diff --git a/.github/workflows/computer-e2e.yml b/.github/workflows/computer-e2e.yml index 784172d699e..3023d414abe 100644 --- a/.github/workflows/computer-e2e.yml +++ b/.github/workflows/computer-e2e.yml @@ -117,7 +117,6 @@ jobs: - run: pnpm verify:computer-native - run: pnpm build:cli - run: pnpm build:electron-vite - - run: pnpm verify:cli-runtime # Why: boot the BUILT daemon-entry under plain Node the way production # forks it. v1.4.129-rc.1 shipped a daemon that exited at module load # (leaked electron require) while every other check passed; this fails @@ -159,7 +158,6 @@ jobs: - run: pnpm verify:computer-native - run: pnpm build:cli - run: pnpm build:electron-vite - - run: pnpm verify:cli-runtime - run: pnpm test:e2e:computer --reporter=verbose tests/e2e/computer-mac.e2e.ts tests/e2e/computer-mac-safari.e2e.ts linux: @@ -187,7 +185,6 @@ jobs: - run: pnpm verify:computer-native - run: pnpm build:cli - run: pnpm build:electron-vite - - run: pnpm verify:cli-runtime - run: xvfb-run --auto-servernum dbus-run-session -- pnpm test:e2e:computer --reporter=verbose tests/e2e/computer-linux.e2e.ts windows: @@ -207,5 +204,4 @@ jobs: - run: pnpm verify:computer-native - run: pnpm build:cli - run: pnpm build:electron-vite - - run: pnpm verify:cli-runtime - run: pnpm test:e2e:computer --reporter=verbose tests/e2e/computer-windows.e2e.ts tests/e2e/computer-windows-store.e2e.ts diff --git a/config/scripts/computer-e2e-workflow.test.mjs b/config/scripts/computer-e2e-workflow.test.mjs index 560859c5264..3ef41583217 100644 --- a/config/scripts/computer-e2e-workflow.test.mjs +++ b/config/scripts/computer-e2e-workflow.test.mjs @@ -177,7 +177,7 @@ describe('computer-use e2e workflow', () => { ) }) - it('builds Electron main output and verifies the CLI before every computer-use e2e run', () => { + it('builds Electron main output before every computer-use e2e run', () => { const workflow = parse( readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8') ) @@ -187,7 +187,6 @@ describe('computer-use e2e workflow', () => { .map((step) => step.run) .filter((run) => typeof run === 'string') const buildIndex = runs.indexOf('pnpm build:electron-vite') - const cliVerifyIndex = runs.indexOf('pnpm verify:cli-runtime') const e2eIndexes = runs .map((run, index) => (run.includes('test:e2e:computer') ? index : -1)) .filter((index) => index >= 0) @@ -196,17 +195,10 @@ describe('computer-use e2e workflow', () => { buildIndex, `${jobName} should build out/main before computer e2e` ).toBeGreaterThanOrEqual(0) - expect( - cliVerifyIndex, - `${jobName} should verify the CLI after the main build` - ).toBeGreaterThan(buildIndex) for (const e2eIndex of e2eIndexes) { expect(buildIndex, `${jobName} should build out/main before computer e2e`).toBeLessThan( e2eIndex ) - expect(cliVerifyIndex, `${jobName} should verify the CLI before computer e2e`).toBeLessThan( - e2eIndex - ) } } }) diff --git a/config/scripts/verify-cli-bin.test.mjs b/config/scripts/verify-cli-bin.test.mjs index d26a603a148..da988064d05 100644 --- a/config/scripts/verify-cli-bin.test.mjs +++ b/config/scripts/verify-cli-bin.test.mjs @@ -47,14 +47,6 @@ describe('verifyPackageCliBin', () => { }) }) - it('rejects a compiled CLI with a missing runtime dependency', () => { - const { projectDir } = makeProjectWithCli( - '#!/usr/bin/env node\nrequire("./missing-runtime-dependency")\n' - ) - - expect(() => verifyPackageCliBin({ projectDir, runHelp: true })).toThrow() - }) - it('rejects an empty package bin target', () => { const { projectDir } = makeProjectWithCli('') diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index 6b31708de90..38086f07969 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -4,9 +4,6 @@ "../src/cli/**/*", "../src/shared/**/*", "../src/main/agent-state-file-reader.ts", - "../src/main/agent-hooks/agent-hook-file-comparison.ts", - "../src/main/agent-hooks/agent-hook-file-limits.ts", - "../src/main/agent-hooks/agent-hook-sftp-text-reader.ts", "../src/main/agent-hooks/hook-stdin-contract.ts", "../src/main/agent-hooks/hook-config-write-path.ts", "../src/main/agent-hooks/hooks-json-read.ts", @@ -23,7 +20,6 @@ "../src/main/codex/codex-app-server-client.ts", "../src/main/codex/codex-app-server-grant-bridge.ts", "../src/main/codex/codex-app-server-grant-envelope.ts", - "../src/main/codex/codex-app-server-grant-json.ts", "../src/main/codex/codex-app-server-session.ts", "../src/main/codex/codex-config-mirror.ts", "../src/main/codex/codex-config-path-reference-rewrite.ts", @@ -31,7 +27,6 @@ "../src/main/codex/codex-config-settings-removal.ts", "../src/main/codex/codex-config-settings-upsert.ts", "../src/main/codex/codex-home-paths.ts", - "../src/main/codex/codex-host-retry-deadlines.ts", "../src/main/codex/codex-hook-identity.ts", "../src/main/codex/codex-hook-trust-grant.ts", "../src/main/codex/codex-managed-trust-reconciliation.ts", @@ -43,7 +38,6 @@ "../src/main/codex/codex-user-hook-trust-rebase-client.ts", "../src/main/codex/codex-user-hook-trust-rebase.ts", "../src/main/codex/codex-wsl-hook-install-plan.ts", - "../src/main/codex/codex-wsl-reconciliation-generations.ts", "../src/main/codex/config-settings-baseline.ts", "../src/main/codex/config-settings-conflict-resolution.ts", "../src/main/codex/config-settings-promotion.ts", @@ -55,14 +49,12 @@ "../src/main/codex-accounts/fs-utils.ts", "../src/main/codex-accounts/wsl-codex-command.ts", "../src/main/codex-cli/command.ts", - "../src/main/codex-cli/nvm-version-directory-discovery.ts", "../src/main/command-code/command-code-managed-script.ts", "../src/main/command-code/hook-service.ts", "../src/main/copilot/hook-service.ts", "../src/main/cursor/hook-service.ts", "../src/main/droid/hook-service.ts", "../src/main/gemini/hook-service.ts", - "../src/main/generated-node-bounded-file-reader.ts", "../src/main/grok/hook-service.ts", "../src/main/devin/hook-settings.ts", "../src/main/devin/hook-service.ts", diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 618545e3f7e..11b2e83083b 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -86,10 +86,6 @@ import { import { MOBILE_AI_VAULT_CAPABILITY } from '../../../../src/agent-history/agent-history-capability' import type { ConnectionState, RpcFailure, RpcSuccess } from '../../../../src/transport/types' import { headlessActivationNeedsHostRenderer } from '../../../../src/worktree/worktree-activation-result' -import { - LAST_VISITED_WORKTREE_STORAGE_KEY, - serializeLastVisitedWorktreeRecord -} from '../../../../src/worktree/last-visited-worktree-repo' import { useMobileDictation } from '../../../../src/hooks/use-mobile-dictation' import { triggerMediumImpact, @@ -231,8 +227,6 @@ import { TERMINAL_GESTURE_INPUT_REFILL_PER_SECOND, updateTerminalCwdFromStreamEvent } from '../../../../src/session/mobile-session-route-helpers' -import { MobileSessionFileDocLifecycle } from '../../../../src/session/mobile-session-file-doc-lifecycle' -import { MobileSessionMarkdownDocLifecycle } from '../../../../src/session/mobile-session-markdown-doc-lifecycle' import { resolveMarkdownFloatingActionsBottom } from '../../../../src/session/markdown-floating-actions-layout' import { resolveTabStripScrollOffset } from '../../../../src/session/tab-strip-scroll' import { activateOpenedSourceControlDiffTab } from '../../../../src/session/opened-mobile-session-tab' @@ -903,9 +897,7 @@ export default function SessionScreen() { const tabLayoutsRef = useRef>(new Map()) const [markdownDocs, setMarkdownDocs] = useState>(new Map()) const markdownDocsRef = useRef>(new Map()) - const markdownDocLifecycleRef = useRef(new MobileSessionMarkdownDocLifecycle()) const [fileDocs, setFileDocs] = useState>(new Map()) - const fileDocLifecycleRef = useRef(new MobileSessionFileDocLifecycle()) const [diffComments, setDiffComments] = useState([]) const diffCommentsRef = useRef([]) const [diffCommentBusy, setDiffCommentBusy] = useState(false) @@ -1729,8 +1721,6 @@ export default function SessionScreen() { if (orphanedDraftTabs.length > 0) { nextTabs = [...orphanedDraftTabs, ...nextTabs] } - markdownDocLifecycleRef.current.reconcile(nextTabs, setMarkdownDocs) - fileDocLifecycleRef.current.reconcile(nextTabs, setFileDocs) sessionTabsRef.current = nextTabs // Why: subscribe snapshots often repeat identical payloads; skip re-set to avoid a subscription teardown/replay loop. setSessionTabs((prev) => (mobileSessionTabsEqual(prev, nextTabs) ? prev : nextTabs)) @@ -1848,7 +1838,8 @@ export default function SessionScreen() { if (!client) { return } - await markdownDocLifecycleRef.current.load(tab, setMarkdownDocs, async () => { + setMarkdownDocs((prev) => new Map(prev).set(tab.id, { status: 'loading' })) + try { const response = await client.sendRequest('markdown.readTab', { worktree: `id:${worktreeId}`, tabId: tab.id @@ -1861,16 +1852,19 @@ export default function SessionScreen() { editable?: boolean readOnlyReason?: string } - return { - status: 'ready', - content: result.content, - localContent: result.content, - baseVersion: result.version, - isDirty: false, - editable: result.editable === true, - stale: result.isDirty, - readOnlyReason: result.readOnlyReason - } + setMarkdownDocs((prev) => + new Map(prev).set(tab.id, { + status: 'ready', + content: result.content, + localContent: result.content, + baseVersion: result.version, + isDirty: false, + editable: result.editable === true, + stale: result.isDirty, + readOnlyReason: result.readOnlyReason + }) + ) + return } if (!shouldReadMarkdownFromDiskAfterReadTabFailure(response as RpcFailure)) { throw new Error((response as RpcFailure).error.message) @@ -1888,12 +1882,24 @@ export default function SessionScreen() { truncated: boolean byteLength: number } - return buildMarkdownDiskFallbackDoc({ - content: fileResult.content, - truncated: fileResult.truncated, - tabIsDirty: tab.isDirty - }) - }) + setMarkdownDocs((prev) => + new Map(prev).set( + tab.id, + buildMarkdownDiskFallbackDoc({ + content: fileResult.content, + truncated: fileResult.truncated, + tabIsDirty: tab.isDirty + }) + ) + ) + } catch { + setMarkdownDocs((prev) => + new Map(prev).set(tab.id, { + status: 'error', + message: "Couldn't load markdown" + }) + ) + } }, [client, worktreeId] ) @@ -1903,13 +1909,31 @@ export default function SessionScreen() { if (!client) { return } - await fileDocLifecycleRef.current.load(tab, setFileDocs, () => - resolveMobileFileTabDoc(client, { + setFileDocs((prev) => new Map(prev).set(tab.id, { status: 'loading' })) + try { + const doc = await resolveMobileFileTabDoc(client, { worktreeId, relativePath: tab.relativePath, diffSource: tab.diffSource }) - ) + setFileDocs((prev) => new Map(prev).set(tab.id, doc)) + } catch (err) { + const message = err instanceof Error ? err.message : '' + const previewMessage = + message === 'binary_file' + ? 'Binary preview unavailable' + : message === 'file_too_large' + ? 'File too large for mobile preview' + : tab.diffSource === 'staged' || tab.diffSource === 'unstaged' + ? "Couldn't load diff preview" + : "Couldn't load file preview" + setFileDocs((prev) => + new Map(prev).set(tab.id, { + status: 'error', + message: previewMessage + }) + ) + } }, [client, worktreeId] ) @@ -2224,9 +2248,6 @@ export default function SessionScreen() { }) } finally { markdownSaveInFlightRef.current.delete(tab.id) - if (markdownSaveSeqRef.current.get(tab.id) === saveSeq) { - markdownSaveSeqRef.current.delete(tab.id) - } } }, [client, markdownDocs, showToast, worktreeId] @@ -2499,10 +2520,10 @@ export default function SessionScreen() { useEffect(() => { if (hostId && worktreeId) { - const serialized = serializeLastVisitedWorktreeRecord({ hostId, worktreeId }) - if (serialized) { - void AsyncStorage.setItem(LAST_VISITED_WORKTREE_STORAGE_KEY, serialized) - } + void AsyncStorage.setItem( + 'orca:last-visited-worktree', + JSON.stringify({ hostId, worktreeId }) + ) } }, [hostId, worktreeId]) @@ -2536,10 +2557,6 @@ export default function SessionScreen() { terminalDiagnosticsRef.current.resetRoute() appliedSnapshotMarkerRef.current = { epoch: null, version: -1 } closedTabTombstonesRef.current.clear() - markdownDocLifecycleRef.current.reset() - fileDocLifecycleRef.current.reset() - markdownSaveSeqRef.current.clear() - markdownSaveInFlightRef.current.clear() for (const queued of terminalGestureInputQueuesRef.current.values()) { if (queued.timer) { clearTimeout(queued.timer) @@ -2559,10 +2576,6 @@ export default function SessionScreen() { return () => { sessionTabActionSheetRequestSeqRef.current += 1 sessionTabActionSheetKeyboardHideSubRef.current?.remove() - markdownDocLifecycleRef.current.reset() - fileDocLifecycleRef.current.reset() - markdownSaveSeqRef.current.clear() - markdownSaveInFlightRef.current.clear() clearPendingLiveInputCommit() clearDelayedActionTimers() } @@ -4096,23 +4109,7 @@ export default function SessionScreen() { initializedHandlesRef.current.delete(terminalHandle) clearTerminalLiveInputDefault(terminalHandle) } - if (tab.type === 'file') { - fileDocLifecycleRef.current.close(tab.id, setFileDocs) - } - if (tab.type === 'markdown') { - markdownDocLifecycleRef.current.close(tab.id, (update) => { - setMarkdownDocs((current) => { - const next = update(current) - markdownDocsRef.current = next - return next - }) - }) - markdownSaveSeqRef.current.delete(tab.id) - markdownSaveInFlightRef.current.delete(tab.id) - } - const remainingTabs = sessionTabsRef.current.filter((candidate) => candidate.id !== tab.id) - sessionTabsRef.current = remainingTabs - setSessionTabs(remainingTabs) + setSessionTabs((prev) => prev.filter((candidate) => candidate.id !== tab.id)) // Why: tombstone the closed tab and rely on the snapshot, not a blind refetch that often re-added the not-yet-closed tab. closedTabTombstonesRef.current.set(tab.id, Date.now() + 10_000) if (activeSessionTabId === tab.id) { diff --git a/mobile/app/h/[hostId]/tasks.tsx b/mobile/app/h/[hostId]/tasks.tsx index b25a40682ca..a9d24ae637a 100644 --- a/mobile/app/h/[hostId]/tasks.tsx +++ b/mobile/app/h/[hostId]/tasks.tsx @@ -137,15 +137,9 @@ import { clearMobileTaskCopyFeedbackTimer, scheduleMobileTaskCopyFeedbackReset } from '../../../src/tasks/mobile-task-copy-feedback-timer' -import { - createMobileItemPrFileContentScope, - createMobileProjectPrFileContentScope, - useMobilePrFileContentCache -} from '../../../src/tasks/use-mobile-pr-file-content-cache' import type { BaseRefSearchResult, GitHubOwnerRepo, - GitHubPRFileContents, PersistedTrustedOrcaHooks, SparsePreset, TuiAgent @@ -364,6 +358,13 @@ type GitHubDetailCheck = { url?: string | null } +type GitHubPRFileContents = { + original: string + modified: string + originalIsBinary: boolean + modifiedIsBinary: boolean +} + type DetailPayload = | { provider: 'github' @@ -2262,6 +2263,8 @@ export default function MobileTasksScreen() { const [itemReviewersDraft, setItemReviewersDraft] = useState('') const [itemReplyDrafts, setItemReplyDrafts] = useState>({}) const [expandedPrFilePath, setExpandedPrFilePath] = useState(null) + const [prFileContents, setPrFileContents] = useState>({}) + const [prFileLoadingPath, setPrFileLoadingPath] = useState(null) const [prFileCommentDrafts, setPrFileCommentDrafts] = useState>({}) const [copiedLinkKey, setCopiedLinkKey] = useState(null) const copiedLinkResetTimerRef = useRef | null>(null) @@ -2599,23 +2602,6 @@ export default function MobileTasksScreen() { () => (projectRowItem ? findProjectRowRepo(projectRowItem) : null), [findProjectRowRepo, projectRowItem] ) - const itemPrFileContentScope = createMobileItemPrFileContentScope(actionItem, detailPayload) - const projectPrFileContentScope = createMobileProjectPrFileContentScope( - projectRowItem, - projectRowHostedRepo, - projectRowDetail, - projectRowItem ? projectRowGitHubRepository(projectRowItem, activeGitHubProjectHost) : null - ) - const activePrFileContentScope = projectRowItem - ? projectPrFileContentScope - : itemPrFileContentScope - const { - clear: clearPrFileContents, - contents: prFileContents, - load: loadPrFileContent, - loadingPath: prFileLoadingPath - } = useMobilePrFileContentCache(activePrFileContentScope) - const itemReviewerCandidates = useMemo(() => { if (!actionItem || actionItem.provider !== 'github' || actionItem.source.type !== 'pr') { return [] @@ -4155,7 +4141,8 @@ export default function MobileTasksScreen() { setItemReviewersDraft('') setItemReplyDrafts({}) setExpandedPrFilePath(null) - clearPrFileContents() + setPrFileContents({}) + setPrFileLoadingPath(null) setPrFileCommentDrafts({}) setExpandedResolvedCommentGroups(new Set()) return @@ -4170,10 +4157,11 @@ export default function MobileTasksScreen() { setItemReviewersDraft('') setItemReplyDrafts({}) setExpandedPrFilePath(null) - clearPrFileContents() + setPrFileContents({}) + setPrFileLoadingPath(null) setPrFileCommentDrafts({}) setExpandedResolvedCommentGroups(new Set()) - }, [actionItem, clearPrFileContents]) + }, [actionItem]) useEffect(() => { if (!detailPayload) { @@ -4470,7 +4458,8 @@ export default function MobileTasksScreen() { setProjectEditingCommentDraft('') setProjectReviewersDraft('') setExpandedPrFilePath(null) - clearPrFileContents() + setPrFileContents({}) + setPrFileLoadingPath(null) setPrFileCommentDrafts({}) setProjectFieldDrafts({}) return @@ -4485,7 +4474,8 @@ export default function MobileTasksScreen() { setProjectEditingCommentDraft('') setProjectReviewersDraft('') setExpandedPrFilePath(null) - clearPrFileContents() + setPrFileContents({}) + setPrFileLoadingPath(null) setPrFileCommentDrafts({}) setProjectFieldDrafts( Object.fromEntries( @@ -4589,7 +4579,6 @@ export default function MobileTasksScreen() { } }, [ activeGitHubProjectHost, - clearPrFileContents, client, githubProjectTable, projectRowDetailRefreshSeq, @@ -6560,6 +6549,9 @@ export default function MobileTasksScreen() { return } setExpandedPrFilePath(file.path) + if (prFileContents[file.path]) { + return + } const repo = findProjectRowRepo(row) if ( !client || @@ -6568,45 +6560,49 @@ export default function MobileTasksScreen() { !row.content.number || projectRowDetail?.provider !== 'github' || !projectRowDetail.headSha || - !projectRowDetail.baseSha || - !projectPrFileContentScope + !projectRowDetail.baseSha ) { setProjectRowDetailError('Unable to load file contents for this pull request.') return } - await loadPrFileContent( - projectPrFileContentScope, - file, - async () => { - const response = await client.sendRequest( - 'github.prFileContents', - { - repo: `id:${repo.id}`, - prNumber: row.content.number, - prRepo: projectRowGitHubRepository(row, activeGitHubProjectHost), - path: file.path, - oldPath: file.oldPath, - status: file.status ?? 'modified', - headSha: projectRowDetail.headSha, - baseSha: projectRowDetail.baseSha - }, - { timeoutMs: 30_000 } - ) - if (!isSuccess(response)) { - throw new Error(response.error.message) - } - return response.result - }, - setProjectRowDetailError - ) + setPrFileLoadingPath(file.path) + setProjectRowDetailError('') + try { + const response = await client.sendRequest( + 'github.prFileContents', + { + repo: `id:${repo.id}`, + prNumber: row.content.number, + prRepo: projectRowGitHubRepository(row, activeGitHubProjectHost), + path: file.path, + oldPath: file.oldPath, + status: file.status ?? 'modified', + headSha: projectRowDetail.headSha, + baseSha: projectRowDetail.baseSha + }, + { timeoutMs: 30_000 } + ) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + setPrFileContents((current) => ({ + ...current, + [file.path]: response.result as GitHubPRFileContents + })) + } catch (err) { + setProjectRowDetailError( + err instanceof Error ? err.message : 'Failed to load file contents' + ) + } finally { + setPrFileLoadingPath(null) + } }, [ activeGitHubProjectHost, client, expandedPrFilePath, findProjectRowRepo, - loadPrFileContent, - projectPrFileContentScope, + prFileContents, projectRowDetail ] ) @@ -7530,43 +7526,49 @@ export default function MobileTasksScreen() { return } setExpandedPrFilePath(file.path) + if (prFileContents[file.path]) { + return + } if ( !client || item.source.type !== 'pr' || detailPayload?.provider !== 'github' || !detailPayload.headSha || - !detailPayload.baseSha || - !itemPrFileContentScope + !detailPayload.baseSha ) { setError('Unable to load file contents for this pull request.') return } - await loadPrFileContent( - itemPrFileContentScope, - file, - async () => { - const response = await client.sendRequest( - 'github.prFileContents', - { - repo: `id:${item.source.repoId}`, - prNumber: item.source.number, - path: file.path, - oldPath: file.oldPath, - status: file.status ?? 'modified', - headSha: detailPayload.headSha, - baseSha: detailPayload.baseSha - }, - { timeoutMs: 30_000 } - ) - if (!isSuccess(response)) { - throw new Error(response.error.message) - } - return response.result - }, - setError - ) + setPrFileLoadingPath(file.path) + setError('') + try { + const response = await client.sendRequest( + 'github.prFileContents', + { + repo: `id:${item.source.repoId}`, + prNumber: item.source.number, + path: file.path, + oldPath: file.oldPath, + status: file.status ?? 'modified', + headSha: detailPayload.headSha, + baseSha: detailPayload.baseSha + }, + { timeoutMs: 30_000 } + ) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + setPrFileContents((current) => ({ + ...current, + [file.path]: response.result as GitHubPRFileContents + })) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load file contents') + } finally { + setPrFileLoadingPath(null) + } }, - [client, detailPayload, expandedPrFilePath, itemPrFileContentScope, loadPrFileContent] + [client, detailPayload, expandedPrFilePath, prFileContents] ) const addGitHubFileReviewComment = useCallback( @@ -12320,7 +12322,7 @@ export default function MobileTasksScreen() { ) : prFileContents[file.path] ? ( @@ -13266,7 +13268,7 @@ export default function MobileTasksScreen() { ) : prFileContents[file.path] ? ( diff --git a/mobile/app/index.tsx b/mobile/app/index.tsx index 6110a5a3c09..b7132849845 100644 --- a/mobile/app/index.tsx +++ b/mobile/app/index.tsx @@ -27,10 +27,6 @@ import AsyncStorage from '@react-native-async-storage/async-storage' import { loadHosts } from '../src/transport/host-store' import { removeHostAndCloseClient } from '../src/transport/host-removal-lifecycle' import { pickResumeWorktree } from '../src/worktree/resume-worktree' -import { - LAST_VISITED_WORKTREE_STORAGE_KEY, - readLastVisitedWorktreeRecord -} from '../src/worktree/last-visited-worktree-repo' import type { RpcClient } from '../src/transport/rpc-client' import { sendSingleFlightRequest } from '../src/transport/request-single-flight' import { @@ -398,14 +394,13 @@ export default function HomeScreen() { router.replace(mobileOnboardingDestination(onboardingSteps)) } }) - void AsyncStorage.getItem(LAST_VISITED_WORKTREE_STORAGE_KEY).then((raw) => { + void AsyncStorage.getItem('orca:last-visited-worktree').then((raw) => { if (stale || !raw) { return } - const record = readLastVisitedWorktreeRecord(raw) - if (record) { - setLastVisited(record) - } + try { + setLastVisited(JSON.parse(raw)) + } catch {} }) for (const entry of allClientsRef.current) { if (entry.client.getState() === 'connected') { diff --git a/mobile/scripts/bounded-process-line-reader.mjs b/mobile/scripts/bounded-process-line-reader.mjs deleted file mode 100644 index a209f03eb0a..00000000000 --- a/mobile/scripts/bounded-process-line-reader.mjs +++ /dev/null @@ -1,103 +0,0 @@ -import { Buffer } from 'node:buffer' - -export const PROCESS_LINE_MAX_BYTES = 64 * 1024 -export const PROCESS_OUTPUT_TAIL_MAX_CODE_UNITS = 64 * 1024 -const TRUNCATED_LINE_SUFFIX = '… [line truncated]' - -export function appendProcessOutputTail( - current, - line, - maxCodeUnits = PROCESS_OUTPUT_TAIL_MAX_CODE_UNITS -) { - if (!Number.isSafeInteger(maxCodeUnits) || maxCodeUnits < 0) { - throw new RangeError('Process output tail limit must be a non-negative safe integer') - } - if (maxCodeUnits === 0) { - return '' - } - const appended = `${current}${line}\n` - return appended.length <= maxCodeUnits ? appended : appended.slice(-maxCodeUnits) -} - -export function attachBoundedProcessLineReader( - stream, - onLine, - maxLineBytes = PROCESS_LINE_MAX_BYTES -) { - if (!Number.isSafeInteger(maxLineBytes) || maxLineBytes < 0) { - throw new RangeError('Process line limit must be a non-negative safe integer') - } - - const retained = Buffer.allocUnsafe(maxLineBytes) - let retainedBytes = 0 - let truncated = false - let swallowLineFeed = false - let closed = false - - const append = (bytes, start, end) => { - if (start >= end) { - return - } - const available = maxLineBytes - retainedBytes - const copied = Math.min(available, end - start) - if (copied > 0) { - bytes.copy(retained, retainedBytes, start, start + copied) - retainedBytes += copied - } - truncated ||= copied < end - start - } - - const emit = () => { - const line = retained.subarray(0, retainedBytes).toString('utf8') - onLine(truncated ? `${line}${TRUNCATED_LINE_SUFFIX}` : line) - retainedBytes = 0 - truncated = false - } - - const onData = (chunk) => { - const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) - let start = 0 - if (swallowLineFeed) { - swallowLineFeed = false - if (bytes[0] === 0x0a) { - start = 1 - } - } - - for (let index = start; index < bytes.length; index += 1) { - const value = bytes[index] - if (value !== 0x0a && value !== 0x0d) { - continue - } - append(bytes, start, index) - emit() - if (value === 0x0d && index + 1 < bytes.length && bytes[index + 1] === 0x0a) { - index += 1 - } else if (value === 0x0d && index + 1 === bytes.length) { - swallowLineFeed = true - } - start = index + 1 - } - append(bytes, start, bytes.length) - } - - const detach = () => { - if (closed) { - return - } - closed = true - stream.off('data', onData) - stream.off('end', onEnd) - } - - const onEnd = () => { - if (retainedBytes > 0 || truncated) { - emit() - } - detach() - } - - stream.on('data', onData) - stream.on('end', onEnd) - return detach -} diff --git a/mobile/scripts/bounded-response-body.mjs b/mobile/scripts/bounded-response-body.mjs deleted file mode 100644 index cef6099d2bb..00000000000 --- a/mobile/scripts/bounded-response-body.mjs +++ /dev/null @@ -1,36 +0,0 @@ -export async function responseBodyIncludesWithinLimit(response, needle, maxBytes) { - const declaredLength = response.headers.get('content-length') - if (declaredLength && /^\d+$/.test(declaredLength) && Number(declaredLength) > maxBytes) { - await response.body?.cancel().catch(() => undefined) - return false - } - if (!response.body) { - return false - } - - const reader = response.body.getReader() - const decoder = new TextDecoder() - let observedBytes = 0 - let suffix = '' - try { - while (true) { - const { done, value } = await reader.read() - if (done) { - return `${suffix}${decoder.decode()}`.includes(needle) - } - observedBytes += value.byteLength - if (observedBytes > maxBytes) { - await reader.cancel().catch(() => undefined) - return false - } - const candidate = suffix + decoder.decode(value, { stream: true }) - if (candidate.includes(needle)) { - await reader.cancel().catch(() => undefined) - return true - } - suffix = candidate.slice(-Math.max(0, needle.length - 1)) - } - } finally { - reader.releaseLock() - } -} diff --git a/mobile/scripts/start-emulator-pairing-runtime.mjs b/mobile/scripts/start-emulator-pairing-runtime.mjs index 3e23c54093b..02078825db4 100644 --- a/mobile/scripts/start-emulator-pairing-runtime.mjs +++ b/mobile/scripts/start-emulator-pairing-runtime.mjs @@ -3,10 +3,7 @@ import { mkdirSync, mkdtempSync } from 'node:fs' import os from 'node:os' import path from 'node:path' import process from 'node:process' -import { - appendProcessOutputTail, - attachBoundedProcessLineReader -} from './bounded-process-line-reader.mjs' +import readline from 'node:readline' function primaryLanIp(lanIpCandidates) { return lanIpCandidates()[0] || '127.0.0.1' @@ -69,15 +66,15 @@ async function waitForPairingRuntime({ child, userData, pairingAddress, logSucce let stderr = '' let resolved = false let exited = false - let closeStdout = () => {} - let closeStderr = () => {} + let rl = null + let rlErr = null const stop = () => { if (!exited) { child.kill('SIGTERM') } - closeStdout() - closeStderr() + rl?.close() + rlErr?.close() child.stdout?.destroy() child.stderr?.destroy() } @@ -122,17 +119,15 @@ async function waitForPairingRuntime({ child, userData, pairingAddress, logSucce reject(error) } - closeStdout = attachBoundedProcessLineReader(child.stdout, (line) => { - if (!resolved) { - output = appendProcessOutputTail(output, line) - } + rl = readline.createInterface({ input: child.stdout }) + rl.on('line', (line) => { + output += line + '\n' handleRuntimeLine(line, finishResolve) }) - closeStderr = attachBoundedProcessLineReader(child.stderr, (line) => { - if (!resolved) { - stderr = appendProcessOutputTail(stderr, line) - } + rlErr = readline.createInterface({ input: child.stderr }) + rlErr.on('line', (line) => { + stderr += line + '\n' }) child.on('error', (error) => { diff --git a/mobile/scripts/start-emulator.mjs b/mobile/scripts/start-emulator.mjs index 8c5e11f944c..d6489b530ee 100755 --- a/mobile/scripts/start-emulator.mjs +++ b/mobile/scripts/start-emulator.mjs @@ -22,21 +22,16 @@ import os from 'node:os' import { promisify } from 'node:util' import path from 'node:path' import process from 'node:process' +import readline from 'node:readline' import { registerWorktreeForPairingRuntime, startHeadlessPairingRuntime } from './start-emulator-pairing-runtime.mjs' -import { - appendProcessOutputTail, - attachBoundedProcessLineReader -} from './bounded-process-line-reader.mjs' -import { responseBodyIncludesWithinLimit } from './bounded-response-body.mjs' import { ensureMobileExpoCli, getMobileExpoExecutablePath } from './mobile-expo-cli.mjs' const execFileAsync = promisify(execFile) const DEFAULT_METRO_PORT = 8081 const METRO_PORT_SEARCH_LIMIT = 100 -const METRO_STATUS_MAX_BYTES = 64 * 1024 // Parse CLI arguments const args = process.argv.slice(2) @@ -118,22 +113,6 @@ function logInfo(message) { log(`[info] ${message}`, 'yellow') } -function createBackpressuredLineWriter(source, target, color) { - let waitingForDrain = false - return (line) => { - const accepted = target.write(color + line + colors.reset + '\n') - if (accepted || waitingForDrain) { - return - } - waitingForDrain = true - source.pause() - target.once('drain', () => { - waitingForDrain = false - source.resume() - }) - } -} - function assertIosSimulatorPlatform() { if (process.platform !== 'darwin') { throw new Error('iOS Simulator automation requires macOS and Xcode.') @@ -403,10 +382,8 @@ async function startMetro(worktree) { let url = null let resolved = false let exited = false - let closeStdout = () => {} - let closeStderr = () => {} - const writeStdoutLine = createBackpressuredLineWriter(metro.stdout, process.stdout, colors.dim) - const writeStderrLine = createBackpressuredLineWriter(metro.stderr, process.stderr, colors.red) + let rl = null + let rlErr = null const metroResult = () => ({ process: metro, @@ -414,8 +391,8 @@ async function startMetro(worktree) { output, isExited: () => exited, closeOutput: () => { - closeStdout() - closeStderr() + rl?.close() + rlErr?.close() metro.stdin?.destroy() metro.stdout?.destroy() metro.stderr?.destroy() @@ -423,11 +400,10 @@ async function startMetro(worktree) { }) // Parse Metro output for the development URL - closeStdout = attachBoundedProcessLineReader(metro.stdout, (line) => { - if (!resolved) { - output = appendProcessOutputTail(output, line) - } - writeStdoutLine(line) + rl = readline.createInterface({ input: metro.stdout }) + rl.on('line', (line) => { + output += line + '\n' + process.stdout.write(colors.dim + line + colors.reset + '\n') // Look for "Waiting on" message from Metro // When Metro says "Waiting on http://localhost:8081", we need to construct the dev-client URL @@ -470,11 +446,10 @@ async function startMetro(worktree) { }) // Also check stderr - closeStderr = attachBoundedProcessLineReader(metro.stderr, (line) => { - if (!resolved) { - output = appendProcessOutputTail(output, line) - } - writeStderrLine(line) + rlErr = readline.createInterface({ input: metro.stderr }) + rlErr.on('line', (line) => { + output += line + '\n' + process.stderr.write(colors.red + line + colors.reset + '\n') }) metro.on('error', (error) => { @@ -572,11 +547,7 @@ async function verifyMetro(url) { try { const response = await fetch(statusUrl, { signal: controller.signal }) - return await responseBodyIncludesWithinLimit( - response, - 'packager-status:running', - METRO_STATUS_MAX_BYTES - ) + return (await response.text()).includes('packager-status:running') } catch { return false } finally { diff --git a/mobile/src/browser/MobileBrowserPane.tsx b/mobile/src/browser/MobileBrowserPane.tsx index bc516a4787a..683b972bfa8 100644 --- a/mobile/src/browser/MobileBrowserPane.tsx +++ b/mobile/src/browser/MobileBrowserPane.tsx @@ -1,4 +1,7 @@ /* oxlint-disable react-doctor/no-adjust-state-on-prop-change -- Why: mobile browser state mirrors a remote desktop screencast session and CDP dialogs, which are external systems that cannot be derived during render. */ +// Why: import from 'buffer' (the npm polyfill), not 'node:buffer' — Metro +// can't resolve Node's builtin in a React Native bundle. +import { Buffer } from 'buffer' import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { ActivityIndicator, @@ -39,10 +42,6 @@ import { getInitialMobileBrowserViewMode, saveMobileBrowserViewMode } from './mobile-browser-view-mode-state' -import { - createMobileBrowserFrameDataUri, - MobileBrowserFrameCache -} from './mobile-browser-frame-cache' import { clampBrowserZoomState, computeBrowserFrameGeometry, @@ -109,7 +108,14 @@ const TOUCH_CLICK_RADIUS_DIP = 14 const MIN_ZOOM = 1 const MAX_ZOOM = 3.5 const DEFAULT_ZOOM: BrowserZoomState = { scale: 1, offsetX: 0, offsetY: 0 } -const browserFrameCache = new MobileBrowserFrameCache() +const BROWSER_FRAME_CACHE_LIMIT = 4 + +type BrowserFrameCacheEntry = { + uri: string + metadata: BrowserScreencastFrameMetadata +} + +const browserFrameCache = new Map() type BrowserPageParams = { worktree: string @@ -137,7 +143,7 @@ export function MobileBrowserPane({ getInitialMobileBrowserViewMode(worktreeId, tab.browserPageId) ) const cacheKey = makeBrowserFrameCacheKey(worktreeId, tab.browserPageId, browserViewMode) - const cachedInitialFrame = browserFrameCache.peek(cacheKey) + const cachedInitialFrame = peekCachedBrowserFrame(cacheKey) const [addressValue, setAddressValue] = useState(displayBrowserUrl(tab.url)) const [addressFocused, setAddressFocused] = useState(false) const [addressSyncState, setAddressSyncState] = useState({ @@ -223,7 +229,7 @@ export function MobileBrowserPane({ const subscription = AppState.addEventListener('change', (nextState) => { const active = nextState === 'active' if (!active) { - browserFrameCache.clearWorktree(worktreeId) + clearCachedBrowserFramesForWorktree(worktreeId) } setAppActive(active) }) @@ -274,14 +280,8 @@ export function MobileBrowserPane({ frameMetadataRef.current = frame.metadata setFrameMetadata(frame.metadata) } - const nextFrameUri = createMobileBrowserFrameDataUri(frame) - if (!nextFrameUri) { - busyRef.current = false - setBusy(false) - setError('Browser frame is too large to display safely.') - return - } - browserFrameCache.set(frameCacheKey, { uri: nextFrameUri, metadata: frame.metadata }) + const nextFrameUri = createBrowserFrameDataUri(frame) + cacheBrowserFrame(frameCacheKey, { uri: nextFrameUri, metadata: frame.metadata }) if (!frameMountedRef.current) { frameUriRef.current = nextFrameUri frameMountedRef.current = true @@ -389,7 +389,7 @@ export function MobileBrowserPane({ const sameStream = Boolean(cacheKey) && lastStreamCacheKeyRef.current === cacheKey lastStreamCacheKeyRef.current = cacheKey if (!sameStream || !frameUriRef.current) { - const cachedFrame = browserFrameCache.get(cacheKey) + const cachedFrame = getCachedBrowserFrame(cacheKey) if (cachedFrame) { frameUriRef.current = cachedFrame.uri frameMountedRef.current = true @@ -1303,6 +1303,10 @@ function buttonColor(enabled: boolean): string { return enabled ? colors.textSecondary : colors.textMuted } +function createBrowserFrameDataUri(frame: BrowserScreencastFrame): string { + return `data:image/${frame.format};base64,${Buffer.from(frame.image).toString('base64')}` +} + function makeBrowserFrameCacheKey( worktreeId: string, browserPageId: string | null, @@ -1311,6 +1315,47 @@ function makeBrowserFrameCacheKey( return browserPageId ? `${worktreeId}:${browserPageId}:${viewMode}` : null } +function clearCachedBrowserFramesForWorktree(worktreeId: string): void { + const prefix = `${worktreeId}:` + for (const key of browserFrameCache.keys()) { + if (key.startsWith(prefix)) { + browserFrameCache.delete(key) + } + } +} + +function getCachedBrowserFrame(cacheKey: string | null): BrowserFrameCacheEntry | null { + if (!cacheKey) { + return null + } + const cached = browserFrameCache.get(cacheKey) + if (!cached) { + return null + } + browserFrameCache.delete(cacheKey) + browserFrameCache.set(cacheKey, cached) + return cached +} + +function peekCachedBrowserFrame(cacheKey: string | null): BrowserFrameCacheEntry | null { + return cacheKey ? (browserFrameCache.get(cacheKey) ?? null) : null +} + +function cacheBrowserFrame(cacheKey: string | null, entry: BrowserFrameCacheEntry): void { + if (!cacheKey) { + return + } + browserFrameCache.delete(cacheKey) + browserFrameCache.set(cacheKey, entry) + while (browserFrameCache.size > BROWSER_FRAME_CACHE_LIMIT) { + const oldestKey = browserFrameCache.keys().next().value + if (typeof oldestKey !== 'string') { + break + } + browserFrameCache.delete(oldestKey) + } +} + function updateBrowserLayerVisibility( layers: [View | null, View | null], visible: FrameLayer diff --git a/mobile/src/browser/mobile-browser-frame-cache.test.ts b/mobile/src/browser/mobile-browser-frame-cache.test.ts deleted file mode 100644 index 79280dd1055..00000000000 --- a/mobile/src/browser/mobile-browser-frame-cache.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - BrowserScreencastOpcode, - type BrowserScreencastFrame -} from '../transport/browser-screencast-protocol' -import { - createMobileBrowserFrameDataUri, - MOBILE_BROWSER_FRAME_MAX_IMAGE_BYTES, - MobileBrowserFrameCache -} from './mobile-browser-frame-cache' - -function entry(uri: string) { - return { uri, metadata: {} } -} - -function frame(imageBytes: number): BrowserScreencastFrame { - return { - opcode: BrowserScreencastOpcode.Frame, - seq: 1, - format: 'jpeg', - metadata: {}, - image: new Uint8Array(imageBytes) - } -} - -describe('MobileBrowserFrameCache', () => { - it('preserves LRU behavior below the count and retained-character limits', () => { - const cache = new MobileBrowserFrameCache(2, 100) - cache.set('first', entry('frame-1')) - cache.set('second', entry('frame-2')) - - expect(cache.get('first')).toEqual(entry('frame-1')) - cache.set('third', entry('frame-3')) - - expect(cache.peek('second')).toBeNull() - expect(cache.evidence().keysOldestFirst).toEqual(['first', 'third']) - }) - - it('accepts the exact aggregate budget and evicts oldest at one character over', () => { - const cache = new MobileBrowserFrameCache(4, 20) - expect(cache.set('a', entry('x'.repeat(9)))).toBe(true) - expect(cache.set('b', entry('x'.repeat(9)))).toBe(true) - expect(cache.evidence()).toMatchObject({ entryCount: 2, retainedCharacters: 20 }) - - expect(cache.set('c', entry(''))).toBe(true) - expect(cache.peek('a')).toBeNull() - expect(cache.evidence()).toMatchObject({ entryCount: 2, retainedCharacters: 11 }) - }) - - it('does not retain one entry larger than the full budget', () => { - const cache = new MobileBrowserFrameCache(4, 10) - - expect(cache.set('key', entry('x'.repeat(8)))).toBe(false) - expect(cache.evidence()).toEqual({ - entryCount: 0, - retainedCharacters: 0, - keysOldestFirst: [] - }) - }) -}) - -describe('createMobileBrowserFrameDataUri', () => { - it('accepts the exact image-byte limit and rejects one byte over before base64 expansion', () => { - expect(MOBILE_BROWSER_FRAME_MAX_IMAGE_BYTES).toBe(8 * 1024 * 1024) - expect(createMobileBrowserFrameDataUri(frame(4), 4)).toBe('data:image/jpeg;base64,AAAAAA==') - expect(createMobileBrowserFrameDataUri(frame(5), 4)).toBeNull() - }) -}) diff --git a/mobile/src/browser/mobile-browser-frame-cache.ts b/mobile/src/browser/mobile-browser-frame-cache.ts deleted file mode 100644 index 0d856c546b3..00000000000 --- a/mobile/src/browser/mobile-browser-frame-cache.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { Buffer } from 'buffer' -import type { - BrowserScreencastFrame, - BrowserScreencastFrameMetadata -} from '../transport/browser-screencast-protocol' - -export const MOBILE_BROWSER_FRAME_MAX_IMAGE_BYTES = 8 * 1024 * 1024 -export const MOBILE_BROWSER_FRAME_CACHE_MAX_ENTRIES = 4 -export const MOBILE_BROWSER_FRAME_CACHE_MAX_RETAINED_CHARACTERS = 16 * 1024 * 1024 - -export type MobileBrowserFrameCacheEntry = { - uri: string - metadata: BrowserScreencastFrameMetadata -} - -export type MobileBrowserFrameCacheEvidence = { - entryCount: number - retainedCharacters: number - keysOldestFirst: string[] -} - -type RetainedFrame = { - entry: MobileBrowserFrameCacheEntry - retainedCharacters: number -} - -export class MobileBrowserFrameCache { - private readonly entries = new Map() - private retainedCharacters = 0 - - constructor( - private readonly maxEntries = MOBILE_BROWSER_FRAME_CACHE_MAX_ENTRIES, - private readonly maxRetainedCharacters = MOBILE_BROWSER_FRAME_CACHE_MAX_RETAINED_CHARACTERS - ) { - if ( - !Number.isInteger(maxEntries) || - maxEntries < 1 || - !Number.isSafeInteger(maxRetainedCharacters) || - maxRetainedCharacters < 1 - ) { - throw new Error('Mobile browser frame cache limits must be positive integers') - } - } - - get(key: string | null): MobileBrowserFrameCacheEntry | null { - if (!key) { - return null - } - const retained = this.entries.get(key) - if (!retained) { - return null - } - this.entries.delete(key) - this.entries.set(key, retained) - return retained.entry - } - - peek(key: string | null): MobileBrowserFrameCacheEntry | null { - return key ? (this.entries.get(key)?.entry ?? null) : null - } - - set(key: string | null, entry: MobileBrowserFrameCacheEntry): boolean { - if (!key) { - return false - } - const retainedCharacters = key.length + entry.uri.length - const previous = this.entries.get(key) - if (previous) { - this.retainedCharacters -= previous.retainedCharacters - this.entries.delete(key) - } - if (retainedCharacters > this.maxRetainedCharacters) { - return false - } - this.entries.set(key, { entry, retainedCharacters }) - this.retainedCharacters += retainedCharacters - this.evictOverflow() - return this.entries.has(key) - } - - clearWorktree(worktreeId: string): void { - const prefix = `${worktreeId}:` - for (const key of this.entries.keys()) { - if (key.startsWith(prefix)) { - this.delete(key) - } - } - } - - clear(): void { - this.entries.clear() - this.retainedCharacters = 0 - } - - evidence(): MobileBrowserFrameCacheEvidence { - return { - entryCount: this.entries.size, - retainedCharacters: this.retainedCharacters, - keysOldestFirst: [...this.entries.keys()] - } - } - - private delete(key: string): void { - const retained = this.entries.get(key) - if (!retained) { - return - } - this.retainedCharacters -= retained.retainedCharacters - this.entries.delete(key) - } - - private evictOverflow(): void { - while ( - this.entries.size > this.maxEntries || - this.retainedCharacters > this.maxRetainedCharacters - ) { - const oldestKey = this.entries.keys().next().value - if (typeof oldestKey !== 'string') { - return - } - this.delete(oldestKey) - } - } -} - -export function createMobileBrowserFrameDataUri( - frame: BrowserScreencastFrame, - maxImageBytes = MOBILE_BROWSER_FRAME_MAX_IMAGE_BYTES -): string | null { - if (frame.image.byteLength > maxImageBytes) { - return null - } - return `data:image/${frame.format};base64,${Buffer.from(frame.image).toString('base64')}` -} diff --git a/mobile/src/browser/mobile-browser-view-mode-state.test.ts b/mobile/src/browser/mobile-browser-view-mode-state.test.ts index 448412fff5e..20ba4844de5 100644 --- a/mobile/src/browser/mobile-browser-view-mode-state.test.ts +++ b/mobile/src/browser/mobile-browser-view-mode-state.test.ts @@ -1,7 +1,5 @@ import { beforeEach, describe, expect, it } from 'vitest' import { - BROWSER_VIEW_MODE_PAGE_KEY_MAX_CHARACTERS, - BROWSER_VIEW_MODE_STATE_LIMIT, clearMobileBrowserViewModeState, getInitialMobileBrowserViewMode, saveMobileBrowserViewMode @@ -24,28 +22,4 @@ describe('mobile browser view mode state', () => { expect(getInitialMobileBrowserViewMode('worktree-1', 'page-2')).toBe('web') expect(getInitialMobileBrowserViewMode('worktree-2', 'page-1')).toBe('web') }) - - it('retains the exact LRU count and evicts the oldest page at one over', () => { - for (let index = 0; index < BROWSER_VIEW_MODE_STATE_LIMIT; index += 1) { - saveMobileBrowserViewMode('worktree', `page-${index}`, 'mobile') - } - expect(getInitialMobileBrowserViewMode('worktree', 'page-0')).toBe('mobile') - - saveMobileBrowserViewMode('worktree', 'one-over', 'mobile') - - expect(getInitialMobileBrowserViewMode('worktree', 'page-0')).toBe('web') - expect(getInitialMobileBrowserViewMode('worktree', 'one-over')).toBe('mobile') - }) - - it('accepts the exact page-key character limit and rejects one over', () => { - const worktreeId = 'w' - const exactPageId = 'p'.repeat(BROWSER_VIEW_MODE_PAGE_KEY_MAX_CHARACTERS - 2) - const oversizedPageId = `${exactPageId}p` - - saveMobileBrowserViewMode(worktreeId, exactPageId, 'mobile') - saveMobileBrowserViewMode(worktreeId, oversizedPageId, 'mobile') - - expect(getInitialMobileBrowserViewMode(worktreeId, exactPageId)).toBe('mobile') - expect(getInitialMobileBrowserViewMode(worktreeId, oversizedPageId)).toBe('web') - }) }) diff --git a/mobile/src/browser/mobile-browser-view-mode-state.ts b/mobile/src/browser/mobile-browser-view-mode-state.ts index da9a906f5e2..35923d61a81 100644 --- a/mobile/src/browser/mobile-browser-view-mode-state.ts +++ b/mobile/src/browser/mobile-browser-view-mode-state.ts @@ -1,7 +1,6 @@ import type { MobileBrowserViewMode } from './browser-screencast-request' -export const BROWSER_VIEW_MODE_STATE_LIMIT = 40 -export const BROWSER_VIEW_MODE_PAGE_KEY_MAX_CHARACTERS = 4_096 +const BROWSER_VIEW_MODE_STATE_LIMIT = 40 const browserViewModeByPageKey = new Map() export function getInitialMobileBrowserViewMode( @@ -43,9 +42,5 @@ function makeBrowserViewModePageKey( worktreeId: string, browserPageId: string | null ): string | null { - if (!browserPageId) { - return null - } - const key = `${worktreeId}:${browserPageId}` - return key.length <= BROWSER_VIEW_MODE_PAGE_KEY_MAX_CHARACTERS ? key : null + return browserPageId ? `${worktreeId}:${browserPageId}` : null } diff --git a/mobile/src/cache/home-snapshot-cache.test.ts b/mobile/src/cache/home-snapshot-cache.test.ts deleted file mode 100644 index 4c2485b741b..00000000000 --- a/mobile/src/cache/home-snapshot-cache.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import AsyncStorage from '@react-native-async-storage/async-storage' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { - HOME_SNAPSHOT_MAX_SERIALIZED_BYTES, - loadHomeSnapshot, - resetHomeSnapshotCacheForTests, - saveHomeSnapshot, - type HomeSnapshot -} from './home-snapshot-cache' - -vi.mock('@react-native-async-storage/async-storage', () => ({ - default: { - getItem: vi.fn(), - setItem: vi.fn() - } -})) - -function snapshotWithSerializedBytes(serializedBytes: number): HomeSnapshot { - const base = { - worktreeInfo: {}, - accountsByHost: {}, - savedAt: 1, - padding: '' - } - const baseBytes = JSON.stringify(base).length - return { - ...base, - padding: 'x'.repeat(serializedBytes - baseBytes) - } as unknown as HomeSnapshot -} - -describe('home snapshot cache', () => { - beforeEach(() => { - vi.useFakeTimers() - resetHomeSnapshotCacheForTests() - vi.mocked(AsyncStorage.getItem).mockReset() - vi.mocked(AsyncStorage.setItem).mockReset().mockResolvedValue(undefined) - }) - - afterEach(() => { - resetHomeSnapshotCacheForTests() - vi.useRealTimers() - }) - - it('persists and reloads normal snapshots unchanged', async () => { - const snapshot = snapshotWithSerializedBytes(128) - saveHomeSnapshot(snapshot) - await vi.advanceTimersByTimeAsync(250) - - const raw = vi.mocked(AsyncStorage.setItem).mock.calls[0]?.[1] - expect(raw).toBe(JSON.stringify(snapshot)) - - resetHomeSnapshotCacheForTests() - vi.mocked(AsyncStorage.getItem).mockResolvedValue(raw ?? null) - await expect(loadHomeSnapshot()).resolves.toEqual(snapshot) - }) - - it('accepts the exact serialized-byte cap and rejects one byte over', async () => { - const exact = snapshotWithSerializedBytes(HOME_SNAPSHOT_MAX_SERIALIZED_BYTES) - saveHomeSnapshot(exact) - await vi.advanceTimersByTimeAsync(250) - expect(AsyncStorage.setItem).toHaveBeenCalledOnce() - expect(vi.mocked(AsyncStorage.setItem).mock.calls[0]?.[1]).toHaveLength( - HOME_SNAPSHOT_MAX_SERIALIZED_BYTES - ) - - vi.mocked(AsyncStorage.setItem).mockClear() - saveHomeSnapshot(snapshotWithSerializedBytes(HOME_SNAPSHOT_MAX_SERIALIZED_BYTES + 1)) - await vi.advanceTimersByTimeAsync(250) - expect(AsyncStorage.setItem).not.toHaveBeenCalled() - }) - - it('rejects an oversized durable payload before JSON parsing', async () => { - vi.mocked(AsyncStorage.getItem).mockResolvedValue( - `"${'x'.repeat(HOME_SNAPSHOT_MAX_SERIALIZED_BYTES)}"` - ) - - await expect(loadHomeSnapshot()).resolves.toBeNull() - }) -}) diff --git a/mobile/src/cache/home-snapshot-cache.ts b/mobile/src/cache/home-snapshot-cache.ts index 4d906887c04..a591bb01d70 100644 --- a/mobile/src/cache/home-snapshot-cache.ts +++ b/mobile/src/cache/home-snapshot-cache.ts @@ -5,10 +5,8 @@ // WebSocket reconnects and the first responses come back. import AsyncStorage from '@react-native-async-storage/async-storage' import type { AccountsSnapshot } from '../components/AccountUsage' -import { stringifyMobileOutboundJson } from '../transport/mobile-outbound-json' const STORAGE_KEY = 'orca:home-snapshot:v1' -export const HOME_SNAPSHOT_MAX_SERIALIZED_BYTES = 2 * 1024 * 1024 type WorktreeSummary = { worktreeId: string @@ -44,12 +42,6 @@ export async function loadHomeSnapshot(): Promise { if (!raw) { return null } - if ( - raw.length > HOME_SNAPSHOT_MAX_SERIALIZED_BYTES || - utf8ByteLengthExceeds(raw, HOME_SNAPSHOT_MAX_SERIALIZED_BYTES) - ) { - return null - } const parsed = JSON.parse(raw) as HomeSnapshot if ( typeof parsed !== 'object' || @@ -69,53 +61,12 @@ export async function loadHomeSnapshot(): Promise { // Why: throttle writes so a flurry of streamed account-snapshot updates // (one per provider fetch finishing) doesn't hammer AsyncStorage. export function saveHomeSnapshot(snapshot: HomeSnapshot): void { - let serialized: string - try { - serialized = stringifyMobileOutboundJson(snapshot, HOME_SNAPSHOT_MAX_SERIALIZED_BYTES) - } catch { - return - } memoryCache = snapshot if (writeTimer) { clearTimeout(writeTimer) } writeTimer = setTimeout(() => { writeTimer = null - void AsyncStorage.setItem(STORAGE_KEY, serialized).catch(() => {}) + void AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot)).catch(() => {}) }, 250) } - -function utf8ByteLengthExceeds(value: string, limit: number): boolean { - let bytes = 0 - for (let index = 0; index < value.length; index += 1) { - const code = value.charCodeAt(index) - if (code <= 0x7f) { - bytes += 1 - } else if (code <= 0x7ff) { - bytes += 2 - } else if (code >= 0xd800 && code <= 0xdbff) { - const next = value.charCodeAt(index + 1) - if (next >= 0xdc00 && next <= 0xdfff) { - bytes += 4 - index += 1 - } else { - bytes += 3 - } - } else { - bytes += 3 - } - if (bytes > limit) { - return true - } - } - return false -} - -/** Test-only: clear the process-lifetime snapshot and delayed write. */ -export function resetHomeSnapshotCacheForTests(): void { - memoryCache = null - if (writeTimer) { - clearTimeout(writeTimer) - writeTimer = null - } -} diff --git a/mobile/src/cache/mobile-rpc-list-cache.test.ts b/mobile/src/cache/mobile-rpc-list-cache.test.ts deleted file mode 100644 index 50eeccf027e..00000000000 --- a/mobile/src/cache/mobile-rpc-list-cache.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { MobileRpcListCache } from './mobile-rpc-list-cache' - -describe('MobileRpcListCache', () => { - it('preserves values below the limits and expires them at the existing age boundary', () => { - const cache = new MobileRpcListCache(100, 2, 3, 100) - const values = [{ id: 'repo' }] - - expect(cache.set('host', values, 1_000)).toBe(true) - expect(cache.get('host', 1_100)).toBe(values) - expect(cache.get('host', 1_101)).toBeNull() - }) - - it('accepts the exact item cap and rejects one over', () => { - const cache = new MobileRpcListCache(100, 2, 3, 1_000) - const exact = [1, 2, 3] - - expect(cache.set('host', exact)).toBe(true) - expect(cache.get('host')).toBe(exact) - expect(cache.set('host', [...exact, 4])).toBe(false) - expect(cache.get('host')).toBeNull() - }) - - it('accepts the exact aggregate byte budget and evicts oldest at one over', () => { - const probe = new MobileRpcListCache(100, 3, 3, 1_000) - probe.set('first', ['a']) - const firstBytes = probe.evidence().retainedBytes - probe.clear() - - const cache = new MobileRpcListCache(100, 3, 3, firstBytes * 2) - expect(cache.set('first', ['a'])).toBe(true) - expect(cache.set('other', ['a'])).toBe(true) - expect(cache.evidence().retainedBytes).toBe(firstBytes * 2) - - expect(cache.set('x', [])).toBe(true) - expect(cache.get('first')).toBeNull() - expect(cache.evidence().retainedBytes).toBeLessThanOrEqual(firstBytes * 2) - }) - - it('rejects a single payload larger than the full byte budget', () => { - const cache = new MobileRpcListCache(100, 2, 3, 20) - - expect(cache.set('host', ['a'.repeat(100)])).toBe(false) - expect(cache.evidence()).toEqual({ - entryCount: 0, - retainedBytes: 0, - keysOldestFirst: [] - }) - }) -}) diff --git a/mobile/src/cache/mobile-rpc-list-cache.ts b/mobile/src/cache/mobile-rpc-list-cache.ts deleted file mode 100644 index 33619a0fed6..00000000000 --- a/mobile/src/cache/mobile-rpc-list-cache.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { stringifyMobileOutboundJson } from '../transport/mobile-outbound-json' - -export type MobileRpcListCacheEvidence = { - entryCount: number - retainedBytes: number - keysOldestFirst: string[] -} - -type CacheEntry = { - values: unknown[] - at: number - retainedBytes: number -} - -export class MobileRpcListCache { - private readonly entries = new Map() - private retainedBytes = 0 - - constructor( - private readonly maxAgeMs: number, - private readonly maxEntries: number, - private readonly maxItemsPerEntry: number, - private readonly maxRetainedBytes: number - ) { - if ( - !Number.isFinite(maxAgeMs) || - maxAgeMs < 0 || - !Number.isInteger(maxEntries) || - maxEntries < 1 || - !Number.isInteger(maxItemsPerEntry) || - maxItemsPerEntry < 1 || - !Number.isSafeInteger(maxRetainedBytes) || - maxRetainedBytes < 1 - ) { - throw new Error('Mobile RPC list cache limits must be positive') - } - } - - set(key: string, values: unknown[], now = Date.now()): boolean { - this.delete(key) - if (values.length > this.maxItemsPerEntry) { - return false - } - let serialized: string - try { - serialized = stringifyMobileOutboundJson({ key, values }, this.maxRetainedBytes) - } catch { - return false - } - const retainedBytes = utf8ByteLength(serialized) - if (retainedBytes > this.maxRetainedBytes) { - return false - } - this.entries.set(key, { values, at: now, retainedBytes }) - this.retainedBytes += retainedBytes - this.evictOverflow() - return this.entries.has(key) - } - - get(key: string, now = Date.now()): unknown[] | null { - const entry = this.entries.get(key) - if (!entry) { - return null - } - if (now - entry.at > this.maxAgeMs) { - this.delete(key) - return null - } - return entry.values - } - - clear(): void { - this.entries.clear() - this.retainedBytes = 0 - } - - evidence(): MobileRpcListCacheEvidence { - return { - entryCount: this.entries.size, - retainedBytes: this.retainedBytes, - keysOldestFirst: [...this.entries.keys()] - } - } - - private delete(key: string): void { - const entry = this.entries.get(key) - if (!entry) { - return - } - this.retainedBytes -= entry.retainedBytes - this.entries.delete(key) - } - - private evictOverflow(): void { - while (this.entries.size > this.maxEntries || this.retainedBytes > this.maxRetainedBytes) { - const oldestKey = this.entries.keys().next().value - if (typeof oldestKey !== 'string') { - return - } - this.delete(oldestKey) - } - } -} - -function utf8ByteLength(value: string): number { - let bytes = 0 - for (let index = 0; index < value.length; index += 1) { - const code = value.charCodeAt(index) - if (code <= 0x7f) { - bytes += 1 - } else if (code <= 0x7ff) { - bytes += 2 - } else if (code >= 0xd800 && code <= 0xdbff) { - const next = value.charCodeAt(index + 1) - if (next >= 0xdc00 && next <= 0xdfff) { - bytes += 4 - index += 1 - } else { - bytes += 3 - } - } else { - bytes += 3 - } - } - return bytes -} diff --git a/mobile/src/cache/repo-cache.test.ts b/mobile/src/cache/repo-cache.test.ts index adef8d02fcc..156fa507619 100644 --- a/mobile/src/cache/repo-cache.test.ts +++ b/mobile/src/cache/repo-cache.test.ts @@ -1,17 +1,8 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { describe, expect, it, vi } from 'vitest' -import { - getCachedRepos, - MOBILE_REPO_CACHE_MAX_ITEMS_PER_HOST, - resetRepoCacheForTests, - setCachedRepos -} from './repo-cache' +import { getCachedRepos, setCachedRepos } from './repo-cache' describe('repo cache', () => { - beforeEach(() => { - resetRepoCacheForTests() - }) - it('returns recent host-scoped repos', () => { const repos = [{ id: 'repo-1' }] @@ -32,13 +23,4 @@ describe('repo cache', () => { vi.useRealTimers() } }) - - it('retains the exact per-host item cap and rejects one over', () => { - const exact = Array.from({ length: MOBILE_REPO_CACHE_MAX_ITEMS_PER_HOST }, () => null) - setCachedRepos('host', exact) - expect(getCachedRepos('host')).toBe(exact) - - setCachedRepos('host', [...exact, null]) - expect(getCachedRepos('host')).toBeNull() - }) }) diff --git a/mobile/src/cache/repo-cache.ts b/mobile/src/cache/repo-cache.ts index e5e407aee06..ecb485c4d67 100644 --- a/mobile/src/cache/repo-cache.ts +++ b/mobile/src/cache/repo-cache.ts @@ -2,29 +2,35 @@ // host-scoped cache lets workspace creation open from the last known list while // a fresh repo.list refresh happens in the background. -import { MobileRpcListCache } from './mobile-rpc-list-cache' +type CachedRepos = { + repos: unknown[] + at: number +} + +const cache = new Map() const MAX_AGE_MS = 60_000 -export const MOBILE_REPO_CACHE_MAX_ENTRIES = 20 -export const MOBILE_REPO_CACHE_MAX_ITEMS_PER_HOST = 10_000 -export const MOBILE_REPO_CACHE_MAX_RETAINED_BYTES = 16 * 1024 * 1024 - -const cache = new MobileRpcListCache( - MAX_AGE_MS, - MOBILE_REPO_CACHE_MAX_ENTRIES, - MOBILE_REPO_CACHE_MAX_ITEMS_PER_HOST, - MOBILE_REPO_CACHE_MAX_RETAINED_BYTES -) +const MAX_ENTRIES = 20 export function setCachedRepos(hostId: string, repos: unknown[]): void { - cache.set(hostId, repos) + cache.delete(hostId) + cache.set(hostId, { repos, at: Date.now() }) + if (cache.size > MAX_ENTRIES) { + const oldest = cache.keys().next().value + if (oldest) { + cache.delete(oldest) + } + } } export function getCachedRepos(hostId: string): unknown[] | null { - return cache.get(hostId) -} - -/** Test-only: clear process-lifetime cache state between cases. */ -export function resetRepoCacheForTests(): void { - cache.clear() + const entry = cache.get(hostId) + if (!entry) { + return null + } + if (Date.now() - entry.at > MAX_AGE_MS) { + cache.delete(hostId) + return null + } + return entry.repos } diff --git a/mobile/src/cache/worktree-cache.test.ts b/mobile/src/cache/worktree-cache.test.ts index 5412627516c..f167c6c547d 100644 --- a/mobile/src/cache/worktree-cache.test.ts +++ b/mobile/src/cache/worktree-cache.test.ts @@ -1,19 +1,10 @@ -import { beforeEach, describe, expect, it } from 'vitest' -import { - getCachedWorktrees, - MOBILE_WORKTREE_CACHE_MAX_ITEMS_PER_HOST, - resetWorktreeCacheForTests, - setCachedWorktrees -} from './worktree-cache' +import { describe, expect, it } from 'vitest' +import { setCachedWorktrees, getCachedWorktrees } from './worktree-cache' // Why: AC #8498 guarantees a reconnect refetch writes through the // same cache path the host detail screen seeds from, so a reconnect can't // serve a stale snapshot. This unit pins the write-through contract. describe('worktree-cache write-through', () => { - beforeEach(() => { - resetWorktreeCacheForTests() - }) - it('returns the most-recently written snapshot, not a stale one', () => { const hostId = 'host-write-through' const stale = [{ worktreeId: 'a', name: 'stale' }] @@ -49,13 +40,4 @@ describe('worktree-cache write-through', () => { // A fresh screen mount reads the cache — must see the connected set. expect(getCachedWorktrees(hostId)).toEqual(reconnected) }) - - it('retains the exact per-host item cap and rejects one over', () => { - const exact = Array.from({ length: MOBILE_WORKTREE_CACHE_MAX_ITEMS_PER_HOST }, () => null) - setCachedWorktrees('host', exact) - expect(getCachedWorktrees('host')).toBe(exact) - - setCachedWorktrees('host', [...exact, null]) - expect(getCachedWorktrees('host')).toBeNull() - }) }) diff --git a/mobile/src/cache/worktree-cache.ts b/mobile/src/cache/worktree-cache.ts index b95d97f8549..0b05f78b260 100644 --- a/mobile/src/cache/worktree-cache.ts +++ b/mobile/src/cache/worktree-cache.ts @@ -2,29 +2,38 @@ // so the host detail page can render instantly on navigation instead of // waiting for a fresh RPC connection + fetch cycle. -import { MobileRpcListCache } from './mobile-rpc-list-cache' +type CachedWorktrees = { + worktrees: unknown[] + at: number +} + +const cache = new Map() const MAX_AGE_MS = 30_000 -export const MOBILE_WORKTREE_CACHE_MAX_ENTRIES = 20 -export const MOBILE_WORKTREE_CACHE_MAX_ITEMS_PER_HOST = 10_000 -export const MOBILE_WORKTREE_CACHE_MAX_RETAINED_BYTES = 16 * 1024 * 1024 - -const cache = new MobileRpcListCache( - MAX_AGE_MS, - MOBILE_WORKTREE_CACHE_MAX_ENTRIES, - MOBILE_WORKTREE_CACHE_MAX_ITEMS_PER_HOST, - MOBILE_WORKTREE_CACHE_MAX_RETAINED_BYTES -) +const MAX_ENTRIES = 20 export function setCachedWorktrees(hostId: string, worktrees: unknown[]): void { - cache.set(hostId, worktrees) + // Why: Map.set on an existing key does not move it to the end of iteration + // order. Delete first so the re-inserted key becomes the newest entry, + // giving us true LRU eviction when the cap is hit. + cache.delete(hostId) + cache.set(hostId, { worktrees, at: Date.now() }) + if (cache.size > MAX_ENTRIES) { + const oldest = cache.keys().next().value + if (oldest) { + cache.delete(oldest) + } + } } export function getCachedWorktrees(hostId: string): unknown[] | null { - return cache.get(hostId) -} - -/** Test-only: clear process-lifetime cache state between cases. */ -export function resetWorktreeCacheForTests(): void { - cache.clear() + const entry = cache.get(hostId) + if (!entry) { + return null + } + if (Date.now() - entry.at > MAX_AGE_MS) { + cache.delete(hostId) + return null + } + return entry.worktrees } diff --git a/mobile/src/components/CustomKeyModal.tsx b/mobile/src/components/CustomKeyModal.tsx index 6f81da63880..2777e310dc8 100644 --- a/mobile/src/components/CustomKeyModal.tsx +++ b/mobile/src/components/CustomKeyModal.tsx @@ -1,6 +1,7 @@ import { useCallback, useMemo, useState } from 'react' import { View, Text, Pressable, TextInput, StyleSheet, Switch } from 'react-native' import { ChevronLeft } from 'lucide-react-native' +import AsyncStorage from '@react-native-async-storage/async-storage' import { colors, spacing, radii, typography } from '../theme/mobile-theme' import { BottomDrawer } from './BottomDrawer' import { @@ -10,19 +11,15 @@ import { type TerminalShortcutModifier, type TerminalShortcutSpecialKey } from '../terminal/terminal-accessory-keys' -import { - CUSTOM_ACCESSORY_KEY_MAX_BYTES_CHARACTERS, - CUSTOM_ACCESSORY_KEY_MAX_LABEL_CHARACTERS, - loadCustomKeys, - saveCustomKeys, - type CustomKey -} from '../terminal/custom-accessory-key-store' -export { - loadCustomKeys, - saveCustomKeys, - type CustomKey -} from '../terminal/custom-accessory-key-store' +const CUSTOM_ACCESSORY_KEYS_STORAGE_KEY = 'orca:custom-accessory-keys' + +export type CustomKey = { + id: string + label: string + bytes: string + enter: boolean +} type Step = 'choose-type' | 'shortcut-combo' | 'special-keys' | 'text-macro' @@ -67,6 +64,19 @@ type Props = { onManageShortcuts?: () => void } +export async function loadCustomKeys(): Promise { + try { + const raw = await AsyncStorage.getItem(CUSTOM_ACCESSORY_KEYS_STORAGE_KEY) + return raw ? (JSON.parse(raw) as CustomKey[]) : [] + } catch { + return [] + } +} + +export async function saveCustomKeys(keys: CustomKey[]): Promise { + await AsyncStorage.setItem(CUSTOM_ACCESSORY_KEYS_STORAGE_KEY, JSON.stringify(keys)) +} + export function CustomKeyModal({ visible, onClose, onKeysChanged, onManageShortcuts }: Props) { const [step, setStep] = useState('choose-type') const [shortcutKey, setShortcutKey] = useState('c') @@ -94,7 +104,8 @@ export function CustomKeyModal({ visible, onClose, onKeysChanged, onManageShortc async (key: Omit) => { const existing = await loadCustomKeys() const newKey: CustomKey = { ...key, id: `custom-${Date.now()}` } - const updated = await saveCustomKeys([...existing, newKey]) + const updated = [...existing, newKey] + await saveCustomKeys(updated) onKeysChanged(updated) onClose() }, @@ -361,7 +372,6 @@ export function CustomKeyModal({ visible, onClose, onKeysChanged, onManageShortc placeholderTextColor={colors.textMuted} autoCapitalize="none" autoCorrect={false} - maxLength={CUSTOM_ACCESSORY_KEY_MAX_LABEL_CHARACTERS} /> Command Press Enter diff --git a/mobile/src/components/TerminalShortcutSettings.tsx b/mobile/src/components/TerminalShortcutSettings.tsx index 66bf28046c5..18d882aa3fb 100644 --- a/mobile/src/components/TerminalShortcutSettings.tsx +++ b/mobile/src/components/TerminalShortcutSettings.tsx @@ -117,7 +117,7 @@ export function TerminalShortcutSettings({ pendingCustomKeysWritesRef.current += 1 customKeysWriteChainRef.current = customKeysWriteChainRef.current .catch(() => {}) - .then(() => saveCustomKeys(next).then(() => undefined)) + .then(() => saveCustomKeys(next)) .catch(() => {}) .finally(() => { pendingCustomKeysWritesRef.current -= 1 diff --git a/mobile/src/components/pr-sidebar/CommentMarkdown.tsx b/mobile/src/components/pr-sidebar/CommentMarkdown.tsx index 236f653f81b..0fcc49008c2 100644 --- a/mobile/src/components/pr-sidebar/CommentMarkdown.tsx +++ b/mobile/src/components/pr-sidebar/CommentMarkdown.tsx @@ -167,10 +167,7 @@ function TableBlock({ block: Extract base: number }) { - let columnCount = Math.max(block.headers.length, 1) - for (const row of block.rows) { - columnCount = Math.max(columnCount, row.length) - } + const columnCount = Math.max(block.headers.length, ...block.rows.map((r) => r.length), 1) const columns = Array.from({ length: columnCount }, (_, c) => c) return ( { it('classifies headings, fenced code, quotes, lists, hr, and paragraphs', () => { @@ -100,42 +100,6 @@ describe('parseMarkdownBlocks tables', () => { ]) }) - it('accepts the exact table column limit and falls back one column later', () => { - const header = Array.from({ length: COMMENT_MARKDOWN_LIMITS.tableColumns }, (_, index) => - String(index) - ).join('|') - const delimiter = Array.from( - { length: COMMENT_MARKDOWN_LIMITS.tableColumns }, - () => '---' - ).join('|') - - expect(parseMarkdownBlocks(`${header}\n${delimiter}`)[0]).toMatchObject({ - kind: 'table', - headers: { length: COMMENT_MARKDOWN_LIMITS.tableColumns } - }) - expect(parseMarkdownBlocks(`${header}|extra\n${delimiter}|---`)[0]).toMatchObject({ - kind: 'paragraph' - }) - }) - - it('falls back to a bounded preview before splitting oversized input', () => { - const exact = 'x'.repeat(COMMENT_MARKDOWN_LIMITS.sourceCodeUnits) - const content = 'x'.repeat(COMMENT_MARKDOWN_LIMITS.sourceCodeUnits + 1) - const [block] = parseMarkdownBlocks(content) - - expect(parseMarkdownBlocks(exact)).toEqual([{ kind: 'paragraph', text: exact }]) - expect(block).toMatchObject({ kind: 'paragraph' }) - expect(block?.kind === 'paragraph' ? block.text.length : 0).toBeLessThan( - COMMENT_MARKDOWN_LIMITS.fallbackCodeUnits + 100 - ) - }) - - it('checks normalized break tags before allocating line arrays', () => { - const content = '
'.repeat(COMMENT_MARKDOWN_LIMITS.lines + 1) - - expect(parseMarkdownBlocks(content)[0]).toMatchObject({ kind: 'paragraph' }) - }) - it('reads per-column alignment from the delimiter row', () => { const md = ['| L | C | R |', '| :--- | :---: | ---: |', '| a | b | c |'].join('\n') const block = parseMarkdownBlocks(md)[0] diff --git a/mobile/src/components/pr-sidebar/markdown-blocks.ts b/mobile/src/components/pr-sidebar/markdown-blocks.ts index dd597383914..d5866585b68 100644 --- a/mobile/src/components/pr-sidebar/markdown-blocks.ts +++ b/mobile/src/components/pr-sidebar/markdown-blocks.ts @@ -40,20 +40,6 @@ const ORDERED = /^\s*\d+[.)]\s+(.*)$/ const HTML_BLOCK = /<(details|blockquote)\b[^>]*>([\s\S]*?)<\/\1>/i const SUMMARY = /]*>([\s\S]*?)<\/summary>/i -export const COMMENT_MARKDOWN_LIMITS = { - sourceCodeUnits: 1024 * 1024, - lines: 10_000, - detailsDepth: 64, - tableColumns: 256, - tableRows: 2000, - tableCells: 50_000, - fallbackCodeUnits: 64 * 1024 -} as const - -const LIMIT_FALLBACK_SUFFIX = '\n\n[Comment preview truncated for safety]' - -class CommentMarkdownLimitError extends Error {} - // Removes residual HTML tags from rendered text so stray // etc. don't // show literally. Conservative: only matches `` / `` shapes, so a bare // "a < b" in prose is left alone. @@ -62,56 +48,9 @@ export function stripHtmlTags(text: string): string { } export function parseMarkdownBlocks(content: string): MarkdownBlock[] { - if (!isCommentMarkdownWithinPreparseLimits(content)) { - return commentMarkdownLimitFallback(content) - } // Drop HTML comments and normalize
before block parsing. const cleaned = content.replace(//g, '').replace(//gi, '\n') - if (!isCommentMarkdownWithinPreparseLimits(cleaned)) { - return commentMarkdownLimitFallback(content) - } - try { - return parseSegment(cleaned) - } catch (error) { - if (error instanceof CommentMarkdownLimitError) { - return commentMarkdownLimitFallback(content) - } - throw error - } -} - -function isCommentMarkdownWithinPreparseLimits(content: string): boolean { - if (content.length > COMMENT_MARKDOWN_LIMITS.sourceCodeUnits) { - return false - } - let lines = 1 - let detailsDepth = 0 - const detailsPattern = /<\/?details\b/gi - for (let index = 0; index < content.length; index += 1) { - if (content.charCodeAt(index) !== 10) { - continue - } - lines += 1 - if (lines > COMMENT_MARKDOWN_LIMITS.lines) { - return false - } - } - for (const match of content.matchAll(detailsPattern)) { - if (match[0][1] === '/') { - detailsDepth = Math.max(0, detailsDepth - 1) - continue - } - detailsDepth += 1 - if (detailsDepth > COMMENT_MARKDOWN_LIMITS.detailsDepth) { - return false - } - } - return true -} - -function commentMarkdownLimitFallback(content: string): MarkdownBlock[] { - const preview = content.slice(0, COMMENT_MARKDOWN_LIMITS.fallbackCodeUnits) - return [{ kind: 'paragraph', text: `${preview}${LIMIT_FALLBACK_SUFFIX}` }] + return parseSegment(cleaned) } // Splits a segment at top-level
/
regions (preserving order), @@ -181,17 +120,8 @@ function parseLines(content: string): MarkdownBlock[] { const align = parseAlignRow(lines[i + 1]) i += 2 const rows: string[][] = [] - let tableCells = headers.length while (i < lines.length && lines[i].includes('|') && lines[i].trim() !== '') { - if (rows.length >= COMMENT_MARKDOWN_LIMITS.tableRows) { - throw new CommentMarkdownLimitError('Table row limit exceeded') - } - const row = splitTableRow(lines[i]) - tableCells += row.length - if (tableCells > COMMENT_MARKDOWN_LIMITS.tableCells) { - throw new CommentMarkdownLimitError('Table cell limit exceeded') - } - rows.push(row) + rows.push(splitTableRow(lines[i])) i += 1 } blocks.push({ kind: 'table', headers, rows, align }) @@ -277,18 +207,12 @@ function splitTableRow(line: string): string[] { continue } if (ch === '|') { - if (cells.length >= COMMENT_MARKDOWN_LIMITS.tableColumns) { - throw new CommentMarkdownLimitError('Table column limit exceeded') - } cells.push(cell.trim()) cell = '' continue } cell += ch } - if (cells.length >= COMMENT_MARKDOWN_LIMITS.tableColumns) { - throw new CommentMarkdownLimitError('Table column limit exceeded') - } cells.push(cell.trim()) return cells } diff --git a/mobile/src/dictation/dictation-setup-poll-controller.test.ts b/mobile/src/dictation/dictation-setup-poll-controller.test.ts index c1eacd0c999..5ca010759d6 100644 --- a/mobile/src/dictation/dictation-setup-poll-controller.test.ts +++ b/mobile/src/dictation/dictation-setup-poll-controller.test.ts @@ -132,27 +132,6 @@ describe('DictationSetupPollController', () => { poller.dispose() }) - it('shares one completion while repeated manual refreshes are pending', async () => { - const requests = [deferred(), deferred()] - const refresh = vi.fn(() => requests[refresh.mock.calls.length - 1].promise) - const poller = new DictationSetupPollController(refresh, POLL_INTERVAL_MS) - poller.setPolling(true) - poller.setVisible(true) - poller.setForeground(true) - - const completions = Array.from({ length: 1_000 }, () => poller.refreshNow()) - expect(new Set(completions).size).toBe(1) - expect(refresh).toHaveBeenCalledOnce() - - requests[0].resolve(true) - await flushPromises() - expect(refresh).toHaveBeenCalledTimes(2) - - requests[1].resolve(false) - await expect(completions[0]).resolves.toBeUndefined() - poller.dispose() - }) - it('refreshes immediately when visibility or foreground eligibility resumes', async () => { const refresh = vi.fn().mockResolvedValue(true) const poller = new DictationSetupPollController(refresh, POLL_INTERVAL_MS) diff --git a/mobile/src/dictation/dictation-setup-poll-controller.ts b/mobile/src/dictation/dictation-setup-poll-controller.ts index de74fdb4f8e..cb927f161f9 100644 --- a/mobile/src/dictation/dictation-setup-poll-controller.ts +++ b/mobile/src/dictation/dictation-setup-poll-controller.ts @@ -11,7 +11,7 @@ export class DictationSetupPollController { private timer: ReturnType | null = null private inFlight = false private immediateRefreshPending = false - private refreshCompletion: { promise: Promise; resolve: () => void } | null = null + private refreshWaiters: Array<() => void> = [] private disposed = false // Why: an explicit setPolling is a newer lifecycle intent than a read that was already on the wire. // Bumped on every setPolling so an in-flight refresh resolving after an explicit stop/start can be @@ -40,22 +40,17 @@ export class DictationSetupPollController { if (this.disposed || !this.isEligible()) { return Promise.resolve() } - if (!this.refreshCompletion) { - let resolve!: () => void - const promise = new Promise((nextResolve) => { - resolve = nextResolve - }) - this.refreshCompletion = { promise, resolve } - } - this.requestRefresh(true) - return this.refreshCompletion.promise + return new Promise((resolve) => { + this.refreshWaiters.push(resolve) + this.requestRefresh(true) + }) } dispose(): void { this.disposed = true this.immediateRefreshPending = false this.clearTimer() - this.resolveRefreshCompletion() + this.resolveRefreshWaiters() } private update(next: Partial): void { @@ -118,7 +113,7 @@ export class DictationSetupPollController { this.state.polling = shouldContinue } if (this.disposed || !this.isEligible()) { - this.resolveRefreshCompletion() + this.resolveRefreshWaiters() return } if (this.immediateRefreshPending) { @@ -126,7 +121,7 @@ export class DictationSetupPollController { this.requestRefresh(true) return } - this.resolveRefreshCompletion() + this.resolveRefreshWaiters() if (this.state.polling) { this.scheduleRefresh() } @@ -149,9 +144,10 @@ export class DictationSetupPollController { } } - private resolveRefreshCompletion(): void { - const completion = this.refreshCompletion - this.refreshCompletion = null - completion?.resolve() + private resolveRefreshWaiters(): void { + const waiters = this.refreshWaiters.splice(0) + for (const resolve of waiters) { + resolve() + } } } diff --git a/mobile/src/files/MobileFileExplorerPanel.test.ts b/mobile/src/files/MobileFileExplorerPanel.test.ts index 86719d7c1cd..0d003e9f456 100644 --- a/mobile/src/files/MobileFileExplorerPanel.test.ts +++ b/mobile/src/files/MobileFileExplorerPanel.test.ts @@ -2,7 +2,6 @@ import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, describe, expect, it, vi } from 'vitest' import { MobileFileExplorerPanel } from './MobileFileExplorerPanel' -import { LEGACY_MOBILE_FILE_PATH_MAX_BYTES } from './file-list-fallback' import type { MobileDirEntry } from './file-tree' import type { RpcResponse } from '../transport/types' @@ -207,18 +206,6 @@ describe('MobileFileExplorerPanel', () => { expect(client.sendRequest).toHaveBeenCalledTimes(2) }) - it('rejects an extreme directory listing instead of silently truncating it', async () => { - const client = createMockClient({ - '': Array.from({ length: 10_001 }, (_, index) => entry(`file-${index}.txt`)) - }) - mockTransport.client = client - - const renderer = await renderExplorer() - - expect(renderedText(renderer)).toContain('This folder is too large to show safely on mobile') - expect(renderedText(renderer)).toContain('10,000 items') - }) - it('keeps the loaded tree visible during a transient disconnect', async () => { const client = createMockClient({ '': [entry('src', true), entry('README.md')] @@ -379,43 +366,6 @@ describe('MobileFileExplorerPanel', () => { expect(renderedText(renderer)).toContain('Showing first 5000') }) - it('reports a bounded-memory error when a legacy path exceeds the fallback cap', async () => { - const legacyClient: MockClient = { - sendRequest: vi.fn(async (method: string): Promise => { - if (method === 'files.readDir') { - return { - id: 'response-id', - ok: false, - error: { code: 'method_not_found', message: 'Unknown method' }, - _meta: { runtimeId: 'runtime-id' } - } - } - return { - id: 'response-id', - ok: true, - result: { - files: [ - { - relativePath: 'x'.repeat(LEGACY_MOBILE_FILE_PATH_MAX_BYTES + 1), - basename: 'oversized', - kind: 'text' - } - ], - totalCount: 1, - truncated: false - }, - _meta: { runtimeId: 'runtime-id' } - } - }) - } - mockTransport.client = legacyClient - - const renderer = await renderExplorer() - - expect(renderedText(renderer)).toContain('legacy file list is too large') - expect(renderedText(renderer)).toContain('Update Orca Desktop') - }) - it('reports the files.list failure when the fallback itself fails', async () => { const legacyClient: MockClient = { sendRequest: vi.fn(async (method: string): Promise => { diff --git a/mobile/src/files/MobileFileExplorerPanel.tsx b/mobile/src/files/MobileFileExplorerPanel.tsx index d34c4ffd07a..7e67bf81b5f 100644 --- a/mobile/src/files/MobileFileExplorerPanel.tsx +++ b/mobile/src/files/MobileFileExplorerPanel.tsx @@ -16,15 +16,14 @@ import { flattenDirectoryCache, getDirectoryCacheState, type DirectoryCache, - type DirectoryState, - type FileExplorerRow + type FileExplorerRow, + type MobileDirEntry } from './file-tree' import type { RpcSuccess } from '../transport/types' import { colors } from '../theme/mobile-theme' import { beginDirectoryLoad, createDirectoryLoadRevisions, - forgetDirectoryLoadBranches, isCurrentDirectoryLoad, resetDirectoryLoadRevisions, type DirectoryLoadRevisions @@ -37,12 +36,6 @@ import { import { fileExplorerStyles as styles } from './mobile-file-explorer-styles' import { MobileFileExplorerRow } from './mobile-file-explorer-row' import { navigateToMobileFilePreview } from './mobile-file-preview-navigation' -import { - MOBILE_DIRECTORY_CACHE_LIMIT_MESSAGE, - parseBoundedMobileDirectoryEntries, - removeEvictedExpandedPaths, - retainMobileDirectoryState -} from './mobile-directory-cache-retention' export function MobileFileExplorerPanel(props: { hostId: string @@ -61,48 +54,13 @@ export function MobileFileExplorerPanel(props: { const directoryLoadRevisionsRef = useRef(createDirectoryLoadRevisions()) const pendingDirectoryRetriesRef = useRef>(new Set()) const directoryCacheRef = useRef({}) - const directoryCacheAccessRef = useRef(0) const [directoryCache, setDirectoryCache] = useState({}) const [expanded, setExpanded] = useState>(() => new Set()) - const expandedRef = useRef(expanded) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [legacyListTruncated, setLegacyListTruncated] = useState(false) const worktreeLabel = getWorktreeLabel(name, worktreeId) - const commitDirectoryState = useCallback( - (relativePath: string, state: DirectoryState): boolean => { - const retained = retainMobileDirectoryState( - directoryCacheRef.current, - relativePath, - { ...state, lastAccess: ++directoryCacheAccessRef.current }, - expandedRef.current - ) - if (!retained.admitted) { - return false - } - directoryCacheRef.current = retained.cache - setDirectoryCache(retained.cache) - if (retained.evictedPaths.length > 0) { - forgetDirectoryLoadBranches(directoryLoadRevisionsRef.current, retained.evictedPaths) - for (const pendingPath of pendingDirectoryRetriesRef.current) { - if ( - retained.evictedPaths.some( - (evicted) => pendingPath === evicted || pendingPath.startsWith(`${evicted}/`) - ) - ) { - pendingDirectoryRetriesRef.current.delete(pendingPath) - } - } - const nextExpanded = removeEvictedExpandedPaths(expandedRef.current, retained.evictedPaths) - expandedRef.current = nextExpanded - setExpanded(nextExpanded) - } - return true - }, - [] - ) - const loadDirectory = useCallback( async (relativePath: string) => { const scope = scopeRef.current @@ -119,10 +77,13 @@ export function MobileFileExplorerPanel(props: { // Why: transient reconnects should not blank an already browsable tree. setError(hasLoadedRoot ? null : message) } else { - commitDirectoryState(relativePath, { - entries: getDirectoryCacheState(directoryCacheRef.current, relativePath)?.entries ?? [], - error: message - }) + setDirectoryCache((prev) => ({ + ...prev, + [relativePath]: { + entries: getDirectoryCacheState(prev, relativePath)?.entries ?? [], + error: message + } + })) } return } @@ -137,16 +98,13 @@ export function MobileFileExplorerPanel(props: { } setError(null) } - const admittedLoading = commitDirectoryState(relativePath, { - entries: getDirectoryCacheState(directoryCacheRef.current, relativePath)?.entries ?? [], - loading: true - }) - if (!admittedLoading) { - forgetDirectoryLoadBranches(directoryLoadRevisionsRef.current, [relativePath]) - setLoading(false) - setError(MOBILE_DIRECTORY_CACHE_LIMIT_MESSAGE) - return - } + setDirectoryCache((prev) => ({ + ...prev, + [relativePath]: { + entries: getDirectoryCacheState(prev, relativePath)?.entries ?? [], + loading: true + } + })) try { const response = await client.sendRequest('files.readDir', { @@ -175,9 +133,7 @@ export function MobileFileExplorerPanel(props: { return } const legacyResult = (legacy as RpcSuccess).result as LegacyFilesListResult - const legacyCache = directoryCacheFromFileList(legacyResult.files) - directoryCacheRef.current = legacyCache - setDirectoryCache(legacyCache) + setDirectoryCache(directoryCacheFromFileList(legacyResult.files)) // Why: the capped list silently omits files past the cap — keep // the legacy explorer's "Showing first 5000" note. setLegacyListTruncated(legacyResult.truncated) @@ -194,13 +150,14 @@ export function MobileFileExplorerPanel(props: { ) { return } - const entries = parseBoundedMobileDirectoryEntries((response as RpcSuccess).result) + const entries = (response as RpcSuccess).result as MobileDirEntry[] if (rootLoad) { setLegacyListTruncated(false) } - if (!commitDirectoryState(relativePath, { entries })) { - throw new Error(MOBILE_DIRECTORY_CACHE_LIMIT_MESSAGE) - } + setDirectoryCache((prev) => ({ + ...prev, + [relativePath]: { entries } + })) } catch (err) { if ( !isCurrentDirectoryLoad(directoryLoadRevisionsRef.current, scopeRef.current, loadToken) @@ -213,10 +170,13 @@ export function MobileFileExplorerPanel(props: { // only a cold load surfaces the full-screen error. setError(hadLoadedRoot ? null : message) } else { - commitDirectoryState(relativePath, { - entries: getDirectoryCacheState(directoryCacheRef.current, relativePath)?.entries ?? [], - error: message - }) + setDirectoryCache((prev) => ({ + ...prev, + [relativePath]: { + entries: getDirectoryCacheState(prev, relativePath)?.entries ?? [], + error: message + } + })) } } finally { if ( @@ -227,7 +187,7 @@ export function MobileFileExplorerPanel(props: { } } }, - [client, commitDirectoryState, connState, worktreeId] + [client, connState, worktreeId] ) useEffect(() => { @@ -235,15 +195,17 @@ export function MobileFileExplorerPanel(props: { resetDirectoryLoadRevisions(directoryLoadRevisionsRef.current) pendingDirectoryRetriesRef.current.clear() directoryCacheRef.current = {} - directoryCacheAccessRef.current = 0 setDirectoryCache({}) - expandedRef.current = new Set() - setExpanded(expandedRef.current) + setExpanded(new Set()) setLoading(true) setError(null) setLegacyListTruncated(false) }, [scope]) + useEffect(() => { + directoryCacheRef.current = directoryCache + }, [directoryCache]) + useEffect(() => { void loadDirectory('') }, [hostId, loadDirectory]) @@ -266,21 +228,21 @@ export function MobileFileExplorerPanel(props: { const toggleDirectory = useCallback( (relativePath: string) => { - const wasExpanded = expandedRef.current.has(relativePath) - const nextExpanded = new Set(expandedRef.current) - if (wasExpanded) { - nextExpanded.delete(relativePath) - } else { - nextExpanded.add(relativePath) - } - expandedRef.current = nextExpanded - setExpanded(nextExpanded) + setExpanded((prev) => { + const next = new Set(prev) + if (next.has(relativePath)) { + next.delete(relativePath) + } else { + next.add(relativePath) + } + return next + }) const state = getDirectoryCacheState(directoryCache, relativePath) - if (!wasExpanded && !state?.loading && (!state?.entries || state.error)) { + if (!expanded.has(relativePath) && !state?.loading && (!state?.entries || state.error)) { void loadDirectory(relativePath) } }, - [directoryCache, loadDirectory] + [directoryCache, expanded, loadDirectory] ) const retryDirectory = useCallback( diff --git a/mobile/src/files/directory-load-revisions.test.ts b/mobile/src/files/directory-load-revisions.test.ts index b36f88c7022..86ebfc3c0e8 100644 --- a/mobile/src/files/directory-load-revisions.test.ts +++ b/mobile/src/files/directory-load-revisions.test.ts @@ -2,7 +2,6 @@ import { describe, expect, it } from 'vitest' import { beginDirectoryLoad, createDirectoryLoadRevisions, - forgetDirectoryLoadBranches, isCurrentDirectoryLoad, resetDirectoryLoadRevisions, type DirectoryLoadRevisions @@ -42,17 +41,4 @@ describe('directory-load-revisions', () => { expect(isCurrentDirectoryLoad(revisions, 'host-a:worktree-a', load)).toBe(true) }) - - it('invalidates evicted branches without staling unrelated loads', () => { - const revisions = createDirectoryLoadRevisions() - const src = beginDirectoryLoad(revisions, 'host-a:worktree-a', 'src') - const nested = beginDirectoryLoad(revisions, 'host-a:worktree-a', 'src/lib') - const docs = beginDirectoryLoad(revisions, 'host-a:worktree-a', 'docs') - - forgetDirectoryLoadBranches(revisions, ['src']) - - expect(isCurrentDirectoryLoad(revisions, 'host-a:worktree-a', src)).toBe(false) - expect(isCurrentDirectoryLoad(revisions, 'host-a:worktree-a', nested)).toBe(false) - expect(isCurrentDirectoryLoad(revisions, 'host-a:worktree-a', docs)).toBe(true) - }) }) diff --git a/mobile/src/files/directory-load-revisions.ts b/mobile/src/files/directory-load-revisions.ts index 584d2bc0aa9..04153092bbe 100644 --- a/mobile/src/files/directory-load-revisions.ts +++ b/mobile/src/files/directory-load-revisions.ts @@ -19,21 +19,6 @@ export function resetDirectoryLoadRevisions(revisions: DirectoryLoadRevisions): revisions.revisionsByPath.clear() } -export function forgetDirectoryLoadBranches( - revisions: DirectoryLoadRevisions, - relativePaths: readonly string[] -): void { - for (const loadedPath of revisions.revisionsByPath.keys()) { - if ( - relativePaths.some( - (relativePath) => loadedPath === relativePath || loadedPath.startsWith(`${relativePath}/`) - ) - ) { - revisions.revisionsByPath.delete(loadedPath) - } - } -} - export function beginDirectoryLoad( revisions: DirectoryLoadRevisions, scope: string, diff --git a/mobile/src/files/file-list-fallback.test.ts b/mobile/src/files/file-list-fallback.test.ts index ad6fc7eacec..a69affc5fe8 100644 --- a/mobile/src/files/file-list-fallback.test.ts +++ b/mobile/src/files/file-list-fallback.test.ts @@ -1,34 +1,7 @@ import { describe, expect, it } from 'vitest' -import { - directoryCacheFromFileList, - isMobileMethodUnavailableError, - LEGACY_MOBILE_FILE_CACHE_MAX_DIRECTORIES, - LEGACY_MOBILE_FILE_CACHE_MAX_ENTRIES, - LEGACY_MOBILE_FILE_CACHE_MAX_RETAINED_BYTES, - LEGACY_MOBILE_FILE_LIST_LIMIT_MESSAGE, - LEGACY_MOBILE_FILE_LIST_MAX_FILES, - LEGACY_MOBILE_FILE_PATH_MAX_BYTES, - LEGACY_MOBILE_FILE_PATH_MAX_DEPTH, - type LegacyMobileFileEntry -} from './file-list-fallback' +import { directoryCacheFromFileList, isMobileMethodUnavailableError } from './file-list-fallback' import { getDirectoryCacheState } from './file-tree' -const RETAINED_NODE_ESTIMATE_BYTES = 64 - -function file(relativePath: string): LegacyMobileFileEntry { - return { relativePath, basename: relativePath, kind: 'text' } -} - -function directoryChainPath(prefix: string, directories: number): string { - const names = Array.from({ length: directories }, (_, index) => (index === 0 ? prefix : 'd')) - return `${names.join('/')}/file` -} - -function fixedLengthUniqueName(index: number, length: number): string { - const suffix = `-${index.toString(36)}` - return `${'x'.repeat(length - suffix.length)}${suffix}` -} - describe('isMobileMethodUnavailableError', () => { it('detects old-desktop allowlist and missing-method failures', () => { expect(isMobileMethodUnavailableError('forbidden', undefined)).toBe(true) @@ -47,9 +20,9 @@ describe('isMobileMethodUnavailableError', () => { describe('directoryCacheFromFileList', () => { it('synthesizes every ancestor directory from flat paths', () => { const cache = directoryCacheFromFileList([ - file('src/lib/util.ts'), - file('src/app.ts'), - file('README.md') + { relativePath: 'src/lib/util.ts', basename: 'util.ts', kind: 'text' }, + { relativePath: 'src/app.ts', basename: 'app.ts', kind: 'text' }, + { relativePath: 'README.md', basename: 'README.md', kind: 'text' } ]) expect(cache['']?.entries).toEqual( expect.arrayContaining([ @@ -66,27 +39,11 @@ describe('directoryCacheFromFileList', () => { expect(cache['src/lib']?.entries).toEqual([{ name: 'util.ts', isDirectory: false }]) }) - it('preserves empty-segment filtering and first-seen entry order', () => { - const cache = directoryCacheFromFileList([ - file('//src///lib//util.ts//'), - file('/README.md'), - file('src/app.ts'), - file('///') - ]) - - expect(cache['']?.entries).toEqual([ - { name: 'src', isDirectory: true }, - { name: 'README.md', isDirectory: false } - ]) - expect(cache['src']?.entries).toEqual([ - { name: 'lib', isDirectory: true }, - { name: 'app.ts', isDirectory: false } - ]) - expect(cache['src/lib']?.entries).toEqual([{ name: 'util.ts', isDirectory: false }]) - }) - it('keeps a name a directory when it appears as both file and dir prefix', () => { - const cache = directoryCacheFromFileList([file('src'), file('src/app.ts')]) + const cache = directoryCacheFromFileList([ + { relativePath: 'src', basename: 'src', kind: 'text' }, + { relativePath: 'src/app.ts', basename: 'app.ts', kind: 'text' } + ]) expect(cache['']?.entries).toEqual([{ name: 'src', isDirectory: true }]) }) @@ -96,112 +53,13 @@ describe('directoryCacheFromFileList', () => { }) it('stores a __proto__ directory as an own key instead of mutating the prototype', () => { - const cache = directoryCacheFromFileList([file('__proto__/pollute.js')]) + const cache = directoryCacheFromFileList([ + { relativePath: '__proto__/pollute.js', basename: 'pollute.js', kind: 'text' } + ]) expect(Object.getPrototypeOf(cache)).toBe(Object.prototype) expect(cache['']?.entries).toEqual([{ name: '__proto__', isDirectory: true }]) expect(getDirectoryCacheState(cache, '__proto__')?.entries).toEqual([ { name: 'pollute.js', isDirectory: false } ]) }) - - it('accepts a delimiter-heavy path at the exact byte cap without retaining empty segments', () => { - const cache = directoryCacheFromFileList([file('/'.repeat(LEGACY_MOBILE_FILE_PATH_MAX_BYTES))]) - expect(cache['']?.entries).toEqual([]) - }) - - it('measures the per-file path cap in UTF-8 bytes and rejects one byte over', () => { - const exactPath = 'é'.repeat(LEGACY_MOBILE_FILE_PATH_MAX_BYTES / 2) - expect(directoryCacheFromFileList([file(exactPath)])['']?.entries).toEqual([ - { name: exactPath, isDirectory: false } - ]) - expect(() => directoryCacheFromFileList([file(`${exactPath}a`)])).toThrow( - LEGACY_MOBILE_FILE_LIST_LIMIT_MESSAGE - ) - }) - - it('accepts the exact path depth and rejects the next segment', () => { - const exactPath = Array.from({ length: LEGACY_MOBILE_FILE_PATH_MAX_DEPTH }, () => 'd').join('/') - const parentPath = exactPath.slice(0, exactPath.lastIndexOf('/')) - expect( - getDirectoryCacheState(directoryCacheFromFileList([file(exactPath)]), parentPath) - ).toEqual({ - entries: [{ name: 'd', isDirectory: false }] - }) - expect(() => directoryCacheFromFileList([file(`${exactPath}/overflow`)])).toThrow( - LEGACY_MOBILE_FILE_LIST_LIMIT_MESSAGE - ) - }) - - it('rejects a response above the desktop files.list record cap', () => { - const files = Array.from({ length: LEGACY_MOBILE_FILE_LIST_MAX_FILES + 1 }, () => file('')) - expect(() => directoryCacheFromFileList(files)).toThrow(LEGACY_MOBILE_FILE_LIST_LIMIT_MESSAGE) - }) - - it('accepts the exact directory cap and rejects one additional directory', () => { - const fullChains = Math.floor((LEGACY_MOBILE_FILE_CACHE_MAX_DIRECTORIES - 1) / 255) - const remainingDirectories = LEGACY_MOBILE_FILE_CACHE_MAX_DIRECTORIES - 1 - fullChains * 255 - const files = Array.from({ length: fullChains }, (_, index) => - file(directoryChainPath(`root-${index}`, 255)) - ) - if (remainingDirectories > 0) { - files.push(file(directoryChainPath('tail', remainingDirectories))) - } - - expect(Object.keys(directoryCacheFromFileList(files))).toHaveLength( - LEGACY_MOBILE_FILE_CACHE_MAX_DIRECTORIES - ) - expect(() => - directoryCacheFromFileList([...files, file(directoryChainPath('overflow', 1))]) - ).toThrow(LEGACY_MOBILE_FILE_LIST_LIMIT_MESSAGE) - }) - - it('accepts the exact aggregate entry cap and rejects one additional entry', () => { - const exactFiles = Array.from({ length: LEGACY_MOBILE_FILE_LIST_MAX_FILES }, (_, index) => - file(`root-${index}/a/b/file`) - ) - const overflowFiles = exactFiles.map((entry, index) => - index === 0 ? file('root-0/a/b/c/file') : entry - ) - - const cache = directoryCacheFromFileList(exactFiles) - const entryCount = Object.values(cache).reduce( - (total, state) => total + (state?.entries.length ?? 0), - 0 - ) - expect(entryCount).toBe(LEGACY_MOBILE_FILE_CACHE_MAX_ENTRIES) - expect(() => directoryCacheFromFileList(overflowFiles)).toThrow( - LEGACY_MOBILE_FILE_LIST_LIMIT_MESSAGE - ) - }) - - it('accepts the exact retained-byte budget and rejects two bytes over', () => { - const namesBytes = - LEGACY_MOBILE_FILE_CACHE_MAX_RETAINED_BYTES - - RETAINED_NODE_ESTIMATE_BYTES - - LEGACY_MOBILE_FILE_LIST_MAX_FILES * RETAINED_NODE_ESTIMATE_BYTES - const namesCharacters = namesBytes / 2 - const baseLength = Math.floor(namesCharacters / LEGACY_MOBILE_FILE_LIST_MAX_FILES) - const longerNames = namesCharacters % LEGACY_MOBILE_FILE_LIST_MAX_FILES - const exactFiles = Array.from({ length: LEGACY_MOBILE_FILE_LIST_MAX_FILES }, (_, index) => { - const length = baseLength + (index < longerNames ? 1 : 0) - return file(fixedLengthUniqueName(index, length)) - }) - const overflowFiles = exactFiles.map((entry, index) => - index === 0 ? file(`${entry.relativePath}x`) : entry - ) - - expect(directoryCacheFromFileList(exactFiles)['']?.entries).toHaveLength( - LEGACY_MOBILE_FILE_LIST_MAX_FILES - ) - expect(() => directoryCacheFromFileList(overflowFiles)).toThrow( - LEGACY_MOBILE_FILE_LIST_LIMIT_MESSAGE - ) - }) - - it('rejects malformed legacy response shapes with a clear error', () => { - expect(() => directoryCacheFromFileList({ files: [] })).toThrow('invalid legacy file list') - expect(() => directoryCacheFromFileList([{ relativePath: 42 }])).toThrow( - 'invalid legacy file list' - ) - }) }) diff --git a/mobile/src/files/file-list-fallback.ts b/mobile/src/files/file-list-fallback.ts index 5c2dde0f30e..ef3041029af 100644 --- a/mobile/src/files/file-list-fallback.ts +++ b/mobile/src/files/file-list-fallback.ts @@ -1,22 +1,8 @@ // Fallback for desktops that predate files.readDir in the mobile RPC // allowlist: synthesize the lazy directory cache from the flat, capped // files.list result so the Files tab stays browsable against old desktops. -import { getUtf8ByteLengthForCodePoint } from '../../../src/shared/utf8-byte-limits' import type { DirectoryCache, MobileDirEntry } from './file-tree' -// Why: each flat legacy path can amplify into many retained ancestor records. -export const LEGACY_MOBILE_FILE_LIST_MAX_FILES = 5_000 -export const LEGACY_MOBILE_FILE_PATH_MAX_BYTES = 16 * 1024 -export const LEGACY_MOBILE_FILE_PATH_MAX_DEPTH = 256 -export const LEGACY_MOBILE_FILE_CACHE_MAX_DIRECTORIES = 16_384 -export const LEGACY_MOBILE_FILE_CACHE_MAX_ENTRIES = 20_000 -export const LEGACY_MOBILE_FILE_CACHE_MAX_RETAINED_BYTES = 16 * 1024 * 1024 -export const LEGACY_MOBILE_FILE_LIST_LIMIT_MESSAGE = - 'This legacy file list is too large to show safely on mobile. Update Orca Desktop to browse it folder by folder.' - -const DIRECTORY_RETAINED_BYTES = 64 -const ENTRY_RETAINED_BYTES = 64 - export type LegacyMobileFileEntry = { relativePath: string basename: string @@ -29,13 +15,6 @@ export type LegacyFilesListResult = { truncated: boolean } -type LegacyFileCacheBuildState = { - childrenByDir: Map> - directories: number - entries: number - retainedBytes: number -} - // Same detection shape as isMobileGitUnavailable in mobile-git-status.ts: // 'forbidden' = method exists but is not mobile-allowlisted on the old // desktop; 'method_not_found' = desktop predates the method entirely. @@ -50,156 +29,45 @@ export function isMobileMethodUnavailableError( ) } -export function directoryCacheFromFileList(files: unknown): DirectoryCache { - if (!Array.isArray(files)) { - throw new Error('Desktop returned an invalid legacy file list.') +export function directoryCacheFromFileList(files: LegacyMobileFileEntry[]): DirectoryCache { + const childrenByDir = new Map>() + const ensureDir = (path: string): Map => { + let children = childrenByDir.get(path) + if (!children) { + children = new Map() + childrenByDir.set(path, children) + } + return children } - if (files.length > LEGACY_MOBILE_FILE_LIST_MAX_FILES) { - throwLegacyFileListLimitError() - } - - const state: LegacyFileCacheBuildState = { - childrenByDir: new Map(), - directories: 0, - entries: 0, - retainedBytes: 0 - } - ensureDirectory(state, '') + ensureDir('') for (const file of files) { - const relativePath = getLegacyRelativePath(file) - if (relativePath === null) { - throw new Error('Desktop returned an invalid legacy file list.') - } - addFilePath(state, relativePath, measureBoundedPathDepth(relativePath)) - } - return createDirectoryCache(state.childrenByDir) -} - -function getLegacyRelativePath(value: unknown): string | null { - if (value === null || typeof value !== 'object') { - return null - } - const relativePath = (value as { relativePath?: unknown }).relativePath - return typeof relativePath === 'string' ? relativePath : null -} - -function measureBoundedPathDepth(relativePath: string): number { - let bytes = 0 - let depth = 0 - let insideSegment = false - for (let index = 0; index < relativePath.length; index += 1) { - const codePoint = relativePath.codePointAt(index) ?? 0 - bytes += getUtf8ByteLengthForCodePoint(codePoint) - if (bytes > LEGACY_MOBILE_FILE_PATH_MAX_BYTES) { - throwLegacyFileListLimitError() - } - if (codePoint === 47) { - if (insideSegment) { - depth += 1 - assertPathDepth(depth) - } - insideSegment = false - } else { - insideSegment = true - } - if (codePoint > 0xffff) { - index += 1 - } - } - if (insideSegment) { - depth += 1 - assertPathDepth(depth) - } - return depth -} - -function assertPathDepth(depth: number): void { - if (depth > LEGACY_MOBILE_FILE_PATH_MAX_DEPTH) { - throwLegacyFileListLimitError() - } -} - -function addFilePath(state: LegacyFileCacheBuildState, relativePath: string, depth: number): void { - let parentPath = '' - let segmentStart = 0 - let segmentIndex = 0 - for (let cursor = 0; cursor <= relativePath.length; cursor += 1) { - if (cursor < relativePath.length && relativePath.charCodeAt(cursor) !== 47) { - continue - } - if (cursor > segmentStart) { - segmentIndex += 1 - const name = relativePath.slice(segmentStart, cursor) - const isDirectory = segmentIndex < depth - addDirectoryEntry(state, parentPath, name, isDirectory) + const parts = file.relativePath.split('/').filter(Boolean) + let parentPath = '' + parts.forEach((name, index) => { + const isDirectory = index < parts.length - 1 + const children = ensureDir(parentPath) + children.set(name, children.get(name) === true || isDirectory) + parentPath = parentPath ? `${parentPath}/${name}` : name if (isDirectory) { - parentPath = parentPath ? `${parentPath}/${name}` : name - ensureDirectory(state, parentPath) + ensureDir(parentPath) } - } - segmentStart = cursor + 1 - } -} - -function ensureDirectory(state: LegacyFileCacheBuildState, path: string): void { - if (state.childrenByDir.has(path)) { - return - } - const retainedBytes = path.length * 2 + DIRECTORY_RETAINED_BYTES - if ( - state.directories >= LEGACY_MOBILE_FILE_CACHE_MAX_DIRECTORIES || - state.retainedBytes > LEGACY_MOBILE_FILE_CACHE_MAX_RETAINED_BYTES - retainedBytes - ) { - throwLegacyFileListLimitError() - } - state.childrenByDir.set(path, new Map()) - state.directories += 1 - state.retainedBytes += retainedBytes -} - -function addDirectoryEntry( - state: LegacyFileCacheBuildState, - parentPath: string, - name: string, - isDirectory: boolean -): void { - const children = state.childrenByDir.get(parentPath) - if (!children) { - throw new Error('Legacy file cache builder lost its parent directory.') - } - const existing = children.get(name) - if (existing) { - existing.isDirectory ||= isDirectory - return - } - const retainedBytes = name.length * 2 + ENTRY_RETAINED_BYTES - if ( - state.entries >= LEGACY_MOBILE_FILE_CACHE_MAX_ENTRIES || - state.retainedBytes > LEGACY_MOBILE_FILE_CACHE_MAX_RETAINED_BYTES - retainedBytes - ) { - throwLegacyFileListLimitError() - } - children.set(name, { name, isDirectory }) - state.entries += 1 - state.retainedBytes += retainedBytes -} - -function createDirectoryCache( - childrenByDir: ReadonlyMap> -): DirectoryCache { - const cache: DirectoryCache = {} - for (const [path, children] of childrenByDir) { - // Why: assignment to a '__proto__' path would invoke its legacy setter. - Object.defineProperty(cache, path, { - configurable: true, - enumerable: true, - value: { entries: Array.from(children.values()) }, - writable: true }) } - return cache -} - -function throwLegacyFileListLimitError(): never { - throw new Error(LEGACY_MOBILE_FILE_LIST_LIMIT_MESSAGE) + // Why: plain `cache[path] = ...` with a '__proto__' path segment mutates the + // object's prototype instead of storing the directory; fromEntries always + // creates own keys. + return Object.fromEntries( + Array.from(childrenByDir, ([path, children]) => [ + path, + { + entries: Array.from( + children, + ([name, isDirectory]): MobileDirEntry => ({ + name, + isDirectory + }) + ) + } + ]) + ) } diff --git a/mobile/src/files/file-tree.ts b/mobile/src/files/file-tree.ts index 8a8a2170e07..a9283cfa457 100644 --- a/mobile/src/files/file-tree.ts +++ b/mobile/src/files/file-tree.ts @@ -22,7 +22,6 @@ export type DirectoryState = { entries: MobileDirEntry[] loading?: boolean error?: string - lastAccess?: number } export type DirectoryCache = Record diff --git a/mobile/src/files/mobile-diff-image-preview.test.ts b/mobile/src/files/mobile-diff-image-preview.test.ts index a7ae1cdc185..e63f532b28d 100644 --- a/mobile/src/files/mobile-diff-image-preview.test.ts +++ b/mobile/src/files/mobile-diff-image-preview.test.ts @@ -1,57 +1,44 @@ import { describe, expect, it } from 'vitest' import { mobileDiffImageDataUri } from './mobile-diff-image-preview' -function pngBase64(width = 1): string { - const bytes = Buffer.alloc(24) - Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(bytes) - bytes.writeUInt32BE(13, 8) - bytes.write('IHDR', 12, 'ascii') - bytes.writeUInt32BE(width, 16) - bytes.writeUInt32BE(1, 20) - return bytes.toString('base64') -} - describe('mobileDiffImageDataUri', () => { it('renders a modified image diff from the post-change bytes', () => { - const modifiedContent = pngBase64(2) expect( mobileDiffImageDataUri({ kind: 'binary', originalContent: 'b2xk', - modifiedContent, + modifiedContent: 'bmV3', isImage: true, mimeType: 'image/png' }) - ).toBe(`data:image/png;base64,${modifiedContent}`) + ).toBe('data:image/png;base64,bmV3') }) it('renders an added image diff (no original) from the modified bytes', () => { - const modifiedContent = pngBase64() expect( mobileDiffImageDataUri({ kind: 'binary', originalContent: '', - modifiedContent, + modifiedContent: 'bmV3', isImage: true, mimeType: 'image/png' }) - ).toBe(`data:image/png;base64,${modifiedContent}`) + ).toBe('data:image/png;base64,bmV3') }) it('falls back to the original bytes for a proven deletion (modifiedDeleted)', () => { - const originalContent = pngBase64() expect( mobileDiffImageDataUri({ kind: 'binary', - originalContent, + originalContent: 'b2xk', originalIsBinary: true, modifiedContent: '', modifiedIsBinary: false, modifiedDeleted: true, isImage: true, - mimeType: 'image/png' + mimeType: 'image/jpeg' }) - ).toBe(`data:image/png;base64,${originalContent}`) + ).toBe('data:image/jpeg;base64,b2xk') }) // The reviewer's read-failure case: a relay/SSH read returns an empty modified diff --git a/mobile/src/files/mobile-directory-cache-retention.test.ts b/mobile/src/files/mobile-directory-cache-retention.test.ts deleted file mode 100644 index 421d995492d..00000000000 --- a/mobile/src/files/mobile-directory-cache-retention.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { DirectoryCache, DirectoryState } from './file-tree' -import { - MOBILE_DIRECTORY_CACHE_MAX_DIRECTORIES, - parseBoundedMobileDirectoryEntries, - removeEvictedExpandedPaths, - retainMobileDirectoryState -} from './mobile-directory-cache-retention' - -function state(name: string, lastAccess: number): DirectoryState { - return { entries: [{ name, isDirectory: false }], lastAccess } -} - -describe('mobile directory cache retention', () => { - it('keeps the cache bounded across an unlimited sequence of visited directories', () => { - let cache: DirectoryCache = { '': state('root', 0) } - - for (let index = 1; index <= MOBILE_DIRECTORY_CACHE_MAX_DIRECTORIES * 3; index++) { - const result = retainMobileDirectoryState( - cache, - `dir-${index}`, - state(`file-${index}`, index), - new Set() - ) - expect(result.admitted).toBe(true) - cache = result.cache - } - - expect(Object.keys(cache)).toHaveLength(MOBILE_DIRECTORY_CACHE_MAX_DIRECTORIES) - expect(cache['']).toBeDefined() - expect(cache['dir-1']).toBeUndefined() - expect(cache[`dir-${MOBILE_DIRECTORY_CACHE_MAX_DIRECTORIES * 3}`]).toBeDefined() - }) - - it('evicts a collapsed branch before an older expanded branch', () => { - const cache: DirectoryCache = { - '': state('root', 0), - expanded: state('expanded', 1), - collapsed: state('collapsed', 2) - } - - const result = retainMobileDirectoryState( - cache, - 'new', - state('new', 3), - new Set(['expanded']), - { directories: 3, entries: 100, retainedBytes: 10_000 } - ) - - expect(result.cache.expanded).toBeDefined() - expect(result.cache.collapsed).toBeUndefined() - expect(result.evictedPaths).toEqual(['collapsed']) - }) - - it('evicts old content when aggregate entry retention reaches its cap', () => { - const cache: DirectoryCache = { - '': state('root', 0), - old: state('old', 1) - } - - const result = retainMobileDirectoryState(cache, 'new', state('new', 2), new Set(), { - directories: 10, - entries: 2, - retainedBytes: 10_000 - }) - - expect(result.cache.old).toBeUndefined() - expect(result.cache.new).toBeDefined() - }) - - it('evicts old content when aggregate retained bytes reach their cap', () => { - const cache: DirectoryCache = { - '': state('root', 0), - old: state('x'.repeat(100), 1) - } - - const result = retainMobileDirectoryState(cache, 'new', state('new', 2), new Set(), { - directories: 10, - entries: 100, - retainedBytes: 300 - }) - - expect(result.cache.old).toBeUndefined() - expect(result.cache.new).toBeDefined() - }) - - it('collapses expanded descendants whose cached branch was evicted', () => { - expect([...removeEvictedExpandedPaths(new Set(['src', 'src/lib', 'docs']), ['src'])]).toEqual([ - 'docs' - ]) - }) - - it('rejects malformed listings instead of retaining untrusted response shapes', () => { - expect(() => parseBoundedMobileDirectoryEntries([{ name: 'src' }])).toThrow( - 'invalid folder listing' - ) - }) -}) diff --git a/mobile/src/files/mobile-directory-cache-retention.ts b/mobile/src/files/mobile-directory-cache-retention.ts deleted file mode 100644 index b6ec4b9748b..00000000000 --- a/mobile/src/files/mobile-directory-cache-retention.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { - assertMobileFileDirectoryWithinLimit, - estimateMobileDirectoryEntryBytes, - MOBILE_FILE_DIRECTORY_LIMIT_MESSAGE, - MOBILE_FILE_DIRECTORY_MAX_ENTRIES -} from '../../../src/shared/mobile-file-directory-limit' -import type { DirectoryCache, DirectoryState, MobileDirEntry } from './file-tree' - -// Why: old collapsed branches can reload on demand once explorer metadata reaches a phone-safe ceiling. -export const MOBILE_DIRECTORY_CACHE_MAX_DIRECTORIES = 128 -export const MOBILE_DIRECTORY_CACHE_MAX_ENTRIES = 25_000 -export const MOBILE_DIRECTORY_CACHE_MAX_RETAINED_BYTES = 16 * 1024 * 1024 -export const MOBILE_DIRECTORY_CACHE_LIMIT_MESSAGE = - 'Too many folders are open to load this folder safely. Close another folder and retry.' - -type CacheLimits = { - directories: number - entries: number - retainedBytes: number -} - -type RetentionResult = { - cache: DirectoryCache - evictedPaths: string[] - admitted: boolean -} - -const DEFAULT_LIMITS: CacheLimits = { - directories: MOBILE_DIRECTORY_CACHE_MAX_DIRECTORIES, - entries: MOBILE_DIRECTORY_CACHE_MAX_ENTRIES, - retainedBytes: MOBILE_DIRECTORY_CACHE_MAX_RETAINED_BYTES -} - -export function parseBoundedMobileDirectoryEntries(value: unknown): MobileDirEntry[] { - if (!Array.isArray(value)) { - throw new Error('Desktop returned an invalid folder listing.') - } - if (value.length > MOBILE_FILE_DIRECTORY_MAX_ENTRIES) { - throw new Error(MOBILE_FILE_DIRECTORY_LIMIT_MESSAGE) - } - for (const entry of value) { - if (!isMobileDirectoryEntry(entry)) { - throw new Error('Desktop returned an invalid folder listing.') - } - } - const entries = value as MobileDirEntry[] - assertMobileFileDirectoryWithinLimit(entries) - return entries -} - -export function retainMobileDirectoryState( - cache: DirectoryCache, - relativePath: string, - state: DirectoryState, - expandedPaths: ReadonlySet, - limits: CacheLimits = DEFAULT_LIMITS -): RetentionResult { - const next: DirectoryCache = { ...cache, [relativePath]: state } - const evictedPaths: string[] = [] - const essentialPaths = directoryAncestors(relativePath) - - while (cacheExceedsLimits(next, limits)) { - const victim = selectEvictionPath(next, expandedPaths, essentialPaths) - if (victim === null) { - return { cache, evictedPaths: [], admitted: false } - } - for (const path of Object.keys(next)) { - if (path === victim || path.startsWith(`${victim}/`)) { - delete next[path] - evictedPaths.push(path) - } - } - } - return { cache: next, evictedPaths, admitted: true } -} - -export function removeEvictedExpandedPaths( - expandedPaths: ReadonlySet, - evictedPaths: readonly string[] -): Set { - if (evictedPaths.length === 0) { - return new Set(expandedPaths) - } - return new Set( - [...expandedPaths].filter( - (expanded) => - !evictedPaths.some((evicted) => expanded === evicted || expanded.startsWith(`${evicted}/`)) - ) - ) -} - -function isMobileDirectoryEntry(value: unknown): value is MobileDirEntry { - if (!value || typeof value !== 'object') { - return false - } - const entry = value as Record - return ( - typeof entry.name === 'string' && - typeof entry.isDirectory === 'boolean' && - (entry.isSymlink === undefined || typeof entry.isSymlink === 'boolean') - ) -} - -function directoryAncestors(relativePath: string): Set { - const ancestors = new Set(['', relativePath]) - let cursor = relativePath - while (cursor.includes('/')) { - cursor = cursor.slice(0, cursor.lastIndexOf('/')) - ancestors.add(cursor) - } - return ancestors -} - -function selectEvictionPath( - cache: DirectoryCache, - expandedPaths: ReadonlySet, - essentialPaths: ReadonlySet -): string | null { - const candidates = Object.keys(cache) - .filter((path) => !essentialPaths.has(path)) - .sort((left, right) => accessOrder(cache, left) - accessOrder(cache, right)) - return candidates.find((path) => !expandedPaths.has(path)) ?? candidates[0] ?? null -} - -function accessOrder(cache: DirectoryCache, path: string): number { - return cache[path]?.lastAccess ?? 0 -} - -function cacheExceedsLimits(cache: DirectoryCache, limits: CacheLimits): boolean { - const paths = Object.keys(cache) - if (paths.length > limits.directories) { - return true - } - let entries = 0 - let retainedBytes = 0 - for (const path of paths) { - const state = cache[path] - if (!state) { - continue - } - entries += state.entries.length - retainedBytes += estimateDirectoryStateBytes(path, state) - if (entries > limits.entries || retainedBytes > limits.retainedBytes) { - return true - } - } - return false -} - -function estimateDirectoryStateBytes(path: string, state: DirectoryState): number { - let bytes = path.length * 2 + (state.error?.length ?? 0) * 2 + 64 - for (const entry of state.entries) { - bytes += estimateMobileDirectoryEntryBytes(entry) - } - return bytes -} diff --git a/mobile/src/files/mobile-file-preview-request.test.ts b/mobile/src/files/mobile-file-preview-request.test.ts index 185962bad72..f7f77fabd19 100644 --- a/mobile/src/files/mobile-file-preview-request.test.ts +++ b/mobile/src/files/mobile-file-preview-request.test.ts @@ -28,16 +28,6 @@ function clientWithResponses(responses: RpcResponse[]) { } } -function pngBase64(width = 1, height = 1): string { - const bytes = Buffer.alloc(24) - Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(bytes) - bytes.writeUInt32BE(13, 8) - bytes.write('IHDR', 12, 'ascii') - bytes.writeUInt32BE(width, 16) - bytes.writeUInt32BE(height, 20) - return bytes.toString('base64') -} - describe('mobile-file-preview-request', () => { it('selects readPreview for raster images and read for text-like files', () => { expect(createMobileFilePreviewRequest('wt-1', 'assets/logo.png')).toEqual({ @@ -55,13 +45,14 @@ describe('mobile-file-preview-request', () => { }) it('loads images through readPreview and never calls files.open', async () => { - const content = pngBase64() - const client = clientWith(ok({ content, isBinary: true, isImage: true, mimeType: 'image/png' })) + const client = clientWith( + ok({ content: 'aW1hZ2U=', isBinary: true, isImage: true, mimeType: 'image/png' }) + ) await expect(loadMobileFilePreview(client, 'wt-1', 'assets/logo.png')).resolves.toEqual({ status: 'ready', kind: 'image', - dataUri: `data:image/png;base64,${content}` + dataUri: 'data:image/png;base64,aW1hZ2U=' }) expect(client.sendRequest).toHaveBeenCalledWith('files.readPreview', { worktree: 'id:wt-1', @@ -536,9 +527,9 @@ describe('mobile-file-preview-request', () => { }) it.each([ - ['missing isBinary', { content: pngBase64(), isImage: true, mimeType: 'image/png' }], - ['missing isImage', { content: pngBase64(), isBinary: true, mimeType: 'image/png' }], - ['missing mimeType', { content: pngBase64(), isBinary: true, isImage: true }], + ['missing isBinary', { content: 'aW1hZ2U=', isImage: true, mimeType: 'image/png' }], + ['missing isImage', { content: 'aW1hZ2U=', isBinary: true, mimeType: 'image/png' }], + ['missing mimeType', { content: 'aW1hZ2U=', isBinary: true, isImage: true }], ['empty content', { content: '', isBinary: true, isImage: true, mimeType: 'image/png' }] ])('rejects invalid image preview results: %s', (_label, result) => { expect(normalizeMobileFilePreviewResponse('assets/logo.png', ok(result))).toEqual({ @@ -548,24 +539,6 @@ describe('mobile-file-preview-request', () => { }) }) - it('rejects an oversized raster response before React Native receives a data URI', () => { - expect( - normalizeMobileFilePreviewResponse( - 'assets/logo.png', - ok({ - content: pngBase64(32_769, 1), - isBinary: true, - isImage: true, - mimeType: 'image/png' - }) - ) - ).toEqual({ - status: 'error', - message: 'Binary preview unavailable', - reconnect: false - }) - }) - it('normalizes markdown, html, text, empty, and truncated reads', () => { expect( normalizeMobileFilePreviewResponse( diff --git a/mobile/src/files/mobile-file-preview-response.ts b/mobile/src/files/mobile-file-preview-response.ts index 8f57a92edf7..0b7b1b23be8 100644 --- a/mobile/src/files/mobile-file-preview-response.ts +++ b/mobile/src/files/mobile-file-preview-response.ts @@ -2,7 +2,6 @@ import { classifyMobileArtifact } from '../session/mobile-artifact-kind' import type { RpcFailure, RpcResponse, RpcSuccess } from '../transport/types' import { isMarkdownPath } from './file-tree' import { isTerminalArtifactGrantError } from './terminal-artifact-grant-error' -import { buildImageDataUri } from '../../../src/shared/image-data-uri' export type MobileFilePreviewTextKind = 'html' | 'markdown' | 'text' @@ -118,14 +117,10 @@ function normalizeImagePreviewResult(result: unknown): MobileFilePreviewResult { ) { return previewError('binary_file') } - const dataUri = buildImageDataUri(preview.mimeType, preview.content) - if (!dataUri) { - return previewError('binary_file') - } return { status: 'ready', kind: 'image', - dataUri + dataUri: `data:${preview.mimeType};base64,${preview.content}` } } diff --git a/mobile/src/files/mobile-file-tab-doc.test.ts b/mobile/src/files/mobile-file-tab-doc.test.ts index cfe5578a7e0..a9570e6cf44 100644 --- a/mobile/src/files/mobile-file-tab-doc.test.ts +++ b/mobile/src/files/mobile-file-tab-doc.test.ts @@ -31,16 +31,6 @@ function clientOf(byMethod: Record): { const WT = { worktreeId: 'wt1' } -function pngBase64(width = 1): string { - const bytes = Buffer.alloc(24) - Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(bytes) - bytes.writeUInt32BE(13, 8) - bytes.write('IHDR', 12, 'ascii') - bytes.writeUInt32BE(width, 16) - bytes.writeUInt32BE(1, 20) - return bytes.toString('base64') -} - describe('resolveMobileFileTabDoc', () => { it('renders a staged text diff', async () => { const client = clientOf({ @@ -56,12 +46,11 @@ describe('resolveMobileFileTabDoc', () => { }) it('renders an unstaged image diff from the modified bytes', async () => { - const modifiedContent = pngBase64() const client = clientOf({ 'git.diff': ok({ kind: 'binary', originalContent: 'b2xk', - modifiedContent, + modifiedContent: 'bmV3', modifiedIsBinary: true, isImage: true, mimeType: 'image/png' @@ -72,11 +61,7 @@ describe('resolveMobileFileTabDoc', () => { relativePath: 'm1.png', diffSource: 'unstaged' }) - expect(doc).toEqual({ - status: 'ready', - kind: 'image', - dataUri: `data:image/png;base64,${modifiedContent}` - }) + expect(doc).toEqual({ status: 'ready', kind: 'image', dataUri: 'data:image/png;base64,bmV3' }) }) it('throws binary_file for an image modify whose bytes are empty (no stale fallback)', async () => { @@ -103,16 +88,11 @@ describe('resolveMobileFileTabDoc', () => { }) it('renders a live image preview via files.readPreview', async () => { - const content = pngBase64() const client = clientOf({ - 'files.readPreview': ok({ content, isImage: true, mimeType: 'image/png' }) + 'files.readPreview': ok({ content: 'bmV3', isImage: true, mimeType: 'image/png' }) }) const doc = await resolveMobileFileTabDoc(client, { ...WT, relativePath: 'logo.png' }) - expect(doc).toEqual({ - status: 'ready', - kind: 'image', - dataUri: `data:image/png;base64,${content}` - }) + expect(doc).toEqual({ status: 'ready', kind: 'image', dataUri: 'data:image/png;base64,bmV3' }) expect(client.calls).toEqual(['files.readPreview']) }) diff --git a/mobile/src/hooks/mobile-dictation-audio-chunk.test.ts b/mobile/src/hooks/mobile-dictation-audio-chunk.test.ts deleted file mode 100644 index 69fa92f0265..00000000000 --- a/mobile/src/hooks/mobile-dictation-audio-chunk.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import type { RpcClient } from '../transport/rpc-client' -import { - enqueueMobileDictationAudioChunk, - MOBILE_DICTATION_MAX_PENDING_CHUNKS -} from './mobile-dictation-audio-chunk' -import { MobileDictationPendingAudioBudget } from './mobile-dictation-pending-audio-budget' - -describe('enqueueMobileDictationAudioChunk', () => { - it('accepts the exact pending-promise cap and rejects one over even for empty chunks', () => { - const sendRequest = vi.fn(() => new Promise(() => undefined)) - const failActiveDictation = vi.fn() - const pendingChunks = new Set>() - const queue = { - pendingChunks, - pendingAudioBudget: new MobileDictationPendingAudioBudget(), - shouldReleaseBudget: () => true, - failActiveDictation - } - const event = { data: new Uint8Array() } - - for (let index = 0; index < MOBILE_DICTATION_MAX_PENDING_CHUNKS; index += 1) { - enqueueMobileDictationAudioChunk( - { sendRequest } as unknown as RpcClient, - 'dictation', - event, - queue - ) - } - expect(pendingChunks).toHaveLength(MOBILE_DICTATION_MAX_PENDING_CHUNKS) - expect(failActiveDictation).not.toHaveBeenCalled() - - enqueueMobileDictationAudioChunk( - { sendRequest } as unknown as RpcClient, - 'dictation', - event, - queue - ) - expect(pendingChunks).toHaveLength(MOBILE_DICTATION_MAX_PENDING_CHUNKS) - expect(failActiveDictation).toHaveBeenCalledOnce() - }) -}) diff --git a/mobile/src/hooks/mobile-dictation-audio-chunk.ts b/mobile/src/hooks/mobile-dictation-audio-chunk.ts index 947deee0e61..792292e6c16 100644 --- a/mobile/src/hooks/mobile-dictation-audio-chunk.ts +++ b/mobile/src/hooks/mobile-dictation-audio-chunk.ts @@ -14,21 +14,12 @@ type MobileDictationAudioChunkQueue = { failActiveDictation: (dictationId: string, err: unknown) => void } -export const MOBILE_DICTATION_MAX_PENDING_CHUNKS = 256 - export function enqueueMobileDictationAudioChunk( client: RpcClient, dictationId: string, event: MicrophoneDataEvent, queue: MobileDictationAudioChunkQueue ): void { - if (queue.pendingChunks.size >= MOBILE_DICTATION_MAX_PENDING_CHUNKS) { - queue.failActiveDictation( - dictationId, - new Error(MOBILE_DICTATION_CONNECTION_SLOW_ERROR_MESSAGE) - ) - return - } const raw = event.data const bytes = raw instanceof Uint8Array ? raw : new Uint8Array(raw) const byteLength = bytes.byteLength diff --git a/mobile/src/hooks/mobile-dictation-keep-awake.test.ts b/mobile/src/hooks/mobile-dictation-keep-awake.test.ts index 4bb2803def4..362f3a0cf68 100644 --- a/mobile/src/hooks/mobile-dictation-keep-awake.test.ts +++ b/mobile/src/hooks/mobile-dictation-keep-awake.test.ts @@ -11,11 +11,9 @@ vi.mock('expo-keep-awake', () => ({ })) import { - MOBILE_DICTATION_KEEP_AWAKE_MAX_TRACKED_TAGS, MOBILE_DICTATION_KEEP_AWAKE_NATIVE_TIMEOUT_MS, MobileDictationKeepAwakeOwner, - drainMobileDictationKeepAwakeCleanup, - resetMobileDictationKeepAwakeForTests + drainMobileDictationKeepAwakeCleanup } from './mobile-dictation-keep-awake' function deferred(): { @@ -38,33 +36,10 @@ function deferred(): { describe('MobileDictationKeepAwakeOwner', () => { beforeEach(() => { - resetMobileDictationKeepAwakeForTests() keepAwake.activate.mockReset().mockResolvedValue(undefined) keepAwake.deactivate.mockReset().mockResolvedValue(undefined) }) - it('accepts the exact tracked-tag cap, rejects one over, and recovers after release', async () => { - const owners = Array.from( - { length: MOBILE_DICTATION_KEEP_AWAKE_MAX_TRACKED_TAGS }, - () => new MobileDictationKeepAwakeOwner() - ) - await Promise.all(owners.map((owner, index) => owner.acquire(`dictation-${index}`))) - expect(keepAwake.activate).toHaveBeenCalledTimes(MOBILE_DICTATION_KEEP_AWAKE_MAX_TRACKED_TAGS) - - const oneOver = new MobileDictationKeepAwakeOwner() - await expect(oneOver.acquire('one-over')).rejects.toThrow( - 'Too many dictation keep-awake operations are pending' - ) - expect(keepAwake.activate).toHaveBeenCalledTimes(MOBILE_DICTATION_KEEP_AWAKE_MAX_TRACKED_TAGS) - - await owners[0]!.release('dictation-0') - await expect(oneOver.reacquire('one-over')).resolves.toBeUndefined() - await Promise.all([ - ...owners.slice(1).map((owner, index) => owner.release(`dictation-${index + 1}`)), - oneOver.release('one-over') - ]) - }) - it('retries a failed native deactivation after the hook owner is replaced', async () => { const firstOwner = new MobileDictationKeepAwakeOwner() diff --git a/mobile/src/hooks/mobile-dictation-keep-awake.ts b/mobile/src/hooks/mobile-dictation-keep-awake.ts index 08f93ac98be..f94e3fb785a 100644 --- a/mobile/src/hooks/mobile-dictation-keep-awake.ts +++ b/mobile/src/hooks/mobile-dictation-keep-awake.ts @@ -5,7 +5,6 @@ const MOBILE_DICTATION_KEEP_AWAKE_TAG_PREFIX = 'orca-mobile-dictation' // Native keep-awake promises can be lost during Activity teardown; a bounded // wait keeps the serialized queue below from wedging dictation until restart. export const MOBILE_DICTATION_KEEP_AWAKE_NATIVE_TIMEOUT_MS = 10_000 -export const MOBILE_DICTATION_KEEP_AWAKE_MAX_TRACKED_TAGS = 128 let nextOwnerId = 0 let keepAwakeOperation: Promise = Promise.resolve() @@ -52,34 +51,7 @@ function withNativeCallTimeout(nativeCall: Promise): Promise { }) } -function trackedTagCount(): number { - let count = activeTags.size - for (const tag of pendingCleanupTags) { - if (!activeTags.has(tag)) { - count += 1 - } - } - for (const tag of pendingActivations.keys()) { - if (!activeTags.has(tag) && !pendingCleanupTags.has(tag)) { - count += 1 - } - } - return count -} - -function assertTrackedTagCapacity(tag: string): void { - if ( - !activeTags.has(tag) && - !pendingCleanupTags.has(tag) && - !pendingActivations.has(tag) && - trackedTagCount() >= MOBILE_DICTATION_KEEP_AWAKE_MAX_TRACKED_TAGS - ) { - throw new Error('Too many dictation keep-awake operations are pending') - } -} - async function activateTrackedTag(tag: string, isStillWanted: () => boolean): Promise { - assertTrackedTagCapacity(tag) const nativeActivation = activateKeepAwakeAsync(tag) try { await withNativeCallTimeout(nativeActivation) @@ -262,12 +234,3 @@ export function createMobileDictationKeepAwakeOwner(): MobileDictationKeepAwakeO export function drainMobileDictationKeepAwakeCleanup(): Promise { return enqueueKeepAwakeOperation(cleanupPendingTags) } - -/** Test-only: drop retained native-operation bookkeeping between cases. */ -export function resetMobileDictationKeepAwakeForTests(): void { - nextOwnerId = 0 - keepAwakeOperation = Promise.resolve() - activeTags.clear() - pendingCleanupTags.clear() - pendingActivations.clear() -} diff --git a/mobile/src/notifications/mobile-notification-delivery.ts b/mobile/src/notifications/mobile-notification-delivery.ts deleted file mode 100644 index 60bc572e291..00000000000 --- a/mobile/src/notifications/mobile-notification-delivery.ts +++ /dev/null @@ -1,203 +0,0 @@ -import * as Notifications from 'expo-notifications' -import { Platform } from 'react-native' -import { loadPushNotificationsEnabled } from '../storage/preferences' -import { buildLocalNotificationData, type DesktopNotificationSource } from './notification-routing' -import { MobileNotificationDeliveryLedger } from './mobile-notification-retention' -import { - MobileScheduledNotificationRegistry, - type MobileScheduledNotificationState -} from './mobile-scheduled-notification-registry' - -export type NotificationEvent = { - type: 'notification' - source: DesktopNotificationSource - title: string - body: string - worktreeId?: string - notificationId?: string - notificationSeq?: number -} - -export type DismissNotificationEvent = { - type: 'dismiss' - notificationId: string - notificationSeq?: number -} - -const scheduledNotifications = new MobileScheduledNotificationRegistry() -const notificationDeliveryLedger = new MobileNotificationDeliveryLedger() - -function getStoredNotificationKey(hostId: string, notificationId: string): string { - return `${encodeURIComponent(hostId)}:${encodeURIComponent(notificationId)}` -} - -/** Test-only: override the cap (pass no arg to restore the default). */ -export function setScheduledNotificationsMaxForTests(max?: number): void { - scheduledNotifications.resetForTests(max) - notificationDeliveryLedger.resetForTests(max) -} - -export type NotificationPermissionState = { - granted: boolean - status: string - canAskAgain: boolean - authorizationReflectsUserChoice: boolean -} - -export async function getNotificationPermissionState(): Promise { - const { status, canAskAgain } = await Notifications.getPermissionsAsync() - return { - granted: status === 'granted', - status, - canAskAgain, - // Why: Android <33 has no runtime notification permission, so "granted" is capability, not user consent. - authorizationReflectsUserChoice: - status === 'granted' && (Platform.OS !== 'android' || Number(Platform.Version) >= 33) - } -} - -// Why: re-read OS state every call — users can change it in Settings while Orca is backgrounded. -export async function ensureNotificationPermissions(): Promise { - const existing = await getNotificationPermissionState() - if (existing.granted) { - return true - } - const { status } = await Notifications.requestPermissionsAsync() - return status === 'granted' -} - -export function configureNotificationChannel(): void { - if (Platform.OS === 'android') { - void Notifications.setNotificationChannelAsync('orca-desktop', { - name: 'Desktop Notifications', - importance: Notifications.AndroidImportance.HIGH, - vibrationPattern: [0, 250], - lightColor: '#6366f1' - }) - } -} - -async function showLocalNotification(event: NotificationEvent, hostId: string): Promise { - const storedKey = event.notificationId - ? getStoredNotificationKey(hostId, event.notificationId) - : null - if (!storedKey) { - if (!(await loadPushNotificationsEnabled()) || !(await ensureNotificationPermissions())) { - return - } - await scheduleLocalNotification(event, hostId) - return - } - - let state = scheduledNotifications.get(storedKey) - let evictedIdentifiers: string[] = [] - if (state?.pending) { - return - } - if (!state) { - const reservation = scheduledNotifications.reserve(storedKey) - if (!reservation) { - return - } - state = reservation.state - evictedIdentifiers = reservation.evictedIdentifiers - } - const notificationState = state - const pending = scheduleTrackedNotification(event, hostId, notificationState, evictedIdentifiers) - notificationState.pending = pending - - try { - const scheduledIdentifier = await pending - if (!scheduledIdentifier) { - if (!notificationState.identifier) { - scheduledNotifications.delete(storedKey) - } - return - } - if (notificationState.dismissAfterSchedule) { - notificationState.dismissAfterSchedule = false - scheduledNotifications.delete(storedKey) - await Notifications.dismissNotificationAsync(scheduledIdentifier).catch(() => {}) - return - } - if (!scheduledNotifications.retainIdentifier(notificationState, scheduledIdentifier)) { - scheduledNotifications.delete(storedKey) - await Notifications.dismissNotificationAsync(scheduledIdentifier).catch(() => {}) - } - } finally { - if (notificationState.pending === pending) { - notificationState.pending = undefined - notificationState.dismissAfterSchedule = false - } - } -} - -async function scheduleTrackedNotification( - event: NotificationEvent, - hostId: string, - state: MobileScheduledNotificationState, - evictedIdentifiers: string[] -): Promise { - // Why: retaining the native id makes a later desktop dismiss work; eviction must close it before slot reuse. - for (const identifier of evictedIdentifiers) { - await Notifications.dismissNotificationAsync(identifier).catch(() => {}) - } - if (!(await loadPushNotificationsEnabled()) || !(await ensureNotificationPermissions())) { - return null - } - if (state.identifier) { - await Notifications.dismissNotificationAsync(state.identifier).catch(() => {}) - scheduledNotifications.clearIdentifier(state) - } - return scheduleLocalNotification(event, hostId) -} - -function scheduleLocalNotification(event: NotificationEvent, hostId: string): Promise { - return Notifications.scheduleNotificationAsync({ - content: { - title: event.title, - body: event.body, - data: buildLocalNotificationData(event, hostId), - ...(Platform.OS === 'android' ? { channelId: 'orca-desktop' } : {}) - }, - trigger: null - }) -} - -async function dismissLocalNotification( - event: DismissNotificationEvent, - hostId: string -): Promise { - const storedKey = getStoredNotificationKey(hostId, event.notificationId) - const state = scheduledNotifications.get(storedKey) - if (!state) { - return - } - if (state.pending) { - // Why: dismiss can arrive while the OS is still scheduling; defer it so no stale banner survives. - state.dismissAfterSchedule = true - return - } - if (!state.identifier) { - return - } - scheduledNotifications.delete(storedKey) - await Notifications.dismissNotificationAsync(state.identifier).catch(() => {}) -} - -export function startLocalNotificationDelivery( - event: NotificationEvent | DismissNotificationEvent, - hostId: string -): Promise | null { - const releaseDelivery = notificationDeliveryLedger.claim(event, hostId) - if (!releaseDelivery) { - return null - } - const delivery = - event.type === 'notification' - ? showLocalNotification(event, hostId) - : dismissLocalNotification(event, hostId) - return delivery.finally(() => { - releaseDelivery() - }) -} diff --git a/mobile/src/notifications/mobile-notification-retention.test.ts b/mobile/src/notifications/mobile-notification-retention.test.ts deleted file mode 100644 index a2fb3e52a22..00000000000 --- a/mobile/src/notifications/mobile-notification-retention.test.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - MOBILE_NOTIFICATION_ACTIVE_MAX_BYTES, - MOBILE_NOTIFICATION_EVENT_MAX_BYTES, - MOBILE_NOTIFICATION_HOST_ID_MAX_BYTES, - MOBILE_NOTIFICATION_ID_MAX_BYTES, - MobileNotificationDeliveryLedger, - measureMobileNotificationDeliveryBytes -} from './mobile-notification-retention' -import { - MOBILE_NATIVE_NOTIFICATION_ID_MAX_BYTES, - MOBILE_SCHEDULED_NOTIFICATION_MAX_RETAINED_BYTES, - MobileScheduledNotificationRegistry -} from './mobile-scheduled-notification-registry' - -describe('mobile notification retention', () => { - it('accepts an exact-limit event and rejects one more byte', () => { - const baseEvent = { - type: 'notification', - source: 'test', - title: '', - body: '', - notificationId: 'notification-1' - } - const baseBytes = measureMobileNotificationDeliveryBytes(baseEvent, 'host-1')! - const exactEvent = { - ...baseEvent, - body: 'x'.repeat(MOBILE_NOTIFICATION_EVENT_MAX_BYTES - baseBytes) - } - - expect(measureMobileNotificationDeliveryBytes(exactEvent, 'host-1')).toBe( - MOBILE_NOTIFICATION_EVENT_MAX_BYTES - ) - expect( - measureMobileNotificationDeliveryBytes( - { ...exactEvent, body: `${exactEvent.body}x` }, - 'host-1' - ) - ).toBeNull() - }) - - it('bounds host and notification identifiers before URI key expansion', () => { - const event = { - type: 'dismiss', - notificationId: 'n'.repeat(MOBILE_NOTIFICATION_ID_MAX_BYTES) - } - - expect( - measureMobileNotificationDeliveryBytes( - event, - 'h'.repeat(MOBILE_NOTIFICATION_HOST_ID_MAX_BYTES) - ) - ).not.toBeNull() - expect( - measureMobileNotificationDeliveryBytes( - { ...event, notificationId: `${event.notificationId}n` }, - 'host-1' - ) - ).toBeNull() - expect( - measureMobileNotificationDeliveryBytes( - event, - 'h'.repeat(MOBILE_NOTIFICATION_HOST_ID_MAX_BYTES + 1) - ) - ).toBeNull() - }) - - it('caps aggregate active bytes and restores capacity on release', () => { - const ledger = new MobileNotificationDeliveryLedger() - const baseEvent = { type: 'notification', title: '', body: '' } - const baseBytes = measureMobileNotificationDeliveryBytes(baseEvent, 'host-1')! - const exactEvent = { - ...baseEvent, - body: 'x'.repeat(MOBILE_NOTIFICATION_EVENT_MAX_BYTES - baseBytes) - } - const exactClaims = MOBILE_NOTIFICATION_ACTIVE_MAX_BYTES / MOBILE_NOTIFICATION_EVENT_MAX_BYTES - const releases = Array.from({ length: exactClaims }, () => ledger.claim(exactEvent, 'host-1')) - - expect(releases.every(Boolean)).toBe(true) - expect(ledger.claim(exactEvent, 'host-1')).toBeNull() - releases[0]?.() - expect(ledger.claim(exactEvent, 'host-1')).not.toBeNull() - }) - - it('rejects a scheduled key that alone exceeds the aggregate budget', () => { - const registry = new MobileScheduledNotificationRegistry() - const maximumKeyCharacters = (MOBILE_SCHEDULED_NOTIFICATION_MAX_RETAINED_BYTES - 64) / 2 - - expect(registry.reserve('x'.repeat(maximumKeyCharacters))).not.toBeNull() - registry.resetForTests() - expect(registry.reserve('x'.repeat(maximumKeyCharacters + 1))).toBeNull() - }) - - it('retains exact-limit native ids and rejects oversized values', () => { - const registry = new MobileScheduledNotificationRegistry() - const exactState = registry.reserve('exact')!.state - const oversizedState = registry.reserve('oversized')!.state - - expect( - registry.retainIdentifier(exactState, 'x'.repeat(MOBILE_NATIVE_NOTIFICATION_ID_MAX_BYTES)) - ).toBe(true) - expect( - registry.retainIdentifier( - oversizedState, - 'x'.repeat(MOBILE_NATIVE_NOTIFICATION_ID_MAX_BYTES + 1) - ) - ).toBe(false) - expect(registry.getRetainedBytesForTests()).toBeLessThanOrEqual( - MOBILE_SCHEDULED_NOTIFICATION_MAX_RETAINED_BYTES - ) - }) -}) diff --git a/mobile/src/notifications/mobile-notification-retention.ts b/mobile/src/notifications/mobile-notification-retention.ts deleted file mode 100644 index c68f11b66b5..00000000000 --- a/mobile/src/notifications/mobile-notification-retention.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { measureUtf8ByteLength } from '../../../src/shared/utf8-byte-limits' - -export const MOBILE_NOTIFICATION_EVENT_MAX_BYTES = 256 * 1024 -export const MOBILE_NOTIFICATION_ACTIVE_MAX_BYTES = 4 * 1024 * 1024 -export const MOBILE_NOTIFICATION_ACTIVE_MAX_ENTRIES = 256 -export const MOBILE_NOTIFICATION_HOST_ID_MAX_BYTES = 8 * 1024 -export const MOBILE_NOTIFICATION_ID_MAX_BYTES = 8 * 1024 -export const MOBILE_NOTIFICATION_WORKTREE_ID_MAX_BYTES = 16 * 1024 - -type MobileNotificationRetentionEvent = { - type: string - source?: string - title?: string - body?: string - worktreeId?: string - notificationId?: string -} - -function boundedStringBytes(value: unknown, maxBytes: number): number | null { - if (value === undefined) { - return 0 - } - if (typeof value !== 'string') { - return null - } - const measured = measureUtf8ByteLength(value, { stopAfterBytes: maxBytes }) - return measured.exceededLimit ? null : measured.byteLength -} - -export function isMobileNotificationHostIdRetainable(hostId: string): boolean { - return boundedStringBytes(hostId, MOBILE_NOTIFICATION_HOST_ID_MAX_BYTES) !== null -} - -export function measureMobileNotificationDeliveryBytes( - event: MobileNotificationRetentionEvent, - hostId: string -): number | null { - const hostBytes = boundedStringBytes(hostId, MOBILE_NOTIFICATION_HOST_ID_MAX_BYTES) - const notificationIdBytes = boundedStringBytes( - event.notificationId, - MOBILE_NOTIFICATION_ID_MAX_BYTES - ) - const worktreeIdBytes = boundedStringBytes( - event.worktreeId, - MOBILE_NOTIFICATION_WORKTREE_ID_MAX_BYTES - ) - if (hostBytes === null || notificationIdBytes === null || worktreeIdBytes === null) { - return null - } - - let retainedBytes = 256 + hostBytes + notificationIdBytes + worktreeIdBytes - for (const value of [event.type, event.source, event.title, event.body]) { - const remaining = MOBILE_NOTIFICATION_EVENT_MAX_BYTES - retainedBytes - const valueBytes = boundedStringBytes(value, remaining) - if (valueBytes === null) { - return null - } - retainedBytes += valueBytes - } - return retainedBytes <= MOBILE_NOTIFICATION_EVENT_MAX_BYTES ? retainedBytes : null -} - -export class MobileNotificationDeliveryLedger { - private activeEntries = 0 - private activeBytes = 0 - private maxEntries = MOBILE_NOTIFICATION_ACTIVE_MAX_ENTRIES - - claim(event: MobileNotificationRetentionEvent, hostId: string): (() => void) | null { - const retainedBytes = measureMobileNotificationDeliveryBytes(event, hostId) - if ( - retainedBytes === null || - this.activeEntries >= this.maxEntries || - this.activeBytes + retainedBytes > MOBILE_NOTIFICATION_ACTIVE_MAX_BYTES - ) { - return null - } - this.activeEntries += 1 - this.activeBytes += retainedBytes - let released = false - return () => { - if (released) { - return - } - released = true - this.activeEntries = Math.max(0, this.activeEntries - 1) - this.activeBytes = Math.max(0, this.activeBytes - retainedBytes) - } - } - - resetForTests(maxEntries?: number): void { - this.activeEntries = 0 - this.activeBytes = 0 - this.maxEntries = maxEntries ?? MOBILE_NOTIFICATION_ACTIVE_MAX_ENTRIES - } -} diff --git a/mobile/src/notifications/mobile-notifications.test.ts b/mobile/src/notifications/mobile-notifications.test.ts index 5fce317b915..b1f7c89578e 100644 --- a/mobile/src/notifications/mobile-notifications.test.ts +++ b/mobile/src/notifications/mobile-notifications.test.ts @@ -313,11 +313,9 @@ describe('subscribeToDesktopNotifications', () => { // Why: notificationId is unique per completion, so the map grew unbounded when // the desktop never sent a dismiss (the remote-mobile case). It is now capped. - it('dismisses the oldest scheduled entry when reusing its bounded slot', async () => { + it('evicts the oldest scheduled entry once the cap is exceeded', async () => { setScheduledNotificationsMaxForTests(1) try { - vi.mocked(Notifications.scheduleNotificationAsync).mockReset() - vi.mocked(Notifications.dismissNotificationAsync).mockReset() vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true) vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({ status: 'granted', @@ -343,12 +341,10 @@ describe('subscribeToDesktopNotifications', () => { onEvent?.({ type: 'notification', title: 't', body: 'b', notificationId: 'agent:new' }) await flushAsync() - expect(Notifications.dismissNotificationAsync).toHaveBeenCalledWith('scheduled-old') - - // The older entry was already dismissed during eviction, so a later desktop dismiss is a no-op... + // The older entry was evicted by the cap: dismissing it is a no-op... onEvent?.({ type: 'dismiss', notificationId: 'agent:old' }) await flushAsync() - expect(Notifications.dismissNotificationAsync).toHaveBeenCalledTimes(1) + expect(Notifications.dismissNotificationAsync).not.toHaveBeenCalledWith('scheduled-old') // ...while the most-recent entry is retained and still dismissable. onEvent?.({ type: 'dismiss', notificationId: 'agent:new' }) @@ -358,105 +354,6 @@ describe('subscribeToDesktopNotifications', () => { setScheduledNotificationsMaxForTests() } }) - - it('keeps overload gaps replayable and advances after reconnect catches up', async () => { - setScheduledNotificationsMaxForTests(1) - const firstSchedule = makeDeferred() - try { - vi.mocked(Notifications.scheduleNotificationAsync).mockReset() - vi.mocked(Notifications.dismissNotificationAsync).mockReset() - vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true) - vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({ - status: 'granted', - canAskAgain: true - } as never) - vi.mocked(Notifications.scheduleNotificationAsync) - .mockReturnValueOnce(firstSchedule.promise) - .mockResolvedValueOnce('scheduled-after-drain') - .mockResolvedValueOnce('scheduled-replayed') - vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined) - let onEvent: ((data: unknown) => void) | null = null - const client = { - subscribe: vi.fn((_method, _params, callback: (data: unknown) => void) => { - onEvent = callback - return vi.fn() - }), - getState: vi.fn(() => 'connected'), - sendRequest: vi.fn(async (method: string) => { - if (method === 'notifications.getMissedSince') { - return { - ok: true, - result: { - notifications: [ - { - type: 'notification', - title: 'two', - body: 'two', - notificationId: 'two', - notificationSeq: 2 - }, - { - type: 'notification', - title: 'three', - body: 'three', - notificationId: 'three', - notificationSeq: 3 - } - ] - } - } as never - } - return { ok: true, result: undefined } as never - }) - } as unknown as RpcClient - - subscribeToDesktopNotifications(client, 'host-hung') - onEvent?.({ type: 'ready', subscriptionId: 'sub-1' }) - onEvent?.({ - type: 'notification', - title: 'one', - body: 'one', - notificationId: 'one', - notificationSeq: 1 - }) - await flushAsync() - onEvent?.({ - type: 'notification', - title: 'two', - body: 'two', - notificationId: 'two', - notificationSeq: 2 - }) - await flushAsync() - expect(Notifications.scheduleNotificationAsync).toHaveBeenCalledOnce() - expect(AsyncStorage.setItem).toHaveBeenCalledTimes(1) - - firstSchedule.resolve('scheduled-first') - await flushAsync() - onEvent?.({ - type: 'notification', - title: 'three', - body: 'three', - notificationId: 'three', - notificationSeq: 3 - }) - await flushAsync() - expect(Notifications.scheduleNotificationAsync).toHaveBeenCalledTimes(2) - expect(AsyncStorage.setItem).toHaveBeenCalledTimes(1) - - onEvent?.({ type: 'ready', subscriptionId: 'sub-2' }) - await flushAsync() - await flushAsync() - expect(Notifications.scheduleNotificationAsync).toHaveBeenCalledTimes(3) - expect(AsyncStorage.setItem).toHaveBeenLastCalledWith( - 'orca:mobileNotificationsLastSeq:host-hung', - '3' - ) - } finally { - firstSchedule.resolve('scheduled-first') - setScheduledNotificationsMaxForTests() - } - }) }) // Why: #8129 catch-up. On a reconnect the live stream re-emits `ready`; the diff --git a/mobile/src/notifications/mobile-notifications.ts b/mobile/src/notifications/mobile-notifications.ts index 938c5c56ce0..7e1444d9b0c 100644 --- a/mobile/src/notifications/mobile-notifications.ts +++ b/mobile/src/notifications/mobile-notifications.ts @@ -1,14 +1,8 @@ +import * as Notifications from 'expo-notifications' +import { Platform } from 'react-native' import type { RpcClient } from '../transport/rpc-client' -import { - configureNotificationChannel, - startLocalNotificationDelivery, - type DismissNotificationEvent, - type NotificationEvent -} from './mobile-notification-delivery' -import { - isMobileNotificationHostIdRetainable, - measureMobileNotificationDeliveryBytes -} from './mobile-notification-retention' +import { loadPushNotificationsEnabled } from '../storage/preferences' +import { buildLocalNotificationData, type DesktopNotificationSource } from './notification-routing' import { createSeenNotificationGuard, loadLastSeenSeq, @@ -16,81 +10,249 @@ import { seenKeyForEvent } from './notification-reconnect-catchup' -export { - ensureNotificationPermissions, - getNotificationPermissionState, - setScheduledNotificationsMaxForTests -} from './mobile-notification-delivery' -export type { NotificationPermissionState } from './mobile-notification-delivery' +type NotificationEvent = { + type: 'notification' + source: DesktopNotificationSource + title: string + body: string + worktreeId?: string + notificationId?: string + // Desktop-assigned seq for reconnect catch-up (#8129); optional since older runtimes may omit it. + notificationSeq?: number +} + +type DismissNotificationEvent = { + type: 'dismiss' + notificationId: string + notificationSeq?: number +} type SubscribeResult = { type: 'ready' subscriptionId: string } +type ScheduledNotificationState = { + identifier?: string + pending?: Promise + dismissAfterSchedule?: boolean +} + +const scheduledNotificationsByHostAndNotificationId = new Map() + +// Why: keys never repeat and are only freed on desktop dismiss (which remote users often miss), so bound the map to stop unbounded growth. +const MAX_SCHEDULED_NOTIFICATIONS = 256 +let maxScheduledNotifications = MAX_SCHEDULED_NOTIFICATIONS + +function getStoredNotificationKey(hostId: string, notificationId: string): string { + return `${encodeURIComponent(hostId)}:${encodeURIComponent(notificationId)}` +} + +// Evict oldest settled entries (never mid-schedule); Map iteration is insertion order so the first match is oldest. +function boundScheduledNotifications(): void { + while (scheduledNotificationsByHostAndNotificationId.size > maxScheduledNotifications) { + let evicted = false + for (const [key, state] of scheduledNotificationsByHostAndNotificationId) { + if (!state.pending) { + scheduledNotificationsByHostAndNotificationId.delete(key) + evicted = true + break + } + } + if (!evicted) { + break + } + } +} + +/** Test-only: override the cap (pass no arg to restore the default). */ +export function setScheduledNotificationsMaxForTests(max?: number): void { + maxScheduledNotifications = max ?? MAX_SCHEDULED_NOTIFICATIONS +} + +export type NotificationPermissionState = { + granted: boolean + status: string + canAskAgain: boolean + authorizationReflectsUserChoice: boolean +} + +export async function getNotificationPermissionState(): Promise { + const { status, canAskAgain } = await Notifications.getPermissionsAsync() + return { + granted: status === 'granted', + status, + canAskAgain, + // Why: Android <33 has no runtime notification permission, so "granted" is capability, not user consent. + authorizationReflectsUserChoice: + status === 'granted' && (Platform.OS !== 'android' || Number(Platform.Version) >= 33) + } +} + +// Why: re-read OS state every call — users can change it in Settings while Orca is backgrounded. +export async function ensureNotificationPermissions(): Promise { + const existing = await getNotificationPermissionState() + if (existing.granted) { + return true + } + + const { status } = await Notifications.requestPermissionsAsync() + return status === 'granted' +} + +function configureNotificationChannel(): void { + if (Platform.OS === 'android') { + void Notifications.setNotificationChannelAsync('orca-desktop', { + name: 'Desktop Notifications', + importance: Notifications.AndroidImportance.HIGH, + vibrationPattern: [0, 250], + lightColor: '#6366f1' + }) + } +} + +async function showLocalNotification(event: NotificationEvent, hostId: string): Promise { + const storedKey = event.notificationId + ? getStoredNotificationKey(hostId, event.notificationId) + : null + + if (!storedKey) { + const enabled = await loadPushNotificationsEnabled() + if (!enabled) { + return + } + + const granted = await ensureNotificationPermissions() + if (!granted) { + return + } + + await Notifications.scheduleNotificationAsync({ + content: { + title: event.title, + body: event.body, + data: buildLocalNotificationData(event, hostId), + ...(Platform.OS === 'android' ? { channelId: 'orca-desktop' } : {}) + }, + trigger: null + }) + return + } + + let state = scheduledNotificationsByHostAndNotificationId.get(storedKey) + if (state?.pending) { + return + } + if (!state) { + state = {} + scheduledNotificationsByHostAndNotificationId.set(storedKey, state) + } + const notificationState = state + + const pending = (async () => { + const enabled = await loadPushNotificationsEnabled() + if (!enabled) { + return null + } + + const granted = await ensureNotificationPermissions() + if (!granted) { + return null + } + + if (notificationState.identifier) { + await Notifications.dismissNotificationAsync(notificationState.identifier).catch(() => {}) + notificationState.identifier = undefined + } + + return Notifications.scheduleNotificationAsync({ + content: { + title: event.title, + body: event.body, + data: buildLocalNotificationData(event, hostId), + ...(Platform.OS === 'android' ? { channelId: 'orca-desktop' } : {}) + }, + trigger: null + }) + })() + notificationState.pending = pending + + try { + const scheduledIdentifier = await pending + if (!scheduledIdentifier) { + if (!notificationState.identifier) { + scheduledNotificationsByHostAndNotificationId.delete(storedKey) + } + return + } + if (notificationState.dismissAfterSchedule) { + notificationState.dismissAfterSchedule = false + scheduledNotificationsByHostAndNotificationId.delete(storedKey) + await Notifications.dismissNotificationAsync(scheduledIdentifier).catch(() => {}) + return + } + notificationState.identifier = scheduledIdentifier + boundScheduledNotifications() + } finally { + if (notificationState.pending === pending) { + notificationState.pending = undefined + notificationState.dismissAfterSchedule = false + } + } +} + +async function dismissLocalNotification( + event: DismissNotificationEvent, + hostId: string +): Promise { + if (!event.notificationId) { + return + } + const storedKey = getStoredNotificationKey(hostId, event.notificationId) + const state = scheduledNotificationsByHostAndNotificationId.get(storedKey) + if (!state) { + return + } + if (state.pending) { + // Why: dismiss can arrive while the OS is still scheduling; defer it so no stale banner survives. + state.dismissAfterSchedule = true + return + } + if (!state.identifier) { + return + } + scheduledNotificationsByHostAndNotificationId.delete(storedKey) + await Notifications.dismissNotificationAsync(state.identifier).catch(() => {}) +} + // Per-connection subscription; a reconnect `ready` triggers watermarked catch-up (#8129) so already-pushed events aren't re-sent. export function subscribeToDesktopNotifications(client: RpcClient, hostId: string): () => void { - if (!isMobileNotificationHostIdRetainable(hostId)) { - return () => {} - } configureNotificationChannel() let subscriptionId: string | null = null let disposed = false // Highest seq delivered (live or replay) this connection; persisted per-host so cold start resumes from the right cut. let lastDeliveredSeq = 0 - let watermarkBlockedThroughSeq: number | null = null // Why: defense-in-depth dedup for replayed events if the desktop's bounded buffer evicted across a reconnect boundary. const seenReplay = createSeenNotificationGuard() - function eventSequence(event: NotificationEvent | DismissNotificationEvent): number | null { - return typeof event.notificationSeq === 'number' && Number.isSafeInteger(event.notificationSeq) - ? event.notificationSeq - : null - } - - function advanceDeliveredWatermark( - event: NotificationEvent | DismissNotificationEvent, - replay: boolean - ): void { - const seq = eventSequence(event) - if (seq == null || seq <= lastDeliveredSeq) { - return - } - if (watermarkBlockedThroughSeq !== null) { - if (!replay) { - watermarkBlockedThroughSeq = Math.max(watermarkBlockedThroughSeq, seq) - return - } - if (seq < watermarkBlockedThroughSeq) { - return - } - watermarkBlockedThroughSeq = null - } - lastDeliveredSeq = seq - void saveLastSeenSeq(hostId, lastDeliveredSeq) - } - function deliverLive( - event: NotificationEvent | DismissNotificationEvent, - replay = false + type: 'notification' | 'dismiss', + event: NotificationEvent | DismissNotificationEvent ): Promise { - const delivery = startLocalNotificationDelivery(event, hostId) - if (!delivery) { - const seq = eventSequence(event) - if (seq !== null && seq > lastDeliveredSeq) { - // Why: advancing past a dropped event would make reconnect catch-up permanently skip it. - watermarkBlockedThroughSeq = Math.max(watermarkBlockedThroughSeq ?? seq, seq) - } - return Promise.resolve() + if (event.notificationSeq != null && event.notificationSeq > lastDeliveredSeq) { + lastDeliveredSeq = event.notificationSeq + void saveLastSeenSeq(hostId, lastDeliveredSeq) } - // Why (#8129): only accepted work is seen; overload drops remain eligible for reconnect catch-up. + // Why (#8129): mark seen on the live path too, so a later replay of an already-pushed id dedups instead of double-pushing. const key = seenKeyForEvent(event) if (key) { seenReplay.add(key) } - advanceDeliveredWatermark(event, replay) - return delivery + if (type === 'notification') { + return showLocalNotification(event as NotificationEvent, hostId) + } + return dismissLocalNotification(event as DismissNotificationEvent, hostId) } // Why: desktop cuts by seq > lastSeenSeq, so re-fetching from the watermark is idempotent (seenReplay guards residual overlap). @@ -110,19 +272,17 @@ export function subscribeToDesktopNotifications(client: RpcClient, hostId: strin .catch(() => []) for (const raw of missed) { const event = raw as NotificationEvent | DismissNotificationEvent - if (measureMobileNotificationDeliveryBytes(event, hostId) === null) { - await deliverLive(event, true) - continue - } const key = seenKeyForEvent(event) if (key && seenReplay.has(key)) { - advanceDeliveredWatermark(event, true) continue } + if (key) { + seenReplay.add(key) + } if (event.type === 'notification') { - await deliverLive(event, true) + await deliverLive('notification', event) } else if (event.type === 'dismiss') { - await deliverLive(event, true) + await deliverLive('dismiss', event) } } } @@ -171,9 +331,9 @@ export function subscribeToDesktopNotifications(client: RpcClient, hostId: strin return } if (event.type === 'notification') { - void deliverLive(event as NotificationEvent) + void deliverLive('notification', event as NotificationEvent) } else if (event.type === 'dismiss') { - void deliverLive(event as DismissNotificationEvent) + void deliverLive('dismiss', event as DismissNotificationEvent) } }) diff --git a/mobile/src/notifications/mobile-scheduled-notification-registry.ts b/mobile/src/notifications/mobile-scheduled-notification-registry.ts deleted file mode 100644 index 27f662b0453..00000000000 --- a/mobile/src/notifications/mobile-scheduled-notification-registry.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { measureUtf8ByteLength } from '../../../src/shared/utf8-byte-limits' - -export const MOBILE_SCHEDULED_NOTIFICATION_MAX_ENTRIES = 256 -export const MOBILE_SCHEDULED_NOTIFICATION_MAX_RETAINED_BYTES = 2 * 1024 * 1024 -export const MOBILE_NATIVE_NOTIFICATION_ID_MAX_BYTES = 8 * 1024 - -export type MobileScheduledNotificationState = { - identifier?: string - pending?: Promise - dismissAfterSchedule?: boolean - retainedKeyBytes: number - retainedIdentifierBytes: number - retained: boolean -} - -export type MobileScheduledNotificationReservation = { - state: MobileScheduledNotificationState - evictedIdentifiers: string[] -} - -function retainedStringBytes(value: string): number { - return 64 + value.length * 2 -} - -export class MobileScheduledNotificationRegistry { - private readonly entries = new Map() - private retainedBytes = 0 - private maxEntries = MOBILE_SCHEDULED_NOTIFICATION_MAX_ENTRIES - - get(key: string): MobileScheduledNotificationState | undefined { - return this.entries.get(key) - } - - reserve(key: string): MobileScheduledNotificationReservation | null { - const retainedKeyBytes = retainedStringBytes(key) - if (retainedKeyBytes > MOBILE_SCHEDULED_NOTIFICATION_MAX_RETAINED_BYTES) { - return null - } - const evictedIdentifiers: string[] = [] - while ( - this.entries.size >= this.maxEntries || - this.retainedBytes + retainedKeyBytes > MOBILE_SCHEDULED_NOTIFICATION_MAX_RETAINED_BYTES - ) { - const settled = this.findOldestSettled() - if (!settled) { - return null - } - this.delete(settled[0]) - if (settled[1].identifier) { - evictedIdentifiers.push(settled[1].identifier) - } - } - const state: MobileScheduledNotificationState = { - retainedKeyBytes, - retainedIdentifierBytes: 0, - retained: true - } - this.entries.set(key, state) - this.retainedBytes += retainedKeyBytes - return { state, evictedIdentifiers } - } - - delete(key: string): boolean { - const state = this.entries.get(key) - if (!state) { - return false - } - this.entries.delete(key) - this.retainedBytes -= state.retainedKeyBytes + state.retainedIdentifierBytes - state.retained = false - return true - } - - clearIdentifier(state: MobileScheduledNotificationState): void { - if (!state.identifier) { - return - } - if (state.retained) { - this.retainedBytes -= state.retainedIdentifierBytes - } - state.identifier = undefined - state.retainedIdentifierBytes = 0 - } - - retainIdentifier(state: MobileScheduledNotificationState, identifier: string): boolean { - const measurement = measureUtf8ByteLength(identifier, { - stopAfterBytes: MOBILE_NATIVE_NOTIFICATION_ID_MAX_BYTES - }) - if (measurement.exceededLimit || !state.retained) { - return false - } - const identifierBytes = retainedStringBytes(identifier) - if ( - this.retainedBytes - state.retainedIdentifierBytes + identifierBytes > - MOBILE_SCHEDULED_NOTIFICATION_MAX_RETAINED_BYTES - ) { - return false - } - this.clearIdentifier(state) - state.identifier = identifier - state.retainedIdentifierBytes = identifierBytes - this.retainedBytes += identifierBytes - return true - } - - resetForTests(maxEntries?: number): void { - for (const state of this.entries.values()) { - state.retained = false - } - this.entries.clear() - this.retainedBytes = 0 - this.maxEntries = maxEntries ?? MOBILE_SCHEDULED_NOTIFICATION_MAX_ENTRIES - } - - getRetainedBytesForTests(): number { - return this.retainedBytes - } - - private findOldestSettled(): [string, MobileScheduledNotificationState] | null { - for (const entry of this.entries) { - if (!entry[1].pending) { - return entry - } - } - return null - } -} diff --git a/mobile/src/notifications/notification-reconnect-catchup.ts b/mobile/src/notifications/notification-reconnect-catchup.ts index 22172cbfdbf..d40af93a090 100644 --- a/mobile/src/notifications/notification-reconnect-catchup.ts +++ b/mobile/src/notifications/notification-reconnect-catchup.ts @@ -1,6 +1,4 @@ import AsyncStorage from '@react-native-async-storage/async-storage' -import { measureUtf8ByteLength } from '../../../src/shared/utf8-byte-limits' -import { MOBILE_NOTIFICATION_ID_MAX_BYTES } from './mobile-notification-retention' // Why: the reconnect catch-up watermark + dedup helpers for #8129, extracted // from mobile-notifications.ts so that file stays under its max-lines budget. @@ -86,23 +84,14 @@ export function seenKeyForEvent(event: { notificationSeq?: number }): string | null { const id = event.notificationId - const seq = - typeof event.notificationSeq === 'number' && Number.isSafeInteger(event.notificationSeq) - ? event.notificationSeq - : null - const retainedId = - typeof id === 'string' && - !measureUtf8ByteLength(id, { stopAfterBytes: MOBILE_NOTIFICATION_ID_MAX_BYTES }).exceededLimit - ? id - : null - if (retainedId !== null && seq !== null) { - return `id:${retainedId}#${seq}` + if (id != null && event.notificationSeq != null) { + return `id:${id}#${event.notificationSeq}` } - if (retainedId !== null) { - return `id:${retainedId}` + if (id != null) { + return `id:${id}` } - if (seq !== null) { - return `seq:${seq}` + if (event.notificationSeq != null) { + return `seq:${event.notificationSeq}` } return null } diff --git a/mobile/src/session/mobile-image-base64-accumulator.test.ts b/mobile/src/session/mobile-image-base64-accumulator.test.ts deleted file mode 100644 index d6da669b7d3..00000000000 --- a/mobile/src/session/mobile-image-base64-accumulator.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { MobileImageBase64Accumulator } from './mobile-image-base64-accumulator' - -describe('MobileImageBase64Accumulator', () => { - it('preserves bytes delivered as 100,000 one-byte fragments', () => { - const accumulator = new MobileImageBase64Accumulator() - const expected = Buffer.alloc(100_000) - - for (let index = 0; index < expected.byteLength; index += 1) { - const value = index % 251 - expected[index] = value - accumulator.append(Uint8Array.of(value)) - } - - expect(accumulator.finish()).toBe(expected.toString('base64')) - }) -}) diff --git a/mobile/src/session/mobile-image-base64-accumulator.ts b/mobile/src/session/mobile-image-base64-accumulator.ts deleted file mode 100644 index 079a4d89a0e..00000000000 --- a/mobile/src/session/mobile-image-base64-accumulator.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { Buffer } from 'buffer' - -const MOBILE_IMAGE_BASE64_CHUNK_BYTES = 256 * 1024 - 1 - -export class MobileImageBase64Accumulator { - private readonly staging = new Uint8Array(MOBILE_IMAGE_BASE64_CHUNK_BYTES) - private readonly encodedChunks: string[] = [] - private stagingLength = 0 - - append(bytes: Uint8Array): void { - let offset = 0 - while (offset < bytes.byteLength) { - const copied = Math.min( - this.staging.byteLength - this.stagingLength, - bytes.byteLength - offset - ) - this.staging.set(bytes.subarray(offset, offset + copied), this.stagingLength) - this.stagingLength += copied - offset += copied - if (this.stagingLength === this.staging.byteLength) { - this.flushStaging() - } - } - } - - finish(): string { - this.flushStaging() - return this.encodedChunks.join('') - } - - private flushStaging(): void { - if (this.stagingLength === 0) { - return - } - this.encodedChunks.push( - Buffer.from(this.staging.subarray(0, this.stagingLength)).toString('base64') - ) - this.stagingLength = 0 - } -} diff --git a/mobile/src/session/mobile-image-source-picker.test.ts b/mobile/src/session/mobile-image-source-picker.test.ts index 1a582bde3fe..a165e963329 100644 --- a/mobile/src/session/mobile-image-source-picker.test.ts +++ b/mobile/src/session/mobile-image-source-picker.test.ts @@ -1,5 +1,4 @@ import { describe, expect, it, vi } from 'vitest' -import { CLIPBOARD_IMAGE_MAX_SOURCE_BYTES } from '../../../src/shared/clipboard-image' vi.mock('expo-image-picker', () => ({ requestMediaLibraryPermissionsAsync: vi.fn(), @@ -8,9 +7,6 @@ vi.mock('expo-image-picker', () => ({ vi.mock('expo-document-picker', () => ({ getDocumentAsync: vi.fn() })) -vi.mock('expo-file-system', () => ({ - File: vi.fn() -})) import { ImageLibraryPermissionError, pickMobileImage } from './mobile-image-source-picker' @@ -19,48 +15,17 @@ const granted = { granted: true } as Awaited< > const denied = { granted: false } as typeof granted -function fileFactory( - chunks: Uint8Array[], - options?: { fileSize?: number; handleSize?: number | null; readError?: Error } -) { - const close = vi.fn() - const readBytes = vi.fn(() => { - if (options?.readError) { - throw options.readError - } - return chunks.shift() ?? new Uint8Array() - }) - const open = vi.fn(() => ({ - size: options?.handleSize ?? options?.fileSize ?? 0, - readBytes, - close - })) - const createFile = vi.fn(() => ({ size: options?.fileSize ?? 0, open })) - return { close, createFile, open, readBytes } -} - describe('pickMobileImage', () => { it('returns base64 from the photo library', async () => { - const bytes = new Uint8Array([0, 1, 2, 3]) - const file = fileFactory([bytes]) - const launchLibrary = vi.fn().mockResolvedValue({ - canceled: false, - assets: [{ uri: 'file:///x.jpg', fileSize: bytes.length }] - }) const result = await pickMobileImage('library', { requestLibraryPermission: vi.fn().mockResolvedValue(granted), - launchLibrary, - createFile: file.createFile + launchLibrary: vi.fn().mockResolvedValue({ + canceled: false, + assets: [{ uri: 'file:///x.jpg', base64: 'AAAA' }] + }) }) - expect(result).toEqual({ - base64: Buffer.from(bytes).toString('base64'), - uri: 'file:///x.jpg' - }) - expect(launchLibrary).toHaveBeenCalledWith( - expect.objectContaining({ base64: false, allowsMultipleSelection: false }) - ) - expect(file.close).toHaveBeenCalledTimes(1) + expect(result).toEqual({ base64: 'AAAA', uri: 'file:///x.jpg' }) }) it('throws when photo library permission is denied', async () => { @@ -83,25 +48,22 @@ describe('pickMobileImage', () => { it('reads a picked file URI into base64 for the files source', async () => { const bytes = new Uint8Array([1, 2, 3, 4]) - const file = fileFactory([bytes]) - const launchFiles = vi.fn().mockResolvedValue({ - canceled: false, - assets: [{ uri: 'file:///doc.png', size: bytes.length }] - }) + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue(new Response(bytes.buffer, { headers: { 'content-type': 'image/png' } })) const result = await pickMobileImage('files', { - launchFiles, - createFile: file.createFile + launchFiles: vi.fn().mockResolvedValue({ + canceled: false, + assets: [{ uri: 'file:///doc.png' }] + }) }) expect(result).toEqual({ base64: Buffer.from(bytes).toString('base64'), uri: 'file:///doc.png' }) - expect(launchFiles).toHaveBeenCalledWith( - expect.objectContaining({ copyToCacheDirectory: true }) - ) - expect(file.close).toHaveBeenCalledTimes(1) + fetchSpy.mockRestore() }) it('returns null when the files picker is cancelled', async () => { @@ -111,72 +73,4 @@ describe('pickMobileImage', () => { expect(result).toBeNull() }) - - it('rejects a declared oversized asset before opening it', async () => { - const file = fileFactory([], { fileSize: 1 }) - await expect( - pickMobileImage('files', { - launchFiles: vi.fn().mockResolvedValue({ - canceled: false, - assets: [{ uri: 'file:///huge.png', size: CLIPBOARD_IMAGE_MAX_SOURCE_BYTES + 1 }] - }), - createFile: file.createFile - }) - ).rejects.toThrow('Clipboard image is too large') - expect(file.createFile).not.toHaveBeenCalled() - expect(file.open).not.toHaveBeenCalled() - }) - - it('does not let stale size metadata bypass the bounded read', async () => { - const close = vi.fn() - const readBytes = vi.fn((length: number) => new Uint8Array(length)) - const createFile = vi.fn(() => ({ - size: 1, - open: () => ({ size: 1, readBytes, close }) - })) - - await expect( - pickMobileImage('library', { - requestLibraryPermission: vi.fn().mockResolvedValue(granted), - launchLibrary: vi.fn().mockResolvedValue({ - canceled: false, - assets: [{ uri: 'file:///grew.png', fileSize: 1 }] - }), - createFile - }) - ).rejects.toThrow('Clipboard image is too large') - expect(readBytes).toHaveBeenLastCalledWith(1) - expect(close).toHaveBeenCalledTimes(1) - }) - - it('closes the file handle when a read fails', async () => { - const file = fileFactory([], { fileSize: 4, readError: new Error('read failed') }) - await expect( - pickMobileImage('files', { - launchFiles: vi.fn().mockResolvedValue({ - canceled: false, - assets: [{ uri: 'file:///broken.png', size: 4 }] - }), - createFile: file.createFile - }) - ).rejects.toThrow('read failed') - expect(file.close).toHaveBeenCalledTimes(1) - }) - - it('preserves bytes across chunk boundaries that are not base64 aligned', async () => { - const chunks = [new Uint8Array([1]), new Uint8Array([2, 3]), new Uint8Array([4, 5])] - const file = fileFactory([...chunks], { fileSize: 5, handleSize: 5 }) - const result = await pickMobileImage('files', { - launchFiles: vi.fn().mockResolvedValue({ - canceled: false, - assets: [{ uri: 'file:///chunked.png', size: 5 }] - }), - createFile: file.createFile - }) - expect(result).toEqual({ - base64: Buffer.from([1, 2, 3, 4, 5]).toString('base64'), - uri: 'file:///chunked.png' - }) - expect(file.close).toHaveBeenCalledTimes(1) - }) }) diff --git a/mobile/src/session/mobile-image-source-picker.ts b/mobile/src/session/mobile-image-source-picker.ts index bf9c529cc25..7f510494618 100644 --- a/mobile/src/session/mobile-image-source-picker.ts +++ b/mobile/src/session/mobile-image-source-picker.ts @@ -1,12 +1,8 @@ +// Why: import from 'buffer' (the npm polyfill), not 'node:buffer' — Metro +// can't resolve Node's builtin in a React Native bundle. +import { Buffer } from 'buffer' import * as DocumentPicker from 'expo-document-picker' -import { File as FsFile } from 'expo-file-system' import * as ImagePicker from 'expo-image-picker' -import { - CLIPBOARD_IMAGE_MAX_SOURCE_BYTES, - assertClipboardImageBase64LengthWithinLimit, - assertClipboardImageByteLengthWithinLimit -} from '../../../src/shared/clipboard-image' -import { MobileImageBase64Accumulator } from './mobile-image-base64-accumulator' export type MobileImageSource = 'library' | 'files' @@ -25,68 +21,18 @@ export class ImageLibraryPermissionError extends Error { } } -const MOBILE_IMAGE_READ_CHUNK_BYTES = 256 * 1024 - -type MobileImageFileHandle = { - readonly size: number | null - readBytes(length: number): Uint8Array - close(): void -} - -type MobileImageFile = { - readonly size: number - open(): MobileImageFileHandle -} - -export type MobileImageFileFactory = (uri: string) => MobileImageFile - -function defaultMobileImageFileFactory(uri: string): MobileImageFile { - return new FsFile(uri) -} - -async function readUriAsBase64( - uri: string, - declaredSize: number | undefined, - createFile: MobileImageFileFactory -): Promise { - if (typeof declaredSize === 'number' && Number.isFinite(declaredSize)) { - assertClipboardImageByteLengthWithinLimit(declaredSize) - } - - const file = createFile(uri) - assertClipboardImageByteLengthWithinLimit(file.size) - const handle = file.open() - try { - if (handle.size !== null) { - assertClipboardImageByteLengthWithinLimit(handle.size) - } - const accumulator = new MobileImageBase64Accumulator() - let bytesRead = 0 - while (bytesRead <= CLIPBOARD_IMAGE_MAX_SOURCE_BYTES) { - const requested = Math.min( - MOBILE_IMAGE_READ_CHUNK_BYTES, - CLIPBOARD_IMAGE_MAX_SOURCE_BYTES - bytesRead + 1 - ) - const bytes = handle.readBytes(requested) - if (bytes.byteLength === 0) { - break - } - bytesRead += bytes.byteLength - assertClipboardImageByteLengthWithinLimit(bytesRead) - accumulator.append(bytes) - } - const base64 = accumulator.finish() - assertClipboardImageBase64LengthWithinLimit(base64.length) - return base64 - } finally { - handle.close() - } +// Why: expo-document-picker returns a file URI, not base64. Read it through +// fetch + Buffer so we match the base64 contract the upload pipeline expects +// without pulling in expo-file-system. +async function readUriAsBase64(uri: string): Promise { + const response = await fetch(uri) + const bytes = new Uint8Array(await response.arrayBuffer()) + return Buffer.from(bytes).toString('base64') } async function pickFromLibrary( requestPermission: typeof ImagePicker.requestMediaLibraryPermissionsAsync = ImagePicker.requestMediaLibraryPermissionsAsync, - launch: typeof ImagePicker.launchImageLibraryAsync = ImagePicker.launchImageLibraryAsync, - createFile: MobileImageFileFactory = defaultMobileImageFileFactory + launch: typeof ImagePicker.launchImageLibraryAsync = ImagePicker.launchImageLibraryAsync ): Promise { const permission = await requestPermission() // Why: `granted` covers full + limited iOS access; only a hard denial blocks us. @@ -95,7 +41,7 @@ async function pickFromLibrary( } const result = await launch({ mediaTypes: ['images'], - base64: false, + base64: true, allowsMultipleSelection: false, quality: 1 }) @@ -103,7 +49,7 @@ async function pickFromLibrary( return null } const asset = result.assets[0] - const base64 = asset?.uri ? await readUriAsBase64(asset.uri, asset.fileSize, createFile) : null + const base64 = asset?.base64 ?? (asset?.uri ? await readUriAsBase64(asset.uri) : null) if (!base64) { return null } @@ -111,8 +57,7 @@ async function pickFromLibrary( } async function pickFromFiles( - launch: typeof DocumentPicker.getDocumentAsync = DocumentPicker.getDocumentAsync, - createFile: MobileImageFileFactory = defaultMobileImageFileFactory + launch: typeof DocumentPicker.getDocumentAsync = DocumentPicker.getDocumentAsync ): Promise { const result = await launch({ type: 'image/*', @@ -126,8 +71,7 @@ async function pickFromFiles( if (!asset?.uri) { return null } - const base64 = await readUriAsBase64(asset.uri, asset.size, createFile) - return base64 ? { base64, uri: asset.uri } : null + return { base64: await readUriAsBase64(asset.uri), uri: asset.uri } } export async function pickMobileImage( @@ -136,11 +80,10 @@ export async function pickMobileImage( readonly requestLibraryPermission?: typeof ImagePicker.requestMediaLibraryPermissionsAsync readonly launchLibrary?: typeof ImagePicker.launchImageLibraryAsync readonly launchFiles?: typeof DocumentPicker.getDocumentAsync - readonly createFile?: MobileImageFileFactory } ): Promise { if (source === 'library') { - return pickFromLibrary(deps?.requestLibraryPermission, deps?.launchLibrary, deps?.createFile) + return pickFromLibrary(deps?.requestLibraryPermission, deps?.launchLibrary) } - return pickFromFiles(deps?.launchFiles, deps?.createFile) + return pickFromFiles(deps?.launchFiles) } diff --git a/mobile/src/session/mobile-session-file-doc-lifecycle.test.ts b/mobile/src/session/mobile-session-file-doc-lifecycle.test.ts deleted file mode 100644 index f29685b7bca..00000000000 --- a/mobile/src/session/mobile-session-file-doc-lifecycle.test.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - MobileSessionFileDocLifecycle, - beginMobileFileDocLoad, - createMobileFileDocLifecycle, - finishMobileFileDocLoad, - forgetMobileFileDocTab, - reconcileMobileFileDocTabs, - removeMobileFileDoc, - resetMobileFileDocLifecycle, - retainLiveMobileFileDocs, - type MobileFileTabIdentity -} from './mobile-session-file-doc-lifecycle' -import type { MobileFileTabDoc } from '../files/mobile-file-tab-doc' - -type TestFileDoc = MobileFileTabDoc | { status: 'loading' } | { status: 'error'; message: string } - -function fileTab(id: string, relativePath = `${id}.ts`): MobileFileTabIdentity { - return { - id, - filePath: `/repo/${relativePath}`, - relativePath, - mode: 'edit' - } -} - -describe('mobile session file document lifecycle', () => { - it('retains payloads only for file tabs in the accepted live snapshot', () => { - const lifecycle = createMobileFileDocLifecycle() - reconcileMobileFileDocTabs(lifecycle, [fileTab('live-a'), fileTab('live-b')]) - const docs = new Map([ - ['closed', { payload: 'x'.repeat(1_000_000) }], - ['live-a', { payload: 'a' }], - ['live-b', { payload: 'b' }] - ]) - - const retained = retainLiveMobileFileDocs(docs, lifecycle) - - expect([...retained.keys()]).toEqual(['live-a', 'live-b']) - expect(retained.get('live-a')).toBe(docs.get('live-a')) - }) - - it('deletes a successfully closed tab immediately', () => { - const lifecycle = createMobileFileDocLifecycle() - reconcileMobileFileDocTabs(lifecycle, [fileTab('closed')]) - forgetMobileFileDocTab(lifecycle, 'closed') - - expect(removeMobileFileDoc(new Map([['closed', { payload: 'large' }]]), 'closed').size).toBe(0) - }) - - it('rejects a late read after its tab closes', () => { - const lifecycle = createMobileFileDocLifecycle() - const tab = fileTab('file') - reconcileMobileFileDocTabs(lifecycle, [tab]) - const token = beginMobileFileDocLoad(lifecycle, tab) - expect(token).not.toBeNull() - - forgetMobileFileDocTab(lifecycle, tab.id) - - expect(finishMobileFileDocLoad(lifecycle, token!)).toBe(false) - }) - - it('keeps route state empty when a read resolves after close', async () => { - const lifecycle = new MobileSessionFileDocLifecycle() - const tab = fileTab('file') - let docs = new Map() - const updateDocs = (update: (current: typeof docs) => typeof docs) => { - docs = update(docs) - } - lifecycle.reconcile([{ ...tab, type: 'file' }], updateDocs) - let resolveRead: (doc: MobileFileTabDoc) => void = () => undefined - const read = new Promise((resolve) => { - resolveRead = resolve - }) - - const pending = lifecycle.load(tab, updateDocs, () => read) - expect(docs.get(tab.id)).toEqual({ status: 'loading' }) - lifecycle.close(tab.id, updateDocs) - resolveRead({ status: 'ready', kind: 'file', content: 'late', truncated: false, byteLength: 4 }) - await pending - - expect(docs.size).toBe(0) - }) - - it('rejects a late read when the same tab id is replaced with another file', () => { - const lifecycle = createMobileFileDocLifecycle() - const oldTab = fileTab('file', 'old.ts') - reconcileMobileFileDocTabs(lifecycle, [oldTab]) - const token = beginMobileFileDocLoad(lifecycle, oldTab) - expect(token).not.toBeNull() - - const replaced = reconcileMobileFileDocTabs(lifecycle, [fileTab('file', 'replacement.ts')]) - - expect(finishMobileFileDocLoad(lifecycle, token!)).toBe(false) - expect( - retainLiveMobileFileDocs( - new Map([['file', { payload: 'old contents' }]]), - lifecycle, - replaced - ) - ).toEqual(new Map()) - }) - - it('lets only the newest read for a live tab commit', () => { - const lifecycle = createMobileFileDocLifecycle() - const tab = fileTab('file') - reconcileMobileFileDocTabs(lifecycle, [tab]) - const first = beginMobileFileDocLoad(lifecycle, tab) - const second = beginMobileFileDocLoad(lifecycle, tab) - - expect(finishMobileFileDocLoad(lifecycle, first!)).toBe(false) - expect(finishMobileFileDocLoad(lifecycle, second!)).toBe(true) - }) - - it('invalidates requests when the route scope resets even if tab ids are reused', () => { - const lifecycle = createMobileFileDocLifecycle() - const tab = fileTab('reused') - reconcileMobileFileDocTabs(lifecycle, [tab]) - const oldScope = beginMobileFileDocLoad(lifecycle, tab) - - resetMobileFileDocLifecycle(lifecycle) - reconcileMobileFileDocTabs(lifecycle, [tab]) - const newScope = beginMobileFileDocLoad(lifecycle, tab) - - expect(finishMobileFileDocLoad(lifecycle, oldScope!)).toBe(false) - expect(finishMobileFileDocLoad(lifecycle, newScope!)).toBe(true) - }) -}) diff --git a/mobile/src/session/mobile-session-file-doc-lifecycle.ts b/mobile/src/session/mobile-session-file-doc-lifecycle.ts deleted file mode 100644 index 843aa21758e..00000000000 --- a/mobile/src/session/mobile-session-file-doc-lifecycle.ts +++ /dev/null @@ -1,242 +0,0 @@ -import type { MobileFileTabDoc } from '../files/mobile-file-tab-doc' - -export type MobileFileTabIdentity = { - id: string - filePath: string - relativePath: string - mode?: 'edit' | 'diff' - diffSource?: 'staged' | 'unstaged' | 'branch' | 'commit' -} - -export type MobileFileDocLoadToken = { - generation: number - requestId: number - tabId: string - tabIdentity: string -} - -export type MobileFileDocLifecycle = { - generation: number - nextRequestId: number - liveTabIdentityById: Map - activeRequestIdByTabId: Map -} - -type MobileFileTabCandidate = { - type: string - id: string - filePath?: unknown - relativePath?: unknown - mode?: unknown - diffSource?: unknown -} - -type MobileSessionFileDoc = - | MobileFileTabDoc - | { status: 'loading' } - | { status: 'error'; message: string } - -type MobileSessionFileDocUpdater = ( - update: (current: Map) => Map -) => void - -export class MobileSessionFileDocLifecycle { - private readonly lifecycle = createMobileFileDocLifecycle() - - reconcile( - tabs: readonly MobileFileTabCandidate[], - updateDocs: MobileSessionFileDocUpdater - ): void { - const replacedTabIds = reconcileMobileFileDocTabs( - this.lifecycle, - tabs.filter(isMobileFileTabIdentity) - ) - updateDocs((current) => retainLiveMobileFileDocs(current, this.lifecycle, replacedTabIds)) - } - - async load( - tab: MobileFileTabIdentity, - updateDocs: MobileSessionFileDocUpdater, - read: () => Promise - ): Promise { - const token = beginMobileFileDocLoad(this.lifecycle, tab) - if (!token) { - return - } - updateDocs((current) => new Map(current).set(tab.id, { status: 'loading' })) - try { - const doc = await read() - if (finishMobileFileDocLoad(this.lifecycle, token)) { - updateDocs((current) => new Map(current).set(tab.id, doc)) - } - } catch (error) { - if (finishMobileFileDocLoad(this.lifecycle, token)) { - updateDocs((current) => - new Map(current).set(tab.id, { - status: 'error', - message: getMobileFileDocLoadErrorMessage(tab, error) - }) - ) - } - } - } - - close(tabId: string, updateDocs: MobileSessionFileDocUpdater): void { - forgetMobileFileDocTab(this.lifecycle, tabId) - updateDocs((current) => removeMobileFileDoc(current, tabId)) - } - - reset(): void { - resetMobileFileDocLifecycle(this.lifecycle) - } -} - -export function createMobileFileDocLifecycle(): MobileFileDocLifecycle { - return { - generation: 0, - nextRequestId: 0, - liveTabIdentityById: new Map(), - activeRequestIdByTabId: new Map() - } -} - -export function reconcileMobileFileDocTabs( - lifecycle: MobileFileDocLifecycle, - tabs: readonly MobileFileTabIdentity[] -): ReadonlySet { - const nextIdentityById = new Map(tabs.map((tab) => [tab.id, getMobileFileTabIdentity(tab)])) - const replacedTabIds = new Set() - for (const tabId of lifecycle.activeRequestIdByTabId.keys()) { - const nextIdentity = nextIdentityById.get(tabId) - const currentIdentity = lifecycle.liveTabIdentityById.get(tabId) - if (!nextIdentity || nextIdentity !== currentIdentity) { - lifecycle.activeRequestIdByTabId.delete(tabId) - } - } - for (const [tabId, currentIdentity] of lifecycle.liveTabIdentityById) { - const nextIdentity = nextIdentityById.get(tabId) - if (nextIdentity && nextIdentity !== currentIdentity) { - replacedTabIds.add(tabId) - } - } - lifecycle.liveTabIdentityById.clear() - for (const [tabId, identity] of nextIdentityById) { - lifecycle.liveTabIdentityById.set(tabId, identity) - } - return replacedTabIds -} - -export function beginMobileFileDocLoad( - lifecycle: MobileFileDocLifecycle, - tab: MobileFileTabIdentity -): MobileFileDocLoadToken | null { - const tabIdentity = getMobileFileTabIdentity(tab) - if (lifecycle.liveTabIdentityById.get(tab.id) !== tabIdentity) { - return null - } - const requestId = ++lifecycle.nextRequestId - lifecycle.activeRequestIdByTabId.set(tab.id, requestId) - return { - generation: lifecycle.generation, - requestId, - tabId: tab.id, - tabIdentity - } -} - -export function finishMobileFileDocLoad( - lifecycle: MobileFileDocLifecycle, - token: MobileFileDocLoadToken -): boolean { - if (!isCurrentMobileFileDocLoad(lifecycle, token)) { - return false - } - lifecycle.activeRequestIdByTabId.delete(token.tabId) - return true -} - -export function forgetMobileFileDocTab(lifecycle: MobileFileDocLifecycle, tabId: string): void { - lifecycle.liveTabIdentityById.delete(tabId) - lifecycle.activeRequestIdByTabId.delete(tabId) -} - -export function resetMobileFileDocLifecycle(lifecycle: MobileFileDocLifecycle): void { - lifecycle.generation += 1 - lifecycle.liveTabIdentityById.clear() - lifecycle.activeRequestIdByTabId.clear() -} - -export function retainLiveMobileFileDocs( - docs: Map, - lifecycle: MobileFileDocLifecycle, - replacedTabIds?: ReadonlySet -): Map { - let next: Map | null = null - for (const tabId of docs.keys()) { - if (lifecycle.liveTabIdentityById.has(tabId) && !replacedTabIds?.has(tabId)) { - continue - } - next ??= new Map(docs) - next.delete(tabId) - } - return next ?? docs -} - -export function removeMobileFileDoc(docs: Map, tabId: string): Map { - if (!docs.has(tabId)) { - return docs - } - const next = new Map(docs) - next.delete(tabId) - return next -} - -function isCurrentMobileFileDocLoad( - lifecycle: MobileFileDocLifecycle, - token: MobileFileDocLoadToken -): boolean { - return ( - lifecycle.generation === token.generation && - lifecycle.liveTabIdentityById.get(token.tabId) === token.tabIdentity && - lifecycle.activeRequestIdByTabId.get(token.tabId) === token.requestId - ) -} - -function getMobileFileTabIdentity(tab: MobileFileTabIdentity): string { - return JSON.stringify([ - tab.id, - tab.filePath, - tab.relativePath, - tab.mode ?? '', - tab.diffSource ?? '' - ]) -} - -function isMobileFileTabIdentity( - tab: MobileFileTabCandidate -): tab is MobileFileTabCandidate & MobileFileTabIdentity { - return ( - tab.type === 'file' && - typeof tab.filePath === 'string' && - typeof tab.relativePath === 'string' && - (tab.mode === undefined || tab.mode === 'edit' || tab.mode === 'diff') && - (tab.diffSource === undefined || - tab.diffSource === 'staged' || - tab.diffSource === 'unstaged' || - tab.diffSource === 'branch' || - tab.diffSource === 'commit') - ) -} - -function getMobileFileDocLoadErrorMessage(tab: MobileFileTabIdentity, error: unknown): string { - const message = error instanceof Error ? error.message : '' - if (message === 'binary_file') { - return 'Binary preview unavailable' - } - if (message === 'file_too_large') { - return 'File too large for mobile preview' - } - return tab.diffSource === 'staged' || tab.diffSource === 'unstaged' - ? "Couldn't load diff preview" - : "Couldn't load file preview" -} diff --git a/mobile/src/session/mobile-session-markdown-doc-lifecycle.test.ts b/mobile/src/session/mobile-session-markdown-doc-lifecycle.test.ts deleted file mode 100644 index ce566029f3e..00000000000 --- a/mobile/src/session/mobile-session-markdown-doc-lifecycle.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { - MarkdownDocState, - MobileSessionTab -} from '../../app/h/[hostId]/session/mobile-session-route-types' -import { MobileSessionMarkdownDocLifecycle } from './mobile-session-markdown-doc-lifecycle' - -type MarkdownTab = Extract -type ReadyMarkdownDoc = Extract - -function markdownTab(id: string, relativePath = `${id}.md`): MarkdownTab { - return { - type: 'markdown', - id, - title: relativePath, - filePath: `/repo/${relativePath}`, - relativePath, - isDirty: false, - isActive: false, - documentVersion: 'v1' - } -} - -function readyDoc(content: string, isDirty = false): ReadyMarkdownDoc { - return { - status: 'ready', - content, - localContent: content, - baseVersion: 'v1', - isDirty, - editable: true - } -} - -describe('mobile session markdown document lifecycle', () => { - it('retains payloads only for markdown tabs in the accepted snapshot', () => { - const lifecycle = new MobileSessionMarkdownDocLifecycle() - let docs = new Map([ - ['closed', readyDoc('x'.repeat(1_000_000))], - ['live', readyDoc('live')] - ]) - - lifecycle.reconcile([markdownTab('live')], (update) => { - docs = update(docs) - }) - - expect([...docs.keys()]).toEqual(['live']) - }) - - it('retains a dirty orphan when reconciliation keeps its draft tab', () => { - const lifecycle = new MobileSessionMarkdownDocLifecycle() - let docs = new Map([['draft', readyDoc('unsaved', true)]]) - - lifecycle.reconcile([markdownTab('draft')], (update) => { - docs = update(docs) - }) - - expect(docs.get('draft')).toEqual(readyDoc('unsaved', true)) - }) - - it('does not let a late read resurrect a closed tab', async () => { - const lifecycle = new MobileSessionMarkdownDocLifecycle() - const tab = markdownTab('late') - let docs = new Map() - const updateDocs = (update: (current: typeof docs) => typeof docs) => { - docs = update(docs) - } - lifecycle.reconcile([tab], updateDocs) - let resolveRead: (doc: ReadyMarkdownDoc) => void = () => undefined - const read = new Promise((resolve) => { - resolveRead = resolve - }) - - const pending = lifecycle.load(tab, updateDocs, () => read) - lifecycle.close(tab.id, updateDocs) - resolveRead(readyDoc('late payload')) - await pending - - expect(docs.size).toBe(0) - }) - - it('does not let an old path overwrite a replacement using the same tab id', async () => { - const lifecycle = new MobileSessionMarkdownDocLifecycle() - const oldTab = markdownTab('same', 'old.md') - let docs = new Map() - const updateDocs = (update: (current: typeof docs) => typeof docs) => { - docs = update(docs) - } - lifecycle.reconcile([oldTab], updateDocs) - let resolveRead: (doc: ReadyMarkdownDoc) => void = () => undefined - const pending = lifecycle.load( - oldTab, - updateDocs, - () => - new Promise((resolve) => { - resolveRead = resolve - }) - ) - - lifecycle.reconcile([markdownTab('same', 'new.md')], updateDocs) - resolveRead(readyDoc('old payload')) - await pending - - expect(docs.size).toBe(0) - }) -}) diff --git a/mobile/src/session/mobile-session-markdown-doc-lifecycle.ts b/mobile/src/session/mobile-session-markdown-doc-lifecycle.ts deleted file mode 100644 index 29554ce3c69..00000000000 --- a/mobile/src/session/mobile-session-markdown-doc-lifecycle.ts +++ /dev/null @@ -1,147 +0,0 @@ -import type { - MarkdownDocState, - MobileSessionTab -} from '../../app/h/[hostId]/session/mobile-session-route-types' - -type MobileMarkdownTab = Extract - -type MobileMarkdownDocUpdater = ( - update: (current: Map) => Map -) => void - -type MobileMarkdownDocLoadToken = { - generation: number - requestId: number - tabId: string - tabIdentity: string -} - -export class MobileSessionMarkdownDocLifecycle { - private generation = 0 - private nextRequestId = 0 - private readonly liveTabIdentityById = new Map() - private readonly activeRequestIdByTabId = new Map() - - reconcile(tabs: readonly MobileSessionTab[], updateDocs: MobileMarkdownDocUpdater): void { - const nextIdentityById = new Map( - tabs - .filter((tab): tab is MobileMarkdownTab => tab.type === 'markdown') - .map((tab) => [tab.id, markdownTabIdentity(tab)]) - ) - const replacedTabIds = new Set() - for (const [tabId, currentIdentity] of this.liveTabIdentityById) { - const nextIdentity = nextIdentityById.get(tabId) - if (nextIdentity && nextIdentity !== currentIdentity) { - replacedTabIds.add(tabId) - } - } - for (const tabId of this.activeRequestIdByTabId.keys()) { - if (nextIdentityById.get(tabId) !== this.liveTabIdentityById.get(tabId)) { - this.activeRequestIdByTabId.delete(tabId) - } - } - this.liveTabIdentityById.clear() - for (const [tabId, identity] of nextIdentityById) { - this.liveTabIdentityById.set(tabId, identity) - } - updateDocs((current) => retainLiveMarkdownDocs(current, nextIdentityById, replacedTabIds)) - } - - async load( - tab: MobileMarkdownTab, - updateDocs: MobileMarkdownDocUpdater, - read: () => Promise> - ): Promise { - const token = this.beginLoad(tab) - if (!token) { - return - } - updateDocs((current) => new Map(current).set(tab.id, { status: 'loading' })) - try { - const doc = await read() - if (this.finishLoad(token)) { - updateDocs((current) => new Map(current).set(tab.id, doc)) - } - } catch { - if (this.finishLoad(token)) { - updateDocs((current) => - new Map(current).set(tab.id, { - status: 'error', - message: "Couldn't load markdown" - }) - ) - } - } - } - - close(tabId: string, updateDocs: MobileMarkdownDocUpdater): void { - this.liveTabIdentityById.delete(tabId) - this.activeRequestIdByTabId.delete(tabId) - updateDocs((current) => removeMarkdownDoc(current, tabId)) - } - - reset(): void { - this.generation += 1 - this.liveTabIdentityById.clear() - this.activeRequestIdByTabId.clear() - } - - private beginLoad(tab: MobileMarkdownTab): MobileMarkdownDocLoadToken | null { - const tabIdentity = markdownTabIdentity(tab) - if (this.liveTabIdentityById.get(tab.id) !== tabIdentity) { - return null - } - const requestId = ++this.nextRequestId - this.activeRequestIdByTabId.set(tab.id, requestId) - return { - generation: this.generation, - requestId, - tabId: tab.id, - tabIdentity - } - } - - private finishLoad(token: MobileMarkdownDocLoadToken): boolean { - if ( - token.generation !== this.generation || - this.liveTabIdentityById.get(token.tabId) !== token.tabIdentity || - this.activeRequestIdByTabId.get(token.tabId) !== token.requestId - ) { - return false - } - this.activeRequestIdByTabId.delete(token.tabId) - return true - } -} - -function retainLiveMarkdownDocs( - docs: Map, - liveIdentityById: ReadonlyMap, - replacedTabIds: ReadonlySet -): Map { - let next: Map | null = null - for (const tabId of docs.keys()) { - if (liveIdentityById.has(tabId) && !replacedTabIds.has(tabId)) { - continue - } - next ??= new Map(docs) - next.delete(tabId) - } - return next ?? docs -} - -function removeMarkdownDoc( - docs: Map, - tabId: string -): Map { - if (!docs.has(tabId)) { - return docs - } - const next = new Map(docs) - next.delete(tabId) - return next -} - -function markdownTabIdentity(tab: MobileMarkdownTab): string { - return JSON.stringify([tab.id, tab.filePath, tab.relativePath]) -} diff --git a/mobile/src/session/mobile-terminal-diagnostics.test.ts b/mobile/src/session/mobile-terminal-diagnostics.test.ts index d1c49794664..831ad4bfccc 100644 --- a/mobile/src/session/mobile-terminal-diagnostics.test.ts +++ b/mobile/src/session/mobile-terminal-diagnostics.test.ts @@ -2,7 +2,6 @@ import { describe, expect, it, vi } from 'vitest' import { getMobileTerminalDiagnosticErrorName, logMobileTerminalDiagnostic, - MOBILE_TERMINAL_DIAGNOSTIC_MAX_TRACKED_HANDLES, MobileTerminalDiagnostics, shortenMobileTerminalDiagnosticId } from './mobile-terminal-diagnostics' @@ -44,28 +43,4 @@ describe('mobile terminal diagnostics', () => { expect(log).toHaveBeenCalledTimes(2) log.mockRestore() }) - - it('accepts the exact diagnostic handle cap and evicts oldest at one over', () => { - const log = vi.spyOn(console, 'log').mockImplementation(() => {}) - const diagnostics = new MobileTerminalDiagnostics() - for (let index = 0; index < MOBILE_TERMINAL_DIAGNOSTIC_MAX_TRACKED_HANDLES; index += 1) { - diagnostics.streamSkipped(`terminal-${index}`, 'inactive', false) - diagnostics.firstStreamEvent(`terminal-${index}`, 1, 'subscribed') - } - expect(diagnostics.retainedHandleCountsForTests()).toEqual({ - streamGates: MOBILE_TERMINAL_DIAGNOSTIC_MAX_TRACKED_HANDLES, - firstEvents: MOBILE_TERMINAL_DIAGNOSTIC_MAX_TRACKED_HANDLES - }) - - diagnostics.streamSkipped('one-over', 'inactive', false) - diagnostics.firstStreamEvent('one-over', 1, 'subscribed') - expect(diagnostics.retainedHandleCountsForTests()).toEqual({ - streamGates: MOBILE_TERMINAL_DIAGNOSTIC_MAX_TRACKED_HANDLES, - firstEvents: MOBILE_TERMINAL_DIAGNOSTIC_MAX_TRACKED_HANDLES - }) - diagnostics.streamSkipped('terminal-0', 'inactive', false) - diagnostics.firstStreamEvent('terminal-0', 1, 'subscribed') - expect(log).toHaveBeenCalledTimes(MOBILE_TERMINAL_DIAGNOSTIC_MAX_TRACKED_HANDLES * 2 + 4) - log.mockRestore() - }) }) diff --git a/mobile/src/session/mobile-terminal-diagnostics.ts b/mobile/src/session/mobile-terminal-diagnostics.ts index 4005d564ad3..8695928ed03 100644 --- a/mobile/src/session/mobile-terminal-diagnostics.ts +++ b/mobile/src/session/mobile-terminal-diagnostics.ts @@ -1,5 +1,4 @@ const MOBILE_TERMINAL_DIAGNOSTIC_TAG = '[terminal-diagnostic]' -export const MOBILE_TERMINAL_DIAGNOSTIC_MAX_TRACKED_HANDLES = 1_024 type MobileTerminalDiagnosticValue = string | number | boolean | null | undefined @@ -91,7 +90,7 @@ export class MobileTerminalDiagnostics { if (this.streamGateByHandle.get(handle) === reason) { return } - setBoundedHandleState(this.streamGateByHandle, handle, reason) + this.streamGateByHandle.set(handle, reason) logMobileTerminalDiagnostic('stream-skipped', { handle: shortenMobileTerminalDiagnosticId(handle), reason, @@ -114,7 +113,7 @@ export class MobileTerminalDiagnostics { if (this.firstStreamEventSeqByHandle.get(handle) === seq) { return } - setBoundedHandleState(this.firstStreamEventSeqByHandle, handle, seq) + this.firstStreamEventSeqByHandle.set(handle, seq) logMobileTerminalDiagnostic('stream-first-event', { handle: shortenMobileTerminalDiagnosticId(handle), seq, @@ -268,14 +267,6 @@ export class MobileTerminalDiagnostics { }) } - /** Test-only evidence for process-retained diagnostic dedupe state. */ - retainedHandleCountsForTests(): { streamGates: number; firstEvents: number } { - return { - streamGates: this.streamGateByHandle.size, - firstEvents: this.firstStreamEventSeqByHandle.size - } - } - private logTabs( event: 'tabs-applied' | 'tabs-fetch-success', snapshot: DiagnosticTabsSnapshot, @@ -298,15 +289,3 @@ export class MobileTerminalDiagnostics { }) } } - -function setBoundedHandleState(map: Map, handle: string, value: T): void { - map.delete(handle) - map.set(handle, value) - while (map.size > MOBILE_TERMINAL_DIAGNOSTIC_MAX_TRACKED_HANDLES) { - const oldestHandle = map.keys().next().value - if (typeof oldestHandle !== 'string') { - return - } - map.delete(oldestHandle) - } -} diff --git a/mobile/src/session/pr-actions-engine.test.ts b/mobile/src/session/pr-actions-engine.test.ts index 17b22fa87f1..3656b2732f6 100644 --- a/mobile/src/session/pr-actions-engine.test.ts +++ b/mobile/src/session/pr-actions-engine.test.ts @@ -1,9 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { - MOBILE_PR_ACTIONS_MAX_REVIEWER_FIELDS, - PrActionsEngine, - type PrActionMutations -} from './pr-actions-engine' +import { PrActionsEngine, type PrActionMutations } from './pr-actions-engine' import type { GitHubPrMutationOutcome } from './github-pr-mutations' function deferred() { @@ -242,32 +238,3 @@ describe('PrActionsEngine — PR identity changes', () => { expect(refetch).not.toHaveBeenCalled() }) }) - -describe('PrActionsEngine — reviewer retention', () => { - it('releases settled reviewer fields so sequential reviewer actions do not accumulate', async () => { - const engine = makeEngine({}) - - for (let index = 0; index < MOBILE_PR_ACTIONS_MAX_REVIEWER_FIELDS + 1; index += 1) { - await engine.requestReviewer(`reviewer-${index}`) - } - - expect(engine.retainedReviewerFieldCountForTests()).toBe(0) - }) - - it('accepts the exact concurrent reviewer cap and rejects one over', async () => { - const pending = deferred() - const engine = makeEngine({ requestReviewers: () => pending.promise }) - const actions = Array.from({ length: MOBILE_PR_ACTIONS_MAX_REVIEWER_FIELDS }, (_, index) => - engine.requestReviewer(`reviewer-${index}`) - ) - expect(engine.retainedReviewerFieldCountForTests()).toBe(MOBILE_PR_ACTIONS_MAX_REVIEWER_FIELDS) - - await expect(engine.requestReviewer('one-over')).rejects.toThrow( - 'Too many reviewer actions are pending' - ) - - pending.resolve({ ok: true }) - await Promise.all(actions) - expect(engine.retainedReviewerFieldCountForTests()).toBe(0) - }) -}) diff --git a/mobile/src/session/pr-actions-engine.ts b/mobile/src/session/pr-actions-engine.ts index 04bc1ed0afe..ce19c9bbbf9 100644 --- a/mobile/src/session/pr-actions-engine.ts +++ b/mobile/src/session/pr-actions-engine.ts @@ -8,9 +8,6 @@ import type { PrActionMutations } from './pr-action-mutation-contract' export type { PrActionMutations } from './pr-action-mutation-contract' -export const MOBILE_PR_ACTIONS_MAX_REVIEWER_FIELDS = 64 -const MOBILE_PR_ACTIONS_MAX_REVIEWER_LOGIN_CHARACTERS = 256 - // Pure (React-free) engine for the PR mutation actions: owns optimistic fields, // busy/error/blocked state, and the success/transient/permanent routing. The hook // is a thin adapter that subscribes to `onChange` and exposes these methods. Kept @@ -99,25 +96,12 @@ export class PrActionsEngine { private reviewerField(login: string): OptimisticField { let f = this.reviewerFields.get(login) if (!f) { - if ( - login.length === 0 || - login.length > MOBILE_PR_ACTIONS_MAX_REVIEWER_LOGIN_CHARACTERS || - this.reviewerFields.size >= MOBILE_PR_ACTIONS_MAX_REVIEWER_FIELDS - ) { - throw new Error('Too many reviewer actions are pending') - } f = createOptimisticField(this.cfg.onChange) this.reviewerFields.set(login, f) } return f } - private releaseReviewerFieldIfIdle(login: string, field: OptimisticField): void { - if (field.peek() === undefined && this.reviewerFields.get(login) === field) { - this.reviewerFields.delete(login) - } - } - // Why: action start pairs setBusy + setError(null); skip notify when unchanged // so we don't force a full PR panel re-render for free. private setBusy(key: PrActionBusyKey | null): void { @@ -280,7 +264,6 @@ export class PrActionsEngine { }) } finally { this.clearBusyIfOwned(identity, { kind: 'reviewer', login }) - this.releaseReviewerFieldIfIdle(login, field) } } @@ -303,7 +286,6 @@ export class PrActionsEngine { }) } finally { this.clearBusyIfOwned(identity, { kind: 'reviewer', login }) - this.releaseReviewerFieldIfIdle(login, field) } } @@ -337,9 +319,4 @@ export class PrActionsEngine { const f = this.reviewerFields.get(login) return f ? f.resolve(authoritative) : authoritative } - - /** Test-only evidence for retained optimistic reviewer state. */ - retainedReviewerFieldCountForTests(): number { - return this.reviewerFields.size - } } diff --git a/mobile/src/session/use-mobile-native-chat-file-search.test.ts b/mobile/src/session/use-mobile-native-chat-file-search.test.ts index bbd4e8bb4f7..e0500019844 100644 --- a/mobile/src/session/use-mobile-native-chat-file-search.test.ts +++ b/mobile/src/session/use-mobile-native-chat-file-search.test.ts @@ -2,10 +2,7 @@ import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { RpcClient } from '../transport/rpc-client' -import { - retainMobileNativeChatFilePaths, - useMobileNativeChatFileSearch -} from './use-mobile-native-chat-file-search' +import { useMobileNativeChatFileSearch } from './use-mobile-native-chat-file-search' type SearchState = ReturnType @@ -166,15 +163,3 @@ describe('useMobileNativeChatFileSearch', () => { expect(state?.nativeChatFilePaths).toEqual(['docs/readme.md']) }) }) - -describe('retainMobileNativeChatFilePaths', () => { - it('accepts exact count and retained-byte limits and truncates at one over', () => { - const result = { - files: [{ relativePath: 'a' }, { relativePath: 'bb' }, { relativePath: 'ignored' }] - } - const exactBytes = 'a'.length * 2 + 64 + ('bb'.length * 2 + 64) - - expect(retainMobileNativeChatFilePaths(result, 2, exactBytes)).toEqual(['a', 'bb']) - expect(retainMobileNativeChatFilePaths(result, 3, exactBytes - 1)).toEqual(['a']) - }) -}) diff --git a/mobile/src/session/use-mobile-native-chat-file-search.ts b/mobile/src/session/use-mobile-native-chat-file-search.ts index 53296947f32..53df98e3ee0 100644 --- a/mobile/src/session/use-mobile-native-chat-file-search.ts +++ b/mobile/src/session/use-mobile-native-chat-file-search.ts @@ -5,31 +5,12 @@ import { rankSuggestions } from './mobile-native-chat-autocomplete' const FILE_SEARCH_DEBOUNCE_MS = 120 const FILE_SEARCH_RESULT_LIMIT = 16 const FILE_SEARCH_QUERY_CACHE_LIMIT = 20 -export const MOBILE_NATIVE_CHAT_LEGACY_FILE_MAX_PATHS = 50_000 -export const MOBILE_NATIVE_CHAT_LEGACY_FILE_MAX_RETAINED_BYTES = 8 * 1024 * 1024 -const FILE_SEARCH_RESULT_MAX_RETAINED_BYTES = 256 * 1024 -export function retainMobileNativeChatFilePaths( - result: unknown, - maxPaths: number, - maxRetainedBytes: number -): string[] { +function extractPaths(result: unknown): string[] { const files = (result as { files?: Array<{ relativePath?: string }> }).files ?? [] - const paths: string[] = [] - let retainedBytes = 0 - for (const file of files) { - const path = file.relativePath - if (typeof path !== 'string' || path.length === 0) { - continue - } - const nextBytes = retainedBytes + path.length * 2 + 64 - if (paths.length >= maxPaths || nextBytes > maxRetainedBytes) { - break - } - paths.push(path) - retainedBytes = nextBytes - } - return paths + return files + .map((file) => file.relativePath ?? '') + .filter((path): path is string => path.length > 0) } /** Debounces current-host path searches, bounds the mobile result/cache, and @@ -113,11 +94,7 @@ export function useMobileNativeChatFileSearch(args: { if (!response.ok || generationRef.current !== generation) { return null } - const paths = retainMobileNativeChatFilePaths( - response.result, - MOBILE_NATIVE_CHAT_LEGACY_FILE_MAX_PATHS, - MOBILE_NATIVE_CHAT_LEGACY_FILE_MAX_RETAINED_BYTES - ) + const paths = extractPaths(response.result) legacyPathsRef.current = paths return paths }) @@ -152,13 +129,7 @@ export function useMobileNativeChatFileSearch(args: { }) if (response.ok) { searchSupportedRef.current = true - applyPaths( - retainMobileNativeChatFilePaths( - response.result, - FILE_SEARCH_RESULT_LIMIT, - FILE_SEARCH_RESULT_MAX_RETAINED_BYTES - ) - ) + applyPaths(extractPaths(response.result)) return } if (response.error.code === 'method_not_found') { diff --git a/mobile/src/storage/preferences.test.ts b/mobile/src/storage/preferences.test.ts index 3090be4a130..b636ea12d3e 100644 --- a/mobile/src/storage/preferences.test.ts +++ b/mobile/src/storage/preferences.test.ts @@ -1,5 +1,5 @@ import AsyncStorage from '@react-native-async-storage/async-storage' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { HOST_DOCK_MAX_WIDTH, HOST_DOCK_MIN_WIDTH, @@ -10,29 +10,22 @@ import { clampHostSidebarWidth, loadDisabledTerminalLiveInputHandles, loadHostSidebarWidth, - loadPinnedIds, loadPushNotificationsEnabled, loadTerminalAutocompleteEnabled, loadTerminalLinkOpenMode, readPushNotificationsPreference, readDisabledTerminalLiveInputHandlesPreference, - MOBILE_STORED_ID_SET_MAX_ENTRIES, - MOBILE_STORED_ID_SET_MAX_STORAGE_CHARACTERS, saveDisabledTerminalLiveInputHandles, saveHostSidebarWidth, - savePinnedIds, savePushNotificationsEnabled, saveTerminalAutocompleteEnabled, saveTerminalLinkOpenMode } from './preferences' import { - SESSION_VIEW_OVERRIDE_MAX_ACTIVE_BARRIERS, - SESSION_VIEW_OVERRIDE_MAX_ENTRIES, loadDefaultSessionView, loadSessionViewOverrides, readDefaultSessionViewPreference, readSessionViewOverridesPreference, - resetSessionViewPreferencesForTests, saveDefaultSessionView, updateSessionViewOverride } from './session-view-preferences' @@ -54,15 +47,10 @@ function deferred(): { promise: Promise; resolve: (value: T) => void } { describe('session view preference', () => { beforeEach(() => { - resetSessionViewPreferencesForTests() vi.mocked(AsyncStorage.getItem).mockReset() vi.mocked(AsyncStorage.setItem).mockReset() }) - afterEach(() => { - resetSessionViewPreferencesForTests() - }) - it('defaults to terminal and persists the chat default', async () => { vi.mocked(AsyncStorage.getItem).mockResolvedValue(null) await expect(loadDefaultSessionView()).resolves.toBe('terminal') @@ -282,72 +270,6 @@ describe('session view preference', () => { JSON.stringify({ tab: 'chat' }) ) }) - - it('loads the exact override count and rejects one over without parsing it into state', async () => { - const exact = Object.fromEntries( - Array.from({ length: SESSION_VIEW_OVERRIDE_MAX_ENTRIES }, (_, index) => [ - `tab-${index}`, - 'chat' - ]) - ) - vi.mocked(AsyncStorage.getItem).mockResolvedValue(JSON.stringify(exact)) - - await expect(readSessionViewOverridesPreference('host', 'worktree')).resolves.toEqual({ - overrides: new Map(Object.entries(exact)) as Map, - loaded: true - }) - - vi.mocked(AsyncStorage.getItem).mockResolvedValue( - JSON.stringify({ ...exact, 'one-over': 'terminal' }) - ) - await expect(readSessionViewOverridesPreference('host', 'worktree')).resolves.toEqual({ - overrides: new Map(), - loaded: false - }) - }) - - it('evicts the oldest override when a new user choice exceeds the count cap', async () => { - const exact = Object.fromEntries( - Array.from({ length: SESSION_VIEW_OVERRIDE_MAX_ENTRIES }, (_, index) => [ - `tab-${index}`, - 'chat' - ]) - ) - vi.mocked(AsyncStorage.getItem).mockResolvedValue(JSON.stringify(exact)) - - await updateSessionViewOverride('host', 'worktree', 'new-tab', 'terminal') - - const stored = JSON.parse(vi.mocked(AsyncStorage.setItem).mock.calls[0]![1]) as Record< - string, - string - > - expect(Object.keys(stored)).toHaveLength(SESSION_VIEW_OVERRIDE_MAX_ENTRIES) - expect(stored['tab-0']).toBeUndefined() - expect(stored['new-tab']).toBe('terminal') - }) - - it('caps unresolved scoped write barriers and accepts another after one settles', async () => { - const blocked = deferred() - vi.mocked(AsyncStorage.getItem).mockResolvedValue(null) - vi.mocked(AsyncStorage.setItem).mockImplementation(() => blocked.promise) - const writes = Array.from({ length: SESSION_VIEW_OVERRIDE_MAX_ACTIVE_BARRIERS }, (_, index) => - updateSessionViewOverride(`host-${index}`, 'worktree', 'tab', 'chat') - ) - await vi.waitFor(() => - expect(AsyncStorage.setItem).toHaveBeenCalledTimes(SESSION_VIEW_OVERRIDE_MAX_ACTIVE_BARRIERS) - ) - - await expect(updateSessionViewOverride('one-over', 'worktree', 'tab', 'chat')).rejects.toThrow( - 'Too many session view override writes are pending' - ) - - blocked.resolve() - await Promise.all(writes) - vi.mocked(AsyncStorage.setItem).mockResolvedValue(undefined) - await expect( - updateSessionViewOverride('recovered', 'worktree', 'tab', 'chat') - ).resolves.toBeUndefined() - }) }) describe('push notification preference', () => { @@ -484,57 +406,6 @@ describe('terminal live input disabled handles preference', () => { JSON.stringify(['pty-2', 'pty-1']) ) }) - - it('accepts the exact handle cap and persists the newest handles at one over', async () => { - const exact = new Set( - Array.from({ length: MOBILE_STORED_ID_SET_MAX_ENTRIES }, (_, index) => `pty-${index}`) - ) - await saveDisabledTerminalLiveInputHandles('host', 'worktree', exact) - expect(JSON.parse(vi.mocked(AsyncStorage.setItem).mock.calls[0]![1])).toHaveLength( - MOBILE_STORED_ID_SET_MAX_ENTRIES - ) - - exact.add('one-over') - await saveDisabledTerminalLiveInputHandles('host', 'worktree', exact) - const retained = JSON.parse(vi.mocked(AsyncStorage.setItem).mock.calls[1]![1]) as string[] - expect(retained).toHaveLength(MOBILE_STORED_ID_SET_MAX_ENTRIES) - expect(retained).not.toContain('pty-0') - expect(retained).toContain('one-over') - }) - - it('rejects an oversized handle payload before parsing', async () => { - vi.mocked(AsyncStorage.getItem).mockResolvedValue( - 'x'.repeat(MOBILE_STORED_ID_SET_MAX_STORAGE_CHARACTERS + 1) - ) - - await expect( - readDisabledTerminalLiveInputHandlesPreference('host', 'worktree') - ).resolves.toEqual({ handles: new Set(), loaded: false }) - }) -}) - -describe('pinned worktree ids preference', () => { - beforeEach(() => { - vi.mocked(AsyncStorage.getItem).mockReset() - vi.mocked(AsyncStorage.setItem).mockReset() - }) - - it('round-trips normal pins unchanged', async () => { - const pins = new Set(['worktree-2', 'worktree-1']) - await savePinnedIds('host', pins) - expect(AsyncStorage.setItem).toHaveBeenCalledWith('orca:pins:host', JSON.stringify([...pins])) - - vi.mocked(AsyncStorage.getItem).mockResolvedValue(JSON.stringify([...pins])) - await expect(loadPinnedIds('host')).resolves.toEqual(pins) - }) - - it('bounds oversized durable pin payloads', async () => { - vi.mocked(AsyncStorage.getItem).mockResolvedValue( - 'x'.repeat(MOBILE_STORED_ID_SET_MAX_STORAGE_CHARACTERS + 1) - ) - - await expect(loadPinnedIds('host')).resolves.toEqual(new Set()) - }) }) describe('host sidebar width preference', () => { diff --git a/mobile/src/storage/preferences.ts b/mobile/src/storage/preferences.ts index d6128d466ab..5173ac5bc8a 100644 --- a/mobile/src/storage/preferences.ts +++ b/mobile/src/storage/preferences.ts @@ -2,9 +2,6 @@ import AsyncStorage from '@react-native-async-storage/async-storage' const PINS_PREFIX = 'orca:pins:' const NOTIF_KEY = 'orca:pushNotificationsEnabled' -export const MOBILE_STORED_ID_SET_MAX_ENTRIES = 10_000 -export const MOBILE_STORED_ID_SET_MAX_STORAGE_CHARACTERS = 1024 * 1024 -export const MOBILE_STORED_ID_MAX_CHARACTERS = 4_096 export type PushNotificationsPreference = { readonly value: boolean | null @@ -104,10 +101,7 @@ export async function readDisabledTerminalLiveInputHandlesPreference( if (!raw) { return { handles: new Set(), loaded: true } } - if (raw.length > MOBILE_STORED_ID_SET_MAX_STORAGE_CHARACTERS) { - return { handles: new Set(), loaded: false } - } - return { handles: new Set(retainStoredPreferenceIds(JSON.parse(raw)).ids), loaded: true } + return { handles: new Set(stringArray(JSON.parse(raw))), loaded: true } } catch { return { handles: new Set(), loaded: false } } @@ -128,7 +122,7 @@ export async function saveDisabledTerminalLiveInputHandles( ): Promise { await AsyncStorage.setItem( terminalLiveInputDisabledKey(hostId, worktreeId), - retainStoredPreferenceIds(handles).serialized + JSON.stringify([...handles]) ) } @@ -216,59 +210,24 @@ export async function saveTerminalLinkOpenMode(mode: MobileTerminalLinkOpenMode) await AsyncStorage.setItem(TERMINAL_LINK_OPEN_MODE_KEY, mode) } +function stringArray(value: unknown): string[] { + return Array.isArray(value) + ? value.filter((item): item is string => typeof item === 'string') + : [] +} + export async function loadPinnedIds(hostId: string): Promise> { try { const raw = await AsyncStorage.getItem(PINS_PREFIX + hostId) if (!raw) { return new Set() } - if (raw.length > MOBILE_STORED_ID_SET_MAX_STORAGE_CHARACTERS) { - return new Set() - } - return new Set(retainStoredPreferenceIds(JSON.parse(raw)).ids) + return new Set(stringArray(JSON.parse(raw))) } catch { return new Set() } } export async function savePinnedIds(hostId: string, ids: Set): Promise { - await AsyncStorage.setItem(PINS_PREFIX + hostId, retainStoredPreferenceIds(ids).serialized) -} - -function retainStoredPreferenceIds(value: unknown): { ids: string[]; serialized: string } { - const values = Array.isArray(value) - ? value - : value && typeof value === 'object' && Symbol.iterator in value - ? (value as Iterable) - : [] - const retained = new Map() - let entryCharacters = 0 - for (const candidate of values) { - if ( - typeof candidate !== 'string' || - candidate.length > MOBILE_STORED_ID_MAX_CHARACTERS || - retained.has(candidate) - ) { - continue - } - const serialized = JSON.stringify(candidate) - retained.set(candidate, serialized) - entryCharacters += serialized.length - while ( - retained.size > MOBILE_STORED_ID_SET_MAX_ENTRIES || - 2 + entryCharacters + Math.max(0, retained.size - 1) > - MOBILE_STORED_ID_SET_MAX_STORAGE_CHARACTERS - ) { - const oldestId = retained.keys().next().value - if (typeof oldestId !== 'string') { - break - } - entryCharacters -= retained.get(oldestId)?.length ?? 0 - retained.delete(oldestId) - } - } - return { - ids: [...retained.keys()], - serialized: `[${[...retained.values()].join(',')}]` - } + await AsyncStorage.setItem(PINS_PREFIX + hostId, JSON.stringify([...ids])) } diff --git a/mobile/src/storage/session-view-preferences.ts b/mobile/src/storage/session-view-preferences.ts index bef1b3b3c92..cd9597d6f77 100644 --- a/mobile/src/storage/session-view-preferences.ts +++ b/mobile/src/storage/session-view-preferences.ts @@ -5,11 +5,6 @@ export type MobileSessionView = 'terminal' | 'chat' const DEFAULT_SESSION_VIEW_KEY = 'orca:defaultSessionView' const NATIVE_CHAT_TABS_PREFIX = 'orca:nativeChatTabs:' -export const SESSION_VIEW_OVERRIDE_MAX_ENTRIES = 4_096 -export const SESSION_VIEW_OVERRIDE_MAX_STORAGE_CHARACTERS = 512 * 1024 -export const SESSION_VIEW_OVERRIDE_MAX_TAB_ID_CHARACTERS = 1_024 -export const SESSION_VIEW_OVERRIDE_MAX_ACTIVE_BARRIERS = 64 -const SESSION_VIEW_OVERRIDE_MAX_SCOPE_KEY_CHARACTERS = 4_096 // Why: default stays terminal so native chat remains strictly opt-in. export const DEFAULT_SESSION_VIEW: MobileSessionView = 'terminal' @@ -17,11 +12,8 @@ export const DEFAULT_SESSION_VIEW: MobileSessionView = 'terminal' let defaultViewWriteBarrier: Promise | null = null const overrideUpdateBarriers = new Map>() -function sessionViewOverridesKey(hostId: string, worktreeId: string): string | null { - const key = `${NATIVE_CHAT_TABS_PREFIX}${encodeURIComponent(hostId)}:${encodeURIComponent( - worktreeId - )}` - return key.length <= SESSION_VIEW_OVERRIDE_MAX_SCOPE_KEY_CHARACTERS ? key : null +function sessionViewOverridesKey(hostId: string, worktreeId: string): string { + return `${NATIVE_CHAT_TABS_PREFIX}${encodeURIComponent(hostId)}:${encodeURIComponent(worktreeId)}` } function clearDefaultViewWriteBarrier(barrier: Promise): void { @@ -85,9 +77,6 @@ async function readSessionViewOverridesStorage( if (!raw) { return { overrides: new Map(), loaded: true } } - if (raw.length > SESSION_VIEW_OVERRIDE_MAX_STORAGE_CHARACTERS) { - return { overrides: new Map(), loaded: false } - } let parsed: unknown try { parsed = JSON.parse(raw) as unknown @@ -97,38 +86,21 @@ async function readSessionViewOverridesStorage( } // Legacy format: an array of tab ids that were showing native chat. if (Array.isArray(parsed)) { - const overrides = new Map() - for (const id of parsed) { - if (typeof id !== 'string' || id.length > SESSION_VIEW_OVERRIDE_MAX_TAB_ID_CHARACTERS) { - continue - } - if (!overrides.has(id) && overrides.size >= SESSION_VIEW_OVERRIDE_MAX_ENTRIES) { - return { overrides: new Map(), loaded: false } - } - overrides.set(id, 'chat') + return { + overrides: new Map( + parsed + .filter((id): id is string => typeof id === 'string') + .map((id) => [id, 'chat' as const]) + ), + loaded: true } - return { overrides, loaded: true } } if (parsed && typeof parsed === 'object') { - const overrides = new Map() - const record = parsed as Record - for (const id in record) { - if (!Object.prototype.hasOwnProperty.call(record, id)) { - continue - } - const view = record[id] - if ( - (view !== 'terminal' && view !== 'chat') || - id.length > SESSION_VIEW_OVERRIDE_MAX_TAB_ID_CHARACTERS - ) { - continue - } - if (overrides.size >= SESSION_VIEW_OVERRIDE_MAX_ENTRIES) { - return { overrides: new Map(), loaded: false } - } - overrides.set(id, view) - } - return { overrides, loaded: true } + const entries = Object.entries(parsed as Record).filter( + (entry): entry is [string, MobileSessionView] => + entry[1] === 'terminal' || entry[1] === 'chat' + ) + return { overrides: new Map(entries), loaded: true } } return { overrides: new Map(), loaded: true } } @@ -149,9 +121,6 @@ export async function readSessionViewOverridesPreference( worktreeId: string ): Promise { const key = sessionViewOverridesKey(hostId, worktreeId) - if (!key) { - return { overrides: new Map(), loaded: false } - } await overrideUpdateBarriers.get(key) return readSessionViewOverridesStorage(key) } @@ -164,17 +133,7 @@ export async function updateSessionViewOverride( view: MobileSessionView ): Promise { const key = sessionViewOverridesKey(hostId, worktreeId) - if (!key || tabId.length === 0 || tabId.length > SESSION_VIEW_OVERRIDE_MAX_TAB_ID_CHARACTERS) { - throw new Error('Session view override identifier is too large') - } - const existingBarrier = overrideUpdateBarriers.get(key) - if ( - !existingBarrier && - overrideUpdateBarriers.size >= SESSION_VIEW_OVERRIDE_MAX_ACTIVE_BARRIERS - ) { - throw new Error('Too many session view override writes are pending') - } - const previous = existingBarrier ?? Promise.resolve() + const previous = overrideUpdateBarriers.get(key) ?? Promise.resolve() const update = previous.then(async () => { const current = await readSessionViewOverridesStorage(key) // Why: a transient read failure must not replace valid saved siblings with @@ -182,17 +141,8 @@ export async function updateSessionViewOverride( if (!current.loaded) { throw new Error('Session view overrides could not be read') } - if ( - !current.overrides.has(tabId) && - current.overrides.size >= SESSION_VIEW_OVERRIDE_MAX_ENTRIES - ) { - const oldestId = current.overrides.keys().next().value - if (typeof oldestId === 'string') { - current.overrides.delete(oldestId) - } - } current.overrides.set(tabId, view) - await AsyncStorage.setItem(key, serializeSessionViewOverrides(current.overrides, tabId)) + await AsyncStorage.setItem(key, JSON.stringify(Object.fromEntries(current.overrides))) }) const barrier = update.catch(() => undefined) overrideUpdateBarriers.set(key, barrier) @@ -204,48 +154,3 @@ export async function updateSessionViewOverride( } } } - -function serializeSessionViewOverrides( - overrides: Map, - protectedId: string -): string { - while (true) { - const serialized = trySerializeSessionViewOverrides(overrides) - if (serialized !== null) { - return serialized - } - let evictionId: string | null = null - for (const id of overrides.keys()) { - if (id !== protectedId) { - evictionId = id - break - } - } - if (evictionId === null) { - throw new Error('Session view override storage limit exceeded') - } - overrides.delete(evictionId) - } -} - -function trySerializeSessionViewOverrides( - overrides: ReadonlyMap -): string | null { - const entries: string[] = [] - let characters = 2 - for (const [id, view] of overrides) { - const entry = `${JSON.stringify(id)}:${JSON.stringify(view)}` - characters += entry.length + (entries.length > 0 ? 1 : 0) - if (characters > SESSION_VIEW_OVERRIDE_MAX_STORAGE_CHARACTERS) { - return null - } - entries.push(entry) - } - return `{${entries.join(',')}}` -} - -/** Test-only: drop pending module write barriers between cases. */ -export function resetSessionViewPreferencesForTests(): void { - defaultViewWriteBarrier = null - overrideUpdateBarriers.clear() -} diff --git a/mobile/src/tasks/github-pr-file-diff.test.ts b/mobile/src/tasks/github-pr-file-diff.test.ts index 5174dba5b64..2a59c4934a1 100644 --- a/mobile/src/tasks/github-pr-file-diff.test.ts +++ b/mobile/src/tasks/github-pr-file-diff.test.ts @@ -44,24 +44,6 @@ describe('buildGitHubPrFileDiffLines', () => { ]) }) - it('preserves a terminal lone carriage return like the previous line splitter', () => { - expect(buildGitHubPrFileDiffLines('', 'line\r')).toEqual([ - { key: '0:added:1', kind: 'added', newLineNumber: 1, text: 'line\r' } - ]) - }) - - it('treats one empty LF line and CRLF line as equal', () => { - expect(buildGitHubPrFileDiffLines('\n', '\r\n')).toEqual([ - { - key: '0:context:1:1', - kind: 'context', - oldLineNumber: 1, - newLineNumber: 1, - text: '' - } - ]) - }) - it('keeps all lines for large files without exact diff truncation', () => { const original = Array.from({ length: 500 }, (_, index) => `old-${index}`).join('\n') const modified = Array.from({ length: 500 }, (_, index) => `new-${index}`).join('\n') @@ -89,39 +71,6 @@ describe('buildGitHubPrFileDiffLines', () => { }) }) - it('counts newline-dense files without materializing discarded line arrays', () => { - const original = '\n'.repeat(200_000) - - const preview = buildGitHubPrFileDiffPreview(original, 'changed', 3) - - expect(preview.totalLineCount).toBe(200_001) - expect(preview.lines).toEqual([ - { key: '0:removed:1', kind: 'removed', oldLineNumber: 1, text: '' }, - { key: '1:removed:2', kind: 'removed', oldLineNumber: 2, text: '' }, - { key: '2:removed:3', kind: 'removed', oldLineNumber: 3, text: '' } - ]) - }) - - it('preserves common prefix and suffix rows around a streamed middle diff', () => { - const originalMiddle = Array.from({ length: 500 }, (_, index) => `old-${index}`) - const modifiedMiddle = Array.from({ length: 500 }, (_, index) => `new-${index}`) - const original = ['first', ...originalMiddle, 'last'].join('\r\n') - const modified = ['first', ...modifiedMiddle, 'last'].join('\r\n') - - const preview = buildGitHubPrFileDiffPreview(original, modified, 1_002) - - expect(preview.totalLineCount).toBe(1_002) - expect(preview.lines[0]).toMatchObject({ kind: 'context', text: 'first' }) - expect(preview.lines[1]).toMatchObject({ kind: 'removed', text: 'old-0' }) - expect(preview.lines[501]).toMatchObject({ kind: 'added', text: 'new-0' }) - expect(preview.lines.at(-1)).toMatchObject({ - kind: 'context', - oldLineNumber: 502, - newLineNumber: 502, - text: 'last' - }) - }) - it('can compute the total without retaining preview rows', () => { const modified = Array.from({ length: 20 }, (_, index) => `new-${index}`).join('\n') diff --git a/mobile/src/tasks/github-pr-file-diff.ts b/mobile/src/tasks/github-pr-file-diff.ts index 8165d255437..1be684197d4 100644 --- a/mobile/src/tasks/github-pr-file-diff.ts +++ b/mobile/src/tasks/github-pr-file-diff.ts @@ -1,11 +1,3 @@ -import { - collectGitHubPrFileLineRange, - createGitHubPrFileLineSource, - findGitHubPrFileCommonLineEdges, - visitGitHubPrFileLineRange, - type GitHubPrFileLineSource -} from './github-pr-file-line-scan' - export type GitHubPrFileDiffLine = { key: string kind: 'context' | 'added' | 'removed' @@ -26,6 +18,14 @@ type DiffOperation = const EXACT_DIFF_CELL_LIMIT = 160_000 +function splitContentLines(value: string): string[] { + if (!value) { + return [] + } + const lines = value.split(/\r?\n/) + return lines.at(-1) === '' ? lines.slice(0, -1) : lines +} + function appendExactLineDiff( original: string[], modified: string[], @@ -79,6 +79,37 @@ function appendExactLineDiff( } } +function appendMiddleDiff( + original: string[], + modified: string[], + appendOperation: (operation: DiffOperation) => void +): void { + if (original.length === 0) { + for (const newLine of modified) { + appendOperation({ kind: 'added', newLine }) + } + return + } + if (modified.length === 0) { + for (const oldLine of original) { + appendOperation({ kind: 'removed', oldLine }) + } + return + } + if (original.length * modified.length <= EXACT_DIFF_CELL_LIMIT) { + appendExactLineDiff(original, modified, appendOperation) + return + } + // Why: the Tasks diff UI renders a capped preview. Stream fallback rows so a + // generated PR file does not allocate thousands of discarded row objects. + for (const oldLine of original) { + appendOperation({ kind: 'removed', oldLine }) + } + for (const newLine of modified) { + appendOperation({ kind: 'added', newLine }) + } +} + export function buildGitHubPrFileDiffLines( originalContent: string, modifiedContent: string @@ -91,17 +122,42 @@ export function buildGitHubPrFileDiffPreview( modifiedContent: string, maxLines = Number.POSITIVE_INFINITY ): GitHubPrFileDiffPreview { - const original = createGitHubPrFileLineSource(originalContent) - const modified = createGitHubPrFileLineSource(modifiedContent) - const { prefixLineCount, suffixLineCount } = findGitHubPrFileCommonLineEdges(original, modified) - const originalMiddleLineCount = original.lineCount - prefixLineCount - suffixLineCount - const modifiedMiddleLineCount = modified.lineCount - prefixLineCount - suffixLineCount + const originalLines = splitContentLines(originalContent) + const modifiedLines = splitContentLines(modifiedContent) + let prefixLength = 0 + while ( + prefixLength < originalLines.length && + prefixLength < modifiedLines.length && + originalLines[prefixLength] === modifiedLines[prefixLength] + ) { + prefixLength += 1 + } + + let suffixLength = 0 + while ( + suffixLength < originalLines.length - prefixLength && + suffixLength < modifiedLines.length - prefixLength && + originalLines[originalLines.length - suffixLength - 1] === + modifiedLines[modifiedLines.length - suffixLength - 1] + ) { + suffixLength += 1 + } + + const originalMiddle = originalLines.slice( + prefixLength, + suffixLength === 0 ? originalLines.length : originalLines.length - suffixLength + ) + const modifiedMiddle = modifiedLines.slice( + prefixLength, + suffixLength === 0 ? modifiedLines.length : modifiedLines.length - suffixLength + ) + const result: GitHubPrFileDiffLine[] = [] let oldLineNumber = 1 let newLineNumber = 1 let operationIndex = 0 let totalLineCount = 0 - const normalizedMaxLines = Number.isNaN(maxLines) ? 0 : Math.max(0, Math.floor(maxLines)) + const normalizedMaxLines = Math.max(0, Math.floor(maxLines)) function appendOperation(operation: DiffOperation): void { const index = operationIndex operationIndex += 1 @@ -143,55 +199,15 @@ export function buildGitHubPrFileDiffPreview( newLineNumber += 1 } - function skipOperations(kind: DiffOperation['kind'], count: number): void { - operationIndex += count - totalLineCount += count - if (kind === 'context') { - oldLineNumber += count - newLineNumber += count - } else if (kind === 'removed') { - oldLineNumber += count - } else { - newLineNumber += count - } + for (let i = 0; i < prefixLength; i += 1) { + const line = originalLines[i] ?? '' + appendOperation({ kind: 'context', oldLine: line, newLine: line }) } - - function appendLineRange( - kind: DiffOperation['kind'], - source: GitHubPrFileLineSource, - startLine: number, - lineCount: number - ): void { - const retainedLineCount = Math.min(lineCount, Math.max(0, normalizedMaxLines - result.length)) - visitGitHubPrFileLineRange(source, startLine, retainedLineCount, (line) => { - if (kind === 'context') { - appendOperation({ kind, oldLine: line, newLine: line }) - } else if (kind === 'removed') { - appendOperation({ kind, oldLine: line }) - } else { - appendOperation({ kind, newLine: line }) - } - }) - skipOperations(kind, lineCount - retainedLineCount) + appendMiddleDiff(originalMiddle, modifiedMiddle, appendOperation) + for (let i = originalLines.length - suffixLength; i < originalLines.length; i += 1) { + const line = originalLines[i] ?? '' + appendOperation({ kind: 'context', oldLine: line, newLine: line }) } - appendLineRange('context', original, 0, prefixLineCount) - if (originalMiddleLineCount === 0) { - appendLineRange('added', modified, prefixLineCount, modifiedMiddleLineCount) - } else if (modifiedMiddleLineCount === 0) { - appendLineRange('removed', original, prefixLineCount, originalMiddleLineCount) - } else if (originalMiddleLineCount * modifiedMiddleLineCount <= EXACT_DIFF_CELL_LIMIT) { - appendExactLineDiff( - collectGitHubPrFileLineRange(original, prefixLineCount, originalMiddleLineCount), - collectGitHubPrFileLineRange(modified, prefixLineCount, modifiedMiddleLineCount), - appendOperation - ) - } else { - // Why: large generated files need exact counts without retaining discarded preview rows. - appendLineRange('removed', original, prefixLineCount, originalMiddleLineCount) - appendLineRange('added', modified, prefixLineCount, modifiedMiddleLineCount) - } - appendLineRange('context', original, original.lineCount - suffixLineCount, suffixLineCount) - return { lines: result, totalLineCount } } diff --git a/mobile/src/tasks/github-pr-file-line-scan.ts b/mobile/src/tasks/github-pr-file-line-scan.ts deleted file mode 100644 index 6031ce4d2f0..00000000000 --- a/mobile/src/tasks/github-pr-file-line-scan.ts +++ /dev/null @@ -1,171 +0,0 @@ -export type GitHubPrFileLineSource = { - content: string - lineCount: number -} - -export type GitHubPrFileCommonLineEdges = { - prefixLineCount: number - suffixLineCount: number -} - -type LineBounds = { - start: number - end: number -} - -type ForwardLineCursor = { - offset: number - remaining: number - source: GitHubPrFileLineSource -} - -type ReverseLineCursor = { - boundary: number - remaining: number - source: GitHubPrFileLineSource -} - -export function createGitHubPrFileLineSource(content: string): GitHubPrFileLineSource { - if (content.length === 0) { - return { content, lineCount: 0 } - } - let lineCount = content.endsWith('\n') ? 0 : 1 - for (let index = 0; index < content.length; index += 1) { - if (content.charCodeAt(index) === 10) { - lineCount += 1 - } - } - return { content, lineCount } -} - -export function findGitHubPrFileCommonLineEdges( - original: GitHubPrFileLineSource, - modified: GitHubPrFileLineSource -): GitHubPrFileCommonLineEdges { - const comparableLineCount = Math.min(original.lineCount, modified.lineCount) - const originalForward = createForwardCursor(original) - const modifiedForward = createForwardCursor(modified) - let prefixLineCount = 0 - while (prefixLineCount < comparableLineCount) { - const originalLine = takeNextLine(originalForward)! - const modifiedLine = takeNextLine(modifiedForward)! - if (!lineBoundsEqual(original, originalLine, modified, modifiedLine)) { - break - } - prefixLineCount += 1 - } - - const originalReverse = createReverseCursor(original) - const modifiedReverse = createReverseCursor(modified) - const maxSuffixLineCount = comparableLineCount - prefixLineCount - let suffixLineCount = 0 - while (suffixLineCount < maxSuffixLineCount) { - const originalLine = takePreviousLine(originalReverse)! - const modifiedLine = takePreviousLine(modifiedReverse)! - if (!lineBoundsEqual(original, originalLine, modified, modifiedLine)) { - break - } - suffixLineCount += 1 - } - return { prefixLineCount, suffixLineCount } -} - -export function visitGitHubPrFileLineRange( - source: GitHubPrFileLineSource, - startLine: number, - lineCount: number, - visit: (line: string) => void -): void { - if (lineCount <= 0) { - return - } - const cursor = createForwardCursor(source) - for (let index = 0; index < startLine; index += 1) { - takeNextLine(cursor) - } - for (let index = 0; index < lineCount; index += 1) { - const bounds = takeNextLine(cursor) - if (!bounds) { - return - } - visit(source.content.slice(bounds.start, bounds.end)) - } -} - -export function collectGitHubPrFileLineRange( - source: GitHubPrFileLineSource, - startLine: number, - lineCount: number -): string[] { - const lines: string[] = [] - visitGitHubPrFileLineRange(source, startLine, lineCount, (line) => lines.push(line)) - return lines -} - -function createForwardCursor(source: GitHubPrFileLineSource): ForwardLineCursor { - return { source, offset: 0, remaining: source.lineCount } -} - -function takeNextLine(cursor: ForwardLineCursor): LineBounds | null { - if (cursor.remaining === 0) { - return null - } - const { content } = cursor.source - const separator = content.indexOf('\n', cursor.offset) - const rawEnd = separator === -1 ? content.length : separator - const end = - separator !== -1 && rawEnd > cursor.offset && content.charCodeAt(rawEnd - 1) === 13 - ? rawEnd - 1 - : rawEnd - const bounds = { start: cursor.offset, end } - cursor.offset = separator === -1 ? content.length : separator + 1 - cursor.remaining -= 1 - return bounds -} - -function createReverseCursor(source: GitHubPrFileLineSource): ReverseLineCursor { - return { - source, - boundary: source.content.endsWith('\n') ? source.content.length - 1 : source.content.length, - remaining: source.lineCount - } -} - -function takePreviousLine(cursor: ReverseLineCursor): LineBounds | null { - if (cursor.remaining === 0) { - return null - } - const { content } = cursor.source - const separator = cursor.boundary === 0 ? -1 : content.lastIndexOf('\n', cursor.boundary - 1) - const start = separator + 1 - const end = - cursor.boundary < content.length && - cursor.boundary > start && - content.charCodeAt(cursor.boundary - 1) === 13 - ? cursor.boundary - 1 - : cursor.boundary - cursor.boundary = separator === -1 ? 0 : separator - cursor.remaining -= 1 - return { start, end } -} - -function lineBoundsEqual( - leftSource: GitHubPrFileLineSource, - left: LineBounds, - rightSource: GitHubPrFileLineSource, - right: LineBounds -): boolean { - const length = left.end - left.start - if (length !== right.end - right.start) { - return false - } - for (let offset = 0; offset < length; offset += 1) { - if ( - leftSource.content.charCodeAt(left.start + offset) !== - rightSource.content.charCodeAt(right.start + offset) - ) { - return false - } - } - return true -} diff --git a/mobile/src/tasks/mobile-pr-file-content-cache.test.ts b/mobile/src/tasks/mobile-pr-file-content-cache.test.ts deleted file mode 100644 index bb32226a98a..00000000000 --- a/mobile/src/tasks/mobile-pr-file-content-cache.test.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { GitHubPRFileContents } from '../../../src/shared/types' -import { - MOBILE_PR_FILE_CONTENT_CACHE_MAX_BYTES, - MOBILE_PR_FILE_CONTENT_CACHE_MAX_ENTRIES, - MobilePrFileContentCache, - createMobilePrFileContentKey, - createMobilePrFileContentScope, - getMobilePrFileContentByteCount, - getMobilePrFileContentsForScope -} from './mobile-pr-file-content-cache' -import { - createMobileItemPrFileContentScope, - createMobileProjectPrFileContentScope -} from './use-mobile-pr-file-content-cache' - -function contents(original: string, modified = ''): GitHubPRFileContents { - return { - original, - modified, - originalIsBinary: false, - modifiedIsBinary: false - } -} - -const scopeA = createMobilePrFileContentScope({ - source: 'item', - repoId: 'repo-1', - prNumber: 10, - headSha: 'head-a', - baseSha: 'base-a' -}) -const scopeB = createMobilePrFileContentScope({ - source: 'item', - repoId: 'repo-1', - prNumber: 11, - headSha: 'head-b', - baseSha: 'base-a' -}) - -describe('MobilePrFileContentCache', () => { - it('derives route scopes only from complete GitHub PR revisions', () => { - expect( - createMobileItemPrFileContentScope( - { provider: 'github', source: { type: 'pr', repoId: 'repo-1', number: 10 } }, - { provider: 'github', headSha: 'head-a', baseSha: 'base-a' } - ) - ).toBe(scopeA) - expect( - createMobileProjectPrFileContentScope( - { itemType: 'PULL_REQUEST', content: { number: 10 } }, - { id: 'repo-1' }, - { provider: 'github', headSha: 'head-a' } - ) - ).toBeNull() - expect( - createMobileProjectPrFileContentScope( - { itemType: 'PULL_REQUEST', content: { number: 10 } }, - { id: 'repo-1' }, - { provider: 'github', headSha: 'head-a', baseSha: 'base-a' }, - { host: 'github.example', owner: 'orca', repo: 'app' } - ) - ).toBe( - createMobilePrFileContentScope({ - source: 'project', - repoId: 'repo-1', - prNumber: 10, - headSha: 'head-a', - baseSha: 'base-a', - repository: { host: 'github.example', owner: 'orca', repo: 'app' } - }) - ) - }) - - it('uses the desktop diff budget as a hard byte cap with a smaller mobile entry cap', () => { - expect(MOBILE_PR_FILE_CONTENT_CACHE_MAX_ENTRIES).toBeLessThan(64) - expect(MOBILE_PR_FILE_CONTENT_CACHE_MAX_BYTES).toBe(24_000_000) - }) - - it('evicts least-recently-used files at the entry limit', () => { - const cache = new MobilePrFileContentCache(2, 100) - const a = createMobilePrFileContentKey({ path: 'a.ts' }) - const b = createMobilePrFileContentKey({ path: 'b.ts' }) - const c = createMobilePrFileContentKey({ path: 'c.ts' }) - cache.commitRequest(cache.beginRequest(scopeA, a), contents('a')) - cache.commitRequest(cache.beginRequest(scopeA, b), contents('b')) - expect(cache.select(scopeA, a).contents).toEqual(contents('a')) - - cache.commitRequest(cache.beginRequest(scopeA, c), contents('c')) - - expect(cache.evidence()).toMatchObject({ entryCount: 2, keysOldestFirst: [a, c] }) - expect(cache.select(scopeA, b).contents).toBeUndefined() - }) - - it('evicts older payloads to stay within the byte budget', () => { - const cache = new MobilePrFileContentCache(10, 8) - const a = createMobilePrFileContentKey({ path: 'a.ts' }) - const b = createMobilePrFileContentKey({ path: 'b.ts' }) - cache.commitRequest(cache.beginRequest(scopeA, a), contents('12345')) - cache.commitRequest(cache.beginRequest(scopeA, b), contents('67890')) - - expect(cache.evidence()).toMatchObject({ - entryCount: 1, - retainedBytes: 5, - keysOldestFirst: [b] - }) - }) - - it('measures retained UTF-8 bytes rather than JavaScript code units', () => { - expect(getMobilePrFileContentByteCount(contents('a😀', 'é'))).toBe(7) - }) - - it('counts the retained side when the other side is an oversized sentinel', () => { - expect( - getMobilePrFileContentByteCount({ - ...contents('', 'retained'), - originalTooLarge: true - }) - ).toBe(8) - }) - - it('rejects a response from a prior PR context after scope replacement', () => { - const cache = new MobilePrFileContentCache(2, 100) - const key = createMobilePrFileContentKey({ path: 'file.ts' }) - const oldRequest = cache.beginRequest(scopeA, key) - cache.activateScope(scopeB) - - expect(cache.commitRequest(oldRequest, contents('stale'))).toBe('stale') - expect(cache.evidence()).toMatchObject({ scope: scopeB, entryCount: 0, retainedBytes: 0 }) - }) - - it('rejects an older request after the active file selection changes', () => { - const cache = new MobilePrFileContentCache(2, 100) - const a = createMobilePrFileContentKey({ path: 'a.ts' }) - const b = createMobilePrFileContentKey({ path: 'b.ts' }) - const oldRequest = cache.beginRequest(scopeA, a) - cache.select(scopeA, b) - const currentRequest = cache.beginRequest(scopeA, b) - - expect(cache.commitRequest(oldRequest, contents('stale'))).toBe('stale') - expect(cache.commitRequest(currentRequest, contents('current'))).toBe('stored') - }) - - it('publishes only the active scope and refuses a single over-budget payload', () => { - const cache = new MobilePrFileContentCache(2, 4) - const key = createMobilePrFileContentKey({ path: 'file.ts' }) - expect(cache.commitRequest(cache.beginRequest(scopeA, key), contents('12345'))).toBe( - 'too-large' - ) - expect(getMobilePrFileContentsForScope(cache.snapshot(), scopeA)).toEqual({}) - expect(getMobilePrFileContentsForScope(cache.snapshot(), scopeB)).toEqual({}) - }) -}) diff --git a/mobile/src/tasks/mobile-pr-file-content-cache.ts b/mobile/src/tasks/mobile-pr-file-content-cache.ts deleted file mode 100644 index 5dcb17a3912..00000000000 --- a/mobile/src/tasks/mobile-pr-file-content-cache.ts +++ /dev/null @@ -1,226 +0,0 @@ -import { MAX_RENDERED_DIFF_COMBINED_CHARACTERS } from '../../../src/shared/large-diff-render-limit' -import type { GitHubPRFileContents, GitHubRepositoryIdentity } from '../../../src/shared/types' - -export const MOBILE_PR_FILE_CONTENT_CACHE_MAX_ENTRIES = 8 -export const MOBILE_PR_FILE_CONTENT_CACHE_MAX_BYTES = MAX_RENDERED_DIFF_COMBINED_CHARACTERS * 4 - -export type MobilePrFileContentScopeInput = { - source: 'item' | 'project' - repoId: string - prNumber: number - headSha: string - baseSha: string - repository?: GitHubRepositoryIdentity | null -} - -export type MobilePrFileContentKeyInput = { - path: string - oldPath?: string - status?: string -} - -export type MobilePrFileContentRequestToken = { - scope: string - key: string - requestId: number -} - -export type MobilePrFileContentCacheSnapshot = { - scope: string | null - contentsByKey: Readonly> -} - -export type MobilePrFileContentCacheEvidence = { - scope: string | null - entryCount: number - retainedBytes: number - keysOldestFirst: string[] -} - -type CacheEntry = { - contents: GitHubPRFileContents - byteCount: number -} - -type CacheSelection = { - contents: GitHubPRFileContents | undefined - scopeChanged: boolean -} - -export class MobilePrFileContentCache { - private readonly entries = new Map() - private scope: string | null = null - private retainedBytes = 0 - private requestSequence = 0 - private activeRequest: MobilePrFileContentRequestToken | null = null - - constructor( - private readonly maxEntries = MOBILE_PR_FILE_CONTENT_CACHE_MAX_ENTRIES, - private readonly maxBytes = MOBILE_PR_FILE_CONTENT_CACHE_MAX_BYTES - ) { - if ( - !Number.isInteger(maxEntries) || - maxEntries < 1 || - !Number.isFinite(maxBytes) || - maxBytes < 1 - ) { - throw new Error('Mobile PR file-content cache limits must be positive') - } - } - - activateScope(scope: string): boolean { - if (this.scope === scope) { - return false - } - this.scope = scope - this.entries.clear() - this.retainedBytes = 0 - this.activeRequest = null - return true - } - - clear(): void { - this.scope = null - this.entries.clear() - this.retainedBytes = 0 - this.activeRequest = null - } - - select(scope: string, key: string): CacheSelection { - const scopeChanged = this.activateScope(scope) - this.activeRequest = null - const entry = this.entries.get(key) - if (!entry) { - return { contents: undefined, scopeChanged } - } - this.entries.delete(key) - this.entries.set(key, entry) - return { contents: entry.contents, scopeChanged } - } - - beginRequest(scope: string, key: string): MobilePrFileContentRequestToken { - this.activateScope(scope) - const token = { scope, key, requestId: ++this.requestSequence } - this.activeRequest = token - return token - } - - commitRequest( - token: MobilePrFileContentRequestToken, - contents: GitHubPRFileContents - ): 'stored' | 'stale' | 'too-large' { - if (!this.isCurrentRequest(token)) { - return 'stale' - } - this.activeRequest = null - const byteCount = getMobilePrFileContentByteCount(contents) - if (byteCount > this.maxBytes) { - return 'too-large' - } - const previous = this.entries.get(token.key) - this.retainedBytes -= previous?.byteCount ?? 0 - this.entries.delete(token.key) - this.entries.set(token.key, { contents, byteCount }) - this.retainedBytes += byteCount - this.evictOverflow() - return 'stored' - } - - rejectRequest(token: MobilePrFileContentRequestToken): boolean { - if (!this.isCurrentRequest(token)) { - return false - } - this.activeRequest = null - return true - } - - snapshot(): MobilePrFileContentCacheSnapshot { - return { - scope: this.scope, - contentsByKey: Object.fromEntries( - [...this.entries].map(([key, entry]) => [key, entry.contents]) - ) - } - } - - evidence(): MobilePrFileContentCacheEvidence { - return { - scope: this.scope, - entryCount: this.entries.size, - retainedBytes: this.retainedBytes, - keysOldestFirst: [...this.entries.keys()] - } - } - - private isCurrentRequest(token: MobilePrFileContentRequestToken): boolean { - return ( - this.scope === token.scope && - this.activeRequest?.requestId === token.requestId && - this.activeRequest.scope === token.scope && - this.activeRequest.key === token.key - ) - } - - private evictOverflow(): void { - while (this.entries.size > this.maxEntries || this.retainedBytes > this.maxBytes) { - const oldestKey = this.entries.keys().next().value - if (typeof oldestKey !== 'string') { - return - } - const oldest = this.entries.get(oldestKey) - this.retainedBytes -= oldest?.byteCount ?? 0 - this.entries.delete(oldestKey) - } - } -} - -export function createMobilePrFileContentScope(input: MobilePrFileContentScopeInput): string { - return JSON.stringify([ - input.source, - input.repoId, - input.prNumber, - input.repository?.host?.toLowerCase() ?? '', - input.repository?.owner.toLowerCase() ?? '', - input.repository?.repo.toLowerCase() ?? '', - input.headSha, - input.baseSha - ]) -} - -export function createMobilePrFileContentKey(input: MobilePrFileContentKeyInput): string { - return input.path -} - -export function getMobilePrFileContentsForScope( - snapshot: MobilePrFileContentCacheSnapshot, - scope: string | null -): Readonly> { - return scope !== null && snapshot.scope === scope ? snapshot.contentsByKey : {} -} - -export function getMobilePrFileContentByteCount(contents: GitHubPRFileContents): number { - return getUtf8ByteCount(contents.original) + getUtf8ByteCount(contents.modified) -} - -function getUtf8ByteCount(value: string): number { - let byteCount = 0 - for (let index = 0; index < value.length; index += 1) { - const code = value.charCodeAt(index) - if (code < 0x80) { - byteCount += 1 - } else if (code < 0x800) { - byteCount += 2 - } else if (code >= 0xd800 && code <= 0xdbff && index + 1 < value.length) { - const next = value.charCodeAt(index + 1) - if (next >= 0xdc00 && next <= 0xdfff) { - byteCount += 4 - index += 1 - } else { - byteCount += 3 - } - } else { - byteCount += 3 - } - } - return byteCount -} diff --git a/mobile/src/tasks/use-mobile-pr-file-content-cache.ts b/mobile/src/tasks/use-mobile-pr-file-content-cache.ts deleted file mode 100644 index d364324336b..00000000000 --- a/mobile/src/tasks/use-mobile-pr-file-content-cache.ts +++ /dev/null @@ -1,183 +0,0 @@ -import { useCallback, useEffect, useState } from 'react' -import type { GitHubPRFileContents, GitHubRepositoryIdentity } from '../../../src/shared/types' -import { - MobilePrFileContentCache, - createMobilePrFileContentKey, - createMobilePrFileContentScope, - getMobilePrFileContentsForScope, - type MobilePrFileContentKeyInput -} from './mobile-pr-file-content-cache' - -type MobilePrScopeTaskItem = { - provider: string - source: unknown -} | null - -type MobilePrScopeDetail = { - provider: string - headSha?: unknown - baseSha?: unknown -} | null - -type MobileProjectPrScopeItem = { - itemType: string - content: { number?: unknown } -} | null - -type MobileProjectPrScopeRepo = { id?: unknown } | null - -type MobilePrFileContentLoad = () => Promise -type MobilePrFileContentErrorSetter = (message: string) => void - -export function createMobileItemPrFileContentScope( - item: MobilePrScopeTaskItem, - detail: MobilePrScopeDetail -): string | null { - const source = - item?.source && typeof item.source === 'object' - ? (item.source as { type?: unknown; repoId?: unknown; number?: unknown }) - : null - if ( - item?.provider !== 'github' || - source?.type !== 'pr' || - typeof source.repoId !== 'string' || - typeof source.number !== 'number' || - detail?.provider !== 'github' || - typeof detail.headSha !== 'string' || - !detail.headSha || - typeof detail.baseSha !== 'string' || - !detail.baseSha - ) { - return null - } - return createMobilePrFileContentScope({ - source: 'item', - repoId: source.repoId, - prNumber: source.number, - headSha: detail.headSha, - baseSha: detail.baseSha - }) -} - -export function createMobileProjectPrFileContentScope( - item: MobileProjectPrScopeItem, - repo: MobileProjectPrScopeRepo, - detail: MobilePrScopeDetail, - repository?: GitHubRepositoryIdentity | null -): string | null { - if ( - item?.itemType !== 'PULL_REQUEST' || - typeof item.content.number !== 'number' || - typeof repo?.id !== 'string' || - detail?.provider !== 'github' || - typeof detail.headSha !== 'string' || - !detail.headSha || - typeof detail.baseSha !== 'string' || - !detail.baseSha - ) { - return null - } - return createMobilePrFileContentScope({ - source: 'project', - repoId: repo.id, - prNumber: item.content.number, - repository, - headSha: detail.headSha, - baseSha: detail.baseSha - }) -} - -export function useMobilePrFileContentCache(activeScope: string | null): { - clear: () => void - contents: Readonly> - load: ( - scope: string, - file: MobilePrFileContentKeyInput, - loadContents: MobilePrFileContentLoad, - setError: MobilePrFileContentErrorSetter - ) => Promise - loadingPath: string | null -} { - const [cache] = useState(() => new MobilePrFileContentCache()) - const [snapshot, setSnapshot] = useState(() => cache.snapshot()) - const [loadingPath, setLoadingPath] = useState(null) - const clear = useCallback(() => { - cache.clear() - setSnapshot(cache.snapshot()) - setLoadingPath(null) - }, [cache]) - - useEffect(() => { - if (activeScope === null) { - clear() - } else if (cache.activateScope(activeScope)) { - setSnapshot(cache.snapshot()) - setLoadingPath(null) - } - }, [activeScope, cache, clear]) - - const load = useCallback( - async ( - scope: string, - file: MobilePrFileContentKeyInput, - loadContents: MobilePrFileContentLoad, - setError: MobilePrFileContentErrorSetter - ): Promise => { - const key = createMobilePrFileContentKey(file) - const selection = cache.select(scope, key) - if (selection.scopeChanged) { - setSnapshot(cache.snapshot()) - } - if (selection.contents) { - setLoadingPath(null) - return - } - const token = cache.beginRequest(scope, key) - setLoadingPath(file.path) - setError('') - try { - const result = await loadContents() - if (!isGitHubPrFileContents(result)) { - throw new Error('Invalid file contents response') - } - const commit = cache.commitRequest(token, result) - if (commit === 'stale') { - return - } - if (commit === 'too-large') { - setError('File too large for mobile preview.') - } else { - setSnapshot(cache.snapshot()) - } - setLoadingPath((current) => (current === file.path ? null : current)) - } catch (error) { - if (!cache.rejectRequest(token)) { - return - } - setError(error instanceof Error ? error.message : 'Failed to load file contents') - setLoadingPath((current) => (current === file.path ? null : current)) - } - }, - [cache] - ) - - return { - clear, - contents: getMobilePrFileContentsForScope(snapshot, activeScope), - load, - loadingPath - } -} - -function isGitHubPrFileContents(value: unknown): value is GitHubPRFileContents { - if (!value || typeof value !== 'object') { - return false - } - const contents = value as Partial - return ( - typeof contents.original === 'string' && - typeof contents.modified === 'string' && - typeof contents.originalIsBinary === 'boolean' && - typeof contents.modifiedIsBinary === 'boolean' - ) -} diff --git a/mobile/src/terminal/custom-accessory-key-store.test.ts b/mobile/src/terminal/custom-accessory-key-store.test.ts deleted file mode 100644 index f479f366d47..00000000000 --- a/mobile/src/terminal/custom-accessory-key-store.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import AsyncStorage from '@react-native-async-storage/async-storage' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { - CUSTOM_ACCESSORY_KEY_MAX_BYTES_CHARACTERS, - CUSTOM_ACCESSORY_KEYS_MAX_ENTRIES, - CUSTOM_ACCESSORY_KEYS_MAX_STORAGE_CHARACTERS, - loadCustomKeys, - saveCustomKeys, - type CustomKey -} from './custom-accessory-key-store' - -vi.mock('@react-native-async-storage/async-storage', () => ({ - default: { - getItem: vi.fn(), - setItem: vi.fn() - } -})) - -function customKey(index: number, bytes = 'echo ok'): CustomKey { - return { id: `key-${index}`, label: `Key ${index}`, bytes, enter: false } -} - -describe('custom accessory key store', () => { - beforeEach(() => { - vi.mocked(AsyncStorage.getItem).mockReset() - vi.mocked(AsyncStorage.setItem).mockReset().mockResolvedValue(undefined) - }) - - it('round-trips normal custom keys unchanged', async () => { - const keys = [customKey(1), customKey(2)] - vi.mocked(AsyncStorage.getItem).mockResolvedValue(JSON.stringify(keys)) - - await expect(loadCustomKeys()).resolves.toEqual(keys) - await expect(saveCustomKeys(keys)).resolves.toEqual(keys) - expect(AsyncStorage.setItem).toHaveBeenCalledWith( - 'orca:custom-accessory-keys', - JSON.stringify(keys) - ) - }) - - it('accepts the exact key count and evicts oldest at one over', async () => { - const exact = Array.from({ length: CUSTOM_ACCESSORY_KEYS_MAX_ENTRIES }, (_, index) => - customKey(index) - ) - await expect(saveCustomKeys(exact)).resolves.toEqual(exact) - - const oneOver = [...exact, customKey(CUSTOM_ACCESSORY_KEYS_MAX_ENTRIES)] - const retained = await saveCustomKeys(oneOver) - expect(retained).toHaveLength(CUSTOM_ACCESSORY_KEYS_MAX_ENTRIES) - expect(retained[0]?.id).toBe('key-1') - expect(retained.at(-1)?.id).toBe(`key-${CUSTOM_ACCESSORY_KEYS_MAX_ENTRIES}`) - }) - - it('accepts the exact macro length and drops one character over', async () => { - const exact = customKey(1, 'x'.repeat(CUSTOM_ACCESSORY_KEY_MAX_BYTES_CHARACTERS)) - const oversized = customKey(2, 'x'.repeat(CUSTOM_ACCESSORY_KEY_MAX_BYTES_CHARACTERS + 1)) - - await expect(saveCustomKeys([exact, oversized])).resolves.toEqual([exact]) - }) - - it('rejects durable JSON over the storage character budget before parsing', async () => { - vi.mocked(AsyncStorage.getItem).mockResolvedValue( - 'x'.repeat(CUSTOM_ACCESSORY_KEYS_MAX_STORAGE_CHARACTERS + 1) - ) - - await expect(loadCustomKeys()).resolves.toEqual([]) - }) -}) diff --git a/mobile/src/terminal/custom-accessory-key-store.ts b/mobile/src/terminal/custom-accessory-key-store.ts deleted file mode 100644 index e7bde17f950..00000000000 --- a/mobile/src/terminal/custom-accessory-key-store.ts +++ /dev/null @@ -1,81 +0,0 @@ -import AsyncStorage from '@react-native-async-storage/async-storage' - -export const CUSTOM_ACCESSORY_KEYS_STORAGE_KEY = 'orca:custom-accessory-keys' -export const CUSTOM_ACCESSORY_KEYS_MAX_ENTRIES = 128 -export const CUSTOM_ACCESSORY_KEYS_MAX_STORAGE_CHARACTERS = 512 * 1024 -export const CUSTOM_ACCESSORY_KEY_MAX_ID_CHARACTERS = 256 -export const CUSTOM_ACCESSORY_KEY_MAX_LABEL_CHARACTERS = 128 -export const CUSTOM_ACCESSORY_KEY_MAX_BYTES_CHARACTERS = 16 * 1024 - -export type CustomKey = { - id: string - label: string - bytes: string - enter: boolean -} - -export async function loadCustomKeys(): Promise { - try { - const raw = await AsyncStorage.getItem(CUSTOM_ACCESSORY_KEYS_STORAGE_KEY) - if (!raw || raw.length > CUSTOM_ACCESSORY_KEYS_MAX_STORAGE_CHARACTERS) { - return [] - } - return retainCustomKeys(JSON.parse(raw) as unknown).keys - } catch { - return [] - } -} - -export async function saveCustomKeys(keys: CustomKey[]): Promise { - const retained = retainCustomKeys(keys) - await AsyncStorage.setItem(CUSTOM_ACCESSORY_KEYS_STORAGE_KEY, retained.serialized) - return retained.keys -} - -function retainCustomKeys(value: unknown): { keys: CustomKey[]; serialized: string } { - if (!Array.isArray(value)) { - return { keys: [], serialized: '[]' } - } - const retained: Array<{ key: CustomKey; serialized: string }> = [] - let storageCharacters = 2 - for (let index = value.length - 1; index >= 0; index -= 1) { - if (retained.length >= CUSTOM_ACCESSORY_KEYS_MAX_ENTRIES) { - break - } - const key = normalizeCustomKey(value[index]) - if (!key) { - continue - } - const serialized = JSON.stringify(key) - const nextCharacters = storageCharacters + serialized.length + (retained.length > 0 ? 1 : 0) - if (nextCharacters > CUSTOM_ACCESSORY_KEYS_MAX_STORAGE_CHARACTERS) { - continue - } - retained.push({ key, serialized }) - storageCharacters = nextCharacters - } - retained.reverse() - return { - keys: retained.map(({ key }) => key), - serialized: `[${retained.map(({ serialized }) => serialized).join(',')}]` - } -} - -function normalizeCustomKey(value: unknown): CustomKey | null { - if (!value || typeof value !== 'object') { - return null - } - const key = value as Partial - if ( - typeof key.id !== 'string' || - key.id.length > CUSTOM_ACCESSORY_KEY_MAX_ID_CHARACTERS || - typeof key.label !== 'string' || - key.label.length > CUSTOM_ACCESSORY_KEY_MAX_LABEL_CHARACTERS || - typeof key.bytes !== 'string' || - key.bytes.length > CUSTOM_ACCESSORY_KEY_MAX_BYTES_CHARACTERS || - typeof key.enter !== 'boolean' - ) { - return null - } - return { id: key.id, label: key.label, bytes: key.bytes, enter: key.enter } -} diff --git a/mobile/src/terminal/terminal-accessory-layout.test.ts b/mobile/src/terminal/terminal-accessory-layout.test.ts index 692267214be..1a4135494ea 100644 --- a/mobile/src/terminal/terminal-accessory-layout.test.ts +++ b/mobile/src/terminal/terminal-accessory-layout.test.ts @@ -1,8 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { - TERMINAL_ACCESSORY_LAYOUT_MAX_IDS, - TERMINAL_ACCESSORY_LAYOUT_MAX_STORAGE_CHARACTERS, TERMINAL_ACCESSORY_LAYOUT_STORAGE_KEY, createTerminalAccessoryLayoutPreference, getDefaultTerminalAccessoryBuiltInIds, @@ -85,61 +83,6 @@ describe('terminal accessory layout', () => { ) }) - it('accepts the exact storage limit and skips parsing one character more', async () => { - const preference = { - version: 2, - orderedBuiltInIds: getDefaultTerminalAccessoryBuiltInIds(), - visibleBuiltInIds: ['escape'] - } - const serialized = JSON.stringify(preference) - asyncStorageMock.getItem.mockResolvedValueOnce( - serialized + ' '.repeat(TERMINAL_ACCESSORY_LAYOUT_MAX_STORAGE_CHARACTERS - serialized.length) - ) - await expect(loadTerminalAccessoryLayout()).resolves.toMatchObject({ - visibleBuiltInIds: ['escape'] - }) - - const parse = vi.spyOn(JSON, 'parse') - asyncStorageMock.getItem.mockResolvedValueOnce( - 'x'.repeat(TERMINAL_ACCESSORY_LAYOUT_MAX_STORAGE_CHARACTERS + 1) - ) - await expect(loadTerminalAccessoryLayout()).resolves.toEqual( - createTerminalAccessoryLayoutPreference(getDefaultTerminalAccessoryLayout()) - ) - expect(parse).not.toHaveBeenCalled() - parse.mockRestore() - }) - - it('accepts the exact stored id count and falls back on one more', () => { - const exact = Array.from({ length: TERMINAL_ACCESSORY_LAYOUT_MAX_IDS }, (_, index) => - index === 0 ? 'tab' : 'escape' - ) - expect( - normalizeTerminalAccessoryLayoutPreference( - { version: 2, orderedBuiltInIds: exact, visibleBuiltInIds: ['escape'] }, - ['escape', 'tab'] - ) - ).toEqual({ - version: 2, - orderedBuiltInIds: ['tab', 'escape'], - visibleBuiltInIds: ['escape'] - }) - expect( - normalizeTerminalAccessoryLayoutPreference( - { - version: 2, - orderedBuiltInIds: [...exact, 'tab'], - visibleBuiltInIds: ['escape'] - }, - ['escape', 'tab'] - ) - ).toEqual({ - version: 2, - orderedBuiltInIds: ['escape', 'tab'], - visibleBuiltInIds: ['escape', 'tab'] - }) - }) - it('preserves a custom v2 order and its visible subset', () => { const reversed = [...getDefaultTerminalAccessoryBuiltInIds()].toReversed() diff --git a/mobile/src/terminal/terminal-accessory-layout.ts b/mobile/src/terminal/terminal-accessory-layout.ts index 7dc3dc70e00..cee7a74173e 100644 --- a/mobile/src/terminal/terminal-accessory-layout.ts +++ b/mobile/src/terminal/terminal-accessory-layout.ts @@ -3,9 +3,6 @@ import AsyncStorage from '@react-native-async-storage/async-storage' import { TERMINAL_ACCESSORY_KEYS, type TerminalAccessoryKey } from './terminal-accessory-keys' export const TERMINAL_ACCESSORY_LAYOUT_STORAGE_KEY = 'orca:terminal-accessory-layout' -export const TERMINAL_ACCESSORY_LAYOUT_MAX_STORAGE_CHARACTERS = 64 * 1024 -export const TERMINAL_ACCESSORY_LAYOUT_MAX_IDS = 256 -export const TERMINAL_ACCESSORY_LAYOUT_MAX_ID_CHARACTERS = 256 export type TerminalAccessoryLayout = { orderedBuiltInIds: string[] @@ -29,28 +26,17 @@ function defaultPreference(ids = builtInIds()): TerminalAccessoryLayoutPreferenc } function stringArray(value: unknown): string[] | null { - if (!Array.isArray(value) || value.length > TERMINAL_ACCESSORY_LAYOUT_MAX_IDS) { + if (!Array.isArray(value)) { return null } - return value.every( - (item): item is string => - typeof item === 'string' && item.length <= TERMINAL_ACCESSORY_LAYOUT_MAX_ID_CHARACTERS - ) - ? value - : null + return value.every((item): item is string => typeof item === 'string') ? value : null } function dedupeKnownIds(ids: string[], builtInSet: Set): string[] { const seen = new Set() const out: string[] = [] - const count = Math.min(ids.length, TERMINAL_ACCESSORY_LAYOUT_MAX_IDS) - for (let index = 0; index < count; index++) { - const id = ids[index]! - if ( - id.length > TERMINAL_ACCESSORY_LAYOUT_MAX_ID_CHARACTERS || - !builtInSet.has(id) || - seen.has(id) - ) { + for (const id of ids) { + if (!builtInSet.has(id) || seen.has(id)) { continue } seen.add(id) @@ -232,9 +218,6 @@ export async function loadTerminalAccessoryLayout(): Promise TERMINAL_ACCESSORY_LAYOUT_MAX_STORAGE_CHARACTERS) { - return defaultPreference() - } return normalizeTerminalAccessoryLayoutPreference(JSON.parse(raw)) } catch { return defaultPreference() @@ -243,9 +226,5 @@ export async function loadTerminalAccessoryLayout(): Promise { const preference = createTerminalAccessoryLayoutPreference(layout) - const serialized = JSON.stringify(preference) - if (serialized.length > TERMINAL_ACCESSORY_LAYOUT_MAX_STORAGE_CHARACTERS) { - throw new Error('terminal accessory layout exceeds storage limit') - } - await AsyncStorage.setItem(TERMINAL_ACCESSORY_LAYOUT_STORAGE_KEY, serialized) + await AsyncStorage.setItem(TERMINAL_ACCESSORY_LAYOUT_STORAGE_KEY, JSON.stringify(preference)) } diff --git a/mobile/src/terminal/terminal-webview-default-theme.ts b/mobile/src/terminal/terminal-webview-default-theme.ts deleted file mode 100644 index 0769f4283ed..00000000000 --- a/mobile/src/terminal/terminal-webview-default-theme.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { RuntimeMobileTerminalTheme } from '../../../src/shared/runtime-types' -import { colors } from '../theme/mobile-theme' - -export const DEFAULT_TERMINAL_WEBVIEW_THEME: RuntimeMobileTerminalTheme['theme'] = { - background: colors.terminalBg, - foreground: '#c0caf5', - cursor: '#c0caf5', - cursorAccent: colors.terminalBg, - selectionBackground: '#33467c', - selectionForeground: '#c0caf5', - black: '#15161e', - red: '#f7768e', - green: '#9ece6a', - yellow: '#e0af68', - blue: '#7aa2f7', - magenta: '#bb9af7', - cyan: '#7dcfff', - white: '#a9b1d6', - brightBlack: '#414868', - brightRed: '#f7768e', - brightGreen: '#9ece6a', - brightYellow: '#e0af68', - brightBlue: '#7aa2f7', - brightMagenta: '#bb9af7', - brightCyan: '#7dcfff', - brightWhite: '#c0caf5' -} diff --git a/mobile/src/terminal/terminal-webview-html.ts b/mobile/src/terminal/terminal-webview-html.ts index 9d66610dd0e..0b7eaa9149d 100644 --- a/mobile/src/terminal/terminal-webview-html.ts +++ b/mobile/src/terminal/terminal-webview-html.ts @@ -1,7 +1,7 @@ -// xterm.js WebView document; extracted from TerminalWebView.tsx for the max-lines budget. +// xterm.js WebView document + default Tokyonight theme; extracted from TerminalWebView.tsx for the max-lines budget. +import type { RuntimeMobileTerminalTheme } from '../../../src/shared/runtime-types' import { colors } from '../theme/mobile-theme' import { TERMINAL_TEXT_SCALES } from '../storage/preferences' -import { DEFAULT_TERMINAL_WEBVIEW_THEME } from './terminal-webview-default-theme' import { TERMINAL_PATH_TAP_JS } from './terminal-path-tap-injected' import { XTERM_ENGINE_CSS, XTERM_ENGINE_JS } from './terminal-webview-engine.generated' import { TERMINAL_REFLOW_JS } from './terminal-webview-reflow-injected' @@ -12,9 +12,30 @@ import { TERMINAL_QUERY_REPLY_JS } from './terminal-webview-query-reply-injected import { URL_TAP_WEBVIEW_JS } from './terminal-webview-url-tap' import { TERMINAL_WEBGL_RECOVERY_JS } from './terminal-webview-webgl-recovery-injected' -export const TERMINAL_WEBVIEW_WRITE_QUEUE_MAX_UNITS = 1_000_000 -export const TERMINAL_WEBVIEW_WRITE_QUEUE_MAX_ENTRIES = 4_096 -export const TERMINAL_WEBVIEW_AFTER_DRAIN_MAX_CALLBACKS = 256 +const DEFAULT_TERMINAL_THEME: RuntimeMobileTerminalTheme['theme'] = { + background: colors.terminalBg, + foreground: '#c0caf5', + cursor: '#c0caf5', + cursorAccent: colors.terminalBg, + selectionBackground: '#33467c', + selectionForeground: '#c0caf5', + black: '#15161e', + red: '#f7768e', + green: '#9ece6a', + yellow: '#e0af68', + blue: '#7aa2f7', + magenta: '#bb9af7', + cyan: '#7dcfff', + white: '#a9b1d6', + brightBlack: '#414868', + brightRed: '#f7768e', + brightGreen: '#9ece6a', + brightYellow: '#e0af68', + brightBlue: '#7aa2f7', + brightMagenta: '#bb9af7', + brightCyan: '#7dcfff', + brightWhite: '#c0caf5' +} // Why: TUI escape codes assume the desktop's cols/rows, so init xterm at those dims and fit the phone via a measured CSS scale() instead of resizing. export const XTERM_HTML = ` @@ -192,11 +213,10 @@ window.onerror = function(msg) { var scrollIndicator = document.getElementById('scroll-indicator'); var scrollThumb = document.getElementById('scroll-thumb'); var scrollIndicatorHideTimer = null; - var writeQueue = [], writeQueueUnits = 0; + var writeQueue = []; var writeQueueHead = 0; - var writesDraining = false, writeBacklogFailed = false, afterDrainCallbacks = []; - var WRITE_QUEUE_MAX_UNITS = ${TERMINAL_WEBVIEW_WRITE_QUEUE_MAX_UNITS}, WRITE_QUEUE_MAX_ENTRIES = ${TERMINAL_WEBVIEW_WRITE_QUEUE_MAX_ENTRIES}; - var AFTER_DRAIN_MAX_CALLBACKS = ${TERMINAL_WEBVIEW_AFTER_DRAIN_MAX_CALLBACKS}; + var writesDraining = false; + var afterDrainCallbacks = []; var termObserverDisposables = []; var ready = false; // Why: init() flips ready false on every re-init (live width reflow included) @@ -267,7 +287,7 @@ window.onerror = function(msg) { var normalScrollFrameId = null; var initRows = 24; var terminalGeneration = 0; - var defaultTheme = ${JSON.stringify(DEFAULT_TERMINAL_WEBVIEW_THEME)}; + var defaultTheme = ${JSON.stringify(DEFAULT_TERMINAL_THEME)}; var terminalThemeInput = null; var terminalTheme = defaultTheme; var terminalMinimumContrastRatio = 3; @@ -537,22 +557,9 @@ ${TERMINAL_WEBVIEW_THEME_JS} } } - function resetWriteQueue() { writeQueue = []; writeQueueHead = 0; writeQueueUnits = 0; } - - function failWriteBacklog() { - if (writeBacklogFailed) return; - writeBacklogFailed = true; terminalGeneration++; ready = false; writesDraining = false; - resetWriteQueue(); afterDrainCallbacks = []; - reportEngineError('terminal write backlog exceeded safe limit', null, true); - } - - function reserveWriteQueueEntry(units) { - if (writeBacklogFailed) return false; - var pendingEntries = writeQueue.length - writeQueueHead; - if (pendingEntries >= WRITE_QUEUE_MAX_ENTRIES || writeQueueUnits + units > WRITE_QUEUE_MAX_UNITS) { - failWriteBacklog(); return false; - } - writeQueueUnits += units; return true; + function resetWriteQueue() { + writeQueue = []; + writeQueueHead = 0; } function isStatusDotPresentationSelector(value) { @@ -583,12 +590,13 @@ ${TERMINAL_WEBVIEW_THEME_JS} return normalized; } - function enqueueWrite(data) { var normalized = normalizeStatusDotPresentation(data); - if (!reserveWriteQueueEntry(normalized.length)) return false; - writeQueue.push(normalized); return true; + function enqueueWrite(data) { + writeQueue.push(normalizeStatusDotPresentation(data)); } - function enqueueWriteBoundary(callback) { if (!reserveWriteQueueEntry(0)) return false; writeQueue.push(callback); return true; } + function enqueueWriteBoundary(callback) { + writeQueue.push(callback); + } function nextQueuedWrite() { if (writeQueueHead >= writeQueue.length) { @@ -597,7 +605,6 @@ ${TERMINAL_WEBVIEW_THEME_JS} } var next = writeQueue[writeQueueHead]; writeQueueHead++; - if (typeof next === 'string') writeQueueUnits = Math.max(0, writeQueueUnits - next.length); // Why: high-throughput terminals can enqueue faster than xterm parses; // compact consumed slots so drain work stays O(1) without retaining old chunks. if (writeQueueHead > 128 && writeQueueHead * 2 > writeQueue.length) { @@ -653,8 +660,8 @@ ${TERMINAL_WEBVIEW_THEME_JS} } function afterWritesDrained(callback) { - if (afterDrainCallbacks.length >= AFTER_DRAIN_MAX_CALLBACKS) { failWriteBacklog(); return; } - afterDrainCallbacks.push(callback); pumpWrites(terminalGeneration); + afterDrainCallbacks.push(callback); + pumpWrites(terminalGeneration); } ${TERMINAL_WEBGL_RECOVERY_JS} @@ -675,7 +682,6 @@ ${TERMINAL_WEBGL_RECOVERY_JS} webglAddon = null; ready = false; resetWriteQueue(); - writeBacklogFailed = false; statusDotPendingSelector = false; writesDraining = false; afterDrainCallbacks = []; @@ -762,7 +768,7 @@ ${TERMINAL_WEBGL_RECOVERY_JS} function write(data) { updateMouseModeFromData(data); - if (!enqueueWrite(data)) return; + enqueueWrite(data); pumpWrites(terminalGeneration); // Why: first live data chunk after init may widen the buffer past // what the post-replay applyFitScale measured. Re-fit once after this @@ -926,7 +932,6 @@ ${TERMINAL_WEBGL_RECOVERY_JS} } else if (msg.type === 'clear') { terminalGeneration++; resetWriteQueue(); resumeTerminalDataReplyAuthority(); // Why: clear drops the replay boundary. - writeBacklogFailed = false; statusDotPendingSelector = false; afterDrainCallbacks = []; writesDraining = false; diff --git a/mobile/src/terminal/terminal-webview-pending-messages.test.ts b/mobile/src/terminal/terminal-webview-pending-messages.test.ts deleted file mode 100644 index d7bcc5b6c29..00000000000 --- a/mobile/src/terminal/terminal-webview-pending-messages.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { TerminalWebViewCommand } from './terminal-webview-messages' -import { - createTerminalWebViewPendingMessages, - MAX_PENDING_WEB_MESSAGES -} from './terminal-webview-pending-messages' - -function flushPending(queue: ReturnType) { - const delivered: TerminalWebViewCommand[] = [] - queue.flush((message) => delivered.push(message)) - return delivered -} - -describe('terminal WebView pending messages', () => { - it('retains only the latest snapshot when init is superseded before readiness', () => { - const queue = createTerminalWebViewPendingMessages() - queue.queue({ type: 'write', data: 'current-document-tail' }) - queue.queue({ type: 'init', cols: 80, rows: 24, initialData: 'old snapshot' }) - queue.queue({ type: 'write', data: 'covered by the replacement snapshot' }) - queue.queue({ type: 'init', cols: 100, rows: 30, initialData: 'new snapshot' }) - - expect(flushPending(queue)).toEqual([ - { type: 'write', data: 'current-document-tail' }, - { type: 'init', cols: 100, rows: 30, initialData: 'new snapshot' } - ]) - }) - - it('caps tiny control-message floods while preserving the newest state', () => { - const queue = createTerminalWebViewPendingMessages() - for (let index = 0; index < MAX_PENDING_WEB_MESSAGES + 100; index += 1) { - queue.queue({ type: 'resize', cols: index + 1, rows: 24 }) - } - - const delivered = flushPending(queue) - expect(delivered).toHaveLength(MAX_PENDING_WEB_MESSAGES) - expect(delivered.at(-1)).toEqual({ - type: 'resize', - cols: MAX_PENDING_WEB_MESSAGES + 100, - rows: 24 - }) - }) -}) diff --git a/mobile/src/terminal/terminal-webview-pending-messages.ts b/mobile/src/terminal/terminal-webview-pending-messages.ts index 1c3423b5cc8..5aa49e2e94f 100644 --- a/mobile/src/terminal/terminal-webview-pending-messages.ts +++ b/mobile/src/terminal/terminal-webview-pending-messages.ts @@ -2,7 +2,6 @@ import type { TerminalWebViewCommand } from './terminal-webview-messages' const MAX_PENDING_WEB_WRITE_BYTES = 1_000_000 const MAX_PENDING_WEB_WRITE_MESSAGES = 4096 -export const MAX_PENDING_WEB_MESSAGES = 8192 export function createTerminalWebViewPendingMessages() { let pending: TerminalWebViewCommand[] = [] @@ -19,42 +18,9 @@ export function createTerminalWebViewPendingMessages() { resetCounters() } - const removeAt = (index: number) => { - const [removed] = pending.splice(index, 1) - if (removed?.type === 'write') { - pendingWriteBytes = Math.max(0, pendingWriteBytes - removed.data.length) - pendingWriteCount = Math.max(0, pendingWriteCount - 1) - } - } - - const supersedePendingInit = (msg: Extract) => { - const existingIndex = pending.findIndex((candidate) => candidate.type === 'init') - if (existingIndex === -1) { - return false - } - while (pending.length > existingIndex) { - removeAt(pending.length - 1) - } - pending.push(msg) - return true - } - - const trimMessageCount = () => { - while (pending.length > MAX_PENDING_WEB_MESSAGES) { - const controlIndex = pending.findIndex( - (candidate) => candidate.type !== 'write' && candidate.type !== 'init' - ) - removeAt(Math.max(controlIndex, 0)) - } - } - const queue = (msg: TerminalWebViewCommand) => { - if (msg.type === 'init' && supersedePendingInit(msg)) { - return - } pending.push(msg) if (msg.type !== 'write') { - trimMessageCount() return } @@ -69,9 +35,12 @@ export function createTerminalWebViewPendingMessages() { resetCounters() return } - removeAt(dropIndex) + const [dropped] = pending.splice(dropIndex, 1) + if (dropped?.type === 'write') { + pendingWriteBytes = Math.max(0, pendingWriteBytes - dropped.data.length) + pendingWriteCount = Math.max(0, pendingWriteCount - 1) + } } - trimMessageCount() } const flush = (send: (msg: TerminalWebViewCommand) => void) => { diff --git a/mobile/src/terminal/terminal-webview-scroll-routing.test.ts b/mobile/src/terminal/terminal-webview-scroll-routing.test.ts index ddd1f57cb8e..391ccd3a840 100644 --- a/mobile/src/terminal/terminal-webview-scroll-routing.test.ts +++ b/mobile/src/terminal/terminal-webview-scroll-routing.test.ts @@ -117,16 +117,6 @@ describe('TerminalWebView scroll routing', () => { expect(source).not.toContain('writeQueue.shift()') }) - it('fails closed when the in-WebView parser backlog reaches its exact bounds', () => { - expect(source).toContain('export const TERMINAL_WEBVIEW_WRITE_QUEUE_MAX_UNITS = 1_000_000') - expect(source).toContain('export const TERMINAL_WEBVIEW_WRITE_QUEUE_MAX_ENTRIES = 4_096') - expect(source).toContain('export const TERMINAL_WEBVIEW_AFTER_DRAIN_MAX_CALLBACKS = 256') - expect(source).toContain('writeQueueUnits + units > WRITE_QUEUE_MAX_UNITS') - expect(source).toContain('pendingEntries >= WRITE_QUEUE_MAX_ENTRIES') - expect(source).toContain('afterDrainCallbacks.length >= AFTER_DRAIN_MAX_CALLBACKS') - expect(source).toContain("reportEngineError('terminal write backlog exceeded safe limit'") - }) - it('bounds native-side pending WebView writes while preserving control messages', () => { expect(source).toContain('const MAX_PENDING_WEB_WRITE_BYTES = 1_000_000') expect(source).toContain('const MAX_PENDING_WEB_WRITE_MESSAGES = 4096') diff --git a/mobile/src/terminal/terminal-webview-text-zoom.test.ts b/mobile/src/terminal/terminal-webview-text-zoom.test.ts index 1f6ba4fac01..09aeb82d3f7 100644 --- a/mobile/src/terminal/terminal-webview-text-zoom.test.ts +++ b/mobile/src/terminal/terminal-webview-text-zoom.test.ts @@ -91,8 +91,7 @@ describe('TerminalWebView text zoom', () => { expect(terminalHtmlSource).toContain( 'data.replace(CLAUDE_STATUS_DOT_PATTERN, CLAUDE_STATUS_DOT + TEXT_PRESENTATION_SELECTOR)' ) - expect(terminalHtmlSource).toContain('var normalized = normalizeStatusDotPresentation(data)') - expect(terminalHtmlSource).toContain('writeQueue.push(normalized)') + expect(terminalHtmlSource).toContain('writeQueue.push(normalizeStatusDotPresentation(data))') }) it('normalizes Claude status dots idempotently across write chunks', () => { diff --git a/mobile/src/terminal/terminal-write-coalescer.test.ts b/mobile/src/terminal/terminal-write-coalescer.test.ts index 3562e7db0f4..1279bbeb7d6 100644 --- a/mobile/src/terminal/terminal-write-coalescer.test.ts +++ b/mobile/src/terminal/terminal-write-coalescer.test.ts @@ -2,7 +2,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { createTerminalWriteCoalescer, TERMINAL_WRITE_FLUSH_WINDOW_MS, - TERMINAL_WRITE_MAX_PENDING_CHUNKS, TERMINAL_WRITE_MAX_PENDING_UNITS } from './terminal-write-coalescer' @@ -129,20 +128,6 @@ describe('terminal write coalescer', () => { expect(sink.delivered).toHaveLength(2) }) - it('flushes tiny chunks before their object count can grow unbounded', () => { - vi.useFakeTimers() - const sink = createDeliverySink() - const coalescer = createTerminalWriteCoalescer(sink.deliver) - - coalescer.write('leading') - for (let index = 0; index < TERMINAL_WRITE_MAX_PENDING_CHUNKS; index += 1) { - coalescer.write('x') - } - - expect(sink.delivered).toEqual(['leading', 'x'.repeat(TERMINAL_WRITE_MAX_PENDING_CHUNKS)]) - expect(vi.getTimerCount()).toBe(0) - }) - it('treats write("") as a no-op: no delivery, no buffer append, no timer', () => { vi.useFakeTimers() const sink = createDeliverySink() diff --git a/mobile/src/terminal/terminal-write-coalescer.ts b/mobile/src/terminal/terminal-write-coalescer.ts index 1553253864a..b27f6c37fb2 100644 --- a/mobile/src/terminal/terminal-write-coalescer.ts +++ b/mobile/src/terminal/terminal-write-coalescer.ts @@ -5,7 +5,6 @@ export const TERMINAL_WRITE_FLUSH_WINDOW_MS = 48 // Why: defense-in-depth only — server ack flow control bounds inflow; this cap keeps // an upstream flow-control bug from growing the buffer unboundedly. UTF-16 code units. export const TERMINAL_WRITE_MAX_PENDING_UNITS = 512 * 1024 -export const TERMINAL_WRITE_MAX_PENDING_CHUNKS = 4_096 export function createTerminalWriteCoalescer(deliver: (data: string) => void) { let pendingChunks: string[] = [] @@ -47,10 +46,7 @@ export function createTerminalWriteCoalescer(deliver: (data: string) => void) { } pendingChunks.push(data) pendingUnits += data.length - if ( - pendingUnits > TERMINAL_WRITE_MAX_PENDING_UNITS || - pendingChunks.length >= TERMINAL_WRITE_MAX_PENDING_CHUNKS - ) { + if (pendingUnits > TERMINAL_WRITE_MAX_PENDING_UNITS) { flushNow() return } diff --git a/mobile/src/transport/bounded-process-line-reader.test.ts b/mobile/src/transport/bounded-process-line-reader.test.ts deleted file mode 100644 index 3fd0083e134..00000000000 --- a/mobile/src/transport/bounded-process-line-reader.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { once } from 'node:events' -import { PassThrough } from 'node:stream' -import { describe, expect, it } from 'vitest' -import { - appendProcessOutputTail, - attachBoundedProcessLineReader, - PROCESS_LINE_MAX_BYTES -} from '../../scripts/bounded-process-line-reader.mjs' - -describe('attachBoundedProcessLineReader', () => { - it('preserves ordinary LF, CRLF, and split UTF-8 lines', () => { - const stream = new PassThrough() - const lines: string[] = [] - attachBoundedProcessLineReader(stream, (line) => lines.push(line)) - - const unicode = Buffer.from('café\n') - stream.write('first\r\nsecond\r') - stream.write('\n') - stream.write(unicode.subarray(0, -2)) - stream.end(unicode.subarray(-2)) - - expect(lines).toEqual(['first', 'second', 'café']) - }) - - it('retains only the configured prefix of an unterminated line', async () => { - const stream = new PassThrough() - const lines: string[] = [] - attachBoundedProcessLineReader(stream, (line) => lines.push(line), 4) - - const ended = once(stream, 'end') - stream.end('abcdefgh') - await ended - - expect(lines).toEqual(['abcd… [line truncated]']) - }) - - it('uses a bounded default for process output lines', () => { - expect(PROCESS_LINE_MAX_BYTES).toBe(64 * 1024) - }) - - it('keeps exact output below the tail cap and newest output above it', () => { - expect(appendProcessOutputTail('first\n', 'second', 32)).toBe('first\nsecond\n') - expect(appendProcessOutputTail('first\n', 'second', 8)).toBe('\nsecond\n') - expect(appendProcessOutputTail('first\n', 'second', 0)).toBe('') - }) -}) diff --git a/mobile/src/transport/bounded-response-body.test.ts b/mobile/src/transport/bounded-response-body.test.ts deleted file mode 100644 index 183c1dedc61..00000000000 --- a/mobile/src/transport/bounded-response-body.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { responseBodyIncludesWithinLimit } from '../../scripts/bounded-response-body.mjs' - -function responseWithChunks(chunks: string[], contentLength?: number): Response { - const encoder = new TextEncoder() - return new Response( - new ReadableStream({ - start(controller) { - for (const chunk of chunks) { - controller.enqueue(encoder.encode(chunk)) - } - controller.close() - } - }), - { headers: contentLength === undefined ? {} : { 'content-length': String(contentLength) } } - ) -} - -describe('responseBodyIncludesWithinLimit', () => { - it('matches a marker split across admitted chunks', async () => { - const response = responseWithChunks(['packager-', 'status:running']) - await expect( - responseBodyIncludesWithinLimit(response, 'packager-status:running', 64) - ).resolves.toBe(true) - }) - - it('rejects streamed and declared bodies above the byte limit', async () => { - const streamed = responseWithChunks(['1234', '5']) - const declared = responseWithChunks(['ok'], 5) - - await expect(responseBodyIncludesWithinLimit(streamed, 'missing', 4)).resolves.toBe(false) - await expect(responseBodyIncludesWithinLimit(declared, 'ok', 4)).resolves.toBe(false) - }) -}) diff --git a/mobile/src/transport/browser-screencast-protocol.ts b/mobile/src/transport/browser-screencast-protocol.ts index 7cde80ec547..01ee4353f6c 100644 --- a/mobile/src/transport/browser-screencast-protocol.ts +++ b/mobile/src/transport/browser-screencast-protocol.ts @@ -1,5 +1,3 @@ -import { parseMobileJsonTextWithinLimits } from './mobile-json-text-admission' - const BROWSER_SCREENCAST_KIND = 0x62 const BROWSER_SCREENCAST_VERSION = 1 const HEADER_BYTES = 16 @@ -53,7 +51,7 @@ function byteToFormat(value: number): BrowserScreencastFormat | null { function decodeJson(bytes: Uint8Array): unknown { try { - return parseMobileJsonTextWithinLimits(new TextDecoder().decode(bytes)) + return JSON.parse(new TextDecoder().decode(bytes)) as unknown } catch { return null } diff --git a/mobile/src/transport/connection-log-buffer.test.ts b/mobile/src/transport/connection-log-buffer.test.ts index 1f7d8762d4f..e7ce662534b 100644 --- a/mobile/src/transport/connection-log-buffer.test.ts +++ b/mobile/src/transport/connection-log-buffer.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { CONNECTION_LOG_HOST_ID_MAX_BYTES, createConnectionLogStore } from './connection-log-buffer' +import { createConnectionLogStore } from './connection-log-buffer' import type { ConnectionLogEntry } from './types' function entry(id: number): ConnectionLogEntry { @@ -53,81 +53,4 @@ describe('connection log buffer', () => { store.append('host-a', entry(2)) expect(onA).toHaveBeenCalledTimes(1) }) - - it('evicts the least-recently-written inactive host past the host cap', () => { - const store = createConnectionLogStore(3, 2) - store.append('host-a', entry(1)) - store.append('host-b', entry(2)) - store.append('host-a', entry(3)) - store.append('host-c', entry(4)) - - expect(store.get('host-a').map((e) => e.id)).toEqual(['log-1', 'log-3']) - expect(store.get('host-b')).toEqual([]) - expect(store.get('host-c').map((e) => e.id)).toEqual(['log-4']) - }) - - it('drops removed-host entries and refreshes an active snapshot', () => { - const store = createConnectionLogStore() - const listener = vi.fn() - store.append('host-a', entry(1)) - store.subscribe('host-a', listener) - - store.delete('host-a') - - expect(store.get('host-a')).toEqual([]) - expect(listener).toHaveBeenCalledOnce() - }) - - it('accepts the exact host-id limit and rejects one more byte', () => { - const store = createConnectionLogStore() - const exactHost = 'h'.repeat(CONNECTION_LOG_HOST_ID_MAX_BYTES) - - store.append(exactHost, entry(1)) - store.append(`${exactHost}h`, entry(2)) - - expect(store.get(exactHost).map((value) => value.id)).toEqual(['log-1']) - expect(store.get(`${exactHost}h`)).toEqual([]) - }) - - it('accepts an exact entry-byte budget and rejects one more byte', () => { - const store = createConnectionLogStore(10, 10, { - maxEntryBytes: 260, - maxHostBytes: 1024, - maxStoreBytes: 2048 - }) - const exact: ConnectionLogEntry = { id: '', ts: 1, level: 'info', message: '' } - - store.append('host-a', exact) - store.append('host-a', { ...exact, message: 'x' }) - - expect(store.get('host-a')).toEqual([exact]) - }) - - it('keeps newest entries within the per-host byte budget', () => { - const store = createConnectionLogStore(10, 10, { - maxEntryBytes: 1024, - maxHostBytes: 700, - maxStoreBytes: 4096 - }) - - store.append('host-a', entry(1)) - store.append('host-a', entry(2)) - store.append('host-a', entry(3)) - - expect(store.get('host-a').map((value) => value.id)).toEqual(['log-2', 'log-3']) - }) - - it('evicts inactive hosts to stay within the aggregate byte budget', () => { - const store = createConnectionLogStore(10, 10, { - maxEntryBytes: 1024, - maxHostBytes: 1024, - maxStoreBytes: 700 - }) - - store.append('host-a', entry(1)) - store.append('host-b', entry(2)) - - expect(store.get('host-a')).toEqual([]) - expect(store.get('host-b').map((value) => value.id)).toEqual(['log-2']) - }) }) diff --git a/mobile/src/transport/connection-log-buffer.ts b/mobile/src/transport/connection-log-buffer.ts index b7005efc602..1b223ad8e1f 100644 --- a/mobile/src/transport/connection-log-buffer.ts +++ b/mobile/src/transport/connection-log-buffer.ts @@ -1,5 +1,4 @@ import type { ConnectionLogEntry } from './types' -import { measureUtf8ByteLength } from '../../../src/shared/utf8-byte-limits' // Why: the rpc-client's onLog entries were only wired during pairing; for // long-lived host connections everything went to console.log, invisible to @@ -9,159 +8,35 @@ import { measureUtf8ByteLength } from '../../../src/shared/utf8-byte-limits' // swaps (forceReconnect) and provider remounts (hot reload); bounded so an // all-night reconnect loop can't grow memory unbounded. const MAX_ENTRIES_PER_HOST = 200 -const MAX_RETAINED_HOSTS = 128 -export const CONNECTION_LOG_HOST_ID_MAX_BYTES = 4 * 1024 -export const CONNECTION_LOG_ENTRY_MAX_BYTES = 64 * 1024 -export const CONNECTION_LOG_HOST_MAX_RETAINED_BYTES = 256 * 1024 -export const CONNECTION_LOG_STORE_MAX_RETAINED_BYTES = 8 * 1024 * 1024 -const CONNECTION_LOG_MAX_LISTENERS_PER_HOST = 16 - -type RetainedConnectionLogEntry = { - entry: ConnectionLogEntry - bytes: number -} - -type RetainedHostLog = { - entries: RetainedConnectionLogEntry[] - bytes: number -} - -type ConnectionLogByteLimits = { - maxEntryBytes: number - maxHostBytes: number - maxStoreBytes: number -} - -function measureBoundedString(value: unknown, maxBytes: number): number | null { - if (value === undefined) { - return 0 - } - if (typeof value !== 'string') { - return null - } - const measurement = measureUtf8ByteLength(value, { stopAfterBytes: maxBytes }) - return measurement.exceededLimit ? null : measurement.byteLength -} - -function measureConnectionLogEntry(entry: ConnectionLogEntry, maxBytes: number): number | null { - let bytes = 256 - for (const value of [entry.id, entry.level, entry.message, entry.detail]) { - const valueBytes = measureBoundedString(value, maxBytes - bytes) - if (valueBytes === null) { - return null - } - bytes += valueBytes - } - return bytes <= maxBytes ? bytes : null -} export type ConnectionLogStore = { append: (hostId: string, entry: ConnectionLogEntry) => void get: (hostId: string) => readonly ConnectionLogEntry[] subscribe: (hostId: string, listener: () => void) => () => void - delete: (hostId: string) => void } export function createConnectionLogStore( - maxEntriesPerHost: number = MAX_ENTRIES_PER_HOST, - maxRetainedHosts: number = MAX_RETAINED_HOSTS, - byteLimits: Partial = {} + maxEntriesPerHost: number = MAX_ENTRIES_PER_HOST ): ConnectionLogStore { - const maxEntryBytes = Math.min( - byteLimits.maxEntryBytes ?? CONNECTION_LOG_ENTRY_MAX_BYTES, - CONNECTION_LOG_ENTRY_MAX_BYTES - ) - const maxHostBytes = Math.min( - byteLimits.maxHostBytes ?? CONNECTION_LOG_HOST_MAX_RETAINED_BYTES, - CONNECTION_LOG_HOST_MAX_RETAINED_BYTES - ) - const maxStoreBytes = Math.min( - byteLimits.maxStoreBytes ?? CONNECTION_LOG_STORE_MAX_RETAINED_BYTES, - CONNECTION_LOG_STORE_MAX_RETAINED_BYTES - ) - const entriesByHost = new Map() + const entriesByHost = new Map() const listenersByHost = new Map void>>() // Why: useSyncExternalStore compares snapshots by reference — getSnapshot // must return the SAME array until the data actually changes, or React // loops re-rendering. Cache per host; invalidate on append. const snapshotByHost = new Map() const EMPTY: readonly ConnectionLogEntry[] = [] - let retainedStoreBytes = 0 - - const deleteHost = (hostId: string): boolean => { - const host = entriesByHost.get(hostId) - if (!host) { - return false - } - entriesByHost.delete(hostId) - retainedStoreBytes -= host.bytes - snapshotByHost.delete(hostId) - return true - } - - const evictOldestUnobservedHost = (exceptHostId?: string): boolean => { - for (const hostId of entriesByHost.keys()) { - if (hostId !== exceptHostId && !listenersByHost.has(hostId)) { - return deleteHost(hostId) - } - } - return false - } return { append(hostId, entry) { - const hostIdBytes = measureBoundedString(hostId, CONNECTION_LOG_HOST_ID_MAX_BYTES) - const entryBytes = measureConnectionLogEntry(entry, maxEntryBytes) - if (hostIdBytes === null || entryBytes === null || entryBytes > maxHostBytes) { - return + let entries = entriesByHost.get(hostId) + if (!entries) { + entries = [] + entriesByHost.set(hostId, entries) } - let host = entriesByHost.get(hostId) - if (!host) { - while (entriesByHost.size >= maxRetainedHosts) { - if (!evictOldestUnobservedHost()) { - return - } - } - const retainedHostKeyBytes = hostIdBytes + 128 - while (retainedStoreBytes + retainedHostKeyBytes + entryBytes > maxStoreBytes) { - if (!evictOldestUnobservedHost()) { - return - } - } - host = { entries: [], bytes: retainedHostKeyBytes } - entriesByHost.set(hostId, host) - retainedStoreBytes += retainedHostKeyBytes - } else { - entriesByHost.delete(hostId) - entriesByHost.set(hostId, host) + entries.push(entry) + if (entries.length > maxEntriesPerHost) { + entries.splice(0, entries.length - maxEntriesPerHost) } - while ( - host.entries.length >= maxEntriesPerHost || - host.bytes + entryBytes > maxHostBytes || - retainedStoreBytes + entryBytes > maxStoreBytes - ) { - const oldest = host.entries.shift() - if (oldest) { - host.bytes -= oldest.bytes - retainedStoreBytes -= oldest.bytes - continue - } - if (!evictOldestUnobservedHost(hostId)) { - break - } - } - if ( - host.bytes + entryBytes > maxHostBytes || - retainedStoreBytes + entryBytes > maxStoreBytes - ) { - if (host.entries.length === 0) { - deleteHost(hostId) - } - return - } - host.entries.push({ entry, bytes: entryBytes }) - host.bytes += entryBytes - retainedStoreBytes += entryBytes snapshotByHost.delete(hostId) const listeners = listenersByHost.get(hostId) if (listeners) { @@ -176,30 +51,21 @@ export function createConnectionLogStore( if (cached) { return cached } - const host = entriesByHost.get(hostId) - if (!host || host.entries.length === 0) { + const entries = entriesByHost.get(hostId) + if (!entries || entries.length === 0) { return EMPTY } - const snapshot = Object.freeze(host.entries.map((retained) => retained.entry)) + const snapshot = Object.freeze([...entries]) snapshotByHost.set(hostId, snapshot) return snapshot }, subscribe(hostId, listener) { - if (measureBoundedString(hostId, CONNECTION_LOG_HOST_ID_MAX_BYTES) === null) { - return () => {} - } let listeners = listenersByHost.get(hostId) if (!listeners) { - if (listenersByHost.size >= maxRetainedHosts) { - return () => {} - } listeners = new Set() listenersByHost.set(hostId, listeners) } - if (listeners.size >= CONNECTION_LOG_MAX_LISTENERS_PER_HOST) { - return () => {} - } listeners.add(listener) return () => { const set = listenersByHost.get(hostId) @@ -211,16 +77,6 @@ export function createConnectionLogStore( listenersByHost.delete(hostId) } } - }, - - delete(hostId) { - deleteHost(hostId) - const listeners = listenersByHost.get(hostId) - if (listeners) { - for (const listener of listeners) { - listener() - } - } } } } diff --git a/mobile/src/transport/e2ee.ts b/mobile/src/transport/e2ee.ts index d36cb577341..2732b3d615f 100644 --- a/mobile/src/transport/e2ee.ts +++ b/mobile/src/transport/e2ee.ts @@ -4,10 +4,6 @@ // stream frames use the raw byte bundle. import nacl from 'tweetnacl' import * as ExpoCrypto from 'expo-crypto' -import { MOBILE_INBOUND_MAX_FRAME_BYTES } from './mobile-inbound-frame-queue' - -export const MOBILE_E2EE_MAX_FRAME_BASE64_CHARACTERS = - Math.ceil(MOBILE_INBOUND_MAX_FRAME_BYTES / 3) * 4 // Why: Hermes (React Native's JS engine) lacks crypto.getRandomValues, // which tweetnacl requires. expo-crypto provides a native secure RNG @@ -43,14 +39,8 @@ function uint8ToBase64(bytes: Uint8Array): string { return btoa(binary) } -function base64ToUint8(b64: string, maxDecodedBytes: number): Uint8Array { - if (b64.length > Math.ceil(maxDecodedBytes / 3) * 4) { - throw new Error('Base64 payload exceeds safe size') - } +function base64ToUint8(b64: string): Uint8Array { const binary = atob(b64) - if (binary.length > maxDecodedBytes) { - throw new Error('Base64 payload exceeds safe size') - } const bytes = new Uint8Array(binary.length) for (let i = 0; i < binary.length; i++) { bytes[i] = binary.charCodeAt(i) @@ -59,7 +49,7 @@ function base64ToUint8(b64: string, maxDecodedBytes: number): Uint8Array { } export function publicKeyFromBase64(b64: string): Uint8Array { - const key = base64ToUint8(b64, 32) + const key = base64ToUint8(b64) if (key.length !== 32) { throw new Error( `Invalid public key: expected 32 bytes, got ${key.length} from "${b64.slice(0, 20)}..."` @@ -78,10 +68,7 @@ export function encrypt(plaintext: string, sharedKey: Uint8Array): string { } export function decrypt(encrypted: string, sharedKey: Uint8Array): string | null { - if (encrypted.length > MOBILE_E2EE_MAX_FRAME_BASE64_CHARACTERS) { - throw new Error('Base64 payload exceeds safe size') - } - const bundle = base64ToUint8(encrypted, MOBILE_INBOUND_MAX_FRAME_BYTES) + const bundle = base64ToUint8(encrypted) const plaintext = decryptBytes(bundle, sharedKey) return plaintext ? new TextDecoder().decode(plaintext) : null } diff --git a/mobile/src/transport/host-client-open-registry.test.ts b/mobile/src/transport/host-client-open-registry.test.ts deleted file mode 100644 index f70cef41f95..00000000000 --- a/mobile/src/transport/host-client-open-registry.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { HostClientOpenRegistry } from './host-client-open-registry' - -describe('HostClientOpenRegistry', () => { - it('cancels replaced and oldest tickets while bounding pending hosts', () => { - const registry = new HostClientOpenRegistry(2) - const first = registry.register('host-a', new Promise(() => {})) - const replacement = registry.register('host-a', new Promise(() => {})) - const second = registry.register('host-b', new Promise(() => {})) - const third = registry.register('host-c', new Promise(() => {})) - - expect(first.cancelled).toBe(true) - expect(replacement.cancelled).toBe(true) - expect(second.cancelled).toBe(false) - expect(third.cancelled).toBe(false) - expect(registry.getActivePromise('host-a')).toBeNull() - expect(registry.getActivePromise('host-b')).toBe(second.promise) - expect(registry.getActivePromise('host-c')).toBe(third.promise) - }) -}) diff --git a/mobile/src/transport/host-client-open-registry.ts b/mobile/src/transport/host-client-open-registry.ts index ac9c6a16c96..a47ca5a231a 100644 --- a/mobile/src/transport/host-client-open-registry.ts +++ b/mobile/src/transport/host-client-open-registry.ts @@ -3,35 +3,15 @@ export type HostClientOpenTicket = { promise: Promise } -export const HOST_CLIENT_OPEN_MAX_PENDING = 64 - export class HostClientOpenRegistry { private readonly pending = new Map() - constructor(private readonly maxPending = HOST_CLIENT_OPEN_MAX_PENDING) {} - getActivePromise(hostId: string): Promise | null { const ticket = this.pending.get(hostId) return ticket && !ticket.cancelled ? ticket.promise : null } register(hostId: string, promise: Promise): HostClientOpenTicket { - const prior = this.pending.get(hostId) - if (prior) { - prior.cancelled = true - this.pending.delete(hostId) - } - while (this.pending.size >= Math.max(1, this.maxPending)) { - const oldestHostId = this.pending.keys().next().value as string | undefined - if (oldestHostId === undefined) { - break - } - const oldest = this.pending.get(oldestHostId) - if (oldest) { - oldest.cancelled = true - } - this.pending.delete(oldestHostId) - } const ticket = { cancelled: false, promise } this.pending.set(hostId, ticket) return ticket diff --git a/mobile/src/transport/host-credential-cleanup.test.ts b/mobile/src/transport/host-credential-cleanup.test.ts index 2ba0664522d..bd483a3280f 100644 --- a/mobile/src/transport/host-credential-cleanup.test.ts +++ b/mobile/src/transport/host-credential-cleanup.test.ts @@ -1,8 +1,6 @@ import AsyncStorage from '@react-native-async-storage/async-storage' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { - HOST_CREDENTIAL_CLEANUP_MAX_INFLIGHT_DELETES, - HOST_CREDENTIAL_CLEANUP_MAX_PENDING_IDS, loadPendingHostCredentialCleanup, loadPendingHostCredentialCleanupIds, resetHostCredentialCleanupForTests, @@ -272,41 +270,4 @@ describe('host credential cleanup', () => { await expect(loadPendingHostCredentialCleanupIds()).resolves.toEqual([]) }) - - it('accepts the exact pending-id cap and treats one over as unreadable', async () => { - storedPendingIds = Array.from( - { length: HOST_CREDENTIAL_CLEANUP_MAX_PENDING_IDS }, - (_, index) => `host-${index}` - ) - - await expect(loadPendingHostCredentialCleanup()).resolves.toEqual({ - ids: storedPendingIds, - storageUnreadable: false - }) - - storedPendingIds = [...storedPendingIds, 'one-over'] - await expect(loadPendingHostCredentialCleanup()).resolves.toEqual({ - ids: [], - storageUnreadable: true - }) - }) - - it('caps simultaneous native deletes while preserving excess durable intents', async () => { - vi.useFakeTimers() - const deleteCredential = vi.fn(() => new Promise(() => undefined)) - const hostCount = HOST_CREDENTIAL_CLEANUP_MAX_INFLIGHT_DELETES + 1 - - await Promise.all( - Array.from({ length: hostCount }, (_, index) => - scheduleHostCredentialCleanup(`host-${index}`, deleteCredential, 3_000) - ) - ) - await flushMicrotasks() - - expect(deleteCredential).toHaveBeenCalledTimes(HOST_CREDENTIAL_CLEANUP_MAX_INFLIGHT_DELETES) - expect(storedPendingIds).toHaveLength(hostCount) - - await vi.advanceTimersByTimeAsync(3_000) - await expect(loadPendingHostCredentialCleanupIds()).resolves.toHaveLength(hostCount) - }) }) diff --git a/mobile/src/transport/host-credential-cleanup.ts b/mobile/src/transport/host-credential-cleanup.ts index c50c63bca33..d64e37a98dc 100644 --- a/mobile/src/transport/host-credential-cleanup.ts +++ b/mobile/src/transport/host-credential-cleanup.ts @@ -1,12 +1,7 @@ import AsyncStorage from '@react-native-async-storage/async-storage' -import { parseMobileJsonTextWithinLimits } from './mobile-json-text-admission' const PENDING_STORAGE_KEY = 'orca:pending-host-credential-cleanups' const CLEANUP_CONFIRM_TIMEOUT_MS = 3_000 -export const HOST_CREDENTIAL_CLEANUP_MAX_PENDING_IDS = 256 -export const HOST_CREDENTIAL_CLEANUP_MAX_STORAGE_CHARACTERS = 256 * 1024 -export const HOST_CREDENTIAL_CLEANUP_MAX_HOST_ID_CHARACTERS = 4_096 -export const HOST_CREDENTIAL_CLEANUP_MAX_INFLIGHT_DELETES = 16 type DeleteHostCredential = (hostId: string) => Promise type CleanupAttemptResult = 'cleared' | 'pending' @@ -38,11 +33,7 @@ function notifyPendingListeners(): void { } function markUnrecordedPending(hostId: string): void { - if ( - unrecordedPendingIds.has(hostId) || - !isTrackableHostId(hostId) || - unrecordedPendingIds.size >= HOST_CREDENTIAL_CLEANUP_MAX_PENDING_IDS - ) { + if (unrecordedPendingIds.has(hostId)) { return } unrecordedPendingIds.add(hostId) @@ -56,55 +47,17 @@ function clearUnrecordedPending(hostId: string): void { } function parsePendingIds(raw: string): string[] | null { - if (raw.length > HOST_CREDENTIAL_CLEANUP_MAX_STORAGE_CHARACTERS) { - return null - } try { - const parsed = parseMobileJsonTextWithinLimits(raw) + const parsed = JSON.parse(raw) as unknown if (!Array.isArray(parsed)) { return null } - const ids: string[] = [] - const seen = new Set() - for (const value of parsed) { - if (typeof value !== 'string') { - continue - } - if (!isTrackableHostId(value)) { - return null - } - if (!seen.has(value)) { - if (ids.length >= HOST_CREDENTIAL_CLEANUP_MAX_PENDING_IDS) { - return null - } - seen.add(value) - ids.push(value) - } - } - return ids + return [...new Set(parsed.filter((value): value is string => typeof value === 'string'))] } catch { return null } } -function isTrackableHostId(hostId: string): boolean { - return hostId.length > 0 && hostId.length <= HOST_CREDENTIAL_CLEANUP_MAX_HOST_ID_CHARACTERS -} - -function serializePendingIds(ids: string[]): string { - if ( - ids.length > HOST_CREDENTIAL_CLEANUP_MAX_PENDING_IDS || - ids.some((id) => !isTrackableHostId(id)) - ) { - throw new Error('pending host credential cleanup limit exceeded') - } - const serialized = JSON.stringify(ids) - if (serialized.length > HOST_CREDENTIAL_CLEANUP_MAX_STORAGE_CHARACTERS) { - throw new Error('pending host credential cleanup storage limit exceeded') - } - return serialized -} - function sameIdList(a: string[], b: string[]): boolean { return a.length === b.length && a.every((id, index) => id === b[index]) } @@ -150,7 +103,7 @@ async function mutatePendingIds(update: (ids: string[]) => string[]): Promise {}) @@ -189,9 +142,6 @@ function startOrJoinDelete(hostId: string, deleteCredential: DeleteHostCredentia if (existing) { return existing } - if (inflightDeletes.size >= HOST_CREDENTIAL_CLEANUP_MAX_INFLIGHT_DELETES) { - return Promise.reject(new Error('host credential cleanup concurrency limit reached')) - } const cleanup = Promise.resolve() .then(() => deleteCredential(hostId)) .finally(() => { @@ -204,9 +154,6 @@ function startOrJoinDelete(hostId: string, deleteCredential: DeleteHostCredentia } async function recordCleanupIntent(hostId: string): Promise { - if (!isTrackableHostId(hostId)) { - return false - } try { await addPendingId(hostId) return true @@ -287,10 +234,10 @@ export async function retryPendingHostCredentialCleanups( deleteCredential: DeleteHostCredential ): Promise<{ clearedCount: number; remainingIds: string[]; storageUnreadable: boolean }> { const pending = await loadPendingCleanupState() - const outcomes = await retryCleanupIds( - pending.ids, - deleteCredential, - HOST_CREDENTIAL_CLEANUP_MAX_INFLIGHT_DELETES + const outcomes = await Promise.all( + // Why: these ids are already durable (or a session-scoped fallback). Re-adding + // intent can race a late success and recreate a ghost row after deletion. + pending.ids.map((id) => confirmNativeCleanup(id, deleteCredential, CLEANUP_CONFIRM_TIMEOUT_MS)) ) const remaining = await loadPendingCleanupState() return { @@ -300,29 +247,6 @@ export async function retryPendingHostCredentialCleanups( } } -async function retryCleanupIds( - ids: string[], - deleteCredential: DeleteHostCredential, - concurrency: number -): Promise { - const outcomes: CleanupAttemptResult[] = [] - let nextIndex = 0 - const worker = async (): Promise => { - while (nextIndex < ids.length) { - const index = nextIndex++ - // Why: these ids already have recovery intent; re-adding can race a late - // success and recreate a ghost row after deletion. - outcomes[index] = await confirmNativeCleanup( - ids[index]!, - deleteCredential, - CLEANUP_CONFIRM_TIMEOUT_MS - ) - } - } - await Promise.all(Array.from({ length: Math.min(concurrency, ids.length) }, () => worker())) - return outcomes -} - /** Test-only: drop module listeners/in-flight state between cases. */ export function resetHostCredentialCleanupForTests(): void { inflightDeletes.clear() diff --git a/mobile/src/transport/host-removal-lifecycle.test.ts b/mobile/src/transport/host-removal-lifecycle.test.ts index d19b73673e8..e398062b270 100644 --- a/mobile/src/transport/host-removal-lifecycle.test.ts +++ b/mobile/src/transport/host-removal-lifecycle.test.ts @@ -1,23 +1,16 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { removeHostMock, deleteConnectionLogMock } = vi.hoisted(() => ({ - removeHostMock: vi.fn(), - deleteConnectionLogMock: vi.fn() -})) +const removeHostMock = vi.hoisted(() => vi.fn()) vi.mock('./host-store', () => ({ removeHost: (hostId: string) => removeHostMock(hostId) })) -vi.mock('./connection-log-buffer', () => ({ - connectionLogStore: { delete: deleteConnectionLogMock } -})) import { removeHostAndCloseClient } from './host-removal-lifecycle' describe('host removal lifecycle', () => { beforeEach(() => { removeHostMock.mockReset() - deleteConnectionLogMock.mockReset() }) it('closes the client only after metadata removal commits', async () => { @@ -35,7 +28,6 @@ describe('host removal lifecycle', () => { await removal expect(closeHostClient).toHaveBeenCalledWith('host-1') - expect(deleteConnectionLogMock).toHaveBeenCalledWith('host-1') }) it('keeps the client open when metadata removal fails', async () => { @@ -46,18 +38,5 @@ describe('host removal lifecycle', () => { 'storage unavailable' ) expect(closeHostClient).not.toHaveBeenCalled() - expect(deleteConnectionLogMock).not.toHaveBeenCalled() - }) - - it('forgets removed-host logs even when client teardown throws', async () => { - removeHostMock.mockResolvedValue(undefined) - const closeHostClient = vi.fn(() => { - throw new Error('close failed') - }) - - await expect(removeHostAndCloseClient('host-1', closeHostClient)).rejects.toThrow( - 'close failed' - ) - expect(deleteConnectionLogMock).toHaveBeenCalledWith('host-1') }) }) diff --git a/mobile/src/transport/host-removal-lifecycle.ts b/mobile/src/transport/host-removal-lifecycle.ts index 8f3148744f9..ba599614804 100644 --- a/mobile/src/transport/host-removal-lifecycle.ts +++ b/mobile/src/transport/host-removal-lifecycle.ts @@ -1,5 +1,4 @@ import { removeHost } from './host-store' -import { connectionLogStore } from './connection-log-buffer' export async function removeHostAndCloseClient( hostId: string, @@ -8,9 +7,5 @@ export async function removeHostAndCloseClient( // Why: closing before the metadata commit can strand a still-paired host on // storage failure; closing immediately after success prevents socket leaks. await removeHost(hostId) - try { - closeHostClient(hostId) - } finally { - connectionLogStore.delete(hostId) - } + closeHostClient(hostId) } diff --git a/mobile/src/transport/host-store.test.ts b/mobile/src/transport/host-store.test.ts index a9eb268d349..918e6e92f93 100644 --- a/mobile/src/transport/host-store.test.ts +++ b/mobile/src/transport/host-store.test.ts @@ -34,8 +34,6 @@ vi.mock('./host-credential-cleanup', () => ({ })) import { - HOST_TOKEN_CACHE_MAX_ENTRIES, - HOST_TOKEN_CACHE_MAX_ENTRY_CODE_UNITS, loadHosts, MobileRelayUpgradeHostRemovedError, removeHost, @@ -46,18 +44,7 @@ import { updateHostNameAndEndpoint, updateLastConnected } from './host-store' -import { - STORED_HOSTS_MAX_ENTRIES, - STORED_HOSTS_MAX_STORAGE_CHARACTERS -} from './mobile-host-list-storage' import { resetMobileRelayHostOverlayStoreForTests } from './mobile-relay-host-overlay-store' -import { - MOBILE_HOST_ID_MAX_CHARACTERS, - MOBILE_HOST_NAME_MAX_CHARACTERS, - PAIRING_DEVICE_TOKEN_MAX_CHARACTERS, - PAIRING_ENDPOINT_MAX_CHARACTERS, - PAIRING_PUBLIC_KEY_MAX_CHARACTERS -} from './types' const HOSTS_STORAGE_KEY = 'orca:hosts' const OVERLAY_STORAGE_KEY = 'orca:mobile-relay:host-overlays:v2' @@ -235,101 +222,6 @@ describe('host-store list mutations', () => { expect(hosts.some(({ id }) => id === 'removed-by-old-build')).toBe(false) }) - it('bounds cached keychain tokens without changing loaded hosts', async () => { - const storedHosts = Array.from({ length: HOST_TOKEN_CACHE_MAX_ENTRIES + 1 }, (_, index) => ({ - ...HOST_ONE, - id: `host-${index}`, - publicKeyB64: `key-${index}` - })) - storedHostsRaw = JSON.stringify(storedHosts) - secureStoreMock.getItemAsync.mockImplementation(async (key: string) => `token-${key}`) - - await expect(loadHosts()).resolves.toHaveLength(storedHosts.length) - expect(secureStoreMock.getItemAsync).toHaveBeenCalledTimes(storedHosts.length) - - await expect(loadHosts()).resolves.toHaveLength(storedHosts.length) - expect(secureStoreMock.getItemAsync).toHaveBeenCalledTimes(storedHosts.length + 1) - }) - - it('does not retain an oversized token in the in-memory cache', async () => { - storedHostsRaw = JSON.stringify([HOST_ONE]) - secureStoreMock.getItemAsync.mockResolvedValue( - 'x'.repeat(HOST_TOKEN_CACHE_MAX_ENTRY_CODE_UNITS) - ) - - await expect(loadHosts()).resolves.toHaveLength(1) - await expect(loadHosts()).resolves.toHaveLength(1) - - expect(secureStoreMock.getItemAsync).toHaveBeenCalledTimes(2) - }) - - it('accepts the exact host count in order and rejects one more', async () => { - const exact = Array.from({ length: STORED_HOSTS_MAX_ENTRIES }, (_, index) => ({ - ...HOST_ONE, - id: `host-${index}`, - publicKeyB64: `key-${index}` - })) - storedHostsRaw = JSON.stringify(exact) - secureStoreMock.getItemAsync.mockImplementation(async (key: string) => `token-${key}`) - - await expect(loadHosts()).resolves.toMatchObject( - exact.map((host) => ({ id: host.id, publicKeyB64: host.publicKeyB64 })) - ) - - storedHostsRaw = JSON.stringify([ - ...exact, - { ...HOST_ONE, id: 'host-overflow', publicKeyB64: 'key-overflow' } - ]) - await expect(loadHosts()).resolves.toEqual([]) - }) - - it('does not parse an oversized host-list payload', async () => { - storedHostsRaw = { - length: STORED_HOSTS_MAX_STORAGE_CHARACTERS + 1 - } as unknown as string - const parse = vi.spyOn(JSON, 'parse') - - await expect(loadHosts()).resolves.toEqual([]) - expect(parse).not.toHaveBeenCalled() - expect(secureStoreMock.getItemAsync).not.toHaveBeenCalled() - parse.mockRestore() - }) - - it('accepts exact host field limits and rejects one character more', async () => { - const exact = { - id: 'i'.repeat(MOBILE_HOST_ID_MAX_CHARACTERS), - name: 'n'.repeat(MOBILE_HOST_NAME_MAX_CHARACTERS), - endpoint: 'e'.repeat(PAIRING_ENDPOINT_MAX_CHARACTERS), - deviceToken: 't'.repeat(PAIRING_DEVICE_TOKEN_MAX_CHARACTERS), - publicKeyB64: 'p'.repeat(PAIRING_PUBLIC_KEY_MAX_CHARACTERS), - lastConnected: 1 - } - - await expect(saveHost(exact)).resolves.toBeUndefined() - await expect(saveHost({ ...exact, name: `${exact.name}n` })).rejects.toThrow() - }) - - it('refuses a write that would exceed the stored host count', async () => { - storedHostsRaw = JSON.stringify( - Array.from({ length: STORED_HOSTS_MAX_ENTRIES }, (_, index) => ({ - ...HOST_ONE, - id: `host-${index}`, - publicKeyB64: `key-${index}` - })) - ) - - await expect( - saveHost({ - ...HOST_ONE, - id: 'host-overflow', - publicKeyB64: 'key-overflow', - deviceToken: 'token' - }) - ).rejects.toThrow(/storage limit/) - expect(asyncStorageMock.setItem).not.toHaveBeenCalledWith(HOSTS_STORAGE_KEY, expect.any(String)) - expect(secureStoreMock.setItemAsync).not.toHaveBeenCalled() - }) - it('refuses to resurrect a removed host during relay upgrade publication', async () => { storedHostsRaw = JSON.stringify([HOST_TWO]) diff --git a/mobile/src/transport/host-store.ts b/mobile/src/transport/host-store.ts index 7cffbe2d260..61ed6742d12 100644 --- a/mobile/src/transport/host-store.ts +++ b/mobile/src/transport/host-store.ts @@ -3,7 +3,7 @@ import * as SecureStore from 'expo-secure-store' import { Platform } from 'react-native' import { HostProfileSchema, - PAIRING_DEVICE_TOKEN_MAX_CHARACTERS, + StoredHostProfileSchema, type HostProfile, type StoredHostProfile } from './types' @@ -21,10 +21,6 @@ import { import { deleteMobileRelayCredentialBundle } from './mobile-relay-credential-bundle' import { deleteMobileRelayDirectUpgradeJournal } from './mobile-relay-direct-upgrade-journal' import { scheduleOrphanedMobileRelayCleanup } from './mobile-relay-orphan-cleanup' -import { - parseMobileStoredHostList, - serializeMobileStoredHostList -} from './mobile-host-list-storage' const STORAGE_KEY = 'orca:hosts' // Why: SecureStore keys must match [A-Za-z0-9._-] (colons rejected), so use dots as the separator. @@ -47,11 +43,10 @@ function webTokenKey(hostId: string): string { async function readDeviceToken(hostId: string): Promise { // Why: Expo SecureStore has no working web backend; fall back to AsyncStorage only on web so native still uses the keychain. - const token = - Platform.OS === 'web' - ? await AsyncStorage.getItem(webTokenKey(hostId)) - : await SecureStore.getItemAsync(tokenKey(hostId), KEYCHAIN_OPTIONS) - return token !== null && token.length <= PAIRING_DEVICE_TOKEN_MAX_CHARACTERS ? token : null + if (Platform.OS === 'web') { + return AsyncStorage.getItem(webTokenKey(hostId)) + } + return SecureStore.getItemAsync(tokenKey(hostId), KEYCHAIN_OPTIONS) } async function writeDeviceToken(hostId: string, token: string): Promise { @@ -77,30 +72,31 @@ async function deleteHostCredentials(hostId: string): Promise { } // Why: Keychain reads are slow (50-200ms) and loadHosts() runs on every screen mount; cache per-hostId in memory, invalidate on save/remove. -export const HOST_TOKEN_CACHE_MAX_ENTRIES = 64 -export const HOST_TOKEN_CACHE_MAX_ENTRY_CODE_UNITS = 64 * 1024 const tokenCache = new Map() let inflightLoad: Promise | null = null // Why: serialize RMW of the shared hosts JSON; without a queue concurrent writers drop writes (resurrect a removed host, drop a rename). let hostListMutation: Promise = Promise.resolve() -function rememberHostToken(hostId: string, token: string, allowEviction: boolean): void { - if (hostId.length + token.length > HOST_TOKEN_CACHE_MAX_ENTRY_CODE_UNITS) { - tokenCache.delete(hostId) - return +function parseStoredHosts(raw: string | null): StoredHostProfile[] | null { + if (!raw) { + return [] } - if (tokenCache.has(hostId)) { - tokenCache.delete(hostId) - } else if (tokenCache.size >= HOST_TOKEN_CACHE_MAX_ENTRIES) { - if (!allowEviction) { - return - } - const oldestHostId = tokenCache.keys().next().value - if (oldestHostId !== undefined) { - tokenCache.delete(oldestHostId) + try { + const parsed = JSON.parse(raw) as unknown + if (!Array.isArray(parsed)) { + return null } + return parsed.flatMap((item) => { + // Why: pre-v0.0.3 records stored deviceToken in AsyncStorage; drop them (users re-pair) rather than carry a migration shim. + if (item && typeof item === 'object' && 'deviceToken' in item) { + return [] + } + const result = StoredHostProfileSchema.safeParse(item) + return result.success ? [result.data] : [] + }) + } catch { + return null } - tokenCache.set(hostId, token) } export async function loadHosts(): Promise { @@ -118,7 +114,7 @@ export async function loadHosts(): Promise { async function doLoadHosts(): Promise { const raw = await AsyncStorage.getItem(STORAGE_KEY) - const storedHosts = parseMobileStoredHostList(raw) + const storedHosts = parseStoredHosts(raw) if (!storedHosts) { return [] } @@ -147,7 +143,7 @@ async function doLoadHosts(): Promise { continue } token = fetched - rememberHostToken(stored.id, token, false) + tokenCache.set(stored.id, token) } const overlay = overlays.get(stored.id) out.push({ @@ -180,7 +176,7 @@ export async function resolvePairingHostIdentity( async function readStoredHostsForMutation(): Promise { try { - const parsed = parseMobileStoredHostList(await AsyncStorage.getItem(STORAGE_KEY)) + const parsed = parseStoredHosts(await AsyncStorage.getItem(STORAGE_KEY)) if (!parsed) { // Why: refuse to RMW over unreadable payload — treating it as [] would wipe the durable host list on the next write. throw new Error('host list storage unreadable') @@ -200,7 +196,7 @@ async function mutateStoredHosts( const mutation = hostListMutation.then(async () => { const current = await readStoredHostsForMutation() const next = update(current) - await AsyncStorage.setItem(STORAGE_KEY, serializeMobileStoredHostList(next)) + await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(next)) }) hostListMutation = mutation.catch(() => {}) return mutation @@ -253,7 +249,7 @@ async function persistHost(host: HostProfile, requireExisting: boolean): Promise }) // Why: write metadata before the keychain token so a crash leaves recoverable orphaned metadata, not an orphaned token that persists forever. await writeDeviceToken(stored.id, validated.deviceToken) - rememberHostToken(stored.id, validated.deviceToken, true) + tokenCache.set(stored.id, validated.deviceToken) if (validated.endpoints) { await saveMobileRelayHostOverlay({ v: 2, diff --git a/mobile/src/transport/mobile-direct-rpc-outbound.test.ts b/mobile/src/transport/mobile-direct-rpc-outbound.test.ts deleted file mode 100644 index ab69ca0a55c..00000000000 --- a/mobile/src/transport/mobile-direct-rpc-outbound.test.ts +++ /dev/null @@ -1,177 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { createMobileDirectRpcOutbound } from './mobile-direct-rpc-outbound' -import { createMobileOutboundMemoryBudget } from './mobile-outbound-memory-budget' -import { MOBILE_OUTBOUND_SOCKET_RETIRE_TIMEOUT_MS } from './mobile-outbound-socket-ledger' - -vi.mock('./e2ee', () => ({ - encrypt: (plaintext: string) => `encrypted:${plaintext}` -})) - -function socket() { - return { - OPEN: 1, - bufferedAmount: 0, - readyState: 1, - send: vi.fn() - } -} - -describe('mobile direct RPC outbound queue', () => { - beforeEach(() => vi.useFakeTimers()) - afterEach(() => vi.useRealTimers()) - - it('parks in FIFO order while another socket owns aggregate native buffer credit', () => { - const budget = createMobileOutboundMemoryBudget({ - maxBufferedBytes: 100, - maxQueuedBytes: 1_000 - }) - const firstSocket = socket() - const secondSocket = socket() - firstSocket.bufferedAmount = 60 - secondSocket.bufferedAmount = 41 - const first = createMobileDirectRpcOutbound({ - socket: firstSocket, - isActive: () => true, - onOverflow: vi.fn(), - memoryBudget: budget - }) - const second = createMobileDirectRpcOutbound({ - socket: secondSocket, - isActive: () => true, - onOverflow: vi.fn(), - memoryBudget: budget - }) - - expect(first.enqueue('one', new Uint8Array(32), 'rpc-1')).toBe(true) - expect(first.enqueue('two', new Uint8Array(32), 'rpc-2')).toBe(true) - expect(firstSocket.send).not.toHaveBeenCalled() - expect(budget.evidence().queuedClaimCount).toBe(2) - - secondSocket.bufferedAmount = 0 - firstSocket.bufferedAmount = 0 - vi.advanceTimersByTime(25) - - expect(firstSocket.send.mock.calls.map(([frame]) => frame)).toEqual(['encrypted:one']) - first.acknowledge('rpc-1') - vi.advanceTimersByTime(25) - expect(firstSocket.send.mock.calls.map(([frame]) => frame)).toEqual([ - 'encrypted:one', - 'encrypted:two' - ]) - first.acknowledge('rpc-2') - expect(budget.evidence()).toMatchObject({ queuedBytes: 0, queuedClaimCount: 0 }) - first.dispose() - second.dispose() - expect(budget.evidence().bufferedSourceCount).toBe(2) - first.socketClosed() - second.socketClosed() - expect(budget.evidence().bufferedSourceCount).toBe(0) - }) - - it('fails the offending connection when aggregate JavaScript admission is exhausted', () => { - const budget = createMobileOutboundMemoryBudget({ - maxBufferedBytes: 1, - maxQueuedBytes: 10 - }) - const targetSocket = socket() - targetSocket.bufferedAmount = 2 - const onOverflow = vi.fn() - const outbound = createMobileDirectRpcOutbound({ - socket: targetSocket, - isActive: () => true, - onOverflow, - memoryBudget: budget - }) - - expect(outbound.enqueue('payload', new Uint8Array(32))).toBe(false) - - expect(onOverflow).toHaveBeenCalledOnce() - expect(targetSocket.send).not.toHaveBeenCalled() - expect(budget.evidence()).toMatchObject({ - bufferedSourceCount: 1, - queuedBytes: 0, - queuedClaimCount: 0 - }) - outbound.dispose() - expect(budget.evidence().bufferedSourceCount).toBe(1) - outbound.socketClosed() - expect(budget.evidence().bufferedSourceCount).toBe(0) - }) - - it('uses matching RPC responses as backpressure when bufferedAmount is unavailable', () => { - const budget = createMobileOutboundMemoryBudget({ - maxBufferedBytes: 100, - maxQueuedBytes: 1_000 - }) - const targetSocket = socket() - targetSocket.bufferedAmount = Number.NaN - const outbound = createMobileDirectRpcOutbound({ - socket: targetSocket, - isActive: () => true, - onOverflow: vi.fn(), - memoryBudget: budget - }) - - expect(outbound.enqueue('one', new Uint8Array(32), 'rpc-1')).toBe(true) - expect(outbound.enqueue('two', new Uint8Array(32), 'rpc-2')).toBe(true) - expect(targetSocket.send.mock.calls.map(([frame]) => frame)).toEqual(['encrypted:one']) - - outbound.acknowledge('rpc-1') - vi.advanceTimersByTime(25) - expect(targetSocket.send.mock.calls.map(([frame]) => frame)).toEqual([ - 'encrypted:one', - 'encrypted:two' - ]) - expect(budget.evidence()).toMatchObject({ inFlightBytes: 60, inFlightClaimCount: 1 }) - - outbound.socketClosed() - expect(budget.evidence()).toMatchObject({ bufferedBytes: 0, bufferedSourceCount: 0 }) - }) - - it('reports synchronous native send failure instead of marking a stream as sent', () => { - const budget = createMobileOutboundMemoryBudget({ maxBufferedBytes: 100 }) - const targetSocket = socket() - targetSocket.send.mockImplementation(() => { - throw new Error('native send failed') - }) - const onOverflow = vi.fn() - const outbound = createMobileDirectRpcOutbound({ - socket: targetSocket, - isActive: () => true, - onOverflow, - memoryBudget: budget - }) - - expect(outbound.enqueue('one', new Uint8Array(32), 'rpc-1')).toBe(false) - expect(onOverflow).toHaveBeenCalledOnce() - expect(budget.evidence()).toMatchObject({ inFlightBytes: 0, inFlightClaimCount: 0 }) - outbound.socketClosed() - }) - - it('allows another socket after a retired socket never reports close', () => { - const budget = createMobileOutboundMemoryBudget({ - maxBufferedBytes: 100, - maxBufferedSources: 1 - }) - const first = createMobileDirectRpcOutbound({ - socket: socket(), - isActive: () => true, - onOverflow: vi.fn(), - memoryBudget: budget - }) - - first.dispose() - expect(budget.canRegisterBufferedAmount()).toBe(false) - vi.advanceTimersByTime(MOBILE_OUTBOUND_SOCKET_RETIRE_TIMEOUT_MS) - expect(budget.canRegisterBufferedAmount()).toBe(true) - - const second = createMobileDirectRpcOutbound({ - socket: socket(), - isActive: () => true, - onOverflow: vi.fn(), - memoryBudget: budget - }) - second.socketClosed() - expect(budget.evidence().bufferedSourceCount).toBe(0) - }) -}) diff --git a/mobile/src/transport/mobile-direct-rpc-outbound.ts b/mobile/src/transport/mobile-direct-rpc-outbound.ts deleted file mode 100644 index 8a78c30d836..00000000000 --- a/mobile/src/transport/mobile-direct-rpc-outbound.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { createWsOutboundBackpressureQueue } from '../../../src/shared/ws-outbound-backpressure-queue' -import { getUtf8ByteLength } from '../../../src/shared/utf8-byte-limits' -import { encrypt } from './e2ee' -import { - processMobileOutboundMemoryBudget, - MOBILE_OUTBOUND_MAX_FRAME_BYTES, - type MobileOutboundMemoryBudget -} from './mobile-outbound-memory-budget' -import { createMobileOutboundSocketLedger } from './mobile-outbound-socket-ledger' - -type DirectRpcOutboundFrame = { - acknowledgementKey?: string - key: Uint8Array - plaintext: string -} - -type DirectRpcOutboundSocket = { - OPEN: number - bufferedAmount: number - readyState: number - send(frame: string): void -} - -export type MobileDirectRpcOutbound = { - acknowledge(key: string): void - acknowledgeAuthentication(): void - dispose(): void - enqueue(plaintext: string, key: Uint8Array, acknowledgementKey?: string): boolean - socketClosed(): void -} - -const AUTHENTICATION_ACKNOWLEDGEMENT_KEY = 'mobile-direct-authentication' - -export function createMobileDirectRpcOutbound(args: { - socket: DirectRpcOutboundSocket - isActive: () => boolean - onOverflow: () => void - memoryBudget?: MobileOutboundMemoryBudget -}): MobileDirectRpcOutbound { - const memoryBudget = args.memoryBudget ?? processMobileOutboundMemoryBudget - let stopped = false - let disposed = false - let overflowed = false - const socketLedger = createMobileOutboundSocketLedger({ - memoryBudget, - readBufferedAmount: () => args.socket.bufferedAmount - }) - const failOverflow = (): void => { - if (overflowed) { - return - } - overflowed = true - stopped = true - args.onOverflow() - } - const queue = createWsOutboundBackpressureQueue({ - send: (frame) => { - const bytes = encryptedTextFrameBytes(frame.plaintext) - const cancelClaim = socketLedger.claimSentBytes(bytes, frame.acknowledgementKey) - if (!cancelClaim) { - failOverflow() - return - } - try { - args.socket.send(encrypt(frame.plaintext, frame.key)) - } catch { - cancelClaim() - failOverflow() - } - }, - byteLengthOf: (frame) => encryptedTextFrameBytes(frame.plaintext), - getBufferedAmount: () => args.socket.bufferedAmount, - isWritable: () => args.isActive() && args.socket.readyState === args.socket.OPEN && !stopped, - canSend: (bytes) => socketLedger.canSend(bytes), - claimQueuedBytes: (bytes) => memoryBudget.claimQueuedBytes(bytes), - maxFrameBytes: MOBILE_OUTBOUND_MAX_FRAME_BYTES, - onOverflow: failOverflow - }) - - return { - acknowledge: (key) => socketLedger.acknowledge(key), - acknowledgeAuthentication: () => socketLedger.acknowledge(AUTHENTICATION_ACKNOWLEDGEMENT_KEY), - dispose(): void { - if (disposed) { - return - } - disposed = true - stopped = true - queue.dispose() - socketLedger.retire() - }, - enqueue(plaintext, key, acknowledgementKey): boolean { - if (stopped || !args.isActive() || args.socket.readyState !== args.socket.OPEN) { - return false - } - const accepted = queue.enqueue({ - plaintext, - key, - acknowledgementKey: - acknowledgementKey ?? - (isAuthenticationRequest(plaintext) ? AUTHENTICATION_ACKNOWLEDGEMENT_KEY : undefined) - }) - return accepted && !overflowed - }, - socketClosed(): void { - stopped = true - queue.dispose() - socketLedger.socketClosed() - } - } -} - -function encryptedTextFrameBytes(plaintext: string): number { - const encryptedBytes = getUtf8ByteLength(plaintext) + 40 - const wireBytes = Math.ceil(encryptedBytes / 3) * 4 - return Math.max(wireBytes, plaintext.length * 2) -} - -function isAuthenticationRequest(plaintext: string): boolean { - try { - const value = JSON.parse(plaintext) as { type?: unknown } - return value.type === 'e2ee_auth' - } catch { - return false - } -} diff --git a/mobile/src/transport/mobile-direct-rpc-sender.ts b/mobile/src/transport/mobile-direct-rpc-sender.ts deleted file mode 100644 index d2e9c4ca672..00000000000 --- a/mobile/src/transport/mobile-direct-rpc-sender.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { MobileDirectRpcOutbound } from './mobile-direct-rpc-outbound' -import { stringifyMobileOutboundJson } from './mobile-outbound-json' -import type { ConnectionState } from './types' - -export function createMobileDirectRpcSender(args: { - getOutbound: () => MobileDirectRpcOutbound | null - getSharedKey: () => Uint8Array | null - getSocket: () => WebSocket | null - getState: () => ConnectionState - onSocketDesync: (socket: WebSocket) => void -}): (request: unknown) => boolean { - return (request): boolean => { - const socket = args.getSocket() - const sharedKey = args.getSharedKey() - const outbound = args.getOutbound() - if (socket && socket.readyState === WebSocket.OPEN && sharedKey && outbound) { - try { - return outbound.enqueue( - stringifyMobileOutboundJson(request), - sharedKey, - requestAcknowledgementKey(request) - ) - } catch (error) { - console.warn('[net] outbound request rejected', error) - return false - } - } - const state = args.getState() - console.log('[net] sendEncrypted FAILED — channel not ready', { - hasWs: !!socket, - readyState: socket?.readyState, - hasKey: !!sharedKey, - state - }) - if (state === 'connected' && socket && socket.readyState !== WebSocket.OPEN) { - console.log('[net] sendEncrypted detected ws desync — forcing reconnect', { - readyState: socket.readyState - }) - args.onSocketDesync(socket) - } - return false - } -} - -function requestAcknowledgementKey(request: unknown): string | undefined { - if (!request || typeof request !== 'object') { - return undefined - } - const id = (request as { id?: unknown }).id - return typeof id === 'string' ? id : undefined -} diff --git a/mobile/src/transport/mobile-e2ee-legacy-fixtures.test.ts b/mobile/src/transport/mobile-e2ee-legacy-fixtures.test.ts index aea6f3d7a55..7e0628960e1 100644 --- a/mobile/src/transport/mobile-e2ee-legacy-fixtures.test.ts +++ b/mobile/src/transport/mobile-e2ee-legacy-fixtures.test.ts @@ -6,13 +6,7 @@ vi.mock('expo-crypto', () => ({ getRandomBytes: (length: number) => new Uint8Array(length).fill(9) })) -import { - MOBILE_E2EE_MAX_FRAME_BASE64_CHARACTERS, - decrypt, - decryptBytes, - deriveSharedKey, - publicKeyFromBase64 -} from './e2ee' +import { decrypt, decryptBytes, deriveSharedKey } from './e2ee' describe('mobile legacy E2EE fixtures', () => { it('matches the captured desktop key and text/binary frames', () => { @@ -25,17 +19,6 @@ describe('mobile legacy E2EE fixtures', () => { expect(decrypt(fixture.authFrameB64, shared)).toBe(fixture.authPlaintext) expect(decryptBytes(fromHex(fixture.binaryFrameHex), shared)).toEqual(fixture.binaryPlaintext) }) - - it('rejects oversized base64 before calling the decoder', () => { - const decode = vi.spyOn(globalThis, 'atob') - const oversizedFrame = { - length: MOBILE_E2EE_MAX_FRAME_BASE64_CHARACTERS + 1 - } as unknown as string - - expect(() => publicKeyFromBase64('A'.repeat(45))).toThrow(/safe size/) - expect(() => decrypt(oversizedFrame, new Uint8Array(32))).toThrow(/safe size/) - expect(decode).not.toHaveBeenCalled() - }) }) function hex(bytes: Uint8Array): string { diff --git a/mobile/src/transport/mobile-e2ee-v2-client-session.test.ts b/mobile/src/transport/mobile-e2ee-v2-client-session.test.ts index 3cccc28260c..6cff0ef3523 100644 --- a/mobile/src/transport/mobile-e2ee-v2-client-session.test.ts +++ b/mobile/src/transport/mobile-e2ee-v2-client-session.test.ts @@ -12,12 +12,8 @@ vi.mock('expo-crypto', () => ({ })) import { deriveSharedKey } from './e2ee' -import { - MOBILE_E2EE_V2_MAX_TEXT_FRAME_BASE64_CHARACTERS, - MobileE2EEV2ClientSession -} from './mobile-e2ee-v2-client-session' +import { MobileE2EEV2ClientSession } from './mobile-e2ee-v2-client-session' import { deriveMobileE2EEV2KeySchedule } from './mobile-e2ee-v2-key-schedule' -import { MOBILE_INBOUND_MAX_FRAME_BYTES } from './mobile-inbound-frame-queue' const desktop = nacl.box.keyPair.fromSecretKey(new Uint8Array(32).fill(1)) const client = nacl.box.keyPair.fromSecretKey(new Uint8Array(32).fill(2)) @@ -86,19 +82,4 @@ describe('mobile E2EE v2 client session', () => { expect(session.openText(encoded)).toBe('authenticated') expect(session.openText(encoded)).toBeNull() }) - - it('rejects oversized text and binary frames before materialization', () => { - const { session } = setup() - const decode = vi.spyOn(globalThis, 'atob') - const oversizedText = { - length: MOBILE_E2EE_V2_MAX_TEXT_FRAME_BASE64_CHARACTERS + 1 - } as unknown as string - const oversizedBinary = { - byteLength: MOBILE_INBOUND_MAX_FRAME_BYTES + 1 - } as unknown as Uint8Array - - expect(session.openText(oversizedText)).toBeNull() - expect(session.openBinary(oversizedBinary)).toBeNull() - expect(decode).not.toHaveBeenCalled() - }) }) diff --git a/mobile/src/transport/mobile-e2ee-v2-client-session.ts b/mobile/src/transport/mobile-e2ee-v2-client-session.ts index 80246607a8c..1cce2946860 100644 --- a/mobile/src/transport/mobile-e2ee-v2-client-session.ts +++ b/mobile/src/transport/mobile-e2ee-v2-client-session.ts @@ -11,10 +11,6 @@ import { } from '../../../src/shared/mobile-e2ee-v2-framing' import { deriveSharedKey, generateKeyPair, publicKeyFromBase64, publicKeyToBase64 } from './e2ee' import { deriveMobileE2EEV2KeySchedule } from './mobile-e2ee-v2-key-schedule' -import { MOBILE_INBOUND_MAX_FRAME_BYTES } from './mobile-inbound-frame-queue' - -export const MOBILE_E2EE_V2_MAX_TEXT_FRAME_BASE64_CHARACTERS = - Math.ceil(MOBILE_INBOUND_MAX_FRAME_BYTES / 3) * 4 export class MobileE2EEV2ClientSession { readonly hello: MobileE2EEV2Hello @@ -95,9 +91,6 @@ export class MobileE2EEV2ClientSession { } openBinary(frame: Uint8Array): Uint8Array | null { - if (frame.byteLength > MOBILE_INBOUND_MAX_FRAME_BYTES) { - return null - } return this.open(frame, 'binary') } @@ -153,14 +146,8 @@ function encodeBase64(bytes: Uint8Array): string { } function decodeCanonicalBase64(value: string): Uint8Array | null { - if (value.length > MOBILE_E2EE_V2_MAX_TEXT_FRAME_BASE64_CHARACTERS) { - return null - } try { const binary = atob(value) - if (binary.length > MOBILE_INBOUND_MAX_FRAME_BYTES) { - return null - } const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0)) return encodeBase64(bytes) === value ? bytes : null } catch { diff --git a/mobile/src/transport/mobile-e2ee-v2-physical-channel.test.ts b/mobile/src/transport/mobile-e2ee-v2-physical-channel.test.ts index 064b117e168..a7aae9e6d0f 100644 --- a/mobile/src/transport/mobile-e2ee-v2-physical-channel.test.ts +++ b/mobile/src/transport/mobile-e2ee-v2-physical-channel.test.ts @@ -22,16 +22,11 @@ import { MobileE2EEV2PhysicalChannel, type MobileE2EEV2Socket } from './mobile-e2ee-v2-physical-channel' -import { createMobileOutboundMemoryBudget } from './mobile-outbound-memory-budget' -import { MOBILE_OUTBOUND_MAX_JSON_BYTES } from './mobile-outbound-json' const desktop = nacl.box.keyPair.fromSecretKey(new Uint8Array(32).fill(1)) const client = nacl.box.keyPair.fromSecretKey(new Uint8Array(32).fill(2)) -function setup( - decodeBinary: (raw: unknown) => Promise, - deviceToken = 'valid-token' -) { +function setup(decodeBinary: (raw: unknown) => Promise) { const session = MobileE2EEV2ClientSession.create({ desktopPublicKeyB64: Buffer.from(desktop.publicKey).toString('base64'), transport: 'relay', @@ -49,17 +44,15 @@ function setup( const events: string[] = [] const onAuthenticated = vi.fn(() => events.push('authenticated')) const onError = vi.fn() - const outboundMemoryBudget = createMobileOutboundMemoryBudget() const channel = new MobileE2EEV2PhysicalChannel({ session, socket, - deviceToken, + deviceToken: 'valid-token', decodeBinary, onAuthenticated, onText: (plaintext) => events.push(`text:${plaintext}`), onBinary: (plaintext) => events.push(`binary:${plaintext[0]}`), - onError, - outboundMemoryBudget + onError }) channel.start() @@ -79,18 +72,7 @@ function setup( clientNonce: handshake.clientNonce, desktopNonce: handshake.desktopNonce }) - return { - channel, - session, - socket, - sent, - events, - onAuthenticated, - onError, - outboundMemoryBudget, - ready, - schedule - } + return { channel, session, socket, sent, events, onAuthenticated, onError, ready, schedule } } function serverFrame( @@ -140,17 +122,6 @@ describe('mobile E2EE v2 physical channel', () => { expect(ctx.onError).not.toHaveBeenCalled() }) - it('rejects ready JSON above the nesting cap before object materialization', async () => { - const ctx = setup(async () => null) - - await ctx.channel.handleMessage(`${'['.repeat(129)}0${']'.repeat(129)}`) - - expect(ctx.onError).toHaveBeenCalledWith( - expect.objectContaining({ message: 'Invalid E2EE v2 ready JSON' }) - ) - expect(ctx.sent).toHaveLength(1) - }) - it('classifies the encrypted desktop device-token rejection as global auth failure', async () => { const ctx = setup(async () => null) await ctx.channel.handleMessage(JSON.stringify(ctx.ready)) @@ -169,18 +140,6 @@ describe('mobile E2EE v2 physical channel', () => { expect(ctx.onAuthenticated).not.toHaveBeenCalled() }) - it('rejects oversized authentication JSON before sealing or sending it', async () => { - const ctx = setup(async () => null, 'x'.repeat(MOBILE_OUTBOUND_MAX_JSON_BYTES)) - - await ctx.channel.handleMessage(JSON.stringify(ctx.ready)) - - expect(ctx.sent).toHaveLength(1) - expect(ctx.onError).toHaveBeenCalledWith( - expect.objectContaining({ message: expect.stringContaining('Mobile outbound JSON exceeds') }) - ) - ctx.channel.socketClosed() - }) - it('serializes delayed binary conversion before a later text counter', async () => { let releaseBinary!: (bytes: Uint8Array) => void const pendingBinary = new Promise((resolve) => (releaseBinary = resolve)) @@ -201,27 +160,6 @@ describe('mobile E2EE v2 physical channel', () => { expect(ctx.onError).not.toHaveBeenCalled() }) - it('fails closed when delayed inbound conversion lets frames exceed the backlog bound', async () => { - let releaseBinary!: (bytes: Uint8Array) => void - const pendingBinary = new Promise((resolve) => (releaseBinary = resolve)) - const ctx = setup(async () => pendingBinary) - await authenticate(ctx) - const binary = serverFrame(new Uint8Array([7]), 'binary', 1n, ctx.schedule) - const pending = Array.from({ length: 65 }, () => - ctx.channel.handleMessage({ delayedBlob: true }) - ) - - await Promise.resolve() - try { - expect(ctx.onError).toHaveBeenCalledOnce() - expect(ctx.onError.mock.calls[0]![0].message).toBe('E2EE v2 inbound buffer overflow') - } finally { - ctx.channel.dispose() - releaseBinary(binary) - await Promise.all(pending) - } - }) - it('queues outbound text and binary in one counter order', async () => { const ctx = setup(async () => null) await authenticate(ctx) @@ -256,61 +194,15 @@ describe('mobile E2EE v2 physical channel', () => { ).toEqual(new Uint8Array([2])) }) - it('releases sent-frame reservations only after the matching response arrives', async () => { - const ctx = setup(async () => null) - await authenticate(ctx) - expect(ctx.outboundMemoryBudget.evidence().inFlightBytes).toBe(0) - - expect(ctx.channel.sendText(JSON.stringify({ id: 'rpc-1', method: 'status.get' }))).toBe(true) - expect(ctx.outboundMemoryBudget.evidence().inFlightClaimCount).toBe(1) - - const response = serverFrame( - new TextEncoder().encode(JSON.stringify({ id: 'rpc-1', ok: true, result: {} })), - 'text', - 1n, - ctx.schedule - ) - await ctx.channel.handleMessage(Buffer.from(response).toString('base64')) - - expect(ctx.outboundMemoryBudget.evidence()).toMatchObject({ - inFlightBytes: 0, - inFlightClaimCount: 0 - }) - ctx.channel.socketClosed() - }) - - it('reports synchronous native send failure to relay callers', async () => { - const ctx = setup(async () => null) - await authenticate(ctx) - ctx.socket.send = () => { - throw new Error('native send failed') - } - - expect(ctx.channel.sendText(JSON.stringify({ id: 'rpc-1' }))).toBe(false) - expect(ctx.onError).toHaveBeenCalledWith( - expect.objectContaining({ message: 'E2EE v2 outbound buffer overflow' }) - ) - expect(ctx.outboundMemoryBudget.evidence()).toMatchObject({ - inFlightBytes: 0, - inFlightClaimCount: 0 - }) - ctx.channel.socketClosed() - }) - it('bounds the unified outbound queue and reports a wedged link', async () => { const ctx = setup(async () => null) await authenticate(ctx) ctx.socket.bufferedAmount = 9 * 1024 * 1024 const megabyte = new Uint8Array(1024 * 1024) for (let index = 0; index < 65; index++) { - ctx.channel.sendBinary(megabyte) + expect(ctx.channel.sendBinary(megabyte)).toBe(true) } expect(ctx.onError).toHaveBeenCalledOnce() expect(ctx.onError.mock.calls[0]![0].message).toBe('E2EE v2 outbound buffer overflow') - expect(ctx.outboundMemoryBudget.evidence().bufferedSourceCount).toBe(1) - ctx.channel.dispose() - expect(ctx.outboundMemoryBudget.evidence().bufferedSourceCount).toBe(1) - ctx.channel.socketClosed() - expect(ctx.outboundMemoryBudget.evidence().bufferedSourceCount).toBe(0) }) }) diff --git a/mobile/src/transport/mobile-e2ee-v2-physical-channel.ts b/mobile/src/transport/mobile-e2ee-v2-physical-channel.ts index 54ac0e398d0..ec3b8088915 100644 --- a/mobile/src/transport/mobile-e2ee-v2-physical-channel.ts +++ b/mobile/src/transport/mobile-e2ee-v2-physical-channel.ts @@ -2,28 +2,10 @@ import { createWsOutboundBackpressureQueue, type WsOutboundBackpressureQueue } from '../../../src/shared/ws-outbound-backpressure-queue' -import { getUtf8ByteLength } from '../../../src/shared/utf8-byte-limits' import type { MobileE2EEV2ClientSession } from './mobile-e2ee-v2-client-session' -import { - createMobileInboundFrameQueue, - MOBILE_INBOUND_MAX_FRAME_BYTES, - type MobileInboundFrameQueue -} from './mobile-inbound-frame-queue' -import { - processMobileOutboundMemoryBudget, - MOBILE_OUTBOUND_MAX_FRAME_BYTES, - type MobileOutboundMemoryBudget -} from './mobile-outbound-memory-budget' -import { stringifyMobileOutboundJson } from './mobile-outbound-json' -import { createMobileOutboundSocketLedger } from './mobile-outbound-socket-ledger' -import { parseMobileJsonTextWithinLimits } from './mobile-json-text-admission' type ChannelState = 'awaiting-ready' | 'awaiting-authenticated' | 'ready' -type OutboundItem = - | { kind: 'text'; plaintext: string; acknowledgementKey?: string } - | { kind: 'binary'; plaintext: Uint8Array } -const E2EE_V2_FRAME_OVERHEAD_BYTES = 82 -const AUTHENTICATION_ACKNOWLEDGEMENT_KEY = 'mobile-e2ee-v2-authentication' +type OutboundItem = { kind: 'text'; plaintext: string } | { kind: 'binary'; plaintext: Uint8Array } export class MobileE2EEAuthenticationError extends Error { constructor() { @@ -41,10 +23,8 @@ export type MobileE2EEV2Socket = { export class MobileE2EEV2PhysicalChannel { private state: ChannelState = 'awaiting-ready' private generation = 0 - private readonly inboundQueue: MobileInboundFrameQueue + private inboundChain: Promise = Promise.resolve() private readonly outboundQueue: WsOutboundBackpressureQueue - private readonly outboundSocketLedger - private outboundOverflowed = false constructor( private readonly args: { @@ -56,50 +36,25 @@ export class MobileE2EEV2PhysicalChannel { onText: (plaintext: string) => void onBinary: (plaintext: Uint8Array) => void onError: (error: Error) => void - outboundMemoryBudget?: MobileOutboundMemoryBudget } ) { - this.inboundQueue = createMobileInboundFrameQueue({ - process: (raw) => this.processMessage(raw, this.generation), - onError: args.onError, - overflowMessage: 'E2EE v2 inbound buffer overflow', - frameTooLargeMessage: 'E2EE v2 inbound frame too large' - }) - const outboundMemoryBudget = args.outboundMemoryBudget ?? processMobileOutboundMemoryBudget - this.outboundSocketLedger = createMobileOutboundSocketLedger({ - memoryBudget: outboundMemoryBudget, - readBufferedAmount: () => args.socket.bufferedAmount - }) this.outboundQueue = createWsOutboundBackpressureQueue({ // Why: encryption happens only when an admitted item reaches the wire, // so a bounded-queue rejection cannot burn an ordered v2 counter. send: (item) => { - const cancelClaim = this.outboundSocketLedger.claimSentBytes( - outboundItemRetainedBytes(item), - item.kind === 'text' ? item.acknowledgementKey : undefined + args.socket.send( + item.kind === 'text' + ? args.session.sealText(item.plaintext) + : args.session.sealBinary(item.plaintext) ) - if (!cancelClaim) { - this.failOutboundOverflow() - return - } - try { - args.socket.send( - item.kind === 'text' - ? args.session.sealText(item.plaintext) - : args.session.sealBinary(item.plaintext) - ) - } catch { - cancelClaim() - this.failOutboundOverflow() - } }, - byteLengthOf: outboundItemRetainedBytes, + byteLengthOf: (item) => + (item.kind === 'text' + ? new TextEncoder().encode(item.plaintext).length + : item.plaintext.length) + 82, getBufferedAmount: () => args.socket.bufferedAmount, isWritable: () => args.socket.readyState === args.socket.OPEN, - canSend: (bytes) => this.outboundSocketLedger.canSend(bytes), - claimQueuedBytes: (bytes) => outboundMemoryBudget.claimQueuedBytes(bytes), - maxFrameBytes: MOBILE_OUTBOUND_MAX_FRAME_BYTES, - onOverflow: () => this.failOutboundOverflow() + onOverflow: () => args.onError(new Error('E2EE v2 outbound buffer overflow')) }) } @@ -108,15 +63,19 @@ export class MobileE2EEV2PhysicalChannel { } handleMessage(raw: unknown): Promise { - return this.inboundQueue.enqueue(raw) + const generation = this.generation + this.inboundChain = this.inboundChain + .then(() => this.processMessage(raw, generation)) + .catch((error: unknown) => { + if (generation === this.generation) { + this.args.onError(error instanceof Error ? error : new Error(String(error))) + } + }) + return this.inboundChain } sendText(plaintext: string): boolean { - return this.enqueueReady({ - kind: 'text', - plaintext, - acknowledgementKey: outboundTextAcknowledgementKey(plaintext) - }) + return this.enqueueReady({ kind: 'text', plaintext }) } sendBinary(plaintext: Uint8Array): boolean { @@ -125,14 +84,7 @@ export class MobileE2EEV2PhysicalChannel { dispose(): void { this.generation++ - this.inboundQueue.dispose() this.outboundQueue.dispose() - this.outboundSocketLedger.retire() - } - - socketClosed(): void { - this.dispose() - this.outboundSocketLedger.socketClosed() } private async processMessage(raw: unknown, generation: number): Promise { @@ -152,7 +104,6 @@ export class MobileE2EEV2PhysicalChannel { return } if (this.state === 'awaiting-authenticated') { - this.outboundSocketLedger.acknowledge(AUTHENTICATION_ACKNOWLEDGEMENT_KEY) if (typeof plaintext === 'string' && isAuthenticationRejection(plaintext)) { throw new MobileE2EEAuthenticationError() } @@ -162,10 +113,6 @@ export class MobileE2EEV2PhysicalChannel { this.state = 'ready' this.args.onAuthenticated() } else if (typeof plaintext === 'string') { - const acknowledgementKey = outboundTextAcknowledgementKey(plaintext) - if (acknowledgementKey) { - this.outboundSocketLedger.acknowledge(acknowledgementKey) - } this.args.onText(plaintext) } else { this.args.onBinary(plaintext) @@ -178,7 +125,7 @@ export class MobileE2EEV2PhysicalChannel { } let ready: unknown try { - ready = parseMobileJsonTextWithinLimits(raw) + ready = JSON.parse(raw) } catch { throw new Error('Invalid E2EE v2 ready JSON') } @@ -188,8 +135,7 @@ export class MobileE2EEV2PhysicalChannel { this.state = 'awaiting-authenticated' this.outboundQueue.enqueue({ kind: 'text', - acknowledgementKey: AUTHENTICATION_ACKNOWLEDGEMENT_KEY, - plaintext: stringifyMobileOutboundJson({ + plaintext: JSON.stringify({ type: 'e2ee_auth', v: 2, transcriptHashB64: this.args.session.transcriptHashB64, @@ -203,15 +149,12 @@ export class MobileE2EEV2PhysicalChannel { if (!bytes || generation !== this.generation) { return null } - if (bytes.byteLength > MOBILE_INBOUND_MAX_FRAME_BYTES) { - throw new Error('E2EE v2 inbound frame too large') - } return this.args.session.openBinary(bytes) } private isAuthenticated(plaintext: string): boolean { try { - const message = parseMobileJsonTextWithinLimits>(plaintext) + const message = JSON.parse(plaintext) as Record return ( Object.keys(message).sort().join(',') === 'transcriptHashB64,type,v' && message.type === 'e2ee_authenticated' && @@ -227,41 +170,16 @@ export class MobileE2EEV2PhysicalChannel { if (this.state !== 'ready') { return false } - return this.outboundQueue.enqueue(item) && !this.outboundOverflowed - } - - private failOutboundOverflow(): void { - if (this.outboundOverflowed) { - return - } - this.outboundOverflowed = true - this.args.onError(new Error('E2EE v2 outbound buffer overflow')) + this.outboundQueue.enqueue(item) + return true } } function isAuthenticationRejection(plaintext: string): boolean { try { - const message = parseMobileJsonTextWithinLimits>(plaintext) + const message = JSON.parse(plaintext) as Record return message.type === 'e2ee_error' } catch { return false } } - -function outboundItemRetainedBytes(item: OutboundItem): number { - if (item.kind === 'binary') { - return item.plaintext.byteLength + E2EE_V2_FRAME_OVERHEAD_BYTES - } - const encryptedBytes = getUtf8ByteLength(item.plaintext) + E2EE_V2_FRAME_OVERHEAD_BYTES - const wireBytes = Math.ceil(encryptedBytes / 3) * 4 - return Math.max(wireBytes, item.plaintext.length * 2) -} - -function outboundTextAcknowledgementKey(plaintext: string): string | undefined { - try { - const value = parseMobileJsonTextWithinLimits<{ id?: unknown }>(plaintext) - return typeof value.id === 'string' ? value.id : undefined - } catch { - return undefined - } -} diff --git a/mobile/src/transport/mobile-host-list-storage.ts b/mobile/src/transport/mobile-host-list-storage.ts deleted file mode 100644 index 76ccd6fddd2..00000000000 --- a/mobile/src/transport/mobile-host-list-storage.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { StoredHostProfileSchema, type StoredHostProfile } from './types' -import { parseMobileJsonTextWithinLimits } from './mobile-json-text-admission' - -export const STORED_HOSTS_MAX_ENTRIES = 256 -export const STORED_HOSTS_MAX_STORAGE_CHARACTERS = 8 * 1024 * 1024 - -export function parseMobileStoredHostList(raw: string | null): StoredHostProfile[] | null { - if (!raw) { - return [] - } - if (raw.length > STORED_HOSTS_MAX_STORAGE_CHARACTERS) { - return null - } - try { - const parsed = parseMobileJsonTextWithinLimits(raw) - if (!Array.isArray(parsed) || parsed.length > STORED_HOSTS_MAX_ENTRIES) { - return null - } - return parsed.flatMap((item) => { - // Why: pre-v0.0.3 records stored deviceToken in AsyncStorage; dropping - // them keeps bearer material out of the metadata migration path. - if (item && typeof item === 'object' && 'deviceToken' in item) { - return [] - } - const result = StoredHostProfileSchema.safeParse(item) - return result.success ? [result.data] : [] - }) - } catch { - return null - } -} - -export function serializeMobileStoredHostList(hosts: StoredHostProfile[]): string { - if (hosts.length > STORED_HOSTS_MAX_ENTRIES) { - throw new Error('host list storage limit exceeded') - } - const validated = hosts.map((host) => StoredHostProfileSchema.parse(host)) - const serialized = JSON.stringify(validated) - if (serialized.length > STORED_HOSTS_MAX_STORAGE_CHARACTERS) { - throw new Error('host list storage limit exceeded') - } - return serialized -} diff --git a/mobile/src/transport/mobile-inbound-frame-queue-memory.test.ts b/mobile/src/transport/mobile-inbound-frame-queue-memory.test.ts deleted file mode 100644 index fe722ae8359..00000000000 --- a/mobile/src/transport/mobile-inbound-frame-queue-memory.test.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { createMobileInboundFrameQueue } from './mobile-inbound-frame-queue' -import { createMobileInboundMemoryLedger } from './mobile-inbound-memory-ledger' - -function expectEmpty(ledger: ReturnType): void { - expect(ledger.evidence()).toMatchObject({ claimCount: 0, retainedBytes: 0 }) -} - -describe('mobile inbound frame queue aggregate memory', () => { - it('releases a claim after synchronous processing succeeds', async () => { - const ledger = createMobileInboundMemoryLedger(10) - const queue = createMobileInboundFrameQueue({ - process: vi.fn(), - onError: vi.fn(), - overflowMessage: 'overflow', - frameTooLargeMessage: 'too large', - memoryLedger: ledger - }) - - await queue.enqueue(new Uint8Array(3)) - - expectEmpty(ledger) - }) - - it('releases a claim after synchronous processing throws', async () => { - const ledger = createMobileInboundMemoryLedger(10) - const onError = vi.fn() - const queue = createMobileInboundFrameQueue({ - process: () => { - throw new Error('processing failed') - }, - onError, - overflowMessage: 'overflow', - frameTooLargeMessage: 'too large', - memoryLedger: ledger - }) - - await queue.enqueue(new Uint8Array(3)) - - expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: 'processing failed' })) - expectEmpty(ledger) - }) - - it('rejects a frame when another physical queue owns the remaining process credit', async () => { - const ledger = createMobileInboundMemoryLedger(5) - let releaseFirst!: () => void - const first = createMobileInboundFrameQueue({ - process: () => new Promise((resolve) => (releaseFirst = resolve)), - onError: vi.fn(), - overflowMessage: 'first overflow', - frameTooLargeMessage: 'too large', - memoryLedger: ledger - }) - const secondProcess = vi.fn() - const secondError = vi.fn() - const second = createMobileInboundFrameQueue({ - process: secondProcess, - onError: secondError, - overflowMessage: 'aggregate overflow', - frameTooLargeMessage: 'too large', - memoryLedger: ledger - }) - - const firstPending = first.enqueue(new Uint8Array(3)) - await second.enqueue(new Uint8Array(3)) - - expect(secondProcess).not.toHaveBeenCalled() - expect(secondError).toHaveBeenCalledWith( - expect.objectContaining({ message: 'aggregate overflow' }) - ) - expect(ledger.evidence()).toMatchObject({ claimCount: 1, retainedBytes: 3 }) - releaseFirst() - await firstPending - expectEmpty(ledger) - }) - - it('releases current and queued claims after processing rejects', async () => { - const ledger = createMobileInboundMemoryLedger(10) - let rejectCurrent!: (error: Error) => void - const onError = vi.fn() - const queue = createMobileInboundFrameQueue({ - process: () => new Promise((_resolve, reject) => (rejectCurrent = reject)), - onError, - overflowMessage: 'overflow', - frameTooLargeMessage: 'too large', - memoryLedger: ledger - }) - const first = queue.enqueue(new Uint8Array(2)) - const second = queue.enqueue(new Uint8Array(3)) - expect(ledger.evidence()).toMatchObject({ claimCount: 2, retainedBytes: 5 }) - - rejectCurrent(new Error('processing failed')) - await Promise.all([first, second]) - - expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: 'processing failed' })) - expectEmpty(ledger) - }) - - it('releases queued claims on dispose and the active claim when processing settles', async () => { - const ledger = createMobileInboundMemoryLedger(10) - let releaseCurrent!: () => void - const queue = createMobileInboundFrameQueue({ - process: () => new Promise((resolve) => (releaseCurrent = resolve)), - onError: vi.fn(), - overflowMessage: 'overflow', - frameTooLargeMessage: 'too large', - memoryLedger: ledger - }) - const first = queue.enqueue(new Uint8Array(2)) - const second = queue.enqueue(new Uint8Array(3)) - - queue.dispose() - expect(ledger.evidence()).toMatchObject({ claimCount: 1, retainedBytes: 2 }) - releaseCurrent() - await Promise.all([first, second]) - expectEmpty(ledger) - }) - - it('releases every claim after per-queue overflow', async () => { - const ledger = createMobileInboundMemoryLedger(10) - let releaseCurrent!: () => void - const queue = createMobileInboundFrameQueue({ - process: () => new Promise((resolve) => (releaseCurrent = resolve)), - onError: vi.fn(), - overflowMessage: 'overflow', - frameTooLargeMessage: 'too large', - maxBufferedBytes: 4, - memoryLedger: ledger - }) - const first = queue.enqueue(new Uint8Array(3)) - const rejected = queue.enqueue(new Uint8Array(2)) - - expect(ledger.evidence()).toMatchObject({ claimCount: 1, retainedBytes: 3 }) - releaseCurrent() - await Promise.all([first, rejected]) - expectEmpty(ledger) - }) - - it('does not claim process memory for an individually oversized frame', async () => { - const ledger = createMobileInboundMemoryLedger(10) - const queue = createMobileInboundFrameQueue({ - process: vi.fn(), - onError: vi.fn(), - overflowMessage: 'overflow', - frameTooLargeMessage: 'too large', - maxFrameBytes: 2, - memoryLedger: ledger - }) - - await queue.enqueue(new Uint8Array(3)) - - expectEmpty(ledger) - }) -}) diff --git a/mobile/src/transport/mobile-inbound-frame-queue.test.ts b/mobile/src/transport/mobile-inbound-frame-queue.test.ts deleted file mode 100644 index 1a006a9d094..00000000000 --- a/mobile/src/transport/mobile-inbound-frame-queue.test.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { createMobileInboundFrameQueue } from './mobile-inbound-frame-queue' - -describe('mobile inbound frame queue', () => { - it('serializes async processing without losing frame order', async () => { - let release!: () => void - const gate = new Promise((resolve) => (release = resolve)) - const processed: string[] = [] - const queue = createMobileInboundFrameQueue({ - process: async (raw) => { - if (raw === 'first') { - await gate - } - processed.push(String(raw)) - }, - onError: vi.fn(), - overflowMessage: 'overflow', - frameTooLargeMessage: 'too large' - }) - - const first = queue.enqueue('first') - const second = queue.enqueue('second') - expect(processed).toEqual([]) - release() - await Promise.all([first, second]) - expect(processed).toEqual(['first', 'second']) - }) - - it('does not retain processed frame slots while a steady stream keeps the queue busy', async () => { - let releaseCurrent!: () => void - const queue = createMobileInboundFrameQueue({ - process: () => new Promise((resolve) => (releaseCurrent = resolve)), - onError: vi.fn(), - overflowMessage: 'overflow', - frameTooLargeMessage: 'too large' - }) - let current = queue.enqueue({ byteLength: 1, id: 0 }) - let next = queue.enqueue({ byteLength: 1, id: 1 }) - - for (let id = 2; id < 256; id += 1) { - releaseCurrent() - await current - current = next - next = queue.enqueue({ byteLength: 1, id }) - expect(queue.evidence().storageSlots).toBeLessThanOrEqual(64) - } - - releaseCurrent() - await current - releaseCurrent() - await next - expect(queue.evidence()).toEqual({ retainedBytes: 0, retainedFrames: 0, storageSlots: 0 }) - }) - - it('drops and settles the backlog when retained frame count reaches its cap', async () => { - let release!: () => void - const gate = new Promise((resolve) => (release = resolve)) - const onError = vi.fn() - const queue = createMobileInboundFrameQueue({ - process: async () => gate, - onError, - overflowMessage: 'overflow', - frameTooLargeMessage: 'too large', - maxBufferedFrames: 2, - maxBufferedBytes: 100, - maxFrameBytes: 100 - }) - - const pending = [ - queue.enqueue(new Uint8Array(1)), - queue.enqueue(new Uint8Array(1)), - queue.enqueue(new Uint8Array(1)) - ] - expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: 'overflow' })) - release() - await Promise.all(pending) - }) - - it('fails closed when aggregate retained bytes exceed their cap', async () => { - let release!: () => void - const gate = new Promise((resolve) => (release = resolve)) - const onError = vi.fn() - const queue = createMobileInboundFrameQueue({ - process: async () => gate, - onError, - overflowMessage: 'overflow', - frameTooLargeMessage: 'too large', - maxBufferedFrames: 10, - maxBufferedBytes: 5, - maxFrameBytes: 4 - }) - - const first = queue.enqueue(new Uint8Array(3)) - const second = queue.enqueue(new Uint8Array(3)) - expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: 'overflow' })) - release() - await Promise.all([first, second]) - }) - - it('rejects a single known oversized frame without invoking its processor', async () => { - const process = vi.fn() - const onError = vi.fn() - const queue = createMobileInboundFrameQueue({ - process, - onError, - overflowMessage: 'overflow', - frameTooLargeMessage: 'too large', - maxFrameBytes: 4 - }) - - await queue.enqueue(new Uint8Array(5)) - - expect(process).not.toHaveBeenCalled() - expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: 'too large' })) - }) -}) diff --git a/mobile/src/transport/mobile-inbound-frame-queue.ts b/mobile/src/transport/mobile-inbound-frame-queue.ts deleted file mode 100644 index 4396d0f4832..00000000000 --- a/mobile/src/transport/mobile-inbound-frame-queue.ts +++ /dev/null @@ -1,209 +0,0 @@ -import { - processMobileInboundMemoryLedger, - type MobileInboundMemoryLedger -} from './mobile-inbound-memory-ledger' - -// Why: a valid 10 MiB image preview expands twice through nested base64 framing to ~36 MiB of JS text. -export const MOBILE_INBOUND_MAX_FRAME_BYTES = 64 * 1024 * 1024 -// Why: two valid large previews may overlap, but a stalled consumer cannot retain a third. -export const MOBILE_INBOUND_MAX_BUFFERED_BYTES = 96 * 1024 * 1024 -// Why: tiny-frame floods need a count bound independent of their serialized size. -export const MOBILE_INBOUND_MAX_BUFFERED_FRAMES = 64 - -type PendingFrame = { - raw: unknown - releaseMemory: () => void - retainedBytes: number - settle: () => void -} - -export type MobileInboundFrameQueueEvidence = { - retainedBytes: number - retainedFrames: number - storageSlots: number -} - -export type MobileInboundFrameQueue = { - enqueue(raw: unknown): Promise - dispose(): void - evidence(): MobileInboundFrameQueueEvidence -} - -const QUEUE_COMPACTION_HEAD_THRESHOLD = 64 - -export function createMobileInboundFrameQueue(options: { - process: (raw: unknown) => Promise | void - onError: (error: Error) => void - overflowMessage: string - frameTooLargeMessage: string - maxFrameBytes?: number - maxBufferedBytes?: number - maxBufferedFrames?: number - memoryLedger?: MobileInboundMemoryLedger -}): MobileInboundFrameQueue { - const maxFrameBytes = options.maxFrameBytes ?? MOBILE_INBOUND_MAX_FRAME_BYTES - const maxBufferedBytes = options.maxBufferedBytes ?? MOBILE_INBOUND_MAX_BUFFERED_BYTES - const maxBufferedFrames = options.maxBufferedFrames ?? MOBILE_INBOUND_MAX_BUFFERED_FRAMES - const memoryLedger = options.memoryLedger ?? processMobileInboundMemoryLedger - const queue: Array = [] - let queueHead = 0 - let retainedBytes = 0 - let retainedFrames = 0 - let draining = false - let stopped = false - - const release = (frame: PendingFrame): void => { - retainedBytes -= frame.retainedBytes - retainedFrames -= 1 - frame.releaseMemory() - frame.settle() - } - - const dropQueued = (): void => { - while (queueHead < queue.length) { - release(queue[queueHead++]!) - } - queue.length = 0 - queueHead = 0 - } - - const fail = (error: Error): void => { - if (stopped) { - return - } - stopped = true - dropQueued() - options.onError(error) - } - - const finishDrain = (): void => { - if (queueHead === queue.length) { - queue.length = 0 - queueHead = 0 - } - draining = false - } - - const continueDrain = (): void => { - while (!stopped && queueHead < queue.length) { - const frame = queue[queueHead++]! - queue[queueHead - 1] = undefined - if (queueHead >= QUEUE_COMPACTION_HEAD_THRESHOLD) { - queue.splice(0, queueHead) - queueHead = 0 - } - let processing: Promise | void - try { - processing = options.process(frame.raw) - } catch (error) { - release(frame) - fail(error instanceof Error ? error : new Error(String(error))) - finishDrain() - return - } - if (processing) { - void processing.then( - () => { - release(frame) - if (stopped) { - finishDrain() - return - } - continueDrain() - }, - (error: unknown) => { - release(frame) - fail(error instanceof Error ? error : new Error(String(error))) - finishDrain() - } - ) - return - } - release(frame) - } - finishDrain() - } - - const drain = (): void => { - if (draining || stopped) { - return - } - draining = true - continueDrain() - } - - return { - enqueue(raw): Promise { - if (stopped) { - return Promise.resolve() - } - const frameBytes = mobileInboundFrameRetainedBytes(raw, maxFrameBytes) - if (frameBytes > maxFrameBytes) { - fail(new Error(options.frameTooLargeMessage)) - return Promise.resolve() - } - if (retainedFrames >= maxBufferedFrames || retainedBytes + frameBytes > maxBufferedBytes) { - fail(new Error(options.overflowMessage)) - return Promise.resolve() - } - const releaseMemory = memoryLedger.claim(frameBytes) - if (!releaseMemory) { - fail(new Error(options.overflowMessage)) - return Promise.resolve() - } - return new Promise((settle) => { - queue.push({ raw, releaseMemory, retainedBytes: frameBytes, settle }) - retainedBytes += frameBytes - retainedFrames += 1 - drain() - }) - }, - dispose(): void { - if (stopped) { - return - } - stopped = true - dropQueued() - }, - evidence(): MobileInboundFrameQueueEvidence { - return { retainedBytes, retainedFrames, storageSlots: queue.length } - } - } -} - -export function assertMobileInboundFrameSize( - raw: unknown, - message: string, - maxFrameBytes = MOBILE_INBOUND_MAX_FRAME_BYTES -): void { - if (mobileInboundFrameRetainedBytes(raw, maxFrameBytes) > maxFrameBytes) { - throw new Error(message) - } -} - -function mobileInboundFrameRetainedBytes(raw: unknown, unknownFrameBytes: number): number { - if (typeof raw === 'string') { - return raw.length * 2 - } - if (raw instanceof ArrayBuffer || ArrayBuffer.isView(raw)) { - return raw.byteLength - } - if (raw && typeof raw === 'object') { - const size = numericProperty(raw, 'size') ?? numericProperty(raw, 'byteLength') - if (size !== null) { - return size - } - } - // Why: unknown RN payload wrappers may materialize their bytes only during async conversion. - return unknownFrameBytes -} - -function numericProperty(value: object, key: 'size' | 'byteLength'): number | null { - if (!(key in value)) { - return null - } - const candidate = (value as Record)[key] - return typeof candidate === 'number' && Number.isFinite(candidate) && candidate >= 0 - ? candidate - : null -} diff --git a/mobile/src/transport/mobile-inbound-memory-ledger.test.ts b/mobile/src/transport/mobile-inbound-memory-ledger.test.ts deleted file mode 100644 index 6ebb9aa535f..00000000000 --- a/mobile/src/transport/mobile-inbound-memory-ledger.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - createMobileInboundMemoryLedger, - MOBILE_PROCESS_INBOUND_MAX_RETAINED_BYTES, - MOBILE_PROCESS_INBOUND_MAX_RETAINED_FRAMES -} from './mobile-inbound-memory-ledger' - -describe('mobile inbound memory ledger', () => { - it('uses a process ceiling that preserves two full ordinary queue budgets', () => { - expect(MOBILE_PROCESS_INBOUND_MAX_RETAINED_BYTES).toBe(192 * 1024 * 1024) - expect(MOBILE_PROCESS_INBOUND_MAX_RETAINED_FRAMES).toBe(256) - }) - - it('makes claims idempotently releasable', () => { - const ledger = createMobileInboundMemoryLedger(5) - const release = ledger.claim(5) - expect(release).not.toBeNull() - expect(ledger.claim(1)).toBeNull() - - release!() - release!() - - expect(ledger.evidence()).toEqual({ - claimCount: 0, - maxRetainedBytes: 5, - maxRetainedFrames: MOBILE_PROCESS_INBOUND_MAX_RETAINED_FRAMES, - retainedBytes: 0 - }) - }) - - it('bounds aggregate zero-byte claims independently of the byte ceiling', () => { - const ledger = createMobileInboundMemoryLedger(5, 2) - const releaseFirst = ledger.claim(0) - const releaseSecond = ledger.claim(0) - - expect(releaseFirst).not.toBeNull() - expect(releaseSecond).not.toBeNull() - expect(ledger.claim(0)).toBeNull() - - releaseFirst!() - releaseSecond!() - }) -}) diff --git a/mobile/src/transport/mobile-inbound-memory-ledger.ts b/mobile/src/transport/mobile-inbound-memory-ledger.ts deleted file mode 100644 index eb92fca6fe5..00000000000 --- a/mobile/src/transport/mobile-inbound-memory-ledger.ts +++ /dev/null @@ -1,58 +0,0 @@ -export const MOBILE_PROCESS_INBOUND_MAX_RETAINED_BYTES = 192 * 1024 * 1024 -export const MOBILE_PROCESS_INBOUND_MAX_RETAINED_FRAMES = 256 - -export type MobileInboundMemoryLedgerEvidence = { - claimCount: number - maxRetainedFrames: number - maxRetainedBytes: number - retainedBytes: number -} - -export type MobileInboundMemoryLedger = { - claim(bytes: number): (() => void) | null - evidence(): MobileInboundMemoryLedgerEvidence -} - -export function createMobileInboundMemoryLedger( - maxRetainedBytes: number, - maxRetainedFrames = MOBILE_PROCESS_INBOUND_MAX_RETAINED_FRAMES -): MobileInboundMemoryLedger { - if ( - !Number.isFinite(maxRetainedBytes) || - maxRetainedBytes < 1 || - !Number.isInteger(maxRetainedFrames) || - maxRetainedFrames < 1 - ) { - throw new Error('Mobile inbound memory limit must be positive') - } - let retainedBytes = 0 - let claimCount = 0 - return { - claim(bytes): (() => void) | null { - if (!Number.isFinite(bytes) || bytes < 0) { - return null - } - if (claimCount >= maxRetainedFrames || retainedBytes + bytes > maxRetainedBytes) { - return null - } - retainedBytes += bytes - claimCount += 1 - let released = false - return () => { - if (released) { - return - } - released = true - retainedBytes -= bytes - claimCount -= 1 - } - }, - evidence(): MobileInboundMemoryLedgerEvidence { - return { claimCount, maxRetainedBytes, maxRetainedFrames, retainedBytes } - } - } -} - -export const processMobileInboundMemoryLedger = createMobileInboundMemoryLedger( - MOBILE_PROCESS_INBOUND_MAX_RETAINED_BYTES -) diff --git a/mobile/src/transport/mobile-json-text-admission.test.ts b/mobile/src/transport/mobile-json-text-admission.test.ts deleted file mode 100644 index 2e8c12be208..00000000000 --- a/mobile/src/transport/mobile-json-text-admission.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - isMobileJsonStructureCapacityError, - parseMobileJsonTextWithinLimits -} from './mobile-json-text-admission' - -describe('mobile JSON text admission', () => { - it('accepts the exact structural-token limit and rejects one more', () => { - const limits = { structuralTokens: 4, nestingDepth: 2 } - - expect(parseMobileJsonTextWithinLimits('[0,0,0]', limits)).toEqual([0, 0, 0]) - expect(() => parseMobileJsonTextWithinLimits('[0,0,0,0]', limits)).toThrow( - 'JSON structure exceeds 4 tokens' - ) - }) - - it('accepts the exact nesting limit and rejects one more', () => { - const limits = { structuralTokens: 20, nestingDepth: 3 } - - expect(parseMobileJsonTextWithinLimits('[[[]]]', limits)).toEqual([[[]]]) - expect(() => parseMobileJsonTextWithinLimits('[[[[]]]]', limits)).toThrow( - 'JSON nesting exceeds 3 levels' - ) - }) - - it('does not count structural characters inside strings', () => { - expect( - parseMobileJsonTextWithinLimits('{"value":"[{\\\":,}]"}', { - structuralTokens: 3, - nestingDepth: 1 - }) - ).toEqual({ value: '[{":,}]' }) - }) - - it('identifies capacity failures separately from malformed JSON', () => { - const capacityError = captureError(() => - parseMobileJsonTextWithinLimits('[0,0]', { - structuralTokens: 2, - nestingDepth: 2 - }) - ) - const syntaxError = captureError(() => - parseMobileJsonTextWithinLimits('[', { - structuralTokens: 2, - nestingDepth: 2 - }) - ) - - expect(isMobileJsonStructureCapacityError(capacityError)).toBe(true) - expect(isMobileJsonStructureCapacityError(syntaxError)).toBe(false) - }) -}) - -function captureError(run: () => void): unknown { - try { - run() - return null - } catch (error) { - return error - } -} diff --git a/mobile/src/transport/mobile-json-text-admission.ts b/mobile/src/transport/mobile-json-text-admission.ts deleted file mode 100644 index 7645cca69c1..00000000000 --- a/mobile/src/transport/mobile-json-text-admission.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { - assertJsonTextStructureWithinLimits, - JsonTextStructureCapacityError, - type JsonTextStructureLimits -} from '../../../src/shared/json-text-structure-limit' - -export const MOBILE_JSON_TEXT_STRUCTURE_LIMITS: JsonTextStructureLimits = { - structuralTokens: 1_000_000, - nestingDepth: 128 -} - -export function parseMobileJsonTextWithinLimits( - content: string, - limits: JsonTextStructureLimits = MOBILE_JSON_TEXT_STRUCTURE_LIMITS -): T { - assertJsonTextStructureWithinLimits(content, limits) - return JSON.parse(content) as T -} - -export function isMobileJsonStructureCapacityError( - error: unknown -): error is JsonTextStructureCapacityError { - return error instanceof JsonTextStructureCapacityError -} - -export function tryParseMobileJsonTextWithinLimits( - content: string, - limits: JsonTextStructureLimits = MOBILE_JSON_TEXT_STRUCTURE_LIMITS -): T | null { - try { - return parseMobileJsonTextWithinLimits(content, limits) - } catch (error) { - if (isMobileJsonStructureCapacityError(error)) { - throw error - } - return null - } -} diff --git a/mobile/src/transport/mobile-outbound-json.test.ts b/mobile/src/transport/mobile-outbound-json.test.ts deleted file mode 100644 index 04d459e4780..00000000000 --- a/mobile/src/transport/mobile-outbound-json.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { stringifyMobileOutboundJson } from './mobile-outbound-json' - -describe('bounded mobile outbound JSON serialization', () => { - it('preserves ordinary JSON values', () => { - const value = { text: 'hello', escaped: '\u0000\n', items: [1, true, null] } - - expect(stringifyMobileOutboundJson(value, 1_000)).toBe(JSON.stringify(value)) - }) - - it('stops before traversing fields after an oversized string', () => { - const readAfter = vi.fn(() => 'late') - const value = { - payload: 'x'.repeat(100), - get after() { - return readAfter() - } - } - - expect(() => stringifyMobileOutboundJson(value, 32)).toThrow( - 'Mobile outbound JSON exceeds 32 bytes' - ) - expect(readAfter).not.toHaveBeenCalled() - }) - - it('accounts for JSON escaping before the native serializer allocates output', () => { - expect(stringifyMobileOutboundJson('\u0000', 8)).toBe('"\\u0000"') - expect(() => stringifyMobileOutboundJson('\u0000', 7)).toThrow( - 'Mobile outbound JSON exceeds 7 bytes' - ) - }) - - it('accepts arrays and objects whose serialized form is exactly at the limit', () => { - expect(stringifyMobileOutboundJson([1, 2], 5)).toBe('[1,2]') - expect(stringifyMobileOutboundJson({ a: 1 }, 7)).toBe('{"a":1}') - expect(() => stringifyMobileOutboundJson([1, 2], 4)).toThrow( - 'Mobile outbound JSON exceeds 4 bytes' - ) - expect(() => stringifyMobileOutboundJson({ a: 1 }, 6)).toThrow( - 'Mobile outbound JSON exceeds 6 bytes' - ) - }) - - it('does not charge object properties omitted by JSON.stringify', () => { - const value = { - omittedUndefined: undefined, - omittedFunction: () => 'ignored', - omittedSymbol: Symbol('ignored'), - kept: 1 - } - - expect(stringifyMobileOutboundJson(value, 10)).toBe('{"kept":1}') - }) - - it('serializes unsupported array elements as null within the exact limit', () => { - const value = [undefined, () => 'ignored', Symbol('ignored')] - - expect(stringifyMobileOutboundJson(value, 16)).toBe('[null,null,null]') - expect(() => stringifyMobileOutboundJson(value, 15)).toThrow( - 'Mobile outbound JSON exceeds 15 bytes' - ) - }) - - it('accounts for boxed JSON primitives without underestimating them', () => { - expect(stringifyMobileOutboundJson(new String('\u0000'), 8)).toBe('"\\u0000"') - expect(stringifyMobileOutboundJson(new Number(12345), 5)).toBe('12345') - expect(stringifyMobileOutboundJson(new Boolean(false), 5)).toBe('false') - expect(() => stringifyMobileOutboundJson(new String('\u0000'), 7)).toThrow( - 'Mobile outbound JSON exceeds 7 bytes' - ) - }) -}) diff --git a/mobile/src/transport/mobile-outbound-json.ts b/mobile/src/transport/mobile-outbound-json.ts deleted file mode 100644 index fc367f985eb..00000000000 --- a/mobile/src/transport/mobile-outbound-json.ts +++ /dev/null @@ -1,123 +0,0 @@ -export const MOBILE_OUTBOUND_MAX_JSON_BYTES = 4 * 1024 * 1024 - -export function stringifyMobileOutboundJson( - value: unknown, - maxBytes = MOBILE_OUTBOUND_MAX_JSON_BYTES -): string { - if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) { - throw new Error('Mobile outbound JSON limit must be a positive safe integer') - } - let estimatedBytes = 0 - let root = true - const emittedMembers = new WeakMap() - const serialized = JSON.stringify(value, function (key, item: unknown) { - const isRoot = root - root = false - const parent = this as object - const inArray = Array.isArray(parent) - if (!isRoot && !inArray && isOmittedObjectValue(item)) { - return item - } - if (!isRoot) { - const emitted = emittedMembers.get(parent) ?? 0 - estimatedBytes += emitted > 0 ? 1 : 0 - if (!inArray) { - estimatedBytes += escapedJsonStringBytes(key) + 1 - } - emittedMembers.set(parent, emitted + 1) - } - estimatedBytes += inArray && isOmittedObjectValue(item) ? 4 : jsonValueBytes(item) - if (estimatedBytes > maxBytes) { - throw new Error(`Mobile outbound JSON exceeds ${maxBytes} bytes`) - } - return item - }) - if (serialized === undefined) { - throw new Error('Mobile outbound value is not JSON serializable') - } - return serialized -} - -function isOmittedObjectValue(value: unknown): boolean { - return value === undefined || typeof value === 'function' || typeof value === 'symbol' -} - -function jsonValueBytes(value: unknown): number { - if (value === null) { - return 4 - } - if (typeof value === 'string') { - return escapedJsonStringBytes(value) - } - if (typeof value === 'boolean') { - return value ? 4 : 5 - } - if (typeof value === 'number') { - return Number.isFinite(value) ? String(value).length : 4 - } - if (typeof value === 'object' && value !== null) { - return boxedPrimitiveJsonBytes(value) ?? 2 - } - return 4 -} - -function boxedPrimitiveJsonBytes(value: object): number | null { - try { - return escapedJsonStringBytes(String.prototype.valueOf.call(value)) - } catch {} - try { - const number = Number.prototype.valueOf.call(value) - return Number.isFinite(number) ? String(number).length : 4 - } catch {} - try { - return Boolean.prototype.valueOf.call(value) ? 4 : 5 - } catch { - return null - } -} - -function escapedJsonStringBytes(value: string): number { - let bytes = 2 - for (let index = 0; index < value.length; index += 1) { - const code = value.charCodeAt(index) - if ( - code === 0x22 || - code === 0x5c || - code === 0x08 || - code === 0x09 || - code === 0x0a || - code === 0x0c || - code === 0x0d - ) { - bytes += 2 - } else if ( - code <= 0x1f || - (code >= 0xd800 && code <= 0xdfff && !isSurrogatePair(value, index)) - ) { - bytes += 6 - } else if (code <= 0x7f) { - bytes += 1 - } else if (code <= 0x7ff) { - bytes += 2 - } else if (code >= 0xd800 && code <= 0xdbff) { - bytes += 4 - index += 1 - } else { - bytes += 3 - } - } - return bytes -} - -function isSurrogatePair(value: string, index: number): boolean { - const code = value.charCodeAt(index) - if (code >= 0xd800 && code <= 0xdbff) { - const next = value.charCodeAt(index + 1) - return next >= 0xdc00 && next <= 0xdfff - } - if (code >= 0xdc00 && code <= 0xdfff) { - const previous = value.charCodeAt(index - 1) - return previous >= 0xd800 && previous <= 0xdbff - } - return false -} diff --git a/mobile/src/transport/mobile-outbound-memory-budget.test.ts b/mobile/src/transport/mobile-outbound-memory-budget.test.ts deleted file mode 100644 index 832e4e9ecbe..00000000000 --- a/mobile/src/transport/mobile-outbound-memory-budget.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - createMobileOutboundMemoryBudget, - MOBILE_OUTBOUND_MAX_FRAME_BYTES, - MOBILE_PROCESS_OUTBOUND_MAX_BUFFERED_BYTES, - MOBILE_PROCESS_OUTBOUND_MAX_IN_FLIGHT_FRAMES, - MOBILE_PROCESS_OUTBOUND_MAX_QUEUED_BYTES, - MOBILE_PROCESS_OUTBOUND_MAX_QUEUED_FRAMES, - MOBILE_PROCESS_OUTBOUND_MAX_SOCKET_SOURCES -} from './mobile-outbound-memory-budget' - -describe('mobile outbound memory budget', () => { - it('keeps ordinary two-socket bursts within explicit process ceilings', () => { - expect(MOBILE_PROCESS_OUTBOUND_MAX_BUFFERED_BYTES).toBe(16 * 1024 * 1024) - expect(MOBILE_PROCESS_OUTBOUND_MAX_IN_FLIGHT_FRAMES).toBe(16_384) - expect(MOBILE_PROCESS_OUTBOUND_MAX_QUEUED_BYTES).toBe(128 * 1024 * 1024) - expect(MOBILE_PROCESS_OUTBOUND_MAX_QUEUED_FRAMES).toBe(16_384) - expect(MOBILE_PROCESS_OUTBOUND_MAX_SOCKET_SOURCES).toBe(64) - expect(MOBILE_OUTBOUND_MAX_FRAME_BYTES).toBe(8 * 1024 * 1024) - }) - - it('sums native bufferedAmount across registered physical sockets', () => { - const budget = createMobileOutboundMemoryBudget({ maxBufferedBytes: 10 }) - let first = 4 - const firstSource = budget.registerBufferedAmount(() => first) - const secondSource = budget.registerBufferedAmount(() => 6) - expect(firstSource.canSend(0)).toBe(true) - - first = 5 - expect(firstSource.canSend(0)).toBe(false) - secondSource.release() - firstSource.release() - expect(budget.evidence()).toMatchObject({ bufferedBytes: 0, bufferedSourceCount: 0 }) - }) - - it('reserves room for the prospective native frame before allowing a send', () => { - const budget = createMobileOutboundMemoryBudget({ maxBufferedBytes: 10 }) - const source = budget.registerBufferedAmount(() => 6) - - expect(source.canSend(4)).toBe(true) - expect(source.canSend(5)).toBe(false) - }) - - it('counts unacknowledged frames when React Native does not expose bufferedAmount', () => { - const budget = createMobileOutboundMemoryBudget({ maxBufferedBytes: 10 }) - const source = budget.registerBufferedAmount(() => Number.NaN) - const release = source.claimInFlightBytes(7) - - expect(release).not.toBeNull() - expect(source.canSend(4)).toBe(false) - expect(budget.evidence()).toMatchObject({ - bufferedBytes: 7, - inFlightBytes: 7, - inFlightClaimCount: 1 - }) - - release!() - expect(source.canSend(10)).toBe(true) - }) - - it('caps sent-frame claims and retired socket readers independently', () => { - const budget = createMobileOutboundMemoryBudget({ - maxBufferedBytes: 10, - maxBufferedSources: 1, - maxInFlightFrames: 1 - }) - const source = budget.registerBufferedAmount(() => 0) - const release = source.claimInFlightBytes(0) - - expect(release).not.toBeNull() - expect(source.claimInFlightBytes(0)).toBeNull() - expect(budget.canRegisterBufferedAmount()).toBe(false) - expect(() => budget.registerBufferedAmount(() => 0)).toThrow( - 'Mobile outbound socket tracking limit exceeded' - ) - - release!() - source.release() - expect(budget.canRegisterBufferedAmount()).toBe(true) - }) - - it('bounds aggregate JavaScript backlog and releases claims idempotently', () => { - const budget = createMobileOutboundMemoryBudget({ maxQueuedBytes: 5 }) - const release = budget.claimQueuedBytes(5) - expect(release).not.toBeNull() - expect(budget.claimQueuedBytes(1)).toBeNull() - - release!() - release!() - - expect(budget.evidence()).toMatchObject({ queuedBytes: 0, queuedClaimCount: 0 }) - }) - - it('bounds aggregate zero-byte claims independently of the byte ceiling', () => { - const budget = createMobileOutboundMemoryBudget({ maxQueuedBytes: 5, maxQueuedFrames: 2 }) - const releaseFirst = budget.claimQueuedBytes(0) - const releaseSecond = budget.claimQueuedBytes(0) - - expect(releaseFirst).not.toBeNull() - expect(releaseSecond).not.toBeNull() - expect(budget.claimQueuedBytes(0)).toBeNull() - - releaseFirst!() - releaseSecond!() - }) -}) diff --git a/mobile/src/transport/mobile-outbound-memory-budget.ts b/mobile/src/transport/mobile-outbound-memory-budget.ts deleted file mode 100644 index 2b94690a0c6..00000000000 --- a/mobile/src/transport/mobile-outbound-memory-budget.ts +++ /dev/null @@ -1,188 +0,0 @@ -export const MOBILE_PROCESS_OUTBOUND_MAX_BUFFERED_BYTES = 16 * 1024 * 1024 -export const MOBILE_PROCESS_OUTBOUND_MAX_QUEUED_BYTES = 128 * 1024 * 1024 -export const MOBILE_PROCESS_OUTBOUND_MAX_QUEUED_FRAMES = 16_384 -export const MOBILE_PROCESS_OUTBOUND_MAX_IN_FLIGHT_FRAMES = 16_384 -export const MOBILE_PROCESS_OUTBOUND_MAX_SOCKET_SOURCES = 64 -export const MOBILE_OUTBOUND_MAX_FRAME_BYTES = 8 * 1024 * 1024 - -export type MobileOutboundMemoryBudgetEvidence = { - bufferedBytes: number - bufferedSourceCount: number - inFlightBytes: number - inFlightClaimCount: number - queuedBytes: number - queuedClaimCount: number -} - -export type MobileOutboundSocketMemory = { - canSend(bytes: number): boolean - claimInFlightBytes(bytes: number): (() => void) | null - release(): void -} - -export type MobileOutboundMemoryBudget = { - canRegisterBufferedAmount(): boolean - claimQueuedBytes(bytes: number): (() => void) | null - evidence(): MobileOutboundMemoryBudgetEvidence - registerBufferedAmount(readBufferedAmount: () => number): MobileOutboundSocketMemory -} - -export function createMobileOutboundMemoryBudget(options?: { - maxBufferedBytes?: number - maxBufferedSources?: number - maxInFlightFrames?: number - maxQueuedBytes?: number - maxQueuedFrames?: number -}): MobileOutboundMemoryBudget { - const maxBufferedBytes = options?.maxBufferedBytes ?? MOBILE_PROCESS_OUTBOUND_MAX_BUFFERED_BYTES - const maxBufferedSources = - options?.maxBufferedSources ?? MOBILE_PROCESS_OUTBOUND_MAX_SOCKET_SOURCES - const maxInFlightFrames = - options?.maxInFlightFrames ?? MOBILE_PROCESS_OUTBOUND_MAX_IN_FLIGHT_FRAMES - const maxQueuedBytes = options?.maxQueuedBytes ?? MOBILE_PROCESS_OUTBOUND_MAX_QUEUED_BYTES - const maxQueuedFrames = options?.maxQueuedFrames ?? MOBILE_PROCESS_OUTBOUND_MAX_QUEUED_FRAMES - if ( - !Number.isFinite(maxBufferedBytes) || - maxBufferedBytes < 1 || - !Number.isInteger(maxBufferedSources) || - maxBufferedSources < 1 || - !Number.isInteger(maxInFlightFrames) || - maxInFlightFrames < 1 || - !Number.isFinite(maxQueuedBytes) || - maxQueuedBytes < 1 || - !Number.isInteger(maxQueuedFrames) || - maxQueuedFrames < 1 - ) { - throw new Error('Mobile outbound memory limits must be positive') - } - type BufferedSource = { - inFlightBytes: number - inFlightClaimCount: number - readBufferedAmount: () => number - } - const bufferedSources = new Set() - let queuedBytes = 0 - let queuedClaimCount = 0 - - const bufferedBytes = (): number => { - let total = 0 - for (const source of bufferedSources) { - let observed = 0 - try { - const value = source.readBufferedAmount() - if (Number.isFinite(value) && value > 0) { - observed = value - } - } catch { - // Closed native sockets may reject late reads; their close path unregisters them. - } - // Some runtimes update bufferedAmount asynchronously, so unacknowledged - // claims remain additive instead of assuming the native value includes them. - total += observed + source.inFlightBytes - } - return total - } - - const inFlightClaimCount = (): number => { - let count = 0 - for (const source of bufferedSources) { - count += source.inFlightClaimCount - } - return count - } - - const inFlightBytes = (): number => { - let total = 0 - for (const source of bufferedSources) { - total += source.inFlightBytes - } - return total - } - - return { - canRegisterBufferedAmount: () => bufferedSources.size < maxBufferedSources, - claimQueuedBytes(bytes): (() => void) | null { - if ( - !Number.isFinite(bytes) || - bytes < 0 || - queuedClaimCount >= maxQueuedFrames || - queuedBytes + bytes > maxQueuedBytes - ) { - return null - } - queuedBytes += bytes - queuedClaimCount += 1 - let released = false - return () => { - if (released) { - return - } - released = true - queuedBytes -= bytes - queuedClaimCount -= 1 - } - }, - evidence(): MobileOutboundMemoryBudgetEvidence { - return { - bufferedBytes: bufferedBytes(), - bufferedSourceCount: bufferedSources.size, - inFlightBytes: inFlightBytes(), - inFlightClaimCount: inFlightClaimCount(), - queuedBytes, - queuedClaimCount - } - }, - registerBufferedAmount(readBufferedAmount): MobileOutboundSocketMemory { - if (bufferedSources.size >= maxBufferedSources) { - throw new Error('Mobile outbound socket tracking limit exceeded') - } - const source: BufferedSource = { - inFlightBytes: 0, - inFlightClaimCount: 0, - readBufferedAmount - } - bufferedSources.add(source) - let registered = true - return { - canSend(bytes): boolean { - return ( - registered && - Number.isFinite(bytes) && - bytes >= 0 && - inFlightClaimCount() < maxInFlightFrames && - bytes <= maxBufferedBytes - bufferedBytes() - ) - }, - claimInFlightBytes(bytes): (() => void) | null { - if (!this.canSend(bytes)) { - return null - } - source.inFlightBytes += bytes - source.inFlightClaimCount += 1 - let released = false - return () => { - if (released) { - return - } - released = true - if (registered) { - source.inFlightBytes -= bytes - source.inFlightClaimCount -= 1 - } - } - }, - release(): void { - if (!registered) { - return - } - registered = false - bufferedSources.delete(source) - source.inFlightBytes = 0 - source.inFlightClaimCount = 0 - } - } - } - } -} - -export const processMobileOutboundMemoryBudget = createMobileOutboundMemoryBudget() diff --git a/mobile/src/transport/mobile-outbound-socket-ledger.test.ts b/mobile/src/transport/mobile-outbound-socket-ledger.test.ts deleted file mode 100644 index 5dd43ede6dd..00000000000 --- a/mobile/src/transport/mobile-outbound-socket-ledger.test.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { createMobileOutboundMemoryBudget } from './mobile-outbound-memory-budget' -import { - createMobileOutboundSocketLedger, - MOBILE_OUTBOUND_SOCKET_RETIRE_TIMEOUT_MS -} from './mobile-outbound-socket-ledger' - -describe('mobile outbound socket ledger', () => { - afterEach(() => vi.useRealTimers()) - - it('releases only the matching delivered frame while the socket stays open', () => { - const budget = createMobileOutboundMemoryBudget({ maxBufferedBytes: 10 }) - const ledger = createMobileOutboundSocketLedger({ - memoryBudget: budget, - readBufferedAmount: () => Number.NaN - }) - - expect(ledger.claimSentBytes(4, 'rpc-1')).not.toBeNull() - expect(ledger.claimSentBytes(5, 'rpc-2')).not.toBeNull() - expect(ledger.canSend(2)).toBe(false) - - ledger.acknowledge('rpc-1') - - expect(budget.evidence()).toMatchObject({ inFlightBytes: 5, inFlightClaimCount: 1 }) - expect(ledger.canSend(5)).toBe(true) - }) - - it('keeps unacknowledged bytes counted across logical disposal until native close', () => { - const budget = createMobileOutboundMemoryBudget({ maxBufferedBytes: 10 }) - const ledger = createMobileOutboundSocketLedger({ - memoryBudget: budget, - readBufferedAmount: () => Number.NaN - }) - expect(ledger.claimSentBytes(6, 'rpc-1')).not.toBeNull() - - expect(budget.evidence()).toMatchObject({ bufferedBytes: 6, bufferedSourceCount: 1 }) - - ledger.socketClosed() - ledger.socketClosed() - expect(budget.evidence()).toMatchObject({ - bufferedBytes: 0, - bufferedSourceCount: 0, - inFlightClaimCount: 0 - }) - }) - - it('releases a retired socket after a missing native close callback', () => { - vi.useFakeTimers() - const budget = createMobileOutboundMemoryBudget({ maxBufferedBytes: 10 }) - const ledger = createMobileOutboundSocketLedger({ - memoryBudget: budget, - readBufferedAmount: () => Number.NaN - }) - expect(ledger.claimSentBytes(6, 'rpc-1')).not.toBeNull() - - ledger.retire() - vi.advanceTimersByTime(MOBILE_OUTBOUND_SOCKET_RETIRE_TIMEOUT_MS - 1) - expect(budget.evidence().bufferedSourceCount).toBe(1) - - vi.advanceTimersByTime(1) - expect(budget.evidence()).toMatchObject({ - bufferedBytes: 0, - bufferedSourceCount: 0, - inFlightClaimCount: 0 - }) - }) - - it('settles unkeyed frames after a runtime reports two drained polls', () => { - vi.useFakeTimers() - const budget = createMobileOutboundMemoryBudget({ maxBufferedBytes: 10 }) - const ledger = createMobileOutboundSocketLedger({ - memoryBudget: budget, - readBufferedAmount: () => 0 - }) - expect(ledger.claimSentBytes(6)).not.toBeNull() - - vi.advanceTimersByTime(50) - - expect(budget.evidence()).toMatchObject({ inFlightBytes: 0, inFlightClaimCount: 0 }) - ledger.socketClosed() - }) - - it('requires two fresh drained polls after another unkeyed frame is sent', () => { - vi.useFakeTimers() - const budget = createMobileOutboundMemoryBudget({ maxBufferedBytes: 10 }) - const ledger = createMobileOutboundSocketLedger({ - memoryBudget: budget, - readBufferedAmount: () => 0 - }) - expect(ledger.claimSentBytes(4)).not.toBeNull() - - vi.advanceTimersByTime(25) - expect(ledger.claimSentBytes(2)).not.toBeNull() - vi.advanceTimersByTime(25) - - expect(budget.evidence()).toMatchObject({ inFlightBytes: 6, inFlightClaimCount: 2 }) - vi.advanceTimersByTime(25) - expect(budget.evidence()).toMatchObject({ inFlightBytes: 0, inFlightClaimCount: 0 }) - ledger.socketClosed() - }) - - it('keeps unkeyed React Native claims until close when bufferedAmount is unavailable', () => { - vi.useFakeTimers() - const budget = createMobileOutboundMemoryBudget({ maxBufferedBytes: 10 }) - const ledger = createMobileOutboundSocketLedger({ - memoryBudget: budget, - readBufferedAmount: () => Number.NaN - }) - expect(ledger.claimSentBytes(6)).not.toBeNull() - - vi.advanceTimersByTime(1_000) - expect(budget.evidence()).toMatchObject({ inFlightBytes: 6, inFlightClaimCount: 1 }) - - ledger.socketClosed() - expect(budget.evidence()).toMatchObject({ inFlightBytes: 0, inFlightClaimCount: 0 }) - }) -}) diff --git a/mobile/src/transport/mobile-outbound-socket-ledger.ts b/mobile/src/transport/mobile-outbound-socket-ledger.ts deleted file mode 100644 index 7ad1e41d1f0..00000000000 --- a/mobile/src/transport/mobile-outbound-socket-ledger.ts +++ /dev/null @@ -1,151 +0,0 @@ -import type { MobileOutboundMemoryBudget } from './mobile-outbound-memory-budget' - -type InFlightClaim = { - acknowledgementKey?: string - releaseBytes: () => void -} - -export const MOBILE_OUTBOUND_SOCKET_RETIRE_TIMEOUT_MS = 30_000 -const NATIVE_DRAIN_POLL_MS = 25 - -export type MobileOutboundSocketLedger = { - acknowledge(key: string): void - canSend(bytes: number): boolean - claimSentBytes(bytes: number, acknowledgementKey?: string): (() => void) | null - retire(): void - socketClosed(): void -} - -export function createMobileOutboundSocketLedger(args: { - memoryBudget: MobileOutboundMemoryBudget - readBufferedAmount: () => number -}): MobileOutboundSocketLedger { - const socketMemory = args.memoryBudget.registerBufferedAmount(args.readBufferedAmount) - const activeClaims = new Set() - const anonymousClaims = new Set() - const claimsByAcknowledgement = new Map>() - let drainTimer: ReturnType | null = null - let retireTimer: ReturnType | null = null - let consecutiveZeroDrainPolls = 0 - let closed = false - - const settleClaim = (claim: InFlightClaim): void => { - if (!activeClaims.delete(claim)) { - return - } - claim.releaseBytes() - if (claim.acknowledgementKey === undefined) { - anonymousClaims.delete(claim) - return - } - const keyed = claimsByAcknowledgement.get(claim.acknowledgementKey) - keyed?.delete(claim) - if (keyed?.size === 0) { - claimsByAcknowledgement.delete(claim.acknowledgementKey) - } - } - - const clearTimer = (timer: ReturnType | null): void => { - if (timer) { - clearTimeout(timer) - } - } - - const pollNativeDrain = (): void => { - drainTimer = null - if (closed || anonymousClaims.size === 0) { - return - } - let bufferedAmount: number - try { - bufferedAmount = args.readBufferedAmount() - } catch { - return - } - // React Native leaves this undefined; keyed application ACKs remain the authoritative path there. - if (!Number.isFinite(bufferedAmount) || bufferedAmount < 0) { - return - } - consecutiveZeroDrainPolls = bufferedAmount === 0 ? consecutiveZeroDrainPolls + 1 : 0 - if (consecutiveZeroDrainPolls >= 2) { - for (const claim of anonymousClaims) { - settleClaim(claim) - } - return - } - drainTimer = setTimeout(pollNativeDrain, NATIVE_DRAIN_POLL_MS) - unrefTimer(drainTimer) - } - - const scheduleNativeDrainPoll = (): void => { - if (drainTimer || closed) { - return - } - drainTimer = setTimeout(pollNativeDrain, NATIVE_DRAIN_POLL_MS) - unrefTimer(drainTimer) - } - - const closeLedger = (): void => { - if (closed) { - return - } - closed = true - clearTimer(drainTimer) - clearTimer(retireTimer) - drainTimer = null - retireTimer = null - for (const claim of activeClaims) { - settleClaim(claim) - } - anonymousClaims.clear() - claimsByAcknowledgement.clear() - socketMemory.release() - } - - return { - acknowledge(key): void { - const claims = claimsByAcknowledgement.get(key) - if (!claims) { - return - } - for (const claim of claims) { - settleClaim(claim) - } - }, - canSend: (bytes) => socketMemory.canSend(bytes), - claimSentBytes(bytes, acknowledgementKey): (() => void) | null { - const releaseBytes = socketMemory.claimInFlightBytes(bytes) - if (!releaseBytes) { - return null - } - const claim: InFlightClaim = { acknowledgementKey, releaseBytes } - activeClaims.add(claim) - if (acknowledgementKey === undefined) { - anonymousClaims.add(claim) - consecutiveZeroDrainPolls = 0 - scheduleNativeDrainPoll() - } else { - let claims = claimsByAcknowledgement.get(acknowledgementKey) - if (!claims) { - claims = new Set() - claimsByAcknowledgement.set(acknowledgementKey, claims) - } - claims.add(claim) - } - return () => settleClaim(claim) - }, - retire(): void { - if (closed || retireTimer) { - return - } - retireTimer = setTimeout(closeLedger, MOBILE_OUTBOUND_SOCKET_RETIRE_TIMEOUT_MS) - unrefTimer(retireTimer) - }, - socketClosed: closeLedger - } -} - -function unrefTimer(timer: ReturnType): void { - const nodeTimer = timer as unknown as { unref?: () => void } - nodeTimer.unref?.() -} diff --git a/mobile/src/transport/mobile-relay-credential-bundle.test.ts b/mobile/src/transport/mobile-relay-credential-bundle.test.ts index 47203867a7e..4df9d44430b 100644 --- a/mobile/src/transport/mobile-relay-credential-bundle.test.ts +++ b/mobile/src/transport/mobile-relay-credential-bundle.test.ts @@ -14,14 +14,12 @@ vi.mock('expo-secure-store', () => ({ vi.mock('react-native', () => ({ Platform: platform })) import { - MOBILE_RELAY_CREDENTIAL_BUNDLE_MAX_STORAGE_CHARACTERS, deleteMobileRelayCredentialBundle, promotePairingJournalCredential, readMobileRelayCredentialBundle, writeMobileRelayCredentialBundle } from './mobile-relay-credential-bundle' import type { MobileRelayPairingJournal } from './mobile-relay-pairing-journal' -import { MOBILE_HOST_ID_MAX_CHARACTERS, PAIRING_DEVICE_TOKEN_MAX_CHARACTERS } from './types' const journal = { metadata: { @@ -108,51 +106,6 @@ describe('mobile relay credential bundle', () => { ).toThrow(/does not match/) }) - it('round-trips exact field and raw limits and rejects one character more', async () => { - const exact = { - v: 1 as const, - hostId: 'h'.repeat(MOBILE_HOST_ID_MAX_CHARACTERS), - deviceToken: 't'.repeat(PAIRING_DEVICE_TOKEN_MAX_CHARACTERS), - current: { - token: 'A'.repeat(43), - hash: 'B'.repeat(43), - version: 1, - expiresAt: 10_000 - } - } - - await expect(writeMobileRelayCredentialBundle(exact)).resolves.toBeUndefined() - const serialized = stored! - stored = - serialized + - ' '.repeat(MOBILE_RELAY_CREDENTIAL_BUNDLE_MAX_STORAGE_CHARACTERS - serialized.length) - await expect(readMobileRelayCredentialBundle(exact.hostId)).resolves.toEqual(exact) - await expect( - writeMobileRelayCredentialBundle({ - ...exact, - deviceToken: `${exact.deviceToken}t` - }) - ).rejects.toThrow() - }) - - it('does not parse an oversized secure-store record', async () => { - stored = { - length: MOBILE_RELAY_CREDENTIAL_BUNDLE_MAX_STORAGE_CHARACTERS + 1 - } as unknown as string - const parse = vi.spyOn(JSON, 'parse') - - await expect(readMobileRelayCredentialBundle('host-1')).resolves.toBeNull() - expect(parse).not.toHaveBeenCalled() - parse.mockRestore() - }) - - it('does not construct a secure-store key for an oversized host id', async () => { - await expect( - readMobileRelayCredentialBundle('h'.repeat(MOBILE_HOST_ID_MAX_CHARACTERS + 1)) - ).resolves.toBeNull() - expect(secureStore.getItemAsync).not.toHaveBeenCalled() - }) - it('deletes the namespaced bundle and never enables it on web', async () => { await deleteMobileRelayCredentialBundle('host-1') expect(secureStore.deleteItemAsync).toHaveBeenCalledWith( diff --git a/mobile/src/transport/mobile-relay-credential-bundle.ts b/mobile/src/transport/mobile-relay-credential-bundle.ts index cb3e178dc83..87f6f3826f4 100644 --- a/mobile/src/transport/mobile-relay-credential-bundle.ts +++ b/mobile/src/transport/mobile-relay-credential-bundle.ts @@ -3,15 +3,8 @@ import { Platform } from 'react-native' import { z } from 'zod' import type { DeviceCredentialInstalled } from '../../../src/shared/mobile-relay-credential-contract' import type { MobileRelayPairingJournal } from './mobile-relay-pairing-journal' -import { - MOBILE_HOST_ID_MAX_CHARACTERS, - MobileHostIdSchema, - PAIRING_DEVICE_TOKEN_MAX_CHARACTERS -} from './types' -import { parseMobileJsonTextWithinLimits } from './mobile-json-text-admission' const Base64Url32ByteSchema = z.string().regex(/^[A-Za-z0-9_-]{43}$/) -export const MOBILE_RELAY_CREDENTIAL_BUNDLE_MAX_STORAGE_CHARACTERS = 128 * 1024 const ResumeCredentialSchema = z .object({ token: Base64Url32ByteSchema, @@ -24,15 +17,15 @@ const ResumeCredentialSchema = z export const MobileRelayCredentialBundleSchema = z .object({ v: z.literal(1), - hostId: MobileHostIdSchema, - deviceToken: z.string().min(1).max(PAIRING_DEVICE_TOKEN_MAX_CHARACTERS), + hostId: z.string().min(1), + deviceToken: z.string().min(1), current: ResumeCredentialSchema, grace: ResumeCredentialSchema.optional(), pending: z .object({ token: Base64Url32ByteSchema, hash: Base64Url32ByteSchema, - reqId: z.string().min(1).max(128) + reqId: z.string().min(1) }) .strict() .optional(), @@ -81,15 +74,12 @@ export async function readMobileRelayCredentialBundle( hostId: string ): Promise { requireNativeSecretStore() - if (!isValidHostId(hostId)) { - return null - } const raw = await SecureStore.getItemAsync(credentialKey(hostId), KEYCHAIN_OPTIONS) - if (raw === null || raw.length > MOBILE_RELAY_CREDENTIAL_BUNDLE_MAX_STORAGE_CHARACTERS) { + if (raw === null) { return null } try { - const result = MobileRelayCredentialBundleSchema.safeParse(parseMobileJsonTextWithinLimits(raw)) + const result = MobileRelayCredentialBundleSchema.safeParse(JSON.parse(raw)) return result.success && result.data.hostId === hostId ? result.data : null } catch { return null @@ -101,24 +91,20 @@ export async function writeMobileRelayCredentialBundle( ): Promise { requireNativeSecretStore() const validated = MobileRelayCredentialBundleSchema.parse(bundle) - const serialized = JSON.stringify(validated) - if (serialized.length > MOBILE_RELAY_CREDENTIAL_BUNDLE_MAX_STORAGE_CHARACTERS) { - throw new Error('mobile relay credential bundle exceeds storage limit') - } - await SecureStore.setItemAsync(credentialKey(validated.hostId), serialized, KEYCHAIN_OPTIONS) + await SecureStore.setItemAsync( + credentialKey(validated.hostId), + JSON.stringify(validated), + KEYCHAIN_OPTIONS + ) } export async function deleteMobileRelayCredentialBundle(hostId: string): Promise { - if (Platform.OS === 'web' || !isValidHostId(hostId)) { + if (Platform.OS === 'web') { return } await SecureStore.deleteItemAsync(credentialKey(hostId), KEYCHAIN_OPTIONS) } -function isValidHostId(hostId: string): boolean { - return hostId.length > 0 && hostId.length <= MOBILE_HOST_ID_MAX_CHARACTERS -} - function requireNativeSecretStore(): void { if (Platform.OS === 'web') { throw new Error('Orca Relay credentials require a native secret store') diff --git a/mobile/src/transport/mobile-relay-direct-upgrade-journal.test.ts b/mobile/src/transport/mobile-relay-direct-upgrade-journal.test.ts deleted file mode 100644 index 6a2ce32ea57..00000000000 --- a/mobile/src/transport/mobile-relay-direct-upgrade-journal.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const secureStore = vi.hoisted(() => ({ - getItemAsync: vi.fn(), - setItemAsync: vi.fn(), - deleteItemAsync: vi.fn() -})) -const platform = vi.hoisted(() => ({ OS: 'ios' })) - -vi.mock('expo-secure-store', () => ({ - WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'WHEN_UNLOCKED_THIS_DEVICE_ONLY', - ...secureStore -})) -vi.mock('react-native', () => ({ Platform: platform })) - -import { - MOBILE_RELAY_DIRECT_UPGRADE_MAX_STORAGE_CHARACTERS, - createMobileRelayDirectUpgradeJournal, - readMobileRelayDirectUpgradeJournal, - writeMobileRelayDirectUpgradeJournal -} from './mobile-relay-direct-upgrade-journal' -import { MOBILE_HOST_ID_MAX_CHARACTERS } from './types' - -describe('mobile relay direct-upgrade journal storage', () => { - let stored: string | null - - beforeEach(() => { - vi.clearAllMocks() - platform.OS = 'ios' - stored = null - secureStore.getItemAsync.mockImplementation(async () => stored) - secureStore.setItemAsync.mockImplementation(async (_key: string, value: string) => { - stored = value - }) - }) - - it('round-trips the exact host-id and raw limits', async () => { - const hostId = 'h'.repeat(MOBILE_HOST_ID_MAX_CHARACTERS) - const journal = createMobileRelayDirectUpgradeJournal(hostId, (length) => - new Uint8Array(length).fill(7) - ) - - await writeMobileRelayDirectUpgradeJournal(journal) - const serialized = stored! - stored = - serialized + - ' '.repeat(MOBILE_RELAY_DIRECT_UPGRADE_MAX_STORAGE_CHARACTERS - serialized.length) - - await expect(readMobileRelayDirectUpgradeJournal(hostId)).resolves.toEqual(journal) - }) - - it('rejects a host id one character beyond the limit', () => { - expect(() => - createMobileRelayDirectUpgradeJournal( - 'h'.repeat(MOBILE_HOST_ID_MAX_CHARACTERS + 1), - (length) => new Uint8Array(length) - ) - ).toThrow() - }) - - it('does not parse an oversized secure-store record', async () => { - stored = { - length: MOBILE_RELAY_DIRECT_UPGRADE_MAX_STORAGE_CHARACTERS + 1 - } as unknown as string - const parse = vi.spyOn(JSON, 'parse') - - await expect(readMobileRelayDirectUpgradeJournal('host-1')).resolves.toBeNull() - expect(parse).not.toHaveBeenCalled() - parse.mockRestore() - }) - - it('does not construct a secure-store key for an oversized host id', async () => { - await expect( - readMobileRelayDirectUpgradeJournal('h'.repeat(MOBILE_HOST_ID_MAX_CHARACTERS + 1)) - ).resolves.toBeNull() - expect(secureStore.getItemAsync).not.toHaveBeenCalled() - }) -}) diff --git a/mobile/src/transport/mobile-relay-direct-upgrade-journal.ts b/mobile/src/transport/mobile-relay-direct-upgrade-journal.ts index 7fd3327e7dc..19775270b45 100644 --- a/mobile/src/transport/mobile-relay-direct-upgrade-journal.ts +++ b/mobile/src/transport/mobile-relay-direct-upgrade-journal.ts @@ -2,16 +2,13 @@ import * as SecureStore from 'expo-secure-store' import { Platform } from 'react-native' import { z } from 'zod' import { hashMobileRelayCredential } from './mobile-relay-credential-hash' -import { MOBILE_HOST_ID_MAX_CHARACTERS, MobileHostIdSchema } from './types' -import { parseMobileJsonTextWithinLimits } from './mobile-json-text-admission' const Base64Url32ByteSchema = z.string().regex(/^[A-Za-z0-9_-]{43}$/) -export const MOBILE_RELAY_DIRECT_UPGRADE_MAX_STORAGE_CHARACTERS = 32 * 1024 export const MobileRelayDirectUpgradeJournalSchema = z .object({ v: z.literal(1), - hostId: MobileHostIdSchema, + hostId: z.string().min(1), reqId: z.string().min(1).max(128), pendingResumeToken: Base64Url32ByteSchema, pendingResumeTokenHash: Base64Url32ByteSchema @@ -46,17 +43,12 @@ export async function readMobileRelayDirectUpgradeJournal( hostId: string ): Promise { requireNativeSecretStore() - if (!isValidHostId(hostId)) { - return null - } const raw = await SecureStore.getItemAsync(journalKey(hostId), KEYCHAIN_OPTIONS) - if (!raw || raw.length > MOBILE_RELAY_DIRECT_UPGRADE_MAX_STORAGE_CHARACTERS) { + if (!raw) { return null } try { - const parsed = MobileRelayDirectUpgradeJournalSchema.safeParse( - parseMobileJsonTextWithinLimits(raw) - ) + const parsed = MobileRelayDirectUpgradeJournalSchema.safeParse(JSON.parse(raw)) return parsed.success && parsed.data.hostId === hostId ? parsed.data : null } catch { return null @@ -68,24 +60,20 @@ export async function writeMobileRelayDirectUpgradeJournal( ): Promise { requireNativeSecretStore() const parsed = MobileRelayDirectUpgradeJournalSchema.parse(journal) - const serialized = JSON.stringify(parsed) - if (serialized.length > MOBILE_RELAY_DIRECT_UPGRADE_MAX_STORAGE_CHARACTERS) { - throw new Error('mobile relay direct-upgrade journal exceeds storage limit') - } - await SecureStore.setItemAsync(journalKey(parsed.hostId), serialized, KEYCHAIN_OPTIONS) + await SecureStore.setItemAsync( + journalKey(parsed.hostId), + JSON.stringify(parsed), + KEYCHAIN_OPTIONS + ) } export async function deleteMobileRelayDirectUpgradeJournal(hostId: string): Promise { - if (Platform.OS === 'web' || !isValidHostId(hostId)) { + if (Platform.OS === 'web') { return } await SecureStore.deleteItemAsync(journalKey(hostId), KEYCHAIN_OPTIONS) } -function isValidHostId(hostId: string): boolean { - return hostId.length > 0 && hostId.length <= MOBILE_HOST_ID_MAX_CHARACTERS -} - function encodeBase64Url(value: Uint8Array): string { let binary = '' for (const byte of value) { diff --git a/mobile/src/transport/mobile-relay-e2ee-link.ts b/mobile/src/transport/mobile-relay-e2ee-link.ts index 0856732e3cc..183a8deea6d 100644 --- a/mobile/src/transport/mobile-relay-e2ee-link.ts +++ b/mobile/src/transport/mobile-relay-e2ee-link.ts @@ -4,9 +4,7 @@ import { } from '../../../src/shared/mobile-relay-phone-protocol' import { MobileE2EEV2ClientSession } from './mobile-e2ee-v2-client-session' import { MobileE2EEV2PhysicalChannel } from './mobile-e2ee-v2-physical-channel' -import { assertMobileInboundFrameSize } from './mobile-inbound-frame-queue' import { websocketPayloadToUint8 } from './websocket-payload-bytes' -import { parseMobileJsonTextWithinLimits } from './mobile-json-text-admission' export class RelayOuterError extends Error { constructor(readonly code: number) { @@ -34,6 +32,7 @@ export class MobileRelayE2eeLink { private readonly channel: MobileE2EEV2PhysicalChannel private outerReady = false private closed = false + private inboundChain: Promise = Promise.resolve() constructor(options: MobileRelayE2eeLinkOptions) { this.options = options @@ -45,21 +44,16 @@ export class MobileRelayE2eeLink { transport: 'relay', relayHostId: options.endpoint.relayHostId }) - try { - this.channel = new MobileE2EEV2PhysicalChannel({ - session, - socket: this.socket, - deviceToken: options.deviceToken, - decodeBinary: websocketPayloadToUint8, - onAuthenticated: options.onAuthenticated, - onText: options.onText, - onBinary: options.onBinary, - onError: (error) => this.fail(error) - }) - } catch (error) { - this.socket.close() - throw error - } + this.channel = new MobileE2EEV2PhysicalChannel({ + session, + socket: this.socket, + deviceToken: options.deviceToken, + decodeBinary: websocketPayloadToUint8, + onAuthenticated: options.onAuthenticated, + onText: options.onText, + onBinary: options.onBinary, + onError: (error) => this.fail(error) + }) this.bindSocket() } @@ -92,25 +86,21 @@ export class MobileRelayE2eeLink { ) } this.socket.onmessage = (event) => { - if (this.closed) { - return - } - try { - if (!this.outerReady) { - assertMobileInboundFrameSize(event.data, 'relay hello frame too large') - this.acceptHello(event.data) - } else { - void this.channel.handleMessage(event.data) - } - } catch (error) { - this.fail(asError(error)) - } + this.inboundChain = this.inboundChain + .then(async () => { + if (this.closed) { + return + } + if (!this.outerReady) { + this.acceptHello(event.data) + } else { + await this.channel.handleMessage(event.data) + } + }) + .catch((error: unknown) => this.fail(asError(error))) } this.socket.onerror = () => this.fail(new Error('relay transport error')) - this.socket.onclose = (event) => { - this.channel.socketClosed() - this.fail(new RelayOuterError(event.code || 1006)) - } + this.socket.onclose = (event) => this.fail(new RelayOuterError(event.code || 1006)) } private acceptHello(raw: unknown): void { @@ -119,7 +109,7 @@ export class MobileRelayE2eeLink { } let value: unknown try { - value = parseMobileJsonTextWithinLimits(raw) + value = JSON.parse(raw) } catch { throw new Error('invalid relay hello JSON') } diff --git a/mobile/src/transport/mobile-relay-host-overlay-store.test.ts b/mobile/src/transport/mobile-relay-host-overlay-store.test.ts index 0c078b91e1e..812d61ebb1d 100644 --- a/mobile/src/transport/mobile-relay-host-overlay-store.test.ts +++ b/mobile/src/transport/mobile-relay-host-overlay-store.test.ts @@ -9,8 +9,6 @@ vi.mock('@react-native-async-storage/async-storage', () => ({ default: asyncStor import { loadMobileRelayHostOverlays, - MOBILE_RELAY_HOST_OVERLAY_MAX_ENTRIES, - MOBILE_RELAY_HOST_OVERLAY_MAX_STORAGE_CHARACTERS, removeMobileRelayHostOverlays, resetMobileRelayHostOverlayStoreForTests, saveMobileRelayHostOverlay @@ -100,27 +98,4 @@ describe('mobile relay host overlay store', () => { expect(asyncStorage.getItem).toHaveBeenCalledOnce() expect(asyncStorage.setItem).not.toHaveBeenCalled() }) - - it('accepts the exact overlay count and refuses to overwrite one over', async () => { - const exact = Array.from({ length: MOBILE_RELAY_HOST_OVERLAY_MAX_ENTRIES }, (_, index) => ({ - ...OVERLAY, - hostId: `host-${index}` - })) - stored = JSON.stringify(exact) - const hostIds = new Set(exact.map(({ hostId }) => hostId)) - expect((await loadMobileRelayHostOverlays(hostIds)).size).toBe( - MOBILE_RELAY_HOST_OVERLAY_MAX_ENTRIES - ) - - stored = JSON.stringify([...exact, { ...OVERLAY, hostId: 'one-over' }]) - await expect(saveMobileRelayHostOverlay(OVERLAY)).rejects.toThrow(/unreadable/) - expect(asyncStorage.setItem).not.toHaveBeenCalled() - }) - - it('rejects an oversized overlay payload before parsing', async () => { - stored = 'x'.repeat(MOBILE_RELAY_HOST_OVERLAY_MAX_STORAGE_CHARACTERS + 1) - - await expect(saveMobileRelayHostOverlay(OVERLAY)).rejects.toThrow(/unreadable/) - expect(asyncStorage.setItem).not.toHaveBeenCalled() - }) }) diff --git a/mobile/src/transport/mobile-relay-host-overlay-store.ts b/mobile/src/transport/mobile-relay-host-overlay-store.ts index ad6f399b3d2..7f1519eb5c9 100644 --- a/mobile/src/transport/mobile-relay-host-overlay-store.ts +++ b/mobile/src/transport/mobile-relay-host-overlay-store.ts @@ -3,36 +3,23 @@ import { MobileRelayHostOverlaySchema, type MobileRelayHostOverlay } from './mobile-relay-host-overlay' -import { parseMobileJsonTextWithinLimits } from './mobile-json-text-admission' const OVERLAY_STORAGE_KEY = 'orca:mobile-relay:host-overlays:v2' -export const MOBILE_RELAY_HOST_OVERLAY_MAX_ENTRIES = 64 -export const MOBILE_RELAY_HOST_OVERLAY_MAX_STORAGE_CHARACTERS = 512 * 1024 let overlayMutation: Promise = Promise.resolve() function parseOverlays(raw: string | null): MobileRelayHostOverlay[] | null { if (raw === null) { return [] } - if (raw.length > MOBILE_RELAY_HOST_OVERLAY_MAX_STORAGE_CHARACTERS) { - return null - } try { - const value = parseMobileJsonTextWithinLimits(raw) + const value = JSON.parse(raw) as unknown if (!Array.isArray(value)) { return null } - if (value.length > MOBILE_RELAY_HOST_OVERLAY_MAX_ENTRIES) { - return null - } - const overlays: MobileRelayHostOverlay[] = [] - for (const item of value) { + return value.flatMap((item) => { const result = MobileRelayHostOverlaySchema.safeParse(item) - if (result.success) { - overlays.push(result.data) - } - } - return overlays + return result.success ? [result.data] : [] + }) } catch { return null } @@ -57,24 +44,13 @@ async function mutateOverlays( // Why: direct-only saves commonly have no overlay to remove; avoid a full // AsyncStorage write when cleanup leaves the durable list unchanged. if (next !== current) { - await AsyncStorage.setItem(OVERLAY_STORAGE_KEY, serializeOverlays(next)) + await AsyncStorage.setItem(OVERLAY_STORAGE_KEY, JSON.stringify(next)) } }) overlayMutation = mutation.catch(() => {}) return mutation } -function serializeOverlays(overlays: MobileRelayHostOverlay[]): string { - if (overlays.length > MOBILE_RELAY_HOST_OVERLAY_MAX_ENTRIES) { - throw new Error('mobile relay host overlay count limit exceeded') - } - const serialized = JSON.stringify(overlays) - if (serialized.length > MOBILE_RELAY_HOST_OVERLAY_MAX_STORAGE_CHARACTERS) { - throw new Error('mobile relay host overlay storage limit exceeded') - } - return serialized -} - export async function loadMobileRelayHostOverlays( existingHostIds: ReadonlySet ): Promise> { diff --git a/mobile/src/transport/mobile-relay-host-overlay.ts b/mobile/src/transport/mobile-relay-host-overlay.ts index ae15eefdf61..59c407c9ee3 100644 --- a/mobile/src/transport/mobile-relay-host-overlay.ts +++ b/mobile/src/transport/mobile-relay-host-overlay.ts @@ -1,8 +1,6 @@ import { z } from 'zod' import { MobileRelayEndpointSchema } from '../../../src/shared/mobile-relay-credential-contract' -export const MOBILE_RELAY_HOST_OVERLAY_MAX_HOST_ID_CHARACTERS = 4_096 - export const MobileAccessEndpointSchema = z .object({ id: z.string().min(1).max(128), @@ -14,7 +12,7 @@ export const MobileAccessEndpointSchema = z export const MobileRelayHostOverlaySchema = z .object({ v: z.literal(2), - hostId: z.string().min(1).max(MOBILE_RELAY_HOST_OVERLAY_MAX_HOST_ID_CHARACTERS), + hostId: z.string().min(1), endpoints: z.array(MobileAccessEndpointSchema).min(1).max(16), relayHostId: z .string() diff --git a/mobile/src/transport/mobile-relay-invite-director.test.ts b/mobile/src/transport/mobile-relay-invite-director.test.ts index 3b0cab393df..04508024d07 100644 --- a/mobile/src/transport/mobile-relay-invite-director.test.ts +++ b/mobile/src/transport/mobile-relay-invite-director.test.ts @@ -1,8 +1,5 @@ import { describe, expect, it } from 'vitest' -import { - MOBILE_RELAY_DIRECTOR_MAX_FRAME_BYTES, - resolvePairingInviteThroughDirector -} from './mobile-relay-invite-director' +import { resolvePairingInviteThroughDirector } from './mobile-relay-invite-director' class FakeSocket { sent: string[] = [] @@ -80,24 +77,4 @@ describe('pairing invite director resolution', () => { await expect(resolving).rejects.toThrow(/not strictly newer/) }) - - it('rejects over-deep and oversized moves before materializing them', async () => { - const deepSocket = new FakeSocket() - const deep = resolvePairingInviteThroughDirector({ - relay, - createSocket: () => deepSocket as unknown as WebSocket - }) - deepSocket.onmessage?.({ data: `${'['.repeat(129)}0${']'.repeat(129)}` }) - await expect(deep).rejects.toThrow('invalid relay director move') - - const oversizedSocket = new FakeSocket() - const oversized = resolvePairingInviteThroughDirector({ - relay, - createSocket: () => oversizedSocket as unknown as WebSocket - }) - oversizedSocket.onmessage?.({ - data: 'x'.repeat(MOBILE_RELAY_DIRECTOR_MAX_FRAME_BYTES / 2 + 1) - }) - await expect(oversized).rejects.toThrow('invalid relay director move') - }) }) diff --git a/mobile/src/transport/mobile-relay-invite-director.ts b/mobile/src/transport/mobile-relay-invite-director.ts index 1723f0e9f9e..5dc83f526b6 100644 --- a/mobile/src/transport/mobile-relay-invite-director.ts +++ b/mobile/src/transport/mobile-relay-invite-director.ts @@ -1,9 +1,5 @@ import type { PairingRelay } from '../../../src/shared/mobile-relay-pairing-offer' import { RelayMovedSchema } from '../../../src/shared/mobile-relay-phone-protocol' -import { assertMobileInboundFrameSize } from './mobile-inbound-frame-queue' -import { parseMobileJsonTextWithinLimits } from './mobile-json-text-admission' - -export const MOBILE_RELAY_DIRECTOR_MAX_FRAME_BYTES = 64 * 1024 export function resolvePairingInviteThroughDirector(args: { relay: PairingRelay @@ -36,12 +32,7 @@ export function resolvePairingInviteThroughDirector(args: { } let value: unknown try { - assertMobileInboundFrameSize( - event.data, - 'relay director move frame too large', - MOBILE_RELAY_DIRECTOR_MAX_FRAME_BYTES - ) - value = parseMobileJsonTextWithinLimits(event.data) + value = JSON.parse(event.data) } catch { finish(new Error('invalid relay director move')) return diff --git a/mobile/src/transport/mobile-relay-pairing-journal-store.test.ts b/mobile/src/transport/mobile-relay-pairing-journal-store.test.ts index b91ff031238..5790eb456aa 100644 --- a/mobile/src/transport/mobile-relay-pairing-journal-store.test.ts +++ b/mobile/src/transport/mobile-relay-pairing-journal-store.test.ts @@ -20,11 +20,7 @@ vi.mock('expo-secure-store', () => ({ vi.mock('expo-crypto', () => ({ getRandomBytes: vi.fn() })) vi.mock('react-native', () => ({ Platform: platform })) -import { - MOBILE_RELAY_PAIRING_METADATA_MAX_STORAGE_CHARACTERS, - MOBILE_RELAY_PAIRING_SECRETS_MAX_STORAGE_CHARACTERS, - createMobileRelayPairingJournal -} from './mobile-relay-pairing-journal' +import { createMobileRelayPairingJournal } from './mobile-relay-pairing-journal' import { clearMobileRelayPairingJournal, loadMobileRelayPairingJournal, @@ -32,14 +28,7 @@ import { saveMobileRelayPairingJournal, updateMobileRelayPairingJournal } from './mobile-relay-pairing-journal-store' -import { - MOBILE_HOST_ID_MAX_CHARACTERS, - MOBILE_HOST_NAME_MAX_CHARACTERS, - PAIRING_DEVICE_TOKEN_MAX_CHARACTERS, - PAIRING_ENDPOINT_MAX_CHARACTERS, - PAIRING_PUBLIC_KEY_MAX_CHARACTERS, - type PairingOffer -} from './types' +import type { PairingOffer } from './types' const now = Date.UTC(2026, 6, 13) const offer = { @@ -105,75 +94,6 @@ describe('mobile relay pairing journal store', () => { await expect(loadMobileRelayPairingJournal()).resolves.toEqual(journal) }) - it('round-trips exact persisted field limits and rejects one character more', async () => { - const base = createMobileRelayPairingJournal({ - offer: offer as PairingOffer & { relay: NonNullable }, - hostId: 'host-1', - hostName: 'Blue Whale', - now, - randomBytes: (length) => new Uint8Array(length).fill(length) - }) - const exact = { - metadata: { - ...base.metadata, - host: { - ...base.metadata.host, - id: 'i'.repeat(MOBILE_HOST_ID_MAX_CHARACTERS), - name: 'n'.repeat(MOBILE_HOST_NAME_MAX_CHARACTERS), - endpoint: 'e'.repeat(PAIRING_ENDPOINT_MAX_CHARACTERS), - publicKeyB64: 'p'.repeat(PAIRING_PUBLIC_KEY_MAX_CHARACTERS) - } - }, - secrets: { - ...base.secrets, - deviceToken: 't'.repeat(PAIRING_DEVICE_TOKEN_MAX_CHARACTERS) - } - } - - await expect(saveMobileRelayPairingJournal(exact)).resolves.toBeUndefined() - await expect(loadMobileRelayPairingJournal()).resolves.toEqual(exact) - await expect( - saveMobileRelayPairingJournal({ - ...exact, - secrets: { ...exact.secrets, deviceToken: `${exact.secrets.deviceToken}t` } - }) - ).rejects.toThrow() - }) - - it('repairs oversized metadata without parsing it', async () => { - metadataRaw = { - length: MOBILE_RELAY_PAIRING_METADATA_MAX_STORAGE_CHARACTERS + 1 - } as unknown as string - secretRaw = 'orphan' - const parse = vi.spyOn(JSON, 'parse') - - await expect(loadMobileRelayPairingJournal()).resolves.toBeNull() - expect(parse).not.toHaveBeenCalled() - expect(metadataRaw).toBeNull() - expect(secretRaw).toBeNull() - parse.mockRestore() - }) - - it('repairs oversized secrets without parsing them', async () => { - const journal = createMobileRelayPairingJournal({ - offer: offer as PairingOffer & { relay: NonNullable }, - hostId: 'host-1', - hostName: 'Blue Whale', - randomBytes: (length) => new Uint8Array(length).fill(9) - }) - metadataRaw = JSON.stringify(journal.metadata) - secretRaw = { - length: MOBILE_RELAY_PAIRING_SECRETS_MAX_STORAGE_CHARACTERS + 1 - } as unknown as string - const parse = vi.spyOn(JSON, 'parse') - - await expect(loadMobileRelayPairingJournal()).resolves.toBeNull() - expect(parse).toHaveBeenCalledTimes(1) - expect(metadataRaw).toBeNull() - expect(secretRaw).toBeNull() - parse.mockRestore() - }) - it('records a provisional winner only for the active journal identity', async () => { const journal = createMobileRelayPairingJournal({ offer: offer as PairingOffer & { relay: NonNullable }, diff --git a/mobile/src/transport/mobile-relay-pairing-journal-store.ts b/mobile/src/transport/mobile-relay-pairing-journal-store.ts index 9f5841e117a..b7044830da5 100644 --- a/mobile/src/transport/mobile-relay-pairing-journal-store.ts +++ b/mobile/src/transport/mobile-relay-pairing-journal-store.ts @@ -2,15 +2,11 @@ import AsyncStorage from '@react-native-async-storage/async-storage' import * as SecureStore from 'expo-secure-store' import { Platform } from 'react-native' import { - MOBILE_RELAY_PAIRING_METADATA_MAX_STORAGE_CHARACTERS, - MOBILE_RELAY_PAIRING_SECRETS_MAX_STORAGE_CHARACTERS, MobileRelayPairingJournalMetadataSchema, MobileRelayPairingJournalSecretsSchema, type MobileRelayPairingJournal, - type MobileRelayPairingJournalMetadata, - type MobileRelayPairingJournalSecrets + type MobileRelayPairingJournalMetadata } from './mobile-relay-pairing-journal' -import { parseMobileJsonTextWithinLimits } from './mobile-json-text-admission' const JOURNAL_STORAGE_KEY = 'orca:mobile-relay:pairing-journal:v1' const JOURNAL_SECRET_KEY = 'orca.mobile-relay.pairing-journal.v1' @@ -28,8 +24,6 @@ export async function saveMobileRelayPairingJournal( if (metadata.journalId !== secrets.journalId) { throw new Error('mobile relay pairing journal identity mismatch') } - const serializedMetadata = serializeMetadata(metadata) - const serializedSecrets = serializeSecrets(secrets) const mutation = journalMutation.then(async () => { const existingRaw = await AsyncStorage.getItem(JOURNAL_STORAGE_KEY) const existing = existingRaw ? parseMetadata(existingRaw) : null @@ -44,8 +38,8 @@ export async function saveMobileRelayPairingJournal( // a new user-initiated scan may safely supersede a pre-authorization attempt. // Why: metadata-first makes a crash before the keychain write recover as // an incomplete journal, never as an untracked bearer secret. - await AsyncStorage.setItem(JOURNAL_STORAGE_KEY, serializedMetadata) - await SecureStore.setItemAsync(JOURNAL_SECRET_KEY, serializedSecrets, KEYCHAIN_OPTIONS) + await AsyncStorage.setItem(JOURNAL_STORAGE_KEY, JSON.stringify(metadata)) + await SecureStore.setItemAsync(JOURNAL_SECRET_KEY, JSON.stringify(secrets), KEYCHAIN_OPTIONS) }) journalMutation = mutation.catch(() => {}) return mutation @@ -106,7 +100,7 @@ export async function updateMobileRelayPairingJournal( if (next.journalId !== journalId) { throw new Error('mobile relay pairing journal identity mismatch') } - await AsyncStorage.setItem(JOURNAL_STORAGE_KEY, serializeMetadata(next)) + await AsyncStorage.setItem(JOURNAL_STORAGE_KEY, JSON.stringify(next)) }) journalMutation = mutation.catch(() => {}) return mutation @@ -127,49 +121,23 @@ export async function clearMobileRelayPairingJournal(journalId: string): Promise } function parseMetadata(raw: string): MobileRelayPairingJournalMetadata | null { - if (raw.length > MOBILE_RELAY_PAIRING_METADATA_MAX_STORAGE_CHARACTERS) { - return null - } try { - const result = MobileRelayPairingJournalMetadataSchema.safeParse( - parseMobileJsonTextWithinLimits(raw) - ) + const result = MobileRelayPairingJournalMetadataSchema.safeParse(JSON.parse(raw)) return result.success ? result.data : null } catch { return null } } -function parseSecrets(raw: string): MobileRelayPairingJournalSecrets | null { - if (raw.length > MOBILE_RELAY_PAIRING_SECRETS_MAX_STORAGE_CHARACTERS) { - return null - } +function parseSecrets(raw: string) { try { - const result = MobileRelayPairingJournalSecretsSchema.safeParse( - parseMobileJsonTextWithinLimits(raw) - ) + const result = MobileRelayPairingJournalSecretsSchema.safeParse(JSON.parse(raw)) return result.success ? result.data : null } catch { return null } } -function serializeMetadata(metadata: MobileRelayPairingJournalMetadata): string { - const serialized = JSON.stringify(metadata) - if (serialized.length > MOBILE_RELAY_PAIRING_METADATA_MAX_STORAGE_CHARACTERS) { - throw new Error('mobile relay pairing metadata exceeds storage limit') - } - return serialized -} - -function serializeSecrets(secrets: MobileRelayPairingJournalSecrets): string { - const serialized = JSON.stringify(secrets) - if (serialized.length > MOBILE_RELAY_PAIRING_SECRETS_MAX_STORAGE_CHARACTERS) { - throw new Error('mobile relay pairing secrets exceed storage limit') - } - return serialized -} - function requireNativeSecretStore(): void { if (Platform.OS === 'web') { throw new Error('Orca Relay pairing requires a native secret store') diff --git a/mobile/src/transport/mobile-relay-pairing-journal.ts b/mobile/src/transport/mobile-relay-pairing-journal.ts index b2b0648b0c8..08b27ad7009 100644 --- a/mobile/src/transport/mobile-relay-pairing-journal.ts +++ b/mobile/src/transport/mobile-relay-pairing-journal.ts @@ -1,23 +1,11 @@ import * as ExpoCrypto from 'expo-crypto' import { sha256 } from '@noble/hashes/sha256' import { z } from 'zod' -import { - PAIRING_RELAY_URL_MAX_CHARACTERS, - type PairingRelay -} from '../../../src/shared/mobile-relay-pairing-offer' +import type { PairingRelay } from '../../../src/shared/mobile-relay-pairing-offer' import { hashMobileRelayCredential } from './mobile-relay-credential-hash' -import { - MOBILE_HOST_ID_MAX_CHARACTERS, - MOBILE_HOST_NAME_MAX_CHARACTERS, - PAIRING_DEVICE_TOKEN_MAX_CHARACTERS, - PAIRING_ENDPOINT_MAX_CHARACTERS, - PAIRING_PUBLIC_KEY_MAX_CHARACTERS, - type PairingOffer -} from './types' +import type { PairingOffer } from './types' const Base64Url32ByteSchema = z.string().regex(/^[A-Za-z0-9_-]{43}$/) -export const MOBILE_RELAY_PAIRING_METADATA_MAX_STORAGE_CHARACTERS = 256 * 1024 -export const MOBILE_RELAY_PAIRING_SECRETS_MAX_STORAGE_CHARACTERS = 128 * 1024 export const MobileRelayPairingJournalMetadataSchema = z .object({ @@ -26,18 +14,18 @@ export const MobileRelayPairingJournalMetadataSchema = z offerFingerprint: Base64Url32ByteSchema, host: z .object({ - id: z.string().min(1).max(MOBILE_HOST_ID_MAX_CHARACTERS), - name: z.string().min(1).max(MOBILE_HOST_NAME_MAX_CHARACTERS), - endpoint: z.string().min(1).max(PAIRING_ENDPOINT_MAX_CHARACTERS), - publicKeyB64: z.string().min(1).max(PAIRING_PUBLIC_KEY_MAX_CHARACTERS), + id: z.string().min(1), + name: z.string().min(1), + endpoint: z.string().min(1), + publicKeyB64: z.string().min(1), lastConnected: z.number().int().nonnegative() }) .strict(), relay: z .object({ v: z.literal(1), - directorUrl: z.string().min(1).max(PAIRING_RELAY_URL_MAX_CHARACTERS), - cellUrl: z.string().min(1).max(PAIRING_RELAY_URL_MAX_CHARACTERS), + directorUrl: z.string().min(1), + cellUrl: z.string().min(1), assignmentEpoch: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER), relayHostId: z.string().regex(/^[A-Za-z0-9_-]{16}$/), inviteExpiresAt: z.number().int().positive(), @@ -56,7 +44,7 @@ export const MobileRelayPairingJournalSecretsSchema = z .object({ v: z.literal(1), journalId: z.string().min(1).max(128), - deviceToken: z.string().min(1).max(PAIRING_DEVICE_TOKEN_MAX_CHARACTERS), + deviceToken: z.string().min(1), inviteToken: Base64Url32ByteSchema, pendingResumeToken: Base64Url32ByteSchema }) diff --git a/mobile/src/transport/mobile-relay-physical-client.test.ts b/mobile/src/transport/mobile-relay-physical-client.test.ts index 34054a9e866..e4aaa0fe4f8 100644 --- a/mobile/src/transport/mobile-relay-physical-client.test.ts +++ b/mobile/src/transport/mobile-relay-physical-client.test.ts @@ -8,8 +8,7 @@ const fakes = vi.hoisted(() => ({ start: vi.fn(), handleMessage: vi.fn(), sendText: vi.fn(() => true), - dispose: vi.fn(), - socketClosed: vi.fn() + dispose: vi.fn() })) vi.mock('./mobile-e2ee-v2-client-session', () => ({ @@ -24,7 +23,6 @@ vi.mock('./mobile-e2ee-v2-physical-channel', () => ({ handleMessage = fakes.handleMessage sendText = fakes.sendText dispose = fakes.dispose - socketClosed = fakes.socketClosed } })) @@ -134,21 +132,4 @@ describe('mobile relay physical pairing client', () => { await expect(status).rejects.toEqual(new RelayOuterError(4404)) expect(fakes.start).not.toHaveBeenCalled() }) - - it('rejects relay hello JSON above the nesting cap', async () => { - const socket = new FakeSocket() - const client = connectMobileRelayForPairing({ - relay, - deviceToken: 'device-token', - desktopPublicKeyB64: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=', - createSocket: () => socket as unknown as WebSocket - }) - const status = client.sendRequest('status.get') - - socket.receive(`${'['.repeat(129)}0${']'.repeat(129)}`) - - await expect(status).rejects.toThrow('invalid relay hello JSON') - expect(fakes.start).not.toHaveBeenCalled() - expect(fakes.dispose).toHaveBeenCalledOnce() - }) }) diff --git a/mobile/src/transport/mobile-relay-physical-client.ts b/mobile/src/transport/mobile-relay-physical-client.ts index 2334d79e62c..acf264b8c73 100644 --- a/mobile/src/transport/mobile-relay-physical-client.ts +++ b/mobile/src/transport/mobile-relay-physical-client.ts @@ -2,15 +2,9 @@ import type { PairingRelay } from '../../../src/shared/mobile-relay-pairing-offe import { RelayPhoneHelloSchema } from '../../../src/shared/mobile-relay-phone-protocol' import { MobileE2EEV2ClientSession } from './mobile-e2ee-v2-client-session' import { MobileE2EEV2PhysicalChannel } from './mobile-e2ee-v2-physical-channel' -import { assertMobileInboundFrameSize } from './mobile-inbound-frame-queue' -import { stringifyMobileOutboundJson } from './mobile-outbound-json' import { isRpcResponse } from './rpc-response-shape' import type { RpcResponse } from './types' import { websocketPayloadToUint8 } from './websocket-payload-bytes' -import { - isMobileJsonStructureCapacityError, - parseMobileJsonTextWithinLimits -} from './mobile-json-text-admission' export { RelayOuterError } from './mobile-relay-e2ee-link' import { RelayOuterError } from './mobile-relay-e2ee-link' @@ -53,44 +47,35 @@ export function connectMobileRelayForPairing(args: { resolveAuthenticated = resolve rejectAuthenticated = reject }) - let channel: MobileE2EEV2PhysicalChannel - try { - channel = new MobileE2EEV2PhysicalChannel({ - session, - socket, - deviceToken: args.deviceToken, - decodeBinary: websocketPayloadToUint8, - onAuthenticated: () => { - authenticated = true - resolveAuthenticated() - }, - onText: (plaintext) => { - let value: unknown - try { - value = parseMobileJsonTextWithinLimits(plaintext) - } catch (error) { - if (isMobileJsonStructureCapacityError(error)) { - throw error - } - return - } - if (!isRpcResponse(value)) { - return - } - const request = pending.get(value.id) - if (request) { - clearTimeout(request.timer) - pending.delete(value.id) - request.resolve(value) - } - }, - onBinary: () => {}, - onError: fail - }) - } catch (error) { - socket.close() - throw error - } + const channel = new MobileE2EEV2PhysicalChannel({ + session, + socket, + deviceToken: args.deviceToken, + decodeBinary: websocketPayloadToUint8, + onAuthenticated: () => { + authenticated = true + resolveAuthenticated() + }, + onText: (plaintext) => { + let value: unknown + try { + value = JSON.parse(plaintext) + } catch { + return + } + if (!isRpcResponse(value)) { + return + } + const request = pending.get(value.id) + if (request) { + clearTimeout(request.timer) + pending.delete(value.id) + request.resolve(value) + } + }, + onBinary: () => {}, + onError: fail + }) socket.onopen = () => { socket.send( @@ -102,26 +87,23 @@ export function connectMobileRelayForPairing(args: { }) ) } + let inboundChain: Promise = Promise.resolve() socket.onmessage = (event) => { - if (closed) { - return - } - try { - if (!outerReady) { - assertMobileInboundFrameSize(event.data, 'relay hello frame too large') - acceptRelayHello(event.data) - return - } - void channel.handleMessage(event.data) - } catch (error) { - fail(asError(error)) - } + inboundChain = inboundChain + .then(async () => { + if (closed) { + return + } + if (!outerReady) { + acceptRelayHello(event.data) + return + } + await channel.handleMessage(event.data) + }) + .catch((error: unknown) => fail(asError(error))) } socket.onerror = () => fail(new Error('relay transport error')) - socket.onclose = (event) => { - channel.socketClosed() - fail(new RelayOuterError(event.code || 1006)) - } + socket.onclose = (event) => fail(new RelayOuterError(event.code || 1006)) function acceptRelayHello(raw: unknown): void { if (typeof raw !== 'string') { @@ -129,7 +111,7 @@ export function connectMobileRelayForPairing(args: { } let value: unknown try { - value = parseMobileJsonTextWithinLimits(raw) + value = JSON.parse(raw) } catch { throw new Error('invalid relay hello JSON') } @@ -175,21 +157,9 @@ export function connectMobileRelayForPairing(args: { reject(new Error(`relay pairing RPC timed out: ${method}`)) }, requestTimeoutMs) pending.set(id, { resolve, reject, timer }) - let serialized: string - try { - serialized = stringifyMobileOutboundJson({ - id, - deviceToken: args.deviceToken, - method, - params - }) - } catch { - clearTimeout(timer) - pending.delete(id) - reject(new Error('relay RPC request is too large')) - return - } - if (!channel.sendText(serialized)) { + if ( + !channel.sendText(JSON.stringify({ id, deviceToken: args.deviceToken, method, params })) + ) { clearTimeout(timer) pending.delete(id) reject(new Error('relay E2EE channel not ready')) diff --git a/mobile/src/transport/mobile-relay-resume-director.test.ts b/mobile/src/transport/mobile-relay-resume-director.test.ts index 7ea28a368e2..426ff89ba07 100644 --- a/mobile/src/transport/mobile-relay-resume-director.test.ts +++ b/mobile/src/transport/mobile-relay-resume-director.test.ts @@ -63,27 +63,4 @@ describe('mobile relay resume director', () => { resolveMobileRelayEndpoint({ relay, resumeToken: 'A'.repeat(43), fetchImpl: oversized }) ).rejects.toThrow(/too large/) }) - - it('cancels a streamed response once it crosses the mobile limit', async () => { - let cancelled = false - const streamed = vi.fn( - async () => - new Response( - new ReadableStream({ - start(controller) { - controller.enqueue(new Uint8Array(16 * 1024)) - controller.enqueue(new Uint8Array([1])) - }, - cancel() { - cancelled = true - } - }) - ) - ) - - await expect( - resolveMobileRelayEndpoint({ relay, resumeToken: 'A'.repeat(43), fetchImpl: streamed }) - ).rejects.toThrow(/too large/) - expect(cancelled).toBe(true) - }) }) diff --git a/mobile/src/transport/mobile-relay-resume-director.ts b/mobile/src/transport/mobile-relay-resume-director.ts index 1679de56e6f..86fd283b9a5 100644 --- a/mobile/src/transport/mobile-relay-resume-director.ts +++ b/mobile/src/transport/mobile-relay-resume-director.ts @@ -1,10 +1,5 @@ import { z } from 'zod' import type { MobileRelayEndpoint } from '../../../src/shared/mobile-relay-credential-contract' -import { - FetchResponseBodyTooLargeError, - readFetchResponseTextWithinLimit -} from '../../../src/shared/fetch-response-body' -import { parseMobileJsonTextWithinLimits } from './mobile-json-text-admission' const MAX_RESPONSE_BYTES = 16 * 1024 const ResolveResponseSchema = z @@ -39,16 +34,15 @@ export async function resolveMobileRelayEndpoint(args: { if (!response.ok) { throw new Error(`relay director resolve failed (${response.status})`) } - let raw: string - try { - raw = await readFetchResponseTextWithinLimit(response, MAX_RESPONSE_BYTES) - } catch (error) { - if (error instanceof FetchResponseBodyTooLargeError) { - throw new Error('relay director resolve response too large') - } - throw error + const declaredLength = Number(response.headers.get('content-length') ?? 0) + if (declaredLength > MAX_RESPONSE_BYTES) { + throw new Error('relay director resolve response too large') } - const resolved = ResolveResponseSchema.parse(parseMobileJsonTextWithinLimits(raw)) + const raw = await response.text() + if (new TextEncoder().encode(raw).byteLength > MAX_RESPONSE_BYTES) { + throw new Error('relay director resolve response too large') + } + const resolved = ResolveResponseSchema.parse(JSON.parse(raw) as unknown) return { ...args.relay, cellUrl: resolved.cellUrl, diff --git a/mobile/src/transport/mobile-relay-rpc-session.ts b/mobile/src/transport/mobile-relay-rpc-session.ts index e3a3529bd8f..a0e71a85c1b 100644 --- a/mobile/src/transport/mobile-relay-rpc-session.ts +++ b/mobile/src/transport/mobile-relay-rpc-session.ts @@ -7,14 +7,9 @@ import { MobileRelayE2eeLink } from './mobile-relay-e2ee-link' import { MobileRelayRpcStreams } from './mobile-relay-rpc-streams' import { MobileE2EEAuthenticationError } from './mobile-e2ee-v2-physical-channel' import { markRpcDeliveryUnknown } from './rpc-delivery-ambiguity' -import { stringifyMobileOutboundJson } from './mobile-outbound-json' import { isRpcResponse } from './rpc-response-shape' import type { RpcClient } from './rpc-client' import type { ConnectionState, RpcResponse } from './types' -import { - isMobileJsonStructureCapacityError, - parseMobileJsonTextWithinLimits -} from './mobile-json-text-admission' type PendingRequest = { resolve: (response: RpcResponse) => void @@ -167,23 +162,14 @@ export function connectMobileRelayRpcSession(args: { } function sendFrame(request: { id: string; method: string; params?: unknown }): boolean { - try { - return link.sendText( - stringifyMobileOutboundJson({ ...request, deviceToken: args.deviceToken }) - ) - } catch { - return false - } + return link.sendText(JSON.stringify({ ...request, deviceToken: args.deviceToken })) } function handleText(plaintext: string): void { let value: unknown try { - value = parseMobileJsonTextWithinLimits(plaintext) - } catch (error) { - if (isMobileJsonStructureCapacityError(error)) { - throw error - } + value = JSON.parse(plaintext) + } catch { return } if (!isRpcResponse(value)) { diff --git a/mobile/src/transport/mobile-rpc-binary-frame-handler.ts b/mobile/src/transport/mobile-rpc-binary-frame-handler.ts deleted file mode 100644 index 15e9c24061d..00000000000 --- a/mobile/src/transport/mobile-rpc-binary-frame-handler.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { decryptBytes } from './e2ee' -import { websocketPayloadToUint8 } from './websocket-payload-bytes' - -export async function handleMobileRpcSocketBinaryMessage(args: { - rawData: unknown - key: Uint8Array - isCurrent: () => boolean - onFrame: (plaintext: Uint8Array) => void -}): Promise { - const bytes = await websocketPayloadToUint8(args.rawData) - if (!args.isCurrent() || !bytes) { - return - } - const plaintextBytes = decryptBytes(bytes, args.key) - if (plaintextBytes) { - args.onFrame(plaintextBytes) - } -} diff --git a/mobile/src/transport/pairing.test.ts b/mobile/src/transport/pairing.test.ts index 5f14f925d6f..9b7b7514369 100644 --- a/mobile/src/transport/pairing.test.ts +++ b/mobile/src/transport/pairing.test.ts @@ -1,11 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { decodePairingUrl, extractPairingCodeFromUrl, parsePairingCode } from './pairing' -import { - PAIRING_CODE_MAX_CHARACTERS, - PAIRING_DEVICE_TOKEN_MAX_CHARACTERS, - PairingOfferSchema, - type PairingOffer -} from './types' +import type { PairingOffer } from './types' const offer: PairingOffer = { v: 2, @@ -77,31 +72,4 @@ describe('pairing deep links', () => { expect(parsePairingCode(code)).toEqual(proxiedOffer) }) - - it('rejects an oversized code before base64 decoding', () => { - const decode = vi.fn(() => { - throw new Error('must not decode') - }) - vi.stubGlobal('atob', decode) - const oversized = 'A'.repeat(PAIRING_CODE_MAX_CHARACTERS + 1) - - expect(parsePairingCode(oversized)).toBeNull() - expect(decodePairingUrl(`orca://pair?code=${oversized}`)).toBeNull() - expect(decode).not.toHaveBeenCalled() - }) - - it('accepts the device-token limit and rejects one character more', () => { - expect( - PairingOfferSchema.safeParse({ - ...offer, - deviceToken: 't'.repeat(PAIRING_DEVICE_TOKEN_MAX_CHARACTERS) - }).success - ).toBe(true) - expect( - PairingOfferSchema.safeParse({ - ...offer, - deviceToken: 't'.repeat(PAIRING_DEVICE_TOKEN_MAX_CHARACTERS + 1) - }).success - ).toBe(false) - }) }) diff --git a/mobile/src/transport/pairing.ts b/mobile/src/transport/pairing.ts index 0322c107b4b..d330f1834fa 100644 --- a/mobile/src/transport/pairing.ts +++ b/mobile/src/transport/pairing.ts @@ -1,10 +1,4 @@ -import { - PAIRING_CODE_MAX_CHARACTERS, - PAIRING_INPUT_MAX_CHARACTERS, - PairingOfferSchema, - type PairingOffer -} from './types' -import { parseMobileJsonTextWithinLimits } from './mobile-json-text-admission' +import { PairingOfferSchema, type PairingOffer } from './types' // Why: this file mirrors src/shared/pairing.ts (which is covered by CI // vitest) but uses atob/btoa because Metro/Hermes don't ship Node's @@ -12,9 +6,6 @@ import { parseMobileJsonTextWithinLimits } from './mobile-json-text-admission' // the other. export function decodePairingUrl(url: string): PairingOffer | null { - if (url.length > PAIRING_INPUT_MAX_CHARACTERS) { - return null - } try { const code = extractPairingCodeFromUrl(url) if (!code) { @@ -30,9 +21,6 @@ export function decodePairingUrl(url: string): PairingOffer | null { // extraction here makes QR scan, paste, and external deep-link flows // accept the same URL shapes. export function extractPairingCodeFromUrl(url: string): string | null { - if (url.length > PAIRING_INPUT_MAX_CHARACTERS) { - return null - } const trimmed = url.trim() const match = /^orca:\/\/([^/?#]*)([^?#]*)?/i.exec(trimmed) if (!match) { @@ -65,9 +53,6 @@ export function extractPairingCodeFromUrl(url: string): string | null { // string so the paste-pair flow can take whichever the user actually // copied from desktop. export function parsePairingCode(input: string): PairingOffer | null { - if (input.length > PAIRING_INPUT_MAX_CHARACTERS) { - return null - } const trimmed = input.trim() if (!trimmed) { return null @@ -83,18 +68,11 @@ export function parsePairingCode(input: string): PairingOffer | null { } function decodePairingBase64(base64url: string): PairingOffer { - if ( - base64url.length === 0 || - base64url.length > PAIRING_CODE_MAX_CHARACTERS || - !/^[A-Za-z0-9+/_-]+={0,2}$/.test(base64url) - ) { - throw new Error('Invalid pairing code') - } // Why: desktop intentionally strips base64 padding from QR payloads. Some // mobile JS runtimes reject unpadded atob input, so restore it before decode. const base64 = padBase64(base64url.replace(/-/g, '+').replace(/_/g, '/')) const json = atob(base64) - return PairingOfferSchema.parse(parseMobileJsonTextWithinLimits(json)) + return PairingOfferSchema.parse(JSON.parse(json)) } function padBase64(base64: string): string { diff --git a/mobile/src/transport/redacted-websocket-endpoint.ts b/mobile/src/transport/redacted-websocket-endpoint.ts deleted file mode 100644 index b19431099ee..00000000000 --- a/mobile/src/transport/redacted-websocket-endpoint.ts +++ /dev/null @@ -1,9 +0,0 @@ -// Why: keep device tokens and full URLs out of connection logs. -export function redactedWebSocketEndpoint(endpoint: string): string { - try { - const url = new URL(endpoint) - return (url.protocol === 'ws:' || url.protocol === 'wss:') && url.host ? url.host : 'unknown' - } catch { - return 'unknown' - } -} diff --git a/mobile/src/transport/rpc-client-terminal-binary-frame.test.ts b/mobile/src/transport/rpc-client-terminal-binary-frame.test.ts index 8030dec8ce6..216fd2aec56 100644 --- a/mobile/src/transport/rpc-client-terminal-binary-frame.test.ts +++ b/mobile/src/transport/rpc-client-terminal-binary-frame.test.ts @@ -32,92 +32,4 @@ describe('handleTerminalBinaryFrame', () => { expect(recordValidatedInboundTraffic).toHaveBeenCalledTimes(1) expect(listener).toHaveBeenCalledWith({ type: 'metadata', streamId: 42, cwd: '/repo/src' }) }) - - it('assembles an ordinary snapshot without changing its payload', () => { - const listener = vi.fn() - const terminalSnapshots = new Map() - const options = { - terminalSnapshots, - getListener: () => listener, - recordValidatedInboundTraffic: vi.fn() - } - - handleTerminalBinaryFrame( - encodeFrame(TerminalStreamOpcode.SnapshotStart, 7, { kind: 'scrollback' }), - options - ) - handleTerminalBinaryFrame(encodeFrame(TerminalStreamOpcode.SnapshotChunk, 7, 'hello '), options) - handleTerminalBinaryFrame(encodeFrame(TerminalStreamOpcode.SnapshotChunk, 7, 'world'), options) - handleTerminalBinaryFrame(encodeFrame(TerminalStreamOpcode.SnapshotEnd, 7, ''), options) - - expect(listener).toHaveBeenCalledWith({ - kind: 'scrollback', - serialized: 'hello world', - streamId: 7, - type: 'scrollback' - }) - expect(terminalSnapshots.size).toBe(0) - }) - - it('drops cumulative snapshot data at the configured byte bound', () => { - const listener = vi.fn() - const terminalSnapshots = new Map() - const options = { - terminalSnapshots, - getListener: () => listener, - recordValidatedInboundTraffic: vi.fn(), - maxSnapshotBytes: 5 - } - - handleTerminalBinaryFrame(encodeFrame(TerminalStreamOpcode.SnapshotStart, 7, {}), options) - handleTerminalBinaryFrame(encodeFrame(TerminalStreamOpcode.SnapshotChunk, 7, '12345'), options) - handleTerminalBinaryFrame(encodeFrame(TerminalStreamOpcode.SnapshotChunk, 7, '6'), options) - - expect(terminalSnapshots.size).toBe(0) - expect(listener).toHaveBeenLastCalledWith({ - message: 'Terminal snapshot exceeded size limit.', - streamId: 7, - type: 'error' - }) - }) - - it('bounds tiny snapshot chunks independently of byte size', () => { - const listener = vi.fn() - const terminalSnapshots = new Map() - const options = { - terminalSnapshots, - getListener: () => listener, - recordValidatedInboundTraffic: vi.fn(), - maxSnapshotChunks: 2 - } - - handleTerminalBinaryFrame(encodeFrame(TerminalStreamOpcode.SnapshotStart, 7, {}), options) - handleTerminalBinaryFrame(encodeFrame(TerminalStreamOpcode.SnapshotChunk, 7, ''), options) - handleTerminalBinaryFrame(encodeFrame(TerminalStreamOpcode.SnapshotChunk, 7, ''), options) - handleTerminalBinaryFrame(encodeFrame(TerminalStreamOpcode.SnapshotChunk, 7, ''), options) - - expect(terminalSnapshots.size).toBe(0) - expect(listener).toHaveBeenLastCalledWith({ - message: 'Terminal snapshot exceeded size limit.', - streamId: 7, - type: 'error' - }) - }) - - it('rejects a terminal frame before decoding an oversized payload', () => { - const listener = vi.fn() - - handleTerminalBinaryFrame(encodeFrame(TerminalStreamOpcode.Metadata, 42, { value: 'large' }), { - terminalSnapshots: new Map(), - getListener: () => listener, - recordValidatedInboundTraffic: vi.fn(), - maxFramePayloadBytes: 4 - }) - - expect(listener).toHaveBeenCalledWith({ - message: 'Terminal stream frame exceeded size limit.', - streamId: 42, - type: 'error' - }) - }) }) diff --git a/mobile/src/transport/rpc-client-terminal-binary-frame.ts b/mobile/src/transport/rpc-client-terminal-binary-frame.ts index 562362b7e3f..ee7da35383e 100644 --- a/mobile/src/transport/rpc-client-terminal-binary-frame.ts +++ b/mobile/src/transport/rpc-client-terminal-binary-frame.ts @@ -9,44 +9,14 @@ export type TerminalSnapshotState = { streamId: number meta: Record chunks: string[] - bytes: number } -export const MOBILE_TERMINAL_STREAM_FRAME_MAX_PAYLOAD_BYTES = 256 * 1024 -export const MOBILE_TERMINAL_SNAPSHOT_MAX_BYTES = 2 * 1024 * 1024 -export const MOBILE_TERMINAL_SNAPSHOT_MAX_AGGREGATE_BYTES = 16 * 1024 * 1024 -export const MOBILE_TERMINAL_SNAPSHOT_MAX_ACTIVE = 16 -export const MOBILE_TERMINAL_SNAPSHOT_MAX_CHUNKS = 1_024 - type StreamingListener = (result: unknown) => void type TerminalBinaryFrameOptions = { terminalSnapshots: Map getListener: (streamId: number) => StreamingListener | undefined recordValidatedInboundTraffic: () => void - maxFramePayloadBytes?: number - maxSnapshotBytes?: number - maxSnapshotAggregateBytes?: number - maxActiveSnapshots?: number - maxSnapshotChunks?: number -} - -function retainedSnapshotBytes(snapshots: Map): number { - let bytes = 0 - for (const snapshot of snapshots.values()) { - bytes += snapshot.bytes - } - return bytes -} - -function rejectSnapshot( - options: TerminalBinaryFrameOptions, - listener: StreamingListener, - streamId: number, - message: string -): void { - options.terminalSnapshots.delete(streamId) - listener({ type: 'error', streamId, message }) } export function handleTerminalBinaryFrame( @@ -62,12 +32,6 @@ export function handleTerminalBinaryFrame( options.recordValidatedInboundTraffic() return } - const maxFramePayloadBytes = - options.maxFramePayloadBytes ?? MOBILE_TERMINAL_STREAM_FRAME_MAX_PAYLOAD_BYTES - if (frame.payload.byteLength > maxFramePayloadBytes) { - rejectSnapshot(options, listener, frame.streamId, 'Terminal stream frame exceeded size limit.') - return - } if (frame.opcode === TerminalStreamOpcode.Output) { options.recordValidatedInboundTraffic() listener({ @@ -82,20 +46,11 @@ export function handleTerminalBinaryFrame( if (!meta) { return } - const maxActiveSnapshots = options.maxActiveSnapshots ?? MOBILE_TERMINAL_SNAPSHOT_MAX_ACTIVE - if ( - !options.terminalSnapshots.has(frame.streamId) && - options.terminalSnapshots.size >= maxActiveSnapshots - ) { - rejectSnapshot(options, listener, frame.streamId, 'Too many terminal snapshots are active.') - return - } options.recordValidatedInboundTraffic() options.terminalSnapshots.set(frame.streamId, { streamId: frame.streamId, meta, - chunks: [], - bytes: 0 + chunks: [] }) return } @@ -105,21 +60,7 @@ export function handleTerminalBinaryFrame( if (!snapshot) { return } - const maxSnapshotBytes = options.maxSnapshotBytes ?? MOBILE_TERMINAL_SNAPSHOT_MAX_BYTES - const maxSnapshotAggregateBytes = - options.maxSnapshotAggregateBytes ?? MOBILE_TERMINAL_SNAPSHOT_MAX_AGGREGATE_BYTES - const maxSnapshotChunks = options.maxSnapshotChunks ?? MOBILE_TERMINAL_SNAPSHOT_MAX_CHUNKS - if ( - snapshot.chunks.length >= maxSnapshotChunks || - snapshot.bytes + frame.payload.byteLength > maxSnapshotBytes || - retainedSnapshotBytes(options.terminalSnapshots) + frame.payload.byteLength > - maxSnapshotAggregateBytes - ) { - rejectSnapshot(options, listener, frame.streamId, 'Terminal snapshot exceeded size limit.') - return - } snapshot.chunks.push(decodeTerminalStreamText(frame.payload)) - snapshot.bytes += frame.payload.byteLength return } if (frame.opcode === TerminalStreamOpcode.SnapshotEnd) { diff --git a/mobile/src/transport/rpc-client.test.ts b/mobile/src/transport/rpc-client.test.ts index 73302fb8b5d..b2adb18c4ce 100644 --- a/mobile/src/transport/rpc-client.test.ts +++ b/mobile/src/transport/rpc-client.test.ts @@ -153,25 +153,6 @@ describe('mobile rpc-client connection timeout', () => { client.close() }) - it('keeps WebSocket credentials and URL paths out of retained connection logs', () => { - const details: Array = [] - const client = connect( - 'wss://user:password@desktop.example:8443/relay?access_token=secret', - 'token', - 'server-key', - { - onLog: (entry) => details.push(entry.detail) - } - ) - - expect(details).toContain('desktop.example:8443') - expect(details.join('\n')).not.toContain('password') - expect(details.join('\n')).not.toContain('access_token') - expect(details.join('\n')).not.toContain('/relay') - - client.close() - }) - it('ignores stale socket opens after reconnect swaps in a new socket', () => { const client = connect('ws://desktop.invalid', 'token', 'server-key') const firstSocket = mockSockets[0]! diff --git a/mobile/src/transport/rpc-client.ts b/mobile/src/transport/rpc-client.ts index 54d4c4cd9e3..9b0b6246f1c 100644 --- a/mobile/src/transport/rpc-client.ts +++ b/mobile/src/transport/rpc-client.ts @@ -10,7 +10,9 @@ import { deriveSharedKey, publicKeyFromBase64, publicKeyToBase64, - decrypt + encrypt, + decrypt, + decryptBytes } from './e2ee' import { handleTerminalBinaryFrame, @@ -28,13 +30,7 @@ import { import { describeSocketEvent } from './socket-event-debug' import { markRpcDeliveryUnknown } from './rpc-delivery-ambiguity' import { isRpcResponse } from './rpc-response-shape' -import { createMobileInboundFrameQueue } from './mobile-inbound-frame-queue' -import { createMobileDirectRpcOutbound } from './mobile-direct-rpc-outbound' -import { createMobileDirectRpcSender } from './mobile-direct-rpc-sender' -import { handleMobileRpcSocketBinaryMessage } from './mobile-rpc-binary-frame-handler' -import { processMobileOutboundMemoryBudget } from './mobile-outbound-memory-budget' -import { redactedWebSocketEndpoint } from './redacted-websocket-endpoint' -import { tryParseMobileJsonTextWithinLimits } from './mobile-json-text-admission' +import { websocketPayloadToUint8 } from './websocket-payload-bytes' type PendingRequest = { resolve: (response: RpcResponse) => void @@ -146,7 +142,6 @@ export function connect( }) } let ws: WebSocket | null = null - let outbound: ReturnType | null = null let state: ConnectionState = 'disconnected' let requestCounter = 0 let reconnectAttempt = 0 @@ -209,7 +204,7 @@ export function connect( to: next, dweltMs: dwelt, attempt: reconnectAttempt, - endpoint: redactedWebSocketEndpoint(endpoint) + endpoint: redactedEndpoint(endpoint) }) if (next === 'connected') { lastConnectedAt = Date.now() @@ -231,6 +226,16 @@ export function connect( } } + // Why: keep device tokens / full URLs out of log scrolls — truncate to host:port. + function redactedEndpoint(ep: string): string { + try { + const m = ep.match(/^wss?:\/\/([^/]+)/i) + return m ? m[1] : 'unknown' + } catch { + return 'unknown' + } + } + function waitForConnected(timeoutMs?: number): Promise { if (state === 'connected') { return Promise.resolve() @@ -265,11 +270,6 @@ export function connect( return `rpc-${++requestCounter}-${Date.now()}` } - function disposeActiveOutbound(): void { - outbound?.dispose() - outbound = null - } - function openConnection() { if (intentionallyClosed) { return @@ -279,7 +279,7 @@ export function connect( wsConstructionCounter++ console.log('[net] openConnection', { attempt: reconnectAttempt, - endpoint: redactedWebSocketEndpoint(endpoint), + endpoint: redactedEndpoint(endpoint), // Why: diagnostic for RN/OkHttp pool corruption — high wsCount + repeated 1006 closes means process-state stuck. wsCount: wsConstructionCounter, msSinceLastConnected: lastConnectedAt != null ? now - lastConnectedAt : null, @@ -293,37 +293,11 @@ export function connect( emitLog( 'info', reconnectAttempt > 0 ? `Reconnecting (attempt ${reconnectAttempt + 1})` : 'Opening WebSocket', - redactedWebSocketEndpoint(endpoint) + endpoint ) - if (!processMobileOutboundMemoryBudget.canRegisterBufferedAmount()) { - emitLog('error', 'WebSocket reconnect deferred', 'Retired socket buffers are still draining') - setState('reconnecting') - scheduleReconnect() - return - } - ws = new WebSocket(endpoint) const openingWs = ws - const closeForOverload = (direction: 'Inbound' | 'Outbound', detail: string): void => { - emitLog('error', `${direction} WebSocket overload`, detail) - openingWs.close() - if (ws === openingWs) { - handleSocketClosed(openingWs) - } - } - const openingOutbound = createMobileDirectRpcOutbound({ - socket: openingWs, - isActive: () => ws === openingWs, - onOverflow: () => closeForOverload('Outbound', 'Mobile RPC outbound buffer overflow') - }) - outbound = openingOutbound - const inboundQueue = createMobileInboundFrameQueue({ - process: handleSocketMessage, - onError: (error) => closeForOverload('Inbound', error.message), - overflowMessage: 'Mobile RPC inbound buffer overflow', - frameTooLargeMessage: 'Mobile RPC inbound frame too large' - }) const ignoreStaleSocketEvent = (eventName: string): boolean => { if (ws === openingWs) { return false @@ -399,10 +373,10 @@ export function connect( if (ignoreStaleSocketEvent('message')) { return } - void inboundQueue.enqueue(event.data) + void handleSocketMessage(event.data) } - function handleSocketMessage(rawData: unknown): Promise | void { + async function handleSocketMessage(rawData: unknown) { lastInboundAt = Date.now() const raw = typeof rawData === 'string' ? rawData : null @@ -411,11 +385,15 @@ export function connect( if (raw === null) { return } - const plaintextControl = tryParseMobileJsonTextWithinLimits>(raw) - if (plaintextControl?.type === 'e2ee_ready') { - emitLog('success', 'Received e2ee_ready', 'Sending device token') - sendEncrypted({ type: 'e2ee_auth', deviceToken }) - return + try { + const msg = JSON.parse(raw) + if (msg.type === 'e2ee_ready') { + emitLog('success', 'Received e2ee_ready', 'Sending device token') + sendEncrypted({ type: 'e2ee_auth', deviceToken }) + return + } + } catch { + // Not plaintext JSON — fall through and try encrypted handshake messages. } if (!sharedKey || sharedKey.length !== 32) { @@ -427,10 +405,9 @@ export function connect( return } - const msg = tryParseMobileJsonTextWithinLimits>(plaintext) - if (msg) { + try { + const msg = JSON.parse(plaintext) if (msg.type === 'e2ee_authenticated') { - openingOutbound.acknowledgeAuthentication() if (handshakeTimer) { clearTimeout(handshakeTimer) handshakeTimer = null @@ -464,11 +441,7 @@ export function connect( removeStreamListener(id) } } - } else if ( - msg.type === 'e2ee_error' || - (!msg.ok && (msg.error as { code?: unknown } | undefined)?.code === 'unauthorized') - ) { - openingOutbound.acknowledgeAuthentication() + } else if (msg.type === 'e2ee_error' || (!msg.ok && msg.error?.code === 'unauthorized')) { console.log('[net] e2ee auth FAILED', { msgType: msg.type, error: msg.error }) if (handshakeTimer) { clearTimeout(handshakeTimer) @@ -476,6 +449,8 @@ export function connect( } handleAuthRejection('Unauthorized — pairing may be revoked') } + } catch { + // Not JSON — ignore during handshake. } return } @@ -486,12 +461,19 @@ export function connect( } if (raw === null) { - return handleMobileRpcSocketBinaryMessage({ - rawData, - key: sharedKey, - isCurrent: () => ws === openingWs, - onFrame: handleBinaryFrame - }) + const bytes = await websocketPayloadToUint8(rawData) + if (ws !== openingWs) { + return + } + if (!bytes) { + return + } + const plaintextBytes = decryptBytes(bytes, sharedKey) + if (!plaintextBytes) { + return + } + handleBinaryFrame(plaintextBytes) + return } const plaintext = decrypt(raw, sharedKey) @@ -499,14 +481,15 @@ export function connect( return } - const response = tryParseMobileJsonTextWithinLimits(plaintext) - if (response === null) { + let response: unknown + try { + response = JSON.parse(plaintext) + } catch { return } if (!isRpcResponse(response)) { return } - openingOutbound.acknowledge(response.id) recordValidatedInboundTraffic() // Why: a mid-session unauthorized may be transient (issue #5200) — handleAuthRejection retries before latching auth-failed. @@ -597,11 +580,6 @@ export function connect( } ws.onclose = (event) => { - inboundQueue.dispose() - openingOutbound.socketClosed() - if (outbound === openingOutbound) { - disposeActiveOutbound() - } const e = event as { code?: number; reason?: string; wasClean?: boolean } | undefined const closeAt = Date.now() // Why: time-since-construct classifies the failure — instant close = RST/unreachable, slow = SYN timeout/packet loss. @@ -618,7 +596,7 @@ export function connect( state, attempt: reconnectAttempt, intentionallyClosed, - endpoint: redactedWebSocketEndpoint(endpoint), + endpoint: redactedEndpoint(endpoint), constructToCloseMs, aliveMs, inboundIdleMs, @@ -659,7 +637,6 @@ export function connect( return } clearConnectTimer() - disposeActiveOutbound() ws = null sharedKey = null activeBrowserScreencastRequestId = null @@ -707,7 +684,7 @@ export function connect( console.log('[net] auth rejected — retrying handshake', { attempt: authRejectionCount, budget: AUTH_RETRY_BUDGET, - endpoint: redactedWebSocketEndpoint(endpoint) + endpoint: redactedEndpoint(endpoint) }) emitLog( 'warn', @@ -716,7 +693,6 @@ export function connect( ) // Why: close without setting intentionallyClosed so handleSocketClosed routes to reconnect and retries the handshake. const closing = ws - disposeActiveOutbound() ws = null sharedKey = null // Why: close cleanup stale-bails here, so mark active streams for replay. @@ -731,10 +707,9 @@ export function connect( } console.log('[net] auth rejected — budget exhausted, latching auth-failed', { attempt: authRejectionCount, - endpoint: redactedWebSocketEndpoint(endpoint) + endpoint: redactedEndpoint(endpoint) }) intentionallyClosed = true - disposeActiveOutbound() ws?.close() ws = null setState('auth-failed') @@ -954,13 +929,26 @@ export function connect( stream.onBinaryFrame?.(frame) } - const sendEncrypted = createMobileDirectRpcSender({ - getOutbound: () => outbound, - getSharedKey: () => sharedKey, - getSocket: () => ws, - getState: () => state, - onSocketDesync: (socket) => handleSocketClosed(socket, { timedOut: false }) - }) + function sendEncrypted(request: unknown): boolean { + if (ws && ws.readyState === WebSocket.OPEN && sharedKey) { + ws.send(encrypt(JSON.stringify(request), sharedKey)) + return true + } + console.log('[net] sendEncrypted FAILED — channel not ready', { + hasWs: !!ws, + readyState: ws?.readyState, + hasKey: !!sharedKey, + state + }) + // Why: RN can drop onclose, leaving state 'connected' over a dead socket; force reconnect or every send silently fails forever. + if (state === 'connected' && ws && ws.readyState !== WebSocket.OPEN) { + console.log('[net] sendEncrypted detected ws desync — forcing reconnect', { + readyState: ws.readyState + }) + handleSocketClosed(ws, { timedOut: false }) + } + return false + } function sendBrowserScreencastUnsubscribe(subscriptionId: string): void { sendEncrypted({ @@ -1171,7 +1159,6 @@ export function connect( handshakeTimer = null } stopActivityProbe() - disposeActiveOutbound() if (ws) { ws.close() ws = null diff --git a/mobile/src/transport/terminal-stream-protocol.ts b/mobile/src/transport/terminal-stream-protocol.ts index 5113f38254a..384d66996f1 100644 --- a/mobile/src/transport/terminal-stream-protocol.ts +++ b/mobile/src/transport/terminal-stream-protocol.ts @@ -1,5 +1,3 @@ -import { parseMobileJsonTextWithinLimits } from './mobile-json-text-admission' - const TERMINAL_STREAM_KIND = 0x74 const TERMINAL_STREAM_VERSION = 1 const HEADER_BYTES = 16 @@ -54,13 +52,13 @@ export function decodeTerminalStreamFrame(bytes: Uint8Array): TerminalStreamFram opcode, streamId: view.getUint32(4, true), seq: high * 0x100000000 + low, - payload: bytes.subarray(HEADER_BYTES) + payload: bytes.slice(HEADER_BYTES) } } export function decodeTerminalStreamJson(payload: Uint8Array): T | null { try { - return parseMobileJsonTextWithinLimits(new TextDecoder().decode(payload)) + return JSON.parse(new TextDecoder().decode(payload)) as T } catch { return null } diff --git a/mobile/src/transport/types.ts b/mobile/src/transport/types.ts index ffd4afddd06..d031d11ff08 100644 --- a/mobile/src/transport/types.ts +++ b/mobile/src/transport/types.ts @@ -1,10 +1,5 @@ import { z } from 'zod' import { - PAIRING_CODE_MAX_CHARACTERS, - PAIRING_DEVICE_TOKEN_MAX_CHARACTERS, - PAIRING_ENDPOINT_MAX_CHARACTERS, - PAIRING_INPUT_MAX_CHARACTERS, - PAIRING_PUBLIC_KEY_MAX_CHARACTERS, PairingOfferSchema, type PairingOffer } from '../../../src/shared/mobile-relay-pairing-offer' @@ -15,20 +10,9 @@ import { } from './mobile-relay-host-overlay' import { MobileRelayEndpointSchema } from '../../../src/shared/mobile-relay-credential-contract' -export { - PAIRING_CODE_MAX_CHARACTERS, - PAIRING_DEVICE_TOKEN_MAX_CHARACTERS, - PAIRING_ENDPOINT_MAX_CHARACTERS, - PAIRING_INPUT_MAX_CHARACTERS, - PAIRING_PUBLIC_KEY_MAX_CHARACTERS, - PairingOfferSchema -} +export { PairingOfferSchema } export type { PairingOffer } -export const MOBILE_HOST_ID_MAX_CHARACTERS = 4_096 -export const MOBILE_HOST_NAME_MAX_CHARACTERS = 4_096 -export const MobileHostIdSchema = z.string().min(1).max(MOBILE_HOST_ID_MAX_CHARACTERS) - export type RpcRequest = { id: string deviceToken: string @@ -88,11 +72,11 @@ export type HostProfile = { } export const HostProfileSchema = z.object({ - id: MobileHostIdSchema, - name: z.string().min(1).max(MOBILE_HOST_NAME_MAX_CHARACTERS), - endpoint: z.string().min(1).max(PAIRING_ENDPOINT_MAX_CHARACTERS), - deviceToken: z.string().min(1).max(PAIRING_DEVICE_TOKEN_MAX_CHARACTERS), - publicKeyB64: z.string().min(1).max(PAIRING_PUBLIC_KEY_MAX_CHARACTERS), + id: z.string().min(1), + name: z.string().min(1), + endpoint: z.string().min(1), + deviceToken: z.string().min(1), + publicKeyB64: z.string().min(1), lastConnected: z.number().finite(), endpoints: z.array(MobileAccessEndpointSchema).min(1).max(16).optional(), relayHostId: z @@ -106,10 +90,10 @@ export const HostProfileSchema = z.object({ // deviceToken is held in iOS Keychain via expo-secure-store and joined // in at load time; it must NOT appear in AsyncStorage anymore. export const StoredHostProfileSchema = z.object({ - id: MobileHostIdSchema, - name: z.string().min(1).max(MOBILE_HOST_NAME_MAX_CHARACTERS), - endpoint: z.string().min(1).max(PAIRING_ENDPOINT_MAX_CHARACTERS), - publicKeyB64: z.string().min(1).max(PAIRING_PUBLIC_KEY_MAX_CHARACTERS), + id: z.string().min(1), + name: z.string().min(1), + endpoint: z.string().min(1), + publicKeyB64: z.string().min(1), lastConnected: z.number().finite() }) diff --git a/mobile/src/transport/websocket-payload-bytes.test.ts b/mobile/src/transport/websocket-payload-bytes.test.ts index dc630343b23..4b501957ef5 100644 --- a/mobile/src/transport/websocket-payload-bytes.test.ts +++ b/mobile/src/transport/websocket-payload-bytes.test.ts @@ -1,64 +1,14 @@ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import { websocketPayloadToUint8 } from './websocket-payload-bytes' describe('websocketPayloadToUint8', () => { it('returns null when a blob-like payload rejects arrayBuffer conversion', async () => { await expect( websocketPayloadToUint8({ - size: 0, arrayBuffer: async () => { throw new Error('conversion failed') } }) ).resolves.toBeNull() }) - - it('rejects a declared oversized payload before starting binary conversion', async () => { - const arrayBuffer = vi.fn(async () => new ArrayBuffer(1)) - - await expect(websocketPayloadToUint8({ size: 5, arrayBuffer }, 4)).rejects.toThrow( - 'exceeds inbound frame limit' - ) - expect(arrayBuffer).not.toHaveBeenCalled() - }) - - it('accepts an exact declared size and verifies the converted result', async () => { - const arrayBuffer = vi.fn(async () => new ArrayBuffer(4)) - - await expect(websocketPayloadToUint8({ size: 4, arrayBuffer }, 4)).resolves.toEqual( - new Uint8Array(4) - ) - expect(arrayBuffer).toHaveBeenCalledOnce() - }) - - it('rejects an unknown-size payload before starting binary conversion', async () => { - const arrayBuffer = vi.fn(async () => new ArrayBuffer(4)) - - await expect(websocketPayloadToUint8({ arrayBuffer }, 4)).rejects.toThrow( - 'exceeds inbound frame limit' - ) - expect(arrayBuffer).not.toHaveBeenCalled() - }) - - it.each([Number.NaN, -1, Number.POSITIVE_INFINITY])( - 'rejects invalid declared size %s before starting binary conversion', - async (size) => { - const arrayBuffer = vi.fn(async () => new ArrayBuffer(4)) - - await expect(websocketPayloadToUint8({ size, arrayBuffer }, 4)).rejects.toThrow( - 'exceeds inbound frame limit' - ) - expect(arrayBuffer).not.toHaveBeenCalled() - } - ) - - it('preserves direct Uint8Array and ArrayBuffer conversion', async () => { - const uint8 = new Uint8Array([1, 2, 3]) - const arrayBuffer = Uint8Array.from([4, 5, 6]).buffer - - await expect(websocketPayloadToUint8(uint8, 3)).resolves.toBe(uint8) - await expect(websocketPayloadToUint8(arrayBuffer, 3)).resolves.toEqual( - new Uint8Array([4, 5, 6]) - ) - }) }) diff --git a/mobile/src/transport/websocket-payload-bytes.ts b/mobile/src/transport/websocket-payload-bytes.ts index af4b1685dd3..1cd8ac5cdd8 100644 --- a/mobile/src/transport/websocket-payload-bytes.ts +++ b/mobile/src/transport/websocket-payload-bytes.ts @@ -1,48 +1,23 @@ -import { - assertMobileInboundFrameSize, - MOBILE_INBOUND_MAX_FRAME_BYTES -} from './mobile-inbound-frame-queue' - -const OVERSIZED_BINARY_MESSAGE = 'WebSocket binary payload exceeds inbound frame limit' - -export async function websocketPayloadToUint8( - value: unknown, - maxBytes = MOBILE_INBOUND_MAX_FRAME_BYTES -): Promise { - assertMobileInboundFrameSize(value, OVERSIZED_BINARY_MESSAGE, maxBytes) +export async function websocketPayloadToUint8(value: unknown): Promise { if (value instanceof Uint8Array) { - return enforceConvertedLimit(value, maxBytes) + return value } if (value instanceof ArrayBuffer) { - return enforceConvertedLimit(new Uint8Array(value), maxBytes) + return new Uint8Array(value) } if (value && typeof value === 'object' && 'arrayBuffer' in value) { - const declaredBytes = declaredBinaryPayloadBytes(value) - if (declaredBytes === null || declaredBytes > maxBytes) { - throw new Error(OVERSIZED_BINARY_MESSAGE) - } const blob = value as { arrayBuffer: () => Promise } - let buffer: ArrayBuffer try { - buffer = await blob.arrayBuffer() + return new Uint8Array(await blob.arrayBuffer()) } catch { return null } - return enforceConvertedLimit(new Uint8Array(buffer), maxBytes) } if (typeof FileReader !== 'undefined' && value instanceof Blob) { - return new Promise((resolve, reject) => { + return new Promise((resolve) => { const reader = new FileReader() reader.onload = () => { - if (!(reader.result instanceof ArrayBuffer)) { - resolve(null) - return - } - try { - resolve(enforceConvertedLimit(new Uint8Array(reader.result), maxBytes)) - } catch (error) { - reject(error) - } + resolve(reader.result instanceof ArrayBuffer ? new Uint8Array(reader.result) : null) } reader.onerror = () => resolve(null) reader.readAsArrayBuffer(value) @@ -50,23 +25,3 @@ export async function websocketPayloadToUint8( } return null } - -function declaredBinaryPayloadBytes(value: object): number | null { - for (const key of ['size', 'byteLength'] as const) { - if (!(key in value)) { - continue - } - const candidate = (value as Record)[key] - if (typeof candidate === 'number' && Number.isFinite(candidate) && candidate >= 0) { - return candidate - } - } - return null -} - -function enforceConvertedLimit(bytes: Uint8Array, maxBytes: number): Uint8Array { - if (bytes.byteLength > maxBytes) { - throw new Error(OVERSIZED_BINARY_MESSAGE) - } - return bytes -} diff --git a/mobile/src/worktree/last-visited-worktree-repo.test.ts b/mobile/src/worktree/last-visited-worktree-repo.test.ts index 1860871603d..4d484fbe715 100644 --- a/mobile/src/worktree/last-visited-worktree-repo.test.ts +++ b/mobile/src/worktree/last-visited-worktree-repo.test.ts @@ -1,11 +1,5 @@ -import { describe, expect, it, vi } from 'vitest' -import { - LAST_VISITED_WORKTREE_MAX_ID_CHARACTERS, - LAST_VISITED_WORKTREE_MAX_STORAGE_CHARACTERS, - readLastVisitedWorktreeRecord, - readLastVisitedWorktreeRepoId, - serializeLastVisitedWorktreeRecord -} from './last-visited-worktree-repo' +import { describe, expect, it } from 'vitest' +import { readLastVisitedWorktreeRepoId } from './last-visited-worktree-repo' describe('last visited worktree repo', () => { it('extracts the repo id for the current host', () => { @@ -24,37 +18,4 @@ describe('last visited worktree repo', () => { expect(readLastVisitedWorktreeRepoId('{', 'host-1')).toBeNull() expect(readLastVisitedWorktreeRepoId(JSON.stringify({ hostId: 'host-1' }), 'host-1')).toBeNull() }) - - it('round-trips exact field limits and rejects one character more', () => { - const exact = { - hostId: 'h'.repeat(LAST_VISITED_WORKTREE_MAX_ID_CHARACTERS), - worktreeId: 'w'.repeat(LAST_VISITED_WORKTREE_MAX_ID_CHARACTERS) - } - const serialized = serializeLastVisitedWorktreeRecord(exact) - - expect(serialized).not.toBeNull() - expect(readLastVisitedWorktreeRecord(serialized)).toEqual(exact) - expect( - serializeLastVisitedWorktreeRecord({ - ...exact, - worktreeId: `${exact.worktreeId}w` - }) - ).toBeNull() - }) - - it('accepts the exact raw limit and does not parse one character more', () => { - const record = { hostId: 'host-1', worktreeId: 'repo::worktree' } - const serialized = JSON.stringify(record) - const exact = - serialized + ' '.repeat(LAST_VISITED_WORKTREE_MAX_STORAGE_CHARACTERS - serialized.length) - - expect(readLastVisitedWorktreeRecord(exact)).toEqual(record) - - const parse = vi.spyOn(JSON, 'parse') - expect( - readLastVisitedWorktreeRecord('x'.repeat(LAST_VISITED_WORKTREE_MAX_STORAGE_CHARACTERS + 1)) - ).toBeNull() - expect(parse).not.toHaveBeenCalled() - parse.mockRestore() - }) }) diff --git a/mobile/src/worktree/last-visited-worktree-repo.ts b/mobile/src/worktree/last-visited-worktree-repo.ts index c991fe0c90b..4ac9349c9fc 100644 --- a/mobile/src/worktree/last-visited-worktree-repo.ts +++ b/mobile/src/worktree/last-visited-worktree-repo.ts @@ -1,10 +1,8 @@ import { getRepoIdFromMobileWorktreeId } from '../session/mobile-session-route-helpers' export const LAST_VISITED_WORKTREE_STORAGE_KEY = 'orca:last-visited-worktree' -export const LAST_VISITED_WORKTREE_MAX_STORAGE_CHARACTERS = 16 * 1024 -export const LAST_VISITED_WORKTREE_MAX_ID_CHARACTERS = 4_096 -export type LastVisitedWorktreeRecord = { +type LastVisitedWorktreeRecord = { hostId: string worktreeId: string } @@ -13,10 +11,8 @@ function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null } -export function readLastVisitedWorktreeRecord( - raw: string | null -): LastVisitedWorktreeRecord | null { - if (!raw || raw.length > LAST_VISITED_WORKTREE_MAX_STORAGE_CHARACTERS) { +function readLastVisitedWorktreeRecord(raw: string | null): LastVisitedWorktreeRecord | null { + if (!raw) { return null } try { @@ -24,11 +20,7 @@ export function readLastVisitedWorktreeRecord( if ( !isRecord(parsed) || typeof parsed.hostId !== 'string' || - parsed.hostId.length === 0 || - parsed.hostId.length > LAST_VISITED_WORKTREE_MAX_ID_CHARACTERS || - typeof parsed.worktreeId !== 'string' || - parsed.worktreeId.length === 0 || - parsed.worktreeId.length > LAST_VISITED_WORKTREE_MAX_ID_CHARACTERS + typeof parsed.worktreeId !== 'string' ) { return null } @@ -38,24 +30,6 @@ export function readLastVisitedWorktreeRecord( } } -export function serializeLastVisitedWorktreeRecord( - record: LastVisitedWorktreeRecord -): string | null { - if ( - record.hostId.length === 0 || - record.hostId.length > LAST_VISITED_WORKTREE_MAX_ID_CHARACTERS || - record.worktreeId.length === 0 || - record.worktreeId.length > LAST_VISITED_WORKTREE_MAX_ID_CHARACTERS - ) { - return null - } - const serialized = JSON.stringify(record) - if (serialized.length > LAST_VISITED_WORKTREE_MAX_STORAGE_CHARACTERS) { - return null - } - return serialized -} - export function readLastVisitedWorktreeRepoId(raw: string | null, hostId: string): string | null { const record = readLastVisitedWorktreeRecord(raw) if (!record || record.hostId !== hostId) { diff --git a/package.json b/package.json index de4a48b0bd8..ae3f9afa536 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,6 @@ "smoke:computer": "node config/scripts/computer-use-smoke.mjs", "verify:computer-native": "node config/scripts/verify-computer-native.mjs", "verify:cli-bin": "node config/scripts/verify-cli-bin.mjs", - "verify:cli-runtime": "node config/scripts/verify-cli-bin.mjs --run-help", "verify:localization-catalog": "node config/scripts/verify-localization-catalog.mjs", "sync:localization-catalog": "node config/scripts/verify-localization-catalog.mjs --fix", "bootstrap:locale-catalog": "node config/scripts/bootstrap-locale-catalog.mjs", @@ -66,9 +65,9 @@ "build:cli": "tsc -p config/tsconfig.cli.json --outDir out --composite false --incremental false && node config/scripts/verify-cli-bin.mjs --fix-executable --fix-package-json && node config/scripts/install-dev-cli.mjs", "build:electron-vite": "node config/scripts/run-electron-vite-build.mjs", "build:web": "node config/scripts/run-vite-web-build.mjs && node config/scripts/verify-web-build.mjs", - "build:desktop": "pnpm run typecheck && pnpm run build:relay && pnpm run build:cli && pnpm run build:electron-vite && pnpm run verify:cli-runtime && pnpm run build:web", + "build:desktop": "pnpm run typecheck && pnpm run build:relay && pnpm run build:cli && pnpm run build:electron-vite && pnpm run build:web", "build": "pnpm run build:desktop && pnpm run build:native", - "build:release": "pnpm run build:relay && pnpm run build:native && pnpm run verify:computer-native && pnpm run build:cli && pnpm run build:electron-vite && pnpm run verify:cli-runtime && pnpm run build:web", + "build:release": "pnpm run build:relay && pnpm run build:native && pnpm run verify:computer-native && pnpm run build:cli && pnpm run build:electron-vite && pnpm run build:web", "postinstall": "node config/scripts/rebuild-native-deps.mjs", "rebuild:electron": "node config/scripts/rebuild-native-deps.mjs", "rebuild:node": "pnpm rebuild node-pty", diff --git a/src/cli/computer-format.ts b/src/cli/computer-format.ts index b2e076edada..c803074055a 100644 --- a/src/cli/computer-format.ts +++ b/src/cli/computer-format.ts @@ -1,6 +1,16 @@ +import { + chmodSync, + lstatSync, + mkdirSync, + readdirSync, + rmSync, + statSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { formatBase64PayloadByteCount } from './base64-payload-byte-count' import { quoteCliCommandArgument } from './shell-command-quote' -import { writeComputerScreenshotTempFile } from './computer-screenshot-storage' import type { ComputerActionMetadata, ComputerActionResult, @@ -62,11 +72,11 @@ export function prepareComputerCliJsonResult( } try { const extension = screenshot.format === 'png' ? 'png' : 'img' - const { outputPath, expiresAt } = writeComputerScreenshotTempFile( - response.id, - screenshot.data, - extension - ) + const outputDir = computerScreenshotTempDir() + cleanupComputerScreenshots(outputDir) + const outputPath = join(outputDir, `${safeCliFileStem(response.id)}-screenshot.${extension}`) + writeFileSync(outputPath, Buffer.from(screenshot.data, 'base64'), { mode: 0o600 }) + const expiresAt = new Date(Date.now() + COMPUTER_SCREENSHOT_TTL_MS).toISOString() return { ...response, result: { @@ -87,6 +97,63 @@ export function prepareComputerCliJsonResult( } } +const COMPUTER_SCREENSHOT_TTL_MS = 24 * 60 * 60 * 1000 +const COMPUTER_SCREENSHOT_CLEANUP_INTERVAL_MS = 60 * 60 * 1000 +const COMPUTER_SCREENSHOT_CLEANUP_MARKER = '.last-cleanup' + +function computerScreenshotTempDir(): string { + const outputDir = + process.env.ORCA_COMPUTER_SCREENSHOT_TMPDIR || join(tmpdir(), 'orca-computer-use') + mkdirSync(outputDir, { recursive: true, mode: 0o700 }) + const stat = lstatSync(outputDir) + if (!stat.isDirectory() || stat.isSymbolicLink()) { + throw new Error(`Unsafe computer screenshot temp path: ${outputDir}`) + } + if (typeof process.getuid === 'function' && stat.uid !== process.getuid()) { + throw new Error(`Computer screenshot temp path is not owned by the current user: ${outputDir}`) + } + chmodSync(outputDir, 0o700) + return outputDir +} + +function cleanupComputerScreenshots(outputDir: string): void { + const now = Date.now() + const markerPath = join(outputDir, COMPUTER_SCREENSHOT_CLEANUP_MARKER) + try { + // Why: agents can call computer-use CLI commands in loops; a marker keeps + // temp cleanup from becoming a synchronous directory scan per screenshot. + if (statSync(markerPath).mtimeMs > now - COMPUTER_SCREENSHOT_CLEANUP_INTERVAL_MS) { + return + } + } catch { + // Missing or unreadable marker means this process should attempt cleanup. + } + + const cutoff = now - COMPUTER_SCREENSHOT_TTL_MS + for (const entry of readdirSync(outputDir)) { + if (!entry.endsWith('-screenshot.png') && !entry.endsWith('-screenshot.img')) { + continue + } + const path = join(outputDir, entry) + try { + if (statSync(path).mtimeMs < cutoff) { + rmSync(path, { force: true }) + } + } catch { + // Best-effort cleanup only; formatting should not fail because a temp file raced. + } + } + try { + writeFileSync(markerPath, `${now}\n`, { mode: 0o600 }) + } catch { + // Best-effort marker only; stale cleanup state should not hide a screenshot. + } +} + +function safeCliFileStem(value: string): string { + return value.replaceAll(/[^a-zA-Z0-9._-]/g, '_') +} + export function formatListApps(result: ComputerListAppsResult): string { if (result.apps.length === 0) { return 'No apps found.' diff --git a/src/cli/computer-screenshot-storage.test.ts b/src/cli/computer-screenshot-storage.test.ts deleted file mode 100644 index 0ee2aa968e1..00000000000 --- a/src/cli/computer-screenshot-storage.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { afterEach, describe, expect, it } from 'vitest' -import { existsSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { writeComputerScreenshotTempFile } from './computer-screenshot-storage' - -const roots: string[] = [] -const originalTempDir = process.env.ORCA_COMPUTER_SCREENSHOT_TMPDIR - -afterEach(() => { - if (originalTempDir === undefined) { - delete process.env.ORCA_COMPUTER_SCREENSHOT_TMPDIR - } else { - process.env.ORCA_COMPUTER_SCREENSHOT_TMPDIR = originalTempDir - } - for (const root of roots.splice(0)) { - rmSync(root, { recursive: true, force: true }) - } -}) - -describe('computer screenshot storage', () => { - it('writes normal screenshot bytes and streams cleanup of expired captures', () => { - const root = mkdtempSync(join(tmpdir(), 'orca-computer-screenshot-')) - roots.push(root) - process.env.ORCA_COMPUTER_SCREENSHOT_TMPDIR = root - const expiredPath = join(root, 'expired-screenshot.png') - const unrelatedPath = join(root, 'keep.txt') - writeFileSync(expiredPath, 'old') - writeFileSync(unrelatedPath, 'keep') - const expiredAt = new Date(Date.now() - 25 * 60 * 60 * 1000) - utimesSync(expiredPath, expiredAt, expiredAt) - - const result = writeComputerScreenshotTempFile( - 'rpc/id', - Buffer.from('png').toString('base64'), - 'png' - ) - - expect(result.outputPath).toBe(join(root, 'rpc_id-screenshot.png')) - expect(existsSync(result.outputPath)).toBe(true) - expect(existsSync(expiredPath)).toBe(false) - expect(existsSync(unrelatedPath)).toBe(true) - }) -}) diff --git a/src/cli/computer-screenshot-storage.ts b/src/cli/computer-screenshot-storage.ts deleted file mode 100644 index 8c47a89d189..00000000000 --- a/src/cli/computer-screenshot-storage.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { - chmodSync, - lstatSync, - mkdirSync, - opendirSync, - rmSync, - statSync, - writeFileSync -} from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' - -const COMPUTER_SCREENSHOT_TTL_MS = 24 * 60 * 60 * 1000 -const COMPUTER_SCREENSHOT_CLEANUP_INTERVAL_MS = 60 * 60 * 1000 -const COMPUTER_SCREENSHOT_CLEANUP_MARKER = '.last-cleanup' - -export function writeComputerScreenshotTempFile( - responseId: string, - base64Data: string, - extension: 'png' | 'img' -): { outputPath: string; expiresAt: string } { - const outputDir = computerScreenshotTempDir() - cleanupComputerScreenshots(outputDir) - const outputPath = join(outputDir, `${safeCliFileStem(responseId)}-screenshot.${extension}`) - writeFileSync(outputPath, Buffer.from(base64Data, 'base64'), { mode: 0o600 }) - return { - outputPath, - expiresAt: new Date(Date.now() + COMPUTER_SCREENSHOT_TTL_MS).toISOString() - } -} - -function computerScreenshotTempDir(): string { - const outputDir = - process.env.ORCA_COMPUTER_SCREENSHOT_TMPDIR || join(tmpdir(), 'orca-computer-use') - mkdirSync(outputDir, { recursive: true, mode: 0o700 }) - const stat = lstatSync(outputDir) - if (!stat.isDirectory() || stat.isSymbolicLink()) { - throw new Error(`Unsafe computer screenshot temp path: ${outputDir}`) - } - if (typeof process.getuid === 'function' && stat.uid !== process.getuid()) { - throw new Error(`Computer screenshot temp path is not owned by the current user: ${outputDir}`) - } - chmodSync(outputDir, 0o700) - return outputDir -} - -function cleanupComputerScreenshots(outputDir: string): void { - const now = Date.now() - const markerPath = join(outputDir, COMPUTER_SCREENSHOT_CLEANUP_MARKER) - try { - // Why: agents can call computer-use CLI commands in loops; a marker keeps - // temp cleanup from becoming a synchronous directory scan per screenshot. - if (statSync(markerPath).mtimeMs > now - COMPUTER_SCREENSHOT_CLEANUP_INTERVAL_MS) { - return - } - } catch { - // Missing or unreadable marker means this process should attempt cleanup. - } - - const cutoff = now - COMPUTER_SCREENSHOT_TTL_MS - const directory = opendirSync(outputDir) - try { - while (true) { - const entry = directory.readSync() - if (!entry) { - break - } - if (!entry.name.endsWith('-screenshot.png') && !entry.name.endsWith('-screenshot.img')) { - continue - } - const path = join(outputDir, entry.name) - try { - if (statSync(path).mtimeMs < cutoff) { - rmSync(path, { force: true }) - } - } catch { - // Best-effort cleanup only; formatting should not fail because a temp file raced. - } - } - } finally { - try { - directory.closeSync() - } catch { - // Best-effort cleanup only. - } - } - try { - writeFileSync(markerPath, `${now}\n`, { mode: 0o600 }) - } catch { - // Best-effort marker only; stale cleanup state should not hide a screenshot. - } -} - -function safeCliFileStem(value: string): string { - return value.replaceAll(/[^a-zA-Z0-9._-]/g, '_') -} diff --git a/src/cli/handlers/agent-hooks.test.ts b/src/cli/handlers/agent-hooks.test.ts index d1b92d44d8a..4ae4c962d59 100644 --- a/src/cli/handlers/agent-hooks.test.ts +++ b/src/cli/handlers/agent-hooks.test.ts @@ -1,10 +1,9 @@ -import { mkdirSync, mkdtempSync, readFileSync, rmSync, truncateSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { getDefaultPersistedState } from '../../shared/constants' import type { PersistedState } from '../../shared/types' -import { ORCA_PERSISTED_STATE_MAX_BYTES } from '../../shared/persisted-state-file-bounds' const { applyAgentStatusHooksEnabledMock, @@ -46,12 +45,9 @@ vi.mock('../runtime-client', () => { } } - class RuntimeRpcFailureError extends Error {} - return { RuntimeClient, RuntimeClientError, - RuntimeRpcFailureError, getDefaultUserDataPath: getDefaultUserDataPathMock } }) @@ -124,16 +120,4 @@ describe('agent hooks CLI handler', () => { expect(readDataFile(userDataPath).settings.experimentalNewWorktreeCardStyle).toBe(true) }) - - it('rejects an oversized sparse offline state file without replacing it', async () => { - const dataPath = join(userDataPath, 'orca-data.json') - writeFileSync(dataPath, '') - truncateSync(dataPath, ORCA_PERSISTED_STATE_MAX_BYTES + 1) - - await runAgentHooksOff(userDataPath) - - expect(process.exitCode).toBe(1) - expect(console.log).toHaveBeenCalledWith(expect.stringContaining('File too large')) - expect(readFileSync(dataPath).byteLength).toBe(ORCA_PERSISTED_STATE_MAX_BYTES + 1) - }) }) diff --git a/src/cli/handlers/agent-hooks.ts b/src/cli/handlers/agent-hooks.ts index 8ef4d138b8e..2b83f5c15ea 100644 --- a/src/cli/handlers/agent-hooks.ts +++ b/src/cli/handlers/agent-hooks.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdirSync, renameSync, unlinkSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs' import { homedir } from 'node:os' import { dirname, join } from 'node:path' import { randomUUID } from 'node:crypto' @@ -13,11 +13,6 @@ import { getManagedAgentHookStatuses } from '../../main/agent-hooks/managed-agent-hook-controls' import { getDefaultUserDataPath } from '../runtime-client' -import { - ORCA_PERSISTED_STATE_MAX_BYTES, - readPersistedStateJsonFileSync, - stringifyPrettyPersistedStateWithinLimit -} from '../../shared/persisted-state-file-bounds' type AgentHookCommandResult = { enabled: boolean @@ -39,7 +34,7 @@ function readPersistedState(dataPath: string): PersistedState { return getDefaultPersistedState(homedir()) } try { - const { value: parsed } = readPersistedStateJsonFileSync(dataPath) + const parsed = JSON.parse(readFileSync(dataPath, 'utf-8')) if (!isRecord(parsed)) { throw new Error('file does not contain a JSON object') } @@ -57,11 +52,7 @@ function writePersistedState(dataPath: string, state: PersistedState): void { const tmpPath = join(dirname(dataPath), `.${Date.now()}-${randomUUID()}.tmp`) let renamed = false try { - const { serialized } = stringifyPrettyPersistedStateWithinLimit( - state, - ORCA_PERSISTED_STATE_MAX_BYTES - 1 - ) - writeFileSync(tmpPath, `${serialized}\n`, 'utf-8') + writeFileSync(tmpPath, `${JSON.stringify(state, null, 2)}\n`, 'utf-8') renameSync(tmpPath, dataPath) renamed = true } finally { diff --git a/src/cli/handlers/computer-action-flags.ts b/src/cli/handlers/computer-action-flags.ts index 8c771ec8f62..384c1dc0d80 100644 --- a/src/cli/handlers/computer-action-flags.ts +++ b/src/cli/handlers/computer-action-flags.ts @@ -2,11 +2,6 @@ import { computerUseHotkeyValidationMessage, computerUsePressKeyValidationMessage } from '../../shared/computer-use-key-spec' -import { CLIPBOARD_TEXT_WRITE_MAX_BYTES } from '../../shared/clipboard-text' -import { - NodeReadableTextTooLargeError, - readNodeReadableTextWithinLimit -} from '../../shared/node-readable-text' import { getOptionalNonNegativeIntegerFlag, getOptionalNumberFlag, @@ -194,17 +189,11 @@ async function readStdin(): Promise { if (process.stdin.isTTY) { throw new RuntimeClientError('invalid_argument', 'stdin payload requested but stdin is a TTY') } - try { - return await readNodeReadableTextWithinLimit(process.stdin, CLIPBOARD_TEXT_WRITE_MAX_BYTES) - } catch (error) { - if (error instanceof NodeReadableTextTooLargeError) { - throw new RuntimeClientError( - 'invalid_argument', - `stdin payload must be at most ${CLIPBOARD_TEXT_WRITE_MAX_BYTES} bytes` - ) - } - throw error + const chunks: Buffer[] = [] + for await (const chunk of process.stdin) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk))) } + return Buffer.concat(chunks).toString('utf8') } function getOptionalPositiveNumberFlag( diff --git a/src/cli/handlers/orchestration.ts b/src/cli/handlers/orchestration.ts index c631ecfa3db..cdafcd66743 100644 --- a/src/cli/handlers/orchestration.ts +++ b/src/cli/handlers/orchestration.ts @@ -79,25 +79,6 @@ type OrchestrationSendResult = | { message: { id: string }; lifecycle?: LifecycleSendRejection } | { messages: { id: string }[]; recipients: number } -type OrchestrationRetentionPage = { - count: number - truncated?: boolean - total?: number - remaining?: number -} - -function appendOrchestrationRetentionNotice( - body: string, - page: OrchestrationRetentionPage, - noun: string -): string { - if (!page.truncated) { - return body - } - const omitted = page.remaining ?? Math.max(0, (page.total ?? page.count) - page.count) - return `${body}\n\nTruncated: ${omitted} additional ${noun} omitted.` -} - function getOptionalStructuredMessagePayload( flags: Map ): string | undefined { @@ -418,8 +399,6 @@ export const ORCHESTRATION_HANDLERS: Record = { messages: MessageSummary[] count: number formatted?: string - truncated?: boolean - remaining?: number } let result: Awaited>> try { @@ -466,17 +445,15 @@ export const ORCHESTRATION_HANDLERS: Record = { } } printResult(result, json, (r) => { - let body: string if (r.formatted) { - body = r.formatted - } else if (r.count === 0) { - body = 'No messages.' - } else { - body = r.messages - .map((m) => `${m.id} [${m.type ?? 'status'}] from=${m.from_handle} "${m.subject}"`) - .join('\n') + return r.formatted } - return appendOrchestrationRetentionNotice(body, r, 'messages') + if (r.count === 0) { + return 'No messages.' + } + return r.messages + .map((m) => `${m.id} [${m.type ?? 'status'}] from=${m.from_handle} "${m.subject}"`) + .join('\n') }) }, @@ -495,34 +472,31 @@ export const ORCHESTRATION_HANDLERS: Record = { const result = await client.call<{ messages: MessageSummary[] count: number - total?: number - truncated?: boolean }>('orchestration.inbox', { limit: getOptionalPositiveIntegerFlag(flags, 'limit'), terminal: getOptionalStringFlag(flags, 'terminal') }) printResult(result, json, (r) => { + if (r.count === 0) { + return 'No messages.' + } // Why: default output omits body/payload for at-a-glance sweeps; --full prints them for auditing. - const body = - r.count === 0 - ? 'No messages.' - : r.messages - .map((m) => { - const head = `${m.id} ${m.from_handle} -> ${m.to_handle ?? '?'}: "${m.subject}"` - if (!full) { - return head - } - const parts = [head] - if (m.body && m.body.length > 0) { - parts.push(m.body) - } - if (m.payload) { - parts.push(`[payload] ${m.payload}`) - } - return parts.join('\n') - }) - .join(full ? '\n\n' : '\n') - return appendOrchestrationRetentionNotice(body, r, 'messages') + return r.messages + .map((m) => { + const head = `${m.id} ${m.from_handle} -> ${m.to_handle ?? '?'}: "${m.subject}"` + if (!full) { + return head + } + const parts = [head] + if (m.body && m.body.length > 0) { + parts.push(m.body) + } + if (m.payload) { + parts.push(`[payload] ${m.payload}`) + } + return parts.join('\n') + }) + .join(full ? '\n\n' : '\n') }) }, @@ -556,8 +530,6 @@ export const ORCHESTRATION_HANDLERS: Record = { spec_truncated?: boolean }[] count: number - total?: number - truncated?: boolean }>('orchestration.taskList', { status: getOptionalStringFlag(flags, 'status'), ready: flags.has('ready') ? true : undefined, @@ -573,20 +545,19 @@ export const ORCHESTRATION_HANDLERS: Record = { } : result printResult(output, json, (r) => { - const body = - r.count === 0 - ? 'No tasks.' - : r.tasks - .map((t) => { - const label = t.display_name ?? t.task_title ?? t.spec - const head = `${t.id} [${t.status}] ${label.slice(0, 60)}` - if (t.status === 'dispatched' && t.assignee_handle) { - return `${head} -> ${t.assignee_handle} (${t.dispatch_id ?? '?'})` - } - return head - }) - .join('\n') - return appendOrchestrationRetentionNotice(body, r, 'tasks') + if (r.count === 0) { + return 'No tasks.' + } + return r.tasks + .map((t) => { + const label = t.display_name ?? t.task_title ?? t.spec + const head = `${t.id} [${t.status}] ${label.slice(0, 60)}` + if (t.status === 'dispatched' && t.assignee_handle) { + return `${head} -> ${t.assignee_handle} (${t.dispatch_id ?? '?'})` + } + return head + }) + .join('\n') }) }, @@ -751,18 +722,17 @@ export const ORCHESTRATION_HANDLERS: Record = { const result = await client.call<{ gates: { id: string; task_id: string; question: string; status: string }[] count: number - total?: number - truncated?: boolean }>('orchestration.gateList', { task: getOptionalStringFlag(flags, 'task'), status: getOptionalStringFlag(flags, 'status') }) printResult(result, json, (r) => { - const body = - r.gates.length === 0 - ? 'No gates found.' - : r.gates.map((g) => `${g.id} task=${g.task_id} [${g.status}] "${g.question}"`).join('\n') - return appendOrchestrationRetentionNotice(body, r, 'gates') + if (r.gates.length === 0) { + return 'No gates found.' + } + return r.gates + .map((g) => `${g.id} task=${g.task_id} [${g.status}] "${g.question}"`) + .join('\n') }) }, diff --git a/src/cli/handlers/vm.ts b/src/cli/handlers/vm.ts index 39c4062ce8a..aaf01b08872 100644 --- a/src/cli/handlers/vm.ts +++ b/src/cli/handlers/vm.ts @@ -1,4 +1,4 @@ -import { existsSync } from 'node:fs' +import { existsSync, readFileSync } from 'node:fs' import { join } from 'node:path' import type { CommandHandler } from '../dispatch' import { RuntimeClientError } from '../runtime-client' @@ -18,8 +18,6 @@ import { runEphemeralVmRecipeStart } from '../../shared/ephemeral-vm-recipe-runner' import type { OrcaVmRecipe } from '../../shared/types' -import { readNodeFileSyncWithinLimit } from '../../shared/node-bounded-file-reader' -import { MAX_ORCA_YAML_BYTES } from '../../shared/orca-yaml-file-limit' export const VM_HANDLERS: Record = { 'vm recipe doctor': async ({ flags, cwd, json }) => { @@ -82,7 +80,7 @@ function doctorRecipe(repoPath: string, recipeId: string): DoctorResult { } function readTextFile(path: string): string { - return readNodeFileSyncWithinLimit(path, MAX_ORCA_YAML_BYTES).buffer.toString('utf8') + return readFileSync(path, 'utf8') } // Why: give the agent the full create/destroy output so it can self-diagnose a diff --git a/src/cli/index.test.ts b/src/cli/index.test.ts index f6c072e6c85..f61b6fc09ff 100644 --- a/src/cli/index.test.ts +++ b/src/cli/index.test.ts @@ -1,9 +1,8 @@ /* eslint-disable max-lines -- Why: CLI parser tests share one mocked runtime client and fixture queue; splitting this file would duplicate setup and make command coverage harder to audit. */ import path from 'node:path' -import { chmodSync, mkdtempSync, rmSync, truncateSync, writeFileSync, mkdirSync } from 'node:fs' +import { chmodSync, mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'node:fs' import { tmpdir } from 'node:os' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { MAX_ORCA_YAML_BYTES } from '../shared/orca-yaml-file-limit' const { callMock, @@ -2136,24 +2135,6 @@ describe('orca cli worktree awareness', () => { } }) - it('rejects an oversized sparse orca.yaml during vm recipe doctor', async () => { - const repoPath = mkdtempSync(path.join(tmpdir(), 'orca-vm-doctor-bounds-')) - try { - const yamlPath = path.join(repoPath, 'orca.yaml') - writeFileSync(yamlPath, '') - truncateSync(yamlPath, MAX_ORCA_YAML_BYTES + 1) - const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - - await main(['vm', 'recipe', 'doctor', 'bounded', '--repo-path', repoPath, '--json']) - - expect(process.exitCode).toBe(1) - expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('File too large')) - expect(callMock).not.toHaveBeenCalled() - } finally { - rmSync(repoPath, { recursive: true, force: true }) - } - }) - it('warns when vm recipe doctor finds no cleanup hook', async () => { const repoPath = mkdtempSync(path.join(tmpdir(), 'orca-vm-doctor-')) try { diff --git a/src/cli/linear-request-builders.ts b/src/cli/linear-request-builders.ts index cc8b2e5d27f..34d1ee9d473 100644 --- a/src/cli/linear-request-builders.ts +++ b/src/cli/linear-request-builders.ts @@ -1,3 +1,4 @@ +import { readFile } from 'node:fs/promises' import { isAbsolute, join } from 'node:path' import type { LinearIssueInclude, @@ -20,16 +21,6 @@ import { getRequiredStringFlagAllowingEmpty } from './flags' import { RuntimeClientError } from './runtime-client' -import { - NodeReadableTextTooLargeError, - readNodeReadableTextWithinLimit -} from '../shared/node-readable-text' -import { - NodeFileReadTooLargeError, - readNodeFileWithinLimit -} from '../shared/node-bounded-file-reader' - -const LINEAR_WRITE_BODY_MAX_BYTES = LINEAR_WRITE_BODY_CAP * 4 const LINEAR_PRIORITY_VALUES = new Map([ ['none', 0], @@ -271,35 +262,14 @@ export async function readLinearBody( async function readLinearBodyFile(path: string, cwd: string): Promise { if (path !== '-') { - try { - const { buffer } = await readNodeFileWithinLimit( - isAbsolute(path) ? path : join(cwd, path), - LINEAR_WRITE_BODY_MAX_BYTES - ) - return buffer.toString('utf8') - } catch (error) { - if (error instanceof NodeFileReadTooLargeError) { - throw linearBodyTooLargeError() - } - throw error - } + return await readFile(isAbsolute(path) ? path : join(cwd, path), 'utf8') } if (process.stdin.isTTY) { throw new RuntimeClientError('invalid_argument', 'stdin body requested but stdin is a TTY') } - try { - return await readNodeReadableTextWithinLimit(process.stdin, LINEAR_WRITE_BODY_MAX_BYTES) - } catch (error) { - if (error instanceof NodeReadableTextTooLargeError) { - throw linearBodyTooLargeError() - } - throw error + const chunks: Buffer[] = [] + for await (const chunk of process.stdin) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk))) } -} - -function linearBodyTooLargeError(): RuntimeClientError { - return new RuntimeClientError( - 'linear_body_too_large', - `Linear body must be at most ${LINEAR_WRITE_BODY_CAP} characters` - ) + return Buffer.concat(chunks).toString('utf8') } diff --git a/src/cli/runtime/control-file-bounds.test.ts b/src/cli/runtime/control-file-bounds.test.ts deleted file mode 100644 index 3649b2d0780..00000000000 --- a/src/cli/runtime/control-file-bounds.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { existsSync, mkdtempSync, readFileSync, rmSync, truncateSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import { - getRuntimeMetadataPath, - MAX_RUNTIME_METADATA_FILE_BYTES -} from '../../shared/runtime-bootstrap' -import { - getServeUpdateHandoffPath, - MAX_SERVE_UPDATE_HANDOFF_FILE_BYTES -} from '../../shared/serve-update-handoff' -import { readMetadata, tryReadMetadata } from './metadata' -import { - readServeUpdateHandoff, - readServeUpdateHandoffSync, - recordServeUpdateHandoffFailure -} from './serve-update-supervisor' -import { RuntimeClientError } from './types' - -describe('CLI runtime control-file bounds', () => { - const paths: string[] = [] - - afterEach(() => { - for (const path of paths.splice(0)) { - rmSync(path, { recursive: true, force: true }) - } - }) - - function makeRoot(): string { - const path = mkdtempSync(join(tmpdir(), 'orca-cli-control-file-bound-')) - paths.push(path) - return path - } - - it('reports oversized runtime metadata as unavailable', () => { - const root = makeRoot() - const metadataPath = getRuntimeMetadataPath(root) - writeFileSync(metadataPath, '{"runtimeId":"runtime-1"}') - truncateSync(metadataPath, MAX_RUNTIME_METADATA_FILE_BYTES + 1) - - expect(tryReadMetadata(root)).toBeNull() - expect(() => readMetadata(root)).toThrow(RuntimeClientError) - }) - - it('ignores oversized serve-update handoff state in sync and async readers', async () => { - const handoffPath = getServeUpdateHandoffPath(makeRoot()) - writeFileSync(handoffPath, '{"schemaVersion":1}') - truncateSync(handoffPath, MAX_SERVE_UPDATE_HANDOFF_FILE_BYTES + 1) - - expect(readServeUpdateHandoffSync(handoffPath)).toBeNull() - await expect(readServeUpdateHandoff(handoffPath)).resolves.toBeNull() - }) - - it('preserves the prior handoff when bounded transactional serialization fails', async () => { - const handoffPath = getServeUpdateHandoffPath(makeRoot()) - const initial = { - schemaVersion: 1 as const, - phase: 'install-requested' as const, - fromVersion: '1.0.0', - targetVersion: '1.0.1', - servingPid: process.pid - } - writeFileSync(handoffPath, JSON.stringify(initial)) - - await expect( - recordServeUpdateHandoffFailure( - handoffPath, - initial, - 'x'.repeat(MAX_SERVE_UPDATE_HANDOFF_FILE_BYTES + 1) - ) - ).rejects.toThrow(`JSON output exceeds ${MAX_SERVE_UPDATE_HANDOFF_FILE_BYTES} bytes`) - - expect(JSON.parse(readFileSync(handoffPath, 'utf8'))).toEqual(initial) - expect(existsSync(`${handoffPath}.${process.pid}.tmp`)).toBe(false) - }) -}) diff --git a/src/cli/runtime/launch.test.ts b/src/cli/runtime/launch.test.ts index cc8c33c7cf8..235b2b2ed56 100644 --- a/src/cli/runtime/launch.test.ts +++ b/src/cli/runtime/launch.test.ts @@ -519,33 +519,6 @@ describe('serveOrcaApp', () => { expect(child.unref).toHaveBeenCalledOnce() }) - it('preserves recipe JSON after 100,000 one-byte fragments', async () => { - const { child, result, stdoutSpy } = startRecipeJsonServer() - const output = Buffer.from(`${' '.repeat(100_000)}${RECIPE_JSON}\n`) - queueMicrotask(() => { - for (let index = 0; index < output.byteLength; index += 1) { - child.stdout.emit('data', output.subarray(index, index + 1)) - } - }) - - await expect(result).resolves.toBe(0) - expect(stdoutSpy).toHaveBeenCalledWith(`${RECIPE_JSON}\n`) - }) - - it('kills a server that emits an oversized recipe line without a newline', async () => { - const { child, result } = startRecipeJsonServer() - queueMicrotask(() => { - child.stdout.emit('data', Buffer.alloc(4 * 1024 * 1024 + 1, 0x78)) - }) - - await expect(result).rejects.toMatchObject({ - code: 'runtime_serve_failed', - message: 'Recipe JSON output exceeded 4194304 byte line limit.' - }) - expect(child.kill).toHaveBeenCalledWith('SIGTERM') - expect(child.stdout.listenerCount('data')).toBe(0) - }) - it('rejects when the server exits without valid recipe JSON', async () => { const { child, result, stdoutSpy, stderrSpy } = startRecipeJsonServer() const secrets = ['UPPER-SECRET', 'SLASH-SECRET', 'LEGACY-SECRET', 'PRIVATE-SECRET'] diff --git a/src/cli/runtime/launch.ts b/src/cli/runtime/launch.ts index 9bdd9f04252..bd7d939be5a 100644 --- a/src/cli/runtime/launch.ts +++ b/src/cli/runtime/launch.ts @@ -1,12 +1,16 @@ import { spawn as spawnProcess, type SpawnOptions } from 'node:child_process' import { resolve } from 'node:path' +import { StringDecoder } from 'node:string_decoder' import { SERVE_UPDATE_HANDOFF_PATH_ENV, getServeUpdateHandoffPath } from '../../shared/serve-update-handoff' +import { + getEphemeralVmRecipeResultConnection, + parseEphemeralVmRecipeResult +} from '../../shared/ephemeral-vm-recipes' import { getDefaultUserDataPath } from './metadata' import { getMacAppBundlePath } from './mac-app-update-bundle' -import { waitForRecipeJson } from './recipe-json-output' import { readServeUpdateHandoffSync, resumeInterruptedServeUpdate, @@ -14,6 +18,8 @@ import { } from './serve-update-supervisor' import { RuntimeClientError } from './types' +const IGNORED_NON_RECIPE_STDOUT = '[serve] ignored non-recipe stdout' + export function launchOrcaApp(): void { const overrideCommand = process.env.ORCA_OPEN_COMMAND if (typeof overrideCommand === 'string' && overrideCommand.trim().length > 0) { @@ -159,6 +165,97 @@ export function serveOrcaApp( }) } +function waitForRecipeJson(child: ReturnType): Promise { + return new Promise((resolve, reject) => { + let output = '' + let settled = false + const timeout = setTimeout(() => { + finish(new RuntimeClientError('runtime_serve_failed', 'Timed out waiting for recipe JSON.')) + child.kill('SIGTERM') + }, 60000) + const finish = (error?: Error): void => { + if (settled) { + return + } + settled = true + clearTimeout(timeout) + child.stdout?.off('data', onData) + child.off('error', onError) + child.off('close', onClose) + if (error) { + reject(error) + return + } + child.stdout?.destroy?.() + child.unref() + resolve(0) + } + const writeIgnoredRecipeStdout = (): void => { + // Why: non-readiness child stdout is untrusted and cannot be safely + // redacted, including schema-valid results with arbitrary user data. + process.stderr.write(`${IGNORED_NON_RECIPE_STDOUT}\n`) + } + const processRecipeOutputLine = (line: string): void => { + const normalizedLine = line.endsWith('\r') ? line.slice(0, -1) : line + if (!normalizedLine.trim()) { + return + } + const parsed = parseEphemeralVmRecipeResult(normalizedLine) + if (!parsed.ok) { + writeIgnoredRecipeStdout() + return + } + if (getEphemeralVmRecipeResultConnection(parsed.result).type !== 'orca-server') { + writeIgnoredRecipeStdout() + return + } + process.stdout.write(`${normalizedLine.trim()}\n`) + finish() + } + const stdoutDecoder = new StringDecoder('utf8') + const onData = (chunk: Buffer | string): void => { + output += typeof chunk === 'string' ? chunk : stdoutDecoder.write(chunk) + while (!settled) { + const newlineIndex = output.indexOf('\n') + if (newlineIndex === -1) { + return + } + const line = output.slice(0, newlineIndex) + output = output.slice(newlineIndex + 1) + processRecipeOutputLine(line) + } + } + const onError = (error: Error): void => { + finish(error) + } + const onClose = (code: number | null, signal: NodeJS.Signals | null): void => { + if (settled) { + return + } + output += stdoutDecoder.end() + if (output.trim()) { + processRecipeOutputLine(output) + } + if (settled) { + return + } + finish( + new RuntimeClientError( + 'runtime_serve_failed', + typeof code === 'number' + ? `Orca serve exited before printing valid recipe JSON with code ${code}.` + : `Orca serve exited before printing valid recipe JSON via ${signal}.` + ) + ) + } + child.stdout?.on('data', onData) + child.once('error', onError) + // Why: `exit` can precede the final piped stdout data. `close` waits until + // stdio closes so a last recipe chunk is not mistaken for missing output. + child.once('close', onClose) + }) +} + function getExecutableAppArgs(): string[] { return process.env.ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT === '1' ? [resolveAppRoot()] : [] } diff --git a/src/cli/runtime/mac-app-update-bundle.test.ts b/src/cli/runtime/mac-app-update-bundle.test.ts deleted file mode 100644 index 3f802e3bbd6..00000000000 --- a/src/cli/runtime/mac-app-update-bundle.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { mkdirSync, mkdtempSync, rmSync, truncateSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import { MAC_BUNDLE_INFO_PLIST_MAX_BYTES, waitForMacBundleVersion } from './mac-app-update-bundle' - -const roots: string[] = [] - -afterEach(() => { - Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) - for (const root of roots.splice(0)) { - rmSync(root, { recursive: true, force: true }) - } -}) - -const originalPlatform = process.platform - -describe('waitForMacBundleVersion', () => { - it('rejects a sparse oversized Info.plist without reading its payload', async () => { - Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }) - const root = mkdtempSync(join(tmpdir(), 'orca-mac-bundle-bounds-')) - roots.push(root) - const contentsPath = join(root, 'Orca.app', 'Contents') - const executable = join(contentsPath, 'MacOS', 'Orca') - mkdirSync(join(contentsPath, 'MacOS'), { recursive: true }) - writeFileSync(executable, '') - const plistPath = join(contentsPath, 'Info.plist') - writeFileSync(plistPath, '') - truncateSync(plistPath, MAC_BUNDLE_INFO_PLIST_MAX_BYTES + 1) - - await expect(waitForMacBundleVersion(executable, '1.2.3', 1)).resolves.toBe(false) - }) -}) diff --git a/src/cli/runtime/mac-app-update-bundle.ts b/src/cli/runtime/mac-app-update-bundle.ts index 4edc066b0fa..d7770707cb8 100644 --- a/src/cli/runtime/mac-app-update-bundle.ts +++ b/src/cli/runtime/mac-app-update-bundle.ts @@ -1,9 +1,8 @@ import { watch, type FSWatcher } from 'node:fs' +import { readFile } from 'node:fs/promises' import { dirname, resolve } from 'node:path' -import { readNodeFileWithinLimit } from '../../shared/node-bounded-file-reader' const MAC_BUNDLE_UPDATE_TIMEOUT_MS = 120_000 -export const MAC_BUNDLE_INFO_PLIST_MAX_BYTES = 1024 * 1024 export function getMacAppBundlePath(executable: string): string | null { if (process.platform !== 'darwin') { @@ -79,9 +78,7 @@ export async function waitForMacBundleVersion( async function readMacBundleVersion(infoPlistPath: string): Promise { try { - const plist = ( - await readNodeFileWithinLimit(infoPlistPath, MAC_BUNDLE_INFO_PLIST_MAX_BYTES) - ).buffer.toString('utf8') + const plist = await readFile(infoPlistPath, 'utf8') const match = /CFBundleShortVersionString<\/key>\s*([^<]+)<\/string>/.exec(plist) return match?.[1]?.trim() || null } catch { diff --git a/src/cli/runtime/metadata.ts b/src/cli/runtime/metadata.ts index 2e617ca03dc..de1a8122fc2 100644 --- a/src/cli/runtime/metadata.ts +++ b/src/cli/runtime/metadata.ts @@ -1,11 +1,9 @@ import { homedir } from 'node:os' import { join } from 'node:path' -import { readNodeFileSyncWithinLimit } from '../../shared/node-bounded-file-reader' +import { readFileSync } from 'node:fs' import { findTransport, getRuntimeMetadataPath, - MAX_RUNTIME_METADATA_FILE_BYTES, - parseRuntimeMetadataJson, type RuntimeMetadata } from '../../shared/runtime-bootstrap' import { RuntimeClientError } from './types' @@ -13,11 +11,7 @@ import { RuntimeClientError } from './types' export function readMetadata(userDataPath: string): RuntimeMetadata { const metadataPath = getRuntimeMetadataPath(userDataPath) try { - const metadata = parseRuntimeMetadataJson( - readNodeFileSyncWithinLimit(metadataPath, MAX_RUNTIME_METADATA_FILE_BYTES).buffer.toString( - 'utf8' - ) - ) + const metadata = JSON.parse(readFileSync(metadataPath, 'utf8')) as RuntimeMetadata | null if (!metadata || !findTransport(metadata, 'unix', 'named-pipe') || !metadata.authToken) { throw new RuntimeClientError( 'runtime_unavailable', @@ -39,11 +33,7 @@ export function readMetadata(userDataPath: string): RuntimeMetadata { export function tryReadMetadata(userDataPath: string): RuntimeMetadata | null { const metadataPath = getRuntimeMetadataPath(userDataPath) try { - return parseRuntimeMetadataJson( - readNodeFileSyncWithinLimit(metadataPath, MAX_RUNTIME_METADATA_FILE_BYTES).buffer.toString( - 'utf8' - ) - ) + return JSON.parse(readFileSync(metadataPath, 'utf8')) as RuntimeMetadata | null } catch { return null } diff --git a/src/cli/runtime/recipe-json-output.ts b/src/cli/runtime/recipe-json-output.ts deleted file mode 100644 index bcbe5e3fc2e..00000000000 --- a/src/cli/runtime/recipe-json-output.ts +++ /dev/null @@ -1,120 +0,0 @@ -import type { ChildProcess } from 'node:child_process' -import { - getEphemeralVmRecipeResultConnection, - parseEphemeralVmRecipeResult -} from '../../shared/ephemeral-vm-recipes' -import { GrowingByteBuffer } from '../../shared/growing-byte-buffer' -import { RuntimeClientError } from './types' - -const IGNORED_NON_RECIPE_STDOUT = '[serve] ignored non-recipe stdout' -const MAX_RECIPE_OUTPUT_LINE_BYTES = 4 * 1024 * 1024 - -export function waitForRecipeJson(child: ChildProcess): Promise { - return new Promise((resolve, reject) => { - const output = new GrowingByteBuffer() - let settled = false - const timeout = setTimeout(() => { - finish(new RuntimeClientError('runtime_serve_failed', 'Timed out waiting for recipe JSON.')) - child.kill('SIGTERM') - }, 60000) - const finish = (error?: Error): void => { - if (settled) { - return - } - settled = true - clearTimeout(timeout) - output.clear() - child.stdout?.off('data', onData) - child.off('error', onError) - child.off('close', onClose) - if (error) { - reject(error) - return - } - child.stdout?.destroy?.() - child.unref() - resolve(0) - } - const writeIgnoredRecipeStdout = (): void => { - // Why: non-readiness child stdout can contain arbitrary user data. - process.stderr.write(`${IGNORED_NON_RECIPE_STDOUT}\n`) - } - const processRecipeOutputLine = (line: string): void => { - const normalizedLine = line.endsWith('\r') ? line.slice(0, -1) : line - if (!normalizedLine.trim()) { - return - } - const parsed = parseEphemeralVmRecipeResult(normalizedLine) - if (!parsed.ok) { - writeIgnoredRecipeStdout() - return - } - if (getEphemeralVmRecipeResultConnection(parsed.result).type !== 'orca-server') { - writeIgnoredRecipeStdout() - return - } - process.stdout.write(`${normalizedLine.trim()}\n`) - finish() - } - const oversizedLineError = (): RuntimeClientError => - new RuntimeClientError( - 'runtime_serve_failed', - `Recipe JSON output exceeded ${MAX_RECIPE_OUTPUT_LINE_BYTES} byte line limit.` - ) - const onData = (chunk: Buffer | string): void => { - if (settled) { - return - } - output.append(typeof chunk === 'string' ? Buffer.from(chunk) : chunk) - while (!settled) { - const newlineIndex = output.indexOfByte(0x0a) - if (newlineIndex === -1) { - break - } - if (newlineIndex > MAX_RECIPE_OUTPUT_LINE_BYTES) { - finish(oversizedLineError()) - child.kill('SIGTERM') - return - } - const line = output.takePrefixString(newlineIndex) - output.discardPrefix(1) - processRecipeOutputLine(line) - } - if (!settled && output.byteLength > MAX_RECIPE_OUTPUT_LINE_BYTES) { - finish(oversizedLineError()) - child.kill('SIGTERM') - } - } - const onError = (error: Error): void => { - finish(error) - } - const onClose = (code: number | null, signal: NodeJS.Signals | null): void => { - if (settled) { - return - } - if (output.byteLength > MAX_RECIPE_OUTPUT_LINE_BYTES) { - finish(oversizedLineError()) - return - } - const finalOutput = output.takeString() - if (finalOutput.trim()) { - processRecipeOutputLine(finalOutput) - } - if (settled) { - return - } - finish( - new RuntimeClientError( - 'runtime_serve_failed', - typeof code === 'number' - ? `Orca serve exited before printing valid recipe JSON with code ${code}.` - : `Orca serve exited before printing valid recipe JSON via ${signal}.` - ) - ) - } - child.stdout?.on('data', onData) - child.once('error', onError) - // Why: `close` waits for final piped stdout data that can arrive after `exit`. - child.once('close', onClose) - }) -} diff --git a/src/cli/runtime/serve-update-supervisor.ts b/src/cli/runtime/serve-update-supervisor.ts index 61c3cb737ee..343b8b56373 100644 --- a/src/cli/runtime/serve-update-supervisor.ts +++ b/src/cli/runtime/serve-update-supervisor.ts @@ -1,14 +1,9 @@ import type { ChildProcess, SpawnOptions, spawn } from 'node:child_process' -import { rename, unlink, writeFile } from 'node:fs/promises' +import { readFileSync } from 'node:fs' +import { readFile, rename, unlink, writeFile } from 'node:fs/promises' import { - readNodeFileSyncWithinLimit, - readNodeFileWithinLimit -} from '../../shared/node-bounded-file-reader' -import { stringifyJsonWithinByteLimit } from '../../shared/node-bounded-json-stringify' -import { - MAX_SERVE_UPDATE_HANDOFF_FILE_BYTES, - parseServeUpdateHandoffJson, parseServeSupervisorMessage, + parseServeUpdateHandoffState, type ServeUpdateHandoffState } from '../../shared/serve-update-handoff' import { RuntimeClientError } from './types' @@ -218,11 +213,7 @@ export async function readServeUpdateHandoff( handoffPath: string ): Promise { try { - const { buffer } = await readNodeFileWithinLimit( - handoffPath, - MAX_SERVE_UPDATE_HANDOFF_FILE_BYTES - ) - return parseServeUpdateHandoffJson(buffer.toString('utf8')) + return parseServeUpdateHandoffState(JSON.parse(await readFile(handoffPath, 'utf8'))) } catch { return null } @@ -230,8 +221,7 @@ export async function readServeUpdateHandoff( export function readServeUpdateHandoffSync(handoffPath: string): ServeUpdateHandoffState | null { try { - const { buffer } = readNodeFileSyncWithinLimit(handoffPath, MAX_SERVE_UPDATE_HANDOFF_FILE_BYTES) - return parseServeUpdateHandoffJson(buffer.toString('utf8')) + return parseServeUpdateHandoffState(JSON.parse(readFileSync(handoffPath, 'utf8'))) } catch { return null } @@ -269,12 +259,6 @@ async function writeServeUpdateHandoffState( state: ServeUpdateHandoffState ): Promise { const temporaryPath = `${handoffPath}.${process.pid}.tmp` - try { - const { serialized } = stringifyJsonWithinByteLimit(state, MAX_SERVE_UPDATE_HANDOFF_FILE_BYTES) - await writeFile(temporaryPath, serialized, { mode: 0o600 }) - await rename(temporaryPath, handoffPath) - } catch (error) { - await unlink(temporaryPath).catch(() => undefined) - throw error - } + await writeFile(temporaryPath, JSON.stringify(state), { mode: 0o600 }) + await rename(temporaryPath, handoffPath) } diff --git a/src/cli/runtime/transport.test.ts b/src/cli/runtime/transport.test.ts index cf084f5d80a..d722de22065 100644 --- a/src/cli/runtime/transport.test.ts +++ b/src/cli/runtime/transport.test.ts @@ -2,9 +2,9 @@ import { mkdtempSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { createServer, type Socket } from 'node:net' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it } from 'vitest' import type { RuntimeMetadata } from '../../shared/runtime-bootstrap' -import { CLI_RUNTIME_JSON_STRUCTURE_LIMITS, sendRequest } from './transport' +import { sendRequest } from './transport' const servers = new Set>() const sockets = new Set() @@ -114,36 +114,4 @@ describe.skipIf(process.platform === 'win32')('runtime transport', () => { }) expect(Date.now() - start).toBeLessThan(5000) }) - - it('rejects excessive response nesting before JSON.parse', async () => { - const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-transport-')) - const endpoint = join(userDataPath, 'runtime.sock') - const server = createServer((socket) => { - sockets.add(socket) - socket.once('close', () => sockets.delete(socket)) - socket.once('data', () => { - const depth = CLI_RUNTIME_JSON_STRUCTURE_LIMITS.nestingDepth + 1 - socket.write(`${'['.repeat(depth)}0${']'.repeat(depth)}\n`) - }) - }) - servers.add(server) - await new Promise((resolve) => server.listen(endpoint, resolve)) - const parseSpy = vi.spyOn(JSON, 'parse') - const metadata: RuntimeMetadata = { - runtimeId: 'runtime-1', - pid: 123, - transports: [{ kind: 'unix', endpoint }], - authToken: 'token', - startedAt: 1 - } - - try { - await expect(sendRequest(metadata, 'status.get', undefined, 1000)).rejects.toMatchObject({ - code: 'invalid_runtime_response' - }) - expect(parseSpy).not.toHaveBeenCalled() - } finally { - parseSpy.mockRestore() - } - }) }) diff --git a/src/cli/runtime/transport.ts b/src/cli/runtime/transport.ts index ceaac6ad5b6..60da1e2a980 100644 --- a/src/cli/runtime/transport.ts +++ b/src/cli/runtime/transport.ts @@ -1,17 +1,9 @@ import { createConnection } from 'node:net' import { randomUUID } from 'node:crypto' -import { GrowingByteBuffer } from '../../shared/growing-byte-buffer' -import { assertJsonTextStructureWithinLimits } from '../../shared/json-text-structure-limit' import { findTransport, type RuntimeMetadata } from '../../shared/runtime-bootstrap' import { isKeepaliveFrame, RuntimeRpcEnvelopeSchema } from './envelope-schema' import { RuntimeClientError, type RuntimeRpcResponse } from './types' -const MAX_RUNTIME_RESPONSE_LINE_BYTES = 64 * 1024 * 1024 -export const CLI_RUNTIME_JSON_STRUCTURE_LIMITS = { - structuralTokens: 1_000_000, - nestingDepth: 128 -} as const - export async function sendRequest( metadata: RuntimeMetadata, method: string, @@ -30,7 +22,7 @@ export async function sendRequest( return } const socket = createConnection(transport.endpoint) - const buffer = new GrowingByteBuffer() + let buffer = '' let settled = false const requestId = randomUUID() @@ -56,16 +48,15 @@ export async function sendRequest( } settled = true clearTimeout(timeout) - buffer.clear() + socket.end() if (result.ok === false) { - socket.destroy() reject(result.error) } else { - socket.end() resolve(result.response) } } + socket.setEncoding('utf8') socket.once('error', () => { finish({ ok: false, @@ -88,39 +79,24 @@ export async function sendRequest( ) }) }) - socket.on('data', (chunk: Buffer) => { - if (settled) { - return - } - buffer.append(chunk) + socket.on('data', (chunk) => { + buffer += chunk // Why: the server may interleave `{"_keepalive":true}\n` frames with the // final success/failure frame to keep both idle timers alive during a // long-poll (see design doc §3.1). Read frames in a loop until we see a // terminal frame. Each keepalive refreshes the client-side timer so a // 10 min wait doesn't trip the 60 s default ceiling. - let newlineIndex = buffer.indexOfByte(0x0a) + let newlineIndex = buffer.indexOf('\n') while (newlineIndex !== -1 && !settled) { - const lineBytes = newlineIndex - if (lineBytes > MAX_RUNTIME_RESPONSE_LINE_BYTES) { - finish({ - ok: false, - error: new RuntimeClientError( - 'invalid_runtime_response', - `The Orca runtime response exceeded the ${MAX_RUNTIME_RESPONSE_LINE_BYTES} byte frame limit.` - ) - }) - return - } - const line = buffer.takePrefixString(lineBytes) - buffer.discardPrefix(1) + const line = buffer.slice(0, newlineIndex) + buffer = buffer.slice(newlineIndex + 1) if (line.trim().length === 0) { - newlineIndex = buffer.indexOfByte(0x0a) + newlineIndex = buffer.indexOf('\n') continue } let raw: unknown try { - assertJsonTextStructureWithinLimits(line, CLI_RUNTIME_JSON_STRUCTURE_LIMITS) raw = JSON.parse(line) } catch { finish({ @@ -139,7 +115,7 @@ export async function sendRequest( // major). See §7 risk #9. if (isKeepaliveFrame(raw)) { timeout.refresh() - newlineIndex = buffer.indexOfByte(0x0a) + newlineIndex = buffer.indexOf('\n') continue } @@ -165,7 +141,7 @@ export async function sendRequest( const frame = parsed.data if ('_keepalive' in frame) { timeout.refresh() - newlineIndex = buffer.indexOfByte(0x0a) + newlineIndex = buffer.indexOf('\n') continue } @@ -193,15 +169,6 @@ export async function sendRequest( finish({ ok: true, response }) return } - if (buffer.byteLength > MAX_RUNTIME_RESPONSE_LINE_BYTES) { - finish({ - ok: false, - error: new RuntimeClientError( - 'invalid_runtime_response', - `The Orca runtime response exceeded the ${MAX_RUNTIME_RESPONSE_LINE_BYTES} byte frame limit.` - ) - }) - } }) socket.on('connect', () => { socket.write( diff --git a/src/main/active-view-preference.test.ts b/src/main/active-view-preference.test.ts index 8d264b27fa8..972009f722c 100644 --- a/src/main/active-view-preference.test.ts +++ b/src/main/active-view-preference.test.ts @@ -1,12 +1,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { existsSync, mkdtempSync, readFileSync, rmSync, truncateSync, writeFileSync } from 'node:fs' +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { - ActiveViewPreference, - getActiveViewPreferenceFile, - MAX_ACTIVE_VIEW_PREFERENCE_FILE_BYTES -} from './active-view-preference' +import { ActiveViewPreference, getActiveViewPreferenceFile } from './active-view-preference' describe('ActiveViewPreference', () => { let dir: string @@ -92,12 +88,4 @@ describe('ActiveViewPreference', () => { expect(preference.set('__proto__')).toBe(false) expect(preference.get()).toBe('tasks') }) - - it('falls back without loading an oversized sparse sidecar', () => { - const preferenceFile = getActiveViewPreferenceFile(dataFile) - writeFileSync(preferenceFile, '{"activeView":"settings"}', 'utf-8') - truncateSync(preferenceFile, MAX_ACTIVE_VIEW_PREFERENCE_FILE_BYTES + 1) - - expect(new ActiveViewPreference(dataFile, 'tasks').get()).toBe('tasks') - }) }) diff --git a/src/main/active-view-preference.ts b/src/main/active-view-preference.ts index d0728c448f6..beed983aef4 100644 --- a/src/main/active-view-preference.ts +++ b/src/main/active-view-preference.ts @@ -1,13 +1,11 @@ -import { mkdirSync, renameSync, writeFileSync } from 'node:fs' +import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs' import { mkdir, rm, writeFile } from 'node:fs/promises' import { dirname, join } from 'node:path' -import { readNodeFileSyncWithinLimit } from '../shared/node-bounded-file-reader' import type { TopLevelView } from '../shared/types' import { isTopLevelView } from '../shared/top-level-view' const ACTIVE_VIEW_FILE_NAME = 'active-view.json' const SAVE_DEBOUNCE_MS = 100 -export const MAX_ACTIVE_VIEW_PREFERENCE_FILE_BYTES = 4 * 1024 type ActiveViewFile = { activeView: TopLevelView @@ -19,11 +17,7 @@ export function getActiveViewPreferenceFile(dataFile: string): string { function readActiveView(file: string): TopLevelView | null { try { - const parsed = JSON.parse( - readNodeFileSyncWithinLimit(file, MAX_ACTIVE_VIEW_PREFERENCE_FILE_BYTES).buffer.toString( - 'utf8' - ) - ) as Partial + const parsed = JSON.parse(readFileSync(file, 'utf-8')) as Partial return isTopLevelView(parsed.activeView) ? parsed.activeView : null } catch { return null diff --git a/src/main/agent-hooks/agent-hook-file-bounds.test.ts b/src/main/agent-hooks/agent-hook-file-bounds.test.ts deleted file mode 100644 index 9cb28f5a897..00000000000 --- a/src/main/agent-hooks/agent-hook-file-bounds.test.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { - existsSync, - mkdirSync, - mkdtempSync, - rmSync, - statSync, - truncateSync, - writeFileSync -} from 'node:fs' -import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const { homedirMock } = vi.hoisted(() => ({ - homedirMock: vi.fn<() => string>() -})) - -vi.mock('os', async () => { - const actual = (await vi.importActual('os')) as Record - return { ...actual, homedir: homedirMock } -}) - -import { NodeFileReadTooLargeError } from '../../shared/node-bounded-file-reader' -import { AmpHookService, _internals as ampInternals } from '../amp/hook-service' -import { DevinHookService } from '../devin/hook-service' -import { getDevinConfigPath, getDevinManagedScriptPath } from '../devin/hook-settings' -import { HermesHookService, _internals as hermesInternals } from '../hermes/hook-service' -import { KimiHookService } from '../kimi/hook-service' -import { - AGENT_HOOK_CONFIG_MAX_BYTES, - AGENT_HOOK_CONFIG_MAX_STRUCTURAL_TOKENS, - AGENT_HOOK_MANAGED_SCRIPT_MAX_BYTES, - AGENT_HOOK_PLUGIN_MAX_BYTES -} from './agent-hook-file-limits' -import { - readHooksJson, - readHooksJsonRawForGenerationCheck, - writeHooksJson, - writeManagedScript -} from './installer-utils' - -let root: string - -function writeSparseFile(path: string, bytes: number): void { - mkdirSync(dirname(path), { recursive: true }) - writeFileSync(path, '') - truncateSync(path, bytes) -} - -beforeEach(() => { - root = mkdtempSync(join(tmpdir(), 'orca-agent-hook-bounds-')) - homedirMock.mockReturnValue(root) - vi.stubEnv('HERMES_HOME', join(root, '.hermes')) - vi.stubEnv('KIMI_CODE_HOME', join(root, '.kimi-code')) - vi.stubEnv('APPDATA', join(root, 'AppData', 'Roaming')) -}) - -afterEach(() => { - vi.unstubAllEnvs() - vi.clearAllMocks() - rmSync(root, { recursive: true, force: true }) -}) - -describe('agent hook file bounds', () => { - it('fails closed when hooks JSON exceeds the comparison and generation limit', () => { - const configPath = join(root, 'hooks.json') - writeSparseFile(configPath, AGENT_HOOK_CONFIG_MAX_BYTES + 1) - const originalSize = statSync(configPath).size - - expect(readHooksJson(configPath)).toBeNull() - expect(() => readHooksJsonRawForGenerationCheck(configPath)).toThrow(NodeFileReadTooLargeError) - expect(() => writeHooksJson(configPath, { hooks: {} })).toThrow(NodeFileReadTooLargeError) - expect(statSync(configPath).size).toBe(originalSize) - }) - - it('rejects structurally amplified hooks JSON before parsing', () => { - const configPath = join(root, 'hooks.json') - writeFileSync(configPath, `[${'0,'.repeat(AGENT_HOOK_CONFIG_MAX_STRUCTURAL_TOKENS)}0]`, 'utf8') - const parseSpy = vi.spyOn(JSON, 'parse') - - expect(readHooksJson(configPath)).toBeNull() - expect(parseSpy).not.toHaveBeenCalled() - }) - - it('does not replace an oversized managed script during its equality check', () => { - const scriptPath = join(root, '.orca', 'agent-hooks', 'status-hook.sh') - writeSparseFile(scriptPath, AGENT_HOOK_MANAGED_SCRIPT_MAX_BYTES + 1) - const originalSize = statSync(scriptPath).size - - expect(() => writeManagedScript(scriptPath, '#!/bin/sh\n')).toThrow(NodeFileReadTooLargeError) - expect(statSync(scriptPath).size).toBe(originalSize) - }) - - it('does not replace an oversized Amp plugin', () => { - const pluginPath = ampInternals.getPluginPath() - writeSparseFile(pluginPath, AGENT_HOOK_PLUGIN_MAX_BYTES + 1) - const originalSize = statSync(pluginPath).size - - const status = new AmpHookService().install() - - expect(status).toMatchObject({ state: 'error', managedHooksPresent: false }) - expect(status.detail).toContain('File too large') - expect(statSync(pluginPath).size).toBe(originalSize) - }) - - it('does not create Hermes plugin files when config.yaml is oversized', () => { - const configPath = join(process.env.HERMES_HOME!, 'config.yaml') - writeSparseFile(configPath, AGENT_HOOK_CONFIG_MAX_BYTES + 1) - const originalSize = statSync(configPath).size - - const status = new HermesHookService().install() - - expect(status.state).toBe('error') - expect(status.detail).toContain('File too large') - expect(statSync(configPath).size).toBe(originalSize) - expect( - existsSync( - join(process.env.HERMES_HOME!, 'plugins', hermesInternals.HERMES_PLUGIN_NAME, 'plugin.yaml') - ) - ).toBe(false) - }) - - it('does not replace an oversized Hermes plugin manifest', () => { - const manifestPath = join( - process.env.HERMES_HOME!, - 'plugins', - hermesInternals.HERMES_PLUGIN_NAME, - 'plugin.yaml' - ) - writeSparseFile(manifestPath, AGENT_HOOK_PLUGIN_MAX_BYTES + 1) - const originalSize = statSync(manifestPath).size - - const status = new HermesHookService().install() - - expect(status.state).toBe('error') - expect(status.detail).toContain('File too large') - expect(statSync(manifestPath).size).toBe(originalSize) - expect(existsSync(join(process.env.HERMES_HOME!, 'config.yaml'))).toBe(false) - }) - - it('does not create a Kimi script or replace an oversized config.toml', () => { - const configPath = join(process.env.KIMI_CODE_HOME!, 'config.toml') - const scriptPath = join(root, '.orca', 'agent-hooks', 'kimi-hook.sh') - writeSparseFile(configPath, AGENT_HOOK_CONFIG_MAX_BYTES + 1) - const originalSize = statSync(configPath).size - - const status = new KimiHookService().install() - - expect(status.state).toBe('error') - expect(statSync(configPath).size).toBe(originalSize) - expect(existsSync(scriptPath)).toBe(false) - }) - - it('does not create a Devin script or replace an oversized config.json', () => { - const configPath = getDevinConfigPath() - writeSparseFile(configPath, AGENT_HOOK_CONFIG_MAX_BYTES + 1) - const originalSize = statSync(configPath).size - - const status = new DevinHookService().install() - - expect(status.state).toBe('error') - expect(statSync(configPath).size).toBe(originalSize) - expect(existsSync(getDevinManagedScriptPath())).toBe(false) - }) -}) diff --git a/src/main/agent-hooks/agent-hook-file-comparison.ts b/src/main/agent-hooks/agent-hook-file-comparison.ts deleted file mode 100644 index 870161be708..00000000000 --- a/src/main/agent-hooks/agent-hook-file-comparison.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { - NodeFileReadTooLargeError, - readNodeFileSyncWithinLimit -} from '../../shared/node-bounded-file-reader' - -export function readAgentHookFileForComparison(path: string, maxBytes: number): string | null { - try { - return readNodeFileSyncWithinLimit(path, maxBytes).buffer.toString('utf8') - } catch (error) { - if (error instanceof NodeFileReadTooLargeError) { - throw error - } - return null - } -} diff --git a/src/main/agent-hooks/agent-hook-file-limits.ts b/src/main/agent-hooks/agent-hook-file-limits.ts deleted file mode 100644 index f321f114c18..00000000000 --- a/src/main/agent-hooks/agent-hook-file-limits.ts +++ /dev/null @@ -1,6 +0,0 @@ -// Why: leave room for the supported 30,000-hook cleanup, including longer encoded Windows commands. -export const AGENT_HOOK_CONFIG_MAX_BYTES = 64 * 1024 * 1024 -export const AGENT_HOOK_CONFIG_MAX_STRUCTURAL_TOKENS = 1_000_000 -export const AGENT_HOOK_CONFIG_MAX_NESTING_DEPTH = 128 -export const AGENT_HOOK_PLUGIN_MAX_BYTES = 1024 * 1024 -export const AGENT_HOOK_MANAGED_SCRIPT_MAX_BYTES = 1024 * 1024 diff --git a/src/main/agent-hooks/agent-hook-sftp-text-reader.ts b/src/main/agent-hooks/agent-hook-sftp-text-reader.ts deleted file mode 100644 index e5eb23fd40c..00000000000 --- a/src/main/agent-hooks/agent-hook-sftp-text-reader.ts +++ /dev/null @@ -1,151 +0,0 @@ -import type { SFTPWrapper } from 'ssh2' -import { NodeFileReadTooLargeError } from '../../shared/node-bounded-file-reader' - -const READ_CHUNK_BYTES = 64 * 1024 - -type SftpWithBoundedAgentHookRead = SFTPWrapper & { - orcaReadFileWithinLimit?: ( - remotePath: string, - maxBytes: number, - callback: (error: unknown, data?: string | Buffer) => void - ) => void -} - -export async function readAgentHookRemoteTextFile( - sftp: SFTPWrapper, - remotePath: string, - maxBytes: number, - timeoutMs: number -): Promise { - if (!Number.isSafeInteger(maxBytes) || maxBytes < 0) { - throw new RangeError('Remote file read limit must be a non-negative safe integer') - } - const boundedSftp = sftp as SftpWithBoundedAgentHookRead - if (typeof boundedSftp.orcaReadFileWithinLimit === 'function') { - return readWithCallback(remotePath, maxBytes, timeoutMs, (callback) => - boundedSftp.orcaReadFileWithinLimit!(remotePath, maxBytes, callback) - ) - } - if (typeof sftp.createReadStream !== 'function') { - return readWithCompatibilityFallback(sftp, remotePath, maxBytes, timeoutMs) - } - - const stream = sftp.createReadStream(remotePath, { - start: 0, - end: maxBytes, - highWaterMark: READ_CHUNK_BYTES - }) - return new Promise((resolve, reject) => { - let settled = false - let totalBytes = 0 - let retained = Buffer.alloc(0) - const timer = setTimeout(() => { - fail(new Error(`Timed out waiting for SFTP readFile ${remotePath}`)) - }, timeoutMs) - if (typeof timer === 'object' && 'unref' in timer) { - timer.unref() - } - - function fail(error: unknown): void { - if (settled) { - return - } - settled = true - clearTimeout(timer) - stream.destroy() - reject(error) - } - - stream.on('data', (chunk: string | Buffer | Uint8Array) => { - if (settled) { - return - } - const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) - totalBytes += bytes.byteLength - if (totalBytes > maxBytes) { - fail(new NodeFileReadTooLargeError(totalBytes, maxBytes)) - return - } - if (retained.length < totalBytes) { - const nextCapacity = Math.min( - maxBytes, - Math.max(READ_CHUNK_BYTES, retained.length * 2, totalBytes) - ) - const next = Buffer.allocUnsafe(nextCapacity) - retained.copy(next) - retained = next - } - bytes.copy(retained, totalBytes - bytes.byteLength) - }) - stream.once('error', fail) - stream.once('end', () => { - if (settled) { - return - } - settled = true - clearTimeout(timer) - resolve(retained.toString('utf8', 0, totalBytes)) - }) - }) -} - -function readWithCompatibilityFallback( - sftp: SFTPWrapper, - remotePath: string, - maxBytes: number, - timeoutMs: number -): Promise { - return readWithCallback(remotePath, maxBytes, timeoutMs, (callback) => { - sftp.readFile(remotePath, 'utf8', callback) - }) -} - -function readWithCallback( - remotePath: string, - maxBytes: number, - timeoutMs: number, - start: (callback: (error: unknown, data?: string | Buffer) => void) => void -): Promise { - return new Promise((resolve, reject) => { - let settled = false - const timer = setTimeout(() => { - finish(new Error(`Timed out waiting for SFTP readFile ${remotePath}`)) - }, timeoutMs) - if (typeof timer === 'object' && 'unref' in timer) { - timer.unref() - } - - function finish(error: unknown, data?: string | Buffer): void { - if (settled) { - return - } - settled = true - clearTimeout(timer) - if (error) { - reject(error) - return - } - if (typeof data === 'string') { - const byteLength = Buffer.byteLength(data, 'utf8') - if (byteLength > maxBytes) { - reject(new NodeFileReadTooLargeError(byteLength, maxBytes)) - return - } - resolve(data) - return - } - const buffer = data ?? Buffer.alloc(0) - if (buffer.byteLength > maxBytes) { - reject(new NodeFileReadTooLargeError(buffer.byteLength, maxBytes)) - return - } - resolve(buffer.toString('utf8')) - } - - try { - start(finish) - } catch (error) { - finish(error) - } - }) -} diff --git a/src/main/agent-hooks/branch-rename-failure-output.test.ts b/src/main/agent-hooks/branch-rename-failure-output.test.ts index 69675120485..6df43fa457a 100644 --- a/src/main/agent-hooks/branch-rename-failure-output.test.ts +++ b/src/main/agent-hooks/branch-rename-failure-output.test.ts @@ -1,8 +1,5 @@ import { beforeEach, describe, expect, it } from 'vitest' import { - BRANCH_RENAME_FAILURE_KEY_MAX_BYTES, - BRANCH_RENAME_FAILURE_OUTPUT_MAX_BYTES, - __getBranchRenameFailureOutputCountForTests, __resetBranchRenameFailureOutputForTests, readBranchRenameFailureOutputForDisplay, rememberBranchRenameFailureOutput @@ -51,27 +48,4 @@ describe('branch rename failure output store', () => { expect(readBranchRenameFailureOutputForDisplay('wt-0')).not.toBeNull() expect(readBranchRenameFailureOutputForDisplay('wt-1')).toBeNull() }) - - it('admits exact byte boundaries and skips oversized retained text', () => { - const exactKey = 'k'.repeat(BRANCH_RENAME_FAILURE_KEY_MAX_BYTES) - const oversizedKey = `${exactKey}x` - rememberBranchRenameFailureOutput(exactKey, { - label: '', - exitCode: 1, - stdout: 'x'.repeat(BRANCH_RENAME_FAILURE_OUTPUT_MAX_BYTES), - stderr: '' - }) - rememberBranchRenameFailureOutput(oversizedKey, output('oversized-key')) - rememberBranchRenameFailureOutput('oversized-output', { - label: '', - exitCode: 1, - stdout: 'x'.repeat(BRANCH_RENAME_FAILURE_OUTPUT_MAX_BYTES + 1), - stderr: '' - }) - - expect(__getBranchRenameFailureOutputCountForTests()).toBe(1) - expect(readBranchRenameFailureOutputForDisplay(exactKey)).not.toBeNull() - expect(readBranchRenameFailureOutputForDisplay(oversizedKey)).toBeNull() - expect(readBranchRenameFailureOutputForDisplay('oversized-output')).toBeNull() - }) }) diff --git a/src/main/agent-hooks/branch-rename-failure-output.ts b/src/main/agent-hooks/branch-rename-failure-output.ts index bff56937a3e..8b8660484b7 100644 --- a/src/main/agent-hooks/branch-rename-failure-output.ts +++ b/src/main/agent-hooks/branch-rename-failure-output.ts @@ -2,29 +2,14 @@ import { formatAgentGenerationFailureOutputForDisplay, type AgentGenerationFailureOutput } from '../text-generation/agent-failure-output' -import { measureUtf8ByteLength } from '../../shared/utf8-byte-limits' // Why: the full CLI output is a diagnostic for the local user only. Keeping it // in memory (never in worktree metadata) means nothing environment-identifying // is persisted or synced to paired clients; a restart just loses the on-demand // view while the sanitized excerpt badge survives. const MAX_ENTRIES = 32 -export const BRANCH_RENAME_FAILURE_KEY_MAX_BYTES = 4 * 1024 -export const BRANCH_RENAME_FAILURE_OUTPUT_MAX_BYTES = 520 * 1024 const entriesByWorktreeId = new Map() -function fitsByteBudget(values: readonly string[], maxBytes: number): boolean { - let remainingBytes = maxBytes - for (const value of values) { - const measured = measureUtf8ByteLength(value, { stopAfterBytes: remainingBytes }) - if (measured.exceededLimit) { - return false - } - remainingBytes -= measured.byteLength - } - return true -} - export function rememberBranchRenameFailureOutput( worktreeId: string, output: AgentGenerationFailureOutput | null | undefined @@ -35,15 +20,6 @@ export function rememberBranchRenameFailureOutput( if (!output) { return } - if ( - !fitsByteBudget([worktreeId], BRANCH_RENAME_FAILURE_KEY_MAX_BYTES) || - !fitsByteBudget( - [output.label, output.stdout, output.stderr], - BRANCH_RENAME_FAILURE_OUTPUT_MAX_BYTES - ) - ) { - return - } entriesByWorktreeId.set(worktreeId, output) while (entriesByWorktreeId.size > MAX_ENTRIES) { const oldest = entriesByWorktreeId.keys().next().value @@ -62,7 +38,3 @@ export function readBranchRenameFailureOutputForDisplay(worktreeId: string): str export function __resetBranchRenameFailureOutputForTests(): void { entriesByWorktreeId.clear() } - -export function __getBranchRenameFailureOutputCountForTests(): number { - return entriesByWorktreeId.size -} diff --git a/src/main/agent-hooks/first-work-branch-rename.test.ts b/src/main/agent-hooks/first-work-branch-rename.test.ts index 7c9088f1d01..e8d8cd5f373 100644 --- a/src/main/agent-hooks/first-work-branch-rename.test.ts +++ b/src/main/agent-hooks/first-work-branch-rename.test.ts @@ -44,9 +44,7 @@ vi.mock('../ipc/worktree-logic', () => ({ })) import { - FIRST_WORK_BRANCH_RENAME_IN_FLIGHT_LIMIT, FIRST_WORK_BRANCH_RENAME_SETTLED_CACHE_LIMIT, - getFirstWorkBranchRenameStateForTests, maybeAutoRenameBranchOnFirstWork, resetFirstWorkBranchRenameState, type FirstWorkBranchRenameDeps @@ -299,45 +297,6 @@ describe('maybeAutoRenameBranchOnFirstWork', () => { expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1) }) - it('bounds simultaneous auto-rename attempts and recovers after they settle', async () => { - let releaseGit!: () => void - const gitGate = new Promise((resolve) => { - releaseGit = resolve - }) - gitExecFileAsyncMock.mockImplementation(async () => { - await gitGate - return { stdout: 'you/custom-branch\n', stderr: '' } - }) - const { deps } = makeDeps() - const attempts = Array.from({ length: FIRST_WORK_BRANCH_RENAME_IN_FLIGHT_LIMIT }, (_, index) => - maybeAutoRenameBranchOnFirstWork( - workingEvent({ - tabId: undefined, - paneKey: '', - worktreeId: `${REPO_ID}${WORKTREE_ID_SEPARATOR}/repo/concurrent-${index}` - }), - deps - ) - ) - - expect(getFirstWorkBranchRenameStateForTests().inFlight).toBe( - FIRST_WORK_BRANCH_RENAME_IN_FLIGHT_LIMIT - ) - await maybeAutoRenameBranchOnFirstWork( - workingEvent({ - tabId: undefined, - paneKey: '', - worktreeId: `${REPO_ID}${WORKTREE_ID_SEPARATOR}/repo/overflow` - }), - deps - ) - expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(FIRST_WORK_BRANCH_RENAME_IN_FLIGHT_LIMIT) - - releaseGit() - await Promise.all(attempts) - expect(getFirstWorkBranchRenameStateForTests().inFlight).toBe(0) - }) - it('retries on a later event after a transient failure (does not poison the worktree)', async () => { generateBranchNameMock.mockResolvedValueOnce({ success: false, error: 'agent not ready' }) const { deps, onRenamed, setRenameError } = makeDeps() diff --git a/src/main/agent-hooks/first-work-branch-rename.ts b/src/main/agent-hooks/first-work-branch-rename.ts index ae06d5cfbf1..c9703c51637 100644 --- a/src/main/agent-hooks/first-work-branch-rename.ts +++ b/src/main/agent-hooks/first-work-branch-rename.ts @@ -1,5 +1,4 @@ // On first agent work in a fresh workspace, replace the auto-generated creature branch (e.g. `you/Nautilus`) with a short work-derived name. -import { createHash } from 'node:crypto' import type { GlobalSettings, Repo } from '../../shared/types' import { getRepoIdFromWorktreeId, splitWorktreeIdForFilesystem } from '../../shared/worktree-id' import { parseWorkspaceKey } from '../../shared/workspace-scope' @@ -72,7 +71,6 @@ export type FirstWorkBranchRenameDeps = { const inFlightWorktreeIds = new Set() const settledWorktreeIds = new Set() export const FIRST_WORK_BRANCH_RENAME_SETTLED_CACHE_LIMIT = 500 -export const FIRST_WORK_BRANCH_RENAME_IN_FLIGHT_LIMIT = 32 /** Test seam: clear the per-process dedupe sets. */ export function resetFirstWorkBranchRenameState(): void { @@ -93,20 +91,6 @@ function rememberSettledWorktreeId(worktreeId: string): void { } } -function worktreeStateKey(worktreeId: string): string { - return createHash('sha256').update(worktreeId).digest('base64url') -} - -export function getFirstWorkBranchRenameStateForTests(): { - inFlight: number - settled: number -} { - return { - inFlight: inFlightWorktreeIds.size, - settled: settledWorktreeIds.size - } -} - export async function maybeAutoRenameBranchOnFirstWork( event: FirstWorkBranchRenameEvent, deps: FirstWorkBranchRenameDeps @@ -124,30 +108,26 @@ export async function maybeAutoRenameBranchOnFirstWork( if (!worktreeId) { return } - const stateKey = worktreeStateKey(worktreeId) // Short-circuit settled/in-flight worktrees before any logging or work. - if (settledWorktreeIds.has(stateKey) || inFlightWorktreeIds.has(stateKey)) { + if (settledWorktreeIds.has(worktreeId) || inFlightWorktreeIds.has(worktreeId)) { return } const prompt = event.prompt?.trim() if (!prompt) { return } - if (inFlightWorktreeIds.size >= FIRST_WORK_BRANCH_RENAME_IN_FLIGHT_LIMIT) { - return - } - inFlightWorktreeIds.add(stateKey) + inFlightWorktreeIds.add(worktreeId) try { // settled = definitive verdict (renamed/ineligible); false = transient bail to retry later. const settled = await runAutoRename(worktreeId, prompt, event.assistantMessage, deps) if (settled) { - rememberSettledWorktreeId(stateKey) + rememberSettledWorktreeId(worktreeId) } } catch (error) { // Why: best-effort convenience; a failure must never disrupt the user's agent, so swallow after logging. console.warn('[auto-branch-rename] rename attempt failed:', error) } finally { - inFlightWorktreeIds.delete(stateKey) + inFlightWorktreeIds.delete(worktreeId) } } diff --git a/src/main/agent-hooks/hooks-json-read.ts b/src/main/agent-hooks/hooks-json-read.ts index a844f3950a3..c8536fad0b4 100644 --- a/src/main/agent-hooks/hooks-json-read.ts +++ b/src/main/agent-hooks/hooks-json-read.ts @@ -1,12 +1,5 @@ -import { existsSync } from 'node:fs' -import { readNodeFileSyncWithinLimit } from '../../shared/node-bounded-file-reader' -import { assertJsonTextStructureWithinLimits } from '../../shared/json-text-structure-limit' +import { existsSync, readFileSync } from 'node:fs' import type { HooksConfig } from './installer-utils' -import { - AGENT_HOOK_CONFIG_MAX_BYTES, - AGENT_HOOK_CONFIG_MAX_NESTING_DEPTH, - AGENT_HOOK_CONFIG_MAX_STRUCTURAL_TOKENS -} from './agent-hook-file-limits' export function isPlainObject(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) @@ -18,19 +11,6 @@ export type HooksJsonSnapshot = { config: HooksConfig | null } -export function parseHooksJsonText(raw: string): HooksConfig | null { - try { - assertJsonTextStructureWithinLimits(raw, { - structuralTokens: AGENT_HOOK_CONFIG_MAX_STRUCTURAL_TOKENS, - nestingDepth: AGENT_HOOK_CONFIG_MAX_NESTING_DEPTH - }) - const parsed = JSON.parse(raw) - return isPlainObject(parsed) ? parsed : null - } catch { - return null - } -} - // Why: generation guards abort a mutation when the file no longer matches the // bytes it was derived from; the raw snapshot and the parse must come from one // read or a concurrent save can slip between them unnoticed. @@ -40,21 +20,18 @@ export function readHooksJsonWithRaw(configPath: string): HooksJsonSnapshot { } let raw: string try { - raw = readNodeFileSyncWithinLimit(configPath, AGENT_HOOK_CONFIG_MAX_BYTES).buffer.toString( - 'utf8' - ) + raw = readFileSync(configPath, 'utf-8') } catch { return { raw: null, config: null } } - return { raw, config: parseHooksJsonText(raw) } + try { + const parsed = JSON.parse(raw) + return { raw, config: isPlainObject(parsed) ? parsed : null } + } catch { + return { raw, config: null } + } } export function readHooksJson(configPath: string): HooksConfig | null { return readHooksJsonWithRaw(configPath).config } - -export function readHooksJsonRawForGenerationCheck(configPath: string): string { - return readNodeFileSyncWithinLimit(configPath, AGENT_HOOK_CONFIG_MAX_BYTES).buffer.toString( - 'utf8' - ) -} diff --git a/src/main/agent-hooks/installer-utils-remote.test.ts b/src/main/agent-hooks/installer-utils-remote.test.ts index 4b3f7606c28..75f9c9682fe 100644 --- a/src/main/agent-hooks/installer-utils-remote.test.ts +++ b/src/main/agent-hooks/installer-utils-remote.test.ts @@ -1,11 +1,8 @@ -import { Readable } from 'node:stream' import { describe, expect, it, vi } from 'vitest' import type { SFTPWrapper } from 'ssh2' -import { NodeFileReadTooLargeError } from '../../shared/node-bounded-file-reader' import { readHooksJsonRemote, - readTextFileRemote, writeHooksJsonRemote, writeManagedScriptRemote, writeTextFileRemoteAtomic @@ -16,7 +13,6 @@ type FakeFs = { dirs: Set modes: Map openSshRenameCount: number - readdirCount: number } function createFakeSftp( @@ -34,8 +30,7 @@ function createFakeSftp( files: new Map(), dirs: new Set(['/']), modes: new Map(), - openSshRenameCount: 0, - readdirCount: 0 + openSshRenameCount: 0 } const noEntryError = (path: string): { code: number; message: string } => ({ code: 2, @@ -101,10 +96,6 @@ function createFakeSftp( cb(null) }, stat: (path: string, cb: (err: unknown, stats?: { mode: number }) => void): void => { - if (fs.dirs.has(path)) { - cb(null, fakeStats(0o040755)) - return - } if (!fs.files.has(path)) { cb(noEntryError(path)) return @@ -112,7 +103,6 @@ function createFakeSftp( cb(null, fakeStats(fs.modes.get(path) ?? 0o100644)) }, readdir: (path: string, cb: (err: unknown, list?: { filename: string }[]) => void): void => { - fs.readdirCount += 1 if (fs.dirs.has(path)) { cb(null, []) return @@ -211,14 +201,6 @@ describe('installer-utils-remote', () => { expect(fs.modes.get('/home/u/.claude/settings.json')).toBe(0o600) }) - it('probes mkdir ancestors with stat instead of materializing directory listings', async () => { - const { sftp, fs } = createFakeSftp() - - await writeHooksJsonRemote(sftp, '/home/u/.claude/settings.json', { hooks: {} }) - - expect(fs.readdirCount).toBe(0) - }) - it('preserves existing config file mode across atomic replacement', async () => { const { sftp, fs } = createFakeSftp() const path = '/home/u/.codex/config.toml' @@ -305,44 +287,4 @@ describe('installer-utils-remote', () => { await writeHooksJsonRemote(sftp, path, { hooks: {} }) expect(fs.files.get(path)).toBe(beforeKey) }) - - it('preserves ordinary UTF-8 contents through the bounded remote stream', async () => { - const { sftp } = createFakeSftp() - const content = 'stable 🐋 config' - sftp.createReadStream = (() => Readable.from([Buffer.from(content)])) as never - - await expect(readTextFileRemote(sftp, '/home/u/config.toml', 1024)).resolves.toBe(content) - }) - - it('accepts a streamed remote file at the exact byte cap', async () => { - const { sftp } = createFakeSftp() - sftp.createReadStream = (() => Readable.from([Buffer.alloc(1024, 0x61)])) as never - - await expect(readTextFileRemote(sftp, '/home/u/config.toml', 1024)).resolves.toHaveLength(1024) - }) - - it('bounds metadata when a remote emits one byte per stream event', async () => { - const { sftp } = createFakeSftp() - const byte = Buffer.from('x') - sftp.createReadStream = (() => - Readable.from(Array.from({ length: 100_000 }, () => byte))) as never - - await expect(readTextFileRemote(sftp, '/home/u/config.toml', 100_000)).resolves.toBe( - 'x'.repeat(100_000) - ) - }) - - it('stops streamed remote reads at the byte cap and leaves the existing file intact', async () => { - const { sftp, fs } = createFakeSftp() - const path = '/home/u/.config/agent/config.toml' - fs.files.set(path, 'original') - sftp.createReadStream = (() => Readable.from([Buffer.alloc(1025)])) as never - - await expect(readTextFileRemote(sftp, path, 1024)).rejects.toThrow(NodeFileReadTooLargeError) - await expect(writeTextFileRemoteAtomic(sftp, path, 'replacement', 1024)).rejects.toThrow( - NodeFileReadTooLargeError - ) - expect(fs.files.get(path)).toBe('original') - expect(Array.from(fs.files.keys()).some((key) => key.includes('.tmp'))).toBe(false) - }) }) diff --git a/src/main/agent-hooks/installer-utils-remote.ts b/src/main/agent-hooks/installer-utils-remote.ts index b464d8135e8..c8edd5e8060 100644 --- a/src/main/agent-hooks/installer-utils-remote.ts +++ b/src/main/agent-hooks/installer-utils-remote.ts @@ -12,16 +12,9 @@ // See docs/design/agent-status-over-ssh.md §8 (commit #8). import { randomUUID } from 'node:crypto' -import type { SFTPWrapper } from 'ssh2' +import type { SFTPWrapper, FileEntryWithStats } from 'ssh2' -import { NodeFileReadTooLargeError } from '../../shared/node-bounded-file-reader' -import { - AGENT_HOOK_CONFIG_MAX_BYTES, - AGENT_HOOK_MANAGED_SCRIPT_MAX_BYTES -} from './agent-hook-file-limits' -import { readAgentHookRemoteTextFile } from './agent-hook-sftp-text-reader' -import type { HooksConfig } from './installer-utils' -import { parseHooksJsonText } from './hooks-json-read' +import { isPlainObject, type HooksConfig } from './installer-utils' const DEFAULT_REMOTE_CONFIG_MODE = 0o600 const REMOTE_SFTP_OPERATION_TIMEOUT_MS = 10_000 @@ -38,14 +31,19 @@ export async function readHooksJsonRemote( ): Promise { let body: string try { - body = await readFile(sftp, remotePath, AGENT_HOOK_CONFIG_MAX_BYTES) + body = await readFile(sftp, remotePath) } catch (err) { if (isNoEntryError(err)) { return {} } throw err } - return parseHooksJsonText(body) + try { + const parsed = JSON.parse(body) + return isPlainObject(parsed) ? parsed : null + } catch { + return null + } } /** Atomically write a JSON config to the remote — write to a tmp path then @@ -64,14 +62,11 @@ export async function writeHooksJsonRemote( // Why: skip the write when on-disk content is identical so repeated // install() calls do not bump the file's mtime / inode unnecessarily. try { - const existing = await readFile(sftp, remotePath, AGENT_HOOK_CONFIG_MAX_BYTES) + const existing = await readFile(sftp, remotePath) if (existing === serialized) { return } - } catch (error) { - if (error instanceof NodeFileReadTooLargeError) { - throw error - } + } catch { // ENOENT or read error — fall through to the write below. } // Why: tmp + rename so a partial network drop mid-write does not leave a @@ -103,15 +98,12 @@ export async function writeManagedScriptRemote( const dir = dirnamePosix(remotePath) await mkdirpRemote(sftp, dir) try { - const existing = await readFile(sftp, remotePath, AGENT_HOOK_MANAGED_SCRIPT_MAX_BYTES) + const existing = await readFile(sftp, remotePath) if (existing === content) { await chmod(sftp, remotePath, 0o755) return } - } catch (error) { - if (error instanceof NodeFileReadTooLargeError) { - throw error - } + } catch { // ENOENT or read error — fall through to the atomic write below. } @@ -134,11 +126,10 @@ export async function writeManagedScriptRemote( export async function readTextFileRemote( sftp: SFTPWrapper, - remotePath: string, - maxBytes = AGENT_HOOK_CONFIG_MAX_BYTES + remotePath: string ): Promise { try { - return await readFile(sftp, remotePath, maxBytes) + return await readFile(sftp, remotePath) } catch (err) { if (isNoEntryError(err)) { return null @@ -150,20 +141,16 @@ export async function readTextFileRemote( export async function writeTextFileRemoteAtomic( sftp: SFTPWrapper, remotePath: string, - content: string, - maxExistingBytes = AGENT_HOOK_CONFIG_MAX_BYTES + content: string ): Promise { const dir = dirnamePosix(remotePath) await mkdirpRemote(sftp, dir) try { - const existing = await readFile(sftp, remotePath, maxExistingBytes) + const existing = await readFile(sftp, remotePath) if (existing === content) { return } - } catch (error) { - if (error instanceof NodeFileReadTooLargeError) { - throw error - } + } catch { // ENOENT or read error — fall through to the atomic write below. } @@ -224,8 +211,11 @@ function sftpOperation( }) } -async function readFile(sftp: SFTPWrapper, remotePath: string, maxBytes: number): Promise { - return readAgentHookRemoteTextFile(sftp, remotePath, maxBytes, REMOTE_SFTP_OPERATION_TIMEOUT_MS) +async function readFile(sftp: SFTPWrapper, remotePath: string): Promise { + const data = await sftpOperation(`readFile ${remotePath}`, (callback) => { + sftp.readFile(remotePath, 'utf8', callback) + }) + return typeof data === 'string' ? data : data.toString('utf8') } async function writeFile( @@ -306,6 +296,12 @@ async function chmod(sftp: SFTPWrapper, remotePath: string, mode: number): Promi }) } +async function readdir(sftp: SFTPWrapper, remotePath: string): Promise { + return await sftpOperation(`readdir ${remotePath}`, (callback) => { + sftp.readdir(remotePath, callback) + }) +} + async function mkdir(sftp: SFTPWrapper, remotePath: string): Promise { await sftpOperation(`mkdir ${remotePath}`, (callback) => { sftp.mkdir(remotePath, callback) @@ -324,7 +320,7 @@ async function mkdirpRemote(sftp: SFTPWrapper, remotePath: string): Promise { }) describe('managed-hook local filesystem', () => { - it('uses the bounded local reader instead of the legacy whole-file callback', async () => { - const home = await createTempHome() - const configPath = join(home, 'oversized-config.json') - await writeFile(configPath, '123456789', 'utf8') - const filesystem = createManagedHookLocalFilesystem() - const legacyRead = vi.spyOn(filesystem, 'readFile') - - await expect( - readAgentHookRemoteTextFile(filesystem, configPath, 8, 1_000) - ).rejects.toBeInstanceOf(NodeFileReadTooLargeError) - expect(legacyRead).not.toHaveBeenCalled() - }) - - it('probes a directory without reading or retaining any child entries', async () => { - let closeCalls = 0 - let readCalls = 0 - const filesystem = createManagedHookLocalFilesystem({ - openDirectory(_path, callback) { - callback(null, { - close(closeCallback) { - closeCalls += 1 - closeCallback(null) - }, - read() { - readCalls += 1 - } - } as never) - } - }) - - const entries = await new Promise((resolve, reject) => { - filesystem.readdir('/directory-with-millions-of-entries', (error, value) => { - if (error) { - reject(error) - return - } - resolve(value ?? []) - }) - }) - - expect(entries).toEqual([]) - expect(closeCalls).toBe(1) - expect(readCalls).toBe(0) - }) - it('supports cold and warm aggregate installs without SFTP or temp-file residue', async () => { const home = await createTempHome() const filesystem = createManagedHookLocalFilesystem() diff --git a/src/main/agent-hooks/managed-hook-local-filesystem.ts b/src/main/agent-hooks/managed-hook-local-filesystem.ts index 9aa736c48c1..1a104a3a3b1 100644 --- a/src/main/agent-hooks/managed-hook-local-filesystem.ts +++ b/src/main/agent-hooks/managed-hook-local-filesystem.ts @@ -1,23 +1,8 @@ -import { chmod, mkdir, opendir, readFile, rename, stat, unlink, writeFile } from 'node:fs' +import { chmod, mkdir, readFile, readdir, rename, stat, unlink, writeFile } from 'node:fs' import type { SFTPWrapper } from 'ssh2' -import { - NodeFileReadTooLargeError, - readNodeFileWithinLimit -} from '../../shared/node-bounded-file-reader' type Callback = (error: Error | null, value?: T) => void -type DirectoryProbeHandle = { - close(callback: (error: NodeJS.ErrnoException | null) => void): void -} - -export type ManagedHookLocalFilesystemOptions = { - openDirectory?: ( - path: string, - callback: (error: NodeJS.ErrnoException | null, directory?: DirectoryProbeHandle) => void - ) => void -} - function asSftpError(error: NodeJS.ErrnoException): Error & { code: number } { const translated = new Error(error.message, { cause: error }) as Error & { code: number } translated.code = @@ -35,41 +20,11 @@ function finish(callback: Callback, error: NodeJS.ErrnoException | null, v /** The managed installers only need this small callback-style SFTP surface. * On the remote host it turns hundreds of WAN round trips into local fs calls. */ -export function createManagedHookLocalFilesystem( - options?: ManagedHookLocalFilesystemOptions -): SFTPWrapper { - const openDirectory = - options?.openDirectory ?? - (( - path: string, - callback: (error: NodeJS.ErrnoException | null, directory?: DirectoryProbeHandle) => void - ) => { - opendir(path, callback) - }) +export function createManagedHookLocalFilesystem(): SFTPWrapper { const adapter = { readFile(path: string, _encoding: string, callback: Callback): void { readFile(path, 'utf8', (error, contents) => finish(callback, error, contents)) }, - orcaReadFileWithinLimit( - path: string, - maxBytes: number, - callback: Callback - ): void { - void readNodeFileWithinLimit(path, maxBytes).then( - ({ buffer }) => callback(null, buffer), - (error: unknown) => { - if (error instanceof NodeFileReadTooLargeError) { - callback(error) - return - } - callback( - error instanceof Error - ? asSftpError(error as NodeJS.ErrnoException) - : new Error(String(error)) - ) - } - ) - }, writeFile( path: string, contents: string, @@ -82,15 +37,9 @@ export function createManagedHookLocalFilesystem( stat(path, (error, stats) => finish(callback, error, stats)) }, readdir(path: string, callback: Callback<[]>): void { - // Why: installers only probe directory existence; opening and closing - // avoids materializing every child name before returning the unused []. - openDirectory(path, (error, directory) => { - if (error || !directory) { - finish(callback, error ?? new Error('Directory probe returned no handle')) - return - } - directory.close((closeError) => finish(callback, closeError, [])) - }) + // Why: installers use readdir only as an existence check; names and + // attrs would allocate work that no caller consumes. + readdir(path, (error) => finish(callback, error, [])) }, mkdir(path: string, callback: Callback): void { mkdir(path, (error) => finish(callback, error)) diff --git a/src/main/agent-hooks/managed-hook-lock-claims.ts b/src/main/agent-hooks/managed-hook-lock-claims.ts index 4252cfde6fe..d3299c7bfd8 100644 --- a/src/main/agent-hooks/managed-hook-lock-claims.ts +++ b/src/main/agent-hooks/managed-hook-lock-claims.ts @@ -1,10 +1,8 @@ import { randomUUID } from 'node:crypto' -import { opendir, rename, unlink, writeFile } from 'node:fs/promises' +import { readdir, readFile, rename, unlink, writeFile } from 'node:fs/promises' import { join } from 'node:path' -import { NodeFileReadTooLargeError } from '../../shared/node-bounded-file-reader' import { CLAIMED_OWNER_PATTERN, - MANAGED_HOOK_LOCK_DIRECTORY_BUFFER_SIZE, claimedOwnerFileName, claimRecordFileName, hasCode, @@ -12,7 +10,6 @@ import { ownerFileName, parseClaim, parseOwner, - readManagedHookLockRecord, removeFileIfPresent, type ManagedHookLockClaim, type ManagedHookLockOwner @@ -36,21 +33,13 @@ async function findClaimedOwner( lockParent: string, ownerToken: string ): Promise<{ claimToken: string; path: string } | null | undefined> { - const directory = await opendir(lockParent, { - bufferSize: MANAGED_HOOK_LOCK_DIRECTORY_BUFFER_SIZE + const matches = (await readdir(lockParent)).flatMap((entry) => { + const match = CLAIMED_OWNER_PATTERN.exec(entry) + return match?.[1] === ownerToken && match[2] + ? [{ claimToken: match[2], path: join(lockParent, entry) }] + : [] }) - let claimedOwner: { claimToken: string; path: string } | null = null - for await (const entry of directory) { - const match = CLAIMED_OWNER_PATTERN.exec(entry.name) - if (match?.[1] !== ownerToken || !match[2]) { - continue - } - if (claimedOwner) { - return undefined - } - claimedOwner = { claimToken: match[2], path: join(lockParent, entry.name) } - } - return claimedOwner + return matches.length === 1 ? matches[0] : matches.length === 0 ? null : undefined } async function resolveClaimSource( @@ -64,13 +53,10 @@ async function resolveClaimSource( > { const ownerPath = join(lockParent, ownerFileName(owner.token)) try { - return parseOwner(await readManagedHookLockRecord(ownerPath), owner.token) + return parseOwner(await readFile(ownerPath, 'utf8'), owner.token) ? { kind: 'ready', sourcePath: ownerPath } : { kind: 'unverifiable' } } catch (error) { - if (error instanceof NodeFileReadTooLargeError) { - return { kind: 'unverifiable' } - } if (!hasCode(error, 'ENOENT')) { throw error } @@ -87,12 +73,12 @@ async function resolveClaimSource( let priorClaim: ManagedHookLockClaim | null try { priorClaim = parseClaim( - await readManagedHookLockRecord(priorClaimRecordPath), + await readFile(priorClaimRecordPath, 'utf8'), owner.token, claimedOwner.claimToken ) } catch (error) { - if (hasCode(error, 'ENOENT') || error instanceof NodeFileReadTooLargeError) { + if (hasCode(error, 'ENOENT')) { return { kind: 'unverifiable' } } throw error diff --git a/src/main/agent-hooks/managed-hook-lock-cleanup-bounds.test.ts b/src/main/agent-hooks/managed-hook-lock-cleanup-bounds.test.ts deleted file mode 100644 index 860fe743673..00000000000 --- a/src/main/agent-hooks/managed-hook-lock-cleanup-bounds.test.ts +++ /dev/null @@ -1,203 +0,0 @@ -import type { Dir, OpenDirOptions, PathLike, Stats } from 'node:fs' -import { - link, - mkdir, - mkdtemp, - readFile, - readdir, - rename, - rm, - stat, - truncate, - writeFile -} from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { withManagedHookInstallLock } from './managed-hook-install-lock' -import { - cleanupManagedHookLockFiles, - MANAGED_HOOK_LOCK_CLEANUP_CONCURRENCY, - MANAGED_HOOK_LOCK_DIRECTORY_BUFFER_SIZE, - MANAGED_HOOK_LOCK_RECORD_MAX_BYTES -} from './managed-hook-lock-records' - -const tempHomes: string[] = [] -const ioProbe = vi.hoisted(() => ({ - active: 0, - enabled: false, - opendirBufferSizes: [] as number[], - peak: 0 -})) -const identityProbe = vi.hoisted(() => ({ - processIdentity: null as string | null | undefined -})) - -type ProbedFsPromises = { - lstat: (path: PathLike) => Promise - opendir: (path: PathLike, options?: OpenDirOptions) => Promise -} - -vi.mock('node:fs/promises', async (importOriginal) => { - const original = await importOriginal() - return { - ...original, - lstat: async (...args: Parameters) => { - const tracked = ioProbe.enabled && String(args[0]).includes('managed-hook-install.owner-feed') - if (!tracked) { - return await original.lstat(...args) - } - ioProbe.active += 1 - ioProbe.peak = Math.max(ioProbe.peak, ioProbe.active) - try { - await new Promise((resolve) => setTimeout(resolve, 2)) - return await original.lstat(...args) - } finally { - ioProbe.active -= 1 - } - }, - opendir: async (...args: Parameters) => { - const options = args[1] as { bufferSize?: number } | undefined - if (options?.bufferSize !== undefined) { - ioProbe.opendirBufferSizes.push(options.bufferSize) - } - return await original.opendir(...args) - } - } -}) - -vi.mock('./managed-hook-owner-identity', () => ({ - readManagedHookProcessIdentity: vi.fn(async () => identityProbe.processIdentity) -})) - -function lockToken(prefix: string, index: number): string { - return `${prefix}${index.toString(16).padStart(4, '0')}-0000-4000-8000-${index - .toString(16) - .padStart(12, '0')}` -} - -async function createTempHome(): Promise { - const home = await mkdtemp(join(tmpdir(), 'orca-managed-hook-cleanup-bounds-')) - tempHomes.push(home) - return home -} - -async function seedUnrelatedFiles(directory: string, count: number): Promise { - for (let index = 0; index < count; index += 1) { - await writeFile(join(directory, `unrelated-${index.toString().padStart(4, '0')}.txt`), '') - } -} - -afterEach(async () => { - vi.restoreAllMocks() - ioProbe.active = 0 - ioProbe.enabled = false - ioProbe.opendirBufferSizes.length = 0 - ioProbe.peak = 0 - identityProbe.processIdentity = null - for (const home of tempHomes.splice(0)) { - await rm(home, { recursive: true, force: true }) - } -}) - -describe('managed-hook lock cleanup bounds', () => { - it('streams a large directory and cleans at most eight lock records concurrently', async () => { - const home = await createTempHome() - const lockParent = join(home, '.orca') - const hostIdentity = 'test-host' - const recordCount = 257 - const unrelatedCount = 320 - await mkdir(lockParent, { recursive: true }) - for (let index = 0; index < recordCount; index += 1) { - const token = lockToken('feed', index) - await writeFile( - join(lockParent, `managed-hook-install.owner-${token}.json`), - JSON.stringify({ - token, - pid: process.pid, - hostIdentity, - processIdentity: 'stale-process' - }) - ) - } - await seedUnrelatedFiles(lockParent, unrelatedCount) - ioProbe.enabled = true - - await cleanupManagedHookLockFiles(lockParent, hostIdentity) - - const remaining = await readdir(lockParent) - expect(remaining).toHaveLength(unrelatedCount) - expect(remaining.every((entry) => entry.startsWith('unrelated-'))).toBe(true) - expect(ioProbe.peak).toBe(MANAGED_HOOK_LOCK_CLEANUP_CONCURRENCY) - expect(ioProbe.opendirBufferSizes).toEqual([MANAGED_HOOK_LOCK_DIRECTORY_BUFFER_SIZE]) - }) - - it('preserves an oversized lock record without allocating its contents', async () => { - const home = await createTempHome() - const lockParent = join(home, '.orca') - const token = lockToken('feed', 1) - const recordPath = join(lockParent, `managed-hook-install.owner-${token}.json`) - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - await mkdir(lockParent, { recursive: true }) - await writeFile(recordPath, '') - await truncate(recordPath, MANAGED_HOOK_LOCK_RECORD_MAX_BYTES + 1) - - await cleanupManagedHookLockFiles(lockParent, 'test-host') - - expect((await stat(recordPath)).size).toBe(MANAGED_HOOK_LOCK_RECORD_MAX_BYTES + 1) - expect(warn).toHaveBeenCalledWith( - '[agent-hooks] Failed to clean managed-hook lock file', - expect.objectContaining({ name: 'NodeFileReadTooLargeError' }) - ) - }) - - it('recovers a claimed owner without collecting a large directory into an array', async () => { - const home = await createTempHome() - const lockParent = join(home, '.orca') - const lockPath = join(lockParent, 'managed-hook-install.lock') - const ownerToken = lockToken('cafe', 1) - const claimToken = lockToken('face', 2) - const ownerPath = join(lockParent, `managed-hook-install.owner-${ownerToken}.json`) - const claimedOwnerPath = join( - lockParent, - `managed-hook-install.claimed-${ownerToken}-${claimToken}.json` - ) - const hostIdentity = 'test-host' - await mkdir(lockParent, { recursive: true }) - await writeFile( - ownerPath, - JSON.stringify({ - token: ownerToken, - pid: process.pid, - hostIdentity, - processIdentity: 'stale-owner' - }) - ) - await link(ownerPath, lockPath) - await writeFile( - join(lockParent, `managed-hook-install.claim-${ownerToken}-${claimToken}.json`), - JSON.stringify({ - ownerToken, - claimToken, - pid: process.pid, - hostIdentity, - processIdentity: 'stale-claimant' - }) - ) - await rename(ownerPath, claimedOwnerPath) - await seedUnrelatedFiles(lockParent, 512) - identityProbe.processIdentity = 'current-process' - - await expect( - withManagedHookInstallLock(home, undefined, async () => 'installed', hostIdentity) - ).resolves.toBe('installed') - - expect(await readFile(join(lockParent, 'unrelated-0000.txt'), 'utf8')).toBe('') - expect(ioProbe.opendirBufferSizes.length).toBeGreaterThanOrEqual(2) - expect( - ioProbe.opendirBufferSizes.every( - (bufferSize) => bufferSize === MANAGED_HOOK_LOCK_DIRECTORY_BUFFER_SIZE - ) - ).toBe(true) - }) -}) diff --git a/src/main/agent-hooks/managed-hook-lock-records.ts b/src/main/agent-hooks/managed-hook-lock-records.ts index 130476e22a1..555c2a1a521 100644 --- a/src/main/agent-hooks/managed-hook-lock-records.ts +++ b/src/main/agent-hooks/managed-hook-lock-records.ts @@ -1,13 +1,8 @@ import { randomUUID } from 'node:crypto' -import { link, lstat, opendir, rename, unlink, writeFile } from 'node:fs/promises' +import { link, lstat, readdir, readFile, rename, unlink, writeFile } from 'node:fs/promises' import { join } from 'node:path' -import { readNodeFileWithinLimit } from '../../shared/node-bounded-file-reader' import { readManagedHookProcessIdentity } from './managed-hook-owner-identity' -export const MANAGED_HOOK_LOCK_DIRECTORY_BUFFER_SIZE = 32 -export const MANAGED_HOOK_LOCK_CLEANUP_CONCURRENCY = 8 -export const MANAGED_HOOK_LOCK_RECORD_MAX_BYTES = 2 * 1024 * 1024 - const UUID_PATTERN = '[\\da-f]{8}-[\\da-f]{4}-[1-5][\\da-f]{3}-[89ab][\\da-f]{3}-[\\da-f]{12}' const OWNER_FILE_PATTERN = new RegExp( `^managed-hook-install\\.owner-(${UUID_PATTERN})\\.json$`, @@ -132,16 +127,10 @@ export async function removeFileIfPresent(path: string): Promise { } } -export async function readManagedHookLockRecord(path: string): Promise { - return (await readNodeFileWithinLimit(path, MANAGED_HOOK_LOCK_RECORD_MAX_BYTES)).buffer.toString( - 'utf8' - ) -} - export async function inspectManagedHookLock(lockPath: string): Promise { let rawOwner: string try { - rawOwner = await readManagedHookLockRecord(lockPath) + rawOwner = await readFile(lockPath, 'utf8') } catch (error) { if (hasCode(error, 'ENOENT')) { return { kind: 'missing' } @@ -204,7 +193,7 @@ async function cleanOwnerEntry(path: string, token: string, hostIdentity: string if (stats.nlink !== 1) { return } - const owner = parseOwner(await readManagedHookLockRecord(path), token) + const owner = parseOwner(await readFile(path, 'utf8'), token) if (!owner || owner.hostIdentity !== hostIdentity) { return } @@ -240,7 +229,7 @@ async function cleanLockEntry( if (!claimMatch?.[1] || !claimMatch[2]) { return } - const claim = parseClaim(await readManagedHookLockRecord(path), claimMatch[1], claimMatch[2]) + const claim = parseClaim(await readFile(path, 'utf8'), claimMatch[1], claimMatch[2]) if (!claim || claim.hostIdentity !== hostIdentity) { return } @@ -261,59 +250,25 @@ async function cleanLockEntry( } } -function isManagedHookLockRecord(entry: string): boolean { - return ( - OWNER_FILE_PATTERN.test(entry) || - OWNER_DRAFT_PATTERN.test(entry) || - CLAIMED_OWNER_PATTERN.test(entry) || - CLAIM_RECORD_PATTERN.test(entry) - ) -} - -async function cleanLockEntrySafely( - entry: string, - lockParent: string, - hostIdentity: string -): Promise { - try { - await cleanLockEntry(entry, lockParent, hostIdentity) - } catch (error) { - if (!hasCode(error, 'ENOENT')) { - console.warn('[agent-hooks] Failed to clean managed-hook lock file', error) - } - } -} - export async function cleanupManagedHookLockFiles( lockParent: string, hostIdentity: string ): Promise { - let directory: Awaited> + let entries: string[] try { - directory = await opendir(lockParent, { - bufferSize: MANAGED_HOOK_LOCK_DIRECTORY_BUFFER_SIZE - }) + entries = await readdir(lockParent) } catch { return } - - const pending: string[] = [] - try { - for await (const entry of directory) { - if (!isManagedHookLockRecord(entry.name)) { - continue + await Promise.all( + entries.map(async (entry) => { + try { + await cleanLockEntry(entry, lockParent, hostIdentity) + } catch (error) { + if (!hasCode(error, 'ENOENT')) { + console.warn('[agent-hooks] Failed to clean managed-hook lock file', error) + } } - pending.push(entry.name) - if (pending.length === MANAGED_HOOK_LOCK_CLEANUP_CONCURRENCY) { - await Promise.all( - pending.splice(0).map((name) => cleanLockEntrySafely(name, lockParent, hostIdentity)) - ) - } - } - await Promise.all( - pending.splice(0).map((name) => cleanLockEntrySafely(name, lockParent, hostIdentity)) - ) - } catch { - // Directory enumeration is best-effort, matching the prior readdir behavior. - } + }) + ) } diff --git a/src/main/agent-hooks/managed-hook-owner-identity.test.ts b/src/main/agent-hooks/managed-hook-owner-identity.test.ts index fd500a1b8b1..ae8ac15dd12 100644 --- a/src/main/agent-hooks/managed-hook-owner-identity.test.ts +++ b/src/main/agent-hooks/managed-hook-owner-identity.test.ts @@ -57,34 +57,14 @@ async function loadLinuxIdentity(fixture: LinuxIdentityFixture) { uid: process.getuid?.() ?? 0 } }) - const readNodeFileWithinLimit = vi.fn(async (path: string, maxBytes: number) => { - if (String(path).endsWith('host-id') && fixture.hostToken) { - if (Buffer.byteLength(fixture.hostToken, 'utf8') > maxBytes) { - throw new Error('file too large') - } - return { - buffer: Buffer.from(fixture.hostToken, 'utf8'), - stats: { size: Buffer.byteLength(fixture.hostToken, 'utf8') } - } - } - throw Object.assign(new Error(`unavailable path: ${String(path)}`), { code: 'ENOENT' }) - }) vi.doMock('node:fs/promises', async (importOriginal) => ({ ...(await importOriginal>()), lstat, mkdir, readFile, - readlink, - writeFile: vi.fn(async () => { - throw Object.assign(new Error('host-local storage unavailable'), { code: 'EACCES' }) - }) + readlink })) - vi.doMock('../../shared/node-bounded-file-reader', () => ({ readNodeFileWithinLimit })) - return { - identity: await import('./managed-hook-owner-identity'), - readFile, - readNodeFileWithinLimit - } + return { identity: await import('./managed-hook-owner-identity'), readFile } } afterEach(() => { @@ -96,23 +76,10 @@ afterEach(() => { } vi.unstubAllEnvs() vi.doUnmock('node:fs/promises') - vi.doUnmock('../../shared/node-bounded-file-reader') vi.resetModules() }) describe('managed hook owner identity', () => { - it('rejects an oversized durable host token before materializing it', async () => { - const { identity, readNodeFileWithinLimit } = await loadLinuxIdentity({ - hostToken: 'x'.repeat(1025) - }) - - await expect(identity.readManagedHookHostIdentity()).resolves.toMatch(/^runtime:/) - expect(readNodeFileWithinLimit).toHaveBeenCalledWith( - '/var/tmp/orca-managed-hooks-1000/host-id', - 1024 - ) - }) - it('uses durable host-local identity without requiring Linux machine identity files', async () => { vi.stubEnv('SSH_CONNECTION', '198.51.100.8 53100 10.0.0.7 2222') const { identity, readFile } = await loadLinuxIdentity({ diff --git a/src/main/agent-hooks/managed-hook-owner-identity.ts b/src/main/agent-hooks/managed-hook-owner-identity.ts index 8f47c5ca9ae..e9801c3c489 100644 --- a/src/main/agent-hooks/managed-hook-owner-identity.ts +++ b/src/main/agent-hooks/managed-hook-owner-identity.ts @@ -3,7 +3,6 @@ import { randomUUID } from 'node:crypto' import { link, lstat, mkdir, readFile, readlink, unlink, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { promisify } from 'node:util' -import { readNodeFileWithinLimit } from '../../shared/node-bounded-file-reader' const execFileAsync = promisify(execFile) const runtimeHostIdentity = `runtime:${randomUUID()}` @@ -11,7 +10,6 @@ const runtimeProcessIdentity = `runtime:${randomUUID()}` let hostIdentityPromise: Promise | undefined let bootIdentityPromise: Promise | undefined const HOST_TOKEN_PATTERN = /^[\da-f]{8}-[\da-f]{4}-4[\da-f]{3}-[89ab][\da-f]{3}-[\da-f]{12}$/i -const HOST_TOKEN_FILE_MAX_BYTES = 1024 function hasCode(error: unknown, code: string): boolean { return error instanceof Error && 'code' in error && error.code === code @@ -45,8 +43,7 @@ async function readPublishedHostToken(path: string, uid: number): Promise { expect(persist).toHaveBeenCalledTimes(1) expect(persist).toHaveBeenCalledWith([sibling, otherTab]) }) - - it('evicts the oldest unsupported PTY after the retention ceiling', () => { - for (let index = 0; index < MIGRATION_UNSUPPORTED_PTY_MAX_ENTRIES; index++) { - setMigrationUnsupportedPty(makeEntry(`pty-${index}`, `tab-${index}:leaf-a`)) - } - const listener = vi.fn() - const persist = vi.fn() - setMigrationUnsupportedPtyListener(listener) - setMigrationUnsupportedPtyPersistenceListener(persist) - const newest = makeEntry(`pty-${MIGRATION_UNSUPPORTED_PTY_MAX_ENTRIES}`, 'tab-newest:leaf-a') - - setMigrationUnsupportedPty(newest) - - const snapshot = getMigrationUnsupportedPtySnapshot() - expect(snapshot).toHaveLength(MIGRATION_UNSUPPORTED_PTY_MAX_ENTRIES) - expect(snapshot.some((entry) => entry.ptyId === 'pty-0')).toBe(false) - expect(snapshot.at(-1)).toEqual(newest) - expect(listener).toHaveBeenNthCalledWith(1, { type: 'clear', ptyId: 'pty-0' }) - expect(listener).toHaveBeenNthCalledWith(2, { type: 'set', entry: newest }) - expect(persist).toHaveBeenCalledTimes(1) - expect(persist).toHaveBeenCalledWith(snapshot) - }) - - it('rejects oversized entries and clears a prior value for the same PTY', () => { - const listener = vi.fn() - const persist = vi.fn() - setMigrationUnsupportedPtyListener(listener) - setMigrationUnsupportedPtyPersistenceListener(persist) - setMigrationUnsupportedPty(makeEntry('pty-1', 'tab-1:leaf-a')) - listener.mockClear() - persist.mockClear() - - setMigrationUnsupportedPty( - makeEntry('pty-1', `tab-1:${'x'.repeat(MIGRATION_UNSUPPORTED_PTY_MAX_ENTRY_BYTES)}`) - ) - - expect(getMigrationUnsupportedPtySnapshot()).toEqual([]) - expect(listener).toHaveBeenCalledOnce() - expect(listener).toHaveBeenCalledWith({ type: 'clear', ptyId: 'pty-1' }) - expect(persist).toHaveBeenCalledOnce() - expect(persist).toHaveBeenCalledWith([]) - }) }) diff --git a/src/main/agent-hooks/migration-unsupported-pty-state.ts b/src/main/agent-hooks/migration-unsupported-pty-state.ts index dc467c12727..36bdc838569 100644 --- a/src/main/agent-hooks/migration-unsupported-pty-state.ts +++ b/src/main/agent-hooks/migration-unsupported-pty-state.ts @@ -1,13 +1,10 @@ import type { MigrationUnsupportedPtyEntry } from '../../shared/agent-status-types' -import { stringifyJsonWithinByteLimit } from '../../shared/node-bounded-json-stringify' type MigrationUnsupportedPtyEvent = | { type: 'set'; entry: MigrationUnsupportedPtyEntry } | { type: 'clear'; ptyId: string } const entriesByPtyId = new Map() -export const MIGRATION_UNSUPPORTED_PTY_MAX_ENTRIES = 500 -export const MIGRATION_UNSUPPORTED_PTY_MAX_ENTRY_BYTES = 16 * 1024 let listener: ((event: MigrationUnsupportedPtyEvent) => void) | null = null let persistenceListener: ((entries: MigrationUnsupportedPtyEntry[]) => void) | null = null @@ -28,21 +25,7 @@ export function setMigrationUnsupportedPtyPersistenceListener( } export function setMigrationUnsupportedPty(entry: MigrationUnsupportedPtyEntry): void { - try { - stringifyJsonWithinByteLimit(entry, MIGRATION_UNSUPPORTED_PTY_MAX_ENTRY_BYTES) - } catch { - clearMigrationUnsupportedPty(entry.ptyId) - return - } entriesByPtyId.set(entry.ptyId, entry) - while (entriesByPtyId.size > MIGRATION_UNSUPPORTED_PTY_MAX_ENTRIES) { - const oldestPtyId = entriesByPtyId.keys().next().value - if (oldestPtyId === undefined) { - break - } - entriesByPtyId.delete(oldestPtyId) - listener?.({ type: 'clear', ptyId: oldestPtyId }) - } listener?.({ type: 'set', entry }) persistenceListener?.(getMigrationUnsupportedPtySnapshot()) } diff --git a/src/main/agent-hooks/server-retained-metadata-bounds.test.ts b/src/main/agent-hooks/server-retained-metadata-bounds.test.ts deleted file mode 100644 index 22a03c845a0..00000000000 --- a/src/main/agent-hooks/server-retained-metadata-bounds.test.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { - AgentHookServer, - MAX_AGENT_HOOK_RETAINED_ID_UTF8_BYTES, - MAX_AGENT_HOOK_RETAINED_PATH_UTF8_BYTES -} from './server' -import { makePaneKey } from '../../shared/stable-pane-id' - -vi.mock('../telemetry/client', () => ({ track: vi.fn() })) -vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: () => ({}) })) - -const PANE_KEY = makePaneKey('metadata-bounds-tab', '11111111-1111-4111-8111-111111111111') - -describe('AgentHookServer retained metadata bounds', () => { - it('preserves every metadata field at its exact UTF-8 byte limit', () => { - const server = new AgentHookServer() - const exactId = 'é'.repeat(MAX_AGENT_HOOK_RETAINED_ID_UTF8_BYTES / 2) - const exactPath = 'é'.repeat(MAX_AGENT_HOOK_RETAINED_PATH_UTF8_BYTES / 2) - - expect(Buffer.byteLength(exactId, 'utf8')).toBe(MAX_AGENT_HOOK_RETAINED_ID_UTF8_BYTES) - expect(Buffer.byteLength(exactPath, 'utf8')).toBe(MAX_AGENT_HOOK_RETAINED_PATH_UTF8_BYTES) - - server.ingestRemote( - { - paneKey: PANE_KEY, - tabId: 'metadata-bounds-tab', - worktreeId: exactPath, - launchToken: exactId, - promptInteractionKey: exactId, - hookEventName: exactId, - toolUseId: exactId, - toolAgentId: exactId, - toolAgentType: exactId, - providerSession: { - key: 'session_id', - id: 'session-1', - transcriptPath: exactPath - }, - payload: { state: 'working', prompt: 'ordinary prompt', agentType: 'claude' } - }, - exactId - ) - - const retained = server._getStateForTests().lastStatusByPaneKey.get(PANE_KEY) - expect(retained).toMatchObject({ - launchToken: exactId, - worktreeId: exactPath, - connectionId: exactId, - promptInteractionKey: exactId, - hookEventName: exactId, - toolUseId: exactId, - toolAgentId: exactId, - toolAgentType: exactId, - providerSession: { - key: 'session_id', - id: 'session-1', - transcriptPath: exactPath - } - }) - }) - - it('omits optional metadata one byte over its limit', () => { - const server = new AgentHookServer() - const oversizedId = `${'é'.repeat(MAX_AGENT_HOOK_RETAINED_ID_UTF8_BYTES / 2)}x` - const oversizedPath = `${'é'.repeat(MAX_AGENT_HOOK_RETAINED_PATH_UTF8_BYTES / 2)}x` - - expect(Buffer.byteLength(oversizedId, 'utf8')).toBe(MAX_AGENT_HOOK_RETAINED_ID_UTF8_BYTES + 1) - expect(Buffer.byteLength(oversizedPath, 'utf8')).toBe( - MAX_AGENT_HOOK_RETAINED_PATH_UTF8_BYTES + 1 - ) - - server.ingestRemote( - { - paneKey: PANE_KEY, - tabId: 'metadata-bounds-tab', - worktreeId: oversizedPath, - launchToken: oversizedId, - promptInteractionKey: oversizedId, - hookEventName: oversizedId, - toolUseId: oversizedId, - toolAgentId: oversizedId, - toolAgentType: oversizedId, - providerSession: { - key: 'session_id', - id: 'session-1', - transcriptPath: oversizedPath - }, - payload: { state: 'working', prompt: 'ordinary prompt', agentType: 'claude' } - }, - 'connection-1' - ) - - const retained = server._getStateForTests().lastStatusByPaneKey.get(PANE_KEY) - expect(retained).toMatchObject({ - connectionId: 'connection-1', - providerSession: { key: 'session_id', id: 'session-1' } - }) - expect(retained).not.toHaveProperty('providerSession.transcriptPath') - for (const field of [ - 'launchToken', - 'worktreeId', - 'promptInteractionKey', - 'hookEventName', - 'toolUseId', - 'toolAgentId', - 'toolAgentType' - ]) { - expect(retained?.[field as keyof typeof retained]).toBeUndefined() - } - }) - - it('rejects oversized connection authority without retaining a watermark', () => { - const server = new AgentHookServer() - const listener = vi.fn() - const oversizedConnectionId = 'x'.repeat(MAX_AGENT_HOOK_RETAINED_ID_UTF8_BYTES + 1) - server.setPaneStatusClearListener(listener) - - server.ingestRemote( - { - paneKey: PANE_KEY, - payload: { state: 'working', prompt: 'ordinary prompt', agentType: 'claude' } - }, - oversizedConnectionId - ) - server.clearStatusEntriesForConnection(oversizedConnectionId) - - expect(server.getStatusSnapshot()).toEqual([]) - expect( - ( - server as unknown as { - connectionTimestampWatermarkById: Map - } - ).connectionTimestampWatermarkById - ).toHaveLength(0) - expect(listener).not.toHaveBeenCalled() - }) - - it('rejects a path-dependent Pi identity when its transcript path is oversized', () => { - const server = new AgentHookServer() - - server.ingestRemote( - { - paneKey: PANE_KEY, - providerSession: { - key: 'session_id', - id: 'pi-session-1', - transcriptPath: 'x'.repeat(MAX_AGENT_HOOK_RETAINED_PATH_UTF8_BYTES + 1) - }, - providerSessionOnly: true, - payload: { state: 'done', prompt: '', agentType: 'pi' } - }, - 'connection-1' - ) - - expect(server.getStatusSnapshot()).toEqual([]) - }) -}) diff --git a/src/main/agent-hooks/server-status-cache-bound.test.ts b/src/main/agent-hooks/server-status-cache-bound.test.ts deleted file mode 100644 index 8505baf7b31..00000000000 --- a/src/main/agent-hooks/server-status-cache-bound.test.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { describe, expect, it, vi } from 'vitest' -import { - AgentHookServer, - MAX_AGENT_HOOK_CONNECTION_TIMESTAMP_WATERMARKS, - MAX_AGENT_HOOK_LAST_STATUS_FILE_BYTES -} from './server' -import { MAX_AGENT_HOOK_STATUS_CACHE_PANES } from '../../shared/agent-hook-status-cache' -import type { AgentHookEventPayload } from '../../shared/agent-hook-listener' -import { AGENT_STATUS_STALE_AFTER_MS } from '../../shared/agent-status-types' -import { makePaneKey } from '../../shared/stable-pane-id' - -vi.mock('../telemetry/client', () => ({ track: vi.fn() })) -vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: () => ({}) })) - -const LEAF_ID = '11111111-1111-4111-8111-111111111111' - -function paneKey(index: number): string { - return makePaneKey(`status-cache-${index}`, LEAF_ID) -} - -function cachedStatus( - index: number, - state: AgentHookEventPayload['payload']['state'], - receivedAt: number -): AgentHookEventPayload & { receivedAt: number; stateStartedAt: number } { - return { - paneKey: paneKey(index), - connectionId: null, - receivedAt, - stateStartedAt: receivedAt, - payload: { state, prompt: `prompt-${index}`, agentType: 'claude' } - } -} - -describe('AgentHookServer status cache bound', () => { - it('evicts a completed row before fresh work and clears related state', () => { - const server = new AgentHookServer() - const listener = server._getStateForTests() - const now = Date.now() - for (let index = 0; index < MAX_AGENT_HOOK_STATUS_CACHE_PANES; index += 1) { - const state = index === MAX_AGENT_HOOK_STATUS_CACHE_PANES - 1 ? 'done' : 'working' - listener.lastStatusByPaneKey.set(paneKey(index), cachedStatus(index, state, now)) - } - const completedPaneKey = paneKey(MAX_AGENT_HOOK_STATUS_CACHE_PANES - 1) - listener.lastPromptByPaneKey.set(completedPaneKey, 'cached prompt') - const onClear = vi.fn() - server.setPaneStatusClearListener(onClear) - - const currentPaneKey = paneKey(MAX_AGENT_HOOK_STATUS_CACHE_PANES) - server.ingestRemote( - { - paneKey: currentPaneKey, - payload: { state: 'working', prompt: 'current', agentType: 'claude' } - }, - 'connection-1' - ) - - expect(listener.lastStatusByPaneKey.size).toBe(MAX_AGENT_HOOK_STATUS_CACHE_PANES) - expect(listener.lastStatusByPaneKey.has(paneKey(0))).toBe(true) - expect(listener.lastStatusByPaneKey.has(completedPaneKey)).toBe(false) - expect(listener.lastStatusByPaneKey.has(currentPaneKey)).toBe(true) - expect(listener.lastPromptByPaneKey.has(completedPaneKey)).toBe(false) - expect(onClear).toHaveBeenCalledOnce() - expect(onClear).toHaveBeenCalledWith({ paneKey: completedPaneKey }) - }) - - it('evicts a stale hydrated row before older fresh work at capacity', async () => { - const userDataPath = mkdtempSync(join(tmpdir(), 'orca-agent-status-cache-')) - const endpointDir = join(userDataPath, 'agent-hooks') - mkdirSync(endpointDir, { recursive: true }) - const now = Date.now() - const entries: Record> = {} - for (let index = 0; index <= MAX_AGENT_HOOK_STATUS_CACHE_PANES; index += 1) { - const receivedAt = index === 1 ? now - AGENT_STATUS_STALE_AFTER_MS - 1 : now - entries[paneKey(index)] = cachedStatus(index, 'working', receivedAt) - } - writeFileSync(join(endpointDir, 'last-status.json'), JSON.stringify({ version: 2, entries })) - const server = new AgentHookServer() - - try { - await server.start({ env: 'production', userDataPath }) - const listener = server._getStateForTests() - - expect(listener.lastStatusByPaneKey.size).toBe(MAX_AGENT_HOOK_STATUS_CACHE_PANES) - expect(listener.lastStatusByPaneKey.has(paneKey(0))).toBe(true) - expect(listener.lastStatusByPaneKey.has(paneKey(1))).toBe(false) - expect(listener.lastStatusByPaneKey.has(paneKey(MAX_AGENT_HOOK_STATUS_CACHE_PANES))).toBe( - true - ) - } finally { - server.stop() - rmSync(userDataPath, { recursive: true, force: true }) - } - }) - - it('bounds connection watermarks and refreshes recency before overflow', () => { - const server = new AgentHookServer() - const watermarks = ( - server as unknown as { - connectionTimestampWatermarkById: Map - } - ).connectionTimestampWatermarkById - - for (let index = 0; index < MAX_AGENT_HOOK_CONNECTION_TIMESTAMP_WATERMARKS; index += 1) { - server.clearStatusEntriesForConnection(`connection-${index}`) - } - expect(watermarks.size).toBe(MAX_AGENT_HOOK_CONNECTION_TIMESTAMP_WATERMARKS) - - server.clearStatusEntriesForConnection('connection-0') - server.clearStatusEntriesForConnection( - `connection-${MAX_AGENT_HOOK_CONNECTION_TIMESTAMP_WATERMARKS}` - ) - - expect(watermarks.size).toBe(MAX_AGENT_HOOK_CONNECTION_TIMESTAMP_WATERMARKS) - expect(watermarks.has('connection-0')).toBe(true) - expect(watermarks.has('connection-1')).toBe(false) - expect(watermarks.has(`connection-${MAX_AGENT_HOOK_CONNECTION_TIMESTAMP_WATERMARKS}`)).toBe( - true - ) - }) - - it('persists the newest statuses without exceeding its own hydration ceiling', async () => { - const userDataPath = mkdtempSync(join(tmpdir(), 'orca-agent-status-file-bound-')) - const server = new AgentHookServer() - - try { - await server.start({ env: 'production', userDataPath }) - const listener = server._getStateForTests() - const retainedMetadata = 'x'.repeat(64 * 1024) - for (let index = 0; index < MAX_AGENT_HOOK_STATUS_CACHE_PANES; index += 1) { - listener.lastStatusByPaneKey.set(paneKey(index), { - ...cachedStatus(index, 'working', Date.now()), - worktreeId: retainedMetadata - }) - } - - server.flushStatusPersistSync() - - const path = join(userDataPath, 'agent-hooks', 'last-status.json') - const persisted = JSON.parse(readFileSync(path, 'utf8')) as { - entries: Record - } - expect(statSync(path).size).toBeLessThanOrEqual(MAX_AGENT_HOOK_LAST_STATUS_FILE_BYTES) - expect(Object.keys(persisted.entries).length).toBeLessThan(MAX_AGENT_HOOK_STATUS_CACHE_PANES) - expect(persisted.entries[paneKey(MAX_AGENT_HOOK_STATUS_CACHE_PANES - 1)]).toBeDefined() - } finally { - server.stop() - rmSync(userDataPath, { recursive: true, force: true }) - } - }) -}) diff --git a/src/main/agent-hooks/server.test.ts b/src/main/agent-hooks/server.test.ts index 9fa87904af1..6b4e12042c5 100644 --- a/src/main/agent-hooks/server.test.ts +++ b/src/main/agent-hooks/server.test.ts @@ -8,7 +8,6 @@ import { readFileSync, rmSync, statSync, - truncateSync, utimesSync, writeFileSync } from 'node:fs' @@ -18,8 +17,6 @@ import { AgentHookServer, agentHookServer, CLOSED_AGENT_STATUS_TAB_IDS_MAX, - MAX_AGENT_HOOK_LAST_STATUS_FILE_BYTES, - MAX_AGENT_HOOK_LAST_STATUS_STRUCTURAL_TOKENS, _internals } from './server' import { @@ -6620,46 +6617,6 @@ describe('Last-status persistence', () => { } }) - it('treats an oversized sparse last-status file as empty hydration', async () => { - mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true }) - writeFileSync(lastStatusPath(), '{"version":2,"entries":{}}', 'utf8') - truncateSync(lastStatusPath(), MAX_AGENT_HOOK_LAST_STATUS_FILE_BYTES + 1) - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const server = new AgentHookServer() - await server.start({ env: 'production', userDataPath }) - try { - expect(server.getStatusSnapshot()).toEqual([]) - expect(warnSpy).toHaveBeenCalledWith( - '[agent-hooks] failed to read last-status file:', - expect.any(Error) - ) - } finally { - server.stop() - warnSpy.mockRestore() - } - }) - - it('rejects structurally amplified last-status JSON before parsing', async () => { - mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true }) - const values = '0,'.repeat(MAX_AGENT_HOOK_LAST_STATUS_STRUCTURAL_TOKENS) - writeFileSync(lastStatusPath(), `[${values}0]`, 'utf8') - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const parseSpy = vi.spyOn(JSON, 'parse') - const server = new AgentHookServer() - await server.start({ env: 'production', userDataPath }) - try { - expect(server.getStatusSnapshot()).toEqual([]) - expect(parseSpy).not.toHaveBeenCalled() - expect(warnSpy).toHaveBeenCalledWith( - '[agent-hooks] last-status file is invalid or too complex; ignoring' - ) - } finally { - server.stop() - warnSpy.mockRestore() - parseSpy.mockRestore() - } - }) - it('drops hydrated metadata-only entries without a resumable Pi session', async () => { mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true }) const receivedAt = recentTs() diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index 038372491ee..06dfb151560 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -2,19 +2,13 @@ // Why: this main-process adapter keeps listener internals in shared/ (`src/shared/agent-hook-listener.ts`) so the relay can host the same pipeline without Electron. See docs/design/agent-status-over-ssh.md §5. import { createServer, type IncomingMessage, type ServerResponse } from 'node:http' import { createHash, randomBytes, randomUUID } from 'node:crypto' -import { chmodSync, mkdirSync, renameSync, unlinkSync, writeFileSync } from 'node:fs' +import { chmodSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import { track } from '../telemetry/client' import { getCohortAtEmit } from '../telemetry/cohort-classifier' import { AGENT_KIND_VALUES, type AgentKind } from '../../shared/telemetry-events' import { ORCA_HOOK_PROTOCOL_VERSION } from '../../shared/agent-hook-types' -import { readNodeFileSyncWithinLimit } from '../../shared/node-bounded-file-reader' -import { assertJsonTextStructureWithinLimits } from '../../shared/json-text-structure-limit' -import { - JsonStringifyByteLimitError, - stringifyJsonWithinByteLimit -} from '../../shared/node-bounded-json-stringify' import { clearAllListenerCaches, clearPaneCacheState, @@ -41,7 +35,6 @@ import { type HookListenerState } from '../../shared/agent-hook-listener' import type { AgentHookSource } from '../../shared/agent-hook-relay' -import { upsertBoundedAgentHookStatus } from '../../shared/agent-hook-status-cache' import { CLAUDE_STATUSLINE_PATHNAME, parseClaudeStatusLineBody, @@ -76,7 +69,6 @@ import { type AgentProviderSessionMetadata } from '../../shared/agent-session-resume' import { isCommandCodeNewTurnWhileWorking } from '../../shared/command-code-turn-boundary' -import { measureUtf8ByteLength } from '../../shared/utf8-byte-limits' export type { AgentHookSource } @@ -104,9 +96,6 @@ type PaneKeyAliasEntry = { // Why: co-located with the endpoint file in userData/agent-hooks/ so hook-server cross-restart artifacts stay together. const LAST_STATUS_FILE_NAME = 'last-status.json' -export const MAX_AGENT_HOOK_LAST_STATUS_FILE_BYTES = 16 * 1024 * 1024 -export const MAX_AGENT_HOOK_LAST_STATUS_STRUCTURAL_TOKENS = 1_000_000 -export const MAX_AGENT_HOOK_LAST_STATUS_NESTING_DEPTH = 128 const ASSISTANT_MESSAGE_RETRY_ATTEMPTS = 5 const ASSISTANT_MESSAGE_RETRY_MS = 50 const INTERRUPTED_DONE_LATE_WORKING_SUPPRESSION_MS = 15_000 @@ -126,9 +115,6 @@ const HYDRATE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000 export const CLOSED_AGENT_STATUS_TAB_IDS_MAX = 1024 export const CLOSED_AGENT_STATUS_PANE_KEYS_MAX = 1024 export const PANE_KEY_ALIASES_MAX = 1024 -export const MAX_AGENT_HOOK_CONNECTION_TIMESTAMP_WATERMARKS = 1024 -export const MAX_AGENT_HOOK_RETAINED_ID_UTF8_BYTES = 1024 -export const MAX_AGENT_HOOK_RETAINED_PATH_UTF8_BYTES = 16 * 1024 type LastStatusFile = { version: number @@ -195,100 +181,6 @@ function isValidPiProviderSessionOnly( return Boolean(providerSession && agentType === 'pi' && getAgentResumeArgv('pi', providerSession)) } -function isRetainedStringWithinByteLimit(value: string, maxBytes: number): boolean { - return !measureUtf8ByteLength(value, { stopAfterBytes: maxBytes }).exceededLimit -} - -function retainOptionalStringWithinByteLimit(value: unknown, maxBytes: number): string | undefined { - if ( - typeof value !== 'string' || - value.length === 0 || - !isRetainedStringWithinByteLimit(value, maxBytes) - ) { - return undefined - } - return value -} - -function normalizeOptionalRetainedString(value: unknown, maxBytes: number): string | undefined { - const retained = retainOptionalStringWithinByteLimit(value, maxBytes) - if (!retained) { - return undefined - } - const trimmed = retained.trim() - return trimmed.length > 0 ? trimmed : undefined -} - -function boundProviderSessionTranscriptPath( - providerSession: AgentProviderSessionMetadata | undefined -): AgentProviderSessionMetadata | undefined { - if (!providerSession?.transcriptPath) { - return providerSession - } - const transcriptPath = retainOptionalStringWithinByteLimit( - providerSession.transcriptPath, - MAX_AGENT_HOOK_RETAINED_PATH_UTF8_BYTES - ) - return transcriptPath === providerSession.transcriptPath - ? providerSession - : { key: providerSession.key, id: providerSession.id } -} - -function boundAgentHookEventMetadata(event: T): T { - const launchToken = retainOptionalStringWithinByteLimit( - event.launchToken, - MAX_AGENT_HOOK_RETAINED_ID_UTF8_BYTES - ) - const worktreeId = retainOptionalStringWithinByteLimit( - event.worktreeId, - MAX_AGENT_HOOK_RETAINED_PATH_UTF8_BYTES - ) - const promptInteractionKey = retainOptionalStringWithinByteLimit( - event.promptInteractionKey, - MAX_AGENT_HOOK_RETAINED_ID_UTF8_BYTES - ) - const hookEventName = retainOptionalStringWithinByteLimit( - event.hookEventName, - MAX_AGENT_HOOK_RETAINED_ID_UTF8_BYTES - ) - const toolUseId = retainOptionalStringWithinByteLimit( - event.toolUseId, - MAX_AGENT_HOOK_RETAINED_ID_UTF8_BYTES - ) - const toolAgentId = retainOptionalStringWithinByteLimit( - event.toolAgentId, - MAX_AGENT_HOOK_RETAINED_ID_UTF8_BYTES - ) - const toolAgentType = retainOptionalStringWithinByteLimit( - event.toolAgentType, - MAX_AGENT_HOOK_RETAINED_ID_UTF8_BYTES - ) - const providerSession = boundProviderSessionTranscriptPath(event.providerSession) - if ( - launchToken === event.launchToken && - worktreeId === event.worktreeId && - promptInteractionKey === event.promptInteractionKey && - hookEventName === event.hookEventName && - toolUseId === event.toolUseId && - toolAgentId === event.toolAgentId && - toolAgentType === event.toolAgentType && - providerSession === event.providerSession - ) { - return event - } - return { - ...event, - launchToken, - worktreeId, - promptInteractionKey, - hookEventName, - toolUseId, - toolAgentId, - toolAgentType, - providerSession - } -} - function sanitizeHydratedEntry( paneKey: string, rawEntry: unknown @@ -333,10 +225,7 @@ function sanitizeHydratedEntry( let connectionId: string | null if (connectionIdRaw === null || connectionIdRaw === undefined) { connectionId = null - } else if ( - typeof connectionIdRaw === 'string' && - isRetainedStringWithinByteLimit(connectionIdRaw, MAX_AGENT_HOOK_RETAINED_ID_UTF8_BYTES) - ) { + } else if (typeof connectionIdRaw === 'string') { connectionId = connectionIdRaw } else { return null @@ -345,14 +234,12 @@ function sanitizeHydratedEntry( if (!payload) { return null } - const providerSession = boundProviderSessionTranscriptPath( - normalizeAgentProviderSession(record.providerSession) ?? undefined - ) + const providerSession = normalizeAgentProviderSession(record.providerSession) ?? undefined const providerSessionOnly = record.providerSessionOnly === true if (providerSessionOnly && !isValidPiProviderSessionOnly(providerSession, payload.agentType)) { return null } - return boundAgentHookEventMetadata({ + return { paneKey, launchToken: typeof record.launchToken === 'string' ? record.launchToken : undefined, tabId: typeof tabId === 'string' ? tabId : undefined, @@ -368,7 +255,7 @@ function sanitizeHydratedEntry( payload, receivedAt, stateStartedAt - }) + } } function toAgentStatusIpcPayload(entry: EnrichedAgentHookEventPayload): AgentStatusIpcPayload { @@ -863,48 +750,6 @@ export class AgentHookServer { } } - private cacheStatusEntry(entry: EnrichedAgentHookEventPayload, now = entry.receivedAt): number { - const boundedEntry = boundAgentHookEventMetadata(entry) - const evicted = upsertBoundedAgentHookStatus(this.state, boundedEntry, { now }) - for (const { paneKey } of evicted) { - this.clearAssistantMessageRetry(paneKey) - this.runtimeObservedStatusPaneKeys.delete(paneKey) - this.promptSentDedupeByPaneKey.delete(paneKey) - this.onPaneStatusCleared?.({ paneKey }) - } - return evicted.length - } - - private rememberConnectionTimestampWatermark(connectionId: string, timestamp: number): void { - this.connectionTimestampWatermarkById.delete(connectionId) - this.connectionTimestampWatermarkById.set(connectionId, timestamp) - if ( - this.connectionTimestampWatermarkById.size <= MAX_AGENT_HOOK_CONNECTION_TIMESTAMP_WATERMARKS - ) { - return - } - const activeConnectionIds = new Set() - for (const entry of this.state.lastStatusByPaneKey.values()) { - if (entry.connectionId) { - activeConnectionIds.add(entry.connectionId) - } - } - let oldestFallback: string | undefined - for (const retainedConnectionId of this.connectionTimestampWatermarkById.keys()) { - if (retainedConnectionId === connectionId) { - continue - } - oldestFallback ??= retainedConnectionId - if (!activeConnectionIds.has(retainedConnectionId)) { - this.connectionTimestampWatermarkById.delete(retainedConnectionId) - return - } - } - if (oldestFallback) { - this.connectionTimestampWatermarkById.delete(oldestFallback) - } - } - private hashPromptForTelemetryDedupe(prompt: string): string { return createHash('sha256') .update(this.promptSentHashSalt) @@ -974,8 +819,7 @@ export class AgentHookServer { } } - private applyNormalizedStatus(rawPayload: AgentHookEventPayload): EnrichedAgentHookEventPayload { - const payload = boundAgentHookEventMetadata(rawPayload) + private applyNormalizedStatus(payload: AgentHookEventPayload): EnrichedAgentHookEventPayload { const previous = this.state.lastStatusByPaneKey.get(payload.paneKey) as | EnrichedAgentHookEventPayload | undefined @@ -985,14 +829,14 @@ export class AgentHookServer { // Why: Date.now() can repeat across reconnect; a remote replay must sort strictly after its connection's transient clear. const now = Math.max(Date.now(), (connectionClearWatermark ?? -1) + 1) if (payload.connectionId) { - this.rememberConnectionTimestampWatermark(payload.connectionId, now) + this.connectionTimestampWatermarkById.set(payload.connectionId, now) } if (payload.providerSessionOnly) { // Why: Pi session_start replaces stale turn state and survives replay, but must not emit prompt telemetry or a fabricated status. const enriched = this.attachStatusTiming(payload, now) this.clearAssistantMessageRetry(enriched.paneKey) this.runtimeObservedStatusPaneKeys.delete(enriched.paneKey) - this.cacheStatusEntry(enriched) + this.state.lastStatusByPaneKey.set(enriched.paneKey, enriched) this.scheduleStatusPersist() this.notifyStatusChangeListeners() this.onAgentStatus?.(enriched) @@ -1104,7 +948,7 @@ export class AgentHookServer { } const enriched = this.attachStatusTiming(effectivePayload, now) this.runtimeObservedStatusPaneKeys.add(enriched.paneKey) - this.cacheStatusEntry(enriched) + this.state.lastStatusByPaneKey.set(enriched.paneKey, enriched) this.scheduleStatusPersist() this.notifyStatusChangeListeners() this.onAgentStatus?.(enriched) @@ -1476,16 +1320,10 @@ export class AgentHookServer { if (this.shouldSuppressClosedTabStatus(paneKey)) { return } - const worktreeId = normalizeOptionalRetainedString( - event.worktreeId, - MAX_AGENT_HOOK_RETAINED_PATH_UTF8_BYTES - ) - if ( - typeof event.connectionId === 'string' && - !isRetainedStringWithinByteLimit(event.connectionId, MAX_AGENT_HOOK_RETAINED_ID_UTF8_BYTES) - ) { - return - } + const worktreeId = + event.worktreeId !== undefined && event.worktreeId.trim().length > 0 + ? event.worktreeId.trim() + : undefined const connectionId = typeof event.connectionId === 'string' && event.connectionId.trim().length > 0 ? event.connectionId.trim() @@ -1537,9 +1375,6 @@ export class AgentHookServer { if (typeof connectionId !== 'string') { return } - if (!isRetainedStringWithinByteLimit(connectionId, MAX_AGENT_HOOK_RETAINED_ID_UTF8_BYTES)) { - return - } const trimmedConnectionId = connectionId.trim() if (trimmedConnectionId.length === 0) { return @@ -1583,33 +1418,32 @@ export class AgentHookServer { if (this.shouldSuppressClosedTabStatus(paneKey)) { return } - const worktreeId = normalizeOptionalRetainedString( - envelope.worktreeId, - MAX_AGENT_HOOK_RETAINED_PATH_UTF8_BYTES - ) - const hookEventName = normalizeOptionalRetainedString( - envelope.hookEventName, - MAX_AGENT_HOOK_RETAINED_ID_UTF8_BYTES - ) - const promptInteractionKey = normalizeOptionalRetainedString( - envelope.promptInteractionKey, - MAX_AGENT_HOOK_RETAINED_ID_UTF8_BYTES - ) - const toolUseId = normalizeOptionalRetainedString( - envelope.toolUseId, - MAX_AGENT_HOOK_RETAINED_ID_UTF8_BYTES - ) - const toolAgentId = normalizeOptionalRetainedString( - envelope.toolAgentId, - MAX_AGENT_HOOK_RETAINED_ID_UTF8_BYTES - ) - const toolAgentType = normalizeOptionalRetainedString( - envelope.toolAgentType, - MAX_AGENT_HOOK_RETAINED_ID_UTF8_BYTES - ) - const providerSession = boundProviderSessionTranscriptPath( - normalizeAgentProviderSession(envelope.providerSession) ?? undefined - ) + const worktreeId = + envelope.worktreeId !== undefined && envelope.worktreeId.trim().length > 0 + ? envelope.worktreeId.trim() + : undefined + const hookEventName = + typeof envelope.hookEventName === 'string' && envelope.hookEventName.trim().length > 0 + ? envelope.hookEventName.trim() + : undefined + const promptInteractionKey = + typeof envelope.promptInteractionKey === 'string' && + envelope.promptInteractionKey.trim().length > 0 + ? envelope.promptInteractionKey.trim() + : undefined + const toolUseId = + typeof envelope.toolUseId === 'string' && envelope.toolUseId.trim().length > 0 + ? envelope.toolUseId.trim() + : undefined + const toolAgentId = + typeof envelope.toolAgentId === 'string' && envelope.toolAgentId.trim().length > 0 + ? envelope.toolAgentId.trim() + : undefined + const toolAgentType = + typeof envelope.toolAgentType === 'string' && envelope.toolAgentType.trim().length > 0 + ? envelope.toolAgentType.trim() + : undefined + const providerSession = normalizeAgentProviderSession(envelope.providerSession) ?? undefined // Why: relay crosses a trust boundary — re-run the canonical normalizer to enforce caps/invariants (returns null on malformed). const normalizedPayload = normalizeAgentStatusPayload(envelope.payload) if (!normalizedPayload) { @@ -1629,10 +1463,7 @@ export class AgentHookServer { }) const event: AgentHookEventPayload = { paneKey, - launchToken: normalizeOptionalRetainedString( - envelope.launchToken, - MAX_AGENT_HOOK_RETAINED_ID_UTF8_BYTES - ), + launchToken: envelope.launchToken, tabId, worktreeId, connectionId: trimmedConnectionId, @@ -1717,14 +1548,8 @@ export class AgentHookServer { trackEmptyPaneKeyHook(body) const aliasedBody = this.normalizeHookBodyPaneKeyAlias(body) const normalized = normalizeHookPayload(this.state, source, aliasedBody, this.env) - const retained = normalized ? boundAgentHookEventMetadata(normalized) : null - if ( - retained && - (!retained.providerSessionOnly || - isValidPiProviderSessionOnly(retained.providerSession, retained.payload.agentType)) && - !this.shouldSuppressClosedTabStatus(retained.paneKey) - ) { - const enriched = this.applyNormalizedStatus(retained) + if (normalized && !this.shouldSuppressClosedTabStatus(normalized.paneKey)) { + const enriched = this.applyNormalizedStatus(normalized) this.scheduleAssistantMessageRetry(source, aliasedBody, enriched) } @@ -1801,9 +1626,6 @@ export class AgentHookServer { /** Clear statuses proven to belong to one lost SSH transport. */ clearStatusEntriesForConnection(connectionId: string): void { - if (!isRetainedStringWithinByteLimit(connectionId, MAX_AGENT_HOOK_RETAINED_ID_UTF8_BYTES)) { - return - } const normalizedConnectionId = connectionId.trim() if (normalizedConnectionId.length === 0) { return @@ -1812,7 +1634,7 @@ export class AgentHookServer { Date.now(), (this.connectionTimestampWatermarkById.get(normalizedConnectionId) ?? -1) + 1 ) - this.rememberConnectionTimestampWatermark(normalizedConnectionId, clearedAt) + this.connectionTimestampWatermarkById.set(normalizedConnectionId, clearedAt) let statusChanged = false for (const [paneKey, rawEntry] of this.state.lastStatusByPaneKey) { const entry = rawEntry as EnrichedAgentHookEventPayload @@ -2007,10 +1829,7 @@ export class AgentHookServer { this.state.lastStatusByPaneKey.clear() let raw: string try { - raw = readNodeFileSyncWithinLimit( - this.lastStatusFilePath, - MAX_AGENT_HOOK_LAST_STATUS_FILE_BYTES - ).buffer.toString('utf8') + raw = readFileSync(this.lastStatusFilePath, 'utf8') } catch (err) { // Why: missing file is normal (first launch); other errors degrade to empty hydration + one warn. if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { @@ -2020,13 +1839,9 @@ export class AgentHookServer { } let parsed: unknown try { - assertJsonTextStructureWithinLimits(raw, { - structuralTokens: MAX_AGENT_HOOK_LAST_STATUS_STRUCTURAL_TOKENS, - nestingDepth: MAX_AGENT_HOOK_LAST_STATUS_NESTING_DEPTH - }) parsed = JSON.parse(raw) } catch { - console.warn('[agent-hooks] last-status file is invalid or too complex; ignoring') + console.warn('[agent-hooks] last-status file is not valid JSON; ignoring') return } if (typeof parsed !== 'object' || parsed === null) { @@ -2051,8 +1866,7 @@ export class AgentHookServer { let dropped = 0 let prunedLegacyClaudeSubagents = 0 // Why: drop entries older than HYDRATE_MAX_AGE_MS to bound disk growth (one Date.now() for a consistent cutoff). - const hydrateNow = Date.now() - const ttlCutoff = hydrateNow - HYDRATE_MAX_AGE_MS + const ttlCutoff = Date.now() - HYDRATE_MAX_AGE_MS for (const [paneKey, rawEntry] of Object.entries(entries)) { const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey) const rawResolvedEntry = @@ -2067,11 +1881,11 @@ export class AgentHookServer { (entry.payload.subagents?.length ?? 0) - (hydratedPayload.subagents?.length ?? 0) entry.payload = hydratedPayload } - dropped += this.cacheStatusEntry(entry, hydrateNow) + this.state.lastStatusByPaneKey.set(resolvedPaneKey, entry) if (entry.connectionId) { // Why: a restart can see an earlier wall clock; seed ordering so new events stay after disk state. const previousWatermark = this.connectionTimestampWatermarkById.get(entry.connectionId) - this.rememberConnectionTimestampWatermark( + this.connectionTimestampWatermarkById.set( entry.connectionId, Math.max(previousWatermark ?? -1, entry.receivedAt) ) @@ -2093,7 +1907,7 @@ export class AgentHookServer { } if (dropped > 0) { console.warn( - `[agent-hooks] last-status hydrate dropped ${dropped} entries (kept ${this.state.lastStatusByPaneKey.size})` + `[agent-hooks] last-status hydrate dropped ${dropped} entries (kept ${hydrated})` ) } if (dropped > 0 || prunedLegacyClaudeSubagents > 0) { @@ -2106,45 +1920,17 @@ export class AgentHookServer { } private serializeStatusFile(): string { - const prefix = `{"version":${LAST_STATUS_FILE_VERSION},"entries":{` - const suffix = '}}' - let remainingBytes = - MAX_AGENT_HOOK_LAST_STATUS_FILE_BYTES - - Buffer.byteLength(prefix, 'utf8') - - Buffer.byteLength(suffix, 'utf8') - const fragments: string[] = [] - const statuses = Array.from(this.state.lastStatusByPaneKey) - for (let index = statuses.length - 1; index >= 0; index -= 1) { - const [paneKey, payload] = statuses[index] + const entries: Record = {} + for (const [paneKey, payload] of this.state.lastStatusByPaneKey) { // Why: never persist invalid keys (matches the hydrate-path invariant). if (!isValidPaneKey(paneKey)) { continue } const { promptInteractionKey: _promptInteractionKey, ...persistedPayload } = payload - const propertyPrefix = `${JSON.stringify(paneKey)}:` - const separatorBytes = fragments.length > 0 ? 1 : 0 - const payloadBudget = - remainingBytes - separatorBytes - Buffer.byteLength(propertyPrefix, 'utf8') - if (payloadBudget < 0) { - continue - } - let serializedPayload: string - try { - serializedPayload = stringifyJsonWithinByteLimit(persistedPayload, payloadBudget).serialized - } catch (error) { - if (error instanceof JsonStringifyByteLimitError) { - continue - } - throw error - } - fragments.push(`${propertyPrefix}${serializedPayload}`) - remainingBytes -= - separatorBytes + - Buffer.byteLength(propertyPrefix, 'utf8') + - Buffer.byteLength(serializedPayload, 'utf8') + entries[paneKey] = persistedPayload as EnrichedAgentHookEventPayload } - fragments.reverse() - return `${prefix}${fragments.join(',')}${suffix}` + const file: LastStatusFile = { version: LAST_STATUS_FILE_VERSION, entries } + return JSON.stringify(file) } private scheduleStatusPersist(): void { diff --git a/src/main/agent-hooks/wsl-hook-fs-adapter.ts b/src/main/agent-hooks/wsl-hook-fs-adapter.ts index 826a314f2b7..d47dd400199 100644 --- a/src/main/agent-hooks/wsl-hook-fs-adapter.ts +++ b/src/main/agent-hooks/wsl-hook-fs-adapter.ts @@ -5,20 +5,10 @@ // `installer-utils-remote.ts` touches are implemented. import type { SFTPWrapper } from 'ssh2' -import { assertFilesystemDirectoryWithinLimit } from '../../shared/filesystem-directory-listing-limit' -import { NodeFileReadTooLargeError } from '../../shared/node-bounded-file-reader' import type { installRemoteManagedAgentHooks } from './remote-managed-hook-installers' import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' import { wslCodexRuntimeHomeForGuestHome } from '../pty/codex-home-wsl-env' -import { - WSL_HOOK_FS_METHODS, - WSL_HOOK_FS_MAX_DIRECTORY_ENTRIES, - WSL_HOOK_FS_MAX_DIRECTORY_RETAINED_BYTES, - WSL_HOOK_FS_MAX_READ_BYTES, - type WslFsFailure, - type WslFsResult, - type WslHookFsDirectoryLimits -} from '../../shared/wsl-hook-relay-contract' +import { WSL_HOOK_FS_METHODS, type WslFsResult } from '../../shared/wsl-hook-relay-contract' /** Run the shared remote hook installers against a WSL guest over the relay's * fs bridge. Codex is the one agent whose home Orca redirects for WSL @@ -54,23 +44,7 @@ const ERRNO_TO_SFTP_CODE: Record = { EEXIST: 4 } -function toSftpError(failure: { - errno?: string - message?: string - fileCapacity?: { observedBytes: number; maxBytes: number } -}): Error { - if ( - failure.errno === 'EFBIG' && - Number.isSafeInteger(failure.fileCapacity?.observedBytes) && - Number.isSafeInteger(failure.fileCapacity?.maxBytes) && - failure.fileCapacity!.observedBytes >= 0 && - failure.fileCapacity!.maxBytes >= 0 - ) { - return new NodeFileReadTooLargeError( - failure.fileCapacity!.observedBytes, - failure.fileCapacity!.maxBytes - ) - } +function toSftpError(failure: { errno?: string; message?: string }): Error { const err = new Error(failure.message ?? 'wsl fs bridge failure') as Error & { code?: number } err.code = ERRNO_TO_SFTP_CODE[failure.errno ?? ''] ?? 5 return err @@ -88,7 +62,7 @@ export function createWslHookSftpAdapter(mux: SshChannelMultiplexer): SFTPWrappe .then((raw) => { const result = raw as WslFsResult if (!result || typeof result !== 'object' || result.ok !== true) { - callback(toSftpError((result ?? {}) as WslFsFailure)) + callback(toSftpError((result ?? {}) as { errno?: string; message?: string })) return } callback(null, pick(result)) @@ -107,15 +81,7 @@ export function createWslHookSftpAdapter(mux: SshChannelMultiplexer): SFTPWrappe readFile(path: string, _encoding: unknown, callback: SftpCallback): void { call<{ content: string }, string>( WSL_HOOK_FS_METHODS.readFile, - { path, maxBytes: WSL_HOOK_FS_MAX_READ_BYTES }, - callback, - (r) => r.content - ) - }, - orcaReadFileWithinLimit(path: string, maxBytes: number, callback: SftpCallback): void { - call<{ content: string }, string>( - WSL_HOOK_FS_METHODS.readFile, - { path, maxBytes }, + { path }, callback, (r) => r.content ) @@ -156,45 +122,9 @@ export function createWslHookSftpAdapter(mux: SshChannelMultiplexer): SFTPWrappe readdir(path: string, callback: SftpCallback<{ filename: string }[]>): void { call<{ entries: { filename: string }[] }, { filename: string }[]>( WSL_HOOK_FS_METHODS.readdir, - { - path, - maxEntries: WSL_HOOK_FS_MAX_DIRECTORY_ENTRIES, - maxRetainedBytes: WSL_HOOK_FS_MAX_DIRECTORY_RETAINED_BYTES - }, + { path }, callback, - (r) => { - const limits = { - maxEntries: WSL_HOOK_FS_MAX_DIRECTORY_ENTRIES, - maxRetainedBytes: WSL_HOOK_FS_MAX_DIRECTORY_RETAINED_BYTES - } - assertFilesystemDirectoryWithinLimit( - r.entries.map((entry) => ({ name: entry.filename })), - limits - ) - return r.entries - } - ) - }, - orcaReaddirWithinLimit( - path: string, - limits: WslHookFsDirectoryLimits, - callback: SftpCallback<{ filename: string }[]> - ): void { - call<{ entries: { filename: string }[] }, { filename: string }[]>( - WSL_HOOK_FS_METHODS.readdir, - { - path, - maxEntries: limits.maxEntries, - maxRetainedBytes: limits.maxRetainedBytes - }, - callback, - (r) => { - assertFilesystemDirectoryWithinLimit( - r.entries.map((entry) => ({ name: entry.filename })), - limits - ) - return r.entries - } + (r) => r.entries ) }, mkdir(path: string, callback: SftpCallback): void { diff --git a/src/main/agent-hooks/wsl-hook-relay-deps.ts b/src/main/agent-hooks/wsl-hook-relay-deps.ts index a40e340b593..2c30739e25b 100644 --- a/src/main/agent-hooks/wsl-hook-relay-deps.ts +++ b/src/main/agent-hooks/wsl-hook-relay-deps.ts @@ -2,12 +2,12 @@ // production wiring. Tests construct the manager with fakes for everything // that spawns wsl.exe or touches the live agentHookServer. import { createHash } from 'node:crypto' +import { readFileSync } from 'node:fs' import { agentHookServer } from './server' import { installRemoteManagedAgentHooks } from './remote-managed-hook-installers' import { isWslDistroRunning, - readWslHookRelayBundle, resolveWslHookRelayBundle, runWslInstallProcess, spawnWslRelayProcess @@ -73,7 +73,7 @@ export const defaultWslHookRelayDeps: WslHookRelayManagerDeps = { return source ? createHash('sha256').update(source).digest('hex').slice(0, 12) : null }, resolveBundle: resolveWslHookRelayBundle, - readBundle: readWslHookRelayBundle, + readBundle: (jsPath) => readFileSync(jsPath), listDistros: () => listWslDistrosAsync(), isDistroRunning: isWslDistroRunning, spawnRelay: spawnWslRelayProcess, diff --git a/src/main/agent-hooks/wsl-hook-relay-file-bounds.test.ts b/src/main/agent-hooks/wsl-hook-relay-file-bounds.test.ts deleted file mode 100644 index ce2c23d12b0..00000000000 --- a/src/main/agent-hooks/wsl-hook-relay-file-bounds.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { closeSync, ftruncateSync, mkdtempSync, openSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import { NodeFileReadTooLargeError } from '../../shared/node-bounded-file-reader' -import { - readWslHookRelayBundle, - readWslHookRelayBundleVersion, - WSL_HOOK_RELAY_MAX_BUNDLE_BYTES, - WSL_HOOK_RELAY_MAX_VERSION_FILE_BYTES -} from './wsl-hook-relay-launch' - -const roots: string[] = [] - -afterEach(() => { - for (const root of roots.splice(0)) { - rmSync(root, { recursive: true, force: true }) - } -}) - -function tempFile(name: string): string { - const root = mkdtempSync(join(tmpdir(), 'orca-wsl-relay-bounds-')) - roots.push(root) - return join(root, name) -} - -function createSparseFile(path: string, bytes: number): void { - const descriptor = openSync(path, 'w') - try { - ftruncateSync(descriptor, bytes) - } finally { - closeSync(descriptor) - } -} - -describe('WSL hook relay local file bounds', () => { - it('preserves ordinary version and bundle reads', () => { - const versionPath = tempFile('.version') - const bundlePath = tempFile('relay.js') - writeFileSync(versionPath, '1.2.3\n') - writeFileSync(bundlePath, 'console.log("relay")') - - expect(readWslHookRelayBundleVersion(versionPath)).toBe('1.2.3') - expect(readWslHookRelayBundle(bundlePath).toString('utf8')).toBe('console.log("relay")') - }) - - it('rejects an oversized version marker before materializing it', () => { - const versionPath = tempFile('.version') - createSparseFile(versionPath, WSL_HOOK_RELAY_MAX_VERSION_FILE_BYTES + 1) - - expect(() => readWslHookRelayBundleVersion(versionPath)).toThrow(NodeFileReadTooLargeError) - }) - - it('rejects an oversized bundle before materializing or base64-expanding it', () => { - const bundlePath = tempFile('relay.js') - createSparseFile(bundlePath, WSL_HOOK_RELAY_MAX_BUNDLE_BYTES + 1) - - expect(() => readWslHookRelayBundle(bundlePath)).toThrow(NodeFileReadTooLargeError) - }) -}) diff --git a/src/main/agent-hooks/wsl-hook-relay-launch.ts b/src/main/agent-hooks/wsl-hook-relay-launch.ts index 66a7f57a5f7..72ff3bb0c23 100644 --- a/src/main/agent-hooks/wsl-hook-relay-launch.ts +++ b/src/main/agent-hooks/wsl-hook-relay-launch.ts @@ -4,11 +4,10 @@ // MultiplexerTransport. Kept separate from the manager so the state machine // stays readable. See docs/agent-status-over-wsl.md (STA-1515). import { execFile, spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' -import { existsSync } from 'node:fs' +import { existsSync, readFileSync } from 'node:fs' import { join } from 'node:path' import { app } from 'electron' -import { readNodeFileSyncWithinLimit } from '../../shared/node-bounded-file-reader' import type { MultiplexerTransport } from '../ssh/ssh-channel-multiplexer' import { decodeWslText, @@ -28,8 +27,6 @@ import { } from '../../shared/wsl-hook-relay-contract' const INSTALL_TIMEOUT_MS = 30_000 -export const WSL_HOOK_RELAY_MAX_VERSION_FILE_BYTES = 4 * 1024 -export const WSL_HOOK_RELAY_MAX_BUNDLE_BYTES = 8 * 1024 * 1024 export type WslHookRelayBundle = { jsPath: string; version: string } @@ -55,12 +52,7 @@ export function resolveWslHookRelayBundle(): WslHookRelayBundle | null { const jsPath = join(dir, WSL_HOOK_RELAY_BUNDLE_NAME) const versionPath = join(dir, WSL_HOOK_RELAY_VERSION_FILE) if (existsSync(jsPath) && existsSync(versionPath)) { - let version: string - try { - version = readWslHookRelayBundleVersion(versionPath) - } catch { - continue - } + const version = readFileSync(versionPath, 'utf8').trim() // Why: the version lands inside single-quoted guest shell text and in // a guest path segment — refuse anything outside the safe alphabet. if (/^[A-Za-z0-9+.-]+$/.test(version)) { @@ -71,16 +63,6 @@ export function resolveWslHookRelayBundle(): WslHookRelayBundle | null { return null } -export function readWslHookRelayBundleVersion(versionPath: string): string { - return readNodeFileSyncWithinLimit(versionPath, WSL_HOOK_RELAY_MAX_VERSION_FILE_BYTES) - .buffer.toString('utf8') - .trim() -} - -export function readWslHookRelayBundle(jsPath: string): Buffer { - return readNodeFileSyncWithinLimit(jsPath, WSL_HOOK_RELAY_MAX_BUNDLE_BYTES).buffer -} - // Why: the install dir is namespaced by bundle version so concurrent Orca // instances with different bundles (dev + prod) never reinstall over each // other; each instance launches exactly the version it shipped. diff --git a/src/main/agent-hooks/wsl-hook-relay-live.integration.test.ts b/src/main/agent-hooks/wsl-hook-relay-live.integration.test.ts index 28125dd28a8..89b0987a582 100644 --- a/src/main/agent-hooks/wsl-hook-relay-live.integration.test.ts +++ b/src/main/agent-hooks/wsl-hook-relay-live.integration.test.ts @@ -43,11 +43,12 @@ describe.skipIf(process.platform === 'win32')( let child: ChildProcessWithoutNullStreams | null beforeAll(() => { - // Why: a prior build can exist while its embedded fs bridge is stale relative to this checkout. - execFileSync(process.execPath, [join('config', 'scripts', 'build-relay.mjs')], { - cwd: process.cwd(), - stdio: 'ignore' - }) + if (!existsSync(BUNDLE_JS)) { + execFileSync(process.execPath, [join('config', 'scripts', 'build-relay.mjs')], { + cwd: process.cwd(), + stdio: 'ignore' + }) + } }, 120_000) afterEach(() => { diff --git a/src/main/agent-hooks/wsl-hook-relay-manager.test.ts b/src/main/agent-hooks/wsl-hook-relay-manager.test.ts index 2568e69adb9..7e341d408da 100644 --- a/src/main/agent-hooks/wsl-hook-relay-manager.test.ts +++ b/src/main/agent-hooks/wsl-hook-relay-manager.test.ts @@ -2,18 +2,15 @@ // bridge (including a full run of the unchanged remote hook installers), and // the per-distro relay manager state machine with fault injection. import { EventEmitter } from 'node:events' -import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import type { ChildProcessWithoutNullStreams } from 'node:child_process' -import type { SFTPWrapper } from 'ssh2' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { RelayDispatcher } from '../../relay/dispatcher' import { registerWslHookFsHandlers } from '../../relay/wsl-hook-fs-bridge' -import { NodeFileReadTooLargeError } from '../../shared/node-bounded-file-reader' import { SshChannelMultiplexer, type MultiplexerTransport } from '../ssh/ssh-channel-multiplexer' -import { readTextFileRemote } from './installer-utils-remote' import { createWslHookSftpAdapter } from './wsl-hook-fs-adapter' import { installRemoteManagedAgentHooks } from './remote-managed-hook-installers' import { WslHookRelayManager } from './wsl-hook-relay-manager' @@ -116,39 +113,6 @@ describe.skipIf(process.platform === 'win32')( expect(outside).toBeInstanceOf(Error) }) - it('preserves the bounded-read error across the guest bridge and host adapter', async () => { - const adapter = createWslHookSftpAdapter(harness.mux) - const path = `${home}/oversized.json` - writeFileSync(path, 'x'.repeat(1025)) - - await expect(readTextFileRemote(adapter, path, 1024)).rejects.toBeInstanceOf( - NodeFileReadTooLargeError - ) - expect(readFileSync(path, 'utf8')).toHaveLength(1025) - }) - - it('threads requested directory limits through the host adapter', async () => { - const adapter = createWslHookSftpAdapter(harness.mux) as SFTPWrapper & { - orcaReaddirWithinLimit: ( - path: string, - limits: { maxEntries: number; maxRetainedBytes: number }, - callback: (error: Error | null, entries?: { filename: string }[]) => void - ) => void - } - writeFileSync(`${home}/one`, '') - writeFileSync(`${home}/two`, '') - - const error = await new Promise((resolve, reject) => { - adapter.orcaReaddirWithinLimit( - home, - { maxEntries: 1, maxRetainedBytes: 4096 }, - (readError) => - readError ? resolve(readError) : reject(new Error('expected limit error')) - ) - }) - expect(error).toBeInstanceOf(Error) - }) - it('runs the unchanged remote managed hook installers against a WSL guest home', async () => { const adapter = createWslHookSftpAdapter(harness.mux) const results = await installRemoteManagedAgentHooks(adapter, home) diff --git a/src/main/agent-hooks/wsl-hook-relay-sentinel.test.ts b/src/main/agent-hooks/wsl-hook-relay-sentinel.test.ts index bb78100c6ea..a8085485553 100644 --- a/src/main/agent-hooks/wsl-hook-relay-sentinel.test.ts +++ b/src/main/agent-hooks/wsl-hook-relay-sentinel.test.ts @@ -5,11 +5,7 @@ import { EventEmitter } from 'node:events' import type { ChildProcessWithoutNullStreams } from 'node:child_process' import { afterEach, describe, expect, it, vi } from 'vitest' -import { - MAX_BUFFERED_FRAME_CHUNKS, - RELAY_SENTINEL, - RELAY_SENTINEL_TIMEOUT_MS -} from '../ssh/relay-protocol' +import { RELAY_SENTINEL, RELAY_SENTINEL_TIMEOUT_MS } from '../ssh/relay-protocol' import { MAX_STARTUP_BUFFER_BYTES, waitForWslRelaySentinel, @@ -114,26 +110,6 @@ describe('waitForWslRelaySentinel', () => { expect(received).toEqual(['DEFER']) }) - it('closes instead of retaining too many post-sentinel fragments before attachment', async () => { - const child = fakeChild() - const promise = waitForWslRelaySentinel(child) - emitStdout(child, RELAY_SENTINEL) - for (let index = 0; index <= MAX_BUFFERED_FRAME_CHUNKS; index += 1) { - emitStdout(child, Buffer.from('x')) - } - - const transport = await promise - const onClose = vi.fn() - const onData = vi.fn() - transport.onClose(onClose) - transport.onData(onData) - await Promise.resolve() - - expect(child.kill).toHaveBeenCalled() - expect(onClose).toHaveBeenCalledOnce() - expect(onData).not.toHaveBeenCalled() - }) - it('kills the child and rejects when startup output exceeds 64 KiB before the sentinel', async () => { const child = fakeChild() const settled = catchStartup(waitForWslRelaySentinel(child)) diff --git a/src/main/agent-hooks/wsl-hook-relay-sentinel.ts b/src/main/agent-hooks/wsl-hook-relay-sentinel.ts index 19a2b9a160d..fdabf70fe15 100644 --- a/src/main/agent-hooks/wsl-hook-relay-sentinel.ts +++ b/src/main/agent-hooks/wsl-hook-relay-sentinel.ts @@ -4,17 +4,10 @@ // ChildProcess instead of a ClientChannel. import type { ChildProcessWithoutNullStreams } from 'node:child_process' -import { - HEADER_LENGTH, - MAX_BUFFERED_FRAME_CHUNKS, - MAX_MESSAGE_SIZE, - RELAY_SENTINEL, - RELAY_SENTINEL_TIMEOUT_MS -} from '../ssh/relay-protocol' +import { RELAY_SENTINEL, RELAY_SENTINEL_TIMEOUT_MS } from '../ssh/relay-protocol' import type { MultiplexerTransport } from '../ssh/ssh-channel-multiplexer' export const MAX_STARTUP_BUFFER_BYTES = 64 * 1024 -const MAX_PENDING_RELAY_BYTES = (MAX_MESSAGE_SIZE + HEADER_LENGTH) * 2 // Why: without WSL_UTF8, wsl.exe's own messages arrive UTF-16LE; NUL bytes // in breadcrumbs and the catastrophic-failure matcher must not depend on the @@ -52,7 +45,6 @@ export function waitForWslRelaySentinel( // decoder never sees chunks out of order. A setImmediate handoff would // NOT preserve that: it is a macrotask the next 'data' event can beat. const pendingChunks: Buffer[] = [] - let pendingChunkBytes = 0 let closedNotified = false const fail = (failure: WslRelayStartupFailure): void => { @@ -79,22 +71,8 @@ export function waitForWslRelaySentinel( } const dispatch = (chunk: Buffer): void => { - if (closedNotified) { - return - } if (dataCallbacks.length === 0) { - if ( - chunk.length > MAX_PENDING_RELAY_BYTES - pendingChunkBytes || - pendingChunks.length >= MAX_BUFFERED_FRAME_CHUNKS - ) { - pendingChunks.length = 0 - pendingChunkBytes = 0 - notifyClosed() - child.kill() - return - } pendingChunks.push(chunk) - pendingChunkBytes += chunk.length return } for (const cb of dataCallbacks) { @@ -126,32 +104,23 @@ export function waitForWslRelaySentinel( dispatch(chunk) return } - const searchableLength = Math.max( - 0, - MAX_STARTUP_BUFFER_BYTES - stdoutBuffer.length + sentinel.length - ) - const searchableChunk = chunk.subarray(0, searchableLength) - const startupOutput = - stdoutBuffer.length === 0 ? searchableChunk : Buffer.concat([stdoutBuffer, searchableChunk]) - const idx = startupOutput.indexOf(sentinel) + stdoutBuffer = Buffer.concat([stdoutBuffer, chunk]) + const idx = stdoutBuffer.indexOf(sentinel) if (idx === -1) { // Why: pre-sentinel stdout is untrusted startup noise; cap it so a // broken guest cannot grow memory until the timeout fires. - if (startupOutput.length > MAX_STARTUP_BUFFER_BYTES) { + if (stdoutBuffer.length > MAX_STARTUP_BUFFER_BYTES) { child.kill() fail({ kind: 'exit', code: null, stderr: 'startup output exceeded 64 KiB' }) - } else { - stdoutBuffer = startupOutput } return } sentinelSeen = true settled = true clearTimeout(timeout) - const trailingOffset = idx + sentinel.length - stdoutBuffer.length - const trailing = chunk.subarray(Math.max(0, trailingOffset)) + const trailing = stdoutBuffer.subarray(idx + sentinel.length) if (trailing.length > 0) { - dispatch(trailing) + pendingChunks.push(trailing) } const transport: MultiplexerTransport = { write: (data) => { @@ -166,7 +135,6 @@ export function waitForWslRelaySentinel( if (dataCallbacks.length === 1 && pendingChunks.length > 0) { queueMicrotask(() => { for (const pending of pendingChunks.splice(0)) { - pendingChunkBytes -= pending.length for (const dataCb of dataCallbacks) { dataCb(pending) } @@ -174,13 +142,7 @@ export function waitForWslRelaySentinel( }) } }, - onClose: (cb) => { - if (closedNotified) { - queueMicrotask(cb) - } else { - closeCallbacks.push(cb) - } - }, + onClose: (cb) => closeCallbacks.push(cb), close: () => child.kill() } resolve(transport) diff --git a/src/main/agent-trust-presets.test.ts b/src/main/agent-trust-presets.test.ts index e89272696a6..2a979c60e27 100644 --- a/src/main/agent-trust-presets.test.ts +++ b/src/main/agent-trust-presets.test.ts @@ -6,14 +6,11 @@ import { readFileSync, realpathSync, rmSync, - statSync, - truncateSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { MAX_AGENT_STATE_FILE_BYTES } from './agent-state-file-reader' const testState = { fakeHomeDir: '', @@ -41,12 +38,8 @@ vi.mock('node:os', async () => { } }) -const { - MAX_AGENT_TRUST_GIT_POINTER_BYTES, - markCodexProjectTrusted, - markCopilotFolderTrusted, - markCursorWorkspaceTrusted -} = await import('./agent-trust-presets') +const { markCodexProjectTrusted, markCopilotFolderTrusted, markCursorWorkspaceTrusted } = + await import('./agent-trust-presets') beforeEach(() => { testState.fakeHomeDir = mkdtempSync(join(tmpdir(), 'orca-trust-presets-')) @@ -141,115 +134,9 @@ describe('markCopilotFolderTrusted', () => { rmSync(workspace, { recursive: true, force: true }) } }) - - it('does not overwrite a sparse config above the 4 MiB read ceiling', () => { - const workspace = mkdtempSync(join(tmpdir(), 'orca-copilot-ws-')) - const configDir = join(testState.fakeHomeDir, '.copilot') - const configPath = join(configDir, 'config.json') - try { - mkdirSync(configDir, { recursive: true }) - writeFileSync(configPath, '{"keep":true}') - truncateSync(configPath, MAX_AGENT_STATE_FILE_BYTES + 1) - - markCopilotFolderTrusted(workspace) - - expect(statSync(configPath).size).toBe(MAX_AGENT_STATE_FILE_BYTES + 1) - } finally { - rmSync(workspace, { recursive: true, force: true }) - } - }) }) describe('markCodexProjectTrusted', () => { - it('accepts both linked-worktree pointers at the exact 64 KiB boundary', () => { - const fixtureRoot = mkdtempSync(join(tmpdir(), 'orca-codex-exact-git-pointers-')) - const repository = join(fixtureRoot, 'repo') - const workspace = join(fixtureRoot, 'worktrees', 'feature') - const worktreeGitDir = join(repository, '.git', 'worktrees', 'feature') - const padPointer = (value: string): string => - value + ' '.repeat(MAX_AGENT_TRUST_GIT_POINTER_BYTES - Buffer.byteLength(value)) - try { - mkdirSync(worktreeGitDir, { recursive: true }) - mkdirSync(workspace, { recursive: true }) - const gitPointer = padPointer(`gitdir: ${worktreeGitDir}\n`) - const backlink = padPointer(`${join(workspace, '.git')}\n`) - expect(Buffer.byteLength(gitPointer)).toBe(MAX_AGENT_TRUST_GIT_POINTER_BYTES) - expect(Buffer.byteLength(backlink)).toBe(MAX_AGENT_TRUST_GIT_POINTER_BYTES) - writeFileSync(join(workspace, '.git'), gitPointer) - writeFileSync(join(worktreeGitDir, 'gitdir'), backlink) - - markCodexProjectTrusted(workspace) - - const written = readFileSync(join(testState.fakeHomeDir, '.codex', 'config.toml'), 'utf8') - expect(written).toContain( - `[projects."${escapeTomlBasicString(realpathSync.native(repository))}"]` - ) - } finally { - rmSync(fixtureRoot, { recursive: true, force: true }) - } - }) - - it('falls back to the workspace when its sparse .git pointer exceeds 64 KiB', () => { - const workspace = mkdtempSync(join(tmpdir(), 'orca-codex-large-git-pointer-')) - try { - const gitPointerPath = join(workspace, '.git') - writeFileSync(gitPointerPath, '') - truncateSync(gitPointerPath, MAX_AGENT_TRUST_GIT_POINTER_BYTES + 1) - - markCodexProjectTrusted(workspace) - - const written = readFileSync(join(testState.fakeHomeDir, '.codex', 'config.toml'), 'utf8') - expect(written).toContain( - `[projects."${escapeTomlBasicString(realpathSync.native(workspace))}"]` - ) - } finally { - rmSync(workspace, { recursive: true, force: true }) - } - }) - - it('falls back to the workspace when the sparse gitdir backlink exceeds 64 KiB', () => { - const fixtureRoot = mkdtempSync(join(tmpdir(), 'orca-codex-large-gitdir-pointer-')) - const repository = join(fixtureRoot, 'repo') - const workspace = join(fixtureRoot, 'worktrees', 'feature') - const worktreeGitDir = join(repository, '.git', 'worktrees', 'feature') - try { - mkdirSync(worktreeGitDir, { recursive: true }) - mkdirSync(workspace, { recursive: true }) - writeFileSync(join(workspace, '.git'), `gitdir: ${worktreeGitDir}\n`) - const backlinkPath = join(worktreeGitDir, 'gitdir') - writeFileSync(backlinkPath, '') - truncateSync(backlinkPath, MAX_AGENT_TRUST_GIT_POINTER_BYTES + 1) - - markCodexProjectTrusted(workspace) - - const written = readFileSync(join(testState.fakeHomeDir, '.codex', 'config.toml'), 'utf8') - expect(written).toContain( - `[projects."${escapeTomlBasicString(realpathSync.native(workspace))}"]` - ) - expect(written).not.toContain( - `[projects."${escapeTomlBasicString(realpathSync.native(repository))}"]` - ) - } finally { - rmSync(fixtureRoot, { recursive: true, force: true }) - } - }) - - it('does not overwrite a sparse config above the 4 MiB read ceiling', () => { - const workspace = mkdtempSync(join(tmpdir(), 'orca-codex-ws-')) - const configDir = join(testState.fakeHomeDir, '.codex') - const configPath = join(configDir, 'config.toml') - try { - mkdirSync(configDir, { recursive: true }) - writeFileSync(configPath, 'model = "keep"\n') - truncateSync(configPath, MAX_AGENT_STATE_FILE_BYTES + 1) - - expect(() => markCodexProjectTrusted(workspace)).toThrow('File too large') - expect(statSync(configPath).size).toBe(MAX_AGENT_STATE_FILE_BYTES + 1) - } finally { - rmSync(workspace, { recursive: true, force: true }) - } - }) - it('trusts the main repository root for a linked worktree without reading commondir', () => { const fixtureRoot = mkdtempSync(join(tmpdir(), 'orca-codex-linked-ws-')) const repository = join(fixtureRoot, 'repo') diff --git a/src/main/agent-trust-presets.ts b/src/main/agent-trust-presets.ts index 3d109b96acb..3b441d6d1fe 100644 --- a/src/main/agent-trust-presets.ts +++ b/src/main/agent-trust-presets.ts @@ -1,14 +1,11 @@ -import { existsSync, mkdirSync, realpathSync } from 'node:fs' +import { existsSync, mkdirSync, readFileSync, realpathSync } from 'node:fs' import { homedir } from 'node:os' import { basename, dirname, join, resolve } from 'node:path' -import { readNodeFileSyncWithinLimit } from '../shared/node-bounded-file-reader' -import { readAgentStateJsonFileSync } from './agent-state-file-reader' import { writeFileAtomically } from './codex-accounts/fs-utils' import { getOrcaManagedCodexHomePath } from './codex/codex-home-paths' import { upsertProjectTrustLevel } from './codex/config-toml-trust' export type AgentTrustPreset = 'cursor' | 'copilot' | 'codex' -export const MAX_AGENT_TRUST_GIT_POINTER_BYTES = 64 * 1024 /** * Pre-mark a workspace as trusted for cursor-agent, GitHub Copilot CLI, or @@ -76,7 +73,8 @@ export function markCopilotFolderTrusted(workspacePath: string): void { let config: Record = {} try { if (existsSync(configPath)) { - const parsed = readAgentStateJsonFileSync(configPath) + const raw = readFileSync(configPath, 'utf-8') + const parsed = JSON.parse(raw) if (parsed && typeof parsed === 'object') { config = parsed as Record } @@ -122,7 +120,7 @@ export function markCodexProjectTrusted(workspacePath: string): void { function resolveCodexProjectTrustRoot(workspacePath: string): string { const absPath = canonicalize(workspacePath) try { - const gitDirReference = readGitPointerFile(join(absPath, '.git')) + const gitDirReference = readFileSync(join(absPath, '.git'), 'utf-8').trim() if (!gitDirReference.startsWith('gitdir:')) { return absPath } @@ -136,7 +134,7 @@ function resolveCodexProjectTrustRoot(workspacePath: string): string { return absPath } // Why: workspace-controlled .git metadata must not broaden trust without Git's reciprocal link. - const gitDirBacklink = readGitPointerFile(join(gitDir, 'gitdir')) + const gitDirBacklink = readFileSync(join(gitDir, 'gitdir'), 'utf-8').trim() if (!gitDirBacklink) { return absPath } @@ -155,12 +153,6 @@ function resolveCodexProjectTrustRoot(workspacePath: string): string { } } -function readGitPointerFile(filePath: string): string { - return readNodeFileSyncWithinLimit(filePath, MAX_AGENT_TRUST_GIT_POINTER_BYTES) - .buffer.toString('utf8') - .trim() -} - function canonicalize(p: string): string { // Why: macOS reports `/tmp/x` and `/private/tmp/x` as the same inode, but // both Cursor and Copilot's trust comparators run realpath() before the diff --git a/src/main/ai-vault/cached-session-list.ts b/src/main/ai-vault/cached-session-list.ts index 3943e0cb591..4b39e75eb72 100644 --- a/src/main/ai-vault/cached-session-list.ts +++ b/src/main/ai-vault/cached-session-list.ts @@ -3,7 +3,6 @@ import { scanAiVaultSessions } from './session-scanner' import { getWslHomeAsync, listWslDistrosAsync } from '../wsl' import type { AiVaultListArgs, AiVaultListResult } from '../../shared/ai-vault-types' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' -import { aiVaultSessionListCacheKey, boundAiVaultListResult } from './session-list-retention' // Why: ONE module owns the scan cache so the desktop IPC handler AND the runtime // RPC method share a single cache instance — opening the desktop panel and the @@ -35,23 +34,10 @@ export function configureAiVaultSessionSources(next: AiVaultSessionSources): voi export async function listAiVaultSessions(args?: AiVaultListArgs): Promise { // Scope paths change the result set, so they must be part of the cache key. - const key = aiVaultSessionListCacheKey({ + const key = JSON.stringify({ limit: args?.limit ?? 'default', scopePaths: args?.scopePaths ?? [] }) - const scan = async (): Promise => - scanAiVaultSessions({ - limit: args?.limit, - scopePaths: args?.scopePaths, - additionalCodexSessionsDirs: - sources.getAdditionalCodexHomePaths?.().map((homePath) => join(homePath, 'sessions')) ?? [], - wslHomeDirs: await getAiVaultWslHomeDirs(), - // The shared result is restamped at the RPC edge for runtime callers. - executionHostId: LOCAL_EXECUTION_HOST_ID - }) - if (key === null) { - return boundAiVaultListResult(await scan()) - } const now = Date.now() // Why: opening this panel repeatedly should not re-parse hundreds of JSONL // transcripts; explicit refreshes bypass the cache but not an active scan. @@ -63,15 +49,25 @@ export async function listAiVaultSessions(args?: AiVaultListArgs): Promise join(homePath, 'sessions')) ?? [] + inflightList = (async () => + scanAiVaultSessions({ + limit: args?.limit, + scopePaths: args?.scopePaths, + additionalCodexSessionsDirs, + wslHomeDirs: await getAiVaultWslHomeDirs(), + // Why: this scan is always host-local; callers addressing this host by a + // runtime id get the result restamped at the RPC edge, never rescanned. + executionHostId: LOCAL_EXECUTION_HOST_ID + }))() .then((result) => { - const bounded = boundAiVaultListResult(result) cachedList = { key, - result: bounded, + result, expiresAt: Date.now() + AI_VAULT_CACHE_TTL_MS } - return bounded + return result }) .finally(() => { // Only clear tracking if it still refers to this request: a concurrent diff --git a/src/main/ai-vault/claude-subagent-list-retention.ts b/src/main/ai-vault/claude-subagent-list-retention.ts deleted file mode 100644 index 94ab1e9c691..00000000000 --- a/src/main/ai-vault/claude-subagent-list-retention.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { AiVaultSession } from '../../shared/ai-vault-types' -import { - AI_VAULT_SESSION_LIST_MAX_JSON_BYTES, - retainAiVaultSessionsWithinAggregate -} from './session-list-retention' -import { sessionSortTime } from './session-scanner-accumulator' - -export function retainClaudeSubagentSessionBatch( - retainedSessions: readonly AiVaultSession[], - batch: readonly (AiVaultSession | null)[], - maxBytes: number = AI_VAULT_SESSION_LIST_MAX_JSON_BYTES -): { sessions: AiVaultSession[]; omitted: number } { - const candidates = [ - ...retainedSessions, - ...batch.filter((session): session is AiVaultSession => session !== null) - ] - candidates.sort((left, right) => sessionSortTime(right) - sessionSortTime(left)) - return retainAiVaultSessionsWithinAggregate(candidates, maxBytes) -} diff --git a/src/main/ai-vault/remote-session-scanner-codex-index.ts b/src/main/ai-vault/remote-session-scanner-codex-index.ts index 901d28c8681..d6a305ba4cf 100644 --- a/src/main/ai-vault/remote-session-scanner-codex-index.ts +++ b/src/main/ai-vault/remote-session-scanner-codex-index.ts @@ -2,11 +2,6 @@ import type { IFilesystemProvider } from '../providers/types' import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform' import { joinRemotePath } from '../ssh/ssh-remote-platform' import { extractString, normalizeTitleText, parseJsonObject } from './session-scanner-values' -import { - CODEX_SESSION_INDEX_CACHE_KEY_MAX_UTF8_BYTES, - CODEX_SESSION_INDEX_TITLE_CACHE_MAX, - retainCodexSessionIndexTitle -} from './session-scanner-codex-title-index' const CODEX_SESSION_INDEX_FILE = 'session_index.jsonl' @@ -16,25 +11,12 @@ export async function remoteCodexIndexTitles(args: { hostPlatform: RemoteHostPlatform titleCaches: Map>> }): Promise> { - const cacheable = - Buffer.byteLength(args.codexHome, 'utf8') <= CODEX_SESSION_INDEX_CACHE_KEY_MAX_UTF8_BYTES - const cached = cacheable ? args.titleCaches.get(args.codexHome) : undefined + const cached = args.titleCaches.get(args.codexHome) if (cached) { - args.titleCaches.delete(args.codexHome) - args.titleCaches.set(args.codexHome, cached) return cached } const pending = readRemoteCodexIndexTitles(args.provider, args.codexHome, args.hostPlatform) - if (cacheable) { - args.titleCaches.set(args.codexHome, pending) - while (args.titleCaches.size > CODEX_SESSION_INDEX_TITLE_CACHE_MAX) { - const oldest = args.titleCaches.keys().next().value - if (oldest === undefined) { - break - } - args.titleCaches.delete(oldest) - } - } + args.titleCaches.set(args.codexHome, pending) return pending } @@ -59,7 +41,7 @@ async function readRemoteCodexIndexTitles( const sessionId = extractString(record.id) const title = normalizeTitleText(extractString(record.thread_name) ?? '') if (sessionId && title) { - retainCodexSessionIndexTitle(titleBySessionId, sessionId, title) + titleBySessionId.set(sessionId, title) } } } catch { diff --git a/src/main/ai-vault/remote-session-scanner-discovery.ts b/src/main/ai-vault/remote-session-scanner-discovery.ts index 2ec43644d82..14a47f36559 100644 --- a/src/main/ai-vault/remote-session-scanner-discovery.ts +++ b/src/main/ai-vault/remote-session-scanner-discovery.ts @@ -6,7 +6,6 @@ import { isMissingRemoteSessionPathError, statRemoteSessionFile } from './remote import { partitionSubagentTranscriptPaths } from './session-scanner-subagent-transcripts' import type { FileWithMtime } from './session-scanner-types' import { errorMessage } from './session-scanner-values' -import { AiVaultSessionDiscoveryCapacityError } from './session-discovery-budget' import type { RemoteScannerContext, RemoteSessionCandidate, @@ -14,31 +13,15 @@ import type { } from './remote-session-scanner-types' const REMOTE_DISCOVERY_CONCURRENCY = 8 -const REMOTE_DISCOVERY_ISSUE_MAX = 256 export async function discoverRemoteSourceCandidates(args: { source: RemoteSessionSource context: RemoteScannerContext issues: AiVaultScanIssue[] }): Promise { - const walked: string[] = [] - try { - await (args.source.fixedChildFileSegments - ? collectRemoteFixedChildFiles(args.source, args.context, args.issues, walked) - : collectRemoteSessionFiles(args.source, args.context, args.issues, walked)) - } catch (error) { - if (error instanceof AiVaultSessionDiscoveryCapacityError) { - recordRemoteDirectoryIssue( - args.source, - args.context.executionHostId, - args.issues, - args.source.rootDir, - error - ) - } else { - throw error - } - } + const walked = args.source.fixedChildFileSegments + ? await listRemoteFixedChildFiles(args.source, args.context, args.issues) + : await walkRemoteSessionFiles(args.source, args.context, args.issues) const partition = args.source.collectSubagentSiblingCounts ? partitionSubagentTranscriptPaths(walked) : null @@ -62,63 +45,52 @@ export async function discoverRemoteSourceCandidates(args: { })) } -async function collectRemoteFixedChildFiles( +async function listRemoteFixedChildFiles( source: RemoteSessionSource, context: RemoteScannerContext, - issues: AiVaultScanIssue[], - paths: string[] -): Promise { - context.discoveryBudget.enterDirectory(0) + issues: AiVaultScanIssue[] +): Promise { let entries try { entries = await context.provider.readDir(source.rootDir) } catch (err) { recordRemoteDirectoryIssue(source, context.executionHostId, issues, source.rootDir, err) - return + return [] } const segments = source.fixedChildFileSegments ?? [] // Why: Antigravity's transcript path is fixed. Constructing it avoids three // serialized SSH readDir round trips for every conversation directory. - for (const entry of entries) { - const entryPath = joinRemotePath(context.hostPlatform, source.rootDir, entry.name) - context.discoveryBudget.visitEntry(entryPath) - if (!entry.isDirectory || entry.isSymlink) { - continue - } - const path = joinRemotePath(context.hostPlatform, entryPath, ...segments) - if (source.filePredicate?.(path) ?? true) { - paths.push(path) - } - } + return entries + .filter((entry) => entry.isDirectory && !entry.isSymlink) + .map((entry) => joinRemotePath(context.hostPlatform, source.rootDir, entry.name, ...segments)) + .filter((path) => source.filePredicate?.(path) ?? true) } -async function collectRemoteSessionFiles( +async function walkRemoteSessionFiles( source: RemoteSessionSource, context: RemoteScannerContext, issues: AiVaultScanIssue[], - files: string[], dirPath = source.rootDir, depth = 0 -): Promise { - context.discoveryBudget.enterDirectory(depth) +): Promise { let entries try { entries = await context.provider.readDir(dirPath) } catch (err) { recordRemoteDirectoryIssue(source, context.executionHostId, issues, dirPath, err) - return + return [] } const extensions = new Set(source.extensions) + const files: string[] = [] for (const entry of entries) { const fullPath = joinRemotePath(context.hostPlatform, dirPath, entry.name) - context.discoveryBudget.visitEntry(fullPath) if ( entry.isDirectory && !entry.isSymlink && (source.directoryPredicate?.(entry.name, depth) ?? true) ) { - await collectRemoteSessionFiles(source, context, issues, files, fullPath, depth + 1) + files.push(...(await walkRemoteSessionFiles(source, context, issues, fullPath, depth + 1))) continue } if ( @@ -129,6 +101,7 @@ async function collectRemoteSessionFiles( files.push(fullPath) } } + return files } function recordRemoteDirectoryIssue( @@ -138,7 +111,7 @@ function recordRemoteDirectoryIssue( path: string, err: unknown ): void { - if (!isMissingRemoteSessionPathError(err) && issues.length < REMOTE_DISCOVERY_ISSUE_MAX) { + if (!isMissingRemoteSessionPathError(err)) { issues.push({ executionHostId, agent: source.agent, path, message: errorMessage(err) }) } } diff --git a/src/main/ai-vault/remote-session-scanner-types.ts b/src/main/ai-vault/remote-session-scanner-types.ts index 4e785a7752e..77806f38be6 100644 --- a/src/main/ai-vault/remote-session-scanner-types.ts +++ b/src/main/ai-vault/remote-session-scanner-types.ts @@ -4,7 +4,6 @@ import type { IFilesystemProvider } from '../providers/types' import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform' import type { FileWithMtime } from './session-scanner-types' import type { AntigravityWorkspaceResolver } from './session-scanner-antigravity-history' -import type { AiVaultSessionDiscoveryBudget } from './session-discovery-budget' export type RemoteScannerContext = { provider: IFilesystemProvider @@ -12,7 +11,6 @@ export type RemoteScannerContext = { hostPlatform: RemoteHostPlatform titleCaches: Map>> antigravityWorkspaceResolver: AntigravityWorkspaceResolver - discoveryBudget: AiVaultSessionDiscoveryBudget } export type RemoteParserOptions = { diff --git a/src/main/ai-vault/remote-session-scanner.test.ts b/src/main/ai-vault/remote-session-scanner.test.ts index 49a6ff78907..4ba3bd4d647 100644 --- a/src/main/ai-vault/remote-session-scanner.test.ts +++ b/src/main/ai-vault/remote-session-scanner.test.ts @@ -3,7 +3,6 @@ import type { DirEntry } from '../../shared/types' import type { FileReadResult, FileStat, IFilesystemProvider } from '../providers/types' import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform' import { scanRemoteAiVaultSessions } from './remote-session-scanner' -import { AI_VAULT_SESSION_ID_MAX_UTF8_BYTES } from './session-list-retention' class MemoryRemoteProvider implements IFilesystemProvider { private readonly files = new Map() @@ -669,35 +668,6 @@ describe('scanRemoteAiVaultSessions', () => { expect(result.sessions.map((session) => session.sessionId)).toEqual(['user-session']) }) - it('omits remote resume-critical overflow with a bounded issue', async () => { - const provider = new MemoryRemoteProvider() - provider.addFile( - '/home/ada/.codex/sessions/oversized.jsonl', - codexTranscript({ - sessionId: 'x'.repeat(AI_VAULT_SESSION_ID_MAX_UTF8_BYTES + 1), - title: 'Oversized remote identity', - cwd: '/home/ada/repo', - timestamp: '2026-07-04T04:00:00.000Z' - }), - 40 - ) - - const result = await scanRemoteAiVaultSessions({ - provider, - executionHostId: 'ssh:dev-box', - remoteHome: '/home/ada', - hostPlatform: getRemoteHostPlatform('linux-x64') - }) - - expect(result.sessions).toEqual([]) - expect(result.issues).toEqual([ - expect.objectContaining({ - path: '/home/ada/.codex/sessions/oversized.jsonl', - message: expect.stringContaining('session id exceeds the 65536 byte limit') - }) - ]) - }) - it('keeps scoped remote sessions even when they are older than the recency cap', async () => { const provider = new MemoryRemoteProvider() provider.addFile( @@ -736,62 +706,6 @@ describe('scanRemoteAiVaultSessions', () => { 'scoped-session' ]) }) - - it('accepts the aggregate discovery cap and reports cross-source overflow', async () => { - const exactProvider = new MemoryRemoteProvider() - exactProvider.addFile( - '/home/ada/.codex/sessions/exact.jsonl', - codexTranscript({ - sessionId: 'exact-session', - title: 'Exact capacity', - cwd: '/home/ada/repo', - timestamp: '2026-07-04T06:00:00.000Z' - }), - 60 - ) - const exact = await scanRemoteAiVaultSessions({ - provider: exactProvider, - executionHostId: 'ssh:dev-box', - remoteHome: '/home/ada', - hostPlatform: getRemoteHostPlatform('linux-x64'), - discoveryLimits: { maxEntries: 1 } - }) - - expect(exact.sessions.map((session) => session.sessionId)).toEqual(['exact-session']) - expect(exact.issues).toEqual([]) - - const overflowProvider = new MemoryRemoteProvider() - for (const [path, sessionId] of [ - ['/home/ada/.codex/sessions/default.jsonl', 'default-session'], - [ - '/home/ada/.local/share/orca/codex-runtime-home/home/sessions/managed.jsonl', - 'managed-session' - ] - ] as const) { - overflowProvider.addFile( - path, - codexTranscript({ - sessionId, - title: sessionId, - cwd: '/home/ada/repo', - timestamp: '2026-07-04T06:00:00.000Z' - }), - 60 - ) - } - const overflow = await scanRemoteAiVaultSessions({ - provider: overflowProvider, - executionHostId: 'ssh:dev-box', - remoteHome: '/home/ada', - hostPlatform: getRemoteHostPlatform('linux-x64'), - discoveryLimits: { maxEntries: 1 } - }) - - expect(overflow.sessions).toHaveLength(1) - expect(overflow.issues).toEqual([ - expect.objectContaining({ message: expect.stringContaining('safety limit') }) - ]) - }) }) function codexTranscript(args: { diff --git a/src/main/ai-vault/remote-session-scanner.ts b/src/main/ai-vault/remote-session-scanner.ts index eb6afe3e417..0ad894214e1 100644 --- a/src/main/ai-vault/remote-session-scanner.ts +++ b/src/main/ai-vault/remote-session-scanner.ts @@ -18,15 +18,6 @@ import type { RemoteScannerContext, RemoteSessionCandidate } from './remote-sess import { sessionSortTime } from './session-scanner-accumulator' import { createAntigravityWorkspaceResolver } from './session-scanner-antigravity-history' import { errorMessage } from './session-scanner-values' -import { - AiVaultSessionDiscoveryBudget, - type AiVaultSessionDiscoveryLimits -} from './session-discovery-budget' -import { - boundAiVaultListResult, - retainAiVaultSession, - retainAiVaultSessionsWithinAggregate -} from './session-list-retention' const DEFAULT_REMOTE_SCAN_LIMIT = 1000 const REMOTE_SCAN_CONCURRENCY = 8 @@ -39,7 +30,6 @@ export async function scanRemoteAiVaultSessions(args: { hostPlatform: RemoteHostPlatform limit?: number scopePaths?: readonly string[] - discoveryLimits?: Partial }): Promise { const limit = args.limit && args.limit > 0 ? Math.floor(args.limit) : DEFAULT_REMOTE_SCAN_LIMIT const issues: AiVaultScanIssue[] = [] @@ -48,7 +38,6 @@ export async function scanRemoteAiVaultSessions(args: { executionHostId: args.executionHostId, hostPlatform: args.hostPlatform, titleCaches: new Map(), - discoveryBudget: new AiVaultSessionDiscoveryBudget(args.discoveryLimits), antigravityWorkspaceResolver: createAntigravityWorkspaceResolver(async (historyPath) => { try { const read = await args.provider.readFile(historyPath) @@ -96,11 +85,11 @@ export async function scanRemoteAiVaultSessions(args: { ...extraScopeSessions ]) - return boundAiVaultListResult({ + return { sessions: mergeRemoteSessions(cappedSessions, scopeSessions), issues, scannedAt: new Date().toISOString() - }) + } } async function parseRemoteSessionCandidates(args: { @@ -127,12 +116,7 @@ async function parseRemoteSessionCandidates(args: { ) sessions.push(...results.filter(isAiVaultSession)) const uniqueSessions = dedupeCodexSessionsBySessionId(sessions) - const retained = retainAiVaultSessionsWithinAggregate(uniqueSessions) - sessions.splice(0, sessions.length, ...retained.sessions) - if (retained.omitted > 0) { - addRemoteSessionCapacityIssue(args.issues, args.context.executionHostId, retained.omitted) - break - } + sessions.splice(0, sessions.length, ...uniqueSessions) index += batch.length } @@ -166,12 +150,6 @@ async function scanRemoteInScopeSessions(args: { isAiVaultSession(session) && isRemoteSessionInScope(session, args.scopePaths) ) ) - const retained = retainAiVaultSessionsWithinAggregate(sessions) - sessions.splice(0, sessions.length, ...retained.sessions) - if (retained.omitted > 0) { - addRemoteSessionCapacityIssue(args.issues, args.context.executionHostId, retained.omitted) - break - } } return sessions @@ -192,9 +170,10 @@ async function parseRemoteSessionCandidate( // transcript count (row badge; recoverable signal at zero turns). The // walk listing supplies it — the parser can't readdir a remote disk. const subagentTranscriptCount = candidate.subagentTranscriptCount ?? 0 - const enriched = - session && subagentTranscriptCount > 0 ? { ...session, subagentTranscriptCount } : session - return enriched ? retainAiVaultSession(enriched) : null + if (session && subagentTranscriptCount > 0) { + return { ...session, subagentTranscriptCount } + } + return session } catch (err) { issues.push({ executionHostId: context.executionHostId, @@ -254,19 +233,6 @@ function isAiVaultSession(session: AiVaultSession | null): session is AiVaultSes return Boolean(session) } -function addRemoteSessionCapacityIssue( - issues: AiVaultScanIssue[], - executionHostId: ExecutionHostId, - omitted: number -): void { - issues.push({ - executionHostId, - agent: 'codex', - path: 'AI Vault session list', - message: `AI Vault stopped after omitting ${omitted} sessions at its memory limit.` - }) -} - async function mapRemoteScanConcurrently( items: readonly T[], mapper: (item: T) => Promise diff --git a/src/main/ai-vault/session-discovery-budget.test.ts b/src/main/ai-vault/session-discovery-budget.test.ts deleted file mode 100644 index 8f355588f6c..00000000000 --- a/src/main/ai-vault/session-discovery-budget.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - AiVaultSessionDiscoveryBudget, - AiVaultSessionDiscoveryCapacityError -} from './session-discovery-budget' - -describe('AiVaultSessionDiscoveryBudget', () => { - it('accepts the exact path-memory capacity and rejects the next entry', () => { - const budget = new AiVaultSessionDiscoveryBudget({ maxPathBytes: 130 }) - - expect(() => budget.visitEntry('a')).not.toThrow() - expect(() => budget.visitEntry('b')).toThrow(AiVaultSessionDiscoveryCapacityError) - }) -}) diff --git a/src/main/ai-vault/session-discovery-budget.ts b/src/main/ai-vault/session-discovery-budget.ts deleted file mode 100644 index ed92b15afc2..00000000000 --- a/src/main/ai-vault/session-discovery-budget.ts +++ /dev/null @@ -1,56 +0,0 @@ -export const AI_VAULT_DISCOVERY_MAX_ENTRIES = 100_000 -export const AI_VAULT_DISCOVERY_MAX_DEPTH = 64 -export const AI_VAULT_DISCOVERY_MAX_PATH_BYTES = 32 * 1024 * 1024 - -const DISCOVERY_PATH_OVERHEAD_BYTES = 128 - -export type AiVaultSessionDiscoveryLimits = { - maxEntries: number - maxDepth: number - maxPathBytes: number -} - -export class AiVaultSessionDiscoveryCapacityError extends Error { - constructor() { - super( - 'AI Vault session discovery stopped at its safety limit (100,000 entries, 64 folder levels, or 32 MiB of path data).' - ) - this.name = 'AiVaultSessionDiscoveryCapacityError' - } -} - -export class AiVaultSessionDiscoveryBudget { - private entries = 0 - private pathBytes = 0 - readonly limits: AiVaultSessionDiscoveryLimits - - constructor(requested?: Partial) { - this.limits = { - maxEntries: clampLimit(requested?.maxEntries, AI_VAULT_DISCOVERY_MAX_ENTRIES), - maxDepth: clampLimit(requested?.maxDepth, AI_VAULT_DISCOVERY_MAX_DEPTH), - maxPathBytes: clampLimit(requested?.maxPathBytes, AI_VAULT_DISCOVERY_MAX_PATH_BYTES) - } - } - - enterDirectory(depth: number): void { - if (depth > this.limits.maxDepth) { - throw new AiVaultSessionDiscoveryCapacityError() - } - } - - visitEntry(path: string): void { - const nextPathBytes = this.pathBytes + path.length * 2 + DISCOVERY_PATH_OVERHEAD_BYTES - if (this.entries >= this.limits.maxEntries || nextPathBytes > this.limits.maxPathBytes) { - throw new AiVaultSessionDiscoveryCapacityError() - } - this.entries += 1 - this.pathBytes = nextPathBytes - } -} - -function clampLimit(value: number | undefined, maximum: number): number { - if (typeof value !== 'number' || !Number.isSafeInteger(value) || value <= 0) { - return maximum - } - return Math.min(value, maximum) -} diff --git a/src/main/ai-vault/session-jsonl-line-reader.test.ts b/src/main/ai-vault/session-jsonl-line-reader.test.ts deleted file mode 100644 index c78449d48ab..00000000000 --- a/src/main/ai-vault/session-jsonl-line-reader.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { mkdtemp, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import { consumeAiVaultJsonlLines, iterateAiVaultJsonlLines } from './session-jsonl-line-reader' - -const tempDirs: string[] = [] - -async function tempFile(content: string): Promise { - const directory = await mkdtemp(join(tmpdir(), 'orca-vault-jsonl-')) - tempDirs.push(directory) - const path = join(directory, 'session.jsonl') - await writeFile(path, content) - return path -} - -afterEach(async () => { - await Promise.all(tempDirs.splice(0).map((directory) => rm(directory, { recursive: true }))) -}) - -describe('AI Vault bounded JSONL reader', () => { - it('preserves complete UTF-8 and CRLF records plus a trailing partial line', async () => { - const path = await tempFile('first🐋\r\nsecond\npartial') - const lines: string[] = [] - - const result = await consumeAiVaultJsonlLines({ - path, - onLine: (line) => lines.push(line) - }) - - expect(lines).toEqual(['first🐋', 'second']) - expect(result.trailingPartialLine).toBe('partial') - expect(result.consumedThrough).toBe(Buffer.byteLength('first🐋\r\nsecond\n')) - expect(result.bytesRead).toBe(Buffer.byteLength('first🐋\r\nsecond\npartial')) - }) - - it('drops only an oversized record and resumes at the next newline', async () => { - const path = await tempFile(`keep\n${'x'.repeat(40)}\nafter\n`) - const lines: string[] = [] - - const result = await consumeAiVaultJsonlLines({ - path, - maxRecordBytes: 8, - onLine: (line) => lines.push(line) - }) - - expect(lines).toEqual(['keep', 'after']) - expect(result.oversizedRecords).toBe(1) - expect(result.consumedThrough).toBe(Buffer.byteLength(`keep\n${'x'.repeat(40)}\nafter\n`)) - }) - - it('never materializes an oversized unterminated tail', async () => { - const path = await tempFile(`keep\n${'x'.repeat(40)}`) - const iterator = iterateAiVaultJsonlLines(path, { maxRecordBytes: 8 }) - const first = await iterator.next() - const end = await iterator.next() - - expect(first).toMatchObject({ done: false, value: 'keep' }) - expect(end.done).toBe(true) - expect(end.value).toMatchObject({ - trailingPartialLine: null, - trailingPartialOversized: true - }) - }) - - it('yields an accepted unterminated tail for one-shot parsers', async () => { - const path = await tempFile('complete\npartial') - const lines: string[] = [] - - for await (const line of iterateAiVaultJsonlLines(path)) { - lines.push(line) - } - - expect(lines).toEqual(['complete', 'partial']) - }) -}) diff --git a/src/main/ai-vault/session-jsonl-line-reader.ts b/src/main/ai-vault/session-jsonl-line-reader.ts deleted file mode 100644 index e9510c137d8..00000000000 --- a/src/main/ai-vault/session-jsonl-line-reader.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { createReadStream } from 'node:fs' - -const NEWLINE_BYTE = 0x0a -const CARRIAGE_RETURN_BYTE = 0x0d - -export const AI_VAULT_JSONL_MAX_RECORD_BYTES = 8 * 1024 * 1024 - -export type AiVaultJsonlReadResult = { - consumedThrough: number - trailingPartialLine: string | null - trailingPartialOversized: boolean - oversizedRecords: number - bytesRead: number -} - -function decodeLine(parts: Buffer[], byteLength: number): string { - const bytes = parts.length === 1 ? parts[0] : Buffer.concat(parts, byteLength) - const end = - byteLength > 0 && bytes[byteLength - 1] === CARRIAGE_RETURN_BYTE ? byteLength - 1 : byteLength - return bytes.toString('utf8', 0, end) -} - -export async function* iterateAiVaultJsonlLines( - path: string, - options: { - start?: number - maxRecordBytes?: number - yieldTrailingPartial?: boolean - } = {} -): AsyncGenerator { - const start = options.start ?? 0 - const maxRecordBytes = options.maxRecordBytes ?? AI_VAULT_JSONL_MAX_RECORD_BYTES - if (!Number.isSafeInteger(start) || start < 0) { - throw new RangeError('JSONL start offset must be a non-negative safe integer') - } - if (!Number.isSafeInteger(maxRecordBytes) || maxRecordBytes < 0) { - throw new RangeError('JSONL record limit must be a non-negative safe integer') - } - - let bytesRead = 0 - let consumedThrough = start - let lineParts: Buffer[] = [] - let lineBytes = 0 - let discardingLine = false - let oversizedRecords = 0 - const stream = createReadStream(path, { start }) - - for await (const rawChunk of stream as AsyncIterable) { - const chunk = Buffer.isBuffer(rawChunk) ? rawChunk : Buffer.from(rawChunk) - const chunkOffset = start + bytesRead - bytesRead += chunk.length - let cursor = 0 - while (cursor < chunk.length) { - const newline = chunk.indexOf(NEWLINE_BYTE, cursor) - const segmentEnd = newline === -1 ? chunk.length : newline - const segmentLength = segmentEnd - cursor - - if (!discardingLine && lineBytes + segmentLength > maxRecordBytes) { - lineParts = [] - lineBytes = 0 - discardingLine = true - } - if (!discardingLine && segmentLength > 0) { - lineParts.push(chunk.subarray(cursor, segmentEnd)) - lineBytes += segmentLength - } - if (newline === -1) { - break - } - - consumedThrough = chunkOffset + newline + 1 - if (discardingLine) { - oversizedRecords += 1 - } else { - yield decodeLine(lineParts, lineBytes) - } - lineParts = [] - lineBytes = 0 - discardingLine = false - cursor = newline + 1 - } - } - - const trailingPartialLine = - !discardingLine && lineBytes > 0 ? decodeLine(lineParts, lineBytes) : null - if (options.yieldTrailingPartial !== false && trailingPartialLine !== null) { - yield trailingPartialLine - } - return { - consumedThrough, - trailingPartialLine, - trailingPartialOversized: discardingLine, - oversizedRecords, - bytesRead - } -} - -export async function consumeAiVaultJsonlLines(args: { - path: string - start?: number - maxRecordBytes?: number - onLine: (line: string) => void -}): Promise { - const iterator = iterateAiVaultJsonlLines(args.path, { - start: args.start, - maxRecordBytes: args.maxRecordBytes, - yieldTrailingPartial: false - }) - while (true) { - const next = await iterator.next() - if (next.done) { - return next.value - } - args.onLine(next.value) - } -} diff --git a/src/main/ai-vault/session-list-results.test.ts b/src/main/ai-vault/session-list-results.test.ts deleted file mode 100644 index d1cbe002046..00000000000 --- a/src/main/ai-vault/session-list-results.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { AiVaultListResult, AiVaultSession } from '../../shared/ai-vault-types' -import { mergeAiVaultListResults } from './session-list-results' - -function session(id: string, modifiedAt: string): AiVaultSession { - return { - id, - executionHostId: 'local', - agent: 'claude', - sessionId: id, - title: id, - cwd: '/repo', - branch: 'main', - model: null, - filePath: `/${id}`, - codexHome: null, - createdAt: null, - updatedAt: null, - modifiedAt, - messageCount: 1, - totalTokens: 0, - previewMessages: [], - queuedMessageCount: 0, - subagentTranscriptCount: 0, - resumeCommand: `claude --resume ${id}`, - subagent: null - } -} - -function result(sessions: AiVaultSession[]): AiVaultListResult { - return { sessions, issues: [], scannedAt: '2026-01-01T00:00:00.000Z' } -} - -describe('AI Vault list result merging', () => { - it('applies the result limit after later hosts replace duplicate sessions', () => { - const newest = session('duplicate', '2026-04-03T00:00:00.000Z') - const fallback = session('fallback', '2026-04-02T00:00:00.000Z') - const replacement = session('duplicate', '2026-04-01T00:00:00.000Z') - const otherHost = session('other-host', '2026-04-01T12:00:00.000Z') - - const merged = mergeAiVaultListResults( - [result([newest, fallback]), result([replacement, otherHost])], - 1 - ) - - expect(merged.sessions).toEqual([fallback]) - }) -}) diff --git a/src/main/ai-vault/session-list-results.ts b/src/main/ai-vault/session-list-results.ts index fd6466d40ef..06f8df9c39a 100644 --- a/src/main/ai-vault/session-list-results.ts +++ b/src/main/ai-vault/session-list-results.ts @@ -1,14 +1,17 @@ -import type { AiVaultListResult, AiVaultSession } from '../../shared/ai-vault-types' +import type { + AiVaultListResult, + AiVaultScanIssue, + AiVaultSession +} from '../../shared/ai-vault-types' import type { ExecutionHostId } from '../../shared/execution-host' import { sessionSortTime } from './session-scanner-accumulator' -import { boundAiVaultListResult } from './session-list-retention' export function aiVaultScanIssueResult(args: { executionHostId?: ExecutionHostId path: string message: string }): AiVaultListResult { - return boundAiVaultListResult({ + return { sessions: [], issues: [ { @@ -19,7 +22,7 @@ export function aiVaultScanIssueResult(args: { } ], scannedAt: new Date().toISOString() - }) + } } // Why: the serving-side scan is host-local and cached once for every caller @@ -30,9 +33,8 @@ export function restampAiVaultListResult( result: AiVaultListResult, executionHostId: ExecutionHostId ): AiVaultListResult { - const retained = boundAiVaultListResult(result) - return boundAiVaultListResult({ - sessions: retained.sessions.map((session) => + return { + sessions: result.sessions.map((session) => session.executionHostId === executionHostId ? session : { @@ -41,9 +43,9 @@ export function restampAiVaultListResult( id: `${executionHostId}:${session.agent}:${session.sessionId}:${session.filePath}` } ), - issues: retained.issues.map((issue) => ({ ...issue, executionHostId })), - scannedAt: retained.scannedAt - }) + issues: result.issues.map((issue) => ({ ...issue, executionHostId })), + scannedAt: result.scannedAt + } } export function mergeAiVaultListResults( @@ -51,22 +53,19 @@ export function mergeAiVaultListResults( rawLimit: number | undefined ): AiVaultListResult { const limit = rawLimit && rawLimit > 0 ? Math.floor(rawLimit) : 1000 - let merged: AiVaultListResult = { sessions: [], issues: [], scannedAt: new Date().toISOString() } - for (let index = 0; index < results.length; index += 1) { - const rawResult = results[index] - const result = boundAiVaultListResult(rawResult) - const byId = new Map() - for (const session of [...merged.sessions, ...result.sessions]) { + const byId = new Map() + const issues: AiVaultScanIssue[] = [] + for (const result of results) { + for (const session of result.sessions) { byId.set(session.id, session) } - const sessions = [...byId.values()].sort( - (left, right) => sessionSortTime(right) - sessionSortTime(left) - ) - merged = boundAiVaultListResult({ - sessions: index === results.length - 1 ? sessions.slice(0, limit) : sessions, - issues: [...merged.issues, ...result.issues], - scannedAt: new Date().toISOString() - }) + issues.push(...result.issues) + } + return { + sessions: [...byId.values()] + .sort((left, right) => sessionSortTime(right) - sessionSortTime(left)) + .slice(0, limit), + issues, + scannedAt: new Date().toISOString() } - return merged } diff --git a/src/main/ai-vault/session-list-retention.test.ts b/src/main/ai-vault/session-list-retention.test.ts deleted file mode 100644 index d15d7f96d4c..00000000000 --- a/src/main/ai-vault/session-list-retention.test.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { - AiVaultListResult, - AiVaultScanIssue, - AiVaultSession -} from '../../shared/ai-vault-types' -import { - AI_VAULT_SCAN_ISSUE_MAX_ENTRIES, - AI_VAULT_SESSION_DISPLAY_FIELD_MAX_UTF8_BYTES, - AI_VAULT_SESSION_ID_MAX_UTF8_BYTES, - AI_VAULT_SESSION_LIST_CACHE_KEY_MAX_JSON_BYTES, - AI_VAULT_SESSION_PATH_MAX_UTF8_BYTES, - AiVaultSessionCapacityError, - aiVaultSessionListCacheKey, - boundAiVaultListResult, - retainAiVaultSession, - retainAiVaultSessionsWithinAggregate -} from './session-list-retention' - -function session(overrides: Partial = {}): AiVaultSession { - return { - id: 'local:claude:session:/transcript', - executionHostId: 'local', - agent: 'claude', - sessionId: 'session', - title: 'Title', - cwd: '/repo', - branch: 'main', - model: 'claude-test', - filePath: '/transcript', - codexHome: null, - createdAt: null, - updatedAt: null, - modifiedAt: '2026-01-01T00:00:00.000Z', - messageCount: 1, - totalTokens: 1, - previewMessages: [], - queuedMessageCount: 0, - subagentTranscriptCount: 0, - resumeCommand: 'claude --resume session', - subagent: null, - ...overrides - } -} - -function result(sessions: AiVaultSession[], issues: AiVaultScanIssue[] = []): AiVaultListResult { - return { sessions, issues, scannedAt: '2026-01-01T00:00:00.000Z' } -} - -describe('AI Vault session list retention', () => { - it('returns exact under-limit sessions and results by identity', () => { - const normalSession = session() - const normalResult = result( - [normalSession], - [{ agent: 'claude', path: '/transcript', message: 'recoverable warning' }] - ) - - expect(retainAiVaultSession(normalSession)).toBe(normalSession) - expect(boundAiVaultListResult(normalResult)).toBe(normalResult) - }) - - it('accepts exact UTF-8 identity/path limits and rejects one byte over', () => { - const exactId = 'é'.repeat(AI_VAULT_SESSION_ID_MAX_UTF8_BYTES / 2) - const exactPath = 'é'.repeat(AI_VAULT_SESSION_PATH_MAX_UTF8_BYTES / 2) - expect(retainAiVaultSession(session({ sessionId: exactId, filePath: exactPath }))).toEqual( - expect.objectContaining({ sessionId: exactId, filePath: exactPath }) - ) - - expect(() => retainAiVaultSession(session({ sessionId: `${exactId}x` }))).toThrow( - AiVaultSessionCapacityError - ) - expect(() => retainAiVaultSession(session({ filePath: `${exactPath}x` }))).toThrow( - AiVaultSessionCapacityError - ) - }) - - it('UTF-8 truncates display-only metadata without splitting a code point', () => { - const exact = 'é'.repeat(AI_VAULT_SESSION_DISPLAY_FIELD_MAX_UTF8_BYTES / 2) - expect(retainAiVaultSession(session({ model: exact })).model).toBe(exact) - - const retained = retainAiVaultSession(session({ model: `${exact}x` })) - expect(retained.model).toMatch(/\.\.\.$/) - expect(Buffer.byteLength(retained.model ?? '', 'utf8')).toBeLessThanOrEqual( - AI_VAULT_SESSION_DISPLAY_FIELD_MAX_UTF8_BYTES - ) - expect(retained.model).not.toContain('\ufffd') - }) - - it('uses the exact list boundary and keeps the newest session prefix', () => { - const sessions = [session({ id: 'newest' }), session({ id: 'older' })] - const exactArrayBytes = Buffer.byteLength(JSON.stringify(sessions), 'utf8') - expect(retainAiVaultSessionsWithinAggregate(sessions, exactArrayBytes)).toEqual({ - sessions, - omitted: 0 - }) - expect(retainAiVaultSessionsWithinAggregate(sessions, exactArrayBytes - 1)).toEqual({ - sessions: [sessions[0]], - omitted: 1 - }) - - const exactResult = result(sessions) - const exactResultBytes = Buffer.byteLength(JSON.stringify(exactResult), 'utf8') - expect(boundAiVaultListResult(exactResult, exactResultBytes)).toBe(exactResult) - const overflow = boundAiVaultListResult(exactResult, exactResultBytes - 1) - expect(Buffer.byteLength(JSON.stringify(overflow), 'utf8')).toBeLessThanOrEqual( - exactResultBytes - 1 - ) - expect(overflow.issues.at(-1)?.message).toContain('AI Vault omitted') - }) - - it('turns critical overflow into one bounded issue and caps issue count', () => { - const oversized = session({ - sessionId: 'x'.repeat(AI_VAULT_SESSION_ID_MAX_UTF8_BYTES + 1) - }) - const issues = Array.from({ length: AI_VAULT_SCAN_ISSUE_MAX_ENTRIES + 1 }, (_, index) => ({ - agent: 'claude' as const, - path: `/warning/${index}`, - message: 'warning' - })) - - const bounded = boundAiVaultListResult(result([oversized], issues)) - expect(bounded.sessions).toEqual([]) - expect(bounded.issues).toHaveLength(AI_VAULT_SCAN_ISSUE_MAX_ENTRIES) - expect(bounded.issues.at(-1)?.message).toContain('1 sessions and 2 scan issues') - }) - - it('admits an exact-limit cache key and bypasses caching one byte over', () => { - const overhead = Buffer.byteLength(JSON.stringify({ scopePaths: [''] }), 'utf8') - const exact = { - scopePaths: ['x'.repeat(AI_VAULT_SESSION_LIST_CACHE_KEY_MAX_JSON_BYTES - overhead)] - } - expect(Buffer.byteLength(JSON.stringify(exact), 'utf8')).toBe( - AI_VAULT_SESSION_LIST_CACHE_KEY_MAX_JSON_BYTES - ) - expect(aiVaultSessionListCacheKey(exact)).toBe(JSON.stringify(exact)) - expect(aiVaultSessionListCacheKey({ scopePaths: [`${exact.scopePaths[0]}x`] })).toBeNull() - }) -}) diff --git a/src/main/ai-vault/session-list-retention.ts b/src/main/ai-vault/session-list-retention.ts deleted file mode 100644 index 19e085bc79c..00000000000 --- a/src/main/ai-vault/session-list-retention.ts +++ /dev/null @@ -1,321 +0,0 @@ -import type { - AiVaultListResult, - AiVaultScanIssue, - AiVaultSession -} from '../../shared/ai-vault-types' -import { stringifyJsonWithinByteLimit } from '../../shared/node-bounded-json-stringify' - -export const AI_VAULT_SESSION_ID_MAX_UTF8_BYTES = 64 * 1024 -export const AI_VAULT_SESSION_PATH_MAX_UTF8_BYTES = 128 * 1024 -export const AI_VAULT_SESSION_DISPLAY_FIELD_MAX_UTF8_BYTES = 4 * 1024 -export const AI_VAULT_SESSION_MAX_JSON_BYTES = 1024 * 1024 -export const AI_VAULT_SESSION_LIST_MAX_JSON_BYTES = 32 * 1024 * 1024 -export const AI_VAULT_SESSION_LIST_CACHE_KEY_MAX_JSON_BYTES = 256 * 1024 -export const AI_VAULT_SCAN_ISSUE_MAX_ENTRIES = 1024 - -const AI_VAULT_SESSION_DERIVED_FIELD_MAX_UTF8_BYTES = 512 * 1024 -const AI_VAULT_SCAN_ISSUE_FIELD_MAX_UTF8_BYTES = 16 * 1024 -const AI_VAULT_SCAN_ISSUE_MAX_JSON_BYTES = 64 * 1024 -const AI_VAULT_SESSION_PREVIEW_MAX_MESSAGES = 5 -const TRUNCATION_MARKER = '...' - -export class AiVaultSessionCapacityError extends Error { - constructor(field: string, maxBytes: number) { - super(`AI Vault omitted a session because its ${field} exceeds the ${maxBytes} byte limit.`) - this.name = 'AiVaultSessionCapacityError' - } -} - -export function retainAiVaultSession(session: AiVaultSession): AiVaultSession { - requireField(session.executionHostId, 64 * 1024, 'execution host id') - requireField(session.sessionId, AI_VAULT_SESSION_ID_MAX_UTF8_BYTES, 'session id') - requireField(session.cwd, AI_VAULT_SESSION_PATH_MAX_UTF8_BYTES, 'working directory') - requireField(session.filePath, AI_VAULT_SESSION_PATH_MAX_UTF8_BYTES, 'transcript path') - requireField(session.codexHome, AI_VAULT_SESSION_PATH_MAX_UTF8_BYTES, 'Codex home') - requireField(session.id, AI_VAULT_SESSION_DERIVED_FIELD_MAX_UTF8_BYTES, 'derived id') - requireField( - session.resumeCommand, - AI_VAULT_SESSION_DERIVED_FIELD_MAX_UTF8_BYTES, - 'resume command' - ) - if (session.subagent) { - requireField( - session.subagent.parentSessionId, - AI_VAULT_SESSION_ID_MAX_UTF8_BYTES, - 'parent session id' - ) - } - - const title = truncateUtf8(session.title, AI_VAULT_SESSION_DISPLAY_FIELD_MAX_UTF8_BYTES) - const branch = truncateNullableUtf8(session.branch, AI_VAULT_SESSION_DISPLAY_FIELD_MAX_UTF8_BYTES) - const model = truncateNullableUtf8(session.model, AI_VAULT_SESSION_DISPLAY_FIELD_MAX_UTF8_BYTES) - const lastUserPrompt = truncateNullableUtf8( - session.lastUserPrompt, - AI_VAULT_SESSION_DISPLAY_FIELD_MAX_UTF8_BYTES - ) - const previewSource = - session.previewMessages.length > AI_VAULT_SESSION_PREVIEW_MAX_MESSAGES - ? session.previewMessages.slice(-AI_VAULT_SESSION_PREVIEW_MAX_MESSAGES) - : session.previewMessages - const previewMessages = previewSource.map((message) => { - const text = truncateUtf8(message.text, AI_VAULT_SESSION_DISPLAY_FIELD_MAX_UTF8_BYTES) - return text === message.text ? message : { ...message, text } - }) - const agentType = session.subagent - ? truncateNullableUtf8( - session.subagent.agentType, - AI_VAULT_SESSION_DISPLAY_FIELD_MAX_UTF8_BYTES - ) - : undefined - const changed = - title !== session.title || - branch !== session.branch || - model !== session.model || - lastUserPrompt !== session.lastUserPrompt || - previewSource !== session.previewMessages || - previewMessages.some((message, index) => message !== session.previewMessages[index]) || - agentType !== session.subagent?.agentType - const retained = changed - ? { - ...session, - title, - branch, - model, - ...(session.lastUserPrompt === undefined ? {} : { lastUserPrompt }), - previewMessages, - ...(session.subagent - ? { subagent: { ...session.subagent, agentType: agentType ?? null } } - : {}) - } - : session - try { - stringifyJsonWithinByteLimit(retained, AI_VAULT_SESSION_MAX_JSON_BYTES) - } catch { - throw new AiVaultSessionCapacityError('serialized metadata', AI_VAULT_SESSION_MAX_JSON_BYTES) - } - return retained -} - -export function retainAiVaultSessionsWithinAggregate( - sessions: readonly AiVaultSession[], - maxBytes: number = AI_VAULT_SESSION_LIST_MAX_JSON_BYTES -): { - sessions: AiVaultSession[] - omitted: number -} { - const limit = boundedListLimit(maxBytes) - const retained: AiVaultSession[] = [] - let retainedBytes = 2 - let omitted = 0 - for (let index = 0; index < sessions.length; index += 1) { - try { - const session = retainAiVaultSession(sessions[index]) - const bytes = stringifyJsonWithinByteLimit( - session, - AI_VAULT_SESSION_MAX_JSON_BYTES - ).byteLength - const nextBytes = retainedBytes + bytes + (retained.length > 0 ? 1 : 0) - if (nextBytes > limit) { - omitted += sessions.length - index - break - } - retained.push(session) - retainedBytes = nextBytes - } catch { - omitted++ - } - } - return { sessions: retained, omitted } -} - -export function boundAiVaultListResult( - result: AiVaultListResult, - maxBytes: number = AI_VAULT_SESSION_LIST_MAX_JSON_BYTES -): AiVaultListResult { - const limit = boundedListLimit(maxBytes) - const scannedAt = truncateUtf8(result.scannedAt, 256) - const baseBytes = stringifyJsonWithinByteLimit( - { sessions: [], issues: [], scannedAt }, - limit - ).byteLength - const sessions: AiVaultSession[] = [] - const sessionBytes: number[] = [] - const issues: AiVaultScanIssue[] = [] - const issueBytes: number[] = [] - let retainedBytes = baseBytes - let omittedSessions = 0 - let omittedIssues = 0 - let firstOmittedAgent: AiVaultScanIssue['agent'] | null = null - let changed = scannedAt !== result.scannedAt - - for (let index = 0; index < result.sessions.length; index += 1) { - try { - const session = retainAiVaultSession(result.sessions[index]) - const bytes = stringifyJsonWithinByteLimit( - session, - AI_VAULT_SESSION_MAX_JSON_BYTES - ).byteLength - const nextBytes = retainedBytes + bytes + (sessions.length > 0 ? 1 : 0) - if (nextBytes > limit) { - omittedSessions += result.sessions.length - index - firstOmittedAgent ??= session.agent - changed = true - break - } - sessions.push(session) - sessionBytes.push(bytes) - retainedBytes = nextBytes - changed ||= session !== result.sessions[index] - } catch { - omittedSessions++ - firstOmittedAgent ??= result.sessions[index].agent - changed = true - } - } - - for (let index = 0; index < result.issues.length; index += 1) { - if (issues.length >= AI_VAULT_SCAN_ISSUE_MAX_ENTRIES) { - omittedIssues += result.issues.length - index - changed = true - break - } - const issue = retainScanIssue(result.issues[index]) - const bytes = stringifyJsonWithinByteLimit(issue, AI_VAULT_SCAN_ISSUE_MAX_JSON_BYTES).byteLength - const nextBytes = retainedBytes + bytes + (issues.length > 0 ? 1 : 0) - if (nextBytes > limit) { - omittedIssues += result.issues.length - index - changed = true - break - } - issues.push(issue) - issueBytes.push(bytes) - retainedBytes = nextBytes - changed ||= issue !== result.issues[index] - } - - if (omittedSessions > 0 || omittedIssues > 0) { - let summary = overflowSummary(firstOmittedAgent, omittedSessions, omittedIssues) - let summaryBytes = issueJsonBytes(summary) - while ( - issues.length >= AI_VAULT_SCAN_ISSUE_MAX_ENTRIES || - retainedBytes + summaryBytes + (issues.length > 0 ? 1 : 0) > limit - ) { - if (issues.length > 0) { - const removedBytes = issueBytes.pop() ?? 0 - issues.pop() - retainedBytes -= removedBytes + (issues.length > 0 ? 1 : 0) - omittedIssues++ - } else if (sessions.length > 0) { - const removedBytes = sessionBytes.pop() ?? 0 - sessions.pop() - retainedBytes -= removedBytes + (sessions.length > 0 ? 1 : 0) - omittedSessions++ - } else { - break - } - summary = overflowSummary(firstOmittedAgent, omittedSessions, omittedIssues) - summaryBytes = issueJsonBytes(summary) - } - issues.push(summary) - } - - return changed ? { sessions, issues, scannedAt } : result -} - -export function aiVaultSessionListCacheKey(value: unknown): string | null { - try { - return stringifyJsonWithinByteLimit(value, AI_VAULT_SESSION_LIST_CACHE_KEY_MAX_JSON_BYTES) - .serialized - } catch { - return null - } -} - -function retainScanIssue(issue: AiVaultScanIssue): AiVaultScanIssue { - const executionHostId = - issue.executionHostId && - !exceedsUtf8Bytes(issue.executionHostId, AI_VAULT_SCAN_ISSUE_FIELD_MAX_UTF8_BYTES) - ? issue.executionHostId - : undefined - const path = truncateUtf8(issue.path, AI_VAULT_SCAN_ISSUE_FIELD_MAX_UTF8_BYTES) - const message = truncateUtf8(issue.message, AI_VAULT_SCAN_ISSUE_FIELD_MAX_UTF8_BYTES) - return executionHostId === issue.executionHostId && - path === issue.path && - message === issue.message - ? issue - : { - ...issue, - executionHostId, - path, - message - } -} - -function requireField(value: string | null, maxBytes: number, field: string): void { - if (value !== null && exceedsUtf8Bytes(value, maxBytes)) { - throw new AiVaultSessionCapacityError(field, maxBytes) - } -} - -function truncateNullableUtf8(value: string | null, maxBytes: number): string | null -function truncateNullableUtf8( - value: string | null | undefined, - maxBytes: number -): string | null | undefined -function truncateNullableUtf8( - value: string | null | undefined, - maxBytes: number -): string | null | undefined { - return value == null ? value : truncateUtf8(value, maxBytes) -} - -function truncateUtf8(value: string, maxBytes: number): string { - if (!exceedsUtf8Bytes(value, maxBytes)) { - return value - } - const prefixBytes = Math.max(0, maxBytes - TRUNCATION_MARKER.length) - const end = utf8PrefixEnd(value, prefixBytes) - return `${value.slice(0, end)}${TRUNCATION_MARKER}` -} - -function exceedsUtf8Bytes(value: string, maxBytes: number): boolean { - return utf8PrefixEnd(value, maxBytes) < value.length -} - -function utf8PrefixEnd(value: string, maxBytes: number): number { - let bytes = 0 - let index = 0 - while (index < value.length) { - const code = value.codePointAt(index) ?? 0 - const charBytes = code <= 0x7f ? 1 : code <= 0x7ff ? 2 : code <= 0xffff ? 3 : 4 - if (bytes + charBytes > maxBytes) { - break - } - bytes += charBytes - index += code > 0xffff ? 2 : 1 - } - return index -} - -function overflowSummary( - agent: AiVaultScanIssue['agent'] | null, - omittedSessions: number, - omittedIssues: number -): AiVaultScanIssue { - return { - agent: agent ?? 'codex', - path: 'AI Vault session list', - message: `AI Vault omitted ${omittedSessions} sessions and ${omittedIssues} scan issues because the result exceeded its memory limits.` - } -} - -function issueJsonBytes(issue: AiVaultScanIssue): number { - return stringifyJsonWithinByteLimit(issue, AI_VAULT_SCAN_ISSUE_MAX_JSON_BYTES).byteLength -} - -function boundedListLimit(requested: number): number { - if (!Number.isSafeInteger(requested) || requested < 0) { - throw new RangeError('AI Vault session list limit must be a non-negative safe integer') - } - return Math.min(requested, AI_VAULT_SESSION_LIST_MAX_JSON_BYTES) -} diff --git a/src/main/ai-vault/session-parse-cache-payload.test.ts b/src/main/ai-vault/session-parse-cache-payload.test.ts deleted file mode 100644 index 21fe145e1ca..00000000000 --- a/src/main/ai-vault/session-parse-cache-payload.test.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { AiVaultSession } from '../../shared/ai-vault-types' -import { - AI_VAULT_PARSE_CACHE_APP_VERSION_MAX_JSON_BYTES, - AI_VAULT_PARSE_CACHE_PERSIST_MAX_UTF8_BYTES, - serializeSessionParseCachePayload -} from './session-parse-cache-payload' -import type { PersistedSessionParseCacheEntry } from './session-parse-cache-retention' -import { AI_VAULT_WHOLE_JSON_MAX_BYTES } from './session-whole-json-reader' - -function entry(model: string | null = null): PersistedSessionParseCacheEntry { - return { - mtimeMs: 1, - sizeBytes: 2, - platform: process.platform, - session: session(model) - } -} - -function session(model: string | null): AiVaultSession { - return { - id: 'local:claude:session:/transcript', - executionHostId: 'local', - agent: 'claude', - sessionId: 'session', - title: 'Title', - cwd: '/repo', - branch: 'main', - model, - filePath: '/transcript', - codexHome: null, - createdAt: null, - updatedAt: null, - modifiedAt: '2026-01-01T00:00:00.000Z', - messageCount: 1, - totalTokens: 1, - previewMessages: [], - queuedMessageCount: 0, - subagentTranscriptCount: 0, - resumeCommand: 'claude --resume session', - subagent: null - } -} - -describe('session parse cache payload', () => { - it('is byte-identical to native JSON when every LRU entry fits', () => { - const entries: [string, PersistedSessionParseCacheEntry][] = [ - ['/old\n✨', entry('first')], - ['/new', entry('second')] - ] - const expected = JSON.stringify({ schemaVersion: 1, appVersion: '1.2.3', entries }) - - expect( - serializeSessionParseCachePayload({ schemaVersion: 1, appVersion: '1.2.3', entries }) - ).toBe(expected) - }) - - it('uses the exact byte boundary and prioritizes newest LRU entries', () => { - const older: [string, PersistedSessionParseCacheEntry] = ['/older', entry('x'.repeat(200))] - const middle: [string, PersistedSessionParseCacheEntry] = ['/middle', entry('middle')] - const newest: [string, PersistedSessionParseCacheEntry] = ['/newest', entry('newest')] - const expected = JSON.stringify({ - schemaVersion: 1, - appVersion: '1.2.3', - entries: [middle, newest] - }) - const exactBytes = Buffer.byteLength(expected, 'utf8') - - const exact = serializeSessionParseCachePayload({ - schemaVersion: 1, - appVersion: '1.2.3', - entries: [older, middle, newest], - maxBytes: exactBytes - }) - expect(exact).toBe(expected) - expect(Buffer.byteLength(exact, 'utf8')).toBe(exactBytes) - - const overflow = JSON.parse( - serializeSessionParseCachePayload({ - schemaVersion: 1, - appVersion: '1.2.3', - entries: [older, middle, newest], - maxBytes: exactBytes - 1 - }) - ) as { entries: [string, PersistedSessionParseCacheEntry][] } - expect(overflow.entries.map(([path]) => path)).toEqual(['/newest']) - }) - - it('caps production output below the 64 MiB reader ceiling', () => { - const payload = serializeSessionParseCachePayload({ - schemaVersion: 1, - appVersion: '1.2.3', - entries: [['/too-large', entry('x'.repeat(1024 * 1024))]], - maxBytes: 512 - }) - - expect(JSON.parse(payload).entries).toEqual([]) - expect(Buffer.byteLength(payload, 'utf8')).toBeLessThanOrEqual(512) - expect(AI_VAULT_PARSE_CACHE_PERSIST_MAX_UTF8_BYTES).toBeLessThan(AI_VAULT_WHOLE_JSON_MAX_BYTES) - }) - - it('admits an exact-limit encoded app version and rejects one byte over', () => { - const exact = 'x'.repeat(AI_VAULT_PARSE_CACHE_APP_VERSION_MAX_JSON_BYTES - 2) - expect(Buffer.byteLength(JSON.stringify(exact), 'utf8')).toBe( - AI_VAULT_PARSE_CACHE_APP_VERSION_MAX_JSON_BYTES - ) - expect(() => - serializeSessionParseCachePayload({ schemaVersion: 1, appVersion: exact, entries: [] }) - ).not.toThrow() - - expect(() => - serializeSessionParseCachePayload({ - schemaVersion: 1, - appVersion: `${exact}x`, - entries: [] - }) - ).toThrow(`JSON output exceeds ${AI_VAULT_PARSE_CACHE_APP_VERSION_MAX_JSON_BYTES} bytes`) - }) -}) diff --git a/src/main/ai-vault/session-parse-cache-payload.ts b/src/main/ai-vault/session-parse-cache-payload.ts deleted file mode 100644 index c0d731473bb..00000000000 --- a/src/main/ai-vault/session-parse-cache-payload.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { - JsonStringifyByteLimitError, - stringifyJsonWithinByteLimit -} from '../../shared/node-bounded-json-stringify' -import type { PersistedSessionParseCacheEntry } from './session-parse-cache-retention' - -// Half the loader's 64 MiB ceiling leaves headroom for the UTF-16 string and -// the writeFile encoding buffer to coexist during a save. -export const AI_VAULT_PARSE_CACHE_PERSIST_MAX_UTF8_BYTES = 32 * 1024 * 1024 -export const AI_VAULT_PARSE_CACHE_APP_VERSION_MAX_JSON_BYTES = 4 * 1024 - -export function serializeSessionParseCachePayload(args: { - schemaVersion: number - appVersion: string - entries: readonly [string, PersistedSessionParseCacheEntry][] - maxBytes?: number -}): string { - if (!Number.isSafeInteger(args.schemaVersion)) { - throw new TypeError('Session parse cache schema version must be a safe integer') - } - const maxBytes = boundedPayloadLimit(args.maxBytes) - const appVersion = stringifyJsonWithinByteLimit( - args.appVersion, - AI_VAULT_PARSE_CACHE_APP_VERSION_MAX_JSON_BYTES - ).serialized - const prefix = `{"schemaVersion":${args.schemaVersion},"appVersion":${appVersion},"entries":[` - const suffix = ']}' - let retainedBytes = Buffer.byteLength(prefix, 'utf8') + Buffer.byteLength(suffix, 'utf8') - if (retainedBytes > maxBytes) { - throw new JsonStringifyByteLimitError(retainedBytes, maxBytes) - } - - const newestFirst: string[] = [] - for (let index = args.entries.length - 1; index >= 0; index -= 1) { - const separatorBytes = newestFirst.length === 0 ? 0 : 1 - const remaining = maxBytes - retainedBytes - separatorBytes - if (remaining < 0) { - continue - } - try { - const encoded = stringifyJsonWithinByteLimit(args.entries[index], remaining) - newestFirst.push(encoded.serialized) - retainedBytes += separatorBytes + encoded.byteLength - } catch { - // One entry that cannot fit must not prevent smaller older entries from persisting. - } - } - - const fragments = [prefix] - for (let index = newestFirst.length - 1; index >= 0; index -= 1) { - if (index < newestFirst.length - 1) { - fragments.push(',') - } - fragments.push(newestFirst[index]) - } - fragments.push(suffix) - return fragments.join('') -} - -function boundedPayloadLimit(requested: number | undefined): number { - const limit = requested ?? AI_VAULT_PARSE_CACHE_PERSIST_MAX_UTF8_BYTES - if (!Number.isSafeInteger(limit) || limit < 0) { - throw new RangeError('Session parse cache payload limit must be a non-negative safe integer') - } - return Math.min(limit, AI_VAULT_PARSE_CACHE_PERSIST_MAX_UTF8_BYTES) -} diff --git a/src/main/ai-vault/session-parse-cache-persistence.ts b/src/main/ai-vault/session-parse-cache-persistence.ts index 519e024785d..f4a924bd82f 100644 --- a/src/main/ai-vault/session-parse-cache-persistence.ts +++ b/src/main/ai-vault/session-parse-cache-persistence.ts @@ -3,7 +3,7 @@ // of re-reading the whole transcript corpus (issue #9210: 6.7 GB / 109 s cold // scans). Disabled unless the composition root calls init; every failure mode // degrades to today's cold-scan behavior. -import { mkdir, opendir, rename, rm, writeFile } from 'node:fs/promises' +import { mkdir, readdir, readFile, rename, rm, writeFile } from 'node:fs/promises' import { dirname, join } from 'node:path' import { seedSessionParseCache, @@ -11,9 +11,6 @@ import { type PersistedSessionParseCacheEntry, type SessionParseStats } from './session-scanner-parse-cache' -import { serializeSessionParseCachePayload } from './session-parse-cache-payload' -import { withAiVaultWholeJsonFile } from './session-whole-json-reader' -import { parseAiVaultJsonText } from './session-scanner-values' // Bump when the persisted entry layout changes; a mismatched file is discarded whole. const SCHEMA_VERSION = 1 @@ -101,12 +98,11 @@ export async function flushSessionParseCachePersistForTests(): Promise { async function loadPersistedEntries(current: SessionParseCachePersistenceOptions): Promise { await sweepOrphanedTempFiles(current.filePath) try { - await withAiVaultWholeJsonFile(current.filePath, (content) => { - const entries = parsePersistedFile(parseAiVaultJsonText(content), current.appVersion) - if (entries) { - seedSessionParseCache(entries) - } - }) + const raw = await readFile(current.filePath, 'utf-8') + const entries = parsePersistedFile(JSON.parse(raw), current.appVersion) + if (entries) { + seedSessionParseCache(entries) + } } catch { // Why: a missing/corrupt/foreign cache file must never fail the scan; // worst case is exactly today's cold scan. @@ -119,12 +115,12 @@ async function loadPersistedEntries(current: SessionParseCachePersistenceOptions async function sweepOrphanedTempFiles(filePath: string): Promise { const directory = dirname(filePath) try { - const entries = await opendir(directory) - for await (const entry of entries) { - if (entry.name.startsWith('session-parse-cache-') && entry.name.endsWith('.tmp')) { - await rm(join(directory, entry.name), { force: true }).catch(() => {}) - } - } + const names = await readdir(directory) + await Promise.all( + names + .filter((name) => name.startsWith('session-parse-cache-') && name.endsWith('.tmp')) + .map((name) => rm(join(directory, name), { force: true }).catch(() => {})) + ) } catch { // Directory missing or unreadable — nothing to sweep. } @@ -194,7 +190,7 @@ async function persistSnapshot(current: SessionParseCachePersistenceOptions): Pr const directory = dirname(current.filePath) const tempPath = join(directory, `session-parse-cache-${process.pid}-${Date.now()}.tmp`) try { - const payload = serializeSessionParseCachePayload({ + const payload = JSON.stringify({ schemaVersion: SCHEMA_VERSION, appVersion: current.appVersion, entries: snapshotSessionParseCacheForPersistence() diff --git a/src/main/ai-vault/session-parse-cache-retention.test.ts b/src/main/ai-vault/session-parse-cache-retention.test.ts deleted file mode 100644 index 980e63e72bc..00000000000 --- a/src/main/ai-vault/session-parse-cache-retention.test.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import type { AiVaultSession } from '../../shared/ai-vault-types' -import { - AI_VAULT_PARSE_CACHE_KEY_MAX_UTF8_BYTES, - AI_VAULT_PARSE_CACHE_VALUE_MAX_UTF8_BYTES, - inspectSessionParseCacheRetentionForTests, - resetSessionParseCacheRetentionForTests, - seedSessionParseCache, - setSessionParseCacheMaxRetainedBytesForTests, - snapshotSessionParseCacheForPersistence, - storeSessionParseCacheEntry, - type PersistedSessionParseCacheEntry, - type SessionParseCacheEntry -} from './session-parse-cache-retention' -import type { ResumableSessionParseState } from './session-scanner-types' - -beforeEach(() => resetSessionParseCacheRetentionForTests()) -afterEach(() => resetSessionParseCacheRetentionForTests()) - -function persistedEntry(model: string | null = null): PersistedSessionParseCacheEntry { - return { - mtimeMs: 1, - sizeBytes: 2, - platform: process.platform, - session: session(model) - } -} - -function cacheEntry(model: string | null = null): SessionParseCacheEntry { - return { ...persistedEntry(model), resume: null } -} - -function cacheEntryWithResumeBytes(bytes: number): SessionParseCacheEntry { - let state: ResumableSessionParseState - state = { - consumeLine: () => {}, - clone: () => state, - retainedUtf8Bytes: () => bytes, - touchFile: () => {}, - finalize: () => null - } - return { ...persistedEntry(), resume: { state, byteOffset: 0 } } -} - -function session(model: string | null): AiVaultSession { - return { - id: 'local:claude:session:/transcript', - executionHostId: 'local', - agent: 'claude', - sessionId: 'session', - title: 'Title', - cwd: '/repo', - branch: 'main', - model, - filePath: '/transcript', - codexHome: null, - createdAt: null, - updatedAt: null, - modifiedAt: '2026-01-01T00:00:00.000Z', - messageCount: 1, - totalTokens: 1, - previewMessages: [], - queuedMessageCount: 0, - subagentTranscriptCount: 0, - resumeCommand: 'claude --resume session', - subagent: null - } -} - -function entryWithSerializedBytes(targetBytes: number): PersistedSessionParseCacheEntry { - const entry = persistedEntry('') - const baseBytes = Buffer.byteLength(JSON.stringify(entry), 'utf8') - expect(baseBytes).toBeLessThan(targetBytes) - entry.session!.model = 'x'.repeat(targetBytes - baseBytes) - expect(Buffer.byteLength(JSON.stringify(entry), 'utf8')).toBe(targetBytes) - return entry -} - -function retainedBytes(path: string, entry: PersistedSessionParseCacheEntry): number { - return Buffer.byteLength(path, 'utf8') + Buffer.byteLength(JSON.stringify(entry), 'utf8') -} - -describe('session parse cache retention', () => { - it('admits an exact-limit UTF-8 key and rejects one byte over', () => { - const exact = 'é'.repeat(AI_VAULT_PARSE_CACHE_KEY_MAX_UTF8_BYTES / 2) - const overflow = `${exact}x` - expect(Buffer.byteLength(exact, 'utf8')).toBe(AI_VAULT_PARSE_CACHE_KEY_MAX_UTF8_BYTES) - - seedSessionParseCache([ - [exact, persistedEntry()], - [overflow, persistedEntry()] - ]) - - expect(snapshotSessionParseCacheForPersistence().map(([path]) => path)).toEqual([exact]) - }) - - it('admits an exact-limit serialized value and rejects one byte over', () => { - const exact = entryWithSerializedBytes(AI_VAULT_PARSE_CACHE_VALUE_MAX_UTF8_BYTES) - const overflow = entryWithSerializedBytes(AI_VAULT_PARSE_CACHE_VALUE_MAX_UTF8_BYTES + 1) - - seedSessionParseCache([ - ['/exact', exact], - ['/overflow', overflow] - ]) - - expect(snapshotSessionParseCacheForPersistence()).toEqual([['/exact', exact]]) - }) - - it('includes the non-persisted resume fold in the exact value limit', () => { - const persistedBytes = Buffer.byteLength(JSON.stringify(persistedEntry()), 'utf8') - const exactResumeBytes = AI_VAULT_PARSE_CACHE_VALUE_MAX_UTF8_BYTES - persistedBytes - - storeSessionParseCacheEntry('/exact-resume', cacheEntryWithResumeBytes(exactResumeBytes)) - storeSessionParseCacheEntry('/overflow-resume', cacheEntryWithResumeBytes(exactResumeBytes + 1)) - - expect(inspectSessionParseCacheRetentionForTests().paths).toEqual(['/exact-resume']) - }) - - it('fills the aggregate UTF-8 budget exactly and keeps the newest entries on overflow', () => { - const first: [string, PersistedSessionParseCacheEntry] = ['/a', persistedEntry()] - const second: [string, PersistedSessionParseCacheEntry] = ['/b', persistedEntry()] - const exactBytes = retainedBytes(...first) + retainedBytes(...second) - setSessionParseCacheMaxRetainedBytesForTests(exactBytes) - - seedSessionParseCache([first, second]) - expect(inspectSessionParseCacheRetentionForTests()).toEqual({ - paths: ['/a', '/b'], - retainedUtf8Bytes: exactBytes - }) - - setSessionParseCacheMaxRetainedBytesForTests(exactBytes - 1) - seedSessionParseCache([first, second]) - expect(inspectSessionParseCacheRetentionForTests().paths).toEqual(['/b']) - }) - - it('refreshes LRU recency before aggregate eviction', () => { - const persisted = persistedEntry() - const entry = cacheEntry() - const twoEntryBytes = retainedBytes('/a', persisted) + retainedBytes('/b', persisted) - setSessionParseCacheMaxRetainedBytesForTests(twoEntryBytes) - - storeSessionParseCacheEntry('/a', entry) - storeSessionParseCacheEntry('/b', entry) - storeSessionParseCacheEntry('/a', entry) - storeSessionParseCacheEntry('/c', entry) - - expect(inspectSessionParseCacheRetentionForTests().paths).toEqual(['/a', '/c']) - }) -}) diff --git a/src/main/ai-vault/session-parse-cache-retention.ts b/src/main/ai-vault/session-parse-cache-retention.ts deleted file mode 100644 index a6bc5e1e696..00000000000 --- a/src/main/ai-vault/session-parse-cache-retention.ts +++ /dev/null @@ -1,180 +0,0 @@ -import type { AiVaultSession } from '../../shared/ai-vault-types' -import { stringifyJsonWithinByteLimit } from '../../shared/node-bounded-json-stringify' -import type { ResumableSessionParseState } from './session-scanner-types' - -// Covers the 1,000 recent plus 2,000 in-scope result caps with refresh headroom. -export const AI_VAULT_PARSE_CACHE_MAX_ENTRIES = 4_096 -export const AI_VAULT_PARSE_CACHE_KEY_MAX_UTF8_BYTES = 32 * 1024 -export const AI_VAULT_PARSE_CACHE_VALUE_MAX_UTF8_BYTES = 256 * 1024 -export const AI_VAULT_PARSE_CACHE_MAX_RETAINED_UTF8_BYTES = 32 * 1024 * 1024 - -export type SessionParseCacheEntry = { - mtimeMs: number - sizeBytes: number | null - platform: NodeJS.Platform - session: AiVaultSession | null - resume: { - state: ResumableSessionParseState - // A trailing unterminated record must be reread after its writer completes it. - byteOffset: number - } | null -} - -export type PersistedSessionParseCacheEntry = Omit - -type RetainedEntry = { - entry: SessionParseCacheEntry - retainedUtf8Bytes: number -} - -const cache = new Map() -let retainedUtf8Bytes = 0 -let maxRetainedUtf8Bytes = AI_VAULT_PARSE_CACHE_MAX_RETAINED_UTF8_BYTES - -export function getSessionParseCacheEntry(path: string): SessionParseCacheEntry | undefined { - return cache.get(path)?.entry -} - -export function resetSessionParseCacheRetentionForTests(): void { - cache.clear() - retainedUtf8Bytes = 0 - maxRetainedUtf8Bytes = AI_VAULT_PARSE_CACHE_MAX_RETAINED_UTF8_BYTES -} - -export function setSessionParseCacheMaxRetainedBytesForTests(maxBytes: number): void { - if (!Number.isSafeInteger(maxBytes) || maxBytes < 0) { - throw new RangeError('Session parse cache byte limit must be a non-negative safe integer') - } - cache.clear() - retainedUtf8Bytes = 0 - maxRetainedUtf8Bytes = maxBytes -} - -export function inspectSessionParseCacheRetentionForTests(): { - paths: string[] - retainedUtf8Bytes: number -} { - return { paths: [...cache.keys()], retainedUtf8Bytes } -} - -export function snapshotSessionParseCacheForPersistence(): [ - string, - PersistedSessionParseCacheEntry -][] { - return [...cache].map(([path, retained]) => [path, persistedEntry(retained.entry)]) -} - -export function seedSessionParseCache( - entries: Iterable<[string, PersistedSessionParseCacheEntry]> -): void { - const tail = newestEntryTail(entries) - const selected: [string, SessionParseCacheEntry, number][] = [] - const selectedPaths = new Set() - let availableEntries = AI_VAULT_PARSE_CACHE_MAX_ENTRIES - cache.size - let availableBytes = maxRetainedUtf8Bytes - retainedUtf8Bytes - - for (let index = tail.length - 1; index >= 0 && availableEntries > 0; index -= 1) { - const [path, persisted] = tail[index] - // Live entries are fresher than disk, and the newest duplicate seed wins. - if (cache.has(path) || selectedPaths.has(path)) { - continue - } - selectedPaths.add(path) - const entry: SessionParseCacheEntry = { ...persisted, resume: null } - const bytes = retainedEntryUtf8Bytes(path, entry) - if (bytes === null || bytes > availableBytes) { - continue - } - selected.push([path, entry, bytes]) - availableEntries-- - availableBytes -= bytes - } - - for (let index = selected.length - 1; index >= 0; index -= 1) { - const [path, entry, bytes] = selected[index] - cache.set(path, { entry, retainedUtf8Bytes: bytes }) - retainedUtf8Bytes += bytes - } -} - -export function storeSessionParseCacheEntry(path: string, entry: SessionParseCacheEntry): void { - deleteSessionParseCacheEntry(path) - const bytes = retainedEntryUtf8Bytes(path, entry) - if (bytes === null || bytes > maxRetainedUtf8Bytes) { - return - } - - while ( - cache.size >= AI_VAULT_PARSE_CACHE_MAX_ENTRIES || - retainedUtf8Bytes + bytes > maxRetainedUtf8Bytes - ) { - const oldest = cache.keys().next().value - if (oldest === undefined) { - return - } - deleteSessionParseCacheEntry(oldest) - } - - cache.set(path, { entry, retainedUtf8Bytes: bytes }) - retainedUtf8Bytes += bytes -} - -function retainedEntryUtf8Bytes(path: string, entry: SessionParseCacheEntry): number | null { - const keyBytes = Buffer.byteLength(path, 'utf8') - if (keyBytes > AI_VAULT_PARSE_CACHE_KEY_MAX_UTF8_BYTES) { - return null - } - try { - const persistedBytes = stringifyJsonWithinByteLimit( - persistedEntry(entry), - AI_VAULT_PARSE_CACHE_VALUE_MAX_UTF8_BYTES - ).byteLength - const resumeBytes = entry.resume?.state.retainedUtf8Bytes() ?? 0 - const valueBytes = persistedBytes + resumeBytes - if ( - resumeBytes < 0 || - !Number.isSafeInteger(valueBytes) || - valueBytes > AI_VAULT_PARSE_CACHE_VALUE_MAX_UTF8_BYTES - ) { - return null - } - return keyBytes + valueBytes - } catch { - return null - } -} - -function persistedEntry(entry: SessionParseCacheEntry): PersistedSessionParseCacheEntry { - return { - mtimeMs: entry.mtimeMs, - sizeBytes: entry.sizeBytes, - platform: entry.platform, - session: entry.session - } -} - -function deleteSessionParseCacheEntry(path: string): void { - const retained = cache.get(path) - if (!retained) { - return - } - cache.delete(path) - retainedUtf8Bytes -= retained.retainedUtf8Bytes -} - -function newestEntryTail( - entries: Iterable<[string, PersistedSessionParseCacheEntry]> -): [string, PersistedSessionParseCacheEntry][] { - const ring = Array.from({ length: AI_VAULT_PARSE_CACHE_MAX_ENTRIES }) as [ - string, - PersistedSessionParseCacheEntry - ][] - let count = 0 - for (const entry of entries) { - ring[count % AI_VAULT_PARSE_CACHE_MAX_ENTRIES] = entry - count++ - } - const length = Math.min(count, AI_VAULT_PARSE_CACHE_MAX_ENTRIES) - const start = count > length ? count % AI_VAULT_PARSE_CACHE_MAX_ENTRIES : 0 - return Array.from({ length }, (_, index) => ring[(start + index) % ring.length]) -} diff --git a/src/main/ai-vault/session-scanner-accumulator.ts b/src/main/ai-vault/session-scanner-accumulator.ts index e268d547a73..48574818ff3 100644 --- a/src/main/ai-vault/session-scanner-accumulator.ts +++ b/src/main/ai-vault/session-scanner-accumulator.ts @@ -52,31 +52,6 @@ export function cloneSessionAccumulator(accumulator: SessionAccumulator): Sessio return { ...accumulator, previewMessages: [...accumulator.previewMessages] } } -export function sessionAccumulatorRetainedUtf8Bytes(accumulator: SessionAccumulator): number { - let bytes = stringBytes( - accumulator.agent, - accumulator.sessionId, - accumulator.title, - accumulator.fallbackTitle, - accumulator.cwd, - accumulator.branch, - accumulator.model, - accumulator.filePath, - accumulator.createdAt, - accumulator.updatedAt, - accumulator.modifiedAt, - accumulator.lastUserPrompt - ) - for (const preview of accumulator.previewMessages) { - bytes += stringBytes(preview.role, preview.text, preview.timestamp) - } - return bytes -} - -function stringBytes(...values: (string | null)[]): number { - return values.reduce((total, value) => total + Buffer.byteLength(value ?? '', 'utf8'), 0) -} - // Resumable fold for parsers whose only parse state is the accumulator itself // (cursor, copilot, droid, openclaw/pi, gemini-jsonl). Parsers with extra // closure state (claude, codex) build their own ResumableSessionParseState. @@ -88,7 +63,6 @@ export function accumulatorFoldResumeState( consumeLine: (line) => consumeRecordLine(accumulator, line), clone: () => accumulatorFoldResumeState(cloneSessionAccumulator(accumulator), consumeRecordLine), - retainedUtf8Bytes: () => sessionAccumulatorRetainedUtf8Bytes(accumulator), touchFile: (file) => { accumulator.modifiedAt = file.modifiedAt }, diff --git a/src/main/ai-vault/session-scanner-antigravity-parser.ts b/src/main/ai-vault/session-scanner-antigravity-parser.ts index 78c04af58f6..2e3a3a08b4a 100644 --- a/src/main/ai-vault/session-scanner-antigravity-parser.ts +++ b/src/main/ai-vault/session-scanner-antigravity-parser.ts @@ -1,5 +1,6 @@ +import { createReadStream } from 'node:fs' +import { createInterface } from 'node:readline' import type { AiVaultSession } from '../../shared/ai-vault-types' -import { iterateAiVaultJsonlLines } from './session-jsonl-line-reader' import type { ExecutionHostId } from '../../shared/execution-host' import { accumulatorFoldResumeState, @@ -24,7 +25,10 @@ export async function parseAntigravitySessionFile( file: FileWithMtime, platform: NodeJS.Platform = process.platform ): Promise { - const lines = iterateAiVaultJsonlLines(file.path) + const lines = createInterface({ + input: createReadStream(file.path, { encoding: 'utf-8' }), + crlfDelay: Infinity + }) return parseAntigravitySessionLines({ file, lines, platform }) } diff --git a/src/main/ai-vault/session-scanner-claude-subagents.test.ts b/src/main/ai-vault/session-scanner-claude-subagents.test.ts index 5a2a2587199..eb0f90d9bc9 100644 --- a/src/main/ai-vault/session-scanner-claude-subagents.test.ts +++ b/src/main/ai-vault/session-scanner-claude-subagents.test.ts @@ -2,7 +2,6 @@ import { mkdir, mkdtemp, rm, utimes, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { AI_VAULT_SESSION_PATH_MAX_UTF8_BYTES } from './session-list-retention' import { listClaudeSubagentSessions } from './session-scanner-claude-subagents' import { countSubagentTranscripts } from './session-scanner-subagent-transcripts' @@ -376,111 +375,6 @@ describe('listClaudeSubagentSessions', () => { ]) }) - it('caps an oversized subagent directory to the newest transcript files', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-subagent-cap-')) - tempRoots.push(root) - const parentFilePath = join(root, 'project', 'parent-session.jsonl') - const subagentsDir = join(root, 'project', 'parent-session', 'subagents') - - for (const [index, agentId] of ['oldest', 'middle', 'newest'].entries()) { - await writeSubagentTranscript({ - subagentsDir, - agentId, - taskPrompt: `Task for ${agentId}`, - timestamp: `2026-07-05T10:0${index + 1}:00.000Z` - }) - const mtime = new Date(1_700_000_000_000 + index * 1_000) - await utimes(join(subagentsDir, `agent-${agentId}.jsonl`), mtime, mtime) - } - - const result = await listClaudeSubagentSessions({ - parentFilePath, - platform: 'darwin', - maxSessions: 2 - }) - - expect(result.sessions.map((session) => session.title)).toEqual([ - 'Task for newest', - 'Task for middle' - ]) - expect(result.issues).toHaveLength(1) - expect(result.issues[0]?.message).toContain('newest 2 subagent transcripts') - }) - - it('omits a subagent whose retained session metadata exceeds its per-session limit', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-subagent-session-limit-')) - tempRoots.push(root) - const parentFilePath = join(root, 'project', 'parent-session.jsonl') - const subagentsDir = join(root, 'project', 'parent-session', 'subagents') - - await writeJsonlFile(join(subagentsDir, 'agent-oversized.jsonl'), [ - { - type: 'user', - sessionId: 'parent-session', - timestamp: '2026-07-05T10:00:00.000Z', - cwd: 'x'.repeat(AI_VAULT_SESSION_PATH_MAX_UTF8_BYTES + 1), - message: { role: 'user', content: 'Oversized task' } - } - ]) - await writeSubagentTranscript({ - subagentsDir, - agentId: 'retained', - taskPrompt: 'Retained task', - timestamp: '2026-07-05T10:01:00.000Z' - }) - - const result = await listClaudeSubagentSessions({ parentFilePath, platform: 'darwin' }) - - expect(result.sessions.map((session) => session.title)).toEqual(['Retained task']) - expect(result.issues).toHaveLength(1) - expect(result.issues[0]?.message).toContain('working directory exceeds') - }) - - it('stops before parsing older transcripts when the aggregate memory limit is full', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-subagent-aggregate-limit-')) - tempRoots.push(root) - const parentFilePath = join(root, 'project', 'parent-session.jsonl') - const subagentsDir = join(root, 'project', 'parent-session', 'subagents') - - for (let index = 0; index < 8; index += 1) { - await writeSubagentTranscript({ - subagentsDir, - agentId: `task-${index}`, - taskPrompt: `Task for task-${index}`, - timestamp: `2026-07-05T10:${index.toString().padStart(2, '0')}:00.000Z` - }) - const mtime = new Date(1_700_000_000_000 + index * 1_000) - await utimes(join(subagentsDir, `agent-task-${index}.jsonl`), mtime, mtime) - } - - const unbounded = await listClaudeSubagentSessions({ parentFilePath, platform: 'darwin' }) - expect(unbounded.issues).toEqual([]) - const newestSessionBytes = Buffer.byteLength(JSON.stringify([unbounded.sessions[0]]), 'utf8') - - await writeJsonlFile(join(subagentsDir, 'agent-unparsed.jsonl'), [ - { - type: 'user', - sessionId: 'parent-session', - timestamp: '2026-07-05T09:00:00.000Z', - cwd: 'x'.repeat(AI_VAULT_SESSION_PATH_MAX_UTF8_BYTES + 1), - message: { role: 'user', content: 'This older task must not be parsed' } - } - ]) - const oldestMtime = new Date(1_699_999_999_000) - await utimes(join(subagentsDir, 'agent-unparsed.jsonl'), oldestMtime, oldestMtime) - - const result = await listClaudeSubagentSessions({ - parentFilePath, - platform: 'darwin', - maxAggregateBytes: newestSessionBytes - }) - - expect(result.sessions.map((session) => session.title)).toEqual(['Task for task-7']) - expect(result.issues).toHaveLength(1) - expect(result.issues[0]?.message).toContain('8 older entries were omitted') - expect(result.issues[0]?.message).toContain('memory limit') - }) - it('returns an empty list when the session never spawned subagents', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-subagent-none-')) tempRoots.push(root) diff --git a/src/main/ai-vault/session-scanner-claude-subagents.ts b/src/main/ai-vault/session-scanner-claude-subagents.ts index f00ca70de80..5c269d91cf3 100644 --- a/src/main/ai-vault/session-scanner-claude-subagents.ts +++ b/src/main/ai-vault/session-scanner-claude-subagents.ts @@ -1,18 +1,15 @@ -import { opendir, stat } from 'node:fs/promises' +import { createReadStream } from 'node:fs' +import { readdir, readFile, stat } from 'node:fs/promises' import { basename, extname, join } from 'node:path' +import { createInterface } from 'node:readline' import type { AiVaultScanIssue, AiVaultSession, AiVaultSubagentListResult, AiVaultSubagentRunStatus } from '../../shared/ai-vault-types' -import { retainClaudeSubagentSessionBatch } from './claude-subagent-list-retention' -import { retainAiVaultSession } from './session-list-retention' -import { sessionIdFromFileName } from './session-scanner-accumulator' -import { iterateAiVaultJsonlLines } from './session-jsonl-line-reader' -import { withAiVaultWholeJsonFile } from './session-whole-json-reader' +import { sessionIdFromFileName, sessionSortTime } from './session-scanner-accumulator' import { parseClaudeSessionFile } from './session-scanner-primary-parsers' -import type { FileWithMtime } from './session-scanner-types' import { isSubagentTranscriptFileName, subagentTranscriptsDirFor, @@ -32,9 +29,6 @@ const SUBAGENT_RUNNING_RECENCY_MS = 5 * 60_000 // Match the main scanner's deliberate parse batching (SESSION_PARSE_CONCURRENCY): // opening every subagent read stream at once stalls over SSH/WSL UNC paths. const SUBAGENT_PARSE_CONCURRENCY = 8 -// A single expanded parent stays bounded; oversized sessions show the newest transcripts. -export const CLAUDE_SUBAGENT_SESSION_MAX = 2_048 -const CLAUDE_SUBAGENT_ISSUE_MAX = 256 const TASK_NOTIFICATION_MARKER = '' const TOOL_USE_RESULT_MARKER = '"toolUseResult"' @@ -69,40 +63,40 @@ export async function listClaudeSubagentSessions(args: { parentFilePath: string platform?: NodeJS.Platform now?: number - maxSessions?: number - maxAggregateBytes?: number }): Promise { const platform = args.platform ?? process.platform const now = args.now ?? Date.now() const issues: AiVaultScanIssue[] = [] const subagentsDir = subagentTranscriptsDirFor(args.parentFilePath) - const maxSessions = Math.min( - CLAUDE_SUBAGENT_SESSION_MAX, - Math.max(0, Math.floor(args.maxSessions ?? CLAUDE_SUBAGENT_SESSION_MAX)) - ) - const transcriptFiles = await newestSubagentTranscriptFiles(subagentsDir, maxSessions, issues) - if (transcriptFiles.length === 0) { + let entries + try { + entries = await readdir(subagentsDir, { withFileTypes: true }) + } catch { + return { sessions: [], issues } + } + + const transcriptNames = entries + .filter((entry) => isSubagentTranscriptFileName(entry.name, entry.isFile())) + .map((entry) => entry.name) + if (transcriptNames.length === 0) { return { sessions: [], issues } } // One pass over the parent transcript resolves every subagent's status. - const agentIds = new Set( - transcriptFiles.map((file) => subagentIdFromFileName(basename(file.path))) - ) - const statusByAgentId = await collectSubagentTaskStatuses(args.parentFilePath, agentIds) + const statusByAgentId = await collectSubagentTaskStatuses(args.parentFilePath) // Why: the layout fixes the parent's sessionId (.jsonl -> subagents/); // deriving it here avoids a subagent transcript with no sessionId records // linking to its own filename-derived id instead of the parent. const parentSessionId = sessionIdFromFileName(args.parentFilePath) - let parsed: AiVaultSession[] = [] - for (let index = 0; index < transcriptFiles.length; index += SUBAGENT_PARSE_CONCURRENCY) { - const batch = transcriptFiles.slice(index, index + SUBAGENT_PARSE_CONCURRENCY) + const parsed: (AiVaultSession | null)[] = [] + for (let index = 0; index < transcriptNames.length; index += SUBAGENT_PARSE_CONCURRENCY) { + const batch = transcriptNames.slice(index, index + SUBAGENT_PARSE_CONCURRENCY) const batchResults = await Promise.all( - batch.map((file) => + batch.map((name) => parseSubagentTranscript({ - file, - agentId: subagentIdFromFileName(basename(file.path)), + filePath: join(subagentsDir, name), + agentId: subagentIdFromFileName(name), parentSessionId, statusByAgentId, now, @@ -111,92 +105,14 @@ export async function listClaudeSubagentSessions(args: { }) ) ) - const retained = retainClaudeSubagentSessionBatch(parsed, batchResults, args.maxAggregateBytes) - parsed = retained.sessions - if (retained.omitted > 0) { - const unparsed = transcriptFiles.length - index - batch.length - addClaudeSubagentIssue( - issues, - subagentsDir, - `Showing the newest ${parsed.length.toLocaleString()} subagent transcripts; ${(retained.omitted + unparsed).toLocaleString()} older entries were omitted because the list reached its memory limit.` - ) - break - } + parsed.push(...batchResults) } - return { sessions: parsed, issues } -} - -async function newestSubagentTranscriptFiles( - subagentsDir: string, - limit: number, - issues: AiVaultScanIssue[] -): Promise { - let directory - try { - directory = await opendir(subagentsDir) - } catch { - return [] - } - - const files: FileWithMtime[] = [] - let validFileCount = 0 - try { - for await (const entry of directory) { - if (!isSubagentTranscriptFileName(entry.name, entry.isFile())) { - continue - } - const path = join(subagentsDir, entry.name) - try { - const fileStat = await stat(path) - validFileCount += 1 - retainNewestSubagentFile(files, limit, { - path, - mtimeMs: fileStat.mtimeMs, - modifiedAt: fileStat.mtime.toISOString(), - sizeBytes: fileStat.size, - dev: fileStat.dev, - ino: fileStat.ino, - nlink: fileStat.nlink - }) - } catch (err) { - addClaudeSubagentIssue(issues, path, errorMessage(err)) - } - } - } catch { - // A directory disappearing mid-scan leaves the successfully retained prefix usable. - } - if (validFileCount > limit) { - addClaudeSubagentIssue( - issues, - subagentsDir, - `Showing the newest ${limit.toLocaleString()} subagent transcripts; older entries were omitted.` - ) - } - return files -} - -function retainNewestSubagentFile( - files: FileWithMtime[], - limit: number, - file: FileWithMtime -): void { - let low = 0 - let high = files.length - while (low < high) { - const middle = Math.floor((low + high) / 2) - if ((files[middle]?.mtimeMs ?? Number.NEGATIVE_INFINITY) >= file.mtimeMs) { - low = middle + 1 - } else { - high = middle - } - } - if (low >= limit) { - return - } - files.splice(low, 0, file) - if (files.length > limit) { - files.pop() + return { + sessions: parsed + .filter((session): session is AiVaultSession => session !== null) + .sort((left, right) => sessionSortTime(right) - sessionSortTime(left)), + issues } } @@ -205,7 +121,7 @@ function subagentIdFromFileName(name: string): string { } async function parseSubagentTranscript(args: { - file: FileWithMtime + filePath: string agentId: string parentSessionId: string statusByAgentId: ReadonlyMap @@ -214,12 +130,20 @@ async function parseSubagentTranscript(args: { issues: AiVaultScanIssue[] }): Promise { try { - const session = await parseClaudeSessionFile(args.file, args.platform) + const fileStat = await stat(args.filePath) + const session = await parseClaudeSessionFile( + { + path: args.filePath, + mtimeMs: fileStat.mtimeMs, + modifiedAt: fileStat.mtime.toISOString() + }, + args.platform + ) if (!session) { return null } - const meta = await readSubagentMeta(args.file.path) - return retainAiVaultSession({ + const meta = await readSubagentMeta(args.filePath) + return { ...session, // Why: the spawn description is the name the main agent gave this Task; // it beats the transcript-derived fallback (the raw Task prompt). @@ -231,13 +155,13 @@ async function parseSubagentTranscript(args: { agentType: meta.agentType, status: resolveSubagentStatus({ reportedStatus: args.statusByAgentId.get(args.agentId), - mtimeMs: args.file.mtimeMs, + mtimeMs: fileStat.mtimeMs, now: args.now }) } - }) + } } catch (err) { - addClaudeSubagentIssue(args.issues, args.file.path, errorMessage(err)) + args.issues.push({ agent: 'claude', path: args.filePath, message: errorMessage(err) }) return null } } @@ -261,13 +185,13 @@ function resolveSubagentStatus(args: { // launch with status 'async_launched' and finish with a // whose is the agentId. Last record wins, so interim statuses (e.g. // 'async_launched', notification 'running') are superseded by terminal ones. -async function collectSubagentTaskStatuses( - parentFilePath: string, - requestedAgentIds: ReadonlySet -): Promise> { +async function collectSubagentTaskStatuses(parentFilePath: string): Promise> { const statuses = new Map() try { - const lines = iterateAiVaultJsonlLines(parentFilePath) + const lines = createInterface({ + input: createReadStream(parentFilePath, { encoding: 'utf-8' }), + crlfDelay: Infinity + }) for await (const line of lines) { const hasNotification = line.includes(TASK_NOTIFICATION_MARKER) const hasTaskResult = @@ -293,7 +217,7 @@ async function collectSubagentTaskStatuses( if (text.startsWith(TASK_NOTIFICATION_MARKER)) { const taskId = TASK_ID_PATTERN.exec(text)?.[1]?.trim() const status = TASK_STATUS_PATTERN.exec(text)?.[1] - if (taskId && status && requestedAgentIds.has(taskId)) { + if (taskId && status) { statuses.set(taskId, status) } continue @@ -302,7 +226,7 @@ async function collectSubagentTaskStatuses( const result = asRecord(record.toolUseResult) const agentId = extractString(result?.agentId) const status = extractString(result?.status) - if (agentId && status && requestedAgentIds.has(agentId)) { + if (agentId && status) { statuses.set(agentId, status) } } @@ -312,12 +236,6 @@ async function collectSubagentTaskStatuses( return statuses } -function addClaudeSubagentIssue(issues: AiVaultScanIssue[], path: string, message: string): void { - if (issues.length < CLAUDE_SUBAGENT_ISSUE_MAX) { - issues.push({ agent: 'claude', path, message }) - } -} - // The marker follows / lines in real // notifications, so it sits well past the 96-char title cap — the notification // text must be read untruncated (unlike titles/previews). Both delivery shapes @@ -351,13 +269,11 @@ function taskNotificationBlockText(block: unknown): string { async function readSubagentMeta(transcriptPath: string): Promise { const metaPath = `${transcriptPath.slice(0, -extname(transcriptPath).length)}.meta.json` try { - return await withAiVaultWholeJsonFile(metaPath, (content) => { - const record = parseJsonObject(content) - return { - description: normalizeTitleText(extractString(record?.description) ?? ''), - agentType: extractString(record?.agentType) - } - }) + const record = asRecord(JSON.parse(await readFile(metaPath, 'utf-8')) as unknown) + return { + description: normalizeTitleText(extractString(record?.description) ?? ''), + agentType: extractString(record?.agentType) + } } catch { // The sidecar is optional; the transcript still yields a usable title. return { description: null, agentType: null } diff --git a/src/main/ai-vault/session-scanner-codex-parser.ts b/src/main/ai-vault/session-scanner-codex-parser.ts index cbe9fcebe1a..7e08ec9d92e 100644 --- a/src/main/ai-vault/session-scanner-codex-parser.ts +++ b/src/main/ai-vault/session-scanner-codex-parser.ts @@ -1,6 +1,7 @@ +import { createReadStream } from 'node:fs' +import { createInterface } from 'node:readline' import type { AiVaultSession } from '../../shared/ai-vault-types' import { readCodexSessionIndexTitle } from './session-scanner-codex-title-index' -import { iterateAiVaultJsonlLines } from './session-jsonl-line-reader' import type { ExecutionHostId } from '../../shared/execution-host' import { normalizePromptField } from '../../shared/agent-status-field-normalization' import { @@ -8,7 +9,6 @@ import { cloneSessionAccumulator, createAccumulator, finalizeSession, - sessionAccumulatorRetainedUtf8Bytes, sessionIdFromFileName, updateTimeline } from './session-scanner-accumulator' @@ -38,7 +38,10 @@ export async function parseCodexSessionFile( codexHome: string | null = null, executionHostId?: ExecutionHostId ): Promise { - const lines = iterateAiVaultJsonlLines(file.path) + const lines = createInterface({ + input: createReadStream(file.path, { encoding: 'utf-8' }), + crlfDelay: Infinity + }) return parseCodexSessionLines({ file, @@ -273,7 +276,6 @@ function codexResumeStateFromParseState( consumeLine: (line) => consumeCodexRecordLine(state, line), clone: () => codexResumeStateFromParseState(cloneCodexParseState(state), codexHome, titleReader), - retainedUtf8Bytes: () => sessionAccumulatorRetainedUtf8Bytes(state.accumulator), touchFile: (file) => { state.accumulator.modifiedAt = file.modifiedAt }, diff --git a/src/main/ai-vault/session-scanner-codex-title-index.test.ts b/src/main/ai-vault/session-scanner-codex-title-index.test.ts index f011c0605da..3c5b129b5fc 100644 --- a/src/main/ai-vault/session-scanner-codex-title-index.test.ts +++ b/src/main/ai-vault/session-scanner-codex-title-index.test.ts @@ -4,18 +4,14 @@ import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { _getCodexSessionIndexTitleCacheSizeForTest, - _getCodexSessionIndexRetainedTitleCountForTest, _hasCodexSessionIndexTitleCacheEntryForTest, _readCachedCodexSessionIndexTitlesForTest, _storeCodexSessionIndexTitleCacheEntryForTest, - CODEX_SESSION_INDEX_RETAINED_UTF8_BYTES_MAX, - CODEX_SESSION_INDEX_SESSION_ID_MAX_UTF8_BYTES, readCodexSessionIndexTitle, resetCodexSessionIndexTitleCacheForTests } from './session-scanner-codex-title-index' const CACHE_LIMIT = 64 -const RETAINED_TITLE_LIMIT = 2_048 let tempRoots: string[] = [] @@ -46,42 +42,6 @@ async function readTitle(codexHome: string, index: number): Promise { - it('bounds retained titles without dropping an older requested title', async () => { - const codexHome = await createCodexHome(0) - const lines = Array.from({ length: RETAINED_TITLE_LIMIT + 1 }, (_, index) => - JSON.stringify({ id: `session-${index}`, thread_name: `Title ${index}` }) - ) - await writeFile(join(codexHome, 'session_index.jsonl'), `${lines.join('\n')}\n`) - - expect(await readTitle(codexHome, 0)).toBe('Title 0') - expect(await _getCodexSessionIndexRetainedTitleCountForTest(codexHome)).toBe( - RETAINED_TITLE_LIMIT - ) - }) - - it('bounds retained title-key bytes without dropping an oversized requested id', async () => { - const codexHome = await createCodexHome(0) - const ids = Array.from({ length: 9 }, (_, index) => - `${index}`.padEnd(CODEX_SESSION_INDEX_SESSION_ID_MAX_UTF8_BYTES, 'x') - ) - const oversizedId = `${ids[0]}x` - const lines = [ - JSON.stringify({ id: oversizedId, thread_name: 'Oversized requested title' }), - ...ids.map((id, index) => JSON.stringify({ id, thread_name: `Title ${index}` })) - ] - await writeFile(join(codexHome, 'session_index.jsonl'), `${lines.join('\n')}\n`) - - await expect( - readCodexSessionIndexTitle( - join(codexHome, 'sessions', `${oversizedId}.jsonl`), - codexHome, - oversizedId - ) - ).resolves.toBe('Oversized requested title') - expect(await _getCodexSessionIndexRetainedTitleCountForTest(codexHome)).toBeLessThan(9) - expect(CODEX_SESSION_INDEX_RETAINED_UTF8_BYTES_MAX).toBe(512 * 1024) - }) - it('caps cached title indexes by Codex home', async () => { const homes: string[] = [] diff --git a/src/main/ai-vault/session-scanner-codex-title-index.ts b/src/main/ai-vault/session-scanner-codex-title-index.ts index 67ee4f0b499..8ef2d5645cb 100644 --- a/src/main/ai-vault/session-scanner-codex-title-index.ts +++ b/src/main/ai-vault/session-scanner-codex-title-index.ts @@ -1,22 +1,16 @@ +import { createReadStream } from 'node:fs' import { stat } from 'node:fs/promises' import { basename, dirname, join } from 'node:path' +import { createInterface } from 'node:readline' import { extractString, normalizeTitleText, parseJsonObject } from './session-scanner-values' -import { iterateAiVaultJsonlLines } from './session-jsonl-line-reader' // Codex names threads lazily in /session_index.jsonl; transcripts // carry no title of their own, so parsers look the thread name up here. const CODEX_SESSION_INDEX_FILE = 'session_index.jsonl' -// Misses fall back to an exact streaming lookup, so this only trades memory for scan time. -const CODEX_SESSION_INDEX_RETAINED_TITLE_MAX = 2_048 -export const CODEX_SESSION_INDEX_CACHE_KEY_MAX_UTF8_BYTES = 32 * 1024 -export const CODEX_SESSION_INDEX_SESSION_ID_MAX_UTF8_BYTES = 64 * 1024 -export const CODEX_SESSION_INDEX_RETAINED_UTF8_BYTES_MAX = 512 * 1024 -const CODEX_SESSION_INDEX_TITLE_MAX_UTF8_BYTES = 4 * 1024 -const CODEX_SESSION_INDEX_ENTRY_OVERHEAD_BYTES = 64 // Why: custom and WSL Codex homes can vary over a long-lived main process; -// each cached home can retain its own session_index title map. -export const CODEX_SESSION_INDEX_TITLE_CACHE_MAX = 64 +// each cached home can retain a full session_index title map. +const CODEX_SESSION_INDEX_TITLE_CACHE_MAX = 64 type CodexSessionIndexTitleCacheEntry = { signature: string @@ -24,7 +18,6 @@ type CodexSessionIndexTitleCacheEntry = { } const codexSessionIndexTitleCache = new Map>() -const retainedBytesByTitleMap = new WeakMap, number>() export function resetCodexSessionIndexTitleCacheForTests(): void { codexSessionIndexTitleCache.clear() @@ -38,12 +31,6 @@ export function _hasCodexSessionIndexTitleCacheEntryForTest(codexHome: string): return codexSessionIndexTitleCache.has(codexHome) } -export async function _getCodexSessionIndexRetainedTitleCountForTest( - codexHome: string -): Promise { - return (await codexSessionIndexTitleCache.get(codexHome))?.titles.size ?? 0 -} - export function _storeCodexSessionIndexTitleCacheEntryForTest( codexHome: string, signature: string, @@ -72,21 +59,7 @@ export async function readCodexSessionIndexTitle( return null } const titleBySessionId = await readCodexSessionIndexTitles(resolvedCodexHome) - const cachedTitle = titleBySessionId.get(sessionId) - if (cachedTitle) { - return cachedTitle - } - - // Why: the retained map is capped, but an older requested session must still - // get the same title as before the cap. - const title = await readCodexSessionIndexTitleFromDisk( - join(resolvedCodexHome, CODEX_SESSION_INDEX_FILE), - sessionId - ) - if (title) { - retainCodexSessionIndexTitle(titleBySessionId, sessionId, title) - } - return title + return titleBySessionId.get(sessionId) ?? null } function codexHomeFromSessionFilePath(sessionFilePath: string): string | null { @@ -148,15 +121,8 @@ function storeCodexSessionIndexTitleCacheEntry( codexHome: string, pending: Promise ): void { - if (Buffer.byteLength(codexHome, 'utf8') > CODEX_SESSION_INDEX_CACHE_KEY_MAX_UTF8_BYTES) { - return - } - const boundedPending = pending.then((entry) => { - boundCodexSessionIndexTitles(entry.titles) - return entry - }) codexSessionIndexTitleCache.delete(codexHome) - codexSessionIndexTitleCache.set(codexHome, boundedPending) + codexSessionIndexTitleCache.set(codexHome, pending) if (codexSessionIndexTitleCache.size > CODEX_SESSION_INDEX_TITLE_CACHE_MAX) { const oldest = codexSessionIndexTitleCache.keys().next() if (!oldest.done) { @@ -170,7 +136,10 @@ async function readCodexSessionIndexTitlesFromDisk( ): Promise> { const titleBySessionId = new Map() try { - const lines = iterateAiVaultJsonlLines(indexPath) + const lines = createInterface({ + input: createReadStream(indexPath, { encoding: 'utf-8' }), + crlfDelay: Infinity + }) for await (const line of lines) { const record = parseJsonObject(line) if (!record) { @@ -179,7 +148,7 @@ async function readCodexSessionIndexTitlesFromDisk( const sessionId = extractString(record.id) const title = normalizeTitleText(extractString(record.thread_name) ?? '') if (sessionId && title) { - retainCodexSessionIndexTitle(titleBySessionId, sessionId, title) + titleBySessionId.set(sessionId, title) } } } catch { @@ -187,78 +156,3 @@ async function readCodexSessionIndexTitlesFromDisk( } return titleBySessionId } - -async function readCodexSessionIndexTitleFromDisk( - indexPath: string, - requestedSessionId: string -): Promise { - let requestedTitle: string | null = null - try { - for await (const line of iterateAiVaultJsonlLines(indexPath)) { - const record = parseJsonObject(line) - if (record && extractString(record.id) === requestedSessionId) { - requestedTitle = - normalizeTitleText(extractString(record.thread_name) ?? '') ?? requestedTitle - } - } - } catch { - // Match the best-effort behavior of the full index read. - } - return requestedTitle -} - -export function retainCodexSessionIndexTitle( - titles: Map, - sessionId: string, - title: string -): void { - boundCodexSessionIndexTitles(titles) - const sessionIdBytes = Buffer.byteLength(sessionId, 'utf8') - const titleBytes = Buffer.byteLength(title, 'utf8') - if ( - sessionIdBytes > CODEX_SESSION_INDEX_SESSION_ID_MAX_UTF8_BYTES || - titleBytes > CODEX_SESSION_INDEX_TITLE_MAX_UTF8_BYTES - ) { - return - } - let retainedBytes = retainedBytesByTitleMap.get(titles) ?? 0 - const existing = titles.get(sessionId) - if (existing !== undefined) { - retainedBytes -= codexTitleEntryBytes(sessionId, existing) - } - titles.delete(sessionId) - const entryBytes = sessionIdBytes + titleBytes + CODEX_SESSION_INDEX_ENTRY_OVERHEAD_BYTES - while ( - titles.size >= CODEX_SESSION_INDEX_RETAINED_TITLE_MAX || - retainedBytes + entryBytes > CODEX_SESSION_INDEX_RETAINED_UTF8_BYTES_MAX - ) { - const oldest = titles.entries().next().value - if (!oldest) { - break - } - titles.delete(oldest[0]) - retainedBytes -= codexTitleEntryBytes(oldest[0], oldest[1]) - } - titles.set(sessionId, title) - retainedBytesByTitleMap.set(titles, retainedBytes + entryBytes) -} - -function boundCodexSessionIndexTitles(titles: Map): void { - if (retainedBytesByTitleMap.has(titles)) { - return - } - const entries = [...titles] - titles.clear() - retainedBytesByTitleMap.set(titles, 0) - for (const [sessionId, title] of entries) { - retainCodexSessionIndexTitle(titles, sessionId, title) - } -} - -function codexTitleEntryBytes(sessionId: string, title: string): number { - return ( - Buffer.byteLength(sessionId, 'utf8') + - Buffer.byteLength(title, 'utf8') + - CODEX_SESSION_INDEX_ENTRY_OVERHEAD_BYTES - ) -} diff --git a/src/main/ai-vault/session-scanner-devin-parser.ts b/src/main/ai-vault/session-scanner-devin-parser.ts index 81a8bdbc860..4ad6fa45f78 100644 --- a/src/main/ai-vault/session-scanner-devin-parser.ts +++ b/src/main/ai-vault/session-scanner-devin-parser.ts @@ -1,6 +1,6 @@ +import { readFile } from 'node:fs/promises' import type { AiVaultSession } from '../../shared/ai-vault-types' import type { ExecutionHostId } from '../../shared/execution-host' -import { withAiVaultWholeJsonFile } from './session-whole-json-reader' import type { FileWithMtime } from './session-scanner-types' import { addPreviewContent, @@ -15,8 +15,7 @@ import { extractContentText, extractString, normalizeTitleText, - numberValue, - parseJsonObject + numberValue } from './session-scanner-values' type ParserSessionOptions = { @@ -28,9 +27,7 @@ export async function parseDevinSessionFile( file: FileWithMtime, platform: NodeJS.Platform = process.platform ): Promise { - return withAiVaultWholeJsonFile(file.path, (content) => - parseDevinSessionContent(file, content, platform) - ) + return parseDevinSessionContent(file, await readFile(file.path, 'utf-8'), platform) } export function parseDevinSessionContent( @@ -39,7 +36,7 @@ export function parseDevinSessionContent( platform: NodeJS.Platform = process.platform, options: ParserSessionOptions = {} ): AiVaultSession | null { - const record = parseJsonObject(content) + const record = asRecord(JSON.parse(content) as unknown) if (!record) { return null } diff --git a/src/main/ai-vault/session-scanner-discovery.test.ts b/src/main/ai-vault/session-scanner-discovery.test.ts deleted file mode 100644 index 0422134ad3b..00000000000 --- a/src/main/ai-vault/session-scanner-discovery.test.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { mkdir, mkdtemp, rm, utimes, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { basename, join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import type { AiVaultScanIssue } from '../../shared/ai-vault-types' -import { discoverFiles } from './session-scanner-discovery' - -let tempRoots: string[] = [] - -afterEach(async () => { - await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true }))) - tempRoots = [] -}) - -describe('AI Vault session discovery retention', () => { - it('retains only the newest requested files while traversing nested directories', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-discovery-')) - tempRoots.push(root) - const nested = join(root, 'nested') - await mkdir(nested) - - for (let index = 0; index < 40; index += 1) { - const path = join(index % 2 === 0 ? root : nested, `session-${index}.jsonl`) - await writeFile(path, '{}\n') - const timestamp = new Date(1_700_000_000_000 + index * 1_000) - await utimes(path, timestamp, timestamp) - } - - const result = await discoverFiles({ - rootDir: root, - limit: 3, - agent: 'codex', - issues: [], - extensions: ['.jsonl'] - }) - - expect(result.files).toHaveLength(3) - expect(result.files.map((file) => basename(file.path))).toEqual([ - 'session-39.jsonl', - 'session-38.jsonl', - 'session-37.jsonl' - ]) - }) - - it('returns no retained paths when the requested limit is zero', async () => { - const result = await discoverFiles({ - rootDir: join('path', 'that', 'does-not-need-to-be-read'), - limit: 0, - agent: 'codex', - issues: [], - extensions: ['.jsonl'] - }) - - expect(result.files).toEqual([]) - }) - - it('accepts the exact aggregate entry capacity without changing results', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-discovery-')) - tempRoots.push(root) - await Promise.all([ - writeFile(join(root, 'session-a.jsonl'), '{}\n'), - writeFile(join(root, 'session-b.jsonl'), '{}\n') - ]) - const issues: AiVaultScanIssue[] = [] - - const result = await discoverFiles({ - rootDir: root, - limit: 10, - agent: 'codex', - issues, - extensions: ['.jsonl'], - limits: { maxEntries: 2 } - }) - - expect(result.files.map((file) => basename(file.path)).sort()).toEqual([ - 'session-a.jsonl', - 'session-b.jsonl' - ]) - expect(issues).toEqual([]) - }) - - it('reports aggregate entry overflow and releases the open directory', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-discovery-')) - tempRoots.push(root) - await Promise.all([ - writeFile(join(root, 'session-a.jsonl'), '{}\n'), - writeFile(join(root, 'session-b.jsonl'), '{}\n'), - writeFile(join(root, 'session-c.jsonl'), '{}\n') - ]) - const issues: AiVaultScanIssue[] = [] - - const result = await discoverFiles({ - rootDir: root, - limit: 10, - agent: 'codex', - issues, - extensions: ['.jsonl'], - limits: { maxEntries: 2 } - }) - - const retainedNames = result.files.map((file) => basename(file.path)) - expect(retainedNames).toHaveLength(2) - expect( - retainedNames.every((name) => - ['session-a.jsonl', 'session-b.jsonl', 'session-c.jsonl'].includes(name) - ) - ).toBe(true) - expect(issues).toEqual([ - expect.objectContaining({ - agent: 'codex', - path: root, - message: expect.stringContaining('safety limit') - }) - ]) - await expect(rm(root, { recursive: true, force: true })).resolves.toBeUndefined() - tempRoots = tempRoots.filter((candidate) => candidate !== root) - }) - - it('reports a depth overflow before opening the over-limit directory', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-discovery-')) - tempRoots.push(root) - const overLimit = join(root, 'level-one', 'level-two') - await mkdir(overLimit, { recursive: true }) - await writeFile(join(overLimit, 'session.jsonl'), '{}\n') - const issues: AiVaultScanIssue[] = [] - - const result = await discoverFiles({ - rootDir: root, - limit: 10, - agent: 'codex', - issues, - extensions: ['.jsonl'], - limits: { maxDepth: 1 } - }) - - expect(result.files).toEqual([]) - expect(issues).toEqual([ - expect.objectContaining({ path: root, message: expect.stringContaining('safety limit') }) - ]) - }) - - it('accepts a file at the exact folder-depth capacity', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-discovery-')) - tempRoots.push(root) - const exactLimit = join(root, 'level-one') - await mkdir(exactLimit) - await writeFile(join(exactLimit, 'session.jsonl'), '{}\n') - const issues: AiVaultScanIssue[] = [] - - const result = await discoverFiles({ - rootDir: root, - limit: 10, - agent: 'codex', - issues, - extensions: ['.jsonl'], - limits: { maxDepth: 1 } - }) - - expect(result.files.map((file) => basename(file.path))).toEqual(['session.jsonl']) - expect(issues).toEqual([]) - }) -}) diff --git a/src/main/ai-vault/session-scanner-discovery.ts b/src/main/ai-vault/session-scanner-discovery.ts index 90622baa974..d93d6f01fea 100644 --- a/src/main/ai-vault/session-scanner-discovery.ts +++ b/src/main/ai-vault/session-scanner-discovery.ts @@ -1,15 +1,8 @@ -import { opendir, stat } from 'node:fs/promises' +import { readdir, stat } from 'node:fs/promises' import { basename, delimiter, extname, join } from 'node:path' import type { AiVaultAgent, AiVaultScanIssue } from '../../shared/ai-vault-types' import type { FileWithMtime, SessionFileDiscovery } from './session-scanner-types' import { errorMessage } from './session-scanner-values' -import { - AiVaultSessionDiscoveryBudget, - AiVaultSessionDiscoveryCapacityError, - type AiVaultSessionDiscoveryLimits -} from './session-discovery-budget' - -const AI_VAULT_DISCOVERY_ISSUE_MAX = 256 export async function discoverFiles(args: { rootDir: string @@ -19,106 +12,33 @@ export async function discoverFiles(args: { extensions: string[] filePredicate?: (path: string) => boolean directoryPredicate?: (name: string, depth: number) => boolean - limits?: Partial }): Promise { + const paths = await walkSessionFiles(args.rootDir, args.agent, args.issues, { + extensions: new Set(args.extensions), + filePredicate: args.filePredicate, + directoryPredicate: args.directoryPredicate + }) const files: FileWithMtime[] = [] - const limit = Math.max(0, Math.floor(args.limit)) - if (limit > 0) { + for (const path of paths) { try { - await visitSessionFiles( - args.rootDir, - { - extensions: new Set(args.extensions), - filePredicate: args.filePredicate, - directoryPredicate: args.directoryPredicate - }, - async (path) => { - try { - const fileStat = await stat(path) - retainNewestFile(files, limit, { - path, - mtimeMs: fileStat.mtimeMs, - modifiedAt: fileStat.mtime.toISOString(), - sizeBytes: fileStat.size, - dev: fileStat.dev, - ino: fileStat.ino, - nlink: fileStat.nlink - }) - } catch (err) { - addDiscoveryIssue(args.issues, args.agent, path, errorMessage(err)) - } - return true - }, - new AiVaultSessionDiscoveryBudget(args.limits) - ) - } catch (error) { - if (error instanceof AiVaultSessionDiscoveryCapacityError) { - addDiscoveryIssue(args.issues, args.agent, args.rootDir, error.message) - } else { - throw error - } + const fileStat = await stat(path) + files.push({ + path, + mtimeMs: fileStat.mtimeMs, + modifiedAt: fileStat.mtime.toISOString(), + sizeBytes: fileStat.size, + dev: fileStat.dev, + ino: fileStat.ino, + nlink: fileStat.nlink + }) + } catch (err) { + args.issues.push({ agent: args.agent, path, message: errorMessage(err) }) } } - return { agent: args.agent, rootDir: args.rootDir, files } -} - -type SessionFileTraversalOptions = { - extensions: Set - filePredicate?: (path: string) => boolean - directoryPredicate?: (name: string, depth: number) => boolean -} - -export async function findFirstSessionFile( - dirPath: string, - options: SessionFileTraversalOptions -): Promise { - let found: string | null = null - try { - await visitSessionFiles( - dirPath, - options, - async (path) => { - found = path - return false - }, - new AiVaultSessionDiscoveryBudget() - ) - } catch (error) { - if (!(error instanceof AiVaultSessionDiscoveryCapacityError)) { - throw error - } - } - return found -} - -function addDiscoveryIssue( - issues: AiVaultScanIssue[], - agent: AiVaultAgent, - path: string, - message: string -): void { - if (issues.length < AI_VAULT_DISCOVERY_ISSUE_MAX) { - issues.push({ agent, path, message }) - } -} - -function retainNewestFile(files: FileWithMtime[], limit: number, file: FileWithMtime): void { - let low = 0 - let high = files.length - while (low < high) { - const middle = Math.floor((low + high) / 2) - if ((files[middle]?.mtimeMs ?? Number.NEGATIVE_INFINITY) >= file.mtimeMs) { - low = middle + 1 - } else { - high = middle - } - } - if (low >= limit) { - return - } - files.splice(low, 0, file) - if (files.length > limit) { - files.pop() + return { + agent: args.agent, + rootDir: args.rootDir, + files: files.sort((left, right) => right.mtimeMs - left.mtimeMs).slice(0, args.limit) } } @@ -127,94 +47,63 @@ export async function discoverOpenClawFiles(args: { limit: number issues: AiVaultScanIssue[] }): Promise { - const files: FileWithMtime[] = [] - const limit = Math.max(0, Math.floor(args.limit)) - for (const rootDir of args.rootDirs) { - const discovery = await discoverFiles({ - rootDir: basename(rootDir) === 'agents' ? rootDir : join(rootDir, 'agents'), - limit, - agent: 'openclaw', - issues: args.issues, - extensions: ['.jsonl'], - filePredicate: (path) => path.split(/[\\/]/).includes('sessions') - }) - for (const file of discovery.files) { - retainNewestFile(files, limit, file) - } - } + const discoveries = await Promise.all( + args.rootDirs.map((rootDir) => + discoverFiles({ + rootDir: basename(rootDir) === 'agents' ? rootDir : join(rootDir, 'agents'), + limit: args.limit, + agent: 'openclaw', + issues: args.issues, + extensions: ['.jsonl'], + filePredicate: (path) => path.split(/[\\/]/).includes('sessions') + }) + ) + ) + const files = discoveries + .flatMap((discovery) => discovery.files) + .sort((left, right) => right.mtimeMs - left.mtimeMs) + .slice(0, args.limit) return { agent: 'openclaw', rootDir: args.rootDirs.join(delimiter), files } } -async function visitSessionFiles( +export async function walkSessionFiles( dirPath: string, - options: SessionFileTraversalOptions, - visitFile: (path: string) => Promise, - budget: AiVaultSessionDiscoveryBudget, + agent: AiVaultAgent, + issues: AiVaultScanIssue[], + options: { + extensions: Set + filePredicate?: (path: string) => boolean + // Return false to skip descending into a directory; depth 0 is a child of + // rootDir, so pruned subtrees are never stat'd or parsed. + directoryPredicate?: (name: string, depth: number) => boolean + }, depth = 0 -): Promise { - budget.enterDirectory(depth) - let directory +): Promise { + let entries try { - directory = await opendir(dirPath) + entries = await readdir(dirPath, { withFileTypes: true }) } catch { - return true + return [] } - const tasks: { kind: 'directory' | 'file'; path: string }[] = [] - let capacityError: AiVaultSessionDiscoveryCapacityError | null = null - try { - while (true) { - let entry - try { - entry = await directory.read() - } catch { - // A disappearing directory has no safe remainder to visit. - break - } - if (!entry) { - break - } - const fullPath = join(dirPath, entry.name) - try { - budget.visitEntry(fullPath) - } catch (error) { - if (error instanceof AiVaultSessionDiscoveryCapacityError) { - capacityError = error - break - } - throw error - } - if (entry.isDirectory()) { - // Skip whole subtrees an agent never wants (e.g. subagent transcripts), - // avoiding the directory-read cost of descending into them. - if (options.directoryPredicate?.(entry.name, depth) ?? true) { - tasks.push({ kind: 'directory', path: fullPath }) - } - continue - } - if ( - entry.isFile() && - options.extensions.has(extname(entry.name).toLowerCase()) && - (options.filePredicate?.(fullPath) ?? true) - ) { - tasks.push({ kind: 'file', path: fullPath }) + const files: string[] = [] + for (const entry of entries) { + const fullPath = join(dirPath, entry.name) + if (entry.isDirectory()) { + // Skip whole subtrees an agent never wants (e.g. subagent transcripts), + // avoiding the readdir cost of descending into them. + if (options.directoryPredicate?.(entry.name, depth) ?? true) { + files.push(...(await walkSessionFiles(fullPath, agent, issues, options, depth + 1))) } + continue } - } finally { - await directory.close().catch(() => undefined) - } - - for (const task of tasks) { - const shouldContinue = - task.kind === 'directory' - ? await visitSessionFiles(task.path, options, visitFile, budget, depth + 1) - : await visitFile(task.path) - if (!shouldContinue) { - return false + if ( + entry.isFile() && + options.extensions.has(extname(entry.name).toLowerCase()) && + (options.filePredicate?.(fullPath) ?? true) + ) { + files.push(fullPath) } } - if (capacityError) { - throw capacityError - } - return true + return files } diff --git a/src/main/ai-vault/session-scanner-droid-parser.ts b/src/main/ai-vault/session-scanner-droid-parser.ts index bd1f83f954c..2abaa7b8193 100644 --- a/src/main/ai-vault/session-scanner-droid-parser.ts +++ b/src/main/ai-vault/session-scanner-droid-parser.ts @@ -1,6 +1,7 @@ +import { createReadStream } from 'node:fs' +import { createInterface } from 'node:readline' import type { AiVaultSession } from '../../shared/ai-vault-types' import type { ExecutionHostId } from '../../shared/execution-host' -import { iterateAiVaultJsonlLines } from './session-jsonl-line-reader' import type { FileWithMtime, ResumableSessionParseState, @@ -32,7 +33,10 @@ export async function parseDroidSessionFile( file: FileWithMtime, platform: NodeJS.Platform = process.platform ): Promise { - const lines = iterateAiVaultJsonlLines(file.path) + const lines = createInterface({ + input: createReadStream(file.path, { encoding: 'utf-8' }), + crlfDelay: Infinity + }) return parseDroidSessionLines({ file, lines, platform }) } diff --git a/src/main/ai-vault/session-scanner-gemini-parsers.ts b/src/main/ai-vault/session-scanner-gemini-parsers.ts index 7b398d81783..6f0b9fdc7c1 100644 --- a/src/main/ai-vault/session-scanner-gemini-parsers.ts +++ b/src/main/ai-vault/session-scanner-gemini-parsers.ts @@ -1,6 +1,7 @@ +import { createReadStream } from 'node:fs' +import { readFile } from 'node:fs/promises' +import { createInterface } from 'node:readline' import type { AiVaultSession } from '../../shared/ai-vault-types' -import { iterateAiVaultJsonlLines } from './session-jsonl-line-reader' -import { withAiVaultWholeJsonFile } from './session-whole-json-reader' import type { FileWithMtime, ResumableParseFinalizeOptions, @@ -32,9 +33,7 @@ export async function parseGeminiSessionFile( return parseGeminiJsonlSessionFile(file, platform) } - return withAiVaultWholeJsonFile(file.path, (content) => - parseGeminiJsonSessionContent(file, content, platform) - ) + return parseGeminiJsonSessionContent(file, await readFile(file.path, 'utf-8'), platform) } export async function parseGeminiSessionContent( @@ -60,7 +59,7 @@ function parseGeminiJsonSessionContent( platform: NodeJS.Platform, options: ResumableParseFinalizeOptions = {} ): AiVaultSession | null { - const record = parseJsonObject(content) + const record = asRecord(JSON.parse(content) as unknown) if (!record) { return null } @@ -81,7 +80,10 @@ export async function parseGeminiJsonlSessionFile( file: FileWithMtime, platform: NodeJS.Platform ): Promise { - const lines = iterateAiVaultJsonlLines(file.path) + const lines = createInterface({ + input: createReadStream(file.path, { encoding: 'utf-8' }), + crlfDelay: Infinity + }) return parseGeminiJsonlSessionLines({ file, lines, platform }) } diff --git a/src/main/ai-vault/session-scanner-graph-parsers.ts b/src/main/ai-vault/session-scanner-graph-parsers.ts index d128cb89904..04601eacde5 100644 --- a/src/main/ai-vault/session-scanner-graph-parsers.ts +++ b/src/main/ai-vault/session-scanner-graph-parsers.ts @@ -1,8 +1,9 @@ +import { createReadStream } from 'node:fs' +import { readFile } from 'node:fs/promises' import { basename, dirname, join } from 'node:path' +import { createInterface } from 'node:readline' import type { AiVaultSession } from '../../shared/ai-vault-types' import type { ExecutionHostId } from '../../shared/execution-host' -import { iterateAiVaultJsonlLines } from './session-jsonl-line-reader' -import { withAiVaultWholeJsonFile } from './session-whole-json-reader' import type { FileWithMtime, ResumableSessionParseState, @@ -25,6 +26,7 @@ import { extractString, firstString, parseJsonObject, + readJsonObjectIfExists, tokenTotal } from './session-scanner-values' @@ -37,45 +39,39 @@ export async function parseRovoSessionFile( file: FileWithMtime, platform: NodeJS.Platform = process.platform ): Promise { - const accumulator = await withAiVaultWholeJsonFile(file.path, (content) => { - const metadata = parseJsonObject(content) - if (!metadata) { - return null - } - const next = createAccumulator({ - agent: 'rovo', - file, - sessionId: basename(dirname(file.path)) - }) - next.title = firstString(metadata, ['title', 'name', 'summary']) - next.cwd = firstString(metadata, [ - 'workspace_path', - 'workspacePath', - 'workspace', - 'cwd', - 'working_directory', - 'workingDirectory', - 'project_path', - 'projectPath' - ]) - updateTimeline(next, extractString(metadata.created_at) ?? extractString(metadata.createdAt)) - updateTimeline(next, extractString(metadata.updated_at) ?? extractString(metadata.updatedAt)) - return next - }) - if (!accumulator) { + const metadata = asRecord(JSON.parse(await readFile(file.path, 'utf-8')) as unknown) + if (!metadata) { return null } + const accumulator = createAccumulator({ + agent: 'rovo', + file, + sessionId: basename(dirname(file.path)) + }) + accumulator.title = firstString(metadata, ['title', 'name', 'summary']) + accumulator.cwd = firstString(metadata, [ + 'workspace_path', + 'workspacePath', + 'workspace', + 'cwd', + 'working_directory', + 'workingDirectory', + 'project_path', + 'projectPath' + ]) + updateTimeline( + accumulator, + extractString(metadata.created_at) ?? extractString(metadata.createdAt) + ) + updateTimeline( + accumulator, + extractString(metadata.updated_at) ?? extractString(metadata.updatedAt) + ) const contextPath = join(dirname(file.path), 'session_context.json') - try { - await withAiVaultWholeJsonFile(contextPath, (content) => { - const context = parseJsonObject(content) - if (context) { - consumeRovoSessionContext(accumulator, context) - } - }) - } catch { - // Optional context may not exist yet; metadata still yields a useful row. + const context = await readJsonObjectIfExists(contextPath) + if (context) { + consumeRovoSessionContext(accumulator, context) } return finalizeSession(accumulator, platform) @@ -176,7 +172,10 @@ export async function parseMessageGraphSessionFile( file: FileWithMtime, platform: NodeJS.Platform = process.platform ): Promise { - const lines = iterateAiVaultJsonlLines(file.path) + const lines = createInterface({ + input: createReadStream(file.path, { encoding: 'utf-8' }), + crlfDelay: Infinity + }) return parseMessageGraphSessionLines({ agent, file, lines, platform }) } diff --git a/src/main/ai-vault/session-scanner-grok-parser.ts b/src/main/ai-vault/session-scanner-grok-parser.ts index 84d23f7a115..13620007447 100644 --- a/src/main/ai-vault/session-scanner-grok-parser.ts +++ b/src/main/ai-vault/session-scanner-grok-parser.ts @@ -1,7 +1,8 @@ +import { createReadStream } from 'node:fs' +import { readFile } from 'node:fs/promises' import { dirname, join } from 'node:path' +import { createInterface } from 'node:readline' import type { AiVaultSession } from '../../shared/ai-vault-types' -import { iterateAiVaultJsonlLines } from './session-jsonl-line-reader' -import { withAiVaultWholeJsonFile } from './session-whole-json-reader' import type { FileWithMtime, SessionAccumulator } from './session-scanner-types' import { addPreviewMessage, @@ -26,29 +27,24 @@ export async function parseGrokSessionFile( file: FileWithMtime, platform: NodeJS.Platform = process.platform ): Promise { - const accumulator = await withAiVaultWholeJsonFile(file.path, (content) => { - const record = parseJsonObject(content) - if (!record) { - return null - } - const info = asRecord(record.info) - const sessionId = extractString(info?.id) ?? sessionIdFromFileName(dirname(file.path)) - const next = createAccumulator({ agent: 'grok', file, sessionId }) - next.cwd = extractString(info?.cwd) - next.title = - normalizeTitleText(extractString(record.generated_title) ?? '') ?? - normalizeTitleText(extractString(record.session_summary) ?? '') - next.model = extractString(record.current_model_id) - next.branch = extractString(record.head_branch) - next.messageCount = numberValue(record.num_chat_messages) || numberValue(record.num_messages) - updateTimeline(next, extractString(record.created_at)) - updateTimeline(next, extractString(record.updated_at)) - updateTimeline(next, extractString(record.last_active_at)) - return next - }) - if (!accumulator) { + const record = asRecord(JSON.parse(await readFile(file.path, 'utf-8')) as unknown) + if (!record) { return null } + const info = asRecord(record.info) + const sessionId = extractString(info?.id) ?? sessionIdFromFileName(dirname(file.path)) + const accumulator = createAccumulator({ agent: 'grok', file, sessionId }) + accumulator.cwd = extractString(info?.cwd) + accumulator.title = + normalizeTitleText(extractString(record.generated_title) ?? '') ?? + normalizeTitleText(extractString(record.session_summary) ?? '') + accumulator.model = extractString(record.current_model_id) + accumulator.branch = extractString(record.head_branch) + accumulator.messageCount = + numberValue(record.num_chat_messages) || numberValue(record.num_messages) + updateTimeline(accumulator, extractString(record.created_at)) + updateTimeline(accumulator, extractString(record.updated_at)) + updateTimeline(accumulator, extractString(record.last_active_at)) await consumeGrokChatHistory(accumulator, dirname(file.path)) return finalizeSession(accumulator, platform) } @@ -58,7 +54,10 @@ async function consumeGrokChatHistory( sessionDir: string ): Promise { try { - const lines = iterateAiVaultJsonlLines(join(sessionDir, 'chat_history.jsonl')) + const lines = createInterface({ + input: createReadStream(join(sessionDir, 'chat_history.jsonl'), { encoding: 'utf-8' }), + crlfDelay: Infinity + }) for await (const line of lines) { const record = parseJsonObject(line) diff --git a/src/main/ai-vault/session-scanner-kimi-index-cache.test.ts b/src/main/ai-vault/session-scanner-kimi-index-cache.test.ts index 6ac75daa429..549c1a3c170 100644 --- a/src/main/ai-vault/session-scanner-kimi-index-cache.test.ts +++ b/src/main/ai-vault/session-scanner-kimi-index-cache.test.ts @@ -4,18 +4,13 @@ import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { KimiSessionIndexCache, - KIMI_WORK_DIR_CACHE_KEY_MAX_UTF8_BYTES, KIMI_WORK_DIR_CACHE_MAX_INDEX_PATHS, - KIMI_WORK_DIR_MAP_MAX_RETAINED_UTF8_BYTES, - KIMI_WORK_DIR_SESSION_ID_MAX_UTF8_BYTES, KIMI_WORK_DIR_CACHE_TTL_MS, type KimiSessionIndexIdentity } from './session-scanner-kimi-index-cache' import { clearKimiSessionIndexCache, hasKimiSessionIndexCacheEntryForTests, - KIMI_WORK_DIR_CACHE_MAX_SESSIONS_PER_INDEX, - readKimiWorkDirForSessionId, readKimiWorkDirBySessionId } from './session-scanner-kimi-paths' @@ -126,42 +121,9 @@ describe('KimiSessionIndexCache', () => { expect(cache.has('index')).toBe(true) cache.clear() }) - - it('bounds index keys and retained map bytes without changing returned values', async () => { - const cache = new KimiSessionIndexCache() - const oversizedPath = 'x'.repeat(KIMI_WORK_DIR_CACHE_KEY_MAX_UTF8_BYTES + 1) - const oversizedId = 'x'.repeat(KIMI_WORK_DIR_SESSION_ID_MAX_UTF8_BYTES + 1) - const value = new Map([ - [oversizedId, '/oversized'], - ['session', '/repo'] - ]) - - await expect( - cache.get(oversizedPath, IDENTITY, cache.beginRead(), async () => value) - ).resolves.toBe(value) - expect(value).toEqual(new Map([['session', '/repo']])) - expect(cache.has(oversizedPath)).toBe(false) - expect(KIMI_WORK_DIR_MAP_MAX_RETAINED_UTF8_BYTES).toBe(512 * 1024) - }) }) describe('Kimi session index reader cache', () => { - it('bounds retained work dirs without dropping an older requested session', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-kimi-index-bounds-')) - tempDirs.push(root) - const indexPath = join(root, 'session_index.jsonl') - const lines = Array.from( - { length: KIMI_WORK_DIR_CACHE_MAX_SESSIONS_PER_INDEX + 1 }, - (_, index) => JSON.stringify({ sessionId: `session-${index}`, workDir: `/repo/${index}` }) - ) - await writeFile(indexPath, `${lines.join('\n')}\n`) - - await expect(readKimiWorkDirForSessionId(indexPath, 'session-0')).resolves.toBe('/repo/0') - expect((await readKimiWorkDirBySessionId(indexPath)).size).toBe( - KIMI_WORK_DIR_CACHE_MAX_SESSIONS_PER_INDEX - ) - }) - it('releases a retained map when its index file disappears', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-kimi-index-delete-')) tempDirs.push(root) diff --git a/src/main/ai-vault/session-scanner-kimi-index-cache.ts b/src/main/ai-vault/session-scanner-kimi-index-cache.ts index 6f7bc951753..fb5f6926eb3 100644 --- a/src/main/ai-vault/session-scanner-kimi-index-cache.ts +++ b/src/main/ai-vault/session-scanner-kimi-index-cache.ts @@ -13,12 +13,6 @@ type KimiSessionIndexCacheEntry = { } export const KIMI_WORK_DIR_CACHE_MAX_INDEX_PATHS = 64 -export const KIMI_WORK_DIR_CACHE_MAX_SESSIONS_PER_INDEX = 2_048 -export const KIMI_WORK_DIR_CACHE_KEY_MAX_UTF8_BYTES = 32 * 1024 -export const KIMI_WORK_DIR_SESSION_ID_MAX_UTF8_BYTES = 64 * 1024 -export const KIMI_WORK_DIR_VALUE_MAX_UTF8_BYTES = 128 * 1024 -export const KIMI_WORK_DIR_MAP_MAX_RETAINED_UTF8_BYTES = 512 * 1024 -const KIMI_WORK_DIR_ENTRY_OVERHEAD_BYTES = 64 // Active Vault scans refresh this window; closing the surface releases parsed // index maps soon without making a live Kimi session reread on every scan. export const KIMI_WORK_DIR_CACHE_TTL_MS = 5 * 60_000 @@ -58,13 +52,8 @@ export class KimiSessionIndexCache { generation: number, load: () => Promise> ): Promise> { - const loadBounded = (): Promise> => - load().then((value) => boundKimiWorkDirMap(value)) - if ( - generation < this.minimumCacheGeneration || - Buffer.byteLength(indexPath, 'utf8') > KIMI_WORK_DIR_CACHE_KEY_MAX_UTF8_BYTES - ) { - return loadBounded() + if (generation < this.minimumCacheGeneration) { + return load() } const cached = this.entries.get(indexPath) const now = Date.now() @@ -75,7 +64,7 @@ export class KimiSessionIndexCache { if (cached && cached.generation > generation) { // Why: a slower, older stat must not replace a newer file generation // that another concurrent scan already cached for the same path. - return loadBounded() + return load() } const entry: KimiSessionIndexCacheEntry = { @@ -83,7 +72,7 @@ export class KimiSessionIndexCache { generation, identity, timer: null, - value: loadBounded() + value: load() } this.remember(indexPath, entry, now) return entry.value @@ -131,65 +120,6 @@ export class KimiSessionIndexCache { } } -const retainedBytesByWorkDirMap = new WeakMap, number>() - -export function retainKimiWorkDir( - map: Map, - sessionId: string, - workDir: string -): void { - boundKimiWorkDirMap(map) - const sessionIdBytes = Buffer.byteLength(sessionId, 'utf8') - const workDirBytes = Buffer.byteLength(workDir, 'utf8') - if ( - sessionIdBytes > KIMI_WORK_DIR_SESSION_ID_MAX_UTF8_BYTES || - workDirBytes > KIMI_WORK_DIR_VALUE_MAX_UTF8_BYTES - ) { - return - } - let retainedBytes = retainedBytesByWorkDirMap.get(map) ?? 0 - const existing = map.get(sessionId) - if (existing !== undefined) { - retainedBytes -= kimiWorkDirEntryBytes(sessionId, existing) - } - map.delete(sessionId) - const entryBytes = sessionIdBytes + workDirBytes + KIMI_WORK_DIR_ENTRY_OVERHEAD_BYTES - while ( - map.size >= KIMI_WORK_DIR_CACHE_MAX_SESSIONS_PER_INDEX || - retainedBytes + entryBytes > KIMI_WORK_DIR_MAP_MAX_RETAINED_UTF8_BYTES - ) { - const oldest = map.entries().next().value - if (!oldest) { - break - } - map.delete(oldest[0]) - retainedBytes -= kimiWorkDirEntryBytes(oldest[0], oldest[1]) - } - map.set(sessionId, workDir) - retainedBytesByWorkDirMap.set(map, retainedBytes + entryBytes) -} - -function boundKimiWorkDirMap(map: Map): Map { - if (retainedBytesByWorkDirMap.has(map)) { - return map - } - const entries = [...map] - map.clear() - retainedBytesByWorkDirMap.set(map, 0) - for (const [sessionId, workDir] of entries) { - retainKimiWorkDir(map, sessionId, workDir) - } - return map -} - -function kimiWorkDirEntryBytes(sessionId: string, workDir: string): number { - return ( - Buffer.byteLength(sessionId, 'utf8') + - Buffer.byteLength(workDir, 'utf8') + - KIMI_WORK_DIR_ENTRY_OVERHEAD_BYTES - ) -} - function identitiesMatch(left: KimiSessionIndexIdentity, right: KimiSessionIndexIdentity): boolean { return ( left.changeTimeMs === right.changeTimeMs && diff --git a/src/main/ai-vault/session-scanner-kimi-parser.test.ts b/src/main/ai-vault/session-scanner-kimi-parser.test.ts index a093e5d0ae5..67748ae727f 100644 --- a/src/main/ai-vault/session-scanner-kimi-parser.test.ts +++ b/src/main/ai-vault/session-scanner-kimi-parser.test.ts @@ -209,25 +209,6 @@ describe('parseKimiSessionFile', () => { expect(assistantPreview.text.endsWith('...')).toBe(true) }) - it('bounds many streamed assistant chunks before the preview is flushed', async () => { - const wireLines = Array.from({ length: 600 }, () => ({ - type: 'context.append_loop_event', - event: { type: 'content.part', part: { type: 'text', text: 'x'.repeat(1024) } } - })) - wireLines.push({ - type: 'context.append_loop_event', - event: { type: 'step.end', part: { type: 'text', text: '' } } - }) - const { file } = await writeKimiSession({ wireLines }) - - const session = await parseKimiSessionFile(file, 'darwin') - - expect(session?.messageCount).toBe(1) - expect(session?.previewMessages).toEqual([ - { role: 'assistant', text: `${'x'.repeat(217)}...`, timestamp: null } - ]) - }) - it('falls back to lastPrompt when the title is empty', async () => { const { file } = await writeKimiSession({ state: { diff --git a/src/main/ai-vault/session-scanner-kimi-parser.ts b/src/main/ai-vault/session-scanner-kimi-parser.ts index 7767d39f854..61341098e0d 100644 --- a/src/main/ai-vault/session-scanner-kimi-parser.ts +++ b/src/main/ai-vault/session-scanner-kimi-parser.ts @@ -1,6 +1,7 @@ +import { createReadStream } from 'node:fs' +import { readFile } from 'node:fs/promises' +import { createInterface } from 'node:readline' import type { AiVaultSession } from '../../shared/ai-vault-types' -import { iterateAiVaultJsonlLines } from './session-jsonl-line-reader' -import { withAiVaultWholeJsonFile } from './session-whole-json-reader' import { addPreviewContent, addPreviewMessage, @@ -12,7 +13,7 @@ import { kimiPrimaryAgentWirePath, kimiSessionIdFromStatePath, kimiSessionIndexPathFromStatePath, - readKimiWorkDirForSessionId + readKimiWorkDirBySessionId } from './session-scanner-kimi-paths' import type { FileWithMtime, SessionAccumulator } from './session-scanner-types' import { @@ -25,15 +26,6 @@ import { parseJsonObject } from './session-scanner-values' -// The rendered preview is 220 chars; this also covers hidden-context close-tag scanning. -const KIMI_ASSISTANT_PREVIEW_SOURCE_MAX_CHARS = 512 * 1024 -const KIMI_ASSISTANT_PREVIEW_CHUNK_MAX = 256 - -type KimiAssistantPreviewBuffer = { - chunks: string[] - charCount: number -} - // Parses a Kimi Code `state.json` plus its sibling `agents//wire.jsonl` // transcript into an AI Vault session. Metadata (title, timestamps, last prompt) // comes from state.json; the work directory comes from the top-level @@ -42,30 +34,13 @@ export async function parseKimiSessionFile( file: FileWithMtime, platform: NodeJS.Platform = process.platform ): Promise { - let metadata: { - title: string | null - fallbackTitle: string | null - createdAt: string | null - updatedAt: string | null - wirePath: string - } | null + let stateRecord: Record | null try { - metadata = await withAiVaultWholeJsonFile(file.path, (content) => { - const stateRecord = parseJsonObject(content) - return stateRecord - ? { - title: normalizeTitleText(extractString(stateRecord.title) ?? ''), - fallbackTitle: normalizeTitleText(extractString(stateRecord.lastPrompt) ?? ''), - createdAt: extractString(stateRecord.createdAt), - updatedAt: extractString(stateRecord.updatedAt), - wirePath: kimiPrimaryAgentWirePath(file.path, stateRecord) - } - : null - }) + stateRecord = asRecord(JSON.parse(await readFile(file.path, 'utf-8')) as unknown) } catch { return null } - if (!metadata) { + if (!stateRecord) { return null } @@ -75,17 +50,17 @@ export async function parseKimiSessionFile( // Why: Kimi sessions are work-dir-scoped — the resume command must `cd` into // the original directory or the CLI rejects it. That path lives only in the // top-level session_index.jsonl, keyed by the (prefixed) session id. - accumulator.cwd = await readKimiWorkDirForSessionId( - kimiSessionIndexPathFromStatePath(file.path), - sessionId + const workDirBySessionId = await readKimiWorkDirBySessionId( + kimiSessionIndexPathFromStatePath(file.path) ) + accumulator.cwd = workDirBySessionId.get(sessionId) ?? null - accumulator.title = metadata.title - accumulator.fallbackTitle = metadata.fallbackTitle - updateTimeline(accumulator, metadata.createdAt) - updateTimeline(accumulator, metadata.updatedAt) + accumulator.title = normalizeTitleText(extractString(stateRecord.title) ?? '') + accumulator.fallbackTitle = normalizeTitleText(extractString(stateRecord.lastPrompt) ?? '') + updateTimeline(accumulator, extractString(stateRecord.createdAt)) + updateTimeline(accumulator, extractString(stateRecord.updatedAt)) - await consumeKimiWireTranscript(accumulator, metadata.wirePath) + await consumeKimiWireTranscript(accumulator, kimiPrimaryAgentWirePath(file.path, stateRecord)) return finalizeSession(accumulator, platform) } @@ -94,15 +69,14 @@ async function consumeKimiWireTranscript( accumulator: SessionAccumulator, wirePath: string ): Promise { - const pendingAssistantText: KimiAssistantPreviewBuffer = { chunks: [], charCount: 0 } + let pendingAssistantText: string[] = [] const flushAssistant = (): void => { // Why: previews use the 220-char limit (normalizePreviewText), not the // 96-char title limit — assistant replies are shown in full preview width // like every other agent's. Join raw chunks first so inter-chunk spacing // survives; normalizePreviewText then collapses whitespace and caps length. - const text = normalizePreviewText(pendingAssistantText.chunks.join('')) - pendingAssistantText.chunks = [] - pendingAssistantText.charCount = 0 + const text = normalizePreviewText(pendingAssistantText.join('')) + pendingAssistantText = [] if (text) { accumulator.messageCount++ addPreviewMessage(accumulator, { role: 'assistant', text }) @@ -110,7 +84,10 @@ async function consumeKimiWireTranscript( } try { - const lines = iterateAiVaultJsonlLines(wirePath) + const lines = createInterface({ + input: createReadStream(wirePath, { encoding: 'utf-8' }), + crlfDelay: Infinity + }) for await (const line of lines) { const record = parseJsonObject(line) if (!record) { @@ -157,7 +134,7 @@ function consumeKimiUserMessage(accumulator: SessionAccumulator, value: unknown) function consumeKimiLoopEvent( value: unknown, - pendingAssistantText: KimiAssistantPreviewBuffer, + pendingAssistantText: string[], flushAssistant: () => void ): void { const event = asRecord(value) @@ -169,7 +146,7 @@ function consumeKimiLoopEvent( // Push the raw chunk text; flushAssistant normalizes the joined result so // multi-chunk spacing is not lost to per-chunk trimming. if (part?.type === 'text' && typeof part.text === 'string') { - appendKimiAssistantPreviewText(pendingAssistantText, part.text) + pendingAssistantText.push(part.text) } return } @@ -180,19 +157,6 @@ function consumeKimiLoopEvent( } } -function appendKimiAssistantPreviewText(buffer: KimiAssistantPreviewBuffer, text: string): void { - const remaining = KIMI_ASSISTANT_PREVIEW_SOURCE_MAX_CHARS - buffer.charCount - if (remaining <= 0 || text.length === 0) { - return - } - const retained = text.length <= remaining ? text : text.slice(0, remaining) - buffer.chunks.push(retained) - buffer.charCount += retained.length - if (buffer.chunks.length >= KIMI_ASSISTANT_PREVIEW_CHUNK_MAX) { - buffer.chunks = [buffer.chunks.join('')] - } -} - // Kimi reports per-turn usage as {inputOther, output, inputCacheRead, // inputCacheCreation}; sum all four for a session total. Skip any future // cumulative ("session"-scoped) record so turn deltas are not double-counted. diff --git a/src/main/ai-vault/session-scanner-kimi-paths.ts b/src/main/ai-vault/session-scanner-kimi-paths.ts index 2c18b51ecf8..7ed457f6c07 100644 --- a/src/main/ai-vault/session-scanner-kimi-paths.ts +++ b/src/main/ai-vault/session-scanner-kimi-paths.ts @@ -1,21 +1,16 @@ import { stat } from 'node:fs/promises' +import { createReadStream } from 'node:fs' import { homedir } from 'node:os' import { basename, dirname, join } from 'node:path' -import { asRecord, extractString, parseJsonObject } from './session-scanner-values' -import { iterateAiVaultJsonlLines } from './session-jsonl-line-reader' +import { createInterface } from 'node:readline' +import { asRecord, extractString } from './session-scanner-values' import { KimiSessionIndexCache, KIMI_WORK_DIR_CACHE_MAX_INDEX_PATHS, - KIMI_WORK_DIR_CACHE_MAX_SESSIONS_PER_INDEX, - KIMI_WORK_DIR_CACHE_TTL_MS, - retainKimiWorkDir + KIMI_WORK_DIR_CACHE_TTL_MS } from './session-scanner-kimi-index-cache' -export { - KIMI_WORK_DIR_CACHE_MAX_INDEX_PATHS, - KIMI_WORK_DIR_CACHE_MAX_SESSIONS_PER_INDEX, - KIMI_WORK_DIR_CACHE_TTL_MS -} +export { KIMI_WORK_DIR_CACHE_MAX_INDEX_PATHS, KIMI_WORK_DIR_CACHE_TTL_MS } // Why: Kimi Code stores sessions under /sessions/, mirroring the // CLI's own `KIMI_CODE_HOME ?? ~/.kimi-code` resolution (see kimi-fetcher.ts). @@ -104,34 +99,31 @@ export async function readKimiWorkDirBySessionId(indexPath: string): Promise { - const workDirs = await readKimiWorkDirBySessionId(indexPath) - const cached = workDirs.get(sessionId) - if (cached) { - return cached - } - const workDir = await readKimiWorkDirFromIndex(indexPath, sessionId) - if (workDir) { - retainKimiWorkDir(workDirs, sessionId, workDir) - } - return workDir -} - async function parseKimiSessionIndex(indexPath: string): Promise> { const map = new Map() // Why: never reject. This promise is memoized and shared by every session // under one Kimi home; a mid-read failure (file deleted after stat, EACCES) // must degrade to whatever was parsed so the other sessions still list. try { - const lines = iterateAiVaultJsonlLines(indexPath) + const lines = createInterface({ + input: createReadStream(indexPath, { encoding: 'utf-8' }), + crlfDelay: Infinity + }) for await (const line of lines) { - const entry = parseKimiSessionIndexEntry(line) - if (entry) { + if (!line.trim()) { + continue + } + let record: Record | null + try { + record = asRecord(JSON.parse(line) as unknown) + } catch { + continue + } + const sessionId = extractString(record?.sessionId) + const workDir = extractString(record?.workDir) + if (sessionId && workDir) { // Later lines win so a resumed session reflects its most recent workDir. - retainKimiWorkDir(map, entry.sessionId, entry.workDir) + map.set(sessionId, workDir) } } } catch { @@ -139,35 +131,3 @@ async function parseKimiSessionIndex(indexPath: string): Promise { - let requestedWorkDir: string | null = null - try { - for await (const line of iterateAiVaultJsonlLines(indexPath)) { - const entry = parseKimiSessionIndexEntry(line) - if (entry?.sessionId === requestedSessionId) { - requestedWorkDir = entry.workDir - } - } - } catch { - // Match the partial, best-effort full-index read. - } - return requestedWorkDir -} - -function parseKimiSessionIndexEntry(line: string): { sessionId: string; workDir: string } | null { - if (!line.trim()) { - return null - } - try { - const record = parseJsonObject(line) - const sessionId = extractString(record?.sessionId) - const workDir = extractString(record?.workDir) - return sessionId && workDir ? { sessionId, workDir } : null - } catch { - return null - } -} diff --git a/src/main/ai-vault/session-scanner-opencode-sources.ts b/src/main/ai-vault/session-scanner-opencode-sources.ts index b8f14a63af7..e4934aa8fda 100644 --- a/src/main/ai-vault/session-scanner-opencode-sources.ts +++ b/src/main/ai-vault/session-scanner-opencode-sources.ts @@ -1,10 +1,7 @@ +import { readdir } from 'node:fs/promises' import { homedir } from 'node:os' import { dirname, join } from 'node:path' import type { AiVaultScanIssue } from '../../shared/ai-vault-types' -import { - listOpenCodeDatabaseFiles, - OPENCODE_DATABASE_FILE_LIMIT -} from '../opencode/opencode-database-files' import { listOpenCodeDatabases } from '../opencode-usage/scanner' import { discoverOpenCodeSessions } from './session-scanner-opencode-sqlite-discovery' import type { AiVaultScanOptions, SessionFileDiscovery } from './session-scanner-types' @@ -13,6 +10,7 @@ const OPENCODE_STORAGE_DIR = join( process.env.OPENCODE_CONFIG_DIR?.trim() || join(homedir(), '.local', 'share', 'opencode'), 'storage' ) + export function opencodeDiscoveries( options: AiVaultScanOptions, wslHomeDirs: readonly string[], @@ -23,7 +21,7 @@ export function opencodeDiscoveries( return storageDirs.map(async (storageDir, index) => discoverOpenCodeSessions({ storageDir, - dbPaths: await opencodeDbPathsForSource(options, wslHomeDirs, storageDir, index, issues), + dbPaths: await opencodeDbPathsForSource(options, wslHomeDirs, storageDir, index), limitPerAgent: limit, issues }) @@ -44,36 +42,32 @@ async function opencodeDbPathsForSource( options: AiVaultScanOptions, wslHomeDirs: readonly string[], storageDir: string, - sourceIndex: number, - issues: AiVaultScanIssue[] + sourceIndex: number ): Promise { if (options.opencodeDbPaths) { return sourceIndex === 0 ? options.opencodeDbPaths : [] } // Why: custom OpenCode storage roots still keep SQLite DBs in the parent data dir. if (sourceIndex === 0 && options.opencodeStorageDir) { - return listOpenCodeDatabasesInDirectory(dirname(storageDir), issues) + return listOpenCodeDatabasesInDirectory(dirname(storageDir)) } if (sourceIndex === 0) { return listOpenCodeDatabases() } const wslHomeDir = wslHomeDirs[sourceIndex - 1] return wslHomeDir - ? listOpenCodeDatabasesInDirectory(join(wslHomeDir, '.local', 'share', 'opencode'), issues) + ? listOpenCodeDatabasesInDirectory(join(wslHomeDir, '.local', 'share', 'opencode')) : [] } -async function listOpenCodeDatabasesInDirectory( - dataDir: string, - issues: AiVaultScanIssue[] -): Promise { - const result = await listOpenCodeDatabaseFiles(dataDir) - if (result.truncated) { - issues.push({ - agent: 'opencode', - path: dataDir, - message: `OpenCode database discovery stopped after ${OPENCODE_DATABASE_FILE_LIMIT} files.` - }) +async function listOpenCodeDatabasesInDirectory(dataDir: string): Promise { + try { + const entries = await readdir(dataDir, { withFileTypes: true }) + return entries + .filter((entry) => entry.isFile() && /^opencode(?:-[A-Za-z0-9_.-]+)?\.db$/.test(entry.name)) + .map((entry) => join(dataDir, entry.name)) + .sort() + } catch { + return [] } - return result.paths } diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-bounds.test.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-bounds.test.ts index dd075baa36e..7f50a220247 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-bounds.test.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-bounds.test.ts @@ -1,18 +1,12 @@ import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it } from 'vitest' import type { AiVaultScanIssue } from '../../shared/ai-vault-types' import Database from '../sqlite/sync-database' import { buildOpenCodeSqliteCandidatePath } from './session-scanner-opencode-sqlite-paths' import { listOpenCodeSqliteSessions } from './session-scanner-opencode-sqlite-list' import { parseOpenCodeSqliteSession } from './session-scanner-opencode-sqlite' -import { - OPENCODE_SQLITE_MESSAGE_JSON_MAX_BYTES, - OPENCODE_SQLITE_MODEL_JSON_MAX_BYTES, - OPENCODE_SQLITE_PART_JSON_MAX_BYTES, - OPENCODE_SQLITE_SESSION_ID_MAX_BYTES -} from './session-scanner-opencode-sqlite-limits' // Part B (#8864) bounds: discovery reads only the newest session identities and // recency fields, while the preview join reads parts of only the newest 100 @@ -21,7 +15,6 @@ import { let tempDirs: string[] = [] afterEach(() => { - vi.restoreAllMocks() for (const dir of tempDirs) { rmSync(dir, { recursive: true, force: true }) } @@ -127,22 +120,6 @@ function insertMessageWithPart( } describe('listOpenCodeSqliteSessions — LIMIT-first discovery', () => { - it('keeps an exact-limit session id and omits limit +1 before materializing it', async () => { - const { db, path } = createTempDb() - applySchema(db) - const exactId = 'e'.repeat(OPENCODE_SQLITE_SESSION_ID_MAX_BYTES) - const oversizedId = 'o'.repeat(OPENCODE_SQLITE_SESSION_ID_MAX_BYTES + 1) - insertSession(db, exactId, 1_777_634_001_000) - insertSession(db, oversizedId, 1_777_634_002_000) - db.close() - - const candidates = await listOpenCodeSqliteSessions({ dbPaths: [path], limit: 10, issues: [] }) - - expect(candidates.map((candidate) => candidate.file.path)).toEqual([ - buildOpenCodeSqliteCandidatePath(path, exactId) - ]) - }) - it('returns only the newest `limit` sessions by time_updated', async () => { const { db, path } = createTempDb() applySchema(db) @@ -197,88 +174,6 @@ describe('listOpenCodeSqliteSessions — LIMIT-first discovery', () => { }) describe('parseOpenCodeSqliteSession — bounded preview window', () => { - it('preserves exact-limit model JSON and drops limit +1 before JSON.parse', async () => { - const { db, path } = createTempDb() - applySchema(db) - insertSession(db, 'ses_model_exact', 1_777_634_001_000) - insertSession(db, 'ses_model_oversized', 1_777_634_002_000) - const modelPrefix = '{"id":"' - const modelSuffix = '"}' - db.prepare(`UPDATE session SET model = ? WHERE id = 'ses_model_exact'`).run( - `${modelPrefix}${'m'.repeat( - OPENCODE_SQLITE_MODEL_JSON_MAX_BYTES - modelPrefix.length - modelSuffix.length - )}${modelSuffix}` - ) - db.prepare(`UPDATE session SET model = ? WHERE id = 'ses_model_oversized'`).run( - 'x'.repeat(OPENCODE_SQLITE_MODEL_JSON_MAX_BYTES + 1) - ) - db.close() - - const exact = await parseOpenCodeSqliteSession({ - dbPath: path, - sessionId: 'ses_model_exact', - platform: 'darwin' - }) - const parseSpy = vi.spyOn(JSON, 'parse') - const oversized = await parseOpenCodeSqliteSession({ - dbPath: path, - sessionId: 'ses_model_oversized', - platform: 'darwin' - }) - - expect(exact?.model).toHaveLength( - OPENCODE_SQLITE_MODEL_JSON_MAX_BYTES - modelPrefix.length - modelSuffix.length - ) - expect(oversized?.model).toBeNull() - expect(parseSpy).not.toHaveBeenCalled() - }) - - it('omits oversized message and part JSON before retaining previews', async () => { - const { db, path } = createTempDb() - applySchema(db) - insertSession(db, 'ses_oversized_rows', 1_777_634_001_000) - db.prepare(`INSERT INTO message (id, session_id, time_created, data) VALUES (?, ?, ?, ?)`).run( - 'msg_oversized', - 'ses_oversized_rows', - 1_777_634_000_100, - 'x'.repeat(OPENCODE_SQLITE_MESSAGE_JSON_MAX_BYTES + 1) - ) - db.prepare( - `INSERT INTO part (id, message_id, session_id, time_created, data) VALUES (?, ?, ?, ?, ?)` - ).run( - 'part_for_oversized_message', - 'msg_oversized', - 'ses_oversized_rows', - 1_777_634_000_100, - JSON.stringify({ type: 'text', text: 'must not surface' }) - ) - db.prepare(`INSERT INTO message (id, session_id, time_created, data) VALUES (?, ?, ?, ?)`).run( - 'msg_normal', - 'ses_oversized_rows', - 1_777_634_000_200, - JSON.stringify({ role: 'user' }) - ) - db.prepare( - `INSERT INTO part (id, message_id, session_id, time_created, data) VALUES (?, ?, ?, ?, ?)` - ).run( - 'part_oversized', - 'msg_normal', - 'ses_oversized_rows', - 1_777_634_000_200, - 'x'.repeat(OPENCODE_SQLITE_PART_JSON_MAX_BYTES + 1) - ) - db.close() - - const session = await parseOpenCodeSqliteSession({ - dbPath: path, - sessionId: 'ses_oversized_rows', - platform: 'darwin' - }) - - expect(session?.messageCount).toBe(1) - expect(session?.previewMessages).toEqual([]) - }) - it('surfaces the newest text previews and retains the full message count', async () => { // Positive coverage of preview selection (newest OPENCODE_SQLITE_PREVIEW_LIMIT // by recency) + full-count retention. The window bound itself is pinned by the diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-limits.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-limits.ts deleted file mode 100644 index b0cd7c0f360..00000000000 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-limits.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const OPENCODE_SQLITE_SESSION_ID_MAX_BYTES = 16 * 1024 -export const OPENCODE_SQLITE_SESSION_TEXT_MAX_BYTES = 1024 * 1024 -export const OPENCODE_SQLITE_MODEL_JSON_MAX_BYTES = 1024 * 1024 -export const OPENCODE_SQLITE_MESSAGE_JSON_MAX_BYTES = 1024 * 1024 -export const OPENCODE_SQLITE_PART_JSON_MAX_BYTES = 4 * 1024 * 1024 diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-list.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-list.ts index a398712bf47..b89b97b229e 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-list.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-list.ts @@ -5,7 +5,6 @@ import type { SessionFileCandidate } from './session-scanner-types' import { errorMessage } from './session-scanner-values' import SyncDatabase from '../sqlite/sync-database' import { columnExists, tableExists } from '../opencode-usage/schema-helpers' -import { OPENCODE_SQLITE_SESSION_ID_MAX_BYTES } from './session-scanner-opencode-sqlite-limits' // Why: the SQLite session-list query + reader lives in its own electron-free // module so both the worker entry and the main-thread worker client can import @@ -42,9 +41,7 @@ function buildSessionListQuery(db: SyncDatabase): string { // refresh; the parse path loads metadata only for candidates it actually uses. return `SELECT id, time_created, time_updated FROM session - WHERE typeof(id) = 'text' - AND length(CAST(id AS BLOB)) <= ${OPENCODE_SQLITE_SESSION_ID_MAX_BYTES} - ${parentIdPredicate} ${archivedPredicate} + WHERE 1=1 ${parentIdPredicate} ${archivedPredicate} ORDER BY CASE WHEN time_updated > 0 THEN time_updated ELSE time_created END DESC LIMIT ?` } diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-worker-client.test.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-worker-client.test.ts index ee68f50a7ff..ab04a455cb5 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-worker-client.test.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-worker-client.test.ts @@ -4,7 +4,6 @@ import { IDLE_TEARDOWN_MS, LIST_TIMEOUT_MS, MAX_CONSECUTIVE_DEATHS, - MAX_PENDING_CALLS, OpenCodeSqliteWorkerClient, PARSE_TIMEOUT_MS } from './session-scanner-opencode-sqlite-worker-client' @@ -123,33 +122,6 @@ describe('OpenCodeSqliteWorkerClient', () => { expect(workers).toHaveLength(1) }) - it('rejects overload beyond the bounded process-wide queue and recovers after draining', async () => { - const workers: FakeWorker[] = [] - const client = new OpenCodeSqliteWorkerClient({ workerFactory: makeFactory(workers), log() {} }) - const admitted = Array.from({ length: MAX_PENDING_CALLS }, (_, index) => - client.parse({ dbPath: `/db#${index}`, sessionId: `s${index}`, platform: 'darwin' }) - ) - const settled = admitted.map((promise) => promise.catch((error: unknown) => error)) - - await expect( - client.parse({ dbPath: '/db#overflow', sessionId: 'overflow', platform: 'darwin' }) - ).rejects.toThrow(/256-request capacity/) - expect(workers[0]!.postedRequests).toHaveLength(1) - - for (let index = 0; index < MAX_CONSECUTIVE_DEATHS; index += 1) { - workers.at(-1)!.emit('error', new Error(`drain ${index}`)) - } - await Promise.all(settled) - - const recovered = client.parse({ - dbPath: '/db#recovered', - sessionId: 'recovered', - platform: 'darwin' - }) - workers.at(-1)!.emit('message', { id: workers.at(-1)!.lastId(), ok: true, value: 'ok' }) - await expect(recovered).resolves.toBe('ok') - }) - it('times out only the active call, then respawns and drains the queue', async () => { vi.useFakeTimers() try { diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-worker-client.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-worker-client.ts index faa4ef77e97..ef156b0b3f4 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-worker-client.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-worker-client.ts @@ -19,7 +19,6 @@ import { errorMessage } from './session-scanner-values' export const LIST_TIMEOUT_MS = 30_000 export const PARSE_TIMEOUT_MS = 15_000 export const IDLE_TEARDOWN_MS = 30_000 -export const MAX_PENDING_CALLS = 256 // After this many consecutive worker deaths, fail the remaining queued calls to // scan issues instead of respawning so a DB that reliably kills the worker can't // spin a crash loop. Reset on any successful response, after draining, and when a @@ -143,11 +142,6 @@ export class OpenCodeSqliteWorkerClient { } private dispatch(request: OpenCodeSqliteRequestBody, timeoutMs: number): Promise { - if (this.queue.length + (this.active ? 1 : 0) >= MAX_PENDING_CALLS) { - return Promise.reject( - new Error(`OpenCode SQLite worker reached its ${MAX_PENDING_CALLS}-request capacity`) - ) - } return new Promise((resolve, reject) => { const id = this.nextId++ // A fresh burst from full idle starts a new scan: clear any death count diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite.ts b/src/main/ai-vault/session-scanner-opencode-sqlite.ts index 898ca9624db..ad3e9b76f41 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite.ts @@ -5,15 +5,9 @@ import { finalizeSession, updateTimeline } from './session-scanner-accumulator' -import { normalizeTitleText, parseAiVaultJsonText } from './session-scanner-values' +import { normalizeTitleText } from './session-scanner-values' import SyncDatabase from '../sqlite/sync-database' import { columnExists, tableExists } from '../opencode-usage/schema-helpers' -import { - OPENCODE_SQLITE_MESSAGE_JSON_MAX_BYTES, - OPENCODE_SQLITE_MODEL_JSON_MAX_BYTES, - OPENCODE_SQLITE_PART_JSON_MAX_BYTES, - OPENCODE_SQLITE_SESSION_TEXT_MAX_BYTES -} from './session-scanner-opencode-sqlite-limits' // Why: OpenCode 1.17.x migrated session storage from per-session JSON files // to a single SQLite DB at ~/.local/share/opencode/opencode.db. This module @@ -69,16 +63,8 @@ function canReadOpenCodeSessions(db: SyncDatabase): boolean { ) } -function boundedSessionTextColumnSelect( - db: SyncDatabase, - columnName: string, - maxBytes: number -): string { - return columnExists(db, 'session', columnName) - ? `CASE WHEN typeof(s.${columnName}) = 'text' - AND length(CAST(s.${columnName} AS BLOB)) <= ${maxBytes} - THEN s.${columnName} ELSE NULL END` - : 'NULL' +function sessionColumnSelect(db: SyncDatabase, columnName: string): string { + return columnExists(db, 'session', columnName) ? `s.${columnName}` : 'NULL' } function sessionNumberColumnSelect(db: SyncDatabase, columnName: string): string { @@ -97,20 +83,15 @@ function buildSessionQuery(db: SyncDatabase): string { const messageCountSubquery = canCountOpenCodeMessages(db) ? `(SELECT COUNT(*) FROM message m WHERE m.session_id = s.id - AND CASE - WHEN typeof(m.data) = 'text' - AND length(CAST(m.data AS BLOB)) <= ${OPENCODE_SQLITE_MESSAGE_JSON_MAX_BYTES} - THEN json_extract(m.data, '$.role') IN ('user','assistant') - ELSE 0 - END)` + AND json_extract(m.data, '$.role') IN ('user','assistant'))` : '0' return `SELECT s.id, - ${boundedSessionTextColumnSelect(db, 'title', OPENCODE_SQLITE_SESSION_TEXT_MAX_BYTES)} AS title, - ${boundedSessionTextColumnSelect(db, 'directory', OPENCODE_SQLITE_SESSION_TEXT_MAX_BYTES)} AS directory, + ${sessionColumnSelect(db, 'title')} AS title, + ${sessionColumnSelect(db, 'directory')} AS directory, s.time_created, s.time_updated, - ${boundedSessionTextColumnSelect(db, 'model', OPENCODE_SQLITE_MODEL_JSON_MAX_BYTES)} AS model_json, - ${boundedSessionTextColumnSelect(db, 'agent', OPENCODE_SQLITE_SESSION_TEXT_MAX_BYTES)} AS agent, + ${sessionColumnSelect(db, 'model')} AS model_json, + ${sessionColumnSelect(db, 'agent')} AS agent, ${sessionNumberColumnSelect(db, 'tokens_input')} AS tokens_input, ${sessionNumberColumnSelect(db, 'tokens_output')} AS tokens_output, ${sessionNumberColumnSelect(db, 'tokens_reasoning')} AS tokens_reasoning, @@ -127,7 +108,7 @@ function extractModelId(modelJson: string | null): string | null { return null } try { - const parsed = parseAiVaultJsonText(modelJson) + const parsed = JSON.parse(modelJson) as unknown const record = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as Record) @@ -156,7 +137,7 @@ function mapPreviewRole(role: string | null): AiVaultSessionPreviewMessage['role function extractPartText(partData: string): string | null { try { - const parsed = parseAiVaultJsonText(partData) + const parsed = JSON.parse(partData) as unknown const record = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as Record) @@ -191,18 +172,11 @@ function buildPreviewQuery(db: SyncDatabase): string | null { json_extract(m.data, '$.summary.body') AS summary_body FROM (SELECT id, data FROM message WHERE session_id = ? - AND typeof(data) = 'text' - AND length(CAST(data AS BLOB)) <= ${OPENCODE_SQLITE_MESSAGE_JSON_MAX_BYTES} ORDER BY time_created DESC, id DESC LIMIT ${OPENCODE_SQLITE_PREVIEW_MESSAGE_WINDOW}) m JOIN part p ON p.message_id = m.id WHERE json_extract(m.data, '$.role') IN ('user','assistant') - AND CASE - WHEN typeof(p.data) = 'text' - AND length(CAST(p.data AS BLOB)) <= ${OPENCODE_SQLITE_PART_JSON_MAX_BYTES} - THEN json_extract(p.data, '$.type') = 'text' - ELSE 0 - END + AND json_extract(p.data, '$.type') = 'text' ORDER BY p.time_created DESC LIMIT ?` } diff --git a/src/main/ai-vault/session-scanner-parse-cache.test.ts b/src/main/ai-vault/session-scanner-parse-cache.test.ts index 1d1bbf58231..6ba5e3266d6 100644 --- a/src/main/ai-vault/session-scanner-parse-cache.test.ts +++ b/src/main/ai-vault/session-scanner-parse-cache.test.ts @@ -7,7 +7,6 @@ import { resetSessionParseCacheForTests, createSessionParseStats } from './session-scanner-parse-cache' -import { AI_VAULT_PARSE_CACHE_VALUE_MAX_UTF8_BYTES } from './session-parse-cache-retention' import { parseClaudeSessionFile } from './session-scanner-primary-parsers' import type { FileWithMtime, SessionFileCandidate } from './session-scanner-types' @@ -93,29 +92,6 @@ describe('parseAgentSessionFileCached', () => { expect(stats.incremental).toBe(0) }) - it('returns oversized metadata unchanged without retaining it', async () => { - const root = await makeTempDir() - const path = join(root, 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee.jsonl') - const oversizedModel = 'é'.repeat(AI_VAULT_PARSE_CACHE_VALUE_MAX_UTF8_BYTES / 2) - await writeFile( - path, - `${userRecord(0, 'question')}\n${assistantRecord(1, 'answer').replace( - 'claude-fable-5', - oversizedModel - )}\n` - ) - const candidate = await claudeCandidate(path) - const stats = createSessionParseStats() - - const first = await parseAgentSessionFileCached(candidate, process.platform, stats) - const second = await parseAgentSessionFileCached(candidate, process.platform, stats) - - expect(first?.model).toBe(oversizedModel) - expect(second).toEqual(first) - expect(stats.fullParses).toBe(2) - expect(stats.reused).toBe(0) - }) - it('incrementally parses appended lines and matches a cold parse exactly', async () => { const root = await makeTempDir() const path = join(root, 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee.jsonl') diff --git a/src/main/ai-vault/session-scanner-parse-cache.ts b/src/main/ai-vault/session-scanner-parse-cache.ts index 64835da7b5e..b7aab770432 100644 --- a/src/main/ai-vault/session-scanner-parse-cache.ts +++ b/src/main/ai-vault/session-scanner-parse-cache.ts @@ -1,3 +1,4 @@ +import { createReadStream } from 'node:fs' import { open } from 'node:fs/promises' import type { AiVaultSession } from '../../shared/ai-vault-types' import { createAntigravitySessionResumeState } from './session-scanner-antigravity-parser' @@ -13,21 +14,28 @@ import { } from './session-scanner-secondary-parsers' import { countSubagentTranscripts } from './session-scanner-subagent-transcripts' import type { ResumableSessionParseState, SessionFileCandidate } from './session-scanner-types' -import { consumeAiVaultJsonlLines } from './session-jsonl-line-reader' -import { - getSessionParseCacheEntry, - resetSessionParseCacheRetentionForTests, - storeSessionParseCacheEntry, - type SessionParseCacheEntry -} from './session-parse-cache-retention' -export { - seedSessionParseCache, - snapshotSessionParseCacheForPersistence, - type PersistedSessionParseCacheEntry -} from './session-parse-cache-retention' +// Sized past the default recency cap (1000) plus the in-scope cap (2000) so a +// full steady-state result set stays resident between forced rescans. +const MAX_CACHE_ENTRIES = 4096 const NEWLINE_BYTE = 0x0a +const CARRIAGE_RETURN_BYTE = 0x0d + +type ResumePoint = { + state: ResumableSessionParseState + // Byte offset just past the last complete ('\n'-terminated) line consumed; + // a trailing unterminated line is deliberately left before this point. + byteOffset: number +} + +type SessionParseCacheEntry = { + mtimeMs: number + sizeBytes: number | null + platform: NodeJS.Platform + session: AiVaultSession | null + resume: ResumePoint | null +} // Incremental append-parsing applies only to transcripts that are append-only // JSONL line-folds. Whole-JSON documents (grok/rovo/devin/hermes/gemini-json) @@ -83,8 +91,67 @@ export function createSessionParseStats(): SessionParseStats { return { reused: 0, incremental: 0, fullParses: 0, bytesRead: 0 } } +const cache = new Map() + export function resetSessionParseCacheForTests(): void { - resetSessionParseCacheRetentionForTests() + cache.clear() +} + +// Persisted subset of a cache entry: the non-serializable `resume` parser +// state is dropped (see session-parse-cache-persistence.ts). +export type PersistedSessionParseCacheEntry = Omit + +export function snapshotSessionParseCacheForPersistence(): [ + string, + PersistedSessionParseCacheEntry +][] { + return [...cache].map(([path, entry]): [string, PersistedSessionParseCacheEntry] => [ + path, + { + mtimeMs: entry.mtimeMs, + sizeBytes: entry.sizeBytes, + platform: entry.platform, + session: entry.session + } + ]) +} + +// Seeded entries carry `resume: null`: after a restart an unchanged file is a +// cache hit; a file that changed while the app was closed pays one full +// (not incremental) re-parse. +export function seedSessionParseCache( + entries: Iterable<[string, PersistedSessionParseCacheEntry]> +): void { + const list = [...entries] + // Snapshot order is oldest→newest (LRU); an over-cap list keeps the newest + // tail rather than seeding the oldest entries and dropping the tail. + for (const [path, entry] of list.slice(Math.max(0, list.length - MAX_CACHE_ENTRIES))) { + if (cache.size >= MAX_CACHE_ENTRIES) { + return + } + // In-process entries are always fresher than persisted ones; never clobber. + if (cache.has(path)) { + continue + } + cache.set(path, { + mtimeMs: entry.mtimeMs, + sizeBytes: entry.sizeBytes, + platform: entry.platform, + session: entry.session, + resume: null + }) + } +} + +function storeEntry(path: string, entry: SessionParseCacheEntry): void { + cache.delete(path) + cache.set(path, entry) + if (cache.size > MAX_CACHE_ENTRIES) { + const oldest = cache.keys().next() + if (!oldest.done) { + cache.delete(oldest.value) + } + } } /** @@ -102,7 +169,7 @@ export async function parseAgentSessionFileCached( stats?: SessionParseStats ): Promise { const { file } = candidate - const entry = getSessionParseCacheEntry(file.path) + const entry = cache.get(file.path) const unchanged = entry !== undefined && @@ -123,7 +190,7 @@ export async function parseAgentSessionFileCached( entry.session = { ...entry.session, subagentTranscriptCount } } } - storeSessionParseCacheEntry(file.path, entry) + storeEntry(file.path, entry) return entry.session } @@ -136,7 +203,7 @@ export async function parseAgentSessionFileCached( stats, stateFactory }) - storeSessionParseCacheEntry(file.path, parsed) + storeEntry(file.path, parsed) return parsed.session } @@ -145,7 +212,7 @@ export async function parseAgentSessionFileCached( stats.bytesRead += file.sizeBytes ?? 0 } const session = await parseAgentSessionFile(candidate, platform) - storeSessionParseCacheEntry(file.path, { + storeEntry(file.path, { mtimeMs: file.mtimeMs, sizeBytes: file.sizeBytes ?? null, platform, @@ -183,7 +250,7 @@ async function parseResumableCandidate(args: { } } - const readResult = await consumeAiVaultJsonlLines({ + const readResult = await consumeCompleteJsonlLines({ path: file.path, start: startOffset, onLine: (line) => state.consumeLine(line) @@ -227,3 +294,48 @@ async function endsWithNewlineAt(path: string, offset: number): Promise await handle.close() } } + +type JsonlReadResult = { + consumedThrough: number + trailingPartialLine: string | null + bytesRead: number +} + +// Byte-accurate replacement for readline: offsets must count bytes (not +// UTF-8-decoded characters) so a resumed read starts exactly where the last +// complete line ended. +async function consumeCompleteJsonlLines(args: { + path: string + start: number + onLine: (line: string) => void +}): Promise { + let consumedThrough = args.start + let bytesRead = 0 + let remainder: Buffer | null = null + + const stream = createReadStream(args.path, { start: args.start }) + for await (const chunk of stream as AsyncIterable) { + bytesRead += chunk.length + const data = remainder ? Buffer.concat([remainder, chunk]) : chunk + let lineStart = 0 + let newlineIndex = data.indexOf(NEWLINE_BYTE, lineStart) + while (newlineIndex !== -1) { + let lineEnd = newlineIndex + if (lineEnd > lineStart && data[lineEnd - 1] === CARRIAGE_RETURN_BYTE) { + lineEnd-- + } + args.onLine(data.toString('utf-8', lineStart, lineEnd)) + lineStart = newlineIndex + 1 + newlineIndex = data.indexOf(NEWLINE_BYTE, lineStart) + } + consumedThrough += lineStart + // Copy the tail so retaining it doesn't pin the whole chunk buffer. + remainder = lineStart < data.length ? Buffer.from(data.subarray(lineStart)) : null + } + + return { + consumedThrough, + trailingPartialLine: remainder && remainder.length > 0 ? remainder.toString('utf-8') : null, + bytesRead + } +} diff --git a/src/main/ai-vault/session-scanner-primary-parsers.ts b/src/main/ai-vault/session-scanner-primary-parsers.ts index cbd36f74c36..c7e67ec463b 100644 --- a/src/main/ai-vault/session-scanner-primary-parsers.ts +++ b/src/main/ai-vault/session-scanner-primary-parsers.ts @@ -1,3 +1,5 @@ +import { createReadStream } from 'node:fs' +import { createInterface } from 'node:readline' import type { AiVaultSession } from '../../shared/ai-vault-types' import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../shared/execution-host' import { isKnownHarnessInjectedUserTurnText } from '../../shared/harness-injected-user-turns' @@ -11,13 +13,11 @@ import { addPreviewContent, createAccumulator, finalizeSession, - sessionAccumulatorRetainedUtf8Bytes, sessionIdFromFileName, updateLatestLocation, updateTimeline } from './session-scanner-accumulator' import { countSubagentTranscripts } from './session-scanner-subagent-transcripts' -import { iterateAiVaultJsonlLines } from './session-jsonl-line-reader' import { asRecord, claudeUsageTotal, @@ -193,11 +193,6 @@ function claudeResumeStateFromParseState( return { consumeLine: (line) => consumeClaudeSessionLine(state, line), clone: () => claudeResumeStateFromParseState(cloneClaudeSessionParseState(state)), - retainedUtf8Bytes: () => - sessionAccumulatorRetainedUtf8Bytes(state.accumulator) + - Buffer.byteLength(state.metaTitle ?? '', 'utf8') + - Buffer.byteLength(state.generatedTitle ?? '', 'utf8') + - Buffer.byteLength(state.firstUserTitle ?? '', 'utf8'), touchFile: (file) => { state.accumulator.modifiedAt = file.modifiedAt }, @@ -209,7 +204,10 @@ export async function parseClaudeSessionFile( file: FileWithMtime, platform: NodeJS.Platform = process.platform ): Promise { - const lines = iterateAiVaultJsonlLines(file.path) + const lines = createInterface({ + input: createReadStream(file.path, { encoding: 'utf-8' }), + crlfDelay: Infinity + }) return parseClaudeSessionLines({ file, lines, platform }) } diff --git a/src/main/ai-vault/session-scanner-retention.test.ts b/src/main/ai-vault/session-scanner-retention.test.ts deleted file mode 100644 index 4c3f63dfe00..00000000000 --- a/src/main/ai-vault/session-scanner-retention.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import { AI_VAULT_SESSION_ID_MAX_UTF8_BYTES } from './session-list-retention' -import { scanAiVaultSessions } from './session-scanner' -import { isolatedScanRoots, writeJsonlFile } from './session-scanner-test-fixtures' - -const tempRoots: string[] = [] - -afterEach(async () => { - await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) -}) - -describe('local AI Vault result retention', () => { - it('omits a resume-critical overflow and surfaces a bounded scan issue', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-vault-result-retention-')) - tempRoots.push(root) - const roots = isolatedScanRoots(root) - const sessionId = 'x'.repeat(AI_VAULT_SESSION_ID_MAX_UTF8_BYTES + 1) - await writeJsonlFile(join(roots.claudeProjectsDir, 'repo', 'session.jsonl'), [ - { - type: 'user', - sessionId, - timestamp: '2026-01-01T00:00:00.000Z', - cwd: '/repo', - message: { role: 'user', content: 'Retain safely' } - } - ]) - - const result = await scanAiVaultSessions(roots) - - expect(result.sessions).toEqual([]) - expect(result.issues).toEqual([ - expect.objectContaining({ - path: join(roots.claudeProjectsDir, 'repo', 'session.jsonl'), - message: expect.stringContaining('session id exceeds the 65536 byte limit') - }) - ]) - }) -}) diff --git a/src/main/ai-vault/session-scanner-scope-discovery.ts b/src/main/ai-vault/session-scanner-scope-discovery.ts index 525ef6d4c9c..41aac5f2700 100644 --- a/src/main/ai-vault/session-scanner-scope-discovery.ts +++ b/src/main/ai-vault/session-scanner-scope-discovery.ts @@ -1,4 +1,6 @@ -import { opendir, stat } from 'node:fs/promises' +import { createReadStream } from 'node:fs' +import { readdir, stat } from 'node:fs/promises' +import { createInterface } from 'node:readline' import { extname, join } from 'node:path' import { isPathInsideOrEqual, @@ -6,8 +8,6 @@ import { } from '../../shared/cross-platform-path' import type { AiVaultScanIssue } from '../../shared/ai-vault-types' import { parseWslUncPath } from '../../shared/wsl-paths' -import { iterateAiVaultJsonlLines } from './session-jsonl-line-reader' -import { AiVaultScopeCwdCache } from './session-scope-cwd-cache' import type { FileWithMtime } from './session-scanner-types' import { errorMessage, extractString, parseJsonObject } from './session-scanner-values' @@ -19,7 +19,8 @@ const CLAUDE_EXTENSIONS = new Set(['.jsonl']) // A Claude project dir encodes exactly one cwd, so a resolved cwd never // changes; caching it spares each rescan the transcript-head reads. -const projectDirCwdCache = new AiVaultScopeCwdCache() +const PROJECT_DIR_CWD_CACHE_MAX = 2048 +const projectDirCwdCache = new Map() export function resetProjectDirCwdCacheForTests(): void { projectDirCwdCache.clear() @@ -28,10 +29,19 @@ export function resetProjectDirCwdCacheForTests(): void { async function cachedProjectDirCwd(projectDir: string): Promise { const cached = projectDirCwdCache.get(projectDir) if (cached !== undefined) { + // Refresh recency so hot in-scope dirs outlive one-off ones at the cap. + projectDirCwdCache.delete(projectDir) + projectDirCwdCache.set(projectDir, cached) return cached } const cwd = await readProjectDirCwd(projectDir) if (cwd) { + if (projectDirCwdCache.size >= PROJECT_DIR_CWD_CACHE_MAX) { + const oldest = projectDirCwdCache.keys().next() + if (!oldest.done) { + projectDirCwdCache.delete(oldest.value) + } + } projectDirCwdCache.set(projectDir, cwd) } return cwd @@ -60,7 +70,7 @@ export async function discoverInScopeClaudeFiles(args: { const scopeProjectPrefixes = claudeProjectScopePrefixes(args.scopePaths) const collected = new Map() for (const rootDir of args.rootDirs) { - for await (const projectDir of iterateProjectDirs(rootDir, scopeProjectPrefixes)) { + for (const projectDir of await listProjectDirs(rootDir, scopeProjectPrefixes)) { const cwd = await cachedProjectDirCwd(projectDir) if (!cwd || !args.scopePaths.some((scopePath) => isCwdInsideScopePath(scopePath, cwd))) { continue @@ -120,20 +130,21 @@ function isCwdInsideScopePath(scopePath: string, cwd: string): boolean { return isPathInsideOrEqual(wslScopePath.linuxPath, cwd) } -async function* iterateProjectDirs( +async function listProjectDirs( rootDir: string, scopeProjectPrefixes: ReadonlySet -): AsyncGenerator { +): Promise { + let entries try { - const directory = await opendir(rootDir) - for await (const entry of directory) { - if (entry.isDirectory() && isClaudeProjectDirInScope(entry.name, scopeProjectPrefixes)) { - yield join(rootDir, entry.name) - } - } + entries = await readdir(rootDir, { withFileTypes: true }) } catch { - // Missing or unreadable roots have no project directories to yield. + return [] } + return entries + .filter( + (entry) => entry.isDirectory() && isClaudeProjectDirInScope(entry.name, scopeProjectPrefixes) + ) + .map((entry) => join(rootDir, entry.name)) } async function readProjectDirCwd(projectDir: string): Promise { @@ -148,31 +159,34 @@ async function readProjectDirCwd(projectDir: string): Promise { } async function newestClaudeFilesInDir(projectDir: string): Promise { - const newest: { path: string; mtimeMs: number }[] = [] + let entries try { - const directory = await opendir(projectDir) - for await (const entry of directory) { - if (!entry.isFile() || !CLAUDE_EXTENSIONS.has(extname(entry.name).toLowerCase())) { - continue - } - const path = join(projectDir, entry.name) - try { - addBoundedPath(newest, REPRESENTATIVE_FILE_LIMIT, { - path, - mtimeMs: (await stat(path)).mtimeMs - }) - } catch { - // Best effort: unreadable candidates are ignored here and reported during full collection. - } - } + entries = await readdir(projectDir, { withFileTypes: true }) } catch { return [] } + const newest: { path: string; mtimeMs: number }[] = [] + for (const entry of entries) { + if (!entry.isFile() || !CLAUDE_EXTENSIONS.has(extname(entry.name).toLowerCase())) { + continue + } + const path = join(projectDir, entry.name) + try { + addBoundedPath(newest, REPRESENTATIVE_FILE_LIMIT, { + path, + mtimeMs: (await stat(path)).mtimeMs + }) + } catch { + // Best effort: unreadable candidates are ignored here and reported during + // full collection if the project directory proves in-scope. + } + } return newest.sort((left, right) => right.mtimeMs - left.mtimeMs).map((value) => value.path) } async function readFirstCwd(filePath: string): Promise { - const lines = iterateAiVaultJsonlLines(filePath) + const input = createReadStream(filePath, { encoding: 'utf-8' }) + const lines = createInterface({ input, crlfDelay: Infinity }) let read = 0 try { for await (const line of lines) { @@ -186,6 +200,11 @@ async function readFirstCwd(filePath: string): Promise { } } catch { return null + } finally { + // readline.close() leaves the underlying stream open; destroy it so the early + // break/catch paths don't leak a file descriptor (this runs per project dir). + lines.close() + input.destroy() } return null } @@ -197,30 +216,31 @@ async function collectClaudeFiles(args: { limit: number excludedFilePaths: ReadonlySet }): Promise { + let entries try { - const directory = await opendir(args.projectDir) - for await (const entry of directory) { - if (!entry.isFile() || !CLAUDE_EXTENSIONS.has(extname(entry.name).toLowerCase())) { - continue - } - const path = join(args.projectDir, entry.name) - if (args.collected.has(path) || args.excludedFilePaths.has(path)) { - continue - } - try { - const fileStat = await stat(path) - addBoundedFile(args.collected, args.limit, { - path, - mtimeMs: fileStat.mtimeMs, - modifiedAt: fileStat.mtime.toISOString(), - sizeBytes: fileStat.size - }) - } catch (err) { - args.issues.push({ agent: 'claude', path, message: errorMessage(err) }) - } - } + entries = await readdir(args.projectDir, { withFileTypes: true }) } catch { - // Missing or unreadable project directories contribute no sessions. + return + } + for (const entry of entries) { + if (!entry.isFile() || !CLAUDE_EXTENSIONS.has(extname(entry.name).toLowerCase())) { + continue + } + const path = join(args.projectDir, entry.name) + if (args.collected.has(path) || args.excludedFilePaths.has(path)) { + continue + } + try { + const fileStat = await stat(path) + addBoundedFile(args.collected, args.limit, { + path, + mtimeMs: fileStat.mtimeMs, + modifiedAt: fileStat.mtime.toISOString(), + sizeBytes: fileStat.size + }) + } catch (err) { + args.issues.push({ agent: 'claude', path, message: errorMessage(err) }) + } } } diff --git a/src/main/ai-vault/session-scanner-secondary-parsers.ts b/src/main/ai-vault/session-scanner-secondary-parsers.ts index b7c86b0adef..364c25b5d85 100644 --- a/src/main/ai-vault/session-scanner-secondary-parsers.ts +++ b/src/main/ai-vault/session-scanner-secondary-parsers.ts @@ -1,12 +1,12 @@ -import { opendir } from 'node:fs/promises' +import { createReadStream } from 'node:fs' +import { readFile, readdir } from 'node:fs/promises' import { join } from 'node:path' +import { createInterface } from 'node:readline' import type { AiVaultSession } from '../../shared/ai-vault-types' -import { iterateAiVaultJsonlLines } from './session-jsonl-line-reader' -import { withAiVaultWholeJsonFile } from './session-whole-json-reader' +import type { ExecutionHostId } from '../../shared/execution-host' import type { FileWithMtime, ResumableSessionParseState, - ResumableParseFinalizeOptions, SessionAccumulator } from './session-scanner-types' import { @@ -35,11 +35,19 @@ import { tokenTotal } from './session-scanner-values' +type ParserSessionOptions = { + executionHostId?: ExecutionHostId + executionHostPlatform?: NodeJS.Platform | null +} + export async function parseCopilotSessionFile( file: FileWithMtime, platform: NodeJS.Platform = process.platform ): Promise { - const lines = iterateAiVaultJsonlLines(file.path) + const lines = createInterface({ + input: createReadStream(file.path, { encoding: 'utf-8' }), + crlfDelay: Infinity + }) return parseCopilotSessionLines({ file, lines, platform }) } @@ -47,7 +55,7 @@ export async function parseCopilotSessionContent( file: FileWithMtime, content: string, platform: NodeJS.Platform = process.platform, - options: ResumableParseFinalizeOptions = {} + options: ParserSessionOptions = {} ): Promise { return parseCopilotSessionLines({ file, @@ -119,7 +127,7 @@ async function parseCopilotSessionLines(args: { file: FileWithMtime lines: AsyncIterable | Iterable platform: NodeJS.Platform - options?: ResumableParseFinalizeOptions + options?: ParserSessionOptions }): Promise { const state = createCopilotSessionResumeState(args.file) for await (const line of args.lines) { @@ -132,7 +140,10 @@ export async function parseCursorSessionFile( file: FileWithMtime, platform: NodeJS.Platform = process.platform ): Promise { - const lines = iterateAiVaultJsonlLines(file.path) + const lines = createInterface({ + input: createReadStream(file.path, { encoding: 'utf-8' }), + crlfDelay: Infinity + }) return parseCursorSessionLines({ file, lines, platform }) } @@ -140,7 +151,7 @@ export async function parseCursorSessionContent( file: FileWithMtime, content: string, platform: NodeJS.Platform = process.platform, - options: ResumableParseFinalizeOptions = {} + options: ParserSessionOptions = {} ): Promise { return parseCursorSessionLines({ file, @@ -182,7 +193,7 @@ async function parseCursorSessionLines(args: { file: FileWithMtime lines: AsyncIterable | Iterable platform: NodeJS.Platform - options?: ResumableParseFinalizeOptions + options?: ParserSessionOptions }): Promise { const state = createCursorSessionResumeState(args.file) for await (const line of args.lines) { @@ -195,28 +206,18 @@ export async function parseOpenCodeSessionFile( file: FileWithMtime, platform: NodeJS.Platform = process.platform ): Promise { - const parsed = await withAiVaultWholeJsonFile(file.path, (content) => { - const record = parseJsonObject(content) - if (!record) { - return null - } - const sessionId = extractString(record.id) ?? sessionIdFromFileName(file.path) - const accumulator = createAccumulator({ agent: 'opencode', file, sessionId }) - accumulator.title = normalizeTitleText(extractString(record.title) ?? '') - accumulator.cwd = extractString(record.directory) - updateTimeline(accumulator, timeObjectValue(record.time, 'created')) - updateTimeline(accumulator, timeObjectValue(record.time, 'updated')) - return { accumulator, sessionId } - }) - if (!parsed) { + const record = asRecord(JSON.parse(await readFile(file.path, 'utf-8')) as unknown) + if (!record) { return null } - await consumeOpenCodeMessages( - parsed.accumulator, - findOpenCodeStorageRoot(file.path), - parsed.sessionId - ) - return finalizeSession(parsed.accumulator, platform) + const sessionId = extractString(record.id) ?? sessionIdFromFileName(file.path) + const accumulator = createAccumulator({ agent: 'opencode', file, sessionId }) + accumulator.title = normalizeTitleText(extractString(record.title) ?? '') + accumulator.cwd = extractString(record.directory) + updateTimeline(accumulator, timeObjectValue(record.time, 'created')) + updateTimeline(accumulator, timeObjectValue(record.time, 'updated')) + await consumeOpenCodeMessages(accumulator, findOpenCodeStorageRoot(file.path), sessionId) + return finalizeSession(accumulator, platform) } export async function consumeOpenCodeMessages( @@ -228,43 +229,44 @@ export async function consumeOpenCodeMessages( return } const messageDir = join(storageRoot, 'message', sessionId) + let entries try { - const directory = await opendir(messageDir) - for await (const entry of directory) { - if (!entry.isFile() || !entry.name.endsWith('.json')) { - continue - } - await withAiVaultWholeJsonFile(join(messageDir, entry.name), (content) => { - const message = parseJsonObject(content) - if (!message) { - return - } - const role = extractString(message.role) - if (role === 'user' || role === 'assistant') { - accumulator.messageCount++ - updateTimeline(accumulator, timeObjectValue(message.time, 'created')) - if (role === 'user') { - accumulator.title ??= extractString(asRecord(message.summary)?.title) - accumulator.title ??= extractString(asRecord(message.summary)?.body) - } - addPreviewMessage(accumulator, { - role, - text: - extractPreviewContentText(message.content) ?? - extractString(asRecord(message.summary)?.body) ?? - extractString(asRecord(message.summary)?.title), - timestamp: timeObjectValue(message.time, 'created') - }) - accumulator.model = - extractString(asRecord(message.model)?.modelID) || - extractString(message.modelID) || - accumulator.model - accumulator.totalTokens += tokenTotal(message.tokens) - } - }) - } + entries = await readdir(messageDir, { withFileTypes: true }) } catch { - // Missing or unreadable message directories contribute no messages. + return + } + for (const entry of entries) { + if (!entry.isFile() || !entry.name.endsWith('.json')) { + continue + } + const message = asRecord( + JSON.parse(await readFile(join(messageDir, entry.name), 'utf-8')) as unknown + ) + if (!message) { + continue + } + const role = extractString(message.role) + if (role === 'user' || role === 'assistant') { + accumulator.messageCount++ + updateTimeline(accumulator, timeObjectValue(message.time, 'created')) + if (role === 'user') { + accumulator.title ??= extractString(asRecord(message.summary)?.title) + accumulator.title ??= extractString(asRecord(message.summary)?.body) + } + addPreviewMessage(accumulator, { + role, + text: + extractPreviewContentText(message.content) ?? + extractString(asRecord(message.summary)?.body) ?? + extractString(asRecord(message.summary)?.title), + timestamp: timeObjectValue(message.time, 'created') + }) + accumulator.model = + extractString(asRecord(message.model)?.modelID) || + extractString(message.modelID) || + accumulator.model + accumulator.totalTokens += tokenTotal(message.tokens) + } } } @@ -272,18 +274,16 @@ export async function parseHermesSessionFile( file: FileWithMtime, platform: NodeJS.Platform = process.platform ): Promise { - return withAiVaultWholeJsonFile(file.path, (content) => - parseHermesSessionContent(file, content, platform) - ) + return parseHermesSessionContent(file, await readFile(file.path, 'utf-8'), platform) } export async function parseHermesSessionContent( file: FileWithMtime, content: string, platform: NodeJS.Platform = process.platform, - options: ResumableParseFinalizeOptions = {} + options: ParserSessionOptions = {} ): Promise { - const record = parseJsonObject(content) + const record = asRecord(JSON.parse(content) as unknown) if (!record) { return null } diff --git a/src/main/ai-vault/session-scanner-subagent-transcripts.ts b/src/main/ai-vault/session-scanner-subagent-transcripts.ts index bede82c0986..0477f9dc229 100644 --- a/src/main/ai-vault/session-scanner-subagent-transcripts.ts +++ b/src/main/ai-vault/session-scanner-subagent-transcripts.ts @@ -1,4 +1,4 @@ -import { opendir } from 'node:fs/promises' +import { readdir } from 'node:fs/promises' import { basename, dirname, extname, join } from 'node:path' // Exported so discovery can prune these subtrees using the same literal that @@ -36,23 +36,13 @@ export function subagentTranscriptsDirFor(transcriptFilePath: string): string { * transcripts and are excluded. */ export async function countSubagentTranscripts(transcriptFilePath: string): Promise { - let directory + let entries try { - directory = await opendir(subagentTranscriptsDirFor(transcriptFilePath)) + entries = await readdir(subagentTranscriptsDirFor(transcriptFilePath), { withFileTypes: true }) } catch { return 0 } - let count = 0 - try { - for await (const entry of directory) { - if (isSubagentTranscriptFileName(entry.name, entry.isFile())) { - count += 1 - } - } - } catch { - // A disappearing directory still has the successfully counted prefix. - } - return count + return entries.filter((entry) => isSubagentTranscriptFileName(entry.name, entry.isFile())).length } // Direct child of a subagents dir: `//subagents/agent-.jsonl`. diff --git a/src/main/ai-vault/session-scanner-types.ts b/src/main/ai-vault/session-scanner-types.ts index 91cd6c5c0db..f9af3f20897 100644 --- a/src/main/ai-vault/session-scanner-types.ts +++ b/src/main/ai-vault/session-scanner-types.ts @@ -87,8 +87,6 @@ export type ResumableParseFinalizeOptions = { export type ResumableSessionParseState = { consumeLine(line: string): void clone(): ResumableSessionParseState - // The cache budget must include fold state that persistence intentionally omits. - retainedUtf8Bytes(): number // Refresh per-scan file metadata (mtime display string) without re-parsing. touchFile(file: FileWithMtime): void finalize( diff --git a/src/main/ai-vault/session-scanner-values.test.ts b/src/main/ai-vault/session-scanner-values.test.ts index bb93c4681fc..a9954ae8d26 100644 --- a/src/main/ai-vault/session-scanner-values.test.ts +++ b/src/main/ai-vault/session-scanner-values.test.ts @@ -1,22 +1,12 @@ import { describe, expect, it, vi } from 'vitest' import { - AI_VAULT_JSON_STRUCTURE_LIMITS, extractPreviewContentText, normalizeAgentSessionsDir, normalizePreviewText, - normalizeTitleText, - parseJsonObject + normalizeTitleText } from './session-scanner-values' describe('AI Vault session scanner text values', () => { - it('rejects structurally amplified JSON before parsing', () => { - const parseSpy = vi.spyOn(JSON, 'parse') - const amplified = `[${'0,'.repeat(AI_VAULT_JSON_STRUCTURE_LIMITS.structuralTokens)}0]` - - expect(parseJsonObject(amplified)).toBeNull() - expect(parseSpy).not.toHaveBeenCalled() - }) - it('normalizes compact title text without surfacing hidden context blocks', () => { expect( normalizeTitleText( diff --git a/src/main/ai-vault/session-scanner-values.ts b/src/main/ai-vault/session-scanner-values.ts index d954c8d4a28..39006bd68a4 100644 --- a/src/main/ai-vault/session-scanner-values.ts +++ b/src/main/ai-vault/session-scanner-values.ts @@ -1,22 +1,6 @@ import { homedir } from 'node:os' import { basename, dirname, join } from 'node:path' -import { - assertJsonTextStructureWithinLimits, - type JsonTextStructureLimits -} from '../../shared/json-text-structure-limit' - -export const AI_VAULT_JSON_STRUCTURE_LIMITS: JsonTextStructureLimits = { - structuralTokens: 1_000_000, - nestingDepth: 256 -} - -export function parseAiVaultJsonText( - content: string, - limits: JsonTextStructureLimits = AI_VAULT_JSON_STRUCTURE_LIMITS -): unknown { - assertJsonTextStructureWithinLimits(content, limits) - return JSON.parse(content) as unknown -} +import { readFile } from 'node:fs/promises' export function timestampMs(value: unknown): number { if (typeof value === 'string') { @@ -34,7 +18,7 @@ export function parseJsonObject(line: string): Record | null { return null } try { - const parsed = parseAiVaultJsonText(line) + const parsed = JSON.parse(line) as unknown return asRecord(parsed) } catch { return null @@ -85,6 +69,16 @@ export function extractGitBranch(value: unknown): string | null { return extractString(git.branch) || extractString(git.current_branch) } +export async function readJsonObjectIfExists( + filePath: string +): Promise | null> { + try { + return asRecord(JSON.parse(await readFile(filePath, 'utf-8')) as unknown) + } catch { + return null + } +} + export function arrayValue(value: unknown): unknown[] { return Array.isArray(value) ? value : [] } diff --git a/src/main/ai-vault/session-scanner.ts b/src/main/ai-vault/session-scanner.ts index 99ead01f352..f1979c67626 100644 --- a/src/main/ai-vault/session-scanner.ts +++ b/src/main/ai-vault/session-scanner.ts @@ -1,3 +1,4 @@ +import { readFile } from 'node:fs/promises' import type { AiVaultListResult, AiVaultScanIssue, @@ -6,11 +7,6 @@ import type { import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../shared/execution-host' import { withSpan } from '../observability/tracer' import { sessionSortTime } from './session-scanner-accumulator' -import { - boundAiVaultListResult, - retainAiVaultSession, - retainAiVaultSessionsWithinAggregate -} from './session-list-retention' import { codexRolloutHardlinkIdentity, dedupeCodexRolloutFileAliases, @@ -43,7 +39,6 @@ import type { SessionParseResult } from './session-scanner-types' import { clampPositiveInteger, errorMessage } from './session-scanner-values' -import { withAiVaultWholeJsonFile } from './session-whole-json-reader' const DEFAULT_LIMIT = 1000 const DEFAULT_SCAN_LIMIT_PER_AGENT = 1000 @@ -143,11 +138,11 @@ export async function scanAiVaultSessions( scheduleSessionParseCachePersist(parseStats) - return boundAiVaultListResult({ + return { sessions: mergeSessions(cappedSessions, scopeSessions), issues: issues.map((issue) => ({ executionHostId, ...issue })), scannedAt: new Date().toISOString() - }) + } }) } @@ -255,17 +250,7 @@ async function parseSessionCandidates(args: { // Why: cross-volume backfill copies have no shared inode, so collapse // parsed aliases before they can crowd the unique-session parse budget. const uniqueSessions = dedupeCodexSessionsBySessionId(sessions) - const retained = retainAiVaultSessionsWithinAggregate(uniqueSessions) - sessions.splice(0, sessions.length, ...retained.sessions) - if (retained.omitted > 0) { - args.issues.push({ - executionHostId: args.executionHostId, - agent: 'codex', - path: 'AI Vault session list', - message: `AI Vault stopped after omitting ${retained.omitted} sessions at its memory limit.` - }) - break - } + sessions.splice(0, sessions.length, ...uniqueSessions) index += batchSize } @@ -285,9 +270,8 @@ async function parseSessionCandidate( if (session && candidate.antigravityHistoryPath && antigravityWorkspaceResolver) { session = await antigravityWorkspaceResolver.enrich(session, candidate.antigravityHistoryPath) } - const stamped = session ? withSessionExecutionHost(session, executionHostId) : null return { - session: stamped ? retainAiVaultSession(stamped) : null, + session: session ? withSessionExecutionHost(session, executionHostId) : null, issue: null } } catch (err) { @@ -305,7 +289,7 @@ async function parseSessionCandidate( async function readOptionalTextFile(path: string): Promise { try { - return await withAiVaultWholeJsonFile(path, (content) => content) + return await readFile(path, 'utf-8') } catch { return null } diff --git a/src/main/ai-vault/session-scope-cwd-cache.test.ts b/src/main/ai-vault/session-scope-cwd-cache.test.ts deleted file mode 100644 index 02eb96a9737..00000000000 --- a/src/main/ai-vault/session-scope-cwd-cache.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - AI_VAULT_SCOPE_CWD_CACHE_KEY_MAX_UTF8_BYTES, - AI_VAULT_SCOPE_CWD_CACHE_VALUE_MAX_UTF8_BYTES, - AiVaultScopeCwdCache -} from './session-scope-cwd-cache' - -describe('AI Vault scope cwd cache', () => { - it('rejects keys and values one byte over their UTF-8 limits', () => { - const cache = new AiVaultScopeCwdCache() - const exactKey = 'é'.repeat(AI_VAULT_SCOPE_CWD_CACHE_KEY_MAX_UTF8_BYTES / 2) - const exactValue = 'é'.repeat(AI_VAULT_SCOPE_CWD_CACHE_VALUE_MAX_UTF8_BYTES / 2) - - cache.set(exactKey, exactValue) - cache.set(`${exactKey}x`, '/overflow-key') - cache.set('/overflow-value', `${exactValue}x`) - - expect(cache.inspectForTests().keys).toEqual([exactKey]) - }) - - it('evicts least-recently-used entries at the aggregate byte boundary', () => { - const entryBytes = Buffer.byteLength('/a/repo', 'utf8') + Buffer.byteLength('/a', 'utf8') + 128 - const cache = new AiVaultScopeCwdCache({ maxEntries: 3, maxRetainedBytes: entryBytes * 2 }) - - cache.set('/a/repo', '/a') - cache.set('/b/repo', '/b') - expect(cache.get('/a/repo')).toBe('/a') - cache.set('/c/repo', '/c') - - expect(cache.inspectForTests().keys).toEqual(['/a/repo', '/c/repo']) - }) -}) diff --git a/src/main/ai-vault/session-scope-cwd-cache.ts b/src/main/ai-vault/session-scope-cwd-cache.ts deleted file mode 100644 index d562728ab9d..00000000000 --- a/src/main/ai-vault/session-scope-cwd-cache.ts +++ /dev/null @@ -1,77 +0,0 @@ -export const AI_VAULT_SCOPE_CWD_CACHE_MAX_ENTRIES = 2_048 -export const AI_VAULT_SCOPE_CWD_CACHE_KEY_MAX_UTF8_BYTES = 32 * 1024 -export const AI_VAULT_SCOPE_CWD_CACHE_VALUE_MAX_UTF8_BYTES = 128 * 1024 -export const AI_VAULT_SCOPE_CWD_CACHE_MAX_RETAINED_UTF8_BYTES = 8 * 1024 * 1024 - -const ENTRY_OVERHEAD_BYTES = 128 - -type RetainedCwd = { cwd: string; bytes: number } - -export class AiVaultScopeCwdCache { - private readonly entries = new Map() - private retainedBytes = 0 - - constructor( - private readonly limits: { - maxEntries: number - maxRetainedBytes: number - } = { - maxEntries: AI_VAULT_SCOPE_CWD_CACHE_MAX_ENTRIES, - maxRetainedBytes: AI_VAULT_SCOPE_CWD_CACHE_MAX_RETAINED_UTF8_BYTES - } - ) {} - - get(projectDir: string): string | undefined { - const retained = this.entries.get(projectDir) - if (!retained) { - return undefined - } - this.entries.delete(projectDir) - this.entries.set(projectDir, retained) - return retained.cwd - } - - set(projectDir: string, cwd: string): void { - this.delete(projectDir) - const keyBytes = Buffer.byteLength(projectDir, 'utf8') - const valueBytes = Buffer.byteLength(cwd, 'utf8') - const bytes = keyBytes + valueBytes + ENTRY_OVERHEAD_BYTES - if ( - keyBytes > AI_VAULT_SCOPE_CWD_CACHE_KEY_MAX_UTF8_BYTES || - valueBytes > AI_VAULT_SCOPE_CWD_CACHE_VALUE_MAX_UTF8_BYTES || - bytes > this.limits.maxRetainedBytes - ) { - return - } - while ( - this.entries.size >= this.limits.maxEntries || - this.retainedBytes + bytes > this.limits.maxRetainedBytes - ) { - const oldest = this.entries.keys().next().value - if (oldest === undefined) { - return - } - this.delete(oldest) - } - this.entries.set(projectDir, { cwd, bytes }) - this.retainedBytes += bytes - } - - clear(): void { - this.entries.clear() - this.retainedBytes = 0 - } - - inspectForTests(): { keys: string[]; retainedBytes: number } { - return { keys: [...this.entries.keys()], retainedBytes: this.retainedBytes } - } - - private delete(projectDir: string): void { - const retained = this.entries.get(projectDir) - if (!retained) { - return - } - this.entries.delete(projectDir) - this.retainedBytes -= retained.bytes - } -} diff --git a/src/main/ai-vault/session-whole-json-reader.test.ts b/src/main/ai-vault/session-whole-json-reader.test.ts deleted file mode 100644 index 1c524d988d3..00000000000 --- a/src/main/ai-vault/session-whole-json-reader.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { mkdtemp, open, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { describe, expect, it, vi } from 'vitest' -import { - AI_VAULT_WHOLE_JSON_MAX_BYTES, - AI_VAULT_JSON_READ_MAX_ACTIVE, - AI_VAULT_JSON_READ_MAX_WAITERS, - AiVaultJsonReadBudget, - withAiVaultWholeJsonFile -} from './session-whole-json-reader' - -describe('AI Vault whole-JSON read budget', () => { - it('queues aggregate bytes and releases admission idempotently', async () => { - const budget = new AiVaultJsonReadBudget(10) - const releaseSix = await budget.acquire(6) - let admitted = false - const second = budget.acquire(5).then((release) => { - admitted = true - return release - }) - - await Promise.resolve() - expect(admitted).toBe(false) - releaseSix() - const releaseFive = await second - expect(admitted).toBe(true) - - releaseSix() - releaseFive() - await expect(budget.acquire(10)).resolves.toEqual(expect.any(Function)) - }) - - it('rejects a single document larger than the process budget', () => { - const budget = new AiVaultJsonReadBudget(10) - - expect(() => budget.acquire(11)).toThrow('exceeds 10 byte limit') - }) - - it('bounds queued readers and admits them in FIFO order', async () => { - const budget = new AiVaultJsonReadBudget(1) - const releaseActive = await budget.acquire(1) - const queued = Array.from({ length: AI_VAULT_JSON_READ_MAX_WAITERS }, () => budget.acquire(1)) - - expect(() => budget.acquire(1)).toThrow('reader is busy') - releaseActive() - for (const pending of queued) { - const release = await pending - release() - } - await expect(budget.acquire(1)).resolves.toEqual(expect.any(Function)) - }) - - it('uses a fixed active-reader pool even when byte capacity remains', async () => { - const budget = new AiVaultJsonReadBudget(1024) - const releases = await Promise.all( - Array.from({ length: AI_VAULT_JSON_READ_MAX_ACTIVE }, () => budget.acquire(1)) - ) - let admitted = false - const queued = budget.acquire(1).then((release) => { - admitted = true - return release - }) - - await Promise.resolve() - expect(admitted).toBe(false) - releases[0]() - const releaseQueued = await queued - expect(admitted).toBe(true) - for (const release of releases.slice(1)) { - release() - } - releaseQueued() - }) - - it('rejects an oversized sparse file before reading or parsing it', async () => { - const directory = await mkdtemp(join(tmpdir(), 'orca-vault-whole-json-')) - const path = join(directory, 'oversized.json') - const handle = await open(path, 'w') - await handle.truncate(AI_VAULT_WHOLE_JSON_MAX_BYTES + 1) - await handle.close() - const consume = vi.fn() - - try { - await expect(withAiVaultWholeJsonFile(path, consume)).rejects.toThrow( - `exceeds ${AI_VAULT_WHOLE_JSON_MAX_BYTES} byte limit` - ) - expect(consume).not.toHaveBeenCalled() - } finally { - await rm(directory, { recursive: true }) - } - }) -}) diff --git a/src/main/ai-vault/session-whole-json-reader.ts b/src/main/ai-vault/session-whole-json-reader.ts deleted file mode 100644 index ab3c6c2d659..00000000000 --- a/src/main/ai-vault/session-whole-json-reader.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { stat } from 'node:fs/promises' -import { readNodeFileWithinLimit } from '../../shared/node-bounded-file-reader' - -export const AI_VAULT_WHOLE_JSON_MAX_BYTES = 64 * 1024 * 1024 -export const AI_VAULT_JSON_READ_MAX_ACTIVE = 8 -export const AI_VAULT_JSON_READ_MAX_WAITERS = 256 -const WAIT_QUEUE_COMPACTION_HEAD = 64 - -type AdmissionWaiter = { - bytes: number - resolve: (release: () => void) => void -} - -export class AiVaultJsonReadBudget { - private retainedBytes = 0 - private activeReaders = 0 - private waiters: (AdmissionWaiter | undefined)[] = [] - private waiterHead = 0 - - constructor(readonly maxBytes: number) { - if (!Number.isSafeInteger(maxBytes) || maxBytes < 0) { - throw new RangeError('AI Vault JSON read budget must be a non-negative safe integer') - } - } - - acquire(bytes: number): Promise<() => void> { - if (!Number.isSafeInteger(bytes) || bytes < 0 || bytes > this.maxBytes) { - throw new RangeError(`AI Vault JSON document exceeds ${this.maxBytes} byte limit`) - } - if (this.waiters.length - this.waiterHead >= AI_VAULT_JSON_READ_MAX_WAITERS) { - throw new Error('AI Vault JSON reader is busy; retry after active reads finish') - } - return new Promise((resolve) => { - this.waiters.push({ bytes, resolve }) - this.admitWaiters() - }) - } - - private admitWaiters(): void { - while (this.waiterHead < this.waiters.length) { - const waiter = this.waiters[this.waiterHead]! - if ( - this.activeReaders >= AI_VAULT_JSON_READ_MAX_ACTIVE || - waiter.bytes > this.maxBytes - this.retainedBytes - ) { - return - } - this.waiters[this.waiterHead] = undefined - this.waiterHead += 1 - if (this.waiterHead >= WAIT_QUEUE_COMPACTION_HEAD) { - this.waiters.splice(0, this.waiterHead) - this.waiterHead = 0 - } - this.retainedBytes += waiter.bytes - this.activeReaders += 1 - let released = false - waiter.resolve(() => { - if (released) { - return - } - released = true - this.retainedBytes -= waiter.bytes - this.activeReaders -= 1 - this.admitWaiters() - }) - } - this.waiters.length = 0 - this.waiterHead = 0 - } -} - -const jsonReadBudget = new AiVaultJsonReadBudget(AI_VAULT_WHOLE_JSON_MAX_BYTES) - -export async function withAiVaultWholeJsonFile( - filePath: string, - consume: (content: string) => T | Promise -): Promise { - const fileStats = await stat(filePath) - const release = await jsonReadBudget.acquire(fileStats.size) - try { - // Reject concurrent growth: the admission covers exactly the statted bytes. - const { buffer } = await readNodeFileWithinLimit(filePath, fileStats.size) - return await consume(buffer.toString('utf8')) - } finally { - release() - } -} diff --git a/src/main/amp/hook-service.test.ts b/src/main/amp/hook-service.test.ts index 9b902a1f459..5ed0f6c5e3e 100644 --- a/src/main/amp/hook-service.test.ts +++ b/src/main/amp/hook-service.test.ts @@ -2,7 +2,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' -import { GENERATED_NODE_MANAGED_FILE_MAX_BYTES } from '../generated-node-bounded-file-reader' const { homedirMock } = vi.hoisted(() => ({ homedirMock: vi.fn<() => string>() @@ -60,11 +59,6 @@ describe('AmpHookService', () => { expect(source).not.toContain('postQueue = postQueue.then') expect(source).toContain('process.env.ORCA_PANE_KEY') expect(source).toContain('process.env.ORCA_AGENT_HOOK_ENDPOINT') - expect(source).toContain( - `function readOrcaManagedFileWithinLimit(fs: any, path: string, maxBytes = ${GENERATED_NODE_MANAGED_FILE_MAX_BYTES})` - ) - expect(source).toContain('readOrcaManagedFileWithinLimit(fs, endpointPath)') - expect(source).not.toContain('readFileSync') }) it('does not overwrite an existing user-authored Amp plugin file', () => { diff --git a/src/main/amp/hook-service.ts b/src/main/amp/hook-service.ts index 45d2abd5449..83ed74d1665 100644 --- a/src/main/amp/hook-service.ts +++ b/src/main/amp/hook-service.ts @@ -3,18 +3,12 @@ emitted plugin bytes drift from the installer checks that protect user plugin files from being overwritten. */ import { randomUUID } from 'node:crypto' -import { existsSync, mkdirSync, renameSync, unlinkSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs' import { homedir } from 'node:os' import { dirname, join } from 'node:path' import type { SFTPWrapper } from 'ssh2' import type { AgentHookInstallStatus } from '../../shared/agent-hook-types' -import { - NodeFileReadTooLargeError, - readNodeFileSyncWithinLimit -} from '../../shared/node-bounded-file-reader' -import { getGeneratedNodeBoundedFileReaderSourceLines } from '../generated-node-bounded-file-reader' -import { AGENT_HOOK_PLUGIN_MAX_BYTES } from '../agent-hooks/agent-hook-file-limits' import { readTextFileRemote, writeTextFileRemoteAtomic @@ -59,10 +53,7 @@ function readLocalPluginState(pluginPath: string): PluginFileState { return { kind: 'absent' } } try { - const content = readNodeFileSyncWithinLimit( - pluginPath, - AGENT_HOOK_PLUGIN_MAX_BYTES - ).buffer.toString('utf8') + const content = readFileSync(pluginPath, 'utf-8') if (!isManagedPlugin(content)) { return { kind: 'unmanaged' } } @@ -114,17 +105,10 @@ function writeTextFileAtomic(filePath: string, content: string): void { mkdirSync(dir, { recursive: true }) if (existsSync(filePath)) { try { - const existing = readNodeFileSyncWithinLimit( - filePath, - AGENT_HOOK_PLUGIN_MAX_BYTES - ).buffer.toString('utf8') - if (existing === content) { + if (readFileSync(filePath, 'utf-8') === content) { return } - } catch (error) { - if (error instanceof NodeFileReadTooLargeError) { - throw error - } + } catch { // Fall through to the atomic write path. } } @@ -146,14 +130,12 @@ function writeTextFileAtomic(filePath: string, content: string): void { function getAmpPluginSource(): string { return [ - "import * as fs from 'fs'", + "import { readFileSync, statSync } from 'fs'", "import type { PluginAPI } from '@ampcode/plugin'", '', `// ${AMP_PLUGIN_MARKER}`, 'type HookCoords = { port?: string; token?: string; env?: string; version?: string }', '', - ...getGeneratedNodeBoundedFileReaderSourceLines({ typed: true }), - '', 'let warnedBadEndpoint = false', "let cachedEndpointKey = ''", 'let cachedEndpointValues: HookCoords | null = null', @@ -162,12 +144,12 @@ function getAmpPluginSource(): string { ' const endpointPath = process.env.ORCA_AGENT_HOOK_ENDPOINT', ' if (!endpointPath) return null', ' try {', - ' const stat = fs.statSync(endpointPath)', + ' const stat = statSync(endpointPath)', ' const cacheKey = `${stat.mtimeMs}:${stat.size}:${stat.ino}`', ' if (cacheKey === cachedEndpointKey && cachedEndpointValues) {', ' return cachedEndpointValues', ' }', - ' const contents = readOrcaManagedFileWithinLimit(fs, endpointPath)', + " const contents = readFileSync(endpointPath, 'utf8')", ' const out: HookCoords = {}', ' for (const line of contents.split(/\\r?\\n/)) {', ' const match = line.match(/^(?:set\\s+)?([A-Z0-9_]+)=(.*)$/)', @@ -364,16 +346,11 @@ export class AmpHookService { async installRemote(sftp: SFTPWrapper, remoteHome: string): Promise { const remotePluginPath = getRemotePluginPath(remoteHome) try { - const existing = await readTextFileRemote(sftp, remotePluginPath, AGENT_HOOK_PLUGIN_MAX_BYTES) + const existing = await readTextFileRemote(sftp, remotePluginPath) if (existing !== null && !isManagedPlugin(existing)) { return statusFromState(remotePluginPath, { kind: 'unmanaged' }) } - await writeTextFileRemoteAtomic( - sftp, - remotePluginPath, - getAmpPluginSource(), - AGENT_HOOK_PLUGIN_MAX_BYTES - ) + await writeTextFileRemoteAtomic(sftp, remotePluginPath, getAmpPluginSource()) return { agent: 'amp', state: 'installed', diff --git a/src/main/attribution/terminal-attribution-bounds.test.ts b/src/main/attribution/terminal-attribution-bounds.test.ts deleted file mode 100644 index b357565eaad..00000000000 --- a/src/main/attribution/terminal-attribution-bounds.test.ts +++ /dev/null @@ -1,178 +0,0 @@ -import { spawnSync } from 'node:child_process' -import { - chmodSync, - existsSync, - mkdtempSync, - mkdirSync, - readFileSync, - rmSync, - truncateSync, - writeFileSync -} from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import { - ATTRIBUTION_BOUND_EXCEEDED_EXIT_CODE, - ATTRIBUTION_COMMAND_OUTPUT_MAX_BYTES, - ATTRIBUTION_COMMIT_MESSAGE_MAX_BYTES, - applyTerminalAttributionEnv -} from './terminal-attribution' - -const roots: string[] = [] -const posixIt = process.platform === 'win32' ? it.skip : it - -function makeRoot(): string { - const root = mkdtempSync(join(tmpdir(), 'orca-attribution-bounds-')) - roots.push(root) - return root -} - -function attributionEnv(root: string, binDir: string): Record { - const env = { ...process.env, PATH: `${binDir}:${process.env.PATH ?? ''}` } - applyTerminalAttributionEnv(env as Record, { - enabled: true, - userDataPath: join(root, 'user-data') - }) - return env as Record -} - -afterEach(() => { - for (const root of roots.splice(0)) { - rmSync(root, { recursive: true, force: true }) - } -}) - -describe('terminal attribution payload bounds', () => { - posixIt('accepts a commit message file at the exact byte boundary', () => { - const root = makeRoot() - const binDir = join(root, 'bin') - const messagePath = join(root, 'message.txt') - const receivedPath = join(root, 'received-message.txt') - mkdirSync(binDir) - writeFileSync(messagePath, 'm'.repeat(ATTRIBUTION_COMMIT_MESSAGE_MAX_BYTES)) - writeFileSync( - join(binDir, 'git'), - `#!/usr/bin/env bash -set -euo pipefail -while [[ $# -gt 0 ]]; do - if [[ "$1" == "-F" || "$1" == "--file" ]]; then - cp "$2" "${receivedPath}" - exit 0 - fi - shift -done -exit 2 -` - ) - chmodSync(join(binDir, 'git'), 0o755) - - const result = spawnSync('git', ['commit', '-F', messagePath], { - encoding: 'utf8', - env: attributionEnv(root, binDir) - }) - - expect(result.status).toBe(0) - expect(readFileSync(receivedPath, 'utf8')).toBe( - `${'m'.repeat(ATTRIBUTION_COMMIT_MESSAGE_MAX_BYTES)}\n\nCo-authored-by: Orca \n` - ) - }) - - posixIt('rejects a sparse commit message file one byte over the boundary', () => { - const root = makeRoot() - const binDir = join(root, 'bin') - const messagePath = join(root, 'message.txt') - const calledPath = join(root, 'git-called') - mkdirSync(binDir) - writeFileSync(messagePath, 'm') - truncateSync(messagePath, ATTRIBUTION_COMMIT_MESSAGE_MAX_BYTES + 1) - writeFileSync( - join(binDir, 'git'), - `#!/usr/bin/env bash -touch "${calledPath}" -exit 0 -` - ) - chmodSync(join(binDir, 'git'), 0o755) - - const result = spawnSync('git', ['commit', '-F', messagePath], { - encoding: 'utf8', - env: attributionEnv(root, binDir) - }) - - expect(result.status).toBe(ATTRIBUTION_BOUND_EXCEEDED_EXIT_CODE) - expect(result.stderr).toContain( - `${ATTRIBUTION_COMMIT_MESSAGE_MAX_BYTES + 1} bytes exceeds the ${ATTRIBUTION_COMMIT_MESSAGE_MAX_BYTES}-byte limit` - ) - expect(existsSync(calledPath)).toBe(false) - }) - - posixIt('preserves gh output at the exact byte boundary', () => { - const root = makeRoot() - const binDir = join(root, 'bin') - const payloadPath = join(root, 'gh-output.txt') - mkdirSync(binDir) - writeFileSync(payloadPath, 'o'.repeat(ATTRIBUTION_COMMAND_OUTPUT_MAX_BYTES)) - writeFileSync( - join(binDir, 'gh'), - `#!/usr/bin/env bash -cat "${payloadPath}" -` - ) - chmodSync(join(binDir, 'gh'), 0o755) - - const result = spawnSync('gh', ['pr', 'create', '--fill'], { - encoding: 'utf8', - env: attributionEnv(root, binDir), - maxBuffer: ATTRIBUTION_COMMAND_OUTPUT_MAX_BYTES + 64 * 1024 - }) - - expect(result.status).toBe(0) - expect(result.stdout).toBe('o'.repeat(ATTRIBUTION_COMMAND_OUTPUT_MAX_BYTES)) - expect(result.stderr).toBe('') - }) - - posixIt('fails clearly when gh output is one byte over the boundary', () => { - const root = makeRoot() - const binDir = join(root, 'bin') - const payloadPath = join(root, 'gh-output.txt') - mkdirSync(binDir) - writeFileSync(payloadPath, 'o'.repeat(ATTRIBUTION_COMMAND_OUTPUT_MAX_BYTES + 1)) - writeFileSync( - join(binDir, 'gh'), - `#!/usr/bin/env bash -cat "${payloadPath}" -` - ) - chmodSync(join(binDir, 'gh'), 0o755) - - const result = spawnSync('gh', ['pr', 'create', '--fill'], { - encoding: 'utf8', - env: attributionEnv(root, binDir) - }) - - expect(result.status).toBe(ATTRIBUTION_BOUND_EXCEEDED_EXIT_CODE) - expect(result.stdout).toBe('') - expect(result.stderr).toContain( - `${ATTRIBUTION_COMMAND_OUTPUT_MAX_BYTES + 1} bytes exceeds the ${ATTRIBUTION_COMMAND_OUTPUT_MAX_BYTES}-byte limit` - ) - }) - - it('emits inclusive PowerShell byte boundaries for commit files and gh captures', () => { - const root = makeRoot() - applyTerminalAttributionEnv( - { PATH: process.env.PATH ?? '' }, - { enabled: true, userDataPath: join(root, 'user-data') } - ) - const shimDir = join(root, 'user-data', 'orca-terminal-attribution', 'win32') - const gitWrapper = readFileSync(join(shimDir, 'git-wrapper.ps1'), 'utf8') - const ghWrapper = readFileSync(join(shimDir, 'gh-wrapper.ps1'), 'utf8') - - expect(gitWrapper).toContain('$guard.Length -gt $MaxBytes') - expect(gitWrapper).toContain(`'commit message file' ${ATTRIBUTION_COMMIT_MESSAGE_MAX_BYTES}`) - expect(gitWrapper).toContain(`exit ${ATTRIBUTION_BOUND_EXCEEDED_EXIT_CODE}`) - expect(ghWrapper).toContain(`$stdoutBytes -gt ${ATTRIBUTION_COMMAND_OUTPUT_MAX_BYTES}`) - expect(ghWrapper).toContain(`$stderrBytes -gt ${ATTRIBUTION_COMMAND_OUTPUT_MAX_BYTES}`) - expect(ghWrapper).toContain(`exit ${ATTRIBUTION_BOUND_EXCEEDED_EXIT_CODE}`) - }) -}) diff --git a/src/main/attribution/terminal-attribution-retention.test.ts b/src/main/attribution/terminal-attribution-retention.test.ts deleted file mode 100644 index f127e29f55e..00000000000 --- a/src/main/attribution/terminal-attribution-retention.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { mkdtempSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import { - _getAttributionWrittenRootCountForTests, - _isAttributionWrittenRootRetainableForTests, - _resetAttributionWrittenRootsForTests, - applyTerminalAttributionEnv, - ATTRIBUTION_WRITTEN_ROOT_MAX_BYTES, - ATTRIBUTION_WRITTEN_ROOT_MAX_ENTRIES -} from './terminal-attribution' - -let fixtureRoot: string | null = null - -afterEach(() => { - _resetAttributionWrittenRootsForTests() - if (fixtureRoot) { - rmSync(fixtureRoot, { recursive: true, force: true }) - fixtureRoot = null - } -}) - -describe('terminal attribution root retention', () => { - it('bounds successfully ensured roots', () => { - fixtureRoot = mkdtempSync(join(tmpdir(), 'orca-attribution-roots-')) - for (let index = 0; index <= ATTRIBUTION_WRITTEN_ROOT_MAX_ENTRIES; index += 1) { - applyTerminalAttributionEnv( - { PATH: process.env.PATH ?? '' }, - { enabled: true, userDataPath: join(fixtureRoot, String(index)) } - ) - } - expect(_getAttributionWrittenRootCountForTests()).toBe(ATTRIBUTION_WRITTEN_ROOT_MAX_ENTRIES) - }) - - it('measures retained roots by UTF-8 bytes', () => { - expect( - _isAttributionWrittenRootRetainableForTests('x'.repeat(ATTRIBUTION_WRITTEN_ROOT_MAX_BYTES)) - ).toBe(true) - expect( - _isAttributionWrittenRootRetainableForTests( - '😀'.repeat(ATTRIBUTION_WRITTEN_ROOT_MAX_BYTES / 4 + 1) - ) - ).toBe(false) - }) -}) diff --git a/src/main/attribution/terminal-attribution.test.ts b/src/main/attribution/terminal-attribution.test.ts index f981762e06a..86096168f0c 100644 --- a/src/main/attribution/terminal-attribution.test.ts +++ b/src/main/attribution/terminal-attribution.test.ts @@ -6,18 +6,12 @@ import { mkdtempSync, readFileSync, rmSync, - statSync, - truncateSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { - ATTRIBUTION_SHIM_VERSION_MAX_BYTES, - applyTerminalAttributionEnv, - resolveAttributionShellFamily -} from './terminal-attribution' +import { applyTerminalAttributionEnv, resolveAttributionShellFamily } from './terminal-attribution' describe('applyTerminalAttributionEnv', () => { let tmpRoot: string | null = null @@ -87,42 +81,6 @@ describe('applyTerminalAttributionEnv', () => { ) }) - it('accepts an attribution version marker at the exact byte boundary', () => { - const root = makeTmpRoot() - const userDataPath = join(root, 'user-data') - const shimRoot = join(userDataPath, 'orca-terminal-attribution') - const sentinelPath = join(shimRoot, 'posix', 'git') - mkdirSync(join(shimRoot, 'posix'), { recursive: true }) - mkdirSync(join(shimRoot, 'win32'), { recursive: true }) - writeFileSync(sentinelPath, 'leave-existing-wrapper') - writeFileSync( - join(shimRoot, 'VERSION'), - `7${' '.repeat(ATTRIBUTION_SHIM_VERSION_MAX_BYTES - 1)}` - ) - - applyTerminalAttributionEnv({}, { enabled: true, userDataPath }) - - expect(readFileSync(sentinelPath, 'utf8')).toBe('leave-existing-wrapper') - expect(statSync(join(shimRoot, 'VERSION')).size).toBe(ATTRIBUTION_SHIM_VERSION_MAX_BYTES) - }) - - it('rebuilds attribution shims for a sparse version marker over the boundary', () => { - const root = makeTmpRoot() - const userDataPath = join(root, 'user-data') - const shimRoot = join(userDataPath, 'orca-terminal-attribution') - const versionPath = join(shimRoot, 'VERSION') - mkdirSync(shimRoot, { recursive: true }) - writeFileSync(versionPath, '7') - truncateSync(versionPath, ATTRIBUTION_SHIM_VERSION_MAX_BYTES + 1) - - applyTerminalAttributionEnv({}, { enabled: true, userDataPath }) - - expect(readFileSync(versionPath, 'utf8')).toBe('7\n') - expect(readFileSync(join(shimRoot, 'posix', 'git'), 'utf8')).toContain( - 'ORCA_GIT_COMMIT_TRAILER' - ) - }) - posixSubprocessIt('does not amend HEAD when git commit --dry-run exits successfully', () => { const root = makeTmpRoot() const repo = join(root, 'repo') diff --git a/src/main/attribution/terminal-attribution.ts b/src/main/attribution/terminal-attribution.ts index ba2b54b07f4..d1c4dd099fe 100644 --- a/src/main/attribution/terminal-attribution.ts +++ b/src/main/attribution/terminal-attribution.ts @@ -2,18 +2,12 @@ scripts for both POSIX shells and Windows shells. Keeping the scripts adjacent to the env injection code makes the attribution behavior auditable as one unit instead of scattering generated shell fragments across files. */ -import { chmodSync, existsSync, mkdirSync, writeFileSync } from 'node:fs' +import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { join, win32 as pathWin32 } from 'node:path' import { ORCA_GIT_COMMIT_TRAILER } from '../../shared/orca-attribution' -import { readNodeFileSyncWithinLimit } from '../../shared/node-bounded-file-reader' -import { measureUtf8ByteLength } from '../../shared/utf8-byte-limits' const ATTRIBUTION_ROOT_DIR = 'orca-terminal-attribution' -const ATTRIBUTION_SHIM_VERSION = '7' -export const ATTRIBUTION_SHIM_VERSION_MAX_BYTES = 1024 -export const ATTRIBUTION_COMMIT_MESSAGE_MAX_BYTES = 1024 * 1024 -export const ATTRIBUTION_COMMAND_OUTPUT_MAX_BYTES = 1024 * 1024 -export const ATTRIBUTION_BOUND_EXCEEDED_EXIT_CODE = 74 +const ATTRIBUTION_SHIM_VERSION = '6' const ORCA_PRODUCT_URL = 'https://github.com/stablyai/orca' const ORCA_GH_FOOTER = `Made with [Orca](${ORCA_PRODUCT_URL}) 🐋` const SHELL_DOLLAR = '$' @@ -29,38 +23,6 @@ const ATTRIBUTION_ENV_KEYS = [ ] as const const writtenRoots = new Set() -export const ATTRIBUTION_WRITTEN_ROOT_MAX_ENTRIES = 64 -export const ATTRIBUTION_WRITTEN_ROOT_MAX_BYTES = 64 * 1024 - -function hasWrittenRoot(rootDir: string): boolean { - if (!writtenRoots.has(rootDir)) { - return false - } - writtenRoots.delete(rootDir) - writtenRoots.add(rootDir) - return true -} - -function rememberWrittenRoot(rootDir: string): void { - if (!isRetainableWrittenRoot(rootDir)) { - return - } - writtenRoots.delete(rootDir) - writtenRoots.add(rootDir) - while (writtenRoots.size > ATTRIBUTION_WRITTEN_ROOT_MAX_ENTRIES) { - const oldest = writtenRoots.values().next().value - if (oldest === undefined) { - return - } - writtenRoots.delete(oldest) - } -} - -function isRetainableWrittenRoot(rootDir: string): boolean { - return !measureUtf8ByteLength(rootDir, { - stopAfterBytes: ATTRIBUTION_WRITTEN_ROOT_MAX_BYTES - }).exceededLimit -} type AttributionShimPaths = { posixDir: string @@ -193,12 +155,12 @@ function ensureAttributionShims(userDataPath: string): AttributionShimPaths { const win32Dir = join(rootDir, 'win32') const versionFile = join(rootDir, 'VERSION') - if (hasWrittenRoot(rootDir)) { + if (writtenRoots.has(rootDir)) { return { posixDir, win32Dir } } if (readShimVersion(versionFile) === ATTRIBUTION_SHIM_VERSION) { - rememberWrittenRoot(rootDir) + writtenRoots.add(rootDir) return { posixDir, win32Dir } } @@ -214,28 +176,14 @@ function ensureAttributionShims(userDataPath: string): AttributionShimPaths { writeExecutable(join(win32Dir, 'gh-wrapper.ps1'), WIN32_GH_PS_WRAPPER) writeFileSync(versionFile, `${ATTRIBUTION_SHIM_VERSION}\n`, 'utf8') - rememberWrittenRoot(rootDir) + writtenRoots.add(rootDir) return { posixDir, win32Dir } } -export function _resetAttributionWrittenRootsForTests(): void { - writtenRoots.clear() -} - -export function _getAttributionWrittenRootCountForTests(): number { - return writtenRoots.size -} - -export function _isAttributionWrittenRootRetainableForTests(rootDir: string): boolean { - return isRetainableWrittenRoot(rootDir) -} - function readShimVersion(versionFile: string): string | null { try { - return readNodeFileSyncWithinLimit(versionFile, ATTRIBUTION_SHIM_VERSION_MAX_BYTES) - .buffer.toString('utf8') - .trim() + return readFileSync(versionFile, 'utf8').trim() } catch { return null } @@ -398,60 +346,6 @@ has_unsupported_commit_message_source() { return 1 } -validate_commit_message_file_sizes() { - local arg source_file byte_count - while [[ $# -gt 0 ]]; do - arg="$1" - source_file="" - case "$arg" in - -F|--file) - shift - source_file="${SHELL_DOLLAR}{1:-}" - ;; - --file=*) - source_file="${SHELL_DOLLAR}{arg#--file=}" - ;; - -F?*) - source_file="${SHELL_DOLLAR}{arg:2}" - ;; - esac - if [[ -n "$source_file" && -f "$source_file" ]]; then - if ! byte_count="$( - LC_ALL=C head -c ${ATTRIBUTION_COMMIT_MESSAGE_MAX_BYTES + 1} <"$source_file" | - LC_ALL=C wc -c - )"; then - printf 'Orca attribution wrapper could not inspect commit message file %q.\n' "$source_file" >&2 - return 1 - fi - if (( byte_count > ${ATTRIBUTION_COMMIT_MESSAGE_MAX_BYTES} )); then - printf 'Orca attribution wrapper refused commit message file %q: %s bytes exceeds the ${ATTRIBUTION_COMMIT_MESSAGE_MAX_BYTES}-byte limit.\n' "$source_file" "$byte_count" >&2 - return 1 - fi - fi - shift - done - return 0 -} - -read_bounded_commit_message() { - local source_file="$1" - local snapshot byte_count - snapshot="$(mktemp)" - if ! LC_ALL=C head -c ${ATTRIBUTION_COMMIT_MESSAGE_MAX_BYTES + 1} <"$source_file" >"$snapshot"; then - rm -f "$snapshot" - printf 'Orca attribution wrapper could not read commit message file %q.\n' "$source_file" >&2 - return 1 - fi - byte_count="$(LC_ALL=C wc -c <"$snapshot")" - if (( byte_count > ${ATTRIBUTION_COMMIT_MESSAGE_MAX_BYTES} )); then - rm -f "$snapshot" - printf 'Orca attribution wrapper refused commit message file %q: more than ${ATTRIBUTION_COMMIT_MESSAGE_MAX_BYTES} bytes.\n' "$source_file" >&2 - return 1 - fi - cat "$snapshot" - rm -f "$snapshot" -} - message_already_has_trailer() { local arg next_arg while [[ $# -gt 0 ]]; do @@ -492,13 +386,7 @@ message_already_has_trailer() { return 1 } -if ! has_explicit_commit_message "$@" || has_unsupported_commit_message_source "$@"; then - PATH="$real_path" exec "$real_git" "$@" -fi -if ! validate_commit_message_file_sizes "$@"; then - exit ${ATTRIBUTION_BOUND_EXCEEDED_EXIT_CODE} -fi -if message_already_has_trailer "$@"; then +if ! has_explicit_commit_message "$@" || has_unsupported_commit_message_source "$@" || message_already_has_trailer "$@"; then PATH="$real_path" exec "$real_git" "$@" fi @@ -521,10 +409,7 @@ while [[ $# -gt 0 ]]; do source_file="${SHELL_DOLLAR}{1:-}" tmp_file="$(mktemp)" if [[ -n "$source_file" && -f "$source_file" ]]; then - if ! source_message="$(read_bounded_commit_message "$source_file")"; then - exit ${ATTRIBUTION_BOUND_EXCEEDED_EXIT_CODE} - fi - printf '%s\n\n%s\n' "$source_message" "$trailer" >"$tmp_file" + printf '%s\n\n%s\n' "$(cat "$source_file")" "$trailer" >"$tmp_file" attributed_args+=("$arg" "$tmp_file") replaced_file_message=1 else @@ -539,10 +424,7 @@ while [[ $# -gt 0 ]]; do source_file="${SHELL_DOLLAR}{arg#--file=}" tmp_file="$(mktemp)" if [[ -f "$source_file" ]]; then - if ! source_message="$(read_bounded_commit_message "$source_file")"; then - exit ${ATTRIBUTION_BOUND_EXCEEDED_EXIT_CODE} - fi - printf '%s\n\n%s\n' "$source_message" "$trailer" >"$tmp_file" + printf '%s\n\n%s\n' "$(cat "$source_file")" "$trailer" >"$tmp_file" attributed_args+=("--file=$tmp_file") replaced_file_message=1 else @@ -557,10 +439,7 @@ while [[ $# -gt 0 ]]; do source_file="${SHELL_DOLLAR}{arg:2}" tmp_file="$(mktemp)" if [[ -f "$source_file" ]]; then - if ! source_message="$(read_bounded_commit_message "$source_file")"; then - exit ${ATTRIBUTION_BOUND_EXCEEDED_EXIT_CODE} - fi - printf '%s\n\n%s\n' "$source_message" "$trailer" >"$tmp_file" + printf '%s\n\n%s\n' "$(cat "$source_file")" "$trailer" >"$tmp_file" attributed_args+=("-F$tmp_file") replaced_file_message=1 else @@ -595,21 +474,6 @@ if [[ -z "$real_gh" ]]; then exit 127 fi -read_bounded_capture() { - local capture_path="$1" - local stream_name="$2" - local byte_count - if ! byte_count="$(LC_ALL=C wc -c <"$capture_path")"; then - printf 'Orca attribution wrapper could not inspect gh %s capture.\n' "$stream_name" >&2 - return 1 - fi - if (( byte_count > ${ATTRIBUTION_COMMAND_OUTPUT_MAX_BYTES} )); then - printf 'Orca attribution wrapper refused gh %s capture: %s bytes exceeds the ${ATTRIBUTION_COMMAND_OUTPUT_MAX_BYTES}-byte limit.\n' "$stream_name" "$byte_count" >&2 - return 1 - fi - cat "$capture_path" -} - append_footer() { local kind="$1" local url_pattern="$2" @@ -723,15 +587,8 @@ if [[ "\${1:-}" == "pr" && "\${2:-}" == "create" ]]; then else status=$? fi - capture_overflow=0 - stdout_capture="$(read_bounded_capture "$stdout_file" stdout)" || capture_overflow=1 - stderr_capture="$(read_bounded_capture "$stderr_file" stderr)" || capture_overflow=1 - if [[ $capture_overflow -ne 0 ]]; then - cleanup_capture - trap - EXIT - [[ $status -ne 0 ]] && exit $status - exit ${ATTRIBUTION_BOUND_EXCEEDED_EXIT_CODE} - fi + stdout_capture="$(cat "$stdout_file")" + stderr_capture="$(cat "$stderr_file")" cat "$stderr_file" >&2 cat "$stdout_file" if [[ $status -eq 0 ]]; then @@ -764,15 +621,8 @@ if [[ "\${1:-}" == "issue" && "\${2:-}" == "create" ]]; then else status=$? fi - capture_overflow=0 - stdout_capture="$(read_bounded_capture "$stdout_file" stdout)" || capture_overflow=1 - stderr_capture="$(read_bounded_capture "$stderr_file" stderr)" || capture_overflow=1 - if [[ $capture_overflow -ne 0 ]]; then - cleanup_capture - trap - EXIT - [[ $status -ne 0 ]] && exit $status - exit ${ATTRIBUTION_BOUND_EXCEEDED_EXIT_CODE} - fi + stdout_capture="$(cat "$stdout_file")" + stderr_capture="$(cat "$stderr_file")" cat "$stderr_file" >&2 cat "$stdout_file" if [[ $status -eq 0 ]]; then @@ -852,23 +702,6 @@ const WIN32_GIT_PS_WRAPPER = String.raw`$ErrorActionPreference = 'Stop' $realGit = if ($env:ORCA_REAL_GIT) { $env:ORCA_REAL_GIT } else { 'git' } $trailer = if ($env:ORCA_GIT_COMMIT_TRAILER) { $env:ORCA_GIT_COMMIT_TRAILER } else { 'Co-authored-by: Orca ' } -function Get-OrcaBoundedFileContent { - param([string]$Path, [string]$Description, [long]$MaxBytes) - $guard = $null - try { - $guard = [System.IO.File]::Open($Path, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::Read) - if ($guard.Length -gt $MaxBytes) { - [Console]::Error.WriteLine("Orca attribution wrapper refused $($Description): $($guard.Length) bytes exceeds the $MaxBytes-byte limit.") - exit ${ATTRIBUTION_BOUND_EXCEEDED_EXIT_CODE} - } - return (Get-Content -LiteralPath $Path -Raw) - } finally { - if ($null -ne $guard) { - $guard.Dispose() - } - } -} - if ($args -contains '--dry-run') { & $realGit @args exit $LASTEXITCODE @@ -974,17 +807,17 @@ function Test-CommitMessageHasTrailer { } } elseif ($arg -eq '-F' -or $arg -eq '--file') { $i++ - if ($i -lt $CommandArgs.Count -and (Test-Path -LiteralPath $CommandArgs[$i]) -and (Get-OrcaBoundedFileContent $CommandArgs[$i] 'commit message file' ${ATTRIBUTION_COMMIT_MESSAGE_MAX_BYTES}) -match [Regex]::Escape($trailer)) { + if ($i -lt $CommandArgs.Count -and (Test-Path -LiteralPath $CommandArgs[$i]) -and (Get-Content -LiteralPath $CommandArgs[$i] -Raw) -match [Regex]::Escape($trailer)) { return $true } } elseif ($arg.StartsWith('--file=')) { $path = $arg.Substring('--file='.Length) - if ((Test-Path -LiteralPath $path) -and (Get-OrcaBoundedFileContent $path 'commit message file' ${ATTRIBUTION_COMMIT_MESSAGE_MAX_BYTES}) -match [Regex]::Escape($trailer)) { + if ((Test-Path -LiteralPath $path) -and (Get-Content -LiteralPath $path -Raw) -match [Regex]::Escape($trailer)) { return $true } } elseif ($arg.StartsWith('-F') -and $arg.Length -gt 2) { $path = $arg.Substring(2) - if ((Test-Path -LiteralPath $path) -and (Get-OrcaBoundedFileContent $path 'commit message file' ${ATTRIBUTION_COMMIT_MESSAGE_MAX_BYTES}) -match [Regex]::Escape($trailer)) { + if ((Test-Path -LiteralPath $path) -and (Get-Content -LiteralPath $path -Raw) -match [Regex]::Escape($trailer)) { return $true } } @@ -1007,7 +840,7 @@ for ($i = 0; $i -lt $args.Count; $i++) { $sourceFile = if ($i -lt $args.Count) { $args[$i] } else { '' } if ($sourceFile -and (Test-Path -LiteralPath $sourceFile)) { $tmpFile = [System.IO.Path]::GetTempFileName() - Set-Content -LiteralPath $tmpFile -Value ((Get-OrcaBoundedFileContent $sourceFile 'commit message file' ${ATTRIBUTION_COMMIT_MESSAGE_MAX_BYTES}).TrimEnd("${POWERSHELL_TICK}r", "${POWERSHELL_TICK}n") + "${POWERSHELL_TICK}r${POWERSHELL_TICK}n${POWERSHELL_TICK}r${POWERSHELL_TICK}n" + $trailer) -NoNewline + Set-Content -LiteralPath $tmpFile -Value ((Get-Content -LiteralPath $sourceFile -Raw).TrimEnd("${POWERSHELL_TICK}r", "${POWERSHELL_TICK}n") + "${POWERSHELL_TICK}r${POWERSHELL_TICK}n${POWERSHELL_TICK}r${POWERSHELL_TICK}n" + $trailer) -NoNewline $attributedArgs.Add($arg) $attributedArgs.Add($tmpFile) $replacedFileMessage = $true @@ -1019,7 +852,7 @@ for ($i = 0; $i -lt $args.Count; $i++) { $sourceFile = $arg.Substring('--file='.Length) if (Test-Path -LiteralPath $sourceFile) { $tmpFile = [System.IO.Path]::GetTempFileName() - Set-Content -LiteralPath $tmpFile -Value ((Get-OrcaBoundedFileContent $sourceFile 'commit message file' ${ATTRIBUTION_COMMIT_MESSAGE_MAX_BYTES}).TrimEnd("${POWERSHELL_TICK}r", "${POWERSHELL_TICK}n") + "${POWERSHELL_TICK}r${POWERSHELL_TICK}n${POWERSHELL_TICK}r${POWERSHELL_TICK}n" + $trailer) -NoNewline + Set-Content -LiteralPath $tmpFile -Value ((Get-Content -LiteralPath $sourceFile -Raw).TrimEnd("${POWERSHELL_TICK}r", "${POWERSHELL_TICK}n") + "${POWERSHELL_TICK}r${POWERSHELL_TICK}n${POWERSHELL_TICK}r${POWERSHELL_TICK}n" + $trailer) -NoNewline $attributedArgs.Add("--file=$tmpFile") $replacedFileMessage = $true } else { @@ -1029,7 +862,7 @@ for ($i = 0; $i -lt $args.Count; $i++) { $sourceFile = $arg.Substring(2) if (Test-Path -LiteralPath $sourceFile) { $tmpFile = [System.IO.Path]::GetTempFileName() - Set-Content -LiteralPath $tmpFile -Value ((Get-OrcaBoundedFileContent $sourceFile 'commit message file' ${ATTRIBUTION_COMMIT_MESSAGE_MAX_BYTES}).TrimEnd("${POWERSHELL_TICK}r", "${POWERSHELL_TICK}n") + "${POWERSHELL_TICK}r${POWERSHELL_TICK}n${POWERSHELL_TICK}r${POWERSHELL_TICK}n" + $trailer) -NoNewline + Set-Content -LiteralPath $tmpFile -Value ((Get-Content -LiteralPath $sourceFile -Raw).TrimEnd("${POWERSHELL_TICK}r", "${POWERSHELL_TICK}n") + "${POWERSHELL_TICK}r${POWERSHELL_TICK}n${POWERSHELL_TICK}r${POWERSHELL_TICK}n" + $trailer) -NoNewline $attributedArgs.Add("-F$tmpFile") $replacedFileMessage = $true } else { @@ -1118,21 +951,6 @@ $stdoutFile = [System.IO.Path]::GetTempFileName() $stderrFile = [System.IO.Path]::GetTempFileName() & $realGh @args > $stdoutFile 2> $stderrFile $status = $LASTEXITCODE -$stdoutBytes = if (Test-Path -LiteralPath $stdoutFile) { (Get-Item -LiteralPath $stdoutFile).Length } else { 0 } -$stderrBytes = if (Test-Path -LiteralPath $stderrFile) { (Get-Item -LiteralPath $stderrFile).Length } else { 0 } -if ($stdoutBytes -gt ${ATTRIBUTION_COMMAND_OUTPUT_MAX_BYTES} -or $stderrBytes -gt ${ATTRIBUTION_COMMAND_OUTPUT_MAX_BYTES}) { - if ($stdoutBytes -gt ${ATTRIBUTION_COMMAND_OUTPUT_MAX_BYTES}) { - [Console]::Error.WriteLine("Orca attribution wrapper refused gh stdout capture: $stdoutBytes bytes exceeds the ${ATTRIBUTION_COMMAND_OUTPUT_MAX_BYTES}-byte limit.") - } - if ($stderrBytes -gt ${ATTRIBUTION_COMMAND_OUTPUT_MAX_BYTES}) { - [Console]::Error.WriteLine("Orca attribution wrapper refused gh stderr capture: $stderrBytes bytes exceeds the ${ATTRIBUTION_COMMAND_OUTPUT_MAX_BYTES}-byte limit.") - } - Remove-Item -LiteralPath $stdoutFile, $stderrFile -Force -ErrorAction SilentlyContinue - if ($status -ne 0) { - exit $status - } - exit ${ATTRIBUTION_BOUND_EXCEEDED_EXIT_CODE} -} $stdoutCapture = if (Test-Path -LiteralPath $stdoutFile) { Get-Content -LiteralPath $stdoutFile -Raw } else { '' } $stderrCapture = if (Test-Path -LiteralPath $stderrFile) { Get-Content -LiteralPath $stderrFile -Raw } else { '' } if ($stderrCapture) { diff --git a/src/main/automations/dispatch-tokens.test.ts b/src/main/automations/dispatch-tokens.test.ts deleted file mode 100644 index 87775f5b592..00000000000 --- a/src/main/automations/dispatch-tokens.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { - _getAutomationDispatchTokenCountForTests, - _resetAutomationDispatchTokensForTests, - beginAutomationDispatchTokenUse, - clearAutomationDispatchTokens, - createAutomationDispatchToken, - DISPATCH_TOKEN_MAX_ENTRIES, - finishAutomationDispatchTokenUse, - releaseAutomationDispatchTokenUse -} from './dispatch-tokens' - -beforeEach(() => { - _resetAutomationDispatchTokensForTests() -}) - -afterEach(() => { - vi.useRealTimers() -}) - -describe('automation dispatch tokens', () => { - it('preserves reservation, release, and one-time finish behavior', () => { - const token = createAutomationDispatchToken('automation', 'run') - const args = { token, automationId: 'automation', runId: 'run', reservationId: 'request-1' } - - expect(beginAutomationDispatchTokenUse(args)).toBe(true) - expect(beginAutomationDispatchTokenUse(args)).toBe(false) - releaseAutomationDispatchTokenUse({ token, reservationId: 'request-1' }) - expect(beginAutomationDispatchTokenUse(args)).toBe(true) - finishAutomationDispatchTokenUse({ token, reservationId: 'request-1' }) - expect(beginAutomationDispatchTokenUse(args)).toBe(false) - }) - - it('keeps delimiter-containing identities distinct', () => { - const token = createAutomationDispatchToken('a:b', 'c') - expect( - beginAutomationDispatchTokenUse({ - token, - automationId: 'a', - runId: 'b:c', - reservationId: 'request' - }) - ).toBe(false) - }) - - it('clears only the matching automation run', () => { - const removed = createAutomationDispatchToken('automation', 'run-1') - const retained = createAutomationDispatchToken('automation', 'run-2') - clearAutomationDispatchTokens('automation', 'run-1') - - expect( - beginAutomationDispatchTokenUse({ - token: removed, - automationId: 'automation', - runId: 'run-1', - reservationId: 'request' - }) - ).toBe(false) - expect( - beginAutomationDispatchTokenUse({ - token: retained, - automationId: 'automation', - runId: 'run-2', - reservationId: 'request' - }) - ).toBe(true) - }) - - it('physically prunes expired records', () => { - vi.useFakeTimers() - const expired = createAutomationDispatchToken('automation', 'old') - vi.advanceTimersByTime(30 * 60_000 + 1) - createAutomationDispatchToken('automation', 'new') - - expect(_getAutomationDispatchTokenCountForTests()).toBe(1) - expect( - beginAutomationDispatchTokenUse({ - token: expired, - automationId: 'automation', - runId: 'old', - reservationId: 'request' - }) - ).toBe(false) - }) - - it('evicts the oldest unused token at the entry cap', () => { - const oldest = createAutomationDispatchToken('automation', 'run-0') - for (let index = 1; index <= DISPATCH_TOKEN_MAX_ENTRIES; index += 1) { - createAutomationDispatchToken('automation', `run-${index}`) - } - - expect(_getAutomationDispatchTokenCountForTests()).toBe(DISPATCH_TOKEN_MAX_ENTRIES) - expect( - beginAutomationDispatchTokenUse({ - token: oldest, - automationId: 'automation', - runId: 'run-0', - reservationId: 'request' - }) - ).toBe(false) - }) -}) diff --git a/src/main/automations/dispatch-tokens.ts b/src/main/automations/dispatch-tokens.ts index 075794fc686..b8856a16220 100644 --- a/src/main/automations/dispatch-tokens.ts +++ b/src/main/automations/dispatch-tokens.ts @@ -1,10 +1,10 @@ -import { createHash, randomUUID } from 'node:crypto' +import { randomUUID } from 'node:crypto' const DISPATCH_TOKEN_TTL_MS = 30 * 60_000 -export const DISPATCH_TOKEN_MAX_ENTRIES = 1024 type DispatchTokenRecord = { - dispatchIdentity: string + automationId: string + runId: string expiresAt: number reservedBy?: string inFlight: boolean @@ -20,34 +20,12 @@ function pruneExpiredDispatchTokens(now = Date.now()): void { } } -function identityDigest(...parts: string[]): string { - const digest = createHash('sha256') - for (const part of parts) { - digest.update(`${part.length}:`) - digest.update(part) - } - return digest.digest('base64url') -} - -function evictOldestUnusedDispatchTokens(): void { - while (dispatchTokens.size >= DISPATCH_TOKEN_MAX_ENTRIES) { - const oldestUnused = Array.from(dispatchTokens).find(([, record]) => !record.inFlight) - if (!oldestUnused) { - return - } - dispatchTokens.delete(oldestUnused[0]) - } -} - export function createAutomationDispatchToken(automationId: string, runId: string): string { pruneExpiredDispatchTokens() - evictOldestUnusedDispatchTokens() const token = randomUUID() - if (dispatchTokens.size >= DISPATCH_TOKEN_MAX_ENTRIES) { - return token - } dispatchTokens.set(token, { - dispatchIdentity: identityDigest(automationId, runId), + automationId, + runId, expiresAt: Date.now() + DISPATCH_TOKEN_TTL_MS, inFlight: false }) @@ -63,19 +41,19 @@ export function beginAutomationDispatchTokenUse(args: { pruneExpiredDispatchTokens() const record = dispatchTokens.get(args.token) const valid = - record?.dispatchIdentity === identityDigest(args.automationId, args.runId) && + record?.automationId === args.automationId && + record.runId === args.runId && record.expiresAt > Date.now() if (!valid) { return false } - const reservationIdentity = identityDigest(args.reservationId) - if (record.reservedBy !== undefined && record.reservedBy !== reservationIdentity) { + if (record.reservedBy !== undefined && record.reservedBy !== args.reservationId) { return false } if (record.inFlight) { return false } - record.reservedBy = reservationIdentity + record.reservedBy = args.reservationId record.inFlight = true return true } @@ -85,7 +63,7 @@ export function releaseAutomationDispatchTokenUse(args: { reservationId: string }): void { const record = dispatchTokens.get(args.token) - if (record?.reservedBy === identityDigest(args.reservationId)) { + if (record?.reservedBy === args.reservationId) { record.inFlight = false } } @@ -95,24 +73,15 @@ export function finishAutomationDispatchTokenUse(args: { reservationId: string }): void { const record = dispatchTokens.get(args.token) - if (record?.reservedBy === identityDigest(args.reservationId)) { + if (record?.reservedBy === args.reservationId) { dispatchTokens.delete(args.token) } } export function clearAutomationDispatchTokens(automationId: string, runId: string): void { - const dispatchIdentity = identityDigest(automationId, runId) for (const [token, record] of dispatchTokens) { - if (record.dispatchIdentity === dispatchIdentity) { + if (record.automationId === automationId && record.runId === runId) { dispatchTokens.delete(token) } } } - -export function _resetAutomationDispatchTokensForTests(): void { - dispatchTokens.clear() -} - -export function _getAutomationDispatchTokenCountForTests(): number { - return dispatchTokens.size -} diff --git a/src/main/automations/external-job-mappers.ts b/src/main/automations/external-job-mappers.ts index 6a1f99783b3..d3648af446a 100644 --- a/src/main/automations/external-job-mappers.ts +++ b/src/main/automations/external-job-mappers.ts @@ -200,7 +200,6 @@ export function mapHermesJobs(managerId: string, rawJobs: unknown): ExternalAuto lastError: asString(job.last_error) ?? asString(job.last_delivery_error), workdir: asString(job.workdir), runCount: asNumber(job.run_count) ?? (Array.isArray(job.runs) ? job.runs.length : 0), - ...(job.run_count_saturated === true ? { runCountSaturated: true as const } : {}), runs: mapExternalRuns({ managerId, provider: 'hermes', diff --git a/src/main/automations/external-manager-remote-fanout.test.ts b/src/main/automations/external-manager-remote-fanout.test.ts deleted file mode 100644 index 15353016e66..00000000000 --- a/src/main/automations/external-manager-remote-fanout.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type * as NodeFs from 'node:fs' -import type { Store } from '../persistence' -import type { SshTarget } from '../../shared/ssh-types' -import { getActiveMultiplexer } from '../ipc/ssh' -import { listExternalAutomationManagers } from './external-manager' - -const execFileMock = vi.hoisted(() => - vi.fn( - ( - _command: string, - _args: string[], - _options: unknown, - callback: (error: Error | null) => void - ) => { - callback(new Error('not installed')) - return { kill: vi.fn() } - } - ) -) - -vi.mock('node:child_process', () => ({ execFile: execFileMock })) -vi.mock('node:fs', async () => { - const actual = await vi.importActual('node:fs') - return { ...actual, existsSync: vi.fn(() => false) } -}) -vi.mock('../ipc/ssh', () => ({ getActiveMultiplexer: vi.fn() })) - -beforeEach(() => { - vi.mocked(getActiveMultiplexer).mockReset() -}) - -describe('remote external automation manager fanout', () => { - it('bounds probes for a large target list and preserves provider order', async () => { - const targets: SshTarget[] = Array.from({ length: 100 }, (_, index) => ({ - id: `ssh-${index}`, - label: `Target ${index}`, - host: `host-${index}`, - port: 22, - username: 'orca' - })) - let inFlight = 0 - let peak = 0 - const request = vi.fn(async (_method: string, input: { provider: string }) => { - inFlight += 1 - peak = Math.max(peak, inFlight) - await Promise.resolve() - inFlight -= 1 - return { - jobs: [], - hermesAvailable: input.provider === 'hermes', - openclawAvailable: input.provider === 'openclaw' - } - }) - vi.mocked(getActiveMultiplexer).mockReturnValue({ - isDisposed: () => false, - request - } as never) - - const managers = await listExternalAutomationManagers({ - getSshTargets: () => targets - } as Store) - - expect(peak).toBe(4) - expect(request).toHaveBeenCalledTimes(200) - expect(managers.map((manager) => manager.id)).toEqual( - targets.flatMap((target) => [`hermes:ssh:${target.id}`, `openclaw:ssh:${target.id}`]) - ) - }) -}) diff --git a/src/main/automations/external-manager.test.ts b/src/main/automations/external-manager.test.ts index 59c104197a3..dc96b9b964a 100644 --- a/src/main/automations/external-manager.test.ts +++ b/src/main/automations/external-manager.test.ts @@ -344,7 +344,6 @@ describe('listExternalAutomationRuns', () => { it('requests paginated Hermes runs from the remote relay', async () => { const request = vi.fn().mockResolvedValue({ total: 42, - totalSaturated: true, runs: [ { id: 'job-1:2026-05-15_09-00-00.md', @@ -376,7 +375,6 @@ describe('listExternalAutomationRuns', () => { page: 2, pageSize: 10, total: 42, - totalSaturated: true, runs: [ { id: 'job-1:2026-05-15_09-00-00.md', diff --git a/src/main/automations/external-manager.ts b/src/main/automations/external-manager.ts index b5f9d698782..8c4d0f0d53e 100644 --- a/src/main/automations/external-manager.ts +++ b/src/main/automations/external-manager.ts @@ -2,6 +2,7 @@ * and lifecycle routing share provider/target validation and remote relay fallbacks. */ import { execFile } from 'node:child_process' import { existsSync } from 'node:fs' +import { readFile } from 'node:fs/promises' import { homedir } from 'node:os' import { join } from 'node:path' import type { @@ -14,8 +15,6 @@ import type { ExternalAutomationRunsPage, ExternalAutomationUpdateInput } from '../../shared/automations-types' -import { readExternalAutomationJobsFile } from '../../shared/external-automation-jobs-file' -import { mapWithConcurrency } from '../../shared/map-with-concurrency' import type { SshTarget } from '../../shared/ssh-types' import type { Store } from '../persistence' import { getActiveMultiplexer } from '../ipc/ssh' @@ -33,8 +32,6 @@ const OPENCLAW_JOBS_FILE = join(homedir(), '.openclaw', 'cron', 'jobs.json') const EXTERNAL_JOB_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/ const LOCAL_COMMAND_LOOKUP_TIMEOUT_MS = 5_000 const LOCAL_AUTOMATION_COMMAND_TIMEOUT_MS = 30_000 -const HERMES_JOB_RUN_COUNT_CONCURRENCY = 4 -const REMOTE_AUTOMATION_MANAGER_FETCH_CONCURRENCY = 4 function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) @@ -133,30 +130,39 @@ async function readLocalHermesJobs(): Promise { if (!existsSync(HERMES_JOBS_FILE)) { return [] } - const jobs = await readExternalAutomationJobsFile(HERMES_JOBS_FILE, { allowRootArray: true }) - return mapWithConcurrency(jobs, HERMES_JOB_RUN_COUNT_CONCURRENCY, async (job) => { - if (!isRecord(job)) { - return job - } - const jobId = typeof job.id === 'string' ? job.id : null - if (!jobId) { - return job - } - const runsPage = await readHermesCronOutputRunsPage(jobId, { page: 1, pageSize: 0 }) - return { - ...job, - run_count: runsPage.total, - ...(runsPage.totalSaturated ? { run_count_saturated: true } : {}), - runs: [] - } - }) + const content = await readFile(HERMES_JOBS_FILE, 'utf-8') + const parsed = JSON.parse(content) as unknown + const jobs = Array.isArray(parsed) + ? parsed + : isRecord(parsed) && Array.isArray(parsed.jobs) + ? parsed.jobs + : [] + return Promise.all( + jobs.map(async (job) => { + if (!isRecord(job)) { + return job + } + const jobId = typeof job.id === 'string' ? job.id : null + if (!jobId) { + return job + } + const runsPage = await readHermesCronOutputRunsPage(jobId, { page: 1, pageSize: 0 }) + return { + ...job, + run_count: runsPage.total, + runs: [] + } + }) + ) } async function readLocalOpenClawJobs(): Promise { if (!existsSync(OPENCLAW_JOBS_FILE)) { return [] } - return readExternalAutomationJobsFile(OPENCLAW_JOBS_FILE, { allowRootArray: false }) + const content = await readFile(OPENCLAW_JOBS_FILE, 'utf-8') + const parsed = JSON.parse(content) as unknown + return isRecord(parsed) && Array.isArray(parsed.jobs) ? parsed.jobs : [] } async function listLocalHermesManager(): Promise { @@ -294,22 +300,16 @@ async function listRemoteManager( export async function listExternalAutomationManagers( store: Store ): Promise { - const remoteManagerRequests = store - .getSshTargets() - // Why: runtime-owned hidden targets are excluded from SSH/run-target surfaces. - .filter((target) => !isRuntimeOwnedSshTarget(target)) - .flatMap((target) => [ - { provider: 'hermes' as const, target }, - { provider: 'openclaw' as const, target } - ]) const [localHermes, localOpenClaw, remote] = await Promise.all([ listLocalHermesManager(), listLocalOpenClawManager(), - mapWithConcurrency( - remoteManagerRequests, - REMOTE_AUTOMATION_MANAGER_FETCH_CONCURRENCY, - ({ provider, target }) => - provider === 'hermes' ? listRemoteHermesManager(target) : listRemoteOpenClawManager(target) + Promise.all( + store + .getSshTargets() + // Why: runtime-owned hidden targets are excluded from SSH/run-target + // surfaces; don't probe them for external automations either. + .filter((target) => !isRuntimeOwnedSshTarget(target)) + .flatMap((target) => [listRemoteHermesManager(target), listRemoteOpenClawManager(target)]) ) ]) return [ @@ -351,7 +351,6 @@ export async function listExternalAutomationRuns( page, pageSize, total: result.total, - ...(result.totalSaturated ? { totalSaturated: true } : {}), runs: mapHermesJobs(input.managerId, [{ id: input.jobId, runs: result.runs }])[0]?.runs ?? [] } } @@ -364,7 +363,7 @@ export async function listExternalAutomationRuns( jobId: input.jobId, page, pageSize - })) as { total?: number; totalSaturated?: boolean; runs?: unknown[] } + })) as { total?: number; runs?: unknown[] } return { managerId: input.managerId, provider: input.provider, @@ -373,7 +372,6 @@ export async function listExternalAutomationRuns( page, pageSize, total: typeof result.total === 'number' && Number.isFinite(result.total) ? result.total : 0, - ...(result.totalSaturated === true ? { totalSaturated: true } : {}), runs: mapHermesJobs(input.managerId, [{ id: input.jobId, runs: result.runs ?? [] }])[0]?.runs ?? [] } diff --git a/src/main/automations/headless-dispatch.ts b/src/main/automations/headless-dispatch.ts index 00cca368e35..359673bc573 100644 --- a/src/main/automations/headless-dispatch.ts +++ b/src/main/automations/headless-dispatch.ts @@ -4,7 +4,6 @@ import type { AutomationRunOutputSnapshot } from '../../shared/automations-types' import type { AutomationRunTargetResult } from './run-target-resolution' -import { appendCompactedStringChunk } from '../../shared/string-chunk-compaction' const MAX_HEADLESS_OUTPUT_SNAPSHOT_CHARS = 256 * 1024 @@ -40,7 +39,7 @@ export function createHeadlessAutomationOutputSnapshotBuffer(): { if (!chunk) { return } - appendCompactedStringChunk(chunks, chunk) + chunks.push(chunk) totalChars += chunk.length let overflowChars = totalChars - MAX_HEADLESS_OUTPUT_SNAPSHOT_CHARS while (overflowChars > 0 && chunks.length > 0) { diff --git a/src/main/automations/hermes-cron-output.test.ts b/src/main/automations/hermes-cron-output.test.ts index 2419863ae2e..5bf9b2fddb5 100644 --- a/src/main/automations/hermes-cron-output.test.ts +++ b/src/main/automations/hermes-cron-output.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, mkdir, rm, truncate, writeFile } from 'node:fs/promises' +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -11,7 +11,6 @@ const fakeDbRows = vi.hoisted(() => ({ messages: [] as Record[] })) const fakePrepareSqls = vi.hoisted(() => [] as string[]) -const fakeIteratedSqls = vi.hoisted(() => [] as string[]) const fakeDatabase = vi.hoisted(() => vi.fn(function FakeDatabase() { return { @@ -27,14 +26,7 @@ const fakeDatabase = vi.hoisted(() => sql.includes('FROM sessions') ? fakeDbRows.sessions : fakeDbRows.messages.filter((message) => message.session_id === param) - ), - iterate: vi.fn(function* (param: string) { - fakeIteratedSqls.push(sql) - const rows = sql.includes('FROM sessions') - ? fakeDbRows.sessions - : fakeDbRows.messages.filter((message) => message.session_id === param) - yield* rows - }) + ) } }), close: vi.fn() @@ -61,7 +53,6 @@ beforeEach(() => { fakeDbRows.sessions = [] fakeDbRows.messages = [] fakePrepareSqls.length = 0 - fakeIteratedSqls.length = 0 fakeDatabase.mockClear() }) @@ -150,26 +141,6 @@ Run summary: monitor automation completed successfully. expect((page.runs[0] as { output_content?: string }).output_content).toContain( 'full command output line' ) - const transcriptSql = fakeIteratedSqls.find((sql) => sql.includes('FROM messages')) - expect(transcriptSql).toContain('substr(CAST(content AS BLOB)') - expect(transcriptSql).toContain('LIMIT 10001') - }) - - it('omits an oversized sparse markdown output without reading it wholesale', async () => { - const home = await createHermesHome() - const outputDir = join(home, 'cron', 'output', 'job-1') - const outputPath = join(outputDir, '2026-05-15_09-02-00.md') - await mkdir(outputDir, { recursive: true }) - await writeFile(outputPath, '', 'utf-8') - await truncate(outputPath, 64 * 1024 * 1024) - - const { readHermesCronOutputRunsPage } = await loadReader() - const page = await readHermesCronOutputRunsPage('job-1', { page: 1, pageSize: 25 }) - - expect(page.runs[0]).toMatchObject({ - output_content: null, - error: expect.stringContaining('File too large') - }) }) it('builds large response previews without broad regex captures', async () => { @@ -306,47 +277,6 @@ Run summary: monitor automation completed successfully. expect(fakePrepareSqls.some((sql) => sql.includes('FROM messages'))).toBe(false) }) - it('caps one hydrated history page at 100 runs', async () => { - const home = await createHermesHome() - const outputDir = join(home, 'cron', 'output', 'job-1') - await mkdir(outputDir, { recursive: true }) - await Promise.all( - Array.from({ length: 101 }, async (_, index) => { - const minute = String(Math.floor(index / 60)).padStart(2, '0') - const second = String(index % 60).padStart(2, '0') - await writeFile( - join(outputDir, `2026-05-15_09-${minute}-${second}.md`), - `run ${index}`, - 'utf-8' - ) - }) - ) - - const { readHermesCronOutputRunsPage } = await loadReader() - const page = await readHermesCronOutputRunsPage('job-1', { - page: 1, - pageSize: Number.MAX_SAFE_INTEGER - }) - - expect(page.total).toBe(101) - expect(page.runs).toHaveLength(100) - }) - - it('bounds session run refs and reports a saturated total', async () => { - const home = await createHermesHome() - await writeFile(join(home, 'state.db'), '', 'utf-8') - fakeDbRows.sessions = Array.from({ length: 10_001 }, (_, index) => ({ - id: `cron_job-1_20260515_${String(index).padStart(6, '0')}`, - started_at: Date.UTC(2026, 4, 15, 9, 0, 0) / 1000 - index - })) - - const { readHermesCronOutputRunsPage } = await loadReader() - const page = await readHermesCronOutputRunsPage('job-1', { page: 1, pageSize: 0 }) - - expect(page).toEqual({ total: 10_000, totalSaturated: true, runs: [] }) - expect(fakeIteratedSqls.find((sql) => sql.includes('FROM sessions'))).toContain('LIMIT 10001') - }) - it('caches count-only reads until the cache is cleared', async () => { const home = await createHermesHome() const outputDir = join(home, 'cron', 'output', 'job-1') diff --git a/src/main/automations/hermes-cron-output.ts b/src/main/automations/hermes-cron-output.ts index 62eb2b22b85..db1e054f7e8 100644 --- a/src/main/automations/hermes-cron-output.ts +++ b/src/main/automations/hermes-cron-output.ts @@ -1,25 +1,9 @@ /* eslint-disable max-lines -- Why: Hermes run history has to reconcile * markdown output files with SQLite session transcripts from separate stores. */ import { existsSync } from 'node:fs' -import { open, opendir, realpath, stat } from 'node:fs/promises' +import { open, readdir, readFile, realpath, stat } from 'node:fs/promises' import { homedir } from 'node:os' import { isAbsolute, join, relative, resolve, sep } from 'node:path' -import { - type BoundedHermesRunRefs, - HERMES_RUN_REF_MAX_ENTRIES, - HERMES_SESSION_RUN_REFS_SELECT_SQL, - HermesRunRefRetainer -} from '../../shared/hermes-run-ref-retention' -import { - formatHermesSessionMessagesWithinLimits, - HERMES_PRIMARY_OUTPUT_MAX_BYTES, - HERMES_RUN_PAGE_MAX_RUNS, - HERMES_SESSION_RUN_SELECT_SQL, - HERMES_SESSION_TRANSCRIPT_SELECT_SQL, - HERMES_SESSION_TRANSCRIPT_TRUNCATED_ERROR, - hydrateHermesRunPageWithinLimits -} from '../../shared/hermes-run-output-limits' -import { readNodeFileWithinLimit } from '../../shared/node-bounded-file-reader' import Database from '../sqlite/sync-database' const HERMES_HOME = process.env.HERMES_HOME?.trim() || join(homedir(), '.hermes') @@ -38,7 +22,6 @@ const LATEST_LOG_PATH_PATTERN = export type HermesCronOutputRunsPage = { total: number - totalSaturated?: true runs: unknown[] } @@ -379,10 +362,9 @@ async function readReferencedLogFile(content: string): Promise<{ return null } if (logStat.size <= MAX_REFERENCED_LOG_BYTES) { - const { buffer } = await readNodeFileWithinLimit(logPath, MAX_REFERENCED_LOG_BYTES) return { path: logPath, - content: buffer.toString('utf-8'), + content: await readFile(logPath, 'utf-8'), truncated: false } } @@ -429,6 +411,31 @@ async function appendReferencedLogFile(content: string): Promise { .join('\n\n') } +function formatSessionMessages(messages: Record[]): string | null { + if (messages.length === 0) { + return null + } + return messages + .map((message) => { + const role = typeof message.role === 'string' ? message.role : 'message' + const content = typeof message.content === 'string' ? message.content.trim() : '' + const toolName = typeof message.tool_name === 'string' ? message.tool_name.trim() : '' + const reasoning = + typeof message.reasoning_content === 'string' + ? message.reasoning_content.trim() + : typeof message.reasoning === 'string' + ? message.reasoning.trim() + : '' + const parts = [ + `## ${role}${toolName ? ` / ${toolName}` : ''}`, + reasoning ? `### Reasoning\n\n${reasoning}` : null, + content || '(empty)' + ].filter(Boolean) + return parts.join('\n\n') + }) + .join('\n\n---\n\n') +} + function getRunKey(run: unknown): string | null { return isRecord(run) ? asString(run.run_key) : null } @@ -572,20 +579,18 @@ export async function readHermesCronOutputRuns(jobId: string): Promise> { +async function readHermesCronOutputRunRefs(jobId: string): Promise { const outputRuns = await readHermesOutputFileRunRefs(jobId) - const sessionRuns = readHermesSessionDbRunRefs(jobId) - const mergedRetainer = new HermesRunRefRetainer() - for (const ref of mergeHermesOutputAndSessionRunRefs(outputRuns.refs, sessionRuns.refs)) { - mergedRetainer.add(ref) - } - const merged = mergedRetainer.finish() - return { - refs: merged.refs, - saturated: outputRuns.saturated || sessionRuns.saturated || merged.saturated - } + return mergeHermesOutputAndSessionRunRefs(outputRuns, readHermesSessionDbRunRefs(jobId)).sort( + (a, b) => { + const aTime = getRawRunTime(a) + const bTime = getRawRunTime(b) + if (Number.isFinite(aTime) && Number.isFinite(bTime)) { + return bTime - aTime + } + return getRawRunId(b).localeCompare(getRawRunId(a)) + } + ) } // Why: opening the Automations page calls readHermesCronOutputRunsPage with @@ -595,10 +600,9 @@ async function readHermesCronOutputRunRefs( const HERMES_RUN_COUNT_CACHE_TTL_MS = 2000 const HERMES_RUN_COUNT_CACHE_MAX_ENTRIES = 200 type HermesRunCountCacheEntry = { - promise: Promise + promise: Promise expiresAt: number } -type HermesRunCount = { total: number; totalSaturated?: true } const hermesRunCountCache = new Map() export function clearHermesCronOutputRunCountCache(jobId?: string): void { @@ -624,7 +628,7 @@ function pruneHermesRunCountCache(now: number): void { } } -async function readHermesCronOutputRunCount(jobId: string): Promise { +async function readHermesCronOutputRunCount(jobId: string): Promise { const now = Date.now() const cached = hermesRunCountCache.get(jobId) if (cached && cached.expiresAt > now) { @@ -637,10 +641,7 @@ async function readHermesCronOutputRunCount(jobId: string): Promise ({ - total: result.refs.length, - ...(result.saturated ? { totalSaturated: true as const } : {}) - })), + promise: readHermesCronOutputRunRefs(jobId).then((refs) => refs.length), expiresAt: Number.POSITIVE_INFINITY } hermesRunCountCache.set(jobId, entry) @@ -684,54 +685,57 @@ export async function readHermesCronOutputRunsPage( return { total: 0, runs: [] } } const safePage = Math.max(1, Math.floor(page)) - const safePageSize = Math.min(HERMES_RUN_PAGE_MAX_RUNS, Math.max(0, Math.floor(pageSize))) + const safePageSize = Math.max(0, Math.floor(pageSize)) if (safePageSize === 0) { // Why: manager listing only needs a badge count; hydrating markdown logs // and full session transcripts can make opening Automations very slow. - return { ...(await readHermesCronOutputRunCount(jobId)), runs: [] } + return { total: await readHermesCronOutputRunCount(jobId), runs: [] } } const runRefs = await readHermesCronOutputRunRefs(jobId) const start = (safePage - 1) * safePageSize - const pageRefs = runRefs.refs.slice(start, start + safePageSize) + const pageRefs = runRefs.slice(start, start + safePageSize) return { - total: runRefs.refs.length, - ...(runRefs.saturated ? { totalSaturated: true } : {}), - runs: await hydrateHermesRunPageWithinLimits(pageRefs, (ref) => hydrateHermesRunRef(jobId, ref)) + total: runRefs.length, + runs: await Promise.all(pageRefs.map((ref) => hydrateHermesRunRef(jobId, ref))) } } -async function readHermesOutputFileRunRefs( - jobId: string -): Promise> { +function getRawRunId(run: unknown): string { + if (typeof run === 'object' && run !== null && 'id' in run) { + return String((run as { id: unknown }).id) + } + return '' +} + +function getRawRunTime(run: unknown): number { + if (typeof run !== 'object' || run === null || !('run_at' in run)) { + return Number.NaN + } + const runAt = (run as { run_at: unknown }).run_at + return typeof runAt === 'string' ? Date.parse(runAt) : Number.NaN +} + +async function readHermesOutputFileRunRefs(jobId: string): Promise { const outputDir = join(HERMES_OUTPUT_DIR, jobId) if (!existsSync(outputDir)) { - return { refs: [], saturated: false } + return [] } - const retainer = new HermesRunRefRetainer() - const directory = await opendir(outputDir) - for await (const entry of directory) { - if (!entry.isFile() || !HERMES_OUTPUT_FILE_PATTERN.test(entry.name)) { - continue - } - retainer.add({ + const entries = await readdir(outputDir, { withFileTypes: true }) + return entries + .filter((entry) => entry.isFile() && HERMES_OUTPUT_FILE_PATTERN.test(entry.name)) + .map((entry) => ({ kind: 'output' as const, id: `${jobId}:${entry.name}`, job_id: jobId, run_at: runAtFromHermesOutputFile(entry.name), run_key: runKeyFromHermesOutputFile(entry.name), output_path: join(outputDir, entry.name) - }) - } - return retainer.finish() + })) } async function readHermesOutputFileRun(ref: HermesOutputRunRef): Promise { try { - const { buffer } = await readNodeFileWithinLimit( - ref.output_path, - HERMES_PRIMARY_OUTPUT_MAX_BYTES - ) - const content = buffer.toString('utf-8') + const content = await readFile(ref.output_path, 'utf-8') const parsed = parseHermesOutput(content) const outputContent = await appendReferencedLogFile(parsed.outputContent) return { @@ -760,41 +764,37 @@ async function readHermesOutputFileRun(ref: HermesOutputRunRef): Promise { +function readHermesSessionDbRunRefs(jobId: string): HermesSessionRunRef[] { if (!existsSync(HERMES_STATE_DB)) { - return { refs: [], saturated: false } + return [] } try { const db = new Database(HERMES_STATE_DB, { readonly: true, fileMustExist: true }) try { const pattern = `cron\\_${escapeSqlLike(jobId)}\\_%` - const statement = db.prepare(HERMES_SESSION_RUN_REFS_SELECT_SQL) - const rows = - typeof statement.iterate === 'function' - ? (statement.iterate(pattern) as Iterable>) - : (statement.all(pattern) as Record[]) - const retainer = new HermesRunRefRetainer() - let read = 0 - for (const row of rows) { - if (read >= HERMES_RUN_REF_MAX_ENTRIES + 1) { - break - } - read += 1 + const rows = db + .prepare( + `SELECT id, started_at + FROM sessions + WHERE id LIKE ? ESCAPE '\\' + ORDER BY started_at DESC` + ) + .all(pattern) as Record[] + return rows.map((row) => { const runId = typeof row.id === 'string' ? row.id : `${jobId}:${String(row.started_at)}` - retainer.add({ + return { kind: 'session', id: runId, job_id: jobId, run_at: runAtFromUnixSeconds(row.started_at), run_key: runId.split(`${jobId}_`).at(-1) ?? null - }) - } - return retainer.finish() + } + }) } finally { db.close() } } catch { - return { refs: [], saturated: false } + return [] } } @@ -805,18 +805,25 @@ function readHermesSessionDbRunById(jobId: string, runId: string): unknown | nul try { const db = new Database(HERMES_STATE_DB, { readonly: true, fileMustExist: true }) try { - const row = db.prepare(HERMES_SESSION_RUN_SELECT_SQL).get(runId) as - | Record - | undefined + const row = db + .prepare( + `SELECT id, title, started_at, ended_at, end_reason, model, message_count, + input_tokens, output_tokens, estimated_cost_usd + FROM sessions + WHERE id = ?` + ) + .get(runId) as Record | undefined if (!row) { return null } - const messageStatement = db.prepare(HERMES_SESSION_TRANSCRIPT_SELECT_SQL) - const messages = - typeof messageStatement.iterate === 'function' - ? (messageStatement.iterate(runId) as Iterable>) - : (messageStatement.all(runId) as Record[]) - const formattedMessages = formatHermesSessionMessagesWithinLimits(messages) + const messages = db + .prepare( + `SELECT role, content, tool_name, reasoning, reasoning_content + FROM messages + WHERE session_id = ? + ORDER BY timestamp, id` + ) + .all(runId) as Record[] const title = typeof row.title === 'string' && row.title.trim() ? row.title.trim() : null const model = typeof row.model === 'string' && row.model.trim() ? row.model.trim() : null const messageCount = typeof row.message_count === 'number' ? row.message_count : null @@ -836,8 +843,8 @@ function readHermesSessionDbRunById(jobId: string, runId: string): unknown | nul run_key: runId.split(`${jobId}_`).at(-1) ?? null, status: typeof row.ended_at === 'number' ? 'completed' : 'unknown', output_preview: summaryParts.join(' · ') || null, - output_content: formattedMessages.content, - error: formattedMessages.truncated ? HERMES_SESSION_TRANSCRIPT_TRUNCATED_ERROR : null, + output_content: formatSessionMessages(messages), + error: null, output_path: HERMES_STATE_DB } } finally { diff --git a/src/main/azure-devops/azure-devops-api-request.ts b/src/main/azure-devops/azure-devops-api-request.ts index d5dfe3e9a33..5fc8f256793 100644 --- a/src/main/azure-devops/azure-devops-api-request.ts +++ b/src/main/azure-devops/azure-devops-api-request.ts @@ -1,7 +1,6 @@ import { Buffer } from 'node:buffer' import type { AzureDevOpsRepoRef } from './repository-ref' import { cancelUnreadResponseBody } from '../lib/unread-response-body' -import { readFetchResponseJsonWithinLimit } from '../lib/fetch-response-body' const REQUEST_TIMEOUT_MS = 5000 @@ -96,7 +95,7 @@ export async function requestAzureDevOpsJsonAtBase( } return null } - return await readFetchResponseJsonWithinLimit(response) + return (await response.json()) as T } catch (error) { if (throwOnFailure) { throw error diff --git a/src/main/azure-devops/repository-ref.ts b/src/main/azure-devops/repository-ref.ts index a826681b297..868d0d8c9bc 100644 --- a/src/main/azure-devops/repository-ref.ts +++ b/src/main/azure-devops/repository-ref.ts @@ -1,9 +1,5 @@ import { gitExecFileAsync } from '../git/runner' import { getSshGitProvider } from '../providers/ssh-git-dispatch' -import { - buildRepositoryRefCacheKey, - RepositoryRefCache -} from '../source-control/repository-ref-cache' export type AzureDevOpsRepoRef = { host: string @@ -18,7 +14,8 @@ type LocalGitExecOptions = { wslDistro?: string } -const repoRefCache = new RepositoryRefCache() +const REPO_REF_CACHE_MAX_ENTRIES = 512 +const repoRefCache = new Map() /** @internal - exposed for tests only */ export function _resetAzureDevOpsRepoRefCache(): void { @@ -30,6 +27,17 @@ export function _getAzureDevOpsRepoRefCacheSize(): number { return repoRefCache.size } +function rememberRepoRefCacheEntry(cacheKey: string, value: AzureDevOpsRepoRef | null): void { + repoRefCache.set(cacheKey, value) + while (repoRefCache.size > REPO_REF_CACHE_MAX_ENTRIES) { + const oldestKey = repoRefCache.keys().next().value + if (oldestKey === undefined) { + return + } + repoRefCache.delete(oldestKey) + } +} + function decodeSegment(value: string): string { try { return decodeURIComponent(value) @@ -204,10 +212,9 @@ export async function getAzureDevOpsRepoRefForRemote( localGitOptions: LocalGitExecOptions = {} ): Promise { const runtimeKey = connectionId ?? `local:${localGitOptions.wslDistro ?? 'host'}` - const cacheKey = buildRepositoryRefCacheKey([runtimeKey, repoPath, remoteName]) - const cached = repoRefCache.get(cacheKey) - if (cached.found) { - return cached.value + const cacheKey = `${runtimeKey}\0${repoPath}\0${remoteName}` + if (repoRefCache.has(cacheKey)) { + return repoRefCache.get(cacheKey)! } try { const sshGitProvider = connectionId ? getSshGitProvider(connectionId) : null @@ -221,20 +228,7 @@ export async function getAzureDevOpsRepoRefForRemote( ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}) }) const result = parseAzureDevOpsRepoRef(stdout) - repoRefCache.remember( - cacheKey, - result, - result - ? [ - result.host, - result.project, - result.repository, - result.apiBaseUrl, - result.webBaseUrl, - result.organization ?? '' - ] - : [] - ) + rememberRepoRefCacheEntry(cacheKey, result) return result } catch { if (connectionId) { @@ -242,7 +236,7 @@ export async function getAzureDevOpsRepoRefForRemote( // caching them as "not Azure DevOps" would poison the repo for the session. return null } - repoRefCache.remember(cacheKey, null, []) + rememberRepoRefCacheEntry(cacheKey, null) return null } } diff --git a/src/main/bitbucket/client.ts b/src/main/bitbucket/client.ts index ca124bb07ef..e5163bd525a 100644 --- a/src/main/bitbucket/client.ts +++ b/src/main/bitbucket/client.ts @@ -15,7 +15,6 @@ import { type HostedReviewExecutionOptions } from '../source-control/hosted-review-git-options' import { cancelUnreadResponseBody } from '../lib/unread-response-body' -import { readFetchResponseJsonWithinLimit } from '../lib/fetch-response-body' const DEFAULT_API_BASE_URL = 'https://api.bitbucket.org/2.0' const REQUEST_TIMEOUT_MS = 5000 @@ -114,7 +113,7 @@ async function requestJson( } return null } - return await readFetchResponseJsonWithinLimit(response) + return (await response.json()) as T } catch (error) { if (throwOnFailure) { throw error diff --git a/src/main/bitbucket/repository-ref.ts b/src/main/bitbucket/repository-ref.ts index a4b743157de..fb07c32d4bc 100644 --- a/src/main/bitbucket/repository-ref.ts +++ b/src/main/bitbucket/repository-ref.ts @@ -1,9 +1,5 @@ import { gitExecFileAsync } from '../git/runner' import { getSshGitProvider } from '../providers/ssh-git-dispatch' -import { - buildRepositoryRefCacheKey, - RepositoryRefCache -} from '../source-control/repository-ref-cache' export type BitbucketRepoRef = { workspace: string @@ -14,7 +10,8 @@ type LocalGitExecOptions = { wslDistro?: string } -const repoRefCache = new RepositoryRefCache() +const REPO_REF_CACHE_MAX_ENTRIES = 512 +const repoRefCache = new Map() /** @internal - exposed for tests only */ export function _resetBitbucketRepoRefCache(): void { @@ -26,6 +23,17 @@ export function _getBitbucketRepoRefCacheSize(): number { return repoRefCache.size } +function rememberRepoRefCacheEntry(cacheKey: string, value: BitbucketRepoRef | null): void { + repoRefCache.set(cacheKey, value) + while (repoRefCache.size > REPO_REF_CACHE_MAX_ENTRIES) { + const oldestKey = repoRefCache.keys().next().value + if (oldestKey === undefined) { + return + } + repoRefCache.delete(oldestKey) + } +} + function decodeSegment(value: string): string { try { return decodeURIComponent(value) @@ -79,10 +87,9 @@ export async function getBitbucketRepoRefForRemote( localGitOptions: LocalGitExecOptions = {} ): Promise { const runtimeKey = connectionId ?? `local:${localGitOptions.wslDistro ?? 'host'}` - const cacheKey = buildRepositoryRefCacheKey([runtimeKey, repoPath, remoteName]) - const cached = repoRefCache.get(cacheKey) - if (cached.found) { - return cached.value + const cacheKey = `${runtimeKey}\0${repoPath}\0${remoteName}` + if (repoRefCache.has(cacheKey)) { + return repoRefCache.get(cacheKey)! } try { const sshGitProvider = connectionId ? getSshGitProvider(connectionId) : null @@ -96,7 +103,7 @@ export async function getBitbucketRepoRefForRemote( ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}) }) const result = parseBitbucketRepoRef(stdout) - repoRefCache.remember(cacheKey, result, result ? [result.workspace, result.repoSlug] : []) + rememberRepoRefCacheEntry(cacheKey, result) return result } catch { if (connectionId) { @@ -104,7 +111,7 @@ export async function getBitbucketRepoRefForRemote( // caching them as "not Bitbucket" would poison the repo for the session. return null } - repoRefCache.remember(cacheKey, null, []) + rememberRepoRefCacheEntry(cacheKey, null) return null } } diff --git a/src/main/browser/agent-browser-bridge.test.ts b/src/main/browser/agent-browser-bridge.test.ts index 6a708937382..dc804a2dbe1 100644 --- a/src/main/browser/agent-browser-bridge.test.ts +++ b/src/main/browser/agent-browser-bridge.test.ts @@ -1,25 +1,23 @@ /* eslint-disable max-lines */ import { describe, it, expect, vi, beforeEach } from 'vitest' -const { execFileMock, webContentsFromIdMock, existsSyncMock, readScreenshotFileMock, stdinWrites } = +const { execFileMock, webContentsFromIdMock, existsSyncMock, readFileSyncMock, stdinWrites } = vi.hoisted(() => ({ execFileMock: vi.fn(), webContentsFromIdMock: vi.fn(), existsSyncMock: vi.fn(() => false), - readScreenshotFileMock: vi.fn(() => Buffer.from('')), + readFileSyncMock: vi.fn(() => Buffer.from('')), stdinWrites: [] as string[] })) vi.mock('child_process', () => ({ execFile: execFileMock })) vi.mock('fs', () => ({ existsSync: existsSyncMock, + readFileSync: readFileSyncMock, accessSync: vi.fn(), chmodSync: vi.fn(), constants: { X_OK: 1 } })) -vi.mock('./browser-screenshot-file-reader', () => ({ - readBrowserScreenshotFile: readScreenshotFileMock -})) vi.mock('os', () => ({ platform: () => 'darwin', arch: () => 'arm64' })) vi.mock('electron', () => { return { @@ -55,16 +53,10 @@ vi.mock('./cdp-bridge', () => ({ import { AGENT_BROWSER_CLIPBOARD_WRITE_MAX_BYTES, - AGENT_BROWSER_MAX_QUEUED_COMMANDS_PER_SESSION, AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES, AgentBrowserBridge } from './agent-browser-bridge' import type { BrowserManager } from './browser-manager' -import { - BROWSER_SCREENSHOT_MAX_DIMENSION_PX, - BROWSER_SCREENSHOT_MEMORY_LIMIT_ERROR -} from './browser-screenshot-limits' -import { CDP_PDF_MAX_RETAINED_BYTES, CDP_PDF_MEMORY_LIMIT_ERROR } from './cdp-print-to-pdf' import { CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS, CLIPBOARD_TEXT_WRITE_MAX_BYTES, @@ -306,7 +298,7 @@ describe('AgentBrowserBridge', () => { stdinWrites.length = 0 CdpWsProxyMock.instances.length = 0 existsSyncMock.mockReturnValue(false) - readScreenshotFileMock.mockReturnValue(Buffer.from('')) + readFileSyncMock.mockReturnValue(Buffer.from('')) const wc = mockWebContents(100) webContentsFromIdMock.mockReturnValue(wc) bridge = new AgentBrowserBridge(mockBrowserManager()) @@ -771,68 +763,6 @@ describe('AgentBrowserBridge', () => { expect(snapshotIdx).toBeLessThan(clickIdx) }) - it('rejects overload instead of retaining an unbounded per-session command queue', async () => { - let releaseFirst!: () => void - const firstTurn = new Promise((resolve) => { - releaseFirst = resolve - }) - const internals = bridge as unknown as { - enqueueTargetedCommand: ( - worktreeId: string | undefined, - browserPageId: string | undefined, - execute: () => Promise, - options: { ensureSession: boolean; ensureVisible: boolean } - ) => Promise - } - const enqueue = (execute: () => Promise) => - internals.enqueueTargetedCommand(undefined, undefined, execute, { - ensureSession: false, - ensureVisible: false - }) - - const active = enqueue(() => firstTurn) - await vi.waitFor(() => - expect((bridge as unknown as { processingQueues: Set }).processingQueues.size).toBe(1) - ) - const queued = Array.from({ length: AGENT_BROWSER_MAX_QUEUED_COMMANDS_PER_SESSION }, () => - enqueue(async () => {}) - ) - - await expect(enqueue(async () => {})).rejects.toMatchObject({ code: 'browser_busy' }) - releaseFirst() - await expect(Promise.all([active, ...queued])).resolves.toHaveLength( - AGENT_BROWSER_MAX_QUEUED_COMMANDS_PER_SESSION + 1 - ) - }) - - it('bounds callers waiting for hung browser session setup before they reach the queue', async () => { - let releaseSetup!: () => void - const setup = new Promise((resolve) => { - releaseSetup = resolve - }) - const internals = bridge as unknown as { - ensureSession: () => Promise - enqueueTargetedCommand: ( - worktreeId: string | undefined, - browserPageId: string | undefined, - execute: () => Promise, - options: { ensureVisible: boolean } - ) => Promise - } - internals.ensureSession = vi.fn(() => setup) - const enqueue = () => - internals.enqueueTargetedCommand(undefined, undefined, async () => {}, { - ensureVisible: false - }) - const pending = Array.from({ length: AGENT_BROWSER_MAX_QUEUED_COMMANDS_PER_SESSION }, enqueue) - - await expect(enqueue()).rejects.toMatchObject({ code: 'browser_busy' }) - releaseSetup() - await expect(Promise.all(pending)).resolves.toHaveLength( - AGENT_BROWSER_MAX_QUEUED_COMMANDS_PER_SESSION - ) - }) - it('acquires an automation visibility lease while running snapshot commands', async () => { const lifecycleEvents: string[] = [] const restore = vi.fn(() => { @@ -1082,7 +1012,7 @@ describe('AgentBrowserBridge', () => { ) existsSyncMock.mockReturnValue(true) const screenshotBytes = Buffer.from('serialized-screenshot') - readScreenshotFileMock.mockReturnValue(screenshotBytes) + readFileSyncMock.mockReturnValue(screenshotBytes) const b = new AgentBrowserBridge( mockBrowserManager(tabs, worktrees, { @@ -1154,22 +1084,6 @@ describe('AgentBrowserBridge', () => { } }) - it('rejects an oversized screenshot file before reading or base64-copying it', () => { - existsSyncMock.mockReturnValue(true) - readScreenshotFileMock.mockImplementation(() => { - throw new Error(BROWSER_SCREENSHOT_MEMORY_LIMIT_ERROR) - }) - - expect(() => - ( - bridge as unknown as { - readScreenshotFromResult: (raw: unknown, format?: string) => unknown - } - ).readScreenshotFromResult({ path: '/tmp/oversized.png' }, 'png') - ).toThrow(BROWSER_SCREENSHOT_MEMORY_LIMIT_ERROR) - expect(readScreenshotFileMock).toHaveBeenCalledWith('/tmp/oversized.png') - }) - it('captures full-page screenshots directly through CDP using CSS layout bounds', async () => { vi.useFakeTimers() try { @@ -1217,88 +1131,6 @@ describe('AgentBrowserBridge', () => { } }) - it('reports oversized full-page geometry with a stable browser error code', async () => { - vi.useFakeTimers() - try { - const wc = mockWebContents(100) - wc.debugger.sendCommand.mockResolvedValueOnce({ - cssContentSize: { width: BROWSER_SCREENSHOT_MAX_DIMENSION_PX + 1, height: 1 } - }) - webContentsFromIdMock.mockReturnValue(wc) - - const screenshotPromise = bridge.fullPageScreenshot('png') - const rejection = expect(screenshotPromise).rejects.toMatchObject({ - code: 'browser_screenshot_too_large', - message: BROWSER_SCREENSHOT_MEMORY_LIMIT_ERROR - }) - await vi.advanceTimersByTimeAsync(500) - - await rejection - expect(wc.debugger.sendCommand).toHaveBeenCalledOnce() - } finally { - vi.useRealTimers() - } - }) - - it('rejects a direct PDF before making an oversized base64 copy', async () => { - succeedWith(null) - const toString = vi.fn() - const wc = { - ...mockWebContents(100), - printToPDF: vi.fn(async () => ({ - length: CDP_PDF_MAX_RETAINED_BYTES + 1, - toString - })) - } - webContentsFromIdMock.mockReturnValue(wc) - - await expect(bridge.pdf()).rejects.toThrow(CDP_PDF_MEMORY_LIMIT_ERROR) - expect(toString).not.toHaveBeenCalled() - }) - - it('shares one native PDF print cap across browser sessions', async () => { - const tabs = new Map([ - ['tab-1', 1], - ['tab-2', 2], - ['tab-3', 3] - ]) - const worktrees = new Map([ - ['tab-1', 'wt-1'], - ['tab-2', 'wt-2'], - ['tab-3', 'wt-3'] - ]) - const resolvers: ((buffer: Buffer) => void)[] = [] - const webContentsById = new Map( - [1, 2, 3].map((id) => [ - id, - { - ...mockWebContents(id), - printToPDF: vi.fn( - () => - new Promise((resolve) => { - resolvers.push(resolve) - }) - ) - } - ]) - ) - webContentsFromIdMock.mockImplementation((id: number) => webContentsById.get(id) ?? null) - succeedWith(null) - const boundedBridge = new AgentBrowserBridge(mockBrowserManager(tabs, worktrees)) - boundedBridge.setActiveTab(1, 'wt-1') - boundedBridge.setActiveTab(2, 'wt-2') - boundedBridge.setActiveTab(3, 'wt-3') - - const active = [boundedBridge.pdf('wt-1'), boundedBridge.pdf('wt-2')] - await vi.waitFor(() => expect(resolvers).toHaveLength(2)) - await expect(boundedBridge.pdf('wt-3')).rejects.toMatchObject({ code: 'browser_busy' }) - - for (const resolve of resolvers) { - resolve(Buffer.from('%PDF-bounded')) - } - await expect(Promise.all(active)).resolves.toHaveLength(2) - }) - // ── Timeout escalation ── it('destroys session after 3 consecutive timeouts', async () => { @@ -2690,19 +2522,6 @@ describe('AgentBrowserBridge', () => { expect(viewportCall).toBeUndefined() }) - it('rejects an oversized viewport before asking Chromium to allocate it', async () => { - const wc = mockWebContents(100) - webContentsFromIdMock.mockReturnValue(wc) - - await expect( - bridge.setViewport(BROWSER_SCREENSHOT_MAX_DIMENSION_PX + 1, 1, 1) - ).rejects.toMatchObject({ - code: 'browser_screenshot_too_large', - message: BROWSER_SCREENSHOT_MEMORY_LIMIT_ERROR - }) - expect(wc.debugger.sendCommand).not.toHaveBeenCalled() - }) - it('normalizes selector wait state=visible to the default supported semantics', async () => { succeedWith({ selector: 'h1', waited: 'selector' }) diff --git a/src/main/browser/agent-browser-bridge.ts b/src/main/browser/agent-browser-bridge.ts index 1063d7b6843..565352d3d50 100644 --- a/src/main/browser/agent-browser-bridge.ts +++ b/src/main/browser/agent-browser-bridge.ts @@ -1,19 +1,11 @@ /* eslint-disable max-lines */ import { execFile, type ChildProcess } from 'node:child_process' -import { existsSync, accessSync, chmodSync, constants } from 'node:fs' +import { existsSync, accessSync, chmodSync, readFileSync, constants } from 'node:fs' import { join } from 'node:path' import { platform, arch } from 'node:os' import { app, type WebContents } from 'electron' import { CdpWsProxy } from './cdp-ws-proxy' import { captureFullPageScreenshot } from './cdp-screenshot' -import { assertCdpPdfWithinMemoryLimit } from './cdp-print-to-pdf' -import { - assertBrowserScreenshotGeometry, - BROWSER_SCREENSHOT_BUSY_ERROR, - BROWSER_SCREENSHOT_MEMORY_LIMIT_ERROR -} from './browser-screenshot-limits' -import { readBrowserScreenshotFile } from './browser-screenshot-file-reader' -import { BROWSER_PDF_BUSY_ERROR, startBrowserPdfPrint } from './browser-pdf-admission' import { acquireElectronDebugger } from './electron-debugger-lease' import type { BrowserManager } from './browser-manager' import { BrowserError } from './cdp-bridge' @@ -67,8 +59,6 @@ const STALE_SESSION_CLOSE_TIMEOUT_MS = 3_000 const EMBEDDED_NAVIGATION_TIMEOUT_MS = 30_000 export const AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES = 8 * 1024 export const AGENT_BROWSER_CLIPBOARD_WRITE_MAX_BYTES = AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES -export const AGENT_BROWSER_MAX_QUEUED_COMMANDS_PER_SESSION = 64 -const AGENT_BROWSER_MAX_QUEUED_COMMANDS_TOTAL = 512 type SessionState = { proxy: CdpWsProxy @@ -585,9 +575,6 @@ export class AgentBrowserBridge { private readonly sessions = new Map() private readonly commandQueues = new Map() private readonly processingQueues = new Set() - private queuedCommandCount = 0 - private readonly pendingEnqueueCounts = new Map() - private pendingEnqueueCount = 0 // Why: screenshot prep mutates shared paintability across tabs; serialize globally so concurrent captures don't blank each other. private screenshotTurn: Promise = Promise.resolve() private readonly agentBrowserBin: string @@ -1496,17 +1483,7 @@ export class AgentBrowserBridge { if (!existsSync(parsed.path)) { throw new BrowserError('browser_error', `Screenshot file not found: ${parsed.path}`) } - let bytes: Buffer - try { - bytes = readBrowserScreenshotFile(parsed.path) - } catch (error) { - const message = error instanceof Error ? error.message : 'Unable to read screenshot file' - if (message !== BROWSER_SCREENSHOT_MEMORY_LIMIT_ERROR) { - throw new BrowserError('browser_error', message) - } - throw new BrowserError('browser_screenshot_too_large', BROWSER_SCREENSHOT_MEMORY_LIMIT_ERROR) - } - const data = bytes.toString('base64') + const data = readFileSync(parsed.path).toString('base64') return { data, format: format === 'jpeg' ? 'jpeg' : 'png' } as BrowserScreenshotResult } @@ -1552,17 +1529,7 @@ export class AgentBrowserBridge { } return await captureFullPageScreenshot(wc, format) } catch (error) { - if (error instanceof BrowserError) { - throw error - } - const message = error instanceof Error ? error.message : 'Screenshot failed' - if (message === BROWSER_SCREENSHOT_MEMORY_LIMIT_ERROR) { - throw new BrowserError('browser_screenshot_too_large', message) - } - if (message === BROWSER_SCREENSHOT_BUSY_ERROR) { - throw new BrowserError('browser_busy', message) - } - throw new BrowserError('browser_error', message) + throw new BrowserError('browser_error', (error as Error).message) } finally { restore() } @@ -1813,15 +1780,10 @@ export class AgentBrowserBridge { if (!wc) { throw new BrowserError('browser_no_tab', 'Tab is no longer available') } - const print = startBrowserPdfPrint(wc, { + const buffer = await wc.printToPDF({ printBackground: true, preferCSSPageSize: true }) - if (!print) { - throw new BrowserError('browser_busy', BROWSER_PDF_BUSY_ERROR) - } - const buffer = await print - assertCdpPdfWithinMemoryLimit(buffer) return { data: buffer.toString('base64') } }) } @@ -1899,14 +1861,6 @@ export class AgentBrowserBridge { worktreeId?: string, browserPageId?: string ): Promise { - try { - assertBrowserScreenshotGeometry(width, height, scale) - } catch (error) { - throw new BrowserError( - 'browser_screenshot_too_large', - error instanceof Error ? error.message : BROWSER_SCREENSHOT_MEMORY_LIMIT_ERROR - ) - } return this.enqueueTargetedCommand(worktreeId, browserPageId, async (_sessionName, target) => { const wc = this.getWebContents(target.webContentsId) if (!wc) { @@ -2115,29 +2069,12 @@ export class AgentBrowserBridge { const target = this.resolveCommandTarget(worktreeId, browserPageId, options.requireScopedTarget) const sessionName = `orca-tab-${target.browserPageId}` - this.acquirePendingEnqueue(sessionName) - try { - if (options.ensureSession !== false) { - await this.ensureSession(sessionName, target.browserPageId, target.webContentsId) - } - } finally { - this.releasePendingEnqueue(sessionName) + if (options.ensureSession !== false) { + await this.ensureSession(sessionName, target.browserPageId, target.webContentsId) } return new Promise((resolve, reject) => { let queue = this.commandQueues.get(sessionName) - if ( - (queue?.length ?? 0) >= AGENT_BROWSER_MAX_QUEUED_COMMANDS_PER_SESSION || - this.queuedCommandCount >= AGENT_BROWSER_MAX_QUEUED_COMMANDS_TOTAL - ) { - reject( - new BrowserError( - 'browser_busy', - 'Browser command queue is full; retry after the current commands finish' - ) - ) - return - } if (!queue) { queue = [] this.commandQueues.set(sessionName, queue) @@ -2154,37 +2091,10 @@ export class AgentBrowserBridge { resolve: resolve as (value: unknown) => void, reject }) - this.queuedCommandCount += 1 this.processQueue(sessionName) }) } - private acquirePendingEnqueue(sessionName: string): void { - const pendingForSession = this.pendingEnqueueCounts.get(sessionName) ?? 0 - const queuedForSession = this.commandQueues.get(sessionName)?.length ?? 0 - if ( - pendingForSession + queuedForSession >= AGENT_BROWSER_MAX_QUEUED_COMMANDS_PER_SESSION || - this.pendingEnqueueCount + this.queuedCommandCount >= AGENT_BROWSER_MAX_QUEUED_COMMANDS_TOTAL - ) { - throw new BrowserError( - 'browser_busy', - 'Browser command queue is full; retry after the current commands finish' - ) - } - this.pendingEnqueueCounts.set(sessionName, pendingForSession + 1) - this.pendingEnqueueCount += 1 - } - - private releasePendingEnqueue(sessionName: string): void { - const remaining = (this.pendingEnqueueCounts.get(sessionName) ?? 1) - 1 - if (remaining > 0) { - this.pendingEnqueueCounts.set(sessionName, remaining) - } else { - this.pendingEnqueueCounts.delete(sessionName) - } - this.pendingEnqueueCount = Math.max(0, this.pendingEnqueueCount - 1) - } - private async executeWithVisibleTarget( sessionName: string, worktreeId: string | undefined, @@ -2249,7 +2159,6 @@ export class AgentBrowserBridge { const queue = this.commandQueues.get(sessionName) while (queue && queue.length > 0) { const cmd = queue.shift()! - this.queuedCommandCount = Math.max(0, this.queuedCommandCount - 1) try { const result = await cmd.execute() cmd.resolve(result) @@ -2547,7 +2456,6 @@ export class AgentBrowserBridge { this.commandQueues.delete(sessionName) this.processingQueues.delete(sessionName) if (queue) { - this.queuedCommandCount = Math.max(0, this.queuedCommandCount - queue.length) const err = new BrowserError( 'browser_tab_closed', 'Tab was closed while commands were queued' diff --git a/src/main/browser/browser-cookie-import.comet.test.ts b/src/main/browser/browser-cookie-import.comet.test.ts index 1bb906bb2c2..47e84db1607 100644 --- a/src/main/browser/browser-cookie-import.comet.test.ts +++ b/src/main/browser/browser-cookie-import.comet.test.ts @@ -1,14 +1,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as childProcessModule from 'node:child_process' import type * as fsModule from 'node:fs' -import type * as boundedFileReaderModule from '../../shared/node-bounded-file-reader' -const { sessionFromPartitionMock, dialogShowOpenDialogMock, boundedLocalStateReadMock } = - vi.hoisted(() => ({ - sessionFromPartitionMock: vi.fn(), - dialogShowOpenDialogMock: vi.fn(), - boundedLocalStateReadMock: vi.fn<(filePath: string) => string>() - })) +const { sessionFromPartitionMock, dialogShowOpenDialogMock } = vi.hoisted(() => ({ + sessionFromPartitionMock: vi.fn(), + dialogShowOpenDialogMock: vi.fn() +})) vi.mock('electron', () => ({ BrowserWindow: { fromWebContents: vi.fn() }, @@ -16,32 +13,12 @@ vi.mock('electron', () => ({ session: { fromPartition: sessionFromPartitionMock } })) -vi.mock('../../shared/node-bounded-file-reader', async () => { - const actual = await vi.importActual( - '../../shared/node-bounded-file-reader' - ) - return { - ...actual, - readNodeFileSyncWithinLimit: (filePath: string, maxBytes: number) => { - const buffer = Buffer.from(boundedLocalStateReadMock(filePath)) - if (buffer.byteLength > maxBytes) { - throw new actual.NodeFileReadTooLargeError(buffer.byteLength, maxBytes) - } - return { buffer, stats: { size: buffer.byteLength } } - } - } -}) - import { BROWSER_FAMILY_LABELS } from '../../shared/constants' function slashPath(pathValue: string): string { return pathValue.replaceAll('\\', '/') } -function mockLocalState(value: unknown): void { - boundedLocalStateReadMock.mockReturnValue(JSON.stringify(value)) -} - describe('detectInstalledBrowsers — Comet', () => { const originalPlatform = process.platform const originalHome = process.env.HOME @@ -55,10 +32,6 @@ describe('detectInstalledBrowsers — Comet', () => { vi.resetModules() Object.defineProperty(process, 'platform', { value: 'darwin' }) process.env.HOME = '/Users/test' - boundedLocalStateReadMock.mockReset() - boundedLocalStateReadMock.mockImplementation(() => { - throw new Error('ENOENT') - }) }) afterEach(() => { @@ -68,7 +41,6 @@ describe('detectInstalledBrowsers — Comet', () => { }) it('detects Comet when its data directory and Cookies DB exist', async () => { - mockLocalState({ profile: { info_cache: { Default: { name: 'Default' } } } }) vi.doMock('node:fs', async () => { const actual = await vi.importActual('node:fs') return { @@ -116,15 +88,6 @@ describe('detectInstalledBrowsers — Comet', () => { }) it('enumerates all Comet profiles from Local State info_cache', async () => { - mockLocalState({ - profile: { - info_cache: { - Default: { name: 'Personal' }, - 'Profile 1': { name: 'Work' }, - 'Profile 2': { name: 'Research' } - } - } - }) vi.doMock('node:fs', async () => { const actual = await vi.importActual('node:fs') return { @@ -167,13 +130,6 @@ describe('detectInstalledBrowsers — Comet', () => { }) it('ignores Comet profile directories that escape the browser root', async () => { - mockLocalState({ - profile: { - info_cache: { - '../Outside': { name: 'Outside' } - } - } - }) vi.doMock('node:fs', async () => { const actual = await vi.importActual('node:fs') return { @@ -239,7 +195,6 @@ describe('detectInstalledBrowsers — Comet', () => { }) it('skips Comet when the data directory exists but no Cookies DB is present', async () => { - mockLocalState({ profile: { info_cache: { Default: { name: 'Default' } } } }) vi.doMock('node:fs', async () => { const actual = await vi.importActual('node:fs') return { diff --git a/src/main/browser/browser-cookie-import.helium.test.ts b/src/main/browser/browser-cookie-import.helium.test.ts index 4dc44ee3ea8..1c551ac004a 100644 --- a/src/main/browser/browser-cookie-import.helium.test.ts +++ b/src/main/browser/browser-cookie-import.helium.test.ts @@ -1,14 +1,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as childProcessModule from 'node:child_process' import type * as fsModule from 'node:fs' -import type * as boundedFileReaderModule from '../../shared/node-bounded-file-reader' -const { sessionFromPartitionMock, dialogShowOpenDialogMock, boundedLocalStateReadMock } = - vi.hoisted(() => ({ - sessionFromPartitionMock: vi.fn(), - dialogShowOpenDialogMock: vi.fn(), - boundedLocalStateReadMock: vi.fn<(filePath: string) => string>() - })) +const { sessionFromPartitionMock, dialogShowOpenDialogMock } = vi.hoisted(() => ({ + sessionFromPartitionMock: vi.fn(), + dialogShowOpenDialogMock: vi.fn() +})) vi.mock('electron', () => ({ BrowserWindow: { fromWebContents: vi.fn() }, @@ -16,32 +13,12 @@ vi.mock('electron', () => ({ session: { fromPartition: sessionFromPartitionMock } })) -vi.mock('../../shared/node-bounded-file-reader', async () => { - const actual = await vi.importActual( - '../../shared/node-bounded-file-reader' - ) - return { - ...actual, - readNodeFileSyncWithinLimit: (filePath: string, maxBytes: number) => { - const buffer = Buffer.from(boundedLocalStateReadMock(filePath)) - if (buffer.byteLength > maxBytes) { - throw new actual.NodeFileReadTooLargeError(buffer.byteLength, maxBytes) - } - return { buffer, stats: { size: buffer.byteLength } } - } - } -}) - import { BROWSER_FAMILY_LABELS } from '../../shared/constants' function slashPath(pathValue: string): string { return pathValue.replaceAll('\\', '/') } -function mockLocalState(value: unknown): void { - boundedLocalStateReadMock.mockReturnValue(JSON.stringify(value)) -} - describe('detectInstalledBrowsers — Helium', () => { const originalPlatform = process.platform const originalHome = process.env.HOME @@ -53,10 +30,6 @@ describe('detectInstalledBrowsers — Helium', () => { vi.resetModules() Object.defineProperty(process, 'platform', { value: 'darwin' }) process.env.HOME = '/Users/test' - boundedLocalStateReadMock.mockReset() - boundedLocalStateReadMock.mockImplementation(() => { - throw new Error('ENOENT') - }) }) afterEach(() => { @@ -66,7 +39,6 @@ describe('detectInstalledBrowsers — Helium', () => { }) it('detects Helium under its bundle-id data dir via the legacy Cookies path', async () => { - mockLocalState({ profile: { info_cache: { Default: { name: 'Default' } } } }) vi.doMock('node:fs', async () => { const actual = await vi.importActual('node:fs') return { @@ -121,14 +93,6 @@ describe('detectInstalledBrowsers — Helium', () => { }) it('enumerates all Helium profiles from Local State info_cache', async () => { - mockLocalState({ - profile: { - info_cache: { - Default: { name: 'Personal' }, - 'Profile 1': { name: 'Work' } - } - } - }) vi.doMock('node:fs', async () => { const actual = await vi.importActual('node:fs') return { diff --git a/src/main/browser/browser-cookie-import.test.ts b/src/main/browser/browser-cookie-import.test.ts index 1c1f2b9c85b..27663017097 100644 --- a/src/main/browser/browser-cookie-import.test.ts +++ b/src/main/browser/browser-cookie-import.test.ts @@ -36,7 +36,6 @@ vi.mock('electron', () => ({ import { buildChromiumCookieInsertParams, - CHROMIUM_LOCAL_STATE_MAX_BYTES, importCookiesFromFile, importCookiesFromBrowser, detectInstalledBrowsers, @@ -48,20 +47,7 @@ import { createChromiumCookieTestDatabase, encryptMacChromiumCookie } from './browser-cookie-import-test-database' -import { COOKIE_JSON_FILE_MAX_BYTES } from './browser-cookie-json-file-parser' -import { INSTALLED_BROWSER_COOKIE_STORE_MAX_BYTES } from './installed-browser-cookie-store-limits' -import { SAFARI_COOKIE_STORE_MAX_FILE_BYTES } from './safari-cookie-store-decoder' -import { - existsSync, - mkdtempSync, - mkdirSync, - readFileSync, - readdirSync, - rmSync, - truncateSync, - writeFileSync -} from 'node:fs' -import { DatabaseSync } from 'node:sqlite' +import { existsSync, writeFileSync, mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' @@ -77,40 +63,6 @@ function chromeBrowser(cookiesPath: string): DetectedBrowser { } } -function firefoxBrowser(cookiesPath: string): DetectedBrowser { - return { - family: 'firefox', - label: 'Firefox', - cookiesPath, - profiles: [{ name: 'default', directory: 'default' }], - selectedProfile: 'default' - } -} - -function createFirefoxCookieTestDatabase( - databasePath: string, - rows: { name: string; value: string; host: string }[] -): void { - const database = new DatabaseSync(databasePath) - database.exec(` - CREATE TABLE moz_cookies ( - name TEXT, - value TEXT, - host TEXT, - path TEXT, - expiry INTEGER, - isSecure INTEGER, - isHttpOnly INTEGER, - sameSite INTEGER - ) - `) - const insert = database.prepare('INSERT INTO moz_cookies VALUES (?, ?, ?, ?, ?, ?, ?, ?)') - for (const row of rows) { - insert.run(row.name, row.value, row.host, '/', 2_000_000_000, 0, 0, 0) - } - database.close() -} - const LARGE_SAFARI_COOKIE_COUNT = 150_000 describe('summarizeCookieImportError', () => { @@ -329,20 +281,6 @@ describe('importCookiesFromFile', () => { expect(result.reason).toContain('Could not read') }) - it('rejects an oversized JSON file before reading its bytes', async () => { - const filePath = join(tmpDir, 'oversized.json') - writeFileSync(filePath, '') - truncateSync(filePath, COOKIE_JSON_FILE_MAX_BYTES + 1) - - const result = await importCookiesFromFile(filePath, 'persist:test') - - expect(result).toEqual({ - ok: false, - reason: 'Cookie file is too large to import safely (64 MiB file limit).' - }) - expect(sessionFromPartitionMock).not.toHaveBeenCalled() - }) - it('normalizes sameSite values', async () => { const filePath = writeCookieFile([ { domain: '.test.com', name: 'a', value: '1', sameSite: 'None' }, @@ -426,94 +364,6 @@ describe('importCookiesFromBrowser Safari', () => { expect(result).toEqual({ ok: false, reason: 'All Safari cookies are expired.' }) expect(cookiesSetMock).not.toHaveBeenCalled() }) - - it('rejects an oversized Safari store before reading its bytes', async () => { - const cookiesPath = join(tmpDir, 'Cookies.binarycookies') - writeFileSync(cookiesPath, '') - truncateSync(cookiesPath, SAFARI_COOKIE_STORE_MAX_FILE_BYTES + 1) - const browser: DetectedBrowser = { - family: 'safari', - label: 'Safari', - cookiesPath, - profiles: [], - selectedProfile: 'Default' - } - - const result = await importCookiesFromBrowser(browser, 'persist:test') - - expect(result).toEqual({ - ok: false, - reason: 'Safari cookie store is too large to import safely (64 MiB file limit).' - }) - expect(sessionFromPartitionMock).not.toHaveBeenCalled() - }) -}) - -describe('importCookiesFromBrowser Firefox', () => { - let tmpDir: string - let cookiesSetMock: ReturnType - - beforeEach(() => { - tmpDir = mkdtempSync(join(tmpdir(), 'orca-firefox-cookie-test-')) - cookiesSetMock = vi.fn().mockResolvedValue(undefined) - sessionFromPartitionMock.mockReset() - sessionFromPartitionMock.mockReturnValue({ - cookies: { set: cookiesSetMock } - }) - }) - - afterEach(() => { - rmSync(tmpDir, { recursive: true, force: true }) - }) - - it('streams ordinary cookies in source order without changing the summary', async () => { - const cookiesPath = join(tmpDir, 'cookies.sqlite') - createFirefoxCookieTestDatabase(cookiesPath, [ - { name: 'first', value: 'one', host: '.first.example.com' }, - { name: 'second', value: 'two', host: '.second.example.com' } - ]) - - const result = await importCookiesFromBrowser(firefoxBrowser(cookiesPath), 'persist:test') - - expect(result).toEqual({ - ok: true, - profileId: '', - summary: { - totalCookies: 2, - importedCookies: 2, - skippedCookies: 0, - domains: ['first.example.com', 'second.example.com'] - } - }) - expect(cookiesSetMock.mock.calls.map(([cookie]) => cookie.name)).toEqual(['first', 'second']) - }) - - it('rejects oversized cookie data before iterating or mutating the target session', async () => { - const cookiesPath = join(tmpDir, 'cookies.sqlite') - const database = new DatabaseSync(cookiesPath) - database.exec(` - CREATE VIEW moz_cookies AS - SELECT - 'sid' AS name, - zeroblob(${INSTALLED_BROWSER_COOKIE_STORE_MAX_BYTES + 1}) AS value, - '.example.com' AS host, - '/' AS path, - 0 AS expiry, - 0 AS isSecure, - 0 AS isHttpOnly, - 0 AS sameSite - `) - database.close() - - const result = await importCookiesFromBrowser(firefoxBrowser(cookiesPath), 'persist:test') - - expect(result).toEqual({ - ok: false, - reason: - 'Firefox cookie store is too large to import safely (250,000-cookie and 64 MiB cookie-data limits).' - }) - expect(sessionFromPartitionMock).not.toHaveBeenCalled() - }) }) describe('importCookiesFromBrowser Chromium', () => { @@ -680,79 +530,9 @@ describe('importCookiesFromBrowser Chromium', () => { expect(result).toEqual({ ok: false, reason: 'Could not create staging cookie database.' }) expect(readdirSync(join(tmpDir, 'userData', 'cookie-import-staging'))).toEqual([]) }) - - it('rejects oversized source data before decrypting or clearing target cookies', async () => { - const sourceCookiesPath = join(tmpDir, 'Chrome', 'Default', 'Network', 'Cookies') - const targetCookiesPath = join(tmpDir, 'userData', 'Partitions', 'test', 'Network', 'Cookies') - mkdirSync(join(sourceCookiesPath, '..'), { recursive: true }) - const sourceDatabase = new DatabaseSync(sourceCookiesPath) - sourceDatabase.exec(` - CREATE VIEW cookies AS - SELECT - 1 AS creation_utc, - '.example.com' AS host_key, - '' AS top_frame_site_key, - 'sid' AS name, - zeroblob(${INSTALLED_BROWSER_COOKIE_STORE_MAX_BYTES + 1}) AS value, - X'' AS encrypted_value, - '/' AS path, - 0 AS expires_utc, - 0 AS is_secure, - 0 AS is_httponly, - 0 AS samesite, - 0 AS source_scheme, - -1 AS source_port, - 0 AS last_update_utc, - 0 AS has_cross_site_ancestor - `) - sourceDatabase.close() - createChromiumCookieTestDatabase(targetCookiesPath, []).close() - - const result = await importCookiesFromBrowser(chromeBrowser(sourceCookiesPath), 'persist:test') - - expect(result).toEqual({ - ok: false, - reason: - 'Google Chrome cookie store is too large to import safely (250,000-cookie and 64 MiB cookie-data limits).' - }) - expect(clearStorageDataMock).not.toHaveBeenCalled() - expect(cookiesSetMock).not.toHaveBeenCalled() - expect(readdirSync(join(tmpDir, 'userData', 'cookie-import-staging'))).toEqual([]) - }) }) describe('detectInstalledBrowsers', () => { - it('falls back safely when Chromium Local State exceeds its read cap', () => { - const root = mkdtempSync(join(tmpdir(), 'orca-browser-detection-bounds-')) - const originalConfigHome = process.env.XDG_CONFIG_HOME - const originalPlatform = process.platform - try { - Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) - process.env.XDG_CONFIG_HOME = root - const browserRoot = join(root, 'google-chrome') - mkdirSync(join(browserRoot, 'Default'), { recursive: true }) - writeFileSync(join(browserRoot, 'Default', 'Cookies'), '') - writeFileSync(join(browserRoot, 'Local State'), '') - truncateSync(join(browserRoot, 'Local State'), CHROMIUM_LOCAL_STATE_MAX_BYTES + 1) - - const chrome = detectInstalledBrowsers().find((browser) => browser.family === 'chrome') - - expect(chrome?.profiles).toEqual([{ name: 'Default', directory: 'Default' }]) - expect(chrome?.selectedProfile).toBe('Default') - } finally { - Object.defineProperty(process, 'platform', { - configurable: true, - value: originalPlatform - }) - if (originalConfigHome === undefined) { - delete process.env.XDG_CONFIG_HOME - } else { - process.env.XDG_CONFIG_HOME = originalConfigHome - } - rmSync(root, { recursive: true, force: true }) - } - }) - it('returns an array of detected browsers', () => { const browsers = detectInstalledBrowsers() expect(Array.isArray(browsers)).toBe(true) diff --git a/src/main/browser/browser-cookie-import.ts b/src/main/browser/browser-cookie-import.ts index 5e1e30b9a18..7bcfd4ea247 100644 --- a/src/main/browser/browser-cookie-import.ts +++ b/src/main/browser/browser-cookie-import.ts @@ -8,10 +8,12 @@ import { existsSync, mkdtempSync, mkdirSync, - opendirSync, + readFileSync, + readdirSync, rmSync, unlinkSync } from 'node:fs' +import { readFile } from 'node:fs/promises' import { DatabaseSync } from 'node:sqlite' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -71,42 +73,13 @@ import type { BrowserCookieImportSummary, BrowserSessionProfileSource } from '../../shared/types' -import { - NodeFileReadTooLargeError, - readNodeFileWithinLimit, - readNodeFileSyncWithinLimit -} from '../../shared/node-bounded-file-reader' import { browserSessionRegistry } from './browser-session-registry' import { setupClientHintsOverride } from './browser-session-ua' -import { - COOKIE_JSON_FILE_MAX_BYTES, - COOKIE_JSON_FILE_MAX_DEPTH, - COOKIE_JSON_FILE_MAX_ENTRIES, - COOKIE_JSON_FILE_MAX_RETAINED_BYTES, - CookieJsonFileFormatError, - CookieJsonFileLimitError, - enforceCookieJsonRetainedBytes, - visitCookieJsonFileObjects -} from './browser-cookie-json-file-parser' import { createChromiumCookieSnapshot, type ChromiumCookieSnapshot } from './chromium-cookie-snapshot' import { resolveChromiumCookiesPath } from './chromium-cookie-path' -import { - assertInstalledBrowserCookieStoreWithinLimits, - InstalledBrowserCookieStoreLimitError, - installedBrowserCookieStoreLimitReason -} from './installed-browser-cookie-store-limits' -import { - decodeSafariCookieStore, - SAFARI_COOKIE_STORE_MAX_COOKIES, - SAFARI_COOKIE_STORE_MAX_FILE_BYTES, - SAFARI_COOKIE_STORE_MAX_PAGES, - SAFARI_COOKIE_STORE_MAX_PARSED_BYTES, - SafariCookieStoreLimitError, - removeExpiredSafariCookiesInPlace -} from './safari-cookie-store-decoder' // --------------------------------------------------------------------------- // Browser detection @@ -117,10 +90,6 @@ export type BrowserProfile = { directory: string } -export const CHROMIUM_LOCAL_STATE_MAX_BYTES = 16 * 1024 * 1024 -const BROWSER_PROFILE_SCAN_MAX_ENTRIES = 10_000 -const BROWSER_PROFILE_RETAINED_NAME_MAX_BYTES = 4 * 1024 * 1024 - export type DetectedBrowser = { family: BrowserSessionProfileSource['browserFamily'] label: string @@ -241,38 +210,19 @@ function discoverProfiles(browserRoot: string): BrowserProfile[] { if (!existsSync(localStatePath)) { return [{ name: 'Default', directory: 'Default' }] } - const localState = JSON.parse( - readNodeFileSyncWithinLimit(localStatePath, CHROMIUM_LOCAL_STATE_MAX_BYTES).buffer.toString( - 'utf8' - ) - ) + const raw = readFileSync(localStatePath, 'utf-8') + const localState = JSON.parse(raw) const infoCache = localState?.profile?.info_cache if (!infoCache || typeof infoCache !== 'object') { return [{ name: 'Default', directory: 'Default' }] } const profiles: BrowserProfile[] = [] - let retainedNameBytes = 0 - let visitedEntries = 0 - for (const dir in infoCache) { - if (!Object.hasOwn(infoCache, dir)) { - continue - } - visitedEntries++ - if (visitedEntries > BROWSER_PROFILE_SCAN_MAX_ENTRIES) { - return [{ name: 'Default', directory: 'Default' }] - } + for (const [dir, info] of Object.entries(infoCache)) { // Why: Local State is external metadata, but profile dirs become path segments. if (!isSafeBrowserProfileDirectory(dir)) { continue } - const info = (infoCache as Record)[dir] - const configuredName = - info && typeof info === 'object' ? (info as { name?: unknown }).name : undefined - const profileName = typeof configuredName === 'string' ? configuredName : dir - retainedNameBytes += Buffer.byteLength(dir, 'utf8') + Buffer.byteLength(profileName, 'utf8') - if (retainedNameBytes > BROWSER_PROFILE_RETAINED_NAME_MAX_BYTES) { - return [{ name: 'Default', directory: 'Default' }] - } + const profileName = (info as { name?: string })?.name ?? dir profiles.push({ name: profileName, directory: dir }) } return profiles.length > 0 ? profiles : [{ name: 'Default', directory: 'Default' }] @@ -307,30 +257,9 @@ function discoverFirefoxProfiles(): BrowserProfile[] { if (!existsSync(profilesRoot)) { return [] } - const entries: string[] = [] - let retainedNameBytes = 0 - const directory = opendirSync(profilesRoot) - try { - for (let visitedEntries = 0; ; visitedEntries++) { - const entry = directory.readSync() - if (!entry) { - break - } - if (visitedEntries >= BROWSER_PROFILE_SCAN_MAX_ENTRIES) { - return [] - } - if (!entry.isDirectory()) { - continue - } - retainedNameBytes += Buffer.byteLength(entry.name, 'utf8') - if (retainedNameBytes > BROWSER_PROFILE_RETAINED_NAME_MAX_BYTES) { - return [] - } - entries.push(entry.name) - } - } finally { - directory.closeSync() - } + const entries = readdirSync(profilesRoot, { withFileTypes: true }) + .filter((e) => e.isDirectory()) + .map((e) => e.name) // Why: Firefox dirs are named .; prefer 'default-release' as the primary profile on most installs. const sorted = entries.sort((a, b) => { if (a.includes('default-release')) { @@ -381,6 +310,8 @@ function detectFirefox(): DetectedBrowser | null { // Safari detection // --------------------------------------------------------------------------- +const MAC_EPOCH_DELTA = 978_307_200 + function detectSafari(): DetectedBrowser | null { if (process.platform !== 'darwin') { return null @@ -714,62 +645,41 @@ export async function importCookiesFromFile( ): Promise { let rawContent: string try { - rawContent = ( - await readNodeFileWithinLimit(filePath, COOKIE_JSON_FILE_MAX_BYTES) - ).buffer.toString('utf8') - } catch (err) { - if (err instanceof NodeFileReadTooLargeError) { - return { - ok: false, - reason: `Cookie file is too large to import safely (${COOKIE_JSON_FILE_MAX_BYTES / 1024 / 1024} MiB file limit).` - } - } + rawContent = await readFile(filePath, 'utf-8') + } catch { return { ok: false, reason: 'Could not read the selected file.' } } - const validated: ValidatedCookie[] = [] - let totalEntries = 0 - let retainedBytes = 0 + let parsed: unknown try { - totalEntries = visitCookieJsonFileObjects(rawContent, (entry) => { - const cookie = validateCookieEntry(entry as RawCookieEntry) - if (!cookie) { - return - } - retainedBytes += - Buffer.byteLength(cookie.url) + - Buffer.byteLength(cookie.name) + - Buffer.byteLength(cookie.value) + - Buffer.byteLength(cookie.domain) + - Buffer.byteLength(cookie.path) - enforceCookieJsonRetainedBytes(retainedBytes) - validated.push(cookie) - }) - } catch (err) { - if (err instanceof CookieJsonFileFormatError) { - return err.kind === 'root' - ? { ok: false, reason: 'Expected a JSON array of cookie objects.' } - : { ok: false, reason: 'File is not valid JSON.' } - } - if (err instanceof CookieJsonFileLimitError) { - const limit = { - depth: `${COOKIE_JSON_FILE_MAX_DEPTH} levels of nesting`, - entries: `${COOKIE_JSON_FILE_MAX_ENTRIES.toLocaleString('en-US')} entries`, - 'retained-bytes': `${COOKIE_JSON_FILE_MAX_RETAINED_BYTES / 1024 / 1024} MiB parsed cookie data` - }[err.kind] - return { - ok: false, - reason: `Cookie file is too large to import safely (${limit} limit).` - } - } + parsed = JSON.parse(rawContent) + } catch { return { ok: false, reason: 'File is not valid JSON.' } } - if (totalEntries === 0) { + if (!Array.isArray(parsed)) { + return { ok: false, reason: 'Expected a JSON array of cookie objects.' } + } + + if (parsed.length === 0) { return { ok: false, reason: 'Cookie file is empty.' } } - const skipped = totalEntries - validated.length + const validated: ValidatedCookie[] = [] + let skipped = 0 + for (const entry of parsed) { + if (typeof entry !== 'object' || entry === null) { + skipped++ + continue + } + const cookie = validateCookieEntry(entry as RawCookieEntry) + if (cookie) { + validated.push(cookie) + } else { + skipped++ + } + } + if (validated.length === 0) { return { ok: false, @@ -777,7 +687,7 @@ export async function importCookiesFromFile( } } - return importValidatedCookies(validated, totalEntries, targetPartition) + return importValidatedCookies(validated, parsed.length, targetPartition) } // --------------------------------------------------------------------------- @@ -1076,11 +986,8 @@ function getWindowsEncryptionKey(browser: DetectedBrowser): EncryptionKeyResult } try { - const localState = JSON.parse( - readNodeFileSyncWithinLimit(localStatePath, CHROMIUM_LOCAL_STATE_MAX_BYTES).buffer.toString( - 'utf8' - ) - ) + const raw = readFileSync(localStatePath, 'utf-8') + const localState = JSON.parse(raw) const encryptedKeyB64 = localState?.os_crypt?.encrypted_key if (typeof encryptedKeyB64 !== 'string') { return null @@ -1196,6 +1103,144 @@ function decryptAes256Gcm(payload: Buffer, key: Buffer): Buffer | null { } } +// --------------------------------------------------------------------------- +// Safari binary cookie parser +// --------------------------------------------------------------------------- + +function decodeSafariBinaryCookies(buffer: Buffer): ValidatedCookie[] { + if (buffer.length < 8) { + return [] + } + if (buffer.subarray(0, 4).toString('utf8') !== 'cook') { + return [] + } + + const pageCount = buffer.readUInt32BE(4) + let cursor = 8 + if (cursor + pageCount * 4 > buffer.length) { + return [] + } + const pageSizes: number[] = [] + for (let i = 0; i < pageCount; i++) { + pageSizes.push(buffer.readUInt32BE(cursor)) + cursor += 4 + } + + const cookies: ValidatedCookie[] = [] + for (const pageSize of pageSizes) { + const page = buffer.subarray(cursor, cursor + pageSize) + cursor += pageSize + appendSafariCookies(cookies, decodeSafariPage(page)) + } + return cookies +} + +function appendSafariCookies(target: ValidatedCookie[], cookies: readonly ValidatedCookie[]): void { + // Why: pages can hold large cookie lists; push per-item to avoid exceeding the spread argument limit. + for (const cookie of cookies) { + target.push(cookie) + } +} + +function decodeSafariPage(page: Buffer): ValidatedCookie[] { + if (page.length < 16) { + return [] + } + if (page.readUInt32BE(0) !== 0x00000100) { + return [] + } + + const cookieCount = page.readUInt32LE(4) + if (8 + cookieCount * 4 > page.length) { + return [] + } + const offsets: number[] = [] + let cursor = 8 + for (let i = 0; i < cookieCount; i++) { + offsets.push(page.readUInt32LE(cursor)) + cursor += 4 + } + + const cookies: ValidatedCookie[] = [] + for (const offset of offsets) { + const cookie = decodeSafariCookie(page.subarray(offset)) + if (cookie) { + cookies.push(cookie) + } + } + return cookies +} + +function decodeSafariCookie(buf: Buffer): ValidatedCookie | null { + if (buf.length < 48) { + return null + } + // Why: size comes from the file and could be attacker-controlled; clamp so readCString can't escape the subarray. + const size = Math.min(buf.readUInt32LE(0), buf.length) + if (size < 48) { + return null + } + + const flags = buf.readUInt32LE(8) + const secure = (flags & 1) !== 0 + const httpOnly = (flags & 4) !== 0 + + const urlOffset = buf.readUInt32LE(16) + const nameOffset = buf.readUInt32LE(20) + const pathOffset = buf.readUInt32LE(24) + const valueOffset = buf.readUInt32LE(28) + + // Why: Safari stores dates as Mac absolute time (seconds since 2001-01-01). + const expiration = buf.length >= 48 ? buf.readDoubleLE(40) : 0 + + const name = readCString(buf, nameOffset, size) + if (!name) { + return null + } + const value = readCString(buf, valueOffset, size) ?? '' + const path = readCString(buf, pathOffset, size) ?? '/' + const rawUrl = readCString(buf, urlOffset, size) ?? '' + + // Why: Safari stores the domain in the URL field, not as a separate domain column. + const domain = rawUrl.startsWith('.') ? rawUrl : rawUrl || null + if (!domain) { + return null + } + + const url = deriveUrl(domain, secure) + if (!url) { + return null + } + + const expirationDate = expiration > 0 ? Math.round(expiration + MAC_EPOCH_DELTA) : undefined + + return { + url, + name, + value, + domain, + path, + secure, + httpOnly, + sameSite: 'unspecified', + expirationDate + } +} + +function readCString(buf: Buffer, offset: number, end: number): string | null { + if (offset < 0 || offset >= end) { + return null + } + let cursor = offset + while (cursor < end && buf[cursor] !== 0) { + cursor++ + } + if (cursor >= end) { + return null + } + return buf.toString('utf8', offset, cursor) +} + // --------------------------------------------------------------------------- // Firefox import // --------------------------------------------------------------------------- @@ -1229,9 +1274,8 @@ async function importCookiesFromFirefox( } } - let db: InstanceType | null = null try { - db = new DatabaseSync(tmpCookiesPath, { readOnly: true }) + const db = new DatabaseSync(tmpCookiesPath, { readOnly: true }) type FirefoxRow = { name: string value: string @@ -1242,37 +1286,18 @@ async function importCookiesFromFirefox( isHttpOnly: number sameSite: number } - const sourceStats = db - .prepare( - `SELECT - COUNT(*) AS cookie_count, - COALESCE(SUM( - length(CAST(COALESCE(name, '') AS BLOB)) + - length(CAST(COALESCE(value, '') AS BLOB)) + - length(CAST(COALESCE(host, '') AS BLOB)) + - length(CAST(COALESCE(path, '') AS BLOB)) - ), 0) AS cookie_bytes - FROM moz_cookies` - ) - .get() as { cookie_count: number | bigint; cookie_bytes: number | bigint } - const totalRows = assertInstalledBrowserCookieStoreWithinLimits( - sourceStats.cookie_count, - sourceStats.cookie_bytes - ) - - diag(` Firefox source has ${totalRows} cookies`) - if (totalRows === 0) { - db.close() - db = null - rmSync(tmpDir, { recursive: true, force: true }) - return { ok: false, reason: 'No cookies found in Firefox.' } - } - const rows = db .prepare( 'SELECT name, value, host, path, expiry, isSecure, isHttpOnly, sameSite FROM moz_cookies' ) - .iterate() as IterableIterator + .all() as FirefoxRow[] + db.close() + + diag(` Firefox source has ${rows.length} cookies`) + if (rows.length === 0) { + rmSync(tmpDir, { recursive: true, force: true }) + return { ok: false, reason: 'No cookies found in Firefox.' } + } const now = Math.floor(Date.now() / 1000) const validated: ValidatedCookie[] = [] @@ -1303,8 +1328,6 @@ async function importCookiesFromFirefox( expirationDate: row.expiry > 0 ? row.expiry : undefined }) } - db.close() - db = null rmSync(tmpDir, { recursive: true, force: true }) @@ -1312,18 +1335,10 @@ async function importCookiesFromFirefox( return { ok: false, reason: 'No valid cookies found in Firefox.' } } - return importValidatedCookies(validated, totalRows, targetPartition) + return importValidatedCookies(validated, rows.length, targetPartition) } catch (err) { - try { - db?.close() - } catch { - /* best-effort */ - } rmSync(tmpDir, { recursive: true, force: true }) diag(` Firefox import failed: ${err}`) - if (err instanceof InstalledBrowserCookieStoreLimitError) { - return { ok: false, reason: installedBrowserCookieStoreLimitReason(browser.label) } - } return { ok: false, reason: 'Could not import cookies from Firefox. Try closing Firefox first.' @@ -1335,17 +1350,6 @@ async function importCookiesFromFirefox( // Safari import // --------------------------------------------------------------------------- -function safariCookieStoreLimitReason(error: SafariCookieStoreLimitError): string { - switch (error.kind) { - case 'pages': - return `Safari cookie store is too large to import safely (${SAFARI_COOKIE_STORE_MAX_PAGES.toLocaleString('en-US')}-page limit).` - case 'cookies': - return `Safari cookie store is too large to import safely (${SAFARI_COOKIE_STORE_MAX_COOKIES.toLocaleString('en-US')}-cookie limit).` - case 'parsed-bytes': - return `Safari cookie store is too large to import safely (${SAFARI_COOKIE_STORE_MAX_PARSED_BYTES / 1024 / 1024} MiB parsed cookie-data limit).` - } -} - async function importCookiesFromSafari( browser: DetectedBrowser, targetPartition: string @@ -1354,18 +1358,9 @@ async function importCookiesFromSafari( let data: Buffer try { - data = readNodeFileSyncWithinLimit( - browser.cookiesPath, - SAFARI_COOKIE_STORE_MAX_FILE_BYTES - ).buffer + data = readFileSync(browser.cookiesPath) } catch (err) { diag(` Safari read failed: ${err}`) - if (err instanceof NodeFileReadTooLargeError) { - return { - ok: false, - reason: `Safari cookie store is too large to import safely (${SAFARI_COOKIE_STORE_MAX_FILE_BYTES / 1024 / 1024} MiB file limit).` - } - } // Why: Safari's Cookies.binarycookies is in a sandbox container; reading it needs Full Disk Access. const isPermError = err instanceof Error && 'code' in err && (err as NodeJS.ErrnoException).code === 'EPERM' @@ -1380,25 +1375,23 @@ async function importCookiesFromSafari( } try { - const decoded = decodeSafariCookieStore(data) - diag(` Safari source has ${decoded.totalCookies} cookies`) + const cookies = decodeSafariBinaryCookies(data) + diag(` Safari source has ${cookies.length} cookies`) - if (decoded.totalCookies === 0) { + if (cookies.length === 0) { return { ok: false, reason: 'No cookies found in Safari.' } } const now = Math.floor(Date.now() / 1000) - removeExpiredSafariCookiesInPlace(decoded.cookies, now) - if (decoded.cookies.length === 0) { + const valid = cookies.filter((c) => !c.expirationDate || c.expirationDate > now) + + if (valid.length === 0) { return { ok: false, reason: 'All Safari cookies are expired.' } } - return importValidatedCookies(decoded.cookies, decoded.totalCookies, targetPartition) + return importValidatedCookies(valid, cookies.length, targetPartition) } catch (err) { diag(` Safari import failed: ${err}`) - if (err instanceof SafariCookieStoreLimitError) { - return { ok: false, reason: safariCookieStoreLimitReason(err) } - } return { ok: false, reason: 'Could not import cookies from Safari.' } } } @@ -1505,30 +1498,16 @@ export async function importCookiesFromBrowser( stagingDb.exec('DELETE FROM cookies') - const sourceStats = sourceDb - .prepare( - `SELECT - COUNT(*) AS cookie_count, - COALESCE(SUM( - length(CAST(COALESCE(host_key, '') AS BLOB)) + - length(CAST(COALESCE(name, '') AS BLOB)) + - length(CAST(COALESCE(value, '') AS BLOB)) + - length(COALESCE(encrypted_value, X'')) + - length(CAST(COALESCE(path, '') AS BLOB)) - ), 0) AS cookie_bytes - FROM cookies` - ) - .get() as { cookie_count: number | bigint; cookie_bytes: number | bigint } - const totalSourceRows = assertInstalledBrowserCookieStoreWithinLimits( - sourceStats.cookie_count, - sourceStats.cookie_bytes - ) + const sourceRows = sourceDb.prepare('SELECT * FROM cookies ORDER BY rowid').all() as Record< + string, + unknown + >[] + sourceDb.close() + sourceDb = null - diag(` source has ${totalSourceRows} cookies`) + diag(` source has ${sourceRows.length} cookies`) - if (totalSourceRows === 0) { - sourceDb.close() - sourceDb = null + if (sourceRows.length === 0) { stagingDb.close() stagingDb = null try { @@ -1539,16 +1518,14 @@ export async function importCookiesFromBrowser( return { ok: false, reason: `No cookies found in ${browser.label}.` } } - const needsSourceKey = - sourceDb - .prepare('SELECT 1 AS needed FROM cookies WHERE length(encrypted_value) > 0 LIMIT 1') - .get() !== undefined + const needsSourceKey = sourceRows.some((sourceRow) => { + const encRaw = sourceRow.encrypted_value + return encRaw instanceof Uint8Array && encRaw.length > 0 + }) const sourceKey = needsSourceKey ? getEncryptionKey(browser.keychainService!, browser.keychainAccount!, browser) : null if (needsSourceKey && !sourceKey) { - sourceDb.close() - sourceDb = null stagingDb.close() stagingDb = null // Why: key denial happens after staging, so clean up the target DB copy or retries pile up. @@ -1587,6 +1564,7 @@ export async function importCookiesFromBrowser( const domainSet = new Set() type DecryptedCookie = { + decryptedValue: Buffer value: string domain: string name: string @@ -1606,9 +1584,6 @@ export async function importCookiesFromBrowser( stagingDb.exec('BEGIN TRANSACTION') - const sourceRows = sourceDb - .prepare('SELECT * FROM cookies ORDER BY rowid') - .iterate() as IterableIterator> for (const sourceRow of sourceRows) { const encRaw = sourceRow.encrypted_value // Why: node:sqlite returns BLOBs as Uint8Array; treat any other type as missing, not an empty buffer that would silently blank the cookie value. @@ -1651,6 +1626,7 @@ export async function importCookiesFromBrowser( const value = decryptedValue.toString('latin1') decryptedCookies.push({ + decryptedValue, value, domain, name, @@ -1665,8 +1641,6 @@ export async function importCookiesFromBrowser( insertStmt.run(...params) imported++ } - sourceDb.close() - sourceDb = null diag(` skipped ${integritySkipped} Google integrity cookies (SIDCC/STRP/AEC)`) stagingDb.exec('COMMIT') @@ -1732,7 +1706,7 @@ export async function importCookiesFromBrowser( } const summary: BrowserCookieImportSummary = { - totalCookies: totalSourceRows, + totalCookies: sourceRows.length, importedCookies: imported, skippedCookies: skipped, domains: [...domainSet].sort() @@ -1757,9 +1731,6 @@ export async function importCookiesFromBrowser( /* may not exist yet */ } diag(` SQLite import failed: ${err}`) - if (err instanceof InstalledBrowserCookieStoreLimitError) { - return { ok: false, reason: installedBrowserCookieStoreLimitReason(browser.label) } - } return { ok: false, reason: reasonWithDiagLog( diff --git a/src/main/browser/browser-cookie-json-file-parser.test.ts b/src/main/browser/browser-cookie-json-file-parser.test.ts deleted file mode 100644 index 40ad84e35ee..00000000000 --- a/src/main/browser/browser-cookie-json-file-parser.test.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { - COOKIE_JSON_FILE_MAX_BYTES, - COOKIE_JSON_FILE_MAX_DEPTH, - COOKIE_JSON_FILE_MAX_ENTRIES, - COOKIE_JSON_FILE_MAX_RETAINED_BYTES, - CookieJsonFileFormatError, - CookieJsonFileLimitError, - enforceCookieJsonRetainedBytes, - visitCookieJsonFileObjects -} from './browser-cookie-json-file-parser' - -describe('visitCookieJsonFileObjects', () => { - it('visits top-level objects in order and counts every entry', () => { - const onObject = vi.fn() - const total = visitCookieJsonFileObjects( - '[{"name":"first"}, 42, ["ignored"], {"name":"last","nested":{"x":1}}]', - onObject - ) - - expect(total).toBe(4) - expect(onObject.mock.calls.map(([entry]) => entry)).toEqual([ - { name: 'first' }, - { name: 'last' } - ]) - }) - - it('accepts the exact entry boundary and rejects one over it', () => { - expect(visitCookieJsonFileObjects('[{},{}]', () => undefined, 2)).toBe(2) - expect(() => visitCookieJsonFileObjects('[{},{},{}]', () => undefined, 2)).toThrow( - expect.objectContaining({ - name: CookieJsonFileLimitError.name, - kind: 'entries', - observed: 3, - limit: 2 - }) - ) - }) - - it('accepts the retained-byte boundary and rejects one byte over it', () => { - expect(() => enforceCookieJsonRetainedBytes(COOKIE_JSON_FILE_MAX_RETAINED_BYTES)).not.toThrow() - expect(() => enforceCookieJsonRetainedBytes(COOKIE_JSON_FILE_MAX_RETAINED_BYTES + 1)).toThrow( - expect.objectContaining({ - name: CookieJsonFileLimitError.name, - kind: 'retained-bytes', - observed: COOKIE_JSON_FILE_MAX_RETAINED_BYTES + 1 - }) - ) - }) - - it('accepts the nesting boundary and rejects one level over it', () => { - expect( - visitCookieJsonFileObjects(`${'['.repeat(4)}0${']'.repeat(4)}`, () => undefined, 10, 4) - ).toBe(1) - expect(() => - visitCookieJsonFileObjects(`${'['.repeat(5)}0${']'.repeat(5)}`, () => undefined, 10, 4) - ).toThrow( - expect.objectContaining({ - name: CookieJsonFileLimitError.name, - kind: 'depth', - observed: 5, - limit: 4 - }) - ) - }) - - it.each([ - { raw: '{"name":"not-an-array"}', kind: 'root' }, - { raw: '[{"name":}]', kind: 'syntax' }, - { raw: '[{"name":"cookie"},]', kind: 'syntax' }, - { raw: '[/* comment */ {"name":"cookie"}]', kind: 'syntax' } - ])('rejects $kind input without returning partial data', ({ raw, kind }) => { - expect(() => visitCookieJsonFileObjects(raw, () => undefined)).toThrow( - expect.objectContaining({ name: CookieJsonFileFormatError.name, kind }) - ) - }) - - it('publishes the production file, entry, and retained-data limits', () => { - expect({ - fileBytes: COOKIE_JSON_FILE_MAX_BYTES, - depth: COOKIE_JSON_FILE_MAX_DEPTH, - entries: COOKIE_JSON_FILE_MAX_ENTRIES, - retainedBytes: COOKIE_JSON_FILE_MAX_RETAINED_BYTES - }).toEqual({ - fileBytes: 64 * 1024 * 1024, - depth: 128, - entries: 250_000, - retainedBytes: 64 * 1024 * 1024 - }) - }) -}) diff --git a/src/main/browser/browser-cookie-json-file-parser.ts b/src/main/browser/browser-cookie-json-file-parser.ts deleted file mode 100644 index 5c33bddecb8..00000000000 --- a/src/main/browser/browser-cookie-json-file-parser.ts +++ /dev/null @@ -1,184 +0,0 @@ -import { visit, type JSONPath } from 'jsonc-parser' - -export const COOKIE_JSON_FILE_MAX_BYTES = 64 * 1024 * 1024 -export const COOKIE_JSON_FILE_MAX_ENTRIES = 250_000 -export const COOKIE_JSON_FILE_MAX_DEPTH = 128 -export const COOKIE_JSON_FILE_MAX_RETAINED_BYTES = 64 * 1024 * 1024 - -export type CookieJsonFileLimitKind = 'depth' | 'entries' | 'retained-bytes' -export type CookieJsonFileFormatKind = 'syntax' | 'root' - -export class CookieJsonFileLimitError extends Error { - constructor( - readonly kind: CookieJsonFileLimitKind, - readonly observed: number, - readonly limit: number - ) { - super(`Cookie JSON file exceeds the ${kind} limit`) - this.name = 'CookieJsonFileLimitError' - } -} - -export class CookieJsonFileFormatError extends Error { - constructor(readonly kind: CookieJsonFileFormatKind) { - super(`Cookie JSON file has invalid ${kind}`) - this.name = 'CookieJsonFileFormatError' - } -} - -type Container = { - kind: 'array' | 'object' - topLevelEntry: boolean -} - -const COOKIE_PROPERTY_NAMES = new Set([ - 'domain', - 'name', - 'value', - 'path', - 'secure', - 'httpOnly', - 'sameSite', - 'expirationDate' -]) - -export function visitCookieJsonFileObjects( - rawContent: string, - onObject: (entry: Record) => void, - maxEntries = COOKIE_JSON_FILE_MAX_ENTRIES, - maxDepth = COOKIE_JSON_FILE_MAX_DEPTH -): number { - enforceJsonNestingDepth(rawContent, maxDepth) - const containers: Container[] = [] - let rootKind: 'array' | 'other' | null = null - let currentEntry: Record | null = null - let entryCount = 0 - let syntaxError = false - - const countEntry = (): void => { - entryCount += 1 - if (entryCount > maxEntries) { - throw new CookieJsonFileLimitError('entries', entryCount, maxEntries) - } - } - const isTopLevelEntry = (path: JSONPath): boolean => rootKind === 'array' && path.length === 1 - const setDirectProperty = (path: JSONPath, value: unknown): void => { - const property = path[1] - if ( - currentEntry && - path.length === 2 && - typeof property === 'string' && - COOKIE_PROPERTY_NAMES.has(property) - ) { - currentEntry[property] = value - } - } - - // Why: retain only fields validation uses instead of materializing the entire external JSON tree. - visit( - rawContent, - { - onArrayBegin: (_offset, _length, _line, _character, pathSupplier) => { - const path = pathSupplier() - if (path.length === 0) { - rootKind = 'array' - } - const topLevelEntry = isTopLevelEntry(path) - if (topLevelEntry) { - countEntry() - } else { - setDirectProperty(path, undefined) - } - containers.push({ kind: 'array', topLevelEntry }) - }, - onArrayEnd: () => { - containers.pop() - }, - onObjectBegin: (_offset, _length, _line, _character, pathSupplier) => { - const path = pathSupplier() - if (path.length === 0) { - rootKind = 'other' - } - const topLevelEntry = isTopLevelEntry(path) - if (topLevelEntry) { - countEntry() - currentEntry = {} - } else { - setDirectProperty(path, undefined) - } - containers.push({ kind: 'object', topLevelEntry }) - }, - onObjectEnd: () => { - const container = containers.pop() - if (!container || container.kind !== 'object' || !container.topLevelEntry) { - return - } - if (currentEntry) { - onObject(currentEntry) - } - currentEntry = null - }, - onLiteralValue: (value, _offset, _length, _line, _character, pathSupplier) => { - const path = pathSupplier() - if (path.length === 0) { - rootKind = 'other' - } else if (isTopLevelEntry(path)) { - countEntry() - } else { - setDirectProperty(path, value) - } - }, - onError: () => { - syntaxError = true - } - }, - { disallowComments: true, allowTrailingComma: false, allowEmptyContent: false } - ) - - if (syntaxError) { - throw new CookieJsonFileFormatError('syntax') - } - if (rootKind !== 'array') { - throw new CookieJsonFileFormatError('root') - } - return entryCount -} - -function enforceJsonNestingDepth(rawContent: string, maxDepth: number): void { - let depth = 0 - let inString = false - let escaped = false - for (let index = 0; index < rawContent.length; index += 1) { - const character = rawContent[index] - if (inString) { - if (escaped) { - escaped = false - } else if (character === '\\') { - escaped = true - } else if (character === '"') { - inString = false - } - continue - } - if (character === '"') { - inString = true - } else if (character === '[' || character === '{') { - depth += 1 - if (depth > maxDepth) { - throw new CookieJsonFileLimitError('depth', depth, maxDepth) - } - } else if (character === ']' || character === '}') { - depth = Math.max(0, depth - 1) - } - } -} - -export function enforceCookieJsonRetainedBytes(observed: number): void { - if (observed > COOKIE_JSON_FILE_MAX_RETAINED_BYTES) { - throw new CookieJsonFileLimitError( - 'retained-bytes', - observed, - COOKIE_JSON_FILE_MAX_RETAINED_BYTES - ) - } -} diff --git a/src/main/browser/browser-manager.test.ts b/src/main/browser/browser-manager.test.ts index 6432e8b08da..c49088569f4 100644 --- a/src/main/browser/browser-manager.test.ts +++ b/src/main/browser/browser-manager.test.ts @@ -54,7 +54,7 @@ vi.mock('./popup-origin-bar-window', () => ({ openPopupWithOriginBar: openPopupWithOriginBarMock })) -import { MAX_ACTIVE_BROWSER_DOWNLOADS, browserManager } from './browser-manager' +import { browserManager } from './browser-manager' describe('browserManager', () => { const rendererWebContentsId = 5001 @@ -1927,24 +1927,6 @@ describe('browserManager', () => { ) }) - it('cancels excess concurrent downloads before retaining another item', () => { - const item = createDownloadItem() - const managerState = browserManager as unknown as { downloadsById: Map } - for (let index = 0; index < MAX_ACTIVE_BROWSER_DOWNLOADS; index += 1) { - managerState.downloadsById.set(`active-${index}`, {}) - } - - try { - browserManager.handleGuestWillDownload({ guestWebContentsId: 999, item }) - - expect(item.cancel).toHaveBeenCalledTimes(1) - expect(item.setSavePath).not.toHaveBeenCalled() - expect(managerState.downloadsById.size).toBe(MAX_ACTIVE_BROWSER_DOWNLOADS) - } finally { - managerState.downloadsById.clear() - } - }) - it('flushes started and terminal snapshots for downloads that finish before registration', () => { const rendererSendMock = vi.fn() const guest = { diff --git a/src/main/browser/browser-manager.ts b/src/main/browser/browser-manager.ts index 0c7856d070f..bd0e5698cd8 100644 --- a/src/main/browser/browser-manager.ts +++ b/src/main/browser/browser-manager.ts @@ -198,8 +198,6 @@ type ActiveDownload = { cleanup: (() => void) | null } -export const MAX_ACTIVE_BROWSER_DOWNLOADS = 64 - function safeOrigin(rawUrl: string): string { const external = normalizeExternalBrowserUrl(rawUrl) const urlToParse = external ?? rawUrl @@ -1259,14 +1257,6 @@ export class BrowserManager { handleGuestWillDownload(args: { guestWebContentsId: number; item: Electron.DownloadItem }): void { const { guestWebContentsId, item } = args - if (this.downloadsById.size >= MAX_ACTIVE_BROWSER_DOWNLOADS) { - try { - item.cancel() - } catch { - // Why: rejecting excess admission must remain safe if Chromium already finalized the item. - } - return - } const downloadId = randomUUID() const requestedFilename = (() => { try { diff --git a/src/main/browser/browser-pdf-admission.test.ts b/src/main/browser/browser-pdf-admission.test.ts deleted file mode 100644 index f68771f9907..00000000000 --- a/src/main/browser/browser-pdf-admission.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { - acquireBrowserPdfAdmission, - BROWSER_PDF_MAX_CONCURRENT_PRINTS, - startBrowserPdfPrint -} from './browser-pdf-admission' - -function createWebContents() { - return { printToPDF: vi.fn<() => Promise>() } -} - -describe('browser PDF admission', () => { - it('caps native prints process-wide and releases only after native settlement', async () => { - const resolvers: ((data: Buffer) => void)[] = [] - const webContents = createWebContents() - webContents.printToPDF.mockImplementation( - () => - new Promise((resolve) => { - resolvers.push(resolve) - }) - ) - - const active = Array.from({ length: BROWSER_PDF_MAX_CONCURRENT_PRINTS }, () => - startBrowserPdfPrint(webContents as never, {}) - ) - expect(active.every(Boolean)).toBe(true) - expect(startBrowserPdfPrint(createWebContents() as never, {})).toBeNull() - - resolvers[0]!(Buffer.from('first')) - await Promise.resolve() - const nextWebContents = createWebContents() - nextWebContents.printToPDF.mockResolvedValue(Buffer.from('next')) - await expect(startBrowserPdfPrint(nextWebContents as never, {})).resolves.toEqual( - Buffer.from('next') - ) - - resolvers[1]!(Buffer.from('second')) - await Promise.all(active) - }) - - it('lets pre-render work release unused admission', () => { - const first = acquireBrowserPdfAdmission() - const second = acquireBrowserPdfAdmission() - expect(first).not.toBeNull() - expect(second).not.toBeNull() - expect(acquireBrowserPdfAdmission()).toBeNull() - - first!.releaseIfIdle() - const replacement = acquireBrowserPdfAdmission() - expect(replacement).not.toBeNull() - second!.releaseIfIdle() - replacement!.releaseIfIdle() - }) - - it('releases process-wide print slots after synchronous and asynchronous failures', async () => { - const synchronous = createWebContents() - synchronous.printToPDF.mockImplementation(() => { - throw new Error('sync print failure') - }) - await expect(startBrowserPdfPrint(synchronous as never, {})).rejects.toThrow( - 'sync print failure' - ) - - const asynchronous = createWebContents() - asynchronous.printToPDF.mockRejectedValue(new Error('async print failure')) - await expect(startBrowserPdfPrint(asynchronous as never, {})).rejects.toThrow( - 'async print failure' - ) - - const replacement = createWebContents() - replacement.printToPDF.mockResolvedValue(Buffer.from('replacement')) - await expect(startBrowserPdfPrint(replacement as never, {})).resolves.toEqual( - Buffer.from('replacement') - ) - }) -}) diff --git a/src/main/browser/browser-pdf-admission.ts b/src/main/browser/browser-pdf-admission.ts deleted file mode 100644 index 8ef66e39415..00000000000 --- a/src/main/browser/browser-pdf-admission.ts +++ /dev/null @@ -1,59 +0,0 @@ -import type { PrintToPDFOptions, WebContents } from 'electron' - -export const BROWSER_PDF_MAX_CONCURRENT_PRINTS = 2 -export const BROWSER_PDF_BUSY_ERROR = 'Too many PDF print requests are already running' - -let activePrints = 0 - -export type BrowserPdfAdmission = { - print(webContents: WebContents, options: PrintToPDFOptions): Promise - releaseIfIdle(): void -} - -export function acquireBrowserPdfAdmission(): BrowserPdfAdmission | null { - if (activePrints >= BROWSER_PDF_MAX_CONCURRENT_PRINTS) { - return null - } - activePrints += 1 - let printStarted = false - let released = false - const release = (): void => { - if (released) { - return - } - released = true - activePrints = Math.max(0, activePrints - 1) - } - - return { - print(webContents, options) { - if (printStarted) { - return Promise.reject(new Error('PDF admission has already started a print')) - } - printStarted = true - let print: Promise - try { - print = webContents.printToPDF(options) - } catch (error) { - release() - return Promise.reject(error) - } - // Why: native PDF work cannot be cancelled when its caller times out or disconnects. - void print.then(release, release) - return print - }, - releaseIfIdle() { - if (!printStarted) { - release() - } - } - } -} - -export function startBrowserPdfPrint( - webContents: WebContents, - options: PrintToPDFOptions -): Promise | null { - const admission = acquireBrowserPdfAdmission() - return admission?.print(webContents, options) ?? null -} diff --git a/src/main/browser/browser-screencast-stream.test.ts b/src/main/browser/browser-screencast-stream.test.ts index 7929ba611a3..d1fc838807d 100644 --- a/src/main/browser/browser-screencast-stream.test.ts +++ b/src/main/browser/browser-screencast-stream.test.ts @@ -3,11 +3,7 @@ import { EventEmitter } from 'node:events' import { describe, expect, it, vi } from 'vitest' import { decodeBrowserScreencastFrame } from '../../shared/browser-screencast-protocol' -import { - decodeBrowserScreencastImage, - MAX_BROWSER_SCREENCAST_BASE64_CHARACTERS, - startBrowserScreencast -} from './browser-screencast-stream' +import { startBrowserScreencast } from './browser-screencast-stream' function createMockWebContents() { let attached = false @@ -61,16 +57,6 @@ function jpegWithSize(width: number, height: number): Buffer { } describe('startBrowserScreencast', () => { - it('rejects oversized CDP image text before base64 decoding', () => { - const decode = vi.spyOn(Buffer, 'from') - - expect( - decodeBrowserScreencastImage('A'.repeat(MAX_BROWSER_SCREENCAST_BASE64_CHARACTERS + 1)) - ).toBeNull() - expect(decode).not.toHaveBeenCalled() - decode.mockRestore() - }) - it('emits an initial captured frame before CDP produces screencast events', async () => { const webContents = createMockWebContents() const firstFrame = Buffer.from('first-frame') diff --git a/src/main/browser/browser-screencast-stream.ts b/src/main/browser/browser-screencast-stream.ts index 19f9d57d423..ad2e1e27e62 100644 --- a/src/main/browser/browser-screencast-stream.ts +++ b/src/main/browser/browser-screencast-stream.ts @@ -10,12 +10,9 @@ import { import { BrowserError } from './cdp-bridge' import { acquireElectronDebugger, type ElectronDebuggerLease } from './electron-debugger-lease' import { readBrowserScreencastImageSize } from './browser-screencast-image-size' -import { REMOTE_RUNTIME_MAX_OUTBOUND_BINARY_FRAME_BYTES } from '../../shared/remote-runtime-memory-limits' const DEBUGGER_COMMAND_TIMEOUT_MS = 8_000 const BACKPRESSURE_RETRY_MS = 50 -export const MAX_BROWSER_SCREENCAST_BASE64_CHARACTERS = - Math.ceil(REMOTE_RUNTIME_MAX_OUTBOUND_BINARY_FRAME_BYTES / 3) * 4 export type BrowserScreencastOptions = { format: BrowserScreencastFormat @@ -455,10 +452,7 @@ export async function startBrowserScreencast( } try { - const image = decodeBrowserScreencastImage(data) - if (!image) { - throw new Error('Browser screencast frame is too large') - } + const image = new Uint8Array(Buffer.from(data, 'base64')) // Why: image dimension parsing happens for every live frame; share the // result between stale-frame rejection and metadata enrichment. const imageSize = readBrowserScreencastImageSize(image, options.format) @@ -564,10 +558,7 @@ export async function startBrowserScreencast( if (!data) { return } - image = decodeBrowserScreencastImage(data) - if (!image) { - return - } + image = new Uint8Array(Buffer.from(data, 'base64')) } if (isSnapshotStale(initialOnly, generation)) { return @@ -641,11 +632,3 @@ export async function startBrowserScreencast( done } } - -export function decodeBrowserScreencastImage(data: string): Uint8Array | null { - if (data.length > MAX_BROWSER_SCREENCAST_BASE64_CHARACTERS) { - return null - } - const image = new Uint8Array(Buffer.from(data, 'base64')) - return image.byteLength <= REMOTE_RUNTIME_MAX_OUTBOUND_BINARY_FRAME_BYTES ? image : null -} diff --git a/src/main/browser/browser-screenshot-admission.test.ts b/src/main/browser/browser-screenshot-admission.test.ts deleted file mode 100644 index f6b74b5d37e..00000000000 --- a/src/main/browser/browser-screenshot-admission.test.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { EventEmitter } from 'node:events' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { - resetBrowserScreenshotAdmissionForTests, - startBrowserFallbackCapture, - startBrowserScreenshotCommand -} from './browser-screenshot-admission' -import { BROWSER_SCREENSHOT_MAX_CONCURRENT_CAPTURES } from './browser-screenshot-limits' - -function deferred(): { - promise: Promise - reject: (error: Error) => void - resolve: (value: T) => void -} { - let reject!: (error: Error) => void - let resolve!: (value: T) => void - const promise = new Promise((accept, fail) => { - resolve = accept - reject = fail - }) - return { promise, reject, resolve } -} - -function createWebContents() { - const events = new EventEmitter() - return Object.assign(events, { - capturePage: vi.fn(), - debugger: { sendCommand: vi.fn() } - }) -} - -afterEach(() => { - resetBrowserScreenshotAdmissionForTests() -}) - -describe('browser screenshot admission', () => { - it('caps native capture work across different WebContents and releases on settlement', async () => { - const operations = Array.from({ length: BROWSER_SCREENSHOT_MAX_CONCURRENT_CAPTURES }, () => - deferred() - ) - const contents = operations.map((operation) => { - const webContents = createWebContents() - webContents.debugger.sendCommand.mockReturnValue(operation.promise) - return webContents - }) - const active = contents.map((webContents) => - startBrowserScreenshotCommand(webContents as never, 'Page.captureScreenshot', {}) - ) - - const overflow = createWebContents() - overflow.debugger.sendCommand.mockResolvedValue({ data: 'later' }) - expect( - startBrowserScreenshotCommand(overflow as never, 'Page.captureScreenshot', {}) - ).toBeNull() - - operations[0]!.resolve({ data: 'first' }) - await expect(active[0]).resolves.toEqual({ data: 'first' }) - await expect( - startBrowserScreenshotCommand(overflow as never, 'Page.captureScreenshot', {}) - ).resolves.toEqual({ data: 'later' }) - - operations[1]!.reject(new Error('capture failed')) - await expect(active[1]).rejects.toThrow('capture failed') - }) - - it('shares admission with fallback capture and releases synchronous failures', async () => { - const commandContents = createWebContents() - commandContents.debugger.sendCommand.mockImplementation(() => { - throw new Error('sync failure') - }) - await expect( - startBrowserScreenshotCommand(commandContents as never, 'Page.captureScreenshot', {}) - ).rejects.toThrow('sync failure') - - const fallbackContents = createWebContents() - const image = { isEmpty: () => false } - fallbackContents.capturePage.mockResolvedValue(image) - await expect(startBrowserFallbackCapture(fallbackContents as never)).resolves.toBe(image) - }) - - it('releases a hung operation when its WebContents is destroyed', async () => { - const hung = createWebContents() - hung.debugger.sendCommand.mockImplementation(() => new Promise(() => {})) - expect( - startBrowserScreenshotCommand(hung as never, 'Page.captureScreenshot', {}) - ).not.toBeNull() - hung.emit('destroyed') - - const replacement = createWebContents() - replacement.debugger.sendCommand.mockResolvedValue({ data: 'replacement' }) - await expect( - startBrowserScreenshotCommand(replacement as never, 'Page.captureScreenshot', {}) - ).resolves.toEqual({ data: 'replacement' }) - }) -}) diff --git a/src/main/browser/browser-screenshot-admission.ts b/src/main/browser/browser-screenshot-admission.ts deleted file mode 100644 index 4442da7d18c..00000000000 --- a/src/main/browser/browser-screenshot-admission.ts +++ /dev/null @@ -1,64 +0,0 @@ -import type { WebContents } from 'electron' -import { BROWSER_SCREENSHOT_MAX_CONCURRENT_CAPTURES } from './browser-screenshot-limits' - -let activeCaptures = 0 -let admissionEpoch = 0 - -function startBrowserScreenshotOperation( - webContents: WebContents, - start: () => Promise -): Promise | null { - if (activeCaptures >= BROWSER_SCREENSHOT_MAX_CONCURRENT_CAPTURES) { - return null - } - activeCaptures += 1 - const operationEpoch = admissionEpoch - - let released = false - const onDestroyed = (): void => release() - const release = (): void => { - if (released) { - return - } - released = true - webContents.removeListener?.('destroyed', onDestroyed) - if (operationEpoch === admissionEpoch) { - activeCaptures = Math.max(0, activeCaptures - 1) - } - } - // Why: destroying the native target releases its capture resources even if Chromium omits rejection. - webContents.once?.('destroyed', onDestroyed) - - let command: Promise - try { - command = start() - } catch (error) { - release() - return Promise.reject(error) - } - // Why: a timed-out native command cannot be cancelled, so it owns its slot until it truly settles. - void command.then(release, release) - return command -} - -export function startBrowserScreenshotCommand( - webContents: WebContents, - method: 'Page.captureScreenshot' | 'Page.getLayoutMetrics', - params: Record -): Promise | null { - return startBrowserScreenshotOperation( - webContents, - () => webContents.debugger.sendCommand(method, params) as Promise - ) -} - -export function startBrowserFallbackCapture( - webContents: WebContents -): Promise | null { - return startBrowserScreenshotOperation(webContents, () => webContents.capturePage()) -} - -export function resetBrowserScreenshotAdmissionForTests(): void { - admissionEpoch += 1 - activeCaptures = 0 -} diff --git a/src/main/browser/browser-screenshot-file-reader.test.ts b/src/main/browser/browser-screenshot-file-reader.test.ts deleted file mode 100644 index a608d2d3dfa..00000000000 --- a/src/main/browser/browser-screenshot-file-reader.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import { readBrowserScreenshotFile } from './browser-screenshot-file-reader' -import { BROWSER_SCREENSHOT_MEMORY_LIMIT_ERROR } from './browser-screenshot-limits' - -const temporaryDirectories: string[] = [] - -function createFile(bytes: Buffer): string { - const directory = mkdtempSync(join(tmpdir(), 'orca-screenshot-reader-')) - temporaryDirectories.push(directory) - const path = join(directory, 'screenshot.png') - writeFileSync(path, bytes) - return path -} - -afterEach(() => { - for (const directory of temporaryDirectories.splice(0)) { - rmSync(directory, { recursive: true, force: true }) - } -}) - -describe('readBrowserScreenshotFile', () => { - it('reads a screenshot at the byte boundary', () => { - const path = createFile(Buffer.from('1234')) - - expect(readBrowserScreenshotFile(path, 4)).toEqual(Buffer.from('1234')) - }) - - it('rejects an oversized screenshot before allocating its contents', () => { - const path = createFile(Buffer.from('12345')) - - expect(() => readBrowserScreenshotFile(path, 4)).toThrow(BROWSER_SCREENSHOT_MEMORY_LIMIT_ERROR) - }) -}) diff --git a/src/main/browser/browser-screenshot-file-reader.ts b/src/main/browser/browser-screenshot-file-reader.ts deleted file mode 100644 index 85a24734ed0..00000000000 --- a/src/main/browser/browser-screenshot-file-reader.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { closeSync, fstatSync, openSync, readSync } from 'node:fs' -import { - BROWSER_SCREENSHOT_MAX_ENCODED_BYTES, - BROWSER_SCREENSHOT_MEMORY_LIMIT_ERROR -} from './browser-screenshot-limits' - -const MIN_GROWTH_BYTES = 64 * 1024 - -function throwIfOverLimit(bytes: number, maxBytes: number): void { - if (bytes > maxBytes) { - throw new Error(BROWSER_SCREENSHOT_MEMORY_LIMIT_ERROR) - } -} - -export function readBrowserScreenshotFile( - path: string, - maxBytes = BROWSER_SCREENSHOT_MAX_ENCODED_BYTES -): Buffer { - const descriptor = openSync(path, 'r') - try { - const initialSize = fstatSync(descriptor).size - throwIfOverLimit(initialSize, maxBytes) - let bytes = Buffer.allocUnsafe(initialSize) - let offset = 0 - - while (true) { - while (offset < bytes.length) { - const read = readSync(descriptor, bytes, offset, bytes.length - offset, null) - if (read === 0) { - return bytes.subarray(0, offset) - } - offset += read - } - - const probe = Buffer.allocUnsafe(1) - if (readSync(descriptor, probe, 0, 1, null) === 0) { - return bytes.subarray(0, offset) - } - throwIfOverLimit(offset + 1, maxBytes) - - const nextCapacity = Math.min( - maxBytes, - Math.max(MIN_GROWTH_BYTES, bytes.length * 2, offset + 1) - ) - const expanded = Buffer.allocUnsafe(nextCapacity) - bytes.copy(expanded, 0, 0, offset) - expanded[offset] = probe[0]! - bytes = expanded - offset += 1 - } - } finally { - closeSync(descriptor) - } -} diff --git a/src/main/browser/browser-screenshot-limits.test.ts b/src/main/browser/browser-screenshot-limits.test.ts deleted file mode 100644 index 1379905e127..00000000000 --- a/src/main/browser/browser-screenshot-limits.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - assertBrowserScreenshotEncodedBytes, - assertBrowserScreenshotGeometry, - BROWSER_SCREENSHOT_MAX_EFFECTIVE_PIXELS, - BROWSER_SCREENSHOT_MAX_ENCODED_BYTES, - BROWSER_SCREENSHOT_MEMORY_LIMIT_ERROR -} from './browser-screenshot-limits' - -describe('browser screenshot memory limits', () => { - it('accepts ordinary HiDPI viewport geometry', () => { - expect(() => assertBrowserScreenshotGeometry(1_440, 900, 2)).not.toThrow() - }) - - it('counts device scale in the effective pixel budget', () => { - const side = Math.floor(Math.sqrt(BROWSER_SCREENSHOT_MAX_EFFECTIVE_PIXELS)) - - expect(() => assertBrowserScreenshotGeometry(side, side, 2)).toThrow( - BROWSER_SCREENSHOT_MEMORY_LIMIT_ERROR - ) - }) - - it('accepts the encoded-byte boundary and rejects the next byte', () => { - expect(() => - assertBrowserScreenshotEncodedBytes(BROWSER_SCREENSHOT_MAX_ENCODED_BYTES) - ).not.toThrow() - expect(() => - assertBrowserScreenshotEncodedBytes(BROWSER_SCREENSHOT_MAX_ENCODED_BYTES + 1) - ).toThrow(BROWSER_SCREENSHOT_MEMORY_LIMIT_ERROR) - }) -}) diff --git a/src/main/browser/browser-screenshot-limits.ts b/src/main/browser/browser-screenshot-limits.ts deleted file mode 100644 index c440369badd..00000000000 --- a/src/main/browser/browser-screenshot-limits.ts +++ /dev/null @@ -1,37 +0,0 @@ -export const BROWSER_SCREENSHOT_MAX_DIMENSION_PX = 32_768 -export const BROWSER_SCREENSHOT_MAX_EFFECTIVE_PIXELS = 32 * 1024 * 1024 -export const BROWSER_SCREENSHOT_MAX_ENCODED_BYTES = 32 * 1024 * 1024 -export const BROWSER_SCREENSHOT_MAX_CONCURRENT_CAPTURES = 2 -export const BROWSER_SCREENSHOT_MEMORY_LIMIT_ERROR = - 'Screenshot exceeds the browser automation memory limit' -export const BROWSER_SCREENSHOT_BUSY_ERROR = 'Too many screenshot requests are already running' - -export function assertBrowserScreenshotGeometry(width: number, height: number, scale = 1): void { - const effectiveWidth = width * scale - const effectiveHeight = height * scale - if ( - !Number.isFinite(width) || - !Number.isFinite(height) || - !Number.isFinite(scale) || - width <= 0 || - height <= 0 || - scale <= 0 || - effectiveWidth > BROWSER_SCREENSHOT_MAX_DIMENSION_PX || - effectiveHeight > BROWSER_SCREENSHOT_MAX_DIMENSION_PX || - effectiveWidth * effectiveHeight > BROWSER_SCREENSHOT_MAX_EFFECTIVE_PIXELS - ) { - throw new Error(BROWSER_SCREENSHOT_MEMORY_LIMIT_ERROR) - } -} - -export function assertBrowserScreenshotEncodedBytes(bytes: number): void { - if (!Number.isFinite(bytes) || bytes < 0 || bytes > BROWSER_SCREENSHOT_MAX_ENCODED_BYTES) { - throw new Error(BROWSER_SCREENSHOT_MEMORY_LIMIT_ERROR) - } -} - -export function assertBrowserScreenshotBase64(data: string): void { - const padding = data.endsWith('==') ? 2 : data.endsWith('=') ? 1 : 0 - const decodedBytes = Math.max(0, Math.floor((data.length * 3) / 4) - padding) - assertBrowserScreenshotEncodedBytes(decodedBytes) -} diff --git a/src/main/browser/browser-session-registry.persistence.test.ts b/src/main/browser/browser-session-registry.persistence.test.ts index 1d3b47ca08b..174b5aa7715 100644 --- a/src/main/browser/browser-session-registry.persistence.test.ts +++ b/src/main/browser/browser-session-registry.persistence.test.ts @@ -105,19 +105,6 @@ function installModuleMocks( fsState.present.add(key) }) })) - vi.doMock('../../shared/node-bounded-file-reader', () => ({ - readNodeFileSyncWithinLimit: vi.fn((p: string, maxBytes: number) => { - const value = fsState.files.get(fsKey(p)) - if (value === undefined) { - throw new Error('ENOENT') - } - const buffer = Buffer.from(value) - if (buffer.byteLength > maxBytes) { - throw new Error('File too large') - } - return { buffer, stats: {} } - }) - })) vi.doMock('./browser-manager', () => ({ browserManager: { @@ -220,73 +207,6 @@ describe('BrowserSessionRegistry persistence', () => { }) }) - it('ignores oversized browser-session metadata', async () => { - const fsState = createFsState() - fsState.files.set(META_PATH, ' '.repeat(1024 * 1024 + 1)) - fsState.present.add(META_PATH) - - installModuleMocks(fsState) - const { browserSessionRegistry } = await import('./browser-session-registry') - browserSessionRegistry.initializeBrowserSessionsFromPersistedState() - - expect(browserSessionRegistry.listProfiles()).toHaveLength(1) - expect(browserSessionRegistry.listProfiles()[0]?.id).toBe('default') - }) - - it('rejects an oversized profile label without retaining or persisting it', async () => { - const fsState = createFsState() - installModuleMocks(fsState) - const { browserSessionRegistry, MAX_BROWSER_SESSION_LABEL_BYTES } = - await import('./browser-session-registry') - - expect( - browserSessionRegistry.createProfile( - 'isolated', - 'x'.repeat(MAX_BROWSER_SESSION_LABEL_BYTES + 1) - ) - ).toBeNull() - expect(browserSessionRegistry.listProfiles()).toHaveLength(1) - expect(fsState.files.has(META_PATH)).toBe(false) - }) - - it('keeps profile creation bounded while preserving the last readable metadata snapshot', async () => { - const fsState = createFsState() - installModuleMocks(fsState) - const { browserSessionRegistry, MAX_BROWSER_SESSION_LABEL_BYTES } = - await import('./browser-session-registry') - const label = 'x'.repeat(MAX_BROWSER_SESSION_LABEL_BYTES - 8) - for (let index = 0; index < 256; index += 1) { - browserSessionRegistry.createProfile('isolated', `${index}-${label}`) - } - - const durableProfiles = JSON.parse(fsState.files.get(META_PATH) ?? '{}').profiles - expect(browserSessionRegistry.listProfiles().length).toBeGreaterThan(durableProfiles.length + 1) - expect(Buffer.byteLength(fsState.files.get(META_PATH) ?? '')).toBeLessThanOrEqual(1024 * 1024) - }) - - it('preserves metadata when a pending import path would exceed the byte limit', async () => { - const fsState = createFsState() - seedMeta(fsState, { - defaultSource: null, - userAgent: null, - userAgentByPartition: {}, - pendingCookieDbPath: null, - pendingCookieImports: {}, - profiles: [] - }) - const before = fsState.files.get(META_PATH) - installModuleMocks(fsState) - const { browserSessionRegistry, MAX_BROWSER_SESSION_META_FILE_BYTES } = - await import('./browser-session-registry') - - browserSessionRegistry.setPendingCookieImport( - 'persist:orca-browser', - 'x'.repeat(MAX_BROWSER_SESSION_META_FILE_BYTES) - ) - - expect(fsState.files.get(META_PATH)).toBe(before) - }) - it('merges partition-keyed pending entries without clobbering unrelated entries', async () => { const fsState = createFsState() seedMeta(fsState, { diff --git a/src/main/browser/browser-session-registry.ts b/src/main/browser/browser-session-registry.ts index fdd15140abf..72a888edb62 100644 --- a/src/main/browser/browser-session-registry.ts +++ b/src/main/browser/browser-session-registry.ts @@ -2,11 +2,17 @@ import { app, session } from 'electron' import type { Session } from 'electron' import { randomUUID } from 'node:crypto' -import { copyFileSync, existsSync, mkdirSync, renameSync, unlinkSync, writeFileSync } from 'node:fs' +import { + copyFileSync, + existsSync, + mkdirSync, + readFileSync, + renameSync, + unlinkSync, + writeFileSync +} from 'node:fs' import { dirname, join } from 'node:path' import { ORCA_BROWSER_PARTITION } from '../../shared/constants' -import { readNodeFileSyncWithinLimit } from '../../shared/node-bounded-file-reader' -import { stringifyJsonWithinByteLimit } from '../../shared/node-bounded-json-stringify' import { DEFAULT_LOCAL_ORCA_PROFILE_ID, getOrcaProfileBrowserDefaultPartition, @@ -40,9 +46,6 @@ export type BrowserSessionRegistryProfileOptions = { } const BROWSER_SESSION_META_FILE_NAME = 'browser-session-meta.json' -export const MAX_BROWSER_SESSION_META_FILE_BYTES = 1024 * 1024 -export const MAX_BROWSER_SESSION_PROFILES = 256 -export const MAX_BROWSER_SESSION_LABEL_BYTES = 16 * 1024 const LEGACY_BROWSER_SESSION_PARTITION_RE = /^persist:orca-browser-session-[\da-f-]{8}-[\da-f-]{4}-[\da-f-]{4}-[\da-f-]{4}-[\da-f-]{12}$/ @@ -100,12 +103,8 @@ class BrowserSessionRegistry { try { const existing = this.loadPersistedMeta() const tmpPath = `${this.metadataPath}.tmp` - const serialized = stringifyJsonWithinByteLimit( - { ...existing, ...updates }, - MAX_BROWSER_SESSION_META_FILE_BYTES - ).serialized mkdirSync(dirname(this.metadataPath), { recursive: true }) - writeFileSync(tmpPath, serialized) + writeFileSync(tmpPath, JSON.stringify({ ...existing, ...updates })) renameSync(tmpPath, this.metadataPath) } catch { // best-effort @@ -127,10 +126,7 @@ class BrowserSessionRegistry { private loadPersistedMeta(): BrowserSessionMeta { try { - const raw = readNodeFileSyncWithinLimit( - this.metadataPath, - MAX_BROWSER_SESSION_META_FILE_BYTES - ).buffer.toString('utf8') + const raw = readFileSync(this.metadataPath, 'utf-8') const data = JSON.parse(raw) const legacyUserAgent = typeof data?.userAgent === 'string' ? data.userAgent : null const userAgentByPartition: Record = @@ -344,11 +340,7 @@ class BrowserSessionRegistry { createProfile(scope: BrowserSessionProfileScope, label: string): BrowserSessionProfile | null { // Why: block scope:'default' here — only the constructor makes the default profile; a second one sharing the partition breaks delete. - if ( - scope === 'default' || - Buffer.byteLength(label, 'utf8') > MAX_BROWSER_SESSION_LABEL_BYTES || - this.profiles.size >= MAX_BROWSER_SESSION_PROFILES - ) { + if (scope === 'default') { return null } const id = randomUUID() diff --git a/src/main/browser/cdp-bridge-memory-bounds.test.ts b/src/main/browser/cdp-bridge-memory-bounds.test.ts deleted file mode 100644 index 80a0ef50c68..00000000000 --- a/src/main/browser/cdp-bridge-memory-bounds.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { webContentsFromId } = vi.hoisted(() => ({ webContentsFromId: vi.fn() })) - -vi.mock('electron', () => ({ - webContents: { fromId: webContentsFromId } -})) - -import { CdpBridge } from './cdp-bridge' -import { - CDP_MAX_CONSOLE_TEXT_CODE_UNITS, - CDP_MAX_IFRAME_SESSIONS, - CDP_MAX_PAUSED_REQUESTS -} from './cdp-event-memory-bounds' - -function createHarness() { - let attached = false - const messageListeners: ((event: unknown, method: string, params: unknown) => void)[] = [] - const sendCommand = vi.fn(async (_method: string, _params?: unknown) => ({})) - const guest = { - id: 1, - isDestroyed: () => false, - getURL: () => 'https://example.com', - getTitle: () => 'Example', - debugger: { - isAttached: () => attached, - attach: () => { - attached = true - }, - sendCommand, - on: (event: string, listener: (event: unknown, method: string, params: unknown) => void) => { - if (event === 'message') { - messageListeners.push(listener) - } - }, - removeListener: vi.fn() - } - } - const browserManager = { - webContentsIdByTabId: new Map([['page-1', guest.id]]) - } - const bridge = new CdpBridge(browserManager as never) - bridge.setActiveTab(guest.id) - return { - bridge, - guest, - sendCommand, - emit(method: string, params: unknown) { - for (const listener of messageListeners) { - listener({}, method, params) - } - } - } -} - -describe('CdpBridge event retention', () => { - beforeEach(() => { - webContentsFromId.mockReset() - }) - - it('bounds paused requests, iframe sessions, and console text', async () => { - const harness = createHarness() - webContentsFromId.mockReturnValue(harness.guest) - const internals = harness.bridge as unknown as { - ensureDebuggerAttached: (guest: unknown) => Promise - tabState: Map< - string, - { - capturing: boolean - intercepting: boolean - consoleLog: { text: string }[] - pausedRequests: Map - iframeSessions: Map - } - > - } - await internals.ensureDebuggerAttached(harness.guest) - const state = internals.tabState.get('page-1')! - state.capturing = true - state.intercepting = true - - harness.emit('Runtime.consoleAPICalled', { - args: [{ value: 'x'.repeat(CDP_MAX_CONSOLE_TEXT_CODE_UNITS * 2) }] - }) - for (let index = 0; index < CDP_MAX_PAUSED_REQUESTS + 10; index++) { - harness.emit('Fetch.requestPaused', { - requestId: `request-${index}`, - request: { url: `https://example.com/${index}`, method: 'GET', headers: {} } - }) - } - for (let index = 0; index < CDP_MAX_IFRAME_SESSIONS + 1; index++) { - harness.emit('Target.attachedToTarget', { - sessionId: `session-${index}`, - targetInfo: { type: 'iframe', targetId: `frame-${index}` } - }) - } - - expect(state.consoleLog[0]?.text).toHaveLength(CDP_MAX_CONSOLE_TEXT_CODE_UNITS) - expect(state.pausedRequests.size).toBe(CDP_MAX_PAUSED_REQUESTS) - expect(state.iframeSessions.size).toBe(CDP_MAX_IFRAME_SESSIONS) - expect( - harness.sendCommand.mock.calls.filter(([method]) => method === 'Fetch.continueRequest') - ).toHaveLength(10) - expect( - harness.sendCommand.mock.calls.filter(([method]) => method === 'Target.detachFromTarget') - ).toHaveLength(1) - }) -}) diff --git a/src/main/browser/cdp-bridge.ts b/src/main/browser/cdp-bridge.ts index 2d6ac9f8dd4..47d263ac371 100644 --- a/src/main/browser/cdp-bridge.ts +++ b/src/main/browser/cdp-bridge.ts @@ -48,16 +48,8 @@ import { import { insertTextThroughCdp } from './browser-text-insertion' import type { BrowserManager } from './browser-manager' import { ANTI_DETECTION_SCRIPT } from './anti-detection' -import { CdpCommandQueue } from './cdp-command-queue' -import { - buildBoundedCdpConsoleEntry, - buildBoundedCdpInterceptedRequest, - buildBoundedCdpNetworkEntry, - CDP_CAPTURE_LOG_LIMIT, - CDP_MAX_IFRAME_SESSIONS, - CDP_MAX_PAUSED_REQUESTS, - isBoundedCdpIframeSession -} from './cdp-event-memory-bounds' + +const CAPTURE_LOG_LIMIT = 1000 export class BrowserError extends Error { constructor( @@ -87,21 +79,21 @@ type TabState = { networkRequestMap: Map } +type QueuedCommand = { + execute: () => Promise + resolve: (value: unknown) => void + reject: (reason: unknown) => void +} + export class CdpBridge { private activeWebContentsId: number | null = null private readonly tabState = new Map() - private readonly commandQueue: CdpCommandQueue + private readonly commandQueues = new Map() + private readonly processingQueues = new Set() private readonly browserManager: BrowserManager constructor(browserManager: BrowserManager) { this.browserManager = browserManager - this.commandQueue = new CdpCommandQueue( - () => - new BrowserError( - 'browser_busy', - 'Browser command queue is full; retry after the current commands finish' - ) - ) } setActiveTab(webContentsId: number): void { @@ -1018,13 +1010,7 @@ export class CdpBridge { this.removeDebuggerListeners(guest, state) } this.tabState.delete(tabId) - this.commandQueue.closeTab( - tabId, - new BrowserError( - 'browser_debugger_detached', - 'Browser tab closed before command execution.' - ) - ) + this.commandQueues.delete(tabId) } } @@ -1209,21 +1195,10 @@ export class CdpBridge { } | undefined if (p?.sessionId && p.targetInfo?.type === 'iframe' && p.targetInfo.targetId) { - const frameId = p.targetInfo.targetId - const canRetainSession = - isBoundedCdpIframeSession(frameId, p.sessionId) && - (state.iframeSessions.has(frameId) || - state.iframeSessions.size < CDP_MAX_IFRAME_SESSIONS) - if (canRetainSession) { - state.iframeSessions.set(frameId, p.sessionId) - guest.debugger.sendCommand('DOM.enable', {}, p.sessionId).catch(() => {}) - guest.debugger.sendCommand('Accessibility.enable', {}, p.sessionId).catch(() => {}) - guest.debugger.sendCommand('Runtime.enable', {}, p.sessionId).catch(() => {}) - } else { - guest.debugger - .sendCommand('Target.detachFromTarget', { sessionId: p.sessionId }) - .catch(() => {}) - } + state.iframeSessions.set(p.targetInfo.targetId, p.sessionId) + guest.debugger.sendCommand('DOM.enable', {}, p.sessionId).catch(() => {}) + guest.debugger.sendCommand('Accessibility.enable', {}, p.sessionId).catch(() => {}) + guest.debugger.sendCommand('Runtime.enable', {}, p.sessionId).catch(() => {}) } } if (method === 'Target.detachedFromTarget') { @@ -1249,8 +1224,15 @@ export class CdpBridge { } | undefined if (p) { - state.consoleLog.push(buildBoundedCdpConsoleEntry(p)) - if (state.consoleLog.length > CDP_CAPTURE_LOG_LIMIT) { + const text = (p.args ?? []).map((a) => a.value ?? a.description ?? '').join(' ') + state.consoleLog.push({ + level: p.type ?? 'log', + text, + timestamp: p.timestamp ?? Date.now(), + url: p.stackTrace?.callFrames?.[0]?.url, + line: p.stackTrace?.callFrames?.[0]?.lineNumber + }) + if (state.consoleLog.length > CAPTURE_LOG_LIMIT) { state.consoleLog.shift() } } @@ -1270,13 +1252,20 @@ export class CdpBridge { } | undefined if (p?.response) { - const entry = buildBoundedCdpNetworkEntry(p.response, p.timestamp) + const entry: BrowserNetworkEntry = { + url: p.response.url ?? '', + method: '', + status: p.response.status ?? 0, + mimeType: p.response.mimeType ?? '', + size: 0, + timestamp: p.timestamp ?? Date.now() + } state.networkLog.push(entry) // Why: map requestId→entry so loadingFinished attributes size to the right response, not the latest one. if (p.requestId) { state.networkRequestMap.set(p.requestId, entry) } - if (state.networkLog.length > CDP_CAPTURE_LOG_LIMIT) { + if (state.networkLog.length > CAPTURE_LOG_LIMIT) { const evicted = state.networkLog.shift() if (evicted) { for (const [requestId, requestEntry] of state.networkRequestMap) { @@ -1310,23 +1299,13 @@ export class CdpBridge { } | undefined if (p?.requestId && p.request) { - const boundedRequest = buildBoundedCdpInterceptedRequest({ - requestId: p.requestId, - request: p.request, - resourceType: p.resourceType + state.pausedRequests.set(p.requestId, { + id: p.requestId, + url: p.request.url ?? '', + method: p.request.method ?? 'GET', + headers: (p.request.headers ?? {}) as Record, + resourceType: p.resourceType ?? 'Other' }) - if ( - boundedRequest && - (state.pausedRequests.has(p.requestId) || - state.pausedRequests.size < CDP_MAX_PAUSED_REQUESTS) - ) { - state.pausedRequests.set(p.requestId, boundedRequest) - } else { - state.pausedRequests.delete(p.requestId) - guest.debugger - .sendCommand('Fetch.continueRequest', { requestId: p.requestId }) - .catch(() => {}) - } } } } @@ -1702,7 +1681,40 @@ export class CdpBridge { private async enqueueCommand(execute: () => Promise): Promise { const guest = this.getActiveGuest() const tabId = this.resolveTabId(guest.id) - return this.commandQueue.enqueue(tabId, execute) + + return new Promise((resolve, reject) => { + let queue = this.commandQueues.get(tabId) + if (!queue) { + queue = [] + this.commandQueues.set(tabId, queue) + } + queue.push({ + execute: execute as () => Promise, + resolve: resolve as (value: unknown) => void, + reject + }) + this.processQueue(tabId) + }) + } + + private async processQueue(tabId: string): Promise { + if (this.processingQueues.has(tabId)) { + return + } + this.processingQueues.add(tabId) + + const queue = this.commandQueues.get(tabId) + while (queue && queue.length > 0) { + const cmd = queue.shift()! + try { + const result = await cmd.execute() + cmd.resolve(result) + } catch (error) { + cmd.reject(error) + } + } + + this.processingQueues.delete(tabId) } } diff --git a/src/main/browser/cdp-command-queue.test.ts b/src/main/browser/cdp-command-queue.test.ts deleted file mode 100644 index 04f919b900a..00000000000 --- a/src/main/browser/cdp-command-queue.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { CdpCommandQueue } from './cdp-command-queue' - -function deferred(): { - promise: Promise - resolve: (value: T | PromiseLike) => void -} { - let resolve!: (value: T | PromiseLike) => void - const promise = new Promise((done) => { - resolve = done - }) - return { promise, resolve } -} - -describe('CdpCommandQueue', () => { - it('preserves ordinary per-tab command order', async () => { - const first = deferred() - const calls: number[] = [] - const queue = new CdpCommandQueue(() => new Error('full'), 4, 8) - const one = queue.enqueue('tab-1', async () => { - calls.push(1) - await first.promise - return 'one' - }) - const two = queue.enqueue('tab-1', async () => { - calls.push(2) - return 'two' - }) - - expect(calls).toEqual([1]) - first.resolve() - - await expect(Promise.all([one, two])).resolves.toEqual(['one', 'two']) - expect(calls).toEqual([1, 2]) - }) - - it('rejects commands beyond per-tab and aggregate queue caps', async () => { - const first = deferred() - const second = deferred() - const queue = new CdpCommandQueue(() => new Error('full'), 2, 3) - const active = queue.enqueue('tab-1', () => first.promise) - const activeTwo = queue.enqueue('tab-2', () => second.promise) - const queued = [ - queue.enqueue('tab-1', async () => 1), - queue.enqueue('tab-1', async () => 2), - queue.enqueue('tab-2', async () => 3) - ] - - await expect(queue.enqueue('tab-1', async () => 4)).rejects.toThrow('full') - await expect(queue.enqueue('tab-3', async () => 5)).rejects.toThrow('full') - - first.resolve() - second.resolve() - await expect(Promise.all([active, activeTwo, ...queued])).resolves.toEqual([ - undefined, - undefined, - 1, - 2, - 3 - ]) - }) - - it('rejects retained commands when a tab closes and continues a replacement queue', async () => { - const first = deferred() - const executeAfterClose = vi.fn(async () => 'stale') - const queue = new CdpCommandQueue(() => new Error('full'), 4, 8) - const active = queue.enqueue('tab-1', () => first.promise) - const stale = queue.enqueue('tab-1', executeAfterClose) - - queue.closeTab('tab-1', new Error('closed')) - await expect(stale).rejects.toThrow('closed') - expect(executeAfterClose).not.toHaveBeenCalled() - - const replacement = queue.enqueue('tab-1', async () => 'fresh') - first.resolve() - await expect(active).resolves.toBeUndefined() - await expect(replacement).resolves.toBe('fresh') - }) -}) diff --git a/src/main/browser/cdp-command-queue.ts b/src/main/browser/cdp-command-queue.ts deleted file mode 100644 index 71ea74e68fc..00000000000 --- a/src/main/browser/cdp-command-queue.ts +++ /dev/null @@ -1,80 +0,0 @@ -export const CDP_MAX_QUEUED_COMMANDS_PER_TAB = 64 -export const CDP_MAX_QUEUED_COMMANDS_TOTAL = 512 - -type QueuedCdpCommand = { - execute: () => Promise - resolve: (value: unknown) => void - reject: (reason: unknown) => void -} - -export class CdpCommandQueue { - private readonly queues = new Map() - private readonly processing = new Set() - private queuedCount = 0 - - constructor( - private readonly createOverflowError: () => Error, - private readonly maxQueuedPerTab = CDP_MAX_QUEUED_COMMANDS_PER_TAB, - private readonly maxQueuedTotal = CDP_MAX_QUEUED_COMMANDS_TOTAL - ) {} - - enqueue(tabId: string, execute: () => Promise): Promise { - return new Promise((resolve, reject) => { - let queue = this.queues.get(tabId) - if ((queue?.length ?? 0) >= this.maxQueuedPerTab || this.queuedCount >= this.maxQueuedTotal) { - reject(this.createOverflowError()) - return - } - if (!queue) { - queue = [] - this.queues.set(tabId, queue) - } - queue.push({ - execute: execute as () => Promise, - resolve: resolve as (value: unknown) => void, - reject - }) - this.queuedCount += 1 - void this.process(tabId) - }) - } - - closeTab(tabId: string, error: Error): void { - const queue = this.queues.get(tabId) - if (!queue) { - return - } - this.queues.delete(tabId) - for (const command of queue.splice(0)) { - this.queuedCount = Math.max(0, this.queuedCount - 1) - command.reject(error) - } - } - - private async process(tabId: string): Promise { - if (this.processing.has(tabId)) { - return - } - this.processing.add(tabId) - const queue = this.queues.get(tabId) - try { - while (queue && queue.length > 0) { - const command = queue.shift()! - this.queuedCount = Math.max(0, this.queuedCount - 1) - try { - command.resolve(await command.execute()) - } catch (error) { - command.reject(error) - } - } - if (this.queues.get(tabId) === queue) { - this.queues.delete(tabId) - } - } finally { - this.processing.delete(tabId) - if ((this.queues.get(tabId)?.length ?? 0) > 0) { - void this.process(tabId) - } - } - } -} diff --git a/src/main/browser/cdp-event-memory-bounds.test.ts b/src/main/browser/cdp-event-memory-bounds.test.ts deleted file mode 100644 index 7d30f6c0bfb..00000000000 --- a/src/main/browser/cdp-event-memory-bounds.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - buildBoundedCdpConsoleEntry, - buildBoundedCdpInterceptedRequest, - buildBoundedCdpNetworkEntry, - CDP_MAX_CAPTURE_URL_CODE_UNITS, - CDP_MAX_CONSOLE_ARGUMENTS, - CDP_MAX_CONSOLE_TEXT_CODE_UNITS, - CDP_MAX_INTERCEPTED_HEADERS, - CDP_MAX_INTERCEPTED_METADATA_CODE_UNITS -} from './cdp-event-memory-bounds' - -describe('CDP event memory bounds', () => { - it('preserves ordinary console and network capture fields', () => { - expect( - buildBoundedCdpConsoleEntry({ - type: 'warning', - args: [{ value: 'hello' }, { value: 42 }], - timestamp: 123, - stackTrace: { callFrames: [{ url: 'https://example.com/app.js', lineNumber: 7 }] } - }) - ).toEqual({ - level: 'warning', - text: 'hello 42', - timestamp: 123, - url: 'https://example.com/app.js', - line: 7 - }) - expect( - buildBoundedCdpNetworkEntry( - { url: 'https://example.com/data', status: 200, mimeType: 'application/json' }, - 456 - ) - ).toEqual({ - url: 'https://example.com/data', - method: '', - status: 200, - mimeType: 'application/json', - size: 0, - timestamp: 456 - }) - }) - - it('bounds console argument count, joined text, and capture URLs', () => { - const entry = buildBoundedCdpConsoleEntry({ - args: Array.from({ length: CDP_MAX_CONSOLE_ARGUMENTS + 100 }, () => ({ - value: 'x'.repeat(100) - })), - stackTrace: { callFrames: [{ url: 'u'.repeat(CDP_MAX_CAPTURE_URL_CODE_UNITS + 100) }] } - }) - - expect(entry.text.length).toBeLessThanOrEqual(CDP_MAX_CONSOLE_TEXT_CODE_UNITS) - expect(entry.url).toHaveLength(CDP_MAX_CAPTURE_URL_CODE_UNITS) - }) - - it('rejects intercepted metadata beyond aggregate and header-count caps', () => { - expect( - buildBoundedCdpInterceptedRequest({ - requestId: 'request-1', - request: { url: 'https://example.com', headers: { accept: '*/*' } } - }) - ).toEqual({ - id: 'request-1', - url: 'https://example.com', - method: 'GET', - headers: { accept: '*/*' }, - resourceType: 'Other' - }) - - expect( - buildBoundedCdpInterceptedRequest({ - requestId: 'request-2', - request: { url: 'u'.repeat(CDP_MAX_INTERCEPTED_METADATA_CODE_UNITS + 1) } - }) - ).toBeNull() - - const headers = Object.fromEntries( - Array.from({ length: CDP_MAX_INTERCEPTED_HEADERS + 1 }, (_, index) => [`x-${index}`, 'v']) - ) - expect( - buildBoundedCdpInterceptedRequest({ requestId: 'request-3', request: { headers } }) - ).toBeNull() - }) -}) diff --git a/src/main/browser/cdp-event-memory-bounds.ts b/src/main/browser/cdp-event-memory-bounds.ts deleted file mode 100644 index c2fdfa61649..00000000000 --- a/src/main/browser/cdp-event-memory-bounds.ts +++ /dev/null @@ -1,145 +0,0 @@ -import type { - BrowserConsoleEntry, - BrowserInterceptedRequest, - BrowserNetworkEntry -} from '../../shared/runtime-types' - -export const CDP_CAPTURE_LOG_LIMIT = 1000 -export const CDP_MAX_PAUSED_REQUESTS = 256 -export const CDP_MAX_IFRAME_SESSIONS = 1024 -export const CDP_MAX_CONSOLE_ARGUMENTS = 1024 -export const CDP_MAX_CONSOLE_TEXT_CODE_UNITS = 16 * 1024 -export const CDP_MAX_CAPTURE_URL_CODE_UNITS = 16 * 1024 -export const CDP_MAX_INTERCEPTED_HEADERS = 256 -export const CDP_MAX_INTERCEPTED_METADATA_CODE_UNITS = 64 * 1024 -const CDP_MAX_LEVEL_CODE_UNITS = 32 -const CDP_MAX_MIME_TYPE_CODE_UNITS = 1024 -const CDP_MAX_SESSION_ID_CODE_UNITS = 1024 - -type ConsoleEvent = { - type?: string - args?: { value?: unknown; description?: unknown }[] - timestamp?: number - stackTrace?: { callFrames?: { url?: string; lineNumber?: number }[] } -} - -type InterceptedRequestEvent = { - requestId: string - request: { url?: string; method?: string; headers?: Record } - resourceType?: string -} - -export function buildBoundedCdpConsoleEntry(event: ConsoleEvent): BrowserConsoleEntry { - const parts: string[] = [] - let remaining = CDP_MAX_CONSOLE_TEXT_CODE_UNITS - const args = event.args ?? [] - const argumentCount = Math.min(args.length, CDP_MAX_CONSOLE_ARGUMENTS) - for (let index = 0; index < argumentCount && remaining > 0; index++) { - if (index > 0) { - parts.push(' ') - remaining -= 1 - if (remaining <= 0) { - break - } - } - const raw = consoleArgumentText(args[index]) - const value = truncateCdpField(raw, remaining) - parts.push(value) - remaining -= value.length - } - return { - level: truncateCdpField(event.type ?? 'log', CDP_MAX_LEVEL_CODE_UNITS), - text: parts.join(''), - timestamp: event.timestamp ?? Date.now(), - url: truncateOptionalCdpField( - event.stackTrace?.callFrames?.[0]?.url, - CDP_MAX_CAPTURE_URL_CODE_UNITS - ), - line: event.stackTrace?.callFrames?.[0]?.lineNumber - } -} - -export function buildBoundedCdpNetworkEntry( - response: { url?: string; status?: number; mimeType?: string }, - timestamp?: number -): BrowserNetworkEntry { - return { - url: truncateCdpField(response.url ?? '', CDP_MAX_CAPTURE_URL_CODE_UNITS), - method: '', - status: response.status ?? 0, - mimeType: truncateCdpField(response.mimeType ?? '', CDP_MAX_MIME_TYPE_CODE_UNITS), - size: 0, - timestamp: timestamp ?? Date.now() - } -} - -export function buildBoundedCdpInterceptedRequest( - event: InterceptedRequestEvent -): BrowserInterceptedRequest | null { - let remaining = CDP_MAX_INTERCEPTED_METADATA_CODE_UNITS - const reserve = (value: string): boolean => { - if (value.length > remaining) { - return false - } - remaining -= value.length - return true - } - const url = event.request.url ?? '' - const method = event.request.method ?? 'GET' - const resourceType = event.resourceType ?? 'Other' - if (![event.requestId, url, method, resourceType].every(reserve)) { - return null - } - - const headers: Record = Object.create(null) as Record - let headerCount = 0 - for (const name in event.request.headers ?? {}) { - if (!Object.hasOwn(event.request.headers ?? {}, name)) { - continue - } - const value = event.request.headers![name] - if ( - headerCount >= CDP_MAX_INTERCEPTED_HEADERS || - typeof value !== 'string' || - !reserve(name) || - !reserve(value) - ) { - return null - } - headers[name] = value - headerCount += 1 - } - return { id: event.requestId, url, method, headers, resourceType } -} - -export function isBoundedCdpIframeSession(frameId: string, sessionId: string): boolean { - return ( - frameId.length <= CDP_MAX_SESSION_ID_CODE_UNITS && - sessionId.length <= CDP_MAX_SESSION_ID_CODE_UNITS - ) -} - -function consoleArgumentText(arg: { value?: unknown; description?: unknown } | undefined): string { - const value = arg?.value ?? arg?.description ?? '' - if (typeof value === 'string') { - return value - } - if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') { - return String(value) - } - return '' -} - -function truncateOptionalCdpField( - value: string | undefined, - maxLength: number -): string | undefined { - return value === undefined ? undefined : truncateCdpField(value, maxLength) -} - -function truncateCdpField(value: string, maxLength: number): string { - if (value.length <= maxLength) { - return value - } - return `${value.slice(0, Math.max(0, maxLength - 1))}…` -} diff --git a/src/main/browser/cdp-print-to-pdf.test.ts b/src/main/browser/cdp-print-to-pdf.test.ts index 8391599fd9a..e6f2163b5dd 100644 --- a/src/main/browser/cdp-print-to-pdf.test.ts +++ b/src/main/browser/cdp-print-to-pdf.test.ts @@ -1,22 +1,8 @@ import { describe, it, expect, vi, afterEach } from 'vitest' -import { - buildPrintToPdfOptions, - CDP_PDF_MAX_RETAINED_STREAMS, - CDP_PDF_MEMORY_LIMIT_ERROR, - CdpPdfRetentionBudget, - CdpPdfStreamStore -} from './cdp-print-to-pdf' -import type { CdpPdfStreamStoreOptions } from './cdp-print-to-pdf' +import { buildPrintToPdfOptions, CdpPdfStreamStore } from './cdp-print-to-pdf' const DEFAULT_MARGIN_INCHES = 1 / 2.54 const TTL_MS = 5 * 60 * 1000 -const stores = new Set() - -function createStore(options: CdpPdfStreamStoreOptions = {}): CdpPdfStreamStore { - const store = new CdpPdfStreamStore(options) - stores.add(store) - return store -} describe('buildPrintToPdfOptions', () => { it('returns empty options for empty params', () => { @@ -92,15 +78,11 @@ describe('buildPrintToPdfOptions', () => { describe('CdpPdfStreamStore', () => { afterEach(() => { - for (const store of stores) { - store.clear() - } - stores.clear() vi.useRealTimers() }) it('claims only its own handles', () => { - const store = createStore() + const store = new CdpPdfStreamStore() const handle = store.create(Buffer.from('pdf')) expect(store.ownsHandle({ handle })).toBe(true) @@ -111,12 +93,12 @@ describe('CdpPdfStreamStore', () => { }) it('mints distinct handles per stream', () => { - const store = createStore() + const store = new CdpPdfStreamStore() expect(store.create(Buffer.from('a'))).not.toBe(store.create(Buffer.from('b'))) }) it('reads sequential chunks and reports eof', () => { - const store = createStore() + const store = new CdpPdfStreamStore() const handle = store.create(Buffer.from('abcdef')) expect(store.read({ handle, size: 2 })).toEqual({ @@ -130,7 +112,7 @@ describe('CdpPdfStreamStore', () => { }) it('honors an explicit read offset', () => { - const store = createStore() + const store = new CdpPdfStreamStore() const handle = store.create(Buffer.from('abcdef')) expect(store.read({ handle, offset: 4 })).toEqual({ @@ -140,20 +122,20 @@ describe('CdpPdfStreamStore', () => { }) it('returns an empty eof chunk when reading past the end', () => { - const store = createStore() + const store = new CdpPdfStreamStore() const handle = store.create(Buffer.from('abc')) expect(store.read({ handle, offset: 99 })).toEqual({ data: '', eof: true }) }) it('returns null for unknown handles', () => { - const store = createStore() + const store = new CdpPdfStreamStore() expect(store.read({ handle: 'nope' })).toBeNull() expect(store.read({})).toBeNull() }) it('drops a stream on close', () => { - const store = createStore() + const store = new CdpPdfStreamStore() const handle = store.create(Buffer.from('abc')) store.close({ handle }) @@ -161,7 +143,7 @@ describe('CdpPdfStreamStore', () => { }) it('drops all streams on clear', () => { - const store = createStore() + const store = new CdpPdfStreamStore() const handle = store.create(Buffer.from('abc')) store.clear() @@ -170,7 +152,7 @@ describe('CdpPdfStreamStore', () => { it('evicts an abandoned stream after the TTL', () => { vi.useFakeTimers() - const store = createStore() + const store = new CdpPdfStreamStore() const handle = store.create(Buffer.from('abc')) vi.advanceTimersByTime(TTL_MS + 1) @@ -179,7 +161,7 @@ describe('CdpPdfStreamStore', () => { it('refreshes the TTL on each read', () => { vi.useFakeTimers() - const store = createStore() + const store = new CdpPdfStreamStore() const handle = store.create(Buffer.from('abcdef')) vi.advanceTimersByTime(TTL_MS - 1) @@ -187,67 +169,4 @@ describe('CdpPdfStreamStore', () => { vi.advanceTimersByTime(TTL_MS - 1) expect(store.read({ handle, size: 1 })).not.toBeNull() }) - - it('rejects aggregate stream retention past its count or byte budget', () => { - const store = createStore({ maxStreams: 2, maxRetainedBytes: 5 }) - const first = store.create(Buffer.from('ab')) - store.create(Buffer.from('cde')) - - expect(() => store.create(Buffer.alloc(0))).toThrow(CDP_PDF_MEMORY_LIMIT_ERROR) - store.close({ handle: first }) - expect(() => store.create(Buffer.from('fg'))).not.toThrow() - expect(() => createStore({ maxRetainedBytes: 5 }).create(Buffer.alloc(6))).toThrow( - CDP_PDF_MEMORY_LIMIT_ERROR - ) - store.clear() - }) - - it('clamps oversized IO.read requests to a bounded base64 chunk', () => { - const store = createStore({ maxReadChunkBytes: 2 }) - const handle = store.create(Buffer.from('abcdef')) - - expect(store.read({ handle, size: Number.MAX_SAFE_INTEGER })).toEqual({ - data: Buffer.from('ab').toString('base64'), - eof: false - }) - store.clear() - }) - - it('shares the default retained-stream cap across independent proxy stores', () => { - const admitted = Array.from({ length: CDP_PDF_MAX_RETAINED_STREAMS }, () => { - const store = createStore() - return { handle: store.create(Buffer.alloc(0)), store } - }) - const overflow = createStore() - - expect(() => overflow.create(Buffer.alloc(0))).toThrow(CDP_PDF_MEMORY_LIMIT_ERROR) - - admitted[0]!.store.close({ handle: admitted[0]!.handle }) - expect(() => overflow.create(Buffer.alloc(0))).not.toThrow() - }) - - it('releases a shared byte budget on close, expiry, clear, and failed admission', () => { - vi.useFakeTimers() - const budget = new CdpPdfRetentionBudget(4, 5) - const first = createStore({ maxRetainedBytes: 5, retentionBudget: budget }) - const second = createStore({ maxRetainedBytes: 5, retentionBudget: budget }) - const firstHandle = first.create(Buffer.from('abc')) - const secondHandle = second.create(Buffer.from('de')) - - expect(budget.inspect()).toEqual({ retainedBytes: 5, retainedStreams: 2 }) - expect(() => second.create(Buffer.from('f'))).toThrow(CDP_PDF_MEMORY_LIMIT_ERROR) - expect(budget.inspect()).toEqual({ retainedBytes: 5, retainedStreams: 2 }) - - first.close({ handle: firstHandle }) - expect(budget.inspect()).toEqual({ retainedBytes: 2, retainedStreams: 1 }) - second.close({ handle: secondHandle }) - const expiring = first.create(Buffer.from('xy')) - expect(first.read({ handle: expiring })).not.toBeNull() - vi.advanceTimersByTime(TTL_MS + 1) - expect(budget.inspect()).toEqual({ retainedBytes: 0, retainedStreams: 0 }) - - first.create(Buffer.from('z')) - first.clear() - expect(budget.inspect()).toEqual({ retainedBytes: 0, retainedStreams: 0 }) - }) }) diff --git a/src/main/browser/cdp-print-to-pdf.ts b/src/main/browser/cdp-print-to-pdf.ts index bc0999012cb..643013377d8 100644 --- a/src/main/browser/cdp-print-to-pdf.ts +++ b/src/main/browser/cdp-print-to-pdf.ts @@ -5,9 +5,6 @@ const PDF_DEFAULT_MARGIN_INCHES = 1 / 2.54 const PDF_STREAM_CHUNK_BYTES = 1024 * 1024 const PDF_STREAM_HANDLE_PREFIX = 'orca-pdf-' const PDF_STREAM_TTL_MS = 5 * 60 * 1000 -export const CDP_PDF_MAX_RETAINED_STREAMS = 8 -export const CDP_PDF_MAX_RETAINED_BYTES = 64 * 1024 * 1024 -export const CDP_PDF_MEMORY_LIMIT_ERROR = 'PDF exceeds the browser automation memory limit' function finiteNumber(value: unknown): number | null { return typeof value === 'number' && Number.isFinite(value) ? value : null @@ -83,7 +80,6 @@ type PdfStream = { data: Buffer offset: number cleanupTimer: ReturnType - releaseRetention: () => void } export type PdfStreamChunk = { @@ -91,57 +87,6 @@ export type PdfStreamChunk = { eof: boolean } -export type CdpPdfStreamStoreOptions = { - maxStreams?: number - maxRetainedBytes?: number - maxReadChunkBytes?: number - retentionBudget?: CdpPdfRetentionBudget -} - -export function assertCdpPdfWithinMemoryLimit(data: Buffer): void { - if (data.length > CDP_PDF_MAX_RETAINED_BYTES) { - throw new Error(CDP_PDF_MEMORY_LIMIT_ERROR) - } -} - -export class CdpPdfRetentionBudget { - private retainedBytes = 0 - private retainedStreams = 0 - - constructor( - private readonly maxStreams = CDP_PDF_MAX_RETAINED_STREAMS, - private readonly maxRetainedBytes = CDP_PDF_MAX_RETAINED_BYTES - ) {} - - retain(bytes: number): (() => void) | null { - if ( - !Number.isSafeInteger(bytes) || - bytes < 0 || - this.retainedStreams >= this.maxStreams || - this.retainedBytes + bytes > this.maxRetainedBytes - ) { - return null - } - this.retainedStreams += 1 - this.retainedBytes += bytes - let released = false - return () => { - if (released) { - return - } - released = true - this.retainedStreams = Math.max(0, this.retainedStreams - 1) - this.retainedBytes = Math.max(0, this.retainedBytes - bytes) - } - } - - inspect(): { retainedBytes: number; retainedStreams: number } { - return { retainedBytes: this.retainedBytes, retainedStreams: this.retainedStreams } - } -} - -const processPdfRetentionBudget = new CdpPdfRetentionBudget() - /** * Holds the PDF buffers produced for CDP `transferMode: "ReturnAsStream"` and * serves them back through `IO.read` / `IO.close`, the path Playwright's @@ -152,25 +97,7 @@ const processPdfRetentionBudget = new CdpPdfRetentionBudget() export class CdpPdfStreamStore { private readonly streams = new Map() private readonly handlePrefix = `${PDF_STREAM_HANDLE_PREFIX}${randomUUID()}-` - private readonly maxStreams: number - private readonly maxRetainedBytes: number - private readonly maxReadChunkBytes: number - private readonly retentionBudget: CdpPdfRetentionBudget private nextId = 0 - private retainedBytes = 0 - - constructor(options: CdpPdfStreamStoreOptions = {}) { - this.maxStreams = Math.max(0, Math.floor(options.maxStreams ?? CDP_PDF_MAX_RETAINED_STREAMS)) - this.maxRetainedBytes = Math.max( - 0, - Math.floor(options.maxRetainedBytes ?? CDP_PDF_MAX_RETAINED_BYTES) - ) - this.maxReadChunkBytes = Math.max( - 1, - Math.floor(options.maxReadChunkBytes ?? PDF_STREAM_CHUNK_BYTES) - ) - this.retentionBudget = options.retentionBudget ?? processPdfRetentionBudget - } /** True when `params.handle` names one of this store's streams. */ ownsHandle(params: Record): boolean { @@ -178,30 +105,12 @@ export class CdpPdfStreamStore { } create(data: Buffer): string { - if ( - data.length > this.maxRetainedBytes || - this.streams.size >= this.maxStreams || - this.retainedBytes + data.length > this.maxRetainedBytes - ) { - throw new Error(CDP_PDF_MEMORY_LIMIT_ERROR) - } - const releaseRetention = this.retentionBudget.retain(data.length) - if (!releaseRetention) { - throw new Error(CDP_PDF_MEMORY_LIMIT_ERROR) - } const handle = `${this.handlePrefix}${++this.nextId}` - let cleanupTimer: ReturnType | null = null - try { - cleanupTimer = this.scheduleCleanup(handle) - this.streams.set(handle, { data, offset: 0, cleanupTimer, releaseRetention }) - } catch (error) { - if (cleanupTimer) { - clearTimeout(cleanupTimer) - } - releaseRetention() - throw error - } - this.retainedBytes += data.length + this.streams.set(handle, { + data, + offset: 0, + cleanupTimer: this.scheduleCleanup(handle) + }) return handle } @@ -221,8 +130,8 @@ export class CdpPdfStreamStore { const requestedSize = finiteNumber(params.size) const size = requestedSize !== null && requestedSize > 0 - ? Math.min(Math.floor(requestedSize), this.maxReadChunkBytes) - : this.maxReadChunkBytes + ? Math.floor(requestedSize) + : PDF_STREAM_CHUNK_BYTES const start = Math.min(stream.offset, stream.data.length) const end = Math.min(start + size, stream.data.length) const chunk = stream.data.subarray(start, end) @@ -237,9 +146,10 @@ export class CdpPdfStreamStore { } clear(): void { - for (const handle of this.streams.keys()) { - this.delete(handle) + for (const stream of this.streams.values()) { + clearTimeout(stream.cleanupTimer) } + this.streams.clear() } private scheduleCleanup(handle: string): ReturnType { @@ -263,7 +173,5 @@ export class CdpPdfStreamStore { } clearTimeout(stream.cleanupTimer) this.streams.delete(handle) - this.retainedBytes = Math.max(0, this.retainedBytes - stream.data.length) - stream.releaseRetention() } } diff --git a/src/main/browser/cdp-screenshot.test.ts b/src/main/browser/cdp-screenshot.test.ts index 23409d48598..97859f7297f 100644 --- a/src/main/browser/cdp-screenshot.test.ts +++ b/src/main/browser/cdp-screenshot.test.ts @@ -1,18 +1,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { captureFullPageScreenshot, captureScreenshot } from './cdp-screenshot' -import { resetBrowserScreenshotAdmissionForTests } from './browser-screenshot-admission' -import { - BROWSER_SCREENSHOT_BUSY_ERROR, - BROWSER_SCREENSHOT_MAX_CONCURRENT_CAPTURES, - BROWSER_SCREENSHOT_MAX_DIMENSION_PX, - BROWSER_SCREENSHOT_MEMORY_LIMIT_ERROR -} from './browser-screenshot-limits' function createMockWebContents() { return { - once: vi.fn(), - removeListener: vi.fn(), isDestroyed: vi.fn(() => false), invalidate: vi.fn(), capturePage: vi.fn(), @@ -25,7 +16,6 @@ function createMockWebContents() { describe('captureScreenshot', () => { afterEach(() => { - resetBrowserScreenshotAdmissionForTests() vi.useRealTimers() }) @@ -46,102 +36,6 @@ describe('captureScreenshot', () => { expect(onError).not.toHaveBeenCalled() }) - it('rejects concurrent capture overload without retaining another native promise', async () => { - const webContents = createMockWebContents() - const resolvers: ((result: { data: string }) => void)[] = [] - webContents.debugger.sendCommand.mockImplementation( - () => - new Promise<{ data: string }>((resolve) => { - resolvers.push(resolve) - }) - ) - const results = Array.from({ length: BROWSER_SCREENSHOT_MAX_CONCURRENT_CAPTURES + 1 }, () => - vi.fn() - ) - const errors = results.map(() => vi.fn()) - - for (let index = 0; index < results.length; index += 1) { - captureScreenshot(webContents as never, { format: 'png' }, results[index]!, errors[index]!) - } - await Promise.resolve() - await Promise.resolve() - - expect(webContents.debugger.sendCommand).toHaveBeenCalledTimes( - BROWSER_SCREENSHOT_MAX_CONCURRENT_CAPTURES - ) - expect(errors.at(-1)).toHaveBeenCalledWith(BROWSER_SCREENSHOT_BUSY_ERROR) - for (const resolve of resolvers) { - resolve({ data: 'cG5n' }) - } - await Promise.resolve() - await Promise.resolve() - expect( - results - .slice(0, BROWSER_SCREENSHOT_MAX_CONCURRENT_CAPTURES) - .every((callback) => callback.mock.calls.length === 1) - ).toBe(true) - }) - - it('keeps timed-out native captures admitted until Chromium actually settles', async () => { - vi.useFakeTimers() - const webContents = createMockWebContents() - webContents.debugger.sendCommand.mockImplementation(() => new Promise(() => {})) - webContents.capturePage.mockResolvedValue({ - isEmpty: () => false, - getSize: () => ({ width: 800, height: 600 }), - toPNG: () => Buffer.from('fallback-png') - }) - const results = Array.from({ length: BROWSER_SCREENSHOT_MAX_CONCURRENT_CAPTURES }, () => - vi.fn() - ) - const errors = results.map(() => vi.fn()) - - for (let index = 0; index < results.length; index += 1) { - captureScreenshot(webContents as never, { format: 'png' }, results[index]!, errors[index]!) - } - await vi.advanceTimersByTimeAsync(8000) - - expect(results.every((callback) => callback.mock.calls.length === 0)).toBe(true) - expect( - errors.every((callback) => callback.mock.calls[0]?.[0] === BROWSER_SCREENSHOT_BUSY_ERROR) - ).toBe(true) - expect(webContents.capturePage).not.toHaveBeenCalled() - const overloadError = vi.fn() - captureScreenshot(webContents as never, { format: 'png' }, vi.fn(), overloadError) - expect(overloadError).toHaveBeenCalledWith(BROWSER_SCREENSHOT_BUSY_ERROR) - expect(webContents.debugger.sendCommand).toHaveBeenCalledTimes( - BROWSER_SCREENSHOT_MAX_CONCURRENT_CAPTURES - ) - }) - - it('retains admission for a fallback that outlives the failed CDP capture', async () => { - vi.useFakeTimers() - const webContents = createMockWebContents() - const commandRejectors: ((error: Error) => void)[] = [] - webContents.debugger.sendCommand.mockImplementation( - () => - new Promise((_, reject) => { - commandRejectors.push(reject) - }) - ) - webContents.capturePage.mockImplementation(() => new Promise(() => {})) - const firstError = vi.fn() - - captureScreenshot(webContents as never, { format: 'png' }, vi.fn(), firstError) - await vi.advanceTimersByTimeAsync(8000) - commandRejectors[0]!(new Error('CDP failed after timeout')) - await Promise.resolve() - await Promise.resolve() - - expect(firstError).toHaveBeenCalledWith('CDP failed after timeout') - const secondError = vi.fn() - captureScreenshot(webContents as never, { format: 'png' }, vi.fn(), secondError) - await vi.advanceTimersByTimeAsync(8000) - - expect(secondError).toHaveBeenCalledWith(BROWSER_SCREENSHOT_BUSY_ERROR) - expect(webContents.capturePage).toHaveBeenCalledOnce() - }) - it('falls back to capturePage when Page.captureScreenshot stalls', async () => { vi.useFakeTimers() @@ -149,7 +43,6 @@ describe('captureScreenshot', () => { webContents.debugger.sendCommand.mockImplementation(() => new Promise(() => {})) webContents.capturePage.mockResolvedValueOnce({ isEmpty: () => false, - getSize: () => ({ width: 800, height: 600 }), toPNG: () => Buffer.from('fallback-png') }) const onResult = vi.fn() @@ -170,7 +63,6 @@ describe('captureScreenshot', () => { const croppedImage = { isEmpty: () => false, - getSize: () => ({ width: 60, height: 80 }), toPNG: () => Buffer.from('cropped-png') } const webContents = createMockWebContents() @@ -235,47 +127,6 @@ describe('captureScreenshot', () => { ) }) - it('rejects an oversized clip before asking Chromium to capture it', () => { - const webContents = createMockWebContents() - const onResult = vi.fn() - const onError = vi.fn() - - captureScreenshot( - webContents as never, - { - format: 'png', - clip: { x: 0, y: 0, width: BROWSER_SCREENSHOT_MAX_DIMENSION_PX + 1, height: 1 } - }, - onResult, - onError - ) - - expect(onError).toHaveBeenCalledWith(BROWSER_SCREENSHOT_MEMORY_LIMIT_ERROR) - expect(onResult).not.toHaveBeenCalled() - expect(webContents.debugger.sendCommand).not.toHaveBeenCalled() - }) - - it('rejects an oversized fallback bitmap before encoding it', async () => { - vi.useFakeTimers() - const toPNG = vi.fn(() => Buffer.from('unused')) - const webContents = createMockWebContents() - webContents.debugger.sendCommand.mockImplementation(() => new Promise(() => {})) - webContents.capturePage.mockResolvedValueOnce({ - isEmpty: () => false, - getSize: () => ({ width: BROWSER_SCREENSHOT_MAX_DIMENSION_PX + 1, height: 1 }), - toPNG - }) - const onResult = vi.fn() - const onError = vi.fn() - - captureScreenshot(webContents as never, { format: 'png' }, onResult, onError) - await vi.advanceTimersByTimeAsync(8000) - - expect(onError).toHaveBeenCalledWith(BROWSER_SCREENSHOT_MEMORY_LIMIT_ERROR) - expect(onResult).not.toHaveBeenCalled() - expect(toPNG).not.toHaveBeenCalled() - }) - it('ignores the fallback result when CDP settles first after the timeout fires', async () => { vi.useFakeTimers() @@ -386,11 +237,6 @@ describe('captureScreenshot', () => { }) describe('captureFullPageScreenshot', () => { - afterEach(() => { - resetBrowserScreenshotAdmissionForTests() - vi.useRealTimers() - }) - it('uses cssContentSize so HiDPI pages are captured at the real page size', async () => { const webContents = createMockWebContents() webContents.debugger.sendCommand.mockImplementation((method: string) => { @@ -442,43 +288,4 @@ describe('captureFullPageScreenshot', () => { clip: { x: 0, y: 0, width: 800, height: 1600, scale: 1 } }) }) - - it('rejects oversized full-page layout bounds before capture allocation', async () => { - const webContents = createMockWebContents() - webContents.debugger.sendCommand.mockResolvedValueOnce({ - cssContentSize: { width: BROWSER_SCREENSHOT_MAX_DIMENSION_PX + 1, height: 1 } - }) - - await expect(captureFullPageScreenshot(webContents as never, 'png')).rejects.toThrow( - BROWSER_SCREENSHOT_MEMORY_LIMIT_ERROR - ) - expect(webContents.debugger.sendCommand).toHaveBeenCalledOnce() - expect(webContents.debugger.sendCommand).toHaveBeenCalledWith('Page.getLayoutMetrics', {}) - }) - - it('bounds hung full-page metric commands across logical timeouts', async () => { - vi.useFakeTimers() - const webContents = createMockWebContents() - webContents.debugger.sendCommand.mockImplementation(() => new Promise(() => {})) - const active = Array.from({ length: BROWSER_SCREENSHOT_MAX_CONCURRENT_CAPTURES }, () => - captureFullPageScreenshot(webContents as never, 'png') - ) - const activeAssertions = active.map((capture) => - expect(capture).rejects.toThrow( - 'Screenshot timed out — the browser tab may not be visible or the window may not have focus.' - ) - ) - - await expect(captureFullPageScreenshot(webContents as never, 'png')).rejects.toThrow( - BROWSER_SCREENSHOT_BUSY_ERROR - ) - await vi.advanceTimersByTimeAsync(8000) - await Promise.all(activeAssertions) - await expect(captureFullPageScreenshot(webContents as never, 'png')).rejects.toThrow( - BROWSER_SCREENSHOT_BUSY_ERROR - ) - expect(webContents.debugger.sendCommand).toHaveBeenCalledTimes( - BROWSER_SCREENSHOT_MAX_CONCURRENT_CAPTURES - ) - }) }) diff --git a/src/main/browser/cdp-screenshot.ts b/src/main/browser/cdp-screenshot.ts index 2abba87c152..7ba4315d7d9 100644 --- a/src/main/browser/cdp-screenshot.ts +++ b/src/main/browser/cdp-screenshot.ts @@ -1,20 +1,83 @@ import type { WebContents } from 'electron' -import { - assertBrowserScreenshotBase64, - assertBrowserScreenshotGeometry, - BROWSER_SCREENSHOT_BUSY_ERROR, - BROWSER_SCREENSHOT_MEMORY_LIMIT_ERROR -} from './browser-screenshot-limits' -import { - startBrowserFallbackCapture, - startBrowserScreenshotCommand -} from './browser-screenshot-admission' -import { encodeNativeImageScreenshot } from './native-image-screenshot-encoder' const SCREENSHOT_TIMEOUT_MS = 8000 const FALLBACK_CAPTURE_TIMEOUT_MS = 1000 const SCREENSHOT_TIMEOUT_MESSAGE = 'Screenshot timed out — the browser tab may not be visible or the window may not have focus.' + +function applyFallbackClip( + image: Electron.NativeImage, + params: Record | undefined +): Electron.NativeImage | null { + if (params?.captureBeyondViewport) { + // Why: capturePage() can only see the currently painted viewport. If the + // caller asked for beyond-viewport pixels, returning a viewport-sized image + // would silently lie about what was captured. + return null + } + + const clip = params?.clip + if (!clip || typeof clip !== 'object') { + return image + } + const clipRect = clip as Record + + const x = typeof clipRect.x === 'number' ? clipRect.x : Number.NaN + const y = typeof clipRect.y === 'number' ? clipRect.y : Number.NaN + const width = typeof clipRect.width === 'number' ? clipRect.width : Number.NaN + const height = typeof clipRect.height === 'number' ? clipRect.height : Number.NaN + const scale = + typeof clipRect.scale === 'number' && Number.isFinite(clipRect.scale) && clipRect.scale > 0 + ? clipRect.scale + : 1 + + if (![x, y, width, height].every(Number.isFinite) || width <= 0 || height <= 0) { + return null + } + + const cropRect = { + x: Math.round(x * scale), + y: Math.round(y * scale), + width: Math.round(width * scale), + height: Math.round(height * scale) + } + const imageSize = image.getSize() + if ( + cropRect.x < 0 || + cropRect.y < 0 || + cropRect.width <= 0 || + cropRect.height <= 0 || + cropRect.x + cropRect.width > imageSize.width || + cropRect.y + cropRect.height > imageSize.height + ) { + return null + } + + return image.crop(cropRect) +} + +function encodeNativeImageScreenshot( + image: Electron.NativeImage, + params: Record | undefined +): { data: string } | null { + if (image.isEmpty()) { + return null + } + + const clippedImage = applyFallbackClip(image, params) + if (!clippedImage || clippedImage.isEmpty()) { + return null + } + + const format = params?.format === 'jpeg' ? 'jpeg' : 'png' + const quality = + typeof params?.quality === 'number' && Number.isFinite(params.quality) + ? Math.max(0, Math.min(100, Math.round(params.quality))) + : undefined + const buffer = format === 'jpeg' ? clippedImage.toJPEG(quality ?? 90) : clippedImage.toPNG() + return { data: buffer.toString('base64') } +} + function getLayoutClip(metrics: { cssContentSize?: { width?: number; height?: number } contentSize?: { width?: number; height?: number } @@ -37,31 +100,25 @@ function getLayoutClip(metrics: { return null } - const clip = { + return { x: 0, y: 0, width: Math.ceil(width), height: Math.ceil(height), scale: 1 } - assertBrowserScreenshotGeometry(clip.width, clip.height) - return clip } async function sendCommandWithTimeout( webContents: WebContents, - method: 'Page.captureScreenshot' | 'Page.getLayoutMetrics', + method: string, params: Record | undefined, timeoutMessage: string ): Promise { - const command = startBrowserScreenshotCommand(webContents, method, params ?? {}) - if (!command) { - throw new Error(BROWSER_SCREENSHOT_BUSY_ERROR) - } let timer: NodeJS.Timeout | null = null try { return await Promise.race([ - command, + webContents.debugger.sendCommand(method, params ?? {}) as Promise, new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(timeoutMessage)), SCREENSHOT_TIMEOUT_MS) }) @@ -103,27 +160,17 @@ export async function captureFullPageScreenshot( const { data } = await sendCommandWithTimeout<{ data: string }>( webContents, 'Page.captureScreenshot', - { format, captureBeyondViewport: true, clip }, + { + format, + captureBeyondViewport: true, + clip + }, SCREENSHOT_TIMEOUT_MESSAGE ) - assertBrowserScreenshotBase64(data) return { data, format } } -function assertCaptureClipWithinLimit(params: Record | undefined): void { - const clip = params?.clip - if (!clip || typeof clip !== 'object') { - return - } - const values = clip as Record - if (typeof values.width !== 'number' || typeof values.height !== 'number') { - return - } - const scale = typeof values.scale === 'number' ? values.scale : 1 - assertBrowserScreenshotGeometry(values.width, values.height, scale) -} - // Why: Electron's capturePage() is unreliable on webview guests — the compositor // may not produce frames when the webview panel is inactive, unfocused, or in a // split-pane layout. Instead, use the debugger's Page.captureScreenshot which @@ -145,12 +192,7 @@ export function captureScreenshot( onError('Debugger not attached') return } - try { - assertCaptureClipWithinLimit(params) - } catch (error) { - onError(error instanceof Error ? error.message : BROWSER_SCREENSHOT_MEMORY_LIMIT_ERROR) - return - } + const screenshotParams: Record = {} if (params?.format) { screenshotParams.format = params.format @@ -167,15 +209,6 @@ export function captureScreenshot( if (params?.fromSurface != null) { screenshotParams.fromSurface = params.fromSurface } - const capture = startBrowserScreenshotCommand( - webContents, - 'Page.captureScreenshot', - screenshotParams - ) - if (!capture) { - onError(BROWSER_SCREENSHOT_BUSY_ERROR) - return - } let settled = false let timeoutTimer: ReturnType | null = null @@ -194,16 +227,6 @@ export function captureScreenshot( if (settled) { return } - const data = - result && typeof result === 'object' ? (result as { data?: unknown }).data : undefined - try { - if (typeof data === 'string') { - assertBrowserScreenshotBase64(data) - } - } catch (error) { - settleError(error instanceof Error ? error.message : BROWSER_SCREENSHOT_MEMORY_LIMIT_ERROR) - return - } settled = true clearTimers() onResult(result) @@ -234,48 +257,42 @@ export function captureScreenshot( () => settleError(SCREENSHOT_TIMEOUT_MESSAGE), FALLBACK_CAPTURE_TIMEOUT_MS ) - const fallbackCapture = startBrowserFallbackCapture(webContents) - if (!fallbackCapture) { - settleError(BROWSER_SCREENSHOT_BUSY_ERROR) - return - } - void fallbackCapture.then( - (image) => { - if (settled) { - return + void Promise.resolve() + .then(() => webContents.capturePage()) + .then( + (image) => { + if (settled) { + return + } + if (fallbackTimer) { + clearTimeout(fallbackTimer) + fallbackTimer = null + } + let fallback: { data: string } | null = null + try { + fallback = encodeNativeImageScreenshot(image, params) + } catch { + settleError(SCREENSHOT_TIMEOUT_MESSAGE) + return + } + if (fallback) { + settleResult(fallback) + return + } + settleError(SCREENSHOT_TIMEOUT_MESSAGE) + }, + () => { + if (fallbackTimer) { + clearTimeout(fallbackTimer) + fallbackTimer = null + } + settleError(SCREENSHOT_TIMEOUT_MESSAGE) } - if (fallbackTimer) { - clearTimeout(fallbackTimer) - fallbackTimer = null - } - let fallback: { data: string } | null = null - try { - fallback = encodeNativeImageScreenshot(image, params) - } catch (error) { - settleError( - error instanceof Error && error.message === BROWSER_SCREENSHOT_MEMORY_LIMIT_ERROR - ? error.message - : SCREENSHOT_TIMEOUT_MESSAGE - ) - return - } - if (fallback) { - settleResult(fallback) - return - } - settleError(SCREENSHOT_TIMEOUT_MESSAGE) - }, - () => { - if (fallbackTimer) { - clearTimeout(fallbackTimer) - fallbackTimer = null - } - settleError(SCREENSHOT_TIMEOUT_MESSAGE) - } - ) + ) }, SCREENSHOT_TIMEOUT_MS) - void capture + dbg + .sendCommand('Page.captureScreenshot', screenshotParams) .then((result) => settleResult(result)) .catch((err) => settleError((err as Error).message)) } diff --git a/src/main/browser/cdp-ws-proxy.test.ts b/src/main/browser/cdp-ws-proxy.test.ts index 522efddf022..5c366eda727 100644 --- a/src/main/browser/cdp-ws-proxy.test.ts +++ b/src/main/browser/cdp-ws-proxy.test.ts @@ -1,10 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import WebSocket from 'ws' -import { - CDP_PROXY_MAX_MESSAGE_BYTES, - CDP_PROXY_MAX_TCP_CONNECTIONS, - CdpWsProxy -} from './cdp-ws-proxy' +import { CdpWsProxy } from './cdp-ws-proxy' import { connect, createMockWebContents, @@ -56,16 +52,6 @@ describe('CdpWsProxy', () => { expect(server.listenerCount('error')).toBeLessThanOrEqual(1) }) - it('caps pre-upgrade sockets and WebSocket message materialization', () => { - const internals = proxy as unknown as { - httpServer: { maxConnections: number } - wss: { options: { maxPayload: number } } - } - - expect(internals.httpServer.maxConnections).toBe(CDP_PROXY_MAX_TCP_CONNECTIONS) - expect(internals.wss.options.maxPayload).toBe(CDP_PROXY_MAX_MESSAGE_BYTES) - }) - it('attaches debugger on start', () => { expect(mock.webContents.debugger.attach).toHaveBeenCalledWith('1.3') }) @@ -758,37 +744,6 @@ describe('CdpWsProxy', () => { createSpy.mockRestore() }) - it('rejects PDF print overload instead of retaining unlimited native promises', async () => { - const resolvePrints: ((buffer: Buffer) => void)[] = [] - mock.webContents.printToPDF.mockImplementation( - () => - new Promise>((resolve) => { - resolvePrints.push(resolve) - }) - ) - const client = await connect(endpoint) - const responses: Record[] = [] - client.on('message', (data) => responses.push(JSON.parse(data.toString()))) - - for (let id = 31; id <= 33; id += 1) { - client.send(JSON.stringify({ id, method: 'Page.printToPDF' })) - } - - await vi.waitFor(() => - expect(responses.find((response) => response.id === 33)).toEqual({ - id: 33, - error: { code: -32000, message: 'Too many PDF print requests are already running' } - }) - ) - expect(mock.webContents.printToPDF).toHaveBeenCalledTimes(2) - - for (const resolve of resolvePrints) { - resolve(Buffer.from('%PDF-bounded')) - } - await vi.waitFor(() => expect(responses).toHaveLength(3)) - client.close() - }) - it('forwards non-PDF IO streams to the debugger', async () => { mock.webContents.debugger.sendCommand .mockResolvedValueOnce({ data: 'trace-data', eof: false }) diff --git a/src/main/browser/cdp-ws-proxy.ts b/src/main/browser/cdp-ws-proxy.ts index 1e7277d2598..99c1d554654 100644 --- a/src/main/browser/cdp-ws-proxy.ts +++ b/src/main/browser/cdp-ws-proxy.ts @@ -3,18 +3,11 @@ import { WebSocketServer, WebSocket } from 'ws' import { createServer, type Server, type IncomingMessage, type ServerResponse } from 'node:http' import type { WebContents } from 'electron' import { captureScreenshot } from './cdp-screenshot' -import { - assertCdpPdfWithinMemoryLimit, - buildPrintToPdfOptions, - CdpPdfStreamStore -} from './cdp-print-to-pdf' +import { buildPrintToPdfOptions, CdpPdfStreamStore } from './cdp-print-to-pdf' import { ANTI_DETECTION_SCRIPT } from './anti-detection' import { acquireElectronDebugger, type ElectronDebuggerLease } from './electron-debugger-lease' -import { BROWSER_PDF_BUSY_ERROR, startBrowserPdfPrint } from './browser-pdf-admission' const LIFECYCLE_PRIMING_TIMEOUT_MS = 1_000 -export const CDP_PROXY_MAX_MESSAGE_BYTES = 16 * 1024 * 1024 -export const CDP_PROXY_MAX_TCP_CONNECTIONS = 16 export class CdpWsProxy { // Why: holds each session's last DOM.focus params to replay right before the next @@ -47,12 +40,7 @@ export class CdpWsProxy { await this.attachDebugger() return new Promise((resolve, reject) => { this.httpServer = createServer((req, res) => this.handleHttpRequest(req, res)) - // Why: raw sockets that never upgrade must not bypass the proxy's single active WebSocket. - this.httpServer.maxConnections = CDP_PROXY_MAX_TCP_CONNECTIONS - this.wss = new WebSocketServer({ - server: this.httpServer, - maxPayload: CDP_PROXY_MAX_MESSAGE_BYTES - }) + this.wss = new WebSocketServer({ server: this.httpServer }) const failStart = (error: Error): void => { this.httpServer?.removeListener('error', onListenError) this.wss?.close() @@ -647,13 +635,8 @@ export class CdpWsProxy { this.sendError(clientId, 'Browser tab is no longer available', client) return } - const print = startBrowserPdfPrint(this.webContents, buildPrintToPdfOptions(params)) - if (!print) { - this.sendError(clientId, BROWSER_PDF_BUSY_ERROR, client) - return - } try { - const pdf = await print + const pdf = await this.webContents.printToPDF(buildPrintToPdfOptions(params)) // Why: printToPDF can resolve after the client disconnected (or was // replaced). Bail before registering a stream so its buffer isn't // orphaned in pdfStreams past the disconnect's clear() until the TTL. @@ -661,7 +644,6 @@ export class CdpWsProxy { return } const buffer = Buffer.isBuffer(pdf) ? pdf : Buffer.from(pdf) - assertCdpPdfWithinMemoryLimit(buffer) if (params.transferMode === 'ReturnAsStream') { const handle = this.pdfStreams.create(buffer) this.sendResult(clientId, { data: '', stream: handle }, client) diff --git a/src/main/browser/installed-browser-cookie-store-limits.test.ts b/src/main/browser/installed-browser-cookie-store-limits.test.ts deleted file mode 100644 index 6dad57cd4d3..00000000000 --- a/src/main/browser/installed-browser-cookie-store-limits.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - assertInstalledBrowserCookieStoreWithinLimits, - INSTALLED_BROWSER_COOKIE_STORE_MAX_BYTES, - INSTALLED_BROWSER_COOKIE_STORE_MAX_COOKIES, - InstalledBrowserCookieStoreLimitError -} from './installed-browser-cookie-store-limits' - -describe('assertInstalledBrowserCookieStoreWithinLimits', () => { - it('accepts the exact cookie-count and byte boundaries', () => { - expect( - assertInstalledBrowserCookieStoreWithinLimits( - BigInt(INSTALLED_BROWSER_COOKIE_STORE_MAX_COOKIES), - BigInt(INSTALLED_BROWSER_COOKIE_STORE_MAX_BYTES) - ) - ).toBe(INSTALLED_BROWSER_COOKIE_STORE_MAX_COOKIES) - }) - - it.each([ - { - kind: 'cookies', - cookies: INSTALLED_BROWSER_COOKIE_STORE_MAX_COOKIES + 1, - bytes: INSTALLED_BROWSER_COOKIE_STORE_MAX_BYTES - }, - { - kind: 'bytes', - cookies: INSTALLED_BROWSER_COOKIE_STORE_MAX_COOKIES, - bytes: INSTALLED_BROWSER_COOKIE_STORE_MAX_BYTES + 1 - } - ])('rejects one $kind over its boundary', ({ kind, cookies, bytes }) => { - expect(() => assertInstalledBrowserCookieStoreWithinLimits(cookies, bytes)).toThrow( - expect.objectContaining({ - name: InstalledBrowserCookieStoreLimitError.name, - kind - }) - ) - }) -}) diff --git a/src/main/browser/installed-browser-cookie-store-limits.ts b/src/main/browser/installed-browser-cookie-store-limits.ts deleted file mode 100644 index be62a7f82fe..00000000000 --- a/src/main/browser/installed-browser-cookie-store-limits.ts +++ /dev/null @@ -1,41 +0,0 @@ -export const INSTALLED_BROWSER_COOKIE_STORE_MAX_COOKIES = 250_000 -export const INSTALLED_BROWSER_COOKIE_STORE_MAX_BYTES = 64 * 1024 * 1024 - -export type InstalledBrowserCookieStoreLimitKind = 'cookies' | 'bytes' - -export class InstalledBrowserCookieStoreLimitError extends Error { - constructor( - readonly kind: InstalledBrowserCookieStoreLimitKind, - readonly observed: number | bigint, - readonly limit: number - ) { - super(`Installed browser cookie store exceeds the ${kind} limit`) - this.name = 'InstalledBrowserCookieStoreLimitError' - } -} - -export function assertInstalledBrowserCookieStoreWithinLimits( - cookieCount: number | bigint, - cookieBytes: number | bigint -): number { - enforceLimit('cookies', cookieCount, INSTALLED_BROWSER_COOKIE_STORE_MAX_COOKIES) - enforceLimit('bytes', cookieBytes, INSTALLED_BROWSER_COOKIE_STORE_MAX_BYTES) - return Number(cookieCount) -} - -export function installedBrowserCookieStoreLimitReason(browserLabel: string): string { - return `${browserLabel} cookie store is too large to import safely (${INSTALLED_BROWSER_COOKIE_STORE_MAX_COOKIES.toLocaleString('en-US')}-cookie and ${INSTALLED_BROWSER_COOKIE_STORE_MAX_BYTES / 1024 / 1024} MiB cookie-data limits).` -} - -function enforceLimit( - kind: InstalledBrowserCookieStoreLimitKind, - observed: number | bigint, - limit: number -): void { - if ( - (typeof observed === 'bigint' && observed > BigInt(limit)) || - (typeof observed === 'number' && (!Number.isSafeInteger(observed) || observed > limit)) - ) { - throw new InstalledBrowserCookieStoreLimitError(kind, observed, limit) - } -} diff --git a/src/main/browser/native-image-screenshot-encoder.ts b/src/main/browser/native-image-screenshot-encoder.ts deleted file mode 100644 index 48a326feb4c..00000000000 --- a/src/main/browser/native-image-screenshot-encoder.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { - assertBrowserScreenshotEncodedBytes, - assertBrowserScreenshotGeometry -} from './browser-screenshot-limits' - -function applyFallbackClip( - image: Electron.NativeImage, - params: Record | undefined -): Electron.NativeImage | null { - if (params?.captureBeyondViewport) { - // Why: capturePage cannot produce pixels outside the currently painted viewport. - return null - } - - const clip = params?.clip - if (!clip || typeof clip !== 'object') { - return image - } - const clipRect = clip as Record - - const x = typeof clipRect.x === 'number' ? clipRect.x : Number.NaN - const y = typeof clipRect.y === 'number' ? clipRect.y : Number.NaN - const width = typeof clipRect.width === 'number' ? clipRect.width : Number.NaN - const height = typeof clipRect.height === 'number' ? clipRect.height : Number.NaN - const scale = - typeof clipRect.scale === 'number' && Number.isFinite(clipRect.scale) && clipRect.scale > 0 - ? clipRect.scale - : 1 - - if (![x, y, width, height].every(Number.isFinite) || width <= 0 || height <= 0) { - return null - } - - const cropRect = { - x: Math.round(x * scale), - y: Math.round(y * scale), - width: Math.round(width * scale), - height: Math.round(height * scale) - } - assertBrowserScreenshotGeometry(cropRect.width, cropRect.height) - const imageSize = image.getSize() - if ( - cropRect.x < 0 || - cropRect.y < 0 || - cropRect.width <= 0 || - cropRect.height <= 0 || - cropRect.x + cropRect.width > imageSize.width || - cropRect.y + cropRect.height > imageSize.height - ) { - return null - } - - return image.crop(cropRect) -} - -export function encodeNativeImageScreenshot( - image: Electron.NativeImage, - params: Record | undefined -): { data: string } | null { - if (image.isEmpty()) { - return null - } - - const clippedImage = applyFallbackClip(image, params) - if (!clippedImage || clippedImage.isEmpty()) { - return null - } - const clippedSize = clippedImage.getSize() - assertBrowserScreenshotGeometry(clippedSize.width, clippedSize.height) - - const format = params?.format === 'jpeg' ? 'jpeg' : 'png' - const quality = - typeof params?.quality === 'number' && Number.isFinite(params.quality) - ? Math.max(0, Math.min(100, Math.round(params.quality))) - : undefined - const buffer = format === 'jpeg' ? clippedImage.toJPEG(quality ?? 90) : clippedImage.toPNG() - assertBrowserScreenshotEncodedBytes(buffer.length) - return { data: buffer.toString('base64') } -} diff --git a/src/main/browser/safari-cookie-store-decoder.test.ts b/src/main/browser/safari-cookie-store-decoder.test.ts deleted file mode 100644 index 5ec0e09be33..00000000000 --- a/src/main/browser/safari-cookie-store-decoder.test.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - decodeSafariCookieStore, - SAFARI_COOKIE_STORE_MAX_COOKIES, - SAFARI_COOKIE_STORE_MAX_FILE_BYTES, - SAFARI_COOKIE_STORE_MAX_PAGES, - SAFARI_COOKIE_STORE_MAX_PARSED_BYTES, - SafariCookieStoreLimitError, - removeExpiredSafariCookiesInPlace, - type SafariCookieDecodeLimits -} from './safari-cookie-store-decoder' - -type TestCookie = { - domain: string - name: string - value: string - path?: string - secure?: boolean - httpOnly?: boolean - expiration?: number -} - -function buildCookie(input: TestCookie): Buffer { - const strings = [input.domain, input.name, input.path ?? '/', input.value] - let cursor = 48 - const offsets = strings.map((text) => { - const offset = cursor - cursor += Buffer.byteLength(text) + 1 - return offset - }) - const cookie = Buffer.alloc(cursor) - cookie.writeUInt32LE(cookie.length, 0) - cookie.writeUInt32LE((input.secure ? 1 : 0) | (input.httpOnly ? 4 : 0), 8) - cookie.writeUInt32LE(offsets[0]!, 16) - cookie.writeUInt32LE(offsets[1]!, 20) - cookie.writeUInt32LE(offsets[2]!, 24) - cookie.writeUInt32LE(offsets[3]!, 28) - cookie.writeDoubleLE(input.expiration ?? 0, 40) - strings.forEach((text, index) => cookie.write(text, offsets[index]!, 'utf8')) - return cookie -} - -function buildPage(inputs: TestCookie[]): Buffer { - const cookies = inputs.map(buildCookie) - const headerBytes = 8 + cookies.length * 4 - const page = Buffer.alloc(headerBytes + cookies.reduce((sum, cookie) => sum + cookie.length, 0)) - page.writeUInt32BE(0x00000100, 0) - page.writeUInt32LE(cookies.length, 4) - let cursor = headerBytes - cookies.forEach((cookie, index) => { - page.writeUInt32LE(cursor, 8 + index * 4) - cookie.copy(page, cursor) - cursor += cookie.length - }) - return page -} - -function buildStore(pages: TestCookie[][]): Buffer { - const encodedPages = pages.map(buildPage) - const headerBytes = 8 + encodedPages.length * 4 - const store = Buffer.alloc(headerBytes + encodedPages.reduce((sum, page) => sum + page.length, 0)) - store.write('cook', 0, 'utf8') - store.writeUInt32BE(encodedPages.length, 4) - let cursor = headerBytes - encodedPages.forEach((page, index) => { - store.writeUInt32BE(page.length, 8 + index * 4) - page.copy(store, cursor) - cursor += page.length - }) - return store -} - -function limits(overrides: Partial = {}): SafariCookieDecodeLimits { - return { - maxPages: 10, - maxCookies: 10, - maxParsedBytes: 1024, - ...overrides - } -} - -function retainedBytes(cookie: TestCookie): number { - const cleanDomain = cookie.domain.startsWith('.') ? cookie.domain.slice(1) : cookie.domain - const url = `${cookie.secure ? 'https' : 'http'}://${cleanDomain}/` - return [url, cookie.domain, cookie.name, cookie.value, cookie.path ?? '/'].reduce( - (sum, value) => sum + Buffer.byteLength(value), - 0 - ) -} - -describe('decodeSafariCookieStore', () => { - it('preserves page order and removes expired cookies without a second retained list', () => { - const first = { - domain: '.first.example.com', - name: 'first', - value: 'one', - secure: true, - httpOnly: true - } - const expired = { - domain: '.expired.example.com', - name: 'expired', - value: 'old', - expiration: 1 - } - const last = { domain: '.last.example.com', name: 'last', value: 'three' } - - const result = decodeSafariCookieStore(buildStore([[first, expired], [last]])) - const originalList = result.cookies - removeExpiredSafariCookiesInPlace(result.cookies, 2_000_000_000) - - expect(result.totalCookies).toBe(3) - expect(result.cookies).toBe(originalList) - expect(result.cookies.map((cookie) => cookie.name)).toEqual(['first', 'last']) - expect(result.cookies[0]).toMatchObject({ - url: 'https://first.example.com/', - domain: '.first.example.com', - value: 'one', - path: '/', - secure: true, - httpOnly: true, - sameSite: 'unspecified' - }) - }) - - it('accepts the exact cookie and parsed-byte boundaries', () => { - const first = { domain: '.a.example.com', name: 'a', value: 'one' } - const second = { domain: '.b.example.com', name: 'b', value: 'two' } - const maxParsedBytes = retainedBytes(first) + retainedBytes(second) - - const result = decodeSafariCookieStore( - buildStore([[first, second]]), - limits({ maxCookies: 2, maxParsedBytes }) - ) - - expect(result.cookies.map((cookie) => cookie.name)).toEqual(['a', 'b']) - }) - - it('rejects one cookie or parsed byte over the configured boundary', () => { - const first = { domain: '.a.example.com', name: 'a', value: 'one' } - const second = { domain: '.b.example.com', name: 'b', value: 'two' } - const store = buildStore([[first, second]]) - - expect(() => decodeSafariCookieStore(store, limits({ maxCookies: 1 }))).toThrow( - expect.objectContaining({ kind: 'cookies', observed: 2, limit: 1 }) - ) - expect(() => - decodeSafariCookieStore( - store, - limits({ maxParsedBytes: retainedBytes(first) + retainedBytes(second) - 1 }) - ) - ).toThrow( - expect.objectContaining({ - kind: 'parsed-bytes', - observed: retainedBytes(first) + retainedBytes(second) - }) - ) - }) - - it('counts malformed declared entries against the parse budget', () => { - const malformedPage = Buffer.alloc(20) - malformedPage.writeUInt32BE(0x00000100, 0) - malformedPage.writeUInt32LE(3, 4) - const header = Buffer.alloc(12) - header.write('cook', 0, 'utf8') - header.writeUInt32BE(1, 4) - header.writeUInt32BE(malformedPage.length, 8) - - expect(() => - decodeSafariCookieStore(Buffer.concat([header, malformedPage]), limits({ maxCookies: 2 })) - ).toThrow(SafariCookieStoreLimitError) - }) - - it('publishes generous production limits', () => { - expect({ - fileBytes: SAFARI_COOKIE_STORE_MAX_FILE_BYTES, - pages: SAFARI_COOKIE_STORE_MAX_PAGES, - cookies: SAFARI_COOKIE_STORE_MAX_COOKIES, - parsedBytes: SAFARI_COOKIE_STORE_MAX_PARSED_BYTES - }).toEqual({ - fileBytes: 64 * 1024 * 1024, - pages: 65_536, - cookies: 250_000, - parsedBytes: 64 * 1024 * 1024 - }) - }) -}) diff --git a/src/main/browser/safari-cookie-store-decoder.ts b/src/main/browser/safari-cookie-store-decoder.ts deleted file mode 100644 index b6e58fc3b49..00000000000 --- a/src/main/browser/safari-cookie-store-decoder.ts +++ /dev/null @@ -1,212 +0,0 @@ -const MAC_EPOCH_DELTA_SECONDS = 978_307_200 - -export const SAFARI_COOKIE_STORE_MAX_FILE_BYTES = 64 * 1024 * 1024 -export const SAFARI_COOKIE_STORE_MAX_PAGES = 65_536 -export const SAFARI_COOKIE_STORE_MAX_COOKIES = 250_000 -export const SAFARI_COOKIE_STORE_MAX_PARSED_BYTES = 64 * 1024 * 1024 - -export type SafariCookieStoreLimitKind = 'pages' | 'cookies' | 'parsed-bytes' - -export class SafariCookieStoreLimitError extends Error { - constructor( - readonly kind: SafariCookieStoreLimitKind, - readonly observed: number, - readonly limit: number - ) { - super(`Safari cookie store exceeds the ${kind} limit`) - this.name = 'SafariCookieStoreLimitError' - } -} - -export type SafariDecodedCookie = { - url: string - name: string - value: string - domain: string - path: string - secure: boolean - httpOnly: boolean - sameSite: 'unspecified' - expirationDate: number | undefined -} - -export type SafariCookieDecodeResult = { - cookies: SafariDecodedCookie[] - totalCookies: number -} - -export type SafariCookieDecodeLimits = { - maxPages: number - maxCookies: number - maxParsedBytes: number -} - -const DEFAULT_LIMITS: SafariCookieDecodeLimits = { - maxPages: SAFARI_COOKIE_STORE_MAX_PAGES, - maxCookies: SAFARI_COOKIE_STORE_MAX_COOKIES, - maxParsedBytes: SAFARI_COOKIE_STORE_MAX_PARSED_BYTES -} - -type DecodeState = { - readonly cookies: SafariDecodedCookie[] - readonly limits: SafariCookieDecodeLimits - decodedCookies: number - inspectedEntries: number - parsedBytes: number -} - -export function decodeSafariCookieStore( - buffer: Buffer, - limits: SafariCookieDecodeLimits = DEFAULT_LIMITS -): SafariCookieDecodeResult { - if (buffer.length < 8 || buffer.subarray(0, 4).toString('utf8') !== 'cook') { - return { cookies: [], totalCookies: 0 } - } - - const pageCount = buffer.readUInt32BE(4) - enforceLimit('pages', pageCount, limits.maxPages) - const pagesOffset = 8 + pageCount * 4 - if (pagesOffset > buffer.length) { - return { cookies: [], totalCookies: 0 } - } - - const state: DecodeState = { - cookies: [], - limits, - decodedCookies: 0, - inspectedEntries: 0, - parsedBytes: 0 - } - let pageOffset = pagesOffset - for (let pageIndex = 0; pageIndex < pageCount; pageIndex += 1) { - const pageSize = buffer.readUInt32BE(8 + pageIndex * 4) - const pageEnd = Math.min(buffer.length, pageOffset + pageSize) - decodeSafariCookiePage(buffer.subarray(pageOffset, pageEnd), state) - pageOffset += pageSize - } - return { cookies: state.cookies, totalCookies: state.decodedCookies } -} - -function decodeSafariCookiePage(page: Buffer, state: DecodeState): void { - if (page.length < 16 || page.readUInt32BE(0) !== 0x00000100) { - return - } - - const cookieCount = page.readUInt32LE(4) - state.inspectedEntries += cookieCount - enforceLimit('cookies', state.inspectedEntries, state.limits.maxCookies) - if (8 + cookieCount * 4 > page.length) { - return - } - - for (let index = 0; index < cookieCount; index += 1) { - const offset = page.readUInt32LE(8 + index * 4) - const cookie = decodeSafariCookie(page.subarray(offset)) - if (!cookie) { - continue - } - state.decodedCookies += 1 - state.parsedBytes += cookieRetainedBytes(cookie) - enforceLimit('parsed-bytes', state.parsedBytes, state.limits.maxParsedBytes) - state.cookies.push(cookie) - } -} - -export function removeExpiredSafariCookiesInPlace( - cookies: SafariDecodedCookie[], - nowSeconds: number -): void { - let retained = 0 - for (const cookie of cookies) { - if (!cookie.expirationDate || cookie.expirationDate > nowSeconds) { - cookies[retained] = cookie - retained += 1 - } - } - cookies.length = retained -} - -function decodeSafariCookie(buffer: Buffer): SafariDecodedCookie | null { - if (buffer.length < 48) { - return null - } - // Why: the declared size is external; clamp string scans to bytes present in this page. - const size = Math.min(buffer.readUInt32LE(0), buffer.length) - if (size < 48) { - return null - } - - const flags = buffer.readUInt32LE(8) - const secure = (flags & 1) !== 0 - const name = readCString(buffer, buffer.readUInt32LE(20), size) - if (!name) { - return null - } - const value = readCString(buffer, buffer.readUInt32LE(28), size) ?? '' - const path = readCString(buffer, buffer.readUInt32LE(24), size) ?? '/' - const rawUrl = readCString(buffer, buffer.readUInt32LE(16), size) ?? '' - const domain = rawUrl.startsWith('.') ? rawUrl : rawUrl || null - if (!domain) { - return null - } - - const url = deriveUrl(domain, secure) - if (!url) { - return null - } - // Why: Safari stores expiration seconds relative to 2001-01-01. - const expiration = buffer.readDoubleLE(40) - const expirationDate = - expiration > 0 ? Math.round(expiration + MAC_EPOCH_DELTA_SECONDS) : undefined - - return { - url, - name, - value, - domain, - path, - secure, - httpOnly: (flags & 4) !== 0, - sameSite: 'unspecified', - expirationDate - } -} - -function readCString(buffer: Buffer, offset: number, end: number): string | null { - if (offset < 0 || offset >= end) { - return null - } - let cursor = offset - while (cursor < end && buffer[cursor] !== 0) { - cursor += 1 - } - return cursor < end ? buffer.toString('utf8', offset, cursor) : null -} - -function deriveUrl(domain: string, secure: boolean): string | null { - const cleanDomain = domain.startsWith('.') ? domain.slice(1) : domain - if (!cleanDomain || cleanDomain.includes(' ')) { - return null - } - try { - return new URL(`${secure ? 'https' : 'http'}://${cleanDomain}/`).toString() - } catch { - return null - } -} - -function cookieRetainedBytes(cookie: SafariDecodedCookie): number { - return ( - Buffer.byteLength(cookie.url) + - Buffer.byteLength(cookie.name) + - Buffer.byteLength(cookie.value) + - Buffer.byteLength(cookie.domain) + - Buffer.byteLength(cookie.path) - ) -} - -function enforceLimit(kind: SafariCookieStoreLimitKind, observed: number, limit: number): void { - if (observed > limit) { - throw new SafariCookieStoreLimitError(kind, observed, limit) - } -} diff --git a/src/main/browser/snapshot-engine.test.ts b/src/main/browser/snapshot-engine.test.ts index c06b5d43d21..17f5341b220 100644 --- a/src/main/browser/snapshot-engine.test.ts +++ b/src/main/browser/snapshot-engine.test.ts @@ -1,11 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { - buildSnapshot, - SNAPSHOT_MAX_ENTRIES, - SNAPSHOT_MAX_NAME_CODE_UNITS, - SNAPSHOT_MAX_RETAINED_NAME_CODE_UNITS, - type CdpCommandSender -} from './snapshot-engine' +import { buildSnapshot, type CdpCommandSender } from './snapshot-engine' type AXNode = { nodeId: string @@ -199,46 +193,4 @@ describe('buildSnapshot', () => { expect(result.snapshot).toContain('[Footer]') expect(result.snapshot).toContain('heading "Dashboard"') }) - - it('bounds retained entries and page-controlled accessible names', async () => { - const childIds = Array.from({ length: SNAPSHOT_MAX_ENTRIES + 100 }, (_, index) => - String(index + 2) - ) - const nodes: AXNode[] = [node('1', 'WebArea', 'page', { childIds })] - for (const childId of childIds) { - nodes.push( - node( - childId, - 'button', - `${childId.padStart(8, '0')}-${'x'.repeat(SNAPSHOT_MAX_NAME_CODE_UNITS + 100)}` - ) - ) - } - - const result = await buildSnapshot(makeSender(nodes)) - - expect(result.refs).toHaveLength( - Math.min( - SNAPSHOT_MAX_ENTRIES, - Math.floor(SNAPSHOT_MAX_RETAINED_NAME_CODE_UNITS / SNAPSHOT_MAX_NAME_CODE_UNITS) - ) - ) - expect(result.refs[0]?.name).toHaveLength(SNAPSHOT_MAX_NAME_CODE_UNITS) - expect(result.refs.reduce((total, ref) => total + ref.name.length, 0)).toBeLessThanOrEqual( - SNAPSHOT_MAX_RETAINED_NAME_CODE_UNITS - ) - }) - - it('walks malformed cyclic trees without recursive growth', async () => { - const nodes: AXNode[] = [ - node('1', 'WebArea', 'page', { childIds: ['2'] }), - node('2', 'generic', '', { childIds: ['3'] }), - node('3', 'generic', '', { childIds: ['2', '4'] }), - node('4', 'button', 'Reachable', { backendDOMNodeId: 4 }) - ] - - const result = await buildSnapshot(makeSender(nodes)) - - expect(result.refs).toEqual([{ ref: '@e1', role: 'button', name: 'Reachable' }]) - }) }) diff --git a/src/main/browser/snapshot-engine.ts b/src/main/browser/snapshot-engine.ts index 1d6aac01a19..141a3d957dc 100644 --- a/src/main/browser/snapshot-engine.ts +++ b/src/main/browser/snapshot-engine.ts @@ -75,18 +75,6 @@ const HEADING_PATTERN = /^heading$/ const SKIP_ROLES = new Set(['none', 'presentation', 'generic']) -export const SNAPSHOT_MAX_AX_NODES = 50_000 -export const SNAPSHOT_MAX_ENTRIES = 4096 -export const SNAPSHOT_MAX_NAME_CODE_UNITS = 1024 -export const SNAPSHOT_MAX_RETAINED_NAME_CODE_UNITS = 1024 * 1024 -const SNAPSHOT_MAX_VISUAL_DEPTH = 64 - -type SnapshotWalkBudget = { - remainingNodes: number - remainingEntries: number - remainingNameCodeUnits: number -} - export async function buildSnapshot( sendCommand: CdpCommandSender, iframeSessions?: Map, @@ -96,50 +84,37 @@ export async function buildSnapshot( const { nodes } = (await sendCommand('Accessibility.getFullAXTree')) as { nodes: AXNode[] } const nodeById = new Map() - for (const node of nodes.slice(0, SNAPSHOT_MAX_AX_NODES)) { + for (const node of nodes) { nodeById.set(node.nodeId, node) } const entries: SnapshotEntry[] = [] let refCounter = 1 - const budget: SnapshotWalkBudget = { - remainingNodes: SNAPSHOT_MAX_AX_NODES, - remainingEntries: SNAPSHOT_MAX_ENTRIES, - remainingNameCodeUnits: SNAPSHOT_MAX_RETAINED_NAME_CODE_UNITS - } const root = nodes[0] if (!root) { return { snapshot: '', refs: [], refMap: new Map() } } - walkTree(root, nodeById, 0, entries, () => refCounter++, budget) + walkTree(root, nodeById, 0, entries, () => refCounter++) // Why: many modern SPAs use styled
s, s, and custom elements as // interactive controls without proper ARIA roles. These elements are invisible // to the accessibility tree walk above but are clearly interactive (cursor:pointer, // onclick, tabindex, contenteditable). This DOM query pass discovers them and // promotes them to interactive refs so the agent can interact with them. - const cursorInteractiveEntries = - budget.remainingEntries > 0 ? await findCursorInteractiveElements(sendCommand, entries) : [] + const cursorInteractiveEntries = await findCursorInteractiveElements(sendCommand, entries) for (const cie of cursorInteractiveEntries) { - const name = reserveSnapshotName(cie.name, budget) - if (name === null || !reserveSnapshotEntry(budget)) { - break - } cie.ref = `@e${refCounter++}` - entries.push({ ...cie, name }) + entries.push(cie) } // Why: cross-origin iframes have their own AX trees accessible only through // their dedicated CDP session. Append their elements after the parent tree // so the agent can see and interact with iframe content. - const iframeRefSessions = new Map() + const iframeRefSessions: { ref: string; sessionId: string }[] = [] if (iframeSessions && makeIframeSender && iframeSessions.size > 0) { for (const [_frameId, sessionId] of iframeSessions) { - if (budget.remainingEntries <= 0 || budget.remainingNodes <= 0) { - break - } try { const iframeSender = makeIframeSender(sessionId) await iframeSender('Accessibility.enable') @@ -150,15 +125,15 @@ export async function buildSnapshot( continue } const iframeNodeById = new Map() - for (const n of iframeNodes.slice(0, budget.remainingNodes)) { + for (const n of iframeNodes) { iframeNodeById.set(n.nodeId, n) } const iframeRoot = iframeNodes[0] if (iframeRoot) { const startRef = refCounter - walkTree(iframeRoot, iframeNodeById, 1, entries, () => refCounter++, budget) + walkTree(iframeRoot, iframeNodeById, 1, entries, () => refCounter++) for (let i = startRef; i < refCounter; i++) { - iframeRefSessions.set(`@e${i}`, sessionId) + iframeRefSessions.push({ ref: `@e${i}`, sessionId }) } } } catch { @@ -196,11 +171,12 @@ export async function buildSnapshot( } lines.push(`${indent}[${entry.ref}] ${entry.role} "${displayName}"`) refs.push({ ref: entry.ref, role: entry.role, name: displayName }) + const iframeSession = iframeRefSessions.find((s) => s.ref === entry.ref) refMap.set(entry.ref, { backendDOMNodeId: entry.backendDOMNodeId, role: entry.role, name: entry.name, - sessionId: iframeRefSessions.get(entry.ref), + sessionId: iframeSession?.sessionId, nth: total > 1 ? nth : undefined }) } else { @@ -212,159 +188,107 @@ export async function buildSnapshot( } function walkTree( - root: AXNode, - nodeById: Map, - depth: number, - entries: SnapshotEntry[], - nextRef: () => number, - budget: SnapshotWalkBudget -): void { - const stack: { node: AXNode; depth: number }[] = [{ node: root, depth }] - const visited = new Set() - while (stack.length > 0 && budget.remainingNodes > 0 && budget.remainingEntries > 0) { - const current = stack.pop()! - const node = current.node - if (visited.has(node.nodeId)) { - continue - } - visited.add(node.nodeId) - budget.remainingNodes -= 1 - - const role = node.role?.value ?? '' - const rawName = node.name?.value ?? '' - const isInteractive = INTERACTIVE_ROLES.has(role) - const isHeading = HEADING_PATTERN.test(role) - const isLandmark = LANDMARK_ROLES.has(role) - const isStaticText = role === 'staticText' || role === 'StaticText' - const shouldWalkChildren = - node.ignored === true || - SKIP_ROLES.has(role) || - (!isInteractive && !isHeading && !isLandmark && !isStaticText) || - (!rawName && !isLandmark) - - if (shouldWalkChildren) { - pushSnapshotChildren(stack, node, nodeById, current.depth, budget.remainingNodes) - continue - } - - if (isLandmark) { - const name = reserveSnapshotName(rawName || role, budget) - if (name !== null && reserveSnapshotEntry(budget)) { - entries.push({ - ref: '', - role: formatLandmarkRole(role, rawName ? name : ''), - name, - backendDOMNodeId: node.backendDOMNodeId ?? 0, - depth: current.depth - }) - } - pushSnapshotChildren( - stack, - node, - nodeById, - Math.min(current.depth + 1, SNAPSHOT_MAX_VISUAL_DEPTH), - budget.remainingNodes - ) - continue - } - - if (isHeading) { - appendSnapshotEntry(entries, budget, { - ref: '', - role: 'heading', - rawName, - backendDOMNodeId: node.backendDOMNodeId ?? 0, - depth: current.depth - }) - continue - } - - if (isStaticText) { - const name = trimSnapshotName(rawName) - if (name) { - appendSnapshotEntry(entries, budget, { - ref: '', - role: 'text', - rawName: name, - backendDOMNodeId: node.backendDOMNodeId ?? 0, - depth: current.depth - }) - } - continue - } - - if (isInteractive && (isFocusable(node) || node.backendDOMNodeId)) { - appendSnapshotEntry(entries, budget, { - ref: `@e${nextRef()}`, - role: formatInteractiveRole(role), - rawName, - backendDOMNodeId: node.backendDOMNodeId ?? 0, - depth: current.depth - }) - } - } -} - -function pushSnapshotChildren( - stack: { node: AXNode; depth: number }[], node: AXNode, nodeById: Map, depth: number, - maxChildren: number -): void { - const childCount = Math.min(node.childIds?.length ?? 0, maxChildren) - for (let index = childCount - 1; index >= 0; index--) { - const child = nodeById.get(node.childIds![index]) - if (child) { - stack.push({ node: child, depth }) - } - } -} - -function appendSnapshotEntry( entries: SnapshotEntry[], - budget: SnapshotWalkBudget, - entry: Omit & { rawName: string } + nextRef: () => number ): void { - const name = reserveSnapshotName(entry.rawName, budget) - if (name === null || !reserveSnapshotEntry(budget)) { + if (node.ignored) { + walkChildren(node, nodeById, depth, entries, nextRef) return } - const { rawName: _rawName, ...rest } = entry - entries.push({ ...rest, name }) + + const role = node.role?.value ?? '' + const name = node.name?.value ?? '' + + if (SKIP_ROLES.has(role)) { + walkChildren(node, nodeById, depth, entries, nextRef) + return + } + + const isInteractive = INTERACTIVE_ROLES.has(role) + const isHeading = HEADING_PATTERN.test(role) + const isLandmark = LANDMARK_ROLES.has(role) + const isStaticText = role === 'staticText' || role === 'StaticText' + + if (!isInteractive && !isHeading && !isLandmark && !isStaticText) { + walkChildren(node, nodeById, depth, entries, nextRef) + return + } + + if (!name && !isLandmark) { + walkChildren(node, nodeById, depth, entries, nextRef) + return + } + + const hasFocusable = isInteractive && isFocusable(node) + + if (isLandmark) { + entries.push({ + ref: '', + role: formatLandmarkRole(role, name), + name: name || role, + backendDOMNodeId: node.backendDOMNodeId ?? 0, + depth + }) + walkChildren(node, nodeById, depth + 1, entries, nextRef) + return + } + + if (isHeading) { + entries.push({ + ref: '', + role: 'heading', + name, + backendDOMNodeId: node.backendDOMNodeId ?? 0, + depth + }) + return + } + + if (isStaticText && name.trim().length > 0) { + entries.push({ + ref: '', + role: 'text', + name: name.trim(), + backendDOMNodeId: node.backendDOMNodeId ?? 0, + depth + }) + return + } + + if (isInteractive && (hasFocusable || node.backendDOMNodeId)) { + const ref = `@e${nextRef()}` + entries.push({ + ref, + role: formatInteractiveRole(role), + name: name || '(unlabeled)', + backendDOMNodeId: node.backendDOMNodeId ?? 0, + depth + }) + return + } + + walkChildren(node, nodeById, depth, entries, nextRef) } -function reserveSnapshotEntry(budget: SnapshotWalkBudget): boolean { - if (budget.remainingEntries <= 0) { - return false +function walkChildren( + node: AXNode, + nodeById: Map, + depth: number, + entries: SnapshotEntry[], + nextRef: () => number +): void { + if (!node.childIds) { + return } - budget.remainingEntries -= 1 - return true -} - -function reserveSnapshotName(rawName: string, budget: SnapshotWalkBudget): string | null { - const maxLength = Math.min(SNAPSHOT_MAX_NAME_CODE_UNITS, budget.remainingNameCodeUnits) - if (maxLength <= 0) { - return null + for (const childId of node.childIds) { + const child = nodeById.get(childId) + if (child) { + walkTree(child, nodeById, depth, entries, nextRef) + } } - const name = rawName.slice(0, maxLength) - budget.remainingNameCodeUnits -= name.length - return name -} - -function trimSnapshotName(rawName: string): string { - let start = 0 - while (start < rawName.length && /\s/.test(rawName[start])) { - start += 1 - } - if (start === rawName.length) { - return '' - } - let end = rawName.length - while (end > start && /\s/.test(rawName[end - 1])) { - end -= 1 - } - return rawName.slice(start, Math.min(end, start + SNAPSHOT_MAX_NAME_CODE_UNITS)) } function isFocusable(node: AXNode): boolean { diff --git a/src/main/cache-identity-digest.ts b/src/main/cache-identity-digest.ts deleted file mode 100644 index fbcbad42abe..00000000000 --- a/src/main/cache-identity-digest.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { createHash } from 'node:crypto' - -/** Fixed-size identity for long-lived maps whose source fields can be arbitrarily large. */ -export function cacheIdentityDigest(parts: readonly string[]): string { - const digest = createHash('sha256') - for (const part of parts) { - digest.update(`${part.length}:`) - digest.update(part) - } - return digest.digest('base64url') -} diff --git a/src/main/claude-accounts/managed-auth-path.ts b/src/main/claude-accounts/managed-auth-path.ts index ff4303a083b..2914b72e033 100644 --- a/src/main/claude-accounts/managed-auth-path.ts +++ b/src/main/claude-accounts/managed-auth-path.ts @@ -1,9 +1,7 @@ -import { existsSync, lstatSync, realpathSync, writeFileSync } from 'node:fs' +import { existsSync, lstatSync, readFileSync, realpathSync, writeFileSync } from 'node:fs' import { join, relative, resolve, sep } from 'node:path' import { app } from 'electron' -import { readAgentStateFileSync } from '../agent-state-file-reader' import { writeFileAtomically } from '../codex-accounts/fs-utils' -import { NodeFileReadTooLargeError } from '../../shared/node-bounded-file-reader' const MANAGED_AUTH_MARKER = '.orca-managed-claude-auth' @@ -67,11 +65,8 @@ export function readClaudeManagedAuthFile( if (!isOwnedChildFile(managedAuthPath, filePath)) { return null } - return readAgentStateFileSync(filePath) - } catch (error) { - if (error instanceof NodeFileReadTooLargeError) { - throw error - } + return readFileSync(filePath, 'utf-8') + } catch { return null } } @@ -85,9 +80,6 @@ export function writeClaudeManagedAuthFile( if (existsSync(filePath) && !isOwnedChildFile(managedAuthPath, filePath)) { throw new Error('Managed Claude auth child file is not owned by Orca.') } - if (existsSync(filePath)) { - readAgentStateFileSync(filePath) - } writeFileAtomically(filePath, contents, { mode: 0o600 }) } @@ -100,7 +92,7 @@ function isManagedAuthMarkerValid(markerPath: string, accountId: string): boolea ) { return false } - return readAgentStateFileSync(markerPath).trim() === accountId + return readFileSync(markerPath, 'utf-8').trim() === accountId } catch { return false } diff --git a/src/main/claude-accounts/oauth-refresh.ts b/src/main/claude-accounts/oauth-refresh.ts index a69c12ad04a..cc717ef51ee 100644 --- a/src/main/claude-accounts/oauth-refresh.ts +++ b/src/main/claude-accounts/oauth-refresh.ts @@ -1,6 +1,5 @@ import { net, session } from 'electron' import { ensureElectronProxyFromEnvironment } from '../network/proxy-settings' -import { readFetchResponseJsonWithinLimit } from '../lib/fetch-response-body' // Why: the OAuth client id and token endpoint are the public Claude Code // values, verified against the installed `claude` binary (2.1.177) and the @@ -159,7 +158,7 @@ export async function refreshClaudeOauthCredentials( console.warn(`[claude-oauth-refresh] token endpoint returned ${res.status}`) return null } - const data = await readFetchResponseJsonWithinLimit(res) + const data = (await res.json()) as TokenEndpointResponse return applyRefreshedToken(credentialsJson, data, now) } catch (error) { console.warn( diff --git a/src/main/claude-accounts/runtime-auth-service.ts b/src/main/claude-accounts/runtime-auth-service.ts index 3ef0ec4b64c..58328222528 100644 --- a/src/main/claude-accounts/runtime-auth-service.ts +++ b/src/main/claude-accounts/runtime-auth-service.ts @@ -1,11 +1,10 @@ /* eslint-disable max-lines -- Why: keeps file/Keychain/snapshot/env-patch auth semantics together so PTY launch and quota-fetch paths can't drift. */ import { execFileSync } from 'node:child_process' -import { chmodSync, existsSync, mkdirSync, rmSync } from 'node:fs' +import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs' import { dirname, join } from 'node:path' import { app } from 'electron' import type { ClaudeManagedAccount } from '../../shared/types' import type { Store } from '../persistence' -import { readAgentStateFileSync, readAgentStateJsonFileSync } from '../agent-state-file-reader' import { writeFileAtomically } from '../codex-accounts/fs-utils' import type { ClaudeEnvPatch } from './environment' import { @@ -36,7 +35,6 @@ import { setSelectedClaudeAccountIdForTarget, type ClaudeAccountSelectionTarget } from './runtime-selection' -import { NodeFileReadTooLargeError } from '../../shared/node-bounded-file-reader' export type ClaudeRuntimeAuthPreparation = { configDir: string @@ -348,7 +346,7 @@ export class ClaudeRuntimeAuthService { if (this.lastSyncedAccountId === null) { const paths = this.pathResolver.getRuntimePaths() const runtimeCredentialsJson = existsSync(paths.credentialsPath) - ? readAgentStateFileSync(paths.credentialsPath) + ? readFileSync(paths.credentialsPath, 'utf-8') : null await this.captureSystemDefaultSnapshotForManagedEntry( runtimeCredentialsJson, @@ -571,7 +569,7 @@ export class ClaudeRuntimeAuthService { ): Promise { const paths = this.pathResolver.getRuntimePaths() const fileCredentials = existsSync(paths.credentialsPath) - ? readAgentStateFileSync(paths.credentialsPath) + ? readFileSync(paths.credentialsPath, 'utf-8') : null const runtimeOauthAccount = this.readRuntimeOauthAccount() const candidates: ClaudeRuntimeCredentialCandidate[] = [] @@ -1164,7 +1162,7 @@ export class ClaudeRuntimeAuthService { options.credentialsJsonOverride !== undefined ? options.credentialsJsonOverride : existsSync(paths.credentialsPath) - ? readAgentStateFileSync(paths.credentialsPath) + ? readFileSync(paths.credentialsPath, 'utf-8') : null const keychainCredentialsJson = await this.readAggregateClaudeKeychainCredentialsBestEffort( paths.configDir @@ -1295,7 +1293,7 @@ export class ClaudeRuntimeAuthService { return null } try { - const parsed = readAgentStateJsonFileSync(snapshotPath) + const parsed = JSON.parse(readFileSync(snapshotPath, 'utf-8')) as unknown if (this.isSystemDefaultSnapshot(parsed)) { return parsed } @@ -1413,7 +1411,7 @@ export class ClaudeRuntimeAuthService { private readRuntimeCredentialsFile(): string | null { const credentialsPath = this.pathResolver.getRuntimePaths().credentialsPath - return existsSync(credentialsPath) ? readAgentStateFileSync(credentialsPath) : null + return existsSync(credentialsPath) ? readFileSync(credentialsPath, 'utf-8') : null } private runtimeCredentialsBelongToAccount( @@ -1454,7 +1452,7 @@ export class ClaudeRuntimeAuthService { } const paths = this.pathResolver.getRuntimePaths() const currentCredentialsJson = existsSync(paths.credentialsPath) - ? readAgentStateFileSync(paths.credentialsPath) + ? readFileSync(paths.credentialsPath, 'utf-8') : null return currentCredentialsJson === previouslyWrittenCredentialsJson } @@ -1463,7 +1461,7 @@ export class ClaudeRuntimeAuthService { const paths = this.pathResolver.getRuntimePaths() try { const currentCredentialsJson = existsSync(paths.credentialsPath) - ? readAgentStateFileSync(paths.credentialsPath) + ? readFileSync(paths.credentialsPath, 'utf-8') : null return ( currentCredentialsJson !== null && @@ -1553,7 +1551,7 @@ export class ClaudeRuntimeAuthService { return null } try { - const parsed = readAgentStateJsonFileSync(configPath) + const parsed = JSON.parse(readFileSync(configPath, 'utf-8')) as unknown const record = this.asRecord(parsed) if (!record) { return RUNTIME_OAUTH_ACCOUNT_PARSE_ERROR @@ -1763,11 +1761,8 @@ export class ClaudeRuntimeAuthService { private fileContentsEqual(targetPath: string, contents: string): boolean { try { - return existsSync(targetPath) && readAgentStateFileSync(targetPath) === contents - } catch (error) { - if (error instanceof NodeFileReadTooLargeError) { - throw error - } + return existsSync(targetPath) && readFileSync(targetPath, 'utf-8') === contents + } catch { return false } } @@ -1788,7 +1783,7 @@ export class ClaudeRuntimeAuthService { return {} } try { - const parsed = readAgentStateJsonFileSync(targetPath) + const parsed = JSON.parse(readFileSync(targetPath, 'utf-8')) as unknown if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { return parsed as Record } diff --git a/src/main/claude-accounts/service.ts b/src/main/claude-accounts/service.ts index 1b0485997bf..5e68b20d615 100644 --- a/src/main/claude-accounts/service.ts +++ b/src/main/claude-accounts/service.ts @@ -2,7 +2,7 @@ for login, credential capture, Keychain storage, selection, and rate-limit refresh. */ import { randomUUID } from 'node:crypto' import { execFileSync, spawn } from 'node:child_process' -import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join, relative, resolve, sep } from 'node:path' import type { @@ -12,7 +12,6 @@ import type { } from '../../shared/types' import type { Store } from '../persistence' import type { RateLimitService } from '../rate-limits/service' -import { readAgentStateFileSync, readAgentStateJsonFileSync } from '../agent-state-file-reader' import { resolveClaudeCommand } from '../codex-cli/command' import type { ClaudeRuntimeAuthService } from './runtime-auth-service' import { @@ -614,7 +613,7 @@ export class ClaudeAccountService { } } const credentialsPath = join(configDir, '.credentials.json') - return existsSync(credentialsPath) ? readAgentStateFileSync(credentialsPath) : null + return existsSync(credentialsPath) ? readFileSync(credentialsPath, 'utf-8') : null } private readOauthAccountFromConfigDir(configDir: string): unknown { @@ -623,7 +622,7 @@ export class ClaudeAccountService { continue } try { - const parsed = readAgentStateJsonFileSync(configPath) as Record + const parsed = JSON.parse(readFileSync(configPath, 'utf-8')) as Record if (parsed.oauthAccount) { return parsed.oauthAccount } diff --git a/src/main/claude-usage/scanner-large-directory.test.ts b/src/main/claude-usage/scanner-large-directory.test.ts index 8262a54da83..78d3895c58b 100644 --- a/src/main/claude-usage/scanner-large-directory.test.ts +++ b/src/main/claude-usage/scanner-large-directory.test.ts @@ -1,12 +1,12 @@ import { describe, expect, it, vi } from 'vitest' -import type { Dir, Dirent } from 'node:fs' +import type { Dirent } from 'node:fs' import type * as FsPromises from 'node:fs/promises' import type * as NodeOs from 'node:os' import { join } from 'node:path' -const { homedirMock, opendirMock } = vi.hoisted(() => ({ +const { homedirMock, readdirMock } = vi.hoisted(() => ({ homedirMock: vi.fn<() => string>(), - opendirMock: vi.fn<(dirPath: string) => Promise>() + readdirMock: vi.fn<(dirPath: string) => Promise>() })) vi.mock('os', async () => { @@ -21,7 +21,7 @@ vi.mock('fs/promises', async () => { const actual = await vi.importActual('fs/promises') return { ...actual, - opendir: opendirMock + readdir: readdirMock } }) @@ -43,84 +43,24 @@ const largeTranscriptEntries = Array.from({ length: FILE_COUNT }, (_, index) => dirent(`session-${index}.jsonl`, 'file') ) -function directory(entries: Dirent[]): Dir { - return { - async *[Symbol.asyncIterator]() { - yield* entries - } - } as Dir -} - -function generatedFileDirectory(count: number): Dir { - return { - async *[Symbol.asyncIterator]() { - for (let index = 0; index < count; index++) { - yield dirent(`session-${index}.jsonl`, 'file') - } - } - } as Dir -} - describe('listClaudeTranscriptFiles large directories', () => { it('keeps nested transcript scans past the JavaScript spread-argument limit', async () => { homedirMock.mockReturnValue(FAKE_HOME) - opendirMock.mockImplementation(async (dirPath) => { + readdirMock.mockImplementation(async (dirPath) => { if (dirPath === PROJECTS_ROOT) { - return directory([dirent('large-project', 'directory')]) + return [dirent('large-project', 'directory')] } if (dirPath === PROJECT_DIR) { - return directory(largeTranscriptEntries) + return largeTranscriptEntries } if (dirPath === TRANSCRIPTS_ROOT) { - return directory([]) + return [] } - throw new Error(`Unexpected opendir path: ${dirPath}`) + throw new Error(`Unexpected readdir path: ${dirPath}`) }) const { listClaudeTranscriptFiles } = await import('./scanner') await expect(listClaudeTranscriptFiles()).resolves.toHaveLength(FILE_COUNT) }) - - it('fails closed instead of silently omitting a transcript past capacity', async () => { - opendirMock.mockImplementation(async (dirPath) => { - if (dirPath === PROJECTS_ROOT) { - return generatedFileDirectory(200_001) - } - if (dirPath === TRANSCRIPTS_ROOT) { - return directory([]) - } - throw new Error(`Unexpected opendir path: ${dirPath}`) - }) - - const { listClaudeTranscriptFiles } = await import('./scanner') - - await expect(listClaudeTranscriptFiles()).rejects.toMatchObject({ - name: 'UsageHistoryScanCapacityError', - resource: 'files', - limit: 200_000 - }) - }) - - it('keeps stable transcripts when a nested directory vanishes during discovery', async () => { - const vanishedDirectory = join(PROJECTS_ROOT, 'vanished') - opendirMock.mockImplementation(async (dirPath) => { - if (dirPath === PROJECTS_ROOT) { - return directory([dirent('stable.jsonl', 'file'), dirent('vanished', 'directory')]) - } - if (dirPath === vanishedDirectory) { - throw Object.assign(new Error('directory vanished'), { code: 'ENOENT' }) - } - if (dirPath === TRANSCRIPTS_ROOT) { - return directory([]) - } - throw new Error(`Unexpected opendir path: ${dirPath}`) - }) - - const { listClaudeTranscriptFiles } = await import('./scanner') - - await expect(listClaudeTranscriptFiles()).resolves.toEqual([ - join(PROJECTS_ROOT, 'stable.jsonl') - ]) - }) }) diff --git a/src/main/claude-usage/scanner.test.ts b/src/main/claude-usage/scanner.test.ts index c04879d1684..f7de6cef141 100644 --- a/src/main/claude-usage/scanner.test.ts +++ b/src/main/claude-usage/scanner.test.ts @@ -8,7 +8,6 @@ import { import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' -import { UsageHistoryScanBudget } from '../usage-history-scan-budget' describe('parseClaudeUsageRecord', () => { it('extracts token usage from assistant transcript lines', () => { @@ -103,31 +102,6 @@ describe('parseClaudeUsageRecord', () => { await rm(root, { recursive: true, force: true }) } }) - - it('fails the whole parse when retained usage records exceed capacity', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-claude-capacity-')) - const filePath = join(root, 'session.jsonl') - const record = (uuid: string) => - JSON.stringify({ - type: 'assistant', - sessionId: 'session-1', - uuid, - timestamp: '2026-04-09T10:00:00.000Z', - message: { usage: { input_tokens: 1 } } - }) - try { - await writeFile(filePath, [record('turn-1'), record('turn-2')].join('\n')) - const budget = new UsageHistoryScanBudget({ records: 1 }) - - await expect(parseClaudeUsageFile(filePath, budget)).rejects.toMatchObject({ - name: 'UsageHistoryScanCapacityError', - resource: 'records', - limit: 1 - }) - } finally { - await rm(root, { recursive: true, force: true }) - } - }) }) describe('Claude usage aggregation', () => { diff --git a/src/main/claude-usage/scanner.ts b/src/main/claude-usage/scanner.ts index f797def80ef..db1c1f21f7c 100644 --- a/src/main/claude-usage/scanner.ts +++ b/src/main/claude-usage/scanner.ts @@ -1,16 +1,10 @@ /* eslint-disable max-lines -- Why: transcript discovery, parsing, attribution, and aggregation share one data shape pipeline. Keeping them co-located makes it easier to audit correctness when Claude usage numbers look surprising. */ import { homedir } from 'node:os' import { join, basename } from 'node:path' -import { realpath, stat } from 'node:fs/promises' +import { realpath, readdir, stat } from 'node:fs/promises' +import { createReadStream } from 'node:fs' +import { createInterface } from 'node:readline' import type { Repo } from '../../shared/types' -import { walkUsageHistoryJsonlFiles } from '../usage-history-file-discovery' -import { readUsageHistoryJsonlLines } from '../usage-history-jsonl-reader' -import { - MAX_USAGE_HISTORY_FILES, - UsageHistoryScanBudget, - UsageHistoryScanCapacityError, - getUsageHistoryRetainedBytes -} from '../usage-history-scan-budget' import type { ClaudeUsageAttributedTurn, ClaudeUsageDailyAggregate, @@ -134,29 +128,54 @@ async function yieldToEventLoop(): Promise { await new Promise((resolve) => setTimeout(resolve, 0)) } -export async function listClaudeTranscriptFiles( - budget = new UsageHistoryScanBudget() -): Promise { - const roots = [CLAUDE_PROJECTS_DIR, CLAUDE_TRANSCRIPTS_DIR] +async function walkJsonlFiles(dirPath: string): Promise { + const entries = await readdir(dirPath, { withFileTypes: true }) const files: string[] = [] - for (const root of roots) { - try { - for (const filePath of await walkUsageHistoryJsonlFiles(root, budget)) { - files.push(filePath) - } - } catch (error) { - if (error instanceof UsageHistoryScanCapacityError) { - throw error - } + + for (const entry of entries) { + const fullPath = join(dirPath, entry.name) + if (entry.isDirectory()) { + appendDiscoveredFiles(files, await walkJsonlFiles(fullPath)) + continue + } + if (entry.isFile() && entry.name.endsWith('.jsonl')) { + files.push(fullPath) } } - return [...new Set(files)].sort() + + return files +} + +function appendDiscoveredFiles(target: string[], source: readonly string[]): void { + // Why: long-lived transcript directories can exceed V8's argument limit if + // child file arrays are spread into push(). + for (const filePath of source) { + target.push(filePath) + } +} + +export async function listClaudeTranscriptFiles(): Promise { + const roots = [CLAUDE_PROJECTS_DIR, CLAUDE_TRANSCRIPTS_DIR] + const files = await Promise.all( + roots.map(async (root) => { + try { + return await walkJsonlFiles(root) + } catch { + return [] + } + }) + ) + return [...new Set(files.flat())].sort() } export async function getProcessedFileInfo(filePath: string): Promise { const fileStat = await stat(filePath) let lineCount = 0 - for await (const _line of readUsageHistoryJsonlLines(filePath)) { + const lines = createInterface({ + input: createReadStream(filePath, { encoding: 'utf-8' }), + crlfDelay: Infinity + }) + for await (const _line of lines) { lineCount++ } return { @@ -289,17 +308,17 @@ export function parseClaudeUsageRecord(line: string): ClaudeUsageParsedTurn | nu return parsed ? stripClaudeSourceMetadata(parsed) : null } -export async function parseClaudeUsageFile( - filePath: string, - budget = new UsageHistoryScanBudget() -): Promise { +export async function parseClaudeUsageFile(filePath: string): Promise { const turns: ClaudeUsageParsedSourceTurn[] = [] const fallbackSessionId = basename(filePath, '.jsonl') + const lines = createInterface({ + input: createReadStream(filePath, { encoding: 'utf-8' }), + crlfDelay: Infinity + }) - for await (const line of readUsageHistoryJsonlLines(filePath)) { + for await (const line of lines) { const parsed = parseClaudeUsageSourceRecord(line, fallbackSessionId) if (parsed) { - claimClaudeTurn(budget, parsed) turns.push(parsed) } } @@ -307,10 +326,7 @@ export async function parseClaudeUsageFile( return dedupeClaudeUsageTurns(turns).map(stripClaudeSourceMetadata) } -async function readClaudeUsageScanFile( - filePath: string, - budget: UsageHistoryScanBudget -): Promise<{ +async function readClaudeUsageScanFile(filePath: string): Promise<{ processedFile: ClaudeUsageProcessedFile turns: ClaudeUsageParsedSourceTurn[] }> { @@ -318,12 +334,15 @@ async function readClaudeUsageScanFile( let lineCount = 0 const turns: ClaudeUsageParsedSourceTurn[] = [] const fallbackSessionId = basename(filePath, '.jsonl') + const lines = createInterface({ + input: createReadStream(filePath, { encoding: 'utf-8' }), + crlfDelay: Infinity + }) - for await (const line of readUsageHistoryJsonlLines(filePath)) { + for await (const line of lines) { lineCount++ const parsed = parseClaudeUsageSourceRecord(line, fallbackSessionId) if (parsed) { - claimClaudeTurn(budget, parsed) turns.push(parsed) } } @@ -339,19 +358,6 @@ async function readClaudeUsageScanFile( } } -function claimClaudeTurn(budget: UsageHistoryScanBudget, turn: ClaudeUsageParsedSourceTurn): void { - budget.claimRecord( - getUsageHistoryRetainedBytes([ - turn.sessionId, - turn.timestamp, - turn.model, - turn.cwd, - turn.gitBranch, - turn.dedupeKey - ]) - ) -} - function localDayFromTimestamp(timestamp: string): string | null { const parsed = new Date(timestamp) if (Number.isNaN(parsed.getTime())) { @@ -486,49 +492,6 @@ function mergeClaudeDailyAggregates( } } -function claimClaudeUsageProjection( - budget: UsageHistoryScanBudget, - sessions: readonly ClaudeUsageSession[], - dailyAggregates: readonly ClaudeUsageDailyAggregate[] -): void { - for (const session of sessions) { - budget.claimProjection( - getUsageHistoryRetainedBytes([ - session.sessionId, - session.firstTimestamp, - session.lastTimestamp, - session.model, - session.lastCwd, - session.lastGitBranch, - session.primaryWorktreeId, - session.primaryRepoId - ]) - ) - for (const location of session.locationBreakdown) { - budget.claimProjection( - getUsageHistoryRetainedBytes([ - location.locationKey, - location.projectLabel, - location.repoId, - location.worktreeId - ]) - ) - } - } - for (const daily of dailyAggregates) { - budget.claimProjection( - getUsageHistoryRetainedBytes([ - daily.day, - daily.model, - daily.projectKey, - daily.projectLabel, - daily.repoId, - daily.worktreeId - ]) - ) - } -} - function finalizeClaudeSessions( sessionsById: Map ): ClaudeUsageSession[] { @@ -663,14 +626,7 @@ export async function scanClaudeUsageFiles( sessions: ClaudeUsageSession[] dailyAggregates: ClaudeUsageDailyAggregate[] }> { - if (previousProcessedFiles.length > MAX_USAGE_HISTORY_FILES) { - throw new UsageHistoryScanCapacityError('files', MAX_USAGE_HISTORY_FILES) - } - const budget = new UsageHistoryScanBudget() - const files = await listClaudeTranscriptFiles(budget) - for (const previous of previousProcessedFiles) { - budget.claimPath(previous.path) - } + const files = await listClaudeTranscriptFiles() const previousByPath = new Map(previousProcessedFiles.map((file) => [file.path, file])) const worktreeLookup = await buildWorktreeLookup(worktrees) @@ -729,12 +685,7 @@ export async function scanClaudeUsageFiles( // turn for exactly one file; cached files keep the claims they persisted. const turnOwnerByDedupeKey = new Map() for (const [filePath, previous] of reusedByPath) { - for (const session of previous.sessions) { - budget.claimRecords(session.turnCount) - } - claimClaudeUsageProjection(budget, previous.sessions, previous.dailyAggregates) for (const dedupeKey of previous.ownedDedupeKeys) { - budget.claimOwnershipKey(dedupeKey) // First cached claim wins so conflicting projections stay deterministic. if (!turnOwnerByDedupeKey.has(dedupeKey)) { turnOwnerByDedupeKey.set(dedupeKey, filePath) @@ -747,9 +698,7 @@ export async function scanClaudeUsageFiles( const batch = pathsToParse.slice(index, index + FILE_SCAN_BATCH_SIZE) // Why: transcript scans run in Electron's main process. Small parallel // batches cut independent file I/O without letting Settings stay blocked. - const reads = await Promise.all( - batch.map((filePath) => readClaudeUsageScanFile(filePath, budget)) - ) + const reads = await Promise.all(batch.map((filePath) => readClaudeUsageScanFile(filePath))) for (const [batchIndex, filePath] of batch.entries()) { const { processedFile, turns } = reads[batchIndex] // Why: ownership claims must be sequential in sorted-path order so @@ -764,20 +713,15 @@ export async function scanClaudeUsageFiles( hasDeferredClaims = true continue } - if (owner === undefined) { - budget.claimOwnershipKey(turn.dedupeKey) - } turnOwnerByDedupeKey.set(turn.dedupeKey, filePath) ownedDedupeKeys.push(turn.dedupeKey) } ownedTurns.push(stripClaudeSourceMetadata(turn)) } const attributed = await attributeClaudeUsageTurns(ownedTurns, worktreeLookup) - const aggregates = aggregateClaudeUsage(attributed) - claimClaudeUsageProjection(budget, aggregates.sessions, aggregates.dailyAggregates) parsedByPath.set(filePath, { ...processedFile, - ...aggregates, + ...aggregateClaudeUsage(attributed), ownedDedupeKeys, hasDeferredClaims }) @@ -800,17 +744,14 @@ export async function scanClaudeUsageFiles( mergeClaudeDailyAggregates(dailyByKey, processed.dailyAggregates) } - const sessions = finalizeClaudeSessions(sessionsById) - const dailyAggregates = [...dailyByKey.values()].sort((left, right) => - left.day === right.day - ? left.projectLabel.localeCompare(right.projectLabel) - : left.day.localeCompare(right.day) - ) - claimClaudeUsageProjection(budget, sessions, dailyAggregates) return { processedFiles, - sessions, - dailyAggregates + sessions: finalizeClaudeSessions(sessionsById), + dailyAggregates: [...dailyByKey.values()].sort((left, right) => + left.day === right.day + ? left.projectLabel.localeCompare(right.projectLabel) + : left.day.localeCompare(right.day) + ) } } diff --git a/src/main/claude-usage/store.ts b/src/main/claude-usage/store.ts index bea648c6a5e..a020186ee90 100644 --- a/src/main/claude-usage/store.ts +++ b/src/main/claude-usage/store.ts @@ -1,6 +1,7 @@ /* eslint-disable max-lines -- Why: this store is the single main-process owner for Claude usage persistence, scan gating, and query semantics. Keeping those policy decisions together avoids split-brain range/scope logic across multiple files. */ import { app } from 'electron' -import { join } from 'node:path' +import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' import type { ClaudeUsageBreakdownKind, ClaudeUsageBreakdownRow, @@ -14,10 +15,6 @@ import type { } from '../../shared/claude-usage-types' import type { AutomationRunUsage } from '../../shared/automations-types' import type { Store } from '../persistence' -import { - readUsageProjectionStateFile, - writeUsageProjectionStateFileWithRecovery -} from '../usage-projection-state-file' import { loadKnownUsageWorktreesByRepo, type UsageWorktreeRef } from '../usage-worktree-metadata' import type { ClaudeUsagePersistedState } from './types' import { createWorktreeRefs, getSessionProjectLabel, scanClaudeUsageFiles } from './scanner' @@ -329,11 +326,10 @@ export class ClaudeUsageStore { private load(): ClaudeUsagePersistedState { try { const usageFile = getClaudeUsageFile() - const raw = readUsageProjectionStateFile(usageFile) - if (raw === null) { + if (!existsSync(usageFile)) { return getDefaultState() } - const parsed = JSON.parse(raw) as ClaudeUsagePersistedState + const parsed = JSON.parse(readFileSync(usageFile, 'utf-8')) as ClaudeUsagePersistedState if (parsed.schemaVersion !== SCHEMA_VERSION) { // Why: scanner semantics affect persisted totals, so old Claude caches // must be rebuilt after parser/source changes instead of reused briefly. @@ -367,12 +363,16 @@ export class ClaudeUsageStore { private writeToDisk(): void { const usageFile = getClaudeUsageFile() - this.state = writeUsageProjectionStateFileWithRecovery(usageFile, this.state, (error) => { - const reset = getDefaultState() - reset.scanState.enabled = this.state.scanState.enabled - reset.scanState.lastScanError = error.message - return reset - }) + const dir = dirname(usageFile) + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }) + } + // Why: scans can refresh while the app is in active use. Use the same + // atomic temp-file pattern as the main store so a crash or concurrent write + // cannot leave a truncated analytics file as the common failure mode. + const tmpFile = `${usageFile}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp` + writeFileSync(tmpFile, JSON.stringify(this.state, null, 2), 'utf-8') + renameSync(tmpFile, usageFile) } async setEnabled(enabled: boolean): Promise { diff --git a/src/main/cli/cli-installer.test.ts b/src/main/cli/cli-installer.test.ts index 523d637ab9e..9c17fd21116 100644 --- a/src/main/cli/cli-installer.test.ts +++ b/src/main/cli/cli-installer.test.ts @@ -7,7 +7,6 @@ import { readFile, readlink, symlink, - truncate, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -28,7 +27,7 @@ vi.mock('node:child_process', () => ({ execFile: execFileMock })) -import { CLI_LAUNCHER_INSPECTION_MAX_BYTES, CliInstaller } from './cli-installer' +import { CliInstaller } from './cli-installer' import { buildAppImageCliWrapper } from './appimage-cli-wrapper' import { WindowsUserPathRegistryReader, @@ -707,48 +706,6 @@ describe('CliInstaller', () => { } ) - it.skipIf(process.platform === 'win32')( - 'preserves an oversized regular launcher even when its prefix resembles an Orca launcher', - async () => { - const fixture = await makeFixture() - const commandDir = join(fixture.root, 'bin') - const installPath = join(commandDir, 'orca') - const resourcesPath = await createPackagedMacLauncher(fixture.root) - await mkdir(commandDir, { recursive: true }) - await writeFile( - installPath, - [ - '#!/usr/bin/env bash', - 'ELECTRON_RUN_AS_NODE=1', - 'ORCA_NODE_OPTIONS=x', - 'NODE_REPL_EXTERNAL_MODULE=x', - `CLI='${join(fixture.root, 'out', 'cli', 'index.js')}'`, - '' - ].join('\n'), - 'utf8' - ) - await truncate(installPath, CLI_LAUNCHER_INSPECTION_MAX_BYTES + 1) - - const installer = new CliInstaller({ - platform: 'darwin', - isPackaged: true, - resourcesPath, - commandPathOverride: installPath, - processPathEnv: commandDir - }) - - await expect(installer.getStatus()).resolves.toMatchObject({ - state: 'conflict', - currentTarget: null - }) - await expect(installer.install()).rejects.toThrow('Refusing to replace non-Orca command') - await expect(lstat(installPath)).resolves.toHaveProperty( - 'size', - CLI_LAUNCHER_INSPECTION_MAX_BYTES + 1 - ) - } - ) - // Why: a dev build can temporarily own the public command on developer // machines; packaged Orca should treat that as stale, not a hard conflict. it.skipIf(process.platform === 'win32')( diff --git a/src/main/cli/cli-installer.ts b/src/main/cli/cli-installer.ts index 4e1e81c3001..18d2fcc8f18 100644 --- a/src/main/cli/cli-installer.ts +++ b/src/main/cli/cli-installer.ts @@ -2,16 +2,21 @@ import { app } from 'electron' import { execFile } from 'node:child_process' import { constants, existsSync } from 'node:fs' -import { access, lstat, mkdir, readlink, stat, symlink, unlink, writeFile } from 'node:fs/promises' +import { + access, + lstat, + mkdir, + readFile, + readlink, + stat, + symlink, + unlink, + writeFile +} from 'node:fs/promises' import { homedir } from 'node:os' import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path' import { promisify } from 'node:util' import type { CliInstallMethod, CliInstallStatus } from '../../shared/cli-install-types' -import { nodeFileContentsEqual } from '../../shared/node-file-content-equality' -import { - NodeFileReadTooLargeError, - readNodeFileWithinLimit -} from '../../shared/node-bounded-file-reader' import { buildAppImageCliWrapper } from './appimage-cli-wrapper' import { invalidateWindowsUserPathRegistryCache, @@ -27,7 +32,6 @@ const LINUX_COMMAND_NAME = 'orca-ide' const LEGACY_LINUX_COMMAND_NAME = 'orca' const DEV_LAUNCHER_DIR = ['cli', 'bin'] const WINDOWS_PATH_WRITE_TIMEOUT_MS = 5_000 -export const CLI_LAUNCHER_INSPECTION_MAX_BYTES = 64 * 1024 type CliInstallerOptions = { platform?: NodeJS.Platform @@ -496,18 +500,19 @@ export class CliInstaller { }) } + const currentContent = await readFile(commandPath, 'utf8') const expectedContent = buildAppImageCliWrapper(appImagePath) - const matches = await nodeFileContentsEqual(commandPath, expectedContent) return this.buildStatus({ commandPath, launcherPath: appImagePath, installMethod: 'wrapper', supported: true, - state: matches ? 'installed' : 'stale', + state: currentContent === expectedContent ? 'installed' : 'stale', currentTarget: appImagePath, - detail: matches - ? `Registered at ${commandPath}.` - : `${commandPath} points to a different launcher.` + detail: + currentContent === expectedContent + ? `Registered at ${commandPath}.` + : `${commandPath} points to a different launcher.` }) } catch (error) { if (isMissingError(error)) { @@ -533,7 +538,8 @@ export class CliInstaller { const stats = await lstat(commandPath) if (!stats.isSymbolicLink()) { if (stats.isFile()) { - const managedTarget = await readManagedUnixLauncherTarget(commandPath) + const currentContent = await readFile(commandPath, 'utf8') + const managedTarget = extractManagedUnixLauncherTarget(currentContent) if (managedTarget) { return this.buildStatus({ commandPath, @@ -688,18 +694,19 @@ export class CliInstaller { }) } + const currentContent = await readFile(commandPath, 'utf8') const expectedContent = buildWindowsForwarder(launcherPath) - const matches = await nodeFileContentsEqual(commandPath, expectedContent) return this.buildStatus({ commandPath, launcherPath, installMethod: 'wrapper', supported: true, - state: matches ? 'installed' : 'stale', + state: currentContent === expectedContent ? 'installed' : 'stale', currentTarget: launcherPath, - detail: matches - ? `Registered at ${commandPath}.` - : `${commandPath} points to a different launcher.` + detail: + currentContent === expectedContent + ? `Registered at ${commandPath}.` + : `${commandPath} points to a different launcher.` }) } catch (error) { if (isMissingError(error)) { @@ -1006,20 +1013,6 @@ function extractManagedUnixLauncherTarget(content: string): string | null { : null } -async function readManagedUnixLauncherTarget(filePath: string): Promise { - try { - const contents = ( - await readNodeFileWithinLimit(filePath, CLI_LAUNCHER_INSPECTION_MAX_BYTES) - ).buffer.toString('utf8') - return extractManagedUnixLauncherTarget(contents) - } catch (error) { - if (error instanceof NodeFileReadTooLargeError) { - return null - } - throw error - } -} - function extractShellAssignment(content: string, name: string): string | null { const match = new RegExp(`^${name}=('([^']*)'|"([^"]*)"|([^\\n]+))$`, 'm').exec(content) if (!match) { diff --git a/src/main/cli/linux-bare-orca-dispatcher.test.ts b/src/main/cli/linux-bare-orca-dispatcher.test.ts index b95b5c87a54..b2bb40e558b 100644 --- a/src/main/cli/linux-bare-orca-dispatcher.test.ts +++ b/src/main/cli/linux-bare-orca-dispatcher.test.ts @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, readFile, rm, stat, truncate, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -7,10 +7,7 @@ vi.mock('electron', () => ({ app: { isPackaged: true } })) -import { - LINUX_BARE_ORCA_DISPATCHER_MAX_BYTES, - installLinuxBareOrcaDispatcher -} from './linux-bare-orca-dispatcher' +import { installLinuxBareOrcaDispatcher } from './linux-bare-orca-dispatcher' const created: string[] = [] @@ -70,45 +67,6 @@ describe('installLinuxBareOrcaDispatcher', () => { expect(second.state).toBe('installed') }) - it('recognizes an owned dispatcher at the exact inspection boundary', async () => { - const { homePath, resourcesPath } = await makeFixture() - const dispatcherPath = join(homePath, '.local', 'bin', 'orca') - const ownedPrefix = '#!/bin/sh\n# orca-serve-bare-orca-dispatcher\n' - await mkdir(join(homePath, '.local', 'bin'), { recursive: true }) - await writeFile( - dispatcherPath, - ownedPrefix + - ' '.repeat(LINUX_BARE_ORCA_DISPATCHER_MAX_BYTES - Buffer.byteLength(ownedPrefix)), - 'utf8' - ) - - const result = await installLinuxBareOrcaDispatcher({ - resourcesPath, - homePath, - appImagePath: null - }) - - expect(result.state).toBe('installed') - expect((await stat(dispatcherPath)).size).toBeLessThan(LINUX_BARE_ORCA_DISPATCHER_MAX_BYTES) - }) - - it('preserves an oversized dispatcher even when its prefix contains the ownership marker', async () => { - const { homePath, resourcesPath } = await makeFixture() - const dispatcherPath = join(homePath, '.local', 'bin', 'orca') - await mkdir(join(homePath, '.local', 'bin'), { recursive: true }) - await writeFile(dispatcherPath, '#!/bin/sh\n# orca-serve-bare-orca-dispatcher\n', 'utf8') - await truncate(dispatcherPath, LINUX_BARE_ORCA_DISPATCHER_MAX_BYTES + 1) - - const result = await installLinuxBareOrcaDispatcher({ - resourcesPath, - homePath, - appImagePath: null - }) - - expect(result.state).toBe('skipped-foreign') - expect((await stat(dispatcherPath)).size).toBe(LINUX_BARE_ORCA_DISPATCHER_MAX_BYTES + 1) - }) - it('quotes a resources path containing spaces so the exec line cannot be split', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-bare-dispatcher-space-')) created.push(root) diff --git a/src/main/cli/linux-bare-orca-dispatcher.ts b/src/main/cli/linux-bare-orca-dispatcher.ts index c77f296cc5f..3dc8df2906c 100644 --- a/src/main/cli/linux-bare-orca-dispatcher.ts +++ b/src/main/cli/linux-bare-orca-dispatcher.ts @@ -1,15 +1,13 @@ import { existsSync } from 'node:fs' -import { chmod, mkdir, writeFile } from 'node:fs/promises' +import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises' import { homedir } from 'node:os' import { dirname, join } from 'node:path' -import { readNodeFileWithinLimit } from '../../shared/node-bounded-file-reader' import { buildAppImageCliWrapper, quoteShell } from './appimage-cli-wrapper' import { getBundledLauncherPath } from './cli-installer' // Why: marks a dispatcher this function wrote so repeat serve starts overwrite // our own file idempotently but never clobber a user's own ~/.local/bin/orca. const DISPATCHER_MARKER = '# orca-serve-bare-orca-dispatcher' -export const LINUX_BARE_ORCA_DISPATCHER_MAX_BYTES = 64 * 1024 export type LinuxBareOrcaDispatcherOptions = { /** Packaged app resources root; the bundled `orca-ide` launcher lives under it. */ @@ -108,9 +106,7 @@ function withMarker(script: string): string { async function isOwnedDispatcher(dispatcherPath: string): Promise { try { - return ( - await readNodeFileWithinLimit(dispatcherPath, LINUX_BARE_ORCA_DISPATCHER_MAX_BYTES) - ).buffer.includes(DISPATCHER_MARKER) + return (await readFile(dispatcherPath, 'utf8')).includes(DISPATCHER_MARKER) } catch { return false } diff --git a/src/main/cli/linux-terminal-orca-cli-shim.test.ts b/src/main/cli/linux-terminal-orca-cli-shim.test.ts index 29f63a472e7..81af17ba779 100644 --- a/src/main/cli/linux-terminal-orca-cli-shim.test.ts +++ b/src/main/cli/linux-terminal-orca-cli-shim.test.ts @@ -1,4 +1,4 @@ -import { chmodSync, mkdirSync, readFileSync, statSync, truncateSync, writeFileSync } from 'node:fs' +import { chmodSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -8,14 +8,7 @@ vi.mock('electron', () => ({ app: { isPackaged: true } })) -import { - _getLinuxTerminalShimCacheSizeForTests, - _isLinuxTerminalShimCacheKeyRetainableForTests, - _resetLinuxTerminalShimCacheForTests, - ensureLinuxTerminalOrcaCliShimDir, - LINUX_TERMINAL_SHIM_CACHE_KEY_MAX_BYTES, - LINUX_TERMINAL_SHIM_CACHE_MAX_ENTRIES -} from './linux-terminal-orca-cli-shim' +import { ensureLinuxTerminalOrcaCliShimDir } from './linux-terminal-orca-cli-shim' const created: string[] = [] @@ -30,7 +23,6 @@ async function makeFixture(): Promise<{ userDataPath: string; resourcesPath: str } afterEach(async () => { - _resetLinuxTerminalShimCacheForTests() await Promise.all(created.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))) }) @@ -99,25 +91,6 @@ describe('ensureLinuxTerminalOrcaCliShimDir', () => { expect(content).not.toContain(resourcesPath) }) - it('replaces a large sparse file at the owned shim path without loading it whole', async () => { - const { userDataPath, resourcesPath } = await makeFixture() - const shimDir = join(userDataPath, 'linux-orca-cli-shim') - const shimPath = join(shimDir, 'orca') - mkdirSync(shimDir, { recursive: true }) - writeFileSync(shimPath, 'stale') - truncateSync(shimPath, 256 * 1024 * 1024) - - expect( - ensureLinuxTerminalOrcaCliShimDir({ - userDataPath, - resourcesPath, - appImagePath: null - }) - ).toBe(shimDir) - expect(statSync(shimPath).size).toBeLessThan(64 * 1024) - expect(readFileSync(shimPath, 'utf8')).toContain('orca-ide') - }) - it('returns null (and does not memoize) when the bundled launcher is missing', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-terminal-cli-shim-missing-')) created.push(root) @@ -142,31 +115,4 @@ describe('ensureLinuxTerminalOrcaCliShimDir', () => { }) expect(recovered).toBe(join(userDataPath, 'linux-orca-cli-shim')) }) - - it('bounds successful user-data path memoization', async () => { - const { userDataPath, resourcesPath } = await makeFixture() - for (let index = 0; index <= LINUX_TERMINAL_SHIM_CACHE_MAX_ENTRIES; index += 1) { - expect( - ensureLinuxTerminalOrcaCliShimDir({ - userDataPath: join(userDataPath, String(index)), - resourcesPath, - appImagePath: null - }) - ).not.toBeNull() - } - expect(_getLinuxTerminalShimCacheSizeForTests()).toBe(LINUX_TERMINAL_SHIM_CACHE_MAX_ENTRIES) - }) - - it('measures memoized paths by UTF-8 bytes', () => { - expect( - _isLinuxTerminalShimCacheKeyRetainableForTests( - 'x'.repeat(LINUX_TERMINAL_SHIM_CACHE_KEY_MAX_BYTES) - ) - ).toBe(true) - expect( - _isLinuxTerminalShimCacheKeyRetainableForTests( - '😀'.repeat(LINUX_TERMINAL_SHIM_CACHE_KEY_MAX_BYTES / 4 + 1) - ) - ).toBe(false) - }) }) diff --git a/src/main/cli/linux-terminal-orca-cli-shim.ts b/src/main/cli/linux-terminal-orca-cli-shim.ts index df682923173..56f38df15ce 100644 --- a/src/main/cli/linux-terminal-orca-cli-shim.ts +++ b/src/main/cli/linux-terminal-orca-cli-shim.ts @@ -1,7 +1,5 @@ -import { chmodSync, mkdirSync, writeFileSync } from 'node:fs' +import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { join } from 'node:path' -import { nodeFileContentsEqualSync } from '../../shared/node-file-content-equality' -import { measureUtf8ByteLength } from '../../shared/utf8-byte-limits' import { buildBareOrcaCliScript } from './linux-bare-orca-dispatcher' const SHIM_DIR_NAME = 'linux-orca-cli-shim' @@ -10,37 +8,6 @@ const SHIM_DIR_NAME = 'linux-orca-cli-shim' // changes with the install itself, so one successful write per process is enough. // Failures are NOT cached so a transient fs error retries on the next spawn. const ensuredShimDirs = new Map() -export const LINUX_TERMINAL_SHIM_CACHE_MAX_ENTRIES = 64 -export const LINUX_TERMINAL_SHIM_CACHE_KEY_MAX_BYTES = 64 * 1024 - -function getEnsuredShimDir(userDataPath: string): string | undefined { - const cached = ensuredShimDirs.get(userDataPath) - if (cached === undefined) { - return undefined - } - ensuredShimDirs.delete(userDataPath) - ensuredShimDirs.set(userDataPath, cached) - return cached -} - -function rememberEnsuredShimDir(userDataPath: string, shimDir: string): void { - if ( - measureUtf8ByteLength(userDataPath, { - stopAfterBytes: LINUX_TERMINAL_SHIM_CACHE_KEY_MAX_BYTES - }).exceededLimit - ) { - return - } - ensuredShimDirs.delete(userDataPath) - ensuredShimDirs.set(userDataPath, shimDir) - while (ensuredShimDirs.size > LINUX_TERMINAL_SHIM_CACHE_MAX_ENTRIES) { - const oldest = ensuredShimDirs.keys().next().value - if (oldest === undefined) { - return - } - ensuredShimDirs.delete(oldest) - } -} export type LinuxTerminalOrcaCliShimOptions = { userDataPath: string @@ -60,7 +27,7 @@ export type LinuxTerminalOrcaCliShimOptions = { export function ensureLinuxTerminalOrcaCliShimDir( options: LinuxTerminalOrcaCliShimOptions ): string | null { - const cached = getEnsuredShimDir(options.userDataPath) + const cached = ensuredShimDirs.get(options.userDataPath) if (cached !== undefined) { return cached } @@ -80,7 +47,7 @@ export function ensureLinuxTerminalOrcaCliShimDir( const shimDir = join(options.userDataPath, SHIM_DIR_NAME) const shimPath = join(shimDir, 'orca') try { - if (!shimMatches(shimPath, resolved.script)) { + if (readShim(shimPath) !== resolved.script) { mkdirSync(shimDir, { recursive: true }) writeFileSync(shimPath, resolved.script, 'utf8') } @@ -90,28 +57,14 @@ export function ensureLinuxTerminalOrcaCliShimDir( } catch { return null } - rememberEnsuredShimDir(options.userDataPath, shimDir) + ensuredShimDirs.set(options.userDataPath, shimDir) return shimDir } -export function _resetLinuxTerminalShimCacheForTests(): void { - ensuredShimDirs.clear() -} - -export function _getLinuxTerminalShimCacheSizeForTests(): number { - return ensuredShimDirs.size -} - -export function _isLinuxTerminalShimCacheKeyRetainableForTests(userDataPath: string): boolean { - return !measureUtf8ByteLength(userDataPath, { - stopAfterBytes: LINUX_TERMINAL_SHIM_CACHE_KEY_MAX_BYTES - }).exceededLimit -} - -function shimMatches(shimPath: string, expected: string): boolean { +function readShim(shimPath: string): string | null { try { - return nodeFileContentsEqualSync(shimPath, expected) + return readFileSync(shimPath, 'utf8') } catch { - return false + return null } } diff --git a/src/main/cli/wsl-cli-installer-bounds.test.ts b/src/main/cli/wsl-cli-installer-bounds.test.ts deleted file mode 100644 index 61694684913..00000000000 --- a/src/main/cli/wsl-cli-installer-bounds.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { execFileSync } from 'node:child_process' -import { mkdir, mkdtemp, rm, stat, truncate, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { describe, expect, it } from 'vitest' -import type { CliInstallStatus } from '../../shared/cli-install-types' -import { WSL_CLI_INSPECTION_MAX_BYTES, WslCliInstaller, _internals } from './wsl-cli-installer' - -function installedHostStatus(): CliInstallStatus { - const launcherPath = 'C:\\Orca\\resources\\bin\\orca.exe' - return { - platform: 'win32', - commandName: 'orca', - commandPath: launcherPath, - pathDirectory: 'C:\\Orca\\resources\\bin', - pathConfigured: true, - launcherPath, - installMethod: 'wrapper', - supported: true, - state: 'installed', - currentTarget: launcherPath, - unsupportedReason: null, - detail: null - } -} - -describe('WSL CLI launcher inspection bounds', () => { - it('accepts encoded file output at the exact byte ceiling and rejects one byte more', () => { - const prefix = _internals.WSL_BOUNDED_FILE_OUTPUT_PREFIX - const exact = Buffer.alloc(WSL_CLI_INSPECTION_MAX_BYTES, 0x61) - const oversized = Buffer.alloc(WSL_CLI_INSPECTION_MAX_BYTES + 1, 0x61) - - expect( - _internals.parseBoundedWslFileOutput(`${prefix}${exact.toString('base64')}`) - ).toHaveLength(WSL_CLI_INSPECTION_MAX_BYTES) - expect(_internals.parseBoundedWslFileOutput(`${prefix}${oversized.toString('base64')}`)).toBe( - 'not_file' - ) - }) - - it.skipIf(process.platform === 'win32')( - 'fails closed on a sparse oversized launcher without reading it whole', - async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-wsl-cli-bounded-read-')) - const home = join(root, 'home') - const commandPath = join(home, '.local', 'bin', 'orca-ide') - await mkdir(join(home, '.local', 'bin'), { recursive: true }) - await writeFile(commandPath, '# Orca managed WSL CLI launcher\n', 'utf8') - await truncate(commandPath, 256 * 1024 * 1024) - const runner = async (_distro: string, command: string): Promise => { - if (command.includes('printf %s "$HOME"')) { - return home - } - if (command.includes('command -v powershell.exe')) { - return 'yes' - } - if (command.includes('case ":$PATH:"')) { - return 'yes' - } - return execFileSync('bash', ['-c', command], { - encoding: 'utf8', - maxBuffer: 256 * 1024 - }) - } - const installer = new WslCliInstaller({ - platform: 'win32', - distro: 'Ubuntu', - hostInstaller: { getStatus: async () => installedHostStatus() }, - wslRunner: runner - }) - - try { - await expect(installer.getStatus()).resolves.toMatchObject({ state: 'conflict' }) - await expect(stat(commandPath)).resolves.toHaveProperty('size', 256 * 1024 * 1024) - } finally { - await rm(root, { recursive: true, force: true }) - } - } - ) -}) diff --git a/src/main/cli/wsl-cli-installer.test.ts b/src/main/cli/wsl-cli-installer.test.ts index d7d8be6c3fd..13bd8088de3 100644 --- a/src/main/cli/wsl-cli-installer.test.ts +++ b/src/main/cli/wsl-cli-installer.test.ts @@ -134,7 +134,7 @@ function createWslRunner( } return '' } - if (command.includes('head -c ')) { + if (command.includes('cat ')) { if (command.includes(commandPath)) { return files.get(commandPath) ?? '__ORCA_MISSING__' } @@ -267,10 +267,7 @@ describe('WslCliInstaller', () => { distro: 'Ubuntu', hostInstaller: { getStatus: async () => makeHostStatus('C:\\Orca\\orca.cmd') }, wslRunner: async (distro, command) => { - if ( - command.includes('head -c ') && - command.includes('/home/alice/.local/share/orca/orca-wsl-bridge.ps1') - ) { + if (command.includes('cat /home/alice/.local/share/orca/orca-wsl-bridge.ps1')) { return `${_internals.buildWslBridgeScript()}\n` } return wsl.runner(distro, command) @@ -831,6 +828,9 @@ describe('WslCliInstaller', () => { distro: 'Ubuntu', hostInstaller: { getStatus: async () => makeHostStatus('C:\\Orca\\orca.cmd') }, wslRunner: async (distro, command) => { + if (command.includes('cat /home/alice/.local/share/orca/orca-wsl-bridge.ps1')) { + return 'user bridge' + } if (command.includes('rm -f')) { throw new Error('__ORCA_CONFLICT__') } diff --git a/src/main/cli/wsl-cli-installer.ts b/src/main/cli/wsl-cli-installer.ts index 7153329c35f..f6a1684e19c 100644 --- a/src/main/cli/wsl-cli-installer.ts +++ b/src/main/cli/wsl-cli-installer.ts @@ -24,8 +24,6 @@ const BRIDGE_MANAGED_MARKER = getWslBridgeMarker() const WSL_COMMAND_NAME = 'orca-ide' const LEGACY_WSL_COMMAND_NAME = 'orca' const WSL_COMMAND_TIMEOUT_MS = 10_000 -const WSL_BOUNDED_FILE_OUTPUT_PREFIX = '__ORCA_BOUNDED_FILE_BASE64__:' -export const WSL_CLI_INSPECTION_MAX_BYTES = 64 * 1024 function normalizeManagedScriptContent(content: string): string { return content.replace(/\n+$/u, '\n') @@ -385,7 +383,6 @@ export class WslCliInstaller { const output = await this.run( distro, [ - 'set -o pipefail', `if [ -L ${quoteShell(commandPath)} ]; then`, ' printf __ORCA_NOT_FILE__', `elif [ ! -e ${quoteShell(commandPath)} ]; then`, @@ -393,8 +390,7 @@ export class WslCliInstaller { `elif [ ! -f ${quoteShell(commandPath)} ]; then`, ' printf __ORCA_NOT_FILE__', 'else', - ` printf ${quoteShell(WSL_BOUNDED_FILE_OUTPUT_PREFIX)}`, - ` head -c ${WSL_CLI_INSPECTION_MAX_BYTES + 1} -- ${quoteShell(commandPath)} | base64`, + ` cat ${quoteShell(commandPath)}`, 'fi' ].join('\n') ) @@ -404,7 +400,7 @@ export class WslCliInstaller { if (output === '__ORCA_NOT_FILE__') { return 'not_file' } - return parseBoundedWslFileOutput(output) + return output } private buildStatus(args: { @@ -510,24 +506,10 @@ function buildEncodedWslBashCommand(command: string): string { return `set -o pipefail; printf %s ${quoteShell(encoded)} | base64 -d | bash` } -function parseBoundedWslFileOutput(output: string): string | 'not_file' { - if (!output.startsWith(WSL_BOUNDED_FILE_OUTPUT_PREFIX)) { - return output - } - const encoded = output.slice(WSL_BOUNDED_FILE_OUTPUT_PREFIX.length).replace(/\s/gu, '') - if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u.test(encoded)) { - return 'not_file' - } - const decoded = Buffer.from(encoded, 'base64') - return decoded.length <= WSL_CLI_INSPECTION_MAX_BYTES ? decoded.toString('utf8') : 'not_file' -} - export const _internals = { buildEncodedWslBashCommand, buildWslBridgeScript, buildWslLauncher, getBridgePathFromCommandPath, - parseManagedLauncherTarget, - parseBoundedWslFileOutput, - WSL_BOUNDED_FILE_OUTPUT_PREFIX + parseManagedLauncherTarget } diff --git a/src/main/cli/wsl-cli-registration-registry.test.ts b/src/main/cli/wsl-cli-registration-registry.test.ts index 92c60caa85e..95bf554a104 100644 --- a/src/main/cli/wsl-cli-registration-registry.test.ts +++ b/src/main/cli/wsl-cli-registration-registry.test.ts @@ -1,12 +1,11 @@ -import { mkdir, mkdtemp, readFile, rm, truncate, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { getWslCliRegistrationCandidates, recordWslCliRegistrationObservations, - recordWslCliRegistrationRemoved, - WSL_CLI_REGISTRY_MAX_BYTES + recordWslCliRegistrationRemoved } from './wsl-cli-registration-registry' describe('WSL CLI registration registry', () => { @@ -145,16 +144,6 @@ describe('WSL CLI registration registry', () => { ).resolves.toEqual(['Ubuntu', 'Debian']) }) - it('rediscovers available distros without reading an oversized sparse registry', async () => { - const registryPath = join(userDataPath, 'wsl-cli-registrations.json') - await writeFile(registryPath, '{"schemaVersion":2}', 'utf8') - await truncate(registryPath, WSL_CLI_REGISTRY_MAX_BYTES + 1) - - await expect( - getWslCliRegistrationCandidates(userDataPath, ['Ubuntu', 'Debian']) - ).resolves.toEqual(['Ubuntu', 'Debian']) - }) - it('stops reconciling a registration removed through Settings', async () => { await recordWslCliRegistrationObservations(userDataPath, [ { distro: 'Ubuntu', inspected: true, managed: true } diff --git a/src/main/cli/wsl-cli-registration-registry.ts b/src/main/cli/wsl-cli-registration-registry.ts index 70dc6934553..d1a84a42f52 100644 --- a/src/main/cli/wsl-cli-registration-registry.ts +++ b/src/main/cli/wsl-cli-registration-registry.ts @@ -1,9 +1,5 @@ -import { mkdir } from 'node:fs/promises' +import { mkdir, readFile } from 'node:fs/promises' import { join } from 'node:path' -import { - NodeFileReadTooLargeError, - readNodeFileWithinLimit -} from '../../shared/node-bounded-file-reader' import { writeFileAtomically } from '../codex-accounts/fs-utils' import { getKeyedSerializedQueueTail, runKeyedSerializedOperation } from './keyed-promise-queue' import { normalizeWslDistroKey } from './wsl-cli-registration-operation' @@ -11,7 +7,6 @@ import { normalizeWslDistroKey } from './wsl-cli-registration-operation' const REGISTRY_FILE_NAME = 'wsl-cli-registrations.json' const REGISTRY_SCHEMA_VERSION = 2 const DEFAULT_NEGATIVE_INSPECTION_TTL_MS = 7 * 24 * 60 * 60 * 1_000 -export const WSL_CLI_REGISTRY_MAX_BYTES = 1024 * 1024 // Why: the registry is advisory; cap per-distro bookkeeping so hosts that // cycle many uniquely named distros cannot grow the file without bound. const MAX_INSPECTION_ENTRIES = 64 @@ -131,18 +126,11 @@ function getRegistryPath(userDataPath: string): string { async function readState(userDataPath: string): Promise { try { - const { buffer } = await readNodeFileWithinLimit( - getRegistryPath(userDataPath), - WSL_CLI_REGISTRY_MAX_BYTES - ) - return parseState(buffer.toString('utf8')) + return parseState(await readFile(getRegistryPath(userDataPath), 'utf8')) } catch (error) { if (isMissingError(error)) { return emptyState() } - if (error instanceof NodeFileReadTooLargeError) { - return emptyState() - } throw error } } diff --git a/src/main/codex-accounts/codex-wsl-runtime-home-retention.test.ts b/src/main/codex-accounts/codex-wsl-runtime-home-retention.test.ts deleted file mode 100644 index adcc9d6372e..00000000000 --- a/src/main/codex-accounts/codex-wsl-runtime-home-retention.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { CodexWslRuntimeHomeRetention } from './codex-wsl-runtime-home-retention' - -const BOUNDS = { - maxEntries: 2, - maxKeyBytes: 8, - maxValueBytes: 8, - maxTotalBytes: 18 -} - -describe('CodexWslRuntimeHomeRetention', () => { - it('retains the coordinated state and distinguishes an explicit system account', () => { - const retention = new CodexWslRuntimeHomeRetention(BOUNDS) - retention.setRuntimeHomePath('a', '/home') - retention.setLastWrittenAuthJson('a', 'auth') - retention.setLastSyncedAccountId('a', null) - - expect(retention.getRuntimeHomePath('a')).toBe('/home') - expect(retention.getLastWrittenAuthJson('a')).toBe('auth') - expect(retention.hasLastSyncedAccountId('a')).toBe(true) - expect(retention.getLastSyncedAccountId('a')).toBeNull() - }) - - it('evicts the least-recently-used distro at the entry bound', () => { - const retention = new CodexWslRuntimeHomeRetention(BOUNDS) - retention.setRuntimeHomePath('a', 'one') - retention.setRuntimeHomePath('b', 'two') - retention.getRuntimeHomePath('a') - retention.setRuntimeHomePath('c', 'three') - - expect(retention.getRuntimeHomePath('a')).toBe('one') - expect(retention.getRuntimeHomePath('b')).toBeUndefined() - expect(retention.getRuntimeHomePath('c')).toBe('three') - expect(retention.evidence().entries).toBe(2) - }) - - it('bounds aggregate retained bytes and fails closed for oversized fields', () => { - const retention = new CodexWslRuntimeHomeRetention(BOUNDS) - retention.setRuntimeHomePath('a', '12345678') - retention.setRuntimeHomePath('b', '12345678') - - expect(retention.evidence()).toEqual({ entries: 2, retainedBytes: 18 }) - - retention.setLastWrittenAuthJson('b', '123456789') - - expect(retention.getRuntimeHomePath('b')).toBeUndefined() - expect(retention.evidence()).toEqual({ entries: 1, retainedBytes: 9 }) - }) -}) diff --git a/src/main/codex-accounts/codex-wsl-runtime-home-retention.ts b/src/main/codex-accounts/codex-wsl-runtime-home-retention.ts deleted file mode 100644 index e15e4709ef7..00000000000 --- a/src/main/codex-accounts/codex-wsl-runtime-home-retention.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { measureUtf8ByteLength } from '../../shared/utf8-byte-limits' - -export type CodexWslRuntimeHomeRetentionBounds = { - maxEntries: number - maxKeyBytes: number - maxValueBytes: number - maxTotalBytes: number -} - -export const DEFAULT_CODEX_WSL_RUNTIME_HOME_RETENTION_BOUNDS: CodexWslRuntimeHomeRetentionBounds = { - maxEntries: 64, - maxKeyBytes: 1024, - maxValueBytes: 4 * 1024 * 1024, - maxTotalBytes: 8 * 1024 * 1024 -} - -type CodexWslRuntimeHomeEntry = { - runtimeHomePath?: string - lastWrittenAuthJson?: string | null - lastSyncedAccountId?: string | null - retainedBytes: number -} - -export class CodexWslRuntimeHomeRetention { - private readonly entries = new Map() - private retainedBytes = 0 - - constructor( - private readonly bounds: CodexWslRuntimeHomeRetentionBounds = DEFAULT_CODEX_WSL_RUNTIME_HOME_RETENTION_BOUNDS - ) { - for (const value of Object.values(bounds)) { - if (!Number.isSafeInteger(value) || value < 1) { - throw new RangeError('Codex WSL runtime-home retention bounds must be positive integers') - } - } - } - - getRuntimeHomePath(distro: string): string | undefined { - return this.get(distro)?.runtimeHomePath - } - - hasLastSyncedAccountId(distro: string): boolean { - return this.get(distro)?.lastSyncedAccountId !== undefined - } - - getLastSyncedAccountId(distro: string): string | null | undefined { - return this.get(distro)?.lastSyncedAccountId - } - - getLastWrittenAuthJson(distro: string): string | null | undefined { - return this.get(distro)?.lastWrittenAuthJson - } - - setRuntimeHomePath(distro: string, runtimeHomePath: string): void { - this.update(distro, { runtimeHomePath }) - } - - setLastSyncedAccountId(distro: string, accountId: string | null): void { - this.update(distro, { lastSyncedAccountId: accountId }) - } - - setLastWrittenAuthJson(distro: string, authJson: string | null): void { - this.update(distro, { lastWrittenAuthJson: authJson }) - } - - evidence(): { entries: number; retainedBytes: number } { - return { entries: this.entries.size, retainedBytes: this.retainedBytes } - } - - private get(distro: string): CodexWslRuntimeHomeEntry | undefined { - const entry = this.entries.get(distro) - if (entry) { - this.entries.delete(distro) - this.entries.set(distro, entry) - } - return entry - } - - private update( - distro: string, - patch: Omit, 'retainedBytes'> - ): void { - const previous = this.entries.get(distro) - const next = { ...previous, ...patch, retainedBytes: 0 } - const retainedBytes = this.measureEntry(distro, next) - this.delete(distro) - if (retainedBytes === null || retainedBytes > this.bounds.maxTotalBytes) { - return - } - while ( - this.entries.size >= this.bounds.maxEntries || - this.retainedBytes + retainedBytes > this.bounds.maxTotalBytes - ) { - const oldest = this.entries.keys().next().value - if (oldest === undefined) { - return - } - this.delete(oldest) - } - next.retainedBytes = retainedBytes - this.entries.set(distro, next) - this.retainedBytes += retainedBytes - } - - private measureEntry(distro: string, entry: CodexWslRuntimeHomeEntry): number | null { - const keyBytes = measureUtf8ByteLength(distro, { - stopAfterBytes: this.bounds.maxKeyBytes - }) - if (keyBytes.exceededLimit) { - return null - } - let retainedBytes = keyBytes.byteLength - for (const value of [ - entry.runtimeHomePath, - entry.lastWrittenAuthJson, - entry.lastSyncedAccountId - ]) { - if (typeof value !== 'string') { - continue - } - const valueBytes = measureUtf8ByteLength(value, { - stopAfterBytes: this.bounds.maxValueBytes - }) - if (valueBytes.exceededLimit) { - return null - } - retainedBytes += valueBytes.byteLength - } - return retainedBytes - } - - private delete(distro: string): void { - const entry = this.entries.get(distro) - if (!entry) { - return - } - this.entries.delete(distro) - this.retainedBytes -= entry.retainedBytes - } -} diff --git a/src/main/codex-accounts/host-codex-managed-home-ownership.ts b/src/main/codex-accounts/host-codex-managed-home-ownership.ts index a7d2d4a09c5..6f0603c73f4 100644 --- a/src/main/codex-accounts/host-codex-managed-home-ownership.ts +++ b/src/main/codex-accounts/host-codex-managed-home-ownership.ts @@ -1,6 +1,5 @@ -import { existsSync, lstatSync, realpathSync } from 'node:fs' +import { existsSync, lstatSync, readFileSync, realpathSync } from 'node:fs' import { isAbsolute, join, relative, resolve, sep } from 'node:path' -import { readAgentStateFileSync } from '../agent-state-file-reader' type HostCodexManagedHomeOwnershipOptions = { candidatePath: string @@ -88,7 +87,7 @@ export function assertOwnedHostCodexManagedHomePath({ if (!markerIsRegularFile) { throw new Error('Managed Codex home ownership marker is not a regular file.') } - const markerContents = readAgentStateFileSync(markerPath) + const markerContents = readFileSync(markerPath, 'utf-8') if (expectedAccountId !== undefined && markerContents.trim() !== expectedAccountId) { throw new Error('Managed Codex home ownership marker does not match its account ID.') } diff --git a/src/main/codex-accounts/legacy-history-migration.test.ts b/src/main/codex-accounts/legacy-history-migration.test.ts deleted file mode 100644 index 7b47c93d9ca..00000000000 --- a/src/main/codex-accounts/legacy-history-migration.test.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { existsSync, mkdtempSync, readFileSync, rmSync, truncateSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { - CODEX_LEGACY_HISTORY_READ_CHUNK_BYTES, - mergeCodexLegacyHistorySync -} from './legacy-history-migration' - -describe('Codex legacy history migration', () => { - let root = '' - let legacyHistoryPath = '' - let runtimeHistoryPath = '' - - beforeEach(() => { - root = mkdtempSync(join(tmpdir(), 'orca-codex-legacy-history-')) - legacyHistoryPath = join(root, 'legacy-history.jsonl') - runtimeHistoryPath = join(root, 'runtime-history.jsonl') - }) - - afterEach(() => { - rmSync(root, { recursive: true, force: true }) - }) - - it('preserves the legacy merge order and exact normalized output in limit', () => { - const longLegacyLine = `🦀${'x'.repeat(CODEX_LEGACY_HISTORY_READ_CHUNK_BYTES + 8)}` - writeFileSync(runtimeHistoryPath, 'first\n\nsecond\r\nlast') - writeFileSync(legacyHistoryPath, `second\r\nthird\nfirst\n\nthird\n${longLegacyLine}`) - - const result = mergeCodexLegacyHistorySync({ legacyHistoryPath, runtimeHistoryPath }) - - expect(result).toMatchObject({ kind: 'merged', addedLineCount: 2, outputLineCount: 5 }) - expect(readFileSync(runtimeHistoryPath, 'utf8')).toBe( - `first\nsecond\r\nlast\nthird\n${longLegacyLine}\n` - ) - }) - - it('leaves the runtime file byte-for-byte unchanged when output exceeds its limit', () => { - const originalRuntime = 'runtime\n' - writeFileSync(runtimeHistoryPath, originalRuntime) - writeFileSync(legacyHistoryPath, 'new-value\n') - - const result = mergeCodexLegacyHistorySync({ - legacyHistoryPath, - runtimeHistoryPath, - limits: { maxOutputBytes: Buffer.byteLength(originalRuntime) } - }) - - expect(result).toEqual({ - kind: 'skipped', - reason: 'output-bytes', - observed: Buffer.byteLength(originalRuntime) + Buffer.byteLength('new-value\n'), - limit: Buffer.byteLength(originalRuntime) - }) - expect(readFileSync(runtimeHistoryPath, 'utf8')).toBe(originalRuntime) - }) - - it('does not create partial output for an oversized source file or line', () => { - writeFileSync(legacyHistoryPath, '') - truncateSync(legacyHistoryPath, 4096) - - const fileResult = mergeCodexLegacyHistorySync({ - legacyHistoryPath, - runtimeHistoryPath, - limits: { maxFileBytes: 1024 } - }) - - expect(fileResult).toMatchObject({ kind: 'skipped', reason: 'file-bytes', limit: 1024 }) - expect(existsSync(runtimeHistoryPath)).toBe(false) - - writeFileSync(legacyHistoryPath, 'oversized\n') - const lineResult = mergeCodexLegacyHistorySync({ - legacyHistoryPath, - runtimeHistoryPath, - limits: { maxLineBytes: 4 } - }) - - expect(lineResult).toMatchObject({ kind: 'skipped', reason: 'line-bytes', limit: 4 }) - expect(existsSync(runtimeHistoryPath)).toBe(false) - }) - - it('bounds input records even when every record is empty or duplicated', () => { - writeFileSync(legacyHistoryPath, 'same\nsame\n\nsame\n') - - const result = mergeCodexLegacyHistorySync({ - legacyHistoryPath, - runtimeHistoryPath, - limits: { maxInputLines: 3 } - }) - - expect(result).toMatchObject({ - kind: 'skipped', - reason: 'input-lines', - observed: 4, - limit: 3 - }) - expect(existsSync(runtimeHistoryPath)).toBe(false) - }) - - it('leaves an existing empty-only runtime file untouched when no record survives', () => { - writeFileSync(runtimeHistoryPath, '\n\n') - writeFileSync(legacyHistoryPath, '\n') - - expect(mergeCodexLegacyHistorySync({ legacyHistoryPath, runtimeHistoryPath })).toEqual({ - kind: 'empty' - }) - expect(readFileSync(runtimeHistoryPath, 'utf8')).toBe('\n\n') - }) -}) diff --git a/src/main/codex-accounts/legacy-history-migration.ts b/src/main/codex-accounts/legacy-history-migration.ts deleted file mode 100644 index 81dbd40c3ae..00000000000 --- a/src/main/codex-accounts/legacy-history-migration.ts +++ /dev/null @@ -1,230 +0,0 @@ -import { randomUUID } from 'node:crypto' -import { - closeSync, - existsSync, - fstatSync, - mkdirSync, - openSync, - readSync, - rmSync, - writeSync -} from 'node:fs' -import { dirname } from 'node:path' -import { renameFileWithWindowsRetry } from './fs-utils' - -export const CODEX_LEGACY_HISTORY_FILE_MAX_BYTES = 32 * 1024 * 1024 -export const CODEX_LEGACY_HISTORY_LINE_MAX_BYTES = 1024 * 1024 -export const CODEX_LEGACY_HISTORY_INPUT_MAX_LINES = 200_000 -export const CODEX_LEGACY_HISTORY_OUTPUT_MAX_BYTES = 32 * 1024 * 1024 -export const CODEX_LEGACY_HISTORY_READ_CHUNK_BYTES = 64 * 1024 - -export type CodexLegacyHistoryMigrationLimits = { - maxFileBytes: number - maxLineBytes: number - maxInputLines: number - maxOutputBytes: number -} - -export const DEFAULT_CODEX_LEGACY_HISTORY_MIGRATION_LIMITS: CodexLegacyHistoryMigrationLimits = { - maxFileBytes: CODEX_LEGACY_HISTORY_FILE_MAX_BYTES, - maxLineBytes: CODEX_LEGACY_HISTORY_LINE_MAX_BYTES, - maxInputLines: CODEX_LEGACY_HISTORY_INPUT_MAX_LINES, - maxOutputBytes: CODEX_LEGACY_HISTORY_OUTPUT_MAX_BYTES -} - -export type CodexLegacyHistorySkipReason = - | 'file-bytes' - | 'line-bytes' - | 'input-lines' - | 'output-bytes' - -export type CodexLegacyHistoryMigrationResult = - | { - kind: 'merged' - addedLineCount: number - outputBytes: number - outputLineCount: number - } - | { kind: 'empty' } - | { - kind: 'skipped' - reason: CodexLegacyHistorySkipReason - observed: number - limit: number - } - -class CodexLegacyHistoryCapacityError extends Error { - constructor( - readonly reason: CodexLegacyHistorySkipReason, - readonly observed: number, - readonly limit: number - ) { - super(`Codex legacy history exceeded ${reason} limit (${observed} > ${limit})`) - this.name = 'CodexLegacyHistoryCapacityError' - } -} - -export function mergeCodexLegacyHistorySync(options: { - legacyHistoryPath: string - runtimeHistoryPath: string - limits?: Partial -}): CodexLegacyHistoryMigrationResult { - const limits = resolveLimits(options.limits) - const temporaryPath = `${options.runtimeHistoryPath}.${process.pid}.${randomUUID()}.migration.tmp` - let outputDescriptor: number | null = null - - try { - mkdirSync(dirname(options.runtimeHistoryPath), { recursive: true }) - outputDescriptor = openSync(temporaryPath, 'wx') - const seenLines = new Set() - let inputLineCount = 0 - let outputBytes = 0 - let addedLineCount = 0 - - const retainLine = (line: string, fromLegacy: boolean): void => { - inputLineCount += 1 - assertWithinCapacity('input-lines', inputLineCount, limits.maxInputLines) - if (!line || seenLines.has(line)) { - return - } - - const encodedLine = Buffer.from(`${line}\n`, 'utf8') - assertWithinCapacity('output-bytes', outputBytes + encodedLine.length, limits.maxOutputBytes) - seenLines.add(line) - writeAll(outputDescriptor!, encodedLine) - outputBytes += encodedLine.length - if (fromLegacy) { - addedLineCount += 1 - } - } - - if (existsSync(options.runtimeHistoryPath)) { - readHistoryLinesSync(options.runtimeHistoryPath, limits, (line) => retainLine(line, false)) - } - readHistoryLinesSync(options.legacyHistoryPath, limits, (line) => retainLine(line, true)) - - closeSync(outputDescriptor) - outputDescriptor = null - if (seenLines.size === 0) { - rmSync(temporaryPath, { force: true }) - return { kind: 'empty' } - } - - renameFileWithWindowsRetry(temporaryPath, options.runtimeHistoryPath) - return { - kind: 'merged', - addedLineCount, - outputBytes, - outputLineCount: seenLines.size - } - } catch (error) { - closeIgnoringErrors(outputDescriptor) - rmSync(temporaryPath, { force: true }) - if (error instanceof CodexLegacyHistoryCapacityError) { - return { - kind: 'skipped', - reason: error.reason, - observed: error.observed, - limit: error.limit - } - } - throw error - } -} - -function readHistoryLinesSync( - filePath: string, - limits: CodexLegacyHistoryMigrationLimits, - onLine: (line: string) => void -): void { - const descriptor = openSync(filePath, 'r') - try { - const initialBytes = fstatSync(descriptor).size - assertWithinCapacity('file-bytes', initialBytes, limits.maxFileBytes) - const readBuffer = Buffer.allocUnsafe(CODEX_LEGACY_HISTORY_READ_CHUNK_BYTES) - let sourceBytes = 0 - let fragments: Buffer[] = [] - let fragmentBytes = 0 - - while (true) { - const bytesRead = readSync(descriptor, readBuffer, 0, readBuffer.length, null) - if (bytesRead === 0) { - break - } - sourceBytes += bytesRead - assertWithinCapacity('file-bytes', sourceBytes, limits.maxFileBytes) - - let offset = 0 - while (offset < bytesRead) { - const newlineIndex = readBuffer.indexOf(0x0a, offset) - const end = newlineIndex === -1 || newlineIndex >= bytesRead ? bytesRead : newlineIndex - const nextFragmentBytes = end - offset - assertWithinCapacity('line-bytes', fragmentBytes + nextFragmentBytes, limits.maxLineBytes) - if (nextFragmentBytes > 0) { - fragments.push(Buffer.from(readBuffer.subarray(offset, end))) - fragmentBytes += nextFragmentBytes - } - if (newlineIndex === -1 || newlineIndex >= bytesRead) { - break - } - onLine(decodeLine(fragments, fragmentBytes)) - fragments = [] - fragmentBytes = 0 - offset = newlineIndex + 1 - } - } - - if (fragmentBytes > 0) { - onLine(decodeLine(fragments, fragmentBytes)) - } - } finally { - closeSync(descriptor) - } -} - -function decodeLine(fragments: Buffer[], bytes: number): string { - if (fragments.length === 1) { - return fragments[0]!.toString('utf8') - } - return Buffer.concat(fragments, bytes).toString('utf8') -} - -function writeAll(descriptor: number, buffer: Buffer): void { - let offset = 0 - while (offset < buffer.length) { - offset += writeSync(descriptor, buffer, offset, buffer.length - offset) - } -} - -function assertWithinCapacity( - reason: CodexLegacyHistorySkipReason, - observed: number, - limit: number -): void { - if (observed > limit) { - throw new CodexLegacyHistoryCapacityError(reason, observed, limit) - } -} - -function resolveLimits( - overrides: Partial | undefined -): CodexLegacyHistoryMigrationLimits { - const limits = { ...DEFAULT_CODEX_LEGACY_HISTORY_MIGRATION_LIMITS, ...overrides } - for (const [name, value] of Object.entries(limits)) { - if (!Number.isSafeInteger(value) || value < 0) { - throw new RangeError(`${name} must be a non-negative safe integer`) - } - } - return limits -} - -function closeIgnoringErrors(descriptor: number | null): void { - if (descriptor === null) { - return - } - try { - closeSync(descriptor) - } catch { - // The original migration error is more actionable than cleanup failure. - } -} diff --git a/src/main/codex-accounts/legacy-managed-state-migration.test.ts b/src/main/codex-accounts/legacy-managed-state-migration.test.ts deleted file mode 100644 index e43b7d8f6d9..00000000000 --- a/src/main/codex-accounts/legacy-managed-state-migration.test.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { migrateLegacyManagedCodexStateSync } from './legacy-managed-state-migration' - -describe('Codex legacy managed-state migration', () => { - let root = '' - let managedAccountsRoot = '' - let metadataDir = '' - let runtimeHomePath = '' - - beforeEach(() => { - root = mkdtempSync(join(tmpdir(), 'orca-codex-managed-state-')) - managedAccountsRoot = join(root, 'codex-accounts') - metadataDir = join(root, 'metadata') - runtimeHomePath = join(root, 'runtime') - mkdirSync(managedAccountsRoot) - mkdirSync(metadataDir) - mkdirSync(runtimeHomePath) - }) - - afterEach(() => { - vi.restoreAllMocks() - rmSync(root, { recursive: true, force: true }) - }) - - function createManagedHome(accountId: string): string { - const homePath = join(managedAccountsRoot, accountId, 'home') - mkdirSync(homePath, { recursive: true }) - writeFileSync(join(homePath, '.orca-managed-home'), '') - return homePath - } - - function migrate( - limits?: Parameters[0]['limits'] - ): ReturnType { - return migrateLegacyManagedCodexStateSync({ - managedAccountsRoot, - metadataDir, - runtimeHomePath, - limits - }) - } - - it('preserves ordinary history, session, diagnostic, and one-shot marker behavior', () => { - const homePath = createManagedHome('account-1') - writeFileSync(join(runtimeHomePath, 'history.jsonl'), '{"id":"shared"}\n') - writeFileSync(join(homePath, 'history.jsonl'), '{"id":"shared"}\n{"id":"managed"}\n') - mkdirSync(join(homePath, 'sessions')) - mkdirSync(join(runtimeHomePath, 'sessions')) - writeFileSync(join(homePath, 'sessions', 'conflict.jsonl'), 'legacy\n') - writeFileSync(join(runtimeHomePath, 'sessions', 'conflict.jsonl'), 'runtime\n') - - const summary = migrate() - - expect(summary).toMatchObject({ - diagnosticRecordsOmitted: 0, - diagnosticRecordsWritten: 1, - historySkippedHomeCount: 0, - managedHomeDiscoverySkipped: false, - migratedHomeCount: 1, - sessionSkippedHomeCount: 0 - }) - expect(readFileSync(join(runtimeHomePath, 'history.jsonl'), 'utf8')).toBe( - '{"id":"shared"}\n{"id":"managed"}\n' - ) - expect( - readFileSync( - join(runtimeHomePath, 'sessions', 'conflict.orca-legacy-account-1.jsonl'), - 'utf8' - ) - ).toBe('legacy\n') - const diagnostic = readFileSync(join(metadataDir, 'migration-diagnostics.jsonl'), 'utf8') - expect(diagnostic).toContain('"type":"session-conflict"') - - const marker = JSON.parse(readFileSync(join(metadataDir, 'migration-v1.json'), 'utf8')) - expect(Object.keys(marker).sort()).toEqual(['completedAt', 'migratedHomeCount']) - expect(marker.migratedHomeCount).toBe(1) - expect(migrate()).toBeNull() - }) - - it('skips all homes deterministically when managed-home discovery exceeds its cap', () => { - const firstHome = createManagedHome('account-1') - createManagedHome('account-2') - writeFileSync(join(firstHome, 'history.jsonl'), '{"id":"must-not-import"}\n') - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - - const summary = migrate({ maxAccountEntries: 1 }) - - expect(summary).toMatchObject({ - managedHomeDiscoverySkipped: true, - migratedHomeCount: 0 - }) - expect(existsSync(join(runtimeHomePath, 'history.jsonl'))).toBe(false) - const marker = JSON.parse(readFileSync(join(metadataDir, 'migration-v1.json'), 'utf8')) - expect(marker).toMatchObject({ - managedHomeDiscoverySkipped: true, - migratedHomeCount: 0 - }) - expect(readFileSync(join(metadataDir, 'migration-diagnostics.jsonl'), 'utf8')).toContain( - '"type":"managed-home-discovery-skipped"' - ) - expect(warnSpy).toHaveBeenCalledTimes(1) - }) - - it('records history and session capacity skips without partial output', () => { - const homePath = createManagedHome('account-1') - writeFileSync(join(homePath, 'history.jsonl'), 'oversized\n') - mkdirSync(join(homePath, 'sessions')) - writeFileSync(join(homePath, 'sessions', 'a.jsonl'), 'a') - writeFileSync(join(homePath, 'sessions', 'b.jsonl'), 'b') - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - - const summary = migrate({ - history: { maxFileBytes: 4 }, - sessions: { maxEntries: 1 } - }) - - expect(summary).toMatchObject({ - historySkippedHomeCount: 1, - sessionSkippedHomeCount: 1 - }) - expect(existsSync(join(runtimeHomePath, 'history.jsonl'))).toBe(false) - expect(existsSync(join(runtimeHomePath, 'sessions'))).toBe(false) - const marker = JSON.parse(readFileSync(join(metadataDir, 'migration-v1.json'), 'utf8')) - expect(marker).toMatchObject({ - historySkippedHomeCount: 1, - sessionSkippedHomeCount: 1 - }) - const diagnostics = readFileSync(join(metadataDir, 'migration-diagnostics.jsonl'), 'utf8') - expect(diagnostics).toContain('"type":"history-skipped"') - expect(diagnostics).toContain('"type":"sessions-skipped"') - expect(warnSpy).toHaveBeenCalledTimes(1) - }) - - it('caps diagnostic output while still preserving every conflicting session', () => { - const homePath = createManagedHome('account-1') - mkdirSync(join(homePath, 'sessions')) - mkdirSync(join(runtimeHomePath, 'sessions')) - for (const name of ['a.jsonl', 'b.jsonl']) { - writeFileSync(join(homePath, 'sessions', name), `legacy-${name}`) - writeFileSync(join(runtimeHomePath, 'sessions', name), `runtime-${name}`) - } - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - - const summary = migrate({ maxDiagnosticRecords: 1 }) - - expect(summary).toMatchObject({ - diagnosticRecordsOmitted: 1, - diagnosticRecordsWritten: 1 - }) - expect(existsSync(join(runtimeHomePath, 'sessions', 'a.orca-legacy-account-1.jsonl'))).toBe( - true - ) - expect(existsSync(join(runtimeHomePath, 'sessions', 'b.orca-legacy-account-1.jsonl'))).toBe( - true - ) - const marker = JSON.parse(readFileSync(join(metadataDir, 'migration-v1.json'), 'utf8')) - expect(marker.diagnosticRecordsOmitted).toBe(1) - expect(warnSpy).toHaveBeenCalledTimes(1) - }) -}) diff --git a/src/main/codex-accounts/legacy-managed-state-migration.ts b/src/main/codex-accounts/legacy-managed-state-migration.ts deleted file mode 100644 index 9da9cbf10e5..00000000000 --- a/src/main/codex-accounts/legacy-managed-state-migration.ts +++ /dev/null @@ -1,317 +0,0 @@ -import { appendFileSync, existsSync, opendirSync } from 'node:fs' -import { join } from 'node:path' -import { writeFileAtomically } from './fs-utils' -import { - mergeCodexLegacyHistorySync, - type CodexLegacyHistoryMigrationLimits -} from './legacy-history-migration' -import { - migrateCodexLegacySessionsSync, - type CodexLegacySessionMigrationLimits -} from './legacy-session-migration' - -export const CODEX_LEGACY_MANAGED_ACCOUNT_MAX_ENTRIES = 4096 -export const CODEX_LEGACY_MANAGED_HOME_MAX_COUNT = 256 -export const CODEX_LEGACY_MIGRATION_DIAGNOSTIC_MAX_RECORDS = 2048 -export const CODEX_LEGACY_MIGRATION_DIAGNOSTIC_MAX_BYTES = 1024 * 1024 - -type LegacyManagedHome = { - accountId: string - homePath: string -} - -type ManagedHomeDiscoveryResult = - | { kind: 'complete'; homes: LegacyManagedHome[] } - | { - kind: 'skipped' - reason: 'account-entries' | 'managed-homes' - observed: number - limit: number - } - -type MigrationDiagnostic = Record - -export type CodexLegacyManagedStateMigrationLimits = { - maxAccountEntries: number - maxManagedHomes: number - maxDiagnosticRecords: number - maxDiagnosticBytes: number - history?: Partial - sessions?: Partial -} - -export type CodexLegacyManagedStateMigrationSummary = { - diagnosticRecordsOmitted: number - diagnosticRecordsWritten: number - historySkippedHomeCount: number - managedHomeDiscoverySkipped: boolean - migratedHomeCount: number - sessionSkippedHomeCount: number -} - -const DEFAULT_LIMITS: CodexLegacyManagedStateMigrationLimits = { - maxAccountEntries: CODEX_LEGACY_MANAGED_ACCOUNT_MAX_ENTRIES, - maxManagedHomes: CODEX_LEGACY_MANAGED_HOME_MAX_COUNT, - maxDiagnosticRecords: CODEX_LEGACY_MIGRATION_DIAGNOSTIC_MAX_RECORDS, - maxDiagnosticBytes: CODEX_LEGACY_MIGRATION_DIAGNOSTIC_MAX_BYTES -} - -export function migrateLegacyManagedCodexStateSync(options: { - managedAccountsRoot: string - metadataDir: string - runtimeHomePath: string - limits?: Partial -}): CodexLegacyManagedStateMigrationSummary | null { - const markerPath = join(options.metadataDir, 'migration-v1.json') - if (existsSync(markerPath)) { - return null - } - - const limits = resolveLimits(options.limits) - const diagnostics = new MigrationDiagnosticWriter( - join(options.metadataDir, 'migration-diagnostics.jsonl'), - limits.maxDiagnosticRecords, - limits.maxDiagnosticBytes - ) - const discovery = discoverLegacyManagedHomesSync(options.managedAccountsRoot, limits) - let historySkippedHomeCount = 0 - let sessionSkippedHomeCount = 0 - - if (discovery.kind === 'skipped') { - diagnostics.append({ - type: 'managed-home-discovery-skipped', - reason: discovery.reason, - observed: discovery.observed, - limit: discovery.limit - }) - } else { - for (const home of discovery.homes) { - const legacyHistoryPath = join(home.homePath, 'history.jsonl') - if (existsSync(legacyHistoryPath)) { - const historyResult = mergeCodexLegacyHistorySync({ - legacyHistoryPath, - runtimeHistoryPath: join(options.runtimeHomePath, 'history.jsonl'), - limits: limits.history - }) - if (historyResult.kind === 'skipped') { - historySkippedHomeCount += 1 - diagnostics.append({ - type: 'history-skipped', - accountId: home.accountId, - legacyHistoryPath, - reason: historyResult.reason, - observed: historyResult.observed, - limit: historyResult.limit - }) - } - } - - const legacySessionsRoot = join(home.homePath, 'sessions') - if (!existsSync(legacySessionsRoot)) { - continue - } - const sessionResult = migrateCodexLegacySessionsSync({ - accountId: home.accountId, - legacySessionsRoot, - runtimeSessionsRoot: join(options.runtimeHomePath, 'sessions'), - limits: limits.sessions, - onConflict: ({ runtimeFilePath, preservedPath }) => { - diagnostics.append({ - type: 'session-conflict', - accountId: home.accountId, - runtimeFilePath, - preservedPath - }) - } - }) - if (sessionResult.kind === 'skipped') { - sessionSkippedHomeCount += 1 - diagnostics.append({ - type: 'sessions-skipped', - accountId: home.accountId, - legacySessionsRoot, - reason: sessionResult.reason, - observed: sessionResult.observed, - limit: sessionResult.limit - }) - } - } - } - - const summary: CodexLegacyManagedStateMigrationSummary = { - diagnosticRecordsOmitted: diagnostics.omittedCount, - diagnosticRecordsWritten: diagnostics.writtenCount, - historySkippedHomeCount, - managedHomeDiscoverySkipped: discovery.kind === 'skipped', - migratedHomeCount: discovery.kind === 'complete' ? discovery.homes.length : 0, - sessionSkippedHomeCount - } - writeMigrationMarker(markerPath, summary) - warnAboutCapacitySkips(summary, markerPath) - return summary -} - -function discoverLegacyManagedHomesSync( - managedAccountsRoot: string, - limits: CodexLegacyManagedStateMigrationLimits -): ManagedHomeDiscoveryResult { - if (!existsSync(managedAccountsRoot)) { - return { kind: 'complete', homes: [] } - } - - const homes: LegacyManagedHome[] = [] - let entryCount = 0 - const directory = opendirSync(managedAccountsRoot) - try { - while (true) { - const entry = directory.readSync() - if (entry === null) { - break - } - entryCount += 1 - if (entryCount > limits.maxAccountEntries) { - return { - kind: 'skipped', - reason: 'account-entries', - observed: entryCount, - limit: limits.maxAccountEntries - } - } - if (!entry.isDirectory()) { - continue - } - const homePath = join(managedAccountsRoot, entry.name, 'home') - if (!existsSync(join(homePath, '.orca-managed-home'))) { - continue - } - homes.push({ accountId: entry.name, homePath }) - if (homes.length > limits.maxManagedHomes) { - return { - kind: 'skipped', - reason: 'managed-homes', - observed: homes.length, - limit: limits.maxManagedHomes - } - } - } - } finally { - closeDirectoryIgnoringAlreadyClosed(directory) - } - return { - kind: 'complete', - homes: homes.sort(compareManagedHomePaths) - } -} - -function compareManagedHomePaths(left: LegacyManagedHome, right: LegacyManagedHome): number { - if (left.homePath < right.homePath) { - return -1 - } - return left.homePath > right.homePath ? 1 : 0 -} - -function writeMigrationMarker( - markerPath: string, - summary: CodexLegacyManagedStateMigrationSummary -): void { - const capacityDetails = { - ...(summary.managedHomeDiscoverySkipped ? { managedHomeDiscoverySkipped: true } : {}), - ...(summary.historySkippedHomeCount > 0 - ? { historySkippedHomeCount: summary.historySkippedHomeCount } - : {}), - ...(summary.sessionSkippedHomeCount > 0 - ? { sessionSkippedHomeCount: summary.sessionSkippedHomeCount } - : {}), - ...(summary.diagnosticRecordsOmitted > 0 - ? { diagnosticRecordsOmitted: summary.diagnosticRecordsOmitted } - : {}) - } - writeFileAtomically( - markerPath, - `${JSON.stringify({ - completedAt: Date.now(), - migratedHomeCount: summary.migratedHomeCount, - ...capacityDetails - })}\n` - ) -} - -function warnAboutCapacitySkips( - summary: CodexLegacyManagedStateMigrationSummary, - markerPath: string -): void { - const skippedCount = - Number(summary.managedHomeDiscoverySkipped) + - summary.historySkippedHomeCount + - summary.sessionSkippedHomeCount - if (skippedCount === 0 && summary.diagnosticRecordsOmitted === 0) { - return - } - console.warn('[codex-runtime-home] Legacy state migration completed with bounded skips:', { - skippedCount, - diagnosticRecordsOmitted: summary.diagnosticRecordsOmitted, - markerPath - }) -} - -class MigrationDiagnosticWriter { - private bytesWritten = 0 - private failed = false - omittedCount = 0 - writtenCount = 0 - - constructor( - private readonly path: string, - private readonly maxRecords: number, - private readonly maxBytes: number - ) {} - - append(record: MigrationDiagnostic): void { - const line = `${JSON.stringify(record)}\n` - const lineBytes = Buffer.byteLength(line) - if ( - this.failed || - this.writtenCount >= this.maxRecords || - lineBytes > this.maxBytes - this.bytesWritten - ) { - this.omittedCount += 1 - return - } - try { - appendFileSync(this.path, line, { encoding: 'utf8' }) - this.writtenCount += 1 - this.bytesWritten += lineBytes - } catch (error) { - this.failed = true - this.omittedCount += 1 - console.warn('[codex-runtime-home] Failed to append migration diagnostic:', error) - } - } -} - -function resolveLimits( - overrides: Partial | undefined -): CodexLegacyManagedStateMigrationLimits { - const limits = { ...DEFAULT_LIMITS, ...overrides } - for (const [name, value] of Object.entries({ - maxAccountEntries: limits.maxAccountEntries, - maxManagedHomes: limits.maxManagedHomes, - maxDiagnosticRecords: limits.maxDiagnosticRecords, - maxDiagnosticBytes: limits.maxDiagnosticBytes - })) { - if (!Number.isSafeInteger(value) || value < 0) { - throw new RangeError(`${name} must be a non-negative safe integer`) - } - } - return limits -} - -function closeDirectoryIgnoringAlreadyClosed(directory: ReturnType): void { - try { - directory.closeSync() - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ERR_DIR_CLOSED') { - throw error - } - } -} diff --git a/src/main/codex-accounts/legacy-session-migration.test.ts b/src/main/codex-accounts/legacy-session-migration.test.ts deleted file mode 100644 index 7ba50dd7302..00000000000 --- a/src/main/codex-accounts/legacy-session-migration.test.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { - existsSync, - mkdirSync, - mkdtempSync, - readFileSync, - rmSync, - truncateSync, - writeFileSync -} from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { - CODEX_LEGACY_SESSION_COMPARE_CHUNK_BYTES, - codexLegacySessionFilesEqualSync, - migrateCodexLegacySessionsSync -} from './legacy-session-migration' - -describe('Codex legacy session migration', () => { - let root = '' - let legacySessionsRoot = '' - let runtimeSessionsRoot = '' - - beforeEach(() => { - root = mkdtempSync(join(tmpdir(), 'orca-codex-legacy-sessions-')) - legacySessionsRoot = join(root, 'legacy') - runtimeSessionsRoot = join(root, 'runtime') - mkdirSync(legacySessionsRoot) - }) - - afterEach(() => { - rmSync(root, { recursive: true, force: true }) - }) - - it('preserves creation of the runtime sessions directory for an empty legacy tree', () => { - const result = migrateCodexLegacySessionsSync({ - accountId: 'account-1', - legacySessionsRoot, - runtimeSessionsRoot - }) - - expect(result).toMatchObject({ - kind: 'migrated', - copiedFileCount: 0, - discoveredEntryCount: 0, - discoveredFileCount: 0 - }) - expect(existsSync(runtimeSessionsRoot)).toBe(true) - }) - - it('copies and conflict-preserves ordinary files in the historical sorted order', () => { - mkdirSync(join(legacySessionsRoot, 'nested')) - mkdirSync(join(runtimeSessionsRoot, 'nested'), { recursive: true }) - writeFileSync(join(legacySessionsRoot, 'z-copy.jsonl'), 'copy\n') - writeFileSync(join(legacySessionsRoot, 'a-conflict.jsonl'), 'legacy\n') - writeFileSync(join(runtimeSessionsRoot, 'a-conflict.jsonl'), 'runtime\n') - writeFileSync(join(legacySessionsRoot, 'nested', 'same.jsonl'), 'same\n') - writeFileSync(join(runtimeSessionsRoot, 'nested', 'same.jsonl'), 'same\n') - const conflicts: string[] = [] - - const result = migrateCodexLegacySessionsSync({ - accountId: 'account-1', - legacySessionsRoot, - runtimeSessionsRoot, - onConflict: ({ runtimeFilePath }) => conflicts.push(runtimeFilePath) - }) - - expect(result).toMatchObject({ - kind: 'migrated', - conflictCount: 1, - copiedFileCount: 2, - discoveredEntryCount: 4, - discoveredFileCount: 3 - }) - expect(conflicts).toEqual([join(runtimeSessionsRoot, 'a-conflict.jsonl')]) - expect(readFileSync(join(runtimeSessionsRoot, 'z-copy.jsonl'), 'utf8')).toBe('copy\n') - expect( - readFileSync(join(runtimeSessionsRoot, 'a-conflict.orca-legacy-account-1.jsonl'), 'utf8') - ).toBe('legacy\n') - expect(readFileSync(join(runtimeSessionsRoot, 'a-conflict.jsonl'), 'utf8')).toBe('runtime\n') - }) - - it('compares sparse multi-megabyte collisions with fixed-size buffers', () => { - mkdirSync(runtimeSessionsRoot) - const legacyPath = join(legacySessionsRoot, 'large.jsonl') - const runtimePath = join(runtimeSessionsRoot, 'large.jsonl') - const sparseBytes = CODEX_LEGACY_SESSION_COMPARE_CHUNK_BYTES * 128 - for (const path of [legacyPath, runtimePath]) { - writeFileSync(path, 'same-prefix') - truncateSync(path, sparseBytes) - } - - expect(codexLegacySessionFilesEqualSync(legacyPath, runtimePath)).toBe(true) - const result = migrateCodexLegacySessionsSync({ - accountId: 'account-1', - legacySessionsRoot, - runtimeSessionsRoot - }) - - expect(result).toMatchObject({ kind: 'migrated', conflictCount: 0, copiedFileCount: 0 }) - expect(existsSync(join(runtimeSessionsRoot, 'large.orca-legacy-account-1.jsonl'))).toBe(false) - }) - - it('preflights the entry cap before copying any file', () => { - writeFileSync(join(legacySessionsRoot, 'a.jsonl'), 'a') - writeFileSync(join(legacySessionsRoot, 'b.jsonl'), 'b') - - const result = migrateCodexLegacySessionsSync({ - accountId: 'account-1', - legacySessionsRoot, - runtimeSessionsRoot, - limits: { maxEntries: 1 } - }) - - expect(result).toMatchObject({ - kind: 'skipped', - reason: 'entries', - observed: 2, - limit: 1 - }) - expect(existsSync(runtimeSessionsRoot)).toBe(false) - }) - - it('preflights depth and aggregate file bytes before copying', () => { - mkdirSync(join(legacySessionsRoot, 'one', 'two'), { recursive: true }) - writeFileSync(join(legacySessionsRoot, 'one', 'two', 'deep.jsonl'), 'deep') - - const depthResult = migrateCodexLegacySessionsSync({ - accountId: 'account-1', - legacySessionsRoot, - runtimeSessionsRoot, - limits: { maxDepth: 1 } - }) - - expect(depthResult).toMatchObject({ kind: 'skipped', reason: 'depth', observed: 2, limit: 1 }) - expect(existsSync(runtimeSessionsRoot)).toBe(false) - - rmSync(join(legacySessionsRoot, 'one'), { recursive: true }) - writeFileSync(join(legacySessionsRoot, 'a.jsonl'), '1234') - writeFileSync(join(legacySessionsRoot, 'b.jsonl'), '5678') - const byteResult = migrateCodexLegacySessionsSync({ - accountId: 'account-1', - legacySessionsRoot, - runtimeSessionsRoot, - limits: { maxTotalFileBytes: 7 } - }) - - expect(byteResult).toMatchObject({ - kind: 'skipped', - reason: 'total-file-bytes', - observed: 8, - limit: 7 - }) - expect(existsSync(runtimeSessionsRoot)).toBe(false) - }) - - it('bounds retained path text independently of entry count', () => { - writeFileSync(join(legacySessionsRoot, 'long-name.jsonl'), 'content') - - const result = migrateCodexLegacySessionsSync({ - accountId: 'account-1', - legacySessionsRoot, - runtimeSessionsRoot, - limits: { maxPathCodeUnits: legacySessionsRoot.length } - }) - - expect(result).toMatchObject({ - kind: 'skipped', - reason: 'path-code-units', - limit: legacySessionsRoot.length - }) - expect(existsSync(runtimeSessionsRoot)).toBe(false) - }) -}) diff --git a/src/main/codex-accounts/legacy-session-migration.ts b/src/main/codex-accounts/legacy-session-migration.ts deleted file mode 100644 index b36b84f16c6..00000000000 --- a/src/main/codex-accounts/legacy-session-migration.ts +++ /dev/null @@ -1,305 +0,0 @@ -import { randomUUID } from 'node:crypto' -import { - closeSync, - existsSync, - fstatSync, - mkdirSync, - openSync, - opendirSync, - readSync, - rmSync, - statSync -} from 'node:fs' -import { dirname, extname, join, relative } from 'node:path' -import { copyFileWithWindowsRetry, renameFileWithWindowsRetry } from './fs-utils' - -export const CODEX_LEGACY_SESSION_MAX_DEPTH = 32 -export const CODEX_LEGACY_SESSION_MAX_ENTRIES = 50_000 -export const CODEX_LEGACY_SESSION_MAX_TOTAL_FILE_BYTES = 4 * 1024 * 1024 * 1024 -export const CODEX_LEGACY_SESSION_MAX_PATH_CODE_UNITS = 4 * 1024 * 1024 -export const CODEX_LEGACY_SESSION_COMPARE_CHUNK_BYTES = 64 * 1024 - -export type CodexLegacySessionMigrationLimits = { - maxDepth: number - maxEntries: number - maxTotalFileBytes: number - maxPathCodeUnits: number -} - -export const DEFAULT_CODEX_LEGACY_SESSION_MIGRATION_LIMITS: CodexLegacySessionMigrationLimits = { - maxDepth: CODEX_LEGACY_SESSION_MAX_DEPTH, - maxEntries: CODEX_LEGACY_SESSION_MAX_ENTRIES, - maxTotalFileBytes: CODEX_LEGACY_SESSION_MAX_TOTAL_FILE_BYTES, - maxPathCodeUnits: CODEX_LEGACY_SESSION_MAX_PATH_CODE_UNITS -} - -export type CodexLegacySessionSkipReason = - | 'depth' - | 'entries' - | 'total-file-bytes' - | 'path-code-units' - -export type CodexLegacySessionMigrationResult = - | { - kind: 'migrated' - conflictCount: number - copiedFileCount: number - discoveredEntryCount: number - discoveredFileBytes: number - discoveredFileCount: number - } - | { - kind: 'skipped' - reason: CodexLegacySessionSkipReason - observed: number - limit: number - visitedEntryCount: number - } - -type TraversalResult = - | { - kind: 'complete' - entryCount: number - fileBytes: number - filePaths: string[] - } - | { - kind: 'skipped' - reason: CodexLegacySessionSkipReason - observed: number - limit: number - visitedEntryCount: number - } - -class CodexLegacySessionCapacityError extends Error { - constructor( - readonly reason: CodexLegacySessionSkipReason, - readonly observed: number, - readonly limit: number, - readonly visitedEntryCount: number - ) { - super(`Codex legacy sessions exceeded ${reason} limit (${observed} > ${limit})`) - this.name = 'CodexLegacySessionCapacityError' - } -} - -export function migrateCodexLegacySessionsSync(options: { - accountId: string - legacySessionsRoot: string - runtimeSessionsRoot: string - limits?: Partial - onConflict?: (conflict: { runtimeFilePath: string; preservedPath: string }) => void -}): CodexLegacySessionMigrationResult { - const limits = resolveLimits(options.limits) - const traversal = collectLegacySessionFilesSync(options.legacySessionsRoot, limits) - if (traversal.kind === 'skipped') { - return traversal - } - - mkdirSync(options.runtimeSessionsRoot, { recursive: true }) - let conflictCount = 0 - let copiedFileCount = 0 - for (const legacyFilePath of traversal.filePaths) { - const relativePath = relative(options.legacySessionsRoot, legacyFilePath) - const runtimeFilePath = join(options.runtimeSessionsRoot, relativePath) - mkdirSync(dirname(runtimeFilePath), { recursive: true }) - if (!existsSync(runtimeFilePath)) { - copyFileAtomicallySync(legacyFilePath, runtimeFilePath) - copiedFileCount += 1 - continue - } - if (codexLegacySessionFilesEqualSync(legacyFilePath, runtimeFilePath)) { - continue - } - - const preservedPath = getPreservedLegacySessionPath(runtimeFilePath, options.accountId) - copyFileAtomicallySync(legacyFilePath, preservedPath) - conflictCount += 1 - copiedFileCount += 1 - options.onConflict?.({ runtimeFilePath, preservedPath }) - } - - return { - kind: 'migrated', - conflictCount, - copiedFileCount, - discoveredEntryCount: traversal.entryCount, - discoveredFileBytes: traversal.fileBytes, - discoveredFileCount: traversal.filePaths.length - } -} - -function collectLegacySessionFilesSync( - rootPath: string, - limits: CodexLegacySessionMigrationLimits -): TraversalResult { - let entryCount = 0 - let fileBytes = 0 - let pathCodeUnits = rootPath.length - const filePaths: string[] = [] - const rootStats = statSync(rootPath) - if (rootStats.isFile()) { - assertWithinCapacity('total-file-bytes', rootStats.size, limits.maxTotalFileBytes, 0) - assertWithinCapacity('path-code-units', pathCodeUnits, limits.maxPathCodeUnits, 0) - return { kind: 'complete', entryCount: 0, fileBytes: rootStats.size, filePaths: [rootPath] } - } - - const pendingDirectories = [{ depth: 0, path: rootPath }] - try { - while (pendingDirectories.length > 0) { - const current = pendingDirectories.pop()! - const directory = opendirSync(current.path) - try { - while (true) { - const entry = directory.readSync() - if (entry === null) { - break - } - entryCount += 1 - assertWithinCapacity('entries', entryCount, limits.maxEntries, entryCount) - const childPath = join(current.path, entry.name) - pathCodeUnits += childPath.length - assertWithinCapacity( - 'path-code-units', - pathCodeUnits, - limits.maxPathCodeUnits, - entryCount - ) - - if (entry.isDirectory()) { - const childDepth = current.depth + 1 - assertWithinCapacity('depth', childDepth, limits.maxDepth, entryCount) - pendingDirectories.push({ depth: childDepth, path: childPath }) - continue - } - if (!entry.isFile()) { - continue - } - - const size = statSync(childPath).size - fileBytes += size - assertWithinCapacity('total-file-bytes', fileBytes, limits.maxTotalFileBytes, entryCount) - filePaths.push(childPath) - } - } finally { - closeDirectoryIgnoringAlreadyClosed(directory) - } - } - } catch (error) { - if (error instanceof CodexLegacySessionCapacityError) { - return { - kind: 'skipped', - reason: error.reason, - observed: error.observed, - limit: error.limit, - visitedEntryCount: error.visitedEntryCount - } - } - throw error - } - - return { kind: 'complete', entryCount, fileBytes, filePaths: filePaths.sort() } -} - -export function codexLegacySessionFilesEqualSync(leftPath: string, rightPath: string): boolean { - let leftDescriptor: number | null = null - let rightDescriptor: number | null = null - try { - leftDescriptor = openSync(leftPath, 'r') - rightDescriptor = openSync(rightPath, 'r') - if (fstatSync(leftDescriptor).size !== fstatSync(rightDescriptor).size) { - return false - } - - const leftBuffer = Buffer.allocUnsafe(CODEX_LEGACY_SESSION_COMPARE_CHUNK_BYTES) - const rightBuffer = Buffer.allocUnsafe(CODEX_LEGACY_SESSION_COMPARE_CHUNK_BYTES) - while (true) { - const leftBytes = readFullChunkSync(leftDescriptor, leftBuffer) - const rightBytes = readFullChunkSync(rightDescriptor, rightBuffer) - if (leftBytes !== rightBytes) { - return false - } - if (leftBytes === 0) { - return true - } - if (!leftBuffer.subarray(0, leftBytes).equals(rightBuffer.subarray(0, rightBytes))) { - return false - } - } - } finally { - closeIgnoringErrors(leftDescriptor) - closeIgnoringErrors(rightDescriptor) - } -} - -function readFullChunkSync(descriptor: number, buffer: Buffer): number { - let offset = 0 - while (offset < buffer.length) { - const bytesRead = readSync(descriptor, buffer, offset, buffer.length - offset, null) - if (bytesRead === 0) { - break - } - offset += bytesRead - } - return offset -} - -function copyFileAtomicallySync(sourcePath: string, targetPath: string): void { - const temporaryPath = `${targetPath}.${process.pid}.${randomUUID()}.migration.tmp` - try { - copyFileWithWindowsRetry(sourcePath, temporaryPath) - renameFileWithWindowsRetry(temporaryPath, targetPath) - } finally { - rmSync(temporaryPath, { force: true }) - } -} - -function getPreservedLegacySessionPath(runtimeFilePath: string, accountId: string): string { - const extension = extname(runtimeFilePath) - const basename = runtimeFilePath.slice(0, runtimeFilePath.length - extension.length) - return `${basename}.orca-legacy-${accountId}${extension}` -} - -function assertWithinCapacity( - reason: CodexLegacySessionSkipReason, - observed: number, - limit: number, - visitedEntryCount: number -): void { - if (!Number.isSafeInteger(observed) || observed > limit) { - throw new CodexLegacySessionCapacityError(reason, observed, limit, visitedEntryCount) - } -} - -function resolveLimits( - overrides: Partial | undefined -): CodexLegacySessionMigrationLimits { - const limits = { ...DEFAULT_CODEX_LEGACY_SESSION_MIGRATION_LIMITS, ...overrides } - for (const [name, value] of Object.entries(limits)) { - if (!Number.isSafeInteger(value) || value < 0) { - throw new RangeError(`${name} must be a non-negative safe integer`) - } - } - return limits -} - -function closeIgnoringErrors(descriptor: number | null): void { - if (descriptor === null) { - return - } - try { - closeSync(descriptor) - } catch { - // Preserve the comparison result or original read error. - } -} - -function closeDirectoryIgnoringAlreadyClosed(directory: ReturnType): void { - try { - directory.closeSync() - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ERR_DIR_CLOSED') { - throw error - } - } -} diff --git a/src/main/codex-accounts/legacy-shared-auth-migration.ts b/src/main/codex-accounts/legacy-shared-auth-migration.ts index 4f1405ba96e..b21d858cf04 100644 --- a/src/main/codex-accounts/legacy-shared-auth-migration.ts +++ b/src/main/codex-accounts/legacy-shared-auth-migration.ts @@ -1,7 +1,6 @@ -import { lstatSync } from 'node:fs' +import { lstatSync, readFileSync } from 'node:fs' import { join } from 'node:path' import type { CodexManagedAccount } from '../../shared/types' -import { readAgentStateFileSync } from '../agent-state-file-reader' import { writeFileAtomically } from './fs-utils' import { assertOwnedHostCodexManagedHomePath } from './host-codex-managed-home-ownership' import { codexAuthMatchesManagedAccount, compareCodexAuthFreshness } from './codex-auth-identity' @@ -175,7 +174,7 @@ function migrateSharedMcpCredentials( function readRegularFile(filePath: string): string | null { const state = regularFileState(filePath) - return state === 'missing' ? null : readAgentStateFileSync(filePath) + return state === 'missing' ? null : readFileSync(filePath, 'utf-8') } function regularFileState(filePath: string): 'missing' | 'present' { diff --git a/src/main/codex-accounts/runtime-home-service.ts b/src/main/codex-accounts/runtime-home-service.ts index db4e55b7929..dfa49c74cd1 100644 --- a/src/main/codex-accounts/runtime-home-service.ts +++ b/src/main/codex-accounts/runtime-home-service.ts @@ -1,22 +1,35 @@ /* eslint-disable max-lines -- Why: keeps Codex's whole runtime-home contract in one place so account-switch semantics don't drift across launch/login/quota paths. */ import { + appendFileSync, + copyFileSync, existsSync, chmodSync, lstatSync, mkdirSync, readlinkSync, + readdirSync, + readFileSync, renameSync, rmdirSync, rmSync, + statSync, symlinkSync, unlinkSync } from 'node:fs' import { execFileSync } from 'node:child_process' -import { dirname, isAbsolute, join, resolve, win32 as pathWin32 } from 'node:path' +import { + dirname, + extname, + isAbsolute, + join, + parse, + relative, + resolve, + win32 as pathWin32 +} from 'node:path' import { app } from 'electron' import type { CodexManagedAccount } from '../../shared/types' import type { Store } from '../persistence' -import { readAgentStateFileSync, readAgentStateJsonFileSync } from '../agent-state-file-reader' import { WSL_CODEX_RUNTIME_HOME_SEGMENTS } from '../pty/codex-home-wsl-env' import { writeFileAtomically } from './fs-utils' import { @@ -56,9 +69,6 @@ import { codexAuthMatchesSystemDefaultIdentity } from './codex-auth-identity' import { migrateLegacySharedAuthToPerAccountHome } from './legacy-shared-auth-migration' -import { migrateLegacyManagedCodexStateSync } from './legacy-managed-state-migration' -import { NodeFileReadTooLargeError } from '../../shared/node-bounded-file-reader' -import { CodexWslRuntimeHomeRetention } from './codex-wsl-runtime-home-retention' type CodexSystemDefaultSnapshot = { authJson: string | null @@ -104,7 +114,9 @@ export class CodexRuntimeHomeService { // Last auth.json Orca wrote to the runtime home; a later diff signals an out-of-band change (Codex token refresh, or external login to adopt). private lastWrittenAuthJson: string | null = null // Why: WSL terminals have per-distro runtime homes; sharing the host baseline can make stale WSL auth look newer than managed storage. - private readonly wslRuntimeHomeRetention = new CodexWslRuntimeHomeRetention() + private readonly lastWrittenWslAuthJsonByDistro = new Map() + private readonly lastSyncedWslAccountIdByDistro = new Map() + private readonly wslRuntimeHomePathByDistro = new Map() private skipNextReadBackForAccountId: string | null = null // Why: a flag-ON host account refreshes auth in its own home. Remember that // provenance so a later deselect/rollback never adopts stale shared bytes. @@ -628,7 +640,7 @@ export class CodexRuntimeHomeService { this.skipNextReadBackForAccountId = null } this.lastSyncedAccountId = activeAccount.id - this.writeRuntimeAuth(readAgentStateFileSync(activeAuthPath)) + this.writeRuntimeAuth(readFileSync(activeAuthPath, 'utf-8')) } // Why: re-auth/add-account write fresh managed tokens, so skip the next read-back to avoid clobbering them with stale runtime tokens. @@ -679,7 +691,7 @@ export class CodexRuntimeHomeService { options.lastWrittenAuthJson === undefined ? this.lastWrittenAuthJson : options.lastWrittenAuthJson - const runtimeContents = readAgentStateFileSync(runtimeAuthPath) + const runtimeContents = readFileSync(runtimeAuthPath, 'utf-8') if (lastWrittenAuthJson !== null && runtimeContents === lastWrittenAuthJson) { return 'unchanged' } @@ -734,7 +746,7 @@ export class CodexRuntimeHomeService { if (!existsSync(runtimeAuthPath) || this.lastWrittenAuthJson === null) { return 'rejected' } - const runtimeContents = readAgentStateFileSync(runtimeAuthPath) + const runtimeContents = readFileSync(runtimeAuthPath, 'utf-8') if (runtimeContents === this.lastWrittenAuthJson) { return 'unchanged' } @@ -791,11 +803,11 @@ export class CodexRuntimeHomeService { // Why: the system-default account changes outside Orca, so read its real home directly to avoid a stale cached runtime copy. return this.getWslSystemCodexHomePath(target) } - const cachedRuntimeHomePath = this.wslRuntimeHomeRetention.getRuntimeHomePath(distro) + const cachedRuntimeHomePath = this.wslRuntimeHomePathByDistro.get(distro) if ( cachedRuntimeHomePath && - this.wslRuntimeHomeRetention.hasLastSyncedAccountId(distro) && - this.wslRuntimeHomeRetention.getLastSyncedAccountId(distro) === selectedAccountId + this.lastSyncedWslAccountIdByDistro.has(distro) && + this.lastSyncedWslAccountIdByDistro.get(distro) === selectedAccountId ) { // Why: RateLimitService resolves provenance twice per poll; stay path-only so it doesn't block main on UNC reads and a wsl.exe probe. return cachedRuntimeHomePath @@ -824,14 +836,14 @@ export class CodexRuntimeHomeService { if (!runtimeHomePath) { return null } - this.wslRuntimeHomeRetention.setRuntimeHomePath(distro, runtimeHomePath) + this.wslRuntimeHomePathByDistro.set(distro, runtimeHomePath) mkdirSync(runtimeHomePath, { recursive: true }) this.safeMigrateLegacyWslActiveHomePointer(distro, runtimeHomePath) this.seedWslRuntimeHome(runtimeHomePath, activeAccount, distro) const runtimeAuthPath = join(runtimeHomePath, 'auth.json') - const previousWslAccountId = this.wslRuntimeHomeRetention.getLastSyncedAccountId(distro) ?? null + const previousWslAccountId = this.lastSyncedWslAccountIdByDistro.get(distro) ?? null if (previousWslAccountId) { if (this.skipNextReadBackForAccountId === previousWslAccountId) { this.skipNextReadBackForAccountId = null @@ -843,10 +855,9 @@ export class CodexRuntimeHomeService { if (previousWslAccount) { this.readBackRefreshedTokensFromPath(runtimeAuthPath, { updateLastWrittenAuthJson: true, - lastWrittenAuthJson: - this.wslRuntimeHomeRetention.getLastWrittenAuthJson(distro) ?? null, + lastWrittenAuthJson: this.lastWrittenWslAuthJsonByDistro.get(distro) ?? null, setLastWrittenAuthJson: (contents) => { - this.wslRuntimeHomeRetention.setLastWrittenAuthJson(distro, contents) + this.lastWrittenWslAuthJsonByDistro.set(distro, contents) }, expectedAccountId: previousWslAccount.id }) @@ -856,10 +867,10 @@ export class CodexRuntimeHomeService { const activeAuthPath = activeAccount ? join(activeAccount.managedHomePath, 'auth.json') : null if (activeAccount && activeAuthPath && existsSync(activeAuthPath)) { - const activeAuth = readAgentStateFileSync(activeAuthPath) + const activeAuth = readFileSync(activeAuthPath, 'utf-8') this.writeRuntimeAuthAtPath(runtimeAuthPath, activeAuth) - this.wslRuntimeHomeRetention.setLastWrittenAuthJson(distro, activeAuth) - this.wslRuntimeHomeRetention.setLastSyncedAccountId(distro, activeAccount.id) + this.lastWrittenWslAuthJsonByDistro.set(distro, activeAuth) + this.lastSyncedWslAccountIdByDistro.set(distro, activeAccount.id) return runtimeHomePath } if (activeAccount && activeAuthPath) { @@ -878,11 +889,10 @@ export class CodexRuntimeHomeService { const systemAuthPath = this.getWslSystemCodexAuthPath({ runtime: 'wsl', wslDistro: distro }) if (systemAuthPath && existsSync(systemAuthPath)) { - const systemAuth = readAgentStateFileSync(systemAuthPath) - const mirroredSystemDefaultAuth = - this.wslRuntimeHomeRetention.getLastWrittenAuthJson(distro) ?? null + const systemAuth = readFileSync(systemAuthPath, 'utf-8') + const mirroredSystemDefaultAuth = this.lastWrittenWslAuthJsonByDistro.get(distro) ?? null const runtimeAuth = existsSync(runtimeAuthPath) - ? readAgentStateFileSync(runtimeAuthPath) + ? readFileSync(runtimeAuthPath, 'utf-8') : null if ( runtimeAuth !== null && @@ -894,19 +904,19 @@ export class CodexRuntimeHomeService { ) { // Why: WSL baselines are lost on restart, so a same-identity fresher runtime auth is a token refresh; copy it back before mirroring ~/.codex. this.writeRuntimeAuthAtPath(systemAuthPath, runtimeAuth) - this.wslRuntimeHomeRetention.setLastWrittenAuthJson(distro, runtimeAuth) - this.wslRuntimeHomeRetention.setLastSyncedAccountId(distro, null) + this.lastWrittenWslAuthJsonByDistro.set(distro, runtimeAuth) + this.lastSyncedWslAccountIdByDistro.set(distro, null) return runtimeHomePath } this.writeRuntimeAuthAtPath(runtimeAuthPath, systemAuth) - this.wslRuntimeHomeRetention.setLastWrittenAuthJson(distro, systemAuth) - this.wslRuntimeHomeRetention.setLastSyncedAccountId(distro, null) + this.lastWrittenWslAuthJsonByDistro.set(distro, systemAuth) + this.lastSyncedWslAccountIdByDistro.set(distro, null) return runtimeHomePath } rmSync(runtimeAuthPath, { force: true }) - this.wslRuntimeHomeRetention.setLastWrittenAuthJson(distro, null) - this.wslRuntimeHomeRetention.setLastSyncedAccountId(distro, null) + this.lastWrittenWslAuthJsonByDistro.set(distro, null) + this.lastSyncedWslAccountIdByDistro.set(distro, null) return runtimeHomePath } @@ -938,16 +948,16 @@ export class CodexRuntimeHomeService { return } - const runtimeHomePath = this.wslRuntimeHomeRetention.getRuntimeHomePath(distro) + const runtimeHomePath = this.wslRuntimeHomePathByDistro.get(distro) if (!runtimeHomePath) { return } this.readBackRefreshedTokensFromPath(join(runtimeHomePath, 'auth.json'), { updateLastWrittenAuthJson: true, - lastWrittenAuthJson: this.wslRuntimeHomeRetention.getLastWrittenAuthJson(distro) ?? null, + lastWrittenAuthJson: this.lastWrittenWslAuthJsonByDistro.get(distro) ?? null, setLastWrittenAuthJson: (contents) => { - this.wslRuntimeHomeRetention.setLastWrittenAuthJson(distro, contents) + this.lastWrittenWslAuthJsonByDistro.set(distro, contents) }, expectedAccountId: account.id }) @@ -1046,7 +1056,7 @@ export class CodexRuntimeHomeService { if (existsSync(configPath)) { writeFileAtomically( runtimeConfigPath, - prepareWslRuntimeSeedConfig(readAgentStateFileSync(configPath), homePath) + prepareWslRuntimeSeedConfig(readFileSync(configPath, 'utf-8'), homePath) ) return } @@ -1070,7 +1080,7 @@ export class CodexRuntimeHomeService { if (!existsSync(managedAuthPath)) { continue } - const managedAuthContents = readAgentStateFileSync(managedAuthPath) + const managedAuthContents = readFileSync(managedAuthPath, 'utf-8') if (codexAuthMatchesManagedAccount(runtimeAuthContents, account, managedAuthContents)) { matches.push({ account, managedAuthPath, managedAuthContents }) } @@ -1162,6 +1172,14 @@ export class CodexRuntimeHomeService { return join(this.getRuntimeMetadataDir(), 'active', 'host', 'home') } + private getMigrationMarkerPath(): string { + return join(this.getRuntimeMetadataDir(), 'migration-v1.json') + } + + private getMigrationDiagnosticsPath(): string { + return join(this.getRuntimeMetadataDir(), 'migration-diagnostics.jsonl') + } + private getManagedAccountsRoot(): string { return join(app.getPath('userData'), 'codex-accounts') } @@ -1266,11 +1284,150 @@ export class CodexRuntimeHomeService { } private migrateLegacyManagedStateIfNeeded(): void { - migrateLegacyManagedCodexStateSync({ - managedAccountsRoot: this.getManagedAccountsRoot(), - metadataDir: this.getRuntimeMetadataDir(), - runtimeHomePath: this.getRuntimeHomePath() - }) + if (existsSync(this.getMigrationMarkerPath())) { + return + } + + const managedHomes = this.getLegacyManagedHomes() + for (const managedHomePath of managedHomes) { + const accountId = parse(relative(this.getManagedAccountsRoot(), managedHomePath)).dir.split( + /[\\/]/ + )[0] + if (!accountId) { + continue + } + this.migrateLegacyHistory(managedHomePath) + this.migrateLegacySessions(managedHomePath, accountId) + } + + // Why: migration is one-shot; re-importing every startup would replay stale managed-home state into the shared runtime. + writeFileAtomically( + this.getMigrationMarkerPath(), + `${JSON.stringify({ completedAt: Date.now(), migratedHomeCount: managedHomes.length })}\n` + ) + } + + private getLegacyManagedHomes(): string[] { + const managedAccountsRoot = this.getManagedAccountsRoot() + if (!existsSync(managedAccountsRoot)) { + return [] + } + + const accountEntries = readdirSync(managedAccountsRoot, { withFileTypes: true }) + const managedHomes: string[] = [] + for (const entry of accountEntries) { + if (!entry.isDirectory()) { + continue + } + const managedHomePath = join(managedAccountsRoot, entry.name, 'home') + if (existsSync(join(managedHomePath, '.orca-managed-home'))) { + managedHomes.push(managedHomePath) + } + } + return managedHomes.sort() + } + + private migrateLegacyHistory(managedHomePath: string): void { + const legacyHistoryPath = join(managedHomePath, 'history.jsonl') + if (!existsSync(legacyHistoryPath)) { + return + } + + const runtimeHistoryPath = join(this.getRuntimeHomePath(), 'history.jsonl') + const existingLines = existsSync(runtimeHistoryPath) + ? readFileSync(runtimeHistoryPath, 'utf-8').split('\n').filter(Boolean) + : [] + const mergedLines = [...existingLines] + const seenLines = new Set(existingLines) + for (const line of readFileSync(legacyHistoryPath, 'utf-8').split('\n')) { + if (!line || seenLines.has(line)) { + continue + } + seenLines.add(line) + mergedLines.push(line) + } + + if (mergedLines.length === 0) { + return + } + writeFileAtomically(runtimeHistoryPath, `${mergedLines.join('\n')}\n`) + } + + private migrateLegacySessions(managedHomePath: string, accountId: string): void { + const legacySessionsRoot = join(managedHomePath, 'sessions') + if (!existsSync(legacySessionsRoot)) { + return + } + + const runtimeSessionsRoot = join(this.getRuntimeHomePath(), 'sessions') + mkdirSync(runtimeSessionsRoot, { recursive: true }) + for (const legacyFilePath of this.listFilesRecursively(legacySessionsRoot)) { + const relativePath = relative(legacySessionsRoot, legacyFilePath) + const runtimeFilePath = join(runtimeSessionsRoot, relativePath) + mkdirSync(dirname(runtimeFilePath), { recursive: true }) + if (!existsSync(runtimeFilePath)) { + copyFileSync(legacyFilePath, runtimeFilePath) + continue + } + + const legacyContents = readFileSync(legacyFilePath) + const runtimeContents = readFileSync(runtimeFilePath) + if (runtimeContents.equals(legacyContents)) { + continue + } + + const preservedPath = this.getPreservedLegacySessionPath(runtimeFilePath, accountId) + copyFileSync(legacyFilePath, preservedPath) + this.appendMigrationDiagnostic({ + type: 'session-conflict', + accountId, + runtimeFilePath, + preservedPath + }) + } + } + + private listFilesRecursively(rootPath: string): string[] { + const stat = statSync(rootPath) + if (!stat.isDirectory()) { + return [rootPath] + } + + const files: string[] = [] + for (const entry of readdirSync(rootPath, { withFileTypes: true })) { + const childPath = join(rootPath, entry.name) + if (entry.isDirectory()) { + this.appendListedFiles(files, this.listFilesRecursively(childPath)) + continue + } + if (entry.isFile()) { + files.push(childPath) + } + } + return files.sort() + } + + private appendListedFiles(target: string[], source: readonly string[]): void { + // Why: tolerate directories larger than V8's argument limit for spread calls. + for (const filePath of source) { + target.push(filePath) + } + } + + private getPreservedLegacySessionPath(runtimeFilePath: string, accountId: string): string { + const extension = extname(runtimeFilePath) + const basename = runtimeFilePath.slice(0, runtimeFilePath.length - extension.length) + return `${basename}.orca-legacy-${accountId}${extension}` + } + + private appendMigrationDiagnostic(record: Record): void { + const diagnosticsPath = this.getMigrationDiagnosticsPath() + try { + appendFileSync(diagnosticsPath, `${JSON.stringify(record)}\n`, { encoding: 'utf-8' }) + } catch (error) { + // Why: diagnostics must not fail the one-shot migration after the session file is already preserved. + console.warn('[codex-runtime-home] Failed to append migration diagnostic:', error) + } } private captureSystemDefaultSnapshot(options: { force: boolean }): void { @@ -1281,7 +1438,7 @@ export class CodexRuntimeHomeService { const runtimeAuthPath = join(getSystemCodexHomePath(), 'auth.json') const snapshot: CodexSystemDefaultSnapshot = { - authJson: existsSync(runtimeAuthPath) ? readAgentStateFileSync(runtimeAuthPath) : null + authJson: existsSync(runtimeAuthPath) ? readFileSync(runtimeAuthPath, 'utf-8') : null } writeFileAtomically(snapshotPath, `${JSON.stringify(snapshot, null, 2)}\n`, { mode: 0o600 }) } @@ -1294,7 +1451,7 @@ export class CodexRuntimeHomeService { } try { - const runtimeAuth = readAgentStateFileSync(runtimeAuthPath) + const runtimeAuth = readFileSync(runtimeAuthPath, 'utf-8') if (!existsSync(systemDefaultAuthPath)) { const snapshot = this.readSystemDefaultSnapshot(this.getSystemDefaultSnapshotPath()) const mirroredSystemDefaultAuth = this.lastWrittenAuthJson ?? snapshot?.authJson ?? null @@ -1310,7 +1467,7 @@ export class CodexRuntimeHomeService { } return } - const systemDefaultAuth = readAgentStateFileSync(systemDefaultAuthPath) + const systemDefaultAuth = readFileSync(systemDefaultAuthPath, 'utf-8') if (runtimeAuth !== systemDefaultAuth) { const snapshot = this.readSystemDefaultSnapshot(this.getSystemDefaultSnapshotPath()) const mirroredSystemDefaultAuth = this.lastWrittenAuthJson ?? snapshot?.authJson ?? null @@ -1339,7 +1496,7 @@ export class CodexRuntimeHomeService { const runtimeAuthPath = this.getRuntimeAuthPath() const systemDefaultAuthPath = join(getSystemCodexHomePath(), 'auth.json') if (existsSync(systemDefaultAuthPath)) { - const systemDefaultAuth = readAgentStateFileSync(systemDefaultAuthPath) + const systemDefaultAuth = readFileSync(systemDefaultAuthPath, 'utf-8') this.captureSystemDefaultSnapshot({ force: true }) this.writeRuntimeAuth(systemDefaultAuth) return @@ -1409,7 +1566,7 @@ export class CodexRuntimeHomeService { private readSystemDefaultAuth(): string | null { const systemDefaultAuthPath = join(getSystemCodexHomePath(), 'auth.json') - return existsSync(systemDefaultAuthPath) ? readAgentStateFileSync(systemDefaultAuthPath) : null + return existsSync(systemDefaultAuthPath) ? readFileSync(systemDefaultAuthPath, 'utf-8') : null } private writeRuntimeAuth(contents: string): void { @@ -1435,11 +1592,8 @@ export class CodexRuntimeHomeService { private fileContentsEqual(targetPath: string, contents: string): boolean { try { - return existsSync(targetPath) && readAgentStateFileSync(targetPath) === contents - } catch (error) { - if (error instanceof NodeFileReadTooLargeError) { - throw error - } + return existsSync(targetPath) && readFileSync(targetPath, 'utf-8') === contents + } catch { return false } } @@ -1481,7 +1635,7 @@ export class CodexRuntimeHomeService { private readRuntimeLogoutMarker(): CodexRuntimeLogoutMarker | null { let parsed: unknown try { - parsed = readAgentStateJsonFileSync(this.getRuntimeLogoutMarkerPath()) + parsed = JSON.parse(readFileSync(this.getRuntimeLogoutMarkerPath(), 'utf-8')) as unknown } catch { return null } @@ -1511,7 +1665,7 @@ export class CodexRuntimeHomeService { private readSystemDefaultSnapshot(snapshotPath: string): CodexSystemDefaultSnapshot | null { let rawContents: string try { - rawContents = readAgentStateFileSync(snapshotPath) + rawContents = readFileSync(snapshotPath, 'utf-8') } catch { return null } diff --git a/src/main/codex-accounts/service.ts b/src/main/codex-accounts/service.ts index a7448e855f2..5c2f419ca4c 100644 --- a/src/main/codex-accounts/service.ts +++ b/src/main/codex-accounts/service.ts @@ -1,7 +1,7 @@ /* eslint-disable max-lines -- Why: keeps Codex account lifecycle, path safety, login, and identity parsing in one audited main-process module. */ import { randomUUID } from 'node:crypto' import { execFileSync, spawn, type ChildProcess } from 'node:child_process' -import { existsSync, mkdirSync, realpathSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs' import { dirname, join, resolve, sep } from 'node:path' import { homedir } from 'node:os' import { app } from 'electron' @@ -44,8 +44,6 @@ import { type CodexAccountSelectionTarget } from './runtime-selection' import { assertOwnedHostCodexManagedHomePath } from './host-codex-managed-home-ownership' -import { readAgentStateFileSync } from '../agent-state-file-reader' -import { NodeFileReadTooLargeError } from '../../shared/node-bounded-file-reader' const LOGIN_TIMEOUT_MS = 120_000 const MAX_LOGIN_OUTPUT_CHARS = 4_000 @@ -135,7 +133,7 @@ function killLoginProcessTree(child: ChildProcess): void { function readLoginAuthSnapshot(authJsonPath: string): string | null | undefined { try { - return readAgentStateFileSync(authJsonPath) + return readFileSync(authJsonPath, 'utf-8') } catch (error) { const code = (error as NodeJS.ErrnoException).code if (code === 'ENOENT' || code === 'ENOTDIR') { @@ -428,7 +426,7 @@ export class CodexAccountService { try { // Why: a single read avoids an exists/read race and halves filesystem // probes whenever an accounts snapshot resolves this live identity. - contents = readAgentStateFileSync(authFilePath) + contents = readFileSync(authFilePath, 'utf-8') } catch (error) { const code = (error as NodeJS.ErrnoException | null)?.code if (code === 'ENOENT' || code === 'ENOTDIR') { @@ -724,7 +722,7 @@ export class CodexAccountService { try { return { - contents: readAgentStateFileSync(primaryConfigPath), + contents: readFileSync(primaryConfigPath, 'utf-8'), sourceHomePath, sourceHooksPath: join(sourceHomePath, 'hooks.json') } @@ -755,7 +753,7 @@ export class CodexAccountService { // Why: the config is read over UNC but consumed by Codex inside WSL, so // path rewrites must anchor to the Linux-side ~/.codex, not the UNC path. return { - contents: readAgentStateFileSync(configPath), + contents: readFileSync(configPath, 'utf-8'), sourceHomePath: `${wslHome}/.codex`, sourceHooksPath: `${wslHome}/.codex/hooks.json` } @@ -783,13 +781,10 @@ export class CodexAccountService { private writeManagedConfig(managedHomePath: string, contents: string): void { const configPath = join(managedHomePath, 'config.toml') try { - if (existsSync(configPath) && readAgentStateFileSync(configPath) === contents) { + if (existsSync(configPath) && readFileSync(configPath, 'utf-8') === contents) { return } - } catch (error) { - if (error instanceof NodeFileReadTooLargeError) { - throw error - } + } catch { // Why: a read error must not make a stale config look current; atomic write owns ACL repair and error surfacing. } writeFileAtomically(configPath, contents) @@ -958,7 +953,7 @@ export class CodexAccountService { } if ( expectedAccountId !== undefined && - readAgentStateFileSync(join(candidatePath, '.orca-managed-home')).trim() !== + readFileSync(join(candidatePath, '.orca-managed-home'), 'utf-8').trim() !== expectedAccountId ) { throw new Error('Managed WSL Codex home ownership marker does not match its account ID.') @@ -1266,7 +1261,7 @@ export class CodexAccountService { this.assertManagedHomePath(managedHomePath, expectedAccountId), 'auth.json' ) - const authFileContents = readAgentStateFileSync(authFilePath) + const authFileContents = readFileSync(authFilePath, 'utf-8') let parsed: Record try { parsed = JSON.parse(authFileContents) as Record diff --git a/src/main/codex-cli/command.ts b/src/main/codex-cli/command.ts index bf4a3410915..177b7e0ea9c 100644 --- a/src/main/codex-cli/command.ts +++ b/src/main/codex-cli/command.ts @@ -1,7 +1,6 @@ -import { accessSync, constants, statSync } from 'node:fs' +import { accessSync, constants, existsSync, readdirSync, statSync } from 'node:fs' import { homedir } from 'node:os' import { delimiter, dirname, join } from 'node:path' -import { discoverNvmVersionBinDirectories } from './nvm-version-directory-discovery' type ResolveCommandOptions = { pathEnv?: string | null @@ -28,6 +27,29 @@ function splitPath(pathEnv: string | null | undefined): string[] { .filter(Boolean) } +function parseVersionSegment(raw: string): number[] { + return raw + .replace(/^v/i, '') + .split('.') + .map((segment) => Number.parseInt(segment, 10)) + .map((segment) => (Number.isFinite(segment) ? segment : 0)) +} + +function compareVersionDesc(left: string, right: string): number { + const leftParts = parseVersionSegment(left) + const rightParts = parseVersionSegment(right) + const length = Math.max(leftParts.length, rightParts.length) + + for (let index = 0; index < length; index += 1) { + const delta = (rightParts[index] ?? 0) - (leftParts[index] ?? 0) + if (delta !== 0) { + return delta + } + } + + return right.localeCompare(left) +} + function findFirstExecutable( platform: NodeJS.Platform, directories: string[], @@ -101,6 +123,19 @@ function getBaseVersionManagerDirectories(platform: NodeJS.Platform, homePath: s return directories } +function getNvmVersionDirectories(homePath: string): string[] { + const nvmVersionsDir = join(homePath, '.nvm', 'versions', 'node') + if (!existsSync(nvmVersionsDir)) { + return [] + } + + return readdirSync(nvmVersionsDir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort(compareVersionDesc) + .map((entry) => join(nvmVersionsDir, entry, 'bin')) +} + function getVersionManagerDirectories( platform: NodeJS.Platform, homePath: string, @@ -112,7 +147,7 @@ function getVersionManagerDirectories( // command resolution probes the newest installed Node versions explicitly. const firstNvmMatch = findFirstExecutable( platform, - discoverNvmVersionBinDirectories(homePath), + getNvmVersionDirectories(homePath), executableNames ) if (firstNvmMatch) { @@ -154,7 +189,7 @@ export function resolveCliCommands( // Why: agent detection probes many CLIs at once; compute expensive install // directories, especially nvm versions, once per detection pass. const installDirectories = [ - ...discoverNvmVersionBinDirectories(homePath), + ...getNvmVersionDirectories(homePath), ...getBaseVersionManagerDirectories(platform, homePath) ] const resolved = new Map() diff --git a/src/main/codex-cli/nvm-version-directory-discovery.test.ts b/src/main/codex-cli/nvm-version-directory-discovery.test.ts deleted file mode 100644 index c2d725dec1c..00000000000 --- a/src/main/codex-cli/nvm-version-directory-discovery.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { join } from 'node:path' -import type * as NodeFs from 'node:fs' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const opendirSyncMock = vi.hoisted(() => vi.fn()) - -vi.mock('node:fs', async (importOriginal) => ({ - ...(await importOriginal()), - opendirSync: opendirSyncMock -})) - -import { discoverNvmVersionBinDirectories } from './nvm-version-directory-discovery' - -type FakeEntry = { name: string; directory?: boolean } - -function useEntries(entries: FakeEntry[]): { closeSync: ReturnType } { - let index = 0 - const closeSync = vi.fn() - opendirSyncMock.mockReturnValue({ - closeSync, - readSync: vi.fn(() => { - const entry = entries[index] - index += 1 - return entry - ? { - name: entry.name, - isDirectory: () => entry.directory !== false - } - : null - }) - }) - return { closeSync } -} - -describe('nvm version directory discovery', () => { - beforeEach(() => { - opendirSyncMock.mockReset() - }) - - it('preserves newest-version ordering for an ordinary listing', () => { - useEntries([ - { name: 'v20.18.0' }, - { name: 'README', directory: false }, - { name: 'v24.2.0' }, - { name: 'v22.14.0' } - ]) - - expect(discoverNvmVersionBinDirectories('/home/alice')).toEqual([ - join('/home/alice', '.nvm', 'versions', 'node', 'v24.2.0', 'bin'), - join('/home/alice', '.nvm', 'versions', 'node', 'v22.14.0', 'bin'), - join('/home/alice', '.nvm', 'versions', 'node', 'v20.18.0', 'bin') - ]) - }) - - it('accepts the exact entry and retained-name limits', () => { - const { closeSync } = useEntries([{ name: 'v20' }, { name: 'v22' }]) - - expect( - discoverNvmVersionBinDirectories('/home/alice', { - maxEntries: 2, - maxRetainedNameBytes: 6 - }) - ).toHaveLength(2) - expect(closeSync).toHaveBeenCalledOnce() - }) - - it('fails closed and closes the stream on the first entry over the count limit', () => { - const { closeSync } = useEntries([ - { name: 'v20' }, - { name: 'v22' }, - { name: 'v24' }, - { name: 'v26' } - ]) - - expect( - discoverNvmVersionBinDirectories('/home/alice', { - maxEntries: 2 - }) - ).toEqual([]) - expect(closeSync).toHaveBeenCalledOnce() - }) - - it('fails closed when retained directory names exceed the byte limit', () => { - const { closeSync } = useEntries([{ name: 'v20' }, { name: 'v22' }]) - - expect( - discoverNvmVersionBinDirectories('/home/alice', { - maxRetainedNameBytes: 5 - }) - ).toEqual([]) - expect(closeSync).toHaveBeenCalledOnce() - }) -}) diff --git a/src/main/codex-cli/nvm-version-directory-discovery.ts b/src/main/codex-cli/nvm-version-directory-discovery.ts deleted file mode 100644 index c537eef95b0..00000000000 --- a/src/main/codex-cli/nvm-version-directory-discovery.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { opendirSync } from 'node:fs' -import { join } from 'node:path' - -export const NVM_VERSION_DISCOVERY_MAX_ENTRIES = 4_096 -export const NVM_VERSION_DISCOVERY_MAX_RETAINED_NAME_BYTES = 1024 * 1024 - -export type NvmVersionDiscoveryLimits = { - maxEntries: number - maxRetainedNameBytes: number -} - -function parseVersionSegment(raw: string): number[] { - return raw - .replace(/^v/i, '') - .split('.') - .map((segment) => Number.parseInt(segment, 10)) - .map((segment) => (Number.isFinite(segment) ? segment : 0)) -} - -function compareVersionDesc(left: string, right: string): number { - const leftParts = parseVersionSegment(left) - const rightParts = parseVersionSegment(right) - const length = Math.max(leftParts.length, rightParts.length) - - for (let index = 0; index < length; index += 1) { - const delta = (rightParts[index] ?? 0) - (leftParts[index] ?? 0) - if (delta !== 0) { - return delta - } - } - - return right.localeCompare(left) -} - -function resolveLimit(requested: number | undefined, maximum: number, name: string): number { - if (requested === undefined) { - return maximum - } - if (!Number.isSafeInteger(requested) || requested < 0) { - throw new RangeError(`${name} must be a non-negative safe integer`) - } - return Math.min(requested, maximum) -} - -function closeNvmVersionsDirectory(directory: ReturnType): void { - try { - directory.closeSync() - } catch { - // The OS may already have closed a fully consumed directory stream. - } -} - -export function discoverNvmVersionBinDirectories( - homePath: string, - requestedLimits: Partial = {} -): string[] { - const maxEntries = resolveLimit( - requestedLimits.maxEntries, - NVM_VERSION_DISCOVERY_MAX_ENTRIES, - 'maxEntries' - ) - const maxRetainedNameBytes = resolveLimit( - requestedLimits.maxRetainedNameBytes, - NVM_VERSION_DISCOVERY_MAX_RETAINED_NAME_BYTES, - 'maxRetainedNameBytes' - ) - const versionsDir = join(homePath, '.nvm', 'versions', 'node') - let directory: ReturnType - try { - directory = opendirSync(versionsDir, { bufferSize: 32 }) - } catch { - return [] - } - - const versionNames: string[] = [] - let scannedEntries = 0 - let retainedNameBytes = 0 - try { - for (let entry = directory.readSync(); entry !== null; entry = directory.readSync()) { - scannedEntries += 1 - if (scannedEntries > maxEntries) { - return [] - } - if (!entry.isDirectory()) { - continue - } - retainedNameBytes += Buffer.byteLength(entry.name, 'utf8') - if (retainedNameBytes > maxRetainedNameBytes) { - return [] - } - versionNames.push(entry.name) - } - } finally { - closeNvmVersionsDirectory(directory) - } - - return versionNames - .sort(compareVersionDesc) - .map((versionName) => join(versionsDir, versionName, 'bin')) -} diff --git a/src/main/codex-usage/scanner-large-directory.test.ts b/src/main/codex-usage/scanner-large-directory.test.ts index 6a7e4aa5099..1ef57ff9d03 100644 --- a/src/main/codex-usage/scanner-large-directory.test.ts +++ b/src/main/codex-usage/scanner-large-directory.test.ts @@ -1,12 +1,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { Dir, Dirent, Stats } from 'node:fs' +import type { Dirent, Stats } from 'node:fs' import type * as FsPromises from 'node:fs/promises' import { join } from 'node:path' -const { getLegacyCopiedCodexSessionBridgeScanPreferenceMock, opendirMock, statMock } = vi.hoisted( +const { getLegacyCopiedCodexSessionBridgeScanPreferenceMock, readdirMock, statMock } = vi.hoisted( () => ({ getLegacyCopiedCodexSessionBridgeScanPreferenceMock: vi.fn(), - opendirMock: vi.fn<(dirPath: string) => Promise>(), + readdirMock: vi.fn<(dirPath: string) => Promise>(), statMock: vi.fn<(filePath: string) => Promise>() }) ) @@ -15,7 +15,7 @@ vi.mock('fs/promises', async () => { const actual = await vi.importActual('fs/promises') return { ...actual, - opendir: opendirMock, + readdir: readdirMock, stat: statMock } }) @@ -49,44 +49,26 @@ const largeSessionEntries = Array.from({ length: FILE_COUNT }, (_, index) => dirent(`session-${index}.jsonl`, 'file') ) -function directory(entries: Dirent[]): Dir { - return { - async *[Symbol.asyncIterator]() { - yield* entries - } - } as Dir -} - -function generatedFileDirectory(count: number): Dir { - return { - async *[Symbol.asyncIterator]() { - for (let index = 0; index < count; index++) { - yield dirent(`session-${index}.jsonl`, 'file') - } - } - } as Dir -} - describe('listCodexSessionFiles large directories', () => { beforeEach(() => { getLegacyCopiedCodexSessionBridgeScanPreferenceMock.mockReset() getLegacyCopiedCodexSessionBridgeScanPreferenceMock.mockReturnValue(null) - opendirMock.mockReset() + readdirMock.mockReset() statMock.mockReset() }) it('keeps nested session scans past the JavaScript spread-argument limit', async () => { - opendirMock.mockImplementation(async (dirPath) => { + readdirMock.mockImplementation(async (dirPath) => { if (dirPath === RUNTIME_SESSIONS_ROOT) { - return directory([dirent('bulk', 'directory')]) + return [dirent('bulk', 'directory')] } if (dirPath === RUNTIME_BULK_DIR) { - return directory(largeSessionEntries) + return largeSessionEntries } if (dirPath === SYSTEM_SESSIONS_ROOT) { - return directory([]) + return [] } - throw new Error(`Unexpected opendir path: ${dirPath}`) + throw new Error(`Unexpected readdir path: ${dirPath}`) }) statMock.mockImplementation(async (filePath) => { const match = /session-(\d+)\.jsonl$/.exec(filePath.replaceAll('\\', '/')) @@ -101,24 +83,4 @@ describe('listCodexSessionFiles large directories', () => { await expect(listCodexSessionFiles()).resolves.toHaveLength(FILE_COUNT) expect(getLegacyCopiedCodexSessionBridgeScanPreferenceMock).not.toHaveBeenCalled() }) - - it('fails closed instead of silently omitting a rollout past capacity', async () => { - opendirMock.mockImplementation(async (dirPath) => { - if (dirPath === RUNTIME_SESSIONS_ROOT) { - return generatedFileDirectory(200_001) - } - if (dirPath === SYSTEM_SESSIONS_ROOT) { - return directory([]) - } - throw new Error(`Unexpected opendir path: ${dirPath}`) - }) - - const { listCodexSessionFiles } = await import('./scanner') - - await expect(listCodexSessionFiles()).rejects.toMatchObject({ - name: 'UsageHistoryScanCapacityError', - resource: 'files', - limit: 200_000 - }) - }) }) diff --git a/src/main/codex-usage/scanner.test.ts b/src/main/codex-usage/scanner.test.ts index e31a5b4f476..6426222461f 100644 --- a/src/main/codex-usage/scanner.test.ts +++ b/src/main/codex-usage/scanner.test.ts @@ -1,7 +1,4 @@ import { describe, expect, it, vi } from 'vitest' -import { mkdtemp, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' const { getPathMock } = vi.hoisted(() => ({ getPathMock: vi.fn<(name: string) => string>() @@ -13,8 +10,7 @@ vi.mock('electron', () => ({ } })) -import { attributeCodexUsageEvent, parseCodexUsageFile, parseCodexUsageRecord } from './scanner' -import { UsageHistoryScanBudget } from '../usage-history-scan-budget' +import { attributeCodexUsageEvent, parseCodexUsageRecord } from './scanner' describe('parseCodexUsageRecord', () => { it('uses token totals only as a duplicate baseline', () => { @@ -325,37 +321,3 @@ describe('attributeCodexUsageEvent', () => { expect(attributed?.worktreeId).toBeNull() }) }) - -describe('parseCodexUsageFile capacity', () => { - it('fails the whole parse when retained usage events exceed capacity', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-codex-capacity-')) - const filePath = join(root, 'rollout.jsonl') - const event = (timestamp: string, total: number) => - JSON.stringify({ - timestamp, - type: 'event_msg', - payload: { - type: 'token_count', - info: { - total_token_usage: { input_tokens: total, total_tokens: total }, - last_token_usage: { input_tokens: 1, total_tokens: 1 } - } - } - }) - try { - await writeFile( - filePath, - [event('2026-04-09T10:00:00.000Z', 1), event('2026-04-09T10:00:01.000Z', 2)].join('\n') - ) - const budget = new UsageHistoryScanBudget({ records: 1 }) - - await expect(parseCodexUsageFile(filePath, [], { budget })).rejects.toMatchObject({ - name: 'UsageHistoryScanCapacityError', - resource: 'records', - limit: 1 - }) - } finally { - await rm(root, { recursive: true, force: true }) - } - }) -}) diff --git a/src/main/codex-usage/scanner.ts b/src/main/codex-usage/scanner.ts index a837b8df676..eb5fbcaab14 100644 --- a/src/main/codex-usage/scanner.ts +++ b/src/main/codex-usage/scanner.ts @@ -1,21 +1,14 @@ /* eslint-disable max-lines -- Why: Codex discovery, incremental parsing, attribution, and aggregation all depend on the same event-normalization rules. Keeping them together makes the duplicate-snapshot logic easier to audit when usage totals look wrong. */ import { basename, join, win32, posix } from 'node:path' -import { existsSync } from 'node:fs' -import { realpath, stat } from 'node:fs/promises' +import { createReadStream, existsSync } from 'node:fs' +import { realpath, readdir, stat } from 'node:fs/promises' +import { createInterface } from 'node:readline' import type { Repo } from '../../shared/types' import { areWorktreePathsEqual } from '../ipc/worktree-logic' import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from '../codex/codex-home-paths' import { getCodexAccountHomeSessionDirectories } from '../codex/codex-account-home-discovery' import { getLegacyCopiedCodexSessionBridgeScanPreference } from '../codex/codex-session-bridge' import { canonicalizeUsageWorktreePaths } from '../usage-worktree-canonicalizer' -import { walkUsageHistoryJsonlFiles } from '../usage-history-file-discovery' -import { readUsageHistoryJsonlLines } from '../usage-history-jsonl-reader' -import { - MAX_USAGE_HISTORY_FILES, - UsageHistoryScanBudget, - UsageHistoryScanCapacityError, - getUsageHistoryRetainedBytes -} from '../usage-history-scan-budget' import type { CodexUsageAttributedEvent, CodexUsageDailyAggregate, @@ -100,6 +93,39 @@ async function yieldToEventLoop(): Promise { await new Promise((resolve) => setImmediate(resolve)) } +async function walkJsonlFiles( + dirPath: string, + progress: { entriesVisited: number } = { entriesVisited: 0 } +): Promise { + const entries = await readdir(dirPath, { withFileTypes: true }) + const files: string[] = [] + + for (const entry of entries) { + progress.entriesVisited += 1 + if (progress.entriesVisited % YIELD_EVERY_DISCOVERY_ENTRIES === 0) { + await yieldToEventLoop() + } + const fullPath = join(dirPath, entry.name) + if (entry.isDirectory()) { + appendDiscoveredFiles(files, await walkJsonlFiles(fullPath, progress)) + continue + } + if (entry.isFile() && entry.name.endsWith('.jsonl')) { + files.push(fullPath) + } + } + + return files +} + +function appendDiscoveredFiles(target: string[], source: readonly string[]): void { + // Why: large session directories can exceed V8's argument limit if child + // file arrays are spread into push(). + for (const filePath of source) { + target.push(filePath) + } +} + export function getCodexSessionsDirectory(): string { // Why: Orca-launched Codex processes receive an Orca-owned CODEX_HOME, so // callers that need the primary runtime path should not consult ambient @@ -122,19 +148,12 @@ function hasLegacyCopiedSessionBridgeMarkers(): boolean { return existsSync(join(getOrcaManagedCodexHomePath(), '.orca-session-copies')) } -export async function listCodexSessionFiles( - budget = new UsageHistoryScanBudget() -): Promise { +export async function listCodexSessionFiles(): Promise { const files: string[] = [] for (const dirPath of getCodexSessionDirectories()) { try { - for (const filePath of await walkUsageHistoryJsonlFiles(dirPath, budget)) { - files.push(filePath) - } - } catch (error) { - if (error instanceof UsageHistoryScanCapacityError) { - throw error - } + appendDiscoveredFiles(files, await walkJsonlFiles(dirPath)) + } catch { // Missing or unreadable history in one home should not hide the other. } } @@ -875,62 +894,6 @@ function mergeDailyAggregates( } } -function claimCodexUsageProjection( - budget: UsageHistoryScanBudget, - sessions: readonly CodexUsageSession[], - dailyAggregates: readonly CodexUsageDailyAggregate[] -): void { - for (const session of sessions) { - budget.claimProjection( - getUsageHistoryRetainedBytes([ - session.sessionId, - session.firstTimestamp, - session.lastTimestamp, - session.primaryModel, - session.primaryProjectLabel, - session.primaryWorktreeId, - session.primaryRepoId - ]) - ) - for (const location of session.locationBreakdown) { - budget.claimProjection( - getUsageHistoryRetainedBytes([ - location.locationKey, - location.projectLabel, - location.repoId, - location.worktreeId - ]) - ) - } - for (const model of session.modelBreakdown) { - budget.claimProjection(getUsageHistoryRetainedBytes([model.modelKey, model.modelLabel])) - } - for (const locationModel of session.locationModelBreakdown) { - budget.claimProjection( - getUsageHistoryRetainedBytes([ - locationModel.locationKey, - locationModel.modelKey, - locationModel.modelLabel, - locationModel.repoId, - locationModel.worktreeId - ]) - ) - } - } - for (const daily of dailyAggregates) { - budget.claimProjection( - getUsageHistoryRetainedBytes([ - daily.day, - daily.model, - daily.projectKey, - daily.projectLabel, - daily.repoId, - daily.worktreeId - ]) - ) - } -} - export function parseCodexUsageRecord( line: string, context: CodexUsageParseContext @@ -1034,14 +997,16 @@ export function parseCodexUsageRecord( export async function parseCodexUsageFile( filePath: string, worktrees: (CodexUsageWorktreeRef & { canonicalPath: string })[], - options: { - skipInitialBytes?: number - claimEventKey?: (eventKey: string) => boolean - budget?: UsageHistoryScanBudget - } = {} + options: { skipInitialBytes?: number; claimEventKey?: (eventKey: string) => boolean } = {} ): Promise { - const budget = options.budget ?? new UsageHistoryScanBudget() const processedFile = await getProcessedFileInfo(filePath) + const lines = createInterface({ + input: createReadStream(filePath, { + encoding: 'utf-8', + start: options.skipInitialBytes ?? 0 + }), + crlfDelay: Infinity + }) const events: CodexUsageAttributedEvent[] = [] const context: CodexUsageParseContext = { sessionId: basename(filePath, '.jsonl'), @@ -1056,9 +1021,7 @@ export async function parseCodexUsageFile( const ownedEventKeys = new Set() let hasDeferredClaims = false - for await (const line of readUsageHistoryJsonlLines(filePath, { - start: options.skipInitialBytes ?? 0 - })) { + for await (const line of lines) { const parsed = parseCodexUsageRecord(line, context) if (!parsed) { continue @@ -1070,18 +1033,6 @@ export async function parseCodexUsageFile( hasDeferredClaims = true continue } - budget.claimRecord( - getUsageHistoryRetainedBytes([ - parsed.sessionId, - parsed.timestamp, - parsed.eventKey, - parsed.cwd, - parsed.model - ]) - ) - if (!ownedEventKeys.has(parsed.eventKey)) { - budget.claimOwnershipKey(parsed.eventKey) - } ownedEventKeys.add(parsed.eventKey) const attributed = await attributeCodexUsageEvent(parsed, worktrees) if (attributed) { @@ -1089,11 +1040,9 @@ export async function parseCodexUsageFile( } } - const aggregates = aggregateCodexUsage(events) - claimCodexUsageProjection(budget, aggregates.sessions, aggregates.dailyAggregates) return { ...processedFile, - ...aggregates, + ...aggregateCodexUsage(events), ownedEventKeys: [...ownedEventKeys], hasDeferredClaims } @@ -1107,14 +1056,7 @@ export async function scanCodexUsageFiles( sessions: CodexUsageSession[] dailyAggregates: CodexUsageDailyAggregate[] }> { - if (previousProcessedFiles.length > MAX_USAGE_HISTORY_FILES) { - throw new UsageHistoryScanCapacityError('files', MAX_USAGE_HISTORY_FILES) - } - const budget = new UsageHistoryScanBudget() - const files = await listCodexSessionFiles(budget) - for (const previous of previousProcessedFiles) { - budget.claimPath(previous.path) - } + const files = await listCodexSessionFiles() const previousByPath = new Map(previousProcessedFiles.map((file) => [file.path, file])) const worktreesWithCanonicalPaths = await buildWorktreesWithCanonicalPaths(worktrees) const legacySourceSkipBytesByPath = getLegacySourceSkipBytesByPath(files) @@ -1165,12 +1107,7 @@ export async function scanCodexUsageFiles( // deterministic. const eventOwnerByKey = new Map() for (const [filePath, previous] of reusedByPath) { - for (const session of previous.sessions) { - budget.claimRecords(session.eventCount) - } - claimCodexUsageProjection(budget, previous.sessions, previous.dailyAggregates) for (const eventKey of previous.ownedEventKeys) { - budget.claimOwnershipKey(eventKey) // First cached claim wins so conflicting projections stay deterministic. if (!eventOwnerByKey.has(eventKey)) { eventOwnerByKey.set(eventKey, filePath) @@ -1182,7 +1119,6 @@ export async function scanCodexUsageFiles( for (const [index, filePath] of pathsToParse.entries()) { const processed = await parseCodexUsageFile(filePath, worktreesWithCanonicalPaths, { skipInitialBytes: legacySourceSkipBytesByPath.get(filePath) ?? 0, - budget, claimEventKey: (eventKey) => { const owner = eventOwnerByKey.get(eventKey) if (owner !== undefined && owner !== filePath) { @@ -1215,17 +1151,14 @@ export async function scanCodexUsageFiles( mergeDailyAggregates(dailyByKey, processed.dailyAggregates) } - const sessions = finalizeSessions(sessionsById) - const dailyAggregates = [...dailyByKey.values()].sort((left, right) => - left.day === right.day - ? left.projectLabel.localeCompare(right.projectLabel) - : left.day.localeCompare(right.day) - ) - claimCodexUsageProjection(budget, sessions, dailyAggregates) return { processedFiles, - sessions, - dailyAggregates + sessions: finalizeSessions(sessionsById), + dailyAggregates: [...dailyByKey.values()].sort((left, right) => + left.day === right.day + ? left.projectLabel.localeCompare(right.projectLabel) + : left.day.localeCompare(right.day) + ) } } diff --git a/src/main/codex-usage/store.ts b/src/main/codex-usage/store.ts index b873d9ae9eb..ca42ba34061 100644 --- a/src/main/codex-usage/store.ts +++ b/src/main/codex-usage/store.ts @@ -1,6 +1,7 @@ /* eslint-disable max-lines -- Why: this store owns Codex analytics persistence, scan policy, and renderer query semantics. Keeping them together prevents the Codex range/scope rules from drifting away from the scanner’s event model. */ import { app } from 'electron' -import { join } from 'node:path' +import { dirname, join } from 'node:path' +import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs' import type { CodexUsageBreakdownKind, CodexUsageBreakdownRow, @@ -14,10 +15,6 @@ import type { } from '../../shared/codex-usage-types' import type { AutomationRunUsage } from '../../shared/automations-types' import type { Store } from '../persistence' -import { - readUsageProjectionStateFile, - writeUsageProjectionStateFileWithRecovery -} from '../usage-projection-state-file' import { loadKnownUsageWorktreesByRepo, type UsageWorktreeRef } from '../usage-worktree-metadata' import type { CodexUsagePersistedState } from './types' import { createWorktreeRefs, scanCodexUsageFiles } from './scanner' @@ -339,11 +336,10 @@ export class CodexUsageStore { private load(): CodexUsagePersistedState { try { const usageFile = getCodexUsageFile() - const raw = readUsageProjectionStateFile(usageFile) - if (raw === null) { + if (!existsSync(usageFile)) { return getDefaultState() } - const parsed = JSON.parse(raw) as CodexUsagePersistedState + const parsed = JSON.parse(readFileSync(usageFile, 'utf-8')) as CodexUsagePersistedState return normalizePersistedState({ ...getDefaultState(), ...parsed, @@ -360,12 +356,13 @@ export class CodexUsageStore { private writeToDisk(): void { const usageFile = getCodexUsageFile() - this.state = writeUsageProjectionStateFileWithRecovery(usageFile, this.state, (error) => { - const reset = getDefaultState() - reset.scanState.enabled = this.state.scanState.enabled - reset.scanState.lastScanError = error.message - return reset - }) + const dir = dirname(usageFile) + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }) + } + const tmpFile = `${usageFile}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp` + writeFileSync(tmpFile, JSON.stringify(this.state), 'utf-8') + renameSync(tmpFile, usageFile) } async setEnabled(enabled: boolean): Promise { diff --git a/src/main/codex/codex-account-home-discovery.test.ts b/src/main/codex/codex-account-home-discovery.test.ts deleted file mode 100644 index 5e973a4e386..00000000000 --- a/src/main/codex/codex-account-home-discovery.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const { pathState, ownershipMock } = vi.hoisted(() => ({ - pathState: { userData: '' }, - ownershipMock: vi.fn() -})) - -vi.mock('./codex-home-paths', () => ({ - getOrcaUserDataPath: () => pathState.userData, - getSystemCodexHomePath: () => join(pathState.userData, 'system-codex') -})) - -vi.mock('../codex-accounts/host-codex-managed-home-ownership', () => ({ - assertOwnedHostCodexManagedHomePath: ownershipMock -})) - -import { getCodexAccountHomeSessionDirectories } from './codex-account-home-discovery' - -let root = '' -let accountsRoot = '' - -async function createAccount(accountId: string): Promise { - const sessionsPath = join(accountsRoot, accountId, 'home', 'sessions') - await mkdir(sessionsPath, { recursive: true }) - return sessionsPath -} - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-codex-account-discovery-')) - pathState.userData = root - accountsRoot = join(root, 'codex-accounts') - await mkdir(accountsRoot) - ownershipMock.mockReset() -}) - -afterEach(async () => { - vi.restoreAllMocks() - await rm(root, { recursive: true, force: true }) -}) - -describe('Codex account-home discovery limits', () => { - it('preserves sorted valid account homes below every limit', async () => { - const later = await createAccount('z-account') - const earlier = await createAccount('a-account') - await writeFile(join(accountsRoot, 'not-an-account'), 'x') - - expect(getCodexAccountHomeSessionDirectories()).toEqual([earlier, later]) - }) - - it('accepts the exact entry limit and fails closed on the next entry', async () => { - const sessionsPath = await createAccount('account-1') - await writeFile(join(accountsRoot, 'ignored'), 'x') - - expect(getCodexAccountHomeSessionDirectories({ maxEntries: 2 })).toEqual([sessionsPath]) - await writeFile(join(accountsRoot, 'overflow'), 'x') - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - - expect(getCodexAccountHomeSessionDirectories({ maxEntries: 2 })).toEqual([]) - expect(warnSpy).toHaveBeenCalledWith( - '[codex-usage] Account-home discovery exceeded 2 entries; skipping homes' - ) - }) - - it('accepts the exact home limit and fails closed on the next valid home', async () => { - await createAccount('account-1') - await createAccount('account-2') - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - - expect(getCodexAccountHomeSessionDirectories({ maxHomes: 2 })).toHaveLength(2) - expect(getCodexAccountHomeSessionDirectories({ maxHomes: 1 })).toEqual([]) - expect(warnSpy).toHaveBeenCalledWith( - '[codex-usage] Account-home discovery exceeded 1 homes; skipping homes' - ) - }) - - it('bounds retained account and session path strings at the exact code-unit limit', async () => { - const accountId = 'account-1' - const sessionsPath = await createAccount(accountId) - const accountHome = join(accountsRoot, accountId, 'home') - const exactPathCodeUnits = accountId.length + accountHome.length + sessionsPath.length - - expect(getCodexAccountHomeSessionDirectories({ maxPathCodeUnits: exactPathCodeUnits })).toEqual( - [sessionsPath] - ) - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - expect( - getCodexAccountHomeSessionDirectories({ maxPathCodeUnits: exactPathCodeUnits - 1 }) - ).toEqual([]) - expect(warnSpy).toHaveBeenCalledWith( - `[codex-usage] Account-home discovery exceeded ${exactPathCodeUnits - 1} path code units; skipping homes` - ) - }) -}) diff --git a/src/main/codex/codex-account-home-discovery.ts b/src/main/codex/codex-account-home-discovery.ts index 72f6d6ee504..d8d30bcd922 100644 --- a/src/main/codex/codex-account-home-discovery.ts +++ b/src/main/codex/codex-account-home-discovery.ts @@ -1,102 +1,34 @@ -import { lstatSync, opendirSync } from 'node:fs' +import { lstatSync, readdirSync } from 'node:fs' import { join } from 'node:path' import { getOrcaUserDataPath, getSystemCodexHomePath } from './codex-home-paths' import { assertOwnedHostCodexManagedHomePath } from '../codex-accounts/host-codex-managed-home-ownership' -export const CODEX_ACCOUNT_HOME_DISCOVERY_MAX_ENTRIES = 4096 -export const CODEX_ACCOUNT_HOME_DISCOVERY_MAX_HOMES = 256 -export const CODEX_ACCOUNT_HOME_DISCOVERY_MAX_PATH_CODE_UNITS = 1024 * 1024 - -export type CodexAccountHomeDiscoveryLimits = { - maxEntries: number - maxHomes: number - maxPathCodeUnits: number -} - /** Session roots of per-account self-contained host Codex homes present on disk. * Why disk-enumerated, not settings-driven: rollouts retained after an account * change must still be counted, and CLI callers have no settings store. WSL * account homes live inside their distro and are scanned by their own lane. */ -export function getCodexAccountHomeSessionDirectories( - limitOverrides: Partial = {} -): string[] { - const limits: CodexAccountHomeDiscoveryLimits = { - maxEntries: CODEX_ACCOUNT_HOME_DISCOVERY_MAX_ENTRIES, - maxHomes: CODEX_ACCOUNT_HOME_DISCOVERY_MAX_HOMES, - maxPathCodeUnits: CODEX_ACCOUNT_HOME_DISCOVERY_MAX_PATH_CODE_UNITS, - ...limitOverrides - } - for (const [name, value] of Object.entries(limits)) { - if (!Number.isSafeInteger(value) || value < 0) { - throw new RangeError(`${name} must be a non-negative safe integer`) - } - } - +export function getCodexAccountHomeSessionDirectories(): string[] { const accountsRoot = join(getOrcaUserDataPath(), 'codex-accounts') try { - const accountIds: string[] = [] - let entryCount = 0 - let pathCodeUnits = 0 - const directory = opendirSync(accountsRoot) - try { - for (let entry = directory.readSync(); entry !== null; entry = directory.readSync()) { - entryCount += 1 - if (entryCount > limits.maxEntries) { - return failCodexAccountHomeDiscovery('entries', limits.maxEntries) + return readdirSync(accountsRoot, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .flatMap((entry) => { + const accountHome = join(accountsRoot, entry.name, 'home') + try { + assertOwnedHostCodexManagedHomePath({ + candidatePath: accountHome, + managedAccountsRoot: accountsRoot, + systemCodexHomePath: getSystemCodexHomePath(), + expectedAccountId: entry.name + }) + const sessionsPath = join(accountHome, 'sessions') + // Why: a redirected sessions root could make usage scan unrelated, unbounded trees. + return lstatSync(sessionsPath).isDirectory() ? [sessionsPath] : [] + } catch { + return [] } - pathCodeUnits += entry.name.length - if (pathCodeUnits > limits.maxPathCodeUnits) { - return failCodexAccountHomeDiscovery('path code units', limits.maxPathCodeUnits) - } - if (entry.isDirectory()) { - accountIds.push(entry.name) - } - } - } finally { - closeCodexAccountDirectory(directory) - } - - const sessionDirectories: string[] = [] - for (const accountId of accountIds.sort()) { - const accountHome = join(accountsRoot, accountId, 'home') - const sessionsPath = join(accountHome, 'sessions') - pathCodeUnits += accountHome.length + sessionsPath.length - if (pathCodeUnits > limits.maxPathCodeUnits) { - return failCodexAccountHomeDiscovery('path code units', limits.maxPathCodeUnits) - } - try { - assertOwnedHostCodexManagedHomePath({ - candidatePath: accountHome, - managedAccountsRoot: accountsRoot, - systemCodexHomePath: getSystemCodexHomePath(), - expectedAccountId: accountId - }) - // Why: a redirected sessions root could make usage scan unrelated, unbounded trees. - if (lstatSync(sessionsPath).isDirectory()) { - sessionDirectories.push(sessionsPath) - if (sessionDirectories.length > limits.maxHomes) { - return failCodexAccountHomeDiscovery('homes', limits.maxHomes) - } - } - } catch { - // A missing, redirected, or invalid account home is not a usage source. - } - } - return sessionDirectories + }) } catch { return [] } } - -function closeCodexAccountDirectory(directory: ReturnType): void { - try { - directory.closeSync() - } catch { - // The OS may have already closed a failed directory stream. - } -} - -function failCodexAccountHomeDiscovery(resource: string, limit: number): [] { - console.warn(`[codex-usage] Account-home discovery exceeded ${limit} ${resource}; skipping homes`) - return [] -} diff --git a/src/main/codex/codex-app-server-capability-cache.test.ts b/src/main/codex/codex-app-server-capability-cache.test.ts index 632fa1b8e91..c6dea6e0031 100644 --- a/src/main/codex/codex-app-server-capability-cache.test.ts +++ b/src/main/codex/codex-app-server-capability-cache.test.ts @@ -2,10 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import { CODEX_APP_SERVER_CAPABILITY_RETRY_INTERVAL_MS, CodexAppServerCapabilityCache, - getCodexAppServerHostKey, - MAX_CODEX_APP_SERVER_CAPABILITY_HOSTS, - MAX_CODEX_APP_SERVER_HOST_KEY_CODE_UNITS, - type CodexAppServerHostKey + getCodexAppServerHostKey } from './codex-app-server-capability-cache' const unsupportedError = new Error('unsupported') @@ -117,48 +114,4 @@ describe('CodexAppServerCapabilityCache', () => { expect(getCodexAppServerHostKey({ kind: 'wsl', distro: 'Ubuntu' })).toBe('wsl:Ubuntu') expect(getCodexAppServerHostKey({ kind: 'wsl', distro: 'Debian' })).toBe('wsl:Debian') }) - - it('retains exactly the host limit and LRU-evicts on the next host', () => { - const cache = new CodexAppServerCapabilityCache() - const hostKeys = Array.from( - { length: MAX_CODEX_APP_SERVER_CAPABILITY_HOSTS }, - (_, index): CodexAppServerHostKey => `wsl:distro-${index}` - ) - hostKeys.forEach((hostKey) => cache.rememberUnsupported(hostKey, 0)) - - expect(cache.shouldTry(hostKeys[0], 1)).toBe(false) - cache.rememberUnsupported('wsl:overflow', 0) - - expect(cache.shouldTry(hostKeys[0], 1)).toBe(false) - expect(cache.shouldTry(hostKeys[1], 1)).toBe(true) - expect(cache.shouldTry('wsl:overflow', 1)).toBe(false) - }) - - it('bounds oversized generated and direct host keys', () => { - const exactDistro = 'x'.repeat(MAX_CODEX_APP_SERVER_HOST_KEY_CODE_UNITS - 'wsl:'.length) - const oversizedDistro = `${exactDistro}x` - - expect(getCodexAppServerHostKey({ kind: 'wsl', distro: exactDistro })).toBe( - `wsl:${exactDistro}` - ) - const firstDigest = getCodexAppServerHostKey({ kind: 'wsl', distro: oversizedDistro }) - expect(firstDigest).toMatch(/^wsl:sha256:[a-f0-9]{64}$/) - expect(getCodexAppServerHostKey({ kind: 'wsl', distro: oversizedDistro })).toBe(firstDigest) - - const directOversized = `wsl:${oversizedDistro}` as CodexAppServerHostKey - const cache = new CodexAppServerCapabilityCache() - cache.rememberUnsupported(directOversized, 0) - expect(cache.shouldTry(directOversized, 1)).toBe(false) - }) - - it('clear forgets supported and unsupported host state', () => { - const cache = new CodexAppServerCapabilityCache() - cache.rememberSupported('native') - cache.rememberUnsupported('wsl:Ubuntu', 0) - - cache.clear() - - expect(cache.isKnownSupported('native')).toBe(false) - expect(cache.shouldTry('wsl:Ubuntu', 1)).toBe(true) - }) }) diff --git a/src/main/codex/codex-app-server-capability-cache.ts b/src/main/codex/codex-app-server-capability-cache.ts index 1decbbf29da..85816c9cf0c 100644 --- a/src/main/codex/codex-app-server-capability-cache.ts +++ b/src/main/codex/codex-app-server-capability-cache.ts @@ -1,36 +1,18 @@ -import { createHash } from 'node:crypto' - // Why: suppress a known-missing RPC surface without pinning it forever — an // in-place codex upgrade during a long Orca session self-heals after the // interval, mirroring GitCapabilityCache's rationale. export const CODEX_APP_SERVER_CAPABILITY_RETRY_INTERVAL_MS = 30 * 60_000 -export const MAX_CODEX_APP_SERVER_CAPABILITY_HOSTS = 64 -export const MAX_CODEX_APP_SERVER_HOST_KEY_CODE_UNITS = 4 * 1024 /** Execution host that runs the codex binary. WSL distros are isolated from * the native host and from each other — each can carry a different codex. */ export type CodexAppServerHostKey = 'native' | `wsl:${string}` -function boundHostKey(hostKey: CodexAppServerHostKey): CodexAppServerHostKey { - if (hostKey.length <= MAX_CODEX_APP_SERVER_HOST_KEY_CODE_UNITS) { - return hostKey - } - return `wsl:sha256:${createHash('sha256').update(hostKey.slice('wsl:'.length)).digest('hex')}` -} - export function getCodexAppServerHostKey( host: { kind: 'native' } | { kind: 'wsl'; distro: string } ): CodexAppServerHostKey { - if (host.kind === 'native') { - return 'native' - } - return boundHostKey(`wsl:${host.distro}`) + return host.kind === 'wsl' ? `wsl:${host.distro}` : 'native' } -type CodexAppServerCapabilityState = - | { kind: 'supported' } - | { kind: 'unsupported'; retryAfterMs: number } - /** * Capability cache for the codex app-server trust-grant RPC pair, modeled on * GitCapabilityCache but with a synchronous runner: the grant client blocks @@ -38,56 +20,33 @@ type CodexAppServerCapabilityState = * unsupported mark alone is what keeps later installs off the dead probe. */ export class CodexAppServerCapabilityCache { - private readonly stateByHost = new Map() - - private getState(hostKey: CodexAppServerHostKey): CodexAppServerCapabilityState | undefined { - const retainedHostKey = boundHostKey(hostKey) - const state = this.stateByHost.get(retainedHostKey) - if (state) { - this.stateByHost.delete(retainedHostKey) - this.stateByHost.set(retainedHostKey, state) - } - return state - } - - private remember(hostKey: CodexAppServerHostKey, state: CodexAppServerCapabilityState): void { - const retainedHostKey = boundHostKey(hostKey) - this.stateByHost.delete(retainedHostKey) - this.stateByHost.set(retainedHostKey, state) - while (this.stateByHost.size > MAX_CODEX_APP_SERVER_CAPABILITY_HOSTS) { - const oldestHost = this.stateByHost.keys().next().value - if (oldestHost === undefined) { - return - } - this.stateByHost.delete(oldestHost) - } - } + private readonly retryAfterByHost = new Map() + private readonly supportedHosts = new Set() shouldTry(hostKey: CodexAppServerHostKey, nowMs = Date.now()): boolean { - const state = this.getState(hostKey) - if (!state || state.kind === 'supported') { + const retryAfterMs = this.retryAfterByHost.get(hostKey) + if (retryAfterMs === undefined) { return true } - if (nowMs < state.retryAfterMs) { + if (nowMs < retryAfterMs) { return false } - this.stateByHost.delete(boundHostKey(hostKey)) + this.retryAfterByHost.delete(hostKey) return true } isKnownSupported(hostKey: CodexAppServerHostKey): boolean { - return this.getState(hostKey)?.kind === 'supported' + return this.supportedHosts.has(hostKey) } rememberUnsupported(hostKey: CodexAppServerHostKey, nowMs = Date.now()): void { - this.remember(hostKey, { - kind: 'unsupported', - retryAfterMs: nowMs + CODEX_APP_SERVER_CAPABILITY_RETRY_INTERVAL_MS - }) + this.supportedHosts.delete(hostKey) + this.retryAfterByHost.set(hostKey, nowMs + CODEX_APP_SERVER_CAPABILITY_RETRY_INTERVAL_MS) } rememberSupported(hostKey: CodexAppServerHostKey): void { - this.remember(hostKey, { kind: 'supported' }) + this.retryAfterByHost.delete(hostKey) + this.supportedHosts.add(hostKey) } runWithFallbackSync( @@ -97,7 +56,7 @@ export class CodexAppServerCapabilityCache { isUnsupportedError: (error: unknown) => boolean, nowMs = Date.now() ): T { - if (!this.shouldTry(hostKey, nowMs)) { + if (!this.supportedHosts.has(hostKey) && !this.shouldTry(hostKey, nowMs)) { return runFallback() } try { @@ -117,7 +76,8 @@ export class CodexAppServerCapabilityCache { } clear(): void { - this.stateByHost.clear() + this.retryAfterByHost.clear() + this.supportedHosts.clear() } } diff --git a/src/main/codex/codex-app-server-fragment-memory.test.ts b/src/main/codex/codex-app-server-fragment-memory.test.ts deleted file mode 100644 index d4336d704df..00000000000 --- a/src/main/codex/codex-app-server-fragment-memory.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import type { ChildProcess, ChildProcessWithoutNullStreams, spawn } from 'node:child_process' -import { EventEmitter } from 'node:events' -import { PassThrough } from 'node:stream' -import { describe, expect, it, vi } from 'vitest' -import { runCodexAppServerSession } from './codex-app-server-session' - -describe('runCodexAppServerSession fragmented output', () => { - it('parses a response delivered as 100,000 one-byte fragments', async () => { - const child = new EventEmitter() as ChildProcessWithoutNullStreams - child.stdin = new PassThrough() - child.stdout = new PassThrough() - child.stderr = new PassThrough() - child.kill = vi.fn(() => true) as ChildProcess['kill'] - child.stdin.on('data', (bytes: Buffer) => { - const request = JSON.parse(bytes.toString()) as { id?: number; method: string } - if (request.id === undefined) { - return - } - const response = Buffer.from( - `${request.method === 'fragment/get' ? ' '.repeat(99_950) : ''}${JSON.stringify({ - id: request.id, - result: request.method === 'fragment/get' ? { value: 'complete' } : {} - })}\n` - ) - for (let index = 0; index < response.byteLength; index += 1) { - child.stdout.emit('data', response.subarray(index, index + 1)) - } - }) - child.stdin.once('finish', () => { - queueMicrotask(() => { - child.emit('exit', 0, null) - child.emit('close', 0, null) - }) - }) - const spawnImpl = vi.fn(() => child) as unknown as typeof spawn - - const result = await runCodexAppServerSession( - { command: 'codex', args: ['app-server'], timeoutMs: 5_000 }, - ({ request }) => request('fragment/get'), - spawnImpl - ) - - expect(result).toEqual({ value: 'complete' }) - }) -}) diff --git a/src/main/codex/codex-app-server-grant-bridge.ts b/src/main/codex/codex-app-server-grant-bridge.ts index c20d7090462..c85c4a1c479 100644 --- a/src/main/codex/codex-app-server-grant-bridge.ts +++ b/src/main/codex/codex-app-server-grant-bridge.ts @@ -16,10 +16,6 @@ import type { CodexUserHookTrustRebaseRequest, CodexUserHookTrustRebaseResult } from './codex-user-hook-trust-rebase-client' -import { - findLastNonEmptyCodexAppServerGrantLine, - parseCodexAppServerGrantJson -} from './codex-app-server-grant-json' // Why: hook install/refresh is synchronous launch prep — a Codex pane must // not start before its trust is settled — but a stdio JSON-RPC session needs @@ -118,11 +114,12 @@ function runCodexAppServerEntrySync( `codex trust-grant entry killed by ${spawned.signal} after ${request.invocation.timeoutMs}ms deadline` ) } - const lastLine = findLastNonEmptyCodexAppServerGrantLine(spawned.stdout ?? '') + const lines = (spawned.stdout ?? '').split('\n').filter((line) => line.trim().length > 0) + const lastLine = lines.at(-1) let envelope: GrantEntryEnvelope | null = null if (lastLine) { try { - envelope = parseCodexAppServerGrantJson(lastLine) + envelope = JSON.parse(lastLine) as GrantEntryEnvelope } catch { envelope = null } diff --git a/src/main/codex/codex-app-server-grant-entry.ts b/src/main/codex/codex-app-server-grant-entry.ts index 14c465391ce..8e16892d279 100644 --- a/src/main/codex/codex-app-server-grant-entry.ts +++ b/src/main/codex/codex-app-server-grant-entry.ts @@ -11,21 +11,22 @@ import { import { writeSync } from 'node:fs' import { runCodexHookTrustGrantSession } from './codex-app-server-client' import { runCodexUserHookTrustRebaseSession } from './codex-user-hook-trust-rebase-client' -import { readNodeReadableTextWithinLimit } from '../../shared/node-readable-text' -import { parseCodexAppServerGrantJson } from './codex-app-server-grant-json' const HARD_EXIT_MARGIN_MS = 2_000 -const GRANT_ENTRY_MAX_INPUT_BYTES = 16 * 1024 * 1024 async function readStdin(): Promise { - return await readNodeReadableTextWithinLimit(process.stdin, GRANT_ENTRY_MAX_INPUT_BYTES) + const chunks: Buffer[] = [] + for await (const chunk of process.stdin) { + chunks.push(chunk as Buffer) + } + return Buffer.concat(chunks).toString('utf8') } async function main(): Promise { const raw = await readStdin() let request: CodexAppServerEntryRequest try { - request = parseCodexAppServerGrantJson(raw) + request = JSON.parse(raw) as CodexAppServerEntryRequest } catch (error) { process.stdout.write( `${JSON.stringify({ diff --git a/src/main/codex/codex-app-server-grant-json.test.ts b/src/main/codex/codex-app-server-grant-json.test.ts deleted file mode 100644 index 8a2178b799b..00000000000 --- a/src/main/codex/codex-app-server-grant-json.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { - CODEX_APP_SERVER_GRANT_JSON_MAX_STRUCTURAL_TOKENS, - findLastNonEmptyCodexAppServerGrantLine, - parseCodexAppServerGrantJson -} from './codex-app-server-grant-json' - -describe('Codex app-server grant JSON admission', () => { - it('preserves the last non-empty output line without splitting all output', () => { - expect(findLastNonEmptyCodexAppServerGrantLine('diagnostic\n\n{"ok":true}\n \t\r')).toBe( - '{"ok":true}' - ) - expect(findLastNonEmptyCodexAppServerGrantLine('\n \t\r')).toBeNull() - }) - - it('rejects structurally amplified grant JSON before parsing', () => { - const text = `{"values":[${'0,'.repeat(CODEX_APP_SERVER_GRANT_JSON_MAX_STRUCTURAL_TOKENS)}0]}` - const parseSpy = vi.spyOn(JSON, 'parse') - try { - expect(() => parseCodexAppServerGrantJson(text)).toThrow(/JSON structure exceeds/) - expect(parseSpy).not.toHaveBeenCalled() - } finally { - parseSpy.mockRestore() - } - }) -}) diff --git a/src/main/codex/codex-app-server-grant-json.ts b/src/main/codex/codex-app-server-grant-json.ts deleted file mode 100644 index 47cb25d3f4b..00000000000 --- a/src/main/codex/codex-app-server-grant-json.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { assertJsonTextStructureWithinLimits } from '../../shared/json-text-structure-limit' - -export const CODEX_APP_SERVER_GRANT_JSON_MAX_STRUCTURAL_TOKENS = 1_000_000 -export const CODEX_APP_SERVER_GRANT_JSON_MAX_NESTING_DEPTH = 128 - -export function parseCodexAppServerGrantJson(text: string): T { - assertJsonTextStructureWithinLimits(text, { - structuralTokens: CODEX_APP_SERVER_GRANT_JSON_MAX_STRUCTURAL_TOKENS, - nestingDepth: CODEX_APP_SERVER_GRANT_JSON_MAX_NESTING_DEPTH - }) - return JSON.parse(text) as T -} - -export function findLastNonEmptyCodexAppServerGrantLine(output: string): string | null { - let end = output.length - while (end > 0) { - const newline = output.lastIndexOf('\n', end - 1) - const start = newline + 1 - const line = output.slice(start, end) - if (hasNonWhitespace(line)) { - return line - } - end = newline === -1 ? 0 : newline - } - return null -} - -function hasNonWhitespace(value: string): boolean { - for (let index = 0; index < value.length; index += 1) { - if (!isEcmaWhitespace(value.charCodeAt(index))) { - return true - } - } - return false -} - -function isEcmaWhitespace(code: number): boolean { - return ( - code === 0x20 || - (code >= 0x09 && code <= 0x0d) || - code === 0xa0 || - code === 0x1680 || - (code >= 0x2000 && code <= 0x200a) || - code === 0x2028 || - code === 0x2029 || - code === 0x202f || - code === 0x205f || - code === 0x3000 || - code === 0xfeff - ) -} diff --git a/src/main/codex/codex-app-server-session.ts b/src/main/codex/codex-app-server-session.ts index eea85544eb9..04748e6eaa2 100644 --- a/src/main/codex/codex-app-server-session.ts +++ b/src/main/codex/codex-app-server-session.ts @@ -1,5 +1,4 @@ import { spawn, type ChildProcess, type ChildProcessWithoutNullStreams } from 'node:child_process' -import { GrowingByteBuffer } from '../../shared/growing-byte-buffer' import { waitForProcessExitUntil } from './codex-process-exit-deadline' import { stderrIndicatesMissingAppServer } from './codex-app-server-capability-signal' @@ -118,7 +117,7 @@ export async function runCodexAppServerSession( windowsHide: true }) as ChildProcessWithoutNullStreams - const stderrTail = new GrowingByteBuffer() + let stderrTail = '' let exited = false let nextRequestId = 1 let timedOut = false @@ -146,15 +145,10 @@ export async function runCodexAppServerSession( child.on('close', () => { failPending(buildEarlyExitError()) }) - // Why: retain raw bytes so split UTF-8 code points decode only after a full line arrives. - child.stderr.on('data', (chunk: Buffer) => { - if (chunk.byteLength >= STDERR_TAIL_MAX_BYTES) { - stderrTail.clear() - stderrTail.append(chunk.subarray(chunk.byteLength - STDERR_TAIL_MAX_BYTES)) - return - } - stderrTail.append(chunk) - stderrTail.retainSuffix(STDERR_TAIL_MAX_BYTES) + // Why: JSONL can contain non-ASCII hook paths. Stream decoding must retain a + // multibyte character split across pipe chunks or the response becomes invalid JSON. + child.stderr.setEncoding('utf8').on('data', (chunk: string) => { + stderrTail = (stderrTail + chunk).slice(-STDERR_TAIL_MAX_BYTES) }) // Why: a child can exit between the liveness check and stdin.write(); an // EPIPE must reject the RPC instead of becoming an unhandled stream error. @@ -162,25 +156,21 @@ export async function runCodexAppServerSession( failPending(error) }) - const stdoutBuffer = new GrowingByteBuffer() - child.stdout.on('data', (chunk: Buffer) => { - stdoutBuffer.append(chunk) - if (stdoutBuffer.byteLength > STDOUT_LINE_MAX_BYTES) { + let stdoutBuffer = '' + child.stdout.setEncoding('utf8').on('data', (chunk: string) => { + stdoutBuffer += chunk + if (Buffer.byteLength(stdoutBuffer) > STDOUT_LINE_MAX_BYTES) { // Why: Windows process-tree termination is asynchronous; stop buffered // chunks from spawning another taskkill for the same oversized response. child.stdout.destroy() - stdoutBuffer.clear() killCodexAppServerProcessTree(child) failPending(new Error('codex app-server emitted an oversized JSONL response')) return } - while (true) { - const newlineIndex = stdoutBuffer.indexOfByte(0x0a) - if (newlineIndex === -1) { - break - } - const line = stdoutBuffer.takePrefixString(newlineIndex).trim() - stdoutBuffer.discardPrefix(1) + let newlineIndex + while ((newlineIndex = stdoutBuffer.indexOf('\n')) !== -1) { + const line = stdoutBuffer.slice(0, newlineIndex).trim() + stdoutBuffer = stdoutBuffer.slice(newlineIndex + 1) if (!line) { continue } @@ -273,14 +263,13 @@ export async function runCodexAppServerSession( } function buildEarlyExitError(): Error { - const stderr = stderrTail.toString() - if (stderrIndicatesMissingAppServer(stderr)) { + if (stderrIndicatesMissingAppServer(stderrTail)) { return new CodexAppServerUnsupportedError( - `codex CLI does not support the app-server subcommand: ${stderr.trim().slice(0, 400)}` + `codex CLI does not support the app-server subcommand: ${stderrTail.trim().slice(0, 400)}` ) } return new Error( - `codex app-server exited before completing the session${stderr ? `: ${stderr.trim().slice(0, 400)}` : ''}` + `codex app-server exited before completing the session${stderrTail ? `: ${stderrTail.trim().slice(0, 400)}` : ''}` ) } @@ -300,11 +289,10 @@ export async function runCodexAppServerSession( error instanceof Error && !(error instanceof CodexAppServerUnsupportedError) && !(error instanceof CodexAppServerTimeoutError) && - stderrIndicatesMissingAppServer(stderrTail.toString()) + stderrIndicatesMissingAppServer(stderrTail) ) { - const stderr = stderrTail.toString() throw new CodexAppServerUnsupportedError( - `codex CLI does not support the app-server subcommand: ${stderr.trim().slice(0, 400)}` + `codex CLI does not support the app-server subcommand: ${stderrTail.trim().slice(0, 400)}` ) } throw error @@ -324,7 +312,5 @@ export async function runCodexAppServerSession( } } clearTimeout(deadline) - stdoutBuffer.clear() - stderrTail.clear() } } diff --git a/src/main/codex/codex-home-paths.ts b/src/main/codex/codex-home-paths.ts index 0f2ee9cfb13..61e2eba1eca 100644 --- a/src/main/codex/codex-home-paths.ts +++ b/src/main/codex/codex-home-paths.ts @@ -3,6 +3,7 @@ import { existsSync, lstatSync, mkdirSync, + readFileSync, readlinkSync, rmdirSync, rmSync, @@ -13,11 +14,8 @@ import { } from 'node:fs' import { homedir } from 'node:os' import { dirname, join } from 'node:path' -import { nodeSourceAndCopyContentsEqualSync } from '../../shared/node-source-copy-content-equality' -import { readNodeFileSyncWithinLimit } from '../../shared/node-bounded-file-reader' const CODEX_GLOBAL_INSTRUCTIONS_ENTRY = 'AGENTS.md' -const CODEX_RESOURCE_COPY_MARKER_MAX_BYTES = 64 * 1024 const CODEX_SYSTEM_RESOURCE_ENTRIES = [ 'skills', @@ -126,7 +124,7 @@ function linkSystemCodexResource( // rewriting an unchanged file across the UNC boundary on every launch. if ( entryName === CODEX_GLOBAL_INSTRUCTIONS_ENTRY && - nodeSourceAndCopyContentsEqualSync(sourcePath, targetPath) + copiedFileContentsMatch(sourcePath, targetPath) ) { return } @@ -215,6 +213,19 @@ function pathEntryExists(entryPath: string): boolean { } } +function copiedFileContentsMatch(sourcePath: string, targetPath: string): boolean { + try { + // Why: reading a FIFO or device synchronously can block Codex launch. + // Follow source symlinks, but only compare two regular files. + if (!statSync(sourcePath).isFile() || !lstatSync(targetPath).isFile()) { + return false + } + return readFileSync(sourcePath).equals(readFileSync(targetPath)) + } catch { + return false + } +} + function targetAlreadyPointsToSource(targetPath: string, sourcePath: string): boolean { try { return ( @@ -253,10 +264,7 @@ function markCopiedResource(managedHomePath: string, entryName: string, sourcePa function readCopiedResourceSourcePath(managedHomePath: string, entryName: string): string | null { try { const parsed: unknown = JSON.parse( - readNodeFileSyncWithinLimit( - getResourceCopyMarkerPath(managedHomePath, entryName), - CODEX_RESOURCE_COPY_MARKER_MAX_BYTES - ).buffer.toString('utf8') + readFileSync(getResourceCopyMarkerPath(managedHomePath, entryName), 'utf-8') ) if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { return null diff --git a/src/main/codex/codex-hook-trust-grant.ts b/src/main/codex/codex-hook-trust-grant.ts index b6fc569034b..94eb3117b45 100644 --- a/src/main/codex/codex-hook-trust-grant.ts +++ b/src/main/codex/codex-hook-trust-grant.ts @@ -15,7 +15,6 @@ import { codexAppServerCapabilityCache, getCodexAppServerHostKey } from './codex-app-server-capability-cache' -import { CodexHostRetryDeadlines } from './codex-host-retry-deadlines' import { writeCodexTrustGrantLedgerHome, type CodexTrustGrantBinaryStamp, @@ -73,7 +72,7 @@ const diagnostics = { lastFallbackReason: null as CodexTrustGrantFallbackReason | null } export type CodexTrustGrantDiagnostics = typeof diagnostics -const transientRetryAfterByHost = new CodexHostRetryDeadlines() +const transientRetryAfterByHost = new Map() export function getCodexTrustGrantDiagnostics(): CodexTrustGrantDiagnostics { return { ...diagnostics } diff --git a/src/main/codex/codex-host-retry-deadlines.test.ts b/src/main/codex/codex-host-retry-deadlines.test.ts deleted file mode 100644 index 978bb7e1ff5..00000000000 --- a/src/main/codex/codex-host-retry-deadlines.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - MAX_CODEX_APP_SERVER_CAPABILITY_HOSTS, - type CodexAppServerHostKey -} from './codex-app-server-capability-cache' -import { CodexHostRetryDeadlines } from './codex-host-retry-deadlines' - -function host(index: number): CodexAppServerHostKey { - return `wsl:distro-${index}` -} - -describe('CodexHostRetryDeadlines', () => { - it('preserves every deadline through the exact host limit', () => { - const deadlines = new CodexHostRetryDeadlines() - for (let index = 0; index < MAX_CODEX_APP_SERVER_CAPABILITY_HOSTS; index += 1) { - deadlines.set(host(index), index + 1) - } - - expect(deadlines.sizeForTest()).toBe(MAX_CODEX_APP_SERVER_CAPABILITY_HOSTS) - for (let index = 0; index < MAX_CODEX_APP_SERVER_CAPABILITY_HOSTS; index += 1) { - expect(deadlines.get(host(index))).toBe(index + 1) - } - }) - - it('evicts the least recently used host above the limit', () => { - const deadlines = new CodexHostRetryDeadlines() - for (let index = 0; index < MAX_CODEX_APP_SERVER_CAPABILITY_HOSTS; index += 1) { - deadlines.set(host(index), index + 1) - } - expect(deadlines.get(host(0))).toBe(1) - - deadlines.set(host(MAX_CODEX_APP_SERVER_CAPABILITY_HOSTS), 999) - - expect(deadlines.sizeForTest()).toBe(MAX_CODEX_APP_SERVER_CAPABILITY_HOSTS) - expect(deadlines.get(host(0))).toBe(1) - expect(deadlines.get(host(1))).toBeUndefined() - expect(deadlines.get(host(MAX_CODEX_APP_SERVER_CAPABILITY_HOSTS))).toBe(999) - }) -}) diff --git a/src/main/codex/codex-host-retry-deadlines.ts b/src/main/codex/codex-host-retry-deadlines.ts deleted file mode 100644 index 20b2f9df41a..00000000000 --- a/src/main/codex/codex-host-retry-deadlines.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { - MAX_CODEX_APP_SERVER_CAPABILITY_HOSTS, - type CodexAppServerHostKey -} from './codex-app-server-capability-cache' - -export class CodexHostRetryDeadlines { - private readonly deadlineByHost = new Map() - - get(hostKey: CodexAppServerHostKey): number | undefined { - const deadline = this.deadlineByHost.get(hostKey) - if (deadline !== undefined) { - this.deadlineByHost.delete(hostKey) - this.deadlineByHost.set(hostKey, deadline) - } - return deadline - } - - set(hostKey: CodexAppServerHostKey, deadline: number): void { - this.deadlineByHost.delete(hostKey) - this.deadlineByHost.set(hostKey, deadline) - while (this.deadlineByHost.size > MAX_CODEX_APP_SERVER_CAPABILITY_HOSTS) { - const oldestHost = this.deadlineByHost.keys().next().value - if (oldestHost === undefined) { - return - } - this.deadlineByHost.delete(oldestHost) - } - } - - delete(hostKey: CodexAppServerHostKey): void { - this.deadlineByHost.delete(hostKey) - } - - clear(): void { - this.deadlineByHost.clear() - } - - sizeForTest(): number { - return this.deadlineByHost.size - } -} diff --git a/src/main/codex/codex-real-home-hook-install.ts b/src/main/codex/codex-real-home-hook-install.ts index 769d99ec21d..1d59c17ac14 100644 --- a/src/main/codex/codex-real-home-hook-install.ts +++ b/src/main/codex/codex-real-home-hook-install.ts @@ -1,11 +1,10 @@ -import { existsSync, mkdirSync, statSync, unlinkSync } from 'node:fs' +import { existsSync, mkdirSync, readFileSync, statSync, unlinkSync } from 'node:fs' import { join } from 'node:path' import { writeFileAtomically } from '../codex-accounts/fs-utils' import { buildManagedCommandHook, createManagedCommandMatcher, MANAGED_HOOK_TIMEOUT_SECONDS, - readHooksJsonRawForGenerationCheck, readHooksJsonWithRaw, removeManagedCommands, writeHooksJson, @@ -75,9 +74,7 @@ function assertHooksJsonGeneration( hooksWritePath: string, expectedRaw: string | null ): void { - const currentRaw = existsSync(hooksJsonPath) - ? readHooksJsonRawForGenerationCheck(hooksJsonPath) - : null + const currentRaw = existsSync(hooksJsonPath) ? readFileSync(hooksJsonPath, 'utf-8') : null if (currentRaw !== expectedRaw || resolveHooksJsonWritePath(hooksJsonPath) !== hooksWritePath) { // Why: the pre-mutation RPC can overlap a user's editor save. Abort rather // than atomically replacing a newer file with the stale parsed snapshot. diff --git a/src/main/codex/codex-session-backfill-marker.ts b/src/main/codex/codex-session-backfill-marker.ts index f03fbbccbd6..d3d5a07e5f7 100644 --- a/src/main/codex/codex-session-backfill-marker.ts +++ b/src/main/codex/codex-session-backfill-marker.ts @@ -1,6 +1,5 @@ -import { mkdirSync, rmSync } from 'node:fs' +import { mkdirSync, readFileSync, rmSync } from 'node:fs' import { dirname } from 'node:path' -import { readAgentStateJsonFileSync } from '../agent-state-file-reader' import { writeFileAtomically } from '../codex-accounts/fs-utils' import type { CodexSessionBackfillSummary } from './codex-session-backfill-types' @@ -13,7 +12,7 @@ export function hasCompletedCodexSessionBackfillMarker( systemSessionsRoot: string ): boolean { try { - const parsed = readAgentStateJsonFileSync(markerPath) + const parsed: unknown = JSON.parse(readFileSync(markerPath, 'utf-8')) if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { return false } diff --git a/src/main/codex/codex-session-bridge.ts b/src/main/codex/codex-session-bridge.ts index 6681f64407b..e546ce9d8f8 100644 --- a/src/main/codex/codex-session-bridge.ts +++ b/src/main/codex/codex-session-bridge.ts @@ -3,13 +3,13 @@ import { linkSync, lstatSync, mkdirSync, + readFileSync, readlinkSync, renameSync, rmSync, symlinkSync } from 'node:fs' import { dirname, isAbsolute, join, relative, sep } from 'node:path' -import { readAgentStateFileSync } from '../agent-state-file-reader' import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from './codex-home-paths' import { listCodexSessionJsonlFiles, @@ -334,7 +334,7 @@ function getLegacySessionCopyMarkerPath(relativePath: string): string { function readLegacyCopiedSessionMarker(relativePath: string): LegacyCopiedSessionMarker | null { try { const parsed: unknown = JSON.parse( - readAgentStateFileSync(getLegacySessionCopyMarkerPath(relativePath)) + readFileSync(getLegacySessionCopyMarkerPath(relativePath), 'utf-8') ) if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { return null diff --git a/src/main/codex/codex-session-file-listing.test.ts b/src/main/codex/codex-session-file-listing.test.ts deleted file mode 100644 index 59ea9f9f14e..00000000000 --- a/src/main/codex/codex-session-file-listing.test.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { - CodexSessionListingCapacityError, - listCodexSessionJsonlFilesIncrementally, - listCodexSessionJsonlFilesWithinLimits -} from './codex-session-file-listing' - -const tempRoots: string[] = [] - -async function makeRoot(): Promise { - const root = await mkdtemp(join(tmpdir(), 'orca-codex-session-listing-')) - tempRoots.push(root) - return root -} - -async function collect(files: AsyncIterable): Promise { - const collected: string[] = [] - for await (const file of files) { - collected.push(file) - } - return collected -} - -afterEach(async () => { - vi.restoreAllMocks() - await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) -}) - -describe('Codex session file-listing limits', () => { - it('preserves sorted JSONL discovery below every limit', async () => { - const root = await makeRoot() - await mkdir(join(root, '2026', '01', '02'), { recursive: true }) - const later = join(root, '2026', '01', '02', 'z.jsonl') - const earlier = join(root, '2026', '01', '02', 'a.jsonl') - await writeFile(later, '{}') - await writeFile(earlier, '{}') - await writeFile(join(root, '2026', '01', '02', 'ignored.txt'), 'x') - - expect(listCodexSessionJsonlFilesWithinLimits(root)).toEqual([earlier, later]) - }) - - it('accepts exact entry and file limits, then rejects the next file', async () => { - const root = await makeRoot() - await writeFile(join(root, 'a.jsonl'), '{}') - await writeFile(join(root, 'b.jsonl'), '{}') - - expect( - listCodexSessionJsonlFilesWithinLimits(root, { maxEntries: 2, maxFiles: 2 }) - ).toHaveLength(2) - await writeFile(join(root, 'c.jsonl'), '{}') - expect(() => - listCodexSessionJsonlFilesWithinLimits(root, { maxEntries: 3, maxFiles: 2 }) - ).toThrow('Codex session listing exceeded 2 files') - }) - - it('bounds a zero-file directory tree by entries and depth', async () => { - const root = await makeRoot() - await mkdir(join(root, 'a', 'b'), { recursive: true }) - - expect(listCodexSessionJsonlFilesWithinLimits(root, { maxEntries: 2, maxDepth: 2 })).toEqual([]) - expect(() => - listCodexSessionJsonlFilesWithinLimits(root, { maxEntries: 1, maxDepth: 2 }) - ).toThrow('Codex session listing exceeded 1 entries') - expect(() => - listCodexSessionJsonlFilesWithinLimits(root, { maxEntries: 2, maxDepth: 1 }) - ).toThrow('Codex session listing exceeded 1 depth') - }) - - it('accepts the exact aggregate path limit and rejects one code unit less', async () => { - const root = await makeRoot() - const filePath = join(root, 'a.jsonl') - await writeFile(filePath, '{}') - const exactPathCodeUnits = root.length + filePath.length - - expect( - listCodexSessionJsonlFilesWithinLimits(root, { - maxPathCodeUnits: exactPathCodeUnits - }) - ).toEqual([filePath]) - expect(() => - listCodexSessionJsonlFilesWithinLimits(root, { - maxPathCodeUnits: exactPathCodeUnits - 1 - }) - ).toThrow(`Codex session listing exceeded ${exactPathCodeUnits - 1} path code units`) - }) - - it('stops an incremental scan at capacity and reports it as a failed directory', async () => { - const root = await makeRoot() - await writeFile(join(root, 'a.jsonl'), '{}') - await writeFile(join(root, 'b.jsonl'), '{}') - const onDirectoryError = vi.fn() - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - - const files = await collect( - listCodexSessionJsonlFilesIncrementally( - root, - { batchSize: 1, limits: { maxFiles: 1 }, yieldMs: 0 }, - onDirectoryError - ) - ) - - expect(files).toHaveLength(1) - expect(onDirectoryError).toHaveBeenCalledWith( - root, - expect.any(CodexSessionListingCapacityError) - ) - expect(warnSpy).toHaveBeenCalledTimes(1) - }) -}) diff --git a/src/main/codex/codex-session-file-listing.ts b/src/main/codex/codex-session-file-listing.ts index cfdf1459001..8b0a8dd6f2e 100644 --- a/src/main/codex/codex-session-file-listing.ts +++ b/src/main/codex/codex-session-file-listing.ts @@ -1,25 +1,10 @@ -import { opendirSync } from 'node:fs' +import { readdirSync } from 'node:fs' import { opendir } from 'node:fs/promises' import { join } from 'node:path' -import { - CodexSessionListingBudget, - CodexSessionListingCapacityError, - type CodexSessionListingLimits -} from './codex-session-listing-budget' - -export { - CODEX_SESSION_LISTING_MAX_DEPTH, - CODEX_SESSION_LISTING_MAX_ENTRIES, - CODEX_SESSION_LISTING_MAX_FILES, - CODEX_SESSION_LISTING_MAX_PATH_CODE_UNITS, - CodexSessionListingCapacityError -} from './codex-session-listing-budget' export type CodexSessionBridgeIncrementalOptions = { /** Directory entries to process before yielding back to the event loop. */ batchSize?: number - /** Optional lower limits for tests or constrained callers. */ - limits?: Partial /** Delay after each processed batch; zero still yields on a timer turn. */ yieldMs?: number } @@ -34,59 +19,32 @@ const INCREMENTAL_BRIDGE_YIELD_MS = 10 * that run outside the CLI launch path. */ export function listCodexSessionJsonlFiles(rootPath: string): string[] { - return listCodexSessionJsonlFilesWithinLimits(rootPath) -} - -export function listCodexSessionJsonlFilesWithinLimits( - rootPath: string, - limits: Partial = {} -): string[] { - const budget = new CodexSessionListingBudget(limits) - budget.claimDepth(0) - budget.claimPath(rootPath) const files: string[] = [] - const pendingDirectories = [{ depth: 0, path: rootPath }] - - while (pendingDirectories.length > 0) { - const current = pendingDirectories.pop()! - let directory: ReturnType - try { - directory = opendirSync(current.path) - } catch (error) { - warnAboutCodexSessionListingError(error) - continue - } - try { - for (let entry = directory.readSync(); entry !== null; entry = directory.readSync()) { - budget.claimEntry() - const childPath = join(current.path, entry.name) - budget.claimPath(childPath) - if (entry.isDirectory()) { - const depth = current.depth + 1 - budget.claimDepth(depth) - pendingDirectories.push({ depth, path: childPath }) - } else if (entry.isFile() && entry.name.endsWith('.jsonl')) { - budget.claimFile() - files.push(childPath) - } + try { + for (const entry of readdirSync(rootPath, { withFileTypes: true })) { + const childPath = join(rootPath, entry.name) + if (entry.isDirectory()) { + appendSessionFilePaths(files, listCodexSessionJsonlFiles(childPath)) + continue } - } catch (error) { - if (error instanceof CodexSessionListingCapacityError) { - throw error + if (entry.isFile() && entry.name.endsWith('.jsonl')) { + files.push(childPath) } - warnAboutCodexSessionListingError(error) - } finally { - closeCodexSessionDirectory(directory) } + } catch (error) { + console.warn('[codex-session-bridge] Failed to list system Codex sessions:', error) } return files.sort() } -function closeCodexSessionDirectory(directory: ReturnType): void { - try { - directory.closeSync() - } catch { - // The OS may have already closed a failed directory stream. +/** + * Appends session paths without spreading large arrays into a single call. + */ +function appendSessionFilePaths(target: string[], source: readonly string[]): void { + // Why: existing Codex homes can accumulate enough nested sessions to exceed + // V8's argument limit if child arrays are spread into push(). + for (const filePath of source) { + target.push(filePath) } } @@ -131,26 +89,21 @@ async function* listCodexSessionFilesIncrementally( ): AsyncGenerator { const batchSize = Math.max(1, options.batchSize ?? INCREMENTAL_BRIDGE_BATCH_SIZE) const yieldMs = Math.max(0, options.yieldMs ?? INCREMENTAL_BRIDGE_YIELD_MS) - const budget = new CodexSessionListingBudget(options.limits) - budget.claimDepth(0) - budget.claimPath(rootPath) - const pendingDirectories = [{ depth: 0, path: rootPath }] + const pendingDirectories = [rootPath] let entriesSinceYield = 0 while (pendingDirectories.length > 0) { - const currentDirectory = pendingDirectories.pop()! + const currentDirectory = pendingDirectories.pop() + if (!currentDirectory) { + continue + } try { - const directory = await opendir(currentDirectory.path) + const directory = await opendir(currentDirectory) for await (const entry of directory) { - budget.claimEntry() - const childPath = join(currentDirectory.path, entry.name) - budget.claimPath(childPath) + const childPath = join(currentDirectory, entry.name) if (entry.isDirectory()) { - const depth = currentDirectory.depth + 1 - budget.claimDepth(depth) - pendingDirectories.push({ depth, path: childPath }) + pendingDirectories.push(childPath) } else if (entry.isFile() && isSessionFile(entry.name)) { - budget.claimFile() yield childPath } entriesSinceYield += 1 @@ -160,19 +113,12 @@ async function* listCodexSessionFilesIncrementally( } } } catch (error) { - await onDirectoryError?.(currentDirectory.path, error) - warnAboutCodexSessionListingError(error) - if (error instanceof CodexSessionListingCapacityError) { - return - } + await onDirectoryError?.(currentDirectory, error) + console.warn('[codex-session-bridge] Failed to list system Codex sessions:', error) } } } -function warnAboutCodexSessionListingError(error: unknown): void { - console.warn('[codex-session-bridge] Failed to list system Codex sessions:', error) -} - /** * Defers incremental bridge work to a later timer turn. */ diff --git a/src/main/codex/codex-session-index-heal-jsonl.test.ts b/src/main/codex/codex-session-index-heal-jsonl.test.ts deleted file mode 100644 index 4a9729a335e..00000000000 --- a/src/main/codex/codex-session-index-heal-jsonl.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { appendFileSync, mkdtempSync, rmSync, truncateSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import { - MAX_CODEX_SESSION_INDEX_HEAL_JSONL_LINE_BYTES, - readCodexSessionIndexHealJsonlRecords -} from './codex-session-index-heal-jsonl' - -describe('Codex session index heal JSONL reader', () => { - const roots: string[] = [] - - afterEach(() => { - for (const root of roots.splice(0)) { - rmSync(root, { recursive: true, force: true }) - } - }) - - function makePath(): string { - const root = mkdtempSync(join(tmpdir(), 'orca-heal-jsonl-')) - roots.push(root) - return join(root, 'ledger.jsonl') - } - - it('accepts a valid record exactly at the per-line byte limit', () => { - const path = makePath() - const prefix = '{"padding":"' - const suffix = '"}' - const padding = 'x'.repeat( - MAX_CODEX_SESSION_INDEX_HEAL_JSONL_LINE_BYTES - prefix.length - suffix.length - ) - writeFileSync(path, `${prefix}${padding}${suffix}\n`) - - expect([...readCodexSessionIndexHealJsonlRecords(path)]).toEqual([{ padding }]) - }) - - it('skips an oversized sparse line and resumes at the next record', () => { - const path = makePath() - writeFileSync(path, '{"ignored":"') - truncateSync(path, MAX_CODEX_SESSION_INDEX_HEAL_JSONL_LINE_BYTES + 8 * 1024 * 1024) - appendFileSync(path, '\n{"kept":true}\n') - - expect([...readCodexSessionIndexHealJsonlRecords(path)]).toEqual([{ kept: true }]) - }) -}) diff --git a/src/main/codex/codex-session-index-heal-jsonl.ts b/src/main/codex/codex-session-index-heal-jsonl.ts deleted file mode 100644 index cb67e9a8d80..00000000000 --- a/src/main/codex/codex-session-index-heal-jsonl.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { closeSync, openSync, readSync } from 'node:fs' - -export const MAX_CODEX_SESSION_INDEX_HEAL_JSONL_LINE_BYTES = 1024 * 1024 -const HEAL_JSONL_READ_CHUNK_BYTES = 64 * 1024 - -function isNotFoundError(error: unknown): boolean { - return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT' -} - -function parseJsonlRecord(raw: string): Record | null { - if (!raw.trim()) { - return null - } - try { - const parsed: unknown = JSON.parse(raw) - return parsed && typeof parsed === 'object' && !Array.isArray(parsed) - ? (parsed as Record) - : null - } catch { - return null - } -} - -export function* readCodexSessionIndexHealJsonlRecords( - filePath: string -): Generator> { - let descriptor: number - try { - descriptor = openSync(filePath, 'r') - } catch (error) { - if (isNotFoundError(error)) { - return - } - throw error - } - - const readBuffer = Buffer.allocUnsafe(HEAL_JSONL_READ_CHUNK_BYTES) - let lineParts: Buffer[] = [] - let lineBytes = 0 - let skippingOversizedLine = false - - const resetLine = (): void => { - lineParts = [] - lineBytes = 0 - skippingOversizedLine = false - } - const appendSegment = (start: number, end: number): void => { - if (skippingOversizedLine || start === end) { - return - } - const nextBytes = lineBytes + end - start - if (nextBytes > MAX_CODEX_SESSION_INDEX_HEAL_JSONL_LINE_BYTES) { - // Why: a corrupt line may span an arbitrarily large sparse file; discard until its newline. - lineParts = [] - lineBytes = 0 - skippingOversizedLine = true - return - } - lineParts.push(Buffer.from(readBuffer.subarray(start, end))) - lineBytes = nextBytes - } - const takeRecord = (): Record | null => { - const raw = - lineParts.length === 1 - ? lineParts[0].toString('utf8') - : Buffer.concat(lineParts, lineBytes).toString('utf8') - return parseJsonlRecord(raw) - } - - try { - while (true) { - const bytesRead = readSync(descriptor, readBuffer, 0, readBuffer.length, null) - if (bytesRead === 0) { - break - } - let start = 0 - for (let index = 0; index < bytesRead; index += 1) { - if (readBuffer[index] !== 0x0a) { - continue - } - appendSegment(start, index) - if (!skippingOversizedLine) { - const record = takeRecord() - if (record) { - yield record - } - } - resetLine() - start = index + 1 - } - appendSegment(start, bytesRead) - } - if (!skippingOversizedLine && lineBytes > 0) { - const record = takeRecord() - if (record) { - yield record - } - } - } finally { - closeSync(descriptor) - } -} diff --git a/src/main/codex/codex-session-index-heal-state-memory.test.ts b/src/main/codex/codex-session-index-heal-state-memory.test.ts deleted file mode 100644 index 86717e717ed..00000000000 --- a/src/main/codex/codex-session-index-heal-state-memory.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { - closeSync, - mkdtempSync, - openSync, - rmSync, - truncateSync, - writeFileSync, - writeSync -} from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import { - CODEX_SESSION_INDEX_HEAL_VERSION, - CodexSessionIndexHealCapacityError, - collectPendingHealThreads, - isHealMarkerCurrent, - MAX_CODEX_SESSION_INDEX_HEAL_MARKER_FILE_BYTES, - MAX_CODEX_SESSION_INDEX_HEAL_TRACKED_THREADS, - type CodexSessionIndexHealPaths -} from './codex-session-index-heal-state' - -describe('Codex session index heal state memory bounds', () => { - const roots: string[] = [] - - afterEach(() => { - for (const root of roots.splice(0)) { - rmSync(root, { recursive: true, force: true }) - } - }) - - function makePaths(): CodexSessionIndexHealPaths { - const root = mkdtempSync(join(tmpdir(), 'orca-heal-state-memory-')) - roots.push(root) - return { - auditLogPath: join(root, 'audit.jsonl'), - systemSessionsRoot: '/sessions', - healLedgerPath: join(root, 'heal.jsonl'), - healMarkerPath: join(root, 'marker.json') - } - } - - it('fails closed when processed state exceeds the retained-thread ceiling', () => { - const paths = makePaths() - const descriptor = openSync(paths.healLedgerPath, 'w') - try { - for (let start = 0; start <= MAX_CODEX_SESSION_INDEX_HEAL_TRACKED_THREADS; start += 1000) { - const count = Math.min(1000, MAX_CODEX_SESSION_INDEX_HEAL_TRACKED_THREADS + 1 - start) - const lines = Array.from({ length: count }, (_, offset) => { - const suffix = (start + offset).toString(16).padStart(12, '0') - return JSON.stringify({ - v: CODEX_SESSION_INDEX_HEAL_VERSION, - systemSessionsRoot: paths.systemSessionsRoot, - threadId: `00000000-0000-0000-0000-${suffix}`, - outcome: 'healed' - }) - }) - writeSync(descriptor, `${lines.join('\n')}\n`) - } - } finally { - closeSync(descriptor) - } - - expect(() => collectPendingHealThreads(paths)).toThrow(CodexSessionIndexHealCapacityError) - }) - - it('rejects an oversized sparse completion marker', () => { - const paths = makePaths() - writeFileSync( - paths.healMarkerPath, - JSON.stringify({ - version: CODEX_SESSION_INDEX_HEAL_VERSION, - systemSessionsRoot: paths.systemSessionsRoot, - auditBytes: 0 - }) - ) - truncateSync(paths.healMarkerPath, MAX_CODEX_SESSION_INDEX_HEAL_MARKER_FILE_BYTES + 1) - - expect(isHealMarkerCurrent(paths, 0)).toBe(false) - }) -}) diff --git a/src/main/codex/codex-session-index-heal-state.ts b/src/main/codex/codex-session-index-heal-state.ts index c546ba2faa4..4efc124316e 100644 --- a/src/main/codex/codex-session-index-heal-state.ts +++ b/src/main/codex/codex-session-index-heal-state.ts @@ -1,17 +1,10 @@ -import { appendFileSync, mkdirSync, statSync } from 'node:fs' +import { appendFileSync, mkdirSync, readFileSync, statSync } from 'node:fs' import { dirname } from 'node:path' import { isPathInsideOrEqual, normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' -import { readNodeFileSyncWithinLimit } from '../../shared/node-bounded-file-reader' import { writeFileAtomically } from '../codex-accounts/fs-utils' -import { - MAX_CODEX_SESSION_INDEX_HEAL_JSONL_LINE_BYTES, - readCodexSessionIndexHealJsonlRecords -} from './codex-session-index-heal-jsonl' - -export { MAX_CODEX_SESSION_INDEX_HEAL_JSONL_LINE_BYTES } // State files for the session index heal: which backfilled rollouts exist // (the backfill audit ledger), which thread ids this pass already processed @@ -21,22 +14,6 @@ export { MAX_CODEX_SESSION_INDEX_HEAL_JSONL_LINE_BYTES } // Bump to re-drive the heal for every host after a semantics change; already // processed thread ids are re-read because ledger lines are version-scoped. export const CODEX_SESSION_INDEX_HEAL_VERSION = 3 -export const MAX_CODEX_SESSION_INDEX_HEAL_TRACKED_THREADS = 100_000 -export const MAX_CODEX_SESSION_INDEX_HEAL_MARKER_FILE_BYTES = 64 * 1024 - -const MAX_AUDIT_TARGET_LENGTH = 64 * 1024 -const MAX_AUDIT_RECORD_ID_LENGTH = 128 -const MAX_ROLLOUT_STAMP_LENGTH = 512 -const CODEX_THREAD_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i - -export class CodexSessionIndexHealCapacityError extends Error { - constructor(kind: 'pending' | 'processed') { - super( - `Codex session index heal ${kind} state exceeds ${MAX_CODEX_SESSION_INDEX_HEAL_TRACKED_THREADS} entries` - ) - this.name = 'CodexSessionIndexHealCapacityError' - } -} // Why: an unsupported CLI stays unsupported until upgraded; re-probing once a // day is enough to notice an upgrade without a per-startup spawn. @@ -76,11 +53,11 @@ export type HealMarkerSummary = { export function collectPendingHealThreads(paths: CodexSessionIndexHealPaths): PendingHealThread[] { const processed = readProcessedHealThreads(paths) const pendingByThreadId = new Map() - for (const line of readCodexSessionIndexHealJsonlRecords(paths.auditLogPath)) { + for (const line of readJsonlLines(paths.auditLogPath, true)) { if (line.action !== 'hardlink' && line.action !== 'copy' && line.action !== 'existing') { continue } - if (typeof line.target !== 'string' || line.target.length > MAX_AUDIT_TARGET_LENGTH) { + if (typeof line.target !== 'string') { continue } // Why: the append-only audit can contain runs for several custom Codex @@ -89,13 +66,10 @@ export function collectPendingHealThreads(paths: CodexSessionIndexHealPaths): Pe continue } const match = CODEX_ROLLOUT_THREAD_ID_PATTERN.exec(lastPathSegment(line.target)) - if (!match || match[1].length > MAX_ROLLOUT_STAMP_LENGTH) { + if (!match) { continue } const threadId = match[2].toLowerCase() - if (typeof line.recordId === 'string' && line.recordId.length > MAX_AUDIT_RECORD_ID_LENGTH) { - continue - } const auditRecordId = typeof line.recordId === 'string' ? line.recordId : null if ( processed.healedThreadIds.has(threadId) || @@ -108,12 +82,6 @@ export function collectPendingHealThreads(paths: CodexSessionIndexHealPaths): Pe pendingByThreadId.delete(threadId) continue } - if ( - !pendingByThreadId.has(threadId) && - pendingByThreadId.size >= MAX_CODEX_SESSION_INDEX_HEAL_TRACKED_THREADS - ) { - throw new CodexSessionIndexHealCapacityError('pending') - } pendingByThreadId.set(threadId, { threadId, rolloutStamp: match[1], auditRecordId }) } return [...pendingByThreadId.values()].sort((left, right) => @@ -134,49 +102,23 @@ function readProcessedHealThreads(paths: CodexSessionIndexHealPaths): { const missingAuditRecords = new Set() const legacyMissingThreadIds = new Set() const expectedRoot = normalizeRuntimePathForComparison(paths.systemSessionsRoot) - let trackedEntries = 0 - const addTrackedEntry = (entries: Set, key: string): void => { - if (entries.has(key)) { - return - } - if (trackedEntries >= MAX_CODEX_SESSION_INDEX_HEAL_TRACKED_THREADS) { - throw new CodexSessionIndexHealCapacityError('processed') - } - entries.add(key) - trackedEntries += 1 - } - try { - for (const line of readCodexSessionIndexHealJsonlRecords(paths.healLedgerPath)) { - if ( - line.v === CODEX_SESSION_INDEX_HEAL_VERSION && - typeof line.threadId === 'string' && - CODEX_THREAD_ID_PATTERN.test(line.threadId) && - typeof line.systemSessionsRoot === 'string' && - (line.outcome === 'healed' || line.outcome === 'missing') && - normalizeRuntimePathForComparison(line.systemSessionsRoot) === expectedRoot - ) { - const threadId = line.threadId.toLowerCase() - if (line.outcome === 'healed') { - addTrackedEntry(healedThreadIds, threadId) - } else if ( - typeof line.auditRecordId === 'string' && - line.auditRecordId.length <= MAX_AUDIT_RECORD_ID_LENGTH - ) { - addTrackedEntry(missingAuditRecords, `${threadId}\0${line.auditRecordId}`) - } else if (line.auditRecordId === undefined) { - addTrackedEntry(legacyMissingThreadIds, threadId) - } + for (const line of readJsonlLines(paths.healLedgerPath)) { + if ( + line.v === CODEX_SESSION_INDEX_HEAL_VERSION && + typeof line.threadId === 'string' && + typeof line.systemSessionsRoot === 'string' && + (line.outcome === 'healed' || line.outcome === 'missing') && + normalizeRuntimePathForComparison(line.systemSessionsRoot) === expectedRoot + ) { + const threadId = line.threadId.toLowerCase() + if (line.outcome === 'healed') { + healedThreadIds.add(threadId) + } else if (typeof line.auditRecordId === 'string') { + missingAuditRecords.add(`${threadId}\0${line.auditRecordId}`) + } else { + legacyMissingThreadIds.add(threadId) } } - } catch (error) { - if (error instanceof CodexSessionIndexHealCapacityError) { - throw error - } - return { - healedThreadIds: new Set(), - missingAuditRecords: new Set(), - legacyMissingThreadIds: new Set() - } } return { healedThreadIds, missingAuditRecords, legacyMissingThreadIds } } @@ -211,6 +153,35 @@ export function appendHealLedgerRecord( } } +function readJsonlLines(filePath: string, throwOnReadFailure = false): Record[] { + let contents: string + try { + contents = readFileSync(filePath, 'utf-8') + } catch (error) { + if (throwOnReadFailure && !isNotFoundError(error)) { + // Why: the audit is the heal work queue. Treating EACCES/EIO as empty + // would write a completion marker that permanently skips every session. + throw error + } + return [] + } + const lines: Record[] = [] + for (const raw of contents.split('\n')) { + if (!raw.trim()) { + continue + } + try { + const parsed: unknown = JSON.parse(raw) + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + lines.push(parsed as Record) + } + } catch { + // Skip torn/corrupt lines; both ledgers are append-only diagnostics. + } + } + return lines +} + export function readAuditLogSize(auditLogPath: string): number { try { return statSync(auditLogPath).size @@ -231,12 +202,7 @@ export function isHealMarkerCurrent( auditBytes: number ): boolean { try { - const parsed: unknown = JSON.parse( - readNodeFileSyncWithinLimit( - paths.healMarkerPath, - MAX_CODEX_SESSION_INDEX_HEAL_MARKER_FILE_BYTES - ).buffer.toString('utf8') - ) + const parsed: unknown = JSON.parse(readFileSync(paths.healMarkerPath, 'utf-8')) if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { return false } diff --git a/src/main/codex/codex-session-listing-budget.ts b/src/main/codex/codex-session-listing-budget.ts deleted file mode 100644 index bcb355eb618..00000000000 --- a/src/main/codex/codex-session-listing-budget.ts +++ /dev/null @@ -1,81 +0,0 @@ -export const CODEX_SESSION_LISTING_MAX_DEPTH = 32 -export const CODEX_SESSION_LISTING_MAX_ENTRIES = 200_000 -export const CODEX_SESSION_LISTING_MAX_FILES = 100_000 -export const CODEX_SESSION_LISTING_MAX_PATH_CODE_UNITS = 32 * 1024 * 1024 - -export type CodexSessionListingLimits = { - maxDepth: number - maxEntries: number - maxFiles: number - maxPathCodeUnits: number -} - -const DEFAULT_LIMITS: CodexSessionListingLimits = { - maxDepth: CODEX_SESSION_LISTING_MAX_DEPTH, - maxEntries: CODEX_SESSION_LISTING_MAX_ENTRIES, - maxFiles: CODEX_SESSION_LISTING_MAX_FILES, - maxPathCodeUnits: CODEX_SESSION_LISTING_MAX_PATH_CODE_UNITS -} - -export type CodexSessionListingCapacityResource = 'depth' | 'entries' | 'files' | 'path code units' - -export class CodexSessionListingCapacityError extends Error { - constructor( - readonly resource: CodexSessionListingCapacityResource, - readonly observed: number, - readonly limit: number - ) { - super(`Codex session listing exceeded ${limit} ${resource}`) - this.name = 'CodexSessionListingCapacityError' - } -} - -export class CodexSessionListingBudget { - readonly limits: CodexSessionListingLimits - private entries = 0 - private files = 0 - private pathCodeUnits = 0 - - constructor(limits: Partial = {}) { - this.limits = { ...DEFAULT_LIMITS, ...limits } - for (const [name, value] of Object.entries(this.limits)) { - if (!Number.isSafeInteger(value) || value < 0) { - throw new RangeError(`${name} must be a non-negative safe integer`) - } - } - } - - claimDepth(depth: number): void { - if (depth > this.limits.maxDepth) { - throw new CodexSessionListingCapacityError('depth', depth, this.limits.maxDepth) - } - } - - claimEntry(): void { - this.entries += 1 - if (this.entries > this.limits.maxEntries) { - throw new CodexSessionListingCapacityError('entries', this.entries, this.limits.maxEntries) - } - } - - claimFile(): void { - this.files += 1 - if (this.files > this.limits.maxFiles) { - throw new CodexSessionListingCapacityError('files', this.files, this.limits.maxFiles) - } - } - - claimPath(path: string): void { - this.pathCodeUnits += path.length - if ( - !Number.isSafeInteger(this.pathCodeUnits) || - this.pathCodeUnits > this.limits.maxPathCodeUnits - ) { - throw new CodexSessionListingCapacityError( - 'path code units', - this.pathCodeUnits, - this.limits.maxPathCodeUnits - ) - } - } -} diff --git a/src/main/codex/codex-trust-config-rollback.test.ts b/src/main/codex/codex-trust-config-rollback.test.ts index 554470fe19f..0eae14ff986 100644 --- a/src/main/codex/codex-trust-config-rollback.test.ts +++ b/src/main/codex/codex-trust-config-rollback.test.ts @@ -8,13 +8,10 @@ import { rmSync, statSync, symlinkSync, - truncateSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { NodeFileReadTooLargeError } from '../../shared/node-bounded-file-reader' -import { MAX_AGENT_STATE_FILE_BYTES } from '../agent-state-file-reader' import { captureCodexTrustConfig, restoreCodexTrustConfig } from './codex-trust-config-rollback' const roots: string[] = [] @@ -32,15 +29,6 @@ function tempConfigPath(): string { } describe('Codex trust config rollback', () => { - it('rejects a sparse config above 4 MiB without changing it', () => { - const configPath = tempConfigPath() - writeFileSync(configPath, '') - truncateSync(configPath, MAX_AGENT_STATE_FILE_BYTES + 1) - - expect(() => captureCodexTrustConfig(configPath)).toThrow(NodeFileReadTooLargeError) - expect(statSync(configPath).size).toBe(MAX_AGENT_STATE_FILE_BYTES + 1) - }) - it('treats a missing config as absent and tolerates it remaining absent', () => { const configPath = tempConfigPath() const snapshot = captureCodexTrustConfig(configPath) diff --git a/src/main/codex/codex-trust-config-rollback.ts b/src/main/codex/codex-trust-config-rollback.ts index df3ad72c228..0642b834483 100644 --- a/src/main/codex/codex-trust-config-rollback.ts +++ b/src/main/codex/codex-trust-config-rollback.ts @@ -1,6 +1,10 @@ import { chmodSync, + closeSync, + fstatSync, lstatSync, + openSync, + readFileSync, readlinkSync, realpathSync, unlinkSync, @@ -8,8 +12,6 @@ import { } from 'node:fs' import { randomUUID } from 'node:crypto' import { dirname, resolve } from 'node:path' -import { readNodeFileSyncWithinLimit } from '../../shared/node-bounded-file-reader' -import { MAX_AGENT_STATE_FILE_BYTES } from '../agent-state-file-reader' import { renameFileWithWindowsRetry } from '../codex-accounts/fs-utils' export type CodexTrustConfigSnapshot = @@ -41,22 +43,27 @@ function resolveConfigRestorePath(tomlPath: string): string { export function captureCodexTrustConfig(tomlPath: string): CodexTrustConfigSnapshot { const restorePath = resolveConfigRestorePath(tomlPath) + let descriptor: number try { - // Why: contents and mode come from one descriptor, so a path replacement - // cannot pair one file's bytes with another file's permissions. - const { buffer, stats } = readNodeFileSyncWithinLimit(restorePath, MAX_AGENT_STATE_FILE_BYTES) - return { - existed: true, - contents: buffer, - mode: stats.mode, - restorePath - } + descriptor = openSync(restorePath, 'r') } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') { return restorePath === tomlPath ? { existed: false } : { existed: false, restorePath } } throw error } + try { + // Why: read and stat the same open file so replacement between two path + // lookups cannot pair one file's contents with another file's mode. + return { + existed: true, + contents: readFileSync(descriptor), + mode: fstatSync(descriptor).mode, + restorePath + } + } finally { + closeSync(descriptor) + } } export function restoreCodexTrustConfig( @@ -75,11 +82,7 @@ export function restoreCodexTrustConfig( } const { restorePath } = snapshot try { - if ( - readNodeFileSyncWithinLimit(restorePath, MAX_AGENT_STATE_FILE_BYTES).buffer.equals( - snapshot.contents - ) - ) { + if (readFileSync(restorePath).equals(snapshot.contents)) { // Why: the RPC may change permissions without changing bytes; rollback // restores the complete captured file state, not only its contents. chmodSync(restorePath, snapshot.mode) diff --git a/src/main/codex/codex-trust-grant-ledger.test.ts b/src/main/codex/codex-trust-grant-ledger.test.ts index 2eeb651343f..cb4c5450c6d 100644 --- a/src/main/codex/codex-trust-grant-ledger.test.ts +++ b/src/main/codex/codex-trust-grant-ledger.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { @@ -9,7 +9,6 @@ import { removeCodexTrustGrantLedgerHome, writeCodexTrustGrantLedgerHome } from './codex-trust-grant-ledger' -import { MAX_AGENT_STATE_FILE_BYTES } from '../agent-state-file-reader' let userDataDir: string let previousUserDataPath: string | undefined @@ -82,26 +81,6 @@ describe('codex trust grant ledger', () => { expect(readCodexTrustGrantLedgerHome(home)).not.toBeNull() }) - it('preserves the prior ledger when a grant exceeds its read ceiling', () => { - const home = join(userDataDir, 'codex-runtime-home', 'home') - const ledgerPath = getCodexTrustGrantLedgerPath() - writeCodexTrustGrantLedgerHome(home, { binary: null, entries: {} }) - const before = readFileSync(ledgerPath, 'utf8') - - expect(() => - writeCodexTrustGrantLedgerHome(home, { - binary: null, - entries: { - oversized: { - signature: 'x'.repeat(MAX_AGENT_STATE_FILE_BYTES), - trustedHash: 'sha256:test' - } - } - }) - ).toThrow('JSON output exceeds') - expect(readFileSync(ledgerPath, 'utf8')).toBe(before) - }) - it('matches binary stamps only on identical identity', () => { const stamp = { kind: 'native' as const, path: '/bin/codex', size: 1, mtimeMs: 2 } const wslStamp = { diff --git a/src/main/codex/codex-trust-grant-ledger.ts b/src/main/codex/codex-trust-grant-ledger.ts index 6a28a6831c1..b1d20421bce 100644 --- a/src/main/codex/codex-trust-grant-ledger.ts +++ b/src/main/codex/codex-trust-grant-ledger.ts @@ -1,7 +1,5 @@ -import { existsSync, mkdirSync, statSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs' import { dirname, join } from 'node:path' -import { MAX_AGENT_STATE_FILE_BYTES, readAgentStateJsonFileSync } from '../agent-state-file-reader' -import { stringifyJsonWithinByteLimit } from '../../shared/node-bounded-json-stringify' import { getOrcaManagedCodexHomePath } from './codex-home-paths' import { normalizeCodexProjectPathForLookup } from './config-toml-trust' @@ -48,7 +46,7 @@ function readLedgerFile(ledgerPath: string): CodexTrustGrantLedgerFile { return empty } try { - const parsed = readAgentStateJsonFileSync(ledgerPath) + const parsed: unknown = JSON.parse(readFileSync(ledgerPath, 'utf-8')) if ( !parsed || typeof parsed !== 'object' || @@ -71,8 +69,7 @@ function readLedgerFile(ledgerPath: string): CodexTrustGrantLedgerFile { function persistLedgerFile(ledgerPath: string, file: CodexTrustGrantLedgerFile): void { mkdirSync(dirname(ledgerPath), { recursive: true, mode: 0o700 }) - const { serialized } = stringifyJsonWithinByteLimit(file, MAX_AGENT_STATE_FILE_BYTES - 1, 2) - writeFileSync(ledgerPath, `${serialized}\n`, { + writeFileSync(ledgerPath, `${JSON.stringify(file, null, 2)}\n`, { encoding: 'utf-8', mode: 0o600 }) diff --git a/src/main/codex/codex-user-hook-trust-rebase.ts b/src/main/codex/codex-user-hook-trust-rebase.ts index 9085ba7dece..0020e106c2f 100644 --- a/src/main/codex/codex-user-hook-trust-rebase.ts +++ b/src/main/codex/codex-user-hook-trust-rebase.ts @@ -7,7 +7,6 @@ import { import { runCodexUserHookTrustRebaseSessionSync } from './codex-app-server-grant-bridge' import { isCodexAppServerUnsupportedError } from './codex-app-server-session' import { CODEX_TRUST_GRANT_TRANSIENT_RETRY_INTERVAL_MS } from './codex-hook-trust-grant' -import { CodexHostRetryDeadlines } from './codex-host-retry-deadlines' import { createCodexHookTrustEntry } from './codex-hook-identity' import { resolveCodexTrustGrantHost } from './codex-trust-grant-host' import { @@ -33,7 +32,7 @@ let runSessionSync: RebaseSessionRunnerSync = runCodexUserHookTrustRebaseSession // Why: launch prep re-runs the callers on every pane spawn. A host stuck // without a usable rebase lane (old CLI, unmatched keys) must not pay a codex // session each time — bound retries like the grant lane does. -const rebaseRetryAfterByHost = new CodexHostRetryDeadlines() +const rebaseRetryAfterByHost = new Map() function rememberRebaseSessionFailure(hostKey: CodexAppServerHostKey, error: unknown): void { if (isCodexAppServerUnsupportedError(error)) { diff --git a/src/main/codex/codex-wsl-hook-install-plan.test.ts b/src/main/codex/codex-wsl-hook-install-plan.test.ts index 89f47334d90..83ab0141a34 100644 --- a/src/main/codex/codex-wsl-hook-install-plan.test.ts +++ b/src/main/codex/codex-wsl-hook-install-plan.test.ts @@ -6,12 +6,7 @@ vi.mock('node:child_process', () => ({ execFile: execFileMock })) -import { - _internals, - createCodexWslRuntimeHookInstallPlan, - WSL_CANONICAL_PATH_CACHE_MAX_ENTRIES, - WSL_CANONICAL_PATH_CACHE_MAX_UTF8_BYTES -} from './codex-wsl-hook-install-plan' +import { _internals, createCodexWslRuntimeHookInstallPlan } from './codex-wsl-hook-install-plan' const originalPlatform = process.platform @@ -130,17 +125,6 @@ describe('canonicalizeWslLinuxPath', () => { expect(execFileMock).toHaveBeenCalledTimes(2) }) - it('clears in-flight ownership when spawning wsl.exe throws synchronously', () => { - setPlatform('win32') - execFileMock.mockImplementationOnce(() => { - throw new Error('invalid WSL argv') - }) - - expect(_internals.canonicalizeWslLinuxPath('Ubuntu', '/home/alias')).toBeNull() - expect(_internals.canonicalizeWslLinuxPath('Ubuntu', '/home/alias')).toBeNull() - expect(execFileMock).toHaveBeenCalledTimes(2) - }) - it('keeps the last known-good cache when revalidation later fails', () => { setPlatform('win32') _internals.canonicalizeWslLinuxPath('Ubuntu', '/home/alias') @@ -224,48 +208,4 @@ describe('canonicalizeWslLinuxPath', () => { expect(_internals.canonicalizeWslLinuxPath('Ubuntu', '/home/alias')).toBeNull() }) - - it('LRU-evicts canonical identities beyond the entry cap', () => { - setPlatform('win32') - for (let index = 0; index <= WSL_CANONICAL_PATH_CACHE_MAX_ENTRIES; index += 1) { - const logicalPath = `/home/runtime-${index}` - _internals.canonicalizeWslLinuxPath('Ubuntu', logicalPath) - const callback = execFileMock.mock.calls[index][3] as ( - error: Error | null, - stdout: string - ) => void - callback(null, `/canonical/runtime-${index}\n`) - } - - expect(_internals.getWslCanonicalPathCacheSize()).toBe(WSL_CANONICAL_PATH_CACHE_MAX_ENTRIES) - expect(_internals.canonicalizeWslLinuxPath('Ubuntu', '/home/runtime-0')).toBeNull() - expect(_internals.canonicalizeWslLinuxPath('Ubuntu', '/home/runtime-1')).toBe( - '/canonical/runtime-1' - ) - }) - - it('does not retain a canonical identity larger than the aggregate byte budget', () => { - setPlatform('win32') - _internals.canonicalizeWslLinuxPath('Ubuntu', '/home/oversized') - const callback = execFileMock.mock.calls[0][3] as (error: Error | null, stdout: string) => void - callback(null, `/${'x'.repeat(WSL_CANONICAL_PATH_CACHE_MAX_UTF8_BYTES)}\n`) - - expect(_internals.getWslCanonicalPathCacheSize()).toBe(0) - expect(_internals.getWslCanonicalPathCacheBytes()).toBe(0) - expect(_internals.canonicalizeWslLinuxPath('Ubuntu', '/home/oversized')).toBeNull() - }) - - it('retains a canonical identity at the exact aggregate byte budget', () => { - setPlatform('win32') - const logicalPath = '/home/exact-limit' - const keyBytes = Buffer.byteLength(`Ubuntu\0${logicalPath}`, 'utf8') - const canonicalPath = `/${'x'.repeat(WSL_CANONICAL_PATH_CACHE_MAX_UTF8_BYTES - keyBytes - 1)}` - _internals.canonicalizeWslLinuxPath('Ubuntu', logicalPath) - const callback = execFileMock.mock.calls[0][3] as (error: Error | null, stdout: string) => void - callback(null, canonicalPath) - - expect(_internals.getWslCanonicalPathCacheSize()).toBe(1) - expect(_internals.getWslCanonicalPathCacheBytes()).toBe(WSL_CANONICAL_PATH_CACHE_MAX_UTF8_BYTES) - expect(_internals.canonicalizeWslLinuxPath('Ubuntu', logicalPath)).toBe(canonicalPath) - }) }) diff --git a/src/main/codex/codex-wsl-hook-install-plan.ts b/src/main/codex/codex-wsl-hook-install-plan.ts index 31c92729771..4db2bdea93f 100644 --- a/src/main/codex/codex-wsl-hook-install-plan.ts +++ b/src/main/codex/codex-wsl-hook-install-plan.ts @@ -48,81 +48,17 @@ function toDefaultWslLinuxPath(windowsPath: string): string { const WSL_CANONICALIZE_TIMEOUT_MS = 5000 const WSL_PATH_MISSING_OUTPUT = '__ORCA_WSL_PATH_MISSING__' -export const WSL_CANONICAL_PATH_CACHE_MAX_ENTRIES = 256 -export const WSL_CANONICAL_PATH_CACHE_MAX_UTF8_BYTES = 2 * 1024 * 1024 // Why: `readlink -f` over wsl.exe stalls up to the timeout on a cold or wedged // distro. Running it synchronously on the Electron main process froze the UI on // every Codex WSL launch, so resolve it off-thread and cache the latest result. -const canonicalWslPathCache = new Map() -let canonicalWslPathCacheBytes = 0 +const canonicalWslPathCache = new Map() const inFlightWslCanonicalizations = new Map>() function wslCanonicalizeCacheKey(distro: string, linuxPath: string): string { return `${distro}\x00${linuxPath}` } -function getCachedWslCanonicalPath(key: string): string | null { - const cached = canonicalWslPathCache.get(key) - if (!cached) { - return null - } - canonicalWslPathCache.delete(key) - canonicalWslPathCache.set(key, cached) - return cached.canonicalPath -} - -function deleteCachedWslCanonicalPath(key: string): void { - const cached = canonicalWslPathCache.get(key) - if (!cached) { - return - } - canonicalWslPathCache.delete(key) - canonicalWslPathCacheBytes -= cached.retainedBytes -} - -function cacheWslCanonicalPath(key: string, canonicalPath: string): void { - deleteCachedWslCanonicalPath(key) - const retainedBytes = Buffer.byteLength(key, 'utf8') + Buffer.byteLength(canonicalPath, 'utf8') - if (retainedBytes > WSL_CANONICAL_PATH_CACHE_MAX_UTF8_BYTES) { - return - } - while ( - canonicalWslPathCache.size >= WSL_CANONICAL_PATH_CACHE_MAX_ENTRIES || - canonicalWslPathCacheBytes + retainedBytes > WSL_CANONICAL_PATH_CACHE_MAX_UTF8_BYTES - ) { - const oldestKey = canonicalWslPathCache.keys().next().value as string | undefined - if (oldestKey === undefined) { - break - } - deleteCachedWslCanonicalPath(oldestKey) - } - canonicalWslPathCache.set(key, { canonicalPath, retainedBytes }) - canonicalWslPathCacheBytes += retainedBytes -} - -function settleWslCanonicalization(key: string, settlement: WslCanonicalPathSettlement): void { - if (settlement.status === 'resolved') { - cacheWslCanonicalPath(key, settlement.canonicalPath) - } else if (settlement.status === 'missing') { - // Why: a successful directory probe is stronger than a transport error; - // clear the identity so stale trust can be revoked and later rediscovered. - deleteCachedWslCanonicalPath(key) - } - // Why: keep the last known-good cache on timeout/transient WSL failures. - // Dropping it forces the next launch onto the logical `/mnt/...` guess, - // which is wrong under custom automount roots and rewrites trust keys. - const settledListeners = inFlightWslCanonicalizations.get(key) ?? new Set() - inFlightWslCanonicalizations.delete(key) - for (const listener of settledListeners) { - try { - listener(settlement) - } catch (listenerError) { - console.warn('[codex-wsl-hook-path] failed to reconcile canonical path', listenerError) - } - } -} - function scheduleWslLinuxPathCanonicalization( distro: string, linuxPath: string, @@ -166,28 +102,40 @@ function scheduleWslLinuxPathCanonicalization( 'sh', linuxPath ] - try { - execFile( - 'wsl.exe', - args, - { encoding: 'utf-8', timeout: WSL_CANONICALIZE_TIMEOUT_MS, windowsHide: true }, - (error, stdout) => { - const canonicalPath = stdout.trim() - const resolvedPath = !error && canonicalPath.startsWith('/') ? canonicalPath : null - const pathMissing = !error && canonicalPath === WSL_PATH_MISSING_OUTPUT - settleWslCanonicalization( - key, - resolvedPath - ? { status: 'resolved', canonicalPath: resolvedPath } - : pathMissing - ? { status: 'missing' } - : { status: 'unavailable' } - ) + execFile( + 'wsl.exe', + args, + { encoding: 'utf-8', timeout: WSL_CANONICALIZE_TIMEOUT_MS, windowsHide: true }, + (error, stdout) => { + const canonicalPath = stdout.trim() + const resolvedPath = !error && canonicalPath.startsWith('/') ? canonicalPath : null + const pathMissing = !error && canonicalPath === WSL_PATH_MISSING_OUTPUT + const settlement: WslCanonicalPathSettlement = resolvedPath + ? { status: 'resolved', canonicalPath: resolvedPath } + : pathMissing + ? { status: 'missing' } + : { status: 'unavailable' } + if (settlement.status === 'resolved') { + canonicalWslPathCache.set(key, canonicalPath) + } else if (settlement.status === 'missing') { + // Why: a successful directory probe is stronger than a transport error; + // clear the identity so stale trust can be revoked and later rediscovered. + canonicalWslPathCache.delete(key) } - ) - } catch { - settleWslCanonicalization(key, { status: 'unavailable' }) - } + // Why: keep the last known-good cache on timeout/transient WSL failures. + // Dropping it forces the next launch onto the logical `/mnt/...` guess, + // which is wrong under custom automount roots and rewrites trust keys. + const settledListeners = inFlightWslCanonicalizations.get(key) ?? new Set() + inFlightWslCanonicalizations.delete(key) + for (const listener of settledListeners) { + try { + listener(settlement) + } catch (listenerError) { + console.warn('[codex-wsl-hook-path] failed to reconcile canonical path', listenerError) + } + } + } + ) } function canonicalizeWslLinuxPath( @@ -199,7 +147,7 @@ function canonicalizeWslLinuxPath( if (process.platform !== 'win32') { return linuxPath } - const cached = getCachedWslCanonicalPath(wslCanonicalizeCacheKey(distro, linuxPath)) + const cached = canonicalWslPathCache.get(wslCanonicalizeCacheKey(distro, linuxPath)) // Why: every launch revalidates asynchronously. Returning the cache keeps // launch prep synchronous while settlement repairs or revokes trust in-place. scheduleWslLinuxPathCanonicalization(distro, linuxPath, windowsPath, onSettled) @@ -251,13 +199,6 @@ export const _internals = { canonicalizeWslLinuxPath, resetWslCanonicalPathCache(): void { canonicalWslPathCache.clear() - canonicalWslPathCacheBytes = 0 inFlightWslCanonicalizations.clear() - }, - getWslCanonicalPathCacheSize(): number { - return canonicalWslPathCache.size - }, - getWslCanonicalPathCacheBytes(): number { - return canonicalWslPathCacheBytes } } diff --git a/src/main/codex/codex-wsl-reconciliation-generations.test.ts b/src/main/codex/codex-wsl-reconciliation-generations.test.ts deleted file mode 100644 index 7d7166d6344..00000000000 --- a/src/main/codex/codex-wsl-reconciliation-generations.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { CodexWslReconciliationGenerations } from './codex-wsl-reconciliation-generations' - -const BOUNDS = { - maxEntries: 2, - maxKeyBytes: 4, - maxTotalKeyBytes: 6 -} - -describe('CodexWslReconciliationGenerations', () => { - it('invalidates an older reconciliation for the same runtime home', () => { - const generations = new CodexWslReconciliationGenerations(BOUNDS) - const stale = generations.advance('a') - const current = generations.advance('a') - - expect(generations.isCurrent('a', stale)).toBe(false) - expect(generations.isCurrent('a', current)).toBe(true) - }) - - it('bounds retained homes and fails closed for an evicted callback', () => { - const generations = new CodexWslReconciliationGenerations(BOUNDS) - const evicted = generations.advance('a') - generations.advance('bb') - generations.advance('ccc') - - expect(generations.isCurrent('a', evicted)).toBe(false) - expect(generations.evidence()).toEqual({ entries: 2, keyBytes: 5 }) - }) - - it('does not retain an oversized home key', () => { - const generations = new CodexWslReconciliationGenerations(BOUNDS) - const generation = generations.advance('oversized') - - expect(generations.isCurrent('oversized', generation)).toBe(false) - expect(generations.evidence()).toEqual({ entries: 0, keyBytes: 0 }) - }) -}) diff --git a/src/main/codex/codex-wsl-reconciliation-generations.ts b/src/main/codex/codex-wsl-reconciliation-generations.ts deleted file mode 100644 index caf2cfeb1bb..00000000000 --- a/src/main/codex/codex-wsl-reconciliation-generations.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { measureUtf8ByteLength } from '../../shared/utf8-byte-limits' - -export type CodexWslReconciliationGenerationBounds = { - maxEntries: number - maxKeyBytes: number - maxTotalKeyBytes: number -} - -export const DEFAULT_CODEX_WSL_RECONCILIATION_GENERATION_BOUNDS: CodexWslReconciliationGenerationBounds = - { - maxEntries: 256, - maxKeyBytes: 64 * 1024, - maxTotalKeyBytes: 1024 * 1024 - } - -type RetainedGeneration = { - generation: number - keyBytes: number -} - -export class CodexWslReconciliationGenerations { - private readonly generations = new Map() - private nextGeneration = 0 - private retainedKeyBytes = 0 - - constructor( - private readonly bounds: CodexWslReconciliationGenerationBounds = DEFAULT_CODEX_WSL_RECONCILIATION_GENERATION_BOUNDS - ) { - for (const value of Object.values(bounds)) { - if (!Number.isSafeInteger(value) || value < 1) { - throw new RangeError('Codex WSL reconciliation bounds must be positive integers') - } - } - } - - advance(key: string): number { - const generation = ++this.nextGeneration - const keyBytes = measureUtf8ByteLength(key, { - stopAfterBytes: this.bounds.maxKeyBytes - }) - this.delete(key) - if (keyBytes.exceededLimit || keyBytes.byteLength > this.bounds.maxTotalKeyBytes) { - return generation - } - while ( - this.generations.size >= this.bounds.maxEntries || - this.retainedKeyBytes + keyBytes.byteLength > this.bounds.maxTotalKeyBytes - ) { - const oldest = this.generations.keys().next().value - if (oldest === undefined) { - return generation - } - this.delete(oldest) - } - this.generations.set(key, { generation, keyBytes: keyBytes.byteLength }) - this.retainedKeyBytes += keyBytes.byteLength - return generation - } - - isCurrent(key: string, generation: number): boolean { - return this.generations.get(key)?.generation === generation - } - - evidence(): { entries: number; keyBytes: number } { - return { entries: this.generations.size, keyBytes: this.retainedKeyBytes } - } - - private delete(key: string): void { - const retained = this.generations.get(key) - if (!retained) { - return - } - this.generations.delete(key) - this.retainedKeyBytes -= retained.keyBytes - } -} diff --git a/src/main/codex/config-toml-trust.ts b/src/main/codex/config-toml-trust.ts index f1f5c991e68..a4768730c5b 100644 --- a/src/main/codex/config-toml-trust.ts +++ b/src/main/codex/config-toml-trust.ts @@ -3,6 +3,7 @@ import { existsSync, lstatSync, mkdirSync, + readFileSync, realpathSync, statSync, unlinkSync, @@ -11,7 +12,6 @@ import { import { basename, dirname, join, posix as pathPosix, win32 as pathWin32 } from 'node:path' import { createHash, randomUUID } from 'node:crypto' import { renameFileWithWindowsRetry } from '../codex-accounts/fs-utils' -import { readAgentStateFileSync } from '../agent-state-file-reader' import { foldWslUncPathCaseInsensitiveParts } from '../../shared/wsl-paths' import { writeRollingFileBackup } from '../rolling-file-backup' import { @@ -327,7 +327,7 @@ function isCodexEventLabel(value: string): value is CodexEventLabel { // Why: strip a leading BOM (some Windows editors write one) so header regexes anchored at `^[ \t]*\[` still match. function readTomlFile(configPath: string): string { - const raw = readAgentStateFileSync(configPath) + const raw = readFileSync(configPath, 'utf-8') return raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw } diff --git a/src/main/codex/hook-service.ts b/src/main/codex/hook-service.ts index e42bf1620e7..1c4052cefca 100644 --- a/src/main/codex/hook-service.ts +++ b/src/main/codex/hook-service.ts @@ -1,9 +1,8 @@ /* eslint-disable max-lines -- Why: getStatus + install + remove all share the managed-command and trust-key derivation. Splitting would hide that the three operations must agree on group index, event label, and command bytes. */ -import { existsSync, statSync, unlinkSync } from 'node:fs' +import { existsSync, readFileSync, statSync, unlinkSync } from 'node:fs' import { join, win32 as pathWin32 } from 'node:path' import type { SFTPWrapper } from 'ssh2' import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types' -import { readAgentStateFileSync } from '../agent-state-file-reader' import { buildManagedCommandHook, createManagedCommandMatcher, @@ -12,7 +11,6 @@ import { hookDefinitionHasManagedCommand, MANAGED_HOOK_TIMEOUT_SECONDS, readHooksJson, - readHooksJsonRawForGenerationCheck, readHooksJsonWithRaw, removeManagedCommands, wrapPosixHookCommand, @@ -83,7 +81,6 @@ import { } from './codex-managed-trust-reconciliation' import type { CodexTrustGrantLedgerHome } from './codex-trust-grant-ledger' import { mutateRealHomeHooksPreservingUserTrust } from './codex-user-hook-trust-rebase' -import { CodexWslReconciliationGenerations } from './codex-wsl-reconciliation-generations' // Why: Pre/PostToolUse feed the live in-flight-tool readout; PermissionRequest exits with no decision so Codex still shows its approval UI while Orca flips the pane to waiting. const CODEX_EVENTS = [ @@ -544,7 +541,7 @@ function applyMirroredRuntimeUserHookTrustStates( return } - const existing = readAgentStateFileSync(tomlPath) + const existing = readFileSync(tomlPath, 'utf-8') let updated = existing for (const { entry, enabled } of entries) { const headerKeyPattern = buildHookTrustHeaderKeyPattern(computeTrustKey(entry)) @@ -648,7 +645,7 @@ function cleanupLegacySystemManagedHooks(): void { afterHooks: nextHooks, writeHooks: () => { if ( - readHooksJsonRawForGenerationCheck(legacyConfigPath) !== previousRaw || + readFileSync(legacyConfigPath, 'utf-8') !== previousRaw || resolveHooksJsonWritePath(legacyConfigPath) !== hooksWritePath ) { // Why: the pre-mutation RPC may overlap a user save; downgrade must @@ -694,7 +691,7 @@ function cleanupLegacyCodexProfileHooks(): void { return } - const existing = readAgentStateFileSync(profilePath) + const existing = readFileSync(profilePath, 'utf-8') const next = stripLegacyManagedProfileBlock(existing) if (next === existing) { return @@ -1024,14 +1021,16 @@ function getWslReconciliationKey(runtimeHomePath: string): string { } export class CodexHookService { - private readonly wslReconciliationGenerations = new CodexWslReconciliationGenerations() + private readonly wslReconciliationGeneration = new Map() private supersedeWslReconciliation(runtimeHomePath: string | null | undefined): number { if (!runtimeHomePath) { return 0 } const key = getWslReconciliationKey(runtimeHomePath) - return this.wslReconciliationGenerations.advance(key) + const generation = (this.wslReconciliationGeneration.get(key) ?? 0) + 1 + this.wslReconciliationGeneration.set(key, generation) + return generation } installForRuntimeHome( @@ -1059,7 +1058,7 @@ export class CodexHookService { : null const action = getWslHookReconciliationAction({ settlement, - isCurrentGeneration: this.wslReconciliationGenerations.isCurrent(key, generation), + isCurrentGeneration: this.wslReconciliationGeneration.get(key) === generation, installedTrustConfigPath, resolvedTrustConfigPath: resolvedPlan?.trustConfigPath ?? null, installSucceeded diff --git a/src/main/codex/hook-trust-promotion.ts b/src/main/codex/hook-trust-promotion.ts index 76276add0fb..16b19ceeae6 100644 --- a/src/main/codex/hook-trust-promotion.ts +++ b/src/main/codex/hook-trust-promotion.ts @@ -1,7 +1,5 @@ -import { existsSync, writeFileSync } from 'node:fs' +import { existsSync, readFileSync, writeFileSync } from 'node:fs' import { join } from 'node:path' -import { MAX_AGENT_STATE_FILE_BYTES, readAgentStateJsonFileSync } from '../agent-state-file-reader' -import { stringifyJsonWithinByteLimit } from '../../shared/node-bounded-json-stringify' import { createManagedCommandMatcher, readHooksJson, @@ -53,7 +51,7 @@ function readHookTrustProvenance( return null } try { - const parsed = readAgentStateJsonFileSync(provenancePath) + const parsed: unknown = JSON.parse(readFileSync(provenancePath, 'utf-8')) if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { return null } @@ -99,8 +97,7 @@ export function snapshotCodexRuntimeHookTrustProvenance( } } const file: HookTrustProvenanceFile = { version: 1, entries } - const { serialized } = stringifyJsonWithinByteLimit(file, MAX_AGENT_STATE_FILE_BYTES - 1, 2) - writeFileSync(getProvenancePath(runtimeHomePath), `${serialized}\n`, { + writeFileSync(getProvenancePath(runtimeHomePath), `${JSON.stringify(file, null, 2)}\n`, { encoding: 'utf-8', mode: 0o600 }) diff --git a/src/main/computer/macos-computer-use-permission-status.test.ts b/src/main/computer/macos-computer-use-permission-status.test.ts index 09323ccc85f..cfa511b11ce 100644 --- a/src/main/computer/macos-computer-use-permission-status.test.ts +++ b/src/main/computer/macos-computer-use-permission-status.test.ts @@ -1,17 +1,12 @@ import { execFileSync, spawn, spawnSync } from 'node:child_process' -import { mkdtemp, rm, stat } from 'node:fs/promises' +import { mkdtemp, readFile, rm, stat } from 'node:fs/promises' import { join } from 'node:path' -import { EventEmitter } from 'node:events' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import * as nodeBoundedFileReader from '../../shared/node-bounded-file-reader' const resolveHelperAppPathMock = vi.hoisted(() => vi.fn()) const resolveHelperExecutablePathMock = vi.hoisted(() => vi.fn()) const permissionStatusTempDir = '/tmp/orca-computer-use-permissions-test' const permissionStatusPath = join(permissionStatusTempDir, 'status.json') -const { readNodeFileWithinLimitMock } = vi.hoisted(() => ({ - readNodeFileWithinLimitMock: vi.fn() -})) vi.mock('child_process', () => ({ execFileSync: vi.fn(), @@ -35,15 +30,11 @@ vi.mock('child_process', () => ({ vi.mock('fs/promises', () => ({ mkdtemp: vi.fn(), + readFile: vi.fn(), rm: vi.fn(), stat: vi.fn() })) -vi.mock('../../shared/node-bounded-file-reader', async (importOriginal) => { - const actual = await importOriginal() - return { ...actual, readNodeFileWithinLimit: readNodeFileWithinLimitMock } -}) - vi.mock('./macos-native-provider-paths', () => ({ resolveMacOSComputerUseAppPath: resolveHelperAppPathMock, resolveMacOSComputerUseExecutablePath: resolveHelperExecutablePathMock @@ -57,7 +48,7 @@ describe('getComputerUsePermissionStatus', () => { vi.mocked(spawnSync).mockClear() vi.mocked(execFileSync).mockReset() vi.mocked(mkdtemp).mockReset() - readNodeFileWithinLimitMock.mockReset() + vi.mocked(readFile).mockReset() vi.mocked(rm).mockReset() vi.mocked(stat).mockReset() resolveHelperAppPathMock.mockReset() @@ -171,56 +162,6 @@ describe('getComputerUsePermissionStatus', () => { }) }) - it('kills the helper and rejects oversized launch output', async () => { - const { getComputerUsePermissionStatus } = await import('./macos-computer-use-permissions') - const child = new EventEmitter() as EventEmitter & { - stdout: EventEmitter & { setEncoding: ReturnType } - stderr: EventEmitter & { setEncoding: ReturnType } - kill: ReturnType - } - child.stdout = Object.assign(new EventEmitter(), { setEncoding: vi.fn() }) - child.stderr = Object.assign(new EventEmitter(), { setEncoding: vi.fn() }) - child.kill = vi.fn() - vi.mocked(spawn).mockImplementationOnce(() => child as unknown as ReturnType) - - const statusPromise = getComputerUsePermissionStatus() - await Promise.resolve() - child.stdout.emit('data', 'x'.repeat(64 * 1024 + 1)) - - await expect(statusPromise).rejects.toMatchObject({ - name: 'RuntimeClientError', - code: 'accessibility_error', - message: 'Permission helper returned too much launch output' - }) - expect(child.kill).toHaveBeenCalledTimes(1) - }) - - it('preserves helper diagnostics delivered as 50,000 one-byte fragments', async () => { - const { getComputerUsePermissionStatus } = await import('./macos-computer-use-permissions') - const child = new EventEmitter() as EventEmitter & { - stdout: EventEmitter & { setEncoding: ReturnType } - stderr: EventEmitter & { setEncoding: ReturnType } - kill: ReturnType - } - child.stdout = Object.assign(new EventEmitter(), { setEncoding: vi.fn() }) - child.stderr = Object.assign(new EventEmitter(), { setEncoding: vi.fn() }) - child.kill = vi.fn() - vi.mocked(spawn).mockImplementationOnce(() => child as unknown as ReturnType) - const statusPromise = getComputerUsePermissionStatus() - await Promise.resolve() - - for (let index = 0; index < 50_000; index += 1) { - child.stderr.emit('data', ' ') - } - child.stderr.emit('data', 'permission denied') - child.emit('close', 1) - - await expect(statusPromise).rejects.toMatchObject({ - code: 'accessibility_error', - message: 'Could not check permissions: permission denied' - }) - }) - it('reads permission status through the helper app identity', async () => { const { getComputerUsePermissionStatus } = await import('./macos-computer-use-permissions') mockPermissionStatus('{"accessibility":"granted","screenshots":"not-granted"}') @@ -246,26 +187,13 @@ describe('getComputerUsePermissionStatus', () => { { stdio: ['ignore', 'pipe', 'pipe'] } ) expect(spawnSync).not.toHaveBeenCalled() - expect(readNodeFileWithinLimitMock).toHaveBeenCalledWith(permissionStatusPath, 64 * 1024) + expect(readFile).toHaveBeenCalledWith(permissionStatusPath, 'utf8') expect(rm).toHaveBeenCalledWith(permissionStatusTempDir, { recursive: true, force: true }) }) - it('maps an oversized permission status file to a stable runtime error', async () => { - const { getComputerUsePermissionStatus } = await import('./macos-computer-use-permissions') - readNodeFileWithinLimitMock.mockRejectedValueOnce( - new nodeBoundedFileReader.NodeFileReadTooLargeError(64 * 1024 + 1, 64 * 1024) - ) - - await expect(getComputerUsePermissionStatus()).rejects.toMatchObject({ - name: 'RuntimeClientError', - code: 'accessibility_error', - message: 'Permission helper returned too much status data' - }) - }) - it('returns unavailable permission status when the helper app is missing on macOS', async () => { const { getComputerUsePermissionStatus } = await import('./macos-computer-use-permissions') resolveHelperAppPathMock.mockReturnValue(null) @@ -285,10 +213,7 @@ describe('getComputerUsePermissionStatus', () => { function mockPermissionStatus(json: string): void { vi.mocked(spawnSync).mockReturnValue({ status: 0 } as ReturnType) - readNodeFileWithinLimitMock.mockResolvedValue({ - buffer: Buffer.from(json), - stats: { size: Buffer.byteLength(json) } - }) + vi.mocked(readFile).mockResolvedValue(json) } function setPlatform(platform: NodeJS.Platform): void { diff --git a/src/main/computer/macos-computer-use-permission-status.ts b/src/main/computer/macos-computer-use-permission-status.ts index 431ea47b08f..b23c3e9ab96 100644 --- a/src/main/computer/macos-computer-use-permission-status.ts +++ b/src/main/computer/macos-computer-use-permission-status.ts @@ -1,5 +1,5 @@ import { spawn } from 'node:child_process' -import { mkdtemp, rm, stat } from 'node:fs/promises' +import { mkdtemp, readFile, rm, stat } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { setTimeout as delay } from 'node:timers/promises' @@ -13,14 +13,8 @@ import { resolveMacOSComputerUseExecutablePath } from './macos-native-provider-paths' import { RuntimeClientError } from './runtime-client-error' -import { - NodeFileReadTooLargeError, - readNodeFileWithinLimit -} from '../../shared/node-bounded-file-reader' -import { GrowingByteBuffer } from '../../shared/growing-byte-buffer' const PERMISSION_STATUS_HELPER_LAUNCH_TIMEOUT_MS = 5_000 -export const PERMISSION_STATUS_MAX_BYTES = 64 * 1024 export function getComputerUsePermissionStatus(): Promise { return getComputerUsePermissionStatusAsync() @@ -92,22 +86,10 @@ async function readPermissionStatusFromHelperApp( for (let attempt = 0; attempt < 50; attempt++) { if (await fileExists(statusPath)) { - try { - const output = ( - await readNodeFileWithinLimit(statusPath, PERMISSION_STATUS_MAX_BYTES) - ).buffer.toString('utf8') - return JSON.parse(output) as Partial< - Record - > - } catch (error) { - if (error instanceof NodeFileReadTooLargeError) { - throw new RuntimeClientError( - 'accessibility_error', - 'Permission helper returned too much status data' - ) - } - throw error - } + const output = await readFile(statusPath, 'utf8') + return JSON.parse(output) as Partial< + Record + > } await delay(100) } @@ -126,41 +108,16 @@ function launchPermissionStatusHelper(helperAppPath: string, statusPath: string) stdio: ['ignore', 'pipe', 'pipe'] } ) - const stdout = new GrowingByteBuffer() - const stderr = new GrowingByteBuffer() - let outputBytes = 0 + let stdout = '' + let stderr = '' launch.stdout?.setEncoding('utf8') launch.stderr?.setEncoding('utf8') const onStdoutData = (chunk: string): void => { - const bytes = Buffer.from(chunk) - outputBytes += bytes.byteLength - if (outputBytes > PERMISSION_STATUS_MAX_BYTES) { - launch.kill() - settleReject( - new RuntimeClientError( - 'accessibility_error', - 'Permission helper returned too much launch output' - ) - ) - return - } - stdout.append(bytes) + stdout += chunk } const onStderrData = (chunk: string): void => { - const bytes = Buffer.from(chunk) - outputBytes += bytes.byteLength - if (outputBytes > PERMISSION_STATUS_MAX_BYTES) { - launch.kill() - settleReject( - new RuntimeClientError( - 'accessibility_error', - 'Permission helper returned too much launch output' - ) - ) - return - } - stderr.append(bytes) + stderr += chunk } let settled = false let launchTimeout: ReturnType | null = null @@ -180,8 +137,6 @@ function launchPermissionStatusHelper(helperAppPath: string, statusPath: string) } settled = true removeListeners() - stdout.clear() - stderr.clear() resolve() } const settleReject = (error: Error): void => { @@ -190,8 +145,6 @@ function launchPermissionStatusHelper(helperAppPath: string, statusPath: string) } settled = true removeListeners() - stdout.clear() - stderr.clear() reject(error) } const onError = (): void => { @@ -207,8 +160,7 @@ function launchPermissionStatusHelper(helperAppPath: string, statusPath: string) settleResolve() return } - const detail = - stderr.toString().trim() || stdout.toString().trim() || `exit ${status ?? 'unknown'}` + const detail = stderr.trim() || stdout.trim() || `exit ${status ?? 'unknown'}` settleReject( new RuntimeClientError('accessibility_error', `Could not check permissions: ${detail}`) ) diff --git a/src/main/computer/macos-computer-use-permissions.test.ts b/src/main/computer/macos-computer-use-permissions.test.ts index 974c7ab8bcc..a8b6442b719 100644 --- a/src/main/computer/macos-computer-use-permissions.test.ts +++ b/src/main/computer/macos-computer-use-permissions.test.ts @@ -1,8 +1,7 @@ import { execFileSync, spawn, spawnSync } from 'node:child_process' -import { mkdtemp, rm, stat } from 'node:fs/promises' +import { mkdtemp, readFile, rm, stat } from 'node:fs/promises' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type * as nodeBoundedFileReader from '../../shared/node-bounded-file-reader' import { openComputerUsePermissions, resetComputerUsePermissions @@ -13,9 +12,6 @@ const resolveHelperExecutablePathMock = vi.hoisted(() => vi.fn()) const permissionStatusTempDir = '/tmp/orca-computer-use-permissions-test' const helperAppPath = '/Applications/Orca Computer Use.app' const helperInfoPlistPath = join(helperAppPath, 'Contents', 'Info.plist') -const { readNodeFileWithinLimitMock } = vi.hoisted(() => ({ - readNodeFileWithinLimitMock: vi.fn() -})) vi.mock('child_process', () => ({ execFileSync: vi.fn(), @@ -39,15 +35,11 @@ vi.mock('child_process', () => ({ vi.mock('fs/promises', () => ({ mkdtemp: vi.fn(), + readFile: vi.fn(), rm: vi.fn(), stat: vi.fn() })) -vi.mock('../../shared/node-bounded-file-reader', async (importOriginal) => { - const actual = await importOriginal() - return { ...actual, readNodeFileWithinLimit: readNodeFileWithinLimitMock } -}) - vi.mock('./macos-native-provider-paths', () => ({ resolveMacOSComputerUseAppPath: resolveHelperAppPathMock, resolveMacOSComputerUseExecutablePath: resolveHelperExecutablePathMock @@ -61,7 +53,7 @@ describe('openComputerUsePermissions', () => { vi.mocked(spawnSync).mockClear() vi.mocked(execFileSync).mockReset() vi.mocked(mkdtemp).mockReset() - readNodeFileWithinLimitMock.mockReset() + vi.mocked(readFile).mockReset() vi.mocked(rm).mockReset() vi.mocked(stat).mockReset() resolveHelperAppPathMock.mockReset() @@ -218,13 +210,9 @@ describe('openComputerUsePermissions', () => { it('resets stale macOS TCC grants for the helper bundle id', async () => { resolveHelperAppPathMock.mockReturnValue('/Applications/Orca Computer Use.app') - readNodeFileWithinLimitMock - .mockResolvedValueOnce( - boundedPermissionStatus('{"accessibility":"granted","screenshots":"granted"}') - ) - .mockResolvedValueOnce( - boundedPermissionStatus('{"accessibility":"not-granted","screenshots":"not-granted"}') - ) + vi.mocked(readFile) + .mockResolvedValueOnce('{"accessibility":"granted","screenshots":"granted"}') + .mockResolvedValueOnce('{"accessibility":"not-granted","screenshots":"not-granted"}') vi.mocked(execFileSync).mockReturnValueOnce('com.example.orca.computer-use\n') vi.mocked(spawnSync).mockReturnValue({ status: 0 } as ReturnType) @@ -258,14 +246,7 @@ describe('openComputerUsePermissions', () => { function mockPermissionStatus(json: string): void { vi.mocked(spawnSync).mockReturnValue({ status: 0 } as ReturnType) - readNodeFileWithinLimitMock.mockResolvedValue(boundedPermissionStatus(json)) -} - -function boundedPermissionStatus(json: string) { - return { - buffer: Buffer.from(json), - stats: { size: Buffer.byteLength(json) } - } + vi.mocked(readFile).mockResolvedValue(json) } function setPlatform(platform: NodeJS.Platform): void { diff --git a/src/main/computer/macos-native-provider-client.ts b/src/main/computer/macos-native-provider-client.ts index faf71f955d2..0b917037685 100644 --- a/src/main/computer/macos-native-provider-client.ts +++ b/src/main/computer/macos-native-provider-client.ts @@ -20,7 +20,7 @@ import { import { resolveMacOSComputerUseExecutablePath } from './macos-native-provider-paths' import { attachMacOSNativeProviderSocketListeners, - MacOSNativeProviderLineBuffer, + consumeNativeProviderLines, startMacOSNativeProviderSocket } from './macos-native-provider-transport' import { validateComputerProviderActionParams } from './computer-provider-action-validation' @@ -37,7 +37,7 @@ export class MacOSNativeProviderClient { private socketToken: string | null = null private nextId = 1 private pending = new Map() - private readonly socketBuffer = new MacOSNativeProviderLineBuffer() + private socketBuffer = '' private providerCapabilities: ComputerProviderCapabilities | null = null private socketListenerCleanup: (() => void) | null = null private socketStartGeneration = 0 @@ -70,16 +70,20 @@ export class MacOSNativeProviderClient { this.socketStartPromise = null this.socketStartGeneration++ this.providerCapabilities = null - this.socketBuffer.clear() + this.socketBuffer = '' this.cleanupActiveSocketListeners() if (socket && !socket.destroyed) { const id = this.nextId++ socket.write(`${JSON.stringify({ id, method: 'terminate', params: {}, token })}\n`) socket.end() } - this.rejectPending( - new RuntimeClientError('accessibility_error', 'native macOS provider shut down') - ) + for (const [id, pending] of this.pending) { + clearTimeout(pending.timer) + pending.reject( + new RuntimeClientError('accessibility_error', 'native macOS provider shut down') + ) + this.pending.delete(id) + } this.cleanupSocketDirectory() } private async call(method: NativeMethod, params: unknown): Promise { @@ -195,7 +199,7 @@ export class MacOSNativeProviderClient { this.socketToken = started.socketToken const socket = started.socket socket.setEncoding('utf8') - this.socketBuffer.clear() + this.socketBuffer = '' this.socketListenerCleanup = attachMacOSNativeProviderSocketListeners(socket, { data: (chunk) => this.handleSocketData(socket, chunk), close: () => this.handleSocketClose(socket), @@ -210,11 +214,10 @@ export class MacOSNativeProviderClient { if (this.socket !== socket) { return } - try { - this.socketBuffer.feed(chunk, (line) => this.handleLine(line)) - } catch (error) { - this.handleTransportError(socket, error instanceof Error ? error : new Error(String(error))) - } + this.socketBuffer += chunk + this.socketBuffer = consumeNativeProviderLines(this.socketBuffer, (line) => + this.handleLine(line) + ) } private handleLine(line: string): void { let response: NativeResponse @@ -242,7 +245,7 @@ export class MacOSNativeProviderClient { } this.cleanupActiveSocketListeners() this.socket = null - this.socketBuffer.clear() + this.socketBuffer = '' this.cleanupSocketDirectory() this.rejectPending( new RuntimeClientError('accessibility_error', 'native macOS helper app connection closed') @@ -256,7 +259,7 @@ export class MacOSNativeProviderClient { this.cleanupActiveSocketListeners() // Why: an active transport error makes the helper socket unreliable for the next request. this.socket = null - this.socketBuffer.clear() + this.socketBuffer = '' if (!socket.destroyed) { socket.destroy() } @@ -272,7 +275,7 @@ export class MacOSNativeProviderClient { } this.cleanupActiveSocketListeners() this.socket = null - this.socketBuffer.clear() + this.socketBuffer = '' if (!socket.destroyed) { socket.destroy() } diff --git a/src/main/computer/macos-native-provider-transport.test.ts b/src/main/computer/macos-native-provider-transport.test.ts deleted file mode 100644 index e3e17b30a72..00000000000 --- a/src/main/computer/macos-native-provider-transport.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { - MacOSNativeProviderLineBuffer, - MacOSNativeProviderResponseTooLargeError, - consumeNativeProviderLines -} from './macos-native-provider-transport' - -describe('consumeNativeProviderLines', () => { - it('preserves complete lines and returns the partial tail', () => { - const handleLine = vi.fn() - - const remaining = consumeNativeProviderLines('one\ntwo\npartial', handleLine, 16) - - expect(handleLine.mock.calls.map(([line]) => line)).toEqual(['one', 'two']) - expect(remaining).toBe('partial') - }) - - it('rejects a complete oversized response before dispatching it', () => { - const handleLine = vi.fn() - - expect(() => consumeNativeProviderLines('12345\n', handleLine, 4)).toThrow( - MacOSNativeProviderResponseTooLargeError - ) - expect(handleLine).not.toHaveBeenCalled() - }) - - it('rejects a newline-free response once its retained tail crosses the limit', () => { - expect(() => consumeNativeProviderLines('12345', vi.fn(), 4)).toThrow( - 'native macOS provider response exceeded' - ) - }) -}) - -describe('MacOSNativeProviderLineBuffer', () => { - it('parses a response delivered in more than 100,000 one-character fragments', () => { - const buffer = new MacOSNativeProviderLineBuffer() - const handleLine = vi.fn() - const line = JSON.stringify({ id: 1, ok: true, result: 'x'.repeat(100_000) }) - - for (const character of line) { - buffer.feed(character, handleLine) - } - buffer.feed('\n', handleLine) - - expect(handleLine).toHaveBeenCalledWith(line) - }) - - it('clears an oversized fragmented line and accepts a later response', () => { - const buffer = new MacOSNativeProviderLineBuffer(4) - const handleLine = vi.fn() - buffer.feed('12', handleLine) - expect(() => buffer.feed('345', handleLine)).toThrow(MacOSNativeProviderResponseTooLargeError) - - buffer.feed('ok\n', handleLine) - - expect(handleLine).toHaveBeenCalledWith('ok') - }) -}) diff --git a/src/main/computer/macos-native-provider-transport.ts b/src/main/computer/macos-native-provider-transport.ts index 262e6760589..54c4cf773aa 100644 --- a/src/main/computer/macos-native-provider-transport.ts +++ b/src/main/computer/macos-native-provider-transport.ts @@ -6,20 +6,8 @@ import { join } from 'node:path' import { randomUUID } from 'node:crypto' import { connectMacOSProviderSocket } from './macos-native-provider-socket' import { RuntimeClientError } from './runtime-client-error' -import { GrowingByteBuffer } from '../../shared/growing-byte-buffer' const HELPER_CONNECT_TIMEOUT_MS = 10_000 -export const MACOS_NATIVE_PROVIDER_MAX_RESPONSE_LINE_BYTES = 64 * 1024 * 1024 - -export class MacOSNativeProviderResponseTooLargeError extends Error { - constructor( - readonly observedBytes: number, - readonly maxBytes: number - ) { - super(`native macOS provider response exceeded ${maxBytes} byte line limit`) - this.name = 'MacOSNativeProviderResponseTooLargeError' - } -} export type StartedMacOSProviderSocket = { socket: net.Socket @@ -28,40 +16,6 @@ export type StartedMacOSProviderSocket = { socketToken: string } -export class MacOSNativeProviderLineBuffer { - private readonly pending = new GrowingByteBuffer() - - constructor(private readonly maxLineBytes = MACOS_NATIVE_PROVIDER_MAX_RESPONSE_LINE_BYTES) {} - - feed(chunk: string, handleLine: (line: string) => void): void { - let remaining = chunk - while (remaining.length > 0) { - const newline = remaining.indexOf('\n') - const hasNewline = newline >= 0 - const segment = hasNewline ? remaining.slice(0, newline) : remaining - remaining = hasNewline ? remaining.slice(newline + 1) : '' - const segmentBytes = Buffer.byteLength(segment) - const observedBytes = this.pending.byteLength + segmentBytes - if (observedBytes > this.maxLineBytes) { - this.pending.clear() - throw new MacOSNativeProviderResponseTooLargeError(observedBytes, this.maxLineBytes) - } - this.pending.append(Buffer.from(segment)) - if (!hasNewline) { - return - } - const line = this.pending.takeString('utf8') - if (line.trim()) { - handleLine(line) - } - } - } - - clear(): void { - this.pending.clear() - } -} - export function isMacOS14OrNewer(): boolean { const darwinMajor = Number.parseInt(release().split('.')[0] ?? '', 10) return Number.isFinite(darwinMajor) && darwinMajor >= 23 @@ -93,25 +47,16 @@ export function attachMacOSNativeProviderSocketListeners( export function consumeNativeProviderLines( buffer: string, - handleLine: (line: string) => void, - maxLineBytes = MACOS_NATIVE_PROVIDER_MAX_RESPONSE_LINE_BYTES + handleLine: (line: string) => void ): string { let remaining = buffer while (true) { const newline = remaining.indexOf('\n') if (newline < 0) { - const remainingBytes = Buffer.byteLength(remaining) - if (remainingBytes > maxLineBytes) { - throw new MacOSNativeProviderResponseTooLargeError(remainingBytes, maxLineBytes) - } return remaining } const line = remaining.slice(0, newline) remaining = remaining.slice(newline + 1) - const lineBytes = Buffer.byteLength(line) - if (lineBytes > maxLineBytes) { - throw new MacOSNativeProviderResponseTooLargeError(lineBytes, maxLineBytes) - } if (line.trim()) { handleLine(line) } diff --git a/src/main/computer/sidecar-client.test.ts b/src/main/computer/sidecar-client.test.ts index 61c0b5e8426..fb3b28bf444 100644 --- a/src/main/computer/sidecar-client.test.ts +++ b/src/main/computer/sidecar-client.test.ts @@ -8,7 +8,6 @@ import { import { callComputerSidecarAction, callComputerSidecarCapabilities, - COMPUTER_SIDECAR_MAX_QUEUED_CALLS, resetComputerSidecarForTest } from './sidecar-client' @@ -167,44 +166,6 @@ describe('computer sidecar client', () => { await expect(secondCall).resolves.toEqual({ provider: 'second' }) }) - it('fails closed when the serialized call queue reaches its cap', async () => { - const admitted = Array.from({ length: COMPUTER_SIDECAR_MAX_QUEUED_CALLS }, () => - callComputerSidecarCapabilities() - ) - const admittedSettlements = Promise.allSettled(admitted) - const child = children[0]! - - await expect(callComputerSidecarCapabilities()).rejects.toThrow( - 'computer sidecar queue limit reached' - ) - expect(child.sent).toHaveLength(1) - - child.emit('error', new Error('stop saturated sidecar')) - await admittedSettlements - }) - - it('releases queue admission after a call settles', async () => { - const firstCall = callComputerSidecarCapabilities() - const firstChild = children[0]! - const firstRequest = firstChild.sent[0]! - firstChild.emit('message', { - id: firstRequest.id, - ok: true, - result: { provider: 'first' } - }) - await expect(firstCall).resolves.toEqual({ provider: 'first' }) - - const secondCall = callComputerSidecarCapabilities() - const secondRequest = firstChild.sent[1]! - firstChild.emit('message', { - id: secondRequest.id, - ok: true, - result: { provider: 'second' } - }) - - await expect(secondCall).resolves.toEqual({ provider: 'second' }) - }) - it('marks synthetic action results without provider verification as unverified', async () => { const call = callComputerSidecarAction('click', { app: 'Finder', elementIndex: 0 }) const child = children[0]! diff --git a/src/main/computer/sidecar-client.ts b/src/main/computer/sidecar-client.ts index 731c6066eca..d0cfa9dfb2c 100644 --- a/src/main/computer/sidecar-client.ts +++ b/src/main/computer/sidecar-client.ts @@ -43,7 +43,6 @@ type PendingRequest = { } const REQUEST_TIMEOUT_MS = 60_000 -export const COMPUTER_SIDECAR_MAX_QUEUED_CALLS = 16 let sidecar: ComputerSidecarProcess | null = null // Why: Node treats unhandled child 'error' events as process exceptions, so @@ -123,20 +122,10 @@ class ComputerSidecarProcess { private pending = new Map() private queueTail: Promise | null = null private queueGeneration = 0 - private queuedCalls = 0 constructor(private readonly entryPath: string) {} call(method: ComputerSidecarMethod, params: unknown): Promise { - if (this.queuedCalls >= COMPUTER_SIDECAR_MAX_QUEUED_CALLS) { - return Promise.reject( - new RuntimeClientError( - 'accessibility_error', - 'computer sidecar queue limit reached; retry the computer-use request' - ) - ) - } - this.queuedCalls += 1 const generation = this.queueGeneration const run = () => { if (generation !== this.queueGeneration) { @@ -147,17 +136,8 @@ class ComputerSidecarProcess { } return this.send(method, params) } - let result: Promise - try { - result = this.queueTail ? this.queueTail.then(run, run) : run() - } catch (error) { - this.queuedCalls -= 1 - throw error - } - const trackedResult = result.finally(() => { - this.queuedCalls -= 1 - }) - const tail = trackedResult.then( + const result = this.queueTail ? this.queueTail.then(run, run) : run() + const tail = result.then( () => undefined, () => undefined ) @@ -167,7 +147,7 @@ class ComputerSidecarProcess { this.queueTail = null } }) - return trackedResult + return result } private send(method: ComputerSidecarMethod, params: unknown): Promise { diff --git a/src/main/crash-reporting/crash-report-store.test.ts b/src/main/crash-reporting/crash-report-store.test.ts index 14485a136cd..1a14a9fa2e6 100644 --- a/src/main/crash-reporting/crash-report-store.test.ts +++ b/src/main/crash-reporting/crash-report-store.test.ts @@ -12,13 +12,8 @@ vi.mock('../win32-utils', async (importOriginal) => { return { ...actual, grantDirAclAsync: grantDirAclAsyncMock } }) -import { - CrashReportStore, - MAX_CRASH_REPORT_FILE_BYTES, - MAX_CRASH_REPORT_JSON_STRUCTURAL_TOKENS -} from './crash-report-store' +import { CrashReportStore } from './crash-report-store' import type { CrashReportCreateInput } from '../../shared/crash-reporting' -import * as boundedFileReader from '../../shared/node-bounded-file-reader' const tempDirs: string[] = [] @@ -97,46 +92,6 @@ describe('CrashReportStore', () => { await expect(store.listRecent()).resolves.toEqual([]) }) - it('rejects an oversized sparse report file and recovers with the next report', async () => { - const { store, filePath } = await createStore() - await fs.writeFile(filePath, 'x') - await fs.truncate(filePath, MAX_CRASH_REPORT_FILE_BYTES + 1) - - await expect(store.listRecent()).resolves.toEqual([]) - await expect(store.record(input('after-oversize'))).resolves.toMatchObject({ - reason: 'after-oversize' - }) - await expect(store.listRecent()).resolves.toHaveLength(1) - }) - - it('rejects structurally amplified report JSON before parsing', async () => { - const { store, filePath } = await createStore() - await fs.writeFile( - filePath, - `{"reports":[${'0,'.repeat(MAX_CRASH_REPORT_JSON_STRUCTURAL_TOKENS)}0]}`, - 'utf8' - ) - vi.spyOn(console, 'warn').mockImplementation(() => {}) - const parseSpy = vi.spyOn(JSON, 'parse') - - await expect(store.listRecent()).resolves.toEqual([]) - expect(parseSpy).not.toHaveBeenCalled() - }) - - it('preserves prior reports when the next report exceeds the write ceiling', async () => { - const { store, filePath } = await createStore() - await store.record(input('preserved')) - const before = await fs.readFile(filePath, 'utf8') - const writeSpy = vi.spyOn(fs, 'writeFile') - - await expect(store.record(input('x'.repeat(MAX_CRASH_REPORT_FILE_BYTES)))).rejects.toThrow( - 'JSON output exceeds' - ) - - expect(writeSpy).not.toHaveBeenCalled() - await expect(fs.readFile(filePath, 'utf8')).resolves.toBe(before) - }) - it('allows a pending report to reach one terminal status only', async () => { const { store } = await createStore() const report = await store.record(input()) @@ -224,13 +179,11 @@ describe('CrashReportStore', () => { const reloaded = new CrashReportStore(filePath) vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') const readError = Object.assign(new Error('temporary read failure'), { code }) - const readSpy = vi - .spyOn(boundedFileReader, 'readNodeFileWithinLimit') - .mockRejectedValueOnce(readError) + const readFileSpy = vi.spyOn(fs, 'readFile').mockRejectedValueOnce(readError) await expect(reloaded.getLatestPending()).resolves.toMatchObject({ id: report.id }) - expect(readSpy).toHaveBeenCalledTimes(2) + expect(readFileSpy).toHaveBeenCalledTimes(2) if (code === 'EBUSY') { expect(grantDirAclAsyncMock).not.toHaveBeenCalled() } else { diff --git a/src/main/crash-reporting/crash-report-store.ts b/src/main/crash-reporting/crash-report-store.ts index e0299dc1e4a..35f9eb5cb48 100644 --- a/src/main/crash-reporting/crash-report-store.ts +++ b/src/main/crash-reporting/crash-report-store.ts @@ -12,14 +12,8 @@ import { type CrashReportRecord, type CrashReportStatus } from '../../shared/crash-reporting' -import * as boundedFileReader from '../../shared/node-bounded-file-reader' -import { stringifyJsonWithinByteLimit } from '../../shared/node-bounded-json-stringify' -import { assertJsonTextStructureWithinLimits } from '../../shared/json-text-structure-limit' const MAX_REPORTS = 5 -export const MAX_CRASH_REPORT_FILE_BYTES = 4 * 1024 * 1024 -export const MAX_CRASH_REPORT_JSON_STRUCTURAL_TOKENS = 1_000_000 -export const MAX_CRASH_REPORT_JSON_NESTING_DEPTH = 128 const RELATED_CRASH_WINDOW_MS = 5_000 const WINDOWS_FILE_OPERATION_RETRY_DELAYS_MS = [50, 100, 150, 200, 250] @@ -208,18 +202,8 @@ export class CrashReportStore { try { const raw = await runCrashReportFileOperationWithWindowsRecovery( path.dirname(this.filePath), - async () => - ( - await boundedFileReader.readNodeFileWithinLimit( - this.filePath, - MAX_CRASH_REPORT_FILE_BYTES - ) - ).buffer.toString('utf8') + () => fs.readFile(this.filePath, 'utf8') ) - assertJsonTextStructureWithinLimits(raw, { - structuralTokens: MAX_CRASH_REPORT_JSON_STRUCTURAL_TOKENS, - nestingDepth: MAX_CRASH_REPORT_JSON_NESTING_DEPTH - }) const parsed = JSON.parse(raw) as Partial return Array.isArray(parsed.reports) ? parsed.reports.slice(0, MAX_REPORTS) : [] } catch (error) { @@ -235,13 +219,8 @@ export class CrashReportStore { const tmpPath = `${this.filePath}.${process.pid}.${Date.now()}.${crypto.randomUUID()}.tmp` try { await runCrashReportFileOperationWithWindowsRecovery(directory, async () => { - const { serialized } = stringifyJsonWithinByteLimit( - { reports }, - MAX_CRASH_REPORT_FILE_BYTES - Buffer.byteLength(os.EOL), - 2 - ) await fs.mkdir(directory, { recursive: true }) - await fs.writeFile(tmpPath, `${serialized}${os.EOL}`, 'utf8') + await fs.writeFile(tmpPath, `${JSON.stringify({ reports }, null, 2)}${os.EOL}`, 'utf8') await fs.rename(tmpPath, this.filePath) }) } finally { diff --git a/src/main/daemon/binary-frame.test.ts b/src/main/daemon/binary-frame.test.ts index 468c34a0dab..81148f18a59 100644 --- a/src/main/daemon/binary-frame.test.ts +++ b/src/main/daemon/binary-frame.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it, vi } from 'vitest' import { encodeFrame, createFrameParser, FrameType, FRAME_HEADER_SIZE } from './binary-frame' -import { FRAME_MAX_PAYLOAD } from './types' describe('encodeFrame', () => { it('encodes a data frame with correct header', () => { @@ -131,22 +130,6 @@ describe('createFrameParser', () => { expect(onFrame.mock.calls[0][1].length).toBe(0) }) - it('rejects an oversized declared payload from the header alone', () => { - const onFrame = vi.fn() - const parser = createFrameParser(onFrame) - const header = Buffer.alloc(FRAME_HEADER_SIZE) - header[0] = FrameType.Data - header.writeUInt32BE(FRAME_MAX_PAYLOAD + 1, 1) - - expect(() => parser.feed(header)).toThrow( - `Frame payload ${FRAME_MAX_PAYLOAD + 1} exceeds max ${FRAME_MAX_PAYLOAD}` - ) - expect(onFrame).not.toHaveBeenCalled() - - parser.feed(encodeFrame(FrameType.Data, Buffer.from('fresh'))) - expect(onFrame).toHaveBeenCalledOnce() - }) - it('parses different frame types correctly', () => { const onFrame = vi.fn() const parser = createFrameParser(onFrame) diff --git a/src/main/daemon/binary-frame.ts b/src/main/daemon/binary-frame.ts index c43a488cbe9..e8c4fd6ae90 100644 --- a/src/main/daemon/binary-frame.ts +++ b/src/main/daemon/binary-frame.ts @@ -29,10 +29,6 @@ export function createFrameParser( function parse(): void { while (buffer.length >= FRAME_HEADER_SIZE) { const payloadLength = buffer.readUInt32BE(1) - if (payloadLength > FRAME_MAX_PAYLOAD) { - buffer = Buffer.alloc(0) - throw new Error(`Frame payload ${payloadLength} exceeds max ${FRAME_MAX_PAYLOAD}`) - } const totalLength = FRAME_HEADER_SIZE + payloadLength if (buffer.length < totalLength) { diff --git a/src/main/daemon/client.test.ts b/src/main/daemon/client.test.ts index eb9dbb8de90..a3d6d9f610d 100644 --- a/src/main/daemon/client.test.ts +++ b/src/main/daemon/client.test.ts @@ -5,7 +5,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { mkdtempSync, writeFileSync, rmSync } from 'node:fs' import { DaemonClient } from './client' -import { DAEMON_HANDSHAKE_MAX_LINE_BYTES, encodeNdjson } from './ndjson' +import { encodeNdjson } from './ndjson' import type { HelloMessage, DaemonRequest, DaemonEvent } from './types' import { getDaemonSocketPath } from './daemon-spawner' @@ -310,31 +310,6 @@ describe('DaemonClient', () => { expect(socket.listenerCount('close')).toBe(0) }) - it('rejects a newline-free oversized hello response before the timeout', async () => { - client = new DaemonClient({ socketPath, tokenPath }) - const socket = new EventEmitter() as Socket - socket.write = vi.fn(() => true) as unknown as Socket['write'] - socket.destroy = vi.fn() as unknown as Socket['destroy'] - const sendHello = ( - client as unknown as { - sendHello( - socket: Socket, - token: string, - role: 'control' | 'stream', - timeoutMs: number - ): Promise - } - ).sendHello.bind(client) - - const promise = sendHello(socket, 'test-token-123', 'control', 60_000) - socket.emit('data', Buffer.alloc(DAEMON_HANDSHAKE_MAX_LINE_BYTES + 1, 0x78)) - - await expect(promise).rejects.toThrow('Invalid hello response') - expect(socket.listenerCount('data')).toBe(0) - expect(socket.listenerCount('error')).toBe(0) - expect(socket.listenerCount('close')).toBe(0) - }) - it('rejects when the daemon closes before hello completes', async () => { await startMockDaemon({ closeOnHello: true }) diff --git a/src/main/daemon/client.ts b/src/main/daemon/client.ts index 5f9650ad6cf..d0dd2453471 100644 --- a/src/main/daemon/client.ts +++ b/src/main/daemon/client.ts @@ -1,18 +1,9 @@ /* eslint-disable max-lines -- Why: daemon handshake, RPC, stream events, and reconnect cleanup share one socket lifecycle. */ import { connect, type Socket } from 'node:net' +import { readFileSync } from 'node:fs' import { randomUUID } from 'node:crypto' import { StringDecoder } from 'node:string_decoder' -import { - DAEMON_HANDSHAKE_MAX_LINE_BYTES, - encodeBoundedNdjson, - encodeNdjson, - createNdjsonParser -} from './ndjson' -import { - DAEMON_CONTROL_SOCKET_MAX_BUFFERED_BYTES, - DAEMON_MAX_ACTIVE_REQUEST_BYTES_PER_CLIENT, - DAEMON_MAX_ACTIVE_REQUESTS_PER_CLIENT -} from './daemon-admission-limits' +import { encodeNdjson, createNdjsonParser } from './ndjson' import { CLEAN_DISCONNECT_PROTOCOL_VERSION, PROTOCOL_VERSION, @@ -27,14 +18,10 @@ import type { DaemonEvent } from './types' import { addNodePtyRecoveryHint } from './node-pty-error-hints' -import { readDaemonControlFileText } from './daemon-control-file-reader' const CONNECT_TIMEOUT_MS = 5000 const CONNECTION_ATTEMPT_WAIT_MS = CONNECT_TIMEOUT_MS * 4 const REQUEST_TIMEOUT_MS = 30000 -export const DAEMON_CLIENT_MAX_PENDING_REQUESTS = DAEMON_MAX_ACTIVE_REQUESTS_PER_CLIENT -export const DAEMON_CLIENT_MAX_CONTROL_BUFFERED_BYTES = DAEMON_CONTROL_SOCKET_MAX_BUFFERED_BYTES -export const DAEMON_CLIENT_MAX_REQUEST_LINE_BYTES = DAEMON_MAX_ACTIVE_REQUEST_BYTES_PER_CLIENT export type DaemonClientOptions = { socketPath: string @@ -131,7 +118,7 @@ export class DaemonClient { attemptGeneration: number, sharedBudget: boolean ): Promise { - const token = readDaemonControlFileText(this.tokenPath).trim() + const token = readFileSync(this.tokenPath, 'utf-8').trim() const deadlineMs = Date.now() + timeoutMs const remainingMs = (): number => sharedBudget ? Math.max(1, deadlineMs - Date.now()) : timeoutMs @@ -209,18 +196,9 @@ export class DaemonClient { if (!this.connected || !this.controlSocket) { throw new DaemonProtocolError('Not connected') } - if (this.pendingRequests.size >= DAEMON_CLIENT_MAX_PENDING_REQUESTS) { - throw new DaemonProtocolError('Daemon client pending request limit reached') - } const id = `req-${++this.requestCounter}` const msg = { id, type, ...(payload !== undefined ? { payload } : {}) } - const encoded = encodeBoundedNdjson(msg, DAEMON_CLIENT_MAX_REQUEST_LINE_BYTES + 1) - const socket = this.controlSocket - if (!this.canBufferControlMessage(socket, encoded)) { - socket.destroy(new DaemonProtocolError('Daemon client control buffer limit reached')) - throw new DaemonProtocolError('Daemon client control buffer limit reached') - } return new Promise((resolve, reject) => { const timer = setTimeout(() => { @@ -234,13 +212,7 @@ export class DaemonClient { timer }) - try { - socket.write(encoded) - } catch (error) { - this.pendingRequests.delete(id) - clearTimeout(timer) - reject(error instanceof Error ? error : new DaemonProtocolError(String(error))) - } + this.controlSocket!.write(encodeNdjson(msg)) }) } @@ -251,14 +223,7 @@ export class DaemonClient { const id = `${NOTIFY_PREFIX}${++this.requestCounter}` const msg = { id, type, ...(payload !== undefined ? { payload } : {}) } - const encoded = encodeBoundedNdjson(msg, DAEMON_CLIENT_MAX_REQUEST_LINE_BYTES + 1) - if (!this.canBufferControlMessage(this.controlSocket, encoded)) { - this.controlSocket.destroy( - new DaemonProtocolError('Daemon client control buffer limit reached') - ) - return - } - this.controlSocket.write(encoded) + this.controlSocket.write(encodeNdjson(msg)) } onEvent(listener: (event: unknown) => void): () => void { @@ -368,6 +333,7 @@ export class DaemonClient { role } + let buffer = '' let settled = false let timer: ReturnType | null = null const cleanup = (): void => { @@ -394,12 +360,16 @@ export class DaemonClient { // Why: daemon socket chunks can split emoji/box-drawing UTF-8 bytes. // Decoding each Buffer independently would permanently inject U+FFFD. const decoder = new StringDecoder('utf8') - const parser = createNdjsonParser( - (message) => { - if (settled) { - return - } - const response = message as HelloResponse + const onData = (chunk: Buffer): void => { + buffer += decoder.write(chunk) + const newlineIdx = buffer.indexOf('\n') + if (newlineIdx === -1) { + return + } + + const line = buffer.slice(0, newlineIdx) + try { + const response = JSON.parse(line) as HelloResponse if (response.ok) { const identity = parseDaemonEndpointIdentity(response.daemonIdentity) if ( @@ -415,15 +385,9 @@ export class DaemonClient { new DaemonProtocolError(addNodePtyRecoveryHint(response.error ?? 'Hello rejected')) ) } - }, - () => finish(new DaemonProtocolError('Invalid hello response')), - { maxLineBytes: DAEMON_HANDSHAKE_MAX_LINE_BYTES } - ) - const onData = (chunk: Buffer): void => { - if (settled) { - return + } catch { + finish(new DaemonProtocolError('Invalid hello response')) } - parser.feed(decoder.write(chunk)) } const onError = (error: Error): void => finish(error) const onClose = (): void => @@ -491,11 +455,6 @@ export class DaemonClient { return () => socket.off('data', onData) } - private canBufferControlMessage(socket: Socket, encoded: string): boolean { - const messageBytes = Buffer.byteLength(encoded, 'utf8') - return messageBytes <= DAEMON_CLIENT_MAX_CONTROL_BUFFERED_BYTES - socket.writableLength - } - private handleDisconnect(generation: number): void { if (!this.disconnectArmed || generation !== this.connectionGeneration) { return diff --git a/src/main/daemon/daemon-admission-limits.test.ts b/src/main/daemon/daemon-admission-limits.test.ts deleted file mode 100644 index daa7c00f914..00000000000 --- a/src/main/daemon/daemon-admission-limits.test.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - DAEMON_CLIENT_ID_MAX_BYTES, - DAEMON_PTY_COMMAND_MAX_BYTES, - DAEMON_PTY_CWD_MAX_BYTES, - DAEMON_PTY_ENV_DELETE_MAX_ENTRIES, - DAEMON_PTY_ENV_MAX_BYTES, - DAEMON_PTY_ENV_MAX_ENTRIES, - DAEMON_PTY_ENV_VALUE_MAX_BYTES, - DAEMON_PTY_HISTORY_SEED_MAX_BYTES, - DAEMON_REQUEST_ID_MAX_BYTES, - DAEMON_SESSION_ID_MAX_BYTES, - daemonHelloAdmissionError, - daemonRequestAdmissionError -} from './daemon-admission-limits' -import { MAX_TERMINAL_COLS, MAX_TERMINAL_ROWS } from '../../shared/terminal-size-limits' - -function createRequest(payload: Record): Record { - return { id: 'req-1', type: 'createOrAttach', payload } -} - -describe('daemon admission limits', () => { - it('preserves ordinary cross-platform create fields without rewriting them', () => { - const request = createRequest({ - sessionId: 'repo::C:\\projects\\orca@@12345678', - cols: 120, - rows: 40, - cwd: '\\\\wsl.localhost\\Ubuntu\\home\\orca', - command: 'printf "ready"', - env: { PATH: 'C:\\Windows\\System32', ORCA_TERMINAL_HANDLE: 'term_123' }, - envToDelete: ['CODEX_HOME'], - terminalWindowsWslDistro: null, - historySeed: '\u001b[32mrestored\u001b[0m' - }) - - expect(daemonRequestAdmissionError(request)).toBeNull() - expect(request).toEqual( - expect.objectContaining({ - payload: expect.objectContaining({ - cwd: '\\\\wsl.localhost\\Ubuntu\\home\\orca', - terminalWindowsWslDistro: null - }) - }) - ) - }) - - it('measures hello and request identifiers by UTF-8 bytes', () => { - const exactClientId = 'é'.repeat(DAEMON_CLIENT_ID_MAX_BYTES / 2) - expect( - daemonHelloAdmissionError({ type: 'hello', role: 'control', clientId: exactClientId }) - ).toBeNull() - expect( - daemonHelloAdmissionError({ - type: 'hello', - role: 'control', - clientId: `${exactClientId}é` - }) - ).toContain(`${DAEMON_CLIENT_ID_MAX_BYTES} bytes`) - - const exactRequestId = 'é'.repeat(DAEMON_REQUEST_ID_MAX_BYTES / 2) - expect(daemonRequestAdmissionError({ id: exactRequestId, type: 'ping' })).toBeNull() - expect(daemonRequestAdmissionError({ id: `${exactRequestId}é`, type: 'ping' })).toContain( - `${DAEMON_REQUEST_ID_MAX_BYTES} bytes` - ) - }) - - it('rejects unbounded retained identifiers and PTY strings', () => { - expect( - daemonRequestAdmissionError({ - id: 'req', - type: 'getCwd', - payload: { sessionId: 's'.repeat(DAEMON_SESSION_ID_MAX_BYTES + 1) } - }) - ).toContain(`${DAEMON_SESSION_ID_MAX_BYTES} bytes`) - expect( - daemonRequestAdmissionError( - createRequest({ - sessionId: 's', - cwd: 'c'.repeat(DAEMON_PTY_CWD_MAX_BYTES + 1) - }) - ) - ).toContain(`${DAEMON_PTY_CWD_MAX_BYTES} bytes`) - expect( - daemonRequestAdmissionError( - createRequest({ - sessionId: 's', - command: 'c'.repeat(DAEMON_PTY_COMMAND_MAX_BYTES + 1) - }) - ) - ).toContain(`${DAEMON_PTY_COMMAND_MAX_BYTES} bytes`) - expect( - daemonRequestAdmissionError( - createRequest({ - sessionId: 's', - historySeed: 'h'.repeat(DAEMON_PTY_HISTORY_SEED_MAX_BYTES + 1) - }) - ) - ).toContain(`${DAEMON_PTY_HISTORY_SEED_MAX_BYTES} bytes`) - }) - - it('caps environment entry counts, individual values, and aggregate bytes', () => { - const tooManyEntries = Object.fromEntries( - Array.from({ length: DAEMON_PTY_ENV_MAX_ENTRIES + 1 }, (_, index) => [`K${index}`, 'v']) - ) - expect( - daemonRequestAdmissionError(createRequest({ sessionId: 's', env: tooManyEntries })) - ).toContain(`${DAEMON_PTY_ENV_MAX_ENTRIES} entries`) - - expect( - daemonRequestAdmissionError( - createRequest({ - sessionId: 's', - env: { VALUE: 'v'.repeat(DAEMON_PTY_ENV_VALUE_MAX_BYTES + 1) } - }) - ) - ).toContain(`${DAEMON_PTY_ENV_VALUE_MAX_BYTES} bytes`) - - const aggregate = Object.fromEntries( - Array.from({ length: 5 }, (_, index) => [ - `K${index}`, - 'v'.repeat(Math.floor(DAEMON_PTY_ENV_MAX_BYTES / 5)) - ]) - ) - expect( - daemonRequestAdmissionError(createRequest({ sessionId: 's', env: aggregate })) - ).toContain(`${DAEMON_PTY_ENV_MAX_BYTES} bytes`) - }) - - it('caps environment deletion lists and rejects malformed retained fields', () => { - expect( - daemonRequestAdmissionError( - createRequest({ - sessionId: 's', - envToDelete: Array.from( - { length: DAEMON_PTY_ENV_DELETE_MAX_ENTRIES + 1 }, - (_, index) => `K${index}` - ) - }) - ) - ).toContain(`${DAEMON_PTY_ENV_DELETE_MAX_ENTRIES} entries`) - expect( - daemonRequestAdmissionError(createRequest({ sessionId: 's', env: ['not-a-record'] })) - ).toBe('createOrAttach payload.env must be a string record') - expect(daemonRequestAdmissionError({ id: 'req', type: 'getCwd', payload: {} })).toBe( - 'getCwd payload.sessionId must be a string' - ) - }) - - it('rejects PTY dimensions that could force pathological native allocations', () => { - expect( - daemonRequestAdmissionError( - createRequest({ sessionId: 's', cols: MAX_TERMINAL_COLS + 1, rows: 24 }) - ) - ).toContain(`1 through ${MAX_TERMINAL_COLS}`) - expect( - daemonRequestAdmissionError({ - id: 'req', - type: 'resize', - payload: { sessionId: 's', cols: 80, rows: MAX_TERMINAL_ROWS + 1 } - }) - ).toContain(`1 through ${MAX_TERMINAL_ROWS}`) - }) -}) diff --git a/src/main/daemon/daemon-admission-limits.ts b/src/main/daemon/daemon-admission-limits.ts deleted file mode 100644 index babf2933550..00000000000 --- a/src/main/daemon/daemon-admission-limits.ts +++ /dev/null @@ -1,293 +0,0 @@ -import { measureUtf8ByteLength } from '../../shared/utf8-byte-limits' -import { terminalSizeAdmissionError } from '../../shared/terminal-size-limits' - -export const DAEMON_MAX_TRANSPORT_SOCKETS = 128 -export const DAEMON_MAX_CONTROL_CLIENTS = 32 -export const DAEMON_MAX_STREAM_ATTACHMENTS = 24 -export const DAEMON_HANDSHAKE_TIMEOUT_MS = 10_000 - -export const DAEMON_MAX_ACTIVE_REQUESTS = 256 -export const DAEMON_MAX_ACTIVE_REQUESTS_PER_CLIENT = 128 -export const DAEMON_MAX_ACTIVE_REQUEST_BYTES = 32 * 1024 * 1024 -export const DAEMON_MAX_ACTIVE_REQUEST_BYTES_PER_CLIENT = 16 * 1024 * 1024 -export const DAEMON_CONTROL_SOCKET_MAX_BUFFERED_BYTES = 32 * 1024 * 1024 - -export const DAEMON_CLIENT_ID_MAX_BYTES = 1024 -export const DAEMON_REQUEST_ID_MAX_BYTES = 1024 -export const DAEMON_REQUEST_TYPE_MAX_BYTES = 256 -export const DAEMON_SESSION_ID_MAX_BYTES = 4 * 1024 -export const DAEMON_PTY_CWD_MAX_BYTES = 256 * 1024 -export const DAEMON_PTY_COMMAND_MAX_BYTES = 4 * 1024 * 1024 -export const DAEMON_PTY_HISTORY_SEED_MAX_BYTES = 12 * 1024 * 1024 -export const DAEMON_PTY_ENV_MAX_ENTRIES = 4096 -export const DAEMON_PTY_ENV_NAME_MAX_BYTES = 32 * 1024 -export const DAEMON_PTY_ENV_VALUE_MAX_BYTES = 1024 * 1024 -export const DAEMON_PTY_ENV_MAX_BYTES = 4 * 1024 * 1024 -export const DAEMON_PTY_ENV_DELETE_MAX_ENTRIES = 4096 -export const DAEMON_PTY_ENV_DELETE_MAX_BYTES = 1024 * 1024 - -const REQUESTS_WITH_SESSION_ID = new Set([ - 'cancelCreateOrAttach', - 'closeStartupQueryAuthority', - 'write', - 'resize', - 'pausePty', - 'resumePty', - 'setSessionBackground', - 'kill', - 'signal', - 'detach', - 'getCwd', - 'getForegroundProcess', - 'confirmForegroundProcess', - 'clearScrollback', - 'getSnapshot', - 'getSize', - 'takePendingOutput' -]) - -type JsonRecord = Record - -function isRecord(value: unknown): value is JsonRecord { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} - -function boundedStringError( - value: unknown, - field: string, - maxBytes: number, - options: { nonEmpty?: boolean; forbidNull?: boolean } = {} -): string | null { - if (typeof value !== 'string') { - return `${field} must be a string` - } - if (options.nonEmpty && value.length === 0) { - return `${field} must not be empty` - } - if (options.forbidNull && value.includes('\0')) { - return `${field} must not contain NUL` - } - if ( - value.length > maxBytes || - measureUtf8ByteLength(value, { stopAfterBytes: maxBytes }).exceededLimit - ) { - return `${field} exceeds ${maxBytes} bytes` - } - return null -} - -function optionalBoundedStringError( - value: unknown, - field: string, - maxBytes: number -): string | null { - return value === undefined ? null : boundedStringError(value, field, maxBytes) -} - -function optionalNullableBoundedStringError( - value: unknown, - field: string, - maxBytes: number -): string | null { - return value === undefined || value === null ? null : boundedStringError(value, field, maxBytes) -} - -function createEnvironmentError(env: unknown): string | null { - if (env === undefined) { - return null - } - if (!isRecord(env)) { - return 'createOrAttach payload.env must be a string record' - } - - let entries = 0 - let retainedBytes = 0 - for (const name in env) { - if (!Object.prototype.hasOwnProperty.call(env, name)) { - continue - } - entries += 1 - if (entries > DAEMON_PTY_ENV_MAX_ENTRIES) { - return `createOrAttach payload.env exceeds ${DAEMON_PTY_ENV_MAX_ENTRIES} entries` - } - const nameError = boundedStringError( - name, - 'createOrAttach payload.env name', - DAEMON_PTY_ENV_NAME_MAX_BYTES - ) - if (nameError) { - return nameError - } - const value = env[name] - const valueError = boundedStringError( - value, - 'createOrAttach payload.env value', - DAEMON_PTY_ENV_VALUE_MAX_BYTES - ) - if (valueError) { - return valueError - } - retainedBytes += - measureUtf8ByteLength(name).byteLength + measureUtf8ByteLength(value as string).byteLength - if (retainedBytes > DAEMON_PTY_ENV_MAX_BYTES) { - return `createOrAttach payload.env exceeds ${DAEMON_PTY_ENV_MAX_BYTES} bytes` - } - } - return null -} - -function createEnvironmentDeleteError(envToDelete: unknown): string | null { - if (envToDelete === undefined) { - return null - } - if (!Array.isArray(envToDelete)) { - return 'createOrAttach payload.envToDelete must be a string array' - } - if (envToDelete.length > DAEMON_PTY_ENV_DELETE_MAX_ENTRIES) { - return `createOrAttach payload.envToDelete exceeds ${DAEMON_PTY_ENV_DELETE_MAX_ENTRIES} entries` - } - - let retainedBytes = 0 - for (const name of envToDelete) { - const nameError = boundedStringError( - name, - 'createOrAttach payload.envToDelete entry', - DAEMON_PTY_ENV_NAME_MAX_BYTES - ) - if (nameError) { - return nameError - } - retainedBytes += measureUtf8ByteLength(name as string).byteLength - if (retainedBytes > DAEMON_PTY_ENV_DELETE_MAX_BYTES) { - return `createOrAttach payload.envToDelete exceeds ${DAEMON_PTY_ENV_DELETE_MAX_BYTES} bytes` - } - } - return null -} - -function createOrAttachPayloadError(payload: unknown): string | null { - if (!isRecord(payload)) { - return 'createOrAttach payload must be an object' - } - - const checks = [ - () => - boundedStringError( - payload.sessionId, - 'createOrAttach payload.sessionId', - DAEMON_SESSION_ID_MAX_BYTES, - { nonEmpty: true, forbidNull: true } - ), - () => - optionalBoundedStringError( - payload.cwd, - 'createOrAttach payload.cwd', - DAEMON_PTY_CWD_MAX_BYTES - ), - () => - optionalBoundedStringError( - payload.command, - 'createOrAttach payload.command', - DAEMON_PTY_COMMAND_MAX_BYTES - ), - () => - optionalBoundedStringError( - payload.historySeed, - 'createOrAttach payload.historySeed', - DAEMON_PTY_HISTORY_SEED_MAX_BYTES - ), - () => - optionalBoundedStringError( - payload.shellOverride, - 'createOrAttach payload.shellOverride', - DAEMON_PTY_CWD_MAX_BYTES - ), - () => - optionalNullableBoundedStringError( - payload.terminalWindowsWslDistro, - 'createOrAttach payload.terminalWindowsWslDistro', - DAEMON_PTY_CWD_MAX_BYTES - ), - () => - terminalSizeAdmissionError(payload.cols, payload.rows, 'createOrAttach payload', { - allowMissing: true - }), - () => createEnvironmentError(payload.env), - () => createEnvironmentDeleteError(payload.envToDelete) - ] - for (const check of checks) { - const error = check() - if (error) { - return error - } - } - return null -} - -export function daemonHelloAdmissionError(value: unknown): string | null { - if (!isRecord(value) || value.type !== 'hello') { - return 'Expected hello' - } - if (value.role !== 'control' && value.role !== 'stream') { - return 'Invalid hello role' - } - return boundedStringError(value.clientId, 'hello clientId', DAEMON_CLIENT_ID_MAX_BYTES, { - nonEmpty: true, - forbidNull: true - }) -} - -export function daemonRequestAdmissionError(value: unknown): string | null { - if (!isRecord(value)) { - return 'Daemon request must be an object' - } - const idError = boundedStringError(value.id, 'Daemon request id', DAEMON_REQUEST_ID_MAX_BYTES, { - nonEmpty: true, - forbidNull: true - }) - if (idError) { - return idError - } - const typeError = boundedStringError( - value.type, - 'Daemon request type', - DAEMON_REQUEST_TYPE_MAX_BYTES, - { nonEmpty: true } - ) - if (typeError) { - return typeError - } - if (value.type === 'createOrAttach') { - return createOrAttachPayloadError(value.payload) - } - if (!REQUESTS_WITH_SESSION_ID.has(value.type as string)) { - return null - } - if (!isRecord(value.payload)) { - return `${value.type as string} payload must be an object` - } - const sessionIdError = boundedStringError( - value.payload.sessionId, - `${value.type as string} payload.sessionId`, - DAEMON_SESSION_ID_MAX_BYTES, - { nonEmpty: true, forbidNull: true } - ) - if (sessionIdError) { - return sessionIdError - } - return value.type === 'resize' - ? terminalSizeAdmissionError(value.payload.cols, value.payload.rows, 'resize payload') - : null -} - -export function getBoundedDaemonRequestId(value: unknown): string | null { - if (!isRecord(value)) { - return null - } - return boundedStringError(value.id, 'Daemon request id', DAEMON_REQUEST_ID_MAX_BYTES, { - nonEmpty: true, - forbidNull: true - }) === null - ? (value.id as string) - : null -} diff --git a/src/main/daemon/daemon-client-outbound-admission.test.ts b/src/main/daemon/daemon-client-outbound-admission.test.ts deleted file mode 100644 index 33b3a983ea5..00000000000 --- a/src/main/daemon/daemon-client-outbound-admission.test.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { - DAEMON_CLIENT_MAX_CONTROL_BUFFERED_BYTES, - DAEMON_CLIENT_MAX_PENDING_REQUESTS, - DAEMON_CLIENT_MAX_REQUEST_LINE_BYTES, - DaemonClient -} from './client' - -type PendingRequest = { - resolve: (value: unknown) => void - reject: (reason: Error) => void - timer: ReturnType -} - -type FakeControlSocket = { - writableLength: number - write: ReturnType - destroy: ReturnType -} - -function createConnectedClient(): { - client: DaemonClient - socket: FakeControlSocket - pendingRequests: Map -} { - const client = new DaemonClient({ socketPath: 'unused', tokenPath: 'unused' }) - const socket: FakeControlSocket = { - writableLength: 0, - write: vi.fn(() => true), - destroy: vi.fn() - } - const pendingRequests = new Map() - const state = client as unknown as { - connected: boolean - controlSocket: FakeControlSocket - pendingRequests: Map - } - state.connected = true - state.controlSocket = socket - state.pendingRequests = pendingRequests - return { client, socket, pendingRequests } -} - -afterEach(() => { - vi.useRealTimers() -}) - -describe('DaemonClient outbound admission', () => { - it('rejects before serialization when the pending request cap is full', async () => { - vi.useFakeTimers() - const { client, socket, pendingRequests } = createConnectedClient() - for (let index = 0; index < DAEMON_CLIENT_MAX_PENDING_REQUESTS; index += 1) { - pendingRequests.set(`request-${index}`, { - resolve: () => {}, - reject: () => {}, - timer: setTimeout(() => {}, 60_000) - }) - } - - await expect( - client.request('write', { data: 'x'.repeat(DAEMON_CLIENT_MAX_REQUEST_LINE_BYTES) }) - ).rejects.toThrow('pending request limit') - - expect(socket.write).not.toHaveBeenCalled() - expect(pendingRequests.size).toBe(DAEMON_CLIENT_MAX_PENDING_REQUESTS) - for (const pending of pendingRequests.values()) { - clearTimeout(pending.timer) - } - }) - - it('rejects a request whose serialized line exceeds the daemon contract', async () => { - const { client, socket, pendingRequests } = createConnectedClient() - - await expect( - client.request('write', { - data: 'x'.repeat(DAEMON_CLIENT_MAX_REQUEST_LINE_BYTES) - }) - ).rejects.toThrow() - - expect(socket.write).not.toHaveBeenCalled() - expect(pendingRequests.size).toBe(0) - }) - - it('rejects and closes before growing a saturated control buffer', async () => { - const { client, socket, pendingRequests } = createConnectedClient() - socket.writableLength = DAEMON_CLIENT_MAX_CONTROL_BUFFERED_BYTES - - await expect(client.request('listSessions', undefined)).rejects.toThrow('control buffer limit') - - expect(socket.write).not.toHaveBeenCalled() - expect(socket.destroy).toHaveBeenCalledOnce() - expect(pendingRequests.size).toBe(0) - }) - - it('releases request admission when socket.write throws', async () => { - vi.useFakeTimers() - const { client, socket, pendingRequests } = createConnectedClient() - socket.write.mockImplementation(() => { - throw new Error('write failed') - }) - - await expect(client.request('listSessions', undefined)).rejects.toThrow('write failed') - - expect(pendingRequests.size).toBe(0) - expect(vi.getTimerCount()).toBe(0) - }) - - it('drops a notification and closes before growing a saturated control buffer', () => { - const { client, socket } = createConnectedClient() - socket.writableLength = DAEMON_CLIENT_MAX_CONTROL_BUFFERED_BYTES - - client.notify('write', { sessionId: 'session-a', data: 'hello' }) - - expect(socket.write).not.toHaveBeenCalled() - expect(socket.destroy).toHaveBeenCalledOnce() - }) -}) diff --git a/src/main/daemon/daemon-control-file-reader.test.ts b/src/main/daemon/daemon-control-file-reader.test.ts deleted file mode 100644 index 63fde6f746c..00000000000 --- a/src/main/daemon/daemon-control-file-reader.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { closeSync, ftruncateSync, mkdtempSync, openSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import { - MAX_DAEMON_CONTROL_FILE_BYTES, - readDaemonControlFileText -} from './daemon-control-file-reader' - -const roots: string[] = [] - -afterEach(() => { - for (const root of roots.splice(0)) { - rmSync(root, { recursive: true, force: true }) - } -}) - -function createPath(): string { - const root = mkdtempSync(join(tmpdir(), 'orca-daemon-control-')) - roots.push(root) - return join(root, 'control') -} - -describe('readDaemonControlFileText', () => { - it('accepts a control file exactly at the byte cap', () => { - const filePath = createPath() - writeFileSync(filePath, Buffer.alloc(MAX_DAEMON_CONTROL_FILE_BYTES, 0x61)) - - expect(readDaemonControlFileText(filePath)).toHaveLength(MAX_DAEMON_CONTROL_FILE_BYTES) - }) - - it('rejects a sparse control file beyond the byte cap', () => { - const filePath = createPath() - const file = openSync(filePath, 'w') - ftruncateSync(file, MAX_DAEMON_CONTROL_FILE_BYTES + 1) - closeSync(file) - - expect(() => readDaemonControlFileText(filePath)).toThrow('exceeds') - }) -}) diff --git a/src/main/daemon/daemon-control-file-reader.ts b/src/main/daemon/daemon-control-file-reader.ts deleted file mode 100644 index f8e1e114a37..00000000000 --- a/src/main/daemon/daemon-control-file-reader.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { readNodeFileSyncWithinLimit } from '../../shared/node-bounded-file-reader' - -export const MAX_DAEMON_CONTROL_FILE_BYTES = 64 * 1024 - -export function readDaemonControlFileText(filePath: string): string { - return readNodeFileSyncWithinLimit(filePath, MAX_DAEMON_CONTROL_FILE_BYTES).buffer.toString( - 'utf8' - ) -} diff --git a/src/main/daemon/daemon-entry.ts b/src/main/daemon/daemon-entry.ts index b422e30dc85..ba98af21c5b 100644 --- a/src/main/daemon/daemon-entry.ts +++ b/src/main/daemon/daemon-entry.ts @@ -6,6 +6,7 @@ * Signals readiness to parent via IPC: { type: 'ready' } * Shuts down cleanly on SIGTERM. */ +import { readFileSync } from 'node:fs' import { startDaemon, type DaemonHandle } from './daemon-main' import { createPtySubprocess } from './pty-subprocess' import { warmWindowsConptyOnce } from './windows-conpty-warmup' @@ -16,7 +17,6 @@ import { prepareMacosTccLoginShell, probeMacosLoginSessionAlive } from '../providers/macos-tcc-login-shell' -import { readDaemonLoginSessionProbeVerdict } from './daemon-login-session-probe-verdict' import { MacosLoginSessionDeathWatch } from './macos-login-session-death-watch' import { readCurrentProcessMacSystemResolverHealth } from '../network/macos-system-resolver-health' @@ -176,7 +176,13 @@ async function main(): Promise { // 'alive' → accepted/healthy, 'dead' → rejected/unhealthy, 'hang' → // timeout-inconclusive/unhealthy (the fail-safe path), else inconclusive. const e2eProbeFile = process.env.ORCA_E2E_LOGIN_SESSION_PROBE_FILE - const readE2eVerdict = (): string => readDaemonLoginSessionProbeVerdict(e2eProbeFile as string) + const readE2eVerdict = (): string => { + try { + return readFileSync(e2eProbeFile as string, 'utf8').trim() + } catch { + return '' + } + } deathWatch = loginSessionWatch && process.platform === 'darwin' ? new MacosLoginSessionDeathWatch({ diff --git a/src/main/daemon/daemon-health-socket-cleanup.test.ts b/src/main/daemon/daemon-health-socket-cleanup.test.ts index 0bb7e5f7207..6daa6cdf00b 100644 --- a/src/main/daemon/daemon-health-socket-cleanup.test.ts +++ b/src/main/daemon/daemon-health-socket-cleanup.test.ts @@ -10,8 +10,7 @@ const { netConnectMock } = vi.hoisted(() => ({ vi.mock('net', () => ({ connect: netConnectMock })) -import { checkDaemonHealth, healthCheckDaemon, killStaleDaemon } from './daemon-health' -import { DAEMON_HANDSHAKE_MAX_LINE_BYTES } from './ndjson' +import { healthCheckDaemon, killStaleDaemon } from './daemon-health' class FakeSocket extends EventEmitter { destroy = vi.fn() @@ -72,21 +71,6 @@ describe('daemon health socket listener cleanup', () => { expect(socket.destroy).toHaveBeenCalledTimes(1) }) - it('rejects and releases a newline-free oversized health response', async () => { - const socket = new FakeSocket() - netConnectMock.mockReturnValueOnce(socket) - - const result = checkDaemonHealth(socketPath, tokenPath) - socket.emit('connect') - socket.emit('data', Buffer.alloc(DAEMON_HANDSHAKE_MAX_LINE_BYTES + 1, 0x78)) - - await expect(result).resolves.toBe('rejected') - expect(socket.listenerCount('connect')).toBe(0) - expect(socket.listenerCount('error')).toBe(0) - expect(socket.listenerCount('data')).toBe(0) - expect(socket.destroy).toHaveBeenCalledOnce() - }) - it('removes stale-socket probe listeners after a timeout', async () => { vi.useFakeTimers() const socket = new FakeSocket() diff --git a/src/main/daemon/daemon-health.ts b/src/main/daemon/daemon-health.ts index d27456b3ec2..50e7226f73c 100644 --- a/src/main/daemon/daemon-health.ts +++ b/src/main/daemon/daemon-health.ts @@ -4,13 +4,12 @@ import { execFile, execFileSync } from 'node:child_process' import { existsSync, readFileSync, unlinkSync } from 'node:fs' import { connect, type Socket } from 'node:net' import { promisify } from 'node:util' -import { StringDecoder } from 'node:string_decoder' import { getProcessOutputFields, iterateProcessOutputLines } from '../../shared/process-output-field-scanner' import { isStartupDiagnosticsEnabled, logStartupDiagnostic } from '../startup/startup-diagnostics' -import { DAEMON_HANDSHAKE_MAX_LINE_BYTES, createNdjsonParser, encodeNdjson } from './ndjson' +import { encodeNdjson } from './ndjson' import { getDaemonPidPath } from './daemon-spawner' import { PROTOCOL_VERSION, @@ -19,7 +18,6 @@ import { type SystemResolverHealth, type SystemResolverHealthResult } from './types' -import { readDaemonControlFileText } from './daemon-control-file-reader' const HEALTH_CHECK_TIMEOUT_MS = 3_000 const RESOLVER_HEALTH_CHECK_TIMEOUT_MS = 3_000 @@ -91,7 +89,7 @@ export function checkDaemonHealth(socketPath: string, tokenPath: string): Promis let token: string try { - token = readDaemonControlFileText(tokenPath).trim() + token = readFileSync(tokenPath, 'utf8').trim() } catch { resolve('unreachable') return @@ -125,42 +123,55 @@ export function checkDaemonHealth(socketPath: string, tokenPath: string): Promis } sock?.write(encodeNdjson(hello)) } - const onMessage = (rawMessage: unknown): void => { + const onData = (chunk: Buffer): void => { if (settled) { return } - if (!rawMessage || typeof rawMessage !== 'object') { - settle('rejected') - return - } - const message = rawMessage as Record - if (message.type === 'hello') { - if (!(message as HelloResponse).ok) { + buffer += chunk.toString() + for (;;) { + const newlineIdx = buffer.indexOf('\n') + if (newlineIdx === -1) { + break + } + const line = buffer.slice(0, newlineIdx) + buffer = buffer.slice(newlineIdx + 1) + if (!line) { + continue + } + + let message: Record + try { + message = JSON.parse(line) as Record + } catch { settle('rejected') return } - // Why: a protocol-live daemon with a stale cwd or node-pty helper - // will answer ping but cannot create terminals, so reuse must check - // the PTY spawn prerequisites too. - sock?.write(encodeNdjson({ id: 'health-1', type: 'ptySpawnHealth' })) - return - } - if (message.id === 'health-1') { - settle(message.ok === true ? 'healthy' : 'pty-spawn-unhealthy') + if (message.type === 'hello') { + if (!(message as HelloResponse).ok) { + settle('rejected') + return + } + // Why: a protocol-live daemon with a stale cwd or node-pty helper + // will answer ping but cannot create terminals, so reuse must check + // the PTY spawn prerequisites too. + sock?.write(encodeNdjson({ id: 'health-1', type: 'ptySpawnHealth' })) + continue + } + + if (message.id === 'health-1') { + settle(message.ok === true ? 'healthy' : 'pty-spawn-unhealthy') + return + } } } - const decoder = new StringDecoder('utf8') - const parser = createNdjsonParser(onMessage, () => settle('rejected'), { - maxLineBytes: DAEMON_HANDSHAKE_MAX_LINE_BYTES - }) - const onData = (chunk: Buffer): void => parser.feed(decoder.write(chunk)) const timer = setTimeout(() => settle('unreachable'), HEALTH_CHECK_TIMEOUT_MS) sock = connect({ path: socketPath }) sock.on('error', onError) sock.on('connect', onConnect) + let buffer = '' sock.on('data', onData) }) } @@ -190,7 +201,7 @@ export function getMacDaemonSystemResolverHealth( let token: string try { - token = readDaemonControlFileText(tokenPath).trim() + token = readFileSync(tokenPath, 'utf8').trim() } catch { resolve('unknown') return @@ -224,46 +235,59 @@ export function getMacDaemonSystemResolverHealth( } sock?.write(encodeNdjson(hello)) } - const onMessage = (rawMessage: unknown): void => { + const onData = (chunk: Buffer): void => { if (settled) { return } - if (!rawMessage || typeof rawMessage !== 'object') { - settle('unknown') - return - } - const message = rawMessage as Record - if (message.type === 'hello') { - if (!(message as HelloResponse).ok) { - settle('unknown') - return + buffer += chunk.toString() + for (;;) { + const newlineIdx = buffer.indexOf('\n') + if (newlineIdx === -1) { + break + } + const line = buffer.slice(0, newlineIdx) + buffer = buffer.slice(newlineIdx + 1) + if (!line) { + continue } - // Why: the daemon must report health from inside its own process; - // external launchctl bsexec probes can misclassify healthy PTYs. - sock?.write(encodeNdjson({ id: 'resolver-health-1', type: 'systemResolverHealth' })) - return - } - if (message.id === 'resolver-health-1') { - if (!message.ok || typeof message.payload !== 'object' || message.payload === null) { + let message: Record + try { + message = JSON.parse(line) as Record + } catch { settle('unknown') return } - const payload = message.payload as Partial - settle(isSystemResolverHealth(payload.health) ? payload.health : 'unknown') + + if (message.type === 'hello') { + if (!(message as HelloResponse).ok) { + settle('unknown') + return + } + // Why: the daemon must report health from inside its own process; + // external launchctl bsexec probes can misclassify healthy PTYs. + sock?.write(encodeNdjson({ id: 'resolver-health-1', type: 'systemResolverHealth' })) + continue + } + + if (message.id === 'resolver-health-1') { + if (!message.ok || typeof message.payload !== 'object' || message.payload === null) { + settle('unknown') + return + } + const payload = message.payload as Partial + settle(isSystemResolverHealth(payload.health) ? payload.health : 'unknown') + return + } } } - const decoder = new StringDecoder('utf8') - const parser = createNdjsonParser(onMessage, () => settle('unknown'), { - maxLineBytes: DAEMON_HANDSHAKE_MAX_LINE_BYTES - }) - const onData = (chunk: Buffer): void => parser.feed(decoder.write(chunk)) const timer = setTimeout(() => settle('unknown'), RESOLVER_HEALTH_CHECK_TIMEOUT_MS) sock = connect({ path: socketPath }) sock.on('error', onError) sock.on('connect', onConnect) + let buffer = '' sock.on('data', onData) }) } @@ -567,7 +591,7 @@ async function readVerifiedDaemonPid( let parsedPid: ParsedDaemonPid | null try { parsedPid = parseDaemonPidFile( - readDaemonControlFileText(getDaemonPidPath(runtimeDir, protocolVersion)) + readFileSync(getDaemonPidPath(runtimeDir, protocolVersion), 'utf8') ) } catch { return null @@ -614,7 +638,7 @@ export async function killStaleDaemon( const pidPath = getDaemonPidPath(runtimeDir, protocolVersion) let killedDaemon = false try { - const parsedPid = parseDaemonPidFile(readDaemonControlFileText(pidPath)) + const parsedPid = parseDaemonPidFile(readFileSync(pidPath, 'utf8')) if ( parsedPid && (await isDaemonProcess(parsedPid.pid, socketPath, tokenPath, parsedPid.startedAtMs)) diff --git a/src/main/daemon/daemon-host-relocation.ts b/src/main/daemon/daemon-host-relocation.ts index 0e8e3410600..4618fdbdfe5 100644 --- a/src/main/daemon/daemon-host-relocation.ts +++ b/src/main/daemon/daemon-host-relocation.ts @@ -3,7 +3,8 @@ import { cpSync, existsSync, mkdirSync, - opendirSync, + readFileSync, + readdirSync, renameSync, rmSync, writeFileSync @@ -11,7 +12,6 @@ import { import { dirname, join, win32 as winPath } from 'node:path' import { app } from 'electron' import { parseDaemonPidFile, startTimeMatches } from './daemon-health' -import { readDaemonControlFileText } from './daemon-control-file-reader' /** * Relocate the terminal daemon's process image out of the app install dir into LOCAL userData so it @@ -30,7 +30,6 @@ export type RelocatedDaemonHost = { const HOST_SUBDIR = 'daemon-host' const MARKER_NAME = '.materialized.json' -const MAX_PINNED_DAEMON_VERSIONS = 1024 // LOCAL appData (not roaming) so OneDrive/roaming never syncs this ~260MB runtime. Shared with NSIS uninstall (config/nsis/daemon-host-uninstall.nsh) — keep in sync. const LOCAL_HOST_ROOT_NAME = 'Orca' @@ -185,7 +184,7 @@ function executeManifest(ops: CopyOp[], stagingRoot: string): void { function readMarker(dir: string): MaterializeMarker | null { try { const parsed = JSON.parse( - readDaemonControlFileText(join(dir, MARKER_NAME)) + readFileSync(join(dir, MARKER_NAME), 'utf8') ) as Partial if (typeof parsed.version === 'string' && typeof parsed.entryRelPath === 'string') { return { @@ -290,41 +289,25 @@ function isDaemonPidAlive(pid: number, startedAtMs: number | null): boolean { */ export function collectPinnedDaemonVersions(runtimeDir: string): Set { const pinned = new Set() - let directory: ReturnType + let entries try { - directory = opendirSync(runtimeDir) + entries = readdirSync(runtimeDir, { withFileTypes: true }) } catch { return pinned } - try { - while (pinned.size < MAX_PINNED_DAEMON_VERSIONS) { - const entry = directory.readSync() - if (!entry) { - break - } - if (!entry.isFile() || !/^daemon-v\d+\.pid$/.test(entry.name)) { - continue - } - let parsed - try { - parsed = parseDaemonPidFile(readDaemonControlFileText(join(runtimeDir, entry.name))) - } catch { - continue - } - // appVersion null => pre-relocation daemon forked from the install dir; pins no host dir here. - if ( - parsed && - parsed.appVersion !== null && - isDaemonPidAlive(parsed.pid, parsed.startedAtMs) - ) { - pinned.add(parsed.appVersion) - } + for (const entry of entries) { + if (!entry.isFile() || !/^daemon-v\d+\.pid$/.test(entry.name)) { + continue } - } finally { + let parsed try { - directory.closeSync() + parsed = parseDaemonPidFile(readFileSync(join(runtimeDir, entry.name), 'utf8')) } catch { - // Best-effort discovery cleanup. + continue + } + // appVersion null => pre-relocation daemon forked from the install dir; pins no host dir here. + if (parsed && parsed.appVersion !== null && isDaemonPidAlive(parsed.pid, parsed.startedAtMs)) { + pinned.add(parsed.appVersion) } } return pinned @@ -338,38 +321,22 @@ export function pruneOldDaemonHosts(pinnedVersions: ReadonlySet): void { if (process.platform !== 'win32' || !app.isPackaged) { return } - // Why: an incomplete saturated pin scan must fail closed rather than remove a live daemon image. - if (pinnedVersions.size >= MAX_PINNED_DAEMON_VERSIONS) { - return - } const version = app.getVersion() const root = hostRootDir() - let directory: ReturnType + let entries try { - directory = opendirSync(root) + entries = readdirSync(root, { withFileTypes: true }) } catch { return } - try { - while (true) { - const entry = directory.readSync() - if (!entry) { - break - } - if (!entry.isDirectory() || entry.name === version || pinnedVersions.has(entry.name)) { - continue - } - try { - rmSync(join(root, entry.name), { recursive: true, force: true }) - } catch { - // Still locked or already gone — retry on a future launch. - } + for (const entry of entries) { + if (!entry.isDirectory() || entry.name === version || pinnedVersions.has(entry.name)) { + continue } - } finally { try { - directory.closeSync() + rmSync(join(root, entry.name), { recursive: true, force: true }) } catch { - // Best-effort pruning cleanup. + // Still locked or already gone — retry on a future launch. } } } diff --git a/src/main/daemon/daemon-init.test.ts b/src/main/daemon/daemon-init.test.ts index 0f5d216fd81..5a3003f767d 100644 --- a/src/main/daemon/daemon-init.test.ts +++ b/src/main/daemon/daemon-init.test.ts @@ -235,10 +235,6 @@ vi.mock('fs', () => ({ writeFileSync: writeFileSyncMock })) -vi.mock('./daemon-control-file-reader', () => ({ - readDaemonControlFileText: readFileSyncMock -})) - vi.mock('child_process', () => ({ fork: forkMock })) vi.mock('net', () => ({ connect: netConnectMock })) diff --git a/src/main/daemon/daemon-init.ts b/src/main/daemon/daemon-init.ts index b242ecca362..a748ea10a8b 100644 --- a/src/main/daemon/daemon-init.ts +++ b/src/main/daemon/daemon-init.ts @@ -3,7 +3,7 @@ restart, teardown); the "swap the provider atomically" invariant keeps restart + import { join } from 'node:path' import { randomUUID } from 'node:crypto' import { app } from 'electron' -import { mkdirSync, existsSync, unlinkSync, writeFileSync } from 'node:fs' +import { mkdirSync, existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs' import { fork, type ChildProcess } from 'node:child_process' import { connect } from 'node:net' import { @@ -51,7 +51,6 @@ import { confirmSeededClaudeLivePtys, hasSeededUnconfirmedClaudePtys } from '../claude-accounts/live-pty-gate' -import { readDaemonControlFileText } from './daemon-control-file-reader' // Why: daemon init runs concurrent with window load, so an in-process t timestamp (not harness stderr timing) measures cold-start. function logDaemonMilestone(event: string, details: Record = {}): void { @@ -1016,7 +1015,7 @@ async function waitForDaemonEndpointExit(socketPath: string): Promise { function legacyDaemonProcessMayBeAlive(runtimeDir: string, protocolVersion: number): boolean { try { const parsed = parseDaemonPidFile( - readDaemonControlFileText(getDaemonPidPath(runtimeDir, protocolVersion)) + readFileSync(getDaemonPidPath(runtimeDir, protocolVersion), 'utf8') ) if (!parsed) { return false diff --git a/src/main/daemon/daemon-login-session-probe-verdict.test.ts b/src/main/daemon/daemon-login-session-probe-verdict.test.ts deleted file mode 100644 index e870d113aff..00000000000 --- a/src/main/daemon/daemon-login-session-probe-verdict.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { mkdtemp, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import { - MAX_DAEMON_LOGIN_SESSION_PROBE_BYTES, - readDaemonLoginSessionProbeVerdict -} from './daemon-login-session-probe-verdict' - -describe('daemon login-session probe verdict', () => { - const cleanupPaths: string[] = [] - - afterEach(async () => { - await Promise.all(cleanupPaths.splice(0).map((path) => rm(path, { recursive: true }))) - }) - - async function createProbe(content: string): Promise { - const directory = await mkdtemp(join(tmpdir(), 'orca-daemon-login-probe-')) - cleanupPaths.push(directory) - const path = join(directory, 'verdict') - await writeFile(path, content) - return path - } - - it('reads a normal verdict', async () => { - expect(readDaemonLoginSessionProbeVerdict(await createProbe(' alive\n'))).toBe('alive') - }) - - it('fails inconclusive when the verdict file exceeds its cap', async () => { - const path = await createProbe('x'.repeat(MAX_DAEMON_LOGIN_SESSION_PROBE_BYTES + 1)) - expect(readDaemonLoginSessionProbeVerdict(path)).toBe('') - }) -}) diff --git a/src/main/daemon/daemon-login-session-probe-verdict.ts b/src/main/daemon/daemon-login-session-probe-verdict.ts deleted file mode 100644 index 16df15bb83a..00000000000 --- a/src/main/daemon/daemon-login-session-probe-verdict.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { readNodeFileSyncWithinLimit } from '../../shared/node-bounded-file-reader' - -export const MAX_DAEMON_LOGIN_SESSION_PROBE_BYTES = 64 - -export function readDaemonLoginSessionProbeVerdict(filePath: string): string { - try { - return readNodeFileSyncWithinLimit(filePath, MAX_DAEMON_LOGIN_SESSION_PROBE_BYTES) - .buffer.toString('utf8') - .trim() - } catch { - return '' - } -} diff --git a/src/main/daemon/daemon-pty-adapter.test.ts b/src/main/daemon/daemon-pty-adapter.test.ts index 9e58b413672..089d1a59910 100644 --- a/src/main/daemon/daemon-pty-adapter.test.ts +++ b/src/main/daemon/daemon-pty-adapter.test.ts @@ -868,60 +868,6 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { }) describe('listProcesses', () => { - it('strips unknown daemon owner payloads before publication', () => { - const normalize = ( - adapter as unknown as { - validatedAgentSessionOwners(owners: unknown): { - agentSessionOwners?: unknown[] - } - } - ).validatedAgentSessionOwners.bind(adapter) - const owner = { - claim: { - digestVersion: 1, - keyId: 'key', - identityDigest: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', - worktreeScopeDigest: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', - agent: 'codex', - unknownPayload: 'claim payload' - }, - generation: 'generation-1', - phase: 'live', - ptyId: 'pty-1', - surface: { - worktreeId: 'worktree', - tabId: 'tab', - leafId: '11111111-1111-4111-8111-111111111111', - terminalHandle: 'term_claimed', - unknownPayload: 'surface payload' - }, - unknownPayload: 'owner payload' - } - - expect(normalize([owner])).toEqual({ - agentSessionOwners: [ - { - claim: { - digestVersion: 1, - keyId: 'key', - identityDigest: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', - worktreeScopeDigest: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', - agent: 'codex' - }, - generation: 'generation-1', - phase: 'live', - ptyId: 'pty-1', - surface: { - worktreeId: 'worktree', - tabId: 'tab', - leafId: '11111111-1111-4111-8111-111111111111', - terminalHandle: 'term_claimed' - } - } - ] - }) - }) - it('returns active sessions', async () => { await adapter.spawn({ cols: 80, @@ -1366,6 +1312,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { it('does not schedule a checkpoint timer until a session is dirty', async () => { const adapterClass = DaemonPtyAdapter as unknown as { CHECKPOINT_INTERVAL_MS: number } const previousInterval = adapterClass.CHECKPOINT_INTERVAL_MS + const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout') adapterClass.CHECKPOINT_INTERVAL_MS = 10_000 try { @@ -1376,16 +1323,14 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { cwd: '/home/user', sessionId: 'idle-checkpoint' }) - const internals = historyAdapter as unknown as { - checkpointTimer: ReturnType | null - } - expect(internals.checkpointTimer).toBeNull() + expect(setTimeoutSpy.mock.calls.some(([, delay]) => delay === 10_000)).toBe(false) lastSubprocess._simulateData('dirty after idle\r\n') - await waitFor(() => internals.checkpointTimer !== null) + await waitFor(() => setTimeoutSpy.mock.calls.some(([, delay]) => delay === 10_000)) } finally { adapterClass.CHECKPOINT_INTERVAL_MS = previousInterval + setTimeoutSpy.mockRestore() } }) diff --git a/src/main/daemon/daemon-pty-router.test.ts b/src/main/daemon/daemon-pty-router.test.ts index 155a252e538..0c66cf38d47 100644 --- a/src/main/daemon/daemon-pty-router.test.ts +++ b/src/main/daemon/daemon-pty-router.test.ts @@ -7,7 +7,6 @@ import { AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION, GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION } from './types' -import { MAX_AGGREGATED_PTY_PROCESS_LIST_ENTRIES } from '../providers/pty-process-list-admission' type AdapterMock = DaemonPtyAdapter & { emitData: (id: string, data: string, sequenceChars?: number) => void @@ -422,17 +421,6 @@ describe('DaemonPtyRouter', () => { await expect(router.listProcesses()).rejects.toThrow('legacy unavailable') }) - it('fails listProcesses closed when adapters amplify the aggregate listing', async () => { - const current = createAdapter( - 'current', - buildSessionIds('current', MAX_AGGREGATED_PTY_PROCESS_LIST_ENTRIES) - ) - const legacy = createAdapter('legacy', ['legacy-over-cap']) - const router = new DaemonPtyRouter({ current, legacy: [legacy] }) - - await expect(router.listProcesses()).rejects.toThrow('pty_process_list_capacity') - }) - it('merges startup reconciliation and updates route mappings', async () => { const current = createAdapter('current', [], { alive: ['current-alive'], diff --git a/src/main/daemon/daemon-pty-router.ts b/src/main/daemon/daemon-pty-router.ts index e5b71eac1d2..e30021a6f0f 100644 --- a/src/main/daemon/daemon-pty-router.ts +++ b/src/main/daemon/daemon-pty-router.ts @@ -8,10 +8,6 @@ import type { PtySpawnResult } from '../providers/types' import type { PtyIncarnationId } from '../../shared/pty-incarnation' -import { - collectPtyProcessListings, - PtyProcessListAdmission -} from '../providers/pty-process-list-admission' export class DaemonPtyRouter implements IPtyProvider { private current: DaemonPtyAdapter @@ -53,12 +49,10 @@ export class DaemonPtyRouter implements IPtyProvider { } async discoverLegacySessions(): Promise { - const admission = new PtyProcessListAdmission() for (const adapter of this.legacy) { try { const sessions = await adapter.listProcesses() - for (const rawSession of sessions) { - const session = admission.admit(rawSession) + for (const session of sessions) { this.sessionAdapters.set(session.id, adapter) } } catch (error) { @@ -216,9 +210,10 @@ export class DaemonPtyRouter implements IPtyProvider { async listProcesses(opts?: { deadlineMs?: number }): Promise { // Why: runtime exact-stop/liveness flows must fail closed if any adapter // cannot provide a trustworthy process list. - return await collectPtyProcessListings(this.allAdapters(), (adapter) => - adapter.listProcesses(opts) + const results = await Promise.all( + this.allAdapters().map((adapter) => adapter.listProcesses(opts)) ) + return results.flat() } async getDefaultShell(): Promise { diff --git a/src/main/daemon/daemon-pty-size.ts b/src/main/daemon/daemon-pty-size.ts index 2a4b02e96a7..13f8dfe2bc3 100644 --- a/src/main/daemon/daemon-pty-size.ts +++ b/src/main/daemon/daemon-pty-size.ts @@ -1,9 +1,13 @@ -import { isValidTerminalSize, normalizeTerminalSize } from '../../shared/terminal-size-limits' +const DEFAULT_COLS = 80 +const DEFAULT_ROWS = 24 export function isValidPtySize(cols: number, rows: number): boolean { - return isValidTerminalSize(cols, rows) + return Number.isFinite(cols) && Number.isFinite(rows) && cols >= 1 && rows >= 1 } export function normalizePtySize(cols: number, rows: number): { cols: number; rows: number } { - return normalizeTerminalSize(cols, rows) + if (isValidPtySize(cols, rows)) { + return { cols, rows } + } + return { cols: DEFAULT_COLS, rows: DEFAULT_ROWS } } diff --git a/src/main/daemon/daemon-response-admission.ts b/src/main/daemon/daemon-response-admission.ts deleted file mode 100644 index 815b5f6fb6c..00000000000 --- a/src/main/daemon/daemon-response-admission.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { DaemonRequest } from './types' - -export const DAEMON_MAX_RESPONSE_BYTES = 16 * 1024 * 1024 -export const DAEMON_RESPONSE_RESERVATION_BYTES = DAEMON_MAX_RESPONSE_BYTES -export const DAEMON_MAX_ACTIVE_RESPONSE_BYTES = 128 * 1024 * 1024 -export const DAEMON_MAX_ACTIVE_RESPONSE_BYTES_PER_CLIENT = 64 * 1024 * 1024 -export const DAEMON_CONTROL_PROCESS_MAX_BUFFERED_BYTES = 64 * 1024 * 1024 - -const REQUESTS_WITH_POTENTIALLY_LARGE_RESULTS = new Set([ - 'createOrAttach', - 'getSnapshot', - 'listSessions', - 'takePendingOutput' -]) - -export function daemonResponseReservationBytes(request: DaemonRequest): number { - return REQUESTS_WITH_POTENTIALLY_LARGE_RESULTS.has(request.type) - ? DAEMON_RESPONSE_RESERVATION_BYTES - : 0 -} diff --git a/src/main/daemon/daemon-server-admission.test.ts b/src/main/daemon/daemon-server-admission.test.ts deleted file mode 100644 index c7ec4b8d9c0..00000000000 --- a/src/main/daemon/daemon-server-admission.test.ts +++ /dev/null @@ -1,354 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { connect, type Socket } from 'node:net' -import { mkdtempSync, readFileSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { - DAEMON_MAX_ACTIVE_REQUEST_BYTES_PER_CLIENT, - DAEMON_MAX_ACTIVE_REQUESTS_PER_CLIENT, - DAEMON_MAX_CONTROL_CLIENTS, - DAEMON_MAX_STREAM_ATTACHMENTS, - DAEMON_MAX_TRANSPORT_SOCKETS -} from './daemon-admission-limits' -import { - DAEMON_MAX_ACTIVE_RESPONSE_BYTES_PER_CLIENT, - DAEMON_RESPONSE_RESERVATION_BYTES -} from './daemon-response-admission' -import { DaemonClient } from './client' -import { DaemonServer } from './daemon-server' -import { encodeNdjson } from './ndjson' -import { getDaemonSocketPath } from './daemon-spawner' -import { PROTOCOL_VERSION, type DaemonRequest } from './types' - -type ConnectedClientState = { - clientId: string - controlSocket: Socket - streamSocket: Socket | null - activeRequestCount: number - activeRequestBytes: number - activeResponseBytes: number -} - -type DaemonServerAdmissionState = { - transportSockets: Set - clients: Map - activeRequestCount: number - activeRequestBytes: number - activeResponseBytes: number - pendingPtySpawnPreparations: Map> - dispatchRequest(socket: Socket, clientId: string, value: unknown, lineBytes: number): void -} - -function readJsonLine(socket: Socket): Promise> { - return new Promise((resolve, reject) => { - let buffered = '' - const cleanup = (): void => { - socket.off('data', onData) - socket.off('error', onError) - socket.off('close', onClose) - } - const onData = (chunk: Buffer): void => { - buffered += chunk.toString('utf8') - const newline = buffered.indexOf('\n') - if (newline === -1) { - return - } - cleanup() - resolve(JSON.parse(buffered.slice(0, newline)) as Record) - } - const onError = (error: Error): void => { - cleanup() - reject(error) - } - const onClose = (): void => { - cleanup() - reject(new Error('Socket closed before a response')) - } - socket.on('data', onData) - socket.on('error', onError) - socket.on('close', onClose) - }) -} - -describe('DaemonServer admission', () => { - let directory: string - let socketPath: string - let tokenPath: string - let server: DaemonServer - let client: DaemonClient | null - let sockets: Socket[] - - beforeEach(() => { - directory = mkdtempSync(join(tmpdir(), 'daemon-admission-test-')) - socketPath = getDaemonSocketPath(directory) - tokenPath = join(directory, 'daemon.token') - client = null - sockets = [] - }) - - afterEach(async () => { - client?.disconnect() - for (const socket of sockets) { - socket.destroy() - } - await server?.shutdown() - rmSync(directory, { recursive: true, force: true }) - }) - - async function startServer( - preparePtySpawn?: () => Promise, - ptySpawnHealthCheck?: () => Promise - ): Promise { - server = new DaemonServer({ - socketPath, - tokenPath, - ...(preparePtySpawn ? { preparePtySpawn } : {}), - ...(ptySpawnHealthCheck ? { ptySpawnHealthCheck } : {}), - spawnSubprocess: () => { - throw new Error('Test unexpectedly spawned a subprocess') - } - }) - await server.start() - } - - async function openSocket(): Promise { - const socket = connect(socketPath) - sockets.push(socket) - await new Promise((resolve, reject) => { - socket.once('connect', resolve) - socket.once('error', reject) - }) - return socket - } - - async function hello( - role: 'control' | 'stream', - clientId: string - ): Promise<{ socket: Socket; response: Record }> { - const socket = await openSocket() - const responsePromise = readJsonLine(socket) - socket.write( - encodeNdjson({ - type: 'hello', - version: PROTOCOL_VERSION, - token: readFileSync(tokenPath, 'utf8').trim(), - clientId, - role - }) - ) - return { socket, response: await responsePromise } - } - - async function waitFor(predicate: () => boolean): Promise { - await vi.waitFor(() => expect(predicate()).toBe(true)) - } - - it('bounds pre-auth transports and releases capacity after disconnect', async () => { - await startServer() - const state = server as unknown as DaemonServerAdmissionState - - await Promise.all(Array.from({ length: DAEMON_MAX_TRANSPORT_SOCKETS }, () => openSocket())) - await waitFor(() => state.transportSockets.size === DAEMON_MAX_TRANSPORT_SOCKETS) - - const overflow = await openSocket() - await waitFor(() => overflow.destroyed) - expect(state.transportSockets.size).toBe(DAEMON_MAX_TRANSPORT_SOCKETS) - - sockets[0].destroy() - await waitFor(() => state.transportSockets.size === DAEMON_MAX_TRANSPORT_SOCKETS - 1) - await openSocket() - await waitFor(() => state.transportSockets.size === DAEMON_MAX_TRANSPORT_SOCKETS) - }) - - it('bounds control clients and stream attachments, then reuses released slots', async () => { - await startServer() - const state = server as unknown as DaemonServerAdmissionState - const controls: Socket[] = [] - - for (let index = 0; index < DAEMON_MAX_CONTROL_CLIENTS; index += 1) { - const result = await hello('control', `client-${index}`) - expect(result.response.ok).toBe(true) - controls.push(result.socket) - } - await expect(hello('control', 'control-overflow')).resolves.toMatchObject({ - response: { ok: false, retryable: true, error: expect.stringContaining('control-client') } - }) - expect(state.clients.size).toBe(DAEMON_MAX_CONTROL_CLIENTS) - - for (let index = 0; index < DAEMON_MAX_STREAM_ATTACHMENTS; index += 1) { - await expect(hello('stream', `client-${index}`)).resolves.toMatchObject({ - response: { ok: true } - }) - } - await expect(hello('stream', `client-${DAEMON_MAX_STREAM_ATTACHMENTS}`)).resolves.toMatchObject( - { - response: { - ok: false, - retryable: true, - error: expect.stringContaining('stream-attachment') - } - } - ) - - controls[0].destroy() - await waitFor(() => state.clients.size === DAEMON_MAX_CONTROL_CLIENTS - 1) - await expect(hello('control', 'replacement-control')).resolves.toMatchObject({ - response: { ok: true } - }) - await expect(hello('stream', `client-${DAEMON_MAX_STREAM_ATTACHMENTS}`)).resolves.toMatchObject( - { - response: { ok: true } - } - ) - }) - - it('bounds concurrent response construction and cleans ownership after disconnect', async () => { - let finishPreparation!: () => void - const preparation = new Promise((resolve) => { - finishPreparation = resolve - }) - const preparePtySpawn = vi.fn(() => preparation) - await startServer(preparePtySpawn) - client = new DaemonClient({ socketPath, tokenPath }) - await client.ensureConnected() - const state = server as unknown as DaemonServerAdmissionState - - const maxLargeResponses = - DAEMON_MAX_ACTIVE_RESPONSE_BYTES_PER_CLIENT / DAEMON_RESPONSE_RESERVATION_BYTES - const pending = Array.from({ length: maxLargeResponses }, (_, index) => - client! - .request('createOrAttach', { - sessionId: `pending-${index}`, - cols: 80, - rows: 24 - }) - .catch((error: unknown) => error) - ) - await waitFor(() => preparePtySpawn.mock.calls.length === maxLargeResponses) - await expect( - client.request('createOrAttach', { - sessionId: 'request-overflow', - cols: 80, - rows: 24 - }) - ).rejects.toThrow('request capacity exceeded') - expect(state.activeRequestCount).toBe(maxLargeResponses) - expect(state.activeResponseBytes).toBe(DAEMON_MAX_ACTIVE_RESPONSE_BYTES_PER_CLIENT) - expect(state.pendingPtySpawnPreparations.size).toBe(maxLargeResponses) - - client.disconnect() - finishPreparation() - await Promise.all(pending) - await waitFor( - () => - state.activeRequestCount === 0 && - state.activeRequestBytes === 0 && - state.activeResponseBytes === 0 && - state.pendingPtySpawnPreparations.size === 0 - ) - }) - - it('keeps the independent active-request cap for small response methods', async () => { - let finishHealthCheck!: () => void - const healthCheck = new Promise((resolve) => { - finishHealthCheck = resolve - }) - const ptySpawnHealthCheck = vi.fn(() => healthCheck) - await startServer(undefined, ptySpawnHealthCheck) - const { socket, response } = await hello('control', 'active-request-cap-client') - expect(response.ok).toBe(true) - const state = server as unknown as DaemonServerAdmissionState - - for (let index = 0; index < DAEMON_MAX_ACTIVE_REQUESTS_PER_CLIENT; index += 1) { - socket.write( - encodeNdjson({ id: `pending-${index}`, type: 'ptySpawnHealth', payload: undefined }) - ) - } - await waitFor( - () => ptySpawnHealthCheck.mock.calls.length === DAEMON_MAX_ACTIVE_REQUESTS_PER_CLIENT - ) - const overflowResponse = readJsonLine(socket) - socket.write(encodeNdjson({ id: 'overflow', type: 'ptySpawnHealth', payload: undefined })) - await expect(overflowResponse).resolves.toMatchObject({ - id: 'overflow', - ok: false, - error: expect.stringContaining('request capacity exceeded') - }) - - socket.destroy() - finishHealthCheck() - await waitFor(() => state.activeRequestCount === 0 && state.activeRequestBytes === 0) - }) - - it('enforces the per-client retained-byte budget independently of request count', async () => { - let finishPreparation!: () => void - const preparation = new Promise((resolve) => { - finishPreparation = resolve - }) - const preparePtySpawn = vi.fn(() => preparation) - await startServer(preparePtySpawn) - client = new DaemonClient({ socketPath, tokenPath }) - await client.ensureConnected() - const state = server as unknown as DaemonServerAdmissionState - const connected = [...state.clients.values()][0] - const request = (id: string): DaemonRequest => ({ - id, - type: 'createOrAttach', - payload: { sessionId: id, cols: 80, rows: 24 } - }) - - state.dispatchRequest( - connected.controlSocket, - connected.clientId, - request('within-byte-budget'), - DAEMON_MAX_ACTIVE_REQUEST_BYTES_PER_CLIENT - 1024 - ) - await waitFor(() => preparePtySpawn.mock.calls.length === 1) - state.dispatchRequest( - connected.controlSocket, - connected.clientId, - request('over-byte-budget'), - 2048 - ) - await new Promise((resolve) => setTimeout(resolve, 0)) - expect(preparePtySpawn).toHaveBeenCalledOnce() - expect(connected.activeRequestCount).toBe(1) - - client.disconnect() - finishPreparation() - await waitFor(() => state.activeRequestCount === 0 && state.activeRequestBytes === 0) - }) - - it('holds idle retirement until admitted work releases after disconnect', async () => { - let finishHealthCheck!: () => void - const healthCheck = new Promise((resolve) => { - finishHealthCheck = resolve - }) - const ptySpawnHealthCheck = vi.fn(() => healthCheck) - const onIdleShutdown = vi.fn() - server = new DaemonServer({ - socketPath, - tokenPath, - ptySpawnHealthCheck, - onIdleShutdown, - spawnSubprocess: () => { - throw new Error('Test unexpectedly spawned a subprocess') - } - }) - await server.start() - client = new DaemonClient({ socketPath, tokenPath }) - await client.ensureConnected() - const state = server as unknown as DaemonServerAdmissionState - - const pending = client.request('ptySpawnHealth', undefined).catch((error: unknown) => error) - await waitFor(() => ptySpawnHealthCheck.mock.calls.length === 1) - client.disconnect() - await pending - await waitFor(() => state.transportSockets.size === 0) - expect(state.activeRequestCount).toBe(1) - expect(onIdleShutdown).not.toHaveBeenCalled() - - finishHealthCheck() - await waitFor(() => state.activeRequestCount === 0 && onIdleShutdown.mock.calls.length === 1) - }) -}) diff --git a/src/main/daemon/daemon-server.ts b/src/main/daemon/daemon-server.ts index 5c38c4d2837..95fb09715f0 100644 --- a/src/main/daemon/daemon-server.ts +++ b/src/main/daemon/daemon-server.ts @@ -4,12 +4,7 @@ import { randomUUID } from 'node:crypto' import { performance } from 'node:perf_hooks' import { writeFileSync, chmodSync } from 'node:fs' import { StringDecoder } from 'node:string_decoder' -import { - DAEMON_HANDSHAKE_MAX_LINE_BYTES, - encodeBoundedNdjson, - encodeNdjson, - createNdjsonParser -} from './ndjson' +import { encodeNdjson, createNdjsonParser } from './ndjson' import { TerminalHost } from './terminal-host' import { DaemonStreamDataBatcher } from './daemon-stream-data-batcher' import { @@ -41,27 +36,6 @@ import { isAgentSessionExecutionClaim, isAgentSessionSurfaceBinding } from '../../shared/agent-session-host-authority' -import { - DAEMON_CONTROL_SOCKET_MAX_BUFFERED_BYTES, - DAEMON_HANDSHAKE_TIMEOUT_MS, - DAEMON_MAX_ACTIVE_REQUEST_BYTES, - DAEMON_MAX_ACTIVE_REQUEST_BYTES_PER_CLIENT, - DAEMON_MAX_ACTIVE_REQUESTS, - DAEMON_MAX_ACTIVE_REQUESTS_PER_CLIENT, - DAEMON_MAX_CONTROL_CLIENTS, - DAEMON_MAX_STREAM_ATTACHMENTS, - DAEMON_MAX_TRANSPORT_SOCKETS, - daemonHelloAdmissionError, - daemonRequestAdmissionError, - getBoundedDaemonRequestId -} from './daemon-admission-limits' -import { - DAEMON_CONTROL_PROCESS_MAX_BUFFERED_BYTES, - DAEMON_MAX_ACTIVE_RESPONSE_BYTES, - DAEMON_MAX_ACTIVE_RESPONSE_BYTES_PER_CLIENT, - DAEMON_MAX_RESPONSE_BYTES, - daemonResponseReservationBytes -} from './daemon-response-admission' export type DaemonServerOptions = { socketPath: string @@ -103,9 +77,6 @@ type ConnectedClient = { controlSocket: Socket streamSocket: Socket | null authenticatedPairEstablished: boolean - activeRequestCount: number - activeRequestBytes: number - activeResponseBytes: number } type PendingPtySpawnPreparation = { @@ -138,9 +109,6 @@ export class DaemonServer { private preparePtySpawn: () => Promise private log: DaemonFileLog private transportSockets = new Set() - private activeRequestCount = 0 - private activeRequestBytes = 0 - private activeResponseBytes = 0 private createOrAttachInFlight = 0 private idleShutdownState: 'running' | 'idle-shutdown-pending' | 'shutting-down' = 'running' private initialAdoptionTimer: unknown | null = null @@ -336,7 +304,6 @@ export class DaemonServer { return ( this.transportSockets.size === 0 && this.clients.size === 0 && - this.activeRequestCount === 0 && this.createOrAttachInFlight === 0 && this.host.listSessions().length === 0 ) @@ -415,12 +382,6 @@ export class DaemonServer { } private handleConnection(socket: Socket): void { - socket.on('error', () => socket.destroy()) - if (this.transportSockets.size >= DAEMON_MAX_TRANSPORT_SOCKETS) { - socket.destroy() - return - } - this.cancelInitialAdoptionTimer() this.transportSockets.add(socket) const removeTransport = (): void => { @@ -428,6 +389,7 @@ export class DaemonServer { this.reevaluateIdleShutdown() } socket.once('close', removeTransport) + socket.on('error', () => socket.destroy()) if (this.idleShutdownState !== 'running') { // Why: a connection accepted just before close() gets an explicit retry signal instead of dying mid-auth. @@ -441,67 +403,45 @@ export class DaemonServer { ) return } - - let handshakeComplete = false - const handshakeTimer = setTimeout(() => socket.destroy(), DAEMON_HANDSHAKE_TIMEOUT_MS) - handshakeTimer.unref() - socket.once('close', () => clearTimeout(handshakeTimer)) // Why: keep UTF-8 sequences intact across socket chunks before NDJSON parsing. const decoder = new StringDecoder('utf8') const parser = createNdjsonParser( - (msg) => { - if (handshakeComplete) { - return - } - handshakeComplete = true - clearTimeout(handshakeTimer) - this.handleFirstMessage(socket, msg) - }, + (msg) => this.handleFirstMessage(socket, msg, parser), () => { socket.destroy() - }, - { maxLineBytes: DAEMON_HANDSHAKE_MAX_LINE_BYTES } + } ) socket.on('data', (chunk) => parser.feed(decoder.write(chunk))) } - private handleFirstMessage(socket: Socket, msg: unknown): void { - const helloError = daemonHelloAdmissionError(msg) - if (helloError) { - this.log.log('client-hello-rejected', { reason: 'invalid-hello' }) - this.rejectHello(socket, helloError) + private handleFirstMessage( + socket: Socket, + msg: unknown, + _parser: ReturnType + ): void { + const hello = msg as HelloMessage + if (hello.type !== 'hello') { + this.log.log('client-hello-rejected', { reason: 'expected-hello' }) + socket.write(encodeNdjson({ type: 'hello', ok: false, error: 'Expected hello' })) + socket.destroy() return } - const hello = msg as HelloMessage if (hello.version !== this.protocolVersion) { this.log.log('client-hello-rejected', { reason: 'protocol-mismatch', clientVersion: hello.version }) - this.rejectHello(socket, 'Protocol version mismatch') + socket.write(encodeNdjson({ type: 'hello', ok: false, error: 'Protocol version mismatch' })) + socket.destroy() return } if (hello.token !== this.token) { this.log.log('client-hello-rejected', { reason: 'invalid-token', role: hello.role }) - this.rejectHello(socket, 'Invalid token') - return - } - - const previous = hello.role === 'control' ? this.clients.get(hello.clientId) : undefined - const streamClient = hello.role === 'stream' ? this.clients.get(hello.clientId) : undefined - if (hello.role === 'control' && !previous && this.clients.size >= DAEMON_MAX_CONTROL_CLIENTS) { - this.rejectHello(socket, 'Daemon control-client capacity exceeded; reconnect', true) - return - } - if ( - streamClient && - streamClient.streamSocket === null && - this.streamAttachmentCount() >= DAEMON_MAX_STREAM_ATTACHMENTS - ) { - this.rejectHello(socket, 'Daemon stream-attachment capacity exceeded; reconnect', true) + socket.write(encodeNdjson({ type: 'hello', ok: false, error: 'Invalid token' })) + socket.destroy() return } @@ -523,14 +463,12 @@ export class DaemonServer { ) if (hello.role === 'control') { + const previous = this.clients.get(hello.clientId) const client: ConnectedClient = { clientId: hello.clientId, controlSocket: socket, streamSocket: null, - authenticatedPairEstablished: false, - activeRequestCount: 0, - activeRequestBytes: 0, - activeResponseBytes: 0 + authenticatedPairEstablished: false } this.clients.set(hello.clientId, client) this.setupControlSocket(socket, hello.clientId) @@ -542,50 +480,30 @@ export class DaemonServer { previous.streamSocket?.destroy() previous.controlSocket.destroy() } - } else if (streamClient) { - this.setupStreamSocket(socket, streamClient) - streamClient.authenticatedPairEstablished = true + } else if (hello.role === 'stream') { + const client = this.clients.get(hello.clientId) + if (!client) { + // Why: a stream socket is meaningless without its control socket; drop the orphan. + socket.destroy() + return + } + this.setupStreamSocket(socket, client) + client.authenticatedPairEstablished = true // Why: one-shot health probes authenticate only a control socket; they are not fresh app activity. this.onAuthenticatedClientPair() // A complete app connection (unlike a probe) re-owns the endpoint and cancels pending retirement. this.initialAdoptionDeadlineMs = null this.retirementRequested = false this.cancelInitialAdoptionTimer() - } else { - // Why: preserve the legacy authenticated handshake before dropping a stream with no owner. - socket.destroy() } } - private rejectHello(socket: Socket, error: string, retryable = false): void { - socket.end( - encodeNdjson({ - type: 'hello', - ok: false, - error, - ...(retryable ? { retryable: true } : {}) - }), - () => socket.destroy() - ) - } - - private streamAttachmentCount(): number { - let count = 0 - for (const client of this.clients.values()) { - if (client.streamSocket) { - count += 1 - } - } - return count - } - private setupControlSocket(socket: Socket, clientId: string): void { // Why: decode as a UTF-8 stream so emoji/Unicode split across chunks isn't corrupted. const decoder = new StringDecoder('utf8') const parser = createNdjsonParser( - (msg, lineBytes) => this.dispatchRequest(socket, clientId, msg, lineBytes ?? 0), - () => socket.destroy(), - { includeLineBytes: true } + (msg) => this.handleRequest(socket, clientId, msg as DaemonRequest), + () => {} // Ignore parse errors ) // Remove the initial data listener and replace with the RPC parser @@ -651,86 +569,6 @@ export class DaemonServer { } } - private dispatchRequest( - socket: Socket, - clientId: string, - value: unknown, - lineBytes: number - ): void { - const client = this.clients.get(clientId) - if (!client || client.controlSocket !== socket) { - return - } - - const requestId = getBoundedDaemonRequestId(value) - const admissionError = daemonRequestAdmissionError(value) - if (admissionError) { - this.writeRequestError(socket, requestId, admissionError) - return - } - const request = value as DaemonRequest - const responseReservationBytes = daemonResponseReservationBytes(request) - if ( - client.activeRequestCount >= DAEMON_MAX_ACTIVE_REQUESTS_PER_CLIENT || - this.activeRequestCount >= DAEMON_MAX_ACTIVE_REQUESTS || - client.activeRequestBytes + lineBytes > DAEMON_MAX_ACTIVE_REQUEST_BYTES_PER_CLIENT || - this.activeRequestBytes + lineBytes > DAEMON_MAX_ACTIVE_REQUEST_BYTES || - client.activeResponseBytes + responseReservationBytes > - DAEMON_MAX_ACTIVE_RESPONSE_BYTES_PER_CLIENT || - this.activeResponseBytes + responseReservationBytes > DAEMON_MAX_ACTIVE_RESPONSE_BYTES - ) { - this.writeRequestError(socket, request.id, 'Daemon request capacity exceeded; retry') - return - } - - client.activeRequestCount += 1 - client.activeRequestBytes += lineBytes - this.activeRequestCount += 1 - this.activeRequestBytes += lineBytes - client.activeResponseBytes += responseReservationBytes - this.activeResponseBytes += responseReservationBytes - void this.handleRequest(socket, clientId, request).finally(() => { - client.activeRequestCount -= 1 - client.activeRequestBytes -= lineBytes - this.activeRequestCount -= 1 - this.activeRequestBytes -= lineBytes - client.activeResponseBytes -= responseReservationBytes - this.activeResponseBytes -= responseReservationBytes - this.reevaluateIdleShutdown() - }) - } - - private writeRequestError(socket: Socket, requestId: string | null, error: string): void { - if (!requestId || requestId.startsWith(NOTIFY_PREFIX)) { - return - } - this.writeControlMessage(socket, { id: requestId, ok: false, error }) - } - - private writeControlMessage(socket: Socket, message: unknown, onFlushed?: () => void): void { - const encoded = encodeBoundedNdjson(message, DAEMON_MAX_RESPONSE_BYTES) - const encodedBytes = Buffer.byteLength(encoded, 'utf8') - if ( - socket.writableLength + encodedBytes > DAEMON_CONTROL_SOCKET_MAX_BUFFERED_BYTES || - this.controlSocketBufferedBytes() + encodedBytes > DAEMON_CONTROL_PROCESS_MAX_BUFFERED_BYTES - ) { - socket.destroy() - return - } - socket.write(encoded, onFlushed) - } - - private controlSocketBufferedBytes(): number { - let total = 0 - for (const client of this.clients.values()) { - total += Math.max(0, client.controlSocket.writableLength) - if (total > DAEMON_CONTROL_PROCESS_MAX_BUFFERED_BYTES) { - break - } - } - return total - } - private async handleRequest( socket: Socket, clientId: string, @@ -744,17 +582,19 @@ export class DaemonServer { const pendingShutdown = this.pendingShutdownReplies.get( this.shutdownReplyKey(clientId, request.id) ) - this.writeControlMessage(socket, { id: request.id, ok: true, payload: result }, () => { + socket.write(encodeNdjson({ id: request.id, ok: true, payload: result }), () => { pendingShutdown?.start() }) } } catch (err) { if (!isNotify) { - this.writeControlMessage(socket, { - id: request.id, - ok: false, - error: err instanceof Error ? err.message : String(err) - }) + socket.write( + encodeNdjson({ + id: request.id, + ok: false, + error: err instanceof Error ? err.message : String(err) + }) + ) } } } @@ -1090,7 +930,6 @@ export class DaemonServer { authenticatedClient !== undefined && authenticatedClient.streamSocket !== null && this.clients.size === 1 && - this.activeRequestCount <= 1 && this.createOrAttachInFlight === 0 && this.host.listSessions().length === 0 && [...this.transportSockets].every( diff --git a/src/main/daemon/daemon-spawner.ts b/src/main/daemon/daemon-spawner.ts index 2a900d03cde..ba0ea239c67 100644 --- a/src/main/daemon/daemon-spawner.ts +++ b/src/main/daemon/daemon-spawner.ts @@ -1,8 +1,7 @@ import { createHash, randomUUID } from 'node:crypto' -import { constants, copyFileSync, existsSync, renameSync, unlinkSync } from 'node:fs' +import { constants, copyFileSync, existsSync, readFileSync, renameSync, unlinkSync } from 'node:fs' import { join } from 'node:path' import { PROTOCOL_VERSION } from './types' -import { readDaemonControlFileText } from './daemon-control-file-reader' export type DaemonConnectionInfo = { socketPath: string @@ -142,7 +141,7 @@ function claimAndUnlinkOwnedFile( return false } try { - if (ownsContent(readDaemonControlFileText(claimedPath))) { + if (ownsContent(readFileSync(claimedPath, 'utf8'))) { unlinkSync(claimedPath) return true } diff --git a/src/main/daemon/degraded-daemon-pty-provider.ts b/src/main/daemon/degraded-daemon-pty-provider.ts index 47c5375bad4..e3bd38af6fd 100644 --- a/src/main/daemon/degraded-daemon-pty-provider.ts +++ b/src/main/daemon/degraded-daemon-pty-provider.ts @@ -4,10 +4,6 @@ import { inspectPtyProviderProcess } from '../providers/pty-process-inspection' import type { IPtyProvider, PtyBackgroundStreamEvent } from '../providers/types' import type { PtyDataEvent, PtyProviderBufferSnapshot } from '../providers/types' import type { PtyProcessInfo, PtySpawnOptions, PtySpawnResult } from '../providers/types' -import { - collectPtyProcessListings, - PtyProcessListAdmission -} from '../providers/pty-process-list-admission' export class DegradedDaemonPtyProvider implements IPtyProvider { readonly routesFreshSpawnsToLocalProvider = true @@ -49,12 +45,11 @@ export class DegradedDaemonPtyProvider implements IPtyProvider { } async discoverDaemonSessions(): Promise { - const admission = new PtyProcessListAdmission() for (const adapter of this.allDaemonAdapters()) { try { const sessions = await adapter.listProcesses() for (const session of sessions) { - this.sessionProviders.set(admission.admit(session).id, adapter) + this.sessionProviders.set(session.id, adapter) } } catch (error) { console.warn('[daemon] Failed to discover degraded daemon sessions', error) @@ -175,8 +170,12 @@ export class DegradedDaemonPtyProvider implements IPtyProvider { await this.fallback.revive(state) } - listProcesses = (opts?: { deadlineMs?: number }): Promise => - collectPtyProcessListings(this.allProviders(), (provider) => provider.listProcesses(opts)) + async listProcesses(opts?: { deadlineMs?: number }): Promise { + const results = await Promise.all( + this.allProviders().map((provider) => provider.listProcesses(opts)) + ) + return results.flat() + } async getDefaultShell(): Promise { return this.fallback.getDefaultShell() diff --git a/src/main/daemon/headless-emulator-size-limits.test.ts b/src/main/daemon/headless-emulator-size-limits.test.ts deleted file mode 100644 index 38e4f096cd6..00000000000 --- a/src/main/daemon/headless-emulator-size-limits.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { MAX_TERMINAL_COLS } from '../../shared/terminal-size-limits' -import { HeadlessEmulator } from './headless-emulator' - -describe('HeadlessEmulator terminal size limits', () => { - it('uses safe defaults for oversized persisted construction and resize dimensions', () => { - const emulator = new HeadlessEmulator({ cols: MAX_TERMINAL_COLS + 1, rows: 24 }) - expect(emulator.getAppliedSize()).toEqual({ cols: 80, rows: 24 }) - - emulator.resize(80, Number.MAX_SAFE_INTEGER) - expect(emulator.getAppliedSize()).toEqual({ cols: 80, rows: 24 }) - emulator.dispose() - }) -}) diff --git a/src/main/daemon/headless-emulator.ts b/src/main/daemon/headless-emulator.ts index 4f3797e6f51..7cd36fe80f3 100644 --- a/src/main/daemon/headless-emulator.ts +++ b/src/main/daemon/headless-emulator.ts @@ -20,7 +20,6 @@ import { } from './terminal-view-attribute-responder' import type { TerminalSnapshot, TerminalModes } from './types' import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges' -import { normalizeTerminalSize } from '../../shared/terminal-size-limits' export type HeadlessEmulatorOptions = { cols: number @@ -70,7 +69,6 @@ export class HeadlessEmulator { private partialEscapeTail = '' constructor(opts: HeadlessEmulatorOptions) { - const size = normalizeTerminalSize(opts.cols, opts.rows) this.pathFlavor = opts.pathFlavor this.remotePosixFileUriAuthority = opts.remotePosixFileUriAuthority === true this.oscText = new TerminalOscCwdTitleScanner({ @@ -79,8 +77,8 @@ export class HeadlessEmulator { wslDistro: opts.wslDistro }) this.terminal = new Terminal({ - cols: size.cols, - rows: size.rows, + cols: opts.cols, + rows: opts.rows, scrollback: opts.scrollback ?? DEFAULT_SCROLLBACK, allowProposedApi: true, logLevel: 'off', @@ -226,8 +224,7 @@ export class HeadlessEmulator { return } this.restoredOscLinks = [] - const size = normalizeTerminalSize(cols, rows) - this.terminal.resize(size.cols, size.rows) + this.terminal.resize(cols, rows) } // Why: these dims proxy the child's real size, so they stay stale on a dropped resize the renderer must detect. diff --git a/src/main/daemon/history-manager-memory.test.ts b/src/main/daemon/history-manager-memory.test.ts deleted file mode 100644 index c472cb3a55b..00000000000 --- a/src/main/daemon/history-manager-memory.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { existsSync, mkdtempSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import { getHistorySessionDirName } from './history-paths' -import { HistoryManager } from './history-manager' -import type { TerminalSnapshot } from './types' - -const directories: string[] = [] - -function createManager(): { directory: string; manager: HistoryManager } { - const directory = mkdtempSync(join(tmpdir(), 'orca-history-manager-memory-')) - directories.push(directory) - return { directory, manager: new HistoryManager(directory) } -} - -function snapshot(snapshotAnsi: string): TerminalSnapshot { - return { - snapshotAnsi, - scrollbackAnsi: '', - rehydrateSequences: '', - cwd: '/workspace', - cols: 80, - rows: 24, - modes: { - bracketedPaste: false, - mouseTracking: false, - applicationCursor: false, - alternateScreen: false - }, - scrollbackLines: 0 - } -} - -afterEach(() => { - for (const directory of directories.splice(0)) { - rmSync(directory, { recursive: true, force: true }) - } -}) - -describe('terminal history write memory limits', () => { - it('rejects an oversized escaped checkpoint before writing it', async () => { - const { directory, manager } = createManager() - await manager.openSession('checkpoint', { cwd: '/workspace', cols: 80, rows: 24 }) - - await manager.checkpoint('checkpoint', snapshot('\n'.repeat(9 * 1024 * 1024))) - - expect(manager.isSessionDisabled('checkpoint')).toBe(true) - expect( - existsSync(join(directory, getHistorySessionDirName('checkpoint'), 'checkpoint.json')) - ).toBe(false) - }) - - it('rejects oversized escaped metadata before writing it', async () => { - const { directory, manager } = createManager() - - await manager.openSession('metadata', { - cwd: '\n'.repeat(40 * 1024), - cols: 80, - rows: 24 - }) - - expect(manager.isSessionDisabled('metadata')).toBe(true) - expect(existsSync(join(directory, getHistorySessionDirName('metadata'), 'meta.json'))).toBe( - false - ) - }) -}) diff --git a/src/main/daemon/history-manager.ts b/src/main/daemon/history-manager.ts index 9fc5a140dfc..1af27025182 100644 --- a/src/main/daemon/history-manager.ts +++ b/src/main/daemon/history-manager.ts @@ -2,39 +2,55 @@ import { join } from 'node:path' import { mkdirSync, writeFileSync, + readFileSync, existsSync, rmSync, unlinkSync, + openSync, + closeSync, + readSync, + fstatSync, promises as fsPromises } from 'node:fs' import { getHistorySessionDirName } from './history-paths' import { - encodeLogBatchWithinLimit, + decodeLogHeader, + encodeLogBatch, encodeLogHeader, LOG_HEADER_BYTES } from './terminal-history-log' import type { PendingOutputRecord, TerminalCheckpointFile, TerminalSnapshot } from './types' -import { stringifyJsonWithinByteLimit } from '../../shared/node-bounded-json-stringify' -import { - TERMINAL_HISTORY_CHECKPOINT_MAX_BYTES, - TERMINAL_HISTORY_LOG_MAX_BYTES -} from './terminal-history-file-limits' -import { - readTerminalHistorySessionMeta, - stringifyTerminalHistorySessionMeta, - type HistoryManagerOptions, - type OpenSessionOptions, - type SessionMeta -} from './terminal-history-session-metadata' -import { - resolveTerminalHistoryLogState, - type TerminalHistoryLogState -} from './terminal-history-log-state' -export type { HistoryManagerOptions, OpenSessionOptions, SessionMeta } +// Why 5MB: bounds cold-restore replay time and per-session disk; hitting the cap triggers one checkpoint that resets the log. +const LOG_MAX_BYTES = 5 * 1024 * 1024 -type SessionWriter = TerminalHistoryLogState & { +export type SessionMeta = { + cwd: string + cols: number + rows: number + startedAt: string + endedAt: string | null + exitCode: number | null +} + +export type OpenSessionOptions = { + cwd: string + cols: number + rows: number +} + +type SessionWriter = { dir: string + checkpointPath: string + logPath: string + /** Generation of the on-disk log header. Null until lazily resolved on first append after a warm registerWriter. */ + logGeneration: number | null + /** Current log file size. Null until lazily resolved alongside generation. */ + logBytes: number | null +} + +export type HistoryManagerOptions = { + onWriteError?: (sessionId: string, error: Error) => void } export class HistoryManager { @@ -62,7 +78,7 @@ export class HistoryManager { endedAt: null, exitCode: null } - writeFileSync(join(dir, 'meta.json'), stringifyTerminalHistorySessionMeta(meta)) + writeFileSync(join(dir, 'meta.json'), JSON.stringify(meta, null, 2)) // Why: clear stale recovery files (incl. legacy scrollback.bin) so a crash before the first checkpoint can't replay a prior session's content. const checkpointPath = join(dir, 'checkpoint.json') @@ -137,15 +153,11 @@ export class HistoryManager { return 'ok' } try { - resolveTerminalHistoryLogState(writer) + this.resolveLogState(writer) + const batch = encodeLogBatch(seq, records) // Why max(..., header): a fresh log's header (written below) must count toward the projected size or the cap overshoots. - const existingBytes = Math.max(writer.logBytes ?? 0, LOG_HEADER_BYTES) - const batch = encodeLogBatchWithinLimit( - seq, - records, - TERMINAL_HISTORY_LOG_MAX_BYTES - existingBytes - ) - if (!batch) { + const projectedBytes = Math.max(writer.logBytes ?? 0, LOG_HEADER_BYTES) + batch.length + if (projectedBytes > LOG_MAX_BYTES) { return 'needs-checkpoint' } if (writer.logBytes === 0) { @@ -180,7 +192,7 @@ export class HistoryManager { effectiveCwd = meta?.cwd ?? null } - resolveTerminalHistoryLogState(writer) + this.resolveLogState(writer) const generation = (writer.logGeneration ?? 0) + 1 const checkpointFile: TerminalCheckpointFile = { snapshotAnsi: snapshot.snapshotAnsi, @@ -195,10 +207,7 @@ export class HistoryManager { generation, checkpointedAt: new Date().toISOString() } - const data = stringifyJsonWithinByteLimit( - checkpointFile, - TERMINAL_HISTORY_CHECKPOINT_MAX_BYTES - ).serialized + const data = JSON.stringify(checkpointFile) // Why: tmp+rename is atomic (corrupt checkpoint > stale); async so a sync ~MB write can't stall IPC (worse under Windows AV). // The adapter's checkpointInFlight guard serializes checkpoints, so concurrent async writes can't collide on the fixed .tmp path. const tmpPath = `${writer.checkpointPath}.tmp` @@ -213,6 +222,46 @@ export class HistoryManager { } } + // Why: a warm registerWriter may attach to an existing log; read generation/size once so appends continue it, not clobber it. + private resolveLogState(writer: SessionWriter): void { + if (writer.logBytes !== null && writer.logGeneration !== null) { + return + } + let headerGeneration: number | null = null + let size = 0 + try { + const fd = openSync(writer.logPath, 'r') + try { + size = fstatSync(fd).size + const header = Buffer.alloc(LOG_HEADER_BYTES) + if (readSync(fd, header, 0, LOG_HEADER_BYTES, 0) === LOG_HEADER_BYTES) { + headerGeneration = decodeLogHeader(header) + } + } finally { + closeSync(fd) + } + } catch { + // Missing log file — fresh state below. + } + if (headerGeneration !== null) { + writer.logGeneration = headerGeneration + writer.logBytes = size + return + } + // Missing/unreadable header: logBytes = 0 makes the next append truncate-rewrite, so a garbage file can't be extended. + writer.logBytes = 0 + writer.logGeneration = this.readCheckpointGeneration(writer) ?? 0 + } + + private readCheckpointGeneration(writer: SessionWriter): number | null { + try { + const checkpoint = JSON.parse(readFileSync(writer.checkpointPath, 'utf-8')) + return typeof checkpoint.generation === 'number' ? checkpoint.generation : null + } catch { + return null + } + } + async closeSession(sessionId: string, exitCode: number): Promise { const writer = this.writers.get(sessionId) if (!writer) { @@ -257,7 +306,7 @@ export class HistoryManager { return null } try { - return readTerminalHistorySessionMeta(metaPath) + return JSON.parse(readFileSync(metaPath, 'utf-8')) } catch { return null } @@ -284,7 +333,7 @@ export class HistoryManager { private readMetaFromDir(dir: string): SessionMeta | null { const metaPath = join(dir, 'meta.json') try { - return readTerminalHistorySessionMeta(metaPath) + return JSON.parse(readFileSync(metaPath, 'utf-8')) } catch { return null } @@ -294,11 +343,11 @@ export class HistoryManager { const metaPath = join(dir, 'meta.json') let meta: SessionMeta try { - meta = readTerminalHistorySessionMeta(metaPath) + meta = JSON.parse(readFileSync(metaPath, 'utf-8')) } catch { return } Object.assign(meta, updates) - writeFileSync(metaPath, stringifyTerminalHistorySessionMeta(meta)) + writeFileSync(metaPath, JSON.stringify(meta, null, 2)) } } diff --git a/src/main/daemon/ndjson.test.ts b/src/main/daemon/ndjson.test.ts index a7075f5194a..3d1d2c1512b 100644 --- a/src/main/daemon/ndjson.test.ts +++ b/src/main/daemon/ndjson.test.ts @@ -1,20 +1,7 @@ import { describe, expect, it, vi } from 'vitest' -import { - encodeBoundedNdjson, - encodeNdjson, - createNdjsonParser, - NDJSON_MAX_LINE_BYTES, - NDJSON_MAX_STRUCTURAL_TOKENS -} from './ndjson' +import { encodeNdjson, createNdjsonParser, NDJSON_MAX_LINE_BYTES } from './ndjson' describe('encodeNdjson', () => { - it('bounds response serialization while preserving admitted wire bytes', () => { - expect(encodeBoundedNdjson({ ok: true }, 12)).toBe('{"ok":true}\n') - expect(() => encodeBoundedNdjson({ value: 'x'.repeat(100) }, 32)).toThrow( - 'JSON output exceeds 31 bytes' - ) - }) - it('encodes an object as a JSON line ending with newline', () => { const result = encodeNdjson({ type: 'hello', version: 1 }) expect(result).toBe('{"type":"hello","version":1}\n') @@ -45,32 +32,6 @@ describe('createNdjsonParser', () => { expect(onError).not.toHaveBeenCalled() }) - it('optionally reports each parsed line byte length without re-serializing it', () => { - const onMessage = vi.fn() - const parser = createNdjsonParser(onMessage, undefined, { includeLineBytes: true }) - - parser.feed('{"text":"é"}\n') - - expect(onMessage).toHaveBeenCalledWith({ text: 'é' }, Buffer.byteLength('{"text":"é"}')) - }) - - it('rejects structurally amplified lines before parsing', () => { - const onMessage = vi.fn() - const onError = vi.fn() - const parser = createNdjsonParser(onMessage, onError) - const parseSpy = vi.spyOn(JSON, 'parse') - try { - parser.feed(`{"values":[${'0,'.repeat(NDJSON_MAX_STRUCTURAL_TOKENS)}0]}\n`) - expect(onMessage).not.toHaveBeenCalled() - expect(onError).toHaveBeenCalledWith( - expect.objectContaining({ message: expect.stringContaining('JSON structure exceeds') }) - ) - expect(parseSpy).not.toHaveBeenCalled() - } finally { - parseSpy.mockRestore() - } - }) - it('parses multiple messages in a single chunk', () => { const onMessage = vi.fn() const parser = createNdjsonParser(onMessage) @@ -95,20 +56,6 @@ describe('createNdjsonParser', () => { expect(onMessage).toHaveBeenCalledWith({ type: 'hello', version: 1 }) }) - it('parses a line delivered in more than 100,000 one-character fragments', () => { - const onMessage = vi.fn() - const parser = createNdjsonParser(onMessage, undefined, { includeLineBytes: true }) - const message = { value: 'x'.repeat(100_000) } - const line = JSON.stringify(message) - - for (const character of line) { - parser.feed(character) - } - parser.feed('\n') - - expect(onMessage).toHaveBeenCalledWith(message, Buffer.byteLength(line)) - }) - it('handles a chunk that ends mid-line followed by more data', () => { const onMessage = vi.fn() const parser = createNdjsonParser(onMessage) diff --git a/src/main/daemon/ndjson.ts b/src/main/daemon/ndjson.ts index 60e1c5359df..17b37cb8d11 100644 --- a/src/main/daemon/ndjson.ts +++ b/src/main/daemon/ndjson.ts @@ -1,22 +1,8 @@ -import { assertJsonTextStructureWithinLimits } from '../../shared/json-text-structure-limit' -import { GrowingByteBuffer } from '../../shared/growing-byte-buffer' -import { stringifyJsonWithinByteLimit } from '../../shared/node-bounded-json-stringify' - export function encodeNdjson(msg: unknown): string { return `${JSON.stringify(msg)}\n` } -export function encodeBoundedNdjson(msg: unknown, maxBytes: number): string { - if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) { - throw new RangeError('NDJSON byte limit must be a positive safe integer') - } - return `${stringifyJsonWithinByteLimit(msg, maxBytes - 1).serialized}\n` -} - export const NDJSON_MAX_LINE_BYTES = 16 * 1024 * 1024 -export const DAEMON_HANDSHAKE_MAX_LINE_BYTES = 64 * 1024 -export const NDJSON_MAX_STRUCTURAL_TOKENS = 1_000_000 -export const NDJSON_MAX_NESTING_DEPTH = 128 export type NdjsonParser = { feed(chunk: string): void @@ -25,20 +11,21 @@ export type NdjsonParser = { export type NdjsonParserOptions = { maxLineBytes?: number - includeLineBytes?: boolean } export function createNdjsonParser( - onMessage: (msg: unknown, lineBytes?: number) => void, + onMessage: (msg: unknown) => void, onError?: (err: Error) => void, options: NdjsonParserOptions = {} ): NdjsonParser { - const buffer = new GrowingByteBuffer() + let buffer = '' + let bufferBytes = 0 let discardingOversizedLine = false const maxLineBytes = Math.max(1, options.maxLineBytes ?? NDJSON_MAX_LINE_BYTES) const clearBuffer = (): void => { - buffer.clear() + buffer = '' + bufferBytes = 0 } const reportOversizedLine = (observedBytes: number): void => { @@ -66,8 +53,8 @@ export function createNdjsonParser( return } - const segmentBytes = Buffer.from(segment, 'utf8') - const nextLineBytes = buffer.byteLength + segmentBytes.byteLength + const segmentBytes = Buffer.byteLength(segment, 'utf8') + const nextLineBytes = bufferBytes + segmentBytes // Why: daemon sockets are local but persistent; a peer that never sends // a newline must not grow the parser buffer without bound. if (nextLineBytes > maxLineBytes) { @@ -80,29 +67,21 @@ export function createNdjsonParser( continue } - buffer.append(segmentBytes) + buffer += segment + bufferBytes = nextLineBytes if (!hasNewline) { return } - const lineBytes = buffer.byteLength - const line = buffer.takeString('utf8') + const line = buffer + clearBuffer() if (line.length === 0) { continue } try { - assertJsonTextStructureWithinLimits(line, { - structuralTokens: NDJSON_MAX_STRUCTURAL_TOKENS, - nestingDepth: NDJSON_MAX_NESTING_DEPTH - }) - const parsed = JSON.parse(line) - if (options.includeLineBytes) { - onMessage(parsed, lineBytes) - } else { - onMessage(parsed) - } + onMessage(JSON.parse(line)) } catch (err) { onError?.(err instanceof Error ? err : new Error(String(err))) } diff --git a/src/main/daemon/pty-subprocess.ts b/src/main/daemon/pty-subprocess.ts index db721e4d6a4..8066770c526 100644 --- a/src/main/daemon/pty-subprocess.ts +++ b/src/main/daemon/pty-subprocess.ts @@ -66,7 +66,6 @@ import { assertSafeAgentStartupCwd, resolveSafePtyDefaultCwd } from '../provider import { ORCA_HERMES_STARTUP_QUERY_ENV } from '../../shared/hermes-startup-query' import type { TuiAgent } from '../../shared/types' import { forceKillPosixPtyProcessGroups } from '../pty/posix-pty-process-groups' -import { appendCompactedStringChunk } from '../../shared/string-chunk-compaction' const PANE_IDENTITY_ENV_KEYS = [ 'ORCA_PANE_KEY', @@ -811,7 +810,7 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl const bufferPreListenerData = (data: string): void => { // Why: Windows shell-arg startup commands can print before Session wires this subprocess in; preserve that spawn-time race window. - appendCompactedStringChunk(pendingPreListenerData, data) + pendingPreListenerData.push(data) pendingPreListenerDataChars += data.length while (pendingPreListenerDataChars > PENDING_PRE_LISTENER_DATA_MAX_CHARS) { const removed = pendingPreListenerData.shift() diff --git a/src/main/daemon/session-pending-output.test.ts b/src/main/daemon/session-pending-output.test.ts index 3d1d8b7de6d..446c080a504 100644 --- a/src/main/daemon/session-pending-output.test.ts +++ b/src/main/daemon/session-pending-output.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it } from 'vitest' -import { PENDING_OUTPUT_MAX_RECORDS, Session } from './session' +import { Session } from './session' // Coverage for the incremental-checkpoint record stream (issue #5096): every // PTY byte, resize, and clear is recorded so the 5s checkpoint can persist @@ -115,25 +115,6 @@ describe('Session pending output', () => { expect(recovered!.records).toEqual([{ kind: 'output', data: 'post-overflow' }]) }) - it('flags overflow when tiny records reach the metadata cap', () => { - const subprocess = createMockSubprocess() - const live = createSession(subprocess) - - for (let index = 0; index <= PENDING_OUTPUT_MAX_RECORDS / 2; index += 1) { - subprocess.simulateData('x') - live.clearScrollback() - } - - const overflowed = live.takePendingOutput(false) - expect(overflowed!.overflowed).toBe(true) - expect(overflowed!.records).toEqual([]) - - subprocess.simulateData('post-overflow') - expect(live.takePendingOutput(false)!.records).toEqual([ - { kind: 'output', data: 'post-overflow' } - ]) - }) - it('returns the snapshot and drops records in the same take when requested', () => { const subprocess = createMockSubprocess() const live = createSession(subprocess) diff --git a/src/main/daemon/session-pre-ready-input-memory.test.ts b/src/main/daemon/session-pre-ready-input-memory.test.ts deleted file mode 100644 index 4aadf16895a..00000000000 --- a/src/main/daemon/session-pre-ready-input-memory.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { PRE_READY_STDIN_MAX_SEGMENTS, Session, type SubprocessHandle } from './session' - -function createSubprocess(): SubprocessHandle { - return { - pid: 12345, - getForegroundProcess: () => null, - write() {}, - resize() {}, - kill() {}, - forceKill() {}, - signal() {}, - onData() {}, - onExit() {}, - dispose() {} - } -} - -describe('Session pre-ready input memory', () => { - it('bounds one-character segments independently of the byte cap', () => { - const session = new Session({ - sessionId: 'pre-ready-segment-test', - cols: 80, - rows: 24, - subprocess: createSubprocess(), - shellReadySupported: true - }) - - try { - for (let index = 0; index < PRE_READY_STDIN_MAX_SEGMENTS; index += 1) { - session.write('x') - } - expect(() => session.write('x')).toThrow('safe memory limit') - } finally { - session.dispose() - } - }) -}) diff --git a/src/main/daemon/session.test.ts b/src/main/daemon/session.test.ts index 3a68c1c584f..bc4b45484f3 100644 --- a/src/main/daemon/session.test.ts +++ b/src/main/daemon/session.test.ts @@ -1,5 +1,4 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { PRE_READY_STDIN_MAX_CODE_UNITS } from './session' import { PRODUCER_PAUSE_FAILSAFE_MS, SESSION_FORCE_KILL_RETRY_MS, Session } from './session' import type { SessionState, ShellReadyState } from './types' import type { TuiAgent } from '../../shared/types' @@ -353,19 +352,6 @@ describe('Session', () => { expect(subprocess.written).toEqual(['first\n', 'second\n']) }) - it('bounds aggregate input retained before shell readiness without changing admitted writes', () => { - createSession({ shellReadySupported: true }) - const chunk = 'x'.repeat(16 * 1024) - const admittedChunks = PRE_READY_STDIN_MAX_CODE_UNITS / chunk.length - - for (let index = 0; index < admittedChunks; index += 1) { - session.write(chunk) - } - expect(() => session.write('overflow')).toThrow('safe memory limit') - vi.advanceTimersByTime(15_000) - expect(subprocess.written).toEqual(Array(admittedChunks).fill(chunk)) - }) - it('uses the short settle path when marker and prompt bytes arrive together', () => { createSession({ shellReadySupported: true }) session.write('codex\n') diff --git a/src/main/daemon/session.ts b/src/main/daemon/session.ts index cbec43de1e4..7edaa500663 100644 --- a/src/main/daemon/session.ts +++ b/src/main/daemon/session.ts @@ -37,9 +37,6 @@ const SESSION_FORCE_KILL_MAX_ATTEMPTS = 2 // Why: bounds in-memory pending output when no client drains it; past the cap we drop records and flag // overflow so the next take falls back to one full snapshot. UTF-16 units; worst-case wire is ~6x, under NDJSON_MAX_LINE_BYTES (16MB). const PENDING_OUTPUT_MAX_BYTES = 2 * 1024 * 1024 -export const PENDING_OUTPUT_MAX_RECORDS = 4_096 -export const PRE_READY_STDIN_MAX_CODE_UNITS = 16 * 1024 * 1024 -export const PRE_READY_STDIN_MAX_SEGMENTS = 4_096 // Why: pause is a fire-and-forget notify, so a resume can be lost (main crash, dropped socket); a lost // resume must never wedge a shell, so auto-resume after this window — a still-flooded main re-pauses. export const PRODUCER_PAUSE_FAILSAFE_MS = 5_000 @@ -115,7 +112,6 @@ export class Session { private readonly onSessionExit?: (code: number) => void private attachedClients: AttachedClient[] = [] private preReadyStdinQueue: string[] = [] - private preReadyStdinCodeUnits = 0 private shellReadyScanState: ShellReadyScanState | null = null private shellReadyTimer: ReturnType | null = null private killTimer: ReturnType | null = null @@ -222,17 +218,7 @@ export class Session { // Why: keep queuing during the post-ready flush-gate window ('ready' but not yet flushed); a // direct write would race fresh input ahead of the buffered startup command. if (this._shellState === 'pending' || this.postReadyFlushGate.isPending) { - const nextCodeUnits = this.preReadyStdinCodeUnits + data.length - if ( - nextCodeUnits > PRE_READY_STDIN_MAX_CODE_UNITS || - this.preReadyStdinQueue.length >= PRE_READY_STDIN_MAX_SEGMENTS - ) { - throw new Error( - 'Terminal input queued before shell readiness exceeds the safe memory limit.' - ) - } this.preReadyStdinQueue.push(data) - this.preReadyStdinCodeUnits = nextCodeUnits return } @@ -537,7 +523,6 @@ export class Session { this.attachedClients = [] this.preReadyStdinQueue = [] - this.preReadyStdinCodeUnits = 0 this.postReadyFlushGate.clear() this.emulator.dispose() @@ -582,7 +567,6 @@ export class Session { } this.shellReadyScanState = null this.preReadyStdinQueue = [] - this.preReadyStdinCodeUnits = 0 this.postReadyFlushGate.clear() this.disposeSubprocessHandle() } @@ -605,20 +589,15 @@ export class Session { return } const bytes = record.kind === 'output' ? record.data.length : 8 - const last = this.pendingOutputRecords.at(-1) - const canCoalesce = - record.kind === 'output' && last?.kind === 'output' && last.data.length < 64 * 1024 - if ( - this.pendingOutputBytes + bytes > PENDING_OUTPUT_MAX_BYTES || - (!canCoalesce && this.pendingOutputRecords.length >= PENDING_OUTPUT_MAX_RECORDS) - ) { + if (this.pendingOutputBytes + bytes > PENDING_OUTPUT_MAX_BYTES) { this.pendingOutputRecords = [] this.pendingOutputBytes = 0 this.pendingOutputOverflowed = true return } // Why: coalesce the thousands of tiny TUI chunks per tick to keep take RPC/log frames compact; 64KB cap bounds append cost. - if (canCoalesce) { + const last = this.pendingOutputRecords.at(-1) + if (record.kind === 'output' && last?.kind === 'output' && last.data.length < 64 * 1024) { last.data += record.data } else { this.pendingOutputRecords.push(record) @@ -741,7 +720,6 @@ export class Session { private flushPreReadyQueue(): void { const queued = this.preReadyStdinQueue this.preReadyStdinQueue = [] - this.preReadyStdinCodeUnits = 0 for (const data of queued) { this.subprocess.write(data) } diff --git a/src/main/daemon/terminal-history-log-state.ts b/src/main/daemon/terminal-history-log-state.ts deleted file mode 100644 index 6ad44e0977a..00000000000 --- a/src/main/daemon/terminal-history-log-state.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { closeSync, fstatSync, openSync, readSync } from 'node:fs' -import { decodeLogHeader, LOG_HEADER_BYTES } from './terminal-history-log' -import { TERMINAL_HISTORY_CHECKPOINT_MAX_BYTES } from './terminal-history-file-limits' -import { readTerminalHistoryJson } from './terminal-history-file-reader' - -export type TerminalHistoryLogState = { - checkpointPath: string - logPath: string - /** Null until a warm writer resolves the existing log on first append. */ - logGeneration: number | null - /** Null until resolved alongside the generation. */ - logBytes: number | null -} - -export function resolveTerminalHistoryLogState(writer: TerminalHistoryLogState): void { - if (writer.logBytes !== null && writer.logGeneration !== null) { - return - } - let headerGeneration: number | null = null - let size = 0 - try { - const fd = openSync(writer.logPath, 'r') - try { - size = fstatSync(fd).size - const header = Buffer.alloc(LOG_HEADER_BYTES) - if (readSync(fd, header, 0, LOG_HEADER_BYTES, 0) === LOG_HEADER_BYTES) { - headerGeneration = decodeLogHeader(header) - } - } finally { - closeSync(fd) - } - } catch { - // Missing log file starts a fresh generation below. - } - if (headerGeneration !== null) { - writer.logGeneration = headerGeneration - writer.logBytes = size - return - } - // Why: a zero byte count makes the next append replace a corrupt or headerless log. - writer.logBytes = 0 - writer.logGeneration = readCheckpointGeneration(writer.checkpointPath) ?? 0 -} - -function readCheckpointGeneration(checkpointPath: string): number | null { - try { - const checkpoint = readTerminalHistoryJson<{ generation?: unknown }>( - checkpointPath, - TERMINAL_HISTORY_CHECKPOINT_MAX_BYTES - ) - return typeof checkpoint.generation === 'number' ? checkpoint.generation : null - } catch { - return null - } -} diff --git a/src/main/daemon/terminal-history-log.test.ts b/src/main/daemon/terminal-history-log.test.ts index 0fd6fea07d3..426056d4725 100644 --- a/src/main/daemon/terminal-history-log.test.ts +++ b/src/main/daemon/terminal-history-log.test.ts @@ -1,9 +1,8 @@ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import { decodeLogHeader, decodeTerminalHistoryLog, encodeLogBatch, - encodeLogBatchWithinLimit, encodeLogHeader, LOG_HEADER_BYTES } from './terminal-history-log' @@ -45,16 +44,6 @@ describe('terminal history log codec', () => { expect(log!.batches).toEqual([{ seq: 3, records }]) }) - it('rejects an oversized batch before allocating its encoded buffer', () => { - const allocation = vi.spyOn(Buffer, 'allocUnsafe') - - expect( - encodeLogBatchWithinLimit(1, [{ kind: 'output', data: 'escaped 🐋 output' }], 12) - ).toBeNull() - expect(allocation).not.toHaveBeenCalled() - allocation.mockRestore() - }) - it('decodes multiple contiguous batches', () => { const log = decodeTerminalHistoryLog( buildLog(1, [ diff --git a/src/main/daemon/terminal-history-log.ts b/src/main/daemon/terminal-history-log.ts index ba260d32dca..4f7866c37d6 100644 --- a/src/main/daemon/terminal-history-log.ts +++ b/src/main/daemon/terminal-history-log.ts @@ -23,8 +23,6 @@ const FRAME_BATCH = 0x01 const FRAME_OUTPUT = 0x02 const FRAME_RESIZE = 0x03 const FRAME_CLEAR = 0x04 -const FRAME_HEADER_BYTES = 5 -const BATCH_FRAME_BYTES = FRAME_HEADER_BYTES + 4 export type TerminalHistoryLogBatch = { seq: number @@ -63,75 +61,20 @@ export function decodeLogHeader(buffer: Buffer): number | null { } export function encodeLogBatch(seq: number, records: PendingOutputRecord[]): Buffer { - const byteLength = measureLogBatchBytes(records) - return encodeLogBatchWithByteLength(seq, records, byteLength) -} - -export function encodeLogBatchWithinLimit( - seq: number, - records: PendingOutputRecord[], - maxBytes: number -): Buffer | null { - if (!Number.isSafeInteger(maxBytes) || maxBytes < 0) { - return null - } - const byteLength = measureLogBatchBytes(records, maxBytes) - return byteLength === null ? null : encodeLogBatchWithByteLength(seq, records, byteLength) -} - -function measureLogBatchBytes(records: PendingOutputRecord[]): number -function measureLogBatchBytes(records: PendingOutputRecord[], maxBytes: number): number | null -function measureLogBatchBytes(records: PendingOutputRecord[], maxBytes?: number): number | null { - let byteLength = BATCH_FRAME_BYTES - if (maxBytes !== undefined && byteLength > maxBytes) { - return null - } + const frames: Buffer[] = [encodeFrame(FRAME_BATCH, encodeSeqPayload(seq))] for (const record of records) { if (record.kind === 'output') { - const payloadBytes = Buffer.byteLength(record.data, 'utf8') - if (maxBytes !== undefined && payloadBytes > maxBytes - byteLength - FRAME_HEADER_BYTES) { - return null - } - byteLength += FRAME_HEADER_BYTES + payloadBytes + frames.push(encodeFrame(FRAME_OUTPUT, Buffer.from(record.data, 'utf8'))) } else if (record.kind === 'resize') { - byteLength += FRAME_HEADER_BYTES + 4 + const payload = Buffer.alloc(4) + payload.writeUInt16LE(clampU16(record.cols), 0) + payload.writeUInt16LE(clampU16(record.rows), 2) + frames.push(encodeFrame(FRAME_RESIZE, payload)) } else { - byteLength += FRAME_HEADER_BYTES - } - if (maxBytes !== undefined && byteLength > maxBytes) { - return null + frames.push(encodeFrame(FRAME_CLEAR, Buffer.alloc(0))) } } - return byteLength -} - -function encodeLogBatchWithByteLength( - seq: number, - records: PendingOutputRecord[], - byteLength: number -): Buffer { - const batch = Buffer.allocUnsafe(byteLength) - let offset = 0 - offset = writeFrameHeader(batch, offset, FRAME_BATCH, 4) - batch.writeUInt32LE(seq >>> 0, offset) - offset += 4 - - for (const record of records) { - if (record.kind === 'output') { - const payloadBytes = Buffer.byteLength(record.data, 'utf8') - offset = writeFrameHeader(batch, offset, FRAME_OUTPUT, payloadBytes) - batch.write(record.data, offset, payloadBytes, 'utf8') - offset += payloadBytes - } else if (record.kind === 'resize') { - offset = writeFrameHeader(batch, offset, FRAME_RESIZE, 4) - batch.writeUInt16LE(clampU16(record.cols), offset) - batch.writeUInt16LE(clampU16(record.rows), offset + 2) - offset += 4 - } else { - offset = writeFrameHeader(batch, offset, FRAME_CLEAR, 0) - } - } - return batch + return Buffer.concat(frames) } /** Returns null for missing magic / unknown format version — callers fall @@ -204,15 +147,17 @@ export function decodeTerminalHistoryLog(buffer: Buffer): TerminalHistoryLogCont return { generation, batches, truncatedTail } } -function writeFrameHeader( - buffer: Buffer, - offset: number, - kind: number, - payloadBytes: number -): number { - buffer.writeUInt8(kind, offset) - buffer.writeUInt32LE(payloadBytes, offset + 1) - return offset + FRAME_HEADER_BYTES +function encodeFrame(kind: number, payload: Buffer): Buffer { + const header = Buffer.alloc(5) + header.writeUInt8(kind, 0) + header.writeUInt32LE(payload.length, 1) + return Buffer.concat([header, payload]) +} + +function encodeSeqPayload(seq: number): Buffer { + const payload = Buffer.alloc(4) + payload.writeUInt32LE(seq >>> 0, 0) + return payload } function clampU16(value: number): number { diff --git a/src/main/daemon/terminal-history-session-metadata.ts b/src/main/daemon/terminal-history-session-metadata.ts deleted file mode 100644 index fb83c033314..00000000000 --- a/src/main/daemon/terminal-history-session-metadata.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { stringifyJsonWithinByteLimit } from '../../shared/node-bounded-json-stringify' -import { TERMINAL_HISTORY_META_MAX_BYTES } from './terminal-history-file-limits' -import { readTerminalHistoryJson } from './terminal-history-file-reader' - -export type SessionMeta = { - cwd: string - cols: number - rows: number - startedAt: string - endedAt: string | null - exitCode: number | null -} - -export type OpenSessionOptions = { - cwd: string - cols: number - rows: number -} - -export type HistoryManagerOptions = { - onWriteError?: (sessionId: string, error: Error) => void -} - -export function stringifyTerminalHistorySessionMeta(meta: SessionMeta): string { - // Preflight compact JSON before materializing the larger pretty representation. - stringifyJsonWithinByteLimit(meta, TERMINAL_HISTORY_META_MAX_BYTES) - const serialized = JSON.stringify(meta, null, 2) - if (Buffer.byteLength(serialized, 'utf8') > TERMINAL_HISTORY_META_MAX_BYTES) { - throw new Error('Terminal history metadata exceeds its byte limit') - } - return serialized -} - -export function readTerminalHistorySessionMeta(filePath: string): SessionMeta { - return readTerminalHistoryJson(filePath, TERMINAL_HISTORY_META_MAX_BYTES) -} diff --git a/src/main/devin/hook-config-json.ts b/src/main/devin/hook-config-json.ts index 2c9592c0d39..6a316ebc6aa 100644 --- a/src/main/devin/hook-config-json.ts +++ b/src/main/devin/hook-config-json.ts @@ -1,7 +1,5 @@ -import { existsSync } from 'node:fs' +import { existsSync, readFileSync } from 'node:fs' import { parse as parseJsonc, type ParseError } from 'jsonc-parser' -import { readNodeFileSyncWithinLimit } from '../../shared/node-bounded-file-reader' -import { AGENT_HOOK_CONFIG_MAX_BYTES } from '../agent-hooks/agent-hook-file-limits' import { isPlainObject, type HooksConfig } from '../agent-hooks/installer-utils' /** Devin documents config.json as JSONC; stock JSON.parse rejects comments. */ @@ -11,10 +9,7 @@ export function readDevinHooksConfig(configPath: string): HooksConfig | null { } try { - const text = readNodeFileSyncWithinLimit( - configPath, - AGENT_HOOK_CONFIG_MAX_BYTES - ).buffer.toString('utf8') + const text = readFileSync(configPath, 'utf-8') return parseDevinHooksConfigText(text, 'Devin config.json') } catch { return null diff --git a/src/main/diagnostics/main-thread-churn-probe.test.ts b/src/main/diagnostics/main-thread-churn-probe.test.ts index deefc95273b..cfc159fc1fb 100644 --- a/src/main/diagnostics/main-thread-churn-probe.test.ts +++ b/src/main/diagnostics/main-thread-churn-probe.test.ts @@ -1,7 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { MAIN_THREAD_DIAGNOSTICS_ENV, - SUBPROCESS_SPAWN_STATS_MAX_ENTRIES, classifySubprocessCommand, drainSubprocessSpawnStats, isMainThreadDiagnosticsEnabled, @@ -72,22 +71,4 @@ describe('recordSubprocessSpawn', () => { }) expect(drainSubprocessSpawnStats()).toEqual({}) }) - - it('bounds unique diagnostic buckets and aggregates overflow', () => { - vi.stubEnv(MAIN_THREAD_DIAGNOSTICS_ENV, '1') - for (let index = 0; index < 200; index += 1) { - recordSubprocessSpawn(`tool-${index}`, [], 1) - } - - const drained = drainSubprocessSpawnStats() - expect(Object.keys(drained)).toHaveLength(SUBPROCESS_SPAWN_STATS_MAX_ENTRIES) - expect(drained.other).toEqual({ count: 73, blockMsTotal: 73, blockMsMax: 1 }) - }) - - it('materializes a bounded binary bucket from an oversized command path', () => { - const command = `/tmp/${'x'.repeat(1024 * 1024)}` - const classified = classifySubprocessCommand(command, []) - expect(classified).toHaveLength(64) - expect(classified).toBe('x'.repeat(64)) - }) }) diff --git a/src/main/diagnostics/main-thread-churn-probe.ts b/src/main/diagnostics/main-thread-churn-probe.ts index 97dc30a93eb..f4fe900cf8c 100644 --- a/src/main/diagnostics/main-thread-churn-probe.ts +++ b/src/main/diagnostics/main-thread-churn-probe.ts @@ -8,9 +8,6 @@ export const MAIN_THREAD_DIAGNOSTICS_ENV = 'ORCA_MAIN_THREAD_DIAGNOSTICS' // reported in issue #7576. const TICK_MS = 25 const REPORT_EVERY_MS = 5_000 -const SUBPROCESS_BINARY_NAME_MAX_CHARS = 64 -export const SUBPROCESS_SPAWN_STATS_MAX_ENTRIES = 128 -const SUBPROCESS_SPAWN_STATS_OVERFLOW_KEY = 'other' export function isMainThreadDiagnosticsEnabled(env: NodeJS.ProcessEnv = process.env): boolean { return env[MAIN_THREAD_DIAGNOSTICS_ENV] === '1' @@ -33,39 +30,32 @@ const SUBCOMMAND_BINARIES = new Set(['git', 'gh', 'glab']) // Split on both separators so Windows-style paths classify correctly even // when the classifier itself runs in a posix test environment. function binaryName(command: string): string { - const slash = command.lastIndexOf('/') - const backslash = command.lastIndexOf('\\') - const leaf = command.slice( - Math.max(slash, backslash) + 1, - Math.max(slash, backslash) + 1 + SUBPROCESS_BINARY_NAME_MAX_CHARS - ) - return leaf - .replace(/\.exe$/i, '') - .toLowerCase() - .replace(/$/u, '') + const leaf = command.split(/[\\/]/).pop() ?? command + return leaf.replace(/\.exe$/i, '').toLowerCase() } export function classifySubprocessCommand(command: string, args: readonly string[]): string { let binary = binaryName(command) - let firstArgIndex = 0 + const rest = [...args] if (binary === 'wsl') { - const separatorIndex = args.indexOf('--') - if (separatorIndex < 0) { - return 'wsl' + while (rest.length > 0) { + const arg = rest.shift() + if (arg === '--') { + break + } } - const unwrapped = args[separatorIndex + 1] + const unwrapped = rest.shift() if (!unwrapped) { return 'wsl' } binary = binaryName(unwrapped) - firstArgIndex = separatorIndex + 2 } if (!SUBCOMMAND_BINARIES.has(binary)) { return binary } let subcommand: string | null = null - for (let i = firstArgIndex; i < args.length; i++) { - const arg = args[i] + for (let i = 0; i < rest.length; i++) { + const arg = rest[i] if (!arg.startsWith('-')) { subcommand = arg break @@ -79,7 +69,7 @@ export function classifySubprocessCommand(command: string, args: readonly string if (!subcommand) { return binary } - return `${binary} ${subcommand.slice(0, 40).replace(/$/u, '')}` + return `${binary} ${subcommand.slice(0, 40)}` } export type SubprocessSpawnStats = { @@ -105,12 +95,7 @@ export function recordSubprocessSpawn( if (!isMainThreadDiagnosticsEnabled()) { return } - const classifiedKey = classifySubprocessCommand(command, args) - const key = - spawnStatsByCommand.has(classifiedKey) || - spawnStatsByCommand.size < SUBPROCESS_SPAWN_STATS_MAX_ENTRIES - 1 - ? classifiedKey - : SUBPROCESS_SPAWN_STATS_OVERFLOW_KEY + const key = classifySubprocessCommand(command, args) const stats = spawnStatsByCommand.get(key) if (stats) { stats.count++ diff --git a/src/main/emulator/android/android-device-inventory.test.ts b/src/main/emulator/android/android-device-inventory.test.ts index a608a53b518..d70d35a4510 100644 --- a/src/main/emulator/android/android-device-inventory.test.ts +++ b/src/main/emulator/android/android-device-inventory.test.ts @@ -2,11 +2,9 @@ import { describe, expect, it, vi } from 'vitest' import type { AndroidCommandResult, AndroidCommandRunner } from './android-command-runner' import type { AndroidSdkPaths } from './android-sdk-discovery' import { - ANDROID_AVD_NAME_LOOKUP_CONCURRENCY, findRunningAvdSerial, listAndroidDevices, - mergeAndroidDevices, - resolveRunningAvdNames + mergeAndroidDevices } from './android-device-inventory' import { parseAdbDevices } from './adb-devices' @@ -90,41 +88,4 @@ describe('findRunningAvdSerial', () => { expect(await findRunningAvdSerial(fake, SDK, 'Pixel_7', running)).toBe('emulator-5554') expect(await findRunningAvdSerial(fake, SDK, 'Other', running)).toBeNull() }) - - it.each([ - ['at the limit', ANDROID_AVD_NAME_LOOKUP_CONCURRENCY], - ['above the limit', ANDROID_AVD_NAME_LOOKUP_CONCURRENCY + 1] - ])('bounds AVD name probes %s', async (_, count) => { - let active = 0 - let peak = 0 - let started = 0 - const releases: (() => void)[] = [] - const fake: AndroidCommandRunner = async (_binary, args) => { - started++ - active++ - peak = Math.max(peak, active) - await new Promise((resolve) => releases.push(resolve)) - active-- - return ok(`${args[1]}\nOK`) - } - const running = parseAdbDevices( - `List of devices attached\n${Array.from( - { length: count }, - (_, index) => `emulator-${index}\tdevice` - ).join('\n')}` - ) - - const result = resolveRunningAvdNames(fake, SDK, running) - await vi.waitFor(() => - expect(started).toBe(Math.min(count, ANDROID_AVD_NAME_LOOKUP_CONCURRENCY)) - ) - if (count > ANDROID_AVD_NAME_LOOKUP_CONCURRENCY) { - releases.shift()?.() - await vi.waitFor(() => expect(started).toBe(count)) - } - releases.splice(0).forEach((release) => release()) - - expect((await result).size).toBe(count) - expect(peak).toBe(Math.min(count, ANDROID_AVD_NAME_LOOKUP_CONCURRENCY)) - }) }) diff --git a/src/main/emulator/android/android-device-inventory.ts b/src/main/emulator/android/android-device-inventory.ts index 1df03fce44c..63c3b17a2e8 100644 --- a/src/main/emulator/android/android-device-inventory.ts +++ b/src/main/emulator/android/android-device-inventory.ts @@ -3,9 +3,6 @@ import type { AndroidSdkPaths } from './android-sdk-discovery' import { adbDevicesArgs, parseAdbDevices, type AndroidAdbDevice } from './adb-devices' import { listAvdsArgs, parseAvdList } from './avd-manager' import type { EmulatorDevice } from '../backends/emulator-backend' -import { mapWithConcurrency } from '../../../shared/map-with-concurrency' - -export const ANDROID_AVD_NAME_LOOKUP_CONCURRENCY = 4 // Android device discovery: turns raw `adb`/`emulator` output into the // cross-backend EmulatorDevice list and resolves AVD names to running serials. @@ -26,19 +23,17 @@ export async function resolveRunningAvdNames( running: AndroidAdbDevice[] ): Promise> { const names = new Map() - const resolved = await mapWithConcurrency( - running.filter((device) => device.isEmulator), - ANDROID_AVD_NAME_LOOKUP_CONCURRENCY, - async (device) => { - const out = await runner(sdk.adb, ['-s', device.serial, 'emu', 'avd', 'name']) - return { serial: device.serial, name: firstNonStatusLine(out.stdout) } - } + await Promise.all( + running + .filter((device) => device.isEmulator) + .map(async (device) => { + const out = await runner(sdk.adb, ['-s', device.serial, 'emu', 'avd', 'name']) + const name = firstNonStatusLine(out.stdout) + if (name) { + names.set(device.serial, name) + } + }) ) - for (const { serial, name } of resolved) { - if (name) { - names.set(serial, name) - } - } return names } diff --git a/src/main/emulator/android/scrcpy-stream-session.test.ts b/src/main/emulator/android/scrcpy-stream-session.test.ts deleted file mode 100644 index 59fefd4e762..00000000000 --- a/src/main/emulator/android/scrcpy-stream-session.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - appendScrcpyServerLogPreview, - SCRCPY_SERVER_LOG_PREVIEW_CHARS -} from './scrcpy-stream-session' - -describe('appendScrcpyServerLogPreview', () => { - it('preserves the diagnostic prefix while bounding a chatty long-lived server', () => { - const prefix = 'startup\n' - const first = appendScrcpyServerLogPreview(prefix, Buffer.from('x'.repeat(100_000))) - const complete = appendScrcpyServerLogPreview(first, Buffer.from('ignored forever')) - - expect(complete).toBe(first) - expect(complete).toHaveLength(SCRCPY_SERVER_LOG_PREVIEW_CHARS) - expect(complete.startsWith(prefix)).toBe(true) - }) -}) diff --git a/src/main/emulator/android/scrcpy-stream-session.ts b/src/main/emulator/android/scrcpy-stream-session.ts index 552c1bfc3b5..b7cec131e2c 100644 --- a/src/main/emulator/android/scrcpy-stream-session.ts +++ b/src/main/emulator/android/scrcpy-stream-session.ts @@ -29,7 +29,6 @@ import { emulatorProbe, emulatorProbeError } from '../emulator-probe' const DEVICE_NAME_BYTES = 64 const DUMMY_BYTE = 1 const DYNAMIC_FORWARD_PORT = 0 -export const SCRCPY_SERVER_LOG_PREVIEW_CHARS = 1_000 export type ScrcpyStreamCallbacks = { onMeta: (meta: ScrcpyVideoMeta) => void @@ -47,14 +46,6 @@ export type ScrcpyStreamOptions = { maxSize?: number } -export function appendScrcpyServerLogPreview(current: string, chunk: Buffer): string { - const remaining = SCRCPY_SERVER_LOG_PREVIEW_CHARS - current.length - if (remaining <= 0) { - return current - } - return current + chunk.toString().slice(0, remaining) -} - function newScid(): string { // scrcpy parses scid as a SIGNED 32-bit hex int, so mask to 31 bits and pad to // 8 hex digits to match the server's own %08x format. @@ -136,13 +127,13 @@ export class ScrcpyStreamSession { }) let serverLog = '' const capture = (chunk: Buffer): void => { - serverLog = appendScrcpyServerLogPreview(serverLog, chunk) + serverLog += chunk.toString() } this.server.stdout?.on('data', capture) this.server.stderr?.on('data', capture) this.server.on('error', (error) => this.fail(error.message)) this.server.on('exit', (code) => { - emulatorProbe('scrcpy.server.exit', { code, log: serverLog.trim() }) + emulatorProbe('scrcpy.server.exit', { code, log: serverLog.slice(0, 1000).trim() }) if (!this.metaSeen) { this.fail('scrcpy server exited before the video stream started') return diff --git a/src/main/emulator/android/uiautomator-tree.test.ts b/src/main/emulator/android/uiautomator-tree.test.ts index 9d9ec7665f1..041c1a4abbd 100644 --- a/src/main/emulator/android/uiautomator-tree.test.ts +++ b/src/main/emulator/android/uiautomator-tree.test.ts @@ -1,12 +1,6 @@ import { describe, expect, it } from 'vitest' import { EmulatorError } from '../emulator-errors' -import { - ANDROID_UIAUTOMATOR_XML_MAX_ATTRIBUTES_PER_ELEMENT, - ANDROID_UIAUTOMATOR_XML_MAX_DEPTH, - ANDROID_UIAUTOMATOR_XML_MAX_ELEMENTS, - parseAndroidBounds, - parseUiAutomatorXml -} from './uiautomator-tree' +import { parseAndroidBounds, parseUiAutomatorXml } from './uiautomator-tree' // Realistic `uiautomator dump` output: an XML prolog, a root, and // nested self-describing elements (mix of container + self-closing leaf). @@ -103,23 +97,6 @@ describe('parseUiAutomatorXml', () => { expect(() => parseUiAutomatorXml('')).toThrowError(EmulatorError) expect(() => parseUiAutomatorXml('')).toThrowError(EmulatorError) }) - - it('rejects element-count, nesting, and per-element attribute amplification', () => { - const tooManyElements = `${''.repeat( - ANDROID_UIAUTOMATOR_XML_MAX_ELEMENTS - )}` - const tooDeep = `${''.repeat(ANDROID_UIAUTOMATOR_XML_MAX_DEPTH + 1)}${''.repeat( - ANDROID_UIAUTOMATOR_XML_MAX_DEPTH + 1 - )}` - const tooManyAttributes = ` `a${index}="x"` - ).join(' ')} />` - - expect(() => parseUiAutomatorXml(tooManyElements)).toThrow('exceeds 50000 elements') - expect(() => parseUiAutomatorXml(tooDeep)).toThrow('exceeds 256 levels') - expect(() => parseUiAutomatorXml(tooManyAttributes)).toThrow('exceeds 64 attributes') - }) }) describe('parseAndroidBounds', () => { diff --git a/src/main/emulator/android/uiautomator-tree.ts b/src/main/emulator/android/uiautomator-tree.ts index f63299db528..3a3bb7d6e6a 100644 --- a/src/main/emulator/android/uiautomator-tree.ts +++ b/src/main/emulator/android/uiautomator-tree.ts @@ -18,11 +18,6 @@ export type AndroidAxNode = { // Raw element produced by the parser before mapping to the typed Android node. type RawElement = { tag: string; attributes: Record; children: RawElement[] } -export const ANDROID_UIAUTOMATOR_XML_MAX_BYTES = 16 * 1024 * 1024 -export const ANDROID_UIAUTOMATOR_XML_MAX_ELEMENTS = 50_000 -export const ANDROID_UIAUTOMATOR_XML_MAX_DEPTH = 256 -export const ANDROID_UIAUTOMATOR_XML_MAX_ATTRIBUTES_PER_ELEMENT = 64 - // Parses an Android bounds string "[left,top][right,bottom]" -> AndroidAxBounds, // or null when the format doesn't match. Coordinates may be negative (off-screen). export function parseAndroidBounds(value: string): AndroidAxBounds | null { @@ -45,9 +40,6 @@ export function parseUiAutomatorXml(xml: string): AndroidAxNode { if (xml.trim() === '') { throw new EmulatorError('emulator_error', 'Cannot parse empty uiautomator XML') } - if (Buffer.byteLength(xml) > ANDROID_UIAUTOMATOR_XML_MAX_BYTES) { - throw new EmulatorError('emulator_error', 'uiautomator XML exceeds the 16 MiB limit') - } let root: RawElement try { root = parseDocument(xml) @@ -115,7 +107,6 @@ function setBool( function parseDocument(xml: string): RawElement { let i = 0 const n = xml.length - let elementCount = 0 const fail = (message: string): never => { throw new EmulatorError('emulator_error', `${message} at offset ${i}`) @@ -165,14 +156,7 @@ function parseDocument(xml: string): RawElement { } } - const parseElement = (depth: number): RawElement => { - if (depth > ANDROID_UIAUTOMATOR_XML_MAX_DEPTH) { - fail(`uiautomator XML exceeds ${ANDROID_UIAUTOMATOR_XML_MAX_DEPTH} levels`) - } - elementCount += 1 - if (elementCount > ANDROID_UIAUTOMATOR_XML_MAX_ELEMENTS) { - fail(`uiautomator XML exceeds ${ANDROID_UIAUTOMATOR_XML_MAX_ELEMENTS} elements`) - } + const parseElement = (): RawElement => { if (xml[i] !== '<') { fail('Expected element start') } @@ -182,7 +166,6 @@ function parseDocument(xml: string): RawElement { fail('Expected tag name') } const attributes: Record = {} - let attributeCount = 0 for (;;) { skipWs() if (i >= n) { @@ -203,12 +186,6 @@ function parseDocument(xml: string): RawElement { if (name === '') { fail('Expected attribute name') } - attributeCount += 1 - if (attributeCount > ANDROID_UIAUTOMATOR_XML_MAX_ATTRIBUTES_PER_ELEMENT) { - fail( - `uiautomator XML element exceeds ${ANDROID_UIAUTOMATOR_XML_MAX_ATTRIBUTES_PER_ELEMENT} attributes` - ) - } skipWs() if (xml[i] !== '=') { fail("Expected '=' after attribute name") @@ -230,14 +207,10 @@ function parseDocument(xml: string): RawElement { attributes[name] = decodeEntities(xml.slice(start, i)) i++ } - return parseChildren(tag, attributes, depth) + return parseChildren(tag, attributes) } - const parseChildren = ( - tag: string, - attributes: Record, - depth: number - ): RawElement => { + const parseChildren = (tag: string, attributes: Record): RawElement => { const children: RawElement[] = [] for (;;) { if (i >= n) { @@ -270,7 +243,7 @@ function parseDocument(xml: string): RawElement { } else if (startsWith('', 'processing instruction') } else { - children.push(parseElement(depth + 1)) + children.push(parseElement()) } } } @@ -279,7 +252,7 @@ function parseDocument(xml: string): RawElement { if (i >= n || xml[i] !== '<') { fail('No root element found') } - return parseElement(1) + return parseElement() } // Decodes the predefined XML entities plus numeric character references. diff --git a/src/main/emulator/mjpeg-frame-stream-reconnect.test.ts b/src/main/emulator/mjpeg-frame-stream-reconnect.test.ts deleted file mode 100644 index ad8816d3893..00000000000 --- a/src/main/emulator/mjpeg-frame-stream-reconnect.test.ts +++ /dev/null @@ -1,169 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -type FakeResponseLike = { - destroy: ReturnType - destroyed: boolean - emit: (eventName: string, ...args: unknown[]) => boolean -} - -type FakeRequestLike = { - destroy: ReturnType - destroyed: boolean - emit: (eventName: string, ...args: unknown[]) => boolean - respond: (statusCode: number) => FakeResponseLike -} - -const requestState = vi.hoisted(() => ({ - requests: [] as FakeRequestLike[] -})) - -vi.mock('node:http', async () => { - const { EventEmitter } = await import('node:events') - - class FakeResponse extends EventEmitter implements FakeResponseLike { - destroyed = false - readonly resume = vi.fn() - readonly destroy = vi.fn(() => { - this.destroyed = true - return this - }) - - constructor(readonly statusCode: number) { - super() - } - } - - class FakeRequest extends EventEmitter implements FakeRequestLike { - destroyed = false - private response: FakeResponse | null = null - readonly end = vi.fn() - readonly destroy = vi.fn((error?: Error) => { - if (this.destroyed) { - return this - } - this.destroyed = true - this.response?.destroy() - if (error) { - this.emit('error', error) - } - return this - }) - - constructor(private readonly onResponse: (response: FakeResponse) => void) { - super() - } - - respond(statusCode: number): FakeResponse { - const response = new FakeResponse(statusCode) - this.response = response - this.onResponse(response) - return response - } - } - - return { - request: vi.fn( - ( - _url: unknown, - _options: unknown, - onResponse: (response: FakeResponse) => void - ): FakeRequest => { - const request = new FakeRequest(onResponse) - requestState.requests.push(request) - return request - } - ) - } -}) - -import { MjpegFrameStream } from './mjpeg-frame-stream' - -const JPEG = Buffer.from([0xff, 0xd8, 0x01, 0x02, 0xff, 0xd9]) -const streams: MjpegFrameStream[] = [] - -function makeStream() { - const callbacks = { - onError: vi.fn(), - onFrame: vi.fn() - } - const stream = new MjpegFrameStream('http://127.0.0.1:3100/stream.mjpeg', callbacks) - streams.push(stream) - return { callbacks, stream } -} - -beforeEach(() => { - vi.useFakeTimers() - requestState.requests.length = 0 -}) - -afterEach(() => { - for (const stream of streams) { - stream.stop() - } - streams.length = 0 - vi.useRealTimers() -}) - -describe('MjpegFrameStream reconnect ownership', () => { - it('closes an endless error response before reconnecting and ignores stale errors', () => { - const { callbacks, stream } = makeStream() - stream.start() - stream.start() - expect(requestState.requests).toHaveLength(1) - - const firstRequest = requestState.requests[0] - const errorResponse = firstRequest.respond(503) - expect(firstRequest.destroyed).toBe(true) - expect(errorResponse.destroyed).toBe(true) - expect(callbacks.onError).toHaveBeenCalledWith('Simulator stream returned HTTP 503.') - - vi.runOnlyPendingTimers() - expect(requestState.requests).toHaveLength(2) - - firstRequest.emit('error', new Error('late failure')) - vi.runOnlyPendingTimers() - expect(requestState.requests).toHaveLength(2) - expect(callbacks.onError).toHaveBeenCalledTimes(1) - - requestState.requests[1].emit('timeout') - expect(callbacks.onError).toHaveBeenLastCalledWith('Simulator stream timed out.') - stream.stop() - vi.runOnlyPendingTimers() - expect(requestState.requests).toHaveLength(2) - }) - - it('delivers a valid response and reconnects once after it ends', () => { - const { callbacks, stream } = makeStream() - stream.start() - const firstRequest = requestState.requests[0] - const response = firstRequest.respond(200) - - response.emit('data', JPEG) - expect(callbacks.onFrame).toHaveBeenCalledWith(JPEG) - - response.emit('end') - expect(firstRequest.destroyed).toBe(true) - vi.runOnlyPendingTimers() - expect(requestState.requests).toHaveLength(2) - - response.emit('error', new Error('late response error')) - vi.runOnlyPendingTimers() - expect(requestState.requests).toHaveLength(2) - expect(callbacks.onError).not.toHaveBeenCalled() - }) - - it('destroys a response that arrives after its request is stale', () => { - const { callbacks, stream } = makeStream() - stream.start() - const firstRequest = requestState.requests[0] - - firstRequest.emit('error', new Error('connection reset')) - vi.runOnlyPendingTimers() - expect(requestState.requests).toHaveLength(2) - - const staleResponse = firstRequest.respond(200) - staleResponse.emit('data', JPEG) - expect(staleResponse.destroyed).toBe(true) - expect(callbacks.onFrame).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/emulator/mjpeg-frame-stream.ts b/src/main/emulator/mjpeg-frame-stream.ts index 1df5817839c..315b13f303d 100644 --- a/src/main/emulator/mjpeg-frame-stream.ts +++ b/src/main/emulator/mjpeg-frame-stream.ts @@ -56,9 +56,6 @@ export class MjpegFrameStream { } start(): void { - if (this.stopped || this.request || this.reconnectTimer) { - return - } this.openRequest() } @@ -73,12 +70,10 @@ export class MjpegFrameStream { this.pending = Buffer.alloc(0) } - private scheduleReconnect(request: ClientRequest): void { - if (this.stopped || this.request !== request || this.reconnectTimer) { + private scheduleReconnect(): void { + if (this.stopped || this.reconnectTimer) { return } - this.request = null - request.destroy() this.reconnectTimer = setTimeout(() => { this.reconnectTimer = null this.openRequest() @@ -86,45 +81,34 @@ export class MjpegFrameStream { } private openRequest(): void { - if (this.stopped || this.request) { + if (this.stopped) { return } const req = requestForUrl(this.streamUrl, (res) => { - if (this.stopped || this.request !== req) { - res.destroy() - return - } if (res.statusCode && res.statusCode >= 400) { - this.scheduleReconnect(req) - res.destroy() this.callbacks.onError(`Simulator stream returned HTTP ${res.statusCode}.`) + res.resume() + this.scheduleReconnect() return } - res.on('data', (chunk: Buffer) => { - if (!this.stopped && this.request === req) { - this.handleChunk(chunk) - } - }) - res.on('end', () => this.scheduleReconnect(req)) + res.on('data', (chunk: Buffer) => this.handleChunk(chunk)) + res.on('end', () => this.scheduleReconnect()) res.on('error', (error) => { - if (this.stopped || this.request !== req) { - return - } - this.scheduleReconnect(req) this.callbacks.onError(error.message) + this.scheduleReconnect() }) }) this.request = req req.on('timeout', () => req.destroy(new Error('Simulator stream timed out.'))) req.on('error', (error) => { - if (this.stopped || this.request !== req) { + if (this.stopped) { return } - this.scheduleReconnect(req) this.callbacks.onError(error.message) + this.scheduleReconnect() }) req.end() } diff --git a/src/main/emulator/scrcpy-video-registry-memory.test.ts b/src/main/emulator/scrcpy-video-registry-memory.test.ts deleted file mode 100644 index 805c7f9bb83..00000000000 --- a/src/main/emulator/scrcpy-video-registry-memory.test.ts +++ /dev/null @@ -1,194 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { - SCRCPY_VIDEO_MAX_GOP_FRAMES, - SCRCPY_VIDEO_MAX_REGISTRY_ENTRIES, - SCRCPY_VIDEO_MAX_REPLAY_BYTES_PER_DEVICE, - SCRCPY_VIDEO_MAX_REPLAY_BYTES_TOTAL, - SCRCPY_VIDEO_MAX_SUBSCRIBERS, - scrcpyVideoRegistry, - type ScrcpyVideoFrameMessage -} from './scrcpy-video-registry' - -const MEBIBYTE = 1024 * 1024 -const registeredDevices = new Set() - -function registerDevice(deviceId: string, close = vi.fn()): void { - scrcpyVideoRegistry.register(deviceId, close) - registeredDevices.add(deviceId) -} - -function frame( - pts: string, - byteLength: number, - options: { config?: boolean; keyFrame?: boolean } = {} -): ScrcpyVideoFrameMessage { - return { - config: options.config === true, - keyFrame: options.keyFrame === true, - pts, - bytes: { byteLength } as ArrayBuffer - } -} - -function replayFrames(deviceId: string): ScrcpyVideoFrameMessage[] { - const frames: ScrcpyVideoFrameMessage[] = [] - const unsubscribe = scrcpyVideoRegistry.subscribe(deviceId, (event) => { - if (event.type === 'frame') { - frames.push(event.frame) - } - }) - unsubscribe() - return frames -} - -function replayBytes(deviceId: string): number { - return replayFrames(deviceId).reduce((total, replayed) => total + replayed.bytes.byteLength, 0) -} - -afterEach(() => { - for (const deviceId of registeredDevices) { - scrcpyVideoRegistry.stop(deviceId) - } - registeredDevices.clear() -}) - -describe('scrcpy video replay retention', () => { - it('sheds an overloaded GOP until a fresh keyframe arrives', () => { - registerDevice('frame-cap') - scrcpyVideoRegistry.pushFrame('frame-cap', frame('key', 1, { keyFrame: true })) - for (let index = 1; index <= SCRCPY_VIDEO_MAX_GOP_FRAMES + 10; index += 1) { - scrcpyVideoRegistry.pushFrame('frame-cap', frame(String(index), 1)) - } - - expect(replayFrames('frame-cap')).toEqual([]) - - scrcpyVideoRegistry.pushFrame('frame-cap', frame('recovery-key', 1, { keyFrame: true })) - expect(replayFrames('frame-cap').map((replayed) => replayed.pts)).toEqual(['recovery-key']) - }) - - it('sheds only cached deltas under per-device byte pressure', () => { - registerDevice('device-bytes') - const liveFrames: string[] = [] - const unsubscribe = scrcpyVideoRegistry.subscribe('device-bytes', (event) => { - if (event.type === 'frame') { - liveFrames.push(event.frame.pts) - } - }) - - scrcpyVideoRegistry.pushFrame('device-bytes', frame('config', MEBIBYTE, { config: true })) - scrcpyVideoRegistry.pushFrame('device-bytes', frame('key', 8 * MEBIBYTE, { keyFrame: true })) - for (let index = 1; index <= 5; index += 1) { - scrcpyVideoRegistry.pushFrame('device-bytes', frame(`delta-${index}`, 8 * MEBIBYTE)) - } - unsubscribe() - - expect(liveFrames).toEqual([ - 'config', - 'key', - 'delta-1', - 'delta-2', - 'delta-3', - 'delta-4', - 'delta-5' - ]) - expect(replayFrames('device-bytes').map((replayed) => replayed.pts)).toEqual(['config']) - expect(replayBytes('device-bytes')).toBeLessThanOrEqual( - SCRCPY_VIDEO_MAX_REPLAY_BYTES_PER_DEVICE - ) - scrcpyVideoRegistry.pushFrame( - 'device-bytes', - frame('recovery-key', 8 * MEBIBYTE, { keyFrame: true }) - ) - expect(replayFrames('device-bytes').map((replayed) => replayed.pts)).toEqual([ - 'config', - 'recovery-key' - ]) - }) - - it('delivers an oversized frame live without retaining invalid replay', () => { - registerDevice('oversized') - const liveFrames: string[] = [] - const unsubscribe = scrcpyVideoRegistry.subscribe('oversized', (event) => { - if (event.type === 'frame') { - liveFrames.push(event.frame.pts) - } - }) - scrcpyVideoRegistry.pushFrame('oversized', frame('config', 1, { config: true })) - scrcpyVideoRegistry.pushFrame( - 'oversized', - frame('key', SCRCPY_VIDEO_MAX_REPLAY_BYTES_PER_DEVICE, { keyFrame: true }) - ) - unsubscribe() - - expect(liveFrames).toEqual(['config', 'key']) - expect(replayFrames('oversized').map((replayed) => replayed.pts)).toEqual(['config']) - }) - - it('bounds replay bytes across devices while protecting the newest device', () => { - const perEntryBytes = 16 * MEBIBYTE - const deviceCount = Math.floor(SCRCPY_VIDEO_MAX_REPLAY_BYTES_TOTAL / perEntryBytes) + 1 - const deviceIds = Array.from({ length: deviceCount }, (_, index) => `aggregate-${index}`) - - for (const deviceId of deviceIds) { - registerDevice(deviceId) - scrcpyVideoRegistry.pushFrame(deviceId, frame('config', MEBIBYTE, { config: true })) - scrcpyVideoRegistry.pushFrame( - deviceId, - frame('key', perEntryBytes - MEBIBYTE, { keyFrame: true }) - ) - } - - const totalReplayBytes = deviceIds.reduce((total, deviceId) => total + replayBytes(deviceId), 0) - expect(totalReplayBytes).toBeLessThanOrEqual(SCRCPY_VIDEO_MAX_REPLAY_BYTES_TOTAL) - expect(replayFrames(deviceIds.at(-1)!).map((replayed) => replayed.pts)).toEqual([ - 'config', - 'key' - ]) - for (const deviceId of deviceIds) { - expect(replayFrames(deviceId).map((replayed) => replayed.pts)).toEqual( - expect.arrayContaining(['config']) - ) - } - }) - - it('cleans accounting before a reentrant close callback', () => { - const close = vi.fn(() => scrcpyVideoRegistry.stop('reentrant')) - registerDevice('reentrant', close) - scrcpyVideoRegistry.pushFrame( - 'reentrant', - frame('key', SCRCPY_VIDEO_MAX_REPLAY_BYTES_PER_DEVICE, { keyFrame: true }) - ) - - scrcpyVideoRegistry.stop('reentrant') - - expect(close).toHaveBeenCalledOnce() - expect(scrcpyVideoRegistry.has('reentrant')).toBe(false) - }) - - it('bounds active registry entries without closing admitted streams', () => { - const closes: ReturnType[] = [] - for (let index = 0; index < SCRCPY_VIDEO_MAX_REGISTRY_ENTRIES; index += 1) { - const close = vi.fn() - closes.push(close) - registerDevice(`entry-${index}`, close) - } - - expect(() => registerDevice('entry-overflow')).toThrow(/active scrcpy video streams/) - for (const close of closes) { - expect(close).not.toHaveBeenCalled() - } - }) - - it('bounds subscriber callbacks and releases admission on unsubscribe', () => { - registerDevice('subscriber-cap') - const unsubscribes = Array.from({ length: SCRCPY_VIDEO_MAX_SUBSCRIBERS }, () => - scrcpyVideoRegistry.subscribe('subscriber-cap', () => {}) - ) - - expect(() => scrcpyVideoRegistry.subscribe('subscriber-cap', () => {})).toThrow( - /video subscribers/ - ) - unsubscribes[0]() - expect(() => scrcpyVideoRegistry.subscribe('subscriber-cap', () => {})).not.toThrow() - }) -}) diff --git a/src/main/emulator/scrcpy-video-registry.ts b/src/main/emulator/scrcpy-video-registry.ts index 47a4215f0e9..417317ac550 100644 --- a/src/main/emulator/scrcpy-video-registry.ts +++ b/src/main/emulator/scrcpy-video-registry.ts @@ -8,13 +8,8 @@ import type { ScrcpyVideoMeta } from './android/scrcpy-video-frame-parser' // scrcpy keyframes ~every 10s; high-motion content can otherwise buffer // hundreds of deltas. Cap the replayed GOP so memory stays bounded and a -// late subscriber isn't flooded. An overloaded GOP is shed as a unit because -// dropping an interior delta would make the retained suffix undecodable. -export const SCRCPY_VIDEO_MAX_GOP_FRAMES = 120 -export const SCRCPY_VIDEO_MAX_REPLAY_BYTES_PER_DEVICE = 32 * 1024 * 1024 -export const SCRCPY_VIDEO_MAX_REPLAY_BYTES_TOTAL = 128 * 1024 * 1024 -export const SCRCPY_VIDEO_MAX_REGISTRY_ENTRIES = 16 -export const SCRCPY_VIDEO_MAX_SUBSCRIBERS = 8 +// late subscriber isn't flooded — always keep the keyframe at index 0. +const MAX_GOP_FRAMES = 120 export type ScrcpyVideoFrameMessage = { config: boolean @@ -35,27 +30,13 @@ type RegistryEntry = { gop: ScrcpyVideoFrameMessage[] subscribers: Set close: () => void - retainedBytes: number } class ScrcpyVideoRegistry { private readonly entries = new Map() - private retainedBytes = 0 - private subscriberCount = 0 register(deviceId: string, close: () => void): void { - if (!this.entries.has(deviceId) && this.entries.size >= SCRCPY_VIDEO_MAX_REGISTRY_ENTRIES) { - throw new Error( - `Orca can have at most ${SCRCPY_VIDEO_MAX_REGISTRY_ENTRIES} active scrcpy video streams.` - ) - } - this.stop(deviceId) - this.entries.set(deviceId, { - subscribers: new Set(), - gop: [], - close, - retainedBytes: 0 - }) + this.entries.set(deviceId, { subscribers: new Set(), gop: [], close }) } pushMeta(deviceId: string, meta: ScrcpyVideoMeta): void { @@ -75,21 +56,20 @@ class ScrcpyVideoRegistry { return } if (frame.config) { - this.replaceConfig(entry, frame) + entry.config = frame } else if (frame.keyFrame) { // A keyframe starts a fresh decodeable GOP; buffer it + the following // deltas so a late subscriber can decode immediately on replay. - this.clearGop(entry) - entry.gop.push(frame) - this.adjustRetainedBytes(entry, frame.bytes.byteLength) + entry.gop = [frame] } else if (entry.gop.length > 0) { // Only buffer deltas once a keyframe anchors the GOP (a delta alone is // undecodable); deltas before the first keyframe are still sent live below. entry.gop.push(frame) - this.adjustRetainedBytes(entry, frame.bytes.byteLength) + // Drop the oldest delta (never index 0, the keyframe) so replay stays decodable. + if (entry.gop.length > MAX_GOP_FRAMES) { + entry.gop.splice(1, 1) + } } - this.trimEntryReplay(entry) - this.trimAggregateReplay(deviceId) for (const subscriber of entry.subscribers) { subscriber({ type: 'frame', frame }) } @@ -102,11 +82,6 @@ class ScrcpyVideoRegistry { if (!entry) { return () => {} } - if (this.subscriberCount >= SCRCPY_VIDEO_MAX_SUBSCRIBERS) { - throw new Error( - `Orca can have at most ${SCRCPY_VIDEO_MAX_SUBSCRIBERS} scrcpy video subscribers.` - ) - } if (entry.meta) { subscriber({ type: 'meta', meta: entry.meta }) } @@ -117,16 +92,8 @@ class ScrcpyVideoRegistry { for (const frame of entry.gop) { subscriber({ type: 'frame', frame }) } - if (this.entries.get(deviceId) !== entry) { - return () => {} - } entry.subscribers.add(subscriber) - this.subscriberCount += 1 - return () => { - if (entry.subscribers.delete(subscriber)) { - this.subscriberCount -= 1 - } - } + return () => entry.subscribers.delete(subscriber) } stop(deviceId: string): void { @@ -134,90 +101,14 @@ class ScrcpyVideoRegistry { if (!entry) { return } - this.entries.delete(deviceId) - this.subscriberCount -= entry.subscribers.size - entry.subscribers.clear() - this.clearGop(entry) - this.clearConfig(entry) entry.close() + entry.subscribers.clear() + this.entries.delete(deviceId) } has(deviceId: string): boolean { return this.entries.has(deviceId) } - - private adjustRetainedBytes(entry: RegistryEntry, delta: number): void { - entry.retainedBytes += delta - this.retainedBytes += delta - } - - private replaceConfig(entry: RegistryEntry, frame: ScrcpyVideoFrameMessage): void { - this.clearConfig(entry) - entry.config = frame - this.adjustRetainedBytes(entry, frame.bytes.byteLength) - } - - private clearConfig(entry: RegistryEntry): void { - if (!entry.config) { - return - } - this.adjustRetainedBytes(entry, -entry.config.bytes.byteLength) - entry.config = undefined - } - - private clearGop(entry: RegistryEntry): void { - for (const frame of entry.gop) { - this.adjustRetainedBytes(entry, -frame.bytes.byteLength) - } - entry.gop = [] - } - - private trimEntryReplay(entry: RegistryEntry): void { - if ( - entry.gop.length > SCRCPY_VIDEO_MAX_GOP_FRAMES || - entry.retainedBytes > SCRCPY_VIDEO_MAX_REPLAY_BYTES_PER_DEVICE - ) { - this.clearGop(entry) - } - if (entry.retainedBytes > SCRCPY_VIDEO_MAX_REPLAY_BYTES_PER_DEVICE) { - this.clearConfig(entry) - } - } - - private trimAggregateReplay(preferredDeviceId: string): void { - if (this.retainedBytes <= SCRCPY_VIDEO_MAX_REPLAY_BYTES_TOTAL) { - return - } - const preferred = this.entries.get(preferredDeviceId) - - for (const [deviceId, entry] of this.entries) { - if (deviceId === preferredDeviceId) { - continue - } - if (this.retainedBytes > SCRCPY_VIDEO_MAX_REPLAY_BYTES_TOTAL && entry.gop.length > 0) { - this.clearGop(entry) - } - } - if ( - preferred && - this.retainedBytes > SCRCPY_VIDEO_MAX_REPLAY_BYTES_TOTAL && - preferred.gop.length > 0 - ) { - this.clearGop(preferred) - } - for (const [deviceId, entry] of this.entries) { - if (this.retainedBytes <= SCRCPY_VIDEO_MAX_REPLAY_BYTES_TOTAL) { - break - } - if (deviceId === preferredDeviceId) { - continue - } - this.clearConfig(entry) - } - if (preferred && this.retainedBytes > SCRCPY_VIDEO_MAX_REPLAY_BYTES_TOTAL) { - this.clearConfig(preferred) - } - } } export const scrcpyVideoRegistry = new ScrcpyVideoRegistry() diff --git a/src/main/emulator/serve-sim-accessibility-tree.test.ts b/src/main/emulator/serve-sim-accessibility-tree.test.ts index 2976554f0ee..48314017a87 100644 --- a/src/main/emulator/serve-sim-accessibility-tree.test.ts +++ b/src/main/emulator/serve-sim-accessibility-tree.test.ts @@ -4,10 +4,7 @@ const { netFetchMock } = vi.hoisted(() => ({ netFetchMock: vi.fn() })) vi.mock('electron', () => ({ net: { fetch: netFetchMock } })) -import { - MAX_SERVE_SIM_AX_RESPONSE_BYTES, - requestServeSimAccessibilityTree -} from './serve-sim-accessibility-tree' +import { requestServeSimAccessibilityTree } from './serve-sim-accessibility-tree' const AX_URL = 'http://127.0.0.1:3100/ax' @@ -88,25 +85,6 @@ describe('requestServeSimAccessibilityTree', () => { }) }) - it('rejects oversized or excessively nested payloads before parsing', async () => { - netFetchMock.mockResolvedValueOnce( - new Response('[]', { - status: 200, - headers: { 'content-length': String(MAX_SERVE_SIM_AX_RESPONSE_BYTES + 1) } - }) - ) - await expect(requestServeSimAccessibilityTree(AX_URL)).rejects.toMatchObject({ - code: 'emulator_helper_failed' - }) - - netFetchMock.mockResolvedValueOnce( - new Response(`${'['.repeat(129)}0${']'.repeat(129)}`, { status: 200 }) - ) - await expect(requestServeSimAccessibilityTree(AX_URL)).rejects.toMatchObject({ - code: 'emulator_error' - }) - }) - it('maps a network failure to a helper error', async () => { netFetchMock.mockRejectedValue(new Error('connect ECONNREFUSED')) diff --git a/src/main/emulator/serve-sim-accessibility-tree.ts b/src/main/emulator/serve-sim-accessibility-tree.ts index cde73fdc646..dab53d0e382 100644 --- a/src/main/emulator/serve-sim-accessibility-tree.ts +++ b/src/main/emulator/serve-sim-accessibility-tree.ts @@ -1,22 +1,16 @@ import { net } from 'electron' import { EmulatorError } from './emulator-errors' import { normalizeServeSimAxTree, type NormalizedAxNode } from './serve-sim-ax-normalization' -import { - API_RESPONSE_JSON_LIMITS, - readFetchResponseTextWithinLimit -} from '../../shared/fetch-response-body' -import { assertJsonTextStructureWithinLimits } from '../../shared/json-text-structure-limit' const AX_REQUEST_TIMEOUT_MS = 5_000 const MAX_ERROR_BODY_LENGTH = 512 -export const MAX_SERVE_SIM_AX_RESPONSE_BYTES = 16 * 1024 * 1024 export async function requestServeSimAccessibilityTree(axUrl: string): Promise { try { const response = await net.fetch(axUrl, { signal: AbortSignal.timeout(AX_REQUEST_TIMEOUT_MS) }) - const body = await readFetchResponseTextWithinLimit(response, MAX_SERVE_SIM_AX_RESPONSE_BYTES) + const body = await response.text() if (!response.ok) { const detail = body.slice(0, MAX_ERROR_BODY_LENGTH) || response.statusText const retry = response.status === 503 ? ' Accessibility may still be warming up; retry.' : '' @@ -28,7 +22,6 @@ export async function requestServeSimAccessibilityTree(axUrl: string): Promise { - return { - device: udid, - streamUrl: 'http://127.0.0.1:3100/stream.mjpeg', - wsUrl: 'ws://127.0.0.1:3100/ws' - } -} - -afterEach(() => { - for (const filePath of stateFiles.splice(0)) { - rmSync(filePath, { force: true }) - } -}) - describe('parseServeSimDetachedSession', () => { it('uses serve-sim streamUrl when present', () => { const info = parseServeSimDetachedSession( @@ -83,27 +56,6 @@ describe('parseServeSimDetachedSession', () => { expect(info.streamUrl).toBe('http://127.0.0.1:3100/stream.mjpeg') }) - - it('reads a helper PID state file at the exact byte boundary', () => { - const udid = `orca-boundary-${process.pid}-${Date.now()}` - const statePath = stateFileFor(udid) - const state = '{"pid":4321}' - writeFileSync( - statePath, - state + ' '.repeat(SERVE_SIM_STATE_FILE_MAX_BYTES - Buffer.byteLength(state)) - ) - - expect(parseServeSimDetachedSession(detachedPayload(udid), udid).helperPid).toBe(4321) - }) - - it('ignores a sparse helper PID state file one byte over the boundary', () => { - const udid = `orca-oversized-${process.pid}-${Date.now()}` - const statePath = stateFileFor(udid) - writeFileSync(statePath, '{"pid":4321}') - truncateSync(statePath, SERVE_SIM_STATE_FILE_MAX_BYTES + 1) - - expect(parseServeSimDetachedSession(detachedPayload(udid), udid).helperPid).toBeUndefined() - }) }) describe('deriveAxUrlFromStreamUrl', () => { diff --git a/src/main/emulator/serve-sim-detached-session.ts b/src/main/emulator/serve-sim-detached-session.ts index 2d579d06322..d45bde41ff0 100644 --- a/src/main/emulator/serve-sim-detached-session.ts +++ b/src/main/emulator/serve-sim-detached-session.ts @@ -1,11 +1,10 @@ +import { existsSync, readFileSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' -import { readNodeFileSyncWithinLimit } from '../../shared/node-bounded-file-reader' import { EmulatorError } from './emulator-errors' import type { EmulatorSessionInfo } from './emulator-types' const MJPEG_STREAM_SUFFIX = '/stream.mjpeg' -export const SERVE_SIM_STATE_FILE_MAX_BYTES = 8 * 1024 function streamUrlFromServeSimUrl(url: string): string { return url.endsWith(MJPEG_STREAM_SUFFIX) ? url : `${url.replace(/\/$/, '')}${MJPEG_STREAM_SUFFIX}` @@ -43,11 +42,11 @@ export function parseServeSimDetachedSession(raw: unknown, udid: string): Emulat } try { const statePath = join(tmpdir(), 'serve-sim', `server-${info.deviceUdid}.json`) - const state = JSON.parse( - readNodeFileSyncWithinLimit(statePath, SERVE_SIM_STATE_FILE_MAX_BYTES).buffer.toString('utf8') - ) as { pid?: unknown } - if (typeof state.pid === 'number') { - info.helperPid = state.pid + if (existsSync(statePath)) { + const state = JSON.parse(readFileSync(statePath, 'utf8')) as { pid?: unknown } + if (typeof state.pid === 'number') { + info.helperPid = state.pid + } } } catch {} return info diff --git a/src/main/emulator/serve-sim-execution.ts b/src/main/emulator/serve-sim-execution.ts index 9207fdfd913..5bdf0e34cc2 100644 --- a/src/main/emulator/serve-sim-execution.ts +++ b/src/main/emulator/serve-sim-execution.ts @@ -1,9 +1,16 @@ import { execFile } from 'node:child_process' -import { accessSync, chmodSync, constants, existsSync, mkdirSync, writeFileSync } from 'node:fs' +import { + accessSync, + chmodSync, + constants, + existsSync, + mkdirSync, + readFileSync, + writeFileSync +} from 'node:fs' import { app } from 'electron' import { platform, tmpdir } from 'node:os' import { delimiter, dirname, join } from 'node:path' -import { nodeFileContentsEqualSync } from '../../shared/node-file-content-equality' import { EmulatorError } from './emulator-errors' import { materializeServeSimRuntime } from './serve-sim-runtime-materializer' @@ -38,10 +45,8 @@ function ensureMacOpenShim(): string | null { } try { mkdirSync(MAC_OPEN_SHIM_DIR, { recursive: true }) - if ( - !existsSync(MAC_OPEN_SHIM_PATH) || - !nodeFileContentsEqualSync(MAC_OPEN_SHIM_PATH, MAC_OPEN_SHIM) - ) { + const current = existsSync(MAC_OPEN_SHIM_PATH) ? readFileSync(MAC_OPEN_SHIM_PATH, 'utf8') : '' + if (current !== MAC_OPEN_SHIM) { writeFileSync(MAC_OPEN_SHIM_PATH, MAC_OPEN_SHIM, { mode: 0o755 }) } chmodSync(MAC_OPEN_SHIM_PATH, 0o755) diff --git a/src/main/emulator/serve-sim-runtime-materializer.ts b/src/main/emulator/serve-sim-runtime-materializer.ts index c7519c79cb1..26abff4a6be 100644 --- a/src/main/emulator/serve-sim-runtime-materializer.ts +++ b/src/main/emulator/serve-sim-runtime-materializer.ts @@ -1,5 +1,5 @@ import { execFileSync } from 'node:child_process' -import { chmodSync, cpSync, existsSync, mkdirSync, opendirSync, renameSync, rmSync } from 'node:fs' +import { chmodSync, cpSync, existsSync, mkdirSync, readdirSync, renameSync, rmSync } from 'node:fs' import { join } from 'node:path' export type ServeSimRuntimeMaterializerOptions = { @@ -13,7 +13,6 @@ const EXECUTABLE_RELATIVE_PATHS = [ join('bin', 'serve-sim-bin'), join('dist', 'simcam', 'serve-sim-camera-helper') ] -export const SERVE_SIM_RUNTIME_MAX_PRUNE_ENTRIES = 4_096 function defaultClearQuarantine(dir: string): void { if (process.platform !== 'darwin') { @@ -29,44 +28,21 @@ function defaultClearQuarantine(dir: string): void { execFileSync('/usr/bin/xattr', ['-rd', 'com.apple.quarantine', dir], { timeout: 30_000 }) } -export function pruneStaleServeSimRuntimes( - targetRootDir: string, - keepVersion: string, - requestedMaxEntries = SERVE_SIM_RUNTIME_MAX_PRUNE_ENTRIES -): void { - const maxEntries = - Number.isSafeInteger(requestedMaxEntries) && requestedMaxEntries >= 0 - ? Math.min(requestedMaxEntries, SERVE_SIM_RUNTIME_MAX_PRUNE_ENTRIES) - : SERVE_SIM_RUNTIME_MAX_PRUNE_ENTRIES - let directory: ReturnType +function pruneStaleServeSimRuntimes(targetRootDir: string, keepVersion: string): void { + let entries: string[] try { - directory = opendirSync(targetRootDir, { bufferSize: 32 }) + entries = readdirSync(targetRootDir) } catch { return } - - let scannedEntries = 0 - try { - while (scannedEntries < maxEntries) { - const entry = directory.readSync() - if (entry === null) { - return - } - scannedEntries += 1 - if (entry.name === keepVersion) { - continue - } - try { - rmSync(join(targetRootDir, entry.name), { recursive: true, force: true }) - } catch { - // Old-version cleanup is best-effort; a locked file must not block materialization. - } + for (const entryName of entries) { + if (entryName === keepVersion) { + continue } - } finally { try { - directory.closeSync() + rmSync(join(targetRootDir, entryName), { recursive: true, force: true }) } catch { - // A failed close cannot make already bounded cleanup unsafe. + // Old-version cleanup is best-effort; a locked file must not block materialization. } } } diff --git a/src/main/emulator/serve-sim-runtime-pruning-bounds.test.ts b/src/main/emulator/serve-sim-runtime-pruning-bounds.test.ts deleted file mode 100644 index d3990cb350a..00000000000 --- a/src/main/emulator/serve-sim-runtime-pruning-bounds.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -import type * as NodeFs from 'node:fs' -import { join } from 'node:path' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { opendirSyncMock, rmSyncMock } = vi.hoisted(() => ({ - opendirSyncMock: vi.fn(), - rmSyncMock: vi.fn() -})) - -vi.mock('node:fs', async (importOriginal) => ({ - ...(await importOriginal()), - opendirSync: opendirSyncMock, - rmSync: rmSyncMock -})) - -import { - pruneStaleServeSimRuntimes, - SERVE_SIM_RUNTIME_MAX_PRUNE_ENTRIES -} from './serve-sim-runtime-materializer' - -function useEntryNames(names: string[]): { - closeSync: ReturnType - readSync: ReturnType -} { - let index = 0 - const directory = { - closeSync: vi.fn(), - readSync: vi.fn(() => { - const name = names[index] - index += 1 - return name === undefined ? null : { name } - }) - } - opendirSyncMock.mockReturnValue(directory) - return directory -} - -describe('serve-sim stale runtime pruning bounds', () => { - beforeEach(() => { - opendirSyncMock.mockReset() - rmSyncMock.mockReset() - }) - - it('processes the exact requested entry limit in stream order', () => { - const directory = useEntryNames(['old-a', 'keep', 'old-b', 'old-c']) - - pruneStaleServeSimRuntimes('/runtime', 'keep', 3) - - expect(rmSyncMock).toHaveBeenCalledTimes(2) - expect(rmSyncMock).toHaveBeenNthCalledWith(1, join('/runtime', 'old-a'), { - recursive: true, - force: true - }) - expect(rmSyncMock).toHaveBeenNthCalledWith(2, join('/runtime', 'old-b'), { - recursive: true, - force: true - }) - expect(directory.readSync).toHaveBeenCalledTimes(3) - expect(directory.closeSync).toHaveBeenCalledOnce() - }) - - it('stops at the production cap without reading or retaining the next entry', () => { - let nextEntry = 0 - const directory = { - closeSync: vi.fn(), - readSync: vi.fn(() => { - nextEntry += 1 - return { name: `old-${nextEntry}` } - }) - } - opendirSyncMock.mockReturnValue(directory) - - pruneStaleServeSimRuntimes('/runtime', 'keep') - - expect(directory.readSync).toHaveBeenCalledTimes(SERVE_SIM_RUNTIME_MAX_PRUNE_ENTRIES) - expect(rmSyncMock).toHaveBeenCalledTimes(SERVE_SIM_RUNTIME_MAX_PRUNE_ENTRIES) - expect(directory.closeSync).toHaveBeenCalledOnce() - }) -}) diff --git a/src/main/filesystem-provider-bounded-text.test.ts b/src/main/filesystem-provider-bounded-text.test.ts deleted file mode 100644 index 71acf33d3ed..00000000000 --- a/src/main/filesystem-provider-bounded-text.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import type { IFilesystemProvider } from './providers/types' -import { readFilesystemProviderBoundedText } from './filesystem-provider-bounded-text' - -const limits = { maxBytes: 8, maxCodeUnits: 8 } - -function provider(args: { - size: number - content?: string - isBinary?: boolean -}): IFilesystemProvider { - return { - stat: vi.fn().mockResolvedValue({ - size: args.size, - type: 'file', - mtime: 0 - }), - readFile: vi.fn().mockResolvedValue({ - content: args.content ?? '', - isBinary: args.isBinary ?? false - }) - } as unknown as IFilesystemProvider -} - -describe('readFilesystemProviderBoundedText', () => { - it('reads a file at the exact byte boundary', async () => { - const fsProvider = provider({ size: limits.maxBytes, content: '12345678' }) - - await expect( - readFilesystemProviderBoundedText(fsProvider, '/repo/orca.yaml', limits) - ).resolves.toEqual({ kind: 'text', content: '12345678' }) - expect(fsProvider.readFile).toHaveBeenCalledOnce() - }) - - it('rejects a +1 stat before remote materialization', async () => { - const fsProvider = provider({ size: limits.maxBytes + 1, content: 'not read' }) - - await expect( - readFilesystemProviderBoundedText(fsProvider, '/repo/orca.yaml', limits) - ).resolves.toEqual({ kind: 'oversized' }) - expect(fsProvider.readFile).not.toHaveBeenCalled() - }) - - it('rechecks UTF-8 bytes after a stat/read race', async () => { - const fsProvider = provider({ size: 2, content: '🐋🐋🐋' }) - - await expect( - readFilesystemProviderBoundedText(fsProvider, '/repo/orca.yaml', limits) - ).resolves.toEqual({ kind: 'oversized' }) - }) - - it('rechecks code units after a stat/read race', async () => { - const fsProvider = provider({ size: 2, content: '123456789' }) - - await expect( - readFilesystemProviderBoundedText(fsProvider, '/repo/orca.yaml', limits) - ).resolves.toEqual({ kind: 'oversized' }) - }) - - it('preserves binary classification', async () => { - const fsProvider = provider({ size: 2, content: 'xx', isBinary: true }) - - await expect( - readFilesystemProviderBoundedText(fsProvider, '/repo/orca.yaml', limits) - ).resolves.toEqual({ kind: 'binary' }) - }) -}) diff --git a/src/main/filesystem-provider-bounded-text.ts b/src/main/filesystem-provider-bounded-text.ts deleted file mode 100644 index e255e60605b..00000000000 --- a/src/main/filesystem-provider-bounded-text.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { measureUtf8ByteLength } from '../shared/utf8-byte-limits' -import type { IFilesystemProvider } from './providers/types' - -export type FilesystemProviderBoundedText = - | { kind: 'text'; content: string } - | { kind: 'binary' } - | { kind: 'oversized' } - -export async function readFilesystemProviderBoundedText( - provider: IFilesystemProvider, - filePath: string, - limits: { maxBytes: number; maxCodeUnits: number } -): Promise { - const fileStat = await provider.stat(filePath) - if ( - !Number.isSafeInteger(fileStat.size) || - fileStat.size < 0 || - fileStat.size > limits.maxBytes - ) { - return { kind: 'oversized' } - } - - const result = await provider.readFile(filePath) - if (result.isBinary) { - return { kind: 'binary' } - } - if ( - result.content.length > limits.maxCodeUnits || - measureUtf8ByteLength(result.content, { stopAfterBytes: limits.maxBytes }).exceededLimit - ) { - return { kind: 'oversized' } - } - return { kind: 'text', content: result.content } -} diff --git a/src/main/generated-node-bounded-file-reader.test.ts b/src/main/generated-node-bounded-file-reader.test.ts deleted file mode 100644 index 462be297bb7..00000000000 --- a/src/main/generated-node-bounded-file-reader.test.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { - closeSync, - mkdtempSync, - openSync, - readSync, - rmSync, - truncateSync, - writeFileSync -} from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { runInNewContext } from 'node:vm' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { - GENERATED_NODE_MANAGED_FILE_MAX_BYTES, - getGeneratedNodeBoundedFileReaderSourceLines -} from './generated-node-bounded-file-reader' - -type GeneratedReader = ( - fs: { - closeSync: typeof closeSync - openSync: typeof openSync - readSync: typeof readSync - }, - path: string -) => string - -const temporaryDirectories: string[] = [] - -function buildGeneratedReader(): GeneratedReader { - const module = { exports: undefined as GeneratedReader | undefined } - runInNewContext( - [ - ...getGeneratedNodeBoundedFileReaderSourceLines(), - 'module.exports = readOrcaManagedFileWithinLimit;' - ].join('\n'), - { Buffer, module } - ) - if (!module.exports) { - throw new Error('generated bounded reader did not export') - } - return module.exports -} - -function createFile(content: string): string { - const directory = mkdtempSync(join(tmpdir(), 'orca-generated-node-read-')) - temporaryDirectories.push(directory) - const path = join(directory, 'managed-file') - writeFileSync(path, content) - return path -} - -afterEach(() => { - for (const directory of temporaryDirectories.splice(0)) { - rmSync(directory, { recursive: true, force: true }) - } -}) - -describe('generated Node bounded file reader', () => { - it('preserves ordinary managed-file contents', () => { - const path = createFile('stable 🐋 endpoint') - - expect(buildGeneratedReader()({ openSync, readSync, closeSync }, path)).toBe( - 'stable 🐋 endpoint' - ) - }) - - it('accepts a file at the exact byte cap', () => { - const path = createFile('x'.repeat(GENERATED_NODE_MANAGED_FILE_MAX_BYTES)) - - expect( - Buffer.byteLength(buildGeneratedReader()({ openSync, readSync, closeSync }, path), 'utf8') - ).toBe(GENERATED_NODE_MANAGED_FILE_MAX_BYTES) - }) - - it('rejects a sparse oversized file and always closes its descriptor', () => { - const path = createFile('') - truncateSync(path, GENERATED_NODE_MANAGED_FILE_MAX_BYTES + 1) - const close = vi.fn(closeSync) - - expect(() => buildGeneratedReader()({ openSync, readSync, closeSync: close }, path)).toThrow( - `Managed Orca file exceeds ${GENERATED_NODE_MANAGED_FILE_MAX_BYTES} bytes` - ) - expect(close).toHaveBeenCalledOnce() - }) -}) diff --git a/src/main/generated-node-bounded-file-reader.ts b/src/main/generated-node-bounded-file-reader.ts deleted file mode 100644 index 7f483557e5e..00000000000 --- a/src/main/generated-node-bounded-file-reader.ts +++ /dev/null @@ -1,30 +0,0 @@ -export const GENERATED_NODE_MANAGED_FILE_MAX_BYTES = 64 * 1024 - -// Why: emitted runtimes cannot import Orca's reader; fixed capacity also catches growth after stat. -export function getGeneratedNodeBoundedFileReaderSourceLines(options?: { - typed?: boolean -}): string[] { - const signature = options?.typed - ? `function readOrcaManagedFileWithinLimit(fs: any, path: string, maxBytes = ${GENERATED_NODE_MANAGED_FILE_MAX_BYTES}): string {` - : `function readOrcaManagedFileWithinLimit(fs, path, maxBytes = ${GENERATED_NODE_MANAGED_FILE_MAX_BYTES}) {` - return [ - signature, - " const descriptor = fs.openSync(path, 'r');", - ' try {', - ' const buffer = Buffer.allocUnsafe(maxBytes + 1);', - ' let offset = 0;', - ' while (offset < buffer.length) {', - ' const bytesRead = fs.readSync(descriptor, buffer, offset, buffer.length - offset, null);', - ' if (bytesRead === 0) break;', - ' offset += bytesRead;', - ' }', - ' if (offset > maxBytes) {', - " throw Object.assign(new Error('Managed Orca file exceeds ' + maxBytes + ' bytes'), { code: 'EFBIG' });", - ' }', - " return buffer.toString('utf8', 0, offset);", - ' } finally {', - ' fs.closeSync(descriptor);', - ' }', - '}' - ] -} diff --git a/src/main/ghostty/index.test.ts b/src/main/ghostty/index.test.ts index 8c2485425ed..37d059c91fd 100644 --- a/src/main/ghostty/index.test.ts +++ b/src/main/ghostty/index.test.ts @@ -1,36 +1,22 @@ import type { Store } from '../persistence' import type { GlobalSettings } from '../../shared/types' -import type * as BoundedFileReader from '../../shared/node-bounded-file-reader' -import type * as GhosttyParser from './parser' import { afterEach, describe, expect, it, vi } from 'vitest' -const { statMock, readNodeFileWithinLimitMock, parseGhosttyConfigMock } = vi.hoisted(() => ({ +const { statMock, readFileMock } = vi.hoisted(() => ({ statMock: vi.fn(), - readNodeFileWithinLimitMock: vi.fn(), - parseGhosttyConfigMock: vi.fn() + readFileMock: vi.fn() })) vi.mock('fs/promises', () => ({ - stat: statMock + stat: statMock, + readFile: readFileMock })) -vi.mock('../../shared/node-bounded-file-reader', async (importOriginal) => ({ - ...(await importOriginal()), - readNodeFileWithinLimit: readNodeFileWithinLimitMock -})) - -vi.mock('./parser', async (importOriginal) => { - const actual = await importOriginal() - parseGhosttyConfigMock.mockImplementation(actual.parseGhosttyConfig) - return { ...actual, parseGhosttyConfig: parseGhosttyConfigMock } -}) - vi.mock('os', () => ({ platform: vi.fn(() => 'darwin'), homedir: vi.fn(() => '/Users/alice') })) -import { NodeFileReadTooLargeError } from '../../shared/node-bounded-file-reader' import { previewGhosttyImport } from './index' const originalXdgConfigHome = process.env.XDG_CONFIG_HOME @@ -50,13 +36,6 @@ function createStore(settings: Record = {}): Store { } as Store } -function fileRead(content: string, size = Buffer.byteLength(content)) { - return { - buffer: Buffer.from(content), - stats: { isFile: () => true, size } - } -} - describe('previewGhosttyImport', () => { it('returns found false when no config exists', async () => { statMock.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })) @@ -73,13 +52,11 @@ describe('previewGhosttyImport', () => { } throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) }) - readNodeFileWithinLimitMock.mockResolvedValue( - fileRead(` + readFileMock.mockResolvedValue(` font-family = JetBrains Mono font-size = 14 background = #1a1a1a `) - ) const result = await previewGhosttyImport( createStore({ @@ -111,12 +88,12 @@ background = #1a1a1a } throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) }) - readNodeFileWithinLimitMock.mockImplementation(async (p: string) => { + readFileMock.mockImplementation(async (p: string) => { if (p === '/Users/alice/.config/ghostty/config.ghostty') { - return fileRead('font-size = 22\nbackground = #1a1a1a\n') + return 'font-size = 22\nbackground = #1a1a1a\n' } if (p === '/Users/alice/.config/ghostty/config') { - return fileRead('font-family = JetBrains Mono\nfont-size = 18\n') + return 'font-family = JetBrains Mono\nfont-size = 18\n' } throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) }) @@ -144,7 +121,7 @@ background = #1a1a1a } throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) }) - readNodeFileWithinLimitMock.mockResolvedValue(fileRead('font-family = Menlo\nfont-size = 12\n')) + readFileMock.mockResolvedValue('font-family = Menlo\nfont-size = 12\n') const result = await previewGhosttyImport( createStore({ @@ -165,9 +142,7 @@ background = #1a1a1a } throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) }) - readNodeFileWithinLimitMock.mockResolvedValue( - fileRead('background = #1a1a1a\nforeground = #e0e0e0\n') - ) + readFileMock.mockResolvedValue('background = #1a1a1a\nforeground = #e0e0e0\n') const result = await previewGhosttyImport( createStore({ @@ -187,9 +162,7 @@ background = #1a1a1a } throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) }) - readNodeFileWithinLimitMock.mockResolvedValue( - fileRead('background = #1a1a1a\nforeground = #e0e0e0\n') - ) + readFileMock.mockResolvedValue('background = #1a1a1a\nforeground = #e0e0e0\n') const result = await previewGhosttyImport( createStore({ @@ -219,7 +192,7 @@ background = #1a1a1a } throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) }) - readNodeFileWithinLimitMock.mockResolvedValue(fileRead('font-family = JetBrains Mono\n')) + readFileMock.mockResolvedValue('font-family = JetBrains Mono\n') // Why: Replace timer globals temporarily to detect any polling setup. const originalSetInterval = globalThis.setInterval @@ -239,44 +212,4 @@ background = #1a1a1a expect(setIntervalMock).not.toHaveBeenCalled() expect(setTimeoutMock).not.toHaveBeenCalled() }) - - it('accepts a config at the exact byte limit', async () => { - const configPath = '/Users/alice/Library/Application Support/com.mitchellh.ghostty/config' - statMock.mockImplementation(async (p: string) => { - if (p === configPath) { - return { isFile: () => true, size: 1_000_000 } - } - throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) - }) - readNodeFileWithinLimitMock.mockResolvedValue(fileRead('font-size = 14\n', 1_000_000)) - - const result = await previewGhosttyImport(createStore()) - - expect(result.found).toBe(true) - expect(result.diff).toEqual({ terminalFontSize: 14 }) - expect(readNodeFileWithinLimitMock).toHaveBeenCalledWith(configPath, 1_000_000) - }) - - it('rejects config growth beyond the limit before parsing', async () => { - const configPath = '/Users/alice/Library/Application Support/com.mitchellh.ghostty/config' - statMock.mockImplementation(async (p: string) => { - if (p === configPath) { - return { isFile: () => true, size: 128 } - } - throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) - }) - readNodeFileWithinLimitMock.mockRejectedValue( - new NodeFileReadTooLargeError(1_000_001, 1_000_000) - ) - - const result = await previewGhosttyImport(createStore()) - - expect(result).toMatchObject({ - found: false, - diff: {}, - unsupportedKeys: [], - error: 'Config file is too large to import (1000001 bytes, limit 1000000).' - }) - expect(parseGhosttyConfigMock).not.toHaveBeenCalled() - }) }) diff --git a/src/main/ghostty/index.ts b/src/main/ghostty/index.ts index da406241aca..8557193fe9f 100644 --- a/src/main/ghostty/index.ts +++ b/src/main/ghostty/index.ts @@ -1,9 +1,6 @@ +import { readFile, stat } from 'node:fs/promises' import { platform } from 'node:os' import type { GlobalSettings, GhosttyImportPreview } from '../../shared/types' -import { - NodeFileReadTooLargeError, - readNodeFileWithinLimit -} from '../../shared/node-bounded-file-reader' import type { Store } from '../persistence' import { findGhosttyConfigPaths } from './discovery' import { parseGhosttyConfig } from './parser' @@ -113,25 +110,17 @@ export async function previewGhosttyImport(store: Store): Promise MAX_CONFIG_BYTES) { return { found: false, diff: {}, unsupportedKeys: [], - error: 'Could not read config: Config path is not a file' + error: `Config file is too large to import (${info.size} bytes, limit ${MAX_CONFIG_BYTES}).` } } - content = result.buffer.toString('utf8') + content = await readFile(configPath, 'utf-8') } catch (err) { - if (err instanceof NodeFileReadTooLargeError) { - return { - found: false, - diff: {}, - unsupportedKeys: [], - error: `Config file is too large to import (${err.observedBytes} bytes, limit ${MAX_CONFIG_BYTES}).` - } - } const message = err instanceof Error ? err.message : 'Could not read config file' return { found: false, diff --git a/src/main/ghostty/theme-import.test.ts b/src/main/ghostty/theme-import.test.ts index 3698c88f369..c2d2ec61ab9 100644 --- a/src/main/ghostty/theme-import.test.ts +++ b/src/main/ghostty/theme-import.test.ts @@ -1,20 +1,15 @@ import type { Store } from '../persistence' import type { GlobalSettings } from '../../shared/types' -import type * as BoundedFileReader from '../../shared/node-bounded-file-reader' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { statMock, readNodeFileWithinLimitMock } = vi.hoisted(() => ({ +const { statMock, readFileMock } = vi.hoisted(() => ({ statMock: vi.fn(), - readNodeFileWithinLimitMock: vi.fn() + readFileMock: vi.fn() })) vi.mock('fs/promises', () => ({ - stat: statMock -})) - -vi.mock('../../shared/node-bounded-file-reader', async (importOriginal) => ({ - ...(await importOriginal()), - readNodeFileWithinLimit: readNodeFileWithinLimitMock + stat: statMock, + readFile: readFileMock })) vi.mock('os', () => ({ @@ -52,13 +47,6 @@ function createStore(settings: Record = {}): Store { } as Store } -function fileRead(content: string) { - return { - buffer: Buffer.from(content), - stats: { isFile: () => true, size: Buffer.byteLength(content) } - } -} - describe('previewGhosttyImport theme references', () => { it('resolves a theme reference into color overrides', async () => { const configPath = '/Users/alice/Library/Application Support/com.mitchellh.ghostty/config' @@ -69,11 +57,11 @@ describe('previewGhosttyImport theme references', () => { } throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) }) - readNodeFileWithinLimitMock.mockImplementation(async (p: string) => { + readFileMock.mockImplementation(async (p: string) => { if (p === themePath) { - return fileRead('palette = 1=#d54e53\nbackground = #000000\nforeground = #eaeaea\n') + return 'palette = 1=#d54e53\nbackground = #000000\nforeground = #eaeaea\n' } - return fileRead('theme = Tomorrow Night Bright\nfont-size = 14\n') + return 'theme = Tomorrow Night Bright\nfont-size = 14\n' }) const result = await previewGhosttyImport(createStore()) @@ -98,11 +86,11 @@ describe('previewGhosttyImport theme references', () => { } throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) }) - readNodeFileWithinLimitMock.mockImplementation(async (p: string) => { + readFileMock.mockImplementation(async (p: string) => { if (p === themePath) { - return fileRead('palette = 1=#d54e53\npalette = 2=#b9ca4a\nbackground = #000000\n') + return 'palette = 1=#d54e53\npalette = 2=#b9ca4a\nbackground = #000000\n' } - return fileRead('theme = night\nbackground = #101010\npalette = 1=#ff0000\n') + return 'theme = night\nbackground = #101010\npalette = 1=#ff0000\n' }) const result = await previewGhosttyImport(createStore()) @@ -127,11 +115,11 @@ describe('previewGhosttyImport theme references', () => { } throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) }) - readNodeFileWithinLimitMock.mockImplementation(async (p: string) => { + readFileMock.mockImplementation(async (p: string) => { if (p === themePath) { - return fileRead('background = #202020\nforeground = #f0f0f0\n') + return 'background = #202020\nforeground = #f0f0f0\n' } - return fileRead(`theme = ${themePath}\n`) + return `theme = ${themePath}\n` }) const result = await previewGhosttyImport(createStore()) @@ -157,7 +145,7 @@ describe('previewGhosttyImport theme references', () => { } throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) }) - readNodeFileWithinLimitMock.mockResolvedValue(fileRead('theme = Missing Theme\n')) + readFileMock.mockResolvedValue('theme = Missing Theme\n') const result = await previewGhosttyImport(createStore()) @@ -173,9 +161,7 @@ describe('previewGhosttyImport theme references', () => { } throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) }) - readNodeFileWithinLimitMock.mockResolvedValue( - fileRead('theme = light:Tomorrow,dark:Tomorrow Night\n') - ) + readFileMock.mockResolvedValue('theme = light:Tomorrow,dark:Tomorrow Night\n') const result = await previewGhosttyImport(createStore()) diff --git a/src/main/ghostty/theme-resolution.test.ts b/src/main/ghostty/theme-resolution.test.ts index f31e8bbcd05..607e69a39e8 100644 --- a/src/main/ghostty/theme-resolution.test.ts +++ b/src/main/ghostty/theme-resolution.test.ts @@ -1,34 +1,20 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type * as BoundedFileReader from '../../shared/node-bounded-file-reader' -import type * as GhosttyParser from './parser' -const { statMock, readNodeFileWithinLimitMock, parseGhosttyConfigMock } = vi.hoisted(() => ({ +const { statMock, readFileMock } = vi.hoisted(() => ({ statMock: vi.fn(), - readNodeFileWithinLimitMock: vi.fn(), - parseGhosttyConfigMock: vi.fn() + readFileMock: vi.fn() })) vi.mock('fs/promises', () => ({ - stat: statMock + stat: statMock, + readFile: readFileMock })) -vi.mock('../../shared/node-bounded-file-reader', async (importOriginal) => ({ - ...(await importOriginal()), - readNodeFileWithinLimit: readNodeFileWithinLimitMock -})) - -vi.mock('./parser', async (importOriginal) => { - const actual = await importOriginal() - parseGhosttyConfigMock.mockImplementation(actual.parseGhosttyConfig) - return { ...actual, parseGhosttyConfig: parseGhosttyConfigMock } -}) - vi.mock('os', () => ({ platform: vi.fn(() => 'darwin'), homedir: vi.fn(() => '/Users/alice') })) -import { NodeFileReadTooLargeError } from '../../shared/node-bounded-file-reader' import { getGhosttyThemeSearchDirs, resolveGhosttyThemeColors } from './theme-resolution' const originalXdgConfigHome = process.env.XDG_CONFIG_HOME @@ -63,13 +49,6 @@ selection-background = #424242 selection-foreground = #eaeaea ` -function fileRead(content: string, size = Buffer.byteLength(content)) { - return { - buffer: Buffer.from(content), - stats: { isFile: () => true, size } - } -} - describe('getGhosttyThemeSearchDirs', () => { it('probes XDG, then the Ghostty.app resources dir', () => { delete process.env.XDG_CONFIG_HOME @@ -101,7 +80,7 @@ describe('resolveGhosttyThemeColors', () => { } throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) }) - readNodeFileWithinLimitMock.mockResolvedValue(fileRead(THEME_FILE)) + readFileMock.mockResolvedValue(THEME_FILE) const result = await resolveGhosttyThemeColors('Tomorrow Night Bright') expect(result).toEqual({ @@ -121,7 +100,7 @@ describe('resolveGhosttyThemeColors', () => { } throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) }) - readNodeFileWithinLimitMock.mockResolvedValue(fileRead('background = #1d1f21')) + readFileMock.mockResolvedValue('background = #1d1f21') const result = await resolveGhosttyThemeColors('Tomorrow') expect(result).toEqual({ background: '#1d1f21' }) @@ -134,7 +113,7 @@ describe('resolveGhosttyThemeColors', () => { } throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) }) - readNodeFileWithinLimitMock.mockResolvedValue(fileRead('background = #1d1f21')) + readFileMock.mockResolvedValue('background = #1d1f21') const result = await resolveGhosttyThemeColors('/Users/alice/themes/work') expect(result).toEqual({ background: '#1d1f21' }) @@ -148,7 +127,7 @@ describe('resolveGhosttyThemeColors', () => { } throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) }) - readNodeFileWithinLimitMock.mockResolvedValue(fileRead('background = #101010\nfont-size = 20')) + readFileMock.mockResolvedValue('background = #101010\nfont-size = 20') const result = await resolveGhosttyThemeColors('custom') expect(result).toEqual({ background: '#101010' }) @@ -170,7 +149,7 @@ describe('resolveGhosttyThemeColors', () => { it('rejects oversized theme files', async () => { statMock.mockImplementation(async () => ({ isFile: () => true, size: 10_000_000 })) expect(await resolveGhosttyThemeColors('huge')).toBeNull() - expect(readNodeFileWithinLimitMock).not.toHaveBeenCalled() + expect(readFileMock).not.toHaveBeenCalled() expect(statMock).toHaveBeenCalledTimes(1) }) @@ -186,35 +165,7 @@ describe('resolveGhosttyThemeColors', () => { }) expect(await resolveGhosttyThemeColors('night')).toBeNull() - expect(readNodeFileWithinLimitMock).not.toHaveBeenCalled() + expect(readFileMock).not.toHaveBeenCalled() expect(statMock).toHaveBeenCalledTimes(1) }) - - it('accepts a theme at the exact byte limit', async () => { - const themePath = '/Users/alice/.config/ghostty/themes/boundary' - statMock.mockImplementation(async (p: string) => { - if (p === themePath) { - return { isFile: () => true, size: 262_144 } - } - throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) - }) - readNodeFileWithinLimitMock.mockResolvedValue(fileRead('background = #1d1f21', 262_144)) - - expect(await resolveGhosttyThemeColors('boundary')).toEqual({ background: '#1d1f21' }) - expect(readNodeFileWithinLimitMock).toHaveBeenCalledWith(themePath, 262_144) - }) - - it('rejects theme growth beyond the limit before parsing', async () => { - const themePath = '/Users/alice/.config/ghostty/themes/growing' - statMock.mockImplementation(async (p: string) => { - if (p === themePath) { - return { isFile: () => true, size: 128 } - } - throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) - }) - readNodeFileWithinLimitMock.mockRejectedValue(new NodeFileReadTooLargeError(262_145, 262_144)) - - expect(await resolveGhosttyThemeColors('growing')).toBeNull() - expect(parseGhosttyConfigMock).not.toHaveBeenCalled() - }) }) diff --git a/src/main/ghostty/theme-resolution.ts b/src/main/ghostty/theme-resolution.ts index 8354a397a94..eedb2c1592d 100644 --- a/src/main/ghostty/theme-resolution.ts +++ b/src/main/ghostty/theme-resolution.ts @@ -1,7 +1,6 @@ import { homedir, platform } from 'node:os' import path from 'node:path' -import { stat } from 'node:fs/promises' -import { readNodeFileWithinLimit } from '../../shared/node-bounded-file-reader' +import { readFile, stat } from 'node:fs/promises' import { parseGhosttyConfig } from './parser' // Why: theme files ship a few dozen short lines; anything larger is not a @@ -87,11 +86,7 @@ async function readThemeColors(themePath: string): Promise { if (!info.isFile() || info.size > MAX_THEME_BYTES) { return { status: 'invalid' } } - const result = await readNodeFileWithinLimit(themePath, MAX_THEME_BYTES) - if (!result.stats.isFile()) { - return { status: 'invalid' } - } - content = result.buffer.toString('utf8') + content = await readFile(themePath, 'utf-8') } catch (err) { return { status: isMissingThemeError(err) ? 'missing' : 'invalid' } } diff --git a/src/main/git/gh-rate-limit-breaker.ts b/src/main/git/gh-rate-limit-breaker.ts index 36c15077490..8bc4276490a 100644 --- a/src/main/git/gh-rate-limit-breaker.ts +++ b/src/main/git/gh-rate-limit-breaker.ts @@ -12,8 +12,6 @@ * the github rate-limit prober can use it without an import cycle. */ -import { cacheIdentityDigest } from '../cache-identity-digest' - export type GhRateLimitBucket = 'core' | 'search' | 'graphql' // Why: a primary 403 does not carry the reset time. The search window resets @@ -64,7 +62,7 @@ const blockedUntilMsByScopeAndBucket = new Map() let resetProbe: ((bucket: GhRateLimitBucket, scope: string) => void) | null = null function breakerKey(bucket: GhRateLimitBucket, scope = DEFAULT_SCOPE): string { - return cacheIdentityDigest([scope, bucket]) + return `${scope}\0${bucket}` } // gh api flags that take a separate value, so the endpoint arg can be found. diff --git a/src/main/git/git-capability-state.test.ts b/src/main/git/git-capability-state.test.ts index c26d68350f2..0d499aea163 100644 --- a/src/main/git/git-capability-state.test.ts +++ b/src/main/git/git-capability-state.test.ts @@ -2,9 +2,7 @@ import { beforeEach, describe, expect, it } from 'vitest' import { clearGitCapabilityStateForTests, getLocalGitCapabilityCache, - getSshGitCapabilityCache, - LOCAL_GIT_CAPABILITY_HOST_KEY_MAX_BYTES, - LOCAL_GIT_CAPABILITY_HOST_MAX_ENTRIES + getSshGitCapabilityCache } from './git-capability-state' describe('Git capability execution-host state', () => { @@ -36,27 +34,4 @@ describe('Git capability execution-host state', () => { getSshGitCapabilityCache(replacementProvider) ) }) - - it('evicts the oldest local execution host after the host ceiling', () => { - const oldest = getLocalGitCapabilityCache({ wslDistro: 'Distro-0' }) - let newest = oldest - for (let index = 1; index <= LOCAL_GIT_CAPABILITY_HOST_MAX_ENTRIES; index++) { - newest = getLocalGitCapabilityCache({ wslDistro: `Distro-${index}` }) - } - - expect(getLocalGitCapabilityCache({ wslDistro: 'Distro-0' })).not.toBe(oldest) - expect( - getLocalGitCapabilityCache({ - wslDistro: `Distro-${LOCAL_GIT_CAPABILITY_HOST_MAX_ENTRIES}` - }) - ).toBe(newest) - }) - - it('does not retain oversized execution-host keys', () => { - const oversizedDistro = 'x'.repeat(LOCAL_GIT_CAPABILITY_HOST_KEY_MAX_BYTES + 1) - - expect(getLocalGitCapabilityCache({ wslDistro: oversizedDistro })).not.toBe( - getLocalGitCapabilityCache({ wslDistro: oversizedDistro }) - ) - }) }) diff --git a/src/main/git/git-capability-state.ts b/src/main/git/git-capability-state.ts index 9898cfffc9b..aec7a369a26 100644 --- a/src/main/git/git-capability-state.ts +++ b/src/main/git/git-capability-state.ts @@ -1,5 +1,4 @@ import { GitCapabilityCache } from '../../shared/git-capability-cache' -import { measureUtf8ByteLength } from '../../shared/utf8-byte-limits' import { parseWslUncPath } from '../../shared/wsl-paths' type LocalGitCapabilityTarget = { @@ -7,8 +6,6 @@ type LocalGitCapabilityTarget = { wslDistro?: string } -export const LOCAL_GIT_CAPABILITY_HOST_MAX_ENTRIES = 64 -export const LOCAL_GIT_CAPABILITY_HOST_KEY_MAX_BYTES = 4 * 1024 const localCapabilitiesByExecutionHost = new Map() // Why: reconnecting creates a new provider, while concurrent IPC/runtime users // of one SSH connection must share the same remote Git capability results. @@ -24,24 +21,10 @@ export function getLocalGitCapabilityCache( target: LocalGitCapabilityTarget = {} ): GitCapabilityCache { const executionHost = getLocalGitExecutionHostKey(target) - if ( - measureUtf8ByteLength(executionHost, { - stopAfterBytes: LOCAL_GIT_CAPABILITY_HOST_KEY_MAX_BYTES - }).exceededLimit - ) { - return new GitCapabilityCache() - } let cache = localCapabilitiesByExecutionHost.get(executionHost) if (!cache) { cache = new GitCapabilityCache() localCapabilitiesByExecutionHost.set(executionHost, cache) - while (localCapabilitiesByExecutionHost.size > LOCAL_GIT_CAPABILITY_HOST_MAX_ENTRIES) { - const oldestHost = localCapabilitiesByExecutionHost.keys().next().value - if (oldestHost === undefined) { - break - } - localCapabilitiesByExecutionHost.delete(oldestHost) - } } return cache } diff --git a/src/main/git/huge-folder-ignore.test.ts b/src/main/git/huge-folder-ignore.test.ts index 74f93e71474..60b107ce34c 100644 --- a/src/main/git/huge-folder-ignore.test.ts +++ b/src/main/git/huge-folder-ignore.test.ts @@ -84,35 +84,6 @@ describe('appendFolderToGitignore', () => { expect(wrote).toBe(false) }) - it('streams a large single-line file while preserving append semantics', async () => { - const gitignorePath = path.join(dir, '.gitignore') - await fs.writeFile(gitignorePath, 'x') - await fs.truncate(gitignorePath, 8 * 1024 * 1024) - - expect(await appendFolderToGitignore(dir, 'dist')).toBe(true) - - const handle = await fs.open(gitignorePath, 'r') - try { - const info = await handle.stat() - const tail = Buffer.alloc(7) - await handle.read(tail, 0, tail.length, info.size - tail.length) - expect(tail.toString('utf8')).toBe('\ndist/\n') - } finally { - await handle.close() - } - }) - - it('preserves trimmed-line matching across streamed chunks', async () => { - const gitignorePath = path.join(dir, '.gitignore') - await fs.writeFile(gitignorePath, 'x') - await fs.truncate(gitignorePath, 8 * 1024 * 1024) - await fs.appendFile(gitignorePath, '\n\u00a0node_modules/\u00a0\n') - const sizeBefore = (await fs.stat(gitignorePath)).size - - expect(await appendFolderToGitignore(dir, 'node_modules')).toBe(false) - expect((await fs.stat(gitignorePath)).size).toBe(sizeBefore) - }) - it('rejects folder names outside the known allowlist (injection guard)', async () => { await expect(appendFolderToGitignore(dir, 'node_modules\n/etc/passwd')).rejects.toThrow( /Refusing to add/ diff --git a/src/main/git/huge-folder-ignore.ts b/src/main/git/huge-folder-ignore.ts index 507def3fc1f..a115a38f6d8 100644 --- a/src/main/git/huge-folder-ignore.ts +++ b/src/main/git/huge-folder-ignore.ts @@ -1,7 +1,6 @@ -import { createReadStream, existsSync } from 'node:fs' -import { appendFile, stat } from 'node:fs/promises' +import { existsSync } from 'node:fs' +import { appendFile, readFile, stat } from 'node:fs/promises' import * as path from 'node:path' -import { StringDecoder } from 'node:string_decoder' import { checkIgnoredPaths } from './check-ignored-paths' import type { GitRuntimeOptions } from './git-runtime-options' @@ -10,78 +9,6 @@ import type { GitRuntimeOptions } from './git-runtime-options' // to ignore these by name (matching the well-known offenders) the way a mature // SCM does, rather than asking the user to hand-edit .gitignore. const KNOWN_HUGE_FOLDER_NAMES = ['node_modules', '.next', 'dist', 'build', 'target', 'vendor'] -const GITIGNORE_SCAN_CHUNK_BYTES = 64 * 1024 - -type GitignoreScanResult = { - alreadyListed: boolean - hasContent: boolean - endsWithNewline: boolean -} - -async function scanGitignoreForFolder( - gitignorePath: string, - folderName: string -): Promise { - const targets = [folderName, `${folderName}/`] - const decoder = new StringDecoder('utf8') - const input = createReadStream(gitignorePath, { highWaterMark: GITIGNORE_SCAN_CHUNK_BYTES }) - let phase: 'leading' | 'candidate' | 'trailing' | 'rejected' = 'leading' - let candidate = '' - let alreadyListed = false - let hasContent = false - let endsWithNewline = false - - const finishLine = (): void => { - if (phase === 'trailing' || (phase === 'candidate' && targets.includes(candidate))) { - alreadyListed = true - } - phase = 'leading' - candidate = '' - } - const acceptText = (text: string): void => { - for (const character of text) { - if (character === '\n') { - finishLine() - continue - } - const whitespace = character.trim().length === 0 - if (phase === 'leading') { - if (whitespace) { - continue - } - candidate = character - phase = targets.some((target) => target.startsWith(candidate)) ? 'candidate' : 'rejected' - continue - } - if (phase === 'candidate') { - if (whitespace) { - phase = targets.includes(candidate) ? 'trailing' : 'rejected' - continue - } - candidate += character - if (!targets.some((target) => target.startsWith(candidate))) { - phase = 'rejected' - } - continue - } - if (phase === 'trailing' && !whitespace) { - phase = 'rejected' - } - } - } - - for await (const chunk of input) { - const buffer = chunk as Buffer - hasContent ||= buffer.length > 0 - if (buffer.length > 0) { - endsWithNewline = buffer.at(-1) === 0x0a - } - acceptText(decoder.write(buffer)) - } - acceptText(decoder.end()) - finishLine() - return { alreadyListed, hasContent, endsWithNewline } -} /** * Return the relative names of known-huge folders that exist in the worktree and @@ -131,21 +58,21 @@ export async function appendFolderToGitignore( } const gitignorePath = path.join(worktreePath, '.gitignore') const line = `${safeFolderName}/` - let scan: GitignoreScanResult = { - alreadyListed: false, - hasContent: false, - endsWithNewline: false - } + let existingContent = '' try { - scan = await scanGitignoreForFolder(gitignorePath, safeFolderName) + existingContent = await readFile(gitignorePath, 'utf-8') } catch { // .gitignore doesn't exist yet — we'll create it below } - if (scan.alreadyListed) { + const alreadyListed = existingContent + .split(/\r?\n/) + .map((l) => l.trim()) + .some((l) => l === safeFolderName || l === line) + if (alreadyListed) { return false } // Why: keep a clean trailing newline whether or not the file ended with one. - const needsLeadingNewline = scan.hasContent && !scan.endsWithNewline + const needsLeadingNewline = existingContent.length > 0 && !existingContent.endsWith('\n') await appendFile(gitignorePath, `${needsLeadingNewline ? '\n' : ''}${line}\n`, 'utf-8') return true } diff --git a/src/main/git/repo-metadata-bounds.test.ts b/src/main/git/repo-metadata-bounds.test.ts deleted file mode 100644 index f9374a2d618..00000000000 --- a/src/main/git/repo-metadata-bounds.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { mkdirSync, mkdtempSync, truncateSync, writeFileSync } from 'node:fs' -import { rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { isGitRepo, MAX_GIT_MARKER_FILE_BYTES } from './repo' - -describe.sequential('Git marker fallback metadata bounds', () => { - let root: string - let originalPath: string | undefined - - beforeEach(() => { - root = mkdtempSync(join(tmpdir(), 'orca-git-marker-bounds-')) - originalPath = process.env.PATH - process.env.PATH = '' - }) - - afterEach(async () => { - if (originalPath === undefined) { - delete process.env.PATH - } else { - process.env.PATH = originalPath - } - await rm(root, { recursive: true, force: true }) - }) - - function writeOversizedSparseFile(filePath: string): void { - writeFileSync(filePath, 'x') - truncateSync(filePath, MAX_GIT_MARKER_FILE_BYTES + 1) - } - - it('rejects an oversized .git pointer without loading it', () => { - const checkout = join(root, 'checkout') - mkdirSync(checkout) - writeOversizedSparseFile(join(checkout, '.git')) - - expect(isGitRepo(checkout)).toBe(false) - }) - - it('rejects an oversized linked-worktree commondir pointer', () => { - const checkout = join(root, 'checkout') - const adminDir = join(root, 'admin') - mkdirSync(checkout) - mkdirSync(adminDir) - writeFileSync(join(checkout, '.git'), `gitdir: ${adminDir}\n`) - writeFileSync(join(adminDir, 'HEAD'), 'ref: refs/heads/main\n') - writeOversizedSparseFile(join(adminDir, 'commondir')) - - expect(isGitRepo(checkout)).toBe(false) - }) - - it('checks a bare marker without retaining an oversized config', () => { - const bareRepo = join(root, 'bare.git') - mkdirSync(join(bareRepo, 'objects'), { recursive: true }) - mkdirSync(join(bareRepo, 'refs')) - writeFileSync(join(bareRepo, 'HEAD'), 'ref: refs/heads/main\n') - writeOversizedSparseFile(join(bareRepo, 'config')) - - expect(isGitRepo(bareRepo)).toBe(true) - }) -}) diff --git a/src/main/git/repo.ts b/src/main/git/repo.ts index 52c8a65aedd..df5b2865132 100644 --- a/src/main/git/repo.ts +++ b/src/main/git/repo.ts @@ -1,5 +1,5 @@ /* oxlint-disable max-lines */ -import { existsSync, realpathSync, statSync } from 'node:fs' +import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs' import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path' import { gitExecFileSync, gitExecFileAsync } from './runner' import type { BaseRefSearchResult } from '../../shared/types' @@ -10,7 +10,6 @@ import { parseWslUncPath } from '../../shared/wsl-paths' import { toWindowsWslPath } from '../wsl' import { buildHostedRemoteCommitUrl, buildHostedRemoteFileUrl } from './hosted-remote-url' import { getLocalGitCapabilityCache } from './git-capability-state' -import { readNodeFileSyncWithinLimit } from '../../shared/node-bounded-file-reader' type LocalGitExecOptions = { wslDistro?: string @@ -22,7 +21,6 @@ type LocalDefaultBaseRefGitOptions = { } const DEFAULT_BASE_REF_PROBE_TIMEOUT_MS = 15_000 -export const MAX_GIT_MARKER_FILE_BYTES = 64 * 1024 type GitRepoProbeResult = 'repo' | 'not-repo' | 'indeterminate' type GitMarkerScanResult = { status: 'valid'; rootPath: string } | { status: 'absent' | 'invalid' } @@ -278,10 +276,7 @@ function scanWorktreeMarkerSync(worktreePath: string): GitMarkerScanResult { if (marker.isFile()) { let gitDir: string | null try { - gitDir = parseGitdirFile( - worktreePath, - readNodeFileSyncWithinLimit(dotGit, MAX_GIT_MARKER_FILE_BYTES).buffer.toString('utf8') - ) + gitDir = parseGitdirFile(worktreePath, readFileSync(dotGit, 'utf8')) } catch { return { status: 'invalid' } } @@ -336,10 +331,7 @@ function hasValidLinkedWorktreeGitDirectorySync(gitDir: string): boolean { } const commonDir = resolveGitMetadataPath( gitDir, - readNodeFileSyncWithinLimit( - join(gitDir, 'commondir'), - MAX_GIT_MARKER_FILE_BYTES - ).buffer.toString('utf8') + readFileSync(join(gitDir, 'commondir'), 'utf8') ) return commonDir !== null && hasValidCommonGitDirectorySync(commonDir) } catch { @@ -353,10 +345,7 @@ function hasValidBareRepoMarkerSync(path: string): boolean { function gitConfigDeclaresNonBare(gitDir: string): boolean { try { - const config = readNodeFileSyncWithinLimit( - join(gitDir, 'config'), - MAX_GIT_MARKER_FILE_BYTES - ).buffer.toString('utf8') + const config = readFileSync(join(gitDir, 'config'), 'utf8') let inCoreSection = false for (const line of config.split(/\r?\n/)) { const section = line.match(/^\s*\[([^\]]+)\]/) diff --git a/src/main/git/runner-command-exec.test.ts b/src/main/git/runner-command-exec.test.ts index 18a58770740..97f8eb8254e 100644 --- a/src/main/git/runner-command-exec.test.ts +++ b/src/main/git/runner-command-exec.test.ts @@ -15,7 +15,6 @@ vi.mock('node:child_process', () => ({ import { commandExecFileAsync, - DEFAULT_GIT_MAX_BUFFER, ghExecFileAsync, gitExecFileAsync, gitStreamStdout, @@ -156,35 +155,6 @@ describe('commandExecFileAsync Windows command shims', () => { }) }) - it('applies the default output cap to Windows .cmd shim executions', async () => { - await withPlatform('win32', async () => { - const command = createMockChildProcess(1234) - const taskkill = createMockTaskkillProcess() - spawnMock.mockImplementation((cmd: string) => (cmd === 'taskkill' ? taskkill : command)) - const toString = vi.fn(() => 'should not decode') - const oversizedChunk = { - byteLength: DEFAULT_GIT_MAX_BUFFER + 1, - toString - } as unknown as Buffer - - const promise = commandExecFileAsync('C:\\tools\\pnpm.cmd', ['store', 'prune'], { - cwd: 'C:\\repo' - }) - const rejection = expect(promise).rejects.toThrow( - 'C:\\tools\\pnpm.cmd stdout exceeded maxBuffer.' - ) - command.stdout.emit('data', oversizedChunk) - - await rejection - expect(toString).not.toHaveBeenCalled() - expect(spawnMock).toHaveBeenCalledWith( - 'taskkill', - ['/pid', '1234', '/t', '/f'], - expect.objectContaining({ stdio: 'ignore', windowsHide: true }) - ) - }) - }) - it('removes listeners after successful Windows .cmd shim executions', async () => { await withPlatform('win32', async () => { const command = createMockChildProcess(1234) @@ -204,26 +174,6 @@ describe('commandExecFileAsync Windows command shims', () => { expect(command.listenerCount('close')).toBe(0) }) }) - - it('captures 100,000 one-byte fragments from a Windows command shim', async () => { - await withPlatform('win32', async () => { - const command = createMockChildProcess(1234) - spawnMock.mockReturnValue(command) - const promise = commandExecFileAsync('C:\\tools\\pnpm.cmd', ['--version'], { - cwd: 'C:\\repo' - }) - const fragment = Buffer.from('x') - - for (let index = 0; index < 100_000; index += 1) { - command.stdout.emit('data', fragment) - } - command.emit('close', 0) - - const result = await promise - expect(result.stdout).toHaveLength(100_000) - expect(result.stdout.slice(-4)).toBe('xxxx') - }) - }) }) describe('runner execFile timeout handling', () => { @@ -695,21 +645,6 @@ describe('gitStreamStdout', () => { await rejection }) - it('captures streamed stderr delivered as 100,000 one-byte fragments', async () => { - const child = createMockChildProcess(1234) - spawnMock.mockReturnValue(child) - const promise = gitStreamStdout(['status'], { cwd: '/repo', onStdout: () => {} }) - const fragment = Buffer.from(' ') - - for (let index = 0; index < 100_000; index += 1) { - child.stderr.emit('data', fragment) - } - child.stderr.emit('data', Buffer.from('fatal: fragmented')) - child.emit('close', 128) - - await expect(promise).rejects.toThrow(/fatal: fragmented$/) - }) - it('rejects (not crashes) when the onStdout callback throws', async () => { const child = createMockChildProcess(1234) spawnMock.mockReturnValue(child) diff --git a/src/main/git/runner.ts b/src/main/git/runner.ts index 2f5e3952110..ae4e93174e1 100644 --- a/src/main/git/runner.ts +++ b/src/main/git/runner.ts @@ -33,7 +33,6 @@ import { appendGitConfigEnv, gitCredentialPromptGuardEnv } from '../../shared/git-credential-prompt-env' -import { GrowingByteBuffer } from '../../shared/growing-byte-buffer' import { getSpawnArgsForWindows, isWindowsBatchScript, resolveWindowsCommand } from '../win32-utils' import { buildWslLoginShellCommand, @@ -498,7 +497,6 @@ async function spawnCommandCapture( args: string[], options: CommandExecOptions ): Promise<{ stdout: string; stderr: string }> { - const maxBuffer = options.maxBuffer ?? DEFAULT_GIT_MAX_BUFFER const { spawnCmd, spawnArgs } = getSpawnArgsForWindows(command, args) return new Promise((resolve, reject) => { if (options.signal?.aborted) { @@ -506,8 +504,10 @@ async function spawnCommandCapture( return } let settled = false - const stdout = new GrowingByteBuffer() - const stderr = new GrowingByteBuffer() + let stdout = '' + let stderr = '' + let stdoutBytes = 0 + let stderrBytes = 0 const spawnStartedAt = performance.now() const child = spawn(spawnCmd, spawnArgs, { cwd: options.cwd, @@ -538,15 +538,11 @@ async function spawnCommandCapture( } settled = true cleanupListeners() - const stdoutText = stdout.toString(options.encoding ?? 'utf-8') - const stderrText = stderr.toString(options.encoding ?? 'utf-8') - stdout.clear() - stderr.clear() if (error) { - reject(Object.assign(error, { stdout: stdoutText, stderr: stderrText })) + reject(Object.assign(error, { stdout, stderr })) return } - resolve({ stdout: stdoutText, stderr: stderrText }) + resolve({ stdout, stderr }) } timer = options.timeout ? setTimeout(() => { @@ -556,20 +552,22 @@ async function spawnCommandCapture( : null options.signal?.addEventListener('abort', onAbort, { once: true }) function onStdoutData(chunk: Buffer): void { - if (stdout.byteLength + chunk.byteLength > maxBuffer) { + stdoutBytes += chunk.byteLength + if (options.maxBuffer && stdoutBytes > options.maxBuffer) { void killSpawnedCommandTree(child) finish(new Error(`${command} stdout exceeded maxBuffer.`)) return } - stdout.append(chunk) + stdout += chunk.toString(options.encoding ?? 'utf-8') } function onStderrData(chunk: Buffer): void { - if (stderr.byteLength + chunk.byteLength > maxBuffer) { + stderrBytes += chunk.byteLength + if (options.maxBuffer && stderrBytes > options.maxBuffer) { void killSpawnedCommandTree(child) finish(new Error(`${command} stderr exceeded maxBuffer.`)) return } - stderr.append(chunk) + stderr += chunk.toString(options.encoding ?? 'utf-8') } function onError(error: Error): void { finish(error) @@ -974,9 +972,11 @@ export async function gitStreamStdout( let settled = false let stoppedEarly = false let stdoutBytes = 0 - const stderr = new GrowingByteBuffer() + let stderr = '' + let stderrBytes = 0 // Why: decode statefully so a multibyte UTF-8 char split across chunks isn't corrupted into replacement chars. const stdoutDecoder = new StringDecoder('utf8') + const stderrDecoder = new StringDecoder('utf8') const cleanup = (): void => { child.stdout?.off('data', onStdoutData) @@ -986,6 +986,7 @@ export async function gitStreamStdout( options.signal?.removeEventListener('abort', onAbort) // Flush any bytes the decoders were holding for an incomplete sequence. stdoutDecoder.end() + stderrDecoder.end() } const finish = (error: Error | null): void => { if (settled) { @@ -993,10 +994,8 @@ export async function gitStreamStdout( } settled = true cleanup() - const stderrText = stderr.toString() - stderr.clear() if (error) { - reject(Object.assign(error, { stderr: stderrText })) + reject(Object.assign(error, { stderr })) return } resolve({ stoppedEarly }) @@ -1030,12 +1029,13 @@ export async function gitStreamStdout( } } function onStderrData(chunk: Buffer): void { - if (stderr.byteLength + chunk.byteLength > maxBuffer) { + stderrBytes += chunk.byteLength + if (stderrBytes > maxBuffer) { void killSpawnedCommandTree(child) finish(new Error('git stderr exceeded maxBuffer.')) return } - stderr.append(chunk) + stderr += stderrDecoder.write(chunk) } function onError(error: Error): void { finish(error) @@ -1045,7 +1045,7 @@ export async function gitStreamStdout( finish(null) return } - finish(new Error(`git exited with ${code}: ${stderr.toString()}`)) + finish(new Error(`git exited with ${code}: ${stderr}`)) } function onAbort(): void { if (!child.pid) { diff --git a/src/main/git/status-git-pointer-bounds.test.ts b/src/main/git/status-git-pointer-bounds.test.ts deleted file mode 100644 index 6f62d91527b..00000000000 --- a/src/main/git/status-git-pointer-bounds.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { afterEach, describe, expect, it } from 'vitest' -import { mkdir, mkdtemp, rm, truncate, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { resolveGitDir } from './status' - -describe('resolveGitDir metadata bounds', () => { - const roots: string[] = [] - - afterEach(async () => { - await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) - }) - - async function makeWorktree(): Promise { - const root = await mkdtemp(join(tmpdir(), 'orca-resolve-git-dir-')) - roots.push(root) - const worktreePath = join(root, 'checkout') - await mkdir(worktreePath) - return worktreePath - } - - it('preserves a normal linked-worktree pointer', async () => { - const worktreePath = await makeWorktree() - await writeFile(join(worktreePath, '.git'), 'gitdir: ../common/.git/worktrees/checkout\n') - - await expect(resolveGitDir(worktreePath)).resolves.toBe( - join(worktreePath, '..', 'common', '.git', 'worktrees', 'checkout') - ) - }) - - it('falls back to the .git path for an oversized sparse pointer', async () => { - const worktreePath = await makeWorktree() - const dotGitPath = join(worktreePath, '.git') - await writeFile(dotGitPath, 'x') - await truncate(dotGitPath, 64 * 1024 + 1) - - await expect(resolveGitDir(worktreePath)).resolves.toBe(dotGitPath) - }) -}) diff --git a/src/main/git/status-submodule-path-payload-bounds.test.ts b/src/main/git/status-submodule-path-payload-bounds.test.ts deleted file mode 100644 index c0e1cf9b042..00000000000 --- a/src/main/git/status-submodule-path-payload-bounds.test.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { gitExecFileAsyncMock } = vi.hoisted(() => ({ gitExecFileAsyncMock: vi.fn() })) - -vi.mock('./runner', () => ({ - gitExecFileAsync: gitExecFileAsyncMock, - gitOptionalLocksDisabledEnv: (env: NodeJS.ProcessEnv = process.env) => ({ - ...env, - GIT_OPTIONAL_LOCKS: '0' - }) -})) - -import { - clearSubmodulePathsCacheForTests, - getSubmodulePathsCacheCodeUnitsForTests, - getSubmodulePathsCacheCountForTests, - listSubmodulePaths, - MAX_SUBMODULE_PATH_CODE_UNITS, - MAX_SUBMODULE_PATHS_CACHE_CODE_UNITS, - MAX_SUBMODULE_PATHS_PER_REPO -} from './status' - -describe('submodule path payload bounds', () => { - beforeEach(() => { - clearSubmodulePathsCacheForTests() - gitExecFileAsyncMock.mockReset() - }) - - it('preserves under-limit path order and cache reuse', async () => { - gitExecFileAsyncMock.mockResolvedValue({ - stdout: 'submodule.one.path packages/one\nsubmodule.two.path packages/two/\n' - }) - - await expect(listSubmodulePaths('/repo')).resolves.toEqual(['packages/one', 'packages/two']) - await expect(listSubmodulePaths('/repo')).resolves.toEqual(['packages/one', 'packages/two']) - expect(gitExecFileAsyncMock).toHaveBeenCalledOnce() - }) - - it('fails closed above the per-repository path count', async () => { - gitExecFileAsyncMock.mockResolvedValue({ - stdout: Array.from( - { length: MAX_SUBMODULE_PATHS_PER_REPO + 1 }, - (_, index) => `submodule.s${index}.path modules/${index}\n` - ).join('') - }) - - await expect(listSubmodulePaths('/repo')).resolves.toEqual([]) - }) - - it('fails closed on a single oversized path', async () => { - gitExecFileAsyncMock.mockResolvedValue({ - stdout: `submodule.large.path ${'x'.repeat(MAX_SUBMODULE_PATH_CODE_UNITS + 1)}\n` - }) - - await expect(listSubmodulePaths('/repo')).resolves.toEqual([]) - }) - - it('evicts old payloads at the aggregate retained-code-unit budget', async () => { - const segment = 'x'.repeat(60 * 1024) - const stdout = Array.from( - { length: 16 }, - (_, index) => `submodule.s${index}.path ${segment}${index}\n` - ).join('') - gitExecFileAsyncMock.mockResolvedValue({ stdout }) - - for (let index = 0; index < 18; index += 1) { - await listSubmodulePaths(`/repo-${index}`) - } - - expect(getSubmodulePathsCacheCodeUnitsForTests()).toBeLessThanOrEqual( - MAX_SUBMODULE_PATHS_CACHE_CODE_UNITS - ) - expect(getSubmodulePathsCacheCountForTests()).toBeLessThan(18) - }) - - it('does not retain an oversized cache key', async () => { - gitExecFileAsyncMock.mockResolvedValue({ stdout: 'submodule.one.path packages/one\n' }) - const worktreePath = `/${'r'.repeat(64 * 1024)}` - - await listSubmodulePaths(worktreePath) - await listSubmodulePaths(worktreePath) - - expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2) - expect(getSubmodulePathsCacheCountForTests()).toBe(0) - expect(getSubmodulePathsCacheCodeUnitsForTests()).toBe(0) - }) -}) diff --git a/src/main/git/status-upstream-cache-key-bounds.test.ts b/src/main/git/status-upstream-cache-key-bounds.test.ts deleted file mode 100644 index a163ccd12a0..00000000000 --- a/src/main/git/status-upstream-cache-key-bounds.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type * as BoundedFileReader from '../../shared/node-bounded-file-reader' - -const { existsSyncMock, gitExecFileAsyncMock, readFileMock } = vi.hoisted(() => ({ - existsSyncMock: vi.fn(), - gitExecFileAsyncMock: vi.fn(), - readFileMock: vi.fn() -})) - -vi.mock('./runner', () => ({ - gitExecFileAsync: gitExecFileAsyncMock, - gitStreamStdout: async ( - args: string[], - options: { onStdout: (chunk: string) => boolean | void } - ) => { - const { stdout } = await gitExecFileAsyncMock(args) - return { stoppedEarly: options.onStdout(stdout ?? '') === true } - }, - gitOptionalLocksDisabledEnv: (env: NodeJS.ProcessEnv = process.env) => ({ - ...env, - GIT_OPTIONAL_LOCKS: '0' - }) -})) - -vi.mock('fs/promises', () => ({ readFile: readFileMock })) -vi.mock('fs', () => ({ existsSync: existsSyncMock })) -vi.mock('../../shared/node-bounded-file-reader', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - readNodeFileWithinLimit: async (filePath: string, maxBytes: number) => { - const value = await readFileMock(filePath) - const buffer = Buffer.isBuffer(value) ? value : Buffer.from(value) - if (buffer.length > maxBytes) { - throw new actual.NodeFileReadTooLargeError(buffer.length, maxBytes) - } - return { buffer, stats: { isFile: () => true, size: buffer.length } } - } - } -}) - -import { - clearEffectiveUpstreamNegativeStatusCache, - clearEffectiveUpstreamStatusCacheForTests, - getEffectiveUpstreamStatusCacheCountForTests, - getEffectiveUpstreamStatusGenerationCountForTests, - getStatus, - MAX_EFFECTIVE_UPSTREAM_CACHE_KEY_BYTES -} from './status' - -describe('effective-upstream cache key bounds', () => { - beforeEach(() => { - clearEffectiveUpstreamStatusCacheForTests() - existsSyncMock.mockReset() - gitExecFileAsyncMock.mockReset() - readFileMock.mockReset() - existsSyncMock.mockReturnValue(false) - readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') - gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { - if (args.includes('status')) { - return { stdout: '# branch.oid abcdef1234567890\n# branch.head feature\n' } - } - if (args[0] === 'symbolic-ref') { - return { stdout: 'feature\n' } - } - if (args[0] === 'config' && args[1] === '--list') { - return { stdout: 'core.repositoryformatversion\n0\0' } - } - if (args[0] === 'rev-parse') { - throw new Error('missing upstream') - } - throw new Error(`unexpected git command: ${args.join(' ')}`) - }) - }) - - it('re-probes without retaining keys above 64 KiB', async () => { - const worktreePath = `/${'p'.repeat(MAX_EFFECTIVE_UPSTREAM_CACHE_KEY_BYTES)}` - - await getStatus(worktreePath) - await getStatus(worktreePath) - - const upstreamProbes = gitExecFileAsyncMock.mock.calls.filter( - ([args]) => (args as string[])[0] === 'rev-parse' && (args as string[]).includes('HEAD@{u}') - ) - expect(upstreamProbes).toHaveLength(2) - expect(getEffectiveUpstreamStatusCacheCountForTests()).toBe(0) - expect(getEffectiveUpstreamStatusGenerationCountForTests()).toBe(0) - - clearEffectiveUpstreamNegativeStatusCache({ worktreePath, branchName: 'feature' }) - expect(getEffectiveUpstreamStatusGenerationCountForTests()).toBe(0) - }) -}) diff --git a/src/main/git/status-upstream-negative-cache.test.ts b/src/main/git/status-upstream-negative-cache.test.ts index a92e849180d..263842810a2 100644 --- a/src/main/git/status-upstream-negative-cache.test.ts +++ b/src/main/git/status-upstream-negative-cache.test.ts @@ -1,5 +1,4 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import type * as BoundedFileReader from '../../shared/node-bounded-file-reader' const { existsSyncMock, gitExecFileAsyncMock, readFileMock } = vi.hoisted(() => ({ existsSyncMock: vi.fn(), @@ -31,21 +30,6 @@ vi.mock('fs', () => ({ existsSync: existsSyncMock })) -vi.mock('../../shared/node-bounded-file-reader', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - readNodeFileWithinLimit: async (filePath: string, maxBytes: number) => { - const value = await readFileMock(filePath) - const buffer = Buffer.isBuffer(value) ? value : Buffer.from(value) - if (buffer.length > maxBytes) { - throw new actual.NodeFileReadTooLargeError(buffer.length, maxBytes) - } - return { buffer, stats: { isFile: () => true, size: buffer.length } } - } - } -}) - function isConfigListSnapshotCommand(args: string[]): boolean { return args[0] === 'config' && args[1] === '--list' && args[2] === '-z' } diff --git a/src/main/git/status-upstream-probe-churn.test.ts b/src/main/git/status-upstream-probe-churn.test.ts index 705f75f3521..49f6913e0af 100644 --- a/src/main/git/status-upstream-probe-churn.test.ts +++ b/src/main/git/status-upstream-probe-churn.test.ts @@ -1,5 +1,4 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type * as BoundedFileReader from '../../shared/node-bounded-file-reader' // Repro command: // pnpm exec vitest run --config config/vitest.config.ts src/main/git/status-upstream-probe-churn.test.ts -t "missing-upstream polling churn" @@ -36,21 +35,6 @@ vi.mock('fs', () => ({ existsSync: existsSyncMock })) -vi.mock('../../shared/node-bounded-file-reader', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - readNodeFileWithinLimit: async (filePath: string, maxBytes: number) => { - const value = await readFileMock(filePath) - const buffer = Buffer.isBuffer(value) ? value : Buffer.from(value) - if (buffer.length > maxBytes) { - throw new actual.NodeFileReadTooLargeError(buffer.length, maxBytes) - } - return { buffer, stats: { isFile: () => true, size: buffer.length } } - } - } -}) - import { clearEffectiveUpstreamStatusCacheForTests, getStatus } from './status' function getGitArgs(call: unknown[]): string[] { diff --git a/src/main/git/status.test.ts b/src/main/git/status.test.ts index 2cdc41dc367..6084d67f59c 100644 --- a/src/main/git/status.test.ts +++ b/src/main/git/status.test.ts @@ -1,7 +1,6 @@ /* eslint-disable max-lines -- Why: git status/discard/chunking behavior is verified together here to keep the command contract readable in one place. */ import { beforeEach, describe, expect, it, vi } from 'vitest' import type * as NodeFs from 'node:fs' -import type * as BoundedFileReader from '../../shared/node-bounded-file-reader' import path from 'node:path' import { MAX_RENDERED_DIFF_COMBINED_CHARACTERS, @@ -63,33 +62,6 @@ vi.mock('fs', () => ({ existsSync: existsSyncMock })) -vi.mock('../../shared/node-bounded-file-reader', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - readNodeFileWithinLimit: async (filePath: string, maxBytes: number) => { - if (maxBytes === 64 * 1024) { - const value = await readFileMock(filePath) - const buffer = Buffer.isBuffer(value) ? value : Buffer.from(value) - if (buffer.length > maxBytes) { - throw new actual.NodeFileReadTooLargeError(buffer.length, maxBytes) - } - return { buffer, stats: { isFile: () => true, size: buffer.length } } - } - const stats = await statMock(filePath) - if (stats.size > maxBytes) { - throw new actual.NodeFileReadTooLargeError(stats.size, maxBytes) - } - const value = await readFileMock(filePath) - const buffer = Buffer.isBuffer(value) ? value : Buffer.from(value) - if (buffer.length > maxBytes) { - throw new actual.NodeFileReadTooLargeError(buffer.length, maxBytes) - } - return { buffer, stats } - } - } -}) - import { abortMerge, abortRebase, diff --git a/src/main/git/status.ts b/src/main/git/status.ts index c90555a9247..34c5d750476 100644 --- a/src/main/git/status.ts +++ b/src/main/git/status.ts @@ -1,5 +1,6 @@ /* eslint-disable max-lines */ import { existsSync } from 'node:fs' +import { readFile, stat } from 'node:fs/promises' import * as path from 'node:path' import type { GitBranchChangeEntry, @@ -30,8 +31,6 @@ import { type GitLineStats } from '../../shared/git-uncommitted-line-stats' import { decodeGitCQuotedPath } from '../../shared/git-cquoted-path' -import { iterateNulDelimitedFields } from '../../shared/nul-delimited-fields' -import { iterateProcessOutputLines } from '../../shared/process-output-field-scanner' import { gitExecFileAsync, gitExecFileAsyncBuffer, @@ -58,18 +57,12 @@ import { clearGitStatusLineStatsCacheKey, reuseOrRecomputeGitStatusLineStats } from '../../shared/git-status-line-stats-cache' -import { - NodeFileReadTooLargeError, - readNodeFileWithinLimit -} from '../../shared/node-bounded-file-reader' const MAX_GIT_SHOW_BYTES = 10 * 1024 * 1024 -const MAX_GIT_POINTER_FILE_BYTES = 64 * 1024 const MAX_STAGED_COMMIT_CONTEXT_BYTES = MAX_GIT_SHOW_BYTES const BULK_CHUNK_SIZE = 100 const EFFECTIVE_UPSTREAM_NEGATIVE_CACHE_TTL_MS = 5 * 60_000 const MAX_EFFECTIVE_UPSTREAM_NEGATIVE_CACHE_ENTRIES = 512 -export const MAX_EFFECTIVE_UPSTREAM_CACHE_KEY_BYTES = 64 * 1024 type EffectiveUpstreamStatusCacheEntry = { expiresAt: number @@ -78,19 +71,9 @@ type EffectiveUpstreamStatusCacheEntry = { const SUBMODULE_PATHS_CACHE_TTL_MS = 5_000 export const MAX_SUBMODULE_PATHS_CACHE_ENTRIES = 512 -export const MAX_SUBMODULE_PATHS_PER_REPO = 10_000 -export const MAX_SUBMODULE_PATH_CODE_UNITS = 64 * 1024 -export const MAX_SUBMODULE_PATHS_PER_REPO_CODE_UNITS = 4 * 1024 * 1024 -export const MAX_SUBMODULE_PATHS_CACHE_CODE_UNITS = 16 * 1024 * 1024 -const MAX_SUBMODULE_PATHS_CACHE_KEY_BYTES = 64 * 1024 -type SubmodulePathsCacheEntry = { - paths: string[] - expiresAt: number - retainedCodeUnits: number -} +type SubmodulePathsCacheEntry = { paths: string[]; expiresAt: number } const submodulePathsCache = new Map() let submodulePathsCacheGeneration = 0 -let submodulePathsCacheCodeUnits = 0 // Why: cache the upstream name to skip its 4-5-spawn resolution chain each poll; revalidate via one rev-list (issue #7576). const RESOLVED_UPSTREAM_NAME_CACHE_TTL_MS = 60_000 @@ -134,7 +117,6 @@ export function clearSubmodulePathsCacheForTests(): void { function clearSubmodulePathsCache(): void { submodulePathsCache.clear() - submodulePathsCacheCodeUnits = 0 // Why: bump the generation so a pre-mutation read can't repopulate the invalidated cache. submodulePathsCacheGeneration += 1 } @@ -143,10 +125,6 @@ export function getSubmodulePathsCacheCountForTests(): number { return submodulePathsCache.size } -export function getSubmodulePathsCacheCodeUnitsForTests(): number { - return submodulePathsCacheCodeUnits -} - function gitRuntimeOptionsKey(options: GitRuntimeOptions): readonly unknown[] { return [options.wslDistro ?? null] } @@ -159,30 +137,18 @@ function getSubmodulePathsCacheKey(worktreePath: string, options: GitRuntimeOpti function pruneExpiredSubmodulePathsCache(now: number): void { for (const [cacheKey, entry] of submodulePathsCache) { if (entry.expiresAt <= now) { - deleteSubmodulePathsCacheEntry(cacheKey) + submodulePathsCache.delete(cacheKey) } } } -function deleteSubmodulePathsCacheEntry(cacheKey: string): void { - const entry = submodulePathsCache.get(cacheKey) - if (!entry) { - return - } - submodulePathsCache.delete(cacheKey) - submodulePathsCacheCodeUnits -= entry.retainedCodeUnits -} - function trimSubmodulePathsCache(): void { - while ( - submodulePathsCache.size > MAX_SUBMODULE_PATHS_CACHE_ENTRIES || - submodulePathsCacheCodeUnits > MAX_SUBMODULE_PATHS_CACHE_CODE_UNITS - ) { + while (submodulePathsCache.size > MAX_SUBMODULE_PATHS_CACHE_ENTRIES) { const oldestKey = submodulePathsCache.keys().next().value if (oldestKey === undefined) { break } - deleteSubmodulePathsCacheEntry(oldestKey) + submodulePathsCache.delete(oldestKey) } } @@ -192,7 +158,7 @@ function getCachedSubmodulePaths(cacheKey: string, now: number): string[] | null return null } if (cached.expiresAt <= now) { - deleteSubmodulePathsCacheEntry(cacheKey) + submodulePathsCache.delete(cacheKey) return null } submodulePathsCache.delete(cacheKey) @@ -201,23 +167,8 @@ function getCachedSubmodulePaths(cacheKey: string, now: number): string[] | null } function rememberSubmodulePaths(cacheKey: string, paths: string[], now: number): void { - if (Buffer.byteLength(cacheKey, 'utf8') > MAX_SUBMODULE_PATHS_CACHE_KEY_BYTES) { - deleteSubmodulePathsCacheEntry(cacheKey) - return - } - const retainedCodeUnits = - cacheKey.length + paths.reduce((total, submodulePath) => total + submodulePath.length, 0) - if (retainedCodeUnits > MAX_SUBMODULE_PATHS_CACHE_CODE_UNITS) { - deleteSubmodulePathsCacheEntry(cacheKey) - return - } - deleteSubmodulePathsCacheEntry(cacheKey) - submodulePathsCache.set(cacheKey, { - paths, - expiresAt: now + SUBMODULE_PATHS_CACHE_TTL_MS, - retainedCodeUnits - }) - submodulePathsCacheCodeUnits += retainedCodeUnits + submodulePathsCache.delete(cacheKey) + submodulePathsCache.set(cacheKey, { paths, expiresAt: now + SUBMODULE_PATHS_CACHE_TTL_MS }) trimSubmodulePathsCache() } @@ -634,10 +585,6 @@ function getEffectiveUpstreamStatusCacheKey( return [worktreePath, options.wslDistro ?? 'host', branchName, upstreamName ?? ''].join('\0') } -function canRetainEffectiveUpstreamCacheKey(cacheKey: string): boolean { - return Buffer.byteLength(cacheKey, 'utf8') <= MAX_EFFECTIVE_UPSTREAM_CACHE_KEY_BYTES -} - export function clearEffectiveUpstreamNegativeStatusCache(identity: { worktreePath: string branchName: string @@ -654,9 +601,6 @@ export function clearEffectiveUpstreamNegativeStatusCache(identity: { effectiveUpstreamStatusCache.delete(cacheKey) effectiveUpstreamStatusInFlight.delete(cacheKey) resolvedUpstreamNameCache.delete(cacheKey) - if (!canRetainEffectiveUpstreamCacheKey(cacheKey)) { - return - } effectiveUpstreamStatusWriteGeneration.set( cacheKey, (effectiveUpstreamStatusWriteGeneration.get(cacheKey) ?? 0) + 1 @@ -722,11 +666,6 @@ function rememberEffectiveUpstreamStatus( probedSameNameOriginRef: boolean, writeGeneration: number ): void { - if (!canRetainEffectiveUpstreamCacheKey(cacheKey)) { - effectiveUpstreamStatusCache.delete(cacheKey) - effectiveUpstreamStatusWriteGeneration.delete(cacheKey) - return - } // Why: hasConfiguredPushTarget gates a write action; re-probe each poll rather than cache a stale positive. if (status.hasUpstream || status.hasConfiguredPushTarget) { effectiveUpstreamStatusCache.delete(cacheKey) @@ -763,8 +702,7 @@ async function readOrProbeEffectiveUpstreamStatus( options: GitRuntimeOptions = {}, bypassCache = false ): Promise { - const cacheable = !bypassCache && canRetainEffectiveUpstreamCacheKey(cacheKey) - if (cacheable) { + if (!bypassCache) { const cached = readCachedEffectiveUpstreamStatus(cacheKey, Date.now()) if (cached) { return cached @@ -783,7 +721,7 @@ async function readOrProbeEffectiveUpstreamStatus( worktreePath, branchName, options, - !cacheable + bypassCache ).then((result) => { rememberEffectiveUpstreamStatus( cacheKey, @@ -794,7 +732,7 @@ async function readOrProbeEffectiveUpstreamStatus( ) return result.status }) - if (cacheable) { + if (!bypassCache) { effectiveUpstreamStatusInFlight.set(cacheKey, probe) } try { @@ -835,11 +773,7 @@ async function probeOrRevalidateEffectiveUpstreamStatus( } } const result = await probeEffectiveUpstreamStatus(worktreePath, branchName, options) - if ( - canRetainEffectiveUpstreamCacheKey(cacheKey) && - result.status.hasUpstream && - result.status.upstreamName - ) { + if (result.status.hasUpstream && result.status.upstreamName) { resolvedUpstreamNameCache.set(cacheKey, { upstreamName: result.status.upstreamName, expiresAt: Date.now() + RESOLVED_UPSTREAM_NAME_CACHE_TTL_MS @@ -1039,9 +973,7 @@ export async function resolveGitDir(worktreePath: string): Promise { const dotGitPath = path.join(worktreePath, '.git') try { - const dotGitContents = ( - await readNodeFileWithinLimit(dotGitPath, MAX_GIT_POINTER_FILE_BYTES) - ).buffer.toString('utf-8') + const dotGitContents = await readFile(dotGitPath, 'utf-8') const match = dotGitContents.match(/^gitdir:\s*(.+)\s*$/m) if (match) { return path.resolve(worktreePath, match[1]) @@ -1057,37 +989,6 @@ export async function resolveGitDir(worktreePath: string): Promise { * List configured submodule paths (relative, forward-slash) for a worktree, cached * briefly. Read from `.gitmodules` to avoid an index-wide `ls-files` scan. */ -function parseSubmodulePaths(stdout: string): string[] | null { - const paths: string[] = [] - let retainedCodeUnits = 0 - for (const line of iterateProcessOutputLines(stdout)) { - if (line.length > MAX_SUBMODULE_PATH_CODE_UNITS + 4_096) { - return null - } - const spaceIndex = line.indexOf(' ') - const submodulePath = - spaceIndex === -1 - ? '' - : line - .slice(spaceIndex + 1) - .trim() - .replace(/\/+$/, '') - if (!submodulePath) { - continue - } - if ( - paths.length >= MAX_SUBMODULE_PATHS_PER_REPO || - submodulePath.length > MAX_SUBMODULE_PATH_CODE_UNITS || - submodulePath.length > MAX_SUBMODULE_PATHS_PER_REPO_CODE_UNITS - retainedCodeUnits - ) { - return null - } - paths.push(submodulePath) - retainedCodeUnits += submodulePath.length - } - return paths -} - export async function listSubmodulePaths( worktreePath: string, options: GitRuntimeOptions = {} @@ -1107,7 +1008,18 @@ export async function listSubmodulePaths( ['config', '--file', '.gitmodules', '--get-regexp', '^submodule\\..*\\.path$'], { ...gitOptionsForWorktree(worktreePath, options), env: gitOptionalLocksDisabledEnv() } ) - paths = parseSubmodulePaths(stdout) ?? [] + paths = stdout + .split(/\r?\n/) + .map((line) => { + const spaceIndex = line.indexOf(' ') + return spaceIndex === -1 + ? '' + : line + .slice(spaceIndex + 1) + .trim() + .replace(/\/+$/, '') + }) + .filter((value) => value.length > 0) } catch { // No .gitmodules (or git config failure) — treat as a repo without submodules. paths = [] @@ -1858,23 +1770,31 @@ async function readGitBlobAtOidPath( } async function readWorkingTreeFile(filePath: string): Promise { + let fileStat try { - const { buffer, stats: fileStat } = await readNodeFileWithinLimit(filePath, MAX_GIT_SHOW_BYTES) - if (!fileStat.isFile()) { - return { content: '', isBinary: false, exists: false } - } - return bufferToBlob(buffer, filePath) + fileStat = await stat(filePath) } catch (error) { - if (error instanceof NodeFileReadTooLargeError) { - return { content: '', isBinary: true, exists: true } - } - // Why: only ENOENT is a real deletion; other read errors are failures, not absence. + // Why: only ENOENT is a real deletion; other stat errors are read failures, not absence. return { content: '', isBinary: false, exists: (error as NodeJS.ErrnoException)?.code !== 'ENOENT' } } + if (!fileStat.isFile()) { + return { content: '', isBinary: false, exists: false } + } + if (fileStat.size > MAX_GIT_SHOW_BYTES) { + // Why: mirror git's maxBuffer cap for working-tree reads so readFile can't pull in huge assets. + return { content: '', isBinary: true, exists: true } + } + try { + const buffer = await readFile(filePath) + return bufferToBlob(buffer, filePath) + } catch { + // Why: the file exists but could not be read — a read failure, not a deletion. + return { content: '', isBinary: false, exists: true } + } } function bufferToBlob(buffer: Buffer, filePath?: string): GitBlobReadResult { @@ -2149,7 +2069,7 @@ async function listTrackedPathSpecs( } ) // Why: a tracked directory can hold enough paths to exceed the JS argument limit. - for (const trackedPath of iterateNulDelimitedFields(stdout)) { + for (const trackedPath of stdout.split('\0')) { if (trackedPath) { trackedPaths.push(trackedPath) } diff --git a/src/main/git/worktree.ts b/src/main/git/worktree.ts index a802aea6986..08decaefcaf 100644 --- a/src/main/git/worktree.ts +++ b/src/main/git/worktree.ts @@ -18,7 +18,6 @@ import { isSubmoduleWorktreeRemovalRefusal } from '../../shared/worktree-submodu import { decodeGitCQuotedPath } from '../../shared/git-cquoted-path' import { parseGitRevListAheadBehindCounts } from '../../shared/git-rev-list-output' import { parseWslUncPath } from '../../shared/wsl-paths' -import { iterateNulDelimitedFields } from '../../shared/nul-delimited-fields' import { hasUnsupportedRevParsePathFormatEcho, isUnsupportedRevParsePathFormatError, @@ -549,7 +548,7 @@ function splitNulWorktreeList(output: string): string[][] { const blocks: string[][] = [] let currentBlock: string[] = [] - for (const field of iterateNulDelimitedFields(output)) { + for (const field of output.split('\0')) { if (field) { currentBlock.push(field) continue @@ -1377,12 +1376,10 @@ function hasOnlyIgnoredUntrackedStatus( ) .filter((entry) => entry && !entry.split('/').includes('..')) ) - for (const entry of iterateNulDelimitedFields(status)) { - if (entry && (!entry.startsWith('?? ') || !ignored.has(entry.slice(3).replace(/\\/g, '/')))) { - return false - } - } - return true + return status + .split('\0') + .filter(Boolean) + .every((entry) => entry.startsWith('?? ') && ignored.has(entry.slice(3).replace(/\\/g, '/'))) } function translateWorktreePath( diff --git a/src/main/gitea/client.test.ts b/src/main/gitea/client.test.ts index 112c03ab6c9..eada3d6cd95 100644 --- a/src/main/gitea/client.test.ts +++ b/src/main/gitea/client.test.ts @@ -17,11 +17,7 @@ import { } from './client' import { _resetGiteaRepoRefCache } from './repository-ref' import { - GITEA_SCAN_CACHE_ENTRY_MAX_BYTES, - GITEA_SCAN_MAX_IN_FLIGHT, - GITEA_SCAN_REPO_KEY_MAX_BYTES, _getGiteaPullRequestScanCacheSize, - _getGiteaPullRequestScanState, _resetGiteaPullRequestScanCache, scanGiteaPullRequests } from './pull-request-scan-cache' @@ -303,62 +299,6 @@ describe('Gitea client', () => { } }) - it('skips retained state for oversized scan keys and payloads', async () => { - const exactKey = 'k'.repeat(GITEA_SCAN_REPO_KEY_MAX_BYTES) - const oversizedKey = `${exactKey}x` - await scanGiteaPullRequests(exactKey, async () => [], 50, 5) - await scanGiteaPullRequests(oversizedKey, async () => [], 50, 5) - const oversizedListing = [giteaPr(1, 'x'.repeat(GITEA_SCAN_CACHE_ENTRY_MAX_BYTES))] - await expect( - scanGiteaPullRequests('oversized-payload', async () => oversizedListing, 50, 5) - ).resolves.toEqual(oversizedListing) - - expect(_getGiteaPullRequestScanCacheSize()).toBe(1) - }) - - it('caps distinct in-flight scans and recovers tracked capacity', async () => { - let release!: () => void - const gate = new Promise((resolve) => { - release = resolve - }) - const scans = Array.from({ length: GITEA_SCAN_MAX_IN_FLIGHT }, (_, index) => - scanGiteaPullRequests( - `in-flight-${index}`, - async () => { - await gate - return [] - }, - 50, - 5 - ) - ) - let overflowCalls = 0 - await scanGiteaPullRequests( - 'overflow', - async () => { - overflowCalls += 1 - return [] - }, - 50, - 5 - ) - await scanGiteaPullRequests( - 'overflow', - async () => { - overflowCalls += 1 - return [] - }, - 50, - 5 - ) - - expect(_getGiteaPullRequestScanState().inFlight).toBe(GITEA_SCAN_MAX_IN_FLIGHT) - expect(overflowCalls).toBe(2) - release() - await Promise.all(scans) - expect(_getGiteaPullRequestScanState().inFlight).toBe(0) - }) - it('does not let an in-flight scan re-cache results from before an invalidation', async () => { let releaseFirstScan!: () => void const firstScanGate = new Promise((resolve) => { diff --git a/src/main/gitea/client.ts b/src/main/gitea/client.ts index ca805222106..ba9e7c3ed8f 100644 --- a/src/main/gitea/client.ts +++ b/src/main/gitea/client.ts @@ -14,7 +14,6 @@ import { type HostedReviewExecutionOptions } from '../source-control/hosted-review-git-options' import { cancelUnreadResponseBody } from '../lib/unread-response-body' -import { readFetchResponseJsonWithinLimit } from '../lib/fetch-response-body' const REQUEST_TIMEOUT_MS = 5000 // Why: self-hosted Forgejo can take ~5s to serve one /pulls page (it loads @@ -103,7 +102,7 @@ async function requestJsonAtBase( } return null } - return await readFetchResponseJsonWithinLimit(response) + return (await response.json()) as T } catch (error) { if (throwOnFailure) { throw error diff --git a/src/main/gitea/pull-request-scan-cache.ts b/src/main/gitea/pull-request-scan-cache.ts index b06f21846aa..8864f087d6d 100644 --- a/src/main/gitea/pull-request-scan-cache.ts +++ b/src/main/gitea/pull-request-scan-cache.ts @@ -1,5 +1,4 @@ import type { RawGiteaPullRequest } from './pull-request-mappers' -import { measureUtf8ByteLength } from '../../shared/utf8-byte-limits' export type GiteaPullRequestPageFetcher = (page: number) => Promise @@ -17,11 +16,7 @@ const SCAN_TTL_MS = 30_000 const FAILED_SCAN_RETRY_MS = 3_000 // Why: each entry can retain hundreds of full PR payloads, so TTL alone is not // enough protection when many repositories are opened during one app session. -export const GITEA_SCAN_CACHE_MAX_ENTRIES = 32 -export const GITEA_SCAN_MAX_IN_FLIGHT = 32 -export const GITEA_SCAN_REPO_KEY_MAX_BYTES = 4 * 1024 -export const GITEA_SCAN_CACHE_ENTRY_MAX_PULL_REQUESTS = 250 -export const GITEA_SCAN_CACHE_ENTRY_MAX_BYTES = 512 * 1024 +const MAX_SCAN_CACHE_ENTRIES = 32 const scanCache = new Map() const inFlightScans = new Map>() @@ -46,9 +41,6 @@ function rememberScanCacheEntry( ttlMs: number ): void { removeScanCacheEntry(repoKey) - if (!isRetainablePullRequestListing(pullRequests)) { - return - } let entry!: GiteaPullRequestScanEntry const expirationTimer = setTimeout(() => removeScanCacheEntry(repoKey, entry), ttlMs) expirationTimer.unref() @@ -58,7 +50,7 @@ function rememberScanCacheEntry( pullRequests } scanCache.set(repoKey, entry) - while (scanCache.size > GITEA_SCAN_CACHE_MAX_ENTRIES) { + while (scanCache.size > MAX_SCAN_CACHE_ENTRIES) { const oldestKey = scanCache.keys().next().value if (oldestKey === undefined) { break @@ -67,65 +59,6 @@ function rememberScanCacheEntry( } } -function isRetainableRepoKey(repoKey: string): boolean { - return !measureUtf8ByteLength(repoKey, { - stopAfterBytes: GITEA_SCAN_REPO_KEY_MAX_BYTES - }).exceededLimit -} - -function isRetainablePullRequestListing(pullRequests: RawGiteaPullRequest[]): boolean { - if (pullRequests.length > GITEA_SCAN_CACHE_ENTRY_MAX_PULL_REQUESTS) { - return false - } - let remainingBytes = GITEA_SCAN_CACHE_ENTRY_MAX_BYTES - pullRequests.length * 128 - if (remainingBytes < 0) { - return false - } - for (const pullRequest of pullRequests) { - const values = [ - pullRequest.title, - pullRequest.state, - pullRequest.html_url, - pullRequest.updated_at, - pullRequest.head?.ref, - pullRequest.head?.label, - pullRequest.head?.sha - ] - for (const value of values) { - if (typeof value !== 'string') { - continue - } - const measured = measureUtf8ByteLength(value, { stopAfterBytes: remainingBytes }) - if (measured.exceededLimit) { - return false - } - remainingBytes -= measured.byteLength - } - } - return true -} - -async function collectPullRequests( - fetchPage: GiteaPullRequestPageFetcher, - pageLimit: number, - maxPages: number -): Promise<{ completed: boolean; pullRequests: RawGiteaPullRequest[] }> { - const pullRequests: RawGiteaPullRequest[] = [] - let completed = true - for (let page = 1; page <= maxPages; page++) { - const list = await fetchPage(page) - if (!list) { - completed = false - break - } - pullRequests.push(...list) - if (list.length < pageLimit) { - break - } - } - return { completed, pullRequests } -} - function reusableScanCacheEntry(repoKey: string): GiteaPullRequestScanEntry | null { const entry = scanCache.get(repoKey) if (!entry) { @@ -156,9 +89,6 @@ export async function scanGiteaPullRequests( pageLimit: number, maxPages: number ): Promise { - if (!isRetainableRepoKey(repoKey)) { - return (await collectPullRequests(fetchPage, pageLimit, maxPages)).pullRequests - } const cached = reusableScanCacheEntry(repoKey) if (cached) { return cached.pullRequests @@ -167,13 +97,22 @@ export async function scanGiteaPullRequests( if (running) { return running } - if (inFlightScans.size >= GITEA_SCAN_MAX_IN_FLIGHT) { - return (await collectPullRequests(fetchPage, pageLimit, maxPages)).pullRequests - } const generation = scanGenerations.get(repoKey) ?? 0 activeScanCounts.set(repoKey, (activeScanCounts.get(repoKey) ?? 0) + 1) const scan = (async () => { - const { completed, pullRequests } = await collectPullRequests(fetchPage, pageLimit, maxPages) + const pullRequests: RawGiteaPullRequest[] = [] + let completed = true + for (let page = 1; page <= maxPages; page++) { + const list = await fetchPage(page) + if (!list) { + completed = false + break + } + pullRequests.push(...list) + if (list.length < pageLimit) { + break + } + } if ((scanGenerations.get(repoKey) ?? 0) === generation) { rememberScanCacheEntry(repoKey, pullRequests, completed ? SCAN_TTL_MS : FAILED_SCAN_RETRY_MS) } @@ -220,17 +159,3 @@ export function _resetGiteaPullRequestScanCache(): void { export function _getGiteaPullRequestScanCacheSize(): number { return scanCache.size } - -export function _getGiteaPullRequestScanState(): { - cached: number - inFlight: number - active: number - generations: number -} { - return { - cached: scanCache.size, - inFlight: inFlightScans.size, - active: activeScanCounts.size, - generations: scanGenerations.size - } -} diff --git a/src/main/gitea/repository-ref.ts b/src/main/gitea/repository-ref.ts index 29ba407f909..f5a9acc8e5f 100644 --- a/src/main/gitea/repository-ref.ts +++ b/src/main/gitea/repository-ref.ts @@ -1,9 +1,5 @@ import { gitExecFileAsync } from '../git/runner' import { getSshGitProvider } from '../providers/ssh-git-dispatch' -import { - buildRepositoryRefCacheKey, - RepositoryRefCache -} from '../source-control/repository-ref-cache' export type GiteaRepoRef = { host: string @@ -24,7 +20,8 @@ const KNOWN_NON_GITEA_HOSTS = new Set([ 'dev.azure.com', 'ssh.dev.azure.com' ]) -const repoRefCache = new RepositoryRefCache() +const REPO_REF_CACHE_MAX_ENTRIES = 512 +const repoRefCache = new Map() /** @internal - exposed for tests only */ export function _resetGiteaRepoRefCache(): void { @@ -36,6 +33,17 @@ export function _getGiteaRepoRefCacheSize(): number { return repoRefCache.size } +function rememberRepoRefCacheEntry(cacheKey: string, value: GiteaRepoRef | null): void { + repoRefCache.set(cacheKey, value) + while (repoRefCache.size > REPO_REF_CACHE_MAX_ENTRIES) { + const oldestKey = repoRefCache.keys().next().value + if (oldestKey === undefined) { + return + } + repoRefCache.delete(oldestKey) + } +} + function decodeSegment(value: string): string { try { return decodeURIComponent(value) @@ -140,10 +148,9 @@ export async function getGiteaRepoRefForRemote( localGitOptions: LocalGitExecOptions = {} ): Promise { const runtimeKey = connectionId ?? `local:${localGitOptions.wslDistro ?? 'host'}` - const cacheKey = buildRepositoryRefCacheKey([runtimeKey, repoPath, remoteName]) - const cached = repoRefCache.get(cacheKey) - if (cached.found) { - return cached.value + const cacheKey = `${runtimeKey}\0${repoPath}\0${remoteName}` + if (repoRefCache.has(cacheKey)) { + return repoRefCache.get(cacheKey)! } try { const sshGitProvider = connectionId ? getSshGitProvider(connectionId) : null @@ -157,11 +164,7 @@ export async function getGiteaRepoRefForRemote( ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}) }) const result = parseGiteaRepoRef(stdout) - repoRefCache.remember( - cacheKey, - result, - result ? [result.host, result.owner, result.repo, result.apiBaseUrl, result.webBaseUrl] : [] - ) + rememberRepoRefCacheEntry(cacheKey, result) return result } catch { if (connectionId) { @@ -169,7 +172,7 @@ export async function getGiteaRepoRefForRemote( // caching them as "not Gitea" would poison the repo for the session. return null } - repoRefCache.remember(cacheKey, null, []) + rememberRepoRefCacheEntry(cacheKey, null) return null } } diff --git a/src/main/github/client.ts b/src/main/github/client.ts index 311484d0c4a..5229c187f11 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -34,7 +34,7 @@ import { GITHUB_WORK_ITEMS_SSH_REMOTE_REQUIRED_MESSAGE, sortWorkItemsByNumber } from '../../shared/work-items' -import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' import { sliceCheckLogTail } from './check-job-log-tail-slice' @@ -43,6 +43,8 @@ import { safePRRefreshErrorMessage } from './pr-refresh-error-classification' import { getPRConflictSummary } from './conflict-summary' +import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' +import { joinWorktreeRelativePath } from '../runtime/runtime-relative-paths' import { splitRemoteBranchName } from '../../shared/git-effective-upstream' import { execFileAsync, @@ -72,9 +74,6 @@ import { } from '../source-control/hosted-review-git-options' import { shouldHideNonOpenReviewOnDefaultBranch } from '../source-control/repo-default-branch' import { readLocalGitConfigSignature } from './local-git-config-signature' -import { readHostedReviewTemplate } from '../source-control/pull-request-template' -import { cacheIdentityDigest } from '../cache-identity-digest' -import { measureUtf8ByteLength } from '../../shared/utf8-byte-limits' import { getGitHubApiRepositoryForRemote, getIssueGitHubApiRepository, @@ -1774,6 +1773,41 @@ async function findOpenPRByHeadBase(args: { return { number: list[0].number, url: list[0].url } } +async function readPullRequestTemplate( + repoPath: string, + connectionId?: string | null +): Promise { + const relativeCandidates = [ + '.github/pull_request_template.md', + '.github/PULL_REQUEST_TEMPLATE.md', + 'pull_request_template.md', + 'PULL_REQUEST_TEMPLATE.md', + 'docs/pull_request_template.md', + 'docs/PULL_REQUEST_TEMPLATE.md' + ] + const remoteProvider = connectionId ? getSshFilesystemProvider(connectionId) : undefined + if (connectionId && !remoteProvider) { + return '' + } + for (const relativeCandidate of relativeCandidates) { + try { + if (remoteProvider) { + const result = await remoteProvider.readFile( + joinWorktreeRelativePath(repoPath, relativeCandidate) + ) + if (result.isBinary) { + continue + } + return result.content + } + return await readFile(join(repoPath, relativeCandidate), 'utf8') + } catch { + // Try the next conventional PR template path. + } + } + return '' +} + export async function createGitHubPullRequest( repoPath: string, input: CreateHostedReviewInput, @@ -1828,7 +1862,7 @@ export async function createGitHubPullRequest( try { const body = input.useTemplate && !input.body?.trim() - ? await readHostedReviewTemplate(repoPath, connectionId, 'github') + ? await readPullRequestTemplate(repoPath, connectionId) : (input.body ?? '') await writeFile(bodyPath, body, 'utf8') const createArgs = [ @@ -2182,7 +2216,7 @@ async function detectRepositoryMergeMetadata( branchName: string | undefined, ghOptions: GhExecOptions ): Promise { - const cacheKey = cacheIdentityDigest([githubRepoIdentityKey(ownerRepo), branchName ?? '__repo__']) + const cacheKey = `${githubRepoIdentityKey(ownerRepo)}:${branchName ?? '__repo__'}` pruneRepositoryMergeMetadataCache() const cached = repositoryMergeMetadataCache.get(cacheKey) if (cached) { @@ -2356,16 +2390,11 @@ type TrackedUpstreamBranch = { const TRACKED_UPSTREAM_SNAPSHOT_CACHE_TTL_MS = 30_000 const TRACKED_UPSTREAM_SNAPSHOT_CACHE_MAX_ENTRIES = 512 -export const TRACKED_UPSTREAM_SNAPSHOT_MAX_IN_FLIGHT = 32 -export const TRACKED_UPSTREAM_SNAPSHOT_MAX_BRANCHES = 4096 -export const TRACKED_UPSTREAM_SNAPSHOT_MAX_BYTES = 2 * 1024 * 1024 -export const TRACKED_UPSTREAM_SNAPSHOT_CACHE_MAX_BYTES = 32 * 1024 * 1024 type TrackedUpstreamSnapshotCacheEntry = { expiresAt: number gitConfigSignature?: string upstreamsByBranchName: Map - retainedBytes: number } type TrackedUpstreamSnapshotProbeResult = { @@ -2373,7 +2402,6 @@ type TrackedUpstreamSnapshotProbeResult = { gitConfigSignature?: string probeFailed: boolean upstreamsByBranchName: Map - retainedBytes: number } const trackedUpstreamSnapshotCache = new Map() @@ -2382,16 +2410,6 @@ const trackedUpstreamSnapshotInFlight = new Map< Promise >() const trackedUpstreamSnapshotGenerations = new Map() -let trackedUpstreamSnapshotCacheBytes = 0 - -function deleteTrackedUpstreamSnapshot(cacheKey: string): void { - const cached = trackedUpstreamSnapshotCache.get(cacheKey) - if (!cached) { - return - } - trackedUpstreamSnapshotCacheBytes -= cached.retainedBytes - trackedUpstreamSnapshotCache.delete(cacheKey) -} function beginTrackedUpstreamSnapshotProbe(cacheKey: string): symbol { const generation = Symbol() @@ -2409,19 +2427,16 @@ function finishTrackedUpstreamSnapshotProbe(cacheKey: string, generation: symbol function pruneTrackedUpstreamSnapshotCache(now: number): void { for (const [cacheKey, cached] of trackedUpstreamSnapshotCache) { if (cached.expiresAt <= now) { - deleteTrackedUpstreamSnapshot(cacheKey) + trackedUpstreamSnapshotCache.delete(cacheKey) } } // Why: workspace/runtime churn can create unbounded unique keys within one TTL window, so expiry sweeping alone isn't a memory bound. - while ( - trackedUpstreamSnapshotCache.size > TRACKED_UPSTREAM_SNAPSHOT_CACHE_MAX_ENTRIES || - trackedUpstreamSnapshotCacheBytes > TRACKED_UPSTREAM_SNAPSHOT_CACHE_MAX_BYTES - ) { + while (trackedUpstreamSnapshotCache.size > TRACKED_UPSTREAM_SNAPSHOT_CACHE_MAX_ENTRIES) { const oldestKey = trackedUpstreamSnapshotCache.keys().next().value if (oldestKey === undefined) { break } - deleteTrackedUpstreamSnapshot(oldestKey) + trackedUpstreamSnapshotCache.delete(oldestKey) } } @@ -2441,7 +2456,6 @@ export function __resetTrackedUpstreamBranchCacheForTests(): void { trackedUpstreamSnapshotCache.clear() trackedUpstreamSnapshotInFlight.clear() trackedUpstreamSnapshotGenerations.clear() - trackedUpstreamSnapshotCacheBytes = 0 } function parseTrackedUpstreamBranch(upstreamRef: string): TrackedUpstreamBranch | null { @@ -2449,10 +2463,7 @@ function parseTrackedUpstreamBranch(upstreamRef: string): TrackedUpstreamBranch if (!parsed) { return null } - return { - remoteName: parsed.remoteName.replace(/$/u, ''), - branchName: parsed.branchName.replace(/$/u, '') - } + return parsed } function shouldRetryTrackedUpstreamBranch( @@ -2493,10 +2504,10 @@ async function getTrackedUpstreamBranch( ) { return cached.upstreamsByBranchName.get(branchName) ?? null } - deleteTrackedUpstreamSnapshot(cacheKey) + trackedUpstreamSnapshotCache.delete(cacheKey) } if (cached) { - deleteTrackedUpstreamSnapshot(cacheKey) + trackedUpstreamSnapshotCache.delete(cacheKey) } const inFlight = trackedUpstreamSnapshotInFlight.get(cacheKey) @@ -2513,31 +2524,18 @@ async function getTrackedUpstreamBranch( } } - if (trackedUpstreamSnapshotInFlight.size >= TRACKED_UPSTREAM_SNAPSHOT_MAX_IN_FLIGHT) { - const result = await probeTrackedUpstreamSnapshot( - repoPath, - connectionId, - localGitOptions, - branchName - ) - return result.upstreamsByBranchName.get(branchName) ?? null - } - // Why: PR polling asks about hundreds of branches at once; read all upstreams in one git process per repo/runtime, not one probe per branch. const probeGeneration = beginTrackedUpstreamSnapshotProbe(cacheKey) - const probe = probeTrackedUpstreamSnapshot(repoPath, connectionId, localGitOptions, branchName) + const probe = probeTrackedUpstreamSnapshot(repoPath, connectionId, localGitOptions) trackedUpstreamSnapshotInFlight.set(cacheKey, probe) try { const result = await probe if (result.cacheable && trackedUpstreamSnapshotGenerations.get(cacheKey) === probeGeneration) { - deleteTrackedUpstreamSnapshot(cacheKey) trackedUpstreamSnapshotCache.set(cacheKey, { ...(result.gitConfigSignature ? { gitConfigSignature: result.gitConfigSignature } : {}), upstreamsByBranchName: getCacheableTrackedUpstreamSnapshot(result.upstreamsByBranchName), - retainedBytes: result.retainedBytes, expiresAt: Date.now() + TRACKED_UPSTREAM_SNAPSHOT_CACHE_TTL_MS }) - trackedUpstreamSnapshotCacheBytes += result.retainedBytes pruneTrackedUpstreamSnapshotCache(Date.now()) } if (trackedUpstreamSnapshotGenerations.get(cacheKey) !== probeGeneration) { @@ -2558,8 +2556,7 @@ async function getTrackedUpstreamBranch( async function probeTrackedUpstreamSnapshot( repoPath: string, connectionId?: string | null, - localGitOptions: { wslDistro?: string } = {}, - requestedBranchName?: string + localGitOptions: { wslDistro?: string } = {} ): Promise { const startingGitConfigSignature = await readLocalGitConfigSignature({ repoPath, @@ -2569,10 +2566,8 @@ async function probeTrackedUpstreamSnapshot( const { probeFailed, upstreamsByBranchName } = await probeTrackedUpstreamBranches( repoPath, connectionId, - localGitOptions, - requestedBranchName + localGitOptions ) - const retainedBytes = measureTrackedUpstreamSnapshotBytes(upstreamsByBranchName) const endingGitConfigSignature = await readLocalGitConfigSignature({ repoPath, connectionId: connectionId ?? null, @@ -2588,8 +2583,7 @@ async function probeTrackedUpstreamSnapshot( cacheable: !configSignatureChanged && !probeFailed, probeFailed, ...(gitConfigSignature ? { gitConfigSignature } : {}), - upstreamsByBranchName, - retainedBytes + upstreamsByBranchName } } @@ -2632,14 +2626,13 @@ function getTrackedUpstreamBranchCacheKey( const runtimeKey = connectionId ? `ssh:${connectionId}` : `local:${localGitOptions.wslDistro ?? 'host'}` - return cacheIdentityDigest([runtimeKey, repoPath]) + return [runtimeKey, repoPath].join('\0') } async function probeTrackedUpstreamBranches( repoPath: string, connectionId?: string | null, - localGitOptions: { wslDistro?: string } = {}, - requestedBranchName?: string + localGitOptions: { wslDistro?: string } = {} ): Promise<{ probeFailed: boolean upstreamsByBranchName: Map @@ -2655,106 +2648,29 @@ async function probeTrackedUpstreamBranches( }) return { probeFailed: false, - upstreamsByBranchName: parseTrackedUpstreamBranches(result.stdout, requestedBranchName) + upstreamsByBranchName: parseTrackedUpstreamBranches(result.stdout) } } catch { return { probeFailed: true, upstreamsByBranchName: new Map() } } } -function parseTrackedUpstreamBranches( - stdout: string, - requestedBranchName?: string -): Map { +function parseTrackedUpstreamBranches(stdout: string): Map { const upstreamsByBranchName = new Map() - let retainedBytes = 0 - let lineStart = 0 - while (lineStart <= stdout.length) { - const newline = stdout.indexOf('\n', lineStart) - const lineEnd = newline === -1 ? stdout.length : newline - const line = stdout.slice( - lineStart, - lineEnd > lineStart && stdout[lineEnd - 1] === '\r' ? lineEnd - 1 : lineEnd - ) - lineStart = newline === -1 ? stdout.length + 1 : newline + 1 + for (const line of stdout.split(/\r?\n/)) { if (!line) { continue } - const separator = line.indexOf('\0') - const branchName = separator === -1 ? line : line.slice(0, separator) - const upstreamRef = separator === -1 ? '' : line.slice(separator + 1) - const localBranchName = branchName.startsWith('refs/heads/') - ? branchName.slice('refs/heads/'.length) - : branchName + const [branchName, upstreamRef] = line.split('\0') + const localBranchName = branchName?.replace(/^refs\/heads\//, '') if (!localBranchName) { continue } - const parsedUpstream = parseTrackedUpstreamRef(upstreamRef) - const entryBytes = measureTrackedUpstreamEntryBytes(localBranchName, parsedUpstream) - if (entryBytes === null) { - continue - } - const isRequested = localBranchName === requestedBranchName - while ( - isRequested && - upstreamsByBranchName.size > 0 && - (upstreamsByBranchName.size >= TRACKED_UPSTREAM_SNAPSHOT_MAX_BRANCHES || - retainedBytes + entryBytes > TRACKED_UPSTREAM_SNAPSHOT_MAX_BYTES) - ) { - const oldest = upstreamsByBranchName.keys().next().value - if (oldest === undefined) { - break - } - retainedBytes -= - measureTrackedUpstreamEntryBytes(oldest, upstreamsByBranchName.get(oldest) ?? null) ?? 0 - upstreamsByBranchName.delete(oldest) - } - if ( - upstreamsByBranchName.size >= TRACKED_UPSTREAM_SNAPSHOT_MAX_BRANCHES || - retainedBytes + entryBytes > TRACKED_UPSTREAM_SNAPSHOT_MAX_BYTES - ) { - continue - } - upstreamsByBranchName.set(localBranchName.replace(/$/u, ''), parsedUpstream) - retainedBytes += entryBytes + upstreamsByBranchName.set(localBranchName, parseTrackedUpstreamRef(upstreamRef ?? '')) } return upstreamsByBranchName } -export function _parseTrackedUpstreamBranchesForTests( - stdout: string, - requestedBranchName?: string -): Map { - return parseTrackedUpstreamBranches(stdout, requestedBranchName) -} - -function measureTrackedUpstreamEntryBytes( - branchName: string, - upstream: TrackedUpstreamBranch | null -): number | null { - let remainingBytes = TRACKED_UPSTREAM_SNAPSHOT_MAX_BYTES - 64 - let bytes = 64 - for (const value of [branchName, upstream?.remoteName ?? '', upstream?.branchName ?? '']) { - const measured = measureUtf8ByteLength(value, { stopAfterBytes: remainingBytes }) - if (measured.exceededLimit) { - return null - } - remainingBytes -= measured.byteLength - bytes += measured.byteLength - } - return bytes <= TRACKED_UPSTREAM_SNAPSHOT_MAX_BYTES ? bytes : null -} - -function measureTrackedUpstreamSnapshotBytes( - upstreamsByBranchName: Map -): number { - let bytes = 0 - for (const [branchName, upstream] of upstreamsByBranchName) { - bytes += measureTrackedUpstreamEntryBytes(branchName, upstream) ?? 0 - } - return bytes -} - function parseTrackedUpstreamRef(upstreamRef: string): TrackedUpstreamBranch | null { const remoteRefPrefix = 'refs/remotes/' const normalizedRef = upstreamRef.trim() @@ -3871,9 +3787,7 @@ async function attachFailedJobLogTails( // Why: cap log fetches so failed-job details stay a bounded follow-up, not a burst of hosted log downloads. for (const job of failedJobs) { const jobCacheKey = getCheckJobLogTailCacheKey(job) - const cacheKey = jobCacheKey - ? cacheIdentityDigest([githubRepoIdentityKey(ownerRepo), jobCacheKey]) - : null + const cacheKey = jobCacheKey ? `${githubRepoIdentityKey(ownerRepo)}:${jobCacheKey}` : null if (!cacheKey) { continue } diff --git a/src/main/github/conflict-summary-bounds.test.ts b/src/main/github/conflict-summary-bounds.test.ts deleted file mode 100644 index 5196f66726b..00000000000 --- a/src/main/github/conflict-summary-bounds.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - parseMergeTreeNameOnlyOutput, - PR_CONFLICT_FILES_MAX_BYTES, - PR_CONFLICT_FILES_MAX_ENTRIES -} from './conflict-summary' - -describe('PR conflict summary retention bounds', () => { - it('caps retained conflict file count', () => { - const stdout = [ - 'tree-oid', - ...Array.from({ length: PR_CONFLICT_FILES_MAX_ENTRIES + 1 }, (_, index) => `file-${index}`) - ].join('\0') - const files = parseMergeTreeNameOnlyOutput(stdout) - expect(files).toHaveLength(PR_CONFLICT_FILES_MAX_ENTRIES) - expect(files.at(-1)).toBe(`file-${PR_CONFLICT_FILES_MAX_ENTRIES - 1}`) - }) - - it('stops before retaining an oversized path', () => { - const files = parseMergeTreeNameOnlyOutput( - `tree-oid\0${'x'.repeat(PR_CONFLICT_FILES_MAX_BYTES + 1)}\0later` - ) - expect(files).toEqual([]) - }) -}) diff --git a/src/main/github/conflict-summary-cache.ts b/src/main/github/conflict-summary-cache.ts index 654df1005ee..99a2715ce6d 100644 --- a/src/main/github/conflict-summary-cache.ts +++ b/src/main/github/conflict-summary-cache.ts @@ -1,5 +1,4 @@ import type { PRConflictSummary } from '../../shared/types' -import { cacheIdentityDigest } from '../cache-identity-digest' // Why 60s: the hottest coordinator cadences that re-derive a CONFLICTING PR // (10s mergeability-pending, 2.5s manual-pending) previously each ran a @@ -14,7 +13,6 @@ export const CONFLICT_SUMMARY_BASE_FETCH_WINDOW_MS = 60_000 // that stop refreshing can't accumulate entries forever. const BASE_OID_CACHE_MAX = 64 const SUMMARY_CACHE_MAX = 128 -const CONFLICT_SUMMARY_MAX_IN_FLIGHT = 32 export type FreshBaseTipResolution = | { kind: 'resolved'; oid: string } @@ -44,10 +42,11 @@ export function getConflictSummaryGitRuntimeKey(wslDistro: string | undefined): return wslDistro ? `wsl:${wslDistro}` : 'local:host' } -// Why digest: keys include arbitrary repo paths and refs; a length-framed -// digest prevents delimiter aliases without retaining those strings. +// Why JSON: repo paths and git ref names may contain any printable joiner +// character (git allows `|` in branch names), so a delimiter-joined key could +// alias distinct identities onto one cache entry. export function buildConflictSummaryCacheKey(...parts: string[]): string { - return cacheIdentityDigest(parts) + return JSON.stringify(parts) } export function readFreshBaseTipResolution(baseKey: string): FreshBaseTipResolution | null { @@ -122,14 +121,8 @@ function dedupeInFlight( if (existing) { return existing } - if (map.size >= CONFLICT_SUMMARY_MAX_IN_FLIGHT) { - return factory() - } - let promise!: Promise - promise = factory().finally(() => { - if (map.get(key) === promise) { - map.delete(key) - } + const promise = factory().finally(() => { + map.delete(key) }) map.set(key, promise) return promise diff --git a/src/main/github/conflict-summary.ts b/src/main/github/conflict-summary.ts index 392c41a6c30..73ec657977c 100644 --- a/src/main/github/conflict-summary.ts +++ b/src/main/github/conflict-summary.ts @@ -4,7 +4,6 @@ import { isUnsupportedMergeTreeWriteTreeError } from '../../shared/git-merge-tree-capability' import { gitExecFileAsync } from '../git/runner' -import { iterateNulDelimitedFields } from '../../shared/nul-delimited-fields' import { clearGitCapabilityStateForTests, getLocalGitCapabilityCache @@ -21,15 +20,11 @@ import { storeResolvedBaseTip, storeCachedSummary } from './conflict-summary-cache' -import { measureUtf8ByteLength } from '../../shared/utf8-byte-limits' type LocalGitExecOptions = { wslDistro?: string } -export const PR_CONFLICT_FILES_MAX_ENTRIES = 512 -export const PR_CONFLICT_FILES_MAX_BYTES = 256 * 1024 - export function __resetPRConflictSummaryCachesForTests(): void { clearGitCapabilityStateForTests() __resetPRConflictSummaryDerivationCachesForTests() @@ -306,27 +301,13 @@ async function loadConflictingFilesWithLegacyMergeTree( } } -export function parseMergeTreeNameOnlyOutput(stdout: string): string[] { - const files: string[] = [] - let skippedTreeId = false - let retainedBytes = 0 - for (const entry of iterateNulDelimitedFields(stdout)) { - if (!entry) { - continue - } - if (!skippedTreeId) { - skippedTreeId = true - continue - } - const measured = measureUtf8ByteLength(entry, { - stopAfterBytes: PR_CONFLICT_FILES_MAX_BYTES - retainedBytes - }) - if (measured.exceededLimit || files.length >= PR_CONFLICT_FILES_MAX_ENTRIES) { - break - } - files.push(entry.replace(/$/u, '')) - retainedBytes += measured.byteLength +function parseMergeTreeNameOnlyOutput(stdout: string): string[] { + const entries = stdout.split('\0').filter(Boolean) + if (entries.length === 0) { + return [] } + + const [, ...files] = entries return files } diff --git a/src/main/github/gh-utils.test.ts b/src/main/github/gh-utils.test.ts index 23f3879c896..04c76eb5e53 100644 --- a/src/main/github/gh-utils.test.ts +++ b/src/main/github/gh-utils.test.ts @@ -678,35 +678,6 @@ describe('github owner/repo resolution', () => { await rm(repoPath, { recursive: true, force: true }) } }) - - it('parses exact-limit config files and safely skips oversized include graphs', async () => { - const repoPath = await mkdtemp(join(tmpdir(), 'orca-gh-utils-')) - const gitDir = join(repoPath, '.git') - const includedConfigPath = join(repoPath, 'included.gitconfig') - const configPath = join(gitDir, 'config') - const maxConfigBytes = 4 * 1024 * 1024 - await mkdir(gitDir) - await writeFile(includedConfigPath, '[user]\n\tname = first\n') - const includePrefix = `[include]\n\tpath = "${includedConfigPath}"\n#` - await writeFile(configPath, includePrefix + 'x'.repeat(maxConfigBytes - includePrefix.length)) - try { - const exactFirst = await readLocalGitConfigSignature({ repoPath, connectionId: null }) - await writeFile(includedConfigPath, '[user]\n\tname = exact-limit-change\n') - const exactSecond = await readLocalGitConfigSignature({ repoPath, connectionId: null }) - expect(exactSecond).not.toEqual(exactFirst) - - await writeFile( - configPath, - includePrefix + 'x'.repeat(maxConfigBytes + 1 - includePrefix.length) - ) - const oversizedFirst = await readLocalGitConfigSignature({ repoPath, connectionId: null }) - await writeFile(includedConfigPath, '[user]\n\tname = ignored-oversized-change\n') - const oversizedSecond = await readLocalGitConfigSignature({ repoPath, connectionId: null }) - expect(oversizedSecond).toEqual(oversizedFirst) - } finally { - await rm(repoPath, { recursive: true, force: true }) - } - }) }) describe('resolveIssueSource', () => { diff --git a/src/main/github/gh-utils.ts b/src/main/github/gh-utils.ts index ab81e4e3630..b294e90ac1a 100644 --- a/src/main/github/gh-utils.ts +++ b/src/main/github/gh-utils.ts @@ -4,7 +4,6 @@ import { gitExecFileAsync, ghExecFileAsync } from '../git/runner' // Pure error-parsing helpers come from the lightweight module (not `runner`) so // tests that mock `../git/runner` still resolve the real implementations. import { extractExecError, parseRetryAfterMs } from '../git/exec-error' -import { IntegrationApiConcurrencyGate } from '../integration-api-concurrency' // Why: legacy generic execFile wrapper - only used by callers that don't need // WSL-aware routing. Repo-scoped callers should use the runner exports below. @@ -35,12 +34,26 @@ export type { } from './github-repository-identity' const MAX_CONCURRENT = 4 -const concurrencyGate = new IntegrationApiConcurrencyGate(MAX_CONCURRENT) +let running = 0 +const queue: (() => void)[] = [] export function acquire(): Promise { - return concurrencyGate.acquire() + if (running < MAX_CONCURRENT) { + running += 1 + return Promise.resolve() + } + return new Promise((resolve) => + queue.push(() => { + running += 1 + resolve() + }) + ) } export function release(): void { - concurrencyGate.release() + running -= 1 + const next = queue.shift() + if (next) { + next() + } } diff --git a/src/main/github/github-api-repository-validation.ts b/src/main/github/github-api-repository-validation.ts deleted file mode 100644 index a3ab47471f8..00000000000 --- a/src/main/github/github-api-repository-validation.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { GitHubOwnerRepo } from '../../shared/types' - -const GITHUB_OWNER_SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9-]*$/ -const GITHUB_REPO_SLUG_RE = /^[A-Za-z0-9._-]+$/ - -// Why: renderer/RPC overrides are interpolated into authenticated REST paths. -export function isValidGitHubApiRepository(repository: GitHubOwnerRepo): boolean { - return ( - GITHUB_OWNER_SLUG_RE.test(repository.owner) && - GITHUB_REPO_SLUG_RE.test(repository.repo) && - repository.repo !== '.' && - repository.repo !== '..' - ) -} diff --git a/src/main/github/github-api-repository.ts b/src/main/github/github-api-repository.ts index 16965705148..08461f849e9 100644 --- a/src/main/github/github-api-repository.ts +++ b/src/main/github/github-api-repository.ts @@ -14,8 +14,6 @@ import { getEnterpriseGitHubRepoSlugForRemote, isGitHubHostAuthenticated } from './github-enterprise-repository' -import { isValidGitHubApiRepository } from './github-api-repository-validation' -import { cacheIdentityDigest } from '../cache-identity-digest' export type GitHubApiRepository = GitHubOwnerRepo export type GitHubRepoExecOptions = ReturnType & { host?: string } @@ -30,12 +28,25 @@ type GitHubApiRepositoryResolution = | undefined | (() => Promise) +// Why: renderer/RPC repository overrides are interpolated into REST paths. +// Reject path syntax before an authenticated gh process can target it. +const GITHUB_OWNER_SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9-]*$/ +const GITHUB_REPO_SLUG_RE = /^[A-Za-z0-9._-]+$/ + +function isValidGitHubApiRepository(repository: GitHubApiRepository): boolean { + return ( + GITHUB_OWNER_SLUG_RE.test(repository.owner) && + GITHUB_REPO_SLUG_RE.test(repository.repo) && + repository.repo !== '.' && + repository.repo !== '..' + ) +} + // Why: the enterprise branch spawns an uncached `git remote get-url` (an SSH // round trip on connection-backed repos) — hot paths like per-file contents // and viewed-state toggles resolve per call, so cache like ownerRepoCache does. const ORIGIN_REPO_CACHE_TTL_MS = 30_000 const ORIGIN_REPO_CACHE_MAX_ENTRIES = 512 -const ORIGIN_REPO_MAX_IN_FLIGHT = 32 const originRepoCache = new Map() const originRepoInFlight = new Map>() @@ -45,12 +56,7 @@ function originRepoCacheKey( connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): string { - return cacheIdentityDigest([ - connectionId ?? 'local', - localGitOptions.wslDistro ?? '', - repoPath, - remoteName - ]) + return `${connectionId ?? 'local'}\0${localGitOptions.wslDistro ?? ''}\0${repoPath}\0${remoteName}` } /** @internal - exposed for tests only */ @@ -130,9 +136,6 @@ export async function getGitHubApiRepositoryForRemote( } return slug ?? null })() - if (originRepoInFlight.size >= ORIGIN_REPO_MAX_IN_FLIGHT) { - return probe - } originRepoInFlight.set(cacheKey, probe) try { return await probe diff --git a/src/main/github/github-enterprise-repository.ts b/src/main/github/github-enterprise-repository.ts index 87d5acde4ea..f0277068471 100644 --- a/src/main/github/github-enterprise-repository.ts +++ b/src/main/github/github-enterprise-repository.ts @@ -13,7 +13,6 @@ import { type LocalGitExecOptions } from './github-repository-identity' import { parseWslPath } from '../wsl' -import { cacheIdentityDigest } from '../cache-identity-digest' export type GitHubEnterpriseRepoSlug = GitHubOwnerRepo & { host: string } @@ -23,8 +22,6 @@ export type GitHubEnterpriseRepoSlug = GitHubOwnerRepo & { host: string } // GHES remote is not left to fall through to Gitea (#8312). const HOST_AUTH_TTL_MS = 60_000 const HOST_AUTH_CACHE_MAX_ENTRIES = 512 -const HOST_AUTH_MAX_IN_FLIGHT = 16 -const GITHUB_HOST_MAX_CHARS = 1024 type HostAuthCacheEntry = { authenticatedHost: string | null @@ -85,9 +82,6 @@ type NormalizedGitHubHost = { } function normalizeGitHubHost(host: string): NormalizedGitHubHost | null { - if (host.length > GITHUB_HOST_MAX_CHARS) { - return null - } const match = host .trim() .toLowerCase() @@ -134,14 +128,8 @@ async function resolveAuthenticatedGitHubHost( connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { - const normalizedHost = normalizeGitHubHost(host)?.authority - if (!normalizedHost) { - return null - } - const cacheKey = cacheIdentityDigest([ - runtimeCacheKey(repoPath, localGitOptions.wslDistro), - normalizedHost - ]) + const normalizedHost = normalizeGitHubHost(host)?.authority ?? host.trim().toLowerCase() + const cacheKey = `${runtimeCacheKey(repoPath, localGitOptions.wslDistro)}\0${normalizedHost}` const now = Date.now() pruneHostAuthCache(now) const cached = hostAuthCache.get(cacheKey) @@ -180,9 +168,6 @@ async function resolveAuthenticatedGitHubHost( pruneHostAuthCache(Date.now()) return authenticatedHost })() - if (hostAuthInFlight.size >= HOST_AUTH_MAX_IN_FLIGHT) { - return probe - } hostAuthInFlight.set(cacheKey, probe) try { return await probe diff --git a/src/main/github/github-remote-identity-parsing.test.ts b/src/main/github/github-remote-identity-parsing.test.ts index 0f7c70249c1..c0b8b599520 100644 --- a/src/main/github/github-remote-identity-parsing.test.ts +++ b/src/main/github/github-remote-identity-parsing.test.ts @@ -1,22 +1,8 @@ import { describe, expect, it } from 'vitest' -import { - GITHUB_REMOTE_REPO_MAX_BYTES, - GITHUB_REMOTE_URL_MAX_BYTES, - parseGitHubOwnerRepo, - parseGitHubRemoteIdentity -} from './github-remote-identity-parsing' +import { parseGitHubOwnerRepo, parseGitHubRemoteIdentity } from './github-remote-identity-parsing' describe('parseGitHubRemoteIdentity', () => { - it('rejects oversized remote URLs and identity fields before retention', () => { - expect(parseGitHubRemoteIdentity('x'.repeat(GITHUB_REMOTE_URL_MAX_BYTES + 1))).toBeNull() - expect( - parseGitHubRemoteIdentity( - `git@github.com:owner/${'r'.repeat(GITHUB_REMOTE_REPO_MAX_BYTES + 1)}.git` - ) - ).toBeNull() - }) - it('parses a plain github.com https remote', () => { expect(parseGitHubRemoteIdentity('https://github.com/team/orca.git')).toEqual({ host: 'github.com', diff --git a/src/main/github/github-remote-identity-parsing.ts b/src/main/github/github-remote-identity-parsing.ts index 7ec2f10dd33..6f13e62014d 100644 --- a/src/main/github/github-remote-identity-parsing.ts +++ b/src/main/github/github-remote-identity-parsing.ts @@ -1,15 +1,6 @@ import type { GitHubOwnerRepo } from '../../shared/types' -import { measureUtf8ByteLength } from '../../shared/utf8-byte-limits' export type GitHubRemoteIdentity = GitHubOwnerRepo & { host: string } -export const GITHUB_REMOTE_URL_MAX_BYTES = 64 * 1024 -export const GITHUB_REMOTE_HOST_MAX_BYTES = 1024 -export const GITHUB_REMOTE_OWNER_MAX_BYTES = 256 -export const GITHUB_REMOTE_REPO_MAX_BYTES = 1024 - -function fitsRemoteField(value: string, maxBytes: number): boolean { - return !measureUtf8ByteLength(value, { stopAfterBytes: maxBytes }).exceededLimit -} function normalizeGitHubRemoteHost(host: string): string { const normalizedHost = host.toLowerCase() @@ -31,31 +22,16 @@ function parseGitHubRemotePath(path: string): Pick { const context = githubRepoContext(repoPath, connectionId, localGitOptions) const runtimeKey = context.connectionId ?? `local:${context.wslDistro ?? 'host'}` - const cacheKey = cacheIdentityDigest([runtimeKey, context.repoPath, remoteName]) + const cacheKey = `${runtimeKey}\0${context.repoPath}\0${remoteName}` const now = Date.now() pruneOwnerRepoCache(now) const cached = ownerRepoCache.get(cacheKey) @@ -160,9 +158,6 @@ export async function getOwnerRepoForRemote( // Why: startup can resolve issue sources, PR candidates, and repo metadata // for the same repo concurrently. Coalesce missing-remote probes. const probe = resolveOwnerRepoForRemote(context, remoteName, cacheKey, nextConfigSignature) - if (ownerRepoInFlight.size >= OWNER_REPO_MAX_IN_FLIGHT) { - return probe - } ownerRepoInFlight.set(cacheKey, probe) try { return await probe diff --git a/src/main/github/local-git-config-signature.ts b/src/main/github/local-git-config-signature.ts index 350650d78fc..1a3c297d5f7 100644 --- a/src/main/github/local-git-config-signature.ts +++ b/src/main/github/local-git-config-signature.ts @@ -1,11 +1,7 @@ -import { stat } from 'node:fs/promises' -import { createHash } from 'node:crypto' +import { readFile, stat } from 'node:fs/promises' import { homedir } from 'node:os' import { dirname, isAbsolute, join, resolve } from 'node:path' -import { readNodeFileWithinLimit } from '../../shared/node-bounded-file-reader' -import { measureUtf8ByteLength } from '../../shared/utf8-byte-limits' import type { GitHubRepoContext } from './github-repository-identity' -import { cacheIdentityDigest } from '../cache-identity-digest' type LocalGitConfigPaths = { commonConfigPath: string @@ -13,18 +9,6 @@ type LocalGitConfigPaths = { } const localGitConfigSignatureInFlight = new Map>() -const LOCAL_GIT_CONFIG_SIGNATURE_MAX_IN_FLIGHT = 32 -const MAX_GIT_CONFIG_BYTES = 4 * 1024 * 1024 -const MAX_GIT_POINTER_FILE_BYTES = 64 * 1024 -const MAX_INCLUDED_CONFIG_FILES = 256 -const MAX_INCLUDED_CONFIG_DEPTH = 8 -const MAX_INCLUDED_CONFIG_PATH_BYTES = 16 * 1024 -const MAX_INCLUDED_CONFIG_AGGREGATE_PATH_BYTES = 2 * 1024 * 1024 - -type ConfigSignatureBudget = { - admittedFiles: number - pathBytes: number -} export async function readLocalGitConfigSignature( context: GitHubRepoContext @@ -34,16 +18,13 @@ export async function readLocalGitConfigSignature( // runtimes are already separated by cache key and probed through git. return undefined } - const cacheKey = cacheIdentityDigest([context.repoPath]) + const cacheKey = context.repoPath const inFlight = localGitConfigSignatureInFlight.get(cacheKey) if (inFlight) { return inFlight } const read = readUncachedLocalGitConfigSignature(context.repoPath) - if (localGitConfigSignatureInFlight.size >= LOCAL_GIT_CONFIG_SIGNATURE_MAX_IN_FLIGHT) { - return read - } localGitConfigSignatureInFlight.set(cacheKey, read) try { return await read @@ -67,54 +48,31 @@ async function readUncachedLocalGitConfigSignature(repoPath: string): Promise(), - budget: ConfigSignatureBudget = { admittedFiles: 0, pathBytes: 0 }, - depth = 0 + visited = new Set() ): Promise { if (visited.has(configPath)) { return [] } - const measuredPath = measureUtf8ByteLength(configPath, { - stopAfterBytes: MAX_INCLUDED_CONFIG_PATH_BYTES - }) - if ( - measuredPath.exceededLimit || - depth > MAX_INCLUDED_CONFIG_DEPTH || - budget.admittedFiles >= MAX_INCLUDED_CONFIG_FILES || - budget.pathBytes + measuredPath.byteLength > MAX_INCLUDED_CONFIG_AGGREGATE_PATH_BYTES - ) { - return [] - } visited.add(configPath) - budget.admittedFiles += 1 - budget.pathBytes += measuredPath.byteLength const ownSignature = await readConfigPathSignature(configPath) let configText: string try { - configText = (await readNodeFileWithinLimit(configPath, MAX_GIT_CONFIG_BYTES)).buffer.toString( - 'utf8' - ) + configText = await readFile(configPath, 'utf8') } catch { return [ownSignature] } const includedPaths = parseIncludedConfigPaths(configText, dirname(configPath)) - const signatures = [ownSignature] - for (const includedPath of includedPaths) { - signatures.push(...(await readConfigPathSignatures(includedPath, visited, budget, depth + 1))) - } - return signatures + const includedSignatures = await Promise.all( + includedPaths.map((includedPath) => readConfigPathSignatures(includedPath, visited)) + ) + return [ownSignature, ...includedSignatures.flat()] } async function readConfigPathSignature(configPath: string): Promise { @@ -145,9 +103,6 @@ function parseIncludedConfigPaths(configText: string, baseDir: string): string[] const includePath = parseIncludedConfigPath(line) if (includePath) { includedPaths.push(resolveIncludedConfigPath(includePath, baseDir)) - if (includedPaths.length >= MAX_INCLUDED_CONFIG_FILES) { - break - } } } return includedPaths @@ -259,9 +214,7 @@ async function resolveLocalGitConfigPaths(repoPath: string): Promise[1] @@ -62,11 +61,7 @@ export async function isCommitPartOfMergedPR(args: { } const owner = args.ownerRepo.owner const repo = args.ownerRepo.repo - const cacheKey = cacheIdentityDigest([ - githubRepoIdentityKey(args.ownerRepo), - String(args.prNumber), - oid - ]) + const cacheKey = `${githubRepoIdentityKey(args.ownerRepo)}#${args.prNumber}@${oid}` const ghOptions = { ...args.ghOptions, ...githubHostExecOptions(args.ownerRepo) } const now = Date.now() pruneMergedPRCommitMembershipCache(now) diff --git a/src/main/github/pr-refresh-coordinator-memory.test.ts b/src/main/github/pr-refresh-coordinator-memory.test.ts deleted file mode 100644 index 57472c2e50a..00000000000 --- a/src/main/github/pr-refresh-coordinator-memory.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { GitHubPRRefreshCandidate } from '../../shared/types' -import { - PR_REFRESH_ALIAS_LIMIT, - PR_REFRESH_QUEUE_ENTRY_LIMIT -} from '../../shared/pr-refresh-memory-limits' - -const { sendToTrustedUIRendererMock } = vi.hoisted(() => ({ - sendToTrustedUIRendererMock: vi.fn() -})) - -vi.mock('electron', () => ({ - webContents: { getAllWebContents: () => [] } -})) -vi.mock('./client', () => ({ getPRForBranchOutcome: vi.fn() })) -vi.mock('./github-api-repository', () => ({ getOriginGitHubApiRepository: vi.fn() })) -vi.mock('./rate-limit', () => ({ - getRateLimit: vi.fn(), - noteRepositoryRateLimitSpend: vi.fn(), - repositoryRateLimitGuard: vi.fn(() => ({ blocked: false })), - spendsSharedGitHubComQuota: vi.fn(() => false) -})) -vi.mock('../crash-reporting/crash-breadcrumb-store', () => ({ - recordCoalescedCrashBreadcrumb: vi.fn() -})) -vi.mock('../ipc/ui', () => ({ sendToTrustedUIRenderer: sendToTrustedUIRendererMock })) - -function candidate(index: number, linkedPRNumber?: number): GitHubPRRefreshCandidate { - return { - cacheKey: `/repo-${index}::feature-${index}`, - repoPath: linkedPRNumber === undefined ? `/repo-${index}` : '/repo', - branch: `feature-${index}`, - repoKind: 'git', - repoId: `repo-${index}`, - worktreeId: `worktree-${index}`, - cachedFetchedAt: Date.now(), - linkedPRNumber - } -} - -describe('PR refresh coordinator memory admission', () => { - beforeEach(() => { - vi.resetModules() - vi.useFakeTimers() - vi.setSystemTime(1_000) - sendToTrustedUIRendererMock.mockReset() - }) - - afterEach(() => { - vi.useRealTimers() - }) - - it('caps distinct queued refreshes while GitHub work is delayed', async () => { - const { enqueuePRRefresh, _getPRRefreshQueueSizeForTests } = - await import('./pr-refresh-coordinator') - - for (let index = 0; index <= PR_REFRESH_QUEUE_ENTRY_LIMIT; index += 1) { - enqueuePRRefresh(candidate(index), 'visible', 40, 1) - } - - expect(_getPRRefreshQueueSizeForTests()).toBe(PR_REFRESH_QUEUE_ENTRY_LIMIT) - expect(sendToTrustedUIRendererMock).toHaveBeenLastCalledWith( - 'gh:prRefreshEvent', - expect.objectContaining({ status: 'skipped', skippedReason: 'capacity' }) - ) - }) - - it('caps aliases coalesced behind one linked review', async () => { - const { enqueuePRRefresh, _getPRRefreshAliasCountForTests } = - await import('./pr-refresh-coordinator') - - for (let index = 0; index <= PR_REFRESH_ALIAS_LIMIT; index += 1) { - enqueuePRRefresh(candidate(index, 42), 'visible', 40, 1) - } - - expect(_getPRRefreshAliasCountForTests('local::runtime:host::/repo::pr::42')).toBe( - PR_REFRESH_ALIAS_LIMIT - ) - expect(sendToTrustedUIRendererMock).toHaveBeenCalledWith( - 'gh:prRefreshEvent', - expect.objectContaining({ status: 'skipped', skippedReason: 'capacity' }) - ) - }) -}) diff --git a/src/main/github/pr-refresh-coordinator.ts b/src/main/github/pr-refresh-coordinator.ts index a4197c50539..70b13ec2e69 100644 --- a/src/main/github/pr-refresh-coordinator.ts +++ b/src/main/github/pr-refresh-coordinator.ts @@ -11,14 +11,6 @@ import type { import { getPRForBranchOutcome, type GitHubPRBranchLookupOptions } from './client' import { getOriginGitHubApiRepository } from './github-api-repository' import { ghRepoExecOptions, githubRepoContext } from './gh-utils' -import { - boundedVisiblePRRefreshCandidates, - PR_REFRESH_ACTIVE_SCOPE_LIMIT, - PR_REFRESH_QUEUE_ENTRY_LIMIT, - PR_REFRESH_RETRY_STATE_LIMIT, - retainPRRefreshAlias, - retainPRRefreshState -} from './pr-refresh-memory-bounds' import { getRateLimit, noteRepositoryRateLimitSpend, @@ -105,19 +97,8 @@ let lastBackgroundStartAt = 0 * Only a rate-limit outcome carrying `retryDisabledUntil` sets a gate; any other settled outcome clears it. */ function noteManualRetryGate(key: string, outcome: PRRefreshOutcome): void { - const now = Date.now() - for (const [gateKey, retryAt] of manualRetryGates) { - if (retryAt <= now) { - manualRetryGates.delete(gateKey) - } - } if (outcome.kind === 'upstream-error' && outcome.retryDisabledUntil !== undefined) { - retainPRRefreshState( - manualRetryGates, - key, - outcome.retryDisabledUntil, - PR_REFRESH_RETRY_STATE_LIMIT - ) + manualRetryGates.set(key, outcome.retryDisabledUntil) } else { manualRetryGates.delete(key) } @@ -137,14 +118,6 @@ const diagnosticsCounters = { backgroundPauses: 0 } -function setBoundedQueueEntry(entry: QueueEntry): boolean { - if (!queue.has(entry.key) && queue.size >= PR_REFRESH_QUEUE_ENTRY_LIMIT) { - return false - } - queue.set(entry.key, entry) - return true -} - export function setPRRefreshOutcomeObserver(observer: PRRefreshOutcomeObserver | null): void { outcomeObserver = observer } @@ -185,26 +158,6 @@ function recordPRRefreshQueueDiagnostic( }) } -function broadcastCapacitySkip( - aliases: GitHubPRRefreshAlias[], - reason: GitHubPRRefreshReason -): void { - diagnosticsCounters.skipped += 1 - recordPRRefreshQueueDiagnostic('skipped', reason, 'capacity') - broadcast({ aliases, reason, status: 'skipped', skippedReason: 'capacity' }) -} - -function addBoundedQueueAlias( - entry: QueueEntry, - alias: GitHubPRRefreshAlias, - reason: GitHubPRRefreshReason -): void { - const evicted = retainPRRefreshAlias(entry.aliases, alias, entry.candidate.cacheKey) - if (evicted) { - broadcastCapacitySkip([evicted], reason) - } -} - function clearActiveBurstWindow(windowId: number): void { const windowPrefix = `${windowId}::` for (const scope of Array.from(activeStartsByScope.keys())) { @@ -411,18 +364,15 @@ function visibleCandidateAfterOutcome( } } -function setVisibleFollowUp(entry: QueueEntry): boolean { +function setVisibleFollowUp(entry: QueueEntry): void { const existing = queue.get(entry.key) if (!existing) { - if (!setBoundedQueueEntry(entry)) { - broadcastCapacitySkip(Array.from(entry.aliases.values()), entry.reason) - return false - } - return true + queue.set(entry.key, entry) + return } for (const alias of entry.aliases.values()) { - addBoundedQueueAlias(existing, alias, entry.reason) + existing.aliases.set(alias.cacheKey, alias) } // Why: a user activation can arrive while a background refresh awaits gh; the follow-up must not overwrite that pending active/manual work. @@ -431,14 +381,13 @@ function setVisibleFollowUp(entry: QueueEntry): boolean { existing.priority > entry.priority || existing.dueAt <= entry.dueAt ) { - return true + return } - setBoundedQueueEntry({ + queue.set(entry.key, { ...entry, aliases: existing.aliases }) - return true } function removeQueuedAliasForInvalidCandidate(key: string, alias: GitHubPRRefreshAlias): void { @@ -477,7 +426,7 @@ function nextVisibleErrorRetryAt(key: string): number { const failures = (errorBackoff.get(key)?.failures ?? 0) + 1 const retryAt = Date.now() + Math.min(BACKOFF_MAX_MS, BACKOFF_BASE_MS * 2 ** Math.min(failures - 1, 4)) - retainPRRefreshState(errorBackoff, key, { failures, retryAt }, PR_REFRESH_RETRY_STATE_LIMIT) + errorBackoff.set(key, { failures, retryAt }) return retryAt } @@ -514,7 +463,7 @@ function scheduleVisibleFollowUp( if (outcome.kind === 'upstream-error') { // Why: reuse the retry time already computed for the broadcast so the same failure isn't counted twice against the backoff. const retryAt = options?.plannedRetryAt ?? nextVisibleErrorRetryAt(key) - const retained = setVisibleFollowUp({ + setVisibleFollowUp({ key, candidate, aliases: new Map(aliases.map((alias) => [alias.cacheKey, alias])), @@ -524,10 +473,6 @@ function scheduleVisibleFollowUp( queuedAt: nextQueueOrder(), windowId }) - if (!retained) { - resetKeyRetryState(key) - return - } // Why: this is a delayed retry, not active work; a spinner would make visible worktrees look stuck until backoff expires. scheduleDrain(retryAt - Date.now()) return @@ -544,7 +489,7 @@ function scheduleVisibleFollowUp( ? regularDueAt : Math.min(regularDueAt, pendingMergeabilityDueAt) // Why: a coalesced linked-PR refresh may represent several branches; preserve every alias so all cache entries keep getting updates. - const retained = setVisibleFollowUp({ + setVisibleFollowUp({ key, candidate: followUpCandidate, aliases: new Map(aliases.map((alias) => [alias.cacheKey, alias])), @@ -556,10 +501,6 @@ function scheduleVisibleFollowUp( bypassBackgroundBudget: pendingMergeabilityDueAt !== null, windowId }) - if (!retained) { - resetKeyRetryState(key) - return - } scheduleDrain(Math.max(0, dueAt - Date.now())) } @@ -654,12 +595,6 @@ function pruneActiveStarts(scope: string, now: number): number[] { return activeStarts } -function pruneExpiredActiveScopes(now: number): void { - for (const scope of Array.from(activeStartsByScope.keys())) { - pruneActiveStarts(scope, now) - } -} - function nextActiveBurstDelay(entry: QueueEntry): number { const now = Date.now() const activeStarts = pruneActiveStarts(activeBurstScope(entry), now) @@ -672,10 +607,9 @@ function nextActiveBurstDelay(entry: QueueEntry): number { function noteActiveStart(entry: QueueEntry): void { const now = Date.now() const scope = activeBurstScope(entry) - pruneExpiredActiveScopes(now) const activeStarts = pruneActiveStarts(scope, now) activeStarts.push(now) - retainPRRefreshState(activeStartsByScope, scope, activeStarts, PR_REFRESH_ACTIVE_SCOPE_LIMIT) + activeStartsByScope.set(scope, activeStarts) } function activeOrder(a: QueueEntry, b: QueueEntry): number { @@ -824,12 +758,7 @@ async function drainQueue(): Promise { .find((guard) => guard.blocked) if (blockedGuard?.blocked) { const retryAt = blockedGuard.resetAt * 1000 - const retained = setBoundedQueueEntry({ ...next, dueAt: retryAt }) - if (!retained) { - resetKeyRetryState(next.key) - broadcastCapacitySkip(aliases, next.reason) - continue - } + queue.set(next.key, { ...next, dueAt: retryAt }) broadcast({ aliases, reason: next.reason, @@ -916,7 +845,7 @@ export function enqueuePRRefresh( const freshDueAt = shouldSkipFresh(candidate, reason) ? freshRetryAt(candidate) : null const dueAt = freshDueAt ?? Date.now() + (reason === 'post-push' ? POST_PUSH_DELAY_MS : 0) if (existing) { - addBoundedQueueAlias(existing, alias, reason) + existing.aliases.set(alias.cacheKey, alias) diagnosticsCounters.coalesced += 1 recordPRRefreshQueueDiagnostic('coalesced', reason) const shouldPromoteExisting = @@ -942,13 +871,9 @@ export function enqueuePRRefresh( } } } else { - if (queue.size >= PR_REFRESH_QUEUE_ENTRY_LIMIT) { - broadcastCapacitySkip([alias], reason) - return - } diagnosticsCounters.enqueued += 1 recordPRRefreshQueueDiagnostic('enqueued', reason) - setBoundedQueueEntry({ + queue.set(key, { key, candidate, aliases: new Map([[alias.cacheKey, alias]]), @@ -975,13 +900,9 @@ export function reportVisiblePRRefreshCandidates( if (existingVisible && generation < existingVisible.generation) { return } - const retainedCandidates = boundedVisiblePRRefreshCandidates(candidates) - visibleByWindow.set(windowId, { - generation, - keys: new Set(retainedCandidates.map(refreshKey)) - }) + visibleByWindow.set(windowId, { generation, keys: new Set(candidates.map(refreshKey)) }) removeInvisibleVisibleRefreshes() - for (const candidate of retainedCandidates) { + for (const candidate of candidates) { enqueuePRRefresh(candidate, 'visible', 40, windowId) } } @@ -1007,14 +928,7 @@ export async function refreshPRNow(candidate: GitHubPRRefreshCandidate): Promise const key = refreshKey(candidate) const existing = queue.get(key) const aliasMap = new Map(existing ? existing.aliases : []) - const evictedAlias = retainPRRefreshAlias( - aliasMap, - alias, - existing?.candidate.cacheKey ?? alias.cacheKey - ) - if (evictedAlias) { - broadcastCapacitySkip([evictedAlias], 'manual') - } + aliasMap.set(alias.cacheKey, alias) const aliases = Array.from(aliasMap.values()) const skippedReason = validateCandidate(candidate) if (skippedReason) { @@ -1049,7 +963,7 @@ export async function refreshPRNow(candidate: GitHubPRRefreshCandidate): Promise if (gateUntil > Date.now()) { const retryAt = gateUntil // Why: paused maps `pausedUntil` into the renderer's auto-retry, so requeue at reset (finding 12) — don't advertise an unscheduled retry. - const retained = setBoundedQueueEntry({ + queue.set(key, { key, candidate, aliases: aliasMap, @@ -1058,16 +972,6 @@ export async function refreshPRNow(candidate: GitHubPRRefreshCandidate): Promise dueAt: retryAt, queuedAt: nextQueueOrder() }) - if (!retained) { - broadcastCapacitySkip(aliases, 'manual') - return { - kind: 'upstream-error', - errorType: 'rate_limited', - message: 'GitHub is temporarily limiting requests. Try again after the limit resets.', - fetchedAt: Date.now(), - retryDisabledUntil: retryAt - } - } broadcast({ aliases, reason: 'manual', diff --git a/src/main/github/pr-refresh-memory-bounds.test.ts b/src/main/github/pr-refresh-memory-bounds.test.ts deleted file mode 100644 index 9e703a6895c..00000000000 --- a/src/main/github/pr-refresh-memory-bounds.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { GitHubPRRefreshAlias, GitHubPRRefreshCandidate } from '../../shared/types' -import { - boundedVisiblePRRefreshCandidates, - PR_REFRESH_ALIAS_LIMIT, - PR_REFRESH_RETRY_STATE_LIMIT, - retainPRRefreshAlias, - retainPRRefreshState -} from './pr-refresh-memory-bounds' -import { PR_REFRESH_VISIBLE_CANDIDATE_LIMIT } from '../../shared/pr-refresh-memory-limits' - -function alias(index: number): GitHubPRRefreshAlias { - return { - cacheKey: `cache-${index}`, - repoId: 'repo-1', - repoPath: '/repo', - branch: `feature-${index}`, - worktreeId: `worktree-${index}`, - connectionId: null, - currentHeadOid: null, - linkedPRNumber: 42, - fallbackPRNumber: null, - fallbackPRSource: null - } -} - -function candidate(index: number): GitHubPRRefreshCandidate { - return { - cacheKey: `cache-${index}`, - repoId: 'repo-1', - repoPath: '/repo', - branch: `feature-${index}`, - worktreeId: `worktree-${index}`, - connectionId: null, - currentHeadOid: null, - linkedPRNumber: 42, - fallbackPRNumber: null, - fallbackPRSource: null, - repoKind: 'git', - cachedFetchedAt: null - } -} - -describe('PR refresh memory bounds', () => { - it('retains ordinary aliases without changing their identity or order', () => { - const aliases = new Map() - - expect(retainPRRefreshAlias(aliases, alias(0), 'cache-0')).toBeNull() - expect(retainPRRefreshAlias(aliases, alias(1), 'cache-0')).toBeNull() - - expect(Array.from(aliases.keys())).toEqual(['cache-0', 'cache-1']) - }) - - it('caps aliases while preserving the representative candidate', () => { - const aliases = new Map() - for (let index = 0; index < PR_REFRESH_ALIAS_LIMIT; index += 1) { - retainPRRefreshAlias(aliases, alias(index), 'cache-0') - } - - const evicted = retainPRRefreshAlias(aliases, alias(PR_REFRESH_ALIAS_LIMIT), 'cache-0') - - expect(aliases).toHaveLength(PR_REFRESH_ALIAS_LIMIT) - expect(aliases.has('cache-0')).toBe(true) - expect(aliases.has(`cache-${PR_REFRESH_ALIAS_LIMIT}`)).toBe(true) - expect(evicted?.cacheKey).toBe('cache-1') - }) - - it('caps retry state and refreshes existing keys without growing', () => { - const states = new Map() - for (let index = 0; index < PR_REFRESH_RETRY_STATE_LIMIT; index += 1) { - retainPRRefreshState(states, index, index, PR_REFRESH_RETRY_STATE_LIMIT) - } - - expect(retainPRRefreshState(states, 1, 99, PR_REFRESH_RETRY_STATE_LIMIT)).toBeNull() - expect( - retainPRRefreshState(states, PR_REFRESH_RETRY_STATE_LIMIT, 1, PR_REFRESH_RETRY_STATE_LIMIT) - ).toBe(0) - expect(states).toHaveLength(PR_REFRESH_RETRY_STATE_LIMIT) - expect(states.get(1)).toBe(99) - }) - - it('passes ordinary visible candidates through and truncates adversarial batches', () => { - const ordinary = [candidate(0), candidate(1)] - expect(boundedVisiblePRRefreshCandidates(ordinary)).toBe(ordinary) - - const oversized = Array.from({ length: PR_REFRESH_VISIBLE_CANDIDATE_LIMIT + 1 }, (_, index) => - candidate(index) - ) - expect(boundedVisiblePRRefreshCandidates(oversized)).toHaveLength( - PR_REFRESH_VISIBLE_CANDIDATE_LIMIT - ) - }) -}) diff --git a/src/main/github/pr-refresh-memory-bounds.ts b/src/main/github/pr-refresh-memory-bounds.ts deleted file mode 100644 index 27f57ae4fdc..00000000000 --- a/src/main/github/pr-refresh-memory-bounds.ts +++ /dev/null @@ -1,75 +0,0 @@ -import type { GitHubPRRefreshAlias, GitHubPRRefreshCandidate } from '../../shared/types' -import { - PR_REFRESH_ALIAS_LIMIT, - PR_REFRESH_VISIBLE_CANDIDATE_LIMIT -} from '../../shared/pr-refresh-memory-limits' - -export { - PR_REFRESH_ACTIVE_SCOPE_LIMIT, - PR_REFRESH_ALIAS_LIMIT, - PR_REFRESH_QUEUE_ENTRY_LIMIT, - PR_REFRESH_RETRY_STATE_LIMIT, - PR_REFRESH_VISIBLE_CANDIDATE_LIMIT -} from '../../shared/pr-refresh-memory-limits' - -export function retainPRRefreshAlias( - aliases: Map, - alias: GitHubPRRefreshAlias, - protectedCacheKey: string -): GitHubPRRefreshAlias | null { - if (aliases.has(alias.cacheKey)) { - aliases.delete(alias.cacheKey) - aliases.set(alias.cacheKey, alias) - return null - } - if (aliases.size < PR_REFRESH_ALIAS_LIMIT) { - aliases.set(alias.cacheKey, alias) - return null - } - - let evictionKey: string | undefined - for (const cacheKey of aliases.keys()) { - if (cacheKey !== protectedCacheKey) { - evictionKey = cacheKey - break - } - } - if (evictionKey === undefined) { - return alias - } - const evicted = aliases.get(evictionKey) ?? null - aliases.delete(evictionKey) - aliases.set(alias.cacheKey, alias) - return evicted -} - -export function retainPRRefreshState( - entries: Map, - key: K, - value: V, - limit: number -): K | null { - if (entries.has(key)) { - entries.delete(key) - entries.set(key, value) - return null - } - let evictedKey: K | null = null - if (entries.size >= limit) { - const oldest = entries.keys().next() - if (!oldest.done) { - evictedKey = oldest.value - entries.delete(oldest.value) - } - } - entries.set(key, value) - return evictedKey -} - -export function boundedVisiblePRRefreshCandidates( - candidates: GitHubPRRefreshCandidate[] -): GitHubPRRefreshCandidate[] { - return candidates.length <= PR_REFRESH_VISIBLE_CANDIDATE_LIMIT - ? candidates - : candidates.slice(0, PR_REFRESH_VISIBLE_CANDIDATE_LIMIT) -} diff --git a/src/main/github/pr-refresh-validation-backoff.ts b/src/main/github/pr-refresh-validation-backoff.ts index 0f077d80029..8f8ea56b4b6 100644 --- a/src/main/github/pr-refresh-validation-backoff.ts +++ b/src/main/github/pr-refresh-validation-backoff.ts @@ -1,7 +1,6 @@ import { createHash } from 'node:crypto' import { resolve } from 'node:path' import { recordCoalescedCrashBreadcrumb } from '../crash-reporting/crash-breadcrumb-store' -import { cacheIdentityDigest } from '../cache-identity-digest' const VALIDATION_BACKOFF_TTL_MS = 5 * 60_000 const MAX_VALIDATION_BACKOFF_ENTRIES = 256 @@ -36,7 +35,7 @@ const counters: ValidationBackoffCounters = { } function validationIdentityKey(identity: ValidationBackoffIdentity): string { - return cacheIdentityDigest([identity.repoId ?? '', resolve(identity.repoPath), identity.reason]) + return [identity.repoId ?? '', resolve(identity.repoPath), identity.reason].join('\0') } function validationIdentityToken(key: string): string { diff --git a/src/main/github/project-view.ts b/src/main/github/project-view.ts index 2a42a3b8e69..2e53eff357c 100644 --- a/src/main/github/project-view.ts +++ b/src/main/github/project-view.ts @@ -52,7 +52,6 @@ import { isGitHubProjectRefInputTooLarge } from '../../shared/github-project-ref-input' import { githubProjectHost } from '../../shared/github-project-identity' -import { cacheIdentityDigest } from '../cache-identity-digest' // Re-export the public API so existing `./project-view` call sites keep working; the split is internal-only. export { isValidOwnerSlug, isValidRepoSlug, isValidSlug } from './project-view/internals' @@ -130,11 +129,12 @@ const parentFieldProbeInFlight = new Map>() // host's probe result can't leak into another. Normalize github.com so // host-less callers share the same probe state as explicitly pinned calls. function ownerScopeKey(owner: string, ownerType: GitHubProjectOwnerType, host?: string): string { - return cacheIdentityDigest([owner, ownerType, githubProjectHost(host)]) + const base = `${owner}\u0000${ownerType}` + return `${base}\u0000${githubProjectHost(host)}` } function ownerTypeCacheKey(owner: string, host?: string): string { - return cacheIdentityDigest([owner, githubProjectHost(host)]) + return `${owner}\u0000${githubProjectHost(host)}` } function rememberOwnerType( diff --git a/src/main/github/project-view/internals.ts b/src/main/github/project-view/internals.ts index c3842da5f9e..5952118ee4c 100644 --- a/src/main/github/project-view/internals.ts +++ b/src/main/github/project-view/internals.ts @@ -68,23 +68,15 @@ export async function projectHostAuthenticationError( const OWNER_SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9-]*$/ const REPO_SLUG_RE = /^[A-Za-z0-9._-]+$/ const REPO_SLUG_RESERVED = new Set(['.', '..']) -const OWNER_SLUG_MAX_CHARS = 256 -const REPO_SLUG_MAX_CHARS = 1024 export function isValidOwnerSlug(value: unknown): value is string { - return ( - typeof value === 'string' && - value.length > 0 && - value.length <= OWNER_SLUG_MAX_CHARS && - OWNER_SLUG_RE.test(value) - ) + return typeof value === 'string' && value.length > 0 && OWNER_SLUG_RE.test(value) } export function isValidRepoSlug(value: unknown): value is string { return ( typeof value === 'string' && value.length > 0 && - value.length <= REPO_SLUG_MAX_CHARS && REPO_SLUG_RE.test(value) && !REPO_SLUG_RESERVED.has(value) ) diff --git a/src/main/github/rate-limit.ts b/src/main/github/rate-limit.ts index 9409fa6fc20..2ca3f5cad43 100644 --- a/src/main/github/rate-limit.ts +++ b/src/main/github/rate-limit.ts @@ -22,7 +22,6 @@ import { registerGhRateLimitResetProbe, type GhRateLimitBucket } from '../git/gh-rate-limit-breaker' -import { cacheIdentityDigest } from '../cache-identity-digest' // Why: GET /rate_limit is exempt from limits, so caching only avoids a gh subprocess per render; 30s stays live while absorbing 1/s polling. const RATE_LIMIT_CACHE_TTL_MS = 30_000 @@ -197,21 +196,15 @@ const DEFAULT_BREAKER_SCOPE = ghRateLimitScopeKey('native', 'github.com') const scopeRefinementInFlight = new Map>() const scopeProbeFailureAtMs = new Map() const SCOPE_PROBE_FAILURE_MAX_ENTRIES = 512 -const SCOPE_REFINEMENT_MAX_IN_FLIGHT = 16 - -function scopeRetentionKey(scope: string): string { - return cacheIdentityDigest([scope]) -} function rememberScopeProbeFailure(scope: string, failedAt: number): void { - const retentionKey = scopeRetentionKey(scope) for (const [key, at] of scopeProbeFailureAtMs) { if (failedAt - at >= RATE_LIMIT_CACHE_TTL_MS) { scopeProbeFailureAtMs.delete(key) } } - scopeProbeFailureAtMs.delete(retentionKey) - scopeProbeFailureAtMs.set(retentionKey, failedAt) + scopeProbeFailureAtMs.delete(scope) + scopeProbeFailureAtMs.set(scope, failedAt) while (scopeProbeFailureAtMs.size > SCOPE_PROBE_FAILURE_MAX_ENTRIES) { const oldestKey = scopeProbeFailureAtMs.keys().next().value if (oldestKey === undefined) { @@ -228,20 +221,16 @@ function refineBreakerForScope(scope: string): void { return } const parts = parseGhRateLimitScopeKey(scope) - const retentionKey = scopeRetentionKey(scope) - if (!parts || scopeRefinementInFlight.has(retentionKey)) { + if (!parts || scopeRefinementInFlight.has(scope)) { return } // Why: GHES with rate limiting disabled 404s every probe. Fail open (the // fallback block stands) and don't re-probe in a tight loop while the // breaker keeps tripping. - const failedAt = scopeProbeFailureAtMs.get(retentionKey) + const failedAt = scopeProbeFailureAtMs.get(scope) if (failedAt !== undefined && Date.now() - failedAt < RATE_LIMIT_CACHE_TTL_MS) { return } - if (scopeRefinementInFlight.size >= SCOPE_REFINEMENT_MAX_IN_FLIGHT) { - return - } const probe = (async () => { try { await acquire() @@ -254,7 +243,7 @@ function refineBreakerForScope(scope: string): void { ...(parts.runtime === 'wsl' ? { wslDistro: parts.wslDistro } : {}) }) const parsed = JSON.parse(stdout) as GhRateLimitPayload - scopeProbeFailureAtMs.delete(retentionKey) + scopeProbeFailureAtMs.delete(scope) // Why: mirrors the default-scope refinement, but records into the // per-scope breaker only — the shared snapshot must keep describing // native github.com exclusively. @@ -274,10 +263,10 @@ function refineBreakerForScope(scope: string): void { // negative cache so repeated failing hosts cannot accumulate forever. rememberScopeProbeFailure(scope, Date.now()) } finally { - scopeRefinementInFlight.delete(retentionKey) + scopeRefinementInFlight.delete(scope) } })() - scopeRefinementInFlight.set(retentionKey, probe) + scopeRefinementInFlight.set(scope, probe) } registerGhRateLimitResetProbe((_bucket, scope) => refineBreakerForScope(scope)) diff --git a/src/main/github/tracked-upstream-snapshot-bounds.test.ts b/src/main/github/tracked-upstream-snapshot-bounds.test.ts deleted file mode 100644 index 32e3fe92dd5..00000000000 --- a/src/main/github/tracked-upstream-snapshot-bounds.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - _parseTrackedUpstreamBranchesForTests, - TRACKED_UPSTREAM_SNAPSHOT_MAX_BRANCHES, - TRACKED_UPSTREAM_SNAPSHOT_MAX_BYTES -} from './client' - -describe('tracked upstream snapshot bounds', () => { - it('caps branch count while preserving the currently requested branch', () => { - const requested = `branch-${TRACKED_UPSTREAM_SNAPSHOT_MAX_BRANCHES}` - const stdout = Array.from( - { length: TRACKED_UPSTREAM_SNAPSHOT_MAX_BRANCHES + 1 }, - (_, index) => `refs/heads/branch-${index}\0refs/remotes/origin/branch-${index}\n` - ).join('') - - const parsed = _parseTrackedUpstreamBranchesForTests(stdout, requested) - expect(parsed.size).toBe(TRACKED_UPSTREAM_SNAPSHOT_MAX_BRANCHES) - expect(parsed.get(requested)).toEqual({ - remoteName: 'origin', - branchName: requested - }) - expect(parsed.has('branch-0')).toBe(false) - }) - - it('skips an individual branch that cannot fit the byte budget', () => { - const oversized = 'x'.repeat(TRACKED_UPSTREAM_SNAPSHOT_MAX_BYTES + 1) - const parsed = _parseTrackedUpstreamBranchesForTests( - `refs/heads/${oversized}\0refs/remotes/origin/main\n`, - oversized - ) - expect(parsed.size).toBe(0) - }) -}) diff --git a/src/main/gitlab/client-mr.test.ts b/src/main/gitlab/client-mr.test.ts index 559b0b32719..91273d7a83f 100644 --- a/src/main/gitlab/client-mr.test.ts +++ b/src/main/gitlab/client-mr.test.ts @@ -55,7 +55,6 @@ import { closeMR, diagnoseAuth, getRateLimit, - GITLAB_RATE_LIMIT_CACHE_HOST_MAX_BYTES, listMergeRequests, listWorkItems, mergeMR, @@ -340,15 +339,6 @@ describe('gitlab client — MR operations', () => { expect(_getGitLabRateLimitCacheSize()).toBe(64) }) - - it('does not retain an oversized host snapshot', async () => { - glabApiWithHeadersMock.mockResolvedValue({ body: '{}', headers: {} }) - await getRateLimit({ - host: 'h'.repeat(GITLAB_RATE_LIMIT_CACHE_HOST_MAX_BYTES + 1), - force: true - }) - expect(_getGitLabRateLimitCacheSize()).toBe(0) - }) }) describe('getMergeRequestForBranch', () => { diff --git a/src/main/gitlab/client.ts b/src/main/gitlab/client.ts index 4c0b994502d..d2f30b2d531 100644 --- a/src/main/gitlab/client.ts +++ b/src/main/gitlab/client.ts @@ -46,8 +46,6 @@ import { type HostedReviewExecutionOptions } from '../source-control/hosted-review-git-options' import { shouldHideNonOpenReviewOnDefaultBranch } from '../source-control/repo-default-branch' -import { measureUtf8ByteLength } from '../../shared/utf8-byte-limits' -import { cacheIdentityDigest } from '../cache-identity-digest' // Why: glab REST addresses projects by URL-encoded path; escapes slashes for nested groups. function encodedProject(projectPath: string): string { @@ -56,7 +54,6 @@ function encodedProject(projectPath: string): string { const GITLAB_RATE_LIMIT_CACHE_TTL_MS = 30_000 const GITLAB_RATE_LIMIT_CACHE_MAX_ENTRIES = 64 -export const GITLAB_RATE_LIMIT_CACHE_HOST_MAX_BYTES = 1024 const gitLabRateLimitCache = new Map() type HostedReviewLocalGitOptions = ReturnType @@ -213,14 +210,9 @@ export async function getRateLimit(options?: { host?: string | null }): Promise { const host = options?.host?.trim() || null - const retainableHost = - host === null || - !measureUtf8ByteLength(host, { - stopAfterBytes: GITLAB_RATE_LIMIT_CACHE_HOST_MAX_BYTES - }).exceededLimit - const cacheKey = cacheIdentityDigest([host ?? 'default']) + const cacheKey = host ?? 'default' pruneGitLabRateLimitCache() - const cached = retainableHost ? gitLabRateLimitCache.get(cacheKey) : undefined + const cached = gitLabRateLimitCache.get(cacheKey) if (!options?.force && cached && Date.now() - cached.fetchedAt < GITLAB_RATE_LIMIT_CACHE_TTL_MS) { return { ok: true, snapshot: cached } } @@ -231,9 +223,7 @@ export async function getRateLimit(options?: { const args = host ? ['--hostname', host, 'user'] : ['user'] const { headers } = await glabApiWithHeaders(args) const snapshot = parseGitLabRateLimitSnapshot(headers, host) - if (retainableHost) { - rememberGitLabRateLimitSnapshot(cacheKey, snapshot) - } + rememberGitLabRateLimitSnapshot(cacheKey, snapshot) return { ok: true, snapshot } } catch (err) { const message = err instanceof Error ? err.message : String(err) diff --git a/src/main/gitlab/gitlab-known-host-probe.ts b/src/main/gitlab/gitlab-known-host-probe.ts index 920ba77f761..1756df4ce96 100644 --- a/src/main/gitlab/gitlab-known-host-probe.ts +++ b/src/main/gitlab/gitlab-known-host-probe.ts @@ -7,160 +7,24 @@ export type LocalGitExecOptions = { } const GLAB_KNOWN_HOSTS_TIMEOUT_MS = 10_000 -export const GLAB_KNOWN_HOSTS_OUTPUT_MAX_BYTES = 1024 * 1024 -export const GLAB_KNOWN_HOSTS_CONTEXT_MAX_ENTRIES = 64 -export const GLAB_KNOWN_HOSTS_CONTEXT_KEY_MAX_BYTES = 4 * 1024 -export const GLAB_KNOWN_HOSTS_CONTEXT_KEYS_MAX_BYTES = 128 * 1024 -export const GLAB_KNOWN_HOSTS_MAX_ENTRIES = 64 -export const GLAB_KNOWN_HOST_MAX_BYTES = 1024 -export const GLAB_KNOWN_HOSTS_MAX_BYTES = 32 * 1024 -export const GLAB_KNOWN_HOSTS_MAX_IN_FLIGHT = 16 - -type KnownHostsCacheEntry = { - hosts: readonly string[] - keyBytes: number -} - -type KnownHostRetention = { - hosts: string[] - retainedBytes: number - protectedEntries: number -} - -const knownHostsCacheByExecutionContext = new Map() +const knownHostsCacheByExecutionContext = new Map() const knownHostsInFlightByExecutionContext = new Map>() -let knownHostsCachedContextKeyBytes = 0 - -function utf8Bytes(value: string): number { - return Buffer.byteLength(value, 'utf8') -} function knownHostsExecutionKey( connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} -): string | null { - let key: string +): string { if (connectionId) { - if (utf8Bytes(connectionId) > GLAB_KNOWN_HOSTS_CONTEXT_KEY_MAX_BYTES) { - return null - } // Why: reconnecting can replace the SSH/relay execution host under the same id. - key = `connection:${connectionId}:${getSshGitProviderGeneration(connectionId)}` - } else if (localGitOptions.wslDistro) { - if (utf8Bytes(localGitOptions.wslDistro) > GLAB_KNOWN_HOSTS_CONTEXT_KEY_MAX_BYTES) { - return null - } - key = `wsl:${localGitOptions.wslDistro}` - } else { - key = 'native' + return `connection:${connectionId}:${getSshGitProviderGeneration(connectionId)}` } - return utf8Bytes(key) <= GLAB_KNOWN_HOSTS_CONTEXT_KEY_MAX_BYTES ? key : null -} - -function deleteCachedContext(key: string): void { - const cached = knownHostsCacheByExecutionContext.get(key) - if (!cached) { - return - } - knownHostsCachedContextKeyBytes -= cached.keyBytes - knownHostsCacheByExecutionContext.delete(key) -} - -function getCachedHosts(key: string): readonly string[] | undefined { - const cached = knownHostsCacheByExecutionContext.get(key) - if (!cached) { - return undefined - } - knownHostsCacheByExecutionContext.delete(key) - knownHostsCacheByExecutionContext.set(key, cached) - return cached.hosts -} - -function createKnownHostRetention(includeDefaults: boolean): KnownHostRetention { - const hosts = includeDefaults ? [...DEFAULT_GITLAB_HOSTS] : [] - return { - hosts, - retainedBytes: hosts.reduce((total, host) => total + utf8Bytes(host), 0), - protectedEntries: hosts.length - } -} - -function retainKnownHost(retention: KnownHostRetention, host: string): void { - if (utf8Bytes(host) > GLAB_KNOWN_HOST_MAX_BYTES) { - return - } - const normalized = normalizeGitLabHost(host) - const hostBytes = utf8Bytes(normalized) - if ( - hostBytes === 0 || - hostBytes > GLAB_KNOWN_HOST_MAX_BYTES || - retention.hosts.includes(normalized) - ) { - return - } - while ( - retention.hosts.length > retention.protectedEntries && - (retention.hosts.length >= GLAB_KNOWN_HOSTS_MAX_ENTRIES || - retention.retainedBytes + hostBytes > GLAB_KNOWN_HOSTS_MAX_BYTES) - ) { - const removed = retention.hosts.splice(retention.protectedEntries, 1)[0] - retention.retainedBytes -= utf8Bytes(removed) - } - if ( - retention.hosts.length >= GLAB_KNOWN_HOSTS_MAX_ENTRIES || - retention.retainedBytes + hostBytes > GLAB_KNOWN_HOSTS_MAX_BYTES - ) { - return - } - retention.hosts.push(normalized) - retention.retainedBytes += hostBytes -} - -function retainKnownHosts(hosts: Iterable): readonly string[] { - const retention = createKnownHostRetention(true) - for (const host of hosts) { - retainKnownHost(retention, host) - } - return retention.hosts -} - -function cacheKnownHosts(key: string, hosts: Iterable): readonly string[] { - const retained = retainKnownHosts(hosts) - const keyBytes = utf8Bytes(key) - deleteCachedContext(key) - while ( - knownHostsCacheByExecutionContext.size >= GLAB_KNOWN_HOSTS_CONTEXT_MAX_ENTRIES || - knownHostsCachedContextKeyBytes + keyBytes > GLAB_KNOWN_HOSTS_CONTEXT_KEYS_MAX_BYTES - ) { - const oldestKey = knownHostsCacheByExecutionContext.keys().next().value - if (oldestKey === undefined) { - break - } - deleteCachedContext(oldestKey) - } - knownHostsCacheByExecutionContext.set(key, { hosts: retained, keyBytes }) - knownHostsCachedContextKeyBytes += keyBytes - return retained + return localGitOptions.wslDistro ? `wsl:${localGitOptions.wslDistro}` : 'native' } /** @internal - exposed for tests only */ export function _resetKnownHostsCache(): void { knownHostsCacheByExecutionContext.clear() knownHostsInFlightByExecutionContext.clear() - knownHostsCachedContextKeyBytes = 0 -} - -/** @internal - exposed for tests only */ -export function _getKnownHostsCacheState(): { - cachedContexts: number - cachedContextKeyBytes: number - inFlightContexts: number -} { - return { - cachedContexts: knownHostsCacheByExecutionContext.size, - cachedContextKeyBytes: knownHostsCachedContextKeyBytes, - inFlightContexts: knownHostsInFlightByExecutionContext.size - } } export function rememberGlabKnownHost( @@ -168,19 +32,13 @@ export function rememberGlabKnownHost( connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): void { - const key = knownHostsExecutionKey(connectionId, localGitOptions) - if (!key || utf8Bytes(host) > GLAB_KNOWN_HOST_MAX_BYTES) { - return - } const normalizedHost = normalizeGitLabHost(host) - if (utf8Bytes(normalizedHost) > GLAB_KNOWN_HOST_MAX_BYTES) { + const key = knownHostsExecutionKey(connectionId, localGitOptions) + const cached = knownHostsCacheByExecutionContext.get(key) + if (!cached || cached.map(normalizeGitLabHost).includes(normalizedHost)) { return } - const cached = getCachedHosts(key) - if (!cached || cached.includes(normalizedHost)) { - return - } - cacheKnownHosts(key, [...cached, normalizedHost]) + knownHostsCacheByExecutionContext.set(key, [...cached, normalizedHost]) } export async function getGlabKnownHosts( @@ -188,10 +46,7 @@ export async function getGlabKnownHosts( localGitOptions: LocalGitExecOptions = {} ): Promise { const key = knownHostsExecutionKey(connectionId, localGitOptions) - if (!key) { - return [...DEFAULT_GITLAB_HOSTS] - } - const cached = getCachedHosts(key) + const cached = knownHostsCacheByExecutionContext.get(key) if (cached) { return cached } @@ -199,9 +54,6 @@ export async function getGlabKnownHosts( if (inFlight) { return inFlight } - if (knownHostsInFlightByExecutionContext.size >= GLAB_KNOWN_HOSTS_MAX_IN_FLIGHT) { - return [...DEFAULT_GITLAB_HOSTS] - } const probe = probeGlabKnownHosts(key, connectionId, localGitOptions) knownHostsInFlightByExecutionContext.set(key, probe) try { @@ -223,13 +75,14 @@ async function probeGlabKnownHosts( // or reconnected SSH/relay results, and bound an otherwise global probe. const { stdout, stderr } = await glabExecFileAsync(['auth', 'status'], { timeout: GLAB_KNOWN_HOSTS_TIMEOUT_MS, - maxBuffer: GLAB_KNOWN_HOSTS_OUTPUT_MAX_BYTES, ...(!connectionId && localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}) }) const hosts = parseGlabAuthStatusHosts(`${stdout}\n${stderr}`) - return cacheKnownHosts(key, hosts) + const merged = Array.from(new Set([...DEFAULT_GITLAB_HOSTS, ...hosts])) + knownHostsCacheByExecutionContext.set(key, merged) + return merged } catch { // Keep failures uncached so auth or tunnel recovery is discovered later. return [...DEFAULT_GITLAB_HOSTS] @@ -237,11 +90,11 @@ async function probeGlabKnownHosts( } export function parseGlabAuthStatusHosts(output: string): string[] { - const retention = createKnownHostRetention(false) + const hosts = new Set() // Why: self-hosted GitLab can run on a non-default port; preserve it so // services on the same hostname remain distinct downstream. for (const match of output.matchAll(/logged in to ([a-zA-Z0-9.-]+(?::\d+)?)/gi)) { - retainKnownHost(retention, match[1].toLowerCase()) + hosts.add(match[1].toLowerCase()) } for (const line of output.split('\n')) { const bareLine = line.trim() @@ -250,8 +103,8 @@ export function parseGlabAuthStatusHosts(output: string): string[] { line === bareLine && /^[a-zA-Z0-9](?:[a-zA-Z0-9.-]*[a-zA-Z0-9])?(?::\d+)?$/.test(hostLine) ) { - retainKnownHost(retention, hostLine.toLowerCase()) + hosts.add(hostLine.toLowerCase()) } } - return retention.hosts + return Array.from(hosts) } diff --git a/src/main/gitlab/gitlab-project-ref-resolution.ts b/src/main/gitlab/gitlab-project-ref-resolution.ts index d17108ecb9b..218a41869c9 100644 --- a/src/main/gitlab/gitlab-project-ref-resolution.ts +++ b/src/main/gitlab/gitlab-project-ref-resolution.ts @@ -2,10 +2,6 @@ import { gitExecFileAsync, glabExecFileAsync } from '../git/runner' import type { IssueSourcePreference } from '../../shared/types' import { getSshGitProvider } from '../providers/ssh-git-dispatch' import { clearProjectRefInFlight, runProjectRefProbeOnce } from './project-ref-inflight' -import { - buildRepositoryRefCacheKey, - RepositoryRefCache -} from '../source-control/repository-ref-cache' import { parseGlabAuthStatusHosts, rememberGlabKnownHost, @@ -22,23 +18,14 @@ import { export { DEFAULT_GITLAB_HOSTS, parseGitLabProjectRef } export type { ProjectRef } export { - GLAB_KNOWN_HOST_MAX_BYTES, - GLAB_KNOWN_HOSTS_CONTEXT_KEYS_MAX_BYTES, - GLAB_KNOWN_HOSTS_CONTEXT_KEY_MAX_BYTES, - GLAB_KNOWN_HOSTS_CONTEXT_MAX_ENTRIES, - GLAB_KNOWN_HOSTS_MAX_BYTES, - GLAB_KNOWN_HOSTS_MAX_ENTRIES, - GLAB_KNOWN_HOSTS_MAX_IN_FLIGHT, - GLAB_KNOWN_HOSTS_OUTPUT_MAX_BYTES, - _getKnownHostsCacheState, _resetKnownHostsCache, getGlabKnownHosts, - parseGlabAuthStatusHosts, - rememberGlabKnownHost + parseGlabAuthStatusHosts } from './gitlab-known-host-probe' export type { LocalGitExecOptions } from './gitlab-known-host-probe' -const projectRefCache = new RepositoryRefCache() +const PROJECT_REF_CACHE_MAX_ENTRIES = 512 +const projectRefCache = new Map() /** @internal - exposed for tests only */ export function _resetProjectRefCache(): void { @@ -51,6 +38,17 @@ export function _getProjectRefCacheSize(): number { return projectRefCache.size } +function rememberProjectRefCacheEntry(cacheKey: string, value: ProjectRef | null): void { + projectRefCache.set(cacheKey, value) + while (projectRefCache.size > PROJECT_REF_CACHE_MAX_ENTRIES) { + const oldestKey = projectRefCache.keys().next().value + if (oldestKey === undefined) { + return + } + projectRefCache.delete(oldestKey) + } +} + export async function getProjectRefForRemote( repoPath: string, remoteName: string, @@ -59,13 +57,12 @@ export async function getProjectRefForRemote( localGitOptions: LocalGitExecOptions = {} ): Promise { const runtimeKey = connectionId ?? `local:${localGitOptions.wslDistro ?? 'host'}` - const cacheKey = buildRepositoryRefCacheKey([runtimeKey, repoPath, remoteName, ...knownHosts]) - const cached = projectRefCache.get(cacheKey) - if (cached.found) { - return cached.value + const cacheKey = `${runtimeKey}\0${repoPath}\0${remoteName}\0${knownHosts.join(',')}` + if (projectRefCache.has(cacheKey)) { + return projectRefCache.get(cacheKey)! } - const probe = () => + return runProjectRefProbeOnce(cacheKey, () => resolveProjectRefForRemote( repoPath, remoteName, @@ -74,7 +71,7 @@ export async function getProjectRefForRemote( cacheKey, localGitOptions ) - return cacheKey === null ? probe() : runProjectRefProbeOnce(cacheKey, probe) + ) } async function resolveProjectRefForRemote( @@ -82,7 +79,7 @@ async function resolveProjectRefForRemote( remoteName: string, knownHosts: readonly string[], connectionId: string | null | undefined, - cacheKey: string | null, + cacheKey: string, localGitOptions: LocalGitExecOptions ): Promise { try { @@ -98,7 +95,7 @@ async function resolveProjectRefForRemote( }) const result = parseGitLabProjectRef(stdout, knownHosts) if (result) { - projectRefCache.remember(cacheKey, result, [result.host, result.path]) + rememberProjectRefCacheEntry(cacheKey, result) return result } const remoteCandidate = parseRemoteProjectRefCandidate(stdout) @@ -112,10 +109,7 @@ async function resolveProjectRefForRemote( )) ) { rememberGlabKnownHost(remoteCandidate.host, connectionId, localGitOptions) - projectRefCache.remember(cacheKey, remoteCandidate, [ - remoteCandidate.host, - remoteCandidate.path - ]) + rememberProjectRefCacheEntry(cacheKey, remoteCandidate) return remoteCandidate } } catch { @@ -123,7 +117,7 @@ async function resolveProjectRefForRemote( return null } } - projectRefCache.remember(cacheKey, null, []) + rememberProjectRefCacheEntry(cacheKey, null) return null } diff --git a/src/main/gitlab/gl-utils.test.ts b/src/main/gitlab/gl-utils.test.ts index fc34280f8e8..9ab99ddc441 100644 --- a/src/main/gitlab/gl-utils.test.ts +++ b/src/main/gitlab/gl-utils.test.ts @@ -12,15 +12,6 @@ vi.mock('../git/runner', () => ({ })) import { - GLAB_KNOWN_HOST_MAX_BYTES, - GLAB_KNOWN_HOSTS_CONTEXT_KEYS_MAX_BYTES, - GLAB_KNOWN_HOSTS_CONTEXT_KEY_MAX_BYTES, - GLAB_KNOWN_HOSTS_CONTEXT_MAX_ENTRIES, - GLAB_KNOWN_HOSTS_MAX_BYTES, - GLAB_KNOWN_HOSTS_MAX_ENTRIES, - GLAB_KNOWN_HOSTS_MAX_IN_FLIGHT, - GLAB_KNOWN_HOSTS_OUTPUT_MAX_BYTES, - _getKnownHostsCacheState, _getProjectRefCacheSize, _resetKnownHostsCache, _resetProjectRefCache, @@ -32,7 +23,6 @@ import { getProjectRefForRemote, parseGlabApiResponse, parseGlabAuthStatusHosts, - rememberGlabKnownHost, resolveIssueSource } from './gl-utils' import { registerSshGitProvider, unregisterSshGitProvider } from '../providers/ssh-git-dispatch' @@ -444,10 +434,7 @@ describe('getGlabKnownHosts', () => { }) await expect(getGlabKnownHosts()).resolves.toEqual(['gitlab.com', 'gitlab.example.com']) - expect(glabExecFileAsyncMock).toHaveBeenCalledWith(['auth', 'status'], { - timeout: 10_000, - maxBuffer: GLAB_KNOWN_HOSTS_OUTPUT_MAX_BYTES - }) + expect(glabExecFileAsyncMock).toHaveBeenCalledWith(['auth', 'status'], { timeout: 10_000 }) }) it('falls back to default when glab auth status fails', async () => { @@ -508,12 +495,10 @@ describe('getGlabKnownHosts', () => { expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(4) expect(glabExecFileAsyncMock).toHaveBeenNthCalledWith(1, ['auth', 'status'], { timeout: 10_000, - maxBuffer: GLAB_KNOWN_HOSTS_OUTPUT_MAX_BYTES, wslDistro: 'Ubuntu' }) expect(glabExecFileAsyncMock).toHaveBeenNthCalledWith(2, ['auth', 'status'], { timeout: 10_000, - maxBuffer: GLAB_KNOWN_HOSTS_OUTPUT_MAX_BYTES, wslDistro: 'Debian' }) }) @@ -612,144 +597,4 @@ describe('getGlabKnownHosts', () => { expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(2) unregisterSshGitProvider(connectionId) }) - - it('keeps the most recently used execution contexts at the cache limit', async () => { - glabExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' }) - - for (let index = 0; index < GLAB_KNOWN_HOSTS_CONTEXT_MAX_ENTRIES; index += 1) { - await getGlabKnownHosts(undefined, { wslDistro: `Distro-${index}` }) - } - expect(_getKnownHostsCacheState().cachedContexts).toBe(GLAB_KNOWN_HOSTS_CONTEXT_MAX_ENTRIES) - - await getGlabKnownHosts(undefined, { wslDistro: 'Distro-0' }) - await getGlabKnownHosts(undefined, { wslDistro: 'Distro-overflow' }) - expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(GLAB_KNOWN_HOSTS_CONTEXT_MAX_ENTRIES + 1) - - await getGlabKnownHosts(undefined, { wslDistro: 'Distro-1' }) - await getGlabKnownHosts(undefined, { wslDistro: 'Distro-0' }) - expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(GLAB_KNOWN_HOSTS_CONTEXT_MAX_ENTRIES + 2) - }) - - it('accepts an exact-limit execution key and skips an oversized context', async () => { - glabExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' }) - const prefixBytes = Buffer.byteLength('wsl:', 'utf8') - const exactDistro = 'x'.repeat(GLAB_KNOWN_HOSTS_CONTEXT_KEY_MAX_BYTES - prefixBytes) - - await expect(getGlabKnownHosts(undefined, { wslDistro: exactDistro })).resolves.toEqual([ - 'gitlab.com' - ]) - expect(_getKnownHostsCacheState().cachedContextKeyBytes).toBe( - GLAB_KNOWN_HOSTS_CONTEXT_KEY_MAX_BYTES - ) - - await expect(getGlabKnownHosts(undefined, { wslDistro: `${exactDistro}x` })).resolves.toEqual([ - 'gitlab.com' - ]) - expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(1) - }) - - it('evicts old large context keys at the aggregate byte limit', async () => { - glabExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' }) - const prefixBytes = Buffer.byteLength('wsl:', 'utf8') - const retainedContexts = - GLAB_KNOWN_HOSTS_CONTEXT_KEYS_MAX_BYTES / GLAB_KNOWN_HOSTS_CONTEXT_KEY_MAX_BYTES - const distroAt = (index: number): string => { - const suffix = String(index).padStart(3, '0') - return `${'x'.repeat(GLAB_KNOWN_HOSTS_CONTEXT_KEY_MAX_BYTES - prefixBytes - suffix.length)}${suffix}` - } - - for (let index = 0; index <= retainedContexts; index += 1) { - await getGlabKnownHosts(undefined, { wslDistro: distroAt(index) }) - } - expect(_getKnownHostsCacheState()).toMatchObject({ - cachedContexts: retainedContexts, - cachedContextKeyBytes: GLAB_KNOWN_HOSTS_CONTEXT_KEYS_MAX_BYTES - }) - - await getGlabKnownHosts(undefined, { wslDistro: distroAt(0) }) - expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(retainedContexts + 2) - }) - - it('bounds parsed hosts while retaining the newest values', () => { - const hosts = Array.from( - { length: GLAB_KNOWN_HOSTS_MAX_ENTRIES + 6 }, - (_, index) => `gitlab-${index}.example.com` - ) - - expect(parseGlabAuthStatusHosts(hosts.join('\n'))).toEqual( - hosts.slice(-GLAB_KNOWN_HOSTS_MAX_ENTRIES) - ) - }) - - it('preserves gitlab.com and the newest remembered hosts at the count limit', async () => { - glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) - await getGlabKnownHosts() - const customHosts = Array.from( - { length: GLAB_KNOWN_HOSTS_MAX_ENTRIES + 6 }, - (_, index) => `gitlab-${index}.example.com` - ) - for (const host of customHosts) { - rememberGlabKnownHost(host) - } - - await expect(getGlabKnownHosts()).resolves.toEqual([ - 'gitlab.com', - ...customHosts.slice(-(GLAB_KNOWN_HOSTS_MAX_ENTRIES - 1)) - ]) - }) - - it('accepts an exact-limit host and ignores an oversized host', async () => { - glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) - await getGlabKnownHosts() - const exactHost = `${'x'.repeat(GLAB_KNOWN_HOST_MAX_BYTES - 5)}.test` - const oversizedHost = `${exactHost}x` - - rememberGlabKnownHost(exactHost) - rememberGlabKnownHost(oversizedHost) - - await expect(getGlabKnownHosts()).resolves.toEqual(['gitlab.com', exactHost]) - }) - - it('bounds aggregate retained host bytes and keeps newest values', async () => { - glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) - await getGlabKnownHosts() - const customHosts = Array.from({ length: 40 }, (_, index) => { - const prefix = String(index).padStart(2, '0') - return `${prefix}${'x'.repeat(GLAB_KNOWN_HOST_MAX_BYTES - prefix.length)}` - }) - for (const host of customHosts) { - rememberGlabKnownHost(host) - } - - const retained = await getGlabKnownHosts() - expect(retained).toEqual(['gitlab.com', ...customHosts.slice(-31)]) - expect( - retained.reduce((total, host) => total + Buffer.byteLength(host), 0) - ).toBeLessThanOrEqual(GLAB_KNOWN_HOSTS_MAX_BYTES) - }) - - it('caps distinct in-flight probes and recovers capacity after settlement', async () => { - const resolvers: ((value: { stdout: string; stderr: string }) => void)[] = [] - glabExecFileAsyncMock.mockImplementation( - () => - new Promise((resolve) => { - resolvers.push(resolve) - }) - ) - const probes = Array.from({ length: GLAB_KNOWN_HOSTS_MAX_IN_FLIGHT }, (_, index) => - getGlabKnownHosts(undefined, { wslDistro: `Distro-${index}` }) - ) - - expect(_getKnownHostsCacheState().inFlightContexts).toBe(GLAB_KNOWN_HOSTS_MAX_IN_FLIGHT) - await expect(getGlabKnownHosts(undefined, { wslDistro: 'Distro-overflow' })).resolves.toEqual([ - 'gitlab.com' - ]) - expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(GLAB_KNOWN_HOSTS_MAX_IN_FLIGHT) - - for (const resolve of resolvers) { - resolve({ stdout: '', stderr: '' }) - } - await Promise.all(probes) - expect(_getKnownHostsCacheState().inFlightContexts).toBe(0) - }) }) diff --git a/src/main/gitlab/gl-utils.ts b/src/main/gitlab/gl-utils.ts index a01182b9c8d..32b25ab0717 100644 --- a/src/main/gitlab/gl-utils.ts +++ b/src/main/gitlab/gl-utils.ts @@ -2,7 +2,6 @@ import { execFile } from 'node:child_process' import { promisify } from 'node:util' import { gitExecFileAsync, glabExecFileAsync } from '../git/runner' import { parseGlabApiResponse, type GlabApiResponse } from './glab-api-response' -import { IntegrationApiConcurrencyGate } from '../integration-api-concurrency' // Why: legacy generic execFile wrapper - only used by callers that don't need // WSL-aware routing. Repo-scoped callers should use the runner exports below. @@ -11,15 +10,6 @@ export { glabExecFileAsync, gitExecFileAsync } export { classifyGlabError, classifyListIssuesError } from './glab-error-classification' export { DEFAULT_GITLAB_HOSTS, - GLAB_KNOWN_HOST_MAX_BYTES, - GLAB_KNOWN_HOSTS_CONTEXT_KEYS_MAX_BYTES, - GLAB_KNOWN_HOSTS_CONTEXT_KEY_MAX_BYTES, - GLAB_KNOWN_HOSTS_CONTEXT_MAX_ENTRIES, - GLAB_KNOWN_HOSTS_MAX_BYTES, - GLAB_KNOWN_HOSTS_MAX_ENTRIES, - GLAB_KNOWN_HOSTS_MAX_IN_FLIGHT, - GLAB_KNOWN_HOSTS_OUTPUT_MAX_BYTES, - _getKnownHostsCacheState, _getProjectRefCacheSize, _resetKnownHostsCache, _resetProjectRefCache, @@ -31,7 +21,6 @@ export { glabRepoExecOptions, parseGlabAuthStatusHosts, parseGitLabProjectRef, - rememberGlabKnownHost, resolveIssueSource } from './gitlab-project-ref-resolution' export type { @@ -42,14 +31,28 @@ export type { export { parseGlabApiResponse, type GlabApiResponse } from './glab-api-response' const MAX_CONCURRENT = 4 -const concurrencyGate = new IntegrationApiConcurrencyGate(MAX_CONCURRENT) +let running = 0 +const queue: (() => void)[] = [] export function acquire(): Promise { - return concurrencyGate.acquire() + if (running < MAX_CONCURRENT) { + running += 1 + return Promise.resolve() + } + return new Promise((resolve) => + queue.push(() => { + running += 1 + resolve() + }) + ) } export function release(): void { - concurrencyGate.release() + running -= 1 + const next = queue.shift() + if (next) { + next() + } } export async function glabApiWithHeaders( diff --git a/src/main/gitlab/merge-request-creation.ts b/src/main/gitlab/merge-request-creation.ts index fc5a08511c3..1b7d253f037 100644 --- a/src/main/gitlab/merge-request-creation.ts +++ b/src/main/gitlab/merge-request-creation.ts @@ -1,8 +1,12 @@ +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' import type { CreateHostedReviewInput, CreateHostedReviewResult } from '../../shared/hosted-review' import { normalizeHostedReviewBaseRef, normalizeHostedReviewHeadRef } from '../../shared/hosted-review-refs' +import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' +import { joinWorktreeRelativePath } from '../runtime/runtime-relative-paths' import { getHostedReviewLocalGitOptions, hasHostedReviewLocalGitOptions, @@ -17,7 +21,6 @@ import { release } from './gl-utils' import { findOpenMRByHeadBase, parseMergeRequestPayload } from './merge-request-creation-lookup' -import { readGitLabMergeRequestTemplate } from '../source-control/pull-request-template' function execErrorMessage(error: unknown): string { if (error instanceof Error) { @@ -85,6 +88,39 @@ function hostedReviewExecutionOptionArgs( return hasHostedReviewLocalGitOptions(options) ? [options] : [] } +async function readMergeRequestTemplate( + repoPath: string, + connectionId?: string | null +): Promise { + const relativeCandidates = [ + '.gitlab/merge_request_templates/Default.md', + '.gitlab/merge_request_templates/default.md', + '.gitlab/merge_request_template.md', + '.gitlab/MERGE_REQUEST_TEMPLATE.md' + ] + const remoteProvider = connectionId ? getSshFilesystemProvider(connectionId) : undefined + if (connectionId && !remoteProvider) { + return '' + } + for (const relativeCandidate of relativeCandidates) { + try { + if (remoteProvider) { + const result = await remoteProvider.readFile( + joinWorktreeRelativePath(repoPath, relativeCandidate) + ) + if (result.isBinary) { + continue + } + return result.content + } + return await readFile(join(repoPath, relativeCandidate), 'utf8') + } catch { + // Try the next conventional GitLab merge-request template path. + } + } + return '' +} + export async function createGitLabMergeRequest( repoPath: string, input: CreateHostedReviewInput, @@ -134,7 +170,7 @@ export async function createGitLabMergeRequest( try { const body = input.useTemplate && !input.body?.trim() - ? await readGitLabMergeRequestTemplate(repoPath, connectionId) + ? await readMergeRequestTemplate(repoPath, connectionId) : (input.body ?? '') const createArgs = [ 'mr', diff --git a/src/main/gitlab/project-ref-inflight.test.ts b/src/main/gitlab/project-ref-inflight.test.ts deleted file mode 100644 index afdcf7dcd04..00000000000 --- a/src/main/gitlab/project-ref-inflight.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - clearProjectRefInFlight, - PROJECT_REF_MAX_IN_FLIGHT, - runProjectRefProbeOnce -} from './project-ref-inflight' - -describe('GitLab project-ref in-flight probes', () => { - it('caps retained probes and recovers coalescing after settlement', async () => { - clearProjectRefInFlight() - const releases: (() => void)[] = [] - const probes = Array.from({ length: PROJECT_REF_MAX_IN_FLIGHT }, (_, index) => - runProjectRefProbeOnce( - `key-${index}`, - () => - new Promise((resolve) => { - releases.push(() => resolve(null)) - }) - ) - ) - let overflowCalls = 0 - await expect( - runProjectRefProbeOnce('overflow', async () => { - overflowCalls += 1 - return null - }) - ).resolves.toBeNull() - await expect( - runProjectRefProbeOnce('overflow', async () => { - overflowCalls += 1 - return null - }) - ).resolves.toBeNull() - expect(overflowCalls).toBe(2) - - releases.forEach((release) => release()) - await Promise.all(probes) - - let retainedCalls = 0 - const first = runProjectRefProbeOnce('recovered', async () => { - retainedCalls += 1 - return null - }) - const second = runProjectRefProbeOnce('recovered', async () => { - retainedCalls += 1 - return null - }) - await Promise.all([first, second]) - expect(retainedCalls).toBe(1) - }) -}) diff --git a/src/main/gitlab/project-ref-inflight.ts b/src/main/gitlab/project-ref-inflight.ts index ab7295184b2..8df88f63a7a 100644 --- a/src/main/gitlab/project-ref-inflight.ts +++ b/src/main/gitlab/project-ref-inflight.ts @@ -1,7 +1,6 @@ import type { ProjectRef } from './gl-utils' const projectRefInFlight = new Map>() -export const PROJECT_REF_MAX_IN_FLIGHT = 32 export function clearProjectRefInFlight(): void { projectRefInFlight.clear() @@ -15,9 +14,6 @@ export async function runProjectRefProbeOnce( if (inFlight) { return inFlight } - if (projectRefInFlight.size >= PROJECT_REF_MAX_IN_FLIGHT) { - return createProbe() - } const probe = createProbe() projectRefInFlight.set(cacheKey, probe) try { diff --git a/src/main/hermes/hook-service.ts b/src/main/hermes/hook-service.ts index f37c2eaf23b..cd4a4b99974 100644 --- a/src/main/hermes/hook-service.ts +++ b/src/main/hermes/hook-service.ts @@ -4,6 +4,7 @@ import { copyFileSync, existsSync, mkdirSync, + readFileSync, renameSync, rmSync, unlinkSync, @@ -15,14 +16,6 @@ import type { SFTPWrapper } from 'ssh2' import { parse, stringify } from 'yaml' import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types' -import { - NodeFileReadTooLargeError, - readNodeFileSyncWithinLimit -} from '../../shared/node-bounded-file-reader' -import { - AGENT_HOOK_CONFIG_MAX_BYTES, - AGENT_HOOK_PLUGIN_MAX_BYTES -} from '../agent-hooks/agent-hook-file-limits' import { readTextFileRemote, writeTextFileRemoteAtomic @@ -144,18 +137,7 @@ function readConfigFile(configPath: string): ConfigParseResult { if (!existsSync(configPath)) { return { ok: true, config: {} } } - try { - const content = readNodeFileSyncWithinLimit( - configPath, - AGENT_HOOK_CONFIG_MAX_BYTES - ).buffer.toString('utf8') - return parseHermesConfig(content) - } catch (error) { - return { - ok: false, - detail: error instanceof Error ? error.message : String(error) - } - } + return parseHermesConfig(readFileSync(configPath, 'utf-8')) } function writeConfigFile(configPath: string, config: HermesConfig): void { @@ -164,17 +146,10 @@ function writeConfigFile(configPath: string, config: HermesConfig): void { const serialized = serializeHermesConfig(config) if (existsSync(configPath)) { try { - const existing = readNodeFileSyncWithinLimit( - configPath, - AGENT_HOOK_CONFIG_MAX_BYTES - ).buffer.toString('utf8') - if (existing === serialized) { + if (readFileSync(configPath, 'utf-8') === serialized) { return } - } catch (error) { - if (error instanceof NodeFileReadTooLargeError) { - throw error - } + } catch { // Fall through to the atomic write path. } } @@ -211,56 +186,27 @@ function updateConfigContent( function getPluginFilesState(pluginDir = getPluginDir()): { present: boolean managed: boolean - blocked: boolean detail: string | null } { const manifestPath = getManifestPath(pluginDir) const initPath = getInitPath(pluginDir) - const manifest = readPluginFile(manifestPath, 'plugin.yaml') - const init = readPluginFile(initPath, '__init__.py') - if (manifest.kind === 'error') { - return { present: true, managed: false, blocked: manifest.blocked, detail: manifest.detail } - } - if (init.kind === 'error') { - return { present: true, managed: false, blocked: init.blocked, detail: init.detail } - } - if (manifest.kind === 'absent' || init.kind === 'absent') { - return { - present: false, - managed: false, - blocked: false, - detail: 'Managed Hermes plugin files are missing' - } - } - const managed = - manifest.content.includes(HERMES_PLUGIN_MARKER) && init.content.includes(HERMES_PLUGIN_MARKER) - return { - present: true, - managed, - blocked: false, - detail: managed ? null : 'Hermes orca-status plugin exists but is not Orca-managed' - } -} - -type PluginFileRead = - | { kind: 'absent' } - | { kind: 'content'; content: string } - | { kind: 'error'; blocked: boolean; detail: string } - -function readPluginFile(path: string, name: string): PluginFileRead { - if (!existsSync(path)) { - return { kind: 'absent' } + if (!existsSync(manifestPath) || !existsSync(initPath)) { + return { present: false, managed: false, detail: 'Managed Hermes plugin files are missing' } } try { - const content = readNodeFileSyncWithinLimit(path, AGENT_HOOK_PLUGIN_MAX_BYTES).buffer.toString( - 'utf8' - ) - return { kind: 'content', content } + const manifest = readFileSync(manifestPath, 'utf-8') + const init = readFileSync(initPath, 'utf-8') + const managed = manifest.includes(HERMES_PLUGIN_MARKER) && init.includes(HERMES_PLUGIN_MARKER) + return { + present: true, + managed, + detail: managed ? null : 'Hermes orca-status plugin exists but is not Orca-managed' + } } catch (error) { return { - kind: 'error', - blocked: error instanceof NodeFileReadTooLargeError, - detail: `Could not read Hermes ${name}: ${error instanceof Error ? error.message : String(error)}` + present: true, + managed: false, + detail: error instanceof Error ? error.message : String(error) } } } @@ -288,11 +234,8 @@ function getConfigEnablement(config: HermesConfig): { } } -function buildStatus( - configPath: string, - config: HermesConfig, - pluginFiles = getPluginFilesState() -): AgentHookInstallStatus { +function buildStatus(configPath: string, config: HermesConfig): AgentHookInstallStatus { + const pluginFiles = getPluginFilesState() const enablement = getConfigEnablement(config) const details = [ pluginFiles.detail, @@ -302,9 +245,7 @@ function buildStatus( ].filter((detail): detail is string => Boolean(detail)) let state: AgentHookInstallState - if (pluginFiles.blocked) { - state = 'error' - } else if (!pluginFiles.present && !enablement.enabled) { + if (!pluginFiles.present && !enablement.enabled) { state = 'not_installed' } else if ( pluginFiles.present && @@ -497,43 +438,14 @@ def register(ctx: Any) -> None: function writePluginFiles(pluginDir = getPluginDir()): void { mkdirSync(pluginDir, { recursive: true }) - writePluginFile(getManifestPath(pluginDir), getPluginManifest()) - writePluginFile(getInitPath(pluginDir), getPluginInitSource()) -} - -function writePluginFile(path: string, content: string): void { - if (existsSync(path)) { - try { - const existing = readNodeFileSyncWithinLimit( - path, - AGENT_HOOK_PLUGIN_MAX_BYTES - ).buffer.toString('utf8') - if (existing === content) { - return - } - } catch (error) { - if (error instanceof NodeFileReadTooLargeError) { - throw error - } - } - } - writeFileSync(path, content, 'utf-8') + writeFileSync(getManifestPath(pluginDir), getPluginManifest(), 'utf-8') + writeFileSync(getInitPath(pluginDir), getPluginInitSource(), 'utf-8') } function stripTrailingSlash(path: string): string { return path.replace(/\/+$/, '') } -async function guardRemotePluginFileSize(sftp: SFTPWrapper, path: string): Promise { - try { - await readTextFileRemote(sftp, path, AGENT_HOOK_PLUGIN_MAX_BYTES) - } catch (error) { - if (error instanceof NodeFileReadTooLargeError) { - throw error - } - } -} - export class HermesHookService { getStatus(): AgentHookInstallStatus { const configPath = getConfigPath() @@ -562,10 +474,6 @@ export class HermesHookService { detail: `Could not parse Hermes config.yaml: ${parsed.detail}` } } - const pluginFiles = getPluginFilesState() - if (pluginFiles.blocked) { - return buildStatus(configPath, parsed.config, pluginFiles) - } writePluginFiles() writeConfigFile(configPath, enablePlugin(parsed.config)) @@ -578,12 +486,6 @@ export class HermesHookService { const remotePluginDir = `${remoteRoot}/.hermes/plugins/${HERMES_PLUGIN_NAME}` try { const existing = await readTextFileRemote(sftp, remoteConfigPath) - const remoteManifestPath = `${remotePluginDir}/plugin.yaml` - const remoteInitPath = `${remotePluginDir}/__init__.py` - await Promise.all([ - guardRemotePluginFileSize(sftp, remoteManifestPath), - guardRemotePluginFileSize(sftp, remoteInitPath) - ]) const next = updateConfigContent(existing, enablePlugin) if (next.content === null) { return { @@ -594,18 +496,8 @@ export class HermesHookService { detail: `Could not parse remote Hermes config.yaml: ${next.detail ?? 'unknown error'}` } } - await writeTextFileRemoteAtomic( - sftp, - remoteManifestPath, - getPluginManifest(), - AGENT_HOOK_PLUGIN_MAX_BYTES - ) - await writeTextFileRemoteAtomic( - sftp, - remoteInitPath, - getPluginInitSource(), - AGENT_HOOK_PLUGIN_MAX_BYTES - ) + await writeTextFileRemoteAtomic(sftp, `${remotePluginDir}/plugin.yaml`, getPluginManifest()) + await writeTextFileRemoteAtomic(sftp, `${remotePluginDir}/__init__.py`, getPluginInitSource()) await writeTextFileRemoteAtomic(sftp, remoteConfigPath, next.content) return { agent: 'hermes', @@ -638,11 +530,7 @@ export class HermesHookService { } } const pluginDir = getPluginDir() - const pluginFiles = getPluginFilesState(pluginDir) - if (pluginFiles.blocked) { - return buildStatus(configPath, parsed.config, pluginFiles) - } - if (pluginFiles.managed) { + if (getPluginFilesState(pluginDir).managed) { rmSync(pluginDir, { recursive: true, force: true }) } writeConfigFile(configPath, disablePlugin(parsed.config)) diff --git a/src/main/hooks-file-bounds.test.ts b/src/main/hooks-file-bounds.test.ts deleted file mode 100644 index 7a61dcab820..00000000000 --- a/src/main/hooks-file-bounds.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { - mkdirSync, - mkdtempSync, - readFileSync, - rmSync, - statSync, - truncateSync, - writeFileSync -} from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { - loadHooks, - MAX_HOOK_GITIGNORE_BYTES, - MAX_ISSUE_COMMAND_BYTES, - MAX_ORCA_YAML_BYTES, - readIssueCommand, - writeIssueCommand -} from './hooks' - -describe('hook configuration file bounds', () => { - const roots: string[] = [] - - afterEach(() => { - vi.restoreAllMocks() - for (const root of roots.splice(0)) { - rmSync(root, { recursive: true, force: true }) - } - }) - - function makeRepo(): string { - const root = mkdtempSync(join(tmpdir(), 'orca-hook-file-bounds-')) - roots.push(root) - return root - } - - function writeOversizedSparseFile(path: string, limit: number): void { - writeFileSync(path, 'x') - truncateSync(path, limit + 1) - } - - it('fails closed on an oversized sparse orca.yaml', () => { - const repoPath = makeRepo() - writeOversizedSparseFile(join(repoPath, 'orca.yaml'), MAX_ORCA_YAML_BYTES) - - expect(loadHooks(repoPath)).toBeNull() - }) - - it('ignores an oversized local issue command while retaining the shared command', () => { - const repoPath = makeRepo() - mkdirSync(join(repoPath, '.orca')) - writeOversizedSparseFile(join(repoPath, '.orca', 'issue-command'), MAX_ISSUE_COMMAND_BYTES) - writeFileSync(join(repoPath, 'orca.yaml'), 'issueCommand: shared command\n') - - expect(readIssueCommand(repoPath)).toMatchObject({ - localContent: null, - sharedContent: 'shared command', - effectiveContent: 'shared command', - source: 'shared' - }) - }) - - it('does not rewrite an oversized .gitignore when saving a local command', () => { - const repoPath = makeRepo() - const gitignorePath = join(repoPath, '.gitignore') - writeOversizedSparseFile(gitignorePath, MAX_HOOK_GITIGNORE_BYTES) - const originalSize = statSync(gitignorePath).size - vi.spyOn(console, 'warn').mockImplementation(() => undefined) - - writeIssueCommand(repoPath, 'local command') - - expect(statSync(gitignorePath).size).toBe(originalSize) - expect(readFileSync(join(repoPath, '.orca', 'issue-command'), 'utf8')).toBe('local command\n') - }) -}) diff --git a/src/main/hooks.test.ts b/src/main/hooks.test.ts index 69df4711a8c..d117470bffe 100644 --- a/src/main/hooks.test.ts +++ b/src/main/hooks.test.ts @@ -16,20 +16,6 @@ vi.mock('fs', () => ({ chmodSync: vi.fn() })) -vi.mock('../shared/node-bounded-file-reader', async () => { - const fs = await import('node:fs') - return { - readNodeFileSyncWithinLimit: (path: string, maxBytes: number) => { - const value = fs.readFileSync(path) - const buffer = Buffer.isBuffer(value) ? value : Buffer.from(value) - if (buffer.length > maxBytes) { - throw new Error('File too large') - } - return { buffer, stats: { size: buffer.length } } - } - } -}) - const { execMock, execFileMock, gitExecFileSyncMock } = vi.hoisted(() => ({ execMock: vi.fn(), execFileMock: vi.fn(), diff --git a/src/main/hooks.ts b/src/main/hooks.ts index cd17d2d539e..ef5aaf1e5ea 100644 --- a/src/main/hooks.ts +++ b/src/main/hooks.ts @@ -1,5 +1,5 @@ /* eslint-disable max-lines -- Why: hook parsing, layered issue-command resolution, and cross-platform runner setup share one execution surface, so keeping them together avoids subtle drift across create/read/write paths. */ -import { existsSync, mkdirSync, writeFileSync, chmodSync, rmSync } from 'node:fs' +import { readFileSync, existsSync, mkdirSync, writeFileSync, chmodSync, rmSync } from 'node:fs' import { dirname, join } from 'node:path' import { exec, execFile } from 'node:child_process' import { getDefaultRepoHookSettings } from '../shared/constants' @@ -21,17 +21,8 @@ import type { WorktreeSetupLaunch } from '../shared/types' import type { ProjectExecutionRuntimeResolution } from '../shared/project-execution-runtime' -import { readNodeFileSyncWithinLimit } from '../shared/node-bounded-file-reader' -import { MAX_ORCA_YAML_BYTES } from '../shared/orca-yaml-file-limit' const HOOK_TIMEOUT = 120_000 // 2 minutes -export { MAX_ORCA_YAML_BYTES } -export const MAX_ISSUE_COMMAND_BYTES = 1024 * 1024 -export const MAX_HOOK_GITIGNORE_BYTES = 4 * 1024 * 1024 - -function readHookFile(path: string, maxBytes: number): string { - return readNodeFileSyncWithinLimit(path, maxBytes).buffer.toString('utf8') -} export type HookRuntimeTarget = { wslDistro?: string | null @@ -57,7 +48,7 @@ export function loadHooks(repoPath: string): OrcaHooks | null { } try { - const content = readHookFile(yamlPath, MAX_ORCA_YAML_BYTES) + const content = readFileSync(yamlPath, 'utf-8') return parseOrcaYaml(content) } catch { return null @@ -82,7 +73,7 @@ const RECOGNIZED_ORCA_YAML_KEYS = new Set([ /** True when `orca.yaml` has a top-level key this version of Orca does not handle. */ export function hasUnrecognizedOrcaYamlKeys(repoPath: string): boolean { try { - const content = readHookFile(join(repoPath, 'orca.yaml'), MAX_ORCA_YAML_BYTES) + const content = readFileSync(join(repoPath, 'orca.yaml'), 'utf-8') for (const line of iterateLfScriptLines(content)) { // Why: match bare `key:` at end-of-line too, since a mapping with a block value on the next line is valid YAML. const m = line.match(/^([A-Za-z][A-Za-z0-9_-]*):(\s|$)/) @@ -127,7 +118,7 @@ export function readIssueCommand(repoPath: string): ResolvedIssueCommand { if (existsSync(filePath)) { try { - const content = readHookFile(filePath, MAX_ISSUE_COMMAND_BYTES).trim() + const content = readFileSync(filePath, 'utf-8').trim() localContent = content || null } catch { localContent = null @@ -178,7 +169,7 @@ function ensureOrcaDirIgnored(repoPath: string): void { const gitignorePath = join(repoPath, '.gitignore') try { if (existsSync(gitignorePath)) { - const content = readHookFile(gitignorePath, MAX_HOOK_GITIGNORE_BYTES) + const content = readFileSync(gitignorePath, 'utf-8') if (/^\.orca\/?$/m.test(content)) { return } diff --git a/src/main/integration-account-persistence-limits.test.ts b/src/main/integration-account-persistence-limits.test.ts deleted file mode 100644 index e81b237e049..00000000000 --- a/src/main/integration-account-persistence-limits.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - assertIntegrationAccountCount, - assertIntegrationCredentialBytes, - assertIntegrationStringBytes, - MAX_INTEGRATION_ACCOUNT_FILE_BYTES, - MAX_INTEGRATION_ACCOUNTS, - MAX_INTEGRATION_CREDENTIAL_BYTES, - serializeIntegrationAccountFile -} from './integration-account-persistence-limits' - -describe('integration account persistence limits', () => { - it('admits the exact account boundary and rejects one more', () => { - expect(() => assertIntegrationAccountCount('Test', MAX_INTEGRATION_ACCOUNTS)).not.toThrow() - expect(() => assertIntegrationAccountCount('Test', MAX_INTEGRATION_ACCOUNTS + 1)).toThrow( - `at most ${MAX_INTEGRATION_ACCOUNTS}` - ) - }) - - it('measures credential and metadata fields as UTF-8 bytes', () => { - const exactCredential = 'é'.repeat(MAX_INTEGRATION_CREDENTIAL_BYTES / 2) - expect(() => assertIntegrationCredentialBytes('Test', exactCredential)).not.toThrow() - expect(() => assertIntegrationCredentialBytes('Test', `${exactCredential}a`)).toThrow( - `${MAX_INTEGRATION_CREDENTIAL_BYTES} UTF-8 bytes` - ) - - expect(() => assertIntegrationStringBytes('Test', 'field', 'éé', 4)).not.toThrow() - expect(() => assertIntegrationStringBytes('Test', 'field', 'ééa', 4)).toThrow('4 UTF-8 bytes') - }) - - it('serializes ordinary JSON identically through the bounded writer', () => { - const value = { version: 1, accounts: [{ id: 'alpha', name: 'Ada' }] } - expect(serializeIntegrationAccountFile(value)).toBe(JSON.stringify(value, null, 2)) - }) - - it('admits an exact-size metadata file and rejects one extra byte', () => { - const base = JSON.stringify({ value: '' }, null, 2) - const exact = { value: 'a'.repeat(MAX_INTEGRATION_ACCOUNT_FILE_BYTES - base.length) } - expect(Buffer.byteLength(JSON.stringify(exact, null, 2))).toBe( - MAX_INTEGRATION_ACCOUNT_FILE_BYTES - ) - expect(serializeIntegrationAccountFile(exact)).toBe(JSON.stringify(exact, null, 2)) - expect(() => serializeIntegrationAccountFile({ value: `${exact.value}a` })).toThrow( - `${MAX_INTEGRATION_ACCOUNT_FILE_BYTES} bytes` - ) - }) -}) diff --git a/src/main/integration-account-persistence-limits.ts b/src/main/integration-account-persistence-limits.ts deleted file mode 100644 index 040a9c9a5c1..00000000000 --- a/src/main/integration-account-persistence-limits.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { stringifyJsonWithinByteLimit } from '../shared/node-bounded-json-stringify' -import { MAX_INTEGRATION_CREDENTIAL_FILE_BYTES } from './integration-credential-file' - -export const MAX_INTEGRATION_ACCOUNTS = 256 -export const MAX_INTEGRATION_ACCOUNT_ID_BYTES = 128 -export const MAX_INTEGRATION_ACCOUNT_URL_BYTES = 16 * 1024 -export const MAX_INTEGRATION_ACCOUNT_EMAIL_BYTES = 4 * 1024 -export const MAX_INTEGRATION_ACCOUNT_LABEL_BYTES = 16 * 1024 -export const MAX_INTEGRATION_CREDENTIAL_BYTES = 64 * 1024 -export const MAX_INTEGRATION_ACCOUNT_FILE_BYTES = MAX_INTEGRATION_CREDENTIAL_FILE_BYTES - -export class IntegrationAccountPersistenceLimitError extends Error { - constructor(message: string) { - super(message) - this.name = 'IntegrationAccountPersistenceLimitError' - } -} - -export function assertIntegrationAccountCount(service: string, count: number): void { - if (count > MAX_INTEGRATION_ACCOUNTS) { - throw new IntegrationAccountPersistenceLimitError( - `${service} supports at most ${MAX_INTEGRATION_ACCOUNTS} saved accounts.` - ) - } -} - -export function assertIntegrationStringBytes( - service: string, - field: string, - value: string, - maxBytes: number -): void { - if (Buffer.byteLength(value, 'utf8') > maxBytes) { - throw new IntegrationAccountPersistenceLimitError( - `${service} ${field} exceeds ${maxBytes} UTF-8 bytes.` - ) - } -} - -export function assertIntegrationCredentialBytes(service: string, value: string): void { - assertIntegrationStringBytes(service, 'credential', value, MAX_INTEGRATION_CREDENTIAL_BYTES) -} - -export function serializeIntegrationAccountFile(value: unknown): string { - return stringifyJsonWithinByteLimit(value, MAX_INTEGRATION_ACCOUNT_FILE_BYTES, 2).serialized -} - -export function unreadableIntegrationAccountFileError(service: string): Error { - return new Error( - `Saved ${service} account metadata is unreadable or exceeds supported limits; ` + - 'the existing file was left unchanged.' - ) -} diff --git a/src/main/integration-api-concurrency.test.ts b/src/main/integration-api-concurrency.test.ts deleted file mode 100644 index edb199f6987..00000000000 --- a/src/main/integration-api-concurrency.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { IntegrationApiConcurrencyGate } from './integration-api-concurrency' - -describe('IntegrationApiConcurrencyGate', () => { - it('preserves FIFO admission within the ordinary concurrency limit', async () => { - const gate = new IntegrationApiConcurrencyGate(1, 2) - const first = gate.acquire() - const secondResolved = vi.fn() - const second = gate.acquire().then(secondResolved) - - await first - expect(secondResolved).not.toHaveBeenCalled() - gate.release() - await second - - expect(secondResolved).toHaveBeenCalledTimes(1) - gate.release() - }) - - it('rejects fan-out beyond the retained waiter cap', async () => { - const gate = new IntegrationApiConcurrencyGate(1, 2) - await gate.acquire() - const queued = [gate.acquire(), gate.acquire()] - - await expect(gate.acquire()).rejects.toThrow('queue is full') - - gate.release() - await queued[0] - gate.release() - await queued[1] - gate.release() - }) -}) diff --git a/src/main/integration-api-concurrency.ts b/src/main/integration-api-concurrency.ts deleted file mode 100644 index 2b44a752068..00000000000 --- a/src/main/integration-api-concurrency.ts +++ /dev/null @@ -1,34 +0,0 @@ -export const INTEGRATION_API_MAX_WAITERS = 1024 - -export class IntegrationApiConcurrencyGate { - private running = 0 - private readonly waiters: (() => void)[] = [] - - constructor( - private readonly maxConcurrent: number, - private readonly maxWaiters = INTEGRATION_API_MAX_WAITERS - ) {} - - acquire(): Promise { - if (this.running < this.maxConcurrent) { - this.running += 1 - return Promise.resolve() - } - if (this.waiters.length >= this.maxWaiters) { - return Promise.reject( - new Error('Integration API request queue is full; retry after current requests finish.') - ) - } - return new Promise((resolve) => { - this.waiters.push(() => { - this.running += 1 - resolve() - }) - }) - } - - release(): void { - this.running = Math.max(0, this.running - 1) - this.waiters.shift()?.() - } -} diff --git a/src/main/integration-credential-file.test.ts b/src/main/integration-credential-file.test.ts deleted file mode 100644 index 3364e0324e6..00000000000 --- a/src/main/integration-credential-file.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { closeSync, ftruncateSync, mkdtempSync, openSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import { - MAX_INTEGRATION_CREDENTIAL_FILE_BYTES, - readIntegrationCredentialFileSync, - readIntegrationCredentialFileText -} from './integration-credential-file' - -const roots: string[] = [] - -afterEach(() => { - for (const root of roots.splice(0)) { - rmSync(root, { recursive: true, force: true }) - } -}) - -function createPath(): string { - const root = mkdtempSync(join(tmpdir(), 'orca-integration-credential-')) - roots.push(root) - return join(root, 'credential') -} - -describe('integration credential file bounds', () => { - it('accepts exact-cap credential bytes in sync and async readers', async () => { - const filePath = createPath() - writeFileSync(filePath, Buffer.alloc(MAX_INTEGRATION_CREDENTIAL_FILE_BYTES, 0x61)) - - expect(readIntegrationCredentialFileSync(filePath)).toHaveLength( - MAX_INTEGRATION_CREDENTIAL_FILE_BYTES - ) - await expect(readIntegrationCredentialFileText(filePath)).resolves.toHaveLength( - MAX_INTEGRATION_CREDENTIAL_FILE_BYTES - ) - }) - - it('rejects a sparse credential file beyond the cap', () => { - const filePath = createPath() - const file = openSync(filePath, 'w') - ftruncateSync(file, MAX_INTEGRATION_CREDENTIAL_FILE_BYTES + 1) - closeSync(file) - - expect(() => readIntegrationCredentialFileSync(filePath)).toThrow('exceeds') - }) -}) diff --git a/src/main/integration-credential-file.ts b/src/main/integration-credential-file.ts index d55d81b1beb..2ad90492f1b 100644 --- a/src/main/integration-credential-file.ts +++ b/src/main/integration-credential-file.ts @@ -1,30 +1,10 @@ import { statSync } from 'node:fs' import { safeStorage } from 'electron' -import { - readNodeFileSyncWithinLimit, - readNodeFileWithinLimit -} from '../shared/node-bounded-file-reader' import { credentialDecryptionMessage, type IntegrationCredentialService } from '../shared/integration-credential-errors' -export const MAX_INTEGRATION_CREDENTIAL_FILE_BYTES = 1024 * 1024 - -export function readIntegrationCredentialFileSync(filePath: string): Buffer { - return readNodeFileSyncWithinLimit(filePath, MAX_INTEGRATION_CREDENTIAL_FILE_BYTES).buffer -} - -export function readIntegrationCredentialFileSyncText(filePath: string): string { - return readIntegrationCredentialFileSync(filePath).toString('utf8') -} - -export async function readIntegrationCredentialFileText(filePath: string): Promise { - return ( - await readNodeFileWithinLimit(filePath, MAX_INTEGRATION_CREDENTIAL_FILE_BYTES) - ).buffer.toString('utf8') -} - // Why: connection status treats a token file as a saved credential; empty // files read as "missing", so counting them would split-brain getStatus. export function credentialFileHasContent(path: string): boolean { diff --git a/src/main/integration-error-message.test.ts b/src/main/integration-error-message.test.ts deleted file mode 100644 index a40513aaef6..00000000000 --- a/src/main/integration-error-message.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - boundedIntegrationErrorLog, - boundedIntegrationErrorMessage, - MAX_INTEGRATION_ERROR_MESSAGE_CHARS -} from './integration-error-message' - -describe('boundedIntegrationErrorMessage', () => { - it('preserves the exact character boundary and truncates character +1', () => { - const exact = 'a'.repeat(MAX_INTEGRATION_ERROR_MESSAGE_CHARS) - expect(boundedIntegrationErrorMessage(new Error(exact))).toBe(exact) - - const truncated = boundedIntegrationErrorMessage(`${exact}b`) - expect(truncated).toHaveLength(MAX_INTEGRATION_ERROR_MESSAGE_CHARS) - expect(truncated.endsWith('…')).toBe(true) - }) - - it('bounds retained stack text used by provider logs', () => { - const error = new Error('failed') - error.stack = 's'.repeat(MAX_INTEGRATION_ERROR_MESSAGE_CHARS + 1) - - expect(boundedIntegrationErrorLog(error)).toHaveLength(MAX_INTEGRATION_ERROR_MESSAGE_CHARS) - }) -}) diff --git a/src/main/integration-error-message.ts b/src/main/integration-error-message.ts deleted file mode 100644 index 38c533844e8..00000000000 --- a/src/main/integration-error-message.ts +++ /dev/null @@ -1,13 +0,0 @@ -export const MAX_INTEGRATION_ERROR_MESSAGE_CHARS = 16 * 1024 - -export function boundedIntegrationErrorMessage(error: unknown): string { - const message = error instanceof Error ? error.message : String(error) - if (message.length <= MAX_INTEGRATION_ERROR_MESSAGE_CHARS) { - return message - } - return `${message.slice(0, MAX_INTEGRATION_ERROR_MESSAGE_CHARS - 1)}…` -} - -export function boundedIntegrationErrorLog(error: unknown): string { - return boundedIntegrationErrorMessage(error instanceof Error && error.stack ? error.stack : error) -} diff --git a/src/main/integration-fanout.test.ts b/src/main/integration-fanout.test.ts deleted file mode 100644 index 4151db0682c..00000000000 --- a/src/main/integration-fanout.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - runBoundedIntegrationFanout, - runBoundedIntegrationSettledFanout -} from './integration-fanout' - -describe('runBoundedIntegrationFanout', () => { - it('bounds concurrency and preserves input order across out-of-order completions', async () => { - let active = 0 - let peak = 0 - const result = await runBoundedIntegrationFanout( - [0, 1, 2, 3, 4], - async (entry) => { - active += 1 - peak = Math.max(peak, active) - await new Promise((resolve) => setTimeout(resolve, (4 - entry) % 3)) - active -= 1 - return [entry] - }, - (items) => items, - { - maxConcurrent: 2, - limits: { maxPages: 5, maxItems: 5, maxRetainedBytes: 100 } - } - ) - - expect(peak).toBe(2) - expect(result).toEqual({ - results: [[0], [1], [2], [3], [4]], - truncated: false, - attemptedCount: 5 - }) - }) - - it('admits the exact aggregate item boundary and stops scheduling after item +1', async () => { - const visited: number[] = [] - const result = await runBoundedIntegrationFanout( - [0, 1, 2, 3], - async (entry) => { - visited.push(entry) - return [entry] - }, - (items) => items, - { - maxConcurrent: 2, - limits: { maxPages: 4, maxItems: 2, maxRetainedBytes: 100 } - } - ) - - expect(result).toEqual({ - results: [[0], [1]], - truncated: true, - attemptedCount: 2 - }) - expect(visited).toEqual([0, 1]) - }) - - it('does not retain or schedule later batches after byte +1', async () => { - const visited: number[] = [] - const result = await runBoundedIntegrationFanout( - [0, 1, 2], - async (entry) => { - visited.push(entry) - return entry === 0 ? ['a'] : ['aa'] - }, - (items) => items, - { - maxConcurrent: 1, - limits: { maxPages: 3, maxItems: 3, maxRetainedBytes: 10 } - } - ) - - expect(result).toEqual({ - results: [['a']], - truncated: true, - attemptedCount: 2 - }) - expect(visited).toEqual([0, 1]) - }) - - it('preserves settled success and failure order without rejecting the fan-out', async () => { - const result = await runBoundedIntegrationSettledFanout( - ['ok', 'bad', 'later'], - async (entry) => { - if (entry === 'bad') { - throw new Error('failed') - } - return [entry] - }, - (items) => items - ) - - expect(result.results).toMatchObject([ - { status: 'fulfilled', value: ['ok'] }, - { status: 'rejected', reason: { message: 'failed' } }, - { status: 'fulfilled', value: ['later'] } - ]) - }) -}) diff --git a/src/main/integration-fanout.ts b/src/main/integration-fanout.ts deleted file mode 100644 index a86d0f7047a..00000000000 --- a/src/main/integration-fanout.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { MAX_INTEGRATION_ACCOUNTS } from './integration-account-persistence-limits' -import { - INTEGRATION_PAGINATION_MAX_ITEMS, - INTEGRATION_PAGINATION_MAX_RETAINED_BYTES, - IntegrationPaginationBudget, - type IntegrationPaginationLimits -} from './integration-pagination-budget' - -export const INTEGRATION_FANOUT_MAX_CONCURRENT = 4 - -const DEFAULT_FANOUT_LIMITS: IntegrationPaginationLimits = { - maxPages: MAX_INTEGRATION_ACCOUNTS, - maxItems: INTEGRATION_PAGINATION_MAX_ITEMS, - maxRetainedBytes: INTEGRATION_PAGINATION_MAX_RETAINED_BYTES -} - -export function createIntegrationFanoutBudget(): IntegrationPaginationBudget { - return new IntegrationPaginationBudget(DEFAULT_FANOUT_LIMITS) -} - -export async function runBoundedIntegrationFanout( - entries: readonly TEntry[], - load: (entry: TEntry, index: number) => Promise, - retainedValues: (result: TResult) => readonly unknown[], - options: { - budget?: IntegrationPaginationBudget - maxConcurrent?: number - limits?: IntegrationPaginationLimits - } = {} -): Promise<{ results: TResult[]; truncated: boolean; attemptedCount: number }> { - const maxConcurrent = options.maxConcurrent ?? INTEGRATION_FANOUT_MAX_CONCURRENT - if (!Number.isSafeInteger(maxConcurrent) || maxConcurrent <= 0) { - throw new RangeError('Integration fan-out concurrency must be a positive safe integer') - } - - const budget = - options.budget ?? new IntegrationPaginationBudget(options.limits ?? DEFAULT_FANOUT_LIMITS) - const results: TResult[] = [] - let attemptedCount = 0 - for (let offset = 0; offset < entries.length; offset += maxConcurrent) { - const batch = entries.slice(offset, offset + maxConcurrent) - attemptedCount += batch.length - const loaded = await Promise.all( - batch.map((entry, batchIndex) => load(entry, offset + batchIndex)) - ) - for (const result of loaded) { - if (!budget.admitPage(retainedValues(result))) { - return { results, truncated: true, attemptedCount } - } - results.push(result) - } - if (!budget.canRequestPage && offset + batch.length < entries.length) { - return { results, truncated: true, attemptedCount } - } - } - return { results, truncated: false, attemptedCount } -} - -export function runBoundedIntegrationSettledFanout( - entries: readonly TEntry[], - load: (entry: TEntry, index: number) => Promise, - retainedValues: (result: TResult) => readonly unknown[] -): Promise<{ - results: PromiseSettledResult[] - truncated: boolean - attemptedCount: number -}> { - return runBoundedIntegrationFanout( - entries, - async (entry, index): Promise> => { - try { - return { status: 'fulfilled', value: await load(entry, index) } - } catch (reason) { - return { status: 'rejected', reason } - } - }, - (result) => - result.status === 'fulfilled' - ? retainedValues(result.value) - : [result.reason instanceof Error ? result.reason.message : String(result.reason)] - ) -} diff --git a/src/main/integration-pagination-budget.test.ts b/src/main/integration-pagination-budget.test.ts deleted file mode 100644 index fa90d052662..00000000000 --- a/src/main/integration-pagination-budget.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - IntegrationPaginationBudget, - IntegrationPaginationLimitError -} from './integration-pagination-budget' - -describe('IntegrationPaginationBudget', () => { - it('admits exact page and item boundaries and rejects page +1', () => { - const budget = new IntegrationPaginationBudget({ - maxPages: 1, - maxItems: 2, - maxRetainedBytes: 100 - }) - - expect(budget.admitPage(['a', 'b'])).toBe(true) - expect(budget.canRequestPage).toBe(false) - expect(budget.admitPage([])).toBe(false) - }) - - it('admits the exact retained-byte boundary and rejects byte +1', () => { - const exact = new IntegrationPaginationBudget({ - maxPages: 2, - maxItems: 2, - maxRetainedBytes: 5 - }) - const over = new IntegrationPaginationBudget({ - maxPages: 2, - maxItems: 2, - maxRetainedBytes: 5 - }) - - expect(exact.admitPage(['a'])).toBe(true) - expect(over.admitPage(['aa'])).toBe(false) - }) - - it('does not consume capacity when a page is rejected', () => { - const budget = new IntegrationPaginationBudget({ - maxPages: 1, - maxItems: 1, - maxRetainedBytes: 5 - }) - - expect(budget.admitPage(['aa'])).toBe(false) - expect(budget.admitPage(['a'])).toBe(true) - }) - - it('bounds SDK-style cumulative pages at exact and +1 byte sizes', () => { - const exact = new IntegrationPaginationBudget({ - maxPages: 2, - maxItems: 2, - maxRetainedBytes: 5 - }) - const over = new IntegrationPaginationBudget({ - maxPages: 2, - maxItems: 2, - maxRetainedBytes: 5 - }) - - expect(() => exact.assertCumulativePage(['a'])).not.toThrow() - expect(() => over.assertCumulativePage(['aa'])).toThrow(IntegrationPaginationLimitError) - }) -}) diff --git a/src/main/integration-pagination-budget.ts b/src/main/integration-pagination-budget.ts deleted file mode 100644 index 202243c6702..00000000000 --- a/src/main/integration-pagination-budget.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { - JsonStringifyByteLimitError, - stringifyJsonWithinByteLimit -} from '../shared/node-bounded-json-stringify' - -export const INTEGRATION_PAGINATION_MAX_PAGES = 100 -export const INTEGRATION_PAGINATION_MAX_ITEMS = 10_000 -export const INTEGRATION_PAGINATION_MAX_RETAINED_BYTES = 32 * 1024 * 1024 - -export type IntegrationPaginationLimits = { - maxPages: number - maxItems: number - maxRetainedBytes: number -} - -const DEFAULT_LIMITS: IntegrationPaginationLimits = { - maxPages: INTEGRATION_PAGINATION_MAX_PAGES, - maxItems: INTEGRATION_PAGINATION_MAX_ITEMS, - maxRetainedBytes: INTEGRATION_PAGINATION_MAX_RETAINED_BYTES -} - -export class IntegrationPaginationLimitError extends Error { - constructor() { - super('Integration pagination exceeded its retained result budget.') - this.name = 'IntegrationPaginationLimitError' - } -} - -export class IntegrationPaginationBudget { - private pages = 0 - private items = 0 - private retainedBytes = 0 - - constructor(private readonly limits: IntegrationPaginationLimits = DEFAULT_LIMITS) {} - - admitPage(pageItems: readonly unknown[]): boolean { - if ( - this.pages >= this.limits.maxPages || - pageItems.length > this.limits.maxItems - this.items - ) { - return false - } - const pageBytes = this.measureWithinRemainingBudget(pageItems) - if (pageBytes === null) { - return false - } - this.pages += 1 - this.items += pageItems.length - this.retainedBytes += pageBytes - return true - } - - assertCumulativePage(items: readonly unknown[]): void { - if (this.pages >= this.limits.maxPages || items.length > this.limits.maxItems) { - throw new IntegrationPaginationLimitError() - } - const measuredBytes = this.measureWithinTotalBudget(items) - if (measuredBytes === null) { - throw new IntegrationPaginationLimitError() - } - this.pages += 1 - this.items = items.length - this.retainedBytes = measuredBytes - } - - get canRequestPage(): boolean { - return ( - this.pages < this.limits.maxPages && - this.items < this.limits.maxItems && - this.retainedBytes <= this.limits.maxRetainedBytes - 2 - ) - } - - private measureWithinRemainingBudget(value: unknown): number | null { - const remaining = this.limits.maxRetainedBytes - this.retainedBytes - try { - return stringifyJsonWithinByteLimit(value, remaining).byteLength - } catch (error) { - if (error instanceof JsonStringifyByteLimitError) { - return null - } - throw error - } - } - - private measureWithinTotalBudget(value: unknown): number | null { - try { - return stringifyJsonWithinByteLimit(value, this.limits.maxRetainedBytes).byteLength - } catch (error) { - if (error instanceof JsonStringifyByteLimitError) { - return null - } - throw error - } - } -} diff --git a/src/main/ipc/ai-vault-host-scan-batches.ts b/src/main/ipc/ai-vault-host-scan-batches.ts deleted file mode 100644 index 488933bc684..00000000000 --- a/src/main/ipc/ai-vault-host-scan-batches.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { AiVaultListResult } from '../../shared/ai-vault-types' -import { mergeAiVaultListResults } from '../ai-vault/session-list-results' - -export const AI_VAULT_ALL_HOST_SCAN_CONCURRENCY = 4 - -export async function scanAiVaultHostsInBatches( - scans: readonly (() => Promise)[], - initialResults: readonly AiVaultListResult[], - limit: number | undefined -): Promise { - let merged = mergeAiVaultListResults(initialResults, limit) - for (let offset = 0; offset < scans.length; offset += AI_VAULT_ALL_HOST_SCAN_CONCURRENCY) { - const batch = scans.slice(offset, offset + AI_VAULT_ALL_HOST_SCAN_CONCURRENCY) - merged = mergeAiVaultListResults( - [merged, ...(await Promise.all(batch.map((scan) => scan())))], - limit - ) - } - return merged -} diff --git a/src/main/ipc/ai-vault.test.ts b/src/main/ipc/ai-vault.test.ts index 820779e05f3..5f4153106fd 100644 --- a/src/main/ipc/ai-vault.test.ts +++ b/src/main/ipc/ai-vault.test.ts @@ -4,10 +4,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { AiVaultListResult, AiVaultSession } from '../../shared/ai-vault-types' import type { IFilesystemProvider } from '../providers/types' import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform' -import { - AI_VAULT_SESSION_ID_MAX_UTF8_BYTES, - AI_VAULT_SESSION_LIST_CACHE_KEY_MAX_JSON_BYTES -} from '../ai-vault/session-list-retention' const mocks = vi.hoisted(() => ({ scanAiVaultSessions: vi.fn(), @@ -54,8 +50,7 @@ vi.mock('./ssh', () => ({ getActiveSshAiVaultHostInfos: mocks.getActiveSshAiVaultHostInfos })) -const { AI_VAULT_ALL_HOST_SCAN_CONCURRENCY, _internals, registerAiVaultHandlers } = - await import('./ai-vault') +const { _internals, registerAiVaultHandlers } = await import('./ai-vault') const provider = {} as IFilesystemProvider @@ -141,33 +136,6 @@ describe('listAiVaultSessions host routing', () => { ]) }) - it('bounds concurrent scans across a pathological runtime-host roster', async () => { - let active = 0 - let peak = 0 - mocks.getActiveSshAiVaultHostInfos.mockReturnValue([]) - mocks.scanRuntimeAiVaultSessions.mockImplementation(async (environmentId: string) => { - active += 1 - peak = Math.max(peak, active) - await Promise.resolve() - active -= 1 - return result([session(`runtime:${environmentId}`, environmentId)]) - }) - registerAiVaultHandlers({ - getActiveRuntimeAiVaultHostInfos: () => - Array.from({ length: 20 }, (_, index) => ({ - environmentId: `runtime-${index}`, - executionHostId: `runtime:runtime-${index}` as const - })), - scanRuntimeAiVaultSessions: mocks.scanRuntimeAiVaultSessions - }) - - const merged = await _internals.listAiVaultSessions({ executionHostScope: 'all' }) - - expect(mocks.scanRuntimeAiVaultSessions).toHaveBeenCalledTimes(20) - expect(peak).toBeLessThanOrEqual(AI_VAULT_ALL_HOST_SCAN_CONCURRENCY) - expect(merged.sessions).toHaveLength(21) - }) - it('keeps local and SSH results when runtime host discovery fails', async () => { registerAiVaultHandlers({ getActiveRuntimeAiVaultHostInfos: () => { @@ -237,29 +205,6 @@ describe('listAiVaultSessions host routing', () => { expect(mocks.scanAiVaultSessions).toHaveBeenCalledTimes(1) expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledTimes(1) }) - - it('bounds a pathological multi-host result before retaining it', async () => { - const oversized = session('ssh:dev-box', 'remote-session') - oversized.sessionId = 'x'.repeat(AI_VAULT_SESSION_ID_MAX_UTF8_BYTES + 1) - mocks.scanRemoteAiVaultSessions.mockResolvedValue(result([oversized])) - - const first = await _internals.listAiVaultSessions({ executionHostScope: 'ssh:dev-box' }) - const second = await _internals.listAiVaultSessions({ executionHostScope: 'ssh:dev-box' }) - - expect(first.sessions).toEqual([]) - expect(first.issues.at(-1)?.message).toContain('AI Vault omitted') - expect(second).toBe(first) - expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledTimes(1) - }) - - it('bypasses the multi-host cache when its key exceeds the memory limit', async () => { - const scopePaths = ['x'.repeat(AI_VAULT_SESSION_LIST_CACHE_KEY_MAX_JSON_BYTES + 1)] - - await _internals.listAiVaultSessions({ executionHostScope: 'ssh:dev-box', scopePaths }) - await _internals.listAiVaultSessions({ executionHostScope: 'ssh:dev-box', scopePaths }) - - expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledTimes(2) - }) }) describe('prepareSessionResume IPC', () => { diff --git a/src/main/ipc/ai-vault.ts b/src/main/ipc/ai-vault.ts index 7c64344e541..ebfd52e63a7 100644 --- a/src/main/ipc/ai-vault.ts +++ b/src/main/ipc/ai-vault.ts @@ -11,8 +11,7 @@ import { scanRemoteAiVaultSessions } from '../ai-vault/remote-session-scanner' import { listClaudeSubagentSessions } from '../ai-vault/session-scanner-claude-subagents' import { claudeProjectsRootDirs } from '../ai-vault/session-scanner-source-discovery' import { isPathInsideOrEqual } from '../../shared/cross-platform-path' -import { aiVaultScanIssueResult } from '../ai-vault/session-list-results' -import * as aiVaultListRetention from '../ai-vault/session-list-retention' +import { aiVaultScanIssueResult, mergeAiVaultListResults } from '../ai-vault/session-list-results' import type { AiVaultListArgs, AiVaultListResult, @@ -33,9 +32,6 @@ import { SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE } from '../providers/ssh-filesystem-dispatch' import { getActiveSshAiVaultHostInfo, getActiveSshAiVaultHostInfos } from './ssh' -import { scanAiVaultHostsInBatches } from './ai-vault-host-scan-batches' - -export { AI_VAULT_ALL_HOST_SCAN_CONCURRENCY } from './ai-vault-host-scan-batches' const AI_VAULT_CACHE_TTL_MS = 15_000 const AI_VAULT_ALL_HOST_RUNTIME_TIMEOUT_MS = 3_000 @@ -50,7 +46,9 @@ type AiVaultHandlerOptions = AiVaultSessionSources & ) => Promise } -type RuntimeAiVaultScanOptions = { timeoutMs?: number } +type RuntimeAiVaultScanOptions = { + timeoutMs?: number +} type CachedAiVaultList = { key: string @@ -72,21 +70,19 @@ async function listAiVaultSessions(args?: AiVaultListArgs): Promise { - const bounded = aiVaultListRetention.boundAiVaultListResult(result) cachedList = { key, - result: bounded, + result, expiresAt: Date.now() + AI_VAULT_CACHE_TTL_MS } - return bounded + return result }) .finally(() => { // Only clear tracking if it still refers to this request: a concurrent @@ -126,19 +121,21 @@ async function scanAiVaultSessionsByHostScope( if (executionHostScope === 'all') { const runtimeHosts = getActiveRuntimeAiVaultHostInfosResult() const runtimeResults = runtimeHosts.issue ? [runtimeHosts.issue] : [] - const scans: (() => Promise)[] = [() => scanLocalAiVaultSessions(args)] - scans.push( - ...getActiveSshAiVaultHostInfos().map( - (hostInfo) => () => scanSshAiVaultSessions(hostInfo.targetId, args) - ), - ...runtimeHosts.hostInfos.map( - (hostInfo) => () => + return mergeAiVaultListResults( + await Promise.all([ + scanLocalAiVaultSessions(args), + ...getActiveSshAiVaultHostInfos().map((hostInfo) => + scanSshAiVaultSessions(hostInfo.targetId, args) + ), + ...runtimeHosts.hostInfos.map((hostInfo) => scanRuntimeAiVaultSessions(hostInfo, args, { timeoutMs: AI_VAULT_ALL_HOST_RUNTIME_TIMEOUT_MS }) - ) + ), + ...runtimeResults + ]), + args?.limit ) - return scanAiVaultHostsInBatches(scans, runtimeResults, args?.limit) } const parsed = parseExecutionHostId(executionHostScope) diff --git a/src/main/ipc/app.test.ts b/src/main/ipc/app.test.ts index 79e044dba30..7702dcd6349 100644 --- a/src/main/ipc/app.test.ts +++ b/src/main/ipc/app.test.ts @@ -33,13 +33,12 @@ vi.mock('node:child_process', () => ({ // events, so tests drive the async command lifecycle readCommandStdout expects. function createFakeSpawnChild(options: { stdout?: string - stdoutFragmentSize?: number code?: number error?: Error pid?: number hang?: boolean }): EventEmitter & { pid: number; kill: ReturnType; stdout: EventEmitter } { - const { stdout, stdoutFragmentSize, code = 0, error, pid = 4242, hang = false } = options + const { stdout, code = 0, error, pid = 4242, hang = false } = options const child = new EventEmitter() as EventEmitter & { pid: number kill: ReturnType @@ -59,13 +58,7 @@ function createFakeSpawnChild(options: { return } if (stdout !== undefined) { - if (stdoutFragmentSize === undefined) { - stdoutStream.emit('data', stdout) - } else { - for (let offset = 0; offset < stdout.length; offset += stdoutFragmentSize) { - stdoutStream.emit('data', stdout.slice(offset, offset + stdoutFragmentSize)) - } - } + stdoutStream.emit('data', stdout) } child.emit('close', code) }) @@ -349,44 +342,6 @@ describe('registerAppHandlers', () => { expect(spawnMock).toHaveBeenCalledTimes(2) }) - it('kills an oversized selected-input probe before using the layout fallback', async () => { - Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }) - spawnMock - .mockImplementationOnce(() => - createFakeSpawnChild({ stdout: 'x'.repeat(1024 * 1024 + 1), pid: 4242 }) - ) - .mockImplementationOnce(() => createFakeSpawnChild({ stdout: 'com.apple.keylayout.ABC\n' })) - registerAppHandlers({} as never) - - await expect(handlers.get('app:getKeyboardInputSourceId')?.(null)).resolves.toBe( - 'com.apple.keylayout.ABC' - ) - expect(processKillSpy).toHaveBeenCalledWith(-4242, 'SIGKILL') - expect(spawnMock).toHaveBeenCalledTimes(2) - }) - - it('parses selected-input JSON after 100,000 one-byte fragments', async () => { - Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }) - const selected = JSON.stringify([ - { - 'Bundle ID': 'com.apple.inputmethod.SCIM', - 'Input Mode': 'com.apple.inputmethod.SCIM.ITABC', - InputSourceKind: 'Input Mode' - } - ]) - spawnMock.mockImplementationOnce(() => - createFakeSpawnChild({ - stdout: `${' '.repeat(100_000)}${selected}`, - stdoutFragmentSize: 1 - }) - ) - registerAppHandlers({} as never) - - await expect(handlers.get('app:getKeyboardInputSourceId')?.(null)).resolves.toBe( - 'com.apple.inputmethod.SCIM.ITABC' - ) - }) - it('falls back when macOS keyboard input source probes never report completion', async () => { Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }) spawnMock.mockImplementation(() => createFakeSpawnChild({ pid: 4242, hang: true })) diff --git a/src/main/ipc/app.ts b/src/main/ipc/app.ts index 1bc47fdc3bd..7485c03b5f0 100644 --- a/src/main/ipc/app.ts +++ b/src/main/ipc/app.ts @@ -6,7 +6,6 @@ import { app, BrowserWindow, dialog, ipcMain, type IpcMainInvokeEvent } from 'el import { is } from '@electron-toolkit/utils' import type { AppIdentity } from '../../shared/app-identity' import type { FloatingTerminalCwdRequest, MarkdownDocument } from '../../shared/types' -import { GrowingByteBuffer } from '../../shared/growing-byte-buffer' import { relaunchApp } from '../app-relaunch' import type { Store } from '../persistence' import { getDevInstanceIdentity } from '../startup/dev-instance-identity' @@ -25,7 +24,6 @@ import { isMarkdownDocumentName, markdownDocumentFromFilePath } from './markdown import { registerRendererShutdownCheckpointHandler } from './renderer-shutdown-checkpoint' const KEYBOARD_INPUT_SOURCE_TIMEOUT_MS = 500 -const KEYBOARD_INPUT_SOURCE_MAX_OUTPUT_BYTES = 1024 * 1024 const MAC_HITOOLBOX_DOMAIN = 'com.apple.HIToolbox' // Why: defaults export reads live prefs (on-disk plist lags cfprefsd); xml1 dodges plutil's json abort on macOS 15 input-source arrays; absolute paths so a minimal PATH can't shadow the tools. const MAC_SELECTED_INPUT_SOURCES_JSON_COMMAND = [ @@ -118,7 +116,6 @@ function readCommandStdout( return new Promise((resolve, reject) => { let settled = false let child: ReturnType | undefined - const stdout = new GrowingByteBuffer() // Why: killing only the shell orphans pipeline stages; detached spawn lets one negative-pid SIGKILL reap the whole group. const killTree = (): void => { @@ -149,19 +146,14 @@ function readCommandStdout( settled = true clearTimeout(timer) callback() - stdout.clear() } try { child = spawn(command, args, { detached: true, stdio: ['ignore', 'pipe', 'ignore'] }) + let stdout = '' child.stdout?.setEncoding('utf8') child.stdout?.on('data', (chunk: string) => { - const bytes = Buffer.from(chunk) - if (stdout.byteLength + bytes.byteLength > KEYBOARD_INPUT_SOURCE_MAX_OUTPUT_BYTES) { - failWith(new Error(`${command} exceeded its output limit`)) - return - } - stdout.append(bytes) + stdout += chunk }) const failWith = (error: Error): void => { killTree() @@ -173,7 +165,7 @@ function readCommandStdout( child.on('close', (code, signal) => { settle(() => code === 0 - ? resolve(stdout.takeString()) + ? resolve(stdout) : reject( new Error( `${command} exited with ${signal ? `signal ${signal}` : `code ${code ?? 'unknown'}`}` diff --git a/src/main/ipc/custom-pet-file-reader.test.ts b/src/main/ipc/custom-pet-file-reader.test.ts deleted file mode 100644 index d09a613d448..00000000000 --- a/src/main/ipc/custom-pet-file-reader.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { MAX_CUSTOM_PET_FILE_BYTES } from '../../shared/custom-pet-media-limits' - -const { openMock } = vi.hoisted(() => ({ openMock: vi.fn() })) - -vi.mock('node:fs/promises', () => ({ open: openMock })) - -import { CustomPetFileTooLargeError, readCustomPetFile } from './custom-pet-file-reader' - -function fileHandle({ - afterSize, - beforeSize, - extraByte = false -}: { - afterSize?: number - beforeSize: number - extraByte?: boolean -}) { - let statCalls = 0 - return { - close: vi.fn().mockResolvedValue(undefined), - read: vi.fn(async (target: Buffer, offset: number, length: number, position: number) => { - if (position < beforeSize) { - const bytesRead = Math.min(length, beforeSize - position) - target.fill(0x2a, offset, offset + bytesRead) - return { bytesRead, buffer: target } - } - if (extraByte) { - target[offset] = 0x2b - return { bytesRead: 1, buffer: target } - } - return { bytesRead: 0, buffer: target } - }), - stat: vi.fn(async () => { - statCalls += 1 - return { - isFile: () => true, - size: statCalls === 1 ? beforeSize : (afterSize ?? beforeSize) - } - }) - } -} - -beforeEach(() => openMock.mockReset()) - -describe('readCustomPetFile', () => { - it('reads a stable file into an exact ArrayBuffer', async () => { - const handle = fileHandle({ beforeSize: 3 }) - openMock.mockResolvedValue(handle) - - const result = await readCustomPetFile('/stored/pet.png') - - expect([...new Uint8Array(result)]).toEqual([0x2a, 0x2a, 0x2a]) - expect(handle.close).toHaveBeenCalledOnce() - }) - - it('rejects an oversized file before issuing a read allocation', async () => { - const handle = fileHandle({ beforeSize: MAX_CUSTOM_PET_FILE_BYTES + 1 }) - openMock.mockResolvedValue(handle) - - await expect(readCustomPetFile('/stored/replaced-pet.png')).rejects.toBeInstanceOf( - CustomPetFileTooLargeError - ) - expect(handle.read).not.toHaveBeenCalled() - expect(handle.close).toHaveBeenCalledOnce() - }) - - it('rejects a file that grows between its handle stat and bounded read', async () => { - const handle = fileHandle({ beforeSize: 3, afterSize: 4, extraByte: true }) - openMock.mockResolvedValue(handle) - - await expect(readCustomPetFile('/stored/growing-pet.png')).rejects.toThrow( - 'changed while it was being read' - ) - expect(handle.close).toHaveBeenCalledOnce() - }) -}) diff --git a/src/main/ipc/custom-pet-file-reader.ts b/src/main/ipc/custom-pet-file-reader.ts deleted file mode 100644 index c9f03db0c7d..00000000000 --- a/src/main/ipc/custom-pet-file-reader.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { open } from 'node:fs/promises' -import { MAX_CUSTOM_PET_FILE_BYTES } from '../../shared/custom-pet-media-limits' - -const MAX_CUSTOM_PET_READ_CALLS = 1_024 - -export class CustomPetFileTooLargeError extends Error { - constructor(maxBytes: number) { - super(`Custom pet file exceeds the ${maxBytes} byte limit.`) - this.name = 'CustomPetFileTooLargeError' - } -} - -export async function readCustomPetFile( - filePath: string, - maxBytes = MAX_CUSTOM_PET_FILE_BYTES -): Promise { - const file = await open(filePath, 'r') - try { - const beforeRead = await file.stat() - if (!beforeRead.isFile()) { - throw new Error('Custom pet path is not a file.') - } - if ( - !Number.isSafeInteger(beforeRead.size) || - beforeRead.size < 0 || - beforeRead.size > maxBytes - ) { - throw new CustomPetFileTooLargeError(maxBytes) - } - - const bytes = new ArrayBuffer(beforeRead.size) - const target = Buffer.from(bytes) - let offset = 0 - let readCalls = 0 - while (offset < target.byteLength && readCalls < MAX_CUSTOM_PET_READ_CALLS) { - const result = await file.read(target, offset, target.byteLength - offset, offset) - readCalls += 1 - if (result.bytesRead === 0) { - break - } - offset += result.bytesRead - } - - const sentinel = Buffer.allocUnsafe(1) - const extra = await file.read(sentinel, 0, 1, offset) - const afterRead = await file.stat() - if ( - offset !== beforeRead.size || - extra.bytesRead !== 0 || - afterRead.size !== beforeRead.size || - bytes.byteLength > maxBytes - ) { - throw new Error('Custom pet file changed while it was being read.') - } - return bytes - } finally { - await file.close() - } -} diff --git a/src/main/ipc/emulator-frame-stream-admission.test.ts b/src/main/ipc/emulator-frame-stream-admission.test.ts deleted file mode 100644 index c144693682c..00000000000 --- a/src/main/ipc/emulator-frame-stream-admission.test.ts +++ /dev/null @@ -1,190 +0,0 @@ -import { EventEmitter } from 'node:events' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -type StreamMock = { - start: ReturnType - stop: ReturnType -} - -const { handlers, streamState } = vi.hoisted(() => ({ - handlers: new Map unknown>(), - streamState: { - instances: [] as StreamMock[], - startError: null as Error | null - } -})) - -vi.mock('electron', () => ({ - ipcMain: { - handle: (channel: string, listener: (event: unknown, args: unknown) => unknown): void => { - handlers.set(channel, listener) - }, - on: (channel: string, listener: (event: unknown, args: unknown) => unknown): void => { - handlers.set(channel, listener) - } - }, - BrowserWindow: { - fromWebContents: (owner: { isDestroyed: () => boolean }) => (owner.isDestroyed() ? null : {}) - } -})) - -vi.mock('../emulator/mjpeg-frame-stream', () => ({ - MjpegFrameStream: class implements StreamMock { - readonly start = vi.fn(() => { - if (streamState.startError) { - throw streamState.startError - } - }) - readonly stop = vi.fn() - - constructor() { - streamState.instances.push(this) - } - } -})) - -import { - EMULATOR_FRAME_STREAM_MAX_SESSIONS_PER_RENDERER, - EMULATOR_FRAME_STREAM_MAX_SESSIONS_TOTAL, - registerEmulatorFrameStreamHandlers -} from './emulator-frame-stream' - -type Owner = EventEmitter & { - destroy: () => void - isDestroyed: () => boolean - send: ReturnType -} - -const owners: Owner[] = [] - -function makeOwner(): Owner { - let destroyed = false - const owner = new EventEmitter() as Owner - owner.isDestroyed = () => destroyed - owner.send = vi.fn() - owner.destroy = () => { - if (destroyed) { - return - } - destroyed = true - owner.emit('destroyed') - } - owners.push(owner) - return owner -} - -function handler(channel: string): (event: unknown, args: unknown) => unknown { - const registered = handlers.get(channel) - if (!registered) { - throw new Error(`Missing handler for ${channel}`) - } - return registered -} - -function startStream(owner: Owner): string { - const result = handler('emulator:frameStreamStart')( - { sender: owner }, - { streamUrl: 'http://127.0.0.1:3100/stream.mjpeg' } - ) as { streamId: string } - return result.streamId -} - -function stopStream(owner: Owner, streamId: string): void { - handler('emulator:frameStreamStop')({ sender: owner }, { streamId }) -} - -function startedStreamCount(): number { - return streamState.instances.filter((stream) => stream.start.mock.calls.length > 0).length -} - -beforeEach(() => { - handlers.clear() - streamState.instances.length = 0 - streamState.startError = null - registerEmulatorFrameStreamHandlers() -}) - -afterEach(() => { - for (const owner of owners) { - owner.destroy() - } - owners.length = 0 - streamState.startError = null -}) - -describe('emulator frame stream admission', () => { - it('caps active sessions per renderer without affecting another renderer', () => { - const firstOwner = makeOwner() - for (let index = 0; index < EMULATOR_FRAME_STREAM_MAX_SESSIONS_PER_RENDERER; index += 1) { - startStream(firstOwner) - } - - expect(() => startStream(firstOwner)).toThrow(/renderer can have at most/) - expect(() => startStream(makeOwner())).not.toThrow() - expect(startedStreamCount()).toBe(EMULATOR_FRAME_STREAM_MAX_SESSIONS_PER_RENDERER + 1) - }) - - it('caps active sessions across renderers', () => { - const ownerCount = - EMULATOR_FRAME_STREAM_MAX_SESSIONS_TOTAL / EMULATOR_FRAME_STREAM_MAX_SESSIONS_PER_RENDERER - for (let ownerIndex = 0; ownerIndex < ownerCount; ownerIndex += 1) { - const owner = makeOwner() - for ( - let streamIndex = 0; - streamIndex < EMULATOR_FRAME_STREAM_MAX_SESSIONS_PER_RENDERER; - streamIndex += 1 - ) { - startStream(owner) - } - } - - expect(() => startStream(makeOwner())).toThrow(/Orca can have at most/) - expect(startedStreamCount()).toBe(EMULATOR_FRAME_STREAM_MAX_SESSIONS_TOTAL) - }) - - it('releases capacity on an owned explicit stop and ignores another renderer', () => { - const owner = makeOwner() - const otherOwner = makeOwner() - const firstStreamId = startStream(owner) - startStream(owner) - const firstStream = streamState.instances[0] - - stopStream(otherOwner, firstStreamId) - expect(firstStream.stop).not.toHaveBeenCalled() - expect(() => startStream(owner)).toThrow(/renderer can have at most/) - - stopStream(owner, firstStreamId) - expect(firstStream.stop).toHaveBeenCalledOnce() - expect(() => startStream(owner)).not.toThrow() - }) - - it('releases every owned session when its renderer is destroyed', () => { - const owner = makeOwner() - startStream(owner) - startStream(owner) - const ownedStreams = streamState.instances.slice() - - owner.destroy() - - expect(owner.listenerCount('destroyed')).toBe(0) - for (const stream of ownedStreams) { - expect(stream.stop).toHaveBeenCalledOnce() - } - expect(() => startStream(makeOwner())).not.toThrow() - }) - - it('releases admission and the destroyed listener when start throws', () => { - const owner = makeOwner() - const startError = new Error('request setup failed') - streamState.startError = startError - - expect(() => startStream(owner)).toThrow(startError) - expect(owner.listenerCount('destroyed')).toBe(0) - expect(streamState.instances[0].stop).toHaveBeenCalledOnce() - - streamState.startError = null - for (let index = 0; index < EMULATOR_FRAME_STREAM_MAX_SESSIONS_PER_RENDERER; index += 1) { - expect(() => startStream(owner)).not.toThrow() - } - }) -}) diff --git a/src/main/ipc/emulator-frame-stream-delivery.test.ts b/src/main/ipc/emulator-frame-stream-delivery.test.ts deleted file mode 100644 index 66e36846d5a..00000000000 --- a/src/main/ipc/emulator-frame-stream-delivery.test.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { EventEmitter } from 'node:events' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -type StreamCallbacks = { - onError: (message: string) => void - onFrame: (frame: Buffer) => void -} - -const { handlers, streamState } = vi.hoisted(() => ({ - handlers: new Map unknown>(), - streamState: { - callbacks: [] as StreamCallbacks[] - } -})) - -vi.mock('electron', () => ({ - ipcMain: { - handle: (channel: string, listener: (event: unknown, args: unknown) => unknown): void => { - handlers.set(channel, listener) - }, - on: (channel: string, listener: (event: unknown, args: unknown) => unknown): void => { - handlers.set(channel, listener) - } - }, - BrowserWindow: { fromWebContents: () => ({}) } -})) - -vi.mock('../emulator/mjpeg-frame-stream', () => ({ - MjpegFrameStream: class { - readonly start = vi.fn() - readonly stop = vi.fn() - - constructor(_url: string, callbacks: StreamCallbacks) { - streamState.callbacks.push(callbacks) - } - } -})) - -import { registerEmulatorFrameStreamHandlers } from './emulator-frame-stream' - -type Owner = EventEmitter & { - isDestroyed: () => boolean - send: ReturnType -} - -function makeOwner(): Owner { - const owner = new EventEmitter() as Owner - owner.isDestroyed = () => false - owner.send = vi.fn() - return owner -} - -function handler(channel: string): (event: unknown, args: unknown) => unknown { - const registered = handlers.get(channel) - if (!registered) { - throw new Error(`Missing handler for ${channel}`) - } - return registered -} - -function startStream(owner: Owner): string { - return ( - handler('emulator:frameStreamStart')( - { sender: owner }, - { streamUrl: 'http://127.0.0.1:3100/stream.mjpeg' } - ) as { streamId: string } - ).streamId -} - -function frameMessages(owner: Owner): { - streamId: string - deliveryId: number - bytes: ArrayBuffer -}[] { - return owner.send.mock.calls - .filter(([channel]) => channel === 'emulator:frameStreamFrame') - .map(([, payload]) => payload) -} - -beforeEach(() => { - handlers.clear() - streamState.callbacks.length = 0 - registerEmulatorFrameStreamHandlers() -}) - -describe('emulator frame stream delivery', () => { - it('keeps only the latest frame while waiting for an exact renderer ack', () => { - const owner = makeOwner() - const otherOwner = makeOwner() - const streamId = startStream(owner) - const callbacks = streamState.callbacks[0] - - for (let value = 0; value < 100; value += 1) { - callbacks.onFrame(Buffer.from([value])) - } - - expect(frameMessages(owner)).toHaveLength(1) - expect([...new Uint8Array(frameMessages(owner)[0].bytes)]).toEqual([0]) - - handler('emulator:frameStreamFrameAck')({ sender: otherOwner }, { streamId, deliveryId: 1 }) - expect(frameMessages(owner)).toHaveLength(1) - - handler('emulator:frameStreamFrameAck')({ sender: owner }, { streamId, deliveryId: 1 }) - expect(frameMessages(owner)).toHaveLength(2) - expect(frameMessages(owner)[1].deliveryId).toBe(2) - expect([...new Uint8Array(frameMessages(owner)[1].bytes)]).toEqual([99]) - - handler('emulator:frameStreamFrameAck')({ sender: owner }, { streamId, deliveryId: 1 }) - expect(frameMessages(owner)).toHaveLength(2) - }) - - it('coalesces repeated errors until the renderer proves progress', () => { - const owner = makeOwner() - const streamId = startStream(owner) - const callbacks = streamState.callbacks[0] - - callbacks.onError('first') - callbacks.onError('second') - expect( - owner.send.mock.calls.filter(([channel]) => channel === 'emulator:frameStreamError') - ).toHaveLength(1) - - callbacks.onFrame(Buffer.from([1])) - handler('emulator:frameStreamFrameAck')({ sender: owner }, { streamId, deliveryId: 1 }) - callbacks.onError('after progress') - expect( - owner.send.mock.calls.filter(([channel]) => channel === 'emulator:frameStreamError') - ).toHaveLength(2) - }) - - it('drops retained delivery state when the stream stops', () => { - const owner = makeOwner() - const streamId = startStream(owner) - const callbacks = streamState.callbacks[0] - callbacks.onFrame(Buffer.from([1])) - callbacks.onFrame(Buffer.from([2])) - - handler('emulator:frameStreamStop')({ sender: owner }, { streamId }) - callbacks.onFrame(Buffer.from([3])) - handler('emulator:frameStreamFrameAck')({ sender: owner }, { streamId, deliveryId: 1 }) - - expect(frameMessages(owner)).toHaveLength(1) - }) -}) diff --git a/src/main/ipc/emulator-frame-stream.ts b/src/main/ipc/emulator-frame-stream.ts index 0907b044bd4..4cf8eee1a5b 100644 --- a/src/main/ipc/emulator-frame-stream.ts +++ b/src/main/ipc/emulator-frame-stream.ts @@ -6,47 +6,20 @@ type FrameStreamSession = { owner: WebContents stream: MjpegFrameStream onOwnerDestroyed: () => void - nextDeliveryId: number - inFlightDeliveryId: number | null - pendingFrame: ArrayBuffer | null - errorDelivered: boolean } -export const EMULATOR_FRAME_STREAM_MAX_SESSIONS_TOTAL = 8 -export const EMULATOR_FRAME_STREAM_MAX_SESSIONS_PER_RENDERER = 2 - const sessions = new Map() -function assertFrameStreamCapacity(owner: WebContents): void { - let ownerSessionCount = 0 - for (const session of sessions.values()) { - if (session.owner === owner) { - ownerSessionCount += 1 - } - } - if (ownerSessionCount >= EMULATOR_FRAME_STREAM_MAX_SESSIONS_PER_RENDERER) { - throw new Error( - `A renderer can have at most ${EMULATOR_FRAME_STREAM_MAX_SESSIONS_PER_RENDERER} active emulator frame streams.` - ) - } - if (sessions.size >= EMULATOR_FRAME_STREAM_MAX_SESSIONS_TOTAL) { - throw new Error( - `Orca can have at most ${EMULATOR_FRAME_STREAM_MAX_SESSIONS_TOTAL} active emulator frame streams.` - ) - } -} - -function stopFrameStream(streamId: string, owner: WebContents): void { +function stopFrameStream(streamId: string): void { const session = sessions.get(streamId) - if (!session || session.owner !== owner) { + if (!session) { return } - sessions.delete(streamId) + session.stream.stop() // Why: `.once('destroyed')` self-removes only when that event fires (window // close), so an explicit stop must drop it or each show/hide cycle leaks one. session.owner.removeListener('destroyed', session.onOwnerDestroyed) - session.pendingFrame = null - session.stream.stop() + sessions.delete(streamId) } function frameToArrayBuffer(frame: Buffer): ArrayBuffer { @@ -55,47 +28,6 @@ function frameToArrayBuffer(frame: Buffer): ArrayBuffer { return arrayBuffer } -function sendFrame(streamId: string, session: FrameStreamSession, bytes: ArrayBuffer): void { - const deliveryId = session.nextDeliveryId - session.nextDeliveryId += 1 - session.inFlightDeliveryId = deliveryId - try { - session.owner.send('emulator:frameStreamFrame', { streamId, deliveryId, bytes }) - } catch { - stopFrameStream(streamId, session.owner) - } -} - -function queueFrame(streamId: string, frame: Buffer): void { - const session = sessions.get(streamId) - if (!session || session.owner.isDestroyed()) { - return - } - const bytes = frameToArrayBuffer(frame) - if (session.inFlightDeliveryId !== null) { - session.pendingFrame = bytes - return - } - sendFrame(streamId, session, bytes) -} - -function acknowledgeFrame( - owner: WebContents, - args: { streamId: string; deliveryId: number } -): void { - const session = sessions.get(args.streamId) - if (!session || session.owner !== owner || session.inFlightDeliveryId !== args.deliveryId) { - return - } - session.inFlightDeliveryId = null - session.errorDelivered = false - const pendingFrame = session.pendingFrame - session.pendingFrame = null - if (pendingFrame) { - sendFrame(args.streamId, session, pendingFrame) - } -} - export function registerEmulatorFrameStreamHandlers(): void { ipcMain.handle( 'emulator:frameStreamStart', @@ -113,56 +45,31 @@ export function registerEmulatorFrameStreamHandlers(): void { args.streamUrl, { onError: (message) => { - const session = sessions.get(streamId) - if (!owner.isDestroyed() && session && !session.errorDelivered) { - session.errorDelivered = true - try { - owner.send('emulator:frameStreamError', { streamId, message }) - } catch { - stopFrameStream(streamId, owner) - } + if (!owner.isDestroyed()) { + owner.send('emulator:frameStreamError', { streamId, message }) } }, - onFrame: (frame) => queueFrame(streamId, frame) + onFrame: (frame) => { + if (!owner.isDestroyed()) { + owner.send('emulator:frameStreamFrame', { + streamId, + bytes: frameToArrayBuffer(frame) + }) + } + } }, args.streamKey ) - assertFrameStreamCapacity(owner) - const onOwnerDestroyed = (): void => stopFrameStream(streamId, owner) - sessions.set(streamId, { - owner, - stream, - onOwnerDestroyed, - nextDeliveryId: 1, - inFlightDeliveryId: null, - pendingFrame: null, - errorDelivered: false - }) + const onOwnerDestroyed = (): void => stopFrameStream(streamId) + sessions.set(streamId, { owner, stream, onOwnerDestroyed }) owner.once('destroyed', onOwnerDestroyed) - try { - stream.start() - } catch (error) { - stopFrameStream(streamId, owner) - throw error - } + stream.start() return { streamId } } ) - ipcMain.handle('emulator:frameStreamStop', (event, args: { streamId: string }) => { - stopFrameStream(args.streamId, event.sender) + ipcMain.handle('emulator:frameStreamStop', (_event, args: { streamId: string }) => { + stopFrameStream(args.streamId) }) - ipcMain.on( - 'emulator:frameStreamFrameAck', - (event, args: { streamId: string; deliveryId: number }) => { - if ( - typeof args?.streamId === 'string' && - Number.isSafeInteger(args.deliveryId) && - args.deliveryId > 0 - ) { - acknowledgeFrame(event.sender, args) - } - } - ) } diff --git a/src/main/ipc/emulator-stream-listener-cleanup.test.ts b/src/main/ipc/emulator-stream-listener-cleanup.test.ts index d5cb28e9bfa..16e332ad14a 100644 --- a/src/main/ipc/emulator-stream-listener-cleanup.test.ts +++ b/src/main/ipc/emulator-stream-listener-cleanup.test.ts @@ -9,9 +9,6 @@ vi.mock('electron', () => ({ ipcMain: { handle: (channel: string, listener: (event: unknown, args: unknown) => unknown) => { handlers.set(channel, listener) - }, - on: (channel: string, listener: (event: unknown, args: unknown) => unknown) => { - handlers.set(channel, listener) } }, // Any non-null return marks the sender as a real BrowserWindow renderer. @@ -26,8 +23,6 @@ vi.mock('../emulator/mjpeg-frame-stream', () => ({ })) vi.mock('../emulator/scrcpy-video-registry', () => ({ - SCRCPY_VIDEO_MAX_GOP_FRAMES: 120, - SCRCPY_VIDEO_MAX_REPLAY_BYTES_PER_DEVICE: 32 * 1024 * 1024, scrcpyVideoRegistry: { subscribe: () => () => {} } })) diff --git a/src/main/ipc/emulator-video-frame-delivery.test.ts b/src/main/ipc/emulator-video-frame-delivery.test.ts deleted file mode 100644 index ae81b2c5431..00000000000 --- a/src/main/ipc/emulator-video-frame-delivery.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { - SCRCPY_VIDEO_MAX_GOP_FRAMES, - SCRCPY_VIDEO_MAX_REPLAY_BYTES_PER_DEVICE, - type ScrcpyVideoFrameMessage -} from '../emulator/scrcpy-video-registry' -import { EmulatorVideoFrameDelivery } from './emulator-video-frame-delivery' - -function frame( - pts: string, - byteLength = 1, - options: { config?: boolean; keyFrame?: boolean } = {} -): ScrcpyVideoFrameMessage { - return { - config: options.config === true, - keyFrame: options.keyFrame === true, - pts, - bytes: { byteLength } as ArrayBuffer - } -} - -describe('EmulatorVideoFrameDelivery', () => { - it('bounds a full cached replay behind one in-flight renderer message', () => { - const send = vi.fn() - const delivery = new EmulatorVideoFrameDelivery(send) - const replay = [ - frame('config', 1, { config: true }), - frame('key', 1, { keyFrame: true }), - ...Array.from({ length: SCRCPY_VIDEO_MAX_GOP_FRAMES - 1 }, (_, index) => - frame(`delta-${index}`) - ) - ] - - for (const replayed of replay) { - delivery.enqueue(replayed) - } - expect(send).toHaveBeenCalledTimes(1) - - for (let deliveryId = 1; deliveryId <= replay.length; deliveryId += 1) { - delivery.acknowledge(deliveryId) - } - expect(send).toHaveBeenCalledTimes(replay.length) - expect(send.mock.calls.map(([sent]) => sent.pts)).toEqual( - replay.map((replayed) => replayed.pts) - ) - }) - - it('waits for a keyframe after a stalled renderer overruns the frame bound', () => { - const send = vi.fn() - const delivery = new EmulatorVideoFrameDelivery(send) - delivery.enqueue(frame('in-flight')) - for (let index = 0; index < SCRCPY_VIDEO_MAX_GOP_FRAMES + 2; index += 1) { - delivery.enqueue(frame(`dropped-${index}`)) - } - - delivery.acknowledge(1) - delivery.enqueue(frame('still-dropped')) - expect(send).toHaveBeenCalledTimes(1) - - delivery.enqueue(frame('config', 1, { config: true })) - delivery.enqueue(frame('recovery-key', 1, { keyFrame: true })) - expect(send.mock.calls[1][0].pts).toBe('config') - delivery.acknowledge(2) - expect(send.mock.calls[2][0].pts).toBe('recovery-key') - }) - - it('recovers from a recent queued keyframe without retaining the older GOP', () => { - const send = vi.fn() - const delivery = new EmulatorVideoFrameDelivery(send) - delivery.enqueue(frame('in-flight')) - delivery.enqueue(frame('stale-config', 1, { config: true })) - for (let index = 0; index < 100; index += 1) { - delivery.enqueue(frame(`old-${index}`)) - } - delivery.enqueue(frame('new-key', 1, { keyFrame: true })) - for (let index = 0; index < 30; index += 1) { - delivery.enqueue(frame(`new-${index}`)) - } - - delivery.acknowledge(1) - - expect(send.mock.calls[1][0].pts).toBe('new-key') - }) - - it('drops an over-byte continuation and ignores stale acknowledgements', () => { - const send = vi.fn() - const delivery = new EmulatorVideoFrameDelivery(send) - delivery.enqueue(frame('in-flight-key', 1, { keyFrame: true })) - delivery.enqueue(frame('large-1', SCRCPY_VIDEO_MAX_REPLAY_BYTES_PER_DEVICE / 2 + 1)) - delivery.enqueue(frame('large-2', SCRCPY_VIDEO_MAX_REPLAY_BYTES_PER_DEVICE / 2 + 1)) - - delivery.acknowledge(99) - expect(send).toHaveBeenCalledTimes(1) - delivery.acknowledge(1) - expect(send).toHaveBeenCalledTimes(1) - - delivery.enqueue(frame('recovery-key', 1, { keyFrame: true })) - expect(send.mock.calls[1][0].pts).toBe('recovery-key') - }) -}) diff --git a/src/main/ipc/emulator-video-frame-delivery.ts b/src/main/ipc/emulator-video-frame-delivery.ts deleted file mode 100644 index c06e8cbb56c..00000000000 --- a/src/main/ipc/emulator-video-frame-delivery.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { - SCRCPY_VIDEO_MAX_GOP_FRAMES, - SCRCPY_VIDEO_MAX_REPLAY_BYTES_PER_DEVICE, - type ScrcpyVideoFrameMessage -} from '../emulator/scrcpy-video-registry' - -const MAX_PENDING_FRAMES = SCRCPY_VIDEO_MAX_GOP_FRAMES + 1 - -export class EmulatorVideoFrameDelivery { - private nextDeliveryId = 1 - private inFlightDeliveryId: number | null = null - private pendingFrames: ScrcpyVideoFrameMessage[] = [] - private pendingBytes = 0 - private waitingForKeyFrame = false - private waitingConfig: ScrcpyVideoFrameMessage | null = null - - constructor( - private readonly send: (frame: ScrcpyVideoFrameMessage, deliveryId: number) => void - ) {} - - enqueue(frame: ScrcpyVideoFrameMessage): void { - if (this.waitingForKeyFrame) { - this.enqueueWhileWaiting(frame) - return - } - this.pendingFrames.push(frame) - this.pendingBytes += frame.bytes.byteLength - this.recoverFromOverflow() - this.flushNext() - } - - acknowledge(deliveryId: number): void { - if (this.inFlightDeliveryId !== deliveryId) { - return - } - this.inFlightDeliveryId = null - this.flushNext() - } - - clear(): void { - this.inFlightDeliveryId = null - this.pendingFrames = [] - this.pendingBytes = 0 - this.waitingForKeyFrame = false - this.waitingConfig = null - } - - private enqueueWhileWaiting(frame: ScrcpyVideoFrameMessage): void { - if (frame.config) { - this.waitingConfig = - frame.bytes.byteLength <= SCRCPY_VIDEO_MAX_REPLAY_BYTES_PER_DEVICE ? frame : null - return - } - if (!frame.keyFrame) { - return - } - const pendingFrames = this.waitingConfig ? [this.waitingConfig, frame] : [frame] - const pendingBytes = pendingFrames.reduce( - (total, pending) => total + pending.bytes.byteLength, - 0 - ) - this.waitingConfig = null - if (pendingBytes > SCRCPY_VIDEO_MAX_REPLAY_BYTES_PER_DEVICE) { - return - } - this.waitingForKeyFrame = false - this.pendingFrames = pendingFrames - this.pendingBytes = pendingBytes - this.flushNext() - } - - private recoverFromOverflow(): void { - if ( - this.pendingFrames.length <= MAX_PENDING_FRAMES && - this.pendingBytes <= SCRCPY_VIDEO_MAX_REPLAY_BYTES_PER_DEVICE - ) { - return - } - - const keyFrameIndex = this.findNewestKeyFrame() - if (keyFrameIndex >= 0) { - const startIndex = this.findConfigBeforeKeyFrame(keyFrameIndex) - const recoverableFrames = this.pendingFrames.slice(startIndex) - const recoverableBytes = recoverableFrames.reduce( - (total, pending) => total + pending.bytes.byteLength, - 0 - ) - if ( - recoverableFrames.length <= MAX_PENDING_FRAMES && - recoverableBytes <= SCRCPY_VIDEO_MAX_REPLAY_BYTES_PER_DEVICE - ) { - this.pendingFrames = recoverableFrames - this.pendingBytes = recoverableBytes - return - } - } - - this.waitingConfig = this.findNewestConfig() - this.pendingFrames = [] - this.pendingBytes = 0 - this.waitingForKeyFrame = true - } - - private findNewestKeyFrame(): number { - for (let index = this.pendingFrames.length - 1; index >= 0; index -= 1) { - if (this.pendingFrames[index].keyFrame) { - return index - } - } - return -1 - } - - private findConfigBeforeKeyFrame(keyFrameIndex: number): number { - let startIndex = keyFrameIndex - for (let index = keyFrameIndex - 1; index >= 0; index -= 1) { - if (!this.pendingFrames[index].config) { - break - } - startIndex = index - } - return startIndex - } - - private findNewestConfig(): ScrcpyVideoFrameMessage | null { - for (let index = this.pendingFrames.length - 1; index >= 0; index -= 1) { - const frame = this.pendingFrames[index] - if (frame.config && frame.bytes.byteLength <= SCRCPY_VIDEO_MAX_REPLAY_BYTES_PER_DEVICE) { - return frame - } - } - return null - } - - private flushNext(): void { - if (this.inFlightDeliveryId !== null || this.pendingFrames.length === 0) { - return - } - const frame = this.pendingFrames.shift() - if (!frame) { - return - } - this.pendingBytes -= frame.bytes.byteLength - const deliveryId = this.nextDeliveryId - this.nextDeliveryId += 1 - this.inFlightDeliveryId = deliveryId - this.send(frame, deliveryId) - } -} diff --git a/src/main/ipc/emulator-video-stream-admission.test.ts b/src/main/ipc/emulator-video-stream-admission.test.ts deleted file mode 100644 index a2220ce03a2..00000000000 --- a/src/main/ipc/emulator-video-stream-admission.test.ts +++ /dev/null @@ -1,243 +0,0 @@ -import { EventEmitter } from 'node:events' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -type VideoEvent = - | { type: 'meta'; meta: { codecId: string; width: number; height: number } } - | { - type: 'frame' - frame: { - config: boolean - keyFrame: boolean - pts: string - bytes: ArrayBuffer - } - } - -const { handlers, registryState, subscribeMock } = vi.hoisted(() => { - const state = { - error: null as Error | null, - subscribers: [] as ((event: VideoEvent) => void)[], - unsubscribes: [] as ReturnType[] - } - return { - handlers: new Map unknown>(), - registryState: state, - subscribeMock: vi.fn((_deviceId: string, subscriber: (event: VideoEvent) => void) => { - if (state.error) { - throw state.error - } - const unsubscribe = vi.fn() - state.subscribers.push(subscriber) - state.unsubscribes.push(unsubscribe) - return unsubscribe - }) - } -}) - -vi.mock('electron', () => ({ - ipcMain: { - handle: (channel: string, listener: (event: unknown, args: unknown) => unknown): void => { - handlers.set(channel, listener) - }, - on: (channel: string, listener: (event: unknown, args: unknown) => unknown): void => { - handlers.set(channel, listener) - } - }, - BrowserWindow: { - fromWebContents: (owner: { isDestroyed: () => boolean }) => (owner.isDestroyed() ? null : {}) - } -})) - -vi.mock('../emulator/scrcpy-video-registry', () => ({ - SCRCPY_VIDEO_MAX_GOP_FRAMES: 120, - SCRCPY_VIDEO_MAX_REPLAY_BYTES_PER_DEVICE: 32 * 1024 * 1024, - scrcpyVideoRegistry: { subscribe: subscribeMock } -})) - -vi.mock('../emulator/emulator-probe', () => ({ emulatorProbe: vi.fn() })) - -import { - EMULATOR_VIDEO_STREAM_MAX_SUBSCRIPTIONS_PER_RENDERER, - EMULATOR_VIDEO_STREAM_MAX_SUBSCRIPTIONS_TOTAL, - registerEmulatorVideoStreamHandlers -} from './emulator-video-stream' - -type Owner = EventEmitter & { - destroy: () => void - isDestroyed: () => boolean - send: ReturnType -} - -const owners: Owner[] = [] - -function makeOwner(): Owner { - let destroyed = false - const owner = new EventEmitter() as Owner - owner.isDestroyed = () => destroyed - owner.send = vi.fn() - owner.destroy = () => { - if (destroyed) { - return - } - destroyed = true - owner.emit('destroyed') - } - owners.push(owner) - return owner -} - -function handler(channel: string): (event: unknown, args: unknown) => unknown { - const registered = handlers.get(channel) - if (!registered) { - throw new Error(`Missing handler for ${channel}`) - } - return registered -} - -function startStream(owner: Owner, streamId: string): void { - handler('emulator:videoStreamStart')({ sender: owner }, { deviceId: 'emulator-5554', streamId }) -} - -function stopStream(owner: Owner, streamId: string): void { - handler('emulator:videoStreamStop')({ sender: owner }, { streamId }) -} - -beforeEach(() => { - vi.useFakeTimers() - handlers.clear() - registryState.error = null - registryState.subscribers.length = 0 - registryState.unsubscribes.length = 0 - subscribeMock.mockClear() - registerEmulatorVideoStreamHandlers() -}) - -afterEach(() => { - for (const owner of owners) { - owner.destroy() - } - owners.length = 0 - vi.runOnlyPendingTimers() - vi.useRealTimers() -}) - -describe('emulator video stream admission', () => { - it('caps subscriptions per renderer without affecting another renderer', () => { - const owner = makeOwner() - for (let index = 0; index < EMULATOR_VIDEO_STREAM_MAX_SUBSCRIPTIONS_PER_RENDERER; index += 1) { - startStream(owner, `owned-${index}`) - } - - expect(() => startStream(owner, 'overflow')).toThrow(/renderer can have at most/) - expect(() => startStream(makeOwner(), 'other-renderer')).not.toThrow() - }) - - it('caps subscriptions across renderers before registry fan-out begins', () => { - const ownerCount = - EMULATOR_VIDEO_STREAM_MAX_SUBSCRIPTIONS_TOTAL / - EMULATOR_VIDEO_STREAM_MAX_SUBSCRIPTIONS_PER_RENDERER - for (let ownerIndex = 0; ownerIndex < ownerCount; ownerIndex += 1) { - const owner = makeOwner() - for ( - let streamIndex = 0; - streamIndex < EMULATOR_VIDEO_STREAM_MAX_SUBSCRIPTIONS_PER_RENDERER; - streamIndex += 1 - ) { - startStream(owner, `${ownerIndex}-${streamIndex}`) - } - } - - expect(() => startStream(makeOwner(), 'overflow')).toThrow(/Orca can have at most/) - expect(subscribeMock).not.toHaveBeenCalled() - }) - - it('allows owned replacement and stop without cross-renderer capacity changes', () => { - const owner = makeOwner() - const otherOwner = makeOwner() - startStream(owner, 'first') - startStream(owner, 'second') - vi.runOnlyPendingTimers() - const firstUnsubscribe = registryState.unsubscribes[0] - - startStream(owner, 'first') - expect(firstUnsubscribe).toHaveBeenCalledOnce() - expect(owner.listenerCount('destroyed')).toBe(2) - - stopStream(otherOwner, 'first') - expect(() => startStream(owner, 'overflow')).toThrow(/renderer can have at most/) - - stopStream(owner, 'first') - expect(() => startStream(owner, 'replacement')).not.toThrow() - }) - - it('releases pending subscriptions when the renderer is destroyed', () => { - const owner = makeOwner() - startStream(owner, 'first') - startStream(owner, 'second') - - owner.destroy() - vi.runOnlyPendingTimers() - - expect(owner.listenerCount('destroyed')).toBe(0) - expect(subscribeMock).not.toHaveBeenCalled() - const nextOwner = makeOwner() - expect(() => startStream(nextOwner, 'next-first')).not.toThrow() - expect(() => startStream(nextOwner, 'next-second')).not.toThrow() - }) - - it('releases admission when synchronous registry replay throws', () => { - const owner = makeOwner() - registryState.error = new Error('renderer send failed') - startStream(owner, 'failing') - - vi.runOnlyPendingTimers() - - expect(owner.listenerCount('destroyed')).toBe(0) - registryState.error = null - for (let index = 0; index < EMULATOR_VIDEO_STREAM_MAX_SUBSCRIPTIONS_PER_RENDERER; index += 1) { - expect(() => startStream(owner, `recovered-${index}`)).not.toThrow() - } - }) - - it('sends one H.264 frame at a time and requires the exact delivery token', () => { - const owner = makeOwner() - startStream(owner, 'bounded') - vi.runOnlyPendingTimers() - const subscriber = registryState.subscribers[0] - - for (let index = 0; index < 20; index += 1) { - subscriber({ - type: 'frame', - frame: { - config: false, - keyFrame: index === 0, - pts: String(index), - bytes: new ArrayBuffer(1) - } - }) - } - const frameCalls = () => - owner.send.mock.calls.filter(([channel]) => channel === 'emulator:videoStreamFrame') - expect(frameCalls()).toHaveLength(1) - const firstPayload = frameCalls()[0][1] as { - deliveryToken: string - deliveryId: number - } - - handler('emulator:videoStreamFrameAck')( - { sender: owner }, - { streamId: 'bounded', deliveryToken: 'stale', deliveryId: 1 } - ) - expect(frameCalls()).toHaveLength(1) - - handler('emulator:videoStreamFrameAck')( - { sender: owner }, - { - streamId: 'bounded', - deliveryToken: firstPayload.deliveryToken, - deliveryId: firstPayload.deliveryId - } - ) - expect(frameCalls()).toHaveLength(2) - }) -}) diff --git a/src/main/ipc/emulator-video-stream.ts b/src/main/ipc/emulator-video-stream.ts index 9bf93f124bc..8bfe8367103 100644 --- a/src/main/ipc/emulator-video-stream.ts +++ b/src/main/ipc/emulator-video-stream.ts @@ -2,10 +2,6 @@ import { BrowserWindow, ipcMain, type WebContents } from 'electron' import { randomUUID } from 'node:crypto' import { scrcpyVideoRegistry } from '../emulator/scrcpy-video-registry' import { emulatorProbe } from '../emulator/emulator-probe' -import { EmulatorVideoFrameDelivery } from './emulator-video-frame-delivery' - -export const EMULATOR_VIDEO_STREAM_MAX_SUBSCRIPTIONS_TOTAL = 8 -export const EMULATOR_VIDEO_STREAM_MAX_SUBSCRIPTIONS_PER_RENDERER = 2 // Bridges the main-process scrcpy video registry to renderer subscribers. The // renderer calls emulator:videoStreamStart with a deviceId; meta + H.264 access @@ -16,41 +12,19 @@ export function registerEmulatorVideoStreamHandlers(): void { owner: WebContents unsubscribe: () => void onOwnerDestroyed: () => void - delivery: EmulatorVideoFrameDelivery - deliveryToken: string } const subscriptions = new Map() - const assertSubscriptionCapacity = (owner: WebContents): void => { - let ownerSubscriptionCount = 0 - for (const subscription of subscriptions.values()) { - if (subscription.owner === owner) { - ownerSubscriptionCount += 1 - } - } - if (ownerSubscriptionCount >= EMULATOR_VIDEO_STREAM_MAX_SUBSCRIPTIONS_PER_RENDERER) { - throw new Error( - `A renderer can have at most ${EMULATOR_VIDEO_STREAM_MAX_SUBSCRIPTIONS_PER_RENDERER} active emulator video streams.` - ) - } - if (subscriptions.size >= EMULATOR_VIDEO_STREAM_MAX_SUBSCRIPTIONS_TOTAL) { - throw new Error( - `Orca can have at most ${EMULATOR_VIDEO_STREAM_MAX_SUBSCRIPTIONS_TOTAL} active emulator video streams.` - ) - } - } - const stopSubscription = (streamId: string, owner?: WebContents): void => { const subscription = subscriptions.get(streamId) if (!subscription || (owner && subscription.owner !== owner)) { return } - subscriptions.delete(streamId) + subscription.unsubscribe() // Why: `.once('destroyed')` self-removes only when that event fires (window // close), so an explicit stop must drop it or each show/hide cycle leaks one. subscription.owner.removeListener('destroyed', subscription.onOwnerDestroyed) - subscription.delivery.clear() - subscription.unsubscribe() + subscriptions.delete(streamId) } ipcMain.handle( @@ -70,65 +44,36 @@ export function registerEmulatorVideoStreamHandlers(): void { throw new Error('Video stream id is already in use by another renderer') } stopSubscription(streamId, owner) - assertSubscriptionCapacity(owner) const onOwnerDestroyed = (): void => stopSubscription(streamId, owner) - const deliveryToken = randomUUID() - const delivery = new EmulatorVideoFrameDelivery((frame, deliveryId) => { - const current = subscriptions.get(streamId) - if (!current || current.delivery !== delivery || owner.isDestroyed()) { - return - } - try { - owner.send('emulator:videoStreamFrame', { - streamId, - deliveryToken, - deliveryId, - deviceId: args.deviceId, - ...frame - }) - } catch { - stopSubscription(streamId, owner) - } - }) const pendingSubscription: Subscription = { owner, unsubscribe: () => {}, - onOwnerDestroyed, - delivery, - deliveryToken + onOwnerDestroyed } subscriptions.set(streamId, pendingSubscription) setTimeout(() => { if (owner.isDestroyed() || subscriptions.get(streamId) !== pendingSubscription) { return } - try { - const unsubscribe = scrcpyVideoRegistry.subscribe(args.deviceId, (videoEvent) => { - if (owner.isDestroyed() || subscriptions.get(streamId) !== pendingSubscription) { - return - } - if (videoEvent.type === 'meta') { - try { - owner.send('emulator:videoStreamMeta', { - streamId, - deviceId: args.deviceId, - meta: videoEvent.meta - }) - } catch { - stopSubscription(streamId, owner) - } - } else { - delivery.enqueue(videoEvent.frame) - } - }) - if (subscriptions.get(streamId) !== pendingSubscription) { - unsubscribe() + const unsubscribe = scrcpyVideoRegistry.subscribe(args.deviceId, (videoEvent) => { + if (owner.isDestroyed()) { return } - pendingSubscription.unsubscribe = unsubscribe - } catch { - stopSubscription(streamId, owner) - } + if (videoEvent.type === 'meta') { + owner.send('emulator:videoStreamMeta', { + streamId, + deviceId: args.deviceId, + meta: videoEvent.meta + }) + } else { + owner.send('emulator:videoStreamFrame', { + streamId, + deviceId: args.deviceId, + ...videoEvent.frame + }) + } + }) + pendingSubscription.unsubscribe = unsubscribe }, 0) owner.once('destroyed', onOwnerDestroyed) return { streamId } @@ -138,19 +83,4 @@ export function registerEmulatorVideoStreamHandlers(): void { ipcMain.handle('emulator:videoStreamStop', (event, args: { streamId: string }) => { stopSubscription(args.streamId, event.sender) }) - ipcMain.on( - 'emulator:videoStreamFrameAck', - (event, args: { streamId: string; deliveryToken: string; deliveryId: number }) => { - const subscription = - typeof args?.streamId === 'string' ? subscriptions.get(args.streamId) : undefined - if ( - subscription?.owner === event.sender && - args.deliveryToken === subscription.deliveryToken && - Number.isSafeInteger(args.deliveryId) && - args.deliveryId > 0 - ) { - subscription.delivery.acknowledge(args.deliveryId) - } - } - ) } diff --git a/src/main/ipc/export.ts b/src/main/ipc/export.ts index 83eec23bcf3..667d3529287 100644 --- a/src/main/ipc/export.ts +++ b/src/main/ipc/export.ts @@ -1,7 +1,6 @@ import { BrowserWindow, dialog, ipcMain } from 'electron' import { writeFile } from 'node:fs/promises' import { ExportTimeoutError, htmlToPdf } from '../lib/html-to-pdf' -import { assertHtmlToPdfInputWithinMemoryLimit } from '../lib/html-to-pdf-memory-limit' export type ExportHtmlToPdfArgs = { html: string @@ -18,12 +17,11 @@ export function registerExportHandlers(): void { 'export:html-to-pdf', async (event, args: ExportHtmlToPdfArgs): Promise => { const { html, title } = args + if (!html.trim()) { + return { success: false, error: 'No content to export' } + } try { - assertHtmlToPdfInputWithinMemoryLimit(html) - if (!html.trim()) { - return { success: false, error: 'No content to export' } - } const pdfBuffer = await htmlToPdf(html) // Why: sanitize to keep the suggested filename legal on every platform. diff --git a/src/main/ipc/filesystem-directory-reader.test.ts b/src/main/ipc/filesystem-directory-reader.test.ts deleted file mode 100644 index 833e572a33d..00000000000 --- a/src/main/ipc/filesystem-directory-reader.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { FILESYSTEM_DIRECTORY_LIMIT_MESSAGE } from '../../shared/filesystem-directory-listing-limit' -import { collectLocalFilesystemDirectoryEntries } from './filesystem-directory-reader' - -type TestEntryOptions = { - name: string - directory?: boolean - symlink?: boolean - onClassify?: () => void -} - -function entry(options: TestEntryOptions) { - return { - name: options.name, - isDirectory: () => { - options.onClassify?.() - return options.directory ?? false - }, - isSymbolicLink: () => options.symlink ?? false - } -} - -describe('local filesystem directory reader', () => { - it('preserves directory-first sorting and keeps symlinks file-like', async () => { - const source = [ - entry({ name: 'z.txt' }), - entry({ name: 'linked-dir', directory: true, symlink: true }), - entry({ name: 'beta', directory: true }), - entry({ name: 'alpha', directory: true }) - ] - - await expect(collectLocalFilesystemDirectoryEntries(source)).resolves.toEqual([ - { name: 'alpha', isDirectory: true, isSymlink: false }, - { name: 'beta', isDirectory: true, isSymlink: false }, - { name: 'linked-dir', isDirectory: false, isSymlink: true }, - { name: 'z.txt', isDirectory: false, isSymlink: false } - ]) - }) - - it('stops before classifying or retaining the first over-limit entry', async () => { - let enumerated = 0 - let overLimitClassified = false - const source = { - async *[Symbol.asyncIterator]() { - for (const value of [ - entry({ name: 'one' }), - entry({ name: 'two' }), - entry({ name: 'three', onClassify: () => (overLimitClassified = true) }) - ]) { - enumerated += 1 - yield value - } - } - } - - await expect( - collectLocalFilesystemDirectoryEntries(source, { - maxEntries: 2, - maxRetainedBytes: 1024 - }) - ).rejects.toThrow(FILESYSTEM_DIRECTORY_LIMIT_MESSAGE) - expect(enumerated).toBe(3) - expect(overLimitClassified).toBe(false) - }) -}) diff --git a/src/main/ipc/filesystem-directory-reader.ts b/src/main/ipc/filesystem-directory-reader.ts deleted file mode 100644 index 3977f1accba..00000000000 --- a/src/main/ipc/filesystem-directory-reader.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { opendir } from 'node:fs/promises' -import type { DirEntry } from '../../shared/types' -import { - createFilesystemDirectoryLimitState, - trackFilesystemDirectoryEntry, - type FilesystemDirectoryListingLimits -} from '../../shared/filesystem-directory-listing-limit' - -type LocalDirectorySourceEntry = { - name: string - isDirectory(): boolean - isSymbolicLink(): boolean -} - -export async function readLocalFilesystemDirectory(dirPath: string): Promise { - return collectLocalFilesystemDirectoryEntries(await opendir(dirPath)) -} - -export async function collectLocalFilesystemDirectoryEntries( - directory: AsyncIterable | Iterable, - requestedLimits?: Partial -): Promise { - const entries: DirEntry[] = [] - const limit = createFilesystemDirectoryLimitState(requestedLimits) - for await (const entry of directory) { - trackFilesystemDirectoryEntry(limit, entry) - const isSymlink = entry.isSymbolicLink() - entries.push({ - name: entry.name, - // Why: avoid probing macOS TCC-protected symlink targets during listing. - isDirectory: !isSymlink && entry.isDirectory(), - isSymlink - }) - } - return entries.sort((left, right) => { - if (left.isDirectory !== right.isDirectory) { - return left.isDirectory ? -1 : 1 - } - return left.name.localeCompare(right.name) - }) -} diff --git a/src/main/ipc/filesystem-external-import-limits.test.ts b/src/main/ipc/filesystem-external-import-limits.test.ts deleted file mode 100644 index 66079a8344d..00000000000 --- a/src/main/ipc/filesystem-external-import-limits.test.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - admitExternalImportTreeEntry, - assertExternalImportSourcePaths, - assertExternalImportTreeDepth, - captureRuntimeUploadRetentionCheckpoint, - createExternalImportTreeBudget, - createRuntimeUploadRetentionBudget, - EXTERNAL_IMPORT_MAX_RELATIVE_PATH_BYTES, - EXTERNAL_IMPORT_MAX_SOURCE_PATH_BYTES, - EXTERNAL_IMPORT_MAX_SOURCE_PATHS, - EXTERNAL_IMPORT_MAX_TREE_DEPTH, - EXTERNAL_IMPORT_MAX_TREE_ENTRIES, - ExternalImportCapacityError, - REMOTE_IMPORT_MAX_FILE_BYTES, - REMOTE_IMPORT_MAX_RETAINED_PATH_BYTES, - REMOTE_IMPORT_MAX_TOTAL_BYTES, - restoreRuntimeUploadRetentionCheckpoint, - retainRuntimeUploadFileBytes -} from './filesystem-external-import-limits' - -describe('external filesystem import limits', () => { - it('accepts the exact source-count boundary and rejects the next path', () => { - expect(() => - assertExternalImportSourcePaths( - Array.from({ length: EXTERNAL_IMPORT_MAX_SOURCE_PATHS }, () => '') - ) - ).not.toThrow() - expect(() => - assertExternalImportSourcePaths( - Array.from({ length: EXTERNAL_IMPORT_MAX_SOURCE_PATHS + 1 }, () => '') - ) - ).toThrow('External import accepts at most 256 source paths') - }) - - it('accepts the exact source-path byte boundary and rejects one byte more', () => { - expect(() => - assertExternalImportSourcePaths(['a'.repeat(EXTERNAL_IMPORT_MAX_SOURCE_PATH_BYTES)]) - ).not.toThrow() - expect(() => - assertExternalImportSourcePaths(['a'.repeat(EXTERNAL_IMPORT_MAX_SOURCE_PATH_BYTES + 1)]) - ).toThrow('External import source paths exceed 256 KiB') - }) - - it('accepts exactly 100,000 tree entries without retaining their paths', () => { - const budget = createExternalImportTreeBudget() - for (let index = 0; index < EXTERNAL_IMPORT_MAX_TREE_ENTRIES; index += 1) { - admitExternalImportTreeEntry(budget, 'entry', false) - } - - expect(budget).toEqual({ - entries: EXTERNAL_IMPORT_MAX_TREE_ENTRIES, - retainedPathBytes: 0 - }) - expect(() => admitExternalImportTreeEntry(budget, 'overflow', false)).toThrow( - 'External import tree exceeds 100,000 entries' - ) - expect(budget.entries).toBe(EXTERNAL_IMPORT_MAX_TREE_ENTRIES) - }) - - it('accepts the exact depth and per-path boundaries', () => { - const budget = createExternalImportTreeBudget() - expect(() => assertExternalImportTreeDepth(EXTERNAL_IMPORT_MAX_TREE_DEPTH)).not.toThrow() - expect(() => assertExternalImportTreeDepth(EXTERNAL_IMPORT_MAX_TREE_DEPTH + 1)).toThrow( - 'External import tree exceeds 256 nested directory levels' - ) - expect(() => - admitExternalImportTreeEntry( - budget, - 'a'.repeat(EXTERNAL_IMPORT_MAX_RELATIVE_PATH_BYTES), - false - ) - ).not.toThrow() - expect(() => - admitExternalImportTreeEntry( - budget, - 'a'.repeat(EXTERNAL_IMPORT_MAX_RELATIVE_PATH_BYTES + 1), - false - ) - ).toThrow('External import relative path exceeds 64 KiB') - }) - - it('accepts the exact aggregate retained-path boundary', () => { - const budget = createExternalImportTreeBudget() - const path = 'a'.repeat(EXTERNAL_IMPORT_MAX_RELATIVE_PATH_BYTES) - const pathCount = - REMOTE_IMPORT_MAX_RETAINED_PATH_BYTES / (EXTERNAL_IMPORT_MAX_RELATIVE_PATH_BYTES * 2) - for (let index = 0; index < pathCount; index += 1) { - admitExternalImportTreeEntry(budget, path, true) - } - - expect(budget.retainedPathBytes).toBe(REMOTE_IMPORT_MAX_RETAINED_PATH_BYTES) - expect(() => admitExternalImportTreeEntry(budget, 'b', true)).toThrow( - 'Remote import retained paths exceed 16 MiB' - ) - expect(budget.retainedPathBytes).toBe(REMOTE_IMPORT_MAX_RETAINED_PATH_BYTES) - }) - - it('applies file and total byte limits across the full runtime-upload request', () => { - const budget = createRuntimeUploadRetentionBudget() - const exactFileCount = REMOTE_IMPORT_MAX_TOTAL_BYTES / REMOTE_IMPORT_MAX_FILE_BYTES - for (let index = 0; index < exactFileCount; index += 1) { - retainRuntimeUploadFileBytes(budget, `file-${index}`, REMOTE_IMPORT_MAX_FILE_BYTES) - } - - expect(budget.fileBytes).toBe(REMOTE_IMPORT_MAX_TOTAL_BYTES) - expect(() => retainRuntimeUploadFileBytes(budget, 'overflow', 1)).toThrow( - 'Remote import is too large' - ) - expect(budget.fileBytes).toBe(REMOTE_IMPORT_MAX_TOTAL_BYTES) - }) - - it('rejects one oversized file without consuming request capacity', () => { - const budget = createRuntimeUploadRetentionBudget() - - expect(() => - retainRuntimeUploadFileBytes(budget, 'large.bin', REMOTE_IMPORT_MAX_FILE_BYTES + 1) - ).toThrow("'large.bin' is too large for remote import") - expect(budget.fileBytes).toBe(0) - }) - - it('restores capacity reserved by a failed staged source', () => { - const budget = createRuntimeUploadRetentionBudget() - const checkpoint = captureRuntimeUploadRetentionCheckpoint(budget) - admitExternalImportTreeEntry(budget.tree, 'partial.txt', true) - retainRuntimeUploadFileBytes(budget, 'partial.txt', 1024) - - restoreRuntimeUploadRetentionCheckpoint(budget, checkpoint) - - expect(budget).toEqual({ - tree: { entries: 0, retainedPathBytes: 0 }, - fileBytes: 0 - }) - }) - - it('uses a typed error for every capacity rejection', () => { - const budget = createExternalImportTreeBudget() - - expect(() => - admitExternalImportTreeEntry( - budget, - 'a'.repeat(EXTERNAL_IMPORT_MAX_RELATIVE_PATH_BYTES + 1), - false - ) - ).toThrow(ExternalImportCapacityError) - }) -}) diff --git a/src/main/ipc/filesystem-external-import-limits.ts b/src/main/ipc/filesystem-external-import-limits.ts deleted file mode 100644 index 542cb12e04c..00000000000 --- a/src/main/ipc/filesystem-external-import-limits.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { - NATIVE_FILE_DROP_MAX_PATH_BYTES, - NATIVE_FILE_DROP_MAX_PATHS -} from '../../shared/native-file-drop' - -export const EXTERNAL_IMPORT_MAX_SOURCE_PATHS = NATIVE_FILE_DROP_MAX_PATHS -export const EXTERNAL_IMPORT_MAX_SOURCE_PATH_BYTES = NATIVE_FILE_DROP_MAX_PATH_BYTES -export const EXTERNAL_IMPORT_MAX_TREE_ENTRIES = 100_000 -export const EXTERNAL_IMPORT_MAX_TREE_DEPTH = 256 -export const EXTERNAL_IMPORT_MAX_RELATIVE_PATH_BYTES = 64 * 1024 -export const REMOTE_IMPORT_MAX_RETAINED_PATH_BYTES = 16 * 1024 * 1024 -export const REMOTE_IMPORT_MAX_FILE_BYTES = 25 * 1024 * 1024 -export const REMOTE_IMPORT_MAX_TOTAL_BYTES = 100 * 1024 * 1024 - -export const EXTERNAL_IMPORT_TREE_ENTRY_LIMIT_MESSAGE = - 'External import tree exceeds 100,000 entries' -export const EXTERNAL_IMPORT_TREE_DEPTH_LIMIT_MESSAGE = - 'External import tree exceeds 256 nested directory levels' -export const EXTERNAL_IMPORT_RELATIVE_PATH_LIMIT_MESSAGE = - 'External import relative path exceeds 64 KiB' -export const REMOTE_IMPORT_RETAINED_PATH_LIMIT_MESSAGE = - 'Remote import retained paths exceed 16 MiB' - -export type ExternalImportTreeBudget = { - entries: number - retainedPathBytes: number -} - -export type RuntimeUploadRetentionBudget = { - tree: ExternalImportTreeBudget - fileBytes: number -} - -export type RuntimeUploadRetentionCheckpoint = { - entries: number - retainedPathBytes: number - fileBytes: number -} - -export class ExternalImportCapacityError extends Error { - constructor(message: string) { - super(message) - this.name = 'ExternalImportCapacityError' - } -} - -export function assertExternalImportSourcePaths( - sourcePaths: unknown -): asserts sourcePaths is readonly string[] { - if (!Array.isArray(sourcePaths)) { - throw new TypeError('External import source paths must be an array') - } - if (sourcePaths.length > EXTERNAL_IMPORT_MAX_SOURCE_PATHS) { - throw new ExternalImportCapacityError( - `External import accepts at most ${EXTERNAL_IMPORT_MAX_SOURCE_PATHS} source paths` - ) - } - - let pathBytes = 0 - for (const sourcePath of sourcePaths) { - if (typeof sourcePath !== 'string') { - throw new TypeError('External import source paths must be strings') - } - pathBytes += Buffer.byteLength(sourcePath, 'utf8') - if (pathBytes > EXTERNAL_IMPORT_MAX_SOURCE_PATH_BYTES) { - throw new ExternalImportCapacityError('External import source paths exceed 256 KiB') - } - } -} - -export function createExternalImportTreeBudget(): ExternalImportTreeBudget { - return { entries: 0, retainedPathBytes: 0 } -} - -export function assertExternalImportTreeDepth(depth: number): void { - if (depth > EXTERNAL_IMPORT_MAX_TREE_DEPTH) { - throw new ExternalImportCapacityError(EXTERNAL_IMPORT_TREE_DEPTH_LIMIT_MESSAGE) - } -} - -export function admitExternalImportTreeEntry( - budget: ExternalImportTreeBudget, - relativePath: string, - retainPath: boolean -): void { - if (budget.entries >= EXTERNAL_IMPORT_MAX_TREE_ENTRIES) { - throw new ExternalImportCapacityError(EXTERNAL_IMPORT_TREE_ENTRY_LIMIT_MESSAGE) - } - - const pathBytes = Buffer.byteLength(relativePath, 'utf8') - if (pathBytes > EXTERNAL_IMPORT_MAX_RELATIVE_PATH_BYTES) { - throw new ExternalImportCapacityError(EXTERNAL_IMPORT_RELATIVE_PATH_LIMIT_MESSAGE) - } - const retainedPathBytes = relativePath.length * 2 - if ( - retainPath && - retainedPathBytes > REMOTE_IMPORT_MAX_RETAINED_PATH_BYTES - budget.retainedPathBytes - ) { - throw new ExternalImportCapacityError(REMOTE_IMPORT_RETAINED_PATH_LIMIT_MESSAGE) - } - - budget.entries += 1 - if (retainPath) { - budget.retainedPathBytes += retainedPathBytes - } -} - -export function createRuntimeUploadRetentionBudget(): RuntimeUploadRetentionBudget { - return { - tree: createExternalImportTreeBudget(), - fileBytes: 0 - } -} - -export function captureRuntimeUploadRetentionCheckpoint( - budget: RuntimeUploadRetentionBudget -): RuntimeUploadRetentionCheckpoint { - return { - entries: budget.tree.entries, - retainedPathBytes: budget.tree.retainedPathBytes, - fileBytes: budget.fileBytes - } -} - -export function restoreRuntimeUploadRetentionCheckpoint( - budget: RuntimeUploadRetentionBudget, - checkpoint: RuntimeUploadRetentionCheckpoint -): void { - budget.tree.entries = checkpoint.entries - budget.tree.retainedPathBytes = checkpoint.retainedPathBytes - budget.fileBytes = checkpoint.fileBytes -} - -export function assertRuntimeUploadFileFits( - budget: RuntimeUploadRetentionBudget, - relativePath: string, - fileBytes: number -): void { - if (!Number.isSafeInteger(fileBytes) || fileBytes < 0) { - throw new Error(`Could not safely measure '${relativePath}' for remote import`) - } - if (fileBytes > REMOTE_IMPORT_MAX_FILE_BYTES) { - throw new ExternalImportCapacityError(`'${relativePath}' is too large for remote import`) - } - if (fileBytes > REMOTE_IMPORT_MAX_TOTAL_BYTES - budget.fileBytes) { - throw new ExternalImportCapacityError('Remote import is too large') - } -} - -export function retainRuntimeUploadFileBytes( - budget: RuntimeUploadRetentionBudget, - relativePath: string, - fileBytes: number -): void { - assertRuntimeUploadFileFits(budget, relativePath, fileBytes) - budget.fileBytes += fileBytes -} diff --git a/src/main/ipc/filesystem-import-ssh-directory.ts b/src/main/ipc/filesystem-import-ssh-directory.ts index f38cf4d43d6..d4dc794a7cc 100644 --- a/src/main/ipc/filesystem-import-ssh-directory.ts +++ b/src/main/ipc/filesystem-import-ssh-directory.ts @@ -1,13 +1,7 @@ -import { lstat, opendir, realpath } from 'node:fs/promises' +import { lstat, readdir, realpath } from 'node:fs/promises' import { isAbsolute, join, relative, sep } from 'node:path' import type { FileUploadSession, IFilesystemProvider } from '../providers/types' import { assertSafeRemotePathSegment, type RemotePathFlavor } from '../ssh/ssh-remote-platform' -import { - admitExternalImportTreeEntry, - assertExternalImportTreeDepth, - createExternalImportTreeBudget, - type ExternalImportTreeBudget -} from './filesystem-external-import-limits' export async function captureLocalUploadRoot( sourcePath: string, @@ -29,47 +23,18 @@ export async function preScanSshImportDirectory( dirPath: string, remotePathFlavor: RemotePathFlavor ): Promise { - return preScanSshImportDirectoryWithinBudget( - dirPath, - remotePathFlavor, - createExternalImportTreeBudget(), - '', - 0 - ) -} - -async function preScanSshImportDirectoryWithinBudget( - dirPath: string, - remotePathFlavor: RemotePathFlavor, - budget: ExternalImportTreeBudget, - relativeDir: string, - depth: number -): Promise { - assertExternalImportTreeDepth(depth) - const directory = await opendir(dirPath) - try { - for await (const entry of directory) { - assertSafeRemotePathSegment(entry.name, remotePathFlavor) - const relativePath = relativeDir ? `${relativeDir}/${entry.name}` : entry.name - admitExternalImportTreeEntry(budget, relativePath, false) - if (entry.isSymbolicLink()) { - return true - } - if ( - entry.isDirectory() && - (await preScanSshImportDirectoryWithinBudget( - join(dirPath, entry.name), - remotePathFlavor, - budget, - relativePath, - depth + 1 - )) - ) { + const entries = await readdir(dirPath, { withFileTypes: true }) + for (const entry of entries) { + assertSafeRemotePathSegment(entry.name, remotePathFlavor) + if (entry.isSymbolicLink()) { + return true + } + if (entry.isDirectory()) { + const childPath = join(dirPath, entry.name) + if (await preScanSshImportDirectory(childPath, remotePathFlavor)) { return true } } - } finally { - await directory.close().catch(() => undefined) } return false } @@ -83,72 +48,37 @@ export async function uploadSshImportDirectory( remotePathFlavor: RemotePathFlavor, assertCurrent?: () => void ): Promise { - await uploadSshImportDirectoryWithinBudget( - provider, - uploadSession, - localDir, - remoteDir, - rootRealPath, - remotePathFlavor, - createExternalImportTreeBudget(), - '', - 0, - assertCurrent - ) -} - -async function uploadSshImportDirectoryWithinBudget( - provider: IFilesystemProvider, - uploadSession: FileUploadSession, - localDir: string, - remoteDir: string, - rootRealPath: string, - remotePathFlavor: RemotePathFlavor, - budget: ExternalImportTreeBudget, - relativeDir: string, - depth: number, - assertCurrent?: () => void -): Promise { - assertExternalImportTreeDepth(depth) await assertLocalUploadPathInsideRoot(rootRealPath, localDir) - const directory = await opendir(localDir) - try { - for await (const entry of directory) { - assertSafeRemotePathSegment(entry.name, remotePathFlavor) - const relativePath = relativeDir ? `${relativeDir}/${entry.name}` : entry.name - admitExternalImportTreeEntry(budget, relativePath, false) - const localPath = join(localDir, entry.name) - const remotePath = `${remoteDir}/${entry.name}` - await assertLocalUploadPathInsideRoot(rootRealPath, localPath) - const statResult = await lstat(localPath) + const entries = await readdir(localDir, { withFileTypes: true }) + for (const entry of entries) { + assertSafeRemotePathSegment(entry.name, remotePathFlavor) + const localPath = join(localDir, entry.name) + const remotePath = `${remoteDir}/${entry.name}` + await assertLocalUploadPathInsideRoot(rootRealPath, localPath) + const statResult = await lstat(localPath) - // Why: the up-front scan cannot prevent a source swap during upload. - if (statResult.isSymbolicLink() || (!statResult.isFile() && !statResult.isDirectory())) { - continue - } - - if (statResult.isDirectory()) { - assertCurrent?.() - await provider.createDirNoClobber(remotePath) - await uploadSshImportDirectoryWithinBudget( - provider, - uploadSession, - localPath, - remotePath, - rootRealPath, - remotePathFlavor, - budget, - relativePath, - depth + 1, - assertCurrent - ) - continue - } - assertCurrent?.() - await uploadSession.uploadFile(localPath, remotePath, { exclusive: true }) + // Why: skip symlinks and special files even after the up-front pre-scan; + // this closes the TOCTOU gap if one is created during upload. + if (statResult.isSymbolicLink() || (!statResult.isFile() && !statResult.isDirectory())) { + continue } - } finally { - await directory.close().catch(() => undefined) + + if (statResult.isDirectory()) { + assertCurrent?.() + await provider.createDirNoClobber(remotePath) + await uploadSshImportDirectory( + provider, + uploadSession, + localPath, + remotePath, + rootRealPath, + remotePathFlavor, + assertCurrent + ) + continue + } + assertCurrent?.() + await uploadSession.uploadFile(localPath, remotePath, { exclusive: true }) } } diff --git a/src/main/ipc/filesystem-import-ssh-ops.test.ts b/src/main/ipc/filesystem-import-ssh-ops.test.ts index 95b5c4f0cdd..2c36fd9f2bb 100644 --- a/src/main/ipc/filesystem-import-ssh-ops.test.ts +++ b/src/main/ipc/filesystem-import-ssh-ops.test.ts @@ -9,7 +9,6 @@ const { mkdirMock, realpathMock, copyFileMock, - opendirMock, readdirMock, getConnMgrMock } = vi.hoisted(() => ({ @@ -18,7 +17,6 @@ const { mkdirMock: vi.fn(), realpathMock: vi.fn(), copyFileMock: vi.fn(), - opendirMock: vi.fn(), readdirMock: vi.fn(), getConnMgrMock: vi.fn() })) @@ -31,15 +29,14 @@ vi.mock('fs/promises', () => ({ writeFile: vi.fn(), realpath: realpathMock, copyFile: copyFileMock, - opendir: opendirMock + readdir: readdirMock })) vi.mock('./ssh', () => ({ getSshConnectionManager: getConnMgrMock })) import { registerFilesystemMutationHandlers } from './filesystem-mutations' import { advanceSshConnectionGeneration, - resetSshConnectionGenerations, - setSshConnectionGeneration + resetSshConnectionGenerations } from '../ssh/ssh-connection-generation' import { registerSshFilesystemProvider, @@ -85,7 +82,6 @@ function createProvider(uploadSession: FileUploadSession): IFilesystemProvider { } describe('fs:importExternalPaths — SSH operations', () => { - const sessionCounterStride = 2 ** 13 const destDir = '/home/user/project/src' const connId = 'ssh-conn-1' let provider: IFilesystemProvider @@ -134,15 +130,12 @@ describe('fs:importExternalPaths — SSH operations', () => { beforeEach(() => { handlers.clear() - resetSshConnectionGenerations() - setSshConnectionGeneration(connId, 0) ;[ handleMock, lstatMock, mkdirMock, realpathMock, copyFileMock, - opendirMock, readdirMock, getConnMgrMock ].forEach((m) => m.mockReset()) @@ -152,12 +145,6 @@ describe('fs:importExternalPaths — SSH operations', () => { realpathMock.mockImplementation(async (p: string) => p) lstatMock.mockRejectedValue(enoent()) readdirMock.mockResolvedValue([]) - opendirMock.mockImplementation(async (directoryPath: string) => ({ - async *[Symbol.asyncIterator]() { - yield* await readdirMock(directoryPath, { withFileTypes: true }) - }, - close: vi.fn().mockResolvedValue(undefined) - })) getConnMgrMock.mockReturnValue({ getConnection: () => makeConn() }) uploadSession = { uploadFile: vi.fn().mockResolvedValue(undefined), @@ -175,7 +162,6 @@ describe('fs:importExternalPaths — SSH operations', () => { it('rejects a staged upload when a restarted HUB reaches the same target counter', async () => { resetSshConnectionGenerations(71) - setSshConnectionGeneration(connId, 71 * sessionCounterStride) const stagedGeneration = advanceSshConnectionGeneration(connId) const sourcePath = path.resolve('/tmp/dropped/restart.txt') lstatMock.mockImplementation(async (candidate: string) => { @@ -183,7 +169,6 @@ describe('fs:importExternalPaths — SSH operations', () => { throw enoent() } resetSshConnectionGenerations(72) - setSshConnectionGeneration(connId, 72 * sessionCounterStride) advanceSshConnectionGeneration(connId) return { size: 12, diff --git a/src/main/ipc/filesystem-import-ssh-path-safety.test.ts b/src/main/ipc/filesystem-import-ssh-path-safety.test.ts index 8d1e5de545b..ec01175ad35 100644 --- a/src/main/ipc/filesystem-import-ssh-path-safety.test.ts +++ b/src/main/ipc/filesystem-import-ssh-path-safety.test.ts @@ -2,19 +2,17 @@ import path from 'node:path' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { FileUploadSession, IFilesystemProvider } from '../providers/types' -const { getConnMgrMock, lstatMock, opendirMock, providerMock, readdirMock, realpathMock } = - vi.hoisted(() => ({ - getConnMgrMock: vi.fn(), - lstatMock: vi.fn(), - opendirMock: vi.fn(), - providerMock: vi.fn(), - readdirMock: vi.fn(), - realpathMock: vi.fn() - })) +const { getConnMgrMock, lstatMock, providerMock, readdirMock, realpathMock } = vi.hoisted(() => ({ + getConnMgrMock: vi.fn(), + lstatMock: vi.fn(), + providerMock: vi.fn(), + readdirMock: vi.fn(), + realpathMock: vi.fn() +})) vi.mock('node:fs/promises', () => ({ lstat: lstatMock, - opendir: opendirMock, + readdir: readdirMock, realpath: realpathMock })) vi.mock('./filesystem-auth', () => ({ @@ -27,7 +25,6 @@ vi.mock('../providers/ssh-filesystem-dispatch', () => ({ })) import { importExternalPathsSsh } from './filesystem-import-ssh' -import { EXTERNAL_IMPORT_MAX_TREE_ENTRIES } from './filesystem-external-import-limits' function createProvider(uploadSession: FileUploadSession): IFilesystemProvider { const missing = Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) @@ -57,12 +54,6 @@ describe('SSH import remote path safety', () => { provider = createProvider(uploadSession) providerMock.mockReturnValue(provider) readdirMock.mockResolvedValue([]) - opendirMock.mockImplementation(async (directoryPath: string) => ({ - async *[Symbol.asyncIterator]() { - yield* await readdirMock(directoryPath, { withFileTypes: true }) - }, - close: vi.fn().mockResolvedValue(undefined) - })) realpathMock.mockImplementation(async (value: string) => value) }) @@ -198,47 +189,6 @@ describe('SSH import remote path safety', () => { } }) - it('rejects an oversized directory before creating the remote root', async () => { - const sourcePath = path.resolve('/tmp/generated') - let yieldedEntries = 0 - let iteratorClosed = false - lstatMock.mockResolvedValue({ - isFile: () => false, - isDirectory: () => true, - isSymbolicLink: () => false - }) - opendirMock.mockResolvedValue({ - async *[Symbol.asyncIterator]() { - try { - while (yieldedEntries <= EXTERNAL_IMPORT_MAX_TREE_ENTRIES) { - const index = yieldedEntries - yieldedEntries += 1 - yield { - name: `entry-${index}`, - isFile: () => true, - isDirectory: () => false, - isSymbolicLink: () => false - } - } - } finally { - iteratorClosed = true - } - }, - close: vi.fn().mockResolvedValue(undefined) - }) - - const { results } = await importExternalPathsSsh([sourcePath], destDir, connectionId) - - expect(results[0]).toMatchObject({ - status: 'failed', - reason: 'External import tree exceeds 100,000 entries' - }) - expect(yieldedEntries).toBe(EXTERNAL_IMPORT_MAX_TREE_ENTRIES + 1) - expect(iteratorClosed).toBe(true) - expect(provider.createDirNoClobber).not.toHaveBeenCalled() - expect(uploadSession.uploadFile).not.toHaveBeenCalled() - }) - it('skips special entries without failing the directory import', async () => { const sourcePath = path.resolve('/tmp/assets') const regularPath = path.join(sourcePath, 'report.txt') diff --git a/src/main/ipc/filesystem-import-ssh.test.ts b/src/main/ipc/filesystem-import-ssh.test.ts index db00cbd5df4..6a5d502b14c 100644 --- a/src/main/ipc/filesystem-import-ssh.test.ts +++ b/src/main/ipc/filesystem-import-ssh.test.ts @@ -37,7 +37,6 @@ vi.mock('fs/promises', () => ({ copyFile: copyFileMock, open: openMock, readdir: readdirMock, - opendir: vi.fn(), unlink: unlinkMock, rm: vi.fn() })) @@ -48,10 +47,7 @@ import { registerSshFilesystemProvider, unregisterSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' -import { - resetSshConnectionGenerations, - setSshConnectionGeneration -} from '../ssh/ssh-connection-generation' +import { resetSshConnectionGenerations } from '../ssh/ssh-connection-generation' const store = { getRepos: () => [ @@ -134,7 +130,6 @@ describe('fs:importExternalPaths — SSH routing & connection', () => { beforeEach(() => { handlers.clear() resetSshConnectionGenerations() - setSshConnectionGeneration(connId, 0) ;[ handleMock, lstatMock, diff --git a/src/main/ipc/filesystem-import.test.ts b/src/main/ipc/filesystem-import.test.ts index 79180a1cfb3..fcd004dcd06 100644 --- a/src/main/ipc/filesystem-import.test.ts +++ b/src/main/ipc/filesystem-import.test.ts @@ -11,7 +11,7 @@ const { realpathMock, copyFileMock, openMock, - opendirMock, + readFileMock, readdirMock, rmMock, unlinkMock @@ -22,7 +22,7 @@ const { realpathMock: vi.fn(), copyFileMock: vi.fn(), openMock: vi.fn(), - opendirMock: vi.fn(), + readFileMock: vi.fn(), readdirMock: vi.fn(), rmMock: vi.fn(), unlinkMock: vi.fn() @@ -40,17 +40,13 @@ vi.mock('fs/promises', () => ({ writeFile: vi.fn(), realpath: realpathMock, copyFile: copyFileMock, - opendir: opendirMock, + readFile: readFileMock, + readdir: readdirMock, rm: rmMock, unlink: unlinkMock })) import { registerFilesystemMutationHandlers } from './filesystem-mutations' -import { - EXTERNAL_IMPORT_MAX_SOURCE_PATHS, - EXTERNAL_IMPORT_MAX_TREE_ENTRIES, - REMOTE_IMPORT_MAX_FILE_BYTES -} from './filesystem-external-import-limits' const REPO_PATH = path.resolve('/workspace/repo') const WORKSPACE_DIR = path.resolve('/workspace') @@ -66,24 +62,6 @@ function enoent(): Error { return Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) } -function streamDirectoryEntries(entries: unknown[]) { - return { - async *[Symbol.asyncIterator]() { - yield* entries - } - } -} - -function createFileHandleRead(content: Buffer) { - return vi.fn(async (buffer: Buffer, offset: number, length: number, position: number) => { - const bytesRead = Math.min(length, Math.max(0, content.byteLength - position)) - if (bytesRead > 0) { - content.copy(buffer, offset, position, position + bytesRead) - } - return { buffer, bytesRead } - }) -} - describe('fs:importExternalPaths', () => { const destDir = path.resolve('/workspace/repo/src') @@ -180,7 +158,7 @@ describe('fs:importExternalPaths', () => { realpathMock.mockReset() copyFileMock.mockReset() openMock.mockReset() - opendirMock.mockReset() + readFileMock.mockReset() readdirMock.mockReset() rmMock.mockReset() unlinkMock.mockReset() @@ -194,10 +172,8 @@ describe('fs:importExternalPaths', () => { mkdirMock.mockResolvedValue(undefined) copyFileMock.mockResolvedValue(undefined) mockLocalCopyOpenSuccess() + readFileMock.mockResolvedValue(Buffer.from('file-content')) readdirMock.mockResolvedValue([]) - opendirMock.mockImplementation(async (dirPath: string) => - streamDirectoryEntries(await readdirMock(dirPath)) - ) rmMock.mockResolvedValue(undefined) unlinkMock.mockResolvedValue(undefined) @@ -408,67 +384,6 @@ describe('fs:importExternalPaths', () => { expect(copyFileMock).not.toHaveBeenCalled() }) - it('stops streaming an oversized local tree before creating its destination', async () => { - const sourcePath = '/tmp/dropped/generated' - const resolvedSource = path.resolve(sourcePath) - let iteratorClosed = false - let yieldedEntries = 0 - lstatMock.mockImplementation(async (candidatePath: string) => { - if (candidatePath === resolvedSource) { - return { - isFile: () => false, - isDirectory: () => true, - isSymbolicLink: () => false - } - } - throw enoent() - }) - opendirMock.mockResolvedValue({ - async *[Symbol.asyncIterator]() { - try { - while (yieldedEntries <= EXTERNAL_IMPORT_MAX_TREE_ENTRIES) { - const index = yieldedEntries - yieldedEntries += 1 - yield { - name: `entry-${index}`, - isDirectory: () => false, - isSymbolicLink: () => false, - isFile: () => true - } - } - } finally { - iteratorClosed = true - } - } - }) - - const result = (await handlers.get('fs:importExternalPaths')!(null, { - sourcePaths: [sourcePath], - destDir - })) as { results: { status: string; reason?: string }[] } - - expect(result.results[0]).toMatchObject({ - status: 'failed', - reason: 'External import tree exceeds 100,000 entries' - }) - expect(yieldedEntries).toBe(EXTERNAL_IMPORT_MAX_TREE_ENTRIES + 1) - expect(iteratorClosed).toBe(true) - expect(mkdirMock).not.toHaveBeenCalled() - }) - - it('rejects oversized source batches before touching the filesystem', async () => { - const sourcePaths = Array.from( - { length: EXTERNAL_IMPORT_MAX_SOURCE_PATHS + 1 }, - (_, index) => `/tmp/dropped/${index}` - ) - - await expect( - handlers.get('fs:stageExternalPathsForRuntimeUpload')!(null, { sourcePaths }) - ).rejects.toThrow('External import accepts at most 256 source paths') - expect(lstatMock).not.toHaveBeenCalled() - expect(openMock).not.toHaveBeenCalled() - }) - it('fails and removes output if a local directory entry becomes a symlink after pre-scan', async () => { const sourcePath = '/tmp/dropped/mixeddir' const resolvedSource = path.resolve(sourcePath) @@ -566,7 +481,7 @@ describe('fs:importExternalPaths', () => { lstatMock.mockImplementation(async (p: string) => { if (p === resolvedPath) { return { - size: 3, + size: 4, ino: 1, dev: 1, isFile: () => true, @@ -577,15 +492,15 @@ describe('fs:importExternalPaths', () => { throw enoent() }) const closeMock = vi.fn().mockResolvedValue(undefined) - const readFileHandleMock = createFileHandleRead(Buffer.from('png')) + const readFileHandleMock = vi.fn().mockResolvedValue(Buffer.from('png')) openMock.mockResolvedValue({ stat: vi.fn().mockResolvedValue({ - size: 3, + size: 4, ino: 1, dev: 1, isFile: () => true }), - read: readFileHandleMock, + readFile: readFileHandleMock, close: closeMock }) @@ -665,7 +580,7 @@ describe('fs:importExternalPaths', () => { dev: 1, isFile: () => true }), - read: createFileHandleRead(Buffer.from('icon')), + readFile: vi.fn().mockResolvedValue(Buffer.from('icon')), close: vi.fn().mockResolvedValue(undefined) }) @@ -722,10 +637,16 @@ describe('fs:importExternalPaths', () => { expect(openMock).not.toHaveBeenCalled() }) - it('checks the runtime upload file budget before opening an oversized entry', async () => { + it('checks runtime upload directory byte budget before reading a file that exceeds the total cap', async () => { const sourcePath = '/tmp/dropped/project' const resolvedPath = path.resolve(sourcePath) - const oversizedPath = path.join(resolvedPath, 'oversized.bin') + const filePaths = ['one.bin', 'two.bin', 'three.bin', 'four.bin', 'overflow.bin'].map((name) => + path.join(resolvedPath, name) + ) + const mib = 1024 * 1024 + const regularSize = 25 * mib + const overflowSize = 1 * mib + const readFileMock = vi.fn().mockResolvedValue(Buffer.from('chunk')) lstatMock.mockImplementation(async (p: string) => { if (p === resolvedPath) { @@ -738,10 +659,12 @@ describe('fs:importExternalPaths', () => { isSymbolicLink: () => false } } - if (p === oversizedPath) { + const fileIndex = filePaths.indexOf(p) + if (fileIndex !== -1) { + const size = fileIndex === filePaths.length - 1 ? overflowSize : regularSize return { - size: REMOTE_IMPORT_MAX_FILE_BYTES + 1, - ino: 2, + size, + ino: fileIndex + 2, dev: 1, isFile: () => true, isDirectory: () => false, @@ -750,14 +673,30 @@ describe('fs:importExternalPaths', () => { } throw enoent() }) - readdirMock.mockResolvedValue([ - { - name: path.basename(oversizedPath), + readdirMock.mockResolvedValue( + filePaths.map((filePath) => ({ + name: path.basename(filePath), isDirectory: () => false, isSymbolicLink: () => false, isFile: () => true + })) + ) + openMock.mockImplementation(async (p: string) => { + const fileIndex = filePaths.indexOf(p) + if (fileIndex >= 0 && fileIndex < filePaths.length - 1) { + return { + stat: vi.fn().mockResolvedValue({ + size: regularSize, + ino: fileIndex + 2, + dev: 1, + isFile: () => true + }), + readFile: readFileMock, + close: vi.fn().mockResolvedValue(undefined) + } } - ]) + throw new Error(`unexpected open: ${p}`) + }) const result = (await handlers.get('fs:stageExternalPathsForRuntimeUpload')!(null, { sourcePaths: [sourcePath] @@ -765,9 +704,10 @@ describe('fs:importExternalPaths', () => { expect(result.sources[0]).toMatchObject({ status: 'failed', - reason: "'oversized.bin' is too large for remote import" + reason: 'Remote import is too large' }) - expect(openMock).not.toHaveBeenCalled() + expect(readFileMock).toHaveBeenCalledTimes(4) + expect(openMock).not.toHaveBeenCalledWith(filePaths.at(-1), expect.anything()) }) it('fails runtime upload staging when a file changes between lstat and open', async () => { @@ -786,7 +726,7 @@ describe('fs:importExternalPaths', () => { } throw enoent() }) - const readFileHandleMock = createFileHandleRead(Buffer.from('png')) + const readFileHandleMock = vi.fn().mockResolvedValue(Buffer.from('png')) openMock.mockResolvedValue({ stat: vi.fn().mockResolvedValue({ size: 4, @@ -794,7 +734,7 @@ describe('fs:importExternalPaths', () => { dev: 1, isFile: () => true }), - read: readFileHandleMock, + readFile: readFileHandleMock, close: vi.fn().mockResolvedValue(undefined) }) diff --git a/src/main/ipc/filesystem-list-files-git-directory-expansion.test.ts b/src/main/ipc/filesystem-list-files-git-directory-expansion.test.ts index ee0eeb3a6df..5d5cf163746 100644 --- a/src/main/ipc/filesystem-list-files-git-directory-expansion.test.ts +++ b/src/main/ipc/filesystem-list-files-git-directory-expansion.test.ts @@ -62,13 +62,12 @@ describe('main Quick Open git directory expansion', () => { const promise = listFilesWithGit(root, [], {}) revParse.emit('close', 0, null) - await vi.waitFor(() => expect(gitSpawnMock).toHaveBeenCalledTimes(2)) + await vi.waitFor(() => expect(gitSpawnMock).toHaveBeenCalledTimes(3)) ;(primary.stdout as unknown as EventEmitter).emit( 'data', `100644 ${SHA1} 0\tsrc/index.ts\0scratch/\0` ) primary.emit('close', 0, null) - await vi.waitFor(() => expect(gitSpawnMock).toHaveBeenCalledTimes(3)) ;(ignored.stdout as unknown as EventEmitter).emit('data', 'dist/\0') ignored.emit('close', 0, null) @@ -80,7 +79,7 @@ describe('main Quick Open git directory expansion', () => { expect(gitSpawnMock.mock.calls[2][0]).toContain('--directory') }) - it('cancels the active local Git pass when Quick Open abandons the request', async () => { + it('cancels both local Git passes when Quick Open abandons the request', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-main-git-cancel-')) tempDirs.push(root) const revParse = createMockProcess() @@ -94,12 +93,11 @@ describe('main Quick Open git directory expansion', () => { const controller = new AbortController() const promise = listFilesWithGit(root, [], {}, controller.signal) revParse.emit('close', 0, null) - await vi.waitFor(() => expect(gitSpawnMock).toHaveBeenCalledTimes(2)) + await vi.waitFor(() => expect(gitSpawnMock).toHaveBeenCalledTimes(3)) controller.abort() await expect(promise).rejects.toSatisfy(isFileListingCancellation) expect(primary.kill).toHaveBeenCalled() - expect(ignored.kill).not.toHaveBeenCalled() - expect(gitSpawnMock).toHaveBeenCalledTimes(2) + expect(ignored.kill).toHaveBeenCalled() }) }) diff --git a/src/main/ipc/filesystem-list-files-git-fallback.ts b/src/main/ipc/filesystem-list-files-git-fallback.ts index f86d13e879a..ff390d13caf 100644 --- a/src/main/ipc/filesystem-list-files-git-fallback.ts +++ b/src/main/ipc/filesystem-list-files-git-fallback.ts @@ -12,13 +12,6 @@ import { parseQuickOpenGitLsFilesEntry } from '../../shared/quick-open-readdir-walk' import { fileListingCancellationError } from '../../shared/file-listing-cancellation' -import { - createQuickOpenListingBudget, - QUICK_OPEN_LISTING_MAX_PATH_BYTES, - QuickOpenSubprocessPathAccumulator, - resolveQuickOpenResultLimit, - retainQuickOpenPath -} from '../../shared/quick-open-listing-limits' /** * Fallback file lister using git ls-files. Used when rg is not available. @@ -88,13 +81,6 @@ export async function listFilesWithGit( signal?: AbortSignal, maxResults?: number ): Promise { - if (signal?.aborted) { - throw fileListingCancellationError(signal) - } - const resultLimit = resolveQuickOpenResultLimit(maxResults) - if (resultLimit === 0) { - return [] - } const isGitWorkTree = await isInsideGitWorkTree(rootPath, localGitOptions, signal) if (signal?.aborted) { throw fileListingCancellationError(signal) @@ -103,7 +89,7 @@ export async function listFilesWithGit( return listQuickOpenFilesWithReaddir(rootPath, { excludePathPrefixes, budget: createQuickOpenReaddirBudget(), - maxResults: resultLimit, + maxResults, signal }) } @@ -111,7 +97,6 @@ export async function listFilesWithGit( const gitPaths = new Set() const directoryPaths = new Set() const directFileCandidates = new Set() - const listingBudget = createQuickOpenListingBudget() const { primary, ignoredPass } = buildGitLsFilesArgsForQuickOpen(excludePathPrefixes) const children: { child: ChildProcess @@ -122,7 +107,7 @@ export async function listFilesWithGit( const runGitLsFiles = (args: string[]): Promise => { return new Promise((resolve, reject) => { - const paths = new QuickOpenSubprocessPathAccumulator(0) + let buf = '' let done = false const processPath = (path: string): boolean => { @@ -130,23 +115,27 @@ export async function listFilesWithGit( return false } if (path.endsWith('/')) { - retainQuickOpenPath(directoryPaths, path, listingBudget) + directoryPaths.add(path) } else { - retainQuickOpenPath(gitPaths, path, listingBudget) - const parsed = parseQuickOpenGitLsFilesEntry(path) - const relPath = parsed.path.replace(/\/+$/, '') - if ( - !parsed.isGitlink && - !parsed.isUntrackedDir && - shouldIncludeQuickOpenPath(relPath) && - !shouldExcludeQuickOpenRelPath(relPath, excludePathPrefixes) - ) { - retainQuickOpenPath(directFileCandidates, relPath, listingBudget) + gitPaths.add(path) + if (maxResults !== undefined) { + // Why: this duplicate classification exists only to stop bounded + // scans; unbounded scans must not retain a second repo-sized set. + const parsed = parseQuickOpenGitLsFilesEntry(path) + const relPath = parsed.path.replace(/\/+$/, '') + if ( + !parsed.isGitlink && + !parsed.isUntrackedDir && + shouldIncludeQuickOpenPath(relPath) && + !shouldExcludeQuickOpenRelPath(relPath, excludePathPrefixes) + ) { + directFileCandidates.add(relPath) + } } } // Why: collapsed directories and gitlinks may be discarded during the // later filesystem classification, so they cannot consume the stop cap. - return directFileCandidates.size >= resultLimit + return maxResults !== undefined && directFileCandidates.size >= maxResults } // Why: git ls-files outputs paths relative to cwd, so we set cwd to @@ -171,7 +160,7 @@ export async function listFilesWithGit( return } done = true - paths.clear() + buf = '' cleanup() reject(err) } @@ -180,7 +169,6 @@ export async function listFilesWithGit( return } done = true - paths.clear() cleanup() resolve() } @@ -190,23 +178,20 @@ export async function listFilesWithGit( reject: rejectPass, resolve: resolvePass }) - const failForOutput = (error: unknown): void => { - child.kill() - rejectPass(error instanceof Error ? error : new Error(String(error))) - } - const handleStdoutData = (chunk: Buffer | string): void => { - try { - const outcome = paths.push(chunk, (path) => !processPath(path)) - if (outcome === 'stopped') { + const handleStdoutData = (chunk: string): void => { + buf += chunk + let start = 0 + let nulIdx = buf.indexOf('\0', start) + while (nulIdx !== -1) { + if (processPath(buf.substring(start, nulIdx))) { + buf = '' finishAtLimit() - } else if (outcome === 'path-too-large') { - failForOutput( - new Error(`Quick Open file path exceeded ${QUICK_OPEN_LISTING_MAX_PATH_BYTES} bytes`) - ) + return } - } catch (error) { - failForOutput(error) + start = nulIdx + 1 + nulIdx = buf.indexOf('\0', start) } + buf = start < buf.length ? buf.substring(start) : '' } const handleStderrData = (): void => { /* drain */ @@ -222,14 +207,9 @@ export async function listFilesWithGit( rejectPass(new Error(`git ls-files killed by ${signal}`)) return } - try { - const trailingPath = paths.finish() - if (trailingPath && processPath(trailingPath)) { - finishAtLimit() - return - } - } catch (error) { - failForOutput(error) + if (buf && processPath(buf)) { + buf = '' + finishAtLimit() return } if (code === 0) { @@ -239,12 +219,13 @@ export async function listFilesWithGit( rejectPass(new Error(`git ls-files exited with code ${code}`)) } + child.stdout!.setEncoding('utf-8') child.stdout!.on('data', handleStdoutData) child.stderr!.on('data', handleStderrData) child.once('error', handleError) child.once('close', handleClose) timer = setTimeout(() => { - paths.clear() + buf = '' child.kill() rejectPass(new Error('git ls-files timed out')) }, 10000) @@ -288,11 +269,15 @@ export async function listFilesWithGit( console.warn('[quick-open] git ignored-file pass failed; keeping primary results:', err) } }) - // Why: give ordinary source files first claim on a bounded autocomplete - // inventory; a large ignored tree must not win a parallel-output race. - await runGitLsFiles(primary) - if (directFileCandidates.size < resultLimit) { - await runIgnoredPass() + if (maxResults === undefined) { + await Promise.all([runGitLsFiles(primary), runIgnoredPass()]) + } else { + // Why: give ordinary source files first claim on a bounded autocomplete + // inventory; a large ignored tree must not win a parallel-output race. + await runGitLsFiles(primary) + if (directFileCandidates.size < maxResults) { + await runIgnoredPass() + } } } catch (err) { killSurvivors() @@ -310,9 +295,9 @@ export async function listFilesWithGit( directoryPaths, excludePathPrefixes, signal, - maxResults: resultLimit + maxResults }) // Why: directory placeholders are expanded after Git exits; restore Git's // path order so empty queries and fuzzy-score ties remain stable. - return files.sort().slice(0, resultLimit) + return files.sort().slice(0, maxResults) } diff --git a/src/main/ipc/filesystem-list-files.test.ts b/src/main/ipc/filesystem-list-files.test.ts index 1801b779ece..946f8653dd6 100644 --- a/src/main/ipc/filesystem-list-files.test.ts +++ b/src/main/ipc/filesystem-list-files.test.ts @@ -36,11 +36,6 @@ import { listQuickOpenFiles } from './filesystem-list-files' import { EventEmitter } from 'node:events' import type { Store } from '../persistence' import type { ChildProcess } from 'node:child_process' -import { - QUICK_OPEN_LISTING_MAX_PATH_BYTES, - QUICK_OPEN_LISTING_MAX_RESULTS -} from '../../shared/quick-open-listing-limits' -import { isFileListingCancellation } from '../../shared/file-listing-cancellation' const SHA1 = '0123456789abcdef0123456789abcdef01234567' @@ -100,88 +95,6 @@ describe('filesystem-list-files', () => { expect(spawnMock).toHaveBeenCalledTimes(1) }) - it('bounds an omitted rg result limit and recovers on the next scan', async () => { - const primary = createMockProcess() - const ignored = createMockProcess() - spawnMock.mockImplementation((_cmd, args: string[]) => - isIgnoredRgPass(args) ? ignored : primary - ) - const promise = listQuickOpenFiles('/mock/root', {} as unknown as Store) - const paths = Array.from( - { length: QUICK_OPEN_LISTING_MAX_RESULTS + 1 }, - (_value, index) => `src/file-${index}.ts` - ) - - await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(1)) - ;(primary.stdout as unknown as EventEmitter).emit('data', paths.join('\n')) - - const result = await promise - expect(result).toHaveLength(QUICK_OPEN_LISTING_MAX_RESULTS) - expect(result.at(-1)).toBe(`src/file-${QUICK_OPEN_LISTING_MAX_RESULTS - 1}.ts`) - expect(primary.kill).toHaveBeenCalled() - expect(ignored.kill).not.toHaveBeenCalled() - expect(spawnMock).toHaveBeenCalledTimes(1) - - const recoveryPrimary = createMockProcess() - const recoveryIgnored = createMockProcess() - spawnMock.mockImplementation((_cmd, args: string[]) => - isIgnoredRgPass(args) ? recoveryIgnored : recoveryPrimary - ) - const recovery = listQuickOpenFiles('/mock/root', {} as unknown as Store) - await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(2)) - ;(recoveryPrimary.stdout as unknown as EventEmitter).emit('data', 'src/recovered.ts\n') - recoveryPrimary.emit('close', 0, null) - queueMicrotask(() => recoveryIgnored.emit('close', 0, null)) - - await expect(recovery).resolves.toEqual(['src/recovered.ts']) - }) - - it('kills a local rg scan whose residual path exceeds the field limit', async () => { - const primary = createMockProcess() - const ignored = createMockProcess() - spawnMock.mockImplementation((_cmd, args: string[]) => - isIgnoredRgPass(args) ? ignored : primary - ) - const promise = listQuickOpenFiles('/mock/root', {} as unknown as Store) - - await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(1)) - ;(primary.stdout as unknown as EventEmitter).emit( - 'data', - Buffer.alloc(QUICK_OPEN_LISTING_MAX_PATH_BYTES + 1, 0x61) - ) - - await expect(promise).rejects.toThrow( - `Quick Open file path exceeded ${QUICK_OPEN_LISTING_MAX_PATH_BYTES} bytes` - ) - expect(primary.kill).toHaveBeenCalled() - expect(ignored.kill).not.toHaveBeenCalled() - expect(spawnMock).toHaveBeenCalledTimes(1) - }) - - it('kills the active local rg pass when the listing is cancelled', async () => { - const primary = createMockProcess() - const ignored = createMockProcess() - spawnMock.mockImplementation((_cmd, args: string[]) => - isIgnoredRgPass(args) ? ignored : primary - ) - const controller = new AbortController() - const promise = listQuickOpenFiles( - '/mock/root', - {} as unknown as Store, - undefined, - controller.signal - ) - await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(1)) - - controller.abort() - - await expect(promise).rejects.toSatisfy(isFileListingCancellation) - expect(primary.kill).toHaveBeenCalled() - expect(ignored.kill).not.toHaveBeenCalled() - expect((primary.stdout as unknown as EventEmitter).listenerCount('data')).toBe(0) - expect(primary.listenerCount('close')).toBe(0) - }) - it('merges normal files and ignored files and filters correctly', async () => { const p1 = createMockProcess() const p2 = createMockProcess() @@ -206,13 +119,12 @@ describe('filesystem-list-files', () => { ;(p1.stdout as unknown as EventEmitter).emit('data', 'file2.js\n') p1.emit('close', 0, null) - queueMicrotask(() => { - ;(p2.stdout as unknown as EventEmitter).emit('data', '.env.local\n') - ;(p2.stdout as unknown as EventEmitter).emit('data', 'dist/generated.js\n') - ;(p2.stdout as unknown as EventEmitter).emit('data', 'file1.ts\n') // Duplicate - ;(p2.stdout as unknown as EventEmitter).emit('data', 'node_modules/ignored.js\n') - p2.emit('close', 0, null) - }) + // Simulate stdout output for ignored files + ;(p2.stdout as unknown as EventEmitter).emit('data', '.env.local\n') + ;(p2.stdout as unknown as EventEmitter).emit('data', 'dist/generated.js\n') + ;(p2.stdout as unknown as EventEmitter).emit('data', 'file1.ts\n') // Duplicate + ;(p2.stdout as unknown as EventEmitter).emit('data', 'node_modules/ignored.js\n') + p2.emit('close', 0, null) }, 10) const result = await promise @@ -244,7 +156,7 @@ describe('filesystem-list-files', () => { setTimeout(() => { ;(p1.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\n') p1.emit('close', 0, null) - queueMicrotask(() => p2.emit('close', 0, null)) + p2.emit('close', 0, null) }, 10) await expect(promise).resolves.toEqual(['src/index.ts']) @@ -274,7 +186,7 @@ describe('filesystem-list-files', () => { setTimeout(() => { ;(p1.stdout as unknown as EventEmitter).emit('data', '/mnt/c/repo/src/index.ts\n') p1.emit('close', 0, null) - queueMicrotask(() => p2.emit('close', 0, null)) + p2.emit('close', 0, null) }, 10) await expect(promise).resolves.toEqual(['src/index.ts']) @@ -296,12 +208,13 @@ describe('filesystem-list-files', () => { setTimeout(() => { p1.emit('close', 2, null) + p2.emit('close', 0, null) }, 10) await expect(promise).rejects.toThrow('rg exited with code 2') }) - it('does not spawn the ignored rg pass after the primary pass fails', async () => { + it('kills the sibling rg pass after one pass fails', async () => { const p1 = createMockProcess() const p2 = createMockProcess() @@ -321,8 +234,7 @@ describe('filesystem-list-files', () => { }, 10) await expect(promise).rejects.toThrow('rg exited with code 2') - expect(p2.kill).not.toHaveBeenCalled() - expect(spawnMock).toHaveBeenCalledTimes(1) + expect(p2.kill).toHaveBeenCalled() }) it('accepts rg code 2 when rg emitted parseable paths first', async () => { @@ -342,7 +254,7 @@ describe('filesystem-list-files', () => { setTimeout(() => { ;(p1.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\n') p1.emit('close', 2, null) - queueMicrotask(() => p2.emit('close', 0, null)) + p2.emit('close', 0, null) }, 10) await expect(promise).resolves.toEqual(['src/index.ts']) @@ -376,8 +288,7 @@ describe('filesystem-list-files', () => { await rejection expect(p1.kill).toHaveBeenCalled() - expect(p2.kill).not.toHaveBeenCalled() - expect(spawnMock).toHaveBeenCalledTimes(1) + expect(p2.kill).toHaveBeenCalled() expect((p1.stdout as unknown as EventEmitter).listenerCount('data')).toBe(0) expect((p1.stderr as unknown as EventEmitter).listenerCount('data')).toBe(0) expect(p1.listenerCount('error')).toBe(0) @@ -410,7 +321,8 @@ describe('filesystem-list-files', () => { ;(p1.stdout as unknown as EventEmitter).emit('data', 'valid.ts\n') p1.emit('close', 0, null) - queueMicrotask(() => p2.emit('close', 0, null)) + // Empty ignored result + p2.emit('close', 0, null) }, 10) const result = await promise @@ -459,11 +371,9 @@ describe('filesystem-list-files', () => { ) gitP1.emit('close', 0, null) - queueMicrotask(() => { - ;(gitP2.stdout as unknown as EventEmitter).emit('data', '.env.local\0') - ;(gitP2.stdout as unknown as EventEmitter).emit('data', 'dist/generated.js\0') - gitP2.emit('close', 0, null) - }) + ;(gitP2.stdout as unknown as EventEmitter).emit('data', '.env.local\0') + ;(gitP2.stdout as unknown as EventEmitter).emit('data', 'dist/generated.js\0') + gitP2.emit('close', 0, null) }, 10) const result = await promise @@ -528,37 +438,6 @@ describe('filesystem-list-files', () => { expect(callIndex).toBe(1) }) - it('bounds the omitted local Git result limit before spawning the ignored pass', async () => { - checkRgAvailableMock.mockResolvedValue(false) - const revParseProc = createMockProcess() - const primary = createMockProcess() - const ignored = createMockProcess() - let gitPasses = 0 - spawnMock.mockImplementation((cmd: string, args: string[]) => { - if (cmd === 'git' && args.includes('rev-parse')) { - return revParseProc - } - gitPasses++ - return gitPasses === 1 ? primary : ignored - }) - const promise = listQuickOpenFiles('/mock/root', {} as unknown as Store) - const paths = Array.from( - { length: QUICK_OPEN_LISTING_MAX_RESULTS + 1 }, - (_value, index) => `src/file-${index}.ts` - ) - setTimeout(() => revParseProc.emit('close', 0, null), 0) - setTimeout( - () => (primary.stdout as unknown as EventEmitter).emit('data', paths.join('\0')), - 10 - ) - - const result = await promise - expect(result).toHaveLength(QUICK_OPEN_LISTING_MAX_RESULTS) - expect(primary.kill).toHaveBeenCalled() - expect(ignored.kill).not.toHaveBeenCalled() - expect(gitPasses).toBe(1) - }) - it('does not let a discarded Git directory placeholder consume the result budget', async () => { checkRgAvailableMock.mockResolvedValue(false) const revParseProc = createMockProcess() @@ -630,7 +509,7 @@ describe('filesystem-list-files', () => { ;(gitP1.stdout as unknown as EventEmitter).emit('data', `${staged('100644', 'valid.ts')}\0`) gitP1.emit('close', 0, null) - queueMicrotask(() => gitP2.emit('close', 0, null)) + gitP2.emit('close', 0, null) }, 10) const result = await promise @@ -677,8 +556,7 @@ describe('filesystem-list-files', () => { await rejection expect(gitP1.kill).toHaveBeenCalled() - expect(gitP2.kill).not.toHaveBeenCalled() - expect(callIndex).toBe(1) + expect(gitP2.kill).toHaveBeenCalled() expect((gitP1.stdout as unknown as EventEmitter).listenerCount('data')).toBe(0) expect((gitP1.stderr as unknown as EventEmitter).listenerCount('data')).toBe(0) expect(gitP1.listenerCount('error')).toBe(0) @@ -726,7 +604,6 @@ describe('filesystem-list-files', () => { `${staged('100644', 'src/index.ts')}\0` ) gitP1.emit('close', 0, null) - await Promise.resolve() // Ignored entries streamed before the timeout are kept. ;(gitP2.stdout as unknown as EventEmitter).emit('data', 'dist/generated.js\0') @@ -762,7 +639,7 @@ describe('filesystem-list-files', () => { setTimeout(() => { ;(p1.stdout as unknown as EventEmitter).emit('data', 'file.ts\n') p1.emit('close', 0, null) - queueMicrotask(() => p2.emit('close', 0, null)) + p2.emit('close', 0, null) }, 10) const result = await promise diff --git a/src/main/ipc/filesystem-list-files.ts b/src/main/ipc/filesystem-list-files.ts index 49105dc9b40..7edb688de72 100644 --- a/src/main/ipc/filesystem-list-files.ts +++ b/src/main/ipc/filesystem-list-files.ts @@ -16,14 +16,6 @@ import { } from '../../shared/quick-open-filter' import { isQuickOpenReaddirBudgetError } from '../../shared/quick-open-readdir-walk' import { buildInstallRgMessage } from '../../shared/quick-open-install-rg' -import { - createQuickOpenListingBudget, - QUICK_OPEN_LISTING_MAX_PATH_BYTES, - QuickOpenSubprocessPathAccumulator, - resolveQuickOpenResultLimit, - retainQuickOpenPath -} from '../../shared/quick-open-listing-limits' -import { fileListingCancellationError } from '../../shared/file-listing-cancellation' import { listFilesWithGit } from './filesystem-list-files-git-fallback' export async function listQuickOpenFiles( @@ -45,13 +37,6 @@ export async function listQuickOpenFiles( // every worktree instead of just the active one. The shared helper // normalizes, validates, and root-relativizes every input. const excludePathPrefixes = buildExcludePathPrefixes(authorizedRootPath, excludePaths) - if (signal?.aborted) { - throw fileListingCancellationError(signal) - } - const resultLimit = resolveQuickOpenResultLimit(maxResults) - if (resultLimit === 0) { - return [] - } // Why: checking rg availability upfront avoids a race condition where // spawn('rg') emits 'close' before 'error' on some platforms, causing @@ -65,7 +50,7 @@ export async function listQuickOpenFiles( excludePathPrefixes, localGitOptions, signal, - resultLimit + maxResults ) } catch (err) { if (!isQuickOpenReaddirBudgetError(err)) { @@ -76,11 +61,10 @@ export async function listQuickOpenFiles( } const files = new Set() - const listingBudget = createQuickOpenListingBudget() const children: { child: ChildProcess isDone: () => boolean - finish: (error?: Error) => void + finish: () => void }[] = [] // Why: WSL-routed rg can emit Linux-native absolute paths. UNC repos carry // their distro in the path; Windows-path repos carry it in project runtime. @@ -98,11 +82,8 @@ export async function listQuickOpenFiles( }) const runRg = (args: string[]): Promise => { - if (signal?.aborted) { - return Promise.reject(fileListingCancellationError(signal)) - } return new Promise((resolve, reject) => { - const paths = new QuickOpenSubprocessPathAccumulator(0x0a) + let buf = '' let done = false let parseablePathCount = 0 @@ -125,11 +106,11 @@ export async function listQuickOpenFiles( if (shouldExcludeQuickOpenRelPath(relPath, excludePathPrefixes)) { return false } - if (files.size >= resultLimit) { + if (maxResults !== undefined && files.size >= maxResults) { return true } - retainQuickOpenPath(files, relPath, listingBudget) - return files.size >= resultLimit + files.add(relPath) + return maxResults !== undefined && files.size >= maxResults } const child = wslAwareSpawn('rg', args, { @@ -138,24 +119,20 @@ export async function listQuickOpenFiles( stdio: ['ignore', 'pipe', 'pipe'] }) let timer: ReturnType - const failForOutput = (error: unknown): void => { - paths.clear() - child.kill() - finish(error instanceof Error ? error : new Error(String(error))) - } - const handleStdoutData = (chunk: Buffer | string): void => { - try { - const outcome = paths.push(chunk, (path) => !processLine(path)) - if (outcome === 'stopped') { + const handleStdoutData = (chunk: string): void => { + buf += chunk + let start = 0 + let newlineIdx = buf.indexOf('\n', start) + while (newlineIdx !== -1) { + if (processLine(buf.substring(start, newlineIdx))) { + buf = '' finishAtLimit() - } else if (outcome === 'path-too-large') { - failForOutput( - new Error(`Quick Open file path exceeded ${QUICK_OPEN_LISTING_MAX_PATH_BYTES} bytes`) - ) + return } - } catch (error) { - failForOutput(error) + start = newlineIdx + 1 + newlineIdx = buf.indexOf('\n', start) } + buf = start < buf.length ? buf.substring(start) : '' } const handleStderrData = (): void => { /* drain */ @@ -163,7 +140,7 @@ export async function listQuickOpenFiles( const handleError = (): void => { // Why: treat spawn errors like an abnormal exit — discard residual // buffer so a truncated final byte sequence cannot leak as a path. - paths.clear() + buf = '' finish(new Error('rg failed to start')) } const handleClose = (code: number | null, signal: NodeJS.Signals | null): void => { @@ -171,18 +148,13 @@ export async function listQuickOpenFiles( // Why: a signal exit means timeout/OOM/external kill. Returning the // already-streamed prefix would recreate the false-empty bug this // path is meant to avoid. - paths.clear() + buf = '' finish(new Error(`rg killed by ${signal}`)) return } - try { - const trailingPath = paths.finish() - if (trailingPath && processLine(trailingPath)) { - finishAtLimit() - return - } - } catch (error) { - failForOutput(error) + if (buf && processLine(buf)) { + buf = '' + finishAtLimit() return } if (code === 0 || code === 1) { @@ -216,6 +188,7 @@ export async function listQuickOpenFiles( children.push({ child, isDone: () => done, finish }) + child.stdout!.setEncoding('utf-8') child.stdout!.on('data', handleStdoutData) child.stderr!.on('data', handleStderrData) child.once('error', handleError) @@ -223,7 +196,7 @@ export async function listQuickOpenFiles( timer = setTimeout(() => { // Why: on timeout, the buffer is likely truncated mid-path. Discard // it so Quick Open never displays a malformed entry. - paths.clear() + buf = '' child.kill() finish(new Error('rg list timed out')) }, 10000) @@ -257,33 +230,22 @@ export async function listQuickOpenFiles( } } - const onAbort = (): void => { - const error = fileListingCancellationError(signal) - for (const entry of children) { - if (entry.isDone()) { - continue - } - entry.finish(error) - if (entry.child.exitCode === null && entry.child.signalCode === null) { - entry.child.kill() - } - } - } - signal?.addEventListener('abort', onAbort, { once: true }) try { - // Why: ignored-file output can be much larger and faster than the primary - // pass; let source files claim the bounded autocomplete budget first. - await runRg(primary) - if (files.size < resultLimit) { - await runRg(ignoredPass) + if (maxResults === undefined) { + await Promise.all([runRg(primary), runRg(ignoredPass)]) + } else { + // Why: ignored-file output can be much larger and faster than the primary + // pass; let source files claim the bounded autocomplete budget first. + await runRg(primary) + if (files.size < maxResults) { + await runRg(ignoredPass) + } } } catch (err) { killSurvivors() throw err - } finally { - signal?.removeEventListener('abort', onAbort) } - return Array.from(files).slice(0, resultLimit) + return Array.from(files).slice(0, maxResults) } function getQuickOpenRgOutputMode( diff --git a/src/main/ipc/filesystem-mutations.test.ts b/src/main/ipc/filesystem-mutations.test.ts index 6bc17af2af7..dcaeb74b624 100644 --- a/src/main/ipc/filesystem-mutations.test.ts +++ b/src/main/ipc/filesystem-mutations.test.ts @@ -24,7 +24,7 @@ vi.mock('fs/promises', () => ({ writeFile: writeFileMock, realpath: realpathMock, copyFile: copyFileMock, - opendir: vi.fn() + readdir: vi.fn() })) import { registerFilesystemMutationHandlers } from './filesystem-mutations' @@ -77,7 +77,6 @@ describe('registerFilesystemMutationHandlers', () => { writeFileMock.mockReset() realpathMock.mockReset() resetSshConnectionGenerations() - setSshConnectionGeneration('ssh-1', 0) handleMock.mockImplementation((channel: string, handler: never) => { handlers.set(channel, handler) diff --git a/src/main/ipc/filesystem-mutations.ts b/src/main/ipc/filesystem-mutations.ts index 80b30617691..e89ff761b63 100644 --- a/src/main/ipc/filesystem-mutations.ts +++ b/src/main/ipc/filesystem-mutations.ts @@ -7,7 +7,7 @@ import { lstat, mkdir, open, - opendir, + readdir, realpath, rename, rm, @@ -24,19 +24,6 @@ import { importExternalPathsSsh } from './filesystem-import-ssh' import { assertNoClobberRenameDestinationAvailable } from '../../shared/filesystem-rename-collision' import type { SshMutationExpectation } from '../../shared/ssh-types' import { assertSshMutationExpectation } from '../ssh/ssh-connection-generation' -import { readStableRuntimeUploadFile } from './filesystem-runtime-upload-file-reader' -import { - admitExternalImportTreeEntry, - assertExternalImportSourcePaths, - assertExternalImportTreeDepth, - assertRuntimeUploadFileFits, - captureRuntimeUploadRetentionCheckpoint, - createExternalImportTreeBudget, - createRuntimeUploadRetentionBudget, - restoreRuntimeUploadRetentionCheckpoint, - retainRuntimeUploadFileBytes, - type RuntimeUploadRetentionBudget -} from './filesystem-external-import-limits' /** * Re-throw filesystem errors with user-friendly messages. @@ -214,7 +201,6 @@ export function registerFilesystemMutationHandlers(store: Store): void { args.expectedSshConnectionGeneration, args.expectedExecutionHostId ) - assertExternalImportSourcePaths(args.sourcePaths) if (args.connectionId) { return importExternalPathsSsh(args.sourcePaths, args.destDir, args.connectionId, { ensureDir: args.ensureDir, @@ -256,10 +242,8 @@ export function registerFilesystemMutationHandlers(store: Store): void { args: { sourcePaths: string[] } ): Promise<{ sources: StagedExternalImportSource[] }> => { const sources: StagedExternalImportSource[] = [] - assertExternalImportSourcePaths(args.sourcePaths) - const retentionBudget = createRuntimeUploadRetentionBudget() for (const sourcePath of args.sourcePaths) { - sources.push(await stageOneSourceForRuntimeUpload(sourcePath, retentionBudget)) + sources.push(await stageOneSourceForRuntimeUpload(sourcePath)) } return { sources } } @@ -287,7 +271,6 @@ export function registerFilesystemMutationHandlers(store: Store): void { args.expectedSshConnectionGeneration, args.expectedExecutionHostId ) - assertExternalImportSourcePaths(args.paths) // Why: `== null` (not `!args.connectionId`) so an empty string is // treated as a renderer error, not silently routed to the local branch. if (args.connectionId == null) { @@ -379,6 +362,9 @@ export type StagedExternalImportEntry = | { relativePath: string; kind: 'directory' } | { relativePath: string; kind: 'file'; contentBase64: string } +const REMOTE_IMPORT_MAX_FILE_BYTES = 25 * 1024 * 1024 +const REMOTE_IMPORT_MAX_TOTAL_BYTES = 100 * 1024 * 1024 + class RuntimeUploadSymlinkError extends Error {} // ─── External Import Implementation ───────────────────────────────── @@ -439,17 +425,9 @@ async function importOneSource( // creating any destination files. This prevents partially imported // trees when a symlink is discovered halfway through recursive copy. if (isDir) { - try { - const hasSymlink = await preScanForSymlinks(resolvedSource) - if (hasSymlink) { - return { sourcePath, status: 'skipped', reason: 'symlink' } - } - } catch (error) { - return { - sourcePath, - status: 'failed', - reason: error instanceof Error ? error.message : String(error) - } + const hasSymlink = await preScanForSymlinks(resolvedSource) + if (hasSymlink) { + return { sourcePath, status: 'skipped', reason: 'symlink' } } } @@ -484,8 +462,7 @@ async function importOneSource( } async function stageOneSourceForRuntimeUpload( - sourcePath: string, - retentionBudget: RuntimeUploadRetentionBudget + sourcePath: string ): Promise { const resolvedSource = resolve(sourcePath) @@ -521,15 +498,10 @@ async function stageOneSourceForRuntimeUpload( if (!sourceStat.isFile() && !sourceStat.isDirectory()) { return { sourcePath, status: 'skipped', reason: 'unsupported' } } - const checkpoint = captureRuntimeUploadRetentionCheckpoint(retentionBudget) try { - let entries: StagedExternalImportEntry[] - if (sourceStat.isDirectory()) { - entries = await stageDirectoryEntries(resolvedSource, retentionBudget) - } else { - admitExternalImportTreeEntry(retentionBudget.tree, '', true) - entries = [await stageFileEntry(resolvedSource, '', { retentionBudget })] - } + const entries = sourceStat.isDirectory() + ? await stageDirectoryEntries(resolvedSource) + : [(await stageFileEntry(resolvedSource, '')).entry] return { sourcePath, status: 'staged', @@ -538,7 +510,6 @@ async function stageOneSourceForRuntimeUpload( entries } } catch (error) { - restoreRuntimeUploadRetentionCheckpoint(retentionBudget, checkpoint) if (error instanceof RuntimeUploadSymlinkError) { return { sourcePath, status: 'skipped', reason: 'symlink' } } @@ -550,61 +521,61 @@ async function stageOneSourceForRuntimeUpload( } } -async function stageDirectoryEntries( - rootPath: string, - retentionBudget: RuntimeUploadRetentionBudget -): Promise { - admitExternalImportTreeEntry(retentionBudget.tree, '', true) +async function stageDirectoryEntries(rootPath: string): Promise { const entries: StagedExternalImportEntry[] = [{ relativePath: '', kind: 'directory' }] + let totalBytes = 0 const rootRealPath = await realpath(rootPath) - async function visit(dirPath: string, depth: number): Promise { - assertExternalImportTreeDepth(depth) + async function visit(dirPath: string): Promise { const dirStat = await lstat(dirPath) - const dirRelativePath = normalizeRelativeUploadPath(relative(rootPath, dirPath)) if (dirStat.isSymbolicLink()) { - throw new RuntimeUploadSymlinkError(`Symlink not allowed in '${dirRelativePath}'`) + throw new RuntimeUploadSymlinkError( + `Symlink not allowed in '${normalizeRelativeUploadPath(relative(rootPath, dirPath))}'` + ) } if (!dirStat.isDirectory()) { - throw new Error(`Unsupported file type in '${dirRelativePath}'`) + throw new Error( + `Unsupported file type in '${normalizeRelativeUploadPath(relative(rootPath, dirPath))}'` + ) } - await assertRealPathInsideRoot(rootRealPath, dirPath, dirRelativePath) - const directory = await opendir(dirPath) - for await (const entry of directory) { + await assertRealPathInsideRoot( + rootRealPath, + dirPath, + normalizeRelativeUploadPath(relative(rootPath, dirPath)) + ) + const dirEntries = await readdir(dirPath, { withFileTypes: true }) + for (const entry of dirEntries) { const childPath = join(dirPath, entry.name) const childRelativePath = normalizeRelativeUploadPath(relative(rootPath, childPath)) if (entry.isSymbolicLink()) { throw new RuntimeUploadSymlinkError(`Symlink not allowed in '${childRelativePath}'`) } if (entry.isDirectory()) { - assertExternalImportTreeDepth(depth + 1) - admitExternalImportTreeEntry(retentionBudget.tree, childRelativePath, true) entries.push({ relativePath: childRelativePath, kind: 'directory' }) - await visit(childPath, depth + 1) + await visit(childPath) continue } if (!entry.isFile()) { throw new Error(`Unsupported file type in '${childRelativePath}'`) } - admitExternalImportTreeEntry(retentionBudget.tree, childRelativePath, true) - entries.push( - await stageFileEntry(childPath, childRelativePath, { - rootRealPath, - retentionBudget - }) - ) + const stagedFile = await stageFileEntry(childPath, childRelativePath, { + rootRealPath, + totalBytesBefore: totalBytes + }) + totalBytes += stagedFile.byteLength + entries.push(stagedFile.entry) } } - await visit(rootPath, 0) + await visit(rootPath) return entries } async function stageFileEntry( filePath: string, relativePath: string, - options: { retentionBudget: RuntimeUploadRetentionBudget; rootRealPath?: string } -): Promise { + options?: { rootRealPath?: string; totalBytesBefore?: number } +): Promise<{ entry: StagedExternalImportEntry; byteLength: number }> { const statResult = await lstat(filePath) const displayPath = normalizeRelativeUploadPath(relativePath) if (statResult.isSymbolicLink()) { @@ -616,7 +587,11 @@ async function stageFileEntry( if (options?.rootRealPath) { await assertRealPathInsideRoot(options.rootRealPath, filePath, displayPath) } - assertRuntimeUploadFileFits(options.retentionBudget, displayPath, statResult.size) + const initialTotalBytes = + options?.totalBytesBefore === undefined + ? statResult.size + : options.totalBytesBefore + statResult.size + assertRemoteUploadBudget(relativePath, statResult.size, initialTotalBytes) const fileHandle = await open(filePath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)) try { const openedStat = await fileHandle.stat() @@ -630,17 +605,23 @@ async function stageFileEntry( ) { throw new Error(`File changed during upload staging: '${displayPath}'`) } - assertRuntimeUploadFileFits(options.retentionBudget, displayPath, openedStat.size) - const buffer = await readStableRuntimeUploadFile(fileHandle, openedStat.size, displayPath) + const totalBytes = + options?.totalBytesBefore === undefined + ? openedStat.size + : options.totalBytesBefore + openedStat.size + assertRemoteUploadBudget(relativePath, openedStat.size, totalBytes) + const buffer = await fileHandle.readFile() const afterReadStat = await fileHandle.stat() if (afterReadStat.size !== openedStat.size) { throw new Error(`File changed during upload staging: '${displayPath}'`) } - retainRuntimeUploadFileBytes(options.retentionBudget, displayPath, buffer.byteLength) return { - relativePath: displayPath, - kind: 'file', - contentBase64: buffer.toString('base64') + entry: { + relativePath: displayPath, + kind: 'file', + contentBase64: buffer.toString('base64') + }, + byteLength: openedStat.size } } finally { await fileHandle.close() @@ -663,6 +644,19 @@ async function assertRealPathInsideRoot( } } +function assertRemoteUploadBudget( + relativePath: string, + fileBytes: number, + totalBytes: number +): void { + if (fileBytes > REMOTE_IMPORT_MAX_FILE_BYTES) { + throw new Error(`'${relativePath}' is too large for remote import`) + } + if (totalBytes > REMOTE_IMPORT_MAX_TOTAL_BYTES) { + throw new Error('Remote import is too large') + } +} + function normalizeRelativeUploadPath(path: string): string { return path.replace(/[\\/]+/g, '/').replace(/^\/+/, '') } @@ -672,40 +666,19 @@ function normalizeRelativeUploadPath(path: string): string { * is found anywhere in the subtree. */ async function preScanForSymlinks(dirPath: string): Promise { - const budget = createExternalImportTreeBudget() - - async function visit( - currentDirPath: string, - relativeDirPath: string, - depth: number - ): Promise { - assertExternalImportTreeDepth(depth) - const currentStat = await lstat(currentDirPath) - if (currentStat.isSymbolicLink()) { + const entries = await readdir(dirPath, { withFileTypes: true }) + for (const entry of entries) { + if (entry.isSymbolicLink()) { return true } - if (!currentStat.isDirectory()) { - throw new Error(`Unsupported file type in '${relativeDirPath}'`) - } - - const directory = await opendir(currentDirPath) - for await (const entry of directory) { - const childRelativePath = join(relativeDirPath, entry.name) - admitExternalImportTreeEntry(budget, childRelativePath, false) - if (entry.isSymbolicLink()) { + if (entry.isDirectory()) { + const childPath = join(dirPath, entry.name) + if (await preScanForSymlinks(childPath)) { return true } - if (entry.isDirectory()) { - assertExternalImportTreeDepth(depth + 1) - if (await visit(join(currentDirPath, entry.name), childRelativePath, depth + 1)) { - return true - } - } } - return false } - - return visit(dirPath, '', 0) + return false } /** @@ -714,47 +687,24 @@ async function preScanForSymlinks(dirPath: string): Promise { * buffering entire files into memory. */ async function recursiveCopyDir(srcDir: string, destDir: string): Promise { - const budget = createExternalImportTreeBudget() - - async function copyDirectory( - currentSrcDir: string, - currentDestDir: string, - relativeDirPath: string, - depth: number - ): Promise { - assertExternalImportTreeDepth(depth) - const dirStat = await lstat(currentSrcDir) - if (dirStat.isSymbolicLink()) { - throw new Error(`Symlink not allowed in '${relativeDirPath}'`) + await mkdir(destDir, { recursive: false }) + const entries = await readdir(srcDir, { withFileTypes: true }) + for (const entry of entries) { + const srcPath = join(srcDir, entry.name) + const dstPath = join(destDir, entry.name) + const statResult = await lstat(srcPath) + if (statResult.isSymbolicLink()) { + throw new Error(`Symlink not allowed in '${entry.name}'`) } - if (!dirStat.isDirectory()) { - throw new Error(`Unsupported file type in '${relativeDirPath}'`) + if (statResult.isDirectory()) { + await recursiveCopyDir(srcPath, dstPath) + continue } - await mkdir(currentDestDir, { recursive: false }) - - const directory = await opendir(currentSrcDir) - for await (const entry of directory) { - const srcPath = join(currentSrcDir, entry.name) - const dstPath = join(currentDestDir, entry.name) - const childRelativePath = join(relativeDirPath, entry.name) - admitExternalImportTreeEntry(budget, childRelativePath, false) - const statResult = await lstat(srcPath) - if (statResult.isSymbolicLink()) { - throw new Error(`Symlink not allowed in '${childRelativePath}'`) - } - if (statResult.isDirectory()) { - assertExternalImportTreeDepth(depth + 1) - await copyDirectory(srcPath, dstPath, childRelativePath, depth + 1) - continue - } - if (!statResult.isFile()) { - throw new Error(`Unsupported file type in '${childRelativePath}'`) - } - await copyLocalFileNoFollow(srcPath, dstPath, statResult) + if (!statResult.isFile()) { + throw new Error(`Unsupported file type in '${entry.name}'`) } + await copyLocalFileNoFollow(srcPath, dstPath, statResult) } - - await copyDirectory(srcDir, destDir, '', 0) } async function copyLocalFileNoFollow( diff --git a/src/main/ipc/filesystem-runtime-upload-file-reader.test.ts b/src/main/ipc/filesystem-runtime-upload-file-reader.test.ts deleted file mode 100644 index d2c3d7f92be..00000000000 --- a/src/main/ipc/filesystem-runtime-upload-file-reader.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { readStableRuntimeUploadFile } from './filesystem-runtime-upload-file-reader' - -function createRead(content: Buffer, maxBytesPerRead = content.byteLength) { - return vi.fn(async (buffer: Buffer, offset: number, length: number, position: number) => { - const bytesRead = Math.min(length, maxBytesPerRead, Math.max(0, content.byteLength - position)) - if (bytesRead > 0) { - content.copy(buffer, offset, position, position + bytesRead) - } - return { buffer, bytesRead } - }) -} - -describe('runtime upload file reader', () => { - it('preserves an exact-size file across partial reads', async () => { - const content = Buffer.from('bounded-content') - const read = createRead(content, 3) - - await expect( - readStableRuntimeUploadFile({ read }, content.byteLength, 'asset.bin') - ).resolves.toEqual(content) - expect(read).toHaveBeenLastCalledWith(expect.any(Buffer), 0, 1, content.byteLength) - }) - - it('rejects a file that becomes shorter without exposing uninitialized bytes', async () => { - const content = Buffer.from('short') - - await expect( - readStableRuntimeUploadFile({ read: createRead(content) }, content.byteLength + 1, '') - ).rejects.toThrow("File changed during upload staging: ''") - }) - - it('uses a one-byte probe and rejects growth beyond the admitted size', async () => { - const content = Buffer.from('growth') - const admittedBytes = content.byteLength - 1 - const read = createRead(content) - - await expect( - readStableRuntimeUploadFile({ read }, admittedBytes, 'growth.bin') - ).rejects.toThrow("File changed during upload staging: 'growth.bin'") - expect(read).toHaveBeenNthCalledWith(1, expect.any(Buffer), 0, admittedBytes, 0) - expect(read).toHaveBeenNthCalledWith(2, expect.any(Buffer), 0, 1, admittedBytes) - }) -}) diff --git a/src/main/ipc/filesystem-runtime-upload-file-reader.ts b/src/main/ipc/filesystem-runtime-upload-file-reader.ts deleted file mode 100644 index 842db049fef..00000000000 --- a/src/main/ipc/filesystem-runtime-upload-file-reader.ts +++ /dev/null @@ -1,33 +0,0 @@ -type RuntimeUploadFileHandle = { - read( - buffer: Buffer, - offset: number, - length: number, - position: number - ): Promise<{ bytesRead: number }> -} - -export async function readStableRuntimeUploadFile( - fileHandle: RuntimeUploadFileHandle, - expectedBytes: number, - displayPath: string -): Promise { - const buffer = Buffer.allocUnsafe(expectedBytes) - let offset = 0 - while (offset < expectedBytes) { - const { bytesRead } = await fileHandle.read(buffer, offset, expectedBytes - offset, offset) - if (bytesRead === 0) { - throw fileChangedError(displayPath) - } - offset += bytesRead - } - const probe = Buffer.allocUnsafe(1) - if ((await fileHandle.read(probe, 0, 1, offset)).bytesRead !== 0) { - throw fileChangedError(displayPath) - } - return buffer -} - -function fileChangedError(displayPath: string): Error { - return new Error(`File changed during upload staging: '${displayPath}'`) -} diff --git a/src/main/ipc/filesystem-search-git.ts b/src/main/ipc/filesystem-search-git.ts index 3a71c83c1ea..065dbd11dad 100644 --- a/src/main/ipc/filesystem-search-git.ts +++ b/src/main/ipc/filesystem-search-git.ts @@ -8,7 +8,6 @@ import { SEARCH_TIMEOUT_MS } from '../../shared/text-search' import { gitSpawn } from '../git/runner' -import { SearchSubprocessLineAccumulator } from '../../shared/search-subprocess-lines' /** * Fallback text search using git grep. Used when rg is not available. @@ -27,7 +26,7 @@ export function searchWithGitGrep( const gitArgs = buildGitGrepArgs(args.query, args) const matchRegex = buildSubmatchRegex(args.query, args) const acc = createAccumulator() - const stdoutLines = new SearchSubprocessLineAccumulator() + let stdoutBuffer = '' let done = false const child = gitSpawn(gitArgs, { @@ -59,11 +58,12 @@ export function searchWithGitGrep( } } - function handleStdoutData(chunk: Buffer): void { - if (!stdoutLines.push(chunk, processLine)) { - acc.truncated = true - child.kill() - resolveOnce() + function handleStdoutData(chunk: string): void { + stdoutBuffer += chunk + const lines = stdoutBuffer.split('\n') + stdoutBuffer = lines.pop() ?? '' + for (const l of lines) { + processLine(l) } } @@ -76,13 +76,13 @@ export function searchWithGitGrep( } function handleClose(): void { - const trailingLine = stdoutLines.finish() - if (trailingLine !== null) { - processLine(trailingLine) + if (stdoutBuffer) { + processLine(stdoutBuffer) } resolveOnce() } + child.stdout!.setEncoding('utf-8') child.stdout!.on('data', handleStdoutData) child.stderr!.on('data', handleStderrData) child.once('error', handleError) diff --git a/src/main/ipc/filesystem-watcher-admission.test.ts b/src/main/ipc/filesystem-watcher-admission.test.ts deleted file mode 100644 index 0851c0d0886..00000000000 --- a/src/main/ipc/filesystem-watcher-admission.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - FILESYSTEM_WATCHER_MAX_CLAIMS, - FILESYSTEM_WATCHER_MAX_CLAIMS_PER_SENDER, - FILESYSTEM_WATCHER_MAX_PATH_BYTES, - FilesystemWatcherAdmission, - parseFilesystemWatcherIdentity -} from './filesystem-watcher-admission' - -describe('filesystem watcher admission', () => { - it('caps unique claims per sender and recovers after release', () => { - const admission = new FilesystemWatcherAdmission() - const releases = Array.from({ length: FILESYSTEM_WATCHER_MAX_CLAIMS_PER_SENDER }, (_, index) => - admission.claim(1, `local:/repo-${index}`, 1) - ) - - expect(() => admission.claim(1, 'local:/overflow', 1)).toThrow('capacity reached') - expect(admission.evidence().claimCount).toBe(FILESYSTEM_WATCHER_MAX_CLAIMS_PER_SENDER) - - releases[0]?.release() - expect(() => admission.claim(1, 'local:/recovered', 1)).not.toThrow() - }) - - it('caps aggregate claims across senders without double-counting duplicate watches', () => { - const admission = new FilesystemWatcherAdmission() - for (let index = 0; index < FILESYSTEM_WATCHER_MAX_CLAIMS; index += 1) { - admission.claim(index, `local:/repo-${index}`, 1) - } - - expect(admission.claim(0, 'local:/repo-0', 1).added).toBe(false) - expect(() => admission.claim(FILESYSTEM_WATCHER_MAX_CLAIMS, 'local:/overflow', 1)).toThrow( - 'capacity reached' - ) - - admission.releaseSender(0) - expect(() => - admission.claim(FILESYSTEM_WATCHER_MAX_CLAIMS, 'local:/recovered', 1) - ).not.toThrow() - }) - - it('bounds retained path bytes before creating a watcher key', () => { - const exact = 'é'.repeat(FILESYSTEM_WATCHER_MAX_PATH_BYTES / 2) - - expect(parseFilesystemWatcherIdentity({ worktreePath: exact }).retainedBytes).toBe( - FILESYSTEM_WATCHER_MAX_PATH_BYTES - ) - expect(() => parseFilesystemWatcherIdentity({ worktreePath: `${exact}x` })).toThrow( - `exceeds ${FILESYSTEM_WATCHER_MAX_PATH_BYTES} UTF-8 bytes` - ) - }) - - it('does not let a stale release remove a replacement claim', () => { - const admission = new FilesystemWatcherAdmission() - const old = admission.claim(1, 'local:/repo', 1) - admission.clear() - admission.claim(1, 'local:/repo', 1) - - old.release() - - expect(admission.evidence().claimCount).toBe(1) - }) -}) diff --git a/src/main/ipc/filesystem-watcher-admission.ts b/src/main/ipc/filesystem-watcher-admission.ts deleted file mode 100644 index ecece338ea1..00000000000 --- a/src/main/ipc/filesystem-watcher-admission.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { measureUtf8ByteLength } from '../../shared/utf8-byte-limits' - -export const FILESYSTEM_WATCHER_MAX_CLAIMS = 1_024 -export const FILESYSTEM_WATCHER_MAX_CLAIMS_PER_SENDER = 256 -export const FILESYSTEM_WATCHER_MAX_RETAINED_IDENTITY_BYTES = 16 * 1024 * 1024 -export const FILESYSTEM_WATCHER_MAX_PATH_BYTES = 64 * 1024 -export const FILESYSTEM_WATCHER_MAX_CONNECTION_ID_BYTES = 8 * 1024 - -type WatchClaim = { retainedBytes: number } - -export type FilesystemWatcherIdentity = { - worktreePath: string - connectionId?: string - retainedBytes: number -} - -export class FilesystemWatcherAdmission { - private readonly claimsBySender = new Map>() - private claimCount = 0 - private retainedBytes = 0 - - claim( - senderId: number, - key: string, - retainedBytes: number - ): { added: boolean; release: () => void } { - let senderClaims = this.claimsBySender.get(senderId) - const existing = senderClaims?.get(key) - if (existing) { - return { added: false, release: () => undefined } - } - if ( - this.claimCount >= FILESYSTEM_WATCHER_MAX_CLAIMS || - (senderClaims?.size ?? 0) >= FILESYSTEM_WATCHER_MAX_CLAIMS_PER_SENDER || - this.retainedBytes + retainedBytes > FILESYSTEM_WATCHER_MAX_RETAINED_IDENTITY_BYTES - ) { - throw new Error('Filesystem watcher capacity reached; close an existing watch and retry.') - } - senderClaims ??= new Map() - this.claimsBySender.set(senderId, senderClaims) - const claim = { retainedBytes } - senderClaims.set(key, claim) - this.claimCount += 1 - this.retainedBytes += retainedBytes - return { - added: true, - release: () => this.releaseClaim(senderId, key, claim) - } - } - - release(senderId: number, key: string): void { - const claim = this.claimsBySender.get(senderId)?.get(key) - if (claim) { - this.releaseClaim(senderId, key, claim) - } - } - - releaseSender(senderId: number): void { - const senderClaims = this.claimsBySender.get(senderId) - if (!senderClaims) { - return - } - this.claimsBySender.delete(senderId) - for (const claim of senderClaims.values()) { - this.claimCount -= 1 - this.retainedBytes -= claim.retainedBytes - } - } - - clear(): void { - this.claimsBySender.clear() - this.claimCount = 0 - this.retainedBytes = 0 - } - - evidence(): { claimCount: number; retainedBytes: number; senderCount: number } { - return { - claimCount: this.claimCount, - retainedBytes: this.retainedBytes, - senderCount: this.claimsBySender.size - } - } - - private releaseClaim(senderId: number, key: string, expected: WatchClaim): void { - const senderClaims = this.claimsBySender.get(senderId) - if (senderClaims?.get(key) !== expected) { - return - } - senderClaims.delete(key) - this.claimCount -= 1 - this.retainedBytes -= expected.retainedBytes - if (senderClaims.size === 0) { - this.claimsBySender.delete(senderId) - } - } -} - -export function parseFilesystemWatcherIdentity(value: unknown): FilesystemWatcherIdentity { - if (!value || typeof value !== 'object') { - throw new TypeError('Filesystem watcher arguments are required.') - } - const record = value as Record - const worktreePath = record.worktreePath - const connectionId = record.connectionId - if (typeof worktreePath !== 'string' || worktreePath.length === 0) { - throw new TypeError('Filesystem watcher worktreePath must be a non-empty string.') - } - const pathBytes = boundedFieldBytes( - worktreePath, - FILESYSTEM_WATCHER_MAX_PATH_BYTES, - 'worktreePath' - ) - if (connectionId !== undefined && typeof connectionId !== 'string') { - throw new TypeError('Filesystem watcher connectionId must be a string.') - } - const connectionBytes = connectionId - ? boundedFieldBytes(connectionId, FILESYSTEM_WATCHER_MAX_CONNECTION_ID_BYTES, 'connectionId') - : 0 - return { - worktreePath, - ...(connectionId ? { connectionId } : {}), - retainedBytes: pathBytes + connectionBytes - } -} - -function boundedFieldBytes(value: string, limit: number, field: string): number { - const measured = measureUtf8ByteLength(value, { stopAfterBytes: limit }) - if (measured.exceededLimit) { - throw new TypeError(`Filesystem watcher ${field} exceeds ${limit} UTF-8 bytes.`) - } - return measured.byteLength -} diff --git a/src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts b/src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts index 54f712c18ad..9a5c5831492 100644 --- a/src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts +++ b/src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts @@ -142,26 +142,6 @@ describe('local filesystem watcher unsubscribe cleanup', () => { expect(shutdownResolved).toBe(true) }) - it('releases watcher admission after repeated subscription errors', async () => { - vi.mocked(stat).mockResolvedValue({ isDirectory: () => true } as never) - let watcherCallback: (err: Error | null, events: []) => void = () => {} - vi.mocked(subscribeParcelWatcher).mockImplementation(async (_root, callback) => { - watcherCallback = callback as typeof watcherCallback - return { unsubscribe: vi.fn() } as never - }) - const sender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) - - for (let index = 0; index < 257; index += 1) { - await expect( - handlers['fs:watchWorktree']({ sender }, { worktreePath: `/tmp/erroring-repo-${index}` }) - ).resolves.toBeUndefined() - watcherCallback(new Error('root disappeared'), []) - } - - errorSpy.mockRestore() - }) - it('unsubscribes if the sender is destroyed while the local watcher is opening', async () => { vi.mocked(stat).mockResolvedValue({ isDirectory: () => true } as never) const destroyedCallbacks: (() => void)[] = [] diff --git a/src/main/ipc/filesystem-watcher-wsl.test.ts b/src/main/ipc/filesystem-watcher-wsl.test.ts index 955013a86b8..4c8333d508e 100644 --- a/src/main/ipc/filesystem-watcher-wsl.test.ts +++ b/src/main/ipc/filesystem-watcher-wsl.test.ts @@ -100,17 +100,6 @@ describe('createWslWatcher', () => { ) }) - it('parses a snapshot delivered as 100,000 one-byte fragments', async () => { - const { child, promise } = startWatcher() - const frame = Buffer.from(snapshotFrame([['f', '1.0', `/home/me/repo/${'x'.repeat(99_950)}`]])) - - for (let index = 0; index < frame.byteLength; index += 1) { - child.stdout.write(frame.subarray(index, index + 1)) - } - - await expect(promise).resolves.toBeDefined() - }) - it('counts WSL watcher processes against the global physical child cap', async () => { const releases = Array.from({ length: MAX_PHYSICAL_WATCHER_CHILDREN }, () => reserveWatcherChild() diff --git a/src/main/ipc/filesystem-watcher-wsl.ts b/src/main/ipc/filesystem-watcher-wsl.ts index 366f3068405..b73f58d5122 100644 --- a/src/main/ipc/filesystem-watcher-wsl.ts +++ b/src/main/ipc/filesystem-watcher-wsl.ts @@ -6,14 +6,13 @@ * inside the distro so shutdown kills it instead of Orca restarting WSL. */ import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' +import { StringDecoder } from 'node:string_decoder' import type { WebContents } from 'electron' import type { Event as WatcherEvent } from '@parcel/watcher' -import { GrowingByteBuffer } from '../../shared/growing-byte-buffer' import { queueWatcherEvents } from './filesystem-watcher-event-batch' import { parseWslUncPath } from '../../shared/wsl-paths' import { createWslWatcherProcessExit, createWslWatcherStartup } from './wsl-watcher-process-exit' import { reserveWatcherChild, WatcherChildCapacityError } from './parcel-watcher-child-registry' -import { diffWslSnapshots, parseWslSnapshotFrame, type WslSnapshot } from './wsl-watcher-snapshot' export type WatcherSubscription = { unsubscribe(): Promise @@ -42,7 +41,19 @@ export type WslWatcherDeps = { const POLL_INTERVAL_SECONDS = 2 const STARTUP_TIMEOUT_MS = 10_000 const [SNAPSHOT_START, SNAPSHOT_END] = ['\x1e', '\x1f'] -export const MAX_WSL_SNAPSHOT_FRAME_BYTES = 10 * 1024 * 1024 +const MAX_STREAM_BUFFER_CHARS = 10 * 1024 * 1024 + +type WslSnapshotEntry = { + path: string + type: string + mtime: string +} + +type WslSnapshot = Map + +function toWslUncPath(linuxPath: string, distro: string): string { + return `\\\\wsl.localhost\\${distro}${linuxPath.replace(/\//g, '\\')}` +} function quoteSafeFindName(name: string): string { if (!/^[A-Za-z0-9_.-]+$/.test(name)) { @@ -75,6 +86,59 @@ function buildSnapshotScript(ignoreDirs: readonly string[]): string { ].join('\n') } +function parseSnapshotFrame(frame: string, distro: string): WslSnapshot { + const snapshot: WslSnapshot = new Map() + for (const rawEntry of frame.split('\0')) { + if (!rawEntry) { + continue + } + const firstTab = rawEntry.indexOf('\t') + const secondTab = firstTab === -1 ? -1 : rawEntry.indexOf('\t', firstTab + 1) + if (firstTab <= 0 || secondTab <= firstTab + 1) { + continue + } + const linuxPath = rawEntry.slice(secondTab + 1) + if (!linuxPath.startsWith('/')) { + continue + } + const entry: WslSnapshotEntry = { + type: rawEntry.slice(0, firstTab), + mtime: rawEntry.slice(firstTab + 1, secondTab), + path: toWslUncPath(linuxPath, distro) + } + snapshot.set(entry.path, entry) + } + return snapshot +} + +function diffSnapshots(prev: WslSnapshot, next: WslSnapshot): WatcherEvent[] { + const events: WatcherEvent[] = [] + + for (const [entryPath, nextEntry] of next) { + const prevEntry = prev.get(entryPath) + if (!prevEntry) { + events.push({ type: 'create', path: entryPath } as WatcherEvent) + continue + } + if (prevEntry.type !== nextEntry.type) { + events.push({ type: 'delete', path: entryPath } as WatcherEvent) + events.push({ type: 'create', path: entryPath } as WatcherEvent) + continue + } + if (prevEntry.mtime !== nextEntry.mtime) { + events.push({ type: 'update', path: entryPath } as WatcherEvent) + } + } + + for (const entryPath of prev.keys()) { + if (!next.has(entryPath)) { + events.push({ type: 'delete', path: entryPath } as WatcherEvent) + } + } + + return events +} + function markOverflowWithoutUncStat(root: WatchedRoot): void { if (root.batch.timer) { clearTimeout(root.batch.timer) @@ -112,21 +176,13 @@ export async function createWslWatcher( let disposed = false let prevSnapshot: WslSnapshot | null = null let stopped = false - const streamBuffer = new GrowingByteBuffer() - const stderrTail = new GrowingByteBuffer() + let streamBuffer = '' + const stdoutDecoder = new StringDecoder('utf8') + const stderrDecoder = new StringDecoder('utf8') + let stderrTail = '' const startup = createWslWatcherStartup() - function reportSnapshotOverflow(message: string): void { - if (!startup.settled) { - startup.settle(new Error(message)) - return - } - prevSnapshot = new Map() - markOverflowWithoutUncStat(root) - deps.scheduleBatchFlush(rootKey, root) - } - function signalWatcherStopped(): void { if (stopped) { return @@ -141,17 +197,13 @@ export async function createWslWatcher( } function ingestFrame(frame: string): void { - const nextSnapshot = parseWslSnapshotFrame(frame, distro) - if (!nextSnapshot) { - reportSnapshotOverflow('WSL watcher snapshot exceeded its retained entry limit') - return - } + const nextSnapshot = parseSnapshotFrame(frame, distro) if (!prevSnapshot) { prevSnapshot = nextSnapshot startup.settle() return } - const events = diffWslSnapshots(prevSnapshot, nextSnapshot) + const events = diffSnapshots(prevSnapshot, nextSnapshot) prevSnapshot = nextSnapshot if (events.length > 0) { @@ -162,30 +214,25 @@ export async function createWslWatcher( function drainFrames(): void { while (true) { - const start = streamBuffer.indexOfByte(SNAPSHOT_START.charCodeAt(0)) + const start = streamBuffer.indexOf(SNAPSHOT_START) if (start === -1) { - streamBuffer.retainSuffix(1) + streamBuffer = streamBuffer.slice(-1) return } if (start > 0) { - streamBuffer.discardPrefix(start) + streamBuffer = streamBuffer.slice(start) } - const end = streamBuffer.indexOfByte(SNAPSHOT_END.charCodeAt(0), 1) + const end = streamBuffer.indexOf(SNAPSHOT_END, 1) if (end === -1) { - if (streamBuffer.byteLength > MAX_WSL_SNAPSHOT_FRAME_BYTES) { - streamBuffer.clear() - reportSnapshotOverflow('WSL watcher snapshot exceeded its frame byte limit') + if (streamBuffer.length > MAX_STREAM_BUFFER_CHARS) { + streamBuffer = '' + markOverflowWithoutUncStat(root) + deps.scheduleBatchFlush(rootKey, root) } return } - if (end - 1 > MAX_WSL_SNAPSHOT_FRAME_BYTES) { - streamBuffer.discardPrefix(end + 1) - reportSnapshotOverflow('WSL watcher snapshot exceeded its frame byte limit') - continue - } - const frameWithStart = streamBuffer.takePrefixString(end) - streamBuffer.discardPrefix(1) - const frame = frameWithStart.slice(1) + const frame = streamBuffer.slice(1, end) + streamBuffer = streamBuffer.slice(end + 1) ingestFrame(frame) } } @@ -232,18 +279,12 @@ export async function createWslWatcher( if (disposed) { return } - streamBuffer.append(chunk) + streamBuffer += stdoutDecoder.write(chunk) drainFrames() }) child.stderr.on('data', (chunk: Buffer) => { - if (chunk.byteLength >= 4096) { - stderrTail.clear() - stderrTail.append(chunk.subarray(chunk.byteLength - 4096)) - return - } - stderrTail.append(chunk) - stderrTail.retainSuffix(4096) + stderrTail = (stderrTail + stderrDecoder.write(chunk)).slice(-4096) }) child.stdout.on('error', (error) => { @@ -278,8 +319,7 @@ export async function createWslWatcher( child.once('close', (code, signal) => { processExit.markPhysicalExit() if (!startup.settled) { - const stderr = stderrTail.toString() - const suffix = stderr.trim() ? `: ${stderr.trim()}` : '' + const suffix = stderrTail.trim() ? `: ${stderrTail.trim()}` : '' startup.settle( new Error(`WSL watcher exited before first snapshot (${code ?? signal})${suffix}`) ) diff --git a/src/main/ipc/filesystem-watcher.test.ts b/src/main/ipc/filesystem-watcher.test.ts index d13221acf41..8d658b25f12 100644 --- a/src/main/ipc/filesystem-watcher.test.ts +++ b/src/main/ipc/filesystem-watcher.test.ts @@ -30,7 +30,6 @@ vi.mock('../providers/ssh-filesystem-dispatch', () => ({ import { closeAllWatchers, closeRemoteWatcherForWorktreePath, - LOCAL_WATCHER_DIRECTORY_STAT_CONCURRENCY, registerFilesystemWatcherHandlers, restoreRemoteWatcherAfterFailedRemoval } from './filesystem-watcher' @@ -44,7 +43,6 @@ import { WatcherChildCapacityError } from './parcel-watcher-child-registry' import { acquireWatcherRemovalGate } from './watcher-removal-gate' -import { FILESYSTEM_WATCHER_MAX_CLAIMS_PER_SENDER } from './filesystem-watcher-admission' type HandlerMap = Record Promise | unknown> @@ -96,99 +94,6 @@ describe('registerFilesystemWatcherHandlers', () => { await closeAllWatchers() }) - it('rejects pending watcher churn at the sender cap and recovers after unwatch', async () => { - vi.useFakeTimers() - getSshFilesystemProviderMock.mockReturnValue(undefined) - const sender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - - for (let index = 0; index < FILESYSTEM_WATCHER_MAX_CLAIMS_PER_SENDER; index += 1) { - await handlers['fs:watchWorktree']( - { sender }, - { worktreePath: `/missing/repo-${index}`, connectionId: 'conn-1' } - ) - } - await expect( - handlers['fs:watchWorktree']( - { sender }, - { worktreePath: '/missing/overflow', connectionId: 'conn-1' } - ) - ).rejects.toThrow('Filesystem watcher capacity reached') - - handlers['fs:unwatchWorktree']( - { sender }, - { worktreePath: '/missing/repo-0', connectionId: 'conn-1' } - ) - await expect( - handlers['fs:watchWorktree']( - { sender }, - { worktreePath: '/missing/recovered', connectionId: 'conn-1' } - ) - ).resolves.toBeUndefined() - await closeAllWatchers() - warn.mockRestore() - vi.useRealTimers() - }) - - it.each([ - ['at the limit', LOCAL_WATCHER_DIRECTORY_STAT_CONCURRENCY], - ['above the limit', LOCAL_WATCHER_DIRECTORY_STAT_CONCURRENCY + 1] - ])('bounds event directory stats %s', async (_, count) => { - let active = 0 - let peak = 0 - let started = 0 - const releases: (() => void)[] = [] - vi.mocked(stat).mockImplementation((filePath) => { - if (filePath === '/repo') { - return Promise.resolve({ isDirectory: () => true } as never) - } - started++ - active++ - peak = Math.max(peak, active) - return new Promise((resolve) => { - releases.push(() => { - active-- - resolve({ isDirectory: () => false } as never) - }) - }) - }) - vi.mocked(subscribeParcelWatcher).mockResolvedValue({ unsubscribe: vi.fn() } as never) - const sender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } - await handlers['fs:watchWorktree']({ sender }, { worktreePath: '/repo' }) - const onEvents = vi.mocked(subscribeParcelWatcher).mock.calls[0][1] as ( - error: Error | null, - events: { type: 'create'; path: string }[] - ) => void - - vi.useFakeTimers() - onEvents( - null, - Array.from({ length: count }, (_, index) => ({ - type: 'create', - path: `/repo/file-${index}` - })) - ) - await vi.advanceTimersByTimeAsync(150) - expect(started).toBe(Math.min(count, LOCAL_WATCHER_DIRECTORY_STAT_CONCURRENCY)) - if (count > LOCAL_WATCHER_DIRECTORY_STAT_CONCURRENCY) { - releases.shift()?.() - for (let turn = 0; turn < 5 && started < count; turn++) { - await Promise.resolve() - } - expect(started).toBe(count) - } - releases.splice(0).forEach((release) => release()) - - expect(peak).toBe(Math.min(count, LOCAL_WATCHER_DIRECTORY_STAT_CONCURRENCY)) - await vi.waitFor(() => - expect(sender.send).toHaveBeenCalledWith( - 'fs:changed', - expect.objectContaining({ events: expect.arrayContaining([expect.any(Object)]) }) - ) - ) - vi.useRealTimers() - }) - it('automatically retries a WSL watcher when child capacity becomes available', async () => { Object.defineProperty(process, 'platform', { configurable: true, diff --git a/src/main/ipc/filesystem-watcher.ts b/src/main/ipc/filesystem-watcher.ts index 6e4d0d5e783..2e2a27cb3f6 100644 --- a/src/main/ipc/filesystem-watcher.ts +++ b/src/main/ipc/filesystem-watcher.ts @@ -8,7 +8,6 @@ import { isWindowsAbsolutePathLike, normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' -import { mapWithConcurrency } from '../../shared/map-with-concurrency' import { isWslPath } from '../wsl' import { createWslWatcher } from './filesystem-watcher-wsl' import type { WatchedRoot } from './filesystem-watcher-wsl' @@ -21,11 +20,6 @@ import { WatcherChildCapacityError } from './parcel-watcher-child-registry' import { beginWatcherInstall, isWatcherRemovalInProgressError } from './watcher-removal-gate' -import { - FilesystemWatcherAdmission, - parseFilesystemWatcherIdentity, - type FilesystemWatcherIdentity -} from './filesystem-watcher-admission' // Why: suppress high-churn dirs at the watcher level (separate from the File Explorer display filter, which only hides rows). import { WATCHER_IGNORE_DIRS, buildParcelWatcherIgnoreOptions } from './filesystem-watcher-ignore' @@ -33,8 +27,6 @@ import { WATCHER_IGNORE_DIRS, buildParcelWatcherIgnoreOptions } from './filesyst const DEBOUNCE_TRAILING_MS = 150 const DEBOUNCE_MAX_WAIT_MS = 500 -export const LOCAL_WATCHER_DIRECTORY_STAT_CONCURRENCY = 8 -const WATCHER_LISTENER_INSTALL_CONCURRENCY = 8 // ── Per-root watcher state ─────────────────────────────────────────── // WatchedRoot/WatcherSubscription live in filesystem-watcher-wsl.ts so native and WSL watchers share one shape. @@ -42,7 +34,6 @@ const WATCHER_LISTENER_INSTALL_CONCURRENCY = 8 // ── Module state ───────────────────────────────────────────────────── const watchedRoots = new Map() -const watcherAdmission = new FilesystemWatcherAdmission() // Why: cache roots that failed watcher creation (e.g. WSL UNC paths) so we don't retry every worktree switch and spam the console with errors. const UNWATCHABLE_ROOT_CACHE_MAX = 256 @@ -160,10 +151,8 @@ function scheduleLocalCapacityRetry( return } pendingLocalCapacityRetries.delete(rootKey) - await mapWithConcurrency( - [...retry.listeners.values()], - WATCHER_LISTENER_INSTALL_CONCURRENCY, - async (listener) => { + await Promise.all( + [...retry.listeners.values()].map(async (listener) => { if (listener.isDestroyed()) { return } @@ -172,7 +161,7 @@ function scheduleLocalCapacityRetry( console.error(`[filesystem-watcher] capacity retry failed for ${rootKey}:`, error) } }) - } + }) ) }) retry = { listeners: new Map(), cancelWait } @@ -210,26 +199,6 @@ function localWatcherRoot(rootPath: string): { key: string; path: string } { } } -function watcherAdmissionKey(identity: FilesystemWatcherIdentity): string { - return identity.connectionId - ? `remote:${remoteWatcherKey(identity.connectionId, identity.worktreePath)}` - : `local:${localWatcherRoot(identity.worktreePath).key}` -} - -function hasRetainedLocalWatcherListener(rootKey: string, senderId: number): boolean { - return ( - watchedRoots.get(rootKey)?.listeners.has(senderId) === true || - inFlightLocalInstalls.get(rootKey)?.listeners.has(senderId) === true || - pendingLocalCapacityRetries.get(rootKey)?.listeners.has(senderId) === true - ) -} - -function releaseLocalWatcherAdmissions(rootKey: string, listeners: Map): void { - for (const senderId of listeners.keys()) { - watcherAdmission.release(senderId, `local:${rootKey}`) - } -} - function normalizeEventPath(eventPath: string): string { let resolved = path.resolve(eventPath) if (/^[a-zA-Z]:/.test(resolved)) { @@ -334,10 +303,8 @@ async function flushBatch(rootKey: string, root: WatchedRoot): Promise { const coalesced = coalesceEvents(rawEvents) - const events = await mapWithConcurrency( - coalesced, - LOCAL_WATCHER_DIRECTORY_STAT_CONCURRENCY, - async (evt): Promise => { + const events: FsChangeEvent[] = await Promise.all( + coalesced.map(async (evt) => { // Why: a deleted path can't be stat'd; leave isDirectory undefined and let the renderer infer from dirCache. const isDirectory = evt.type === 'delete' ? undefined : await tryStatIsDirectory(evt.path) @@ -346,7 +313,7 @@ async function flushBatch(rootKey: string, root: WatchedRoot): Promise { absolutePath: evt.path, isDirectory } - } + }) ) const payload: FsChangedPayload = { @@ -430,7 +397,6 @@ async function createWatcher( retainLocalWatcherPhysicalFailure(rootKey, err) void trackLocalUnsubscribe(rootKey, root) } - releaseLocalWatcherAdmissions(rootKey, root.listeners) errorCleanedUp = true watchedRoots.delete(rootKey) return @@ -466,7 +432,6 @@ async function createWatcher( // ── Subscribe / Unsubscribe ────────────────────────────────────────── function cleanupLocalWatchersForSender(senderId: number): void { - watcherAdmission.releaseSender(senderId) for (const [rootKey, suspended] of suspendedLocalWatcherListeners) { suspended.listeners.delete(senderId) if (suspended.listeners.size === 0) { @@ -692,13 +657,11 @@ async function doInstallLocalWatcher( if (!s.isDirectory()) { console.warn(`[filesystem-watcher] not a directory: ${rootKey}`) rememberUnwatchableRoot(rootKey) - finishInFlightLocalInstall(rootKey, cancelToken) return 'unavailable' } } catch { console.warn(`[filesystem-watcher] cannot stat root: ${rootKey}`) rememberUnwatchableRoot(rootKey) - finishInFlightLocalInstall(rootKey, cancelToken) return 'unavailable' } @@ -733,7 +696,9 @@ async function doInstallLocalWatcher( rememberUnwatchableRoot(rootKey) return 'unavailable' } finally { - finishInFlightLocalInstall(rootKey, cancelToken) + if (inFlightLocalInstalls.get(rootKey) === cancelToken) { + inFlightLocalInstalls.delete(rootKey) + } } const liveListeners = new Map( @@ -755,12 +720,6 @@ async function doInstallLocalWatcher( return 'installed' } -function finishInFlightLocalInstall(rootKey: string, cancelToken: LocalWatcherInstallToken): void { - if (inFlightLocalInstalls.get(rootKey) === cancelToken) { - inFlightLocalInstalls.delete(rootKey) - } -} - function unsubscribe(worktreePath: string, senderId: number): void { const { key: rootKey } = localWatcherRoot(worktreePath) const suspended = suspendedLocalWatcherListeners.get(rootKey) @@ -906,12 +865,7 @@ export async function restoreLocalWatcherAfterFailedRemoval(worktreePath: string } export function forgetLocalWatcherRemovalSnapshot(worktreePath: string): void { - const rootKey = localWatcherRoot(worktreePath).key - const suspended = suspendedLocalWatcherListeners.get(rootKey) - for (const senderId of suspended?.listeners.keys() ?? []) { - watcherAdmission.release(senderId, `local:${rootKey}`) - } - suspendedLocalWatcherListeners.delete(rootKey) + suspendedLocalWatcherListeners.delete(localWatcherRoot(worktreePath).key) } // Remote watcher state @@ -1025,12 +979,7 @@ export function forgetRemoteWatcherRemovalSnapshot( connectionId: string, worktreePath: string ): void { - const key = remoteWatcherKey(connectionId, worktreePath) - const suspended = suspendedRemoteWatcherListeners.get(key) - for (const senderId of suspended?.listeners.keys() ?? []) { - watcherAdmission.release(senderId, `remote:${key}`) - } - suspendedRemoteWatcherListeners.delete(key) + suspendedRemoteWatcherListeners.delete(remoteWatcherKey(connectionId, worktreePath)) } function addInFlightRemoteInstallListener( @@ -1343,8 +1292,8 @@ function scheduleRemoteWatcherRetry( const listeners = Array.from(retry.listeners.values()).filter( (listener) => !listener.isDestroyed() ) - void mapWithConcurrency(listeners, WATCHER_LISTENER_INSTALL_CONCURRENCY, (listener) => - installRemoteWatcher(listener, connectionId, worktreePath) + void Promise.all( + listeners.map((listener) => installRemoteWatcher(listener, connectionId, worktreePath)) ) .then((results) => { // Why: don't re-arm on 'cancelled' (renderer stopped watching) — it would fire a stale overflow when the 60s window expires. @@ -1369,14 +1318,9 @@ function scheduleRemoteWatcherRetry( // ── Public API ─────────────────────────────────────────────────────── export function registerFilesystemWatcherHandlers(): void { - ipcMain.handle('fs:watchWorktree', async (event, rawArgs: unknown): Promise => { - const args = parseFilesystemWatcherIdentity(rawArgs) - const admission = watcherAdmission.claim( - event.sender.id, - watcherAdmissionKey(args), - args.retainedBytes - ) - try { + ipcMain.handle( + 'fs:watchWorktree', + async (event, args: { worktreePath: string; connectionId?: string }): Promise => { if (args.connectionId) { // Why: a real new watch reopens the subsystem after closeAllWatchers latched it shut (also resets tests between cases). remoteWatchersClosed = false @@ -1401,51 +1345,41 @@ export function registerFilesystemWatcherHandlers(): void { // Why: reopen the local subsystem for tests and post-shutdown reattachment; stale callers keep the prior generation. localWatchersClosed = false await subscribe(args.worktreePath, event.sender) - if (admission.added) { - const rootKey = localWatcherRoot(args.worktreePath).key - if (!hasRetainedLocalWatcherListener(rootKey, event.sender.id)) { - admission.release() - } - } - } catch (error) { - if (admission.added) { - admission.release() - } - throw error } - }) + ) - ipcMain.handle('fs:unwatchWorktree', (_event, rawArgs: unknown): void => { - const args = parseFilesystemWatcherIdentity(rawArgs) - watcherAdmission.release(_event.sender.id, watcherAdmissionKey(args)) - if (args.connectionId) { - const key = remoteWatcherKey(args.connectionId, args.worktreePath) - const suspended = suspendedRemoteWatcherListeners.get(key) - suspended?.listeners.delete(_event.sender.id) - if (suspended?.listeners.size === 0) { - suspendedRemoteWatcherListeners.delete(key) + ipcMain.handle( + 'fs:unwatchWorktree', + (_event, args: { worktreePath: string; connectionId?: string }): void => { + if (args.connectionId) { + const key = remoteWatcherKey(args.connectionId, args.worktreePath) + const suspended = suspendedRemoteWatcherListeners.get(key) + suspended?.listeners.delete(_event.sender.id) + if (suspended?.listeners.size === 0) { + suspendedRemoteWatcherListeners.delete(key) + } + const retry = pendingRemoteWatcherRetryListeners.get(key) + retry?.listeners.delete(_event.sender.id) + const retryTimer = pendingRemoteWatcherRetries.get(key) + if (retryTimer && retry?.listeners.size === 0) { + clearTimeout(retryTimer) + pendingRemoteWatcherRetries.delete(key) + pendingRemoteWatcherRetryListeners.delete(key) + } + // Why: a retry-tick provider.watch() may still be in flight; mark cancelled so its resolved unwatch handle is discarded. + const inFlight = inFlightRemoteInstalls.get(key) + if (inFlight) { + inFlight.listeners.delete(_event.sender.id) + cancelInFlightRemoteInstallIfUnowned(inFlight) + } + loggedUnavailableRemoteWatchers.delete(key) + releaseRemoteWatchListener(key, _event?.sender?.id ?? 0) + return } - const retry = pendingRemoteWatcherRetryListeners.get(key) - retry?.listeners.delete(_event.sender.id) - const retryTimer = pendingRemoteWatcherRetries.get(key) - if (retryTimer && retry?.listeners.size === 0) { - clearTimeout(retryTimer) - pendingRemoteWatcherRetries.delete(key) - pendingRemoteWatcherRetryListeners.delete(key) - } - // Why: a retry-tick provider.watch() may still be in flight; mark cancelled so its resolved unwatch handle is discarded. - const inFlight = inFlightRemoteInstalls.get(key) - if (inFlight) { - inFlight.listeners.delete(_event.sender.id) - cancelInFlightRemoteInstallIfUnowned(inFlight) - } - loggedUnavailableRemoteWatchers.delete(key) - releaseRemoteWatchListener(key, _event?.sender?.id ?? 0) - return + const senderId = _event.sender.id + unsubscribe(args.worktreePath, senderId) } - const senderId = _event.sender.id - unsubscribe(args.worktreePath, senderId) - }) + ) } function remoteWatcherKey(connectionId: string, worktreePath: string): string { @@ -1454,7 +1388,6 @@ function remoteWatcherKey(connectionId: string, worktreePath: string): string { /** Tear down all watchers on app shutdown. */ export async function closeAllWatchers(): Promise { - watcherAdmission.clear() senderCleanupRegistered.clear() unwatchableRoots.clear() suspendedLocalWatcherListeners.clear() diff --git a/src/main/ipc/filesystem.test.ts b/src/main/ipc/filesystem.test.ts index 92490ff1a3a..2f76c80791b 100644 --- a/src/main/ipc/filesystem.test.ts +++ b/src/main/ipc/filesystem.test.ts @@ -11,7 +11,7 @@ const { fromWebContentsMock, trashItemMock, readdirMock, - opendirMock, + readFileMock, writeFileMock, statMock, openMock, @@ -54,7 +54,7 @@ const { fromWebContentsMock: vi.fn(), trashItemMock: vi.fn(), readdirMock: vi.fn(), - opendirMock: vi.fn(), + readFileMock: vi.fn(), writeFileMock: vi.fn(), statMock: vi.fn(), openMock: vi.fn(), @@ -110,7 +110,7 @@ vi.mock('electron', () => ({ vi.mock('fs/promises', () => ({ readdir: readdirMock, - opendir: opendirMock, + readFile: readFileMock, writeFile: writeFileMock, stat: statMock, open: openMock, @@ -219,33 +219,6 @@ function dirEntry({ name, directory, file, symlink }: MockDirEntry): { } } -function mockReadableFile( - content: Buffer, - stats: { dev?: number; ino?: number; birthtimeMs?: number } = {} -) { - const handle = { - stat: vi.fn().mockResolvedValue({ size: content.byteLength, ...stats }), - read: vi.fn(async (target: Buffer, offset: number, length: number, position: number) => { - const bytesRead = Math.min(length, Math.max(0, content.byteLength - position)) - content.copy(target, offset, position, position + bytesRead) - return { bytesRead, buffer: target } - }), - close: vi.fn().mockResolvedValue(undefined) - } - openMock.mockResolvedValue(handle) - return handle -} - -function pngHeader(width = 1, height = 1): Buffer { - const bytes = Buffer.alloc(24) - Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(bytes) - bytes.writeUInt32BE(13, 8) - bytes.write('IHDR', 12, 'ascii') - bytes.writeUInt32BE(width, 16) - bytes.writeUInt32BE(height, 20) - return bytes -} - async function withPlatform(platform: NodeJS.Platform, run: () => Promise): Promise { const original = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: platform }) @@ -289,7 +262,7 @@ describe('registerFilesystemHandlers', () => { fromWebContentsMock, trashItemMock, readdirMock, - opendirMock, + readFileMock, writeFileMock, statMock, openMock, @@ -367,14 +340,6 @@ describe('registerFilesystemHandlers', () => { close: vi.fn() }) lstatMock.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })) - opendirMock.mockImplementation(async (dirPath: string) => { - const entries = await readdirMock(dirPath, { withFileTypes: true }) - return { - async *[Symbol.asyncIterator]() { - yield* entries - } - } - }) }) it('returns an actionable reconnect error when the SSH filesystem provider is unavailable', async () => { @@ -1021,7 +986,7 @@ describe('registerFilesystemHandlers', () => { 'Access denied: path resolves outside allowed directories' ) - expect(openMock).not.toHaveBeenCalled() + expect(readFileMock).not.toHaveBeenCalled() }) it('allows readDir when a registered worktree resolves to a macOS canonical alias', async () => { @@ -1140,7 +1105,7 @@ describe('registerFilesystemHandlers', () => { 'Access denied: path resolves outside allowed directories' ) - expect(openMock).not.toHaveBeenCalled() + expect(readFileMock).not.toHaveBeenCalled() }) it('does not enumerate worktrees when filesystem handlers register', () => { @@ -1183,56 +1148,32 @@ describe('registerFilesystemHandlers', () => { ) it.each([ - { - ext: 'png', - mime: 'image/png', - data: Array.from(pngHeader()), - imageDimensions: { width: 1, height: 1 } - }, + { ext: 'png', mime: 'image/png', data: [0x89, 0x50, 0x4e, 0x47, 0x00] }, { ext: 'pdf', mime: 'application/pdf', data: [0x25, 0x50, 0x44, 0x46, 0x00] }, { ext: 'svg', mime: 'image/svg+xml', data: Array.from(Buffer.from('')) } - ])( - 'returns base64 content for supported $ext binaries', - async ({ ext, mime, data, imageDimensions }) => { - const buf = Buffer.from(data) - statMock.mockResolvedValue({ size: buf.length, isDirectory: () => false, mtimeMs: 123 }) - mockReadableFile(buf) - registerFilesystemHandlers(store as never) - await expect( - handlers.get('fs:readFile')!(null, { - filePath: path.resolve(`/workspace/repo/file.${ext}`) - }) - ).resolves.toEqual({ - content: buf.toString('base64'), - isBinary: true, - isImage: true, - mimeType: mime, - ...(imageDimensions ? { imageDimensions } : {}) - }) - } - ) - - it('rejects a raster dimension bomb before returning base64 to the renderer', async () => { - const buf = pngHeader(32_769, 1) + ])('returns base64 content for supported $ext binaries', async ({ ext, mime, data }) => { + const buf = Buffer.from(data) statMock.mockResolvedValue({ size: buf.length, isDirectory: () => false, mtimeMs: 123 }) - mockReadableFile(buf) + readFileMock.mockResolvedValue(buf) registerFilesystemHandlers(store as never) - await expect( - handlers.get('fs:readFile')!(null, { - filePath: path.resolve('/workspace/repo/bomb.png') - }) - ).rejects.toThrow('Image dimensions exceed the preview safety limit') + handlers.get('fs:readFile')!(null, { filePath: path.resolve(`/workspace/repo/file.${ext}`) }) + ).resolves.toEqual({ + content: buf.toString('base64'), + isBinary: true, + isImage: true, + mimeType: mime + }) }) it('opens text files larger than the old 5MB guard', async () => { const content = 'a'.repeat(6 * 1024 * 1024) statMock.mockResolvedValue({ size: content.length, isDirectory: () => false, mtimeMs: 123 }) - mockReadableFile(Buffer.from(content)) + readFileMock.mockResolvedValue(Buffer.from(content)) registerFilesystemHandlers(store as never) @@ -1246,7 +1187,17 @@ describe('registerFilesystemHandlers', () => { it('returns stable byte metadata only for opted-in local log snapshots', async () => { const content = Buffer.from('first\npartial') - const handle = mockReadableFile(content, { dev: 1, ino: 2, birthtimeMs: 3 }) + const close = vi.fn() + openMock.mockResolvedValue({ + stat: vi.fn().mockResolvedValue({ + size: content.byteLength, + dev: 1, + ino: 2, + birthtimeMs: 3 + }), + readFile: vi.fn().mockResolvedValue(content), + close + }) registerFilesystemHandlers(store as never) await expect( @@ -1259,7 +1210,8 @@ describe('registerFilesystemHandlers', () => { isBinary: false, fileIdentity: '1:2:3' }) - expect(handle.close).toHaveBeenCalledTimes(1) + expect(close).toHaveBeenCalledTimes(1) + expect(readFileMock).not.toHaveBeenCalled() }) it('rejects text files beyond the editor read budget', async () => { @@ -1271,7 +1223,7 @@ describe('registerFilesystemHandlers', () => { handlers.get('fs:readFile')!(null, { filePath: path.resolve('/workspace/repo/huge.json') }) ).rejects.toThrow('exceeds 50MB limit') - expect(openMock).not.toHaveBeenCalled() + expect(readFileMock).not.toHaveBeenCalled() }) it('probes large unknown binaries without reading the full file', async () => { @@ -1293,7 +1245,7 @@ describe('registerFilesystemHandlers', () => { isBinary: true }) - expect(openMock).toHaveBeenCalledTimes(1) + expect(readFileMock).not.toHaveBeenCalled() }) it('moves files to trash', async () => { @@ -1364,7 +1316,7 @@ describe('registerFilesystemHandlers', () => { it('keeps non-image binaries hidden from the editor payload', async () => { statMock.mockResolvedValue({ size: 4, isDirectory: () => false, mtimeMs: 123 }) - mockReadableFile(Buffer.from([0x00, 0x01, 0x02])) + readFileMock.mockResolvedValue(Buffer.from([0x00, 0x01, 0x02])) registerFilesystemHandlers(store as never) @@ -1825,7 +1777,7 @@ describe('registerFilesystemHandlers', () => { it('lists remote markdown documents through the SSH filesystem provider', async () => { const provider = { - listMarkdownDocuments: vi + listFiles: vi .fn() .mockResolvedValue(['README.md', 'docs/guide.mdx', '../outside.md', 'src/app.ts']) } @@ -1852,7 +1804,6 @@ describe('registerFilesystemHandlers', () => { name: 'README' } ]) - expect(provider.listMarkdownDocuments).toHaveBeenCalledWith('/home/user/project') }) it('routes branch compare queries through the git compare helper', async () => { diff --git a/src/main/ipc/filesystem.ts b/src/main/ipc/filesystem.ts index 0d74fa796f2..414b67516f1 100644 --- a/src/main/ipc/filesystem.ts +++ b/src/main/ipc/filesystem.ts @@ -1,6 +1,6 @@ /* eslint-disable max-lines */ import { BrowserWindow, dialog, ipcMain, shell } from 'electron' -import { writeFile, stat, lstat, open, rename, rm } from 'node:fs/promises' +import { readdir, readFile, writeFile, stat, lstat, open, rename, rm } from 'node:fs/promises' import type { FileHandle } from 'node:fs/promises' import { randomUUID } from 'node:crypto' import { dirname, extname, join, resolve } from 'node:path' @@ -39,7 +39,6 @@ import { ingestRgJsonLine, SEARCH_TIMEOUT_MS } from '../../shared/text-search' -import { SearchSubprocessLineAccumulator } from '../../shared/search-subprocess-lines' import { getStatus, getSubmoduleStatus, @@ -120,7 +119,6 @@ import { import { listRepoWorktrees } from '../repo-worktrees' import { recordCrashBreadcrumb } from '../crash-reporting/crash-breadcrumb-store' import { buildReadDirErrorBreadcrumb, type ReadDirThrowSite } from './readdir-error-diagnostics' -import { readLocalFilesystemDirectory } from './filesystem-directory-reader' import { splitWorktreeId } from '../../shared/worktree-id' import { getRuntimePathBasename } from '../../shared/cross-platform-path' import type { LocalProjectWorktreeGitOptions } from '../project-runtime-git-options' @@ -129,8 +127,6 @@ import { localLogFileIdentity } from '../ai-vault/local-log-tail-reader' import { sanitizeLocalDownloadFilename } from '../local-download-filename' import { registerFilesystemDownloadFolderHandlers } from './filesystem-download-folder' import { createSenderScopedRequestCancellations } from './sender-scoped-request-cancellation' -import { readNodeFileWithinLimit } from '../../shared/node-bounded-file-reader' -import { assertRasterImagePreviewWithinLimits } from '../../shared/raster-image-preview-limits' // Why: Monaco degrades features on large files like VS Code, so a 5MB block would needlessly lock out ordinary JSON/log files. const MAX_TEXT_FILE_SIZE = 50 * 1024 * 1024 // 50MB @@ -154,14 +150,30 @@ async function readLocalLogSnapshot(filePath: string): Promise<{ isBinary: boolean fileIdentity?: string }> { - const { buffer, stats } = await readNodeFileWithinLimit(filePath, MAX_TEXT_FILE_SIZE) - if (isBinaryBuffer(buffer)) { - return { content: '', isBinary: true } - } - return { - content: buffer.toString('utf8'), - isBinary: false, - fileIdentity: localLogFileIdentity(stats) + const handle = await open(filePath, 'r') + try { + const stats = await handle.stat() + if (stats.size > MAX_TEXT_FILE_SIZE) { + throw new Error( + `File too large: ${(stats.size / 1024 / 1024).toFixed(1)}MB exceeds ${MAX_TEXT_FILE_SIZE / 1024 / 1024}MB limit` + ) + } + const buffer = await handle.readFile() + if (buffer.byteLength > MAX_TEXT_FILE_SIZE) { + throw new Error( + `File too large: ${(buffer.byteLength / 1024 / 1024).toFixed(1)}MB exceeds ${MAX_TEXT_FILE_SIZE / 1024 / 1024}MB limit` + ) + } + if (isBinaryBuffer(buffer)) { + return { content: '', isBinary: true } + } + return { + content: buffer.toString('utf8'), + isBinary: false, + fileIdentity: localLogFileIdentity(stats) + } + } finally { + await handle.close() } } @@ -434,6 +446,23 @@ async function isBinaryFilePrefix(filePath: string): Promise { } } +async function isDirectoryEntry( + dirPath: string, + entry: { name: string; isDirectory(): boolean; isSymbolicLink(): boolean }, + _resolveEntryPath: (entryPath: string) => Promise +): Promise { + // Why: following a symlink in readDir can touch macOS TCC-protected containers; treat links as file-like until explicitly opened. + void _resolveEntryPath + if (entry.isSymbolicLink()) { + void dirPath + return false + } + if (entry.isDirectory()) { + return true + } + return false +} + export function registerFilesystemHandlers( store: Store, commitMessageAgentEnv?: CommitMessageAgentEnvironmentResolvers @@ -481,7 +510,22 @@ export function registerFilesystemHandlers( throwSite = 'authorize' const dirPath = await resolveAuthorizedPath(args.dirPath, store) throwSite = 'readdir' - return await readLocalFilesystemDirectory(dirPath) + const entries = await readdir(dirPath, { withFileTypes: true }) + const mapped = await Promise.all( + entries.map(async (entry) => ({ + name: entry.name, + isDirectory: await isDirectoryEntry(dirPath, entry, (entryPath) => + resolveAuthorizedPath(entryPath, store) + ), + isSymlink: entry.isSymbolicLink() + })) + ) + return mapped.sort((a, b) => { + if (a.isDirectory !== b.isDirectory) { + return a.isDirectory ? -1 : 1 + } + return a.name.localeCompare(b.name) + }) } catch (error: unknown) { recordCrashBreadcrumb( 'fs_readdir_error', @@ -507,7 +551,6 @@ export function registerFilesystemHandlers( isBinary: boolean isImage?: boolean mimeType?: string - imageDimensions?: { width: number; height: number } fileIdentity?: string }> => { if (args.connectionId) { @@ -528,15 +571,13 @@ export function registerFilesystemHandlers( } if (mimeType) { - const { buffer } = await readNodeFileWithinLimit(filePath, sizeLimit) - const imageDimensions = assertRasterImagePreviewWithinLimits(buffer, mimeType) + const buffer = await readFile(filePath) return { content: buffer.toString('base64'), isBinary: true, // Why: the renderer keys previewable-binary rendering off `isImage`, so set it for PDFs too to stay compatible. isImage: true, - mimeType, - ...(imageDimensions ? { imageDimensions } : {}) + mimeType } } @@ -545,7 +586,7 @@ export function registerFilesystemHandlers( return { content: '', isBinary: true } } - const { buffer } = await readNodeFileWithinLimit(filePath, sizeLimit) + const buffer = await readFile(filePath) if (isBinaryBuffer(buffer)) { return { content: '', isBinary: true } } @@ -755,12 +796,7 @@ export function registerFilesystemHandlers( ): Promise => { if (args.connectionId) { const provider = requireSshFilesystemProvider(args.connectionId) - if (!provider.listMarkdownDocuments) { - throw new Error( - 'Remote Markdown link discovery is unavailable. Reconnect the SSH target and retry.' - ) - } - const relativePaths = await provider.listMarkdownDocuments(args.rootPath) + const relativePaths = await provider.listFiles(args.rootPath) return markdownDocumentsFromRelativePaths(args.rootPath, relativePaths) } @@ -925,7 +961,7 @@ export function registerFilesystemHandlers( activeTextSearches.get(searchKey)?.kill() const acc = createAccumulator() - const stdoutLines = new SearchSubprocessLineAccumulator() + let stdoutBuffer = '' let resolved = false let child: ChildProcess | null = null let killTimeout: ReturnType @@ -968,11 +1004,12 @@ export function registerFilesystemHandlers( child = nextChild activeTextSearches.set(searchKey, nextChild) - const handleStdoutData = (chunk: Buffer): void => { - if (!stdoutLines.push(chunk, processLine)) { - acc.truncated = true - child?.kill() - resolveOnce() + const handleStdoutData = (chunk: string): void => { + stdoutBuffer += chunk + const lines = stdoutBuffer.split('\n') + stdoutBuffer = lines.pop() ?? '' + for (const line of lines) { + processLine(line) } } const handleStderrData = (): void => { @@ -982,13 +1019,13 @@ export function registerFilesystemHandlers( resolveOnce() } const handleClose = (): void => { - const trailingLine = stdoutLines.finish() - if (trailingLine !== null) { - processLine(trailingLine) + if (stdoutBuffer) { + processLine(stdoutBuffer) } resolveOnce() } + nextChild.stdout!.setEncoding('utf-8') nextChild.stdout!.on('data', handleStdoutData) nextChild.stderr!.on('data', handleStderrData) nextChild.once('error', handleError) diff --git a/src/main/ipc/github.ts b/src/main/ipc/github.ts index 531c87a026e..a01225bfccf 100644 --- a/src/main/ipc/github.ts +++ b/src/main/ipc/github.ts @@ -16,7 +16,6 @@ import type { PRRefreshOutcome } from '../../shared/types' import { getRepoExecutionHostId } from '../../shared/execution-host' -import { PR_REFRESH_VISIBLE_CANDIDATE_LIMIT } from '../../shared/pr-refresh-memory-limits' import type { TaskSourceContext } from '../../shared/task-source-context' import type { Store } from '../persistence' import type { StatsCollector } from '../stats/collector' @@ -361,9 +360,6 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi const candidates: GitHubPRRefreshCandidate[] = [] const repos = store.getRepos() for (const candidate of args.candidates) { - if (candidates.length >= PR_REFRESH_VISIBLE_CANDIDATE_LIMIT) { - break - } const validation = validateAutomaticPRRefreshCandidate(candidate, store, repos) if (validation.kind === 'ok') { candidates.push(validation.candidate) diff --git a/src/main/ipc/local-log-tail-operation-admission.ts b/src/main/ipc/local-log-tail-operation-admission.ts deleted file mode 100644 index adaf41b8db1..00000000000 --- a/src/main/ipc/local-log-tail-operation-admission.ts +++ /dev/null @@ -1,88 +0,0 @@ -export const MAX_LOCAL_LOG_TAIL_WATCHES_PER_SENDER = 32 -export const MAX_LOCAL_LOG_TAIL_WATCHES_PROCESS_WIDE = 128 -export const MAX_LOCAL_LOG_TAIL_READS_PER_SENDER = 16 -export const MAX_LOCAL_LOG_TAIL_READS_PROCESS_WIDE = 64 - -class ScopedLocalLogTailClaims { - private readonly claims = new Set() - private readonly claimsBySender = new Map>() - - constructor( - private readonly maxPerSender: number, - private readonly maxProcessWide: number, - private readonly label: string - ) {} - - claim(senderId: number, errorMessage: string): symbol { - const senderClaims = this.claimsBySender.get(senderId) - if ((senderClaims?.size ?? 0) >= this.maxPerSender || this.claims.size >= this.maxProcessWide) { - throw new Error(errorMessage) - } - const token = Symbol(this.label) - const nextSenderClaims = senderClaims ?? new Set() - nextSenderClaims.add(token) - this.claimsBySender.set(senderId, nextSenderClaims) - this.claims.add(token) - return token - } - - release(senderId: number, token: symbol): void { - this.claims.delete(token) - const senderClaims = this.claimsBySender.get(senderId) - senderClaims?.delete(token) - if (senderClaims?.size === 0) { - this.claimsBySender.delete(senderId) - } - } - - reset(): void { - this.claims.clear() - this.claimsBySender.clear() - } - - get size(): number { - return this.claims.size - } -} - -export class LocalLogTailOperationAdmission { - private readonly starts = new ScopedLocalLogTailClaims( - MAX_LOCAL_LOG_TAIL_WATCHES_PER_SENDER, - MAX_LOCAL_LOG_TAIL_WATCHES_PROCESS_WIDE, - 'local-log-tail-start' - ) - private readonly reads = new ScopedLocalLogTailClaims( - MAX_LOCAL_LOG_TAIL_READS_PER_SENDER, - MAX_LOCAL_LOG_TAIL_READS_PROCESS_WIDE, - 'local-log-tail-read' - ) - - claimStart(senderId: number): symbol { - return this.starts.claim(senderId, 'Too many local log tail starts') - } - - releaseStart(senderId: number, token: symbol): void { - this.starts.release(senderId, token) - } - - claimRead(senderId: number): symbol { - return this.reads.claim(senderId, 'Too many concurrent local log tail reads') - } - - releaseRead(senderId: number, token: symbol): void { - this.reads.release(senderId, token) - } - - reset(): void { - this.starts.reset() - this.reads.reset() - } - - get pendingStartCount(): number { - return this.starts.size - } - - get pendingReadCount(): number { - return this.reads.size - } -} diff --git a/src/main/ipc/local-log-tail.test.ts b/src/main/ipc/local-log-tail.test.ts index ffa07812ab9..beac464f4a4 100644 --- a/src/main/ipc/local-log-tail.test.ts +++ b/src/main/ipc/local-log-tail.test.ts @@ -26,18 +26,8 @@ vi.mock('../ai-vault/local-log-tail-reader', () => ({ import { closeAllLocalLogTailWatchers, getActiveLocalLogTailWatcherCount, - getLocalLogTailSenderCleanupCountForTest, - getPendingLocalLogTailReadCountForTest, - getPendingLocalLogTailStartCountForTest, - MAX_LOCAL_LOG_TAIL_FILE_IDENTITY_BYTES, - MAX_LOCAL_LOG_TAIL_SUBSCRIPTION_ID_BYTES, registerLocalLogTailHandlers } from './local-log-tail' -import { - MAX_LOCAL_LOG_TAIL_READS_PER_SENDER, - MAX_LOCAL_LOG_TAIL_WATCHES_PER_SENDER, - MAX_LOCAL_LOG_TAIL_WATCHES_PROCESS_WIDE -} from './local-log-tail-operation-admission' type FakeWatcher = { close: ReturnType @@ -60,40 +50,19 @@ function makeWatcher(): FakeWatcher { function makeSender(id: number) { let destroyedListener: (() => void) | undefined - let destroyed = false return { id, send: vi.fn(), - isDestroyed: vi.fn(() => destroyed), + isDestroyed: vi.fn(() => false), once: vi.fn((event: string, listener: () => void) => { if (event === 'destroyed') { destroyedListener = listener } }), - removeListener: vi.fn((event: string, listener: () => void) => { - if (event === 'destroyed' && destroyedListener === listener) { - destroyedListener = undefined - } - }), - destroy: () => { - destroyed = true - destroyedListener?.() - } + destroy: () => destroyedListener?.() } } -async function startWatch( - sender: ReturnType, - subscriptionId: string, - filePath = `/logs/${subscriptionId}.jsonl` -): Promise { - await handlers.get('fs:startLocalLogTail')?.({ sender }, { filePath, subscriptionId }) -} - -function stopWatch(sender: ReturnType, subscriptionId: string): void { - handlers.get('fs:stopLocalLogTail')?.({ sender }, { subscriptionId }) -} - beforeEach(() => { handlers.clear() watchMock.mockReset() @@ -133,8 +102,6 @@ describe('local log tail IPC', () => { handlers.get('fs:stopLocalLogTail')?.({ sender }, { subscriptionId: 'tail-1' }) expect(watcher.close).toHaveBeenCalledTimes(1) expect(getActiveLocalLogTailWatcherCount()).toBe(0) - expect(getLocalLogTailSenderCleanupCountForTest()).toBe(0) - expect(sender.removeListener).toHaveBeenCalledWith('destroyed', expect.any(Function)) }) it('closes every watcher owned by a destroyed renderer', async () => { @@ -156,219 +123,5 @@ describe('local log tail IPC', () => { expect(first.close).toHaveBeenCalledTimes(1) expect(second.close).toHaveBeenCalledTimes(1) expect(getActiveLocalLogTailWatcherCount()).toBe(0) - expect(getLocalLogTailSenderCleanupCountForTest()).toBe(0) - }) - - it('caps one renderer at the exact watcher limit and recovers after stop', async () => { - watchMock.mockImplementation(() => makeWatcher()) - const sender = makeSender(10) - for (let index = 0; index < MAX_LOCAL_LOG_TAIL_WATCHES_PER_SENDER; index++) { - await startWatch(sender, `tail-${index}`) - } - - expect(getActiveLocalLogTailWatcherCount()).toBe(MAX_LOCAL_LOG_TAIL_WATCHES_PER_SENDER) - await expect(startWatch(sender, 'one-over')).rejects.toThrow('Too many local log tail watchers') - expect(watchMock).toHaveBeenCalledTimes(MAX_LOCAL_LOG_TAIL_WATCHES_PER_SENDER) - - stopWatch(sender, 'tail-0') - await startWatch(sender, 'after-stop') - expect(getActiveLocalLogTailWatcherCount()).toBe(MAX_LOCAL_LOG_TAIL_WATCHES_PER_SENDER) - expect(getLocalLogTailSenderCleanupCountForTest()).toBe(1) - }) - - it('allows same-key replacement at process capacity while rejecting a new key', async () => { - watchMock.mockImplementation(() => makeWatcher()) - const senderCount = Math.ceil( - MAX_LOCAL_LOG_TAIL_WATCHES_PROCESS_WIDE / MAX_LOCAL_LOG_TAIL_WATCHES_PER_SENDER - ) - const senders = Array.from({ length: senderCount + 1 }, (_, index) => makeSender(20 + index)) - for (let index = 0; index < MAX_LOCAL_LOG_TAIL_WATCHES_PROCESS_WIDE; index++) { - const sender = senders[Math.floor(index / MAX_LOCAL_LOG_TAIL_WATCHES_PER_SENDER)] - await startWatch(sender, `global-${index}`) - } - - await expect(startWatch(senders.at(-1)!, 'global-one-over')).rejects.toThrow( - 'Too many local log tail watchers' - ) - await startWatch(senders[0], 'global-0', '/logs/replacement.jsonl') - - expect(getActiveLocalLogTailWatcherCount()).toBe(MAX_LOCAL_LOG_TAIL_WATCHES_PROCESS_WIDE) - expect(watchMock).toHaveBeenCalledTimes(MAX_LOCAL_LOG_TAIL_WATCHES_PROCESS_WIDE + 1) - }) - - it('bounds pending authorization attempts and rechecks destruction before watching', async () => { - let resolvePaths = (): void => {} - const pathGate = new Promise((resolve) => { - resolvePaths = resolve - }) - resolveAuthorizedPathMock.mockImplementation(async (path: string) => { - await pathGate - return path - }) - watchMock.mockImplementation(() => makeWatcher()) - const sender = makeSender(30) - const starts = Array.from({ length: MAX_LOCAL_LOG_TAIL_WATCHES_PER_SENDER }, (_, index) => - startWatch(sender, `pending-${index}`) - ) - - await vi.waitFor(() => - expect(getPendingLocalLogTailStartCountForTest()).toBe(MAX_LOCAL_LOG_TAIL_WATCHES_PER_SENDER) - ) - await expect(startWatch(sender, 'pending-one-over')).rejects.toThrow( - 'Too many local log tail starts' - ) - sender.destroy() - resolvePaths() - await Promise.all(starts) - - expect(watchMock).not.toHaveBeenCalled() - expect(getPendingLocalLogTailStartCountForTest()).toBe(0) - }) - - it('does not install a watcher after stop wins a pending authorization race', async () => { - let authorizePath = (): void => {} - resolveAuthorizedPathMock.mockImplementation( - (path: string) => - new Promise((resolve) => { - authorizePath = () => resolve(path) - }) - ) - watchMock.mockImplementation(() => makeWatcher()) - const sender = makeSender(31) - - const pendingStart = startWatch(sender, 'pending-stop') - await vi.waitFor(() => expect(getPendingLocalLogTailStartCountForTest()).toBe(1)) - stopWatch(sender, 'pending-stop') - authorizePath() - await pendingStart - - expect(watchMock).not.toHaveBeenCalled() - expect(getActiveLocalLogTailWatcherCount()).toBe(0) - expect(getLocalLogTailSenderCleanupCountForTest()).toBe(0) - }) - - it('lets the newest same-key start win when authorizations resolve out of order', async () => { - const authorizations = new Map void>() - resolveAuthorizedPathMock.mockImplementation( - (path: string) => - new Promise((resolve) => { - authorizations.set(path, resolve) - }) - ) - watchMock.mockImplementation(() => makeWatcher()) - const sender = makeSender(32) - - const olderStart = startWatch(sender, 'same-key', '/logs/older.jsonl') - const newerStart = startWatch(sender, 'same-key', '/logs/newer.jsonl') - await vi.waitFor(() => expect(authorizations.size).toBe(2)) - authorizations.get('/logs/newer.jsonl')?.('/logs/newer.jsonl') - await newerStart - authorizations.get('/logs/older.jsonl')?.('/logs/older.jsonl') - await olderStart - - expect(watchMock).toHaveBeenCalledTimes(1) - expect(watchMock).toHaveBeenCalledWith('/logs/newer.jsonl', expect.any(Function)) - expect(getActiveLocalLogTailWatcherCount()).toBe(1) - }) - - it('bounds concurrent ranged reads and releases admission after settlement', async () => { - let resolveReads!: (value: { - contentBase64: string - nextByteOffset: number - fileSize: number - fileIdentity: string - hasMore: boolean - reset: boolean - }) => void - const readGate = new Promise<{ - contentBase64: string - nextByteOffset: number - fileSize: number - fileIdentity: string - hasMore: boolean - reset: boolean - }>((resolve) => { - resolveReads = resolve - }) - readRangeMock.mockReturnValue(readGate) - const sender = makeSender(33) - const read = handlers.get('fs:readLocalLogTail')! - const reads = Array.from({ length: MAX_LOCAL_LOG_TAIL_READS_PER_SENDER }, () => - read({ sender }, { filePath: '/logs/session.jsonl', fromByteOffset: 0 }) - ) - - await vi.waitFor(() => - expect(getPendingLocalLogTailReadCountForTest()).toBe(MAX_LOCAL_LOG_TAIL_READS_PER_SENDER) - ) - await expect( - read({ sender }, { filePath: '/logs/session.jsonl', fromByteOffset: 0 }) - ).rejects.toThrow('Too many concurrent local log tail reads') - resolveReads({ - contentBase64: '', - nextByteOffset: 0, - fileSize: 0, - fileIdentity: 'identity', - hasMore: false, - reset: false - }) - await Promise.all(reads) - - expect(readRangeMock).toHaveBeenCalledTimes(MAX_LOCAL_LOG_TAIL_READS_PER_SENDER) - expect(getPendingLocalLogTailReadCountForTest()).toBe(0) - }) - - it('rejects an oversized UTF-8 subscription id before path authorization', async () => { - const sender = makeSender(34) - const oversizedId = '😀'.repeat(Math.floor(MAX_LOCAL_LOG_TAIL_SUBSCRIPTION_ID_BYTES / 4) + 1) - - await expect(startWatch(sender, oversizedId)).rejects.toThrow( - 'Invalid local log tail subscription id' - ) - expect(resolveAuthorizedPathMock).not.toHaveBeenCalled() - expect(watchMock).not.toHaveBeenCalled() - }) - - it('validates and snapshots ranged-read fields before path authorization', async () => { - let authorizePath = (): void => {} - resolveAuthorizedPathMock.mockImplementation( - (path: string) => - new Promise((resolve) => { - authorizePath = () => resolve(path) - }) - ) - readRangeMock.mockResolvedValue({ - contentBase64: '', - nextByteOffset: 7, - fileSize: 7, - fileIdentity: 'identity-before', - hasMore: false, - reset: false - }) - const sender = makeSender(35) - const args = { - filePath: '/logs/session.jsonl', - fromByteOffset: 7, - expectedIdentity: 'identity-before' - } - - const pendingRead = handlers.get('fs:readLocalLogTail')?.({ sender }, args) - args.fromByteOffset = 99 - args.expectedIdentity = 'identity-after' - authorizePath() - await pendingRead - - expect(readRangeMock).toHaveBeenCalledWith('/logs/session.jsonl', 7, 'identity-before') - - await expect( - handlers.get('fs:readLocalLogTail')?.( - { sender }, - { - filePath: '/logs/session.jsonl', - fromByteOffset: 0, - expectedIdentity: '😀'.repeat(Math.floor(MAX_LOCAL_LOG_TAIL_FILE_IDENTITY_BYTES / 4) + 1) - } - ) - ).rejects.toThrow('Invalid local log tail file identity') - expect(resolveAuthorizedPathMock).toHaveBeenCalledTimes(1) }) }) diff --git a/src/main/ipc/local-log-tail.ts b/src/main/ipc/local-log-tail.ts index 7b450aa9431..430882b4e07 100644 --- a/src/main/ipc/local-log-tail.ts +++ b/src/main/ipc/local-log-tail.ts @@ -9,192 +9,58 @@ import type { } from '../../shared/local-log-tail-types' import { readLocalLogTailRange } from '../ai-vault/local-log-tail-reader' import { resolveAuthorizedPath } from './filesystem-auth' -import { measureUtf8ByteLength } from '../../shared/utf8-byte-limits' -import { - LocalLogTailOperationAdmission, - MAX_LOCAL_LOG_TAIL_WATCHES_PER_SENDER, - MAX_LOCAL_LOG_TAIL_WATCHES_PROCESS_WIDE -} from './local-log-tail-operation-admission' type TailWatch = { - generation: symbol senderId: number watcher: FSWatcher } const tailWatches = new Map() -const watchGenerations = new Map() -const senderCleanups = new Map void }>() -const operationAdmission = new LocalLogTailOperationAdmission() - -export const MAX_LOCAL_LOG_TAIL_SUBSCRIPTION_ID_BYTES = 1_024 -export const MAX_LOCAL_LOG_TAIL_PATH_BYTES = 64 * 1_024 -export const MAX_LOCAL_LOG_TAIL_FILE_IDENTITY_BYTES = 1_024 +const senderCleanupRegistered = new Set() function watchKey(senderId: number, subscriptionId: string): string { return `${senderId}:${subscriptionId}` } -function closeWatchHandle(key: string, generation?: symbol): number | undefined { +function closeWatch(key: string): void { const subscription = tailWatches.get(key) - if (!subscription || (generation !== undefined && subscription.generation !== generation)) { - return undefined + if (!subscription) { + return } tailWatches.delete(key) subscription.watcher.close() - releaseSenderCleanupIfIdle(subscription.senderId) - return subscription.senderId -} - -function closeWatch(key: string): void { - const owner = watchGenerations.get(key) - watchGenerations.delete(key) - const senderId = closeWatchHandle(key) ?? owner?.senderId - if (senderId !== undefined) { - releaseSenderCleanupIfIdle(senderId) - } -} - -function closeWatchGeneration(key: string, generation: symbol): void { - const owner = watchGenerations.get(key) - if (owner?.generation === generation) { - watchGenerations.delete(key) - } - const senderId = closeWatchHandle(key, generation) ?? owner?.senderId - if (senderId !== undefined) { - releaseSenderCleanupIfIdle(senderId) - } } function closeSenderWatches(senderId: number): void { - releaseSenderCleanup(senderId) - for (const [key, owner] of watchGenerations) { - if (owner.senderId === senderId) { + senderCleanupRegistered.delete(senderId) + for (const [key, subscription] of tailWatches) { + if (subscription.senderId === senderId) { closeWatch(key) } } } function validateSubscriptionId(value: unknown): string { - if ( - typeof value !== 'string' || - value.length === 0 || - measureUtf8ByteLength(value, { - stopAfterBytes: MAX_LOCAL_LOG_TAIL_SUBSCRIPTION_ID_BYTES - }).exceededLimit - ) { + if (typeof value !== 'string' || value.length === 0 || value.length > 200) { throw new Error('Invalid local log tail subscription id') } return value } -function validateFilePath(value: unknown): string { - if ( - typeof value !== 'string' || - value.length === 0 || - measureUtf8ByteLength(value, { stopAfterBytes: MAX_LOCAL_LOG_TAIL_PATH_BYTES }).exceededLimit - ) { - throw new Error('Invalid local log tail path') - } - return value -} - -function validateExpectedIdentity(value: unknown): string | undefined { - if (value === undefined) { - return undefined - } - if ( - typeof value !== 'string' || - measureUtf8ByteLength(value, { - stopAfterBytes: MAX_LOCAL_LOG_TAIL_FILE_IDENTITY_BYTES - }).exceededLimit - ) { - throw new Error('Invalid local log tail file identity') - } - return value -} - function registerSenderCleanup(sender: WebContents): void { - if (senderCleanups.has(sender.id)) { + if (senderCleanupRegistered.has(sender.id)) { return } - const onDestroyed = (): void => closeSenderWatches(sender.id) - senderCleanups.set(sender.id, { sender, onDestroyed }) - sender.once('destroyed', onDestroyed) -} - -function releaseSenderCleanup(senderId: number): void { - const cleanup = senderCleanups.get(senderId) - if (!cleanup) { - return - } - senderCleanups.delete(senderId) - cleanup.sender.removeListener('destroyed', cleanup.onDestroyed) -} - -function releaseSenderCleanupIfIdle(senderId: number): void { - for (const owner of watchGenerations.values()) { - if (owner.senderId === senderId) { - return - } - } - for (const subscription of tailWatches.values()) { - if (subscription.senderId === senderId) { - return - } - } - releaseSenderCleanup(senderId) -} - -function activeSenderWatchCount(senderId: number): number { - let count = 0 - for (const subscription of tailWatches.values()) { - if (subscription.senderId === senderId) { - count += 1 - } - } - return count -} - -function restorePreviousWatchGeneration( - key: string, - generation: symbol, - previousGeneration: symbol | undefined, - senderId: number -): void { - if (watchGenerations.get(key)?.generation !== generation) { - return - } - const previousWatch = tailWatches.get(key) - if (previousGeneration !== undefined && previousWatch?.generation === previousGeneration) { - watchGenerations.set(key, { generation: previousGeneration, senderId }) - } else { - watchGenerations.delete(key) - } -} - -function validateByteOffset(value: unknown): number { - if (!Number.isSafeInteger(value) || (value as number) < 0) { - throw new Error('Invalid local log tail byte offset') - } - return value as number + senderCleanupRegistered.add(sender.id) + sender.once('destroyed', () => closeSenderWatches(sender.id)) } export function registerLocalLogTailHandlers(store: Store): void { ipcMain.handle( 'fs:readLocalLogTail', - async (event, args: LocalLogTailReadArgs): Promise => { - const senderId = event.sender.id - const requestedPath = validateFilePath(args.filePath) - const fromByteOffset = validateByteOffset(args.fromByteOffset) - const expectedIdentity = validateExpectedIdentity(args.expectedIdentity) - const readToken = operationAdmission.claimRead(senderId) - try { - const filePath = await resolveAuthorizedPath(requestedPath, store) - return await readLocalLogTailRange(filePath, fromByteOffset, expectedIdentity) - } finally { - operationAdmission.releaseRead(senderId, readToken) - } + async (_event, args: LocalLogTailReadArgs): Promise => { + const filePath = await resolveAuthorizedPath(args.filePath, store) + return readLocalLogTailRange(filePath, args.fromByteOffset, args.expectedIdentity) } ) @@ -202,56 +68,26 @@ export function registerLocalLogTailHandlers(store: Store): void { 'fs:startLocalLogTail', async (event, args: LocalLogTailWatchArgs): Promise => { const subscriptionId = validateSubscriptionId(args.subscriptionId) - const requestedPath = validateFilePath(args.filePath) - const senderId = event.sender.id - const key = watchKey(senderId, subscriptionId) - const pendingToken = operationAdmission.claimStart(senderId) - const previousGeneration = watchGenerations.get(key)?.generation - const generation = Symbol(subscriptionId) - watchGenerations.set(key, { generation, senderId }) - registerSenderCleanup(event.sender) - let published = false - try { - const filePath = await resolveAuthorizedPath(requestedPath, store) - if (event.sender.isDestroyed() || watchGenerations.get(key)?.generation !== generation) { + const filePath = await resolveAuthorizedPath(args.filePath, store) + const key = watchKey(event.sender.id, subscriptionId) + closeWatch(key) + + const sendChange = (eventType: 'change' | 'rename'): void => { + if (!tailWatches.has(key) || event.sender.isDestroyed()) { return } - const replacing = tailWatches.has(key) - if ( - !replacing && - (activeSenderWatchCount(senderId) >= MAX_LOCAL_LOG_TAIL_WATCHES_PER_SENDER || - tailWatches.size >= MAX_LOCAL_LOG_TAIL_WATCHES_PROCESS_WIDE) - ) { - throw new Error('Too many local log tail watchers') - } - closeWatchHandle(key) - - const sendChange = (eventType: 'change' | 'rename'): void => { - if (tailWatches.get(key)?.generation !== generation || event.sender.isDestroyed()) { - return - } - const payload: LocalLogTailChangedPayload = { subscriptionId, eventType } - event.sender.send('fs:localLogTailChanged', payload) - } - const watcher = watch(filePath, (eventType) => sendChange(eventType)) - watcher.on('error', () => { - // Why: an error commonly accompanies rotation. Signal one final drain so - // the renderer can detect identity change, then release the dead handle. - sendChange('rename') - closeWatchGeneration(key, generation) - }) - tailWatches.set(key, { generation, senderId, watcher }) - published = true - if (event.sender.isDestroyed()) { - closeWatchGeneration(key, generation) - } - } finally { - if (!published) { - restorePreviousWatchGeneration(key, generation, previousGeneration, senderId) - releaseSenderCleanupIfIdle(senderId) - } - operationAdmission.releaseStart(senderId, pendingToken) + const payload: LocalLogTailChangedPayload = { subscriptionId, eventType } + event.sender.send('fs:localLogTailChanged', payload) } + const watcher = watch(filePath, (eventType) => sendChange(eventType)) + watcher.on('error', () => { + // Why: an error commonly accompanies rotation. Signal one final drain so + // the renderer can detect identity change, then release the dead handle. + sendChange('rename') + closeWatch(key) + }) + tailWatches.set(key, { senderId: event.sender.id, watcher }) + registerSenderCleanup(event.sender) } ) @@ -261,28 +97,13 @@ export function registerLocalLogTailHandlers(store: Store): void { } export function closeAllLocalLogTailWatchers(): void { - for (const key of new Set([...watchGenerations.keys(), ...tailWatches.keys()])) { + for (const key of Array.from(tailWatches.keys())) { closeWatch(key) } - for (const senderId of Array.from(senderCleanups.keys())) { - releaseSenderCleanup(senderId) - } - operationAdmission.reset() + senderCleanupRegistered.clear() } /** Test-only: verifies tab/window teardown does not retain native watchers. */ export function getActiveLocalLogTailWatcherCount(): number { return tailWatches.size } - -export function getLocalLogTailSenderCleanupCountForTest(): number { - return senderCleanups.size -} - -export function getPendingLocalLogTailStartCountForTest(): number { - return operationAdmission.pendingStartCount -} - -export function getPendingLocalLogTailReadCountForTest(): number { - return operationAdmission.pendingReadCount -} diff --git a/src/main/ipc/markdown-documents.test.ts b/src/main/ipc/markdown-documents.test.ts index b500acb3c14..b23f5748cb4 100644 --- a/src/main/ipc/markdown-documents.test.ts +++ b/src/main/ipc/markdown-documents.test.ts @@ -1,9 +1,5 @@ import { describe, expect, it } from 'vitest' -import { - markdownDocumentFromFilePath, - markdownDocumentsFromRelativePaths -} from './markdown-documents' -import { MarkdownDocumentListingCapacityError } from '../../shared/markdown-document-listing-limits' +import { markdownDocumentFromFilePath } from './markdown-documents' describe('markdownDocumentFromFilePath', () => { it('keeps in-root path segments that merely start with parent traversal text', () => { @@ -28,52 +24,3 @@ describe('markdownDocumentFromFilePath', () => { }) }) }) - -describe('markdownDocumentsFromRelativePaths', () => { - it('preserves filtering and sorted output below every limit', () => { - expect( - markdownDocumentsFromRelativePaths('/workspace', [ - 'z-last.markdown', - 'src/app.ts', - '../outside.md', - 'docs/Guide.MDX', - 'README.md' - ]) - ).toEqual([ - { - filePath: '/workspace/docs/Guide.MDX', - relativePath: 'docs/Guide.MDX', - basename: 'Guide.MDX', - name: 'Guide' - }, - { - filePath: '/workspace/README.md', - relativePath: 'README.md', - basename: 'README.md', - name: 'README' - }, - { - filePath: '/workspace/z-last.markdown', - relativePath: 'z-last.markdown', - basename: 'z-last.markdown', - name: 'z-last' - } - ]) - }) - - it('rejects count, metadata, and UTF-8 path overflow before retaining another result', () => { - expect(() => - markdownDocumentsFromRelativePaths('/workspace', ['one.md', 'two.md'], { - maxDocuments: 1 - }) - ).toThrow(MarkdownDocumentListingCapacityError) - expect(() => - markdownDocumentsFromRelativePaths('/workspace', ['metadata.md'], { - maxMetadataBytes: 1 - }) - ).toThrow(MarkdownDocumentListingCapacityError) - expect(() => - markdownDocumentsFromRelativePaths('/workspace', [`${'é'.repeat(40_000)}.md`]) - ).toThrow(MarkdownDocumentListingCapacityError) - }) -}) diff --git a/src/main/ipc/markdown-documents.ts b/src/main/ipc/markdown-documents.ts index 687f50e1c5e..713adc9e0fb 100644 --- a/src/main/ipc/markdown-documents.ts +++ b/src/main/ipc/markdown-documents.ts @@ -1,22 +1,14 @@ -import { basename as pathBasename, extname, isAbsolute, relative, resolve } from 'node:path' +import { readdir } from 'node:fs/promises' +import { basename as pathBasename, extname, isAbsolute, join, relative, resolve } from 'node:path' import type { MarkdownDocument } from '../../shared/types' -import { - assertMarkdownDocumentPathWithinLimit, - createMarkdownDocumentListingBudget, - retainMarkdownDocument, - type MarkdownDocumentListingLimits -} from '../../shared/markdown-document-listing-limits' -import { - discoverMarkdownRelativePaths, - isMarkdownDocumentPath -} from '../../shared/node-markdown-document-discovery' function normalizeRelativePath(path: string): string { return path.replace(/[\\/]+/g, '/').replace(/^\/+/, '') } export function isMarkdownDocumentName(name: string): boolean { - return isMarkdownDocumentPath(name) + const extension = extname(name).toLowerCase() + return extension === '.md' || extension === '.mdx' || extension === '.markdown' } function basenameFromRelativePath(relativePath: string): string { @@ -47,8 +39,6 @@ export function markdownDocumentFromFilePath( filePath: string, options: { outsideRootRelativePath?: 'basename' | 'relative' } = {} ): MarkdownDocument { - assertMarkdownDocumentPathWithinLimit(rootPath) - assertMarkdownDocumentPathWithinLimit(filePath) const basename = pathBasename(filePath) const extension = extname(basename) const relativePath = @@ -56,23 +46,18 @@ export function markdownDocumentFromFilePath( (options.outsideRootRelativePath === 'basename' ? basename : normalizeRelativePath(relative(rootPath, filePath))) - const document = { + return { filePath, relativePath, basename, name: extension ? basename.slice(0, -extension.length) : basename } - const budget = createMarkdownDocumentListingBudget() - retainMarkdownDocument(budget, document) - return document } export function markdownDocumentFromRelativePath( rootPath: string, relativePath: string ): MarkdownDocument | null { - assertMarkdownDocumentPathWithinLimit(rootPath) - assertMarkdownDocumentPathWithinLimit(relativePath) const normalizedRelativePath = normalizeRelativePath(relativePath) // Why: SSH providers should return root-relative paths; reject escape // segments before building a synthetic absolute path for renderer use. @@ -85,42 +70,52 @@ export function markdownDocumentFromRelativePath( } const extension = extname(basename) const normalizedRoot = rootPath.replace(/[\\/]+$/, '') - const document = { + return { filePath: `${normalizedRoot}/${normalizedRelativePath}`, relativePath: normalizedRelativePath, basename, name: extension ? basename.slice(0, -extension.length) : basename } - const budget = createMarkdownDocumentListingBudget() - retainMarkdownDocument(budget, document) - return document } export function markdownDocumentsFromRelativePaths( rootPath: string, - relativePaths: readonly string[], - limits: Partial = {} + relativePaths: string[] ): MarkdownDocument[] { - const budget = createMarkdownDocumentListingBudget(limits) - const documents: MarkdownDocument[] = [] - for (const relativePath of relativePaths) { - const document = markdownDocumentFromRelativePath(rootPath, relativePath) - if (document) { - retainMarkdownDocument(budget, document) - documents.push(document) - } - } - return documents.sort((a, b) => a.relativePath.localeCompare(b.relativePath)) + return relativePaths + .map((relativePath) => markdownDocumentFromRelativePath(rootPath, relativePath)) + .filter((document): document is MarkdownDocument => document !== null) + .sort((a, b) => a.relativePath.localeCompare(b.relativePath)) } -export async function listMarkdownDocuments( - rootPath: string, - limits: Partial = {} -): Promise { - const relativePaths = await discoverMarkdownRelativePaths(rootPath, { - limits, - shouldDescend: (_relativePath, name) => - name !== '.git' && name !== 'node_modules' && (!name.startsWith('.') || name === '.github') - }) - return markdownDocumentsFromRelativePaths(rootPath, relativePaths, limits) +export async function listMarkdownDocuments(rootPath: string): Promise { + const documents: MarkdownDocument[] = [] + + async function visitDirectory(dirPath: string): Promise { + const entries = await readdir(dirPath, { withFileTypes: true }) + for (const entry of entries) { + if (entry.isSymbolicLink()) { + continue + } + + const entryPath = join(dirPath, entry.name) + if (entry.isDirectory()) { + if (entry.name === '.git' || entry.name === 'node_modules') { + continue + } + if (entry.name.startsWith('.') && entry.name !== '.github') { + continue + } + await visitDirectory(entryPath) + continue + } + + if (entry.isFile() && isMarkdownDocumentName(entry.name)) { + documents.push(markdownDocumentFromFilePath(rootPath, entryPath)) + } + } + } + + await visitDirectory(rootPath) + return documents.sort((a, b) => a.relativePath.localeCompare(b.relativePath)) } diff --git a/src/main/ipc/native-chat-ipc-admission.ts b/src/main/ipc/native-chat-ipc-admission.ts deleted file mode 100644 index fd19d898895..00000000000 --- a/src/main/ipc/native-chat-ipc-admission.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { measureUtf8ByteLength } from '../../shared/utf8-byte-limits' - -const DESKTOP_READ_WINDOW = 300 - -export const MAX_NATIVE_CHAT_DESKTOP_READ_LIMIT = 2_000 -export const MAX_NATIVE_CHAT_SUBSCRIPTIONS_PER_SENDER = 64 -export const MAX_NATIVE_CHAT_SUBSCRIPTIONS_PROCESS_WIDE = 256 -export const MAX_NATIVE_CHAT_SETUP_ATTEMPTS_PER_SENDER = 64 -export const MAX_NATIVE_CHAT_SETUP_ATTEMPTS_PROCESS_WIDE = 256 -export const MAX_NATIVE_CHAT_READS_PER_SENDER = 8 -export const MAX_NATIVE_CHAT_READS_PROCESS_WIDE = 16 -export const MAX_NATIVE_CHAT_SUBSCRIPTION_ID_BYTES = 1_024 -export const MAX_NATIVE_CHAT_SESSION_ID_BYTES = 4 * 1_024 -export const MAX_NATIVE_CHAT_TRANSCRIPT_PATH_BYTES = 64 * 1_024 -export const MAX_NATIVE_CHAT_AGENT_TYPE_BYTES = 64 - -function boundedString( - value: unknown, - maxBytes: number, - options: { allowEmpty?: boolean } = {} -): value is string { - if (typeof value !== 'string' || (!options.allowEmpty && value.length === 0)) { - return false - } - return !measureUtf8ByteLength(value, { stopAfterBytes: maxBytes }).exceededLimit -} - -export function isValidNativeChatTranscriptRequestStrings(args: { - agent: unknown - sessionId: unknown - transcriptPath?: unknown -}): boolean { - return ( - boundedString(args.agent, MAX_NATIVE_CHAT_AGENT_TYPE_BYTES) && - boundedString(args.sessionId, MAX_NATIVE_CHAT_SESSION_ID_BYTES, { allowEmpty: true }) && - (args.transcriptPath === undefined || - boundedString(args.transcriptPath, MAX_NATIVE_CHAT_TRANSCRIPT_PATH_BYTES, { - allowEmpty: true - })) - ) -} - -export function isValidNativeChatSubscriptionId(value: unknown): value is string { - return boundedString(value, MAX_NATIVE_CHAT_SUBSCRIPTION_ID_BYTES) -} - -export function normalizeNativeChatDesktopReadLimit(limit: unknown): number { - if (typeof limit !== 'number' || !Number.isFinite(limit) || limit <= 0) { - return DESKTOP_READ_WINDOW - } - return Math.min(MAX_NATIVE_CHAT_DESKTOP_READ_LIMIT, Math.floor(limit)) -} - -class NativeChatAttemptAdmission { - private readonly attempts = new Set() - private readonly attemptsBySender = new Map>() - - constructor( - private readonly maxPerSender: number, - private readonly maxProcessWide: number, - private readonly tokenLabel: string - ) {} - - claim(senderId: number): symbol | null { - const senderAttempts = this.attemptsBySender.get(senderId) - if ( - (senderAttempts?.size ?? 0) >= this.maxPerSender || - this.attempts.size >= this.maxProcessWide - ) { - return null - } - const token = Symbol(this.tokenLabel) - const nextSenderAttempts = senderAttempts ?? new Set() - nextSenderAttempts.add(token) - this.attemptsBySender.set(senderId, nextSenderAttempts) - this.attempts.add(token) - return token - } - - release(senderId: number, token: symbol): void { - this.attempts.delete(token) - const senderAttempts = this.attemptsBySender.get(senderId) - senderAttempts?.delete(token) - if (senderAttempts?.size === 0) { - this.attemptsBySender.delete(senderId) - } - } - - reset(): void { - this.attempts.clear() - this.attemptsBySender.clear() - } - - get size(): number { - return this.attempts.size - } -} - -export class NativeChatSetupAttemptAdmission extends NativeChatAttemptAdmission { - constructor() { - super( - MAX_NATIVE_CHAT_SETUP_ATTEMPTS_PER_SENDER, - MAX_NATIVE_CHAT_SETUP_ATTEMPTS_PROCESS_WIDE, - 'native-chat-setup' - ) - } -} - -export class NativeChatReadAdmission extends NativeChatAttemptAdmission { - constructor() { - super(MAX_NATIVE_CHAT_READS_PER_SENDER, MAX_NATIVE_CHAT_READS_PROCESS_WIDE, 'native-chat-read') - } -} diff --git a/src/main/ipc/native-chat-subscribe-lifecycle.test.ts b/src/main/ipc/native-chat-subscribe-lifecycle.test.ts index 681f0c3310d..683e0271086 100644 --- a/src/main/ipc/native-chat-subscribe-lifecycle.test.ts +++ b/src/main/ipc/native-chat-subscribe-lifecycle.test.ts @@ -1,10 +1,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { NativeChatTurnLifecycle } from '../../shared/native-chat-types' -const { handlers, listeners, readTranscriptTail, subscribeTranscript } = vi.hoisted(() => ({ +const { handlers, listeners, subscribeTranscript } = vi.hoisted(() => ({ handlers: new Map unknown>(), listeners: new Map unknown>(), - readTranscriptTail: vi.fn(), subscribeTranscript: vi.fn() })) @@ -20,28 +19,15 @@ vi.mock('electron', () => ({ })) vi.mock('../native-chat/transcript-watch', () => ({ - readNativeChatTranscriptTail: readTranscriptTail, subscribeNativeChatTranscript: subscribeTranscript })) import { - _getNativeChatLiveSubscriptionCountForTest, _getNativeChatPendingSubscriptionCountForTest, - _getNativeChatReadCountForTest, _getNativeChatSenderCleanupCountForTest, - _getNativeChatSetupAttemptCountForTest, clearNativeChatSubscriptions, registerNativeChatHandlers } from './native-chat' -import { - MAX_NATIVE_CHAT_DESKTOP_READ_LIMIT, - MAX_NATIVE_CHAT_READS_PER_SENDER, - MAX_NATIVE_CHAT_SESSION_ID_BYTES, - MAX_NATIVE_CHAT_SETUP_ATTEMPTS_PER_SENDER, - MAX_NATIVE_CHAT_SUBSCRIPTIONS_PROCESS_WIDE, - MAX_NATIVE_CHAT_SUBSCRIPTIONS_PER_SENDER, - MAX_NATIVE_CHAT_SUBSCRIPTION_ID_BYTES -} from './native-chat-ipc-admission' type TestSubscription = { unsubscribe: ReturnType @@ -62,7 +48,6 @@ type SenderHarness = { id: number isDestroyed: () => boolean once: (event: string, callback: () => void) => void - removeListener: (event: string, callback: () => void) => void send: ReturnType } } @@ -71,8 +56,6 @@ beforeEach(() => { clearNativeChatSubscriptions() handlers.clear() listeners.clear() - readTranscriptTail.mockReset() - readTranscriptTail.mockResolvedValue({ messages: [], hasMore: false, beforeOffset: 0 }) subscribeTranscript.mockReset() registerNativeChatHandlers() }) @@ -99,7 +82,7 @@ function createSender(id: number): SenderHarness { return { destroy: () => { destroyed = true - for (const callback of destroyedCallbacks.slice()) { + for (const callback of destroyedCallbacks) { callback() } }, @@ -112,15 +95,6 @@ function createSender(id: number): SenderHarness { destroyedCallbacks.push(callback) } }, - removeListener: (event, callback) => { - if (event !== 'destroyed') { - return - } - const index = destroyedCallbacks.indexOf(callback) - if (index >= 0) { - destroyedCallbacks.splice(index, 1) - } - }, send: vi.fn() } } @@ -171,166 +145,6 @@ async function waitFor(predicate: () => boolean, timeoutMs = 1000): Promise { - it('clamps read and watch windows before transcript work starts', async () => { - const read = handlers.get('nativeChat:readSession') - if (!read) { - throw new Error('read handler not registered') - } - await read( - { sender: { id: 9 } }, - { agent: 'claude', sessionId: 'session', limit: Number.MAX_SAFE_INTEGER } - ) - expect(readTranscriptTail).toHaveBeenCalledWith( - expect.objectContaining({ limit: MAX_NATIVE_CHAT_DESKTOP_READ_LIMIT }) - ) - - const pending = deferredSubscription() - subscribeTranscript.mockReturnValueOnce(pending.promise) - const renderer = createSender(10) - const listener = listeners.get('nativeChat:subscribe')! - listener( - { sender: renderer.sender }, - { - subscriptionId: 'bounded-window', - agent: 'claude', - sessionId: 'session', - limit: Number.MAX_SAFE_INTEGER - } - ) - expect(subscribeTranscript).toHaveBeenCalledWith( - expect.objectContaining({ initialLimit: MAX_NATIVE_CHAT_DESKTOP_READ_LIMIT }) - ) - pending.resolve() - await waitFor(() => _getNativeChatSetupAttemptCountForTest() === 0) - renderer.destroy() - }) - - it('rejects oversized retained identifiers before starting transcript work', async () => { - const renderer = createSender(11) - subscribe(renderer.sender, 'x'.repeat(MAX_NATIVE_CHAT_SUBSCRIPTION_ID_BYTES + 1)) - const listener = listeners.get('nativeChat:subscribe')! - listener( - { sender: renderer.sender }, - { - subscriptionId: 'valid', - agent: 'claude', - sessionId: 'x'.repeat(MAX_NATIVE_CHAT_SESSION_ID_BYTES + 1) - } - ) - - expect(subscribeTranscript).not.toHaveBeenCalled() - expect(_getNativeChatPendingSubscriptionCountForTest()).toBe(0) - expect(_getNativeChatSetupAttemptCountForTest()).toBe(0) - }) - - it('bounds concurrent transcript pages per renderer and releases admission', async () => { - let releaseReads!: (value: { - messages: never[] - hasMore: boolean - beforeOffset: number - }) => void - const readGate = new Promise<{ - messages: never[] - hasMore: boolean - beforeOffset: number - }>((resolve) => { - releaseReads = resolve - }) - readTranscriptTail.mockReturnValue(readGate) - const read = handlers.get('nativeChat:readSession')! - const reads = Array.from({ length: MAX_NATIVE_CHAT_READS_PER_SENDER }, () => - read({ sender: { id: 20 } }, { agent: 'claude', sessionId: 'session' }) - ) - - await vi.waitFor(() => - expect(_getNativeChatReadCountForTest()).toBe(MAX_NATIVE_CHAT_READS_PER_SENDER) - ) - await expect( - read({ sender: { id: 20 } }, { agent: 'claude', sessionId: 'session' }) - ).resolves.toEqual({ error: 'Too many concurrent native chat transcript reads' }) - expect(readTranscriptTail).toHaveBeenCalledTimes(MAX_NATIVE_CHAT_READS_PER_SENDER) - - releaseReads({ messages: [], hasMore: false, beforeOffset: 0 }) - await Promise.all(reads) - expect(_getNativeChatReadCountForTest()).toBe(0) - }) - - it('caps unique logical subscriptions per renderer and recovers after unsubscribe', async () => { - subscribeTranscript.mockImplementation(async () => ({ - unsubscribe: vi.fn(), - watching: true - })) - const renderer = createSender(12) - for (let index = 0; index < MAX_NATIVE_CHAT_SUBSCRIPTIONS_PER_SENDER; index++) { - subscribe(renderer.sender, `sub-${index}`) - } - subscribe(renderer.sender, 'one-over') - await waitFor(() => _getNativeChatSetupAttemptCountForTest() === 0) - - expect(subscribeTranscript).toHaveBeenCalledTimes(MAX_NATIVE_CHAT_SUBSCRIPTIONS_PER_SENDER) - expect(_getNativeChatLiveSubscriptionCountForTest()).toBe( - MAX_NATIVE_CHAT_SUBSCRIPTIONS_PER_SENDER - ) - - unsubscribe(renderer.sender, 'sub-0') - subscribe(renderer.sender, 'after-release') - await waitFor(() => _getNativeChatSetupAttemptCountForTest() === 0) - expect(subscribeTranscript).toHaveBeenCalledTimes(MAX_NATIVE_CHAT_SUBSCRIPTIONS_PER_SENDER + 1) - renderer.destroy() - }) - - it('caps logical subscriptions across renderers process-wide', async () => { - subscribeTranscript.mockImplementation(async () => ({ - unsubscribe: vi.fn(), - watching: true - })) - const rendererCount = Math.ceil( - MAX_NATIVE_CHAT_SUBSCRIPTIONS_PROCESS_WIDE / MAX_NATIVE_CHAT_SUBSCRIPTIONS_PER_SENDER - ) - const renderers = Array.from({ length: rendererCount + 1 }, (_, index) => - createSender(100 + index) - ) - for (let index = 0; index < MAX_NATIVE_CHAT_SUBSCRIPTIONS_PROCESS_WIDE; index++) { - const renderer = renderers[Math.floor(index / MAX_NATIVE_CHAT_SUBSCRIPTIONS_PER_SENDER)] - subscribe(renderer.sender, `global-${index}`) - } - subscribe(renderers.at(-1)!.sender, 'global-one-over') - await waitFor(() => _getNativeChatSetupAttemptCountForTest() === 0) - - expect(subscribeTranscript).toHaveBeenCalledTimes(MAX_NATIVE_CHAT_SUBSCRIPTIONS_PROCESS_WIDE) - expect(_getNativeChatLiveSubscriptionCountForTest()).toBe( - MAX_NATIVE_CHAT_SUBSCRIPTIONS_PROCESS_WIDE - ) - renderers.forEach((renderer) => renderer.destroy()) - }) - - it('caps unresolved same-id setup attempts without displacing the latest admitted setup', async () => { - const pending = Array.from( - { length: MAX_NATIVE_CHAT_SETUP_ATTEMPTS_PER_SENDER }, - deferredSubscription - ) - for (const setup of pending) { - subscribeTranscript.mockReturnValueOnce(setup.promise) - } - const renderer = createSender(13) - for (let index = 0; index < pending.length; index++) { - subscribe(renderer.sender, 'same-id-storm') - } - subscribe(renderer.sender, 'same-id-storm') - - expect(subscribeTranscript).toHaveBeenCalledTimes(MAX_NATIVE_CHAT_SETUP_ATTEMPTS_PER_SENDER) - expect(_getNativeChatSetupAttemptCountForTest()).toBe(MAX_NATIVE_CHAT_SETUP_ATTEMPTS_PER_SENDER) - expect(_getNativeChatPendingSubscriptionCountForTest()).toBe(1) - - pending.forEach((setup) => setup.resolve()) - await waitFor(() => _getNativeChatSetupAttemptCountForTest() === 0) - expect(pending.slice(0, -1).every((setup) => setup.unsubscribe.mock.calls.length === 1)).toBe( - true - ) - expect(pending.at(-1)?.unsubscribe).not.toHaveBeenCalled() - renderer.destroy() - }) - it('closes a watcher that resolves after renderer unsubscribe', async () => { const pending = deferredSubscription() subscribeTranscript.mockReturnValueOnce(pending.promise) @@ -347,8 +161,6 @@ describe('nativeChat subscribe lifecycle', () => { await waitFor(() => pending.unsubscribe.mock.calls.length === 1) unsubscribe(renderer.sender, 'unmount') expect(pending.unsubscribe).toHaveBeenCalledOnce() - expect(_getNativeChatSenderCleanupCountForTest()).toBe(0) - expect(renderer.registeredCleanupCount()).toBe(0) renderer.destroy() expect(_getNativeChatSenderCleanupCountForTest()).toBe(0) }) @@ -397,8 +209,7 @@ describe('nativeChat subscribe lifecycle', () => { subscribe(renderer.sender, 'retry') failed.reject(new Error('watch setup failed')) await waitFor(() => _getNativeChatPendingSubscriptionCountForTest() === 0) - expect(_getNativeChatSenderCleanupCountForTest()).toBe(0) - expect(renderer.registeredCleanupCount()).toBe(0) + expect(_getNativeChatSenderCleanupCountForTest()).toBe(1) subscribe(renderer.sender, 'retry') expect(renderer.registeredCleanupCount()).toBe(1) @@ -406,7 +217,6 @@ describe('nativeChat subscribe lifecycle', () => { await waitFor(() => _getNativeChatPendingSubscriptionCountForTest() === 0) unsubscribe(renderer.sender, 'retry') expect(retry.unsubscribe).toHaveBeenCalledOnce() - expect(renderer.registeredCleanupCount()).toBe(0) renderer.destroy() expect(_getNativeChatSenderCleanupCountForTest()).toBe(0) }) diff --git a/src/main/ipc/native-chat-subscription-registry.ts b/src/main/ipc/native-chat-subscription-registry.ts deleted file mode 100644 index 0826039fd17..00000000000 --- a/src/main/ipc/native-chat-subscription-registry.ts +++ /dev/null @@ -1,178 +0,0 @@ -import type { WebContents } from 'electron' -import type { NativeChatTranscriptSubscription } from '../native-chat/transcript-watch' -import { - MAX_NATIVE_CHAT_SUBSCRIPTIONS_PER_SENDER, - MAX_NATIVE_CHAT_SUBSCRIPTIONS_PROCESS_WIDE, - NativeChatSetupAttemptAdmission -} from './native-chat-ipc-admission' - -export class NativeChatSubscriptionRegistry { - private readonly live = new Map>() - private readonly pending = new Map>() - private readonly senderCleanups = new Map< - number, - { onDestroyed: () => void; sender: WebContents } - >() - private readonly setupAttempts = new NativeChatSetupAttemptAdmission() - - canAdmit(senderId: number, subscriptionId: string): boolean { - if ( - this.live.get(senderId)?.has(subscriptionId) || - this.pending.get(senderId)?.has(subscriptionId) - ) { - return true - } - return ( - this.logicalCount(senderId) < MAX_NATIVE_CHAT_SUBSCRIPTIONS_PER_SENDER && - this.logicalCount() < MAX_NATIVE_CHAT_SUBSCRIPTIONS_PROCESS_WIDE - ) - } - - claimSetup(senderId: number): symbol | null { - return this.setupAttempts.claim(senderId) - } - - releaseSetup(senderId: number, token: symbol): void { - this.setupAttempts.release(senderId, token) - } - - beginPending(senderId: number, subscriptionId: string): symbol { - this.teardown(senderId, subscriptionId) - const token = Symbol(subscriptionId) - const bySubscription = this.pending.get(senderId) ?? new Map() - bySubscription.set(subscriptionId, token) - this.pending.set(senderId, bySubscription) - return token - } - - takePending(senderId: number, subscriptionId: string, token: symbol): boolean { - const bySubscription = this.pending.get(senderId) - if (bySubscription?.get(subscriptionId) !== token) { - return false - } - bySubscription.delete(subscriptionId) - if (bySubscription.size === 0) { - this.pending.delete(senderId) - } - return true - } - - publish( - senderId: number, - subscriptionId: string, - subscription: NativeChatTranscriptSubscription - ): void { - const bySubscription = - this.live.get(senderId) ?? new Map() - bySubscription.get(subscriptionId)?.unsubscribe() - bySubscription.set(subscriptionId, subscription) - this.live.set(senderId, bySubscription) - } - - registerSenderCleanup(sender: WebContents): void { - const existing = this.senderCleanups.get(sender.id) - if (existing?.sender === sender) { - return - } - if (existing) { - this.teardownSender(sender.id) - } - const onDestroyed = (): void => { - if (this.senderCleanups.get(sender.id)?.sender === sender) { - this.teardownSender(sender.id) - } - } - this.senderCleanups.set(sender.id, { onDestroyed, sender }) - sender.once('destroyed', onDestroyed) - } - - teardown(senderId: number, subscriptionId: string): void { - const pendingBySubscription = this.pending.get(senderId) - pendingBySubscription?.delete(subscriptionId) - if (pendingBySubscription?.size === 0) { - this.pending.delete(senderId) - } - const liveBySubscription = this.live.get(senderId) - const subscription = liveBySubscription?.get(subscriptionId) - subscription?.unsubscribe() - liveBySubscription?.delete(subscriptionId) - if (liveBySubscription?.size === 0) { - this.live.delete(senderId) - } - this.releaseSenderCleanupIfIdle(senderId) - } - - releaseSenderCleanupIfIdle(senderId: number): void { - if (this.live.has(senderId) || this.pending.has(senderId)) { - return - } - this.releaseSenderCleanup(senderId) - } - - reset(): void { - const senderIds = new Set([ - ...this.live.keys(), - ...this.pending.keys(), - ...this.senderCleanups.keys() - ]) - for (const senderId of senderIds) { - this.teardownSender(senderId) - } - this.pending.clear() - this.setupAttempts.reset() - } - - get cleanupCount(): number { - return this.senderCleanups.size - } - - get pendingCount(): number { - let count = 0 - for (const bySubscription of this.pending.values()) { - count += bySubscription.size - } - return count - } - - get logicalSubscriptionCount(): number { - return this.logicalCount() - } - - get setupAttemptCount(): number { - return this.setupAttempts.size - } - - private logicalCount(senderId?: number): number { - if (senderId !== undefined) { - return (this.live.get(senderId)?.size ?? 0) + (this.pending.get(senderId)?.size ?? 0) - } - let count = 0 - const senderIds = new Set([...this.live.keys(), ...this.pending.keys()]) - for (const id of senderIds) { - count += this.logicalCount(id) - } - return count - } - - private teardownSender(senderId: number): void { - this.releaseSenderCleanup(senderId) - this.pending.delete(senderId) - const bySubscription = this.live.get(senderId) - if (!bySubscription) { - return - } - for (const subscription of bySubscription.values()) { - subscription.unsubscribe() - } - this.live.delete(senderId) - } - - private releaseSenderCleanup(senderId: number): void { - const cleanup = this.senderCleanups.get(senderId) - if (!cleanup) { - return - } - this.senderCleanups.delete(senderId) - cleanup.sender.removeListener('destroyed', cleanup.onDestroyed) - } -} diff --git a/src/main/ipc/native-chat.test.ts b/src/main/ipc/native-chat.test.ts index 324d8422ed6..4d0e1765a44 100644 --- a/src/main/ipc/native-chat.test.ts +++ b/src/main/ipc/native-chat.test.ts @@ -65,7 +65,7 @@ async function invokeReadSession(args: { if (!handler) { throw new Error('handler not registered') } - return handler({ sender: { id: 1 } }, args) + return handler({}, args) } describe('nativeChat:readSession handler', () => { @@ -197,11 +197,6 @@ describe('nativeChat:readSession handler', () => { destroyedCb = cb } }, - removeListener: (event: string, cb: () => void) => { - if (event === 'destroyed' && destroyedCb === cb) { - destroyedCb = undefined - } - }, send: (channel: string, payload: unknown) => sent.push({ channel, payload }) } @@ -288,11 +283,6 @@ describe('nativeChat:readSession handler', () => { destroyedCb = cb } }, - removeListener: (event: string, cb: () => void) => { - if (event === 'destroyed' && destroyedCb === cb) { - destroyedCb = undefined - } - }, send: vi.fn() } diff --git a/src/main/ipc/native-chat.ts b/src/main/ipc/native-chat.ts index 65cc1bfac6f..6521bef3964 100644 --- a/src/main/ipc/native-chat.ts +++ b/src/main/ipc/native-chat.ts @@ -1,4 +1,4 @@ -import { ipcMain, type IpcMainEvent } from 'electron' +import { ipcMain, type IpcMainEvent, type WebContents } from 'electron' import type { AgentType, NativeChatMessage, @@ -11,17 +11,6 @@ import { readNativeChatTranscriptTail, type NativeChatTranscriptSubscription } from '../native-chat/transcript-watch' -import { - NATIVE_CHAT_TRANSCRIPT_PAGE_RESERVATION_BYTES, - nativeChatTranscriptReadAdmission -} from '../native-chat/transcript-read-admission' -import { - isValidNativeChatSubscriptionId, - isValidNativeChatTranscriptRequestStrings, - NativeChatReadAdmission, - normalizeNativeChatDesktopReadLimit -} from './native-chat-ipc-admission' -import { NativeChatSubscriptionRegistry } from './native-chat-subscription-registry' // Re-export so existing test imports of `clearNativeChatTranscriptCache` from // this module keep working after the cache moved to transcript-read-cache.ts. @@ -38,43 +27,20 @@ export type NativeChatReadSessionArgs = { transcriptPath?: string } -const readAdmission = new NativeChatReadAdmission() +// Why: render and parse only the recent window so long transcripts do not stall +// either the main process or the message list. Pagination raises this limit. +const DESKTOP_READ_WINDOW = 300 -async function readSession( - senderId: number, - args: NativeChatReadSessionArgs -): Promise { - if (!args || !isValidNativeChatTranscriptRequestStrings(args)) { - return { error: 'Invalid native chat transcript request' } - } - const readToken = readAdmission.claim(senderId) - if (!readToken) { - return { error: 'Too many concurrent native chat transcript reads' } - } - const { agent, sessionId, transcriptPath } = args - const limit = normalizeNativeChatDesktopReadLimit(args.limit) - let releaseMemory: (() => void) | undefined - try { - releaseMemory = await nativeChatTranscriptReadAdmission.acquire( - NATIVE_CHAT_TRANSCRIPT_PAGE_RESERVATION_BYTES - ) - return await readNativeChatTranscriptTail({ - agent, - sessionId, - transcriptPath, - limit - }) - } catch (error) { - if (!releaseMemory) { - return { - error: error instanceof Error ? error.message : 'Native chat transcript reader is busy' - } - } - throw error - } finally { - releaseMemory?.() - readAdmission.release(senderId, readToken) - } +async function readSession(args: NativeChatReadSessionArgs): Promise { + const { agent, sessionId } = args + // Clamp to a positive window; default to the desktop window for the first page. + const limit = args.limit && args.limit > 0 ? Math.floor(args.limit) : DESKTOP_READ_WINDOW + return readNativeChatTranscriptTail({ + agent, + sessionId, + transcriptPath: args.transcriptPath, + limit + }) } export type NativeChatSubscribeArgs = { @@ -111,28 +77,91 @@ export type NativeChatAppendedPayload = { } } -const subscriptionRegistry = new NativeChatSubscriptionRegistry() +type LiveSubscription = { + subscription: NativeChatTranscriptSubscription +} + +// Why: live subscriptions are keyed by (webContents.id, subscriptionId) so the +// same renderer can watch several panes, and a destroyed window tears down all +// of its watchers — strict teardown to avoid fd leaks (plan U4 risk). +const liveSubscriptions = new Map>() +// Why: unsubscribe and renderer destruction must invalidate async watcher setup +// before it can publish a late subscription into the live map. +const pendingSubscriptions = new Map>() +const senderCleanupRegistered = new Set() + +function teardownSubscription(senderId: number, subscriptionId: string): void { + const pendingBySubId = pendingSubscriptions.get(senderId) + pendingBySubId?.delete(subscriptionId) + if (pendingBySubId?.size === 0) { + pendingSubscriptions.delete(senderId) + } + const bySubId = liveSubscriptions.get(senderId) + const live = bySubId?.get(subscriptionId) + if (!live || !bySubId) { + return + } + live.subscription.unsubscribe() + bySubId.delete(subscriptionId) + if (bySubId.size === 0) { + liveSubscriptions.delete(senderId) + } +} + +function teardownAllForSender(senderId: number): void { + // The destroyed event can arrive before async subscription setup stores a watcher. + senderCleanupRegistered.delete(senderId) + pendingSubscriptions.delete(senderId) + const bySubId = liveSubscriptions.get(senderId) + if (!bySubId) { + return + } + for (const live of bySubId.values()) { + live.subscription.unsubscribe() + } + liveSubscriptions.delete(senderId) +} + +function registerSenderCleanup(sender: WebContents): void { + if (senderCleanupRegistered.has(sender.id)) { + return + } + senderCleanupRegistered.add(sender.id) + // Strict teardown: a closed/reloaded window releases every watcher it owns. + sender.once('destroyed', () => teardownAllForSender(sender.id)) +} + +function beginPendingSubscription(senderId: number, subscriptionId: string): symbol { + teardownSubscription(senderId, subscriptionId) + const token = Symbol(subscriptionId) + const bySubId = pendingSubscriptions.get(senderId) ?? new Map() + bySubId.set(subscriptionId, token) + pendingSubscriptions.set(senderId, bySubId) + return token +} + +function takePendingSubscription(senderId: number, subscriptionId: string, token: symbol): boolean { + const bySubId = pendingSubscriptions.get(senderId) + if (bySubId?.get(subscriptionId) !== token) { + return false + } + bySubId.delete(subscriptionId) + if (bySubId.size === 0) { + pendingSubscriptions.delete(senderId) + } + return true +} async function handleSubscribe(event: IpcMainEvent, args: NativeChatSubscribeArgs): Promise { const sender = event.sender - if ( - sender.isDestroyed() || - !args || - !isValidNativeChatSubscriptionId(args.subscriptionId) || - !isValidNativeChatTranscriptRequestStrings(args) || - !subscriptionRegistry.canAdmit(sender.id, args.subscriptionId) - ) { + if (sender.isDestroyed()) { return } const { subscriptionId, agent, sessionId, transcriptPath } = args - const setupAttempt = subscriptionRegistry.claimSetup(sender.id) - if (!setupAttempt) { - return - } - const limit = normalizeNativeChatDesktopReadLimit(args.limit) + const limit = args.limit && args.limit > 0 ? Math.floor(args.limit) : DESKTOP_READ_WINDOW // Replace any prior subscription under the same id (session change/resubscribe). - const pendingToken = subscriptionRegistry.beginPending(sender.id, subscriptionId) - subscriptionRegistry.registerSenderCleanup(sender) + const pendingToken = beginPendingSubscription(sender.id, subscriptionId) + registerSenderCleanup(sender) let subscription: NativeChatTranscriptSubscription try { @@ -189,21 +218,25 @@ async function handleSubscribe(event: IpcMainEvent, args: NativeChatSubscribeArg } }) } catch { - subscriptionRegistry.takePending(sender.id, subscriptionId, pendingToken) - subscriptionRegistry.releaseSenderCleanupIfIdle(sender.id) + takePendingSubscription(sender.id, subscriptionId, pendingToken) return - } finally { - subscriptionRegistry.releaseSetup(sender.id, setupAttempt) } // Why: unmount, destruction, or a newer same-id subscribe can invalidate setup // while path resolution is pending; only the owning token may publish its watcher. - const stillCurrent = subscriptionRegistry.takePending(sender.id, subscriptionId, pendingToken) + const stillCurrent = takePendingSubscription(sender.id, subscriptionId, pendingToken) if (sender.isDestroyed() || !stillCurrent) { subscription.unsubscribe() return } - subscriptionRegistry.publish(sender.id, subscriptionId, subscription) + const bySubId = liveSubscriptions.get(sender.id) ?? new Map() + // A concurrent subscribe with the same id beat us here; honor the latest. + const existing = bySubId.get(subscriptionId) + if (existing) { + existing.subscription.unsubscribe() + } + bySubId.set(subscriptionId, { subscription }) + liveSubscriptions.set(sender.id, bySubId) if (!subscription.watching && !sender.isDestroyed()) { const payload: NativeChatAppendedPayload = { subscriptionId, @@ -220,40 +253,34 @@ async function handleSubscribe(event: IpcMainEvent, args: NativeChatSubscribeArg /** Test-only: drop all live and pending transcript subscriptions between runs. */ export function clearNativeChatSubscriptions(): void { - subscriptionRegistry.reset() - readAdmission.reset() + const senderIds = new Set([...liveSubscriptions.keys(), ...pendingSubscriptions.keys()]) + for (const senderId of senderIds) { + teardownAllForSender(senderId) + } + pendingSubscriptions.clear() + senderCleanupRegistered.clear() } export function _getNativeChatSenderCleanupCountForTest(): number { - return subscriptionRegistry.cleanupCount + return senderCleanupRegistered.size } export function _getNativeChatPendingSubscriptionCountForTest(): number { - return subscriptionRegistry.pendingCount -} - -export function _getNativeChatLiveSubscriptionCountForTest(): number { - return subscriptionRegistry.logicalSubscriptionCount -} - -export function _getNativeChatSetupAttemptCountForTest(): number { - return subscriptionRegistry.setupAttemptCount -} - -export function _getNativeChatReadCountForTest(): number { - return readAdmission.size + let count = 0 + for (const bySubId of pendingSubscriptions.values()) { + count += bySubId.size + } + return count } export function registerNativeChatHandlers(): void { - ipcMain.handle('nativeChat:readSession', (event, args: NativeChatReadSessionArgs) => - readSession(event.sender.id, args) + ipcMain.handle('nativeChat:readSession', (_event, args: NativeChatReadSessionArgs) => + readSession(args) ) ipcMain.on('nativeChat:subscribe', (event, args: NativeChatSubscribeArgs) => { void handleSubscribe(event, args) }) ipcMain.on('nativeChat:unsubscribe', (event, args: { subscriptionId: string }) => { - if (args && isValidNativeChatSubscriptionId(args.subscriptionId)) { - subscriptionRegistry.teardown(event.sender.id, args.subscriptionId) - } + teardownSubscription(event.sender.id, args.subscriptionId) }) } diff --git a/src/main/ipc/native-notification-retention.ts b/src/main/ipc/native-notification-retention.ts deleted file mode 100644 index 13336035e25..00000000000 --- a/src/main/ipc/native-notification-retention.ts +++ /dev/null @@ -1,104 +0,0 @@ -import type { Notification } from 'electron' - -const NOTIFICATION_RELEASE_FALLBACK_MS = 5 * 60 * 1000 -export const MAX_ACTIVE_NATIVE_NOTIFICATIONS = 128 - -type RetainedNotification = { - evict: () => void - release: () => void -} - -export type RetainedNativeNotificationIdEntry = { - notification: Notification - release: () => void -} - -const activeNotifications = new Map() -const activeNotificationsById = new Map() - -export function retainNativeNotification( - notification: Notification, - onRelease?: () => void, - onEvict?: () => void, - options: { fallbackMs?: number | null } = {} -): () => void { - while (activeNotifications.size >= MAX_ACTIVE_NATIVE_NOTIFICATIONS) { - const oldest = activeNotifications.values().next() - if (oldest.done) { - break - } - oldest.value.evict() - } - - let released = false - let releaseTimer: ReturnType | null = null - - function release(): void { - if (released) { - return - } - released = true - activeNotifications.delete(notification) - notification.removeListener('close', release) - if (releaseTimer) { - clearTimeout(releaseTimer) - releaseTimer = null - } - onRelease?.() - } - - function evict(): void { - try { - onEvict?.() - } finally { - try { - notification.close() - } finally { - release() - } - } - } - - activeNotifications.set(notification, { evict, release }) - notification.on('close', release) - const fallbackMs = - options.fallbackMs === undefined ? NOTIFICATION_RELEASE_FALLBACK_MS : options.fallbackMs - if (fallbackMs !== null) { - releaseTimer = setTimeout(release, fallbackMs) - releaseTimer.unref?.() - } - return release -} - -export function getRetainedNativeNotificationById( - id: string -): RetainedNativeNotificationIdEntry | undefined { - return activeNotificationsById.get(id) -} - -export function setRetainedNativeNotificationId( - id: string, - entry: RetainedNativeNotificationIdEntry -): void { - activeNotificationsById.set(id, entry) -} - -export function deleteRetainedNativeNotificationId( - id: string, - expected: RetainedNativeNotificationIdEntry -): void { - if (activeNotificationsById.get(id) === expected) { - activeNotificationsById.delete(id) - } -} - -export function getActiveNativeNotificationCountForTest(): number { - return activeNotifications.size -} - -export function clearActiveNativeNotificationsForTest(): void { - for (const entry of Array.from(activeNotifications.values())) { - entry.evict() - } - activeNotificationsById.clear() -} diff --git a/src/main/ipc/notebook.test.ts b/src/main/ipc/notebook.test.ts index 78f1773d9c2..d68668038bd 100644 --- a/src/main/ipc/notebook.test.ts +++ b/src/main/ipc/notebook.test.ts @@ -89,23 +89,4 @@ describe('notebook IPC', () => { expect(processKillSpy).toHaveBeenCalledWith(-4321, 'SIGKILL') } }) - - it('preserves output delivered as 100,000 one-byte subprocess events', async () => { - const proc = createMockProcess() - spawnMock.mockReturnValue(proc) - registerNotebookHandlers({} as never) - - const resultPromise = handlers.get('notebook:runPythonCell')?.(null, { - filePath: '/repo/notebook.ipynb', - code: 'print("x")' - }) as Promise<{ stdout: string }> - await Promise.resolve() - - for (let index = 0; index < 100_000; index += 1) { - proc.stdout.emit('data', Buffer.from('x')) - } - proc.emit('close', 0) - - await expect(resultPromise).resolves.toMatchObject({ stdout: 'x'.repeat(100_000) }) - }) }) diff --git a/src/main/ipc/notebook.ts b/src/main/ipc/notebook.ts index 080a94e5ca2..9255ab7383d 100644 --- a/src/main/ipc/notebook.ts +++ b/src/main/ipc/notebook.ts @@ -4,7 +4,6 @@ import { dirname } from 'node:path' import { ipcMain } from 'electron' import type { Store } from '../persistence' import { resolveAuthorizedPath } from './filesystem-auth' -import { GrowingByteBuffer } from '../../shared/growing-byte-buffer' export type NotebookRunResult = { stdout: string @@ -17,9 +16,9 @@ const PYTHON_RUN_TIMEOUT_MS = 60_000 const MAX_CAPTURE_BYTES = 2 * 1024 * 1024 type BoundedCapture = { - buffer: GrowingByteBuffer + text: string + bytes: number truncated: boolean - truncationMarker: boolean } function pythonCandidates(): { command: string; argsPrefix: string[] }[] { @@ -39,23 +38,19 @@ function appendBounded(capture: BoundedCapture, chunk: Buffer): void { if (capture.truncated) { return } - const remainingBytes = MAX_CAPTURE_BYTES - capture.buffer.byteLength + const remainingBytes = MAX_CAPTURE_BYTES - capture.bytes if (remainingBytes <= 0) { capture.truncated = true return } if (chunk.byteLength <= remainingBytes) { - capture.buffer.append(chunk) + capture.text += chunk.toString('utf8') + capture.bytes += chunk.byteLength return } - capture.buffer.append(chunk.subarray(0, remainingBytes)) + capture.text += `${chunk.subarray(0, remainingBytes).toString('utf8')}\n[output truncated]\n` + capture.bytes = MAX_CAPTURE_BYTES capture.truncated = true - capture.truncationMarker = true -} - -function boundedCaptureText(capture: BoundedCapture): string { - const text = capture.buffer.toString() - return capture.truncationMarker ? `${text}\n[output truncated]\n` : text } function terminateNotebookProcessTree( @@ -122,16 +117,8 @@ async function runPythonCandidate( cwd: string ): Promise { return new Promise((resolve) => { - const stdout: BoundedCapture = { - buffer: new GrowingByteBuffer(), - truncated: false, - truncationMarker: false - } - const stderr: BoundedCapture = { - buffer: new GrowingByteBuffer(), - truncated: false, - truncationMarker: false - } + const stdout: BoundedCapture = { text: '', bytes: 0, truncated: false } + const stderr: BoundedCapture = { text: '', bytes: 0, truncated: false } let settled = false let forceKillTimer: ReturnType | null = null let timeout: ReturnType | null = null @@ -175,8 +162,8 @@ async function runPythonCandidate( forceKillTimer = terminateNotebookProcessTree(child) finish( { - stdout: boundedCaptureText(stdout), - stderr: boundedCaptureText(stderr), + stdout: stdout.text, + stderr: stderr.text, exitCode: null, error: 'Python cell timed out.' }, @@ -191,17 +178,12 @@ async function runPythonCandidate( appendBounded(stderr, chunk) } const onError = (error: Error): void => { - finish({ - stdout: boundedCaptureText(stdout), - stderr: boundedCaptureText(stderr), - exitCode: null, - error: error.message - }) + finish({ stdout: stdout.text, stderr: stderr.text, exitCode: null, error: error.message }) } const onClose = (exitCode: number | null): void => { finish({ - stdout: boundedCaptureText(stdout), - stderr: boundedCaptureText(stderr), + stdout: stdout.text, + stderr: stderr.text, exitCode }) } diff --git a/src/main/ipc/notification-ipc-admission.test.ts b/src/main/ipc/notification-ipc-admission.test.ts deleted file mode 100644 index a4a70a6f377..00000000000 --- a/src/main/ipc/notification-ipc-admission.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - MAX_NOTIFICATION_DISPATCH_INPUT_BYTES, - normalizeNotificationDispatchRequest -} from './notification-ipc-admission' - -describe('notification IPC admission', () => { - it('copies only whitelisted fields so unknown payloads are not retained', () => { - const request = normalizeNotificationDispatchRequest({ - source: 'agent-task-complete', - worktreeId: 'repo::worktree', - requireDisplayConfirmation: true, - unknownPayload: 'x'.repeat(MAX_NOTIFICATION_DISPATCH_INPUT_BYTES * 2) - }) - - expect(request).toEqual({ - source: 'agent-task-complete', - worktreeId: 'repo::worktree', - requireDisplayConfirmation: true - }) - expect(request).not.toHaveProperty('unknownPayload') - }) - - it('rejects non-boolean control fields', () => { - for (const field of [ - 'requireDisplayConfirmation', - 'hasMultipleActiveRepos', - 'isActiveWorktree', - 'agentInterrupted' - ]) { - expect( - normalizeNotificationDispatchRequest({ - source: 'agent-task-complete', - [field]: 'true' - }) - ).toBeNull() - } - }) - - it('enforces the aggregate UTF-8 budget across known string fields', () => { - expect( - normalizeNotificationDispatchRequest({ - source: 'agent-task-complete', - agentPrompt: '😀'.repeat(MAX_NOTIFICATION_DISPATCH_INPUT_BYTES / 8), - agentLastAssistantMessage: '😀'.repeat(MAX_NOTIFICATION_DISPATCH_INPUT_BYTES / 8) - }) - ).toBeNull() - }) -}) diff --git a/src/main/ipc/notification-ipc-admission.ts b/src/main/ipc/notification-ipc-admission.ts deleted file mode 100644 index ca99226daef..00000000000 --- a/src/main/ipc/notification-ipc-admission.ts +++ /dev/null @@ -1,122 +0,0 @@ -import type { NotificationDispatchRequest } from '../../shared/types' -import { measureUtf8ByteLength } from '../../shared/utf8-byte-limits' - -export const MAX_NOTIFICATION_DISPATCH_INPUT_BYTES = 256 * 1024 -export const MAX_NOTIFICATION_ID_BYTES = 8 * 1024 -export const MAX_NOTIFICATION_WORKTREE_ID_BYTES = 16 * 1024 -export const MAX_NOTIFICATION_PANE_KEY_BYTES = 8 * 1024 -export const MAX_NOTIFICATION_DISMISS_IDS = 256 -export const MAX_NOTIFICATION_DISMISS_SCAN_ENTRIES = 1_024 -export const MAX_NOTIFICATION_DISMISS_TOTAL_ID_BYTES = 256 * 1024 - -const STRING_FIELDS = [ - 'notificationId', - 'worktreeId', - 'paneKey', - 'repoLabel', - 'worktreeLabel', - 'terminalTitle', - 'agentType', - 'agentState', - 'agentPrompt', - 'agentToolName', - 'agentToolInput', - 'agentLastAssistantMessage' -] as const - -const BOOLEAN_FIELDS = [ - 'requireDisplayConfirmation', - 'hasMultipleActiveRepos', - 'isActiveWorktree', - 'agentInterrupted' -] as const - -function stringLimit(field: (typeof STRING_FIELDS)[number]): number { - if (field === 'notificationId') { - return MAX_NOTIFICATION_ID_BYTES - } - if (field === 'worktreeId') { - return MAX_NOTIFICATION_WORKTREE_ID_BYTES - } - if (field === 'paneKey') { - return MAX_NOTIFICATION_PANE_KEY_BYTES - } - return MAX_NOTIFICATION_DISPATCH_INPUT_BYTES -} - -export function normalizeNotificationDispatchRequest( - value: unknown -): NotificationDispatchRequest | null { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - return null - } - const request = value as Record - if ( - request.source !== 'agent-task-complete' && - request.source !== 'terminal-bell' && - request.source !== 'test' - ) { - return null - } - - const normalized: Record = { source: request.source } - let retainedBytes = measureUtf8ByteLength(request.source).byteLength - for (const field of STRING_FIELDS) { - const fieldValue = request[field] - if (fieldValue === undefined || fieldValue === null) { - continue - } - if (typeof fieldValue !== 'string') { - return null - } - const remaining = Math.min( - stringLimit(field), - MAX_NOTIFICATION_DISPATCH_INPUT_BYTES - retainedBytes - ) - const measured = measureUtf8ByteLength(fieldValue, { stopAfterBytes: remaining }) - if (measured.exceededLimit) { - return null - } - retainedBytes += measured.byteLength - normalized[field] = fieldValue - } - for (const field of BOOLEAN_FIELDS) { - const fieldValue = request[field] - if (fieldValue === undefined) { - continue - } - if (typeof fieldValue !== 'boolean') { - return null - } - normalized[field] = fieldValue - } - return normalized as NotificationDispatchRequest -} - -export function boundedUniqueNotificationDismissIds(value: unknown): string[] { - if (!Array.isArray(value)) { - return [] - } - const ids: string[] = [] - const unique = new Set() - let retainedBytes = 0 - const scanCount = Math.min(value.length, MAX_NOTIFICATION_DISMISS_SCAN_ENTRIES) - for (let index = 0; index < scanCount && ids.length < MAX_NOTIFICATION_DISMISS_IDS; index++) { - const id = value[index] - if (typeof id !== 'string' || id.length === 0 || unique.has(id)) { - continue - } - const remaining = Math.min( - MAX_NOTIFICATION_ID_BYTES, - MAX_NOTIFICATION_DISMISS_TOTAL_ID_BYTES - retainedBytes - ) - const measured = measureUtf8ByteLength(id, { stopAfterBytes: remaining }) - if (measured.exceededLimit) { - continue - } - unique.add(id) - ids.push(id) - retainedBytes += measured.byteLength - } - return ids -} diff --git a/src/main/ipc/notifications.test.ts b/src/main/ipc/notifications.test.ts index feb4bc798e0..d7930066ad7 100644 --- a/src/main/ipc/notifications.test.ts +++ b/src/main/ipc/notifications.test.ts @@ -1,6 +1,6 @@ /* eslint-disable max-lines */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { mkdtempSync, rmSync, truncateSync, writeFileSync } from 'node:fs' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -90,11 +90,6 @@ vi.mock('../tray/system-tray', () => ({ })) import { - clearActiveNativeNotificationsForTest, - getActiveNativeNotificationCountForTest, - MAX_ACTIVE_NATIVE_NOTIFICATIONS, - MAX_NOTIFICATION_DISMISS_IDS, - MAX_NOTIFICATION_ID_BYTES, registerNotificationHandlers, triggerStartupNotificationRegistration } from './notifications' @@ -110,7 +105,6 @@ describe('registerNotificationHandlers', () => { beforeEach(() => { vi.useFakeTimers() - clearActiveNativeNotificationsForTest() vi.setSystemTime(new Date('2026-03-28T16:00:00Z')) tempDir = mkdtempSync(join(tmpdir(), 'orca-notification-test-')) removeHandlerMock.mockReset() @@ -1081,124 +1075,6 @@ describe('registerNotificationHandlers', () => { expect(notificationShowMock).toHaveBeenCalledTimes(2) }) - it('evicts the oldest retained notification at the process cap and keeps the newest', async () => { - registerNotificationHandlers({ - getSettings: () => ({ - notifications: { - enabled: true, - agentTaskComplete: true, - terminalBell: true, - suppressWhenFocused: false - } - }) - } as never) - const dispatch = getDispatchHandler() - for (let index = 0; index <= MAX_ACTIVE_NATIVE_NOTIFICATIONS; index++) { - await dispatch({}, { source: 'test', notificationId: `notification-${index}` }) - } - - expect(getActiveNativeNotificationCountForTest()).toBe(MAX_ACTIVE_NATIVE_NOTIFICATIONS) - expect(notificationCloseMock).toHaveBeenCalledTimes(1) - const dismiss = getDismissHandler() - expect(dismiss({}, ['notification-0'])).toEqual({ dismissed: 0 }) - expect(dismiss({}, [`notification-${MAX_ACTIVE_NATIVE_NOTIFICATIONS}`])).toEqual({ - dismissed: 1 - }) - }) - - it('settles display confirmation when retention evicts its notification', async () => { - registerNotificationHandlers({ - getSettings: () => ({ - notifications: { - enabled: true, - agentTaskComplete: true, - terminalBell: true, - suppressWhenFocused: false - } - }) - } as never) - const dispatch = getDispatchHandler() - const confirmations = Array.from({ length: MAX_ACTIVE_NATIVE_NOTIFICATIONS + 1 }, (_, index) => - Promise.resolve( - dispatch( - {}, - { - source: 'test', - notificationId: `confirmation-${index}`, - requireDisplayConfirmation: true - } - ) - ) - ) - await vi.advanceTimersByTimeAsync(0) - - await expect(confirmations[0]).resolves.toEqual({ - delivered: false, - reason: 'not-displayed' - }) - expect(getActiveNativeNotificationCountForTest()).toBe(MAX_ACTIVE_NATIVE_NOTIFICATIONS) - expect(notificationRemoveListenerMock).toHaveBeenCalledWith('show', expect.any(Function)) - expect(notificationRemoveListenerMock).toHaveBeenCalledWith('failed', expect.any(Function)) - - clearActiveNativeNotificationsForTest() - await Promise.all(confirmations) - }) - - it('rejects oversized dispatch identifiers before native or mobile delivery', async () => { - const dispatchMobileNotification = vi.fn() - registerNotificationHandlers( - { - getSettings: () => ({ - notifications: { - enabled: true, - agentTaskComplete: true, - terminalBell: true, - suppressWhenFocused: false - } - }) - } as never, - { dispatchMobileNotification } as never - ) - - const result = await getDispatchHandler()( - {}, - { - source: 'agent-task-complete', - notificationId: '😀'.repeat(Math.floor(MAX_NOTIFICATION_ID_BYTES / 4) + 1) - } - ) - - expect(result).toEqual({ delivered: false, reason: 'invalid-request' }) - expect(notificationCtorMock).not.toHaveBeenCalled() - expect(dispatchMobileNotification).not.toHaveBeenCalled() - expect(setTrayAttentionMock).not.toHaveBeenCalled() - }) - - it('bounds dismiss deduplication and mobile fanout', () => { - const dismissMobileNotification = vi.fn() - registerNotificationHandlers( - { - getSettings: () => ({ - notifications: { - enabled: true, - agentTaskComplete: true, - terminalBell: true, - suppressWhenFocused: false - } - }) - } as never, - { dismissMobileNotification } as never - ) - const ids = Array.from( - { length: MAX_NOTIFICATION_DISMISS_IDS + 1 }, - (_, index) => `id-${index}` - ) - - expect(getDismissHandler()({}, ids)).toEqual({ dismissed: 0 }) - expect(dismissMobileNotification).toHaveBeenCalledTimes(MAX_NOTIFICATION_DISMISS_IDS) - expect(dismissMobileNotification).not.toHaveBeenCalledWith(`id-${MAX_NOTIFICATION_DISMISS_IDS}`) - }) - it('silences the native notification when a custom sound is configured', async () => { registerNotificationHandlers({ getSettings: () => ({ @@ -1450,26 +1326,6 @@ describe('registerNotificationHandlers', () => { }) }) - it('rejects a sparse custom sound above the bounded read limit', async () => { - const soundPath = join(tempDir, 'oversized.ogg') - writeFileSync(soundPath, '') - truncateSync(soundPath, 10 * 1024 * 1024 + 1) - registerNotificationHandlers({ - getSettings: () => ({ - notifications: { - enabled: true, - agentTaskComplete: true, - terminalBell: true, - suppressWhenFocused: false, - customSoundPath: soundPath - } - }) - } as never) - - const handler = getLoadSoundHandler() - await expect(handler({})).resolves.toEqual({ ok: false, reason: 'too-large' }) - }) - it('rejects unsupported custom sound file types', async () => { const soundPath = join(tempDir, 'sound.txt') writeFileSync(soundPath, 'not audio') @@ -1576,7 +1432,6 @@ describe('notifications:probeDelivery', () => { beforeEach(() => { vi.useFakeTimers() - clearActiveNativeNotificationsForTest() handleMock.mockReset() removeHandlerMock.mockReset() notificationCtorMock.mockClear() @@ -1739,7 +1594,6 @@ describe('triggerStartupNotificationRegistration', () => { beforeEach(() => { vi.useFakeTimers() - clearActiveNativeNotificationsForTest() vi.clearAllTimers() notificationCtorMock.mockClear() notificationShowMock.mockClear() diff --git a/src/main/ipc/notifications.ts b/src/main/ipc/notifications.ts index e2a55aa7836..95e9e3e3a47 100644 --- a/src/main/ipc/notifications.ts +++ b/src/main/ipc/notifications.ts @@ -1,5 +1,6 @@ /* eslint-disable max-lines -- Why: notification IPC keeps permission, dispatch, custom sound asset, and sound-loading handlers colocated so renderer/main contracts stay auditable. */ import { app, BrowserWindow, Notification, ipcMain, shell } from 'electron' +import { readFile, stat } from 'node:fs/promises' import { extname, isAbsolute, normalize } from 'node:path' import beepSoundPath from '../../../resources/notification-sounds/beep.mp3?asset' import blipSoundPath from '../../../resources/notification-sounds/blip.mp3?asset' @@ -27,40 +28,11 @@ import { readNotificationAuthorizationStatus } from './notification-authorizatio import { parsePaneKey } from '../../shared/stable-pane-id' import { setTrayAttention } from '../tray/system-tray' import { isMainWindowVisible } from '../window/main-window-visibility' -import { - NodeFileReadTooLargeError, - readNodeFileWithinLimit -} from '../../shared/node-bounded-file-reader' -import { - boundedUniqueNotificationDismissIds, - normalizeNotificationDispatchRequest -} from './notification-ipc-admission' -import { - deleteRetainedNativeNotificationId, - getRetainedNativeNotificationById, - retainNativeNotification, - setRetainedNativeNotificationId, - type RetainedNativeNotificationIdEntry -} from './native-notification-retention' - -export { - MAX_NOTIFICATION_DISMISS_IDS, - MAX_NOTIFICATION_DISMISS_SCAN_ENTRIES, - MAX_NOTIFICATION_DISMISS_TOTAL_ID_BYTES, - MAX_NOTIFICATION_DISPATCH_INPUT_BYTES, - MAX_NOTIFICATION_ID_BYTES, - MAX_NOTIFICATION_PANE_KEY_BYTES, - MAX_NOTIFICATION_WORKTREE_ID_BYTES -} from './notification-ipc-admission' -export { - clearActiveNativeNotificationsForTest, - getActiveNativeNotificationCountForTest, - MAX_ACTIVE_NATIVE_NOTIFICATIONS -} from './native-notification-retention' const NOTIFICATION_COOLDOWN_MS = 5000 const MAX_RECENT_NOTIFICATION_KEYS = 50 const NOTIFICATION_DISPLAY_CONFIRMATION_TIMEOUT_MS = 2500 +const NOTIFICATION_RELEASE_FALLBACK_MS = 5 * 60 * 1000 const MAX_NOTIFICATION_SOUND_BYTES = 10 * 1024 * 1024 const MACOS_PACKAGED_BUNDLE_ID = 'com.stablyai.orca' const MACOS_NOTIFICATION_SETTINGS_URL = @@ -86,6 +58,44 @@ const BUILT_IN_NOTIFICATION_SOUNDS: ReadonlyMap = new Map([ ]) type NotificationSoundId = NotificationSettings['customSoundId'] +// Why: keep a strong reference so GC can't collect notifications (and their click handlers) before the user interacts with them. +const activeNotifications = new Set() +const activeNotificationsById = new Map< + string, + { notification: Notification; release: () => void } +>() + +function retainNotificationUntilRelease( + notification: Notification, + onRelease?: () => void +): () => void { + activeNotifications.add(notification) + let released = false + let releaseTimer: ReturnType | null = null + + function release(): void { + if (released) { + return + } + released = true + activeNotifications.delete(notification) + notification.removeListener('close', release) + if (releaseTimer) { + clearTimeout(releaseTimer) + releaseTimer = null + } + onRelease?.() + } + + notification.on('close', release) + releaseTimer = setTimeout(release, NOTIFICATION_RELEASE_FALLBACK_MS) + if (typeof releaseTimer.unref === 'function') { + releaseTimer.unref() + } + + return release +} + const NOTIFICATION_PROBE_RESULT_TIMEOUT_MS = 3000 const NOTIFICATION_PROBE_BANNER_CLOSE_DELAY_MS = 4000 @@ -113,19 +123,20 @@ function probeNotificationDelivery(): Promise { body: 'Orca will alert you when agents finish or terminals need attention.', silent: true }) + activeNotifications.add(probe) deliveryProbeInFlight = new Promise((resolve) => { let settled = false let timeoutTimer: ReturnType | null = null - let closeTimer: ReturnType | null = null - let releaseRetention = (): void => {} function releaseProbe(): void { - releaseRetention() + activeNotifications.delete(probe) + probe.removeListener('show', onShow) + probe.removeListener('failed', onFailed) probe.close() } - function settle(state: 'delivered' | 'blocked', recordOutcome: boolean): void { + function settle(state: 'delivered' | 'blocked'): void { if (settled) { return } @@ -134,16 +145,14 @@ function probeNotificationDelivery(): Promise { clearTimeout(timeoutTimer) timeoutTimer = null } - if (recordOutcome) { - lastObservedDeliveryOutcome = state === 'delivered' ? 'delivered' : 'failed' - } + lastObservedDeliveryOutcome = state === 'delivered' ? 'delivered' : 'failed' resolve({ state, authoritative: false }) } function onShow(): void { - settle('delivered', true) + settle('delivered') // Why: the probe banner doubles as the user-facing confirmation, so let it linger briefly instead of vanishing instantly. - closeTimer = setTimeout(releaseProbe, NOTIFICATION_PROBE_BANNER_CLOSE_DELAY_MS) + const closeTimer = setTimeout(releaseProbe, NOTIFICATION_PROBE_BANNER_CLOSE_DELAY_MS) if (typeof closeTimer.unref === 'function') { closeTimer.unref() } @@ -151,34 +160,17 @@ function probeNotificationDelivery(): Promise { function onFailed(_event: unknown, _error?: string): void { // Why: a rejected probe is expected (denied permission); don't log — it would spam the console on every poll. - settle('blocked', true) + settle('blocked') releaseProbe() } - releaseRetention = retainNativeNotification( - probe, - () => { - probe.removeListener('show', onShow) - probe.removeListener('failed', onFailed) - if (timeoutTimer) { - clearTimeout(timeoutTimer) - timeoutTimer = null - } - if (closeTimer) { - clearTimeout(closeTimer) - closeTimer = null - } - settle('blocked', false) - }, - () => settle('blocked', false), - { fallbackMs: null } - ) probe.once('show', onShow) probe.once('failed', onFailed) // Why: don't record 'failed' on timeout — a missing callback is ambiguous, only the 'failed' event is definitive. timeoutTimer = setTimeout(() => { if (!settled) { - settle('blocked', false) + settled = true + resolve({ state: 'blocked', authoritative: false }) releaseProbe() } }, NOTIFICATION_PROBE_RESULT_TIMEOUT_MS) @@ -236,12 +228,8 @@ function getSelectedNotificationSoundPath(settings: NotificationSettings): { return { path: normalizedPath } } -function waitForNotificationDisplay(notification: Notification): { - cancel: () => void - result: Promise -} { - let cancel = (): void => {} - const result = new Promise((resolve) => { +function waitForNotificationDisplay(notification: Notification): Promise { + return new Promise((resolve) => { let settled = false let timer: ReturnType | null = null @@ -262,7 +250,6 @@ function waitForNotificationDisplay(notification: Notification): { cleanup() resolve(displayed) } - cancel = () => settle(false) function onShow(): void { settle(true) @@ -276,7 +263,6 @@ function waitForNotificationDisplay(notification: Notification): { notification.once('failed', onFailed) timer = setTimeout(() => settle(false), NOTIFICATION_DISPLAY_CONFIRMATION_TIMEOUT_MS) }) - return { cancel, result } } function logNativeNotificationFailure(context: string, error?: string): void { @@ -384,10 +370,12 @@ export function registerNotificationHandlers(store: Store, runtime?: OrcaRuntime ipcMain.removeHandler('notifications:dismiss') ipcMain.handle('notifications:dismiss', (_event, ids: string[]): NotificationDismissResult => { - const uniqueIds = boundedUniqueNotificationDismissIds(ids) + const uniqueIds = Array.from( + new Set(ids.filter((id): id is string => typeof id === 'string' && id.length > 0)) + ) let dismissed = 0 for (const id of uniqueIds) { - const entry = getRetainedNativeNotificationById(id) + const entry = activeNotificationsById.get(id) if (entry) { entry.notification.close() entry.release() @@ -403,13 +391,8 @@ export function registerNotificationHandlers(store: Store, runtime?: OrcaRuntime 'notifications:dispatch', ( _event, - request: unknown + args: NotificationDispatchRequest ): NotificationDispatchResult | Promise => { - const normalizedArgs = normalizeNotificationDispatchRequest(request) - if (!normalizedArgs) { - return { delivered: false, reason: 'invalid-request' } - } - const args: NotificationDispatchRequest = normalizedArgs // Why: light the tray attention dot before the cooldown/focus/enabled gates so they can't hold it back (clears on window show/restore; see index.ts). if (args.source === 'agent-task-complete' || args.source === 'terminal-bell') { const activeWindow = BrowserWindow.getAllWindows().find((win) => !win.isDestroyed()) ?? null @@ -482,7 +465,7 @@ export function registerNotificationHandlers(store: Store, runtime?: OrcaRuntime } const notification = new Notification(notificationOptions) if (args.notificationId) { - const previous = getRetainedNativeNotificationById(args.notificationId) + const previous = activeNotificationsById.get(args.notificationId) if (previous) { previous.notification.close() previous.release() @@ -492,13 +475,9 @@ export function registerNotificationHandlers(store: Store, runtime?: OrcaRuntime // Why: prevent GC from collecting the notification and its click handler while it's still visible. let clickHandler: (() => void) | null = null let failedHandler: ((_event: unknown, error?: string) => void) | null = null - let cancelDisplayConfirmation: (() => void) | null = null - const entryForId: RetainedNativeNotificationIdEntry | null = args.notificationId - ? { notification, release: () => {} } - : null - const release = retainNativeNotification(notification, () => { - cancelDisplayConfirmation?.() - cancelDisplayConfirmation = null + const entryForId: { notification: Notification; release: () => void } | null = + args.notificationId ? { notification, release: () => {} } : null + const release = retainNotificationUntilRelease(notification, () => { if (clickHandler) { notification.removeListener('click', clickHandler) clickHandler = null @@ -507,13 +486,16 @@ export function registerNotificationHandlers(store: Store, runtime?: OrcaRuntime notification.removeListener('failed', failedHandler) failedHandler = null } - if (args.notificationId && entryForId) { - deleteRetainedNativeNotificationId(args.notificationId, entryForId) + if ( + args.notificationId && + activeNotificationsById.get(args.notificationId) === entryForId + ) { + activeNotificationsById.delete(args.notificationId) } }) if (entryForId && args.notificationId) { entryForId.release = release - setRetainedNativeNotificationId(args.notificationId, entryForId) + activeNotificationsById.set(args.notificationId, entryForId) } failedHandler = (_event, error) => { @@ -564,12 +546,10 @@ export function registerNotificationHandlers(store: Store, runtime?: OrcaRuntime const displayConfirmation = args.requireDisplayConfirmation ? waitForNotificationDisplay(notification) : null - cancelDisplayConfirmation = displayConfirmation?.cancel ?? null notification.show() if (displayConfirmation) { - return displayConfirmation.result.then((displayed) => { - cancelDisplayConfirmation = null + return displayConfirmation.then((displayed) => { if (!displayed) { release() return { delivered: false, reason: 'not-displayed' } @@ -630,18 +610,17 @@ export function registerNotificationHandlers(store: Store, runtime?: OrcaRuntime } try { - const { buffer, stats } = await readNodeFileWithinLimit( - normalizedPath, - MAX_NOTIFICATION_SOUND_BYTES - ) - if (!stats.isFile()) { + const fileStat = await stat(normalizedPath) + if (!fileStat.isFile()) { return { ok: false, reason: 'invalid-path' } } - return { ok: true, data: new Uint8Array(buffer), mimeType, path: normalizedPath } - } catch (error) { - if (error instanceof NodeFileReadTooLargeError) { + if (fileStat.size > MAX_NOTIFICATION_SOUND_BYTES) { return { ok: false, reason: 'too-large' } } + + const data = await readFile(normalizedPath) + return { ok: true, data: new Uint8Array(data), mimeType, path: normalizedPath } + } catch { return { ok: false, reason: 'read-failed' } } }) @@ -668,10 +647,12 @@ export function triggerStartupNotificationRegistration(store: Store): void { body: 'Allow notifications so Orca can alert you when agents finish or terminals need attention.' }) + // Why: prevent GC from collecting the notification and its click handler while it's still visible. + activeNotifications.add(notification) + let handled = false let closeTimer: ReturnType | null = null let fallbackTimer: ReturnType | null = null - let releaseRetention = (): void => {} function clearStartupTimers(): void { if (closeTimer) { @@ -684,22 +665,16 @@ export function triggerStartupNotificationRegistration(store: Store): void { } } - function releaseState(): void { + function cleanup(): void { if (handled) { return } handled = true clearStartupTimers() + activeNotifications.delete(notification) notification.removeListener('click', onClick) notification.removeListener('show', onShow) notification.removeListener('failed', onFailed) - } - - function cleanup(): void { - if (handled) { - return - } - releaseRetention() notification.close() } @@ -727,9 +702,6 @@ export function triggerStartupNotificationRegistration(store: Store): void { notification.on('click', onClick) notification.on('show', onShow) notification.on('failed', onFailed) - releaseRetention = retainNativeNotification(notification, releaseState, undefined, { - fallbackMs: null - }) // Fallback in case macOS doesn't fire the 'show' event (e.g. user denies). fallbackTimer = setTimeout(cleanup, 10_000) diff --git a/src/main/ipc/pet-bundle.test.ts b/src/main/ipc/pet-bundle.test.ts index a9d25b11e77..096dd37e853 100644 --- a/src/main/ipc/pet-bundle.test.ts +++ b/src/main/ipc/pet-bundle.test.ts @@ -4,9 +4,9 @@ import { CODEX_PET_ANIMATIONS, CODEX_PET_FRAME, CODEX_PET_SPRITESHEET_PATH, - applyCodexPetDefaults + applyCodexPetDefaults, + readWebpDimensionsFromBuffer } from './pet-bundle' -import { readRasterImageDimensions } from '../../shared/raster-image-dimensions' function u32(value: number): Buffer { const buffer = Buffer.alloc(4) @@ -129,40 +129,15 @@ describe('applyCodexPetDefaults', () => { }) }) -describe('readRasterImageDimensions', () => { +describe('readWebpDimensionsFromBuffer', () => { it('reads VP8X WebP canvas dimensions without decoding pixels', () => { - expect(readRasterImageDimensions(webpVp8x(1536, 1872))).toEqual({ + expect(readWebpDimensionsFromBuffer(webpVp8x(1536, 1872))).toEqual({ width: 1536, height: 1872 }) }) it('returns null for non-WebP data', () => { - expect(readRasterImageDimensions(Buffer.from('not an image'))).toBeNull() - }) - - it('reads PNG and GIF dimensions without decoding pixels', () => { - const png = Buffer.alloc(24) - Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(png) - png.writeUInt32BE(13, 8) - png.write('IHDR', 12, 'ascii') - png.writeUInt32BE(8192, 16) - png.writeUInt32BE(512, 20) - const gif = Buffer.alloc(10) - gif.write('GIF89a', 0, 'ascii') - gif.writeUInt16LE(320, 6) - gif.writeUInt16LE(240, 8) - - expect(readRasterImageDimensions(png)).toEqual({ width: 8192, height: 512 }) - expect(readRasterImageDimensions(gif)).toEqual({ width: 320, height: 240 }) - }) - - it('reads JPEG dimensions without decoding pixels', () => { - const jpeg = Buffer.from([ - 0xff, 0xd8, 0xff, 0xc0, 0x00, 0x11, 0x08, 0x10, 0x00, 0x20, 0x00, 0x03, 0x01, 0x11, 0x00, - 0x02, 0x11, 0x00, 0x03, 0x11, 0x00 - ]) - - expect(readRasterImageDimensions(jpeg)).toEqual({ width: 8192, height: 4096 }) + expect(readWebpDimensionsFromBuffer(Buffer.from('not an image'))).toBeNull() }) }) diff --git a/src/main/ipc/pet-bundle.ts b/src/main/ipc/pet-bundle.ts index 6b39e393028..1b701a7a105 100644 --- a/src/main/ipc/pet-bundle.ts +++ b/src/main/ipc/pet-bundle.ts @@ -72,3 +72,64 @@ export function applyCodexPetDefaults( : codexAnimationsAtUniformFps(manifest.fps)) } } + +function readUInt24LE(buffer: Buffer, offset: number): number { + return buffer[offset] | (buffer[offset + 1] << 8) | (buffer[offset + 2] << 16) +} + +export function readWebpDimensionsFromBuffer( + buffer: Buffer +): { width: number; height: number } | null { + if ( + buffer.byteLength < 20 || + buffer.toString('ascii', 0, 4) !== 'RIFF' || + buffer.toString('ascii', 8, 12) !== 'WEBP' + ) { + return null + } + + let offset = 12 + while (offset + 8 <= buffer.byteLength) { + const chunkType = buffer.toString('ascii', offset, offset + 4) + const chunkSize = buffer.readUInt32LE(offset + 4) + const dataOffset = offset + 8 + const dataEnd = dataOffset + chunkSize + if (dataEnd > buffer.byteLength) { + return null + } + + if (chunkType === 'VP8X' && chunkSize >= 10) { + return { + width: readUInt24LE(buffer, dataOffset + 4) + 1, + height: readUInt24LE(buffer, dataOffset + 7) + 1 + } + } + + if (chunkType === 'VP8L' && chunkSize >= 5 && buffer[dataOffset] === 0x2f) { + const b0 = buffer[dataOffset + 1] + const b1 = buffer[dataOffset + 2] + const b2 = buffer[dataOffset + 3] + const b3 = buffer[dataOffset + 4] + return { + width: 1 + (((b1 & 0x3f) << 8) | b0), + height: 1 + (((b3 & 0x0f) << 10) | (b2 << 2) | ((b1 & 0xc0) >> 6)) + } + } + + if ( + chunkType === 'VP8 ' && + chunkSize >= 10 && + buffer[dataOffset + 3] === 0x9d && + buffer[dataOffset + 4] === 0x01 && + buffer[dataOffset + 5] === 0x2a + ) { + const width = buffer.readUInt16LE(dataOffset + 6) & 0x3fff + const height = buffer.readUInt16LE(dataOffset + 8) & 0x3fff + return width > 0 && height > 0 ? { width, height } : null + } + + offset = dataEnd + (chunkSize % 2) + } + + return null +} diff --git a/src/main/ipc/pet.test.ts b/src/main/ipc/pet.test.ts index d78b59f9ad5..5e857bb1244 100644 --- a/src/main/ipc/pet.test.ts +++ b/src/main/ipc/pet.test.ts @@ -8,12 +8,14 @@ const { browserWindowFromWebContentsMock, browserWindowGetFocusedWindowMock, handleMock, + nativeImageCreateFromBufferMock, showOpenDialogMock } = vi.hoisted(() => ({ appGetPathMock: vi.fn(), browserWindowFromWebContentsMock: vi.fn(), browserWindowGetFocusedWindowMock: vi.fn(), handleMock: vi.fn(), + nativeImageCreateFromBufferMock: vi.fn(), showOpenDialogMock: vi.fn() })) @@ -30,6 +32,9 @@ vi.mock('electron', () => ({ }, ipcMain: { handle: handleMock + }, + nativeImage: { + createFromBuffer: nativeImageCreateFromBufferMock } })) @@ -49,6 +54,7 @@ describe('registerPetHandlers', () => { browserWindowFromWebContentsMock.mockReset() browserWindowGetFocusedWindowMock.mockReset() handleMock.mockReset() + nativeImageCreateFromBufferMock.mockReset() showOpenDialogMock.mockReset() appGetPathMock.mockReturnValue(userDataDir) @@ -57,6 +63,10 @@ describe('registerPetHandlers', () => { handleMock.mockImplementation((channel, handler) => { handlers.set(channel, handler) }) + nativeImageCreateFromBufferMock.mockReturnValue({ + isEmpty: () => true, + getSize: () => ({ width: 0, height: 0 }) + }) }) afterEach(async () => { @@ -72,19 +82,9 @@ describe('registerPetHandlers', () => { return handler } - function pngHeader(width: number, height: number): Buffer { - const png = Buffer.alloc(24) - Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(png) - png.writeUInt32BE(13, 8) - png.write('IHDR', 12, 'ascii') - png.writeUInt32BE(width, 16) - png.writeUInt32BE(height, 20) - return png - } - it('imports a pet bundle whose manifest uses Windows separators', async () => { const bundleDir = join(tempDir, 'windows-export.codex-pet') - const sheetBytes = pngHeader(32, 24) + const sheetBytes = Buffer.from('not decoded without frame metadata') await mkdir(join(bundleDir, 'assets'), { recursive: true }) await writeFile( join(bundleDir, 'pet.json'), @@ -147,45 +147,6 @@ describe('registerPetHandlers', () => { return bundleDir } - it('rejects unsafe raster dimensions from a metadata-free bundle before import', async () => { - const bundleDir = join(tempDir, 'dimension-bomb.codex-pet') - await mkdir(bundleDir, { recursive: true }) - await writeFile( - join(bundleDir, 'pet.json'), - JSON.stringify({ - spritesheetPath: 'sheet.png' - }) - ) - await writeFile(join(bundleDir, 'sheet.png'), pngHeader(8_193, 512)) - showOpenDialogMock.mockResolvedValue({ canceled: false, filePaths: [bundleDir] }) - - await expect(getHandler('pet:importPetBundle')({ sender: {} })).rejects.toThrow( - 'exceed the safe limit' - ) - }) - - it('imports an under-limit legacy raster without changing its bytes', async () => { - const source = join(tempDir, 'safe-pet.png') - const sourceBytes = pngHeader(64, 48) - await writeFile(source, sourceBytes) - showOpenDialogMock.mockResolvedValue({ canceled: false, filePaths: [source] }) - - const result = (await getHandler('pet:import')({ sender: {} })) as CustomPet - - expect(result).toMatchObject({ kind: 'image', mimeType: 'image/png' }) - await expect( - readFile(join(userDataDir, 'sidekicks', 'custom', result.fileName)) - ).resolves.toEqual(sourceBytes) - }) - - it('rejects a legacy raster dimension bomb before copying it', async () => { - const source = join(tempDir, 'dimension-bomb.png') - await writeFile(source, pngHeader(32_768, 32_768)) - showOpenDialogMock.mockResolvedValue({ canceled: false, filePaths: [source] }) - - await expect(getHandler('pet:import')({ sender: {} })).rejects.toThrow('exceed the safe limit') - }) - it('imports a bundle whose animations declare per-frame durations', async () => { const bundleDir = await writeSpriteBundle({ idle: { row: 0, frames: 2, frameDurationsMs: [1680, 1920] } @@ -211,27 +172,4 @@ describe('registerPetHandlers', () => { 'declares 1 frame durations but 2 frames' ) }) - - it('reads a stored pet through the bounded file reader without changing its bytes', async () => { - const id = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee' - const fileName = `${id}.png` - const storedDir = join(userDataDir, 'sidekicks', 'custom') - await mkdir(storedDir, { recursive: true }) - const storedBytes = pngHeader(4, 4) - await writeFile(join(storedDir, fileName), storedBytes) - - const result = (await getHandler('pet:read')({}, id, fileName, 'image')) as ArrayBuffer - - expect(Buffer.from(result)).toEqual(storedBytes) - }) - - it('rejects a replaced legacy pet dimension bomb before renderer delivery', async () => { - const id = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee' - const fileName = `${id}.png` - const storedDir = join(userDataDir, 'sidekicks', 'custom') - await mkdir(storedDir, { recursive: true }) - await writeFile(join(storedDir, fileName), pngHeader(32_768, 32_768)) - - await expect(getHandler('pet:read')({}, id, fileName, 'image')).resolves.toBeNull() - }) }) diff --git a/src/main/ipc/pet.ts b/src/main/ipc/pet.ts index 497e42b89ad..df48249f001 100644 --- a/src/main/ipc/pet.ts +++ b/src/main/ipc/pet.ts @@ -1,19 +1,18 @@ /* eslint-disable max-lines */ -import { app, BrowserWindow, dialog, ipcMain } from 'electron' -import { copyFile, mkdir, open, rename, rm, stat, lstat } from 'node:fs/promises' +import { app, BrowserWindow, dialog, ipcMain, nativeImage } from 'electron' +import { copyFile, mkdir, open, readFile, rename, rm, stat, lstat } from 'node:fs/promises' import { constants as fsConstants, createWriteStream } from 'node:fs' import { pipeline } from 'node:stream/promises' import { randomUUID } from 'node:crypto' import { basename, dirname, extname, isAbsolute, join, normalize, resolve, sep } from 'node:path' import { z } from 'zod' import type { CustomPet } from '../../shared/types' -import { applyCodexPetDefaults, type PetManifestLike, type ResolvedPetManifest } from './pet-bundle' import { - MAX_CUSTOM_PET_FILE_BYTES, - isCustomPetSheetSizeSafe -} from '../../shared/custom-pet-media-limits' -import { readCustomPetFile } from './custom-pet-file-reader' -import { readRasterImageDimensions } from '../../shared/raster-image-dimensions' + applyCodexPetDefaults, + readWebpDimensionsFromBuffer, + type PetManifestLike, + type ResolvedPetManifest +} from './pet-bundle' // Why: pets are image-only — render natively via (no 3D engine); main owns this format allowlist. const IMAGE_FORMATS: Record = { @@ -40,6 +39,7 @@ function getPetsDir(): string { return join(app.getPath('userData'), 'sidekicks', 'custom') } +const MAX_BYTES = 64 * 1024 * 1024 // 64 MB — generous but bounded so a user can't point at a multi-GB file and OOM the renderer when it builds a Blob URL. const MAX_MANIFEST_BYTES = 64 * 1024 // pet.json is tiny by spec; cap to defend against a malicious bundle stuffing megabytes into the manifest. function isSafeId(id: string): boolean { @@ -121,24 +121,25 @@ const PetFileRequestSchema = z.object({ kind: z.enum(['image', 'bundle']).optional() }) -function assertCustomPetRasterDimensions( - data: ArrayBuffer, - extension: string, - label: 'Pet image' | 'Spritesheet' -): { width: number; height: number } | null { - if (extension === '.svg') { +async function readSheetDimensions( + buffer: Buffer +): Promise<{ width: number; height: number } | null> { + // Why: nativeImage can fail on some valid WebP that Chromium renders — read WebP dims from the header before native decode. + const webpDims = readWebpDimensionsFromBuffer(buffer) + if (webpDims) { + return webpDims + } + + // Why: nativeImage can't decode SVG (vector → no pixel grid) — pet bundles must use a raster sheet. + const image = nativeImage.createFromBuffer(buffer) + if (image.isEmpty()) { return null } - const dimensions = readRasterImageDimensions(new Uint8Array(data)) - if (!dimensions) { - throw new Error(`Could not decode the ${label.toLowerCase()}.`) + const size = image.getSize() + if (size.width <= 0 || size.height <= 0) { + return null } - if (!isCustomPetSheetSizeSafe(dimensions.width, dimensions.height)) { - throw new Error( - `${label} dimensions ${dimensions.width}×${dimensions.height} exceed the safe limit.` - ) - } - return dimensions + return { width: size.width, height: size.height } } // Why: TOCTOU symlink-swap defense — O_NOFOLLOW makes open() fail on a symlink; Windows lacks it, so fall back to copyFile. @@ -200,16 +201,11 @@ export function registerPetHandlers(): void { if (!srcStat.isFile()) { throw new Error('Selected path is not a file') } - if (srcStat.size > MAX_CUSTOM_PET_FILE_BYTES) { + if (srcStat.size > MAX_BYTES) { throw new Error( - `File is too large (${(srcStat.size / (1024 * 1024)).toFixed(1)} MB). Max is ${MAX_CUSTOM_PET_FILE_BYTES / (1024 * 1024)} MB.` + `File is too large (${(srcStat.size / (1024 * 1024)).toFixed(1)} MB). Max is ${MAX_BYTES / (1024 * 1024)} MB.` ) } - if (classified.ext !== '.svg') { - const sourceBytes = await readCustomPetFile(src) - // Why: renderer delivery would otherwise let Chromium decode a compressed dimension bomb first. - assertCustomPetRasterDimensions(sourceBytes, classified.ext, 'Pet image') - } const dir = getPetsDir() await mkdir(dir, { recursive: true }) @@ -274,9 +270,11 @@ export function registerPetHandlers(): void { let manifest: ResolvedPetManifest try { - const raw = Buffer.from(await readCustomPetFile(manifestPath, MAX_MANIFEST_BYTES)).toString( - 'utf8' - ) + const raw = await readFile(manifestPath, 'utf8') + // Why: defend against TOCTOU — the file may have grown between the stat check and this read. + if (Buffer.byteLength(raw, 'utf8') > MAX_MANIFEST_BYTES) { + throw new Error('pet.json exceeded the manifest size limit.') + } manifest = applyCodexPetDefaults(PetManifestSchema.parse(JSON.parse(raw))) } catch (error) { throw new Error(`Invalid pet.json: ${error instanceof Error ? error.message : 'parse error'}`) @@ -320,23 +318,24 @@ export function registerPetHandlers(): void { if (!sheetStat.isFile()) { throw new Error('Spritesheet path is not a file.') } - if (sheetStat.size > MAX_CUSTOM_PET_FILE_BYTES) { + if (sheetStat.size > MAX_BYTES) { throw new Error( `Spritesheet is too large (${(sheetStat.size / (1024 * 1024)).toFixed(1)} MB).` ) } - const sheetBytes = await readCustomPetFile(sheetSrc) - // Why: metadata-free bundles also reach the renderer image decoder, so every raster needs the same pre-decode gate. - const sheetDimensions = assertCustomPetRasterDimensions( - sheetBytes, - sheetClass.ext, - 'Spritesheet' - ) - let sprite: NonNullable | undefined if (manifest.frame) { - const dims = sheetDimensions! + // Why: only decode when a frame layout needs validating — nativeImage can fail on some WebP variants in headless contexts. + const sheetBuf = await readFile(sheetSrc) + // Why: defend against TOCTOU — file may have grown between stat and read. + if (sheetBuf.byteLength > MAX_BYTES) { + throw new Error('Spritesheet exceeded the size limit.') + } + const dims = await readSheetDimensions(sheetBuf) + if (!dims) { + throw new Error('Could not decode the spritesheet image.') + } const { width: fw, height: fh } = manifest.frame if (dims.width % fw !== 0 || dims.height % fh !== 0) { throw new Error( @@ -433,16 +432,8 @@ export function registerPetHandlers(): void { return null } try { - // Why: stored pet files can be replaced after import; the reader keeps - // one handle and never allocates beyond the same 64 MiB import limit. - const data = await readCustomPetFile(filePath) - const classified = classifyFile(filePath) - if (!classified) { - return null - } - // Why: this gates legacy image pets and metadata-free bundles before renderer decode. - assertCustomPetRasterDimensions(data, classified.ext, 'Pet image') - return data + const buf = await readFile(filePath) + return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) } catch (error) { console.warn('[pet-overlay] pet:read failed', error) return null diff --git a/src/main/ipc/pty-external-renderer-delivery.test.ts b/src/main/ipc/pty-external-renderer-delivery.test.ts deleted file mode 100644 index 8ed151c07fa..00000000000 --- a/src/main/ipc/pty-external-renderer-delivery.test.ts +++ /dev/null @@ -1,244 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const { handleMock, onMock, removeHandlerMock, removeAllListenersMock } = vi.hoisted(() => ({ - handleMock: vi.fn(), - onMock: vi.fn(), - removeHandlerMock: vi.fn(), - removeAllListenersMock: vi.fn() -})) - -vi.mock('electron', () => ({ - app: { - isPackaged: true, - getPath: () => '/tmp/orca-pty-external-delivery-test', - getVersion: () => '0.0.0-test' - }, - powerMonitor: { on: vi.fn() }, - nativeTheme: { shouldUseDarkColors: true }, - ipcMain: { - handle: handleMock, - on: onMock, - removeHandler: removeHandlerMock, - removeAllListeners: removeAllListenersMock - } -})) - -vi.mock('node-pty', () => ({ spawn: vi.fn() })) -vi.mock('../pwsh', () => ({ isPwshAvailable: vi.fn(() => false) })) - -import { LocalPtyProvider } from '../providers/local-pty-provider' -import { registerPtyHandlers, setLocalPtyProvider } from './pty' -import { - routeExternalPtyData, - routeExternalPtyExit, - routeExternalPtyReplay -} from './pty-renderer-delivery-router' - -describe('external PTY renderer delivery', () => { - const mainWindow = { - isDestroyed: () => false, - isFocused: () => true, - isVisible: () => true, - isMinimized: () => false, - webContents: { - on: vi.fn(), - send: vi.fn(), - removeListener: vi.fn(), - isLoadingMainFrame: vi.fn(() => true) - } - } - const mainWindowEvent = { sender: mainWindow.webContents } - - beforeEach(() => { - vi.useFakeTimers() - vi.clearAllMocks() - onMock.mockImplementation((channel: string, listener: (...args: unknown[]) => void) => { - if (channel === 'pty:rendererDispatcherReady') { - listener(mainWindowEvent) - vi.advanceTimersByTime(0) - } - }) - }) - - afterEach(() => { - setLocalPtyProvider(new LocalPtyProvider()) - vi.useRealTimers() - }) - - function findIpcListener(channel: string): (...args: never[]) => void { - const call = onMock.mock.calls.findLast(([registered]) => registered === channel) - if (!call) { - throw new Error(`Missing ${channel} listener`) - } - return call[1] as (...args: never[]) => void - } - - function makeRuntime(sequence = 17) { - return { - setPtyController: vi.fn(), - onPtyData: vi.fn(() => sequence), - getPtyOutputSequence: vi.fn(() => sequence), - hasRawTerminalViewSubscriber: vi.fn(() => false) - } - } - - it('returns captured SSH credit only after the renderer acknowledges parsing', () => { - const runtime = makeRuntime() - const upstreamCredit = { charCount: 9, acknowledge: vi.fn() } - registerPtyHandlers(mainWindow as never, runtime as never) - mainWindow.webContents.send.mockClear() - - routeExternalPtyData({ - id: 'ssh:target@@pty-1', - data: '', - sequenceChars: 9, - transformed: true, - upstreamCredit - }) - vi.advanceTimersByTime(2) - - expect(runtime.onPtyData).toHaveBeenCalledWith( - 'ssh:target@@pty-1', - '', - expect.any(Number), - 9, - true - ) - expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { - id: 'ssh:target@@pty-1', - data: '', - seq: 17, - rawLength: 9, - transformed: true - }) - expect(upstreamCredit.acknowledge).not.toHaveBeenCalled() - - findIpcListener('pty:ackData')( - mainWindowEvent as never, - { - id: 'ssh:target@@pty-1', - processedChars: 9 - } as never - ) - - expect(upstreamCredit.acknowledge).toHaveBeenCalledOnce() - expect(upstreamCredit.acknowledge).toHaveBeenCalledWith(9) - }) - - it('returns credit immediately when hidden output is intentionally dropped', () => { - const runtime = makeRuntime(42) - const upstreamCredit = { charCount: 13, acknowledge: vi.fn() } - registerPtyHandlers(mainWindow as never, runtime as never) - findIpcListener('pty:setHiddenRendererPty')( - null as never, - { - id: 'ssh:target@@pty-hidden', - hidden: true - } as never - ) - mainWindow.webContents.send.mockClear() - - routeExternalPtyData({ - id: 'ssh:target@@pty-hidden', - data: 'hidden output', - upstreamCredit - }) - - expect(runtime.onPtyData).toHaveBeenCalledOnce() - expect(upstreamCredit.acknowledge).toHaveBeenCalledWith(13) - expect(mainWindow.webContents.send).toHaveBeenCalledOnce() - expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:modelRestoreNeeded', { - id: 'ssh:target@@pty-hidden', - reason: 'hidden-drop', - markerSeq: 42 - }) - }) - - it('settles queued live credit when a reconnect replay supersedes it', () => { - const runtime = makeRuntime() - const upstreamCredit = { charCount: 11, acknowledge: vi.fn() } - registerPtyHandlers(mainWindow as never, runtime as never) - mainWindow.webContents.send.mockClear() - - routeExternalPtyData({ - id: 'ssh:target@@pty-replay', - data: 'queued live', - upstreamCredit - }) - routeExternalPtyReplay({ id: 'ssh:target@@pty-replay', data: 'full snapshot' }) - vi.advanceTimersByTime(10) - - expect(upstreamCredit.acknowledge).toHaveBeenCalledWith(11) - expect(mainWindow.webContents.send.mock.calls).toEqual([ - ['pty:replay', { id: 'ssh:target@@pty-replay', data: 'full snapshot' }] - ]) - }) - - it('writes off captured in-flight credit when the renderer lifecycle ends', () => { - const runtime = makeRuntime() - const upstreamCredit = { charCount: 7, acknowledge: vi.fn() } - registerPtyHandlers(mainWindow as never, runtime as never) - routeExternalPtyData({ - id: 'ssh:target@@pty-reload', - data: 'inflight', - upstreamCredit - }) - vi.advanceTimersByTime(2) - expect(upstreamCredit.acknowledge).not.toHaveBeenCalled() - - const lifecycleCall = mainWindow.webContents.on.mock.calls.findLast( - ([event]) => event === 'did-start-loading' - ) - const handleLifecycleReset = lifecycleCall?.[1] as (() => void) | undefined - expect(handleLifecycleReset).toBeTypeOf('function') - handleLifecycleReset?.() - - expect(upstreamCredit.acknowledge).toHaveBeenCalledWith(7) - }) - - it('settles queued final-tail credit when exit flushes before the batch timer', () => { - const runtime = makeRuntime() - const upstreamCredit = { charCount: 10, acknowledge: vi.fn() } - registerPtyHandlers(mainWindow as never, runtime as never) - mainWindow.webContents.send.mockClear() - - routeExternalPtyData({ - id: 'ssh:target@@pty-final-tail', - data: 'final tail', - upstreamCredit - }) - routeExternalPtyExit({ id: 'ssh:target@@pty-final-tail', code: 0 }) - - expect(upstreamCredit.acknowledge).toHaveBeenCalledWith(10) - expect(mainWindow.webContents.send.mock.calls).toEqual([ - [ - 'pty:data', - { - id: 'ssh:target@@pty-final-tail', - data: 'final tail', - seq: 17, - rawLength: 10 - } - ], - ['pty:exit', { id: 'ssh:target@@pty-final-tail', code: 0 }] - ]) - }) - - it('settles already-sent credit when exit arrives before renderer ACK', () => { - const runtime = makeRuntime() - const upstreamCredit = { charCount: 8, acknowledge: vi.fn() } - registerPtyHandlers(mainWindow as never, runtime as never) - - routeExternalPtyData({ - id: 'ssh:target@@pty-inflight-exit', - data: 'inflight', - upstreamCredit - }) - vi.advanceTimersByTime(2) - expect(upstreamCredit.acknowledge).not.toHaveBeenCalled() - - routeExternalPtyExit({ id: 'ssh:target@@pty-inflight-exit', code: 0 }) - - expect(upstreamCredit.acknowledge).toHaveBeenCalledWith(8) - }) -}) diff --git a/src/main/ipc/pty-renderer-delivery-credit.test.ts b/src/main/ipc/pty-renderer-delivery-credit.test.ts deleted file mode 100644 index af1d2bb8ed7..00000000000 --- a/src/main/ipc/pty-renderer-delivery-credit.test.ts +++ /dev/null @@ -1,180 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { - appendPtyDeliveryCredit, - MAX_PENDING_PTY_DELIVERY_CREDIT_SPANS, - MAX_RENDERER_PTY_DELIVERY_CREDIT_SPANS, - MAX_RENDERER_PTY_DELIVERY_CREDIT_SPANS_PER_PTY, - MAX_RENDERER_PTY_DELIVERY_CREDIT_STATE_ID_BYTES, - MAX_RENDERER_PTY_DELIVERY_CREDIT_STATES, - PtyRendererDeliveryCreditLedger, - settlePtyDeliveryCredit, - takePtyDeliveryCredit -} from './pty-renderer-delivery-credit' - -function makeCredit(charCount: number) { - return { charCount, acknowledge: vi.fn() } -} - -describe('PTY renderer delivery credit', () => { - it('splits queued provider credit without settling bytes left for a later renderer chunk', () => { - const first = makeCredit(5) - const second = makeCredit(7) - const spans = appendPtyDeliveryCredit(appendPtyDeliveryCredit(undefined, first), second) - - const taken = takePtyDeliveryCredit(spans, 8) - settlePtyDeliveryCredit(taken) - - expect(first.acknowledge).toHaveBeenCalledWith(5) - expect(second.acknowledge).toHaveBeenCalledWith(3) - expect(spans).toEqual([{ credit: second, chars: 4 }]) - }) - - it('settles explicit provider owners and legacy fallback spans in renderer order', () => { - const first = makeCredit(5) - const second = makeCredit(4) - const acknowledgeFallback = vi.fn() - const ledger = new PtyRendererDeliveryCreditLedger() - - ledger.recordSent('pty-1', 5, [{ credit: first, chars: 5 }], acknowledgeFallback) - ledger.recordSent('pty-1', 3, undefined, acknowledgeFallback) - ledger.recordSent('pty-1', 4, [{ credit: second, chars: 4 }], acknowledgeFallback) - ledger.acknowledge('pty-1', 7, acknowledgeFallback) - ledger.acknowledge('pty-1', 2, acknowledgeFallback) - ledger.acknowledge('pty-1', 3, acknowledgeFallback) - - expect(first.acknowledge).toHaveBeenCalledWith(5) - expect(second.acknowledge.mock.calls).toEqual([[1], [3]]) - expect(acknowledgeFallback.mock.calls).toEqual([[2], [1]]) - }) - - it('writes off every captured owner while keeping PTY fallback routing separate', () => { - const first = makeCredit(4) - const second = makeCredit(6) - const acknowledgeFallback = vi.fn() - const ledger = new PtyRendererDeliveryCreditLedger() - - ledger.recordSent('pty-1', 4, [{ credit: first, chars: 4 }], acknowledgeFallback) - ledger.recordSent('pty-2', 2, undefined, acknowledgeFallback) - ledger.recordSent('pty-3', 6, [{ credit: second, chars: 6 }], acknowledgeFallback) - ledger.writeOffAll(acknowledgeFallback) - - expect(first.acknowledge).toHaveBeenCalledWith(4) - expect(second.acknowledge).toHaveBeenCalledWith(6) - expect(acknowledgeFallback).toHaveBeenCalledOnce() - expect(acknowledgeFallback).toHaveBeenCalledWith('pty-2', 2) - }) - - it('abandons an exited PTY without crediting a replacement owner', () => { - const exited = makeCredit(4) - const acknowledgeFallback = vi.fn() - const ledger = new PtyRendererDeliveryCreditLedger() - ledger.recordSent('reused-id', 4, [{ credit: exited, chars: 4 }], acknowledgeFallback) - - ledger.abandon('reused-id') - ledger.acknowledge('reused-id', 4, acknowledgeFallback) - - expect(exited.acknowledge).not.toHaveBeenCalled() - expect(acknowledgeFallback).not.toHaveBeenCalled() - }) - - it('collapses one-byte pending credit floods into already-settled metadata', () => { - const total = MAX_PENDING_PTY_DELIVERY_CREDIT_SPANS + 100 - const credits = Array.from({ length: total }, () => makeCredit(1)) - let spans - for (const credit of credits) { - spans = appendPtyDeliveryCredit(spans, credit) - } - - expect(spans).toEqual([{ chars: total, settled: true }]) - expect(credits.every((credit) => credit.acknowledge.mock.calls.length === 1)).toBe(true) - - const ledger = new PtyRendererDeliveryCreditLedger() - const acknowledgeFallback = vi.fn() - const sent = takePtyDeliveryCredit(spans, total) - ledger.recordSent('pty-tiny', total, sent, acknowledgeFallback) - ledger.acknowledge('pty-tiny', total, acknowledgeFallback) - - expect(acknowledgeFallback).not.toHaveBeenCalled() - expect(credits.every((credit) => credit.acknowledge.mock.calls.length === 1)).toBe(true) - }) - - it('bounds per-PTY in-flight credit closures and settles each exactly once', () => { - const total = MAX_RENDERER_PTY_DELIVERY_CREDIT_SPANS_PER_PTY + 100 - const credits = Array.from({ length: total }, () => makeCredit(1)) - const acknowledgeFallback = vi.fn() - const ledger = new PtyRendererDeliveryCreditLedger() - - for (const credit of credits) { - ledger.recordSent('pty-tiny', 1, [{ credit, chars: 1 }], acknowledgeFallback) - } - - const state = (Reflect.get(ledger, 'inFlightByPty') as Map).get( - 'pty-tiny' - ) - expect(state?.spans.length).toBeLessThanOrEqual(MAX_RENDERER_PTY_DELIVERY_CREDIT_SPANS_PER_PTY) - ledger.acknowledge('pty-tiny', total, acknowledgeFallback) - - expect(acknowledgeFallback).not.toHaveBeenCalled() - expect(credits.every((credit) => credit.acknowledge.mock.calls.length === 1)).toBe(true) - }) - - it('bounds aggregate in-flight credit spans across PTYs', () => { - let acknowledged = 0 - const acknowledgeFallback = vi.fn() - const ledger = new PtyRendererDeliveryCreditLedger() - const total = MAX_RENDERER_PTY_DELIVERY_CREDIT_SPANS + 100 - - for (let index = 0; index < total; index += 1) { - const credit = { - charCount: 1, - acknowledge: (chars: number) => { - acknowledged += chars - } - } - ledger.recordSent(`pty-${index % 8}`, 1, [{ credit, chars: 1 }], acknowledgeFallback) - } - - expect(Reflect.get(ledger, 'retainedSpanCount')).toBeLessThanOrEqual( - MAX_RENDERER_PTY_DELIVERY_CREDIT_SPANS - ) - ledger.writeOffAll(acknowledgeFallback) - expect(acknowledged).toBe(total) - expect(acknowledgeFallback).not.toHaveBeenCalled() - }) - - it('bounds unique in-flight IDs and settles evicted credit exactly once', () => { - const credits = Array.from({ length: MAX_RENDERER_PTY_DELIVERY_CREDIT_STATES + 50 }, () => - makeCredit(1) - ) - const acknowledgeFallback = vi.fn() - const ledger = new PtyRendererDeliveryCreditLedger() - - for (const [index, credit] of credits.entries()) { - ledger.recordSent(`pty-${index}`, 1, [{ credit, chars: 1 }], acknowledgeFallback) - } - - const states = Reflect.get(ledger, 'inFlightByPty') as Map - expect(states.size).toBe(MAX_RENDERER_PTY_DELIVERY_CREDIT_STATES) - expect(states.has('pty-0')).toBe(false) - ledger.writeOffAll(acknowledgeFallback) - expect(credits.every((credit) => credit.acknowledge.mock.calls.length === 1)).toBe(true) - expect(acknowledgeFallback).not.toHaveBeenCalled() - }) - - it('bounds aggregate in-flight ID bytes and settles fallback before eviction', () => { - const id = 'x'.repeat(MAX_RENDERER_PTY_DELIVERY_CREDIT_STATE_ID_BYTES) - const settleFallback = vi.fn() - const ledger = new PtyRendererDeliveryCreditLedger() - - ledger.recordSent(id, 3, undefined, settleFallback) - ledger.recordSent('extra', 2, undefined, settleFallback) - - const states = Reflect.get(ledger, 'inFlightByPty') as Map - expect(states.size).toBe(1) - expect(states.has(id)).toBe(false) - expect(settleFallback).toHaveBeenCalledOnce() - expect(settleFallback).toHaveBeenCalledWith(3) - ledger.acknowledge(id, 3, settleFallback) - expect(settleFallback).toHaveBeenCalledOnce() - }) -}) diff --git a/src/main/ipc/pty-renderer-delivery-credit.ts b/src/main/ipc/pty-renderer-delivery-credit.ts deleted file mode 100644 index a6c0325cf64..00000000000 --- a/src/main/ipc/pty-renderer-delivery-credit.ts +++ /dev/null @@ -1,297 +0,0 @@ -import type { PtyDataUpstreamCredit } from '../providers/pty-provider-events' - -export const MAX_PENDING_PTY_DELIVERY_CREDIT_SPANS = 4096 -export const MAX_RENDERER_PTY_DELIVERY_CREDIT_SPANS_PER_PTY = 4096 -export const MAX_RENDERER_PTY_DELIVERY_CREDIT_SPANS = 16_384 -export const MAX_RENDERER_PTY_DELIVERY_CREDIT_STATES = 4096 -export const MAX_RENDERER_PTY_DELIVERY_CREDIT_STATE_ID_BYTES = 8 * 1024 * 1024 - -export type PtyDeliveryCreditSpan = - | { - credit: PtyDataUpstreamCredit - chars: number - settled?: never - } - | { - chars: number - settled: true - credit?: never - } - -type InFlightCreditSpan = { - chars: number - credit?: PtyDataUpstreamCredit - settled?: true -} - -type InFlightCreditState = { - idBytes: number - spans: InFlightCreditSpan[] - settledPrefixChars: number - settleFallback: (chars: number) => void -} - -export function appendPtyDeliveryCredit( - spans: PtyDeliveryCreditSpan[] | undefined, - credit: PtyDataUpstreamCredit | undefined -): PtyDeliveryCreditSpan[] | undefined { - if (!credit || !Number.isFinite(credit.charCount) || credit.charCount <= 0) { - return spans - } - const chars = Math.floor(credit.charCount) - const next = spans ?? [] - const tail = next.at(-1) - if (tail?.settled === true) { - credit.acknowledge(chars) - tail.chars += chars - return next - } - if (next.length >= MAX_PENDING_PTY_DELIVERY_CREDIT_SPANS) { - let settledChars = chars - for (const span of next) { - settledChars += span.chars - span.credit?.acknowledge(span.chars) - } - credit.acknowledge(chars) - next.splice(0, next.length, { chars: settledChars, settled: true }) - return next - } - next.push({ credit, chars }) - return next -} - -export function takePtyDeliveryCredit( - spans: PtyDeliveryCreditSpan[] | undefined, - requestedChars: number -): PtyDeliveryCreditSpan[] | undefined { - if (!spans || spans.length === 0 || requestedChars <= 0) { - return undefined - } - const taken: PtyDeliveryCreditSpan[] = [] - let remaining = requestedChars - while (remaining > 0 && spans.length > 0) { - const span = spans[0] - const chars = Math.min(remaining, span.chars) - taken.push(span.settled === true ? { chars, settled: true } : { credit: span.credit, chars }) - span.chars -= chars - remaining -= chars - if (span.chars === 0) { - spans.shift() - } - } - return taken -} - -export function settlePtyDeliveryCredit(spans: PtyDeliveryCreditSpan[] | undefined): void { - if (!spans) { - return - } - for (const span of spans.splice(0)) { - span.credit?.acknowledge(span.chars) - } -} - -export class PtyRendererDeliveryCreditLedger { - private readonly inFlightByPty = new Map() - private retainedSpanCount = 0 - private retainedStateIdBytes = 0 - - recordSent( - id: string, - charCount: number, - explicitCredit: PtyDeliveryCreditSpan[] | undefined, - settleFallback: (chars: number) => void - ): void { - if (!Number.isFinite(charCount) || charCount <= 0) { - return - } - const admittedCharCount = Math.floor(charCount) - const state = this.inFlightByPty.get(id) ?? { - idBytes: Buffer.byteLength(id, 'utf8'), - spans: [], - settledPrefixChars: 0, - settleFallback - } - const isNewState = !this.inFlightByPty.has(id) - state.settleFallback = settleFallback - let recorded = 0 - for (const span of explicitCredit ?? []) { - if (recorded >= admittedCharCount || !Number.isFinite(span.chars) || span.chars <= 0) { - break - } - const chars = Math.min(Math.floor(span.chars), admittedCharCount - recorded) - this.appendSpan( - state, - span.settled === true ? { chars, settled: true } : { chars, credit: span.credit } - ) - recorded += chars - } - if (recorded < admittedCharCount) { - this.appendSpan(state, { chars: admittedCharCount - recorded }) - } - this.inFlightByPty.delete(id) - this.inFlightByPty.set(id, state) - if (isNewState) { - this.retainedStateIdBytes += state.idBytes - } - this.capStates() - } - - acknowledge(id: string, charCount: number, acknowledgeFallback: (chars: number) => void): void { - const state = this.inFlightByPty.get(id) - if (!state || !Number.isFinite(charCount) || charCount <= 0) { - return - } - let remaining = Math.floor(charCount) - const settledPrefix = Math.min(remaining, state.settledPrefixChars) - state.settledPrefixChars -= settledPrefix - remaining -= settledPrefix - let fallbackChars = 0 - while (remaining > 0 && state.spans.length > 0) { - const span = state.spans[0] - const chars = Math.min(remaining, span.chars) - if (span.credit) { - span.credit.acknowledge(chars) - } else if (span.settled !== true) { - fallbackChars += chars - } - span.chars -= chars - remaining -= chars - if (span.chars === 0) { - state.spans.shift() - this.retainedSpanCount-- - } - } - if (state.spans.length === 0 && state.settledPrefixChars === 0) { - this.deleteState(id, state) - } - if (fallbackChars > 0) { - acknowledgeFallback(fallbackChars) - } - } - - writeOff(id: string, acknowledgeFallback: (chars: number) => void): void { - const state = this.inFlightByPty.get(id) - if (!state) { - return - } - this.acknowledge( - id, - state.settledPrefixChars + state.spans.reduce((total, span) => total + span.chars, 0), - acknowledgeFallback - ) - } - - writeOffAll(acknowledgeFallback: (id: string, chars: number) => void): void { - for (const id of Array.from(this.inFlightByPty.keys())) { - this.writeOff(id, (chars) => acknowledgeFallback(id, chars)) - } - } - - abandon(id: string): void { - const state = this.inFlightByPty.get(id) - if (!state) { - return - } - this.retainedSpanCount -= state.spans.length - this.deleteState(id, state) - } - - private appendSpan(state: InFlightCreditState, span: InFlightCreditSpan): void { - if (span.chars <= 0) { - return - } - let tail = state.spans.at(-1) - if (tail && this.canMergeSpans(tail, span)) { - tail.chars += span.chars - return - } - this.ensureSpanCapacity(state) - tail = state.spans.at(-1) - if (tail && this.canMergeSpans(tail, span)) { - tail.chars += span.chars - return - } - state.spans.push(span) - this.retainedSpanCount++ - } - - private capStates(): void { - while ( - this.inFlightByPty.size > MAX_RENDERER_PTY_DELIVERY_CREDIT_STATES || - this.retainedStateIdBytes > MAX_RENDERER_PTY_DELIVERY_CREDIT_STATE_ID_BYTES - ) { - const oldest = this.inFlightByPty.entries().next().value as - | [string, InFlightCreditState] - | undefined - if (!oldest) { - return - } - this.collapseState(oldest[1]) - this.deleteState(oldest[0], oldest[1]) - } - } - - private ensureSpanCapacity(state: InFlightCreditState): void { - if ( - state.spans.length >= MAX_RENDERER_PTY_DELIVERY_CREDIT_SPANS_PER_PTY || - this.retainedSpanCount >= MAX_RENDERER_PTY_DELIVERY_CREDIT_SPANS - ) { - this.collapseState(state) - } - while (this.retainedSpanCount >= MAX_RENDERER_PTY_DELIVERY_CREDIT_SPANS) { - const candidate = Array.from(this.inFlightByPty.values()).find( - (entry) => entry.spans.length > 0 - ) - if (!candidate) { - break - } - this.collapseState(candidate) - } - } - - private collapseState(state: InFlightCreditState): void { - if (state.spans.length === 0) { - return - } - let settledChars = 0 - let fallbackChars = 0 - for (const span of state.spans) { - settledChars += span.chars - try { - if (span.credit) { - span.credit.acknowledge(span.chars) - } else if (span.settled !== true) { - fallbackChars += span.chars - } - } catch { - // Why: overload shedding must still release local metadata if an upstream is gone. - } - } - if (fallbackChars > 0) { - try { - state.settleFallback(fallbackChars) - } catch { - // Why: overload shedding must still release local metadata if an upstream is gone. - } - } - state.settledPrefixChars += settledChars - this.retainedSpanCount -= state.spans.length - state.spans.length = 0 - } - - private deleteState(id: string, state: InFlightCreditState): void { - if (this.inFlightByPty.get(id) !== state) { - return - } - this.inFlightByPty.delete(id) - this.retainedStateIdBytes -= state.idBytes - } - - private canMergeSpans(left: InFlightCreditSpan, right: InFlightCreditSpan): boolean { - if (left.credit || right.credit) { - return left.credit !== undefined && left.credit === right.credit - } - return left.settled === right.settled - } -} diff --git a/src/main/ipc/pty-renderer-delivery-retention.test.ts b/src/main/ipc/pty-renderer-delivery-retention.test.ts deleted file mode 100644 index 3af40ac3401..00000000000 --- a/src/main/ipc/pty-renderer-delivery-retention.test.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { - MAX_PENDING_PTY_DATA_CHARS, - MAX_PENDING_PTY_DATA_CREDIT_SPANS, - MAX_PENDING_PTY_DATA_ID_BYTES, - MAX_PENDING_PTY_DATA_STATES, - MAX_RENDERER_PTY_DELIVERY_ACCOUNTING_ID_BYTES, - MAX_RENDERER_PTY_DELIVERY_ACCOUNTING_STATES, - PendingPtyDataMap, - PtyRendererDeliveryAccountingMap, - PtyRendererDeliveryIdSet, - settleRejectedPtyRendererDelivery -} from './pty-renderer-delivery-retention' - -type Pending = { - data: string - creditSpans: number -} - -function pendingMap(onRejected = vi.fn()) { - return { - map: new PendingPtyDataMap( - (pending) => ({ chars: pending.data.length, creditSpans: pending.creditSpans }), - onRejected - ), - onRejected - } -} - -describe('PTY renderer delivery retention', () => { - it('preserves ordinary pending updates and exact map bookkeeping', () => { - const { map, onRejected } = pendingMap() - - expect(map.admit('pty-1', { data: 'one', creditSpans: 1 })).toBe(true) - expect(map.admit('pty-1', { data: 'updated', creditSpans: 2 })).toBe(true) - expect(map.delete('pty-1')).toBe(true) - - expect(map.size).toBe(0) - expect(onRejected).not.toHaveBeenCalled() - }) - - it('rejects unique pending-ID floods after the bounded state count', () => { - const { map, onRejected } = pendingMap() - for (let index = 0; index < MAX_PENDING_PTY_DATA_STATES + 50; index += 1) { - map.set(`pty-${index}`, { data: 'x', creditSpans: 1 }) - } - - expect(map.size).toBe(MAX_PENDING_PTY_DATA_STATES) - expect(map.has('pty-0')).toBe(true) - expect(map.has(`pty-${MAX_PENDING_PTY_DATA_STATES}`)).toBe(false) - expect(onRejected).toHaveBeenCalledTimes(50) - }) - - it('bounds pending ID bytes, characters, and credit records independently', () => { - const idPressure = pendingMap() - const id = 'x'.repeat(MAX_PENDING_PTY_DATA_ID_BYTES) - idPressure.map.set(id, { data: 'x', creditSpans: 0 }) - idPressure.map.set('extra', { data: 'x', creditSpans: 0 }) - expect(idPressure.map.size).toBe(1) - expect(idPressure.onRejected).toHaveBeenCalledWith('extra', { - data: 'x', - creditSpans: 0 - }) - - const charPressure = pendingMap() - charPressure.map.set('full', { - data: 'x'.repeat(MAX_PENDING_PTY_DATA_CHARS), - creditSpans: 0 - }) - charPressure.map.set('extra', { data: 'x', creditSpans: 0 }) - expect(charPressure.map.size).toBe(1) - expect(charPressure.onRejected).toHaveBeenCalledOnce() - - const creditPressure = pendingMap() - creditPressure.map.set('full', { data: 'x', creditSpans: MAX_PENDING_PTY_DATA_CREDIT_SPANS }) - creditPressure.map.set('extra', { data: 'x', creditSpans: 1 }) - expect(creditPressure.map.size).toBe(1) - expect(creditPressure.onRejected).toHaveBeenCalledOnce() - }) - - it('bounds accounting and warning IDs without evicting admitted live state', () => { - const accounting = new PtyRendererDeliveryAccountingMap() - const warned = new PtyRendererDeliveryIdSet() - for (let index = 0; index < MAX_RENDERER_PTY_DELIVERY_ACCOUNTING_STATES + 50; index += 1) { - accounting.set(`pty-${index}`, index) - warned.add(`pty-${index}`) - } - - expect(accounting.size).toBe(MAX_RENDERER_PTY_DELIVERY_ACCOUNTING_STATES) - expect(warned.size).toBe(MAX_RENDERER_PTY_DELIVERY_ACCOUNTING_STATES) - expect(accounting.has('pty-0')).toBe(true) - expect(accounting.has(`pty-${MAX_RENDERER_PTY_DELIVERY_ACCOUNTING_STATES}`)).toBe(false) - expect(warned.has(`pty-${MAX_RENDERER_PTY_DELIVERY_ACCOUNTING_STATES}`)).toBe(false) - - const byteBounded = new PtyRendererDeliveryAccountingMap() - byteBounded.set('x'.repeat(MAX_RENDERER_PTY_DELIVERY_ACCOUNTING_ID_BYTES), 1) - expect(byteBounded.admit('extra', 2)).toBe(false) - expect(byteBounded.size).toBe(1) - }) - - it('settles rejected explicit, already-settled, and fallback credit exactly once', () => { - const explicit = vi.fn() - const fallback = vi.fn() - - settleRejectedPtyRendererDelivery( - 7, - [ - { chars: 2, settled: true }, - { chars: 3, credit: { charCount: 3, acknowledge: explicit } } - ], - fallback - ) - - expect(explicit).toHaveBeenCalledOnce() - expect(explicit).toHaveBeenCalledWith(3) - expect(fallback).toHaveBeenCalledOnce() - expect(fallback).toHaveBeenCalledWith(2) - }) -}) diff --git a/src/main/ipc/pty-renderer-delivery-retention.ts b/src/main/ipc/pty-renderer-delivery-retention.ts deleted file mode 100644 index 71efeb1f0fa..00000000000 --- a/src/main/ipc/pty-renderer-delivery-retention.ts +++ /dev/null @@ -1,188 +0,0 @@ -import { - MAX_RENDERER_PTY_DELIVERY_CREDIT_STATE_ID_BYTES, - MAX_RENDERER_PTY_DELIVERY_CREDIT_STATES, - type PtyDeliveryCreditSpan -} from './pty-renderer-delivery-credit' - -export const MAX_PENDING_PTY_DATA_STATES = MAX_RENDERER_PTY_DELIVERY_CREDIT_STATES -export const MAX_PENDING_PTY_DATA_ID_BYTES = MAX_RENDERER_PTY_DELIVERY_CREDIT_STATE_ID_BYTES -export const MAX_PENDING_PTY_DATA_CHARS = 32 * 1024 * 1024 -export const MAX_PENDING_PTY_DATA_CREDIT_SPANS = 16_384 -export const MAX_RENDERER_PTY_DELIVERY_ACCOUNTING_STATES = MAX_RENDERER_PTY_DELIVERY_CREDIT_STATES -export const MAX_RENDERER_PTY_DELIVERY_ACCOUNTING_ID_BYTES = - MAX_RENDERER_PTY_DELIVERY_CREDIT_STATE_ID_BYTES - -type PendingPtyDataMeasurement = { - chars: number - creditSpans: number -} - -export class PendingPtyDataMap extends Map { - private readonly measurements = new Map() - private retainedChars = 0 - private retainedCreditSpans = 0 - private retainedIdBytes = 0 - - constructor( - private readonly measure: (value: T) => PendingPtyDataMeasurement, - private readonly onRejected: (id: string, value: T) => void - ) { - super() - } - - admit(id: string, value: T): boolean { - const hadPrevious = super.has(id) - const previousMeasurement = this.measurements.get(id) ?? { chars: 0, creditSpans: 0 } - const measurement = this.measure(value) - const idBytes = Buffer.byteLength(id, 'utf8') - const nextSize = this.size + (hadPrevious ? 0 : 1) - const nextIdBytes = this.retainedIdBytes + (hadPrevious ? 0 : idBytes) - const nextChars = this.retainedChars - previousMeasurement.chars + measurement.chars - const nextCreditSpans = - this.retainedCreditSpans - previousMeasurement.creditSpans + measurement.creditSpans - - if ( - nextSize > MAX_PENDING_PTY_DATA_STATES || - nextIdBytes > MAX_PENDING_PTY_DATA_ID_BYTES || - nextChars > MAX_PENDING_PTY_DATA_CHARS || - nextCreditSpans > MAX_PENDING_PTY_DATA_CREDIT_SPANS - ) { - if (hadPrevious) { - this.delete(id) - } - this.onRejected(id, value) - return false - } - - super.set(id, value) - this.measurements.set(id, measurement) - this.retainedChars = nextChars - this.retainedCreditSpans = nextCreditSpans - this.retainedIdBytes = nextIdBytes - return true - } - - override set(id: string, value: T): this { - this.admit(id, value) - return this - } - - override delete(id: string): boolean { - const measurement = this.measurements.get(id) ?? { chars: 0, creditSpans: 0 } - if (!super.delete(id)) { - return false - } - this.measurements.delete(id) - this.retainedChars -= measurement.chars - this.retainedCreditSpans -= measurement.creditSpans - this.retainedIdBytes -= Buffer.byteLength(id, 'utf8') - return true - } - - override clear(): void { - super.clear() - this.measurements.clear() - this.retainedChars = 0 - this.retainedCreditSpans = 0 - this.retainedIdBytes = 0 - } -} - -export class PtyRendererDeliveryAccountingMap extends Map { - private retainedIdBytes = 0 - - admit(id: string, value: T): boolean { - if (super.has(id)) { - super.set(id, value) - return true - } - const idBytes = Buffer.byteLength(id, 'utf8') - if ( - this.size >= MAX_RENDERER_PTY_DELIVERY_ACCOUNTING_STATES || - this.retainedIdBytes + idBytes > MAX_RENDERER_PTY_DELIVERY_ACCOUNTING_ID_BYTES - ) { - return false - } - super.set(id, value) - this.retainedIdBytes += idBytes - return true - } - - override set(id: string, value: T): this { - this.admit(id, value) - return this - } - - override delete(id: string): boolean { - if (!super.delete(id)) { - return false - } - this.retainedIdBytes -= Buffer.byteLength(id, 'utf8') - return true - } - - override clear(): void { - super.clear() - this.retainedIdBytes = 0 - } -} - -export class PtyRendererDeliveryIdSet extends Set { - private retainedIdBytes = 0 - - remember(id: string): boolean { - if (super.has(id)) { - return false - } - const idBytes = Buffer.byteLength(id, 'utf8') - if ( - this.size >= MAX_RENDERER_PTY_DELIVERY_ACCOUNTING_STATES || - this.retainedIdBytes + idBytes > MAX_RENDERER_PTY_DELIVERY_ACCOUNTING_ID_BYTES - ) { - return false - } - super.add(id) - this.retainedIdBytes += idBytes - return true - } - - override add(id: string): this { - this.remember(id) - return this - } - - override delete(id: string): boolean { - if (!super.delete(id)) { - return false - } - this.retainedIdBytes -= Buffer.byteLength(id, 'utf8') - return true - } - - override clear(): void { - super.clear() - this.retainedIdBytes = 0 - } -} - -export function settleRejectedPtyRendererDelivery( - charCount: number, - creditSpans: PtyDeliveryCreditSpan[] | undefined, - settleFallback: (chars: number) => void -): void { - if (!Number.isFinite(charCount) || charCount <= 0) { - return - } - let remaining = Math.floor(charCount) - for (const span of creditSpans ?? []) { - if (remaining <= 0 || !Number.isFinite(span.chars) || span.chars <= 0) { - continue - } - const chars = Math.min(remaining, Math.floor(span.chars)) - remaining -= chars - span.credit?.acknowledge(chars) - } - if (remaining > 0) { - settleFallback(remaining) - } -} diff --git a/src/main/ipc/pty-renderer-delivery-router.ts b/src/main/ipc/pty-renderer-delivery-router.ts deleted file mode 100644 index 1cffa26bb5f..00000000000 --- a/src/main/ipc/pty-renderer-delivery-router.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { PtyDataEvent } from '../providers/pty-provider-events' - -export type ExternalPtyRendererDeliveryRouter = { - data(payload: PtyDataEvent): void - replay(payload: { id: string; data: string }): void - exit(payload: { id: string; code: number }): void -} - -function returnUnroutedCredit(payload: PtyDataEvent): void { - payload.upstreamCredit?.acknowledge(payload.upstreamCredit.charCount) -} - -let router: ExternalPtyRendererDeliveryRouter = { - data: returnUnroutedCredit, - replay: () => {}, - exit: () => {} -} - -export function installExternalPtyRendererDeliveryRouter( - nextRouter: ExternalPtyRendererDeliveryRouter -): void { - router = nextRouter -} - -export function routeExternalPtyData(payload: PtyDataEvent): void { - router.data(payload) -} - -export function routeExternalPtyReplay(payload: { id: string; data: string }): void { - router.replay(payload) -} - -export function routeExternalPtyExit(payload: { id: string; code: number }): void { - router.exit(payload) -} diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 54a1851d30d..2f3acb3c842 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -11,8 +11,6 @@ import { redactPtyIdForDiagnostics } from '../../shared/pty-delivery-diagnostics import { FLOATING_TERMINAL_WORKTREE_ID } from '../../shared/constants' import type { TuiAgent } from '../../shared/types' import type { AgentSessionOwnerBinding } from '../../shared/agent-session-host-authority' -import { MAX_CLAIMED_AGENT_PTY_OWNER_ENTRIES } from '../../shared/claimed-agent-pty-owner' -import type * as NodeBoundedFileReader from '../../shared/node-bounded-file-reader' const isWindowsHost = process.platform === 'win32' const posixOnlyIt = isWindowsHost ? it.skip : it @@ -128,21 +126,6 @@ vi.mock('fs', () => ({ } })) -vi.mock('../../shared/node-bounded-file-reader', async (importOriginal) => ({ - ...(await importOriginal()), - readNodeFileSyncWithinLimit: (path: string, maxBytes: number) => { - const content = readFileSyncMock(path) - if (typeof content !== 'string' && !Buffer.isBuffer(content)) { - throw new Error('File unavailable') - } - const buffer = Buffer.isBuffer(content) ? content : Buffer.from(content) - if (buffer.byteLength > maxBytes) { - throw new Error('File too large') - } - return { buffer, stats: { size: buffer.byteLength } } - } -})) - vi.mock('node-pty', () => ({ spawn: spawnMock })) @@ -1036,52 +1019,6 @@ describe('registerPtyHandlers', () => { clearProviderPtyState(owner.ptyId) }) - it('fails claimed ensure closed before aggregate owner listings amplify memory', async () => { - const ownersPerSession = 256 - const sessionCount = Math.floor(MAX_CLAIMED_AGENT_PTY_OWNER_ENTRIES / ownersPerSession) + 1 - const sessions = Array.from({ length: sessionCount }, (_, sessionIndex) => { - const id = `pty-owner-cap-${sessionIndex}` - return { - id, - incarnationId: 'incarnation-owner-cap', - cwd: '/tmp/recovered-worktree', - title: 'Codex', - agentSessionOwners: Array.from({ length: ownersPerSession }, (_, ownerIndex) => { - const index = sessionIndex * ownersPerSession + ownerIndex - return { - claim: { - ...recoveredAgentClaim, - identityDigest: index.toString(36).padStart(43, 'a') - }, - generation: `generation-owner-cap-${index}`, - phase: 'live' as const, - ptyId: id, - surface: recoveredAgentSurface - } - }) - } - }) - const provider = createAgentClaimProvider({ sessions }) - setLocalPtyProvider(provider as never) - const controller = registerAgentClaimController() - - await expect( - controller.spawn({ - cols: 80, - rows: 24, - cwd: '/tmp/recovered-worktree', - agentSessionEnsure: { - claim: { - ...recoveredAgentClaim, - identityDigest: '7777777777777777777777777777777777777777777' - }, - surface: recoveredAgentSurface - } - }) - ).rejects.toThrow('execution_owner_unavailable') - expect(provider.spawn).not.toHaveBeenCalled() - }) - it('releases an adopted-owner fence when that owner exits during admission', async () => { const incarnationId = 'incarnation-adopted-exit' const owner: AgentSessionOwnerBinding = { diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index ea528aca371..5f23193c3ee 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -57,18 +57,12 @@ import { piTitlebarExtensionService } from '../pi/titlebar-extension-service' import { detectPiAgentKindFromCommand, type PiAgentKind } from '../../shared/pi-agent-kind' import { isPwshAvailable } from '../pwsh' import { LocalPtyProvider } from '../providers/local-pty-provider' -import type { - IPtyProvider, - PtyProcessInfo, - PtySpawnOptions, - PtySpawnResult -} from '../providers/types' +import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../providers/types' import { inspectPtyProviderProcess } from '../providers/pty-process-inspection' import { PtyProcessListAdmission, visitPtyProcessListingsInBatches } from '../providers/pty-process-list-admission' -import type { PtyDataEvent } from '../providers/pty-provider-events' import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery' import { SSH_SESSION_EXPIRED_ERROR, @@ -179,20 +173,6 @@ import { import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import { resolveLocalProjectRuntimeForWorktreeId } from '../local-project-runtime-resolution' import { isPtyIncarnationId } from '../../shared/pty-incarnation' -import { - appendPtyDeliveryCredit, - PtyRendererDeliveryCreditLedger, - settlePtyDeliveryCredit, - takePtyDeliveryCredit, - type PtyDeliveryCreditSpan -} from './pty-renderer-delivery-credit' -import { - PendingPtyDataMap, - PtyRendererDeliveryAccountingMap, - PtyRendererDeliveryIdSet, - settleRejectedPtyRendererDelivery -} from './pty-renderer-delivery-retention' -import { installExternalPtyRendererDeliveryRouter } from './pty-renderer-delivery-router' // ─── Provider Registry ────────────────────────────────────────────── // Routes PTY operations by connectionId (null = local provider). @@ -202,7 +182,19 @@ type FreshLocalFallbackProvider = IPtyProvider & { routesFreshSpawnsToLocalProvider?: true } const sshProviders = new Map() -export const MAX_REGISTERED_SSH_PTY_PROVIDERS = 256 + +type RegisteredPtyProvider = { + provider: IPtyProvider + connectionId: string | null +} + +function registeredPtyProviders(): RegisteredPtyProvider[] { + return [ + { provider: localProvider, connectionId: null }, + ...Array.from(sshProviders, ([connectionId, provider]) => ({ provider, connectionId })) + ] +} + const SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS = 30_000 // Why: kill switch — flip to disable producer flow control (pause/resume) without untangling the wiring. const PRODUCER_FLOW_CONTROL_ENABLED = true @@ -274,18 +266,6 @@ const paneSpawnReservationsByPaneKey = new Map() const agentSessionOwners = new ClaimedAgentPtyOwnerRegistry() let agentSessionOwnerReconciliation: Promise | null = null -type RegisteredPtyProvider = { - provider: IPtyProvider - connectionId: string | null -} - -function registeredPtyProviders(): RegisteredPtyProvider[] { - return [ - { provider: localProvider, connectionId: null }, - ...Array.from(sshProviders, ([connectionId, provider]) => ({ provider, connectionId })) - ] -} - function assertSpawnReplyWasLive(result: PtySpawnResult): void { if (!result.exitedBeforeSpawnReply) { return @@ -301,35 +281,39 @@ async function reconcileAgentSessionOwnerListings(): Promise { return await agentSessionOwnerReconciliation } const reconciliation = (async () => { - const admission = new PtyProcessListAdmission('execution_owner_unavailable') + const providers: { provider: IPtyProvider; connectionId: string | null }[] = [ + { provider: localProvider, connectionId: null }, + ...Array.from(sshProviders, ([connectionId, provider]) => ({ provider, connectionId })) + ] + const listings = await Promise.all( + providers.map(async ({ provider, connectionId }) => ({ + connectionId, + sessions: await provider.listProcesses() + })) + ) const advertisedOwners: AgentSessionOwnerBinding[] = [] const advertisedOwnerSessions: { id: string connectionId: string | null incarnationId: string }[] = [] - await visitPtyProcessListingsInBatches( - registeredPtyProviders(), - ({ provider }) => provider.listProcesses(), - ({ connectionId }, sessions) => { - for (const rawSession of sessions) { - const session = admission.admit(rawSession) - const incarnationId = session.incarnationId - let hasAdvertisedOwner = false - for (const owner of session.agentSessionOwners ?? []) { - if (owner.ptyId !== session.id || !isPtyIncarnationId(incarnationId)) { - // Why: a recovered claim without process-incarnation proof cannot safely reject a delayed exit. - throw new Error('agent_session_ownership_unknown') - } - advertisedOwners.push(owner) - hasAdvertisedOwner = true - } - if (hasAdvertisedOwner && isPtyIncarnationId(incarnationId)) { - advertisedOwnerSessions.push({ id: session.id, connectionId, incarnationId }) + for (const { connectionId, sessions } of listings) { + for (const session of sessions) { + const incarnationId = session.incarnationId + let hasAdvertisedOwner = false + for (const owner of session.agentSessionOwners ?? []) { + if (owner.ptyId !== session.id || !isPtyIncarnationId(incarnationId)) { + // Why: a recovered claim without process-incarnation proof cannot safely reject a delayed exit. + throw new Error('agent_session_ownership_unknown') } + advertisedOwners.push(owner) + hasAdvertisedOwner = true + } + if (hasAdvertisedOwner && isPtyIncarnationId(incarnationId)) { + advertisedOwnerSessions.push({ id: session.id, connectionId, incarnationId }) } } - ) + } agentSessionOwners.reconcileAuthoritative(advertisedOwners, { // Why: an unregistered relay can still own a live PTY during reconnect; // only providers that serialize claims may make listing absence authoritative. @@ -1226,9 +1210,6 @@ function beginPtySpawnForWorktree( /** Register an SSH PTY provider for a connection. */ export function registerSshPtyProvider(connectionId: string, provider: IPtyProvider): void { - if (!sshProviders.has(connectionId) && sshProviders.size >= MAX_REGISTERED_SSH_PTY_PROVIDERS) { - throw new Error('ssh_pty_provider_capacity') - } sshProviders.set(connectionId, provider) } @@ -1730,7 +1711,6 @@ export function registerPtyHandlers( containsBackgroundOutput?: boolean // Why droppedOutput (not main's droppedBacklog trim): this branch's drop-to-sentinel + snapshot-restore supersedes #7630's 2MB-tail trim; both would race two cap policies over one buffer. droppedOutput?: true - upstreamCreditSpans?: PtyDeliveryCreditSpan[] } type PtyDataPayload = { @@ -1743,15 +1723,9 @@ export function registerPtyHandlers( droppedOutput?: boolean } - const pendingData = new PendingPtyDataMap( - (pending) => ({ - chars: pending.data.length, - creditSpans: pending.upstreamCreditSpans?.length ?? 0 - }), - rejectPendingPtyDataForRetentionPressure - ) + const pendingData = new Map() // Why: one restore marker per overflow episode — cleared on full drain so a later overflow re-marks exactly once. - const pendingOverflowMarkedPtys = new PtyRendererDeliveryIdSet() + const pendingOverflowMarkedPtys = new Set() // Why: TCP-style cumulative accounting — monotonic sent/acked totals self-heal on any later ACK, where relative in-flight counters would make each lost ACK a permanent debt. type RendererPtyDeliveryAccounting = { sentChars: number @@ -1759,9 +1733,7 @@ export function registerPtyHandlers( lastSendAtMs: number lastAckAtMs: number | null } - const rendererDeliveryAccountingByPty = - new PtyRendererDeliveryAccountingMap() - const rendererDeliveryCredit = new PtyRendererDeliveryCreditLedger() + const rendererDeliveryAccountingByPty = new Map() const trustedTerminalHandleEnv = new Set() let flushTimer: ReturnType | null = null let rendererInFlightTotalChars = 0 @@ -2032,17 +2004,12 @@ export function registerPtyHandlers( rendererLifecycleResetCount += 1 // Why release before clearing: pending bytes and credits belonged to the dead page; releasing producer pauses first keeps no shell wedged. producerFlowControl.releaseAll() - settleAllPendingUpstreamCredit() - rendererDeliveryCredit.writeOffAll((id, chars) => { - tryGetProviderForPty(id)?.acknowledgeDataEvent(id, chars) - }) clearDeliveryResyncProbe() deliveryResyncUnansweredWarnLogged = false rendererDeliveryAccountingByPty.clear() rendererInFlightTotalChars = 0 pendingData.clear() pendingOverflowMarkedPtys.clear() - pendingDataDropWarnedPtys.clear() // Why hold sends: the reloading page's pty:data listener is gone until it re-registers/handshakes, so bytes would drop into a listener-less page and re-pin the gate. rendererPtyDispatcherReady = false // Why: arm the self-heal watchdog so a never-arriving handshake can't hold the gate forever; the real handshake cancels it. @@ -2133,9 +2100,6 @@ export function registerPtyHandlers( accounting.ackedChars = nextAckedChars if (acknowledged > 0) { accounting.lastAckAtMs = Date.now() - rendererDeliveryCredit.acknowledge(id, acknowledged, (chars) => { - tryGetProviderForPty(id)?.acknowledgeDataEvent(id, chars) - }) } rendererInFlightTotalChars = Math.max(0, rendererInFlightTotalChars - acknowledged) return acknowledged @@ -2196,11 +2160,11 @@ export function registerPtyHandlers( if (acknowledged <= 0) { continue } + tryGetProviderForPty(id)?.acknowledgeDataEvent(id, acknowledged) // Why drop pending: everything at/before markerSeq comes from the snapshot, so flushing pre-marker bytes would double-paint the restore. const pending = pendingData.get(id) if (pending) { pendingDroppedChars += pending.data.length - settlePendingPtyDataDelivery(id, pending) pendingData.delete(id) pendingOverflowMarkedPtys.delete(id) updateProducerFlowControl(id) @@ -2229,30 +2193,20 @@ export function registerPtyHandlers( return writtenOff } - function sendPtyDataToRenderer( - id: string, - payload: PtyDataPayload, - upstreamCredit?: PtyDeliveryCreditSpan[] - ): void { + function sendPtyDataToRenderer(id: string, payload: PtyDataPayload): void { const charCount = getPtyPayloadCharCount(payload) - let accounting = rendererDeliveryAccountingByPty.get(id) - if (!accounting) { - accounting = { - sentChars: 0, + const accounting = rendererDeliveryAccountingByPty.get(id) + if (accounting) { + accounting.sentChars += charCount + accounting.lastSendAtMs = Date.now() + } else { + rendererDeliveryAccountingByPty.set(id, { + sentChars: charCount, ackedChars: 0, lastSendAtMs: Date.now(), lastAckAtMs: null - } - if (!rendererDeliveryAccountingByPty.admit(id, accounting)) { - rejectSentPtyDataForRetentionPressure(id, payload, upstreamCredit) - return - } + }) } - rendererDeliveryCredit.recordSent(id, charCount, upstreamCredit, (chars) => { - tryGetProviderForPty(id)?.acknowledgeDataEvent(id, chars) - }) - accounting.sentChars += charCount - accounting.lastSendAtMs = Date.now() rendererInFlightTotalChars += charCount recordPtyRendererDeliveryPressure() mainWindow.webContents.send('pty:data', payload) @@ -2315,47 +2269,7 @@ export function registerPtyHandlers( return [...active, ...background] } - const pendingDataDropWarnedPtys = new PtyRendererDeliveryIdSet() - - function markPtyRendererDeliveryDropped(id: string, droppedChars: number, detail: string): void { - pendingDroppedChars += droppedChars - if (pendingOverflowMarkedPtys.remember(id)) { - sendModelRestoreNeededMarker(id, 'pending-cap', runtime?.getPtyOutputSequence(id)) - } - if (!pendingDataDropWarnedPtys.remember(id)) { - return - } - console.error(`[pty] dropped ${droppedChars} buffered chars: ${detail}`) - recordCrashBreadcrumb('terminal_pending_output_dropped', { - droppedChars, - capChars: pendingDataCapChars() - }) - } - - function rejectPendingPtyDataForRetentionPressure(id: string, pending: PendingPtyData): void { - settlePendingPtyDataDelivery(id, pending) - producerFlowControl.release(id) - markPtyRendererDeliveryDropped( - id, - pending.data.length, - 'aggregate renderer pending-output retention limit exceeded; pane will restore from the main-owned snapshot' - ) - } - - function rejectSentPtyDataForRetentionPressure( - id: string, - payload: PtyDataPayload, - upstreamCredit: PtyDeliveryCreditSpan[] | undefined - ): void { - settleRejectedPtyRendererDelivery(getPtyPayloadCharCount(payload), upstreamCredit, (chars) => - tryGetProviderForPty(id)?.acknowledgeDataEvent(id, chars) - ) - markPtyRendererDeliveryDropped( - id, - payload.data.length, - 'aggregate renderer delivery-accounting limit exceeded; pane will restore from the main-owned snapshot' - ) - } + const pendingDataDropWarnedPtys = new Set() // Why capped: keeps O(1) memory per PTY; salvaged query bytes are tiny, so past the cap a pathological stream can degrade to the plain sentinel. const DROPPED_QUERY_SALVAGE_MAX_CHARS = 4096 @@ -2374,7 +2288,8 @@ export function registerPtyHandlers( if (pending.droppedOutput === true || pending.data.length <= capChars) { return pending } - if (pendingDataDropWarnedPtys.remember(id)) { + if (!pendingDataDropWarnedPtys.has(id)) { + pendingDataDropWarnedPtys.add(id) console.error( `[pty] dropped ${pending.data.length} buffered chars for ${id}: renderer not receiving and per-PTY pending cap exceeded; pane will restore from the main-owned snapshot` ) @@ -2385,11 +2300,11 @@ export function registerPtyHandlers( }) } // Why the marker: the snapshot can recover the dropped middle; emit it once per overflow episode so a fresh or reloaded view latches restore too. - if (isHiddenPtyDeliveryGateEnabled(getSettings?.()) && pendingOverflowMarkedPtys.remember(id)) { + if (isHiddenPtyDeliveryGateEnabled(getSettings?.()) && !pendingOverflowMarkedPtys.has(id)) { + pendingOverflowMarkedPtys.add(id) sendModelRestoreNeededMarker(id, 'pending-cap', runtime?.getPtyOutputSequence(id)) } pendingDroppedChars += pending.data.length - settlePendingPtyDataDelivery(id, pending) // Why no trimmed content tail: a mid-stream gap would corrupt the pane; the droppedOutput sentinel repaints from the snapshot and realigns by sequence (only query bytes ride along). return { data: extractDroppedPtyQueryBytes(pending.data).slice(0, DROPPED_QUERY_SALVAGE_MAX_CHARS), @@ -2405,16 +2320,10 @@ export function registerPtyHandlers( preservesSeq: boolean, containsBackgroundOutput: boolean, rawLength = data.length, - transformed = false, - upstreamCredit?: PtyDataEvent['upstreamCredit'] + transformed = false ): PendingPtyData { // Why stay dropped at O(1): once over the cap the restore sentinel supersedes interim bytes; queries still get carved out (bounded) so replies survive the whole episode. if (existing?.droppedOutput === true) { - settleRejectedPtyRendererDelivery( - rawLength, - appendPtyDeliveryCredit(undefined, upstreamCredit), - (chars) => tryGetProviderForPty(id)?.acknowledgeDataEvent(id, chars) - ) if (existing.data.length >= DROPPED_QUERY_SALVAGE_MAX_CHARS) { return existing } @@ -2429,24 +2338,16 @@ export function registerPtyHandlers( ...(typeof startSeq === 'number' ? { startSeq } : {}), ...(rawLength !== data.length ? { rawLength } : {}), ...(transformed ? { transformed: true } : {}), - ...(nextContainsBackgroundOutput ? { containsBackgroundOutput: true } : {}), - ...(upstreamCredit - ? { upstreamCreditSpans: appendPtyDeliveryCredit(undefined, upstreamCredit) } - : {}) + ...(nextContainsBackgroundOutput ? { containsBackgroundOutput: true } : {}) }) } const existingRawLength = existing.rawLength ?? existing.data.length - const upstreamCreditSpans = appendPtyDeliveryCredit( - existing.upstreamCreditSpans, - upstreamCredit - ) const next: PendingPtyData = { data: existing.data + data, ...(!preservesSeq || existing.transformed || transformed ? { rawLength: existingRawLength + rawLength, transformed: true as const } : {}), - ...(nextContainsBackgroundOutput ? { containsBackgroundOutput: true } : {}), - ...(upstreamCreditSpans ? { upstreamCreditSpans } : {}) + ...(nextContainsBackgroundOutput ? { containsBackgroundOutput: true } : {}) } if (typeof existing.startSeq === 'number') { next.startSeq = existing.startSeq @@ -2491,14 +2392,9 @@ export function registerPtyHandlers( if (mainWindow.isDestroyed()) { // Why release now: bookkeeping is being wiped, so no future drain can resume these producers — local shells would wedge. producerFlowControl.releaseAll() - settleAllPendingUpstreamCredit() - rendererDeliveryCredit.writeOffAll((id, chars) => { - tryGetProviderForPty(id)?.acknowledgeDataEvent(id, chars) - }) clearDeliveryResyncProbe() pendingData.clear() pendingOverflowMarkedPtys.clear() - pendingDataDropWarnedPtys.clear() rendererDeliveryAccountingByPty.clear() rendererInFlightTotalChars = 0 clearDispatcherReadyWatchdog() @@ -2518,7 +2414,6 @@ export function registerPtyHandlers( // Why drop, never re-queue: the model already ingested hidden-gated bytes; reveal restores from the snapshot+seq machinery. if (shouldDropHiddenRendererPtyData(id, settings)) { pendingData.delete(id) - settlePendingPtyDataDelivery(id, pending) pendingOverflowMarkedPtys.delete(id) updateProducerFlowControl(id) const drop = recordHiddenRendererPtyDataDrop(id, pending.data.length) @@ -2543,8 +2438,6 @@ export function registerPtyHandlers( const indivisible = pending.transformed === true const chunk = indivisible ? data : data.slice(0, PTY_BATCH_FLUSH_CHUNK_CHARS) const remaining = indivisible ? '' : data.slice(PTY_BATCH_FLUSH_CHUNK_CHARS) - const chunkRawLength = indivisible ? (pending.rawLength ?? chunk.length) : chunk.length - const upstreamCredit = takePtyDeliveryCredit(pending.upstreamCreditSpans, chunkRawLength) if (remaining) { const nextPending: PendingPtyData = { data: remaining } if (typeof pending.startSeq === 'number') { @@ -2553,9 +2446,6 @@ export function registerPtyHandlers( if (pending.containsBackgroundOutput === true) { nextPending.containsBackgroundOutput = true } - if (pending.upstreamCreditSpans?.length) { - nextPending.upstreamCreditSpans = pending.upstreamCreditSpans - } pendingData.set(id, nextPending) } else { pendingOverflowMarkedPtys.delete(id) @@ -2570,8 +2460,7 @@ export function registerPtyHandlers( pending.containsBackgroundOutput, pending.rawLength, pending.transformed - ), - upstreamCredit + ) ) writes++ } @@ -2593,24 +2482,6 @@ export function registerPtyHandlers( flushTimer = null } - function settleAllPendingUpstreamCredit(): void { - for (const [id, pending] of pendingData) { - settlePendingPtyDataDelivery(id, pending) - } - } - - function settlePendingPtyDataDelivery(id: string, pending: PendingPtyData): void { - if (pending.droppedOutput === true) { - settlePtyDeliveryCredit(pending.upstreamCreditSpans) - return - } - settleRejectedPtyRendererDelivery( - pending.rawLength ?? pending.data.length, - pending.upstreamCreditSpans, - (chars) => tryGetProviderForPty(id)?.acknowledgeDataEvent(id, chars) - ) - } - const syntheticKillExitPtyIds = new Map() const reversibleStopOwnersByPtyId = new Map() @@ -2639,27 +2510,12 @@ export function registerPtyHandlers( function sendPtyExitToRenderer(payload: { id: string; code: number }): void { if (mainWindow.isDestroyed()) { - const pending = pendingData.get(payload.id) - if (pending) { - settlePendingPtyDataDelivery(payload.id, pending) - } - pendingData.delete(payload.id) - pendingDataDropWarnedPtys.delete(payload.id) - rendererDeliveryCredit.writeOff(payload.id, (chars) => { - tryGetProviderForPty(payload.id)?.acknowledgeDataEvent(payload.id, chars) - }) - rendererInFlightTotalChars = Math.max( - 0, - rendererInFlightTotalChars - getRendererInFlightCharsForPty(payload.id) - ) - rendererDeliveryAccountingByPty.delete(payload.id) return } // Why flush before exit: the renderer tears down the terminal on pty:exit, so any batched output not yet flushed would be silently lost. const remaining = pendingData.get(payload.id) if (remaining) { if (remaining.droppedOutput === true) { - settlePendingPtyDataDelivery(payload.id, remaining) // Sentinel entry: only salvaged query bytes remain; keep the flag so the renderer knows the span was dropped. sendPtyDataToRenderer(payload.id, { id: payload.id, @@ -2667,11 +2523,6 @@ export function registerPtyHandlers( droppedOutput: true }) } else { - const upstreamCredit = takePtyDeliveryCredit( - remaining.upstreamCreditSpans, - remaining.rawLength ?? remaining.data.length - ) - settlePtyDeliveryCredit(remaining.upstreamCreditSpans) sendPtyDataToRenderer( payload.id, makePtyDataPayload( @@ -2681,8 +2532,7 @@ export function registerPtyHandlers( remaining.containsBackgroundOutput, remaining.rawLength, remaining.transformed - ), - upstreamCredit + ) ) } pendingData.delete(payload.id) @@ -2690,7 +2540,6 @@ export function registerPtyHandlers( // Why resume a dead PTY (no-op): avoid leaving a stale paused mark behind for a reused id. producerFlowControl.release(payload.id) pendingOverflowMarkedPtys.delete(payload.id) - pendingDataDropWarnedPtys.delete(payload.id) lastInputAtByPty.delete(payload.id) interactiveOutputCharsByPty.delete(payload.id) rendererInFlightTotalChars = Math.max( @@ -2699,9 +2548,6 @@ export function registerPtyHandlers( ) // Why: the renderer also drops its cumulative total on pty:exit, so a reused id restarts aligned at zero on both sides. rendererDeliveryAccountingByPty.delete(payload.id) - rendererDeliveryCredit.writeOff(payload.id, (chars) => { - tryGetProviderForPty(payload.id)?.acknowledgeDataEvent(payload.id, chars) - }) recordPtyRendererDeliveryPressure() mainWindow.webContents.send('pty:exit', { ...payload, @@ -2715,20 +2561,6 @@ export function registerPtyHandlers( } } - function sendPtyReplayToRenderer(payload: { id: string; data: string }): void { - const pending = pendingData.get(payload.id) - if (pending) { - settlePendingPtyDataDelivery(payload.id, pending) - pendingData.delete(payload.id) - pendingOverflowMarkedPtys.delete(payload.id) - updateProducerFlowControl(payload.id) - clearFlushTimerIfIdle() - } - if (!mainWindow.isDestroyed()) { - mainWindow.webContents.send('pty:replay', payload) - } - } - async function shutdownProviderAndDetectExit( provider: IPtyProvider, id: string, @@ -2752,144 +2584,6 @@ export function registerPtyHandlers( return providerExitObserved } - function routeProviderData(payload: PtyDataEvent, runtimeAlreadyIngested: boolean): void { - const rawLength = payload.sequenceChars ?? payload.data.length - const outputSeq = runtimeAlreadyIngested - ? runtime?.getPtyOutputSequence(payload.id) - : runtime?.onPtyData(payload.id, payload.data, Date.now(), rawLength, payload.transformed) - const rendererData = payload.data - const preservesSeq = !payload.transformed && rawLength === payload.data.length - const startSeq = typeof outputSeq === 'number' ? Math.max(0, outputSeq - rawLength) : undefined - if (mainWindow.isDestroyed()) { - // Why clear the flush timer: macOS app re-activation otherwise leaks orphaned timers from the previous window's registration. - if (flushTimer) { - clearTimeout(flushTimer) - flushTimer = null - } - producerFlowControl.releaseAll() - settleRejectedPtyRendererDelivery( - rawLength, - appendPtyDeliveryCredit(undefined, payload.upstreamCredit), - (chars) => tryGetProviderForPty(payload.id)?.acknowledgeDataEvent(payload.id, chars) - ) - settleAllPendingUpstreamCredit() - rendererDeliveryCredit.writeOffAll((id, chars) => { - tryGetProviderForPty(id)?.acknowledgeDataEvent(id, chars) - }) - clearDeliveryResyncProbe() - pendingData.clear() - pendingOverflowMarkedPtys.clear() - pendingDataDropWarnedPtys.clear() - rendererDeliveryAccountingByPty.clear() - rendererInFlightTotalChars = 0 - clearDispatcherReadyWatchdog() - recordPtyRendererDeliveryPressure() - return - } - const settings = getSettings?.() - // Why drop before the interactive bypass: runtime already ingested the chunk, so gated PTYs skip both renderer paths and reveal restores from the snapshot. - if (shouldDropHiddenRendererPtyData(payload.id, settings)) { - settleRejectedPtyRendererDelivery( - rawLength, - appendPtyDeliveryCredit(undefined, payload.upstreamCredit), - (chars) => tryGetProviderForPty(payload.id)?.acknowledgeDataEvent(payload.id, chars) - ) - const drop = recordHiddenRendererPtyDataDrop(payload.id, payload.data.length) - warnIfDroppingHiddenBytesForVisiblePty(payload.id, payload.data.length) - if (drop.shouldEmitRestoreMarker) { - sendModelRestoreNeededMarker(payload.id, 'hidden-drop', outputSeq) - } - return - } - if (rendererData.length === 0 && !payload.transformed) { - settleRejectedPtyRendererDelivery( - rawLength, - appendPtyDeliveryCredit(undefined, payload.upstreamCredit), - (chars) => tryGetProviderForPty(payload.id)?.acknowledgeDataEvent(payload.id, chars) - ) - return - } - const containsBackgroundOutput = - rendererPtyIsKnownHidden(payload.id) || ptyHasHiddenRendererResizeOutput(payload.id) - if (containsBackgroundOutput) { - markHiddenRendererResizeOutputDelivered(payload.id) - } - const existing = pendingData.get(payload.id) - const pending = appendPendingPtyData( - payload.id, - existing, - rendererData, - startSeq, - preservesSeq, - containsBackgroundOutput, - rawLength, - payload.transformed === true, - payload.upstreamCredit - ) - const nextData = pending.data - const isInteractiveOutput = shouldSendInteractiveOutputNow( - payload.id, - nextData, - performance.now() - ) - // Why gate the fast path on the handshake too: else boot-window keystroke echo is sent into a listener-less page and pins the gate. - if (isInteractiveOutput && rendererPtyDispatcherReady) { - // Why the reserve: keep input echo from being pinned behind unrelated bulk output; it's bounded and the per-PTY cap still prevents an active TUI runaway. - if (!canSendPtyDataToRenderer(payload.id, { interactive: true })) { - requestDeliveryResyncForGatedPty() - pendingData.set(payload.id, pending) - updateProducerFlowControl(payload.id) - recordPtyRendererDeliveryPressure() - return - } - pendingData.delete(payload.id) - updateProducerFlowControl(payload.id) - pendingOverflowMarkedPtys.delete(payload.id) - clearFlushTimerIfIdle() - const upstreamCredit = takePtyDeliveryCredit( - pending.upstreamCreditSpans, - pending.rawLength ?? nextData.length - ) - // Why immediate: agent TUIs redraw small prompt regions per keystroke; the throughput batch timer would add visible input latency. - sendPtyDataToRenderer( - payload.id, - { - id: payload.id, - data: nextData, - ...(typeof pending.startSeq === 'number' - ? { - seq: pending.startSeq + (pending.rawLength ?? nextData.length), - rawLength: pending.rawLength ?? nextData.length - } - : {}), - ...(pending.transformed ? { transformed: true } : {}), - ...(pending.containsBackgroundOutput === true ? { background: true } : {}), - ...(pending.droppedOutput === true ? { droppedOutput: true } : {}) - }, - upstreamCredit - ) - return - } - pendingData.set(payload.id, pending) - updateProducerFlowControl(payload.id) - recordPtyRendererDeliveryPressure() - // Why probe on data arrival (not flush skips): new output for a fully gated PTY is the moment stuck delivery becomes observable. - if ( - !canSendPtyDataToRenderer(payload.id, { interactive: activeRendererPtys.has(payload.id) }) - ) { - requestDeliveryResyncForGatedPty() - } - if (!flushTimer) { - schedulePendingDataFlush(PTY_BATCH_INTERVAL_MS) - } - } - - installExternalPtyRendererDeliveryRouter({ - data: (payload) => routeProviderData(payload, false), - replay: sendPtyReplayToRenderer, - exit: sendPtyExitToRenderer - }) - // Why extracted: the "Restart daemon" flow rebinds against the fresh adapter after replaceDaemonProvider, sharing this code path with startup registration. const bindProviderListeners = (): void => { localDataUnsub?.() @@ -2923,7 +2617,109 @@ export function registerPtyHandlers( // Why: daemon providers lack configure().onData, so feed the runtime here or their tail buffer (terminal.read, agent-detection, mobile stream) stays empty. const isLocalProvider = localProvider instanceof LocalPtyProvider - localDataUnsub = localProvider.onData((payload) => routeProviderData(payload, isLocalProvider)) + localDataUnsub = localProvider.onData((payload) => { + const rawLength = payload.sequenceChars ?? payload.data.length + const outputSeq = isLocalProvider + ? runtime?.getPtyOutputSequence(payload.id) + : runtime?.onPtyData(payload.id, payload.data, Date.now(), rawLength, payload.transformed) + const rendererData = payload.data + const preservesSeq = !payload.transformed && rawLength === payload.data.length + const startSeq = + typeof outputSeq === 'number' ? Math.max(0, outputSeq - rawLength) : undefined + if (mainWindow.isDestroyed()) { + // Why clear the flush timer: macOS app re-activation otherwise leaks orphaned timers from the previous window's registration. + if (flushTimer) { + clearTimeout(flushTimer) + flushTimer = null + } + producerFlowControl.releaseAll() + clearDeliveryResyncProbe() + pendingData.clear() + pendingOverflowMarkedPtys.clear() + rendererDeliveryAccountingByPty.clear() + rendererInFlightTotalChars = 0 + clearDispatcherReadyWatchdog() + recordPtyRendererDeliveryPressure() + return + } + const settings = getSettings?.() + // Why drop before the interactive bypass: runtime already ingested the chunk, so gated PTYs skip both renderer paths and reveal restores from the snapshot. + if (shouldDropHiddenRendererPtyData(payload.id, settings)) { + const drop = recordHiddenRendererPtyDataDrop(payload.id, payload.data.length) + warnIfDroppingHiddenBytesForVisiblePty(payload.id, payload.data.length) + if (drop.shouldEmitRestoreMarker) { + sendModelRestoreNeededMarker(payload.id, 'hidden-drop', outputSeq) + } + return + } + if (rendererData.length === 0 && !payload.transformed) { + return + } + const containsBackgroundOutput = + rendererPtyIsKnownHidden(payload.id) || ptyHasHiddenRendererResizeOutput(payload.id) + if (containsBackgroundOutput) { + markHiddenRendererResizeOutputDelivered(payload.id) + } + const existing = pendingData.get(payload.id) + const pending = appendPendingPtyData( + payload.id, + existing, + rendererData, + startSeq, + preservesSeq, + containsBackgroundOutput, + rawLength, + payload.transformed === true + ) + const nextData = pending.data + const isInteractiveOutput = shouldSendInteractiveOutputNow( + payload.id, + nextData, + performance.now() + ) + // Why gate the fast path on the handshake too: else boot-window keystroke echo is sent into a listener-less page and pins the gate. + if (isInteractiveOutput && rendererPtyDispatcherReady) { + // Why the reserve: keep input echo from being pinned behind unrelated bulk output; it's bounded and the per-PTY cap still prevents an active TUI runaway. + if (!canSendPtyDataToRenderer(payload.id, { interactive: true })) { + requestDeliveryResyncForGatedPty() + pendingData.set(payload.id, pending) + updateProducerFlowControl(payload.id) + recordPtyRendererDeliveryPressure() + return + } + pendingData.delete(payload.id) + updateProducerFlowControl(payload.id) + pendingOverflowMarkedPtys.delete(payload.id) + clearFlushTimerIfIdle() + // Why immediate: agent TUIs redraw small prompt regions per keystroke; the throughput batch timer would add visible input latency. + sendPtyDataToRenderer(payload.id, { + id: payload.id, + data: nextData, + ...(typeof pending.startSeq === 'number' + ? { + seq: pending.startSeq + (pending.rawLength ?? nextData.length), + rawLength: pending.rawLength ?? nextData.length + } + : {}), + ...(pending.transformed ? { transformed: true } : {}), + ...(pending.containsBackgroundOutput === true ? { background: true } : {}), + ...(pending.droppedOutput === true ? { droppedOutput: true } : {}) + }) + return + } + pendingData.set(payload.id, pending) + updateProducerFlowControl(payload.id) + recordPtyRendererDeliveryPressure() + // Why probe on data arrival (not flush skips): new output for a fully gated PTY is the moment stuck delivery becomes observable. + if ( + !canSendPtyDataToRenderer(payload.id, { interactive: activeRendererPtys.has(payload.id) }) + ) { + requestDeliveryResyncForGatedPty() + } + if (!flushTimer) { + schedulePendingDataFlush(PTY_BATCH_INTERVAL_MS) + } + }) localExitUnsub = localProvider.onExit((payload) => { if (!isCurrentPtyExit(payload)) { return @@ -4025,18 +3821,11 @@ export function registerPtyHandlers( } }, listProcesses: async () => { - const processes: PtyProcessInfo[] = [] - const admission = new PtyProcessListAdmission() - await visitPtyProcessListingsInBatches( - registeredPtyProviders(), - ({ provider }) => provider.listProcesses(), - (_source, listing) => { - for (const process of listing) { - processes.push(admission.admit(process)) - } - } - ) - return processes + const providerSessions = await Promise.all([ + localProvider.listProcesses(), + ...Array.from(sshProviders.values(), (provider) => provider.listProcesses()) + ]) + return providerSessions.flat() }, serializeBuffer: (ptyId, opts) => { // Why: mobile xterm must start from the desktop's exact screen state/dimensions before live TUI chunks render correctly. @@ -5234,14 +5023,9 @@ export function registerPtyHandlers( // Why: tolerate legacy per-chunk delta payloads — dev hot-reload can pair an old renderer with a new main. const accounting = rendererDeliveryAccountingByPty.get(args.id) const delta = Number.isFinite(args.charCount) ? Math.max(0, args.charCount ?? 0) : 0 - if (accounting) { - acknowledged = applyCumulativeAck(args.id, accounting.ackedChars + delta) - } - } - // Why preserve the zero callback: legacy providers observe every renderer ACK, while captured token owners still receive only their exact positive credit above. - if (acknowledged === 0) { - tryGetProviderForPty(args.id)?.acknowledgeDataEvent(args.id, 0) + acknowledged = accounting ? applyCumulativeAck(args.id, accounting.ackedChars + delta) : 0 } + tryGetProviderForPty(args.id)?.acknowledgeDataEvent(args.id, acknowledged) recordPtyRendererDeliveryPressure() if (pendingData.size > 0 && !flushTimer) { schedulePendingDataFlush(0) @@ -5265,7 +5049,10 @@ export function registerPtyHandlers( if (typeof processedChars !== 'number' || !Number.isFinite(processedChars)) { continue } - applyCumulativeAck(id, Math.max(0, processedChars)) + const acknowledged = applyCumulativeAck(id, Math.max(0, processedChars)) + if (acknowledged > 0) { + tryGetProviderForPty(id)?.acknowledgeDataEvent(id, acknowledged) + } } recordPtyRendererDeliveryPressure() if (pendingData.size > 0 && !flushTimer) { @@ -5283,7 +5070,10 @@ export function registerPtyHandlers( if (typeof processedChars !== 'number' || !Number.isFinite(processedChars)) { continue } - applyCumulativeAck(id, Math.max(0, processedChars)) + const acknowledged = applyCumulativeAck(id, Math.max(0, processedChars)) + if (acknowledged > 0) { + tryGetProviderForPty(id)?.acknowledgeDataEvent(id, acknowledged) + } } let writtenOff: PtyDeliveryWriteOff[] = [] // Why the main-side ACK-silence check: requiring main to have also seen no ACK stops a buggy/foreign caller from writing off live delivery. @@ -5378,7 +5168,6 @@ export function registerPtyHandlers( const pending = pendingData.get(args.id) if (pending && shouldDropHiddenRendererPtyData(args.id, getSettings?.())) { pendingData.delete(args.id) - settlePendingPtyDataDelivery(args.id, pending) updateProducerFlowControl(args.id) pendingOverflowMarkedPtys.delete(args.id) const drop = recordHiddenRendererPtyDataDrop(args.id, pending.data.length) diff --git a/src/main/ipc/remote-workspace.test.ts b/src/main/ipc/remote-workspace.test.ts index 67b38164176..6da587ac86c 100644 --- a/src/main/ipc/remote-workspace.test.ts +++ b/src/main/ipc/remote-workspace.test.ts @@ -36,7 +36,6 @@ vi.mock('./remote-workspace-events', () => ({ import { _resetRemoteWorkspaceCachesForTests, - REMOTE_WORKSPACE_PATCH_CONCURRENCY, registerRemoteWorkspaceHandlers, remoteWorkspaceSessionMatchesSnapshot } from './remote-workspace' @@ -265,66 +264,6 @@ describe('remoteWorkspace:setForConnectedTargets', () => { expect(requestByTargetId.get('target-2')).toBeUndefined() }) - it.each([ - ['at the limit', REMOTE_WORKSPACE_PATCH_CONCURRENCY], - ['above the limit', REMOTE_WORKSPACE_PATCH_CONCURRENCY + 1] - ])('bounds per-target workspace patches %s', async (_, count) => { - const manyTargets: SshTarget[] = Array.from({ length: count }, (_, index) => ({ - id: `target-${index}`, - label: `Target ${index}`, - host: `${index}.example.com`, - port: 22, - username: 'alice' - })) - getSshConnectionStoreMock.mockReturnValue({ listTargets: () => manyTargets }) - let active = 0 - let peak = 0 - let started = 0 - const releases: (() => void)[] = [] - getActiveMultiplexerMock.mockImplementation(() => ({ - request: async (method: string) => { - if (method === 'workspace.get') { - started++ - active++ - peak = Math.max(peak, active) - await new Promise((resolve) => releases.push(resolve)) - active-- - return snapshot({ - activeWorktreePath: '/previous', - activeTabId: null, - tabsByWorktreePath: {}, - terminalLayoutsByTabId: {} - }) - } - return { - ok: true, - snapshot: snapshot({ - activeWorktreePath: null, - activeTabId: null, - tabsByWorktreePath: {}, - terminalLayoutsByTabId: {} - }) - } - } - })) - - const patches = callSetForConnectedTargets({ - session: baseSession, - hydratedTargetIds: manyTargets.map((target) => target.id) - }) - await vi.waitFor(() => - expect(started).toBe(Math.min(count, REMOTE_WORKSPACE_PATCH_CONCURRENCY)) - ) - if (count > REMOTE_WORKSPACE_PATCH_CONCURRENCY) { - releases.shift()?.() - await vi.waitFor(() => expect(started).toBe(count)) - } - releases.splice(0).forEach((release) => release()) - - await expect(patches).resolves.toHaveLength(count) - expect(peak).toBe(Math.min(count, REMOTE_WORKSPACE_PATCH_CONCURRENCY)) - }) - it('can export from the persisted store session when no session argument is provided', async () => { getWorkspaceSessionMock.mockReturnValue({ activeRepoId: 'repo-target-1', diff --git a/src/main/ipc/remote-workspace.ts b/src/main/ipc/remote-workspace.ts index 8d75bf838f3..713fe1b8a17 100644 --- a/src/main/ipc/remote-workspace.ts +++ b/src/main/ipc/remote-workspace.ts @@ -16,7 +16,6 @@ import type { import type { SshTarget } from '../../shared/ssh-types' import type { WorkspaceSessionState } from '../../shared/types' import { getRepoIdFromWorktreeId } from '../../shared/worktree-id' -import { mapWithConcurrency } from '../../shared/map-with-concurrency' import { getRemoteWorkspaceNamespace } from './remote-workspace-namespace' import { registerRemoteWorkspaceNotificationHandler } from './remote-workspace-events' @@ -24,7 +23,6 @@ const CLIENT_ID = randomUUID() const CLIENT_NAME = hostname() || 'This device' const SNAPSHOT_SCHEMA_VERSION = 1 export const REMOTE_WORKSPACE_SNAPSHOT_CACHE_MAX_ENTRIES = 64 -export const REMOTE_WORKSPACE_PATCH_CONCURRENCY = 4 let mainWindowGetter: (() => BrowserWindow | null) | null = null const latestSnapshotByTargetId = new Map() @@ -422,10 +420,8 @@ export function registerRemoteWorkspaceHandlers( ) ?? [] const workspaceSession = args.session ?? store.getWorkspaceSession() - const results = await mapWithConcurrency( - targets, - REMOTE_WORKSPACE_PATCH_CONCURRENCY, - async (target) => { + const results = await Promise.all( + targets.map(async (target) => { // Why: each target has its own revision stream. Keep same-target // writes queued, but do not let one slow relay block others. const session = exportSessionForTarget(store, target.id, workspaceSession) @@ -433,7 +429,7 @@ export function registerRemoteWorkspaceHandlers( patchRemoteWorkspaceSession(target, session) ) return result ? { targetId: target.id, result } : null - } + }) ) return results.filter( (entry): entry is { targetId: string; result: RemoteWorkspacePatchResult } => entry !== null diff --git a/src/main/ipc/repo-target-directory-emptiness.ts b/src/main/ipc/repo-target-directory-emptiness.ts deleted file mode 100644 index 2ecd505ff3a..00000000000 --- a/src/main/ipc/repo-target-directory-emptiness.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { opendir } from 'node:fs/promises' - -export async function isRepoTargetDirectoryEmpty(targetPath: string): Promise { - const directory = await opendir(targetPath, { bufferSize: 1 }) - try { - return (await directory.read()) === null - } finally { - await directory.close().catch(() => { - // The OS may already have closed a fully consumed directory stream. - }) - } -} diff --git a/src/main/ipc/repos-create.test.ts b/src/main/ipc/repos-create.test.ts index e00fb50b8f2..040f9348250 100644 --- a/src/main/ipc/repos-create.test.ts +++ b/src/main/ipc/repos-create.test.ts @@ -19,9 +19,7 @@ const { mockStore, mkdirMock, accessMock, - opendirMock, - directoryReadMock, - directoryCloseMock, + readdirMock, rmMock, gitExecFileAsyncMock, homedirMock, @@ -39,9 +37,7 @@ const { }, mkdirMock: vi.fn(), accessMock: vi.fn(), - opendirMock: vi.fn(), - directoryReadMock: vi.fn(), - directoryCloseMock: vi.fn(), + readdirMock: vi.fn(), rmMock: vi.fn(), gitExecFileAsyncMock: vi.fn(), homedirMock: vi.fn(), @@ -60,7 +56,7 @@ vi.mock('electron', () => ({ vi.mock('fs/promises', () => ({ mkdir: mkdirMock, access: accessMock, - opendir: opendirMock, + readdir: readdirMock, rm: rmMock })) @@ -142,12 +138,7 @@ describe('repos:create', () => { // Default baseline: target does NOT exist yet, mkdir succeeds, git OK. accessMock.mockReset().mockRejectedValue(new Error('ENOENT')) - directoryReadMock.mockReset().mockResolvedValue(null) - directoryCloseMock.mockReset().mockResolvedValue(undefined) - opendirMock.mockReset().mockResolvedValue({ - read: directoryReadMock, - close: directoryCloseMock - }) + readdirMock.mockReset().mockResolvedValue([]) mkdirMock.mockReset().mockResolvedValue(undefined) rmMock.mockReset().mockResolvedValue(undefined) gitExecFileAsyncMock.mockReset().mockResolvedValue({ stdout: '', stderr: '' }) @@ -212,7 +203,7 @@ describe('repos:create', () => { it('rejects a non-empty existing directory without creating the target', async () => { accessMock.mockResolvedValueOnce(undefined) // exists - directoryReadMock.mockResolvedValueOnce({ name: 'README.md' }) + readdirMock.mockResolvedValueOnce(['README.md', '.DS_Store']) const result = await callCreate({ parentPath: '/tmp', name: 'busy', kind: 'git' }) @@ -220,12 +211,11 @@ describe('repos:create', () => { expect(mkdirMock).toHaveBeenCalledWith('/tmp', { recursive: true }) expect(mkdirMock).not.toHaveBeenCalledWith('/tmp/busy', expect.anything()) expect(mockStore.addRepo).not.toHaveBeenCalled() - expect(directoryReadMock).toHaveBeenCalledOnce() - expect(directoryCloseMock).toHaveBeenCalledOnce() }) it('accepts an empty existing directory and does not create the target', async () => { accessMock.mockResolvedValueOnce(undefined) // exists + readdirMock.mockResolvedValueOnce([]) const result = await callCreate({ parentPath: '/tmp', name: 'empty', kind: 'folder' }) @@ -324,6 +314,7 @@ describe('repos:create', () => { it('does NOT rm a pre-existing empty directory when git init fails', async () => { // Pretend the directory already existed (and is empty) — user pre-created it. accessMock.mockResolvedValueOnce(undefined) + readdirMock.mockResolvedValueOnce([]) gitExecFileAsyncMock.mockReset().mockRejectedValueOnce(new Error('git init blew up')) const result = await callCreate({ parentPath: '/tmp', name: 'preexisting', kind: 'git' }) @@ -366,6 +357,7 @@ describe('repos:create', () => { // The folder itself must survive (user owns it) but the half-init'd // .git/ should be removed so the folder looks untouched. accessMock.mockResolvedValueOnce(undefined) + readdirMock.mockResolvedValueOnce([]) gitExecFileAsyncMock .mockReset() .mockResolvedValueOnce({ stdout: '', stderr: '' }) diff --git a/src/main/ipc/repos-picker.test.ts b/src/main/ipc/repos-picker.test.ts index aed95e8128f..af0c5b48df4 100644 --- a/src/main/ipc/repos-picker.test.ts +++ b/src/main/ipc/repos-picker.test.ts @@ -54,9 +54,6 @@ describe('repos folder pickers', () => { getRepo: vi.fn(), updateRepo: vi.fn() } - const mockRuntime = { - notifyRepoStoreChanged: vi.fn() - } const callPickFolders = (): Promise => { const handler = handlers.get('repos:pickFolders') @@ -82,10 +79,8 @@ describe('repos folder pickers', () => { }) removeHandlerMock.mockReset() showOpenDialogMock.mockReset() - mockStore.removeProject.mockReset() - mockRuntime.notifyRepoStoreChanged.mockReset() - registerRepoHandlers(mockWindow as never, mockStore as never, mockRuntime as never) + registerRepoHandlers(mockWindow as never, mockStore as never) }) it('registers the multi-folder picker with handler cleanup', () => { @@ -114,13 +109,6 @@ describe('repos folder pickers', () => { await expect(callPickFolders()).resolves.toEqual([]) }) - it('releases runtime repo caches when local IPC removes a project', async () => { - await handlers.get('repos:remove')?.(null, { repoId: 'repo-1' }) - - expect(mockStore.removeProject).toHaveBeenCalledWith('repo-1') - expect(mockRuntime.notifyRepoStoreChanged).toHaveBeenCalledWith('repo-1') - }) - it('picks an existing directory without enabling native directory creation', async () => { const parentDir = join(sep, 'projects') showOpenDialogMock.mockResolvedValue({ diff --git a/src/main/ipc/repos.ts b/src/main/ipc/repos.ts index ca7289f76b1..fd0ddcf770d 100644 --- a/src/main/ipc/repos.ts +++ b/src/main/ipc/repos.ts @@ -39,7 +39,7 @@ import { import { isTuiAgent } from '../../shared/tui-agent-config' import { invalidateAuthorizedRootsCache } from './filesystem-auth' import type { ChildProcess } from 'node:child_process' -import { access, mkdir, rm } from 'node:fs/promises' +import { access, mkdir, readdir, rm } from 'node:fs/promises' import { gitExecFileAsync, gitSpawn, nonInteractiveGitEnv } from '../git/runner' import { isAbsolute, join, posix } from 'node:path' import { @@ -78,7 +78,6 @@ import { getSshGitUsername, resolveLocalGitUsername } from '../git/git-username' import { enrichRepoGitUsernames } from '../repo-git-username-enrichment' import { getActiveMultiplexer } from './ssh' import { normalizeSparseDirectories } from './sparse-checkout-directories' -import { isRepoTargetDirectoryEmpty } from './repo-target-directory-emptiness' import { track } from '../telemetry/client' import { scheduleCurrentWorktreeBaseDirectoryWatcherSync } from './worktree-base-directory-watcher' import { getCohortAtEmit } from '../telemetry/cohort-classifier' @@ -101,7 +100,6 @@ import { import { getGitCloneFailureMessage } from '../../shared/git-clone-failure-message' import { prepareLocalWorktreeRootForRepo } from '../worktree-root-preparation' import { runWithGitReadCacheInvalidation } from '../git/status' -import type { OrcaRuntimeService } from '../runtime/orca-runtime' // Why: `method` is the IPC entry point the user took, not what they added (never path/URL/name); repos:create → 'folder_picker'. // Why: `isGitRepo` is a non-identifying git-vs-folder signal from the caller's detection; pass undefined when unknown, never default false. @@ -1094,11 +1092,7 @@ async function runNestedRepoScanForIpc( } } -export function registerRepoHandlers( - mainWindow: BrowserWindow, - store: Store, - runtime?: OrcaRuntimeService -): void { +export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): void { // Remove previously registered handlers so we can re-register on macOS app re-activation (new window). ipcMain.removeHandler('repos:list') ipcMain.removeHandler('repos:add') @@ -1745,7 +1739,8 @@ export function registerRepoHandlers( if (targetExists) { try { - if (!(await isRepoTargetDirectoryEmpty(targetPath))) { + const entries = await readdir(targetPath) + if (entries.length > 0) { return { error: `"${name}" already exists at this location and is not empty.` } @@ -1883,7 +1878,6 @@ export function registerRepoHandlers( ipcMain.handle('repos:remove', async (_event, args: { repoId: string }) => { store.removeProject(args.repoId) - runtime?.notifyRepoStoreChanged(args.repoId) invalidateAuthorizedRootsCache() notifyReposChanged(mainWindow) }) @@ -1897,7 +1891,6 @@ export function registerRepoHandlers( throw new Error(`Invalid host ID: ${args.hostId}`) } store.removeProjectForHost(args.repoId, hostId) - runtime?.notifyRepoStoreChanged(args.repoId) invalidateAuthorizedRootsCache() notifyReposChanged(mainWindow) } diff --git a/src/main/ipc/runtime-environment-call-queue.ts b/src/main/ipc/runtime-environment-call-queue.ts index a9f08e49ba6..722f6fe8ada 100644 --- a/src/main/ipc/runtime-environment-call-queue.ts +++ b/src/main/ipc/runtime-environment-call-queue.ts @@ -5,8 +5,7 @@ const runtimeCallQueuePool = new RuntimeRpcCallQueuePool() export function enqueueRuntimeCall( selector: string, method: string, - run: () => Promise, - retainedBytes = 0 + run: () => Promise ): Promise { - return runtimeCallQueuePool.enqueue(selector, method, run, retainedBytes) + return runtimeCallQueuePool.enqueue(selector, method, run) } diff --git a/src/main/ipc/runtime-environment-shared-control-support.ts b/src/main/ipc/runtime-environment-shared-control-support.ts deleted file mode 100644 index 28abeede78c..00000000000 --- a/src/main/ipc/runtime-environment-shared-control-support.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY } from '../../shared/protocol-version' -import { sendRemoteRuntimeRequest } from '../../shared/remote-runtime-client' -import { markEnvironmentUsed } from '../../shared/runtime-environment-store' -import type { - getPreferredPairingOffer, - KnownRuntimeEnvironment -} from '../../shared/runtime-environments' -import type { RuntimeStatus } from '../../shared/runtime-types' - -const sharedControlSupport = new Map }>() - -export function resetSharedControlSupport(): void { - sharedControlSupport.clear() -} - -export function clearSharedControlSupport(environmentId: string): void { - sharedControlSupport.delete(environmentId) -} - -export async function supportsSharedControl( - userDataPath: string, - environment: KnownRuntimeEnvironment, - pairing: ReturnType, - timeoutMs: number -): Promise { - const cacheKey = getSharedControlSupportCacheKey(environment, pairing) - const cached = sharedControlSupport.get(environment.id) - if (cached?.cacheKey === cacheKey) { - return cached.check - } - let resolvedCacheKey = cacheKey - const check = (async () => { - const response = await sendRemoteRuntimeRequest( - pairing, - 'status.get', - undefined, - timeoutMs - ) - if (response.ok === true) { - markEnvironmentUsed(userDataPath, environment.id, { runtimeId: response._meta.runtimeId }) - resolvedCacheKey = getSharedControlSupportCacheKey( - environment, - pairing, - response._meta.runtimeId - ) - return ( - response.result.capabilities?.includes(REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY) === true - ) - } - return false - })() - // Why: the same saved host can be re-paired or point at a different runtime - // binary over time; capability support belongs to that pairing/runtime identity. - sharedControlSupport.set(environment.id, { cacheKey, check }) - try { - const supported = await check - const cachedAfterCheck = sharedControlSupport.get(environment.id) - if (cachedAfterCheck?.check === check && cachedAfterCheck.cacheKey !== resolvedCacheKey) { - sharedControlSupport.set(environment.id, { cacheKey: resolvedCacheKey, check }) - } - return supported - } catch (error) { - if (sharedControlSupport.get(environment.id)?.check === check) { - sharedControlSupport.delete(environment.id) - } - throw error - } -} - -function getSharedControlSupportCacheKey( - environment: KnownRuntimeEnvironment, - pairing: ReturnType, - runtimeId = environment.runtimeId -): string { - return [ - runtimeId ?? 'unknown-runtime', - pairing.endpoint, - pairing.deviceToken, - pairing.publicKeyB64 - ].join('\0') -} diff --git a/src/main/ipc/runtime-environment-subscription-admission.test.ts b/src/main/ipc/runtime-environment-subscription-admission.test.ts deleted file mode 100644 index 2305ec33fb1..00000000000 --- a/src/main/ipc/runtime-environment-subscription-admission.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - REMOTE_RUNTIME_MAX_RETAINED_SUBSCRIPTION_BYTES, - REMOTE_RUNTIME_MAX_SUBSCRIPTION_ID_BYTES, - REMOTE_RUNTIME_MAX_SUBSCRIPTION_PARAM_BYTES, - REMOTE_RUNTIME_MAX_SUBSCRIPTIONS -} from '../../shared/remote-runtime-memory-limits' -import { createRuntimeEnvironmentSubscriptionAdmission } from './runtime-environment-subscription-admission' - -describe('runtime environment subscription admission', () => { - it('caps pending and active subscriptions, then recovers after release', () => { - const admission = createRuntimeEnvironmentSubscriptionAdmission() - const releases = Array.from({ length: REMOTE_RUNTIME_MAX_SUBSCRIPTIONS }, (_value, index) => - admission.claim(`subscription-${index}`, undefined) - ) - - expect(admission.evidence()).toEqual({ - retainedBytes: 0, - subscriptionCount: REMOTE_RUNTIME_MAX_SUBSCRIPTIONS - }) - expect(() => admission.claim('overflow', undefined)).toThrow('subscription limit reached') - - releases[0]?.() - expect(() => admission.claim('recovered', undefined)).not.toThrow() - }) - - it('caps aggregate retained params and releases the claim exactly once', () => { - const admission = createRuntimeEnvironmentSubscriptionAdmission() - const exactParams = 'x'.repeat(REMOTE_RUNTIME_MAX_SUBSCRIPTION_PARAM_BYTES - 2) - const claimCount = - REMOTE_RUNTIME_MAX_RETAINED_SUBSCRIPTION_BYTES / REMOTE_RUNTIME_MAX_SUBSCRIPTION_PARAM_BYTES - const releases = Array.from({ length: claimCount }, (_value, index) => - admission.claim(`large-${index}`, exactParams) - ) - - expect(admission.evidence()).toEqual({ - retainedBytes: REMOTE_RUNTIME_MAX_RETAINED_SUBSCRIPTION_BYTES, - subscriptionCount: claimCount - }) - expect(() => admission.claim('aggregate-overflow', null)).toThrow( - 'subscription memory limit reached' - ) - - releases[0]?.() - releases[0]?.() - const recovered = admission.claim('aggregate-recovered', exactParams) - expect(admission.evidence().retainedBytes).toBe(REMOTE_RUNTIME_MAX_RETAINED_SUBSCRIPTION_BYTES) - recovered() - }) - - it('bounds caller-supplied ids by UTF-8 bytes and reserves duplicates', () => { - const admission = createRuntimeEnvironmentSubscriptionAdmission() - const exactId = 'é'.repeat(REMOTE_RUNTIME_MAX_SUBSCRIPTION_ID_BYTES / 2) - const release = admission.claim(exactId, undefined) - - expect(() => admission.claim(exactId, undefined)).toThrow('already exists') - expect(() => admission.claim(`${exactId}x`, undefined)).toThrow( - `between 1 and ${REMOTE_RUNTIME_MAX_SUBSCRIPTION_ID_BYTES} bytes` - ) - - release() - expect(() => admission.claim(exactId, undefined)).not.toThrow() - }) -}) diff --git a/src/main/ipc/runtime-environment-subscription-admission.ts b/src/main/ipc/runtime-environment-subscription-admission.ts deleted file mode 100644 index 4779ed4dc07..00000000000 --- a/src/main/ipc/runtime-environment-subscription-admission.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { RemoteRuntimeClientError } from '../../shared/remote-runtime-client-error' -import { - measureRemoteRuntimeSubscriptionParams, - REMOTE_RUNTIME_MAX_RETAINED_SUBSCRIPTION_BYTES, - REMOTE_RUNTIME_MAX_SUBSCRIPTION_ID_BYTES, - REMOTE_RUNTIME_MAX_SUBSCRIPTIONS -} from '../../shared/remote-runtime-memory-limits' -import { measureUtf8ByteLength } from '../../shared/utf8-byte-limits' - -export type RuntimeEnvironmentSubscriptionAdmission = { - claim: (subscriptionId: string, params: unknown) => () => void - evidence: () => { retainedBytes: number; subscriptionCount: number } -} - -export function createRuntimeEnvironmentSubscriptionAdmission(): RuntimeEnvironmentSubscriptionAdmission { - const subscriptionIds = new Set() - let retainedBytes = 0 - - return { - claim(subscriptionId, params): () => void { - assertSubscriptionId(subscriptionId) - if (subscriptionIds.has(subscriptionId)) { - throw new RemoteRuntimeClientError( - 'invalid_argument', - 'Runtime environment subscription id already exists.' - ) - } - if (subscriptionIds.size >= REMOTE_RUNTIME_MAX_SUBSCRIPTIONS) { - throw new RemoteRuntimeClientError( - 'remote_runtime_busy', - 'Remote runtime subscription limit reached; close a subscription and retry.' - ) - } - const paramsBytes = measureRemoteRuntimeSubscriptionParams(params) - if (retainedBytes + paramsBytes > REMOTE_RUNTIME_MAX_RETAINED_SUBSCRIPTION_BYTES) { - throw new RemoteRuntimeClientError( - 'remote_runtime_busy', - 'Remote runtime subscription memory limit reached; close a subscription and retry.' - ) - } - subscriptionIds.add(subscriptionId) - retainedBytes += paramsBytes - let claimed = true - return () => { - if (!claimed) { - return - } - claimed = false - subscriptionIds.delete(subscriptionId) - retainedBytes -= paramsBytes - } - }, - evidence: () => ({ retainedBytes, subscriptionCount: subscriptionIds.size }) - } -} - -function assertSubscriptionId(subscriptionId: string): void { - if ( - subscriptionId.length === 0 || - measureUtf8ByteLength(subscriptionId, { - stopAfterBytes: REMOTE_RUNTIME_MAX_SUBSCRIPTION_ID_BYTES - }).exceededLimit - ) { - throw new RemoteRuntimeClientError( - 'invalid_argument', - `Runtime environment subscription id must be between 1 and ${REMOTE_RUNTIME_MAX_SUBSCRIPTION_ID_BYTES} bytes.` - ) - } -} diff --git a/src/main/ipc/runtime-environment-subscription-handlers.ts b/src/main/ipc/runtime-environment-subscription-handlers.ts deleted file mode 100644 index 10d6c832fc6..00000000000 --- a/src/main/ipc/runtime-environment-subscription-handlers.ts +++ /dev/null @@ -1,238 +0,0 @@ -import { ipcMain } from 'electron' -import { randomUUID } from 'node:crypto' -import type { RemoteRuntimeSubscription } from '../../shared/remote-runtime-client' -import { isRemoteRuntimeBinaryFrameWithinLimit } from '../../shared/remote-runtime-memory-limits' -import { resolveEnvironment } from '../../shared/runtime-environment-store' -import { createRuntimeEnvironmentSubscriptionAdmission } from './runtime-environment-subscription-admission' -import { - retainRuntimeEnvironmentTransportGeneration, - type RuntimeEnvironmentTransportGenerationLease -} from './runtime-environment-transport-generation' -import { subscribeRuntimeEnvironment } from './runtime-environment-transport-routing' - -type RetainedRemoteRuntimeSubscription = RemoteRuntimeSubscription & { - environmentId: string - ownerWebContentsId: number - removeDestroyedListener: () => void - releaseOwnership: () => void -} - -const remoteRuntimeSubscriptions = new Map() - -export function closeAllRuntimeEnvironmentSubscriptions(): void { - for (const [subscriptionId, subscription] of Array.from(remoteRuntimeSubscriptions)) { - remoteRuntimeSubscriptions.delete(subscriptionId) - subscription.close() - } -} - -export function closeRuntimeEnvironmentSubscriptionsForEnvironment(environmentId: string): void { - for (const [subscriptionId, subscription] of remoteRuntimeSubscriptions) { - if (subscription.environmentId !== environmentId) { - continue - } - remoteRuntimeSubscriptions.delete(subscriptionId) - subscription.close() - } -} - -export function registerRuntimeEnvironmentSubscriptionHandlers( - getUserDataPath: () => string -): void { - const subscriptionAdmission = createRuntimeEnvironmentSubscriptionAdmission() - ipcMain.handle( - 'runtimeEnvironments:subscribe', - async ( - event, - args: { - selector: string - method: string - params?: unknown - timeoutMs?: number - subscriptionId?: string - expectedEnvironmentPairingRevision?: number - } - ): Promise<{ subscriptionId: string; requestId: string }> => { - const subscriptionId = - typeof args.subscriptionId === 'string' && args.subscriptionId.length > 0 - ? args.subscriptionId - : randomUUID() - const releaseAdmission = subscriptionAdmission.claim(subscriptionId, args.params) - let transportLease: RuntimeEnvironmentTransportGenerationLease | null = null - let transportLeaseReleased = false - let transportWasCurrentAtRelease = true - const transportSetupIsCurrent = (): boolean => - transportLeaseReleased - ? transportWasCurrentAtRelease - : (transportLease?.isCurrent() ?? true) - const transportIsCurrent = (): boolean => - !transportLeaseReleased && (transportLease?.isCurrent() ?? true) - const releaseTransportLease = (): void => { - if (!transportLease || transportLeaseReleased) { - return - } - transportWasCurrentAtRelease = transportLease.isCurrent() - transportLeaseReleased = true - transportLease.release() - } - const releaseOwnership = (): void => { - releaseAdmission() - releaseTransportLease() - } - let ownershipTransferred = false - try { - if (remoteRuntimeSubscriptions.has(subscriptionId)) { - throw new Error('Runtime environment subscription id already exists') - } - const environment = resolveEnvironment(getUserDataPath(), args.selector) - const pairingRevision = environment.pairingRevision ?? environment.createdAt - if ( - args.expectedEnvironmentPairingRevision !== undefined && - pairingRevision !== args.expectedEnvironmentPairingRevision - ) { - throw new Error('Runtime environment pairing changed; refresh and try again') - } - transportLease = retainRuntimeEnvironmentTransportGeneration(environment.id) - const sender = event.sender - const ownerWebContentsId = sender.id - let senderDestroyed = sender.isDestroyed() - let transportClosed = false - let subscription: RemoteRuntimeSubscription | null = null - let destroyedListenerAttached = false - const removeDestroyedListener = (): void => { - if (!destroyedListenerAttached) { - return - } - destroyedListenerAttached = false - sender.removeListener('destroyed', closeSubscription) - } - const closeSubscription = (): void => { - senderDestroyed = true - const retained = remoteRuntimeSubscriptions.get(subscriptionId) ?? null - remoteRuntimeSubscriptions.delete(subscriptionId) - if (retained) { - retained.close() - return - } - removeDestroyedListener() - subscription?.close() - releaseOwnership() - } - sender.once('destroyed', closeSubscription) - destroyedListenerAttached = true - try { - subscription = await subscribeRuntimeEnvironment( - getUserDataPath(), - environment.id, - args.method, - args.params, - args.timeoutMs, - { - onEvent: (payload) => { - if (transportIsCurrent() && !sender.isDestroyed()) { - sender.send('runtimeEnvironments:subscriptionEvent', { - subscriptionId, - ...payload - }) - } - }, - onClose: () => { - transportClosed = true - const retained = remoteRuntimeSubscriptions.get(subscriptionId) ?? null - remoteRuntimeSubscriptions.delete(subscriptionId) - if (retained) { - retained.removeDestroyedListener() - retained.releaseOwnership() - return - } - removeDestroyedListener() - releaseOwnership() - } - } - ) - } catch (error) { - removeDestroyedListener() - throw error - } - let pairingIsCurrent = false - try { - const currentEnvironment = resolveEnvironment(getUserDataPath(), environment.id) - pairingIsCurrent = - (currentEnvironment.pairingRevision ?? currentEnvironment.createdAt) === pairingRevision - } catch { - pairingIsCurrent = false - } - if (!transportSetupIsCurrent() || !pairingIsCurrent) { - removeDestroyedListener() - subscription.close() - throw new Error('Runtime environment pairing changed; refresh and try again') - } - if (senderDestroyed || transportClosed || sender.isDestroyed()) { - removeDestroyedListener() - subscription.close() - return { subscriptionId, requestId: subscription.requestId } - } - remoteRuntimeSubscriptions.set(subscriptionId, { - requestId: subscription.requestId, - environmentId: environment.id, - ownerWebContentsId, - removeDestroyedListener, - releaseOwnership, - sendBinary: (bytes) => subscription?.sendBinary(bytes) ?? false, - close: () => { - removeDestroyedListener() - releaseOwnership() - subscription?.close() - } - }) - ownershipTransferred = true - return { subscriptionId, requestId: subscription.requestId } - } finally { - if (!ownershipTransferred) { - releaseOwnership() - } - } - } - ) - ipcMain.handle( - 'runtimeEnvironments:unsubscribe', - (event, args: { subscriptionId: string }): { unsubscribed: boolean } => { - const subscription = remoteRuntimeSubscriptions.get(args.subscriptionId) - if (!subscription || subscription.ownerWebContentsId !== event.sender.id) { - return { unsubscribed: false } - } - remoteRuntimeSubscriptions.delete(args.subscriptionId) - subscription.close() - return { unsubscribed: true } - } - ) - ipcMain.on( - 'runtimeEnvironments:subscriptionBinary', - (event, args: { subscriptionId?: unknown; bytes?: unknown }) => { - if (typeof args.subscriptionId !== 'string') { - return - } - const bytes = toBinaryPayload(args.bytes) - if (!bytes || !isRemoteRuntimeBinaryFrameWithinLimit(bytes)) { - return - } - const subscription = remoteRuntimeSubscriptions.get(args.subscriptionId) - if (subscription?.ownerWebContentsId === event.sender.id) { - subscription.sendBinary(bytes) - } - } - ) -} - -function toBinaryPayload(value: unknown): Uint8Array | null { - if (value instanceof Uint8Array) { - return value - } - if (value instanceof ArrayBuffer) { - return new Uint8Array(value) - } - if (ArrayBuffer.isView(value)) { - return new Uint8Array(value.buffer, value.byteOffset, value.byteLength) - } - return null -} diff --git a/src/main/ipc/runtime-environment-transport-generation.test.ts b/src/main/ipc/runtime-environment-transport-generation.test.ts deleted file mode 100644 index 664ed842fd9..00000000000 --- a/src/main/ipc/runtime-environment-transport-generation.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { afterEach, describe, expect, it } from 'vitest' -import { - _internals, - advanceRuntimeEnvironmentTransportGeneration, - retainRuntimeEnvironmentTransportGeneration -} from './runtime-environment-transport-generation' - -afterEach(() => { - _internals.resetForTest() -}) - -describe('runtime environment transport generations', () => { - it('invalidates retained transports and releases their environment entry', () => { - const lease = retainRuntimeEnvironmentTransportGeneration('environment-1') - - expect(lease.isCurrent()).toBe(true) - advanceRuntimeEnvironmentTransportGeneration('environment-1') - expect(lease.isCurrent()).toBe(false) - - lease.release() - expect(_internals.trackedEnvironmentCountForTest()).toBe(0) - }) - - it('retains a shared environment entry until its final lease closes', () => { - const first = retainRuntimeEnvironmentTransportGeneration('environment-1') - const second = retainRuntimeEnvironmentTransportGeneration('environment-1') - - first.release() - expect(second.isCurrent()).toBe(true) - expect(_internals.trackedEnvironmentCountForTest()).toBe(1) - - second.release() - expect(_internals.trackedEnvironmentCountForTest()).toBe(0) - }) - - it('does not accumulate tombstones under sequential unique-environment churn', () => { - for (let index = 0; index < 10_000; index += 1) { - const environmentId = `environment-${index}` - const lease = retainRuntimeEnvironmentTransportGeneration(environmentId) - advanceRuntimeEnvironmentTransportGeneration(environmentId) - lease.release() - } - - expect(_internals.trackedEnvironmentCountForTest()).toBe(0) - }) - - it('does not allocate tombstones when invalidating environments without transports', () => { - for (let index = 0; index < 10_000; index += 1) { - advanceRuntimeEnvironmentTransportGeneration(`environment-${index}`) - } - - expect(_internals.trackedEnvironmentCountForTest()).toBe(0) - }) -}) diff --git a/src/main/ipc/runtime-environment-transport-generation.ts b/src/main/ipc/runtime-environment-transport-generation.ts index 263b6dbc7a4..e39c86a52df 100644 --- a/src/main/ipc/runtime-environment-transport-generation.ts +++ b/src/main/ipc/runtime-environment-transport-generation.ts @@ -1,71 +1,12 @@ -type RuntimeEnvironmentTransportGenerationEntry = { - token: number - leaseCount: number -} +const generationByEnvironment = new Map() -export type RuntimeEnvironmentTransportGenerationLease = { - isCurrent: () => boolean - release: () => void -} - -const generationByEnvironment = new Map() -let nextGenerationToken = 1 - -function allocateGenerationToken(): number { - if (!Number.isSafeInteger(nextGenerationToken)) { - throw new Error('Runtime environment transport generation exhausted') - } - const token = nextGenerationToken - nextGenerationToken += 1 - return token -} - -export function retainRuntimeEnvironmentTransportGeneration( - environmentId: string -): RuntimeEnvironmentTransportGenerationLease { - let entry = generationByEnvironment.get(environmentId) - if (!entry) { - entry = { token: allocateGenerationToken(), leaseCount: 0 } - generationByEnvironment.set(environmentId, entry) - } - entry.leaseCount += 1 - const retainedEntry = entry - const retainedToken = entry.token - let retained = true - return { - isCurrent: () => - retained && - generationByEnvironment.get(environmentId) === retainedEntry && - retainedEntry.token === retainedToken, - release: () => { - if (!retained) { - return - } - retained = false - retainedEntry.leaseCount -= 1 - if ( - retainedEntry.leaseCount === 0 && - generationByEnvironment.get(environmentId) === retainedEntry - ) { - generationByEnvironment.delete(environmentId) - } - } - } +export function getRuntimeEnvironmentTransportGeneration(environmentId: string): number { + return generationByEnvironment.get(environmentId) ?? 0 } export function advanceRuntimeEnvironmentTransportGeneration(environmentId: string): void { - const entry = generationByEnvironment.get(environmentId) - if (entry) { - entry.token = allocateGenerationToken() - } -} - -export const _internals = { - trackedEnvironmentCountForTest(): number { - return generationByEnvironment.size - }, - resetForTest(): void { - generationByEnvironment.clear() - nextGenerationToken = 1 - } + generationByEnvironment.set( + environmentId, + getRuntimeEnvironmentTransportGeneration(environmentId) + 1 + ) } diff --git a/src/main/ipc/runtime-environment-transport-routing.ts b/src/main/ipc/runtime-environment-transport-routing.ts index efde61ccd5c..46241a82521 100644 --- a/src/main/ipc/runtime-environment-transport-routing.ts +++ b/src/main/ipc/runtime-environment-transport-routing.ts @@ -1,8 +1,11 @@ -import { getPreferredPairingOffer } from '../../shared/runtime-environments' +import { + getPreferredPairingOffer, + type KnownRuntimeEnvironment +} from '../../shared/runtime-environments' import { resolveEnvironment, markEnvironmentUsed } from '../../shared/runtime-environment-store' import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope' -import { serializeRemoteRuntimeRpcRequest } from '../../shared/remote-runtime-memory-limits' import type { RuntimeStatus } from '../../shared/runtime-types' +import { REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY } from '../../shared/protocol-version' import { sendRemoteRuntimeRequest, subscribeRemoteRuntimeRequest, @@ -19,14 +22,14 @@ import { import { attachRemoteControlDiagnostics } from './runtime-environment-status-diagnostics' import { runtimeEnvironmentRevisionFailure } from './runtime-environment-revision-guard' import { withTailscaleHintForResponse } from './runtime-environment-tailscale-response' -import { supportsSharedControl } from './runtime-environment-shared-control-support' - -export { - clearSharedControlSupport, - resetSharedControlSupport -} from './runtime-environment-shared-control-support' const DEFAULT_REMOTE_RUNTIME_TIMEOUT_MS = 15_000 +const sharedControlSupport = new Map }>() + +export const resetSharedControlSupport = (): void => sharedControlSupport.clear() + +export const clearSharedControlSupport = (environmentId: string): void => + void sharedControlSupport.delete(environmentId) export async function getRuntimeEnvironmentStatus( userDataPath: string, @@ -81,78 +84,58 @@ export async function callRuntimeEnvironment( expectedEnvironmentPairingRevision?: number ): Promise> { const environment = resolveEnvironment(userDataPath, selector) - const initialPairing = getPreferredPairingOffer(environment) - const retainedBytes = Buffer.byteLength( - serializeRemoteRuntimeRpcRequest({ - requestId: '00000000-0000-4000-8000-000000000000', - deviceToken: initialPairing.deviceToken, - method, - params - }), - 'utf8' - ) // Why: connection failures reject (they don't resolve as ok:false), so the // Tailscale hint is applied to the thrown error here — wrapping the resolved // value would miss the in-use connect/timeout case the toast surfaces. // Track the endpoint the queued closure actually used: it re-resolves the // environment, so a re-pair between enqueue and dispatch can change it. - let endpoint = initialPairing.endpoint + let endpoint = getPreferredPairingOffer(environment).endpoint try { - return await enqueueRuntimeCall( - environment.id, - method, - async () => { - const currentEnvironment = resolveEnvironment(userDataPath, environment.id) - const revisionFailure = runtimeEnvironmentRevisionFailure( - currentEnvironment, - expectedEnvironmentPairingRevision, - method + return await enqueueRuntimeCall(environment.id, method, async () => { + const currentEnvironment = resolveEnvironment(userDataPath, environment.id) + const revisionFailure = runtimeEnvironmentRevisionFailure( + currentEnvironment, + expectedEnvironmentPairingRevision, + method + ) + if (revisionFailure) { + return revisionFailure + } + const pairing = getPreferredPairingOffer(currentEnvironment) + endpoint = pairing.endpoint + const effectiveTimeoutMs = timeoutMs ?? DEFAULT_REMOTE_RUNTIME_TIMEOUT_MS + if (shouldUseCachedRequestConnection(method)) { + const response = await sendRemoteRuntimeConnectionRequest( + currentEnvironment.id, + pairing, + method, + params, + effectiveTimeoutMs ) - if (revisionFailure) { - return revisionFailure - } - const pairing = getPreferredPairingOffer(currentEnvironment) - endpoint = pairing.endpoint - const effectiveTimeoutMs = timeoutMs ?? DEFAULT_REMOTE_RUNTIME_TIMEOUT_MS - if (shouldUseCachedRequestConnection(method)) { - const response = await sendRemoteRuntimeConnectionRequest( - currentEnvironment.id, - pairing, - method, - params, - effectiveTimeoutMs - ) - markEnvironmentUsedFromResponse(userDataPath, currentEnvironment.id, response) - return response - } - if ( - method !== 'status.get' && - !shouldUseOneShotRequest(method) && - (await supportsSharedControl( - userDataPath, - currentEnvironment, - pairing, - effectiveTimeoutMs - )) - ) { - const response = await sendRemoteRuntimeSharedControlRequest( - currentEnvironment.id, - pairing, - method, - params, - effectiveTimeoutMs - ) - markEnvironmentUsedFromResponse(userDataPath, currentEnvironment.id, response) - return response - } - // Why: startup/control-plane RPCs use the proven one-shot path so repo - // hydration cannot be coupled to a stale terminal-control connection. - const response = await sendRemoteRuntimeRequest(pairing, method, params, effectiveTimeoutMs) markEnvironmentUsedFromResponse(userDataPath, currentEnvironment.id, response) return response - }, - retainedBytes - ) + } + if ( + method !== 'status.get' && + !shouldUseOneShotRequest(method) && + (await supportsSharedControl(userDataPath, currentEnvironment, pairing, effectiveTimeoutMs)) + ) { + const response = await sendRemoteRuntimeSharedControlRequest( + currentEnvironment.id, + pairing, + method, + params, + effectiveTimeoutMs + ) + markEnvironmentUsedFromResponse(userDataPath, currentEnvironment.id, response) + return response + } + // Why: startup/control-plane RPCs use the proven one-shot path so repo + // hydration cannot be coupled to a stale terminal-control connection. + const response = await sendRemoteRuntimeRequest(pairing, method, params, effectiveTimeoutMs) + markEnvironmentUsedFromResponse(userDataPath, currentEnvironment.id, response) + return response + }) } catch (error) { if (error instanceof Error) { error.message = withRemoteRuntimeTailscaleHint(error.message, endpoint) @@ -274,3 +257,66 @@ function shouldUseSharedControlSubscription(method: string): boolean { method === 'files.watch' ) } + +async function supportsSharedControl( + userDataPath: string, + environment: KnownRuntimeEnvironment, + pairing: ReturnType, + timeoutMs: number +): Promise { + const cacheKey = getSharedControlSupportCacheKey(environment, pairing) + const cached = sharedControlSupport.get(environment.id) + if (cached?.cacheKey === cacheKey) { + return cached.check + } + let resolvedCacheKey = cacheKey + const check = (async () => { + const response = await sendRemoteRuntimeRequest( + pairing, + 'status.get', + undefined, + timeoutMs + ) + if (response.ok === true) { + markEnvironmentUsed(userDataPath, environment.id, { runtimeId: response._meta.runtimeId }) + resolvedCacheKey = getSharedControlSupportCacheKey( + environment, + pairing, + response._meta.runtimeId + ) + return ( + response.result.capabilities?.includes(REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY) === true + ) + } + return false + })() + // Why: the same saved host can be re-paired or point at a different runtime + // binary over time; capability support belongs to that pairing/runtime identity. + sharedControlSupport.set(environment.id, { cacheKey, check }) + try { + const supported = await check + const cachedAfterCheck = sharedControlSupport.get(environment.id) + if (cachedAfterCheck?.check === check && cachedAfterCheck.cacheKey !== resolvedCacheKey) { + sharedControlSupport.set(environment.id, { cacheKey: resolvedCacheKey, check }) + } + return supported + } catch (error) { + if (sharedControlSupport.get(environment.id)?.check === check) { + sharedControlSupport.delete(environment.id) + } + throw error + } +} + +function getSharedControlSupportCacheKey( + environment: KnownRuntimeEnvironment, + pairing: ReturnType, + runtimeId = environment.runtimeId +): string { + return [ + runtimeId ?? 'unknown-runtime', + pairing.endpoint, + pairing.deviceToken, + pairing.publicKeyB64 + ].join('\0') +} diff --git a/src/main/ipc/runtime-environments.test.ts b/src/main/ipc/runtime-environments.test.ts index ee90414938a..5a0cd612864 100644 --- a/src/main/ipc/runtime-environments.test.ts +++ b/src/main/ipc/runtime-environments.test.ts @@ -1381,56 +1381,6 @@ describe('registerRuntimeEnvironmentHandlers', () => { markUsedSpy.mockRestore() }) - it('drops a queued old-peer frame after unsubscribe and transport invalidation', async () => { - registerRuntimeEnvironmentHandlers(store as never) - const close = vi.fn() - subscribeRemoteRuntimeRequestMock.mockResolvedValue({ - requestId: 'queued-old-peer', - close, - sendBinary: vi.fn() - }) - - const add = handler< - { name: string; pairingCode: string }, - { environment: { id: string; name: string } } - >('runtimeEnvironments:addFromPairingCode') - const added = await add(null, { name: 'desk', pairingCode: pairingCode() }) - const senderSend = vi.fn() - const subscribe = handler< - { selector: string; method: string; subscriptionId: string }, - { subscriptionId: string; requestId: string } - >('runtimeEnvironments:subscribe') - const result = await subscribe( - { - sender: { - id: 1, - isDestroyed: () => false, - send: senderSend, - once: vi.fn(), - removeListener: vi.fn() - } - }, - { - selector: added.environment.id, - method: 'terminal.subscribe', - subscriptionId: 'queued-old-peer' - } - ) - const callbacks = subscribeRemoteRuntimeRequestMock.mock.calls[0]![4] as { - onBinary: (bytes: Uint8Array) => void - } - const unsubscribe = handler<{ subscriptionId: string }, { unsubscribed: boolean }>( - 'runtimeEnvironments:unsubscribe' - ) - - expect(await unsubscribe({ sender: { id: 1 } }, result)).toEqual({ unsubscribed: true }) - invalidateRuntimeEnvironmentTransport(added.environment.id) - callbacks.onBinary(new Uint8Array([1, 2, 3])) - - expect(close).toHaveBeenCalledTimes(1) - expect(senderSend).not.toHaveBeenCalled() - }) - it('closes streaming subscriptions when their saved runtime is removed', async () => { registerRuntimeEnvironmentHandlers(store as never) const close = vi.fn() diff --git a/src/main/ipc/runtime-environments.ts b/src/main/ipc/runtime-environments.ts index 5e5cede71b2..9662b8ab598 100644 --- a/src/main/ipc/runtime-environments.ts +++ b/src/main/ipc/runtime-environments.ts @@ -1,4 +1,5 @@ import { app, ipcMain } from 'electron' +import { randomUUID } from 'node:crypto' import { addEnvironmentFromPairingCode, listEnvironments, @@ -11,20 +12,20 @@ import { } from '../../shared/runtime-environments' import type { RuntimeStatus } from '../../shared/runtime-types' import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope' +import type { RemoteRuntimeSubscription } from '../../shared/remote-runtime-client' import type { Store } from '../persistence' import { closeRemoteRuntimeRequestConnection } from './runtime-environment-request-connections' -import { advanceRuntimeEnvironmentTransportGeneration } from './runtime-environment-transport-generation' +import { + advanceRuntimeEnvironmentTransportGeneration, + getRuntimeEnvironmentTransportGeneration +} from './runtime-environment-transport-generation' import { callRuntimeEnvironment, clearSharedControlSupport, getRuntimeEnvironmentStatus, - resetSharedControlSupport + resetSharedControlSupport, + subscribeRuntimeEnvironment } from './runtime-environment-transport-routing' -import { - closeAllRuntimeEnvironmentSubscriptions, - closeRuntimeEnvironmentSubscriptionsForEnvironment, - registerRuntimeEnvironmentSubscriptionHandlers -} from './runtime-environment-subscription-handlers' const RUNTIME_ENVIRONMENT_HANDLER_CHANNELS = [ 'runtimeEnvironments:list', @@ -38,14 +39,30 @@ const RUNTIME_ENVIRONMENT_HANDLER_CHANNELS = [ 'runtimeEnvironments:unsubscribe' ] as const +type RetainedRemoteRuntimeSubscription = RemoteRuntimeSubscription & { + environmentId: string + ownerWebContentsId: number + removeDestroyedListener: () => void +} +const remoteRuntimeSubscriptions = new Map() const getUserDataPath = (): string => app.getPath('userData') +function closeSubscriptionsForEnvironment(environmentId: string): void { + // Why: removed runtimes must not retain terminal/browser WebSockets until renderer teardown. + for (const [subscriptionId, subscription] of remoteRuntimeSubscriptions) { + if (subscription.environmentId !== environmentId) { + continue + } + remoteRuntimeSubscriptions.delete(subscriptionId) + subscription.close() + } +} export function invalidateRuntimeEnvironmentTransport(environmentId: string): void { // Why: a same-id re-pair must retire every transport that still authenticates as the old peer. advanceRuntimeEnvironmentTransportGeneration(environmentId) closeRemoteRuntimeRequestConnection(environmentId) clearSharedControlSupport(environmentId) - closeRuntimeEnvironmentSubscriptionsForEnvironment(environmentId) + closeSubscriptionsForEnvironment(environmentId) } function listPublicRuntimeEnvironments(): PublicKnownRuntimeEnvironment[] { @@ -57,7 +74,6 @@ export function registerRuntimeEnvironmentHandlers(store: Store): void { // Why: keep direct re-registration safe even though register-core-handlers // normally guards this path; otherwise the binary send listener can stack. resetSharedControlSupport() - closeAllRuntimeEnvironmentSubscriptions() for (const channel of RUNTIME_ENVIRONMENT_HANDLER_CHANNELS) { ipcMain.removeHandler(channel) } @@ -137,5 +153,160 @@ export function registerRuntimeEnvironmentHandlers(store: Store): void { ) } ) - registerRuntimeEnvironmentSubscriptionHandlers(getUserDataPath) + ipcMain.handle( + 'runtimeEnvironments:subscribe', + async ( + event, + args: { + selector: string + method: string + params?: unknown + timeoutMs?: number + subscriptionId?: string + expectedEnvironmentPairingRevision?: number + } + ): Promise<{ subscriptionId: string; requestId: string }> => { + const subscriptionId = + typeof args.subscriptionId === 'string' && args.subscriptionId.length > 0 + ? args.subscriptionId + : randomUUID() + if (remoteRuntimeSubscriptions.has(subscriptionId)) { + throw new Error('Runtime environment subscription id already exists') + } + const environment = resolveEnvironment(getUserDataPath(), args.selector) + const pairingRevision = environment.pairingRevision ?? environment.createdAt + if ( + args.expectedEnvironmentPairingRevision !== undefined && + pairingRevision !== args.expectedEnvironmentPairingRevision + ) { + throw new Error('Runtime environment pairing changed; refresh and try again') + } + const transportGeneration = getRuntimeEnvironmentTransportGeneration(environment.id) + const transportIsCurrent = (): boolean => + getRuntimeEnvironmentTransportGeneration(environment.id) === transportGeneration + const sender = event.sender + const ownerWebContentsId = sender.id + let senderDestroyed = sender.isDestroyed() + let subscription: RemoteRuntimeSubscription | null = null + let destroyedListenerAttached = false + const removeDestroyedListener = (): void => { + if (!destroyedListenerAttached) { + return + } + destroyedListenerAttached = false + sender.removeListener('destroyed', closeSubscription) + } + const closeSubscription = (): void => { + senderDestroyed = true + const retained = remoteRuntimeSubscriptions.get(subscriptionId) ?? null + remoteRuntimeSubscriptions.delete(subscriptionId) + if (retained) { + retained.close() + return + } + removeDestroyedListener() + subscription?.close() + } + sender.once('destroyed', closeSubscription) + destroyedListenerAttached = true + try { + subscription = await subscribeRuntimeEnvironment( + getUserDataPath(), + environment.id, + args.method, + args.params, + args.timeoutMs, + { + onEvent: (payload) => { + if (transportIsCurrent() && !sender.isDestroyed()) { + sender.send('runtimeEnvironments:subscriptionEvent', { + subscriptionId, + ...payload + }) + } + }, + onClose: () => { + const retained = remoteRuntimeSubscriptions.get(subscriptionId) ?? null + retained?.removeDestroyedListener() + remoteRuntimeSubscriptions.delete(subscriptionId) + } + } + ) + } catch (error) { + removeDestroyedListener() + throw error + } + let pairingIsCurrent = false + try { + const currentEnvironment = resolveEnvironment(getUserDataPath(), environment.id) + pairingIsCurrent = + (currentEnvironment.pairingRevision ?? currentEnvironment.createdAt) === pairingRevision + } catch { + pairingIsCurrent = false + } + if (!transportIsCurrent() || !pairingIsCurrent) { + removeDestroyedListener() + subscription.close() + throw new Error('Runtime environment pairing changed; refresh and try again') + } + if (senderDestroyed || sender.isDestroyed()) { + removeDestroyedListener() + subscription.close() + return { subscriptionId, requestId: subscription.requestId } + } + remoteRuntimeSubscriptions.set(subscriptionId, { + requestId: subscription.requestId, + environmentId: environment.id, + ownerWebContentsId, + removeDestroyedListener, + sendBinary: (bytes) => subscription?.sendBinary(bytes) ?? false, + close: () => { + removeDestroyedListener() + subscription?.close() + } + }) + return { subscriptionId, requestId: subscription.requestId } + } + ) + ipcMain.handle( + 'runtimeEnvironments:unsubscribe', + (event, args: { subscriptionId: string }): { unsubscribed: boolean } => { + const subscription = remoteRuntimeSubscriptions.get(args.subscriptionId) + if (!subscription || subscription.ownerWebContentsId !== event.sender.id) { + return { unsubscribed: false } + } + remoteRuntimeSubscriptions.delete(args.subscriptionId) + subscription.close() + return { unsubscribed: true } + } + ) + ipcMain.on( + 'runtimeEnvironments:subscriptionBinary', + (event, args: { subscriptionId?: unknown; bytes?: unknown }) => { + if (typeof args.subscriptionId !== 'string') { + return + } + const bytes = toBinaryPayload(args.bytes) + if (!bytes) { + return + } + const subscription = remoteRuntimeSubscriptions.get(args.subscriptionId) + if (subscription?.ownerWebContentsId === event.sender.id) { + subscription.sendBinary(bytes) + } + } + ) +} + +function toBinaryPayload(value: unknown): Uint8Array | null { + if (value instanceof Uint8Array) { + return value + } + if (value instanceof ArrayBuffer) { + return new Uint8Array(value) + } + if (ArrayBuffer.isView(value)) { + return new Uint8Array(value.buffer, value.byteOffset, value.byteLength) + } + return null } diff --git a/src/main/ipc/shell-repo-icon-picker.ts b/src/main/ipc/shell-repo-icon-picker.ts deleted file mode 100644 index 3d376f1ab85..00000000000 --- a/src/main/ipc/shell-repo-icon-picker.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { dialog } from 'electron' -import { stat } from 'node:fs/promises' -import { basename, extname } from 'node:path' -import { - NodeFileReadTooLargeError, - readNodeFileWithinLimit -} from '../../shared/node-bounded-file-reader' -import { assertRasterImagePreviewWithinLimits } from '../../shared/raster-image-preview-limits' -import { MAX_REPO_ICON_UPLOAD_BYTES } from '../../shared/repo-icon' - -const REPO_ICON_IMAGE_MIME_TYPES: Record = { - '.png': 'image/png' -} - -export async function pickRepoIconImage(): Promise<{ - dataUrl: string - fileName: string -} | null> { - const result = await dialog.showOpenDialog({ - properties: ['openFile'], - filters: [{ name: 'Repo icon images', extensions: ['png'] }] - }) - if (result.canceled || result.filePaths.length === 0) { - return null - } - - const filePath = result.filePaths[0] - const mimeType = REPO_ICON_IMAGE_MIME_TYPES[extname(filePath).toLowerCase()] - if (!mimeType) { - throw new Error('Repo icons must be PNG files.') - } - - const stats = await stat(filePath) - if (stats.size > MAX_REPO_ICON_UPLOAD_BYTES) { - throw new Error('Repo icon image must be 256KB or smaller.') - } - - let buffer: Buffer - try { - buffer = (await readNodeFileWithinLimit(filePath, MAX_REPO_ICON_UPLOAD_BYTES)).buffer - } catch (error) { - if (error instanceof NodeFileReadTooLargeError) { - throw new Error('Repo icon image must be 256KB or smaller.') - } - throw error - } - assertRasterImagePreviewWithinLimits(buffer, mimeType) - return { - dataUrl: `data:${mimeType};base64,${buffer.toString('base64')}`, - fileName: basename(filePath) - } -} diff --git a/src/main/ipc/shell.test.ts b/src/main/ipc/shell.test.ts index 9656fbf18d2..7193da4beaa 100644 --- a/src/main/ipc/shell.test.ts +++ b/src/main/ipc/shell.test.ts @@ -1,15 +1,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { normalize, resolve } from 'node:path' import { pathToFileURL } from 'node:url' -import * as NodeBoundedFileReader from '../../shared/node-bounded-file-reader' const { getSpawnArgsForWindowsMock, handleMock, openPathMock, - readNodeFileWithinLimitMock, resolveCliCommandMock, - readFileMock, showItemInFolderMock, showOpenDialogMock, spawnMock, @@ -18,9 +15,7 @@ const { getSpawnArgsForWindowsMock: vi.fn(), handleMock: vi.fn(), openPathMock: vi.fn(), - readNodeFileWithinLimitMock: vi.fn(), resolveCliCommandMock: vi.fn(), - readFileMock: vi.fn(), showItemInFolderMock: vi.fn(), showOpenDialogMock: vi.fn(), spawnMock: vi.fn(), @@ -47,11 +42,6 @@ vi.mock('node:fs/promises', () => ({ stat: statMock })) -vi.mock('../../shared/node-bounded-file-reader', async (importOriginal) => { - const actual = await importOriginal() - return { ...actual, readNodeFileWithinLimit: readNodeFileWithinLimitMock } -}) - vi.mock('node:child_process', () => ({ spawn: spawnMock })) @@ -115,11 +105,6 @@ describe('registerShellHandlers', () => { getSpawnArgsForWindowsMock.mockReset() openPathMock.mockReset() resolveCliCommandMock.mockReset() - readFileMock.mockReset() - readNodeFileWithinLimitMock.mockReset() - readNodeFileWithinLimitMock.mockImplementation(async (path: string) => ({ - buffer: await readFileMock(path) - })) showItemInFolderMock.mockReset() showOpenDialogMock.mockReset() spawnMock.mockReset() @@ -169,32 +154,6 @@ describe('registerShellHandlers', () => { await expect(handler({})).resolves.toBeNull() }) - it('rejects a repo icon raster dimension bomb before returning a data URL', async () => { - const bytes = Buffer.alloc(24) - Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(bytes) - bytes.writeUInt32BE(13, 8) - bytes.write('IHDR', 12, 'ascii') - bytes.writeUInt32BE(32_769, 16) - bytes.writeUInt32BE(1, 20) - showOpenDialogMock.mockResolvedValue({ canceled: false, filePaths: ['/tmp/bomb.png'] }) - statMock.mockResolvedValue({ size: bytes.length }) - readFileMock.mockResolvedValue(bytes) - - const handler = getHandler('shell:pickRepoIconImage') - await expect(handler({})).rejects.toThrow('Image dimensions exceed the preview safety limit') - }) - - it('rejects a repo icon that grows past the byte cap after its size check', async () => { - showOpenDialogMock.mockResolvedValue({ canceled: false, filePaths: ['/tmp/growing.png'] }) - statMock.mockResolvedValue({ size: 1 }) - readNodeFileWithinLimitMock.mockRejectedValue( - new NodeBoundedFileReader.NodeFileReadTooLargeError(256 * 1024 + 1, 256 * 1024) - ) - - const handler = getHandler('shell:pickRepoIconImage') - await expect(handler({})).rejects.toThrow('Repo icon image must be 256KB or smaller.') - }) - it('picks an existing directory without enabling native directory creation', async () => { showOpenDialogMock.mockResolvedValue({ canceled: false, diff --git a/src/main/ipc/shell.ts b/src/main/ipc/shell.ts index 60a04205447..760b32eed2a 100644 --- a/src/main/ipc/shell.ts +++ b/src/main/ipc/shell.ts @@ -1,13 +1,14 @@ import { ipcMain, shell, dialog } from 'electron' import { spawn } from 'node:child_process' -import { constants, copyFile, stat } from 'node:fs/promises' -import { isAbsolute, normalize, posix, win32 } from 'node:path' +import { constants, copyFile, readFile, stat } from 'node:fs/promises' +import { basename, extname, isAbsolute, normalize, posix, win32 } from 'node:path' import { fileURLToPath } from 'node:url' import type { ShellOpenExternalEditorRequest, ShellOpenExternalEditorResult, ShellOpenLocalPathResult } from '../../shared/shell-open-types' +import { MAX_REPO_ICON_UPLOAD_BYTES } from '../../shared/repo-icon' import type { Store } from '../persistence' import { getSpawnArgsForWindows } from '../win32-utils' import { @@ -17,10 +18,13 @@ import { type ExternalEditorLaunchSpec } from '../external-editor-launch' import { resolveVsCodeSshAuthority } from '../ssh/vscode-ssh-authority' -import { pickRepoIconImage } from './shell-repo-icon-picker' export { EXTERNAL_EDITOR_CLI_COMMAND } +const REPO_ICON_IMAGE_MIME_TYPES: Record = { + '.png': 'image/png' +} + async function pathExists(pathValue: string): Promise { try { await stat(pathValue) @@ -292,7 +296,36 @@ export function registerShellHandlers(store: Store): void { return result.filePaths[0] }) - ipcMain.handle('shell:pickRepoIconImage', pickRepoIconImage) + ipcMain.handle( + 'shell:pickRepoIconImage', + async (): Promise<{ dataUrl: string; fileName: string } | null> => { + const result = await dialog.showOpenDialog({ + properties: ['openFile'], + filters: [{ name: 'Repo icon images', extensions: ['png'] }] + }) + if (result.canceled || result.filePaths.length === 0) { + return null + } + + const filePath = result.filePaths[0] + const extension = extname(filePath).toLowerCase() + const mimeType = REPO_ICON_IMAGE_MIME_TYPES[extension] + if (!mimeType) { + throw new Error('Repo icons must be PNG files.') + } + + const stats = await stat(filePath) + if (stats.size > MAX_REPO_ICON_UPLOAD_BYTES) { + throw new Error('Repo icon image must be 256KB or smaller.') + } + + const buffer = await readFile(filePath) + return { + dataUrl: `data:${mimeType};base64,${buffer.toString('base64')}`, + fileName: basename(filePath) + } + } + ) ipcMain.handle('shell:pickAudio', async (): Promise => { const result = await dialog.showOpenDialog({ diff --git a/src/main/ipc/speech-ipc-admission.ts b/src/main/ipc/speech-ipc-admission.ts deleted file mode 100644 index d9e0f380e83..00000000000 --- a/src/main/ipc/speech-ipc-admission.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { measureUtf8ByteLength } from '../../shared/utf8-byte-limits' - -export const MAX_SPEECH_AUDIO_CHUNK_BYTES = 1024 * 1024 -export const MAX_SPEECH_HOTWORDS = 256 -export const MAX_SPEECH_HOTWORD_BYTES = 4 * 1024 -export const MAX_SPEECH_HOTWORDS_TOTAL_BYTES = 256 * 1024 -export const MAX_SPEECH_SESSION_ID_BYTES = 1024 -export const MAX_SPEECH_MODEL_ID_BYTES = 1024 -export const MAX_SPEECH_OPENAI_API_KEY_BYTES = 64 * 1024 -export const MAX_PENDING_DESKTOP_DICTATION_STARTS = 16 -export const MAX_ACTIVE_DESKTOP_DICTATION_LISTENERS = 16 - -const MIN_SPEECH_SAMPLE_RATE = 8_000 -const MAX_SPEECH_SAMPLE_RATE = 384_000 -const HOTWORD_LINE_SUFFIX = ' :2.0\n' - -export type DesktopDictationListener = { release: () => void } - -function requireBoundedString( - value: unknown, - name: string, - maxBytes: number, - options: { allowEmpty?: boolean } = {} -): string { - if ( - typeof value !== 'string' || - (!options.allowEmpty && value.length === 0) || - measureUtf8ByteLength(value, { stopAfterBytes: maxBytes }).exceededLimit - ) { - throw new Error(`Invalid ${name}`) - } - return value -} - -export function validateSpeechSessionId(value: unknown): string { - return requireBoundedString( - value === undefined ? 'desktop' : value, - 'speech session id', - MAX_SPEECH_SESSION_ID_BYTES - ) -} - -export function validateSpeechModelId(value: unknown): string { - return requireBoundedString(value, 'speech model id', MAX_SPEECH_MODEL_ID_BYTES) -} - -export function validateOpenAiSpeechApiKey(value: unknown): string { - return requireBoundedString(value, 'OpenAI speech API key', MAX_SPEECH_OPENAI_API_KEY_BYTES) -} - -export function buildSpeechHotwordsContent(value: unknown): string | undefined { - if (value === undefined) { - return undefined - } - if (!Array.isArray(value) || value.length > MAX_SPEECH_HOTWORDS) { - throw new Error('Invalid speech hotwords') - } - const lines: string[] = [] - let retainedBytes = 0 - for (const hotword of value) { - const text = requireBoundedString(hotword, 'speech hotword', MAX_SPEECH_HOTWORD_BYTES, { - allowEmpty: true - }) - const lineBytes = measureUtf8ByteLength(text).byteLength + HOTWORD_LINE_SUFFIX.length - if (lineBytes > MAX_SPEECH_HOTWORDS_TOTAL_BYTES - retainedBytes) { - throw new Error('Speech hotwords are too large') - } - retainedBytes += lineBytes - lines.push(`${text}${HOTWORD_LINE_SUFFIX}`) - } - return lines.length > 0 ? lines.join('') : undefined -} - -export function decodeSpeechAudioChunk( - value: unknown, - sampleRate: unknown -): { samples: Float32Array; sampleRate: number } { - if ( - !(value instanceof Uint8Array) || - value.byteLength === 0 || - value.byteLength > MAX_SPEECH_AUDIO_CHUNK_BYTES || - value.byteLength % Float32Array.BYTES_PER_ELEMENT !== 0 - ) { - throw new Error('Invalid speech audio chunk') - } - if ( - typeof sampleRate !== 'number' || - !Number.isFinite(sampleRate) || - sampleRate < MIN_SPEECH_SAMPLE_RATE || - sampleRate > MAX_SPEECH_SAMPLE_RATE - ) { - throw new Error('Invalid speech sample rate') - } - // Why: worker transfer moves the whole backing buffer; own only the admitted view. - const ownedBytes = new Uint8Array(value.byteLength) - ownedBytes.set(value) - return { samples: new Float32Array(ownedBytes.buffer), sampleRate } -} - -export class SpeechIpcAdmission { - private readonly pendingStarts = new Set() - private readonly activeListeners = new Map() - - claimStart(owner: string): void { - if ( - this.pendingStarts.has(owner) || - this.pendingStarts.size >= MAX_PENDING_DESKTOP_DICTATION_STARTS - ) { - throw new Error('Too many pending speech dictation starts') - } - this.pendingStarts.add(owner) - } - - releaseStart(owner: string): void { - this.pendingStarts.delete(owner) - } - - commitListener(owner: string, listener: DesktopDictationListener): void { - const previous = this.activeListeners.get(owner) - if (!previous && this.activeListeners.size >= MAX_ACTIVE_DESKTOP_DICTATION_LISTENERS) { - this.activeListeners.values().next().value?.release() - } - this.activeListeners.set(owner, listener) - previous?.release() - } - - deleteListenerIfCurrent(owner: string, listener: DesktopDictationListener): void { - if (this.activeListeners.get(owner) === listener) { - this.activeListeners.delete(owner) - } - } - - releaseListener(owner: string): void { - this.activeListeners.get(owner)?.release() - } - - reset(): void { - this.pendingStarts.clear() - for (const listener of Array.from(this.activeListeners.values())) { - listener.release() - } - this.activeListeners.clear() - } - - get pendingStartCount(): number { - return this.pendingStarts.size - } - - get activeListenerCount(): number { - return this.activeListeners.size - } -} diff --git a/src/main/ipc/speech.test.ts b/src/main/ipc/speech.test.ts index f3587b947e5..2e84124b8e9 100644 --- a/src/main/ipc/speech.test.ts +++ b/src/main/ipc/speech.test.ts @@ -43,46 +43,18 @@ vi.mock('../speech/speech-model-deletion', () => ({ deleteLocalSpeechModel: deleteLocalSpeechModelMock })) -import { - clearSpeechIpcAdmissionForTests, - getActiveDesktopDictationListenerCountForTest, - getPendingDesktopDictationStartCountForTest, - registerSpeechHandlers -} from './speech' -import { - MAX_PENDING_DESKTOP_DICTATION_STARTS, - MAX_SPEECH_AUDIO_CHUNK_BYTES, - MAX_SPEECH_HOTWORD_BYTES, - MAX_SPEECH_HOTWORDS, - MAX_SPEECH_SESSION_ID_BYTES -} from './speech-ipc-admission' +import { registerSpeechHandlers } from './speech' type SpeechDownloadHandler = (event: { sender: { id: number } }, modelId: string) => Promise -function getHandler(channel: string): T { +function getHandler(channel: string): SpeechDownloadHandler { const call = handleMock.mock.calls.find((entry) => entry[0] === channel) if (!call) { throw new Error(`${channel} handler not registered`) } - return call[1] as T + return call[1] as SpeechDownloadHandler } -type SpeechFeedHandler = ( - event: { sender: { id: number } }, - buffer: Uint8Array, - sampleRate: number, - sessionId?: string -) => Promise - -type SpeechStartHandler = ( - event: { sender: { id: number } }, - modelId: string, - hotwords?: string[], - sessionId?: string -) => Promise - -type SpeechStopHandler = (event: { sender: { id: number } }, sessionId?: string) => Promise - describe('registerSpeechHandlers', () => { beforeEach(() => { handleMock.mockReset() @@ -90,7 +62,6 @@ describe('registerSpeechHandlers', () => { getSpeechModelManagerMock.mockReset() getSpeechSttServiceMock.mockReset() deleteLocalSpeechModelMock.mockReset() - clearSpeechIpcAdmissionForTests() }) it('clears the model download progress callback after completion', async () => { @@ -185,151 +156,4 @@ describe('registerSpeechHandlers', () => { modelId: 'model-1' }) }) - - it('rejects oversized or malformed audio chunks before reaching the STT service', async () => { - const feedAudio = vi.fn() - getSpeechSttServiceMock.mockReturnValue({ feedAudio }) - registerSpeechHandlers({} as never) - const handler = getHandler('speech:feedAudio') - - await expect( - handler( - { sender: { id: 7 } }, - new Uint8Array(MAX_SPEECH_AUDIO_CHUNK_BYTES + 4), - 48_000, - 'session' - ) - ).rejects.toThrow('Invalid speech audio chunk') - await expect( - handler({ sender: { id: 7 } }, new Uint8Array(3), 48_000, 'session') - ).rejects.toThrow('Invalid speech audio chunk') - expect(feedAudio).not.toHaveBeenCalled() - }) - - it('copies an admitted audio view into an exact-size transferable buffer', async () => { - const feedAudio = vi.fn() - getSpeechSttServiceMock.mockReturnValue({ feedAudio }) - registerSpeechHandlers({} as never) - const handler = getHandler('speech:feedAudio') - const oversizedBacking = new Uint8Array(MAX_SPEECH_AUDIO_CHUNK_BYTES + 128) - const admittedView = oversizedBacking.subarray(64, 64 + MAX_SPEECH_AUDIO_CHUNK_BYTES) - - await handler({ sender: { id: 8 } }, admittedView, 48_000, 'session') - - const samples = feedAudio.mock.calls[0]?.[0] as Float32Array - expect(samples.byteLength).toBe(MAX_SPEECH_AUDIO_CHUNK_BYTES) - expect(samples.buffer.byteLength).toBe(MAX_SPEECH_AUDIO_CHUNK_BYTES) - expect(feedAudio).toHaveBeenCalledWith(samples, 48_000, 'desktop:8:session') - }) - - it.each([ - ['count', Array.from({ length: MAX_SPEECH_HOTWORDS + 1 }, () => 'word')], - ['per-word bytes', ['😀'.repeat(Math.floor(MAX_SPEECH_HOTWORD_BYTES / 4) + 1)]], - ['aggregate bytes', Array.from({ length: 64 }, () => 'x'.repeat(MAX_SPEECH_HOTWORD_BYTES))] - ])('rejects hotword %s overflow before window or service retention', async (_label, hotwords) => { - registerSpeechHandlers({} as never) - const handler = getHandler('speech:startDictation') - - await expect(handler({ sender: { id: 9 } }, 'model-1', hotwords, 'session')).rejects.toThrow( - /hotword/i - ) - expect(fromWebContentsMock).not.toHaveBeenCalled() - expect(getSpeechSttServiceMock).not.toHaveBeenCalled() - }) - - it('rejects oversized session ids for start, feed, and stop before service work', async () => { - registerSpeechHandlers({} as never) - const oversizedSession = '😀'.repeat(Math.floor(MAX_SPEECH_SESSION_ID_BYTES / 4) + 1) - - await expect( - getHandler('speech:startDictation')( - { sender: { id: 10 } }, - 'model-1', - undefined, - oversizedSession - ) - ).rejects.toThrow('Invalid speech session id') - await expect( - getHandler('speech:feedAudio')( - { sender: { id: 10 } }, - new Uint8Array(4), - 16_000, - oversizedSession - ) - ).rejects.toThrow('Invalid speech session id') - await expect( - getHandler('speech:stopDictation')( - { sender: { id: 10 } }, - oversizedSession - ) - ).rejects.toThrow('Invalid speech session id') - expect(getSpeechSttServiceMock).not.toHaveBeenCalled() - }) - - it('caps concurrent desktop dictation starts and releases admission after settlement', async () => { - let resolveStarts = (): void => {} - const startGate = new Promise((resolve) => { - resolveStarts = resolve - }) - const startDictation = vi.fn(() => startGate) - getSpeechSttServiceMock.mockReturnValue({ startDictation, stopDictation: vi.fn() }) - fromWebContentsMock.mockImplementation((sender: { id: number }) => ({ - isDestroyed: vi.fn(() => false), - webContents: { send: vi.fn() }, - once: vi.fn(), - off: vi.fn(), - sender - })) - registerSpeechHandlers({} as never) - const handler = getHandler('speech:startDictation') - const starts = Array.from({ length: MAX_PENDING_DESKTOP_DICTATION_STARTS }, (_, index) => - handler({ sender: { id: 100 + index } }, 'model-1', undefined, 'session') - ) - - await vi.waitFor(() => - expect(getPendingDesktopDictationStartCountForTest()).toBe( - MAX_PENDING_DESKTOP_DICTATION_STARTS - ) - ) - await expect(handler({ sender: { id: 999 } }, 'model-1', undefined, 'session')).rejects.toThrow( - 'Too many pending speech dictation starts' - ) - resolveStarts() - await Promise.all(starts) - - expect(startDictation).toHaveBeenCalledTimes(MAX_PENDING_DESKTOP_DICTATION_STARTS) - expect(getPendingDesktopDictationStartCountForTest()).toBe(0) - }) - - it('replaces the retained window listener for a sequential same-owner restart', async () => { - const stopDictation = vi.fn().mockResolvedValue(undefined) - getSpeechSttServiceMock.mockReturnValue({ - startDictation: vi.fn().mockResolvedValue(undefined), - stopDictation - }) - const firstWindow = { - isDestroyed: vi.fn(() => false), - webContents: { send: vi.fn() }, - once: vi.fn(), - off: vi.fn() - } - const secondWindow = { - isDestroyed: vi.fn(() => false), - webContents: { send: vi.fn() }, - once: vi.fn(), - off: vi.fn() - } - fromWebContentsMock.mockReturnValueOnce(firstWindow).mockReturnValueOnce(secondWindow) - registerSpeechHandlers({} as never) - const start = getHandler('speech:startDictation') - - await start({ sender: { id: 200 } }, 'model-1', undefined, 'session') - await start({ sender: { id: 200 } }, 'model-1', undefined, 'session') - - expect(getActiveDesktopDictationListenerCountForTest()).toBe(1) - expect(firstWindow.off).toHaveBeenCalledWith('closed', expect.any(Function)) - await getHandler('speech:stopDictation')({ sender: { id: 200 } }, 'session') - expect(secondWindow.off).toHaveBeenCalledWith('closed', expect.any(Function)) - expect(getActiveDesktopDictationListenerCountForTest()).toBe(0) - }) }) diff --git a/src/main/ipc/speech.ts b/src/main/ipc/speech.ts index 572493c9deb..f89a2ab8b70 100644 --- a/src/main/ipc/speech.ts +++ b/src/main/ipc/speech.ts @@ -11,28 +11,6 @@ import { saveOpenAiSpeechApiKey } from '../speech/openai-api-key-store' import type { Store } from '../persistence' -import { - buildSpeechHotwordsContent, - decodeSpeechAudioChunk, - SpeechIpcAdmission, - validateOpenAiSpeechApiKey, - validateSpeechModelId, - validateSpeechSessionId -} from './speech-ipc-admission' - -const speechAdmission = new SpeechIpcAdmission() - -export function clearSpeechIpcAdmissionForTests(): void { - speechAdmission.reset() -} - -export function getPendingDesktopDictationStartCountForTest(): number { - return speechAdmission.pendingStartCount -} - -export function getActiveDesktopDictationListenerCountForTest(): number { - return speechAdmission.activeListenerCount -} export function registerSpeechHandlers(store: Store): void { ipcMain.handle('speech:getCatalog', () => { @@ -48,7 +26,7 @@ export function registerSpeechHandlers(store: Store): void { }) ipcMain.handle('speech:saveOpenAiApiKey', async (_event, apiKey: string) => { - saveOpenAiSpeechApiKey(validateOpenAiSpeechApiKey(apiKey)) + saveOpenAiSpeechApiKey(apiKey) return { configured: true } }) @@ -58,7 +36,6 @@ export function registerSpeechHandlers(store: Store): void { }) ipcMain.handle('speech:downloadModel', async (event, modelId: string) => { - const validatedModelId = validateSpeechModelId(modelId) const manager = getSpeechModelManager(store) const window = BrowserWindow.fromWebContents(event.sender) if (!window) { @@ -82,23 +59,22 @@ export function registerSpeechHandlers(store: Store): void { } window.once('closed', cleanupProgressCallback) try { - await manager.downloadModel(validatedModelId) + await manager.downloadModel(modelId) } finally { cleanupProgressCallback() } }) ipcMain.handle('speech:cancelDownload', async (_event, modelId: string) => { - getSpeechModelManager(store).cancelDownload(validateSpeechModelId(modelId)) + getSpeechModelManager(store).cancelDownload(modelId) }) ipcMain.handle('speech:deleteModel', async (_event, modelId: string) => { - const validatedModelId = validateSpeechModelId(modelId) await deleteLocalSpeechModel({ store, modelManager: getSpeechModelManager(store), sttService: getSpeechSttService(store), - modelId: validatedModelId + modelId }) }) @@ -115,21 +91,15 @@ export function registerSpeechHandlers(store: Store): void { ipcMain.handle( 'speech:startDictation', async (event, modelId: string, hotwords?: string[], sessionId = 'desktop') => { - const validatedModelId = validateSpeechModelId(modelId) - const validatedSessionId = validateSpeechSessionId(sessionId) - const hotwordsContent = buildSpeechHotwordsContent(hotwords) const window = BrowserWindow.fromWebContents(event.sender) if (!window) { return } let resolvedHotwordsPath: string | undefined let windowClosed = false - let sessionListenerReleased = false - const owner = getDesktopOwner(event.sender.id, validatedSessionId) - speechAdmission.claimStart(owner) + const owner = getDesktopOwner(event.sender.id, sessionId) const cleanupOnWindowClosed = (): void => { windowClosed = true - cleanupSessionListener() void getSpeechSttService(store) .stopDictation(owner) .finally(() => { @@ -140,14 +110,8 @@ export function registerSpeechHandlers(store: Store): void { .catch(() => {}) } const cleanupSessionListener = (): void => { - if (sessionListenerReleased) { - return - } - sessionListenerReleased = true window.off('closed', cleanupOnWindowClosed) - speechAdmission.deleteListenerIfCurrent(owner, sessionListener) } - const sessionListener = { release: cleanupSessionListener } window.once('closed', cleanupOnWindowClosed) try { @@ -169,9 +133,10 @@ export function registerSpeechHandlers(store: Store): void { } } - if (hotwordsContent) { - const hotwordsFilePath = getHotwordsFilePath(hotwordsContent) - await writeFile(hotwordsFilePath, hotwordsContent, 'utf-8') + if (hotwords && hotwords.length > 0) { + const content = `${hotwords.map((w) => `${w} :2.0`).join('\n')}\n` + const hotwordsFilePath = getHotwordsFilePath(content) + await writeFile(hotwordsFilePath, content, 'utf-8') resolvedHotwordsPath = hotwordsFilePath } @@ -184,36 +149,27 @@ export function registerSpeechHandlers(store: Store): void { } await getSpeechSttService(store).startDictation( - validatedModelId, + modelId, (msg) => { if (window.isDestroyed()) { return } switch (msg.type) { case 'ready': - window.webContents.send('speech:ready', { sessionId: validatedSessionId }) + window.webContents.send('speech:ready', { sessionId }) break case 'partial': - window.webContents.send('speech:partial', { - text: msg.text ?? '', - sessionId: validatedSessionId - }) + window.webContents.send('speech:partial', { text: msg.text ?? '', sessionId }) break case 'final': - window.webContents.send('speech:final', { - text: msg.text ?? '', - sessionId: validatedSessionId - }) + window.webContents.send('speech:final', { text: msg.text ?? '', sessionId }) break case 'stopped': cleanupSessionListener() - window.webContents.send('speech:stopped', { sessionId: validatedSessionId }) + window.webContents.send('speech:stopped', { sessionId }) break case 'error': - window.webContents.send('speech:error', { - error: msg.error ?? '', - sessionId: validatedSessionId - }) + window.webContents.send('speech:error', { error: msg.error ?? '', sessionId }) void getSpeechSttService(store) .stopDictation(owner) .catch(() => undefined) @@ -224,14 +180,6 @@ export function registerSpeechHandlers(store: Store): void { resolvedHotwordsPath, owner ) - if (windowClosed || window.isDestroyed() || sessionListenerReleased) { - cleanupSessionListener() - if (resolvedHotwordsPath) { - unlink(resolvedHotwordsPath).catch(() => {}) - } - return - } - speechAdmission.commitListener(owner, sessionListener) if (resolvedHotwordsPath) { unlink(resolvedHotwordsPath).catch(() => {}) } @@ -241,33 +189,25 @@ export function registerSpeechHandlers(store: Store): void { unlink(resolvedHotwordsPath).catch(() => {}) } throw err - } finally { - speechAdmission.releaseStart(owner) } } ) ipcMain.handle( 'speech:feedAudio', - async (_event, buffer: Uint8Array, sampleRate: number, sessionId = 'desktop') => { + async (_event, buffer: Buffer, sampleRate: number, sessionId = 'desktop') => { // Why: the preload sends audio as a Buffer to avoid Float32Array data // being zeroed out during contextBridge + IPC serialization. - const audio = decodeSpeechAudioChunk(buffer, sampleRate) - const validatedSessionId = validateSpeechSessionId(sessionId) + const samples = new Float32Array(buffer.buffer, buffer.byteOffset, buffer.byteLength / 4) getSpeechSttService(store).feedAudio( - audio.samples, - audio.sampleRate, - getDesktopOwner(_event.sender.id, validatedSessionId) + samples, + sampleRate, + getDesktopOwner(_event.sender.id, sessionId) ) } ) ipcMain.handle('speech:stopDictation', async (_event, sessionId = 'desktop') => { - const owner = getDesktopOwner(_event.sender.id, validateSpeechSessionId(sessionId)) - try { - await getSpeechSttService(store).stopDictation(owner) - } finally { - speechAdmission.releaseListener(owner) - } + await getSpeechSttService(store).stopDictation(getDesktopOwner(_event.sender.id, sessionId)) }) } diff --git a/src/main/ipc/ssh-browse.test.ts b/src/main/ipc/ssh-browse.test.ts index 64f4e81c94c..6d04e8c7b0f 100644 --- a/src/main/ipc/ssh-browse.test.ts +++ b/src/main/ipc/ssh-browse.test.ts @@ -1,10 +1,6 @@ import { EventEmitter } from 'node:events' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { registerSshBrowseHandler, type RemoteDirEntry } from './ssh-browse' -import { - FILESYSTEM_DIRECTORY_LIMIT_MESSAGE, - FILESYSTEM_DIRECTORY_MAX_RETAINED_BYTES -} from '../../shared/filesystem-directory-listing-limit' +import { registerSshBrowseHandler } from './ssh-browse' const { handleMock, removeHandlerMock } = vi.hoisted(() => ({ handleMock: vi.fn(), @@ -81,68 +77,6 @@ describe('registerSshBrowseHandler', () => { expect(channel.stderr.listenerCount('error')).toBe(0) }) - it('closes and rejects a remote listing that exceeds the shared byte limit', async () => { - const channel = Object.assign(createMockChannel(), { close: vi.fn() }) - const exec = vi.fn().mockResolvedValue(channel) - const getConnectionManager = () => ({ getConnection: () => ({ exec }) }) - registerSshBrowseHandler(getConnectionManager as never) - - const resultPromise = handler(null, { targetId: 'ssh-1', dirPath: '~' }) - await Promise.resolve() - channel.emit('data', Buffer.alloc(FILESYSTEM_DIRECTORY_MAX_RETAINED_BYTES + 1, 0x78)) - - await expect(resultPromise).rejects.toThrow(FILESYSTEM_DIRECTORY_LIMIT_MESSAGE) - expect(channel.close).toHaveBeenCalledOnce() - expect(channel.listenerCount('data')).toBe(0) - expect(channel.stderr.listenerCount('data')).toBe(0) - }) - - it('retains tens of thousands of tiny stdout chunks without quadratic copying', async () => { - const channel = createMockChannel() - const exec = vi.fn().mockResolvedValue(channel) - const getConnectionManager = () => ({ getConnection: () => ({ exec }) }) - registerSshBrowseHandler(getConnectionManager as never) - - const resultPromise = handler(null, { targetId: 'ssh-1', dirPath: '~' }) - await Promise.resolve() - channel.emit('data', Buffer.from('/home/user\n')) - for (let index = 0; index < 65_000; index += 1) { - channel.emit('data', Buffer.from(`f${String(index).padStart(5, '0')}\n`)) - } - channel.emit('exit', 0) - channel.emit('close') - - const result = (await resultPromise) as { - resolvedPath: string - entries: RemoteDirEntry[] - } - expect(result.resolvedPath).toBe('/home/user') - expect(result.entries).toHaveLength(65_000) - expect(result.entries[0]).toEqual({ name: 'f00000', isDirectory: false }) - expect(result.entries.at(-1)).toEqual({ name: 'f64999', isDirectory: false }) - }) - - it('decodes a filename whose UTF-8 bytes span stdout chunks', async () => { - const channel = createMockChannel() - const exec = vi.fn().mockResolvedValue(channel) - const getConnectionManager = () => ({ getConnection: () => ({ exec }) }) - registerSshBrowseHandler(getConnectionManager as never) - - const resultPromise = handler(null, { targetId: 'ssh-1', dirPath: '~' }) - await Promise.resolve() - const output = Buffer.from('/home/user\nemoji-🙂.txt\n') - const emojiOffset = output.indexOf(Buffer.from('🙂')) - channel.emit('data', output.subarray(0, emojiOffset + 1)) - channel.emit('data', output.subarray(emojiOffset + 1)) - channel.emit('exit', 0) - channel.emit('close') - - await expect(resultPromise).resolves.toEqual({ - resolvedPath: '/home/user', - entries: [{ name: 'emoji-🙂.txt', isDirectory: false }] - }) - }) - it('escapes remote browse paths before invoking command ls', async () => { const channel = createMockChannel() const exec = vi.fn().mockResolvedValue(channel) diff --git a/src/main/ipc/ssh-browse.ts b/src/main/ipc/ssh-browse.ts index f8539f63183..964c877a557 100644 --- a/src/main/ipc/ssh-browse.ts +++ b/src/main/ipc/ssh-browse.ts @@ -2,14 +2,6 @@ import { ipcMain } from 'electron' import type { SshConnectionManager } from '../ssh/ssh-connection' import type { SshExecOptions } from '../ssh/ssh-connection-utils' import { powerShellCommand, powerShellLiteral } from '../ssh/ssh-remote-powershell' -import { - createFilesystemDirectoryLimitState, - FILESYSTEM_DIRECTORY_LIMIT_MESSAGE, - FILESYSTEM_DIRECTORY_MAX_RETAINED_BYTES, - trackFilesystemDirectoryEntry -} from '../../shared/filesystem-directory-listing-limit' -import { GrowingByteBuffer } from '../../shared/growing-byte-buffer' -import { SystemSshOutputTail } from '../ssh/system-ssh-output-tail' export type RemoteDirEntry = { name: string @@ -110,8 +102,8 @@ async function runBrowseCommand( const channel = options ? await conn.exec(command, options) : await conn.exec(command) return new Promise((resolve, reject) => { - const stdout = new GrowingByteBuffer() - const stderr = new SystemSshOutputTail() + let stdout = '' + let stderr = '' let exitCode: number | null = null let settled = false let timeout: ReturnType | null = null @@ -163,16 +155,10 @@ async function runBrowseCommand( } const onStdoutData = (data: Buffer): void => { - if (data.byteLength > FILESYSTEM_DIRECTORY_MAX_RETAINED_BYTES - stdout.byteLength) { - stdout.clear() - rejectOnce(new Error(FILESYSTEM_DIRECTORY_LIMIT_MESSAGE)) - closeChannel() - return - } - stdout.append(data) + stdout += data.toString() } const onStderrData = (data: Buffer): void => { - stderr.push(data) + stderr += data.toString() } // `exit` fires before `close`; capture the code to tell a failed `ls` (that still printed `pwd`) from an empty listing. const onExit = (code: number | null): void => { @@ -184,24 +170,21 @@ async function runBrowseCommand( const onClose = (): void => { // Why: a null exitCode (channel closed without exit status) isn't success; don't treat empty stdout as an empty dir. if (exitCode !== 0) { - const stderrText = stderr.toString() const msg = - stderrText.trim() || + stderr.trim() || (exitCode === null ? 'Remote listing failed (channel closed without exit status)' : `Remote listing failed (exit ${exitCode})`) rejectOnce(new RemoteBrowseError(msg, exitCode)) return } - const stderrText = stderr.toString() - const stdoutText = stdout.toString('utf8') - if (stderrText.trim() && !stdoutText.trim()) { - rejectOnce(new Error(stderrText.trim())) + if (stderr.trim() && !stdout.trim()) { + rejectOnce(new Error(stderr.trim())) return } // Why: Windows OpenSSH exec emits CRLF; split on \r?\n so a trailing \r doesn't defeat the endsWith('/') dir check or leave a stray CR in names. - const lines = stdoutText.trim().split(/\r?\n/) + const lines = stdout.trim().split(/\r?\n/) if (lines.length === 0) { rejectOnce(new Error('Empty response from remote')) return @@ -209,24 +192,16 @@ async function runBrowseCommand( const resolvedPath = lines[0] const entries: RemoteDirEntry[] = [] - const listingLimit = createFilesystemDirectoryLimitState() for (let i = 1; i < lines.length; i++) { const line = lines[i] if (!line || line === './' || line === '../') { continue } - const name = line.endsWith('/') ? line.slice(0, -1) : line - try { - trackFilesystemDirectoryEntry(listingLimit, { name }) - } catch (error) { - rejectOnce(error instanceof Error ? error : new Error(String(error))) - return - } if (line.endsWith('/')) { - entries.push({ name, isDirectory: true }) + entries.push({ name: line.slice(0, -1), isDirectory: true }) } else { - entries.push({ name, isDirectory: false }) + entries.push({ name: line, isDirectory: false }) } } diff --git a/src/main/ipc/ssh-passphrase.test.ts b/src/main/ipc/ssh-passphrase.test.ts deleted file mode 100644 index 538a648c45d..00000000000 --- a/src/main/ipc/ssh-passphrase.test.ts +++ /dev/null @@ -1,179 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { getUtf8ByteLength } from '../../shared/utf8-byte-limits' -import { - SSH_CREDENTIAL_DETAIL_MAX_UTF8_BYTES, - SSH_RETAINED_IDENTIFIER_MAX_UTF8_BYTES -} from '../../shared/ssh-retained-payload-admission' - -const electronMocks = vi.hoisted(() => { - const handlers = new Map unknown>() - return { - handlers, - ipcMain: { - removeHandler: vi.fn((channel: string) => handlers.delete(channel)), - handle: vi.fn((channel: string, handler: (event: unknown, args: unknown) => unknown) => { - handlers.set(channel, handler) - }) - } - } -}) - -vi.mock('electron', () => ({ ipcMain: electronMocks.ipcMain })) - -import { - getPendingCredentialRequestCountForTests, - registerCredentialHandler, - requestCredential, - resetPendingCredentialRequestsForTests, - SSH_CREDENTIAL_VALUE_MAX_UTF8_BYTES, - SSH_MAX_PENDING_CREDENTIAL_REQUESTS -} from './ssh-passphrase' - -function createWindow() { - return { - isDestroyed: vi.fn(() => false), - webContents: { send: vi.fn() } - } -} - -describe('SSH credential request admission', () => { - beforeEach(() => { - vi.useFakeTimers() - electronMocks.handlers.clear() - resetPendingCredentialRequestsForTests() - }) - - afterEach(() => { - resetPendingCredentialRequestsForTests() - vi.useRealTimers() - }) - - it('forwards ordinary metadata and resolves the submitted credential', async () => { - const win = createWindow() - registerCredentialHandler(() => win as never) - const result = requestCredential(() => win as never, 'ssh-a', 'password', 'example.test') - const request = win.webContents.send.mock.calls[0][1] as { requestId: string } - - electronMocks.handlers.get('ssh:submitCredential')?.( - {}, - { - requestId: request.requestId, - value: 'secret' - } - ) - - await expect(result).resolves.toBe('secret') - expect(win.webContents.send).toHaveBeenNthCalledWith(1, 'ssh:credential-request', { - requestId: request.requestId, - targetId: 'ssh-a', - kind: 'password', - detail: 'example.test' - }) - expect(win.webContents.send).toHaveBeenNthCalledWith(2, 'ssh:credential-resolved', { - requestId: request.requestId - }) - }) - - it('caps detail before sending it to the renderer', () => { - const win = createWindow() - - void requestCredential( - () => win as never, - 'ssh-a', - 'passphrase', - '🙂'.repeat(SSH_CREDENTIAL_DETAIL_MAX_UTF8_BYTES) - ) - - const sent = win.webContents.send.mock.calls[0][1] as { detail: string } - expect(getUtf8ByteLength(sent.detail)).toBeLessThanOrEqual(SSH_CREDENTIAL_DETAIL_MAX_UTF8_BYTES) - }) - - it('rejects an oversized submitted credential without retaining the request', async () => { - const win = createWindow() - registerCredentialHandler(() => win as never) - const result = requestCredential(() => win as never, 'ssh-a', 'password', 'example.test') - const request = win.webContents.send.mock.calls[0][1] as { requestId: string } - - electronMocks.handlers.get('ssh:submitCredential')?.( - {}, - { - requestId: request.requestId, - value: '🙂'.repeat(SSH_CREDENTIAL_VALUE_MAX_UTF8_BYTES) - } - ) - - await expect(result).resolves.toBeNull() - expect(getPendingCredentialRequestCountForTests()).toBe(0) - expect(vi.getTimerCount()).toBe(0) - }) - - it('settles a submitted credential when its resolution notification throws', async () => { - const win = createWindow() - registerCredentialHandler(() => win as never) - const result = requestCredential(() => win as never, 'ssh-a', 'password', 'example.test') - const request = win.webContents.send.mock.calls[0][1] as { requestId: string } - win.webContents.send.mockImplementation(() => { - throw new Error('renderer gone') - }) - - electronMocks.handlers.get('ssh:submitCredential')?.( - {}, - { - requestId: request.requestId, - value: 'secret' - } - ) - - await expect(result).resolves.toBe('secret') - expect(getPendingCredentialRequestCountForTests()).toBe(0) - expect(vi.getTimerCount()).toBe(0) - }) - - it('settles a timed-out credential when its resolution notification throws', async () => { - const win = createWindow() - const result = requestCredential(() => win as never, 'ssh-a', 'password', 'example.test') - win.webContents.send.mockImplementation(() => { - throw new Error('renderer gone') - }) - - await vi.runAllTimersAsync() - - await expect(result).resolves.toBeNull() - expect(getPendingCredentialRequestCountForTests()).toBe(0) - }) - - it('rejects an oversized target without allocating request state', async () => { - const win = createWindow() - const result = requestCredential( - () => win as never, - 'x'.repeat(SSH_RETAINED_IDENTIFIER_MAX_UTF8_BYTES + 1), - 'password', - 'host' - ) - - await expect(result).resolves.toBeNull() - expect(getPendingCredentialRequestCountForTests()).toBe(0) - expect(vi.getTimerCount()).toBe(0) - expect(win.webContents.send).not.toHaveBeenCalled() - }) - - it('fails closed at the pending-request cap without adding a timer or map row', async () => { - const win = createWindow() - const pending = Array.from({ length: SSH_MAX_PENDING_CREDENTIAL_REQUESTS }, (_, index) => - requestCredential(() => win as never, `ssh-${index}`, 'password', 'host') - ) - expect(getPendingCredentialRequestCountForTests()).toBe(SSH_MAX_PENDING_CREDENTIAL_REQUESTS) - expect(vi.getTimerCount()).toBe(SSH_MAX_PENDING_CREDENTIAL_REQUESTS) - - const overflow = requestCredential(() => win as never, 'ssh-overflow', 'password', 'host') - - await expect(overflow).resolves.toBeNull() - expect(getPendingCredentialRequestCountForTests()).toBe(SSH_MAX_PENDING_CREDENTIAL_REQUESTS) - expect(vi.getTimerCount()).toBe(SSH_MAX_PENDING_CREDENTIAL_REQUESTS) - expect(win.webContents.send).toHaveBeenCalledTimes(SSH_MAX_PENDING_CREDENTIAL_REQUESTS) - resetPendingCredentialRequestsForTests() - await expect(Promise.all(pending)).resolves.toEqual( - Array.from({ length: SSH_MAX_PENDING_CREDENTIAL_REQUESTS }, () => null) - ) - }) -}) diff --git a/src/main/ipc/ssh-passphrase.ts b/src/main/ipc/ssh-passphrase.ts index f89fc60a247..e1e21045373 100644 --- a/src/main/ipc/ssh-passphrase.ts +++ b/src/main/ipc/ssh-passphrase.ts @@ -1,15 +1,8 @@ import { ipcMain, type BrowserWindow } from 'electron' import { randomUUID } from 'node:crypto' import type { SshCredentialKind } from '../ssh/ssh-connection-utils' -import { - isSshRetainedIdentifier, - SSH_CREDENTIAL_DETAIL_MAX_UTF8_BYTES -} from '../../shared/ssh-retained-payload-admission' -import { clampUtf8TextPrefix, measureUtf8ByteLength } from '../../shared/utf8-byte-limits' const CREDENTIAL_TIMEOUT_MS = 120_000 -export const SSH_MAX_PENDING_CREDENTIAL_REQUESTS = 64 -export const SSH_CREDENTIAL_VALUE_MAX_UTF8_BYTES = 64 * 1024 const pendingRequests = new Map void }>() function notifyCredentialResolved( @@ -18,11 +11,7 @@ function notifyCredentialResolved( ): void { const win = getMainWindow() if (win && !win.isDestroyed()) { - try { - win.webContents.send('ssh:credential-resolved', { requestId }) - } catch { - // The SSH caller must still settle if its renderer disappears between checks. - } + win.webContents.send('ssh:credential-resolved', { requestId }) } } @@ -32,16 +21,6 @@ export function requestCredential( kind: SshCredentialKind, detail: string ): Promise { - const win = getMainWindow() - if ( - !isSshRetainedIdentifier(targetId) || - pendingRequests.size >= SSH_MAX_PENDING_CREDENTIAL_REQUESTS || - !win || - win.isDestroyed() - ) { - return Promise.resolve(null) - } - const retainedDetail = clampUtf8TextPrefix(detail, SSH_CREDENTIAL_DETAIL_MAX_UTF8_BYTES) const requestId = randomUUID() return new Promise((resolve) => { const timer = setTimeout(() => { @@ -58,16 +37,13 @@ export function requestCredential( } }) - try { - win.webContents.send('ssh:credential-request', { - requestId, - targetId, - kind, - detail: retainedDetail - }) - } catch { + const win = getMainWindow() + if (win && !win.isDestroyed()) { + win.webContents.send('ssh:credential-request', { requestId, targetId, kind, detail }) + } else { pendingRequests.delete(requestId) clearTimeout(timer) + notifyCredentialResolved(getMainWindow, requestId) resolve(null) } }) @@ -77,36 +53,13 @@ export function registerCredentialHandler(getMainWindow: () => BrowserWindow | n ipcMain.removeHandler('ssh:submitCredential') ipcMain.handle( 'ssh:submitCredential', - (_event, args: { requestId?: unknown; value?: unknown }) => { - if (!args || typeof args !== 'object' || typeof args.requestId !== 'string') { - return - } + (_event, args: { requestId: string; value: string | null }) => { const pending = pendingRequests.get(args.requestId) if (pending) { pendingRequests.delete(args.requestId) notifyCredentialResolved(getMainWindow, args.requestId) - const value = - args.value === null || - (typeof args.value === 'string' && - !measureUtf8ByteLength(args.value, { - stopAfterBytes: SSH_CREDENTIAL_VALUE_MAX_UTF8_BYTES - }).exceededLimit) - ? args.value - : null - pending.resolve(value) + pending.resolve(args.value) } } ) } - -export function resetPendingCredentialRequestsForTests(): void { - const pending = Array.from(pendingRequests.values()) - pendingRequests.clear() - for (const request of pending) { - request.resolve(null) - } -} - -export function getPendingCredentialRequestCountForTests(): number { - return pendingRequests.size -} diff --git a/src/main/ipc/ssh.test.ts b/src/main/ipc/ssh.test.ts index 45abd3a7bfb..909498882ee 100644 --- a/src/main/ipc/ssh.test.ts +++ b/src/main/ipc/ssh.test.ts @@ -18,10 +18,7 @@ const { mockPortForwardManager, mockPortScannerCallbacks, mockNextConnectionManagers, - mockNextPortForwardManagers, - routeExternalPtyDataMock, - routeExternalPtyReplayMock, - routeExternalPtyExitMock + mockNextPortForwardManagers } = vi.hoisted(() => ({ handleMock: vi.fn(), powerMonitorOffMock: vi.fn(), @@ -84,10 +81,7 @@ const { }, mockPortScannerCallbacks: new Map(), mockNextConnectionManagers: [] as unknown[], - mockNextPortForwardManagers: [] as unknown[], - routeExternalPtyDataMock: vi.fn(), - routeExternalPtyReplayMock: vi.fn(), - routeExternalPtyExitMock: vi.fn() + mockNextPortForwardManagers: [] as unknown[] })) vi.mock('electron', () => ({ @@ -173,12 +167,6 @@ vi.mock('./pty', () => ({ isRendererPtyOutputPaused: vi.fn().mockReturnValue(false) })) -vi.mock('./pty-renderer-delivery-router', () => ({ - routeExternalPtyData: routeExternalPtyDataMock, - routeExternalPtyReplay: routeExternalPtyReplayMock, - routeExternalPtyExit: routeExternalPtyExitMock -})) - vi.mock('../providers/ssh-filesystem-dispatch', () => ({ registerSshFilesystemProvider: vi.fn(), unregisterSshFilesystemProvider: vi.fn(), @@ -237,11 +225,7 @@ import { getSshPtyProvider, getPtyIdsForConnection } from './pty' -import { - MAX_TRACKED_SSH_CONNECTION_GENERATIONS, - assertSshMutationExpectation, - retainSshConnectionGeneration -} from '../ssh/ssh-connection-generation' +import { assertSshMutationExpectation } from '../ssh/ssh-connection-generation' describe('SSH IPC handlers', () => { const handlers = new Map unknown>() @@ -350,9 +334,6 @@ describe('SSH IPC handlers', () => { mockPtyProvider.onReplay.mockReset() mockPtyProvider.attachForReconnect.mockReset().mockResolvedValue({}) mockPtyProvider.shutdown.mockReset() - routeExternalPtyDataMock.mockReset() - routeExternalPtyReplayMock.mockReset() - routeExternalPtyExitMock.mockReset() mockPortForwardManager.addForward.mockReset() mockPortForwardManager.updateForward.mockReset() mockPortForwardManager.removeForward.mockReset() @@ -500,32 +481,6 @@ describe('SSH IPC handlers', () => { ) }) - it('rejects the 4,097th retained SSH target before starting connection work', async () => { - const target: SshTarget = { - id: 'ssh-overflow', - label: 'Overflow', - host: 'example.com', - port: 22, - username: 'deploy' - } - const leases = Array.from({ length: MAX_TRACKED_SSH_CONNECTION_GENERATIONS }, (_, index) => - retainSshConnectionGeneration(`ssh-cap-${index}`) - ) - mockSshStore.getTarget.mockReturnValue(target) - - try { - await expect(handlers.get('ssh:connect')!(null, { targetId: target.id })).rejects.toThrow( - 'SSH connection generation target capacity exhausted' - ) - expect(mockConnectionManager.connect).not.toHaveBeenCalled() - expect(mockDeployAndLaunchRelay).not.toHaveBeenCalled() - } finally { - for (const lease of leases) { - lease.release() - } - } - }) - it('ssh:connect calls connection manager', async () => { const target: SshTarget = { id: 'ssh-1', @@ -893,7 +848,7 @@ describe('SSH IPC handlers', () => { } }) - it('forwards remote PTY events through shared renderer delivery', async () => { + it('forwards remote PTY events into the runtime', async () => { const runtime = { onPtyData: vi.fn(), onPtyExit: vi.fn() @@ -926,12 +881,13 @@ describe('SSH IPC handlers', () => { onData?.({ id: 'remote-pty', data: 'hello' }) onExit?.({ id: 'remote-pty', code: 7 }) - expect(routeExternalPtyDataMock).toHaveBeenCalledWith({ - id: 'remote-pty', - data: 'hello' - }) - expect(routeExternalPtyExitMock).toHaveBeenCalledWith({ id: 'remote-pty', code: 7 }) - expect(runtime.onPtyData).not.toHaveBeenCalled() + expect(runtime.onPtyData).toHaveBeenCalledWith( + 'remote-pty', + 'hello', + expect.any(Number), + 'hello'.length, + undefined + ) expect(runtime.onPtyExit).toHaveBeenCalledWith('remote-pty', 7, undefined) }) @@ -1294,11 +1250,11 @@ describe('SSH IPC handlers', () => { connectionGeneration: 1 } }) - expect(routeExternalPtyDataMock).toHaveBeenCalledWith({ - id: 'remote-pty', - data: 'hello' - }) - expect(routeExternalPtyExitMock).toHaveBeenCalledWith({ + expect(secondWindow.webContents.send).toHaveBeenCalledWith( + 'pty:data', + expect.objectContaining({ id: 'remote-pty', data: 'hello' }) + ) + expect(secondWindow.webContents.send).toHaveBeenCalledWith('pty:exit', { id: 'remote-pty', code: 9 }) @@ -1306,7 +1262,13 @@ describe('SSH IPC handlers', () => { targetId: 'ssh-1', ports: expect.arrayContaining([expect.objectContaining({ port: 3000 })]) }) - expect(secondRuntime.onPtyData).not.toHaveBeenCalled() + expect(secondRuntime.onPtyData).toHaveBeenCalledWith( + 'remote-pty', + 'hello', + expect.any(Number), + 'hello'.length, + undefined + ) expect(secondRuntime.onPtyExit).toHaveBeenCalledWith('remote-pty', 9, undefined) expect(firstRuntime.onPtyData).not.toHaveBeenCalled() expect(firstRuntime.onPtyExit).not.toHaveBeenCalled() diff --git a/src/main/ipc/ssh.ts b/src/main/ipc/ssh.ts index d088aff842b..16029e22a6a 100644 --- a/src/main/ipc/ssh.ts +++ b/src/main/ipc/ssh.ts @@ -18,7 +18,6 @@ import type { } from '../../shared/ssh-types' import { SSH_TERMINATE_RECONNECT_REQUIRED } from '../../shared/constants' import { isRuntimeOwnedSshTargetId } from '../../shared/execution-host' -import { clampSshConnectionError } from '../../shared/ssh-retained-payload-admission' import { isAuthError } from '../ssh/ssh-connection-utils' import { forceStopRelayForTarget } from '../ssh/ssh-relay-reset' import { isSshPtyNotFoundError } from '../providers/ssh-pty-errors' @@ -43,9 +42,7 @@ import { advanceSshConnectionGeneration, getSshConnectionGeneration, initializeSshConnectionGenerationSession, - resetSshConnectionGenerations, - retainSshConnectionGeneration, - type SshConnectionGenerationLease + resetSshConnectionGenerations } from '../ssh/ssh-connection-generation' let sshStore: SshConnectionStore | null = null @@ -138,24 +135,6 @@ export async function removeRegisteredSshTarget(targetId: string): Promise // One session per SSH target owns the whole relay lifecycle (mux, providers, abort controller, state machine). const activeSessions = new Map() -const activeSessionGenerationLeases = new Map() - -function retainActiveSessionGeneration(targetId: string): boolean { - if (activeSessionGenerationLeases.has(targetId)) { - return false - } - activeSessionGenerationLeases.set(targetId, retainSshConnectionGeneration(targetId)) - return true -} - -function releaseActiveSessionGeneration(targetId: string): void { - const lease = activeSessionGenerationLeases.get(targetId) - if (!lease) { - return - } - activeSessionGenerationLeases.delete(targetId) - lease.release() -} export function getActiveSshAiVaultHostInfo(targetId: string): SshRelayAiVaultHostInfo | null { if (isRuntimeOwnedSshTargetId(targetId)) { @@ -194,7 +173,6 @@ async function teardownActiveSshSession( await portForwardManager?.removeAllForwards(targetId) teardown(session) activeSessions.delete(targetId) - releaseActiveSessionGeneration(targetId) clearRelayLostBackoff(targetId) clearRelayStateOverride(targetId) } @@ -206,6 +184,7 @@ function relayGracePeriodForTarget(target: SshTarget | null | undefined): number // Why: tabs must share one connect, while a disconnect must invalidate that // attempt so its late continuation cannot clobber a replacement. type ConnectAttempt = { + generation: number promise: Promise } @@ -284,7 +263,6 @@ function withSshRemotePlatform(targetId: string, state: SshConnectionState): Ssh const remotePlatform = activeSessions.get(targetId)?.getHostPlatform()?.os return { ...state, - error: clampSshConnectionError(state.error), connectionGeneration: currentConnectGeneration(targetId), ...(remotePlatform ? { remotePlatform } : {}) } @@ -826,41 +804,31 @@ export function registerSshHandlers( appendFileSync(e2eProbePath, `${JSON.stringify(targetId)}\n`) throw new Error('e2e_forbidden_local_ssh_connect') } - const target = sshStore!.getTarget(targetId) - if (!target) { - throw new Error(`SSH target "${targetId}" not found`) + const observedGeneration = currentConnectGeneration(targetId) + const reset = resetRelayInFlight.get(targetId) + if (reset) { + await reset } - const generationLease = retainSshConnectionGeneration(targetId) - let observedGeneration = currentConnectGeneration(targetId) + + // Why: serialize concurrent ssh:connect for the same target; interleaved connects otherwise leak the first session. + const existing = connectInFlight.get(targetId) + if (existing) { + return existing.promise + } + if (currentConnectGeneration(targetId) !== observedGeneration) { + throw connectCancelledError() + } + + pendingTransportReconnects.delete(targetId) + const promise = doConnect(targetId) + const attempt = { generation: currentConnectGeneration(targetId), promise } + connectInFlight.set(targetId, attempt) try { - const reset = resetRelayInFlight.get(targetId) - if (reset) { - await reset - observedGeneration = currentConnectGeneration(targetId) - } - - // Why: serialize concurrent ssh:connect for the same target; interleaved connects otherwise leak the first session. - const existing = connectInFlight.get(targetId) - if (existing) { - return existing.promise - } - if (currentConnectGeneration(targetId) !== observedGeneration) { - throw connectCancelledError() - } - - pendingTransportReconnects.delete(targetId) - const promise = doConnect(targetId) - const attempt = { promise } - connectInFlight.set(targetId, attempt) - try { - return await promise - } finally { - if (connectInFlight.get(targetId) === attempt) { - connectInFlight.delete(targetId) - } - } + return await promise } finally { - generationLease.release() + if (connectInFlight.get(targetId) === attempt) { + connectInFlight.delete(targetId) + } } } @@ -894,14 +862,7 @@ export function registerSshHandlers( return getPublicSshState(targetId)! } - const createdGenerationOwnership = retainActiveSessionGeneration(targetId) - const generation = createdGenerationOwnership - ? currentConnectGeneration(targetId) - : advanceSshConnectionGeneration(targetId) - if (generation === null) { - releaseActiveSessionGeneration(targetId) - throw connectCancelledError() - } + const generation = advanceSshConnectionGeneration(targetId) clearRelayStateOverride(targetId) let conn // Why: tear down any existing session first to avoid leaking its multiplexer, providers, and timers (double-connect / reconnect-after-error). @@ -946,7 +907,6 @@ export function registerSshHandlers( // Why: clear this failed connect's flag so a later non-prompting connect isn't deferred. credentialRequestedForTarget.delete(targetId) activeSessions.delete(targetId) - releaseActiveSessionGeneration(targetId) clearRelayLostBackoff(targetId) clearRelayStateOverride(targetId) broadcastSshState(getCurrentMainWindow, targetId, { @@ -985,7 +945,6 @@ export function registerSshHandlers( throw connectCancelledError() } activeSessions.delete(targetId) - releaseActiveSessionGeneration(targetId) clearRelayLostBackoff(targetId) await connectionManager!.disconnect(targetId) throw err @@ -1061,7 +1020,6 @@ export function registerSshHandlers( await portForwardManager!.removeAllForwards(args.targetId) session.dispose() activeSessions.delete(args.targetId) - releaseActiveSessionGeneration(args.targetId) clearRelayLostBackoff(args.targetId) clearRelayStateOverride(args.targetId) } @@ -1069,53 +1027,46 @@ export function registerSshHandlers( }) async function doResetRelay(targetId: string, target: SshTarget): Promise { - const generationLease = retainSshConnectionGeneration(targetId) - try { - const inFlightConnect = connectInFlight.get(targetId) - if (inFlightConnect) { - try { - // Why: resetting activeSessions mid-deploy would dispose the session doConnect will use. - await inFlightConnect.promise - } catch { - // The reset can still recover a stale remote relay after a failed connect. - } - } - advanceSshConnectionGeneration(targetId) - - const session = activeSessions.get(targetId) - if (session) { - await portForwardManager!.removeAllForwards(targetId) - // Why: detach() not dispose() — reset has its own stale-lease semantics below that dispose()'s clean-termination recording would hide. - session.detach() - activeSessions.delete(targetId) - releaseActiveSessionGeneration(targetId) - clearRelayLostBackoff(targetId) - } - - const existingConn = connectionManager!.getConnection(targetId) - const conn = existingConn ?? (await connectionManager!.connect(target)) + const inFlightConnect = connectInFlight.get(targetId) + if (inFlightConnect) { try { - await forceStopRelayForTarget(conn, targetId) - } finally { - const ptyIds = new Set(getPtyIdsForConnection(targetId)) - for (const lease of persistedStore!.getSshRemotePtyLeases(targetId)) { - if (lease.state !== 'terminated' && lease.state !== 'expired') { - ptyIds.add(lease.ptyId) - persistedStore!.markSshRemotePtyLease(targetId, lease.ptyId, 'expired') - } - } - // Why: reset force-kills the remote relay, so every local PTY handle it owned is stale even if the reset command failed after SIGTERM. - for (const ptyId of ptyIds) { - const appPtyId = toAppSshPtyId(targetId, ptyId) - clearProviderPtyState(appPtyId) - deletePtyOwnership(appPtyId) - } - // Why: reset's connect() may trip onCredentialRequest; clear so a later non-prompting doConnect doesn't persist lastRequiredPassphrase=true. - credentialRequestedForTarget.delete(targetId) - await connectionManager!.disconnect(targetId) + // Why: resetting activeSessions mid-deploy would dispose the session doConnect will use. + await inFlightConnect.promise + } catch { + // The reset can still recover a stale remote relay after a failed connect. } + } + + const session = activeSessions.get(targetId) + if (session) { + await portForwardManager!.removeAllForwards(targetId) + // Why: detach() not dispose() — reset has its own stale-lease semantics below that dispose()'s clean-termination recording would hide. + session.detach() + activeSessions.delete(targetId) + clearRelayLostBackoff(targetId) + } + + const existingConn = connectionManager!.getConnection(targetId) + const conn = existingConn ?? (await connectionManager!.connect(target)) + try { + await forceStopRelayForTarget(conn, targetId) } finally { - generationLease.release() + const ptyIds = new Set(getPtyIdsForConnection(targetId)) + for (const lease of persistedStore!.getSshRemotePtyLeases(targetId)) { + if (lease.state !== 'terminated' && lease.state !== 'expired') { + ptyIds.add(lease.ptyId) + persistedStore!.markSshRemotePtyLease(targetId, lease.ptyId, 'expired') + } + } + // Why: reset force-kills the remote relay, so every local PTY handle it owned is stale even if the reset command failed after SIGTERM. + for (const ptyId of ptyIds) { + const appPtyId = toAppSshPtyId(targetId, ptyId) + clearProviderPtyState(appPtyId) + deletePtyOwnership(appPtyId) + } + // Why: reset's connect() may trip onCredentialRequest; clear so a later non-prompting doConnect doesn't persist lastRequiredPassphrase=true. + credentialRequestedForTarget.delete(targetId) + await connectionManager!.disconnect(targetId) } } @@ -1188,9 +1139,7 @@ export function registerSshHandlers( } testingTargets.add(args.targetId) - let generationLease: SshConnectionGenerationLease | null = null try { - generationLease = retainSshConnectionGeneration(args.targetId) const conn = await connectionManager!.connect(target) const state = conn.getState() await connectionManager!.disconnect(args.targetId) @@ -1201,7 +1150,6 @@ export function registerSshHandlers( error: err instanceof Error ? err.message : String(err) } } finally { - generationLease?.release() testingTargets.delete(args.targetId) // Why: clear so a test's credential prompt doesn't leave lastRequiredPassphrase=true and defer this target at startup. credentialRequestedForTarget.delete(args.targetId) @@ -1323,10 +1271,6 @@ export async function resetSshHandlerStateForTests(): Promise { session.dispose() } activeSessions.clear() - for (const lease of activeSessionGenerationLeases.values()) { - lease.release() - } - activeSessionGenerationLeases.clear() for (const targetId of relayLostBackoff.keys()) { clearRelayLostBackoff(targetId) } diff --git a/src/main/ipc/terminal-preview-admission.test.ts b/src/main/ipc/terminal-preview-admission.test.ts deleted file mode 100644 index 50f3b750179..00000000000 --- a/src/main/ipc/terminal-preview-admission.test.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { handlers, ipcMainMock } = vi.hoisted(() => { - const map = new Map unknown>() - return { - handlers: map, - ipcMainMock: { - removeHandler: vi.fn(), - handle: (channel: string, fn: (...args: unknown[]) => unknown) => map.set(channel, fn) - } - } -}) - -vi.mock('electron', () => ({ ipcMain: ipcMainMock })) -vi.mock('../window/dashboard-popout-window', () => ({ - isDashboardPopoutRenderer: () => true -})) - -import { - registerTerminalPreviewHandlers, - TERMINAL_PREVIEW_MAX_ENTRIES_PER_CONTENTS, - TERMINAL_PREVIEW_MAX_ENTRIES_TOTAL -} from './terminal-preview' - -function makeRuntime() { - return { - serializeTerminalBuffer: vi.fn(async () => ({ data: '', cols: 80, rows: 24, seq: 0 })), - subscribeToTerminalData: vi.fn(() => vi.fn()), - subscribeToTerminalResize: vi.fn(() => vi.fn()), - registerRawTerminalViewSubscriber: vi.fn(() => vi.fn()), - writeTerminalPreviewInput: vi.fn(async () => true), - updateRemoteDesktopViewer: vi.fn(async () => true), - unregisterRemoteDesktopViewer: vi.fn(async () => true), - getTerminalSize: vi.fn(() => ({ cols: 80, rows: 24 })) - } -} - -function makeSender(id: number) { - return { - id, - isDestroyed: () => false, - send: vi.fn(), - once: vi.fn() - } -} - -function eventFor(sender: ReturnType) { - return { sender } as never -} - -describe('terminal preview aggregate admission', () => { - beforeEach(() => handlers.clear()) - - it('bounds output streams per renderer without affecting replacements', async () => { - const runtime = makeRuntime() - registerTerminalPreviewHandlers(runtime as never) - const sender = makeSender(1) - const connect = handlers.get('terminalPreview:connect')! - - for (let index = 0; index < TERMINAL_PREVIEW_MAX_ENTRIES_PER_CONTENTS; index += 1) { - await connect(eventFor(sender), { ptyId: `pty-${index}` }) - } - await expect(connect(eventFor(sender), { ptyId: 'overflow' })).resolves.toEqual({ - snapshot: null, - replay: [] - }) - await expect(connect(eventFor(sender), { ptyId: 'pty-0' })).resolves.toMatchObject({ - snapshot: { cols: 80, rows: 24 } - }) - expect(runtime.subscribeToTerminalData).toHaveBeenCalledTimes( - TERMINAL_PREVIEW_MAX_ENTRIES_PER_CONTENTS + 1 - ) - }) - - it('bounds output streams across renderers', async () => { - const runtime = makeRuntime() - registerTerminalPreviewHandlers(runtime as never) - const connect = handlers.get('terminalPreview:connect')! - - for (let index = 0; index < TERMINAL_PREVIEW_MAX_ENTRIES_TOTAL; index += 1) { - const sender = makeSender(Math.floor(index / TERMINAL_PREVIEW_MAX_ENTRIES_PER_CONTENTS) + 1) - await connect(eventFor(sender), { ptyId: `pty-${index}` }) - } - await expect(connect(eventFor(makeSender(99)), { ptyId: 'overflow' })).resolves.toEqual({ - snapshot: null, - replay: [] - }) - expect(runtime.subscribeToTerminalData).toHaveBeenCalledTimes( - TERMINAL_PREVIEW_MAX_ENTRIES_TOTAL - ) - }) - - it('bounds independent fit claims per renderer', async () => { - const runtime = makeRuntime() - registerTerminalPreviewHandlers(runtime as never) - const sender = makeSender(1) - const fit = handlers.get('terminalPreview:fit')! - - for (let index = 0; index < TERMINAL_PREVIEW_MAX_ENTRIES_PER_CONTENTS; index += 1) { - await fit(eventFor(sender), { ptyId: `pty-${index}`, cols: 80, rows: 24 }) - } - await expect( - fit(eventFor(sender), { ptyId: 'overflow', cols: 80, rows: 24 }) - ).resolves.toBeNull() - expect(runtime.updateRemoteDesktopViewer).toHaveBeenCalledTimes( - TERMINAL_PREVIEW_MAX_ENTRIES_PER_CONTENTS - ) - }) -}) diff --git a/src/main/ipc/terminal-preview-output-stream-memory.test.ts b/src/main/ipc/terminal-preview-output-stream-memory.test.ts deleted file mode 100644 index 271f0eb87d1..00000000000 --- a/src/main/ipc/terminal-preview-output-stream-memory.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { RETAINED_STRING_CHUNK_LIMIT } from '../../shared/string-chunk-compaction' -import { - TERMINAL_PREVIEW_OUTPUT_BATCH_MAX_BYTES, - TERMINAL_PREVIEW_PENDING_MAX_RECORDS, - TerminalPreviewOutputStream -} from './terminal-preview-output-stream' - -function makeStream() { - const contents = { - isDestroyed: () => false, - send: vi.fn() - } - const stream = new TerminalPreviewOutputStream(contents as never, 'pty-1', vi.fn(), vi.fn()) - return { contents, stream } -} - -describe('TerminalPreviewOutputStream memory bounds', () => { - afterEach(() => vi.useRealTimers()) - - it('ignores empty snapshot records and resyncs when tiny records reach the count cap', () => { - const { stream } = makeStream() - - for (let index = 0; index < 100_000; index += 1) { - stream.append('', { seq: index, rawLength: 0 }) - } - expect(stream.consumeInitialOverflow()).toBe(false) - - for (let index = 0; index <= TERMINAL_PREVIEW_PENDING_MAX_RECORDS; index += 1) { - stream.append('x', { seq: index + 1, rawLength: 1 }) - } - expect(stream.consumeInitialOverflow()).toBe(true) - }) - - it('compacts 50,000 live fragments without changing the emitted output', () => { - vi.useFakeTimers() - const { contents, stream } = makeStream() - stream.completeSnapshot() - - for (let index = 0; index < 50_000; index += 1) { - stream.append(String.fromCharCode(97 + (index % 26))) - } - const retained = stream as unknown as { batchChunks: string[] } - expect(retained.batchChunks.length).toBeLessThanOrEqual(RETAINED_STRING_CHUNK_LIMIT) - - vi.advanceTimersByTime(5) - expect(contents.send).toHaveBeenCalledWith( - 'terminalPreview:data', - expect.objectContaining({ data: expect.stringMatching(/^[a-z]{50000}$/), bytes: 50_000 }) - ) - }) - - it('resyncs when a stalled renderer accumulates too many tiny pending batches', () => { - vi.useFakeTimers() - const { contents, stream } = makeStream() - stream.completeSnapshot() - const fullBatch = 'x'.repeat(TERMINAL_PREVIEW_OUTPUT_BATCH_MAX_BYTES) - - for (let index = 0; index < 8; index += 1) { - stream.append(fullBatch) - } - for (let index = 0; index <= TERMINAL_PREVIEW_PENDING_MAX_RECORDS; index += 1) { - stream.append('x') - vi.advanceTimersByTime(5) - } - - stream.acknowledge(8 * TERMINAL_PREVIEW_OUTPUT_BATCH_MAX_BYTES) - expect(contents.send).toHaveBeenLastCalledWith('terminalPreview:data', { - type: 'resync', - ptyId: 'pty-1' - }) - }) -}) diff --git a/src/main/ipc/terminal-preview-output-stream.ts b/src/main/ipc/terminal-preview-output-stream.ts index 03382b40f3a..af14424cbe2 100644 --- a/src/main/ipc/terminal-preview-output-stream.ts +++ b/src/main/ipc/terminal-preview-output-stream.ts @@ -1,5 +1,4 @@ import type { WebContents } from 'electron' -import { appendCompactedStringChunk } from '../../shared/string-chunk-compaction' import { iterateTerminalInputChunks } from '../../shared/terminal-input' import type { TerminalPreviewDataPayload } from '../../shared/terminal-preview' @@ -8,7 +7,6 @@ export const TERMINAL_PREVIEW_OUTPUT_BATCH_MAX_BYTES = 64 * 1024 const OUTPUT_IN_FLIGHT_MAX_BYTES = 512 * 1024 const OUTPUT_PENDING_MAX_BYTES = 256 * 1024 const INITIAL_PENDING_MAX_BYTES = 256 * 1024 -export const TERMINAL_PREVIEW_PENDING_MAX_RECORDS = 4_096 export type TerminalPreviewOutputMeta = { seq?: number @@ -83,7 +81,7 @@ export class TerminalPreviewOutputStream { } append(data: string, meta?: TerminalPreviewOutputMeta): void { - if (data.length === 0 || this.isDisposed || this.awaitingReconnect || this.resyncPending) { + if (this.isDisposed || this.awaitingReconnect || this.resyncPending) { return } if (this.bufferingSnapshot) { @@ -207,10 +205,7 @@ export class TerminalPreviewOutputStream { } this.pendingBatches.push({ data, bytes }) this.pendingBatchBytes += bytes - if ( - this.pendingBatchBytes > OUTPUT_PENDING_MAX_BYTES || - this.pendingBatches.length > TERMINAL_PREVIEW_PENDING_MAX_RECORDS - ) { + if (this.pendingBatchBytes > OUTPUT_PENDING_MAX_BYTES) { // Why: a stuck renderer heals from a fresh authoritative snapshot instead of retaining output without bound. this.pendingBatches = [] this.pendingBatchBytes = 0 @@ -242,7 +237,7 @@ export class TerminalPreviewOutputStream { ) { this.flushBatch() } - appendCompactedStringChunk(this.batchChunks, chunk) + this.batchChunks.push(chunk) this.batchBytes += bytes if (this.batchBytes >= TERMINAL_PREVIEW_OUTPUT_BATCH_MAX_BYTES) { this.flushBatch() @@ -254,18 +249,11 @@ export class TerminalPreviewOutputStream { } private appendInitial(data: string, meta?: TerminalPreviewOutputMeta): void { - if (this.initialPendingOverflowed) { - return - } const bytes = Buffer.byteLength(data, 'utf8') this.initialPending.push({ data, bytes, meta }) this.initialPendingBytes += bytes - if ( - this.initialPendingBytes > INITIAL_PENDING_MAX_BYTES || - this.initialPending.length > TERMINAL_PREVIEW_PENDING_MAX_RECORDS - ) { - this.initialPending = [] - this.initialPendingBytes = 0 + while (this.initialPendingBytes > INITIAL_PENDING_MAX_BYTES && this.initialPending.length > 0) { + this.initialPendingBytes -= this.initialPending.shift()!.bytes this.initialPendingOverflowed = true } } diff --git a/src/main/ipc/terminal-preview.ts b/src/main/ipc/terminal-preview.ts index 39e75390737..59193df6894 100644 --- a/src/main/ipc/terminal-preview.ts +++ b/src/main/ipc/terminal-preview.ts @@ -11,32 +11,11 @@ import { } from './terminal-preview-output-stream' const PREVIEW_ID_MAX_LENGTH = 4096 -export const TERMINAL_PREVIEW_MAX_ENTRIES_PER_CONTENTS = 64 -export const TERMINAL_PREVIEW_MAX_ENTRIES_TOTAL = 256 function isValidPtyId(value: unknown): value is string { return typeof value === 'string' && value.length > 0 && value.length <= PREVIEW_ID_MAX_LENGTH } -function canRetainPreviewEntry( - registry: Map>, - contentsId: number, - ptyId: string -): boolean { - const retained = registry.get(contentsId) - if (retained?.has(ptyId)) { - return true - } - if ((retained?.size ?? 0) >= TERMINAL_PREVIEW_MAX_ENTRIES_PER_CONTENTS) { - return false - } - let total = 0 - for (const perContents of registry.values()) { - total += perContents.size - } - return total < TERMINAL_PREVIEW_MAX_ENTRIES_TOTAL -} - /** Pop-out terminal transport with an atomic snapshot/live boundary. */ export function registerTerminalPreviewHandlers(runtime: OrcaRuntimeService): void { ipcMain.removeHandler('terminalPreview:connect') @@ -109,9 +88,6 @@ export function registerTerminalPreviewHandlers(runtime: OrcaRuntimeService): vo } const ptyId = args.ptyId const perPty = subscriptionsFor(event.sender) - if (!canRetainPreviewEntry(subscriptionsByContents, event.sender.id, ptyId)) { - return { snapshot: null, replay: [] } - } perPty.get(ptyId)?.dispose() const subscription = new TerminalPreviewOutputStream( @@ -234,9 +210,6 @@ export function registerTerminalPreviewHandlers(runtime: OrcaRuntimeService): vo // Why: guarantees the destroyed hook exists even if this claim outlives // the current output stream across a resync reconnect. subscriptionsFor(event.sender) - if (!canRetainPreviewEntry(fitClaimsByContents, event.sender.id, ptyId)) { - return null - } let claimed = fitClaimsByContents.get(event.sender.id) if (!claimed) { claimed = new Map() diff --git a/src/main/ipc/terminal-render-desync-evidence.ts b/src/main/ipc/terminal-render-desync-evidence.ts index 6952fb5815f..9ea82246539 100644 --- a/src/main/ipc/terminal-render-desync-evidence.ts +++ b/src/main/ipc/terminal-render-desync-evidence.ts @@ -1,4 +1,4 @@ -import { mkdir, opendir, rm, stat, writeFile } from 'node:fs/promises' +import { mkdir, readdir, rm, stat, writeFile } from 'node:fs/promises' import path from 'node:path' import { app, ipcMain } from 'electron' import type { @@ -79,23 +79,29 @@ export async function writeTerminalRenderDesyncEvidence( } async function pruneRenderDesyncEvidence(root: string, currentDirectory: string): Promise { - const captures: EvidenceCapture[] = [] - const entries = await opendir(root) - for await (const entry of entries) { - if (!entry.isDirectory()) { - continue - } - captures.push(await inspectEvidenceCapture(path.join(root, entry.name))) - captures.sort((a, b) => a.modifiedAt - b.modifiedAt) - if (captures.length > MAX_CAPTURE_DIRECTORIES) { - const removableIndex = captures.findIndex((capture) => capture.directory !== currentDirectory) - const [capture] = captures.splice(Math.max(removableIndex, 0), 1) - await rm(capture.directory, { recursive: true, force: true }) - } - } + const entries = await readdir(root, { withFileTypes: true }) + const captures = await Promise.all( + entries + .filter((entry) => entry.isDirectory()) + .map(async (entry) => { + const directory = path.join(root, entry.name) + const [directoryStat, files] = await Promise.all([ + stat(directory), + readdir(directory, { withFileTypes: true }) + ]) + const sizes = await Promise.all( + files.filter((file) => file.isFile()).map((file) => stat(path.join(directory, file.name))) + ) + return { + directory, + bytes: sizes.reduce((total, file) => total + file.size, 0), + modifiedAt: directoryStat.mtimeMs + } + }) + ) captures.sort((a, b) => a.modifiedAt - b.modifiedAt) let totalBytes = captures.reduce((total, capture) => total + capture.bytes, 0) - while (totalBytes > MAX_EVIDENCE_BYTES) { + while (captures.length > MAX_CAPTURE_DIRECTORIES || totalBytes > MAX_EVIDENCE_BYTES) { const removableIndex = captures.findIndex((capture) => capture.directory !== currentDirectory) const index = Math.max(removableIndex, 0) const [capture] = captures.splice(index, 1) @@ -106,23 +112,3 @@ async function pruneRenderDesyncEvidence(root: string, currentDirectory: string) } } } - -type EvidenceCapture = { - directory: string - bytes: number - modifiedAt: number -} - -async function inspectEvidenceCapture(directory: string): Promise { - const directoryStat = await stat(directory) - const files = await opendir(directory) - let bytes = 0 - for await (const file of files) { - if (!file.isFile()) { - continue - } - const fileStat = await stat(path.join(directory, file.name)) - bytes = Math.min(MAX_EVIDENCE_BYTES + 1, bytes + fileStat.size) - } - return { directory, bytes, modifiedAt: directoryStat.mtimeMs } -} diff --git a/src/main/ipc/workspace-cleanup-activity.ts b/src/main/ipc/workspace-cleanup-activity.ts index 259bf73f99e..9026b4464d9 100644 --- a/src/main/ipc/workspace-cleanup-activity.ts +++ b/src/main/ipc/workspace-cleanup-activity.ts @@ -1,10 +1,8 @@ -import { lstat } from 'node:fs/promises' +import { lstat, readFile } from 'node:fs/promises' import path from 'node:path' import type { Repo, Worktree } from '../../shared/types' import { parseWslUncPath } from '../../shared/wsl-paths' import { toWindowsWslPath } from '../wsl' -import { readNodeFileWithinLimit } from '../../shared/node-bounded-file-reader' -import { MAX_WORKTREE_GIT_POINTER_BYTES } from '../worktree-orphan-gitdir-proof' type StatPath = (targetPath: string) => Promise<{ mtimeMs: number }> type ReadTextFile = (targetPath: string) => Promise @@ -44,9 +42,7 @@ async function statLocalPath(targetPath: string): Promise<{ mtimeMs: number }> { } async function readLocalTextFile(targetPath: string): Promise { - return ( - await readNodeFileWithinLimit(targetPath, MAX_WORKTREE_GIT_POINTER_BYTES) - ).buffer.toString('utf8') + return readFile(targetPath, 'utf8') } async function resolveWorkspaceCleanupActivityAt( diff --git a/src/main/ipc/worktree-base-directory-poller.ts b/src/main/ipc/worktree-base-directory-poller.ts index 8ec685fbff6..31a068146c8 100644 --- a/src/main/ipc/worktree-base-directory-poller.ts +++ b/src/main/ipc/worktree-base-directory-poller.ts @@ -1,17 +1,12 @@ +import { readdir, stat } from 'node:fs/promises' import { join } from 'node:path' +import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' import { isMainWindowVisible, onMainWindowBecameVisible } from '../window/main-window-visibility' import type { WorktreeBaseRepoWatchConfig, WorktreeBaseWatchTarget } from './worktree-base-directory-event-filter' import { startGitCommonWatch } from './worktree-git-common-watch' -import type { WorktreePollingScanLimits } from './worktree-polling-scan-budget' -import { - collectWorktreeBaseDirectorySignatures, - hasWorktreeGitMarker, - takeWorktreeBaseDirectorySnapshot, - type WorktreeBaseDirectorySnapshot -} from './worktree-base-directory-snapshot' export type WorktreeBasePollEvent = { type: 'create' | 'update' | 'delete'; path: string } @@ -66,7 +61,6 @@ export type WorktreeBasePollerOptions = { visibility?: WorktreePollerWindowVisibility /** Test hook: called whenever a full snapshot scan runs (vs. a gated skip). */ onFullScan?: () => void - scanLimits?: Partial } // Why: these targets used to be recursive FSEvents subscriptions spanning the @@ -90,10 +84,93 @@ export const WORKTREE_BASE_BACKSTOP_TICKS = 15 // backstop scan cover the pathological case. const PENDING_MARKER_MAX_TICKS = 300 -function diffBase( - prev: WorktreeBaseDirectorySnapshot, - next: WorktreeBaseDirectorySnapshot -): WorktreeBasePollEvent[] { +function statSignature(s: { mtimeMs: number; ctimeMs: number; ino: number }): string { + return `${s.mtimeMs}:${s.ctimeMs}:${s.ino}` +} + +async function dirSignature(path: string): Promise { + try { + return statSignature(await stat(path)) + } catch { + return 'missing' + } +} + +async function hasGitMarker(dir: string): Promise { + try { + await stat(join(dir, '.git')) + return true + } catch { + return false + } +} + +type BaseSnapshot = { + // worktree-candidate dir → whether its `.git` completion marker exists + markers: Map + // dirs whose listing determines the candidate set: the root plus any + // nested repo containers. Their stat signatures gate the next full scan. + gateDirs: string[] +} + +// Depth-1 worktree dirs (flat layout), plus depth-2 dirs under each nested +// repo's container, mirroring what worktree-base-directory-event-filter +// matches: `/.git` completion markers and `` deletions. +async function snapshotBase( + rootPath: string, + repos: ReadonlyMap +): Promise { + const markers = new Map() + const gateDirs = [rootPath] + const configs = [...repos.values()] + const includeFlat = configs.some((config) => !config.nestWorkspaces) + const nestedRepoNames = new Set( + configs + .filter((config) => config.nestWorkspaces) + .map((config) => normalizeRuntimePathForComparison(config.repoName)) + ) + + let rootEntries + try { + rootEntries = await readdir(rootPath, { withFileTypes: true }) + } catch { + // Root vanished: an empty snapshot diffs into delete events for every + // previously-known worktree dir, matching the old watcher's error path. + return { markers, gateDirs } + } + + const candidates: string[] = [] + for (const entry of rootEntries) { + if (!entry.isDirectory() && !entry.isSymbolicLink()) { + continue + } + const entryPath = join(rootPath, entry.name) + if (includeFlat) { + candidates.push(entryPath) + } + if (nestedRepoNames.has(normalizeRuntimePathForComparison(entry.name))) { + gateDirs.push(entryPath) + let subEntries + try { + subEntries = await readdir(entryPath, { withFileTypes: true }) + } catch { + subEntries = [] + } + for (const sub of subEntries) { + if (sub.isDirectory() || sub.isSymbolicLink()) { + candidates.push(join(entryPath, sub.name)) + } + } + } + } + + for (const dir of candidates) { + markers.set(dir, await hasGitMarker(dir)) + } + return { markers, gateDirs } +} + +function diffBase(prev: BaseSnapshot, next: BaseSnapshot): WorktreeBasePollEvent[] { const events: WorktreeBasePollEvent[] = [] for (const [dir, marker] of next.markers) { if (marker && prev.markers.get(dir) !== true) { @@ -114,14 +191,13 @@ async function startBasePoller( onEvents: (events: WorktreeBasePollEvent[]) => void, pollIntervalMs: number, visibility: WorktreePollerWindowVisibility, - onFullScan: (() => void) | undefined, - scanLimits: Partial + onFullScan?: () => void ): Promise { let disposed = false let ticking = false let tickCount = 0 - let snapshot = await takeWorktreeBaseDirectorySnapshot(target.path, getRepos(), scanLimits) - let gateSignatures = await collectWorktreeBaseDirectorySignatures(snapshot.gateDirs) + let snapshot = await snapshotBase(target.path, getRepos()) + let gateSignatures = await Promise.all(snapshot.gateDirs.map(dirSignature)) let timer: ReturnType | null = null let parkedWhileHidden = false // dir → tick when first seen without a `.git` marker @@ -134,8 +210,8 @@ async function startBasePoller( const fullScan = async (): Promise => { onFullScan?.() - const next = await takeWorktreeBaseDirectorySnapshot(target.path, getRepos(), scanLimits) - const nextSignatures = await collectWorktreeBaseDirectorySignatures(next.gateDirs) + const next = await snapshotBase(target.path, getRepos()) + const nextSignatures = await Promise.all(next.gateDirs.map(dirSignature)) if (disposed) { return } @@ -162,7 +238,7 @@ async function startBasePoller( const checkPendingMarkers = async (): Promise => { const events: WorktreeBasePollEvent[] = [] for (const dir of pendingMarkers.keys()) { - if (await hasWorktreeGitMarker(dir)) { + if (await hasGitMarker(dir)) { pendingMarkers.delete(dir) snapshot.markers.set(dir, true) events.push({ type: 'create', path: join(dir, '.git') }) @@ -181,7 +257,7 @@ async function startBasePoller( } // Idle fast path: when the dirs whose listings define the candidate set // are untouched, skip the readdir + per-candidate stat fan-out entirely. - const signatures = await collectWorktreeBaseDirectorySignatures(snapshot.gateDirs) + const signatures = await Promise.all(snapshot.gateDirs.map(dirSignature)) const gateChanged = signatures.length !== gateSignatures.length || signatures.some((sig, index) => sig !== gateSignatures[index]) @@ -273,17 +349,8 @@ export async function startWorktreeBaseDirectoryPoller( pollIntervalMs, platform, visibility, - options.onFullScan, - options.scanLimits + options.onFullScan ) } - return startBasePoller( - target, - getRepos, - onEvents, - pollIntervalMs, - visibility, - options.onFullScan, - options.scanLimits ?? {} - ) + return startBasePoller(target, getRepos, onEvents, pollIntervalMs, visibility, options.onFullScan) } diff --git a/src/main/ipc/worktree-base-directory-snapshot.ts b/src/main/ipc/worktree-base-directory-snapshot.ts deleted file mode 100644 index 197c0540009..00000000000 --- a/src/main/ipc/worktree-base-directory-snapshot.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { opendir, stat } from 'node:fs/promises' -import { join } from 'node:path' -import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' -import type { WorktreeBaseRepoWatchConfig } from './worktree-base-directory-event-filter' -import { - WorktreePollingScanBudget, - type WorktreePollingScanLimits -} from './worktree-polling-scan-budget' - -export type WorktreeBaseDirectorySnapshot = { - markers: Map - gateDirs: string[] -} - -function statSignature(value: { mtimeMs: number; ctimeMs: number; ino: number }): string { - return `${value.mtimeMs}:${value.ctimeMs}:${value.ino}` -} - -async function directorySignature(path: string): Promise { - try { - return statSignature(await stat(path)) - } catch { - return 'missing' - } -} - -export async function hasWorktreeGitMarker(dir: string): Promise { - try { - await stat(join(dir, '.git')) - return true - } catch { - return false - } -} - -export async function takeWorktreeBaseDirectorySnapshot( - rootPath: string, - repos: ReadonlyMap, - scanLimits: Partial -): Promise { - const markers = new Map() - const gateDirs = [rootPath] - const budget = new WorktreePollingScanBudget(scanLimits) - budget.claimRetainedPath(rootPath) - let includeFlat = false - const nestedRepoNames = new Set() - for (const config of repos.values()) { - budget.claimRepoConfig(config.repoId, config.repoName) - if (config.nestWorkspaces) { - nestedRepoNames.add(normalizeRuntimePathForComparison(config.repoName)) - } else { - includeFlat = true - } - } - - let rootDirectory - try { - rootDirectory = await opendir(rootPath, { bufferSize: 32 }) - } catch { - return { markers, gateDirs } - } - - const candidates: string[] = [] - for await (const entry of rootDirectory) { - budget.claimEntry() - if (!entry.isDirectory() && !entry.isSymbolicLink()) { - continue - } - const entryPath = join(rootPath, entry.name) - if (includeFlat) { - budget.claimRetainedPath(entryPath) - candidates.push(entryPath) - } - if (!nestedRepoNames.has(normalizeRuntimePathForComparison(entry.name))) { - continue - } - budget.claimRetainedPath(entryPath) - gateDirs.push(entryPath) - let subDirectory - try { - subDirectory = await opendir(entryPath, { bufferSize: 32 }) - } catch { - continue - } - for await (const sub of subDirectory) { - budget.claimEntry() - if (sub.isDirectory() || sub.isSymbolicLink()) { - const candidatePath = join(entryPath, sub.name) - budget.claimRetainedPath(candidatePath) - candidates.push(candidatePath) - } - } - } - - for (const dir of candidates) { - markers.set(dir, await hasWorktreeGitMarker(dir)) - } - return { markers, gateDirs } -} - -export async function collectWorktreeBaseDirectorySignatures( - paths: readonly string[] -): Promise { - const signatures: string[] = [] - for (const path of paths) { - signatures.push(await directorySignature(path)) - } - return signatures -} diff --git a/src/main/ipc/worktree-base-directory-watch-targets-bounds.test.ts b/src/main/ipc/worktree-base-directory-watch-targets-bounds.test.ts deleted file mode 100644 index be4151e0c64..00000000000 --- a/src/main/ipc/worktree-base-directory-watch-targets-bounds.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import type { GlobalSettings, Repo } from '../../shared/types' -import { - buildWorktreeBaseDirectoryWatchTargets, - clearWorktreeBaseDirectoryWatchTargetWarnings -} from './worktree-base-directory-watch-targets' -import { WORKTREE_POLLING_MAX_REPO_CONFIGS } from './worktree-polling-scan-budget' - -const settings = { - workspaceDir: '/worktrees', - nestWorkspaces: false -} as GlobalSettings - -function folderRepo(index: number): Repo { - return { - id: `repo-${index}`, - path: `/repos/${index}`, - displayName: `Repo ${index}`, - badgeColor: '#000000', - addedAt: index, - kind: 'folder' - } -} - -describe('worktree watcher target repo bounds', () => { - afterEach(() => { - clearWorktreeBaseDirectoryWatchTargetWarnings() - vi.restoreAllMocks() - }) - - it('accepts the exact repo count before hydrating repos', async () => { - const repos = Array.from({ length: WORKTREE_POLLING_MAX_REPO_CONFIGS }, (_, index) => - folderRepo(index) - ) - const getRepos = vi.fn(() => repos) - - await expect( - buildWorktreeBaseDirectoryWatchTargets({ - getRepoCount: () => repos.length, - getRepos, - getSettings: () => settings - } as never) - ).resolves.toEqual(new Map()) - expect(getRepos).toHaveBeenCalledOnce() - }) - - it('rejects one repo over the cap without hydrating the repo array', async () => { - const getRepos = vi.fn(() => { - throw new Error('must not hydrate') - }) - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - - await expect( - buildWorktreeBaseDirectoryWatchTargets({ - getRepoCount: () => WORKTREE_POLLING_MAX_REPO_CONFIGS + 1, - getRepos, - getSettings: () => settings - } as never) - ).resolves.toEqual(new Map()) - expect(getRepos).not.toHaveBeenCalled() - expect(warn).toHaveBeenCalledOnce() - }) - - it('resumes target construction after the repo count returns below the cap', async () => { - let repoCount = WORKTREE_POLLING_MAX_REPO_CONFIGS + 1 - const getRepos = vi.fn(() => [folderRepo(1)]) - const store = { - getRepoCount: () => repoCount, - getRepos, - getSettings: () => settings - } - vi.spyOn(console, 'warn').mockImplementation(() => {}) - - await buildWorktreeBaseDirectoryWatchTargets(store as never) - expect(getRepos).not.toHaveBeenCalled() - - repoCount = 1 - await buildWorktreeBaseDirectoryWatchTargets(store as never) - expect(getRepos).toHaveBeenCalledOnce() - }) -}) diff --git a/src/main/ipc/worktree-base-directory-watch-targets.ts b/src/main/ipc/worktree-base-directory-watch-targets.ts index b98594d0db9..1283333ee95 100644 --- a/src/main/ipc/worktree-base-directory-watch-targets.ts +++ b/src/main/ipc/worktree-base-directory-watch-targets.ts @@ -27,11 +27,9 @@ import type { WorktreeBaseWatchKind, WorktreeBaseWatchTarget } from './worktree-base-directory-event-filter' -import { WORKTREE_POLLING_MAX_REPO_CONFIGS } from './worktree-polling-scan-budget' const missingRootWarnings = new Set() const skippedWslWarnings = new Set() -let repoCapacityWarningEmitted = false function normalizeWatchKey(pathValue: string): string { return normalizeRuntimePathForComparison(normalize(pathValue)) @@ -181,23 +179,8 @@ async function maybeAddBaseTarget( export async function buildWorktreeBaseDirectoryWatchTargets( store: Store ): Promise> { - const targets = new Map() - const repoCount = store.getRepoCount() - if ( - !Number.isSafeInteger(repoCount) || - repoCount < 0 || - repoCount > WORKTREE_POLLING_MAX_REPO_CONFIGS - ) { - if (!repoCapacityWarningEmitted) { - console.warn( - `[worktree-base-watcher] skipping background watchers for ${repoCount} repos; limit is ${WORKTREE_POLLING_MAX_REPO_CONFIGS}` - ) - repoCapacityWarningEmitted = true - } - return targets - } - repoCapacityWarningEmitted = false const settings = store.getSettings() + const targets = new Map() for (const repo of store.getRepos()) { if (isFolderRepo(repo)) { continue @@ -215,5 +198,4 @@ export async function buildWorktreeBaseDirectoryWatchTargets( export function clearWorktreeBaseDirectoryWatchTargetWarnings(): void { missingRootWarnings.clear() skippedWslWarnings.clear() - repoCapacityWarningEmitted = false } diff --git a/src/main/ipc/worktree-base-directory-watcher.test.ts b/src/main/ipc/worktree-base-directory-watcher.test.ts index aafa690ea5f..0a5773b13a0 100644 --- a/src/main/ipc/worktree-base-directory-watcher.test.ts +++ b/src/main/ipc/worktree-base-directory-watcher.test.ts @@ -73,7 +73,6 @@ function makeRepo(overrides: Partial = {}): Repo { function makeStore(repos: Repo[]) { return { getSettings: () => settings, - getRepoCount: () => repos.length, getRepos: () => repos } } diff --git a/src/main/ipc/worktree-base-directory-watcher.ts b/src/main/ipc/worktree-base-directory-watcher.ts index ab0a19d8540..85cd501a2eb 100644 --- a/src/main/ipc/worktree-base-directory-watcher.ts +++ b/src/main/ipc/worktree-base-directory-watcher.ts @@ -1,6 +1,5 @@ import type { BrowserWindow } from 'electron' import type { Store } from '../persistence' -import { forEachWithConcurrency } from '../../shared/map-with-concurrency' import { notifyWorktreeGitStatusMetadataChanged, notifyWorktreesChanged } from './worktree-remote' import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import { @@ -35,7 +34,6 @@ type ActiveWatch = WorktreeBaseWatchTarget & { } const WATCH_DEBOUNCE_MS = 250 -export const WORKTREE_WATCH_CLOSE_LIMIT = 8 const activeWatches = new Map() let syncGeneration = 0 let scheduledSync: ReturnType | null = null @@ -329,8 +327,10 @@ export function scheduleCurrentWorktreeBaseDirectoryWatcherSync(): void { export async function disposeWorktreeBaseDirectoryWatchers(): Promise { syncGeneration++ latestSyncContext = null - clearTimeout(scheduledSync ?? undefined) - scheduledSync = null - await forEachWithConcurrency([...activeWatches.keys()], WORKTREE_WATCH_CLOSE_LIMIT, removeWatch) + if (scheduledSync) { + clearTimeout(scheduledSync) + scheduledSync = null + } + await Promise.all([...activeWatches.keys()].map((key) => removeWatch(key))) clearWorktreeBaseDirectoryWatchTargetWarnings() } diff --git a/src/main/ipc/worktree-common-git-directory.test.ts b/src/main/ipc/worktree-common-git-directory.test.ts deleted file mode 100644 index 73c4fd76a07..00000000000 --- a/src/main/ipc/worktree-common-git-directory.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { mkdir, mkdtemp, rm, truncate, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join, resolve } from 'node:path' -import type { Repo } from '../../shared/types' -import { - MAX_GIT_DIRECTORY_POINTER_BYTES, - resolveWorktreeCommonGitDirectory -} from './worktree-common-git-directory' - -describe('resolveWorktreeCommonGitDirectory', () => { - const roots: string[] = [] - - afterEach(async () => { - vi.restoreAllMocks() - await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) - }) - - async function makeRepo(): Promise<{ repo: Repo; root: string }> { - const root = await mkdtemp(join(tmpdir(), 'orca-common-git-dir-')) - roots.push(root) - const repoPath = join(root, 'checkout') - await mkdir(repoPath) - return { repo: { id: 'repo-1', path: repoPath } as Repo, root } - } - - it('resolves a normal linked-worktree pointer to its common directory', async () => { - const { repo, root } = await makeRepo() - await writeFile(join(repo.path, '.git'), 'gitdir: ../common/.git/worktrees/checkout\n') - - await expect(resolveWorktreeCommonGitDirectory(repo)).resolves.toBe( - resolve(root, 'common', '.git') - ) - }) - - it('rejects an oversized sparse local pointer without loading it', async () => { - const { repo } = await makeRepo() - const dotGitPath = join(repo.path, '.git') - await writeFile(dotGitPath, 'x') - await truncate(dotGitPath, MAX_GIT_DIRECTORY_POINTER_BYTES + 1) - vi.spyOn(console, 'warn').mockImplementation(() => undefined) - - await expect(resolveWorktreeCommonGitDirectory(repo)).resolves.toBeNull() - }) - - it('rejects oversized provider content at the same boundary', async () => { - const { repo } = await makeRepo() - const readFile = vi.fn(async () => `gitdir: ${'x'.repeat(MAX_GIT_DIRECTORY_POINTER_BYTES)}\n`) - - await expect( - resolveWorktreeCommonGitDirectory(repo, { - stat: async () => ({ type: 'file', size: 1, mtime: 0 }), - readFile - }) - ).resolves.toBeNull() - expect(readFile).toHaveBeenCalledOnce() - }) -}) diff --git a/src/main/ipc/worktree-common-git-directory.ts b/src/main/ipc/worktree-common-git-directory.ts index 42941c4c41b..a2ec6b0e990 100644 --- a/src/main/ipc/worktree-common-git-directory.ts +++ b/src/main/ipc/worktree-common-git-directory.ts @@ -1,5 +1,5 @@ import type { Stats } from 'node:fs' -import { stat } from 'node:fs/promises' +import { readFile, stat } from 'node:fs/promises' import type { Repo } from '../../shared/types' import { getRuntimePathBasename, @@ -7,9 +7,6 @@ import { resolveRuntimePath } from '../../shared/cross-platform-path' import type { FileStat } from '../providers/types' -import { readNodeFileWithinLimit } from '../../shared/node-bounded-file-reader' - -export const MAX_GIT_DIRECTORY_POINTER_BYTES = 64 * 1024 type GitDirectoryStat = Stats | FileStat @@ -44,12 +41,7 @@ export async function resolveWorktreeCommonGitDirectory( ): Promise { const dotGitPath = resolveRuntimePath(repo.path, '.git') const statPath = access.stat ?? stat - const readText = - access.readFile ?? - (async (path: string) => - (await readNodeFileWithinLimit(path, MAX_GIT_DIRECTORY_POINTER_BYTES)).buffer.toString( - 'utf8' - )) + const readText = access.readFile ?? ((path: string) => readFile(path, 'utf8')) try { const dotGitStat = await statPath(dotGitPath) if (isDirectoryStat(dotGitStat)) { @@ -59,9 +51,6 @@ export async function resolveWorktreeCommonGitDirectory( return null } const content = await readText(dotGitPath) - if (Buffer.byteLength(content, 'utf8') > MAX_GIT_DIRECTORY_POINTER_BYTES) { - return null - } const gitDir = content.match(/^gitdir:\s*(.+)\s*$/m)?.[1]?.trim() if (!gitDir) { return null diff --git a/src/main/ipc/worktree-git-common-polling.ts b/src/main/ipc/worktree-git-common-polling.ts index 3c16440636d..ed0043b3c5b 100644 --- a/src/main/ipc/worktree-git-common-polling.ts +++ b/src/main/ipc/worktree-git-common-polling.ts @@ -1,41 +1,270 @@ +import { readdir, stat } from 'node:fs/promises' +import { join } from 'node:path' import type { WorktreeBasePollEvent, WorktreeBaseSubscription, WorktreePollerWindowVisibility } from './worktree-base-directory-poller' -import type { WorktreePollingScanLimits } from './worktree-polling-scan-budget' -import { - diffGitCommon, - PRIMARY_CHECKOUT_METADATA_FILES, - snapshotGitCommon -} from './worktree-git-common-snapshot' - -export { PRIMARY_CHECKOUT_METADATA_FILES } +// Shared with the darwin primary-metadata poll so platforms cannot drift. +// `logs/HEAD` catches head moves; `config.worktree` carries the sparse flag. +export const PRIMARY_CHECKOUT_METADATA_FILES = [ + 'HEAD', + 'packed-refs', + 'index', + 'config.worktree', + 'logs/HEAD' +] +const LINKED_WORKTREE_STRUCTURAL_METADATA_FILES = ['HEAD', 'gitdir', 'locked', 'config.worktree'] +const LINKED_WORKTREE_INDEX_FILE = 'index' +const LINKED_WORKTREE_HEAD_LOG_FILE = join('logs', 'HEAD') // Why: the entry-dir signature gate can miss same-granule index rewrites on // coarse-mtime filesystems; a periodic ungated re-stat bounds that miss the // same way the base poller's backstop rescan does. const INDEX_BACKSTOP_TICKS = 15 +function statSignature(s: { mtimeMs: number; ctimeMs: number; ino: number }): string { + return `${s.mtimeMs}:${s.ctimeMs}:${s.ino}` +} + +async function dirSignature(path: string): Promise { + try { + // Why: keep `size` — on a coarse-timestamp filesystem a same-granule directory + // allocation change would otherwise slip the readdir gate to the backstop. + const s = await stat(path) + return `${statSignature(s)}:${s.size}` + } catch { + return 'missing' + } +} + +async function fileSignature(path: string): Promise { + try { + const s = await stat(path) + return s.isFile() ? `${statSignature(s)}:${s.size}` : null + } catch { + return null + } +} + +type GitCommonEntrySnapshot = { + dirSignature: string + structuralSignatures: Map + indexSignature: string | null + headLogSignature: string | null +} + +type GitCommonSnapshot = { + worktreesDirSignature: string + entries: Map + primarySignatures: Map + didFullScan: boolean +} + +async function snapshotGitCommonEntry( + entryPath: string, + previous: GitCommonEntrySnapshot | undefined, + forceFullScan: boolean +): Promise { + // Why: HEAD, gitdir, locked, config.worktree and logs/HEAD are rewritten in place without bumping + // the entry-dir mtime, so — like the pre-idle-gate poller — they are re-stat'd EVERY tick, never + // gated behind the dir signature (else a raw HEAD/structural rewrite would slip to the ~30s + // backstop). Only `index` rides the entry-dir signature (its same-dir rewrites are index-backstop-bounded). + const structuralSignatures = new Map() + const [nextDirSignature, headLogSignature] = await Promise.all([ + dirSignature(entryPath), + fileSignature(join(entryPath, LINKED_WORKTREE_HEAD_LOG_FILE)), + Promise.all( + LINKED_WORKTREE_STRUCTURAL_METADATA_FILES.map(async (name) => { + const signature = await fileSignature(join(entryPath, name)) + if (signature !== null) { + structuralSignatures.set(name, signature) + } + }) + ) + ]) + if (nextDirSignature === 'missing') { + // A transient stat failure must not masquerade as a removal; the parent listing is authoritative. + return ( + previous ?? { + dirSignature: nextDirSignature, + structuralSignatures, + indexSignature: null, + headLogSignature + } + ) + } + const shouldReadIndex = forceFullScan || !previous || previous.dirSignature !== nextDirSignature + const indexSignature = shouldReadIndex + ? await fileSignature(join(entryPath, LINKED_WORKTREE_INDEX_FILE)) + : previous.indexSignature + return { + dirSignature: nextDirSignature, + structuralSignatures, + indexSignature, + headLogSignature + } +} + +async function snapshotPrimaryCheckoutSignatures( + commonDirPath: string +): Promise> { + const signatures = new Map() + await Promise.all( + PRIMARY_CHECKOUT_METADATA_FILES.map(async (name) => { + const signature = await fileSignature(join(commonDirPath, name)) + if (signature !== null) { + signatures.set(name, signature) + } + }) + ) + return signatures +} + +async function snapshotGitCommon( + commonDirPath: string, + previous?: GitCommonSnapshot, + includePrimary = true, + forceFullScan = false +): Promise { + const worktreesDir = join(commonDirPath, 'worktrees') + const [worktreesDirSignature, primarySignatures] = await Promise.all([ + dirSignature(worktreesDir), + includePrimary ? snapshotPrimaryCheckoutSignatures(commonDirPath) : new Map() + ]) + // Why: enumerate the worktrees dir EVERY tick rather than gating the readdir on its stat signature. + // A single readdir of a small dir is negligible next to the per-entry structural stats that already + // run each tick, and the signature gate could miss a same-granule add+remove on a coarse-mtime/FAT + // filesystem (its size/mtime/ino/ctime all collide), leaving a linked worktree add/remove undetected + // until the ~30s index backstop (#9882 review). The listing is the authoritative add/remove signal. + let entryPaths: string[] + try { + const entries = await readdir(worktreesDir, { withFileTypes: true }) + entryPaths = entries + .filter((entry) => entry.isDirectory()) + .map((entry) => join(worktreesDir, entry.name)) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + // Dir genuinely absent (no linked worktrees, or all removed) → authoritative empty listing. + entryPaths = [] + } else { + // Why: a TRANSIENT readdir failure (EIO/ESTALE/EMFILE, network/SSH hiccup) must not masquerade as + // "every worktree removed" — that would emit false delete events (and false creates next tick). + // Reuse the known entries so per-entry stats still run; a real removal surfaces as that entry's own + // stat miss (handled in snapshotGitCommonEntry), and the next successful readdir catches any add. + entryPaths = previous ? [...previous.entries.keys()] : [] + } + } + + const entries = new Map() + await Promise.all( + entryPaths.map(async (entryPath) => { + const previousEntry = previous?.entries.get(entryPath) + entries.set(entryPath, await snapshotGitCommonEntry(entryPath, previousEntry, forceFullScan)) + }) + ) + // Why: the expensive per-entry `index` read stays gated on each entry's own dir signature; onFullScan + // now reflects an ungated index-metadata backstop fan-out (forceFullScan) — the real periodic cost — + // rather than the always-run worktrees-dir readdir. + return { + worktreesDirSignature, + entries, + primarySignatures, + didFullScan: forceFullScan + } +} + +function classifySignatureDiff( + prevSignature: string | null | undefined, + nextSignature: string | null | undefined +): 'create' | 'update' | 'delete' | null { + if (prevSignature == null && nextSignature == null) { + return null + } + if (prevSignature == null) { + return 'create' + } + if (nextSignature == null) { + return 'delete' + } + return prevSignature === nextSignature ? null : 'update' +} + +function diffSignatureMaps( + prev: Map, + next: Map, + resolvePath: (name: string) => string +): WorktreeBasePollEvent[] { + const events: WorktreeBasePollEvent[] = [] + const names = new Set([...prev.keys(), ...next.keys()]) + for (const name of names) { + const type = classifySignatureDiff(prev.get(name), next.get(name)) + if (type) { + events.push({ type, path: resolvePath(name) }) + } + } + return events +} + +function diffGitCommon( + commonDirPath: string, + prev: GitCommonSnapshot, + next: GitCommonSnapshot +): WorktreeBasePollEvent[] { + const events: WorktreeBasePollEvent[] = [] + const worktreesDir = join(commonDirPath, 'worktrees') + const worktreesDirDiff = classifySignatureDiff( + prev.worktreesDirSignature, + next.worktreesDirSignature + ) + if (worktreesDirDiff) { + events.push({ type: worktreesDirDiff, path: worktreesDir }) + } + for (const [entryPath, entry] of next.entries) { + const prevEntry = prev.entries.get(entryPath) + if (!prevEntry) { + events.push({ type: 'create', path: entryPath }) + continue + } + events.push( + ...diffSignatureMaps(prevEntry.structuralSignatures, entry.structuralSignatures, (name) => + join(entryPath, name) + ) + ) + const indexDiff = classifySignatureDiff(prevEntry.indexSignature, entry.indexSignature) + if (indexDiff) { + events.push({ type: indexDiff, path: join(entryPath, LINKED_WORKTREE_INDEX_FILE) }) + } + const headLogDiff = classifySignatureDiff(prevEntry.headLogSignature, entry.headLogSignature) + if (headLogDiff) { + events.push({ type: headLogDiff, path: join(entryPath, LINKED_WORKTREE_HEAD_LOG_FILE) }) + } + } + for (const entryPath of prev.entries.keys()) { + if (!next.entries.has(entryPath)) { + events.push({ type: 'delete', path: entryPath }) + } + } + events.push( + ...diffSignatureMaps(prev.primarySignatures, next.primarySignatures, (name) => + join(commonDirPath, name) + ) + ) + return events +} + export async function startGitCommonPolling( commonDirPath: string, onEvents: (events: WorktreeBasePollEvent[]) => void, pollIntervalMs: number, visibility: WorktreePollerWindowVisibility, onFullScan?: () => void, - includePrimary = true, - scanLimits: Partial = {} + includePrimary = true ): Promise { let disposed = false let ticking = false let tickCount = 0 - let snapshot = await snapshotGitCommon( - commonDirPath, - undefined, - includePrimary, - false, - scanLimits - ) + let snapshot = await snapshotGitCommon(commonDirPath, undefined, includePrimary) let timer: ReturnType | null = null let parkedWhileHidden = false @@ -62,8 +291,7 @@ export async function startGitCommonPolling( commonDirPath, snapshot, includePrimary, - shouldForceFullScan, - scanLimits + shouldForceFullScan ) if (disposed) { return diff --git a/src/main/ipc/worktree-git-common-snapshot.ts b/src/main/ipc/worktree-git-common-snapshot.ts deleted file mode 100644 index 229c36e1ae2..00000000000 --- a/src/main/ipc/worktree-git-common-snapshot.ts +++ /dev/null @@ -1,258 +0,0 @@ -import { opendir, stat } from 'node:fs/promises' -import { join } from 'node:path' -import { mapWithConcurrency } from '../../shared/map-with-concurrency' -import type { WorktreeBasePollEvent } from './worktree-base-directory-poller' -import { - WorktreePollingScanBudget, - type WorktreePollingScanLimits -} from './worktree-polling-scan-budget' - -export const PRIMARY_CHECKOUT_METADATA_FILES = [ - 'HEAD', - 'packed-refs', - 'index', - 'config.worktree', - 'logs/HEAD' -] - -const LINKED_WORKTREE_STRUCTURAL_METADATA_FILES = ['HEAD', 'gitdir', 'locked', 'config.worktree'] -const LINKED_WORKTREE_INDEX_FILE = 'index' -const LINKED_WORKTREE_HEAD_LOG_FILE = join('logs', 'HEAD') -const GIT_COMMON_ENTRY_STAT_CONCURRENCY = 16 - -function statSignature(s: { mtimeMs: number; ctimeMs: number; ino: number }): string { - return `${s.mtimeMs}:${s.ctimeMs}:${s.ino}` -} - -async function dirSignature(path: string): Promise { - try { - const stats = await stat(path) - return `${statSignature(stats)}:${stats.size}` - } catch { - return 'missing' - } -} - -async function fileSignature(path: string): Promise { - try { - const stats = await stat(path) - return stats.isFile() ? `${statSignature(stats)}:${stats.size}` : null - } catch { - return null - } -} - -type GitCommonEntrySnapshot = { - dirSignature: string - structuralSignatures: Map - indexSignature: string | null - headLogSignature: string | null -} - -export type GitCommonSnapshot = { - worktreesDirSignature: string - entries: Map - primarySignatures: Map - didFullScan: boolean -} - -async function snapshotGitCommonEntry( - entryPath: string, - previous: GitCommonEntrySnapshot | undefined, - forceFullScan: boolean -): Promise { - // Why: structural leaves can change without the entry directory changing; only the index uses the gate. - const structuralSignatures = new Map() - const [nextDirSignature, headLogSignature] = await Promise.all([ - dirSignature(entryPath), - fileSignature(join(entryPath, LINKED_WORKTREE_HEAD_LOG_FILE)), - Promise.all( - LINKED_WORKTREE_STRUCTURAL_METADATA_FILES.map(async (name) => { - const signature = await fileSignature(join(entryPath, name)) - if (signature !== null) { - structuralSignatures.set(name, signature) - } - }) - ) - ]) - if (nextDirSignature === 'missing') { - // A transient stat failure must not masquerade as a removal; the parent listing is authoritative. - return ( - previous ?? { - dirSignature: nextDirSignature, - structuralSignatures, - indexSignature: null, - headLogSignature - } - ) - } - const shouldReadIndex = forceFullScan || !previous || previous.dirSignature !== nextDirSignature - const indexSignature = shouldReadIndex - ? await fileSignature(join(entryPath, LINKED_WORKTREE_INDEX_FILE)) - : previous.indexSignature - return { - dirSignature: nextDirSignature, - structuralSignatures, - indexSignature, - headLogSignature - } -} - -async function snapshotPrimaryCheckoutSignatures( - commonDirPath: string -): Promise> { - const signatures = new Map() - await Promise.all( - PRIMARY_CHECKOUT_METADATA_FILES.map(async (name) => { - const signature = await fileSignature(join(commonDirPath, name)) - if (signature !== null) { - signatures.set(name, signature) - } - }) - ) - return signatures -} - -export async function snapshotGitCommon( - commonDirPath: string, - previous?: GitCommonSnapshot, - includePrimary = true, - forceFullScan = false, - scanLimits: Partial = {} -): Promise { - const worktreesDir = join(commonDirPath, 'worktrees') - const [worktreesDirSignature, primarySignatures] = await Promise.all([ - dirSignature(worktreesDir), - includePrimary ? snapshotPrimaryCheckoutSignatures(commonDirPath) : new Map() - ]) - // Why: listing is authoritative for add/remove; directory timestamps can collide on coarse filesystems. - const budget = new WorktreePollingScanBudget(scanLimits) - let directory: Awaited> | undefined - let entryPaths: string[] = [] - try { - directory = await opendir(worktreesDir, { bufferSize: 32 }) - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { - entryPaths = previous ? [...previous.entries.keys()] : [] - } - } - if (directory) { - for await (const entry of directory) { - budget.claimEntry() - if (!entry.isDirectory()) { - continue - } - const entryPath = join(worktreesDir, entry.name) - budget.claimRetainedPath(entryPath) - entryPaths.push(entryPath) - } - } else { - for (const entryPath of entryPaths) { - budget.claimRetainedPath(entryPath) - } - } - const entrySnapshots = await mapWithConcurrency( - entryPaths, - GIT_COMMON_ENTRY_STAT_CONCURRENCY, - async (entryPath) => ({ - entryPath, - snapshot: await snapshotGitCommonEntry( - entryPath, - previous?.entries.get(entryPath), - forceFullScan - ) - }) - ) - const entries = new Map() - for (const { entryPath, snapshot } of entrySnapshots) { - entries.set(entryPath, snapshot) - } - return { - worktreesDirSignature, - entries, - primarySignatures, - didFullScan: forceFullScan - } -} - -function classifySignatureDiff( - previous: string | null | undefined, - next: string | null | undefined -): 'create' | 'update' | 'delete' | null { - if (previous == null && next == null) { - return null - } - if (previous == null) { - return 'create' - } - if (next == null) { - return 'delete' - } - return previous === next ? null : 'update' -} - -function diffSignatureMaps( - previous: Map, - next: Map, - resolvePath: (name: string) => string -): WorktreeBasePollEvent[] { - const events: WorktreeBasePollEvent[] = [] - const names = new Set([...previous.keys(), ...next.keys()]) - for (const name of names) { - const type = classifySignatureDiff(previous.get(name), next.get(name)) - if (type) { - events.push({ type, path: resolvePath(name) }) - } - } - return events -} - -export function diffGitCommon( - commonDirPath: string, - previous: GitCommonSnapshot, - next: GitCommonSnapshot -): WorktreeBasePollEvent[] { - const events: WorktreeBasePollEvent[] = [] - const worktreesDir = join(commonDirPath, 'worktrees') - const worktreesDirDiff = classifySignatureDiff( - previous.worktreesDirSignature, - next.worktreesDirSignature - ) - if (worktreesDirDiff) { - events.push({ type: worktreesDirDiff, path: worktreesDir }) - } - for (const [entryPath, entry] of next.entries) { - const previousEntry = previous.entries.get(entryPath) - if (!previousEntry) { - events.push({ type: 'create', path: entryPath }) - continue - } - events.push( - ...diffSignatureMaps(previousEntry.structuralSignatures, entry.structuralSignatures, (name) => - join(entryPath, name) - ) - ) - const indexDiff = classifySignatureDiff(previousEntry.indexSignature, entry.indexSignature) - if (indexDiff) { - events.push({ type: indexDiff, path: join(entryPath, LINKED_WORKTREE_INDEX_FILE) }) - } - const headLogDiff = classifySignatureDiff( - previousEntry.headLogSignature, - entry.headLogSignature - ) - if (headLogDiff) { - events.push({ type: headLogDiff, path: join(entryPath, LINKED_WORKTREE_HEAD_LOG_FILE) }) - } - } - for (const entryPath of previous.entries.keys()) { - if (!next.entries.has(entryPath)) { - events.push({ type: 'delete', path: entryPath }) - } - } - events.push( - ...diffSignatureMaps(previous.primarySignatures, next.primarySignatures, (name) => - join(commonDirPath, name) - ) - ) - return events -} diff --git a/src/main/ipc/worktree-git-common-watch.ts b/src/main/ipc/worktree-git-common-watch.ts index b8745dd129e..a24adf8e357 100644 --- a/src/main/ipc/worktree-git-common-watch.ts +++ b/src/main/ipc/worktree-git-common-watch.ts @@ -11,7 +11,6 @@ import { PRIMARY_CHECKOUT_METADATA_FILES, startGitCommonPolling } from './worktree-git-common-polling' -import type { WorktreePollingScanLimits } from './worktree-polling-scan-budget' // Watches a repo's `/.git/worktrees` metadata plus the primary // checkout's shallow branch/index files — the only paths the git-common event @@ -285,8 +284,7 @@ export async function startGitCommonWatch( pollIntervalMs: number, platform: NodeJS.Platform, visibility: WorktreePollerWindowVisibility, - onFullScan?: () => void, - scanLimits: Partial = {} + onFullScan?: () => void ): Promise { if (platform === 'darwin') { const [narrowWatch, primaryMetadataPoll] = await Promise.all([ @@ -305,13 +303,5 @@ export async function startGitCommonWatch( } } } - return startGitCommonPolling( - target.path, - onEvents, - pollIntervalMs, - visibility, - onFullScan, - true, - scanLimits - ) + return startGitCommonPolling(target.path, onEvents, pollIntervalMs, visibility, onFullScan) } diff --git a/src/main/ipc/worktree-head-identity-reader.test.ts b/src/main/ipc/worktree-head-identity-reader.test.ts index e4055e8a0bf..c722d171ee0 100644 --- a/src/main/ipc/worktree-head-identity-reader.test.ts +++ b/src/main/ipc/worktree-head-identity-reader.test.ts @@ -1,13 +1,8 @@ import { afterEach, describe, expect, it } from 'vitest' -import { mkdir, mkdtemp, rm, truncate, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' -import { - MAX_GIT_HEAD_METADATA_BYTES, - MAX_LINKED_WORKTREE_ENTRIES, - MAX_PACKED_REFS_BYTES, - readGitCommonHeadIdentities -} from './worktree-head-identity-reader' +import { readGitCommonHeadIdentities } from './worktree-head-identity-reader' const OID_A = 'a'.repeat(40) const OID_B = 'b'.repeat(40) @@ -154,32 +149,4 @@ describe('readGitCommonHeadIdentities', () => { expect(await readGitCommonHeadIdentities(commonDir)).toEqual([]) }) - - it('rejects metadata that grows beyond the exact file limits', async () => { - const commonDir = await makeCommonDir() - const headPath = join(commonDir, 'HEAD') - await writeFile(headPath, 'x') - await truncate(headPath, MAX_GIT_HEAD_METADATA_BYTES + 1) - expect(await readGitCommonHeadIdentities(commonDir)).toEqual([]) - - await writeFile(headPath, 'ref: refs/heads/main\n') - const packedRefsPath = join(commonDir, 'packed-refs') - await writeFile(packedRefsPath, 'x') - await truncate(packedRefsPath, MAX_PACKED_REFS_BYTES + 1) - expect(await readGitCommonHeadIdentities(commonDir)).toEqual([]) - }) - - it('fails closed instead of retaining a partial oversized worktree listing', async () => { - const commonDir = await makeCommonDir() - await writeFile(join(commonDir, 'HEAD'), `${OID_A}\n`) - const worktreesDir = join(commonDir, 'worktrees') - await mkdir(worktreesDir) - await Promise.all( - Array.from({ length: MAX_LINKED_WORKTREE_ENTRIES + 1 }, (_, index) => - writeFile(join(worktreesDir, `entry-${index}`), '') - ) - ) - - expect(await readGitCommonHeadIdentities(commonDir)).toEqual([]) - }) }) diff --git a/src/main/ipc/worktree-head-identity-reader.ts b/src/main/ipc/worktree-head-identity-reader.ts index 8310bc78b75..e99de115480 100644 --- a/src/main/ipc/worktree-head-identity-reader.ts +++ b/src/main/ipc/worktree-head-identity-reader.ts @@ -1,26 +1,16 @@ -import { opendir } from 'node:fs/promises' +import { readdir, readFile } from 'node:fs/promises' import { basename, dirname, isAbsolute, join } from 'node:path' import type { WorktreeHeadIdentity } from '../../shared/types' -import { readNodeFileWithinLimit } from '../../shared/node-bounded-file-reader' // Why: the whole point of this reader is replacing `git worktree list` fanout // with bounded metadata-file reads, so head freshness never re-creates the // spawn pressure that stalled terminal input. Keep it spawn-free. const MAX_SYMREF_DEPTH = 5 -export const MAX_GIT_HEAD_METADATA_BYTES = 64 * 1024 -export const MAX_PACKED_REFS_BYTES = 16 * 1024 * 1024 -export const MAX_PACKED_REFS_ENTRIES = 100_000 -export const MAX_LINKED_WORKTREE_ENTRIES = 1_024 -const MAX_PACKED_REFS_RETAINED_BYTES = 16 * 1024 * 1024 -const MAX_IDENTITY_RETAINED_BYTES = 4 * 1024 * 1024 -async function readTrimmedFile( - path: string, - maxBytes = MAX_GIT_HEAD_METADATA_BYTES -): Promise { +async function readTrimmedFile(path: string): Promise { try { - return (await readNodeFileWithinLimit(path, maxBytes)).buffer.toString('utf8').trim() + return (await readFile(path, 'utf8')).trim() } catch { return null } @@ -29,39 +19,19 @@ async function readTrimmedFile( // packed-refs lines are ` `; `#` headers and `^` peel lines skipped. async function readPackedRefs(commonDirPath: string): Promise> { const refs = new Map() - const content = await readTrimmedFile(join(commonDirPath, 'packed-refs'), MAX_PACKED_REFS_BYTES) + const content = await readTrimmedFile(join(commonDirPath, 'packed-refs')) if (content === null) { return refs } - let retainedBytes = 0 - for (let start = 0; start <= content.length; ) { - const newline = content.indexOf('\n', start) - const end = newline === -1 ? content.length : newline - if (end - start > MAX_GIT_HEAD_METADATA_BYTES) { - return new Map() - } - const line = content.slice(start, end) + for (const line of content.split('\n')) { if (!line || line.startsWith('#') || line.startsWith('^')) { - // Headers and peeled-object lines never resolve a branch head. - } else { - const separator = line.indexOf(' ') - if (separator > 0) { - const ref = line.slice(separator + 1).trim() - const oid = line.slice(0, separator) - retainedBytes += (ref.length + oid.length) * 2 - if ( - refs.size >= MAX_PACKED_REFS_ENTRIES || - retainedBytes > MAX_PACKED_REFS_RETAINED_BYTES - ) { - return new Map() - } - refs.set(ref, oid) - } + continue } - if (newline === -1) { - break + const separator = line.indexOf(' ') + if (separator <= 0) { + continue } - start = newline + 1 + refs.set(line.slice(separator + 1).trim(), line.slice(0, separator)) } return refs } @@ -143,16 +113,6 @@ export async function readGitCommonHeadIdentities( (packedRefsPromise ??= readPackedRefs(commonDirPath)) const identities: WorktreeHeadIdentity[] = [] - let retainedIdentityBytes = 0 - const retainIdentity = (identity: WorktreeHeadIdentity): boolean => { - retainedIdentityBytes += - (identity.worktreePath.length + (identity.branch?.length ?? 0) + identity.head.length) * 2 - if (retainedIdentityBytes > MAX_IDENTITY_RETAINED_BYTES) { - return false - } - identities.push(identity) - return true - } // Only the standard `/.git` layout maps a common dir back to its // primary checkout path; bare/custom GIT_DIR layouts have no primary row. if (basename(commonDirPath) === '.git') { @@ -162,51 +122,40 @@ export async function readGitCommonHeadIdentities( dirname(commonDirPath), packedRefs ) - if (primary && !retainIdentity(primary)) { - return [] + if (primary) { + identities.push(primary) } } - let directory: Awaited> + let entries try { - directory = await opendir(join(commonDirPath, 'worktrees')) + entries = await readdir(join(commonDirPath, 'worktrees'), { withFileTypes: true }) } catch { return identities } - let entriesSeen = 0 - try { - for await (const entry of directory) { - entriesSeen += 1 - if (entriesSeen > MAX_LINKED_WORKTREE_ENTRIES) { - return [] - } - if (!entry.isDirectory()) { - continue - } - const entryPath = join(commonDirPath, 'worktrees', entry.name) - const gitdirContent = await readTrimmedFile(join(entryPath, 'gitdir')) - if (!gitdirContent) { - continue - } - // `gitdir` holds `/.git`, absolute or (with relative-path - // worktrees) relative to the entry dir. - const gitdirAbsolute = isAbsolute(gitdirContent) - ? gitdirContent - : join(entryPath, gitdirContent) - const identity = await readHeadIdentity( - commonDirPath, - join(entryPath, 'HEAD'), - dirname(gitdirAbsolute), - packedRefs - ) - if (identity && !retainIdentity(identity)) { - return [] - } + for (const entry of entries) { + if (!entry.isDirectory()) { + continue + } + const entryPath = join(commonDirPath, 'worktrees', entry.name) + const gitdirContent = await readTrimmedFile(join(entryPath, 'gitdir')) + if (!gitdirContent) { + continue + } + // `gitdir` holds `/.git`, absolute or (with relative-path + // worktrees) relative to the entry dir. + const gitdirAbsolute = isAbsolute(gitdirContent) + ? gitdirContent + : join(entryPath, gitdirContent) + const identity = await readHeadIdentity( + commonDirPath, + join(entryPath, 'HEAD'), + dirname(gitdirAbsolute), + packedRefs + ) + if (identity) { + identities.push(identity) } - } catch { - return identities - } finally { - await directory.close().catch(() => undefined) } return identities } diff --git a/src/main/ipc/worktree-polling-scan-budget.test.ts b/src/main/ipc/worktree-polling-scan-budget.test.ts deleted file mode 100644 index 02aca191ade..00000000000 --- a/src/main/ipc/worktree-polling-scan-budget.test.ts +++ /dev/null @@ -1,287 +0,0 @@ -import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' -import type { WorktreeBaseWatchTarget } from './worktree-base-directory-event-filter' -import { startWorktreeBaseDirectoryPoller } from './worktree-base-directory-poller' -import { - WorktreePollingScanBudget, - type WorktreePollingCapacityError -} from './worktree-polling-scan-budget' - -describe('worktree polling scan budget', () => { - const cleanupPaths: string[] = [] - - afterEach(async () => { - await Promise.all( - cleanupPaths.splice(0).map((path) => rm(path, { recursive: true, force: true })) - ) - }) - - async function createRoot(): Promise { - const root = await mkdtemp(join(tmpdir(), 'orca-worktree-poll-bounds-')) - cleanupPaths.push(root) - return root - } - - function makeTarget(kind: 'base' | 'git-common', path: string): WorktreeBaseWatchTarget { - return { - key: `${kind}:local:${path}`, - kind, - path, - repos: new Map([ - [ - 'repo-1', - { - repoId: 'repo-1', - repoName: 'project', - nestWorkspaces: false - } - ] - ]) - } - } - - it('accepts exact entry, path-count, and path-byte limits', () => { - const budget = new WorktreePollingScanBudget({ - maxScannedEntries: 2, - maxRetainedPaths: 2, - maxRetainedPathBytes: 6 - }) - - budget.claimEntry() - budget.claimEntry() - budget.claimRetainedPath('one') - expect(() => budget.claimRetainedPath('two')).not.toThrow() - }) - - it('rejects the first scanned entry over the limit', () => { - const budget = new WorktreePollingScanBudget({ maxScannedEntries: 1 }) - budget.claimEntry() - - expect(() => budget.claimEntry()).toThrow( - expect.objectContaining>({ - resource: 'scanned entries', - observed: 2, - limit: 1 - }) - ) - }) - - it('rejects the first retained path byte over the limit', () => { - const budget = new WorktreePollingScanBudget({ - maxRetainedPaths: 2, - maxRetainedPathBytes: 5 - }) - budget.claimRetainedPath('one') - - expect(() => budget.claimRetainedPath('two')).toThrow( - expect.objectContaining>({ - resource: 'retained path bytes', - observed: 6, - limit: 5 - }) - ) - }) - - it('accepts the exact retained repo budget and rejects one byte less', () => { - const exact = new WorktreePollingScanBudget({ - maxRepoConfigs: 1, - maxRetainedRepoBytes: 130 - }) - expect(() => exact.claimRepoConfig('a', 'b')).not.toThrow() - - const overflow = new WorktreePollingScanBudget({ - maxRepoConfigs: 1, - maxRetainedRepoBytes: 129 - }) - expect(() => overflow.claimRepoConfig('a', 'b')).toThrow( - expect.objectContaining>({ - resource: 'retained repo bytes', - observed: 130, - limit: 129 - }) - ) - }) - - it('rejects the first repo config over the count limit', () => { - const budget = new WorktreePollingScanBudget({ maxRepoConfigs: 1 }) - budget.claimRepoConfig('repo-1', 'one') - - expect(() => budget.claimRepoConfig('repo-2', 'two')).toThrow( - expect.objectContaining>({ - resource: 'repo configs', - observed: 2, - limit: 1 - }) - ) - }) - - it('starts a base poller at the exact directory-entry limit', async () => { - const root = await createRoot() - await Promise.all([mkdir(join(root, 'one')), mkdir(join(root, 'two'))]) - const target = makeTarget('base', root) - - const poller = await startWorktreeBaseDirectoryPoller( - target, - () => target.repos, - () => {}, - { - scanLimits: { - maxScannedEntries: 2, - maxRetainedPaths: 3 - } - } - ) - - await poller.unsubscribe() - }) - - it('rejects a base snapshot on the first directory entry over the limit', async () => { - const root = await createRoot() - await Promise.all([ - mkdir(join(root, 'one')), - mkdir(join(root, 'two')), - mkdir(join(root, 'three')) - ]) - const target = makeTarget('base', root) - - await expect( - startWorktreeBaseDirectoryPoller( - target, - () => target.repos, - () => {}, - { - scanLimits: { maxScannedEntries: 2 } - } - ) - ).rejects.toMatchObject({ resource: 'scanned entries', observed: 3, limit: 2 }) - }) - - it('keeps the last complete snapshot while over capacity and resumes afterward', async () => { - const root = await createRoot() - const knownWorktree = join(root, 'known') - await mkdir(knownWorktree) - await writeFile(join(knownWorktree, '.git'), 'gitdir: elsewhere') - const target = makeTarget('base', root) - const events: { type: string; path: string }[] = [] - const poller = await startWorktreeBaseDirectoryPoller( - target, - () => target.repos, - (nextEvents) => events.push(...nextEvents), - { - pollIntervalMs: 100, - scanLimits: { maxScannedEntries: 2 } - } - ) - - try { - await Promise.all([ - mkdir(join(root, 'overflow-a')), - mkdir(join(root, 'overflow-b')), - mkdir(join(root, 'overflow-c')) - ]) - await rm(knownWorktree, { recursive: true }) - await new Promise((resolve) => setTimeout(resolve, 250)) - expect(events).toEqual([]) - - await Promise.all([ - rm(join(root, 'overflow-b'), { recursive: true }), - rm(join(root, 'overflow-c'), { recursive: true }) - ]) - await vi.waitFor( - () => { - expect(events).toContainEqual({ type: 'delete', path: knownWorktree }) - }, - { timeout: 2_000 } - ) - } finally { - await poller.unsubscribe() - } - }) - - it('pauses snapshots for excess repo configs and resumes when they are removed', async () => { - const root = await createRoot() - const knownWorktree = join(root, 'known') - await mkdir(knownWorktree) - await writeFile(join(knownWorktree, '.git'), 'gitdir: elsewhere') - const target = makeTarget('base', root) - const events: { type: string; path: string }[] = [] - const poller = await startWorktreeBaseDirectoryPoller( - target, - () => target.repos, - (nextEvents) => events.push(...nextEvents), - { - pollIntervalMs: 100, - scanLimits: { maxRepoConfigs: 1 } - } - ) - - try { - target.repos.set('repo-2', { - repoId: 'repo-2', - repoName: 'second', - nestWorkspaces: false - }) - await rm(knownWorktree, { recursive: true }) - await new Promise((resolve) => setTimeout(resolve, 250)) - expect(events).toEqual([]) - - target.repos.delete('repo-2') - await vi.waitFor( - () => { - expect(events).toContainEqual({ type: 'delete', path: knownWorktree }) - }, - { timeout: 2_000 } - ) - } finally { - await poller.unsubscribe() - } - }) - - it('starts git-common polling at the exact linked-worktree limit', async () => { - const root = await createRoot() - await Promise.all([ - mkdir(join(root, 'worktrees', 'one'), { recursive: true }), - mkdir(join(root, 'worktrees', 'two'), { recursive: true }) - ]) - const target = makeTarget('git-common', root) - - const poller = await startWorktreeBaseDirectoryPoller( - target, - () => target.repos, - () => {}, - { - platform: 'linux', - scanLimits: { - maxScannedEntries: 2, - maxRetainedPaths: 2 - } - } - ) - - await poller.unsubscribe() - }) - - it('rejects git-common polling on the first linked worktree over the limit', async () => { - const root = await createRoot() - await Promise.all([ - mkdir(join(root, 'worktrees', 'one'), { recursive: true }), - mkdir(join(root, 'worktrees', 'two'), { recursive: true }), - mkdir(join(root, 'worktrees', 'three'), { recursive: true }) - ]) - const target = makeTarget('git-common', root) - - await expect( - startWorktreeBaseDirectoryPoller( - target, - () => target.repos, - () => {}, - { - platform: 'linux', - scanLimits: { maxScannedEntries: 2 } - } - ) - ).rejects.toMatchObject({ resource: 'scanned entries', observed: 3, limit: 2 }) - }) -}) diff --git a/src/main/ipc/worktree-polling-scan-budget.ts b/src/main/ipc/worktree-polling-scan-budget.ts deleted file mode 100644 index 727b587ce41..00000000000 --- a/src/main/ipc/worktree-polling-scan-budget.ts +++ /dev/null @@ -1,128 +0,0 @@ -export const WORKTREE_POLLING_MAX_SCANNED_ENTRIES = 100_000 -export const WORKTREE_POLLING_MAX_RETAINED_PATHS = 16_384 -export const WORKTREE_POLLING_MAX_RETAINED_PATH_BYTES = 16 * 1024 * 1024 -export const WORKTREE_POLLING_MAX_REPO_CONFIGS = 4_096 -export const WORKTREE_POLLING_MAX_RETAINED_REPO_BYTES = 4 * 1024 * 1024 - -export type WorktreePollingScanLimits = { - maxScannedEntries: number - maxRetainedPaths: number - maxRetainedPathBytes: number - maxRepoConfigs: number - maxRetainedRepoBytes: number -} - -export class WorktreePollingCapacityError extends Error { - constructor( - readonly resource: - | 'scanned entries' - | 'retained paths' - | 'retained path bytes' - | 'repo configs' - | 'retained repo bytes', - readonly observed: number, - readonly limit: number - ) { - super(`Worktree polling exceeded ${limit} ${resource} (observed ${observed})`) - this.name = 'WorktreePollingCapacityError' - } -} - -function resolveLimit(requested: number | undefined, maximum: number, name: string): number { - if (requested === undefined) { - return maximum - } - if (!Number.isSafeInteger(requested) || requested < 0) { - throw new RangeError(`${name} must be a non-negative safe integer`) - } - return Math.min(requested, maximum) -} - -export class WorktreePollingScanBudget { - private scannedEntries = 0 - private retainedPaths = 0 - private retainedPathBytes = 0 - private repoConfigs = 0 - private retainedRepoBytes = 0 - private readonly limits: WorktreePollingScanLimits - - constructor(requested: Partial = {}) { - this.limits = { - maxScannedEntries: resolveLimit( - requested.maxScannedEntries, - WORKTREE_POLLING_MAX_SCANNED_ENTRIES, - 'maxScannedEntries' - ), - maxRetainedPaths: resolveLimit( - requested.maxRetainedPaths, - WORKTREE_POLLING_MAX_RETAINED_PATHS, - 'maxRetainedPaths' - ), - maxRetainedPathBytes: resolveLimit( - requested.maxRetainedPathBytes, - WORKTREE_POLLING_MAX_RETAINED_PATH_BYTES, - 'maxRetainedPathBytes' - ), - maxRepoConfigs: resolveLimit( - requested.maxRepoConfigs, - WORKTREE_POLLING_MAX_REPO_CONFIGS, - 'maxRepoConfigs' - ), - maxRetainedRepoBytes: resolveLimit( - requested.maxRetainedRepoBytes, - WORKTREE_POLLING_MAX_RETAINED_REPO_BYTES, - 'maxRetainedRepoBytes' - ) - } - } - - claimEntry(): void { - this.scannedEntries += 1 - if (this.scannedEntries > this.limits.maxScannedEntries) { - throw new WorktreePollingCapacityError( - 'scanned entries', - this.scannedEntries, - this.limits.maxScannedEntries - ) - } - } - - claimRetainedPath(path: string): void { - this.retainedPaths += 1 - if (this.retainedPaths > this.limits.maxRetainedPaths) { - throw new WorktreePollingCapacityError( - 'retained paths', - this.retainedPaths, - this.limits.maxRetainedPaths - ) - } - this.retainedPathBytes += Buffer.byteLength(path, 'utf8') - if (this.retainedPathBytes > this.limits.maxRetainedPathBytes) { - throw new WorktreePollingCapacityError( - 'retained path bytes', - this.retainedPathBytes, - this.limits.maxRetainedPathBytes - ) - } - } - - claimRepoConfig(repoId: string, repoName: string): void { - this.repoConfigs += 1 - if (this.repoConfigs > this.limits.maxRepoConfigs) { - throw new WorktreePollingCapacityError( - 'repo configs', - this.repoConfigs, - this.limits.maxRepoConfigs - ) - } - this.retainedRepoBytes += - Buffer.byteLength(repoId, 'utf8') + Buffer.byteLength(repoName, 'utf8') + 128 - if (this.retainedRepoBytes > this.limits.maxRetainedRepoBytes) { - throw new WorktreePollingCapacityError( - 'retained repo bytes', - this.retainedRepoBytes, - this.limits.maxRetainedRepoBytes - ) - } - } -} diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index feabd181e4a..014e04a583a 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -57,9 +57,7 @@ import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import type { SshGitProvider } from '../providers/ssh-git-provider' import { TUI_AGENT_CONFIG, isTuiAgent } from '../../shared/tui-agent-config' import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' -import { MAX_ORCA_YAML_BYTES, MAX_ORCA_YAML_CODE_UNITS } from '../../shared/orca-yaml-file-limit' import { getSshGitUsername } from '../git/git-username' -import { readFilesystemProviderBoundedText } from '../filesystem-provider-bounded-text' import { runWorktreeChangeInvalidators } from './worktree-change-invalidators' import { registerOptionalSshWorktreeCreateRoots, @@ -1188,12 +1186,8 @@ async function readRemoteOrcaYaml( hooksRootPath: string ): Promise> { try { - const result = await readFilesystemProviderBoundedText( - fsProvider, - joinWorktreeRelativePath(hooksRootPath, 'orca.yaml'), - { maxBytes: MAX_ORCA_YAML_BYTES, maxCodeUnits: MAX_ORCA_YAML_CODE_UNITS } - ) - return result.kind === 'text' ? parseOrcaYaml(result.content) : null + const result = await fsProvider.readFile(joinWorktreeRelativePath(hooksRootPath, 'orca.yaml')) + return result.isBinary ? null : parseOrcaYaml(result.content) } catch { return null } diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index 955700a35c8..6e126be2091 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -8,8 +8,6 @@ import type { CreateWorktreeResult, GitWorktreeInfo, Worktree } from '../../shar import * as localWorktreeFilesystem from '../local-worktree-filesystem' const ORIGINAL_PLATFORM = process.platform -const TEST_MAX_HOOK_GITIGNORE_BYTES = 4 * 1024 * 1024 -const TEST_MAX_ISSUE_COMMAND_BYTES = 1024 * 1024 const removeWorktreeLinkedPathsMock = vi.hoisted(() => vi.fn()) const findExistingWorktreeSymlinkPathsMock = vi.hoisted(() => vi.fn()) @@ -195,8 +193,6 @@ vi.mock('../hooks', () => ({ getEffectiveHooksFromConfig: getEffectiveHooksFromConfigMock, getDefaultTabsLaunch: getDefaultTabsLaunchMock, getSetupRunnerEnvVars: getSetupRunnerEnvVarsMock, - MAX_HOOK_GITIGNORE_BYTES: 4 * 1024 * 1024, - MAX_ISSUE_COMMAND_BYTES: 1024 * 1024, loadHooks: loadHooksMock, parseOrcaYaml: parseOrcaYamlMock, runHook: runHookMock, @@ -3701,12 +3697,6 @@ describe('registerWorktreeHandlers', () => { ]) } const fsProvider = { - stat: vi.fn(async (filePath: string) => { - if (filePath.endsWith('orca.yaml')) { - return { size: 32, type: 'file', mtime: 0 } - } - throw Object.assign(new Error('missing'), { code: 'ENOENT' }) - }), readFile: vi.fn().mockResolvedValue({ content: 'scripts:\n setup: pnpm install\n', isBinary: false @@ -7096,7 +7086,6 @@ describe('registerWorktreeHandlers', () => { }) } const fsProvider = { - stat: vi.fn().mockResolvedValue({ size: 36, type: 'file', mtime: 0 }), readFile: vi.fn().mockResolvedValue({ content: 'scripts:\n archive: echo archived\n', isBinary: false @@ -7174,7 +7163,6 @@ describe('registerWorktreeHandlers', () => { }) } const fsProvider = { - stat: vi.fn().mockResolvedValue({ size: 36, type: 'file', mtime: 0 }), readFile: vi.fn().mockResolvedValue({ content: 'scripts:\n archive: echo archived\n', isBinary: false @@ -7234,7 +7222,6 @@ describe('registerWorktreeHandlers', () => { }) } const fsProvider = { - stat: vi.fn().mockResolvedValue({ size: 36, type: 'file', mtime: 0 }), readFile: vi.fn().mockResolvedValue({ content: 'scripts:\n archive: echo archived\n', isBinary: false @@ -7293,7 +7280,6 @@ describe('registerWorktreeHandlers', () => { }) } const fsProvider = { - stat: vi.fn().mockResolvedValue({ size: 30, type: 'file', mtime: 0 }), readFile: vi.fn().mockResolvedValue({ content: 'scripts:\n archive: exit 7\n', isBinary: false @@ -7352,7 +7338,6 @@ describe('registerWorktreeHandlers', () => { execNonInteractive: vi.fn().mockRejectedValue(new Error('relay disconnected')) } const fsProvider = { - stat: vi.fn().mockResolvedValue({ size: 36, type: 'file', mtime: 0 }), readFile: vi.fn().mockResolvedValue({ content: 'scripts:\n archive: echo archived\n', isBinary: false @@ -7415,7 +7400,6 @@ describe('registerWorktreeHandlers', () => { }) } const fsProvider = { - stat: vi.fn().mockResolvedValue({ size: 36, type: 'file', mtime: 0 }), readFile: vi.fn().mockResolvedValue({ content: 'scripts:\n archive: echo archived\n', isBinary: false @@ -7576,7 +7560,6 @@ describe('registerWorktreeHandlers', () => { } const sshRepo = { ...localRepo, path: '/remote/repo', connectionId: 'conn-1' } const fsProvider = { - stat: vi.fn().mockResolvedValue({ size: 35, type: 'file', mtime: 0 }), readFile: vi.fn().mockResolvedValue({ content: 'scripts:\n archive: remote-cleanup', isBinary: false @@ -8706,12 +8689,6 @@ describe('registerWorktreeHandlers', () => { worktreeBaseRef: null } const fsProvider = { - stat: vi.fn(async (filePath: string) => { - if (filePath.endsWith('/.orca/issue-command')) { - return { size: 14, type: 'file', mtime: 0 } - } - throw new Error('shared read failed') - }), readFile: vi.fn(async (filePath: string) => { if (filePath.endsWith('/.orca/issue-command')) { return { content: 'local command\n', isBinary: false } @@ -8735,46 +8712,6 @@ describe('registerWorktreeHandlers', () => { }) }) - it('admits an exact-size SSH issue command and rejects +1 before reading', async () => { - const repo = { - id: 'repo-ssh', - path: '/remote/repo', - displayName: 'ssh', - badgeColor: '#000', - addedAt: 0, - connectionId: 'conn-1', - worktreeBaseRef: null - } - let issueCommandSize = TEST_MAX_ISSUE_COMMAND_BYTES - const exactContent = 'x'.repeat(TEST_MAX_ISSUE_COMMAND_BYTES) - const fsProvider = { - stat: vi.fn(async (filePath: string) => { - if (filePath.endsWith('/.orca/issue-command')) { - return { size: issueCommandSize, type: 'file', mtime: 0 } - } - throw Object.assign(new Error('missing'), { code: 'ENOENT' }) - }), - readFile: vi.fn().mockResolvedValue({ content: exactContent, isBinary: false }) - } - store.getRepo.mockReturnValue(repo) - getSshFilesystemProviderMock.mockReturnValue(fsProvider) - - const exactResult = (await handlers['hooks:readIssueCommand'](null, { - repoId: 'repo-ssh' - })) as { localContent: string | null } - expect(exactResult.localContent).toHaveLength(TEST_MAX_ISSUE_COMMAND_BYTES) - expect(fsProvider.readFile).toHaveBeenCalledOnce() - - issueCommandSize += 1 - fsProvider.readFile.mockClear() - await expect( - handlers['hooks:readIssueCommand'](null, { - repoId: 'repo-ssh' - }) - ).resolves.toMatchObject({ localContent: null, effectiveContent: null, source: 'none' }) - expect(fsProvider.readFile).not.toHaveBeenCalled() - }) - it('writes SSH issue-command overrides without clobbering .gitignore on read failure', async () => { const repo = { id: 'repo-ssh', @@ -8787,7 +8724,6 @@ describe('registerWorktreeHandlers', () => { } const fsProvider = { createDir: vi.fn().mockResolvedValue(undefined), - stat: vi.fn().mockRejectedValue(new Error('ssh read failed')), readFile: vi.fn().mockRejectedValue(new Error('ssh read failed')), writeFile: vi.fn().mockResolvedValue(undefined), deletePath: vi.fn().mockResolvedValue(undefined) @@ -8820,12 +8756,6 @@ describe('registerWorktreeHandlers', () => { connectionId: 'conn-1' } const fsProvider = { - stat: vi.fn(async (filePath: string) => { - if (filePath.endsWith('/.orca/issue-command')) { - return { size: 15, type: 'file', mtime: 0 } - } - throw Object.assign(new Error('missing'), { code: 'ENOENT' }) - }), readFile: vi.fn(async (filePath: string) => { if (filePath.endsWith('/.orca/issue-command')) { return { content: 'remote command\n', isBinary: false } @@ -8863,7 +8793,6 @@ describe('registerWorktreeHandlers', () => { const enoent = Object.assign(new Error('missing'), { code: 'ENOENT' }) const fsProvider = { createDir: vi.fn().mockResolvedValue(undefined), - stat: vi.fn().mockRejectedValue(enoent), readFile: vi.fn().mockRejectedValue(enoent), writeFile: vi.fn().mockResolvedValue(undefined), deletePath: vi.fn().mockResolvedValue(undefined) @@ -8884,56 +8813,6 @@ describe('registerWorktreeHandlers', () => { ) }) - it('admits an exact-size SSH .gitignore and rejects +1 before reading', async () => { - const repo = { - id: 'repo-ssh', - path: '/remote/repo', - displayName: 'ssh', - badgeColor: '#000', - addedAt: 0, - connectionId: 'conn-1', - worktreeBaseRef: null - } - let gitignoreSize = TEST_MAX_HOOK_GITIGNORE_BYTES - const exactContent = 'x'.repeat(TEST_MAX_HOOK_GITIGNORE_BYTES) - const fsProvider = { - createDir: vi.fn().mockResolvedValue(undefined), - stat: vi.fn().mockImplementation(async () => ({ - size: gitignoreSize, - type: 'file', - mtime: 0 - })), - readFile: vi.fn().mockResolvedValue({ content: exactContent, isBinary: false }), - writeFile: vi.fn().mockResolvedValue(undefined), - deletePath: vi.fn().mockResolvedValue(undefined) - } - store.getRepo.mockReturnValue(repo) - getSshFilesystemProviderMock.mockReturnValue(fsProvider) - - await handlers['hooks:writeIssueCommand'](null, { - repoId: 'repo-ssh', - content: 'orca issue command' - }) - const gitignoreWrite = fsProvider.writeFile.mock.calls.find(([filePath]) => - filePath.endsWith('/.gitignore') - ) - expect(gitignoreWrite?.[1]).toHaveLength(TEST_MAX_HOOK_GITIGNORE_BYTES + 7) - expect(gitignoreWrite?.[1]).toMatch(/\n\.orca\n$/) - expect(fsProvider.readFile).toHaveBeenCalledOnce() - - gitignoreSize += 1 - fsProvider.readFile.mockClear() - fsProvider.writeFile.mockClear() - await expect( - handlers['hooks:writeIssueCommand'](null, { - repoId: 'repo-ssh', - content: 'orca issue command' - }) - ).rejects.toThrow('Remote .gitignore exceeds the supported size limit') - expect(fsProvider.readFile).not.toHaveBeenCalled() - expect(fsProvider.writeFile).not.toHaveBeenCalled() - }) - it('rejects SSH issue-command writes when the remote filesystem provider is unavailable', async () => { const repo = { id: 'repo-ssh', diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index 399ddcfd172..40cf7efd17d 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -1,7 +1,7 @@ /* oxlint-disable max-lines */ import type { BrowserWindow } from 'electron' import { ipcMain } from 'electron' -import { stat } from 'node:fs/promises' +import { readFile, stat } from 'node:fs/promises' import { randomUUID } from 'node:crypto' import type { Store } from '../persistence' import { isFolderRepo } from '../../shared/repo-kind' @@ -12,7 +12,6 @@ import { worktreeWorkspaceKey } from '../../shared/workspace-scope' import { inspectSetupScriptImportCandidates } from '../../shared/setup-script-imports' -import { MAX_ORCA_YAML_BYTES, MAX_ORCA_YAML_CODE_UNITS } from '../../shared/orca-yaml-file-limit' import { getProjectHostSetupWorktreeMeta } from '../../shared/project-host-setup-projection' import { projectResolvedWorktreeLineage } from '../../shared/resolved-worktree-lineage' import { deleteWorktreeHistoryDir } from '../terminal-history' @@ -61,8 +60,6 @@ import { getEffectiveHooks, getEffectiveHooksFromConfig, getSetupRunnerEnvVars, - MAX_HOOK_GITIGNORE_BYTES, - MAX_ISSUE_COMMAND_BYTES, loadHooks, parseOrcaYaml, readIssueCommand, @@ -107,12 +104,6 @@ import { resolveAutomationWorkspaceProvenance } from '../automations/workspace-provenance' import { shouldEmitBoundedWarning } from './bounded-warning-dedupe' -import { readFilesystemProviderBoundedText } from '../filesystem-provider-bounded-text' -import { - readSetupScriptImportFile, - SETUP_SCRIPT_IMPORT_FILE_MAX_BYTES, - SETUP_SCRIPT_IMPORT_MAX_CODE_UNITS -} from '../setup-script-import-file' type CreateWorktreeArgsWithSystemProvenance = CreateWorktreeArgs & { automationProvenance?: AutomationWorkspaceProvenance @@ -358,12 +349,8 @@ async function getArchiveHooksForRemoval(repo: Repo): Promise } try { - const result = await readFilesystemProviderBoundedText( - fsProvider, - joinWorktreeRelativePath(repo.path, 'orca.yaml'), - { maxBytes: MAX_ORCA_YAML_BYTES, maxCodeUnits: MAX_ORCA_YAML_CODE_UNITS } - ) - const yamlHooks = result.kind === 'text' ? parseOrcaYaml(result.content) : null + const result = await fsProvider.readFile(joinWorktreeRelativePath(repo.path, 'orca.yaml')) + const yamlHooks = result.isBinary ? null : parseOrcaYaml(result.content) return getEffectiveHooksFromConfig(repo, yamlHooks) } catch { return getEffectiveHooksFromConfig(repo, null) @@ -2120,15 +2107,11 @@ export function registerWorktreeHandlers( return { status: 'error', hasHooks: false, hooks: null, mayNeedUpdate: false } } try { - const result = await readFilesystemProviderBoundedText( - fsProvider, - joinWorktreeRelativePath(repo.path, 'orca.yaml'), - { maxBytes: MAX_ORCA_YAML_BYTES, maxCodeUnits: MAX_ORCA_YAML_CODE_UNITS } - ) + const result = await fsProvider.readFile(joinWorktreeRelativePath(repo.path, 'orca.yaml')) return { status: 'ok', - hasHooks: result.kind !== 'binary', - hooks: result.kind === 'text' ? parseOrcaYaml(result.content) : null, + hasHooks: !result.isBinary, + hooks: result.isBinary ? null : parseOrcaYaml(result.content), mayNeedUpdate: false } } catch (error) { @@ -2186,18 +2169,15 @@ export function registerWorktreeHandlers( return null } try { - const result = await readFilesystemProviderBoundedText(fsProvider, filePath, { - maxBytes: SETUP_SCRIPT_IMPORT_FILE_MAX_BYTES, - maxCodeUnits: SETUP_SCRIPT_IMPORT_MAX_CODE_UNITS - }) - return result.kind === 'text' ? result.content : null + const result = await fsProvider.readFile(filePath) + return result.isBinary ? null : result.content } catch { return null } } try { - return await readSetupScriptImportFile(filePath) + return await readFile(filePath, 'utf-8') } catch (error) { if (!isENOENT(error)) { console.warn('[hooks] Failed to inspect setup script import candidate:', error) @@ -2267,26 +2247,18 @@ export function registerWorktreeHandlers( let localContent: string | null = null let sharedContent: string | null = null try { - const result = await readFilesystemProviderBoundedText(fsProvider, issueCommandPath, { - maxBytes: MAX_ISSUE_COMMAND_BYTES, - maxCodeUnits: MAX_ISSUE_COMMAND_BYTES - }) - localContent = result.kind === 'text' ? result.content.trim() || null : null + const result = await fsProvider.readFile(issueCommandPath) + localContent = result.isBinary ? null : result.content.trim() || null } catch (error) { if (!isENOENT(error)) { status = 'error' } } try { - const result = await readFilesystemProviderBoundedText( - fsProvider, - joinWorktreeRelativePath(repo.path, 'orca.yaml'), - { maxBytes: MAX_ORCA_YAML_BYTES, maxCodeUnits: MAX_ORCA_YAML_CODE_UNITS } - ) - sharedContent = - result.kind === 'text' - ? parseOrcaYaml(result.content)?.issueCommand?.trim() || null - : null + const result = await fsProvider.readFile(joinWorktreeRelativePath(repo.path, 'orca.yaml')) + sharedContent = result.isBinary + ? null + : parseOrcaYaml(result.content)?.issueCommand?.trim() || null } catch (error) { if (!isENOENT(error)) { status = 'error' @@ -2337,14 +2309,8 @@ export function registerWorktreeHandlers( await fsProvider.createDir(joinWorktreeRelativePath(repo.path, '.orca')) const gitignorePath = joinWorktreeRelativePath(repo.path, '.gitignore') try { - const result = await readFilesystemProviderBoundedText(fsProvider, gitignorePath, { - maxBytes: MAX_HOOK_GITIGNORE_BYTES, - maxCodeUnits: MAX_HOOK_GITIGNORE_BYTES - }) - if (result.kind === 'oversized') { - throw new Error('Remote .gitignore exceeds the supported size limit') - } - if (result.kind === 'text' && !/^\.orca\/?$/m.test(result.content)) { + const result = await fsProvider.readFile(gitignorePath) + if (!result.isBinary && !/^\.orca\/?$/m.test(result.content)) { const separator = result.content.endsWith('\n') ? '' : '\n' await fsProvider.writeFile(gitignorePath, `${result.content}${separator}.orca\n`) } diff --git a/src/main/ipc/wsl-watcher-snapshot.test.ts b/src/main/ipc/wsl-watcher-snapshot.test.ts deleted file mode 100644 index ff80833ce7f..00000000000 --- a/src/main/ipc/wsl-watcher-snapshot.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { MAX_WSL_SNAPSHOT_RETAINED_ENTRIES, parseWslSnapshotFrame } from './wsl-watcher-snapshot' - -describe('parseWslSnapshotFrame', () => { - it('rejects a snapshot with more retained paths than the entry budget', () => { - const records = Array.from( - { length: MAX_WSL_SNAPSHOT_RETAINED_ENTRIES + 1 }, - (_, index) => `f\t1\t/repo/file-${index}\0` - ).join('') - - expect(parseWslSnapshotFrame(records, 'Ubuntu')).toBeNull() - }) - - it('does not charge duplicate path updates as distinct retained entries', () => { - const records = Array.from( - { length: MAX_WSL_SNAPSHOT_RETAINED_ENTRIES + 1 }, - (_, index) => `f\t${index}\t/repo/same\0` - ).join('') - - const snapshot = parseWslSnapshotFrame(records, 'Ubuntu') - expect(snapshot).not.toBeNull() - expect(snapshot?.size).toBe(1) - expect(snapshot?.get('\\\\wsl.localhost\\Ubuntu\\repo\\same')?.mtime).toBe( - String(MAX_WSL_SNAPSHOT_RETAINED_ENTRIES) - ) - }) -}) diff --git a/src/main/ipc/wsl-watcher-snapshot.ts b/src/main/ipc/wsl-watcher-snapshot.ts deleted file mode 100644 index c1192d77de7..00000000000 --- a/src/main/ipc/wsl-watcher-snapshot.ts +++ /dev/null @@ -1,72 +0,0 @@ -import type { Event as WatcherEvent } from '@parcel/watcher' -import { iterateNulDelimitedFields } from '../../shared/nul-delimited-fields' - -export const MAX_WSL_SNAPSHOT_RETAINED_ENTRIES = 50_000 - -export type WslSnapshotEntry = { - path: string - type: string - mtime: string -} - -export type WslSnapshot = Map - -function toWslUncPath(linuxPath: string, distro: string): string { - return `\\\\wsl.localhost\\${distro}${linuxPath.replace(/\//g, '\\')}` -} - -export function parseWslSnapshotFrame(frame: string, distro: string): WslSnapshot | null { - const snapshot: WslSnapshot = new Map() - for (const rawEntry of iterateNulDelimitedFields(frame)) { - if (!rawEntry) { - continue - } - const firstTab = rawEntry.indexOf('\t') - const secondTab = firstTab === -1 ? -1 : rawEntry.indexOf('\t', firstTab + 1) - if (firstTab <= 0 || secondTab <= firstTab + 1) { - continue - } - const linuxPath = rawEntry.slice(secondTab + 1) - if (!linuxPath.startsWith('/')) { - continue - } - const path = toWslUncPath(linuxPath, distro) - if (!snapshot.has(path) && snapshot.size >= MAX_WSL_SNAPSHOT_RETAINED_ENTRIES) { - return null - } - snapshot.set(path, { - type: rawEntry.slice(0, firstTab), - mtime: rawEntry.slice(firstTab + 1, secondTab), - path - }) - } - return snapshot -} - -export function diffWslSnapshots(prev: WslSnapshot, next: WslSnapshot): WatcherEvent[] { - const events: WatcherEvent[] = [] - - for (const [entryPath, nextEntry] of next) { - const prevEntry = prev.get(entryPath) - if (!prevEntry) { - events.push({ type: 'create', path: entryPath } as WatcherEvent) - continue - } - if (prevEntry.type !== nextEntry.type) { - events.push({ type: 'delete', path: entryPath } as WatcherEvent) - events.push({ type: 'create', path: entryPath } as WatcherEvent) - continue - } - if (prevEntry.mtime !== nextEntry.mtime) { - events.push({ type: 'update', path: entryPath } as WatcherEvent) - } - } - - for (const entryPath of prev.keys()) { - if (!next.has(entryPath)) { - events.push({ type: 'delete', path: entryPath } as WatcherEvent) - } - } - - return events -} diff --git a/src/main/jira/client.test.ts b/src/main/jira/client.test.ts index 71893f71d1d..0c0ee11511c 100644 --- a/src/main/jira/client.test.ts +++ b/src/main/jira/client.test.ts @@ -3,10 +3,6 @@ import { tmpdir } from 'node:os' import type * as Os from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { - MAX_INTEGRATION_ACCOUNTS, - MAX_INTEGRATION_CREDENTIAL_BYTES -} from '../integration-account-persistence-limits' const OLD_FETCH = globalThis.fetch const { closeAllConnectionsMock, netFetchMock, resolveProxyMock, setProxyMock } = vi.hoisted( @@ -654,73 +650,4 @@ describe('Jira client credential storage', () => { expect(headers.get('User-Agent')).toBe('Orca') expect(fetchMock).not.toHaveBeenCalled() }) - - it('admits the exact saved-site boundary without changing order', async () => { - const sites = Array.from({ length: MAX_INTEGRATION_ACCOUNTS }, (_, index) => ({ - id: `site-${index}`, - token: `token-${index}` - })) - writeMultiSiteFiles(sites, 'all') - const jira = await loadClientModule() - - const status = jira.getStatus() - expect(status.sites).toHaveLength(MAX_INTEGRATION_ACCOUNTS) - expect(status.sites?.map((site) => site.id)).toEqual(sites.map((site) => site.id)) - }) - - it('preserves an over-limit saved-site file and refuses to overwrite it', async () => { - const sites = Array.from({ length: MAX_INTEGRATION_ACCOUNTS + 1 }, (_, index) => ({ - id: `site-${index}`, - token: `token-${index}` - })) - writeMultiSiteFiles(sites, 'all') - const path = join(tempHome, '.orca', 'jira-sites.json') - const before = readFileSync(path, 'utf8') - const jira = await loadClientModule() - - expect(jira.getStatus()).toMatchObject({ connected: false, sites: [] }) - await expect( - jira.connect({ - siteUrl: 'example.atlassian.net', - email: 'ada@example.com', - apiToken: 'token-alpha' - }) - ).resolves.toMatchObject({ ok: false, error: expect.stringContaining('left unchanged') }) - expect(netFetchMock).not.toHaveBeenCalled() - expect(readFileSync(path, 'utf8')).toBe(before) - }) - - it('admits an exact-size Jira credential and rejects credential byte +1 before fetch', async () => { - const exactToken = 't'.repeat(MAX_INTEGRATION_CREDENTIAL_BYTES) - netFetchMock.mockResolvedValueOnce( - new Response( - JSON.stringify({ - accountId: 'account-alpha', - displayName: 'Ada', - emailAddress: 'ada@example.com' - }), - { status: 200, headers: { 'Content-Type': 'application/json' } } - ) - ) - const jira = await loadClientModule() - - await expect( - jira.connect({ - siteUrl: 'example.atlassian.net', - email: 'ada@example.com', - apiToken: exactToken - }) - ).resolves.toMatchObject({ ok: true }) - await expect( - jira.connect({ - siteUrl: 'another.atlassian.net', - email: 'ada@example.com', - apiToken: `${exactToken}t` - }) - ).resolves.toMatchObject({ - ok: false, - error: expect.stringContaining(`${MAX_INTEGRATION_CREDENTIAL_BYTES} UTF-8 bytes`) - }) - expect(netFetchMock).toHaveBeenCalledTimes(1) - }) }) diff --git a/src/main/jira/client.ts b/src/main/jira/client.ts index fa5e2ad8a47..4c182ee2ad5 100644 --- a/src/main/jira/client.ts +++ b/src/main/jira/client.ts @@ -2,36 +2,17 @@ request plumbing share one boundary so encrypted token lifecycle and multi-site selection cannot drift between task operations. */ import { createHash } from 'node:crypto' -import { existsSync, mkdirSync, unlinkSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs' import { homedir } from 'node:os' import { join } from 'node:path' import { net, safeStorage, session } from 'electron' import { CredentialDecryptionError, credentialFileHasContent, - readIntegrationCredentialFileSync, - readIntegrationCredentialFileSyncText, readStoredCredentialToken } from '../integration-credential-file' import { ensureElectronProxyFromEnvironment } from '../network/proxy-settings' import { withSpan } from '../observability/tracer' -import { readFetchResponseJsonWithinLimit } from '../lib/fetch-response-body' -import { IntegrationApiConcurrencyGate } from '../integration-api-concurrency' -import { - assertIntegrationAccountCount, - assertIntegrationCredentialBytes, - assertIntegrationStringBytes, - IntegrationAccountPersistenceLimitError, - MAX_INTEGRATION_ACCOUNT_EMAIL_BYTES, - MAX_INTEGRATION_ACCOUNT_FILE_BYTES, - MAX_INTEGRATION_ACCOUNT_ID_BYTES, - MAX_INTEGRATION_ACCOUNT_LABEL_BYTES, - MAX_INTEGRATION_ACCOUNTS, - MAX_INTEGRATION_ACCOUNT_URL_BYTES, - serializeIntegrationAccountFile, - unreadableIntegrationAccountFileError -} from '../integration-account-persistence-limits' -import { boundedIntegrationErrorMessage } from '../integration-error-message' import type { JiraAuthType, JiraConnectArgs, @@ -49,14 +30,28 @@ import type { const JIRA_API_USER_AGENT = 'Orca' const MAX_CONCURRENT = 4 -const concurrencyGate = new IntegrationApiConcurrencyGate(MAX_CONCURRENT) +let running = 0 +const queue: (() => void)[] = [] export function acquire(): Promise { - return concurrencyGate.acquire() + if (running < MAX_CONCURRENT) { + running += 1 + return Promise.resolve() + } + return new Promise((resolve) => + queue.push(() => { + running += 1 + resolve() + }) + ) } export function release(): void { - concurrencyGate.release() + running -= 1 + const next = queue.shift() + if (next) { + next() + } } type JiraSiteFile = { @@ -82,29 +77,18 @@ export class JiraApiError extends Error { status: number | null constructor(message: string, status: number | null = null) { - super(boundedIntegrationErrorMessage(message)) + super(message) this.status = status } } let cachedSiteFile: JiraSiteFile | null = null let siteFileLoaded = false -let siteFileReadError: Error | null = null const cachedTokens = new Map() // Why: decrypt failures are recorded per site so getStatus can explain // failing reads without re-touching the keychain on every status poll. const credentialErrors = new Map() -function cacheToken(siteId: string, token: string): void { - if (!cachedTokens.has(siteId) && cachedTokens.size >= MAX_INTEGRATION_ACCOUNTS) { - const oldestSiteId = cachedTokens.keys().next().value - if (oldestSiteId !== undefined) { - cachedTokens.delete(oldestSiteId) - } - } - cachedTokens.set(siteId, token) -} - function getOrcaDir(): string { return join(homedir(), '.orca') } @@ -118,7 +102,6 @@ function getTokenDir(): string { } function getTokenPath(siteId: string): string { - assertIntegrationStringBytes('Jira', 'site ID', siteId, MAX_INTEGRATION_ACCOUNT_ID_BYTES) return join(getTokenDir(), `${Buffer.from(siteId).toString('base64url')}.enc`) } @@ -174,95 +157,19 @@ function normalizeSite(input: unknown): JiraSite | null { } } -function assertSiteBounds(site: JiraSite): void { - assertIntegrationStringBytes('Jira', 'site ID', site.id, MAX_INTEGRATION_ACCOUNT_ID_BYTES) - assertIntegrationStringBytes('Jira', 'site URL', site.siteUrl, MAX_INTEGRATION_ACCOUNT_URL_BYTES) - assertIntegrationStringBytes('Jira', 'email', site.email, MAX_INTEGRATION_ACCOUNT_EMAIL_BYTES) - assertIntegrationStringBytes( - 'Jira', - 'display name', - site.displayName, - MAX_INTEGRATION_ACCOUNT_LABEL_BYTES - ) - assertIntegrationStringBytes( - 'Jira', - 'account ID', - site.accountId, - MAX_INTEGRATION_ACCOUNT_LABEL_BYTES - ) -} - -function assertStoredSiteBounds(input: unknown): void { - if (!input || typeof input !== 'object') { - return - } - const record = input as Record - const fields = [ - ['site ID', record.id, MAX_INTEGRATION_ACCOUNT_ID_BYTES], - ['site URL', record.siteUrl, MAX_INTEGRATION_ACCOUNT_URL_BYTES], - ['email', record.email, MAX_INTEGRATION_ACCOUNT_EMAIL_BYTES], - ['display name', record.displayName, MAX_INTEGRATION_ACCOUNT_LABEL_BYTES], - ['account ID', record.accountId, MAX_INTEGRATION_ACCOUNT_LABEL_BYTES] - ] as const - for (const [field, value, maxBytes] of fields) { - if (typeof value === 'string') { - assertIntegrationStringBytes('Jira', field, value, maxBytes) - } - } -} - -function assertSiteFileBounds(file: JiraSiteFile): void { - assertIntegrationAccountCount('Jira', file.sites.length) - for (const site of file.sites) { - assertSiteBounds(site) - } -} - function readSiteFileFromDisk(): JiraSiteFile { const path = getSiteFilePath() if (!existsSync(path)) { - siteFileReadError = null return emptySiteFile() } try { - const parsed = JSON.parse(readIntegrationCredentialFileSyncText(path)) as Partial - if ( - !parsed || - typeof parsed !== 'object' || - Array.isArray(parsed) || - parsed.version !== 1 || - !Array.isArray(parsed.sites) - ) { - throw unreadableIntegrationAccountFileError('Jira') - } - if (typeof parsed.activeSiteId === 'string') { - assertIntegrationStringBytes( - 'Jira', - 'active site ID', - parsed.activeSiteId, - MAX_INTEGRATION_ACCOUNT_ID_BYTES - ) - } - if (typeof parsed.selectedSiteId === 'string' && parsed.selectedSiteId !== 'all') { - assertIntegrationStringBytes( - 'Jira', - 'selected site ID', - parsed.selectedSiteId, - MAX_INTEGRATION_ACCOUNT_ID_BYTES - ) - } - const sites: JiraSite[] = [] - assertIntegrationAccountCount('Jira', parsed.sites.length) - for (const input of parsed.sites) { - assertStoredSiteBounds(input) - const site = normalizeSite(input) - if (!site) { - throw unreadableIntegrationAccountFileError('Jira') - } - if (hasStoredToken(site.id)) { - sites.push(site) - } - } + const parsed = JSON.parse(readFileSync(path, { encoding: 'utf-8' })) as Partial + const sites = Array.isArray(parsed.sites) + ? parsed.sites + .map((site) => normalizeSite(site)) + .filter((site): site is JiraSite => site !== null) + .filter((site) => hasStoredToken(site.id)) + : [] const activeSiteId = typeof parsed.activeSiteId === 'string' && sites.some((site) => site.id === parsed.activeSiteId) @@ -274,10 +181,8 @@ function readSiteFileFromDisk(): JiraSiteFile { sites.some((site) => site.id === parsed.selectedSiteId)) ? parsed.selectedSiteId : activeSiteId - siteFileReadError = null return { version: 1, activeSiteId, selectedSiteId, sites } } catch { - siteFileReadError = unreadableIntegrationAccountFileError('Jira') return emptySiteFile() } } @@ -291,10 +196,6 @@ function getSiteFile(): JiraSiteFile { } function writeSiteFile(file: JiraSiteFile): void { - if (siteFileReadError) { - throw siteFileReadError - } - assertSiteFileBounds(file) ensureOrcaDir() const sites = file.sites.filter((site) => hasStoredToken(site.id)) const activeSiteId = @@ -308,31 +209,22 @@ function writeSiteFile(file: JiraSiteFile): void { ? file.selectedSiteId : activeSiteId - const nextFile: JiraSiteFile = { + cachedSiteFile = { version: 1, activeSiteId, selectedSiteId, sites } - const serialized = serializeIntegrationAccountFile(nextFile) - writeFileSync(getSiteFilePath(), serialized, { + siteFileLoaded = true + writeFileSync(getSiteFilePath(), JSON.stringify(cachedSiteFile, null, 2), { encoding: 'utf-8', mode: 0o600 }) - cachedSiteFile = nextFile - siteFileLoaded = true } function writeEncryptedToken(path: string, apiToken: string): void { - assertIntegrationCredentialBytes('Jira', apiToken) if (safeStorage.isEncryptionAvailable()) { - const encrypted = safeStorage.encryptString(apiToken) - if (encrypted.length > MAX_INTEGRATION_ACCOUNT_FILE_BYTES) { - throw new IntegrationAccountPersistenceLimitError( - `Jira encrypted credential exceeds ${MAX_INTEGRATION_ACCOUNT_FILE_BYTES} bytes.` - ) - } - writeFileSync(path, encrypted, { mode: 0o600 }) + writeFileSync(path, safeStorage.encryptString(apiToken), { mode: 0o600 }) return } console.warn('[jira] safeStorage encryption unavailable — storing token in plaintext') @@ -349,19 +241,15 @@ function readToken(siteId: string): string | null { return null } try { - const raw = readIntegrationCredentialFileSync(path) + const raw = readFileSync(path) const token = readStoredCredentialToken('Jira', raw) if (token) { - assertIntegrationCredentialBytes('Jira', token) - cacheToken(siteId, token) + cachedTokens.set(siteId, token) } credentialErrors.delete(siteId) return token } catch (error) { - if ( - error instanceof CredentialDecryptionError || - error instanceof IntegrationAccountPersistenceLimitError - ) { + if (error instanceof CredentialDecryptionError) { credentialErrors.set(siteId, error.message) throw error } @@ -373,7 +261,7 @@ function saveToken(siteId: string, apiToken: string): void { ensureOrcaDir() ensureTokenDir() writeEncryptedToken(getTokenPath(siteId), apiToken) - cacheToken(siteId, apiToken) + cachedTokens.set(siteId, apiToken) credentialErrors.delete(siteId) } @@ -456,9 +344,9 @@ function describeErrorCause(error: unknown): string | undefined { } const cause = (error as { cause?: unknown }).cause if (cause instanceof Error) { - return boundedIntegrationErrorMessage(`${cause.name}: ${cause.message}`) + return `${cause.name}: ${cause.message}` } - return cause === undefined ? undefined : boundedIntegrationErrorMessage(cause) + return cause === undefined ? undefined : String(cause) } async function jiraFetch(url: string, init: RequestInit): Promise { @@ -472,7 +360,7 @@ async function jiraFetch(url: string, init: RequestInit): Promise { }).catch((error) => { span.addEvent('jira.proxySetupFailed', { errorName: error instanceof Error ? error.name : typeof error, - errorMessage: boundedIntegrationErrorMessage(error) + errorMessage: error instanceof Error ? error.message : String(error) }) }) try { @@ -484,7 +372,10 @@ async function jiraFetch(url: string, init: RequestInit): Promise { 'jira.transportErrorName', error instanceof Error ? error.name : typeof error ) - span.setAttribute('jira.transportErrorMessage', boundedIntegrationErrorMessage(error)) + span.setAttribute( + 'jira.transportErrorMessage', + error instanceof Error ? error.message : String(error) + ) const cause = describeErrorCause(error) if (cause) { span.setAttribute('jira.transportErrorCause', cause) @@ -519,16 +410,16 @@ async function requestWithCredentials( if (response.status === 204) { return null } - return readFetchResponseJsonWithinLimit(response) + return response.json() } async function readJiraError(response: Response): Promise { try { - const data = await readFetchResponseJsonWithinLimit<{ + const data = (await response.json()) as { errorMessages?: string[] errors?: Record message?: string - }>(response) + } const messages = [ ...(Array.isArray(data.errorMessages) ? data.errorMessages : []), ...Object.values(data.errors ?? {}), @@ -563,7 +454,7 @@ export async function jiraRequest( if (response.status === 204) { return null as T } - return await readFetchResponseJsonWithinLimit(response) + return (await response.json()) as T } export function getClients(selection?: JiraSiteSelection | null): JiraClientForSite[] { @@ -584,11 +475,7 @@ export function getClients(selection?: JiraSiteSelection | null): JiraClientForS // credentialError for getStatus to surface, so skip this site like a // missing token. A specific-site selection still rethrows so the renderer // can surface the decrypt banner promptly. - if ( - isAllSelection && - (error instanceof CredentialDecryptionError || - error instanceof IntegrationAccountPersistenceLimitError) - ) { + if (isAllSelection && error instanceof CredentialDecryptionError) { return [] } throw error @@ -617,25 +504,9 @@ export function getStatus(): JiraConnectionStatus { export async function connect( args: JiraConnectArgs ): Promise<{ ok: true; viewer: JiraViewer } | { ok: false; error: string }> { - try { - assertIntegrationStringBytes( - 'Jira', - 'site URL', - args.siteUrl, - MAX_INTEGRATION_ACCOUNT_URL_BYTES - ) - assertIntegrationStringBytes('Jira', 'email', args.email, MAX_INTEGRATION_ACCOUNT_EMAIL_BYTES) - assertIntegrationCredentialBytes('Jira', args.apiToken) - } catch (error) { - return { - ok: false, - error: error instanceof Error ? boundedIntegrationErrorMessage(error) : 'Connection failed.' - } - } let siteUrl: string try { siteUrl = normalizeJiraSiteUrl(args.siteUrl) - assertIntegrationStringBytes('Jira', 'site URL', siteUrl, MAX_INTEGRATION_ACCOUNT_URL_BYTES) } catch { return { ok: false, error: 'Enter a valid Jira site URL.' } } @@ -656,10 +527,6 @@ export async function connect( return { ok: false, error: 'Email and API token are required.' } } - getSiteFile() - if (siteFileReadError) { - return { ok: false, error: siteFileReadError.message } - } await acquire() try { const myselfPath = authType === 'server' ? '/rest/api/2/myself' : '/rest/api/3/myself' @@ -687,23 +554,17 @@ export async function connect( accountId: viewer.accountId, authType } + saveToken(id, apiToken) const file = getSiteFile() - const nextFile: JiraSiteFile = { + writeSiteFile({ version: 1, activeSiteId: id, selectedSiteId: id, sites: [site, ...file.sites.filter((entry) => entry.id !== id)] - } - assertSiteFileBounds(nextFile) - serializeIntegrationAccountFile(nextFile) - saveToken(id, apiToken) - writeSiteFile(nextFile) + }) return { ok: true, viewer } } catch (error) { - return { - ok: false, - error: error instanceof Error ? boundedIntegrationErrorMessage(error) : 'Connection failed.' - } + return { ok: false, error: error instanceof Error ? error.message : 'Connection failed.' } } finally { release() } @@ -711,9 +572,6 @@ export async function connect( export function disconnect(siteId?: string): void { const file = getSiteFile() - if (siteFileReadError) { - throw siteFileReadError - } const ids = siteId ? [siteId] : file.sites.map((site) => site.id) for (const id of ids) { deleteToken(id) @@ -746,10 +604,7 @@ export async function testConnection( try { client = getClients(siteId)[0] } catch (error) { - return { - ok: false, - error: error instanceof Error ? boundedIntegrationErrorMessage(error) : 'Connection failed.' - } + return { ok: false, error: error instanceof Error ? error.message : 'Connection failed.' } } if (!client) { return { ok: false, error: 'Not connected to Jira.' } @@ -762,21 +617,15 @@ export async function testConnection( ) return { ok: true, viewer } } catch (error) { - return { - ok: false, - error: error instanceof Error ? boundedIntegrationErrorMessage(error) : 'Connection failed.' - } + return { ok: false, error: error instanceof Error ? error.message : 'Connection failed.' } } finally { release() } } export function clearToken(siteId: string): void { - const file = getSiteFile() - if (siteFileReadError) { - throw siteFileReadError - } deleteToken(siteId) + const file = getSiteFile() writeSiteFile({ ...file, sites: file.sites.filter((site) => site.id !== siteId) }) } diff --git a/src/main/jira/issues.ts b/src/main/jira/issues.ts index 9fcc774889f..2ce24a87420 100644 --- a/src/main/jira/issues.ts +++ b/src/main/jira/issues.ts @@ -21,16 +21,6 @@ import type { JiraTransition, JiraUser } from '../../shared/types' -import { - boundedIntegrationErrorLog, - boundedIntegrationErrorMessage -} from '../integration-error-message' -import { - INTEGRATION_PAGINATION_MAX_ITEMS, - IntegrationPaginationBudget, - INTEGRATION_PAGINATION_MAX_PAGES -} from '../integration-pagination-budget' -import { runBoundedIntegrationFanout } from '../integration-fanout' import { acquire, apiBasePath, @@ -93,13 +83,16 @@ function getErrorStatus(error: unknown): number | null { return typeof status === 'number' && Number.isFinite(status) ? status : null } -function toIssueSearchFailureError(error: unknown): Error { +function toIssueSearchFailureError(error: unknown): unknown { const status = getErrorStatus(error) - const message = boundedIntegrationErrorMessage(error) - if (status === null || message.startsWith(`Error ${status}:`)) { - return new Error(message) + if ( + status === null || + !(error instanceof Error) || + error.message.startsWith(`Error ${status}:`) + ) { + return error } - return new Error(boundedIntegrationErrorMessage(`Error ${status}: ${message}`)) + return new Error(`Error ${status}: ${error.message}`) } function shouldSurfaceSiteFailure( @@ -171,28 +164,17 @@ async function fetchPagedRecords( maxResults = 100 ): Promise { const records: JiraRecord[] = [] - const budget = new IntegrationPaginationBudget() let startAt = 0 - for (let guard = 0; guard < INTEGRATION_PAGINATION_MAX_PAGES; guard += 1) { + for (let guard = 0; guard < 100; guard += 1) { const response = await jiraRequest>( entry, pathForPage(startAt, maxResults) ) const items = getPageItems(response, key) - if (!budget.admitPage(items)) { - console.warn('[jira] Paginated result exceeded its retained result budget; truncating.') - break - } - for (const item of items) { - records.push(item) - } + records.push(...items) if (!shouldFetchNextPage(response, startAt, items, maxResults)) { break } - if (!budget.canRequestPage) { - console.warn('[jira] Paginated result reached its retained result budget; truncating.') - break - } startAt += asFiniteNumber(response.maxResults) ?? maxResults } return records @@ -392,7 +374,7 @@ async function searchIssuesForClient( fields: ISSUE_FIELDS }) }) - return (result.issues ?? []).slice(0, limit).map((issue) => mapJiraIssue(entry.site, issue)) + return (result.issues ?? []).map((issue) => mapJiraIssue(entry.site, issue)) } export async function listIssues( @@ -415,9 +397,8 @@ export async function searchIssues( const safeLimit = clampLimit(limit) const failures: (JiraIssueSearchFailure | undefined)[] = Array.from({ length: entries.length }) const surfaceSiteFailure = shouldSurfaceSiteFailure(siteId, entries.length) - const fanout = await runBoundedIntegrationFanout( - entries, - async (entry, index) => { + const results = await Promise.all( + entries.map(async (entry, index) => { await acquire() try { return await searchIssuesForClient(entry, jql.trim(), safeLimit) @@ -429,14 +410,13 @@ export async function searchIssues( if (surfaceSiteFailure) { throw toIssueSearchFailureError(error) } - console.warn('[jira] searchIssues failed:', boundedIntegrationErrorLog(error)) + console.warn('[jira] searchIssues failed:', error) failures[index] = { error: toIssueSearchFailureError(error), auth: authFailure } return [] as JiraIssue[] } finally { release() } - }, - (issues) => issues + }) ) // 'all' fan-out: only surface an error when every connected site failed, so a // partial success (or a genuinely empty result) is not reported as an error. @@ -446,11 +426,9 @@ export async function searchIssues( if (recordedFailures.length === entries.length) { throw (recordedFailures.find((failure) => !failure.auth) ?? recordedFailures[0]).error } - if (fanout.truncated) { - console.warn('[jira] Cross-site search exceeded its aggregate result budget; truncating.') - } - const results = fanout.results.flat() - return entries.length === 1 ? results.slice(0, safeLimit) : sortAndLimitIssues(results, safeLimit) + return entries.length === 1 + ? results.flat().slice(0, safeLimit) + : sortAndLimitIssues(results.flat(), safeLimit) } export async function getIssue( @@ -475,7 +453,7 @@ export async function getIssue( throw error } } else { - console.warn('[jira] getIssue failed:', boundedIntegrationErrorLog(error)) + console.warn('[jira] getIssue failed:', error) } } finally { release() @@ -653,7 +631,7 @@ export async function getIssueComments( clearToken(entry.site.id) throw error } - console.warn('[jira] getIssueComments failed:', boundedIntegrationErrorLog(error)) + console.warn('[jira] getIssueComments failed:', error) return [] } finally { release() @@ -665,9 +643,8 @@ export async function listProjects(siteId?: JiraSiteSelection | null): Promise { + const results = await Promise.all( + entries.map(async (entry) => { await acquire() try { // Server/DC has no /project/search resource; /project returns the @@ -682,11 +659,7 @@ export async function listProjects(siteId?: JiraSiteSelection | null): Promise retainedProjects.length) { - console.warn('[jira] Projects returned more rows than supported; truncating.') - } - return retainedProjects.map((project) => mapProject(project, entry.site)) + return projects.map((project) => mapProject(project, entry.site)) } catch (error) { if (isAuthError(error)) { clearToken(entry.site.id) @@ -694,19 +667,15 @@ export async function listProjects(siteId?: JiraSiteSelection | null): Promise projects + }) ) - if (fanout.truncated) { - console.warn('[jira] Cross-site projects exceeded their aggregate result budget; truncating.') - } - return fanout.results.flat().sort((a, b) => a.name.localeCompare(b.name)) + return results.flat().sort((a, b) => a.name.localeCompare(b.name)) } export async function listIssueTypes( @@ -735,7 +704,7 @@ export async function listIssueTypes( clearToken(entry.site.id) throw error } - console.warn('[jira] listIssueTypes failed:', boundedIntegrationErrorLog(error)) + console.warn('[jira] listIssueTypes failed:', error) return [] } finally { release() @@ -754,10 +723,9 @@ export async function listCreateFields( await acquire() try { const fields: JiraCreateField[] = [] - const budget = new IntegrationPaginationBudget() let startAt = 0 const maxResults = 100 - for (let guard = 0; guard < INTEGRATION_PAGINATION_MAX_PAGES; guard += 1) { + for (let guard = 0; guard < 100; guard += 1) { const params = new URLSearchParams({ maxResults: String(maxResults), startAt: String(startAt) @@ -769,23 +737,14 @@ export async function listCreateFields( )}/issuetypes/${encodeURIComponent(issueTypeId)}?${params.toString()}` ) const records = getCreateFieldRecords(response) - if (!budget.admitPage(records)) { - console.warn('[jira] Create fields exceeded their retained result budget; truncating.') - break - } - for (const record of records) { - const field = mapCreateField(record) - if (field) { - fields.push(field) - } - } + fields.push( + ...records + .map((record) => mapCreateField(record)) + .filter((field): field is JiraCreateField => field !== null) + ) if (!shouldFetchNextPage(response, startAt, records, maxResults)) { break } - if (!budget.canRequestPage) { - console.warn('[jira] Create fields reached their retained result budget; truncating.') - break - } startAt += asFiniteNumber(response.maxResults) ?? maxResults } return fields @@ -794,7 +753,7 @@ export async function listCreateFields( clearToken(entry.site.id) throw error } - console.warn('[jira] listCreateFields failed:', boundedIntegrationErrorLog(error)) + console.warn('[jira] listCreateFields failed:', error) return [] } finally { release() @@ -809,16 +768,13 @@ export async function listPriorities(siteId?: string | null): Promise(entry, `${apiBasePath(entry.site)}/priority`) - return response - .slice(0, INTEGRATION_PAGINATION_MAX_ITEMS) - .map(mapPriority) - .filter((priority): priority is JiraPriority => !!priority) + return response.map(mapPriority).filter((priority): priority is JiraPriority => !!priority) } catch (error) { if (isAuthError(error)) { clearToken(entry.site.id) throw error } - console.warn('[jira] listPriorities failed:', boundedIntegrationErrorLog(error)) + console.warn('[jira] listPriorities failed:', error) return [] } finally { release() @@ -846,16 +802,13 @@ export async function listAssignableUsers( entry, `${apiBasePath(entry.site)}/user/assignable/search?${params.toString()}` ) - return response - .slice(0, 50) - .map(mapUser) - .filter((user): user is JiraUser => !!user) + return response.map(mapUser).filter((user): user is JiraUser => !!user) } catch (error) { if (isAuthError(error)) { clearToken(entry.site.id) throw error } - console.warn('[jira] listAssignableUsers failed:', boundedIntegrationErrorLog(error)) + console.warn('[jira] listAssignableUsers failed:', error) return [] } finally { release() @@ -876,19 +829,17 @@ export async function listTransitions( entry, `${apiBasePath(entry.site)}/issue/${encodeURIComponent(key)}/transitions` ) - return (response.transitions ?? []) - .slice(0, INTEGRATION_PAGINATION_MAX_ITEMS) - .map((transition) => ({ - id: asString(transition.id), - name: asString(transition.name), - to: mapStatus(transition.to) - })) + return (response.transitions ?? []).map((transition) => ({ + id: asString(transition.id), + name: asString(transition.name), + to: mapStatus(transition.to) + })) } catch (error) { if (isAuthError(error)) { clearToken(entry.site.id) throw error } - console.warn('[jira] listTransitions failed:', boundedIntegrationErrorLog(error)) + console.warn('[jira] listTransitions failed:', error) return [] } finally { release() @@ -940,18 +891,12 @@ export async function getProjectStatusOrder( const seenStatusIds = new Set() const statusIdsByColumn: string[][] = [] for (const column of columns) { - if (seenStatusIds.size >= INTEGRATION_PAGINATION_MAX_ITEMS) { - break - } const statuses = asRecord(column).statuses if (!Array.isArray(statuses)) { continue } const columnStatusIds: string[] = [] for (const status of statuses) { - if (seenStatusIds.size >= INTEGRATION_PAGINATION_MAX_ITEMS) { - break - } const statusId = asIdentifier(asRecord(status).id) if (statusId && !seenStatusIds.has(statusId)) { seenStatusIds.add(statusId) @@ -968,7 +913,7 @@ export async function getProjectStatusOrder( clearToken(entry.site.id) throw error } - console.warn('[jira] getProjectStatusOrder failed:', boundedIntegrationErrorLog(error)) + console.warn('[jira] getProjectStatusOrder failed:', error) return { statusIdsByColumn: [] } } finally { release() diff --git a/src/main/keybindings/keybinding-file-bounds.test.ts b/src/main/keybindings/keybinding-file-bounds.test.ts deleted file mode 100644 index 75dc15a4b35..00000000000 --- a/src/main/keybindings/keybinding-file-bounds.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { mkdtempSync, readFileSync, rmSync, statSync, truncateSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { - MAX_KEYBINDING_FILE_BYTES, - MAX_KEYBINDING_JSON_STRUCTURAL_TOKENS, - readKeybindingFile, - writeKeybindingOverride -} from './keybinding-file' - -describe('keybinding file bounds', () => { - const roots: string[] = [] - - afterEach(() => { - for (const root of roots.splice(0)) { - rmSync(root, { recursive: true, force: true }) - } - }) - - function makePath(): string { - const root = mkdtempSync(join(tmpdir(), 'orca-keybinding-bounds-')) - roots.push(root) - return join(root, 'keybindings.json') - } - - it('parses a valid document exactly at the byte limit', () => { - const filePath = makePath() - const prefix = '{"padding":"' - const suffix = '"}' - writeFileSync( - filePath, - `${prefix}${'x'.repeat(MAX_KEYBINDING_FILE_BYTES - prefix.length - suffix.length)}${suffix}` - ) - - const snapshot = readKeybindingFile(filePath, 'linux') - - expect(snapshot.exists).toBe(true) - expect(snapshot.diagnostics.some((entry) => entry.message.startsWith('Could not read'))).toBe( - false - ) - }) - - it('rejects an oversized sparse document and never overwrites it', () => { - const filePath = makePath() - writeFileSync(filePath, '{}') - truncateSync(filePath, MAX_KEYBINDING_FILE_BYTES + 1) - const originalSize = statSync(filePath).size - - expect(readKeybindingFile(filePath, 'linux')).toMatchObject({ - exists: true, - overrides: {}, - diagnostics: [{ severity: 'error' }] - }) - expect(() => - writeKeybindingOverride(filePath, 'linux', 'worktree.quickOpen', ['Ctrl+P']) - ).toThrow() - expect(statSync(filePath).size).toBe(originalSize) - }) - - it('rejects structurally amplified JSON before parsing', () => { - const filePath = makePath() - writeFileSync(filePath, `{"padding":[${'0,'.repeat(MAX_KEYBINDING_JSON_STRUCTURAL_TOKENS)}0]}`) - const parseSpy = vi.spyOn(JSON, 'parse') - - expect(readKeybindingFile(filePath, 'linux')).toMatchObject({ - exists: true, - overrides: {}, - diagnostics: [{ severity: 'error' }] - }) - expect(parseSpy).not.toHaveBeenCalled() - }) - - it('preserves the prior file when pretty serialization exceeds the read ceiling', () => { - const filePath = makePath() - const prefix = '{"padding":"' - const suffix = '"}' - const before = `${prefix}${'x'.repeat( - MAX_KEYBINDING_FILE_BYTES - prefix.length - suffix.length - )}${suffix}` - writeFileSync(filePath, before) - - expect(() => - writeKeybindingOverride(filePath, 'linux', 'terminal.search', ['Ctrl+Shift+F']) - ).toThrow('JSON output exceeds') - expect(readFileSync(filePath, 'utf8')).toBe(before) - }) -}) diff --git a/src/main/keybindings/keybinding-file.ts b/src/main/keybindings/keybinding-file.ts index 32d3f9316bb..4d41a42ba87 100644 --- a/src/main/keybindings/keybinding-file.ts +++ b/src/main/keybindings/keybinding-file.ts @@ -1,5 +1,5 @@ /* eslint-disable max-lines -- Why: parsing, sanitizing, migrating, and writing the keybindings file must stay together so file-format edge cases share one validation path. */ -import { existsSync, mkdirSync, renameSync, unlinkSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs' import { dirname, join } from 'node:path' import { findKeybindingConflicts, @@ -15,18 +15,12 @@ import { type KeybindingOverrides, type KeybindingPlatform } from '../../shared/keybindings' -import { readNodeFileSyncWithinLimit } from '../../shared/node-bounded-file-reader' -import { stringifyJsonWithinByteLimit } from '../../shared/node-bounded-json-stringify' -import { assertJsonTextStructureWithinLimits } from '../../shared/json-text-structure-limit' type JsonObject = Record const FILE_VERSION = 1 const PLATFORM_KEYS: readonly KeybindingPlatform[] = ['darwin', 'linux', 'win32'] const ROOT_KEYS = new Set(['$schema', 'version', 'keybindings', 'platforms']) -export const MAX_KEYBINDING_FILE_BYTES = 1024 * 1024 -export const MAX_KEYBINDING_JSON_STRUCTURAL_TOKENS = 256 * 1024 -export const MAX_KEYBINDING_JSON_NESTING_DEPTH = 64 export function getUserKeybindingsPath(homePath: string): string { return join(homePath, '.orca', 'keybindings.json') @@ -57,14 +51,7 @@ function readJsonDocument(path: string): { return { exists: false, document: createEmptyDocument() } } try { - const serialized = readNodeFileSyncWithinLimit(path, MAX_KEYBINDING_FILE_BYTES).buffer.toString( - 'utf8' - ) - assertJsonTextStructureWithinLimits(serialized, { - structuralTokens: MAX_KEYBINDING_JSON_STRUCTURAL_TOKENS, - nestingDepth: MAX_KEYBINDING_JSON_NESTING_DEPTH - }) - const parsed = JSON.parse(serialized) as unknown + const parsed = JSON.parse(readFileSync(path, 'utf8')) as unknown if (!isJsonObject(parsed)) { return { exists: true, document: null, error: 'Keybindings file must contain a JSON object.' } } @@ -82,8 +69,7 @@ function writeJsonDocument(path: string, document: JsonObject): void { mkdirSync(dirname(path), { recursive: true }) const tempPath = `${path}.tmp` try { - const { serialized } = stringifyJsonWithinByteLimit(document, MAX_KEYBINDING_FILE_BYTES - 1, 2) - writeFileSync(tempPath, `${serialized}\n`, 'utf8') + writeFileSync(tempPath, `${JSON.stringify(document, null, 2)}\n`, 'utf8') renameSync(tempPath, path) } catch (error) { try { diff --git a/src/main/kimi/hook-service.ts b/src/main/kimi/hook-service.ts index 94a7ae018b3..94f5d11ea3e 100644 --- a/src/main/kimi/hook-service.ts +++ b/src/main/kimi/hook-service.ts @@ -1,14 +1,17 @@ -import { copyFileSync, existsSync, mkdirSync, renameSync, unlinkSync, writeFileSync } from 'node:fs' +import { + copyFileSync, + existsSync, + mkdirSync, + readFileSync, + renameSync, + unlinkSync, + writeFileSync +} from 'node:fs' import { homedir } from 'node:os' import { dirname, join, posix as pathPosix } from 'node:path' import { randomUUID } from 'node:crypto' import type { SFTPWrapper } from 'ssh2' import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types' -import { - NodeFileReadTooLargeError, - readNodeFileSyncWithinLimit -} from '../../shared/node-bounded-file-reader' -import { AGENT_HOOK_CONFIG_MAX_BYTES } from '../agent-hooks/agent-hook-file-limits' import { createManagedCommandMatcher, getSharedManagedScriptPath, @@ -96,9 +99,7 @@ function readConfigToml(configPath: string): string | null { return '' } try { - return readNodeFileSyncWithinLimit(configPath, AGENT_HOOK_CONFIG_MAX_BYTES).buffer.toString( - 'utf8' - ) + return readFileSync(configPath, 'utf-8') } catch { return null } @@ -111,17 +112,10 @@ function writeConfigToml(configPath: string, text: string): void { mkdirSync(dir, { recursive: true }) if (existsSync(configPath)) { try { - const existing = readNodeFileSyncWithinLimit( - configPath, - AGENT_HOOK_CONFIG_MAX_BYTES - ).buffer.toString('utf8') - if (existing === text) { + if (readFileSync(configPath, 'utf-8') === text) { return } - } catch (error) { - if (error instanceof NodeFileReadTooLargeError) { - throw error - } + } catch { // Fall through to the atomic write path. } } diff --git a/src/main/lib/fetch-response-body.test.ts b/src/main/lib/fetch-response-body.test.ts deleted file mode 100644 index 197c2196ee8..00000000000 --- a/src/main/lib/fetch-response-body.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { - FetchResponseBodyTooLargeError, - readFetchResponseBytesWithinLimit, - readFetchResponseJsonWithinLimit, - readFetchResponseTextWithinLimit -} from './fetch-response-body' - -function chunkedResponse(chunks: Uint8Array[], onCancel?: () => void): Response { - return new Response( - new ReadableStream({ - start(controller) { - for (const chunk of chunks) { - controller.enqueue(chunk) - } - controller.close() - }, - cancel() { - onCancel?.() - } - }) - ) -} - -describe('bounded fetch response bodies', () => { - it('preserves bytes, split UTF-8, and parsed JSON below the limit', async () => { - const encoded = new TextEncoder().encode('{"message":"hello 🐋"}') - const chunks = [encoded.subarray(0, 20), encoded.subarray(20, 22), encoded.subarray(22)] - - await expect(readFetchResponseBytesWithinLimit(chunkedResponse(chunks), 1024)).resolves.toEqual( - encoded - ) - await expect(readFetchResponseTextWithinLimit(chunkedResponse(chunks), 1024)).resolves.toBe( - '{"message":"hello 🐋"}' - ) - await expect( - readFetchResponseJsonWithinLimit<{ message: string }>(chunkedResponse(chunks), 1024) - ).resolves.toEqual({ message: 'hello 🐋' }) - }) - - it('rejects an oversized declared body before reading it', async () => { - let cancelled = false - const response = chunkedResponse([new Uint8Array([1])], () => { - cancelled = true - }) - response.headers.set('content-length', '1025') - - await expect(readFetchResponseBytesWithinLimit(response, 1024)).rejects.toThrow( - FetchResponseBodyTooLargeError - ) - expect(cancelled).toBe(true) - }) - - it('cancels a chunked body as soon as accumulated bytes exceed the limit', async () => { - let cancelled = false - const chunks = [new Uint8Array(700), new Uint8Array(400)] - let index = 0 - const response = new Response( - new ReadableStream({ - pull(controller) { - controller.enqueue(chunks[index++] ?? new Uint8Array()) - }, - cancel() { - cancelled = true - } - }) - ) - - await expect(readFetchResponseBytesWithinLimit(response, 1024)).rejects.toThrow( - FetchResponseBodyTooLargeError - ) - expect(cancelled).toBe(true) - }) - - it('rejects invalid limits and preserves native JSON parse failures', async () => { - await expect(readFetchResponseBytesWithinLimit(new Response('ok'), -1)).rejects.toThrow( - RangeError - ) - await expect(readFetchResponseJsonWithinLimit(new Response(''), 10)).rejects.toThrow( - SyntaxError - ) - }) - - it('rejects structural amplification before JSON.parse', async () => { - const parseSpy = vi.spyOn(JSON, 'parse') - - await expect( - readFetchResponseJsonWithinLimit(new Response('[0,0]'), 1024, { - structuralTokens: 2, - nestingDepth: 4 - }) - ).rejects.toThrow('JSON structure exceeds 2 tokens') - expect(parseSpy).not.toHaveBeenCalled() - }) - - it('preserves injected adapters that expose only high-level response methods', async () => { - const textResponse = { text: async () => 'adapter text' } as Response - const jsonResponse = { json: async () => ({ source: 'adapter' }) } as Response - - await expect(readFetchResponseTextWithinLimit(textResponse)).resolves.toBe('adapter text') - await expect(readFetchResponseJsonWithinLimit(jsonResponse)).resolves.toEqual({ - source: 'adapter' - }) - }) -}) diff --git a/src/main/lib/fetch-response-body.ts b/src/main/lib/fetch-response-body.ts deleted file mode 100644 index b59b540fc35..00000000000 --- a/src/main/lib/fetch-response-body.ts +++ /dev/null @@ -1 +0,0 @@ -export * from '../../shared/fetch-response-body' diff --git a/src/main/lib/html-to-pdf-memory-limit.test.ts b/src/main/lib/html-to-pdf-memory-limit.test.ts deleted file mode 100644 index fc9e92e3804..00000000000 --- a/src/main/lib/html-to-pdf-memory-limit.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - assertHtmlToPdfInputWithinMemoryLimit, - HTML_TO_PDF_MEMORY_LIMIT_ERROR -} from './html-to-pdf-memory-limit' - -describe('HTML to PDF memory limit', () => { - it('measures UTF-8 bytes and accepts the boundary', () => { - expect(() => assertHtmlToPdfInputWithinMemoryLimit('éé', 4)).not.toThrow() - }) - - it('rejects the next byte', () => { - expect(() => assertHtmlToPdfInputWithinMemoryLimit('ééa', 4)).toThrow( - HTML_TO_PDF_MEMORY_LIMIT_ERROR - ) - }) -}) diff --git a/src/main/lib/html-to-pdf-memory-limit.ts b/src/main/lib/html-to-pdf-memory-limit.ts deleted file mode 100644 index 6b04e8b59e4..00000000000 --- a/src/main/lib/html-to-pdf-memory-limit.ts +++ /dev/null @@ -1 +0,0 @@ -export * from '../../shared/html-to-pdf-memory-limit' diff --git a/src/main/lib/html-to-pdf.test.ts b/src/main/lib/html-to-pdf.test.ts deleted file mode 100644 index 9a0f197b69e..00000000000 --- a/src/main/lib/html-to-pdf.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { - BrowserWindowMock, - destroyMock, - executeJavaScriptMock, - loadFileMock, - printToPDFMock, - unlinkMock, - writeFileMock, - setDidFinishLoad -} = vi.hoisted(() => { - let didFinishLoad: (() => void) | undefined - const setDidFinishLoad = (listener: (() => void) | undefined): void => { - didFinishLoad = listener - } - const webContentsOnceMock = vi.fn( - (event: string, listener: (...args: unknown[]) => void): void => { - if (event === 'did-finish-load') { - didFinishLoad = () => listener() - } - } - ) - const executeJavaScriptMock = vi.fn(async () => undefined) - const printToPDFMock = vi.fn(async () => Buffer.from('%PDF-export')) - const loadFileMock = vi.fn(async () => { - didFinishLoad?.() - }) - const destroyMock = vi.fn() - const exportWindow = { - webContents: { - once: webContentsOnceMock, - executeJavaScript: executeJavaScriptMock, - printToPDF: printToPDFMock - }, - loadFile: loadFileMock, - isDestroyed: vi.fn(() => false), - destroy: destroyMock - } - return { - BrowserWindowMock: vi.fn(function () { - return exportWindow - }), - destroyMock, - executeJavaScriptMock, - loadFileMock, - printToPDFMock, - unlinkMock: vi.fn(async () => undefined), - writeFileMock: vi.fn(async () => undefined), - setDidFinishLoad - } -}) - -vi.mock('electron', () => ({ - app: { getPath: vi.fn(() => '/tmp') }, - BrowserWindow: BrowserWindowMock -})) -vi.mock('node:fs/promises', () => ({ writeFile: writeFileMock, unlink: unlinkMock })) -vi.mock('node:crypto', () => ({ randomUUID: vi.fn(() => 'test-id') })) - -import { htmlToPdf } from './html-to-pdf' - -describe('htmlToPdf', () => { - beforeEach(() => { - vi.clearAllMocks() - setDidFinishLoad(undefined) - printToPDFMock.mockResolvedValue(Buffer.from('%PDF-export')) - }) - - it('preserves ordinary export rendering and cleanup under the memory admission', async () => { - await expect(htmlToPdf('Hello')).resolves.toEqual( - Buffer.from('%PDF-export') - ) - - expect(writeFileMock).toHaveBeenCalledWith( - '/tmp/orca-export-test-id.html', - 'Hello', - 'utf-8' - ) - expect(loadFileMock).toHaveBeenCalledWith('/tmp/orca-export-test-id.html') - expect(executeJavaScriptMock).toHaveBeenCalledOnce() - expect(printToPDFMock).toHaveBeenCalledWith({ - printBackground: true, - pageSize: 'A4', - margins: { top: 0.75, bottom: 0.75, left: 0.75, right: 0.75 } - }) - expect(destroyMock).toHaveBeenCalledOnce() - expect(unlinkMock).toHaveBeenCalledWith('/tmp/orca-export-test-id.html') - }) -}) diff --git a/src/main/lib/html-to-pdf.ts b/src/main/lib/html-to-pdf.ts index a50578fcee5..f0cc467e515 100644 --- a/src/main/lib/html-to-pdf.ts +++ b/src/main/lib/html-to-pdf.ts @@ -2,12 +2,6 @@ import { app, BrowserWindow } from 'electron' import { writeFile, unlink } from 'node:fs/promises' import path from 'node:path' import { randomUUID } from 'node:crypto' -import { - acquireBrowserPdfAdmission, - BROWSER_PDF_BUSY_ERROR -} from '../browser/browser-pdf-admission' -import { assertCdpPdfWithinMemoryLimit } from '../browser/cdp-print-to-pdf' -import { assertHtmlToPdfInputWithinMemoryLimit } from './html-to-pdf-memory-limit' export class ExportTimeoutError extends Error { constructor(message = 'Export timed out') { @@ -37,42 +31,40 @@ new Promise((resolve) => { ` export async function htmlToPdf(html: string): Promise { - assertHtmlToPdfInputWithinMemoryLimit(html) const tempDir = app.getPath('temp') const tempPath = path.join(tempDir, `orca-export-${randomUUID()}.html`) - const pdfAdmission = acquireBrowserPdfAdmission() - if (!pdfAdmission) { - throw new Error(BROWSER_PDF_BUSY_ERROR) - } - let win: BrowserWindow | null = null + await writeFile(tempPath, html, 'utf-8') + + const win = new BrowserWindow({ + show: false, + webPreferences: { + sandbox: true, + contextIsolation: true, + nodeIntegration: false, + // Why: image-wait needs to run a short script inside the export page, and + // the exported renderer DOM may already embed scripts/SVGs (e.g. Mermaid) + // that need JS to paint correctly. The window stays sandboxed and + // isolated so this is safe. + javascript: true + } + }) + let timer: NodeJS.Timeout | undefined try { - await writeFile(tempPath, html, 'utf-8') - win = new BrowserWindow({ - show: false, - webPreferences: { - sandbox: true, - contextIsolation: true, - nodeIntegration: false, - // Why: image-wait needs script execution to finish remote images and rendered SVGs. - javascript: true - } - }) - const exportWindow = win const loadPromise = new Promise((resolve, reject) => { - exportWindow.webContents.once('did-finish-load', () => resolve()) - exportWindow.webContents.once('did-fail-load', (_event, errorCode, errorDescription) => { + win.webContents.once('did-finish-load', () => resolve()) + win.webContents.once('did-fail-load', (_event, errorCode, errorDescription) => { reject(new Error(`Failed to load export document: ${errorDescription} (${errorCode})`)) }) }) - await exportWindow.loadFile(tempPath) + await win.loadFile(tempPath) await loadPromise const renderAndPrint = (async (): Promise => { - await exportWindow.webContents.executeJavaScript(WAIT_FOR_IMAGES_SCRIPT, true) - return pdfAdmission.print(exportWindow.webContents, { + await win.webContents.executeJavaScript(WAIT_FOR_IMAGES_SCRIPT, true) + return win.webContents.printToPDF({ printBackground: true, pageSize: 'A4', margins: { @@ -88,15 +80,12 @@ export async function htmlToPdf(html: string): Promise { timer = setTimeout(() => reject(new ExportTimeoutError()), EXPORT_TIMEOUT_MS) }) - const pdf = await Promise.race([renderAndPrint, timeoutPromise]) - assertCdpPdfWithinMemoryLimit(pdf) - return pdf + return await Promise.race([renderAndPrint, timeoutPromise]) } finally { - pdfAdmission.releaseIfIdle() if (timer) { clearTimeout(timer) } - if (win && !win.isDestroyed()) { + if (!win.isDestroyed()) { win.destroy() } try { diff --git a/src/main/linear/client.test.ts b/src/main/linear/client.test.ts index 82fb079bdd6..7f518afe6e8 100644 --- a/src/main/linear/client.test.ts +++ b/src/main/linear/client.test.ts @@ -3,10 +3,6 @@ import { tmpdir } from 'node:os' import type * as Os from 'node:os' import { join } from 'node:path' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { - MAX_INTEGRATION_ACCOUNTS, - MAX_INTEGRATION_CREDENTIAL_BYTES -} from '../integration-account-persistence-limits' type ViewerFixture = { displayName: string @@ -393,57 +389,4 @@ describe('Linear client workspace storage', () => { expect(() => linear.getClients('bad')).toThrow('Could not decrypt') }) - - it('admits the exact saved-workspace boundary without changing order', async () => { - const workspaces = Array.from({ length: MAX_INTEGRATION_ACCOUNTS }, (_, index) => ({ - id: `workspace-${index}`, - token: `token-${index}` - })) - writeMultiWorkspaceFiles(workspaces, 'all') - const linear = await loadClientModule() - - const status = linear.getStatus() - expect(status.workspaces).toHaveLength(MAX_INTEGRATION_ACCOUNTS) - expect(status.workspaces?.map((workspace) => workspace.id)).toEqual( - workspaces.map((workspace) => workspace.id) - ) - }) - - it('preserves an over-limit saved-workspace file and refuses to overwrite it', async () => { - const workspaces = Array.from({ length: MAX_INTEGRATION_ACCOUNTS + 1 }, (_, index) => ({ - id: `workspace-${index}`, - token: `token-${index}` - })) - writeMultiWorkspaceFiles(workspaces, 'all') - const path = join(tempHome, '.orca', 'linear-workspaces.json') - const before = readFileSync(path, 'utf8') - const linear = await loadClientModule() - - expect(linear.getStatus()).toMatchObject({ connected: false, workspaces: [] }) - await expect(linear.connect('token-alpha')).resolves.toMatchObject({ - ok: false, - error: expect.stringContaining('left unchanged') - }) - expect(linearClientMock).not.toHaveBeenCalled() - expect(readFileSync(path, 'utf8')).toBe(before) - }) - - it('admits an exact-size Linear credential and rejects credential byte +1 before SDK use', async () => { - const exactToken = 't'.repeat(MAX_INTEGRATION_CREDENTIAL_BYTES) - fixtures.set(exactToken, { - displayName: 'Ada', - email: 'ada@example.com', - organizationId: 'org-exact', - organizationName: 'Exact', - organizationUrlKey: 'exact' - }) - const linear = await loadClientModule() - - await expect(linear.connect(exactToken)).resolves.toMatchObject({ ok: true }) - await expect(linear.connect(`${exactToken}t`)).resolves.toMatchObject({ - ok: false, - error: expect.stringContaining(`${MAX_INTEGRATION_CREDENTIAL_BYTES} UTF-8 bytes`) - }) - expect(linearClientMock).toHaveBeenCalledTimes(1) - }) }) diff --git a/src/main/linear/client.ts b/src/main/linear/client.ts index ca71dbb3d0d..7058d130597 100644 --- a/src/main/linear/client.ts +++ b/src/main/linear/client.ts @@ -3,15 +3,13 @@ stay in one consistency boundary. */ import { safeStorage } from 'electron' import type { LinearClient } from '@linear/sdk' -import { existsSync, mkdirSync, unlinkSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs' import { homedir } from 'node:os' import { join } from 'node:path' import { loadLinearSdk } from './linear-sdk' import { CredentialDecryptionError, credentialFileHasContent, - readIntegrationCredentialFileSync, - readIntegrationCredentialFileSyncText, readStoredCredentialToken } from '../integration-credential-file' import type { @@ -20,32 +18,31 @@ import type { LinearWorkspace, LinearWorkspaceSelection } from '../../shared/types' -import { IntegrationApiConcurrencyGate } from '../integration-api-concurrency' -import { - assertIntegrationAccountCount, - assertIntegrationCredentialBytes, - assertIntegrationStringBytes, - IntegrationAccountPersistenceLimitError, - MAX_INTEGRATION_ACCOUNT_EMAIL_BYTES, - MAX_INTEGRATION_ACCOUNT_FILE_BYTES, - MAX_INTEGRATION_ACCOUNT_ID_BYTES, - MAX_INTEGRATION_ACCOUNT_LABEL_BYTES, - MAX_INTEGRATION_ACCOUNTS, - serializeIntegrationAccountFile, - unreadableIntegrationAccountFileError -} from '../integration-account-persistence-limits' -import { boundedIntegrationErrorMessage } from '../integration-error-message' // ── Concurrency limiter — max 4 parallel Linear API calls ──────────── const MAX_CONCURRENT = 4 -const concurrencyGate = new IntegrationApiConcurrencyGate(MAX_CONCURRENT) +let running = 0 +const queue: (() => void)[] = [] export function acquire(): Promise { - return concurrencyGate.acquire() + if (running < MAX_CONCURRENT) { + running++ + return Promise.resolve() + } + return new Promise((resolve) => + queue.push(() => { + running++ + resolve() + }) + ) } export function release(): void { - concurrencyGate.release() + running-- + const next = queue.shift() + if (next) { + next() + } } // ── Token + workspace storage ──────────────────────────────────────── @@ -77,17 +74,6 @@ let cachedLegacyViewer: LinearViewer | null = null let legacyViewerLoadedFromDisk = false let cachedWorkspaceFile: LinearWorkspaceFile | null = null let workspaceFileLoadedFromDisk = false -let workspaceFileReadError: Error | null = null - -function cacheToken(workspaceId: string, token: string): void { - if (!cachedTokens.has(workspaceId) && cachedTokens.size >= MAX_INTEGRATION_ACCOUNTS) { - const oldestWorkspaceId = cachedTokens.keys().next().value - if (oldestWorkspaceId !== undefined) { - cachedTokens.delete(oldestWorkspaceId) - } - } - cachedTokens.set(workspaceId, token) -} function getOrcaDir(): string { return join(homedir(), '.orca') @@ -113,12 +99,6 @@ function getWorkspaceTokenPath(workspaceId: string): string { if (workspaceId === LEGACY_WORKSPACE_ID) { return getLegacyTokenPath() } - assertIntegrationStringBytes( - 'Linear', - 'workspace ID', - workspaceId, - MAX_INTEGRATION_ACCOUNT_ID_BYTES - ) return join(getWorkspaceTokenDir(), `${Buffer.from(workspaceId).toString('base64url')}.enc`) } @@ -142,47 +122,11 @@ function readLegacyViewerFromDisk(): LinearViewer | null { return null } try { - const raw = readIntegrationCredentialFileSyncText(path) + const raw = readFileSync(path, { encoding: 'utf-8' }) const parsed = JSON.parse(raw) as Partial if (typeof parsed?.displayName !== 'string' || typeof parsed?.organizationName !== 'string') { return null } - assertIntegrationStringBytes( - 'Linear', - 'legacy display name', - parsed.displayName, - MAX_INTEGRATION_ACCOUNT_LABEL_BYTES - ) - assertIntegrationStringBytes( - 'Linear', - 'legacy organization name', - parsed.organizationName, - MAX_INTEGRATION_ACCOUNT_LABEL_BYTES - ) - if (typeof parsed.email === 'string') { - assertIntegrationStringBytes( - 'Linear', - 'legacy email', - parsed.email, - MAX_INTEGRATION_ACCOUNT_EMAIL_BYTES - ) - } - if (typeof parsed.organizationId === 'string') { - assertIntegrationStringBytes( - 'Linear', - 'legacy organization ID', - parsed.organizationId, - MAX_INTEGRATION_ACCOUNT_ID_BYTES - ) - } - if (typeof parsed.organizationUrlKey === 'string') { - assertIntegrationStringBytes( - 'Linear', - 'legacy organization URL key', - parsed.organizationUrlKey, - MAX_INTEGRATION_ACCOUNT_LABEL_BYTES - ) - } return { displayName: parsed.displayName, email: typeof parsed.email === 'string' ? parsed.email : null, @@ -236,76 +180,6 @@ function normalizeWorkspace(input: unknown): LinearWorkspace | null { } } -function assertWorkspaceBounds(workspace: LinearWorkspace): void { - assertIntegrationStringBytes( - 'Linear', - 'workspace ID', - workspace.id, - MAX_INTEGRATION_ACCOUNT_ID_BYTES - ) - assertIntegrationStringBytes( - 'Linear', - 'organization ID', - workspace.organizationId, - MAX_INTEGRATION_ACCOUNT_ID_BYTES - ) - assertIntegrationStringBytes( - 'Linear', - 'organization name', - workspace.organizationName, - MAX_INTEGRATION_ACCOUNT_LABEL_BYTES - ) - if (workspace.organizationUrlKey !== undefined) { - assertIntegrationStringBytes( - 'Linear', - 'organization URL key', - workspace.organizationUrlKey, - MAX_INTEGRATION_ACCOUNT_LABEL_BYTES - ) - } - assertIntegrationStringBytes( - 'Linear', - 'display name', - workspace.displayName, - MAX_INTEGRATION_ACCOUNT_LABEL_BYTES - ) - if (workspace.email !== null) { - assertIntegrationStringBytes( - 'Linear', - 'email', - workspace.email, - MAX_INTEGRATION_ACCOUNT_EMAIL_BYTES - ) - } -} - -function assertStoredWorkspaceBounds(input: unknown): void { - if (!input || typeof input !== 'object') { - return - } - const record = input as Record - const fields = [ - ['workspace ID', record.id, MAX_INTEGRATION_ACCOUNT_ID_BYTES], - ['organization ID', record.organizationId, MAX_INTEGRATION_ACCOUNT_ID_BYTES], - ['organization name', record.organizationName, MAX_INTEGRATION_ACCOUNT_LABEL_BYTES], - ['organization URL key', record.organizationUrlKey, MAX_INTEGRATION_ACCOUNT_LABEL_BYTES], - ['display name', record.displayName, MAX_INTEGRATION_ACCOUNT_LABEL_BYTES], - ['email', record.email, MAX_INTEGRATION_ACCOUNT_EMAIL_BYTES] - ] as const - for (const [field, value, maxBytes] of fields) { - if (typeof value === 'string') { - assertIntegrationStringBytes('Linear', field, value, maxBytes) - } - } -} - -function assertWorkspaceFileBounds(file: LinearWorkspaceFile): void { - assertIntegrationAccountCount('Linear', file.workspaces.length) - for (const workspace of file.workspaces) { - assertWorkspaceBounds(workspace) - } -} - function emptyWorkspaceFile(): LinearWorkspaceFile { return { version: 1, @@ -318,49 +192,17 @@ function emptyWorkspaceFile(): LinearWorkspaceFile { function readWorkspaceFileFromDisk(): LinearWorkspaceFile { const path = getWorkspaceFilePath() if (!existsSync(path)) { - workspaceFileReadError = null return emptyWorkspaceFile() } try { - const raw = readIntegrationCredentialFileSyncText(path) + const raw = readFileSync(path, { encoding: 'utf-8' }) const parsed = JSON.parse(raw) as Partial - if ( - !parsed || - typeof parsed !== 'object' || - Array.isArray(parsed) || - parsed.version !== 1 || - !Array.isArray(parsed.workspaces) - ) { - throw unreadableIntegrationAccountFileError('Linear') - } - if (typeof parsed.activeWorkspaceId === 'string') { - assertIntegrationStringBytes( - 'Linear', - 'active workspace ID', - parsed.activeWorkspaceId, - MAX_INTEGRATION_ACCOUNT_ID_BYTES - ) - } - if (typeof parsed.selectedWorkspaceId === 'string' && parsed.selectedWorkspaceId !== 'all') { - assertIntegrationStringBytes( - 'Linear', - 'selected workspace ID', - parsed.selectedWorkspaceId, - MAX_INTEGRATION_ACCOUNT_ID_BYTES - ) - } - const workspaces: LinearWorkspace[] = [] - assertIntegrationAccountCount('Linear', parsed.workspaces.length) - for (const input of parsed.workspaces) { - assertStoredWorkspaceBounds(input) - const workspace = normalizeWorkspace(input) - if (!workspace) { - throw unreadableIntegrationAccountFileError('Linear') - } - if (hasStoredToken(workspace.id)) { - workspaces.push(workspace) - } - } + const workspaces = Array.isArray(parsed.workspaces) + ? parsed.workspaces + .map((workspace) => normalizeWorkspace(workspace)) + .filter((workspace): workspace is LinearWorkspace => workspace !== null) + .filter((workspace) => hasStoredToken(workspace.id)) + : [] const activeWorkspaceId = typeof parsed.activeWorkspaceId === 'string' && workspaces.some((workspace) => workspace.id === parsed.activeWorkspaceId) @@ -373,7 +215,6 @@ function readWorkspaceFileFromDisk(): LinearWorkspaceFile { ? parsed.selectedWorkspaceId : activeWorkspaceId - workspaceFileReadError = null return { version: 1, activeWorkspaceId, @@ -381,7 +222,6 @@ function readWorkspaceFileFromDisk(): LinearWorkspaceFile { workspaces } } catch { - workspaceFileReadError = unreadableIntegrationAccountFileError('Linear') return emptyWorkspaceFile() } } @@ -395,10 +235,6 @@ function getWorkspaceFile(): LinearWorkspaceFile { } function writeWorkspaceFile(file: LinearWorkspaceFile): void { - if (workspaceFileReadError) { - throw workspaceFileReadError - } - assertWorkspaceFileBounds(file) ensureOrcaDir() const persistedWorkspaces = file.workspaces.filter( (workspace) => workspace.id !== LEGACY_WORKSPACE_ID @@ -419,19 +255,17 @@ function writeWorkspaceFile(file: LinearWorkspaceFile): void { ? file.selectedWorkspaceId : activeWorkspaceId - const nextFile: LinearWorkspaceFile = { + cachedWorkspaceFile = { version: 1, activeWorkspaceId, selectedWorkspaceId, workspaces: persistedWorkspaces } - const serialized = serializeIntegrationAccountFile(nextFile) - writeFileSync(getWorkspaceFilePath(), serialized, { + workspaceFileLoadedFromDisk = true + writeFileSync(getWorkspaceFilePath(), JSON.stringify(cachedWorkspaceFile, null, 2), { encoding: 'utf-8', mode: 0o600 }) - cachedWorkspaceFile = nextFile - workspaceFileLoadedFromDisk = true } function getLegacyWorkspace(): LinearWorkspace | null { @@ -487,14 +321,8 @@ function clearLegacyViewerOnDisk(): void { } function writeEncryptedToken(path: string, apiKey: string): void { - assertIntegrationCredentialBytes('Linear', apiKey) if (safeStorage.isEncryptionAvailable()) { const encrypted = safeStorage.encryptString(apiKey) - if (encrypted.length > MAX_INTEGRATION_ACCOUNT_FILE_BYTES) { - throw new IntegrationAccountPersistenceLimitError( - `Linear encrypted credential exceeds ${MAX_INTEGRATION_ACCOUNT_FILE_BYTES} bytes.` - ) - } writeFileSync(path, encrypted, { mode: 0o600 }) return } @@ -510,7 +338,7 @@ function saveWorkspaceToken(workspaceId: string, apiKey: string): void { } const tokenPath = getWorkspaceTokenPath(workspaceId) writeEncryptedToken(tokenPath, apiKey) - cacheToken(workspaceId, apiKey) + cachedTokens.set(workspaceId, apiKey) credentialErrors.delete(workspaceId) } @@ -524,12 +352,6 @@ export function loadToken(options: { force?: boolean; workspaceId?: string } = { if (!workspaceId) { return null } - if ( - workspaceId !== LEGACY_WORKSPACE_ID && - !getWorkspaceState().workspaces.some((workspace) => workspace.id === workspaceId) - ) { - return null - } const cached = cachedTokens.get(workspaceId) if (cached !== undefined) { return cached @@ -542,19 +364,15 @@ export function loadToken(options: { force?: boolean; workspaceId?: string } = { return null } try { - const raw = readIntegrationCredentialFileSync(tokenPath) + const raw = readFileSync(tokenPath) const token = readStoredCredentialToken('Linear', raw) if (token) { - assertIntegrationCredentialBytes('Linear', token) - cacheToken(workspaceId, token) + cachedTokens.set(workspaceId, token) } credentialErrors.delete(workspaceId) return token } catch (error) { - if ( - error instanceof CredentialDecryptionError || - error instanceof IntegrationAccountPersistenceLimitError - ) { + if (error instanceof CredentialDecryptionError) { credentialErrors.set(workspaceId, error.message) throw error } @@ -583,10 +401,6 @@ function clearTokenFile(workspaceId: string): void { } export function clearToken(workspaceId?: string): void { - getWorkspaceFile() - if (workspaceFileReadError) { - throw workspaceFileReadError - } if (!workspaceId) { const state = getWorkspaceState() for (const workspace of state.workspaces) { @@ -603,9 +417,6 @@ export function clearToken(workspaceId?: string): void { return } - if (!getWorkspaceState().workspaces.some((workspace) => workspace.id === workspaceId)) { - return - } clearTokenFile(workspaceId) if (workspaceId === LEGACY_WORKSPACE_ID) { cachedLegacyViewer = null @@ -642,10 +453,7 @@ function workspaceFromLinearData( } } -function workspaceFileWithUpsert( - workspace: LinearWorkspace, - options: { select?: boolean } = {} -): LinearWorkspaceFile { +function upsertWorkspace(workspace: LinearWorkspace, options: { select?: boolean } = {}): void { const file = getWorkspaceFile() const current = file.workspaces.find((entry) => entry.id === workspace.id) const credentialRevision = (current?.credentialRevision ?? 0) + 1 @@ -659,16 +467,12 @@ function workspaceFileWithUpsert( : file.selectedWorkspaceId && file.selectedWorkspaceId !== LEGACY_WORKSPACE_ID ? file.selectedWorkspaceId : workspace.id - return { + writeWorkspaceFile({ version: 1, activeWorkspaceId: workspace.id, selectedWorkspaceId, workspaces - } -} - -function upsertWorkspace(workspace: LinearWorkspace, options: { select?: boolean } = {}): void { - writeWorkspaceFile(workspaceFileWithUpsert(workspace, options)) + }) } function replaceLegacyWorkspace(workspace: LinearWorkspace, token: string): void { @@ -682,8 +486,7 @@ function replaceLegacyWorkspace(workspace: LinearWorkspace, token: string): void function resolveWorkspaceId(workspaceId?: string | null): string | null { if (workspaceId && workspaceId !== 'all') { - const state = getWorkspaceState() - return state.workspaces.some((workspace) => workspace.id === workspaceId) ? workspaceId : null + return workspaceId } const state = getWorkspaceState() if ( @@ -736,11 +539,7 @@ export function getClients( // per-workspace credentialError for getStatus to surface, so skip this // workspace like a missing token. A specific-workspace selection still // rethrows so the renderer can surface the decrypt banner promptly. - if ( - isAllSelection && - (error instanceof CredentialDecryptionError || - error instanceof IntegrationAccountPersistenceLimitError) - ) { + if (isAllSelection && error instanceof CredentialDecryptionError) { continue } throw error @@ -781,22 +580,13 @@ export async function connect( { ok: true; viewer: LinearViewer; workspace: LinearWorkspace } | { ok: false; error: string } > { try { - assertIntegrationCredentialBytes('Linear', apiKey) - getWorkspaceFile() - if (workspaceFileReadError) { - throw workspaceFileReadError - } const client = new (loadLinearSdk().LinearClient)({ apiKey }) const me = await client.viewer const org = await me.organization const workspace = workspaceFromLinearData(me, org) - assertWorkspaceBounds(workspace) - const legacyWorkspace = getLegacyWorkspace() - const candidateFile = workspaceFileWithUpsert(workspace, { select: true }) - assertWorkspaceFileBounds(candidateFile) - serializeIntegrationAccountFile(candidateFile) saveWorkspaceToken(workspace.id, apiKey) + const legacyWorkspace = getLegacyWorkspace() if ( legacyWorkspace && legacyWorkspace.organizationName === workspace.organizationName && @@ -810,8 +600,7 @@ export async function connect( upsertWorkspace(workspace, { select: true }) return { ok: true, viewer: workspace, workspace } } catch (error) { - const message = - error instanceof Error ? boundedIntegrationErrorMessage(error) : 'Failed to validate API key' + const message = error instanceof Error ? error.message : 'Failed to validate API key' return { ok: false, error: message } } } @@ -870,10 +659,6 @@ export async function testConnection( ): Promise< { ok: true; viewer: LinearViewer; workspace: LinearWorkspace } | { ok: false; error: string } > { - getWorkspaceFile() - if (workspaceFileReadError) { - return { ok: false, error: workspaceFileReadError.message } - } const resolvedWorkspaceId = resolveWorkspaceId(workspaceId) if (!resolvedWorkspaceId) { return { ok: false, error: 'No API key stored.' } @@ -882,7 +667,7 @@ export async function testConnection( try { token = loadToken({ force: true, workspaceId: resolvedWorkspaceId }) } catch (error) { - const message = error instanceof Error ? boundedIntegrationErrorMessage(error) : 'Test failed' + const message = error instanceof Error ? error.message : 'Test failed' return { ok: false, error: message } } if (!token) { @@ -894,10 +679,6 @@ export async function testConnection( const me = await client.viewer const org = await me.organization const workspace = workspaceFromLinearData(me, org) - assertWorkspaceBounds(workspace) - const candidateFile = workspaceFileWithUpsert(workspace, { select: true }) - assertWorkspaceFileBounds(candidateFile) - serializeIntegrationAccountFile(candidateFile) if (resolvedWorkspaceId === LEGACY_WORKSPACE_ID) { replaceLegacyWorkspace(workspace, token) } else { @@ -909,7 +690,7 @@ export async function testConnection( if (isAuthError(error)) { clearToken(resolvedWorkspaceId) } - const message = error instanceof Error ? boundedIntegrationErrorMessage(error) : 'Test failed' + const message = error instanceof Error ? error.message : 'Test failed' return { ok: false, error: message } } } diff --git a/src/main/linear/issue-context-client.test.ts b/src/main/linear/issue-context-client.test.ts index abd3a63bf83..c284ae8a5d4 100644 --- a/src/main/linear/issue-context-client.test.ts +++ b/src/main/linear/issue-context-client.test.ts @@ -78,7 +78,7 @@ describe('Linear agent issue context client', () => { }) expect(console.warn).toHaveBeenCalledWith( '[linear] agent issue read failed:', - expect.stringContaining('fetch failed') + expect.any(Error) ) }) diff --git a/src/main/linear/issue-context-client.ts b/src/main/linear/issue-context-client.ts index 541430d06cf..1f4863d235a 100644 --- a/src/main/linear/issue-context-client.ts +++ b/src/main/linear/issue-context-client.ts @@ -24,11 +24,11 @@ import { linearError, linearMessage } from './issue-context-errors' -import { getFanoutClientEntries, type WorkspaceReadFailure } from './issue-context-fanout' import { - readLinearIssueWorkspaceFanout, - readLinearSearchWorkspaceFanout -} from './issue-context-fanout-reads' + getFanoutClientEntries, + workspaceFailure, + type WorkspaceReadFailure +} from './issue-context-fanout' import { ambiguousWorkspace, resolveWorkspaceSelector, @@ -59,10 +59,11 @@ export async function searchLinearIssuesForAgents(args: { }) } - const perWorkspace = await readLinearSearchWorkspaceFanout( + const perWorkspace = await readSearchWorkspaces( entries, + args.query, + limit + 1, workspaceId, - (entry) => readSearchWorkspace(entry, args.query, limit + 1, workspaceId), entryFailures ) const merged = perWorkspace.results @@ -76,8 +77,8 @@ export async function searchLinearIssuesForAgents(args: { workspaceId, limit, returned: limited.length, - limitReached: perWorkspace.truncated || merged.length > limit, - partial: perWorkspace.truncated || perWorkspace.failures.length > 0, + limitReached: merged.length > limit, + partial: perWorkspace.failures.length > 0, workspaceErrors: perWorkspace.failures.map(({ workspace, code, message }) => ({ workspace, code, @@ -105,21 +106,9 @@ export async function resolveIssue( }) } - const issueFanout = await readLinearIssueWorkspaceFanout( - entries, - selection, - (entry) => readIssueWorkspace(entry, identifier), - entryFailures - ) - const results = issueFanout.results + const results = await readIssueWorkspaces(entries, identifier, selection, entryFailures) if (results.length === 0) { - if (issueFanout.truncated) { - throw linearError( - 'linear_partial', - 'Linear issue lookup exceeded its aggregate result budget.' - ) - } throw linearError('linear_issue_not_found', `Linear issue ${identifier} was not found.`) } if (results.length > 1) { @@ -214,6 +203,42 @@ async function readIssueWorkspace( return response ? { issue: mapIssue(response), workspace: entry.workspace } : null } +async function readIssueWorkspaces( + entries: LinearClientForWorkspace[], + identifier: string, + selection: string | 'all', + initialFailures: WorkspaceReadFailure[] = [] +): Promise { + if (selection !== 'all') { + const selected = await readIssueWorkspace(entries[0], identifier) + return selected ? [selected] : [] + } + + const settled = await Promise.allSettled( + entries.map((entry) => readIssueWorkspace(entry, identifier)) + ) + const results: ResolvedIssue[] = [] + const failures: LinearAgentAccessError[] = initialFailures.map((failure) => failure.error) + + for (const result of settled) { + if (result.status === 'fulfilled') { + if (result.value) { + results.push(result.value) + } + continue + } + if (result.reason instanceof LinearAgentAccessError) { + failures.push(result.reason) + } + console.warn('[linear] agent issue read failed:', result.reason) + } + + if (results.length === 0 && failures[0]) { + throw failures[0] + } + return results +} + async function readSearchWorkspace( entry: LinearClientForWorkspace, query: string, @@ -227,7 +252,7 @@ async function readSearchWorkspace( SEARCH_QUERY, { term: query, first: limit } ) - return (raw.data?.searchIssues?.nodes ?? []).slice(0, limit) + return raw.data?.searchIssues?.nodes ?? [] }, workspaceId ) @@ -239,3 +264,41 @@ async function readSearchWorkspace( } })) } + +async function readSearchWorkspaces( + entries: LinearClientForWorkspace[], + query: string, + limit: number, + workspaceId?: string | 'all', + initialFailures: WorkspaceReadFailure[] = [] +): Promise<{ results: LinearSearchIssueSummary[][]; failures: WorkspaceReadFailure[] }> { + if (workspaceId && workspaceId !== 'all') { + return { + results: [await readSearchWorkspace(entries[0], query, limit, workspaceId)], + failures: [] + } + } + + const settled = await Promise.allSettled( + entries.map(async (entry) => readSearchWorkspace(entry, query, limit, workspaceId)) + ) + const attemptedWorkspaceCount = entries.length + initialFailures.length + const results: LinearSearchIssueSummary[][] = [] + const failures: WorkspaceReadFailure[] = [...initialFailures] + for (let index = 0; index < settled.length; index += 1) { + const result = settled[index] + if (result.status === 'fulfilled') { + results.push(result.value) + continue + } + if (result.reason instanceof LinearAgentAccessError) { + failures.push(workspaceFailure(entries[index].workspace, result.reason)) + } + console.warn('[linear] agent search failed:', result.reason) + } + + if (results.length === 0 && failures.length === attemptedWorkspaceCount && failures[0]) { + throw failures[0].error + } + return { results, failures } +} diff --git a/src/main/linear/issue-context-errors.ts b/src/main/linear/issue-context-errors.ts index 4f8d3c6b93c..4e76835eb6a 100644 --- a/src/main/linear/issue-context-errors.ts +++ b/src/main/linear/issue-context-errors.ts @@ -1,12 +1,11 @@ import type { LinearErrorCode, LinearIncludeErrorCode } from '../../shared/linear-agent-access' -import { boundedIntegrationErrorMessage } from '../integration-error-message' export class LinearAgentAccessError extends Error { readonly code: LinearErrorCode readonly data?: unknown constructor(code: LinearErrorCode, message: string, data?: unknown) { - super(boundedIntegrationErrorMessage(message)) + super(message) this.name = 'LinearAgentAccessError' this.code = code this.data = data @@ -59,12 +58,13 @@ export function classifyLinearError(error: unknown): LinearErrorCode { } export function linearMessage(error: unknown): string { - return sanitizeLinearErrorMessage(boundedIntegrationErrorMessage(error)) + const message = error instanceof Error ? error.message : String(error) + return sanitizeLinearErrorMessage(message) } export function sanitizeLinearErrorMessage(message: string): string { // Why: provider text is useful in CLI errors, but raw SDK failures can embed secrets or user payloads. - return stripLinearStackTrace(boundedIntegrationErrorMessage(message)) + return stripLinearStackTrace(message) .replace( /(headers?\s*[:=]\s*)\{[^{}]*(?:authorization|token|api[-_]?key)[^{}]*\}/gi, '$1[REDACTED]' diff --git a/src/main/linear/issue-context-fanout-reads.ts b/src/main/linear/issue-context-fanout-reads.ts deleted file mode 100644 index 4b744d97022..00000000000 --- a/src/main/linear/issue-context-fanout-reads.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { runBoundedIntegrationSettledFanout } from '../integration-fanout' -import { boundedIntegrationErrorLog } from '../integration-error-message' -import type { LinearClientForWorkspace } from './client' -import { LinearAgentAccessError } from './issue-context-errors' -import { workspaceFailure, type WorkspaceReadFailure } from './issue-context-fanout' - -export async function readLinearIssueWorkspaceFanout( - entries: LinearClientForWorkspace[], - selection: string | 'all', - readWorkspace: (entry: LinearClientForWorkspace) => Promise, - initialFailures: WorkspaceReadFailure[] = [] -): Promise<{ results: TResult[]; truncated: boolean }> { - if (selection !== 'all') { - const selected = await readWorkspace(entries[0]) - return { results: selected ? [selected] : [], truncated: false } - } - - const fanout = await runBoundedIntegrationSettledFanout(entries, readWorkspace, (result) => - result ? [result] : [] - ) - const results: TResult[] = [] - const failures: LinearAgentAccessError[] = initialFailures.map((failure) => failure.error) - - for (const result of fanout.results) { - if (result.status === 'fulfilled') { - if (result.value) { - results.push(result.value) - } - continue - } - if (result.reason instanceof LinearAgentAccessError) { - failures.push(result.reason) - } - console.warn('[linear] agent issue read failed:', boundedIntegrationErrorLog(result.reason)) - } - - if (fanout.truncated) { - console.warn('[linear] Agent issue fan-out exceeded its aggregate result budget; truncating.') - } - if (results.length === 0 && failures[0] && !fanout.truncated) { - throw failures[0] - } - return { results, truncated: fanout.truncated } -} - -export async function readLinearSearchWorkspaceFanout( - entries: LinearClientForWorkspace[], - workspaceId: string | 'all' | undefined, - readWorkspace: (entry: LinearClientForWorkspace) => Promise, - initialFailures: WorkspaceReadFailure[] = [] -): Promise<{ - results: TResult[][] - failures: WorkspaceReadFailure[] - truncated: boolean -}> { - if (workspaceId && workspaceId !== 'all') { - return { - results: [await readWorkspace(entries[0])], - failures: [], - truncated: false - } - } - - const fanout = await runBoundedIntegrationSettledFanout( - entries, - readWorkspace, - (results) => results - ) - const attemptedWorkspaceCount = fanout.attemptedCount + initialFailures.length - const results: TResult[][] = [] - const failures: WorkspaceReadFailure[] = [...initialFailures] - for (let index = 0; index < fanout.results.length; index += 1) { - const result = fanout.results[index] - if (result.status === 'fulfilled') { - results.push(result.value) - continue - } - if (result.reason instanceof LinearAgentAccessError) { - failures.push(workspaceFailure(entries[index].workspace, result.reason)) - } - console.warn('[linear] agent search failed:', boundedIntegrationErrorLog(result.reason)) - } - - if (fanout.truncated) { - console.warn('[linear] Agent search fan-out exceeded its aggregate result budget; truncating.') - } - if ( - results.length === 0 && - failures.length === attemptedWorkspaceCount && - failures[0] && - !fanout.truncated - ) { - throw failures[0].error - } - return { results, failures, truncated: fanout.truncated } -} diff --git a/src/main/linear/issue-context-fanout.ts b/src/main/linear/issue-context-fanout.ts index 17fe07ead15..484eec818f7 100644 --- a/src/main/linear/issue-context-fanout.ts +++ b/src/main/linear/issue-context-fanout.ts @@ -1,6 +1,5 @@ import type { LinearErrorCode, LinearWorkspaceCandidate } from '../../shared/linear-agent-access' import type { LinearWorkspace } from '../../shared/types' -import { boundedIntegrationErrorLog } from '../integration-error-message' import { getClients, getStatus, type LinearClientForWorkspace } from './client' import { LinearAgentAccessError, @@ -36,10 +35,7 @@ export function getFanoutClientEntries(): { } catch (error) { const failure = workspaceFailure(workspace, toLinearAccessError(error)) failures.push(failure) - console.warn( - '[linear] agent workspace credential read failed:', - boundedIntegrationErrorLog(error) - ) + console.warn('[linear] agent workspace credential read failed:', error) } } return { entries, failures } diff --git a/src/main/linear/issue-context-includes.ts b/src/main/linear/issue-context-includes.ts index 0a8dd70d75c..13a8f4ae234 100644 --- a/src/main/linear/issue-context-includes.ts +++ b/src/main/linear/issue-context-includes.ts @@ -17,8 +17,6 @@ import { clampLinearIssueDepth } from '../../shared/linear-agent-access' import { extractLinearInlineMedia } from '../../shared/linear-inline-media' -import { boundedIntegrationErrorMessage } from '../integration-error-message' -import { IntegrationPaginationBudget } from '../integration-pagination-budget' import type { ResolvedIssue } from './issue-context-client' import { getRequiredEntry, withLinearRead } from './issue-context-client' import { getPublicFileUrlClient } from './client' @@ -71,7 +69,7 @@ export async function readOptionalIncludes( includeErrors.push({ include, code: includeErrorCode(error), - message: boundedIntegrationErrorMessage(error) + message: error instanceof Error ? error.message : String(error) }) } } @@ -177,7 +175,6 @@ async function readChildren( let returned = 0 let capReached = false let depthReached = false - const budget = new IntegrationPaginationBudget() const readLevel = async (issueId: string, level: number): Promise => { if (level > depth || returned >= LINEAR_CHILDREN_NODE_CAP) { @@ -185,20 +182,16 @@ async function readChildren( return [] } const remaining = LINEAR_CHILDREN_NODE_CAP - returned - const response = await readConnectionPages( - remaining, - async (page) => { - return await withLinearRead(entry, async () => { - const client = getPublicFileUrlClient(entry) - const raw = await client.client.rawRequest>( - CHILDREN_QUERY, - { id: issueId, ...page } - ) - return raw.data?.issue?.children ?? null - }) - }, - budget - ) + const response = await readConnectionPages(remaining, async (page) => { + return await withLinearRead(entry, async () => { + const client = getPublicFileUrlClient(entry) + const raw = await client.client.rawRequest>( + CHILDREN_QUERY, + { id: issueId, ...page } + ) + return raw.data?.issue?.children ?? null + }) + }) const nodes = response.nodes if (response.hasMore || nodes.length > remaining) { capReached = true diff --git a/src/main/linear/issue-context-pagination.test.ts b/src/main/linear/issue-context-pagination.test.ts deleted file mode 100644 index b10b36c859a..00000000000 --- a/src/main/linear/issue-context-pagination.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { IntegrationPaginationBudget } from '../integration-pagination-budget' -import { readConnectionPages } from './issue-context-pagination' - -describe('Linear issue context pagination', () => { - it('stops before retaining a page that exceeds the aggregate byte budget', async () => { - const loadConnection = vi - .fn() - .mockResolvedValueOnce({ - nodes: [{ id: 'first', body: 'small' }], - pageInfo: { hasNextPage: true, endCursor: 'next' } - }) - .mockResolvedValueOnce({ - nodes: [{ id: 'second', body: 'x'.repeat(100) }], - pageInfo: { hasNextPage: false, endCursor: null } - }) - const budget = new IntegrationPaginationBudget({ - maxPages: 10, - maxItems: 10, - maxRetainedBytes: 64 - }) - - await expect(readConnectionPages(10, loadConnection, budget)).resolves.toEqual({ - nodes: [{ id: 'first', body: 'small' }], - hasMore: true - }) - expect(loadConnection).toHaveBeenCalledTimes(2) - }) - - it('reports truncation when a backend over-delivers the requested page', async () => { - await expect( - readConnectionPages(1, async () => ({ - nodes: [{ id: 'first' }, { id: 'omitted' }], - pageInfo: { hasNextPage: false, endCursor: null } - })) - ).resolves.toEqual({ - nodes: [{ id: 'first' }], - hasMore: true - }) - }) -}) diff --git a/src/main/linear/issue-context-pagination.ts b/src/main/linear/issue-context-pagination.ts index ad0c5639628..d8c5f239ac9 100644 --- a/src/main/linear/issue-context-pagination.ts +++ b/src/main/linear/issue-context-pagination.ts @@ -1,5 +1,4 @@ import { LINEAR_ISSUE_API_PAGE_SIZE_MAX } from '../../shared/linear-issue-read-limits' -import { IntegrationPaginationBudget } from '../integration-pagination-budget' export type LinearPageVariables = { first: number; after?: string } @@ -13,8 +12,7 @@ export type LinearConnection = { export async function readConnectionPages( limit: number, - loadConnection: (page: LinearPageVariables) => Promise>, - budget = new IntegrationPaginationBudget() + loadConnection: (page: LinearPageVariables) => Promise> ): Promise<{ nodes: T[]; hasMore: boolean }> { const nodes: T[] = [] let after: string | undefined @@ -26,21 +24,13 @@ export async function readConnectionPages( const first = Math.min(LINEAR_ISSUE_API_PAGE_SIZE_MAX, limit - nodes.length) const connection = await loadConnection(after ? { first, after } : { first }) const pageNodes = connection?.nodes ?? [] - const retainedPageNodes = pageNodes.slice(0, limit - nodes.length) - hasMore = - pageNodes.length > retainedPageNodes.length || Boolean(connection?.pageInfo?.hasNextPage) - if (!budget.admitPage(retainedPageNodes)) { - return { nodes, hasMore: true } - } - nodes.push(...retainedPageNodes) + nodes.push(...pageNodes.slice(0, limit - nodes.length)) + hasMore = Boolean(connection?.pageInfo?.hasNextPage) const nextCursor = connection?.pageInfo?.endCursor ?? undefined if (!hasMore || !nextCursor || nextCursor === after || pageNodes.length === 0) { break } - if (!budget.canRequestPage) { - return { nodes, hasMore: true } - } after = nextCursor } diff --git a/src/main/linear/issue-context-raw.ts b/src/main/linear/issue-context-raw.ts index fe61843035d..6f29e9e6a01 100644 --- a/src/main/linear/issue-context-raw.ts +++ b/src/main/linear/issue-context-raw.ts @@ -231,7 +231,7 @@ export function mapIssue(issue: RawIssue): LinearIssueSummary { project: issue.project ?? null, cycle: issue.cycle ?? null, assignee: issue.assignee ?? null, - labels: (issue.labels?.nodes ?? []).slice(0, 50), + labels: issue.labels?.nodes ?? [], priority: issue.priority, estimate: issue.estimate, dueDate: issue.dueDate, diff --git a/src/main/linear/issue-context-relations.ts b/src/main/linear/issue-context-relations.ts index 69e942f1441..fd39787d720 100644 --- a/src/main/linear/issue-context-relations.ts +++ b/src/main/linear/issue-context-relations.ts @@ -1,6 +1,5 @@ import type { LinearCollectionMeta, LinearIssueRelation } from '../../shared/linear-agent-access' import { LINEAR_RELATIONS_CAP } from '../../shared/linear-agent-access' -import { IntegrationPaginationBudget } from '../integration-pagination-budget' import type { ResolvedIssue } from './issue-context-client' import { getRequiredEntry, withLinearRead } from './issue-context-client' import { readConnectionPages } from './issue-context-pagination' @@ -17,20 +16,15 @@ export async function readIssueRelations( resolved: ResolvedIssue ): Promise<{ items: LinearIssueRelation[]; meta: LinearCollectionMeta }> { const entry = getRequiredEntry(resolved.workspace.id) - const budget = new IntegrationPaginationBudget() - const response = await readConnectionPages( - LINEAR_RELATIONS_CAP, - async (page) => { - return await withLinearRead(entry, async () => { - const raw = await entry.client.client.rawRequest< - RawRelationsResponse, - Record - >(RELATIONS_QUERY, { id: resolved.issue.id, ...page }) - return raw.data?.issue?.relations ?? null - }) - }, - budget - ) + const response = await readConnectionPages(LINEAR_RELATIONS_CAP, async (page) => { + return await withLinearRead(entry, async () => { + const raw = await entry.client.client.rawRequest< + RawRelationsResponse, + Record + >(RELATIONS_QUERY, { id: resolved.issue.id, ...page }) + return raw.data?.issue?.relations ?? null + }) + }) const outbound = response.nodes .slice(0, LINEAR_RELATIONS_CAP) .map((node) => mapRelation(node, 'outbound', node.relatedIssue)) @@ -38,19 +32,15 @@ export async function readIssueRelations( // Why: when outbound relations exactly fill the cap, probe inverse relations so // the response cannot claim completeness while silently omitting inbound ones. const inverseReadLimit = Math.max(1, remaining) - const inverse = await readConnectionPages( - inverseReadLimit, - async (page) => { - return await withLinearRead(entry, async () => { - const raw = await entry.client.client.rawRequest< - RawRelationsResponse, - Record - >(INVERSE_RELATIONS_QUERY, { id: resolved.issue.id, ...page }) - return raw.data?.issue?.inverseRelations ?? null - }) - }, - budget - ) + const inverse = await readConnectionPages(inverseReadLimit, async (page) => { + return await withLinearRead(entry, async () => { + const raw = await entry.client.client.rawRequest< + RawRelationsResponse, + Record + >(INVERSE_RELATIONS_QUERY, { id: resolved.issue.id, ...page }) + return raw.data?.issue?.inverseRelations ?? null + }) + }) const inbound = inverse.nodes .slice(0, remaining) .map((node) => mapRelation(node, 'inbound', node.issue)) diff --git a/src/main/linear/issues-retention-bounds.test.ts b/src/main/linear/issues-retention-bounds.test.ts deleted file mode 100644 index c736edb6e1b..00000000000 --- a/src/main/linear/issues-retention-bounds.test.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { LinearClientForWorkspace } from './client' -import { MAX_INTEGRATION_ACCOUNTS } from '../integration-account-persistence-limits' -import { LINEAR_SEARCH_MAX_LIMIT } from '../../shared/linear-agent-access' - -const getClients = vi.fn() - -vi.mock('./client', () => ({ - acquire: vi.fn().mockResolvedValue(undefined), - release: vi.fn(), - getClients: (...args: unknown[]) => getClients(...args), - isAuthError: vi.fn().mockReturnValue(false), - clearToken: vi.fn() -})) - -function issue(id: string) { - return { - id, - identifier: id, - title: id, - description: '', - url: `https://linear.app/${id}`, - estimate: null, - priority: 0, - updatedAt: '2026-01-01T00:00:00.000Z', - labelIds: [], - labels: { nodes: [] } - } -} - -function entry(id: string, rawRequest: ReturnType): LinearClientForWorkspace { - return { - workspace: { - id, - organizationId: id, - organizationName: id, - displayName: 'Ada', - email: null - }, - client: { client: { rawRequest } } - } as unknown as LinearClientForWorkspace -} - -describe('Linear issue retention bounds', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('retains only the requested list page when a provider returns extra rows', async () => { - const rawRequest = vi.fn().mockResolvedValue({ - data: { - issues: { - nodes: [issue('LIN-1'), issue('LIN-2')], - pageInfo: { hasNextPage: false } - } - } - }) - getClients.mockReturnValue([entry('workspace-1', rawRequest)]) - const { listIssues } = await import('./issues') - - await expect(listIssues('all', 1, 'workspace-1')).resolves.toMatchObject({ - items: [{ id: 'LIN-1' }], - hasMore: true - }) - expect(rawRequest.mock.calls[0]?.[1]).toMatchObject({ first: 1 }) - }) - - it('clamps direct search callers and maps no more than the requested rows', async () => { - const rawRequest = vi.fn().mockResolvedValue({ - data: { - searchIssues: { - nodes: Array.from({ length: LINEAR_SEARCH_MAX_LIMIT + 1 }, (_, index) => - issue(`LIN-${index + 1}`) - ) - } - } - }) - getClients.mockReturnValue([entry('workspace-1', rawRequest)]) - const { searchIssues } = await import('./issues') - - await expect(searchIssues('bug', Number.MAX_SAFE_INTEGER, 'workspace-1')).resolves.toHaveLength( - LINEAR_SEARCH_MAX_LIMIT - ) - expect(rawRequest.mock.calls[0]?.[1]).toMatchObject({ first: LINEAR_SEARCH_MAX_LIMIT }) - }) - - it('stops all-workspace scheduling at the aggregate account boundary', async () => { - const requests = Array.from({ length: MAX_INTEGRATION_ACCOUNTS + 1 }, (_, index) => - vi.fn().mockResolvedValue({ - data: { - issues: { - nodes: [issue(`LIN-${index + 1}`)], - pageInfo: { hasNextPage: false } - } - } - }) - ) - getClients.mockReturnValue( - requests.map((request, index) => entry(`workspace-${index}`, request)) - ) - const { listIssues } = await import('./issues') - - await expect(listIssues('all', 1, 'all')).resolves.toMatchObject({ - items: [{ id: 'LIN-1' }], - hasMore: true - }) - expect(requests[MAX_INTEGRATION_ACCOUNTS - 1]).toHaveBeenCalledTimes(1) - expect(requests[MAX_INTEGRATION_ACCOUNTS]).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/linear/issues.ts b/src/main/linear/issues.ts index 0d41ddbf40b..1c8d7d8ee74 100644 --- a/src/main/linear/issues.ts +++ b/src/main/linear/issues.ts @@ -11,7 +11,6 @@ import type { } from '../../shared/types' import type { LinearClient } from '@linear/sdk' import { loadLinearSdk } from './linear-sdk' -import { clampLinearSearchLimit } from '../../shared/linear-agent-access' import { LINEAR_ISSUE_API_PAGE_SIZE_MAX, clampLinearIssueListLimit @@ -20,15 +19,6 @@ import { isEmptyLinearIssueAttributeFilter, type LinearIssueAttributeFilter } from '../../shared/linear-issue-attribute-filter' -import { - boundedIntegrationErrorLog, - boundedIntegrationErrorMessage -} from '../integration-error-message' -import { createIntegrationFanoutBudget, runBoundedIntegrationFanout } from '../integration-fanout' -import { - INTEGRATION_PAGINATION_MAX_ITEMS, - IntegrationPaginationBudget -} from '../integration-pagination-budget' import { acquire, release, @@ -407,7 +397,7 @@ function mapRawIssueForWorkspace( entry: LinearClientForWorkspace, issue: LinearIssueNode ): LinearIssue { - const labelNodes = (issue.labels?.nodes ?? []).slice(0, LINEAR_ISSUE_API_PAGE_SIZE_MAX) + const labelNodes = issue.labels?.nodes ?? [] return { id: issue.id, identifier: issue.identifier, @@ -428,9 +418,7 @@ function mapRawIssueForWorkspace( labels: labelNodes.map((label) => label.name), // Why: labelIds drives full-replace updates. Keep Linear's complete id // list even when display label nodes are paginated. - labelIds: - issue.labelIds?.slice(0, INTEGRATION_PAGINATION_MAX_ITEMS) ?? - labelNodes.map((label) => label.id), + labelIds: issue.labelIds ?? labelNodes.map((label) => label.id), assignee: issue.assignee ? { id: issue.assignee.id, @@ -453,7 +441,6 @@ async function readIssueConnectionPages( loadConnection: LinearIssueConnectionLoader ): Promise<{ items: LinearIssue[]; hasMore: boolean }> { const items: LinearIssue[] = [] - const budget = new IntegrationPaginationBudget() let after: string | undefined let hasMore = false @@ -462,22 +449,12 @@ async function readIssueConnectionPages( // cursors instead of asking for the whole expanded limit in one request. const first = Math.min(LINEAR_ISSUE_API_PAGE_SIZE_MAX, limit - items.length) const connection = await loadConnection(after ? { first, after } : { first }) - const pageNodes = connection?.nodes ?? [] - const nodes = pageNodes.slice(0, first) - const pageItems = nodes.map((issue) => mapRawIssueForWorkspace(entry, issue)) - hasMore = pageNodes.length > nodes.length || Boolean(connection?.pageInfo?.hasNextPage) - if (!budget.admitPage(pageItems)) { - console.warn('[linear] Issue list exceeded its retained result budget; truncating.') - return { items, hasMore: true } - } - items.push(...pageItems) + const nodes = connection?.nodes ?? [] + items.push(...nodes.map((issue) => mapRawIssueForWorkspace(entry, issue))) + hasMore = Boolean(connection?.pageInfo?.hasNextPage) const nextCursor = connection?.pageInfo?.endCursor ?? undefined - if (!hasMore || !nextCursor || nextCursor === after || pageNodes.length === 0) { - break - } - if (!budget.canRequestPage) { - console.warn('[linear] Issue list reached its retained result budget; truncating.') + if (!hasMore || !nextCursor || nextCursor === after || nodes.length === 0) { break } after = nextCursor @@ -562,7 +539,7 @@ function shouldThrowAuthError(selection: LinearWorkspaceSelection | null | undef } function linearWriteMessage(error: unknown): string { - return boundedIntegrationErrorMessage(error) + return error instanceof Error ? error.message : String(error) } function isDuplicateIdError(error: unknown): boolean { @@ -666,7 +643,7 @@ async function runLinearLookup( } function isLinearLookupMiss(error: unknown): boolean { - const message = boundedIntegrationErrorMessage(error) + const message = error instanceof Error ? error.message : String(error) // Why: Linear throws for direct entity lookups that miss; write-id probes // need the same null shape as GraphQL nullable data, not a failed write. return message.includes('Entity not found:') && message.includes('Could not find referenced') @@ -726,10 +703,7 @@ function mapRawIssueWriteRecord( ): LinearIssueWriteRecord { return { ...issue, - ...(issue.labelIds - ? { labelIds: issue.labelIds.slice(0, INTEGRATION_PAGINATION_MAX_ITEMS) } - : {}), - labels: (issue.labels?.nodes ?? []).slice(0, LINEAR_ISSUE_API_PAGE_SIZE_MAX) + labels: issue.labels?.nodes ?? [] } } @@ -757,7 +731,7 @@ export async function getIssue( throw error } } else { - console.warn('[linear] getIssue failed:', boundedIntegrationErrorLog(error)) + console.warn('[linear] getIssue failed:', error) } } finally { release() @@ -840,22 +814,20 @@ export async function searchIssues( limit = 20, workspaceId?: LinearWorkspaceSelection | null ): Promise { - const effectiveLimit = clampLinearSearchLimit(limit) const entries = getClients(workspaceId) if (entries.length === 0) { return [] } - const fanout = await runBoundedIntegrationFanout( - entries, - async (entry) => { + const results = await Promise.all( + entries.map(async (entry) => { await acquire() try { const result = await entry.client.client.rawRequest< LinearIssueConnectionResponse, LinearRawVariables - >(SEARCH_ISSUES_QUERY, { term: query, first: effectiveLimit }) - const nodes = (result.data?.searchIssues?.nodes ?? []).slice(0, effectiveLimit) + >(SEARCH_ISSUES_QUERY, { term: query, first: limit }) + const nodes = result.data?.searchIssues?.nodes ?? [] return nodes.map((issue) => mapRawIssueForWorkspace(entry, issue)) } catch (error) { if (isAuthError(error)) { @@ -864,28 +836,21 @@ export async function searchIssues( throw error } } else { - console.warn('[linear] searchIssues failed:', boundedIntegrationErrorLog(error)) + console.warn('[linear] searchIssues failed:', error) } return [] } finally { release() } - }, - (issues) => issues + }) ) // Why: searchIssues returns Linear's relevance ranking. Re-sorting by // updatedAt would discard relevance order for single-workspace results, // diverging from Linear's web UI and pre-PR behavior. - if (fanout.truncated) { - console.warn( - '[linear] Cross-workspace search exceeded its aggregate result budget; truncating.' - ) - } - const results = fanout.results.flat() if (entries.length === 1) { - return results.slice(0, effectiveLimit) + return results.flat().slice(0, limit) } - return sortAndLimitIssues(results, effectiveLimit) + return sortAndLimitIssues(results.flat(), limit) } export type LinearListFilter = 'assigned' | 'created' | 'all' | 'completed' | 'open' @@ -910,7 +875,7 @@ function linearWorkspaceError( entry: LinearClientForWorkspace, error: unknown ): LinearWorkspaceError { - const message = boundedIntegrationErrorMessage(error) + const message = error instanceof Error ? error.message : String(error) const lower = message.toLocaleLowerCase() const type: LinearWorkspaceError['type'] = isAuthError(error) ? 'auth' @@ -952,7 +917,7 @@ async function readListIssuesForWorkspace( throw error } } else { - console.warn('[linear] listIssues failed:', boundedIntegrationErrorLog(error)) + console.warn('[linear] listIssues failed:', error) } return { items: [], hasMore: false, errors: [linearWorkspaceError(entry, error)] } } finally { @@ -966,11 +931,10 @@ async function readIssueConnectionPage( page: LinearIssuePageRequest ): Promise { const connection = await loadConnection(page) - const pageNodes = connection?.nodes ?? [] - const nodes = pageNodes.slice(0, page.first) + const nodes = connection?.nodes ?? [] return { items: nodes.map((issue) => mapRawIssueForWorkspace(entry, issue)), - hasMore: pageNodes.length > nodes.length || Boolean(connection?.pageInfo?.hasNextPage), + hasMore: Boolean(connection?.pageInfo?.hasNextPage), endCursor: connection?.pageInfo?.endCursor ?? undefined } } @@ -978,9 +942,8 @@ async function readIssueConnectionPage( async function readListIssuesPageForState( state: LinearIssueWorkspacePageState, first: number, - workspaceId: LinearWorkspaceSelection | null | undefined, - budget?: IntegrationPaginationBudget -): Promise { + workspaceId: LinearWorkspaceSelection | null | undefined +): Promise { const previousCursor = state.after await acquire() try { @@ -989,38 +952,25 @@ async function readListIssuesPageForState( state.loadConnection, previousCursor ? { first, after: previousCursor } : { first } ) - if (budget && !budget.admitPage(page.items)) { - state.hasMore = true - state.canPage = false - return false - } state.items.push(...page.items) state.hasMore = page.hasMore state.after = page.endCursor state.canPage = Boolean( page.hasMore && page.endCursor && page.endCursor !== previousCursor && page.items.length > 0 ) - return true } catch (error) { state.items = [] state.hasMore = false state.canPage = false - const workspaceError = linearWorkspaceError(state.entry, error) - state.error = workspaceError + state.error = linearWorkspaceError(state.entry, error) if (isAuthError(error)) { clearToken(state.entry.workspace.id) if (shouldThrowAuthError(workspaceId)) { throw error } } else { - console.warn('[linear] listIssues failed:', boundedIntegrationErrorLog(error)) + console.warn('[linear] listIssues failed:', error) } - if (budget && !budget.admitPage([workspaceError])) { - state.error = undefined - state.hasMore = true - return false - } - return true } finally { release() } @@ -1073,40 +1023,17 @@ async function readListIssuesAcrossWorkspaces( canPage: false })) const first = Math.min(LINEAR_ISSUE_API_PAGE_SIZE_MAX, limit) - const aggregateBudget = createIntegrationFanoutBudget() // Why: "all workspaces" is a global sorted list. Pull one bounded page per // workspace first, then spend additional API calls only where unseen issues // can still change the global updatedAt cutoff. - const initialFanout = await runBoundedIntegrationFanout( - states, - async (state) => { - await readListIssuesPageForState(state, first, workspaceId) - return state - }, - (state) => [...state.items, ...(state.error ? [state.error] : [])], - { budget: aggregateBudget } - ) - const acceptedStates = new Set(initialFanout.results) - for (const state of states) { - if (!acceptedStates.has(state)) { - state.items = [] - state.error = undefined - state.hasMore = true - state.canPage = false - } - } - let aggregateTruncated = initialFanout.truncated + await Promise.all(states.map((state) => readListIssuesPageForState(state, first, workspaceId))) for (;;) { const nextState = findWorkspaceToPageForLimit(states, limit) if (!nextState) { break } - if (!aggregateBudget.canRequestPage) { - aggregateTruncated = true - break - } const itemCount = states.reduce((count, state) => count + state.items.length, 0) const pageSize = itemCount < limit @@ -1115,10 +1042,7 @@ async function readListIssuesAcrossWorkspaces( LINEAR_ISSUE_API_PAGE_SIZE_MAX, Math.max(1, countSelectedIssuesOlderThanWorkspaceBoundary(states, nextState, limit)) ) - if (!(await readListIssuesPageForState(nextState, pageSize, workspaceId, aggregateBudget))) { - aggregateTruncated = true - break - } + await readListIssuesPageForState(nextState, pageSize, workspaceId) } const limited = sortLimitAndDescribeIssues( @@ -1127,7 +1051,7 @@ async function readListIssuesAcrossWorkspaces( ) return { items: limited.items, - hasMore: aggregateTruncated || states.some((state) => state.hasMore) || limited.clipped, + hasMore: states.some((state) => state.hasMore) || limited.clipped, errors: states.flatMap((state) => (state.error ? [state.error] : [])) } } @@ -1224,7 +1148,7 @@ export async function createIssue( clearToken(entry.workspace.id) throw error } - const message = boundedIntegrationErrorMessage(error) + const message = error instanceof Error ? error.message : String(error) return { ok: false, error: message } } finally { release() @@ -1360,7 +1284,7 @@ export async function updateIssue( clearToken(entry.workspace.id) throw error } - const message = boundedIntegrationErrorMessage(error) + const message = error instanceof Error ? error.message : String(error) return { ok: false, error: message } } finally { release() @@ -1457,7 +1381,7 @@ export async function addIssueComment( clearToken(entry.workspace.id) throw error } - const message = boundedIntegrationErrorMessage(error) + const message = error instanceof Error ? error.message : String(error) return { ok: false, error: message } } finally { release() @@ -1587,7 +1511,7 @@ export async function getIssueComments( LinearRawVariables >(ISSUE_COMMENTS_QUERY, { id: issueId }) const nodes = result.data?.issue?.comments?.nodes ?? [] - return nodes.slice(0, LINEAR_ISSUE_API_PAGE_SIZE_MAX).map((node) => ({ + return nodes.map((node) => ({ id: node.id, body: node.body ?? '', // Why: rawRequest returns createdAt as an ISO string already; do not @@ -1605,7 +1529,7 @@ export async function getIssueComments( clearToken(entry.workspace.id) throw error } - console.warn('[linear] getIssueComments failed:', boundedIntegrationErrorLog(error)) + console.warn('[linear] getIssueComments failed:', error) return [] } finally { release() diff --git a/src/main/linear/linear-project-request-coalescer.test.ts b/src/main/linear/linear-project-request-coalescer.test.ts deleted file mode 100644 index 22ccb01732a..00000000000 --- a/src/main/linear/linear-project-request-coalescer.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { - LINEAR_PROJECT_MAX_INFLIGHT_KEYS, - LINEAR_PROJECT_MAX_INFLIGHT_KEY_BYTES, - LinearProjectRequestCoalescer -} from './linear-project-request-coalescer' - -function deferred() { - let resolve!: (value: T) => void - const promise = new Promise((done) => { - resolve = done - }) - return { promise, resolve } -} - -describe('LinearProjectRequestCoalescer', () => { - it('preserves ordinary same-key coalescing and cleans up after settlement', async () => { - const coalescer = new LinearProjectRequestCoalescer() - const request = deferred() - const load = vi.fn(() => request.promise) - - const first = coalescer.coalesce('projects:alpha', load) - const second = coalescer.coalesce('projects:alpha', load) - expect(first).toBe(second) - expect(load).toHaveBeenCalledTimes(1) - expect(coalescer.trackedRequestCount).toBe(1) - - request.resolve('done') - await expect(first).resolves.toBe('done') - expect(coalescer.trackedRequestCount).toBe(0) - }) - - it('tracks the exact key-count boundary and executes key +1 untracked', async () => { - const coalescer = new LinearProjectRequestCoalescer() - const requests = Array.from({ length: LINEAR_PROJECT_MAX_INFLIGHT_KEYS + 1 }, () => - deferred() - ) - const promises = requests.map((request, index) => - coalescer.coalesce(`key-${index}`, () => request.promise) - ) - - expect(coalescer.trackedRequestCount).toBe(LINEAR_PROJECT_MAX_INFLIGHT_KEYS) - requests.forEach((request, index) => request.resolve(index)) - await expect(Promise.all(promises)).resolves.toHaveLength(requests.length) - expect(coalescer.trackedRequestCount).toBe(0) - }) - - it('tracks an exact-size key and executes key byte +1 untracked', async () => { - const coalescer = new LinearProjectRequestCoalescer() - const exactRequest = deferred() - const exact = coalescer.coalesce( - 'a'.repeat(LINEAR_PROJECT_MAX_INFLIGHT_KEY_BYTES), - () => exactRequest.promise - ) - const over = coalescer.coalesce( - 'a'.repeat(LINEAR_PROJECT_MAX_INFLIGHT_KEY_BYTES + 1), - async () => 'over' - ) - - expect(coalescer.trackedRequestCount).toBe(1) - await expect(over).resolves.toBe('over') - expect(coalescer.trackedRequestCount).toBe(1) - exactRequest.resolve('exact') - await expect(exact).resolves.toBe('exact') - expect(coalescer.trackedRequestCount).toBe(0) - }) - - it('keeps a forced replacement tracked when the older request settles', async () => { - const coalescer = new LinearProjectRequestCoalescer() - const staleRequest = deferred() - const freshRequest = deferred() - - const stale = coalescer.coalesce('same', () => staleRequest.promise) - const fresh = coalescer.coalesce('same', () => freshRequest.promise, true) - staleRequest.resolve('stale') - await expect(stale).resolves.toBe('stale') - expect(coalescer.trackedRequestCount).toBe(1) - - freshRequest.resolve('fresh') - await expect(fresh).resolves.toBe('fresh') - expect(coalescer.trackedRequestCount).toBe(0) - }) -}) diff --git a/src/main/linear/linear-project-request-coalescer.ts b/src/main/linear/linear-project-request-coalescer.ts deleted file mode 100644 index 35a7983e690..00000000000 --- a/src/main/linear/linear-project-request-coalescer.ts +++ /dev/null @@ -1,33 +0,0 @@ -export const LINEAR_PROJECT_MAX_INFLIGHT_KEYS = 256 -export const LINEAR_PROJECT_MAX_INFLIGHT_KEY_BYTES = 16 * 1024 - -export class LinearProjectRequestCoalescer { - private readonly inFlight = new Map>() - - coalesce(key: string, load: () => Promise, force = false): Promise { - const existing = this.inFlight.get(key) as Promise | undefined - if (existing && !force) { - return existing - } - - const loaded = load() - const mayTrack = - Buffer.byteLength(key, 'utf8') <= LINEAR_PROJECT_MAX_INFLIGHT_KEY_BYTES && - (this.inFlight.has(key) || this.inFlight.size < LINEAR_PROJECT_MAX_INFLIGHT_KEYS) - if (!mayTrack) { - return loaded - } - - const tracked = loaded.finally(() => { - if (this.inFlight.get(key) === tracked) { - this.inFlight.delete(key) - } - }) - this.inFlight.set(key, tracked) - return tracked - } - - get trackedRequestCount(): number { - return this.inFlight.size - } -} diff --git a/src/main/linear/linear-team-pages.test.ts b/src/main/linear/linear-team-pages.test.ts deleted file mode 100644 index af4329c0780..00000000000 --- a/src/main/linear/linear-team-pages.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { - INTEGRATION_PAGINATION_MAX_ITEMS, - INTEGRATION_PAGINATION_MAX_PAGES, - IntegrationPaginationLimitError -} from '../integration-pagination-budget' -import { fetchAllTeamLabels } from './linear-team-pages' - -function label(index: number) { - return { id: `label-${index}`, name: `Label ${index}`, color: '#888888' } -} - -function cumulativeLabelPage(totalPages: number, advertisesOneMore = false) { - let pageNumber = 1 - const page = { - nodes: [label(1)], - pageInfo: { hasNextPage: totalPages > 1 || advertisesOneMore }, - fetchNext: vi.fn(async () => { - pageNumber += 1 - page.nodes.push(label(pageNumber)) - page.pageInfo.hasNextPage = pageNumber < totalPages || advertisesOneMore - return page - }) - } - return page -} - -describe('bounded Linear team metadata pagination', () => { - it('preserves cumulative node order through the exact page boundary', async () => { - const page = cumulativeLabelPage(INTEGRATION_PAGINATION_MAX_PAGES) - - const labels = await fetchAllTeamLabels({ labels: async () => page }) - expect(labels).toHaveLength(INTEGRATION_PAGINATION_MAX_PAGES) - expect(labels[0]).toMatchObject({ id: 'label-1' }) - expect(labels.at(-1)).toMatchObject({ id: `label-${INTEGRATION_PAGINATION_MAX_PAGES}` }) - expect(page.fetchNext).toHaveBeenCalledTimes(INTEGRATION_PAGINATION_MAX_PAGES - 1) - }) - - it('rejects page +1 before asking the SDK to materialize it', async () => { - const page = cumulativeLabelPage(INTEGRATION_PAGINATION_MAX_PAGES, true) - - await expect(fetchAllTeamLabels({ labels: async () => page })).rejects.toThrow( - IntegrationPaginationLimitError - ) - expect(page.fetchNext).toHaveBeenCalledTimes(INTEGRATION_PAGINATION_MAX_PAGES - 1) - }) - - it('admits the exact item boundary and rejects item +1', async () => { - const exact = { - nodes: Array.from({ length: INTEGRATION_PAGINATION_MAX_ITEMS }, (_, index) => label(index)), - pageInfo: { hasNextPage: false }, - fetchNext: vi.fn() - } - const over = { - ...exact, - nodes: [...exact.nodes, label(INTEGRATION_PAGINATION_MAX_ITEMS)] - } - - await expect(fetchAllTeamLabels({ labels: async () => exact })).resolves.toHaveLength( - INTEGRATION_PAGINATION_MAX_ITEMS - ) - await expect(fetchAllTeamLabels({ labels: async () => over })).rejects.toThrow( - IntegrationPaginationLimitError - ) - }) -}) diff --git a/src/main/linear/linear-team-pages.ts b/src/main/linear/linear-team-pages.ts index e3c9f318b7e..1da7fd6242f 100644 --- a/src/main/linear/linear-team-pages.ts +++ b/src/main/linear/linear-team-pages.ts @@ -1,9 +1,5 @@ import type { LinearLabel, LinearMember, LinearTeam, LinearWorkflowState } from '../../shared/types' import { buildLinearTeamUrl } from '../../shared/linear-links' -import { - IntegrationPaginationBudget, - IntegrationPaginationLimitError -} from '../integration-pagination-budget' import type { LinearClientForWorkspace } from './client' const TEAM_PAGE_SIZE = 100 @@ -36,26 +32,13 @@ type TeamStateNode = { position: number } -async function fetchBoundedConnection( - page: LinearConnectionPage -): Promise> { - const budget = new IntegrationPaginationBudget() - while (true) { - budget.assertCumulativePage(page.nodes) - if (!page.pageInfo.hasNextPage) { - return page - } - if (!budget.canRequestPage) { - throw new IntegrationPaginationLimitError() - } - await page.fetchNext() - } -} - export async function fetchAllTeamsForWorkspace( entry: LinearClientForWorkspace ): Promise { - const page = await fetchBoundedConnection(await entry.client.teams({ first: TEAM_PAGE_SIZE })) + let page = await entry.client.teams({ first: TEAM_PAGE_SIZE }) + while (page.pageInfo.hasNextPage) { + await page.fetchNext() + } return page.nodes.map((t) => ({ id: t.id, workspaceId: entry.workspace.id, @@ -73,7 +56,10 @@ export async function fetchAllTeamsForWorkspace( export async function fetchAllTeamStates(team: { states: (variables?: { first?: number }) => Promise> }): Promise { - const states = await fetchBoundedConnection(await team.states({ first: TEAM_PAGE_SIZE })) + const states = await team.states({ first: TEAM_PAGE_SIZE }) + while (states.pageInfo.hasNextPage) { + await states.fetchNext() + } return states.nodes .map((s) => ({ id: s.id, @@ -88,14 +74,20 @@ export async function fetchAllTeamStates(team: { export async function fetchAllTeamLabels(team: { labels: (variables?: { first?: number }) => Promise> }): Promise { - const labels = await fetchBoundedConnection(await team.labels({ first: TEAM_PAGE_SIZE })) + const labels = await team.labels({ first: TEAM_PAGE_SIZE }) + while (labels.pageInfo.hasNextPage) { + await labels.fetchNext() + } return labels.nodes.map((l) => ({ id: l.id, name: l.name, color: l.color })) } export async function fetchAllTeamMembers(team: { members: (variables?: { first?: number }) => Promise> }): Promise { - const members = await fetchBoundedConnection(await team.members({ first: TEAM_PAGE_SIZE })) + const members = await team.members({ first: TEAM_PAGE_SIZE }) + while (members.pageInfo.hasNextPage) { + await members.fetchNext() + } return members.nodes.map((m) => ({ id: m.id, displayName: m.displayName, diff --git a/src/main/linear/mappers.test.ts b/src/main/linear/mappers.test.ts index 7fd239fef8e..0fba648efc8 100644 --- a/src/main/linear/mappers.test.ts +++ b/src/main/linear/mappers.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import { mapLinearIssue } from './mappers' describe('mapLinearIssue', () => { @@ -47,44 +47,4 @@ describe('mapLinearIssue', () => { project: undefined }) }) - - it('retains only requested lazy-relation rows when the SDK returns extra nodes', async () => { - const labels = vi.fn().mockResolvedValue({ - nodes: Array.from({ length: 51 }, (_, index) => ({ - id: `label-${index}`, - name: `Label ${index}` - })) - }) - const children = vi.fn().mockResolvedValue({ - nodes: Array.from({ length: 26 }, (_, index) => ({ - id: `child-${index}`, - identifier: `LIN-${index}`, - title: `Child ${index}`, - url: `https://linear.app/child-${index}` - })) - }) - const issue = { - id: 'issue-1', - identifier: 'LIN-1', - title: 'Bound relations', - description: null, - url: 'https://linear.app/LIN-1', - estimate: null, - priority: 0, - updatedAt: new Date('2026-01-01T00:00:00.000Z'), - state: null, - team: null, - assignee: null, - labels, - children - } - - const mapped = await mapLinearIssue(issue as never, { includeChildren: true }) - - expect(mapped.labels).toHaveLength(50) - expect(mapped.labelIds).toHaveLength(50) - expect(mapped.subIssues).toHaveLength(25) - expect(labels).toHaveBeenCalledWith({ first: 50 }) - expect(children).toHaveBeenCalledWith({ first: 25 }) - }) }) diff --git a/src/main/linear/mappers.ts b/src/main/linear/mappers.ts index ba35743fba4..7ea55be7fca 100644 --- a/src/main/linear/mappers.ts +++ b/src/main/linear/mappers.ts @@ -1,7 +1,5 @@ import type { Issue, IssueSearchResult } from '@linear/sdk' import type { LinearIssue, LinearIssueChildSummary } from '../../shared/types' -import { LINEAR_ISSUE_API_PAGE_SIZE_MAX } from '../../shared/linear-issue-read-limits' -import { INTEGRATION_PAGINATION_MAX_ITEMS } from '../integration-pagination-budget' type IssueWithChildren = Issue & { children: Issue['children'] @@ -53,24 +51,21 @@ export async function mapLinearIssue( let labelIds: string[] = [] if ('labels' in issue && typeof issue.labels === 'function') { try { - const labelsConnection = await (issue as Issue).labels({ - first: LINEAR_ISSUE_API_PAGE_SIZE_MAX - }) - const labels = labelsConnection.nodes.slice(0, LINEAR_ISSUE_API_PAGE_SIZE_MAX) - labelNames = labels.map((label) => label.name) - labelIds = labels.map((label) => label.id) + const labelsConnection = await (issue as Issue).labels() + labelNames = labelsConnection.nodes.map((l) => l.name) + labelIds = labelsConnection.nodes.map((l) => l.id) } catch { // Swallow — labels are non-critical display data. } } else if ('labelIds' in issue && Array.isArray(issue.labelIds)) { - labelIds = issue.labelIds.slice(0, INTEGRATION_PAGINATION_MAX_ITEMS) as string[] + labelIds = issue.labelIds as string[] } let subIssues: LinearIssueChildSummary[] | undefined if (options.includeChildren && 'children' in issue && typeof issue.children === 'function') { try { const childrenConnection = await (issue as IssueWithChildren).children({ first: 25 }) - subIssues = childrenConnection.nodes.slice(0, 25).map(mapLinearIssueChild) + subIssues = childrenConnection.nodes.map(mapLinearIssueChild) } catch { // Swallow — child issues are secondary display data and creation still works without them. } diff --git a/src/main/linear/mcp-issue-list.ts b/src/main/linear/mcp-issue-list.ts index 33b1c187dd0..3906442879e 100644 --- a/src/main/linear/mcp-issue-list.ts +++ b/src/main/linear/mcp-issue-list.ts @@ -2,7 +2,6 @@ import type { LinearMcpIssueListRequest, LinearMcpIssueListResult } from '../../shared/linear-agent-access' -import { runBoundedIntegrationSettledFanout } from '../integration-fanout' import { getClients, getStatus, type LinearClientForWorkspace } from './client' import { withLinearRead } from './issue-context-client' import { linearError } from './issue-context-errors' @@ -71,7 +70,7 @@ export async function listMcpIssues( nextSteps: ['Connect Linear from Orca settings, then retry the issue list.'] }) } - const { pages, failures, truncated } = await readIssueListWorkspaces( + const { pages, failures } = await readIssueListWorkspaces( entries, request, limit, @@ -79,7 +78,7 @@ export async function listMcpIssues( entryFailures ) const issues = pages.flatMap((page) => page.issues) - let hasMore = truncated || pages.some((page) => page.hasMore) + let hasMore = pages.some((page) => page.hasMore) issues.sort((left, right) => compareIssues(left, right, orderBy)) if (issues.length > limit) { @@ -97,7 +96,7 @@ export async function listMcpIssues( : {}), orderBy, workspaceId: request.workspaceId === 'all' ? 'all' : entries[0].workspace.id, - partial: truncated || failures.length > 0, + partial: failures.length > 0, workspaceErrors: failures.map(({ workspace, code, message }) => ({ workspace, code, @@ -126,45 +125,31 @@ async function readIssueListWorkspaces( limit: number, orderBy: 'createdAt' | 'updatedAt', initialFailures: WorkspaceReadFailure[] -): Promise<{ - pages: WorkspaceIssuePage[] - failures: WorkspaceReadFailure[] - truncated: boolean -}> { +): Promise<{ pages: WorkspaceIssuePage[]; failures: WorkspaceReadFailure[] }> { if (request.workspaceId !== 'all') { return { pages: [await readIssueListWorkspace(entries[0], request, limit, orderBy)], - failures: [], - truncated: false + failures: [] } } - const fanout = await runBoundedIntegrationSettledFanout( - entries, - (entry) => readIssueListWorkspace(entry, request, limit, orderBy), - (page) => page.issues + const settled = await Promise.allSettled( + entries.map((entry) => readIssueListWorkspace(entry, request, limit, orderBy)) ) const pages: WorkspaceIssuePage[] = [] const failures = [...initialFailures] - for (let index = 0; index < fanout.results.length; index += 1) { - const result = fanout.results[index] + for (let index = 0; index < settled.length; index += 1) { + const result = settled[index] if (result.status === 'fulfilled') { pages.push(result.value) continue } failures.push(workspaceFailure(entries[index].workspace, result.reason)) } - if ( - pages.length === 0 && - failures.length === fanout.attemptedCount + initialFailures.length && - !fanout.truncated - ) { + if (pages.length === 0 && failures.length === entries.length + initialFailures.length) { throw failures[0].error } - if (fanout.truncated) { - console.warn('[linear] MCP issue list exceeded its aggregate result budget; truncating.') - } - return { pages, failures, truncated: fanout.truncated } + return { pages, failures } } async function readIssueListWorkspace( @@ -185,14 +170,12 @@ async function readIssueListWorkspace( includeArchived: request.includeArchived ?? false }) const connection = raw.data?.issues - const rawIssues = connection?.nodes ?? [] - const issues = rawIssues.slice(0, limit) return { - issues: issues.map((issue) => ({ + issues: (connection?.nodes ?? []).map((issue) => ({ ...mapIssue(issue), workspace: { id: entry.workspace.id, name: entry.workspace.organizationName } })), - hasMore: rawIssues.length > issues.length || connection?.pageInfo?.hasNextPage === true, + hasMore: connection?.pageInfo?.hasNextPage === true, nextCursor: connection?.pageInfo?.endCursor ?? undefined } }) diff --git a/src/main/linear/projects.ts b/src/main/linear/projects.ts index d32289ed4fc..5945c3fc95b 100644 --- a/src/main/linear/projects.ts +++ b/src/main/linear/projects.ts @@ -16,15 +16,6 @@ import { LINEAR_ISSUE_API_PAGE_SIZE_MAX, clampLinearIssueListLimit } from '../../shared/linear-issue-read-limits' -import { - boundedIntegrationErrorLog, - boundedIntegrationErrorMessage -} from '../integration-error-message' -import { runBoundedIntegrationFanout } from '../integration-fanout' -import { - INTEGRATION_PAGINATION_MAX_ITEMS, - IntegrationPaginationBudget -} from '../integration-pagination-budget' import { acquire, clearToken, @@ -33,7 +24,6 @@ import { release, type LinearClientForWorkspace } from './client' -import { LinearProjectRequestCoalescer } from './linear-project-request-coalescer' type LinearRawVariables = Record @@ -518,7 +508,7 @@ const CUSTOM_VIEW_PROJECTS_QUERY = ` } ` -const requestCoalescer = new LinearProjectRequestCoalescer() +const inFlight = new Map>() const LINEAR_PROJECT_API_PAGE_SIZE_MAX = 50 function clampLimit(limit = 20): number { @@ -526,7 +516,17 @@ function clampLimit(limit = 20): number { } function coalesce(key: string, load: () => Promise, force = false): Promise { - return requestCoalescer.coalesce(key, load, force) + const existing = inFlight.get(key) as Promise | undefined + if (existing && !force) { + return existing + } + const promise = load().finally(() => { + if (inFlight.get(key) === promise) { + inFlight.delete(key) + } + }) + inFlight.set(key, promise) + return promise } function normalizeConcreteWorkspaceId(workspaceId: unknown): LinearConcreteWorkspaceId { @@ -547,7 +547,7 @@ function workspaceError(entry: LinearClientForWorkspace, error: unknown): Linear } const record = error as { name?: string; message?: string; status?: number; response?: unknown } - const message = boundedIntegrationErrorMessage(record.message || 'Linear request failed.') + const message = record.message || 'Linear request failed.' const status = typeof record.status === 'number' ? record.status @@ -629,15 +629,14 @@ function mapProjectForWorkspace( priorityLabel: project.priorityLabel ?? null, lead: mapUser(project.lead), members: project.members?.nodes - ?.slice(0, 10) ?.map(mapUser) .filter((user): user is LinearProjectMemberSummary => !!user), - teams: project.teams?.nodes?.slice(0, 10).map((team) => ({ + teams: project.teams?.nodes?.map((team) => ({ id: team.id, name: team.name ?? '', key: team.key ?? undefined })), - labels: project.labels?.nodes?.slice(0, 20).map((label) => ({ + labels: project.labels?.nodes?.map((label) => ({ id: label.id, name: label.name ?? '', color: label.color ?? undefined @@ -662,7 +661,7 @@ function mapProjectDetailForWorkspace( ): LinearProjectDetail { return { ...mapProjectForWorkspace(entry, project), - milestones: project.projectMilestones?.nodes?.slice(0, 20).map((milestone) => ({ + milestones: project.projectMilestones?.nodes?.map((milestone) => ({ id: milestone.id, name: milestone.name ?? '', status: milestone.status ?? undefined, @@ -670,7 +669,6 @@ function mapProjectDetailForWorkspace( progress: milestone.progress ?? null })), resources: project.externalLinks?.nodes - ?.slice(0, 20) ?.filter((link) => link.url) .map((link) => ({ id: link.id, @@ -696,7 +694,7 @@ function mapIssueForWorkspace( entry: LinearClientForWorkspace, issue: LinearIssueNode ): LinearIssue { - const labelNodes = (issue.labels?.nodes ?? []).slice(0, LINEAR_ISSUE_API_PAGE_SIZE_MAX) + const labelNodes = issue.labels?.nodes ?? [] return { id: issue.id, identifier: issue.identifier, @@ -714,9 +712,7 @@ function mapIssueForWorkspace( key: issue.team?.key ?? '' }, labels: labelNodes.map((label) => label.name), - labelIds: - issue.labelIds?.slice(0, INTEGRATION_PAGINATION_MAX_ITEMS) ?? - labelNodes.map((label) => label.id), + labelIds: issue.labelIds ?? labelNodes.map((label) => label.id), assignee: mapUser(issue.assignee), estimate: issue.estimate ?? null, priority: issue.priority, @@ -781,7 +777,6 @@ async function readIssueConnectionPages( }) => Promise | null | undefined> ): Promise> { const items: LinearIssue[] = [] - const budget = new IntegrationPaginationBudget() let after: string | undefined let hasMore = false @@ -790,22 +785,12 @@ async function readIssueConnectionPages( // Orca reads must follow cursors to show more than one backend page. const first = Math.min(LINEAR_ISSUE_API_PAGE_SIZE_MAX, limit - items.length) const connection = await loadConnection(after ? { first, after } : { first }) - const pageNodes = connection?.nodes ?? [] - const nodes = pageNodes.slice(0, first) - const pageItems = nodes.map((issue) => mapIssueForWorkspace(entry, issue)) - hasMore = pageNodes.length > nodes.length || Boolean(connection?.pageInfo?.hasNextPage) - if (!budget.admitPage(pageItems)) { - console.warn('[linear] Project issue list exceeded its retained result budget; truncating.') - return { items, hasMore: true } - } - items.push(...pageItems) + const nodes = connection?.nodes ?? [] + items.push(...nodes.map((issue) => mapIssueForWorkspace(entry, issue))) + hasMore = Boolean(connection?.pageInfo?.hasNextPage) const nextCursor = connection?.pageInfo?.endCursor ?? undefined - if (!hasMore || !nextCursor || nextCursor === after || pageNodes.length === 0) { - break - } - if (!budget.canRequestPage) { - console.warn('[linear] Project issue list reached its retained result budget; truncating.') + if (!hasMore || !nextCursor || nextCursor === after || nodes.length === 0) { break } after = nextCursor @@ -828,9 +813,8 @@ async function readCollection( return { items: [] } } - const fanout = await runBoundedIntegrationFanout( - entries, - async (entry) => { + const results = await Promise.all( + entries.map(async (entry) => { await acquire() try { return await load(entry) @@ -838,7 +822,7 @@ async function readCollection( if (isAuthError(error)) { clearToken(entry.workspace.id) } else { - console.warn('[linear] project/view read failed:', boundedIntegrationErrorLog(error)) + console.warn('[linear] project/view read failed:', error) } if (shouldFailWholeRequest(workspaceId)) { throw error @@ -847,22 +831,15 @@ async function readCollection( } finally { release() } - }, - (result) => [...result.items, ...(result.errors ?? [])] + }) ) - if (fanout.truncated) { - console.warn( - '[linear] Cross-workspace project metadata exceeded its aggregate result budget; truncating.' - ) - } - const results = fanout.results return { items: results.flatMap((result) => result.items), errors: results.flatMap((result) => result.errors ?? []).length ? results.flatMap((result) => result.errors ?? []) : undefined, - hasMore: fanout.truncated || results.some((result) => result.hasMore) + hasMore: results.some((result) => result.hasMore) } }, force @@ -898,11 +875,9 @@ export async function listProjects( LinearRawVariables >(trimmed ? SEARCH_PROJECTS_QUERY : PROJECTS_QUERY, variables) const connection = trimmed ? result.data?.searchProjects : result.data?.projects - const rawProjects = connection?.nodes ?? [] - const projects = rawProjects.slice(0, first) return { - items: projects.map((project) => mapProjectForWorkspace(entry, project)), - hasMore: rawProjects.length > projects.length || !!connection?.pageInfo?.hasNextPage + items: (connection?.nodes ?? []).map((project) => mapProjectForWorkspace(entry, project)), + hasMore: !!connection?.pageInfo?.hasNextPage } }, force @@ -932,7 +907,6 @@ export async function listProjectsByExactName( await acquire() try { const matches: LinearProjectSummary[] = [] - const budget = new IntegrationPaginationBudget() let after: string | undefined while (true) { const result = await entry.client.client.rawRequest< @@ -944,31 +918,17 @@ export async function listProjectsByExactName( ...(after ? { after } : {}) }) const connection = result.data?.searchProjects - const rawNodes = connection?.nodes ?? [] - const nodes = rawNodes.slice(0, LINEAR_PROJECT_API_PAGE_SIZE_MAX) - if (!budget.admitPage(nodes)) { - console.warn('[linear] Project search exceeded its retained result budget; truncating.') - break - } - for (const project of nodes) { + for (const project of connection?.nodes ?? []) { if (project.name.trim().toLowerCase() === normalized) { matches.push(mapProjectForWorkspace(entry, project)) } } - if (rawNodes.length > nodes.length) { - console.warn('[linear] Project search returned more rows than requested; truncating.') - break - } const nextCursor = connection?.pageInfo?.endCursor ?? undefined - if (connection?.pageInfo?.hasNextPage === true && !budget.canRequestPage) { - console.warn('[linear] Project search reached its retained result budget; truncating.') - break - } if ( connection?.pageInfo?.hasNextPage !== true || !nextCursor || nextCursor === after || - nodes.length === 0 + (connection.nodes ?? []).length === 0 ) { break } @@ -1055,7 +1015,7 @@ export async function createProject( clearToken(entry.workspace.id) throw error } - const message = boundedIntegrationErrorMessage(error) + const message = error instanceof Error ? error.message : String(error) return { ok: false, error: message } } finally { release() @@ -1113,7 +1073,6 @@ export async function listProjectTeams( return [] } const teams: NonNullable = [] - const budget = new IntegrationPaginationBudget() let after: string | undefined await acquire() try { @@ -1131,30 +1090,15 @@ export async function listProjectTeams( throw new Error('Project was not found') } const connection = project.teams - const rawNodes = connection?.nodes ?? [] - const nodes = rawNodes.slice(0, LINEAR_PROJECT_API_PAGE_SIZE_MAX) - if (!budget.admitPage(nodes)) { - console.warn( - '[linear] Project teams exceeded their retained result budget; truncating.' - ) - break - } - for (const team of nodes) { - teams.push({ + const nodes = connection?.nodes ?? [] + teams.push( + ...nodes.map((team) => ({ id: team.id, name: team.name ?? '', key: team.key ?? undefined - }) - } - if (rawNodes.length > nodes.length) { - console.warn('[linear] Project teams returned more rows than requested; truncating.') - break - } + })) + ) const nextCursor = connection?.pageInfo?.endCursor ?? undefined - if (connection?.pageInfo?.hasNextPage === true && !budget.canRequestPage) { - console.warn('[linear] Project teams reached their retained result budget; truncating.') - break - } if ( !connection?.pageInfo?.hasNextPage || !nextCursor || @@ -1197,13 +1141,11 @@ export async function listCustomViews( LinearRawVariables >(CUSTOM_VIEWS_QUERY, { first, filter, orderBy: 'updatedAt' }) const connection = result.data?.customViews - const rawViews = connection?.nodes ?? [] - const views = rawViews.slice(0, first) return { - items: views + items: (connection?.nodes ?? []) .map((view) => mapCustomViewForWorkspace(entry, view)) .filter((view): view is LinearCustomViewSummary => !!view && view.model === model), - hasMore: rawViews.length > views.length || !!connection?.pageInfo?.hasNextPage + hasMore: !!connection?.pageInfo?.hasNextPage } }, force @@ -1309,11 +1251,9 @@ export async function listCustomViewProjects( throw new Error('Custom view does not contain projects') } const connection = view?.projects - const rawProjects = connection?.nodes ?? [] - const projects = rawProjects.slice(0, first) return { - items: projects.map((project) => mapProjectForWorkspace(entry, project)), - hasMore: rawProjects.length > projects.length || !!connection?.pageInfo?.hasNextPage + items: (connection?.nodes ?? []).map((project) => mapProjectForWorkspace(entry, project)), + hasMore: !!connection?.pageInfo?.hasNextPage } }, force diff --git a/src/main/linear/teams.ts b/src/main/linear/teams.ts index c587a1a97d7..e06697e8d46 100644 --- a/src/main/linear/teams.ts +++ b/src/main/linear/teams.ts @@ -6,11 +6,6 @@ import type { LinearWorkspaceError, LinearWorkspaceSelection } from '../../shared/types' -import { - boundedIntegrationErrorLog, - boundedIntegrationErrorMessage -} from '../integration-error-message' -import { runBoundedIntegrationFanout } from '../integration-fanout' import { acquire, release, getClients, isAuthError, clearToken } from './client' import { fetchAllTeamLabels, @@ -27,12 +22,11 @@ export async function listTeams( return [] } - const fanout = await runBoundedIntegrationFanout( - entries, - async (entry) => { + const results = await Promise.all( + entries.map(async (entry) => { await acquire() try { - return await fetchAllTeamsForWorkspace(entry) + return fetchAllTeamsForWorkspace(entry) } catch (error) { if (isAuthError(error)) { clearToken(entry.workspace.id) @@ -40,21 +34,15 @@ export async function listTeams( throw error } } else { - console.warn('[linear] listTeams failed:', boundedIntegrationErrorLog(error)) + console.warn('[linear] listTeams failed:', error) } return [] } finally { release() } - }, - (teams) => teams + }) ) - if (fanout.truncated) { - console.warn( - '[linear] Cross-workspace teams exceeded their aggregate result budget; truncating.' - ) - } - return fanout.results.flat().sort((a, b) => a.name.localeCompare(b.name)) + return results.flat().sort((a, b) => a.name.localeCompare(b.name)) } export async function listTeamsOrThrow( @@ -65,9 +53,8 @@ export async function listTeamsOrThrow( return [] } - const fanout = await runBoundedIntegrationFanout( - entries, - async (entry) => { + const results = await Promise.all( + entries.map(async (entry) => { await acquire() try { return await fetchAllTeamsForWorkspace(entry) @@ -79,15 +66,9 @@ export async function listTeamsOrThrow( } finally { release() } - }, - (teams) => teams + }) ) - if (fanout.truncated) { - console.warn( - '[linear] Cross-workspace teams exceeded their aggregate result budget; truncating.' - ) - } - return fanout.results.flat().sort((a, b) => a.name.localeCompare(b.name)) + return results.flat().sort((a, b) => a.name.localeCompare(b.name)) } export async function listTeamsForAgent( @@ -98,9 +79,8 @@ export async function listTeamsForAgent( return { teams: [], errors: [] } } - const fanout = await runBoundedIntegrationFanout( - entries, - async (entry) => { + const results = await Promise.all( + entries.map(async (entry) => { await acquire() try { return { teams: await fetchAllTeamsForWorkspace(entry), error: null } @@ -114,23 +94,17 @@ export async function listTeamsForAgent( workspaceId: entry.workspace.id, workspaceName: entry.workspace.organizationName, type: isAuthError(error) ? 'auth' : 'unknown', - message: boundedIntegrationErrorMessage(error) + message: error instanceof Error ? error.message : String(error) } satisfies LinearWorkspaceError } } finally { release() } - }, - (result) => [...result.teams, ...(result.error ? [result.error] : [])] + }) ) - if (fanout.truncated) { - console.warn('[linear] Agent team list exceeded its aggregate result budget; truncating.') - } return { - teams: fanout.results - .flatMap((result) => result.teams) - .sort((a, b) => a.name.localeCompare(b.name)), - errors: fanout.results.flatMap((result) => (result.error ? [result.error] : [])) + teams: results.flatMap((result) => result.teams).sort((a, b) => a.name.localeCompare(b.name)), + errors: results.flatMap((result) => (result.error ? [result.error] : [])) } } @@ -152,7 +126,7 @@ export async function getTeamStates( clearToken(entry.workspace.id) throw error } - console.warn('[linear] getTeamStates failed:', boundedIntegrationErrorLog(error)) + console.warn('[linear] getTeamStates failed:', error) return [] } finally { release() @@ -200,7 +174,7 @@ export async function getTeamLabels( clearToken(entry.workspace.id) throw error } - console.warn('[linear] getTeamLabels failed:', boundedIntegrationErrorLog(error)) + console.warn('[linear] getTeamLabels failed:', error) return [] } finally { release() @@ -248,7 +222,7 @@ export async function getTeamMembers( clearToken(entry.workspace.id) throw error } - console.warn('[linear] getTeamMembers failed:', boundedIntegrationErrorLog(error)) + console.warn('[linear] getTeamMembers failed:', error) return [] } finally { release() diff --git a/src/main/local-downloaded-folder-promotion-budget.test.ts b/src/main/local-downloaded-folder-promotion-budget.test.ts deleted file mode 100644 index b0f11ec1645..00000000000 --- a/src/main/local-downloaded-folder-promotion-budget.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - LocalDownloadedFolderPromotionBudget, - type LocalDownloadedFolderPromotionLimits -} from './local-downloaded-folder-promotion-budget' - -const limits: LocalDownloadedFolderPromotionLimits = { - maximumEntries: 2, - maximumDepth: 2, - maximumPathBytes: 4, - maximumRetainedPathBytes: 8 -} - -describe('LocalDownloadedFolderPromotionBudget', () => { - it('accepts every exact boundary', () => { - const budget = new LocalDownloadedFolderPromotionBudget(limits) - - budget.recordEntry('12', '34', 2) - budget.recordEntry('56', '78', 2) - }) - - it.each([ - [ - 'entries', - (budget: LocalDownloadedFolderPromotionBudget) => { - budget.recordEntry('', '', 0) - budget.recordEntry('', '', 0) - budget.recordEntry('', '', 0) - } - ], - ['depth', (budget: LocalDownloadedFolderPromotionBudget) => budget.recordEntry('', '', 3)], - ['path', (budget: LocalDownloadedFolderPromotionBudget) => budget.recordEntry('12345', '', 0)] - ] as const)('rejects one unit beyond the %s boundary', (reason, exceed) => { - expect(() => exceed(new LocalDownloadedFolderPromotionBudget(limits))).toThrow( - expect.objectContaining({ reason }) - ) - }) - - it('rejects one unit beyond the retained-path boundary', () => { - const budget = new LocalDownloadedFolderPromotionBudget({ - ...limits, - maximumEntries: 3 - }) - budget.recordEntry('12', '34', 0) - budget.recordEntry('56', '78', 0) - - expect(() => budget.recordEntry('9', '', 0)).toThrow( - expect.objectContaining({ reason: 'paths' }) - ) - }) -}) diff --git a/src/main/local-downloaded-folder-promotion-budget.ts b/src/main/local-downloaded-folder-promotion-budget.ts deleted file mode 100644 index e7821d31313..00000000000 --- a/src/main/local-downloaded-folder-promotion-budget.ts +++ /dev/null @@ -1,49 +0,0 @@ -export type LocalDownloadedFolderPromotionLimits = { - maximumEntries: number - maximumDepth: number - maximumPathBytes: number - maximumRetainedPathBytes: number -} - -export const LOCAL_DOWNLOADED_FOLDER_PROMOTION_LIMITS: LocalDownloadedFolderPromotionLimits = { - maximumEntries: 50_000, - maximumDepth: 64, - maximumPathBytes: 64 * 1024, - maximumRetainedPathBytes: 16 * 1024 * 1024 -} - -export class LocalDownloadedFolderPromotionCapacityError extends Error { - constructor(readonly reason: 'entries' | 'depth' | 'path' | 'paths') { - super(`Downloaded folder promotion exceeds the ${reason} limit`) - this.name = 'LocalDownloadedFolderPromotionCapacityError' - } -} - -export class LocalDownloadedFolderPromotionBudget { - private entries = 0 - private retainedPathBytes = 0 - - constructor(private readonly limits = LOCAL_DOWNLOADED_FOLDER_PROMOTION_LIMITS) {} - - recordEntry(sourcePath: string, destinationPath: string, depth: number): void { - if (depth > this.limits.maximumDepth) { - throw new LocalDownloadedFolderPromotionCapacityError('depth') - } - this.entries += 1 - if (this.entries > this.limits.maximumEntries) { - throw new LocalDownloadedFolderPromotionCapacityError('entries') - } - const sourceBytes = Buffer.byteLength(sourcePath, 'utf8') - const destinationBytes = Buffer.byteLength(destinationPath, 'utf8') - if ( - sourceBytes > this.limits.maximumPathBytes || - destinationBytes > this.limits.maximumPathBytes - ) { - throw new LocalDownloadedFolderPromotionCapacityError('path') - } - this.retainedPathBytes += sourceBytes + destinationBytes - if (this.retainedPathBytes > this.limits.maximumRetainedPathBytes) { - throw new LocalDownloadedFolderPromotionCapacityError('paths') - } - } -} diff --git a/src/main/local-downloaded-folder-promotion-traversal.ts b/src/main/local-downloaded-folder-promotion-traversal.ts deleted file mode 100644 index a7681c225e2..00000000000 --- a/src/main/local-downloaded-folder-promotion-traversal.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { Dirent } from 'node:fs' -import { opendir } from 'node:fs/promises' -import { join } from 'node:path' -import { LocalDownloadedFolderPromotionBudget } from './local-downloaded-folder-promotion-budget' - -export async function readPromotionDirectoryEntries( - sourcePath: string, - destinationPath: string, - budget: LocalDownloadedFolderPromotionBudget, - depth: number -): Promise { - const entries: Dirent[] = [] - const directory = await opendir(sourcePath) - try { - for await (const entry of directory) { - budget.recordEntry(join(sourcePath, entry.name), join(destinationPath, entry.name), depth + 1) - entries.push(entry) - } - } finally { - await directory.close().catch(() => undefined) - } - return entries.sort((a, b) => a.name.localeCompare(b.name)) -} - -export async function assertPromotionTreeWithinCapacity( - sourceRoot: string, - destinationRoot: string, - signal?: AbortSignal -): Promise { - const budget = new LocalDownloadedFolderPromotionBudget() - const pending = [{ sourcePath: sourceRoot, destinationPath: destinationRoot, depth: 0 }] - while (pending.length > 0) { - signal?.throwIfAborted() - const current = pending.pop()! - const directory = await opendir(current.sourcePath) - try { - for await (const entry of directory) { - signal?.throwIfAborted() - const sourcePath = join(current.sourcePath, entry.name) - const destinationPath = join(current.destinationPath, entry.name) - const depth = current.depth + 1 - budget.recordEntry(sourcePath, destinationPath, depth) - if (entry.isDirectory()) { - pending.push({ sourcePath, destinationPath, depth }) - } - } - } finally { - await directory.close().catch(() => undefined) - } - } -} diff --git a/src/main/local-downloaded-folder-promotion.test.ts b/src/main/local-downloaded-folder-promotion.test.ts index 47168f19408..57d82edd6e2 100644 --- a/src/main/local-downloaded-folder-promotion.test.ts +++ b/src/main/local-downloaded-folder-promotion.test.ts @@ -19,7 +19,6 @@ import { copyLocalDownloadedFileNoClobber, publishLocalDownloadedFileNoClobber } from './local-downloaded-folder-promotion' -import { LOCAL_DOWNLOADED_FOLDER_PROMOTION_LIMITS } from './local-downloaded-folder-promotion-budget' describe('promoteLocalDownloadedFolder', () => { const roots: string[] = [] @@ -116,30 +115,6 @@ describe('promoteLocalDownloadedFolder', () => { await expect(readdir(destinationPath)).rejects.toMatchObject({ code: 'ENOENT' }) }) - it('rejects excessive depth before claiming the destination', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-folder-promotion-')) - roots.push(root) - const tempPath = join(root, '.transfer.download') - const destinationPath = join(root, 'downloaded') - await mkdir(tempPath) - let nested = tempPath - for ( - let depth = 0; - depth <= LOCAL_DOWNLOADED_FOLDER_PROMOTION_LIMITS.maximumDepth; - depth += 1 - ) { - nested = join(nested, 'd') - await mkdir(nested) - } - - await expect(promoteLocalDownloadedFolder(tempPath, destinationPath)).rejects.toMatchObject({ - reason: 'depth' - }) - - await expect(lstat(destinationPath)).rejects.toMatchObject({ code: 'ENOENT' }) - await expect(lstat(nested)).resolves.toMatchObject({ isDirectory: expect.any(Function) }) - }) - it('rolls back unchanged entries after a mid-publication failure', async () => { const { tempPath, destinationPath } = await createPaths() await writeFile(join(tempPath, 'a-first.txt'), 'remote') diff --git a/src/main/local-downloaded-folder-promotion.ts b/src/main/local-downloaded-folder-promotion.ts index 9c962d57e75..f5ba45f7a5c 100644 --- a/src/main/local-downloaded-folder-promotion.ts +++ b/src/main/local-downloaded-folder-promotion.ts @@ -1,12 +1,8 @@ -import { fstatSync, lstatSync, type BigIntStats } from 'node:fs' -import { link, lstat, mkdir, open, rm, rmdir, unlink } from 'node:fs/promises' +import { fstatSync, lstatSync } from 'node:fs' +import { link, lstat, mkdir, open, readdir, rm, rmdir, unlink } from 'node:fs/promises' +import type { BigIntStats } from 'node:fs' import type { FileHandle } from 'node:fs/promises' import { join } from 'node:path' -import { LocalDownloadedFolderPromotionBudget } from './local-downloaded-folder-promotion-budget' -import { - assertPromotionTreeWithinCapacity, - readPromotionDirectoryEntries -} from './local-downloaded-folder-promotion-traversal' const LOCAL_COPY_CHUNK_BYTES = 1024 * 1024 @@ -214,8 +210,6 @@ async function publishDirectoryNoClobber( sourcePath: string, destinationPath: string, publishedEntries: PublishedEntry[], - budget: LocalDownloadedFolderPromotionBudget, - depth: number, signal?: AbortSignal ): Promise { await mkdir(destinationPath, { recursive: false }) @@ -223,7 +217,9 @@ async function publishDirectoryNoClobber( // claim so no async gap exists before rollback ownership is registered. const destinationStats = lstatSync(destinationPath, { bigint: true }) publishedEntries.push(publishedEntryFromStats('directory', destinationPath, destinationStats)) - const entries = await readPromotionDirectoryEntries(sourcePath, destinationPath, budget, depth) + const entries = (await readdir(sourcePath, { withFileTypes: true })).toSorted((a, b) => + a.name.localeCompare(b.name) + ) for (const entry of entries) { signal?.throwIfAborted() const sourceEntryPath = join(sourcePath, entry.name) @@ -233,8 +229,6 @@ async function publishDirectoryNoClobber( sourceEntryPath, destinationEntryPath, publishedEntries, - budget, - depth + 1, signal ) } else if (entry.isFile()) { @@ -257,21 +251,12 @@ export async function promoteLocalDownloadedFolder( destinationPath: string, signal?: AbortSignal ): Promise { - signal?.throwIfAborted() - await assertPromotionTreeWithinCapacity(tempPath, destinationPath, signal) signal?.throwIfAborted() const publishedEntries: PublishedEntry[] = [] try { // Why: Node has no portable atomic no-replace directory rename. Claiming // the destination first preserves no-clobber while promotion stays local. - await publishDirectoryNoClobber( - tempPath, - destinationPath, - publishedEntries, - new LocalDownloadedFolderPromotionBudget(), - 0, - signal - ) + await publishDirectoryNoClobber(tempPath, destinationPath, publishedEntries, signal) } catch (error) { await rollbackPublishedEntries(publishedEntries) if (isEEXIST(error)) { diff --git a/src/main/local-worktree-filesystem.test.ts b/src/main/local-worktree-filesystem.test.ts index a8cf9eec6ae..496d88b0eae 100644 --- a/src/main/local-worktree-filesystem.test.ts +++ b/src/main/local-worktree-filesystem.test.ts @@ -1,9 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { execFileMock, lstatMock, readNodeFileWithinLimitMock, rmMock } = vi.hoisted(() => ({ +const { execFileMock, lstatMock, readFileMock, rmMock } = vi.hoisted(() => ({ execFileMock: vi.fn(), lstatMock: vi.fn(), - readNodeFileWithinLimitMock: vi.fn(), + readFileMock: vi.fn(), rmMock: vi.fn() })) @@ -13,13 +13,10 @@ vi.mock('node:child_process', () => ({ vi.mock('node:fs/promises', () => ({ lstat: lstatMock, + readFile: readFileMock, rm: rmMock })) -vi.mock('../shared/node-bounded-file-reader', () => ({ - readNodeFileWithinLimit: readNodeFileWithinLimitMock -})) - import { getLocalWorktreePathAccess, removeLocalWorktreePath, @@ -52,7 +49,7 @@ describe('local worktree filesystem runtime access', () => { beforeEach(() => { execFileMock.mockReset() lstatMock.mockReset() - readNodeFileWithinLimitMock.mockReset() + readFileMock.mockReset() rmMock.mockReset() completeExecFile() }) @@ -64,9 +61,7 @@ describe('local worktree filesystem runtime access', () => { it('uses host filesystem operations when no WSL distro is selected', async () => { lstatMock.mockResolvedValue({ type: 'file' }) - readNodeFileWithinLimitMock.mockResolvedValue({ - buffer: Buffer.from('gitdir: ../.git/worktrees/feature') - }) + readFileMock.mockResolvedValue('gitdir: ../.git/worktrees/feature') const access = getLocalWorktreePathAccess() await access.statPath('C:\\repo\\.git') @@ -74,7 +69,7 @@ describe('local worktree filesystem runtime access', () => { await removeLocalWorktreePath('C:\\repo\\feature') expect(lstatMock).toHaveBeenCalledWith('C:\\repo\\.git') - expect(readNodeFileWithinLimitMock).toHaveBeenCalledWith('C:\\repo\\.git', 64 * 1024) + expect(readFileMock).toHaveBeenCalledWith('C:\\repo\\.git', 'utf8') expect(rmMock).toHaveBeenCalledWith( toHostRemovalPath('C:\\repo\\feature'), expect.objectContaining({ @@ -170,8 +165,6 @@ describe('local worktree filesystem runtime access', () => { expect.objectContaining({ encoding: 'utf8' }), expect.any(Function) ) - const readArgs = execFileMock.mock.calls[1]?.[1] as string[] - expect(readArgs.at(-1)).toContain('head -c 65537 --') const removeArgs = execFileMock.mock.calls[2]?.[1] as string[] expect(removeArgs.at(-1)).toContain('rm -rf --') expect(removeArgs.at(-1)).toContain( @@ -181,17 +174,6 @@ describe('local worktree filesystem runtime access', () => { }) }) - it('rejects oversized WSL Git pointer output', async () => { - await withPlatform('win32', async () => { - completeExecFile('x'.repeat(64 * 1024 + 1)) - const access = getLocalWorktreePathAccess({ wslDistro: 'Ubuntu' }) - - await expect(access.readPath('/home/me/repo/.git')).rejects.toThrow( - 'Worktree Git pointer exceeds the safe read limit' - ) - }) - }) - it('reports missing WSL stat targets with an ENOENT-shaped error', async () => { await withPlatform('win32', async () => { failExecFile(Object.assign(new Error('missing'), { code: 2 })) diff --git a/src/main/local-worktree-filesystem.ts b/src/main/local-worktree-filesystem.ts index f82929eec2f..aafb5ea93fa 100644 --- a/src/main/local-worktree-filesystem.ts +++ b/src/main/local-worktree-filesystem.ts @@ -1,6 +1,6 @@ import { execFile } from 'node:child_process' import type { RmOptions } from 'node:fs' -import { lstat, rm } from 'node:fs/promises' +import { lstat, readFile, rm } from 'node:fs/promises' import { win32 } from 'node:path' import { setTimeout as delay } from 'node:timers/promises' import { @@ -9,12 +9,7 @@ import { quotePosixShell } from '../shared/wsl-login-shell-command' import { toLinuxPath } from './wsl' -import { - MAX_WORKTREE_GIT_POINTER_BYTES, - type ReadPath, - type StatPath -} from './worktree-orphan-gitdir-proof' -import { readNodeFileWithinLimit } from '../shared/node-bounded-file-reader' +import type { ReadPath, StatPath } from './worktree-orphan-gitdir-proof' export type LocalWorktreeFilesystemOptions = { wslDistro?: string @@ -42,13 +37,13 @@ function shouldUseWslFilesystem(options: LocalWorktreeFilesystemOptions): boolea function execFileText( file: string, args: string[], - options: { maxBuffer?: number; timeout: number } + options: { timeout: number } ): Promise { return new Promise((resolve, reject) => { execFile( file, args, - { encoding: 'utf8', timeout: options.timeout, maxBuffer: options.maxBuffer }, + { encoding: 'utf8', timeout: options.timeout }, (error, stdout, stderr) => { if (error) { reject(error) @@ -102,10 +97,7 @@ export function getLocalWorktreePathAccess( if (!shouldUseWslFilesystem(options) || !distro) { return { statPath: lstat, - readPath: async (path) => - (await readNodeFileWithinLimit(path, MAX_WORKTREE_GIT_POINTER_BYTES)).buffer.toString( - 'utf8' - ) + readPath: (path) => readFile(path, 'utf8') } } @@ -128,26 +120,7 @@ export function getLocalWorktreePathAccess( }, readPath: async (path) => { const target = quotePosixShell(toLinuxPath(path)) - const { stdout } = await execFileText( - 'wsl.exe', - [ - '-d', - distro, - '--', - 'sh', - '-lc', - escapeWslShCommandForWindows( - buildWslLoginShellCommand(`head -c ${MAX_WORKTREE_GIT_POINTER_BYTES + 1} -- ${target}`) - ) - ], - { - timeout: WSL_FILE_OPERATION_TIMEOUT_MS, - maxBuffer: (MAX_WORKTREE_GIT_POINTER_BYTES + 1) * 2 - } - ) - if (Buffer.byteLength(stdout, 'utf8') > MAX_WORKTREE_GIT_POINTER_BYTES) { - throw new Error('Worktree Git pointer exceeds the safe read limit') - } + const { stdout } = await runWslLoginShellCommand(distro, `cat -- ${target}`) return stdout } } diff --git a/src/main/memory/collector.test.ts b/src/main/memory/collector.test.ts index e8992b3acc9..63f8026b870 100644 --- a/src/main/memory/collector.test.ts +++ b/src/main/memory/collector.test.ts @@ -653,53 +653,6 @@ describe('collectMemorySnapshot', () => { expectProcessSweepCount(2) }) - it('bounds retained worktree histories and recovers an evicted active history', async () => { - mockPsResponse('') - const { - collectMemorySnapshot, - getMemoryHistoryWorktreeCountForTests, - MEMORY_HISTORY_MAX_WORKTREES - } = await loadCollector() - const registrations = Array.from({ length: MEMORY_HISTORY_MAX_WORKTREES }, (_, index) => ({ - ptyId: `pty-${index}`, - worktreeId: `repo::/worktree-${index}`, - sessionId: `session-${index}`, - paneKey: `pane-${index}`, - pid: null - })) - listRegisteredPtysMock.mockReturnValue(registrations) - - await collectMemorySnapshot(emptyStore) - const atBoundary = await collectMemorySnapshot(emptyStore) - - expect(atBoundary.worktrees.every((worktree) => worktree.history.length === 2)).toBe(true) - expect(getMemoryHistoryWorktreeCountForTests()).toBe(MEMORY_HISTORY_MAX_WORKTREES) - - listRegisteredPtysMock.mockReturnValue([ - ...registrations, - { - ptyId: 'pty-overflow', - worktreeId: 'repo::/worktree-overflow', - sessionId: 'session-overflow', - paneKey: 'pane-overflow', - pid: null - } - ]) - const overflow = await collectMemorySnapshot(emptyStore) - - expect(overflow.worktrees).toHaveLength(MEMORY_HISTORY_MAX_WORKTREES + 1) - expect(overflow.worktrees[0].history).toEqual([0]) - expect(getMemoryHistoryWorktreeCountForTests()).toBe(MEMORY_HISTORY_MAX_WORKTREES) - - listRegisteredPtysMock.mockReturnValue([registrations[0]]) - const reactivated = await collectMemorySnapshot(emptyStore) - const retained = await collectMemorySnapshot(emptyStore) - - expect(reactivated.worktrees[0].history).toEqual([0]) - expect(retained.worktrees[0].history).toEqual([0, 0]) - expect(getMemoryHistoryWorktreeCountForTests()).toBe(MEMORY_HISTORY_MAX_WORKTREES) - }) - it('uses host process RSS for Electron app metrics when available', async () => { mockPsResponse(['10 1 1.5 111', '20 10 2.5 222', '30 10 3.5 333'].join('\n')) appMetricsMock.mockReturnValue([ diff --git a/src/main/memory/collector.ts b/src/main/memory/collector.ts index 527af81e87b..171c07f175b 100644 --- a/src/main/memory/collector.ts +++ b/src/main/memory/collector.ts @@ -126,7 +126,6 @@ function emptySnapshot(): MemorySnapshot { const APP_HISTORY_KEY = '__app__' const HISTORY_CAPACITY = 60 const HISTORY_STALE_MS = 10 * 60 * 1000 -export const MEMORY_HISTORY_MAX_WORKTREES = 1024 type HistoryRing = { samples: number[] @@ -139,39 +138,18 @@ function pushHistorySample(key: string, memoryBytes: number, now: number): void let ring = historyByKey.get(key) if (!ring) { ring = { samples: [], touchedAt: now } + historyByKey.set(key, ring) } ring.samples.push(memoryBytes) if (ring.samples.length > HISTORY_CAPACITY) { ring.samples.shift() } ring.touchedAt = now - // Why: worktree ids can churn faster than the stale TTL in a long-lived - // main process; recency ordering gives the history cache a hard ceiling. - historyByKey.delete(key) - historyByKey.set(key, ring) - trimWorktreeHistory() } -function trimWorktreeHistory(): void { - const appEntryCount = historyByKey.has(APP_HISTORY_KEY) ? 1 : 0 - while (historyByKey.size - appEntryCount > MEMORY_HISTORY_MAX_WORKTREES) { - let oldestWorktreeKey: string | undefined - for (const key of historyByKey.keys()) { - if (key !== APP_HISTORY_KEY) { - oldestWorktreeKey = key - break - } - } - if (oldestWorktreeKey === undefined) { - break - } - historyByKey.delete(oldestWorktreeKey) - } -} - -function readHistory(key: string, currentSample?: number): number[] { +function readHistory(key: string): number[] { const ring = historyByKey.get(key) - return ring ? [...ring.samples] : currentSample === undefined ? [] : [currentSample] + return ring ? [...ring.samples] : [] } function sweepStaleHistory(now: number): void { @@ -182,11 +160,6 @@ function sweepStaleHistory(now: number): void { } } -/** @internal — test-only */ -export function getMemoryHistoryWorktreeCountForTests(): number { - return historyByKey.size - (historyByKey.has(APP_HISTORY_KEY) ? 1 : 0) -} - // ─── Host process enumeration ─────────────────────────────────────── async function enumerateProcesses(): Promise { @@ -460,9 +433,7 @@ async function runSnapshot(store: MemorySnapshotStore): Promise const worktrees: WorktreeMemory[] = bucketList.map((b) => ({ ...b, - // Why: an over-cap active worktree still gets its current point even when - // an older retained ring had to be evicted during this same snapshot. - history: readHistory(b.worktreeId, b.memory) + history: readHistory(b.worktreeId) })) let sessionCpuTotal = 0 diff --git a/src/main/mimo/hook-service.test.ts b/src/main/mimo/hook-service.test.ts index f175debdffc..e30e415ca97 100644 --- a/src/main/mimo/hook-service.test.ts +++ b/src/main/mimo/hook-service.test.ts @@ -1,14 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { - existsSync, - lstatSync, - mkdirSync, - mkdtempSync, - readFileSync, - rmSync, - symlinkSync, - writeFileSync -} from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -84,47 +75,4 @@ describe('MimoCodeHookService buildPtyEnv', () => { readFileSync(join(overlayHome, 'config', 'plugins', 'orca-mimocode-status.js'), 'utf8') ).toContain('/hook/mimo-code') }) - - it.skipIf(process.platform === 'win32')( - 'keeps a symlinked user plugins directory isolated from Orca writes', - () => { - const sourcePlugins = join(mimocodeHome, 'config', 'plugins') - const realPlugins = join(mimocodeHome, 'real-plugins') - rmSync(sourcePlugins, { recursive: true, force: true }) - mkdirSync(realPlugins) - writeFileSync(join(realPlugins, 'user-plugin.js'), 'USER PLUGIN') - symlinkSync(realPlugins, sourcePlugins, 'dir') - - const env = new MimoCodeHookService().buildPtyEnv('pty-1', mimocodeHome) - const overlayPlugins = join(env.MIMOCODE_HOME!, 'config', 'plugins') - - expect(existsSync(join(realPlugins, 'orca-mimocode-status.js'))).toBe(false) - expect(lstatSync(overlayPlugins).isSymbolicLink()).toBe(false) - expect(readFileSync(join(overlayPlugins, 'user-plugin.js'), 'utf8')).toBe('USER PLUGIN') - expect(readFileSync(join(overlayPlugins, 'orca-mimocode-status.js'), 'utf8')).toContain( - '/hook/mimo-code' - ) - } - ) - - it('falls back to the original home when the config plan exceeds capacity', async () => { - const mirroring = await import('../pty/config-overlay-mirroring') - const planSpy = vi.spyOn(mirroring, 'createConfigOverlayPlan').mockImplementation(() => { - throw new mirroring.ConfigOverlayCapacityError('entries', 4_097, 4_096) - }) - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - try { - expect(new MimoCodeHookService().buildPtyEnv('pty-1', mimocodeHome)).toEqual({ - MIMOCODE_HOME: mimocodeHome - }) - expect(warnSpy).toHaveBeenCalledWith( - '[mimocode-hooks] config overlay exceeded its memory limit; using the original MiMo home without Orca status integration' - ) - expect(readFileSync(join(mimocodeHome, 'config', 'mimocode.json'), 'utf8')).toBe( - '{"theme":"dark"}' - ) - } finally { - planSpy.mockRestore() - } - }) }) diff --git a/src/main/mimo/hook-service.ts b/src/main/mimo/hook-service.ts index 7d5420ba2de..3f879d93063 100644 --- a/src/main/mimo/hook-service.ts +++ b/src/main/mimo/hook-service.ts @@ -1,14 +1,9 @@ import { app } from 'electron' import { join } from 'node:path' -import { existsSync, mkdirSync, unlinkSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, readdirSync, writeFileSync } from 'node:fs' import { homedir } from 'node:os' import { getOpenCodeFamilyPluginSource } from '../opencode/hook-service' -import { - ConfigOverlayCapacityError, - applyConfigOverlayPlan, - createConfigOverlayPlan -} from '../pty/config-overlay-mirroring' -import { safeRemoveTree } from '../pty/overlay-mirror' +import { mirrorEntry, safeRemoveTree } from '../pty/overlay-mirror' const ORCA_MIMOCODE_PLUGIN_FILE = 'orca-mimocode-status.js' const MIMOCODE_HOOKS_DIR = 'mimocode-hooks' @@ -29,9 +24,30 @@ function resolveSourceConfigDir(existingHome: string | undefined): string | unde return existsSync(xdg) ? xdg : undefined } -export class MimoCodeHookService { - private warnedOverlayCapacity = false +function mirrorConfigDir(sourceConfigDir: string, targetConfigDir: string): void { + mkdirSync(targetConfigDir, { recursive: true }) + for (const entry of readdirSync(sourceConfigDir, { withFileTypes: true })) { + if (entry.name === 'plugins' && entry.isDirectory()) { + const overlayPlugins = join(targetConfigDir, 'plugins') + mkdirSync(overlayPlugins, { recursive: true }) + for (const pluginEntry of readdirSync(join(sourceConfigDir, 'plugins'), { + withFileTypes: true + })) { + if (pluginEntry.name === ORCA_MIMOCODE_PLUGIN_FILE) { + continue + } + mirrorEntry( + join(sourceConfigDir, 'plugins', pluginEntry.name), + join(overlayPlugins, pluginEntry.name) + ) + } + continue + } + mirrorEntry(join(sourceConfigDir, entry.name), join(targetConfigDir, entry.name)) + } +} +export class MimoCodeHookService { clearPty(_ptyId: string): void {} buildPtyEnv(_ptyId: string, existingMimocodeHome?: string): Record { @@ -45,33 +61,16 @@ export class MimoCodeHookService { const overlayConfig = join(home, 'config') const sourceConfig = resolveSourceConfigDir(existingMimocodeHome) if (sourceConfig) { - const plan = createConfigOverlayPlan(sourceConfig, { - reservedPluginFile: ORCA_MIMOCODE_PLUGIN_FILE - }) - if (!safeRemoveTree(overlayConfig)) { - throw new Error('Unable to clear the MiMo config overlay') - } - mkdirSync(overlayConfig, { recursive: true }) - applyConfigOverlayPlan(plan, overlayConfig) + safeRemoveTree(overlayConfig) + mirrorConfigDir(sourceConfig, overlayConfig) } const pluginsDir = join(home, 'config', 'plugins') mkdirSync(pluginsDir, { recursive: true }) - const pluginPath = join(pluginsDir, ORCA_MIMOCODE_PLUGIN_FILE) - try { - unlinkSync(pluginPath) - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { - throw error - } - } - writeFileSync(pluginPath, getOpenCodeFamilyPluginSource('/hook/mimo-code')) - } catch (error) { - if (!this.warnedOverlayCapacity && error instanceof ConfigOverlayCapacityError) { - this.warnedOverlayCapacity = true - console.warn( - '[mimocode-hooks] config overlay exceeded its memory limit; using the original MiMo home without Orca status integration' - ) - } + writeFileSync( + join(pluginsDir, ORCA_MIMOCODE_PLUGIN_FILE), + getOpenCodeFamilyPluginSource('/hook/mimo-code') + ) + } catch { return existingMimocodeHome ? { MIMOCODE_HOME: existingMimocodeHome } : {} } return { MIMOCODE_HOME: home } diff --git a/src/main/minimax/minimax-cookie-store.test.ts b/src/main/minimax/minimax-cookie-store.test.ts index 1f2466b2237..4362dd2df93 100644 --- a/src/main/minimax/minimax-cookie-store.test.ts +++ b/src/main/minimax/minimax-cookie-store.test.ts @@ -22,13 +22,10 @@ const homedirMock = vi.fn(() => '/home/test') vi.mock('node:fs', () => ({ existsSync: existsSyncMock, + readFileSync: readFileSyncMock, rmSync: rmSyncMock })) -vi.mock('../integration-credential-file', () => ({ - readIntegrationCredentialFileSync: readFileSyncMock -})) - vi.mock('node:os', () => ({ homedir: homedirMock })) diff --git a/src/main/minimax/minimax-cookie-store.ts b/src/main/minimax/minimax-cookie-store.ts index e9bbcbcb508..3864f892bfb 100644 --- a/src/main/minimax/minimax-cookie-store.ts +++ b/src/main/minimax/minimax-cookie-store.ts @@ -1,9 +1,8 @@ import { safeStorage } from 'electron' -import { existsSync, rmSync } from 'node:fs' +import { existsSync, readFileSync, rmSync } from 'node:fs' import { homedir } from 'node:os' import { join } from 'node:path' import { hardenExistingSecureFile, writeSecureFile } from '../../shared/secure-file' -import { readIntegrationCredentialFileSync } from '../integration-credential-file' const MINIMAX_COOKIE_FILE = 'minimax-session-cookie.enc' const COOKIE_ENVELOPE_PREFIX = 'orca-minimax-cookie:v1:' @@ -150,7 +149,7 @@ export function readMiniMaxSessionCookie(): string | null { console.warn('[minimax] Failed to harden MiniMax cookie file while reading', error) } try { - const raw = readIntegrationCredentialFileSync(keyPath) + const raw = readFileSync(keyPath) const envelope = decodeCookieEnvelope(raw) cachedMiniMaxCookie = envelope ? readEnvelope(envelope) : readLegacyCookie(raw) return cachedMiniMaxCookie diff --git a/src/main/native-chat/session-file-resolver.ts b/src/main/native-chat/session-file-resolver.ts index bb23a88512e..33cf8c23823 100644 --- a/src/main/native-chat/session-file-resolver.ts +++ b/src/main/native-chat/session-file-resolver.ts @@ -3,7 +3,7 @@ import { homedir } from 'node:os' import { basename, extname, join } from 'node:path' import type { AgentType } from '../../shared/native-chat-types' import { resolveNativeChatTranscriptAgent } from '../../shared/native-chat-agent-support' -import { findFirstSessionFile } from '../ai-vault/session-scanner-discovery' +import { walkSessionFiles } from '../ai-vault/session-scanner-discovery' import { getOrcaManagedCodexHomePath } from '../codex/codex-home-paths' import { findGrokChatHistoryBySessionId, @@ -102,10 +102,11 @@ async function resolveClaudeSessionFile( projectsDir: string ): Promise { const targetName = `${sessionId}.jsonl` - return findFirstSessionFile(projectsDir, { + const files = await walkSessionFiles(projectsDir, 'claude', [], { extensions: new Set(['.jsonl']), filePredicate: (path) => basename(path) === targetName }) + return files[0] ?? null } async function resolveCodexSessionFile( @@ -119,15 +120,15 @@ async function resolveCodexSessionFile( if (!existsSync(sessionsDir)) { continue } - const file = await findFirstSessionFile(sessionsDir, { + const files = await walkSessionFiles(sessionsDir, 'codex', [], { extensions: new Set(['.jsonl']), filePredicate: (path) => { const name = basename(path, extname(path)) return name === sessionId || name.endsWith(`-${sessionId}`) } }) - if (file) { - return file + if (files[0]) { + return files[0] } } return null diff --git a/src/main/native-chat/transcript-incremental-reader.test.ts b/src/main/native-chat/transcript-incremental-reader.test.ts deleted file mode 100644 index d97a264176f..00000000000 --- a/src/main/native-chat/transcript-incremental-reader.test.ts +++ /dev/null @@ -1,185 +0,0 @@ -import { mkdtemp, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import type { NativeChatMessage } from '../../shared/native-chat-types' -import { - APPEND_BATCH_RETAINED_BYTE_LIMIT, - createIncrementalTranscriptState, - readIncrementalTranscriptMessages -} from './transcript-incremental-reader' -import { - estimateTranscriptMessageRetainedBytes, - MAX_NATIVE_CHAT_TRANSCRIPT_MESSAGES -} from './transcript-message-retention' -import { transcriptFallbackId } from './transcript-fallback-id' -import { MAX_NATIVE_CHAT_TRANSCRIPT_RECORD_BYTES } from './transcript-tail-reader' - -const tempRoots: string[] = [] - -afterEach(async () => { - await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) -}) - -describe('readIncrementalTranscriptMessages', () => { - it('keeps the newest shared-retention window for an initial snapshot without batching', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-transcript-incremental-')) - tempRoots.push(root) - const filePath = join(root, 'transcript.jsonl') - const extraMessages = 5 - await writeFile( - filePath, - Array.from( - { length: MAX_NATIVE_CHAT_TRANSCRIPT_MESSAGES + extraMessages }, - (_unused, index) => `row-${index}\n` - ).join('') - ) - const state = createIncrementalTranscriptState() - - const messages = await readIncrementalTranscriptMessages( - filePath, - state, - (line): NativeChatMessage => ({ - id: line, - role: 'user', - blocks: [{ type: 'text', text: line }], - timestamp: null, - source: 'transcript' - }) - ) - - expect(messages).toHaveLength(MAX_NATIVE_CHAT_TRANSCRIPT_MESSAGES) - expect(messages[0]?.id).toBe(`row-${extraMessages}`) - expect(messages.at(-1)?.id).toBe( - `row-${MAX_NATIVE_CHAT_TRANSCRIPT_MESSAGES + extraMessages - 1}` - ) - }) - - it('flushes append batches by retained bytes before the message-count limit', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-transcript-incremental-batch-bytes-')) - tempRoots.push(root) - const filePath = join(root, 'transcript.jsonl') - const payload = 'x'.repeat(1024 * 1024) - const lines = Array.from({ length: 5 }, (_unused, index) => `row-${index}:${payload}`) - await writeFile(filePath, `${lines.join('\n')}\n`) - const state = createIncrementalTranscriptState() - const batches: NativeChatMessage[][] = [] - const decode = (line: string): NativeChatMessage => ({ - id: line.slice(0, line.indexOf(':')), - role: 'user', - blocks: [{ type: 'text', text: line }], - timestamp: null, - source: 'transcript' - }) - - const remaining = await readIncrementalTranscriptMessages(filePath, state, decode, (messages) => - batches.push(messages) - ) - const delivered = [...batches.flat(), ...remaining] - - expect(batches.map((batch) => batch.length)).toEqual([3]) - expect(delivered.map((message) => message.id)).toEqual( - Array.from({ length: 5 }, (_unused, index) => `row-${index}`) - ) - for (const batch of [...batches, remaining]) { - const retainedBytes = batch.reduce( - (total, message) => - total + - estimateTranscriptMessageRetainedBytes( - lines.find((line) => line.startsWith(`${message.id}:`))!.length - ), - 0 - ) - expect(retainedBytes).toBeLessThanOrEqual(APPEND_BATCH_RETAINED_BYTE_LIMIT) - } - }) - - it('pauses an aggregate drain at a record boundary and resumes without gaps', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-transcript-incremental-drain-budget-')) - tempRoots.push(root) - const filePath = join(root, 'transcript.jsonl') - await writeFile(filePath, 'row-0\nrow-1\nrow-2\nrow-3\nrow-4\n') - const state = createIncrementalTranscriptState() - const decode = (line: string): NativeChatMessage => ({ - id: line, - role: 'user', - blocks: [{ type: 'text', text: line }], - timestamp: null, - source: 'transcript' - }) - const drain = (): Promise => - readIncrementalTranscriptMessages(filePath, state, decode, () => {}, undefined, undefined, { - maxDrainRetainedBytes: 600 - }) - - const first = await drain() - const firstOffset = state.offset - const second = await drain() - const third = await drain() - - expect(first.map((message) => message.id)).toEqual(['row-0', 'row-1']) - expect(second.map((message) => message.id)).toEqual(['row-2', 'row-3']) - expect(third.map((message) => message.id)).toEqual(['row-4']) - expect(firstOffset).toBeGreaterThan(0) - expect(state.pendingRecord.byteLength).toBe(0) - }) - - it('advances one bounded record when the drain budget is smaller than that record', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-transcript-incremental-small-budget-')) - tempRoots.push(root) - const filePath = join(root, 'transcript.jsonl') - await writeFile(filePath, 'row-0\nrow-1\n') - const state = createIncrementalTranscriptState() - const decode = (line: string): NativeChatMessage => ({ - id: line, - role: 'user', - blocks: [{ type: 'text', text: line }], - timestamp: null, - source: 'transcript' - }) - const drain = (): Promise => - readIncrementalTranscriptMessages(filePath, state, decode, () => {}, undefined, undefined, { - maxDrainRetainedBytes: 1 - }) - - const first = await drain() - const firstOffset = state.offset - const second = await drain() - const secondOffset = state.offset - const exhausted = await drain() - - expect(first.map((message) => message.id)).toEqual(['row-0']) - expect(second.map((message) => message.id)).toEqual(['row-1']) - expect(exhausted).toEqual([]) - expect(firstOffset).toBeGreaterThan(0) - expect(secondOffset).toBeGreaterThan(firstOffset) - }) - - it('keeps the exact fallback offset after discarding an oversized record', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-transcript-incremental-oversized-')) - tempRoots.push(root) - const filePath = join(root, 'transcript.jsonl') - const oversized = Buffer.alloc(MAX_NATIVE_CHAT_TRANSCRIPT_RECORD_BYTES + 1, 0x78) - const valid = Buffer.from('valid\n') - await writeFile(filePath, Buffer.concat([oversized, Buffer.from('\n'), valid])) - const state = createIncrementalTranscriptState() - - const messages = await readIncrementalTranscriptMessages( - filePath, - state, - (line, fallbackId): NativeChatMessage => ({ - id: fallbackId, - role: 'user', - blocks: [{ type: 'text', text: line }], - timestamp: null, - source: 'transcript' - }) - ) - - expect(messages.map((message) => message.id)).toEqual([ - transcriptFallbackId(filePath, oversized.byteLength + 1) - ]) - expect(state.offset).toBe(oversized.byteLength + 1 + valid.byteLength) - expect(state.pendingRecord.byteLength).toBe(0) - }) -}) diff --git a/src/main/native-chat/transcript-incremental-reader.ts b/src/main/native-chat/transcript-incremental-reader.ts index 1c1df1ddef5..8ad8cc335d1 100644 --- a/src/main/native-chat/transcript-incremental-reader.ts +++ b/src/main/native-chat/transcript-incremental-reader.ts @@ -1,38 +1,27 @@ import { open, stat } from 'node:fs/promises' import type { NativeChatMessage, NativeChatTurnLifecycle } from '../../shared/native-chat-types' import { transcriptFallbackId } from './transcript-fallback-id' -import { TranscriptRecordBuffer } from './transcript-record-buffer' import { MAX_NATIVE_CHAT_TRANSCRIPT_RECORD_BYTES, type NativeChatLineDecoder } from './transcript-tail-reader' -import { - estimateTranscriptMessageRetainedBytes, - TranscriptMessageRetention -} from './transcript-message-retention' -export const APPEND_BATCH_MESSAGE_LIMIT = 40 -export const APPEND_BATCH_RETAINED_BYTE_LIMIT = 8 * 1024 * 1024 -export const INCREMENTAL_DRAIN_RETAINED_BYTE_LIMIT = 32 * 1024 * 1024 +const APPEND_BATCH_MESSAGE_LIMIT = 40 export type IncrementalTranscriptState = { offset: number - pendingRecord: TranscriptRecordBuffer + pendingChunks: Buffer[] pendingStart: number -} - -export function createIncrementalTranscriptState(): IncrementalTranscriptState { - return { - offset: 0, - pendingRecord: new TranscriptRecordBuffer(MAX_NATIVE_CHAT_TRANSCRIPT_RECORD_BYTES), - pendingStart: 0 - } + pendingBytes: number + droppingOversizedRecord: boolean } export function resetIncrementalTranscriptState(state: IncrementalTranscriptState): void { state.offset = 0 - state.pendingRecord.clear() + state.pendingChunks.length = 0 state.pendingStart = 0 + state.pendingBytes = 0 + state.droppingOversizedRecord = false } export async function readIncrementalTranscriptMessages( @@ -41,41 +30,27 @@ export async function readIncrementalTranscriptMessages( decode: NativeChatLineDecoder, onBatch?: (messages: NativeChatMessage[]) => void, decodeLifecycle?: (line: string, fallbackId: string) => NativeChatTurnLifecycle | null, - onLifecycle?: (lifecycle: NativeChatTurnLifecycle) => void, - options: { maxDrainRetainedBytes?: number } = {} + onLifecycle?: (lifecycle: NativeChatTurnLifecycle) => void ): Promise { const end = (await stat(filePath)).size if (end <= state.offset) { return [] } const messages: NativeChatMessage[] = [] - let messageBatchBytes = 0 - let drainRetainedBytes = 0 - const requestedDrainLimit = options.maxDrainRetainedBytes - const maxDrainRetainedBytes = - Number.isSafeInteger(requestedDrainLimit) && (requestedDrainLimit ?? 0) > 0 - ? Math.min(INCREMENTAL_DRAIN_RETAINED_BYTE_LIMIT, requestedDrainLimit ?? 0) - : INCREMENTAL_DRAIN_RETAINED_BYTE_LIMIT - const retainedSnapshot = onBatch ? null : new TranscriptMessageRetention() const handle = await open(filePath, 'r') try { const stream = handle.createReadStream({ start: state.offset, end: end - 1, autoClose: false }) let absoluteOffset = state.offset - streamChunks: for await (const rawChunk of stream) { + for await (const rawChunk of stream) { const chunk = Buffer.isBuffer(rawChunk) ? rawChunk : Buffer.from(rawChunk) let segmentStart = 0 let newline = chunk.indexOf(0x0a) while (newline >= 0) { retainPart(chunk.subarray(segmentStart, newline)) - if (!state.pendingRecord.isOversized && !decodeLine()) { - const retryOffset = state.pendingStart - resetPendingLine(retryOffset) - state.offset = retryOffset - break streamChunks + if (!state.droppingOversizedRecord) { + decodeLine() } - const nextOffset = absoluteOffset + newline + 1 - resetPendingLine(nextOffset) - state.offset = nextOffset + resetPendingLine(absoluteOffset + newline + 1) segmentStart = newline + 1 newline = chunk.indexOf(0x0a, segmentStart) } @@ -85,69 +60,51 @@ export async function readIncrementalTranscriptMessages( absoluteOffset += chunk.length state.offset = absoluteOffset } - return retainedSnapshot?.values() ?? messages + return messages } finally { await handle.close() } function retainPart(part: Buffer): void { - state.pendingRecord.append(part) + if (state.droppingOversizedRecord) { + return + } + state.pendingBytes += part.length + if (state.pendingBytes > MAX_NATIVE_CHAT_TRANSCRIPT_RECORD_BYTES) { + state.pendingChunks.length = 0 + state.droppingOversizedRecord = true + return + } + state.pendingChunks.push(part) } function resetPendingLine(nextStart: number): void { - state.pendingRecord.clear() + state.pendingChunks.length = 0 + state.pendingBytes = 0 + state.droppingOversizedRecord = false state.pendingStart = nextStart } - function decodeLine(): boolean { - let line = state.pendingRecord.toString() + function decodeLine(): void { + let line = Buffer.concat(state.pendingChunks).toString('utf8') if (line.endsWith('\r')) { line = line.slice(0, -1) } if (!line) { - return true + return } const fallbackId = transcriptFallbackId(filePath, state.pendingStart) - const message = decode(line, fallbackId) - const estimatedBytes = message - ? estimateTranscriptMessageRetainedBytes(state.pendingRecord.byteLength) - : 0 - if ( - message && - onBatch && - drainRetainedBytes > 0 && - estimatedBytes > maxDrainRetainedBytes - drainRetainedBytes - ) { - // Why: a budget below one bounded record must still advance instead of - // retrying that record forever. - return false - } const lifecycle = decodeLifecycle?.(line, fallbackId) if (lifecycle) { onLifecycle?.(lifecycle) } + const message = decode(line, fallbackId) if (!message) { - return true - } - if (retainedSnapshot) { - retainedSnapshot.add(message, state.pendingRecord.byteLength) - return true - } - drainRetainedBytes += estimatedBytes - if ( - onBatch && - messages.length > 0 && - estimatedBytes > APPEND_BATCH_RETAINED_BYTE_LIMIT - messageBatchBytes - ) { - onBatch(messages.splice(0)) - messageBatchBytes = 0 + return } messages.push(message) - messageBatchBytes += estimatedBytes if (onBatch && messages.length >= APPEND_BATCH_MESSAGE_LIMIT) { onBatch(messages.splice(0)) - messageBatchBytes = 0 } - return true } } diff --git a/src/main/native-chat/transcript-message-retention.test.ts b/src/main/native-chat/transcript-message-retention.test.ts deleted file mode 100644 index 9dae78eb069..00000000000 --- a/src/main/native-chat/transcript-message-retention.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { NativeChatMessage } from '../../shared/native-chat-types' -import { - MAX_NATIVE_CHAT_TRANSCRIPT_RETAINED_BYTES, - TranscriptMessageRetention -} from './transcript-message-retention' - -function message(id: string): NativeChatMessage { - return { - id, - role: 'user', - blocks: [{ type: 'text', text: id }], - timestamp: null, - source: 'transcript' - } -} - -describe('TranscriptMessageRetention', () => { - it('keeps the newest messages within both count and byte budgets', () => { - const retention = new TranscriptMessageRetention(2, 600) - - retention.add(message('one'), 10) - retention.add(message('two'), 10) - retention.add(message('three'), 10) - - expect(retention.values().map(({ id }) => id)).toEqual(['two', 'three']) - expect(retention.size).toBe(2) - expect(retention.retainedBytes).toBeLessThanOrEqual(600) - }) - - it('applies the production 64 MiB policy without retaining older oversized history', () => { - const retention = new TranscriptMessageRetention() - const sourceBytes = 2 * 1024 * 1024 - - for (let index = 0; index < 17; index += 1) { - retention.add(message(`message-${index}`), sourceBytes) - } - - expect(retention.values().map(({ id }) => id)).toEqual( - Array.from({ length: 15 }, (_unused, index) => `message-${index + 2}`) - ) - expect(retention.retainedBytes).toBeLessThanOrEqual(MAX_NATIVE_CHAT_TRANSCRIPT_RETAINED_BYTES) - }) -}) diff --git a/src/main/native-chat/transcript-message-retention.ts b/src/main/native-chat/transcript-message-retention.ts deleted file mode 100644 index aee50bacd55..00000000000 --- a/src/main/native-chat/transcript-message-retention.ts +++ /dev/null @@ -1,59 +0,0 @@ -import type { NativeChatMessage } from '../../shared/native-chat-types' - -export const MAX_NATIVE_CHAT_TRANSCRIPT_MESSAGES = 50_000 -export const MAX_NATIVE_CHAT_TRANSCRIPT_RETAINED_BYTES = 64 * 1024 * 1024 - -type RetainedMessage = { - message: NativeChatMessage - estimatedBytes: number -} - -export function estimateTranscriptMessageRetainedBytes(sourceBytes: number): number { - return sourceBytes * 2 + 256 -} - -export class TranscriptMessageRetention { - private entries: (RetainedMessage | undefined)[] = [] - private head = 0 - private retained = 0 - - constructor( - private readonly maxMessages = MAX_NATIVE_CHAT_TRANSCRIPT_MESSAGES, - private readonly maxBytes = MAX_NATIVE_CHAT_TRANSCRIPT_RETAINED_BYTES - ) {} - - add(message: NativeChatMessage, sourceBytes: number): void { - const estimatedBytes = estimateTranscriptMessageRetainedBytes(sourceBytes) - this.entries.push({ message, estimatedBytes }) - this.retained += estimatedBytes - while (this.size > this.maxMessages || this.retained > this.maxBytes) { - const oldest = this.entries[this.head] - this.entries[this.head] = undefined - this.head += 1 - this.retained -= oldest?.estimatedBytes ?? 0 - } - if (this.head >= 1_024 && this.head * 2 >= this.entries.length) { - this.entries.splice(0, this.head) - this.head = 0 - } - } - - values(): NativeChatMessage[] { - const messages: NativeChatMessage[] = [] - for (let index = this.head; index < this.entries.length; index += 1) { - const entry = this.entries[index] - if (entry) { - messages.push(entry.message) - } - } - return messages - } - - get size(): number { - return this.entries.length - this.head - } - - get retainedBytes(): number { - return this.retained - } -} diff --git a/src/main/native-chat/transcript-read-admission.test.ts b/src/main/native-chat/transcript-read-admission.test.ts deleted file mode 100644 index fbb395d4fcf..00000000000 --- a/src/main/native-chat/transcript-read-admission.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { NativeChatTranscriptReadAdmission } from './transcript-read-admission' - -describe('NativeChatTranscriptReadAdmission', () => { - it('admits only reads that fit both concurrency and aggregate byte limits', async () => { - const admission = new NativeChatTranscriptReadAdmission(10, 2, 4) - const releaseSix = await admission.acquire(6) - const releaseFour = await admission.acquire(4) - let thirdAdmitted = false - const third = admission.acquire(1).then((release) => { - thirdAdmitted = true - return release - }) - - await Promise.resolve() - expect(thirdAdmitted).toBe(false) - expect(admission.activeCount).toBe(2) - expect(admission.retainedBytes).toBe(10) - expect(admission.queuedCount).toBe(1) - - releaseFour() - const releaseThird = await third - expect(thirdAdmitted).toBe(true) - expect(admission.retainedBytes).toBe(7) - - releaseSix() - releaseThird() - expect(admission.activeCount).toBe(0) - expect(admission.retainedBytes).toBe(0) - }) - - it('removes an aborted queued read and immediately reuses the slot', async () => { - const admission = new NativeChatTranscriptReadAdmission(10, 2, 1) - const releaseActive = await admission.acquire(10) - const controller = new AbortController() - const queued = admission.acquire(1, controller.signal) - - expect(admission.queuedCount).toBe(1) - controller.abort() - await expect(queued).rejects.toMatchObject({ name: 'AbortError' }) - expect(admission.queuedCount).toBe(0) - - const replacement = admission.acquire(1) - expect(admission.queuedCount).toBe(1) - releaseActive() - const releaseReplacement = await replacement - releaseReplacement() - }) - - it('bounds queued closures and releases reservations idempotently', async () => { - const admission = new NativeChatTranscriptReadAdmission(1, 1, 2) - const releaseActive = await admission.acquire(1) - const first = admission.acquire(1) - const second = admission.acquire(1) - - expect(() => admission.acquire(1)).toThrow('Too many queued native chat transcript reads') - releaseActive() - releaseActive() - const releaseFirst = await first - releaseFirst() - const releaseSecond = await second - releaseSecond() - - expect(admission.activeCount).toBe(0) - expect(admission.retainedBytes).toBe(0) - }) - - it('detaches abort listeners after admission', async () => { - const admission = new NativeChatTranscriptReadAdmission(1, 1, 1) - const controller = new AbortController() - const removeListener = vi.spyOn(controller.signal, 'removeEventListener') - - const release = await admission.acquire(1, controller.signal) - - expect(removeListener).toHaveBeenCalledWith('abort', expect.any(Function)) - controller.abort() - expect(admission.activeCount).toBe(1) - release() - }) -}) diff --git a/src/main/native-chat/transcript-read-admission.ts b/src/main/native-chat/transcript-read-admission.ts deleted file mode 100644 index 54a4a37e3e4..00000000000 --- a/src/main/native-chat/transcript-read-admission.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { INCREMENTAL_DRAIN_RETAINED_BYTE_LIMIT } from './transcript-incremental-reader' -import { MAX_NATIVE_CHAT_TRANSCRIPT_PAGE_RETAINED_BYTES } from './transcript-tail-reader' - -export const MAX_NATIVE_CHAT_TRANSCRIPT_READ_CONCURRENCY = 8 -export const MAX_NATIVE_CHAT_TRANSCRIPT_READ_WAITERS = 256 -export const MAX_NATIVE_CHAT_TRANSCRIPT_PROCESS_RETAINED_BYTES = 128 * 1024 * 1024 -export const NATIVE_CHAT_TRANSCRIPT_PAGE_RESERVATION_BYTES = - MAX_NATIVE_CHAT_TRANSCRIPT_PAGE_RETAINED_BYTES -export const NATIVE_CHAT_TRANSCRIPT_WATCH_DRAIN_RESERVATION_BYTES = - MAX_NATIVE_CHAT_TRANSCRIPT_PAGE_RETAINED_BYTES + INCREMENTAL_DRAIN_RETAINED_BYTE_LIMIT - -type AdmissionWaiter = { - abort: (() => void) | null - bytes: number - reject: (error: Error) => void - resolve: (release: () => void) => void - signal?: AbortSignal -} - -function abortedReadError(): Error { - const error = new Error('Native chat transcript read was canceled') - error.name = 'AbortError' - return error -} - -export class NativeChatTranscriptReadAdmission { - private activeBytes = 0 - private activeReads = 0 - private readonly waiters: AdmissionWaiter[] = [] - - constructor( - private readonly maxBytes = MAX_NATIVE_CHAT_TRANSCRIPT_PROCESS_RETAINED_BYTES, - private readonly maxActive = MAX_NATIVE_CHAT_TRANSCRIPT_READ_CONCURRENCY, - private readonly maxWaiters = MAX_NATIVE_CHAT_TRANSCRIPT_READ_WAITERS - ) { - if ( - !Number.isSafeInteger(maxBytes) || - maxBytes < 0 || - !Number.isSafeInteger(maxActive) || - maxActive < 1 || - !Number.isSafeInteger(maxWaiters) || - maxWaiters < 0 - ) { - throw new RangeError('Invalid native chat transcript read admission limits') - } - } - - acquire(bytes: number, signal?: AbortSignal): Promise<() => void> { - if (!Number.isSafeInteger(bytes) || bytes < 0 || bytes > this.maxBytes) { - throw new RangeError('Native chat transcript read exceeds the process memory budget') - } - if (signal?.aborted) { - return Promise.reject(abortedReadError()) - } - const canAdmitImmediately = - this.waiters.length === 0 && - this.activeReads < this.maxActive && - bytes <= this.maxBytes - this.activeBytes - if (!canAdmitImmediately && this.waiters.length >= this.maxWaiters) { - throw new Error('Too many queued native chat transcript reads') - } - - return new Promise((resolve, reject) => { - const waiter: AdmissionWaiter = { - abort: null, - bytes, - reject, - resolve, - ...(signal ? { signal } : {}) - } - if (signal) { - waiter.abort = (): void => { - const index = this.waiters.indexOf(waiter) - if (index < 0) { - return - } - this.waiters.splice(index, 1) - signal.removeEventListener('abort', waiter.abort!) - waiter.abort = null - reject(abortedReadError()) - this.admitWaiters() - } - signal.addEventListener('abort', waiter.abort, { once: true }) - } - this.waiters.push(waiter) - this.admitWaiters() - }) - } - - get activeCount(): number { - return this.activeReads - } - - get queuedCount(): number { - return this.waiters.length - } - - get retainedBytes(): number { - return this.activeBytes - } - - private admitWaiters(): void { - while (this.waiters.length > 0) { - const waiter = this.waiters[0] - if (this.activeReads >= this.maxActive || waiter.bytes > this.maxBytes - this.activeBytes) { - return - } - this.waiters.shift() - if (waiter.signal && waiter.abort) { - waiter.signal.removeEventListener('abort', waiter.abort) - waiter.abort = null - } - this.activeReads += 1 - this.activeBytes += waiter.bytes - let released = false - waiter.resolve(() => { - if (released) { - return - } - released = true - this.activeReads -= 1 - this.activeBytes -= waiter.bytes - this.admitWaiters() - }) - } - } -} - -export const nativeChatTranscriptReadAdmission = new NativeChatTranscriptReadAdmission() - -export async function withNativeChatTranscriptWatchDrainAdmission( - signal: AbortSignal, - run: () => Promise -): Promise { - const release = await nativeChatTranscriptReadAdmission.acquire( - NATIVE_CHAT_TRANSCRIPT_WATCH_DRAIN_RESERVATION_BYTES, - signal - ) - try { - return await run() - } finally { - release() - } -} diff --git a/src/main/native-chat/transcript-read-cache.ts b/src/main/native-chat/transcript-read-cache.ts index 9a828f0d291..8943c223dd2 100644 --- a/src/main/native-chat/transcript-read-cache.ts +++ b/src/main/native-chat/transcript-read-cache.ts @@ -13,8 +13,9 @@ import { readNativeChatTranscript, type ReadTranscriptResult } from './transcrip // share one sessionId yet resolve to DIFFERENT files (the same session resumed // into a second worktree, which writes a new transcript file), and a // sessionId-only key let one worktree's cached parse be served to another when -// their file mtimes momentarily coincided (#7326). The cache stores one -// canonical bounded parse; surface-specific windowing stays in callers. +// their file mtimes momentarily coincided (#7326). The cache stores ONE +// canonical, unwindowed parse; windowing and per-surface truncation stay in the +// callers so the same parse is reused across all `limit` values and every client kind. type CachedTranscript = { result: ReadTranscriptResult @@ -33,7 +34,7 @@ const cache = new Map() const MAX_CACHE_ENTRIES = 50 // Why: a heavy Claude/Codex coding session's JSONL is routinely tens of MB (tool // results embed whole file contents, command output, and diffs), and each cached -// entry is a large bounded parse. The count cap alone let 50 such entries +// entry is the full unwindowed parse. The count cap alone let 50 such entries // retain multiple GB in the one process that now serves desktop + every paired // web/mobile client. Bound total cached file bytes too; we always keep the most- // recent entry (see setCached) so an active transcript is never re-parsed on @@ -78,7 +79,9 @@ async function fileStat(filePath: string): Promise<{ mtimeMs: number; bytes: num } /** - * Read the canonical bounded transcript for a session, refreshing on file changes. + * Read the full transcript for an agent + session, returning the cached parse on + * an mtime hit and re-reading (and re-caching) when the file changed. Returns the + * canonical, unwindowed result; callers apply their own windowing/truncation. */ export async function readNativeChatTranscriptCached( agent: AgentType, diff --git a/src/main/native-chat/transcript-reader.test.ts b/src/main/native-chat/transcript-reader.test.ts index 06c1c3ec6d1..ff48548804d 100644 --- a/src/main/native-chat/transcript-reader.test.ts +++ b/src/main/native-chat/transcript-reader.test.ts @@ -2,10 +2,8 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import type { NativeChatMessage } from '../../shared/native-chat-types' import { readNativeChatTranscript } from './transcript-reader' import { - MAX_NATIVE_CHAT_TRANSCRIPT_PAGE_RETAINED_BYTES, nativeChatLineDecoderForAgent, readNativeChatTranscriptTail, readNativeChatTranscriptTailFile @@ -334,41 +332,4 @@ describe('readNativeChatTranscriptTailFile', () => { expect(result.messages).toEqual([]) expect(result.hasMore).toBe(false) }) - - it('preserves the newest messages and a correct pagination boundary at the page byte cap', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-tail-page-bytes-')) - tempRoots.push(root) - const filePath = join(root, 'transcript.jsonl') - const payload = 'x'.repeat(1024 * 1024) - const ids = Array.from({ length: 10 }, (_unused, index) => `message-${index}`) - await writeFile(filePath, ids.map((id) => `${id}:${payload}\n`).join('')) - const decode = (line: string): NativeChatMessage => { - const id = line.slice(0, line.indexOf(':')) - return { - id, - role: 'user', - blocks: [{ type: 'text', text: line }], - timestamp: null, - source: 'transcript' - } - } - - const newest = await readNativeChatTranscriptTailFile(filePath, 100, decode, true) - const older = await readNativeChatTranscriptTailFile( - filePath, - 100, - decode, - true, - newest.beforeOffset - ) - - expect(newest.hasMore).toBe(true) - expect(newest.messages.length).toBeGreaterThan(0) - expect(newest.messages.length).toBeLessThan(ids.length) - expect(newest.messages.at(-1)?.id).toBe(ids.at(-1)) - expect([...older.messages, ...newest.messages].map((message) => message.id)).toEqual(ids) - expect(newest.messages.length * (payload.length * 2 + 256)).toBeLessThanOrEqual( - MAX_NATIVE_CHAT_TRANSCRIPT_PAGE_RETAINED_BYTES - ) - }) }) diff --git a/src/main/native-chat/transcript-reader.ts b/src/main/native-chat/transcript-reader.ts index 55542e3a1d6..86353604e20 100644 --- a/src/main/native-chat/transcript-reader.ts +++ b/src/main/native-chat/transcript-reader.ts @@ -28,7 +28,13 @@ export type ReadTranscriptOptions = ResolveSessionFileOptions & { filePath?: string } -/** Reads the newest bounded transcript window; malformed records are skipped. */ +/** + * Read the ENTIRE Claude/Codex JSONL transcript for an agent + session id into + * the NativeChatMessage model. Unlike the AI-Vault preview scan, this applies + * NO message cap. Unknown record types are skipped rather than throwing, so a + * single malformed/unrecognized line cannot fail the whole read. The per-line + * record-to-message mapping is shared with the live tailer. + */ export async function readNativeChatTranscript( agent: AgentType, sessionId: string, diff --git a/src/main/native-chat/transcript-record-buffer.test.ts b/src/main/native-chat/transcript-record-buffer.test.ts deleted file mode 100644 index 6e5d4c3b735..00000000000 --- a/src/main/native-chat/transcript-record-buffer.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { TranscriptRecordBuffer } from './transcript-record-buffer' - -describe('TranscriptRecordBuffer', () => { - it('preserves UTF-8 across 100,000 one-byte fragments', () => { - const expected = Buffer.from(`${'a'.repeat(99_996)}😀`) - const record = new TranscriptRecordBuffer(expected.byteLength) - - for (const byte of expected) { - record.append(Uint8Array.of(byte)) - } - - expect(record.byteLength).toBe(expected.byteLength) - expect(record.toString()).toBe(expected.toString()) - expect(record.isOversized).toBe(false) - }) - - it('drops retained storage at the byte cap while tracking the full record length', () => { - const record = new TranscriptRecordBuffer(4) - - record.append(Buffer.from('1234')) - record.append(Buffer.from('56789')) - record.append(Buffer.from('abc')) - - expect(record.byteLength).toBe(12) - expect(record.toString()).toBe('') - expect(record.isOversized).toBe(true) - - record.clear() - record.append(Buffer.from('ok')) - expect(record.toString()).toBe('ok') - }) -}) diff --git a/src/main/native-chat/transcript-record-buffer.ts b/src/main/native-chat/transcript-record-buffer.ts deleted file mode 100644 index e5f09bf326a..00000000000 --- a/src/main/native-chat/transcript-record-buffer.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { GrowingByteBuffer } from '../../shared/growing-byte-buffer' - -export class TranscriptRecordBuffer { - private readonly retained = new GrowingByteBuffer() - private observedBytes = 0 - private oversized = false - - constructor(private readonly maxRetainedBytes: number) { - if (!Number.isSafeInteger(maxRetainedBytes) || maxRetainedBytes < 0) { - throw new RangeError('Transcript record limit must be a non-negative safe integer') - } - } - - append(part: Buffer | Uint8Array): void { - this.observedBytes += part.byteLength - if (this.oversized) { - return - } - if (this.observedBytes > this.maxRetainedBytes) { - this.retained.clear() - this.oversized = true - return - } - this.retained.append(part) - } - - clear(): void { - this.retained.clear() - this.observedBytes = 0 - this.oversized = false - } - - toString(): string { - return this.retained.toString('utf8') - } - - get byteLength(): number { - return this.observedBytes - } - - get isOversized(): boolean { - return this.oversized - } -} diff --git a/src/main/native-chat/transcript-stream-lines.test.ts b/src/main/native-chat/transcript-stream-lines.test.ts index ddcb2a4edf3..2bfa6f6f9d2 100644 --- a/src/main/native-chat/transcript-stream-lines.test.ts +++ b/src/main/native-chat/transcript-stream-lines.test.ts @@ -1,8 +1,6 @@ import { Readable } from 'node:stream' import { describe, expect, it } from 'vitest' -import { transcriptFallbackId } from './transcript-fallback-id' import { decodeTranscriptStream } from './transcript-stream-lines' -import { MAX_NATIVE_CHAT_TRANSCRIPT_RECORD_BYTES } from './transcript-tail-reader' const decode = (line: string, id: string) => ({ id, @@ -48,52 +46,4 @@ describe('decodeTranscriptStream', () => { expect(result.messages).toHaveLength(1) expect(result.consumedBytes).toBe(Buffer.byteLength(complete, 'utf8')) }) - - it('preserves multibyte UTF-8 split across one-byte stream chunks', async () => { - const line = `prefix-😀-suffix` - const encoded = Buffer.from(`${line}\n`) - const result = await decodeTranscriptStream( - Readable.from([...encoded].map((byte) => Uint8Array.of(byte))), - '/chat.jsonl', - 0, - decode, - false - ) - - expect(result.messages[0]?.blocks).toEqual([{ type: 'text', text: line }]) - expect(result.consumedBytes).toBe(encoded.byteLength) - }) - - it('drops an oversized record without retaining it or losing the next line', async () => { - const oversized = 'x'.repeat(MAX_NATIVE_CHAT_TRANSCRIPT_RECORD_BYTES + 1) - const valid = '{"valid":true}' - const result = await decodeTranscriptStream( - Readable.from([oversized, `\n${valid}\n`]), - '/chat.jsonl', - 0, - decode, - true - ) - - expect(result.messages).toHaveLength(1) - expect(result.messages[0]?.blocks).toEqual([{ type: 'text', text: valid }]) - expect(result.messages[0]?.id).toBe( - transcriptFallbackId('/chat.jsonl', Buffer.byteLength(`${oversized}\n`, 'utf8')) - ) - expect(result.consumedBytes).toBe(Buffer.byteLength(`${oversized}\n${valid}\n`, 'utf8')) - }) - - it('consumes an oversized trailing record when trailing lines are included', async () => { - const oversized = 'x'.repeat(MAX_NATIVE_CHAT_TRANSCRIPT_RECORD_BYTES + 1) - const result = await decodeTranscriptStream( - Readable.from([oversized]), - '/chat.jsonl', - 0, - decode, - true - ) - - expect(result.messages).toEqual([]) - expect(result.consumedBytes).toBe(Buffer.byteLength(oversized, 'utf8')) - }) }) diff --git a/src/main/native-chat/transcript-stream-lines.ts b/src/main/native-chat/transcript-stream-lines.ts index 50339043dc5..a764941ad30 100644 --- a/src/main/native-chat/transcript-stream-lines.ts +++ b/src/main/native-chat/transcript-stream-lines.ts @@ -1,9 +1,6 @@ import type { Readable } from 'node:stream' import type { NativeChatMessage } from '../../shared/native-chat-types' import { transcriptFallbackId } from './transcript-fallback-id' -import { TranscriptRecordBuffer } from './transcript-record-buffer' -import { MAX_NATIVE_CHAT_TRANSCRIPT_RECORD_BYTES } from './transcript-tail-reader' -import { TranscriptMessageRetention } from './transcript-message-retention' type TranscriptDecoder = (line: string, fallbackId: string) => NativeChatMessage | null @@ -14,42 +11,28 @@ export async function decodeTranscriptStream( decode: TranscriptDecoder, includeTrailingLine: boolean ): Promise<{ messages: NativeChatMessage[]; consumedBytes: number }> { - const messages = new TranscriptMessageRetention() - const pending = new TranscriptRecordBuffer(MAX_NATIVE_CHAT_TRANSCRIPT_RECORD_BYTES) + const messages: NativeChatMessage[] = [] + let pending = '' let consumedBytes = 0 for await (const chunk of stream) { - const bytes = - typeof chunk === 'string' - ? Buffer.from(chunk, 'utf8') - : Buffer.isBuffer(chunk) - ? chunk - : Buffer.from(chunk) - let segmentStart = 0 - let newlineIndex = bytes.indexOf(0x0a) + pending += typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8') + let newlineIndex = pending.indexOf('\n') while (newlineIndex !== -1) { - pending.append(bytes.subarray(segmentStart, newlineIndex)) - if (!pending.isOversized) { - decodeLine(pending.toString(), consumedBytes) - } - consumedBytes += pending.byteLength + 1 - pending.clear() - segmentStart = newlineIndex + 1 - newlineIndex = bytes.indexOf(0x0a, segmentStart) - } - if (segmentStart < bytes.length) { - pending.append(bytes.subarray(segmentStart)) + const segment = pending.slice(0, newlineIndex + 1) + decodeLine(segment.slice(0, -1), consumedBytes) + consumedBytes += Buffer.byteLength(segment, 'utf8') + pending = pending.slice(newlineIndex + 1) + newlineIndex = pending.indexOf('\n') } } - if (includeTrailingLine && pending.byteLength > 0) { - if (!pending.isOversized) { - decodeLine(pending.toString(), consumedBytes) - } - consumedBytes += pending.byteLength + if (includeTrailingLine && pending.length > 0) { + decodeLine(pending, consumedBytes) + consumedBytes += Buffer.byteLength(pending, 'utf8') } - return { messages: messages.values(), consumedBytes } + return { messages, consumedBytes } function decodeLine(rawLine: string, relativeOffset: number): void { const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine @@ -58,7 +41,7 @@ export async function decodeTranscriptStream( } const message = decode(line, transcriptFallbackId(filePath, start + relativeOffset)) if (message) { - messages.add(message, Buffer.byteLength(line, 'utf8')) + messages.push(message) } } } diff --git a/src/main/native-chat/transcript-tail-reader.ts b/src/main/native-chat/transcript-tail-reader.ts index f852b881b16..2f874e95746 100644 --- a/src/main/native-chat/transcript-tail-reader.ts +++ b/src/main/native-chat/transcript-tail-reader.ts @@ -16,10 +16,8 @@ import { nativeChatTurnLifecycleDecoderForAgent, type NativeChatTurnLifecycleDecoder } from './transcript-turn-lifecycle' -import { estimateTranscriptMessageRetainedBytes } from './transcript-message-retention' export const MAX_NATIVE_CHAT_TRANSCRIPT_RECORD_BYTES = 2 * 1024 * 1024 -export const MAX_NATIVE_CHAT_TRANSCRIPT_PAGE_RETAINED_BYTES = 16 * 1024 * 1024 const TAIL_CHUNK_BYTES = 64 * 1024 export type NativeChatLineDecoder = (line: string, fallbackId: string) => NativeChatMessage | null @@ -61,8 +59,6 @@ export async function readNativeChatTranscriptTailFile( let lineBytes = 0 let lineOversized = false let lifecycle: NativeChatTurnLifecycle | undefined - let retainedMessageBytes = 0 - let pageBudgetReached = false try { const consumedTo = includeTrailingLine ? end : await findLastCompleteLineEnd(handle, end) if (consumedTo === 0) { @@ -72,16 +68,12 @@ export async function readNativeChatTranscriptTailFile( const finalByte = Buffer.allocUnsafe(1) await handle.read(finalByte, 0, 1, consumedTo - 1) let cursor = consumedTo - (finalByte[0] === 0x0a ? 1 : 0) - while (cursor > 0 && newestFirst.length <= limit && !pageBudgetReached) { + while (cursor > 0 && newestFirst.length <= limit) { const start = Math.max(0, cursor - TAIL_CHUNK_BYTES) const buffer = Buffer.allocUnsafe(cursor - start) const { bytesRead } = await handle.read(buffer, 0, buffer.length, start) let segmentEnd = bytesRead - for ( - let index = bytesRead - 1; - index >= 0 && newestFirst.length <= limit && !pageBudgetReached; - index-- - ) { + for (let index = bytesRead - 1; index >= 0 && newestFirst.length <= limit; index--) { if (buffer[index] !== 0x0a) { continue } @@ -97,7 +89,7 @@ export async function readNativeChatTranscriptTailFile( } cursor = start } - if (cursor === 0 && lineParts.length > 0 && newestFirst.length <= limit && !pageBudgetReached) { + if (cursor === 0 && lineParts.length > 0 && newestFirst.length <= limit) { decodeLine(0, newestFirst) } const chronological = newestFirst.toReversed() @@ -108,7 +100,7 @@ export async function readNativeChatTranscriptTailFile( messages: selected.map((entry) => entry.message), ...(lifecycle ? { lifecycle } : {}), consumedTo, - hasMore: limit > 0 && (chronological.length > limit || pageBudgetReached), + hasMore: limit > 0 && chronological.length > limit, beforeOffset: selected[0]?.offset ?? end } } finally { @@ -152,12 +144,6 @@ export async function readNativeChatTranscriptTailFile( lifecycle ??= decodeLifecycle?.(line, fallbackId) ?? undefined const message = decode(line, fallbackId) if (message) { - const estimatedBytes = estimateTranscriptMessageRetainedBytes(lineBytes) - if (estimatedBytes > MAX_NATIVE_CHAT_TRANSCRIPT_PAGE_RETAINED_BYTES - retainedMessageBytes) { - pageBudgetReached = true - return - } - retainedMessageBytes += estimatedBytes messages.push({ message, offset: lineOffset }) } } diff --git a/src/main/native-chat/transcript-watch-drain-admission.test.ts b/src/main/native-chat/transcript-watch-drain-admission.test.ts deleted file mode 100644 index 87a0a19d753..00000000000 --- a/src/main/native-chat/transcript-watch-drain-admission.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { mkdtemp, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' -import type * as TranscriptTailReader from './transcript-tail-reader' - -const { tailRead } = vi.hoisted(() => ({ tailRead: vi.fn() })) - -vi.mock('./transcript-tail-reader', async () => { - const actual = await vi.importActual('./transcript-tail-reader') - return { ...actual, readNativeChatTranscriptTailFile: tailRead } -}) - -import { nativeChatTranscriptReadAdmission } from './transcript-read-admission' -import { getActiveNativeChatWatcherCount, subscribeNativeChatTranscript } from './transcript-watch' -import type { NativeChatTranscriptSubscription } from './transcript-watch-contract' - -let root: string | null = null -const subscriptions: NativeChatTranscriptSubscription[] = [] - -afterEach(async () => { - for (const subscription of subscriptions.splice(0)) { - subscription.unsubscribe() - } - await vi.waitFor(() => expect(nativeChatTranscriptReadAdmission.activeCount).toBe(0)) - if (root) { - await rm(root, { recursive: true, force: true }) - root = null - } -}) - -describe('native chat transcript watcher drain admission', () => { - it('removes a queued drain immediately when its watcher unsubscribes', async () => { - root = await mkdtemp(join(tmpdir(), 'orca-native-chat-drain-admission-')) - const filePath = join(root, 'transcript.jsonl') - await writeFile(filePath, '{}\n') - let finishReads!: () => void - const readGate = new Promise<{ - messages: never[] - consumedTo: number - hasMore: boolean - beforeOffset: number - }>((resolve) => { - finishReads = () => resolve({ messages: [], consumedTo: 3, hasMore: false, beforeOffset: 0 }) - }) - tailRead.mockReturnValue(readGate) - const activeBefore = getActiveNativeChatWatcherCount() - - for (let index = 0; index < 3; index++) { - subscriptions.push( - await subscribeNativeChatTranscript({ - agent: 'claude', - sessionId: `session-${index}`, - filePath, - initialLimit: 40, - onInitialSnapshot: () => {}, - onAppend: () => {}, - debounceMs: 0, - reconciliationIntervalMs: 10_000 - }) - ) - } - await vi.waitFor(() => { - expect(nativeChatTranscriptReadAdmission.activeCount).toBe(2) - expect(nativeChatTranscriptReadAdmission.queuedCount).toBe(1) - }) - - subscriptions.pop()?.unsubscribe() - - await vi.waitFor(() => expect(nativeChatTranscriptReadAdmission.queuedCount).toBe(0)) - expect(getActiveNativeChatWatcherCount()).toBe(activeBefore + 2) - - finishReads() - await vi.waitFor(() => expect(nativeChatTranscriptReadAdmission.activeCount).toBe(0)) - }) -}) diff --git a/src/main/native-chat/transcript-watch-engine.ts b/src/main/native-chat/transcript-watch-engine.ts index 59825e6a727..ff1df71ee0f 100644 --- a/src/main/native-chat/transcript-watch-engine.ts +++ b/src/main/native-chat/transcript-watch-engine.ts @@ -6,12 +6,11 @@ import { type TranscriptFileVersion } from './transcript-file-version' import { - createIncrementalTranscriptState, readIncrementalTranscriptMessages, - resetIncrementalTranscriptState + resetIncrementalTranscriptState, + type IncrementalTranscriptState } from './transcript-incremental-reader' import { createTranscriptNativeWatcher } from './transcript-native-watcher' -import { withNativeChatTranscriptWatchDrainAdmission } from './transcript-read-admission' import { readNativeChatTranscriptTailFile } from './transcript-tail-reader' import { nativeChatTurnLifecycleDecoderForAgent } from './transcript-turn-lifecycle' import type { @@ -62,7 +61,13 @@ export async function installTranscriptWatcher( const { onAppend, onInitialSnapshot, onReplace, initialLimit } = args const decodeLifecycle = nativeChatTurnLifecycleDecoderForAgent(args.agent) - const state = createIncrementalTranscriptState() + const state: IncrementalTranscriptState = { + offset: 0, + pendingChunks: [], + pendingStart: 0, + pendingBytes: 0, + droppingOversizedRecord: false + } let watchedVersion: TranscriptFileVersion | null = null let watchedBoundary = '' let initialDrain = true @@ -73,7 +78,6 @@ export async function installTranscriptWatcher( let reading = false let pendingReadRequested = false let rotationRetryCount = 0 - const drainController = new AbortController() function scheduleRotationRetry(): void { if (closed) { @@ -130,7 +134,7 @@ export async function installTranscriptWatcher( scheduleRotationRetry() } - async function drainOnceAdmitted(): Promise { + async function drainOnce(): Promise { const current = await readTranscriptFileVersion(filePath) const currentBoundary = await boundaryFingerprint(filePath, state.offset) if (closed) { @@ -235,14 +239,6 @@ export async function installTranscriptWatcher( await finishSuccessfulDrain(current) } - async function drainOnce(): Promise { - await withNativeChatTranscriptWatchDrainAdmission(drainController.signal, async () => { - if (!closed) { - await drainOnceAdmitted() - } - }) - } - async function drain(): Promise { if (closed) { return @@ -321,7 +317,6 @@ export async function installTranscriptWatcher( return } closed = true - drainController.abort() scheduler.dispose() nativeWatcher.dispose() activeWatcherCount-- diff --git a/src/main/network/macos-system-resolver-health.test.ts b/src/main/network/macos-system-resolver-health.test.ts index 62f706a980d..804847e6bbf 100644 --- a/src/main/network/macos-system-resolver-health.test.ts +++ b/src/main/network/macos-system-resolver-health.test.ts @@ -4,7 +4,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { spawn } from 'node:child_process' import { classifyMacSystemResolverHealth, - MAC_RESOLVER_OUTPUT_MAX_BYTES, readCurrentProcessMacSystemResolverHealth } from './macos-system-resolver-health' import type { ChildProcessWithoutNullStreams } from 'node:child_process' @@ -111,44 +110,6 @@ resolver #1 expect(child.kill).toHaveBeenCalledTimes(1) }) - it('kills scutil and fails open before decoding oversized output', async () => { - mockPlatform('darwin') - const child = createMockScutilProcess() - vi.mocked(spawn).mockReturnValue(child) - - const healthPromise = readCurrentProcessMacSystemResolverHealth() - child.stdout.emit('data', 'x'.repeat(MAC_RESOLVER_OUTPUT_MAX_BYTES + 1)) - - await expect(healthPromise).resolves.toBe('unknown') - expect(child.kill).toHaveBeenCalledTimes(1) - expect(child.stdout.listenerCount('data')).toBe(0) - expect(child.stderr.listenerCount('data')).toBe(0) - }) - - it('accepts the exact combined output cap across many tiny chunks', async () => { - mockPlatform('darwin') - const child = createMockScutilProcess() - vi.mocked(spawn).mockReturnValue(child) - - const healthPromise = readCurrentProcessMacSystemResolverHealth() - const resolverOutput = Buffer.from( - 'DNS configuration\nresolver #1\n nameserver[0] : 1.1.1.1\n' - ) - child.stdout.emit('data', resolverOutput) - const tinyChunk = Buffer.alloc(16, 0x20) - let remainingBytes = MAC_RESOLVER_OUTPUT_MAX_BYTES - resolverOutput.byteLength - while (remainingBytes > 0) { - const chunk = - remainingBytes >= tinyChunk.byteLength ? tinyChunk : tinyChunk.subarray(0, remainingBytes) - child.stderr.emit('data', chunk) - remainingBytes -= chunk.byteLength - } - child.emit('close', 0) - - await expect(healthPromise).resolves.toBe('healthy') - expect(child.kill).not.toHaveBeenCalled() - }) - it('removes scutil listeners when the timeout settles before child close', async () => { vi.useFakeTimers() mockPlatform('darwin') diff --git a/src/main/network/macos-system-resolver-health.ts b/src/main/network/macos-system-resolver-health.ts index 43699c6494b..9a3dd6f1eb6 100644 --- a/src/main/network/macos-system-resolver-health.ts +++ b/src/main/network/macos-system-resolver-health.ts @@ -1,9 +1,7 @@ import { spawn } from 'node:child_process' -import { GrowingByteBuffer } from '../../shared/growing-byte-buffer' import type { SystemResolverHealth } from '../daemon/types' const MAC_RESOLVER_CHECK_TIMEOUT_MS = 1_500 -export const MAC_RESOLVER_OUTPUT_MAX_BYTES = 1024 * 1024 const MAC_NO_DNS_CONFIGURATION_RE = /\bNo DNS configuration available\b/i const MAC_DNS_CONFIGURATION_RE = /^DNS configuration\b/m const MAC_NAMESERVER_RE = /nameserver\[\d+\]\s*:/m @@ -26,33 +24,18 @@ export async function readCurrentProcessMacSystemResolverHealth( } return new Promise((resolve) => { - const stdout = new GrowingByteBuffer() - const stderr = new GrowingByteBuffer() - let outputBytes = 0 - let outputLimitExceeded = false + let stdout = '' + let stderr = '' let settled = false let timer: ReturnType | null = null const child = spawn('/usr/sbin/scutil', ['--dns'], { stdio: ['ignore', 'pipe', 'pipe'] }) - const retainOutput = (target: GrowingByteBuffer, chunk: Buffer | string): void => { - const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, 'utf8') - if (bytes.byteLength > MAC_RESOLVER_OUTPUT_MAX_BYTES - outputBytes) { - outputLimitExceeded = true - stdout.clear() - stderr.clear() - child.kill() - finish() - return - } - target.append(bytes) - outputBytes += bytes.byteLength + const onStdoutData = (chunk: string): void => { + stdout += chunk } - const onStdoutData = (chunk: Buffer | string): void => { - retainOutput(stdout, chunk) - } - const onStderrData = (chunk: Buffer | string): void => { - retainOutput(stderr, chunk) + const onStderrData = (chunk: string): void => { + stderr += chunk } const onAbort = (): void => { child.kill('SIGKILL') @@ -72,11 +55,7 @@ export async function readCurrentProcessMacSystemResolverHealth( child.off('error', finish) child.off('close', finish) signal?.removeEventListener('abort', onAbort) - resolve( - outputLimitExceeded - ? 'unknown' - : classifyMacSystemResolverHealth(`${stdout.takeString()}\n${stderr.takeString()}`) - ) + resolve(classifyMacSystemResolverHealth(`${stdout}\n${stderr}`)) } timer = setTimeout(() => { child.kill() @@ -84,6 +63,8 @@ export async function readCurrentProcessMacSystemResolverHealth( // cap the RPC even if scutil is slow to exit after SIGTERM. finish() }, MAC_RESOLVER_CHECK_TIMEOUT_MS) + child.stdout.setEncoding('utf8') + child.stderr.setEncoding('utf8') child.stdout.on('data', onStdoutData) child.stderr.on('data', onStderrData) child.on('error', finish) diff --git a/src/main/observability/bundle.test.ts b/src/main/observability/bundle.test.ts index 0ba1c9ef342..347e1025893 100644 --- a/src/main/observability/bundle.test.ts +++ b/src/main/observability/bundle.test.ts @@ -1,7 +1,7 @@ // Bundle collection + upload tests. Upload helpers live outside bundle.ts, but // this suite keeps the diagnostic bundle contract in one place. -import { mkdtempSync, rmSync, truncateSync, writeFileSync } from 'node:fs' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { createServer, type RequestListener, type Server } from 'node:http' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -344,23 +344,6 @@ describe('bundle — collection', () => { ).not.toThrow() }) - it('skips a sparse source file above the bounded read limit', () => { - writeFileSync(traceFile, '') - truncateSync(traceFile, 50 * 1024 * 1024 + 1) - - const bundle = collectBundle({ - traceFilePath: traceFile, - maxFiles: 10, - appVersion: '1', - platform: 'darwin', - arch: 'arm64', - osRelease: '24', - orcaChannel: 'dev' - }) - - expect(bundle.spanCount).toBe(0) - }) - it('skips valid JSON lines that are not span objects without throwing', () => { writeFileSync( traceFile, diff --git a/src/main/observability/bundle.ts b/src/main/observability/bundle.ts index 12328eea5f4..750f41fead9 100644 --- a/src/main/observability/bundle.ts +++ b/src/main/observability/bundle.ts @@ -7,13 +7,12 @@ // upload body-size cap, token-handling discipline). import { randomBytes } from 'node:crypto' -import { readNodeFileSyncWithinLimit } from '../../shared/node-bounded-file-reader' +import { readFileSync, statSync } from 'node:fs' import { MAX_BUNDLE_BYTES } from './diagnostic-bundle-limits' import { listRotatedFiles } from './local-file-sink' import { redactValue } from './redactor' const DEFAULT_LOOKBACK_MINUTES = 30 -const MAX_BUNDLE_SOURCE_FILE_BYTES = 50 * 1024 * 1024 export type CollectBundleOptions = { readonly traceFilePath: string @@ -104,7 +103,12 @@ export function collectBundle(opts: CollectBundleOptions): CollectedBundle { outer: for (const file of files) { let text: string try { - text = readNodeFileSyncWithinLimit(file, MAX_BUNDLE_SOURCE_FILE_BYTES).buffer.toString('utf8') + // stat first: the sink caps at 10 MB/file, so a tampered oversize file could panic-allocate on read. + const size = statSync(file).size + if (size > 50 * 1024 * 1024) { + continue + } + text = readFileSync(file, 'utf8') } catch { continue } diff --git a/src/main/observability/diagnostic-upload-http.ts b/src/main/observability/diagnostic-upload-http.ts index bb5823d28b4..17e6b186085 100644 --- a/src/main/observability/diagnostic-upload-http.ts +++ b/src/main/observability/diagnostic-upload-http.ts @@ -1,7 +1,6 @@ import { request as httpRequest, type ClientRequest, type IncomingMessage } from 'node:http' import { request as httpsRequest } from 'node:https' import { URL } from 'node:url' -import { GrowingByteBuffer } from '../../shared/growing-byte-buffer' export const MAX_RESPONSE_BYTES = 1024 * 1024 @@ -41,7 +40,7 @@ function postRaw( let settled = false let req: ClientRequest | null = null let res: IncomingMessage | null = null - const responseBody = new GrowingByteBuffer() + const chunks: Buffer[] = [] let responseBytes = 0 function cleanupListeners(): void { req?.off('error', onRequestError) @@ -55,7 +54,6 @@ function postRaw( return } settled = true - responseBody.clear() cleanupListeners() resolve(value) } @@ -67,7 +65,6 @@ function postRaw( return } settled = true - responseBody.clear() if (options.destroyRequest) { req?.destroy() } @@ -88,11 +85,11 @@ function postRaw( }) return } - responseBody.append(chunk) + chunks.push(chunk) } function onResponseEnd(): void { const status = res?.statusCode ?? 0 - const text = responseBody.takeString() + const text = Buffer.concat(chunks).toString('utf8') if (status >= 200 && status < 300) { try { resolveOnce(text.length > 0 ? JSON.parse(text) : {}) diff --git a/src/main/observability/local-file-sink.test.ts b/src/main/observability/local-file-sink.test.ts index 2f9d2bd5f28..4420c043288 100644 --- a/src/main/observability/local-file-sink.test.ts +++ b/src/main/observability/local-file-sink.test.ts @@ -12,12 +12,7 @@ import { import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { - createLocalFileSink, - DEFAULT_FLUSH_BUFFER_BYTE_THRESHOLD, - getRotatedFamilySize, - listRotatedFiles -} from './local-file-sink' +import { createLocalFileSink, getRotatedFamilySize, listRotatedFiles } from './local-file-sink' let dir: string @@ -72,50 +67,6 @@ describe('local-file-sink — basic write', () => { sink.close() }) - it('flushes at the default byte threshold before the count threshold', () => { - const file = join(dir, 'test.ndjson') - const emptyLineBytes = Buffer.byteLength(`${JSON.stringify({ payload: '' })}\n`, 'utf8') - const record = { - payload: 'x'.repeat(DEFAULT_FLUSH_BUFFER_BYTE_THRESHOLD - emptyLineBytes) - } - const sink = createLocalFileSink({ - filePath: file, - batchWindowMs: 100_000, - flushBufferThreshold: 10_000 - }) - - sink.push(record) - - expect(statSync(file).size).toBe(DEFAULT_FLUSH_BUFFER_BYTE_THRESHOLD) - sink.close() - }) - - it('flushes a prior byte batch without reordering the next record', () => { - const file = join(dir, 'test.ndjson') - const first = { i: 1, payload: '😀'.repeat(8) } - const second = { i: 2, payload: '😀'.repeat(8) } - const firstLineBytes = Buffer.byteLength(`${JSON.stringify(first)}\n`, 'utf8') - const secondLineBytes = Buffer.byteLength(`${JSON.stringify(second)}\n`, 'utf8') - const sink = createLocalFileSink({ - filePath: file, - batchWindowMs: 100_000, - flushBufferThreshold: 100, - flushBufferByteThreshold: firstLineBytes + secondLineBytes - 1 - }) - - sink.push(first) - expect(statSync(file).size).toBe(0) - sink.push(second) - expect(statSync(file).size).toBe(firstLineBytes) - sink.close() - - const records = readFileSync(file, 'utf8') - .split('\n') - .filter(Boolean) - .map((line) => JSON.parse(line) as { i: number }) - expect(records.map((record) => record.i)).toEqual([1, 2]) - }) - it('creates trace directories and files with private POSIX permissions', () => { if (process.platform === 'win32') { return @@ -218,31 +169,6 @@ describe('local-file-sink — rotation', () => { expect(getRotatedFamilySize(file, 3)).toBeLessThanOrEqual(100) }) - it('stops serializing an oversized record before visiting the whole value', () => { - const file = join(dir, 'test.ndjson') - const sink = createLocalFileSink({ - filePath: file, - maxBytes: 100, - batchWindowMs: 100_000, - flushBufferThreshold: 1 - }) - let visits = 0 - const payload = Array.from({ length: 10_000 }, () => ({ - toJSON: () => { - visits += 1 - return 'value' - } - })) - - sink.push({ payload }) - sink.push({ ok: true }) - sink.close() - - expect(visits).toBeLessThan(payload.length) - const lines = readFileSync(file, 'utf8').split('\n').filter(Boolean) - expect(lines.map((line) => JSON.parse(line))).toEqual([{ ok: true }]) - }) - it('splits an oversized buffered batch instead of dropping valid records', () => { const file = join(dir, 'test.ndjson') const sink = createLocalFileSink({ diff --git a/src/main/observability/local-file-sink.ts b/src/main/observability/local-file-sink.ts index 853b016ada3..b2a74bbbb40 100644 --- a/src/main/observability/local-file-sink.ts +++ b/src/main/observability/local-file-sink.ts @@ -18,10 +18,8 @@ import { writeSync } from 'node:fs' import { dirname } from 'node:path' -import { stringifyJsonWithinByteLimit } from '../../shared/node-bounded-json-stringify' const DEFAULT_FLUSH_BUFFER_THRESHOLD = 32 -export const DEFAULT_FLUSH_BUFFER_BYTE_THRESHOLD = 1024 * 1024 export const DEFAULT_MAX_BYTES = 10 * 1024 * 1024 // 10 MB export const DEFAULT_MAX_FILES = 10 export const DEFAULT_BATCH_WINDOW_MS = 200 @@ -34,7 +32,6 @@ export type LocalFileSinkOptions = { readonly maxFiles?: number readonly batchWindowMs?: number readonly flushBufferThreshold?: number - readonly flushBufferByteThreshold?: number } export type LocalFileSink = { @@ -69,14 +66,6 @@ export function createLocalFileSink(opts: LocalFileSinkOptions): LocalFileSink { const maxFiles = opts.maxFiles ?? DEFAULT_MAX_FILES const batchWindowMs = opts.batchWindowMs ?? DEFAULT_BATCH_WINDOW_MS const flushThreshold = opts.flushBufferThreshold ?? DEFAULT_FLUSH_BUFFER_THRESHOLD - const requestedFlushByteThreshold = - opts.flushBufferByteThreshold ?? DEFAULT_FLUSH_BUFFER_BYTE_THRESHOLD - const flushByteThreshold = Number.isFinite(requestedFlushByteThreshold) - ? Math.max( - 1, - Math.min(DEFAULT_FLUSH_BUFFER_BYTE_THRESHOLD, Math.trunc(requestedFlushByteThreshold)) - ) - : DEFAULT_FLUSH_BUFFER_BYTE_THRESHOLD // Traces hold paths and crash context; lock to current-user regardless of umask. const traceDirectory = dirname(filePath) @@ -89,7 +78,6 @@ export function createLocalFileSink(opts: LocalFileSinkOptions): LocalFileSink { let currentBytes: number = safeFstatSize(fd) let buffer: string[] = [] - let bufferBytes = 0 let timer: NodeJS.Timeout | null = null let closed = false @@ -147,7 +135,6 @@ export function createLocalFileSink(opts: LocalFileSinkOptions): LocalFileSink { } const lines = buffer buffer = [] - bufferBytes = 0 let pendingChunk: string[] = [] let pendingChunkBytes = 0 @@ -223,21 +210,14 @@ export function createLocalFileSink(opts: LocalFileSinkOptions): LocalFileSink { return } let line: string - let lineBytes: number try { - const serialized = stringifyJsonWithinByteLimit(record, Math.max(0, maxBytes - 1)) - line = `${serialized.serialized}\n` - lineBytes = serialized.byteLength + 1 + line = `${JSON.stringify(record)}\n` } catch { // Redactor handles cycles upstream; a throw here means pre-redact data slipped in — drop rather than crash (best-effort). return } - if (bufferBytes > 0 && bufferBytes + lineBytes > flushByteThreshold) { - flushBuffer() - } buffer.push(line) - bufferBytes += lineBytes - if (buffer.length >= flushThreshold || bufferBytes >= flushByteThreshold) { + if (buffer.length >= flushThreshold) { flushBuffer() } else { ensureTimer() diff --git a/src/main/opencode-usage/scanner.ts b/src/main/opencode-usage/scanner.ts index 67d91e67f77..57a95df3a62 100644 --- a/src/main/opencode-usage/scanner.ts +++ b/src/main/opencode-usage/scanner.ts @@ -1,15 +1,13 @@ /* eslint-disable max-lines -- Why: OpenCode usage analytics need to normalize multiple local DB schema generations, attribute worktrees, and build persisted projections in one auditable pipeline. */ import { existsSync } from 'node:fs' -import { realpath, stat } from 'node:fs/promises' +import { readdir, realpath, stat } from 'node:fs/promises' import { homedir } from 'node:os' import { basename, isAbsolute, join, posix, win32 } from 'node:path' import type { Repo } from '../../shared/types' import { areWorktreePathsEqual } from '../ipc/worktree-logic' import Database from '../sqlite/sync-database' -import { listOpenCodeDatabaseFiles } from '../opencode/opencode-database-files' +import { columnExists, tableExists } from './schema-helpers' import { canonicalizeUsageWorktreePaths } from '../usage-worktree-canonicalizer' -import { getUsageHistoryRetainedBytes, UsageHistoryScanBudget } from '../usage-history-scan-budget' -import { iterateOpenCodeUsageRows, type OpenCodeUsageRow } from './sqlite-usage-row-stream' import type { OpenCodeUsageAttributedEvent, OpenCodeUsageDailyAggregate, @@ -29,6 +27,34 @@ export type OpenCodeUsageWorktreeRef = { displayName: string } +type OpenCodeUsageRow = { + id: string + session_id: string + time_created: number + time_updated: number | null + data: string + directory: string | null + title: string | null + worktree: string | null + session_model: string | null +} + +type OpenCodeSessionUsageRow = { + id: string + session_id: string + time_created: number + time_updated: number | null + directory: string | null + title: string | null + worktree: string | null + session_model: string | null + cost: number + tokens_input: number + tokens_output: number + tokens_reasoning: number + tokens_cache_read: number +} + const YIELD_EVERY_DATABASES = 2 function ensureNumber(value: unknown): number { @@ -85,7 +111,11 @@ export async function listOpenCodeDatabases(): Promise { } try { - return (await listOpenCodeDatabaseFiles(getOpenCodeDataDirectory())).paths + const entries = await readdir(getOpenCodeDataDirectory(), { withFileTypes: true }) + return entries + .filter((entry) => entry.isFile() && /^opencode(?:-[A-Za-z0-9_.-]+)?\.db$/.test(entry.name)) + .map((entry) => join(getOpenCodeDataDirectory(), entry.name)) + .sort() } catch { return [] } @@ -118,6 +148,140 @@ async function yieldToEventLoop(): Promise { await new Promise((resolve) => setTimeout(resolve, 0)) } +function getProjectJoin(db: Database.Database): string { + return tableExists(db, 'project') && columnExists(db, 'session', 'project_id') + ? 'LEFT JOIN project p ON p.id = s.project_id' + : 'LEFT JOIN (SELECT NULL AS id, NULL AS worktree) p ON 1 = 0' +} + +function getSessionModelSelect(db: Database.Database): string { + return columnExists(db, 'session', 'model') ? 's.model AS session_model' : 'NULL AS session_model' +} + +function getAssistantSessionMessageCount(db: Database.Database): number { + if (!tableExists(db, 'session_message')) { + return 0 + } + const assistantPredicate = columnExists(db, 'session_message', 'type') + ? "type = 'assistant' AND json_extract(data, '$.tokens.input') IS NOT NULL" + : "json_extract(data, '$.tokens.input') IS NOT NULL" + const row = db + .prepare(`SELECT COUNT(*) AS count FROM session_message WHERE ${assistantPredicate}`) + .get() as { count?: number } | undefined + return row?.count ?? 0 +} + +function canReadSessionUsageRows(db: Database.Database): boolean { + if (!tableExists(db, 'session')) { + return false + } + return ['cost', 'tokens_input', 'tokens_output', 'tokens_reasoning', 'tokens_cache_read'].every( + (columnName) => columnExists(db, 'session', columnName) + ) +} + +function getSessionUsageRowCount(db: Database.Database): number { + if (!canReadSessionUsageRows(db)) { + return 0 + } + const row = db + .prepare( + `SELECT COUNT(*) AS count + FROM session + WHERE tokens_input + tokens_output + tokens_reasoning + tokens_cache_read > 0` + ) + .get() as { count?: number } | undefined + return row?.count ?? 0 +} + +function selectSessionUsageRows(db: Database.Database): OpenCodeUsageRow[] { + const projectJoin = getProjectJoin(db) + const sessionModelSelect = getSessionModelSelect(db) + const rows = db + .prepare( + `SELECT s.id, s.id AS session_id, s.time_created, s.time_updated, + s.directory, s.title, p.worktree, ${sessionModelSelect}, + s.cost, s.tokens_input, s.tokens_output, s.tokens_reasoning, s.tokens_cache_read + FROM session s + ${projectJoin} + WHERE s.tokens_input + s.tokens_output + s.tokens_reasoning + s.tokens_cache_read > 0 + ORDER BY s.time_created, s.id` + ) + .all() as OpenCodeSessionUsageRow[] + + return rows.map((row) => ({ + id: row.id, + session_id: row.session_id, + time_created: row.time_created, + time_updated: row.time_updated, + directory: row.directory, + title: row.title, + worktree: row.worktree, + session_model: row.session_model, + data: JSON.stringify({ + cost: row.cost, + tokens: { + input: row.tokens_input, + output: row.tokens_output, + reasoning: row.tokens_reasoning, + total: row.tokens_input + row.tokens_output + row.tokens_reasoning, + cache: { + read: row.tokens_cache_read, + write: 0 + } + } + }) + })) +} + +function selectUsageRows(db: Database.Database): OpenCodeUsageRow[] { + if (!tableExists(db, 'session')) { + return [] + } + + // Why: newer OpenCode DBs maintain session-level token/cost totals. Reading + // one aggregate row per session is faster than parsing every message blob. + if (getSessionUsageRowCount(db) > 0) { + return selectSessionUsageRows(db) + } + + const projectJoin = getProjectJoin(db) + const sessionModelSelect = getSessionModelSelect(db) + + if (getAssistantSessionMessageCount(db) > 0) { + const assistantPredicate = columnExists(db, 'session_message', 'type') + ? "sm.type = 'assistant'" + : "json_extract(sm.data, '$.tokens.input') IS NOT NULL" + return db + .prepare( + `SELECT sm.id, sm.session_id, sm.time_created, sm.time_updated, sm.data, + s.directory, s.title, p.worktree, ${sessionModelSelect} + FROM session_message sm + JOIN session s ON s.id = sm.session_id + ${projectJoin} + WHERE ${assistantPredicate} + ORDER BY sm.time_created, sm.id` + ) + .all() as OpenCodeUsageRow[] + } + + if (!tableExists(db, 'message')) { + return [] + } + + return db + .prepare( + `SELECT m.id, m.session_id, m.time_created, m.time_updated, m.data, + s.directory, s.title, p.worktree, ${sessionModelSelect} + FROM message m + JOIN session s ON s.id = m.session_id + ${projectJoin} + WHERE json_extract(m.data, '$.role') = 'assistant' + ORDER BY m.time_created, m.id` + ) + .all() as OpenCodeUsageRow[] +} + function parseJsonObject(value: unknown): Record | null { if (typeof value === 'object' && value !== null && !Array.isArray(value)) { return value as Record @@ -677,71 +841,11 @@ function mergeDailyAggregates( } } -function claimOpenCodeUsageProjection( - budget: UsageHistoryScanBudget, - sessions: readonly OpenCodeUsageSession[], - dailyAggregates: readonly OpenCodeUsageDailyAggregate[] -): void { - for (const session of sessions) { - budget.claimProjection( - getUsageHistoryRetainedBytes([ - session.sessionId, - session.firstTimestamp, - session.lastTimestamp, - session.primaryModel, - session.primaryProjectLabel, - session.primaryWorktreeId, - session.primaryRepoId - ]) - ) - for (const location of session.locationBreakdown) { - budget.claimProjection( - getUsageHistoryRetainedBytes([ - location.locationKey, - location.projectLabel, - location.repoId, - location.worktreeId - ]) - ) - } - for (const model of session.modelBreakdown) { - budget.claimProjection(getUsageHistoryRetainedBytes([model.modelKey, model.modelLabel])) - } - for (const locationModel of session.locationModelBreakdown) { - budget.claimProjection( - getUsageHistoryRetainedBytes([ - locationModel.locationKey, - locationModel.modelKey, - locationModel.modelLabel, - locationModel.repoId, - locationModel.worktreeId - ]) - ) - } - } - for (const daily of dailyAggregates) { - budget.claimProjection( - getUsageHistoryRetainedBytes([ - daily.day, - daily.model, - daily.projectKey, - daily.projectLabel, - daily.repoId, - daily.worktreeId - ]) - ) - } -} - export async function parseOpenCodeUsageDatabase( dbPath: string, worktrees: (OpenCodeUsageWorktreeRef & { canonicalPath: string })[], - options: { - claimSession?: (sessionId: string) => boolean - budget?: UsageHistoryScanBudget - } = {} + options: { claimSession?: (sessionId: string) => boolean } = {} ): Promise { - const budget = options.budget ?? new UsageHistoryScanBudget() const processedDatabase = await getProcessedDatabaseInfo(dbPath) const db = new Database(dbPath, { readonly: true, fileMustExist: true }) try { @@ -749,7 +853,7 @@ export async function parseOpenCodeUsageDatabase( const events: OpenCodeUsageAttributedEvent[] = [] const claimedBySessionId = new Map() let hasDeferredClaims = false - for (const row of iterateOpenCodeUsageRows(db)) { + for (const row of selectUsageRows(db)) { const parsed = parseOpenCodeUsageRow(row) if (!parsed) { continue @@ -758,7 +862,6 @@ export async function parseOpenCodeUsageDatabase( // each session must be counted from exactly one database (#8006). let owned = claimedBySessionId.get(parsed.sessionId) if (owned === undefined) { - budget.claimOwnershipKey(parsed.sessionId) owned = options.claimSession ? options.claimSession(parsed.sessionId) : true claimedBySessionId.set(parsed.sessionId, owned) } @@ -768,27 +871,12 @@ export async function parseOpenCodeUsageDatabase( } const attributed = await attributeOpenCodeUsageEvent(parsed, worktrees) if (attributed) { - budget.claimRecord( - getUsageHistoryRetainedBytes([ - attributed.sessionId, - attributed.timestamp, - attributed.cwd, - attributed.model, - attributed.day, - attributed.projectKey, - attributed.projectLabel, - attributed.repoId, - attributed.worktreeId - ]) - ) events.push(attributed) } } - const aggregates = aggregateOpenCodeUsage(events) - claimOpenCodeUsageProjection(budget, aggregates.sessions, aggregates.dailyAggregates) return { ...processedDatabase, - ...aggregates, + ...aggregateOpenCodeUsage(events), ownedSessionIds: [...claimedBySessionId.entries()] .filter(([, owned]) => owned) .map(([sessionId]) => sessionId), @@ -801,14 +889,12 @@ export async function parseOpenCodeUsageDatabase( export async function scanOpenCodeUsageDatabases( worktrees: OpenCodeUsageWorktreeRef[], - previousProcessedDatabases: OpenCodeUsagePersistedDatabase[], - options: { budget?: UsageHistoryScanBudget } = {} + previousProcessedDatabases: OpenCodeUsagePersistedDatabase[] ): Promise<{ processedDatabases: OpenCodeUsagePersistedDatabase[] sessions: OpenCodeUsageSession[] dailyAggregates: OpenCodeUsageDailyAggregate[] }> { - const budget = options.budget ?? new UsageHistoryScanBudget() const dbPaths = await listOpenCodeDatabases() const previousByPath = new Map( previousProcessedDatabases.map((database) => [database.path, database]) @@ -879,12 +965,7 @@ export async function scanOpenCodeUsageDatabases( const sessionOwnerById = new Map() for (const dbPath of [...reusedByPath.keys()].sort(compareOpenCodeClaimPriority)) { const previous = reusedByPath.get(dbPath) - for (const session of previous?.sessions ?? []) { - budget.claimRecords(session.eventCount) - } - claimOpenCodeUsageProjection(budget, previous?.sessions ?? [], previous?.dailyAggregates ?? []) for (const sessionId of previous?.ownedSessionIds ?? []) { - budget.claimOwnershipKey(sessionId) if (!sessionOwnerById.has(sessionId)) { sessionOwnerById.set(sessionId, dbPath) } @@ -895,7 +976,6 @@ export async function scanOpenCodeUsageDatabases( const orderedPathsToParse = [...pathsToParse].sort(compareOpenCodeClaimPriority) for (const [index, dbPath] of orderedPathsToParse.entries()) { const processed = await parseOpenCodeUsageDatabase(dbPath, worktreesWithCanonicalPaths, { - budget, claimSession: (sessionId) => { const owner = sessionOwnerById.get(sessionId) if (owner !== undefined && owner !== dbPath) { diff --git a/src/main/opencode-usage/sqlite-usage-row-stream.test.ts b/src/main/opencode-usage/sqlite-usage-row-stream.test.ts deleted file mode 100644 index 9244d6728fa..00000000000 --- a/src/main/opencode-usage/sqlite-usage-row-stream.test.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { mkdtempSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import Database from '../sqlite/sync-database' -import { UsageHistoryScanBudget, UsageHistoryScanCapacityError } from '../usage-history-scan-budget' -import { parseOpenCodeUsageDatabase } from './scanner' -import { - iterateOpenCodeUsageRows, - OPENCODE_USAGE_SQLITE_ROW_MAX_BYTES -} from './sqlite-usage-row-stream' - -const tempDirs: string[] = [] - -function createDatabase(): { db: Database.Database; path: string } { - const dir = mkdtempSync(join(tmpdir(), 'orca-opencode-retention-')) - tempDirs.push(dir) - const path = join(dir, 'opencode.db') - const db = new Database(path) - db.exec(` - CREATE TABLE session ( - id TEXT PRIMARY KEY, - directory TEXT, - title TEXT, - cost REAL, - tokens_input INTEGER, - tokens_output INTEGER, - tokens_reasoning INTEGER, - tokens_cache_read INTEGER, - time_created INTEGER, - time_updated INTEGER - ); - `) - return { db, path } -} - -function insertSession(db: Database.Database, id: string, title = '', inputTokens = 1): void { - db.prepare( - `INSERT INTO session ( - id, directory, title, cost, - tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, - time_created, time_updated - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` - ).run(id, null, title, 0, inputTokens, 0, 0, 0, 1_777_777_700_000, 1_777_777_800_000) -} - -afterEach(() => { - for (const dir of tempDirs.splice(0)) { - rmSync(dir, { recursive: true, force: true }) - } -}) - -describe('OpenCode SQLite usage row retention', () => { - it('preserves an exact-boundary row', () => { - const { db } = createDatabase() - const title = 't'.repeat(OPENCODE_USAGE_SQLITE_ROW_MAX_BYTES - 2) - insertSession(db, 's', title) - - const rows = [...iterateOpenCodeUsageRows(db)] - - expect(rows).toHaveLength(1) - expect(rows[0]?.id).toBe('s') - expect(rows[0]?.title).toBe(title) - db.close() - }) - - it('rejects a row one byte above the retained-text limit', () => { - const { db } = createDatabase() - insertSession(db, 's', 't'.repeat(OPENCODE_USAGE_SQLITE_ROW_MAX_BYTES - 1)) - - expect(() => [...iterateOpenCodeUsageRows(db)]).toThrowError( - new UsageHistoryScanCapacityError('retainedBytes', OPENCODE_USAGE_SQLITE_ROW_MAX_BYTES) - ) - db.close() - }) - - it('rejects oversized legacy JSON before asking SQLite to parse it', () => { - const { db } = createDatabase() - insertSession(db, 's', '', 0) - db.exec(` - CREATE TABLE message ( - id TEXT PRIMARY KEY, - session_id TEXT, - time_created INTEGER, - time_updated INTEGER, - data TEXT - ); - `) - const data = `{"role":"assistant","padding":"${'x'.repeat( - OPENCODE_USAGE_SQLITE_ROW_MAX_BYTES - )}"}` - db.prepare( - 'INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?)' - ).run('m', 's', 1, 1, data) - - expect(() => [...iterateOpenCodeUsageRows(db)]).toThrowError( - new UsageHistoryScanCapacityError('retainedBytes', OPENCODE_USAGE_SQLITE_ROW_MAX_BYTES) - ) - db.close() - }) - - it('shares the history record budget across streamed rows', async () => { - const { db, path } = createDatabase() - insertSession(db, 'session-1') - insertSession(db, 'session-2') - db.close() - const budget = new UsageHistoryScanBudget({ - records: 1, - ownershipKeys: 4, - retainedBytes: 16 * 1024 * 1024 - }) - - await expect(parseOpenCodeUsageDatabase(path, [], { budget })).rejects.toMatchObject({ - resource: 'records', - limit: 1 - }) - }) -}) diff --git a/src/main/opencode-usage/sqlite-usage-row-stream.ts b/src/main/opencode-usage/sqlite-usage-row-stream.ts deleted file mode 100644 index 9c7cbca46fd..00000000000 --- a/src/main/opencode-usage/sqlite-usage-row-stream.ts +++ /dev/null @@ -1,238 +0,0 @@ -import type Database from '../sqlite/sync-database' -import { UsageHistoryScanCapacityError } from '../usage-history-scan-budget' -import { columnExists, tableExists } from './schema-helpers' - -export const OPENCODE_USAGE_SQLITE_ROW_MAX_BYTES = 4 * 1024 * 1024 - -export type OpenCodeUsageRow = { - id: string - session_id: string - time_created: number - time_updated: number | null - data: string - directory: string | null - title: string | null - worktree: string | null - session_model: string | null -} - -type OpenCodeSessionUsageRow = Omit & { - cost: number - tokens_input: number - tokens_output: number - tokens_reasoning: number - tokens_cache_read: number -} - -function retainedTextBytesSql(expressions: readonly string[]): string { - return expressions - .map((expression) => `length(CAST(COALESCE(${expression}, '') AS BLOB))`) - .join(' + ') -} - -function assertNoOversizedRows( - db: Database.Database, - fromSql: string, - whereSql: string, - rowBytesSql: string -): void { - const oversized = db - .prepare( - `SELECT 1 - ${fromSql} - WHERE ${whereSql} - AND (${rowBytesSql}) > ${OPENCODE_USAGE_SQLITE_ROW_MAX_BYTES} - LIMIT 1` - ) - .get() - if (oversized) { - throw new UsageHistoryScanCapacityError('retainedBytes', OPENCODE_USAGE_SQLITE_ROW_MAX_BYTES) - } -} - -function assertNoOversizedJsonCandidates( - db: Database.Database, - table: 'message' | 'session_message', - candidateWhereSql: string -): void { - const oversized = db - .prepare( - `SELECT 1 - FROM ${table} - WHERE ${candidateWhereSql} - AND length(CAST(COALESCE(data, '') AS BLOB)) > ${OPENCODE_USAGE_SQLITE_ROW_MAX_BYTES} - LIMIT 1` - ) - .get() - if (oversized) { - throw new UsageHistoryScanCapacityError('retainedBytes', OPENCODE_USAGE_SQLITE_ROW_MAX_BYTES) - } -} - -function boundedJsonPredicate(dataExpression: string, predicate: string): string { - return `CASE - WHEN length(CAST(COALESCE(${dataExpression}, '') AS BLOB)) <= ${OPENCODE_USAGE_SQLITE_ROW_MAX_BYTES} - THEN (${predicate}) - ELSE 0 - END` -} - -function getProjectJoin(db: Database.Database): string { - return tableExists(db, 'project') && columnExists(db, 'session', 'project_id') - ? 'LEFT JOIN project p ON p.id = s.project_id' - : 'LEFT JOIN (SELECT NULL AS id, NULL AS worktree) p ON 1 = 0' -} - -function getSessionModelSelect(db: Database.Database): string { - return columnExists(db, 'session', 'model') ? 's.model AS session_model' : 'NULL AS session_model' -} - -function getAssistantSessionMessageCount(db: Database.Database): number { - if (!tableExists(db, 'session_message')) { - return 0 - } - const hasType = columnExists(db, 'session_message', 'type') - assertNoOversizedJsonCandidates(db, 'session_message', hasType ? "type = 'assistant'" : '1 = 1') - const jsonPredicate = boundedJsonPredicate( - 'data', - "json_extract(data, '$.tokens.input') IS NOT NULL" - ) - const assistantPredicate = hasType ? `type = 'assistant' AND ${jsonPredicate}` : jsonPredicate - const row = db - .prepare(`SELECT COUNT(*) AS count FROM session_message WHERE ${assistantPredicate}`) - .get() as { count?: number } | undefined - return row?.count ?? 0 -} - -function canReadSessionUsageRows(db: Database.Database): boolean { - if (!tableExists(db, 'session')) { - return false - } - return ['cost', 'tokens_input', 'tokens_output', 'tokens_reasoning', 'tokens_cache_read'].every( - (columnName) => columnExists(db, 'session', columnName) - ) -} - -function getSessionUsageRowCount(db: Database.Database): number { - if (!canReadSessionUsageRows(db)) { - return 0 - } - const row = db - .prepare( - `SELECT COUNT(*) AS count - FROM session - WHERE tokens_input + tokens_output + tokens_reasoning + tokens_cache_read > 0` - ) - .get() as { count?: number } | undefined - return row?.count ?? 0 -} - -function* iterateSessionUsageRows(db: Database.Database): Iterable { - const projectJoin = getProjectJoin(db) - const sessionModelSelect = getSessionModelSelect(db) - const fromSql = `FROM session s ${projectJoin}` - const whereSql = 's.tokens_input + s.tokens_output + s.tokens_reasoning + s.tokens_cache_read > 0' - const rowBytesSql = retainedTextBytesSql([ - 's.id', - 's.id', - 's.directory', - 's.title', - 'p.worktree', - columnExists(db, 'session', 'model') ? 's.model' : "''" - ]) - assertNoOversizedRows(db, fromSql, whereSql, rowBytesSql) - - const rows = db - .prepare( - `SELECT s.id, s.id AS session_id, s.time_created, s.time_updated, - s.directory, s.title, p.worktree, ${sessionModelSelect}, - s.cost, s.tokens_input, s.tokens_output, s.tokens_reasoning, s.tokens_cache_read - ${fromSql} - WHERE ${whereSql} - AND (${rowBytesSql}) <= ${OPENCODE_USAGE_SQLITE_ROW_MAX_BYTES} - ORDER BY s.time_created, s.id` - ) - .iterate() as Iterable - - for (const row of rows) { - yield { - id: row.id, - session_id: row.session_id, - time_created: row.time_created, - time_updated: row.time_updated, - directory: row.directory, - title: row.title, - worktree: row.worktree, - session_model: row.session_model, - data: JSON.stringify({ - cost: row.cost, - tokens: { - input: row.tokens_input, - output: row.tokens_output, - reasoning: row.tokens_reasoning, - total: row.tokens_input + row.tokens_output + row.tokens_reasoning, - cache: { read: row.tokens_cache_read, write: 0 } - } - }) - } - } -} - -function iterateMessageUsageRows( - db: Database.Database, - table: 'message' | 'session_message', - assistantPredicate: string -): Iterable { - const alias = table === 'message' ? 'm' : 'sm' - const projectJoin = getProjectJoin(db) - const sessionModelSelect = getSessionModelSelect(db) - const fromSql = `FROM ${table} ${alias} JOIN session s ON s.id = ${alias}.session_id ${projectJoin}` - const rowBytesSql = retainedTextBytesSql([ - `${alias}.id`, - `${alias}.session_id`, - `${alias}.data`, - 's.directory', - 's.title', - 'p.worktree', - columnExists(db, 'session', 'model') ? 's.model' : "''" - ]) - assertNoOversizedRows(db, fromSql, assistantPredicate, rowBytesSql) - - return db - .prepare( - `SELECT ${alias}.id, ${alias}.session_id, ${alias}.time_created, - ${alias}.time_updated, ${alias}.data, - s.directory, s.title, p.worktree, ${sessionModelSelect} - ${fromSql} - WHERE ${assistantPredicate} - AND (${rowBytesSql}) <= ${OPENCODE_USAGE_SQLITE_ROW_MAX_BYTES} - ORDER BY ${alias}.time_created, ${alias}.id` - ) - .iterate() as Iterable -} - -export function iterateOpenCodeUsageRows(db: Database.Database): Iterable { - if (!tableExists(db, 'session')) { - return [] - } - if (getSessionUsageRowCount(db) > 0) { - return iterateSessionUsageRows(db) - } - - if (getAssistantSessionMessageCount(db) > 0) { - const assistantPredicate = columnExists(db, 'session_message', 'type') - ? "sm.type = 'assistant'" - : boundedJsonPredicate('sm.data', "json_extract(sm.data, '$.tokens.input') IS NOT NULL") - return iterateMessageUsageRows(db, 'session_message', assistantPredicate) - } - - if (!tableExists(db, 'message')) { - return [] - } - assertNoOversizedJsonCandidates(db, 'message', '1 = 1') - return iterateMessageUsageRows( - db, - 'message', - boundedJsonPredicate('m.data', "json_extract(m.data, '$.role') = 'assistant'") - ) -} diff --git a/src/main/opencode-usage/store.ts b/src/main/opencode-usage/store.ts index e2966272a21..b926f32651c 100644 --- a/src/main/opencode-usage/store.ts +++ b/src/main/opencode-usage/store.ts @@ -1,6 +1,7 @@ /* eslint-disable max-lines -- Why: this store owns OpenCode analytics persistence, scan policy, and renderer query semantics. Keeping range/scope queries next to scan persistence prevents UI totals from drifting from the SQLite projection. */ import { app } from 'electron' -import { join } from 'node:path' +import { dirname, join } from 'node:path' +import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs' import type { OpenCodeUsageBreakdownKind, OpenCodeUsageBreakdownRow, @@ -13,10 +14,6 @@ import type { OpenCodeUsageSummary } from '../../shared/opencode-usage-types' import type { Store } from '../persistence' -import { - readUsageProjectionStateFile, - writeUsageProjectionStateFileWithRecovery -} from '../usage-projection-state-file' import { loadKnownUsageWorktreesByRepo, type UsageWorktreeRef } from '../usage-worktree-metadata' import type { OpenCodeUsageDailyAggregate, OpenCodeUsagePersistedState } from './types' import { createWorktreeRefs, scanOpenCodeUsageDatabases } from './scanner' @@ -164,11 +161,10 @@ export class OpenCodeUsageStore { private load(): OpenCodeUsagePersistedState { try { const usageFile = getOpenCodeUsageFile() - const raw = readUsageProjectionStateFile(usageFile) - if (raw === null) { + if (!existsSync(usageFile)) { return getDefaultState() } - const parsed = JSON.parse(raw) as OpenCodeUsagePersistedState + const parsed = JSON.parse(readFileSync(usageFile, 'utf-8')) as OpenCodeUsagePersistedState return normalizePersistedState({ ...getDefaultState(), ...parsed, @@ -185,12 +181,13 @@ export class OpenCodeUsageStore { private writeToDisk(): void { const usageFile = getOpenCodeUsageFile() - this.state = writeUsageProjectionStateFileWithRecovery(usageFile, this.state, (error) => { - const reset = getDefaultState() - reset.scanState.enabled = this.state.scanState.enabled - reset.scanState.lastScanError = error.message - return reset - }) + const dir = dirname(usageFile) + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }) + } + const tmpFile = `${usageFile}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp` + writeFileSync(tmpFile, JSON.stringify(this.state, null, 2), 'utf-8') + renameSync(tmpFile, usageFile) } async setEnabled(enabled: boolean): Promise { diff --git a/src/main/opencode/config-overlay-manifest.test.ts b/src/main/opencode/config-overlay-manifest.test.ts deleted file mode 100644 index 0818a98cf94..00000000000 --- a/src/main/opencode/config-overlay-manifest.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - CONFIG_OVERLAY_MAX_RETAINED_NAME_BYTES, - CONFIG_OVERLAY_MAX_SOURCE_ENTRIES, - ConfigOverlayCapacityError -} from '../pty/config-overlay-mirroring' -import { _configOverlayManifestInternals } from './config-overlay-manifest' - -const { parseOverlayManifest } = _configOverlayManifestInternals - -describe('OpenCode overlay manifest bounds', () => { - it('accepts the exact entry limit and rejects one more retained entry', () => { - const exact = parseOverlayManifest( - JSON.stringify({ - topLevelEntries: Array.from({ length: CONFIG_OVERLAY_MAX_SOURCE_ENTRIES }, () => 'same'), - pluginEntries: [] - }) - ) - expect(exact.topLevelEntries).toHaveLength(CONFIG_OVERLAY_MAX_SOURCE_ENTRIES) - - expect(() => - parseOverlayManifest( - JSON.stringify({ - topLevelEntries: Array.from( - { length: CONFIG_OVERLAY_MAX_SOURCE_ENTRIES + 1 }, - () => 'same' - ), - pluginEntries: [] - }) - ) - ).toThrowError( - new ConfigOverlayCapacityError( - 'entries', - CONFIG_OVERLAY_MAX_SOURCE_ENTRIES + 1, - CONFIG_OVERLAY_MAX_SOURCE_ENTRIES - ) - ) - }) - - it('accepts the exact encoded-name budget and rejects the next name', () => { - const name = 'a'.repeat(4_094) - const names = Array.from({ length: CONFIG_OVERLAY_MAX_RETAINED_NAME_BYTES / 4_096 }, () => name) - expect( - parseOverlayManifest(JSON.stringify({ topLevelEntries: names })).topLevelEntries - ).toHaveLength(names.length) - - expect(() => - parseOverlayManifest(JSON.stringify({ topLevelEntries: [...names, 'a'] })) - ).toThrowError( - new ConfigOverlayCapacityError( - 'retained-name-bytes', - CONFIG_OVERLAY_MAX_RETAINED_NAME_BYTES + 3, - CONFIG_OVERLAY_MAX_RETAINED_NAME_BYTES - ) - ) - }) - - it('never authorizes cleanup of reserved overlay-owned paths', () => { - expect( - parseOverlayManifest( - JSON.stringify({ - topLevelEntries: ['plugins', '.orca-opencode-overlay-manifest.json', 'auth.json'], - pluginEntries: ['orca-opencode-status.js', 'user-plugin.js'] - }) - ) - ).toEqual({ - topLevelEntries: ['auth.json'], - pluginEntries: ['user-plugin.js'] - }) - }) -}) diff --git a/src/main/opencode/config-overlay-manifest.ts b/src/main/opencode/config-overlay-manifest.ts deleted file mode 100644 index 6d9f2ffd9e2..00000000000 --- a/src/main/opencode/config-overlay-manifest.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { unlinkSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' -import { stringifyJsonWithinByteLimit } from '../../shared/node-bounded-json-stringify' -import { - NodeFileReadTooLargeError, - readNodeFileSyncWithinLimit -} from '../../shared/node-bounded-file-reader' -import { - CONFIG_OVERLAY_MAX_SOURCE_ENTRIES, - ConfigOverlayCapacityError, - ConfigOverlayEntryBudget, - applyConfigOverlayPlan, - createConfigOverlayPlan, - type AppliedConfigOverlayEntries -} from '../pty/config-overlay-mirroring' -import { safeRemoveTree } from '../pty/overlay-mirror' - -export const ORCA_OPENCODE_PLUGIN_FILE = 'orca-opencode-status.js' -export const OPENCODE_OVERLAY_MANIFEST_FILE = '.orca-opencode-overlay-manifest.json' -export const OPENCODE_OVERLAY_MANIFEST_MAX_BYTES = 2 * 1_024 * 1_024 - -type OpenCodeOverlayManifest = { - topLevelEntries: string[] - pluginEntries: string[] -} - -const RESERVED_TOP_LEVEL_ENTRIES = new Set([OPENCODE_OVERLAY_MANIFEST_FILE]) - -function emptyManifest(): OpenCodeOverlayManifest { - return { topLevelEntries: [], pluginEntries: [] } -} - -function parseManifestEntryNames( - value: unknown, - kind: 'top-level' | 'plugin', - budget: ConfigOverlayEntryBudget -): string[] { - if (!Array.isArray(value)) { - return [] - } - - const names: string[] = [] - for (const candidate of value) { - if (typeof candidate !== 'string') { - continue - } - budget.reserve(candidate) - if ( - (kind === 'top-level' && - (candidate === 'plugins' || candidate === OPENCODE_OVERLAY_MANIFEST_FILE)) || - (kind === 'plugin' && candidate === ORCA_OPENCODE_PLUGIN_FILE) - ) { - continue - } - names.push(candidate) - } - return names -} - -function parseOverlayManifest(contents: string): OpenCodeOverlayManifest { - const parsed = JSON.parse(contents) as Partial - const topLevelCount = Array.isArray(parsed.topLevelEntries) ? parsed.topLevelEntries.length : 0 - const pluginCount = Array.isArray(parsed.pluginEntries) ? parsed.pluginEntries.length : 0 - if (topLevelCount + pluginCount > CONFIG_OVERLAY_MAX_SOURCE_ENTRIES) { - throw new ConfigOverlayCapacityError( - 'entries', - topLevelCount + pluginCount, - CONFIG_OVERLAY_MAX_SOURCE_ENTRIES - ) - } - - const budget = new ConfigOverlayEntryBudget() - return { - topLevelEntries: parseManifestEntryNames(parsed.topLevelEntries, 'top-level', budget), - pluginEntries: parseManifestEntryNames(parsed.pluginEntries, 'plugin', budget) - } -} - -function readOverlayManifest(overlayDir: string): OpenCodeOverlayManifest { - try { - const contents = readNodeFileSyncWithinLimit( - join(overlayDir, OPENCODE_OVERLAY_MANIFEST_FILE), - OPENCODE_OVERLAY_MANIFEST_MAX_BYTES - ).buffer.toString('utf8') - return parseOverlayManifest(contents) - } catch (error) { - if (error instanceof ConfigOverlayCapacityError || error instanceof NodeFileReadTooLargeError) { - throw error - } - return emptyManifest() - } -} - -function removePathBeforeWrite(path: string): void { - try { - unlinkSync(path) - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { - throw error - } - } -} - -function writeOverlayManifest(overlayDir: string, manifest: OpenCodeOverlayManifest): void { - const manifestPath = join(overlayDir, OPENCODE_OVERLAY_MANIFEST_FILE) - const { serialized } = stringifyJsonWithinByteLimit( - manifest, - OPENCODE_OVERLAY_MANIFEST_MAX_BYTES - 1 - ) - removePathBeforeWrite(manifestPath) - writeFileSync(manifestPath, `${serialized}\n`) -} - -function clearManifestEntries(overlayDir: string, manifest: OpenCodeOverlayManifest): void { - for (const entryName of manifest.topLevelEntries) { - if (!safeRemoveTree(join(overlayDir, entryName))) { - throw new Error('Unable to clear an OpenCode config overlay entry') - } - } - - const overlayPluginsDir = join(overlayDir, 'plugins') - for (const entryName of manifest.pluginEntries) { - if (!safeRemoveTree(join(overlayPluginsDir, entryName))) { - throw new Error('Unable to clear an OpenCode plugin overlay entry') - } - } -} - -function toManifest(applied: AppliedConfigOverlayEntries): OpenCodeOverlayManifest { - return { - topLevelEntries: applied.topLevelEntryNames, - pluginEntries: applied.pluginEntryNames - } -} - -export function mirrorOpenCodeConfigWithManifest(sourceDir: string, overlayDir: string): void { - const plan = createConfigOverlayPlan(sourceDir, { - reservedPluginFile: ORCA_OPENCODE_PLUGIN_FILE, - reservedTopLevelEntryNames: RESERVED_TOP_LEVEL_ENTRIES - }) - const previousManifest = readOverlayManifest(overlayDir) - clearManifestEntries(overlayDir, previousManifest) - - const applied: AppliedConfigOverlayEntries = { - topLevelEntryNames: [], - pluginEntryNames: [] - } - try { - applyConfigOverlayPlan(plan, overlayDir, applied) - writeOverlayManifest(overlayDir, toManifest(applied)) - } catch (error) { - try { - clearManifestEntries(overlayDir, toManifest(applied)) - } catch { - // Preserve the original mirror/write failure; a later spawn can retry cleanup. - } - throw error - } -} - -export const _configOverlayManifestInternals = { - parseOverlayManifest, - writeOverlayManifest -} diff --git a/src/main/opencode/hook-service.test.ts b/src/main/opencode/hook-service.test.ts index df9fe7b80e7..f3956519ee7 100644 --- a/src/main/opencode/hook-service.test.ts +++ b/src/main/opencode/hook-service.test.ts @@ -8,12 +8,10 @@ import { readdirSync, rmSync, symlinkSync, - truncateSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { GENERATED_NODE_MANAGED_FILE_MAX_BYTES } from '../generated-node-bounded-file-reader' const { getPathMock } = vi.hoisted(() => ({ getPathMock: vi.fn<(name: string) => string>() @@ -26,10 +24,6 @@ vi.mock('electron', () => ({ })) import { OpenCodeHookService, _internals } from './hook-service' -import { - OPENCODE_OVERLAY_MANIFEST_FILE, - OPENCODE_OVERLAY_MANIFEST_MAX_BYTES -} from './config-overlay-manifest' const { isUsableId, toSafeDirName } = _internals @@ -71,11 +65,6 @@ describe('OpenCode hook plugin source', () => { expect(source).toContain('const coords = resolveHookCoords();') expect(source).toContain('`http://127.0.0.1:${coords.port}/hook/opencode`') expect(source).toContain('"X-Orca-Agent-Hook-Token": coords.token') - expect(source).toContain( - `function readOrcaManagedFileWithinLimit(fs, path, maxBytes = ${GENERATED_NODE_MANAGED_FILE_MAX_BYTES})` - ) - expect(source).toContain('readOrcaManagedFileWithinLimit(fs, path)') - expect(source).not.toContain('fs.readFileSync') }) it('caches the parsed endpoint file on mtime+size+inode to skip re-reads per post', () => { @@ -366,81 +355,6 @@ describe('OpenCodeHookService overlay mode (user OPENCODE_CONFIG_DIR set)', () = expectUserConfigIntact() }) - it('does not mirror or overwrite a user file named like the internal manifest', () => { - const userManifest = join(userConfigDir, OPENCODE_OVERLAY_MANIFEST_FILE) - writeFileSync(userManifest, 'USER MANIFEST SENTINEL') - - const env = new OpenCodeHookService().buildPtyEnv(ptyId, userConfigDir) - const overlayManifest = join(env.OPENCODE_CONFIG_DIR!, OPENCODE_OVERLAY_MANIFEST_FILE) - - expect(readFileSync(userManifest, 'utf8')).toBe('USER MANIFEST SENTINEL') - expect(lstatSync(overlayManifest).isSymbolicLink()).toBe(false) - expect(JSON.parse(readFileSync(overlayManifest, 'utf8'))).toMatchObject({ - topLevelEntries: expect.arrayContaining(['auth.json', 'opencode.json']) - }) - }) - - it('falls back before mutation when a retained manifest exceeds its byte limit', () => { - const service = new OpenCodeHookService() - const first = service.buildPtyEnv(ptyId, userConfigDir) - const overlayDir = first.OPENCODE_CONFIG_DIR! - const overlayManifest = join(overlayDir, OPENCODE_OVERLAY_MANIFEST_FILE) - truncateSync(overlayManifest, OPENCODE_OVERLAY_MANIFEST_MAX_BYTES + 1) - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - - expect(service.buildPtyEnv(ptyId, userConfigDir)).toEqual({ - OPENCODE_CONFIG_DIR: userConfigDir - }) - expect(readFileSync(join(overlayDir, 'auth.json'), 'utf8')).toBe('user-auth-token') - expect(warnSpy).toHaveBeenCalledWith( - '[opencode-hooks] config overlay exceeded its memory limit; using the original OPENCODE_CONFIG_DIR without Orca status integration' - ) - expectUserConfigIntact() - }) - - it('ignores forged manifest traversal entries during cleanup', () => { - const overlayDir = join( - userDataDir, - 'opencode-config-overlays', - toSafeDirName(`source:${userConfigDir}`) - ) - mkdirSync(overlayDir, { recursive: true }) - const outsideMarker = join(userDataDir, 'outside-manifest-marker') - writeFileSync(outsideMarker, 'keep') - writeFileSync( - join(overlayDir, OPENCODE_OVERLAY_MANIFEST_FILE), - JSON.stringify({ topLevelEntries: ['../outside-manifest-marker'] }) - ) - - const env = new OpenCodeHookService().buildPtyEnv(ptyId, userConfigDir) - - expect(env.OPENCODE_CONFIG_DIR).toBe(overlayDir) - expect(readFileSync(outsideMarker, 'utf8')).toBe('keep') - }) - - it.skipIf(process.platform === 'win32')( - 'replaces a stale manifest symlink without writing through to the user file', - () => { - const userManifest = join(userConfigDir, OPENCODE_OVERLAY_MANIFEST_FILE) - writeFileSync(userManifest, 'USER MANIFEST SENTINEL') - const overlayDir = join( - userDataDir, - 'opencode-config-overlays', - toSafeDirName(`source:${userConfigDir}`) - ) - mkdirSync(overlayDir, { recursive: true }) - symlinkSync(userManifest, join(overlayDir, OPENCODE_OVERLAY_MANIFEST_FILE), 'file') - - const env = new OpenCodeHookService().buildPtyEnv(ptyId, userConfigDir) - - expect(env.OPENCODE_CONFIG_DIR).toBe(overlayDir) - expect(readFileSync(userManifest, 'utf8')).toBe('USER MANIFEST SENTINEL') - expect(lstatSync(join(overlayDir, OPENCODE_OVERLAY_MANIFEST_FILE)).isSymbolicLink()).toBe( - false - ) - } - ) - it.skipIf(process.platform === 'win32')( 'does not write through a symlinked plugins/ directory into the user filesystem', () => { diff --git a/src/main/opencode/hook-service.ts b/src/main/opencode/hook-service.ts index cc45e229618..c2baa488dc9 100644 --- a/src/main/opencode/hook-service.ts +++ b/src/main/opencode/hook-service.ts @@ -1,19 +1,29 @@ /* eslint-disable max-lines -- Why: holds an inline JS plugin source emitted as one file; splitting across TS modules would scatter tightly coupled string-template logic. */ import { app } from 'electron' import { join } from 'node:path' -import { existsSync, mkdirSync, unlinkSync, writeFileSync } from 'node:fs' -import { createHash } from 'node:crypto' -import { NodeFileReadTooLargeError } from '../../shared/node-bounded-file-reader' -import { getGeneratedNodeBoundedFileReaderSourceLines } from '../generated-node-bounded-file-reader' -import { ConfigOverlayCapacityError } from '../pty/config-overlay-mirroring' import { - ORCA_OPENCODE_PLUGIN_FILE, - mirrorOpenCodeConfigWithManifest -} from './config-overlay-manifest' + existsSync, + mkdirSync, + readFileSync, + readdirSync, + realpathSync, + statSync, + unlinkSync, + writeFileSync +} from 'node:fs' +import { createHash } from 'node:crypto' +import { mirrorEntry, safeRemoveTree } from '../pty/overlay-mirror' +const ORCA_OPENCODE_PLUGIN_FILE = 'orca-opencode-status.js' const OPENCODE_LEGACY_HOOKS_DIR = 'opencode-hooks' const OPENCODE_OVERLAY_DIR = 'opencode-config-overlays' const OPENCODE_SHARED_CONFIG_DIR = 'shared' +const OPENCODE_OVERLAY_MANIFEST_FILE = '.orca-opencode-overlay-manifest.json' + +type OpenCodeOverlayManifest = { + topLevelEntries: string[] + pluginEntries: string[] +} // Why: bounds-check only — the id is a daemon sessionId with path separators, hashed downstream to a filesystem-safe name (an old regex rejecting "/"/":" broke every such id, #1148); 1024 just caps pathological hash input. function isUsableId(id: string): boolean { @@ -38,13 +48,12 @@ export function getOpenCodeFamilyPluginSource(hookPathname: string): string { "// OpenCode's Node process (not Orca's) and has no access to server.ts's", '// equivalent warnedVersions / warnedEnvs Sets.', 'let warnedBadEndpoint = false;', - ...getGeneratedNodeBoundedFileReaderSourceLines(), '', '// Why: message.part.updated can fire many times per second during a', '// streaming assistant reply, and each post() calls resolveHookCoords()', '// which reads the endpoint file. The file only changes on Orca restart', '// (rare), so a stat+mtime check is substantially cheaper than a full', - '// bounded read+parse on every streamed part. On stat error we fall', + '// readFileSync+parse on every streamed part. On stat error we fall', '// through to parse so the fail-open behavior is preserved.', 'let cachedEndpointKey = "";', 'let cachedEndpointValues = null;', @@ -65,7 +74,7 @@ export function getOpenCodeFamilyPluginSource(hookPathname: string): string { ' if (cacheKey === cachedEndpointKey && cachedEndpointValues) {', ' return cachedEndpointValues;', ' }', - ' const contents = readOrcaManagedFileWithinLimit(fs, path);', + ' const contents = fs.readFileSync(path, "utf8");', ' const out = {};', ' for (const line of contents.split(/\\r?\\n/)) {', ' // Why: Windows endpoint.cmd uses `set KEY=VALUE`; Unix endpoint.env', @@ -82,7 +91,7 @@ export function getOpenCodeFamilyPluginSource(hookPathname: string): string { ' return out;', ' } catch (ioErr) {', ' // Why: any stat or read failure (file yanked mid-read, permission', - ' // race, unlink between stat and bounded read) must invalidate the', + ' // race, unlink between stat and readFileSync) must invalidate the', ' // cache so a transient failure does not lock in a stale parse for', ' // the remaining process lifetime; rethrow to the outer catch.', ' cachedEndpointKey = "";', @@ -368,8 +377,6 @@ export function getOpenCodeFamilyPluginSource(hookPathname: string): string { // Why: installs the plugin into OPENCODE_CONFIG_DIR so it POSTs to the shared agent-hooks server, unifying OpenCode status with Claude/Codex/Gemini (the old loopback-IPC path never reached agentStatusByPaneKey). export class OpenCodeHookService { - private warnedOverlayCapacity = false - clearPty(_ptyId: string): void { // Why: no-op — config dirs are app/source-scoped now, and recursive delete on the main-process hot path could freeze on Windows. } @@ -400,16 +407,7 @@ export class OpenCodeHookService { mkdirSync(overlayDir, { recursive: true }) this.mirrorUserConfig(existingConfigDir, overlayDir) this.writePluginIntoOverlay(overlayDir) - } catch (error) { - if ( - !this.warnedOverlayCapacity && - (error instanceof ConfigOverlayCapacityError || error instanceof NodeFileReadTooLargeError) - ) { - this.warnedOverlayCapacity = true - console.warn( - '[opencode-hooks] config overlay exceeded its memory limit; using the original OPENCODE_CONFIG_DIR without Orca status integration' - ) - } + } catch { // Why: best-effort — symlink creation needs Windows developer mode (else EPERM) and userData may be read-only; preserve the user's config over dropping their auth/models/keymap. return { OPENCODE_CONFIG_DIR: existingConfigDir } } @@ -429,8 +427,90 @@ export class OpenCodeHookService { return join(app.getPath('userData'), OPENCODE_LEGACY_HOOKS_DIR, OPENCODE_SHARED_CONFIG_DIR) } + private readOverlayManifest(overlayDir: string): OpenCodeOverlayManifest { + try { + const parsed = JSON.parse( + readFileSync(join(overlayDir, OPENCODE_OVERLAY_MANIFEST_FILE), 'utf8') + ) as Partial + return { + topLevelEntries: Array.isArray(parsed.topLevelEntries) ? parsed.topLevelEntries : [], + pluginEntries: Array.isArray(parsed.pluginEntries) ? parsed.pluginEntries : [] + } + } catch { + return { topLevelEntries: [], pluginEntries: [] } + } + } + + private writeOverlayManifest(overlayDir: string, manifest: OpenCodeOverlayManifest): void { + writeFileSync( + join(overlayDir, OPENCODE_OVERLAY_MANIFEST_FILE), + `${JSON.stringify(manifest, null, 2)}\n` + ) + } + + private clearManifestEntries(overlayDir: string, manifest: OpenCodeOverlayManifest): void { + for (const entryName of manifest.topLevelEntries) { + safeRemoveTree(join(overlayDir, entryName)) + } + + const overlayPluginsDir = join(overlayDir, 'plugins') + for (const entryName of manifest.pluginEntries) { + if (entryName === ORCA_OPENCODE_PLUGIN_FILE) { + continue + } + safeRemoveTree(join(overlayPluginsDir, entryName)) + } + } + + // Why: mirror user config entries as symlinks so edits propagate live; only plugins/ becomes a real overlay dir so Orca can drop a sibling plugin file. private mirrorUserConfig(sourceDir: string, overlayDir: string): void { - mirrorOpenCodeConfigWithManifest(sourceDir, overlayDir) + const previousManifest = this.readOverlayManifest(overlayDir) + // Why: overlays persist across terminals; remove only Orca-mirrored paths so stale user config clears but OpenCode runtime dirs (node_modules) survive. + this.clearManifestEntries(overlayDir, previousManifest) + + const nextManifest: OpenCodeOverlayManifest = { topLevelEntries: [], pluginEntries: [] } + + for (const entry of readdirSync(sourceDir, { withFileTypes: true })) { + const sourcePath = join(sourceDir, entry.name) + + if (entry.name === 'plugins') { + // Why: check isSymbolicLink before isDirectory — a Windows junction reports both, and the symlink branch must win. + const isSymlink = entry.isSymbolicLink() + let isLinkPointingToDir = false + if (isSymlink) { + try { + isLinkPointingToDir = statSync(sourcePath).isDirectory() + } catch { + // Why: broken/inaccessible symlink — mirror the dangling link verbatim instead of resolving through it. + isLinkPointingToDir = false + } + } + + if ((!isSymlink && entry.isDirectory()) || isLinkPointingToDir) { + // Why: resolve a symlinked plugins/ to its real target so /plugins stays a real dir and writePluginIntoOverlay can't write through the user's link. + const resolvedSource = isLinkPointingToDir ? realpathSync(sourcePath) : sourcePath + const overlayPluginsDir = join(overlayDir, 'plugins') + mkdirSync(overlayPluginsDir, { recursive: true }) + for (const pluginEntry of readdirSync(resolvedSource, { withFileTypes: true })) { + // Why: skip a user plugin sharing Orca's filename; mirroring it would let writePluginIntoOverlay clobber the user's file. + if (pluginEntry.name === ORCA_OPENCODE_PLUGIN_FILE) { + continue + } + mirrorEntry( + join(resolvedSource, pluginEntry.name), + join(overlayPluginsDir, pluginEntry.name) + ) + nextManifest.pluginEntries.push(pluginEntry.name) + } + continue + } + } + + mirrorEntry(sourcePath, join(overlayDir, entry.name)) + nextManifest.topLevelEntries.push(entry.name) + } + + this.writeOverlayManifest(overlayDir, nextManifest) } // Why: pre-write unlink guards against POSIX writeFileSync writing through a mirrored symlink and clobbering a same-named user plugin. @@ -440,10 +520,8 @@ export class OpenCodeHookService { const pluginPath = join(pluginsDir, ORCA_OPENCODE_PLUGIN_FILE) try { unlinkSync(pluginPath) - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { - throw error - } + } catch { + // File may not exist on a fresh overlay; a real failure surfaces on writeFileSync below. } writeFileSync(pluginPath, getOpenCodePluginSource()) } @@ -453,7 +531,7 @@ export class OpenCodeHookService { const pluginsDir = join(configDir, 'plugins') try { mkdirSync(pluginsDir, { recursive: true }) - this.writePluginIntoOverlay(configDir) + writeFileSync(join(pluginsDir, ORCA_OPENCODE_PLUGIN_FILE), getOpenCodePluginSource()) } catch { // Why: userData can be locked on Windows (EPERM/EBUSY); plugin is non-critical, so spawn without it. return null diff --git a/src/main/opencode/opencode-database-files.test.ts b/src/main/opencode/opencode-database-files.test.ts deleted file mode 100644 index f27452e2e89..00000000000 --- a/src/main/opencode/opencode-database-files.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { collectOpenCodeDatabaseFiles } from './opencode-database-files' - -function entry(name: string, file = true): { name: string; isFile(): boolean } { - return { name, isFile: () => file } -} - -describe('OpenCode database file discovery', () => { - it('preserves and sorts every matching file within the limit', async () => { - const result = await collectOpenCodeDatabaseFiles( - '/data', - (async function* () { - yield entry('opencode-z.db') - yield entry('notes.txt') - yield entry('opencode.db') - yield entry('opencode-directory.db', false) - })(), - 2 - ) - - expect(result).toEqual({ - paths: ['/data/opencode-z.db', '/data/opencode.db'].sort(), - truncated: false - }) - }) - - it('stops retaining names immediately after the database count limit', async () => { - let enumerated = 0 - const result = await collectOpenCodeDatabaseFiles( - '/data', - (async function* () { - for (const name of ['opencode-a.db', 'opencode-b.db', 'opencode-c.db', 'opencode-d.db']) { - enumerated += 1 - yield entry(name) - } - })(), - 2 - ) - - expect(result).toEqual({ - paths: ['/data/opencode-a.db', '/data/opencode-b.db'], - truncated: true - }) - expect(enumerated).toBe(3) - }) -}) diff --git a/src/main/opencode/opencode-database-files.ts b/src/main/opencode/opencode-database-files.ts deleted file mode 100644 index 95e3abb9d20..00000000000 --- a/src/main/opencode/opencode-database-files.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { opendir } from 'node:fs/promises' -import { join } from 'node:path' - -export const OPENCODE_DATABASE_FILE_LIMIT = 256 - -type OpenCodeDatabaseDirectoryEntry = { - name: string - isFile(): boolean -} - -export type OpenCodeDatabaseFiles = { - paths: string[] - truncated: boolean -} - -export async function listOpenCodeDatabaseFiles(dataDir: string): Promise { - try { - return collectOpenCodeDatabaseFiles(dataDir, await opendir(dataDir)) - } catch { - return { paths: [], truncated: false } - } -} - -export async function collectOpenCodeDatabaseFiles( - dataDir: string, - directory: AsyncIterable, - maxFiles = OPENCODE_DATABASE_FILE_LIMIT -): Promise { - const paths: string[] = [] - for await (const entry of directory) { - if (!entry.isFile() || !/^opencode(?:-[A-Za-z0-9_.-]+)?\.db$/.test(entry.name)) { - continue - } - if (paths.length >= maxFiles) { - return { paths: paths.sort(), truncated: true } - } - paths.push(join(dataDir, entry.name)) - } - return { paths: paths.sort(), truncated: false } -} diff --git a/src/main/orca-profiles/profile-cloud-client.ts b/src/main/orca-profiles/profile-cloud-client.ts index e6351f22a12..e7657bbdb87 100644 --- a/src/main/orca-profiles/profile-cloud-client.ts +++ b/src/main/orca-profiles/profile-cloud-client.ts @@ -7,7 +7,6 @@ import type { OrcaCloudAuthConfig } from './profile-cloud-auth-config' import type { OrcaCloudSession } from './profile-cloud-session-store' import type { OrcaCloudSessionExchangeResponse } from './profile-cloud-session-exchange' import { cancelUnreadResponseBody } from '../lib/unread-response-body' -import { readFetchResponseJsonWithinLimit } from '../lib/fetch-response-body' type ExchangeCodeArgs = { code: string @@ -178,7 +177,7 @@ async function postJson(url: string, body: unknown, accessToken?: string): Pr await cancelUnreadResponseBody(response) throw new OrcaCloudRequestError(response.status) } - return await readFetchResponseJsonWithinLimit(response) + return (await response.json()) as T } export async function exchangeOrcaCloudAuthCode( diff --git a/src/main/orca-profiles/profile-cloud-dev-org-members.test.ts b/src/main/orca-profiles/profile-cloud-dev-org-members.test.ts deleted file mode 100644 index 357e7316fc0..00000000000 --- a/src/main/orca-profiles/profile-cloud-dev-org-members.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { beforeEach, describe, expect, it } from 'vitest' -import { - _getDevOrcaCloudOrgRosterCountForTests, - _resetDevOrcaCloudOrgRostersForTests, - DEV_ORG_PENDING_INVITE_MAX_ENTRIES, - DEV_ORG_ROSTER_MAX_ENTRIES, - inviteDevOrcaCloudOrgMember, - listDevOrcaCloudOrgMembers -} from './profile-cloud-dev-org-members' - -beforeEach(() => { - _resetDevOrcaCloudOrgRostersForTests() -}) - -describe('dev organization roster retention', () => { - it('bounds retained organizations while keeping recently used rosters', () => { - for (let index = 0; index <= DEV_ORG_ROSTER_MAX_ENTRIES; index += 1) { - listDevOrcaCloudOrgMembers(`org-${index}`) - } - expect(_getDevOrcaCloudOrgRosterCountForTests()).toBe(DEV_ORG_ROSTER_MAX_ENTRIES) - }) - - it('bounds pending invites within one retained roster', () => { - const orgId = 'invite-heavy-org' - for (let index = 1; index < DEV_ORG_PENDING_INVITE_MAX_ENTRIES; index += 1) { - expect( - inviteDevOrcaCloudOrgMember({ - orgId, - email: `person-${index}@example.com`, - role: 'member' - }) - ).toEqual({ status: 'ok' }) - } - expect( - inviteDevOrcaCloudOrgMember({ - orgId, - email: 'overflow@example.com', - role: 'member' - }) - ).toEqual({ status: 'failed', error: 'The dev organization invite roster is full.' }) - }) - - it('does not retain oversized invitation text', () => { - expect( - inviteDevOrcaCloudOrgMember({ - orgId: 'org', - email: `${'x'.repeat(321)}@example.com`, - role: 'member' - }) - ).toEqual({ status: 'failed', error: 'The dev organization invite roster is full.' }) - expect(listDevOrcaCloudOrgMembers('org').pendingInvites).toHaveLength(1) - }) -}) diff --git a/src/main/orca-profiles/profile-cloud-dev-org-members.ts b/src/main/orca-profiles/profile-cloud-dev-org-members.ts index 7e69bc008a0..4f61e03df6a 100644 --- a/src/main/orca-profiles/profile-cloud-dev-org-members.ts +++ b/src/main/orca-profiles/profile-cloud-dev-org-members.ts @@ -1,4 +1,3 @@ -import { createHash } from 'node:crypto' import type { OrcaOrgMember, OrcaOrgMembersRoster, @@ -9,7 +8,6 @@ import type { OrcaProfileOrgMemberRemoveArgs, OrcaProfileOrgInviteRevokeArgs } from '../../shared/orca-profiles' -import { measureUtf8ByteLength } from '../../shared/utf8-byte-limits' // Why: dev-auth mode has no server, so the whole teammate UI is exercised // against this in-memory per-org roster. It mirrors the shape the real client @@ -21,17 +19,10 @@ type DevOrgRoster = { } const devRostersByOrg = new Map() -export const DEV_ORG_ROSTER_MAX_ENTRIES = 64 -export const DEV_ORG_PENDING_INVITE_MAX_ENTRIES = 256 -const DEV_ORG_ENV_FIELD_MAX_BYTES = 4 * 1024 -const DEV_ORG_INVITE_EMAIL_MAX_BYTES = 320 function cleanEnvString(value: string | undefined, fallback: string): string { const trimmed = value?.trim() - return trimmed && - !measureUtf8ByteLength(trimmed, { stopAfterBytes: DEV_ORG_ENV_FIELD_MAX_BYTES }).exceededLimit - ? trimmed - : fallback + return trimmed || fallback } function devSelf(): OrcaOrgMember { @@ -61,22 +52,12 @@ function seedDevRoster(): DevOrgRoster { } function getDevRoster(orgId: string): DevOrgRoster { - const cacheKey = createHash('sha256').update(orgId).digest('base64url') - const existing = devRostersByOrg.get(cacheKey) + const existing = devRostersByOrg.get(orgId) if (existing) { - devRostersByOrg.delete(cacheKey) - devRostersByOrg.set(cacheKey, existing) return existing } const seeded = seedDevRoster() - devRostersByOrg.set(cacheKey, seeded) - while (devRostersByOrg.size > DEV_ORG_ROSTER_MAX_ENTRIES) { - const oldest = devRostersByOrg.keys().next().value - if (oldest === undefined) { - break - } - devRostersByOrg.delete(oldest) - } + devRostersByOrg.set(orgId, seeded) return seeded } @@ -101,14 +82,6 @@ export function inviteDevOrcaCloudOrgMember( if (roster.pendingInvites.some((invite) => invite.email.toLowerCase() === email)) { return { status: 'conflict', reason: 'already_invited' } } - if ( - roster.pendingInvites.length >= DEV_ORG_PENDING_INVITE_MAX_ENTRIES || - measureUtf8ByteLength(args.email, { - stopAfterBytes: DEV_ORG_INVITE_EMAIL_MAX_BYTES - }).exceededLimit - ) { - return { status: 'failed', error: 'The dev organization invite roster is full.' } - } roster.pendingInvites.push({ email: args.email, role: args.role, createdAt: Date.now() }) return { status: 'ok' } } @@ -155,11 +128,3 @@ export function removeDevOrcaCloudOrgMember( roster.members.splice(index, 1) return { status: 'ok' } } - -export function _resetDevOrcaCloudOrgRostersForTests(): void { - devRostersByOrg.clear() -} - -export function _getDevOrcaCloudOrgRosterCountForTests(): number { - return devRostersByOrg.size -} diff --git a/src/main/orca-profiles/profile-cloud-org-members-client.ts b/src/main/orca-profiles/profile-cloud-org-members-client.ts index b3c0e8e3743..cd1b106e489 100644 --- a/src/main/orca-profiles/profile-cloud-org-members-client.ts +++ b/src/main/orca-profiles/profile-cloud-org-members-client.ts @@ -7,7 +7,6 @@ import type { import type { OrcaCloudAuthConfig } from './profile-cloud-auth-config' import type { OrcaCloudSession } from './profile-cloud-session-store' import { OrcaCloudRequestError } from './profile-cloud-client' -import { readFetchResponseJsonWithinLimit } from '../lib/fetch-response-body' const CLOUD_REQUEST_TIMEOUT_MS = 30_000 const ORG_ROLES: readonly OrcaOrgRole[] = ['owner', 'admin', 'member'] @@ -107,7 +106,7 @@ function orgMembersUrl(config: OrcaCloudAuthConfig, orgId: string, path: string) async function extractErrorCode(response: Response): Promise { try { - const body = await readFetchResponseJsonWithinLimit(response) + const body = (await response.json()) as unknown if ( body && typeof body === 'object' && @@ -146,7 +145,7 @@ async function requestOrgMembers( if (!response.ok) { throw new OrcaCloudRequestError(response.status, await extractErrorCode(response)) } - return parse(await readFetchResponseJsonWithinLimit(response)) + return parse((await response.json()) as unknown) } export async function listOrcaCloudOrgMembers( diff --git a/src/main/orca-profiles/profile-cloud-session-mutation.test.ts b/src/main/orca-profiles/profile-cloud-session-mutation.test.ts index 1a4b309566b..a8196a7f1a3 100644 --- a/src/main/orca-profiles/profile-cloud-session-mutation.test.ts +++ b/src/main/orca-profiles/profile-cloud-session-mutation.test.ts @@ -1,13 +1,10 @@ -import { mkdirSync, mkdtempSync, readFileSync, rmSync, truncateSync, writeFileSync } from 'node:fs' +import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { captureCloudSessionMutation, isCloudSessionMutationCurrent, - MAX_CLOUD_SESSION_IDENTITY_KEY_BYTES, - MAX_CLOUD_SESSION_MUTATION_STATE_FILE_BYTES, - MAX_CLOUD_SESSION_TOMBSTONES, recordCloudSessionIdentityMutation, recordSuccessfulCloudSessionLogin, tombstoneCloudSession, @@ -62,58 +59,4 @@ describe('cloud session mutation fence', () => { true ) }) - - it('rejects an oversized sparse mutation fence before parsing it', () => { - const profileDirectory = join(userDataPath, 'profiles', identity.localProfileId) - mkdirSync(profileDirectory, { recursive: true }) - const path = join(profileDirectory, 'account-session-mutation.json') - writeFileSync(path, '{"version":1}') - truncateSync(path, MAX_CLOUD_SESSION_MUTATION_STATE_FILE_BYTES + 1) - - expect(() => captureCloudSessionMutation(identity, userDataPath)).toThrow( - 'invalid_cloud_session_mutation_state' - ) - }) - - it('bounds tombstones while the advancing epoch keeps old snapshots invalid', () => { - const firstIdentity = { ...identity, cloudUserId: 'user-0' } - const firstSnapshot = captureCloudSessionMutation(firstIdentity, userDataPath) - - for (let index = 0; index <= MAX_CLOUD_SESSION_TOMBSTONES; index += 1) { - tombstoneCloudSession({ ...identity, cloudUserId: `user-${index}` }, userDataPath) - } - - const path = join( - userDataPath, - 'profiles', - identity.localProfileId, - 'account-session-mutation.json' - ) - const state = JSON.parse(readFileSync(path, 'utf8')) as { - tombstonedIdentityKeys: string[] - } - expect(state.tombstonedIdentityKeys).toHaveLength(MAX_CLOUD_SESSION_TOMBSTONES) - expect( - isCloudSessionMutationCurrent(identity.localProfileId, userDataPath, firstSnapshot) - ).toBe(false) - }) - - it('rejects an oversized identity before changing the last readable fence', () => { - captureCloudSessionMutation(identity, userDataPath) - const path = join( - userDataPath, - 'profiles', - identity.localProfileId, - 'account-session-mutation.json' - ) - const before = readFileSync(path, 'utf8') - - expect(() => - recordCloudSessionIdentityMutation( - { ...identity, cloudUserId: 'x'.repeat(MAX_CLOUD_SESSION_IDENTITY_KEY_BYTES) }, - userDataPath - ) - ).toThrow('cloud_session_identity_too_large') - expect(readFileSync(path, 'utf8')).toBe(before) - }) }) diff --git a/src/main/orca-profiles/profile-cloud-session-mutation.ts b/src/main/orca-profiles/profile-cloud-session-mutation.ts index b9c89698aa1..d360cb72ab9 100644 --- a/src/main/orca-profiles/profile-cloud-session-mutation.ts +++ b/src/main/orca-profiles/profile-cloud-session-mutation.ts @@ -1,15 +1,10 @@ -import { existsSync } from 'node:fs' +import { existsSync, readFileSync } from 'node:fs' import { join } from 'node:path' -import { readNodeFileSyncWithinLimit } from '../../shared/node-bounded-file-reader' -import { JsonStringifyByteLimitError } from '../../shared/node-bounded-json-stringify' -import { writeSecureJsonFileWithinLimit } from '../../shared/bounded-secure-json-file' +import { writeSecureJsonFile } from '../../shared/secure-file' import type { OrcaProfileCloudSummary } from '../../shared/orca-profiles' import { getOrcaProfileDirectory } from './profile-storage-paths' const MUTATION_STATE_VERSION = 1 -export const MAX_CLOUD_SESSION_MUTATION_STATE_FILE_BYTES = 1024 * 1024 -export const MAX_CLOUD_SESSION_IDENTITY_KEY_BYTES = 16 * 1024 -export const MAX_CLOUD_SESSION_TOMBSTONES = 512 export type CloudSessionIdentity = { localProfileId: string @@ -31,11 +26,7 @@ type CloudSessionMutationState = { } function identityKey(identity: CloudSessionIdentity): string { - const key = `${identity.localProfileId}\0${identity.cloudUserId}\0${identity.cloudProfileId}\0${identity.organizationId}` - if (Buffer.byteLength(key, 'utf8') > MAX_CLOUD_SESSION_IDENTITY_KEY_BYTES) { - throw new Error('cloud_session_identity_too_large') - } - return key + return `${identity.localProfileId}\0${identity.cloudUserId}\0${identity.cloudProfileId}\0${identity.organizationId}` } function statePath(profileId: string, userDataPath: string): string { @@ -63,12 +54,7 @@ function readState(profileId: string, userDataPath: string): CloudSessionMutatio return null } try { - const parsed: unknown = JSON.parse( - readNodeFileSyncWithinLimit( - path, - MAX_CLOUD_SESSION_MUTATION_STATE_FILE_BYTES - ).buffer.toString('utf8') - ) + const parsed: unknown = JSON.parse(readFileSync(path, 'utf-8')) if (!isState(parsed)) { throw new Error('invalid_cloud_session_mutation_state') } @@ -83,22 +69,7 @@ function saveState( userDataPath: string, state: CloudSessionMutationState ): void { - const tombstonedIdentityKeys = state.tombstonedIdentityKeys.slice(-MAX_CLOUD_SESSION_TOMBSTONES) - while (true) { - try { - writeSecureJsonFileWithinLimit( - statePath(profileId, userDataPath), - { ...state, tombstonedIdentityKeys }, - MAX_CLOUD_SESSION_MUTATION_STATE_FILE_BYTES - ) - return - } catch (error) { - if (!(error instanceof JsonStringifyByteLimitError) || tombstonedIdentityKeys.length === 0) { - throw error - } - tombstonedIdentityKeys.shift() - } - } + writeSecureJsonFile(statePath(profileId, userDataPath), state) } export function cloudSessionIdentity( diff --git a/src/main/orca-profiles/profile-cloud-session-store.test.ts b/src/main/orca-profiles/profile-cloud-session-store.test.ts index 47c88827d76..ca32f0529f1 100644 --- a/src/main/orca-profiles/profile-cloud-session-store.test.ts +++ b/src/main/orca-profiles/profile-cloud-session-store.test.ts @@ -1,13 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { - existsSync, - mkdirSync, - mkdtempSync, - readFileSync, - rmSync, - truncateSync, - writeFileSync -} from 'node:fs' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import type { OrcaCloudSession } from './profile-cloud-session-store' @@ -177,102 +169,4 @@ describe('Orca cloud session store', () => { error: 'Unsafe session format.' }) }) - - it('rejects an oversized sparse encrypted-session wrapper before decrypting', async () => { - writePlaintextSessionFile('profile-1', makeSession()) - const store = await loadSessionStore() - truncateSync( - store.getOrcaCloudSessionPath('profile-1', userDataPath), - store.MAX_ORCA_CLOUD_SESSION_FILE_BYTES + 1 - ) - - expect(store.readOrcaCloudSession('profile-1', userDataPath)).toEqual({ - status: 'decrypt-failed', - persistence: 'none', - error: 'Could not decrypt saved Orca account session.' - }) - expect(safeStorageMock.decryptString).not.toHaveBeenCalled() - }) - - it('rejects an oversized session without replacing the prior durable or cached session', async () => { - const store = await loadSessionStore() - const session = makeSession() - store.saveOrcaCloudSession('profile-1', userDataPath, session) - const path = store.getOrcaCloudSessionPath('profile-1', userDataPath) - const durableBefore = readFileSync(path, 'utf-8') - - expect(() => - store.saveOrcaCloudSession('profile-1', userDataPath, { - ...session, - accessToken: 'x'.repeat(store.MAX_ORCA_CLOUD_SESSION_PAYLOAD_BYTES) - }) - ).toThrow(`JSON output exceeds ${store.MAX_ORCA_CLOUD_SESSION_PAYLOAD_BYTES} bytes`) - expect(readFileSync(path, 'utf-8')).toBe(durableBefore) - expect(store.readOrcaCloudSession('profile-1', userDataPath)).toEqual({ - status: 'found', - session, - persistence: 'encrypted' - }) - }) - - it('removes stale durable credentials when encryption expansion exceeds the file limit', async () => { - const store = await loadSessionStore() - const prior = makeSession() - store.saveOrcaCloudSession('profile-1', userDataPath, prior) - const session = { - ...prior, - accessToken: 'x'.repeat(Math.floor(store.MAX_ORCA_CLOUD_SESSION_FILE_BYTES * 0.8)) - } - - expect(store.saveOrcaCloudSession('profile-1', userDataPath, session)).toBe('memory-only') - expect(existsSync(store.getOrcaCloudSessionPath('profile-1', userDataPath))).toBe(false) - expect(store.readOrcaCloudSession('profile-1', userDataPath)).toEqual({ - status: 'found', - session, - persistence: 'memory-only' - }) - }) - - it('evicts the least-recently-used memory-only session at the entry limit', async () => { - safeStorageMock.isEncryptionAvailable.mockReturnValue(false) - const store = await loadSessionStore() - const session = makeSession() - - for (let index = 0; index <= store.MAX_ORCA_CLOUD_MEMORY_SESSIONS; index += 1) { - store.saveOrcaCloudSession(`profile-${index}`, userDataPath, session) - } - - expect(store.readOrcaCloudSession('profile-0', userDataPath)).toEqual({ - status: 'missing', - persistence: 'none' - }) - expect( - store.readOrcaCloudSession(`profile-${store.MAX_ORCA_CLOUD_MEMORY_SESSIONS}`, userDataPath) - ).toMatchObject({ status: 'found', session }) - }) - - it('evicts memory-only sessions before exceeding the aggregate byte limit', async () => { - safeStorageMock.isEncryptionAvailable.mockReturnValue(false) - const store = await loadSessionStore() - const session = { - ...makeSession(), - accessToken: 'x'.repeat(store.MAX_ORCA_CLOUD_SESSION_PAYLOAD_BYTES - 1024) - } - const writesNeeded = - Math.floor( - store.MAX_ORCA_CLOUD_MEMORY_SESSION_BYTES / store.MAX_ORCA_CLOUD_SESSION_PAYLOAD_BYTES - ) + 2 - - for (let index = 0; index < writesNeeded; index += 1) { - store.saveOrcaCloudSession(`profile-${index}`, userDataPath, session) - } - - expect(store.readOrcaCloudSession('profile-0', userDataPath)).toMatchObject({ - status: 'missing' - }) - expect(store.readOrcaCloudSession(`profile-${writesNeeded - 1}`, userDataPath)).toMatchObject({ - status: 'found', - session - }) - }) }) diff --git a/src/main/orca-profiles/profile-cloud-session-store.ts b/src/main/orca-profiles/profile-cloud-session-store.ts index c8803c6390c..62d779772c1 100644 --- a/src/main/orca-profiles/profile-cloud-session-store.ts +++ b/src/main/orca-profiles/profile-cloud-session-store.ts @@ -1,12 +1,7 @@ -import { existsSync, rmSync } from 'node:fs' +import { existsSync, readFileSync, rmSync } from 'node:fs' import { join } from 'node:path' import { safeStorage } from 'electron' -import { readNodeFileSyncWithinLimit } from '../../shared/node-bounded-file-reader' -import { - JsonStringifyByteLimitError, - stringifyJsonWithinByteLimit -} from '../../shared/node-bounded-json-stringify' -import { writeSecureJsonFileWithinLimit } from '../../shared/bounded-secure-json-file' +import { writeSecureJsonFile } from '../../shared/secure-file' import type { OrcaCloudCapabilities, OrcaCloudOrgSummary, @@ -52,54 +47,14 @@ type PersistedPlaintextSession = { type CachedOrcaCloudSession = { session: OrcaCloudSession persistence: Exclude - bytes: number } const memorySessions = new Map() -export const MAX_ORCA_CLOUD_SESSION_FILE_BYTES = 1024 * 1024 -export const MAX_ORCA_CLOUD_SESSION_PAYLOAD_BYTES = MAX_ORCA_CLOUD_SESSION_FILE_BYTES -export const MAX_ORCA_CLOUD_MEMORY_SESSIONS = 128 -export const MAX_ORCA_CLOUD_MEMORY_SESSION_BYTES = 16 * 1024 * 1024 -let memorySessionBytes = 0 function sessionCacheKey(profileId: string, userDataPath: string): string { return `${userDataPath}\0${profileId}` } -function rememberMemorySession( - cacheKey: string, - session: OrcaCloudSession, - persistence: Exclude, - bytes: number -): void { - const previous = memorySessions.get(cacheKey) - if (previous) { - memorySessionBytes -= previous.bytes - memorySessions.delete(cacheKey) - } - while ( - memorySessions.size >= MAX_ORCA_CLOUD_MEMORY_SESSIONS || - memorySessionBytes + bytes > MAX_ORCA_CLOUD_MEMORY_SESSION_BYTES - ) { - const oldestKey = memorySessions.keys().next().value as string | undefined - if (oldestKey === undefined) { - break - } - const oldest = memorySessions.get(oldestKey) - memorySessions.delete(oldestKey) - memorySessionBytes -= oldest?.bytes ?? 0 - } - memorySessions.set(cacheKey, { session, persistence, bytes }) - memorySessionBytes += bytes -} - -function serializedSessionWithinLimit( - session: OrcaCloudSession, - maxBytes = MAX_ORCA_CLOUD_SESSION_PAYLOAD_BYTES -): { serialized: string; byteLength: number } { - return stringifyJsonWithinByteLimit(session, maxBytes) -} - function isObject(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } @@ -151,29 +106,15 @@ export function saveOrcaCloudSession( session: OrcaCloudSession ): OrcaCloudSessionPersistence { const cacheKey = sessionCacheKey(profileId, userDataPath) - const payload = serializedSessionWithinLimit(session) if (safeStorage.isEncryptionAvailable()) { const encrypted: PersistedEncryptedSession = { version: 1, format: 'electron-safe-storage-v1', savedAt: Date.now(), - ciphertext: safeStorage.encryptString(payload.serialized).toString('base64') + ciphertext: safeStorage.encryptString(JSON.stringify(session)).toString('base64') } - try { - writeSecureJsonFileWithinLimit( - getOrcaCloudSessionPath(profileId, userDataPath), - encrypted, - MAX_ORCA_CLOUD_SESSION_FILE_BYTES - ) - } catch (error) { - if (!(error instanceof JsonStringifyByteLimitError)) { - throw error - } - rmSync(getOrcaCloudSessionPath(profileId, userDataPath), { force: true }) - rememberMemorySession(cacheKey, session, 'memory-only', payload.byteLength) - return 'memory-only' - } - rememberMemorySession(cacheKey, session, 'encrypted', payload.byteLength) + writeSecureJsonFile(getOrcaCloudSessionPath(profileId, userDataPath), encrypted) + memorySessions.set(cacheKey, { session, persistence: 'encrypted' }) return 'encrypted' } @@ -184,27 +125,14 @@ export function saveOrcaCloudSession( savedAt: Date.now(), session } - try { - writeSecureJsonFileWithinLimit( - getOrcaCloudSessionPath(profileId, userDataPath), - plaintext, - MAX_ORCA_CLOUD_SESSION_FILE_BYTES - ) - } catch (error) { - if (!(error instanceof JsonStringifyByteLimitError)) { - throw error - } - rmSync(getOrcaCloudSessionPath(profileId, userDataPath), { force: true }) - rememberMemorySession(cacheKey, session, 'memory-only', payload.byteLength) - return 'memory-only' - } - rememberMemorySession(cacheKey, session, 'dev-plaintext', payload.byteLength) + writeSecureJsonFile(getOrcaCloudSessionPath(profileId, userDataPath), plaintext) + memorySessions.set(cacheKey, { session, persistence: 'dev-plaintext' }) return 'dev-plaintext' } // Why: Orca account refresh tokens must not silently fall back to plaintext // in production. Memory-only keeps cloud features usable until restart. - rememberMemorySession(cacheKey, session, 'memory-only', payload.byteLength) + memorySessions.set(cacheKey, { session, persistence: 'memory-only' }) return 'memory-only' } @@ -244,8 +172,6 @@ export function readOrcaCloudSession( const cacheKey = sessionCacheKey(profileId, userDataPath) const memorySession = memorySessions.get(cacheKey) if (memorySession) { - memorySessions.delete(cacheKey) - memorySessions.set(cacheKey, memorySession) return { status: 'found', session: memorySession.session, @@ -259,9 +185,9 @@ export function readOrcaCloudSession( } try { - const parsed = JSON.parse( - readNodeFileSyncWithinLimit(path, MAX_ORCA_CLOUD_SESSION_FILE_BYTES).buffer.toString('utf8') - ) as PersistedEncryptedSession | PersistedPlaintextSession + const parsed = JSON.parse(readFileSync(path, 'utf-8')) as + | PersistedEncryptedSession + | PersistedPlaintextSession if (parsed.version !== 1) { return { status: 'decrypt-failed', persistence: 'none', error: 'Unsupported session format.' } } @@ -278,19 +204,14 @@ export function readOrcaCloudSession( if (!isOrcaCloudSession(session)) { return { status: 'decrypt-failed', persistence: 'none', error: 'Invalid saved session.' } } - const payload = serializedSessionWithinLimit(session, MAX_ORCA_CLOUD_SESSION_FILE_BYTES) - rememberMemorySession(cacheKey, session, 'encrypted', payload.byteLength) + memorySessions.set(cacheKey, { session, persistence: 'encrypted' }) return { status: 'found', session, persistence: 'encrypted' } } if (parsed.format === 'dev-plaintext-v1' && allowsPlaintextOrcaCloudSession()) { if (!isOrcaCloudSession(parsed.session)) { return { status: 'decrypt-failed', persistence: 'none', error: 'Invalid saved session.' } } - const payload = serializedSessionWithinLimit( - parsed.session, - MAX_ORCA_CLOUD_SESSION_FILE_BYTES - ) - rememberMemorySession(cacheKey, parsed.session, 'dev-plaintext', payload.byteLength) + memorySessions.set(cacheKey, { session: parsed.session, persistence: 'dev-plaintext' }) return { status: 'found', session: parsed.session, persistence: 'dev-plaintext' } } return { status: 'decrypt-failed', persistence: 'none', error: 'Unsafe session format.' } @@ -304,11 +225,6 @@ export function readOrcaCloudSession( } export function clearOrcaCloudSession(profileId: string, userDataPath: string): void { - const cacheKey = sessionCacheKey(profileId, userDataPath) - const cached = memorySessions.get(cacheKey) - if (cached) { - memorySessionBytes -= cached.bytes - memorySessions.delete(cacheKey) - } + memorySessions.delete(sessionCacheKey(profileId, userDataPath)) rmSync(getOrcaCloudSessionPath(profileId, userDataPath), { force: true }) } diff --git a/src/main/orca-profiles/profile-index-store.test.ts b/src/main/orca-profiles/profile-index-store.test.ts index cf9aa5f8b59..a2d3446a99c 100644 --- a/src/main/orca-profiles/profile-index-store.test.ts +++ b/src/main/orca-profiles/profile-index-store.test.ts @@ -1,13 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { - existsSync, - mkdirSync, - mkdtempSync, - readFileSync, - rmSync, - truncateSync, - writeFileSync -} from 'node:fs' +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' import { @@ -178,40 +170,6 @@ describe('profile index store', () => { expect(recovered.profiles.length).toBeGreaterThanOrEqual(2) }) - it('recovers an oversized sparse profile index from the backup copy', async () => { - const store = await loadProfileIndexStore() - store.ensureActiveOrcaProfile() - const created = store.createLocalOrcaProfile({ name: 'Work' }) - store.setActiveOrcaProfile(created.profile.id) - const indexPath = store.getOrcaProfileIndexPath() - truncateSync(indexPath, store.MAX_ORCA_PROFILE_INDEX_FILE_BYTES + 1) - - expect(store.getOrcaProfileListState().profiles.map((profile) => profile.id)).toContain( - created.profile.id - ) - }) - - it('rejects an oversized profile index without changing the primary or backup', async () => { - const store = await loadProfileIndexStore() - store.ensureActiveOrcaProfile() - store.createLocalOrcaProfile({ name: 'Work' }) - const indexPath = store.getOrcaProfileIndexPath() - const primaryBefore = readFileSync(indexPath, 'utf-8') - const backupBefore = readFileSync(`${indexPath}.bak`, 'utf-8') - const oversized = store.readProfileIndex(indexPath)! - oversized.profiles[0] = { - ...oversized.profiles[0], - name: 'x'.repeat(store.MAX_ORCA_PROFILE_INDEX_FILE_BYTES) - } - - expect(() => store.writeProfileIndex(indexPath, oversized)).toThrow( - `JSON output exceeds ${store.MAX_ORCA_PROFILE_INDEX_FILE_BYTES} bytes` - ) - expect(readFileSync(indexPath, 'utf-8')).toBe(primaryBefore) - expect(readFileSync(`${indexPath}.bak`, 'utf-8')).toBe(backupBefore) - expect(existsSync(`${indexPath}.tmp`)).toBe(false) - }) - it('rejects profile ids that are not safe path segments', async () => { const store = await loadProfileIndexStore() const indexPath = store.getOrcaProfileIndexPath() diff --git a/src/main/orca-profiles/profile-index-store.ts b/src/main/orca-profiles/profile-index-store.ts index 366db309fc9..903e7a85ff8 100644 --- a/src/main/orca-profiles/profile-index-store.ts +++ b/src/main/orca-profiles/profile-index-store.ts @@ -1,8 +1,13 @@ -import { copyFileSync, existsSync, mkdirSync, renameSync, writeFileSync } from 'node:fs' +import { + copyFileSync, + existsSync, + mkdirSync, + readFileSync, + renameSync, + writeFileSync +} from 'node:fs' import { randomUUID } from 'node:crypto' import { dirname } from 'node:path' -import { readNodeFileSyncWithinLimit } from '../../shared/node-bounded-file-reader' -import { stringifyJsonWithinByteLimit } from '../../shared/node-bounded-json-stringify' import type { GlobalSettings } from '../../shared/types' import { createDefaultLocalOrcaProfile, @@ -44,8 +49,6 @@ export type ActiveOrcaProfileState = { profileDirectory: string } -export const MAX_ORCA_PROFILE_INDEX_FILE_BYTES = 1024 * 1024 - function isObject(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } @@ -102,13 +105,7 @@ function sanitizeProfileName(value: unknown): string { function readProfileIndexFile(indexPath: string): OrcaProfileIndex | null { try { - return normalizeProfileIndex( - JSON.parse( - readNodeFileSyncWithinLimit(indexPath, MAX_ORCA_PROFILE_INDEX_FILE_BYTES).buffer.toString( - 'utf8' - ) - ) - ) + return normalizeProfileIndex(JSON.parse(readFileSync(indexPath, 'utf-8'))) } catch { return null } @@ -121,10 +118,6 @@ export function readProfileIndex(indexPath: string): OrcaProfileIndex | null { } export function writeProfileIndex(indexPath: string, index: OrcaProfileIndex): void { - const serialized = stringifyJsonWithinByteLimit( - index, - MAX_ORCA_PROFILE_INDEX_FILE_BYTES - ).serialized mkdirSync(dirname(indexPath), { recursive: true }) // Why: only a still-parseable current index may refresh the backup; // copying a corrupt file over the backup would destroy the recovery copy. @@ -136,7 +129,7 @@ export function writeProfileIndex(indexPath: string, index: OrcaProfileIndex): v } } const tmpPath = `${indexPath}.tmp` - writeFileSync(tmpPath, serialized, 'utf-8') + writeFileSync(tmpPath, JSON.stringify(index, null, 2), 'utf-8') renameSync(tmpPath, indexPath) } diff --git a/src/main/orca-profiles/profile-project-state-file-bounds.test.ts b/src/main/orca-profiles/profile-project-state-file-bounds.test.ts deleted file mode 100644 index ffe06889259..00000000000 --- a/src/main/orca-profiles/profile-project-state-file-bounds.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { - mkdirSync, - mkdtempSync, - readFileSync, - readdirSync, - rmSync, - truncateSync, - writeFileSync -} from 'node:fs' -import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { getDefaultPersistedState } from '../../shared/constants' -import { NodeFileReadTooLargeError } from '../../shared/node-bounded-file-reader' -import { JsonStringifyByteLimitError } from '../../shared/node-bounded-json-stringify' -import { getOrcaProfileDataFile } from './profile-index-store' -import { readProfileState, writeProfileState } from './profile-project-state-file' - -vi.mock('electron', () => ({ - app: { - getPath: () => '' - } -})) - -describe('profile project state file bounds', () => { - let userDataPath = '' - const profileId = 'work' - - beforeEach(() => { - userDataPath = mkdtempSync(join(tmpdir(), 'orca-profile-state-bounds-')) - }) - - afterEach(() => { - rmSync(userDataPath, { recursive: true, force: true }) - }) - - it('round-trips ordinary state with compact JSON semantics unchanged', () => { - const state = getDefaultPersistedState('/Users/tester') - state.settings.theme = 'dark' - const native = JSON.stringify(state) - - writeProfileState(profileId, userDataPath, state, Buffer.byteLength(native)) - - expect(readFileSync(getOrcaProfileDataFile(profileId, userDataPath), 'utf8')).toBe(native) - expect(readProfileState(profileId, userDataPath).settings.theme).toBe('dark') - }) - - it('rejects a profile read one byte over its limit before parsing', () => { - const dataFile = getOrcaProfileDataFile(profileId, userDataPath) - mkdirSync(dirname(dataFile), { recursive: true }) - writeFileSync(dataFile, '') - truncateSync(dataFile, 1025) - - expect(() => readProfileState(profileId, userDataPath, 1024)).toThrow(NodeFileReadTooLargeError) - }) - - it('leaves the prior profile and no temp file when output exceeds its limit', () => { - const state = getDefaultPersistedState('/Users/tester') - writeProfileState(profileId, userDataPath, state) - const dataFile = getOrcaProfileDataFile(profileId, userDataPath) - const prior = readFileSync(dataFile) - state.settings.theme = 'light' - const nextBytes = Buffer.byteLength(JSON.stringify(state)) - - expect(() => writeProfileState(profileId, userDataPath, state, nextBytes - 1)).toThrow( - JsonStringifyByteLimitError - ) - expect(readFileSync(dataFile)).toEqual(prior) - expect(readdirSync(dirname(dataFile)).filter((name) => name.endsWith('.tmp'))).toEqual([]) - }) -}) diff --git a/src/main/orca-profiles/profile-project-state-file.ts b/src/main/orca-profiles/profile-project-state-file.ts index b26bd123822..4169fea6ffc 100644 --- a/src/main/orca-profiles/profile-project-state-file.ts +++ b/src/main/orca-profiles/profile-project-state-file.ts @@ -1,5 +1,5 @@ import { randomUUID } from 'node:crypto' -import { existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs' import { homedir } from 'node:os' import { dirname } from 'node:path' import { getDefaultPersistedState, getDefaultWorkspaceSession } from '../../shared/constants' @@ -13,10 +13,6 @@ import type { SparsePreset, WorkspaceSessionState } from '../../shared/types' -import { - readPersistedStateJsonFileSync, - stringifyPersistedStateWithinLimit -} from '../../shared/persisted-state-file-bounds' import { getOrcaProfileDataFile } from './profile-index-store' export type TransferProfileState = PersistedState @@ -33,20 +29,13 @@ function recordOrEmpty(value: unknown): Record { return isRecord(value) ? (value as Record) : {} } -export function readProfileState( - profileId: string, - userDataPath: string, - maxBytes?: number -): TransferProfileState { +export function readProfileState(profileId: string, userDataPath: string): TransferProfileState { const defaults = getDefaultPersistedState(homedir()) const dataFile = getOrcaProfileDataFile(profileId, userDataPath) if (!existsSync(dataFile)) { return structuredClone(defaults) } - const { value: parsed } = readPersistedStateJsonFileSync>( - dataFile, - maxBytes - ) + const parsed = JSON.parse(readFileSync(dataFile, 'utf-8')) as Partial return rebuildRepoBackedProjectState({ ...defaults, ...parsed, @@ -95,20 +84,13 @@ export function readProfileState( export function writeProfileState( profileId: string, userDataPath: string, - state: TransferProfileState, - maxBytes?: number + state: TransferProfileState ): void { const dataFile = getOrcaProfileDataFile(profileId, userDataPath) mkdirSync(dirname(dataFile), { recursive: true }) const tmpPath = `${dataFile}.${process.pid}.${randomUUID()}.tmp` - const { serialized } = stringifyPersistedStateWithinLimit(state, maxBytes) - try { - writeFileSync(tmpPath, serialized, 'utf-8') - renameSync(tmpPath, dataFile) - } catch (error) { - rmSync(tmpPath, { force: true }) - throw error - } + writeFileSync(tmpPath, JSON.stringify(state, null, 2), 'utf-8') + renameSync(tmpPath, dataFile) } function isRepoBackedProjectHostSetup( diff --git a/src/main/persistence-state-bounds.test.ts b/src/main/persistence-state-bounds.test.ts deleted file mode 100644 index 98944036fcd..00000000000 --- a/src/main/persistence-state-bounds.test.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { mkdtempSync, readFileSync, rmSync, statSync, truncateSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { - ORCA_PERSISTED_STATE_MAX_BYTES, - ORCA_PERSISTED_STATE_SECRET_MAX_BYTES -} from '../shared/persisted-state-file-bounds' - -const testState = { dir: '' } - -vi.mock('electron', () => ({ - app: { - getPath: () => testState.dir - }, - safeStorage: { - isEncryptionAvailable: () => true, - encryptString: (plaintext: string) => Buffer.from(`encrypted:${plaintext}`, 'utf8'), - decryptString: (ciphertext: Buffer) => ciphertext.toString('utf8').replace(/^encrypted:/, '') - } -})) - -vi.mock('./ssh/ssh-config-parser', () => ({ - loadUserSshConfig: vi.fn(), - sshConfigHostsToTargets: vi.fn() -})) - -vi.mock('./telemetry/client', () => ({ - track: vi.fn() -})) - -vi.mock('./telemetry/cohort-classifier', () => ({ - getCohortAtEmit: vi.fn().mockReturnValue({ nth_repo_added: 0 }) -})) - -describe('Store persisted-state bounds', () => { - beforeEach(() => { - testState.dir = mkdtempSync(join(tmpdir(), 'orca-persistence-bounds-')) - }) - - afterEach(() => { - vi.restoreAllMocks() - rmSync(testState.dir, { recursive: true, force: true }) - }) - - function dataFile(): string { - return join(testState.dir, 'orca-data.json') - } - - function backupFile(index: number): string { - return `${dataFile()}.bak.${index}` - } - - function writeMinimalState(path: string, theme: 'dark' | 'light'): string { - const serialized = JSON.stringify({ - schemaVersion: 1, - repos: [], - worktreeMeta: {}, - settings: { theme }, - ui: {}, - githubCache: { pr: {}, issue: {} }, - workspaceSession: {} - }) - writeFileSync(path, serialized) - return serialized - } - - async function createStore() { - vi.resetModules() - const { Store, initDataPath } = await import('./persistence') - initDataPath() - return new Store() - } - - it('recovers atomically from an in-limit backup when the primary is oversized', async () => { - writeFileSync(dataFile(), '') - truncateSync(dataFile(), ORCA_PERSISTED_STATE_MAX_BYTES + 1) - const backup = writeMinimalState(backupFile(0), 'dark') - - const store = await createStore() - - expect(store.getSettings().theme).toBe('dark') - expect(readFileSync(dataFile(), 'utf8')).toBe(backup) - store.updateSettings({ theme: 'light' }) - store.flushOrThrow() - expect(JSON.parse(readFileSync(dataFile(), 'utf8')).settings.theme).toBe('light') - }) - - it('skips an oversized backup slot and recovers from the next bounded slot', async () => { - writeFileSync(dataFile(), '{{corrupt') - writeFileSync(backupFile(0), '') - truncateSync(backupFile(0), ORCA_PERSISTED_STATE_MAX_BYTES + 1) - writeMinimalState(backupFile(1), 'light') - - const store = await createStore() - - expect(store.getSettings().theme).toBe('light') - }) - - it('uses defaults but freezes writes when no bounded recovery source exists', async () => { - vi.useFakeTimers() - try { - writeFileSync(dataFile(), '') - truncateSync(dataFile(), ORCA_PERSISTED_STATE_MAX_BYTES + 1) - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) - - const store = await createStore() - store.updateSettings({ theme: 'dark' }) - await vi.advanceTimersByTimeAsync(2000) - await store.waitForPendingWrite() - store.flushOrThrow() - - expect(statSync(dataFile()).size).toBe(ORCA_PERSISTED_STATE_MAX_BYTES + 1) - expect(errorSpy).toHaveBeenCalledWith( - '[persistence] State exceeds the safe load limit; using defaults with state writes frozen' - ) - } finally { - vi.useRealTimers() - } - }) - - it('keeps the last valid file when a secret makes a sync flush exceed its bound', async () => { - const store = await createStore() - store.flushOrThrow() - const validState = readFileSync(dataFile()) - store.updateSettings({ - opencodeSessionCookie: 'x'.repeat(ORCA_PERSISTED_STATE_SECRET_MAX_BYTES + 1) - }) - - expect(() => store.flushOrThrow()).toThrow('Persisted state secret exceeds') - expect(readFileSync(dataFile())).toEqual(validState) - }) - - it('keeps the last valid file when an asynchronous bounded write is rejected', async () => { - vi.useFakeTimers() - try { - const store = await createStore() - store.flushOrThrow() - const validState = readFileSync(dataFile()) - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) - store.updateSettings({ - opencodeSessionCookie: 'x'.repeat(ORCA_PERSISTED_STATE_SECRET_MAX_BYTES + 1) - }) - - await vi.advanceTimersByTimeAsync(2000) - await store.waitForPendingWrite() - - expect(readFileSync(dataFile())).toEqual(validState) - expect(errorSpy).toHaveBeenCalledWith( - '[persistence] Failed to write state:', - expect.objectContaining({ name: 'PersistedStateSecretCapacityError' }) - ) - } finally { - vi.useRealTimers() - } - }) -}) diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 2a897ee18f6..8e0532a6753 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -9,8 +9,7 @@ import { existsSync, realpathSync, statSync, - symlinkSync, - truncateSync + symlinkSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' @@ -39,7 +38,6 @@ import { import { folderWorkspaceKey, worktreeWorkspaceKey } from '../shared/workspace-scope' import { toRuntimeExecutionHostId, toSshExecutionHostId } from '../shared/execution-host' import { SshConnectionStore } from './ssh/ssh-connection-store' -import { ORCA_PERSISTED_STATE_MAX_BYTES } from '../shared/persisted-state-file-bounds' import { setSourceControlActionDefault } from '../shared/source-control-ai-actions' import { LEGACY_DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS } from '../shared/ssh-types' import { closeTerminalTabInWorkspaceSession } from '../shared/workspace-session-terminal-tab-close' @@ -5493,16 +5491,6 @@ describe('Store', () => { expect(restarted.getGitHubCache().pr['o/r#7']).toEqual({ fetchedAt: 7 }) }) - it('ignores an oversized sparse GitHub cache sidecar', async () => { - const cacheFile = join(testState.dir, 'orca-github-cache.json') - writeFileSync(cacheFile, '') - truncateSync(cacheFile, ORCA_PERSISTED_STATE_MAX_BYTES + 1) - - const store = await createStore() - - expect(store.getGitHubCache()).toEqual({ pr: {}, issue: {} }) - }) - it('keeps GitHub cache sidecars scoped to explicit profile data files', async () => { const profileADir = join(testState.dir, 'profiles', 'a') const profileBDir = join(testState.dir, 'profiles', 'b') diff --git a/src/main/persistence.ts b/src/main/persistence.ts index a480a9a82e6..0b070b79083 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -1,6 +1,7 @@ /* eslint-disable max-lines -- Why: persistence keeps schema defaults, migration, and load/save/flush in one file so the storage contract reviews as a unit. */ import { app, safeStorage } from 'electron' import { + readFileSync, writeFileSync, mkdirSync, existsSync, @@ -244,19 +245,6 @@ import { import { track } from './telemetry/client' import { getCohortAtEmit } from './telemetry/cohort-classifier' import { isStartupDiagnosticsEnabled, logStartupDiagnostic } from './startup/startup-diagnostics' -import { - assertPersistedStateSecretWithinLimit, - encodePersistedStateJsonStringContent, - isPersistedStateFileCapacityError, - parsePersistedStateJsonBuffer, - readPersistedStateJsonFileSync, - readPersistedStateFileBytesSync, - replacePersistedStateJsonWithinLimit, - replacedPersistedStateJsonByteLength, - restorePersistedStateBackupSync, - stringifyPersistedStateWithinLimit, - updatePersistedStateHashWithJsonRange -} from '../shared/persisted-state-file-bounds' function encrypt(plaintext: string): string { if (!plaintext || !safeStorage.isEncryptionAvailable()) { @@ -418,7 +406,7 @@ function gcStaleWorktreeMeta(state: PersistedState): number { function readGithubCacheSnapshot(dataFile: string): PersistedState['githubCache'] | null { try { - const { value: parsed } = readPersistedStateJsonFileSync(getGithubCacheFile(dataFile)) + const parsed = JSON.parse(readFileSync(getGithubCacheFile(dataFile), 'utf-8')) as unknown const isPlainRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value) if ( @@ -2580,6 +2568,7 @@ export class Store { private lastWrittenStateHash: string | null = null private firstPendingSaveAt: number | null = null private githubCacheDirty = false + private gitUsernameCache = new Map() private loadNeedsSave = false private settingsChangeListeners = new Set< ( @@ -2768,26 +2757,24 @@ export class Store { } } - private restoreFromBackup(dataFile: string): { - rejectedOversizedBackup: boolean - restored: boolean - } { - let rejectedOversizedBackup = false + private restoreFromBackup(dataFile: string): boolean { for (let i = 0; i < BACKUP_COUNT; i++) { const path = backupPath(dataFile, i) if (!existsSync(path)) { continue } try { - restorePersistedStateBackupSync(path, dataFile) + const raw = readFileSync(path, 'utf-8') + JSON.parse(raw) + mkdirSync(dirname(dataFile), { recursive: true }) + writeFileSync(dataFile, raw, 'utf-8') console.warn(`[persistence] Recovered state from backup slot ${i}: ${path}`) - return { rejectedOversizedBackup, restored: true } + return true } catch (err) { - rejectedOversizedBackup ||= isPersistedStateFileCapacityError(err) console.error(`[persistence] Backup slot ${i} unusable, trying next:`, err) } } - return { rejectedOversizedBackup, restored: false } + return false } private load(allowBackupRecovery = true): PersistedState { @@ -2799,17 +2786,16 @@ export class Store { }) let result: PersistedState | null = null - let rejectedOversizedState = false try { if (fileExistedOnLoad) { const readStartedAt = performance.now() - const { buffer } = readPersistedStateFileBytesSync(dataFile) + const raw = readFileSync(dataFile, 'utf-8') logPersistenceStartupMilestone('persistence-read-done', { - bytes: buffer.byteLength, + bytes: Buffer.byteLength(raw), durationMs: Math.round(performance.now() - readStartedAt) }) logPersistenceStartupMilestone('persistence-json-parse-start') - const parsed = parsePersistedStateJsonBuffer(buffer) + const parsed = JSON.parse(raw) as PersistedState logPersistenceStartupMilestone('persistence-json-parse-done') // Why: secrets are stored encrypted via safeStorage; decrypt at the load boundary so the app sees plaintext. @@ -3353,7 +3339,6 @@ export class Store { } } } catch (err) { - rejectedOversizedState = isPersistedStateFileCapacityError(err) console.error('[persistence] Failed to load primary state, trying backups:', err) } @@ -3367,23 +3352,14 @@ export class Store { } } if (fileExistedOnLoad || hasBackup) { - const recovery = this.restoreFromBackup(dataFile) - if (recovery.restored) { + if (this.restoreFromBackup(dataFile)) { return this.load(false) } - rejectedOversizedState ||= recovery.rejectedOversizedBackup console.error('[persistence] No usable state file or backup found, using defaults') } } if (result === null) { - if (rejectedOversizedState) { - // Why: keep recoverable oversized bytes intact instead of replacing them with fallback defaults. - this.writesFrozen = true - console.error( - '[persistence] State exceeds the safe load limit; using defaults with state writes frozen' - ) - } result = getDefaultPersistedState(homedir()) } @@ -3572,79 +3548,49 @@ export class Store { // on deterministic-IV platforms (macOS/legacy-Linux OSCrypt). A per-slot // random UUID can't occur anywhere else in the serialized state (the user // sets their data before it is minted), so it appears exactly once. - const secretSubs: { sentinel: string; plaintext: string }[] = [] - const replaceSecretWithSentinel = (plaintext: string): string => { - assertPersistedStateSecretWithinLimit(plaintext) + const secretSubs: { sentinel: string; blob: string; plaintext: string }[] = [] + const encryptToSentinel = (plaintext: string): string => { + const blob = encrypt(plaintext) + // Deterministic already (empty secret / safeStorage unavailable / encrypt + // failure): blob === plaintext, so no normalization — and no sentinel, + // which also avoids substituting an empty or plaintext-shaped slot. + if (blob === plaintext) { + return blob + } const sentinel = `orca-secret-slot-${randomUUID()}` - secretSubs.push({ sentinel, plaintext }) + secretSubs.push({ sentinel, blob, plaintext }) return sentinel } - // Why: clone with sentinels so in-memory this.state stays plaintext. + // Why: clone before encrypting secrets so in-memory this.state stays plaintext. const stateToSave = { ...this.getDurableState(), settings: { ...this.state.settings, - opencodeSessionCookie: replaceSecretWithSentinel(this.state.settings.opencodeSessionCookie), - httpProxyUrl: replaceSecretWithSentinel(this.state.settings.httpProxyUrl ?? '') + opencodeSessionCookie: encryptToSentinel(this.state.settings.opencodeSessionCookie), + httpProxyUrl: encryptToSentinel(this.state.settings.httpProxyUrl ?? '') }, ui: { ...this.state.ui, browserKagiSessionLink: this.state.ui.browserKagiSessionLink - ? replaceSecretWithSentinel(this.state.ui.browserKagiSessionLink) + ? encryptToSentinel(this.state.ui.browserKagiSessionLink) : null } } // Why compact: ~20% fewer bytes and less serialize time; all readers JSON.parse so formatting is irrelevant. // One full-state stringify; secret slots currently hold sentinels. - const { serialized, byteLength: serializedBytes } = - stringifyPersistedStateWithinLimit(stateToSave) - const preparedSecretSubs: { - escapedPlaintext: string - escapedSentinel: string - index: number - plaintext: string - }[] = [] - let normalizedBytes = serializedBytes - for (const { sentinel, plaintext } of secretSubs) { - const escapedSentinel = encodePersistedStateJsonStringContent(sentinel) - const escapedPlaintext = encodePersistedStateJsonStringContent(plaintext) - const index = serialized.indexOf(escapedSentinel) - if (index === -1 || serialized.includes(escapedSentinel, index + escapedSentinel.length)) { - throw new Error('Persisted state secret sentinel is missing or ambiguous') - } - normalizedBytes = replacedPersistedStateJsonByteLength({ - currentBytes: normalizedBytes, - search: escapedSentinel, - replacement: escapedPlaintext - }) - preparedSecretSubs.push({ escapedPlaintext, escapedSentinel, index, plaintext }) - } - - const normalizedHash = createHash('sha1') - let normalizedOffset = 0 - for (const { escapedPlaintext, escapedSentinel, index } of preparedSecretSubs.sort( - (left, right) => left.index - right.index - )) { - updatePersistedStateHashWithJsonRange(normalizedHash, serialized, normalizedOffset, index) - updatePersistedStateHashWithJsonRange(normalizedHash, escapedPlaintext) - normalizedOffset = index + escapedSentinel.length - } - updatePersistedStateHashWithJsonRange(normalizedHash, serialized, normalizedOffset) - + const serialized = JSON.stringify(stateToSave) + // Substitute each unique sentinel exactly once: ciphertext for the on-disk + // payload, plaintext for the guard hash. Function-form replacement keeps + // `$` in blob/plaintext inert; both sides read the sentinel as JSON-escaped + // in `serialized`, so each replace is byte-for-byte position-exact. let payload = serialized - let payloadBytes = serializedBytes - for (const { escapedSentinel, plaintext } of preparedSecretSubs) { - const escapedBlob = encodePersistedStateJsonStringContent(encrypt(plaintext)) - const nextPayload = replacePersistedStateJsonWithinLimit({ - serialized: payload, - currentBytes: payloadBytes, - search: escapedSentinel, - replacement: escapedBlob - }) - payload = nextPayload.serialized - payloadBytes = nextPayload.byteLength + let hashInput = serialized + for (const { sentinel, blob, plaintext } of secretSubs) { + const escapedSentinel = JSON.stringify(sentinel).slice(1, -1) + payload = payload.replace(escapedSentinel, () => blob) + hashInput = hashInput.replace(escapedSentinel, () => JSON.stringify(plaintext).slice(1, -1)) } - const stateHash = normalizedHash.digest('hex') + const stateHash = createHash('sha1').update(hashInput).digest('hex') return { payload, stateHash } } @@ -3887,7 +3833,8 @@ export class Store { if (!repo) { return false } - const previous = repo.gitUsername ?? '' + const previous = this.gitUsernameCache.get(repo.path) ?? repo.gitUsername ?? '' + this.gitUsernameCache.set(repo.path, username) if (previous === username) { return false } @@ -4539,7 +4486,9 @@ export class Store { const projectHostSetupMethod = sanitizeRepoProjectHostSetupMethod(rawProjectHostSetupMethod) const forkSyncMode = sanitizeForkSyncMode(rawForkSyncMode) // Why: never spawn git/gh username resolution in hydration — a stuck probe froze Windows startup for minutes (issue #7225); read only cache/persisted value. - const gitUsername = isFolderRepo(repo) ? '' : (repo.gitUsername ?? '') + const gitUsername = isFolderRepo(repo) + ? '' + : (this.gitUsernameCache.get(repo.path) ?? repo.gitUsername ?? '') return { ...repoWithoutIcon, @@ -6557,8 +6506,7 @@ export class Store { const cacheFile = getGithubCacheFile(this.dataFile) const tmpFile = `${cacheFile}.${process.pid}.tmp` try { - const { serialized } = stringifyPersistedStateWithinLimit(this.state.githubCache) - writeFileSync(tmpFile, serialized, 'utf-8') + writeFileSync(tmpFile, JSON.stringify(this.state.githubCache), 'utf-8') renameSync(tmpFile, cacheFile) this.githubCacheDirty = false } catch (err) { diff --git a/src/main/pi/agent-status-extension-source.test.ts b/src/main/pi/agent-status-extension-source.test.ts index 49af5f54fad..fd65d81a6cb 100644 --- a/src/main/pi/agent-status-extension-source.test.ts +++ b/src/main/pi/agent-status-extension-source.test.ts @@ -3,7 +3,6 @@ import { runInNewContext } from 'node:vm' import ts from 'typescript-api' import { describe, expect, it, vi } from 'vitest' -import { GENERATED_NODE_MANAGED_FILE_MAX_BYTES } from '../generated-node-bounded-file-reader' import { getPiAgentStatusExtensionSource } from './agent-status-extension-source' type HookContext = { @@ -29,16 +28,11 @@ type Harness = { spawnMock: ReturnType spawnedChildren: FakeCurlChild[] fsMock: { - closeSync: ReturnType existsSync: ReturnType - openSync: ReturnType - readSync: ReturnType readFileSync: ReturnType - statSync: ReturnType } handlers: Record processEnv: Record - warnMock: ReturnType callHook: (name: string, event?: unknown, context?: HookContext) => Promise // Re-invoke the extension factory in the same process (as Pi does on an // in-process extension reload), swapping in the freshly registered handlers. @@ -68,7 +62,6 @@ function createHarness(args: { argv?: string[] existsSync?: (path: string) => boolean readFileSync?: (path: string, encoding: string) => string - statSync?: (path: string) => { ino: number; mtimeMs: number; size: number } fetchImpl?: (...params: Parameters) => Promise }): Harness { const fetchMock = vi.fn( @@ -91,50 +84,14 @@ function createHarness(args: { return child }) - const readFileSyncMock = vi.fn( - args.readFileSync ?? - ((path: string) => { - throw Object.assign(new Error(`ENOENT: ${path}`), { code: 'ENOENT' }) - }) - ) - const openFiles = new Map() - let nextDescriptor = 10 const fsMock = { existsSync: vi.fn(args.existsSync ?? (() => false)), - readFileSync: readFileSyncMock, - statSync: vi.fn(args.statSync ?? (() => ({ ino: 1, mtimeMs: 1, size: 0 }))), - openSync: vi.fn((path: string) => { - const descriptor = nextDescriptor++ - openFiles.set(descriptor, { - bytes: Buffer.from(String(readFileSyncMock(path, 'utf8')), 'utf8'), - offset: 0 - }) - return descriptor - }), - readSync: vi.fn( - ( - descriptor: number, - target: Buffer, - offset: number, - length: number, - position: number | null - ) => { - const opened = openFiles.get(descriptor) - if (!opened) { - throw new Error(`bad descriptor: ${descriptor}`) - } - const start = position ?? opened.offset - const bytesRead = Math.min(length, Math.max(0, opened.bytes.byteLength - start)) - opened.bytes.copy(target, offset, start, start + bytesRead) - if (position === null) { - opened.offset += bytesRead - } - return bytesRead - } - ), - closeSync: vi.fn((descriptor: number) => { - openFiles.delete(descriptor) - }) + readFileSync: vi.fn( + args.readFileSync ?? + ((path: string) => { + throw Object.assign(new Error(`ENOENT: ${path}`), { code: 'ENOENT' }) + }) + ) } const module = { @@ -160,7 +117,6 @@ function createHarness(args: { argv: args.argv ?? ['node', '/usr/bin/orca'] } - const warnMock = vi.fn() const context = { module, exports: module.exports, @@ -168,7 +124,7 @@ function createHarness(args: { process: processMock, fetch: fetchMock, console: { - warn: warnMock, + warn: vi.fn(), error: vi.fn(), log: vi.fn() }, @@ -212,7 +168,6 @@ function createHarness(args: { fsMock, handlers, processEnv: processMock.env, - warnMock, callHook: async (name, event, hookContext) => { await handlers[name]?.(event, hookContext) }, @@ -226,72 +181,6 @@ function createHarness(args: { } describe('getPiAgentStatusExtensionSource', () => { - it('routes every whole-file runtime probe through the emitted bounded reader', () => { - const source = getPiAgentStatusExtensionSource('pi') - - expect(source).toContain( - `function readOrcaManagedFileWithinLimit(fs: any, path: string, maxBytes = ${GENERATED_NODE_MANAGED_FILE_MAX_BYTES})` - ) - expect(source).not.toContain('readFileSync') - }) - - it('uses an ordinary endpoint file before the stale process environment', async () => { - const endpointPath = '/tmp/orca-endpoint.env' - const endpoint = - 'ORCA_AGENT_HOOK_PORT=9876\nORCA_AGENT_HOOK_TOKEN=fresh-token\nORCA_AGENT_HOOK_ENV=fresh-env\n' - const harness = createHarness({ - kind: 'pi', - env: { ORCA_AGENT_HOOK_ENDPOINT: endpointPath }, - readFileSync: (path) => { - if (path === endpointPath) { - return endpoint - } - throw Object.assign(new Error(`ENOENT: ${path}`), { code: 'ENOENT' }) - }, - statSync: () => ({ ino: 1, mtimeMs: 1, size: Buffer.byteLength(endpoint) }) - }) - - await harness.callHook('agent_start') - - expect(harness.fetchMock).toHaveBeenCalledWith( - 'http://127.0.0.1:9876/hook/pi', - expect.objectContaining({ - headers: expect.objectContaining({ - 'X-Orca-Agent-Hook-Token': 'fresh-token' - }) - }) - ) - }) - - it('fails open to process env when the endpoint file exceeds the generated byte cap', async () => { - const endpointPath = '/tmp/orca-endpoint.env' - const oversized = 'x'.repeat(GENERATED_NODE_MANAGED_FILE_MAX_BYTES + 1) - const harness = createHarness({ - kind: 'pi', - env: { ORCA_AGENT_HOOK_ENDPOINT: endpointPath }, - readFileSync: (path) => { - if (path === endpointPath) { - return oversized - } - throw Object.assign(new Error(`ENOENT: ${path}`), { code: 'ENOENT' }) - }, - statSync: () => ({ ino: 1, mtimeMs: 1, size: oversized.length }) - }) - - await harness.callHook('agent_start') - - expect(harness.fetchMock).toHaveBeenCalledWith( - 'http://127.0.0.1:4321/hook/pi', - expect.objectContaining({ - headers: expect.objectContaining({ - 'X-Orca-Agent-Hook-Token': 'token-1' - }) - }) - ) - expect(harness.warnMock).toHaveBeenCalledOnce() - expect(harness.fsMock.closeSync).toHaveBeenCalledOnce() - }) - it('includes the session id and file path in Pi status posts after session_start', async () => { const harness = createHarness({ kind: 'pi', diff --git a/src/main/pi/agent-status-extension-source.ts b/src/main/pi/agent-status-extension-source.ts index a4f111bb710..881640183de 100644 --- a/src/main/pi/agent-status-extension-source.ts +++ b/src/main/pi/agent-status-extension-source.ts @@ -11,7 +11,6 @@ // keep the source body in plain JS without TS types and avoid pulling pi or // any Orca dep into the pi runtime. import type { PiAgentKind } from '../../shared/pi-agent-kind' -import { getGeneratedNodeBoundedFileReaderSourceLines } from '../generated-node-bounded-file-reader' import { getPiAgentStatusHandlerSourceLines } from './agent-status-handler-source' export const ORCA_PI_AGENT_STATUS_EXTENSION_FILE = 'orca-agent-status.ts' @@ -66,7 +65,6 @@ export function getPiAgentStatusExtensionSource(kind: PiAgentKind = 'pi'): strin '// critical path, and the latest-only pending slot prevents a stalled', '// Orca receiver from building an unbounded queue of obsolete snapshots.', 'const HOOK_POST_TIMEOUT_MS = 1000', - ...getGeneratedNodeBoundedFileReaderSourceLines({ typed: true }), 'let activePost = false', 'let pendingPost: { hookEventName: string; extra: Record } | null = null', ...sessionMetadataSourceLines, @@ -88,7 +86,7 @@ export function getPiAgentStatusExtensionSource(kind: PiAgentKind = 'pi'): strin ' if (cacheKey === cachedEndpointKey && cachedEndpointValues) {', ' return cachedEndpointValues', ' }', - ' const contents = readOrcaManagedFileWithinLimit(fs, path)', + " const contents: string = fs.readFileSync(path, 'utf8')", ' const out: Record = {}', ' for (const line of contents.split(/\\r?\\n/)) {', ' // Why: parse `KEY=VALUE` (POSIX endpoint.env) and `set KEY=VALUE`', @@ -234,7 +232,7 @@ export function getPiAgentStatusExtensionSource(kind: PiAgentKind = 'pi'): strin " const fs = require('fs')", " for (const path of ['/proc/sys/kernel/osrelease', '/proc/version']) {", ' try {', - ' const contents = readOrcaManagedFileWithinLimit(fs, path)', + " const contents = String(fs.readFileSync(path, 'utf8'))", ' if (/microsoft|wsl/i.test(contents)) return true', ' } catch {', ' // Why: probe the next runtime hint when a proc file is absent or unreadable.', diff --git a/src/main/pi/legacy-omp-overlay-migration-bounds.test.ts b/src/main/pi/legacy-omp-overlay-migration-bounds.test.ts deleted file mode 100644 index b709ae437d3..00000000000 --- a/src/main/pi/legacy-omp-overlay-migration-bounds.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { - LEGACY_OMP_OVERLAY_MIGRATION_MAX_DEPTH, - LEGACY_OMP_OVERLAY_MIGRATION_MAX_ENTRIES, - LEGACY_OMP_OVERLAY_MIGRATION_MAX_PATH_BYTES, - LEGACY_OMP_OVERLAY_MIGRATION_MAX_RETAINED_PATH_BYTES, - migrateLegacyOmpOverlayState -} from './legacy-omp-overlay-migration' - -const roots: string[] = [] -const MARKER = '.orca-omp-overlay-migration-complete' - -function tempPair(): { overlay: string; source: string } { - const root = mkdtempSync(join(tmpdir(), 'orca-legacy-overlay-bounds-')) - roots.push(root) - const overlay = join(root, 'overlay') - const source = join(root, 'source') - mkdirSync(overlay) - return { overlay, source } -} - -afterEach(() => { - vi.restoreAllMocks() - for (const root of roots.splice(0)) { - rmSync(root, { recursive: true, force: true }) - } -}) - -describe('legacy OMP overlay migration bounds', () => { - it('publishes finite production traversal and retention limits', () => { - expect(LEGACY_OMP_OVERLAY_MIGRATION_MAX_ENTRIES).toBe(100_000) - expect(LEGACY_OMP_OVERLAY_MIGRATION_MAX_DEPTH).toBe(256) - expect(LEGACY_OMP_OVERLAY_MIGRATION_MAX_PATH_BYTES).toBe(64 * 1_024) - expect(LEGACY_OMP_OVERLAY_MIGRATION_MAX_RETAINED_PATH_BYTES).toBe(16 * 1_024 * 1_024) - }) - - it('marks a migration at the exact entry limit', () => { - const { overlay, source } = tempPair() - writeFileSync(join(overlay, 'one'), '1') - writeFileSync(join(overlay, 'two'), '2') - - migrateLegacyOmpOverlayState(source, overlay, { maxEntries: 2 }) - - expect(readFileSync(join(source, 'one'), 'utf8')).toBe('1') - expect(readFileSync(join(source, 'two'), 'utf8')).toBe('2') - expect(existsSync(join(overlay, MARKER))).toBe(true) - }) - - it('withholds the marker when the next streamed entry exceeds the limit', () => { - const { overlay, source } = tempPair() - writeFileSync(join(overlay, 'one'), '1') - writeFileSync(join(overlay, 'two'), '2') - writeFileSync(join(overlay, 'three'), '3') - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - - migrateLegacyOmpOverlayState(source, overlay, { maxEntries: 2 }) - - expect(existsSync(join(overlay, MARKER))).toBe(false) - expect(existsSync(join(source, 'one'))).toBe(false) - expect(existsSync(join(source, 'two'))).toBe(false) - expect(existsSync(join(source, 'three'))).toBe(false) - expect(warnSpy).toHaveBeenCalledWith( - '[pi-titlebar-extension] failed to migrate legacy OMP overlay state:', - expect.objectContaining({ name: 'LegacyOverlayMigrationCapacityError' }) - ) - }) - - it('accepts the exact path and retained-path byte boundaries', () => { - const exact = tempPair() - const exactOverlayPath = join(exact.overlay, 'state') - const exactTargetPath = join(exact.source, 'state') - writeFileSync(exactOverlayPath, 'state') - const exactPathBytes = Math.max( - Buffer.byteLength(exactOverlayPath, 'utf8'), - Buffer.byteLength(exactTargetPath, 'utf8') - ) - const exactRetainedBytes = Buffer.byteLength(exactTargetPath, 'utf8') - - migrateLegacyOmpOverlayState(exact.source, exact.overlay, { - maxPathBytes: exactPathBytes, - maxRetainedPathBytes: exactRetainedBytes - }) - - expect(readFileSync(exactTargetPath, 'utf8')).toBe('state') - expect(existsSync(join(exact.overlay, MARKER))).toBe(true) - - const rejected = tempPair() - const rejectedOverlayPath = join(rejected.overlay, 'state') - const rejectedTargetPath = join(rejected.source, 'state') - writeFileSync(rejectedOverlayPath, 'state') - const retainedLimit = Buffer.byteLength(rejectedTargetPath, 'utf8') - 1 - - migrateLegacyOmpOverlayState(rejected.source, rejected.overlay, { - maxRetainedPathBytes: retainedLimit - }) - - expect(existsSync(rejectedTargetPath)).toBe(false) - expect(existsSync(join(rejected.overlay, MARKER))).toBe(false) - }) - - it('accepts the exact path depth and rejects the next level', () => { - const accepted = tempPair() - mkdirSync(join(accepted.overlay, 'one', 'two'), { recursive: true }) - writeFileSync(join(accepted.overlay, 'one', 'two', 'leaf'), 'leaf') - - migrateLegacyOmpOverlayState(accepted.source, accepted.overlay, { maxDepth: 3 }) - - expect(readFileSync(join(accepted.source, 'one', 'two', 'leaf'), 'utf8')).toBe('leaf') - expect(existsSync(join(accepted.overlay, MARKER))).toBe(true) - - const rejected = tempPair() - mkdirSync(join(rejected.overlay, 'one', 'two'), { recursive: true }) - writeFileSync(join(rejected.overlay, 'one', 'two', 'leaf'), 'leaf') - - migrateLegacyOmpOverlayState(rejected.source, rejected.overlay, { maxDepth: 2 }) - - expect(existsSync(join(rejected.overlay, MARKER))).toBe(false) - }) -}) diff --git a/src/main/pi/legacy-omp-overlay-migration-budget.ts b/src/main/pi/legacy-omp-overlay-migration-budget.ts deleted file mode 100644 index fe4d1aaf440..00000000000 --- a/src/main/pi/legacy-omp-overlay-migration-budget.ts +++ /dev/null @@ -1,98 +0,0 @@ -export const LEGACY_OMP_OVERLAY_MIGRATION_MAX_ENTRIES = 100_000 -export const LEGACY_OMP_OVERLAY_MIGRATION_MAX_DEPTH = 256 -export const LEGACY_OMP_OVERLAY_MIGRATION_MAX_PATH_BYTES = 64 * 1_024 -export const LEGACY_OMP_OVERLAY_MIGRATION_MAX_RETAINED_PATH_BYTES = 16 * 1_024 * 1_024 - -export type LegacyOverlayMigrationLimits = { - maxDepth: number - maxEntries: number - maxPathBytes: number - maxRetainedPathBytes: number -} - -const DEFAULT_LIMITS: LegacyOverlayMigrationLimits = { - maxDepth: LEGACY_OMP_OVERLAY_MIGRATION_MAX_DEPTH, - maxEntries: LEGACY_OMP_OVERLAY_MIGRATION_MAX_ENTRIES, - maxPathBytes: LEGACY_OMP_OVERLAY_MIGRATION_MAX_PATH_BYTES, - maxRetainedPathBytes: LEGACY_OMP_OVERLAY_MIGRATION_MAX_RETAINED_PATH_BYTES -} - -export class LegacyOverlayMigrationCapacityError extends Error { - constructor(kind: string, observed: number, limit: number) { - super(`Legacy OMP overlay ${kind} exceeded its ${limit} limit (observed ${observed})`) - this.name = 'LegacyOverlayMigrationCapacityError' - } -} - -export class LegacyOverlayMigrationBudget { - private entries = 0 - private retainedPathBytes = 0 - readonly limits: LegacyOverlayMigrationLimits - - constructor(requested?: Partial) { - this.limits = { - maxDepth: resolveLimit(requested?.maxDepth, DEFAULT_LIMITS.maxDepth, 'maxDepth'), - maxEntries: resolveLimit(requested?.maxEntries, DEFAULT_LIMITS.maxEntries, 'maxEntries'), - maxPathBytes: resolveLimit( - requested?.maxPathBytes, - DEFAULT_LIMITS.maxPathBytes, - 'maxPathBytes' - ), - maxRetainedPathBytes: resolveLimit( - requested?.maxRetainedPathBytes, - DEFAULT_LIMITS.maxRetainedPathBytes, - 'maxRetainedPathBytes' - ) - } - } - - visit(depth: number, ...paths: string[]): void { - const nextEntries = this.entries + 1 - if (nextEntries > this.limits.maxEntries) { - throw new LegacyOverlayMigrationCapacityError('entries', nextEntries, this.limits.maxEntries) - } - if (depth > this.limits.maxDepth) { - throw new LegacyOverlayMigrationCapacityError('depth', depth, this.limits.maxDepth) - } - for (const path of paths) { - this.measurePath(path) - } - this.entries = nextEntries - } - - retainPaths(...paths: string[]): number { - const retainedBytes = paths.reduce((total, path) => total + this.measurePath(path), 0) - const nextRetainedBytes = this.retainedPathBytes + retainedBytes - if (nextRetainedBytes > this.limits.maxRetainedPathBytes) { - throw new LegacyOverlayMigrationCapacityError( - 'retained-path-bytes', - nextRetainedBytes, - this.limits.maxRetainedPathBytes - ) - } - this.retainedPathBytes = nextRetainedBytes - return retainedBytes - } - - releasePaths(retainedBytes: number): void { - this.retainedPathBytes = Math.max(0, this.retainedPathBytes - retainedBytes) - } - - private measurePath(path: string): number { - const bytes = Buffer.byteLength(path, 'utf8') - if (bytes > this.limits.maxPathBytes) { - throw new LegacyOverlayMigrationCapacityError('path-bytes', bytes, this.limits.maxPathBytes) - } - return bytes - } -} - -function resolveLimit(requested: number | undefined, maximum: number, name: string): number { - if (requested === undefined) { - return maximum - } - if (!Number.isSafeInteger(requested) || requested < 0) { - throw new RangeError(`${name} must be a non-negative safe integer`) - } - return Math.min(requested, maximum) -} diff --git a/src/main/pi/legacy-omp-overlay-migration.ts b/src/main/pi/legacy-omp-overlay-migration.ts index 2bee66014ca..88baf78ea9f 100644 --- a/src/main/pi/legacy-omp-overlay-migration.ts +++ b/src/main/pi/legacy-omp-overlay-migration.ts @@ -1,11 +1,7 @@ -import { cpSync, lstatSync, mkdirSync, opendirSync, unlinkSync, writeFileSync } from 'node:fs' -import type { Stats } from 'node:fs' +import { cpSync, lstatSync, mkdirSync, readdirSync, unlinkSync, writeFileSync } from 'node:fs' +import type { Dirent, Stats } from 'node:fs' import { dirname, join } from 'node:path' import { ORCA_PI_AGENT_STATUS_EXTENSION_FILE } from './agent-status-extension-source' -import { - LegacyOverlayMigrationBudget, - type LegacyOverlayMigrationLimits -} from './legacy-omp-overlay-migration-budget' import { ORCA_PI_PREFILL_EXTENSION_FILE } from './prefill-extension-source' import { ORCA_PI_EXTENSION_FILE } from './titlebar-extension-source' import { isSafeDescendCandidate } from '../pty/overlay-mirror' @@ -23,27 +19,8 @@ const MANAGED_EXTENSION_FILES = new Set([ type DeferredSidecar = { baseTargetPath: string - overlayPath: string - retainedBytes: number - targetPath: string -} - -export { - LEGACY_OMP_OVERLAY_MIGRATION_MAX_DEPTH, - LEGACY_OMP_OVERLAY_MIGRATION_MAX_ENTRIES, - LEGACY_OMP_OVERLAY_MIGRATION_MAX_PATH_BYTES, - LEGACY_OMP_OVERLAY_MIGRATION_MAX_RETAINED_PATH_BYTES -} from './legacy-omp-overlay-migration-budget' -export type { LegacyOverlayMigrationLimits } from './legacy-omp-overlay-migration-budget' - -function closeDirectory(directory: ReturnType): void { - try { - directory.closeSync() - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ERR_DIR_CLOSED') { - throw error - } - } + entry: Dirent + nextSegments: string[] } function getPathStats(path: string): Stats | undefined { @@ -87,13 +64,11 @@ function shouldSkipLegacyOverlayEntry(pathSegments: string[]): boolean { function copyLegacyFile( overlayPath: string, targetPath: string, - copiedFilePaths: Set, - budget: LegacyOverlayMigrationBudget + copiedFilePaths: Set ): boolean { if (getPathStats(targetPath)) { return true } - const retainedBytes = budget.retainPaths(targetPath) try { mkdirSync(dirname(targetPath), { recursive: true }) cpSync(overlayPath, targetPath, { @@ -104,21 +79,15 @@ function copyLegacyFile( copiedFilePaths.add(targetPath) return true } catch { - budget.releasePaths(retainedBytes) return false } } -function removeCopiedFiles( - paths: string[], - copiedFilePaths: Set, - budget: LegacyOverlayMigrationBudget -): void { +function removeCopiedFiles(paths: string[], copiedFilePaths: Set): void { for (const path of paths) { if (!copiedFilePaths.delete(path)) { continue } - budget.releasePaths(Buffer.byteLength(path, 'utf8')) try { unlinkSync(path) } catch { @@ -128,50 +97,46 @@ function removeCopiedFiles( } function copyDeferredSidecars( - budget: LegacyOverlayMigrationBudget, + overlayDir: string, + sourceAgentDir: string, copiedFilePaths: Set, deferredSidecars: DeferredSidecar[] ): boolean { let completed = true const copiedSidecarsByBase = new Map() - for (const sidecar of deferredSidecars) { - const { baseTargetPath, overlayPath, targetPath } = sidecar - try { - if (!copiedFilePaths.has(baseTargetPath)) { - continue - } + for (const { baseTargetPath, entry, nextSegments } of deferredSidecars) { + if (!copiedFilePaths.has(baseTargetPath)) { + continue + } - const stats = getPathStats(overlayPath) - if (!stats) { - removeCopiedFiles( - [baseTargetPath, ...(copiedSidecarsByBase.get(baseTargetPath) ?? [])], - copiedFilePaths, - budget - ) - completed = false - continue - } - if (stats.isSymbolicLink() || !stats.isFile()) { - continue - } - const wasCopied = copiedFilePaths.has(targetPath) - if (!copyLegacyFile(overlayPath, targetPath, copiedFilePaths, budget)) { - removeCopiedFiles( - [baseTargetPath, ...(copiedSidecarsByBase.get(baseTargetPath) ?? [])], - copiedFilePaths, - budget - ) - completed = false - continue - } - if (!wasCopied && copiedFilePaths.has(targetPath)) { - copiedSidecarsByBase.set(baseTargetPath, [ - ...(copiedSidecarsByBase.get(baseTargetPath) ?? []), - targetPath - ]) - } - } finally { - budget.releasePaths(sidecar.retainedBytes) + const overlayPath = join(overlayDir, entry.name) + const targetPath = join(sourceAgentDir, ...nextSegments) + const stats = getPathStats(overlayPath) + if (!stats) { + removeCopiedFiles( + [baseTargetPath, ...(copiedSidecarsByBase.get(baseTargetPath) ?? [])], + copiedFilePaths + ) + completed = false + continue + } + if (stats.isSymbolicLink() || !stats.isFile()) { + continue + } + const wasCopied = copiedFilePaths.has(targetPath) + if (!copyLegacyFile(overlayPath, targetPath, copiedFilePaths)) { + removeCopiedFiles( + [baseTargetPath, ...(copiedSidecarsByBase.get(baseTargetPath) ?? [])], + copiedFilePaths + ) + completed = false + continue + } + if (!wasCopied && copiedFilePaths.has(targetPath)) { + copiedSidecarsByBase.set(baseTargetPath, [ + ...(copiedSidecarsByBase.get(baseTargetPath) ?? []), + targetPath + ]) } } return completed @@ -180,93 +145,78 @@ function copyDeferredSidecars( function copyMissingLegacyOmpOverlayEntries( overlayDir: string, sourceAgentDir: string, - pathSegments: string[], - copiedFilePaths: Set, - budget: LegacyOverlayMigrationBudget + pathSegments: string[] = [], + copiedFilePaths: Set = new Set() ): boolean { let completed = true - let directory + let entries: Dirent[] try { - directory = opendirSync(overlayDir, { bufferSize: 32 }) + entries = readdirSync(overlayDir, { withFileTypes: true }) } catch { return false } const deferredSidecars: DeferredSidecar[] = [] - try { - while (true) { - const entry = directory.readSync() - if (entry === null) { - break - } - const nextSegments = [...pathSegments, entry.name] - const overlayPath = join(overlayDir, entry.name) - const targetPath = join(sourceAgentDir, ...nextSegments) - budget.visit(nextSegments.length, overlayPath, targetPath) - if (shouldSkipLegacyOverlayEntry(nextSegments)) { - continue - } + for (const entry of entries) { + const nextSegments = [...pathSegments, entry.name] + if (shouldSkipLegacyOverlayEntry(nextSegments)) { + continue + } - const sidecarBaseName = getSqliteSidecarBaseName(entry.name) - if (sidecarBaseName) { - const baseTargetPath = join(sourceAgentDir, ...pathSegments, sidecarBaseName) - deferredSidecars.push({ - baseTargetPath, - overlayPath, - retainedBytes: budget.retainPaths(baseTargetPath, overlayPath, targetPath), - targetPath - }) - continue - } + const sidecarBaseName = getSqliteSidecarBaseName(entry.name) + if (sidecarBaseName) { + deferredSidecars.push({ + baseTargetPath: join(sourceAgentDir, ...pathSegments, sidecarBaseName), + entry, + nextSegments + }) + continue + } - const stats = getPathStats(overlayPath) - if (!stats) { - completed = false + const overlayPath = join(overlayDir, entry.name) + const targetPath = join(sourceAgentDir, ...nextSegments) + const stats = getPathStats(overlayPath) + if (!stats) { + completed = false + continue + } + if (stats.isSymbolicLink()) { + continue + } + if (stats.isDirectory()) { + const targetStats = getPathStats(targetPath) + if (targetStats && !isSafeDescendCandidate(targetStats)) { continue } - if (stats.isSymbolicLink()) { - continue - } - if (stats.isDirectory()) { - const targetStats = getPathStats(targetPath) - if (targetStats && !isSafeDescendCandidate(targetStats)) { + if (!targetStats) { + try { + mkdirSync(targetPath, { recursive: true }) + } catch { + completed = false continue } - if (!targetStats) { - try { - mkdirSync(targetPath, { recursive: true }) - } catch { - completed = false - continue - } - } - completed = - copyMissingLegacyOmpOverlayEntries( - overlayPath, - sourceAgentDir, - nextSegments, - copiedFilePaths, - budget - ) && completed - continue } - if (!stats.isFile()) { - continue - } - completed = copyLegacyFile(overlayPath, targetPath, copiedFilePaths, budget) && completed + completed = + copyMissingLegacyOmpOverlayEntries( + overlayPath, + sourceAgentDir, + nextSegments, + copiedFilePaths + ) && completed + continue } - } finally { - closeDirectory(directory) + if (!stats.isFile()) { + continue + } + completed = copyLegacyFile(overlayPath, targetPath, copiedFilePaths) && completed } - return copyDeferredSidecars(budget, copiedFilePaths, deferredSidecars) && completed + return ( + copyDeferredSidecars(overlayDir, sourceAgentDir, copiedFilePaths, deferredSidecars) && completed + ) } -export function migrateLegacyOmpOverlayState( - sourceAgentDir: string, - overlayDir: string, - limits?: Partial -): void { +export function migrateLegacyOmpOverlayState(sourceAgentDir: string, overlayDir: string): void { // Why: temporary rescue shim for OMP builds from the legacy overlay window. // Remove after 2026-08-07 once affected users have had a full upgrade window. const overlayStats = getPathStats(overlayDir) @@ -277,23 +227,16 @@ export function migrateLegacyOmpOverlayState( if (getPathStats(markerPath)) { return } - const budget = new LegacyOverlayMigrationBudget(limits) - const copiedFilePaths = new Set() try { mkdirSync(sourceAgentDir, { recursive: true }) // Why: some pre-managed-extension builds pointed OMP at this overlay, so // first-login auth/session files can exist only there after an update. - if ( - copyMissingLegacyOmpOverlayEntries(overlayDir, sourceAgentDir, [], copiedFilePaths, budget) - ) { + if (copyMissingLegacyOmpOverlayEntries(overlayDir, sourceAgentDir)) { // Why: legacy source overlays can be large and are intentionally kept // for recovery; mark clean migrations so future OMP spawns stay cheap. writeFileSync(markerPath, 'complete\n') } } catch (error) { - // Why: a capacity stop must not strand a copied SQLite base without a - // deferred sidecar; remove only files this attempt created, then retry later. - removeCopiedFiles([...copiedFilePaths], copiedFilePaths, budget) console.warn('[pi-titlebar-extension] failed to migrate legacy OMP overlay state:', error) } } diff --git a/src/main/pi/managed-extension-ownership.test.ts b/src/main/pi/managed-extension-ownership.test.ts deleted file mode 100644 index 1f14ca77a65..00000000000 --- a/src/main/pi/managed-extension-ownership.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import { - ORCA_MANAGED_PI_EXTENSION_MARKER, - PI_MANAGED_EXTENSION_OWNERSHIP_MAX_BYTES, - isManagedPiExtensionFile -} from './managed-extension-ownership' - -const roots: string[] = [] - -function tempFile(contents: string): string { - const root = mkdtempSync(join(tmpdir(), 'orca-managed-extension-ownership-')) - roots.push(root) - const path = join(root, 'orca-agent-status.ts') - writeFileSync(path, contents) - return path -} - -afterEach(() => { - for (const root of roots.splice(0)) { - rmSync(root, { recursive: true, force: true }) - } -}) - -describe('managed Pi extension ownership', () => { - it('accepts a marked file at the exact byte limit', () => { - const prefix = `// ${ORCA_MANAGED_PI_EXTENSION_MARKER}\n` - const path = tempFile( - prefix + 'x'.repeat(PI_MANAGED_EXTENSION_OWNERSHIP_MAX_BYTES - prefix.length) - ) - - expect(isManagedPiExtensionFile(path)).toBe(true) - }) - - it('treats a marked file one byte over the limit as user-owned', () => { - const prefix = `// ${ORCA_MANAGED_PI_EXTENSION_MARKER}\n` - const path = tempFile( - prefix + 'x'.repeat(PI_MANAGED_EXTENSION_OWNERSHIP_MAX_BYTES - prefix.length + 1) - ) - - expect(isManagedPiExtensionFile(path)).toBe(false) - }) - - it('treats missing and unmarked files as user-owned', () => { - const path = tempFile('user extension') - - expect(isManagedPiExtensionFile(path)).toBe(false) - expect(isManagedPiExtensionFile(`${path}.missing`)).toBe(false) - }) -}) diff --git a/src/main/pi/managed-extension-ownership.ts b/src/main/pi/managed-extension-ownership.ts deleted file mode 100644 index 27a6b93f95d..00000000000 --- a/src/main/pi/managed-extension-ownership.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { readNodeFileSyncWithinLimit } from '../../shared/node-bounded-file-reader' - -export const ORCA_MANAGED_PI_EXTENSION_MARKER = '@orca-managed-pi-extension' -export const PI_MANAGED_EXTENSION_OWNERSHIP_MAX_BYTES = 1_024 * 1_024 - -export function withOrcaManagedPiExtensionMarker(source: string): string { - return source.includes(ORCA_MANAGED_PI_EXTENSION_MARKER) - ? source - : `// ${ORCA_MANAGED_PI_EXTENSION_MARKER}\n${source}` -} - -export function isManagedPiExtensionFile(path: string): boolean { - try { - return readNodeFileSyncWithinLimit(path, PI_MANAGED_EXTENSION_OWNERSHIP_MAX_BYTES) - .buffer.toString('utf8') - .includes(ORCA_MANAGED_PI_EXTENSION_MARKER) - } catch { - // Unreadable or oversized files cannot safely be claimed as Orca-owned. - return false - } -} diff --git a/src/main/pi/titlebar-extension-service.ts b/src/main/pi/titlebar-extension-service.ts index 4fafe42d024..37d030b5e31 100644 --- a/src/main/pi/titlebar-extension-service.ts +++ b/src/main/pi/titlebar-extension-service.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdirSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { homedir } from 'node:os' import { join } from 'node:path' import { app } from 'electron' @@ -18,10 +18,6 @@ import { safeRemoveOverlay } from '../pty/overlay-mirror' import { migrateLegacyOmpOverlayState } from './legacy-omp-overlay-migration' -import { - isManagedPiExtensionFile, - withOrcaManagedPiExtensionMarker -} from './managed-extension-ownership' import type { PiAgentKind } from '../../shared/pi-agent-kind' // Why: the Pi test suite imports `isSafeDescendCandidate` from this module's @@ -31,6 +27,7 @@ import type { PiAgentKind } from '../../shared/pi-agent-kind' export const isSafeDescendCandidate = sharedIsSafeDescendCandidate const PI_AGENT_SUBDIR = 'agent' +const ORCA_MANAGED_EXTENSION_MARKER = '@orca-managed-pi-extension' const OMP_MANAGED_STATUS_EXTENSION_DIR = 'omp-managed-status-extension' type ManagedExtensionWriteResult = 'written' | 'skipped-user-owned' | 'failed' @@ -67,6 +64,12 @@ function toSafeOverlayDirName(ptyId: string): string { return createHash('sha256').update(ptyId).digest('hex').slice(0, 32) } +function withOrcaManagedExtensionMarker(source: string): string { + return source.includes(ORCA_MANAGED_EXTENSION_MARKER) + ? source + : `// ${ORCA_MANAGED_EXTENSION_MARKER}\n${source}` +} + export class PiTitlebarExtensionService { private getOverlayRoot(kind: PiAgentKind): string { return join(app.getPath('userData'), OVERLAY_ROOT_DIR_NAME[kind]) @@ -95,8 +98,16 @@ export class PiTitlebarExtensionService { safeRemoveOverlay(overlayDir, this.getOverlayRoot(kind)) } + private canOverwriteManagedExtension(path: string): boolean { + try { + return readFileSync(path, 'utf8').includes(ORCA_MANAGED_EXTENSION_MARKER) + } catch { + return true + } + } + private writeManagedExtension(path: string, source: string): ManagedExtensionWriteResult { - if (existsSync(path) && !isManagedPiExtensionFile(path)) { + if (existsSync(path) && !this.canOverwriteManagedExtension(path)) { return 'skipped-user-owned' } @@ -133,14 +144,14 @@ export class PiTitlebarExtensionService { this.writeManagedExtension( join(extensionsDir, ORCA_PI_EXTENSION_FILE), - withOrcaManagedPiExtensionMarker(getPiTitlebarExtensionSource()) + withOrcaManagedExtensionMarker(getPiTitlebarExtensionSource()) ) this.writeManagedExtension( join(extensionsDir, ORCA_PI_PREFILL_EXTENSION_FILE), - withOrcaManagedPiExtensionMarker(getPiPrefillExtensionSource(kind)) + withOrcaManagedExtensionMarker(getPiPrefillExtensionSource(kind)) ) const statusExtensionPath = join(extensionsDir, ORCA_PI_AGENT_STATUS_EXTENSION_FILE) - const statusSource = withOrcaManagedPiExtensionMarker(getPiAgentStatusExtensionSource(kind)) + const statusSource = withOrcaManagedExtensionMarker(getPiAgentStatusExtensionSource(kind)) const statusResult = this.writeManagedExtension(statusExtensionPath, statusSource) return { diff --git a/src/main/ports/local-workspace-port-scanner.test.ts b/src/main/ports/local-workspace-port-scanner.test.ts index 5b79e499644..a6eeb21e5ee 100644 --- a/src/main/ports/local-workspace-port-scanner.test.ts +++ b/src/main/ports/local-workspace-port-scanner.test.ts @@ -1,6 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import path from 'node:path' -import { LINUX_PROC_LISTENING_SOCKET_MAX_ENTRIES } from '../../shared/linux-proc-port-scan-limits' import { attributePortToWorkspace, isContainerProcess, @@ -111,18 +110,6 @@ describe('local workspace port scanner parsing', () => { expect(ports).toEqual([{ host: '127.0.0.1', port: 3000, inode: 12345 }]) expect(usedWhitespaceFieldSplit).toBe(false) }) - - it('caps retained Linux proc listeners before process attribution', () => { - const rows = Array.from( - { length: LINUX_PROC_LISTENING_SOCKET_MAX_ENTRIES + 5 }, - (_, index) => - ` ${index}: 0100007F:${(index + 1).toString(16).padStart(4, '0')} 00000000:0000 0A 00000000:00000000 00:00000000 00000000 1000 0 ${index + 1}` - ) - - expect(parseProcNetTcp(['header', ...rows].join('\n'))).toHaveLength( - LINUX_PROC_LISTENING_SOCKET_MAX_ENTRIES - ) - }) }) describe('attributePortToWorkspace', () => { diff --git a/src/main/ports/local-workspace-port-scanner.ts b/src/main/ports/local-workspace-port-scanner.ts index 2e89b47b51e..1d8f4dbf891 100644 --- a/src/main/ports/local-workspace-port-scanner.ts +++ b/src/main/ports/local-workspace-port-scanner.ts @@ -1,7 +1,7 @@ /* eslint-disable max-lines -- Why: the platform-specific scan paths share parsing, attribution, and normalization rules that must stay in lockstep. */ import { execFile } from 'node:child_process' -import { readlink } from 'node:fs/promises' +import { readFile, readdir, readlink } from 'node:fs/promises' import path from 'node:path' import type { WorkspacePort, @@ -10,14 +10,6 @@ import type { WorkspacePortScanResult } from '../../shared/workspace-ports' import { getProcessOutputFields } from '../../shared/process-output-field-scanner' -import { mapLinuxSocketInodesToPids } from '../../shared/linux-proc-socket-owner-scanner' -import { - createLinuxProcTextReadBudget, - LINUX_PROC_LISTENING_SOCKET_MAX_ENTRIES, - readLinuxProcNetworkTable, - readLinuxProcTextWithinBudget, - type LinuxProcTextReadBudget -} from '../../shared/linux-proc-port-scan-limits' import { advertisedUrlWatcher, type AdvertisedUrlWatcher } from './advertised-url-watcher' import { WorkspacePortScanTimeoutBackoff } from './workspace-port-scan-timeout-backoff' @@ -198,9 +190,6 @@ export function parseProcNetTcp(content: string): { host: string; port: number; continue } results.push({ ...parsed, inode }) - if (results.length >= LINUX_PROC_LISTENING_SOCKET_MAX_ENTRIES) { - break - } } return results } @@ -241,18 +230,15 @@ async function scanLinuxProcPorts(): Promise { readProcNet('/proc/net/tcp'), readProcNet('/proc/net/tcp6') ]) - const sockets = [...tcp4, ...tcp6].slice(0, LINUX_PROC_LISTENING_SOCKET_MAX_ENTRIES) - const inodeToPid = await mapLinuxSocketInodesToPids( - new Set(sockets.map((socket) => socket.inode)) - ) + const sockets = [...tcp4, ...tcp6] + const inodeToPid = await mapLinuxInodesToPids(new Set(sockets.map((socket) => socket.inode))) const metadata = new Map() - const metadataBudget = createLinuxProcTextReadBudget() const rawPorts: RawListeningPort[] = [] for (const socket of sockets) { const pid = inodeToPid.get(socket.inode) if (pid != null && !metadata.has(pid)) { - metadata.set(pid, await loadLinuxProcessMetadata(pid, metadataBudget)) + metadata.set(pid, await loadLinuxProcessMetadata(pid)) } rawPorts.push({ host: socket.host, @@ -268,17 +254,59 @@ async function scanLinuxProcPorts(): Promise { async function readProcNet( filePath: string ): Promise<{ host: string; port: number; inode: number }[]> { - const content = await readLinuxProcNetworkTable(filePath) - return content === null ? [] : parseProcNetTcp(content) + try { + return parseProcNetTcp(await readFile(filePath, 'utf-8')) + } catch { + return [] + } } -async function loadLinuxProcessMetadata( - pid: number, - budget: LinuxProcTextReadBudget -): Promise { - const comm = await readLinuxProcTextWithinBudget(`/proc/${pid}/comm`, budget) - const cmdline = await readLinuxProcTextWithinBudget(`/proc/${pid}/cmdline`, budget) - const cwd = await readlink(`/proc/${pid}/cwd`).catch(() => undefined) +async function mapLinuxInodesToPids(inodes: Set): Promise> { + const result = new Map() + if (inodes.size === 0) { + return result + } + let pids: string[] + try { + pids = (await readdir('/proc')).filter((entry) => /^\d+$/.test(entry)) + } catch { + return result + } + + for (const pidText of pids) { + let fds: string[] + try { + fds = await readdir(`/proc/${pidText}/fd`) + } catch { + continue + } + const pid = Number.parseInt(pidText, 10) + for (const fd of fds) { + let link: string + try { + link = await readlink(`/proc/${pidText}/fd/${fd}`) + } catch { + continue + } + const match = link.match(/^socket:\[(\d+)\]$/) + if (!match) { + continue + } + const inode = Number.parseInt(match[1], 10) + if (inodes.has(inode)) { + result.set(inode, pid) + } + } + } + return result +} + +async function loadLinuxProcessMetadata(pid: number): Promise { + const [comm, cmdline, cwd] = await Promise.all([ + readTextIfAvailable(`/proc/${pid}/comm`), + readTextIfAvailable(`/proc/${pid}/cmdline`), + readlink(`/proc/${pid}/cwd`).catch(() => undefined) + ]) return { processName: comm?.trim() || undefined, commandLine: cmdline?.split('\u0000').join(' ').trim() || undefined, @@ -412,6 +440,14 @@ function isCommandTimeoutError(error: unknown): boolean { return error instanceof CommandTimeoutError } +async function readTextIfAvailable(filePath: string): Promise { + try { + return await readFile(filePath, 'utf-8') + } catch { + return undefined + } +} + function enrichPort( port: RawListeningPort, worktrees: readonly NormalizedWorkspacePortProbe[], diff --git a/src/main/project-groups/nested-repo-discovery.test.ts b/src/main/project-groups/nested-repo-discovery.test.ts index fc99b18add6..f38e29ea53b 100644 --- a/src/main/project-groups/nested-repo-discovery.test.ts +++ b/src/main/project-groups/nested-repo-discovery.test.ts @@ -1,8 +1,8 @@ -import { mkdtemp, mkdir, writeFile, rm, symlink, truncate } from 'node:fs/promises' +import { mkdtemp, mkdir, writeFile, rm, symlink } from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' import { afterEach, describe, expect, it, vi } from 'vitest' -import { NESTED_REPO_GITIGNORE_MAX_BYTES, scanNestedRepos } from './nested-repo-discovery' +import { scanNestedRepos } from './nested-repo-discovery' let tempDirs: string[] = [] @@ -302,34 +302,6 @@ describe('scanNestedRepos', () => { expect(result.repos.map((repo) => repo.path)).toEqual(['/workspace/active/repo']) }) - it('ignores an oversized gitignore payload instead of retaining its rules', async () => { - const directories = new Map([ - ['/workspace', ['.gitignore', 'repo']], - ['/workspace/repo', []] - ]) - const files = new Map([['/workspace/.gitignore', `repo\n${'x'.repeat(1024 * 1024)}`]]) - const gitRepos = new Set(['/workspace/repo']) - - const result = await scanNestedRepos({ - path: '/workspace', - filesystem: posixTestFilesystem({ directories, gitRepos, files }) - }) - - expect(result.repos.map((repo) => repo.path)).toEqual(['/workspace/repo']) - }) - - it('skips a sparse oversized local gitignore before reading its payload', async () => { - const root = await tempRoot() - const ignorePath = join(root, '.gitignore') - await writeFile(ignorePath, '') - await truncate(ignorePath, NESTED_REPO_GITIGNORE_MAX_BYTES + 1) - await makeGitRepo(join(root, 'repo')) - - const result = await scanNestedRepos({ path: root }) - - expect(result.repos.map((repo) => repo.displayName)).toEqual(['repo']) - }) - it('keeps root-anchored gitignore rules scoped to their base directory', async () => { const directories = new Map([ ['/workspace', ['.gitignore', 'active', 'ignored']], @@ -382,49 +354,6 @@ describe('scanNestedRepos', () => { expect(selectedPathChecks).toEqual(['/workspace']) }) - it('accepts the exact aggregate entry capacity without truncation', async () => { - const directories = new Map([['/workspace', ['api', 'web']]]) - const gitRepos = new Set(['/workspace/api', '/workspace/web']) - - const result = await scanNestedRepos({ - path: '/workspace', - filesystem: posixTestFilesystem({ directories, gitRepos }), - limits: { maxEntries: 2 } - }) - - expect(result.repos.map((repo) => repo.path)).toEqual(['/workspace/api', '/workspace/web']) - expect(result.truncated).toBe(false) - }) - - it('returns bounded partial results and closes the iterator on entry overflow', async () => { - let iteratorClosed = false - const filesystem = { - ...posixTestFilesystem({ - directories: new Map(), - gitRepos: new Set(['/workspace/api', '/workspace/web', '/workspace/worker']) - }), - readDirectory: async function* () { - try { - yield { name: 'api', isDirectory: true } - yield { name: 'web', isDirectory: true } - yield { name: 'worker', isDirectory: true } - } finally { - iteratorClosed = true - } - } - } - - const result = await scanNestedRepos({ - path: '/workspace', - filesystem, - limits: { maxEntries: 2 } - }) - - expect(result.repos.map((repo) => repo.path)).toEqual(['/workspace/api', '/workspace/web']) - expect(result.truncated).toBe(true) - expect(iteratorClosed).toBe(true) - }) - it('skips heavy directories and respects result caps', async () => { const root = await tempRoot() await mkdir(join(root, 'node_modules', 'ignored'), { recursive: true }) diff --git a/src/main/project-groups/nested-repo-discovery.ts b/src/main/project-groups/nested-repo-discovery.ts index 4505b62b7ca..30453748767 100644 --- a/src/main/project-groups/nested-repo-discovery.ts +++ b/src/main/project-groups/nested-repo-discovery.ts @@ -1,6 +1,6 @@ /* eslint-disable max-lines -- Why: scanner traversal, ignore matching, and filesystem abstraction stay together so local, SSH, and runtime scans cannot drift. */ -import { opendir, stat } from 'node:fs/promises' +import { readFile, readdir, stat } from 'node:fs/promises' import { basename, join } from 'node:path' import type { NestedRepoCandidate, @@ -8,8 +8,6 @@ import type { NestedRepoScanResult } from '../../shared/types' import { isGitRepo } from '../git/repo' -import { NestedRepoScanBudget, type NestedRepoScanLimits } from './nested-repo-scan-budget' -import { readNodeFileWithinLimit } from '../../shared/node-bounded-file-reader' type NestedRepoDirectoryEntry = { name: string @@ -18,12 +16,7 @@ type NestedRepoDirectoryEntry = { } type NestedRepoScanFilesystem = { - readDirectory: ( - dirPath: string - ) => - | AsyncIterable - | Iterable - | Promise | Iterable> + readDirectory: (dirPath: string) => Promise readTextFile?: (filePath: string) => Promise joinPath: (parentPath: string, childName: string) => string basename: (path: string) => string @@ -53,7 +46,6 @@ type NormalizedNestedRepoScanOptions = { const DEFAULT_MAX_DEPTH = 3 const DEFAULT_MAX_REPOS = 100 -export const NESTED_REPO_GITIGNORE_MAX_BYTES = 1024 * 1024 const SKIPPED_DIRS = new Set([ 'node_modules', @@ -129,35 +121,24 @@ function pathSegmentsMatch(patternSegments: string[], candidateSegments: string[ return matchFrom(0, 0) } -function parseGitignoreRules( - content: string, - baseSegments: string[], - budget: NestedRepoScanBudget -): IgnoreRule[] { - const rules: IgnoreRule[] = [] - for (const match of content.matchAll(/[^\r\n]+/g)) { - const line = match[0].trim() - if (!line || line.startsWith('#')) { - continue - } - const negate = line.startsWith('!') - const unprefixed = negate ? line.slice(1) : line - const anchored = unprefixed.startsWith('/') - const pattern = unprefixed.replace(/^\/+/, '').replace(/\/+$/, '') - if (!pattern) { - continue - } - if (!budget.tryRetainIgnoreRule(pattern)) { - break - } - rules.push({ - pattern, - negate, - basenameOnly: !anchored && !pattern.includes('/'), - baseSegments +function parseGitignoreRules(content: string, baseSegments: string[]): IgnoreRule[] { + return content + .split(/\r?\n/) + .map((rawLine) => rawLine.trim()) + .filter((line) => line.length > 0 && !line.startsWith('#')) + .map((line) => { + const negate = line.startsWith('!') + const unprefixed = negate ? line.slice(1) : line + const anchored = unprefixed.startsWith('/') + const pattern = unprefixed.replace(/^\/+/, '').replace(/\/+$/, '') + return { + pattern, + negate, + basenameOnly: !anchored && !pattern.includes('/'), + baseSegments + } }) - } - return rules + .filter((rule) => rule.pattern.length > 0) } function isIgnoredByRules(name: string, segments: string[], rules: IgnoreRule[]): boolean { @@ -183,7 +164,6 @@ async function readGitignoreRules(args: { entries: NestedRepoDirectoryEntry[] filesystem: NestedRepoScanFilesystem baseSegments: string[] - budget: NestedRepoScanBudget }): Promise { if (!args.filesystem.readTextFile || !args.entries.some((entry) => entry.name === '.gitignore')) { return [] @@ -192,10 +172,7 @@ async function readGitignoreRules(args: { const content = await args.filesystem.readTextFile( args.filesystem.joinPath(args.folderPath, '.gitignore') ) - if (Buffer.byteLength(content, 'utf8') > NESTED_REPO_GITIGNORE_MAX_BYTES) { - return [] - } - return parseGitignoreRules(content, args.baseSegments, args.budget) + return parseGitignoreRules(content, args.baseSegments) } catch { return [] } @@ -218,15 +195,15 @@ async function hasGitMarker(dirPath: string): Promise { return head?.isFile() === true && objects?.isDirectory() === true && refs?.isDirectory() === true } -async function* readLocalDirectory(dirPath: string): AsyncGenerator { - const directory = await opendir(dirPath) - for await (const entry of directory) { - yield { - name: entry.name, - isDirectory: entry.isDirectory(), - isSymlink: entry.isSymbolicLink() - } - } +async function readLocalDirectory(dirPath: string): Promise { + // Why: Dirent data avoids one stat per child and keeps symlinked directories + // from expanding the scan outside the selected folder. + const entries = await readdir(dirPath, { withFileTypes: true }) + return entries.map((entry) => ({ + name: entry.name, + isDirectory: entry.isDirectory(), + isSymlink: entry.isSymbolicLink() + })) } export async function scanNestedRepos(args: { @@ -235,7 +212,6 @@ export async function scanNestedRepos(args: { filesystem?: NestedRepoScanFilesystem signal?: AbortSignal onProgress?: (scan: NestedRepoScanResult) => void - limits?: Partial }): Promise { const startedAt = Date.now() const options = normalizeScanOptions(args.options) @@ -243,13 +219,9 @@ export async function scanNestedRepos(args: { let truncated = false let timedOut = false let stopped = false - const scanBudget = new NestedRepoScanBudget(args.limits) const filesystem = args.filesystem ?? { readDirectory: readLocalDirectory, - readTextFile: async (path: string) => - (await readNodeFileWithinLimit(path, NESTED_REPO_GITIGNORE_MAX_BYTES)).buffer.toString( - 'utf8' - ), + readTextFile: (path: string) => readFile(path, 'utf8'), joinPath: join, basename, hasGitMarker, @@ -309,12 +281,7 @@ export async function scanNestedRepos(args: { let entries: NestedRepoDirectoryEntry[] try { - entries = await collectNestedRepoDirectoryEntries( - await filesystem.readDirectory(currentFolder.path), - currentFolder.path, - filesystem, - scanBudget - ) + entries = await filesystem.readDirectory(currentFolder.path) } catch { continue } @@ -327,8 +294,7 @@ export async function scanNestedRepos(args: { folderPath: currentFolder.path, entries, filesystem, - baseSegments: currentFolder.segments, - budget: scanBudget + baseSegments: currentFolder.segments })) ] @@ -381,28 +347,7 @@ export async function scanNestedRepos(args: { }) } } - if (scanBudget.capacityReached) { - truncated = true - break - } } return buildResult('non_git_folder') } - -async function collectNestedRepoDirectoryEntries( - source: AsyncIterable | Iterable, - directoryPath: string, - filesystem: NestedRepoScanFilesystem, - budget: NestedRepoScanBudget -): Promise { - const entries: NestedRepoDirectoryEntry[] = [] - for await (const entry of source) { - const entryPath = filesystem.joinPath(directoryPath, entry.name) - if (!budget.tryVisitEntry(entryPath)) { - break - } - entries.push(entry) - } - return entries -} diff --git a/src/main/project-groups/nested-repo-scan-budget.test.ts b/src/main/project-groups/nested-repo-scan-budget.test.ts deleted file mode 100644 index d53d8392ebc..00000000000 --- a/src/main/project-groups/nested-repo-scan-budget.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { NestedRepoScanBudget } from './nested-repo-scan-budget' - -describe('NestedRepoScanBudget', () => { - it('accepts the exact path-memory capacity and rejects the next entry', () => { - const budget = new NestedRepoScanBudget({ maxPathBytes: 258 }) - - expect(budget.tryVisitEntry('a')).toBe(true) - expect(budget.tryVisitEntry('b')).toBe(false) - expect(budget.capacityReached).toBe(true) - }) - - it('accepts the exact ignore-rule capacity and rejects the next rule', () => { - const budget = new NestedRepoScanBudget({ maxIgnoreBytes: 130 }) - - expect(budget.tryRetainIgnoreRule('a')).toBe(true) - expect(budget.tryRetainIgnoreRule('b')).toBe(false) - expect(budget.capacityReached).toBe(true) - }) -}) diff --git a/src/main/project-groups/nested-repo-scan-budget.ts b/src/main/project-groups/nested-repo-scan-budget.ts deleted file mode 100644 index aeb944be8a3..00000000000 --- a/src/main/project-groups/nested-repo-scan-budget.ts +++ /dev/null @@ -1,61 +0,0 @@ -export const NESTED_REPO_SCAN_MAX_ENTRIES = 100_000 -export const NESTED_REPO_SCAN_MAX_PATH_BYTES = 32 * 1024 * 1024 -export const NESTED_REPO_SCAN_MAX_IGNORE_RULES = 100_000 -export const NESTED_REPO_SCAN_MAX_IGNORE_BYTES = 8 * 1024 * 1024 - -const NESTED_REPO_ENTRY_OVERHEAD_BYTES = 256 -const NESTED_REPO_IGNORE_RULE_OVERHEAD_BYTES = 128 - -export type NestedRepoScanLimits = { - maxEntries: number - maxIgnoreBytes: number - maxIgnoreRules: number - maxPathBytes: number -} - -export class NestedRepoScanBudget { - private entries = 0 - private ignoreBytes = 0 - private ignoreRules = 0 - private pathBytes = 0 - readonly limits: NestedRepoScanLimits - capacityReached = false - - constructor(requested?: Partial) { - this.limits = { - maxEntries: clampLimit(requested?.maxEntries, NESTED_REPO_SCAN_MAX_ENTRIES), - maxIgnoreBytes: clampLimit(requested?.maxIgnoreBytes, NESTED_REPO_SCAN_MAX_IGNORE_BYTES), - maxIgnoreRules: clampLimit(requested?.maxIgnoreRules, NESTED_REPO_SCAN_MAX_IGNORE_RULES), - maxPathBytes: clampLimit(requested?.maxPathBytes, NESTED_REPO_SCAN_MAX_PATH_BYTES) - } - } - - tryVisitEntry(path: string): boolean { - const nextPathBytes = this.pathBytes + path.length * 2 + NESTED_REPO_ENTRY_OVERHEAD_BYTES - if (this.entries >= this.limits.maxEntries || nextPathBytes > this.limits.maxPathBytes) { - this.capacityReached = true - return false - } - this.entries += 1 - this.pathBytes = nextPathBytes - return true - } - - tryRetainIgnoreRule(pattern: string): boolean { - const nextBytes = this.ignoreBytes + pattern.length * 2 + NESTED_REPO_IGNORE_RULE_OVERHEAD_BYTES - if (this.ignoreRules >= this.limits.maxIgnoreRules || nextBytes > this.limits.maxIgnoreBytes) { - this.capacityReached = true - return false - } - this.ignoreRules += 1 - this.ignoreBytes = nextBytes - return true - } -} - -function clampLimit(value: number | undefined, maximum: number): number { - if (typeof value !== 'number' || !Number.isSafeInteger(value) || value <= 0) { - return maximum - } - return Math.min(value, maximum) -} diff --git a/src/main/providers/filesystem-path-listing-provider.ts b/src/main/providers/filesystem-path-listing-provider.ts deleted file mode 100644 index 951fe981166..00000000000 --- a/src/main/providers/filesystem-path-listing-provider.ts +++ /dev/null @@ -1,7 +0,0 @@ -export type FilesystemPathListingProvider = { - listFiles( - rootPath: string, - options?: { excludePaths?: string[]; signal?: AbortSignal; maxResults?: number } - ): Promise - listMarkdownDocuments?(rootPath: string): Promise -} diff --git a/src/main/providers/provider-dispatch.test.ts b/src/main/providers/provider-dispatch.test.ts index a6d1b20d4d6..be45aa04ff3 100644 --- a/src/main/providers/provider-dispatch.test.ts +++ b/src/main/providers/provider-dispatch.test.ts @@ -56,7 +56,6 @@ vi.mock('../pi/titlebar-extension-service', () => ({ import { deletePtyOwnership, - MAX_REGISTERED_SSH_PTY_PROVIDERS, registerPtyHandlers, registerSshPtyProvider, setPtyOwnership, @@ -179,28 +178,6 @@ describe('PTY provider dispatch', () => { ).rejects.toThrow('No PTY provider for connection "conn-456"') }) - it('fails closed when the process-wide SSH provider registry is saturated', () => { - const ids = Array.from( - { length: MAX_REGISTERED_SSH_PTY_PROVIDERS }, - (_, index) => `capacity-${index}` - ) - try { - for (const id of ids) { - registerSshPtyProvider(id, createMockProvider(id)) - } - - expect(() => - registerSshPtyProvider('capacity-overflow', createMockProvider('overflow')) - ).toThrow('ssh_pty_provider_capacity') - expect(() => registerSshPtyProvider(ids[0], createMockProvider('replacement'))).not.toThrow() - } finally { - for (const id of ids) { - unregisterSshPtyProvider(id) - } - unregisterSshPtyProvider('capacity-overflow') - } - }) - it('keeps same relay PTY ids distinct across SSH targets', () => { setup() const providerA = createMockProvider('ssh:conn-a@@pty-1') diff --git a/src/main/providers/pty-provider-events.ts b/src/main/providers/pty-provider-events.ts index 0048b85f9dd..6de435cb23f 100644 --- a/src/main/providers/pty-provider-events.ts +++ b/src/main/providers/pty-provider-events.ts @@ -1,18 +1,11 @@ import type { TerminalGitHubPRLink } from '../../shared/terminal-github-pr-link-detector' -export type PtyDataUpstreamCredit = { - charCount: number - acknowledge(charCount: number): void -} - export type PtyDataEvent = { id: string data: string sequenceChars?: number transformed?: boolean seq?: number - /** Main-process-only credit captured from the exact provider generation that emitted this data. */ - upstreamCredit?: PtyDataUpstreamCredit } /** Notification-bearing fact a thinning transport detected while it held diff --git a/src/main/providers/ssh-agent-session-process-list.ts b/src/main/providers/ssh-agent-session-process-list.ts index f5287302ca5..cb1447aff71 100644 --- a/src/main/providers/ssh-agent-session-process-list.ts +++ b/src/main/providers/ssh-agent-session-process-list.ts @@ -1,156 +1,25 @@ import { isAgentSessionOwnerBinding } from '../../shared/agent-session-host-authority' -import { MAX_CLAIMED_AGENT_PTY_OWNER_ENTRIES } from '../../shared/claimed-agent-pty-owner' import { isPtyIncarnationId } from '../../shared/pty-incarnation' import type { PtyProcessInfo } from './types' -import { MAX_SSH_PTY_LIVE_ROSTER_ENTRIES } from './ssh-pty-live-roster' -import { admittedSshRelayPtyIdBytes } from './ssh-pty-wire-admission' - -export const MAX_SSH_PTY_PROCESS_LIST_ENTRIES = MAX_SSH_PTY_LIVE_ROSTER_ENTRIES -export const MAX_SSH_PTY_PROCESS_OWNERS_PER_ENTRY = 256 -export const MAX_SSH_PTY_PROCESS_LIST_OWNERS = MAX_CLAIMED_AGENT_PTY_OWNER_ENTRIES -export const MAX_SSH_PTY_PROCESS_LIST_BYTES = 8 * 1024 * 1024 -export const MAX_SSH_PTY_PROCESS_CWD_BYTES = 128 * 1024 -export const MAX_SSH_PTY_PROCESS_TITLE_BYTES = 16 * 1024 -const MAX_SSH_PTY_PROCESS_WORKTREE_ID_BYTES = 128 * 1024 -const MAX_SSH_PTY_PROCESS_TERMINAL_HANDLE_BYTES = 1024 -const MAX_SSH_PTY_PROCESS_WSL_DISTRO_BYTES = 4 * 1024 - -function stringBytes(value: unknown, maxBytes: number): number | null { - if (typeof value !== 'string') { - return null - } - const bytes = Buffer.byteLength(value, 'utf8') - return bytes <= maxBytes ? bytes : null -} - -function optionalStringBytes(value: unknown, maxBytes: number): number | null { - return value === undefined ? 0 : stringBytes(value, maxBytes) -} - -function ownerBytes(owner: unknown): number | null { - if (!isAgentSessionOwnerBinding(owner)) { - return null - } - const strings = [ - owner.claim.keyId, - owner.claim.identityDigest, - owner.claim.worktreeScopeDigest, - owner.claim.agent, - owner.generation, - owner.ptyId, - owner.surface.worktreeId, - owner.surface.tabId, - owner.surface.leafId, - owner.surface.terminalHandle - ] - return strings.reduce((total, value) => total + Buffer.byteLength(value, 'utf8'), 0) -} - -function assertProcessList( - sessions: unknown -): asserts sessions is (PtyProcessInfo & Record)[] { - if (!Array.isArray(sessions) || sessions.length > MAX_SSH_PTY_PROCESS_LIST_ENTRIES) { - throw new Error('invalid_ssh_pty_process_list') - } - let aggregateBytes = 0 - let aggregateOwners = 0 - for (const session of sessions) { - if (typeof session !== 'object' || session === null) { - throw new Error('invalid_ssh_pty_process_list') - } - const idBytes = admittedSshRelayPtyIdBytes(session.id) - const cwdBytes = stringBytes(session.cwd, MAX_SSH_PTY_PROCESS_CWD_BYTES) - const titleBytes = stringBytes(session.title, MAX_SSH_PTY_PROCESS_TITLE_BYTES) - const worktreeBytes = optionalStringBytes( - session.worktreeId, - MAX_SSH_PTY_PROCESS_WORKTREE_ID_BYTES - ) - const terminalHandleBytes = optionalStringBytes( - session.terminalHandle, - MAX_SSH_PTY_PROCESS_TERMINAL_HANDLE_BYTES - ) - const wslDistroBytes = - session.wslDistro === null - ? 0 - : optionalStringBytes(session.wslDistro, MAX_SSH_PTY_PROCESS_WSL_DISTRO_BYTES) - if ( - idBytes === null || - cwdBytes === null || - titleBytes === null || - worktreeBytes === null || - terminalHandleBytes === null || - wslDistroBytes === null || - (session.incarnationId !== undefined && !isPtyIncarnationId(session.incarnationId)) - ) { - throw new Error('invalid_ssh_pty_process_list') - } - aggregateBytes += - idBytes + cwdBytes + titleBytes + worktreeBytes + terminalHandleBytes + wslDistroBytes - if ( - session.agentSessionOwners !== undefined && - (!Array.isArray(session.agentSessionOwners) || - session.agentSessionOwners.length > MAX_SSH_PTY_PROCESS_OWNERS_PER_ENTRY) - ) { - throw new Error('invalid_ssh_pty_process_list') - } - for (const owner of session.agentSessionOwners ?? []) { - const bytes = ownerBytes(owner) - if (bytes === null || owner.ptyId !== session.id) { - throw new Error('agent_session_ownership_unknown') - } - aggregateBytes += bytes - } - aggregateOwners += session.agentSessionOwners?.length ?? 0 - if ( - aggregateBytes > MAX_SSH_PTY_PROCESS_LIST_BYTES || - aggregateOwners > MAX_SSH_PTY_PROCESS_LIST_OWNERS - ) { - throw new Error('invalid_ssh_pty_process_list') - } - } -} export function mapSshPtyProcessList( - sessions: unknown, + sessions: PtyProcessInfo[], toAppPtyId: (id: string) => string ): PtyProcessInfo[] { - assertProcessList(sessions) return sessions.map((session) => { if (session.agentSessionOwners?.length && !isPtyIncarnationId(session.incarnationId)) { throw new Error('agent_session_ownership_unknown') } return { + ...session, id: toAppPtyId(session.id), - cwd: session.cwd, - title: session.title, - ...(session.incarnationId ? { incarnationId: session.incarnationId } : {}), - ...(session.worktreeId !== undefined ? { worktreeId: session.worktreeId } : {}), - ...(session.terminalHandle !== undefined ? { terminalHandle: session.terminalHandle } : {}), - ...(session.wslDistro !== undefined ? { wslDistro: session.wslDistro } : {}), - ...(session.agentSessionOwners !== undefined + ...(session.agentSessionOwners ? { agentSessionOwners: session.agentSessionOwners.map((owner) => { if (!isAgentSessionOwnerBinding(owner) || owner.ptyId !== session.id) { throw new Error('agent_session_ownership_unknown') } - return { - claim: { - digestVersion: owner.claim.digestVersion, - keyId: owner.claim.keyId, - identityDigest: owner.claim.identityDigest, - worktreeScopeDigest: owner.claim.worktreeScopeDigest, - agent: owner.claim.agent - }, - generation: owner.generation, - phase: owner.phase, - ptyId: toAppPtyId(owner.ptyId), - surface: { - worktreeId: owner.surface.worktreeId, - tabId: owner.surface.tabId, - leafId: owner.surface.leafId, - terminalHandle: owner.surface.terminalHandle - } - } + return { ...owner, ptyId: toAppPtyId(owner.ptyId) } }) } : {}) diff --git a/src/main/providers/ssh-filesystem-directory-reader.ts b/src/main/providers/ssh-filesystem-directory-reader.ts deleted file mode 100644 index 5b25a08610f..00000000000 --- a/src/main/providers/ssh-filesystem-directory-reader.ts +++ /dev/null @@ -1,59 +0,0 @@ -import type { DirEntry } from '../../shared/types' -import { - assertFilesystemDirectoryWithinLimit, - resolveFilesystemDirectoryListingLimits -} from '../../shared/filesystem-directory-listing-limit' -import { - assertMobileFileDirectoryWithinLimit, - MOBILE_FILE_DIRECTORY_MAX_ENTRIES, - MOBILE_FILE_DIRECTORY_MAX_RETAINED_BYTES -} from '../../shared/mobile-file-directory-limit' -import { isMethodNotFoundError } from '../ssh/ssh-filesystem-stream-reader' -import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' - -export const SSH_BOUNDED_READ_DIR_UNAVAILABLE_MESSAGE = - 'Safe remote directory browsing requires an updated relay. Reconnect the SSH target before retrying.' - -export class SshFilesystemDirectoryReader { - private boundedMethodUnavailable = false - - constructor(private readonly mux: SshChannelMultiplexer) {} - - async readDir( - dirPath: string, - options?: { maxEntries?: number; maxRetainedBytes?: number } - ): Promise { - if (this.boundedMethodUnavailable) { - throw new Error(SSH_BOUNDED_READ_DIR_UNAVAILABLE_MESSAGE) - } - const mobileLimits = - options?.maxEntries === MOBILE_FILE_DIRECTORY_MAX_ENTRIES && - options.maxRetainedBytes === MOBILE_FILE_DIRECTORY_MAX_RETAINED_BYTES - const limits = mobileLimits - ? { - maxEntries: MOBILE_FILE_DIRECTORY_MAX_ENTRIES, - maxRetainedBytes: MOBILE_FILE_DIRECTORY_MAX_RETAINED_BYTES - } - : resolveFilesystemDirectoryListingLimits(options) - let entries: DirEntry[] - try { - entries = (await this.mux.request('fs.readDirBounded', { - dirPath, - ...limits - })) as DirEntry[] - } catch (error) { - if (!isMethodNotFoundError(error)) { - throw error - } - // Why: fallback would restore remote-side unbounded enumeration. - this.boundedMethodUnavailable = true - throw new Error(SSH_BOUNDED_READ_DIR_UNAVAILABLE_MESSAGE) - } - if (mobileLimits) { - assertMobileFileDirectoryWithinLimit(entries) - } else { - assertFilesystemDirectoryWithinLimit(entries, limits) - } - return entries - } -} diff --git a/src/main/providers/ssh-filesystem-download.test.ts b/src/main/providers/ssh-filesystem-download.test.ts index 23af1490e7f..d65735cb806 100644 --- a/src/main/providers/ssh-filesystem-download.test.ts +++ b/src/main/providers/ssh-filesystem-download.test.ts @@ -4,7 +4,6 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { downloadFolderViaSftp } from './ssh-filesystem-download' -import { SSH_DIRECTORY_TRANSFER_LIMITS } from '../ssh/ssh-directory-transfer-budget' type SftpEntryKind = 'directory' | 'file' | 'symlink' | 'fifo' @@ -30,31 +29,6 @@ function sftpEntry(filename: string, kind: SftpEntryKind) { return { filename, longname: filename, attrs: sftpStats(kind) } } -function withDirectoryHandles(sftp: Record): unknown { - if (typeof sftp.opendir === 'function' && typeof sftp.close === 'function') { - return sftp - } - const readDirectory = sftp.readdir as ( - path: string, - callback: (error?: Error, value?: unknown) => void - ) => void - const completed = new Set() - return Object.assign(sftp, { - opendir: (path: string, callback: (error: undefined, handle: Buffer) => void) => - callback(undefined, Buffer.from(path)), - readdir: (handle: Buffer, callback: (error?: Error, value?: unknown) => void) => { - const path = handle.toString('utf8') - if (completed.has(path)) { - callback(undefined, false) - return - } - completed.add(path) - readDirectory(path, callback) - }, - close: (_handle: Buffer, callback: (error?: Error) => void) => callback() - }) -} - describe('downloadFolderViaSftp', () => { const roots: string[] = [] @@ -90,11 +64,7 @@ describe('downloadFolderViaSftp', () => { } await expect( - downloadFolderViaSftp( - async () => withDirectoryHandles(sftp) as never, - '/remote/src', - destination - ) + downloadFolderViaSftp(async () => sftp as never, '/remote/src', destination) ).rejects.toThrow("Remote entries map to the same local name 'a.txt'") expect(sftp.fastGet).toHaveBeenCalledTimes(1) }) @@ -113,11 +83,7 @@ describe('downloadFolderViaSftp', () => { } await expect( - downloadFolderViaSftp( - async () => withDirectoryHandles(sftp) as never, - '/remote/src', - destination - ) + downloadFolderViaSftp(async () => sftp as never, '/remote/src', destination) ).rejects.toThrow("Cannot download unsupported remote entry 'build.pipe'") expect(sftp.fastGet).not.toHaveBeenCalled() }) @@ -137,11 +103,7 @@ describe('downloadFolderViaSftp', () => { } await expect( - downloadFolderViaSftp( - async () => withDirectoryHandles(sftp) as never, - '/remote/src', - destination - ) + downloadFolderViaSftp(async () => sftp as never, '/remote/src', destination) ).rejects.toThrow("Cannot download symbolic link 'creds'") // The link target could be /etc/passwd; rejecting from directory-entry // metadata means it is never followed with stat or opened by fastGet. @@ -163,11 +125,7 @@ describe('downloadFolderViaSftp', () => { } await expect( - downloadFolderViaSftp( - async () => withDirectoryHandles(sftp) as never, - '/remote/src', - destination - ) + downloadFolderViaSftp(async () => sftp as never, '/remote/src', destination) ).rejects.toThrow("Remote entries map to the same local name 'download'") expect(sftp.fastGet).not.toHaveBeenCalled() }) @@ -188,12 +146,9 @@ describe('downloadFolderViaSftp', () => { end: vi.fn() } - await downloadFolderViaSftp( - async () => withDirectoryHandles(sftp) as never, - sourcePath, - destination, - { windowsRemotePaths: false } - ) + await downloadFolderViaSftp(async () => sftp as never, sourcePath, destination, { + windowsRemotePaths: false + }) expect(sftp.fastGet).toHaveBeenCalledWith( '/remote/parent\\literal/..\\secret.txt', @@ -216,12 +171,9 @@ describe('downloadFolderViaSftp', () => { } await expect( - downloadFolderViaSftp( - async () => withDirectoryHandles(sftp) as never, - 'C:/remote/src', - destination, - { windowsRemotePaths: true } - ) + downloadFolderViaSftp(async () => sftp as never, 'C:/remote/src', destination, { + windowsRemotePaths: true + }) ).rejects.toThrow("Invalid remote directory entry '..\\secret.txt'") expect(sftp.fastGet).not.toHaveBeenCalled() }) @@ -243,12 +195,9 @@ describe('downloadFolderViaSftp', () => { } const controller = new AbortController() - const result = downloadFolderViaSftp( - async () => withDirectoryHandles(sftp) as never, - '/remote/src', - destination, - { signal: controller.signal } - ) + const result = downloadFolderViaSftp(async () => sftp as never, '/remote/src', destination, { + signal: controller.signal + }) await vi.waitFor(() => expect(sftp.fastGet).toHaveBeenCalledTimes(1)) controller.abort(new Error('renderer closed')) @@ -273,26 +222,22 @@ describe('downloadFolderViaSftp', () => { it('cancels a pending SFTP directory read', async () => { const destination = await createDestination() let readDirCallback: ((error?: Error) => void) | undefined - const readdir = vi.fn((_path: string, callback: (error?: Error) => void) => { - readDirCallback = callback - }) const sftp = { stat: vi.fn((_path: string, callback: (err: Error | undefined, value: unknown) => void) => callback(undefined, sftpStats('directory')) ), - readdir, + readdir: vi.fn((_path: string, callback: (error?: Error) => void) => { + readDirCallback = callback + }), fastGet: vi.fn(), end: vi.fn() } const controller = new AbortController() - const result = downloadFolderViaSftp( - async () => withDirectoryHandles(sftp) as never, - '/remote/src', - destination, - { signal: controller.signal } - ) - await vi.waitFor(() => expect(readdir).toHaveBeenCalledTimes(1)) + const result = downloadFolderViaSftp(async () => sftp as never, '/remote/src', destination, { + signal: controller.signal + }) + await vi.waitFor(() => expect(sftp.readdir).toHaveBeenCalledTimes(1)) controller.abort(new Error('renderer closed')) readDirCallback?.(new Error('channel closed')) @@ -300,44 +245,4 @@ describe('downloadFolderViaSftp', () => { expect(sftp.fastGet).not.toHaveBeenCalled() expect(sftp.end).toHaveBeenCalledTimes(1) }) - - it('streams directory chunks and stops at the retained entry budget', async () => { - const destination = await createDestination() - const handle = Buffer.from('handle') - const chunks = [ - [sftpEntry('one.txt', 'file'), sftpEntry('two.txt', 'file')], - [sftpEntry('three.txt', 'file')], - false - ] - const sftp = { - stat: vi.fn((_path: string, callback: (err: Error | undefined, value: unknown) => void) => - callback(undefined, sftpStats('directory')) - ), - opendir: vi.fn((_path: string, callback: (err: Error | undefined, value: Buffer) => void) => - callback(undefined, handle) - ), - readdir: vi.fn( - (_handle: Buffer, callback: (err: Error | undefined, value: unknown) => void) => - callback(undefined, chunks.shift()) - ), - close: vi.fn((_handle: Buffer, callback: (err?: Error) => void) => callback()), - fastGet: vi.fn(), - end: vi.fn() - } - - await expect( - downloadFolderViaSftp( - async () => withDirectoryHandles(sftp) as never, - '/remote/src', - destination, - { - limits: { ...SSH_DIRECTORY_TRANSFER_LIMITS, maximumEntries: 2 } - } - ) - ).rejects.toThrow('SSH directory transfer exceeds the entries limit') - - expect(sftp.readdir).toHaveBeenCalledTimes(2) - expect(sftp.close).toHaveBeenCalledWith(handle, expect.any(Function)) - expect(sftp.fastGet).not.toHaveBeenCalled() - }) }) diff --git a/src/main/providers/ssh-filesystem-download.ts b/src/main/providers/ssh-filesystem-download.ts index 700216ae036..f73ef4c655f 100644 --- a/src/main/providers/ssh-filesystem-download.ts +++ b/src/main/providers/ssh-filesystem-download.ts @@ -7,25 +7,12 @@ import { normalizeRuntimePathSeparators } from '../../shared/cross-platform-path' import { sanitizeLocalDownloadFilename } from '../local-download-filename' -import { - fastGetViaSftp, - readDirectoryEntriesViaSftp, - statViaSftp -} from './ssh-filesystem-provider-sftp' -import { - SSH_DIRECTORY_TRANSFER_LIMITS, - SshDirectoryTransferBudget, - type SshDirectoryTransferLimits -} from '../ssh/ssh-directory-transfer-budget' +import { fastGetViaSftp, readDirViaSftp, statViaSftp } from './ssh-filesystem-provider-sftp' export type SftpFactory = (options?: { signal?: AbortSignal }) => Promise /** When known, windowsRemotePaths drives remote path joining; omit uses path-shape heuristics. */ -export type FolderDownloadOptions = { - signal?: AbortSignal - windowsRemotePaths?: boolean - limits?: SshDirectoryTransferLimits -} +export type FolderDownloadOptions = { signal?: AbortSignal; windowsRemotePaths?: boolean } const DOWNLOAD_UNAVAILABLE_MESSAGE = 'Remote folder download is unavailable. Reconnect the SSH target and retry.' @@ -95,11 +82,13 @@ async function downloadDirectoryTree( sftp: SFTPWrapper, sourceDir: string, destinationDir: string, - budget: SshDirectoryTransferBudget, - depth: number, signal?: AbortSignal, windowsRemotePaths?: boolean ): Promise { + signal?.throwIfAborted() + const entries = (await readDirViaSftp(sftp, sourceDir, { signal })).filter( + (entry) => entry.filename !== '.' && entry.filename !== '..' + ) signal?.throwIfAborted() const usedLocalNames = new Set() const plannedEntries: { @@ -107,13 +96,7 @@ async function downloadDirectoryTree( kind: 'directory' | 'file' localName: string }[] = [] - for await (const entry of readDirectoryEntriesViaSftp(sftp, sourceDir, { signal })) { - signal?.throwIfAborted() - if (entry.filename === '.' || entry.filename === '..') { - continue - } - const remotePath = joinSftpChildPath(sourceDir, entry.filename, windowsRemotePaths) - budget.recordPath(remotePath, depth + 1) + for (const entry of entries) { const localName = sanitizeLocalDownloadFilename(entry.filename) if (usedLocalNames.has(localName)) { throw new Error(`Remote entries map to the same local name '${localName}'`) @@ -132,15 +115,7 @@ async function downloadDirectoryTree( const remotePath = joinSftpChildPath(sourceDir, entry.filename, windowsRemotePaths) const localPath = join(destinationDir, localName) if (kind === 'directory') { - await downloadDirectoryTree( - sftp, - remotePath, - localPath, - budget, - depth + 1, - signal, - windowsRemotePaths - ) + await downloadDirectoryTree(sftp, remotePath, localPath, signal, windowsRemotePaths) continue } // Why: filesystem semantics belong to the selected volume, not the host OS; @@ -195,14 +170,10 @@ export async function downloadFolderViaSftp( if (!rootStats.isDirectory()) { throw new Error('Cannot download a file as a folder') } - const budget = new SshDirectoryTransferBudget(options?.limits ?? SSH_DIRECTORY_TRANSFER_LIMITS) - budget.recordPath(sourcePath, 0, { countEntry: false }) await downloadDirectoryTree( sftp, sourcePath, destinationPath, - budget, - 0, signal, options?.windowsRemotePaths ) diff --git a/src/main/providers/ssh-filesystem-provider-directory-limits.test.ts b/src/main/providers/ssh-filesystem-provider-directory-limits.test.ts deleted file mode 100644 index 36f39f59d6a..00000000000 --- a/src/main/providers/ssh-filesystem-provider-directory-limits.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { - FILESYSTEM_DIRECTORY_MAX_ENTRIES, - FILESYSTEM_DIRECTORY_MAX_RETAINED_BYTES -} from '../../shared/filesystem-directory-listing-limit' -import { JsonRpcErrorCode } from '../ssh/relay-protocol' -import { SSH_BOUNDED_READ_DIR_UNAVAILABLE_MESSAGE } from './ssh-filesystem-directory-reader' -import { SshFilesystemProvider } from './ssh-filesystem-provider' - -function createProvider(request: ReturnType): SshFilesystemProvider { - return new SshFilesystemProvider('conn-1', { - request, - onNotification: vi.fn(() => () => {}) - } as never) -} - -describe('SshFilesystemProvider directory limits', () => { - it('routes normal listings through the bounded method without changing results', async () => { - const entries = [ - { name: 'src', isDirectory: true, isSymlink: false }, - { name: 'README.md', isDirectory: false, isSymlink: false } - ] - const request = vi.fn().mockResolvedValue(entries) - const provider = createProvider(request) - - await expect(provider.readDir('/home/user/project')).resolves.toEqual(entries) - expect(request).toHaveBeenCalledWith('fs.readDirBounded', { - dirPath: '/home/user/project', - maxEntries: FILESYSTEM_DIRECTORY_MAX_ENTRIES, - maxRetainedBytes: FILESYSTEM_DIRECTORY_MAX_RETAINED_BYTES - }) - }) - - it('forwards stricter mobile directory retention limits', async () => { - const request = vi.fn().mockResolvedValue([]) - const provider = createProvider(request) - - await provider.readDir('/home/user/project', { - maxEntries: 10_000, - maxRetainedBytes: 4 * 1024 * 1024 - }) - - expect(request).toHaveBeenCalledWith('fs.readDirBounded', { - dirPath: '/home/user/project', - maxEntries: 10_000, - maxRetainedBytes: 4 * 1024 * 1024 - }) - }) - - it('validates bounded relay results before returning them', async () => { - const request = vi.fn().mockResolvedValue([ - { name: 'one', isDirectory: false, isSymlink: false }, - { name: 'two', isDirectory: false, isSymlink: false }, - { name: 'three', isDirectory: false, isSymlink: false } - ]) - const provider = createProvider(request) - - await expect( - provider.readDir('/home/user/project', { maxEntries: 2, maxRetainedBytes: 1024 }) - ).rejects.toThrow('This folder is too large to list safely') - }) - - it('requires reconnect instead of falling back to an unbounded old relay', async () => { - const request = vi - .fn() - .mockRejectedValue( - Object.assign(new Error('Method not found'), { code: JsonRpcErrorCode.MethodNotFound }) - ) - const provider = createProvider(request) - - await expect(provider.readDir('/home/user/project')).rejects.toThrow( - SSH_BOUNDED_READ_DIR_UNAVAILABLE_MESSAGE - ) - await expect(provider.readDir('/home/user/project')).rejects.toThrow( - SSH_BOUNDED_READ_DIR_UNAVAILABLE_MESSAGE - ) - expect(request).toHaveBeenCalledTimes(1) - expect(request).not.toHaveBeenCalledWith('fs.readDir', expect.anything()) - }) -}) diff --git a/src/main/providers/ssh-filesystem-provider-download-folder.test.ts b/src/main/providers/ssh-filesystem-provider-download-folder.test.ts index e5b2979f0f5..c875301883d 100644 --- a/src/main/providers/ssh-filesystem-provider-download-folder.test.ts +++ b/src/main/providers/ssh-filesystem-provider-download-folder.test.ts @@ -29,29 +29,6 @@ function sftpEntry(filename: string, kind: SftpEntryKind) { return { filename, longname: filename, attrs: sftpStats(kind) } } -function createSftpDirectoryHandleMethods( - readEntries: (remotePath: string) => ReturnType[] -) { - const completedPaths = new Set() - return { - opendir: vi.fn((remotePath: string, callback: (error: undefined, handle: Buffer) => void) => - callback(undefined, Buffer.from(remotePath)) - ), - readdir: vi.fn( - (handle: Buffer, callback: (error?: Error & { code?: number }, value?: unknown) => void) => { - const remotePath = handle.toString('utf8') - if (completedPaths.has(remotePath)) { - callback(Object.assign(new Error('EOF'), { code: 1 })) - return - } - completedPaths.add(remotePath) - callback(undefined, readEntries(remotePath)) - } - ), - close: vi.fn((_handle: Buffer, callback: (error?: Error) => void) => callback()) - } -} - function createMockMux() { return { request: vi.fn(), @@ -92,10 +69,14 @@ describe('SshFilesystemProvider downloadFolder', () => { (remotePath: string, callback: (err: Error | undefined, value: unknown) => void) => callback(undefined, sftpStats(remotePath === '/remote/src' ? 'directory' : 'file')) ), - ...createSftpDirectoryHandleMethods((remotePath) => - remotePath === '/remote/src' - ? [sftpEntry('index.ts', 'file'), sftpEntry('lib', 'directory')] - : [sftpEntry('a.ts', 'file')] + readdir: vi.fn( + (remotePath: string, callback: (err: Error | undefined, value: unknown) => void) => + callback( + undefined, + remotePath === '/remote/src' + ? [sftpEntry('index.ts', 'file'), sftpEntry('lib', 'directory')] + : [sftpEntry('a.ts', 'file')] + ) ), fastGet: vi.fn((_source: string, _destination: string, callback: (err?: Error) => void) => callback() @@ -114,7 +95,6 @@ describe('SshFilesystemProvider downloadFolder', () => { ['/remote/src/lib/a.ts', join(destination, 'lib', 'a.ts')] ]) await expect(stat(join(destination, 'lib'))).resolves.toMatchObject({}) - expect(sftp.close).toHaveBeenCalledTimes(2) expect(sftp.end).toHaveBeenCalledTimes(1) expect(mux.request).not.toHaveBeenCalled() }) @@ -127,7 +107,9 @@ describe('SshFilesystemProvider downloadFolder', () => { (remotePath: string, callback: (err: Error | undefined, value: unknown) => void) => callback(undefined, sftpStats(remotePath === '/remote/src' ? 'directory' : 'directory')) ), - ...createSftpDirectoryHandleMethods(() => [sftpEntry('linked-dir', 'symlink')]), + readdir: vi.fn((_path: string, callback: (err: Error | undefined, value: unknown) => void) => + callback(undefined, [sftpEntry('linked-dir', 'symlink')]) + ), fastGet: vi.fn(), end: vi.fn() } @@ -138,7 +120,6 @@ describe('SshFilesystemProvider downloadFolder', () => { ) expect(sftp.fastGet).not.toHaveBeenCalled() - expect(sftp.close).toHaveBeenCalledTimes(1) expect(sftp.end).toHaveBeenCalledTimes(1) }) @@ -149,10 +130,9 @@ describe('SshFilesystemProvider downloadFolder', () => { stat: vi.fn((_path: string, callback: (err: Error | undefined, value: unknown) => void) => callback(undefined, sftpStats('directory')) ), - ...createSftpDirectoryHandleMethods(() => [ - sftpEntry('a:b.txt', 'file'), - sftpEntry('a?b.txt', 'file') - ]), + readdir: vi.fn((_path: string, callback: (err: Error | undefined, value: unknown) => void) => + callback(undefined, [sftpEntry('a:b.txt', 'file'), sftpEntry('a?b.txt', 'file')]) + ), fastGet: vi.fn(), end: vi.fn() } @@ -163,7 +143,6 @@ describe('SshFilesystemProvider downloadFolder', () => { ) expect(sftp.fastGet).not.toHaveBeenCalled() - expect(sftp.close).toHaveBeenCalledTimes(1) expect(sftp.end).toHaveBeenCalledTimes(1) }) diff --git a/src/main/providers/ssh-filesystem-provider-sftp.ts b/src/main/providers/ssh-filesystem-provider-sftp.ts index af1af6c2ac2..a3b1b840be3 100644 --- a/src/main/providers/ssh-filesystem-provider-sftp.ts +++ b/src/main/providers/ssh-filesystem-provider-sftp.ts @@ -95,38 +95,15 @@ export function fastGetViaSftp( ) } -export async function* readDirectoryEntriesViaSftp( +export function readDirViaSftp( sftp: SFTPWrapper, dirPath: string, options?: { signal?: AbortSignal } -): AsyncGenerator { - const handle = await waitForSftpCallback( - (callback) => sftp.opendir(dirPath, callback), +): Promise { + return waitForSftpCallback( + (callback) => sftp.readdir(dirPath, callback), options ) - try { - for (;;) { - const entries = await waitForSftpCallback( - (callback) => - sftp.readdir(handle, (error, value) => { - if ((error as { code?: number } | undefined)?.code === 1) { - callback(null, false) - return - } - callback(error, value && value.length > 0 ? value : false) - }), - options - ) - if (entries === false) { - break - } - yield* entries - } - } finally { - await waitForSftpCallback((callback) => sftp.close(handle, callback), options).catch( - () => undefined - ) - } } export function statViaSftp( diff --git a/src/main/providers/ssh-filesystem-provider-stream.test.ts b/src/main/providers/ssh-filesystem-provider-stream.test.ts index 2563fbeaf7b..b66b5633503 100644 --- a/src/main/providers/ssh-filesystem-provider-stream.test.ts +++ b/src/main/providers/ssh-filesystem-provider-stream.test.ts @@ -105,54 +105,6 @@ describe('SshFilesystemProvider readFile streaming', () => { expect(result).toEqual(legacyResult) }) - it('propagates validated raster dimensions from stream metadata', async () => { - const content = Buffer.from('png-header') - mux.request.mockImplementation(async () => { - setImmediate(() => { - mux._emitMethod('fs.streamChunk', { - streamId: 3, - seq: 0, - data: content.toString('base64') - }) - mux._emitMethod('fs.streamEnd', { streamId: 3 }) - }) - return { - streamId: 3, - totalSize: content.length, - isBinary: true, - isImage: true, - mimeType: 'image/png', - imageDimensions: { width: 640, height: 480 }, - resultEncoding: 'base64' - } - }) - - await expect(provider.readFile('/home/image.png')).resolves.toEqual({ - content: content.toString('base64'), - isBinary: true, - isImage: true, - mimeType: 'image/png', - imageDimensions: { width: 640, height: 480 } - }) - }) - - it('rejects unsafe raster dimensions in stream metadata', async () => { - mux.request.mockResolvedValue({ - streamId: 4, - totalSize: 1, - isBinary: true, - isImage: true, - mimeType: 'image/png', - imageDimensions: { width: 32_769, height: 1 }, - resultEncoding: 'base64' - }) - - await expect(provider.readFile('/home/bomb.png')).rejects.toThrow( - 'Malformed file stream metadata' - ) - expect(mux.notify).toHaveBeenCalledWith('fs.cancelStream', { streamId: 4 }) - }) - it('rejects when chunk arrives out of order', async () => { const totalSize = 256 * 1024 * 2 mux.request.mockImplementation(async () => { @@ -226,7 +178,7 @@ describe('SshFilesystemProvider readFile streaming', () => { resultEncoding: 'base64' } }) - await expect(provider.readFile('/home/x.bin')).rejects.toThrow(/incomplete/i) + await expect(provider.readFile('/home/x.bin')).rejects.toThrow(/count mismatch/i) }) it('rejects a short final chunk instead of zero-filling the buffer', async () => { diff --git a/src/main/providers/ssh-filesystem-provider.test.ts b/src/main/providers/ssh-filesystem-provider.test.ts index e8409249c0c..a7061616c4a 100644 --- a/src/main/providers/ssh-filesystem-provider.test.ts +++ b/src/main/providers/ssh-filesystem-provider.test.ts @@ -63,6 +63,20 @@ describe('SshFilesystemProvider', () => { expect(provider.getConnectionId()).toBe('conn-1') }) + describe('readDir', () => { + it('sends fs.readDir request', async () => { + const entries = [ + { name: 'src', isDirectory: true, isSymlink: false }, + { name: 'README.md', isDirectory: false, isSymlink: false } + ] + mux.request.mockResolvedValue(entries) + + const result = await provider.readDir('/home/user/project') + expect(mux.request).toHaveBeenCalledWith('fs.readDir', { dirPath: '/home/user/project' }) + expect(result).toEqual(entries) + }) + }) + describe('readFile', () => { it('short-circuits on empty:true metadata without subscribing to chunks', async () => { mux.request.mockResolvedValue({ totalSize: 0, isBinary: false, empty: true }) diff --git a/src/main/providers/ssh-filesystem-provider.ts b/src/main/providers/ssh-filesystem-provider.ts index 231e695069b..74f31b8e6bf 100644 --- a/src/main/providers/ssh-filesystem-provider.ts +++ b/src/main/providers/ssh-filesystem-provider.ts @@ -25,8 +25,6 @@ import type { DirEntry, FsChangeEvent, SearchOptions, SearchResult } from '../.. import { routeSshFilesystemWatchNotification } from './ssh-filesystem-watch-notifications' import type { WorkspaceSpaceDirectoryScanResult } from '../../shared/workspace-space-types' import { isWindowsRemoteHost, type RemoteHostPlatform } from '../ssh/ssh-remote-platform' -import { SshFilesystemDirectoryReader } from './ssh-filesystem-directory-reader' -import { requestSshMarkdownDocumentPaths } from './ssh-markdown-document-listing' const WORKSPACE_SPACE_SCAN_TIMEOUT_MS = 130_000 export class SshFilesystemProvider implements IFilesystemProvider { @@ -37,7 +35,6 @@ export class SshFilesystemProvider implements IFilesystemProvider { private tempDirPromise: Promise | null = null private disposed = false private loggedStreamFallback = false - private readonly directoryReader: SshFilesystemDirectoryReader readonly downloadFolder?: IFilesystemProvider['downloadFolder'] constructor( @@ -49,7 +46,6 @@ export class SshFilesystemProvider implements IFilesystemProvider { ) { this.connectionId = connectionId this.mux = mux - this.directoryReader = new SshFilesystemDirectoryReader(mux) if (createSftp) { // Why: system SSH has raw single-file transfer but no ssh2 SFTP channel; @@ -87,11 +83,8 @@ export class SshFilesystemProvider implements IFilesystemProvider { return this.connectionId } - async readDir( - dirPath: string, - options?: { maxEntries?: number; maxRetainedBytes?: number } - ): Promise { - return this.directoryReader.readDir(dirPath, options) + async readDir(dirPath: string): Promise { + return (await this.mux.request('fs.readDir', { dirPath })) as DirEntry[] } async readFile(filePath: string): Promise { @@ -326,8 +319,6 @@ export class SshFilesystemProvider implements IFilesystemProvider { })) as string[] } - listMarkdownDocuments = (rootPath: string) => requestSshMarkdownDocumentPaths(this.mux, rootPath) - async watch( rootPath: string, callback: (events: FsChangeEvent[]) => void, diff --git a/src/main/providers/ssh-git-dispatch.test.ts b/src/main/providers/ssh-git-dispatch.test.ts index 8ca20a5e189..a975f3db475 100644 --- a/src/main/providers/ssh-git-dispatch.test.ts +++ b/src/main/providers/ssh-git-dispatch.test.ts @@ -12,7 +12,7 @@ describe('SSH Git provider registry', () => { unregisterSshGitProvider(connectionId) }) - it('uses a new generation after unregister without retaining the disconnected id', () => { + it('keeps provider generations monotonic across unregister and re-register', () => { const before = getSshGitProviderGeneration(connectionId) registerSshGitProvider(connectionId, {} as never) const registered = getSshGitProviderGeneration(connectionId) @@ -21,18 +21,8 @@ describe('SSH Git provider registry', () => { registerSshGitProvider(connectionId, {} as never) const reRegistered = getSshGitProviderGeneration(connectionId) - expect(registered).toBeGreaterThan(before) - expect(unregistered).toBe(0) - expect(reRegistered).toBeGreaterThan(registered) - }) - - it('releases generations for thousands of unique disconnected ids', () => { - for (let index = 0; index < 10_000; index += 1) { - const id = `transient-${index}` - registerSshGitProvider(id, {} as never) - expect(getSshGitProviderGeneration(id)).toBeGreaterThan(0) - unregisterSshGitProvider(id) - expect(getSshGitProviderGeneration(id)).toBe(0) - } + expect(registered).toBe(before + 1) + expect(unregistered).toBe(registered + 1) + expect(reRegistered).toBe(unregistered + 1) }) }) diff --git a/src/main/providers/ssh-git-dispatch.ts b/src/main/providers/ssh-git-dispatch.ts index ea968a0413e..c0acc757ada 100644 --- a/src/main/providers/ssh-git-dispatch.ts +++ b/src/main/providers/ssh-git-dispatch.ts @@ -2,20 +2,19 @@ import type { SshGitProvider } from './ssh-git-provider' const sshProviders = new Map() const sshProviderGenerations = new Map() -let nextSshProviderGeneration = 1 export const SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE = 'Remote connection dropped. Click Reconnect on the SSH target before retrying.' export function registerSshGitProvider(connectionId: string, provider: SshGitProvider): void { sshProviders.set(connectionId, provider) - sshProviderGenerations.set(connectionId, nextSshProviderGeneration) - nextSshProviderGeneration += 1 + sshProviderGenerations.set(connectionId, (sshProviderGenerations.get(connectionId) ?? 0) + 1) } export function unregisterSshGitProvider(connectionId: string): void { - sshProviders.delete(connectionId) - sshProviderGenerations.delete(connectionId) + if (sshProviders.delete(connectionId)) { + sshProviderGenerations.set(connectionId, (sshProviderGenerations.get(connectionId) ?? 0) + 1) + } } export function getSshGitProviderGeneration(connectionId: string): number { diff --git a/src/main/providers/ssh-markdown-document-listing.test.ts b/src/main/providers/ssh-markdown-document-listing.test.ts deleted file mode 100644 index 71272b09c5a..00000000000 --- a/src/main/providers/ssh-markdown-document-listing.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { MarkdownDocumentListingCapacityError } from '../../shared/markdown-document-listing-limits' -import { JsonRpcErrorCode, RelayErrorCode } from '../ssh/relay-protocol' -import { requestSshMarkdownDocumentPaths } from './ssh-markdown-document-listing' - -describe('SSH Markdown document listing', () => { - it('requests producer-filtered Markdown paths', async () => { - const request = vi.fn().mockResolvedValue(['README.md', 'docs/guide.mdx']) - - await expect( - requestSshMarkdownDocumentPaths({ request } as never, '/home/user/project') - ).resolves.toEqual(['README.md', 'docs/guide.mdx']) - expect(request).toHaveBeenCalledWith('fs.listMarkdownDocuments', { - rootPath: '/home/user/project' - }) - }) - - it('surfaces typed producer capacity failures', async () => { - const request = vi.fn().mockRejectedValue( - Object.assign(new Error('capacity'), { - code: RelayErrorCode.MarkdownDocumentListingCapacity - }) - ) - - await expect( - requestSshMarkdownDocumentPaths({ request } as never, '/home/user/project') - ).rejects.toBeInstanceOf(MarkdownDocumentListingCapacityError) - }) - - it('requires reconnect instead of using an unbounded old-relay fallback', async () => { - const request = vi - .fn() - .mockRejectedValue( - Object.assign(new Error('missing'), { code: JsonRpcErrorCode.MethodNotFound }) - ) - - await expect( - requestSshMarkdownDocumentPaths({ request } as never, '/home/user/project') - ).rejects.toThrow('Reconnect the SSH target') - expect(request).toHaveBeenCalledTimes(1) - }) -}) diff --git a/src/main/providers/ssh-markdown-document-listing.ts b/src/main/providers/ssh-markdown-document-listing.ts deleted file mode 100644 index 9061b98aac6..00000000000 --- a/src/main/providers/ssh-markdown-document-listing.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' -import { isMethodNotFoundError } from '../ssh/ssh-filesystem-stream-reader' -import { RelayErrorCode } from '../ssh/relay-protocol' -import { MarkdownDocumentListingCapacityError } from '../../shared/markdown-document-listing-limits' - -export async function requestSshMarkdownDocumentPaths( - mux: SshChannelMultiplexer, - rootPath: string -): Promise { - try { - return (await mux.request('fs.listMarkdownDocuments', { rootPath })) as string[] - } catch (error) { - if ( - error instanceof Error && - 'code' in error && - (error as { code?: unknown }).code === RelayErrorCode.MarkdownDocumentListingCapacity - ) { - throw new MarkdownDocumentListingCapacityError() - } - if (isMethodNotFoundError(error)) { - throw new Error( - 'Remote Markdown link discovery is unavailable. Reconnect the SSH target and retry.' - ) - } - throw error - } -} diff --git a/src/main/providers/ssh-pty-live-roster.test.ts b/src/main/providers/ssh-pty-live-roster.test.ts deleted file mode 100644 index aac1d5adcf4..00000000000 --- a/src/main/providers/ssh-pty-live-roster.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - MAX_SSH_PTY_LIVE_ROSTER_ENTRIES, - MAX_SSH_PTY_LIVE_ROSTER_ID_BYTES, - SshPtyLiveRoster -} from './ssh-pty-live-roster' -import { MAX_SSH_APP_PTY_ID_BYTES } from './ssh-pty-wire-admission' - -describe('SshPtyLiveRoster', () => { - it('preserves ordinary listing and exit behavior', () => { - const roster = new SshPtyLiveRoster() - const listing = roster.beginListing() - - roster.reconcileListing(listing, ['pty-1']) - expect(roster.has('pty-1')).toBe(true) - - roster.recordExit('pty-1') - expect(roster.has('pty-1')).toBe(false) - }) - - it('gives a locally spawned PTY one stale-list grace', () => { - const roster = new SshPtyLiveRoster() - roster.recordSpawn('pty-new') - - roster.reconcileListing(roster.beginListing(), []) - expect(roster.has('pty-new')).toBe(true) - - roster.reconcileListing(roster.beginListing(), []) - expect(roster.has('pty-new')).toBe(false) - }) - - it('keeps a PTY observed while a listing is in flight', () => { - const roster = new SshPtyLiveRoster() - const listing = roster.beginListing() - - roster.recordNotification('pty-new') - roster.reconcileListing(listing, []) - - expect(roster.has('pty-new')).toBe(true) - }) - - it('ignores an older listing after a newer listing reconciles', () => { - const roster = new SshPtyLiveRoster() - roster.recordNotification('pty-live') - const older = roster.beginListing() - const newer = roster.beginListing() - - roster.reconcileListing(newer, ['pty-live']) - roster.reconcileListing(older, []) - - expect(roster.has('pty-live')).toBe(true) - }) - - it('does not resurrect an exit with an in-flight stale listing', () => { - const roster = new SshPtyLiveRoster() - roster.recordNotification('pty-exited') - const listing = roster.beginListing() - - roster.recordExit('pty-exited') - roster.reconcileListing(listing, ['pty-exited']) - - expect(roster.has('pty-exited')).toBe(false) - }) - - it('caps missed-exit unique-id churn and evicts the oldest live row', () => { - const roster = new SshPtyLiveRoster() - const total = MAX_SSH_PTY_LIVE_ROSTER_ENTRIES + 50 - - for (let index = 0; index < total; index += 1) { - roster.recordNotification(`pty-${index}`) - } - - expect(roster.has('pty-0')).toBe(false) - expect(roster.has(`pty-${total - MAX_SSH_PTY_LIVE_ROSTER_ENTRIES}`)).toBe(true) - expect(roster.has(`pty-${total - 1}`)).toBe(true) - }) - - it('does not let exit fences evict supported live entries', () => { - const roster = new SshPtyLiveRoster() - for (let index = 0; index < 50; index += 1) { - roster.recordNotification(`live-${index}`) - } - for (let index = 0; index < MAX_SSH_PTY_LIVE_ROSTER_ENTRIES * 2; index += 1) { - roster.recordExit(`gone-${index}`) - } - - for (let index = 0; index < 50; index += 1) { - expect(roster.has(`live-${index}`)).toBe(true) - } - }) - - it('rejects oversized ids and caps aggregate retained id bytes', () => { - const roster = new SshPtyLiveRoster() - const oversized = 'x'.repeat(MAX_SSH_APP_PTY_ID_BYTES + 1) - roster.recordNotification(oversized) - expect(roster.has(oversized)).toBe(false) - - const payload = 'x'.repeat(16 * 1024 - 32) - const total = Math.floor(MAX_SSH_PTY_LIVE_ROSTER_ID_BYTES / Buffer.byteLength(payload)) + 2 - for (let index = 0; index < total; index += 1) { - roster.recordNotification(`${index}:${payload}`) - } - - expect(roster.has(`0:${payload}`)).toBe(false) - expect(roster.has(`${total - 1}:${payload}`)).toBe(true) - }) - - it('ignores a listing response that completes after clear', () => { - const roster = new SshPtyLiveRoster() - const listing = roster.beginListing() - - roster.clear() - roster.reconcileListing(listing, ['pty-stale']) - - expect(roster.has('pty-stale')).toBe(false) - }) - - it('does not resurrect an exit after its tombstone is evicted', () => { - const roster = new SshPtyLiveRoster() - roster.recordSpawn('pty-reused') - const stale = roster.beginListing() - roster.recordExit('pty-reused') - for (let index = 0; index < MAX_SSH_PTY_LIVE_ROSTER_ENTRIES; index += 1) { - roster.recordExit(`gone-${index}`) - } - - roster.reconcileListing(stale, ['pty-reused']) - expect(roster.has('pty-reused')).toBe(false) - - const fresh = roster.beginListing() - roster.reconcileListing(fresh, ['pty-reused']) - expect(roster.has('pty-reused')).toBe(true) - }) -}) diff --git a/src/main/providers/ssh-pty-live-roster.ts b/src/main/providers/ssh-pty-live-roster.ts deleted file mode 100644 index cafbdb53e5e..00000000000 --- a/src/main/providers/ssh-pty-live-roster.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { admittedSshAppPtyIdBytes } from './ssh-pty-wire-admission' - -export const MAX_SSH_PTY_LIVE_ROSTER_ENTRIES = 256 -export const MAX_SSH_PTY_LIVE_ROSTER_ID_BYTES = 2 * 1024 * 1024 - -export type SshPtyListingToken = { - sequence: number - observationRevision: number -} - -type RosterEntry = { - idBytes: number - live: boolean - missingListingGrace: boolean - observationRevision: number -} - -export class SshPtyLiveRoster { - private readonly entries = new Map() - private observationRevision = 0 - private listingSequence = 0 - private latestReconciledListing = 0 - private retainedIdBytes = 0 - private staleListingFenceRevision = 0 - - beginListing(): SshPtyListingToken { - return { - sequence: ++this.listingSequence, - observationRevision: this.observationRevision - } - } - - recordSpawn(id: string): void { - this.record(id, true) - } - - recordNotification(id: string): void { - this.record(id, false) - } - - recordExit(id: string): void { - const idBytes = admittedSshAppPtyIdBytes(id) - if (idBytes === null) { - return - } - this.setEntry(id, { - idBytes, - live: false, - missingListingGrace: false, - observationRevision: ++this.observationRevision - }) - } - - reconcileListing(token: SshPtyListingToken, listedIds: readonly string[]): void { - if (token.sequence <= this.latestReconciledListing) { - return - } - this.latestReconciledListing = token.sequence - if (token.observationRevision < this.staleListingFenceRevision) { - return - } - const listed = new Set() - for (const id of listedIds) { - if (listed.size < MAX_SSH_PTY_LIVE_ROSTER_ENTRIES && admittedSshAppPtyIdBytes(id) !== null) { - listed.add(id) - } - } - - for (const [id, entry] of this.entries) { - if (!entry.live || listed.has(id) || entry.observationRevision > token.observationRevision) { - continue - } - if (entry.missingListingGrace) { - entry.missingListingGrace = false - entry.observationRevision = ++this.observationRevision - continue - } - this.removeEntry(id) - } - - for (const id of listed) { - const current = this.entries.get(id) - // Why: an exit newer than this listing request must fence its stale response. - if (current && !current.live && current.observationRevision > token.observationRevision) { - continue - } - this.record(id, false) - } - } - - has(id: string): boolean { - return this.entries.get(id)?.live === true - } - - clear(): void { - this.entries.clear() - this.retainedIdBytes = 0 - // Why: responses from listings already in flight must not repopulate a disposed provider. - this.latestReconciledListing = this.listingSequence - } - - private record(id: string, missingListingGrace: boolean): void { - const idBytes = admittedSshAppPtyIdBytes(id) - if (idBytes === null) { - return - } - this.setEntry(id, { - idBytes, - live: true, - missingListingGrace, - observationRevision: ++this.observationRevision - }) - } - - private setEntry(id: string, entry: RosterEntry): void { - this.removeEntry(id) - this.entries.set(id, entry) - this.retainedIdBytes += entry.idBytes - this.capEntries() - } - - private capEntries(): void { - while ( - this.entries.size > MAX_SSH_PTY_LIVE_ROSTER_ENTRIES || - this.retainedIdBytes > MAX_SSH_PTY_LIVE_ROSTER_ID_BYTES - ) { - let inactive: string | undefined - for (const [id, entry] of this.entries) { - if (!entry.live) { - inactive = id - break - } - } - const oldest = this.entries.keys().next().value as string | undefined - const evictedId = inactive ?? oldest ?? '' - const evicted = this.entries.get(evictedId) - if (evicted && !evicted.live) { - this.staleListingFenceRevision = Math.max( - this.staleListingFenceRevision, - evicted.observationRevision - ) - } - this.removeEntry(evictedId) - } - } - - private removeEntry(id: string): void { - const entry = this.entries.get(id) - if (!entry) { - return - } - this.retainedIdBytes -= entry.idBytes - this.entries.delete(id) - } -} diff --git a/src/main/providers/ssh-pty-notification-routing.ts b/src/main/providers/ssh-pty-notification-routing.ts new file mode 100644 index 00000000000..63721877f98 --- /dev/null +++ b/src/main/providers/ssh-pty-notification-routing.ts @@ -0,0 +1,56 @@ +import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' +import { isPtyIncarnationId } from '../../shared/pty-incarnation' +import type { + SshPtyDataCallback, + SshPtyExitCallback, + SshPtyReplayCallback +} from './ssh-pty-provider-contract' + +export type { SshPtyDataCallback, SshPtyExitCallback, SshPtyReplayCallback } + +export function subscribeSshPtyNotifications(args: { + mux: SshChannelMultiplexer + toAppPtyId: (id: string) => string + dataListeners: Set + replayListeners: Set + exitListeners: Set + livePtyIds: Set + recordExit: (relayPtyId: string, incarnationId: unknown) => void +}): () => void { + return args.mux.onNotification((method, params) => { + const id = args.toAppPtyId(params.id as string) + if (method === 'pty.exit') { + args.recordExit(params.id as string, params.incarnationId) + args.livePtyIds.delete(id) + for (const listener of args.exitListeners) { + listener({ + id, + code: params.code as number, + ...(isPtyIncarnationId(params.incarnationId) + ? { incarnationId: params.incarnationId } + : {}) + }) + } + return + } + if (method !== 'pty.data' && method !== 'pty.replay') { + return + } + args.livePtyIds.add(id) + if (method === 'pty.replay') { + for (const listener of args.replayListeners) { + listener({ id, data: params.data as string }) + } + return + } + for (const listener of args.dataListeners) { + listener({ + id, + data: params.data as string, + ...(typeof params.rawLength === 'number' ? { sequenceChars: params.rawLength } : {}), + ...(params.transformed === true ? { transformed: true } : {}), + ...(typeof params.seq === 'number' ? { seq: params.seq } : {}) + }) + } + }) +} diff --git a/src/main/providers/ssh-pty-output-delivery-credit.test.ts b/src/main/providers/ssh-pty-output-delivery-credit.test.ts deleted file mode 100644 index e3df99902c9..00000000000 --- a/src/main/providers/ssh-pty-output-delivery-credit.test.ts +++ /dev/null @@ -1,281 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { - MAX_SSH_PTY_CREDIT_SEGMENTS, - MAX_SSH_PTY_CREDIT_SEGMENTS_PER_STATE, - MAX_SSH_PTY_CREDIT_STATE_ID_BYTES, - MAX_SSH_PTY_CREDIT_STATES, - SshPtyOutputDeliveryCredit -} from './ssh-pty-output-delivery-credit' -import { - MAX_SSH_PTY_DELIVERY_TOKEN_BYTES, - MAX_SSH_RELAY_PTY_ID_BYTES -} from './ssh-pty-wire-admission' - -function retainedStates(credit: SshPtyOutputDeliveryCredit): Map { - return Reflect.get(credit, 'states') as Map -} - -describe('SshPtyOutputDeliveryCredit', () => { - it('defers tokenized credit until the captured owner acknowledges renderer progress', () => { - const notifyAck = vi.fn() - const credit = new SshPtyOutputDeliveryCredit(notifyAck) - let upstreamCredit: { charCount: number; acknowledge(chars: number): void } | undefined - - credit.ingest( - { - id: 'pty-1', - data: 'output', - rawLength: 10, - deliveryToken: 'delivery-1' - }, - (output) => { - upstreamCredit = output.upstreamCredit - } - ) - - expect(notifyAck).not.toHaveBeenCalled() - upstreamCredit?.acknowledge(4) - upstreamCredit?.acknowledge(20) - - expect(notifyAck).toHaveBeenNthCalledWith(1, { - id: 'pty-1', - charCount: 4, - deliveryToken: 'delivery-1' - }) - expect(notifyAck).toHaveBeenNthCalledWith(2, { - id: 'pty-1', - charCount: 6, - deliveryToken: 'delivery-1' - }) - }) - - it('acknowledges a later dropped segment without consuming earlier visible credit', () => { - const notifyAck = vi.fn() - const credit = new SshPtyOutputDeliveryCredit(notifyAck) - let first: { acknowledge(chars: number): void } | undefined - let second: { acknowledge(chars: number): void } | undefined - credit.ingest({ id: 'pty-1', data: 'first', deliveryToken: 'delivery-1' }, (output) => { - first = output.upstreamCredit - }) - credit.ingest({ id: 'pty-1', data: 'second', deliveryToken: 'delivery-1' }, (output) => { - second = output.upstreamCredit - }) - - second?.acknowledge(6) - first?.acknowledge(5) - - expect(notifyAck.mock.calls).toEqual([ - [{ id: 'pty-1', charCount: 6, deliveryToken: 'delivery-1' }], - [{ id: 'pty-1', charCount: 5, deliveryToken: 'delivery-1' }] - ]) - }) - - it('walks token segments in order for legacy provider acknowledgement calls', () => { - const notifyAck = vi.fn() - const credit = new SshPtyOutputDeliveryCredit(notifyAck) - credit.ingest({ id: 'pty-1', data: 'first', deliveryToken: 'token-1' }, () => {}) - credit.ingest({ id: 'pty-1', data: 'second', deliveryToken: 'token-1' }, () => {}) - - credit.acknowledgeLegacy('pty-1', 11) - - expect(notifyAck.mock.calls).toEqual([ - [{ id: 'pty-1', charCount: 5, deliveryToken: 'token-1' }], - [{ id: 'pty-1', charCount: 6, deliveryToken: 'token-1' }] - ]) - }) - - it('drops stale credit when attach rotates the delivery token', () => { - const notifyAck = vi.fn() - const credit = new SshPtyOutputDeliveryCredit(notifyAck) - let stale: { acknowledge(chars: number): void } | undefined - let current: { acknowledge(chars: number): void } | undefined - credit.ingest({ id: 'pty-1', data: 'stale', deliveryToken: 'before-attach' }, (output) => { - stale = output.upstreamCredit - }) - credit.ingest({ id: 'pty-1', data: 'current', deliveryToken: 'after-attach' }, (output) => { - current = output.upstreamCredit - }) - - current?.acknowledge(7) - stale?.acknowledge(5) - credit.ingest({ id: 'pty-1', data: 'next', deliveryToken: 'after-attach' }, () => {}) - credit.acknowledgeLegacy('pty-1', 4) - - expect(notifyAck.mock.calls).toEqual([ - [{ id: 'pty-1', charCount: 7, deliveryToken: 'after-attach' }], - [{ id: 'pty-1', charCount: 4, deliveryToken: 'after-attach' }] - ]) - }) - - it('preserves legacy no-token acknowledgements', () => { - const notifyAck = vi.fn() - const credit = new SshPtyOutputDeliveryCredit(notifyAck) - credit.ingest({ id: 'pty-1', data: 'legacy' }, () => {}) - - credit.acknowledgeLegacy('pty-1', 6) - - expect(notifyAck).toHaveBeenCalledWith({ id: 'pty-1', charCount: 6 }) - }) - - it('falls back to delivered length when raw length is invalid', () => { - const notifyAck = vi.fn() - const credit = new SshPtyOutputDeliveryCredit(notifyAck) - let upstreamCredit: { acknowledge(chars: number): void } | undefined - credit.ingest( - { - id: 'pty-1', - data: 'output', - rawLength: Number.POSITIVE_INFINITY, - deliveryToken: 'delivery-2' - }, - (output) => { - upstreamCredit = output.upstreamCredit - } - ) - - upstreamCredit?.acknowledge(100) - - expect(notifyAck).toHaveBeenCalledWith({ - id: 'pty-1', - charCount: 6, - deliveryToken: 'delivery-2' - }) - }) - - it('makes captured credit inert after exit or disposal', () => { - const notifyAck = vi.fn() - const credit = new SshPtyOutputDeliveryCredit(notifyAck) - let exited: { acknowledge(chars: number): void } | undefined - let disposed: { acknowledge(chars: number): void } | undefined - credit.ingest({ id: 'pty-1', data: 'exit', deliveryToken: 'delivery-3' }, (output) => { - exited = output.upstreamCredit - }) - credit.release('pty-1') - exited?.acknowledge(4) - credit.ingest({ id: 'pty-2', data: 'dispose', deliveryToken: 'delivery-4' }, (output) => { - disposed = output.upstreamCredit - }) - - credit.dispose() - disposed?.acknowledge(7) - - expect(notifyAck).not.toHaveBeenCalled() - }) - - it('returns delivery credit when listener ingestion throws', () => { - const notifyAck = vi.fn() - const credit = new SshPtyOutputDeliveryCredit(notifyAck) - - expect(() => - credit.ingest({ id: 'pty-1', data: 'output', deliveryToken: 'delivery-5' }, () => { - throw new Error('listener failed') - }) - ).toThrow('listener failed') - expect(notifyAck).toHaveBeenCalledWith({ - id: 'pty-1', - charCount: 6, - deliveryToken: 'delivery-5' - }) - }) - - it('rejects oversized ids and delivery tokens before retention', () => { - const deliver = vi.fn() - const credit = new SshPtyOutputDeliveryCredit(vi.fn()) - - credit.ingest( - { - id: 'x'.repeat(MAX_SSH_RELAY_PTY_ID_BYTES + 1), - data: 'output', - deliveryToken: 'token' - }, - deliver - ) - credit.ingest( - { - id: 'pty-1', - data: 'output', - deliveryToken: 'x'.repeat(MAX_SSH_PTY_DELIVERY_TOKEN_BYTES + 1) - }, - deliver - ) - - expect(deliver).not.toHaveBeenCalled() - expect(retainedStates(credit)).toHaveLength(0) - }) - - it('caps unique-id credit state churn and returns evicted credit', () => { - const notifyAck = vi.fn() - const credit = new SshPtyOutputDeliveryCredit(notifyAck) - for (let index = 0; index < MAX_SSH_PTY_CREDIT_STATES + 50; index += 1) { - credit.ingest({ id: `pty-${index}`, data: 'x', deliveryToken: `token-${index}` }, () => {}) - } - - const states = retainedStates(credit) - expect(states.size).toBe(MAX_SSH_PTY_CREDIT_STATES) - expect(states.has('pty-0')).toBe(false) - expect(states.has(`pty-${MAX_SSH_PTY_CREDIT_STATES + 49}`)).toBe(true) - expect(notifyAck).toHaveBeenCalledWith({ - id: 'pty-0', - charCount: 1, - deliveryToken: 'token-0' - }) - }) - - it('caps aggregate credit-state id bytes', () => { - const credit = new SshPtyOutputDeliveryCredit(vi.fn()) - const suffix = 'x'.repeat(MAX_SSH_RELAY_PTY_ID_BYTES - 16) - for (let index = 0; index < MAX_SSH_PTY_CREDIT_STATES; index += 1) { - credit.ingest( - { id: `pty-${index}:${suffix}`, data: 'x', deliveryToken: `token-${index}` }, - () => {} - ) - } - - const retainedBytes = [...retainedStates(credit).keys()].reduce( - (total, id) => total + Buffer.byteLength(id), - 0 - ) - expect(retainedBytes).toBeLessThanOrEqual(MAX_SSH_PTY_CREDIT_STATE_ID_BYTES) - expect(retainedStates(credit).size).toBeLessThan(MAX_SSH_PTY_CREDIT_STATES) - }) - - it('caps retained segments per state and in aggregate', () => { - const credit = new SshPtyOutputDeliveryCredit(vi.fn()) - const perStateTotal = MAX_SSH_PTY_CREDIT_SEGMENTS_PER_STATE + 50 - for (let index = 0; index < perStateTotal; index += 1) { - credit.ingest({ id: 'pty-one', data: 'x', deliveryToken: 'token-one' }, () => {}) - } - expect(retainedStates(credit).get('pty-one')?.segments).toHaveLength( - MAX_SSH_PTY_CREDIT_SEGMENTS_PER_STATE - ) - - for (let state = 0; state < 8; state += 1) { - for (let segment = 0; segment < MAX_SSH_PTY_CREDIT_SEGMENTS_PER_STATE; segment += 1) { - credit.ingest({ id: `pty-${state}`, data: 'x', deliveryToken: `token-${state}` }, () => {}) - } - } - - const retained = [...retainedStates(credit).values()].reduce( - (total, state) => total + state.segments.length, - 0 - ) - expect(retained).toBe(MAX_SSH_PTY_CREDIT_SEGMENTS) - }) - - it('returns unretained direct credit when state churn evicts its owner', () => { - const notifyAck = vi.fn() - const credit = new SshPtyOutputDeliveryCredit(notifyAck) - for (let index = 0; index <= MAX_SSH_PTY_CREDIT_SEGMENTS_PER_STATE; index += 1) { - credit.ingest({ id: 'pty-direct', data: 'x', deliveryToken: 'token-direct' }, () => {}) - } - for (let index = 0; index < MAX_SSH_PTY_CREDIT_STATES; index += 1) { - credit.ingest({ id: `pty-${index}`, data: 'x', deliveryToken: `token-${index}` }, () => {}) - } - - expect(notifyAck).toHaveBeenCalledWith({ - id: 'pty-direct', - charCount: MAX_SSH_PTY_CREDIT_SEGMENTS_PER_STATE + 1, - deliveryToken: 'token-direct' - }) - }) -}) diff --git a/src/main/providers/ssh-pty-output-delivery-credit.ts b/src/main/providers/ssh-pty-output-delivery-credit.ts deleted file mode 100644 index 1c0c40ad007..00000000000 --- a/src/main/providers/ssh-pty-output-delivery-credit.ts +++ /dev/null @@ -1,280 +0,0 @@ -import type { PtyDataUpstreamCredit } from './pty-provider-events' -import { - admittedSshRelayPtyIdBytes, - isAdmittedSshDeliveryToken, - isAdmittedSshRelayPtyId -} from './ssh-pty-wire-admission' - -export const MAX_SSH_PTY_CREDIT_STATES = 256 -export const MAX_SSH_PTY_CREDIT_STATE_ID_BYTES = 512 * 1024 -export const MAX_SSH_PTY_CREDIT_SEGMENTS_PER_STATE = 4096 -export const MAX_SSH_PTY_CREDIT_SEGMENTS = 16_384 - -export type SshPtyOutputDelivery = { - relayId: string - data: string - rawLength?: number - transformed: boolean - seq?: number - upstreamCredit?: PtyDataUpstreamCredit -} - -type AckParams = { - id: string - charCount: number - deliveryToken?: string -} - -export class SshPtyOutputDeliveryCredit { - private readonly states = new Map() - private disposed = false - private retainedStateIdBytes = 0 - private retainedSegments = 0 - - constructor(private readonly notifyAck: (params: AckParams) => void) {} - - ingest(params: Record, deliver: (output: SshPtyOutputDelivery) => void): void { - const relayId = params.id - const data = params.data - if (!isAdmittedSshRelayPtyId(relayId) || typeof data !== 'string') { - return - } - if (params.deliveryToken !== undefined && !isAdmittedSshDeliveryToken(params.deliveryToken)) { - return - } - const deliveryToken = params.deliveryToken as string | undefined - const rawLength = - typeof params.rawLength === 'number' && - Number.isSafeInteger(params.rawLength) && - params.rawLength >= 0 - ? params.rawLength - : undefined - const seq = - typeof params.seq === 'number' && Number.isSafeInteger(params.seq) && params.seq >= 0 - ? params.seq - : undefined - const charCount = rawLength ?? data.length - const upstreamCredit = this.createCredit(relayId, charCount, deliveryToken) - try { - deliver({ - relayId, - data, - ...(rawLength === undefined ? {} : { rawLength }), - transformed: params.transformed === true, - ...(seq === undefined ? {} : { seq }), - ...(upstreamCredit ? { upstreamCredit } : {}) - }) - } catch (error) { - upstreamCredit?.acknowledge(charCount) - throw error - } - } - - acknowledgeLegacy(relayId: string, charCount: number): void { - if ( - !isAdmittedSshRelayPtyId(relayId) || - !Number.isFinite(charCount) || - charCount <= 0 || - this.disposed - ) { - return - } - const state = this.states.get(relayId) - if (!state) { - this.notifyAck({ id: relayId, charCount: Math.floor(charCount) }) - return - } - let remaining = Math.floor(charCount) - while (remaining > 0 && state.segments.length > 0) { - const segment = state.segments[0] - const acknowledged = Math.min(remaining, segment.remainingChars) - this.acknowledgeSegment(state, segment, acknowledged) - remaining -= acknowledged - } - if (remaining > 0 && !state.tokenized) { - this.notifyAck({ id: relayId, charCount: remaining }) - } - } - - release(relayId: string): void { - const state = this.states.get(relayId) - if (!state) { - return - } - this.removeState(state, false) - } - - dispose(): void { - this.disposed = true - for (const state of this.states.values()) { - state.active = false - this.clearSegments(state) - } - this.states.clear() - this.retainedStateIdBytes = 0 - } - - private createCredit( - relayId: string, - charCount: number, - deliveryToken: string | undefined - ): PtyDataUpstreamCredit | undefined { - if (this.disposed || charCount <= 0 || !deliveryToken) { - return undefined - } - let existing = this.states.get(relayId) - // Why: pty.attach rotates the relay token; stale renderer credit from the prior - // generation must not pin every later acknowledged segment behind it. - if (existing && existing.deliveryToken !== deliveryToken) { - this.removeState(existing, false) - existing = undefined - } - const state = - existing?.active === true - ? existing - : { - relayId, - idBytes: admittedSshRelayPtyIdBytes(relayId) ?? 0, - active: true, - tokenized: true, - deliveryToken, - segments: [], - directOutstandingChars: 0 - } - if (!existing) { - this.retainedStateIdBytes += state.idBytes - } else { - this.states.delete(relayId) - } - this.states.set(relayId, state) - this.capStates() - if ( - state.segments.length >= MAX_SSH_PTY_CREDIT_SEGMENTS_PER_STATE || - this.retainedSegments >= MAX_SSH_PTY_CREDIT_SEGMENTS - ) { - return this.createDirectCredit(state, charCount) - } - const segment: PtyCreditSegment = { - deliveryToken, - remainingChars: charCount - } - state.segments.push(segment) - this.retainedSegments++ - return { - charCount, - acknowledge: (requestedChars) => { - if (!Number.isFinite(requestedChars) || requestedChars <= 0) { - return - } - this.acknowledgeSegment(state, segment, Math.floor(requestedChars)) - } - } - } - - private acknowledgeSegment( - state: PtyCreditState, - segment: PtyCreditSegment, - requestedChars: number - ): void { - if (this.disposed || !state.active || requestedChars <= 0 || segment.remainingChars <= 0) { - return - } - const charCount = Math.min(requestedChars, segment.remainingChars) - segment.remainingChars -= charCount - this.notifyAck({ - id: state.relayId, - charCount, - ...(segment.deliveryToken ? { deliveryToken: segment.deliveryToken } : {}) - }) - while (state.segments[0]?.remainingChars === 0) { - state.segments.shift() - this.retainedSegments-- - } - } - - private createDirectCredit(state: PtyCreditState, charCount: number): PtyDataUpstreamCredit { - let remainingChars = charCount - state.directOutstandingChars += charCount - return { - charCount, - acknowledge: (requestedChars) => { - if ( - this.disposed || - !state.active || - state.deliveryToken.length === 0 || - !Number.isFinite(requestedChars) || - requestedChars <= 0 || - remainingChars <= 0 - ) { - return - } - const acknowledged = Math.min(Math.floor(requestedChars), remainingChars) - remainingChars -= acknowledged - state.directOutstandingChars -= acknowledged - this.notifyAck({ - id: state.relayId, - charCount: acknowledged, - deliveryToken: state.deliveryToken - }) - } - } - } - - private capStates(): void { - while ( - this.states.size > MAX_SSH_PTY_CREDIT_STATES || - this.retainedStateIdBytes > MAX_SSH_PTY_CREDIT_STATE_ID_BYTES - ) { - const oldest = this.states.values().next().value as PtyCreditState | undefined - if (!oldest) { - return - } - this.removeState(oldest, true) - } - } - - private removeState(state: PtyCreditState, returnOutstanding: boolean): void { - state.active = false - if (returnOutstanding) { - const outstandingChars = - state.directOutstandingChars + - state.segments.reduce((total, segment) => total + segment.remainingChars, 0) - if (outstandingChars > 0) { - try { - this.notifyAck({ - id: state.relayId, - charCount: outstandingChars, - deliveryToken: state.deliveryToken - }) - } catch { - // Best effort: eviction must still enforce the local memory bound. - } - } - } - state.directOutstandingChars = 0 - this.clearSegments(state) - if (this.states.delete(state.relayId)) { - this.retainedStateIdBytes -= state.idBytes - } - } - - private clearSegments(state: PtyCreditState): void { - this.retainedSegments -= state.segments.length - state.segments.length = 0 - } -} - -type PtyCreditSegment = { - deliveryToken?: string - remainingChars: number -} - -type PtyCreditState = { - relayId: string - idBytes: number - active: boolean - tokenized: boolean - deliveryToken: string - segments: PtyCreditSegment[] - directOutstandingChars: number -} diff --git a/src/main/providers/ssh-pty-provider-contract.ts b/src/main/providers/ssh-pty-provider-contract.ts index 34d46a9148d..9043133b03c 100644 --- a/src/main/providers/ssh-pty-provider-contract.ts +++ b/src/main/providers/ssh-pty-provider-contract.ts @@ -1,5 +1,4 @@ import type { PtyIncarnationId } from '../../shared/pty-incarnation' -import type { PtyDataEvent } from './pty-provider-events' export type RemoteCliBridgeEnv = { binDir: string @@ -9,7 +8,13 @@ export type RemoteCliBridgeEnv = { pathDelimiter?: ':' | ';' } -export type SshPtyDataCallback = (payload: PtyDataEvent) => void +export type SshPtyDataCallback = (payload: { + id: string + data: string + sequenceChars?: number + transformed?: boolean + seq?: number +}) => void export type SshPtyReplayCallback = (payload: { id: string; data: string }) => void export type SshPtyExitCallback = (payload: { id: string diff --git a/src/main/providers/ssh-pty-provider-exit-race.test.ts b/src/main/providers/ssh-pty-provider-exit-race.test.ts index bca158029eb..12616e4d133 100644 --- a/src/main/providers/ssh-pty-provider-exit-race.test.ts +++ b/src/main/providers/ssh-pty-provider-exit-race.test.ts @@ -76,34 +76,3 @@ it('rejects an SSH reattach whose matching exit shares the attach reply batch', isReattach: true }) }) - -it('does not treat a malformed exit incarnation as a wildcard spawn race', async () => { - const mux = { - request: vi.fn(), - notify: vi.fn(), - onNotification: vi.fn(), - dispose: vi.fn(), - isDisposed: vi.fn().mockReturnValue(false) - } - const provider = new SshPtyProvider('conn-1', mux as never) - const exitListener = vi.fn() - provider.onExit(exitListener) - mux.request.mockImplementation(async (method: string) => { - if (method === 'pty.spawn') { - const notify = mux.onNotification.mock.calls[0]?.[0] - notify?.('pty.exit', { - id: 'pty-raced', - code: 0, - incarnationId: 'x'.repeat(129) - }) - return { id: 'pty-raced', incarnationId: 'incarnation-current' } - } - return undefined - }) - - await expect(provider.spawn({ cols: 80, rows: 24 })).resolves.toMatchObject({ - id: 'ssh:conn-1@@pty-raced', - incarnationId: 'incarnation-current' - }) - expect(exitListener).not.toHaveBeenCalled() -}) diff --git a/src/main/providers/ssh-pty-provider-notifications.ts b/src/main/providers/ssh-pty-provider-notifications.ts deleted file mode 100644 index 7d6524f0818..00000000000 --- a/src/main/providers/ssh-pty-provider-notifications.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { isPtyIncarnationId } from '../../shared/pty-incarnation' -import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' -import type { - SshPtyDataCallback, - SshPtyExitCallback, - SshPtyReplayCallback -} from './ssh-pty-provider-contract' -import { SshPtyOutputDeliveryCredit } from './ssh-pty-output-delivery-credit' -import { isAdmittedSshRelayPtyId } from './ssh-pty-wire-admission' - -export class SshPtyProviderNotifications { - private readonly dataListeners = new Set() - private readonly replayListeners = new Set() - private readonly exitListeners = new Set() - private readonly outputDeliveryCredit: SshPtyOutputDeliveryCredit - private unsubscribe: (() => void) | null - - constructor( - mux: SshChannelMultiplexer, - private readonly toAppPtyId: (relayId: string) => string, - recordNotification: (relayId: string) => void, - recordExit: (relayId: string, incarnationId: unknown) => void - ) { - this.outputDeliveryCredit = new SshPtyOutputDeliveryCredit((params) => { - mux.notify('pty.ackData', params) - }) - this.unsubscribe = mux.onNotification((method, params) => { - const relayId = params.id - if (!isAdmittedSshRelayPtyId(relayId)) { - return - } - switch (method) { - case 'pty.data': - this.emitData(params, recordNotification) - break - case 'pty.replay': { - if (typeof params.data !== 'string') { - return - } - recordNotification(relayId) - for (const listener of this.replayListeners) { - listener({ - id: this.toAppPtyId(relayId), - data: params.data - }) - } - break - } - case 'pty.exit': { - const code = params.code - const incarnationId = params.incarnationId - if ( - typeof code !== 'number' || - !Number.isSafeInteger(code) || - (incarnationId !== undefined && !isPtyIncarnationId(incarnationId)) - ) { - return - } - this.outputDeliveryCredit.release(relayId) - recordExit(relayId, incarnationId) - for (const listener of this.exitListeners) { - listener({ - id: this.toAppPtyId(relayId), - code, - ...(isPtyIncarnationId(incarnationId) ? { incarnationId } : {}) - }) - } - break - } - } - }) - } - - private emitData( - params: Record, - recordNotification: (relayId: string) => void - ): void { - this.outputDeliveryCredit.ingest(params, (output) => { - recordNotification(output.relayId) - if (this.dataListeners.size === 0) { - output.upstreamCredit?.acknowledge(output.upstreamCredit.charCount) - return - } - for (const listener of this.dataListeners) { - listener({ - id: this.toAppPtyId(output.relayId), - data: output.data, - ...(output.rawLength === undefined ? {} : { sequenceChars: output.rawLength }), - ...(output.transformed ? { transformed: true } : {}), - ...(output.seq === undefined ? {} : { seq: output.seq }), - ...(output.upstreamCredit ? { upstreamCredit: output.upstreamCredit } : {}) - }) - } - }) - } - - acknowledgeLegacy(relayId: string, charCount: number): void { - this.outputDeliveryCredit.acknowledgeLegacy(relayId, charCount) - } - - onData(callback: SshPtyDataCallback): () => void { - this.dataListeners.add(callback) - return () => this.dataListeners.delete(callback) - } - - onReplay(callback: SshPtyReplayCallback): () => void { - this.replayListeners.add(callback) - return () => this.replayListeners.delete(callback) - } - - onExit(callback: SshPtyExitCallback): () => void { - this.exitListeners.add(callback) - return () => this.exitListeners.delete(callback) - } - - dispose(): void { - this.unsubscribe?.() - this.unsubscribe = null - this.dataListeners.clear() - this.replayListeners.clear() - this.exitListeners.clear() - this.outputDeliveryCredit.dispose() - } -} diff --git a/src/main/providers/ssh-pty-provider-process-events.test.ts b/src/main/providers/ssh-pty-provider-process-events.test.ts index 4a2cf30c6b0..2d033aa40d3 100644 --- a/src/main/providers/ssh-pty-provider-process-events.test.ts +++ b/src/main/providers/ssh-pty-provider-process-events.test.ts @@ -1,14 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { SshPtyProvider } from './ssh-pty-provider' -import { - MAX_SSH_PTY_PROCESS_CWD_BYTES, - MAX_SSH_PTY_PROCESS_LIST_BYTES, - MAX_SSH_PTY_PROCESS_LIST_ENTRIES, - MAX_SSH_PTY_PROCESS_LIST_OWNERS, - MAX_SSH_PTY_PROCESS_OWNERS_PER_ENTRY, - MAX_SSH_PTY_PROCESS_TITLE_BYTES -} from './ssh-agent-session-process-list' -import { MAX_SSH_RELAY_PTY_ID_BYTES } from './ssh-pty-wire-admission' type MockMultiplexer = { request: ReturnType @@ -98,71 +89,6 @@ describe('SshPtyProvider process listings and events', () => { ]) }) - it('drops unknown process and owner fields instead of retaining remote payloads', async () => { - const unknownPayload = 'x'.repeat(1024 * 1024) - mux.request.mockResolvedValue([ - { - id: 'pty-1', - incarnationId: 'incarnation-1', - cwd: '/home', - title: 'codex', - unknownPayload, - agentSessionOwners: [ - { - claim: { - digestVersion: 1, - keyId: 'key', - identityDigest: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', - worktreeScopeDigest: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', - agent: 'codex', - unknownPayload - }, - generation: 'generation-1', - phase: 'live', - ptyId: 'pty-1', - surface: { - worktreeId: 'worktree', - tabId: 'tab', - leafId: '11111111-1111-4111-8111-111111111111', - terminalHandle: 'term_claimed', - unknownPayload - }, - unknownPayload - } - ] - } - ]) - - await expect(provider.listProcesses()).resolves.toEqual([ - { - id: scopedPty1, - incarnationId: 'incarnation-1', - cwd: '/home', - title: 'codex', - agentSessionOwners: [ - { - claim: { - digestVersion: 1, - keyId: 'key', - identityDigest: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', - worktreeScopeDigest: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', - agent: 'codex' - }, - generation: 'generation-1', - phase: 'live', - ptyId: scopedPty1, - surface: { - worktreeId: 'worktree', - tabId: 'tab', - leafId: '11111111-1111-4111-8111-111111111111', - terminalHandle: 'term_claimed' - } - } - ] - } - ]) - }) - it('rejects recovered claimed owners without PTY incarnation proof', async () => { mux.request.mockResolvedValue([ { @@ -195,126 +121,6 @@ describe('SshPtyProvider process listings and events', () => { await expect(provider.listProcesses()).rejects.toThrow('agent_session_ownership_unknown') }) - it('rejects a non-array process list before mapping ids', async () => { - mux.request.mockResolvedValue({ id: 'pty-1' }) - - await expect(provider.listProcesses()).rejects.toThrow('invalid_ssh_pty_process_list') - }) - - it('rejects an oversized process list before mapping ids', async () => { - const id = { - toString: vi.fn(() => 'pty-never-map') - } - mux.request.mockResolvedValue( - Array.from({ length: MAX_SSH_PTY_PROCESS_LIST_ENTRIES + 1 }, () => ({ - id, - cwd: '/home', - title: 'shell' - })) - ) - - await expect(provider.listProcesses()).rejects.toThrow('invalid_ssh_pty_process_list') - expect(id.toString).not.toHaveBeenCalled() - }) - - it('rejects oversized owner arrays before mapping owners', async () => { - mux.request.mockResolvedValue([ - { - id: 'pty-1', - cwd: '/home', - title: 'shell', - agentSessionOwners: Array.from( - { length: MAX_SSH_PTY_PROCESS_OWNERS_PER_ENTRY + 1 }, - () => ({}) - ) - } - ]) - - await expect(provider.listProcesses()).rejects.toThrow('invalid_ssh_pty_process_list') - }) - - it('rejects owner amplification spread across otherwise valid sessions', async () => { - const owner = { - claim: { - digestVersion: 1, - keyId: 'key', - identityDigest: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', - worktreeScopeDigest: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', - agent: 'codex' - }, - generation: 'generation-1', - phase: 'live', - ptyId: 'pty-1', - surface: { - worktreeId: 'worktree', - tabId: 'tab', - leafId: '11111111-1111-4111-8111-111111111111', - terminalHandle: 'term_claimed' - } - } - const ownersPerSession = MAX_SSH_PTY_PROCESS_OWNERS_PER_ENTRY - const sessionCount = Math.floor(MAX_SSH_PTY_PROCESS_LIST_OWNERS / ownersPerSession) + 1 - mux.request.mockResolvedValue( - Array.from({ length: sessionCount }, (_, sessionIndex) => { - const id = `pty-${sessionIndex}` - return { - id, - incarnationId: 'incarnation-1', - cwd: '/home', - title: 'shell', - agentSessionOwners: Array.from({ length: ownersPerSession }, (_, ownerIndex) => ({ - ...owner, - generation: `generation-${sessionIndex}-${ownerIndex}`, - ptyId: id - })) - } - }) - ) - - await expect(provider.listProcesses()).rejects.toThrow('invalid_ssh_pty_process_list') - }) - - it.each([ - { - field: 'id', - value: 'x'.repeat(MAX_SSH_RELAY_PTY_ID_BYTES + 1) - }, - { - field: 'cwd', - value: 'x'.repeat(MAX_SSH_PTY_PROCESS_CWD_BYTES + 1) - }, - { - field: 'title', - value: 'x'.repeat(MAX_SSH_PTY_PROCESS_TITLE_BYTES + 1) - } - ])('rejects an oversized $field before mapping', async ({ field, value }) => { - mux.request.mockResolvedValue([{ id: 'pty-1', cwd: '/home', title: 'shell', [field]: value }]) - - await expect(provider.listProcesses()).rejects.toThrow('invalid_ssh_pty_process_list') - }) - - it('rejects process-list strings above the aggregate byte budget', async () => { - const cwd = 'x'.repeat(MAX_SSH_PTY_PROCESS_CWD_BYTES) - const count = Math.floor(MAX_SSH_PTY_PROCESS_LIST_BYTES / Buffer.byteLength(cwd)) + 1 - mux.request.mockResolvedValue( - Array.from({ length: count }, (_, index) => ({ - id: `pty-${index}`, - cwd, - title: 'shell' - })) - ) - - await expect(provider.listProcesses()).rejects.toThrow('invalid_ssh_pty_process_list') - }) - - it('rejects an oversized spawn id before retention', async () => { - const oversized = 'x'.repeat(MAX_SSH_RELAY_PTY_ID_BYTES + 1) - mux.request.mockResolvedValue({ id: oversized }) - - await expect(provider.spawn({ cols: 80, rows: 24 })).rejects.toThrow('invalid_ssh_pty_id') - expect(provider.hasPty(`ssh:conn-1@@${oversized}`)).toBe(false) - }) - it('forwards data, replay, and incarnation-aware exit notifications', () => { const dataHandler = vi.fn() const replayHandler = vi.fn() @@ -327,9 +133,7 @@ describe('SshPtyProvider process listings and events', () => { notify('pty.data', { id: 'pty-1', data: 'output' }) notify('pty.data', { id: 'pty-1', data: '', rawLength: 9, seq: 9, transformed: true }) notify('pty.replay', { id: 'pty-1', data: 'buffered output' }) - expect(provider.hasPty(scopedPty1)).toBe(true) notify('pty.exit', { id: 'pty-1', code: 0, incarnationId: 'incarnation-1' }) - expect(provider.hasPty(scopedPty1)).toBe(false) expect(dataHandler).toHaveBeenNthCalledWith(1, { id: scopedPty1, data: 'output' }) expect(dataHandler).toHaveBeenNthCalledWith(2, { @@ -347,51 +151,6 @@ describe('SshPtyProvider process listings and events', () => { }) }) - it('drops malformed notification ids before routing or retention', () => { - const dataHandler = vi.fn() - const replayHandler = vi.fn() - const exitHandler = vi.fn() - provider.onData(dataHandler) - provider.onReplay(replayHandler) - provider.onExit(exitHandler) - const notify = mux.onNotification.mock.calls[0][0] - const oversized = 'x'.repeat(MAX_SSH_RELAY_PTY_ID_BYTES + 1) - - notify('pty.data', { id: 42, data: 'output', deliveryToken: 'token' }) - notify('pty.replay', { id: oversized, data: 'replay' }) - notify('pty.exit', { id: oversized, code: 0 }) - - expect(dataHandler).not.toHaveBeenCalled() - expect(replayHandler).not.toHaveBeenCalled() - expect(exitHandler).not.toHaveBeenCalled() - expect(provider.hasPty(`ssh:conn-1@@${oversized}`)).toBe(false) - expect(mux.notify).not.toHaveBeenCalled() - }) - - it('drops malformed notification payloads before liveness or exit-race mutation', () => { - const dataHandler = vi.fn() - const replayHandler = vi.fn() - const exitHandler = vi.fn() - provider.onData(dataHandler) - provider.onReplay(replayHandler) - provider.onExit(exitHandler) - const notify = mux.onNotification.mock.calls[0][0] - - notify('pty.data', { id: 'pty-bad-data', data: 42, deliveryToken: 'token' }) - notify('pty.replay', { id: 'pty-bad-replay', data: { nested: 'output' } }) - notify('pty.exit', { id: 'pty-bad-code', code: '0', incarnationId: 'incarnation-1' }) - notify('pty.exit', { id: 'pty-bad-incarnation', code: 0, incarnationId: 'x'.repeat(129) }) - - expect(dataHandler).not.toHaveBeenCalled() - expect(replayHandler).not.toHaveBeenCalled() - expect(exitHandler).not.toHaveBeenCalled() - expect(provider.hasPty('ssh:conn-1@@pty-bad-data')).toBe(false) - expect(provider.hasPty('ssh:conn-1@@pty-bad-replay')).toBe(false) - expect(provider.hasPty('ssh:conn-1@@pty-bad-code')).toBe(false) - expect(provider.hasPty('ssh:conn-1@@pty-bad-incarnation')).toBe(false) - expect(mux.notify).not.toHaveBeenCalled() - }) - it('supports listener removal, fanout, and connection namespaces', () => { const removed = vi.fn() const first = vi.fn() diff --git a/src/main/providers/ssh-pty-provider.test.ts b/src/main/providers/ssh-pty-provider.test.ts index a21b97f6319..f204369c9ef 100644 --- a/src/main/providers/ssh-pty-provider.test.ts +++ b/src/main/providers/ssh-pty-provider.test.ts @@ -32,16 +32,6 @@ describe('SshPtyProvider', () => { provider = new SshPtyProvider('conn-1', mux as never) }) - function emitRelayNotification(method: string, params: Record): void { - const callback = mux.onNotification.mock.calls[0]?.[0] as - | ((method: string, params: Record) => void) - | undefined - if (!callback) { - throw new Error('Notification listener was not registered') - } - callback(method, params) - } - it('returns the connectionId', () => { expect(provider.getConnectionId()).toBe('conn-1') }) @@ -831,47 +821,6 @@ describe('SshPtyProvider', () => { expect(mux.notify).toHaveBeenCalledWith('pty.ackData', { id: 'pty-1', charCount: 1024 }) }) - it('defers tokenized relay output credit until downstream acknowledgement', () => { - const events: Parameters[0]>[0][] = [] - provider.onData((event) => { - events.push(event) - }) - - emitRelayNotification('pty.data', { - id: 'pty-1', - data: 'clean', - rawLength: 12, - deliveryToken: 'delivery-1' - }) - - expect(events).toHaveLength(1) - expect(events[0]).toMatchObject({ id: scopedPty1, data: 'clean', sequenceChars: 12 }) - expect(mux.notify).not.toHaveBeenCalled() - - events[0]?.upstreamCredit?.acknowledge(12) - - expect(mux.notify).toHaveBeenCalledOnce() - expect(mux.notify).toHaveBeenCalledWith('pty.ackData', { - id: 'pty-1', - charCount: 12, - deliveryToken: 'delivery-1' - }) - }) - - it('restores legacy acknowledgements after tokenized PTY exit', () => { - emitRelayNotification('pty.data', { - id: 'pty-1', - data: 'output', - deliveryToken: 'delivery-3' - }) - emitRelayNotification('pty.exit', { id: 'pty-1', code: 0 }) - mux.notify.mockClear() - - provider.acknowledgeDataEvent(scopedPty1, 6) - - expect(mux.notify).toHaveBeenCalledWith('pty.ackData', { id: 'pty-1', charCount: 6 }) - }) - it('hasChildProcesses sends request and returns result', async () => { mux.request.mockResolvedValue(true) const result = await provider.hasChildProcesses(scopedPty1) diff --git a/src/main/providers/ssh-pty-provider.ts b/src/main/providers/ssh-pty-provider.ts index a5fe9bc3d0a..6dc0cb39a1d 100644 --- a/src/main/providers/ssh-pty-provider.ts +++ b/src/main/providers/ssh-pty-provider.ts @@ -8,6 +8,7 @@ import type { SshPtyExitCallback, SshPtyReplayCallback } from './ssh-pty-provider-contract' +import { subscribeSshPtyNotifications } from './ssh-pty-notification-routing' import { validateClaimedSshSpawn } from './ssh-agent-session-claim-validation' import { assertSshAgentSessionCreateResult, @@ -22,9 +23,6 @@ import { import { buildSshPtySpawnRequest } from './ssh-pty-spawn-request' import { SshPtySpawnExitRaceTracker } from './ssh-pty-spawn-exit-race' import { SshAgentSessionCapabilities } from './ssh-agent-session-capabilities' -import { SshPtyProviderNotifications } from './ssh-pty-provider-notifications' -import { SshPtyLiveRoster } from './ssh-pty-live-roster' -import { isAdmittedSshRelayPtyId } from './ssh-pty-wire-admission' // Why: sequential relay teardown calls share one absolute budget; convert to the mux-relative timeout only at dispatch. function relayTimeoutOptions(deadlineMs: number | undefined): { timeoutMs: number } | undefined { @@ -35,11 +33,15 @@ function relayTimeoutOptions(deadlineMs: number | undefined): { timeoutMs: numbe export class SshPtyProvider implements IPtyProvider { private mux: SshChannelMultiplexer private connectionId: string - private readonly livePtys = new SshPtyLiveRoster() + private dataListeners = new Set() + private replayListeners = new Set() + private exitListeners = new Set() + private livePtyIds = new Set() + // Why: stale notification callbacks must not outlive a disconnected provider. + private unsubscribeNotifications: (() => void) | null = null readonly getAppliedSize: NonNullable private readonly agentSessionCapabilities: SshAgentSessionCapabilities private spawnExitRaces = new SshPtySpawnExitRaceTracker() - private readonly notifications: SshPtyProviderNotifications constructor( connectionId: string, @@ -50,20 +52,28 @@ export class SshPtyProvider implements IPtyProvider { this.mux = mux this.agentSessionCapabilities = new SshAgentSessionCapabilities(mux) this.getAppliedSize = createSshPtyAppliedSizeReader(mux, connectionId) - this.notifications = new SshPtyProviderNotifications( + + this.unsubscribeNotifications = subscribeSshPtyNotifications({ mux, - (relayId) => this.toAppPtyId(relayId), - (relayId) => this.livePtys.recordNotification(this.toAppPtyId(relayId)), - (relayId, incarnationId) => { - this.spawnExitRaces.recordExit(relayId, incarnationId) - this.livePtys.recordExit(this.toAppPtyId(relayId)) - } - ) + toAppPtyId: (id) => this.toAppPtyId(id), + dataListeners: this.dataListeners, + replayListeners: this.replayListeners, + exitListeners: this.exitListeners, + livePtyIds: this.livePtyIds, + recordExit: (relayPtyId, incarnationId) => + this.spawnExitRaces.recordExit(relayPtyId, incarnationId) + }) } dispose(): void { - this.livePtys.clear() - this.notifications.dispose() + if (this.unsubscribeNotifications) { + this.unsubscribeNotifications() + this.unsubscribeNotifications = null + } + this.dataListeners.clear() + this.replayListeners.clear() + this.exitListeners.clear() + this.livePtyIds.clear() } getConnectionId = (): string => this.connectionId @@ -97,7 +107,7 @@ export class SshPtyProvider implements IPtyProvider { options: opts, exitRaceTracker: this.spawnExitRaces }) - this.livePtys.recordSpawn(result.id) + this.livePtyIds.add(result.id) return result } @@ -127,9 +137,6 @@ export class SshPtyProvider implements IPtyProvider { assertSshAgentSessionCreateResult(result) } const spawnResult = result as PtySpawnResult - if (!isAdmittedSshRelayPtyId(spawnResult.id)) { - throw new Error('invalid_ssh_pty_id') - } if (this.spawnExitRaces.didMatchingExitArrive(operation, spawnResult)) { // Why: relay notification can share the response batch; no controller registration may follow. throw Object.assign(new Error('agent_session_exited_during_start'), { @@ -153,7 +160,7 @@ export class SshPtyProvider implements IPtyProvider { } } const id = this.toAppPtyId(spawnResult.id) - this.livePtys.recordSpawn(id) + this.livePtyIds.add(id) return { ...spawnResult, id, @@ -232,7 +239,7 @@ export class SshPtyProvider implements IPtyProvider { }, relayTimeoutOptions(opts.deadlineMs) ) - this.livePtys.recordExit(id) + this.livePtyIds.delete(id) } async sendSignal(id: string, signal: string): Promise { @@ -261,8 +268,7 @@ export class SshPtyProvider implements IPtyProvider { } acknowledgeDataEvent(id: string, charCount: number): void { - const relayId = this.toRelayPtyId(id) - this.notifications.acknowledgeLegacy(relayId, charCount) + this.mux.notify('pty.ackData', { id: this.toRelayPtyId(id), charCount }) } async hasChildProcesses(id: string): Promise { @@ -295,22 +301,20 @@ export class SshPtyProvider implements IPtyProvider { } async listProcesses(opts?: { deadlineMs?: number }): Promise { - const listing = this.livePtys.beginListing() const result = await this.mux.request( 'pty.listProcesses', undefined, relayTimeoutOptions(opts?.deadlineMs) ) - const processes = mapSshPtyProcessList(result, (id) => this.toAppPtyId(id)) - this.livePtys.reconcileListing( - listing, - processes.map((process) => process.id) - ) + const processes = mapSshPtyProcessList(result as PtyProcessInfo[], (id) => this.toAppPtyId(id)) + for (const process of processes) { + this.livePtyIds.add(process.id) + } return processes } hasPty(id: string): boolean { - return this.livePtys.has(id) + return this.livePtyIds.has(id) } async getDefaultShell(): Promise { @@ -324,14 +328,17 @@ export class SshPtyProvider implements IPtyProvider { } onData(callback: SshPtyDataCallback): () => void { - return this.notifications.onData(callback) + this.dataListeners.add(callback) + return () => this.dataListeners.delete(callback) } onReplay(callback: SshPtyReplayCallback): () => void { - return this.notifications.onReplay(callback) + this.replayListeners.add(callback) + return () => this.replayListeners.delete(callback) } onExit(callback: SshPtyExitCallback): () => void { - return this.notifications.onExit(callback) + this.exitListeners.add(callback) + return () => this.exitListeners.delete(callback) } } diff --git a/src/main/providers/ssh-pty-spawn-exit-race.test.ts b/src/main/providers/ssh-pty-spawn-exit-race.test.ts deleted file mode 100644 index ceace5c6aba..00000000000 --- a/src/main/providers/ssh-pty-spawn-exit-race.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - MAX_PENDING_SSH_PTY_SPAWN_EXIT_OPERATIONS, - MAX_SSH_PTY_SPAWN_EXIT_BYTES_PER_OPERATION, - MAX_SSH_PTY_SPAWN_EXIT_EVENTS_PER_OPERATION, - SshPtySpawnExitRaceTracker -} from './ssh-pty-spawn-exit-race' -import { MAX_SSH_RELAY_PTY_ID_BYTES } from './ssh-pty-wire-admission' - -describe('SshPtySpawnExitRaceTracker', () => { - it('matches the same PTY incarnation without fencing a replacement', () => { - const tracker = new SshPtySpawnExitRaceTracker() - const operation = tracker.begin() - - tracker.recordExit('pty-1', 'incarnation-old') - - expect( - tracker.didMatchingExitArrive(operation, { - id: 'pty-1', - incarnationId: 'incarnation-current' - }) - ).toBe(false) - expect( - tracker.didMatchingExitArrive(operation, { - id: 'pty-1', - incarnationId: 'incarnation-old' - }) - ).toBe(true) - }) - - it('does not turn malformed incarnation metadata into a wildcard exit', () => { - const tracker = new SshPtySpawnExitRaceTracker() - const operation = tracker.begin() - - tracker.recordExit('pty-1', 'x'.repeat(129)) - - expect( - tracker.didMatchingExitArrive(operation, { - id: 'pty-1', - incarnationId: 'incarnation-current' - }) - ).toBe(false) - }) - - it('supports all 50 relay sessions without overflowing', () => { - const tracker = new SshPtySpawnExitRaceTracker() - const operation = tracker.begin() - for (let index = 0; index < 50; index += 1) { - tracker.recordExit(`pty-${index}`, `incarnation-${index}`) - } - - expect( - tracker.didMatchingExitArrive(operation, { - id: 'pty-49', - incarnationId: 'incarnation-49' - }) - ).toBe(true) - expect(Reflect.get(operation, 'overflowed')).toBe(false) - }) - - it('fails closed and releases retained exits when the count cap is exceeded', () => { - const tracker = new SshPtySpawnExitRaceTracker() - const operation = tracker.begin() - for (let index = 0; index <= MAX_SSH_PTY_SPAWN_EXIT_EVENTS_PER_OPERATION; index += 1) { - tracker.recordExit(`pty-${index}`, `incarnation-${index}`) - } - - expect(Reflect.get(operation, 'overflowed')).toBe(true) - expect(Reflect.get(operation, 'exits')).toHaveLength(0) - expect( - tracker.didMatchingExitArrive(operation, { - id: 'unseen', - incarnationId: 'unseen-incarnation' - }) - ).toBe(true) - }) - - it('fails closed when retained exit identifiers exceed the byte cap', () => { - const tracker = new SshPtySpawnExitRaceTracker() - const operation = tracker.begin() - const suffix = 'x'.repeat(MAX_SSH_RELAY_PTY_ID_BYTES - 16) - const eventBytes = Buffer.byteLength(`pty-000:${suffix}`) + Buffer.byteLength('💥'.repeat(64)) - const eventsToOverflow = Math.floor(MAX_SSH_PTY_SPAWN_EXIT_BYTES_PER_OPERATION / eventBytes) + 1 - expect(eventsToOverflow).toBeLessThan(MAX_SSH_PTY_SPAWN_EXIT_EVENTS_PER_OPERATION) - - for (let index = 0; index < eventsToOverflow; index += 1) { - tracker.recordExit(`pty-${String(index).padStart(3, '0')}:${suffix}`, '💥'.repeat(64)) - } - - expect(Reflect.get(operation, 'overflowed')).toBe(true) - expect(Reflect.get(operation, 'retainedBytes')).toBe(0) - }) - - it('rejects before dispatch when concurrent operation tracking is saturated', () => { - const tracker = new SshPtySpawnExitRaceTracker() - const admitted = Array.from({ length: MAX_PENDING_SSH_PTY_SPAWN_EXIT_OPERATIONS }, () => - tracker.begin() - ) - - expect(() => tracker.begin()).toThrow('ssh_pty_spawn_exit_tracking_capacity') - - for (const operation of admitted) { - tracker.finish(operation) - } - }) -}) diff --git a/src/main/providers/ssh-pty-spawn-exit-race.ts b/src/main/providers/ssh-pty-spawn-exit-race.ts index f0f9d677496..0658e3e23eb 100644 --- a/src/main/providers/ssh-pty-spawn-exit-race.ts +++ b/src/main/providers/ssh-pty-spawn-exit-race.ts @@ -1,55 +1,24 @@ import { isPtyIncarnationId, type PtyIncarnationId } from '../../shared/pty-incarnation' -import { admittedSshRelayPtyIdBytes } from './ssh-pty-wire-admission' - -export const MAX_SSH_PTY_SPAWN_EXIT_EVENTS_PER_OPERATION = 128 -export const MAX_SSH_PTY_SPAWN_EXIT_BYTES_PER_OPERATION = 512 * 1024 -export const MAX_PENDING_SSH_PTY_SPAWN_EXIT_OPERATIONS = 128 type PendingSshPtySpawn = { exits: { relayPtyId: string; incarnationId?: PtyIncarnationId }[] - retainedBytes: number - overflowed: boolean } export class SshPtySpawnExitRaceTracker { private pending = new Set() begin(): PendingSshPtySpawn { - if (this.pending.size >= MAX_PENDING_SSH_PTY_SPAWN_EXIT_OPERATIONS) { - throw new Error('ssh_pty_spawn_exit_tracking_capacity') - } - const operation: PendingSshPtySpawn = { - exits: [], - retainedBytes: 0, - overflowed: false - } + const operation = { exits: [] } this.pending.add(operation) return operation } recordExit(relayPtyId: string, incarnationId: unknown): void { - const idBytes = admittedSshRelayPtyIdBytes(relayPtyId) - if (idBytes === null || (incarnationId !== undefined && !isPtyIncarnationId(incarnationId))) { - return - } - const retainedBytes = - idBytes + (isPtyIncarnationId(incarnationId) ? Buffer.byteLength(incarnationId, 'utf8') : 0) for (const operation of this.pending) { - if ( - operation.overflowed || - operation.exits.length >= MAX_SSH_PTY_SPAWN_EXIT_EVENTS_PER_OPERATION || - operation.retainedBytes + retainedBytes > MAX_SSH_PTY_SPAWN_EXIT_BYTES_PER_OPERATION - ) { - operation.overflowed = true - operation.exits.length = 0 - operation.retainedBytes = 0 - continue - } operation.exits.push({ relayPtyId, ...(isPtyIncarnationId(incarnationId) ? { incarnationId } : {}) }) - operation.retainedBytes += retainedBytes } } @@ -57,21 +26,16 @@ export class SshPtySpawnExitRaceTracker { operation: PendingSshPtySpawn, result: { id: string; incarnationId?: PtyIncarnationId } ): boolean { - return ( - operation.overflowed || - operation.exits.some( - (exit) => - exit.relayPtyId === result.id && - (!exit.incarnationId || - !result.incarnationId || - exit.incarnationId === result.incarnationId) - ) + return operation.exits.some( + (exit) => + exit.relayPtyId === result.id && + (!exit.incarnationId || + !result.incarnationId || + exit.incarnationId === result.incarnationId) ) } finish(operation: PendingSshPtySpawn): void { this.pending.delete(operation) - operation.exits.length = 0 - operation.retainedBytes = 0 } } diff --git a/src/main/providers/ssh-pty-wire-admission.ts b/src/main/providers/ssh-pty-wire-admission.ts deleted file mode 100644 index af7ac167fc9..00000000000 --- a/src/main/providers/ssh-pty-wire-admission.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { hasUnsafeProviderSessionIdChars } from '../../shared/agent-session-resume' - -export const MAX_SSH_RELAY_PTY_ID_BYTES = 4 * 1024 -export const MAX_SSH_APP_PTY_ID_BYTES = 16 * 1024 -export const MAX_SSH_PTY_DELIVERY_TOKEN_BYTES = 1024 - -function admittedStringBytes(value: unknown, maxBytes: number): number | null { - if (typeof value !== 'string' || value.length === 0 || hasUnsafeProviderSessionIdChars(value)) { - return null - } - const bytes = Buffer.byteLength(value, 'utf8') - return bytes <= maxBytes ? bytes : null -} - -export function admittedSshRelayPtyIdBytes(value: unknown): number | null { - return admittedStringBytes(value, MAX_SSH_RELAY_PTY_ID_BYTES) -} - -export function admittedSshAppPtyIdBytes(value: unknown): number | null { - return admittedStringBytes(value, MAX_SSH_APP_PTY_ID_BYTES) -} - -export function isAdmittedSshRelayPtyId(value: unknown): value is string { - return admittedSshRelayPtyIdBytes(value) !== null -} - -export function isAdmittedSshDeliveryToken(value: unknown): value is string { - return admittedStringBytes(value, MAX_SSH_PTY_DELIVERY_TOKEN_BYTES) !== null -} diff --git a/src/main/providers/types.ts b/src/main/providers/types.ts index 822f540bba0..7ebe3de9f17 100644 --- a/src/main/providers/types.ts +++ b/src/main/providers/types.ts @@ -21,7 +21,6 @@ import type { GitHistoryOptions, GitHistoryResult } from '../../shared/git-histo import type { PtyStartupIngressIntent } from '../../shared/pty-startup-ingress' import type { CommitMessageDraftContext } from '../../shared/commit-message-generation' import type { WorkspaceSpaceDirectoryScanResult } from '../../shared/workspace-space-types' -import type { FilesystemPathListingProvider } from './filesystem-path-listing-provider' import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery' import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges' import type { GitProviderStatusOptions } from './git-provider-status-options' @@ -226,13 +225,10 @@ export type FileReadResult = { isBinary: boolean isImage?: boolean mimeType?: string - imageDimensions?: { width: number; height: number } } -type FilesystemDirectoryReadOptions = { maxEntries?: number; maxRetainedBytes?: number } - -export type IFilesystemProvider = FilesystemPathListingProvider & { - readDir(dirPath: string, options?: FilesystemDirectoryReadOptions): Promise +export type IFilesystemProvider = { + readDir(dirPath: string): Promise readFile(filePath: string): Promise readTerminalArtifact?( filePath: string, @@ -261,6 +257,10 @@ export type IFilesystemProvider = FilesystemPathListingProvider & { copy(source: string, destination: string): Promise realpath(filePath: string): Promise search(opts: SearchOptions): Promise + listFiles( + rootPath: string, + options?: { excludePaths?: string[]; signal?: AbortSignal; maxResults?: number } + ): Promise scanWorkspaceSpace?( rootPath: string, options?: { signal?: AbortSignal } diff --git a/src/main/pty/config-overlay-mirroring.test.ts b/src/main/pty/config-overlay-mirroring.test.ts deleted file mode 100644 index 764c35bca9e..00000000000 --- a/src/main/pty/config-overlay-mirroring.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -import type { Dirent } from 'node:fs' -import { describe, expect, it } from 'vitest' -import { - CONFIG_OVERLAY_MAX_ENTRY_NAME_BYTES, - CONFIG_OVERLAY_MAX_RETAINED_NAME_BYTES, - CONFIG_OVERLAY_MAX_SOURCE_ENTRIES, - ConfigOverlayCapacityError, - ConfigOverlayEntryBudget, - _configOverlayMirroringInternals -} from './config-overlay-mirroring' - -function fileEntry(name: string): Dirent { - return { - isBlockDevice: () => false, - isCharacterDevice: () => false, - isDirectory: () => false, - isFIFO: () => false, - isFile: () => true, - isSocket: () => false, - isSymbolicLink: () => false, - name - } as unknown as Dirent -} - -describe('config overlay entry budget', () => { - it('accepts the exact entry-count limit and rejects the next streamed entry', () => { - const budget = new ConfigOverlayEntryBudget() - for (let index = 0; index < CONFIG_OVERLAY_MAX_SOURCE_ENTRIES; index += 1) { - budget.reserve('a') - } - - expect(() => budget.reserve('a')).toThrowError( - new ConfigOverlayCapacityError( - 'entries', - CONFIG_OVERLAY_MAX_SOURCE_ENTRIES + 1, - CONFIG_OVERLAY_MAX_SOURCE_ENTRIES - ) - ) - }) - - it('accepts an exact-limit entry name and rejects one byte more', () => { - new ConfigOverlayEntryBudget().reserve('a'.repeat(CONFIG_OVERLAY_MAX_ENTRY_NAME_BYTES)) - - expect(() => - new ConfigOverlayEntryBudget().reserve('a'.repeat(CONFIG_OVERLAY_MAX_ENTRY_NAME_BYTES + 1)) - ).toThrowError( - new ConfigOverlayCapacityError( - 'entry-name-bytes', - CONFIG_OVERLAY_MAX_ENTRY_NAME_BYTES + 1, - CONFIG_OVERLAY_MAX_ENTRY_NAME_BYTES - ) - ) - }) - - it('accepts the exact aggregate encoded-name limit and rejects one more name', () => { - const budget = new ConfigOverlayEntryBudget() - const name = 'a'.repeat(4_094) - expect(Buffer.byteLength(JSON.stringify(name), 'utf8')).toBe(4_096) - - for (let index = 0; index < CONFIG_OVERLAY_MAX_RETAINED_NAME_BYTES / 4_096; index += 1) { - budget.reserve(name) - } - - expect(() => budget.reserve('a')).toThrowError( - new ConfigOverlayCapacityError( - 'retained-name-bytes', - CONFIG_OVERLAY_MAX_RETAINED_NAME_BYTES + 3, - CONFIG_OVERLAY_MAX_RETAINED_NAME_BYTES - ) - ) - }) - - it('stops reading immediately after the first over-limit entry', () => { - const entries = Array.from({ length: 10_000 }, (_, index) => fileEntry(`entry-${index}`)) - let reads = 0 - let visits = 0 - const directory = { - readSync() { - reads += 1 - return entries.shift() ?? null - } - } - - expect(() => - _configOverlayMirroringInternals.scanOpenDirectory( - directory, - new ConfigOverlayEntryBudget({ maxEntries: 2 }), - () => { - visits += 1 - } - ) - ).toThrow(ConfigOverlayCapacityError) - expect(reads).toBe(3) - expect(visits).toBe(2) - expect(entries).toHaveLength(9_997) - }) -}) diff --git a/src/main/pty/config-overlay-mirroring.ts b/src/main/pty/config-overlay-mirroring.ts deleted file mode 100644 index 237282ef1be..00000000000 --- a/src/main/pty/config-overlay-mirroring.ts +++ /dev/null @@ -1,228 +0,0 @@ -import { mkdirSync, opendirSync, realpathSync, statSync, type Dirent } from 'node:fs' -import { join } from 'node:path' -import { mirrorEntry } from './overlay-mirror' - -export const CONFIG_OVERLAY_MAX_SOURCE_ENTRIES = 4_096 -export const CONFIG_OVERLAY_MAX_ENTRY_NAME_BYTES = 4 * 1_024 -export const CONFIG_OVERLAY_MAX_RETAINED_NAME_BYTES = 1_024 * 1_024 - -export type ConfigOverlayCapacityKind = 'entries' | 'entry-name-bytes' | 'retained-name-bytes' - -export type ConfigOverlayLimits = { - maxEntries: number - maxEntryNameBytes: number - maxRetainedNameBytes: number -} - -const DEFAULT_LIMITS: ConfigOverlayLimits = { - maxEntries: CONFIG_OVERLAY_MAX_SOURCE_ENTRIES, - maxEntryNameBytes: CONFIG_OVERLAY_MAX_ENTRY_NAME_BYTES, - maxRetainedNameBytes: CONFIG_OVERLAY_MAX_RETAINED_NAME_BYTES -} - -export class ConfigOverlayCapacityError extends Error { - constructor( - readonly kind: ConfigOverlayCapacityKind, - readonly observed: number, - readonly limit: number - ) { - super(`Agent config overlay ${kind} exceeded its ${limit} limit (observed ${observed})`) - this.name = 'ConfigOverlayCapacityError' - } -} - -export class ConfigOverlayEntryBudget { - private entryCount = 0 - private retainedNameBytes = 0 - private readonly limits: ConfigOverlayLimits - - constructor(requested?: Partial) { - this.limits = { - maxEntries: resolveLimit(requested?.maxEntries, DEFAULT_LIMITS.maxEntries, 'maxEntries'), - maxEntryNameBytes: resolveLimit( - requested?.maxEntryNameBytes, - DEFAULT_LIMITS.maxEntryNameBytes, - 'maxEntryNameBytes' - ), - maxRetainedNameBytes: resolveLimit( - requested?.maxRetainedNameBytes, - DEFAULT_LIMITS.maxRetainedNameBytes, - 'maxRetainedNameBytes' - ) - } - } - - reserve(name: string): void { - const nextEntryCount = this.entryCount + 1 - if (nextEntryCount > this.limits.maxEntries) { - throw new ConfigOverlayCapacityError('entries', nextEntryCount, this.limits.maxEntries) - } - - const nameBytes = Buffer.byteLength(name, 'utf8') - if (nameBytes > this.limits.maxEntryNameBytes) { - throw new ConfigOverlayCapacityError( - 'entry-name-bytes', - nameBytes, - this.limits.maxEntryNameBytes - ) - } - assertPortableEntryName(name) - - const retainedNameBytes = Buffer.byteLength(JSON.stringify(name), 'utf8') - const nextRetainedNameBytes = this.retainedNameBytes + retainedNameBytes - if (nextRetainedNameBytes > this.limits.maxRetainedNameBytes) { - throw new ConfigOverlayCapacityError( - 'retained-name-bytes', - nextRetainedNameBytes, - this.limits.maxRetainedNameBytes - ) - } - - this.entryCount = nextEntryCount - this.retainedNameBytes = nextRetainedNameBytes - } -} - -export type ConfigOverlayPlan = { - sourceDir: string - topLevelEntryNames: string[] - pluginSourceDir: string | null - pluginEntryNames: string[] -} - -export type AppliedConfigOverlayEntries = { - topLevelEntryNames: string[] - pluginEntryNames: string[] -} - -type ConfigOverlayPlanOptions = { - reservedPluginFile: string - reservedTopLevelEntryNames?: ReadonlySet -} - -function resolveLimit(requested: number | undefined, maximum: number, name: string): number { - if (requested === undefined) { - return maximum - } - if (!Number.isSafeInteger(requested) || requested < 0) { - throw new RangeError(`${name} must be a non-negative safe integer`) - } - return Math.min(requested, maximum) -} - -function assertPortableEntryName(name: string): void { - if (name.length === 0 || name === '.' || name === '..' || /[/\\]/.test(name)) { - throw new Error('Agent config overlay contains a non-portable entry name') - } -} - -function closeDirectory(directory: ReturnType): void { - try { - directory.closeSync() - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ERR_DIR_CLOSED') { - throw error - } - } -} - -function scanOpenDirectory( - directory: Pick, 'readSync'>, - budget: ConfigOverlayEntryBudget, - visit: (entry: Dirent) => void -): void { - while (true) { - const entry = directory.readSync() - if (entry === null) { - return - } - budget.reserve(entry.name) - visit(entry) - } -} - -function scanDirectory( - path: string, - budget: ConfigOverlayEntryBudget, - visit: (entry: Dirent) => void -): void { - const directory = opendirSync(path, { bufferSize: 32 }) - try { - scanOpenDirectory(directory, budget, visit) - } finally { - closeDirectory(directory) - } -} - -export function createConfigOverlayPlan( - sourceDir: string, - options: ConfigOverlayPlanOptions, - budget = new ConfigOverlayEntryBudget() -): ConfigOverlayPlan { - const topLevelEntryNames: string[] = [] - const pluginEntryNames: string[] = [] - let pluginSourceDir: string | null = null - - scanDirectory(sourceDir, budget, (entry) => { - if (options.reservedTopLevelEntryNames?.has(entry.name)) { - return - } - - const sourcePath = join(sourceDir, entry.name) - if (entry.name === 'plugins') { - const isSymlink = entry.isSymbolicLink() - let isLinkPointingToDir = false - if (isSymlink) { - try { - isLinkPointingToDir = statSync(sourcePath).isDirectory() - } catch { - isLinkPointingToDir = false - } - } - - if ((!isSymlink && entry.isDirectory()) || isLinkPointingToDir) { - pluginSourceDir = isLinkPointingToDir ? realpathSync(sourcePath) : sourcePath - scanDirectory(pluginSourceDir, budget, (pluginEntry) => { - if (pluginEntry.name !== options.reservedPluginFile) { - pluginEntryNames.push(pluginEntry.name) - } - }) - return - } - } - - topLevelEntryNames.push(entry.name) - }) - - return { sourceDir, topLevelEntryNames, pluginSourceDir, pluginEntryNames } -} - -export function applyConfigOverlayPlan( - plan: ConfigOverlayPlan, - targetDir: string, - applied: AppliedConfigOverlayEntries = { - topLevelEntryNames: [], - pluginEntryNames: [] - } -): AppliedConfigOverlayEntries { - for (const entryName of plan.topLevelEntryNames) { - mirrorEntry(join(plan.sourceDir, entryName), join(targetDir, entryName)) - applied.topLevelEntryNames.push(entryName) - } - - if (plan.pluginSourceDir === null) { - return applied - } - - const targetPluginsDir = join(targetDir, 'plugins') - mkdirSync(targetPluginsDir, { recursive: true }) - for (const entryName of plan.pluginEntryNames) { - mirrorEntry(join(plan.pluginSourceDir, entryName), join(targetPluginsDir, entryName)) - applied.pluginEntryNames.push(entryName) - } - return applied -} - -export const _configOverlayMirroringInternals = { - scanOpenDirectory -} diff --git a/src/main/pty/overlay-mirror.test.ts b/src/main/pty/overlay-mirror.test.ts index a97a049d9c4..5499d226422 100644 --- a/src/main/pty/overlay-mirror.test.ts +++ b/src/main/pty/overlay-mirror.test.ts @@ -4,12 +4,7 @@ import { tmpdir } from 'node:os' import type * as NodePath from 'node:path' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' -import { - OVERLAY_REMOVE_MAX_DEPTH, - OVERLAY_REMOVE_MAX_ENTRIES, - safeRemoveOverlay, - safeRemoveTree -} from './overlay-mirror' +import { safeRemoveOverlay } from './overlay-mirror' const tempRoots: string[] = [] @@ -24,50 +19,6 @@ afterEach(() => { }) describe('safeRemoveOverlay', () => { - it('publishes finite production cleanup limits', () => { - expect(OVERLAY_REMOVE_MAX_ENTRIES).toBe(100_000) - expect(OVERLAY_REMOVE_MAX_DEPTH).toBe(256) - }) - - it('removes a tree at the exact entry limit', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-overlay-entry-limit-')) - tempRoots.push(root) - writeFileSync(join(root, 'one'), '') - writeFileSync(join(root, 'two'), '') - - expect(safeRemoveTree(root, { maxEntries: 2 })).toBe(true) - expect(existsSync(root)).toBe(false) - }) - - it('stops before visiting an entry beyond the limit', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-overlay-entry-limit-')) - tempRoots.push(root) - writeFileSync(join(root, 'one'), '') - writeFileSync(join(root, 'two'), '') - writeFileSync(join(root, 'three'), '') - - expect(safeRemoveTree(root, { maxEntries: 2 })).toBe(false) - expect(existsSync(root)).toBe(true) - }) - - it('accepts the exact directory depth and stops before descending one level deeper', async () => { - const acceptedRoot = await mkdtemp(join(tmpdir(), 'orca-overlay-depth-limit-')) - tempRoots.push(acceptedRoot) - mkdirSync(join(acceptedRoot, 'one', 'two'), { recursive: true }) - writeFileSync(join(acceptedRoot, 'one', 'two', 'leaf'), '') - - expect(safeRemoveTree(acceptedRoot, { maxDepth: 2 })).toBe(true) - expect(existsSync(acceptedRoot)).toBe(false) - - const rejectedRoot = await mkdtemp(join(tmpdir(), 'orca-overlay-depth-limit-')) - tempRoots.push(rejectedRoot) - mkdirSync(join(rejectedRoot, 'one', 'two'), { recursive: true }) - writeFileSync(join(rejectedRoot, 'one', 'two', 'leaf'), '') - - expect(safeRemoveTree(rejectedRoot, { maxDepth: 1 })).toBe(false) - expect(existsSync(rejectedRoot)).toBe(true) - }) - it('removes valid overlay children whose names start with dot-dot', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-overlay-root-')) tempRoots.push(root) diff --git a/src/main/pty/overlay-mirror.ts b/src/main/pty/overlay-mirror.ts index eb6206e7cf8..ec1b99d2b92 100644 --- a/src/main/pty/overlay-mirror.ts +++ b/src/main/pty/overlay-mirror.ts @@ -11,27 +11,13 @@ import { cpSync, linkSync, lstatSync, - opendirSync, + readdirSync, rmdirSync, symlinkSync, unlinkSync } from 'node:fs' import { isAbsolute, join, relative, resolve, sep } from 'node:path' -export const OVERLAY_REMOVE_MAX_ENTRIES = 100_000 -export const OVERLAY_REMOVE_MAX_DEPTH = 256 - -type OverlayRemoveLimits = { - maxEntries: number - maxDepth: number -} - -type OverlayRemoveState = { - entries: number - exhausted: boolean - limits: OverlayRemoveLimits -} - export function mirrorEntry(sourcePath: string, targetPath: string): void { // Why: lstatSync (not statSync) so that if the user's source dir contains // its OWN symlinks (e.g. skills symlinked from ~/.agents/skills), we mirror @@ -97,130 +83,67 @@ export function isSafeDescendCandidate(stats: { return stats.isDirectory() } -function resolveRemoveLimit(requested: number | undefined, maximum: number, name: string): number { - if (requested === undefined) { - return maximum - } - if (!Number.isSafeInteger(requested) || requested < 0) { - throw new RangeError(`${name} must be a non-negative safe integer`) - } - return Math.min(requested, maximum) -} - -function closeDirectory(directory: ReturnType): boolean { - try { - directory.closeSync() - return true - } catch (error) { - return (error as NodeJS.ErrnoException).code === 'ERR_DIR_CLOSED' - } -} - -function removeTreeWithinLimits(path: string, depth: number, state: OverlayRemoveState): boolean { - if (state.exhausted) { - return false - } - +// Why: the overlay tree contains symlinks/junctions that point back into the +// user's real state dir. fs.rmSync with { recursive: true } has repeatedly +// regressed on Windows when walking NTFS junctions -- it can follow them and +// delete the *target*, destroying the user's data. Never descend into a +// symlink/junction here: for any non-real-directory entry we unlink the link +// itself; only entries that are truly directories on disk are recursed into. +export function safeRemoveTree(path: string): void { let stat try { stat = lstatSync(path) - } catch (error) { - return (error as NodeJS.ErrnoException).code === 'ENOENT' + } catch { + return } // On Windows, lstat on a directory junction can report BOTH // isSymbolicLink() === true AND isDirectory() === true, so we MUST check // isSymbolicLink first -- otherwise a junction enters the recursive branch - // and enumerates the link's target, the exact bug in #1083. + // and readdirSync enumerates the link's target, the exact bug in #1083. if (!isSafeDescendCandidate(stat)) { try { unlinkSync(path) - return true } catch { // Best-effort: antivirus/indexers can hold handles briefly on Windows. // A leftover link is harmless; the next spawn rebuilds the overlay. - return false } + return } - let directory + let entries try { - directory = opendirSync(path, { bufferSize: 32 }) + entries = readdirSync(path, { withFileTypes: true }) } catch { - return false + return } - let completed = true - try { - while (!state.exhausted) { - const entry = directory.readSync() - if (entry === null) { - break - } - if (state.entries >= state.limits.maxEntries) { - state.exhausted = true - completed = false - break - } - state.entries += 1 - - const child = join(path, entry.name) - if (isSafeDescendCandidate(entry)) { - if (depth >= state.limits.maxDepth) { - state.exhausted = true - completed = false - break - } - completed = removeTreeWithinLimits(child, depth + 1, state) && completed - continue - } - try { - unlinkSync(child) - } catch { - completed = false - } + for (const entry of entries) { + const child = join(path, entry.name) + if (isSafeDescendCandidate(entry)) { + safeRemoveTree(child) + continue + } + try { + unlinkSync(child) + } catch { + // Best-effort, see above. } - } catch { - completed = false - } finally { - completed = closeDirectory(directory) && completed } try { rmdirSync(path) - return completed } catch { // Directory may be non-empty if an unlink above failed; harmless. - return false } } -// Why: the overlay tree contains symlinks/junctions that point back into the -// user's real state dir. Stream a bounded traversal and never descend into a -// link; a pathological tree is left for a later cleanup instead of growing -// an unbounded directory array or call stack in Electron's main process. -export function safeRemoveTree(path: string, requested?: Partial): boolean { - const state: OverlayRemoveState = { - entries: 0, - exhausted: false, - limits: { - maxEntries: resolveRemoveLimit( - requested?.maxEntries, - OVERLAY_REMOVE_MAX_ENTRIES, - 'maxEntries' - ), - maxDepth: resolveRemoveLimit(requested?.maxDepth, OVERLAY_REMOVE_MAX_DEPTH, 'maxDepth') - } - } - return removeTreeWithinLimits(path, 0, state) -} - // Why: last-line guard against an overlay-root constant ever being // mis-resolved. Any caller that points safeRemoveTree at a path outside its // designated overlay root is refused so a misconfiguration cannot turn into // an `rm -rf` of arbitrary user data. Logs (rather than throws) so a buggy // caller stays visible without crashing the PTY spawn. -export function safeRemoveOverlay(overlayDir: string, overlayRoot: string): boolean { +export function safeRemoveOverlay(overlayDir: string, overlayRoot: string): void { const resolvedRoot = resolve(overlayRoot) const resolvedTarget = resolve(overlayDir) const rel = relative(resolvedRoot, resolvedTarget) @@ -228,7 +151,7 @@ export function safeRemoveOverlay(overlayDir: string, overlayRoot: string): bool console.warn( `[overlay-mirror] refusing to remove overlay outside root: target=${resolvedTarget} root=${resolvedRoot}` ) - return false + return } - return safeRemoveTree(resolvedTarget) + safeRemoveTree(resolvedTarget) } diff --git a/src/main/pty/shell-startup-env-memory.test.ts b/src/main/pty/shell-startup-env-memory.test.ts deleted file mode 100644 index c6452b9f20a..00000000000 --- a/src/main/pty/shell-startup-env-memory.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const { readFileSyncMock } = vi.hoisted(() => ({ readFileSyncMock: vi.fn() })) - -vi.mock('../../shared/node-bounded-file-reader', () => ({ - readNodeFileSyncWithinLimit: (path: string, maxBytes: number) => { - const buffer = Buffer.from(readFileSyncMock(path) as string) - if (buffer.byteLength > maxBytes) { - throw new Error('File too large') - } - return { buffer, stats: { size: buffer.byteLength } } - } -})) - -import { - __resetShellStartupEnvCache, - MAX_SHELL_STARTUP_CACHE_BYTES, - MAX_SHELL_STARTUP_CACHE_ENTRIES, - MAX_SHELL_STARTUP_ENV_VALUE_CODE_UNITS, - MAX_SHELL_STARTUP_FILE_BYTES, - readShellStartupEnvVar -} from './shell-startup-env' - -describe('shell startup environment memory bounds', () => { - const originalPlatform = process.platform - - beforeEach(() => { - Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }) - readFileSyncMock.mockReset() - __resetShellStartupEnvCache() - }) - - afterEach(() => { - Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) - }) - - it('accepts an exact-size startup file and skips the next byte', () => { - const assignment = 'export TARGET=/bounded\n' - readFileSyncMock.mockReturnValue( - assignment + '#'.repeat(MAX_SHELL_STARTUP_FILE_BYTES - assignment.length) - ) - expect(readShellStartupEnvVar('TARGET', '/home/alice', '/bin/zsh')).toBe('/bounded') - - __resetShellStartupEnvCache() - readFileSyncMock.mockReturnValue( - assignment + '#'.repeat(MAX_SHELL_STARTUP_FILE_BYTES - assignment.length + 1) - ) - expect(readShellStartupEnvVar('TARGET', '/home/alice', '/bin/zsh')).toBeUndefined() - }) - - it('does not retain an oversized exported value', () => { - readFileSyncMock.mockReturnValue( - `export TARGET=${'x'.repeat(MAX_SHELL_STARTUP_ENV_VALUE_CODE_UNITS + 1)}\n` - ) - - expect(readShellStartupEnvVar('TARGET', '/home/alice', '/bin/zsh')).toBeUndefined() - }) - - it('caps cache entries with LRU recovery', () => { - readFileSyncMock.mockReturnValue('') - for (let index = 0; index <= MAX_SHELL_STARTUP_CACHE_ENTRIES; index += 1) { - readShellStartupEnvVar(`TARGET_${index}`, '/home/alice', '/bin/zsh') - } - const readsBeforeRetry = readFileSyncMock.mock.calls.length - - readShellStartupEnvVar('TARGET_0', '/home/alice', '/bin/zsh') - - expect(readFileSyncMock.mock.calls.length).toBeGreaterThan(readsBeforeRetry) - }) - - it('caps aggregate cached key/value memory independently of entry count', () => { - expect(MAX_SHELL_STARTUP_CACHE_BYTES).toBe(8 * 1024 * 1024) - const value = 'x'.repeat(MAX_SHELL_STARTUP_ENV_VALUE_CODE_UNITS) - for (let index = 0; index < 65; index += 1) { - readFileSyncMock.mockReturnValue(`export TARGET_${index}=${value}\n`) - expect(readShellStartupEnvVar(`TARGET_${index}`, '/home/alice', '/bin/zsh')).toBe(value) - } - const readsBeforeRetry = readFileSyncMock.mock.calls.length - readFileSyncMock.mockReturnValue(`export TARGET_0=${value}\n`) - - readShellStartupEnvVar('TARGET_0', '/home/alice', '/bin/zsh') - - expect(readFileSyncMock.mock.calls.length).toBeGreaterThan(readsBeforeRetry) - }) -}) diff --git a/src/main/pty/shell-startup-env.test.ts b/src/main/pty/shell-startup-env.test.ts index 2e70e2947b8..d41e6c7fdcb 100644 --- a/src/main/pty/shell-startup-env.test.ts +++ b/src/main/pty/shell-startup-env.test.ts @@ -10,16 +10,6 @@ vi.mock('fs', () => ({ readFileSync: readFileSyncMock })) -vi.mock('../../shared/node-bounded-file-reader', () => ({ - readNodeFileSyncWithinLimit: (path: string, maxBytes: number) => { - const buffer = Buffer.from(readFileSyncMock(path) as string) - if (buffer.byteLength > maxBytes) { - throw new Error('File too large') - } - return { buffer, stats: { size: buffer.byteLength } } - } -})) - import { __resetShellStartupEnvCache, readShellStartupEnvVar } from './shell-startup-env' describe('readShellStartupEnvVar', () => { diff --git a/src/main/pty/shell-startup-env.ts b/src/main/pty/shell-startup-env.ts index 0908f4603c8..83a4cad1185 100644 --- a/src/main/pty/shell-startup-env.ts +++ b/src/main/pty/shell-startup-env.ts @@ -1,12 +1,5 @@ +import { existsSync, readFileSync } from 'node:fs' import { posix } from 'node:path' -import { readNodeFileSyncWithinLimit } from '../../shared/node-bounded-file-reader' - -export const MAX_SHELL_STARTUP_FILE_BYTES = 1024 * 1024 -export const MAX_SHELL_STARTUP_CACHE_ENTRIES = 128 -export const MAX_SHELL_STARTUP_CACHE_BYTES = 8 * 1024 * 1024 -export const MAX_SHELL_STARTUP_ENV_VALUE_CODE_UNITS = 64 * 1024 -const MAX_SHELL_STARTUP_NAME_CODE_UNITS = 256 -const MAX_SHELL_STARTUP_PATH_CODE_UNITS = 16 * 1024 // Why: only files the user's actual shell would source. Mixing zsh and bash // files breaks the "last assignment wins matches the live shell" guarantee — @@ -36,7 +29,7 @@ function parseExportedValue(content: string, name: string, home: string): string // Why: $HOME / ${HOME} / ~ expansion mimics what the live shell would // do for double-quoted and unquoted values; single-quoted is literal. const expanded = quoted === "'" ? text : expandHome(text, home) - if (expanded.length > 0 && expanded.length <= MAX_SHELL_STARTUP_ENV_VALUE_CODE_UNITS) { + if (expanded.length > 0) { lastMatch = expanded } } @@ -45,8 +38,11 @@ function parseExportedValue(content: string, name: string, home: string): string } function readStartupFile(path: string): string | null { + if (!existsSync(path)) { + return null + } try { - return readNodeFileSyncWithinLimit(path, MAX_SHELL_STARTUP_FILE_BYTES).buffer.toString('utf8') + return readFileSync(path, 'utf8') } catch { return null } @@ -125,8 +121,7 @@ function expandHome(value: string, home: string): string { .replace(/\$HOME(?![A-Za-z0-9_])/g, home) } -const cache = new Map() -let cacheRetainedBytes = 0 +const cache = new Map() /** * Best-effort static read of a single env-var assignment from the user's @@ -156,26 +151,18 @@ export function readShellStartupEnvVar( home = process.env.HOME, shell = process.env.SHELL ): string | undefined { - if ( - !home || - home.length > MAX_SHELL_STARTUP_PATH_CODE_UNITS || - (shell?.length ?? 0) > MAX_SHELL_STARTUP_PATH_CODE_UNITS || - process.platform === 'win32' - ) { + if (!home || process.platform === 'win32') { return undefined } // Why: the regex above is fixed; rejecting unsafe names is cheap defense // for the day a future caller passes something with regex metacharacters. - if (name.length > MAX_SHELL_STARTUP_NAME_CODE_UNITS || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) { + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) { return undefined } const cacheKey = `${name}\0${home}\0${shell ?? ''}` - const cached = cache.get(cacheKey) - if (cached) { - cache.delete(cacheKey) - cache.set(cacheKey, cached) - return cached.value + if (cache.has(cacheKey)) { + return cache.get(cacheKey) } let lastMatch: string | undefined @@ -192,28 +179,10 @@ export function readShellStartupEnvVar( } } - cacheShellStartupValue(cacheKey, lastMatch) + cache.set(cacheKey, lastMatch) return lastMatch } -function cacheShellStartupValue(key: string, value: string | undefined): void { - const retainedBytes = (key.length + (value?.length ?? 0)) * 2 - while ( - cache.size >= MAX_SHELL_STARTUP_CACHE_ENTRIES || - cacheRetainedBytes + retainedBytes > MAX_SHELL_STARTUP_CACHE_BYTES - ) { - const oldestKey = cache.keys().next().value as string | undefined - if (oldestKey === undefined) { - return - } - const oldest = cache.get(oldestKey) - cache.delete(oldestKey) - cacheRetainedBytes -= oldest?.retainedBytes ?? 0 - } - cache.set(key, { retainedBytes, value }) - cacheRetainedBytes += retainedBytes -} - /** * Test-only helper to reset the per-process cache between cases. * Why: production callers never invalidate (rc files don't change at @@ -221,5 +190,4 @@ function cacheShellStartupValue(key: string, value: string | undefined): void { */ export function __resetShellStartupEnvCache(): void { cache.clear() - cacheRetainedBytes = 0 } diff --git a/src/main/rate-limits/auth-filesystem-operation.test.ts b/src/main/rate-limits/auth-filesystem-operation.test.ts index d27dfd381ec..92099fa2dcc 100644 --- a/src/main/rate-limits/auth-filesystem-operation.test.ts +++ b/src/main/rate-limits/auth-filesystem-operation.test.ts @@ -1,24 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { - AuthFilesystemOperationLimitError, - AuthFilesystemOperationRegistry, - createAuthFilesystemOperation, - MAX_AUTH_FILESYSTEM_OPERATION_PATH_BYTES, - MAX_AUTH_FILESYSTEM_OPERATION_WAITERS, - MAX_AUTH_FILESYSTEM_REGISTRY_ENTRIES, - MAX_QUEUED_WSL_AUTH_OPERATIONS -} from './auth-filesystem-operation' - -function deferred(): { - promise: Promise - resolve: (value: T) => void -} { - let resolve!: (value: T) => void - const promise = new Promise((next) => { - resolve = next - }) - return { promise, resolve } -} +import { createAuthFilesystemOperation } from './auth-filesystem-operation' describe('createAuthFilesystemOperation', () => { it('serializes WSL aliases by distro and drops an abandoned queued read', async () => { @@ -135,152 +116,4 @@ describe('createAuthFilesystemOperation', () => { await expect(Promise.all([ubuntuWait, debianWait])).resolves.toEqual(['ubuntu', 'debian']) expect(fedoraRaw).not.toHaveBeenCalled() }) - - it('caps retained operations, coalesces existing paths at saturation, and recovers', async () => { - const registry = new AuthFilesystemOperationRegistry() - const deferredReads = Array.from({ length: MAX_AUTH_FILESYSTEM_REGISTRY_ENTRIES + 1 }, () => - deferred() - ) - const operations = deferredReads - .slice(0, MAX_AUTH_FILESYSTEM_REGISTRY_ENTRIES) - .map((read, index) => registry.getOrCreate(`/auth/${index}`, () => read.promise)) - - expect(operations.every((operation) => operation !== null)).toBe(true) - expect(registry.size).toBe(MAX_AUTH_FILESYSTEM_REGISTRY_ENTRIES) - expect(registry.getOrCreate('/auth/0', async () => 'duplicate')).toBe(operations[0]) - const overflowRaw = vi.fn(async () => 'overflow') - expect(registry.getOrCreate('/auth/overflow', overflowRaw)).toBeNull() - expect(overflowRaw).not.toHaveBeenCalled() - - deferredReads[0]!.resolve('first') - await expect(operations[0]!.result).resolves.toBe('first') - expect(registry.size).toBe(MAX_AUTH_FILESYSTEM_REGISTRY_ENTRIES - 1) - - const recovered = registry.getOrCreate( - '/auth/recovered', - () => deferredReads[MAX_AUTH_FILESYSTEM_REGISTRY_ENTRIES]!.promise - ) - expect(recovered).not.toBeNull() - expect(registry.size).toBe(MAX_AUTH_FILESYSTEM_REGISTRY_ENTRIES) - - for (let index = 1; index < MAX_AUTH_FILESYSTEM_REGISTRY_ENTRIES; index += 1) { - deferredReads[index]!.resolve(`read-${index}`) - } - deferredReads[MAX_AUTH_FILESYSTEM_REGISTRY_ENTRIES]!.resolve('recovered') - await Promise.all([ - ...operations.slice(1).map((operation) => operation!.result), - recovered!.result - ]) - expect(registry.size).toBe(0) - }) - - it('accepts an exact-limit auth path and rejects one byte more without starting it', async () => { - const exactPath = 'x'.repeat(MAX_AUTH_FILESYSTEM_OPERATION_PATH_BYTES) - const exactRaw = vi.fn(async () => 'exact') - const exact = createAuthFilesystemOperation(exactPath, exactRaw) - const controller = new AbortController() - - await expect(exact.wait(controller.signal)).resolves.toBe('exact') - expect(exactRaw).toHaveBeenCalledOnce() - - const oversizedRaw = vi.fn(async () => 'oversized') - const oversized = createAuthFilesystemOperation(`${exactPath}x`, oversizedRaw) - await expect(oversized.wait(controller.signal)).rejects.toBeInstanceOf( - AuthFilesystemOperationLimitError - ) - expect(oversizedRaw).not.toHaveBeenCalled() - }) - - it('caps coalesced waiters and admits another after one leaves', async () => { - const read = deferred() - const operation = createAuthFilesystemOperation('/auth/shared', () => read.promise) - const controllers = Array.from( - { length: MAX_AUTH_FILESYSTEM_OPERATION_WAITERS }, - () => new AbortController() - ) - const waits = controllers.map((controller) => operation.wait(controller.signal)) - - await expect(operation.wait(new AbortController().signal)).rejects.toBeInstanceOf( - AuthFilesystemOperationLimitError - ) - - const abandoned = new Error('first waiter left') - controllers[0]!.abort(abandoned) - await expect(waits[0]).rejects.toBe(abandoned) - const recovered = operation.wait(new AbortController().signal) - - read.resolve('shared') - await expect(recovered).resolves.toBe('shared') - await expect(Promise.all(waits.slice(1))).resolves.toEqual( - Array.from({ length: MAX_AUTH_FILESYSTEM_OPERATION_WAITERS - 1 }, () => 'shared') - ) - }) - - it('caps the WSL wait queue and recovers as soon as a queued operation leaves', async () => { - const ubuntuRead = deferred() - const debianRead = deferred() - const ubuntuRaw = vi.fn(() => ubuntuRead.promise) - const debianRaw = vi.fn(() => debianRead.promise) - const activeController = new AbortController() - const ubuntu = createAuthFilesystemOperation( - '\\\\wsl$\\Ubuntu\\home\\alice\\auth.json', - ubuntuRaw - ) - const debian = createAuthFilesystemOperation( - '\\\\wsl$\\Debian\\home\\alice\\auth.json', - debianRaw - ) - const ubuntuWait = ubuntu.wait(activeController.signal) - const debianWait = debian.wait(activeController.signal) - await Promise.resolve() - await Promise.resolve() - expect(ubuntuRaw).toHaveBeenCalledOnce() - expect(debianRaw).toHaveBeenCalledOnce() - - const queuedControllers: AbortController[] = [] - const queuedWaits: Promise[] = [] - for (let index = 0; index < MAX_QUEUED_WSL_AUTH_OPERATIONS; index += 1) { - const controller = new AbortController() - const operation = createAuthFilesystemOperation( - `\\\\wsl$\\Queued-${index}\\home\\alice\\auth.json`, - async () => `queued-${index}` - ) - queuedControllers.push(controller) - queuedWaits.push(operation.wait(controller.signal)) - } - - const overflowRaw = vi.fn(async () => 'overflow') - const overflow = createAuthFilesystemOperation( - '\\\\wsl$\\Overflow\\home\\alice\\auth.json', - overflowRaw - ) - await expect(overflow.wait(new AbortController().signal)).rejects.toBeInstanceOf( - AuthFilesystemOperationLimitError - ) - expect(overflowRaw).not.toHaveBeenCalled() - - const released = new Error('release queue slot') - queuedControllers[0]!.abort(released) - await expect(queuedWaits[0]).rejects.toBe(released) - - const recoveredController = new AbortController() - const recoveredRaw = vi.fn(async () => 'recovered') - const recovered = createAuthFilesystemOperation( - '\\\\wsl$\\Recovered\\home\\alice\\auth.json', - recoveredRaw - ) - const recoveredWait = recovered.wait(recoveredController.signal) - const recoveredAbort = new Error('recovered queue entry admitted') - recoveredController.abort(recoveredAbort) - await expect(recoveredWait).rejects.toBe(recoveredAbort) - expect(recoveredRaw).not.toHaveBeenCalled() - - for (const controller of queuedControllers.slice(1)) { - controller.abort(new Error('test cleanup')) - } - await Promise.allSettled(queuedWaits.slice(1)) - ubuntuRead.resolve('ubuntu') - debianRead.resolve('debian') - await expect(Promise.all([ubuntuWait, debianWait])).resolves.toEqual(['ubuntu', 'debian']) - }) }) diff --git a/src/main/rate-limits/auth-filesystem-operation.ts b/src/main/rate-limits/auth-filesystem-operation.ts index 8910f30f19d..228234e92c0 100644 --- a/src/main/rate-limits/auth-filesystem-operation.ts +++ b/src/main/rate-limits/auth-filesystem-operation.ts @@ -1,22 +1,10 @@ import { parseWslUncPath } from '../../shared/wsl-paths' const MAX_CONCURRENT_WSL_AUTH_OPERATIONS = 2 -export const MAX_QUEUED_WSL_AUTH_OPERATIONS = 128 -export const MAX_AUTH_FILESYSTEM_OPERATION_WAITERS = 256 -export const MAX_AUTH_FILESYSTEM_OPERATION_PATH_BYTES = 64 * 1024 -export const MAX_AUTH_FILESYSTEM_REGISTRY_ENTRIES = 128 -export const MAX_AUTH_FILESYSTEM_REGISTRY_PATH_BYTES = 8 * 1024 * 1024 const activeWslOperationDistros = new Set() const queuedWslOperations: QueuedWslOperation[] = [] let activeWslOperationCount = 0 -export class AuthFilesystemOperationLimitError extends Error { - constructor(message: string) { - super(message) - this.name = 'AuthFilesystemOperationLimitError' - } -} - type QueuedWslOperation = { distroKey: string neededSignal: AbortSignal @@ -82,13 +70,6 @@ function scheduleWslAuthFilesystemOperation( neededSignal: AbortSignal, operation: () => Promise ): Promise { - if (queuedWslOperations.length >= MAX_QUEUED_WSL_AUTH_OPERATIONS) { - return Promise.reject( - new AuthFilesystemOperationLimitError( - `Auth filesystem queue exceeds ${MAX_QUEUED_WSL_AUTH_OPERATIONS} operations` - ) - ) - } return new Promise((resolve, reject) => { const task: QueuedWslOperation = { distroKey, @@ -145,19 +126,6 @@ export type SharedAuthFilesystemOperation = { wait: (signal: AbortSignal) => Promise } -function rejectedAuthFilesystemOperation( - error: AuthFilesystemOperationLimitError -): SharedAuthFilesystemOperation { - const result = Promise.reject(error) - void result.catch(() => undefined) - return { - result, - wait(signal) { - return Promise.reject(signal.aborted ? getAbortReason(signal) : error) - } - } -} - /** * Shares one raw operation with all callers for an auth path. WSL paths also * serialize by normalized distro because one stuck UNC request per account can @@ -167,15 +135,8 @@ export function createAuthFilesystemOperation( authPath: string, operation: () => Promise ): SharedAuthFilesystemOperation { - if (Buffer.byteLength(authPath, 'utf8') > MAX_AUTH_FILESYSTEM_OPERATION_PATH_BYTES) { - return rejectedAuthFilesystemOperation( - new AuthFilesystemOperationLimitError( - `Auth filesystem path exceeds ${MAX_AUTH_FILESYSTEM_OPERATION_PATH_BYTES} bytes` - ) - ) - } const neededController = new AbortController() - let waiterCount = 0 + const waiters = new Set() let settled = false const result = scheduleAuthFilesystemOperation(authPath, neededController.signal, operation) const markSettled = (): void => { @@ -187,23 +148,14 @@ export function createAuthFilesystemOperation( result, wait(signal) { if (signal.aborted) { - if (!settled && waiterCount === 0) { + if (!settled && waiters.size === 0) { neededController.abort(getAbortReason(signal)) } return Promise.reject(getAbortReason(signal)) } - if (settled) { - return result - } - if (waiterCount >= MAX_AUTH_FILESYSTEM_OPERATION_WAITERS) { - return Promise.reject( - new AuthFilesystemOperationLimitError( - `Auth filesystem operation exceeds ${MAX_AUTH_FILESYSTEM_OPERATION_WAITERS} waiters` - ) - ) - } - waiterCount += 1 + const waiter = Symbol('auth-filesystem-waiter') + waiters.add(waiter) let onAbort: (() => void) | null = null const aborted = new Promise((_resolve, reject) => { onAbort = () => reject(getAbortReason(signal)) @@ -213,53 +165,11 @@ export function createAuthFilesystemOperation( if (onAbort) { signal.removeEventListener('abort', onAbort) } - waiterCount -= 1 - if (!settled && waiterCount === 0) { + waiters.delete(waiter) + if (!settled && waiters.size === 0) { neededController.abort(getAbortReason(signal)) } }) } } } - -export class AuthFilesystemOperationRegistry { - private readonly operations = new Map>() - private retainedPathBytes = 0 - - get size(): number { - return this.operations.size - } - - getOrCreate( - authPath: string, - operation: () => Promise - ): SharedAuthFilesystemOperation | null { - const pathBytes = Buffer.byteLength(authPath, 'utf8') - if (pathBytes > MAX_AUTH_FILESYSTEM_OPERATION_PATH_BYTES) { - return null - } - const existing = this.operations.get(authPath) - if (existing) { - return existing - } - if ( - this.operations.size >= MAX_AUTH_FILESYSTEM_REGISTRY_ENTRIES || - pathBytes > MAX_AUTH_FILESYSTEM_REGISTRY_PATH_BYTES - this.retainedPathBytes - ) { - return null - } - - const shared = createAuthFilesystemOperation(authPath, operation) - this.operations.set(authPath, shared) - this.retainedPathBytes += pathBytes - const clear = (): void => { - if (this.operations.get(authPath) !== shared) { - return - } - this.operations.delete(authPath) - this.retainedPathBytes -= pathBytes - } - void shared.result.then(clear, clear) - return shared - } -} diff --git a/src/main/rate-limits/claude-fetcher.test.ts b/src/main/rate-limits/claude-fetcher.test.ts index c46c9577951..7eb218f04de 100644 --- a/src/main/rate-limits/claude-fetcher.test.ts +++ b/src/main/rate-limits/claude-fetcher.test.ts @@ -15,25 +15,18 @@ import { } from '../claude-accounts/keychain' import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' -const { - netFetchMock, - readFileMock, - readFileSyncMock, - resolveProxyMock, - setProxyMock, - appGetPathMock -} = vi.hoisted(() => ({ - netFetchMock: vi.fn(), - readFileMock: vi.fn(), - readFileSyncMock: vi.fn(), - resolveProxyMock: vi.fn(), - setProxyMock: vi.fn(), - appGetPathMock: vi.fn() -})) +const { netFetchMock, readFileMock, resolveProxyMock, setProxyMock, appGetPathMock } = vi.hoisted( + () => ({ + netFetchMock: vi.fn(), + readFileMock: vi.fn(), + resolveProxyMock: vi.fn(), + setProxyMock: vi.fn(), + appGetPathMock: vi.fn() + }) +) -vi.mock('../integration-credential-file', () => ({ - readIntegrationCredentialFileSyncText: readFileSyncMock, - readIntegrationCredentialFileText: readFileMock +vi.mock('node:fs/promises', () => ({ + readFile: readFileMock })) vi.mock('electron', () => ({ @@ -81,7 +74,6 @@ describe('fetchClaudeRateLimits', () => { tempDir = null vi.clearAllMocks() readFileMock.mockRejectedValue(new Error('missing file')) - readFileSyncMock.mockImplementation((filePath: string) => readFileSync(filePath, 'utf8')) vi.mocked(readActiveClaudeKeychainCredentials).mockResolvedValue(null) vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValue(null) vi.mocked(readManagedClaudeKeychainCredentials).mockResolvedValue(null) @@ -752,7 +744,10 @@ describe('fetchClaudeRateLimits', () => { status: 'ok' }) - expect(readFileMock).toHaveBeenCalledWith(join('/Users/test/.claude', '.credentials.json')) + expect(readFileMock).toHaveBeenCalledWith( + join('/Users/test/.claude', '.credentials.json'), + 'utf-8' + ) expect(netFetchMock).toHaveBeenCalledWith( 'https://api.anthropic.com/api/oauth/usage', expect.objectContaining({ diff --git a/src/main/rate-limits/claude-fetcher.ts b/src/main/rate-limits/claude-fetcher.ts index ddc1237fcde..206748e1bc5 100644 --- a/src/main/rate-limits/claude-fetcher.ts +++ b/src/main/rate-limits/claude-fetcher.ts @@ -1,5 +1,6 @@ /* eslint-disable max-lines -- Why: keep Claude credential ordering, OAuth usage fetch, and PTY fallback together so usage state can't drift across paths. */ -import { existsSync, lstatSync } from 'node:fs' +import { existsSync, lstatSync, readFileSync } from 'node:fs' +import { readFile } from 'node:fs/promises' import { homedir } from 'node:os' import path from 'node:path' import { net, session } from 'electron' @@ -41,11 +42,6 @@ import { classifyClaudeOAuthUsageError, type ClaudeUsageErrorClassification } from './claude-usage-error-classification' -import { readFetchResponseJsonWithinLimit } from '../lib/fetch-response-body' -import { - readIntegrationCredentialFileSyncText, - readIntegrationCredentialFileText -} from '../integration-credential-file' const OAUTH_USAGE_URL = 'https://api.anthropic.com/api/oauth/usage' const OAUTH_BETA_HEADER = 'oauth-2025-04-20' @@ -197,7 +193,7 @@ async function readCredentialsFromStrictKeychain( async function readFromCredentialsFile(configDir?: string): Promise { const credPath = path.join(configDir ?? path.join(homedir(), '.claude'), '.credentials.json') try { - const raw = await readIntegrationCredentialFileText(credPath) + const raw = await readFile(credPath, 'utf-8') return parseOAuthCredentialsJson(raw, 'credentials-file') } catch { return emptyOAuthCredentialReadResult() @@ -370,7 +366,7 @@ async function fetchViaOAuth(token: string, signal?: AbortSignal): Promise(res) + const data = (await res.json()) as OAuthUsageResponse if (signal?.aborted) { return abortedClaudeRateLimitResult() } @@ -1046,7 +1042,7 @@ function resolveOwnedWslClaudeManagedAuthPath(account: InactiveClaudeAccountInfo if ( !existsSync(markerPath) || lstatSync(markerPath).isSymbolicLink() || - readIntegrationCredentialFileSyncText(markerPath).trim() !== account.id + readFileSync(markerPath, 'utf-8').trim() !== account.id ) { return null } diff --git a/src/main/rate-limits/claude-oauth-usage-error.ts b/src/main/rate-limits/claude-oauth-usage-error.ts index 06d404c5c30..1ccb1471de6 100644 --- a/src/main/rate-limits/claude-oauth-usage-error.ts +++ b/src/main/rate-limits/claude-oauth-usage-error.ts @@ -1,5 +1,3 @@ -import { readFetchResponseJsonWithinLimit } from '../lib/fetch-response-body' - // Why: a corrupt/hostile Retry-After must not gate usage refreshes for days. const MAX_RETRY_AFTER_MS = 24 * 60 * 60 * 1000 @@ -47,7 +45,7 @@ async function describeOAuthUsageError(res: Response): Promise { return 'Claude usage is rate limited right now.' } try { - const data = await readFetchResponseJsonWithinLimit<{ error?: { message?: string } }>(res) + const data = (await res.json()) as { error?: { message?: string } } if (typeof data.error?.message === 'string' && data.error.message.trim()) { return data.error.message } diff --git a/src/main/rate-limits/claude-pty.ts b/src/main/rate-limits/claude-pty.ts index 054e2ce1e2a..b7a8c96dfba 100644 --- a/src/main/rate-limits/claude-pty.ts +++ b/src/main/rate-limits/claude-pty.ts @@ -13,7 +13,6 @@ import { getHiddenRateLimitWslCwdSetupCommands, resolveHiddenRateLimitPtyCwd } from './hidden-rate-limit-pty-cwd' -import { appendRateLimitPtyOutputTail } from './rate-limit-pty-output-tail' const PTY_TIMEOUT_MS = 25_000 const MAX_OUTPUT_LENGTH = 100_000 // 100KB buffer limit @@ -442,10 +441,13 @@ export async function fetchViaPty(options?: { }, STARTUP_DELAY_MS) const onDataDisposable = term.onData((data) => { - const appended = appendRateLimitPtyOutputTail(output, data, MAX_OUTPUT_LENGTH) - output = appended.output + output += data + // Why: prevent memory exhaustion if the CLI process floods output + if (output.length > MAX_OUTPUT_LENGTH) { + output = output.slice(-MAX_OUTPUT_LENGTH) + } - const cleanChunk = stripTerminalControlSequences(appended.scannedChunk) + const cleanChunk = stripTerminalControlSequences(data) // Why: the Claude CLI may prompt for first-run setup (trust files, // workspace directory). Auto-accept so we can reach /usage. diff --git a/src/main/rate-limits/codex-auth-presence.test.ts b/src/main/rate-limits/codex-auth-presence.test.ts index d1b9ce38b86..6f49f27bfb4 100644 --- a/src/main/rate-limits/codex-auth-presence.test.ts +++ b/src/main/rate-limits/codex-auth-presence.test.ts @@ -15,7 +15,6 @@ vi.mock('node:os', () => ({ })) import { probeCodexAuthPresence } from './codex-auth-presence' -import { MAX_AUTH_FILESYSTEM_REGISTRY_ENTRIES } from './auth-filesystem-operation' function fsError(code: string): NodeJS.ErrnoException { return Object.assign(new Error(code), { code }) @@ -153,50 +152,4 @@ describe('probeCodexAuthPresence', () => { resolveAccess() await Promise.resolve() }) - - it('caps distinct stalled probes and admits a new path after one settles', async () => { - const accessResolvers = new Map void>() - accessMock.mockImplementation( - (path: string) => - new Promise((resolve) => { - accessResolvers.set(path, resolve) - }) - ) - const controllers = Array.from( - { length: MAX_AUTH_FILESYSTEM_REGISTRY_ENTRIES + 2 }, - () => new AbortController() - ) - const probes = Array.from({ length: MAX_AUTH_FILESYSTEM_REGISTRY_ENTRIES }, (_, index) => - probeCodexAuthPresence(`/managed/${index}`, { signal: controllers[index]!.signal }) - ) - await Promise.resolve() - await Promise.resolve() - - expect(accessMock).toHaveBeenCalledTimes(MAX_AUTH_FILESYSTEM_REGISTRY_ENTRIES) - await expect( - probeCodexAuthPresence('/managed/overflow', { - signal: controllers[MAX_AUTH_FILESYSTEM_REGISTRY_ENTRIES]!.signal - }) - ).resolves.toBe('unavailable') - expect(accessMock).toHaveBeenCalledTimes(MAX_AUTH_FILESYSTEM_REGISTRY_ENTRIES) - - accessResolvers.get(join('/managed/0', 'auth.json'))!() - await expect(probes[0]).resolves.toBe('present') - - const recovered = probeCodexAuthPresence('/managed/recovered', { - signal: controllers[MAX_AUTH_FILESYSTEM_REGISTRY_ENTRIES + 1]!.signal - }) - await Promise.resolve() - await Promise.resolve() - expect(accessMock).toHaveBeenCalledTimes(MAX_AUTH_FILESYSTEM_REGISTRY_ENTRIES + 1) - accessResolvers.get(join('/managed/recovered', 'auth.json'))!() - await expect(recovered).resolves.toBe('present') - - for (let index = 1; index < MAX_AUTH_FILESYSTEM_REGISTRY_ENTRIES; index += 1) { - accessResolvers.get(join(`/managed/${index}`, 'auth.json'))!() - } - await expect(Promise.all(probes.slice(1))).resolves.toEqual( - Array.from({ length: MAX_AUTH_FILESYSTEM_REGISTRY_ENTRIES - 1 }, () => 'present') - ) - }) }) diff --git a/src/main/rate-limits/codex-auth-presence.ts b/src/main/rate-limits/codex-auth-presence.ts index 2ad19f1aab6..833f46a1b6f 100644 --- a/src/main/rate-limits/codex-auth-presence.ts +++ b/src/main/rate-limits/codex-auth-presence.ts @@ -3,12 +3,12 @@ import { homedir } from 'node:os' import { join } from 'node:path' import { parseWslUncPath } from '../../shared/wsl-paths' import { - AuthFilesystemOperationRegistry, + createAuthFilesystemOperation, type SharedAuthFilesystemOperation } from './auth-filesystem-operation' const AUTH_PRESENCE_TIMEOUT_MS = 5_000 -const authPresenceProbes = new AuthFilesystemOperationRegistry() +const authPresenceProbeByPath = new Map>() export type CodexAuthPresence = 'present' | 'absent' | 'timeout' | 'unavailable' @@ -22,13 +22,15 @@ function isMissingPathError(error: unknown): boolean { return code === 'ENOENT' || code === 'ENOTDIR' } -function getAuthPresenceProbe( - authPath: string -): SharedAuthFilesystemOperation | null { +function getAuthPresenceProbe(authPath: string): SharedAuthFilesystemOperation { + const existing = authPresenceProbeByPath.get(authPath) + if (existing) { + return existing + } // Why: aborting a Node fs promise does not necessarily cancel an already // issued UNC operation. Share the raw probe until it really settles so a // disconnected WSL home cannot accumulate native requests across polls. - return authPresenceProbes.getOrCreate(authPath, async () => { + const probe = createAuthFilesystemOperation(authPath, async () => { try { await access(authPath) return 'present' @@ -36,6 +38,14 @@ function getAuthPresenceProbe( return isMissingPathError(error) ? 'absent' : 'unavailable' } }) + authPresenceProbeByPath.set(authPath, probe) + const clearProbe = (): void => { + if (authPresenceProbeByPath.get(authPath) === probe) { + authPresenceProbeByPath.delete(authPath) + } + } + void probe.result.then(clearProbe, clearProbe) + return probe } // Why: the background quota poller spawns the real `codex` binary to read rate @@ -58,22 +68,14 @@ export async function probeCodexAuthPresence( // Why: managed WSL homes are UNC paths. A synchronous stat can park // Electron main while Windows wakes or reconnects the distro; the race // also keeps a disconnected distro from serializing all later refreshes. - const authProbe = getAuthPresenceProbe(authPath) - if (!authProbe) { - return 'unavailable' - } - const authPresence = await authProbe.wait(signal) + const authPresence = await getAuthPresenceProbe(authPath).wait(signal) if (authPresence !== 'absent' || !parseWslUncPath(home)) { return authPresence } // Why: ENOENT on a WSL UNC auth path can mean either a missing auth file or // an unavailable distro. Only an accessible Codex home proves signed-out. - const homeProbe = getAuthPresenceProbe(home) - if (!homeProbe) { - return 'unavailable' - } - const homePresence = await homeProbe.wait(signal) + const homePresence = await getAuthPresenceProbe(home).wait(signal) return homePresence === 'present' ? 'absent' : 'unavailable' } catch { return timeoutSignal.aborted && !options.signal?.aborted ? 'timeout' : 'unavailable' diff --git a/src/main/rate-limits/codex-fetcher-auth-file-bounds.test.ts b/src/main/rate-limits/codex-fetcher-auth-file-bounds.test.ts deleted file mode 100644 index 04f45dff2a0..00000000000 --- a/src/main/rate-limits/codex-fetcher-auth-file-bounds.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { mkdtempSync, rmSync, statSync, truncateSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { NodeFileReadTooLargeError } from '../../shared/node-bounded-file-reader' -import { MAX_INTEGRATION_CREDENTIAL_FILE_BYTES } from '../integration-credential-file' -import { consumeCodexRateLimitResetCredit } from './codex-fetcher' - -const fetchMock = vi.hoisted(() => vi.fn()) -const roots: string[] = [] - -function createCodexHome(): string { - const root = mkdtempSync(join(tmpdir(), 'orca-codex-auth-bounds-')) - roots.push(root) - return root -} - -function successfulConsumeResponse(): Response { - return new Response(JSON.stringify({ code: 'already_redeemed' }), { - headers: { 'content-type': 'application/json' }, - status: 200 - }) -} - -beforeEach(() => { - fetchMock.mockReset() - fetchMock.mockResolvedValue(successfulConsumeResponse()) - vi.stubGlobal('fetch', fetchMock) -}) - -afterEach(() => { - vi.unstubAllGlobals() - for (const root of roots.splice(0)) { - rmSync(root, { recursive: true, force: true }) - } -}) - -describe('Codex backend auth file bounds', () => { - it('preserves normal auth JSON and backend headers', async () => { - const codexHomePath = createCodexHome() - writeFileSync( - join(codexHomePath, 'auth.json'), - JSON.stringify({ - tokens: { access_token: 'normal-token', account_id: 'normal-account' } - }) - ) - - await expect( - consumeCodexRateLimitResetCredit({ - codexHomePath, - idempotencyKey: 'normal-auth' - }) - ).resolves.toBe('alreadyRedeemed') - expect(fetchMock).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: 'Bearer normal-token', - 'ChatGPT-Account-Id': 'normal-account' - }) - }) - ) - }) - - it('accepts valid auth JSON at the exact credential byte limit', async () => { - const codexHomePath = createCodexHome() - const authPath = join(codexHomePath, 'auth.json') - const json = JSON.stringify({ - tokens: { access_token: 'boundary-token', account_id: 'boundary-account' } - }) - writeFileSync( - authPath, - json + ' '.repeat(MAX_INTEGRATION_CREDENTIAL_FILE_BYTES - Buffer.byteLength(json)) - ) - - expect(statSync(authPath).size).toBe(MAX_INTEGRATION_CREDENTIAL_FILE_BYTES) - await expect( - consumeCodexRateLimitResetCredit({ - codexHomePath, - idempotencyKey: 'boundary-auth' - }) - ).resolves.toBe('alreadyRedeemed') - expect(fetchMock).toHaveBeenCalledOnce() - }) - - it('rejects an oversized sparse auth file before making a request', async () => { - const codexHomePath = createCodexHome() - const authPath = join(codexHomePath, 'auth.json') - writeFileSync(authPath, '') - truncateSync(authPath, MAX_INTEGRATION_CREDENTIAL_FILE_BYTES + 1) - - await expect( - consumeCodexRateLimitResetCredit({ - codexHomePath, - idempotencyKey: 'oversized-auth' - }) - ).rejects.toBeInstanceOf(NodeFileReadTooLargeError) - expect(fetchMock).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/rate-limits/codex-fetcher-backend.test.ts b/src/main/rate-limits/codex-fetcher-backend.test.ts index 7c7cff90a4d..9102e54ab09 100644 --- a/src/main/rate-limits/codex-fetcher-backend.test.ts +++ b/src/main/rate-limits/codex-fetcher-backend.test.ts @@ -9,19 +9,13 @@ const { childSpawnMock, readFileMock, ptySpawnMock } = vi.hoisted(() => ({ })) vi.mock('node:child_process', () => ({ spawn: childSpawnMock })) -vi.mock('../integration-credential-file', () => ({ - readIntegrationCredentialFileText: readFileMock -})) +vi.mock('node:fs/promises', () => ({ readFile: readFileMock })) vi.mock('node-pty', () => ({ spawn: ptySpawnMock })) vi.mock('./codex-auth-presence', () => ({ probeCodexAuthPresence: vi.fn(async () => 'present') })) import { consumeCodexRateLimitResetCredit, fetchCodexRateLimits } from './codex-fetcher' -import { - AuthFilesystemOperationLimitError, - MAX_AUTH_FILESYSTEM_REGISTRY_ENTRIES -} from './auth-filesystem-operation' describe('Codex backend rate-limit requests', () => { beforeEach(() => { @@ -119,7 +113,7 @@ describe('Codex backend rate-limit requests', () => { const second = fetchCodexRateLimits({ codexHomePath, signal: secondController.signal }) await vi.advanceTimersByTimeAsync(0) expect(readFileMock).toHaveBeenCalledTimes(1) - expect(readFileMock).toHaveBeenCalledWith(expect.stringContaining('auth.json')) + expect(readFileMock).toHaveBeenCalledWith(expect.stringContaining('auth.json'), 'utf8') firstController.abort() secondController.abort() @@ -160,7 +154,7 @@ describe('Codex backend rate-limit requests', () => { await vi.advanceTimersByTimeAsync(0) // Why: redeem is user-triggered, so it gets the longer redeem deadline. expect(timeout).toHaveBeenCalledWith(30_000) - expect(readFileMock).toHaveBeenCalledWith(join('/managed/deadline-home', 'auth.json')) + expect(readFileMock).toHaveBeenCalledWith(join('/managed/deadline-home', 'auth.json'), 'utf8') timeoutController.abort(deadlineError) @@ -223,59 +217,4 @@ describe('Codex backend rate-limit requests', () => { ).rejects.toThrow('Codex reset failed: HTTP 429') expect(cancelledBodies).toBe(1) }) - - it('caps distinct stalled backend auth reads and recovers after one settles', async () => { - const authResolvers = new Map void>() - readFileMock.mockImplementation( - (authPath: string) => - new Promise((resolve) => { - authResolvers.set(authPath, resolve) - }) - ) - vi.mocked(fetch).mockResolvedValue({ - ok: true, - json: async () => ({ code: 'already_redeemed' }) - } as Response) - const requests = Array.from({ length: MAX_AUTH_FILESYSTEM_REGISTRY_ENTRIES }, (_, index) => - consumeCodexRateLimitResetCredit({ - codexHomePath: `/managed/saturated-${index}`, - idempotencyKey: `saturated-${index}` - }) - ) - await vi.advanceTimersByTimeAsync(0) - - expect(readFileMock).toHaveBeenCalledTimes(MAX_AUTH_FILESYSTEM_REGISTRY_ENTRIES) - await expect( - consumeCodexRateLimitResetCredit({ - codexHomePath: '/managed/overflow', - idempotencyKey: 'overflow' - }) - ).rejects.toBeInstanceOf(AuthFilesystemOperationLimitError) - expect(readFileMock).toHaveBeenCalledTimes(MAX_AUTH_FILESYSTEM_REGISTRY_ENTRIES) - - const authJson = JSON.stringify({ - tokens: { access_token: 'capacity-token', account_id: 'capacity-account' } - }) - authResolvers.get(join('/managed/saturated-0', 'auth.json'))!(authJson) - await vi.advanceTimersByTimeAsync(0) - await expect(requests[0]).resolves.toBe('alreadyRedeemed') - - const recovered = consumeCodexRateLimitResetCredit({ - codexHomePath: '/managed/recovered', - idempotencyKey: 'recovered' - }) - await vi.advanceTimersByTimeAsync(0) - expect(readFileMock).toHaveBeenCalledTimes(MAX_AUTH_FILESYSTEM_REGISTRY_ENTRIES + 1) - authResolvers.get(join('/managed/recovered', 'auth.json'))!(authJson) - await vi.advanceTimersByTimeAsync(0) - await expect(recovered).resolves.toBe('alreadyRedeemed') - - for (let index = 1; index < MAX_AUTH_FILESYSTEM_REGISTRY_ENTRIES; index += 1) { - authResolvers.get(join(`/managed/saturated-${index}`, 'auth.json'))!(authJson) - } - await vi.advanceTimersByTimeAsync(0) - await expect(Promise.all(requests.slice(1))).resolves.toEqual( - Array.from({ length: MAX_AUTH_FILESYSTEM_REGISTRY_ENTRIES - 1 }, () => 'alreadyRedeemed') - ) - }) }) diff --git a/src/main/rate-limits/codex-fetcher-rpc-buffer.test.ts b/src/main/rate-limits/codex-fetcher-rpc-buffer.test.ts deleted file mode 100644 index ce6304f4e96..00000000000 --- a/src/main/rate-limits/codex-fetcher-rpc-buffer.test.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { EventEmitter } from 'node:events' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const { childSpawnMock, resolveCodexCommandMock } = vi.hoisted(() => ({ - childSpawnMock: vi.fn(), - resolveCodexCommandMock: vi.fn(() => 'codex') -})) - -vi.mock('node:child_process', () => ({ - spawn: childSpawnMock -})) - -vi.mock('../codex-cli/command', () => ({ - resolveCodexCommand: resolveCodexCommandMock -})) - -vi.mock('./codex-auth-presence', () => ({ - probeCodexAuthPresence: vi.fn(async () => 'present') -})) - -import { fetchCodexRateLimits, MAX_RPC_RESPONSE_LINE_BYTES } from './codex-fetcher' - -function createRpcChild() { - const child = new EventEmitter() as EventEmitter & { - stdout: EventEmitter - stderr: EventEmitter - stdin: { write: ReturnType } - kill: ReturnType - } - child.stdout = new EventEmitter() - child.stderr = new EventEmitter() - child.stdin = { write: vi.fn() } - child.kill = vi.fn() - return child -} - -describe('Codex RPC response buffering', () => { - beforeEach(() => { - vi.useFakeTimers() - vi.clearAllMocks() - }) - - afterEach(() => { - vi.useRealTimers() - }) - - it('processes a valid response after tens of thousands of tiny stdout chunks', async () => { - vi.useRealTimers() - const child = createRpcChild() - childSpawnMock.mockReturnValue(child) - child.stdin.write.mockImplementation((line: string) => { - const message = JSON.parse(line) as { id?: number; method?: string } - if (message.method === 'initialize') { - setTimeout(() => { - const noise = Buffer.alloc(1_000_000, 0x78) - for (let offset = 0; offset < noise.byteLength; offset += 16) { - child.stdout.emit('data', noise.subarray(offset, offset + 16)) - } - child.stdout.emit( - 'data', - Buffer.from(`\n${JSON.stringify({ jsonrpc: '2.0', id: message.id, result: {} })}\n`) - ) - }, 0) - } - if (message.method === 'account/rateLimits/read') { - setTimeout(() => { - child.stdout.emit( - 'data', - Buffer.from( - `${JSON.stringify({ - jsonrpc: '2.0', - id: message.id, - result: { - rateLimits: { - primary: { usedPercent: 7 }, - secondary: { usedPercent: 12 } - }, - rateLimitResetCredits: { - availableCount: 0, - nextExpiresAt: Date.now() + 60_000 - } - } - })}\n` - ) - ) - }, 0) - } - return true - }) - - const resultPromise = fetchCodexRateLimits({ allowPtyFallback: false }) - - await expect(resultPromise).resolves.toMatchObject({ - status: 'ok', - session: { usedPercent: 7 }, - weekly: { usedPercent: 12 } - }) - }) - - it('kills the RPC process before retaining an oversized line', async () => { - vi.useRealTimers() - const child = createRpcChild() - childSpawnMock.mockReturnValue(child) - child.stdin.write.mockImplementation((line: string) => { - const message = JSON.parse(line) as { method?: string } - if (message.method === 'initialize') { - setTimeout(() => { - child.stdout.emit('data', Buffer.alloc(MAX_RPC_RESPONSE_LINE_BYTES + 1, 0x78)) - }, 0) - } - return true - }) - - const resultPromise = fetchCodexRateLimits({ allowPtyFallback: false }) - - await expect(resultPromise).resolves.toMatchObject({ - status: 'error', - error: `RPC response exceeded ${MAX_RPC_RESPONSE_LINE_BYTES} byte line limit` - }) - expect(child.kill).toHaveBeenCalledOnce() - expect(child.stdout.listenerCount('data')).toBe(0) - }) -}) diff --git a/src/main/rate-limits/codex-fetcher.test.ts b/src/main/rate-limits/codex-fetcher.test.ts index 237909137b2..a00d86ffc9d 100644 --- a/src/main/rate-limits/codex-fetcher.test.ts +++ b/src/main/rate-limits/codex-fetcher.test.ts @@ -13,8 +13,8 @@ vi.mock('node:child_process', () => ({ spawn: childSpawnMock })) -vi.mock('../integration-credential-file', () => ({ - readIntegrationCredentialFileText: readFileMock +vi.mock('node:fs/promises', () => ({ + readFile: readFileMock })) vi.mock('../codex-cli/command', () => ({ @@ -496,7 +496,7 @@ describe('fetchCodexRateLimits', () => { } ] }) - expect(readFileMock).toHaveBeenCalledWith(join('/managed/codex-home', 'auth.json')) + expect(readFileMock).toHaveBeenCalledWith(join('/managed/codex-home', 'auth.json'), 'utf8') expect(fetch).toHaveBeenCalledWith( 'https://chatgpt.com/backend-api/wham/rate-limit-reset-credits', expect.objectContaining({ diff --git a/src/main/rate-limits/codex-fetcher.ts b/src/main/rate-limits/codex-fetcher.ts index 7d9037e3eed..e1d58ae659e 100644 --- a/src/main/rate-limits/codex-fetcher.ts +++ b/src/main/rate-limits/codex-fetcher.ts @@ -5,10 +5,9 @@ import type { RateLimitWindow } from '../../shared/rate-limit-types' import { spawn } from 'node:child_process' +import { readFile } from 'node:fs/promises' import { homedir } from 'node:os' import { cancelUnreadResponseBody } from '../lib/unread-response-body' -import { readFetchResponseJsonWithinLimit } from '../lib/fetch-response-body' -import { readIntegrationCredentialFileText } from '../integration-credential-file' import { join } from 'node:path' import { probeCodexAuthPresence } from './codex-auth-presence' import { @@ -33,12 +32,9 @@ import { resolveHiddenRateLimitPtyCwd } from './hidden-rate-limit-pty-cwd' import { - AuthFilesystemOperationLimitError, - AuthFilesystemOperationRegistry, + createAuthFilesystemOperation, type SharedAuthFilesystemOperation } from './auth-filesystem-operation' -import { GrowingByteBuffer } from '../../shared/growing-byte-buffer' -import { appendRateLimitPtyOutputTail } from './rate-limit-pty-output-tail' const RPC_TIMEOUT_MS = 10_000 const WSL_RPC_TIMEOUT_MS = 25_000 @@ -47,7 +43,6 @@ const BACKEND_TIMEOUT_MS = 10_000 // Why: redeeming a reset credit is an explicit user action, not a poll — allow more time for a slow backend. const REDEEM_BACKEND_TIMEOUT_MS = 30_000 const MAX_DIAGNOSTIC_OUTPUT_LENGTH = 100_000 -export const MAX_RPC_RESPONSE_LINE_BYTES = 4 * 1024 * 1024 export type FetchCodexRateLimitsOptions = { codexHomePath?: string | null @@ -135,7 +130,10 @@ type BackendAuthReadResult = | { content: string; error?: never } | { content?: never; error: unknown } -const backendAuthReads = new AuthFilesystemOperationRegistry() +const backendAuthReadByPath = new Map< + string, + SharedAuthFilesystemOperation +>() function shellQuote(value: string): string { return `'${value.replace(/'/g, "'\\''")}'` @@ -294,22 +292,30 @@ function createBackendRequestSignal( function getBackendAuthRead( authPath: string -): SharedAuthFilesystemOperation | null { +): SharedAuthFilesystemOperation { + const existing = backendAuthReadByPath.get(authPath) + if (existing) { + return existing + } // Why: Node can't cancel an in-flight UNC read; keep one read per auth path so repeated refreshes don't stack them. - return backendAuthReads.getOrCreate(authPath, () => - readIntegrationCredentialFileText(authPath).then( + const read = createAuthFilesystemOperation(authPath, () => + readFile(authPath, 'utf8').then( (content) => ({ content }), (error: unknown) => ({ error }) ) ) + backendAuthReadByPath.set(authPath, read) + const clearRead = (): void => { + if (backendAuthReadByPath.get(authPath) === read) { + backendAuthReadByPath.delete(authPath) + } + } + void read.result.then(clearRead, clearRead) + return read } async function readBackendAuth(authPath: string, signal: AbortSignal): Promise { - const read = getBackendAuthRead(authPath) - if (!read) { - throw new AuthFilesystemOperationLimitError('Codex backend auth read capacity exceeded') - } - const result = await read.wait(signal) + const result = await getBackendAuthRead(authPath).wait(signal) if ('error' in result) { throw result.error } @@ -365,8 +371,7 @@ async function fetchBackendRateLimitResetCredits( await cancelUnreadResponseBody(response) return null } - const payload = - await readFetchResponseJsonWithinLimit(response) + const payload = (await response.json()) as BackendRateLimitResetCreditsResponse return mapBackendRateLimitResetCredits(payload) ?? null } @@ -434,8 +439,7 @@ export async function consumeCodexRateLimitResetCredit(options: { await cancelUnreadResponseBody(response) throw new Error(`Codex reset failed: HTTP ${response.status}`) } - const payload = - await readFetchResponseJsonWithinLimit(response) + const payload = (await response.json()) as BackendConsumeRateLimitResetCreditResponse return mapBackendConsumeOutcome(payload.code) } @@ -515,7 +519,7 @@ async function fetchViaBackend( await cancelUnreadResponseBody(response) return null } - const payload = await readFetchResponseJsonWithinLimit(response) + const payload = (await response.json()) as BackendUsageResponse // Why: plan_type is required by Codex's RateLimitStatusPayload; reject malformed JSON so the app-server fallback still runs. if (typeof payload.plan_type !== 'string') { return null @@ -547,8 +551,8 @@ async function fetchViaRpc(options?: FetchCodexRateLimitsOptions): Promise((resolve) => { - const buffer = new GrowingByteBuffer() - const stderr = new GrowingByteBuffer() + let buffer = '' + let stderr = '' let resolved = false let rpcId = 0 @@ -595,8 +599,6 @@ async function fetchViaRpc(options?: FetchCodexRateLimitsOptions): Promise MAX_RPC_RESPONSE_LINE_BYTES - buffer.byteLength) { - settle( - { - provider: 'codex', - session: null, - weekly: null, - updatedAt: Date.now(), - error: `RPC response exceeded ${MAX_RPC_RESPONSE_LINE_BYTES} byte line limit`, - status: 'error' - }, - { kill: true } - ) - return - } - buffer.append(segment) - if (!hasNewline) { - return - } - offset = newlineIdx + 1 - const line = buffer.takeString('utf8').trim() + buffer += chunk.toString() + + // JSON-RPC messages are newline-delimited + let newlineIdx: number + while ((newlineIdx = buffer.indexOf('\n')) !== -1) { + const line = buffer.slice(0, newlineIdx).trim() + buffer = buffer.slice(newlineIdx + 1) if (!line) { continue } @@ -706,7 +687,7 @@ async function fetchViaRpc(options?: FetchCodexRateLimitsOptions): Promise MAX_DIAGNOSTIC_OUTPUT_LENGTH) { + stderr = stderr.slice(-MAX_DIAGNOSTIC_OUTPUT_LENGTH) + } } function onError(err: Error): void { @@ -758,7 +743,7 @@ async function fetchViaRpc(options?: FetchCodexRateLimitsOptions): Promise { - const appended = appendRateLimitPtyOutputTail(output, data, MAX_DIAGNOSTIC_OUTPUT_LENGTH) - output = appended.output + output += data + // Why: only recent status output is needed; cap noisy TUI output like the Claude fallback. + if (output.length > MAX_DIAGNOSTIC_OUTPUT_LENGTH) { + output = output.slice(-MAX_DIAGNOSTIC_OUTPUT_LENGTH) + } // Wait for prompt, then send /status - if (!sentStatus && />\s*$/.test(appended.scannedChunk)) { + if (!sentStatus && />\s*$/.test(data)) { sentStatus = true term.write('/status\r') return diff --git a/src/main/rate-limits/gemini-cli-oauth-extractor-bounds.test.ts b/src/main/rate-limits/gemini-cli-oauth-extractor-bounds.test.ts deleted file mode 100644 index d982e15d0ab..00000000000 --- a/src/main/rate-limits/gemini-cli-oauth-extractor-bounds.test.ts +++ /dev/null @@ -1,204 +0,0 @@ -import { mkdirSync, mkdtempSync, rmSync, statSync, truncateSync, writeFileSync } from 'node:fs' -import type * as FsPromises from 'node:fs/promises' -import { tmpdir } from 'node:os' -import path from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const opendirMock = vi.hoisted(() => vi.fn()) - -vi.mock('node:fs/promises', async (importOriginal) => ({ - ...(await importOriginal()), - opendir: opendirMock -})) - -import { - extractGeminiOAuthCredentialsFromBundleDir, - findGeminiPackageRoot, - MAX_GEMINI_CLI_BUNDLE_ENTRIES, - MAX_GEMINI_CLI_BUNDLE_FILES, - MAX_GEMINI_CLI_OAUTH_SOURCE_BYTES, - MAX_GEMINI_CLI_PACKAGE_JSON_BYTES, - readGeminiOAuthCredentialsFile -} from './gemini-cli-oauth-extractor' - -const roots: string[] = [] - -function createRoot(): string { - const root = mkdtempSync(path.join(tmpdir(), 'orca-gemini-oauth-bounds-')) - roots.push(root) - return root -} - -function useDirectoryEntries(names: string[]): { - close: ReturnType - read: ReturnType -} { - let index = 0 - const directory = { - close: vi.fn(async () => undefined), - read: vi.fn(async () => { - const name = names[index] - index += 1 - return name === undefined ? null : { name } - }) - } - opendirMock.mockResolvedValueOnce(directory) - return directory -} - -function oauthSource(clientId: string, clientSecret: string): string { - return [ - `const OAUTH_CLIENT_ID = '${clientId}'`, - `const OAUTH_CLIENT_SECRET = "${clientSecret}"` - ].join('\n') -} - -beforeEach(() => { - opendirMock.mockReset() -}) - -afterEach(() => { - for (const root of roots.splice(0)) { - rmSync(root, { recursive: true, force: true }) - } -}) - -describe('Gemini CLI OAuth extraction bounds', () => { - it('preserves credentials from a normal installed source file', async () => { - const sourcePath = path.join(createRoot(), 'oauth2.js') - writeFileSync(sourcePath, oauthSource('normal-client', 'normal-secret')) - - await expect(readGeminiOAuthCredentialsFile(sourcePath)).resolves.toEqual({ - clientId: 'normal-client', - clientSecret: 'normal-secret' - }) - }) - - it('skips an oversized sparse installed source file', async () => { - const sourcePath = path.join(createRoot(), 'oauth2.js') - writeFileSync(sourcePath, '') - truncateSync(sourcePath, MAX_GEMINI_CLI_OAUTH_SOURCE_BYTES + 1) - - await expect(readGeminiOAuthCredentialsFile(sourcePath)).resolves.toBeNull() - }) - - it('accepts package metadata at the exact byte limit', async () => { - const packageRoot = createRoot() - const packagePath = path.join(packageRoot, 'package.json') - const manifest = JSON.stringify({ name: '@google/gemini-cli' }) - writeFileSync( - packagePath, - manifest + ' '.repeat(MAX_GEMINI_CLI_PACKAGE_JSON_BYTES - Buffer.byteLength(manifest)) - ) - - expect(statSync(packagePath).size).toBe(MAX_GEMINI_CLI_PACKAGE_JSON_BYTES) - await expect(findGeminiPackageRoot(path.join(packageRoot, 'bin', 'gemini'))).resolves.toBe( - packageRoot - ) - }) - - it('skips oversized sparse package metadata and keeps walking', async () => { - const packageRoot = createRoot() - const nestedRoot = path.join(packageRoot, 'nested') - mkdirSync(path.join(nestedRoot, 'bin'), { recursive: true }) - writeFileSync(path.join(packageRoot, 'package.json'), manifestForGemini()) - const oversizedManifest = path.join(nestedRoot, 'package.json') - writeFileSync(oversizedManifest, '') - truncateSync(oversizedManifest, MAX_GEMINI_CLI_PACKAGE_JSON_BYTES + 1) - - await expect(findGeminiPackageRoot(path.join(nestedRoot, 'bin', 'gemini'))).resolves.toBe( - packageRoot - ) - }) - - it('streams bundle entries and continues after an oversized sparse chunk', async () => { - const packageRoot = createRoot() - const bundleDir = path.join(packageRoot, 'bundle') - mkdirSync(bundleDir) - const oversizedPath = path.join(bundleDir, 'old.js') - writeFileSync(oversizedPath, '') - truncateSync(oversizedPath, MAX_GEMINI_CLI_OAUTH_SOURCE_BYTES + 1) - writeFileSync(path.join(bundleDir, 'current.js'), oauthSource('bundle-client', 'bundle-secret')) - const directory = useDirectoryEntries(['old.js', 'current.js']) - - await expect(extractGeminiOAuthCredentialsFromBundleDir(packageRoot)).resolves.toEqual({ - clientId: 'bundle-client', - clientSecret: 'bundle-secret' - }) - expect(directory.read).toHaveBeenCalledTimes(2) - expect(directory.close).toHaveBeenCalledOnce() - }) - - it('admits a credential chunk at the exact directory entry limit', async () => { - const packageRoot = createRoot() - const bundleDir = path.join(packageRoot, 'bundle') - mkdirSync(bundleDir) - writeFileSync(path.join(bundleDir, 'credentials.js'), oauthSource('last-client', 'last-secret')) - const names = [ - ...Array.from({ length: MAX_GEMINI_CLI_BUNDLE_ENTRIES - 1 }, (_, index) => `${index}.txt`), - 'credentials.js' - ] - const directory = useDirectoryEntries(names) - - await expect(extractGeminiOAuthCredentialsFromBundleDir(packageRoot)).resolves.toEqual({ - clientId: 'last-client', - clientSecret: 'last-secret' - }) - expect(directory.read).toHaveBeenCalledTimes(MAX_GEMINI_CLI_BUNDLE_ENTRIES) - }) - - it('does not retain or inspect a directory entry beyond the cap', async () => { - const packageRoot = createRoot() - const bundleDir = path.join(packageRoot, 'bundle') - mkdirSync(bundleDir) - writeFileSync(path.join(bundleDir, 'too-late.js'), oauthSource('late-client', 'late-secret')) - const names = [ - ...Array.from({ length: MAX_GEMINI_CLI_BUNDLE_ENTRIES }, (_, index) => `${index}.txt`), - 'too-late.js' - ] - const directory = useDirectoryEntries(names) - - await expect(extractGeminiOAuthCredentialsFromBundleDir(packageRoot)).resolves.toBeNull() - expect(directory.read).toHaveBeenCalledTimes(MAX_GEMINI_CLI_BUNDLE_ENTRIES) - }) - - it('admits a credential chunk at the exact JavaScript file limit', async () => { - const packageRoot = createRoot() - const bundleDir = path.join(packageRoot, 'bundle') - mkdirSync(bundleDir) - writeFileSync(path.join(bundleDir, 'credentials.js'), oauthSource('file-client', 'file-secret')) - const names = [ - ...Array.from( - { length: MAX_GEMINI_CLI_BUNDLE_FILES - 1 }, - (_, index) => `missing-${index}.js` - ), - 'credentials.js' - ] - const directory = useDirectoryEntries(names) - - await expect(extractGeminiOAuthCredentialsFromBundleDir(packageRoot)).resolves.toEqual({ - clientId: 'file-client', - clientSecret: 'file-secret' - }) - expect(directory.read).toHaveBeenCalledTimes(MAX_GEMINI_CLI_BUNDLE_FILES) - }) - - it('does not inspect a JavaScript file beyond the file cap', async () => { - const packageRoot = createRoot() - const bundleDir = path.join(packageRoot, 'bundle') - mkdirSync(bundleDir) - writeFileSync(path.join(bundleDir, 'too-late.js'), oauthSource('late-client', 'late-secret')) - const names = [ - ...Array.from({ length: MAX_GEMINI_CLI_BUNDLE_FILES }, (_, index) => `missing-${index}.js`), - 'too-late.js' - ] - const directory = useDirectoryEntries(names) - - await expect(extractGeminiOAuthCredentialsFromBundleDir(packageRoot)).resolves.toBeNull() - expect(directory.read).toHaveBeenCalledTimes(MAX_GEMINI_CLI_BUNDLE_FILES) - }) -}) - -function manifestForGemini(): string { - return JSON.stringify({ name: '@google/gemini-cli' }) -} diff --git a/src/main/rate-limits/gemini-cli-oauth-extractor.ts b/src/main/rate-limits/gemini-cli-oauth-extractor.ts index 512398a2e8e..80e841e7adf 100644 --- a/src/main/rate-limits/gemini-cli-oauth-extractor.ts +++ b/src/main/rate-limits/gemini-cli-oauth-extractor.ts @@ -1,17 +1,10 @@ import { exec } from 'node:child_process' -import { access, opendir, realpath } from 'node:fs/promises' +import { access, readdir, readFile, realpath } from 'node:fs/promises' import { promisify } from 'node:util' import { homedir } from 'node:os' import path from 'node:path' -import { readNodeFileWithinLimit } from '../../shared/node-bounded-file-reader' const execAsync = promisify(exec) -const MAX_BINARY_LOOKUP_OUTPUT_BYTES = 64 * 1024 -export const MAX_GEMINI_CLI_OAUTH_SOURCE_BYTES = 32 * 1024 * 1024 -export const MAX_GEMINI_CLI_PACKAGE_JSON_BYTES = 1024 * 1024 -export const MAX_GEMINI_CLI_BUNDLE_ENTRIES = 4_096 -export const MAX_GEMINI_CLI_BUNDLE_FILES = 512 -export const MAX_GEMINI_CLI_BUNDLE_BYTES = 128 * 1024 * 1024 async function fileExists(filePath: string): Promise { try { @@ -28,13 +21,8 @@ const OAUTH2_SUBPATH = path.join('dist', 'src', 'code_assist', 'oauth2.js') async function resolveGeminiBinary(): Promise { const whichCmd = process.platform === 'win32' ? 'where gemini' : 'which gemini' try { - const { stdout } = await execAsync(whichCmd, { - encoding: 'utf-8', - maxBuffer: MAX_BINARY_LOOKUP_OUTPUT_BYTES - }) - const trimmedOutput = stdout.trim() - const firstLineEnd = trimmedOutput.search(/\r?\n/) - const fromPath = trimmedOutput.slice(0, firstLineEnd === -1 ? undefined : firstLineEnd) + const { stdout } = await execAsync(whichCmd, { encoding: 'utf-8' }) + const fromPath = stdout.trim().split(/\r?\n/)[0] if (fromPath && (await fileExists(fromPath))) { return fromPath } @@ -81,35 +69,17 @@ function parseOAuthCredentials(content: string): { clientId: string; clientSecre return null } -type GeminiOAuthSourceRead = { - credentials: { clientId: string; clientSecret: string } | null - bytesRead: number -} - -async function readOAuthSource( - filePath: string, - maxBytes = MAX_GEMINI_CLI_OAUTH_SOURCE_BYTES -): Promise { +async function tryReadCredentials( + filePath: string +): Promise<{ clientId: string; clientSecret: string } | null> { try { - const { buffer } = await readNodeFileWithinLimit( - filePath, - Math.min(MAX_GEMINI_CLI_OAUTH_SOURCE_BYTES, maxBytes) - ) - return { - credentials: parseOAuthCredentials(buffer.toString('utf8')), - bytesRead: buffer.length - } + const content = await readFile(filePath, 'utf-8') + return parseOAuthCredentials(content) } catch { return null } } -export async function readGeminiOAuthCredentialsFile( - filePath: string -): Promise<{ clientId: string; clientSecret: string } | null> { - return (await readOAuthSource(filePath))?.credentials ?? null -} - // Why: these are the known stable layouts for every major Gemini CLI install method. // Checking explicit paths is fast and avoids walking the entire directory tree. async function extractFromKnownPaths( @@ -161,7 +131,7 @@ async function extractFromKnownPaths( ] for (const candidate of candidates) { - const creds = await readGeminiOAuthCredentialsFile(path.normalize(candidate)) + const creds = await tryReadCredentials(path.normalize(candidate)) if (creds) { return creds } @@ -173,79 +143,50 @@ async function extractFromKnownPaths( // Why: newer Gemini CLI versions (>=0.38) ship everything bundled into hash-named // chunks with no oauth2.js source file. Scanning the bundle dir for the credential // constants is the only reliable fallback for those installs. -export async function extractGeminiOAuthCredentialsFromBundleDir( +async function extractFromBundleDir( geminiCliPackageRoot: string ): Promise<{ clientId: string; clientSecret: string } | null> { const bundleDir = path.join(geminiCliPackageRoot, 'bundle') - let directory: Awaited> + if (!(await fileExists(bundleDir))) { + return null + } + + let entries: string[] try { - directory = await opendir(bundleDir, { bufferSize: 32 }) + entries = (await readdir(bundleDir)).filter((f) => f.endsWith('.js')) } catch { return null } - let inspectedEntries = 0 - let inspectedFiles = 0 - let inspectedBytes = 0 - try { - while ( - inspectedEntries < MAX_GEMINI_CLI_BUNDLE_ENTRIES && - inspectedFiles < MAX_GEMINI_CLI_BUNDLE_FILES - ) { - const entry = await directory.read() - if (!entry) { - return null - } - inspectedEntries += 1 - if (!entry.name.endsWith('.js')) { - continue - } - inspectedFiles += 1 - const remainingBytes = MAX_GEMINI_CLI_BUNDLE_BYTES - inspectedBytes - if (remainingBytes === 0) { - return null - } - const source = await readOAuthSource(path.join(bundleDir, entry.name), remainingBytes) - if (!source) { - continue - } - if (source.bytesRead > MAX_GEMINI_CLI_BUNDLE_BYTES - inspectedBytes) { - return null - } - inspectedBytes += source.bytesRead - if (source.credentials) { - return source.credentials - } - } - return null - } catch { - return null - } finally { - try { - await directory.close() - } catch { - // Cleanup failure must not mask credential discovery. + for (const entry of entries) { + const creds = await tryReadCredentials(path.join(bundleDir, entry)) + if (creds) { + return creds } } + + return null } // Resolves the gemini-cli package root directory by walking up the directory // tree from the real binary path, looking for package.json with the right name, // or the global Node layout under lib/node_modules. -export async function findGeminiPackageRoot(realGeminiPath: string): Promise { +async function findGeminiPackageRoot(realGeminiPath: string): Promise { const MAX_ASCENTS = 8 let current = path.dirname(realGeminiPath) for (let i = 0; i <= MAX_ASCENTS; i++) { const pkgJson = path.join(current, 'package.json') - try { - const { buffer } = await readNodeFileWithinLimit(pkgJson, MAX_GEMINI_CLI_PACKAGE_JSON_BYTES) - const pkg = JSON.parse(buffer.toString('utf8')) as { name?: string } - if (pkg.name === '@google/gemini-cli') { - return current + if (await fileExists(pkgJson)) { + try { + const raw = await readFile(pkgJson, 'utf-8') + const pkg = JSON.parse(raw) as { name?: string } + if (pkg.name === '@google/gemini-cli') { + return current + } + } catch { + // malformed package.json — keep walking } - } catch { - // Missing, malformed, or oversized package.json — keep walking. } // Global Node layout: /lib/node_modules/@google/gemini-cli @@ -304,14 +245,14 @@ export async function extractOAuthClientCredentials(): Promise<{ const packageRoot = await findGeminiPackageRoot(realPath) if (packageRoot) { const fromSource = - (await readGeminiOAuthCredentialsFile( + (await tryReadCredentials( path.join(packageRoot, 'node_modules', '@google', 'gemini-cli-core', OAUTH2_SUBPATH) - )) ?? (await readGeminiOAuthCredentialsFile(path.join(packageRoot, OAUTH2_SUBPATH))) + )) ?? (await tryReadCredentials(path.join(packageRoot, OAUTH2_SUBPATH))) if (fromSource) { return fromSource } - const fromBundle = await extractGeminiOAuthCredentialsFromBundleDir(packageRoot) + const fromBundle = await extractFromBundleDir(packageRoot) if (fromBundle) { return fromBundle } diff --git a/src/main/rate-limits/gemini-oauth-sources.ts b/src/main/rate-limits/gemini-oauth-sources.ts index b28f4383b29..386c8b281f3 100644 --- a/src/main/rate-limits/gemini-oauth-sources.ts +++ b/src/main/rate-limits/gemini-oauth-sources.ts @@ -1,10 +1,8 @@ -import { writeFile, rename } from 'node:fs/promises' +import { readFile, writeFile, rename } from 'node:fs/promises' import { homedir } from 'node:os' import path from 'node:path' import { net } from 'electron' import { extractOAuthClientCredentials } from './gemini-cli-oauth-extractor' -import { readFetchResponseJsonWithinLimit } from '../lib/fetch-response-body' -import { readIntegrationCredentialFileText } from '../integration-credential-file' const API_TIMEOUT_MS = 10_000 const OAUTH_CREDS_PATH = path.join(homedir(), '.gemini', 'oauth_creds.json') @@ -41,7 +39,7 @@ export async function readAuthJson(): Promise { for (const candidate of candidates) { try { - const raw = await readIntegrationCredentialFileText(candidate) + const raw = await readFile(candidate, 'utf-8') return JSON.parse(raw) as AuthJson } catch (err) { if (err && typeof err === 'object' && 'code' in err && err.code === 'ENOENT') { @@ -56,7 +54,7 @@ export async function readAuthJson(): Promise { export async function readGeminiCredentials(): Promise { try { - const raw = await readIntegrationCredentialFileText(OAUTH_CREDS_PATH) + const raw = await readFile(OAUTH_CREDS_PATH, 'utf-8') const parsed = JSON.parse(raw) as unknown if ( parsed && @@ -112,11 +110,11 @@ export async function refreshAccessToken( return { accessToken: null, newRefreshToken: null } } - const data = await readFetchResponseJsonWithinLimit<{ + const data = (await res.json()) as { access_token?: string refresh_token?: string expires_in?: number - }>(res) + } return { accessToken: typeof data.access_token === 'string' ? data.access_token : null, newRefreshToken: typeof data.refresh_token === 'string' ? data.refresh_token : null, @@ -139,7 +137,7 @@ export async function loadProjectId(accessToken: string): Promise { throw new Error(`Failed to load Gemini project ID (HTTP ${res.status})`) } - const data = await readFetchResponseJsonWithinLimit<{ cloudaicompanionProject?: string }>(res) + const data = (await res.json()) as { cloudaicompanionProject?: string } if (typeof data.cloudaicompanionProject !== 'string') { throw new Error('Gemini project ID not found in API response') } diff --git a/src/main/rate-limits/gemini-usage-fetcher.fallback.test.ts b/src/main/rate-limits/gemini-usage-fetcher.fallback.test.ts index b2acf274e70..fddd0a77d59 100644 --- a/src/main/rate-limits/gemini-usage-fetcher.fallback.test.ts +++ b/src/main/rate-limits/gemini-usage-fetcher.fallback.test.ts @@ -23,15 +23,13 @@ vi.mock('./gemini-cli-oauth-extractor', () => ({ })) vi.mock('node:fs/promises', () => ({ + readFile: readFileMock, // Why: saveGeminiCredentials is exercised on the refresh path. The atomic // tmp+rename write has no observable side effect in these tests, so the // stubs just resolve. writeFile: vi.fn().mockResolvedValue(undefined), rename: vi.fn().mockResolvedValue(undefined) })) -vi.mock('../integration-credential-file', () => ({ - readIntegrationCredentialFileText: readFileMock -})) vi.mock('electron', () => ({ net: { fetch: netFetchMock } diff --git a/src/main/rate-limits/gemini-usage-fetcher.test.ts b/src/main/rate-limits/gemini-usage-fetcher.test.ts index 16f90a338f8..e8415c2e07e 100644 --- a/src/main/rate-limits/gemini-usage-fetcher.test.ts +++ b/src/main/rate-limits/gemini-usage-fetcher.test.ts @@ -22,12 +22,10 @@ vi.mock('./gemini-cli-oauth-extractor', () => ({ })) vi.mock('node:fs/promises', () => ({ + readFile: readFileMock, writeFile: vi.fn().mockResolvedValue(undefined), rename: vi.fn().mockResolvedValue(undefined) })) -vi.mock('../integration-credential-file', () => ({ - readIntegrationCredentialFileText: readFileMock -})) vi.mock('electron', () => ({ net: { fetch: netFetchMock } })) import { fetchGeminiRateLimits } from './gemini-usage-fetcher' diff --git a/src/main/rate-limits/gemini-usage-fetcher.ts b/src/main/rate-limits/gemini-usage-fetcher.ts index d437456d8c0..1f214b4b5eb 100644 --- a/src/main/rate-limits/gemini-usage-fetcher.ts +++ b/src/main/rate-limits/gemini-usage-fetcher.ts @@ -1,6 +1,5 @@ import { net } from 'electron' import type { ProviderRateLimits } from '../../shared/rate-limit-types' -import { readFetchResponseJsonWithinLimit } from '../lib/fetch-response-body' import { loadProjectId, readAuthJson, @@ -64,7 +63,7 @@ async function fetchQuota(accessToken: string, projectId: string): Promise(res) + const data = (await res.json()) as unknown const buckets = deduplicateBuckets( parseQuotaResponse(data).map((b) => ({ ...buildRateLimitBucket(b), modelId: b.modelId })) ) diff --git a/src/main/rate-limits/grok-auth-file-bounds.test.ts b/src/main/rate-limits/grok-auth-file-bounds.test.ts deleted file mode 100644 index a26e39d8700..00000000000 --- a/src/main/rate-limits/grok-auth-file-bounds.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { mkdtempSync, rmSync, statSync, truncateSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { MAX_INTEGRATION_CREDENTIAL_FILE_BYTES } from '../integration-credential-file' -import { readGrokAuthSession } from './grok-auth' - -const roots: string[] = [] - -function createGrokHome(): string { - const root = mkdtempSync(join(tmpdir(), 'orca-grok-auth-bounds-')) - roots.push(root) - vi.stubEnv('GROK_HOME', root) - return root -} - -afterEach(() => { - vi.unstubAllEnvs() - for (const root of roots.splice(0)) { - rmSync(root, { recursive: true, force: true }) - } -}) - -describe('Grok auth file bounds', () => { - it('preserves a normal Grok auth session', () => { - const root = createGrokHome() - writeFileSync( - join(root, 'auth.json'), - JSON.stringify({ - 'https://auth.x.ai::client': { - key: 'normal-token', - email: 'alice@example.com' - } - }) - ) - - expect(readGrokAuthSession()).toMatchObject({ - status: 'ok', - session: { - accessToken: 'normal-token', - email: 'alice@example.com' - } - }) - }) - - it('accepts valid auth JSON at the exact credential byte limit', () => { - const root = createGrokHome() - const prefix = '{"https://auth.x.ai":{"key":"' - const suffix = '"}}' - const tokenLength = MAX_INTEGRATION_CREDENTIAL_FILE_BYTES - Buffer.byteLength(prefix + suffix) - const authPath = join(root, 'auth.json') - writeFileSync(authPath, `${prefix}${'x'.repeat(tokenLength)}${suffix}`) - - expect(statSync(authPath).size).toBe(MAX_INTEGRATION_CREDENTIAL_FILE_BYTES) - const result = readGrokAuthSession() - expect(result.status).toBe('ok') - if (result.status === 'ok') { - expect(result.session.accessToken).toHaveLength(tokenLength) - expect(result.session.accessToken.at(0)).toBe('x') - expect(result.session.accessToken.at(-1)).toBe('x') - } - }) - - it('rejects an oversized sparse auth file without loading its payload', () => { - const root = createGrokHome() - const authPath = join(root, 'auth.json') - writeFileSync(authPath, '') - truncateSync(authPath, MAX_INTEGRATION_CREDENTIAL_FILE_BYTES + 1) - - expect(readGrokAuthSession()).toEqual({ - status: 'error', - error: 'Unable to read Grok auth file' - }) - }) -}) diff --git a/src/main/rate-limits/grok-auth.test.ts b/src/main/rate-limits/grok-auth.test.ts index 3c133a6bb73..7bf8f9e62b3 100644 --- a/src/main/rate-limits/grok-auth.test.ts +++ b/src/main/rate-limits/grok-auth.test.ts @@ -1,40 +1,21 @@ -import type * as NodeFs from 'node:fs' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const authFile = vi.hoisted<{ contents: string | null; error: Error | null }>(() => ({ - contents: null, - error: null -})) - -vi.mock('node:fs', async (importOriginal) => ({ - ...(await importOriginal()), - existsSync: vi.fn(() => authFile.contents !== null || authFile.error !== null) -})) - -vi.mock('../integration-credential-file', () => ({ - readIntegrationCredentialFileSyncText: vi.fn(() => { - if (authFile.error) { - throw authFile.error - } - if (authFile.contents === null) { - throw new Error('Auth file is missing') - } - return authFile.contents - }) -})) - -import { readGrokAuthSession } from './grok-auth' +import { afterEach, describe, expect, it, vi } from 'vitest' describe('readGrokAuthSession', () => { - beforeEach(() => { - authFile.contents = null - authFile.error = null + afterEach(() => { + vi.resetModules() + vi.doUnmock('node:fs') }) - it('redacts filesystem paths from auth read failures', () => { - authFile.error = new Error( - 'EACCES: permission denied, open /Users/brennanbenson/private/.grok/auth.json' - ) + it('redacts filesystem paths from auth read failures', async () => { + vi.doMock('node:fs', () => ({ + existsSync: vi.fn(() => true), + readFileSync: vi.fn(() => { + throw new Error( + 'EACCES: permission denied, open /Users/brennanbenson/private/.grok/auth.json' + ) + }) + })) + const { readGrokAuthSession } = await import('./grok-auth') expect(readGrokAuthSession()).toEqual({ status: 'error', @@ -42,16 +23,22 @@ describe('readGrokAuthSession', () => { }) }) - it('treats a token-less auth file as signed out, not an error', () => { - authFile.contents = JSON.stringify({ - 'https://auth.x.ai::client': { user_id: 'u1' } - }) + it('treats a token-less auth file as signed out, not an error', async () => { + vi.doMock('node:fs', () => ({ + existsSync: vi.fn(() => true), + readFileSync: vi.fn(() => JSON.stringify({ 'https://auth.x.ai::client': { user_id: 'u1' } })) + })) + const { readGrokAuthSession } = await import('./grok-auth') expect(readGrokAuthSession()).toEqual({ status: 'missing' }) }) - it('reports malformed auth JSON without parser details', () => { - authFile.contents = '{' + it('reports malformed auth JSON without parser details', async () => { + vi.doMock('node:fs', () => ({ + existsSync: vi.fn(() => true), + readFileSync: vi.fn(() => '{') + })) + const { readGrokAuthSession } = await import('./grok-auth') expect(readGrokAuthSession()).toEqual({ status: 'error', @@ -61,23 +48,29 @@ describe('readGrokAuthSession', () => { it.each(['https://auth.x.ai', 'https://auth.x.ai::client'])( 'prefers the %s issuer entry over an earlier alternate issuer', - (preferredIssuer) => { - authFile.contents = JSON.stringify({ - 'https://stale.example.com::client': { - key: 'stale-token', - user_id: 'stale-user', - email: 'stale@example.com', - expires_at: '2099-01-01T00:00:00.000Z' - }, - [preferredIssuer]: { - key: 'live-token', - user_id: 'live-user', - email: 'live@example.com', - team_id: 'team-1', - expires_at: '2099-06-01T00:00:00.000Z', - oidc_client_id: 'client-1' - } - }) + async (preferredIssuer) => { + vi.doMock('node:fs', () => ({ + existsSync: vi.fn(() => true), + readFileSync: vi.fn(() => + JSON.stringify({ + 'https://stale.example.com::client': { + key: 'stale-token', + user_id: 'stale-user', + email: 'stale@example.com', + expires_at: '2099-01-01T00:00:00.000Z' + }, + [preferredIssuer]: { + key: 'live-token', + user_id: 'live-user', + email: 'live@example.com', + team_id: 'team-1', + expires_at: '2099-06-01T00:00:00.000Z', + oidc_client_id: 'client-1' + } + }) + ) + })) + const { readGrokAuthSession } = await import('./grok-auth') expect(readGrokAuthSession()).toEqual({ status: 'ok', @@ -93,15 +86,21 @@ describe('readGrokAuthSession', () => { } ) - it('falls back to the first tokenized entry when no auth.x.ai key exists', () => { - authFile.contents = JSON.stringify({ - 'https://alternate.example.com::client': { - key: 'alt-token', - user_id: 'alt-user', - email: 'alt@example.com', - expires_at: '2099-01-01T00:00:00.000Z' - } - }) + it('falls back to the first tokenized entry when no auth.x.ai key exists', async () => { + vi.doMock('node:fs', () => ({ + existsSync: vi.fn(() => true), + readFileSync: vi.fn(() => + JSON.stringify({ + 'https://alternate.example.com::client': { + key: 'alt-token', + user_id: 'alt-user', + email: 'alt@example.com', + expires_at: '2099-01-01T00:00:00.000Z' + } + }) + ) + })) + const { readGrokAuthSession } = await import('./grok-auth') expect(readGrokAuthSession()).toEqual({ status: 'ok', @@ -116,17 +115,23 @@ describe('readGrokAuthSession', () => { }) }) - it('skips an expired auth.x.ai client entry when a fresh one follows it', () => { - authFile.contents = JSON.stringify({ - 'https://auth.x.ai::old-client': { - key: 'expired-token', - expires_at: '2020-01-01T00:00:00.000Z' - }, - 'https://auth.x.ai::current-client': { - key: 'fresh-token', - expires_at: '2099-01-01T00:00:00.000Z' - } - }) + it('skips an expired auth.x.ai client entry when a fresh one follows it', async () => { + vi.doMock('node:fs', () => ({ + existsSync: vi.fn(() => true), + readFileSync: vi.fn(() => + JSON.stringify({ + 'https://auth.x.ai::old-client': { + key: 'expired-token', + expires_at: '2020-01-01T00:00:00.000Z' + }, + 'https://auth.x.ai::current-client': { + key: 'fresh-token', + expires_at: '2099-01-01T00:00:00.000Z' + } + }) + ) + })) + const { readGrokAuthSession } = await import('./grok-auth') expect(readGrokAuthSession()).toMatchObject({ status: 'ok', @@ -134,11 +139,17 @@ describe('readGrokAuthSession', () => { }) }) - it('does not resurrect an alternate issuer when an auth.x.ai entry is tokenless', () => { - authFile.contents = JSON.stringify({ - 'https://alternate.example.com::client': { key: 'stale-token' }, - 'https://auth.x.ai::client': { user_id: 'signed-out-user' } - }) + it('does not resurrect an alternate issuer when an auth.x.ai entry is tokenless', async () => { + vi.doMock('node:fs', () => ({ + existsSync: vi.fn(() => true), + readFileSync: vi.fn(() => + JSON.stringify({ + 'https://alternate.example.com::client': { key: 'stale-token' }, + 'https://auth.x.ai::client': { user_id: 'signed-out-user' } + }) + ) + })) + const { readGrokAuthSession } = await import('./grok-auth') expect(readGrokAuthSession()).toEqual({ status: 'missing' }) }) diff --git a/src/main/rate-limits/grok-auth.ts b/src/main/rate-limits/grok-auth.ts index 5f2d5708121..e055c022975 100644 --- a/src/main/rate-limits/grok-auth.ts +++ b/src/main/rate-limits/grok-auth.ts @@ -1,7 +1,6 @@ -import { existsSync } from 'node:fs' +import { existsSync, readFileSync } from 'node:fs' import { join } from 'node:path' import { resolveGrokHomeDir } from '../../shared/grok-session-paths' -import { readIntegrationCredentialFileSyncText } from '../integration-credential-file' // Why: when GROK_HOME is set, auth.json must be the same path Grok CLI uses. export function getGrokHome(): string { @@ -89,7 +88,7 @@ export function readGrokAuthSession(): GrokAuthReadResult { return { status: 'missing' } } try { - const parsed: unknown = JSON.parse(readIntegrationCredentialFileSyncText(path)) + const parsed: unknown = JSON.parse(readFileSync(path, 'utf-8')) if (typeof parsed !== 'object' || parsed === null) { return { status: 'error', error: 'Grok auth file is invalid' } } diff --git a/src/main/rate-limits/grok-fetcher.test.ts b/src/main/rate-limits/grok-fetcher.test.ts index 115620352a3..63d7bc5cfcd 100644 --- a/src/main/rate-limits/grok-fetcher.test.ts +++ b/src/main/rate-limits/grok-fetcher.test.ts @@ -11,11 +11,8 @@ vi.mock('electron', () => ({ })) vi.mock('node:fs', () => ({ - existsSync: () => authState.file !== null -})) - -vi.mock('../integration-credential-file', () => ({ - readIntegrationCredentialFileSyncText: () => { + existsSync: () => authState.file !== null, + readFileSync: () => { if (authState.readError) { throw authState.readError } diff --git a/src/main/rate-limits/grok-fetcher.ts b/src/main/rate-limits/grok-fetcher.ts index cd50412b38c..366c75c3835 100644 --- a/src/main/rate-limits/grok-fetcher.ts +++ b/src/main/rate-limits/grok-fetcher.ts @@ -10,7 +10,6 @@ import { type GrokAuthReadResult, type GrokAuthSession } from './grok-auth' -import { readFetchResponseJsonWithinLimit } from '../lib/fetch-response-body' // Why: billing URL and headers must match Grok CLI or xAI rejects the request. const GROK_CLI_PROXY_BASE = @@ -212,7 +211,7 @@ async function fetchBillingData( result: result('error', `Grok usage request failed (HTTP ${res.status})`) } } - const data = await readFetchResponseJsonWithinLimit(res) + const data: unknown = await res.json() return { kind: 'data', data: typeof data === 'object' && data !== null ? (data as GrokBillingResponse) : {} diff --git a/src/main/rate-limits/hidden-pty-cleanup.test.ts b/src/main/rate-limits/hidden-pty-cleanup.test.ts index dc6e9a75e6b..43e8453fa0f 100644 --- a/src/main/rate-limits/hidden-pty-cleanup.test.ts +++ b/src/main/rate-limits/hidden-pty-cleanup.test.ts @@ -2,8 +2,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanupHiddenRateLimitPty, getActiveHiddenRateLimitPtyCount, - HiddenRateLimitPtyCapacityError, - MAX_ACTIVE_HIDDEN_RATE_LIMIT_PTYS, registerHiddenRateLimitPty } from './hidden-pty-cleanup' @@ -134,29 +132,4 @@ describe('cleanupHiddenRateLimitPty', () => { expect(getActiveHiddenRateLimitPtyCount()).toBe(0) }) - - it('caps active hidden PTYs, tears down overflow, and recovers after disposal', () => { - setPlatform('darwin') - const registrations = Array.from({ length: MAX_ACTIVE_HIDDEN_RATE_LIMIT_PTYS }, () => - registerHiddenRateLimitPty({ kill: vi.fn(), destroy: vi.fn() }) - ) - expect(getActiveHiddenRateLimitPtyCount()).toBe(MAX_ACTIVE_HIDDEN_RATE_LIMIT_PTYS) - - const overflowKill = vi.fn() - const overflow = { kill: overflowKill, destroy: vi.fn() } - expect(() => registerHiddenRateLimitPty(overflow)).toThrow(HiddenRateLimitPtyCapacityError) - expect(overflowKill).toHaveBeenCalledOnce() - expect(overflow.destroy).toHaveBeenCalledOnce() - expect(getActiveHiddenRateLimitPtyCount()).toBe(MAX_ACTIVE_HIDDEN_RATE_LIMIT_PTYS) - - registrations[0]!.dispose() - const recovered = registerHiddenRateLimitPty({ kill: vi.fn(), destroy: vi.fn() }) - expect(getActiveHiddenRateLimitPtyCount()).toBe(MAX_ACTIVE_HIDDEN_RATE_LIMIT_PTYS) - - for (const registration of registrations.slice(1)) { - registration.dispose() - } - recovered.dispose() - expect(getActiveHiddenRateLimitPtyCount()).toBe(0) - }) }) diff --git a/src/main/rate-limits/hidden-pty-cleanup.ts b/src/main/rate-limits/hidden-pty-cleanup.ts index 0b95f85b83f..068b937ee84 100644 --- a/src/main/rate-limits/hidden-pty-cleanup.ts +++ b/src/main/rate-limits/hidden-pty-cleanup.ts @@ -7,24 +7,9 @@ type Disposable = { dispose: () => void } -export const MAX_ACTIVE_HIDDEN_RATE_LIMIT_PTYS = 16 const activeHiddenRateLimitPtys = new Set() -export class HiddenRateLimitPtyCapacityError extends Error { - constructor() { - super(`Hidden rate-limit PTY capacity exceeds ${MAX_ACTIVE_HIDDEN_RATE_LIMIT_PTYS}`) - this.name = 'HiddenRateLimitPtyCapacityError' - } -} - export function registerHiddenRateLimitPty(term: HiddenPty): Disposable { - if ( - !activeHiddenRateLimitPtys.has(term) && - activeHiddenRateLimitPtys.size >= MAX_ACTIVE_HIDDEN_RATE_LIMIT_PTYS - ) { - cleanupHiddenRateLimitPty(term, [], { kill: true }) - throw new HiddenRateLimitPtyCapacityError() - } activeHiddenRateLimitPtys.add(term) return { dispose: () => { diff --git a/src/main/rate-limits/kimi-fetcher.test.ts b/src/main/rate-limits/kimi-fetcher.test.ts index 2ef7a137996..58f11dfdb03 100644 --- a/src/main/rate-limits/kimi-fetcher.test.ts +++ b/src/main/rate-limits/kimi-fetcher.test.ts @@ -12,12 +12,7 @@ vi.mock('electron', () => ({ vi.mock('node:fs', () => ({ existsSync: () => fsState.credentials !== null, - writeFileSync: () => {}, - renameSync: () => {} -})) - -vi.mock('../integration-credential-file', () => ({ - readIntegrationCredentialFileSyncText: () => { + readFileSync: () => { if (fsState.readError) { throw fsState.readError } @@ -25,7 +20,9 @@ vi.mock('../integration-credential-file', () => ({ throw new Error('ENOENT') } return fsState.credentials - } + }, + writeFileSync: () => {}, + renameSync: () => {} })) vi.mock('node:os', () => ({ homedir: () => '/home/test' })) diff --git a/src/main/rate-limits/kimi-fetcher.ts b/src/main/rate-limits/kimi-fetcher.ts index 29321b69fc5..778b90b796c 100644 --- a/src/main/rate-limits/kimi-fetcher.ts +++ b/src/main/rate-limits/kimi-fetcher.ts @@ -1,4 +1,4 @@ -import { existsSync } from 'node:fs' +import { existsSync, readFileSync } from 'node:fs' import { homedir } from 'node:os' import { join } from 'node:path' import { net } from 'electron' @@ -7,8 +7,6 @@ import type { RateLimitWindow, UsageRateLimitMetadata } from '../../shared/rate-limit-types' -import { readFetchResponseJsonWithinLimit } from '../lib/fetch-response-body' -import { readIntegrationCredentialFileSyncText } from '../integration-credential-file' // Why: Kimi Code's managed coding plan exposes subscription usage at // `${base}/usages` (see packages/oauth/src/managed-usage.ts in the CLI bundle). @@ -60,7 +58,7 @@ function readCredentials(): CredentialsReadResult { return { status: 'missing' } } try { - const parsed: unknown = JSON.parse(readIntegrationCredentialFileSyncText(path)) + const parsed: unknown = JSON.parse(readFileSync(path, 'utf-8')) const credentials = parseCredentials(parsed) return credentials ? { status: 'ok', credentials } @@ -277,7 +275,7 @@ export async function fetchKimiRateLimits(): Promise { if (!res.ok) { return result('error', `Kimi usage request failed (HTTP ${res.status})`) } - const data = await readFetchResponseJsonWithinLimit(res) + const data: unknown = await res.json() return mapUsageResponse(typeof data === 'object' && data !== null ? data : {}) } catch (err) { return result('error', err instanceof Error ? err.message : 'Kimi usage request failed') diff --git a/src/main/rate-limits/minimax-fetcher.ts b/src/main/rate-limits/minimax-fetcher.ts index 3d26b799cf9..20348feee72 100644 --- a/src/main/rate-limits/minimax-fetcher.ts +++ b/src/main/rate-limits/minimax-fetcher.ts @@ -1,5 +1,4 @@ import type { ProviderRateLimits, RateLimitWindow } from '../../shared/rate-limit-types' -import { readFetchResponseJsonWithinLimit } from '../lib/fetch-response-body' import { extractMiniMaxCookieValue, fetchMiniMaxWithManualCookieHeader, @@ -245,7 +244,7 @@ export async function fetchMiniMaxRateLimits( } let payload: MiniMaxUsageResponse try { - payload = await readFetchResponseJsonWithinLimit(fetchResult.response) + payload = (await fetchResult.response.json()) as MiniMaxUsageResponse } catch (error) { const message = error instanceof Error ? error.message : 'Invalid MiniMax usage response' return makeError(redactMiniMaxSecret(message), 'parse') diff --git a/src/main/rate-limits/minimax-request-context.test.ts b/src/main/rate-limits/minimax-request-context.test.ts index 45ff8ff88e1..9b01b4ff013 100644 --- a/src/main/rate-limits/minimax-request-context.test.ts +++ b/src/main/rate-limits/minimax-request-context.test.ts @@ -27,7 +27,6 @@ import { getUniqueMiniMaxCookieNames, logMiniMaxFetchFailure, makeMiniMaxRequestHeaders, - MINIMAX_COOKIE_WRITE_CONCURRENCY, MINIMAX_USAGE_ENDPOINT, normalizeMiniMaxCookieHeader, redactMiniMaxSecret @@ -165,7 +164,7 @@ describe('makeMiniMaxRequestHeaders', () => { describe('fetchMiniMaxWithSessionCookieJar', () => { beforeEach(() => { clearStorageDataMock.mockClear() - cookiesSetMock.mockReset().mockResolvedValue(undefined) + cookiesSetMock.mockClear() netFetchMock.mockReset() sessionFromPartitionMock.mockClear() sessionFromPartitionMock.mockImplementation(() => ({ @@ -277,42 +276,6 @@ describe('fetchMiniMaxWithSessionCookieJar', () => { ) }) - it.each([ - ['at the limit', MINIMAX_COOKIE_WRITE_CONCURRENCY], - ['above the limit', MINIMAX_COOKIE_WRITE_CONCURRENCY + 1] - ])('bounds session cookie writes %s', async (_, count) => { - netFetchMock.mockResolvedValueOnce({ ok: true, status: 200 }) - let active = 0 - let peak = 0 - const releases: (() => void)[] = [] - cookiesSetMock.mockImplementation(async () => { - active++ - peak = Math.max(peak, active) - await new Promise((resolve) => releases.push(resolve)) - active-- - }) - - const fetchResult = fetchMiniMaxWithSessionCookieJar({ - cookie: Array.from({ length: count }, (_, index) => `cookie_${index}=value`).join('; '), - endpoint: MINIMAX_USAGE_ENDPOINT, - groupId: null, - signal: new AbortController().signal - }) - await vi.waitFor(() => - expect(cookiesSetMock).toHaveBeenCalledTimes( - Math.min(count, MINIMAX_COOKIE_WRITE_CONCURRENCY) - ) - ) - if (count > MINIMAX_COOKIE_WRITE_CONCURRENCY) { - releases.shift()?.() - await vi.waitFor(() => expect(cookiesSetMock).toHaveBeenCalledTimes(count)) - } - releases.splice(0).forEach((release) => release()) - - await fetchResult - expect(peak).toBe(Math.min(count, MINIMAX_COOKIE_WRITE_CONCURRENCY)) - }) - it('reports the transport name as session-cookie-jar on success', async () => { netFetchMock.mockResolvedValueOnce({ ok: true, @@ -340,7 +303,7 @@ describe('fetchMiniMaxWithSessionCookieJar', () => { describe('fetchMiniMaxWithManualCookieHeader', () => { beforeEach(() => { clearStorageDataMock.mockClear() - cookiesSetMock.mockReset().mockResolvedValue(undefined) + cookiesSetMock.mockClear() netFetchMock.mockReset() sessionFromPartitionMock.mockClear() sessionFromPartitionMock.mockImplementation(() => ({ diff --git a/src/main/rate-limits/minimax-request-context.ts b/src/main/rate-limits/minimax-request-context.ts index 47fce7018ce..34dcd47130b 100644 --- a/src/main/rate-limits/minimax-request-context.ts +++ b/src/main/rate-limits/minimax-request-context.ts @@ -1,5 +1,4 @@ import { session, type Session } from 'electron' -import { mapSettledWithConcurrency } from '../../shared/map-with-concurrency' export const MINIMAX_USAGE_ENDPOINT = 'https://platform.minimax.io/v1/api/openplatform/coding_plan/remains' @@ -7,7 +6,6 @@ export const MINIMAX_USAGE_ENDPOINT = const MINIMAX_ORIGIN = 'https://platform.minimax.io' const MINIMAX_REFERER = 'https://platform.minimax.io/console/usage' const MINIMAX_SESSION_PARTITION = 'orca-minimax-rate-limit-fetch' -export const MINIMAX_COOKIE_WRITE_CONCURRENCY = 8 const SENSITIVE_COOKIE_NAMES = new Set([ '_token', '_twpid', @@ -124,10 +122,8 @@ export async function fetchMiniMaxWithSessionCookieJar(args: { const cookiePairs = parseCookiePairs(args.cookie) try { await clearMiniMaxSessionCookieJarForSession(miniMaxSession) - const writes = await mapSettledWithConcurrency( - cookiePairs, - MINIMAX_COOKIE_WRITE_CONCURRENCY, - (pair) => + await Promise.all( + cookiePairs.map((pair) => miniMaxSession.cookies.set({ url: MINIMAX_ORIGIN, name: pair.name, @@ -135,13 +131,8 @@ export async function fetchMiniMaxWithSessionCookieJar(args: { secure: true, path: '/' }) + ) ) - const failedWrite = writes.find( - (result): result is PromiseRejectedResult => result.status === 'rejected' - ) - if (failedWrite) { - throw failedWrite.reason - } const headers = makeMiniMaxRequestHeaders(args.groupId) return { response: await miniMaxSession.fetch(args.endpoint, { diff --git a/src/main/rate-limits/opencode-go-usage-fetcher.ts b/src/main/rate-limits/opencode-go-usage-fetcher.ts index b0713507dbd..934d1e72b42 100644 --- a/src/main/rate-limits/opencode-go-usage-fetcher.ts +++ b/src/main/rate-limits/opencode-go-usage-fetcher.ts @@ -8,7 +8,6 @@ import { OPENCODE_BASE_URL } from './opencode-go-request-session' import { parseSubscriptionFromPageText } from './opencode-go-page-scraper' -import { readFetchResponseTextWithinLimit } from '../lib/fetch-response-body' const OPENCODE_SERVER_URL = 'https://opencode.ai/_server' const API_TIMEOUT_MS = 15_000 @@ -202,7 +201,7 @@ async function fetchOpenCodeGoRateLimitsWithSession( } } - const workspacesText = await readFetchResponseTextWithinLimit(workspacesRes) + const workspacesText = await workspacesRes.text() ids = parseWorkspaceIds(workspacesText) } catch (err) { const message = err instanceof Error ? err.message : 'Unknown error' @@ -252,7 +251,7 @@ async function fetchOpenCodeGoRateLimitsWithSession( continue } - const pageText = await readFetchResponseTextWithinLimit(pageRes) + const pageText = await pageRes.text() const parsed = parseSubscriptionFromPageText(pageText) if (parsed) { const monthly = diff --git a/src/main/rate-limits/rate-limit-pty-output-tail.test.ts b/src/main/rate-limits/rate-limit-pty-output-tail.test.ts deleted file mode 100644 index 672c890b493..00000000000 --- a/src/main/rate-limits/rate-limit-pty-output-tail.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { appendRateLimitPtyOutputTail } from './rate-limit-pty-output-tail' - -describe('appendRateLimitPtyOutputTail', () => { - it('preserves ordinary output exactly', () => { - const first = appendRateLimitPtyOutputTail('', 'first', 16) - const second = appendRateLimitPtyOutputTail(first.output, ' second', 16) - - expect(second).toEqual({ - output: 'first second', - scannedChunk: ' second' - }) - }) - - it('keeps the exact newest characters across chunks', () => { - const first = appendRateLimitPtyOutputTail('', '123456', 8) - const second = appendRateLimitPtyOutputTail(first.output, '789tail', 8) - - expect(second.output).toBe('6789tail') - }) - - it('copies only a bounded suffix from one oversized PTY chunk', () => { - const result = appendRateLimitPtyOutputTail('old', `HEAD${'x'.repeat(1_000_000)}TAIL`, 16) - - expect(result.output).toHaveLength(16) - expect(result.output).toBe(result.scannedChunk) - expect(result.output).toBe(`${'x'.repeat(12)}TAIL`) - expect(result.output).not.toContain('HEAD') - }) - - it('preserves UTF-16 code units at the retained boundary', () => { - const result = appendRateLimitPtyOutputTail('', `${'x'.repeat(20)}😀`, 2) - - expect(result.output).toBe('😀') - }) -}) diff --git a/src/main/rate-limits/rate-limit-pty-output-tail.ts b/src/main/rate-limits/rate-limit-pty-output-tail.ts deleted file mode 100644 index 2e3a608aeb2..00000000000 --- a/src/main/rate-limits/rate-limit-pty-output-tail.ts +++ /dev/null @@ -1,33 +0,0 @@ -export type RateLimitPtyOutputAppend = { - output: string - scannedChunk: string -} - -export function appendRateLimitPtyOutputTail( - existing: string, - chunk: string, - maxChars: number -): RateLimitPtyOutputAppend { - if (!Number.isSafeInteger(maxChars) || maxChars < 0) { - throw new RangeError('Rate-limit PTY output limit must be a non-negative safe integer') - } - if (maxChars === 0) { - return { output: '', scannedChunk: '' } - } - - const scannedChunk = chunk.length > maxChars ? copyUtf16Suffix(chunk, maxChars) : chunk - if (scannedChunk.length >= maxChars) { - return { output: scannedChunk, scannedChunk } - } - const existingBudget = maxChars - scannedChunk.length - const existingTail = existing.length > existingBudget ? existing.slice(-existingBudget) : existing - return { - output: `${existingTail}${scannedChunk}`, - scannedChunk - } -} - -function copyUtf16Suffix(value: string, maxChars: number): string { - // Why: a sliced string may retain the oversized PTY chunk's backing store; this bounded round trip detaches it. - return Buffer.from(value.slice(-maxChars), 'utf16le').toString('utf16le') -} diff --git a/src/main/rate-limits/service.test.ts b/src/main/rate-limits/service.test.ts index 4a6539075a9..f9b113927f0 100644 --- a/src/main/rate-limits/service.test.ts +++ b/src/main/rate-limits/service.test.ts @@ -5,12 +5,7 @@ Keeping them in one file makes the ordering contract reviewable as a unit. */ import { beforeEach, describe, expect, it, vi } from 'vitest' import { EventEmitter } from 'node:events' import type { ProviderRateLimits } from '../../shared/rate-limit-types' -import { - MAX_ACTIVE_RATE_LIMIT_FETCH_CYCLES, - MAX_INACTIVE_RATE_LIMIT_ACCOUNTS, - RateLimitFetchCycleCapacityError, - RateLimitService -} from './service' +import { RateLimitService } from './service' import { fetchClaudeRateLimits, fetchManagedAccountUsage } from './claude-fetcher' import { fetchCodexRateLimits } from './codex-fetcher' import { fetchGeminiRateLimits } from './gemini-usage-fetcher' @@ -1225,60 +1220,6 @@ describe('RateLimitService', () => { expect(fetchCodexRateLimits).toHaveBeenCalledTimes(2) }) - it('shares one idle promise across a burst of queued refresh waiters and recovers', async () => { - const service = new RateLimitService() - const internals = service as unknown as { - fetchIdlePromise: Promise | null - isFetching: boolean - resolveFetchIdleWaiters: () => void - waitForFetchIdle: () => Promise - } - internals.isFetching = true - const shared = internals.waitForFetchIdle() - - for (let index = 0; index < 10_000; index += 1) { - expect(internals.waitForFetchIdle()).toBe(shared) - } - expect(internals.fetchIdlePromise).toBe(shared) - - internals.isFetching = false - internals.resolveFetchIdleWaiters() - await expect(shared).resolves.toBeUndefined() - expect(internals.fetchIdlePromise).toBeNull() - - internals.isFetching = true - const recovered = internals.waitForFetchIdle() - expect(recovered).not.toBe(shared) - internals.isFetching = false - internals.resolveFetchIdleWaiters() - await expect(recovered).resolves.toBeUndefined() - }) - - it('caps active fetch controllers and recovers when a cycle finishes', () => { - const service = new RateLimitService() - const internals = service as unknown as { - activeFetchAbortControllers: Set - beginFetchCycle: () => AbortController - finishFetchCycle: (controller: AbortController) => void - } - const controllers = Array.from({ length: MAX_ACTIVE_RATE_LIMIT_FETCH_CYCLES }, () => - internals.beginFetchCycle() - ) - - expect(internals.activeFetchAbortControllers.size).toBe(MAX_ACTIVE_RATE_LIMIT_FETCH_CYCLES) - expect(() => internals.beginFetchCycle()).toThrow(RateLimitFetchCycleCapacityError) - - internals.finishFetchCycle(controllers[0]!) - const recovered = internals.beginFetchCycle() - expect(internals.activeFetchAbortControllers.size).toBe(MAX_ACTIVE_RATE_LIMIT_FETCH_CYCLES) - - for (const controller of controllers.slice(1)) { - internals.finishFetchCycle(controller) - } - internals.finishFetchCycle(recovered) - expect(internals.activeFetchAbortControllers.size).toBe(0) - }) - it('publishes non-Grok provider results before a slow Grok fetch completes', async () => { const service = new RateLimitService() const grok = deferred() @@ -1806,24 +1747,6 @@ describe('RateLimitService', () => { await firstFetch }) - it('caps inactive Codex preview state at the account admission limit', async () => { - const service = new RateLimitService() - const accounts = Array.from({ length: MAX_INACTIVE_RATE_LIMIT_ACCOUNTS + 1 }, (_, index) => ({ - id: `account-${index}`, - managedHomePath: `/tmp/account-${index}/home` - })) - service.setInactiveCodexAccountsResolver(() => accounts) - vi.mocked(fetchCodexRateLimits).mockResolvedValue(okProvider('codex', 50, Date.now())) - - await service.fetchInactiveCodexAccountsOnOpen() - - expect(fetchCodexRateLimits).toHaveBeenCalledTimes(MAX_INACTIVE_RATE_LIMIT_ACCOUNTS) - expect(service.getState().inactiveCodexAccounts).toHaveLength(MAX_INACTIVE_RATE_LIMIT_ACCOUNTS) - expect(service.getState().inactiveCodexAccounts.at(-1)?.accountId).toBe( - `account-${MAX_INACTIVE_RATE_LIMIT_ACCOUNTS - 1}` - ) - }) - it('keeps sibling inactive Codex preview fetches alive when one account is evicted', async () => { const service = new RateLimitService() const accountFetch = deferred() diff --git a/src/main/rate-limits/service.ts b/src/main/rate-limits/service.ts index 40bea38156d..610e6542a68 100644 --- a/src/main/rate-limits/service.ts +++ b/src/main/rate-limits/service.ts @@ -91,16 +91,6 @@ const RATE_LIMITED_STALE_THRESHOLD_MS = 24 * 60 * 60 * 1000 const LIVE_CLAUDE_INGEST_DEDUPE_MS = 30 * 1000 const INACTIVE_FETCH_DEBOUNCE_MS = 60 * 1000 // 60 seconds — debounce fetch-on-open const DEFERRED_STARTUP_ACTIVE_REFRESH_MS = 1000 -export const MAX_ACTIVE_RATE_LIMIT_FETCH_CYCLES = 8 -export const MAX_INACTIVE_RATE_LIMIT_ACCOUNTS = 256 -export const MAX_RATE_LIMIT_ACCOUNT_ID_BYTES = 1024 - -export class RateLimitFetchCycleCapacityError extends Error { - constructor() { - super(`Active rate-limit fetch cycles exceed ${MAX_ACTIVE_RATE_LIMIT_FETCH_CYCLES}`) - this.name = 'RateLimitFetchCycleCapacityError' - } -} // Why: inactive account arrays are derived from provider caches on demand in getState()/pushToRenderer(). type InternalRateLimitState = { @@ -121,36 +111,6 @@ function normalizePollingInterval(ms: number): number { return Math.min(MAX_POLL_MS, Math.max(MIN_POLL_MS, ms)) } -function boundedInactiveAccounts(accounts: T[]): T[] { - const bounded: T[] = [] - const inspectedCount = Math.min(accounts.length, MAX_INACTIVE_RATE_LIMIT_ACCOUNTS) - for (let index = 0; index < inspectedCount; index += 1) { - const account = accounts[index] - if (account && Buffer.byteLength(account.id, 'utf8') <= MAX_RATE_LIMIT_ACCOUNT_ID_BYTES) { - bounded.push(account) - } - } - return bounded -} - -function hasBoundedInactiveAccount( - accounts: T[], - accountId: string -): boolean { - const inspectedCount = Math.min(accounts.length, MAX_INACTIVE_RATE_LIMIT_ACCOUNTS) - for (let index = 0; index < inspectedCount; index += 1) { - const account = accounts[index] - if ( - account && - Buffer.byteLength(account.id, 'utf8') <= MAX_RATE_LIMIT_ACCOUNT_ID_BYTES && - account.id === accountId - ) { - return true - } - } - return false -} - function isSystemDefaultClaudeAuth( authPreparation: ClaudeRuntimeAuthPreparation | undefined ): boolean { @@ -227,8 +187,7 @@ export class RateLimitService { private claudeOnlyFetchQueued = false private grokOnlyFetchQueued = false private activeFetchAbortControllers = new Set() - private fetchIdlePromise: Promise | null = null - private resolveFetchIdlePromise: (() => void) | null = null + private fetchIdleResolvers: (() => void)[] = [] private codexFetchGeneration = 0 private claudeFetchGeneration = 0 // Why: statusline ingest must attribute live windows to the selected account without re-running the side-effectful auth sync per post. @@ -535,7 +494,7 @@ export class RateLimitService { if (this.inactiveClaudeFetching.size > 0) { return } - const accounts = boundedInactiveAccounts(this.inactiveClaudeAccountsResolver?.() ?? []) + const accounts = this.inactiveClaudeAccountsResolver?.() ?? [] if (accounts.length === 0) { return } @@ -612,7 +571,7 @@ export class RateLimitService { if (this.inactiveCodexFetching.size > 0) { return } - const accounts = boundedInactiveAccounts(this.inactiveCodexAccountsResolver?.() ?? []) + const accounts = this.inactiveCodexAccountsResolver?.() ?? [] if (accounts.length === 0) { return } @@ -692,18 +651,20 @@ export class RateLimitService { } private isCurrentInactiveClaudeAccount(accountId: string): boolean { - return hasBoundedInactiveAccount(this.inactiveClaudeAccountsResolver?.() ?? [], accountId) + return (this.inactiveClaudeAccountsResolver?.() ?? []).some( + (account) => account.id === accountId + ) } private isCurrentInactiveCodexAccount(accountId: string): boolean { - return hasBoundedInactiveAccount(this.inactiveCodexAccountsResolver?.() ?? [], accountId) + return (this.inactiveCodexAccountsResolver?.() ?? []).some( + (account) => account.id === accountId + ) } private pruneInactiveClaudeState(): void { const currentIds = new Set( - boundedInactiveAccounts(this.inactiveClaudeAccountsResolver?.() ?? []).map( - (account) => account.id - ) + (this.inactiveClaudeAccountsResolver?.() ?? []).map((account) => account.id) ) for (const accountId of this.inactiveClaudeCache.keys()) { if (!currentIds.has(accountId)) { @@ -719,9 +680,7 @@ export class RateLimitService { private pruneInactiveCodexState(): void { const currentIds = new Set( - boundedInactiveAccounts(this.inactiveCodexAccountsResolver?.() ?? []).map( - (account) => account.id - ) + (this.inactiveCodexAccountsResolver?.() ?? []).map((account) => account.id) ) for (const accountId of this.inactiveCodexCache.keys()) { if (!currentIds.has(accountId)) { @@ -1163,10 +1122,9 @@ export class RateLimitService { return Promise.resolve() } // Why: explicit-refresh callers must await the queued follow-up cycle when a poll is in flight, else the UI stops spinning early. - this.fetchIdlePromise ??= new Promise((resolve) => { - this.resolveFetchIdlePromise = resolve + return new Promise((resolve) => { + this.fetchIdleResolvers.push(resolve) }) - return this.fetchIdlePromise } private resolveFetchIdleWaiters(): void { @@ -1179,16 +1137,14 @@ export class RateLimitService { ) { return } - const resolve = this.resolveFetchIdlePromise - this.fetchIdlePromise = null - this.resolveFetchIdlePromise = null - resolve?.() + const resolvers = this.fetchIdleResolvers + this.fetchIdleResolvers = [] + for (const resolve of resolvers) { + resolve() + } } private beginFetchCycle(): AbortController { - if (this.activeFetchAbortControllers.size >= MAX_ACTIVE_RATE_LIMIT_FETCH_CYCLES) { - throw new RateLimitFetchCycleCapacityError() - } const controller = new AbortController() this.activeFetchAbortControllers.add(controller) return controller @@ -1225,10 +1181,11 @@ export class RateLimitService { } private resolveAndClearFetchIdleWaiters(): void { - const resolve = this.resolveFetchIdlePromise - this.fetchIdlePromise = null - this.resolveFetchIdlePromise = null - resolve?.() + const resolvers = this.fetchIdleResolvers + this.fetchIdleResolvers = [] + for (const resolve of resolvers) { + resolve() + } } private isSameCodexTarget( diff --git a/src/main/remote-agent-trust-presets.test.ts b/src/main/remote-agent-trust-presets.test.ts index 92770a09086..226d689a537 100644 --- a/src/main/remote-agent-trust-presets.test.ts +++ b/src/main/remote-agent-trust-presets.test.ts @@ -14,7 +14,6 @@ vi.mock('./providers/ssh-filesystem-dispatch', () => ({ })) const { markRemoteAgentWorkspaceTrusted } = await import('./remote-agent-trust-presets') -const { MAX_AGENT_STATE_FILE_BYTES } = await import('./agent-state-file-reader') function makeFsProvider(overrides: Record = {}) { return { @@ -145,27 +144,6 @@ describe('markRemoteAgentWorkspaceTrusted', () => { }) }) - it('rejects oversized remote config without overwriting it', async () => { - const fsProvider = makeFsProvider({ - readFile: vi.fn(async () => ({ - content: 'x'.repeat(MAX_AGENT_STATE_FILE_BYTES + 1), - isBinary: false - })) - }) - mocks.getSshFilesystemProvider.mockReturnValue(fsProvider) - - await expect( - markRemoteAgentWorkspaceTrusted({ - preset: 'codex', - connectionId: 'ssh-1', - workspacePath: '/repo/worktree' - }) - ).rejects.toThrow('Remote agent state exceeds') - - expect(fsProvider.createDir).not.toHaveBeenCalled() - expect(fsProvider.writeFile).not.toHaveBeenCalled() - }) - it('does nothing when the SSH home cannot be resolved safely', async () => { const fsProvider = makeFsProvider() mocks.getActiveMultiplexer.mockReturnValue({ diff --git a/src/main/remote-agent-trust-presets.ts b/src/main/remote-agent-trust-presets.ts index d7ccf96a975..a88aaceae58 100644 --- a/src/main/remote-agent-trust-presets.ts +++ b/src/main/remote-agent-trust-presets.ts @@ -7,14 +7,6 @@ import { isWindowsAbsolutePathLike, normalizeRuntimePathSeparators } from '../shared/cross-platform-path' -import { MAX_AGENT_STATE_FILE_BYTES } from './agent-state-file-reader' - -class RemoteAgentStateFileTooLargeError extends Error { - constructor() { - super(`Remote agent state exceeds ${MAX_AGENT_STATE_FILE_BYTES} bytes`) - this.name = 'RemoteAgentStateFileTooLargeError' - } -} export async function markRemoteAgentWorkspaceTrusted(args: { preset: AgentTrustPreset @@ -77,27 +69,12 @@ async function readRemoteTextFile( ): Promise { try { const result = await fsProvider.readFile(filePath) - if (result.isBinary) { - return '' - } - if (Buffer.byteLength(result.content, 'utf8') > MAX_AGENT_STATE_FILE_BYTES) { - throw new RemoteAgentStateFileTooLargeError() - } - return result.content - } catch (error) { - if (error instanceof RemoteAgentStateFileTooLargeError) { - throw error - } + return result.isBinary ? '' : result.content + } catch { return '' } } -function assertRemoteAgentStateWithinLimit(content: string): void { - if (Buffer.byteLength(content, 'utf8') > MAX_AGENT_STATE_FILE_BYTES) { - throw new RemoteAgentStateFileTooLargeError() - } -} - async function markRemoteCodexProjectTrusted( fsProvider: IFilesystemProvider, remoteHome: string, @@ -114,7 +91,6 @@ async function markRemoteCodexProjectTrusted( if (updated === existing) { return } - assertRemoteAgentStateWithinLimit(updated) await fsProvider.createDir(codexDir) await fsProvider.writeFile(configPath, updated) } @@ -137,9 +113,10 @@ async function markRemoteCursorWorkspaceTrusted( // Missing marker: write the same shape the local trust preset writes. } await fsProvider.createDir(trustDir) - const content = `${JSON.stringify({ trustedAt: new Date().toISOString(), workspacePath }, null, 2)}\n` - assertRemoteAgentStateWithinLimit(content) - await fsProvider.writeFile(trustFile, content) + await fsProvider.writeFile( + trustFile, + `${JSON.stringify({ trustedAt: new Date().toISOString(), workspacePath }, null, 2)}\n` + ) } async function markRemoteCopilotFolderTrusted( @@ -166,8 +143,6 @@ async function markRemoteCopilotFolderTrusted( return } config.trustedFolders = [...existing.filter((entry) => typeof entry === 'string'), workspacePath] - const content = `${JSON.stringify(config, null, 2)}\n` - assertRemoteAgentStateWithinLimit(content) await fsProvider.createDir(configDir) - await fsProvider.writeFile(configPath, content) + await fsProvider.writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`) } diff --git a/src/main/repo-git-remote-identity-enrichment.test.ts b/src/main/repo-git-remote-identity-enrichment.test.ts index c30fff555f5..81a8d2dbd55 100644 --- a/src/main/repo-git-remote-identity-enrichment.test.ts +++ b/src/main/repo-git-remote-identity-enrichment.test.ts @@ -5,9 +5,6 @@ import { detectGitRemoteIdentity } from './repo-git-remote-identity' import { enrichMissingRepoGitRemoteIdentities, flushRepoGitRemoteIdentityEnrichmentForTests, - getRepoGitRemoteIdentityNegativeCacheSizeForTests, - REPO_LOCATION_CACHE_KEY_MAX_BYTES, - REPO_IDENTITY_NEGATIVE_CACHE_MAX_ENTRIES, resetRepoGitRemoteIdentityEnrichmentForTests } from './repo-git-remote-identity-enrichment' @@ -123,44 +120,6 @@ describe('enrichMissingRepoGitRemoteIdentities', () => { expect(detectGitRemoteIdentity).toHaveBeenCalledTimes(1) }) - it('bounds negative results across churned repo locations', async () => { - vi.useFakeTimers() - vi.setSystemTime(1_000) - vi.mocked(detectGitRemoteIdentity).mockResolvedValue(null) - const repo = makeRepo() - const store = makeStore(repo) - - for (let index = 0; index <= REPO_IDENTITY_NEGATIVE_CACHE_MAX_ENTRIES; index++) { - repo.path = `/workspace/repo-${index}` - enrichMissingRepoGitRemoteIdentities(store) - await flushRepoGitRemoteIdentityEnrichmentForTests() - } - - expect(getRepoGitRemoteIdentityNegativeCacheSizeForTests()).toBe( - REPO_IDENTITY_NEGATIVE_CACHE_MAX_ENTRIES - ) - repo.path = '/workspace/repo-0' - enrichMissingRepoGitRemoteIdentities(store) - await flushRepoGitRemoteIdentityEnrichmentForTests() - expect(detectGitRemoteIdentity).toHaveBeenCalledTimes( - REPO_IDENTITY_NEGATIVE_CACHE_MAX_ENTRIES + 2 - ) - }) - - it('does not retain oversized repo-location keys', async () => { - vi.mocked(detectGitRemoteIdentity).mockResolvedValue(null) - const repo = makeRepo({ path: `/${'x'.repeat(REPO_LOCATION_CACHE_KEY_MAX_BYTES)}` }) - const store = makeStore(repo) - - enrichMissingRepoGitRemoteIdentities(store) - await flushRepoGitRemoteIdentityEnrichmentForTests() - enrichMissingRepoGitRemoteIdentities(store) - await flushRepoGitRemoteIdentityEnrichmentForTests() - - expect(detectGitRemoteIdentity).toHaveBeenCalledTimes(2) - expect(getRepoGitRemoteIdentityNegativeCacheSizeForTests()).toBe(0) - }) - it('does not write stale identity data after the repo path changes', async () => { const probe = deferred() vi.mocked(detectGitRemoteIdentity).mockReturnValue(probe.promise) diff --git a/src/main/repo-git-remote-identity-enrichment.ts b/src/main/repo-git-remote-identity-enrichment.ts index 46952f8d0db..166b4d0bec8 100644 --- a/src/main/repo-git-remote-identity-enrichment.ts +++ b/src/main/repo-git-remote-identity-enrichment.ts @@ -1,11 +1,7 @@ import type { Repo } from '../shared/types' import { detectGitRemoteIdentity } from './repo-git-remote-identity' -import { getRepoLocationCacheKey } from './repo-location-cache-key' - -export { REPO_LOCATION_CACHE_KEY_MAX_BYTES } from './repo-location-cache-key' const NO_IDENTITY_RETRY_TTL_MS = 5 * 60 * 1000 -export const REPO_IDENTITY_NEGATIVE_CACHE_MAX_ENTRIES = 512 type RepoIdentityStore = { getRepos(): Repo[] @@ -20,25 +16,8 @@ type EnrichmentOptions = { const inFlightProbesByLocation = new Map>() const noIdentityRetryAfterByLocation = new Map() -function pruneNoIdentityRetryCache(now: number): void { - for (const [locationKey, retryAfter] of noIdentityRetryAfterByLocation) { - if (retryAfter <= now) { - noIdentityRetryAfterByLocation.delete(locationKey) - } - } - while (noIdentityRetryAfterByLocation.size > REPO_IDENTITY_NEGATIVE_CACHE_MAX_ENTRIES) { - const oldestLocation = noIdentityRetryAfterByLocation.keys().next().value - if (oldestLocation === undefined) { - break - } - noIdentityRetryAfterByLocation.delete(oldestLocation) - } -} - -function rememberNoIdentityRetry(locationKey: string, retryAfter: number): void { - noIdentityRetryAfterByLocation.delete(locationKey) - noIdentityRetryAfterByLocation.set(locationKey, retryAfter) - pruneNoIdentityRetryCache(Date.now()) +function getRepoLocationKey(repo: Pick): string { + return `${repo.connectionId ?? 'local'}\0${repo.path}` } function getCurrentRepo(store: RepoIdentityStore, id: string): Repo | undefined { @@ -56,14 +35,12 @@ function isSameUnenrichedRepo(snapshot: Repo, current: Repo | undefined): boolea } async function enrichRepoGitRemoteIdentity(store: RepoIdentityStore, repo: Repo): Promise { - const locationKey = getRepoLocationCacheKey(repo) - const now = Date.now() - pruneNoIdentityRetryCache(now) - const retryAfter = locationKey ? (noIdentityRetryAfterByLocation.get(locationKey) ?? 0) : 0 - if (retryAfter > now) { + const locationKey = getRepoLocationKey(repo) + const retryAfter = noIdentityRetryAfterByLocation.get(locationKey) ?? 0 + if (retryAfter > Date.now()) { return false } - const inFlight = locationKey ? inFlightProbesByLocation.get(locationKey) : undefined + const inFlight = inFlightProbesByLocation.get(locationKey) if (inFlight) { return inFlight } @@ -72,28 +49,22 @@ async function enrichRepoGitRemoteIdentity(store: RepoIdentityStore, repo: Repo) if (!identity) { // Why: repos without a parseable remote are common; cache misses briefly so // list calls stay cheap while still allowing recent remote changes to land. - if (locationKey) { - rememberNoIdentityRetry(locationKey, Date.now() + NO_IDENTITY_RETRY_TTL_MS) - } + noIdentityRetryAfterByLocation.set(locationKey, Date.now() + NO_IDENTITY_RETRY_TTL_MS) return false } - if (locationKey) { - noIdentityRetryAfterByLocation.delete(locationKey) - } + noIdentityRetryAfterByLocation.delete(locationKey) const current = getCurrentRepo(store, repo.id) if (!isSameUnenrichedRepo(repo, current)) { return false } return !!store.updateRepo(repo.id, { gitRemoteIdentity: identity }) })().finally(() => { - if (locationKey && inFlightProbesByLocation.get(locationKey) === probe) { + if (inFlightProbesByLocation.get(locationKey) === probe) { inFlightProbesByLocation.delete(locationKey) } }) - if (locationKey) { - inFlightProbesByLocation.set(locationKey, probe) - } + inFlightProbesByLocation.set(locationKey, probe) return probe } @@ -134,7 +105,3 @@ export function resetRepoGitRemoteIdentityEnrichmentForTests(): void { inFlightProbesByLocation.clear() noIdentityRetryAfterByLocation.clear() } - -export function getRepoGitRemoteIdentityNegativeCacheSizeForTests(): number { - return noIdentityRetryAfterByLocation.size -} diff --git a/src/main/repo-git-username-enrichment.test.ts b/src/main/repo-git-username-enrichment.test.ts index bc48ee2bb8c..bcecb8a2340 100644 --- a/src/main/repo-git-username-enrichment.test.ts +++ b/src/main/repo-git-username-enrichment.test.ts @@ -11,9 +11,6 @@ vi.mock('./git/git-username', () => ({ import { enrichRepoGitUsernames, flushRepoGitUsernameEnrichmentForTests, - getRepoGitUsernameAttemptCountForTests, - REPO_LOCATION_CACHE_KEY_MAX_BYTES, - REPO_GIT_USERNAME_ATTEMPT_MAX_ENTRIES, resetRepoGitUsernameEnrichmentForTests } from './repo-git-username-enrichment' @@ -85,37 +82,6 @@ describe('enrichRepoGitUsernames', () => { expect(resolveLocalGitUsernameDetailedMock).toHaveBeenCalledTimes(1) }) - it('bounds remembered attempts across churned repo locations', async () => { - const repos = Array.from({ length: REPO_GIT_USERNAME_ATTEMPT_MAX_ENTRIES + 1 }, (_, index) => - makeRepo({ id: `r${index}`, path: `C:/repos/${index}` }) - ) - const store = makeStore(repos) - - enrichRepoGitUsernames(store) - await flushRepoGitUsernameEnrichmentForTests() - - expect(getRepoGitUsernameAttemptCountForTests()).toBe(REPO_GIT_USERNAME_ATTEMPT_MAX_ENTRIES) - enrichRepoGitUsernames(store) - await flushRepoGitUsernameEnrichmentForTests() - expect(resolveLocalGitUsernameDetailedMock).toHaveBeenCalledTimes( - REPO_GIT_USERNAME_ATTEMPT_MAX_ENTRIES + 2 - ) - }) - - it('does not retain oversized repo-location keys', async () => { - const store = makeStore([ - makeRepo({ path: `C:/${'x'.repeat(REPO_LOCATION_CACHE_KEY_MAX_BYTES)}` }) - ]) - - enrichRepoGitUsernames(store) - await flushRepoGitUsernameEnrichmentForTests() - enrichRepoGitUsernames(store) - await flushRepoGitUsernameEnrichmentForTests() - - expect(resolveLocalGitUsernameDetailedMock).toHaveBeenCalledTimes(2) - expect(getRepoGitUsernameAttemptCountForTests()).toBe(0) - }) - it('keeps persisted usernames on a non-authoritative empty resolution', async () => { resolveLocalGitUsernameDetailedMock.mockResolvedValue(resolved('', false)) const store = makeStore([makeRepo()]) diff --git a/src/main/repo-git-username-enrichment.ts b/src/main/repo-git-username-enrichment.ts index 1054e6ae3b5..c5ae5c6d7b9 100644 --- a/src/main/repo-git-username-enrichment.ts +++ b/src/main/repo-git-username-enrichment.ts @@ -1,8 +1,5 @@ import type { Repo } from '../shared/types' import { resolveLocalGitUsernameDetailed } from './git/git-username' -import { getRepoLocationCacheKey } from './repo-location-cache-key' - -export { REPO_LOCATION_CACHE_KEY_MAX_BYTES } from './repo-location-cache-key' type RepoUsernameStore = { getRepos(): Repo[] @@ -13,7 +10,6 @@ type EnrichmentOptions = { onChanged?: () => void } -export const REPO_GIT_USERNAME_ATTEMPT_MAX_ENTRIES = 512 // Why: resolution spawns git (and possibly gh) subprocesses, so run it at most // once per repo location per app session — hydrateRepo serves the persisted // value in between, and a relaunch picks up config changes. @@ -21,16 +17,8 @@ const attemptedLocations = new Set() let enrichmentInFlight: Promise | null = null let rerunRequested = false -function rememberAttemptedLocation(locationKey: string): void { - attemptedLocations.delete(locationKey) - attemptedLocations.add(locationKey) - while (attemptedLocations.size > REPO_GIT_USERNAME_ATTEMPT_MAX_ENTRIES) { - const oldestLocation = attemptedLocations.values().next().value - if (oldestLocation === undefined) { - break - } - attemptedLocations.delete(oldestLocation) - } +function getRepoLocationKey(repo: Pick): string { + return `${repo.connectionId ?? 'local'}\0${repo.path}` } async function enrichRepoGitUsernamesInBackground( @@ -43,14 +31,11 @@ async function enrichRepoGitUsernamesInBackground( // Why: SSH repo paths are remote; local git cannot inspect them. The // SSH username path (getSshGitUsername) stays caller-driven. !repo.connectionId && - !attemptedLocations.has(getRepoLocationCacheKey(repo) ?? '') + !attemptedLocations.has(getRepoLocationKey(repo)) ) let changed = false for (const repo of candidates) { - const locationKey = getRepoLocationCacheKey(repo) - if (locationKey) { - rememberAttemptedLocation(locationKey) - } + attemptedLocations.add(getRepoLocationKey(repo)) const { username, authoritative } = await resolveLocalGitUsernameDetailed(repo.path) // Why: a non-authoritative '' means a probe timed out and says nothing // about the account — keep the persisted value. An authoritative result @@ -108,7 +93,3 @@ export function resetRepoGitUsernameEnrichmentForTests(): void { enrichmentInFlight = null rerunRequested = false } - -export function getRepoGitUsernameAttemptCountForTests(): number { - return attemptedLocations.size -} diff --git a/src/main/repo-icon-autodetect.test.ts b/src/main/repo-icon-autodetect.test.ts index 647ebf8359f..c491f344422 100644 --- a/src/main/repo-icon-autodetect.test.ts +++ b/src/main/repo-icon-autodetect.test.ts @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, rm, truncate, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' import { afterEach, describe, expect, it } from 'vitest' @@ -10,16 +10,6 @@ const PNG_1X1_BASE64 = const tempDirs: string[] = [] -function pngHeader(width: number, height: number): Buffer { - const bytes = Buffer.alloc(24) - Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(bytes) - bytes.writeUInt32BE(13, 8) - bytes.write('IHDR', 12, 'ascii') - bytes.writeUInt32BE(width, 16) - bytes.writeUInt32BE(height, 20) - return bytes -} - async function makeTempRepoDir(): Promise { const dir = await mkdtemp(join(tmpdir(), 'orca-repo-icon-')) tempDirs.push(dir) @@ -62,13 +52,6 @@ describe('detectRepoIcon', () => { }) }) - it('ignores a repo-local raster dimension bomb', async () => { - const repoPath = await makeTempRepoDir() - await writeFile(join(repoPath, 'favicon.png'), pngHeader(32_769, 1)) - - await expect(detectRepoIcon({ repoPath, kind: 'folder' })).resolves.toBeUndefined() - }) - it('resolves declared icon hrefs from project source files', async () => { const repoPath = await makeTempRepoDir() await writeFile(join(repoPath, 'index.html'), '') @@ -108,9 +91,10 @@ describe('detectRepoIcon', () => { it('skips oversized source files when looking for declared icon hrefs', async () => { const repoPath = await makeTempRepoDir() - const sourcePath = join(repoPath, 'index.html') - await writeFile(sourcePath, '') - await truncate(sourcePath, 256 * 1024 + 1) + await writeFile( + join(repoPath, 'index.html'), + `${'x'.repeat(256 * 1024 + 1)}` + ) await mkdir(join(repoPath, 'public', 'brand'), { recursive: true }) await writeFile( join(repoPath, 'public', 'brand', 'icon.png'), diff --git a/src/main/repo-icon-autodetect.ts b/src/main/repo-icon-autodetect.ts index 94fdc2bbd1b..233721a8086 100644 --- a/src/main/repo-icon-autodetect.ts +++ b/src/main/repo-icon-autodetect.ts @@ -1,13 +1,50 @@ -import { stat } from 'node:fs/promises' +import { readFile, stat } from 'node:fs/promises' import type { GitHubRepositoryIdentity, RepoKind } from '../shared/types' -import { faviconUrlFromWebsite, githubAvatarIcon, type RepoIcon } from '../shared/repo-icon' +import { + faviconUrlFromWebsite, + githubAvatarIcon, + MAX_REPO_ICON_UPLOAD_BYTES, + type RepoIcon +} from '../shared/repo-icon' import { getRepoSlug, getRepoUpstream } from './github/client' import { getSshFilesystemProvider } from './providers/ssh-filesystem-dispatch' import type { IFilesystemProvider } from './providers/types' import { detectGitRemoteIdentity } from './repo-git-remote-identity' +import { iconHrefCandidates } from './repo-icon-href-candidates' import { joinWorktreeRelativePath } from './runtime/runtime-relative-paths' -import { readNodeFileWithinLimit } from '../shared/node-bounded-file-reader' -import { detectLocalRepoPngIcon, detectRemoteRepoPngIcon } from './repo-icon-file-detection' + +const REPO_ICON_FILE_CANDIDATES = [ + 'favicon.png', + 'public/favicon.png', + 'app/favicon.png', + 'app/icon.png', + 'src/favicon.png', + 'src/app/icon.png', + 'assets/favicon.png', + 'assets/icon.png', + 'static/favicon.png', + 'logo.png', + 'public/logo.png' +] + +const REPO_ICON_SOURCE_FILE_CANDIDATES = [ + 'index.html', + 'public/index.html', + 'app/routes/__root.tsx', + 'src/routes/__root.tsx', + 'app/root.tsx', + 'src/root.tsx', + 'src/index.html' +] + +// Why: repo icon detection runs while adding repos; declared-icon probing should +// not read large app entrypoints just to find a small favicon href. +const MAX_REPO_ICON_SOURCE_BYTES = 256 * 1024 + +const LINK_ICON_HTML_RE = + /]*\brel=["'](?:icon|shortcut icon)["'])(?=[^>]*\bhref=["']([^"'?]+))[^>]*>/i +const LINK_ICON_OBJECT_RE = + /(?=[^}]*\brel\s*:\s*["'](?:icon|shortcut icon)["'])(?=[^}]*\bhref\s*:\s*["']([^"'?]+))[^}]*/i const WEBSITE_HOSTS_TO_SKIP = new Set([ 'github.com', @@ -17,6 +54,21 @@ const WEBSITE_HOSTS_TO_SKIP = new Set([ 'bitbucket.org', 'www.bitbucket.org' ]) + +function isPngBuffer(buffer: Buffer): boolean { + return ( + buffer.length >= 8 && + buffer[0] === 0x89 && + buffer[1] === 0x50 && + buffer[2] === 0x4e && + buffer[3] === 0x47 && + buffer[4] === 0x0d && + buffer[5] === 0x0a && + buffer[6] === 0x1a && + buffer[7] === 0x0a + ) +} + function shouldUseWebsiteFavicon(rawUrl: string): boolean { try { const url = new URL(rawUrl.includes('://') ? rawUrl : `https://${rawUrl}`) @@ -26,6 +78,140 @@ function shouldUseWebsiteFavicon(rawUrl: string): boolean { } } +function extractIconHref(source: string): string | null { + return source.match(LINK_ICON_HTML_RE)?.[1] ?? source.match(LINK_ICON_OBJECT_RE)?.[1] ?? null +} + +async function readLocalPngIcon(repoPath: string, relativePath: string): Promise { + const filePath = joinWorktreeRelativePath(repoPath, relativePath) + const info = await stat(filePath) + if (!info.isFile() || info.size > MAX_REPO_ICON_UPLOAD_BYTES) { + return null + } + const buffer = await readFile(filePath) + if (!isPngBuffer(buffer)) { + return null + } + return { + type: 'image', + src: `data:image/png;base64,${buffer.toString('base64')}`, + source: 'file', + label: relativePath + } +} + +async function readRemotePngIcon( + repoPath: string, + fsProvider: IFilesystemProvider, + relativePath: string +): Promise { + const filePath = joinWorktreeRelativePath(repoPath, relativePath) + const info = await fsProvider.stat(filePath) + if (info.type !== 'file' || info.size > MAX_REPO_ICON_UPLOAD_BYTES) { + return null + } + const result = await fsProvider.readFile(filePath) + if (!result.isBinary || result.mimeType !== 'image/png' || !result.content) { + return null + } + const buffer = Buffer.from(result.content, 'base64') + if (!isPngBuffer(buffer)) { + return null + } + return { + type: 'image', + src: `data:image/png;base64,${buffer.toString('base64')}`, + source: 'file', + label: relativePath + } +} + +async function detectLocalPngIcon(repoPath: string): Promise { + for (const relativePath of REPO_ICON_FILE_CANDIDATES) { + try { + const icon = await readLocalPngIcon(repoPath, relativePath) + if (icon) { + return icon + } + } catch { + // Try the next conventional icon path. + } + } + for (const sourceFile of REPO_ICON_SOURCE_FILE_CANDIDATES) { + try { + const sourcePath = joinWorktreeRelativePath(repoPath, sourceFile) + const sourceInfo = await stat(sourcePath) + if (!sourceInfo.isFile() || sourceInfo.size > MAX_REPO_ICON_SOURCE_BYTES) { + continue + } + const source = await readFile(sourcePath, 'utf8') + const href = extractIconHref(source) + if (!href) { + continue + } + for (const relativePath of iconHrefCandidates(href, sourceFile)) { + try { + const icon = await readLocalPngIcon(repoPath, relativePath) + if (icon) { + return icon + } + } catch { + // Try the next href resolution. + } + } + } catch { + // Try the next source file. + } + } + return null +} + +async function detectRemotePngIcon( + repoPath: string, + fsProvider: IFilesystemProvider +): Promise { + for (const relativePath of REPO_ICON_FILE_CANDIDATES) { + try { + const icon = await readRemotePngIcon(repoPath, fsProvider, relativePath) + if (icon) { + return icon + } + } catch { + // Try the next conventional icon path. + } + } + for (const sourceFile of REPO_ICON_SOURCE_FILE_CANDIDATES) { + try { + const sourcePath = joinWorktreeRelativePath(repoPath, sourceFile) + const sourceInfo = await fsProvider.stat(sourcePath) + if (sourceInfo.type !== 'file' || sourceInfo.size > MAX_REPO_ICON_SOURCE_BYTES) { + continue + } + const result = await fsProvider.readFile(sourcePath) + if (result.isBinary) { + continue + } + const href = extractIconHref(result.content) + if (!href) { + continue + } + for (const relativePath of iconHrefCandidates(href, sourceFile)) { + try { + const icon = await readRemotePngIcon(repoPath, fsProvider, relativePath) + if (icon) { + return icon + } + } catch { + // Try the next href resolution. + } + } + } catch { + // Try the next source file. + } + } + return null +} + function packageHomepageIcon(packageJson: unknown): RepoIcon | null { if (!packageJson || typeof packageJson !== 'object') { return null @@ -45,11 +231,7 @@ async function detectLocalPackageHomepageIcon(repoPath: string): Promise 128 * 1024) { return null } - const packageRead = await readNodeFileWithinLimit(packageJsonPath, 128 * 1024) - if (!packageRead.stats.isFile()) { - return null - } - return packageHomepageIcon(JSON.parse(packageRead.buffer.toString('utf8'))) + return packageHomepageIcon(JSON.parse(await readFile(packageJsonPath, 'utf8'))) } catch { return null } @@ -69,9 +251,6 @@ async function detectRemotePackageHomepageIcon( if (result.isBinary) { return null } - if (Buffer.byteLength(result.content, 'utf8') > 128 * 1024) { - return null - } return packageHomepageIcon(JSON.parse(result.content)) } catch { return null @@ -106,8 +285,8 @@ export async function detectRepoIcon({ try { const fsProvider = connectionId ? getSshFilesystemProvider(connectionId) : undefined const fileIcon = fsProvider - ? await detectRemoteRepoPngIcon(repoPath, fsProvider) - : await detectLocalRepoPngIcon(repoPath) + ? await detectRemotePngIcon(repoPath, fsProvider) + : await detectLocalPngIcon(repoPath) if (fileIcon) { return fileIcon } diff --git a/src/main/repo-icon-file-detection.ts b/src/main/repo-icon-file-detection.ts deleted file mode 100644 index 6e97e19c9b5..00000000000 --- a/src/main/repo-icon-file-detection.ts +++ /dev/null @@ -1,190 +0,0 @@ -import { stat } from 'node:fs/promises' -import { MAX_REPO_ICON_UPLOAD_BYTES, type RepoIcon } from '../shared/repo-icon' -import { assertRasterImagePreviewWithinLimits } from '../shared/raster-image-preview-limits' -import { readNodeFileWithinLimit } from '../shared/node-bounded-file-reader' -import type { IFilesystemProvider } from './providers/types' -import { iconHrefCandidates } from './repo-icon-href-candidates' -import { joinWorktreeRelativePath } from './runtime/runtime-relative-paths' - -const REPO_ICON_FILE_CANDIDATES = [ - 'favicon.png', - 'public/favicon.png', - 'app/favicon.png', - 'app/icon.png', - 'src/favicon.png', - 'src/app/icon.png', - 'assets/favicon.png', - 'assets/icon.png', - 'static/favicon.png', - 'logo.png', - 'public/logo.png' -] - -const REPO_ICON_SOURCE_FILE_CANDIDATES = [ - 'index.html', - 'public/index.html', - 'app/routes/__root.tsx', - 'src/routes/__root.tsx', - 'app/root.tsx', - 'src/root.tsx', - 'src/index.html' -] - -// Why: repo icon detection runs while adding repos; declared-icon probing should -// not read large app entrypoints just to find a small favicon href. -const MAX_REPO_ICON_SOURCE_BYTES = 256 * 1024 - -const LINK_ICON_HTML_RE = - /]*\brel=["'](?:icon|shortcut icon)["'])(?=[^>]*\bhref=["']([^"'?]+))[^>]*>/i -const LINK_ICON_OBJECT_RE = - /(?=[^}]*\brel\s*:\s*["'](?:icon|shortcut icon)["'])(?=[^}]*\bhref\s*:\s*["']([^"'?]+))[^}]*/i -const PNG_SIGNATURE = Buffer.from('89504e470d0a1a0a', 'hex') - -function isPngBuffer(buffer: Buffer): boolean { - return buffer.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE) -} - -function extractIconHref(source: string): string | null { - return source.match(LINK_ICON_HTML_RE)?.[1] ?? source.match(LINK_ICON_OBJECT_RE)?.[1] ?? null -} - -async function readLocalPngIcon(repoPath: string, relativePath: string): Promise { - const filePath = joinWorktreeRelativePath(repoPath, relativePath) - const info = await stat(filePath) - if (!info.isFile() || info.size > MAX_REPO_ICON_UPLOAD_BYTES) { - return null - } - const { buffer, stats } = await readNodeFileWithinLimit(filePath, MAX_REPO_ICON_UPLOAD_BYTES) - if (!stats.isFile() || !isPngBuffer(buffer)) { - return null - } - assertRasterImagePreviewWithinLimits(buffer, 'image/png') - return { - type: 'image', - src: `data:image/png;base64,${buffer.toString('base64')}`, - source: 'file', - label: relativePath - } -} - -async function readRemotePngIcon( - repoPath: string, - fsProvider: IFilesystemProvider, - relativePath: string -): Promise { - const filePath = joinWorktreeRelativePath(repoPath, relativePath) - const info = await fsProvider.stat(filePath) - if (info.type !== 'file' || info.size > MAX_REPO_ICON_UPLOAD_BYTES) { - return null - } - const result = await fsProvider.readFile(filePath) - if (!result.isBinary || result.mimeType !== 'image/png' || !result.content) { - return null - } - if (result.content.length > Math.ceil((MAX_REPO_ICON_UPLOAD_BYTES * 4) / 3) + 4) { - return null - } - const buffer = Buffer.from(result.content, 'base64') - if (buffer.byteLength > MAX_REPO_ICON_UPLOAD_BYTES || !isPngBuffer(buffer)) { - return null - } - assertRasterImagePreviewWithinLimits(buffer, 'image/png') - return { - type: 'image', - src: `data:image/png;base64,${buffer.toString('base64')}`, - source: 'file', - label: relativePath - } -} - -export async function detectLocalRepoPngIcon(repoPath: string): Promise { - for (const relativePath of REPO_ICON_FILE_CANDIDATES) { - try { - const icon = await readLocalPngIcon(repoPath, relativePath) - if (icon) { - return icon - } - } catch { - // Try the next conventional icon path. - } - } - for (const sourceFile of REPO_ICON_SOURCE_FILE_CANDIDATES) { - try { - const sourcePath = joinWorktreeRelativePath(repoPath, sourceFile) - const sourceInfo = await stat(sourcePath) - if (!sourceInfo.isFile() || sourceInfo.size > MAX_REPO_ICON_SOURCE_BYTES) { - continue - } - const sourceRead = await readNodeFileWithinLimit(sourcePath, MAX_REPO_ICON_SOURCE_BYTES) - if (!sourceRead.stats.isFile()) { - continue - } - const href = extractIconHref(sourceRead.buffer.toString('utf8')) - if (!href) { - continue - } - for (const relativePath of iconHrefCandidates(href, sourceFile)) { - try { - const icon = await readLocalPngIcon(repoPath, relativePath) - if (icon) { - return icon - } - } catch { - // Try the next href resolution. - } - } - } catch { - // Try the next source file. - } - } - return null -} - -export async function detectRemoteRepoPngIcon( - repoPath: string, - fsProvider: IFilesystemProvider -): Promise { - for (const relativePath of REPO_ICON_FILE_CANDIDATES) { - try { - const icon = await readRemotePngIcon(repoPath, fsProvider, relativePath) - if (icon) { - return icon - } - } catch { - // Try the next conventional icon path. - } - } - for (const sourceFile of REPO_ICON_SOURCE_FILE_CANDIDATES) { - try { - const sourcePath = joinWorktreeRelativePath(repoPath, sourceFile) - const sourceInfo = await fsProvider.stat(sourcePath) - if (sourceInfo.type !== 'file' || sourceInfo.size > MAX_REPO_ICON_SOURCE_BYTES) { - continue - } - const result = await fsProvider.readFile(sourcePath) - if ( - result.isBinary || - Buffer.byteLength(result.content, 'utf8') > MAX_REPO_ICON_SOURCE_BYTES - ) { - continue - } - const href = extractIconHref(result.content) - if (!href) { - continue - } - for (const relativePath of iconHrefCandidates(href, sourceFile)) { - try { - const icon = await readRemotePngIcon(repoPath, fsProvider, relativePath) - if (icon) { - return icon - } - } catch { - // Try the next href resolution. - } - } - } catch { - // Try the next source file. - } - } - return null -} diff --git a/src/main/repo-location-cache-key.ts b/src/main/repo-location-cache-key.ts deleted file mode 100644 index 5efea35198a..00000000000 --- a/src/main/repo-location-cache-key.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { measureUtf8ByteLength } from '../shared/utf8-byte-limits' - -export const REPO_LOCATION_CACHE_KEY_MAX_BYTES = 4 * 1024 - -export function getRepoLocationCacheKey(repo: { - path: string - connectionId?: string | null -}): string | null { - const connectionId = repo.connectionId ?? 'local' - const connectionBytes = measureUtf8ByteLength(connectionId, { - stopAfterBytes: REPO_LOCATION_CACHE_KEY_MAX_BYTES - 1 - }) - if (connectionBytes.exceededLimit) { - return null - } - const pathBytes = measureUtf8ByteLength(repo.path, { - stopAfterBytes: REPO_LOCATION_CACHE_KEY_MAX_BYTES - connectionBytes.byteLength - 1 - }) - if (pathBytes.exceededLimit) { - return null - } - return `${connectionId}\0${repo.path}` -} diff --git a/src/main/runtime/agent-session-claim-identity.test.ts b/src/main/runtime/agent-session-claim-identity.test.ts index c5407ca303a..39392ea9e72 100644 --- a/src/main/runtime/agent-session-claim-identity.test.ts +++ b/src/main/runtime/agent-session-claim-identity.test.ts @@ -1,11 +1,7 @@ -import { closeSync, ftruncateSync, mkdtempSync, openSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' import { describe, expect, it } from 'vitest' import { canonicalizeAgentSessionIdentity, - createEphemeralAgentSessionClaimSigner, - loadAgentSessionClaimSigner + createEphemeralAgentSessionClaimSigner } from './agent-session-claim-identity' describe('agent session claim identity', () => { @@ -44,17 +40,4 @@ describe('agent session claim identity', () => { canonicalizeAgentSessionIdentity('blank', { key: 'session_id', id: 'session-1' }) ).toThrow('agent_session_identity_required') }) - - it('rejects an oversized persisted coordination key before reading it', () => { - const directory = mkdtempSync(join(tmpdir(), 'orca-claim-key-')) - try { - const descriptor = openSync(join(directory, 'agent-session-authority.key'), 'w') - ftruncateSync(descriptor, 1024 * 1024 * 1024) - closeSync(descriptor) - - expect(() => loadAgentSessionClaimSigner(directory, 'profile-1')).toThrow() - } finally { - rmSync(directory, { recursive: true, force: true }) - } - }) }) diff --git a/src/main/runtime/agent-session-claim-identity.ts b/src/main/runtime/agent-session-claim-identity.ts index 0d302723e68..2bc276f87e5 100644 --- a/src/main/runtime/agent-session-claim-identity.ts +++ b/src/main/runtime/agent-session-claim-identity.ts @@ -1,5 +1,13 @@ import { createHash, createHmac, randomBytes } from 'node:crypto' -import { closeSync, mkdirSync, openSync, realpathSync, statSync, writeFileSync } from 'node:fs' +import { + closeSync, + mkdirSync, + openSync, + readFileSync, + realpathSync, + statSync, + writeFileSync +} from 'node:fs' import { dirname, isAbsolute, join, normalize } from 'node:path' import { AGENT_SESSION_CLAIM_DIGEST_VERSION, @@ -12,7 +20,6 @@ import { type AgentProviderSessionMetadata, type ResumableTuiAgent } from '../../shared/agent-session-resume' -import { readNodeFileSyncWithinLimit } from '../../shared/node-bounded-file-reader' const COORDINATION_KEY_BYTES = 32 const COORDINATION_KEY_FILE = 'agent-session-authority.key' @@ -139,7 +146,7 @@ export function loadAgentSessionClaimSigner( mkdirSync(dirname(keyPath), { recursive: true }) let key: Buffer try { - key = readNodeFileSyncWithinLimit(keyPath, COORDINATION_KEY_BYTES).buffer + key = readFileSync(keyPath) } catch { const candidate = randomBytes(COORDINATION_KEY_BYTES) let fd: number | null = null @@ -148,7 +155,7 @@ export function loadAgentSessionClaimSigner( writeFileSync(fd, candidate) key = candidate } catch { - key = readNodeFileSyncWithinLimit(keyPath, COORDINATION_KEY_BYTES).buffer + key = readFileSync(keyPath) } finally { if (fd !== null) { closeSync(fd) diff --git a/src/main/runtime/claude-agent-teams-shim-env.test.ts b/src/main/runtime/claude-agent-teams-shim-env.test.ts index 1c3e43cb2ba..f79c9d150cf 100644 --- a/src/main/runtime/claude-agent-teams-shim-env.test.ts +++ b/src/main/runtime/claude-agent-teams-shim-env.test.ts @@ -1,4 +1,4 @@ -import { chmod, mkdtemp, readFile, rm, stat, truncate, writeFile } from 'node:fs/promises' +import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' @@ -25,19 +25,6 @@ describe('claude agent teams shim env', () => { await expect(readFile(join(root, 'tmux'), 'utf8')).resolves.toContain('agent-teams-tmux "$@"') }) - it('replaces a large sparse shim without reading its payload', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-agent-teams-large-shim-')) - roots.push(root) - await ensureClaudeAgentTeamsShimDir(root) - const shimPath = join(root, 'tmux') - await truncate(shimPath, 256 * 1024 * 1024) - - await ensureClaudeAgentTeamsShimDir(root) - - expect((await stat(shimPath)).size).toBeLessThan(64 * 1024) - await expect(readFile(shimPath, 'utf8')).resolves.toContain('agent-teams-tmux "$@"') - }) - it('builds native shim env only for direct Claude commands', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-agent-teams-cli-')) roots.push(root) diff --git a/src/main/runtime/claude-agent-teams-shim-env.ts b/src/main/runtime/claude-agent-teams-shim-env.ts index 639aa707376..6b6eeea94ba 100644 --- a/src/main/runtime/claude-agent-teams-shim-env.ts +++ b/src/main/runtime/claude-agent-teams-shim-env.ts @@ -1,4 +1,4 @@ -import { chmod, mkdir, rename, rm, writeFile } from 'node:fs/promises' +import { chmod, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises' import { accessSync, constants, existsSync } from 'node:fs' import { homedir } from 'node:os' import { delimiter, dirname, join } from 'node:path' @@ -8,7 +8,6 @@ import { isDirectClaudeCommand, type ClaudeAgentTeamsMode } from '../../shared/claude-agent-teams-tmux-compat' -import { nodeFileContentsEqual } from '../../shared/node-file-content-equality' import { getOrcaCliCommandNameForPlatform } from '../../shared/orca-cli-command-name' export type ClaudeAgentTeamsLaunchPlan = { @@ -137,7 +136,7 @@ function windowsShimScript(): string { async function writeIfChanged(path: string, content: string): Promise { try { - if (await nodeFileContentsEqual(path, content)) { + if ((await readFile(path, 'utf8')) === content) { return } } catch { diff --git a/src/main/runtime/client-session-tab-selection-bounds.test.ts b/src/main/runtime/client-session-tab-selection-bounds.test.ts deleted file mode 100644 index 4f4285684c0..00000000000 --- a/src/main/runtime/client-session-tab-selection-bounds.test.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { RuntimeMobileSessionTabsResult } from '../../shared/runtime-types' -import type { PersistedMobileClientTabSelection } from '../../shared/types' -import { ClientSessionTabSelectionStore } from './client-session-tab-selection' -import { - isMobileTabSelectionIdRetainable, - MOBILE_TAB_SELECTION_ID_MAX_BYTES, - MOBILE_TAB_SELECTION_MAX_BYTES_PER_CLIENT, - MOBILE_TAB_SELECTION_MAX_CLIENTS, - MOBILE_TAB_SELECTION_MAX_GROUPS_PER_WORKTREE, - MOBILE_TAB_SELECTION_MAX_WORKTREES_PER_CLIENT, - mobileTabSelectionRetainedBytes, - normalizePersistedMobileClientTabSelections -} from './client-session-tab-selection-persistence' - -function selection( - activeTabIdByGroupId: Readonly> = {} -): PersistedMobileClientTabSelection { - return { activeTabId: 'browser-1', activeGroupId: 'group-1', activeTabIdByGroupId } -} - -function snapshot(worktree: string): RuntimeMobileSessionTabsResult { - return { - worktree, - publicationEpoch: 'renderer:1', - snapshotVersion: 1, - activeGroupId: 'group-1', - activeTabId: 'browser-1', - activeTabType: 'browser', - tabGroups: [{ id: 'group-1', activeTabId: 'browser-1', tabOrder: ['browser-1'] }], - tabs: [ - { - type: 'browser', - id: 'browser-1', - browserWorkspaceId: 'browser-workspace', - browserPageId: 'page-1', - title: 'Browser', - url: 'about:blank', - loading: false, - canGoBack: false, - canGoForward: false, - isActive: true - } - ] - } -} - -describe('client session-tab selection retention bounds', () => { - it('keeps the newest persisted clients without changing accepted selections', () => { - const clients = Object.fromEntries( - Array.from({ length: MOBILE_TAB_SELECTION_MAX_CLIENTS + 2 }, (_, index) => [ - `device-${index}`, - { 'wt-1': selection() } - ]) - ) - - const normalized = normalizePersistedMobileClientTabSelections(clients) - - expect(Object.keys(normalized)).toHaveLength(MOBILE_TAB_SELECTION_MAX_CLIENTS) - expect(Object.keys(normalized).at(0)).toBe('device-2') - expect(normalized[`device-${MOBILE_TAB_SELECTION_MAX_CLIENTS + 1}`]?.['wt-1']).toEqual( - selection() - ) - }) - - it('keeps the newest worktrees and group selections within each client', () => { - const groups = Object.fromEntries( - Array.from({ length: MOBILE_TAB_SELECTION_MAX_GROUPS_PER_WORKTREE + 2 }, (_, index) => [ - `group-${index}`, - `tab-${index}` - ]) - ) - const worktrees = Object.fromEntries( - Array.from({ length: MOBILE_TAB_SELECTION_MAX_WORKTREES_PER_CLIENT + 2 }, (_, index) => [ - `wt-${index}`, - selection(index === MOBILE_TAB_SELECTION_MAX_WORKTREES_PER_CLIENT + 1 ? groups : {}) - ]) - ) - - const normalized = normalizePersistedMobileClientTabSelections({ 'device-1': worktrees }) - const retainedWorktrees = normalized['device-1']! - const retainedGroups = - retainedWorktrees[`wt-${MOBILE_TAB_SELECTION_MAX_WORKTREES_PER_CLIENT + 1}`]! - .activeTabIdByGroupId - - expect(Object.keys(retainedWorktrees)).toHaveLength( - MOBILE_TAB_SELECTION_MAX_WORKTREES_PER_CLIENT - ) - expect(Object.keys(retainedWorktrees).at(0)).toBe('wt-2') - expect(Object.keys(retainedGroups)).toHaveLength(MOBILE_TAB_SELECTION_MAX_GROUPS_PER_WORKTREE) - expect(Object.keys(retainedGroups).at(0)).toBe('group-2') - }) - - it('bounds clients and worktrees created during the current runtime', () => { - const clientStore = new ClientSessionTabSelectionStore() - for (let index = 0; index <= MOBILE_TAB_SELECTION_MAX_CLIENTS; index++) { - clientStore.activate(snapshot('wt-1'), `device-${index}`, 'browser-1') - } - const retainedClients = clientStore.serialize() - - expect(Object.keys(retainedClients)).toHaveLength(MOBILE_TAB_SELECTION_MAX_CLIENTS) - expect(retainedClients['device-0']).toBeUndefined() - expect(retainedClients[`device-${MOBILE_TAB_SELECTION_MAX_CLIENTS}`]).toBeDefined() - - const worktreeStore = new ClientSessionTabSelectionStore() - for (let index = 0; index <= MOBILE_TAB_SELECTION_MAX_WORKTREES_PER_CLIENT; index++) { - worktreeStore.activate(snapshot(`wt-${index}`), 'device-1', 'browser-1') - } - const retainedWorktrees = worktreeStore.serialize()['device-1']! - - expect(Object.keys(retainedWorktrees)).toHaveLength( - MOBILE_TAB_SELECTION_MAX_WORKTREES_PER_CLIENT - ) - expect(retainedWorktrees['wt-0']).toBeUndefined() - expect(retainedWorktrees[`wt-${MOBILE_TAB_SELECTION_MAX_WORKTREES_PER_CLIENT}`]).toBeDefined() - }) - - it('rejects relay-sized client and worktree ids instead of retaining them', () => { - const oversizedId = 'x'.repeat(MOBILE_TAB_SELECTION_ID_MAX_BYTES + 1) - expect(isMobileTabSelectionIdRetainable(oversizedId)).toBe(false) - expect( - normalizePersistedMobileClientTabSelections({ - [oversizedId]: { 'wt-1': selection() }, - 'device-1': { [oversizedId]: selection() } - }) - ).toEqual({}) - - const store = new ClientSessionTabSelectionStore() - const ordinarySnapshot = snapshot('wt-1') - expect(store.activate(ordinarySnapshot, oversizedId, 'browser-1')).toBe(ordinarySnapshot) - const oversizedWorktreeSnapshot = snapshot(oversizedId) - expect(store.activate(oversizedWorktreeSnapshot, 'device-1', 'browser-1')).toBe( - oversizedWorktreeSnapshot - ) - expect(store.serialize()).toEqual({}) - }) - - it('retains newest worktree selections within the per-client byte budget', () => { - const fixedSizeId = (prefix: string, index: number): string => { - const label = `${prefix}-${index}-` - return `${label}${'x'.repeat(MOBILE_TAB_SELECTION_ID_MAX_BYTES - label.length)}` - } - const groups = Object.fromEntries( - Array.from({ length: MOBILE_TAB_SELECTION_MAX_GROUPS_PER_WORKTREE }, (_, index) => [ - fixedSizeId('group', index), - fixedSizeId('tab', index) - ]) - ) - const normalized = normalizePersistedMobileClientTabSelections({ - 'device-1': Object.fromEntries( - Array.from({ length: 10 }, (_, index) => [`wt-${index}`, selection(groups)]) - ) - }) - const retained = normalized['device-1']! - const retainedBytes = Object.entries(retained).reduce( - (total, [worktreeId, value]) => total + mobileTabSelectionRetainedBytes(worktreeId, value), - 0 - ) - - expect(retainedBytes).toBeLessThanOrEqual(MOBILE_TAB_SELECTION_MAX_BYTES_PER_CLIENT) - expect(retained['wt-0']).toBeUndefined() - expect(retained['wt-9']).toBeDefined() - }) -}) diff --git a/src/main/runtime/client-session-tab-selection-persistence.ts b/src/main/runtime/client-session-tab-selection-persistence.ts index d8f72add49e..358144cb49d 100644 --- a/src/main/runtime/client-session-tab-selection-persistence.ts +++ b/src/main/runtime/client-session-tab-selection-persistence.ts @@ -2,105 +2,6 @@ import type { PersistedMobileClientTabSelection, PersistedMobileClientTabSelections } from '../../shared/types' -import { measureUtf8ByteLength } from '../../shared/utf8-byte-limits' - -export const MOBILE_TAB_SELECTION_MAX_CLIENTS = 64 -export const MOBILE_TAB_SELECTION_MAX_WORKTREES_PER_CLIENT = 512 -export const MOBILE_TAB_SELECTION_MAX_GROUPS_PER_WORKTREE = 128 -export const MOBILE_TAB_SELECTION_ID_MAX_BYTES = 4 * 1024 -export const MOBILE_TAB_SELECTION_MAX_BYTES_PER_SELECTION = 64 * 1024 -export const MOBILE_TAB_SELECTION_MAX_BYTES_PER_CLIENT = 256 * 1024 - -export function isMobileTabSelectionIdRetainable(value: string): boolean { - return !measureUtf8ByteLength(value, { - stopAfterBytes: MOBILE_TAB_SELECTION_ID_MAX_BYTES - }).exceededLimit -} - -function mobileTabSelectionStringBytes(value: string | null): number { - return value ? measureUtf8ByteLength(value).byteLength : 0 -} - -export function mobileTabSelectionRetainedBytes( - worktreeId: string, - selection: PersistedMobileClientTabSelection -): number { - let bytes = - mobileTabSelectionStringBytes(worktreeId) + - mobileTabSelectionStringBytes(selection.activeTabId) + - mobileTabSelectionStringBytes(selection.activeGroupId) - for (const [groupId, tabId] of Object.entries(selection.activeTabIdByGroupId)) { - bytes += mobileTabSelectionStringBytes(groupId) + mobileTabSelectionStringBytes(tabId) - } - return bytes -} - -function newestOwnEntries( - record: Record, - maxEntries: number, - acceptKey: (key: string) => boolean = () => true -): [string, unknown][] { - const entries: [string, unknown][] = [] - let nextReplacementIndex = 0 - for (const key in record) { - if (!Object.prototype.hasOwnProperty.call(record, key)) { - continue - } - if (!acceptKey(key)) { - continue - } - const entry: [string, unknown] = [key, record[key]] - if (entries.length < maxEntries) { - entries.push(entry) - } else { - entries[nextReplacementIndex] = entry - nextReplacementIndex = (nextReplacementIndex + 1) % maxEntries - } - } - return nextReplacementIndex === 0 - ? entries - : [...entries.slice(nextReplacementIndex), ...entries.slice(0, nextReplacementIndex)] -} - -export function boundMobileClientTabSelectionGroups( - selection: PersistedMobileClientTabSelection -): PersistedMobileClientTabSelection { - const activeTabId = - selection.activeTabId && isMobileTabSelectionIdRetainable(selection.activeTabId) - ? selection.activeTabId - : null - const activeGroupId = - selection.activeGroupId && isMobileTabSelectionIdRetainable(selection.activeGroupId) - ? selection.activeGroupId - : null - let retainedBytes = - mobileTabSelectionStringBytes(activeTabId) + mobileTabSelectionStringBytes(activeGroupId) - const retainedGroups: [string, string][] = [] - const candidates = newestOwnEntries( - selection.activeTabIdByGroupId, - MOBILE_TAB_SELECTION_MAX_GROUPS_PER_WORKTREE, - isMobileTabSelectionIdRetainable - ).filter( - (entry): entry is [string, string] => - typeof entry[1] === 'string' && isMobileTabSelectionIdRetainable(entry[1]) - ) - for (let index = candidates.length - 1; index >= 0; index--) { - const entry = candidates[index]! - const entryBytes = - mobileTabSelectionStringBytes(entry[0]) + mobileTabSelectionStringBytes(entry[1]) - if (retainedBytes + entryBytes > MOBILE_TAB_SELECTION_MAX_BYTES_PER_SELECTION) { - break - } - retainedBytes += entryBytes - retainedGroups.push(entry) - } - return { - ...selection, - activeTabId, - activeGroupId, - activeTabIdByGroupId: Object.fromEntries(retainedGroups.toReversed()) - } -} function normalizeClientSessionTabSelection( raw: unknown @@ -109,28 +10,16 @@ function normalizeClientSessionTabSelection( return null } const candidate = raw as Partial - const activeTabId = - typeof candidate.activeTabId === 'string' && - isMobileTabSelectionIdRetainable(candidate.activeTabId) - ? candidate.activeTabId - : null - const activeGroupId = - typeof candidate.activeGroupId === 'string' && - isMobileTabSelectionIdRetainable(candidate.activeGroupId) - ? candidate.activeGroupId - : null + const activeTabId = typeof candidate.activeTabId === 'string' ? candidate.activeTabId : null + const activeGroupId = typeof candidate.activeGroupId === 'string' ? candidate.activeGroupId : null const activeTabIdByGroupId: Record = {} if ( typeof candidate.activeTabIdByGroupId === 'object' && candidate.activeTabIdByGroupId && !Array.isArray(candidate.activeTabIdByGroupId) ) { - for (const [groupId, tabId] of newestOwnEntries( - candidate.activeTabIdByGroupId as Record, - MOBILE_TAB_SELECTION_MAX_GROUPS_PER_WORKTREE, - isMobileTabSelectionIdRetainable - )) { - if (typeof tabId === 'string' && isMobileTabSelectionIdRetainable(tabId)) { + for (const [groupId, tabId] of Object.entries(candidate.activeTabIdByGroupId)) { + if (typeof tabId === 'string') { activeTabIdByGroupId[groupId] = tabId } } @@ -138,7 +27,7 @@ function normalizeClientSessionTabSelection( if (!activeTabId && !activeGroupId && Object.keys(activeTabIdByGroupId).length === 0) { return null } - return boundMobileClientTabSelectionGroups({ activeTabId, activeGroupId, activeTabIdByGroupId }) + return { activeTabId, activeGroupId, activeTabIdByGroupId } } // Why: this state comes off disk (and, for remote runtimes, another machine); a bad payload must degrade to "no selection", not throw. @@ -149,11 +38,7 @@ export function normalizePersistedMobileClientTabSelections( if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) { return normalized } - for (const [clientNavigationId, selectionsByWorktree] of newestOwnEntries( - raw as Record, - MOBILE_TAB_SELECTION_MAX_CLIENTS, - isMobileTabSelectionIdRetainable - )) { + for (const [clientNavigationId, selectionsByWorktree] of Object.entries(raw)) { if ( typeof selectionsByWorktree !== 'object' || selectionsByWorktree === null || @@ -161,29 +46,15 @@ export function normalizePersistedMobileClientTabSelections( ) { continue } - const entries = new Map() - let retainedBytes = 0 - for (const [worktreeId, selection] of newestOwnEntries( - selectionsByWorktree as Record, - MOBILE_TAB_SELECTION_MAX_WORKTREES_PER_CLIENT, - isMobileTabSelectionIdRetainable - )) { + const entries: Record = {} + for (const [worktreeId, selection] of Object.entries(selectionsByWorktree)) { const normalizedSelection = normalizeClientSessionTabSelection(selection) if (normalizedSelection) { - entries.set(worktreeId, normalizedSelection) - retainedBytes += mobileTabSelectionRetainedBytes(worktreeId, normalizedSelection) - while (retainedBytes > MOBILE_TAB_SELECTION_MAX_BYTES_PER_CLIENT) { - const oldest = entries.entries().next() - if (oldest.done) { - break - } - entries.delete(oldest.value[0]) - retainedBytes -= mobileTabSelectionRetainedBytes(oldest.value[0], oldest.value[1]) - } + entries[worktreeId] = normalizedSelection } } - if (entries.size > 0) { - normalized[clientNavigationId] = Object.fromEntries(entries) + if (Object.keys(entries).length > 0) { + normalized[clientNavigationId] = entries } } return normalized diff --git a/src/main/runtime/client-session-tab-selection-retention.ts b/src/main/runtime/client-session-tab-selection-retention.ts deleted file mode 100644 index 0dd7672a49d..00000000000 --- a/src/main/runtime/client-session-tab-selection-retention.ts +++ /dev/null @@ -1,64 +0,0 @@ -import type { ClientSessionTabSelection } from './client-session-tab-selection' -import { - isMobileTabSelectionIdRetainable, - MOBILE_TAB_SELECTION_MAX_BYTES_PER_CLIENT, - MOBILE_TAB_SELECTION_MAX_CLIENTS, - MOBILE_TAB_SELECTION_MAX_WORKTREES_PER_CLIENT, - mobileTabSelectionRetainedBytes -} from './client-session-tab-selection-persistence' - -export type StoredClientSessionTabSelection = { - selection: ClientSessionTabSelection - revision: number - shouldPersist: boolean -} - -export function getOrCreateClientTabSelectionWorktrees( - statesByClient: Map>, - clientNavigationId: string -): Map { - let statesByWorktree = statesByClient.get(clientNavigationId) - if (!statesByWorktree) { - if (statesByClient.size >= MOBILE_TAB_SELECTION_MAX_CLIENTS) { - const oldestClientId = statesByClient.keys().next().value - if (oldestClientId !== undefined) { - statesByClient.delete(oldestClientId) - } - } - statesByWorktree = new Map() - statesByClient.set(clientNavigationId, statesByWorktree) - } - return statesByWorktree -} - -export function rememberClientTabSelectionWorktree( - statesByWorktree: Map, - worktreeId: string, - state: StoredClientSessionTabSelection -): void { - if (!isMobileTabSelectionIdRetainable(worktreeId)) { - return - } - if ( - !statesByWorktree.has(worktreeId) && - statesByWorktree.size >= MOBILE_TAB_SELECTION_MAX_WORKTREES_PER_CLIENT - ) { - const oldestWorktreeId = statesByWorktree.keys().next().value - if (oldestWorktreeId !== undefined) { - statesByWorktree.delete(oldestWorktreeId) - } - } - statesByWorktree.set(worktreeId, state) - let retainedBytes = 0 - for (const [retainedWorktreeId, retainedState] of statesByWorktree) { - retainedBytes += mobileTabSelectionRetainedBytes(retainedWorktreeId, retainedState.selection) - } - while (retainedBytes > MOBILE_TAB_SELECTION_MAX_BYTES_PER_CLIENT) { - const oldest = statesByWorktree.entries().next() - if (oldest.done) { - break - } - statesByWorktree.delete(oldest.value[0]) - retainedBytes -= mobileTabSelectionRetainedBytes(oldest.value[0], oldest.value[1].selection) - } -} diff --git a/src/main/runtime/client-session-tab-selection.ts b/src/main/runtime/client-session-tab-selection.ts index 5af3bd48cf7..7c8f84f88d2 100644 --- a/src/main/runtime/client-session-tab-selection.ts +++ b/src/main/runtime/client-session-tab-selection.ts @@ -3,16 +3,7 @@ import type { RuntimeMobileSessionTabsResult } from '../../shared/runtime-types' import type { PersistedMobileClientTabSelections } from '../../shared/types' -import { - boundMobileClientTabSelectionGroups, - isMobileTabSelectionIdRetainable, - normalizePersistedMobileClientTabSelections -} from './client-session-tab-selection-persistence' -import { - getOrCreateClientTabSelectionWorktrees, - rememberClientTabSelectionWorktree, - type StoredClientSessionTabSelection -} from './client-session-tab-selection-retention' +import { normalizePersistedMobileClientTabSelections } from './client-session-tab-selection-persistence' export type ClientSessionTabSelection = { activeTabId: string | null @@ -20,6 +11,13 @@ export type ClientSessionTabSelection = { activeTabIdByGroupId: Readonly> } +type StoredClientSessionTabSelection = { + selection: ClientSessionTabSelection + revision: number + // Why: listAll projects every worktree; only hydrated or user-activated selections belong on disk. + shouldPersist: boolean +} + function emptyClientSessionTabSelection(): ClientSessionTabSelection { return { activeTabId: null, activeGroupId: null, activeTabIdByGroupId: {} } } @@ -44,7 +42,7 @@ function findTabByTopLevelId( export function deriveClientSessionTabSelection( snapshot: RuntimeMobileSessionTabsResult ): ClientSessionTabSelection { - return boundMobileClientTabSelectionGroups({ + return { activeTabId: snapshot.activeTabId, activeGroupId: snapshot.activeGroupId, activeTabIdByGroupId: Object.fromEntries( @@ -52,7 +50,7 @@ export function deriveClientSessionTabSelection( group.activeTabId ? [[group.id, group.activeTabId] as const] : [] ) ?? [] ) - }) + } } export function activateClientSessionTabSelection( @@ -68,13 +66,13 @@ export function activateClientSessionTabSelection( const activeGroup = snapshot.tabGroups?.find((group) => group.tabOrder.includes(activeTopLevelTabId) ) - return boundMobileClientTabSelectionGroups({ + return { activeTabId, activeGroupId: activeGroup?.id ?? selection.activeGroupId, activeTabIdByGroupId: activeGroup ? { ...selection.activeTabIdByGroupId, [activeGroup.id]: activeTopLevelTabId } : selection.activeTabIdByGroupId - }) + } } export function projectClientSessionTabSelection( @@ -112,11 +110,11 @@ export function projectClientSessionTabSelection( : null) ?? tabGroups?.[0]?.id ?? null - const nextSelection: ClientSessionTabSelection = boundMobileClientTabSelectionGroups({ + const nextSelection: ClientSessionTabSelection = { activeTabId: activeTab?.id ?? null, activeGroupId, activeTabIdByGroupId - }) + } return { selection: nextSelection, snapshot: { @@ -139,16 +137,9 @@ export class ClientSessionTabSelectionStore { for (const [clientNavigationId, selectionsByWorktree] of Object.entries( normalizePersistedMobileClientTabSelections(persisted) )) { - const statesByWorktree = getOrCreateClientTabSelectionWorktrees( - this.statesByClient, - clientNavigationId - ) + const statesByWorktree = this.getStatesByWorktree(clientNavigationId) for (const [worktreeId, selection] of Object.entries(selectionsByWorktree)) { - rememberClientTabSelectionWorktree(statesByWorktree, worktreeId, { - selection, - revision: 0, - shouldPersist: true - }) + statesByWorktree.set(worktreeId, { selection, revision: 0, shouldPersist: true }) } } } @@ -177,21 +168,25 @@ export class ClientSessionTabSelectionStore { this.persistListener?.(this.serialize()) } + private getStatesByWorktree( + clientNavigationId: string + ): Map { + let statesByWorktree = this.statesByClient.get(clientNavigationId) + if (!statesByWorktree) { + statesByWorktree = new Map() + this.statesByClient.set(clientNavigationId, statesByWorktree) + } + return statesByWorktree + } + project( snapshot: RuntimeMobileSessionTabsResult, clientNavigationId?: string ): RuntimeMobileSessionTabsResult { - if ( - !clientNavigationId || - !isMobileTabSelectionIdRetainable(clientNavigationId) || - !isMobileTabSelectionIdRetainable(snapshot.worktree) - ) { + if (!clientNavigationId) { return snapshot } - const statesByWorktree = getOrCreateClientTabSelectionWorktrees( - this.statesByClient, - clientNavigationId - ) + const statesByWorktree = this.getStatesByWorktree(clientNavigationId) const state = statesByWorktree.get(snapshot.worktree) ?? { // Why: host focus is private navigation; a new paired device starts from deterministic topology instead of inheriting it. selection: emptyClientSessionTabSelection(), @@ -207,7 +202,7 @@ export class ClientSessionTabSelectionStore { } } const projected = projectClientSessionTabSelection(snapshot, state.selection) - rememberClientTabSelectionWorktree(statesByWorktree, snapshot.worktree, { + statesByWorktree.set(snapshot.worktree, { selection: projected.selection, revision: state.revision, shouldPersist: state.shouldPersist @@ -224,23 +219,14 @@ export class ClientSessionTabSelectionStore { clientNavigationId: string, activeTabId: string ): RuntimeMobileSessionTabsResult { - if ( - !isMobileTabSelectionIdRetainable(clientNavigationId) || - !isMobileTabSelectionIdRetainable(snapshot.worktree) - ) { - return snapshot - } - const statesByWorktree = getOrCreateClientTabSelectionWorktrees( - this.statesByClient, - clientNavigationId - ) + const statesByWorktree = this.getStatesByWorktree(clientNavigationId) const state = statesByWorktree.get(snapshot.worktree) ?? { selection: emptyClientSessionTabSelection(), revision: 0, shouldPersist: false } const nextSelection = activateClientSessionTabSelection(snapshot, state.selection, activeTabId) - rememberClientTabSelectionWorktree(statesByWorktree, snapshot.worktree, { + statesByWorktree.set(snapshot.worktree, { selection: nextSelection, revision: state.revision + 1, shouldPersist: true @@ -269,8 +255,8 @@ export class ClientSessionTabSelectionStore { if (!state) { continue } + statesByWorktree.set(newWorktreeId, state) statesByWorktree.delete(oldWorktreeId) - rememberClientTabSelectionWorktree(statesByWorktree, newWorktreeId, state) changed = state.shouldPersist || changed } if (changed) { diff --git a/src/main/runtime/device-registry.ts b/src/main/runtime/device-registry.ts index 91ad94e1534..d246b1f7309 100644 --- a/src/main/runtime/device-registry.ts +++ b/src/main/runtime/device-registry.ts @@ -3,12 +3,9 @@ // compromising one device doesn't expose others. The registry is a simple // JSON file with hardened permissions matching the runtime metadata pattern. import { randomBytes, randomUUID } from 'node:crypto' -import { existsSync } from 'node:fs' +import { existsSync, readFileSync } from 'node:fs' import { join } from 'node:path' -import { JsonStringifyByteLimitError } from '../../shared/node-bounded-json-stringify' -import { readNodeFileSyncWithinLimit } from '../../shared/node-bounded-file-reader' -import { writeSecureJsonFileWithinLimit } from '../../shared/bounded-secure-json-file' -import { hardenExistingSecureFile } from '../../shared/secure-file' +import { hardenExistingSecureFile, writeSecureJsonFile } from '../../shared/secure-file' import type { DeviceScope } from '../../shared/runtime-types' import { DEVICE_REGISTRY_FILENAME } from './mobile-pairing-files' import type { RelayDeviceBinding } from './relay/relay-revoke-outbox' @@ -16,16 +13,6 @@ import type { MobilePairingConnectionMode } from '../../shared/mobile-pairing-co export type { DeviceScope } -export const MAX_DEVICE_REGISTRY_FILE_BYTES = 1024 * 1024 -export const MAX_DEVICE_REGISTRY_ENTRIES = 4096 - -export class DeviceRegistryCapacityError extends Error { - constructor() { - super('Device registry exceeds its durable capacity') - this.name = 'DeviceRegistryCapacityError' - } -} - export type DeviceEntry = { deviceId: string name: string @@ -74,9 +61,6 @@ export class DeviceRegistry { name: string, scope: DeviceScope ): DeviceEntry { - if (existingDevices.length >= MAX_DEVICE_REGISTRY_ENTRIES) { - throw new DeviceRegistryCapacityError() - } const entry: DeviceEntry = { deviceId: randomUUID(), name, @@ -118,13 +102,13 @@ export class DeviceRegistry { } removeDevice(deviceId: string): boolean { - const nextDevices = this.devices.filter((device) => device.deviceId !== deviceId) - if (nextDevices.length === this.devices.length) { - return false + const before = this.devices.length + this.devices = this.devices.filter((d) => d.deviceId !== deviceId) + if (this.devices.length < before) { + this.save() + return true } - this.save(nextDevices) - this.devices = nextDevices - return true + return false } getDevice(deviceId: string): DeviceEntry | null { @@ -140,12 +124,8 @@ export class DeviceRegistry { if (!device || binding.relayDeviceId !== deviceId) { return false } - this.save( - this.devices.map((candidate) => - candidate === device ? { ...candidate, relayBinding: binding } : candidate - ) - ) device.relayBinding = binding + this.save() return true } @@ -154,12 +134,8 @@ export class DeviceRegistry { if (!device || device.scope !== 'mobile') { return false } - this.save( - this.devices.map((candidate) => - candidate === device ? { ...candidate, mobilePairingConnectionMode: mode } : candidate - ) - ) device.mobilePairingConnectionMode = mode + this.save() return true } @@ -184,13 +160,8 @@ export class DeviceRegistry { updateLastSeen(deviceId: string): void { const device = this.devices.find((d) => d.deviceId === deviceId) if (device) { - const lastSeenAt = Date.now() - this.save( - this.devices.map((candidate) => - candidate === device ? { ...candidate, lastSeenAt } : candidate - ) - ) - device.lastSeenAt = lastSeenAt + device.lastSeenAt = Date.now() + this.save() } } @@ -201,16 +172,8 @@ export class DeviceRegistry { } try { hardenExistingSecureFile(this.registryPath) - const parsed: unknown = JSON.parse( - readNodeFileSyncWithinLimit( - this.registryPath, - MAX_DEVICE_REGISTRY_FILE_BYTES - ).buffer.toString('utf8') - ) - if (!Array.isArray(parsed) || parsed.length > MAX_DEVICE_REGISTRY_ENTRIES) { - throw new DeviceRegistryCapacityError() - } - this.devices = (parsed as DeviceEntry[]).map((device) => ({ + const parsed = JSON.parse(readFileSync(this.registryPath, 'utf-8')) as DeviceEntry[] + this.devices = parsed.map((device) => ({ ...device, // Why: older registries only existed for phone pairing. Treat missing // scope as mobile so legacy device tokens do not gain new CLI powers. @@ -225,13 +188,6 @@ export class DeviceRegistry { } private save(devices: DeviceEntry[] = this.devices): void { - try { - writeSecureJsonFileWithinLimit(this.registryPath, devices, MAX_DEVICE_REGISTRY_FILE_BYTES) - } catch (error) { - if (error instanceof JsonStringifyByteLimitError) { - throw new DeviceRegistryCapacityError() - } - throw error - } + writeSecureJsonFile(this.registryPath, devices) } } diff --git a/src/main/runtime/e2ee-keypair.ts b/src/main/runtime/e2ee-keypair.ts index 228a76a2317..3e8b3e0aa0a 100644 --- a/src/main/runtime/e2ee-keypair.ts +++ b/src/main/runtime/e2ee-keypair.ts @@ -1,19 +1,15 @@ // Why: the E2EE keypair enables application-layer encryption between mobile // and desktop over plain ws://. The public key is embedded in the QR pairing // offer so the mobile client can derive a shared secret via ECDH. -import { existsSync } from 'node:fs' +import { existsSync, readFileSync, statSync } from 'node:fs' import { join } from 'node:path' import nacl from 'tweetnacl' -import { readNodeFileSyncWithinLimit } from '../../shared/node-bounded-file-reader' -import { assertJsonTextStructureWithinLimits } from '../../shared/json-text-structure-limit' import { hardenExistingSecureFile, writeSecureJsonFile } from '../../shared/secure-file' import { E2EE_KEYPAIR_FILENAME } from './mobile-pairing-files' const KEYPAIR_FILENAME = E2EE_KEYPAIR_FILENAME const KEYPAIR_VERSION = 1 -export const MAX_KEYPAIR_FILE_BYTES = 8 * 1024 -export const MAX_KEYPAIR_JSON_STRUCTURAL_TOKENS = 2048 -export const MAX_KEYPAIR_JSON_NESTING_DEPTH = 8 +const MAX_KEYPAIR_FILE_BYTES = 8 * 1024 type KeypairFile = { v: number @@ -33,15 +29,12 @@ export function loadOrCreateE2EEKeypair(userDataPath: string): E2EEKeypair { if (existsSync(filePath)) { try { hardenExistingSecureFile(filePath) - const serialized = readNodeFileSyncWithinLimit( - filePath, - MAX_KEYPAIR_FILE_BYTES - ).buffer.toString('utf8') - assertJsonTextStructureWithinLimits(serialized, { - structuralTokens: MAX_KEYPAIR_JSON_STRUCTURAL_TOKENS, - nestingDepth: MAX_KEYPAIR_JSON_NESTING_DEPTH - }) - const raw = JSON.parse(serialized) as KeypairFile + // Why: this startup path reads synchronously; valid keypair files are + // tiny, so oversized/corrupt files should be replaced without loading. + if (statSync(filePath).size > MAX_KEYPAIR_FILE_BYTES) { + throw new Error('E2EE keypair file is too large') + } + const raw: KeypairFile = JSON.parse(readFileSync(filePath, 'utf-8')) if (raw.v === KEYPAIR_VERSION && raw.publicKeyB64 && raw.secretKeyB64) { const publicKey = Uint8Array.from(Buffer.from(raw.publicKeyB64, 'base64')) const secretKey = Uint8Array.from(Buffer.from(raw.secretKeyB64, 'base64')) diff --git a/src/main/runtime/orca-runtime-files.test.ts b/src/main/runtime/orca-runtime-files.test.ts index 3bb68dcff51..f92ebd6b656 100644 --- a/src/main/runtime/orca-runtime-files.test.ts +++ b/src/main/runtime/orca-runtime-files.test.ts @@ -14,7 +14,6 @@ import type * as GitRunner from '../git/runner' const { lstatMock, openMock, - opendirMock, readdirMock, renameMock, resolveAuthorizedPathMock, @@ -30,7 +29,6 @@ const { getLocalGitOptionsForRegisteredWorktreeMock: vi.fn(), lstatMock: vi.fn(), openMock: vi.fn(), - opendirMock: vi.fn(), readdirMock: vi.fn(), renameMock: vi.fn(), resolveAuthorizedPathMock: vi.fn(), @@ -58,10 +56,6 @@ vi.mock('fs/promises', async () => { const impl = openMock.getMockImplementation() return impl ? openMock(...args) : actual.open(...args) }, - opendir: (...args: Parameters) => { - const impl = opendirMock.getMockImplementation() - return impl ? opendirMock(...args) : actual.opendir(...args) - }, readdir: readdirMock, rename: renameMock, stat: statMock @@ -103,11 +97,7 @@ vi.mock('../providers/ssh-filesystem-dispatch', () => ({ 'Remote connection dropped. Click Reconnect on the SSH target before retrying.' })) -import { - awaitRuntimeFileWatcherUnsubscribes, - classifyRuntimeMobileDirectoryEntries, - RuntimeFileCommands -} from './orca-runtime-files' +import { awaitRuntimeFileWatcherUnsubscribes, RuntimeFileCommands } from './orca-runtime-files' import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import { resetSshConnectionGenerations, @@ -115,16 +105,6 @@ import { } from '../ssh/ssh-connection-generation' import { SEARCH_TIMEOUT_MS } from '../../shared/text-search' -function pngHeader(width = 1, height = 1): Buffer { - const bytes = Buffer.alloc(24) - Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(bytes) - bytes.writeUInt32BE(13, 8) - bytes.write('IHDR', 12, 'ascii') - bytes.writeUInt32BE(width, 16) - bytes.writeUInt32BE(height, 20) - return bytes -} - type MockRuntimeSearchChild = EventEmitter & { stdout: EventEmitter & { setEncoding: ReturnType } stderr: EventEmitter @@ -222,7 +202,6 @@ describe('RuntimeFileCommands', () => { vi.useFakeTimers() lstatMock.mockReset() openMock.mockReset() - opendirMock.mockReset() readdirMock.mockReset() renameMock.mockReset() resolveAuthorizedPathMock.mockReset() @@ -233,7 +212,6 @@ describe('RuntimeFileCommands', () => { checkRgAvailableMock.mockReset() vi.mocked(getSshFilesystemProvider).mockReset() resetSshConnectionGenerations() - setSshConnectionGeneration('ssh-1', 0) getLocalGitOptionsForRegisteredWorktreeMock.mockReset() wslAwareSpawnMock.mockReset() getLocalGitOptionsForRegisteredWorktreeMock.mockReturnValue({}) @@ -320,46 +298,6 @@ describe('RuntimeFileCommands', () => { }) }) - it('validates local file explorer raster dimensions before returning base64', async () => { - const dir = await mkdtemp(join(tmpdir(), 'orca-runtime-image-preview-')) - const filePath = join(dir, 'image.png') - const content = pngHeader(640, 480) - await writeFile(filePath, content) - const { commands } = createRuntimeFileCommands({ path: dir }) - resolveAuthorizedPathMock.mockImplementation(async (value: string) => value) - statMock.mockResolvedValue({ size: content.length }) - - try { - await expect(commands.readFileExplorerPreview('id:wt-1', 'image.png')).resolves.toEqual({ - content: content.toString('base64'), - isBinary: true, - isImage: true, - mimeType: 'image/png', - imageDimensions: { width: 640, height: 480 } - }) - } finally { - await rm(dir, { recursive: true, force: true }) - } - }) - - it('rejects a local file explorer raster dimension bomb', async () => { - const dir = await mkdtemp(join(tmpdir(), 'orca-runtime-image-bomb-')) - const filePath = join(dir, 'bomb.png') - const content = pngHeader(32_769, 1) - await writeFile(filePath, content) - const { commands } = createRuntimeFileCommands({ path: dir }) - resolveAuthorizedPathMock.mockImplementation(async (value: string) => value) - statMock.mockResolvedValue({ size: content.length }) - - try { - await expect(commands.readFileExplorerPreview('id:wt-1', 'bomb.png')).rejects.toThrow( - 'Image dimensions exceed the preview safety limit' - ) - } finally { - await rm(dir, { recursive: true, force: true }) - } - }) - it('leaves non-previewable binaries unavailable on mobile', async () => { const openFile = vi.fn() const { commands } = createRuntimeFileCommands({ openFile }) @@ -416,12 +354,10 @@ describe('RuntimeFileCommands', () => { it('does not follow symlinks when reading runtime-local file explorer dirs', async () => { const { commands } = createRuntimeFileCommands() resolveAuthorizedPathMock.mockResolvedValue('/repo') - opendirMock.mockResolvedValue({ - async *[Symbol.asyncIterator]() { - yield dirEntry({ name: 'README.md' }) - yield dirEntry({ name: 'linked-docs', directory: true, symlink: true }) - } - }) + readdirMock.mockResolvedValue([ + dirEntry({ name: 'README.md' }), + dirEntry({ name: 'linked-docs', directory: true, symlink: true }) + ]) const result = await commands.readFileExplorerDir('id:wt-1', '') @@ -432,46 +368,6 @@ describe('RuntimeFileCommands', () => { expect(statMock).not.toHaveBeenCalledWith('/repo/linked-docs') }) - it('stops runtime-local directory enumeration at the mobile entry limit', async () => { - const { commands } = createRuntimeFileCommands() - resolveAuthorizedPathMock.mockResolvedValue('/repo') - let enumerated = 0 - opendirMock.mockResolvedValue({ - async *[Symbol.asyncIterator]() { - while (enumerated < 20_000) { - enumerated += 1 - yield dirEntry({ name: 'entry' }) - } - } - }) - - await expect(commands.readFileExplorerDir('id:wt-1', '')).rejects.toThrow( - 'This folder is too large to show safely on mobile' - ) - expect(enumerated).toBe(10_001) - }) - - it('bounds runtime-local directory classification while preserving entry order', async () => { - let active = 0 - let maxActive = 0 - const classify = vi.fn(async () => { - active += 1 - maxActive = Math.max(maxActive, active) - await Promise.resolve() - active -= 1 - return true - }) - const entries = Array.from({ length: 100 }, (_, index) => - dirEntry({ name: `entry-${index}`, symlink: true }) - ) - - const result = await classifyRuntimeMobileDirectoryEntries('/repo', entries, classify) - - expect(maxActive).toBe(32) - expect(result.map((entry) => entry.name)).toEqual(entries.map((entry) => entry.name)) - expect(result.every((entry) => entry.isDirectory && entry.isSymlink)).toBe(true) - }) - it('renames a runtime-local file when destination does not exist', async () => { const { commands } = createRuntimeFileCommands() resolveAuthorizedPathMock.mockImplementation(async (p: string) => p) @@ -903,7 +799,7 @@ describe('RuntimeFileCommands', () => { tempDirs = [] }) - async function tempFile(name: string, content: string | Uint8Array): Promise { + async function tempFile(name: string, content: string): Promise { const dir = await mkdtemp(join(tmpdir(), 'orca-terminal-artifact-')) tempDirs.push(dir) const filePath = join(dir, name) @@ -1870,49 +1766,6 @@ describe('RuntimeFileCommands', () => { ).rejects.toThrow('terminal_file_grant_stale') }) - it('validates local terminal artifact raster dimensions before returning base64', async () => { - const content = pngHeader(320, 240) - const artifactPath = await tempFile('result.png', content) - const { commands } = createRuntimeFileCommands({ path: '/repo' }) - resolveAuthorizedPathMock.mockImplementation(async (value: string) => value) - - const result = await resolveTerminalArtifactPath(commands, artifactPath) - const target = absoluteFileTarget(result) - - await expect( - commands.readTerminalArtifactPreview( - 'id:wt-1', - target.grantId, - target.absolutePath, - 'client-a' - ) - ).resolves.toEqual({ - content: content.toString('base64'), - isBinary: true, - isImage: true, - mimeType: 'image/png', - imageDimensions: { width: 320, height: 240 } - }) - }) - - it('rejects a local terminal artifact raster dimension bomb', async () => { - const artifactPath = await tempFile('result.png', pngHeader(32_769, 1)) - const { commands } = createRuntimeFileCommands({ path: '/repo' }) - resolveAuthorizedPathMock.mockImplementation(async (value: string) => value) - - const result = await resolveTerminalArtifactPath(commands, artifactPath) - const target = absoluteFileTarget(result) - - await expect( - commands.readTerminalArtifactPreview( - 'id:wt-1', - target.grantId, - target.absolutePath, - 'client-a' - ) - ).rejects.toThrow('Image dimensions exceed the preview safety limit') - }) - it('rejects binary-extension terminal artifacts from the editable text path', async () => { const artifactPath = await tempFile('report.pdf', '%PDF text-looking bytes') const { commands } = createRuntimeFileCommands({ path: '/repo' }) diff --git a/src/main/runtime/orca-runtime-files.ts b/src/main/runtime/orca-runtime-files.ts index 3b7164a2c91..c518a4ced3d 100644 --- a/src/main/runtime/orca-runtime-files.ts +++ b/src/main/runtime/orca-runtime-files.ts @@ -1,7 +1,7 @@ /* eslint-disable max-lines -- Why: filesystem, editor-file, and search commands share the same local/SSH path authorization rules. Keeping that IO adapter together prevents separate command paths from drifting on safety checks. */ import type { ChildProcess } from 'node:child_process' import { randomUUID } from 'node:crypto' -import { watch as watchFs, type Dirent } from 'node:fs' +import { watch as watchFs } from 'node:fs' import type { FileHandle } from 'node:fs/promises' import { chmod, @@ -10,7 +10,8 @@ import { lstat, mkdir, open, - opendir, + readFile, + readdir, rename, realpath, rm, @@ -37,13 +38,6 @@ import { resolveRuntimePath } from '../../shared/cross-platform-path' import { PhysicalExitTracker } from '../../shared/physical-exit-tracker' -import { - assertMobileFileDirectoryWithinLimit, - createMobileFileDirectoryLimitState, - MOBILE_FILE_DIRECTORY_MAX_ENTRIES, - MOBILE_FILE_DIRECTORY_MAX_RETAINED_BYTES, - trackMobileFileDirectoryEntry -} from '../../shared/mobile-file-directory-limit' import type { RuntimeFileListResult, RuntimeFileOpenResult, @@ -86,10 +80,6 @@ import { WatcherProcessFailure } from '../ipc/parcel-watcher-process-failure' import { assertNoClobberRenameDestinationAvailable } from '../../shared/filesystem-rename-collision' -import { - NodeFileReadTooLargeError, - readNodeFileWithinLimit -} from '../../shared/node-bounded-file-reader' import { joinWorktreeRelativePath, normalizeRuntimeRelativePath } from './runtime-relative-paths' import { rankRuntimeMobileFilePaths, @@ -98,18 +88,12 @@ import { import { beginWatcherInstall } from '../ipc/watcher-removal-gate' import { assertSshMutationExpectation } from '../ssh/ssh-connection-generation' import { toSshExecutionHostId } from '../../shared/execution-host' -import { assertRasterImagePreviewWithinLimits } from '../../shared/raster-image-preview-limits' -import { SearchSubprocessLineAccumulator } from '../../shared/search-subprocess-lines' -import { retainRuntimeTerminalFileGrant } from './runtime-terminal-file-grant-retention' -import { assertRuntimeTextSearchAdmission } from './runtime-text-search-admission' -import { RuntimeFileWatcherAdmission } from './runtime-file-watcher-admission' const MOBILE_FILE_LIST_LIMIT = 5000 const MOBILE_FILE_PATH_SEARCH_CACHE_LIMIT = 20_000 const MOBILE_FILE_PATH_SEARCH_CACHE_ENTRIES = 8 const MOBILE_FILE_PATH_SEARCH_CACHE_TTL_MS = 30_000 const MOBILE_FILE_READ_MAX_BYTES = 512 * 1024 -const RUNTIME_MOBILE_DIRECTORY_CLASSIFICATION_CONCURRENCY = 32 const RUNTIME_PREVIEWABLE_BINARY_MAX_BYTES = 10 * 1024 * 1024 const WINDOWS_RUNTIME_FILE_WATCH_DEBOUNCE_MS = 150 export const WINDOWS_RUNTIME_FILE_WATCH_CLOSE_DEADLINE_MS = 10_000 @@ -142,7 +126,6 @@ type RuntimeFileWatcherLease = { forget(): void } const runtimeFileWatcherLeasesByOwnerAndRoot = new Map>() -const runtimeFileWatcherAdmission = new RuntimeFileWatcherAdmission() const MOBILE_BINARY_EXTENSIONS = new Set([ '.avif', '.bmp', @@ -248,8 +231,7 @@ function registerRuntimeFileWatcherRelease( rootPaths: string[], unsubscribe: () => Promise, restart: () => Promise<() => Promise>, - onRestoreError: (error: Error) => void, - releaseAdmission: () => void + onRestoreError: (error: Error) => void ): () => Promise { const keys = Array.from( new Set( @@ -270,7 +252,6 @@ function registerRuntimeFileWatcherRelease( runtimeFileWatcherLeasesByOwnerAndRoot.delete(key) } } - releaseAdmission() } const suspend = (): Promise => { if (releasePromise) { @@ -849,10 +830,7 @@ export class RuntimeFileCommands { expiresAt: Date.now() + TERMINAL_FILE_GRANT_TTL_MS, statIdentity: terminalFileStatIdentity(args.stats) } - this.pruneExpiredTerminalFileGrants() - retainRuntimeTerminalFileGrant(this.terminalFileGrants, grant, (id, retained) => - this.releaseTerminalFileGrant(id, retained) - ) + this.terminalFileGrants.set(grant.id, grant) this.scheduleTerminalFileGrantExpiry(grant) return grant } @@ -1184,23 +1162,21 @@ export class RuntimeFileCommands { if (!provider) { throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) } - const entries = await provider.readDir(target.path, { - maxEntries: MOBILE_FILE_DIRECTORY_MAX_ENTRIES, - maxRetainedBytes: MOBILE_FILE_DIRECTORY_MAX_RETAINED_BYTES - }) - assertMobileFileDirectoryWithinLimit(entries) - return entries + return provider.readDir(target.path) } const dirPath = await resolveAuthorizedPath(target.path, this.host.requireStore()) - const limit = createMobileFileDirectoryLimitState() - const directory = await opendir(dirPath) - const entries: Dirent[] = [] - for await (const entry of directory) { - trackMobileFileDirectoryEntry(limit, entry) - entries.push(entry) - } - const mapped = await classifyRuntimeMobileDirectoryEntries(dirPath, entries) + const entries = await readdir(dirPath, { withFileTypes: true }) + const mapped = await Promise.all( + entries.map(async (entry) => { + const entryPath = join(dirPath, entry.name) + return { + name: entry.name, + isDirectory: await isRuntimeDirectoryEntry(entry, entryPath), + isSymlink: entry.isSymbolicLink() + } + }) + ) return mapped.sort((a, b) => { if (a.isDirectory !== b.isDirectory) { return a.isDirectory ? -1 : 1 @@ -1216,11 +1192,6 @@ export class RuntimeFileCommands { signal?: AbortSignal ): Promise<() => void> { const target = await this.resolveFileExplorerPath(worktreeSelector, '') - const releaseAdmission = runtimeFileWatcherAdmission.claim( - this.host.getRuntimeId(), - target.connectionId, - target.path - ) const open = async (): Promise<{ unsubscribe: () => Promise rootPaths: string[] @@ -1258,30 +1229,15 @@ export class RuntimeFileCommands { finishInstall() } } - let initial: Awaited> - try { - initial = await open() - } catch (error) { - releaseAdmission() - throw error - } - try { - return registerRuntimeFileWatcherRelease( - this.host.getRuntimeId(), - target.connectionId, - initial.rootPaths, - initial.unsubscribe, - async () => (await open()).unsubscribe, - onTerminalError, - releaseAdmission - ) - } catch (error) { - releaseAdmission() - await trackRuntimeFileWatcherUnsubscribe(initial.rootPaths[0], initial.unsubscribe).catch( - () => undefined - ) - throw error - } + const initial = await open() + return registerRuntimeFileWatcherRelease( + this.host.getRuntimeId(), + target.connectionId, + initial.rootPaths, + initial.unsubscribe, + async () => (await open()).unsubscribe, + onTerminalError + ) } async closeFileExplorerWatchersForPath(rootPath: string, connectionId?: string): Promise { @@ -1339,30 +1295,23 @@ export class RuntimeFileCommands { const filePath = await resolveAuthorizedPath(target.path, this.host.requireStore()) const fileStats = await stat(filePath) const mimeType = RUNTIME_PREVIEWABLE_BINARY_MIME_TYPES[extname(filePath).toLowerCase()] - const sizeLimit = mimeType ? RUNTIME_PREVIEWABLE_BINARY_MAX_BYTES : MOBILE_FILE_READ_MAX_BYTES - if (fileStats.size > sizeLimit) { - throw new Error('file_too_large') - } - let buffer: Buffer - try { - buffer = (await readNodeFileWithinLimit(filePath, sizeLimit)).buffer - } catch (error) { - if (error instanceof NodeFileReadTooLargeError) { + if (mimeType) { + if (fileStats.size > RUNTIME_PREVIEWABLE_BINARY_MAX_BYTES) { throw new Error('file_too_large') } - throw error - } - if (mimeType) { - const imageDimensions = assertRasterImagePreviewWithinLimits(buffer, mimeType) + const buffer = await readFile(filePath) return { content: buffer.toString('base64'), isBinary: true, isImage: true, - mimeType, - ...(imageDimensions ? { imageDimensions } : {}) + mimeType } } + if (fileStats.size > MOBILE_FILE_READ_MAX_BYTES) { + throw new Error('file_too_large') + } + const buffer = await readFile(filePath) if (isBinaryBuffer(buffer)) { return { content: '', isBinary: true } } @@ -1789,12 +1738,7 @@ export class RuntimeFileCommands { if (!provider) { throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) } - if (!provider.listMarkdownDocuments) { - throw new Error( - 'Remote Markdown link discovery is unavailable. Reconnect the SSH target and retry.' - ) - } - const relativePaths = await provider.listMarkdownDocuments(target.worktree.path) + const relativePaths = await provider.listFiles(target.worktree.path) return markdownDocumentsFromRelativePaths(target.worktree.path, relativePaths) } return listMarkdownDocuments(target.worktree.path) @@ -1844,12 +1788,11 @@ export class RuntimeFileCommands { return new Promise((resolvePromise) => { const searchKey = `${this.host.getRuntimeId()}:${authorizedRootPath}` - assertRuntimeTextSearchAdmission(this.activeRuntimeTextSearches, searchKey) const rgArgs = buildRgArgs(options.query, authorizedRootPath, options) this.activeRuntimeTextSearches.get(searchKey)?.kill() const acc = createAccumulator() - const stdoutLines = new SearchSubprocessLineAccumulator() + let stdoutBuffer = '' let resolved = false let child: ChildProcess | null = null const wslInfo = parseWslPath(authorizedRootPath) @@ -1902,11 +1845,13 @@ export class RuntimeFileCommands { child = nextChild this.activeRuntimeTextSearches.set(searchKey, nextChild) - const onStdoutData = (chunk: Buffer): void => { - if (!stdoutLines.push(chunk, processLine)) { - acc.truncated = true - child?.kill() - resolveOnce() + nextChild.stdout!.setEncoding('utf-8') + const onStdoutData = (chunk: string): void => { + stdoutBuffer += chunk + const lines = stdoutBuffer.split('\n') + stdoutBuffer = lines.pop() ?? '' + for (const line of lines) { + processLine(line) } } const onStderrData = (): void => { @@ -1914,9 +1859,8 @@ export class RuntimeFileCommands { } const onError = (): void => resolveOnce() const onClose = (): void => { - const trailingLine = stdoutLines.finish() - if (trailingLine !== null) { - processLine(trailingLine) + if (stdoutBuffer) { + processLine(stdoutBuffer) } resolveOnce() } @@ -2115,45 +2059,6 @@ async function isRuntimeDirectoryEntry( return false } -type RuntimeDirectorySourceEntry = { - name: string - isDirectory(): boolean - isSymbolicLink(): boolean -} - -type RuntimeDirectoryClassifier = ( - entry: RuntimeDirectorySourceEntry, - entryPath: string -) => Promise - -export async function classifyRuntimeMobileDirectoryEntries( - dirPath: string, - entries: readonly RuntimeDirectorySourceEntry[], - classify: RuntimeDirectoryClassifier = isRuntimeDirectoryEntry -): Promise { - const mapped: DirEntry[] = [] - for ( - let offset = 0; - offset < entries.length; - offset += RUNTIME_MOBILE_DIRECTORY_CLASSIFICATION_CONCURRENCY - ) { - const batch = entries.slice( - offset, - offset + RUNTIME_MOBILE_DIRECTORY_CLASSIFICATION_CONCURRENCY - ) - mapped.push( - ...(await Promise.all( - batch.map(async (entry) => ({ - name: entry.name, - isDirectory: await classify(entry, join(dirPath, entry.name)), - isSymlink: entry.isSymbolicLink() - })) - )) - ) - } - return mapped -} - function isBinaryBuffer(buffer: Buffer): boolean { const len = Math.min(buffer.length, 8192) for (let i = 0; i < len; i += 1) { @@ -2243,13 +2148,11 @@ async function readLocalTerminalArtifactPreviewFromHandle( handle, RUNTIME_PREVIEWABLE_BINARY_MAX_BYTES + 1 ) - const imageDimensions = assertRasterImagePreviewWithinLimits(buffer, mimeType) return { content: buffer.toString('base64'), isBinary: true, isImage: true, - mimeType, - ...(imageDimensions ? { imageDimensions } : {}) + mimeType } } diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index e982b9bb080..af1344d62a8 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -114,7 +114,6 @@ import { const ORIGINAL_PLATFORM = process.platform const ORIGINAL_PLATFORM_DESCRIPTOR = Object.getOwnPropertyDescriptor(process, 'platform') -const TEST_MAX_HOOK_GITIGNORE_BYTES = 4 * 1024 * 1024 const removeWorktreeLinkedPathsMock = vi.hoisted(() => vi.fn()) const findExistingWorktreeSymlinkPathsMock = vi.hoisted(() => vi.fn()) const resolveLocalGitUsernameMock = vi.hoisted(() => vi.fn(async () => '')) @@ -440,8 +439,6 @@ vi.mock('../hooks', () => ({ ORCA_ROOT_PATH: '/remote/repo', ORCA_WORKTREE_PATH: worktreePath }), - MAX_HOOK_GITIGNORE_BYTES: 4 * 1024 * 1024, - MAX_ISSUE_COMMAND_BYTES: 1024 * 1024, loadHooks: vi.fn().mockReturnValue(null), runHook: vi.fn().mockResolvedValue({ success: true, output: '' }), shouldRunSetupForCreate: vi @@ -3281,10 +3278,7 @@ describe('OrcaRuntimeService', () => { expect(listWorktrees).not.toHaveBeenCalled() expect(gitProvider.listWorktrees).toHaveBeenCalledWith('//Server/Share/Repo') - expect(fsProvider.readDir).toHaveBeenCalledWith('\\\\Server\\Share\\Repo\\src', { - maxEntries: 10_000, - maxRetainedBytes: 4 * 1024 * 1024 - }) + expect(fsProvider.readDir).toHaveBeenCalledWith('\\\\Server\\Share\\Repo\\src') expect(gitProvider.getStatus).toHaveBeenCalledWith('//Server/Share/Repo') }) @@ -3351,10 +3345,7 @@ describe('OrcaRuntimeService', () => { } expect(fsProvider.stat).toHaveBeenCalledWith(folderPath) - expect(fsProvider.readDir).toHaveBeenCalledWith('/srv/platform/src', { - maxEntries: 10_000, - maxRetainedBytes: 4 * 1024 * 1024 - }) + expect(fsProvider.readDir).toHaveBeenCalledWith('/srv/platform/src') expect(fsProvider.stat).toHaveBeenCalledWith('/srv/platform/src/app.ts') expect(fsProvider.readFile).toHaveBeenCalledWith('/srv/platform/src/app.ts') }) @@ -5226,12 +5217,6 @@ describe('OrcaRuntimeService', () => { listWorktrees: vi.fn().mockResolvedValue([created]) } const fsProvider = { - stat: vi.fn(async (filePath: string) => { - if (filePath.endsWith('orca.yaml')) { - return { size: 7, type: 'file', mtime: 0 } - } - throw Object.assign(new Error('missing'), { code: 'ENOENT' }) - }), readFile: vi.fn().mockResolvedValue({ isBinary: false, content: 'hooks:\n' }), createDir: vi.fn().mockResolvedValue(undefined), writeFile: vi.fn().mockResolvedValue(undefined) @@ -5389,12 +5374,6 @@ describe('OrcaRuntimeService', () => { listWorktrees: vi.fn().mockResolvedValue([created]) } const fsProvider = { - stat: vi.fn(async (filePath: string) => { - if (filePath.endsWith('orca.yaml')) { - return { size: 7, type: 'file', mtime: 0 } - } - throw Object.assign(new Error('missing'), { code: 'ENOENT' }) - }), readFile: vi.fn().mockResolvedValue({ isBinary: false, content: 'hooks:\n' }), createDir: vi.fn().mockResolvedValue(undefined), writeFile: vi.fn().mockResolvedValue(undefined) @@ -5607,7 +5586,6 @@ describe('OrcaRuntimeService', () => { ] } const fsProvider = { - stat: vi.fn().mockResolvedValue({ size: 32, type: 'file', mtime: 0 }), readFile: vi.fn().mockResolvedValue({ content: 'scripts:\n setup: pnpm install\n', isBinary: false @@ -5684,7 +5662,6 @@ describe('OrcaRuntimeService', () => { ] } const fsProvider = { - stat: vi.fn().mockResolvedValue({ size: 32, type: 'file', mtime: 0 }), readFile: vi.fn(async (filePath: string) => ({ content: filePath.endsWith('orca.yaml') ? 'scripts:\n setup: pnpm install\n' @@ -5730,49 +5707,6 @@ describe('OrcaRuntimeService', () => { ) }) - it('does not materialize an oversized SSH .gitignore while writing an issue command', async () => { - const remoteStore = { - ...store, - getRepos: () => [ - { - id: TEST_REPO_ID, - path: '/remote/repo', - displayName: 'repo', - badgeColor: 'blue', - addedAt: 1, - connectionId: 'ssh-1' - } - ] - } - const fsProvider = { - stat: vi.fn().mockResolvedValue({ - size: TEST_MAX_HOOK_GITIGNORE_BYTES + 1, - type: 'file', - mtime: 0 - }), - readFile: vi.fn(), - writeFile: vi.fn().mockResolvedValue(undefined), - createDir: vi.fn().mockResolvedValue(undefined) - } - registerSshFilesystemProvider('ssh-1', fsProvider as never) - const runtime = new OrcaRuntimeService(remoteStore as never) - - try { - await expect(runtime.writeRepoIssueCommand('id:repo-1', 'Ship it')).resolves.toEqual({ - ok: true - }) - } finally { - unregisterSshFilesystemProvider('ssh-1') - } - - expect(fsProvider.readFile).not.toHaveBeenCalled() - expect(fsProvider.writeFile).toHaveBeenCalledOnce() - expect(fsProvider.writeFile).toHaveBeenCalledWith( - '/remote/repo/.orca/issue-command', - 'Ship it\n' - ) - }) - it('resolves SSH issue commands from shared orca.yaml and deletes empty overrides', async () => { const remoteStore = { ...store, @@ -5792,7 +5726,6 @@ describe('OrcaRuntimeService', () => { issueCommand: 'claude -p "Fix #{{issue}}"' }) const fsProvider = { - stat: vi.fn().mockResolvedValue({ size: 48, type: 'file', mtime: 0 }), readFile: vi.fn(async (filePath: string) => { if (filePath.endsWith('.orca/issue-command')) { throw Object.assign(new Error('missing'), { code: 'ENOENT' }) @@ -30768,42 +30701,6 @@ describe('OrcaRuntimeService', () => { expect(listWorktrees).toHaveBeenCalledTimes(15) }) - it('bounds resolved worktree scanning to a fixed repo worker pool', async () => { - vi.mocked(listWorktrees).mockReset() - const repos = Array.from({ length: 40 }, (_, index) => ({ - id: `repo-${index}`, - path: `/tmp/repo-${index}`, - displayName: `repo-${index}`, - badgeColor: 'blue' as const, - addedAt: 1 - })) - const gate = deferred() - let inFlight = 0 - let peak = 0 - vi.mocked(listWorktrees).mockImplementation(async (repoPath) => { - inFlight += 1 - peak = Math.max(peak, inFlight) - await gate.promise - inFlight -= 1 - return [makeWorktreeInfo(repoPath)] - }) - const runtime = new OrcaRuntimeService({ - ...store, - getRepos: () => repos, - getRepo: (id: string) => repos.find((candidate) => candidate.id === id), - getAllWorktreeMeta: () => ({}), - getWorktreeMeta: () => undefined - } as never) - - const listing = runtime.listManagedWorktrees() - await vi.waitFor(() => expect(listWorktrees).toHaveBeenCalledTimes(8)) - expect(peak).toBe(8) - gate.resolve() - await expect(listing).resolves.toMatchObject({ totalCount: repos.length }) - expect(listWorktrees).toHaveBeenCalledTimes(repos.length) - expect(peak).toBe(8) - }) - it('worktree scan cache: shares one in-flight repo scan across concurrent consumers', async () => { vi.mocked(listWorktrees).mockClear() const pending = deferred[]>() diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 396e77dc46c..05cc5770d16 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -91,12 +91,11 @@ import { GIT_FETCH_SKIP_AUTO_MAINTENANCE_CONFIG_ARGS } from '../../shared/git-fe import { createHash, randomUUID } from 'node:crypto' import { homedir } from 'node:os' import { isAbsolute, join, resolve } from 'node:path' -import { mkdir, opendir, rm, stat } from 'node:fs/promises' +import { mkdir, readFile, readdir, rm, stat } from 'node:fs/promises' import { resolveWorktreeCreateBase } from '../worktree-create-base' import { resolveWorktreeAddBaseRef } from '../../shared/worktree-base-ref' import { OrchestrationDb } from './orchestration/db' import { formatMessagesForInjection } from './orchestration/formatter' -import { OrchestrationMessageWaiterRegistry } from './orchestration-message-waiter-registry' import type { Automation, AutomationCreateInput, @@ -190,11 +189,6 @@ import { } from '../../shared/runtime-navigation' import type { SshConnectionState } from '../../shared/ssh-types' import { getPublicSshState } from './public-ssh-state' -import { - RuntimeSshRelayRecoveryGenerations, - type RuntimeSshRelayRecoveryGenerationLease -} from './runtime-ssh-relay-recovery-generations' -import { RuntimeOperationGenerations } from './runtime-operation-generations' import { closeTerminalTabInWorkspaceSession } from '../../shared/workspace-session-terminal-tab-close' import type { LinearCurrentIssueContextHints, @@ -247,7 +241,6 @@ import { import { parsePtySessionId } from '../../shared/pty-session-id-format' import { clampLinearIssueListLimit } from '../../shared/linear-issue-read-limits' import { isFolderRepo } from '../../shared/repo-kind' -import { mapWithConcurrency } from '../../shared/map-with-concurrency' import { DEFAULT_WORKSPACE_STATUS_ID } from '../../shared/workspace-statuses' import { buildSetupRunnerCommand, @@ -566,14 +559,6 @@ import type { PRRefreshOutcome } from '../../shared/types' import { inspectSetupScriptImportCandidates } from '../../shared/setup-script-imports' -import { - readSetupScriptImportFile, - SETUP_SCRIPT_IMPORT_FILE_MAX_BYTES, - SETUP_SCRIPT_IMPORT_MAX_CODE_UNITS -} from '../setup-script-import-file' -import { MAX_ORCA_YAML_BYTES, MAX_ORCA_YAML_CODE_UNITS } from '../../shared/orca-yaml-file-limit' -import { readFilesystemProviderBoundedText } from '../filesystem-provider-bounded-text' -import { readLocalFilesystemDirectory } from '../ipc/filesystem-directory-reader' import type { CreateHostedReviewInput, CreateHostedReviewResult, @@ -776,8 +761,6 @@ import { hasUnrecognizedOrcaYamlKeys, hasHooksFile, loadHooks, - MAX_HOOK_GITIGNORE_BYTES, - MAX_ISSUE_COMMAND_BYTES, parseOrcaYaml, readIssueCommand, runHook, @@ -1675,6 +1658,14 @@ type TerminalWaiter = { abortCleanup: (() => void) | null } +type MessageWaiter = { + handle: string + typeFilter: string[] | undefined + resolve: (result: void) => void + timeout: NodeJS.Timeout | null + abortCleanup: (() => void) | null +} + function omitUndefinedProperties>(value: T): Partial { return Object.fromEntries( Object.entries(value).filter(([, entry]) => entry !== undefined) @@ -2499,7 +2490,7 @@ export class OrcaRuntimeService { private resolvedWorktreeCache: ResolvedWorktreeCache | null = null private resolvedWorktreeInFlight: ResolvedWorktreeInFlight | null = null private resolvedWorktreeGeneration = 0 - private readonly worktreeScanGenerations = new RuntimeOperationGenerations() + private worktreeScanGenerations = new Map() private worktreeScanCache = new Map() private worktreeScanInFlight = new Map() private cloneInFlightByPath = new Map>() @@ -2507,7 +2498,7 @@ export class OrcaRuntimeService { private ptyForegroundAgentRefreshes = new Map() private ptyDelayedForegroundSnapshotTitleObservations = new Map() private _orchestrationDb: OrchestrationDb | null = null - private readonly messageWaiters = new OrchestrationMessageWaiterRegistry() + private messageWaitersByHandle = new Map>() // Why: mobile clients subscribe to terminal output via terminal.subscribe. // These listeners fire on every onPtyData call, enabling real-time streaming // without polling. Keyed by ptyId for O(1) lookup per data event. @@ -2578,7 +2569,8 @@ export class OrcaRuntimeService { private providerVisibleStateByPtyId = new Map() private providerVisibleRetryAtByPtyId = new Map() private providerSnapshotsWithLiveModeTransition = new WeakSet() - private readonly ptyLifecycleGenerations = new RuntimeOperationGenerations() + private ptyLifecycleGenerationById = new Map() + private nextPtyLifecycleGeneration = 1 private recentPtyPathCandidatesById = new Map() // Why: candidates only feed mobile file-tap provenance; desktop-only // sessions skip the 3-regex extraction on every PTY chunk until a @@ -2878,7 +2870,7 @@ export class OrcaRuntimeService { | null private readonly agentSessionClaimSigner: AgentSessionClaimSigner private readonly agentSessionCreateOperations = new Map() - private readonly sshRelayRecoveryGenerations = new RuntimeSshRelayRecoveryGenerations() + private sshRelayRecoveryGenerationByTargetId = new Map() private accountServices: RuntimeAccountServices | null = null private commitMessageAgentEnv: CommitMessageAgentEnvironmentResolvers | null = null private automationService: AutomationService | null = null @@ -3575,32 +3567,33 @@ export class OrcaRuntimeService { // Why: SSH state changes originate in main's ssh handlers, not in runtime // methods, so they need a public entry point onto the client-event stream. notifySshStateChanged(targetId: string, state: SshConnectionState): void { - this.sshRelayRecoveryGenerations.invalidate(targetId) + this.bumpSshRelayRecoveryGeneration(targetId) this.invalidateSshWorktreeScanCache(targetId) this.emitClientEvent({ type: 'sshStateChanged', targetId, state: getPublicSshState(state)! }) } notifySshRelayReady(targetId: string): void { - const generationLease = this.sshRelayRecoveryGenerations.begin(targetId) - if (!generationLease) { - return - } - void this.publishRecoveredSshMobileSessionTabs(targetId, generationLease) - .catch((error) => { - if (!generationLease.isCurrent()) { - return - } - console.warn('[runtime] failed to publish recovered SSH session tabs', { - targetId, - error - }) + const generation = this.bumpSshRelayRecoveryGeneration(targetId) + void this.publishRecoveredSshMobileSessionTabs(targetId, generation).catch((error) => { + if (this.sshRelayRecoveryGenerationByTargetId.get(targetId) !== generation) { + return + } + console.warn('[runtime] failed to publish recovered SSH session tabs', { + targetId, + error }) - .finally(() => generationLease.release()) + }) + } + + private bumpSshRelayRecoveryGeneration(targetId: string): number { + const generation = (this.sshRelayRecoveryGenerationByTargetId.get(targetId) ?? 0) + 1 + this.sshRelayRecoveryGenerationByTargetId.set(targetId, generation) + return generation } private async publishRecoveredSshMobileSessionTabs( targetId: string, - generationLease: RuntimeSshRelayRecoveryGenerationLease + generation: number ): Promise { const repoIds = new Set( (this.store?.getRepos() ?? []) @@ -3632,7 +3625,7 @@ export class OrcaRuntimeService { }) } await this.refreshMobileSessionPtyRecords() - if (!generationLease.isCurrent()) { + if (this.sshRelayRecoveryGenerationByTargetId.get(targetId) !== generation) { return } for (const worktreeId of worktreeIds) { @@ -8408,15 +8401,17 @@ export class OrcaRuntimeService { } private getPtyLifecycleGeneration(ptyId: string): number { - return this.ptyLifecycleGenerations.current(ptyId) - } - - private isPtyLifecycleGenerationCurrent(ptyId: string, generation: number): boolean { - return this.ptyLifecycleGenerations.isCurrent(ptyId, generation) + const existing = this.ptyLifecycleGenerationById.get(ptyId) + if (existing !== undefined) { + return existing + } + const generation = this.nextPtyLifecycleGeneration++ + this.ptyLifecycleGenerationById.set(ptyId, generation) + return generation } private advancePtyLifecycleGeneration(ptyId: string): void { - this.ptyLifecycleGenerations.advance(ptyId) + this.ptyLifecycleGenerationById.set(ptyId, this.nextPtyLifecycleGeneration++) // Why: a provider response belongs to the process generation that issued // it; a respawn must neither reuse its frame nor join its in-flight call. this.providerBufferAcquisitionsByPtyId.delete(ptyId) @@ -9268,7 +9263,7 @@ export class OrcaRuntimeService { // Why: daemon PTYs survive an app relaunch before any renderer mounts. // Mobile still needs their retained history without navigating desktop. const snapshot = await this.ptyController?.serializeProviderBuffer?.(ptyId, opts) - if (!snapshot || !this.isPtyLifecycleGenerationCurrent(ptyId, generation)) { + if (!snapshot || this.getPtyLifecycleGeneration(ptyId) !== generation) { return null } const snapshotModeTracker = new TerminalKittyKeyboardModeTracker() @@ -9406,7 +9401,7 @@ export class OrcaRuntimeService { { scrollbackRows: 0 }, { timeoutMs: VISIBLE_TERMINAL_SNAPSHOT_TIMEOUT_MS } ) - if (!snapshot || !this.isPtyLifecycleGenerationCurrent(ptyId, generation)) { + if (!snapshot || this.getPtyLifecycleGeneration(ptyId) !== generation) { this.providerVisibleRetryAtByPtyId.set(ptyId, Date.now() + VISIBLE_TERMINAL_SNAPSHOT_RETRY_MS) return null } @@ -9420,7 +9415,7 @@ export class OrcaRuntimeService { } } const lines = await this.parseVisibleSnapshotLines(snapshot) - if (!this.isPtyLifecycleGenerationCurrent(ptyId, generation)) { + if (this.getPtyLifecycleGeneration(ptyId) !== generation) { return null } const visibleState: RuntimeVisibleTerminalState = { @@ -9446,7 +9441,7 @@ export class OrcaRuntimeService { await state.writeChain if ( this.headlessTerminals.get(ptyId) !== state || - !this.isPtyLifecycleGenerationCurrent(ptyId, generation) + this.getPtyLifecycleGeneration(ptyId) !== generation ) { return null } @@ -9681,15 +9676,16 @@ export class OrcaRuntimeService { // before the first-ever connect no longer yields candidates. for (const [ptyId, buffer] of this.recentPtyOutputById) { let candidates = this.recentPtyPathCandidatesById.get(ptyId) - buffer.forEachRetainedChunk((chunk, index, headChunkIsPartial) => { + const { chunks, headChunkIsPartial } = buffer.retainedChunks() + for (let index = 0; index < chunks.length; index += 1) { if (index === 0 && headChunkIsPartial) { // A pre-sliced over-window chunk was already extracted eagerly at // append time (while its original text was intact); replaying its // truncated remainder would mint or drop candidates spuriously. - return + continue } - candidates = appendRecentPtyPathCandidates(candidates, chunk) - }) + candidates = appendRecentPtyPathCandidates(candidates, chunks[index]!) + } if (candidates) { this.recentPtyPathCandidatesById.set(ptyId, candidates) } @@ -15099,7 +15095,21 @@ export class OrcaRuntimeService { if (!dirStat.isDirectory()) { throw new Error(`${dirPath} is not a directory`) } - return { resolvedPath: dirPath, entries: await readLocalFilesystemDirectory(dirPath) } + const entries = await readdir(dirPath, { withFileTypes: true }) + const mapped = entries + .filter((entry) => entry.name !== '.' && entry.name !== '..') + .map((entry) => ({ + name: entry.name, + isDirectory: entry.isDirectory(), + isSymlink: entry.isSymbolicLink() + })) + mapped.sort((a, b) => { + if (a.isDirectory !== b.isDirectory) { + return a.isDirectory ? -1 : 1 + } + return a.name.localeCompare(b.name) + }) + return { resolvedPath: dirPath, entries: mapped } } async isGitAvailable(): Promise { @@ -15372,13 +15382,9 @@ export class OrcaRuntimeService { if (!existingStat.isDirectory()) { return { error: `"${trimmedName}" already exists at this location and is not a folder.` } } - const directory = await opendir(targetPath) - try { - if ((await directory.read()) !== null) { - return { error: `"${trimmedName}" already exists at this location and is not empty.` } - } - } finally { - await directory.close().catch(() => {}) + const entries = await readdir(targetPath) + if (entries.length > 0) { + return { error: `"${trimmedName}" already exists at this location and is not empty.` } } } else { await mkdir(targetPath, { recursive: false }) @@ -15709,22 +15715,12 @@ export class OrcaRuntimeService { } const repo = await this.resolveRepoSelector(repoSelector) this.store.removeProject(repo.id) - this.notifyRepoStoreChanged(repo.id) - invalidateAuthorizedRootsCache() - return { removed: true } - } - - notifyRepoStoreChanged(repoId: string): void { - const removed = !this.store?.getRepo(repoId) - if (removed) { - this.terminalTopologyRevisionByRepoId.delete(repoId) - } + this.terminalTopologyRevisionByRepoId.delete(repo.id) this.invalidateResolvedWorktreeCache() - this.invalidateWorktreeScanCacheForRepo(repoId) - if (removed) { - this.worktreeScanGenerations.forget(repoId) - } + this.invalidateWorktreeScanCacheForRepo(repo.id) + invalidateAuthorizedRootsCache() this.notifyReposChanged() + return { removed: true } } async inspectTerminalProcess( @@ -17112,12 +17108,8 @@ export class OrcaRuntimeService { } } try { - const result = await readFilesystemProviderBoundedText( - fsProvider, - joinWorktreeRelativePath(repo.path, 'orca.yaml'), - { maxBytes: MAX_ORCA_YAML_BYTES, maxCodeUnits: MAX_ORCA_YAML_CODE_UNITS } - ) - const hooks = result.kind === 'text' ? parseOrcaYaml(result.content) : null + const result = await fsProvider.readFile(joinWorktreeRelativePath(repo.path, 'orca.yaml')) + const hooks = result.isBinary ? null : parseOrcaYaml(result.content) return { hasHooksFile: Boolean(hooks), hooks, @@ -17165,19 +17157,11 @@ export class OrcaRuntimeService { return { hasHooks: false, hooks: null, mayNeedUpdate: false } } try { - const result = await readFilesystemProviderBoundedText( - fsProvider, - joinWorktreeRelativePath(repo.path, 'orca.yaml'), - { maxBytes: MAX_ORCA_YAML_BYTES, maxCodeUnits: MAX_ORCA_YAML_CODE_UNITS } - ) - if (result.kind === 'binary') { + const result = await fsProvider.readFile(joinWorktreeRelativePath(repo.path, 'orca.yaml')) + if (result.isBinary) { return { hasHooks: false, hooks: null, mayNeedUpdate: false } } - return { - hasHooks: true, - hooks: result.kind === 'text' ? parseOrcaYaml(result.content) : null, - mayNeedUpdate: false - } + return { hasHooks: true, hooks: parseOrcaYaml(result.content), mayNeedUpdate: false } } catch { return { hasHooks: false, hooks: null, mayNeedUpdate: false } } @@ -17206,18 +17190,15 @@ export class OrcaRuntimeService { return null } try { - const result = await readFilesystemProviderBoundedText(fsProvider, filePath, { - maxBytes: SETUP_SCRIPT_IMPORT_FILE_MAX_BYTES, - maxCodeUnits: SETUP_SCRIPT_IMPORT_MAX_CODE_UNITS - }) - return result.kind === 'text' ? result.content : null + const result = await fsProvider.readFile(filePath) + return result.isBinary ? null : result.content } catch { return null } } try { - return await readSetupScriptImportFile(filePath) + return await readFile(filePath, 'utf-8') } catch (error) { if (!isENOENT(error)) { console.warn('[runtime] Failed to inspect setup script import candidate:', error) @@ -17275,11 +17256,11 @@ export class OrcaRuntimeService { issueCommandPath: string ): Promise { try { - const result = await readFilesystemProviderBoundedText(fsProvider, issueCommandPath, { - maxBytes: MAX_ISSUE_COMMAND_BYTES, - maxCodeUnits: MAX_ISSUE_COMMAND_BYTES - }) - return result.kind === 'text' ? result.content.trim() || null : null + const result = await fsProvider.readFile(issueCommandPath) + if (result.isBinary) { + return null + } + return result.content.trim() || null } catch { return null } @@ -17290,12 +17271,8 @@ export class OrcaRuntimeService { repoPath: string ): Promise { try { - const result = await readFilesystemProviderBoundedText( - fsProvider, - joinWorktreeRelativePath(repoPath, 'orca.yaml'), - { maxBytes: MAX_ORCA_YAML_BYTES, maxCodeUnits: MAX_ORCA_YAML_CODE_UNITS } - ) - if (result.kind !== 'text') { + const result = await fsProvider.readFile(joinWorktreeRelativePath(repoPath, 'orca.yaml')) + if (result.isBinary) { return null } return parseOrcaYaml(result.content)?.issueCommand?.trim() || null @@ -17341,12 +17318,9 @@ export class OrcaRuntimeService { options: { required?: boolean } = {} ): Promise { const gitignorePath = joinWorktreeRelativePath(repoPath, '.gitignore') - let result: Awaited> + let result: Awaited> try { - result = await readFilesystemProviderBoundedText(fsProvider, gitignorePath, { - maxBytes: MAX_HOOK_GITIGNORE_BYTES, - maxCodeUnits: MAX_HOOK_GITIGNORE_BYTES - }) + result = await fsProvider.readFile(gitignorePath) } catch (error) { if (!isENOENT(error)) { if (options.required) { @@ -17365,13 +17339,9 @@ export class OrcaRuntimeService { } return } - if (result.kind !== 'text') { + if (result.isBinary) { if (options.required) { - throw new Error( - result.kind === 'binary' - ? 'Remote .gitignore is binary; cannot verify .orca is ignored' - : 'Remote .gitignore exceeds the supported size limit' - ) + throw new Error('Remote .gitignore is binary; cannot verify .orca is ignored') } return } @@ -24626,10 +24596,8 @@ export class OrcaRuntimeService { getAgentLaunchPlatformForRepo(repo, projectRuntimeByRepoId.get(repo.id)) ]) ) - const perRepoWorktrees = await mapWithConcurrency( - repos, - RESOLVED_WORKTREE_REPO_CONCURRENCY, - async (repo) => { + const perRepoWorktrees = await Promise.all( + repos.map(async (repo) => { if (isFolderRepo(repo)) { return listRuntimeFolderWorkspaces(this.requireStore(), repo).map((worktree) => ({ ...worktree, @@ -24686,7 +24654,7 @@ export class OrcaRuntimeService { comment: merged.comment } }) - } + }) ) const worktrees = projectResolvedWorktreeLineage( perRepoWorktrees.flat(), @@ -24750,7 +24718,7 @@ export class OrcaRuntimeService { projectRuntimeByRepoId?: ReadonlyMap ): Promise { const now = Date.now() - const generation = this.worktreeScanGenerations.current(repo.id) + const generation = this.worktreeScanGenerations.get(repo.id) ?? 0 const projectRuntime = projectRuntimeByRepoId ? projectRuntimeByRepoId.get(repo.id) : !repo.connectionId @@ -24781,7 +24749,7 @@ export class OrcaRuntimeService { const result = await promise if ( result.ok && - this.worktreeScanGenerations.isCurrent(repo.id, generation) && + generation === (this.worktreeScanGenerations.get(repo.id) ?? 0) && this.worktreeScanInFlight.get(repo.id)?.promise === promise ) { this.worktreeScanCache.set(repo.id, { @@ -24861,7 +24829,7 @@ export class OrcaRuntimeService { } private invalidateWorktreeScanCacheForRepo(repoId: string): void { - this.worktreeScanGenerations.advance(repoId) + this.worktreeScanGenerations.set(repoId, (this.worktreeScanGenerations.get(repoId) ?? 0) + 1) this.worktreeScanCache.delete(repoId) this.worktreeScanInFlight.delete(repoId) } @@ -24872,7 +24840,7 @@ export class OrcaRuntimeService { repos.filter((repo) => repo.connectionId === targetId).map((repo) => repo.id) ) for (const repoId of affectedRepoIds) { - this.worktreeScanGenerations.advance(repoId) + this.worktreeScanGenerations.set(repoId, (this.worktreeScanGenerations.get(repoId) ?? 0) + 1) this.worktreeScanCache.delete(repoId) this.worktreeScanInFlight.delete(repoId) } @@ -25350,7 +25318,6 @@ export class OrcaRuntimeService { this.terminalFileUriHostnameByPtyId.delete(ptyId) this.wslDistroByPtyId.delete(ptyId) this.clearAgentRowSnapshotsForPty(ptyId) - this.ptyLifecycleGenerations.forget(ptyId) const handle = this.handleByPtyId.get(ptyId) if (handle) { // Why: pruning can remove a PTY without onPtyExit firing; release this leader's agent team so it doesn't leak. @@ -26693,14 +26660,84 @@ export class OrcaRuntimeService { // Why: wake blocking orchestration.check --wait calls on this handle so they return the new message immediately instead of polling. notifyMessageArrived(handle: string, messageType?: string): void { - this.messageWaiters.notify(handle, messageType) + const waiters = this.messageWaitersByHandle.get(handle) + if (!waiters || waiters.size === 0) { + return + } + for (const waiter of [...waiters]) { + // Why: don't wake a coordinator waiting for worker_done/escalation on heartbeat noise it would misread as idleness. + if (messageType && waiter.typeFilter && !waiter.typeFilter.includes(messageType)) { + continue + } + this.resolveMessageWaiter(waiter) + } } waitForMessage( handle: string, options?: { typeFilter?: string[]; timeoutMs?: number; signal?: AbortSignal } ): Promise { - return this.messageWaiters.wait(handle, options) + return new Promise((resolve) => { + const timeoutMs = options?.timeoutMs ?? MESSAGE_WAIT_DEFAULT_TIMEOUT_MS + + const waiter: MessageWaiter = { + handle, + typeFilter: options?.typeFilter, + resolve, + timeout: null, + abortCleanup: null + } + + // Why: on caller abort (RPC socket closed — design doc §3.1), resolve now to release the long-poll slot instead of waiting out timeoutMs. + const signal = options?.signal + const onAbort = (): void => { + this.removeMessageWaiter(waiter) + resolve() + } + if (signal) { + if (signal.aborted) { + resolve() + return + } + waiter.abortCleanup = () => signal.removeEventListener('abort', onAbort) + signal.addEventListener('abort', onAbort, { once: true }) + } + + waiter.timeout = setTimeout(() => { + this.removeMessageWaiter(waiter) + resolve() + }, timeoutMs) + + let waiters = this.messageWaitersByHandle.get(handle) + if (!waiters) { + waiters = new Set() + this.messageWaitersByHandle.set(handle, waiters) + } + waiters.add(waiter) + }) + } + + private resolveMessageWaiter(waiter: MessageWaiter): void { + this.removeMessageWaiter(waiter) + waiter.resolve() + } + + private removeMessageWaiter(waiter: MessageWaiter): void { + if (waiter.timeout) { + clearTimeout(waiter.timeout) + waiter.timeout = null + } + if (waiter.abortCleanup) { + waiter.abortCleanup() + waiter.abortCleanup = null + } + const waiters = this.messageWaitersByHandle.get(waiter.handle) + if (waiters) { + waiters.delete(waiter) + if (waiters.size === 0) { + this.messageWaitersByHandle.delete(waiter.handle) + } + } } private buildPtyTerminalSummary( @@ -30213,7 +30250,6 @@ const WORKTREE_SCAN_CACHE_TTL_MS = 30_000 // these (crash-cluster diagnostics, 2026-07). const WORKTREE_SCAN_AGENT_SCRATCH_TTL_MS = 5 * 60_000 const RESOLVED_WORKTREE_REPO_TIMEOUT_MS = 5000 -const RESOLVED_WORKTREE_REPO_CONCURRENCY = 8 export function resolveWorktreeScanCacheTtlMs(repo: Pick): number { return !repo.connectionId && isAgentScratchRepoRootPath(repo.path) @@ -31660,6 +31696,7 @@ async function assertTerminalInputWithinLimitWithYield(text: string | undefined) const TUI_IDLE_DEFAULT_TIMEOUT_MS = 5 * 60 * 1000 const TUI_IDLE_POLL_INTERVAL_MS = 2000 const TUI_IDLE_QUIESCENCE_MS = 3000 +const MESSAGE_WAIT_DEFAULT_TIMEOUT_MS = 2 * 60 * 1000 const EXPLICIT_IDLE_TITLE_RE = /(^|\s)(ready|idle|done)(\s|$|[.!?])/i const CLAUDE_IDLE_PREFIX = '\u2733' const GEMINI_IDLE_PREFIX = '\u25c7' diff --git a/src/main/runtime/orchestration-message-waiter-registry.test.ts b/src/main/runtime/orchestration-message-waiter-registry.test.ts deleted file mode 100644 index ee8ed7afb70..00000000000 --- a/src/main/runtime/orchestration-message-waiter-registry.test.ts +++ /dev/null @@ -1,235 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { - MAX_ORCHESTRATION_MESSAGE_WAITERS, - MAX_ORCHESTRATION_MESSAGE_WAITERS_PER_HANDLE, - MAX_ORCHESTRATION_MESSAGE_WAITER_HANDLE_BYTES, - MAX_ORCHESTRATION_MESSAGE_WAITER_RETAINED_HANDLE_BYTES, - OrchestrationMessageWaiterLimitError, - OrchestrationMessageWaiterRegistry -} from './orchestration-message-waiter-registry' - -const BOUNDS = { - maxWaiters: 3, - maxWaitersPerHandle: 2, - maxHandleBytes: 8, - maxRetainedHandleBytes: 10 -} - -describe('OrchestrationMessageWaiterRegistry', () => { - afterEach(() => { - vi.useRealTimers() - }) - - it('caps global waiters and admits again after settlement', async () => { - const registry = new OrchestrationMessageWaiterRegistry(BOUNDS) - const first = registry.wait('a') - const second = registry.wait('b') - const third = registry.wait('c') - - expect(() => registry.wait('d')).toThrowError(expect.objectContaining({ reason: 'global' })) - registry.notify('a') - await first - const replacement = registry.wait('d') - expect(registry.evidence()).toEqual({ - waiters: 3, - handles: 3, - retainedHandleBytes: 3 - }) - - registry.close() - await Promise.all([second, third, replacement]) - }) - - it('caps waiters per handle without blocking another handle', async () => { - const registry = new OrchestrationMessageWaiterRegistry(BOUNDS) - const first = registry.wait('same') - const second = registry.wait('same') - - expect(() => registry.wait('same')).toThrowError( - expect.objectContaining({ reason: 'per-handle' }) - ) - const other = registry.wait('other') - expect(registry.evidence()).toEqual({ - waiters: 3, - handles: 2, - retainedHandleBytes: 9 - }) - - registry.close() - await Promise.all([first, second, other]) - }) - - it('rejects oversized handles without retaining them', async () => { - const registry = new OrchestrationMessageWaiterRegistry(BOUNDS) - - expect(() => registry.wait('🌊🌊🌊')).toThrow(OrchestrationMessageWaiterLimitError) - expect(() => registry.wait('🌊🌊🌊')).toThrowError( - expect.objectContaining({ reason: 'handle-bytes' }) - ) - expect(registry.evidence()).toEqual({ - waiters: 0, - handles: 0, - retainedHandleBytes: 0 - }) - - const controller = new AbortController() - controller.abort() - await expect(registry.wait('🌊🌊🌊', { signal: controller.signal })).resolves.toBeUndefined() - }) - - it('accounts for each unique handle key once and caps aggregate key bytes', async () => { - const registry = new OrchestrationMessageWaiterRegistry(BOUNDS) - const first = registry.wait('123456') - const sameHandle = registry.wait('123456') - const second = registry.wait('abcd') - - expect(registry.evidence()).toEqual({ - waiters: 3, - handles: 2, - retainedHandleBytes: 10 - }) - registry.notify('abcd') - await second - expect(() => registry.wait('abcde')).toThrowError( - expect.objectContaining({ reason: 'retained-handle-bytes' }) - ) - - registry.close() - await Promise.all([first, sameHandle]) - }) - - it('releases all key accounting under unique-handle churn', async () => { - const registry = new OrchestrationMessageWaiterRegistry(BOUNDS) - - for (let index = 0; index < 10_000; index += 1) { - const handle = String(index) - const wait = registry.wait(handle) - registry.notify(handle) - await wait - } - - expect(registry.evidence()).toEqual({ - waiters: 0, - handles: 0, - retainedHandleBytes: 0 - }) - }) - - it('preserves type-filter wake behavior and deduplicates filters', async () => { - const registry = new OrchestrationMessageWaiterRegistry(BOUNDS) - let statusResolved = false - let workerResolved = false - const status = registry.wait('same', { - typeFilter: ['status', 'status'] - }) - const worker = registry.wait('same', { - typeFilter: ['worker_done'] - }) - void status.then(() => { - statusResolved = true - }) - void worker.then(() => { - workerResolved = true - }) - - expect(registry.notify('same', 'status')).toBe(1) - await status - expect(statusResolved).toBe(true) - expect(workerResolved).toBe(false) - expect(registry.evidence()).toEqual({ - waiters: 1, - handles: 1, - retainedHandleBytes: 4 - }) - - expect(registry.notify('same')).toBe(1) - await worker - }) - - it('removes timeout and abort state after notification', async () => { - vi.useFakeTimers() - const registry = new OrchestrationMessageWaiterRegistry(BOUNDS) - const controller = new AbortController() - const removeListener = vi.spyOn(controller.signal, 'removeEventListener') - const wait = registry.wait('a', { signal: controller.signal, timeoutMs: 100 }) - - registry.notify('a') - await wait - expect(removeListener).toHaveBeenCalledWith('abort', expect.any(Function)) - expect(vi.getTimerCount()).toBe(0) - expect(registry.evidence()).toEqual({ - waiters: 0, - handles: 0, - retainedHandleBytes: 0 - }) - }) - - it('cleans up exactly on timeout', async () => { - vi.useFakeTimers() - const registry = new OrchestrationMessageWaiterRegistry(BOUNDS) - const controller = new AbortController() - const removeListener = vi.spyOn(controller.signal, 'removeEventListener') - const wait = registry.wait('abc', { signal: controller.signal, timeoutMs: 100 }) - - await vi.advanceTimersByTimeAsync(100) - await wait - expect(removeListener).toHaveBeenCalledWith('abort', expect.any(Function)) - expect(registry.evidence()).toEqual({ - waiters: 0, - handles: 0, - retainedHandleBytes: 0 - }) - }) - - it('cleans up exactly on abort and does not register an already-aborted signal', async () => { - const registry = new OrchestrationMessageWaiterRegistry(BOUNDS) - const controller = new AbortController() - const removeListener = vi.spyOn(controller.signal, 'removeEventListener') - const wait = registry.wait('abc', { signal: controller.signal }) - - controller.abort() - await wait - expect(removeListener).toHaveBeenCalledWith('abort', expect.any(Function)) - expect(registry.evidence()).toEqual({ - waiters: 0, - handles: 0, - retainedHandleBytes: 0 - }) - - const alreadyAborted = new AbortController() - alreadyAborted.abort() - const addListener = vi.spyOn(alreadyAborted.signal, 'addEventListener') - await registry.wait('abc', { signal: alreadyAborted.signal }) - expect(addListener).not.toHaveBeenCalled() - expect(registry.evidence().waiters).toBe(0) - }) - - it('settles every waiter and releases all accounting on close', async () => { - vi.useFakeTimers() - const registry = new OrchestrationMessageWaiterRegistry(BOUNDS) - const controller = new AbortController() - const removeListener = vi.spyOn(controller.signal, 'removeEventListener') - const first = registry.wait('first') - const sameHandle = registry.wait('first', { signal: controller.signal }) - const second = registry.wait('other') - - registry.close() - await Promise.all([first, sameHandle, second]) - expect(removeListener).toHaveBeenCalledWith('abort', expect.any(Function)) - expect(vi.getTimerCount()).toBe(0) - expect(registry.evidence()).toEqual({ - waiters: 0, - handles: 0, - retainedHandleBytes: 0 - }) - await expect(registry.wait('later')).resolves.toBeUndefined() - expect(registry.evidence().waiters).toBe(0) - }) - - it('publishes explicit production bounds', () => { - expect(MAX_ORCHESTRATION_MESSAGE_WAITERS).toBe(1_024) - expect(MAX_ORCHESTRATION_MESSAGE_WAITERS_PER_HANDLE).toBe(64) - expect(MAX_ORCHESTRATION_MESSAGE_WAITER_HANDLE_BYTES).toBe(64 * 1024) - expect(MAX_ORCHESTRATION_MESSAGE_WAITER_RETAINED_HANDLE_BYTES).toBe(1024 * 1024) - }) -}) diff --git a/src/main/runtime/orchestration-message-waiter-registry.ts b/src/main/runtime/orchestration-message-waiter-registry.ts deleted file mode 100644 index 56f40f9a1e3..00000000000 --- a/src/main/runtime/orchestration-message-waiter-registry.ts +++ /dev/null @@ -1,235 +0,0 @@ -import { measureUtf8ByteLength } from '../../shared/utf8-byte-limits' - -export const MAX_ORCHESTRATION_MESSAGE_WAITERS = 1_024 -export const MAX_ORCHESTRATION_MESSAGE_WAITERS_PER_HANDLE = 64 -export const MAX_ORCHESTRATION_MESSAGE_WAITER_HANDLE_BYTES = 64 * 1024 -export const MAX_ORCHESTRATION_MESSAGE_WAITER_RETAINED_HANDLE_BYTES = 1024 * 1024 - -const DEFAULT_MESSAGE_WAIT_TIMEOUT_MS = 2 * 60 * 1000 - -export type OrchestrationMessageWaiterBounds = { - maxWaiters: number - maxWaitersPerHandle: number - maxHandleBytes: number - maxRetainedHandleBytes: number -} - -type MessageWaiterBucket = { - handle: string - handleBytes: number - waiters: Set -} - -type MessageWaiter = { - bucket: MessageWaiterBucket - typeFilter: Set | undefined - resolve: () => void - timeout: ReturnType | null - signal: AbortSignal | undefined - onAbort: () => void - active: boolean -} - -const DEFAULT_BOUNDS: OrchestrationMessageWaiterBounds = { - maxWaiters: MAX_ORCHESTRATION_MESSAGE_WAITERS, - maxWaitersPerHandle: MAX_ORCHESTRATION_MESSAGE_WAITERS_PER_HANDLE, - maxHandleBytes: MAX_ORCHESTRATION_MESSAGE_WAITER_HANDLE_BYTES, - maxRetainedHandleBytes: MAX_ORCHESTRATION_MESSAGE_WAITER_RETAINED_HANDLE_BYTES -} - -export type OrchestrationMessageWaiterLimitReason = - | 'global' - | 'per-handle' - | 'handle-bytes' - | 'retained-handle-bytes' - -export class OrchestrationMessageWaiterLimitError extends Error { - constructor( - readonly reason: OrchestrationMessageWaiterLimitReason, - readonly limit: number - ) { - super(getLimitErrorMessage(reason, limit)) - this.name = 'OrchestrationMessageWaiterLimitError' - } -} - -export class OrchestrationMessageWaiterRegistry { - private readonly waitersByHandle = new Map() - private waiterCount = 0 - private retainedHandleBytes = 0 - private closed = false - - constructor( - private readonly bounds: OrchestrationMessageWaiterBounds = DEFAULT_BOUNDS, - private readonly defaultTimeoutMs = DEFAULT_MESSAGE_WAIT_TIMEOUT_MS - ) { - if ( - !Number.isSafeInteger(bounds.maxWaiters) || - bounds.maxWaiters < 1 || - !Number.isSafeInteger(bounds.maxWaitersPerHandle) || - bounds.maxWaitersPerHandle < 1 || - !Number.isSafeInteger(bounds.maxHandleBytes) || - bounds.maxHandleBytes < 1 || - !Number.isSafeInteger(bounds.maxRetainedHandleBytes) || - bounds.maxRetainedHandleBytes < 1 - ) { - throw new RangeError('Orchestration message waiter bounds must be positive integers') - } - } - - wait( - handle: string, - options: { typeFilter?: string[]; timeoutMs?: number; signal?: AbortSignal } = {} - ): Promise { - if (options.signal?.aborted || this.closed) { - return Promise.resolve() - } - - const bucket = this.admit(handle) - const typeFilter = options.typeFilter ? new Set(options.typeFilter) : undefined - - return new Promise((resolve) => { - let waiter!: MessageWaiter - const onAbort = (): void => this.settle(waiter) - waiter = { - bucket, - typeFilter, - resolve, - timeout: null, - signal: options.signal, - onAbort, - active: true - } - this.retain(waiter) - options.signal?.addEventListener('abort', onAbort, { once: true }) - if (options.signal?.aborted) { - this.settle(waiter) - return - } - waiter.timeout = setTimeout( - () => this.settle(waiter), - options.timeoutMs ?? this.defaultTimeoutMs - ) - }) - } - - notify(handle: string, messageType?: string): number { - const bucket = this.waitersByHandle.get(handle) - if (!bucket) { - return 0 - } - - let notified = 0 - for (const waiter of Array.from(bucket.waiters)) { - if (messageType && waiter.typeFilter && !waiter.typeFilter.has(messageType)) { - continue - } - this.settle(waiter) - notified += 1 - } - return notified - } - - close(): void { - if (this.closed) { - return - } - this.closed = true - for (const bucket of this.waitersByHandle.values()) { - for (const waiter of bucket.waiters) { - this.settle(waiter) - } - } - } - - evidence(): { waiters: number; handles: number; retainedHandleBytes: number } { - return { - waiters: this.waiterCount, - handles: this.waitersByHandle.size, - retainedHandleBytes: this.retainedHandleBytes - } - } - - private admit(handle: string): MessageWaiterBucket { - const existing = this.waitersByHandle.get(handle) - if (existing) { - if (this.waiterCount >= this.bounds.maxWaiters) { - throw new OrchestrationMessageWaiterLimitError('global', this.bounds.maxWaiters) - } - if (existing.waiters.size >= this.bounds.maxWaitersPerHandle) { - throw new OrchestrationMessageWaiterLimitError( - 'per-handle', - this.bounds.maxWaitersPerHandle - ) - } - return existing - } - - const measurement = measureUtf8ByteLength(handle, { - stopAfterBytes: this.bounds.maxHandleBytes - }) - if (measurement.exceededLimit) { - throw new OrchestrationMessageWaiterLimitError('handle-bytes', this.bounds.maxHandleBytes) - } - if (this.waiterCount >= this.bounds.maxWaiters) { - throw new OrchestrationMessageWaiterLimitError('global', this.bounds.maxWaiters) - } - if (measurement.byteLength > this.bounds.maxRetainedHandleBytes - this.retainedHandleBytes) { - throw new OrchestrationMessageWaiterLimitError( - 'retained-handle-bytes', - this.bounds.maxRetainedHandleBytes - ) - } - return { - handle, - handleBytes: measurement.byteLength, - waiters: new Set() - } - } - - private retain(waiter: MessageWaiter): void { - const { bucket } = waiter - if (bucket.waiters.size === 0) { - this.waitersByHandle.set(bucket.handle, bucket) - this.retainedHandleBytes += bucket.handleBytes - } - bucket.waiters.add(waiter) - this.waiterCount += 1 - } - - private settle(waiter: MessageWaiter): void { - if (!waiter.active) { - return - } - waiter.active = false - if (waiter.timeout !== null) { - clearTimeout(waiter.timeout) - waiter.timeout = null - } - waiter.signal?.removeEventListener('abort', waiter.onAbort) - const { bucket } = waiter - if (bucket.waiters.delete(waiter)) { - this.waiterCount -= 1 - } - if (bucket.waiters.size === 0 && this.waitersByHandle.delete(bucket.handle)) { - this.retainedHandleBytes -= bucket.handleBytes - } - waiter.resolve() - } -} - -function getLimitErrorMessage( - reason: OrchestrationMessageWaiterLimitReason, - limit: number -): string { - if (reason === 'handle-bytes') { - return `Orchestration message wait handle exceeds ${limit} UTF-8 bytes.` - } - if (reason === 'per-handle') { - return `Orchestration message wait capacity reached for this terminal (${limit}); retry shortly.` - } - if (reason === 'retained-handle-bytes') { - return `Orchestration message wait handle capacity reached (${limit} UTF-8 bytes); retry shortly.` - } - return `Orchestration message wait capacity reached (${limit}); retry shortly.` -} diff --git a/src/main/runtime/orchestration/coordinator.ts b/src/main/runtime/orchestration/coordinator.ts index 7c640588387..d0e56b7e40a 100644 --- a/src/main/runtime/orchestration/coordinator.ts +++ b/src/main/runtime/orchestration/coordinator.ts @@ -3,12 +3,6 @@ import type { OrchestrationDb } from './db' import type { MessageRow, TaskRow, CoordinatorStatus } from './types' import { buildDispatchPreamble } from './preamble' import { reconcileLifecycleMessage } from './lifecycle-reconciliation' -import { - orchestrationRowRetainedUtf8Bytes, - ORCHESTRATION_QUERY_MAX_RETAINED_UTF8_BYTES, - ORCHESTRATION_QUERY_MAX_ROWS, - parseOrchestrationJson -} from './query-retention' export type CoordinatorRuntime = { sendTerminalAgentPrompt(handle: string, prompt: string): Promise @@ -72,7 +66,6 @@ type CoordinatorState = { completedTasks: string[] failedTasks: string[] escalations: MessageRow[] - escalationRetainedBytes: number } const DEFAULT_POLL_MS = 2000 @@ -81,23 +74,6 @@ const MAX_CONCURRENT_DEFAULT = 4 // Why: 10 min = documented heartbeat cadence (5 min) × 2, so one missed heartbeat is the earliest a dispatch can look stale. const HUNG_THRESHOLD_MS = 10 * 60 * 1000 -function mergeCoordinatorTaskIds(...sources: string[][]): string[] { - const retained: string[] = [] - const seen = new Set() - for (const source of sources) { - for (const id of source) { - if (!seen.has(id)) { - seen.add(id) - retained.push(id) - if (retained.length >= ORCHESTRATION_QUERY_MAX_ROWS) { - return retained - } - } - } - } - return retained -} - export class Coordinator { private db: OrchestrationDb private runtime: CoordinatorRuntime @@ -124,8 +100,7 @@ export class Coordinator { phase: 'decomposing', completedTasks: [], failedTasks: [], - escalations: [], - escalationRetainedBytes: 0 + escalations: [] } } @@ -177,12 +152,14 @@ export class Coordinator { } // Why: an early stop leaves tasks incomplete, so the run counts as failed. - const counts = this.db.getTaskStatusCounts() - const allDone = counts.total === counts.completed + counts.failed - const failedTasks = mergeCoordinatorTaskIds( - this.state.failedTasks, - this.db.listTaskIdsByStatus('failed') - ) + const tasks = this.db.listTasks() + const allDone = tasks.every((t) => t.status === 'completed' || t.status === 'failed') + const failedTasks = [ + ...new Set([ + ...this.state.failedTasks, + ...tasks.filter((task) => task.status === 'failed').map((task) => task.id) + ]) + ] const finalStatus = this.stopped || failedTasks.length > 0 || !allDone ? 'failed' : 'completed' this.db.updateCoordinatorRun(runId, finalStatus) @@ -208,13 +185,13 @@ export class Coordinator { // Why: decomposition isn't implemented yet — tasks must be pre-created before run(); AI-driven decomposition is a future phase. private async decompose(): Promise { this.state.phase = 'decomposing' - const taskCount = this.db.getTaskStatusCounts().total - if (taskCount === 0) { + const existing = this.db.listTasks() + if (existing.length === 0) { throw new Error( 'No tasks found. Create tasks with orchestration.taskCreate before running the coordinator.' ) } - this.opts.onLog(`Found ${taskCount} tasks in DAG`) + this.opts.onLog(`Found ${existing.length} tasks in DAG`) this.state.phase = 'dispatching' } @@ -230,12 +207,13 @@ export class Coordinator { // Why: warn only, never auto-fail — a false positive (slow but correct worker) costs more than a false negative (hung worker holding a slot); see R6 of DESIGN_DOC_PREAMBLE_FIX.md. private warnStaleDispatches(): void { const thresholdIso = new Date(Date.now() - HUNG_THRESHOLD_MS).toISOString() - this.db.forEachStaleDispatch(thresholdIso, (ctx) => { + const stale = this.db.getStaleDispatches(thresholdIso) + for (const ctx of stale) { const minutes = Math.round(HUNG_THRESHOLD_MS / 60000) this.opts.onLog( `Warning: worker ${ctx.assignee_handle ?? ''} on task ${ctx.task_id} has not sent a heartbeat in ~${minutes} min (dispatch ${ctx.id})` ) - }) + } } private processMessages(): void { @@ -274,10 +252,7 @@ export class Coordinator { private handleLifecycleMessage(msg: MessageRow): void { const result = reconcileLifecycleMessage(this.db, msg, this.opts.onLog) if (result.action === 'completed') { - if ( - this.state.completedTasks.length < ORCHESTRATION_QUERY_MAX_ROWS && - !this.state.completedTasks.includes(result.taskId) - ) { + if (!this.state.completedTasks.includes(result.taskId)) { this.state.completedTasks.push(result.taskId) } } @@ -285,24 +260,13 @@ export class Coordinator { private handleEscalation(msg: MessageRow): void { this.opts.onLog(`Escalation from ${msg.from_handle}: ${msg.subject}`) - const messageBytes = orchestrationRowRetainedUtf8Bytes(msg) - if ( - this.state.escalations.length < ORCHESTRATION_QUERY_MAX_ROWS && - this.state.escalationRetainedBytes + messageBytes <= - ORCHESTRATION_QUERY_MAX_RETAINED_UTF8_BYTES - ) { - this.state.escalations.push(msg) - this.state.escalationRetainedBytes += messageBytes - } + this.state.escalations.push(msg) let taskId: string | undefined if (msg.payload) { try { - const payload = parseOrchestrationJson(msg.payload) - if (payload && typeof payload === 'object') { - const rawTaskId = (payload as { taskId?: unknown }).taskId - taskId = typeof rawTaskId === 'string' ? rawTaskId : undefined - } + const payload = JSON.parse(msg.payload) + taskId = payload.taskId } catch { // Escalation without structured payload — log subject as context } @@ -327,12 +291,7 @@ export class Coordinator { if (updated?.status === 'circuit_broken') { this.opts.onLog(`Task ${taskId} circuit broken after repeated failures`) this.db.updateTaskStatus(taskId, 'failed', `Circuit broken: ${msg.subject}`) - if ( - this.state.failedTasks.length < ORCHESTRATION_QUERY_MAX_ROWS && - !this.state.failedTasks.includes(taskId) - ) { - this.state.failedTasks.push(taskId) - } + this.state.failedTasks.push(taskId) } else { this.opts.onLog(`Task ${taskId} will be retried (failure ${updated?.failure_count ?? 0}/3)`) } @@ -344,10 +303,7 @@ export class Coordinator { let payload: { taskId?: string; question?: string; options?: string[] } = {} if (msg.payload) { try { - const parsed = parseOrchestrationJson(msg.payload) - if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { - payload = parsed as typeof payload - } + payload = JSON.parse(msg.payload) } catch { return } @@ -373,7 +329,14 @@ export class Coordinator { private processDecisionGates(): void { // Why: the coordinator never auto-resolves gates (humans do, via orchestration.gateResolve) — that would defeat them as approval checkpoints. - this.db.restorePendingGateTaskStatuses() + const pendingGates = this.db.listGates({ status: 'pending' }) + for (const gate of pendingGates) { + const task = this.db.getTask(gate.task_id) + if (task && task.status !== 'blocked') { + // Why: gate exists but task isn't blocked — re-block to restore the invariant. + this.db.updateTaskStatus(gate.task_id, 'blocked') + } + } } private async dispatchReadyTasks(): Promise { @@ -383,8 +346,8 @@ export class Coordinator { return } - const dispatchedCount = this.db.getTaskStatusCounts().dispatched - let slotsAvailable = this.opts.maxConcurrent - dispatchedCount + const dispatched = this.db.listTasks({ status: 'dispatched' }) + let slotsAvailable = this.opts.maxConcurrent - dispatched.length if (slotsAvailable <= 0) { return } @@ -474,9 +437,10 @@ export class Coordinator { }) // Why: surface a since-resolved decision gate's outcome to the worker via the preamble. + const gates = this.db.listGates({ taskId: task.id, status: 'resolved' }) let gateContext = '' - const latest = this.db.getLatestGate({ taskId: task.id, status: 'resolved' }) - if (latest) { + if (gates.length > 0) { + const latest = gates.at(-1)! gateContext = `\n\n--- DECISION GATE RESOLVED ---\nQuestion: ${latest.question}\nResolution: ${latest.resolution}\n---\n` } @@ -488,12 +452,7 @@ export class Coordinator { err instanceof Error ? err.message : String(err) ) if (updated?.status === 'circuit_broken') { - if ( - this.state.failedTasks.length < ORCHESTRATION_QUERY_MAX_ROWS && - !this.state.failedTasks.includes(task.id) - ) { - this.state.failedTasks.push(task.id) - } + this.state.failedTasks.push(task.id) } throw err } @@ -505,11 +464,13 @@ export class Coordinator { private async getAvailableTerminals(): Promise { try { const result = await this.runtime.listTerminals(this.opts.worktree) + const dispatched = this.db.listTasks({ status: 'dispatched' }) const busyHandles = new Set() - for (const terminal of result.terminals) { - if (this.db.getActiveDispatchForTerminal(terminal.handle)) { - busyHandles.add(terminal.handle) + for (const task of dispatched) { + const ctx = this.db.getDispatchContext(task.id) + if (ctx?.assignee_handle) { + busyHandles.add(ctx.assignee_handle) } } @@ -529,22 +490,25 @@ export class Coordinator { } private checkConvergence(): boolean { - const counts = this.db.getTaskStatusCounts() - if (counts.total === 0) { + const tasks = this.db.listTasks() + if (tasks.length === 0) { return true } - const allDone = counts.total === counts.completed + counts.failed + const allDone = tasks.every((t) => t.status === 'completed' || t.status === 'failed') if (allDone) { this.state.phase = 'done' return true } // Why: no active tasks but some blocked → dependencies can never be satisfied (stuck). - const activeCount = counts.ready + counts.dispatched + counts.pending - if (activeCount === 0 && counts.blocked > 0) { + const active = tasks.filter( + (t) => t.status === 'ready' || t.status === 'dispatched' || t.status === 'pending' + ) + const blocked = tasks.filter((t) => t.status === 'blocked') + if (active.length === 0 && blocked.length > 0) { this.opts.onLog( - `Stuck: ${counts.blocked} tasks blocked with no active tasks. Resolve decision gates to continue.` + `Stuck: ${blocked.length} tasks blocked with no active tasks. Resolve decision gates to continue.` ) } diff --git a/src/main/runtime/orchestration/db-query-retention.test.ts b/src/main/runtime/orchestration/db-query-retention.test.ts deleted file mode 100644 index 44068bfcace..00000000000 --- a/src/main/runtime/orchestration/db-query-retention.test.ts +++ /dev/null @@ -1,236 +0,0 @@ -import { afterEach, describe, expect, it } from 'vitest' -import type Database from '../../sqlite/sync-database' -import { OrchestrationDb } from './db' -import { - orchestrationRowRetainedUtf8Bytes, - ORCHESTRATION_JSON_STRUCTURE_LIMITS, - ORCHESTRATION_QUERY_MAX_RETAINED_UTF8_BYTES, - ORCHESTRATION_QUERY_MAX_ROW_UTF8_BYTES, - ORCHESTRATION_QUERY_MAX_ROWS, - ORCHESTRATION_WRITE_MAX_ITEMS, - ORCHESTRATION_WRITE_MAX_UTF8_BYTES, - parseOrchestrationJson -} from './query-retention' - -function sqliteFor(db: OrchestrationDb): Database.Database { - return (db as unknown as { db: Database.Database }).db -} - -function paneKey(index: number, tab = `tab_${index}`): string { - return `${tab}:00000000-0000-4000-8000-${index.toString(16).padStart(12, '0')}` -} - -describe('OrchestrationDb query retention', () => { - let db: OrchestrationDb | undefined - - afterEach(() => { - db?.close() - }) - - function createDb(): OrchestrationDb { - db = new OrchestrationDb(':memory:') - return db - } - - it('returns unread rows in stable bounded pages that can be drained successively', () => { - const d = createDb() - const inserted = Array.from({ length: ORCHESTRATION_QUERY_MAX_ROWS + 2 }, (_, index) => - d.insertMessage({ from: 'sender', to: 'worker', subject: `message-${index}` }) - ) - - const first = d.getUnreadMessages('worker') - expect(first.map((row) => row.id)).toEqual( - inserted.slice(0, ORCHESTRATION_QUERY_MAX_ROWS).map((row) => row.id) - ) - - d.markAsRead(first.map((row) => row.id)) - expect(d.getUnreadMessages('worker').map((row) => row.id)).toEqual( - inserted.slice(ORCHESTRATION_QUERY_MAX_ROWS).map((row) => row.id) - ) - }) - - it('drains undelivered rows successively without replaying the first page', () => { - const d = createDb() - const inserted = Array.from({ length: ORCHESTRATION_QUERY_MAX_ROWS + 1 }, (_, index) => - d.insertMessage({ from: 'sender', to: 'worker', subject: `delivery-${index}` }) - ) - - const first = d.getUndeliveredUnreadMessages('worker') - d.markAsDelivered(first.map((row) => row.id)) - - expect(d.getUndeliveredUnreadMessages('worker').map((row) => row.id)).toEqual([ - inserted.at(-1)!.id - ]) - }) - - it('caps aggregate retained bytes and resumes at the first omitted message', () => { - const d = createDb() - const body = 'x'.repeat(500 * 1024) - const inserted = Array.from({ length: 20 }, (_, index) => - d.insertMessage({ from: 'sender', to: 'worker', subject: `large-${index}`, body }) - ) - - const first = d.getUnreadMessages('worker') - const retainedBytes = first.reduce( - (total, row) => total + orchestrationRowRetainedUtf8Bytes(row), - 0 - ) - expect(first.length).toBeGreaterThan(0) - expect(first.length).toBeLessThan(inserted.length) - expect(retainedBytes).toBeLessThanOrEqual(ORCHESTRATION_QUERY_MAX_RETAINED_UTF8_BYTES) - - d.markAsRead(first.map((row) => row.id)) - expect(d.getUnreadMessages('worker')[0]?.id).toBe(inserted[first.length].id) - }) - - it('rejects new oversized rows and skips oversized legacy rows without starving later rows', () => { - const d = createDb() - const exactSubject = 'x'.repeat(ORCHESTRATION_WRITE_MAX_UTF8_BYTES - 2) - expect(d.insertMessage({ from: 'a', to: 'b', subject: exactSubject }).subject).toBe( - exactSubject - ) - expect(() => d.insertMessage({ from: 'a', to: 'b', subject: `${exactSubject}x` })).toThrow( - /orchestration limit/ - ) - - sqliteFor(d) - .prepare( - `INSERT INTO messages (id, from_handle, to_handle, subject, body) - VALUES (?, 'a', 'legacy', 'oversized', ?)` - ) - .run('msg_oversized', 'z'.repeat(ORCHESTRATION_QUERY_MAX_ROW_UTF8_BYTES + 1)) - const later = d.insertMessage({ from: 'a', to: 'legacy', subject: 'later-valid-row' }) - - expect(d.getMessageById('msg_oversized')).toBeUndefined() - expect(d.getUnreadMessages('legacy').map((row) => row.id)).toEqual([later.id]) - }) - - it('keeps exact task counts and promotes dependencies beyond the returned page', () => { - const d = createDb() - const parent = d.createTask({ spec: 'parent' }) - const children = Array.from({ length: ORCHESTRATION_QUERY_MAX_ROWS + 1 }, (_, index) => - d.createTask({ spec: `child-${index}`, deps: [parent.id] }) - ) - - expect(d.listTasks()).toHaveLength(ORCHESTRATION_QUERY_MAX_ROWS) - expect(d.getTaskStatusCounts()).toMatchObject({ - total: ORCHESTRATION_QUERY_MAX_ROWS + 2, - pending: ORCHESTRATION_QUERY_MAX_ROWS + 1, - ready: 1 - }) - - d.updateTaskStatus(parent.id, 'completed') - - expect(d.getTask(children.at(-1)!.id)?.status).toBe('ready') - expect(d.getTaskStatusCounts()).toMatchObject({ - total: ORCHESTRATION_QUERY_MAX_ROWS + 2, - completed: 1, - ready: ORCHESTRATION_QUERY_MAX_ROWS + 1 - }) - }) - - it('rejects dependency amplification and promotes valid rows after oversized legacy DAG data', () => { - const d = createDb() - const parent = d.createTask({ spec: 'parent' }) - expect(() => - d.createTask({ - spec: 'too many deps', - deps: Array.from({ length: ORCHESTRATION_WRITE_MAX_ITEMS + 1 }, () => parent.id) - }) - ).toThrow(/item orchestration limit/) - - sqliteFor(d) - .prepare( - `INSERT INTO tasks (id, spec, status, deps) - VALUES ('task_legacy_amplified', 'legacy', 'pending', ?)` - ) - .run( - JSON.stringify(Array.from({ length: ORCHESTRATION_WRITE_MAX_ITEMS + 1 }, () => parent.id)) - ) - const valid = d.createTask({ spec: 'valid', deps: [parent.id] }) - - d.updateTaskStatus(parent.id, 'completed') - - expect(d.getTask('task_legacy_amplified')?.status).toBe('pending') - expect(d.getTask(valid.id)?.status).toBe('ready') - }) - - it('streams active pane and stale-dispatch scans beyond the returned page', () => { - const d = createDb() - const contexts = Array.from({ length: ORCHESTRATION_QUERY_MAX_ROWS + 1 }, (_, index) => { - const task = d.createTask({ spec: `task-${index}` }) - return d.createDispatchContext(task.id, `worker-${index}`, paneKey(index)) - }) - const duplicateTask = d.createTask({ spec: 'duplicate pane' }) - - expect(() => - d.createDispatchContext( - duplicateTask.id, - 'reminted-worker', - paneKey(ORCHESTRATION_QUERY_MAX_ROWS, 'reminted-tab') - ) - ).toThrow(/already has an active dispatch/) - - sqliteFor(d) - .prepare( - "UPDATE dispatch_contexts SET dispatched_at = '2020-01-01 00:00:00', last_heartbeat_at = NULL" - ) - .run() - const threshold = '2021-01-01T00:00:00.000Z' - expect(d.getStaleDispatches(threshold)).toHaveLength(ORCHESTRATION_QUERY_MAX_ROWS) - - const visited: string[] = [] - d.forEachStaleDispatch(threshold, (row) => { - visited.push(row.id) - }) - expect(visited).toEqual(contexts.map((row) => row.id)) - }) - - it('repairs every pending-gate task even when the gate list is capped', () => { - const d = createDb() - const rejectedTask = d.createTask({ spec: 'reject amplified options' }) - expect(() => - d.createGate({ - taskId: rejectedTask.id, - question: 'too many', - options: Array.from({ length: ORCHESTRATION_WRITE_MAX_ITEMS + 1 }, () => '') - }) - ).toThrow(/item orchestration limit/) - const tasks = Array.from({ length: ORCHESTRATION_QUERY_MAX_ROWS + 1 }, (_, index) => { - const task = d.createTask({ spec: `gated-${index}` }) - d.createGate({ taskId: task.id, question: `question-${index}` }) - return task - }) - - expect(d.listGates({ status: 'pending' })).toHaveLength(ORCHESTRATION_QUERY_MAX_ROWS) - sqliteFor(d).prepare("UPDATE tasks SET status = 'ready'").run() - - d.restorePendingGateTaskStatuses() - - expect(d.getTask(tasks.at(-1)!.id)?.status).toBe('blocked') - expect(d.getTaskStatusCounts()).toMatchObject({ - blocked: ORCHESTRATION_QUERY_MAX_ROWS + 1, - ready: 1 - }) - }) -}) - -describe('orchestration JSON structure admission', () => { - it('admits the exact structural-token limit and rejects limit +1', () => { - const exactValues = ORCHESTRATION_JSON_STRUCTURE_LIMITS.structuralTokens - 1 - const exact = `[${Array.from({ length: exactValues }, () => '0').join(',')}]` - const over = `${exact.slice(0, -1)},0]` - - expect(parseOrchestrationJson(exact)).toHaveLength(exactValues) - expect(() => parseOrchestrationJson(over)).toThrow(/JSON structure exceeds/) - }) - - it('admits the exact nesting limit and rejects limit +1', () => { - const depth = ORCHESTRATION_JSON_STRUCTURE_LIMITS.nestingDepth - const exact = `${'['.repeat(depth)}0${']'.repeat(depth)}` - const over = `[${exact}]` - - expect(parseOrchestrationJson(exact)).toBeDefined() - expect(() => parseOrchestrationJson(over)).toThrow(/JSON nesting exceeds/) - }) -}) diff --git a/src/main/runtime/orchestration/db.ts b/src/main/runtime/orchestration/db.ts index b8cb36bd5c8..b287cb0182c 100644 --- a/src/main/runtime/orchestration/db.ts +++ b/src/main/runtime/orchestration/db.ts @@ -16,80 +16,6 @@ import type { } from './types' import { buildOrchestrationTaskDisplayMetadata } from '../../../shared/orchestration-task-display' import { parsePaneKey } from '../../../shared/stable-pane-id' -import { - assertOrchestrationStringListFits, - assertOrchestrationWriteFits, - clampOrchestrationQueryLimit, - ORCHESTRATION_QUERY_MAX_ROW_UTF8_BYTES, - ORCHESTRATION_QUERY_MAX_ROWS, - ORCHESTRATION_WRITE_MAX_ITEMS, - parseOrchestrationJson, - retainOrchestrationQueryRows, - truncateOrchestrationDiagnostic -} from './query-retention' - -function retainedTextBytesSql(columns: string[]): string { - return columns.map((column) => `length(CAST(COALESCE(${column}, '') AS BLOB))`).join(' + ') -} - -const MESSAGE_ROW_BYTES_SQL = retainedTextBytesSql([ - 'messages.id', - 'messages.from_handle', - 'messages.to_handle', - 'messages.subject', - 'messages.body', - 'messages.type', - 'messages.priority', - 'messages.thread_id', - 'messages.payload', - 'messages.created_at', - 'messages.delivered_at', - 'messages.sender_pane_key' -]) -const TASK_ROW_BYTES_SQL = retainedTextBytesSql([ - 'tasks.id', - 'tasks.parent_id', - 'tasks.created_by_terminal_handle', - 'tasks.task_title', - 'tasks.display_name', - 'tasks.spec', - 'tasks.status', - 'tasks.deps', - 'tasks.result', - 'tasks.created_at', - 'tasks.completed_at' -]) -const DISPATCH_ROW_BYTES_SQL = retainedTextBytesSql([ - 'dispatch_contexts.id', - 'dispatch_contexts.task_id', - 'dispatch_contexts.assignee_handle', - 'dispatch_contexts.assignee_pane_key', - 'dispatch_contexts.status', - 'dispatch_contexts.last_failure', - 'dispatch_contexts.dispatched_at', - 'dispatch_contexts.completed_at', - 'dispatch_contexts.created_at', - 'dispatch_contexts.last_heartbeat_at' -]) -const GATE_ROW_BYTES_SQL = retainedTextBytesSql([ - 'decision_gates.id', - 'decision_gates.task_id', - 'decision_gates.question', - 'decision_gates.options', - 'decision_gates.status', - 'decision_gates.resolution', - 'decision_gates.created_at', - 'decision_gates.resolved_at' -]) -const COORDINATOR_RUN_ROW_BYTES_SQL = retainedTextBytesSql([ - 'coordinator_runs.id', - 'coordinator_runs.spec', - 'coordinator_runs.status', - 'coordinator_runs.coordinator_handle', - 'coordinator_runs.created_at', - 'coordinator_runs.completed_at' -]) -const INTERNAL_SCAN_BATCH_ROWS = 64 // Why: leaf UUID is the remint-stable pane identity (tab half changes on break-out); exact match covers legacy/unparseable keys. function isEquivalentPaneKey(a: string, b: string): boolean { @@ -115,29 +41,14 @@ export type { CoordinatorRun } -export type TaskStatusCounts = Record & { total: number } - function generateId(prefix: string): string { return `${prefix}_${randomBytes(6).toString('hex')}` } -function uniqueMessageTypes(types: MessageType[] | undefined): MessageType[] { - const unique: MessageType[] = [] - for (const type of types ?? []) { - if (!unique.includes(type)) { - unique.push(type) - if (unique.length === 8) { - break - } - } - } - return unique -} - function addLifecycleRejectionMarker(payload: string | null, reason: string): string { let parsed: Record = {} try { - const value: unknown = payload ? parseOrchestrationJson(payload) : {} + const value: unknown = payload ? JSON.parse(payload) : {} if (value && typeof value === 'object' && !Array.isArray(value)) { parsed = value as Record } @@ -195,24 +106,6 @@ export class OrchestrationDb { this.migrate() } - private readRows( - sql: string, - params: Database.BindValue[] = [], - requestedLimit?: number - ): T[] { - const limit = clampOrchestrationQueryLimit(requestedLimit) - if (limit === 0) { - return [] - } - const rows = this.db.prepare(`${sql} LIMIT ?`).iterate(...params, limit) as Iterable - return retainOrchestrationQueryRows(rows, limit) - } - - private countRows(sql: string, params: Database.BindValue[] = []): number { - const row = this.db.prepare(sql).get(...params) as { count: number } - return row.count - } - private createTables(): void { this.db.exec(` CREATE TABLE IF NOT EXISTS messages ( @@ -440,17 +333,6 @@ export class OrchestrationDb { payload?: string senderPaneKey?: string }): MessageRow { - assertOrchestrationWriteFits('Message', [ - msg.from, - msg.to, - msg.subject, - msg.body, - msg.type, - msg.priority, - msg.threadId, - msg.payload, - msg.senderPaneKey - ]) const id = generateId('msg') const stmt = this.db.prepare(` INSERT INTO messages (id, from_handle, to_handle, subject, body, type, priority, thread_id, payload, sender_pane_key) @@ -468,37 +350,26 @@ export class OrchestrationDb { msg.payload ?? null, msg.senderPaneKey ?? null ) - return this.getMessageById(id)! - } - - getUnreadMessages(toHandle: string, types?: MessageType[]): MessageRow[] { - const params: Database.BindValue[] = [toHandle] - let typeFilter = '' - const retainedTypes = uniqueMessageTypes(types) - if (retainedTypes.length > 0) { - const placeholders = retainedTypes.map(() => '?').join(',') - typeFilter = ` AND type IN (${placeholders})` - params.push(...retainedTypes) - } - return exposeMessageListTimestamps( - this.readRows( - `SELECT * FROM messages - WHERE to_handle = ? AND read = 0${typeFilter} - AND (${MESSAGE_ROW_BYTES_SQL}) <= ${ORCHESTRATION_QUERY_MAX_ROW_UTF8_BYTES} - ORDER BY sequence`, - params - ) + return exposeMessageTimestamps( + this.db.prepare('SELECT * FROM messages WHERE id = ?').get(id) as MessageRow ) } - countUnreadMessages(toHandle: string, types?: MessageType[]): number { - const retainedTypes = uniqueMessageTypes(types) - const placeholders = retainedTypes.map(() => '?').join(',') - const typeFilter = retainedTypes.length > 0 ? ` AND type IN (${placeholders})` : '' - return this.countRows( - `SELECT COUNT(*) AS count FROM messages - WHERE to_handle = ? AND read = 0${typeFilter}`, - [toHandle, ...retainedTypes] + getUnreadMessages(toHandle: string, types?: MessageType[]): MessageRow[] { + if (types && types.length > 0) { + const placeholders = types.map(() => '?').join(',') + return exposeMessageListTimestamps( + this.db + .prepare( + `SELECT * FROM messages WHERE to_handle = ? AND read = 0 AND type IN (${placeholders}) ORDER BY sequence` + ) + .all(toHandle, ...types) as MessageRow[] + ) + } + return exposeMessageListTimestamps( + this.db + .prepare('SELECT * FROM messages WHERE to_handle = ? AND read = 0 ORDER BY sequence') + .all(toHandle) as MessageRow[] ) } @@ -509,11 +380,8 @@ export class OrchestrationDb { } const originalBody = message.body ? `\n\nOriginal body:\n${message.body}` : '' - const boundedReason = truncateOrchestrationDiagnostic(reason) - const body = truncateOrchestrationDiagnostic( - `Orca rejected this ${message.type}: ${boundedReason}${originalBody}` - ) - const payload = addLifecycleRejectionMarker(message.payload, boundedReason) + const body = `Orca rejected this ${message.type}: ${reason}${originalBody}` + const payload = addLifecycleRejectionMarker(message.payload, reason) // Why: rejected lifecycle signals stay auditable but must not reach read paths as actionable completion/liveness events. this.db .prepare( @@ -527,133 +395,116 @@ export class OrchestrationDb { // Why: delivered_at IS NULL filter — push-on-idle delivers each row at most once; read (set only by check) wouldn't prevent replay. getUndeliveredUnreadMessages(toHandle: string, types?: MessageType[]): MessageRow[] { - const params: Database.BindValue[] = [toHandle] - let typeFilter = '' - const retainedTypes = uniqueMessageTypes(types) - if (retainedTypes.length > 0) { - const placeholders = retainedTypes.map(() => '?').join(',') - typeFilter = ` AND type IN (${placeholders})` - params.push(...retainedTypes) + if (types && types.length > 0) { + const placeholders = types.map(() => '?').join(',') + return exposeMessageListTimestamps( + this.db + .prepare( + `SELECT * FROM messages WHERE to_handle = ? AND read = 0 AND delivered_at IS NULL AND type IN (${placeholders}) ORDER BY sequence` + ) + .all(toHandle, ...types) as MessageRow[] + ) } return exposeMessageListTimestamps( - this.readRows( - `SELECT * FROM messages - WHERE to_handle = ? AND read = 0 AND delivered_at IS NULL${typeFilter} - AND (${MESSAGE_ROW_BYTES_SQL}) <= ${ORCHESTRATION_QUERY_MAX_ROW_UTF8_BYTES} - ORDER BY sequence`, - params - ) + this.db + .prepare( + 'SELECT * FROM messages WHERE to_handle = ? AND read = 0 AND delivered_at IS NULL ORDER BY sequence' + ) + .all(toHandle) as MessageRow[] ) } getAllMessages(toHandle: string, limit = 20): MessageRow[] { return exposeMessageListTimestamps( - this.readRows( - `SELECT * FROM messages - WHERE to_handle = ? - AND (${MESSAGE_ROW_BYTES_SQL}) <= ${ORCHESTRATION_QUERY_MAX_ROW_UTF8_BYTES} - ORDER BY sequence DESC`, - [toHandle], - limit - ) + this.db + .prepare('SELECT * FROM messages WHERE to_handle = ? ORDER BY sequence DESC LIMIT ?') + .all(toHandle, limit) as MessageRow[] ) } getMessageById(id: string): MessageRow | undefined { - const message = this.db - .prepare( - `SELECT * FROM messages - WHERE id = ? AND (${MESSAGE_ROW_BYTES_SQL}) <= ${ORCHESTRATION_QUERY_MAX_ROW_UTF8_BYTES}` - ) - .get(id) as MessageRow | undefined + const message = this.db.prepare('SELECT * FROM messages WHERE id = ?').get(id) as + | MessageRow + | undefined return message ? exposeMessageTimestamps(message) : undefined } markAsRead(ids: string[]): void { - this.updateMessageIds(ids, 'read = 1') + if (ids.length === 0) { + return + } + const placeholders = ids.map(() => '?').join(',') + this.db.prepare(`UPDATE messages SET read = 1 WHERE id IN (${placeholders})`).run(...ids) } // Why: use datetime('now') so delivered_at matches the space-format UTC shape of the table's other timestamps for correct ordering (§3.2). markAsDelivered(ids: string[]): void { - this.updateMessageIds(ids, "delivered_at = datetime('now')") + if (ids.length === 0) { + return + } + const placeholders = ids.map(() => '?').join(',') + this.db + .prepare(`UPDATE messages SET delivered_at = datetime('now') WHERE id IN (${placeholders})`) + .run(...ids) } markAsReadAndDelivered(ids: string[]): void { - // Why: superseded lifecycle messages stay in history but must not be consumed or injected after their dispatch finished. - this.updateMessageIds(ids, "read = 1, delivered_at = COALESCE(delivered_at, datetime('now'))") - } - - private updateMessageIds(ids: string[], assignments: string): void { - for (let start = 0; start < ids.length; start += ORCHESTRATION_QUERY_MAX_ROWS) { - const batch = ids.slice(start, start + ORCHESTRATION_QUERY_MAX_ROWS) - const placeholders = batch.map(() => '?').join(',') - this.db - .prepare(`UPDATE messages SET ${assignments} WHERE id IN (${placeholders})`) - .run(...batch) + if (ids.length === 0) { + return } + const placeholders = ids.map(() => '?').join(',') + // Why: superseded lifecycle messages stay in history but must not be consumed or injected after their dispatch finished. + this.db + .prepare( + `UPDATE messages SET read = 1, delivered_at = COALESCE(delivered_at, datetime('now')) WHERE id IN (${placeholders})` + ) + .run(...ids) } getInbox(limit = 20): MessageRow[] { return exposeMessageListTimestamps( - this.readRows( - `SELECT * FROM messages - WHERE (${MESSAGE_ROW_BYTES_SQL}) <= ${ORCHESTRATION_QUERY_MAX_ROW_UTF8_BYTES} - ORDER BY sequence DESC`, - [], - limit - ) + this.db + .prepare('SELECT * FROM messages ORDER BY sequence DESC LIMIT ?') + .all(limit) as MessageRow[] ) } - countInbox(): number { - return this.countRows('SELECT COUNT(*) AS count FROM messages') - } - // Why: read-only history for a handle — returns every message regardless of read/delivered state, never flips the read bit (§3.3). getAllMessagesForHandle(toHandle: string, limit = 100, types?: MessageType[]): MessageRow[] { - const params: Database.BindValue[] = [toHandle] - let typeFilter = '' - const retainedTypes = uniqueMessageTypes(types) - if (retainedTypes.length > 0) { - const placeholders = retainedTypes.map(() => '?').join(',') - typeFilter = ` AND type IN (${placeholders})` - params.push(...retainedTypes) + if (types && types.length > 0) { + const placeholders = types.map(() => '?').join(',') + return exposeMessageListTimestamps( + this.db + .prepare( + `SELECT * FROM messages WHERE to_handle = ? AND type IN (${placeholders}) ORDER BY sequence DESC LIMIT ?` + ) + .all(toHandle, ...types, limit) as MessageRow[] + ) } return exposeMessageListTimestamps( - this.readRows( - `SELECT * FROM messages - WHERE to_handle = ?${typeFilter} - AND (${MESSAGE_ROW_BYTES_SQL}) <= ${ORCHESTRATION_QUERY_MAX_ROW_UTF8_BYTES} - ORDER BY sequence DESC`, - params, - limit - ) - ) - } - - countAllMessagesForHandle(toHandle: string, types?: MessageType[]): number { - const retainedTypes = uniqueMessageTypes(types) - const placeholders = retainedTypes.map(() => '?').join(',') - const typeFilter = retainedTypes.length > 0 ? ` AND type IN (${placeholders})` : '' - return this.countRows( - `SELECT COUNT(*) AS count FROM messages WHERE to_handle = ?${typeFilter}`, - [toHandle, ...retainedTypes] + this.db + .prepare('SELECT * FROM messages WHERE to_handle = ? ORDER BY sequence DESC LIMIT ?') + .all(toHandle, limit) as MessageRow[] ) } // Why: ask wait-loop read — to_handle filter shows only replies to the worker; afterSequence resumes past its own outbound ask. getThreadMessagesFor(threadId: string, toHandle: string, afterSequence?: number): MessageRow[] { - const sequenceFilter = afterSequence === undefined ? '' : ' AND sequence > ?' - const params: Database.BindValue[] = - afterSequence === undefined ? [threadId, toHandle] : [threadId, toHandle, afterSequence] - return exposeMessageListTimestamps( - this.readRows( - `SELECT * FROM messages - WHERE thread_id = ? AND to_handle = ?${sequenceFilter} - AND (${MESSAGE_ROW_BYTES_SQL}) <= ${ORCHESTRATION_QUERY_MAX_ROW_UTF8_BYTES} - ORDER BY sequence ASC`, - params + if (afterSequence !== undefined) { + return exposeMessageListTimestamps( + this.db + .prepare( + 'SELECT * FROM messages WHERE thread_id = ? AND to_handle = ? AND sequence > ? ORDER BY sequence ASC' + ) + .all(threadId, toHandle, afterSequence) as MessageRow[] ) + } + return exposeMessageListTimestamps( + this.db + .prepare( + 'SELECT * FROM messages WHERE thread_id = ? AND to_handle = ? ORDER BY sequence ASC' + ) + .all(threadId, toHandle) as MessageRow[] ) } @@ -668,32 +519,14 @@ export class OrchestrationDb { createdByTerminalHandle?: string }): TaskRow { const id = generateId('task') - const deps = task.deps ?? [] - assertOrchestrationStringListFits('Task dependencies', deps) - assertOrchestrationWriteFits('Task', [ - task.spec, - task.taskTitle, - task.displayName, - task.parentId, - task.createdByTerminalHandle, - ...deps - ]) - const depsJson = JSON.stringify(deps) - const hasDeps = deps.length > 0 + const depsJson = JSON.stringify(task.deps ?? []) + const hasDeps = (task.deps ?? []).length > 0 const status: TaskStatus = hasDeps ? 'pending' : 'ready' const display = buildOrchestrationTaskDisplayMetadata({ spec: task.spec, taskTitle: task.taskTitle, displayName: task.displayName }) - assertOrchestrationWriteFits('Task', [ - task.spec, - display.taskTitle, - display.displayName, - depsJson, - task.parentId, - task.createdByTerminalHandle - ]) this.db .prepare( 'INSERT INTO tasks (id, parent_id, created_by_terminal_handle, task_title, display_name, spec, status, deps) VALUES (?, ?, ?, ?, ?, ?, ?, ?)' @@ -708,35 +541,25 @@ export class OrchestrationDb { status, depsJson ) - return this.getTask(id)! + return this.db.prepare('SELECT * FROM tasks WHERE id = ?').get(id) as TaskRow } getTask(id: string): TaskRow | undefined { - return this.db - .prepare( - `SELECT * FROM tasks - WHERE id = ? AND (${TASK_ROW_BYTES_SQL}) <= ${ORCHESTRATION_QUERY_MAX_ROW_UTF8_BYTES}` - ) - .get(id) as TaskRow | undefined + return this.db.prepare('SELECT * FROM tasks WHERE id = ?').get(id) as TaskRow | undefined } listTasks(filter?: { status?: TaskStatus; ready?: boolean }): TaskRow[] { - const status = filter?.ready ? 'ready' : filter?.status - const statusFilter = status ? 'status = ? AND ' : '' - return this.readRows( - `SELECT * FROM tasks - WHERE ${statusFilter}(${TASK_ROW_BYTES_SQL}) <= ${ORCHESTRATION_QUERY_MAX_ROW_UTF8_BYTES} - ORDER BY created_at, rowid`, - status ? [status] : [] - ) - } - - countTasks(filter?: { status?: TaskStatus; ready?: boolean }): number { - const status = filter?.ready ? 'ready' : filter?.status - return this.countRows( - `SELECT COUNT(*) AS count FROM tasks${status ? ' WHERE status = ?' : ''}`, - status ? [status] : [] - ) + if (filter?.ready) { + return this.db + .prepare("SELECT * FROM tasks WHERE status = 'ready' ORDER BY created_at") + .all() as TaskRow[] + } + if (filter?.status) { + return this.db + .prepare('SELECT * FROM tasks WHERE status = ? ORDER BY created_at') + .all(filter.status) as TaskRow[] + } + return this.db.prepare('SELECT * FROM tasks ORDER BY created_at').all() as TaskRow[] } // Why: LEFT JOIN keeps non-dispatched tasks (NULL assignee); the MAX(rowid) subquery matches getDispatchContext's most-recent-active-dispatch semantics. @@ -747,25 +570,20 @@ export class OrchestrationDb { const whereClauses: string[] = [] const params: Database.BindValue[] = [] if (filter?.ready) { - whereClauses.push("tasks.status = 'ready'") + whereClauses.push("t.status = 'ready'") } else if (filter?.status) { - whereClauses.push('tasks.status = ?') + whereClauses.push('t.status = ?') params.push(filter.status) } - whereClauses.push( - `(${TASK_ROW_BYTES_SQL} - + length(CAST(COALESCE(d.assignee_handle, '') AS BLOB)) - + length(CAST(COALESCE(d.id, '') AS BLOB))) <= ${ORCHESTRATION_QUERY_MAX_ROW_UTF8_BYTES}` - ) const where = whereClauses.length > 0 ? `WHERE ${whereClauses.join(' AND ')}` : '' const sql = ` SELECT - tasks.*, + t.*, d.assignee_handle AS assignee_handle, d.id AS dispatch_id - FROM tasks + FROM tasks t LEFT JOIN ( - SELECT dc.id, dc.task_id, dc.assignee_handle + SELECT dc.* FROM dispatch_contexts dc INNER JOIN ( SELECT task_id, MAX(rowid) AS max_rowid @@ -773,23 +591,17 @@ export class OrchestrationDb { WHERE status IN ('pending', 'dispatched') GROUP BY task_id ) latest ON latest.task_id = dc.task_id AND latest.max_rowid = dc.rowid - ) d ON d.task_id = tasks.id + ) d ON d.task_id = t.id ${where} - ORDER BY tasks.created_at, tasks.rowid + ORDER BY t.created_at ` - return this.readRows< - TaskRow & { - assignee_handle: string | null - dispatch_id: string | null - } - >(sql, params) as (TaskRow & { + return this.db.prepare(sql).all(...params) as (TaskRow & { assignee_handle: string | null dispatch_id: string | null })[] } updateTaskStatus(id: string, status: TaskStatus, result?: string): TaskRow | undefined { - assertOrchestrationWriteFits('Task result', [result]) const completedAt = status === 'completed' || status === 'failed' ? new Date().toISOString() : null this.db @@ -808,81 +620,26 @@ export class OrchestrationDb { // Why: runs in the status-update transaction, so a completed task never leaves its ready children unpromoted. private promoteReadyTasks(completedTaskId: string): void { - let afterRowId = 0 - while (true) { - const candidates = this.readRows<{ rowid: number; id: string; deps: string }>( - `SELECT rowid, id, deps FROM tasks - WHERE status = 'pending' AND rowid > ? - AND (${retainedTextBytesSql(['tasks.id', 'tasks.deps'])}) - <= ${ORCHESTRATION_QUERY_MAX_ROW_UTF8_BYTES} - AND CASE WHEN json_valid(deps) - THEN json_type(deps) = 'array' - AND json_array_length(deps) <= ${ORCHESTRATION_WRITE_MAX_ITEMS} - ELSE 0 - END - ORDER BY rowid`, - [afterRowId], - INTERNAL_SCAN_BATCH_ROWS - ) - if (candidates.length === 0) { - return - } - afterRowId = candidates.at(-1)!.rowid + const candidates = this.db + .prepare("SELECT * FROM tasks WHERE status = 'pending'") + .all() as TaskRow[] - for (const task of candidates) { - let parsedDeps: unknown - try { - parsedDeps = parseOrchestrationJson(task.deps) - } catch { - continue - } - if ( - !Array.isArray(parsedDeps) || - parsedDeps.length > ORCHESTRATION_WRITE_MAX_ITEMS || - !parsedDeps.every((dependency) => typeof dependency === 'string') - ) { - continue - } - const deps = parsedDeps - if (!deps.includes(completedTaskId)) { - continue - } - const allDepsCompleted = deps.every((depId) => this.getTask(depId)?.status === 'completed') - if (allDepsCompleted) { - this.db.prepare("UPDATE tasks SET status = 'ready' WHERE id = ?").run(task.id) - } + for (const task of candidates) { + const deps: string[] = JSON.parse(task.deps) + if (!deps.includes(completedTaskId)) { + continue + } + + const allDepsCompleted = deps.every((depId) => { + const dep = this.getTask(depId) + return dep?.status === 'completed' + }) + if (allDepsCompleted) { + this.db.prepare("UPDATE tasks SET status = 'ready' WHERE id = ?").run(task.id) } } } - getTaskStatusCounts(): TaskStatusCounts { - const row = this.db - .prepare( - `SELECT - COUNT(*) AS total, - COALESCE(SUM(status = 'pending'), 0) AS pending, - COALESCE(SUM(status = 'ready'), 0) AS ready, - COALESCE(SUM(status = 'dispatched'), 0) AS dispatched, - COALESCE(SUM(status = 'completed'), 0) AS completed, - COALESCE(SUM(status = 'failed'), 0) AS failed, - COALESCE(SUM(status = 'blocked'), 0) AS blocked - FROM tasks` - ) - .get() as TaskStatusCounts - return row - } - - listTaskIdsByStatus(status: TaskStatus): string[] { - const rows = this.readRows<{ id: string }>( - `SELECT id FROM tasks - WHERE status = ? - AND length(CAST(id AS BLOB)) <= ${ORCHESTRATION_QUERY_MAX_ROW_UTF8_BYTES} - ORDER BY created_at, rowid`, - [status] - ) - return rows.map((row) => row.id) - } - // ── Dispatch Contexts ── createDispatchContext( @@ -891,7 +648,6 @@ export class OrchestrationDb { // Why: pane key is the remint-stable identity behind the handle — lets worker_done ownership survive handle reissue. assigneePaneKey?: string ): DispatchContextRow { - assertOrchestrationWriteFits('Dispatch context', [taskId, assigneeHandle, assigneePaneKey]) const task = this.getTask(taskId) if (!task) { throw new Error(`Task not found: ${taskId}`) @@ -926,28 +682,21 @@ export class OrchestrationDb { this.db.prepare("UPDATE tasks SET status = 'dispatched' WHERE id = ?").run(taskId) - return this.getDispatchContextById(id)! + return this.db + .prepare('SELECT * FROM dispatch_contexts WHERE id = ?') + .get(id) as DispatchContextRow } getDispatchContext(taskId: string): DispatchContextRow | undefined { return this.db - .prepare( - `SELECT * FROM dispatch_contexts - WHERE task_id = ? - AND (${DISPATCH_ROW_BYTES_SQL}) <= ${ORCHESTRATION_QUERY_MAX_ROW_UTF8_BYTES} - ORDER BY rowid DESC LIMIT 1` - ) + .prepare('SELECT * FROM dispatch_contexts WHERE task_id = ? ORDER BY rowid DESC LIMIT 1') .get(taskId) as DispatchContextRow | undefined } getDispatchContextById(dispatchId: string): DispatchContextRow | undefined { - return this.db - .prepare( - `SELECT * FROM dispatch_contexts - WHERE id = ? - AND (${DISPATCH_ROW_BYTES_SQL}) <= ${ORCHESTRATION_QUERY_MAX_ROW_UTF8_BYTES}` - ) - .get(dispatchId) as DispatchContextRow | undefined + return this.db.prepare('SELECT * FROM dispatch_contexts WHERE id = ?').get(dispatchId) as + | DispatchContextRow + | undefined } getActiveDispatchForTerminal(handle: string): DispatchContextRow | undefined { @@ -974,10 +723,7 @@ export class OrchestrationDb { ): DispatchContextRow | undefined { const byHandle = this.db .prepare( - `SELECT * FROM dispatch_contexts - WHERE assignee_handle = ? AND status IN ('pending', 'dispatched') - AND (${DISPATCH_ROW_BYTES_SQL}) <= ${ORCHESTRATION_QUERY_MAX_ROW_UTF8_BYTES} - LIMIT 1` + "SELECT * FROM dispatch_contexts WHERE assignee_handle = ? AND status IN ('pending', 'dispatched') LIMIT 1" ) .get(assigneeHandle) as DispatchContextRow | undefined if (byHandle) { @@ -990,33 +736,22 @@ export class OrchestrationDb { const actives = this.db .prepare( - `SELECT id, assignee_pane_key FROM dispatch_contexts - WHERE assignee_pane_key IS NOT NULL AND status IN ('pending', 'dispatched') - AND (${retainedTextBytesSql([ - 'dispatch_contexts.id', - 'dispatch_contexts.assignee_pane_key' - ])}) <= ${ORCHESTRATION_QUERY_MAX_ROW_UTF8_BYTES} - ORDER BY rowid` + "SELECT * FROM dispatch_contexts WHERE assignee_pane_key IS NOT NULL AND status IN ('pending', 'dispatched')" ) - .iterate() as Iterable<{ id: string; assignee_pane_key: string }> + .all() as DispatchContextRow[] - let matchingId: string | undefined for (const row of actives) { - if (isEquivalentPaneKey(row.assignee_pane_key, assigneePaneKey)) { - matchingId = row.id - break + if (row.assignee_pane_key && isEquivalentPaneKey(row.assignee_pane_key, assigneePaneKey)) { + return row } } - return matchingId ? this.getDispatchContextById(matchingId) : undefined + return undefined } getLatestDispatchForTerminal(handle: string): DispatchContextRow | undefined { return this.db .prepare( - `SELECT * FROM dispatch_contexts - WHERE assignee_handle = ? - AND (${DISPATCH_ROW_BYTES_SQL}) <= ${ORCHESTRATION_QUERY_MAX_ROW_UTF8_BYTES} - ORDER BY rowid DESC LIMIT 1` + 'SELECT * FROM dispatch_contexts WHERE assignee_handle = ? ORDER BY rowid DESC LIMIT 1' ) .get(handle) as DispatchContextRow | undefined } @@ -1032,9 +767,9 @@ export class OrchestrationDb { completeActiveDispatchForTask(taskId: string): void { const active = this.db .prepare( - "SELECT id FROM dispatch_contexts WHERE task_id = ? AND status IN ('pending', 'dispatched') ORDER BY rowid DESC LIMIT 1" + "SELECT * FROM dispatch_contexts WHERE task_id = ? AND status IN ('pending', 'dispatched') ORDER BY rowid DESC LIMIT 1" ) - .get(taskId) as { id: string } | undefined + .get(taskId) as DispatchContextRow | undefined if (active) { this.completeDispatch(active.id) } @@ -1043,15 +778,14 @@ export class OrchestrationDb { failActiveDispatchForTask(taskId: string, error: string): DispatchContextRow | undefined { const active = this.db .prepare( - "SELECT id FROM dispatch_contexts WHERE task_id = ? AND status IN ('pending', 'dispatched') ORDER BY rowid DESC LIMIT 1" + "SELECT * FROM dispatch_contexts WHERE task_id = ? AND status IN ('pending', 'dispatched') ORDER BY rowid DESC LIMIT 1" ) - .get(taskId) as { id: string } | undefined + .get(taskId) as DispatchContextRow | undefined return active ? this.failDispatch(active.id, error) : undefined } // Why: only bump status='dispatched' — a zombie heartbeat from a finished dispatch would mask a hung retry from the stale detector (§5.3.4). recordHeartbeat(dispatchId: string, at: string): void { - assertOrchestrationWriteFits('Dispatch heartbeat', [dispatchId, at]) this.db .prepare( "UPDATE dispatch_contexts SET last_heartbeat_at = ? WHERE id = ? AND status = 'dispatched'" @@ -1061,37 +795,21 @@ export class OrchestrationDb { // Why: dispatched_at grace skips workers still within their first heartbeat interval; julianday() vs raw-TEXT compare avoids misflagging space-format timestamps as stale (#8452). getStaleDispatches(thresholdIso: string): DispatchContextRow[] { - return this.readRows( - `SELECT * FROM dispatch_contexts - WHERE status = 'dispatched' - AND dispatched_at IS NOT NULL - AND julianday(dispatched_at) < julianday(?) - AND (last_heartbeat_at IS NULL OR julianday(last_heartbeat_at) < julianday(?)) - AND (${DISPATCH_ROW_BYTES_SQL}) <= ${ORCHESTRATION_QUERY_MAX_ROW_UTF8_BYTES} - ORDER BY rowid`, - [thresholdIso, thresholdIso] - ) - } - - forEachStaleDispatch(thresholdIso: string, visit: (row: DispatchContextRow) => void): void { - const rows = this.db + return this.db .prepare( `SELECT * FROM dispatch_contexts WHERE status = 'dispatched' AND dispatched_at IS NOT NULL AND julianday(dispatched_at) < julianday(?) - AND (last_heartbeat_at IS NULL OR julianday(last_heartbeat_at) < julianday(?)) - AND (${DISPATCH_ROW_BYTES_SQL}) <= ${ORCHESTRATION_QUERY_MAX_ROW_UTF8_BYTES} - ORDER BY rowid` + AND (last_heartbeat_at IS NULL OR julianday(last_heartbeat_at) < julianday(?))` ) - .iterate(thresholdIso, thresholdIso) as Iterable - for (const row of rows) { - visit(row) - } + .all(thresholdIso, thresholdIso) as DispatchContextRow[] } failDispatch(ctxId: string, error: string): DispatchContextRow | undefined { - const ctx = this.getDispatchContextById(ctxId) + const ctx = this.db.prepare('SELECT * FROM dispatch_contexts WHERE id = ?').get(ctxId) as + | DispatchContextRow + | undefined if (!ctx) { return undefined } @@ -1103,24 +821,22 @@ export class OrchestrationDb { .prepare( 'UPDATE dispatch_contexts SET status = ?, failure_count = ?, last_failure = ? WHERE id = ?' ) - .run(newStatus, newFailureCount, truncateOrchestrationDiagnostic(error), ctxId) + .run(newStatus, newFailureCount, error, ctxId) // Why: back to 'ready' not 'pending' — 'pending' would strand it since promoteReadyTasks only runs when a dep completes. const taskStatus: TaskStatus = newStatus === 'circuit_broken' ? 'failed' : 'ready' this.db.prepare('UPDATE tasks SET status = ? WHERE id = ?').run(taskStatus, ctx.task_id) - return this.getDispatchContextById(ctxId) + return this.db.prepare('SELECT * FROM dispatch_contexts WHERE id = ?').get(ctxId) as + | DispatchContextRow + | undefined } // ── Decision Gates ── createGate(gate: { taskId: string; question: string; options?: string[] }): DecisionGateRow { const id = generateId('gate') - const options = gate.options ?? [] - assertOrchestrationStringListFits('Decision gate options', options) - assertOrchestrationWriteFits('Decision gate', [gate.taskId, gate.question, ...options]) - const optionsJson = JSON.stringify(options) - assertOrchestrationWriteFits('Decision gate', [gate.taskId, gate.question, optionsJson]) + const optionsJson = JSON.stringify(gate.options ?? []) this.db .prepare('INSERT INTO decision_gates (id, task_id, question, options) VALUES (?, ?, ?, ?)') .run(id, gate.taskId, gate.question, optionsJson) @@ -1128,12 +844,13 @@ export class OrchestrationDb { this.completeActiveDispatchForTask(gate.taskId) this.db.prepare("UPDATE tasks SET status = 'blocked' WHERE id = ?").run(gate.taskId) - return this.getGate(id)! + return this.db.prepare('SELECT * FROM decision_gates WHERE id = ?').get(id) as DecisionGateRow } resolveGate(gateId: string, resolution: string): DecisionGateRow | undefined { - assertOrchestrationWriteFits('Decision gate resolution', [gateId, resolution]) - const gate = this.getGate(gateId) + const gate = this.db.prepare('SELECT * FROM decision_gates WHERE id = ?').get(gateId) as + | DecisionGateRow + | undefined if (!gate) { return undefined } @@ -1147,7 +864,9 @@ export class OrchestrationDb { // Why: set to 'ready' (not the previous status) so the coordinator re-dispatches the worker with the resolution context. this.db.prepare("UPDATE tasks SET status = 'ready' WHERE id = ?").run(gate.task_id) - return this.getGate(gateId) + return this.db.prepare('SELECT * FROM decision_gates WHERE id = ?').get(gateId) as + | DecisionGateRow + | undefined } timeoutGate(gateId: string): DecisionGateRow | undefined { @@ -1156,81 +875,38 @@ export class OrchestrationDb { "UPDATE decision_gates SET status = 'timeout', resolved_at = datetime('now') WHERE id = ?" ) .run(gateId) - return this.getGate(gateId) + return this.db.prepare('SELECT * FROM decision_gates WHERE id = ?').get(gateId) as + | DecisionGateRow + | undefined } listGates(filter?: { taskId?: string; status?: GateStatus }): DecisionGateRow[] { - const clauses = [`(${GATE_ROW_BYTES_SQL}) <= ${ORCHESTRATION_QUERY_MAX_ROW_UTF8_BYTES}`] - const params: Database.BindValue[] = [] if (filter?.taskId && filter?.status) { - clauses.push('task_id = ?', 'status = ?') - params.push(filter.taskId, filter.status) - } else if (filter?.taskId) { - clauses.push('task_id = ?') - params.push(filter.taskId) - } else if (filter?.status) { - clauses.push('status = ?') - params.push(filter.status) + return this.db + .prepare( + 'SELECT * FROM decision_gates WHERE task_id = ? AND status = ? ORDER BY created_at' + ) + .all(filter.taskId, filter.status) as DecisionGateRow[] } - return this.readRows( - `SELECT * FROM decision_gates - WHERE ${clauses.join(' AND ')} - ORDER BY created_at, rowid`, - params - ) - } - - countGates(filter?: { taskId?: string; status?: GateStatus }): number { - const clauses: string[] = [] - const params: Database.BindValue[] = [] if (filter?.taskId) { - clauses.push('task_id = ?') - params.push(filter.taskId) + return this.db + .prepare('SELECT * FROM decision_gates WHERE task_id = ? ORDER BY created_at') + .all(filter.taskId) as DecisionGateRow[] } if (filter?.status) { - clauses.push('status = ?') - params.push(filter.status) + return this.db + .prepare('SELECT * FROM decision_gates WHERE status = ? ORDER BY created_at') + .all(filter.status) as DecisionGateRow[] } - return this.countRows( - `SELECT COUNT(*) AS count FROM decision_gates${ - clauses.length > 0 ? ` WHERE ${clauses.join(' AND ')}` : '' - }`, - params - ) + return this.db + .prepare('SELECT * FROM decision_gates ORDER BY created_at') + .all() as DecisionGateRow[] } getGate(id: string): DecisionGateRow | undefined { - return this.db - .prepare( - `SELECT * FROM decision_gates - WHERE id = ? AND (${GATE_ROW_BYTES_SQL}) <= ${ORCHESTRATION_QUERY_MAX_ROW_UTF8_BYTES}` - ) - .get(id) as DecisionGateRow | undefined - } - - getLatestGate(filter: { taskId: string; status: GateStatus }): DecisionGateRow | undefined { - return this.db - .prepare( - `SELECT * FROM decision_gates - WHERE task_id = ? AND status = ? - AND (${GATE_ROW_BYTES_SQL}) <= ${ORCHESTRATION_QUERY_MAX_ROW_UTF8_BYTES} - ORDER BY created_at DESC, rowid DESC LIMIT 1` - ) - .get(filter.taskId, filter.status) as DecisionGateRow | undefined - } - - restorePendingGateTaskStatuses(): void { - this.db - .prepare( - `UPDATE tasks SET status = 'blocked' - WHERE status <> 'blocked' - AND EXISTS ( - SELECT 1 FROM decision_gates - WHERE decision_gates.task_id = tasks.id - AND decision_gates.status = 'pending' - )` - ) - .run() + return this.db.prepare('SELECT * FROM decision_gates WHERE id = ?').get(id) as + | DecisionGateRow + | undefined } // ── Coordinator Runs ── @@ -1240,24 +916,19 @@ export class OrchestrationDb { coordinatorHandle: string pollIntervalMs?: number }): CoordinatorRun { - assertOrchestrationWriteFits('Coordinator run', [run.spec, run.coordinatorHandle]) const id = generateId('run') this.db .prepare( "INSERT INTO coordinator_runs (id, spec, status, coordinator_handle, poll_interval_ms) VALUES (?, ?, 'running', ?, ?)" ) .run(id, run.spec, run.coordinatorHandle, run.pollIntervalMs ?? 2000) - return this.getCoordinatorRun(id)! + return this.db.prepare('SELECT * FROM coordinator_runs WHERE id = ?').get(id) as CoordinatorRun } getCoordinatorRun(id: string): CoordinatorRun | undefined { - return this.db - .prepare( - `SELECT * FROM coordinator_runs - WHERE id = ? - AND (${COORDINATOR_RUN_ROW_BYTES_SQL}) <= ${ORCHESTRATION_QUERY_MAX_ROW_UTF8_BYTES}` - ) - .get(id) as CoordinatorRun | undefined + return this.db.prepare('SELECT * FROM coordinator_runs WHERE id = ?').get(id) as + | CoordinatorRun + | undefined } updateCoordinatorRun(id: string, status: CoordinatorStatus): CoordinatorRun | undefined { @@ -1274,10 +945,7 @@ export class OrchestrationDb { getActiveCoordinatorRun(): CoordinatorRun | undefined { return this.db .prepare( - `SELECT * FROM coordinator_runs - WHERE status = 'running' - AND (${COORDINATOR_RUN_ROW_BYTES_SQL}) <= ${ORCHESTRATION_QUERY_MAX_ROW_UTF8_BYTES} - ORDER BY created_at DESC, rowid DESC LIMIT 1` + "SELECT * FROM coordinator_runs WHERE status = 'running' ORDER BY created_at DESC LIMIT 1" ) .get() as CoordinatorRun | undefined } @@ -1285,31 +953,22 @@ export class OrchestrationDb { // ── Queries for Coordinator ── getIdleTerminals(excludeHandles: string[] = []): string[] { - const rows = this.db + const active = this.db .prepare( - `WITH handles(handle) AS ( - SELECT to_handle FROM messages - UNION - SELECT from_handle FROM messages - ) - SELECT handle FROM handles - WHERE length(CAST(handle AS BLOB)) <= ${ORCHESTRATION_QUERY_MAX_ROW_UTF8_BYTES} - AND NOT EXISTS ( - SELECT 1 FROM dispatch_contexts - WHERE dispatch_contexts.assignee_handle = handles.handle - AND dispatch_contexts.status IN ('pending', 'dispatched') - ) - ORDER BY handle` + "SELECT DISTINCT assignee_handle FROM dispatch_contexts WHERE status IN ('pending', 'dispatched')" ) - .iterate() as Iterable<{ handle: string }> - const filtered = (function* (): Iterable<{ handle: string }> { - for (const row of rows) { - if (!excludeHandles.includes(row.handle)) { - yield row - } - } - })() - return retainOrchestrationQueryRows(filtered).map((row) => row.handle) + .all() as { assignee_handle: string }[] + const busyHandles = new Set(active.map((r) => r.assignee_handle)) + for (const h of excludeHandles) { + busyHandles.add(h) + } + // Return handles from message history that aren't busy + const allHandles = this.db + .prepare( + 'SELECT DISTINCT to_handle FROM messages UNION SELECT DISTINCT from_handle FROM messages' + ) + .all() as { to_handle: string }[] + return [...new Set(allHandles.map((r) => r.to_handle))].filter((h) => !busyHandles.has(h)) } // ── Lifecycle ── diff --git a/src/main/runtime/orchestration/lifecycle-reconciliation.ts b/src/main/runtime/orchestration/lifecycle-reconciliation.ts index 0485ebdeba8..96917babaf6 100644 --- a/src/main/runtime/orchestration/lifecycle-reconciliation.ts +++ b/src/main/runtime/orchestration/lifecycle-reconciliation.ts @@ -1,7 +1,6 @@ import type { OrchestrationDb } from './db' import type { MessageRow } from './types' import { parsePaneKey } from '../../../shared/stable-pane-id' -import { parseOrchestrationJson } from './query-retention' // Why: the tab half can change on pane break-out, while opaque legacy keys // have no safe equivalence beyond exact equality. @@ -54,10 +53,8 @@ function parseObjectPayload(msg: MessageRow, onInvalidJson: () => void): Record< } try { - const parsed = parseOrchestrationJson(msg.payload) - return parsed && typeof parsed === 'object' && !Array.isArray(parsed) - ? (parsed as Record) - : {} + const parsed: unknown = JSON.parse(msg.payload) + return parsed && typeof parsed === 'object' ? (parsed as Record) : {} } catch { onInvalidJson() return {} diff --git a/src/main/runtime/orchestration/query-retention.ts b/src/main/runtime/orchestration/query-retention.ts deleted file mode 100644 index b7e479c8c97..00000000000 --- a/src/main/runtime/orchestration/query-retention.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { - assertJsonTextStructureWithinLimits, - type JsonTextStructureLimits -} from '../../../shared/json-text-structure-limit' -import { measureUtf8ByteLength } from '../../../shared/utf8-byte-limits' - -export const ORCHESTRATION_QUERY_MAX_ROWS = 256 -export const ORCHESTRATION_QUERY_MAX_ROW_UTF8_BYTES = 2 * 1024 * 1024 -export const ORCHESTRATION_QUERY_MAX_RETAINED_UTF8_BYTES = 8 * 1024 * 1024 -export const ORCHESTRATION_WRITE_MAX_UTF8_BYTES = 512 * 1024 -export const ORCHESTRATION_WRITE_MAX_ITEMS = 4096 -export const ORCHESTRATION_WAIT_TYPE_FILTER_MAX_UTF8_BYTES = 1024 -export const ORCHESTRATION_JSON_STRUCTURE_LIMITS: JsonTextStructureLimits = { - structuralTokens: 64 * 1024, - nestingDepth: 64 -} - -export function parseOrchestrationJson(content: string): unknown { - assertJsonTextStructureWithinLimits(content, ORCHESTRATION_JSON_STRUCTURE_LIMITS) - return JSON.parse(content) as unknown -} - -export function clampOrchestrationQueryLimit( - requested: number | undefined, - fallback = ORCHESTRATION_QUERY_MAX_ROWS -): number { - if (requested === undefined || !Number.isFinite(requested)) { - return Math.min(fallback, ORCHESTRATION_QUERY_MAX_ROWS) - } - return Math.min(ORCHESTRATION_QUERY_MAX_ROWS, Math.max(0, Math.floor(requested))) -} - -export function orchestrationRowRetainedUtf8Bytes(row: object): number { - let bytes = 0 - for (const value of Object.values(row)) { - if (typeof value === 'string') { - bytes += Buffer.byteLength(value, 'utf8') - } - } - return bytes -} - -export function retainOrchestrationQueryRows( - rows: Iterable, - requestedLimit?: number -): T[] { - const limit = clampOrchestrationQueryLimit(requestedLimit) - if (limit === 0) { - return [] - } - const retained: T[] = [] - let retainedBytes = 0 - - for (const row of rows) { - const rowBytes = orchestrationRowRetainedUtf8Bytes(row) - if (rowBytes > ORCHESTRATION_QUERY_MAX_ROW_UTF8_BYTES) { - continue - } - if (retainedBytes + rowBytes > ORCHESTRATION_QUERY_MAX_RETAINED_UTF8_BYTES) { - break - } - retained.push(row) - retainedBytes += rowBytes - if (retained.length >= limit) { - break - } - } - return retained -} - -export function assertOrchestrationWriteFits(label: string, values: unknown[]): void { - let retainedBytes = 0 - for (const value of values) { - if (typeof value !== 'string') { - continue - } - retainedBytes += Buffer.byteLength(value, 'utf8') - if (retainedBytes > ORCHESTRATION_WRITE_MAX_UTF8_BYTES) { - throw new Error( - `${label} exceeds the ${ORCHESTRATION_WRITE_MAX_UTF8_BYTES}-byte orchestration limit` - ) - } - } -} - -export function assertOrchestrationWaitTypeFilterFits(value: string | undefined): void { - if ( - value && - measureUtf8ByteLength(value, { - stopAfterBytes: ORCHESTRATION_WAIT_TYPE_FILTER_MAX_UTF8_BYTES - }).exceededLimit - ) { - throw new Error( - `Message type filter exceeds the ${ORCHESTRATION_WAIT_TYPE_FILTER_MAX_UTF8_BYTES}-byte orchestration wait limit` - ) - } -} - -export function assertOrchestrationStringListFits(label: string, values: string[]): void { - if (values.length > ORCHESTRATION_WRITE_MAX_ITEMS) { - throw new Error( - `${label} exceeds the ${ORCHESTRATION_WRITE_MAX_ITEMS}-item orchestration limit` - ) - } - assertOrchestrationWriteFits(label, values) -} - -export function truncateOrchestrationDiagnostic(value: string): string { - if (Buffer.byteLength(value, 'utf8') <= ORCHESTRATION_WRITE_MAX_UTF8_BYTES) { - return value - } - - let bytes = 0 - let end = 0 - for (const codePoint of value) { - const codePointBytes = Buffer.byteLength(codePoint, 'utf8') - if (bytes + codePointBytes > ORCHESTRATION_WRITE_MAX_UTF8_BYTES) { - break - } - bytes += codePointBytes - end += codePoint.length - } - return value.slice(0, end) -} diff --git a/src/main/runtime/recent-pty-output-buffer.test.ts b/src/main/runtime/recent-pty-output-buffer.test.ts index 5b0dac0742b..6c21284e163 100644 --- a/src/main/runtime/recent-pty-output-buffer.test.ts +++ b/src/main/runtime/recent-pty-output-buffer.test.ts @@ -93,34 +93,6 @@ describe('RecentPtyOutputBuffer', () => { expectEquivalent(chunks) }) - it('retains 100,000 tiny writes in compact payload storage and typed boundary metadata', () => { - const buffer = new RecentPtyOutputBuffer() - let reference: string | undefined - for (let index = 0; index < 100_000; index += 1) { - const chunk = String.fromCharCode(97 + (index % 26)) - buffer.append(chunk) - reference = referenceAppend(reference, chunk) - } - - const internals = buffer as unknown as { - chunks: string[] - originalChunkLengths: { length: number; capacity: number } - } - expect(internals.chunks.length).toBeLessThanOrEqual(1024) - expect(internals.originalChunkLengths.length).toBe(RECENT_PTY_OUTPUT_LIMIT) - expect(internals.originalChunkLengths.capacity).toBe(RECENT_PTY_OUTPUT_LIMIT) - expect(buffer.read()).toBe(reference) - - let streamedLength = 0 - let streamedChunks = 0 - buffer.forEachRetainedChunk((chunk) => { - streamedLength += chunk.length - streamedChunks += 1 - }) - expect(streamedLength).toBe(RECENT_PTY_OUTPUT_LIMIT) - expect(streamedChunks).toBe(RECENT_PTY_OUTPUT_LIMIT) - }) - it('matches the old UTF-16 slice behavior for multi-byte content at the boundary', () => { // JS string .slice counts UTF-16 code units, so trimming can split a // surrogate pair; the buffer must reproduce that split exactly. diff --git a/src/main/runtime/recent-pty-output-buffer.ts b/src/main/runtime/recent-pty-output-buffer.ts index e18c46b96dd..a10559b75c9 100644 --- a/src/main/runtime/recent-pty-output-buffer.ts +++ b/src/main/runtime/recent-pty-output-buffer.ts @@ -1,69 +1,31 @@ export const RECENT_PTY_OUTPUT_LIMIT = 64 * 1024 -const RETAINED_CONTENT_CHUNK_LIMIT = 1024 -const DROPPED_CONTENT_CHUNK_LIMIT = 1024 -const INITIAL_BOUNDARY_CAPACITY = 16 +// Compact the backing array once this many fully-dropped head slots accumulate, +// so the array itself stays bounded under long chunk floods. +const DROPPED_HEAD_COMPACT_THRESHOLD = 1024 -class RetainedChunkLengthQueue { - private values = new Uint32Array(INITIAL_BOUNDARY_CAPACITY) - private head = 0 - private count = 0 - - get length(): number { - return this.count - } - - get capacity(): number { - return this.values.length - } - - push(value: number): void { - if (this.count === this.values.length) { - const grown = new Uint32Array(this.values.length * 2) - for (let index = 0; index < this.count; index += 1) { - grown[index] = this.at(index) - } - this.values = grown - this.head = 0 - } - this.values[(this.head + this.count) % this.values.length] = value - this.count += 1 - } - - shift(): number { - if (this.count === 0) { - return 0 - } - const value = this.values[this.head]! - this.head = (this.head + 1) % this.values.length - this.count -= 1 - if (this.count === 0) { - this.head = 0 - } - return value - } - - at(index: number): number { - return this.values[(this.head + index) % this.values.length]! - } - - reset(): void { - this.values = new Uint32Array(INITIAL_BOUNDARY_CAPACITY) - this.head = 0 - this.count = 0 - } -} - -/** Bounded raw PTY tail that temporarily preserves source chunk boundaries for path backfill. */ +/** + * Bounded deque of raw PTY output chunks retaining exactly the last + * RECENT_PTY_OUTPUT_LIMIT UTF-16 code units. + * + * Why: eagerly rebuilding a rolling 64KB string per PTY chunk flattened a + * ~128KB rope on every write; keep chunks and defer the join to rare readers. + */ export class RecentPtyOutputBuffer { private chunks: string[] = [] - private contentHeadIndex = 0 - private contentHeadOffset = 0 - private totalLen = 0 + private headIndex = 0 + // Code units already trimmed off the front of the head chunk. Deferred so + // repeated small trims never allocate a substring per append, and so the + // head chunk's original text stays available for candidate backfill. private headOffset = 0 + private totalLen = 0 + // True when the stored head chunk is not the full original PTY chunk (a + // single over-limit append is stored pre-sliced), so backfill replay knows + // the original line context of its leading text is gone. private headChunkIsPartial = false + // Original chunk boundaries are owed only to the one-time path-candidate + // backfill; compact() ends that obligation and lets read() collapse. private preserveChunkBoundaries: boolean - private readonly originalChunkLengths = new RetainedChunkLengthQueue() constructor(options?: { preserveChunkBoundaries?: boolean }) { this.preserveChunkBoundaries = options?.preserveChunkBoundaries ?? true @@ -74,145 +36,88 @@ export class RecentPtyOutputBuffer { return } if (data.length >= RECENT_PTY_OUTPUT_LIMIT) { - this.replaceContent(data.slice(-RECENT_PTY_OUTPUT_LIMIT)) - this.totalLen = RECENT_PTY_OUTPUT_LIMIT + this.chunks = [data.slice(-RECENT_PTY_OUTPUT_LIMIT)] + this.headIndex = 0 this.headOffset = 0 + this.totalLen = RECENT_PTY_OUTPUT_LIMIT this.headChunkIsPartial = data.length > RECENT_PTY_OUTPUT_LIMIT - this.originalChunkLengths.reset() - if (this.preserveChunkBoundaries) { - this.originalChunkLengths.push(RECENT_PTY_OUTPUT_LIMIT) - } return } - - this.appendContent(data) - this.totalLen += data.length - if (this.preserveChunkBoundaries) { - this.trimPreservingBoundaries() - this.originalChunkLengths.push(data.length) - } else { - this.trimContentToWindow() - } - } - - read(): string { - const stored = this.storedContent() - const value = this.headOffset > 0 ? stored.slice(this.headOffset) : stored - if (!this.preserveChunkBoundaries) { - this.replaceContent(value) - } - return value - } - - /** Original PTY chunks retained for the one-time path-candidate backfill. */ - retainedChunks(): { chunks: string[]; headChunkIsPartial: boolean } { - const chunks: string[] = [] - const state = this.forEachRetainedChunk((chunk) => chunks.push(chunk)) - return { chunks, headChunkIsPartial: state.headChunkIsPartial } - } - - /** Streams boundaries so activation does not allocate one string object per tiny write. */ - forEachRetainedChunk( - visit: (chunk: string, index: number, headChunkIsPartial: boolean) => void - ): { - headChunkIsPartial: boolean - } { - if (!this.preserveChunkBoundaries) { - const value = this.read() - if (value) { - visit(value, 0, false) - } - return { headChunkIsPartial: false } - } - const stored = this.storedContent() - let offset = 0 - for (let index = 0; index < this.originalChunkLengths.length; index += 1) { - const length = this.originalChunkLengths.at(index) - visit(stored.slice(offset, offset + length), index, this.headChunkIsPartial) - offset += length - } - return { headChunkIsPartial: this.headChunkIsPartial } - } - - /** Ends the boundary obligation and returns to compact steady-state storage. */ - compact(): void { - const value = this.read() - this.preserveChunkBoundaries = false - this.replaceContent(value) - this.headOffset = 0 - this.headChunkIsPartial = false - this.originalChunkLengths.reset() - } - - private appendContent(data: string): void { this.chunks.push(data) - if (this.chunks.length - this.contentHeadIndex > RETAINED_CONTENT_CHUNK_LIMIT) { - this.replaceContent(this.storedContent()) - } - } - - private trimPreservingBoundaries(): void { + this.totalLen += data.length while (this.totalLen > RECENT_PTY_OUTPUT_LIMIT) { - const originalHeadLength = this.originalChunkLengths.at(0) - const headRemaining = originalHeadLength - this.headOffset + const headRemaining = this.chunks[this.headIndex].length - this.headOffset const excess = this.totalLen - RECENT_PTY_OUTPUT_LIMIT if (headRemaining <= excess) { - this.totalLen -= headRemaining + // Release the dropped chunk's reference; the slot is reclaimed on compaction. + this.chunks[this.headIndex] = '' + this.headIndex += 1 this.headOffset = 0 this.headChunkIsPartial = false - this.originalChunkLengths.shift() - this.dropContentPrefix(originalHeadLength) + this.totalLen -= headRemaining } else { this.headOffset += excess this.totalLen -= excess } } - } - - private trimContentToWindow(): void { - const excess = this.totalLen - RECENT_PTY_OUTPUT_LIMIT - if (excess <= 0) { - return + if (this.headIndex >= DROPPED_HEAD_COMPACT_THRESHOLD) { + this.chunks = this.chunks.slice(this.headIndex) + this.headIndex = 0 } - this.dropContentPrefix(excess) - this.totalLen -= excess } - private dropContentPrefix(length: number): void { - let remaining = length - while (remaining > 0) { - const chunk = this.chunks[this.contentHeadIndex] ?? '' - const available = chunk.length - this.contentHeadOffset - if (available <= remaining) { - this.chunks[this.contentHeadIndex] = '' - this.contentHeadIndex += 1 - this.contentHeadOffset = 0 - remaining -= available - } else { - this.contentHeadOffset += remaining - remaining = 0 + read(): string { + if (this.preserveChunkBoundaries) { + // Join without mutating: boundaries and the original head chunk are + // still owed to retainedChunks(); reads are rare before compact(). + if (this.chunks.length - this.headIndex > 1) { + const retained = this.chunks.slice(this.headIndex) + if (this.headOffset > 0) { + retained[0] = retained[0].slice(this.headOffset) + } + return retained.join('') } + const head = this.chunks[this.headIndex] ?? '' + return this.headOffset > 0 ? head.slice(this.headOffset) : head } - if (this.contentHeadIndex >= DROPPED_CONTENT_CHUNK_LIMIT) { - this.chunks = this.chunks.slice(this.contentHeadIndex) - this.contentHeadIndex = 0 + if (this.chunks.length - this.headIndex > 1) { + // Collapse to the joined tail so repeated reads stay O(1). + const retained = this.chunks.slice(this.headIndex) + if (this.headOffset > 0) { + retained[0] = retained[0].slice(this.headOffset) + this.headOffset = 0 + } + this.chunks = [retained.join('')] + this.headIndex = 0 + } else if (this.headOffset > 0) { + // Single retained chunk: apply the deferred head trim once, here. + this.chunks[this.headIndex] = this.chunks[this.headIndex].slice(this.headOffset) + this.headOffset = 0 + } + return this.chunks[this.headIndex] ?? '' + } + + /** + * Retained chunks with original PTY boundaries. The head chunk is its full + * original text (any window-trimmed prefix included) unless + * headChunkIsPartial. Why: path-candidate backfill must replay the eager + * per-chunk extraction exactly — trimming or joining chunks changes the + * candidate set. Only meaningful before compact(). + */ + retainedChunks(): { chunks: string[]; headChunkIsPartial: boolean } { + return { + chunks: this.chunks.slice(this.headIndex), + headChunkIsPartial: this.headChunkIsPartial } } - private storedContent(): string { - const retained = this.chunks.slice(this.contentHeadIndex) - if (retained.length === 0) { - return '' - } - if (this.contentHeadOffset > 0) { - retained[0] = retained[0]!.slice(this.contentHeadOffset) - } - return retained.length === 1 ? retained[0]! : retained.join('') - } - - private replaceContent(value: string): void { - this.chunks = value ? [value] : [] - this.contentHeadIndex = 0 - this.contentHeadOffset = 0 + /** + * Ends the chunk-boundary obligation after the one-time backfill and + * collapses immediately, so the append/read hot path returns to the + * compact single-chunk steady state. + */ + compact(): void { + this.preserveChunkBoundaries = false + this.read() } } diff --git a/src/main/runtime/relay/relay-control-client.test.ts b/src/main/runtime/relay/relay-control-client.test.ts index e07fa5aa434..415b4583d46 100644 --- a/src/main/runtime/relay/relay-control-client.test.ts +++ b/src/main/runtime/relay/relay-control-client.test.ts @@ -2,13 +2,8 @@ import { createHash, createHmac, randomBytes } from 'node:crypto' import { afterEach, describe, expect, it, vi } from 'vitest' import nacl from 'tweetnacl' import { WebSocketServer, type WebSocket } from 'ws' -import { JsonStringifyByteLimitError } from '../../../shared/node-bounded-json-stringify' import type { E2EEKeypair } from '../e2ee-keypair' -import { - RELAY_CONTROL_MAX_MESSAGE_BYTES, - RelayControlClient, - serializeRelayControlMessage -} from './relay-control-client' +import { RelayControlClient } from './relay-control-client' const encoder = new TextEncoder() const HOST_PROOF_DOMAIN = 'orca-relay-host-proof/v1' @@ -85,21 +80,6 @@ describe('RelayControlClient', () => { const servers: WebSocketServer[] = [] const clients: RelayControlClient[] = [] - it('preserves ordinary control message serialization byte-for-byte', () => { - const payload = { type: 'auth-refresh', relayJwt: 'token-😀' } - - expect(serializeRelayControlMessage(payload)).toBe(JSON.stringify(payload)) - }) - - it('rejects an oversized control message during bounded serialization', () => { - expect(() => - serializeRelayControlMessage({ - type: 'auth-refresh', - relayJwt: 'x'.repeat(RELAY_CONTROL_MAX_MESSAGE_BYTES) - }) - ).toThrow(JsonStringifyByteLimitError) - }) - afterEach(async () => { for (const client of clients.splice(0)) { client.closeNow() diff --git a/src/main/runtime/relay/relay-control-client.ts b/src/main/runtime/relay/relay-control-client.ts index 750e59359e3..44f3d65798c 100644 --- a/src/main/runtime/relay/relay-control-client.ts +++ b/src/main/runtime/relay/relay-control-client.ts @@ -1,7 +1,6 @@ import { randomUUID } from 'node:crypto' import WebSocket, { type RawData } from 'ws' import { MOBILE_RELAY_CLOSE_CODE } from '../../../shared/mobile-relay-close-codes' -import { stringifyJsonWithinByteLimit } from '../../../shared/node-bounded-json-stringify' import type { E2EEKeypair } from '../e2ee-keypair' import { RelayConnectionOpenMessageSchema, @@ -20,7 +19,6 @@ import type { DeviceCredentialInstallAuthorization } from './relay-control-reque import { answerRelayHostChallenge } from './relay-host-proof' type RelayControlState = 'idle' | 'opening' | 'proving' | 'active' | 'draining' | 'closed' -export const RELAY_CONTROL_MAX_MESSAGE_BYTES = 64 * 1024 type RelayControlClientOptions = { cellUrl: string @@ -76,7 +74,7 @@ export class RelayControlClient { new WebSocket(url, { headers: { authorization: `Bearer ${token}` }, perMessageDeflate: false, - maxPayload: RELAY_CONTROL_MAX_MESSAGE_BYTES + maxPayload: 64 * 1024 })) } @@ -167,7 +165,7 @@ export class RelayControlClient { } this.state = 'proving' this.socket.send( - serializeRelayControlMessage({ + JSON.stringify({ type: 'host-hello', v: 1, relayHostId: this.options.relayHostId, @@ -199,7 +197,7 @@ export class RelayControlClient { return } if (RelayPingMessageSchema.safeParse(message).success) { - this.socket?.send(serializeRelayControlMessage({ type: 'pong', t: message.t })) + this.socket?.send(JSON.stringify({ type: 'pong', t: message.t })) return } const connection = RelayConnectionOpenMessageSchema.safeParse(message) @@ -237,7 +235,7 @@ export class RelayControlClient { return } this.socket?.send( - serializeRelayControlMessage({ + JSON.stringify({ type: 'host-challenge-ack', challengeId: challenge.data.challengeId, proofB64 @@ -259,7 +257,7 @@ export class RelayControlClient { if (!this.socket || (this.state !== 'active' && this.state !== 'draining')) { throw new Error('relay_control_not_active') } - this.socket.send(serializeRelayControlMessage(payload)) + this.socket.send(JSON.stringify(payload)) } private failProtocol(reason: string): void { @@ -284,7 +282,3 @@ export class RelayControlClient { this.connectReject = null } } - -export function serializeRelayControlMessage(payload: unknown): string { - return stringifyJsonWithinByteLimit(payload, RELAY_CONTROL_MAX_MESSAGE_BYTES).serialized -} diff --git a/src/main/runtime/relay/relay-control-requests.test.ts b/src/main/runtime/relay/relay-control-requests.test.ts deleted file mode 100644 index bafa0611d35..00000000000 --- a/src/main/runtime/relay/relay-control-requests.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { - RELAY_CONTROL_MAX_PENDING_REQUESTS, - RELAY_CONTROL_REQUEST_ID_MAX_UTF8_BYTES, - RelayControlRequests -} from './relay-control-requests' - -afterEach(() => { - vi.useRealTimers() -}) - -describe('RelayControlRequests admission', () => { - it('fails closed at the pending request cap', async () => { - vi.useFakeTimers() - const requests = new RelayControlRequests() - const send = vi.fn() - const admitted = Array.from({ length: RELAY_CONTROL_MAX_PENDING_REQUESTS }, (_, index) => - requests.createInvite(`request-${index}`, 'device-a', send) - ) - const settlements = Promise.allSettled(admitted) - - await expect(requests.createInvite('request-overflow', 'device-a', send)).rejects.toThrow( - 'relay_control_request_limit' - ) - expect(requests.size).toBe(RELAY_CONTROL_MAX_PENDING_REQUESTS) - expect(send).toHaveBeenCalledTimes(RELAY_CONTROL_MAX_PENDING_REQUESTS) - - requests.rejectAll(new Error('closed')) - await settlements - expect(requests.size).toBe(0) - expect(vi.getTimerCount()).toBe(0) - }) - - it('rejects an oversized request id before sending or retaining it', async () => { - const requests = new RelayControlRequests() - const send = vi.fn() - - await expect( - requests.createInvite('🙂'.repeat(RELAY_CONTROL_REQUEST_ID_MAX_UTF8_BYTES), 'device-a', send) - ).rejects.toThrow('relay_control_request_limit') - - expect(requests.size).toBe(0) - expect(send).not.toHaveBeenCalled() - }) - - it('releases admission when send throws', async () => { - vi.useFakeTimers() - const requests = new RelayControlRequests() - - await expect( - requests.createInvite('request-a', 'device-a', () => { - throw new Error('send failed') - }) - ).rejects.toThrow('send failed') - - expect(requests.size).toBe(0) - expect(vi.getTimerCount()).toBe(0) - }) - - it('releases admission when a request times out', async () => { - vi.useFakeTimers() - const requests = new RelayControlRequests() - const result = requests.createInvite('request-a', 'device-a', () => {}) - const rejection = expect(result).rejects.toThrow('relay_control_request_timeout') - - await vi.advanceTimersByTimeAsync(10_000) - - await rejection - expect(requests.size).toBe(0) - expect(vi.getTimerCount()).toBe(0) - }) -}) diff --git a/src/main/runtime/relay/relay-control-requests.ts b/src/main/runtime/relay/relay-control-requests.ts index 36629a10239..2a96b94e3ec 100644 --- a/src/main/runtime/relay/relay-control-requests.ts +++ b/src/main/runtime/relay/relay-control-requests.ts @@ -16,21 +16,14 @@ type PendingRequest = { resolve: (value: unknown) => void reject: (error: Error) => void timer: ReturnType - reqIdBytes: number } -export const RELAY_CONTROL_MAX_PENDING_REQUESTS = 256 -export const RELAY_CONTROL_REQUEST_ID_MAX_UTF8_BYTES = 512 -export const RELAY_CONTROL_RETAINED_REQUEST_ID_MAX_UTF8_BYTES = - RELAY_CONTROL_MAX_PENDING_REQUESTS * RELAY_CONTROL_REQUEST_ID_MAX_UTF8_BYTES - export type DeviceCredentialInstallAuthorization = | { mode: 'relay-basis'; basisConnId: string } | { mode: 'authenticated-direct'; directAuthId: string } export class RelayControlRequests { private readonly pending = new Map() - private retainedRequestIdBytes = 0 get size(): number { return this.pending.size @@ -167,22 +160,12 @@ export class RelayControlRequests { if (this.pending.has(reqId)) { return Promise.reject(new Error('duplicate_relay_request_id')) } - const reqIdBytes = Buffer.byteLength(reqId, 'utf8') - if ( - reqId.length === 0 || - reqIdBytes > RELAY_CONTROL_REQUEST_ID_MAX_UTF8_BYTES || - this.pending.size >= RELAY_CONTROL_MAX_PENDING_REQUESTS || - reqIdBytes > RELAY_CONTROL_RETAINED_REQUEST_ID_MAX_UTF8_BYTES - this.retainedRequestIdBytes - ) { - return Promise.reject(new Error('relay_control_request_limit')) - } return new Promise((resolve, reject) => { const timer = setTimeout(() => { - this.finish(reqId) + this.pending.delete(reqId) reject(new Error('relay_control_request_timeout')) }, 10_000) - this.pending.set(reqId, { kind, resolve, reject, timer, reqIdBytes }) - this.retainedRequestIdBytes += reqIdBytes + this.pending.set(reqId, { kind, resolve, reject, timer }) try { send(payload) } catch (error) { @@ -197,7 +180,6 @@ export class RelayControlRequests { if (pending) { clearTimeout(pending.timer) this.pending.delete(reqId) - this.retainedRequestIdBytes -= pending.reqIdBytes } } } diff --git a/src/main/runtime/relay/relay-host-proof-bounds.test.ts b/src/main/runtime/relay/relay-host-proof-bounds.test.ts deleted file mode 100644 index 602b1bee44b..00000000000 --- a/src/main/runtime/relay/relay-host-proof-bounds.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import nacl from 'tweetnacl' -import { - answerRelayHostChallenge, - RELAY_HOST_CHALLENGE_MAX_CIPHERTEXT_BASE64_CHARACTERS -} from './relay-host-proof' - -describe('relay host proof base64 admission', () => { - it('rejects oversized fixed-width fields before decoding', () => { - const decode = vi.spyOn(Buffer, 'from') - - expect( - answerRelayHostChallenge( - { - challengeId: 'challenge', - relayEphemeralPublicKeyB64: 'A'.repeat(45), - nonceB64: 'A'.repeat(33), - ciphertextB64: 'AAAA', - expiresAt: Date.now() + 1_000 - }, - context() - ) - ).toBeNull() - expect(decode).not.toHaveBeenCalled() - decode.mockRestore() - }) - - it('rejects oversized ciphertext before decoding that field', () => { - const relayKey = Buffer.from(new Uint8Array(32)).toString('base64') - const nonce = Buffer.from(new Uint8Array(24)).toString('base64') - const decode = vi.spyOn(Buffer, 'from') - - expect( - answerRelayHostChallenge( - { - challengeId: 'challenge', - relayEphemeralPublicKeyB64: relayKey, - nonceB64: nonce, - ciphertextB64: 'A'.repeat(RELAY_HOST_CHALLENGE_MAX_CIPHERTEXT_BASE64_CHARACTERS + 1), - expiresAt: Date.now() + 1_000 - }, - context() - ) - ).toBeNull() - expect(decode).toHaveBeenCalledTimes(2) - decode.mockRestore() - }) -}) - -function context() { - const host = nacl.box.keyPair() - return { - relayOrigin: 'https://relay.example', - userId: 'user', - profileId: 'profile', - organizationId: 'organization', - relayHostId: 'host', - hostPublicKey: host.publicKey, - hostSecretKey: host.secretKey, - assignmentEpoch: 1, - resumeRequested: false - } -} diff --git a/src/main/runtime/relay/relay-host-proof.ts b/src/main/runtime/relay/relay-host-proof.ts index 9fcc29ed9d9..94db5b2f1b2 100644 --- a/src/main/runtime/relay/relay-host-proof.ts +++ b/src/main/runtime/relay/relay-host-proof.ts @@ -5,7 +5,6 @@ const HOST_PROOF_TRANSCRIPT_DOMAIN = 'orca-relay-host-proof/v1' const HOST_CHALLENGE_PLAINTEXT_DOMAIN = 'orca-relay-host-challenge/v1' const textEncoder = new TextEncoder() const textDecoder = new TextDecoder() -export const RELAY_HOST_CHALLENGE_MAX_CIPHERTEXT_BASE64_CHARACTERS = 16 * 1024 export type RelayHostChallenge = { challengeId: string @@ -30,10 +29,7 @@ export type RelayHostProofContext = { } function decodeCanonicalBase64(value: string, expectedBytes: number): Uint8Array | null { - if ( - value.length !== Math.ceil(expectedBytes / 3) * 4 || - !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value) - ) { + if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) { return null } const decoded = Buffer.from(value, 'base64') @@ -133,18 +129,8 @@ export function answerRelayHostChallenge( ): string | null { const relayKey = decodeCanonicalBase64(challenge.relayEphemeralPublicKeyB64, 32) const nonce = decodeCanonicalBase64(challenge.nonceB64, 24) - if ( - !relayKey || - !nonce || - challenge.ciphertextB64.length > RELAY_HOST_CHALLENGE_MAX_CIPHERTEXT_BASE64_CHARACTERS || - !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test( - challenge.ciphertextB64 - ) - ) { - return null - } const ciphertext = Buffer.from(challenge.ciphertextB64, 'base64') - if (ciphertext.toString('base64') !== challenge.ciphertextB64) { + if (!relayKey || !nonce || ciphertext.toString('base64') !== challenge.ciphertextB64) { return null } const plaintext = nacl.box.open(ciphertext, nonce, relayKey, context.hostSecretKey) diff --git a/src/main/runtime/relay/relay-http-client.test.ts b/src/main/runtime/relay/relay-http-client.test.ts index 68e27b157ac..57beb527497 100644 --- a/src/main/runtime/relay/relay-http-client.test.ts +++ b/src/main/runtime/relay/relay-http-client.test.ts @@ -1,9 +1,5 @@ import { describe, expect, it, vi } from 'vitest' import nacl from 'tweetnacl' -import { - API_RESPONSE_MAX_BYTES, - FetchResponseBodyTooLargeError -} from '../../lib/fetch-response-body' import { cancelTrackingResponse } from '../../lib/unread-response-body.test-fixtures' import { exchangeRelayAuthorization, requestRelayAssignment } from './relay-http-client' @@ -108,30 +104,4 @@ describe('relay HTTP client', () => { ).rejects.toThrow() expect(cancelledBodies).toBe(2) }) - - it('rejects oversized successful responses from injected standard fetch clients', async () => { - let cancelled = false - const response = new Response( - new ReadableStream({ - start(controller) { - controller.enqueue(new Uint8Array([123])) - }, - cancel() { - cancelled = true - } - }), - { headers: { 'content-length': String(API_RESPONSE_MAX_BYTES + 1) } } - ) - const fetch = vi.fn(async () => response) - - await expect( - requestRelayAssignment({ - directorUrl: 'https://relay.example', - relayToken: 'scoped-token', - relayHostId: 'AbCdEf0123_-xyZ9', - fetch - }) - ).rejects.toBeInstanceOf(FetchResponseBodyTooLargeError) - expect(cancelled).toBe(true) - }) }) diff --git a/src/main/runtime/relay/relay-http-client.ts b/src/main/runtime/relay/relay-http-client.ts index 6862f26ce6c..2dc135ee6bf 100644 --- a/src/main/runtime/relay/relay-http-client.ts +++ b/src/main/runtime/relay/relay-http-client.ts @@ -2,7 +2,6 @@ import { createHash } from 'node:crypto' import { z } from 'zod' import type { E2EEKeypair } from '../e2ee-keypair' import { cancelUnreadResponseBody } from '../../lib/unread-response-body' -import { readFetchResponseJsonWithinLimit } from '../../lib/fetch-response-body' const RelayTokenResponseSchema = z .object({ @@ -86,9 +85,7 @@ export async function exchangeRelayAuthorization(input: { await cancelUnreadResponseBody(response) throw new RelayHttpError('token-exchange', response.status) } - const parsed = RelayTokenResponseSchema.safeParse( - await readFetchResponseJsonWithinLimit(response) - ) + const parsed = RelayTokenResponseSchema.safeParse(await response.json()) if (!parsed.success) { throw new RelayHttpError('token-exchange', 502) } @@ -116,9 +113,7 @@ export async function requestRelayAssignment(input: { await cancelUnreadResponseBody(response) throw new RelayHttpError('assignment', response.status) } - const parsed = AssignmentResponseSchema.safeParse( - await readFetchResponseJsonWithinLimit(response) - ) + const parsed = AssignmentResponseSchema.safeParse(await response.json()) if (!parsed.success || !isAllowedRelayOrigin(parsed.data.cellUrl)) { throw new RelayHttpError('assignment', 502) } diff --git a/src/main/runtime/relay/relay-revoke-outbox.test.ts b/src/main/runtime/relay/relay-revoke-outbox.test.ts index a4a4724164f..54ab0473049 100644 --- a/src/main/runtime/relay/relay-revoke-outbox.test.ts +++ b/src/main/runtime/relay/relay-revoke-outbox.test.ts @@ -1,14 +1,8 @@ -import { mkdtempSync, rmSync, truncateSync, writeFileSync } from 'node:fs' +import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { - MAX_RELAY_REVOKE_OUTBOX_FILE_BYTES, - MAX_RELAY_REVOKE_OUTBOX_ITEMS, - RelayRevokeOutbox, - RelayRevokeOutboxCapacityError, - type RelayRevokeOutboxItem -} from './relay-revoke-outbox' +import { RelayRevokeOutbox } from './relay-revoke-outbox' describe('RelayRevokeOutbox', () => { const paths: string[] = [] @@ -35,70 +29,4 @@ describe('RelayRevokeOutbox', () => { new RelayRevokeOutbox(path).pendingFor(binding.ownerIdentityKey, binding.relayHostId) ).toEqual([]) }) - - it('treats an oversized sparse outbox file as unavailable', () => { - const path = mkdtempSync(join(tmpdir(), 'orca-relay-revoke-bound-')) - paths.push(path) - const outboxPath = join(path, 'mobile-relay-revoke-outbox.json') - writeFileSync(outboxPath, '[]') - truncateSync(outboxPath, MAX_RELAY_REVOKE_OUTBOX_FILE_BYTES + 1) - - expect(new RelayRevokeOutbox(path).pendingFor('owner', 'host')).toEqual([]) - }) - - it('fails closed instead of dropping revocations beyond the retained-item bound', () => { - const path = mkdtempSync(join(tmpdir(), 'orca-relay-revoke-count-')) - paths.push(path) - const items: RelayRevokeOutboxItem[] = Array.from( - { length: MAX_RELAY_REVOKE_OUTBOX_ITEMS + 1 }, - (_, index) => ({ - reqId: `request-${index}`, - relayHostId: 'host', - relayDeviceId: `device-${index}`, - ownerIdentityKey: 'owner', - createdAt: index - }) - ) - writeFileSync(join(path, 'mobile-relay-revoke-outbox.json'), JSON.stringify(items)) - - expect(() => new RelayRevokeOutbox(path)).toThrow(RelayRevokeOutboxCapacityError) - }) - - it('keeps idempotent revokes usable at capacity and rejects only a new revoke', () => { - const path = mkdtempSync(join(tmpdir(), 'orca-relay-revoke-capacity-')) - paths.push(path) - const items: RelayRevokeOutboxItem[] = Array.from( - { length: MAX_RELAY_REVOKE_OUTBOX_ITEMS }, - (_, index) => ({ - reqId: `request-${index}`, - relayHostId: 'host', - relayDeviceId: `device-${index}`, - ownerIdentityKey: 'owner', - createdAt: index - }) - ) - writeFileSync(join(path, 'mobile-relay-revoke-outbox.json'), JSON.stringify(items)) - const outbox = new RelayRevokeOutbox(path) - - expect(outbox.enqueue(items[0]!).reqId).toBe(items[0]!.reqId) - expect(() => - outbox.enqueue({ relayHostId: 'host', relayDeviceId: 'new', ownerIdentityKey: 'owner' }) - ).toThrow(RelayRevokeOutboxCapacityError) - }) - - it('rejects a byte-oversized revoke without publishing partial in-memory state', () => { - const path = mkdtempSync(join(tmpdir(), 'orca-relay-revoke-byte-capacity-')) - paths.push(path) - const outbox = new RelayRevokeOutbox(path) - - expect(() => - outbox.enqueue({ - relayHostId: 'host', - relayDeviceId: 'device', - ownerIdentityKey: 'x'.repeat(MAX_RELAY_REVOKE_OUTBOX_FILE_BYTES) - }) - ).toThrow(RelayRevokeOutboxCapacityError) - expect(outbox.pendingFor('owner', 'host')).toEqual([]) - expect(new RelayRevokeOutbox(path).pendingFor('owner', 'host')).toEqual([]) - }) }) diff --git a/src/main/runtime/relay/relay-revoke-outbox.ts b/src/main/runtime/relay/relay-revoke-outbox.ts index 5fd866ad008..8a7e8cc19bb 100644 --- a/src/main/runtime/relay/relay-revoke-outbox.ts +++ b/src/main/runtime/relay/relay-revoke-outbox.ts @@ -1,10 +1,7 @@ import { randomUUID } from 'node:crypto' -import { existsSync } from 'node:fs' +import { existsSync, readFileSync } from 'node:fs' import { join } from 'node:path' -import { readNodeFileSyncWithinLimit } from '../../../shared/node-bounded-file-reader' -import { JsonStringifyByteLimitError } from '../../../shared/node-bounded-json-stringify' -import { writeSecureJsonFileWithinLimit } from '../../../shared/bounded-secure-json-file' -import { hardenExistingSecureFile } from '../../../shared/secure-file' +import { hardenExistingSecureFile, writeSecureJsonFile } from '../../../shared/secure-file' export type RelayDeviceBinding = { relayHostId: string @@ -19,15 +16,6 @@ export type RelayRevokeOutboxItem = RelayDeviceBinding & { } const OUTBOX_FILENAME = 'mobile-relay-revoke-outbox.json' -export const MAX_RELAY_REVOKE_OUTBOX_FILE_BYTES = 1024 * 1024 -export const MAX_RELAY_REVOKE_OUTBOX_ITEMS = 4096 - -export class RelayRevokeOutboxCapacityError extends Error { - constructor() { - super(`Relay revoke outbox exceeds ${MAX_RELAY_REVOKE_OUTBOX_ITEMS} items`) - this.name = 'RelayRevokeOutboxCapacityError' - } -} function isItem(value: unknown): value is RelayRevokeOutboxItem { if (!value || typeof value !== 'object') { @@ -65,13 +53,9 @@ export class RelayRevokeOutbox { if (existing) { return existing } - if (this.items.length >= MAX_RELAY_REVOKE_OUTBOX_ITEMS) { - throw new RelayRevokeOutboxCapacityError() - } const item = { ...binding, reqId: randomUUID(), createdAt: Date.now() } - const next = [...this.items, item] - this.save(next) - this.items = next + this.items.push(item) + this.save() return item } @@ -86,44 +70,24 @@ export class RelayRevokeOutbox { if (next.length === this.items.length) { return } - this.save(next) this.items = next + this.save() } private load(): RelayRevokeOutboxItem[] { if (!existsSync(this.path)) { return [] } - let parsed: unknown try { hardenExistingSecureFile(this.path) - parsed = JSON.parse( - readNodeFileSyncWithinLimit(this.path, MAX_RELAY_REVOKE_OUTBOX_FILE_BYTES).buffer.toString( - 'utf8' - ) - ) + const parsed: unknown = JSON.parse(readFileSync(this.path, 'utf-8')) + return Array.isArray(parsed) ? parsed.filter(isItem) : [] } catch { return [] } - if (!Array.isArray(parsed)) { - return [] - } - const items = parsed.filter(isItem) - if (items.length > MAX_RELAY_REVOKE_OUTBOX_ITEMS) { - // Why: silently dropping durable revocations could leave remote credentials active. - throw new RelayRevokeOutboxCapacityError() - } - return items } - private save(items: RelayRevokeOutboxItem[]): void { - try { - writeSecureJsonFileWithinLimit(this.path, items, MAX_RELAY_REVOKE_OUTBOX_FILE_BYTES) - } catch (error) { - if (error instanceof JsonStringifyByteLimitError) { - throw new RelayRevokeOutboxCapacityError() - } - throw error - } + private save(): void { + writeSecureJsonFile(this.path, this.items) } } diff --git a/src/main/runtime/rpc/dispatcher-output-bounds.test.ts b/src/main/runtime/rpc/dispatcher-output-bounds.test.ts deleted file mode 100644 index 2bab56bbd08..00000000000 --- a/src/main/runtime/rpc/dispatcher-output-bounds.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES } from '../../../shared/remote-runtime-memory-limits' -import type { OrcaRuntimeService } from '../orca-runtime' -import { defineMethod, defineStreamingMethod, type RpcRequest } from './core' -import { RpcDispatcher } from './dispatcher' - -const request = (method: string): RpcRequest => ({ - id: 'request-1', - authToken: 'unused', - method -}) - -function runtime(): OrcaRuntimeService { - return { - getRuntimeId: () => 'runtime-1', - recordFeatureInteraction: vi.fn() - } as unknown as OrcaRuntimeService -} - -describe('RpcDispatcher outbound response bounds', () => { - it('preserves ordinary streaming response serialization byte-for-byte', async () => { - const dispatcher = new RpcDispatcher({ - runtime: runtime(), - methods: [ - defineMethod({ - name: 'test.normal', - params: null, - handler: () => ({ text: 'hello 😀' }) - }) - ] - }) - const reply = vi.fn() - - await dispatcher.dispatchStreaming(request('test.normal'), reply) - - expect(reply).toHaveBeenCalledWith( - JSON.stringify({ - id: 'request-1', - ok: true, - result: { text: 'hello 😀' }, - _meta: { runtimeId: 'runtime-1' } - }) - ) - }) - - it('replaces an oversized one-shot result with a small correlated error', async () => { - const dispatcher = new RpcDispatcher({ - runtime: runtime(), - methods: [ - defineMethod({ - name: 'test.oversized', - params: null, - handler: () => ({ text: 'x'.repeat(REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES) }) - }) - ] - }) - - const response = await dispatcher.dispatch(request('test.oversized')) - - expect(response).toMatchObject({ - id: 'request-1', - ok: false, - error: { code: 'response_too_large' }, - _meta: { runtimeId: 'runtime-1' } - }) - expect(Buffer.byteLength(JSON.stringify(response), 'utf8')).toBeLessThan( - REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES - ) - }) - - it('bounds an oversized streaming emission before calling the reply transport', async () => { - const dispatcher = new RpcDispatcher({ - runtime: runtime(), - methods: [ - defineStreamingMethod({ - name: 'test.stream-oversized', - params: null, - handler: async (_params, _context, emit) => { - emit({ text: 'x'.repeat(REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES) }) - } - }) - ] - }) - const reply = vi.fn() - - await dispatcher.dispatchStreaming(request('test.stream-oversized'), reply) - - expect(reply).toHaveBeenCalledOnce() - const response = JSON.parse(reply.mock.calls[0]![0] as string) as { - id: string - ok: boolean - error: { code: string } - } - expect(response).toMatchObject({ - id: 'request-1', - ok: false, - error: { code: 'response_too_large' } - }) - }) -}) diff --git a/src/main/runtime/rpc/dispatcher.ts b/src/main/runtime/rpc/dispatcher.ts index da6c9ab2237..5b677088074 100644 --- a/src/main/runtime/rpc/dispatcher.ts +++ b/src/main/runtime/rpc/dispatcher.ts @@ -29,10 +29,6 @@ import { import { ALL_RPC_METHODS } from './methods' import { emulatorProbe, emulatorProbeError } from '../../emulator/emulator-probe' import type { OrcaRuntimeService } from '../orca-runtime' -import { - boundRuntimeRpcResponse, - serializeRuntimeRpcResponse -} from './runtime-rpc-response-serialization' export type DispatcherOptions = { runtime: OrcaRuntimeService @@ -52,27 +48,28 @@ export class RpcDispatcher { const meta = this.meta() const method = this.registry.get(request.method) if (!method) { - return boundRuntimeRpcResponse( - errorResponse(request.id, meta, 'method_not_found', `Unknown method: ${request.method}`) + return errorResponse( + request.id, + meta, + 'method_not_found', + `Unknown method: ${request.method}` ) } const parsedParams = this.parseParams(request, method, meta) if (parsedParams.error) { - return boundRuntimeRpcResponse(parsedParams.error) + return parsedParams.error } // Why: streaming methods are not supported over one-shot transports like // Unix sockets. They require a reply function that can be called multiple // times, which is only available via dispatchStreaming. if (isStreamingMethod(method)) { - return boundRuntimeRpcResponse( - errorResponse( - request.id, - meta, - 'method_not_supported', - `Method ${request.method} requires a streaming transport` - ) + return errorResponse( + request.id, + meta, + 'method_not_supported', + `Method ${request.method} requires a streaming transport` ) } @@ -86,12 +83,12 @@ export class RpcDispatcher { signal: options?.signal }) this.recordRuntimeFeatureInteraction(request.method, result, undefined, request.params) - return boundRuntimeRpcResponse(successResponse(request.id, meta, result)) + return successResponse(request.id, meta, result) } catch (error) { if (isEmulator) { emulatorProbeError(`rpc ${request.method}`, error, { params: request.params }) } - return boundRuntimeRpcResponse(this.mapError(request, meta, error)) + return this.mapError(request, meta, error) } } @@ -119,7 +116,7 @@ export class RpcDispatcher { const method = this.registry.get(request.method) if (!method) { reply( - serializeRuntimeRpcResponse( + JSON.stringify( errorResponse(request.id, meta, 'method_not_found', `Unknown method: ${request.method}`) ) ) @@ -128,7 +125,7 @@ export class RpcDispatcher { const parsedParams = this.parseParams(request, method, meta) if (parsedParams.error) { - reply(serializeRuntimeRpcResponse(parsedParams.error)) + reply(JSON.stringify(parsedParams.error)) return } @@ -147,9 +144,9 @@ export class RpcDispatcher { registerBinaryStreamHandler: options?.registerBinaryStreamHandler }) this.recordRuntimeFeatureInteraction(request.method, result, undefined, request.params) - reply(serializeRuntimeRpcResponse(successResponse(request.id, meta, result))) + reply(JSON.stringify(successResponse(request.id, meta, result))) } catch (error) { - reply(serializeRuntimeRpcResponse(this.mapError(request, meta, error))) + reply(JSON.stringify(this.mapError(request, meta, error))) } return } @@ -164,7 +161,7 @@ export class RpcDispatcher { ) const response = successResponse(request.id, meta, result) response.streaming = true - reply(serializeRuntimeRpcResponse(response)) + reply(JSON.stringify(response)) } try { @@ -191,7 +188,7 @@ export class RpcDispatcher { request.params ) } catch (error) { - reply(serializeRuntimeRpcResponse(this.mapError(request, meta, error))) + reply(JSON.stringify(this.mapError(request, meta, error))) } } diff --git a/src/main/runtime/rpc/errors.test.ts b/src/main/runtime/rpc/errors.test.ts index babc255d5ed..0e4519656cb 100644 --- a/src/main/runtime/rpc/errors.test.ts +++ b/src/main/runtime/rpc/errors.test.ts @@ -1,8 +1,4 @@ import { describe, expect, it } from 'vitest' -import { - MARKDOWN_DOCUMENT_LISTING_ERROR_CODE, - MarkdownDocumentListingCapacityError -} from '../../../shared/markdown-document-listing-limits' import { mapRuntimeError } from './errors' class LineageError extends Error { @@ -149,19 +145,4 @@ describe('mapRuntimeError', () => { _meta: { runtimeId: 'runtime-1' } }) }) - - it('preserves the Markdown listing capacity code across runtime RPC', () => { - expect( - mapRuntimeError( - 'req_1', - { runtimeId: 'runtime-1' }, - new MarkdownDocumentListingCapacityError() - ) - ).toMatchObject({ - ok: false, - error: { - code: MARKDOWN_DOCUMENT_LISTING_ERROR_CODE - } - }) - }) }) diff --git a/src/main/runtime/rpc/errors.ts b/src/main/runtime/rpc/errors.ts index fdd4f143d83..39190c6dfcb 100644 --- a/src/main/runtime/rpc/errors.ts +++ b/src/main/runtime/rpc/errors.ts @@ -7,7 +7,6 @@ import { computerUseErrorRecoveryData } from '../../../shared/computer-use-error import { COMPUTER_ERROR_CODES } from '../../../shared/runtime-types' import { LINEAR_ERROR_CODES } from '../../../shared/linear-agent-access' import { AGENT_SESSION_RPC_ERROR_CODES } from '../../../shared/agent-session-host-authority' -import { MARKDOWN_DOCUMENT_LISTING_ERROR_CODE } from '../../../shared/markdown-document-listing-limits' export function successResponse(id: string, meta: RpcEnvelopeMeta, result: unknown): RpcSuccess { return { @@ -61,8 +60,7 @@ const RUNTIME_PASSTHROUGH_CODES: ReadonlySet = new Set([ const COMPUTER_PASSTHROUGH_CODES: ReadonlySet = new Set(Object.values(COMPUTER_ERROR_CODES)) const LINEAR_PASSTHROUGH_CODES: ReadonlySet = new Set(LINEAR_ERROR_CODES) const STRUCTURED_RUNTIME_PASSTHROUGH_CODES: ReadonlySet = new Set([ - 'worktree_id_requires_full_path', - MARKDOWN_DOCUMENT_LISTING_ERROR_CODE + 'worktree_id_requires_full_path' ]) export function mapRuntimeError(id: string, meta: RpcEnvelopeMeta, error: unknown): RpcFailure { diff --git a/src/main/runtime/rpc/methods/ai-vault.test.ts b/src/main/runtime/rpc/methods/ai-vault.test.ts index 1ecc168dc78..a622ea4f835 100644 --- a/src/main/runtime/rpc/methods/ai-vault.test.ts +++ b/src/main/runtime/rpc/methods/ai-vault.test.ts @@ -4,7 +4,6 @@ import type { RpcRequest } from '../core' import { OrcaRuntimeService } from '../../orca-runtime' import type { AiVaultListResult, AiVaultSession } from '../../../../shared/ai-vault-types' import type { AiVaultScanOptions } from '../../../ai-vault/session-scanner-types' -import { AI_VAULT_SESSION_ID_MAX_UTF8_BYTES } from '../../../ai-vault/session-list-retention' const { scanAiVaultSessions } = vi.hoisted(() => ({ scanAiVaultSessions: vi.fn() @@ -182,24 +181,6 @@ describe('aiVault.listSessions handler + shared cache', () => { expect(scanAiVaultSessions).toHaveBeenCalledTimes(1) }) - it('bounds a pathological result before retaining it in the shared cache', async () => { - const oversized = makeSession() - oversized.sessionId = 'x'.repeat(AI_VAULT_SESSION_ID_MAX_UTF8_BYTES + 1) - scanAiVaultSessions.mockResolvedValue({ - sessions: [oversized], - issues: [], - scannedAt: SCANNED_AT - }) - - const first = await listAiVaultSessions({ limit: 500 }) - const second = await listAiVaultSessions({ limit: 500 }) - - expect(first.sessions).toEqual([]) - expect(first.issues.at(-1)?.message).toContain('AI Vault omitted') - expect(second).toBe(first) - expect(scanAiVaultSessions).toHaveBeenCalledTimes(1) - }) - it('keeps a newer different-key scan dedupable after an older scan resolves', async () => { // Why: the resolving scan's cleanup must not clear tracking a concurrent // different-key scan replaced, or re-requests start a duplicate rescan. diff --git a/src/main/runtime/rpc/methods/clipboard-image-upload-buffer.ts b/src/main/runtime/rpc/methods/clipboard-image-upload-buffer.ts deleted file mode 100644 index eac0c809a33..00000000000 --- a/src/main/runtime/rpc/methods/clipboard-image-upload-buffer.ts +++ /dev/null @@ -1,121 +0,0 @@ -const BASE64_PADDING_BYTE = '='.charCodeAt(0) - -function isBase64DataByte(byte: number): boolean { - return ( - (byte >= 65 && byte <= 90) || - (byte >= 97 && byte <= 122) || - (byte >= 48 && byte <= 57) || - byte === 43 || - byte === 47 - ) -} - -export class ClipboardImageUploadBuffer { - private readonly segments: Buffer[] = [] - private retainedLength = 0 - - constructor( - private readonly expectedLength: number, - private readonly segmentLength: number - ) {} - - get length(): number { - return this.retainedLength - } - - append(contentBase64: string): void { - if (contentBase64.length > this.expectedLength - this.retainedLength) { - throw new Error('Clipboard image upload exceeded expected size') - } - const source = Buffer.from(contentBase64, 'ascii') - const originalLength = this.retainedLength - const originalSegmentCount = this.segments.length - let sourceOffset = 0 - try { - while (sourceOffset < source.length) { - const segmentIndex = Math.floor(this.retainedLength / this.segmentLength) - const segmentOffset = this.retainedLength % this.segmentLength - let segment = this.segments[segmentIndex] - if (!segment) { - const segmentStart = segmentIndex * this.segmentLength - segment = Buffer.allocUnsafe( - Math.min(this.segmentLength, this.expectedLength - segmentStart) - ) - this.segments.push(segment) - } - const copyLength = Math.min(source.length - sourceOffset, segment.length - segmentOffset) - source.copy(segment, segmentOffset, sourceOffset, sourceOffset + copyLength) - sourceOffset += copyLength - this.retainedLength += copyLength - } - } catch (error) { - this.retainedLength = originalLength - this.segments.length = originalSegmentCount - throw error - } - } - - clear(): void { - this.retainedLength = 0 - this.segments.length = 0 - } - - decode(): Buffer { - const dataLength = this.validateAndGetDataLength() - const decoded = Buffer.allocUnsafe(Math.floor((dataLength * 3) / 4)) - let carry = '' - let remainingData = dataLength - let written = 0 - for (const segment of this.segments) { - if (remainingData === 0) { - break - } - const segmentDataLength = Math.min(segment.length, remainingData) - const data = segment.subarray(0, segmentDataLength).toString('ascii') - const combined = carry.length > 0 ? carry + data : data - const completeLength = combined.length - (combined.length % 4) - if (completeLength > 0) { - const complete = - completeLength === combined.length ? combined : combined.slice(0, completeLength) - written += decoded.write(complete, written, 'base64') - } - carry = completeLength === combined.length ? '' : combined.slice(completeLength) - remainingData -= segmentDataLength - } - if (carry.length > 0) { - written += decoded.write(carry, written, 'base64') - } - if (written !== decoded.length) { - throw new Error('Clipboard image content must be base64') - } - return decoded - } - - private validateAndGetDataLength(): number { - if (this.retainedLength % 4 === 1) { - throw new Error('Clipboard image content must be base64') - } - let dataLength = 0 - let paddingLength = 0 - let remaining = this.retainedLength - for (const segment of this.segments) { - const usedLength = Math.min(segment.length, remaining) - for (let index = 0; index < usedLength; index++) { - const byte = segment[index] - if (byte === BASE64_PADDING_BYTE) { - paddingLength += 1 - continue - } - if (paddingLength > 0 || !isBase64DataByte(byte)) { - throw new Error('Clipboard image content must be base64') - } - dataLength += 1 - } - remaining -= usedLength - } - if (paddingLength > 2) { - throw new Error('Clipboard image content must be base64') - } - return dataLength - } -} diff --git a/src/main/runtime/rpc/methods/clipboard.test.ts b/src/main/runtime/rpc/methods/clipboard.test.ts index a528239f000..118b21c766a 100644 --- a/src/main/runtime/rpc/methods/clipboard.test.ts +++ b/src/main/runtime/rpc/methods/clipboard.test.ts @@ -4,7 +4,6 @@ import type { RpcRequest } from '../core' import type { OrcaRuntimeService } from '../../orca-runtime' import { CLIPBOARD_IMAGE_MAX_BASE64_CHARS, - CLIPBOARD_IMAGE_MAX_SOURCE_BYTES, CLIPBOARD_IMAGE_TOO_LARGE_ERROR } from '../../../../shared/clipboard-image' @@ -19,9 +18,7 @@ vi.mock('../../../window/clipboard-image-temp-file', () => ({ import { CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS, CLIPBOARD_IMAGE_UPLOAD_MAX_CONCURRENT, - CLIPBOARD_IMAGE_UPLOAD_MAX_RETAINED_BASE64_CHARS, CLIPBOARD_METHODS, - getRetainedClipboardImageUploadBase64CharsForTest, resetClipboardImageUploadsForTest } from './clipboard' @@ -103,7 +100,7 @@ describe('clipboard RPC methods', () => { it('accepts chunked uploads and forwards the recorded connectionId on commit', async () => { saveClipboardImageBufferAsTempFile.mockResolvedValue('/tmp/orca-paste-image.png') const dispatcher = makeDispatcher() - const contentBase64 = Buffer.from('png-byte').toString('base64') + const contentBase64 = Buffer.from('png-bytes').toString('base64') const start = await dispatcher.dispatch( makeRequest('clipboard.startImageUpload', { @@ -114,8 +111,8 @@ describe('clipboard RPC methods', () => { expect(start.ok).toBe(true) const uploadId = (start.ok ? start.result : null) as { uploadId: string } - const firstChunk = contentBase64.slice(0, 2) - const secondChunk = contentBase64.slice(2) + const firstChunk = contentBase64.slice(0, 4) + const secondChunk = contentBase64.slice(4) await expect( dispatcher.dispatch( makeRequest('clipboard.appendImageUploadChunk', { @@ -124,7 +121,7 @@ describe('clipboard RPC methods', () => { contentBase64: firstChunk }) ) - ).resolves.toMatchObject({ ok: true, result: { receivedBase64Length: 2 } }) + ).resolves.toMatchObject({ ok: true, result: { receivedBase64Length: 4 } }) await expect( dispatcher.dispatch( makeRequest('clipboard.appendImageUploadChunk', { @@ -134,15 +131,13 @@ describe('clipboard RPC methods', () => { }) ) ).resolves.toMatchObject({ ok: true, result: { receivedBase64Length: contentBase64.length } }) - expect(getRetainedClipboardImageUploadBase64CharsForTest()).toBe(contentBase64.length) await expect( dispatcher.dispatch( makeRequest('clipboard.commitImageUpload', { uploadId: uploadId.uploadId }) ) ).resolves.toMatchObject({ ok: true, result: '/tmp/orca-paste-image.png' }) - expect(getRetainedClipboardImageUploadBase64CharsForTest()).toBe(0) - expect(saveClipboardImageBufferAsTempFile).toHaveBeenCalledWith(Buffer.from('png-byte'), { + expect(saveClipboardImageBufferAsTempFile).toHaveBeenCalledWith(Buffer.from('png-bytes'), { connectionId: 'ssh-1' }) }) @@ -166,7 +161,6 @@ describe('clipboard RPC methods', () => { ) expect(response.ok).toBe(false) - expect(getRetainedClipboardImageUploadBase64CharsForTest()).toBe(0) expect(saveClipboardImageBufferAsTempFile).not.toHaveBeenCalled() }) @@ -189,7 +183,6 @@ describe('clipboard RPC methods', () => { }) ) ).resolves.toMatchObject({ ok: false }) - expect(getRetainedClipboardImageUploadBase64CharsForTest()).toBe(0) await expect( dispatcher.dispatch( makeRequest('clipboard.appendImageUploadChunk', { @@ -265,7 +258,6 @@ describe('clipboard RPC methods', () => { ) expect(response.ok).toBe(false) - expect(getRetainedClipboardImageUploadBase64CharsForTest()).toBe(0) expect(saveClipboardImageBufferAsTempFile).not.toHaveBeenCalled() }) @@ -298,7 +290,6 @@ describe('clipboard RPC methods', () => { ) expect(response.ok).toBe(false) - expect(getRetainedClipboardImageUploadBase64CharsForTest()).toBe(0) expect(saveClipboardImageBufferAsTempFile).not.toHaveBeenCalled() }) @@ -311,21 +302,12 @@ describe('clipboard RPC methods', () => { }) ) const uploadId = (start.ok ? start.result : null) as { uploadId: string } - await dispatcher.dispatch( - makeRequest('clipboard.appendImageUploadChunk', { - uploadId: uploadId.uploadId, - offset: 0, - contentBase64: 'AAAA' - }) - ) - expect(getRetainedClipboardImageUploadBase64CharsForTest()).toBe(4) await expect( dispatcher.dispatch( makeRequest('clipboard.abortImageUpload', { uploadId: uploadId.uploadId }) ) ).resolves.toMatchObject({ ok: true, result: { aborted: true } }) - expect(getRetainedClipboardImageUploadBase64CharsForTest()).toBe(0) await expect( dispatcher.dispatch( makeRequest('clipboard.abortImageUpload', { uploadId: uploadId.uploadId }) @@ -367,137 +349,22 @@ describe('clipboard RPC methods', () => { makeRequest('clipboard.commitImageUpload', { uploadId: uploadId.uploadId }) ) ).resolves.toMatchObject({ ok: false }) - expect(getRetainedClipboardImageUploadBase64CharsForTest()).toBe(0) expect(saveClipboardImageBufferAsTempFile).toHaveBeenCalledTimes(1) }) - it('caps retained chunks process-wide and keeps commit memory charged until save completes', async () => { - let releaseSave!: () => void - const saveGate = new Promise((resolve) => { - releaseSave = resolve - }) - saveClipboardImageBufferAsTempFile.mockImplementation(async (content: Buffer) => { - expect(content).toHaveLength(CLIPBOARD_IMAGE_MAX_SOURCE_BYTES) - await saveGate - return '/tmp/orca-paste-image.png' - }) - const dispatcher = makeDispatcher() - const firstStart = await dispatcher.dispatch( - makeRequest('clipboard.startImageUpload', { - expectedBase64Length: CLIPBOARD_IMAGE_UPLOAD_MAX_RETAINED_BASE64_CHARS, - connectionId: 'ssh-1' - }) - ) - const secondStart = await dispatcher.dispatch( - makeRequest('clipboard.startImageUpload', { - expectedBase64Length: 4, - connectionId: 'ssh-2' - }) - ) - const firstUpload = (firstStart.ok ? firstStart.result : null) as { uploadId: string } - const secondUpload = (secondStart.ok ? secondStart.result : null) as { uploadId: string } - const fullChunk = 'A'.repeat(CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS) - - for ( - let offset = 0; - offset < CLIPBOARD_IMAGE_UPLOAD_MAX_RETAINED_BASE64_CHARS; - offset += fullChunk.length - ) { - await expect( - dispatcher.dispatch( - makeRequest('clipboard.appendImageUploadChunk', { - uploadId: firstUpload.uploadId, - offset, - contentBase64: fullChunk - }) - ) - ).resolves.toMatchObject({ ok: true }) - } - expect(getRetainedClipboardImageUploadBase64CharsForTest()).toBe( - CLIPBOARD_IMAGE_UPLOAD_MAX_RETAINED_BASE64_CHARS - ) - - await expect( - dispatcher.dispatch( - makeRequest('clipboard.appendImageUploadChunk', { - uploadId: secondUpload.uploadId, - offset: 0, - contentBase64: 'AAAA' - }) - ) - ).resolves.toMatchObject({ ok: false }) - expect(getRetainedClipboardImageUploadBase64CharsForTest()).toBe( - CLIPBOARD_IMAGE_UPLOAD_MAX_RETAINED_BASE64_CHARS - ) - - const commit = dispatcher.dispatch( - makeRequest('clipboard.commitImageUpload', { uploadId: firstUpload.uploadId }) - ) - await vi.waitFor(() => expect(saveClipboardImageBufferAsTempFile).toHaveBeenCalledTimes(1)) - expect(getRetainedClipboardImageUploadBase64CharsForTest()).toBe( - CLIPBOARD_IMAGE_UPLOAD_MAX_RETAINED_BASE64_CHARS - ) - releaseSave() - await expect(commit).resolves.toMatchObject({ ok: true }) - expect(getRetainedClipboardImageUploadBase64CharsForTest()).toBe(0) - - await expect( - dispatcher.dispatch( - makeRequest('clipboard.appendImageUploadChunk', { - uploadId: secondUpload.uploadId, - offset: 0, - contentBase64: 'AAAA' - }) - ) - ).resolves.toMatchObject({ ok: true, result: { receivedBase64Length: 4 } }) - }) - - it('releases retained chunks when test state is reset', async () => { - const dispatcher = makeDispatcher() - const start = await dispatcher.dispatch( - makeRequest('clipboard.startImageUpload', { - expectedBase64Length: 4, - connectionId: null - }) - ) - const uploadId = (start.ok ? start.result : null) as { uploadId: string } - await dispatcher.dispatch( - makeRequest('clipboard.appendImageUploadChunk', { - uploadId: uploadId.uploadId, - offset: 0, - contentBase64: 'AAAA' - }) - ) - expect(getRetainedClipboardImageUploadBase64CharsForTest()).toBe(4) - - resetClipboardImageUploadsForTest() - - expect(getRetainedClipboardImageUploadBase64CharsForTest()).toBe(0) - }) - it('bounds concurrent uploads and releases slots through TTL cleanup', async () => { vi.useFakeTimers() const dispatcher = makeDispatcher() for (let index = 0; index < CLIPBOARD_IMAGE_UPLOAD_MAX_CONCURRENT; index++) { - const start = await dispatcher.dispatch( - makeRequest('clipboard.startImageUpload', { - expectedBase64Length: 4, - connectionId: null - }) - ) - expect(start).toMatchObject({ ok: true }) - const uploadId = (start.ok ? start.result : null) as { uploadId: string } - await dispatcher.dispatch( - makeRequest('clipboard.appendImageUploadChunk', { - uploadId: uploadId.uploadId, - offset: 0, - contentBase64: 'AAAA' - }) - ) + await expect( + dispatcher.dispatch( + makeRequest('clipboard.startImageUpload', { + expectedBase64Length: 4, + connectionId: null + }) + ) + ).resolves.toMatchObject({ ok: true }) } - expect(getRetainedClipboardImageUploadBase64CharsForTest()).toBe( - CLIPBOARD_IMAGE_UPLOAD_MAX_CONCURRENT * 4 - ) await expect( dispatcher.dispatch( makeRequest('clipboard.startImageUpload', { @@ -508,7 +375,6 @@ describe('clipboard RPC methods', () => { ).resolves.toMatchObject({ ok: false }) vi.advanceTimersByTime(5 * 60 * 1000 + 1) - expect(getRetainedClipboardImageUploadBase64CharsForTest()).toBe(0) await expect( dispatcher.dispatch( diff --git a/src/main/runtime/rpc/methods/clipboard.ts b/src/main/runtime/rpc/methods/clipboard.ts index 72476f677e6..3d5212c7a52 100644 --- a/src/main/runtime/rpc/methods/clipboard.ts +++ b/src/main/runtime/rpc/methods/clipboard.ts @@ -6,28 +6,23 @@ import { CLIPBOARD_IMAGE_MAX_BASE64_CHARS, CLIPBOARD_IMAGE_TOO_LARGE_ERROR } from '../../../../shared/clipboard-image' -import { ClipboardImageUploadBuffer } from './clipboard-image-upload-buffer' const MAX_CLIPBOARD_IMAGE_BASE64_CHARS = CLIPBOARD_IMAGE_MAX_BASE64_CHARS export const CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS = 512 * 1024 export const CLIPBOARD_IMAGE_UPLOAD_MAX_CONCURRENT = 8 -export const CLIPBOARD_IMAGE_UPLOAD_MAX_RETAINED_BASE64_CHARS = CLIPBOARD_IMAGE_MAX_BASE64_CHARS const CLIPBOARD_IMAGE_UPLOAD_TTL_MS = 5 * 60 * 1000 -const CLIPBOARD_IMAGE_UPLOAD_MEMORY_ERROR = 'Too much clipboard image upload data is in progress' const BASE64_PATTERN = /^[A-Za-z0-9+/]*={0,2}$/ type ClipboardImageUpload = { expectedBase64Length: number connectionId?: string | null - content: ClipboardImageUploadBuffer - reservedBase64Length: number + chunks: string[] + receivedBase64Length: number expiresAt: number ttlTimer: ReturnType - committing: boolean } const clipboardImageUploads = new Map() -let retainedClipboardImageUploadBase64Chars = 0 function isValidBase64(value: string): boolean { return value.length % 4 !== 1 && BASE64_PATTERN.test(value) @@ -43,7 +38,7 @@ function pruneExpiredUploads(now = Date.now()): void { function scheduleUploadExpiry(uploadId: string): ReturnType { const timer = setTimeout(() => { - deleteUpload(uploadId) + clipboardImageUploads.delete(uploadId) }, CLIPBOARD_IMAGE_UPLOAD_TTL_MS) if (typeof timer === 'object' && 'unref' in timer) { timer.unref() @@ -57,26 +52,12 @@ function refreshUploadExpiry(uploadId: string, upload: ClipboardImageUpload): vo upload.ttlTimer = scheduleUploadExpiry(uploadId) } -function releaseUploadRetention(upload: ClipboardImageUpload): void { - retainedClipboardImageUploadBase64Chars -= upload.reservedBase64Length - upload.reservedBase64Length = 0 - upload.content.clear() -} - -function finishUpload(uploadId: string, upload: ClipboardImageUpload): void { - clearTimeout(upload.ttlTimer) - if (clipboardImageUploads.get(uploadId) === upload) { - clipboardImageUploads.delete(uploadId) - } - releaseUploadRetention(upload) -} - function deleteUpload(uploadId: string): void { const upload = clipboardImageUploads.get(uploadId) - if (!upload || upload.committing) { - return + if (upload) { + clearTimeout(upload.ttlTimer) } - finishUpload(uploadId, upload) + clipboardImageUploads.delete(uploadId) } function getUpload(uploadId: string): ClipboardImageUpload { @@ -85,21 +66,13 @@ function getUpload(uploadId: string): ClipboardImageUpload { if (!upload) { throw new Error('Clipboard image upload was not found') } - if (upload.committing) { - throw new Error('Clipboard image upload is already committing') - } return upload } -function reserveUploadBase64(chars: number): boolean { - if ( - chars > - CLIPBOARD_IMAGE_UPLOAD_MAX_RETAINED_BASE64_CHARS - retainedClipboardImageUploadBase64Chars - ) { - return false +function assertValidBase64Content(value: string): void { + if (!isValidBase64(value)) { + throw new Error('Clipboard image content must be base64') } - retainedClipboardImageUploadBase64Chars += chars - return true } function clipboardImageBase64Payload(maxChars: number, tooLargeMessage: string) { @@ -175,14 +148,10 @@ export const CLIPBOARD_METHODS: RpcMethod[] = [ clipboardImageUploads.set(uploadId, { expectedBase64Length: params.expectedBase64Length, connectionId: params.connectionId, - content: new ClipboardImageUploadBuffer( - params.expectedBase64Length, - CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS - ), - reservedBase64Length: 0, + chunks: [], + receivedBase64Length: 0, expiresAt: Date.now() + CLIPBOARD_IMAGE_UPLOAD_TTL_MS, - ttlTimer: scheduleUploadExpiry(uploadId), - committing: false + ttlTimer: scheduleUploadExpiry(uploadId) }) return { uploadId } } @@ -192,25 +161,17 @@ export const CLIPBOARD_METHODS: RpcMethod[] = [ params: AppendImageUploadChunk, handler: (params) => { const upload = getUpload(params.uploadId) - if (params.offset !== upload.content.length) { + if (params.offset !== upload.receivedBase64Length) { throw new Error('Clipboard image chunk offset is out of order') } - const nextLength = upload.content.length + params.contentBase64.length + const nextLength = upload.receivedBase64Length + params.contentBase64.length if (nextLength > upload.expectedBase64Length) { throw new Error('Clipboard image upload exceeded expected size') } - if (!reserveUploadBase64(params.contentBase64.length)) { - throw new Error(CLIPBOARD_IMAGE_UPLOAD_MEMORY_ERROR) - } - try { - upload.content.append(params.contentBase64) - } catch (error) { - retainedClipboardImageUploadBase64Chars -= params.contentBase64.length - throw error - } - upload.reservedBase64Length += params.contentBase64.length + upload.chunks.push(params.contentBase64) + upload.receivedBase64Length = nextLength refreshUploadExpiry(params.uploadId, upload) - return { receivedBase64Length: upload.content.length } + return { receivedBase64Length: upload.receivedBase64Length } } }), defineMethod({ @@ -218,19 +179,19 @@ export const CLIPBOARD_METHODS: RpcMethod[] = [ params: CommitImageUpload, handler: async (params) => { const upload = getUpload(params.uploadId) - upload.committing = true - clearTimeout(upload.ttlTimer) try { - if (upload.content.length !== upload.expectedBase64Length) { + if (upload.receivedBase64Length !== upload.expectedBase64Length) { throw new Error('Clipboard image upload is incomplete') } - const content = upload.content.decode() - upload.content.clear() - return await saveClipboardImageBufferAsTempFile(content, { + const contentBase64 = upload.chunks.join('') + assertValidBase64Content(contentBase64) + return await saveClipboardImageBufferAsTempFile(Buffer.from(contentBase64, 'base64'), { connectionId: upload.connectionId }) } finally { - finishUpload(params.uploadId, upload) + // Why: failed SSH or filesystem commits must not leave bounded upload + // memory pinned until TTL cleanup. + deleteUpload(params.uploadId) } } }), @@ -245,12 +206,7 @@ export const CLIPBOARD_METHODS: RpcMethod[] = [ ] export function resetClipboardImageUploadsForTest(): void { - for (const [uploadId, upload] of clipboardImageUploads) { - finishUpload(uploadId, upload) + for (const uploadId of clipboardImageUploads.keys()) { + deleteUpload(uploadId) } - retainedClipboardImageUploadBase64Chars = 0 -} - -export function getRetainedClipboardImageUploadBase64CharsForTest(): number { - return retainedClipboardImageUploadBase64Chars } diff --git a/src/main/runtime/rpc/methods/orchestration-gates.ts b/src/main/runtime/rpc/methods/orchestration-gates.ts index 0b6853fc746..ca16b9719f0 100644 --- a/src/main/runtime/rpc/methods/orchestration-gates.ts +++ b/src/main/runtime/rpc/methods/orchestration-gates.ts @@ -3,10 +3,6 @@ import { defineMethod, type RpcMethod } from '../core' import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas' import type { GateStatus } from '../../orchestration/db' import { Coordinator } from '../../orchestration/coordinator' -import { - assertOrchestrationStringListFits, - assertOrchestrationWriteFits -} from '../../orchestration/query-retention' // Why: the coordinator instance is stored at module scope so orchestration.runStop // can signal it to halt. Only one coordinator can run at a time (enforced by @@ -112,12 +108,10 @@ export const ORCHESTRATION_GATE_METHODS: RpcMethod[] = [ let options: string[] | undefined if (params.options) { try { - assertOrchestrationWriteFits('Decision gate options', [params.options]) const parsed = JSON.parse(params.options) if (!Array.isArray(parsed) || !parsed.every((option) => typeof option === 'string')) { throw new Error('not an array of strings') } - assertOrchestrationStringListFits('Decision gate options', parsed) options = parsed } catch { throw new Error('Invalid --options: must be a JSON array of strings') @@ -150,17 +144,11 @@ export const ORCHESTRATION_GATE_METHODS: RpcMethod[] = [ params: GateListParams, handler: (params, { runtime }) => { const db = runtime.getOrchestrationDb() - const filter = { + const gates = db.listGates({ taskId: params.task, status: params.status as GateStatus - } - const gates = db.listGates(filter) - const total = db.countGates(filter) - return { - gates, - count: gates.length, - ...(total > gates.length ? { total, truncated: true as const } : {}) - } + }) + return { gates, count: gates.length } } }) ] diff --git a/src/main/runtime/rpc/methods/orchestration-query-retention.test.ts b/src/main/runtime/rpc/methods/orchestration-query-retention.test.ts deleted file mode 100644 index 56f6c4fc9c7..00000000000 --- a/src/main/runtime/rpc/methods/orchestration-query-retention.test.ts +++ /dev/null @@ -1,185 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import type Database from '../../../sqlite/sync-database' -import { OrchestrationDb } from '../../orchestration/db' -import { - ORCHESTRATION_QUERY_MAX_ROWS, - ORCHESTRATION_QUERY_MAX_ROW_UTF8_BYTES, - ORCHESTRATION_WAIT_TYPE_FILTER_MAX_UTF8_BYTES -} from '../../orchestration/query-retention' -import { OrcaRuntimeService } from '../../orca-runtime' -import type { RpcContext } from '../core' -import { ORCHESTRATION_METHODS } from './orchestration' - -function sqliteFor(db: OrchestrationDb): Database.Database { - return (db as unknown as { db: Database.Database }).db -} - -describe('orchestration RPC query retention', () => { - let db: OrchestrationDb | undefined - - afterEach(() => { - db?.close() - }) - - async function call(name: string, params: Record): Promise { - db ??= new OrchestrationDb(':memory:') - const runtime = new OrcaRuntimeService() - runtime.setOrchestrationDb(db) - return callWithRuntime(runtime, name, params) - } - - async function callWithRuntime( - runtime: OrcaRuntimeService, - name: string, - params: Record - ): Promise { - const ctx: RpcContext = { runtime } - const method = ORCHESTRATION_METHODS.find((candidate) => candidate.name === name) - if (!method) { - throw new Error(`Method not found: ${name}`) - } - const parsed = method.params ? method.params.parse(params) : undefined - return method.handler(parsed, ctx) - } - - function createDb(): OrchestrationDb { - db = new OrchestrationDb(':memory:') - return db - } - - it('reports remaining unread messages while successive checks drain the queue', async () => { - const d = createDb() - for (let index = 0; index < ORCHESTRATION_QUERY_MAX_ROWS + 1; index += 1) { - d.insertMessage({ from: 'sender', to: 'worker', subject: `message-${index}` }) - } - - const first = (await call('orchestration.check', { terminal: 'worker' })) as { - count: number - truncated?: boolean - remaining?: number - } - expect(first).toMatchObject({ - count: ORCHESTRATION_QUERY_MAX_ROWS, - truncated: true, - remaining: 1 - }) - - const second = (await call('orchestration.check', { terminal: 'worker' })) as { - count: number - truncated?: boolean - } - expect(second).toMatchObject({ count: 1 }) - expect(second.truncated).toBeUndefined() - }) - - it('returns truncation instead of waiting forever on an oversized legacy row', async () => { - const d = createDb() - sqliteFor(d) - .prepare( - `INSERT INTO messages (id, from_handle, to_handle, subject, body) - VALUES ('msg_oversized', 'sender', 'worker', 'oversized', ?)` - ) - .run('x'.repeat(ORCHESTRATION_QUERY_MAX_ROW_UTF8_BYTES + 1)) - const runtime = new OrcaRuntimeService() - runtime.setOrchestrationDb(d) - const waitForMessage = vi.spyOn(runtime, 'waitForMessage') - - const result = await callWithRuntime(runtime, 'orchestration.check', { - terminal: 'worker', - wait: true, - timeoutMs: 60_000 - }) - - expect(result).toMatchObject({ - count: 0, - truncated: true, - remaining: 1 - }) - expect(waitForMessage).not.toHaveBeenCalled() - }) - - it('rejects oversized waiting type filters before retaining the request', async () => { - const d = createDb() - const runtime = new OrcaRuntimeService() - runtime.setOrchestrationDb(d) - const waitForMessage = vi.spyOn(runtime, 'waitForMessage') - const types = 'status,'.repeat( - Math.ceil(ORCHESTRATION_WAIT_TYPE_FILTER_MAX_UTF8_BYTES / 'status,'.length) + 1 - ) - - await expect( - callWithRuntime(runtime, 'orchestration.check', { - terminal: 'worker', - wait: true, - types - }) - ).rejects.toThrow( - `${ORCHESTRATION_WAIT_TYPE_FILTER_MAX_UTF8_BYTES}-byte orchestration wait limit` - ) - expect(waitForMessage).not.toHaveBeenCalled() - }) - - it('preserves normal waiting type filters while removing duplicate retention', async () => { - const d = createDb() - const runtime = new OrcaRuntimeService() - runtime.setOrchestrationDb(d) - const waitForMessage = vi.spyOn(runtime, 'waitForMessage').mockResolvedValue() - - await expect( - callWithRuntime(runtime, 'orchestration.check', { - terminal: 'worker', - wait: true, - timeoutMs: 100, - types: 'status,status,worker_done,escalation,decision_gate' - }) - ).resolves.toMatchObject({ count: 0, messages: [] }) - expect(waitForMessage).toHaveBeenCalledWith('worker', { - typeFilter: ['status', 'worker_done', 'escalation', 'decision_gate'], - timeoutMs: 100, - signal: undefined - }) - }) - - it('reports exact totals when task and gate lists are capped', async () => { - const d = createDb() - const task = d.createTask({ spec: 'gated' }) - for (let index = 0; index < ORCHESTRATION_QUERY_MAX_ROWS + 1; index += 1) { - d.createTask({ spec: `task-${index}` }) - d.createGate({ taskId: task.id, question: `question-${index}` }) - } - - const tasks = (await call('orchestration.taskList', {})) as { - count: number - total?: number - truncated?: boolean - } - expect(tasks).toMatchObject({ - count: ORCHESTRATION_QUERY_MAX_ROWS, - total: ORCHESTRATION_QUERY_MAX_ROWS + 2, - truncated: true - }) - - const gates = (await call('orchestration.gateList', {})) as { - count: number - total?: number - truncated?: boolean - } - expect(gates).toMatchObject({ - count: ORCHESTRATION_QUERY_MAX_ROWS, - total: ORCHESTRATION_QUERY_MAX_ROWS + 1, - truncated: true - }) - }) - - it('reports the exact inbox total when a requested page omits rows', async () => { - const d = createDb() - d.insertMessage({ from: 'a', to: 'b', subject: 'one' }) - d.insertMessage({ from: 'a', to: 'b', subject: 'two' }) - - await expect(call('orchestration.inbox', { limit: 1 })).resolves.toMatchObject({ - count: 1, - total: 2, - truncated: true - }) - }) -}) diff --git a/src/main/runtime/rpc/methods/orchestration.ts b/src/main/runtime/rpc/methods/orchestration.ts index 00b47658863..7a731f91774 100644 --- a/src/main/runtime/rpc/methods/orchestration.ts +++ b/src/main/runtime/rpc/methods/orchestration.ts @@ -7,11 +7,6 @@ import { buildDispatchPreamble } from '../../orchestration/preamble' import { formatMessageBanner } from '../../orchestration/formatter' import { isGroupAddress, resolveGroupAddress } from '../../orchestration/groups' import { reconcileLifecycleMessage } from '../../orchestration/lifecycle-reconciliation' -import { - assertOrchestrationStringListFits, - assertOrchestrationWaitTypeFilterFits, - assertOrchestrationWriteFits -} from '../../orchestration/query-retention' import { abbreviateOrchestrationTasks } from '../../../../shared/orchestration-task-summary' import { ORCHESTRATION_GATE_METHODS } from './orchestration-gates' @@ -26,21 +21,6 @@ const MESSAGE_TYPES: MessageType[] = [ 'heartbeat' ] -function parseMessageTypeFilter(types: string | undefined): MessageType[] | undefined { - if (!types) { - return undefined - } - const parsed = types - .split(',') - .map((type) => type.trim()) - .filter(Boolean) as MessageType[] - const invalidTypes = parsed.filter((type) => !MESSAGE_TYPES.includes(type)) - if (invalidTypes.length > 0) { - throw new Error(`Invalid --types: ${invalidTypes.join(',')}`) - } - return Array.from(new Set(parsed)) -} - const TASK_STATUSES: TaskStatus[] = [ 'pending', 'ready', @@ -292,20 +272,22 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ handler: async (params, { runtime, signal }) => { const db = runtime.getOrchestrationDb() const handle = params.terminal ?? 'unknown' - if (params.wait) { - assertOrchestrationWaitTypeFilterFits(params.types) + const typeFilter = params.types + ? (params.types + .split(',') + .map((t) => t.trim()) + .filter(Boolean) as MessageType[]) + : undefined + const invalidTypes = typeFilter?.filter((t) => !MESSAGE_TYPES.includes(t)) + if (invalidTypes && invalidTypes.length > 0) { + throw new Error(`Invalid --types: ${invalidTypes.join(',')}`) } - assertOrchestrationWriteFits('Message type filter', [params.types]) - const typeFilter = parseMessageTypeFilter(params.types) // Why: unread:false is honored for one release as a compat shim so in-flight callers don't break (design doc §5). const showAll = params.all === true || (params.unread === false && params.peek !== true) const consumeUnread = !showAll && params.peek !== true const readAndReturn = () => { - const totalBeforeRead = showAll - ? db.countAllMessagesForHandle(handle, typeFilter) - : db.countUnreadMessages(handle, typeFilter) const messages = showAll ? db.getAllMessagesForHandle(handle, undefined, typeFilter) : db.getUnreadMessages(handle, typeFilter) @@ -322,28 +304,19 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ db.markAsRead(messages.map((m) => m.id)) } - const remaining = consumeUnread - ? db.countUnreadMessages(handle, typeFilter) - : Math.max(0, totalBeforeRead - messages.length) - const saturation = remaining > 0 ? { truncated: true as const, remaining } : {} if (params.inject) { const formatted = visibleMessages.map(formatMessageBanner).join('\n\n') - return { - messages: visibleMessages, - formatted, - count: visibleMessages.length, - ...saturation - } + return { messages: visibleMessages, formatted, count: visibleMessages.length } } - return { messages: visibleMessages, count: visibleMessages.length, ...saturation } + return { messages: visibleMessages, count: visibleMessages.length } } if (signal?.aborted) { return { messages: [], count: 0 } } const result = readAndReturn() - if (result.count > 0 || result.truncated || !params.wait) { + if (result.count > 0 || !params.wait) { return result } @@ -394,14 +367,7 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ const messages = params.terminal ? db.getAllMessagesForHandle(params.terminal, params.limit) : db.getInbox(params.limit) - const total = params.terminal - ? db.countAllMessagesForHandle(params.terminal) - : db.countInbox() - return { - messages, - count: messages.length, - ...(total > messages.length ? { total, truncated: true as const } : {}) - } + return { messages, count: messages.length } } }), @@ -413,12 +379,10 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ let deps: string[] | undefined if (params.deps) { try { - assertOrchestrationWriteFits('Task dependencies', [params.deps]) const parsed = JSON.parse(params.deps) if (!Array.isArray(parsed) || !parsed.every((d) => typeof d === 'string')) { throw new Error('not an array of strings') } - assertOrchestrationStringListFits('Task dependencies', parsed) deps = parsed } catch { throw new Error('Invalid --deps: must be a JSON array of task IDs') @@ -441,12 +405,11 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ params: TaskListParams, handler: (params, { runtime }) => { const db = runtime.getOrchestrationDb() - const filter = { + // Why: listTasksWithDispatch adds assignee_handle + dispatch_id (NULL for non-dispatched), so legacy-shape consumers are unaffected. + const joined = db.listTasksWithDispatch({ status: params.status as TaskStatus, ready: params.ready - } - // Why: listTasksWithDispatch adds assignee_handle + dispatch_id (NULL for non-dispatched), so legacy-shape consumers are unaffected. - const joined = db.listTasksWithDispatch(filter) + }) const tasks = joined.map((row) => { const { assignee_handle, dispatch_id, ...base } = row if (base.status === 'dispatched') { @@ -454,11 +417,9 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ } return base }) - const total = db.countTasks(filter) return { tasks: params.brief ? abbreviateOrchestrationTasks(tasks) : tasks, - count: tasks.length, - ...(total > tasks.length ? { total, truncated: true as const } : {}) + count: tasks.length } } }), @@ -607,13 +568,11 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ const db = runtime.getOrchestrationDb() const from = params.from ?? 'unknown' const timeoutMs = params.timeoutMs ?? 600_000 - assertOrchestrationWriteFits('Decision gate options', [params.options]) const options = params.options ?.split(',') .map((s) => s.trim()) .filter(Boolean) ?? [] - assertOrchestrationStringListFits('Decision gate options', options) const payload = JSON.stringify({ question: params.question, options }) const outbound = db.insertMessage({ diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index d1847e690c9..487f79f261c 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -53,7 +53,6 @@ import { TERMINAL_STREAM_CHUNK_BYTES } from '../../../../shared/terminal-multiplex-flow-control' import { drainTerminalMultiplexRoundRobin } from '../terminal-multiplex-round-robin' -import { appendCompactedStringChunk } from '../../../../shared/string-chunk-compaction' const REQUESTED_SNAPSHOT_BYTE_BUDGET = 2 * 1024 * 1024 const TERMINAL_OUTPUT_FLUSH_MS = 5 @@ -209,7 +208,7 @@ function createTerminalOutputBatcher(onFlush: (data: string, meta?: TerminalOutp flush() pendingCwd = meta.cwd } - appendCompactedStringChunk(chunks, data) + chunks.push(data) pendingRawLength += rawLength const remainingBudget = Math.max(1, TERMINAL_OUTPUT_BATCH_MAX_BYTES - bytes) const measurement = measureTerminalStreamByteLength(data, { diff --git a/src/main/runtime/rpc/runtime-rpc-response-serialization.ts b/src/main/runtime/rpc/runtime-rpc-response-serialization.ts deleted file mode 100644 index 2a888e42f82..00000000000 --- a/src/main/runtime/rpc/runtime-rpc-response-serialization.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { - JsonStringifyByteLimitError, - stringifyJsonWithinByteLimit -} from '../../../shared/node-bounded-json-stringify' -import { REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES } from '../../../shared/remote-runtime-memory-limits' -import type { RpcResponse } from './core' -import { errorResponse } from './errors' - -const RESPONSE_TOO_LARGE_MESSAGE = `RPC response exceeds ${REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES} bytes` - -export function boundRuntimeRpcResponse(response: RpcResponse): RpcResponse { - try { - serializeWithinLimit(response) - return response - } catch (error) { - if (error instanceof JsonStringifyByteLimitError) { - return responseTooLargeError(response) - } - throw error - } -} - -export function serializeRuntimeRpcResponse(response: RpcResponse): string { - try { - return serializeWithinLimit(response) - } catch (error) { - if (error instanceof JsonStringifyByteLimitError) { - return serializeWithinLimit(responseTooLargeError(response)) - } - throw error - } -} - -function serializeWithinLimit(response: RpcResponse): string { - return stringifyJsonWithinByteLimit(response, REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES).serialized -} - -function responseTooLargeError(response: RpcResponse): RpcResponse { - return errorResponse( - response.id, - response._meta, - 'response_too_large', - RESPONSE_TOO_LARGE_MESSAGE - ) -} diff --git a/src/main/runtime/rpc/unix-socket-transport.test.ts b/src/main/runtime/rpc/unix-socket-transport.test.ts index 8ed4d518075..e321c2c59e9 100644 --- a/src/main/runtime/rpc/unix-socket-transport.test.ts +++ b/src/main/runtime/rpc/unix-socket-transport.test.ts @@ -63,7 +63,7 @@ describe('UnixSocketTransport', () => { ;(transport as unknown as UnixSocketTransportInternals).handleConnection( socket as unknown as Socket ) - socket.emit('data', Buffer.from('{"id":"pending","method":"wait"}\n')) + socket.emit('data', '{"id":"pending","method":"wait"}\n') vi.advanceTimersByTime(100) expect(socket.writes).toHaveLength(1) @@ -74,26 +74,4 @@ describe('UnixSocketTransport', () => { vi.advanceTimersByTime(500) expect(socket.writes).toHaveLength(1) }) - - it('parses a request delivered as 100,000 one-byte fragments', () => { - const transport = new UnixSocketTransport({ - endpoint: '/tmp/orca-runtime-rpc-test.sock', - kind: 'unix' - }) - const socket = new FakeSocket() - let received = '' - transport.onMessage((message) => { - received = message - }) - ;(transport as unknown as UnixSocketTransportInternals).handleConnection( - socket as unknown as Socket - ) - - const request = Buffer.from(`${' '.repeat(99_960)}{"id":"tiny","method":"status"}\n`) - for (let index = 0; index < request.byteLength; index += 1) { - socket.emit('data', request.subarray(index, index + 1)) - } - - expect(received).toBe('{"id":"tiny","method":"status"}') - }) }) diff --git a/src/main/runtime/rpc/unix-socket-transport.ts b/src/main/runtime/rpc/unix-socket-transport.ts index 24b53350bc6..ed66f94d907 100644 --- a/src/main/runtime/rpc/unix-socket-transport.ts +++ b/src/main/runtime/rpc/unix-socket-transport.ts @@ -6,7 +6,6 @@ // away. See design doc §3.1. import { createServer, type Server, type Socket } from 'node:net' import { chmodSync, existsSync, rmSync } from 'node:fs' -import { GrowingByteBuffer } from '../../../shared/growing-byte-buffer' import type { RpcMessageContext, RpcTransport } from './transport' const MAX_RUNTIME_RPC_MESSAGE_BYTES = 1024 * 1024 @@ -105,7 +104,7 @@ export class UnixSocketTransport implements RpcTransport { private handleConnection(socket: Socket): void { this.activeSockets.add(socket) - const buffer = new GrowingByteBuffer() + let buffer = '' let oversized = false // Why: each in-flight dispatch registers its own AbortController here so // `socket.on('close')` can abort them all at once. Keeping the set scoped @@ -115,6 +114,7 @@ export class UnixSocketTransport implements RpcTransport { // multiplexes sequential requests. const inflight = new Set<() => void>() + socket.setEncoding('utf8') socket.setNoDelay(true) socket.setTimeout(RUNTIME_RPC_SOCKET_IDLE_TIMEOUT_MS, () => { socket.destroy() @@ -129,31 +129,30 @@ export class UnixSocketTransport implements RpcTransport { inflight.clear() this.activeSockets.delete(socket) }) - socket.on('data', (chunk: Buffer) => { + socket.on('data', (chunk: string) => { if (oversized) { return } - buffer.append(chunk) + buffer += chunk // Why: the Orca runtime lives in Electron main, so it must reject // oversized local RPC frames instead of letting a local client grow an // unbounded buffer and stall the app. - if (buffer.byteLength > MAX_RUNTIME_RPC_MESSAGE_BYTES) { + if (Buffer.byteLength(buffer, 'utf8') > MAX_RUNTIME_RPC_MESSAGE_BYTES) { oversized = true - buffer.clear() this.messageHandler?.('', (response) => { socket.write(`${response}\n`) socket.end() }) return } - let newlineIndex = buffer.indexOfByte(0x0a) + let newlineIndex = buffer.indexOf('\n') while (newlineIndex !== -1) { - const rawMessage = buffer.takePrefixString(newlineIndex).trim() - buffer.discardPrefix(1) + const rawMessage = buffer.slice(0, newlineIndex).trim() + buffer = buffer.slice(newlineIndex + 1) if (rawMessage) { this.dispatchMessage(socket, rawMessage, inflight) } - newlineIndex = buffer.indexOfByte(0x0a) + newlineIndex = buffer.indexOf('\n') } }) } diff --git a/src/main/runtime/rpc/ws-fallback-port-store.test.ts b/src/main/runtime/rpc/ws-fallback-port-store.test.ts index 2c3ae73381a..2edd314c212 100644 --- a/src/main/runtime/rpc/ws-fallback-port-store.test.ts +++ b/src/main/runtime/rpc/ws-fallback-port-store.test.ts @@ -1,12 +1,8 @@ -import { mkdtempSync, truncateSync, writeFileSync } from 'node:fs' +import { mkdtempSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it } from 'vitest' -import { - MAX_FALLBACK_PORT_FILE_BYTES, - readWsFallbackPort, - writeWsFallbackPort -} from './ws-fallback-port-store' +import { readWsFallbackPort, writeWsFallbackPort } from './ws-fallback-port-store' function makeUserDataPath(): string { return mkdtempSync(join(tmpdir(), 'ws-fallback-port-test-')) @@ -36,13 +32,4 @@ describe('ws-fallback-port-store', () => { writeWsFallbackPort(userDataPath, 70000) expect(readWsFallbackPort(userDataPath)).toBeUndefined() }) - - it('ignores an oversized sparse fallback-port file', () => { - const userDataPath = makeUserDataPath() - const path = join(userDataPath, 'mobile-ws-fallback-port.json') - writeFileSync(path, '{"port":54321}', 'utf8') - truncateSync(path, MAX_FALLBACK_PORT_FILE_BYTES + 1) - - expect(readWsFallbackPort(userDataPath)).toBeUndefined() - }) }) diff --git a/src/main/runtime/rpc/ws-fallback-port-store.ts b/src/main/runtime/rpc/ws-fallback-port-store.ts index 3b12a15f18c..beedcea8cf2 100644 --- a/src/main/runtime/rpc/ws-fallback-port-store.ts +++ b/src/main/runtime/rpc/ws-fallback-port-store.ts @@ -1,6 +1,5 @@ -import { writeFileSync } from 'node:fs' +import { readFileSync, writeFileSync } from 'node:fs' import { join } from 'node:path' -import { readNodeFileSyncWithinLimit } from '../../../shared/node-bounded-file-reader' // Why: when the preferred WS port is taken (second Orca instance), the OS // assigns a random port. Paired mobile devices store ws://ip:port endpoints, @@ -11,7 +10,6 @@ import { readNodeFileSyncWithinLimit } from '../../../shared/node-bounded-file-r // again. const FALLBACK_PORT_FILE = 'mobile-ws-fallback-port.json' -export const MAX_FALLBACK_PORT_FILE_BYTES = 1024 function isValidPort(value: unknown): value is number { return typeof value === 'number' && Number.isInteger(value) && value > 0 && value <= 65535 @@ -19,10 +17,7 @@ function isValidPort(value: unknown): value is number { export function readWsFallbackPort(userDataPath: string): number | undefined { try { - const raw = readNodeFileSyncWithinLimit( - join(userDataPath, FALLBACK_PORT_FILE), - MAX_FALLBACK_PORT_FILE_BYTES - ).buffer.toString('utf8') + const raw = readFileSync(join(userDataPath, FALLBACK_PORT_FILE), 'utf8') const parsed: unknown = JSON.parse(raw) if ( typeof parsed === 'object' && diff --git a/src/main/runtime/runtime-control-file-bounds.test.ts b/src/main/runtime/runtime-control-file-bounds.test.ts deleted file mode 100644 index 2d08c9c5bc9..00000000000 --- a/src/main/runtime/runtime-control-file-bounds.test.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { mkdtempSync, readFileSync, rmSync, statSync, truncateSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { NodeFileReadTooLargeError } from '../../shared/node-bounded-file-reader' -import { - getRuntimeMetadataPath, - MAX_RUNTIME_METADATA_FILE_BYTES, - MAX_RUNTIME_METADATA_JSON_STRUCTURAL_TOKENS -} from '../../shared/runtime-bootstrap' -import { - DeviceRegistry, - DeviceRegistryCapacityError, - MAX_DEVICE_REGISTRY_FILE_BYTES -} from './device-registry' -import { - loadOrCreateE2EEKeypair, - MAX_KEYPAIR_FILE_BYTES, - MAX_KEYPAIR_JSON_STRUCTURAL_TOKENS -} from './e2ee-keypair' -import { readRuntimeMetadata, writeRuntimeMetadata } from './runtime-metadata' - -describe('runtime control-file bounds', () => { - const paths: string[] = [] - - afterEach(() => { - for (const path of paths.splice(0)) { - rmSync(path, { recursive: true, force: true }) - } - }) - - function makeUserDataPath(prefix: string): string { - const path = mkdtempSync(join(tmpdir(), prefix)) - paths.push(path) - return path - } - - it('rejects oversized runtime metadata before parsing it', () => { - const userDataPath = makeUserDataPath('orca-runtime-metadata-bound-') - const metadataPath = getRuntimeMetadataPath(userDataPath) - writeFileSync(metadataPath, '{"runtimeId":"runtime-1"}') - truncateSync(metadataPath, MAX_RUNTIME_METADATA_FILE_BYTES + 1) - - expect(() => readRuntimeMetadata(userDataPath)).toThrow(NodeFileReadTooLargeError) - }) - - it('rejects structurally amplified runtime metadata before parsing it', () => { - const userDataPath = makeUserDataPath('orca-runtime-metadata-structure-') - const metadataPath = getRuntimeMetadataPath(userDataPath) - writeFileSync( - metadataPath, - `{"transports":[${'0,'.repeat(MAX_RUNTIME_METADATA_JSON_STRUCTURAL_TOKENS)}0]}` - ) - const parseSpy = vi.spyOn(JSON, 'parse') - - expect(() => readRuntimeMetadata(userDataPath)).toThrow('JSON structure exceeds') - expect(parseSpy).not.toHaveBeenCalled() - }) - - it('preserves prior runtime metadata when serialization exceeds its read ceiling', () => { - const userDataPath = makeUserDataPath('orca-runtime-metadata-write-') - const metadataPath = getRuntimeMetadataPath(userDataPath) - const initial = { - runtimeId: 'runtime-1', - pid: 42, - transports: [], - authToken: 'token', - startedAt: 1 - } - writeRuntimeMetadata(userDataPath, initial) - const before = readFileSync(metadataPath, 'utf8') - - expect(() => - writeRuntimeMetadata(userDataPath, { - ...initial, - transports: [{ kind: 'unix', endpoint: 'x'.repeat(MAX_RUNTIME_METADATA_FILE_BYTES) }] - }) - ).toThrow('JSON output exceeds') - expect(readFileSync(metadataPath, 'utf8')).toBe(before) - }) - - it('treats an oversized device registry as unavailable', () => { - const userDataPath = makeUserDataPath('orca-device-registry-bound-') - const registryPath = join(userDataPath, 'orca-devices.json') - writeFileSync(registryPath, '[]') - truncateSync(registryPath, MAX_DEVICE_REGISTRY_FILE_BYTES + 1) - - expect(new DeviceRegistry(userDataPath).listDevices()).toEqual([]) - }) - - it('rejects a byte-oversized device without publishing a partial credential', () => { - const userDataPath = makeUserDataPath('orca-device-registry-write-bound-') - const registry = new DeviceRegistry(userDataPath) - - expect(() => registry.addDevice('x'.repeat(MAX_DEVICE_REGISTRY_FILE_BYTES), 'mobile')).toThrow( - DeviceRegistryCapacityError - ) - expect(registry.listDevices()).toEqual([]) - expect(new DeviceRegistry(userDataPath).listDevices()).toEqual([]) - }) - - it('regenerates an oversized E2EE keypair without loading it', () => { - const userDataPath = makeUserDataPath('orca-keypair-bound-') - const keypairPath = join(userDataPath, 'orca-e2ee-keypair.json') - writeFileSync(keypairPath, '{"v":1}') - truncateSync(keypairPath, MAX_KEYPAIR_FILE_BYTES + 1) - - expect(loadOrCreateE2EEKeypair(userDataPath).publicKey).toHaveLength(32) - expect(statSync(keypairPath).size).toBeLessThan(MAX_KEYPAIR_FILE_BYTES) - }) - - it('regenerates a structurally amplified E2EE keypair before parsing it', () => { - const userDataPath = makeUserDataPath('orca-keypair-structure-') - const keypairPath = join(userDataPath, 'orca-e2ee-keypair.json') - writeFileSync(keypairPath, `{"padding":[${'0,'.repeat(MAX_KEYPAIR_JSON_STRUCTURAL_TOKENS)}0]}`) - const parseSpy = vi.spyOn(JSON, 'parse') - - expect(loadOrCreateE2EEKeypair(userDataPath).publicKey).toHaveLength(32) - expect(parseSpy).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/runtime/runtime-file-watcher-admission.test.ts b/src/main/runtime/runtime-file-watcher-admission.test.ts deleted file mode 100644 index ecb5752c84d..00000000000 --- a/src/main/runtime/runtime-file-watcher-admission.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - RUNTIME_FILE_WATCHER_MAX_LEASES, - RUNTIME_FILE_WATCHER_MAX_PATH_BYTES, - RuntimeFileWatcherAdmission -} from './runtime-file-watcher-admission' - -describe('runtime file watcher admission', () => { - it('caps pending and active leases, then recovers after release', () => { - const admission = new RuntimeFileWatcherAdmission() - const releases = Array.from({ length: RUNTIME_FILE_WATCHER_MAX_LEASES }, (_, index) => - admission.claim('runtime-a', index % 2 === 0 ? 'ssh-a' : undefined, `/repo-${index}`) - ) - - expect(admission.evidence().leases).toBe(RUNTIME_FILE_WATCHER_MAX_LEASES) - expect(() => admission.claim('runtime-a', undefined, '/overflow')).toThrow('capacity reached') - - releases[0]?.() - releases[0]?.() - expect(() => admission.claim('runtime-a', undefined, '/recovered')).not.toThrow() - }) - - it('bounds multibyte roots before retaining a lease', () => { - const admission = new RuntimeFileWatcherAdmission() - const exact = 'é'.repeat(RUNTIME_FILE_WATCHER_MAX_PATH_BYTES / 2) - - expect(() => admission.claim('runtime-a', 'ssh-a', exact)).not.toThrow() - expect(() => admission.claim('runtime-a', 'ssh-a', `${exact}x`)).toThrow( - `exceeds ${RUNTIME_FILE_WATCHER_MAX_PATH_BYTES} UTF-8 bytes` - ) - expect(admission.evidence().leases).toBe(1) - }) -}) diff --git a/src/main/runtime/runtime-file-watcher-admission.ts b/src/main/runtime/runtime-file-watcher-admission.ts deleted file mode 100644 index 34a04647adb..00000000000 --- a/src/main/runtime/runtime-file-watcher-admission.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { measureUtf8ByteLength } from '../../shared/utf8-byte-limits' - -export const RUNTIME_FILE_WATCHER_MAX_LEASES = 256 -export const RUNTIME_FILE_WATCHER_MAX_RETAINED_IDENTITY_BYTES = 16 * 1024 * 1024 -export const RUNTIME_FILE_WATCHER_MAX_PATH_BYTES = 64 * 1024 -const RUNTIME_FILE_WATCHER_MAX_OWNER_ID_BYTES = 8 * 1024 - -export class RuntimeFileWatcherAdmission { - private leases = 0 - private retainedBytes = 0 - - claim(runtimeId: string, connectionId: string | undefined, rootPath: string): () => void { - const identityBytes = - boundedIdentityBytes(runtimeId, RUNTIME_FILE_WATCHER_MAX_OWNER_ID_BYTES, 'runtime id') + - boundedIdentityBytes(rootPath, RUNTIME_FILE_WATCHER_MAX_PATH_BYTES, 'root path') + - (connectionId - ? boundedIdentityBytes( - connectionId, - RUNTIME_FILE_WATCHER_MAX_OWNER_ID_BYTES, - 'connection id' - ) - : 0) - if ( - this.leases >= RUNTIME_FILE_WATCHER_MAX_LEASES || - this.retainedBytes + identityBytes > RUNTIME_FILE_WATCHER_MAX_RETAINED_IDENTITY_BYTES - ) { - throw new Error('Runtime file watcher capacity reached; close an existing watch and retry.') - } - this.leases += 1 - this.retainedBytes += identityBytes - let claimed = true - return () => { - if (!claimed) { - return - } - claimed = false - this.leases -= 1 - this.retainedBytes -= identityBytes - } - } - - evidence(): { leases: number; retainedBytes: number } { - return { leases: this.leases, retainedBytes: this.retainedBytes } - } -} - -function boundedIdentityBytes(value: string, limit: number, field: string): number { - const measured = measureUtf8ByteLength(value, { stopAfterBytes: limit }) - if (measured.exceededLimit) { - throw new Error(`Runtime file watcher ${field} exceeds ${limit} UTF-8 bytes.`) - } - return measured.byteLength -} diff --git a/src/main/runtime/runtime-metadata.ts b/src/main/runtime/runtime-metadata.ts index 18218ee8242..bd43203252d 100644 --- a/src/main/runtime/runtime-metadata.ts +++ b/src/main/runtime/runtime-metadata.ts @@ -1,13 +1,6 @@ -import { existsSync, rmSync } from 'node:fs' -import { readNodeFileSyncWithinLimit } from '../../shared/node-bounded-file-reader' -import { - getRuntimeMetadataPath, - MAX_RUNTIME_METADATA_FILE_BYTES, - parseRuntimeMetadataJson, - type RuntimeMetadata -} from '../../shared/runtime-bootstrap' -import { stringifyJsonWithinByteLimit } from '../../shared/node-bounded-json-stringify' -import { writeSecureFile } from '../../shared/secure-file' +import { existsSync, readFileSync, rmSync } from 'node:fs' +import { getRuntimeMetadataPath, type RuntimeMetadata } from '../../shared/runtime-bootstrap' +import { writeSecureJsonFile } from '../../shared/secure-file' export function writeRuntimeMetadata(userDataPath: string, metadata: RuntimeMetadata): void { const metadataPath = getRuntimeMetadataPath(userDataPath) @@ -19,11 +12,7 @@ export function readRuntimeMetadata(userDataPath: string): RuntimeMetadata | nul if (!existsSync(metadataPath)) { return null } - return parseRuntimeMetadataJson( - readNodeFileSyncWithinLimit(metadataPath, MAX_RUNTIME_METADATA_FILE_BYTES).buffer.toString( - 'utf8' - ) - ) + return JSON.parse(readFileSync(metadataPath, 'utf-8')) as RuntimeMetadata } export function clearRuntimeMetadata(userDataPath: string): void { @@ -63,6 +52,5 @@ export function clearRuntimeMetadataIfOwned( } function writeMetadataFile(path: string, metadata: RuntimeMetadata): void { - const { serialized } = stringifyJsonWithinByteLimit(metadata, MAX_RUNTIME_METADATA_FILE_BYTES, 2) - writeSecureFile(path, serialized) + writeSecureJsonFile(path, metadata) } diff --git a/src/main/runtime/runtime-mobile-file-path-search.test.ts b/src/main/runtime/runtime-mobile-file-path-search.test.ts index 177fd993e25..495a7933842 100644 --- a/src/main/runtime/runtime-mobile-file-path-search.test.ts +++ b/src/main/runtime/runtime-mobile-file-path-search.test.ts @@ -95,21 +95,4 @@ describe('RuntimeMobileFilePathSearchCache', () => { vi.useRealTimers() } }) - - it('rejects distinct scans beyond the in-flight cache bound', async () => { - const cache = new RuntimeMobileFilePathSearchCache(2, 100) - const loads: ((value: RuntimeMobileFilePathInventory) => void)[] = [] - const load = () => - new Promise((resolve) => { - loads.push(resolve) - }) - const first = cache.get('a', load) - const second = cache.get('b', load) - - await expect(cache.get('c', load)).rejects.toThrow('search is busy') - - const inventory = { paths: [], totalCount: 0, truncated: false } - loads.forEach((resolve) => resolve(inventory)) - await expect(Promise.all([first, second])).resolves.toEqual([inventory, inventory]) - }) }) diff --git a/src/main/runtime/runtime-mobile-file-path-search.ts b/src/main/runtime/runtime-mobile-file-path-search.ts index 61001b04bec..13213d6dfcf 100644 --- a/src/main/runtime/runtime-mobile-file-path-search.ts +++ b/src/main/runtime/runtime-mobile-file-path-search.ts @@ -34,9 +34,6 @@ export class RuntimeMobileFilePathSearchCache { if (pending) { return pending } - if (this.inFlight.size >= this.maxEntries) { - throw new Error('Mobile file path search is busy; retry after current searches finish.') - } const next = load() .then((loaded) => { // Why: a slow SSH scan should receive a full TTL after it becomes usable, diff --git a/src/main/runtime/runtime-operation-generations.test.ts b/src/main/runtime/runtime-operation-generations.test.ts deleted file mode 100644 index 310fd79add2..00000000000 --- a/src/main/runtime/runtime-operation-generations.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - MAX_RUNTIME_OPERATION_GENERATIONS, - MAX_RUNTIME_OPERATION_GENERATION_KEY_BYTES, - MAX_RUNTIME_OPERATION_GENERATION_RETAINED_KEY_BYTES, - RuntimeOperationGenerations -} from './runtime-operation-generations' - -const BOUNDS = { - maxEntries: 2, - maxKeyBytes: 4, - maxRetainedKeyBytes: 6 -} - -describe('RuntimeOperationGenerations', () => { - it('keeps a stable generation until the operation advances', () => { - const generations = new RuntimeOperationGenerations(BOUNDS) - const initial = generations.current('a') - - expect(generations.current('a')).toBe(initial) - expect(generations.advance('a')).not.toBe(initial) - expect(generations.isCurrent('a', initial)).toBe(false) - }) - - it('evicts least-recently-used keys and fails closed for stale captures', () => { - const generations = new RuntimeOperationGenerations(BOUNDS) - const first = generations.current('a') - const evicted = generations.current('b') - generations.current('a') - const latest = generations.current('cc') - - expect(generations.isCurrent('a', first)).toBe(true) - expect(generations.isCurrent('b', evicted)).toBe(false) - expect(generations.isCurrent('cc', latest)).toBe(true) - expect(generations.evidence()).toEqual({ entries: 2, retainedKeyBytes: 3 }) - }) - - it('bounds aggregate retained key bytes', () => { - const generations = new RuntimeOperationGenerations({ - maxEntries: 3, - maxKeyBytes: 4, - maxRetainedKeyBytes: 3 - }) - const evicted = generations.current('aa') - generations.current('b') - generations.current('cc') - - expect(generations.isCurrent('aa', evicted)).toBe(false) - expect(generations.evidence()).toEqual({ entries: 2, retainedKeyBytes: 3 }) - }) - - it('does not retain oversized keys', () => { - const generations = new RuntimeOperationGenerations(BOUNDS) - const generation = generations.current('oversized') - - expect(generations.isCurrent('oversized', generation)).toBe(false) - expect(generations.evidence()).toEqual({ entries: 0, retainedKeyBytes: 0 }) - }) - - it('invalidates forgotten owners without retaining tombstones', () => { - const generations = new RuntimeOperationGenerations(BOUNDS) - const stale = generations.current('a') - generations.forget('a') - - expect(generations.isCurrent('a', stale)).toBe(false) - expect(generations.current('a')).not.toBe(stale) - expect(generations.evidence()).toEqual({ entries: 1, retainedKeyBytes: 1 }) - }) - - it('publishes explicit production bounds', () => { - expect(MAX_RUNTIME_OPERATION_GENERATIONS).toBe(8_192) - expect(MAX_RUNTIME_OPERATION_GENERATION_KEY_BYTES).toBe(64 * 1024) - expect(MAX_RUNTIME_OPERATION_GENERATION_RETAINED_KEY_BYTES).toBe(4 * 1024 * 1024) - }) -}) diff --git a/src/main/runtime/runtime-operation-generations.ts b/src/main/runtime/runtime-operation-generations.ts deleted file mode 100644 index a35d1aee5db..00000000000 --- a/src/main/runtime/runtime-operation-generations.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { measureUtf8ByteLength } from '../../shared/utf8-byte-limits' - -export const MAX_RUNTIME_OPERATION_GENERATIONS = 8_192 -export const MAX_RUNTIME_OPERATION_GENERATION_KEY_BYTES = 64 * 1024 -export const MAX_RUNTIME_OPERATION_GENERATION_RETAINED_KEY_BYTES = 4 * 1024 * 1024 - -export type RuntimeOperationGenerationBounds = { - maxEntries: number - maxKeyBytes: number - maxRetainedKeyBytes: number -} - -const DEFAULT_BOUNDS: RuntimeOperationGenerationBounds = { - maxEntries: MAX_RUNTIME_OPERATION_GENERATIONS, - maxKeyBytes: MAX_RUNTIME_OPERATION_GENERATION_KEY_BYTES, - maxRetainedKeyBytes: MAX_RUNTIME_OPERATION_GENERATION_RETAINED_KEY_BYTES -} - -type RetainedGeneration = { - generation: number - keyBytes: number -} - -export class RuntimeOperationGenerations { - private readonly generations = new Map() - private nextGeneration = 1 - private retainedKeyBytes = 0 - - constructor(private readonly bounds: RuntimeOperationGenerationBounds = DEFAULT_BOUNDS) { - if ( - !Number.isSafeInteger(bounds.maxEntries) || - bounds.maxEntries < 1 || - !Number.isSafeInteger(bounds.maxKeyBytes) || - bounds.maxKeyBytes < 1 || - !Number.isSafeInteger(bounds.maxRetainedKeyBytes) || - bounds.maxRetainedKeyBytes < 1 - ) { - throw new RangeError('Runtime operation generation bounds must be positive integers') - } - } - - current(key: string): number { - const retained = this.generations.get(key) - if (!retained) { - // Why: a missing key may have been evicted, so it must never reuse a stale token. - return this.replace(key) - } - this.generations.delete(key) - this.generations.set(key, retained) - return retained.generation - } - - advance(key: string): number { - return this.replace(key) - } - - isCurrent(key: string, generation: number): boolean { - const retained = this.generations.get(key) - if (retained?.generation !== generation) { - return false - } - this.generations.delete(key) - this.generations.set(key, retained) - return true - } - - forget(key: string): void { - this.delete(key) - } - - evidence(): { entries: number; retainedKeyBytes: number } { - return { - entries: this.generations.size, - retainedKeyBytes: this.retainedKeyBytes - } - } - - private replace(key: string): number { - this.delete(key) - const generation = this.takeGeneration() - const measurement = measureUtf8ByteLength(key, { - stopAfterBytes: this.bounds.maxKeyBytes - }) - if (measurement.exceededLimit || measurement.byteLength > this.bounds.maxRetainedKeyBytes) { - return generation - } - while ( - this.generations.size >= this.bounds.maxEntries || - this.retainedKeyBytes + measurement.byteLength > this.bounds.maxRetainedKeyBytes - ) { - const oldest = this.generations.keys().next().value - if (oldest === undefined) { - return generation - } - this.delete(oldest) - } - this.generations.set(key, { - generation, - keyBytes: measurement.byteLength - }) - this.retainedKeyBytes += measurement.byteLength - return generation - } - - private takeGeneration(): number { - if (!Number.isSafeInteger(this.nextGeneration)) { - throw new Error('Runtime operation generation exhausted') - } - const generation = this.nextGeneration - this.nextGeneration += 1 - return generation - } - - private delete(key: string): void { - const retained = this.generations.get(key) - if (!retained) { - return - } - this.generations.delete(key) - this.retainedKeyBytes -= retained.keyBytes - } -} diff --git a/src/main/runtime/runtime-rpc-request-admission.test.ts b/src/main/runtime/runtime-rpc-request-admission.test.ts deleted file mode 100644 index 4253cfc14fa..00000000000 --- a/src/main/runtime/runtime-rpc-request-admission.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { mkdtempSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { describe, expect, it, vi } from 'vitest' -import type { WebSocket } from 'ws' -import { REMOTE_RUNTIME_JSON_STRUCTURE_LIMITS } from '../../shared/remote-runtime-request-frames' -import { DeviceRegistry } from './device-registry' -import type { OrcaRuntimeService } from './orca-runtime' -import { OrcaRuntimeRpcServer } from './runtime-rpc' -import type { RpcResponse } from './rpc/core' - -function createServer(shortRequestCap = 64): OrcaRuntimeRpcServer { - const runtime = { getRuntimeId: () => 'runtime-test' } as OrcaRuntimeService - return new OrcaRuntimeRpcServer({ - runtime, - userDataPath: mkdtempSync(join(tmpdir(), 'orca-runtime-admission-')), - shortRequestCap - }) -} - -function localRequest(server: OrcaRuntimeRpcServer, id: string): string { - return JSON.stringify({ - id, - authToken: server['authToken'], - method: 'status.get' - }) -} - -describe('runtime RPC request admission', () => { - it('rejects structural amplification before JSON.parse', async () => { - const server = createServer() - const amplified = `{"id":"amplified","authToken":"${server['authToken']}","method":"status.get","params":[${'0,'.repeat(REMOTE_RUNTIME_JSON_STRUCTURE_LIMITS.structuralTokens)}0]}` - const parse = vi.spyOn(JSON, 'parse') - - const response = await server['handleMessage'](amplified) - - expect(response).toMatchObject({ - ok: false, - error: { code: 'bad_request' } - }) - expect(parse).not.toHaveBeenCalled() - parse.mockRestore() - }) - - it('caps concurrent short local requests and releases capacity on settle', async () => { - const server = createServer(1) - let releaseFirst: ((response: RpcResponse) => void) | undefined - const firstResponse = new Promise((resolve) => { - releaseFirst = resolve - }) - const dispatch = vi - .spyOn(server['dispatcher'], 'dispatch') - .mockImplementationOnce(() => firstResponse) - .mockResolvedValue({ - id: 'after', - ok: true, - result: null, - _meta: { runtimeId: 'runtime-test' } - }) - - const first = server['handleMessage'](localRequest(server, 'first')) - await vi.waitFor(() => expect(server['activeShortRequests']).toBe(1)) - - await expect(server['handleMessage'](localRequest(server, 'overflow'))).resolves.toMatchObject({ - id: 'overflow', - ok: false, - error: { code: 'runtime_busy' } - }) - expect(dispatch).toHaveBeenCalledTimes(1) - - releaseFirst?.({ - id: 'first', - ok: true, - result: null, - _meta: { runtimeId: 'runtime-test' } - }) - await first - expect(server['activeShortRequests']).toBe(0) - await server['handleMessage'](localRequest(server, 'after')) - expect(dispatch).toHaveBeenCalledTimes(2) - }) - - it('applies the same short-request cap to WebSocket dispatch', async () => { - const server = createServer(1) - const registry = new DeviceRegistry(mkdtempSync(join(tmpdir(), 'orca-runtime-device-'))) - const device = registry.addDevice('runtime-test', 'runtime') - server['deviceRegistry'] = registry - server['activeShortRequests'] = 1 - const replies: RpcResponse[] = [] - - await server['handleWebSocketMessage']( - JSON.stringify({ id: 'overflow', method: 'status.get', deviceToken: device.token }), - (response) => replies.push(JSON.parse(response) as RpcResponse), - () => {}, - undefined, - undefined as WebSocket | undefined - ) - - expect(replies).toEqual([ - expect.objectContaining({ - id: 'overflow', - ok: false, - error: expect.objectContaining({ code: 'runtime_busy' }) - }) - ]) - }) -}) diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index 7cf30eabdc9..726981e98de 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -1,7 +1,7 @@ /* eslint-disable max-lines -- Why: this file is the single security boundary for the bundled CLI — transport setup, auth-token enforcement, admission control, keepalive framing, and orphan-socket sweeping all co-locate deliberately so a reviewer can audit the boundary in one sitting. Splitting this across files would scatter the invariants without reducing complexity. */ // Why: the single security boundary for the bundled CLI — auth-token enforcement, metadata publication, transport orchestration. import { randomBytes } from 'node:crypto' -import { opendirSync, rmSync } from 'node:fs' +import { readdirSync, rmSync } from 'node:fs' import { join } from 'node:path' import type { RuntimeMetadata, RuntimeTransportMetadata } from '../../shared/runtime-bootstrap' import type { OrcaRuntimeService } from './orca-runtime' @@ -41,7 +41,6 @@ import { decodeTerminalStreamFrame, type TerminalStreamFrame } from '../../shared/terminal-stream-protocol' -import { parseRemoteRuntimeJsonText } from '../../shared/remote-runtime-request-frames' const DEFAULT_WS_PORT = 6768 @@ -55,10 +54,9 @@ type OrcaRuntimeRpcServerOptions = { // Why: true when the caller pinned a port (`orca serve --port`) so bind order prefers it over a stale STA-1511 fallback (#8535). preferPinnedWsPort?: boolean webClientRoot?: string - // Why: test-only overrides for the admission constants below; production uses the defaults. + // Why: test-only overrides for the two constants below; production must not pass these (defaults set by §3.1). keepaliveIntervalMs?: number longPollCap?: number - shortRequestCap?: number } export type PairingOfferUnavailableReason = @@ -116,8 +114,6 @@ const KEEPALIVE_INTERVAL_MS = 10_000 // Why: cap long-polls at half the 32-slot connection budget so they can't starve short RPCs; overflow → runtime_busy. See §7 risk #2. const LONG_POLL_CAP = 16 -// Why: multiplexed sockets otherwise let one peer retain an unbounded number of active request graphs. -const SHORT_REQUEST_CAP = 64 function createWebClientUrl(endpoint: string, pairingUrl: string): string { const url = new URL(endpoint) @@ -435,7 +431,6 @@ export class OrcaRuntimeRpcServer { private readonly authToken = randomBytes(24).toString('hex') private readonly keepaliveIntervalMs: number private readonly longPollCap: number - private readonly shortRequestCap: number private readonly relayRevokeOutbox: RelayRevokeOutbox private deviceRegistry: DeviceRegistry | null = null private e2eeKeypair: E2EEKeypair | null = null @@ -457,7 +452,6 @@ export class OrcaRuntimeRpcServer { >() // Why: separate from server.maxConnections — count only long-running dispatches, not short RPCs. See §3.1 + §7 risk #2. private activeLongPolls = 0 - private activeShortRequests = 0 constructor({ runtime, @@ -469,8 +463,7 @@ export class OrcaRuntimeRpcServer { preferPinnedWsPort = false, webClientRoot, keepaliveIntervalMs = KEEPALIVE_INTERVAL_MS, - longPollCap = LONG_POLL_CAP, - shortRequestCap = SHORT_REQUEST_CAP + longPollCap = LONG_POLL_CAP }: OrcaRuntimeRpcServerOptions) { this.runtime = runtime this.dispatcher = new RpcDispatcher({ runtime }) @@ -483,7 +476,6 @@ export class OrcaRuntimeRpcServer { this.webClientRoot = webClientRoot this.keepaliveIntervalMs = keepaliveIntervalMs this.longPollCap = longPollCap - this.shortRequestCap = shortRequestCap this.relayRevokeOutbox = new RelayRevokeOutbox(userDataPath) } @@ -870,7 +862,7 @@ export class OrcaRuntimeRpcServer { // Why: best-effort id recovery so the client can correlate the error frame to its pending request. let id = 'unknown' try { - const parsed = parseRemoteRuntimeJsonText(msg) as { id?: unknown } + const parsed = JSON.parse(msg) as { id?: unknown } if (typeof parsed.id === 'string' && parsed.id.length > 0) { id = parsed.id } @@ -1017,7 +1009,7 @@ export class OrcaRuntimeRpcServer { } const request = parsed.request - // Why: long-polls and short work have separate budgets so waits cannot starve ordinary RPCs. + // Why: long-poll admission fence; short RPCs bypass the counter. See §7 risk #2. const longPoll = isLongPollRequest(request) if (longPoll && this.activeLongPolls >= this.longPollCap) { return this.buildError( @@ -1026,19 +1018,10 @@ export class OrcaRuntimeRpcServer { 'long-poll capacity reached; retry with backoff' ) } - if (!longPoll && this.activeShortRequests >= this.shortRequestCap) { - return this.buildError( - request.id, - 'runtime_busy', - 'short-request capacity reached; retry with backoff' - ) - } if (longPoll) { this.activeLongPolls += 1 // Why: arm keepalive only for long-polls; short RPCs never create the setInterval. See §3.1. context?.startKeepalive() - } else { - this.activeShortRequests += 1 } try { @@ -1048,8 +1031,6 @@ export class OrcaRuntimeRpcServer { } finally { if (longPoll) { this.activeLongPolls = Math.max(0, this.activeLongPolls - 1) - } else { - this.activeShortRequests = Math.max(0, this.activeShortRequests - 1) } } } @@ -1057,7 +1038,7 @@ export class OrcaRuntimeRpcServer { private parseAndAuth(rawMessage: string): { request: RpcRequest } | { error: RpcResponse } { let request: RpcRequest try { - request = parseRemoteRuntimeJsonText(rawMessage) as RpcRequest + request = JSON.parse(rawMessage) as RpcRequest } catch { return { error: this.buildError('unknown', 'bad_request', 'Invalid JSON request') } } @@ -1090,7 +1071,7 @@ export class OrcaRuntimeRpcServer { ): Promise { let request: RpcRequest try { - request = parseRemoteRuntimeJsonText(rawMessage) as RpcRequest + request = JSON.parse(rawMessage) as RpcRequest } catch { reply(JSON.stringify(this.buildError('unknown', 'bad_request', 'Invalid JSON request'))) return @@ -1155,24 +1136,10 @@ export class OrcaRuntimeRpcServer { ) return } - if (!longPoll && this.activeShortRequests >= this.shortRequestCap) { - reply( - JSON.stringify( - this.buildError( - request.id, - 'runtime_busy', - 'short-request capacity reached; retry with backoff' - ) - ) - ) - return - } const abortRegistration = ws ? this.registerWebSocketDispatchAbort(ws) : null if (longPoll) { this.activeLongPolls += 1 - } else { - this.activeShortRequests += 1 } // Why: older pairings may lack scope metadata, so stamp the authenticated scope onto status.get. @@ -1223,8 +1190,6 @@ export class OrcaRuntimeRpcServer { abortRegistration?.dispose() if (longPoll) { this.activeLongPolls = Math.max(0, this.activeLongPolls - 1) - } else { - this.activeShortRequests = Math.max(0, this.activeShortRequests - 1) } } } @@ -1249,50 +1214,38 @@ export class OrcaRuntimeRpcServer { export const RUNTIME_SOCKET_NAME_REGEX = /^o-(\d+)-[A-Za-z0-9_-]+\.sock$/ export function sweepOrphanedRuntimeSockets(userDataPath: string, ownPid: number): void { - let directory: ReturnType + let entries: string[] try { - directory = opendirSync(userDataPath) + entries = readdirSync(userDataPath) } catch { // Why: first-launch userData may not exist yet; nothing to sweep. return } - try { - while (true) { - const entry = directory.readSync() - if (!entry) { - break - } - const match = RUNTIME_SOCKET_NAME_REGEX.exec(entry.name) - if (!match) { - continue - } - const pid = Number(match[1]) - if (!Number.isFinite(pid)) { - continue - } - // Why: never delete our own socket — a bug here would rmSync one we're about to bind. - if (pid === ownPid) { - continue - } - try { - // Why: signal 0 is the POSIX liveness probe (sends nothing); ESRCH = dead pid, EPERM = foreign owner (left alone). - process.kill(pid, 0) - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ESRCH') { - try { - rmSync(join(userDataPath, entry.name), { force: true }) - } catch { - // Why: best-effort sweep; a later start() or OS reboot cleans any socket we can't unlink. - } + for (const entry of entries) { + const match = RUNTIME_SOCKET_NAME_REGEX.exec(entry) + if (!match) { + continue + } + const pid = Number(match[1]) + if (!Number.isFinite(pid)) { + continue + } + // Why: never delete our own socket — a bug here would rmSync one we're about to bind. + if (pid === ownPid) { + continue + } + try { + // Why: signal 0 is the POSIX liveness probe (sends nothing); ESRCH = dead pid, EPERM = foreign owner (left alone). + process.kill(pid, 0) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ESRCH') { + try { + rmSync(join(userDataPath, entry), { force: true }) + } catch { + // Why: best-effort sweep; a later start() or OS reboot cleans any socket we can't unlink. } } } - } finally { - try { - directory.closeSync() - } catch { - // Best-effort sweep cleanup. - } } } diff --git a/src/main/runtime/runtime-ssh-relay-recovery-generations.test.ts b/src/main/runtime/runtime-ssh-relay-recovery-generations.test.ts deleted file mode 100644 index bca69548e8e..00000000000 --- a/src/main/runtime/runtime-ssh-relay-recovery-generations.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - MAX_RUNTIME_SSH_RELAY_RECOVERIES, - MAX_RUNTIME_SSH_RELAY_RECOVERY_RETAINED_TARGET_ID_BYTES, - MAX_RUNTIME_SSH_RELAY_RECOVERY_TARGET_ID_BYTES, - RuntimeSshRelayRecoveryGenerations -} from './runtime-ssh-relay-recovery-generations' - -describe('RuntimeSshRelayRecoveryGenerations', () => { - it('releases completed recoveries under sequential unique-target churn', () => { - const generations = new RuntimeSshRelayRecoveryGenerations() - - for (let index = 0; index < 10_000; index += 1) { - const targetId = `ssh-${index}` - const lease = generations.begin(targetId) - expect(lease).not.toBeNull() - lease?.release() - } - - expect(generations.evidence()).toEqual({ recoveries: 0, retainedTargetIdBytes: 0 }) - }) - - it('keeps a replacement current when an older recovery finishes late', () => { - const generations = new RuntimeSshRelayRecoveryGenerations() - const stale = generations.begin('ssh-1')! - const replacement = generations.begin('ssh-1')! - - stale.release() - - expect(stale.isCurrent()).toBe(false) - expect(replacement.isCurrent()).toBe(true) - expect(generations.evidence()).toEqual({ - recoveries: 1, - retainedTargetIdBytes: 5 - }) - }) - - it('caps same-target hung replacements by outstanding lease count', () => { - const generations = new RuntimeSshRelayRecoveryGenerations() - const leases = Array.from({ length: 10_000 }, () => generations.begin('ssh-1')).filter( - (lease) => lease !== null - ) - - expect(leases).toHaveLength(MAX_RUNTIME_SSH_RELAY_RECOVERIES) - expect(leases.slice(0, -1).every((lease) => !lease.isCurrent())).toBe(true) - expect(leases.at(-1)?.isCurrent()).toBe(true) - expect(generations.evidence()).toEqual({ - recoveries: MAX_RUNTIME_SSH_RELAY_RECOVERIES, - retainedTargetIdBytes: MAX_RUNTIME_SSH_RELAY_RECOVERIES * 5 - }) - - for (const lease of leases) { - lease.release() - } - expect(generations.evidence()).toEqual({ recoveries: 0, retainedTargetIdBytes: 0 }) - }) - - it('keeps invalidated attempts admitted until their work releases', () => { - const generations = new RuntimeSshRelayRecoveryGenerations({ - maxRecoveries: 1, - maxTargetIdBytes: 8, - maxRetainedTargetIdBytes: 8 - }) - const stale = generations.begin('ssh-1')! - - generations.invalidate('ssh-1') - - expect(stale.isCurrent()).toBe(false) - expect(generations.begin('ssh-2')).toBeNull() - stale.release() - expect(generations.begin('ssh-2')?.isCurrent()).toBe(true) - }) - - it('rejects unique hung recoveries at the concurrent-entry cap and admits after release', () => { - const generations = new RuntimeSshRelayRecoveryGenerations() - const leases = Array.from({ length: MAX_RUNTIME_SSH_RELAY_RECOVERIES }, (_, index) => - generations.begin(`ssh-${index}`) - ) - - expect(leases.every(Boolean)).toBe(true) - expect(generations.begin('ssh-overflow')).toBeNull() - leases[0]?.release() - - const replacement = generations.begin('ssh-overflow') - expect(replacement?.isCurrent()).toBe(true) - for (const lease of leases) { - lease?.release() - } - replacement?.release() - expect(generations.evidence()).toEqual({ recoveries: 0, retainedTargetIdBytes: 0 }) - }) - - it('bounds individual and aggregate retained target-id bytes', () => { - const generations = new RuntimeSshRelayRecoveryGenerations({ - maxRecoveries: 3, - maxTargetIdBytes: 8, - maxRetainedTargetIdBytes: 10 - }) - const first = generations.begin('123456') - const second = generations.begin('abcd') - - expect(first?.isCurrent()).toBe(true) - expect(second?.isCurrent()).toBe(true) - expect(generations.begin('x')).toBeNull() - expect(generations.begin('🌊🌊🌊')).toBeNull() - expect(generations.evidence()).toEqual({ recoveries: 2, retainedTargetIdBytes: 10 }) - }) - - it('publishes explicit production bounds', () => { - expect(MAX_RUNTIME_SSH_RELAY_RECOVERIES).toBe(256) - expect(MAX_RUNTIME_SSH_RELAY_RECOVERY_TARGET_ID_BYTES).toBe(64 * 1024) - expect(MAX_RUNTIME_SSH_RELAY_RECOVERY_RETAINED_TARGET_ID_BYTES).toBe(4 * 1024 * 1024) - }) -}) diff --git a/src/main/runtime/runtime-ssh-relay-recovery-generations.ts b/src/main/runtime/runtime-ssh-relay-recovery-generations.ts deleted file mode 100644 index 658521eb988..00000000000 --- a/src/main/runtime/runtime-ssh-relay-recovery-generations.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { measureUtf8ByteLength } from '../../shared/utf8-byte-limits' - -export const MAX_RUNTIME_SSH_RELAY_RECOVERIES = 256 -export const MAX_RUNTIME_SSH_RELAY_RECOVERY_TARGET_ID_BYTES = 64 * 1024 -export const MAX_RUNTIME_SSH_RELAY_RECOVERY_RETAINED_TARGET_ID_BYTES = 4 * 1024 * 1024 - -export type RuntimeSshRelayRecoveryGenerationBounds = { - maxRecoveries: number - maxTargetIdBytes: number - maxRetainedTargetIdBytes: number -} - -export type RuntimeSshRelayRecoveryGenerationLease = { - isCurrent: () => boolean - release: () => void -} - -const DEFAULT_BOUNDS: RuntimeSshRelayRecoveryGenerationBounds = { - maxRecoveries: MAX_RUNTIME_SSH_RELAY_RECOVERIES, - maxTargetIdBytes: MAX_RUNTIME_SSH_RELAY_RECOVERY_TARGET_ID_BYTES, - maxRetainedTargetIdBytes: MAX_RUNTIME_SSH_RELAY_RECOVERY_RETAINED_TARGET_ID_BYTES -} - -type RetainedRecovery = { - generation: number - targetIdBytes: number -} - -export class RuntimeSshRelayRecoveryGenerations { - private readonly generationByTargetId = new Map() - private activeRecoveries = 0 - private retainedTargetIdBytes = 0 - private nextGeneration = 1 - - constructor(private readonly bounds: RuntimeSshRelayRecoveryGenerationBounds = DEFAULT_BOUNDS) { - if ( - !Number.isSafeInteger(bounds.maxRecoveries) || - bounds.maxRecoveries < 1 || - !Number.isSafeInteger(bounds.maxTargetIdBytes) || - bounds.maxTargetIdBytes < 1 || - !Number.isSafeInteger(bounds.maxRetainedTargetIdBytes) || - bounds.maxRetainedTargetIdBytes < 1 - ) { - throw new RangeError('SSH relay recovery generation bounds must be positive integers') - } - } - - begin(targetId: string): RuntimeSshRelayRecoveryGenerationLease | null { - const targetIdMeasurement = measureUtf8ByteLength(targetId, { - stopAfterBytes: this.bounds.maxTargetIdBytes - }) - if (targetIdMeasurement.exceededLimit) { - return null - } - if ( - this.activeRecoveries >= this.bounds.maxRecoveries || - this.retainedTargetIdBytes + targetIdMeasurement.byteLength > - this.bounds.maxRetainedTargetIdBytes - ) { - return null - } - if (!Number.isSafeInteger(this.nextGeneration)) { - throw new Error('SSH relay recovery generation exhausted') - } - const generation = this.nextGeneration - this.nextGeneration += 1 - const retained = { - generation, - targetIdBytes: targetIdMeasurement.byteLength - } - this.generationByTargetId.set(targetId, retained) - this.activeRecoveries += 1 - this.retainedTargetIdBytes += retained.targetIdBytes - let active = true - return { - isCurrent: () => - active && - this.generationByTargetId.get(targetId) === retained && - retained.generation === generation, - release: () => { - if (!active) { - return - } - active = false - this.releaseLease(targetId, retained) - } - } - } - - invalidate(targetId: string): void { - this.generationByTargetId.delete(targetId) - } - - evidence(): { recoveries: number; retainedTargetIdBytes: number } { - return { - recoveries: this.activeRecoveries, - retainedTargetIdBytes: this.retainedTargetIdBytes - } - } - - private releaseLease(targetId: string, retained: RetainedRecovery): void { - this.activeRecoveries -= 1 - this.retainedTargetIdBytes -= retained.targetIdBytes - if (this.generationByTargetId.get(targetId) === retained) { - this.generationByTargetId.delete(targetId) - } - } -} diff --git a/src/main/runtime/runtime-terminal-file-grant-retention.test.ts b/src/main/runtime/runtime-terminal-file-grant-retention.test.ts deleted file mode 100644 index 7634f0e1bec..00000000000 --- a/src/main/runtime/runtime-terminal-file-grant-retention.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { retainRuntimeTerminalFileGrant } from './runtime-terminal-file-grant-retention' - -describe('retainRuntimeTerminalFileGrant', () => { - it('preserves grants below the cap and evicts the oldest at admission', () => { - const grants = new Map() - const release = vi.fn((id: string) => grants.delete(id)) - - retainRuntimeTerminalFileGrant(grants, { id: 'a' }, release, 2) - retainRuntimeTerminalFileGrant(grants, { id: 'b' }, release, 2) - expect([...grants.keys()]).toEqual(['a', 'b']) - expect(release).not.toHaveBeenCalled() - - retainRuntimeTerminalFileGrant(grants, { id: 'c' }, release, 2) - expect([...grants.keys()]).toEqual(['b', 'c']) - expect(release).toHaveBeenCalledWith('a', { id: 'a' }) - }) -}) diff --git a/src/main/runtime/runtime-terminal-file-grant-retention.ts b/src/main/runtime/runtime-terminal-file-grant-retention.ts deleted file mode 100644 index 24ef7efea76..00000000000 --- a/src/main/runtime/runtime-terminal-file-grant-retention.ts +++ /dev/null @@ -1,22 +0,0 @@ -export const RUNTIME_TERMINAL_FILE_GRANT_MAX_ENTRIES = 1024 - -export function retainRuntimeTerminalFileGrant( - grants: Map, - grant: T, - release: (id: string, retained: T) => void, - maxEntries = RUNTIME_TERMINAL_FILE_GRANT_MAX_ENTRIES -): void { - while (grants.size >= Math.max(1, maxEntries)) { - const oldestId = grants.keys().next().value as string | undefined - if (oldestId === undefined) { - break - } - const oldest = grants.get(oldestId) - if (oldest) { - release(oldestId, oldest) - } else { - grants.delete(oldestId) - } - } - grants.set(grant.id, grant) -} diff --git a/src/main/runtime/runtime-text-search-admission.test.ts b/src/main/runtime/runtime-text-search-admission.test.ts deleted file mode 100644 index e245160aba6..00000000000 --- a/src/main/runtime/runtime-text-search-admission.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { assertRuntimeTextSearchAdmission } from './runtime-text-search-admission' - -describe('assertRuntimeTextSearchAdmission', () => { - it('allows ordinary and replacement searches but rejects excess distinct roots', () => { - const active = new Map([ - ['repo-a', {}], - ['repo-b', {}] - ]) - - expect(() => assertRuntimeTextSearchAdmission(active, 'repo-a', 2)).not.toThrow() - expect(() => assertRuntimeTextSearchAdmission(active, 'repo-c', 2)).toThrow('search is busy') - }) -}) diff --git a/src/main/runtime/runtime-text-search-admission.ts b/src/main/runtime/runtime-text-search-admission.ts deleted file mode 100644 index 78517515a32..00000000000 --- a/src/main/runtime/runtime-text-search-admission.ts +++ /dev/null @@ -1,11 +0,0 @@ -export const RUNTIME_TEXT_SEARCH_MAX_ACTIVE = 16 - -export function assertRuntimeTextSearchAdmission( - activeSearchKeys: ReadonlyMap, - requestedKey: string, - maxActive = RUNTIME_TEXT_SEARCH_MAX_ACTIVE -): void { - if (!activeSearchKeys.has(requestedKey) && activeSearchKeys.size >= maxActive) { - throw new Error('Runtime text search is busy; retry after current searches finish.') - } -} diff --git a/src/main/runtime/tls-certificate-bounds.test.ts b/src/main/runtime/tls-certificate-bounds.test.ts deleted file mode 100644 index 019738e250f..00000000000 --- a/src/main/runtime/tls-certificate-bounds.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { closeSync, ftruncateSync, mkdtempSync, openSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' - -const execFileSyncMock = vi.hoisted(() => vi.fn()) - -vi.mock('node:child_process', () => ({ execFileSync: execFileSyncMock })) - -import { loadOrCreateTlsCertificate } from './tls-certificate' - -const CERTIFICATE = '-----BEGIN CERTIFICATE-----\nYQ==\n-----END CERTIFICATE-----\n' -const PRIVATE_KEY = '-----BEGIN PRIVATE KEY-----\nYg==\n-----END PRIVATE KEY-----\n' -const roots: string[] = [] - -beforeEach(() => { - execFileSyncMock.mockImplementation((_command: string, args: string[]) => { - const keyIndex = args.indexOf('-keyout') - const certIndex = args.indexOf('-out') - writeFileSync(args[keyIndex + 1], PRIVATE_KEY) - writeFileSync(args[certIndex + 1], CERTIFICATE) - }) -}) - -afterEach(() => { - execFileSyncMock.mockReset() - for (const root of roots.splice(0)) { - rmSync(root, { recursive: true, force: true }) - } -}) - -function makeUserDataPath(): string { - const root = mkdtempSync(join(tmpdir(), 'orca-tls-bounds-')) - roots.push(root) - return root -} - -describe('TLS certificate file bounds', () => { - it('preserves existing ordinary certificate material', () => { - const userDataPath = makeUserDataPath() - writeFileSync(join(userDataPath, 'orca-tls-cert.pem'), CERTIFICATE) - writeFileSync(join(userDataPath, 'orca-tls-key.pem'), PRIVATE_KEY) - - expect(loadOrCreateTlsCertificate(userDataPath)).toMatchObject({ - cert: CERTIFICATE, - key: PRIVATE_KEY - }) - expect(execFileSyncMock).not.toHaveBeenCalled() - }) - - it('regenerates instead of retaining an oversized sparse PEM file', () => { - const userDataPath = makeUserDataPath() - const certPath = join(userDataPath, 'orca-tls-cert.pem') - const descriptor = openSync(certPath, 'w') - ftruncateSync(descriptor, 1024 * 1024 + 1) - closeSync(descriptor) - writeFileSync(join(userDataPath, 'orca-tls-key.pem'), PRIVATE_KEY) - - expect(loadOrCreateTlsCertificate(userDataPath)).toMatchObject({ - cert: CERTIFICATE, - key: PRIVATE_KEY - }) - expect(execFileSyncMock).toHaveBeenCalledOnce() - }) -}) diff --git a/src/main/runtime/tls-certificate.ts b/src/main/runtime/tls-certificate.ts index b3e37d42572..6c502c31442 100644 --- a/src/main/runtime/tls-certificate.ts +++ b/src/main/runtime/tls-certificate.ts @@ -4,16 +4,11 @@ // app pins the certificate fingerprint received during QR pairing. import { createHash } from 'node:crypto' import { execFileSync } from 'node:child_process' -import { existsSync, chmodSync } from 'node:fs' +import { existsSync, readFileSync, chmodSync } from 'node:fs' import { join } from 'node:path' -import { - NodeFileReadTooLargeError, - readNodeFileSyncWithinLimit -} from '../../shared/node-bounded-file-reader' const TLS_CERT_FILENAME = 'orca-tls-cert.pem' const TLS_KEY_FILENAME = 'orca-tls-key.pem' -const MAX_TLS_PEM_FILE_BYTES = 1024 * 1024 export type TlsCertificate = { cert: string @@ -26,17 +21,11 @@ export function loadOrCreateTlsCertificate(userDataPath: string): TlsCertificate const keyPath = join(userDataPath, TLS_KEY_FILENAME) if (existsSync(certPath) && existsSync(keyPath)) { - try { - const cert = readTlsPemFile(certPath) - const key = readTlsPemFile(keyPath) - const fingerprint = computeFingerprint(cert) - if (fingerprint) { - return { cert, key, fingerprint } - } - } catch (error) { - if (!(error instanceof NodeFileReadTooLargeError)) { - throw error - } + const cert = readFileSync(certPath, 'utf-8') + const key = readFileSync(keyPath, 'utf-8') + const fingerprint = computeFingerprint(cert) + if (fingerprint) { + return { cert, key, fingerprint } } // Why: if the existing cert is malformed (e.g., from a buggy earlier // generation), regenerate rather than failing the WebSocket transport. @@ -77,15 +66,11 @@ export function loadOrCreateTlsCertificate(userDataPath: string): TlsCertificate chmodSync(keyPath_, 0o600) chmodSync(certPath_, 0o600) - const cert = readTlsPemFile(certPath_) - const key = readTlsPemFile(keyPath_) + const cert = readFileSync(certPath_, 'utf-8') + const key = readFileSync(keyPath_, 'utf-8') return { cert, key, fingerprint: computeFingerprint(cert)! } } -function readTlsPemFile(filePath: string): string { - return readNodeFileSyncWithinLimit(filePath, MAX_TLS_PEM_FILE_BYTES).buffer.toString('utf-8') -} - function resolveOpenSslExecutable(): string { if (process.platform !== 'win32') { return 'openssl' diff --git a/src/main/serve-update-handoff.test.ts b/src/main/serve-update-handoff.test.ts index 140ea03da33..d649243a8f5 100644 --- a/src/main/serve-update-handoff.test.ts +++ b/src/main/serve-update-handoff.test.ts @@ -1,12 +1,10 @@ -import { existsSync, mkdtempSync, readFileSync, rmSync, truncateSync, writeFileSync } from 'node:fs' +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' import { EventEmitter } from 'node:events' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { SERVE_UPDATE_HANDOFF_PATH_ENV, - MAX_SERVE_UPDATE_HANDOFF_FILE_BYTES, - MAX_SERVE_UPDATE_HANDOFF_JSON_STRUCTURAL_TOKENS, getServeUpdateHandoffPath, parseServeUpdateHandoffState } from '../shared/serve-update-handoff' @@ -83,38 +81,6 @@ describe('serve update handoff', () => { expect(requestServeUpdateHandoff('1.0.61')).toBe(false) }) - it('ignores oversized sparse handoff state', async () => { - const handoffPath = getServeUpdateHandoffPath(root) - writeFileSync(handoffPath, '{"schemaVersion":1}') - truncateSync(handoffPath, MAX_SERVE_UPDATE_HANDOFF_FILE_BYTES + 1) - const { getServeUpdateHandoffFailure } = await import('./serve-update-handoff') - - expect(getServeUpdateHandoffFailure()).toBeNull() - }) - - it('rejects structurally amplified handoff state before parsing', async () => { - const handoffPath = getServeUpdateHandoffPath(root) - writeFileSync( - handoffPath, - `{"padding":[${'0,'.repeat(MAX_SERVE_UPDATE_HANDOFF_JSON_STRUCTURAL_TOKENS)}0]}` - ) - const parseSpy = vi.spyOn(JSON, 'parse') - const { getServeUpdateHandoffFailure } = await import('./serve-update-handoff') - - expect(getServeUpdateHandoffFailure()).toBeNull() - expect(parseSpy).not.toHaveBeenCalled() - }) - - it('preserves prior handoff state when the replacement exceeds its read ceiling', async () => { - const { requestServeUpdateHandoff } = await import('./serve-update-handoff') - expect(requestServeUpdateHandoff('1.0.61')).toBe(true) - const handoffPath = getServeUpdateHandoffPath(root) - const before = readFileSync(handoffPath, 'utf8') - - expect(requestServeUpdateHandoff('x'.repeat(MAX_SERVE_UPDATE_HANDOFF_FILE_BYTES))).toBe(false) - expect(readFileSync(handoffPath, 'utf8')).toBe(before) - }) - it.runIf(process.platform === 'darwin')( 'quits a supervised serve child when its CLI parent is lost', async () => { diff --git a/src/main/serve-update-handoff.ts b/src/main/serve-update-handoff.ts index e0ee6b681ce..ab831a90d49 100644 --- a/src/main/serve-update-handoff.ts +++ b/src/main/serve-update-handoff.ts @@ -1,13 +1,10 @@ -import { mkdirSync, renameSync, unlinkSync, writeFileSync } from 'node:fs' +import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs' import { dirname, resolve } from 'node:path' import { app } from 'electron' -import { readNodeFileSyncWithinLimit } from '../shared/node-bounded-file-reader' -import { stringifyJsonWithinByteLimit } from '../shared/node-bounded-json-stringify' import { - MAX_SERVE_UPDATE_HANDOFF_FILE_BYTES, SERVE_UPDATE_HANDOFF_PATH_ENV, getServeUpdateHandoffPath, - parseServeUpdateHandoffJson, + parseServeUpdateHandoffState, type ServeSupervisorMessage, type ServeUpdateHandoffState } from '../shared/serve-update-handoff' @@ -46,11 +43,7 @@ export function failServeUpdateHandoff(reason: string): void { return } try { - const state = parseServeUpdateHandoffJson( - readNodeFileSyncWithinLimit(handoffPath, MAX_SERVE_UPDATE_HANDOFF_FILE_BYTES).buffer.toString( - 'utf8' - ) - ) + const state = parseServeUpdateHandoffState(JSON.parse(readFileSync(handoffPath, 'utf8'))) if (state?.phase !== 'install-requested' || state.servingPid !== process.pid) { return } @@ -97,11 +90,7 @@ export function getServeUpdateHandoffFailure(): string | null { return null } try { - const state = parseServeUpdateHandoffJson( - readNodeFileSyncWithinLimit(handoffPath, MAX_SERVE_UPDATE_HANDOFF_FILE_BYTES).buffer.toString( - 'utf8' - ) - ) + const state = parseServeUpdateHandoffState(JSON.parse(readFileSync(handoffPath, 'utf8'))) if (state?.phase !== 'failed') { return null } @@ -118,17 +107,11 @@ export function getServeUpdateHandoffFailure(): string | null { function writeHandoffState(path: string, state: ServeUpdateHandoffState): boolean { const temporaryPath = `${path}.${process.pid}.tmp` try { - const { serialized } = stringifyJsonWithinByteLimit(state, MAX_SERVE_UPDATE_HANDOFF_FILE_BYTES) mkdirSync(dirname(path), { recursive: true }) - writeFileSync(temporaryPath, serialized, { mode: 0o600 }) + writeFileSync(temporaryPath, JSON.stringify(state), { mode: 0o600 }) renameSync(temporaryPath, path) return true } catch { - try { - unlinkSync(temporaryPath) - } catch { - // The temporary handoff is best-effort state and may not have been created. - } return false } } diff --git a/src/main/setup-script-import-file.test.ts b/src/main/setup-script-import-file.test.ts deleted file mode 100644 index 9d0bfbe4f36..00000000000 --- a/src/main/setup-script-import-file.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { afterEach, describe, expect, it } from 'vitest' -import { mkdtemp, rm, truncate, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { NodeFileReadTooLargeError } from '../shared/node-bounded-file-reader' -import { - isSetupScriptImportTextWithinLimit, - readSetupScriptImportFile, - SETUP_SCRIPT_IMPORT_FILE_MAX_BYTES -} from './setup-script-import-file' - -describe('setup script import file bounds', () => { - const roots: string[] = [] - - afterEach(async () => { - await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) - }) - - async function makeFile(contents: string): Promise { - const root = await mkdtemp(join(tmpdir(), 'orca-setup-script-import-')) - roots.push(root) - const filePath = join(root, 'config.json') - await writeFile(filePath, contents) - return filePath - } - - it('preserves ordinary configuration text', async () => { - const filePath = await makeFile('{"setup":"pnpm install"}') - - await expect(readSetupScriptImportFile(filePath)).resolves.toBe('{"setup":"pnpm install"}') - }) - - it('rejects an oversized sparse local configuration', async () => { - const filePath = await makeFile('x') - await truncate(filePath, SETUP_SCRIPT_IMPORT_FILE_MAX_BYTES + 1) - - await expect(readSetupScriptImportFile(filePath)).rejects.toBeInstanceOf( - NodeFileReadTooLargeError - ) - }) - - it('measures remote configuration strings by UTF-8 bytes', () => { - expect(isSetupScriptImportTextWithinLimit('a'.repeat(SETUP_SCRIPT_IMPORT_FILE_MAX_BYTES))).toBe( - true - ) - expect( - isSetupScriptImportTextWithinLimit('a'.repeat(SETUP_SCRIPT_IMPORT_FILE_MAX_BYTES + 1)) - ).toBe(false) - expect( - isSetupScriptImportTextWithinLimit('é'.repeat(SETUP_SCRIPT_IMPORT_FILE_MAX_BYTES / 2)) - ).toBe(true) - expect( - isSetupScriptImportTextWithinLimit('é'.repeat(SETUP_SCRIPT_IMPORT_FILE_MAX_BYTES / 2 + 1)) - ).toBe(false) - }) -}) diff --git a/src/main/setup-script-import-file.ts b/src/main/setup-script-import-file.ts deleted file mode 100644 index 66b9dd537da..00000000000 --- a/src/main/setup-script-import-file.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { readNodeFileWithinLimit } from '../shared/node-bounded-file-reader' -import { - isSetupScriptImportTextWithinLimit, - SETUP_SCRIPT_IMPORT_FILE_MAX_BYTES, - SETUP_SCRIPT_IMPORT_MAX_CODE_UNITS -} from '../shared/setup-script-import-limits' - -export { - isSetupScriptImportTextWithinLimit, - SETUP_SCRIPT_IMPORT_FILE_MAX_BYTES, - SETUP_SCRIPT_IMPORT_MAX_CODE_UNITS -} - -export async function readSetupScriptImportFile(filePath: string): Promise { - return ( - await readNodeFileWithinLimit(filePath, SETUP_SCRIPT_IMPORT_FILE_MAX_BYTES) - ).buffer.toString('utf-8') -} diff --git a/src/main/skills/claude-plugin-skill-sources-wsl.test.ts b/src/main/skills/claude-plugin-skill-sources-wsl.test.ts index 216d8385699..4d4f1add82c 100644 --- a/src/main/skills/claude-plugin-skill-sources-wsl.test.ts +++ b/src/main/skills/claude-plugin-skill-sources-wsl.test.ts @@ -35,8 +35,4 @@ describe('WSL Claude plugin metadata', () => { 'invalid response' ) }) - - it('parses delimiter-heavy output without materializing a field array', () => { - expect(parseWslClaudePluginMetadataOutput('\0'.repeat(1_000_000), 2)).toEqual([null, null]) - }) }) diff --git a/src/main/skills/claude-plugin-skill-sources-wsl.ts b/src/main/skills/claude-plugin-skill-sources-wsl.ts index bf22d9aba0f..d3adddfde55 100644 --- a/src/main/skills/claude-plugin-skill-sources-wsl.ts +++ b/src/main/skills/claude-plugin-skill-sources-wsl.ts @@ -7,10 +7,6 @@ import { type ClaudePluginMetadata } from './claude-plugin-skill-sources' import type { SkillScanRoot } from './skill-discovery-sources' -import { - readWslSkillProtocolField, - type WslSkillProtocolFieldCursor -} from './wsl-skill-protocol-fields' const MAX_PLUGIN_METADATA_BYTES = 4 * 1024 * 1024 const WSL_METADATA_TIMEOUT_MS = 5_000 @@ -43,26 +39,17 @@ export function parseWslClaudePluginMetadataOutput( fileCount: number ): (string | null)[] { const contents = Array(fileCount).fill(null) - const cursor: WslSkillProtocolFieldCursor = { offset: 0 } - const readField = (): string => - readWslSkillProtocolField( - output, - cursor, - 'WSL Claude plugin metadata returned an incomplete response.' - ) - while (cursor.offset < output.length) { - // Why: cursor reads keep delimiter-heavy subprocess output from amplifying into a huge array. - const kind = readField() - if (!kind) { - break - } - const fileIndex = Number.parseInt(readField(), 10) - const exists = readField() === '1' - const encoded = readField() + const fields = output.split('\0') + let index = 0 + while (index < fields.length && fields[index]) { + const kind = fields[index++] + const fileIndex = Number.parseInt(fields[index++] ?? '', 10) + const exists = fields[index++] === '1' + const encoded = fields[index++] if (kind !== 'F' || !Number.isInteger(fileIndex) || fileIndex < 0 || fileIndex >= fileCount) { throw new Error('WSL Claude plugin metadata returned an invalid response.') } - if (exists) { + if (exists && encoded !== undefined) { contents[fileIndex] = Buffer.from(encoded, 'base64').toString('utf8') } } diff --git a/src/main/skills/discovery.test.ts b/src/main/skills/discovery.test.ts index 89fed1befe2..a5d113aebe9 100644 --- a/src/main/skills/discovery.test.ts +++ b/src/main/skills/discovery.test.ts @@ -328,25 +328,4 @@ describe('skill discovery', () => { expect(result.skills.map((skill) => skill.name)).not.toContain('Too Deep') }) - - it('rejects pathological retained skill metadata instead of growing the result unboundedly', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-skills-')) - const home = join(root, 'home') - const skillsRoot = join(home, '.agents', 'skills') - const description = 'x'.repeat(150_000) - await Promise.all( - Array.from({ length: 16 }, async (_, index) => { - const skillDir = join(skillsRoot, `large-${index}`) - await mkdir(skillDir, { recursive: true }) - await writeFile( - join(skillDir, 'SKILL.md'), - `---\nname: large-${index}\ndescription: ${description}\n---\n` - ) - }) - ) - - await expect( - discoverSkills({ homeDir: home, cwd: join(root, 'missing-cwd'), repos: [] }) - ).rejects.toThrow(/result is too large/) - }) }) diff --git a/src/main/skills/discovery.ts b/src/main/skills/discovery.ts index e8965d6e1b2..00edfc8e705 100644 --- a/src/main/skills/discovery.ts +++ b/src/main/skills/discovery.ts @@ -1,4 +1,5 @@ -import { open, opendir, realpath, stat } from 'node:fs/promises' +import type { Dirent } from 'node:fs' +import { open, readdir, realpath, stat } from 'node:fs/promises' import { homedir } from 'node:os' import { basename, dirname, isAbsolute, join, relative, sep } from 'node:path' import { summarizeSkillMarkdown } from '../../shared/skill-metadata' @@ -17,15 +18,6 @@ import { type SkillScanRoot } from './skill-discovery-sources' import { discoverClaudePluginSkillSources } from './claude-plugin-skill-sources' -import { mapWithConcurrency } from '../../shared/map-with-concurrency' -import { - MAX_CONCURRENT_SKILL_DISCOVERY_CANDIDATES, - MAX_CONCURRENT_SKILL_DISCOVERY_ROOTS, - MAX_SKILL_PACKAGE_DIRECTORIES, - MAX_SKILL_PACKAGE_ENTRIES, - SkillDiscoveryBudget, - SkillDiscoveryLimitError -} from './skill-discovery-limits' export { buildSkillDiscoverySources } from './skill-discovery-sources' @@ -54,18 +46,13 @@ function isWithinDepth(rootPath: string, childPath: string, maxDepth: number): b return rel.split(sep).length <= maxDepth } -async function findSkillFiles( - rootPath: string, - maxDepth: number, - budget: SkillDiscoveryBudget -): Promise { +async function findSkillFiles(rootPath: string, maxDepth: number): Promise { const out: string[] = [] const visitedDirectoryPaths = new Set() async function visit(dirPath: string): Promise { if (!isWithinDepth(rootPath, dirPath, maxDepth)) { return } - budget.visitDirectory() let resolvedDirPath: string try { resolvedDirPath = await realpath(dirPath) @@ -77,45 +64,44 @@ async function findSkillFiles( } visitedDirectoryPaths.add(resolvedDirPath) - const directory = await opendir(dirPath).catch(() => null) - if (!directory) { + let entries: Dirent[] + try { + entries = await readdir(dirPath, { withFileTypes: true }) + } catch { return } - try { - for await (const entry of directory) { - budget.visitEntry() - const entryPath = join(dirPath, entry.name) - if (entry.name === SKILL_FILE_NAME) { - if (entry.isFile()) { - budget.admitCandidate() - out.push(entryPath) - continue - } - if (entry.isSymbolicLink()) { - const linkedStat = await stat(entryPath).catch(() => null) - if (linkedStat?.isFile()) { - budget.admitCandidate() - out.push(entryPath) - } - } - continue - } - if (entry.isDirectory()) { - await visit(entryPath) + for (const entry of entries) { + const entryPath = join(dirPath, entry.name) + if (entry.name === SKILL_FILE_NAME) { + if (entry.isFile()) { + out.push(entryPath) continue } if (entry.isSymbolicLink()) { - // Why: users commonly symlink agent skill dirs across providers; follow - // directory links but guard by realpath so recursive links cannot loop. - const linkedStat = await stat(entryPath).catch(() => null) - if (linkedStat?.isDirectory()) { - await visit(entryPath) + try { + if ((await stat(entryPath)).isFile()) { + out.push(entryPath) + } + } catch { + // Broken links are not valid skill files. } } + continue } - } catch (error) { - if (error instanceof SkillDiscoveryLimitError) { - throw error + if (entry.isDirectory()) { + await visit(entryPath) + continue + } + if (entry.isSymbolicLink()) { + // Why: users commonly symlink agent skill dirs across providers; follow + // directory links but guard by realpath so recursive links cannot loop. + try { + if ((await stat(entryPath)).isDirectory()) { + await visit(entryPath) + } + } catch { + // Broken links are not valid skill directories. + } } } } @@ -125,16 +111,9 @@ async function findSkillFiles( async function countFiles(dirPath: string): Promise { let count = 0 - let entriesVisited = 0 - let stoppedEarly = false const visitedDirectoryPaths = new Set() async function visit(currentPath: string): Promise { - if ( - stoppedEarly || - count >= MAX_SKILL_FILES || - visitedDirectoryPaths.size >= MAX_SKILL_PACKAGE_DIRECTORIES - ) { - stoppedEarly = true + if (count >= MAX_SKILL_FILES) { return } let resolvedPath: string @@ -148,34 +127,30 @@ async function countFiles(dirPath: string): Promise { } visitedDirectoryPaths.add(resolvedPath) - const directory = await opendir(currentPath).catch(() => null) - if (!directory) { + let entries: Dirent[] + try { + entries = await readdir(currentPath, { withFileTypes: true }) + } catch { return } - try { - for await (const entry of directory) { - entriesVisited += 1 - if (count >= MAX_SKILL_FILES || entriesVisited > MAX_SKILL_PACKAGE_ENTRIES) { - stoppedEarly = true - return - } - const entryPath = join(currentPath, entry.name) - if (entry.isFile()) { - count += 1 - } else if (entry.isDirectory()) { - await visit(entryPath) - } else if (entry.isSymbolicLink()) { - try { - if ((await stat(entryPath)).isFile()) { - count += 1 - } - } catch { - // Broken links do not contribute to the skill package file count. + for (const entry of entries) { + if (count >= MAX_SKILL_FILES) { + return + } + const entryPath = join(currentPath, entry.name) + if (entry.isFile()) { + count += 1 + } else if (entry.isDirectory()) { + await visit(entryPath) + } else if (entry.isSymbolicLink()) { + try { + if ((await stat(entryPath)).isFile()) { + count += 1 } + } catch { + // Broken links do not contribute to the skill package file count. } } - } catch { - // Preserve the partial count when a directory changes during enumeration. } } await visit(dirPath) @@ -209,16 +184,11 @@ async function readSkillSummary(skillFilePath: string): Promise<{ type ScannedSkill = DiscoveredSkill & { canonicalSkillFilePath: string } -async function scanRoot( - root: SkillScanRoot, - budget: SkillDiscoveryBudget -): Promise { +async function scanRoot(root: SkillScanRoot): Promise { const maxDepth = root.sourceKind === 'plugin' ? 9 : 4 - const skillFiles = await findSkillFiles(root.path, maxDepth, budget) - const skills = await mapWithConcurrency( - skillFiles, - MAX_CONCURRENT_SKILL_DISCOVERY_CANDIDATES, - async (skillFilePath): Promise => { + const skillFiles = await findSkillFiles(root.path, maxDepth) + const skills = await Promise.all( + skillFiles.map(async (skillFilePath): Promise => { // Why: path identity belongs to the scanning host; canonicalizing before // returning prevents symlinked roots from becoming duplicate picker rows. const canonicalSkillFilePath = await realpath(skillFilePath).catch(() => skillFilePath) @@ -228,7 +198,7 @@ async function scanRoot( return null } const sourceKind = sourceKindForSkill(root, skillFilePath, { relative, sep }) - const skill = { + return { id: stablePathId(canonicalSkillFilePath), name: summary.name ?? basename(directoryPath), description: summary.description, @@ -245,9 +215,7 @@ async function scanRoot( updatedAt: summary.updatedAt, canonicalSkillFilePath } satisfies ScannedSkill - budget.retainSkill(skill) - return skill - } + }) ) return skills.filter((skill): skill is ScannedSkill => skill !== null) } @@ -267,58 +235,54 @@ export async function discoverSkills(args: { ? await discoverClaudePluginSkillSources({ homeDir, cwd: args.cwd }) : []) ] - const budget = new SkillDiscoveryBudget(roots) - const scannedRoots = await mapWithConcurrency( - roots, - MAX_CONCURRENT_SKILL_DISCOVERY_ROOTS, - async (root) => { + const sources: SkillDiscoverySource[] = [] + const skillGroups = await Promise.all( + roots.map(async (root) => { const exists = await pathExists(root.path) - const source: SkillDiscoverySource = { + sources.push({ ...root, providers: [...root.providers], exists, skippedReason: exists ? undefined : 'missing' - } + }) if (!exists) { - return { source, skills: [] } + return [] } - return { source, skills: await scanRoot(root, budget) } - } + return scanRoot(root) + }) ) const seen = new Map() - for (const group of scannedRoots) { - for (const skill of group.skills) { - // Why: overlapping repo/cwd roots and symlinked provider homes can reach - // the same file. Keep the first source's higher-level scope identity, but - // record every contributing root so per-agent visibility survives dedup. - const existing = seen.get(skill.canonicalSkillFilePath) - if (existing) { - if (existing.rootPaths && !existing.rootPaths.includes(skill.rootPath)) { - existing.rootPaths.push(skill.rootPath) - } - // Why: providers is per-agent visibility just like rootPaths; keeping only - // the first root's tags makes a shared/symlinked skill under-report which - // agents can see it on the Settings provider badges/filter. Reassign a - // fresh array — `providers` aliases the scan root's array, so pushing in - // place would mutate the root and every sibling skill/source sharing it. - const mergedProviders = [...existing.providers] - for (const provider of skill.providers) { - if (!mergedProviders.includes(provider)) { - mergedProviders.push(provider) - } - } - existing.providers = mergedProviders - continue + for (const skill of skillGroups.flat()) { + // Why: overlapping repo/cwd roots and symlinked provider homes can reach + // the same file. Keep the first source's higher-level scope identity, but + // record every contributing root so per-agent visibility survives dedup. + const existing = seen.get(skill.canonicalSkillFilePath) + if (existing) { + if (existing.rootPaths && !existing.rootPaths.includes(skill.rootPath)) { + existing.rootPaths.push(skill.rootPath) } - const { canonicalSkillFilePath, ...publicSkill } = skill - seen.set(canonicalSkillFilePath, { ...publicSkill, rootPaths: [skill.rootPath] }) + // Why: providers is per-agent visibility just like rootPaths; keeping only + // the first root's tags makes a shared/symlinked skill under-report which + // agents can see it on the Settings provider badges/filter. Reassign a + // fresh array — `providers` aliases the scan root's array, so pushing in + // place would mutate the root and every sibling skill/source sharing it. + const mergedProviders = [...existing.providers] + for (const provider of skill.providers) { + if (!mergedProviders.includes(provider)) { + mergedProviders.push(provider) + } + } + existing.providers = mergedProviders + continue } + const { canonicalSkillFilePath, ...publicSkill } = skill + seen.set(canonicalSkillFilePath, { ...publicSkill, rootPaths: [skill.rootPath] }) } return { skills: Array.from(seen.values()).sort(compareSkills), - sources: scannedRoots - .map(({ source }) => source) - .sort((a, b) => a.label.localeCompare(b.label, undefined, { sensitivity: 'base' })), + sources: sources.sort((a, b) => + a.label.localeCompare(b.label, undefined, { sensitivity: 'base' }) + ), scannedAt: Date.now() } } diff --git a/src/main/skills/skill-bundle-artifacts.test.ts b/src/main/skills/skill-bundle-artifacts.test.ts index 459c093df8b..cf58caebe09 100644 --- a/src/main/skills/skill-bundle-artifacts.test.ts +++ b/src/main/skills/skill-bundle-artifacts.test.ts @@ -1,15 +1,8 @@ -import { mkdir, mkdtemp, readFile, rm, truncate, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { NodeFileReadTooLargeError } from '../../shared/node-bounded-file-reader' -import { - loadSkillBundleArtifacts, - readSkillBundleArtifactJson, - SKILL_BUNDLE_CURRENT_MANIFEST_MAX_BYTES, - SKILL_BUNDLE_JSON_MAX_STRUCTURAL_TOKENS, - SKILL_BUNDLE_SNAPSHOT_REGISTRY_MAX_BYTES -} from './skill-bundle-artifacts' +import { afterEach, describe, expect, it } from 'vitest' +import { loadSkillBundleArtifacts } from './skill-bundle-artifacts' const temporaryDirectories: string[] = [] @@ -83,45 +76,4 @@ describe('skill bundle artifacts', () => { 'Invalid skill release mapping' ) }) - - it('rejects a sparse oversized artifact without poisoning a later bounded load', async () => { - const resourceRoot = await mkdtemp(join(tmpdir(), 'orca-skill-artifacts-')) - temporaryDirectories.push(resourceRoot) - const target = join(resourceRoot, 'skills') - const source = resolve('resources', 'skills') - await mkdir(target, { recursive: true }) - const [manifest, registry, releaseMapping] = await Promise.all( - ['current-manifest.json', 'snapshot-registry.json', 'release-mapping.json'].map((name) => - readFile(join(source, name), 'utf8') - ) - ) - const manifestPath = join(target, 'current-manifest.json') - await Promise.all([ - writeFile(manifestPath, ''), - writeFile(join(target, 'snapshot-registry.json'), registry), - writeFile(join(target, 'release-mapping.json'), releaseMapping) - ]) - await truncate(manifestPath, SKILL_BUNDLE_CURRENT_MANIFEST_MAX_BYTES + 1) - - await expect(loadSkillBundleArtifacts(resourceRoot)).rejects.toThrow(NodeFileReadTooLargeError) - - await writeFile(manifestPath, manifest) - await expect(loadSkillBundleArtifacts(resourceRoot)).resolves.toMatchObject({ - manifest: { schemaVersion: 2 } - }) - }) - - it('rejects structural amplification before parsing a bounded artifact', async () => { - const resourceRoot = await mkdtemp(join(tmpdir(), 'orca-skill-artifacts-')) - temporaryDirectories.push(resourceRoot) - const path = join(resourceRoot, 'amplified.json') - await writeFile(path, `[${'0,'.repeat(SKILL_BUNDLE_JSON_MAX_STRUCTURAL_TOKENS)}0]`) - const parseSpy = vi.spyOn(JSON, 'parse') - - await expect( - readSkillBundleArtifactJson(path, SKILL_BUNDLE_SNAPSHOT_REGISTRY_MAX_BYTES) - ).rejects.toThrow('JSON structure exceeds') - expect(parseSpy).not.toHaveBeenCalled() - parseSpy.mockRestore() - }) }) diff --git a/src/main/skills/skill-bundle-artifacts.ts b/src/main/skills/skill-bundle-artifacts.ts index fe54454e0df..08e33a0eeba 100644 --- a/src/main/skills/skill-bundle-artifacts.ts +++ b/src/main/skills/skill-bundle-artifacts.ts @@ -1,8 +1,7 @@ import { app } from 'electron' +import { readFile } from 'node:fs/promises' import { join, resolve } from 'node:path' import { z, type ZodType } from 'zod' -import { assertJsonTextStructureWithinLimits } from '../../shared/json-text-structure-limit' -import { readNodeFileWithinLimit } from '../../shared/node-bounded-file-reader' import type { SkillBundleManifest, SkillKnownSnapshot, @@ -10,12 +9,6 @@ import type { SkillSnapshotRegistry } from '../../shared/skill-freshness' -export const SKILL_BUNDLE_CURRENT_MANIFEST_MAX_BYTES = 2 * 1024 * 1024 -export const SKILL_BUNDLE_SNAPSHOT_REGISTRY_MAX_BYTES = 16 * 1024 * 1024 -export const SKILL_BUNDLE_RELEASE_MAPPING_MAX_BYTES = 2 * 1024 * 1024 -export const SKILL_BUNDLE_JSON_MAX_STRUCTURAL_TOKENS = 1_000_000 -export const SKILL_BUNDLE_JSON_MAX_NESTING_DEPTH = 128 - export type SkillBundleArtifacts = { manifest: SkillBundleManifest registry: SkillSnapshotRegistry @@ -110,18 +103,9 @@ export function loadSkillBundleArtifacts( async function readSkillBundleArtifacts(resourceRoot: string): Promise { const bundleRoot = join(resourceRoot, 'skills') const [manifestValue, registryValue, releaseMappingValue] = await Promise.all([ - readSkillBundleArtifactJson( - join(bundleRoot, 'current-manifest.json'), - SKILL_BUNDLE_CURRENT_MANIFEST_MAX_BYTES - ), - readSkillBundleArtifactJson( - join(bundleRoot, 'snapshot-registry.json'), - SKILL_BUNDLE_SNAPSHOT_REGISTRY_MAX_BYTES - ), - readSkillBundleArtifactJson( - join(bundleRoot, 'release-mapping.json'), - SKILL_BUNDLE_RELEASE_MAPPING_MAX_BYTES - ) + readFile(join(bundleRoot, 'current-manifest.json'), 'utf8').then(JSON.parse), + readFile(join(bundleRoot, 'snapshot-registry.json'), 'utf8').then(JSON.parse), + readFile(join(bundleRoot, 'release-mapping.json'), 'utf8').then(JSON.parse) ]) const manifest: SkillBundleManifest = parseArtifact( manifestSchema, @@ -173,16 +157,3 @@ async function readSkillBundleArtifacts(resourceRoot: string): Promise { - const { buffer } = await readNodeFileWithinLimit(path, maxBytes) - const content = buffer.toString('utf8') - assertJsonTextStructureWithinLimits(content, { - structuralTokens: SKILL_BUNDLE_JSON_MAX_STRUCTURAL_TOKENS, - nestingDepth: SKILL_BUNDLE_JSON_MAX_NESTING_DEPTH - }) - return JSON.parse(content) as unknown -} diff --git a/src/main/skills/skill-discovery-limits.test.ts b/src/main/skills/skill-discovery-limits.test.ts deleted file mode 100644 index 659b0383db1..00000000000 --- a/src/main/skills/skill-discovery-limits.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { DiscoveredSkill } from '../../shared/skills' -import type { SkillScanRoot } from './skill-discovery-sources' -import { - MAX_SKILL_DISCOVERY_CANDIDATES, - MAX_SKILL_DISCOVERY_RESULT_BYTES, - MAX_SKILL_DISCOVERY_ROOTS, - SkillDiscoveryBudget, - WSL_SKILL_DISCOVERY_MAX_OUTPUT_BYTES, - assertSkillDiscoveryOutputWithinLimit -} from './skill-discovery-limits' - -function root(index: number): SkillScanRoot { - return { - id: `root-${index}`, - label: `Root ${index}`, - path: `/skills/${index}`, - sourceKind: 'home', - providers: ['agent-skills'], - owner: null - } -} - -function skill(description: string): DiscoveredSkill { - return { - id: 'skill-id', - name: 'Skill', - description, - providers: ['agent-skills'], - sourceKind: 'home', - sourceLabel: 'Agent skills home', - rootPath: '/skills', - directoryPath: '/skills/example', - skillFilePath: '/skills/example/SKILL.md', - installed: true, - fileCount: 1, - updatedAt: null - } -} - -describe('installed-skill discovery limits', () => { - it('rejects root and candidate counts beyond their fixed budgets', () => { - expect( - () => - new SkillDiscoveryBudget( - Array.from({ length: MAX_SKILL_DISCOVERY_ROOTS + 1 }, (_, index) => root(index)) - ) - ).toThrow(/too many roots/) - - const budget = new SkillDiscoveryBudget([root(0)]) - for (let index = 0; index < MAX_SKILL_DISCOVERY_CANDIDATES; index += 1) { - budget.admitCandidate() - } - expect(() => budget.admitCandidate()).toThrow(/too many skills/) - }) - - it('rejects retained result text and WSL output beyond their byte budgets', () => { - const budget = new SkillDiscoveryBudget([root(0)]) - expect(() => - budget.retainSkill(skill('x'.repeat(Math.floor(MAX_SKILL_DISCOVERY_RESULT_BYTES / 2)))) - ).toThrow(/result is too large/) - - expect(() => - assertSkillDiscoveryOutputWithinLimit('x'.repeat(WSL_SKILL_DISCOVERY_MAX_OUTPUT_BYTES + 1)) - ).toThrow(/output is too large/) - }) -}) diff --git a/src/main/skills/skill-discovery-limits.ts b/src/main/skills/skill-discovery-limits.ts deleted file mode 100644 index 038bf2f6ba7..00000000000 --- a/src/main/skills/skill-discovery-limits.ts +++ /dev/null @@ -1,119 +0,0 @@ -import type { DiscoveredSkill } from '../../shared/skills' -import type { SkillScanRoot } from './skill-discovery-sources' - -export const MAX_SKILL_DISCOVERY_ROOTS = 512 -export const MAX_SKILL_DISCOVERY_CANDIDATES = 1_024 -export const MAX_SKILL_DISCOVERY_TRAVERSED_DIRECTORIES = 20_000 -export const MAX_SKILL_DISCOVERY_TRAVERSED_ENTRIES = 100_000 -export const MAX_SKILL_DISCOVERY_RESULT_BYTES = 4 * 1024 * 1024 -export const MAX_CONCURRENT_SKILL_DISCOVERY_ROOTS = 4 -export const MAX_CONCURRENT_SKILL_DISCOVERY_CANDIDATES = 4 -export const MAX_SKILL_PACKAGE_DIRECTORIES = 512 -export const MAX_SKILL_PACKAGE_ENTRIES = 10_000 -export const WSL_SKILL_DISCOVERY_MAX_OUTPUT_BYTES = 8 * 1024 * 1024 - -export class SkillDiscoveryLimitError extends Error { - constructor(message: string) { - super(message) - this.name = 'SkillDiscoveryLimitError' - } -} - -export function assertSkillDiscoveryRootsWithinLimit(roots: readonly SkillScanRoot[]): void { - if (roots.length > MAX_SKILL_DISCOVERY_ROOTS) { - throw new SkillDiscoveryLimitError( - `Installed-skill discovery has too many roots (${roots.length}; max ${MAX_SKILL_DISCOVERY_ROOTS}).` - ) - } -} - -function stringBytes(value: string | null | undefined): number { - return (value?.length ?? 0) * 2 -} - -export function estimateDiscoveredSkillBytes(skill: DiscoveredSkill): number { - return ( - 256 + - stringBytes(skill.id) + - stringBytes(skill.name) + - stringBytes(skill.description) + - stringBytes(skill.sourceLabel) + - stringBytes(skill.rootPath) + - stringBytes(skill.directoryPath) + - stringBytes(skill.skillFilePath) + - skill.providers.reduce((bytes, provider) => bytes + stringBytes(provider), 0) + - (skill.rootPaths ?? []).reduce((bytes, rootPath) => bytes + stringBytes(rootPath), 0) - ) -} - -function estimateSkillSourceBytes(source: SkillScanRoot): number { - return ( - 192 + - stringBytes(source.id) + - stringBytes(source.label) + - stringBytes(source.path) + - source.providers.reduce((bytes, provider) => bytes + stringBytes(provider), 0) - ) -} - -export class SkillDiscoveryBudget { - private candidates = 0 - private directories = 0 - private entries = 0 - private retainedBytes = 0 - - constructor(roots: readonly SkillScanRoot[]) { - assertSkillDiscoveryRootsWithinLimit(roots) - for (const root of roots) { - this.retainBytes(estimateSkillSourceBytes(root)) - } - } - - visitDirectory(): void { - this.directories += 1 - if (this.directories > MAX_SKILL_DISCOVERY_TRAVERSED_DIRECTORIES) { - throw new SkillDiscoveryLimitError( - `Installed-skill discovery visited too many directories (max ${MAX_SKILL_DISCOVERY_TRAVERSED_DIRECTORIES}).` - ) - } - } - - visitEntry(): void { - this.entries += 1 - if (this.entries > MAX_SKILL_DISCOVERY_TRAVERSED_ENTRIES) { - throw new SkillDiscoveryLimitError( - `Installed-skill discovery visited too many entries (max ${MAX_SKILL_DISCOVERY_TRAVERSED_ENTRIES}).` - ) - } - } - - admitCandidate(): void { - this.candidates += 1 - if (this.candidates > MAX_SKILL_DISCOVERY_CANDIDATES) { - throw new SkillDiscoveryLimitError( - `Installed-skill discovery found too many skills (max ${MAX_SKILL_DISCOVERY_CANDIDATES}).` - ) - } - } - - retainSkill(skill: DiscoveredSkill): void { - this.retainBytes(estimateDiscoveredSkillBytes(skill)) - } - - private retainBytes(bytes: number): void { - this.retainedBytes += bytes - if (this.retainedBytes > MAX_SKILL_DISCOVERY_RESULT_BYTES) { - throw new SkillDiscoveryLimitError( - `Installed-skill discovery result is too large (max ${MAX_SKILL_DISCOVERY_RESULT_BYTES} bytes).` - ) - } - } -} - -export function assertSkillDiscoveryOutputWithinLimit(output: string): void { - if (Buffer.byteLength(output, 'utf8') > WSL_SKILL_DISCOVERY_MAX_OUTPUT_BYTES) { - throw new SkillDiscoveryLimitError( - `WSL installed-skill discovery output is too large (max ${WSL_SKILL_DISCOVERY_MAX_OUTPUT_BYTES} bytes).` - ) - } -} diff --git a/src/main/skills/skill-discovery-wsl.test.ts b/src/main/skills/skill-discovery-wsl.test.ts index 81e6c260316..621b9f4c0a3 100644 --- a/src/main/skills/skill-discovery-wsl.test.ts +++ b/src/main/skills/skill-discovery-wsl.test.ts @@ -85,29 +85,12 @@ describe('WSL skill discovery', () => { expect(script).toContain('find -L "$root_path"') expect(script).toContain('realpath -- "$skill_file"') expect(script).toContain('head -c 262144 -- "$skill_file"') - expect(script).toContain('skill_count=$((skill_count + 1))') - expect(script).toContain("printf '%s\\0%s\\0' E skill-limit") expect(script).toContain(`'/work/alice'\\''s project/.agents/skills'`) }) - it('rejects an explicit distro-side candidate-limit marker', () => { - expect(() => parseWslSkillDiscoveryOutput(record('E', 'skill-limit'), [homeRoot])).toThrow( - /too many skills/ - ) - }) - it('rejects malformed host responses instead of reporting an empty scan', () => { expect(() => parseWslSkillDiscoveryOutput(record('S', '9'), [homeRoot])).toThrow( 'unknown source' ) }) - - it('parses delimiter-heavy output without materializing a field array', () => { - const result = parseWslSkillDiscoveryOutput('\0'.repeat(1_000_000), [homeRoot], 42) - - expect(result.skills).toEqual([]) - expect(result.sources).toEqual([ - expect.objectContaining({ id: 'home-codex', exists: false, skippedReason: 'missing' }) - ]) - }) }) diff --git a/src/main/skills/skill-discovery-wsl.ts b/src/main/skills/skill-discovery-wsl.ts index 189d4fa11af..c4f553225ae 100644 --- a/src/main/skills/skill-discovery-wsl.ts +++ b/src/main/skills/skill-discovery-wsl.ts @@ -16,29 +16,16 @@ import { type SkillScanRoot } from './skill-discovery-sources' import { discoverClaudePluginSkillSourcesInWsl } from './claude-plugin-skill-sources-wsl' -import { - MAX_SKILL_DISCOVERY_CANDIDATES, - SkillDiscoveryBudget, - SkillDiscoveryLimitError, - WSL_SKILL_DISCOVERY_MAX_OUTPUT_BYTES, - assertSkillDiscoveryOutputWithinLimit, - assertSkillDiscoveryRootsWithinLimit -} from './skill-discovery-limits' -import { - readWslSkillProtocolField, - type WslSkillProtocolFieldCursor -} from './wsl-skill-protocol-fields' const MAX_MARKDOWN_BYTES = 256 * 1024 const MAX_PACKAGE_FILES = 200 const WSL_SCAN_TIMEOUT_MS = 10_000 +const WSL_SCAN_MAX_BUFFER_BYTES = 128 * 1024 * 1024 export function buildWslSkillDiscoveryCommand(roots: readonly SkillScanRoot[]): string { - assertSkillDiscoveryRootsWithinLimit(roots) const lines = [ 'set -u', 'set -o pipefail', - 'skill_count=0', 'scan_root() {', ' root_index=$1', ' root_path=$2', @@ -53,11 +40,6 @@ export function buildWslSkillDiscoveryCommand(roots: readonly SkillScanRoot[]): ` directory_path=\${skill_file%/*}`, ` updated_at=$(stat -c '%Y' -- "$skill_file" 2>/dev/null || true)`, ` encoded_markdown=$(head -c ${MAX_MARKDOWN_BYTES} -- "$skill_file" 2>/dev/null | base64 | tr -d '\\n') || continue`, - ' skill_count=$((skill_count + 1))', - ` if [ "$skill_count" -gt ${MAX_SKILL_DISCOVERY_CANDIDATES} ]; then`, - ` printf '%s\\0%s\\0' E skill-limit`, - ' exit 0', - ' fi', ' file_count=0', ` while IFS= read -r -d '' package_file; do`, ' file_count=$((file_count + 1))', @@ -83,7 +65,7 @@ function executeWslSkillDiscovery(distro: string, command: string): Promise() const skillsByCanonicalPath = new Map() - const cursor: WslSkillProtocolFieldCursor = { offset: 0 } - while (cursor.offset < output.length) { - // Why: cursor reads avoid turning delimiter-heavy bounded output into millions of string slots. - const recordKind = readProtocolField(output, cursor) - if (!recordKind) { - break - } - if (recordKind === 'E') { - const reason = readProtocolField(output, cursor) - throw new SkillDiscoveryLimitError( - reason === 'skill-limit' - ? `WSL installed-skill discovery found too many skills (max ${MAX_SKILL_DISCOVERY_CANDIDATES}).` - : 'WSL installed-skill discovery exceeded a safety limit.' - ) - } - const rootIndex = Number.parseInt(readProtocolField(output, cursor), 10) + let index = 0 + while (index < fields.length && fields[index]) { + const recordKind = fields[index++] + const rootIndex = Number.parseInt(readProtocolField(fields, index++), 10) const root = roots[rootIndex] if (!root) { throw new Error('WSL skill discovery returned an unknown source.') } if (recordKind === 'R') { - rootExists.set(rootIndex, readProtocolField(output, cursor) === '1') + rootExists.set(rootIndex, readProtocolField(fields, index++) === '1') continue } if (recordKind !== 'S') { throw new Error('WSL skill discovery returned an invalid response.') } - budget.admitCandidate() - const skillFilePath = readProtocolField(output, cursor) - const canonicalSkillFilePath = readProtocolField(output, cursor) - const updatedAtSeconds = Number.parseInt(readProtocolField(output, cursor), 10) - const fileCount = Number.parseInt(readProtocolField(output, cursor), 10) - const markdown = Buffer.from(readProtocolField(output, cursor), 'base64').toString('utf8') + const skillFilePath = readProtocolField(fields, index++) + const canonicalSkillFilePath = readProtocolField(fields, index++) + const updatedAtSeconds = Number.parseInt(readProtocolField(fields, index++), 10) + const fileCount = Number.parseInt(readProtocolField(fields, index++), 10) + const markdown = Buffer.from(readProtocolField(fields, index++), 'base64').toString('utf8') const existing = skillsByCanonicalPath.get(canonicalSkillFilePath) if (existing) { // Why: dedup keeps one row, but every contributing root must survive so @@ -171,7 +139,7 @@ export function parseWslSkillDiscoveryOutput( const directoryPath = pathPosix.dirname(skillFilePath) const summary = summarizeSkillMarkdown(markdown) const sourceKind = sourceKindForSkill(root, skillFilePath, pathPosix) - const skill: DiscoveredSkill = { + skillsByCanonicalPath.set(canonicalSkillFilePath, { id: stablePathId(canonicalSkillFilePath), name: summary.name ?? pathPosix.basename(directoryPath), description: summary.description, @@ -187,9 +155,7 @@ export function parseWslSkillDiscoveryOutput( installed: true, fileCount: Number.isFinite(fileCount) ? fileCount : 0, updatedAt: Number.isFinite(updatedAtSeconds) ? updatedAtSeconds * 1000 : null - } - budget.retainSkill(skill) - skillsByCanonicalPath.set(canonicalSkillFilePath, skill) + }) } const sources: SkillDiscoverySource[] = roots.map((root, rootIndex) => { diff --git a/src/main/skills/wsl-skill-protocol-fields.ts b/src/main/skills/wsl-skill-protocol-fields.ts deleted file mode 100644 index 4af7bde4aea..00000000000 --- a/src/main/skills/wsl-skill-protocol-fields.ts +++ /dev/null @@ -1,20 +0,0 @@ -export type WslSkillProtocolFieldCursor = { offset: number } - -export function readWslSkillProtocolField( - output: string, - cursor: WslSkillProtocolFieldCursor, - incompleteMessage: string -): string { - if (cursor.offset >= output.length) { - throw new Error(incompleteMessage) - } - const end = output.indexOf('\0', cursor.offset) - if (end === -1) { - const value = output.slice(cursor.offset) - cursor.offset = output.length - return value - } - const value = output.slice(cursor.offset, end) - cursor.offset = end + 1 - return value -} diff --git a/src/main/source-control/hosted-review-api-request.ts b/src/main/source-control/hosted-review-api-request.ts index 3c1582bd616..dfdb0dbad70 100644 --- a/src/main/source-control/hosted-review-api-request.ts +++ b/src/main/source-control/hosted-review-api-request.ts @@ -1,8 +1,3 @@ -import { - readFetchResponseJsonWithinLimit, - readFetchResponseTextWithinLimit -} from '../lib/fetch-response-body' - export class HostedReviewApiRequestError extends Error { readonly status: number | null readonly timedOut: boolean @@ -17,7 +12,7 @@ export class HostedReviewApiRequestError extends Error { async function readResponseText(response: Response): Promise { try { - return await readFetchResponseTextWithinLimit(response) + return await response.text() } catch { return '' } @@ -36,7 +31,7 @@ export async function requestHostedReviewJson( status: response.status }) } - return await readFetchResponseJsonWithinLimit(response) + return (await response.json()) as T } catch (error) { if (error instanceof HostedReviewApiRequestError) { throw error diff --git a/src/main/source-control/pull-request-template.test.ts b/src/main/source-control/pull-request-template.test.ts deleted file mode 100644 index 5b0bf33fcda..00000000000 --- a/src/main/source-control/pull-request-template.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { afterEach, describe, expect, it } from 'vitest' -import { mkdir, mkdtemp, rm, truncate, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { MAX_HOSTED_REVIEW_TEMPLATE_BYTES, readHostedReviewTemplate } from './pull-request-template' - -describe('readHostedReviewTemplate', () => { - const roots: string[] = [] - - afterEach(async () => { - await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) - }) - - async function makeRepo(): Promise { - const root = await mkdtemp(join(tmpdir(), 'orca-review-template-')) - roots.push(root) - await mkdir(join(root, '.github'), { recursive: true }) - return root - } - - it('preserves the first conventional template under the limit', async () => { - const repoPath = await makeRepo() - await writeFile(join(repoPath, '.github', 'pull_request_template.md'), 'normal template\n') - await writeFile(join(repoPath, 'PULL_REQUEST_TEMPLATE.md'), 'fallback\n') - - await expect(readHostedReviewTemplate(repoPath)).resolves.toBe('normal template\n') - }) - - it('skips an oversized sparse template and reads the next candidate', async () => { - const repoPath = await makeRepo() - const oversizedPath = join(repoPath, '.github', 'pull_request_template.md') - await writeFile(oversizedPath, 'x') - await truncate(oversizedPath, MAX_HOSTED_REVIEW_TEMPLATE_BYTES + 1) - await writeFile(join(repoPath, '.github', 'PULL_REQUEST_TEMPLATE.md'), 'bounded fallback\n') - - await expect(readHostedReviewTemplate(repoPath)).resolves.toBe('bounded fallback\n') - }) - - it('accepts a template exactly at the byte limit', async () => { - const repoPath = await makeRepo() - const body = 'a'.repeat(MAX_HOSTED_REVIEW_TEMPLATE_BYTES) - await writeFile(join(repoPath, '.github', 'pull_request_template.md'), body) - - await expect(readHostedReviewTemplate(repoPath)).resolves.toHaveLength( - MAX_HOSTED_REVIEW_TEMPLATE_BYTES - ) - }) -}) diff --git a/src/main/source-control/pull-request-template.ts b/src/main/source-control/pull-request-template.ts index f3ecaa323db..e5f474a4958 100644 --- a/src/main/source-control/pull-request-template.ts +++ b/src/main/source-control/pull-request-template.ts @@ -1,10 +1,8 @@ +import { readFile } from 'node:fs/promises' import { join } from 'node:path' import type { HostedReviewProvider } from '../../shared/hosted-review' import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import { joinWorktreeRelativePath } from '../runtime/runtime-relative-paths' -import { readNodeFileWithinLimit } from '../../shared/node-bounded-file-reader' - -export const MAX_HOSTED_REVIEW_TEMPLATE_BYTES = 1024 * 1024 const PULL_REQUEST_TEMPLATE_CANDIDATES = [ '.github/pull_request_template.md', @@ -30,11 +28,6 @@ function getTemplateCandidates(provider?: HostedReviewProvider | null): string[] if (provider === 'gitlab') { return [...MERGE_REQUEST_TEMPLATE_CANDIDATES, ...PULL_REQUEST_TEMPLATE_CANDIDATES] } - if (provider === 'github') { - return PULL_REQUEST_TEMPLATE_CANDIDATES.filter( - (candidate) => !candidate.startsWith('.azuredevops/') && !candidate.startsWith('.gitea/') - ) - } return PULL_REQUEST_TEMPLATE_CANDIDATES } @@ -45,54 +38,27 @@ export async function readHostedPullRequestTemplate( return readHostedReviewTemplate(repoPath, connectionId) } -export async function readGitLabMergeRequestTemplate( - repoPath: string, - connectionId?: string | null -): Promise { - return readHostedReviewTemplateCandidates( - repoPath, - connectionId, - MERGE_REQUEST_TEMPLATE_CANDIDATES - ) -} - export async function readHostedReviewTemplate( repoPath: string, connectionId?: string | null, provider?: HostedReviewProvider | null -): Promise { - return readHostedReviewTemplateCandidates(repoPath, connectionId, getTemplateCandidates(provider)) -} - -async function readHostedReviewTemplateCandidates( - repoPath: string, - connectionId: string | null | undefined, - candidates: readonly string[] ): Promise { const remoteProvider = connectionId ? getSshFilesystemProvider(connectionId) : undefined if (connectionId && !remoteProvider) { return '' } - for (const relativeCandidate of candidates) { + for (const relativeCandidate of getTemplateCandidates(provider)) { try { if (remoteProvider) { const result = await remoteProvider.readFile( joinWorktreeRelativePath(repoPath, relativeCandidate) ) - if ( - result.isBinary || - Buffer.byteLength(result.content, 'utf8') > MAX_HOSTED_REVIEW_TEMPLATE_BYTES - ) { + if (result.isBinary) { continue } return result.content } - return ( - await readNodeFileWithinLimit( - join(repoPath, relativeCandidate), - MAX_HOSTED_REVIEW_TEMPLATE_BYTES - ) - ).buffer.toString('utf8') + return await readFile(join(repoPath, relativeCandidate), 'utf8') } catch { // Try the next conventional hosted-review template path. } diff --git a/src/main/source-control/repository-ref-cache.test.ts b/src/main/source-control/repository-ref-cache.test.ts deleted file mode 100644 index 41206fe10ad..00000000000 --- a/src/main/source-control/repository-ref-cache.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - buildRepositoryRefCacheKey, - REPOSITORY_REF_CACHE_KEY_MAX_BYTES, - REPOSITORY_REF_CACHE_MAX_ENTRIES, - REPOSITORY_REF_CACHE_VALUE_MAX_BYTES, - RepositoryRefCache -} from './repository-ref-cache' - -describe('repository ref cache bounds', () => { - it('admits an exact-boundary UTF-8 key and rejects one byte over', () => { - expect( - buildRepositoryRefCacheKey(['a'.repeat(REPOSITORY_REF_CACHE_KEY_MAX_BYTES - 1), '']) - ).not.toBeNull() - expect( - buildRepositoryRefCacheKey(['a'.repeat(REPOSITORY_REF_CACHE_KEY_MAX_BYTES), '']) - ).toBeNull() - }) - - it('measures multibyte keys by bytes', () => { - expect( - buildRepositoryRefCacheKey(['😀'.repeat(REPOSITORY_REF_CACHE_KEY_MAX_BYTES / 4)]) - ).not.toBeNull() - expect( - buildRepositoryRefCacheKey(['😀'.repeat(REPOSITORY_REF_CACHE_KEY_MAX_BYTES / 4 + 1)]) - ).toBeNull() - }) - - it('retains exact-boundary values and skips oversized values', () => { - const cache = new RepositoryRefCache<{ value: string }>() - const exactKey = buildRepositoryRefCacheKey(['exact']) - const oversizedKey = buildRepositoryRefCacheKey(['oversized']) - const exact = 'a'.repeat(REPOSITORY_REF_CACHE_VALUE_MAX_BYTES) - const oversized = 'a'.repeat(REPOSITORY_REF_CACHE_VALUE_MAX_BYTES + 1) - - cache.remember(exactKey, { value: exact }, [exact]) - cache.remember(oversizedKey, { value: oversized }, [oversized]) - - expect(cache.get(exactKey)).toEqual({ found: true, value: { value: exact } }) - expect(cache.get(oversizedKey)).toEqual({ found: false }) - }) - - it('bounds entry count and refreshes hits before LRU eviction', () => { - const cache = new RepositoryRefCache<{ value: number }>() - for (let index = 0; index < REPOSITORY_REF_CACHE_MAX_ENTRIES; index += 1) { - cache.remember(`key-${index}`, { value: index }, []) - } - expect(cache.get('key-0')).toEqual({ found: true, value: { value: 0 } }) - cache.remember('new-key', { value: -1 }, []) - - expect(cache.size).toBe(REPOSITORY_REF_CACHE_MAX_ENTRIES) - expect(cache.get('key-0').found).toBe(true) - expect(cache.get('key-1')).toEqual({ found: false }) - }) - - it('never retains an inadmissible key', () => { - const cache = new RepositoryRefCache<{ value: number }>() - cache.remember(null, { value: 1 }, []) - expect(cache.size).toBe(0) - }) -}) diff --git a/src/main/source-control/repository-ref-cache.ts b/src/main/source-control/repository-ref-cache.ts deleted file mode 100644 index 3c12ef0d3a8..00000000000 --- a/src/main/source-control/repository-ref-cache.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { measureUtf8ByteLength } from '../../shared/utf8-byte-limits' - -export const REPOSITORY_REF_CACHE_MAX_ENTRIES = 512 -export const REPOSITORY_REF_CACHE_KEY_MAX_BYTES = 4 * 1024 -export const REPOSITORY_REF_CACHE_VALUE_MAX_BYTES = 16 * 1024 - -export type RepositoryRefCacheLookup = { found: true; value: T | null } | { found: false } - -export function buildRepositoryRefCacheKey(parts: readonly string[]): string | null { - let remainingBytes = REPOSITORY_REF_CACHE_KEY_MAX_BYTES - Math.max(0, parts.length - 1) - if (remainingBytes < 0) { - return null - } - for (const part of parts) { - const measured = measureUtf8ByteLength(part, { stopAfterBytes: remainingBytes }) - if (measured.exceededLimit) { - return null - } - remainingBytes -= measured.byteLength - } - return parts.join('\0') -} - -export class RepositoryRefCache { - private readonly entries = new Map() - - clear(): void { - this.entries.clear() - } - - get size(): number { - return this.entries.size - } - - get(cacheKey: string | null): RepositoryRefCacheLookup { - if (cacheKey === null || !this.entries.has(cacheKey)) { - return { found: false } - } - const value = this.entries.get(cacheKey) ?? null - this.entries.delete(cacheKey) - this.entries.set(cacheKey, value) - return { found: true, value } - } - - remember(cacheKey: string | null, value: T | null, retainedStrings: readonly string[]): void { - if (cacheKey === null || !fitsValueBudget(retainedStrings)) { - return - } - this.entries.delete(cacheKey) - this.entries.set(cacheKey, value) - while (this.entries.size > REPOSITORY_REF_CACHE_MAX_ENTRIES) { - const oldestKey = this.entries.keys().next().value - if (oldestKey === undefined) { - return - } - this.entries.delete(oldestKey) - } - } -} - -function fitsValueBudget(values: readonly string[]): boolean { - let remainingBytes = REPOSITORY_REF_CACHE_VALUE_MAX_BYTES - for (const value of values) { - const measured = measureUtf8ByteLength(value, { stopAfterBytes: remainingBytes }) - if (measured.exceededLimit) { - return false - } - remainingBytes -= measured.byteLength - } - return true -} diff --git a/src/main/speech/model-cache-path-bounds.test.ts b/src/main/speech/model-cache-path-bounds.test.ts deleted file mode 100644 index a1e9bea9821..00000000000 --- a/src/main/speech/model-cache-path-bounds.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { mkdtemp, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { migrateSpeechModelCacheIfNeeded } from './model-cache-path' - -describe('speech model cache migration bounds', () => { - const cleanupPaths: string[] = [] - - afterEach(async () => { - vi.restoreAllMocks() - await Promise.all( - cleanupPaths.splice(0).map((path) => rm(path, { recursive: true, force: true })) - ) - }) - - async function createMigrationDirs(): Promise<{ sourceDir: string; targetDir: string }> { - const root = await mkdtemp(join(tmpdir(), 'orca-speech-migration-')) - cleanupPaths.push(root) - const sourceDir = join(root, 'source') - const targetDir = join(root, 'target') - await Promise.all([ - mkdir(sourceDir, { recursive: true }), - mkdir(targetDir, { recursive: true }) - ]) - return { sourceDir, targetDir } - } - - it('copies every entry at the exact count limit', async () => { - const { sourceDir, targetDir } = await createMigrationDirs() - await Promise.all([ - writeFile(join(sourceDir, 'encoder.onnx'), 'encoder'), - writeFile(join(sourceDir, 'tokens.txt'), 'tokens') - ]) - - await migrateSpeechModelCacheIfNeeded(sourceDir, targetDir, { maxEntries: 2 }) - - expect((await readdir(targetDir)).sort()).toEqual(['encoder.onnx', 'tokens.txt']) - }) - - it('stops before copying the first entry over the count limit', async () => { - const { sourceDir, targetDir } = await createMigrationDirs() - await Promise.all([ - writeFile(join(sourceDir, 'encoder.onnx'), 'encoder'), - writeFile(join(sourceDir, 'decoder.onnx'), 'decoder'), - writeFile(join(sourceDir, 'tokens.txt'), 'tokens') - ]) - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - - await migrateSpeechModelCacheIfNeeded(sourceDir, targetDir, { maxEntries: 2 }) - - expect(await readdir(targetDir)).toHaveLength(2) - expect(warn).toHaveBeenCalledWith( - '[speech] Failed to migrate speech model cache to ASCII path:', - expect.objectContaining({ message: expect.stringContaining('exceeded 2 entries') }) - ) - }) - - it('stops traversal beyond the configured depth', async () => { - const { sourceDir, targetDir } = await createMigrationDirs() - await mkdir(join(sourceDir, 'model', 'nested'), { recursive: true }) - await writeFile(join(sourceDir, 'model', 'nested', 'encoder.onnx'), 'encoder') - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - - await migrateSpeechModelCacheIfNeeded(sourceDir, targetDir, { maxDepth: 2 }) - - await expect(readFile(join(targetDir, 'model', 'nested', 'encoder.onnx'))).rejects.toThrow() - expect(warn).toHaveBeenCalledWith( - '[speech] Failed to migrate speech model cache to ASCII path:', - expect.objectContaining({ message: expect.stringContaining('exceeded depth 2') }) - ) - }) - - it('accepts the exact visited-path byte budget and rejects one byte less', async () => { - const exact = await createMigrationDirs() - await writeFile(join(exact.sourceDir, 'tokens.txt'), 'tokens') - const exactBytes = - Buffer.byteLength(join(exact.sourceDir, 'tokens.txt'), 'utf8') + - Buffer.byteLength(join(exact.targetDir, 'tokens.txt'), 'utf8') - - await migrateSpeechModelCacheIfNeeded(exact.sourceDir, exact.targetDir, { - maxVisitedPathBytes: exactBytes - }) - await expect(readFile(join(exact.targetDir, 'tokens.txt'), 'utf8')).resolves.toBe('tokens') - - const overflow = await createMigrationDirs() - await writeFile(join(overflow.sourceDir, 'tokens.txt'), 'tokens') - const overflowBytes = - Buffer.byteLength(join(overflow.sourceDir, 'tokens.txt'), 'utf8') + - Buffer.byteLength(join(overflow.targetDir, 'tokens.txt'), 'utf8') - vi.spyOn(console, 'warn').mockImplementation(() => {}) - - await migrateSpeechModelCacheIfNeeded(overflow.sourceDir, overflow.targetDir, { - maxVisitedPathBytes: overflowBytes - 1 - }) - await expect(readFile(join(overflow.targetDir, 'tokens.txt'))).rejects.toThrow() - }) - - it('keeps an existing target file unchanged', async () => { - const { sourceDir, targetDir } = await createMigrationDirs() - await writeFile(join(sourceDir, 'tokens.txt'), 'source') - await writeFile(join(targetDir, 'tokens.txt'), 'existing') - - await migrateSpeechModelCacheIfNeeded(sourceDir, targetDir) - - await expect(readFile(join(targetDir, 'tokens.txt'), 'utf8')).resolves.toBe('existing') - }) -}) diff --git a/src/main/speech/model-cache-path.ts b/src/main/speech/model-cache-path.ts index 3361fbca05b..82dd286f1dd 100644 --- a/src/main/speech/model-cache-path.ts +++ b/src/main/speech/model-cache-path.ts @@ -1,6 +1,6 @@ import { createHash } from 'node:crypto' import { existsSync } from 'node:fs' -import { cp, mkdir, opendir, rename, stat } from 'node:fs/promises' +import { cp, mkdir, readdir, rename, stat } from 'node:fs/promises' import { join, resolve } from 'node:path' export type SpeechModelCacheDir = { @@ -9,72 +9,6 @@ export type SpeechModelCacheDir = { } const WINDOWS_SAFE_CACHE_HASH_LENGTH = 16 -export const SPEECH_MODEL_CACHE_MIGRATION_MAX_ENTRIES = 16_384 -export const SPEECH_MODEL_CACHE_MIGRATION_MAX_DEPTH = 16 -export const SPEECH_MODEL_CACHE_MIGRATION_MAX_VISITED_PATH_BYTES = 16 * 1024 * 1024 - -export type SpeechModelCacheMigrationLimits = { - maxEntries: number - maxDepth: number - maxVisitedPathBytes: number -} - -class SpeechModelCacheMigrationBudget { - private entries = 0 - private visitedPathBytes = 0 - private readonly limits: SpeechModelCacheMigrationLimits - - constructor(requested: Partial) { - this.limits = { - maxEntries: resolveMigrationLimit( - requested.maxEntries, - SPEECH_MODEL_CACHE_MIGRATION_MAX_ENTRIES, - 'maxEntries' - ), - maxDepth: resolveMigrationLimit( - requested.maxDepth, - SPEECH_MODEL_CACHE_MIGRATION_MAX_DEPTH, - 'maxDepth' - ), - maxVisitedPathBytes: resolveMigrationLimit( - requested.maxVisitedPathBytes, - SPEECH_MODEL_CACHE_MIGRATION_MAX_VISITED_PATH_BYTES, - 'maxVisitedPathBytes' - ) - } - } - - claim(sourcePath: string, targetPath: string, depth: number): void { - if (depth > this.limits.maxDepth) { - throw new Error(`Speech model cache migration exceeded depth ${this.limits.maxDepth}`) - } - this.entries += 1 - if (this.entries > this.limits.maxEntries) { - throw new Error(`Speech model cache migration exceeded ${this.limits.maxEntries} entries`) - } - this.visitedPathBytes += - Buffer.byteLength(sourcePath, 'utf8') + Buffer.byteLength(targetPath, 'utf8') - if (this.visitedPathBytes > this.limits.maxVisitedPathBytes) { - throw new Error( - `Speech model cache migration exceeded ${this.limits.maxVisitedPathBytes} visited path bytes` - ) - } - } -} - -function resolveMigrationLimit( - requested: number | undefined, - maximum: number, - name: string -): number { - if (requested === undefined) { - return maximum - } - if (!Number.isSafeInteger(requested) || requested < 0) { - throw new RangeError(`${name} must be a non-negative safe integer`) - } - return Math.min(requested, maximum) -} function hasNonAsciiCharacters(value: string): boolean { for (const character of value) { @@ -131,24 +65,12 @@ export function getSpeechModelCacheDirCandidates( return [...candidates, { modelsDir: requestedModelsDir, migrationSourceDir: null }] } -async function copyMissingCacheEntry( - sourcePath: string, - targetPath: string, - depth: number, - budget: SpeechModelCacheMigrationBudget -): Promise { - budget.claim(sourcePath, targetPath, depth) +async function copyMissingCacheEntry(sourcePath: string, targetPath: string): Promise { const sourceStat = await stat(sourcePath) if (sourceStat.isDirectory()) { await mkdir(targetPath, { recursive: true }) - const directory = await opendir(sourcePath, { bufferSize: 32 }) - for await (const entry of directory) { - await copyMissingCacheEntry( - join(sourcePath, entry.name), - join(targetPath, entry.name), - depth + 1, - budget - ) + for (const entry of await readdir(sourcePath, { withFileTypes: true })) { + await copyMissingCacheEntry(join(sourcePath, entry.name), join(targetPath, entry.name)) } return } @@ -166,23 +88,15 @@ async function copyMissingCacheEntry( export async function migrateSpeechModelCacheIfNeeded( sourceDir: string | null, - targetDir: string, - limits: Partial = {} + targetDir: string ): Promise { if (!sourceDir || resolve(sourceDir) === resolve(targetDir) || !existsSync(sourceDir)) { return } try { - const budget = new SpeechModelCacheMigrationBudget(limits) - const directory = await opendir(sourceDir, { bufferSize: 32 }) - for await (const entry of directory) { - await copyMissingCacheEntry( - join(sourceDir, entry.name), - join(targetDir, entry.name), - 1, - budget - ) + for (const entry of await readdir(sourceDir, { withFileTypes: true })) { + await copyMissingCacheEntry(join(sourceDir, entry.name), join(targetDir, entry.name)) } } catch (error) { console.warn('[speech] Failed to migrate speech model cache to ASCII path:', error) diff --git a/src/main/speech/model-manager.test.ts b/src/main/speech/model-manager.test.ts index 97a18e83fbb..07d78749b08 100644 --- a/src/main/speech/model-manager.test.ts +++ b/src/main/speech/model-manager.test.ts @@ -5,7 +5,6 @@ import { dirname, join } from 'node:path' import { beforeEach, describe, expect, it, vi } from 'vitest' import { SPEECH_MODEL_CATALOG } from './model-catalog' import { ModelManager } from './model-manager' -import { SPEECH_MODEL_EXTRACTION_STDERR_MAX_RETAINED_BYTES } from './speech-model-extraction-stderr' const { hasOpenAiSpeechApiKeyMock, netRequestMock, spawnMock } = vi.hoisted(() => ({ hasOpenAiSpeechApiKeyMock: vi.fn(), @@ -453,59 +452,4 @@ describe('ModelManager', () => { rmSync(dir, { recursive: true, force: true }) } }) - - it('surfaces bounded prefix and tail evidence when tar stderr is oversized', async () => { - const dir = mkdtempSync(join(tmpdir(), 'orca-model-manager-')) - try { - const handlers: Record void)[]> = { - close: [], - error: [] - } - const stderrHandlers: ((chunk: Buffer) => void)[] = [] - const child = { - stderr: { - on: vi.fn((_event: string, cb: (chunk: Buffer) => void) => { - stderrHandlers.push(cb) - return child.stderr - }), - off: vi.fn((_event: string, cb: (chunk: Buffer) => void) => { - const index = stderrHandlers.indexOf(cb) - if (index !== -1) { - stderrHandlers.splice(index, 1) - } - return child.stderr - }) - }, - kill: vi.fn(), - on: vi.fn((event: string, cb: (arg?: unknown) => void) => { - handlers[event]?.push(cb) - return child - }), - off: vi.fn((event: string, cb: (arg?: unknown) => void) => { - handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? [] - return child - }) - } - spawnMock.mockReturnValue(child) - const manager = new ModelManager(dir) as unknown as ModelManagerInternals - const extraction = manager.extractArchive(join(dir, 'model.tar.bz2'), dir, 'm', () => false) - stderrHandlers[0]( - Buffer.concat([ - Buffer.from('PREFIX'), - Buffer.alloc(SPEECH_MODEL_EXTRACTION_STDERR_MAX_RETAINED_BYTES * 2, 0x78), - Buffer.from('TAIL') - ]) - ) - handlers.close[0](2) - - await expect(extraction).rejects.toThrow( - /tar exited with code 2: PREFIX[\s\S]+stderr bytes omitted[\s\S]+TAIL$/ - ) - expect(stderrHandlers).toHaveLength(0) - expect(handlers.close).toHaveLength(0) - expect(handlers.error).toHaveLength(0) - } finally { - rmSync(dir, { recursive: true, force: true }) - } - }) }) diff --git a/src/main/speech/model-manager.ts b/src/main/speech/model-manager.ts index 396fb146570..2b8bca8bbca 100644 --- a/src/main/speech/model-manager.ts +++ b/src/main/speech/model-manager.ts @@ -9,7 +9,7 @@ import { rmSync, statSync } from 'node:fs' -import { rm } from 'node:fs/promises' +import { readdir, rm } from 'node:fs/promises' import { createHash } from 'node:crypto' import { pipeline } from 'node:stream/promises' import { spawn } from 'node:child_process' @@ -26,8 +26,6 @@ import { migrateSpeechModelCacheIfNeeded, type SpeechModelCacheDir } from './model-cache-path' -import { findNestedSpeechModelDirectory } from './speech-model-directory-scanner' -import { SpeechModelExtractionStderr } from './speech-model-extraction-stderr' type DownloadHandle = { abort: () => void @@ -837,7 +835,7 @@ export class ModelManager { } ) - const stderr = new SpeechModelExtractionStderr() + let stderr = '' let settled = false let timeout: ReturnType | null = null let abortPoll: ReturnType | null = null @@ -866,7 +864,7 @@ export class ModelManager { reject(error) } const onStderrData = (chunk: Buffer): void => { - stderr.append(chunk) + stderr += chunk.toString() } const onClose = (code: number | null): void => { if (settled) { @@ -877,7 +875,7 @@ export class ModelManager { if (code === 0) { resolve() } else { - reject(new Error(`tar exited with code ${code}: ${stderr.errorEvidence()}`)) + reject(new Error(`tar exited with code ${code}: ${stderr.slice(0, 500)}`)) } } const onError = (err: Error): void => { @@ -904,15 +902,22 @@ export class ModelManager { if (!manifest.files) { return } - const nested = await findNestedSpeechModelDirectory(modelDir, manifest.files) - if (!nested) { - return + const entries = await readdir(modelDir, { withFileTypes: true }) + for (const entry of entries) { + if (entry.isDirectory()) { + const nestedDir = join(modelDir, entry.name) + const nestedFiles = await readdir(nestedDir) + const hasExpected = manifest.files.some((f) => nestedFiles.includes(f)) + if (hasExpected) { + const { rename: fsRename } = await import('node:fs/promises') + for (const file of nestedFiles) { + await fsRename(join(nestedDir, file), join(modelDir, file)) + } + await rm(nestedDir, { recursive: true, force: true }) + return + } + } } - const { rename: fsRename } = await import('node:fs/promises') - for (const file of nested.entryNames) { - await fsRename(join(nested.directoryPath, file), join(modelDir, file)) - } - await rm(nested.directoryPath, { recursive: true, force: true }) } private cleanup(modelId: string, archivePath: string): void { diff --git a/src/main/speech/openai-api-key-store.test.ts b/src/main/speech/openai-api-key-store.test.ts index b783a976361..9475d5afa54 100644 --- a/src/main/speech/openai-api-key-store.test.ts +++ b/src/main/speech/openai-api-key-store.test.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdirSync, mkdtempSync, truncateSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import type * as Os from 'node:os' import { join } from 'node:path' @@ -85,13 +85,4 @@ describe('OpenAI speech API key store', () => { expect(existsSync(join(tempHome, '.orca'))).toBe(false) expect(safeStorageMock.decryptString).not.toHaveBeenCalled() }) - - it('rejects an oversized sparse key file before decrypting it', async () => { - writeStoredOpenAiKey('x') - truncateSync(join(tempHome, '.orca', 'openai-speech-token.enc'), 1024 * 1024 + 1) - const store = await loadStoreModule() - - expect(() => store.readOpenAiSpeechApiKey()).toThrow('could not be decrypted') - expect(safeStorageMock.decryptString).not.toHaveBeenCalled() - }) }) diff --git a/src/main/speech/openai-api-key-store.ts b/src/main/speech/openai-api-key-store.ts index b2a96cd0e95..dc7345f3037 100644 --- a/src/main/speech/openai-api-key-store.ts +++ b/src/main/speech/openai-api-key-store.ts @@ -1,8 +1,7 @@ import { safeStorage } from 'electron' -import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { homedir } from 'node:os' import { join } from 'node:path' -import { readIntegrationCredentialFileSync } from '../integration-credential-file' type StoredOpenAiKey = { encryptedKeyBase64: string @@ -32,9 +31,7 @@ function readLegacyJsonStoredOpenAiKey(): StoredOpenAiKey | null { return null } try { - const parsed = JSON.parse( - readIntegrationCredentialFileSync(keyPath).toString('utf8') - ) as Partial + const parsed = JSON.parse(readFileSync(keyPath, 'utf8')) as Partial if (typeof parsed.encryptedKeyBase64 !== 'string' || parsed.encryptedKeyBase64 === '') { return null } @@ -79,7 +76,7 @@ export function readOpenAiSpeechApiKey(): string { throw new Error('OpenAI API key is not configured') } try { - const raw = readIntegrationCredentialFileSync(keyPath) + const raw = readFileSync(keyPath) const legacyJson = readLegacyJsonStoredOpenAiKey() if (legacyJson) { cachedOpenAiSpeechApiKey = safeStorage.decryptString( diff --git a/src/main/speech/openai-transcription-client.test.ts b/src/main/speech/openai-transcription-client.test.ts index bbe4da6576b..8e20f593b08 100644 --- a/src/main/speech/openai-transcription-client.test.ts +++ b/src/main/speech/openai-transcription-client.test.ts @@ -1,8 +1,5 @@ import { describe, expect, it } from 'vitest' -import { - OpenAiTranscriptionSession, - sanitizeOpenAiTranscriptionErrorMessage -} from './openai-transcription-client' +import { sanitizeOpenAiTranscriptionErrorMessage } from './openai-transcription-client' describe('sanitizeOpenAiTranscriptionErrorMessage', () => { it('does not expose the invalid OpenAI API key echoed by the provider', () => { @@ -20,16 +17,4 @@ describe('sanitizeOpenAiTranscriptionErrorMessage', () => { ) ).toBe('Request failed for [redacted] with Authorization: Bearer [redacted]') }) - - it('retains one growable sample buffer for adversarial one-sample feeds', () => { - const session = new OpenAiTranscriptionSession('openai-gpt-4o-mini-transcribe', () => 'key') - const sample = new Float32Array([0.25]) - - for (let index = 0; index < 100_000; index += 1) { - session.feedAudio(sample, 16_000) - } - - expect(Reflect.get(session, 'sampleCount')).toBe(100_000) - expect(Reflect.get(session, 'samples')).toBeInstanceOf(Float32Array) - }) }) diff --git a/src/main/speech/openai-transcription-client.ts b/src/main/speech/openai-transcription-client.ts index f8b6e9b5336..c61dc940f10 100644 --- a/src/main/speech/openai-transcription-client.ts +++ b/src/main/speech/openai-transcription-client.ts @@ -1,5 +1,4 @@ import { resampleToRate } from './stt-audio-resample' -import { readFetchResponseJsonWithinLimit } from '../lib/fetch-response-body' export const OPENAI_TRANSCRIPTION_MODEL_BY_ID: Record = { 'openai-gpt-4o-mini-transcribe': 'gpt-4o-mini-transcribe', @@ -9,7 +8,6 @@ export const OPENAI_TRANSCRIPTION_MODEL_BY_ID: Record = { const OPENAI_TRANSCRIPTION_URL = 'https://api.openai.com/v1/audio/transcriptions' const CLOUD_TRANSCRIPTION_SAMPLE_RATE = 16000 const MAX_CLOUD_AUDIO_SECONDS = 10 * 60 -const MAX_CLOUD_AUDIO_SAMPLES = CLOUD_TRANSCRIPTION_SAMPLE_RATE * MAX_CLOUD_AUDIO_SECONDS type OpenAiTranscriptionResponse = { text?: unknown @@ -31,8 +29,8 @@ export function sanitizeOpenAiTranscriptionErrorMessage(message: string): string return sanitized || 'OpenAI transcription request failed' } -function encodePcm16Wav(samples: Float32Array, sampleCount: number, sampleRate: number): Buffer { - const dataBytes = sampleCount * 2 +function encodePcm16Wav(samples: Float32Array, sampleRate: number): Buffer { + const dataBytes = samples.length * 2 const buffer = Buffer.alloc(44 + dataBytes) buffer.write('RIFF', 0) @@ -49,7 +47,7 @@ function encodePcm16Wav(samples: Float32Array, sampleCount: number, sampleRate: buffer.write('data', 36) buffer.writeUInt32LE(dataBytes, 40) - for (let i = 0; i < sampleCount; i += 1) { + for (let i = 0; i < samples.length; i += 1) { const clamped = Math.max(-1, Math.min(1, samples[i])) const value = clamped < 0 ? clamped * 0x8000 : clamped * 0x7fff buffer.writeInt16LE(Math.round(value), 44 + i * 2) @@ -58,6 +56,17 @@ function encodePcm16Wav(samples: Float32Array, sampleCount: number, sampleRate: return buffer } +function combineChunks(chunks: Float32Array[]): Float32Array { + const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0) + const combined = new Float32Array(totalLength) + let offset = 0 + for (const chunk of chunks) { + combined.set(chunk, offset) + offset += chunk.length + } + return combined +} + function parseOpenAiTranscriptionResponse(data: OpenAiTranscriptionResponse): string { if (typeof data.text === 'string') { return data.text.trim() @@ -69,8 +78,8 @@ function parseOpenAiTranscriptionResponse(data: OpenAiTranscriptionResponse): st } export class OpenAiTranscriptionSession { - private samples = new Float32Array(0) - private sampleCount = 0 + private chunks: Float32Array[] = [] + private audioSeconds = 0 constructor( private readonly modelId: string, @@ -79,25 +88,15 @@ export class OpenAiTranscriptionSession { feedAudio(samples: Float32Array, sampleRate: number): void { const normalized = resampleToRate(samples, sampleRate, CLOUD_TRANSCRIPTION_SAMPLE_RATE) - const nextSampleCount = this.sampleCount + normalized.length - if (nextSampleCount > MAX_CLOUD_AUDIO_SAMPLES) { + this.audioSeconds += normalized.length / CLOUD_TRANSCRIPTION_SAMPLE_RATE + if (this.audioSeconds > MAX_CLOUD_AUDIO_SECONDS) { throw new Error('Cloud transcription is limited to 10 minutes per dictation') } - if (this.samples.length < nextSampleCount) { - const nextCapacity = Math.min( - MAX_CLOUD_AUDIO_SAMPLES, - Math.max(CLOUD_TRANSCRIPTION_SAMPLE_RATE, this.samples.length * 2, nextSampleCount) - ) - const next = new Float32Array(nextCapacity) - next.set(this.samples.subarray(0, this.sampleCount)) - this.samples = next - } - this.samples.set(normalized, this.sampleCount) - this.sampleCount = nextSampleCount + this.chunks.push(new Float32Array(normalized)) } async finish(): Promise { - if (this.sampleCount === 0) { + if (this.chunks.length === 0) { return '' } @@ -106,9 +105,9 @@ export class OpenAiTranscriptionSession { throw new Error(`Unknown OpenAI transcription model: ${this.modelId}`) } - const wav = encodePcm16Wav(this.samples, this.sampleCount, CLOUD_TRANSCRIPTION_SAMPLE_RATE) - this.samples = new Float32Array(0) - this.sampleCount = 0 + const audio = combineChunks(this.chunks) + this.chunks = [] + const wav = encodePcm16Wav(audio, CLOUD_TRANSCRIPTION_SAMPLE_RATE) const form = new FormData() form.append('model', apiModel) form.append('response_format', 'json') @@ -124,9 +123,7 @@ export class OpenAiTranscriptionSession { body: form }) - const data = await readFetchResponseJsonWithinLimit( - response - ).catch((): OpenAiTranscriptionResponse => ({})) + const data = (await response.json().catch(() => ({}))) as OpenAiTranscriptionResponse if (!response.ok) { const message = typeof data.error?.message === 'string' diff --git a/src/main/speech/speech-model-directory-scanner.test.ts b/src/main/speech/speech-model-directory-scanner.test.ts deleted file mode 100644 index 831b1e2dafb..00000000000 --- a/src/main/speech/speech-model-directory-scanner.test.ts +++ /dev/null @@ -1,172 +0,0 @@ -import type * as NodeFs from 'node:fs' -import type * as NodeFsPromises from 'node:fs/promises' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { opendirMock, opendirSyncMock } = vi.hoisted(() => ({ - opendirMock: vi.fn(), - opendirSyncMock: vi.fn() -})) - -vi.mock('node:fs', async (importOriginal) => ({ - ...(await importOriginal()), - opendirSync: opendirSyncMock -})) - -vi.mock('node:fs/promises', async (importOriginal) => ({ - ...(await importOriginal()), - opendir: opendirMock -})) - -import { - findNestedSpeechModelDirectory, - findSpeechModelBpeVocabFile, - SPEECH_MODEL_VOCAB_SCAN_MAX_ENTRIES, - SpeechModelDirectoryCapacityError -} from './speech-model-directory-scanner' -import { buildHotwordsConfig } from './stt-worker-model-config' - -type FakeEntry = { name: string; directory?: boolean } - -function fakeAsyncDirectory(entries: FakeEntry[], onClose?: () => void): object { - return { - async *[Symbol.asyncIterator]() { - try { - for (const entry of entries) { - yield { - name: entry.name, - isDirectory: () => entry.directory === true - } - } - } finally { - onClose?.() - } - } - } -} - -function useSyncEntries(entries: FakeEntry[]): ReturnType { - let index = 0 - const closeSync = vi.fn() - opendirSyncMock.mockReturnValue({ - closeSync, - readSync: vi.fn(() => { - const entry = entries[index] - index += 1 - return entry ? { name: entry.name } : null - }) - }) - return closeSync -} - -describe('speech model directory scanner', () => { - beforeEach(() => { - opendirMock.mockReset() - opendirSyncMock.mockReset() - }) - - it('preserves the first matching nested directory and its entry order', async () => { - opendirMock.mockImplementation(async (path: string) => { - if (path === '/models') { - return fakeAsyncDirectory([ - { name: 'unrelated', directory: true }, - { name: 'archive', directory: true } - ]) - } - if (path === '/models/unrelated') { - return fakeAsyncDirectory([{ name: 'README.md' }]) - } - return fakeAsyncDirectory([{ name: 'encoder.onnx' }, { name: 'tokens.txt' }]) - }) - - await expect(findNestedSpeechModelDirectory('/models', ['encoder.onnx'])).resolves.toEqual({ - directoryPath: '/models/archive', - entryNames: ['encoder.onnx', 'tokens.txt'] - }) - }) - - it('accepts the exact archive entry limit', async () => { - opendirMock.mockImplementation(async (path: string) => - path === '/models' - ? fakeAsyncDirectory([{ name: 'archive', directory: true }]) - : fakeAsyncDirectory([{ name: 'encoder.onnx' }, { name: 'tokens.txt' }]) - ) - - await expect( - findNestedSpeechModelDirectory('/models', ['encoder.onnx'], { - maxEntries: 3 - }) - ).resolves.toMatchObject({ entryNames: ['encoder.onnx', 'tokens.txt'] }) - }) - - it('closes both streams and rejects the first entry over the archive limit', async () => { - const closed: string[] = [] - opendirMock.mockImplementation(async (path: string) => - path === '/models' - ? fakeAsyncDirectory([{ name: 'archive', directory: true }], () => closed.push('root')) - : fakeAsyncDirectory([{ name: 'one' }, { name: 'two' }, { name: 'three' }], () => - closed.push('nested') - ) - ) - - await expect( - findNestedSpeechModelDirectory('/models', ['missing'], { - maxEntries: 3 - }) - ).rejects.toBeInstanceOf(SpeechModelDirectoryCapacityError) - expect(closed).toEqual(['nested', 'root']) - }) - - it('rejects a nested listing above its retained-name budget', async () => { - opendirMock.mockImplementation(async (path: string) => - path === '/models' - ? fakeAsyncDirectory([{ name: 'archive', directory: true }]) - : fakeAsyncDirectory([{ name: 'x' }]) - ) - - await expect( - findNestedSpeechModelDirectory('/models', ['x'], { - maxRetainedNameBytes: 65 - }) - ).rejects.toThrow('retained name bytes') - }) - - it('finds a vocab at the exact production scan boundary', () => { - const entries = Array.from({ length: SPEECH_MODEL_VOCAB_SCAN_MAX_ENTRIES - 1 }, (_, index) => ({ - name: `file-${index}` - })) - entries.push({ name: 'tokens.vocab' }) - const closeSync = useSyncEntries(entries) - - expect( - buildHotwordsConfig({ - modelDir: '/models', - modelType: 'transducer', - hotwordsFilePath: '/hotwords.txt', - modelingUnit: 'bpe' - }) - ).toMatchObject({ - decodingMethod: 'modified_beam_search', - bpeVocab: '/models/tokens.vocab' - }) - expect(closeSync).toHaveBeenCalledOnce() - }) - - it('ignores a vocab beyond the production scan boundary and closes the stream', () => { - const entries = Array.from({ length: SPEECH_MODEL_VOCAB_SCAN_MAX_ENTRIES }, (_, index) => ({ - name: `file-${index}` - })) - entries.push({ name: 'too-late.vocab' }) - const closeSync = useSyncEntries(entries) - - expect( - buildHotwordsConfig({ - modelDir: '/models', - modelType: 'transducer', - hotwordsFilePath: '/hotwords.txt', - modelingUnit: 'bpe' - }) - ).toEqual({ decodingMethod: 'greedy_search' }) - expect(findSpeechModelBpeVocabFile('/models', 0)).toBeUndefined() - expect(closeSync).toHaveBeenCalledTimes(2) - }) -}) diff --git a/src/main/speech/speech-model-directory-scanner.ts b/src/main/speech/speech-model-directory-scanner.ts deleted file mode 100644 index 5ca7ef01b14..00000000000 --- a/src/main/speech/speech-model-directory-scanner.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { opendirSync } from 'node:fs' -import { opendir } from 'node:fs/promises' -import { join } from 'node:path' - -export const SPEECH_MODEL_ARCHIVE_SCAN_MAX_ENTRIES = 32_768 -export const SPEECH_MODEL_ARCHIVE_MAX_RETAINED_NAME_BYTES = 4 * 1024 * 1024 -export const SPEECH_MODEL_VOCAB_SCAN_MAX_ENTRIES = 4_096 - -export type SpeechModelArchiveScanLimits = { - maxEntries: number - maxRetainedNameBytes: number -} - -export type NestedSpeechModelDirectory = { - directoryPath: string - entryNames: string[] -} - -export class SpeechModelDirectoryCapacityError extends Error { - constructor(resource: string, limit: number) { - super(`Speech model directory exceeded its ${resource} limit (${limit})`) - this.name = 'SpeechModelDirectoryCapacityError' - } -} - -function resolveLimit(requested: number | undefined, maximum: number, name: string): number { - if (requested === undefined) { - return maximum - } - if (!Number.isSafeInteger(requested) || requested < 0) { - throw new RangeError(`${name} must be a non-negative safe integer`) - } - return Math.min(requested, maximum) -} - -function estimateRetainedNameBytes(name: string): number { - return name.length * 2 + 64 -} - -export async function findNestedSpeechModelDirectory( - modelDir: string, - expectedFiles: readonly string[], - requestedLimits: Partial = {} -): Promise { - const maxEntries = resolveLimit( - requestedLimits.maxEntries, - SPEECH_MODEL_ARCHIVE_SCAN_MAX_ENTRIES, - 'maxEntries' - ) - const maxRetainedNameBytes = resolveLimit( - requestedLimits.maxRetainedNameBytes, - SPEECH_MODEL_ARCHIVE_MAX_RETAINED_NAME_BYTES, - 'maxRetainedNameBytes' - ) - const expectedFileNames = new Set(expectedFiles) - let scannedEntries = 0 - - const rootDirectory = await opendir(modelDir, { bufferSize: 32 }) - for await (const entry of rootDirectory) { - scannedEntries += 1 - if (scannedEntries > maxEntries) { - throw new SpeechModelDirectoryCapacityError('entry count', maxEntries) - } - if (!entry.isDirectory()) { - continue - } - - const nestedDirectoryPath = join(modelDir, entry.name) - const entryNames: string[] = [] - let retainedNameBytes = 0 - const nestedDirectory = await opendir(nestedDirectoryPath, { bufferSize: 32 }) - for await (const nestedEntry of nestedDirectory) { - scannedEntries += 1 - if (scannedEntries > maxEntries) { - throw new SpeechModelDirectoryCapacityError('entry count', maxEntries) - } - retainedNameBytes += estimateRetainedNameBytes(nestedEntry.name) - if (retainedNameBytes > maxRetainedNameBytes) { - throw new SpeechModelDirectoryCapacityError('retained name bytes', maxRetainedNameBytes) - } - entryNames.push(nestedEntry.name) - } - if (entryNames.some((name) => expectedFileNames.has(name))) { - return { directoryPath: nestedDirectoryPath, entryNames } - } - } - return null -} - -function closeSpeechModelDirectory(directory: ReturnType): void { - try { - directory.closeSync() - } catch { - // The OS may already have closed a fully consumed directory stream. - } -} - -export function findSpeechModelBpeVocabFile( - modelDir: string, - requestedMaxEntries = SPEECH_MODEL_VOCAB_SCAN_MAX_ENTRIES -): string | undefined { - const maxEntries = resolveLimit( - requestedMaxEntries, - SPEECH_MODEL_VOCAB_SCAN_MAX_ENTRIES, - 'maxEntries' - ) - let directory: ReturnType - try { - directory = opendirSync(modelDir, { bufferSize: 32 }) - } catch { - return undefined - } - - let scannedEntries = 0 - try { - while (scannedEntries < maxEntries) { - const entry = directory.readSync() - if (entry === null) { - return undefined - } - scannedEntries += 1 - if (entry.name.endsWith('.vocab')) { - return join(modelDir, entry.name) - } - } - return undefined - } finally { - closeSpeechModelDirectory(directory) - } -} diff --git a/src/main/speech/speech-model-extraction-stderr.test.ts b/src/main/speech/speech-model-extraction-stderr.test.ts deleted file mode 100644 index 9a851ccc517..00000000000 --- a/src/main/speech/speech-model-extraction-stderr.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - SPEECH_MODEL_EXTRACTION_STDERR_MAX_RETAINED_BYTES, - SpeechModelExtractionStderr -} from './speech-model-extraction-stderr' - -describe('speech model extraction stderr', () => { - it('preserves ordinary error evidence exactly', () => { - const stderr = new SpeechModelExtractionStderr() - stderr.append(Buffer.from('tar: archive is corrupt\n')) - stderr.append(Buffer.from('tar: exiting with failure')) - - expect(stderr.errorEvidence()).toBe('tar: archive is corrupt\ntar: exiting with failure') - expect(stderr.retainedByteLength()).toBe(49) - expect(stderr.wasTruncated()).toBe(false) - }) - - it('retains the exact byte limit without truncation', () => { - const stderr = new SpeechModelExtractionStderr() - stderr.append(Buffer.alloc(SPEECH_MODEL_EXTRACTION_STDERR_MAX_RETAINED_BYTES, 0x61)) - - expect(stderr.retainedByteLength()).toBe(SPEECH_MODEL_EXTRACTION_STDERR_MAX_RETAINED_BYTES) - expect(stderr.wasTruncated()).toBe(false) - expect(stderr.errorEvidence()).toBe('a'.repeat(500)) - }) - - it('caps one byte over the limit while preserving prefix and tail evidence', () => { - const stderr = new SpeechModelExtractionStderr() - stderr.append(Buffer.alloc(SPEECH_MODEL_EXTRACTION_STDERR_MAX_RETAINED_BYTES, 0x61)) - stderr.append(Buffer.from('Z')) - - expect(stderr.retainedByteLength()).toBe(SPEECH_MODEL_EXTRACTION_STDERR_MAX_RETAINED_BYTES) - expect(stderr.wasTruncated()).toBe(true) - expect(stderr.errorEvidence()).toMatch( - /^a{500}\n\[\.\.\. 1 stderr bytes omitted \.\.\.\]\na{499}Z$/ - ) - }) - - it('bounds a single oversized chunk without converting it to an oversized string', () => { - const stderr = new SpeechModelExtractionStderr() - const chunk = Buffer.concat([ - Buffer.from('PREFIX'), - Buffer.alloc(SPEECH_MODEL_EXTRACTION_STDERR_MAX_RETAINED_BYTES * 2, 0x78), - Buffer.from('TAIL') - ]) - - stderr.append(chunk) - - expect(stderr.retainedByteLength()).toBe(SPEECH_MODEL_EXTRACTION_STDERR_MAX_RETAINED_BYTES) - expect(stderr.errorEvidence()).toContain('PREFIX') - expect(stderr.errorEvidence()).toMatch(/TAIL$/) - }) -}) diff --git a/src/main/speech/speech-model-extraction-stderr.ts b/src/main/speech/speech-model-extraction-stderr.ts deleted file mode 100644 index 146d97dda37..00000000000 --- a/src/main/speech/speech-model-extraction-stderr.ts +++ /dev/null @@ -1,74 +0,0 @@ -export const SPEECH_MODEL_EXTRACTION_STDERR_MAX_RETAINED_BYTES = 64 * 1024 - -const STDERR_PREFIX_BYTES = 4 * 1024 -const STDERR_TAIL_BYTES = SPEECH_MODEL_EXTRACTION_STDERR_MAX_RETAINED_BYTES - STDERR_PREFIX_BYTES -const STDERR_EVIDENCE_CHARS_PER_END = 500 - -export class SpeechModelExtractionStderr { - private readonly prefix = Buffer.alloc(STDERR_PREFIX_BYTES) - private readonly tail = Buffer.alloc(STDERR_TAIL_BYTES) - private prefixLength = 0 - private tailLength = 0 - private tailWriteOffset = 0 - private observedBytes = 0 - - append(chunk: Buffer): void { - if (chunk.length === 0) { - return - } - if (this.prefixLength < this.prefix.length) { - const prefixBytes = Math.min(chunk.length, this.prefix.length - this.prefixLength) - chunk.copy(this.prefix, this.prefixLength, 0, prefixBytes) - this.prefixLength += prefixBytes - } - this.appendTail(chunk) - this.observedBytes = Math.min(Number.MAX_SAFE_INTEGER, this.observedBytes + chunk.length) - } - - retainedByteLength(): number { - return Math.min(this.observedBytes, SPEECH_MODEL_EXTRACTION_STDERR_MAX_RETAINED_BYTES) - } - - wasTruncated(): boolean { - return this.observedBytes > SPEECH_MODEL_EXTRACTION_STDERR_MAX_RETAINED_BYTES - } - - errorEvidence(): string { - const prefix = this.prefix - .subarray(0, this.prefixLength) - .toString('utf8') - .slice(0, STDERR_EVIDENCE_CHARS_PER_END) - if (!this.wasTruncated()) { - return prefix - } - const omittedBytes = this.observedBytes - this.retainedByteLength() - const tail = this.orderedTail().toString('utf8').slice(-STDERR_EVIDENCE_CHARS_PER_END) - return `${prefix}\n[... ${omittedBytes} stderr bytes omitted ...]\n${tail}` - } - - private appendTail(chunk: Buffer): void { - if (chunk.length >= this.tail.length) { - chunk.copy(this.tail, 0, chunk.length - this.tail.length) - this.tailLength = this.tail.length - this.tailWriteOffset = 0 - return - } - const firstBytes = Math.min(chunk.length, this.tail.length - this.tailWriteOffset) - chunk.copy(this.tail, this.tailWriteOffset, 0, firstBytes) - if (firstBytes < chunk.length) { - chunk.copy(this.tail, 0, firstBytes) - } - this.tailWriteOffset = (this.tailWriteOffset + chunk.length) % this.tail.length - this.tailLength = Math.min(this.tail.length, this.tailLength + chunk.length) - } - - private orderedTail(): Buffer { - if (this.tailLength < this.tail.length || this.tailWriteOffset === 0) { - return this.tail.subarray(0, this.tailLength) - } - return Buffer.concat([ - this.tail.subarray(this.tailWriteOffset), - this.tail.subarray(0, this.tailWriteOffset) - ]) - } -} diff --git a/src/main/speech/stt-offline-audio-chunker.test.ts b/src/main/speech/stt-offline-audio-chunker.test.ts index 21a552a75c2..4b90976b2c2 100644 --- a/src/main/speech/stt-offline-audio-chunker.test.ts +++ b/src/main/speech/stt-offline-audio-chunker.test.ts @@ -97,17 +97,4 @@ describe('OfflineAudioChunker', () => { expect(chunker.push(new Float32Array(0))).toEqual([]) expect(chunker.flush()).toBeNull() }) - - it('preserves 100,000 one-sample fragments without retaining one object per push', () => { - const chunker = new OfflineAudioChunker(10_000) - const expected = new Float32Array(100_000) - - for (let index = 0; index < expected.length; index += 1) { - const value = (index % 251) / 251 - expected[index] = value - expect(chunker.push(Float32Array.of(value))).toEqual([]) - } - - expect(chunker.flush()).toEqual(expected) - }) }) diff --git a/src/main/speech/stt-offline-audio-chunker.ts b/src/main/speech/stt-offline-audio-chunker.ts index 15df3b79f21..291454bf083 100644 --- a/src/main/speech/stt-offline-audio-chunker.ts +++ b/src/main/speech/stt-offline-audio-chunker.ts @@ -13,7 +13,7 @@ const SPLIT_SEARCH_SECONDS = 5 const SPLIT_ENERGY_WINDOW_SECONDS = 0.1 export class OfflineAudioChunker { - private buffered = new Float32Array(0) + private buffered: Float32Array[] = [] private bufferedSamples = 0 private readonly chunkSampleLimit: number private readonly splitSearchSamples: number @@ -30,16 +30,18 @@ export class OfflineAudioChunker { if (samples.length === 0) { return [] } - this.append(samples) + this.buffered.push(samples) + this.bufferedSamples += samples.length const ready: Float32Array[] = [] while (this.bufferedSamples >= this.chunkSampleLimit) { - const splitIndex = this.findQuietSplitIndex(this.buffered.subarray(0, this.bufferedSamples)) - ready.push(this.buffered.slice(0, splitIndex)) - this.buffered.copyWithin(0, splitIndex, this.bufferedSamples) - this.bufferedSamples -= splitIndex + const combined = this.combineBuffered() + const splitIndex = this.findQuietSplitIndex(combined) + ready.push(combined.slice(0, splitIndex)) + const tail = combined.slice(splitIndex) + this.buffered = tail.length > 0 ? [tail] : [] + this.bufferedSamples = tail.length } - this.releaseOversizedCapacity() return ready } @@ -48,32 +50,23 @@ export class OfflineAudioChunker { if (this.bufferedSamples === 0) { return null } - const combined = this.buffered.slice(0, this.bufferedSamples) - this.buffered = new Float32Array(0) + const combined = this.combineBuffered() + this.buffered = [] this.bufferedSamples = 0 return combined } - private append(samples: Float32Array): void { - const required = this.bufferedSamples + samples.length - if (required > this.buffered.length) { - const capacity = Math.max(required, Math.max(1_024, this.buffered.length * 2)) - const next = new Float32Array(capacity) - next.set(this.buffered.subarray(0, this.bufferedSamples)) - this.buffered = next + private combineBuffered(): Float32Array { + if (this.buffered.length === 1) { + return this.buffered[0] } - this.buffered.set(samples, this.bufferedSamples) - this.bufferedSamples = required - } - - private releaseOversizedCapacity(): void { - const retainedCapacity = Math.max(this.chunkSampleLimit, this.bufferedSamples) - if (this.buffered.length <= retainedCapacity * 2) { - return + const combined = new Float32Array(this.bufferedSamples) + let offset = 0 + for (const chunk of this.buffered) { + combined.set(chunk, offset) + offset += chunk.length } - const compacted = new Float32Array(retainedCapacity) - compacted.set(this.buffered.subarray(0, this.bufferedSamples)) - this.buffered = compacted + return combined } private findQuietSplitIndex(samples: Float32Array): number { diff --git a/src/main/speech/stt-worker-model-config.ts b/src/main/speech/stt-worker-model-config.ts index c9f269c8908..de4687d9f46 100644 --- a/src/main/speech/stt-worker-model-config.ts +++ b/src/main/speech/stt-worker-model-config.ts @@ -1,5 +1,5 @@ +import { readdirSync } from 'node:fs' import { join } from 'node:path' -import { findSpeechModelBpeVocabFile } from './speech-model-directory-scanner' // Why: different models name their ONNX files differently (e.g. // encoder.int8.onnx vs tiny-encoder.onnx vs encoder-epoch-99-avg-1.onnx). @@ -29,6 +29,16 @@ export function resolveTokens(files: string[], modelDir: string): string { // Why: BPE models need a vocab file for hotwords token matching. The file // ships in the model archive but isn't listed in the manifest. We discover // it at runtime to avoid breaking existing downloads. +function discoverBpeVocab(modelDir: string): string | undefined { + try { + const entries = readdirSync(modelDir) + const vocabFile = entries.find((f) => f.endsWith('.vocab')) + return vocabFile ? join(modelDir, vocabFile) : undefined + } catch { + return undefined + } +} + export type HotwordsConfig = { decodingMethod: string hotwordsFile?: string @@ -49,7 +59,7 @@ export function buildHotwordsConfig(opts: { const unit = opts.modelingUnit if (unit?.includes('bpe')) { - const bpeVocab = findSpeechModelBpeVocabFile(opts.modelDir) + const bpeVocab = discoverBpeVocab(opts.modelDir) if (!bpeVocab) { return { decodingMethod: 'greedy_search' } } diff --git a/src/main/ssh/relay-protocol.test.ts b/src/main/ssh/relay-protocol.test.ts index 3e2ada59323..459eaef88df 100644 --- a/src/main/ssh/relay-protocol.test.ts +++ b/src/main/ssh/relay-protocol.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { HEADER_LENGTH, - MAX_BUFFERED_FRAME_CHUNKS, MessageType, encodeFrame, encodeJsonRpcFrame, @@ -13,7 +12,6 @@ import { type JsonRpcRequest, type DecodedFrame } from './relay-protocol' -import { RELAY_JSON_MAX_STRUCTURAL_TOKENS } from '../../shared/relay-json-admission' describe('git response stream marker', () => { it('accepts only complete non-negative integer metadata', () => { @@ -28,15 +26,6 @@ describe('git response stream marker', () => { __orcaGitResponseStream: { streamId: -1, totalBytes: 1024, chunkCount: 2 } }) ).toBe(false) - expect( - isGitResponseStreamMarker({ - __orcaGitResponseStream: { - streamId: 1, - totalBytes: Number.MAX_SAFE_INTEGER + 1, - chunkCount: 2 - } - }) - ).toBe(false) }) }) @@ -90,37 +79,11 @@ describe('frame encoding', () => { method: 'x', params: { data: 'a'.repeat(17 * 1024 * 1024) } } - const fromSpy = vi.spyOn(Buffer, 'from') - try { - expect(() => encodeJsonRpcFrame(bigPayload, 1, 0)).toThrow('Message too large') - expect(fromSpy).not.toHaveBeenCalled() - } finally { - fromSpy.mockRestore() - } + expect(() => encodeJsonRpcFrame(bigPayload, 1, 0)).toThrow('Message too large') }) }) describe('FrameDecoder', () => { - it('bounds retained chunk wrappers for a byte-fragmented frame', () => { - const frames: DecodedFrame[] = [] - const decoder = new FrameDecoder((frame) => frames.push(frame)) - const payloadLength = MAX_BUFFERED_FRAME_CHUNKS + 512 - const header = Buffer.alloc(HEADER_LENGTH) - header[0] = MessageType.Regular - header.writeUInt32BE(payloadLength, 9) - decoder.feed(header) - - const byte = Buffer.from('x') - const state = decoder as unknown as { chunks: Buffer[] } - for (let index = 0; index < payloadLength; index += 1) { - decoder.feed(byte) - expect(state.chunks.length).toBeLessThanOrEqual(MAX_BUFFERED_FRAME_CHUNKS) - } - - expect(frames).toHaveLength(1) - expect(frames[0].payload.equals(Buffer.alloc(payloadLength, 0x78))).toBe(true) - }) - it('decodes a complete frame', () => { const frames: DecodedFrame[] = [] const decoder = new FrameDecoder((f) => frames.push(f)) @@ -200,33 +163,6 @@ describe('FrameDecoder', () => { expect(errors[0].message).toContain('discarded') }) - it('rejects an oversized header immediately without retaining its payload', () => { - const errors: Error[] = [] - const decoder = new FrameDecoder( - () => {}, - (err) => errors.push(err) - ) - const header = Buffer.alloc(HEADER_LENGTH) - header[0] = MessageType.Regular - header.writeUInt32BE(0xffffffff, 9) - - decoder.feed(header) - const state = decoder as unknown as { - bufferedLength: number - oversizedPayloadBytesRemaining: number - } - expect(errors).toHaveLength(1) - expect(state.bufferedLength).toBe(0) - expect(state.oversizedPayloadBytesRemaining).toBe(0xffffffff) - - const payloadChunk = Buffer.alloc(64 * 1024) - for (let index = 0; index < 128; index += 1) { - decoder.feed(payloadChunk) - expect(state.bufferedLength).toBe(0) - } - expect(errors).toHaveLength(1) - }) - it('reset clears internal buffer', () => { const frames: DecodedFrame[] = [] const decoder = new FrameDecoder((f) => frames.push(f)) @@ -288,31 +224,26 @@ describe('FrameDecoder', () => { expect(frames[0].payload.toString()).toBe('after') }) - it('retains ordinary transport chunks without copying them while a frame is incomplete', () => { + it('never rebuilds the buffered stream per feed while assembling a large frame', () => { + // Regression: feed() used Buffer.concat([buffered, chunk]) per data event, + // re-copying the whole backlog for every TCP chunk — O(n²) memcpy on the + // Electron main thread while fs.streamChunk frames arrive (SSH typing lag). const frames: DecodedFrame[] = [] const decoder = new FrameDecoder((f) => frames.push(f)) - const payloadLength = 1024 * 1024 - const header = Buffer.alloc(HEADER_LENGTH) - header[0] = MessageType.Regular - header.writeUInt32BE(1, 1) - header.writeUInt32BE(payloadLength, 9) - decoder.feed(header) - const ordinaryChunks = Array.from({ length: 16 }, () => Buffer.alloc(32 * 1024, 0x61)) + const frame = encodeFrame(MessageType.Regular, 1, 0, Buffer.alloc(512 * 1024, 0x61)) - for (const chunk of ordinaryChunks) { - decoder.feed(chunk) + const concatSpy = vi.spyOn(Buffer, 'concat') + try { + for (let i = 0; i < frame.length; i += 32 * 1024) { + decoder.feed(frame.subarray(i, i + 32 * 1024)) + } + } finally { + concatSpy.mockRestore() } - const state = decoder as unknown as { chunks: Buffer[] } - expect(state.chunks).toHaveLength(ordinaryChunks.length + 1) - for (const [index, chunk] of ordinaryChunks.entries()) { - expect(state.chunks[index + 1]).toBe(chunk) - } - - decoder.feed(Buffer.alloc(payloadLength / 2, 0x61)) - expect(frames).toHaveLength(1) - expect(frames[0].payload.length).toBe(payloadLength) + expect(frames[0].payload.length).toBe(512 * 1024) + expect(concatSpy).not.toHaveBeenCalled() }) }) @@ -339,21 +270,6 @@ describe('parseJsonRpcMessage', () => { const payload = Buffer.from('not json') expect(() => parseJsonRpcMessage(payload)).toThrow() }) - - it('rejects structurally amplified JSON before parsing', () => { - const payload = Buffer.from( - `{"jsonrpc":"2.0","id":1,"method":"x","params":{"values":[${'0,'.repeat( - RELAY_JSON_MAX_STRUCTURAL_TOKENS - )}0]}}` - ) - const parseSpy = vi.spyOn(JSON, 'parse') - try { - expect(() => parseJsonRpcMessage(payload)).toThrow(/JSON structure exceeds/) - expect(parseSpy).not.toHaveBeenCalled() - } finally { - parseSpy.mockRestore() - } - }) }) describe('parseUnameToRelayPlatform', () => { diff --git a/src/main/ssh/relay-protocol.ts b/src/main/ssh/relay-protocol.ts index 6dffbb1112f..c05b6c33bfe 100644 --- a/src/main/ssh/relay-protocol.ts +++ b/src/main/ssh/relay-protocol.ts @@ -3,11 +3,6 @@ // See design-ssh-support.md § JSON-RPC Protocol Specification. import { DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS } from '../../shared/ssh-types' -import { - JsonStringifyByteLimitError, - stringifyJsonWithinByteLimit -} from '../../shared/node-bounded-json-stringify' -import { parseRelayJsonText } from '../../shared/relay-json-admission' export const RELAY_VERSION = '0.1.0' export const RELAY_SENTINEL = `ORCA-RELAY v${RELAY_VERSION} READY\n` @@ -18,8 +13,6 @@ export const RELAY_REMOTE_DIR = '.orca-remote' export const HEADER_LENGTH = 13 export const MAX_MESSAGE_SIZE = 16 * 1024 * 1024 // 16 MB -export const MAX_BUFFERED_FRAME_CHUNKS = 4_096 -const MAX_COALESCED_TINY_FRAME_CHUNK_BYTES = 8 * 1024 /** Message type byte. */ export const MessageType = { @@ -43,8 +36,7 @@ export const RelayErrorCode = { PtyAllocationFailed: -33004, DiskFull: -33005, TooManyStreams: -33006, - StreamProtocolError: -33007, - MarkdownDocumentListingCapacity: -33008 + StreamProtocolError: -33007 } as const export const JsonRpcErrorCode = { @@ -76,11 +68,6 @@ export const GIT_RESPONSE_STREAM_THRESHOLD = 256 * 1024 * so it stays cross-version safe. */ export const GIT_RESPONSE_CHUNK_SIZE = 128 * 1024 -/** Bounds retained serialized Git results while preserving responses far above - * the per-file render limit and git.exec's output ceiling. */ -export const MAX_GIT_RESPONSE_STREAM_BYTES = 64 * 1024 * 1024 -export const MAX_GIT_RESPONSE_STREAM_CHUNKS = 4_096 - /** Sentinel the relay returns as the RPC result when the real payload streams * as git.responseChunk frames. Absent from old relays, so a new client falls * back to the plain result they return. */ @@ -98,11 +85,11 @@ export function isGitResponseStreamMarker(value: unknown): value is GitResponseS } const fields = marker as Record return ( - Number.isSafeInteger(fields.streamId) && + Number.isInteger(fields.streamId) && (fields.streamId as number) > 0 && - Number.isSafeInteger(fields.totalBytes) && + Number.isInteger(fields.totalBytes) && (fields.totalBytes as number) >= 0 && - Number.isSafeInteger(fields.chunkCount) && + Number.isInteger(fields.chunkCount) && (fields.chunkCount as number) >= 0 ) } @@ -157,16 +144,10 @@ export function encodeFrame( } export function encodeJsonRpcFrame(msg: JsonRpcMessage, id: number, ack: number): Buffer { - let serialized: string - try { - serialized = stringifyJsonWithinByteLimit(msg, MAX_MESSAGE_SIZE).serialized - } catch (error) { - if (error instanceof JsonStringifyByteLimitError) { - throw new Error(`Message too large: more than ${MAX_MESSAGE_SIZE} bytes`) - } - throw error + const payload = Buffer.from(JSON.stringify(msg), 'utf-8') + if (payload.length > MAX_MESSAGE_SIZE) { + throw new Error(`Message too large: ${payload.length} bytes (max ${MAX_MESSAGE_SIZE})`) } - const payload = Buffer.from(serialized, 'utf-8') return encodeFrame(MessageType.Regular, id, ack, payload) } @@ -192,7 +173,6 @@ export class FrameDecoder { // list assembles each frame exactly once instead. private chunks: Buffer[] = [] private bufferedLength = 0 - private oversizedPayloadBytesRemaining = 0 private onFrame: (frame: DecodedFrame) => void private onError: ((err: Error) => void) | null @@ -202,18 +182,12 @@ export class FrameDecoder { } feed(chunk: Buffer | Uint8Array): void { - let buf = Buffer.isBuffer(chunk) + const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength) - if (this.oversizedPayloadBytesRemaining > 0 && buf.length > 0) { - const discarded = Math.min(this.oversizedPayloadBytesRemaining, buf.length) - this.oversizedPayloadBytesRemaining -= discarded - buf = buf.subarray(discarded) - } if (buf.length > 0) { this.chunks.push(buf) this.bufferedLength += buf.length - this.coalesceBufferedChunks() } while (this.bufferedLength >= HEADER_LENGTH) { @@ -221,26 +195,25 @@ export class FrameDecoder { const length = header.readUInt32BE(9) const totalLength = HEADER_LENGTH + length + if (this.bufferedLength < totalLength) { + // Not fully received yet (also holds oversized frames until they can + // be skipped whole, keeping the decoder synchronized). + break + } + + // Why: throwing here would leave the buffer in a partially consumed + // state — subsequent feed() calls would try to parse leftover payload + // bytes as a new header, corrupting every future frame. Instead we + // skip the entire oversized frame so the decoder stays synchronized. if (length > MAX_MESSAGE_SIZE) { - // Why: retain only the header, then stream-discard the advertised - // payload so a 13-byte malicious header cannot pin up to 4 GiB. - this.discardBytes(HEADER_LENGTH) - this.oversizedPayloadBytesRemaining = length - this.discardBufferedOversizedPayload() + this.discardBytes(totalLength) const err = new Error(`Frame payload too large: ${length} bytes — discarded`) if (this.onError) { this.onError(err) } - if (this.oversizedPayloadBytesRemaining > 0) { - break - } continue } - if (this.bufferedLength < totalLength) { - break - } - const framed = this.takeBytes(totalLength) const frame: DecodedFrame = { type: framed[0], @@ -255,7 +228,6 @@ export class FrameDecoder { reset(): void { this.chunks = [] this.bufferedLength = 0 - this.oversizedPayloadBytesRemaining = 0 } /** View of the first `count` buffered bytes without consuming them. */ @@ -320,40 +292,14 @@ export class FrameDecoder { } this.bufferedLength -= count } - - private discardBufferedOversizedPayload(): void { - const discarded = Math.min(this.oversizedPayloadBytesRemaining, this.bufferedLength) - if (discarded === 0) { - return - } - this.discardBytes(discarded) - this.oversizedPayloadBytesRemaining -= discarded - } - - private coalesceBufferedChunks(): void { - // Why: merge adversarial tiny fragments without copying ordinary transport chunks. - while (this.chunks.length >= 2) { - const right = this.chunks.at(-1)! - const left = this.chunks.at(-2)! - if ( - left.length > right.length || - left.length + right.length > MAX_COALESCED_TINY_FRAME_CHUNK_BYTES - ) { - break - } - this.chunks.splice(-2, 2, Buffer.concat([left, right], left.length + right.length)) - } - if (this.chunks.length > MAX_BUFFERED_FRAME_CHUNKS) { - this.chunks = [Buffer.concat(this.chunks, this.bufferedLength)] - } - } } /** * Parse a JSON-RPC message from a frame payload. */ export function parseJsonRpcMessage(payload: Buffer): JsonRpcMessage { - const msg = parseRelayJsonText(payload.toString('utf-8')) + const text = payload.toString('utf-8') + const msg = JSON.parse(text) as JsonRpcMessage if (msg.jsonrpc !== '2.0') { throw new Error(`Invalid JSON-RPC version: ${(msg as Record).jsonrpc}`) } diff --git a/src/main/ssh/sftp-directory-removal.ts b/src/main/ssh/sftp-directory-removal.ts deleted file mode 100644 index 546915e42bd..00000000000 --- a/src/main/ssh/sftp-directory-removal.ts +++ /dev/null @@ -1,116 +0,0 @@ -import type { SFTPWrapper } from 'ssh2' -import { SshDirectoryTransferBudget } from './ssh-directory-transfer-budget' - -type SftpDirectoryEntry = { - filename: string - attrs?: { - isDirectory?: () => boolean - } -} - -export async function removeDirectorySftp(sftp: SFTPWrapper, remoteDir: string): Promise { - const budget = new SshDirectoryTransferBudget() - budget.recordPath(remoteDir, 0, { countEntry: false }) - await removeDirectorySftpWithinBudget(sftp, remoteDir, budget, 0) -} - -async function removeDirectorySftpWithinBudget( - sftp: SFTPWrapper, - remoteDir: string, - budget: SshDirectoryTransferBudget, - depth: number -): Promise { - const handle = await opendirSftp(sftp, remoteDir) - const normalizedRemoteDir = remoteDir.replace(/\/+$/, '') - let completed = false - try { - for (;;) { - const entries = await readdirSftp(sftp, handle) - if (!entries) { - break - } - for (const entry of entries) { - if (entry.filename === '.' || entry.filename === '..') { - continue - } - const childPath = `${normalizedRemoteDir}/${entry.filename}` - budget.recordPath(childPath, depth + 1) - await (entry.attrs?.isDirectory?.() - ? removeDirectorySftpWithinBudget(sftp, childPath, budget, depth + 1) - : unlinkSftp(sftp, childPath)) - } - } - completed = true - } finally { - await closeSftpHandle(sftp, handle).catch((error: unknown) => { - if (completed) { - throw error - } - }) - } - await rmdirSftp(sftp, remoteDir) -} - -function opendirSftp(sftp: SFTPWrapper, remoteDir: string): Promise { - return new Promise((resolve, reject) => { - sftp.opendir(remoteDir, (err, handle) => { - if (err) { - reject(err) - return - } - resolve(handle) - }) - }) -} - -function readdirSftp(sftp: SFTPWrapper, handle: Buffer): Promise { - return new Promise((resolve, reject) => { - sftp.readdir(handle, (err, entries) => { - if ((err as { code?: number } | undefined)?.code === 1) { - resolve(null) - return - } - if (err) { - reject(err) - return - } - resolve((entries ?? []) as SftpDirectoryEntry[]) - }) - }) -} - -function closeSftpHandle(sftp: SFTPWrapper, handle: Buffer): Promise { - return new Promise((resolve, reject) => { - sftp.close(handle, (err) => { - if (err) { - reject(err) - return - } - resolve() - }) - }) -} - -function unlinkSftp(sftp: SFTPWrapper, remotePath: string): Promise { - return new Promise((resolve, reject) => { - sftp.unlink(remotePath, (err) => { - if (err) { - reject(err) - return - } - resolve() - }) - }) -} - -function rmdirSftp(sftp: SFTPWrapper, remoteDir: string): Promise { - return new Promise((resolve, reject) => { - sftp.rmdir(remoteDir, (err) => { - if (err) { - reject(err) - return - } - resolve() - }) - }) -} diff --git a/src/main/ssh/sftp-upload.test.ts b/src/main/ssh/sftp-upload.test.ts index 199ee585adf..c87c2a1b7f5 100644 --- a/src/main/ssh/sftp-upload.test.ts +++ b/src/main/ssh/sftp-upload.test.ts @@ -5,7 +5,6 @@ import { Writable } from 'node:stream' import { describe, expect, it, vi } from 'vitest' import type { SFTPWrapper } from 'ssh2' import { removeDirectorySftp, uploadBuffer, uploadDirectory, uploadFile } from './sftp-upload' -import { SSH_DIRECTORY_TRANSFER_LIMITS } from './ssh-directory-transfer-budget' function createWritable(): Writable { return new Writable({ @@ -19,13 +18,9 @@ function createSftpMock(): SFTPWrapper { return { mkdir: vi.fn((_path: string, cb: (err?: Error | null) => void) => cb(null)), createWriteStream: vi.fn(() => createWritable()), - opendir: vi.fn((path: string, cb: (err: Error | undefined, handle: Buffer) => void) => - cb(undefined, Buffer.from(path)) + readdir: vi.fn((_path: string, cb: (err?: Error | null, entries?: unknown[]) => void) => + cb(null, []) ), - readdir: vi.fn((_handle: Buffer, cb: (err?: Error | null, entries?: unknown[]) => void) => - cb(Object.assign(new Error('EOF'), { code: 1 }), []) - ), - close: vi.fn((_handle: Buffer, cb: (err?: Error | null) => void) => cb(null)), unlink: vi.fn((_path: string, cb: (err?: Error | null) => void) => cb(null)), rmdir: vi.fn((_path: string, cb: (err?: Error | null) => void) => cb(null)) } as unknown as SFTPWrapper @@ -99,23 +94,6 @@ describe('sftp-upload', () => { expect(sftp.createWriteStream).not.toHaveBeenCalled() }) - it('rejects excessive depth before creating remote entries', async () => { - const localDir = await mkdtemp(join(tmpdir(), 'orca-sftp-upload-')) - let nested = localDir - for (let depth = 0; depth <= SSH_DIRECTORY_TRANSFER_LIMITS.maximumDepth; depth += 1) { - nested = join(nested, 'd') - await mkdir(nested) - } - const sftp = createSftpMock() - - await expect( - uploadDirectory(sftp, localDir, '/remote/assets', await realpath(localDir)) - ).rejects.toMatchObject({ reason: 'depth' }) - - expect(sftp.mkdir).not.toHaveBeenCalled() - expect(sftp.createWriteStream).not.toHaveBeenCalled() - }) - it('does not create the remote file when the local source is a symlink', async () => { const localDir = await mkdtemp(join(tmpdir(), 'orca-sftp-upload-')) const targetPath = join(localDir, process.platform === 'win32' ? 'target-dir' : 'target.txt') @@ -138,14 +116,8 @@ describe('sftp-upload', () => { it('removes remote directory contents before removing the directory', async () => { const sftp = createSftpMock() - const readPaths = new Set() - vi.mocked(sftp.readdir).mockImplementation((handle, cb) => { - const pathString = String(handle) - if (readPaths.has(pathString)) { - cb(Object.assign(new Error('EOF'), { code: 1 }), [] as never) - return - } - readPaths.add(pathString) + vi.mocked(sftp.readdir).mockImplementation((remotePath, cb) => { + const pathString = String(remotePath) if (pathString === '/remote/assets') { cb(undefined, [ { filename: '.', attrs: { isDirectory: () => true } }, @@ -172,6 +144,5 @@ describe('sftp-upload', () => { expect(sftp.rmdir).toHaveBeenNthCalledWith(1, '/remote/assets/nested', expect.any(Function)) expect(sftp.unlink).toHaveBeenNthCalledWith(2, '/remote/assets/logo.png', expect.any(Function)) expect(sftp.rmdir).toHaveBeenNthCalledWith(2, '/remote/assets', expect.any(Function)) - expect(sftp.close).toHaveBeenCalledTimes(2) }) }) diff --git a/src/main/ssh/sftp-upload.ts b/src/main/ssh/sftp-upload.ts index e15beba93e0..9ece3e2aaa9 100644 --- a/src/main/ssh/sftp-upload.ts +++ b/src/main/ssh/sftp-upload.ts @@ -1,11 +1,8 @@ import { constants } from 'node:fs' import type { ReadStream } from 'node:fs' -import { lstat, open, opendir, realpath } from 'node:fs/promises' +import { lstat, open, readdir, realpath } from 'node:fs/promises' import { isAbsolute, join as pathJoin, relative, sep } from 'node:path' import type { SFTPWrapper } from 'ssh2' -import { SshDirectoryTransferBudget } from './ssh-directory-transfer-budget' - -export { removeDirectorySftp } from './sftp-directory-removal' export function mkdirSftp( sftp: SFTPWrapper, @@ -135,65 +132,87 @@ export async function uploadDirectory( rootRealPath = localDir, options?: { exclusive?: boolean } ): Promise { - const entries = await collectLocalUploadEntries(localDir, remoteDir, rootRealPath) - for (const entry of entries) { - await assertLocalUploadPathInsideRoot(rootRealPath, entry.localPath) - const statResult = await lstat(entry.localPath) - if (statResult.isSymbolicLink() || statResult.isDirectory() !== (entry.kind === 'directory')) { - throw new Error(`Upload source changed during transfer: ${entry.localPath}`) - } - await (entry.kind === 'directory' - ? mkdirSftp(sftp, entry.remotePath, { allowExisting: !options?.exclusive }) - : uploadFile(sftp, entry.localPath, entry.remotePath, { - exclusive: options?.exclusive - })) - } -} - -type LocalUploadEntry = { - kind: 'directory' | 'file' - localPath: string - remotePath: string -} - -async function collectLocalUploadEntries( - localDir: string, - remoteDir: string, - rootRealPath: string -): Promise { - const budget = new SshDirectoryTransferBudget() - const collected: LocalUploadEntry[] = [] - const pending = [{ localDir, remoteDir, depth: 0 }] await assertLocalUploadPathInsideRoot(rootRealPath, localDir) - budget.recordPath(remoteDir, 0, { countEntry: false }) - budget.recordPath(localDir, 0, { countEntry: false }) + const entries = await readdir(localDir, { withFileTypes: true }) + for (const entry of entries) { + const localPath = pathJoin(localDir, entry.name) + const remotePath = `${remoteDir}/${entry.name}` + await assertLocalUploadPathInsideRoot(rootRealPath, localPath) + const statResult = await lstat(localPath) - while (pending.length > 0) { - const current = pending.pop()! - const handle = await opendir(current.localDir) - try { - for await (const entry of handle) { - const localPath = pathJoin(current.localDir, entry.name) - const remotePath = `${current.remoteDir}/${entry.name}` - const depth = current.depth + 1 - budget.recordPath(remotePath, depth) - budget.recordPath(localPath, depth, { countEntry: false }) - await assertLocalUploadPathInsideRoot(rootRealPath, localPath) - const statResult = await lstat(localPath) - if (statResult.isSymbolicLink() || (!statResult.isFile() && !statResult.isDirectory())) { - continue - } - const kind = statResult.isDirectory() ? 'directory' : 'file' - collected.push({ kind, localPath, remotePath }) - if (kind === 'directory') { - pending.push({ localDir: localPath, remoteDir: remotePath, depth }) - } - } - } finally { - await handle.close().catch(() => undefined) + // Why: skip symlinks and special files (sockets, FIFOs, devices) to + // prevent following symlinks that could exfiltrate local files to the + // remote. The caller's pre-scan catches symlinks up-front, but this + // guard closes the TOCTOU gap if one is created between scan and upload. + if (statResult.isSymbolicLink() || (!statResult.isFile() && !statResult.isDirectory())) { + continue + } + + if (statResult.isDirectory()) { + await mkdirSftp(sftp, remotePath, { allowExisting: !options?.exclusive }) + await uploadDirectory(sftp, localPath, remotePath, rootRealPath, options) + } else { + await uploadFile(sftp, localPath, remotePath, { exclusive: options?.exclusive }) } } - return collected +} + +export async function removeDirectorySftp(sftp: SFTPWrapper, remoteDir: string): Promise { + const entries = await readdirSftp(sftp, remoteDir) + const normalizedRemoteDir = remoteDir.replace(/\/+$/, '') + for (const entry of entries) { + if (entry.filename === '.' || entry.filename === '..') { + continue + } + const childPath = `${normalizedRemoteDir}/${entry.filename}` + await (entry.attrs?.isDirectory?.() + ? removeDirectorySftp(sftp, childPath) + : unlinkSftp(sftp, childPath)) + } + await rmdirSftp(sftp, remoteDir) +} + +type SftpDirectoryEntry = { + filename: string + attrs?: { + isDirectory?: () => boolean + } +} + +function readdirSftp(sftp: SFTPWrapper, remoteDir: string): Promise { + return new Promise((resolve, reject) => { + sftp.readdir(remoteDir, (err, entries) => { + if (err) { + reject(err) + return + } + resolve((entries ?? []) as SftpDirectoryEntry[]) + }) + }) +} + +function unlinkSftp(sftp: SFTPWrapper, remotePath: string): Promise { + return new Promise((resolve, reject) => { + sftp.unlink(remotePath, (err) => { + if (err) { + reject(err) + return + } + resolve() + }) + }) +} + +function rmdirSftp(sftp: SFTPWrapper, remoteDir: string): Promise { + return new Promise((resolve, reject) => { + sftp.rmdir(remoteDir, (err) => { + if (err) { + reject(err) + return + } + resolve() + }) + }) } async function assertLocalUploadPathInsideRoot( diff --git a/src/main/ssh/ssh-agent-identity-filter.test.ts b/src/main/ssh/ssh-agent-identity-filter.test.ts index 3a26dafdf5e..5b341c7ce63 100644 --- a/src/main/ssh/ssh-agent-identity-filter.test.ts +++ b/src/main/ssh/ssh-agent-identity-filter.test.ts @@ -4,11 +4,11 @@ import { join } from 'node:path' const mocks = vi.hoisted(() => ({ createAgent: vi.fn(), parseKey: vi.fn(), - readSshKeyFile: vi.fn() + readFileSync: vi.fn() })) -vi.mock('./ssh-key-file', () => ({ - readSshKeyFile: (...args: unknown[]) => mocks.readSshKeyFile(...args) +vi.mock('fs', () => ({ + readFileSync: (...args: unknown[]) => mocks.readFileSync(...args) })) vi.mock('os', () => ({ @@ -50,13 +50,13 @@ describe('createIdentityFilteredAgent', () => { beforeEach(() => { mocks.createAgent.mockReset() mocks.parseKey.mockReset() - mocks.readSshKeyFile.mockReset() + mocks.readFileSync.mockReset() }) it('offers only agent identities matching configured identity files', async () => { const allowedKey = makeKey('allowed') const otherKey = makeKey('other') - mocks.readSshKeyFile.mockReturnValue('ssh-ed25519 AAAA allowed') + mocks.readFileSync.mockReturnValue('ssh-ed25519 AAAA allowed') mocks.parseKey.mockReturnValue(allowedKey) mocks.createAgent.mockReturnValue({ getIdentities: vi.fn((callback) => callback(undefined, [allowedKey, otherKey])), @@ -75,12 +75,12 @@ describe('createIdentityFilteredAgent', () => { }) expect(identities).toEqual([allowedKey]) - expect(mocks.readSshKeyFile).toHaveBeenCalledWith(testHomePath('.ssh', 'work_key.pub')) + expect(mocks.readFileSync).toHaveBeenCalledWith(testHomePath('.ssh', 'work_key.pub')) }) it('expands Windows-style configured identity file paths before filtering', async () => { const allowedKey = makeKey('allowed') - mocks.readSshKeyFile.mockReturnValue('ssh-ed25519 AAAA allowed') + mocks.readFileSync.mockReturnValue('ssh-ed25519 AAAA allowed') mocks.parseKey.mockReturnValue(allowedKey) mocks.createAgent.mockReturnValue({ getIdentities: vi.fn((callback) => callback(undefined, [allowedKey])), @@ -99,7 +99,7 @@ describe('createIdentityFilteredAgent', () => { }) expect(identities).toEqual([allowedKey]) - expect(mocks.readSshKeyFile).toHaveBeenCalledWith(testHomePath('.ssh', 'work_key.pub')) + expect(mocks.readFileSync).toHaveBeenCalledWith(testHomePath('.ssh', 'work_key.pub')) }) it('filters nested public key entries returned by ssh2 agents', async () => { @@ -107,7 +107,7 @@ describe('createIdentityFilteredAgent', () => { const otherKey = makeKey('other') const allowedEntry = { pubKey: { pubKey: allowedKey, comment: 'allowed' } } const otherEntry = { pubKey: { pubKey: otherKey, comment: 'other' } } - mocks.readSshKeyFile.mockReturnValue('ssh-ed25519 AAAA allowed') + mocks.readFileSync.mockReturnValue('ssh-ed25519 AAAA allowed') mocks.parseKey.mockReturnValue(allowedKey) mocks.createAgent.mockReturnValue({ getIdentities: vi.fn((callback) => callback(undefined, [allowedEntry, otherEntry])), @@ -129,7 +129,7 @@ describe('createIdentityFilteredAgent', () => { }) it('does not create a broad agent when configured identity keys cannot be parsed', () => { - mocks.readSshKeyFile.mockReturnValue('not-a-key') + mocks.readFileSync.mockReturnValue('not-a-key') mocks.parseKey.mockReturnValue(new Error('parse failed')) expect(createIdentityFilteredAgent('/tmp/agent.sock', ['~/.ssh/work_key'])).toBeUndefined() @@ -141,7 +141,7 @@ describe('createIdentityFilteredAgent', () => { const sign = vi.fn() const options = { hash: 'sha256' as const } const callback = vi.fn() - mocks.readSshKeyFile.mockReturnValue('ssh-ed25519 AAAA allowed') + mocks.readFileSync.mockReturnValue('ssh-ed25519 AAAA allowed') mocks.parseKey.mockReturnValue(allowedKey) mocks.createAgent.mockReturnValue({ getIdentities: vi.fn(), diff --git a/src/main/ssh/ssh-agent-identity-filter.ts b/src/main/ssh/ssh-agent-identity-filter.ts index cd980be05a0..35a3c0a7721 100644 --- a/src/main/ssh/ssh-agent-identity-filter.ts +++ b/src/main/ssh/ssh-agent-identity-filter.ts @@ -1,3 +1,4 @@ +import { readFileSync } from 'node:fs' import { BaseAgent, createAgent, @@ -9,7 +10,6 @@ import { type SigningRequestOptions } from 'ssh2' import { resolveSshConfigHomePath } from './ssh-config-path-expansion' -import { readSshKeyFile } from './ssh-key-file' type AgentPublicKey = ParsedKey | Buffer | string | PublicKeyEntry @@ -70,7 +70,7 @@ class IdentityFilteredAgent extends BaseAgent { function parseIdentityKeyFile(filePath: string): ParsedKey | undefined { try { - const parsed = utils.parseKey(readSshKeyFile(filePath)) as ParsedKey | ParsedKey[] | Error + const parsed = utils.parseKey(readFileSync(filePath)) as ParsedKey | ParsedKey[] | Error if (parsed instanceof Error) { return undefined } diff --git a/src/main/ssh/ssh-auth-resolution.ts b/src/main/ssh/ssh-auth-resolution.ts index 2444d6bb619..a222ff2ee2d 100644 --- a/src/main/ssh/ssh-auth-resolution.ts +++ b/src/main/ssh/ssh-auth-resolution.ts @@ -1,10 +1,9 @@ -import { existsSync } from 'node:fs' +import { existsSync, readFileSync } from 'node:fs' import { utils, type BaseAgent, type ParsedKey } from 'ssh2' import type { SshTarget } from '../../shared/ssh-types' import type { SshResolvedConfig } from './ssh-config-parser' import { createIdentityFilteredAgent } from './ssh-agent-identity-filter' import { resolveSshConfigHomePath } from './ssh-config-path-expansion' -import { readSshKeyFile } from './ssh-key-file' // Why: ssh2 only tries keys that are explicitly provided. Users with keys in // standard locations (e.g. ~/.ssh/id_ed25519) but no SSH agent running would @@ -27,7 +26,7 @@ export function findDefaultKeyFile(): PrivateKeyFile | undefined { if (!existsSync(resolved)) { continue } - const contents = readSshKeyFile(resolved) + const contents = readFileSync(resolved) return { path: keyPath, contents } } catch { continue @@ -97,7 +96,7 @@ function resolveExplicitPrivateKeyPath( function readPrivateKey(keyPath: string): PrivateKeyFile | undefined { try { const resolvedPath = resolveSshConfigHomePath(keyPath) - return { path: keyPath, contents: readSshKeyFile(resolvedPath) } + return { path: keyPath, contents: readFileSync(resolvedPath) } } catch { return undefined } diff --git a/src/main/ssh/ssh-channel-multiplexer.test.ts b/src/main/ssh/ssh-channel-multiplexer.test.ts index 4f0faee8cc2..d17e756f75b 100644 --- a/src/main/ssh/ssh-channel-multiplexer.test.ts +++ b/src/main/ssh/ssh-channel-multiplexer.test.ts @@ -471,25 +471,6 @@ describe('SshChannelMultiplexer', () => { expect(internals.disposeHandlers).toHaveLength(0) }) - it('invokes every dispose handler when handlers unsubscribe themselves', () => { - const called: string[] = [] - let unsubscribeFirst = (): void => {} - let unsubscribeSecond = (): void => {} - unsubscribeFirst = mux.onDispose(() => { - called.push('first') - unsubscribeFirst() - }) - unsubscribeSecond = mux.onDispose(() => { - called.push('second') - unsubscribeSecond() - }) - - mux.dispose('connection_lost') - - expect(called).toEqual(['first', 'second']) - expect(getMuxInternals(mux).disposeHandlers).toHaveLength(0) - }) - it('does not retain handlers registered after dispose', () => { mux.dispose() diff --git a/src/main/ssh/ssh-channel-multiplexer.ts b/src/main/ssh/ssh-channel-multiplexer.ts index 1550eb5e210..9886b724755 100644 --- a/src/main/ssh/ssh-channel-multiplexer.ts +++ b/src/main/ssh/ssh-channel-multiplexer.ts @@ -305,14 +305,14 @@ export class SshChannelMultiplexer { this.decoder.reset() this.transport.close?.() - // Why: stream handlers unsubscribe while failing; detach first so one cleanup cannot skip the next. - for (const handler of this.disposeHandlers.splice(0)) { + for (const handler of this.disposeHandlers) { try { handler(reason) } catch { // Don't let a handler error prevent other handlers from running } } + this.disposeHandlers.length = 0 } isDisposed(): boolean { diff --git a/src/main/ssh/ssh-config-bounded-glob.test.ts b/src/main/ssh/ssh-config-bounded-glob.test.ts deleted file mode 100644 index fe849184a19..00000000000 --- a/src/main/ssh/ssh-config-bounded-glob.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { basename, join, posix, win32 } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import { resolveBoundedSshConfigGlob } from './ssh-config-bounded-glob' - -const pathApi = process.platform === 'win32' ? win32 : posix -const tempRoots: string[] = [] -const SOURCE = readFileSync(new URL('./ssh-config-bounded-glob.ts', import.meta.url), 'utf8') - -function makeTempRoot(): string { - const root = mkdtempSync(join(tmpdir(), 'orca-ssh-config-glob-')) - tempRoots.push(root) - return root -} - -afterEach(() => { - for (const root of tempRoots.splice(0)) { - rmSync(root, { recursive: true, force: true }) - } -}) - -describe('bounded SSH config include globbing', () => { - it('retains only the first 256 sorted matches without materializing every result', () => { - const root = makeTempRoot() - const includeDir = join(root, 'conf.d') - mkdirSync(includeDir) - for (let index = 0; index < 2_000; index += 1) { - writeFileSync(join(includeDir, `${String(index).padStart(4, '0')}.conf`), '') - } - - const result = resolveBoundedSshConfigGlob(join(includeDir, '*.conf'), pathApi, 256) - - expect(result.totalMatches).toBe(2_000) - expect(result.truncated).toBe(true) - expect(result.matches).toHaveLength(256) - expect(result.matches.map((filePath) => basename(filePath))).toEqual( - Array.from({ length: 256 }, (_, index) => `${String(index).padStart(4, '0')}.conf`) - ) - expect(SOURCE).not.toContain('globSync') - }) - - it('scans past nonmatches and preserves sorted order below the cap', () => { - const root = makeTempRoot() - const includeDir = join(root, 'conf.d') - mkdirSync(includeDir) - for (let index = 0; index < 300; index += 1) { - writeFileSync(join(includeDir, `${index}.txt`), '') - } - writeFileSync(join(includeDir, 'zeta.conf'), '') - writeFileSync(join(includeDir, 'alpha.conf'), '') - - const result = resolveBoundedSshConfigGlob(join(includeDir, '*.conf'), pathApi, 256) - - expect(result).toMatchObject({ totalMatches: 2, truncated: false }) - expect(result.matches.map((filePath) => basename(filePath))).toEqual([ - 'alpha.conf', - 'zeta.conf' - ]) - }) - - it('rejects path patterns deep enough to threaten the traversal stack', () => { - const pattern = pathApi.join( - pathApi.parse(process.cwd()).root, - ...Array.from({ length: 65 }, () => '*') - ) - - expect(resolveBoundedSshConfigGlob(pattern, pathApi, 256)).toEqual({ - matches: [], - totalMatches: 0, - truncated: true, - patternTooDeep: true - }) - }) -}) diff --git a/src/main/ssh/ssh-config-bounded-glob.ts b/src/main/ssh/ssh-config-bounded-glob.ts deleted file mode 100644 index 04b8dbc7e97..00000000000 --- a/src/main/ssh/ssh-config-bounded-glob.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { existsSync, opendirSync, statSync } from 'node:fs' -import type { posix, win32 } from 'node:path' - -export type SshConfigPathApi = typeof posix | typeof win32 - -export type BoundedSshConfigGlobResult = { - matches: string[] - totalMatches: number - truncated: boolean - patternTooDeep: boolean -} - -const MAX_GLOB_PATTERN_SEGMENTS = 64 - -export function resolveBoundedSshConfigGlob( - absolutePattern: string, - pathApi: SshConfigPathApi, - matchLimit: number -): BoundedSshConfigGlobResult { - const root = pathApi.parse(absolutePattern).root - const segments = absolutePattern.slice(root.length).split(pathApi.sep).filter(Boolean) - if (segments.length > MAX_GLOB_PATTERN_SEGMENTS) { - return { matches: [], totalMatches: 0, truncated: true, patternTooDeep: true } - } - - const matches: string[] = [] - let totalMatches = 0 - const retainMatch = (matchedPath: string): void => { - totalMatches += 1 - insertSortedWithinLimit(matches, matchedPath, matchLimit) - } - - const visit = (directoryPath: string, segmentIndex: number): void => { - const segment = segments[segmentIndex] - const isLast = segmentIndex === segments.length - 1 - if (!hasGlobPattern(segment)) { - const nextPath = pathApi.join(directoryPath, segment) - if (isLast) { - if (existsSync(nextPath)) { - retainMatch(nextPath) - } - return - } - visit(nextPath, segmentIndex + 1) - return - } - - let directory: ReturnType - try { - directory = opendirSync(directoryPath) - } catch { - return - } - try { - for (let entry = directory.readSync(); entry; entry = directory.readSync()) { - if (!pathApi.matchesGlob(entry.name, segment)) { - continue - } - const nextPath = pathApi.join(directoryPath, entry.name) - if (isLast) { - retainMatch(nextPath) - continue - } - if (entry.isDirectory() || isDirectorySymlink(nextPath, entry.isSymbolicLink())) { - visit(nextPath, segmentIndex + 1) - } - } - } finally { - directory.closeSync() - } - } - - if (segments.length === 0) { - if (existsSync(root)) { - retainMatch(root) - } - } else { - visit(root, 0) - } - return { - matches, - totalMatches, - truncated: totalMatches > matchLimit, - patternTooDeep: false - } -} - -function insertSortedWithinLimit(target: string[], value: string, limit: number): void { - if (limit <= 0) { - return - } - let low = 0 - let high = target.length - while (low < high) { - const middle = (low + high) >>> 1 - if (target[middle].localeCompare(value) <= 0) { - low = middle + 1 - } else { - high = middle - } - } - if (low >= limit) { - return - } - target.splice(low, 0, value) - if (target.length > limit) { - target.pop() - } -} - -function isDirectorySymlink(filePath: string, isSymbolicLink: boolean): boolean { - if (!isSymbolicLink) { - return false - } - try { - return statSync(filePath).isDirectory() - } catch { - return false - } -} - -function hasGlobPattern(input: string): boolean { - return /[*?[]/.test(input) -} diff --git a/src/main/ssh/ssh-config-expansion-budget.ts b/src/main/ssh/ssh-config-expansion-budget.ts deleted file mode 100644 index 99e5cdc4ab6..00000000000 --- a/src/main/ssh/ssh-config-expansion-budget.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { statSync } from 'node:fs' -import { readNodeFileSyncWithinLimit } from '../../shared/node-bounded-file-reader' - -export const SSH_CONFIG_INCLUDE_LIMITS = { - expandedBytes: 16 * 1024 * 1024, - expandedLines: 200_000, - fileBytes: 1024 * 1024, - files: 1024, - globMatches: 256, - nestingDepth: 16, - sourceBytes: 16 * 1024 * 1024 -} as const - -export type SshConfigExpansionBudget = { - cache: Map - expandedBytes: number - expandedLines: number - fileCount: number - outputTruncated: boolean - sourceBytes: number - warnedLimits: Set -} - -export function createSshConfigExpansionBudget(): SshConfigExpansionBudget { - return { - cache: new Map(), - expandedBytes: 0, - expandedLines: 0, - fileCount: 0, - outputTruncated: false, - sourceBytes: 0, - warnedLimits: new Set() - } -} - -export function admitSshConfigIncludeDepth( - budget: SshConfigExpansionBudget, - activeDepth: number -): boolean { - if (activeDepth < SSH_CONFIG_INCLUDE_LIMITS.nestingDepth) { - return true - } - warnOnce( - budget, - 'nesting-depth', - `[ssh] SSH config Include nesting exceeds ${SSH_CONFIG_INCLUDE_LIMITS.nestingDepth}; skipping deeper files` - ) - return false -} - -export function appendSshConfigExpandedLine( - target: string[], - line: string, - budget: SshConfigExpansionBudget -): void { - const nextBytes = Buffer.byteLength(line, 'utf8') + (target.length > 0 ? 1 : 0) - if ( - budget.expandedLines >= SSH_CONFIG_INCLUDE_LIMITS.expandedLines || - budget.expandedBytes + nextBytes > SSH_CONFIG_INCLUDE_LIMITS.expandedBytes - ) { - budget.outputTruncated = true - warnOnce( - budget, - 'expanded-output', - `[ssh] Expanded SSH config exceeds ${SSH_CONFIG_INCLUDE_LIMITS.expandedBytes} bytes or ${SSH_CONFIG_INCLUDE_LIMITS.expandedLines} lines; truncating` - ) - return - } - target.push(line) - budget.expandedBytes += nextBytes - budget.expandedLines += 1 -} - -export function readSshConfigSourceFile( - filePath: string, - budget: SshConfigExpansionBudget -): string | null { - const cached = budget.cache.get(filePath) - if (cached !== undefined) { - return cached - } - if (budget.fileCount >= SSH_CONFIG_INCLUDE_LIMITS.files) { - warnOnce( - budget, - 'file-count', - `[ssh] SSH config Include file count exceeds ${SSH_CONFIG_INCLUDE_LIMITS.files}; skipping additional files` - ) - return null - } - const fileBytes = getReadableRegularFileBytes(filePath) - if (fileBytes === null || !hasSourceCapacity(budget, fileBytes)) { - return null - } - - try { - const content = readNodeFileSyncWithinLimit( - filePath, - SSH_CONFIG_INCLUDE_LIMITS.fileBytes - ).buffer.toString('utf-8') - const actualBytes = Buffer.byteLength(content, 'utf8') - if ( - actualBytes > SSH_CONFIG_INCLUDE_LIMITS.fileBytes || - !hasSourceCapacity(budget, actualBytes) - ) { - return null - } - budget.cache.set(filePath, content) - budget.fileCount += 1 - budget.sourceBytes += actualBytes - return content - } catch { - return null - } -} - -function hasSourceCapacity(budget: SshConfigExpansionBudget, fileBytes: number): boolean { - if (budget.sourceBytes + fileBytes <= SSH_CONFIG_INCLUDE_LIMITS.sourceBytes) { - return true - } - warnOnce( - budget, - 'source-bytes', - `[ssh] SSH config Include sources exceed ${SSH_CONFIG_INCLUDE_LIMITS.sourceBytes} bytes; skipping additional files` - ) - return false -} - -function getReadableRegularFileBytes(filePath: string): number | null { - try { - const stats = statSync(filePath) - if (!stats.isFile()) { - console.warn(`[ssh] Skipping SSH config include "${filePath}": not a regular file`) - return null - } - if (stats.size > SSH_CONFIG_INCLUDE_LIMITS.fileBytes) { - console.warn( - `[ssh] Skipping SSH config include "${filePath}": size ${stats.size} exceeds ${SSH_CONFIG_INCLUDE_LIMITS.fileBytes} bytes` - ) - return null - } - return stats.size - } catch { - return null - } -} - -function warnOnce(budget: SshConfigExpansionBudget, key: string, message: string): void { - if (budget.warnedLimits.has(key)) { - return - } - budget.warnedLimits.add(key) - console.warn(message) -} diff --git a/src/main/ssh/ssh-config-include-expander.ts b/src/main/ssh/ssh-config-include-expander.ts index 1f49c426fad..eb1aba64c17 100644 --- a/src/main/ssh/ssh-config-include-expander.ts +++ b/src/main/ssh/ssh-config-include-expander.ts @@ -1,20 +1,11 @@ -import { existsSync, realpathSync } from 'node:fs' +import { existsSync, globSync, readFileSync, realpathSync, statSync } from 'node:fs' import { homedir, hostname, userInfo } from 'node:os' import { posix, win32 } from 'node:path' -import { resolveBoundedSshConfigGlob, type SshConfigPathApi } from './ssh-config-bounded-glob' -import { - admitSshConfigIncludeDepth, - appendSshConfigExpandedLine, - createSshConfigExpansionBudget, - readSshConfigSourceFile, - SSH_CONFIG_INCLUDE_LIMITS, - type SshConfigExpansionBudget -} from './ssh-config-expansion-budget' -type PathApi = SshConfigPathApi +type PathApi = typeof posix | typeof win32 type IncludeExpansionContext = { - budget: SshConfigExpansionBudget + cache: Map home: string pathApi: PathApi rootDir: string @@ -23,6 +14,8 @@ type IncludeExpansionContext = { username: string } +const MAX_INCLUDE_GLOB_MATCHES = 256 +const MAX_INCLUDE_FILE_BYTES = 1024 * 1024 const TARGET_DEPENDENT_INCLUDE_TOKENS = new Set(['h', 'n', 'p', 'r', 'j', 'k', 'C']) export function expandSshConfigIncludes(configPath: string): string { @@ -32,7 +25,7 @@ export function expandSshConfigIncludes(configPath: string): string { const localHostname = hostname() const context: IncludeExpansionContext = { - budget: createSshConfigExpansionBudget(), + cache: new Map(), home, pathApi, rootDir: pathApi.dirname(configPath), @@ -41,51 +34,69 @@ export function expandSshConfigIncludes(configPath: string): string { username: currentUser } - const expandedLines: string[] = [] - expandSshConfigFile(configPath, context, [], expandedLines) - return expandedLines.join('\n') + return expandSshConfigFile(configPath, context, []).join('\n') } function expandSshConfigFile( filePath: string, context: IncludeExpansionContext, - activeStack: string[], - expandedLines: string[] -): void { + activeStack: string[] +): string[] { const canonicalPath = getCanonicalPath(filePath) if (!canonicalPath || activeStack.includes(canonicalPath)) { - return - } - if (!admitSshConfigIncludeDepth(context.budget, activeStack.length)) { - return + return [] } - const rawContent = readSshConfigSourceFile(canonicalPath, context.budget) + const rawContent = readCachedFile(canonicalPath, context) if (rawContent === null) { - return + return [] } + const expandedLines: string[] = [] const nextStack = [...activeStack, canonicalPath] for (const line of rawContent.split(/\r?\n/)) { - if (context.budget.outputTruncated) { - return - } const includeArgs = parseIncludeDirective(line) if (!includeArgs) { - appendSshConfigExpandedLine(expandedLines, line, context.budget) + expandedLines.push(line) continue } for (const includeArg of includeArgs) { for (const matchedPath of resolveIncludePaths(includeArg, context)) { - expandSshConfigFile(matchedPath, context, nextStack, expandedLines) - if (context.budget.outputTruncated) { - return - } + appendExpandedLines(expandedLines, expandSshConfigFile(matchedPath, context, nextStack)) } } } + + return expandedLines +} + +function appendExpandedLines(target: string[], lines: readonly string[]): void { + // Why: SSH config includes are user-controlled files, and a large included + // file can exceed the JavaScript call argument limit when spread into push. + for (const line of lines) { + target.push(line) + } +} + +function readCachedFile(filePath: string, context: IncludeExpansionContext): string | null { + const cached = context.cache.get(filePath) + if (cached !== undefined) { + return cached + } + + if (!isReadableRegularFile(filePath)) { + return null + } + + try { + const content = readFileSync(filePath, 'utf-8') + context.cache.set(filePath, content) + return content + } catch { + return null + } } function parseIncludeDirective(line: string): string[] | null { @@ -157,23 +168,18 @@ function resolveIncludePaths(pattern: string, context: IncludeExpansionContext): const absolutePattern = resolveIncludePatternPath(withTokens, context) if (hasGlobPattern(absolutePattern)) { - const result = resolveBoundedSshConfigGlob( - absolutePattern, - context.pathApi, - SSH_CONFIG_INCLUDE_LIMITS.globMatches - ) - if (result.patternTooDeep) { - console.warn( - `[ssh] Include pattern "${absolutePattern}" has too many path segments; skipping` - ) + try { + const matches = globSync(absolutePattern).sort((left, right) => left.localeCompare(right)) + if (matches.length > MAX_INCLUDE_GLOB_MATCHES) { + console.warn( + `[ssh] Include pattern "${absolutePattern}" matched ${matches.length} files; processing first ${MAX_INCLUDE_GLOB_MATCHES}` + ) + return matches.slice(0, MAX_INCLUDE_GLOB_MATCHES) + } + return matches + } catch { return [] } - if (result.truncated) { - console.warn( - `[ssh] Include pattern "${absolutePattern}" matched ${result.totalMatches} files; processing first ${SSH_CONFIG_INCLUDE_LIMITS.globMatches}` - ) - } - return result.matches } return existsSync(absolutePattern) ? [absolutePattern] : [] @@ -287,6 +293,25 @@ function getCanonicalPath(filePath: string): string | null { } } +function isReadableRegularFile(filePath: string): boolean { + try { + const stats = statSync(filePath) + if (!stats.isFile()) { + console.warn(`[ssh] Skipping SSH config include "${filePath}": not a regular file`) + return false + } + if (stats.size > MAX_INCLUDE_FILE_BYTES) { + console.warn( + `[ssh] Skipping SSH config include "${filePath}": size ${stats.size} exceeds ${MAX_INCLUDE_FILE_BYTES} bytes` + ) + return false + } + return true + } catch { + return false + } +} + function getCurrentUid(): string | undefined { try { const info = userInfo() diff --git a/src/main/ssh/ssh-config-include-expansion-bounds.test.ts b/src/main/ssh/ssh-config-include-expansion-bounds.test.ts deleted file mode 100644 index d1eda95eeb0..00000000000 --- a/src/main/ssh/ssh-config-include-expansion-bounds.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { - appendSshConfigExpandedLine, - createSshConfigExpansionBudget, - readSshConfigSourceFile, - SSH_CONFIG_INCLUDE_LIMITS -} from './ssh-config-expansion-budget' -import { expandSshConfigIncludes } from './ssh-config-include-expander' - -const tempRoots: string[] = [] - -function makeTempRoot(): string { - const root = mkdtempSync(join(tmpdir(), 'orca-ssh-config-bounds-')) - tempRoots.push(root) - return root -} - -afterEach(() => { - vi.restoreAllMocks() - for (const root of tempRoots.splice(0)) { - rmSync(root, { recursive: true, force: true }) - } -}) - -describe('SSH config include expansion bounds', () => { - it('stops recursive includes at 16 active files', () => { - vi.spyOn(console, 'warn').mockImplementation(() => {}) - const root = makeTempRoot() - for (let depth = 0; depth < 20; depth += 1) { - writeFileSync( - join(root, `${depth}.conf`), - `Host depth-${depth}\n${depth < 19 ? `Include ${depth + 1}.conf\n` : ''}` - ) - } - - const expanded = expandSshConfigIncludes(join(root, '0.conf')) - - expect(expanded).toContain('Host depth-15') - expect(expanded).not.toContain('Host depth-16') - expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('nesting exceeds 16')) - }) - - it('admits exactly 1,024 unique files and 16 MiB of source bytes', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const root = makeTempRoot() - const firstPath = join(root, 'first.conf') - const secondPath = join(root, 'second.conf') - writeFileSync(firstPath, 'a') - writeFileSync(secondPath, 'b') - const fileBudget = createSshConfigExpansionBudget() - fileBudget.fileCount = SSH_CONFIG_INCLUDE_LIMITS.files - 1 - - expect(readSshConfigSourceFile(firstPath, fileBudget)).toBe('a') - expect(readSshConfigSourceFile(secondPath, fileBudget)).toBeNull() - - const byteBudget = createSshConfigExpansionBudget() - byteBudget.sourceBytes = SSH_CONFIG_INCLUDE_LIMITS.sourceBytes - 1 - expect(readSshConfigSourceFile(firstPath, byteBudget)).toBe('a') - expect(readSshConfigSourceFile(secondPath, byteBudget)).toBeNull() - expect(warn).toHaveBeenCalledWith(expect.stringContaining('file count exceeds 1024')) - expect(warn).toHaveBeenCalledWith(expect.stringContaining('sources exceed 16777216 bytes')) - }) - - it('caps expanded output at 16 MiB or 200,000 lines', () => { - vi.spyOn(console, 'warn').mockImplementation(() => {}) - const byteBudget = createSshConfigExpansionBudget() - byteBudget.expandedBytes = SSH_CONFIG_INCLUDE_LIMITS.expandedBytes - 1 - const byteLines = [''] - appendSshConfigExpandedLine(byteLines, '', byteBudget) - expect(byteBudget.expandedBytes).toBe(SSH_CONFIG_INCLUDE_LIMITS.expandedBytes) - appendSshConfigExpandedLine(byteLines, '', byteBudget) - expect(byteBudget.outputTruncated).toBe(true) - - const lineBudget = createSshConfigExpansionBudget() - lineBudget.expandedLines = SSH_CONFIG_INCLUDE_LIMITS.expandedLines - appendSshConfigExpandedLine([], 'Host overflow', lineBudget) - expect(lineBudget.outputTruncated).toBe(true) - }) -}) diff --git a/src/main/ssh/ssh-config-loader-regression.test.ts b/src/main/ssh/ssh-config-loader-regression.test.ts index 0dd35560883..089ee9d99bb 100644 --- a/src/main/ssh/ssh-config-loader-regression.test.ts +++ b/src/main/ssh/ssh-config-loader-regression.test.ts @@ -8,7 +8,6 @@ afterEach(() => { vi.resetModules() vi.doUnmock('fs') vi.doUnmock('os') - vi.doUnmock('../../shared/node-bounded-file-reader') }) function normalizeWin(value: string): string { @@ -25,24 +24,6 @@ function platformSshPath(home: string, relativePath: string): string { : `${home}/${relativePath}` } -function mockDirectory(names: string[]) { - let index = 0 - return { - readSync: () => { - const name = names[index] - index += 1 - return name === undefined - ? null - : { - name, - isDirectory: () => false, - isSymbolicLink: () => false - } - }, - closeSync: () => {} - } -} - async function mockOs( home: string, username = 'testuser', @@ -61,18 +42,6 @@ async function mockOs( } async function loadUserSshConfig() { - vi.doMock('../../shared/node-bounded-file-reader', async () => { - const fs = await import('node:fs') - return { - readNodeFileSyncWithinLimit: (filePath: string, maxBytes: number) => { - const buffer = Buffer.from(fs.readFileSync(filePath, 'utf8')) - if (buffer.byteLength > maxBytes) { - throw new Error('File too large') - } - return { buffer, stats: fs.statSync(filePath) } - } - } - }) const mod = await import('./ssh-config-parser') return mod.loadUserSshConfig() } @@ -108,12 +77,13 @@ describe('loadUserSshConfig regressions', () => { return { ...actual, existsSync: (filePath: string) => files.has(normalizeWin(filePath)), - opendirSync: (directoryPath: string) => { - if (normalizeWin(directoryPath) !== normalizeWin('C:/Users/Test User/.ssh/conf.d')) { - throw new Error(`ENOENT: ${directoryPath}`) - } - return mockDirectory(['zeta.conf', 'alpha.conf']) - }, + globSync: (pattern: string) => + normalizeWin(pattern) === normalizeWin('C:/Users/Test User/.ssh/conf.d/*.conf') + ? [ + normalizeWin('C:/Users/Test User/.ssh/conf.d/alpha.conf'), + normalizeWin('C:/Users/Test User/.ssh/conf.d/zeta.conf') + ] + : [], readFileSync: (filePath: string) => { const content = files.get(normalizeWin(filePath)) if (content === undefined) { @@ -235,8 +205,7 @@ describe('loadUserSshConfig regressions', () => { ...actual, existsSync: (filePath: string) => filePath === configPath || includePaths.includes(filePath), - opendirSync: () => - mockDirectory(includePaths.map((filePath) => filePath.split(/[\\/]/).at(-1)!)), + globSync: () => [...includePaths].toReversed(), readFileSync: (filePath: string) => { if (filePath === configPath) { return 'Include conf.d/*.conf\n' diff --git a/src/main/ssh/ssh-connection-generation.test.ts b/src/main/ssh/ssh-connection-generation.test.ts index 3216794116a..a6c995bcab2 100644 --- a/src/main/ssh/ssh-connection-generation.test.ts +++ b/src/main/ssh/ssh-connection-generation.test.ts @@ -1,15 +1,9 @@ import { afterEach, describe, expect, it } from 'vitest' import { - _internals, - MAX_RETAINED_SSH_CONNECTION_GENERATION_LEASES, - MAX_SSH_CONNECTION_GENERATION_RETAINED_TARGET_ID_BYTES, - MAX_SSH_CONNECTION_GENERATION_TARGET_ID_BYTES, - MAX_TRACKED_SSH_CONNECTION_GENERATIONS, advanceSshConnectionGeneration, assertSshMutationExpectation, getSshConnectionGeneration, resetSshConnectionGenerations, - retainSshConnectionGeneration, setSshConnectionGeneration } from './ssh-connection-generation' @@ -21,31 +15,23 @@ describe('SSH connection generation session scope', () => { it('does not reuse a target token when a restarted HUB reaches the same counter', () => { resetSshConnectionGenerations(41) - const beforeRestartLease = retainSshConnectionGeneration('ssh-a') - const beforeRestart = getSshConnectionGeneration('ssh-a') + const beforeRestart = advanceSshConnectionGeneration('ssh-a') resetSshConnectionGenerations(42) - const afterRestartLease = retainSshConnectionGeneration('ssh-a') - const afterRestart = getSshConnectionGeneration('ssh-a') + const afterRestart = advanceSshConnectionGeneration('ssh-a') expect(afterRestart).not.toBe(beforeRestart) expect(() => assertSshMutationExpectation('ssh-a', 'ssh-a', beforeRestart)).toThrow( 'SSH connection changed; refresh and try again' ) expect(() => assertSshMutationExpectation('ssh-a', 'ssh-a', afterRestart)).not.toThrow() - beforeRestartLease.release() - afterRestartLease.release() }) - it('allocates globally unique target tokens within one HUB session', () => { + it('keeps target counters independent within one HUB session', () => { resetSshConnectionGenerations(7) - const first = retainSshConnectionGeneration('ssh-a') - const second = retainSshConnectionGeneration('ssh-b') - expect(getSshConnectionGeneration('ssh-a')).not.toBe(getSshConnectionGeneration('ssh-b')) - - first.release() - second.release() + expect(advanceSshConnectionGeneration('ssh-a')).toBe(advanceSshConnectionGeneration('ssh-b')) + expect(getSshConnectionGeneration('ssh-a')).toBe(getSshConnectionGeneration('ssh-b')) }) it('rejects an SSH execution-host expectation when direct IPC resolves locally', () => { @@ -60,165 +46,45 @@ describe('SSH connection generation session scope', () => { ) }) - it('rejects an unretained target even when its expected token equals the session base', () => { + it('rolls the session scope after counter exhaustion and keeps rotating', () => { resetSshConnectionGenerations(7) + const exhaustedGeneration = 8 * SESSION_COUNTER_STRIDE - 1 + setSshConnectionGeneration('ssh-a', exhaustedGeneration) - expect(() => - assertSshMutationExpectation('ssh-a', 'ssh-a', 7 * SESSION_COUNTER_STRIDE) - ).toThrow('SSH connection changed; refresh and try again') + const rolledGeneration = advanceSshConnectionGeneration('ssh-a') + + expect(rolledGeneration).toBe(8 * SESSION_COUNTER_STRIDE + 1) + expect(advanceSshConnectionGeneration('ssh-a')).toBe(rolledGeneration + 1) + expect(() => assertSshMutationExpectation('ssh-a', 'ssh-a', exhaustedGeneration)).toThrow( + 'SSH connection changed; refresh and try again' + ) }) - it('rotates allocation scopes without invalidating unrelated retained targets', () => { + it('invalidates other targets when exhaustion rolls the session scope', () => { resetSshConnectionGenerations(11) - const otherLease = retainSshConnectionGeneration('ssh-b') - const otherTargetGeneration = getSshConnectionGeneration('ssh-b') - const rotatingLease = retainSshConnectionGeneration('ssh-a') + const otherTargetGeneration = advanceSshConnectionGeneration('ssh-b') setSshConnectionGeneration('ssh-a', 12 * SESSION_COUNTER_STRIDE - 1) - const rotatedGeneration = advanceSshConnectionGeneration('ssh-a') + const rolledGeneration = advanceSshConnectionGeneration('ssh-a') - expect(rotatedGeneration).toBe(12 * SESSION_COUNTER_STRIDE + 1) - expect(getSshConnectionGeneration('ssh-b')).toBe(otherTargetGeneration) - expect(() => - assertSshMutationExpectation('ssh-b', 'ssh-b', otherTargetGeneration) - ).not.toThrow() - otherLease.release() - rotatingLease.release() + expect(getSshConnectionGeneration('ssh-b')).toBe(12 * SESSION_COUNTER_STRIDE) + expect(rolledGeneration).toBe(12 * SESSION_COUNTER_STRIDE + 1) + expect(() => assertSshMutationExpectation('ssh-b', 'ssh-b', otherTargetGeneration)).toThrow( + 'SSH connection changed; refresh and try again' + ) + expect(() => assertSshMutationExpectation('ssh-a', 'ssh-a', rolledGeneration)).not.toThrow() }) - it('wraps the maximum numeric scope without reusing an allocated token', () => { + it('wraps the maximum safe numeric scope without reusing it', () => { resetSshConnectionGenerations(MAX_SESSION_SCOPE) - const lease = retainSshConnectionGeneration('ssh-a') setSshConnectionGeneration('ssh-a', Number.MAX_SAFE_INTEGER) const rolledGeneration = advanceSshConnectionGeneration('ssh-a') expect(rolledGeneration).toBe(1) expect(Number.isSafeInteger(rolledGeneration)).toBe(true) - expect(() => assertSshMutationExpectation('ssh-a', 'ssh-a', rolledGeneration!)).not.toThrow() - lease.release() - }) - - it('rejects only the 4,097th target without changing active or in-flight tokens', () => { - resetSshConnectionGenerations(7) - const leases = Array.from({ length: MAX_TRACKED_SSH_CONNECTION_GENERATIONS }, (_, index) => - retainSshConnectionGeneration(`ssh-${index}`) - ) - const activeGeneration = getSshConnectionGeneration('ssh-0') - const inFlightGeneration = getSshConnectionGeneration( - `ssh-${MAX_TRACKED_SSH_CONNECTION_GENERATIONS - 1}` - ) - - expect(() => retainSshConnectionGeneration('ssh-overflow')).toThrow( - 'SSH connection generation target capacity exhausted' - ) - expect(getSshConnectionGeneration('ssh-0')).toBe(activeGeneration) - expect(getSshConnectionGeneration(`ssh-${MAX_TRACKED_SSH_CONNECTION_GENERATIONS - 1}`)).toBe( - inFlightGeneration - ) - expect(() => assertSshMutationExpectation('ssh-0', 'ssh-0', activeGeneration)).not.toThrow() - - leases[1].release() - const overflowLease = retainSshConnectionGeneration('ssh-overflow') - expect(getSshConnectionGeneration('ssh-0')).toBe(activeGeneration) - for (const lease of leases) { - lease.release() - } - overflowLease.release() - expect(_internals.evidenceForTest()).toEqual({ - retainedGenerationLeases: 0, - retainedTargetIdBytes: 0, - trackedTargets: 0 - }) - }) - - it('keeps an entry until its final ownership lease releases', () => { - const activeLease = retainSshConnectionGeneration('ssh-a') - const inFlightLease = retainSshConnectionGeneration('ssh-a') - const generation = getSshConnectionGeneration('ssh-a') - - activeLease.release() - expect(() => assertSshMutationExpectation('ssh-a', 'ssh-a', generation)).not.toThrow() - - inFlightLease.release() - expect(() => assertSshMutationExpectation('ssh-a', 'ssh-a', generation)).toThrow( + expect(() => assertSshMutationExpectation('ssh-a', 'ssh-a', Number.MAX_SAFE_INTEGER)).toThrow( 'SSH connection changed; refresh and try again' ) }) - - it('never reuses a released target token during sequential churn', () => { - let previous = 0 - for (let index = 0; index < 10_000; index += 1) { - const lease = retainSshConnectionGeneration(`ssh-${index}`) - const generation = getSshConnectionGeneration(`ssh-${index}`) - expect(generation).toBeGreaterThan(previous) - previous = generation - lease.release() - } - - expect(_internals.evidenceForTest()).toEqual({ - retainedGenerationLeases: 0, - retainedTargetIdBytes: 0, - trackedTargets: 0 - }) - }) - - it('bounds outstanding leases for one hung target', () => { - const leases = Array.from({ length: MAX_RETAINED_SSH_CONNECTION_GENERATION_LEASES }, () => - retainSshConnectionGeneration('ssh-a') - ) - const generation = getSshConnectionGeneration('ssh-a') - - expect(() => retainSshConnectionGeneration('ssh-a')).toThrow( - 'SSH connection generation lease capacity exhausted' - ) - expect(getSshConnectionGeneration('ssh-a')).toBe(generation) - expect(() => assertSshMutationExpectation('ssh-a', 'ssh-a', generation)).not.toThrow() - expect(_internals.evidenceForTest()).toEqual({ - retainedGenerationLeases: MAX_RETAINED_SSH_CONNECTION_GENERATION_LEASES, - retainedTargetIdBytes: 5, - trackedTargets: 1 - }) - - for (const lease of leases) { - lease.release() - } - expect(_internals.evidenceForTest()).toEqual({ - retainedGenerationLeases: 0, - retainedTargetIdBytes: 0, - trackedTargets: 0 - }) - }) - - it('bounds individual and aggregate retained target-id bytes', () => { - expect(() => - retainSshConnectionGeneration('x'.repeat(MAX_SSH_CONNECTION_GENERATION_TARGET_ID_BYTES + 1)) - ).toThrow('SSH connection generation target id exceeds') - - const leases = Array.from( - { - length: - MAX_SSH_CONNECTION_GENERATION_RETAINED_TARGET_ID_BYTES / - MAX_SSH_CONNECTION_GENERATION_TARGET_ID_BYTES - }, - (_, index) => { - const prefix = `${index}:` - return retainSshConnectionGeneration( - prefix + 'x'.repeat(MAX_SSH_CONNECTION_GENERATION_TARGET_ID_BYTES - prefix.length) - ) - } - ) - expect(() => retainSshConnectionGeneration('overflow')).toThrow( - 'SSH connection generation target capacity exhausted' - ) - - for (const lease of leases) { - lease.release() - } - expect(_internals.evidenceForTest()).toEqual({ - retainedGenerationLeases: 0, - retainedTargetIdBytes: 0, - trackedTargets: 0 - }) - }) }) diff --git a/src/main/ssh/ssh-connection-generation.ts b/src/main/ssh/ssh-connection-generation.ts index e50285a7992..96f1ae04c83 100644 --- a/src/main/ssh/ssh-connection-generation.ts +++ b/src/main/ssh/ssh-connection-generation.ts @@ -1,184 +1,77 @@ import { randomBytes } from 'node:crypto' import { toSshExecutionHostId } from '../../shared/execution-host' -import { measureUtf8ByteLength } from '../../shared/utf8-byte-limits' const SESSION_COUNTER_BITS = 13 const SESSION_COUNTER_STRIDE = 2 ** SESSION_COUNTER_BITS const MAX_SESSION_SCOPE = 2 ** (53 - SESSION_COUNTER_BITS) - 1 -export const MAX_TRACKED_SSH_CONNECTION_GENERATIONS = 4_096 -export const MAX_RETAINED_SSH_CONNECTION_GENERATION_LEASES = 8_192 -export const MAX_SSH_CONNECTION_GENERATION_TARGET_ID_BYTES = 64 * 1024 -export const MAX_SSH_CONNECTION_GENERATION_RETAINED_TARGET_ID_BYTES = 4 * 1024 * 1024 - -type SshConnectionGenerationEntry = { - generation: number - leaseCount: number - targetIdBytes: number -} - -export type SshConnectionGenerationLease = { - release: () => void -} function createSessionScope(): number { return randomBytes(5).readUIntBE(0, 5) } -let allocationSessionBase = 0 -let allocationSessionCounter = 0 +let sessionGenerationBase = 0 let sessionInitialized = false -let allocatedSessionScopeCount = 1 -let generationRegistryEpoch = 0 -let retainedGenerationLeases = 0 -let retainedTargetIdBytes = 0 -const connectionGenerationByTarget = new Map() +const connectionGenerationByTarget = new Map() +const usedSessionScopes = new Set() -function rotateAllocationSessionScope(): void { - if (allocatedSessionScopeCount >= MAX_SESSION_SCOPE + 1) { +function assertGenerationInCurrentSession(generation: number): void { + if ( + !Number.isSafeInteger(generation) || + generation < sessionGenerationBase || + generation - sessionGenerationBase >= SESSION_COUNTER_STRIDE + ) { throw new Error('SSH connection generation exhausted for this runtime session') } - const nextSessionScope = - (allocationSessionBase / SESSION_COUNTER_STRIDE + 1) % (MAX_SESSION_SCOPE + 1) - allocationSessionBase = nextSessionScope * SESSION_COUNTER_STRIDE - allocationSessionCounter = 0 - allocatedSessionScopeCount += 1 -} - -function allocateGeneration(): number { - if (allocationSessionCounter >= SESSION_COUNTER_STRIDE - 1) { - rotateAllocationSessionScope() - } - allocationSessionCounter += 1 - const generation = allocationSessionBase + allocationSessionCounter - if (!Number.isSafeInteger(generation)) { - throw new Error('SSH connection generation exhausted for this runtime session') - } - return generation -} - -function measureTargetId(targetId: string): number { - const measured = measureUtf8ByteLength(targetId, { - stopAfterBytes: MAX_SSH_CONNECTION_GENERATION_TARGET_ID_BYTES - }) - if (measured.exceededLimit) { - throw new Error( - `SSH connection generation target id exceeds ${MAX_SSH_CONNECTION_GENERATION_TARGET_ID_BYTES} UTF-8 bytes` - ) - } - return measured.byteLength -} - -export function retainSshConnectionGeneration(targetId: string): SshConnectionGenerationLease { - if (retainedGenerationLeases >= MAX_RETAINED_SSH_CONNECTION_GENERATION_LEASES) { - throw new Error('SSH connection generation lease capacity exhausted') - } - let entry = connectionGenerationByTarget.get(targetId) - if (!entry) { - const targetIdBytes = measureTargetId(targetId) - if ( - connectionGenerationByTarget.size >= MAX_TRACKED_SSH_CONNECTION_GENERATIONS || - retainedTargetIdBytes + targetIdBytes > MAX_SSH_CONNECTION_GENERATION_RETAINED_TARGET_ID_BYTES - ) { - throw new Error('SSH connection generation target capacity exhausted') - } - entry = { - generation: allocateGeneration(), - leaseCount: 0, - targetIdBytes - } - connectionGenerationByTarget.set(targetId, entry) - retainedTargetIdBytes += targetIdBytes - } - entry.leaseCount += 1 - retainedGenerationLeases += 1 - const retainedEntry = entry - const retainedEpoch = generationRegistryEpoch - let retained = true - return { - release: () => { - if (!retained) { - return - } - retained = false - retainedEntry.leaseCount -= 1 - if (generationRegistryEpoch === retainedEpoch) { - retainedGenerationLeases -= 1 - } - if ( - retainedEntry.leaseCount === 0 && - connectionGenerationByTarget.get(targetId) === retainedEntry - ) { - connectionGenerationByTarget.delete(targetId) - retainedTargetIdBytes -= retainedEntry.targetIdBytes - } - } - } } export function getSshConnectionGeneration(targetId: string): number { - return connectionGenerationByTarget.get(targetId)?.generation ?? allocationSessionBase + return connectionGenerationByTarget.get(targetId) ?? sessionGenerationBase } export function initializeSshConnectionGenerationSession(): void { if (sessionInitialized) { return } - allocationSessionBase = createSessionScope() * SESSION_COUNTER_STRIDE - allocationSessionCounter = 0 - allocatedSessionScopeCount = 1 + const sessionScope = createSessionScope() + // Why: randomize the process scope so a replacement HUB does not predictably reuse the prior target/counter token. + sessionGenerationBase = sessionScope * SESSION_COUNTER_STRIDE + usedSessionScopes.add(sessionScope) sessionInitialized = true } -export function advanceSshConnectionGeneration(targetId: string): number | null { - const entry = connectionGenerationByTarget.get(targetId) - if (!entry) { - return null +export function advanceSshConnectionGeneration(targetId: string): number { + let next = getSshConnectionGeneration(targetId) + 1 + if (next - sessionGenerationBase >= SESSION_COUNTER_STRIDE) { + let nextSessionScope = + (sessionGenerationBase / SESSION_COUNTER_STRIDE + 1) % (MAX_SESSION_SCOPE + 1) + while (usedSessionScopes.has(nextSessionScope)) { + nextSessionScope = (nextSessionScope + 1) % (MAX_SESSION_SCOPE + 1) + } + usedSessionScopes.add(nextSessionScope) + sessionGenerationBase = nextSessionScope * SESSION_COUNTER_STRIDE + // Why: changing the scope must revoke tokens for every target, not only the target that exhausted its counter. + connectionGenerationByTarget.clear() + next = sessionGenerationBase + 1 } - entry.generation = allocateGeneration() - return entry.generation + assertGenerationInCurrentSession(next) + connectionGenerationByTarget.set(targetId, next) + return next } export function setSshConnectionGeneration(targetId: string, generation: number): void { - const generationOffset = generation - allocationSessionBase - if ( - !Number.isSafeInteger(generation) || - generationOffset < 0 || - generationOffset >= SESSION_COUNTER_STRIDE - ) { - throw new Error('SSH connection generation exhausted for this runtime session') - } - let entry = connectionGenerationByTarget.get(targetId) - if (!entry) { - const targetIdBytes = measureTargetId(targetId) - if ( - retainedGenerationLeases >= MAX_RETAINED_SSH_CONNECTION_GENERATION_LEASES || - connectionGenerationByTarget.size >= MAX_TRACKED_SSH_CONNECTION_GENERATIONS || - retainedTargetIdBytes + targetIdBytes > MAX_SSH_CONNECTION_GENERATION_RETAINED_TARGET_ID_BYTES - ) { - throw new Error('SSH connection generation target capacity exhausted') - } - entry = { generation, leaseCount: 1, targetIdBytes } - connectionGenerationByTarget.set(targetId, entry) - retainedGenerationLeases += 1 - retainedTargetIdBytes += targetIdBytes - } else { - entry.generation = generation - } - allocationSessionCounter = Math.max(allocationSessionCounter, generationOffset) + assertGenerationInCurrentSession(generation) + connectionGenerationByTarget.set(targetId, generation) } export function resetSshConnectionGenerations(sessionScope = 0): void { if (!Number.isSafeInteger(sessionScope) || sessionScope < 0 || sessionScope > MAX_SESSION_SCOPE) { throw new Error('Invalid SSH connection generation session scope') } - allocationSessionBase = sessionScope * SESSION_COUNTER_STRIDE - allocationSessionCounter = 0 + sessionGenerationBase = sessionScope * SESSION_COUNTER_STRIDE sessionInitialized = true - allocatedSessionScopeCount = 1 - generationRegistryEpoch += 1 - retainedGenerationLeases = 0 - retainedTargetIdBytes = 0 connectionGenerationByTarget.clear() + usedSessionScopes.clear() + usedSessionScopes.add(sessionScope) } export function assertSshMutationExpectation( @@ -198,27 +91,11 @@ export function assertSshMutationExpectation( } return } - const current = connectionGenerationByTarget.get(connectionId) if ( expectedTargetId !== connectionId || expectedGeneration === undefined || - !current || - expectedGeneration !== current.generation + expectedGeneration !== getSshConnectionGeneration(connectionId) ) { throw new Error('SSH connection changed; refresh and try again') } } - -export const _internals = { - evidenceForTest(): { - retainedGenerationLeases: number - retainedTargetIdBytes: number - trackedTargets: number - } { - return { - retainedGenerationLeases, - retainedTargetIdBytes, - trackedTargets: connectionGenerationByTarget.size - } - } -} diff --git a/src/main/ssh/ssh-connection-utils.test.ts b/src/main/ssh/ssh-connection-utils.test.ts index 8ff44e65787..50f2eb66ff2 100644 --- a/src/main/ssh/ssh-connection-utils.test.ts +++ b/src/main/ssh/ssh-connection-utils.test.ts @@ -24,11 +24,8 @@ function testHomePath(...parts: string[]): string { } vi.mock('fs', () => ({ - existsSync: (...args: unknown[]) => mockExistsSync(...args) -})) - -vi.mock('./ssh-key-file', () => ({ - readSshKeyFile: (...args: unknown[]) => mockReadFileSync(...args) + existsSync: (...args: unknown[]) => mockExistsSync(...args), + readFileSync: (...args: unknown[]) => mockReadFileSync(...args) })) import { @@ -54,10 +51,7 @@ import type { SshResolvedConfig } from './ssh-config-parser' type MockProxyProcess = EventEmitter & { stdin: EventEmitter & { write: ReturnType } - stdout: EventEmitter & { - pause: ReturnType - resume: ReturnType - } + stdout: EventEmitter stderr: EventEmitter } @@ -66,10 +60,7 @@ function createMockProxyProcess(): MockProxyProcess { proc.stdin = Object.assign(new EventEmitter(), { write: vi.fn((_chunk, cb?: (error?: Error | null) => void) => cb?.()) }) - proc.stdout = Object.assign(new EventEmitter(), { - pause: vi.fn(), - resume: vi.fn() - }) + proc.stdout = new EventEmitter() proc.stderr = new EventEmitter() return proc } @@ -732,23 +723,4 @@ describe('spawnProxyCommand', () => { expect(proc.stdin.listenerCount('error')).toBe(0) expect(proc.listenerCount('error')).toBe(0) }) - - it('pauses proxy stdout at the socket high-water mark and resumes on demand', async () => { - const proc = createMockProxyProcess() - spawnMock.mockReturnValue(proc) - const { sock } = spawnProxyCommand( - { kind: 'jump-host', jumpHost: 'bastion.example.com' }, - 'target.example.com', - 22, - 'deploy' - ) - - proc.stdout.emit('data', Buffer.alloc(64 * 1024)) - - expect(proc.stdout.pause).toHaveBeenCalledOnce() - sock.on('data', () => {}) - await new Promise((resolve) => setImmediate(resolve)) - expect(proc.stdout.resume).toHaveBeenCalled() - sock.destroy() - }) }) diff --git a/src/main/ssh/ssh-connection-utils.ts b/src/main/ssh/ssh-connection-utils.ts index d51cda25bbb..de9e0b6c63f 100644 --- a/src/main/ssh/ssh-connection-utils.ts +++ b/src/main/ssh/ssh-connection-utils.ts @@ -311,9 +311,7 @@ export function spawnProxyCommand( proc.off('error', onProcessError) } const onStdoutData = (data: Buffer): void => { - if (!stream.push(data)) { - proc.stdout!.pause() - } + stream.push(data) } const onStdoutEnd = (): void => { stream.push(null) @@ -325,9 +323,7 @@ export function spawnProxyCommand( stream.destroy(err) } const stream = new Duplex({ - read() { - proc.stdout!.resume() - }, + read() {}, write(chunk, _encoding, cb) { proc.stdin!.write(chunk, cb) }, diff --git a/src/main/ssh/ssh-connection.test.ts b/src/main/ssh/ssh-connection.test.ts index 77df52a0677..750932289d8 100644 --- a/src/main/ssh/ssh-connection.test.ts +++ b/src/main/ssh/ssh-connection.test.ts @@ -168,7 +168,6 @@ import { } from './ssh-system-fallback' import { getRemoteHostPlatform } from './ssh-remote-platform' import type { SshTarget } from '../../shared/ssh-types' -import { SSH_CONNECTION_ERROR_MAX_UTF8_BYTES } from '../../shared/ssh-retained-payload-admission' function createTarget(overrides?: Partial): SshTarget { return { @@ -437,16 +436,6 @@ describe('SshConnection', () => { expect(conn.getState().status).toBe('error') }) - it('does not retain an oversized provider error in connection state', async () => { - connectBehavior = 'error' - connectErrorMessage = 'x'.repeat(SSH_CONNECTION_ERROR_MAX_UTF8_BYTES + 100) - const conn = new SshConnection(createTarget(), createCallbacks()) - - await conn.connect().catch(() => undefined) - - expect(conn.getState().error).toHaveLength(SSH_CONNECTION_ERROR_MAX_UTF8_BYTES) - }) - it('guards late ssh2 errors emitted while destroying a failed startup client', async () => { connectBehavior = 'error' connectErrorMessage = 'Connection lost before handshake' diff --git a/src/main/ssh/ssh-connection.ts b/src/main/ssh/ssh-connection.ts index b24d5a35415..baba633d79d 100644 --- a/src/main/ssh/ssh-connection.ts +++ b/src/main/ssh/ssh-connection.ts @@ -5,7 +5,6 @@ import { Client as SshClient } from 'ssh2' import type { ChildProcess } from 'node:child_process' import type { ClientChannel, ConnectConfig, SFTPWrapper } from 'ssh2' import type { SshTarget, SshConnectionState, SshConnectionStatus } from '../../shared/ssh-types' -import { clampSshConnectionError } from '../../shared/ssh-retained-payload-admission' import { getOrcaControlSocketPath, spawnSystemSsh, @@ -47,7 +46,6 @@ import { createLinkedSshFileTransferSignal, raceSftpFileTransferWithAbort } from './ssh-file-transfer-abort' -import { SystemSshOutputTail } from './system-ssh-output-tail' export type { SshConnectionCallbacks } from './ssh-connection-utils' type SshRemoteFileOptions = { @@ -815,8 +813,8 @@ export class SshConnection { }) try { await new Promise((resolve, reject) => { - const stdout = new SystemSshOutputTail() - const stderr = new SystemSshOutputTail() + let stdout = '' + let stderr = '' let settled = false const cleanup = (): void => { clearTimeout(timeout) @@ -835,10 +833,10 @@ export class SshConnection { callback() } const onStdoutData = (data: Buffer): void => { - stdout.push(data) + stdout += data.toString('utf-8') } const onStderrData = (data: Buffer): void => { - stderr.push(data) + stderr += data.toString('utf-8') } const onError = (err: Error): void => { settle(() => reject(err)) @@ -849,11 +847,10 @@ export class SshConnection { reject(new Error('SSH connection attempt was cancelled')) return } - if (code !== 0 || !stdout.toString().includes('ORCA-SYSTEM-SSH-OK')) { - const stderrText = stderr.toString() + if (code !== 0 || !stdout.includes('ORCA-SYSTEM-SSH-OK')) { reject( new Error( - `System SSH probe failed${code != null ? ` (exit ${code})` : ''}.${stderrText ? ` stderr: ${stderrText.trim()}` : ''}` + `System SSH probe failed${code != null ? ` (exit ${code})` : ''}.${stderr ? ` stderr: ${stderr.trim()}` : ''}` ) ) return @@ -1330,7 +1327,7 @@ export class SshConnection { this.state = { ...this.state, status, - error: clampSshConnectionError(error ?? null), + error: error ?? null, supportsFolderDownload: status === 'connected' && !this.useSystemSshTransport } this.callbacks.onStateChange(this.target.id, { ...this.state }) diff --git a/src/main/ssh/ssh-directory-transfer-budget.test.ts b/src/main/ssh/ssh-directory-transfer-budget.test.ts deleted file mode 100644 index 0e2c94594f2..00000000000 --- a/src/main/ssh/ssh-directory-transfer-budget.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - SshDirectoryTransferBudget, - type SshDirectoryTransferLimits -} from './ssh-directory-transfer-budget' - -const limits: SshDirectoryTransferLimits = { - maximumEntries: 2, - maximumDepth: 2, - maximumPathBytes: 4, - maximumRetainedPathBytes: 8, - maximumFileBytes: 4, - maximumTotalFileBytes: 8 -} - -describe('SshDirectoryTransferBudget', () => { - it('accepts every exact boundary', () => { - const budget = new SshDirectoryTransferBudget(limits) - - budget.recordPath('1234', 2) - budget.recordPath('5678', 2) - budget.recordFile(4) - budget.recordFile(4) - }) - - it.each([ - ['entries', (budget: SshDirectoryTransferBudget) => budget.recordPath('', 0)], - ['depth', (budget: SshDirectoryTransferBudget) => budget.recordPath('', 3)], - ['path', (budget: SshDirectoryTransferBudget) => budget.recordPath('12345', 0)], - [ - 'paths', - (budget: SshDirectoryTransferBudget) => budget.recordPath('1', 0, { countEntry: false }) - ], - ['file', (budget: SshDirectoryTransferBudget) => budget.recordFile(5)], - ['files', (budget: SshDirectoryTransferBudget) => budget.recordFile(1)] - ] as const)('rejects one unit beyond the %s boundary', (reason, exceed) => { - const budget = new SshDirectoryTransferBudget(limits) - if (reason === 'entries') { - budget.recordPath('', 0) - budget.recordPath('', 0) - } else if (reason === 'paths') { - budget.recordPath('1234', 0, { countEntry: false }) - budget.recordPath('5678', 0, { countEntry: false }) - } else if (reason === 'files') { - budget.recordFile(4) - budget.recordFile(4) - } - - expect(() => exceed(budget)).toThrow(expect.objectContaining({ reason })) - }) -}) diff --git a/src/main/ssh/ssh-directory-transfer-budget.ts b/src/main/ssh/ssh-directory-transfer-budget.ts deleted file mode 100644 index 18a965b06d5..00000000000 --- a/src/main/ssh/ssh-directory-transfer-budget.ts +++ /dev/null @@ -1,64 +0,0 @@ -export type SshDirectoryTransferLimits = { - maximumEntries: number - maximumDepth: number - maximumPathBytes: number - maximumRetainedPathBytes: number - maximumFileBytes: number - maximumTotalFileBytes: number -} - -export const SSH_DIRECTORY_TRANSFER_LIMITS: SshDirectoryTransferLimits = { - maximumEntries: 4_096, - maximumDepth: 64, - maximumPathBytes: 16 * 1024, - maximumRetainedPathBytes: 4 * 1024 * 1024, - maximumFileBytes: 16 * 1024 * 1024, - maximumTotalFileBytes: 32 * 1024 * 1024 -} - -export const WINDOWS_SSH_UPLOAD_PACKAGE_MAX_BYTES = 48 * 1024 * 1024 - -export class SshDirectoryTransferCapacityError extends Error { - constructor(readonly reason: 'entries' | 'depth' | 'path' | 'paths' | 'file' | 'files') { - super(`SSH directory transfer exceeds the ${reason} limit`) - this.name = 'SshDirectoryTransferCapacityError' - } -} - -export class SshDirectoryTransferBudget { - private entries = 0 - private retainedPathBytes = 0 - private totalFileBytes = 0 - - constructor(private readonly limits = SSH_DIRECTORY_TRANSFER_LIMITS) {} - - recordPath(path: string, depth: number, options?: { countEntry?: boolean }): void { - if (depth > this.limits.maximumDepth) { - throw new SshDirectoryTransferCapacityError('depth') - } - if (options?.countEntry !== false) { - this.entries += 1 - if (this.entries > this.limits.maximumEntries) { - throw new SshDirectoryTransferCapacityError('entries') - } - } - const pathBytes = Buffer.byteLength(path, 'utf8') - if (pathBytes > this.limits.maximumPathBytes) { - throw new SshDirectoryTransferCapacityError('path') - } - this.retainedPathBytes += pathBytes - if (this.retainedPathBytes > this.limits.maximumRetainedPathBytes) { - throw new SshDirectoryTransferCapacityError('paths') - } - } - - recordFile(fileBytes: number): void { - if (fileBytes > this.limits.maximumFileBytes) { - throw new SshDirectoryTransferCapacityError('file') - } - this.totalFileBytes += fileBytes - if (this.totalFileBytes > this.limits.maximumTotalFileBytes) { - throw new SshDirectoryTransferCapacityError('files') - } - } -} diff --git a/src/main/ssh/ssh-file-stream-assembler.ts b/src/main/ssh/ssh-file-stream-assembler.ts deleted file mode 100644 index 4a40c6adf21..00000000000 --- a/src/main/ssh/ssh-file-stream-assembler.ts +++ /dev/null @@ -1,196 +0,0 @@ -import type { FileReadResult } from '../providers/types' -import { RelayErrorCode, STREAM_CHUNK_SIZE } from './relay-protocol' -import { - defaultSshStreamAssemblyBudget, - type SshStreamAssemblyBudget, - base64EncodedLength -} from './ssh-stream-reader-memory' -import type { RasterImageDimensions } from '../../shared/raster-image-dimensions' -import { isRasterImagePreviewDimensions } from '../../shared/raster-image-preview-limits' - -const MAX_PREVIEWABLE_BINARY_SIZE = 50 * 1024 * 1024 -const MAX_TEXT_FILE_SIZE = 10 * 1024 * 1024 - -type FileStreamMetadata = { - streamId?: number - totalSize: number - isBinary: boolean - isImage?: boolean - mimeType?: string - imageDimensions?: RasterImageDimensions - resultEncoding?: 'base64' | 'utf-8' - empty?: boolean -} - -export class StreamProtocolError extends Error { - readonly code = RelayErrorCode.StreamProtocolError - constructor(message: string) { - super(message) - } -} - -export type FileStreamSetup = - | { kind: 'empty'; result: FileReadResult } - | { kind: 'stream'; streamId: number; assembler: FileStreamAssembler } - -export function createFileStreamSetup( - rawMetadata: unknown, - assemblyBudget: SshStreamAssemblyBudget = defaultSshStreamAssemblyBudget -): FileStreamSetup { - const metadata = rawMetadata as FileStreamMetadata - if ( - typeof metadata !== 'object' || - metadata === null || - typeof metadata.isBinary !== 'boolean' || - !Number.isSafeInteger(metadata.totalSize) || - (metadata.empty !== undefined && typeof metadata.empty !== 'boolean') || - (metadata.isImage !== undefined && typeof metadata.isImage !== 'boolean') || - (metadata.mimeType !== undefined && typeof metadata.mimeType !== 'string') || - (metadata.imageDimensions !== undefined && - !isRasterImagePreviewDimensions(metadata.imageDimensions)) || - (metadata.resultEncoding !== undefined && - metadata.resultEncoding !== 'base64' && - metadata.resultEncoding !== 'utf-8') - ) { - throw new StreamProtocolError('Malformed file stream metadata') - } - if (metadata.empty) { - return { - kind: 'empty', - result: { - content: '', - isBinary: metadata.isBinary, - ...(metadata.isImage !== undefined ? { isImage: metadata.isImage } : {}), - ...(metadata.mimeType !== undefined ? { mimeType: metadata.mimeType } : {}), - ...(metadata.imageDimensions !== undefined - ? { imageDimensions: metadata.imageDimensions } - : {}) - } - } - } - if ( - typeof metadata.streamId !== 'number' || - !Number.isSafeInteger(metadata.streamId) || - metadata.streamId <= 0 - ) { - throw new StreamProtocolError('Metadata missing streamId for non-empty stream') - } - const cap = metadata.isBinary ? MAX_PREVIEWABLE_BINARY_SIZE : MAX_TEXT_FILE_SIZE - if (metadata.totalSize < 0 || metadata.totalSize > cap) { - throw new StreamProtocolError( - `Reported totalSize ${metadata.totalSize} exceeds client cap ${cap}` - ) - } - return { - kind: 'stream', - streamId: metadata.streamId, - assembler: new FileStreamAssembler(metadata, assemblyBudget) - } -} - -export class FileStreamAssembler { - private buffer: Buffer | null - private expectedSeq = 0 - private bytesReceived = 0 - private readonly totalChunks: number - private releaseBudget: (() => void) | null - - constructor( - private readonly metadata: FileStreamMetadata, - assemblyBudget: SshStreamAssemblyBudget - ) { - this.releaseBudget = assemblyBudget.reserve(metadata.totalSize) - if (!this.releaseBudget) { - throw new StreamProtocolError( - `Active SSH stream assembly would exceed ${assemblyBudget.maxBytes} bytes` - ) - } - try { - this.buffer = Buffer.alloc(metadata.totalSize) - } catch (error) { - this.releaseMemory() - throw new StreamProtocolError( - `Failed to allocate ${metadata.totalSize} bytes: ${(error as Error).message}` - ) - } - this.totalChunks = - metadata.totalSize === 0 ? 0 : Math.ceil(metadata.totalSize / STREAM_CHUNK_SIZE) - } - - acceptChunk(params: Record, streamId: number): number { - const seq = params.seq - const data = params.data - if (typeof seq !== 'number' || !Number.isSafeInteger(seq) || typeof data !== 'string') { - throw new StreamProtocolError(`Malformed chunk for stream ${streamId}`) - } - if (seq !== this.expectedSeq) { - throw new StreamProtocolError( - `Out-of-order chunk for stream ${streamId}: expected ${this.expectedSeq}, got ${seq}` - ) - } - if (seq >= this.totalChunks) { - throw new StreamProtocolError( - `File stream ${streamId} exceeded declared chunk count ${this.totalChunks}` - ) - } - const offset = seq * STREAM_CHUNK_SIZE - const expectedLength = Math.min(STREAM_CHUNK_SIZE, this.metadata.totalSize - offset) - if (expectedLength < 0 || data.length !== base64EncodedLength(expectedLength)) { - throw new StreamProtocolError( - `Encoded chunk length mismatch for stream ${streamId}: seq=${seq}` - ) - } - const decoded = Buffer.from(data, 'base64') - if (decoded.length !== expectedLength) { - throw new StreamProtocolError( - `Chunk length mismatch for stream ${streamId}: seq=${seq} expected=${expectedLength} got=${decoded.length}` - ) - } - if (!this.buffer) { - throw new StreamProtocolError(`Chunk arrived after completion for stream ${streamId}`) - } - decoded.copy(this.buffer, offset) - this.expectedSeq += 1 - this.bytesReceived += decoded.length - return seq - } - - finish(streamId: number): FileReadResult { - if (this.expectedSeq !== this.totalChunks || this.bytesReceived !== this.metadata.totalSize) { - throw new StreamProtocolError( - `File stream ${streamId} incomplete: chunks ${this.expectedSeq}/${this.totalChunks}, bytes ${this.bytesReceived}/${this.metadata.totalSize}` - ) - } - if (!this.buffer) { - throw new StreamProtocolError(`Stream ${streamId} already completed`) - } - const buffer = this.buffer - this.buffer = null - try { - return { - content: - (this.metadata.resultEncoding ?? 'base64') === 'base64' - ? buffer.toString('base64') - : buffer.toString('utf-8'), - isBinary: this.metadata.isBinary, - ...(this.metadata.isImage !== undefined ? { isImage: this.metadata.isImage } : {}), - ...(this.metadata.mimeType !== undefined ? { mimeType: this.metadata.mimeType } : {}), - ...(this.metadata.imageDimensions !== undefined - ? { imageDimensions: this.metadata.imageDimensions } - : {}) - } - } finally { - this.releaseMemory() - } - } - - release(): void { - this.buffer = null - this.releaseMemory() - } - - private releaseMemory(): void { - this.releaseBudget?.() - this.releaseBudget = null - } -} diff --git a/src/main/ssh/ssh-filesystem-stream-reader.ts b/src/main/ssh/ssh-filesystem-stream-reader.ts index d05089abf80..2147ed0ef4f 100644 --- a/src/main/ssh/ssh-filesystem-stream-reader.ts +++ b/src/main/ssh/ssh-filesystem-stream-reader.ts @@ -1,25 +1,23 @@ import type { SshChannelMultiplexer } from './ssh-channel-multiplexer' -import { JsonRpcErrorCode } from './relay-protocol' +import { STREAM_CHUNK_SIZE, JsonRpcErrorCode, RelayErrorCode } from './relay-protocol' import type { FileReadResult } from '../providers/types' -import { - PreMetadataStreamFrameBuffer, - STREAM_READER_INACTIVITY_TIMEOUT_MS, - defaultSshPreMetadataStreamBudget, - defaultSshStreamAssemblyBudget, - type SshPreMetadataStreamBudget, - type SshStreamAssemblyBudget, - createStreamInactivityDeadline -} from './ssh-stream-reader-memory' -import { - createFileStreamSetup, - type FileStreamAssembler, - StreamProtocolError -} from './ssh-file-stream-assembler' - -export { StreamProtocolError } from './ssh-file-stream-assembler' +const RESULT_ENCODING_BASE64 = 'base64' const SENTINEL_STREAM_ID = -1 +const MAX_PREVIEWABLE_BINARY_SIZE = 50 * 1024 * 1024 +const MAX_TEXT_FILE_SIZE = 10 * 1024 * 1024 + +type StreamMetadataResponse = { + streamId?: number + totalSize: number + isBinary: boolean + isImage?: boolean + mimeType?: string + resultEncoding?: 'base64' | 'utf-8' + empty?: boolean +} + export function isMethodNotFoundError(err: unknown): boolean { if (!err || typeof err !== 'object') { return false @@ -28,12 +26,16 @@ export function isMethodNotFoundError(err: unknown): boolean { return code === JsonRpcErrorCode.MethodNotFound } +export class StreamProtocolError extends Error { + readonly code = RelayErrorCode.StreamProtocolError + constructor(message: string) { + super(message) + } +} + export async function readFileViaStream( mux: SshChannelMultiplexer, - filePath: string, - options?: { inactivityTimeoutMs?: number }, - assemblyBudget: SshStreamAssemblyBudget = defaultSshStreamAssemblyBudget, - preMetadataBudget: SshPreMetadataStreamBudget = defaultSshPreMetadataStreamBudget + filePath: string ): Promise { // Why: subscribe BEFORE awaiting the metadata response so a chunk arriving // immediately after the response cannot beat the listener registration. @@ -53,44 +55,42 @@ export async function readFileViaStream( } return new Promise((resolve, reject) => { - let assembler: FileStreamAssembler | null = null + let buffer: Buffer | null = null + let resultEncoding: 'base64' | 'utf-8' = RESULT_ENCODING_BASE64 + let isBinary = false + let isImage: boolean | undefined + let mimeType: string | undefined + let totalSize = 0 + let expectedSeq = 0 + let receivedChunks = 0 + let totalChunks = 0 + let bytesReceived = 0 let settled = false // Why: chunk/end/error frames may arrive in the same dispatch tick as the // metadata response. Queue them until streamIdRef is set, then drain. - const pending = new PreMetadataStreamFrameBuffer(preMetadataBudget) + type PendingFrame = + | { kind: 'chunk'; params: Record } + | { kind: 'end'; params: Record } + | { kind: 'error'; params: Record } + const pending: PendingFrame[] = [] let metadataReady = false - const inactivityMs = options?.inactivityTimeoutMs ?? STREAM_READER_INACTIVITY_TIMEOUT_MS - const inactivity = createStreamInactivityDeadline(inactivityMs, () => { - fail(new StreamProtocolError(`File stream stalled (>${inactivityMs}ms without data)`)) - }) - - const cancelStreamId = (streamId: number): void => { - if (!mux.isDisposed()) { + const cancel = (): void => { + if (streamIdRef.current !== SENTINEL_STREAM_ID && !mux.isDisposed()) { try { - mux.notify('fs.cancelStream', { streamId }) + mux.notify('fs.cancelStream', { streamId: streamIdRef.current }) } catch { // Best-effort } } } - const cancel = (): void => { - if (streamIdRef.current !== SENTINEL_STREAM_ID) { - cancelStreamId(streamIdRef.current) - } - } - const fail = (err: Error): void => { if (settled) { return } settled = true - inactivity.clear() - pending.clear() - assembler?.release() - assembler = null cancel() cleanup() reject(err) @@ -101,8 +101,6 @@ export async function readFileViaStream( return } settled = true - inactivity.clear() - pending.clear() cleanup() resolve(value) } @@ -115,26 +113,45 @@ export async function readFileViaStream( if (id !== streamIdRef.current) { return } - if (!assembler) { + const seq = params.seq as number + const data = params.data as string + if (typeof seq !== 'number' || typeof data !== 'string') { + fail(new StreamProtocolError(`Malformed chunk for stream ${id}`)) + return + } + if (seq !== expectedSeq) { + fail( + new StreamProtocolError( + `Out-of-order chunk for stream ${id}: expected ${expectedSeq}, got ${seq}` + ) + ) + return + } + const offset = seq * STREAM_CHUNK_SIZE + const decoded = Buffer.from(data, 'base64') + // Why: a short chunk would leave the pre-allocated buffer zero-filled and + // resolve as silently-corrupt data; validate each chunk's exact length. + const expectedLength = Math.min(STREAM_CHUNK_SIZE, totalSize - offset) + if (decoded.length !== expectedLength) { + fail( + new StreamProtocolError( + `Chunk length mismatch for stream ${id}: seq=${seq} expected=${expectedLength} got=${decoded.length}` + ) + ) + return + } + if (!buffer) { fail(new StreamProtocolError(`Chunk arrived before metadata for stream ${id}`)) return } - let seq: number - try { - seq = assembler.acceptChunk(params, id) - } catch (error) { - fail(error as Error) - return - } - inactivity.reset() + decoded.copy(buffer, offset) + expectedSeq += 1 + receivedChunks += 1 + bytesReceived += decoded.length // Why: credit-based flow control — the relay caps unacked chunks so bulk // stream frames cannot queue unbounded ahead of interactive pty.data // frames on the shared SSH channel. Old relays ignore this notification. - try { - mux.notify('fs.streamAck', { streamId: id, seq }) - } catch { - // Disposal can race the write; teardown will settle the reader. - } + mux.notify('fs.streamAck', { streamId: id, seq }) } const handleEnd = (params: Record): void => { @@ -145,15 +162,38 @@ export async function readFileViaStream( if (id !== streamIdRef.current) { return } - if (!assembler) { + if (receivedChunks !== totalChunks) { + fail( + new StreamProtocolError( + `Chunk count mismatch for stream ${id}: expected ${totalChunks}, received ${receivedChunks}` + ) + ) + return + } + // Why: redundant given the per-chunk length + count checks, but kept as a + // last-line invariant guard; never resolve with fewer bytes than declared. + if (bytesReceived !== totalSize) { + fail( + new StreamProtocolError( + `Byte count mismatch for stream ${id}: expected ${totalSize}, received ${bytesReceived}` + ) + ) + return + } + if (!buffer) { fail(new StreamProtocolError(`Stream end before metadata for stream ${id}`)) return } - try { - succeed(assembler.finish(id)) - } catch (error) { - fail(error as Error) - } + const content = + resultEncoding === RESULT_ENCODING_BASE64 + ? buffer.toString('base64') + : buffer.toString('utf-8') + succeed({ + content, + isBinary, + ...(isImage !== undefined ? { isImage } : {}), + ...(mimeType !== undefined ? { mimeType } : {}) + }) } const handleStreamError = (params: Record): void => { @@ -173,10 +213,7 @@ export async function readFileViaStream( const drainPending = (): void => { while (!settled && pending.length > 0) { - const frame = pending.shift() - if (!frame) { - break - } + const frame = pending.shift()! if (frame.kind === 'chunk') { handleChunk(frame.params) } else if (frame.kind === 'end') { @@ -187,18 +224,10 @@ export async function readFileViaStream( } } - const pushPending = ( - kind: 'chunk' | 'end' | 'error', - params: Record - ): void => { - // Why: the stream id is unknown here; overload drops must not let one foreign frame fail every reader. - pending.push({ kind, params }) - } - unsubscribers.push( mux.onNotificationByMethod('fs.streamChunk', (params) => { if (!metadataReady) { - pushPending('chunk', params) + pending.push({ kind: 'chunk', params }) return } handleChunk(params) @@ -207,7 +236,7 @@ export async function readFileViaStream( unsubscribers.push( mux.onNotificationByMethod('fs.streamEnd', (params) => { if (!metadataReady) { - pushPending('end', params) + pending.push({ kind: 'end', params }) return } handleEnd(params) @@ -216,7 +245,7 @@ export async function readFileViaStream( unsubscribers.push( mux.onNotificationByMethod('fs.streamError', (params) => { if (!metadataReady) { - pushPending('error', params) + pending.push({ kind: 'error', params }) return } handleStreamError(params) @@ -240,31 +269,51 @@ export async function readFileViaStream( .request('fs.readFileStream', { filePath, flowControl: 'ack' }) .then((rawMetadata) => { if (settled) { - const streamId = (rawMetadata as { streamId?: unknown } | null)?.streamId - if (typeof streamId === 'number') { - cancelStreamId(streamId) - } return } - let setup + const metadata = rawMetadata as StreamMetadataResponse + isBinary = metadata.isBinary + isImage = metadata.isImage + mimeType = metadata.mimeType + resultEncoding = metadata.resultEncoding ?? RESULT_ENCODING_BASE64 + + if (metadata.empty) { + succeed({ + content: '', + isBinary: metadata.isBinary, + ...(metadata.isImage !== undefined ? { isImage: metadata.isImage } : {}), + ...(metadata.mimeType !== undefined ? { mimeType: metadata.mimeType } : {}) + }) + return + } + + if (typeof metadata.streamId !== 'number') { + fail(new StreamProtocolError('Metadata missing streamId for non-empty stream')) + return + } + + const cap = metadata.isBinary ? MAX_PREVIEWABLE_BINARY_SIZE : MAX_TEXT_FILE_SIZE + if (metadata.totalSize < 0 || metadata.totalSize > cap) { + streamIdRef.current = metadata.streamId + fail( + new StreamProtocolError( + `Reported totalSize ${metadata.totalSize} exceeds client cap ${cap}` + ) + ) + return + } + + totalSize = metadata.totalSize + totalChunks = totalSize === 0 ? 0 : Math.ceil(totalSize / STREAM_CHUNK_SIZE) try { - setup = createFileStreamSetup(rawMetadata, assemblyBudget) - } catch (error) { - const streamId = (rawMetadata as { streamId?: unknown } | null)?.streamId - if (typeof streamId === 'number') { - streamIdRef.current = streamId - } - fail(error as Error) + buffer = Buffer.alloc(totalSize) + } catch (err) { + streamIdRef.current = metadata.streamId + fail(new Error(`Failed to allocate ${totalSize} bytes: ${(err as Error).message}`)) return } - if (setup.kind === 'empty') { - succeed(setup.result) - return - } - streamIdRef.current = setup.streamId - assembler = setup.assembler + streamIdRef.current = metadata.streamId metadataReady = true - inactivity.reset() drainPending() }) .catch((err) => { diff --git a/src/main/ssh/ssh-git-response-stream-assembler.ts b/src/main/ssh/ssh-git-response-stream-assembler.ts deleted file mode 100644 index 7153826ca80..00000000000 --- a/src/main/ssh/ssh-git-response-stream-assembler.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { RelayErrorCode } from './relay-protocol' -import { type SshStreamAssemblyBudget, base64EncodedLength } from './ssh-stream-reader-memory' - -export class GitResponseStreamError extends Error { - readonly code = RelayErrorCode.StreamProtocolError - constructor(message: string) { - super(message) - } -} - -export class GitResponseStreamAssembler { - private buffer: Buffer | null - private expectedSeq = 0 - private receivedBytes = 0 - private releaseBudget: (() => void) | null - - constructor( - private readonly streamId: number, - private readonly totalBytes: number, - private readonly chunkCount: number, - assemblyBudget: SshStreamAssemblyBudget - ) { - this.releaseBudget = assemblyBudget.reserve(totalBytes) - if (!this.releaseBudget) { - throw new GitResponseStreamError( - `Active SSH stream assembly would exceed ${assemblyBudget.maxBytes} bytes` - ) - } - try { - this.buffer = Buffer.alloc(totalBytes) - } catch (error) { - this.release() - throw new GitResponseStreamError( - `Failed to allocate ${totalBytes} bytes: ${(error as Error).message}` - ) - } - } - - acceptChunk(params: Record): number { - const seq = params.seq - const data = params.data - if ( - typeof seq !== 'number' || - !Number.isSafeInteger(seq) || - seq < 0 || - typeof data !== 'string' - ) { - throw new GitResponseStreamError(`Malformed chunk for git stream ${this.streamId}`) - } - if (seq !== this.expectedSeq) { - throw new GitResponseStreamError( - `Out-of-order chunk for git stream ${this.streamId}: expected ${this.expectedSeq}, got ${seq}` - ) - } - if (this.expectedSeq >= this.chunkCount) { - throw new GitResponseStreamError( - `Git stream ${this.streamId} exceeded declared chunk count ${this.chunkCount}` - ) - } - const remainingBytes = this.totalBytes - this.receivedBytes - if (data.length > base64EncodedLength(remainingBytes)) { - throw new GitResponseStreamError( - `Git stream ${this.streamId} chunk exceeds ${remainingBytes} remaining bytes` - ) - } - const decoded = Buffer.from(data, 'base64') - if (decoded.length > remainingBytes) { - throw new GitResponseStreamError( - `Git stream ${this.streamId} decoded beyond its declared size` - ) - } - if (!this.buffer) { - throw new GitResponseStreamError( - `Chunk arrived after completion for git stream ${this.streamId}` - ) - } - decoded.copy(this.buffer, this.receivedBytes) - this.receivedBytes += decoded.length - this.expectedSeq += 1 - return seq - } - - finish(): unknown { - if (this.expectedSeq !== this.chunkCount || this.receivedBytes !== this.totalBytes) { - throw new GitResponseStreamError( - `Git stream ${this.streamId} incomplete: chunks ${this.expectedSeq}/${this.chunkCount}, bytes ${this.receivedBytes}/${this.totalBytes}` - ) - } - if (!this.buffer) { - throw new GitResponseStreamError(`Git stream ${this.streamId} already completed`) - } - const serialized = this.buffer.toString('utf-8') - this.release() - try { - return JSON.parse(serialized) - } catch (error) { - throw new GitResponseStreamError( - `Git stream ${this.streamId} JSON parse failed: ${String(error)}` - ) - } - } - - release(): void { - this.buffer = null - this.releaseBudget?.() - this.releaseBudget = null - } -} diff --git a/src/main/ssh/ssh-git-response-stream-reader.ts b/src/main/ssh/ssh-git-response-stream-reader.ts index 94f9d5b8228..ef63c7a6dc0 100644 --- a/src/main/ssh/ssh-git-response-stream-reader.ts +++ b/src/main/ssh/ssh-git-response-stream-reader.ts @@ -1,26 +1,31 @@ import type { SshChannelMultiplexer } from './ssh-channel-multiplexer' -import { - MAX_GIT_RESPONSE_STREAM_BYTES, - MAX_GIT_RESPONSE_STREAM_CHUNKS, - isGitResponseStreamMarker -} from './relay-protocol' -import { - PreMetadataStreamFrameBuffer, - STREAM_READER_INACTIVITY_TIMEOUT_MS, - defaultSshPreMetadataStreamBudget, - defaultSshStreamAssemblyBudget, - type SshPreMetadataStreamBudget, - type SshStreamAssemblyBudget, - createStreamInactivityDeadline -} from './ssh-stream-reader-memory' -import { - GitResponseStreamAssembler, - GitResponseStreamError -} from './ssh-git-response-stream-assembler' +import { RelayErrorCode, isGitResponseStreamMarker } from './relay-protocol' const SENTINEL_STREAM_ID = -1 -export { GitResponseStreamError } from './ssh-git-response-stream-assembler' +/** Reject if no stream frame (chunk/end/error) arrives within this window, + * reset on each frame. mux.request's own timeout only bounds the fast sentinel + * response; without this, a relay pump that breaks on staleness (which sends no + * responseEnd) while the SSH channel stays up would hang the client forever. */ +const STREAM_INACTIVITY_TIMEOUT_MS = 30_000 + +/** Bound transient buffering of other concurrent streams' chunks while this + * reader awaits its sentinel: every reader sees all git.responseChunk frames + * and can't filter by streamId until its own sentinel resolves. Foreign frames + * are dropped on drain anyway; this just caps the pre-sentinel backlog. */ +const MAX_PENDING_FRAMES = 64 + +export class GitResponseStreamError extends Error { + readonly code = RelayErrorCode.StreamProtocolError + constructor(message: string) { + super(message) + } +} + +type PendingFrame = + | { kind: 'chunk'; params: Record } + | { kind: 'end'; params: Record } + | { kind: 'error'; params: Record } /** * Request a git method that may return a large payload, opting into response @@ -44,9 +49,7 @@ export function requestGitStreamable( timeoutMs?: number /** Bounds the post-sentinel reassembly stall; resets on each chunk. */ inactivityTimeoutMs?: number - }, - assemblyBudget: SshStreamAssemblyBudget = defaultSshStreamAssemblyBudget, - preMetadataBudget: SshPreMetadataStreamBudget = defaultSshPreMetadataStreamBudget + } ): Promise { // Why: subscribe to chunk/end/error BEFORE awaiting the sentinel response so a // chunk that lands in the same dispatch tick as the response is not dropped @@ -65,22 +68,42 @@ export function requestGitStreamable( } return new Promise((resolve, reject) => { - let assembler: GitResponseStreamAssembler | null = null + const parts: Buffer[] = [] + let expectedSeq = 0 + let receivedBytes = 0 + let totalBytes = 0 + let chunkCount = 0 let settled = false let metadataReady = false - const pending = new PreMetadataStreamFrameBuffer(preMetadataBudget) + const pending: PendingFrame[] = [] - const inactivityMs = options?.inactivityTimeoutMs ?? STREAM_READER_INACTIVITY_TIMEOUT_MS - const inactivity = createStreamInactivityDeadline(inactivityMs, () => { - fail( - new GitResponseStreamError(`Git response stream stalled (>${inactivityMs}ms without data)`) - ) - }) + const inactivityMs = options?.inactivityTimeoutMs ?? STREAM_INACTIVITY_TIMEOUT_MS + let inactivityTimer: ReturnType | null = null + const clearInactivity = (): void => { + if (inactivityTimer) { + clearTimeout(inactivityTimer) + inactivityTimer = null + } + } + // Why: reset on every stream frame so a legitimately long stream is not + // killed, but a wedged stream (no frames arriving) rejects instead of + // hanging the caller forever. + const armInactivity = (): void => { + clearInactivity() + inactivityTimer = setTimeout(() => { + fail( + new GitResponseStreamError( + `Git response stream stalled (>${inactivityMs}ms without data)` + ) + ) + }, inactivityMs) + inactivityTimer.unref?.() + } - const cancelStreamId = (streamId: number): void => { - if (streamId !== SENTINEL_STREAM_ID && !mux.isDisposed()) { + const cancel = (): void => { + if (streamIdRef.current !== SENTINEL_STREAM_ID && !mux.isDisposed()) { try { - mux.notify('git.cancelResponseStream', { streamId }) + mux.notify('git.cancelResponseStream', { streamId: streamIdRef.current }) } catch { // best-effort } @@ -91,11 +114,8 @@ export function requestGitStreamable( return } settled = true - inactivity.clear() - pending.clear() - assembler?.release() - assembler = null - cancelStreamId(streamIdRef.current) + clearInactivity() + cancel() cleanup() reject(err) } @@ -104,8 +124,7 @@ export function requestGitStreamable( return } settled = true - inactivity.clear() - pending.clear() + clearInactivity() cleanup() resolve(value) } @@ -114,22 +133,25 @@ export function requestGitStreamable( if (settled || p.streamId !== streamIdRef.current) { return } - if (!assembler) { + const seq = p.seq as number + const data = p.data as string + if (typeof seq !== 'number' || typeof data !== 'string') { + fail(new GitResponseStreamError(`Malformed chunk for git stream ${streamIdRef.current}`)) + return + } + if (seq !== expectedSeq) { fail( new GitResponseStreamError( - `Chunk arrived before metadata for git stream ${streamIdRef.current}` + `Out-of-order chunk for git stream ${streamIdRef.current}: expected ${expectedSeq}, got ${seq}` ) ) return } - let seq: number - try { - seq = assembler.acceptChunk(p) - } catch (error) { - fail(error as Error) - return - } - inactivity.reset() + const decoded = Buffer.from(data, 'base64') + parts.push(decoded) + receivedBytes += decoded.length + expectedSeq += 1 + armInactivity() // Why: credit-based flow control — the relay caps unacked chunks so a big // response cannot queue unbounded ahead of interactive pty.data frames. if (!mux.isDisposed()) { @@ -145,18 +167,22 @@ export function requestGitStreamable( if (settled || p.streamId !== streamIdRef.current) { return } - if (!assembler) { + if (expectedSeq !== chunkCount || receivedBytes !== totalBytes) { fail( new GitResponseStreamError( - `Stream end before metadata for git stream ${streamIdRef.current}` + `Git stream ${streamIdRef.current} incomplete: chunks ${expectedSeq}/${chunkCount}, bytes ${receivedBytes}/${totalBytes}` ) ) return } try { - succeed(assembler.finish()) - } catch (error) { - fail(error as Error) + succeed(JSON.parse(Buffer.concat(parts).toString('utf-8'))) + } catch (err) { + fail( + new GitResponseStreamError( + `Git stream ${streamIdRef.current} JSON parse failed: ${String(err)}` + ) + ) } } @@ -169,10 +195,7 @@ export function requestGitStreamable( const drainPending = (): void => { while (!settled && pending.length > 0) { - const frame = pending.shift() - if (!frame) { - break - } + const frame = pending.shift()! if (frame.kind === 'chunk') { handleChunk(frame.params) } else if (frame.kind === 'end') { @@ -183,18 +206,22 @@ export function requestGitStreamable( } } - const pushPending = ( - kind: 'chunk' | 'end' | 'error', - params: Record - ): void => { - // Why: the stream id is unknown here; overload drops must not let one foreign frame fail every reader. - pending.push({ kind, params }) + // Why: pre-sentinel we cannot filter by streamId (our id is unknown yet), so + // every concurrent reader transiently buffers all readers' chunks. Cap the + // backlog by dropping the oldest; foreign frames are dropped on drain anyway, + // and if our own seq-0 were ever dropped the seq check fails loudly rather + // than corrupting. The sentinel normally resolves long before this cap. + const pushPending = (frame: PendingFrame): void => { + pending.push(frame) + if (pending.length > MAX_PENDING_FRAMES) { + pending.shift() + } } unsubscribers.push( mux.onNotificationByMethod('git.responseChunk', (p) => { if (!metadataReady) { - pushPending('chunk', p) + pushPending({ kind: 'chunk', params: p }) return } handleChunk(p) @@ -203,7 +230,7 @@ export function requestGitStreamable( unsubscribers.push( mux.onNotificationByMethod('git.responseEnd', (p) => { if (!metadataReady) { - pushPending('end', p) + pushPending({ kind: 'end', params: p }) return } handleEnd(p) @@ -212,7 +239,7 @@ export function requestGitStreamable( unsubscribers.push( mux.onNotificationByMethod('git.responseError', (p) => { if (!metadataReady) { - pushPending('error', p) + pushPending({ kind: 'error', params: p }) return } handleStreamError(p) @@ -262,52 +289,21 @@ export function requestGitStreamable( void requestPromise .then((result) => { if (settled) { - if (isGitResponseStreamMarker(result)) { - cancelStreamId(result.__orcaGitResponseStream.streamId) - } return } // Old relay / small result: plain single-frame value, no stream follows. if (!isGitResponseStreamMarker(result)) { - if ( - typeof result === 'object' && - result !== null && - '__orcaGitResponseStream' in result - ) { - fail(new GitResponseStreamError('Malformed Git response stream metadata')) - return - } succeed(result) return } const marker = result.__orcaGitResponseStream + totalBytes = marker.totalBytes + chunkCount = marker.chunkCount streamIdRef.current = marker.streamId - if ( - marker.totalBytes > MAX_GIT_RESPONSE_STREAM_BYTES || - marker.chunkCount > MAX_GIT_RESPONSE_STREAM_CHUNKS - ) { - fail( - new GitResponseStreamError( - `Git response stream exceeds client limit (${marker.totalBytes} bytes, ${marker.chunkCount} chunks)` - ) - ) - return - } - try { - assembler = new GitResponseStreamAssembler( - marker.streamId, - marker.totalBytes, - marker.chunkCount, - assemblyBudget - ) - } catch (error) { - fail(error as Error) - return - } metadataReady = true // Why: start the inactivity deadline now — mux.request's timeout only // covered the sentinel; the reassembly phase needs its own guard. - inactivity.reset() + armInactivity() drainPending() }) .catch((err) => fail(err as Error)) diff --git a/src/main/ssh/ssh-key-file.test.ts b/src/main/ssh/ssh-key-file.test.ts deleted file mode 100644 index 7a9212a22ce..00000000000 --- a/src/main/ssh/ssh-key-file.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { closeSync, ftruncateSync, mkdtempSync, openSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import { readSshKeyFile, SSH_KEY_FILE_MAX_BYTES } from './ssh-key-file' - -const roots: string[] = [] - -afterEach(() => { - for (const root of roots.splice(0)) { - rmSync(root, { recursive: true, force: true }) - } -}) - -function makeKeyPath(): string { - const root = mkdtempSync(join(tmpdir(), 'orca-ssh-key-')) - roots.push(root) - return join(root, 'id_ed25519') -} - -describe('SSH key file bounds', () => { - it('preserves ordinary key bytes', () => { - const keyPath = makeKeyPath() - writeFileSync(keyPath, 'private-key') - - expect(readSshKeyFile(keyPath)).toEqual(Buffer.from('private-key')) - }) - - it('rejects an oversized sparse key file', () => { - const keyPath = makeKeyPath() - const descriptor = openSync(keyPath, 'w') - ftruncateSync(descriptor, SSH_KEY_FILE_MAX_BYTES + 1) - closeSync(descriptor) - - expect(() => readSshKeyFile(keyPath)).toThrow('exceeds') - }) -}) diff --git a/src/main/ssh/ssh-key-file.ts b/src/main/ssh/ssh-key-file.ts deleted file mode 100644 index e04e5cfe1ac..00000000000 --- a/src/main/ssh/ssh-key-file.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { readNodeFileSyncWithinLimit } from '../../shared/node-bounded-file-reader' - -export const SSH_KEY_FILE_MAX_BYTES = 1024 * 1024 - -export function readSshKeyFile(filePath: string): Buffer { - return readNodeFileSyncWithinLimit(filePath, SSH_KEY_FILE_MAX_BYTES).buffer -} diff --git a/src/main/ssh/ssh-port-forward.test.ts b/src/main/ssh/ssh-port-forward.test.ts index a1f3f0c195e..b49c627b8b3 100644 --- a/src/main/ssh/ssh-port-forward.test.ts +++ b/src/main/ssh/ssh-port-forward.test.ts @@ -1,7 +1,7 @@ import { EventEmitter } from 'node:events' import { createServer } from 'node:net' import { describe, expect, it, vi, beforeEach } from 'vitest' -import { SSH_FORWARD_CLOSE_CONCURRENCY, SshPortForwardManager } from './ssh-port-forward' +import { SshPortForwardManager } from './ssh-port-forward' const { startSystemSshPortForwardProcessMock } = vi.hoisted(() => ({ startSystemSshPortForwardProcessMock: vi.fn() @@ -246,43 +246,6 @@ describe('SshPortForwardManager', () => { expect(resolved).toBe(true) }) - it.each([ - ['at the limit', SSH_FORWARD_CLOSE_CONCURRENCY], - ['above the limit', SSH_FORWARD_CLOSE_CONCURRENCY + 1] - ])('bounds forward closes %s', async (_, count) => { - let active = 0 - let peak = 0 - let started = 0 - const releases: (() => void)[] = [] - const forwards = Array.from({ length: count }, () => { - const forward = createFakeSystemSshForward() - forward.close.mockImplementation(async () => { - started++ - active++ - peak = Math.max(peak, active) - await new Promise((resolve) => releases.push(resolve)) - active-- - }) - return forward - }) - startSystemSshPortForwardProcessMock.mockImplementation(() => forwards.shift()) - const conn = createSystemSshConn() - for (let index = 0; index < count; index++) { - await manager.addForward('conn-1', conn as never, 3000 + index, '127.0.0.1', 8080) - } - - const removal = manager.removeAllForwards('conn-1') - await vi.waitFor(() => expect(started).toBe(Math.min(count, SSH_FORWARD_CLOSE_CONCURRENCY))) - if (count > SSH_FORWARD_CLOSE_CONCURRENCY) { - releases.shift()?.() - await vi.waitFor(() => expect(started).toBe(count)) - } - releases.splice(0).forEach((release) => release()) - await removal - - expect(peak).toBe(Math.min(count, SSH_FORWARD_CLOSE_CONCURRENCY)) - }) - it('removes an unexpectedly exited system SSH forward and calls the close callback', async () => { const onForwardClosed = vi.fn() manager.setCallbacks({ onForwardClosed }) diff --git a/src/main/ssh/ssh-port-forward.ts b/src/main/ssh/ssh-port-forward.ts index d296c27086e..bc151eaab1e 100644 --- a/src/main/ssh/ssh-port-forward.ts +++ b/src/main/ssh/ssh-port-forward.ts @@ -7,9 +7,6 @@ import type { SshPortForwardProvider, StartedPortForward } from './ssh-port-forward-provider' -import { mapSettledWithConcurrency } from '../../shared/map-with-concurrency' - -export const SSH_FORWARD_CLOSE_CONCURRENCY = 8 export type { PortForwardEntry } export type { PortForwardCloseReason } @@ -182,15 +179,7 @@ export class SshPortForwardManager { const toRemove = [...this.forwards.entries()] .filter(([, { entry }]) => entry.connectionId === connectionId) .map(([id]) => id) - const results = await mapSettledWithConcurrency(toRemove, SSH_FORWARD_CLOSE_CONCURRENCY, (id) => - this.removeForwardAsync(id) - ) - const failedClose = results.find( - (result): result is PromiseRejectedResult => result.status === 'rejected' - ) - if (failedClose) { - throw failedClose.reason - } + await Promise.all(toRemove.map((id) => this.removeForwardAsync(id))) } dispose(): void { diff --git a/src/main/ssh/ssh-port-scanner.test.ts b/src/main/ssh/ssh-port-scanner.test.ts index 56dba7139e3..09be7c1f686 100644 --- a/src/main/ssh/ssh-port-scanner.test.ts +++ b/src/main/ssh/ssh-port-scanner.test.ts @@ -7,10 +7,6 @@ import { } from './ssh-port-scanner' import type { SshChannelMultiplexer } from './ssh-channel-multiplexer' import type { DetectedPort } from '../../shared/ssh-types' -import { - SSH_DETECTED_PORTS_MAX_ENTRIES, - SSH_DETECTED_PORT_PROCESS_NAME_MAX_UTF8_BYTES -} from '../../shared/ssh-retained-payload-admission' type VisibilityHarness = { visibility: PortScannerWindowVisibility @@ -220,26 +216,6 @@ describe('PortScanner', () => { scanner.dispose() }) - it('bounds remote rows before retaining or publishing a scan', async () => { - const harness = createVisibilityHarness(true) - const remoteRows = Array.from({ length: SSH_DETECTED_PORTS_MAX_ENTRIES + 20 }, (_, index) => ({ - ...port(3000 + index), - processName: 'x'.repeat(SSH_DETECTED_PORT_PROCESS_NAME_MAX_UTF8_BYTES + 100) - })) - const { mux } = createMux(() => remoteRows) - const onChanged = vi.fn() - const scanner = new PortScanner(harness.visibility) - - scanner.startScanning('t1', mux, onChanged) - await vi.advanceTimersByTimeAsync(0) - - const retained = scanner.getDetectedPorts('t1') - expect(retained).toHaveLength(SSH_DETECTED_PORTS_MAX_ENTRIES) - expect(retained[0].processName).toHaveLength(SSH_DETECTED_PORT_PROCESS_NAME_MAX_UTF8_BYTES) - expect(onChanged).toHaveBeenCalledWith('t1', retained, 'linux') - scanner.dispose() - }) - it('stopScanning halts polling and detaches the visibility listener', async () => { const harness = createVisibilityHarness(true) const { mux, request } = createMux(() => [port(3000)]) diff --git a/src/main/ssh/ssh-port-scanner.ts b/src/main/ssh/ssh-port-scanner.ts index abb668095ce..6404d035f22 100644 --- a/src/main/ssh/ssh-port-scanner.ts +++ b/src/main/ssh/ssh-port-scanner.ts @@ -1,6 +1,5 @@ import type { SshChannelMultiplexer } from './ssh-channel-multiplexer' import type { DetectedPort } from '../../shared/ssh-types' -import { admitSshDetectedPorts } from '../../shared/ssh-retained-payload-admission' // Why: every tick walks /proc/*/fd on the remote relay, so cadence is remote // CPU, not just a local timer. 12s cuts steady-state request volume 4x vs the @@ -76,10 +75,8 @@ export class PortScanner { return } - // Why: relay responses cross a provider boundary and are retained between scans. - const admittedPorts = admitSshDetectedPorts(result.ports) const currentPorts = new Map() - for (const p of admittedPorts) { + for (const p of result.ports) { currentPorts.set(`${p.host}:${p.port}`, p) } @@ -90,7 +87,7 @@ export class PortScanner { if (!portsEqual(handle.previousPorts, currentPorts)) { handle.previousPorts = currentPorts handle.intervalMs = SSH_PORT_SCAN_BASE_INTERVAL_MS - onChanged(targetId, admittedPorts, result.platform) + onChanged(targetId, result.ports, result.platform) } else { handle.intervalMs = Math.min(handle.intervalMs * 2, SSH_PORT_SCAN_MAX_INTERVAL_MS) } diff --git a/src/main/ssh/ssh-relay-base-directory-listing.test.ts b/src/main/ssh/ssh-relay-base-directory-listing.test.ts deleted file mode 100644 index 56d9fb676f2..00000000000 --- a/src/main/ssh/ssh-relay-base-directory-listing.test.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { spawnSync } from 'node:child_process' -import { mkdirSync, mkdtempSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { describe, expect, it } from 'vitest' -import { - getRelayBaseDirectoryListingCommand, - RELAY_BASE_DIRECTORY_LISTING_LIMIT_SENTINEL, - RELAY_BASE_DIRECTORY_MAX_ENTRIES, - RELAY_BASE_DIRECTORY_MAX_UTF8_BYTES -} from './ssh-relay-base-directory-listing' -import { getRemoteHostPlatform } from './ssh-remote-platform' - -const posix = getRemoteHostPlatform('linux-x64') -const windows = getRemoteHostPlatform('win32-x64') - -function decodePowerShellCommand(command: string): string { - const match = command.match(/-EncodedCommand\s+([A-Za-z0-9+/=]+)/) - return match ? Buffer.from(match[1], 'base64').toString('utf16le') : '' -} - -function runPosixListing( - directory: string, - options: { maxEntries: number; maxUtf8Bytes: number } -): string[] { - const result = spawnSync( - '/bin/sh', - ['-c', getRelayBaseDirectoryListingCommand(posix, directory, options)], - { encoding: 'utf8' } - ) - if (result.status !== 0) { - throw new Error(`listing exited ${result.status}: ${result.stderr}`) - } - return result.stdout - .split('\n') - .map((line) => line.trim()) - .filter(Boolean) -} - -function withTempDirectory(run: (directory: string) => void): void { - const directory = mkdtempSync(join(tmpdir(), 'orca-relay-listing-')) - try { - run(directory) - } finally { - rmSync(directory, { recursive: true, force: true }) - } -} - -describe('relay base-directory listing', () => { - it('streams POSIX entries through fixed entry and byte budgets', () => { - const command = getRelayBaseDirectoryListingCommand(posix, '/home/u/.orca-remote') - - expect(command).toContain('find "$base" -mindepth 1 -maxdepth 1 -print') - expect(command).toContain(`entry_count>${RELAY_BASE_DIRECTORY_MAX_ENTRIES}`) - expect(command).toContain(`output_bytes+line_bytes>${RELAY_BASE_DIRECTORY_MAX_UTF8_BYTES}`) - expect(command).toContain('| sort') - expect(command).not.toContain('ls -1') - }) - - it('allows the exact POSIX entry and output-byte bounds', () => { - withTempDirectory((directory) => { - mkdirSync(join(directory, 'a')) - mkdirSync(join(directory, 'b')) - - expect( - runPosixListing(directory, { - maxEntries: 2, - maxUtf8Bytes: 4 - }).sort() - ).toEqual(['a', 'b']) - }) - }) - - it('emits a sentinel on the first POSIX entry beyond the bound', () => { - withTempDirectory((directory) => { - mkdirSync(join(directory, 'a')) - mkdirSync(join(directory, 'b')) - mkdirSync(join(directory, 'c')) - - expect( - runPosixListing(directory, { - maxEntries: 2, - maxUtf8Bytes: 100 - }) - ).toContain(RELAY_BASE_DIRECTORY_LISTING_LIMIT_SENTINEL) - }) - }) - - it('emits a sentinel before exceeding the POSIX output-byte bound', () => { - withTempDirectory((directory) => { - mkdirSync(join(directory, 'aa')) - mkdirSync(join(directory, 'bb')) - - const lines = runPosixListing(directory, { - maxEntries: 2, - maxUtf8Bytes: 5 - }) - - expect(lines).toContain(RELAY_BASE_DIRECTORY_LISTING_LIMIT_SENTINEL) - expect( - lines.filter((line) => line !== RELAY_BASE_DIRECTORY_LISTING_LIMIT_SENTINEL) - ).toHaveLength(1) - }) - }) - - it('uses a disposable streaming iterator on Windows', () => { - const script = decodePowerShellCommand( - getRelayBaseDirectoryListingCommand(windows, 'C:/Users/u/.orca-remote') - ) - - expect(script).toContain('[System.IO.Directory]::EnumerateDirectories($base)') - expect(script).toContain('.GetEnumerator()') - expect(script).toContain('$iterator.MoveNext()') - expect(script).toContain('$iterator.Dispose()') - expect(script).toContain(`$maxEntries = ${RELAY_BASE_DIRECTORY_MAX_ENTRIES}`) - expect(script).toContain(`$maxBytes = ${RELAY_BASE_DIRECTORY_MAX_UTF8_BYTES}`) - expect(script).toContain('UTF8.GetByteCount($name) + 2') - expect(script).toContain(RELAY_BASE_DIRECTORY_LISTING_LIMIT_SENTINEL) - expect(script).not.toContain('Get-ChildItem') - }) -}) diff --git a/src/main/ssh/ssh-relay-base-directory-listing.ts b/src/main/ssh/ssh-relay-base-directory-listing.ts deleted file mode 100644 index a20eb2897f7..00000000000 --- a/src/main/ssh/ssh-relay-base-directory-listing.ts +++ /dev/null @@ -1,93 +0,0 @@ -import type { RemoteHostPlatform } from './ssh-remote-platform' -import { isWindowsRemoteHost } from './ssh-remote-platform' -import { powerShellCommand, powerShellLiteral } from './ssh-remote-powershell' -import { shellEscape } from './ssh-connection-utils' - -export const RELAY_BASE_DIRECTORY_MAX_ENTRIES = 4_096 -export const RELAY_BASE_DIRECTORY_MAX_UTF8_BYTES = 256 * 1024 -export const RELAY_BASE_DIRECTORY_LISTING_LIMIT_SENTINEL = - '__ORCA_RELAY_BASE_DIRECTORY_LISTING_TOO_LARGE__' - -export function isRelayBaseDirectoryListingLimited(listing: string): boolean { - return listing - .split(/\r?\n/) - .some((line) => line.trim() === RELAY_BASE_DIRECTORY_LISTING_LIMIT_SENTINEL) -} - -type RelayBaseDirectoryListingOptions = { - maxEntries?: number - maxUtf8Bytes?: number -} - -function positiveInteger(value: number | undefined, fallback: number, name: string): number { - const resolved = value ?? fallback - if (!Number.isSafeInteger(resolved) || resolved < 1) { - throw new Error(`${name} must be a positive integer`) - } - return resolved -} - -export function getRelayBaseDirectoryListingCommand( - host: RemoteHostPlatform, - baseDir: string, - options?: RelayBaseDirectoryListingOptions -): string { - const maxEntries = positiveInteger( - options?.maxEntries, - RELAY_BASE_DIRECTORY_MAX_ENTRIES, - 'maxEntries' - ) - const maxUtf8Bytes = positiveInteger( - options?.maxUtf8Bytes, - RELAY_BASE_DIRECTORY_MAX_UTF8_BYTES, - 'maxUtf8Bytes' - ) - - if (!isWindowsRemoteHost(host)) { - const awkProgram = [ - '{', - 'entry_count++;', - `if(entry_count>${maxEntries}){print "${RELAY_BASE_DIRECTORY_LISTING_LIMIT_SENTINEL}";exit}`, - 'name=$0;sub(/^.*\\//,"",name);', - 'if(substr(name,1,1)==".")next;', - 'line_bytes=length(name)+1;', - `if(output_bytes+line_bytes>${maxUtf8Bytes}){print "${RELAY_BASE_DIRECTORY_LISTING_LIMIT_SENTINEL}";exit}`, - 'print name;output_bytes+=line_bytes', - '}' - ].join('') - return [ - `base=${shellEscape(baseDir)};`, - 'if [ -d "$base" ]; then', - 'LC_ALL=C find "$base" -mindepth 1 -maxdepth 1 -print 2>/dev/null', - `| LC_ALL=C awk ${shellEscape(awkProgram)} | sort;`, - 'fi' - ].join(' ') - } - - return powerShellCommand( - [ - `$base = ${powerShellLiteral(baseDir)}`, - `if (Test-Path -LiteralPath $base -PathType Container) {`, - `$maxEntries = ${maxEntries}`, - `$maxBytes = ${maxUtf8Bytes}`, - `$sentinel = ${powerShellLiteral(RELAY_BASE_DIRECTORY_LISTING_LIMIT_SENTINEL)}`, - '$entryCount = 0', - '$outputBytes = 0', - '$iterator = ([System.IO.Directory]::EnumerateDirectories($base)).GetEnumerator()', - 'try {', - 'while ($iterator.MoveNext()) {', - 'if ($entryCount -ge $maxEntries) { Write-Output $sentinel; break }', - '$entryCount++', - '$name = [System.IO.Path]::GetFileName([string]$iterator.Current)', - '$lineBytes = [System.Text.Encoding]::UTF8.GetByteCount($name) + 2', - 'if (($outputBytes + $lineBytes) -gt $maxBytes) { Write-Output $sentinel; break }', - 'Write-Output $name', - '$outputBytes += $lineBytes', - '}', - '} finally {', - 'if ($null -ne $iterator) { $iterator.Dispose() }', - '}', - '}' - ].join('\n') - ) -} diff --git a/src/main/ssh/ssh-relay-bounded-marker-commands.test.ts b/src/main/ssh/ssh-relay-bounded-marker-commands.test.ts deleted file mode 100644 index 50ab6e7235f..00000000000 --- a/src/main/ssh/ssh-relay-bounded-marker-commands.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - SSH_RELAY_MARKER_MAX_BYTES, - posixReadRelayMarkerAssignment, - powerShellReadRelayMarkerAssignment -} from './ssh-relay-bounded-marker-commands' - -describe('bounded SSH relay marker commands', () => { - it('reads at most one byte past the Windows marker ceiling', () => { - const script = powerShellReadRelayMarkerAssignment('C:/Users/me/.orca-remote/.gc-owner') - - expect(script).toContain('[System.IO.File]::Open') - expect(script).toContain(`New-Object byte[] ${SSH_RELAY_MARKER_MAX_BYTES + 1}`) - expect(script).toContain(`-le ${SSH_RELAY_MARKER_MAX_BYTES}`) - expect(script).not.toContain('Get-Content') - expect(script).not.toContain('ReadAllText') - }) - - it('caps POSIX command substitution before comparing an owner token', () => { - const script = posixReadRelayMarkerAssignment('/home/u/.orca-remote/.gc-owner') - - expect(script).toContain(`bs=${SSH_RELAY_MARKER_MAX_BYTES + 1}`) - expect(script).toContain(`-gt ${SSH_RELAY_MARKER_MAX_BYTES}`) - expect(script).not.toContain('cat ') - }) -}) diff --git a/src/main/ssh/ssh-relay-bounded-marker-commands.ts b/src/main/ssh/ssh-relay-bounded-marker-commands.ts deleted file mode 100644 index 4e052b6c161..00000000000 --- a/src/main/ssh/ssh-relay-bounded-marker-commands.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { shellEscape } from './ssh-connection-utils' -import { powerShellLiteral } from './ssh-remote-powershell' - -export const SSH_RELAY_MARKER_MAX_BYTES = 1024 - -export function powerShellReadRelayMarkerAssignment(markerPath: string): string { - return [ - '$orcaMarkerValue = $null', - '$orcaMarkerStream = $null', - `try { $orcaMarkerStream = [System.IO.File]::Open(${powerShellLiteral(markerPath)}, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, ([System.IO.FileShare]::ReadWrite -bor [System.IO.FileShare]::Delete))`, - `$orcaMarkerBuffer = New-Object byte[] ${SSH_RELAY_MARKER_MAX_BYTES + 1}`, - '$orcaMarkerCount = $orcaMarkerStream.Read($orcaMarkerBuffer, 0, $orcaMarkerBuffer.Length)', - `if ($orcaMarkerCount -le ${SSH_RELAY_MARKER_MAX_BYTES}) { $orcaMarkerValue = [System.Text.Encoding]::UTF8.GetString($orcaMarkerBuffer, 0, $orcaMarkerCount) }`, - '} catch {} finally { if ($null -ne $orcaMarkerStream) { $orcaMarkerStream.Dispose() } }' - ].join('; ') -} - -export function posixReadRelayMarkerAssignment(markerPath: string): string { - return [ - `orca_marker=$(dd if=${shellEscape(markerPath)} bs=${SSH_RELAY_MARKER_MAX_BYTES + 1} count=1 2>/dev/null) || orca_marker=`, - `if [ "\${#orca_marker}" -gt ${SSH_RELAY_MARKER_MAX_BYTES} ]; then orca_marker=; fi` - ].join('; ') -} diff --git a/src/main/ssh/ssh-relay-cross-version-isolation.test.ts b/src/main/ssh/ssh-relay-cross-version-isolation.test.ts index 82cccf1c0ff..14f063e3bf1 100644 --- a/src/main/ssh/ssh-relay-cross-version-isolation.test.ts +++ b/src/main/ssh/ssh-relay-cross-version-isolation.test.ts @@ -14,10 +14,8 @@ vi.mock('electron', () => ({ })) vi.mock('fs', () => ({ - existsSync: vi.fn().mockReturnValue(true) -})) -vi.mock('../../shared/relay-version-marker', () => ({ - readRelayVersionMarkerSync: vi.fn().mockReturnValue('0.1.0+v2hash') + existsSync: vi.fn().mockReturnValue(true), + readFileSync: vi.fn().mockReturnValue('0.1.0+v2hash') })) vi.mock('./relay-protocol', () => ({ diff --git a/src/main/ssh/ssh-relay-deploy-helpers.test.ts b/src/main/ssh/ssh-relay-deploy-helpers.test.ts index 3e634492d25..4df9c8ad28a 100644 --- a/src/main/ssh/ssh-relay-deploy-helpers.test.ts +++ b/src/main/ssh/ssh-relay-deploy-helpers.test.ts @@ -3,12 +3,7 @@ import { describe, expect, it, vi } from 'vitest' import type { ClientChannel } from 'ssh2' import { execCommand, waitForSentinel } from './ssh-relay-deploy-helpers' import { shouldProbeBuildToolchainAfterNativeDepsFailure } from './ssh-relay-build-toolchain' -import { - HEADER_LENGTH, - MAX_BUFFERED_FRAME_CHUNKS, - MAX_MESSAGE_SIZE, - RELAY_SENTINEL -} from './relay-protocol' +import { RELAY_SENTINEL } from './relay-protocol' import { RelayVersionMismatchError, RELAY_EXIT_CODE_VERSION_MISMATCH @@ -209,62 +204,6 @@ describe('waitForSentinel', () => { expect(Buffer.concat(chunks)).toEqual(postSentinelPayload) expect(channel.close).not.toHaveBeenCalled() }) - - it('preserves the exact post-sentinel payload at the pending transport cap', async () => { - const channel = createMockChannel() - const transportPromise = waitForSentinel(channel) - const payload = Buffer.alloc((MAX_MESSAGE_SIZE + HEADER_LENGTH) * 2, 'p') - - channel.emit('data', Buffer.from(RELAY_SENTINEL)) - channel.emit('data', payload) - - const transport = await transportPromise - const chunks: Buffer[] = [] - transport.onData((chunk) => chunks.push(chunk)) - - expect(chunks).toHaveLength(1) - expect(chunks[0]?.equals(payload)).toBe(true) - expect(channel.close).not.toHaveBeenCalled() - }) - - it('closes before retaining post-sentinel bytes beyond the pending transport cap', async () => { - const channel = createMockChannel() - const transportPromise = waitForSentinel(channel) - - channel.emit('data', Buffer.from(RELAY_SENTINEL)) - channel.emit('data', Buffer.alloc((MAX_MESSAGE_SIZE + HEADER_LENGTH) * 2, 'p')) - channel.emit('data', Buffer.from('overflow')) - - const transport = await transportPromise - const onData = vi.fn() - const onClose = vi.fn() - transport.onData(onData) - transport.onClose(onClose) - - expect(onData).not.toHaveBeenCalled() - expect(onClose).toHaveBeenCalledOnce() - expect(channel.close).toHaveBeenCalledOnce() - }) - - it('closes before retaining too many post-sentinel fragments', async () => { - const channel = createMockChannel() - const transportPromise = waitForSentinel(channel) - - channel.emit('data', Buffer.from(RELAY_SENTINEL)) - for (let index = 0; index <= MAX_BUFFERED_FRAME_CHUNKS; index++) { - channel.emit('data', Buffer.from('p')) - } - - const transport = await transportPromise - const onData = vi.fn() - const onClose = vi.fn() - transport.onData(onData) - transport.onClose(onClose) - - expect(onData).not.toHaveBeenCalled() - expect(onClose).toHaveBeenCalledOnce() - expect(channel.close).toHaveBeenCalledOnce() - }) }) describe('execCommand', () => { diff --git a/src/main/ssh/ssh-relay-deploy-helpers.ts b/src/main/ssh/ssh-relay-deploy-helpers.ts index 9294707af60..80d2b78ced2 100644 --- a/src/main/ssh/ssh-relay-deploy-helpers.ts +++ b/src/main/ssh/ssh-relay-deploy-helpers.ts @@ -1,12 +1,6 @@ import type { ClientChannel } from 'ssh2' import { createSshOperationAbortError } from './ssh-connection-utils' -import { - HEADER_LENGTH, - MAX_BUFFERED_FRAME_CHUNKS, - MAX_MESSAGE_SIZE, - RELAY_SENTINEL, - RELAY_SENTINEL_TIMEOUT_MS -} from './relay-protocol' +import { RELAY_SENTINEL, RELAY_SENTINEL_TIMEOUT_MS } from './relay-protocol' import type { MultiplexerTransport } from './ssh-channel-multiplexer' import { buildRelayVersionMismatchError } from './ssh-relay-handshake-mismatch' @@ -16,8 +10,6 @@ export { execCommand, isUnconfirmedSshCommandTermination } from './ssh-relay-exe // ── Sentinel detection ──────────────────────────────────────────────── const MAX_RELAY_STARTUP_BUFFER_BYTES = 64 * 1024 -// One maximum frame may be coalesced with the start of the next. -const MAX_PENDING_RELAY_BYTES = (MAX_MESSAGE_SIZE + HEADER_LENGTH) * 2 const RELAY_SENTINEL_BUFFER = Buffer.from(RELAY_SENTINEL, 'utf-8') export function waitForSentinel( @@ -170,35 +162,19 @@ export function waitForSentinel( notifyClosed() }) - // The caller cannot subscribe until the resolved promise resumes. - let pendingAfterSentinel: Buffer[] = [] - let pendingAfterSentinelBytes = 0 - - const bufferAfterSentinel = (data: Buffer): void => { - if (closedAfterSentinel) { - return - } - if ( - data.length > MAX_PENDING_RELAY_BYTES - pendingAfterSentinelBytes || - pendingAfterSentinel.length >= MAX_BUFFERED_FRAME_CHUNKS - ) { - pendingAfterSentinel = [] - pendingAfterSentinelBytes = 0 - notifyClosed() - channel.close() - return - } - pendingAfterSentinel.push(data) - pendingAfterSentinelBytes += data.length - } + // Why: data arriving in the same TCP chunk as the sentinel is buffered + // here. It's delivered on the first onData registration rather than + // immediately after resolve, because resolve schedules a microtask — + // the caller's `await` hasn't resumed yet, so no callbacks are + // registered when the synchronous code after resolve runs. + let pendingAfterSentinel: Buffer | null = null channel.on('data', (data: Buffer) => { if (sentinelReceived) { - if (closedAfterSentinel) { - return - } if (dataCallbacks.length === 0) { - bufferAfterSentinel(data) + pendingAfterSentinel = pendingAfterSentinel + ? Buffer.concat([pendingAfterSentinel, data]) + : data } else { for (const cb of dataCallbacks) { cb(data) @@ -235,16 +211,19 @@ export function waitForSentinel( const afterSentinel = data.subarray(Math.max(0, afterSentinelOffset)) if (afterSentinel.length > 0) { - bufferAfterSentinel(afterSentinel) + pendingAfterSentinel = afterSentinel } const transport: MultiplexerTransport = { write: (buf: Buffer) => channel.stdin.write(buf), onData: (cb) => { dataCallbacks.push(cb) - if (pendingAfterSentinel.length > 0) { - const buf = Buffer.concat(pendingAfterSentinel, pendingAfterSentinelBytes) - pendingAfterSentinel = [] - pendingAfterSentinelBytes = 0 + // Why: deliver buffered post-sentinel data to the first + // subscriber. This is the multiplexer constructor, which + // registers onData synchronously — the data is guaranteed + // to reach the decoder before any other frames arrive. + if (pendingAfterSentinel) { + const buf = pendingAfterSentinel + pendingAfterSentinel = null cb(buf) } }, diff --git a/src/main/ssh/ssh-relay-deploy.test.ts b/src/main/ssh/ssh-relay-deploy.test.ts index 6c608600ba8..28fe2a3e49c 100644 --- a/src/main/ssh/ssh-relay-deploy.test.ts +++ b/src/main/ssh/ssh-relay-deploy.test.ts @@ -101,11 +101,7 @@ function extractWindowsSockPath(script: string): string { } function extractWindowsMarkerPath(script: string): string { - return ( - /(?:-LiteralPath\s+|\[System\.IO\.File\]::Open\()'([^']*\.windows-active-pipe[^']*)'/.exec( - script - )?.[1] ?? '' - ) + return /-LiteralPath\s+'([^']*\.windows-active-pipe[^']*)'/.exec(script)?.[1] ?? '' } function makeMockConnection(): SshConnection { @@ -899,11 +895,7 @@ describe('deployAndLaunchRelay', () => { const markerPaths = mockExecCommand.mock.calls .map(([, command]) => decodePowerShellCommand(command)) - .filter((script): script is string => - Boolean( - script?.includes('[System.IO.File]::Open') && script.includes('.windows-active-pipe') - ) - ) + .filter((script): script is string => Boolean(script?.includes('Get-Content'))) .map(extractWindowsMarkerPath) expect(markerPaths).toHaveLength(2) diff --git a/src/main/ssh/ssh-relay-deploy.ts b/src/main/ssh/ssh-relay-deploy.ts index 136287ba918..f2154d1eb13 100644 --- a/src/main/ssh/ssh-relay-deploy.ts +++ b/src/main/ssh/ssh-relay-deploy.ts @@ -64,8 +64,6 @@ import { MAX_SSH_RELAY_GRACE_PERIOD_SECONDS, MIN_SSH_RELAY_GRACE_PERIOD_SECONDS } from '../../shared/ssh-types' -import { powerShellReadRelayMarkerAssignment } from './ssh-relay-bounded-marker-commands' -import { windowsRelayTailLogCommand } from './ssh-windows-log-tail-command' export type RelayDeployResult = { transport: MultiplexerTransport @@ -1166,7 +1164,7 @@ async function readWindowsActiveRelayEndpoint( conn, hostPlatform, powerShellCommand( - `${powerShellReadRelayMarkerAssignment(markerPath)}; if ($null -ne $orcaMarkerValue) { [Console]::Out.Write($orcaMarkerValue) }` + `if (Test-Path -LiteralPath ${powerShellLiteral(markerPath)} -PathType Leaf) { Get-Content -LiteralPath ${powerShellLiteral(markerPath)} -Raw -ErrorAction SilentlyContinue }` ), { signal } ).catch(() => { @@ -1520,3 +1518,14 @@ function windowsRelayWaitCommand( ].join(' ') ) } + +function windowsRelayTailLogCommand(logFile: string, errFile: string): string { + const script = [ + `$out = if (Test-Path -LiteralPath ${powerShellLiteral(logFile)}) { Get-Content -LiteralPath ${powerShellLiteral(logFile)} -Tail 20 -ErrorAction SilentlyContinue } else { '(no stdout log)' }`, + `$err = if (Test-Path -LiteralPath ${powerShellLiteral(errFile)}) { Get-Content -LiteralPath ${powerShellLiteral(errFile)} -Tail 20 -ErrorAction SilentlyContinue } else { '(no stderr log)' }`, + 'Write-Output $out', + "Write-Output '--- stderr ---'", + 'Write-Output $err' + ].join('; ') + return powerShellCommand(script) +} diff --git a/src/main/ssh/ssh-relay-gc-claim.ts b/src/main/ssh/ssh-relay-gc-claim.ts index ddc6b178c5b..d0c8cc1221f 100644 --- a/src/main/ssh/ssh-relay-gc-claim.ts +++ b/src/main/ssh/ssh-relay-gc-claim.ts @@ -9,10 +9,6 @@ import { } from './ssh-relay-install-lock-commands' import { removeRemoteTreeCommand } from './ssh-remote-commands' import { powerShellCommand, powerShellLiteral } from './ssh-remote-powershell' -import { - posixReadRelayMarkerAssignment, - powerShellReadRelayMarkerAssignment -} from './ssh-relay-bounded-marker-commands' import { getRemoteHostPlatform, isWindowsRemoteHost, @@ -135,9 +131,9 @@ export async function isRelayGcClaimOwned( const ownerPath = joinRemotePath(host, relayGcClaimPath(remoteRelayDir), RELAY_GC_OWNER_NAME) const command = isWindowsRemoteHost(host) ? powerShellCommand( - `${powerShellReadRelayMarkerAssignment(ownerPath)}; if ($orcaMarkerValue -ceq ${powerShellLiteral(token)}) { 'OWNED' } else { 'LOST' }` + `if ((Get-Content -LiteralPath ${powerShellLiteral(ownerPath)} -Raw -ErrorAction SilentlyContinue) -ceq ${powerShellLiteral(token)}) { 'OWNED' } else { 'LOST' }` ) - : `${posixReadRelayMarkerAssignment(ownerPath)}; test "$orca_marker" = ${shellEscape(token)} && echo OWNED || echo LOST` + : `test "$(cat ${shellEscape(ownerPath)} 2>/dev/null)" = ${shellEscape(token)} && echo OWNED || echo LOST` const output = await execHostCommand(conn, host, command).catch(() => 'LOST') return output.trim() === 'OWNED' } @@ -156,17 +152,15 @@ export async function releaseRelayGcClaim( ? powerShellCommand( `$claim = ${powerShellLiteral(claimPath)}; ` + `if (-not (Test-Path -LiteralPath $claim)) { 'RELEASED' } ` + - `else { ${powerShellReadRelayMarkerAssignment(ownerPath)}; ` + - `if ($orcaMarkerValue -cne ${powerShellLiteral(token)}) { 'LOST' } ` + + `elseif ((Get-Content -LiteralPath ${powerShellLiteral(ownerPath)} -Raw -ErrorAction SilentlyContinue) -cne ${powerShellLiteral(token)}) { 'LOST' } ` + 'else { try { Remove-Item -LiteralPath $claim -Recurse -Force -ErrorAction Stop } catch {}; ' + - "if (Test-Path -LiteralPath $claim) { 'UNKNOWN' } else { 'RELEASED' } } }" + "if (Test-Path -LiteralPath $claim) { 'UNKNOWN' } else { 'RELEASED' } }" ) : [ `if ! test -e ${shellEscape(claimPath)}; then echo RELEASED;`, - `else ${posixReadRelayMarkerAssignment(ownerPath)};`, - `if test "$orca_marker" != ${shellEscape(token)}; then echo LOST;`, + `elif test "$(cat ${shellEscape(ownerPath)} 2>/dev/null)" != ${shellEscape(token)}; then echo LOST;`, `else ${removeRemoteTreeCommand(host, claimPath)} 2>/dev/null;`, - `if test -e ${shellEscape(claimPath)}; then echo UNKNOWN; else echo RELEASED; fi; fi; fi` + `if test -e ${shellEscape(claimPath)}; then echo UNKNOWN; else echo RELEASED; fi; fi` ].join(' ') const output = await execHostCommand(conn, host, command).catch(() => 'UNKNOWN') switch (output.trim()) { diff --git a/src/main/ssh/ssh-relay-install-lock-commands.ts b/src/main/ssh/ssh-relay-install-lock-commands.ts index 05891683243..d6eab561a2d 100644 --- a/src/main/ssh/ssh-relay-install-lock-commands.ts +++ b/src/main/ssh/ssh-relay-install-lock-commands.ts @@ -92,10 +92,7 @@ function posixStealInstallLockCommand(lockDir: string, staleAfterSeconds: number 'steal="$steal_root.$steal_generation";', 'done;', 'if [ "$owns_steal" = 1 ]; then', - 'steal_parent=${steal_root%/*};', - 'steal_leaf=${steal_root##*/};', - '[ "$steal_parent" != "$steal_root" ] || steal_parent=.;', - `trap 'find "$steal_parent" -mindepth 1 -maxdepth 1 -name "$steal_leaf.*" -exec rm -rf {} + 2>/dev/null || true; rm -rf "$lock_tombstone" 2>/dev/null || true' EXIT;`, + `trap 'rm -rf "$steal_root".* 2>/dev/null || true; rm -rf "$lock_tombstone" 2>/dev/null || true' EXIT;`, `${posixLockIdentityAssignment(lockDir, 'current_key')} && current_mtime=\${current_key%%:*} && current_now=$(date +%s) && current_age=$((current_now - current_mtime)) || current_age=0;`, `if [ "$current_key" = "$lock_key" ] && [ "\${current_age:-0}" -gt ${staleAfterSeconds} ] 2>/dev/null; then`, `lock_tombstone=${escapedLockDir}.tombstone.$$.$(date +%s);`, diff --git a/src/main/ssh/ssh-relay-liveness-probe-source.test.ts b/src/main/ssh/ssh-relay-liveness-probe-source.test.ts deleted file mode 100644 index 7c74cf71ef9..00000000000 --- a/src/main/ssh/ssh-relay-liveness-probe-source.test.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { spawnSync } from 'node:child_process' -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { describe, expect, it } from 'vitest' -import { GENERATED_NODE_MANAGED_FILE_MAX_BYTES } from '../generated-node-bounded-file-reader' -import { - getWindowsRelayLivenessProbeSource, - WINDOWS_RELAY_LIVENESS_DIRECTORY_BUFFER_SIZE, - WINDOWS_RELAY_LIVENESS_INCONCLUSIVE_STATE, - WINDOWS_RELAY_LIVENESS_MAX_DIRECTORY_ENTRIES, - WINDOWS_RELAY_LIVENESS_MAX_PIPE_PATHS -} from './ssh-relay-liveness-probe-source' - -const PIPE_A = '\\\\.\\pipe\\orca-relay-00000000000000000000' -const PIPE_B = '\\\\.\\pipe\\orca-relay-11111111111111111111' -const PIPE_C = '\\\\.\\pipe\\orca-relay-22222222222222222222' - -function runProbe( - directory: string, - options: { - maxDirectoryEntries: number - maxPipePaths: number - seedPipePaths?: string[] - } -): string { - const source = getWindowsRelayLivenessProbeSource({ - maxDirectoryEntries: options.maxDirectoryEntries, - maxPipePaths: options.maxPipePaths, - directoryBufferSize: 1 - }) - const result = spawnSync( - process.execPath, - ['-e', source, directory, ...(options.seedPipePaths ?? [])], - { - encoding: 'utf8', - timeout: 5_000 - } - ) - if (result.status !== 0) { - throw new Error(`probe exited ${result.status}: ${result.stderr}`) - } - return result.stdout -} - -function withTempDirectory(run: (directory: string) => void): void { - const directory = mkdtempSync(join(tmpdir(), 'orca-relay-liveness-')) - try { - run(directory) - } finally { - rmSync(directory, { recursive: true, force: true }) - } -} - -describe('Windows relay liveness probe source', () => { - it('uses fixed-capacity incremental directory iteration', () => { - const source = getWindowsRelayLivenessProbeSource() - - expect(source).toContain( - `maxEntries=${WINDOWS_RELAY_LIVENESS_MAX_DIRECTORY_ENTRIES},maxPipes=${WINDOWS_RELAY_LIVENESS_MAX_PIPE_PATHS}` - ) - expect(source).toContain( - `opendirSync(dir,{bufferSize:${WINDOWS_RELAY_LIVENESS_DIRECTORY_BUFFER_SIZE}})` - ) - expect(source).toContain('directory.readSync()') - expect(source).not.toContain('readdirSync') - expect(source).not.toContain('pipes.includes') - }) - - it('allows exactly the directory-entry limit', () => { - withTempDirectory((directory) => { - writeFileSync(join(directory, 'one'), '') - writeFileSync(join(directory, 'two'), '') - - expect( - runProbe(directory, { - maxDirectoryEntries: 2, - maxPipePaths: 1, - seedPipePaths: [PIPE_A] - }) - ).toBe('WAITING') - }) - }) - - it('fails closed when the incremental iterator finds one entry beyond the limit', () => { - withTempDirectory((directory) => { - writeFileSync(join(directory, 'one'), '') - writeFileSync(join(directory, 'two'), '') - writeFileSync(join(directory, 'three'), '') - - expect( - runProbe(directory, { - maxDirectoryEntries: 2, - maxPipePaths: 1, - seedPipePaths: [PIPE_A] - }) - ).toBe(WINDOWS_RELAY_LIVENESS_INCONCLUSIVE_STATE) - }) - }) - - it('allows exactly the retained-pipe limit', () => { - withTempDirectory((directory) => { - writeFileSync(join(directory, '.windows-active-pipe-a'), PIPE_A) - writeFileSync(join(directory, '.windows-active-pipe-b'), PIPE_B) - - expect( - runProbe(directory, { - maxDirectoryEntries: 2, - maxPipePaths: 2 - }) - ).toBe('WAITING') - }) - }) - - it('fails closed before retaining a pipe beyond the limit', () => { - withTempDirectory((directory) => { - writeFileSync(join(directory, '.windows-active-pipe-a'), PIPE_A) - writeFileSync(join(directory, '.windows-active-pipe-b'), PIPE_B) - writeFileSync(join(directory, '.windows-active-pipe-c'), PIPE_C) - - expect( - runProbe(directory, { - maxDirectoryEntries: 3, - maxPipePaths: 2 - }) - ).toBe(WINDOWS_RELAY_LIVENESS_INCONCLUSIVE_STATE) - }) - }) - - it('fails closed when a marker exceeds its file-size limit', () => { - withTempDirectory((directory) => { - writeFileSync( - join(directory, '.windows-active-pipe-oversized'), - 'x'.repeat(GENERATED_NODE_MANAGED_FILE_MAX_BYTES + 1) - ) - - expect( - runProbe(directory, { - maxDirectoryEntries: 1, - maxPipePaths: 1 - }) - ).toBe(WINDOWS_RELAY_LIVENESS_INCONCLUSIVE_STATE) - }) - }) - - it('preserves the missing-directory liveness result', () => { - withTempDirectory((directory) => { - expect( - runProbe(join(directory, 'missing'), { - maxDirectoryEntries: 1, - maxPipePaths: 1 - }) - ).toBe('ALIVE') - }) - }) -}) diff --git a/src/main/ssh/ssh-relay-liveness-probe-source.ts b/src/main/ssh/ssh-relay-liveness-probe-source.ts deleted file mode 100644 index bc2e8063cc1..00000000000 --- a/src/main/ssh/ssh-relay-liveness-probe-source.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { getGeneratedNodeBoundedFileReaderSourceLines } from '../generated-node-bounded-file-reader' - -export const WINDOWS_RELAY_LIVENESS_MAX_DIRECTORY_ENTRIES = 4_096 -export const WINDOWS_RELAY_LIVENESS_MAX_PIPE_PATHS = 1_024 -export const WINDOWS_RELAY_LIVENESS_DIRECTORY_BUFFER_SIZE = 32 -export const WINDOWS_RELAY_LIVENESS_INCONCLUSIVE_STATE = 'INCONCLUSIVE' - -type WindowsRelayLivenessProbeSourceOptions = { - maxDirectoryEntries?: number - maxPipePaths?: number - directoryBufferSize?: number -} - -function positiveInteger(value: number | undefined, fallback: number, name: string): number { - const resolved = value ?? fallback - if (!Number.isSafeInteger(resolved) || resolved < 1) { - throw new Error(`${name} must be a positive integer`) - } - return resolved -} - -export function getWindowsRelayLivenessProbeSource( - options?: WindowsRelayLivenessProbeSourceOptions -): string { - const maxDirectoryEntries = positiveInteger( - options?.maxDirectoryEntries, - WINDOWS_RELAY_LIVENESS_MAX_DIRECTORY_ENTRIES, - 'maxDirectoryEntries' - ) - const maxPipePaths = positiveInteger( - options?.maxPipePaths, - WINDOWS_RELAY_LIVENESS_MAX_PIPE_PATHS, - 'maxPipePaths' - ) - const directoryBufferSize = positiveInteger( - options?.directoryBufferSize, - WINDOWS_RELAY_LIVENESS_DIRECTORY_BUFFER_SIZE, - 'directoryBufferSize' - ) - const boundedReadSource = getGeneratedNodeBoundedFileReaderSourceLines().join('') - - return [ - 'const fs=require("fs"),path=require("path"),net=require("net");', - boundedReadSource, - 'const [dir,...seed]=process.argv.slice(1);', - 'const valid=/^\\\\\\\\[.?]\\\\pipe\\\\orca-relay-[0-9a-f]{20}$/i;', - `const maxEntries=${maxDirectoryEntries},maxPipes=${maxPipePaths};`, - 'const pipes=[],seen=new Set();', - 'let markerCount=0,capacityExceeded=false;', - 'function addPipe(p){', - 'if(!valid.test(p)||seen.has(p))return;', - 'if(pipes.length>=maxPipes){capacityExceeded=true;return}', - 'seen.add(p);pipes.push(p)', - '}', - 'for(const p of seed){addPipe(p);if(capacityExceeded)break}', - 'let directory;', - 'if(!capacityExceeded)try{', - `directory=fs.opendirSync(dir,{bufferSize:${directoryBufferSize}});`, - 'let entryCount=0;', - 'while(true){', - 'const entry=directory.readSync();', - 'if(!entry)break;', - 'if(entryCount>=maxEntries){capacityExceeded=true;break}', - 'entryCount++;', - 'const name=entry.name;', - 'if(!name.startsWith(".windows-active-pipe-"))continue;', - 'markerCount++;', - 'const p=readOrcaManagedFileWithinLimit(fs,path.join(dir,name)).trim();', - 'addPipe(p);', - 'if(capacityExceeded)break', - '}', - '}catch(error){if(error&&error.code==="EFBIG")capacityExceeded=true}', - 'finally{if(directory)try{directory.closeSync()}catch{}}', - `if(capacityExceeded){process.stdout.write("${WINDOWS_RELAY_LIVENESS_INCONCLUSIVE_STATE}");process.exit(0)}`, - 'if(markerCount===0&&pipes.length===0){process.stdout.write("ALIVE");process.exit(0)}', - 'let i=0;', - 'function done(ok){process.stdout.write(ok?"ALIVE":"WAITING")}', - 'function next(){', - 'const pipe=pipes[i++];', - 'if(!pipe)return done(false);', - 'const s=net.connect(pipe);', - 'let settled=false;', - 'function finish(ok){if(settled)return;settled=true;s.destroy();if(ok)done(true);else next()}', - 's.setTimeout(200);', - 's.on("connect",()=>finish(true));', - 's.on("timeout",()=>finish(false));', - 's.on("error",()=>finish(false));', - '}', - 'next();' - ].join('') -} diff --git a/src/main/ssh/ssh-relay-session-data-delivery.test.ts b/src/main/ssh/ssh-relay-session-data-delivery.test.ts index 61283ffae0e..901f7940d31 100644 --- a/src/main/ssh/ssh-relay-session-data-delivery.test.ts +++ b/src/main/ssh/ssh-relay-session-data-delivery.test.ts @@ -56,12 +56,6 @@ vi.mock('../ipc/pty', () => ({ setPtyOwnership: vi.fn() })) -vi.mock('../ipc/pty-renderer-delivery-router', () => ({ - routeExternalPtyData: vi.fn(), - routeExternalPtyReplay: vi.fn(), - routeExternalPtyExit: vi.fn() -})) - vi.mock('../providers/ssh-filesystem-dispatch', () => ({ registerSshFilesystemProvider: vi.fn(), unregisterSshFilesystemProvider: vi.fn(), @@ -74,7 +68,6 @@ vi.mock('../providers/ssh-git-dispatch', () => ({ })) const { registerSshPtyProvider } = await import('../ipc/pty') -const { routeExternalPtyData } = await import('../ipc/pty-renderer-delivery-router') describe('SshRelaySession data delivery', () => { beforeEach(() => { @@ -83,9 +76,16 @@ describe('SshRelaySession data delivery', () => { mockDeploySuccess() }) - it('routes empty transformed relay spans with raw sequence metadata', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) + it('delivers empty transformed relay spans with raw sequence metadata', async () => { + const { mockConn, mockStore, mockPortForward, getMainWindow, mockWindow } = createMockDeps() + const runtime = { onPtyData: vi.fn(() => 17), onPtyExit: vi.fn() } + const session = new SshRelaySession( + 'target-1', + getMainWindow, + mockStore, + mockPortForward, + runtime as never + ) await session.establish(mockConn) const ptyProvider = vi.mocked(registerSshPtyProvider).mock.calls[0]?.[1] as unknown as { onData: ReturnType @@ -99,11 +99,14 @@ describe('SshRelaySession data delivery', () => { onData({ id: 'ssh-pty-1', data: '', sequenceChars: 9, transformed: true }) - expect(routeExternalPtyData).toHaveBeenCalledWith({ + expect(runtime.onPtyData).toHaveBeenCalledWith('ssh-pty-1', '', expect.any(Number), 9, true) + expect(mockWindow.webContents.send).toHaveBeenCalledWith('pty:data', { id: 'ssh-pty-1', data: '', sequenceChars: 9, - transformed: true + transformed: true, + seq: 17, + rawLength: 9 }) }) }) diff --git a/src/main/ssh/ssh-relay-session-incarnation.test.ts b/src/main/ssh/ssh-relay-session-incarnation.test.ts index 828de0fcef4..a98180308b2 100644 --- a/src/main/ssh/ssh-relay-session-incarnation.test.ts +++ b/src/main/ssh/ssh-relay-session-incarnation.test.ts @@ -51,11 +51,6 @@ vi.mock('../ipc/pty', () => ({ isCurrentPtyExit: vi.fn(() => true), answerStartupTerminalColorQueriesForPty: vi.fn((_id: string, data: string) => data) })) -vi.mock('../ipc/pty-renderer-delivery-router', () => ({ - routeExternalPtyData: vi.fn(), - routeExternalPtyReplay: vi.fn(), - routeExternalPtyExit: vi.fn() -})) vi.mock('../providers/ssh-filesystem-dispatch', () => ({ registerSshFilesystemProvider: vi.fn(), unregisterSshFilesystemProvider: vi.fn(), @@ -68,7 +63,6 @@ vi.mock('../providers/ssh-git-dispatch', () => ({ const { registerSshPtyProvider, clearProviderPtyState, deletePtyOwnership, isCurrentPtyExit } = await import('../ipc/pty') -const { routeExternalPtyExit } = await import('../ipc/pty-renderer-delivery-router') describe('SSH relay PTY incarnation exits', () => { beforeEach(() => { @@ -79,7 +73,7 @@ describe('SSH relay PTY incarnation exits', () => { }) it('drops a stale exit before ownership cleanup and propagates a current incarnation', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() + const { mockConn, mockStore, mockPortForward, getMainWindow, mockWindow } = createMockDeps() const runtime = { onPtyData: vi.fn(), onPtyExit: vi.fn() } const session = new SshRelaySession( 'target-1', @@ -105,19 +99,13 @@ describe('SSH relay PTY incarnation exits', () => { expect(deletePtyOwnership).not.toHaveBeenCalled() expect(mockStore.markSshRemotePtyLease).not.toHaveBeenCalled() expect(runtime.onPtyExit).not.toHaveBeenCalled() - expect(routeExternalPtyExit).not.toHaveBeenCalled() + expect(mockWindow.webContents.send).not.toHaveBeenCalledWith('pty:exit', expect.anything()) - const currentExit = { - id: 'ssh:target-1@@pty-reused', - code: 7, - incarnationId: 'current-incarnation' - } - onExit(currentExit) + onExit({ id: 'ssh:target-1@@pty-reused', code: 7, incarnationId: 'current-incarnation' }) expect(runtime.onPtyExit).toHaveBeenCalledWith( 'ssh:target-1@@pty-reused', 7, 'current-incarnation' ) - expect(routeExternalPtyExit).toHaveBeenCalledWith(currentExit) }) }) diff --git a/src/main/ssh/ssh-relay-session-reconnect-incarnation.test.ts b/src/main/ssh/ssh-relay-session-reconnect-incarnation.test.ts index ce89762c602..1a3e8721064 100644 --- a/src/main/ssh/ssh-relay-session-reconnect-incarnation.test.ts +++ b/src/main/ssh/ssh-relay-session-reconnect-incarnation.test.ts @@ -53,11 +53,6 @@ vi.mock('../ipc/pty', () => ({ isCurrentPtyExit: vi.fn(() => true), answerStartupTerminalColorQueriesForPty: vi.fn((_id: string, data: string) => data) })) -vi.mock('../ipc/pty-renderer-delivery-router', () => ({ - routeExternalPtyData: vi.fn(), - routeExternalPtyReplay: vi.fn(), - routeExternalPtyExit: vi.fn() -})) vi.mock('../providers/ssh-filesystem-dispatch', () => ({ registerSshFilesystemProvider: vi.fn(), unregisterSshFilesystemProvider: vi.fn(), @@ -75,7 +70,6 @@ const { setPtyOwnership, restorePtyIncarnation } = await import('../ipc/pty') -const { routeExternalPtyReplay } = await import('../ipc/pty-renderer-delivery-router') const APP_PTY_ID = 'ssh:target-1@@pty-live' const INCARNATION_LEAF_ID = '11111111-1111-4111-8111-111111111111' @@ -152,7 +146,7 @@ describe('SshRelaySession reconnect incarnation ordering', () => { }) it('does not restore a PTY whose matching exit shares the attach reply batch', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() + const { mockConn, mockStore, mockPortForward, getMainWindow, mockWindow } = createMockDeps() const incarnationId = 'incarnation-exited-during-attach' const runtime = { acceptPtyIncarnationForExit: vi.fn(), @@ -192,11 +186,15 @@ describe('SshRelaySession reconnect incarnation ordering', () => { 'pty-live', 'terminated' ) - expect(routeExternalPtyReplay).not.toHaveBeenCalled() + expect( + vi + .mocked(mockWindow.webContents.send) + .mock.calls.some(([channel]) => channel === 'pty:replay') + ).toBe(false) }) it('ignores an older incarnation exit while reconnecting a reused PTY id', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() + const { mockConn, mockStore, mockPortForward, getMainWindow, mockWindow } = createMockDeps() const currentIncarnationId = 'incarnation-current' const runtime = { acceptPtyIncarnationForExit: vi.fn(), @@ -239,7 +237,7 @@ describe('SshRelaySession reconnect incarnation ordering', () => { expect(mockStore.persistPtyBinding).toHaveBeenCalledWith( expect.objectContaining({ ptyId: APP_PTY_ID, incarnationId: currentIncarnationId }) ) - expect(routeExternalPtyReplay).toHaveBeenCalledWith({ + expect(mockWindow.webContents.send).toHaveBeenCalledWith('pty:replay', { id: APP_PTY_ID, data: 'live-output' }) diff --git a/src/main/ssh/ssh-relay-session.test.ts b/src/main/ssh/ssh-relay-session.test.ts index fc055643fc6..8fe7a292bcf 100644 --- a/src/main/ssh/ssh-relay-session.test.ts +++ b/src/main/ssh/ssh-relay-session.test.ts @@ -74,12 +74,6 @@ vi.mock('../ipc/pty', () => ({ isCurrentPtyExit: vi.fn(() => true) })) -vi.mock('../ipc/pty-renderer-delivery-router', () => ({ - routeExternalPtyData: vi.fn(), - routeExternalPtyReplay: vi.fn(), - routeExternalPtyExit: vi.fn() -})) - vi.mock('../providers/ssh-filesystem-dispatch', () => ({ registerSshFilesystemProvider: vi.fn(), unregisterSshFilesystemProvider: vi.fn(), @@ -92,6 +86,12 @@ vi.mock('../providers/ssh-git-dispatch', () => ({ })) const { deployAndLaunchRelay } = await import('./ssh-relay-deploy') +// Why: the hidden-delivery gate module is intentionally real (pure state, no +// electron deps) so the SSH parity tests exercise the same gate main uses. +const { markHiddenRendererPty, setRendererPtyDeliveryInterest } = + await import('../ipc/pty-hidden-delivery-gate') +const { _resetHiddenRendererPtyDeliveryGateForTest } = + await import('../ipc/pty-hidden-delivery-gate') const { execCommand } = await import('./ssh-relay-deploy-helpers') const { getRemoteHostPlatform } = await import('./ssh-remote-platform') const { @@ -106,8 +106,6 @@ const { registerSshFilesystemProvider, unregisterSshFilesystemProvider } = await import('../providers/ssh-filesystem-dispatch') const { registerSshGitProvider, unregisterSshGitProvider } = await import('../providers/ssh-git-dispatch') -const { routeExternalPtyData, routeExternalPtyReplay, routeExternalPtyExit } = - await import('../ipc/pty-renderer-delivery-router') describe('SshRelaySession', () => { beforeEach(() => { @@ -117,10 +115,80 @@ describe('SshRelaySession', () => { muxRequestMock.mockResolvedValue([]) mockDeploySuccess() vi.mocked(getPtyIdsForConnection).mockReturnValue([]) + _resetHiddenRendererPtyDeliveryGateForTest() }) - it('routes SSH PTY data and its captured producer credit through bounded delivery', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() + it('drops hidden-gated PTY data after runtime ingestion with one restore marker', async () => { + const { mockConn, mockStore, mockPortForward, getMainWindow, mockWindow } = createMockDeps() + const runtime = { + onPtyData: vi.fn(() => 99), + onPtyExit: vi.fn() + } + const session = new SshRelaySession( + 'target-1', + getMainWindow, + mockStore, + mockPortForward, + runtime as never + ) + await session.establish(mockConn) + const ptyProvider = vi.mocked(registerSshPtyProvider).mock.calls[0]?.[1] as unknown as { + onData: ReturnType + } + const onData = ptyProvider.onData.mock.calls[0]?.[0] as (payload: { + id: string + data: string + }) => void + + markHiddenRendererPty('ssh-pty-1') + onData({ id: 'ssh-pty-1', data: 'hidden ssh output' }) + + // Runtime ingestion still ran; renderer delivery shrank to one marker. + expect(runtime.onPtyData).toHaveBeenCalledWith( + 'ssh-pty-1', + 'hidden ssh output', + expect.any(Number), + 'hidden ssh output'.length, + undefined + ) + expect(mockWindow.webContents.send).toHaveBeenCalledTimes(1) + // Why out-of-band: an in-band empty pty:data sentinel is ambiguous with + // chunks fully consumed by renderer OSC-9999 stripping. + expect(mockWindow.webContents.send).toHaveBeenCalledWith('pty:modelRestoreNeeded', { + id: 'ssh-pty-1', + reason: 'hidden-drop', + markerSeq: 99 + }) + + onData({ id: 'ssh-pty-1', data: 'more hidden ssh output' }) + expect(mockWindow.webContents.send).toHaveBeenCalledTimes(1) + + // Delivery interest (renderer sidecars) suppresses the gate — parity with + // the local path in ipc/pty.ts. + setRendererPtyDeliveryInterest('ssh-pty-1', true) + onData({ id: 'ssh-pty-1', data: 'sidecar ssh bytes' }) + expect(mockWindow.webContents.send).toHaveBeenLastCalledWith('pty:data', { + id: 'ssh-pty-1', + data: 'sidecar ssh bytes', + seq: 99, + rawLength: 'sidecar ssh bytes'.length + }) + + // Non-hidden PTYs are unaffected. + onData({ id: 'ssh-pty-2', data: 'visible ssh output' }) + expect(mockWindow.webContents.send).toHaveBeenLastCalledWith('pty:data', { + id: 'ssh-pty-2', + data: 'visible ssh output', + seq: 99, + rawLength: 'visible ssh output'.length + }) + }) + + it('keeps hidden SSH delivery when the gate kill switch is off', async () => { + const { mockConn, mockStore, mockPortForward, getMainWindow, mockWindow } = createMockDeps() + ;(mockStore as unknown as { getSettings: () => unknown }).getSettings = vi.fn(() => ({ + terminalHiddenDeliveryGate: false + })) const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) await session.establish(mockConn) const ptyProvider = vi.mocked(registerSshPtyProvider).mock.calls[0]?.[1] as unknown as { @@ -129,16 +197,15 @@ describe('SshRelaySession', () => { const onData = ptyProvider.onData.mock.calls[0]?.[0] as (payload: { id: string data: string - upstreamCredit?: { charCount: number; acknowledge(chars: number): void } }) => void - const upstreamCredit = { charCount: 16, acknowledge: vi.fn() } - onData({ id: 'ssh-pty-1', data: 'remote PTY bytes', upstreamCredit }) + markHiddenRendererPty('ssh-pty-1') + onData({ id: 'ssh-pty-1', data: 'still delivered' }) - expect(routeExternalPtyData).toHaveBeenCalledWith({ + expect(mockWindow.webContents.send).toHaveBeenCalledWith('pty:data', { id: 'ssh-pty-1', - data: 'remote PTY bytes', - upstreamCredit + data: 'still delivered', + rawLength: 'still delivered'.length }) }) @@ -412,7 +479,7 @@ describe('SshRelaySession', () => { }) it('forwards reconnect replay after the attach attempt is still current', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() + const { mockConn, mockStore, mockPortForward, getMainWindow, mockWindow } = createMockDeps() const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) await session.establish(mockConn) vi.clearAllMocks() @@ -428,14 +495,14 @@ describe('SshRelaySession', () => { await session.reconnect(mockConn) - expect(routeExternalPtyReplay).toHaveBeenCalledWith({ + expect(mockWindow.webContents.send).toHaveBeenCalledWith('pty:replay', { id: 'ssh:target-1@@pty-1', data: 'restored-output' }) }) it('drops identical reconnect replay payloads inside one reconnect burst', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() + const { mockConn, mockStore, mockPortForward, getMainWindow, mockWindow } = createMockDeps() const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) await session.establish(mockConn) vi.clearAllMocks() @@ -452,8 +519,11 @@ describe('SshRelaySession', () => { await session.reconnect(mockConn) await session.reconnect(mockConn) + const replaySends = vi + .mocked(mockWindow.webContents.send) + .mock.calls.filter(([channel]) => channel === 'pty:replay') expect(mockAttach).toHaveBeenCalledTimes(2) - expect(routeExternalPtyReplay).toHaveBeenCalledTimes(1) + expect(replaySends).toHaveLength(1) }) it('establish re-attaches owned PTYs after explicit disconnect', async () => { @@ -542,7 +612,7 @@ describe('SshRelaySession', () => { }) it('does not expire a live reused relay id when attach rejects identity mismatch', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() + const { mockConn, mockStore, mockPortForward, getMainWindow, mockWindow } = createMockDeps() const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) await session.establish(mockConn) vi.clearAllMocks() @@ -577,7 +647,7 @@ describe('SshRelaySession', () => { expect(clearProviderPtyState).not.toHaveBeenCalledWith('ssh:target-1@@pty-1') expect(deletePtyOwnership).not.toHaveBeenCalledWith('ssh:target-1@@pty-1') expect(mockStore.markSshRemotePtyLease).not.toHaveBeenCalledWith('target-1', 'pty-1', 'expired') - expect(routeExternalPtyExit).not.toHaveBeenCalledWith({ + expect(mockWindow.webContents.send).not.toHaveBeenCalledWith('pty:exit', { id: 'ssh:target-1@@pty-1', code: -1 }) @@ -648,7 +718,7 @@ describe('SshRelaySession', () => { }) it('invalidates and broadcasts remote PTYs that cannot reattach after relay reconnect', async () => { - const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() + const { mockConn, mockStore, mockPortForward, getMainWindow, mockWindow } = createMockDeps() const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) await session.establish(mockConn) vi.clearAllMocks() @@ -671,7 +741,7 @@ describe('SshRelaySession', () => { expect(mockAttach).toHaveBeenCalledWith('pty-live') expect(clearProviderPtyState).toHaveBeenCalledWith('ssh:target-1@@pty-stale') expect(deletePtyOwnership).toHaveBeenCalledWith('ssh:target-1@@pty-stale') - expect(routeExternalPtyExit).toHaveBeenCalledWith({ + expect(mockWindow.webContents.send).toHaveBeenCalledWith('pty:exit', { id: 'ssh:target-1@@pty-stale', code: -1 }) diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index c3070ae4c1a..fdd0b115203 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -37,10 +37,10 @@ import { isCurrentPtyExit } from '../ipc/pty' import { - routeExternalPtyData, - routeExternalPtyExit, - routeExternalPtyReplay -} from '../ipc/pty-renderer-delivery-router' + recordHiddenRendererPtyDataDrop, + shouldDropHiddenRendererPtyData +} from '../ipc/pty-hidden-delivery-gate' +import type { PtyModelRestoreNeededEvent } from '../../shared/pty-model-restore-marker' import { registerSshFilesystemProvider, unregisterSshFilesystemProvider, @@ -919,10 +919,45 @@ export class SshRelaySession { private wireUpPtyEvents(ptyProvider: SshPtyProvider): void { ptyProvider.onData((payload) => { - routeExternalPtyData(payload) + const rawLength = payload.sequenceChars ?? payload.data.length + const seq = this.runtime?.onPtyData( + payload.id, + payload.data, + Date.now(), + rawLength, + payload.transformed + ) + const win = this.getMainWindow() + if (!win || win.isDestroyed()) { + return + } + // Why: hidden-delivery gate parity with ipc/pty.ts — latch model-restore out-of-band, never an in-band pty:data sentinel (OSC-9999-only chunks strip to empty). + const store = this.store as { getSettings?: Store['getSettings'] } + if (shouldDropHiddenRendererPtyData(payload.id, store.getSettings?.())) { + const drop = recordHiddenRendererPtyDataDrop(payload.id, rawLength) + if (drop.shouldEmitRestoreMarker) { + win.webContents.send('pty:modelRestoreNeeded', { + id: payload.id, + reason: 'hidden-drop', + ...(typeof seq === 'number' ? { markerSeq: seq } : {}) + } satisfies PtyModelRestoreNeededEvent) + } + return + } + if (payload.data.length > 0 || payload.transformed) { + win.webContents.send('pty:data', { + ...payload, + ...(typeof seq === 'number' ? { seq } : {}), + rawLength, + ...(payload.transformed ? { transformed: true } : {}) + }) + } }) ptyProvider.onReplay((payload) => { - routeExternalPtyReplay(payload) + const win = this.getMainWindow() + if (win && !win.isDestroyed()) { + win.webContents.send('pty:replay', payload) + } }) ptyProvider.onExit((payload) => { const pendingReattach = this.pendingPtyReattaches.get(payload.id) @@ -945,7 +980,10 @@ export class SshRelaySession { this.forwardedReattachReplayByPty.delete(payload.id) this.store.markSshRemotePtyLease(this.targetId, relayPtyId, 'terminated') this.runtime?.onPtyExit(payload.id, payload.code, payload.incarnationId) - routeExternalPtyExit(payload) + const win = this.getMainWindow() + if (win && !win.isDestroyed()) { + win.webContents.send('pty:exit', payload) + } } private replayFingerprint(data: string): string { @@ -970,7 +1008,10 @@ export class SshRelaySession { if (!data || !this.shouldForwardReattachReplay(appPtyId, data)) { return } - routeExternalPtyReplay({ id: appPtyId, data }) + const win = this.getMainWindow() + if (win && !win.isDestroyed()) { + win.webContents.send('pty:replay', { id: appPtyId, data }) + } } private async reattachKnownPtys(shouldContinue: () => boolean): Promise { @@ -1084,7 +1125,10 @@ export class SshRelaySession { this.forwardedReattachReplayByPty.delete(appPtyId) this.store.markSshRemotePtyLease(this.targetId, ptyId, 'expired') // Why: reattach failure means the remote process is gone; tell the renderer to clear the stale pane. - routeExternalPtyExit({ id: appPtyId, code: -1 }) + const win = this.getMainWindow() + if (win && !win.isDestroyed()) { + win.webContents.send('pty:exit', { id: appPtyId, code: -1 }) + } } finally { if (this.pendingPtyReattaches.get(appPtyId) === pendingReattach) { this.pendingPtyReattaches.delete(appPtyId) diff --git a/src/main/ssh/ssh-relay-versioned-install.test.ts b/src/main/ssh/ssh-relay-versioned-install.test.ts index 1b6390879a8..23d57757b5b 100644 --- a/src/main/ssh/ssh-relay-versioned-install.test.ts +++ b/src/main/ssh/ssh-relay-versioned-install.test.ts @@ -1,12 +1,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('fs', () => ({ - existsSync: vi.fn() -})) - -const readRelayVersionMarkerSyncMock = vi.hoisted(() => vi.fn()) -vi.mock('../../shared/relay-version-marker', () => ({ - readRelayVersionMarkerSync: readRelayVersionMarkerSyncMock + existsSync: vi.fn(), + readFileSync: vi.fn() })) vi.mock('./ssh-relay-deploy-helpers', () => ({ @@ -17,7 +13,7 @@ vi.mock('./ssh-connection-utils', () => ({ shellEscape: (s: string) => `'${s}'` })) -import { existsSync } from 'node:fs' +import { existsSync, readFileSync } from 'node:fs' import { readLocalFullVersion, computeRemoteRelayDir, @@ -38,11 +34,11 @@ import { import { execCommand } from './ssh-relay-deploy-helpers' import { getRemoteHostPlatform } from './ssh-remote-platform' import type { SshConnection } from './ssh-connection' -import { RELAY_BASE_DIRECTORY_LISTING_LIMIT_SENTINEL } from './ssh-relay-base-directory-listing' const conn = {} as SshConnection const mockExec = vi.mocked(execCommand) const mockExists = vi.mocked(existsSync) +const mockRead = vi.mocked(readFileSync) function decodePowerShellCommand(command: string): string { const match = command.match(/-EncodedCommand\s+([A-Za-z0-9+/=]+)/) @@ -56,7 +52,7 @@ describe('readLocalFullVersion', () => { it('returns trimmed contents of the .version file', () => { mockExists.mockReturnValue(true) - readRelayVersionMarkerSyncMock.mockReturnValue('0.1.0+deadbeef') + mockRead.mockReturnValue('0.1.0+deadbeef\n') expect(readLocalFullVersion('/local/relay')).toBe('0.1.0+deadbeef') }) @@ -67,7 +63,7 @@ describe('readLocalFullVersion', () => { it('throws when the .version file is empty', () => { mockExists.mockReturnValue(true) - readRelayVersionMarkerSyncMock.mockReturnValue('') + mockRead.mockReturnValue(' \n') expect(() => readLocalFullVersion('/local/relay')).toThrow(/is empty/) }) }) @@ -513,9 +509,7 @@ describe('relay GC claim', () => { const releaseScript = decodePowerShellCommand(mockExec.mock.calls[2]?.[1] ?? '') expect(ownerScript).toContain('Set-Content -LiteralPath') expect(ownerScript).toContain('.gc-claim/.gc-owner') - expect(releaseScript).toContain('[System.IO.File]::Open') - expect(releaseScript).toContain('New-Object byte[] 1025') - expect(releaseScript).not.toContain('Get-Content') + expect(releaseScript).toContain('Get-Content -LiteralPath') expect(releaseScript).toContain('-cne') expect(releaseScript).toContain('Remove-Item -LiteralPath') expect(releaseScript).toContain("'RELEASED'") @@ -576,16 +570,6 @@ describe('gcOldRelayVersions', () => { ).toBe(false) }) - it('does not inspect or delete entries from a capacity-limited listing', async () => { - mockExec.mockResolvedValueOnce( - `relay-0.1.0+aaa\n${RELAY_BASE_DIRECTORY_LISTING_LIMIT_SENTINEL}\n` - ) - - await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb') - - expect(mockExec).toHaveBeenCalledTimes(1) - }) - it('cleans only strict POSIX orphan tombstones even with no relay candidates', async () => { mockExec .mockResolvedValueOnce( diff --git a/src/main/ssh/ssh-relay-versioned-install.ts b/src/main/ssh/ssh-relay-versioned-install.ts index e35b9de6c80..9945c997ef2 100644 --- a/src/main/ssh/ssh-relay-versioned-install.ts +++ b/src/main/ssh/ssh-relay-versioned-install.ts @@ -5,8 +5,7 @@ // See: docs/ssh-relay-versioned-install-dirs.md import { join } from 'node:path' -import { existsSync } from 'node:fs' -import { readRelayVersionMarkerSync } from '../../shared/relay-version-marker' +import { existsSync, readFileSync } from 'node:fs' import type { SshConnection } from './ssh-connection' import { RELAY_REMOTE_DIR } from './relay-protocol' import { execCommand } from './ssh-relay-deploy-helpers' @@ -27,7 +26,6 @@ import { removeRemoteTreeCommand, writeRemoteEmptyFileCommand } from './ssh-remote-commands' -import { isRelayBaseDirectoryListingLimited } from './ssh-relay-base-directory-listing' import { getRemoteHostPlatform, isWindowsRemoteHost, @@ -79,7 +77,7 @@ export function readLocalFullVersion(localRelayDir: string): string { `This usually indicates a packaging or build problem; reinstall Orca.` ) } - const v = readRelayVersionMarkerSync(versionFile) + const v = readFileSync(versionFile, 'utf-8').trim() if (!v) { throw new Error( `Orca's local relay version marker at ${versionFile} is empty. ` + @@ -192,9 +190,6 @@ export async function gcOldRelayVersions( } catch { return } - if (isRelayBaseDirectoryListingLimited(listing)) { - return - } const entries = listing .split('\n') .map((s) => s.trim()) diff --git a/src/main/ssh/ssh-remote-cli-host-passthrough.ts b/src/main/ssh/ssh-remote-cli-host-passthrough.ts index 9271a953ced..3ca55edfd69 100644 --- a/src/main/ssh/ssh-remote-cli-host-passthrough.ts +++ b/src/main/ssh/ssh-remote-cli-host-passthrough.ts @@ -9,7 +9,6 @@ import { spawn as nodeSpawn } from 'node:child_process' import { existsSync } from 'node:fs' import { join } from 'node:path' import { getCanonicalUserDataPath } from '../persistence' -import { GrowingByteBuffer } from '../../shared/growing-byte-buffer' export type RemoteOrcaCliRequest = { argv: string[] @@ -227,7 +226,8 @@ export async function runHostOrcaCliPassthrough( } class CappedOutputCollector { - private readonly output = new GrowingByteBuffer() + private readonly chunks: Buffer[] = [] + private bytes = 0 private truncated = false constructor(private readonly maxBytes: number) {} @@ -236,17 +236,19 @@ class CappedOutputCollector { if (this.truncated) { return } - const remaining = this.maxBytes - this.output.byteLength + const remaining = this.maxBytes - this.bytes if (chunk.length >= remaining) { - this.output.append(chunk.subarray(0, remaining)) + this.chunks.push(chunk.subarray(0, remaining)) + this.bytes = this.maxBytes this.truncated = true return } - this.output.append(chunk) + this.chunks.push(chunk) + this.bytes += chunk.length } toString(): string { - const text = this.output.toString() + const text = Buffer.concat(this.chunks).toString('utf8') return this.truncated ? `${text}\n[orca ssh cli] output truncated\n` : text } } diff --git a/src/main/ssh/ssh-remote-commands.test.ts b/src/main/ssh/ssh-remote-commands.test.ts index ea269cd79d6..594ed2864d5 100644 --- a/src/main/ssh/ssh-remote-commands.test.ts +++ b/src/main/ssh/ssh-remote-commands.test.ts @@ -11,7 +11,6 @@ import { import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it } from 'vitest' -import { GENERATED_NODE_MANAGED_FILE_MAX_BYTES } from '../generated-node-bounded-file-reader' import { lockAgeSecondsCommand, tryCreateInstallLockCommand, @@ -173,11 +172,6 @@ describe('ssh remote command builders', () => { expect(script).toContain('markerCount===0&&pipes.length===0') expect(script).toContain('C:\\Program Files\\nodejs') expect(script).not.toContain('Win32_Process') - expect(script).toContain( - `readOrcaManagedFileWithinLimit(fs, path, maxBytes = ${GENERATED_NODE_MANAGED_FILE_MAX_BYTES})` - ) - expect(script).toContain('readOrcaManagedFileWithinLimit(fs,path.join(dir,name)).trim()') - expect(script).not.toContain('readFileSync') expect(listRelayBaseDirsCommand(windows, 'C:/Users/me/.orca-remote')).toContain( '-EncodedCommand' ) @@ -249,9 +243,6 @@ describe('ssh remote command builders', () => { expect(posixCommand).toContain('steal_generation + 1') expect(posixCommand).not.toContain('.next.') expect(posixCommand).toContain('trap') - expect(posixCommand).toContain('find "$steal_parent" -mindepth 1 -maxdepth 1') - expect(posixCommand).toContain('-exec rm -rf {} +') - expect(posixCommand).not.toContain('rm -rf "$steal_root".*') expect(windowsScript).toContain('$lock.steal') expect(windowsScript).toContain('$stealGeneration++') expect(windowsScript).toContain('-gt 1200') diff --git a/src/main/ssh/ssh-remote-commands.ts b/src/main/ssh/ssh-remote-commands.ts index 4a2c244df3c..155fb5ba66f 100644 --- a/src/main/ssh/ssh-remote-commands.ts +++ b/src/main/ssh/ssh-remote-commands.ts @@ -1,6 +1,4 @@ import type { RemoteHostPlatform } from './ssh-remote-platform' -import { getRelayBaseDirectoryListingCommand } from './ssh-relay-base-directory-listing' -import { getWindowsRelayLivenessProbeSource } from './ssh-relay-liveness-probe-source' import { isWindowsRemoteHost, joinRemotePath, remoteDirname } from './ssh-remote-platform' import { powerShellCommand, powerShellLiteral, powerShellNativeArg } from './ssh-remote-powershell' import { shellEscape } from './ssh-connection-utils' @@ -105,7 +103,17 @@ export function probeRelayInstalledCommand( } export function listRelayBaseDirsCommand(host: RemoteHostPlatform, baseDir: string): string { - return getRelayBaseDirectoryListingCommand(host, baseDir) + if (!isWindowsRemoteHost(host)) { + return `ls -1 ${shellEscape(baseDir)} 2>/dev/null || true` + } + return powerShellCommand( + [ + `$base = ${powerShellLiteral(baseDir)}`, + 'if (Test-Path -LiteralPath $base -PathType Container) {', + 'Get-ChildItem -LiteralPath $base -Directory | ForEach-Object { $_.Name }', + '}' + ].join(' ') + ) } export function probeDirectoryExistsCommand(host: RemoteHostPlatform, remotePath: string): string { @@ -146,7 +154,35 @@ export function relayLivenessProbeCommand( if (!windowsOptions) { return powerShellCommand("'ALIVE'") } - const js = getWindowsRelayLivenessProbeSource() + const js = [ + 'const fs=require("fs"),path=require("path"),net=require("net");', + 'const [dir,...seed]=process.argv.slice(1);', + 'const valid=/^\\\\\\\\[.?]\\\\pipe\\\\orca-relay-[0-9a-f]{20}$/i;', + 'const pipes=[];', + 'let markerCount=0;', + 'for(const p of seed){if(valid.test(p)&&!pipes.includes(p))pipes.push(p)}', + 'try{for(const name of fs.readdirSync(dir)){', + 'if(!name.startsWith(".windows-active-pipe-"))continue;', + 'markerCount++;', + 'const p=fs.readFileSync(path.join(dir,name),"utf8").trim();', + 'if(valid.test(p)&&!pipes.includes(p))pipes.push(p)', + '}}catch{}', + 'if(markerCount===0&&pipes.length===0){process.stdout.write("ALIVE");process.exit(0)}', + 'let i=0;', + 'function done(ok){process.stdout.write(ok?"ALIVE":"WAITING")}', + 'function next(){', + 'const pipe=pipes[i++];', + 'if(!pipe)return done(false);', + 'const s=net.connect(pipe);', + 'let settled=false;', + 'function finish(ok){if(settled)return;settled=true;s.destroy();if(ok)done(true);else next()}', + 's.setTimeout(200);', + 's.on("connect",()=>finish(true));', + 's.on("timeout",()=>finish(false));', + 's.on("error",()=>finish(false));', + '}', + 'next();' + ].join('') return commandWithNodePath( host, windowsOptions.nodePath, diff --git a/src/main/ssh/ssh-remote-node-candidate-probe.test.ts b/src/main/ssh/ssh-remote-node-candidate-probe.test.ts deleted file mode 100644 index ad9089a38f4..00000000000 --- a/src/main/ssh/ssh-remote-node-candidate-probe.test.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { execFileSync } from 'node:child_process' -import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { describe, expect, it } from 'vitest' -import { - buildPosixRemoteNodeCandidateProbe, - REMOTE_NODE_CANDIDATE_LIMIT_SENTINEL, - REMOTE_NODE_CANDIDATE_MAX_COUNT, - REMOTE_NODE_CANDIDATE_MAX_UTF8_BYTES, - REMOTE_NODE_PROFILE_MAX_BYTES -} from './ssh-remote-node-candidate-probe' - -describe('POSIX remote Node candidate probe', () => { - it('uses bounded profile reads, batched directory iteration, and bounded output', () => { - const script = buildPosixRemoteNodeCandidateProbe() - - expect(script).toContain('dd if="$nvm_file" bs=1024 count=65') - expect(script).toContain(`-le ${REMOTE_NODE_PROFILE_MAX_BYTES}`) - expect(script).toContain(`find "$candidate_root" -mindepth 1 -maxdepth 1 ! -name '.*'`) - expect(script).toContain('-exec sh -c') - expect(script).toContain('{} +') - expect(script).toContain('| sort') - expect(script).toContain(`candidate_count>=${REMOTE_NODE_CANDIDATE_MAX_COUNT}`) - expect(script).toContain(`output_bytes+line_bytes>${REMOTE_NODE_CANDIDATE_MAX_UTF8_BYTES}`) - expect(script).toContain(REMOTE_NODE_CANDIDATE_LIMIT_SENTINEL) - expect(script).not.toContain('/*/') - }) - - it.runIf(process.platform !== 'win32')( - 'accepts an exact-limit profile and ignores its first byte of overflow', - () => { - const home = mkdtempSync(join(tmpdir(), 'orca-node-profile-limit-')) - try { - const nodePath = join(home, 'tilde-nvm', 'versions', 'node', 'v20', 'bin', 'node') - mkdirSync(join(home, 'tilde-nvm', 'versions', 'node', 'v20', 'bin'), { - recursive: true - }) - writeFileSync(nodePath, '#!/bin/sh\nexit 0\n') - chmodSync(nodePath, 0o755) - const assignment = 'export NVM_DIR=~/tilde-nvm\n' - const exactProfile = - assignment + '#'.repeat(REMOTE_NODE_PROFILE_MAX_BYTES - assignment.length) - writeFileSync(join(home, '.profile'), exactProfile) - const script = buildPosixRemoteNodeCandidateProbe() - const environment = { ...process.env, HOME: home, NVM_DIR: '' } - - const exactOutput = execFileSync('/bin/sh', ['-c', script], { - encoding: 'utf8', - env: environment - }) - expect(exactOutput.split('\n')).toContain(nodePath) - - writeFileSync(join(home, '.profile'), `${exactProfile}x`) - const oversizedOutput = execFileSync('/bin/sh', ['-c', script], { - encoding: 'utf8', - env: environment - }) - expect(oversizedOutput.split('\n')).not.toContain(nodePath) - } finally { - rmSync(home, { recursive: true, force: true }) - } - } - ) - - it.runIf(process.platform !== 'win32')( - 'preserves lexical version ordering within each bounded manager directory', - () => { - const home = mkdtempSync(join(tmpdir(), 'orca-node-version-order-')) - try { - const nvmDir = join(home, 'nvm') - const newer = join(nvmDir, 'versions', 'node', 'v22', 'bin', 'node') - const older = join(nvmDir, 'versions', 'node', 'v18', 'bin', 'node') - for (const nodePath of [newer, older]) { - mkdirSync(join(nodePath, '..'), { recursive: true }) - writeFileSync(nodePath, '#!/bin/sh\nexit 0\n') - chmodSync(nodePath, 0o755) - } - - const output = execFileSync('/bin/sh', ['-c', buildPosixRemoteNodeCandidateProbe()], { - encoding: 'utf8', - env: { ...process.env, HOME: home, NVM_DIR: nvmDir } - }).split('\n') - - expect(output.indexOf(older)).toBeLessThan(output.indexOf(newer)) - } finally { - rmSync(home, { recursive: true, force: true }) - } - } - ) -}) diff --git a/src/main/ssh/ssh-remote-node-candidate-probe.ts b/src/main/ssh/ssh-remote-node-candidate-probe.ts deleted file mode 100644 index ca458712ee6..00000000000 --- a/src/main/ssh/ssh-remote-node-candidate-probe.ts +++ /dev/null @@ -1,133 +0,0 @@ -export const REMOTE_NODE_PROFILE_MAX_BYTES = 64 * 1024 -export const REMOTE_NODE_NVM_DIR_MAX_BYTES = 4 * 1024 -export const REMOTE_NODE_CANDIDATE_MAX_COUNT = 256 -export const REMOTE_NODE_CANDIDATE_MAX_UTF8_BYTES = 64 * 1024 -export const REMOTE_NODE_CANDIDATE_LIMIT_SENTINEL = '__ORCA_NODE_CANDIDATES_TOO_LARGE__' - -type RemoteNodeCandidateProbeOptions = { - maxCandidates?: number - maxUtf8Bytes?: number -} - -function positiveInteger(value: number | undefined, fallback: number, name: string): number { - const resolved = value ?? fallback - if (!Number.isSafeInteger(resolved) || resolved < 1) { - throw new Error(`${name} must be a positive integer`) - } - return resolved -} - -export function isRemoteNodeCandidateProbeLimited(output: string): boolean { - return output.split(/\r?\n/).some((line) => line.trim() === REMOTE_NODE_CANDIDATE_LIMIT_SENTINEL) -} - -export function buildPosixRemoteNodeCandidateProbe( - options?: RemoteNodeCandidateProbeOptions -): string { - const maxCandidates = positiveInteger( - options?.maxCandidates, - REMOTE_NODE_CANDIDATE_MAX_COUNT, - 'maxCandidates' - ) - const maxUtf8Bytes = positiveInteger( - options?.maxUtf8Bytes, - REMOTE_NODE_CANDIDATE_MAX_UTF8_BYTES, - 'maxUtf8Bytes' - ) - - return ` -nvm_dirs=\${NVM_DIR:-"$HOME/.nvm"} -nvm_dirs_bytes=$(LC_ALL=C printf %s "$nvm_dirs" | wc -c | tr -d '[:space:]') -[ "\${nvm_dirs_bytes:-${REMOTE_NODE_NVM_DIR_MAX_BYTES + 1}}" -le ${REMOTE_NODE_NVM_DIR_MAX_BYTES} ] 2>/dev/null || nvm_dirs="$HOME/.nvm" -for nvm_file in "$HOME/.profile" "$HOME/.bash_profile" "$HOME/.bashrc" "$HOME/.zprofile" "$HOME/.zshrc" -do - [ -r "$nvm_file" ] || continue - profile_bytes=$(dd if="$nvm_file" bs=1024 count=65 2>/dev/null | wc -c | tr -d '[:space:]') - [ "\${profile_bytes:-${REMOTE_NODE_PROFILE_MAX_BYTES + 1}}" -le ${REMOTE_NODE_PROFILE_MAX_BYTES} ] 2>/dev/null || continue - nvm_dir_from_file=$(dd if="$nvm_file" bs=1024 count=64 2>/dev/null | sed -n 's/^[[:space:]]*export[[:space:]][[:space:]]*NVM_DIR[[:space:]]*=[[:space:]]*//p; s/^[[:space:]]*NVM_DIR[[:space:]]*=[[:space:]]*//p' | tail -n 1) - case "$nvm_dir_from_file" in - \\"*\\") nvm_dir_from_file=\${nvm_dir_from_file#\\"}; nvm_dir_from_file=\${nvm_dir_from_file%%\\"*} ;; - \\'*\\') nvm_dir_from_file=\${nvm_dir_from_file#\\'}; nvm_dir_from_file=\${nvm_dir_from_file%%\\'*} ;; - *) nvm_dir_from_file=\${nvm_dir_from_file%%[[:space:]]*} ;; - esac - case "$nvm_dir_from_file" in - '$HOME'*) nvm_dir_from_file="$HOME\${nvm_dir_from_file#'$HOME'}" ;; - "~/"*) nvm_dir_from_file="$HOME/\${nvm_dir_from_file#\\~/}" ;; - esac - nvm_dir_bytes=$(LC_ALL=C printf %s "$nvm_dir_from_file" | wc -c | tr -d '[:space:]') - [ "\${nvm_dir_bytes:-${REMOTE_NODE_NVM_DIR_MAX_BYTES + 1}}" -le ${REMOTE_NODE_NVM_DIR_MAX_BYTES} ] 2>/dev/null || continue - [ -n "$nvm_dir_from_file" ] && nvm_dirs="$nvm_dirs -$nvm_dir_from_file" -done - -find_node_candidates() { - candidate_root=$1 - candidate_suffix=$2 - [ -d "$candidate_root" ] || return 0 - find "$candidate_root" -mindepth 1 -maxdepth 1 ! -name '.*' -exec sh -c ' - candidate_suffix=$1 - shift - for candidate_dir - do - candidate=$candidate_dir/$candidate_suffix - [ -x "$candidate" ] && printf "%s\\n" "$candidate" - done - ' sh "$candidate_suffix" {} + | LC_ALL=C awk ' - { - line_bytes=length($0)+1 - if(candidate_count>=${maxCandidates} || output_bytes+line_bytes>${maxUtf8Bytes}) { - print "${REMOTE_NODE_CANDIDATE_LIMIT_SENTINEL}" - exit - } - print - candidate_count++ - output_bytes+=line_bytes - } - ' | sort -} - -{ - command -v node 2>/dev/null - printf '%s\\n' "$nvm_dirs" | while IFS= read -r nvm_dir - do - [ -n "$nvm_dir" ] || continue - find_node_candidates "$nvm_dir/versions/node" "bin/node" - done - for candidate in \\ - /usr/local/bin/node \\ - /opt/homebrew/bin/node \\ - "$HOME/.local/bin/node" \\ - "$HOME/.fnm/aliases/default/bin/node" - do - [ -x "$candidate" ] && printf '%s\\n' "$candidate" - done - find_node_candidates "$HOME/.fnm/node-versions" "installation/bin/node" - find_node_candidates "$HOME/.local/share/fnm/node-versions" "installation/bin/node" - candidate="$HOME/.local/share/mise/shims/node"; [ -x "$candidate" ] && printf '%s\\n' "$candidate" - find_node_candidates "$HOME/.local/share/mise/installs/node" "bin/node" - candidate="$HOME/.asdf/shims/node"; [ -x "$candidate" ] && printf '%s\\n' "$candidate" - find_node_candidates "$HOME/.asdf/installs/nodejs" "bin/node" - candidate="$HOME/.volta/bin/node"; [ -x "$candidate" ] && printf '%s\\n' "$candidate" - find_node_candidates "/usr/local/n/versions/node" "bin/node" -} | LC_ALL=C awk ' - { - candidate=$0 - if(candidate=="${REMOTE_NODE_CANDIDATE_LIMIT_SENTINEL}") { - print candidate - exit - } - if(candidate=="" || seen[candidate]) next - line_bytes=length(candidate)+1 - if(candidate_count>=${maxCandidates} || output_bytes+line_bytes>${maxUtf8Bytes}) { - print "${REMOTE_NODE_CANDIDATE_LIMIT_SENTINEL}" - exit - } - seen[candidate]=1 - print candidate - candidate_count++ - output_bytes+=line_bytes - } -' -true -` -} diff --git a/src/main/ssh/ssh-remote-node-resolution.test.ts b/src/main/ssh/ssh-remote-node-resolution.test.ts index ff8846eb3b2..a48687284ad 100644 --- a/src/main/ssh/ssh-remote-node-resolution.test.ts +++ b/src/main/ssh/ssh-remote-node-resolution.test.ts @@ -4,7 +4,6 @@ import os from 'node:os' import path from 'node:path' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { SshConnection } from './ssh-connection' -import { REMOTE_NODE_CANDIDATE_LIMIT_SENTINEL } from './ssh-remote-node-candidate-probe' import { getRemoteHostPlatform } from './ssh-remote-platform' const execCommandMock = vi.hoisted(() => vi.fn()) @@ -100,9 +99,7 @@ describe('resolveRemoteNodePath', () => { await resolveRemoteNodePath(conn) const callScript = execCommandMock.mock.calls[0]![1] as string - expect(callScript).toContain( - 'find_node_candidates "$HOME/.local/share/mise/installs/node" "bin/node"' - ) + expect(callScript).toContain('"$HOME/.local/share/mise/installs/node"/*/bin/node') }) it('probes asdf install directories', async () => { @@ -113,7 +110,7 @@ describe('resolveRemoteNodePath', () => { await resolveRemoteNodePath(conn) const callScript = execCommandMock.mock.calls[0]![1] as string - expect(callScript).toContain('find_node_candidates "$HOME/.asdf/installs/nodejs" "bin/node"') + expect(callScript).toContain('"$HOME/.asdf/installs/nodejs"/*/bin/node') }) it('probes volta bin directory', async () => { @@ -137,10 +134,10 @@ describe('resolveRemoteNodePath', () => { const callScript = execCommandMock.mock.calls[0]![1] as string expect(callScript).toContain('nvm_dirs=${NVM_DIR:-"$HOME/.nvm"}') expect(callScript).toContain('NVM_DIR[[:space:]]*=') - expect(callScript).toContain('find_node_candidates "$nvm_dir/versions/node" "bin/node"') + expect(callScript).toContain('"$nvm_dir"/versions/node/*/bin/node') }) - it('streams quoted version-manager directories without shell globs', async () => { + it('quotes version-manager directory prefixes while leaving globs active', async () => { execCommandMock .mockResolvedValueOnce('/home/u/.fnm/node-versions/v20.11.0/installation/bin/node\n') .mockResolvedValueOnce('v20.11.0\n') @@ -148,17 +145,10 @@ describe('resolveRemoteNodePath', () => { await resolveRemoteNodePath(conn) const callScript = execCommandMock.mock.calls[0]![1] as string - expect(callScript).toContain( - 'find_node_candidates "$HOME/.fnm/node-versions" "installation/bin/node"' - ) - expect(callScript).toContain( - 'find_node_candidates "$HOME/.local/share/fnm/node-versions" "installation/bin/node"' - ) - expect(callScript).toContain( - 'find_node_candidates "$HOME/.local/share/mise/installs/node" "bin/node"' - ) - expect(callScript).toContain('find_node_candidates "$HOME/.asdf/installs/nodejs" "bin/node"') - expect(callScript).not.toContain('/*/') + expect(callScript).toContain('"$HOME/.fnm/node-versions"/*/installation/bin/node') + expect(callScript).toContain('"$HOME/.local/share/fnm/node-versions"/*/installation/bin/node') + expect(callScript).toContain('"$HOME/.local/share/mise/installs/node"/*/bin/node') + expect(callScript).toContain('"$HOME/.asdf/installs/nodejs"/*/bin/node') }) it('probes fnm XDG data directory installs', async () => { @@ -286,20 +276,6 @@ describe('resolveRemoteNodePath', () => { }) }) - it('falls back without probing a partial capacity-limited candidate inventory', async () => { - execCommandMock - .mockResolvedValueOnce(`/old/node\n${REMOTE_NODE_CANDIDATE_LIMIT_SENTINEL}\n`) - .mockResolvedValueOnce('/bin/bash') - .mockResolvedValueOnce('/home/u/.nvm/versions/node/v20.11.0/bin/node\n') - .mockResolvedValueOnce('v20.11.0\n') - - await expect(resolveRemoteNodePath(conn)).resolves.toBe( - '/home/u/.nvm/versions/node/v20.11.0/bin/node' - ) - - expect(execCommandMock.mock.calls[1]?.[1]).toBe('echo "${SHELL:-/bin/sh}"') - }) - it('falls back to the login shell when every path-probe candidate is too old', async () => { execCommandMock .mockResolvedValueOnce('/old/node\n') // path probe diff --git a/src/main/ssh/ssh-remote-node-resolution.ts b/src/main/ssh/ssh-remote-node-resolution.ts index 5ec0c0e2452..1f2af057039 100644 --- a/src/main/ssh/ssh-remote-node-resolution.ts +++ b/src/main/ssh/ssh-remote-node-resolution.ts @@ -15,10 +15,6 @@ import { } from './ssh-remote-node-toolchain-probe' import { isSshSessionLimitError } from './ssh-session-limit-error' import { buildSshLoginShellCommand } from './ssh-login-shell-command' -import { - buildPosixRemoteNodeCandidateProbe, - isRemoteNodeCandidateProbeLimited -} from './ssh-remote-node-candidate-probe' // Why: the login-shell fallback catches custom PATH setups in ~/.profile that // the path probes don't cover. Interactive configs (conda prompts, etc.) can @@ -62,13 +58,54 @@ async function tryResolveViaKnownPaths( conn: SshConnection, options?: RemoteNodeResolutionOptions ): Promise { - const script = buildPosixRemoteNodeCandidateProbe() + const script = ` +command -v node 2>/dev/null +nvm_dirs=\${NVM_DIR:-"$HOME/.nvm"} +for nvm_file in "$HOME/.profile" "$HOME/.bash_profile" "$HOME/.bashrc" "$HOME/.zprofile" "$HOME/.zshrc" +do + [ -r "$nvm_file" ] || continue + nvm_dir_from_file=$(sed -n 's/^[[:space:]]*export[[:space:]][[:space:]]*NVM_DIR[[:space:]]*=[[:space:]]*//p; s/^[[:space:]]*NVM_DIR[[:space:]]*=[[:space:]]*//p' "$nvm_file" | tail -n 1) + case "$nvm_dir_from_file" in + \\"*\\") nvm_dir_from_file=\${nvm_dir_from_file#\\"}; nvm_dir_from_file=\${nvm_dir_from_file%%\\"*} ;; + \\'*\\') nvm_dir_from_file=\${nvm_dir_from_file#\\'}; nvm_dir_from_file=\${nvm_dir_from_file%%\\'*} ;; + *) nvm_dir_from_file=\${nvm_dir_from_file%%[[:space:]]*} ;; + esac + case "$nvm_dir_from_file" in + '$HOME'*) nvm_dir_from_file="$HOME\${nvm_dir_from_file#'$HOME'}" ;; + "~/"*) nvm_dir_from_file="$HOME/\${nvm_dir_from_file#\\~/}" ;; + esac + [ -n "$nvm_dir_from_file" ] && nvm_dirs="$nvm_dirs +$nvm_dir_from_file" +done +printf '%s\\n' "$nvm_dirs" | while IFS= read -r nvm_dir +do + [ -n "$nvm_dir" ] || continue + for candidate in "$nvm_dir"/versions/node/*/bin/node + do + [ -x "$candidate" ] && printf '%s\\n' "$candidate" + done +done +for candidate in \\ + /usr/local/bin/node \\ + /opt/homebrew/bin/node \\ + "$HOME/.local/bin/node" \\ + "$HOME/.fnm/aliases/default/bin/node" \\ + "$HOME/.fnm/node-versions"/*/installation/bin/node \\ + "$HOME/.local/share/fnm/node-versions"/*/installation/bin/node \\ + "$HOME/.local/share/mise/shims/node" \\ + "$HOME/.local/share/mise/installs/node"/*/bin/node \\ + "$HOME/.asdf/shims/node" \\ + "$HOME/.asdf/installs/nodejs"/*/bin/node \\ + "$HOME/.volta/bin/node" \\ + /usr/local/n/versions/node/*/bin/node +do + [ -x "$candidate" ] && printf '%s\\n' "$candidate" +done +true +` try { const result = await execCommandWithOptionalOptions(conn, script, signalOnlyOptions(options)) - if (isRemoteNodeCandidateProbeLimited(result)) { - return null - } const seen = new Set() for (const line of result.split('\n')) { const candidate = line.trim() diff --git a/src/main/ssh/ssh-remote-orca-cli.test.ts b/src/main/ssh/ssh-remote-orca-cli.test.ts index e99cc30cb69..3d1c3e60793 100644 --- a/src/main/ssh/ssh-remote-orca-cli.test.ts +++ b/src/main/ssh/ssh-remote-orca-cli.test.ts @@ -70,17 +70,9 @@ describe('runRemoteOrcaCli', () => { getUnreadMessages: vi.fn((handle: string) => messages.filter((message) => message.to_handle === handle && message.read_at === null) ), - countUnreadMessages: vi.fn( - (handle: string) => - messages.filter((message) => message.to_handle === handle && message.read_at === null) - .length - ), getAllMessagesForHandle: vi.fn((handle: string) => messages.filter((message) => message.to_handle === handle) ), - countAllMessagesForHandle: vi.fn( - (handle: string) => messages.filter((message) => message.to_handle === handle).length - ), markAsRead: vi.fn((ids: string[]) => { for (const message of messages) { if (ids.includes(message.id)) { diff --git a/src/main/ssh/ssh-stream-reader-memory.test.ts b/src/main/ssh/ssh-stream-reader-memory.test.ts deleted file mode 100644 index 8cbe291dc92..00000000000 --- a/src/main/ssh/ssh-stream-reader-memory.test.ts +++ /dev/null @@ -1,432 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { - MAX_GIT_RESPONSE_STREAM_BYTES, - MAX_GIT_RESPONSE_STREAM_CHUNKS, - STREAM_CHUNK_SIZE, - MessageType, - encodeFrame -} from './relay-protocol' -import { SshChannelMultiplexer, type MultiplexerTransport } from './ssh-channel-multiplexer' -import { readFileViaStream } from './ssh-filesystem-stream-reader' -import { requestGitStreamable } from './ssh-git-response-stream-reader' -import { - MAX_PRE_METADATA_STREAM_ENCODED_BYTES, - PreMetadataStreamFrameBuffer, - SshPreMetadataStreamBudget, - SshStreamAssemblyBudget -} from './ssh-stream-reader-memory' - -type Notification = { method: string; params: Record | undefined } - -function createStreamMux(): { - mux: Record - emit: (method: string, params: Record) => void - resolveRequest: (value: unknown) => void - rejectRequest: (error: Error) => void - dispose: (reason: 'shutdown' | 'connection_lost') => void - notifications: Notification[] -} { - const handlers = new Map) => void>>() - const disposeHandlers = new Set<(reason: 'shutdown' | 'connection_lost') => void>() - const notifications: Notification[] = [] - let resolveRequest!: (value: unknown) => void - let rejectRequest!: (error: Error) => void - const requestPromise = new Promise((resolve, reject) => { - resolveRequest = resolve - rejectRequest = reject - }) - const mux = { - request: vi.fn(() => requestPromise), - notify: vi.fn((method: string, params?: Record) => { - notifications.push({ method, params }) - }), - onNotificationByMethod: vi.fn( - (method: string, handler: (params: Record) => void) => { - const current = handlers.get(method) ?? new Set() - current.add(handler) - handlers.set(method, current) - return () => current.delete(handler) - } - ), - onDispose: vi.fn((handler: (reason: 'shutdown' | 'connection_lost') => void) => { - disposeHandlers.add(handler) - return () => disposeHandlers.delete(handler) - }), - isDisposed: vi.fn(() => false) - } - return { - mux, - emit: (method, params) => { - for (const handler of handlers.get(method) ?? []) { - handler(params) - } - }, - resolveRequest, - rejectRequest, - dispose: (reason) => { - for (const handler of disposeHandlers) { - handler(reason) - } - }, - notifications - } -} - -async function flushRequestResolution(): Promise { - await Promise.resolve() - await Promise.resolve() -} - -function encodeMuxResponse(requestId: number, result: unknown, sequence: number): Buffer { - return encodeFrame( - MessageType.Regular, - sequence, - 0, - Buffer.from(JSON.stringify({ jsonrpc: '2.0', id: requestId, result })) - ) -} - -afterEach(() => { - vi.useRealTimers() -}) - -describe('SSH stream assembly aggregate budget', () => { - it('bounds concurrent reservations and releases them idempotently', () => { - const budget = new SshStreamAssemblyBudget(10) - const releaseSix = budget.reserve(6) - const releaseFour = budget.reserve(4) - - expect(releaseSix).not.toBeNull() - expect(releaseFour).not.toBeNull() - expect(budget.reserve(1)).toBeNull() - expect(budget.retainedBytes).toBe(10) - - releaseSix?.() - releaseSix?.() - expect(budget.retainedBytes).toBe(4) - expect(budget.reserve(6)).not.toBeNull() - }) -}) - -describe('SSH pre-metadata stream aggregate budget', () => { - it('bounds concurrent frame buffers by count and bytes', () => { - const budget = new SshPreMetadataStreamBudget(2, 5) - const first = new PreMetadataStreamFrameBuffer(budget) - const second = new PreMetadataStreamFrameBuffer(budget) - const third = new PreMetadataStreamFrameBuffer(budget) - - expect(first.push({ kind: 'chunk', params: { streamId: 1, seq: 0, data: 'abc' } })).toBe(true) - expect(second.push({ kind: 'chunk', params: { streamId: 2, seq: 0, data: 'de' } })).toBe(true) - expect(third.push({ kind: 'end', params: { streamId: 3 } })).toBe(false) - expect(budget.retainedFrames).toBe(2) - expect(budget.retainedBytes).toBe(5) - - first.clear() - expect(third.push({ kind: 'end', params: { streamId: 3 } })).toBe(true) - expect(budget.retainedFrames).toBe(2) - second.clear() - third.clear() - expect(budget.retainedFrames).toBe(0) - expect(budget.retainedBytes).toBe(0) - }) - - it('releases shared reservations on success, stream error, request timeout, and disconnect', async () => { - const budget = new SshPreMetadataStreamBudget(4, 64) - const fileHarness = createStreamMux() - const errorHarness = createStreamMux() - const gitHarness = createStreamMux() - const disconnectHarness = createStreamMux() - const fileResult = readFileViaStream( - fileHarness.mux as never, - 'empty.txt', - undefined, - new SshStreamAssemblyBudget(1), - budget - ) - const errorResult = readFileViaStream( - errorHarness.mux as never, - 'error.txt', - undefined, - new SshStreamAssemblyBudget(1), - budget - ) - const gitResult = requestGitStreamable( - gitHarness.mux as never, - 'git.diff', - {}, - undefined, - new SshStreamAssemblyBudget(1), - budget - ) - const disconnectResult = readFileViaStream( - disconnectHarness.mux as never, - 'disconnected.txt', - undefined, - new SshStreamAssemblyBudget(1), - budget - ) - fileHarness.emit('fs.streamEnd', { streamId: 1 }) - errorHarness.emit('fs.streamError', { streamId: 2, message: 'remote read failed' }) - gitHarness.emit('git.responseEnd', { streamId: 3 }) - disconnectHarness.emit('fs.streamEnd', { streamId: 4 }) - expect(budget.retainedFrames).toBe(4) - - const streamRejection = expect(errorResult).rejects.toThrow('remote read failed') - const gitRejection = expect(gitResult).rejects.toThrow(/timed out/) - const disconnectRejection = expect(disconnectResult).rejects.toMatchObject({ - code: 'CONNECTION_LOST' - }) - fileHarness.resolveRequest({ streamId: 1, totalSize: 0, isBinary: false }) - errorHarness.resolveRequest({ streamId: 2, totalSize: 1, isBinary: false }) - gitHarness.rejectRequest(new Error('Request "git.diff" timed out after 30ms')) - disconnectHarness.dispose('connection_lost') - - await expect(fileResult).resolves.toEqual({ content: '', isBinary: false }) - await streamRejection - await gitRejection - await disconnectRejection - expect(budget.retainedFrames).toBe(0) - expect(budget.retainedBytes).toBe(0) - }) -}) - -describe('SSH Git response stream memory bounds', () => { - it('rejects and cancels an oversized stream marker', async () => { - const harness = createStreamMux() - const result = requestGitStreamable(harness.mux as never, 'git.diff', {}) - harness.resolveRequest({ - __orcaGitResponseStream: { - streamId: 7, - totalBytes: MAX_GIT_RESPONSE_STREAM_BYTES + 1, - chunkCount: 1 - } - }) - - await expect(result).rejects.toThrow(/exceeds client limit/) - expect(harness.notifications).toContainEqual({ - method: 'git.cancelResponseStream', - params: { streamId: 7 } - }) - }) - - it('rejects an excessive declared chunk count', async () => { - const harness = createStreamMux() - const result = requestGitStreamable(harness.mux as never, 'git.diff', {}) - harness.resolveRequest({ - __orcaGitResponseStream: { - streamId: 8, - totalBytes: 1, - chunkCount: MAX_GIT_RESPONSE_STREAM_CHUNKS + 1 - } - }) - - await expect(result).rejects.toThrow(/exceeds client limit/) - }) - - it('drops an oversized foreign frame received before the stream marker', async () => { - const harness = createStreamMux() - const result = requestGitStreamable(harness.mux as never, 'git.diff', {}) - harness.emit('git.responseChunk', { - streamId: 99, - seq: 0, - data: 'A'.repeat(MAX_PRE_METADATA_STREAM_ENCODED_BYTES + 1) - }) - const payload = Buffer.from('{"ok":true}') - harness.resolveRequest({ - __orcaGitResponseStream: { streamId: 9, totalBytes: payload.length, chunkCount: 1 } - }) - await flushRequestResolution() - harness.emit('git.responseChunk', { streamId: 9, seq: 0, data: payload.toString('base64') }) - harness.emit('git.responseEnd', { streamId: 9 }) - - await expect(result).resolves.toEqual({ ok: true }) - }) - - it('rejects an encoded chunk larger than the remaining declared bytes', async () => { - const harness = createStreamMux() - const result = requestGitStreamable(harness.mux as never, 'git.diff', {}) - harness.resolveRequest({ - __orcaGitResponseStream: { streamId: 10, totalBytes: 1, chunkCount: 1 } - }) - await flushRequestResolution() - harness.emit('git.responseChunk', { streamId: 10, seq: 0, data: 'A'.repeat(1_000_000) }) - - await expect(result).rejects.toThrow(/exceeds 1 remaining bytes/) - }) - - it('reassembles a valid result without changing its value', async () => { - const harness = createStreamMux() - const expected = { answer: 'unchanged', count: 2 } - const payload = Buffer.from(JSON.stringify(expected)) - const budget = new SshStreamAssemblyBudget(payload.length) - const result = requestGitStreamable(harness.mux as never, 'git.diff', {}, undefined, budget) - const first = payload.subarray(0, 7) - const second = payload.subarray(7) - harness.resolveRequest({ - __orcaGitResponseStream: { streamId: 11, totalBytes: payload.length, chunkCount: 2 } - }) - await flushRequestResolution() - expect(budget.retainedBytes).toBe(payload.length) - harness.emit('git.responseChunk', { streamId: 11, seq: 0, data: first.toString('base64') }) - harness.emit('git.responseChunk', { streamId: 11, seq: 1, data: second.toString('base64') }) - harness.emit('git.responseEnd', { streamId: 11 }) - - await expect(result).resolves.toEqual(expected) - expect(budget.retainedBytes).toBe(0) - }) - - it('rejects and cancels when the aggregate assembly budget is unavailable', async () => { - const harness = createStreamMux() - const budget = new SshStreamAssemblyBudget(1) - const held = budget.reserve(1) - const result = requestGitStreamable(harness.mux as never, 'git.diff', {}, undefined, budget) - harness.resolveRequest({ - __orcaGitResponseStream: { streamId: 16, totalBytes: 1, chunkCount: 1 } - }) - - await expect(result).rejects.toThrow(/Active SSH stream assembly would exceed/) - expect(harness.notifications).toContainEqual({ - method: 'git.cancelResponseStream', - params: { streamId: 16 } - }) - held?.() - }) -}) - -describe('SSH filesystem stream memory bounds', () => { - it('releases every active assembly immediately when the shared mux disconnects', async () => { - const dataHandlers: ((data: Buffer) => void)[] = [] - const transport: MultiplexerTransport = { - write: vi.fn(), - onData: (handler) => dataHandlers.push(handler), - onClose: vi.fn(), - close: vi.fn() - } - const mux = new SshChannelMultiplexer(transport) - const budget = new SshStreamAssemblyBudget(2) - const first = readFileViaStream(mux, 'first.txt', undefined, budget) - const second = readFileViaStream(mux, 'second.txt', undefined, budget) - const firstRejection = expect(first).rejects.toMatchObject({ code: 'CONNECTION_LOST' }) - const secondRejection = expect(second).rejects.toMatchObject({ code: 'CONNECTION_LOST' }) - - dataHandlers[0]( - Buffer.concat([ - encodeMuxResponse(1, { streamId: 21, totalSize: 1, isBinary: false }, 1), - encodeMuxResponse(2, { streamId: 22, totalSize: 1, isBinary: false }, 2) - ]) - ) - await flushRequestResolution() - expect(budget.retainedBytes).toBe(2) - - mux.dispose('connection_lost') - - expect(budget.retainedBytes).toBe(0) - await firstRejection - await secondRejection - }) - - it('drops an oversized foreign frame received before file metadata', async () => { - const harness = createStreamMux() - const result = readFileViaStream(harness.mux as never, 'tiny.txt') - harness.emit('fs.streamChunk', { - streamId: 90, - seq: 0, - data: 'A'.repeat(MAX_PRE_METADATA_STREAM_ENCODED_BYTES + 1) - }) - harness.resolveRequest({ - streamId: 12, - totalSize: 1, - isBinary: false, - resultEncoding: 'utf-8' - }) - await flushRequestResolution() - harness.emit('fs.streamChunk', { - streamId: 12, - seq: 0, - data: Buffer.from('a').toString('base64') - }) - harness.emit('fs.streamEnd', { streamId: 12 }) - - await expect(result).resolves.toEqual({ content: 'a', isBinary: false }) - }) - - it('validates encoded size before decoding a file chunk', async () => { - const harness = createStreamMux() - const result = readFileViaStream(harness.mux as never, 'tiny.txt') - harness.resolveRequest({ streamId: 13, totalSize: 1, isBinary: false }) - await flushRequestResolution() - harness.emit('fs.streamChunk', { streamId: 13, seq: 0, data: 'A'.repeat(1_000_000) }) - - await expect(result).rejects.toThrow(/Encoded chunk length mismatch/) - expect(harness.notifications).toContainEqual({ - method: 'fs.cancelStream', - params: { streamId: 13 } - }) - }) - - it('cancels a file stream that stops making progress', async () => { - vi.useFakeTimers() - const harness = createStreamMux() - const result = readFileViaStream(harness.mux as never, 'stalled.txt', { - inactivityTimeoutMs: 50 - }) - harness.resolveRequest({ streamId: 14, totalSize: 1, isBinary: false }) - await flushRequestResolution() - - const rejection = expect(result).rejects.toThrow(/stalled/) - await vi.advanceTimersByTimeAsync(51) - await rejection - expect(harness.notifications).toContainEqual({ - method: 'fs.cancelStream', - params: { streamId: 14 } - }) - }) - - it('resets the inactivity deadline on progress and preserves valid content', async () => { - vi.useFakeTimers() - const harness = createStreamMux() - const first = Buffer.alloc(STREAM_CHUNK_SIZE, 0x61) - const second = Buffer.from('z') - const budget = new SshStreamAssemblyBudget(first.length + second.length) - const result = readFileViaStream( - harness.mux as never, - 'progress.txt', - { inactivityTimeoutMs: 50 }, - budget - ) - harness.resolveRequest({ - streamId: 15, - totalSize: first.length + second.length, - isBinary: false, - resultEncoding: 'utf-8' - }) - await flushRequestResolution() - expect(budget.retainedBytes).toBe(first.length + second.length) - await vi.advanceTimersByTimeAsync(40) - harness.emit('fs.streamChunk', { streamId: 15, seq: 0, data: first.toString('base64') }) - await vi.advanceTimersByTimeAsync(40) - harness.emit('fs.streamChunk', { streamId: 15, seq: 1, data: second.toString('base64') }) - harness.emit('fs.streamEnd', { streamId: 15 }) - - await expect(result).resolves.toEqual({ - content: `${'a'.repeat(STREAM_CHUNK_SIZE)}z`, - isBinary: false - }) - expect(budget.retainedBytes).toBe(0) - }) - - it('rejects and cancels when file assembly would exceed the aggregate budget', async () => { - const harness = createStreamMux() - const budget = new SshStreamAssemblyBudget(1) - const held = budget.reserve(1) - const result = readFileViaStream(harness.mux as never, 'tiny.txt', undefined, budget) - harness.resolveRequest({ streamId: 17, totalSize: 1, isBinary: false }) - - await expect(result).rejects.toThrow(/Active SSH stream assembly would exceed/) - expect(harness.notifications).toContainEqual({ - method: 'fs.cancelStream', - params: { streamId: 17 } - }) - held?.() - }) -}) diff --git a/src/main/ssh/ssh-stream-reader-memory.ts b/src/main/ssh/ssh-stream-reader-memory.ts deleted file mode 100644 index c599a11a58b..00000000000 --- a/src/main/ssh/ssh-stream-reader-memory.ts +++ /dev/null @@ -1,205 +0,0 @@ -export const STREAM_READER_INACTIVITY_TIMEOUT_MS = 30_000 -export const MAX_PRE_METADATA_STREAM_FRAMES = 64 -export const MAX_PRE_METADATA_STREAM_ENCODED_BYTES = 8 * 1024 * 1024 -export const MAX_SSH_PRE_METADATA_STREAM_FRAMES = 256 -export const MAX_SSH_PRE_METADATA_STREAM_RETAINED_BYTES = 32 * 1024 * 1024 -export const MAX_SSH_STREAM_ASSEMBLY_BYTES = 128 * 1024 * 1024 - -export type BufferedStreamFrame = { - kind: 'chunk' | 'end' | 'error' - params: Record -} - -type RetainedStreamFrame = { - frame: BufferedStreamFrame - encodedBytes: number - release: () => void -} - -export class SshPreMetadataStreamBudget { - private frames = 0 - private bytes = 0 - - constructor( - readonly maxFrames: number, - readonly maxBytes: number - ) {} - - reserve(bytes: number): (() => void) | null { - if ( - !Number.isSafeInteger(bytes) || - bytes < 0 || - this.frames >= this.maxFrames || - bytes > this.maxBytes - this.bytes - ) { - return null - } - this.frames += 1 - this.bytes += bytes - let released = false - return () => { - if (released) { - return - } - released = true - this.frames -= 1 - this.bytes -= bytes - } - } - - get retainedFrames(): number { - return this.frames - } - - get retainedBytes(): number { - return this.bytes - } -} - -export const defaultSshPreMetadataStreamBudget = new SshPreMetadataStreamBudget( - MAX_SSH_PRE_METADATA_STREAM_FRAMES, - MAX_SSH_PRE_METADATA_STREAM_RETAINED_BYTES -) - -export class PreMetadataStreamFrameBuffer { - private frames: RetainedStreamFrame[] = [] - private encodedBytes = 0 - - constructor(private readonly budget: SshPreMetadataStreamBudget) {} - - push(frame: BufferedStreamFrame): boolean { - const retainedFrame = retainStreamFrameFields(frame) - const frameBytes = retainedStreamFrameBytes(retainedFrame) - if (frameBytes > MAX_PRE_METADATA_STREAM_ENCODED_BYTES) { - return false - } - while ( - this.frames.length >= MAX_PRE_METADATA_STREAM_FRAMES || - this.encodedBytes + frameBytes > MAX_PRE_METADATA_STREAM_ENCODED_BYTES - ) { - this.shift() - } - let release = this.budget.reserve(frameBytes) - while (!release && this.frames.length > 0) { - this.shift() - release = this.budget.reserve(frameBytes) - } - if (!release) { - return false - } - this.frames.push({ frame: retainedFrame, encodedBytes: frameBytes, release }) - this.encodedBytes += frameBytes - return true - } - - shift(): BufferedStreamFrame | undefined { - const retained = this.frames.shift() - if (retained) { - this.encodedBytes -= retained.encodedBytes - retained.release() - } - return retained?.frame - } - - get length(): number { - return this.frames.length - } - - clear(): void { - while (this.frames.length > 0) { - this.shift() - } - } -} - -function retainStreamFrameFields(frame: BufferedStreamFrame): BufferedStreamFrame { - const { params } = frame - if (frame.kind === 'chunk') { - return { - kind: frame.kind, - params: { - ...(typeof params.streamId === 'number' ? { streamId: params.streamId } : {}), - ...(typeof params.seq === 'number' ? { seq: params.seq } : {}), - ...(typeof params.data === 'string' ? { data: params.data } : {}) - } - } - } - if (frame.kind === 'error') { - return { - kind: frame.kind, - params: { - ...(typeof params.streamId === 'number' ? { streamId: params.streamId } : {}), - ...(typeof params.message === 'string' ? { message: params.message } : {}), - ...(typeof params.code === 'string' ? { code: params.code } : {}) - } - } - } - return { - kind: frame.kind, - params: typeof params.streamId === 'number' ? { streamId: params.streamId } : {} - } -} - -function retainedStreamFrameBytes(frame: BufferedStreamFrame): number { - let bytes = 0 - for (const value of Object.values(frame.params)) { - if (typeof value === 'string') { - bytes += Buffer.byteLength(value, 'utf-8') - } - } - return bytes -} - -export class SshStreamAssemblyBudget { - private retained = 0 - - constructor(readonly maxBytes: number) {} - - reserve(bytes: number): (() => void) | null { - if (!Number.isSafeInteger(bytes) || bytes < 0 || bytes > this.maxBytes - this.retained) { - return null - } - this.retained += bytes - let released = false - return () => { - if (released) { - return - } - released = true - this.retained -= bytes - } - } - - get retainedBytes(): number { - return this.retained - } -} - -export const defaultSshStreamAssemblyBudget = new SshStreamAssemblyBudget( - MAX_SSH_STREAM_ASSEMBLY_BYTES -) - -export function base64EncodedLength(rawBytes: number): number { - return Math.ceil(rawBytes / 3) * 4 -} - -export function createStreamInactivityDeadline( - timeoutMs: number, - onTimeout: () => void -): { reset: () => void; clear: () => void } { - let timer: ReturnType | null = null - const clear = (): void => { - if (timer) { - clearTimeout(timer) - timer = null - } - } - return { - reset: () => { - clear() - timer = setTimeout(onTimeout, timeoutMs) - timer.unref?.() - }, - clear - } -} diff --git a/src/main/ssh/ssh-system-fallback.test.ts b/src/main/ssh/ssh-system-fallback.test.ts index 61721899554..71f94204d58 100644 --- a/src/main/ssh/ssh-system-fallback.test.ts +++ b/src/main/ssh/ssh-system-fallback.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, readFileSync, rmSync, truncateSync, writeFileSync } from 'node:fs' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { EventEmitter } from 'node:events' @@ -37,7 +37,6 @@ import { spawnSystemSshPortForward } from './system-ssh-forward-process' import { getRemoteHostPlatform } from './ssh-remote-platform' import type { SshTarget } from '../../shared/ssh-types' import type { SystemSshResolvedConfig } from './ssh-control-socket' -import { SSH_DIRECTORY_TRANSFER_LIMITS } from './ssh-directory-transfer-budget' const SYSTEM_SSH_PATH = process.platform === 'win32' ? 'C:\\Windows\\System32\\OpenSSH\\ssh.exe' : '/usr/bin/ssh' @@ -799,24 +798,6 @@ describe('spawnSystemSsh', () => { ) }) - it('rejects an oversized Windows upload file before starting SSH', async () => { - const localDir = mkdtempSync(join(tmpdir(), 'orca-system-ssh-upload-')) - const oversized = join(localDir, 'oversized.bin') - writeFileSync(oversized, '') - truncateSync(oversized, SSH_DIRECTORY_TRANSFER_LIMITS.maximumFileBytes + 1) - - try { - await expect( - uploadDirectoryViaSystemSsh(createTarget(), localDir, 'C:/Users/me/.orca-remote/relay', { - hostPlatform: getRemoteHostPlatform('win32-x64') - }) - ).rejects.toMatchObject({ reason: 'file' }) - expect(spawnMock).not.toHaveBeenCalled() - } finally { - rmSync(localDir, { recursive: true, force: true }) - } - }) - it('forces standalone SSH for Windows upload packages when requested', async () => { const localDir = mkdtempSync(join(tmpdir(), 'orca-system-ssh-upload-')) writeFileSync(join(localDir, 'relay.js'), 'console.log("relay")') diff --git a/src/main/ssh/ssh-windows-log-tail-command.test.ts b/src/main/ssh/ssh-windows-log-tail-command.test.ts deleted file mode 100644 index ea3b9946df4..00000000000 --- a/src/main/ssh/ssh-windows-log-tail-command.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - windowsRelayTailLogCommand, - WINDOWS_RELAY_LOG_TAIL_MAX_BYTES -} from './ssh-windows-log-tail-command' - -function decodePowerShellCommand(command: string): string { - const match = command.match(/-EncodedCommand\s+([A-Za-z0-9+/=]+)/) - return match ? Buffer.from(match[1], 'base64').toString('utf16le') : '' -} - -describe('Windows relay log tail command', () => { - it('reads a fixed byte tail without line materialization', () => { - const script = decodePowerShellCommand( - windowsRelayTailLogCommand('C:/Users/u/relay.log', 'C:/Users/u/relay.err.log') - ) - - expect(script).toContain( - `[Math]::Min([long]${WINDOWS_RELAY_LOG_TAIL_MAX_BYTES}, $stream.Length)` - ) - expect(script).toContain('$stream.Seek(-[long]$readLength') - expect(script).toContain('$stream.Read($buffer, $offset, $readLength - $offset)') - expect(script).toContain('[System.IO.FileShare]::ReadWrite') - expect(script).toContain('--- stderr ---') - expect(script).not.toContain('Get-Content') - expect(script).not.toContain('-Tail 20') - }) -}) diff --git a/src/main/ssh/ssh-windows-log-tail-command.ts b/src/main/ssh/ssh-windows-log-tail-command.ts deleted file mode 100644 index 3d447e3f895..00000000000 --- a/src/main/ssh/ssh-windows-log-tail-command.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { powerShellCommand, powerShellLiteral } from './ssh-remote-powershell' - -export const WINDOWS_RELAY_LOG_TAIL_MAX_BYTES = 64 * 1024 - -export function windowsRelayTailLogCommand(logFile: string, errFile: string): string { - const script = [ - 'function Read-OrcaLogTail {', - 'param([string]$Path, [string]$Missing)', - 'if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return $Missing }', - '$stream = $null', - 'try {', - '$stream = [System.IO.File]::Open($Path, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::ReadWrite)', - `$readLength = [int][Math]::Min([long]${WINDOWS_RELAY_LOG_TAIL_MAX_BYTES}, $stream.Length)`, - '$buffer = New-Object byte[] $readLength', - '$null = $stream.Seek(-[long]$readLength, [System.IO.SeekOrigin]::End)', - '$offset = 0', - 'while ($offset -lt $readLength) {', - '$bytesRead = $stream.Read($buffer, $offset, $readLength - $offset)', - 'if ($bytesRead -eq 0) { break }', - '$offset += $bytesRead', - '}', - 'return [System.Text.Encoding]::UTF8.GetString($buffer, 0, $offset)', - "} catch { return '(unable to read log)' }", - 'finally { if ($null -ne $stream) { $stream.Dispose() } }', - '}', - `[Console]::Out.Write((Read-OrcaLogTail ${powerShellLiteral(logFile)} '(no stdout log)'))`, - '[Console]::Out.Write("`n--- stderr ---`n")', - `[Console]::Out.Write((Read-OrcaLogTail ${powerShellLiteral(errFile)} '(no stderr log)'))` - ].join('\n') - return powerShellCommand(script) -} diff --git a/src/main/ssh/system-ssh-file-transfer.ts b/src/main/ssh/system-ssh-file-transfer.ts index 0f508e28c07..72cb4425d11 100644 --- a/src/main/ssh/system-ssh-file-transfer.ts +++ b/src/main/ssh/system-ssh-file-transfer.ts @@ -1,10 +1,9 @@ import { spawn } from 'node:child_process' import { constants } from 'node:fs' -import { lstat, open, opendir } from 'node:fs/promises' +import { lstat, open, readdir } from 'node:fs/promises' import { join as pathJoin } from 'node:path' import { pipeline } from 'node:stream/promises' import type { SshTarget } from '../../shared/ssh-types' -import { stringifyJsonWithinByteLimit } from '../../shared/node-bounded-json-stringify' import { shellEscape, wrapRemoteCommandForPosixShell } from './ssh-connection-utils' import { findSystemSsh } from './system-ssh-binary' import { @@ -24,10 +23,6 @@ import { type ProcessResult } from './system-ssh-operation-lifecycle' import { writeBufferViaSystemSsh } from './system-ssh-file-binary-transfer' -import { - SshDirectoryTransferBudget, - WINDOWS_SSH_UPLOAD_PACKAGE_MAX_BYTES -} from './ssh-directory-transfer-budget' type SystemSshOperationOptions = SystemSshBuildArgsOptions & { signal?: AbortSignal @@ -118,9 +113,7 @@ async function uploadDirectoryViaSystemSshWindows( localDir, remoteDir, hostPlatform, - options.signal, - new SshDirectoryTransferBudget(), - 0 + options.signal ) await writeWindowsUploadPackageViaSystemSsh(target, entries, options) } @@ -140,42 +133,26 @@ async function collectWindowsUploadEntries( localDir: string, remoteDir: string, hostPlatform: RemoteHostPlatform, - signal: AbortSignal | undefined, - budget: SshDirectoryTransferBudget, - depth: number + signal: AbortSignal | undefined ): Promise { - budget.recordPath(remoteDir, depth, { countEntry: depth !== 0 }) const entries: WindowsUploadEntry[] = [{ kind: 'directory', path: remoteDir }] - const directory = await opendir(localDir) - try { - for await (const entry of directory) { - throwIfAborted(signal) - const localPath = pathJoin(localDir, entry.name) - const remotePath = joinRemotePath(hostPlatform, remoteDir, entry.name) - const statResult = await lstat(localPath) - if (statResult.isSymbolicLink() || (!statResult.isFile() && !statResult.isDirectory())) { - continue - } - if (statResult.isDirectory()) { - entries.push( - ...(await collectWindowsUploadEntries( - localPath, - remotePath, - hostPlatform, - signal, - budget, - depth + 1 - )) - ) - continue - } - budget.recordPath(remotePath, depth + 1) - budget.recordFile(statResult.size) - const buffer = await readLocalUploadFile(localPath, statResult) - entries.push({ kind: 'file', path: remotePath, contentsBase64: buffer.toString('base64') }) + const dirEntries = await readdir(localDir, { withFileTypes: true }) + for (const entry of dirEntries) { + throwIfAborted(signal) + const localPath = pathJoin(localDir, entry.name) + const remotePath = joinRemotePath(hostPlatform, remoteDir, entry.name) + const statResult = await lstat(localPath) + if (statResult.isSymbolicLink() || (!statResult.isFile() && !statResult.isDirectory())) { + continue } - } finally { - await directory.close().catch(() => undefined) + if (statResult.isDirectory()) { + entries.push( + ...(await collectWindowsUploadEntries(localPath, remotePath, hostPlatform, signal)) + ) + continue + } + const buffer = await readLocalUploadFile(localPath, statResult) + entries.push({ kind: 'file', path: remotePath, contentsBase64: buffer.toString('base64') }) } return entries } @@ -186,10 +163,6 @@ async function writeWindowsUploadPackageViaSystemSsh( options: SystemSshOperationOptions ): Promise { throwIfAborted(options.signal) - const serialized = stringifyJsonWithinByteLimit( - entries, - WINDOWS_SSH_UPLOAD_PACKAGE_MAX_BYTES - ).serialized const channel = spawnSystemSshCommand(target, makeWindowsUploadPackageCommand(), { wrapCommand: false, ...getSystemSshBuildArgsFromOperationOptions(options) @@ -200,7 +173,7 @@ async function writeWindowsUploadPackageViaSystemSsh( waitForChannelClose(channel, 'windows relay upload') ) if (!options.signal?.aborted) { - channel.stdin.end(serialized) + channel.stdin.end(JSON.stringify(entries)) } await closePromise } @@ -220,20 +193,7 @@ async function readLocalUploadFile( ) { throw new Error(`File changed during upload: ${localPath}`) } - const buffer = Buffer.allocUnsafe(openedStat.size) - let offset = 0 - while (offset < buffer.length) { - const result = await handle.read(buffer, offset, buffer.length - offset, offset) - if (result.bytesRead === 0) { - throw new Error(`File changed during upload: ${localPath}`) - } - offset += result.bytesRead - } - const probe = Buffer.allocUnsafe(1) - if ((await handle.read(probe, 0, 1, offset)).bytesRead !== 0) { - throw new Error(`File changed during upload: ${localPath}`) - } - return buffer + return await handle.readFile() } finally { await handle.close() } diff --git a/src/main/ssh/system-ssh-forward-process.test.ts b/src/main/ssh/system-ssh-forward-process.test.ts index b5e2fcaa3f5..4178d0e1796 100644 --- a/src/main/ssh/system-ssh-forward-process.test.ts +++ b/src/main/ssh/system-ssh-forward-process.test.ts @@ -34,7 +34,6 @@ import { waitForSystemSshForwardStop } from './system-ssh-forward-process' import type { SshTarget } from '../../shared/ssh-types' -import { SYSTEM_SSH_OUTPUT_TAIL_MAX_BYTES } from './system-ssh-output-tail' const SYSTEM_SSH_PATH = process.platform === 'win32' ? 'C:\\Windows\\System32\\OpenSSH\\ssh.exe' : '/usr/bin/ssh' @@ -248,22 +247,6 @@ describe('system SSH forward process', () => { await expect(pending).rejects.toThrow('bind: Address already in use') }) - it('keeps only a bounded stderr tail while waiting for forward startup', async () => { - vi.useFakeTimers() - const child = createFakeProcess() - connectMock.mockReturnValue(createFakeSocket()) - - const pending = waitForSystemSshForwardStartup(child as never, 3000) - child.stderr.emit( - 'data', - Buffer.from(`HEAD${'x'.repeat(SYSTEM_SSH_OUTPUT_TAIL_MAX_BYTES)}TAIL`) - ) - child.emit('exit', 255) - - await expect(pending).rejects.toThrow('TAIL') - await expect(pending).rejects.not.toThrow('HEAD') - }) - it('rejects startup when the ssh process emits an error', async () => { vi.useFakeTimers() const child = createFakeProcess() diff --git a/src/main/ssh/system-ssh-forward-process.ts b/src/main/ssh/system-ssh-forward-process.ts index 65a4732d426..13486101bfd 100644 --- a/src/main/ssh/system-ssh-forward-process.ts +++ b/src/main/ssh/system-ssh-forward-process.ts @@ -2,7 +2,6 @@ import { spawn, type ChildProcess } from 'node:child_process' import { connect, createServer } from 'node:net' import { buildSshArgs, findSystemSsh, type SystemSshBuildArgsOptions } from './ssh-system-fallback' import type { SshTarget } from '../../shared/ssh-types' -import { SystemSshOutputTail } from './system-ssh-output-tail' export const SYSTEM_SSH_FORWARD_STARTUP_GRACE_MS = 750 export const SYSTEM_SSH_FORWARD_LISTENER_PROBE_INTERVAL_MS = 50 @@ -104,7 +103,7 @@ export function waitForSystemSshForwardStartup( localPort: number ): Promise { return new Promise((resolve, reject) => { - const stderr = new SystemSshOutputTail() + let stderr = '' let settled = false let probeTimer: ReturnType | null = null let graceTimer: ReturnType | null = null @@ -128,13 +127,13 @@ export function waitForSystemSshForwardStartup( callback() } const onStderr = (chunk: Buffer): void => { - stderr.push(chunk) + stderr += chunk.toString('utf-8') } const onError = (error: Error): void => { finish(() => reject(error)) } const onExit = (code: number | null): void => { - finish(() => reject(systemSshForwardError(code, stderr.toString()))) + finish(() => reject(systemSshForwardError(code, stderr))) } const scheduleProbe = (): void => { probeTimer = setTimeout(() => { diff --git a/src/main/ssh/system-ssh-operation-lifecycle.test.ts b/src/main/ssh/system-ssh-operation-lifecycle.test.ts deleted file mode 100644 index bda9f6c9206..00000000000 --- a/src/main/ssh/system-ssh-operation-lifecycle.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { EventEmitter } from 'node:events' -import { describe, expect, it } from 'vitest' -import { waitForChannelClose, waitForProcess } from './system-ssh-operation-lifecycle' -import { SYSTEM_SSH_OUTPUT_TAIL_MAX_BYTES } from './system-ssh-output-tail' - -function createProcess(): EventEmitter & { - stderr: EventEmitter -} { - return Object.assign(new EventEmitter(), { stderr: new EventEmitter() }) -} - -function createChannel(): EventEmitter & { - stderr: EventEmitter -} { - return Object.assign(new EventEmitter(), { stderr: new EventEmitter() }) -} - -describe('system SSH operation output capture', () => { - it('returns a bounded stderr tail for successful child processes', async () => { - const process = createProcess() - const result = waitForProcess(process as never, 'test process') - - process.stderr.emit( - 'data', - Buffer.from(`HEAD${'x'.repeat(SYSTEM_SSH_OUTPUT_TAIL_MAX_BYTES)}TAIL`) - ) - process.emit('close', 0) - - await expect(result).resolves.toMatchObject({ - label: 'test process', - stderr: expect.stringContaining('TAIL') - }) - expect((await result).stderr).not.toContain('HEAD') - }) - - it('uses the bounded stderr tail in channel failure diagnostics', async () => { - const channel = createChannel() - const result = waitForChannelClose(channel as never, 'test channel') - - channel.stderr.emit( - 'data', - Buffer.from(`HEAD${'x'.repeat(SYSTEM_SSH_OUTPUT_TAIL_MAX_BYTES)}TAIL`) - ) - channel.emit('close', 1, null) - - await expect(result).rejects.toThrow('TAIL') - await expect(result).rejects.not.toThrow('HEAD') - expect(channel.stderr.listenerCount('data')).toBe(0) - }) -}) diff --git a/src/main/ssh/system-ssh-operation-lifecycle.ts b/src/main/ssh/system-ssh-operation-lifecycle.ts index 864669ad326..c9c4424325b 100644 --- a/src/main/ssh/system-ssh-operation-lifecycle.ts +++ b/src/main/ssh/system-ssh-operation-lifecycle.ts @@ -1,6 +1,5 @@ import type { ChildProcess } from 'node:child_process' import type { SystemSshCommandChannel } from './system-ssh-command' -import { SystemSshOutputTail } from './system-ssh-output-tail' export type ProcessResult = { label: string; stderr: string } @@ -9,7 +8,7 @@ export function waitForChannelClose( label: string ): Promise { return new Promise((resolve, reject) => { - const stderr = new SystemSshOutputTail() + let stderr = '' const cleanup = (): void => { channel.stderr.off('data', onStderrData) channel.off('error', onError) @@ -20,7 +19,7 @@ export function waitForChannelClose( fn(val as never) } const onStderrData = (data: Buffer): void => { - stderr.push(data) + stderr += data.toString('utf-8') } const onError = (err: Error): void => { settle(reject, err) @@ -28,7 +27,7 @@ export function waitForChannelClose( const onClose = (code: number | null, signal?: NodeJS.Signals | null): void => { if (code !== 0) { const detail = code === null ? `signal ${signal ?? 'unknown'}` : `exit ${code}` - settle(reject, new Error(`${label} failed (${detail}): ${stderr.toString().trim()}`)) + settle(reject, new Error(`${label} failed (${detail}): ${stderr.trim()}`)) return } settle(resolve) @@ -42,7 +41,7 @@ export function waitForChannelClose( export function waitForProcess(proc: ChildProcess, label: string): Promise { return new Promise((resolve, reject) => { - const stderr = new SystemSshOutputTail() + let stderr = '' const cleanup = (): void => { proc.stderr?.off('data', onStderrData) proc.off('error', onError) @@ -53,17 +52,17 @@ export function waitForProcess(proc: ChildProcess, label: string): Promise { - stderr.push(data) + stderr += data.toString('utf-8') } const onError = (err: Error): void => { settle(reject, err) } const onClose = (code: number | null): void => { if (code !== 0) { - settle(reject, new Error(`${label} failed (exit ${code}): ${stderr.toString().trim()}`)) + settle(reject, new Error(`${label} failed (exit ${code}): ${stderr.trim()}`)) return } - settle(resolve, { label, stderr: stderr.toString() }) + settle(resolve, { label, stderr }) } proc.stderr?.on('data', onStderrData) diff --git a/src/main/ssh/system-ssh-output-tail.test.ts b/src/main/ssh/system-ssh-output-tail.test.ts deleted file mode 100644 index d47332eaad7..00000000000 --- a/src/main/ssh/system-ssh-output-tail.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { SystemSshOutputTail } from './system-ssh-output-tail' - -describe('SystemSshOutputTail', () => { - it('preserves ordinary output exactly', () => { - const output = new SystemSshOutputTail(16) - output.push('first ') - output.push(Buffer.from('second')) - - expect(output.toString()).toBe('first second') - }) - - it('keeps only the newest bytes after many chunks', () => { - const output = new SystemSshOutputTail(8) - for (const chunk of ['123', '456', '789', 'tail']) { - output.push(chunk) - } - - expect(output.toString()).toBe('[earlier system SSH output truncated]\n6789tail') - }) - - it('copies a bounded slice instead of retaining a large chunk backing buffer', () => { - const output = new SystemSshOutputTail(4) - const large = Buffer.from('discard-prefix-tail') - output.push(large) - large.fill(0) - - expect(output.toString()).toBe('[earlier system SSH output truncated]\ntail') - }) -}) diff --git a/src/main/ssh/system-ssh-output-tail.ts b/src/main/ssh/system-ssh-output-tail.ts deleted file mode 100644 index 89fa190f820..00000000000 --- a/src/main/ssh/system-ssh-output-tail.ts +++ /dev/null @@ -1,37 +0,0 @@ -export const SYSTEM_SSH_OUTPUT_TAIL_MAX_BYTES = 64 * 1024 - -const TRUNCATION_NOTICE = '[earlier system SSH output truncated]\n' - -export class SystemSshOutputTail { - private buffer = Buffer.alloc(0) - private truncated = false - - constructor(private readonly maxBytes = SYSTEM_SSH_OUTPUT_TAIL_MAX_BYTES) { - if (!Number.isSafeInteger(maxBytes) || maxBytes < 0) { - throw new RangeError('System SSH output tail limit must be a non-negative safe integer') - } - } - - push(value: Buffer | string): void { - const chunk = Buffer.isBuffer(value) ? value : Buffer.from(value) - if (chunk.byteLength >= this.maxBytes) { - const hadBufferedOutput = this.buffer.byteLength > 0 - this.buffer = Buffer.from(chunk.subarray(chunk.byteLength - this.maxBytes)) - this.truncated ||= hadBufferedOutput || chunk.byteLength > this.maxBytes - return - } - const combinedBytes = this.buffer.byteLength + chunk.byteLength - if (combinedBytes <= this.maxBytes) { - this.buffer = Buffer.concat([this.buffer, chunk], combinedBytes) - return - } - const discardBytes = combinedBytes - this.maxBytes - this.buffer = Buffer.concat([this.buffer.subarray(discardBytes), chunk], this.maxBytes) - this.truncated = true - } - - toString(): string { - const text = this.buffer.toString('utf8') - return this.truncated ? `${TRUNCATION_NOTICE}${text}` : text - } -} diff --git a/src/main/ssh/system-ssh-port-forward-provider.ts b/src/main/ssh/system-ssh-port-forward-provider.ts index 5570628b69c..e2b53abab7b 100644 --- a/src/main/ssh/system-ssh-port-forward-provider.ts +++ b/src/main/ssh/system-ssh-port-forward-provider.ts @@ -8,7 +8,6 @@ import type { SshPortForwardProvider, StartedPortForward } from './ssh-port-forward-provider' -import { SystemSshOutputTail } from './system-ssh-output-tail' export class SystemSshPortForwardProvider implements SshPortForwardProvider { canHandle(conn: SshConnection): boolean { @@ -34,9 +33,17 @@ export class SystemSshPortForwardProvider implements SshPortForwardProvider { ) await forward.waitForStartup() - const stderr = new SystemSshOutputTail() + // Why: this stderr stays attached for the forward's whole lifetime but is + // only used to build the exit-error detail, so keep a bounded tail — a chatty + // remote sshd could otherwise grow it unbounded on a long-lived forward + // (mirrors MAX_RELAY_STARTUP_BUFFER_BYTES in ssh-relay-deploy-helpers). + const MAX_STDERR_TAIL_BYTES = 64 * 1024 + let stderr = '' const onStderr = (chunk: Buffer): void => { - stderr.push(chunk) + stderr += chunk.toString('utf-8') + if (stderr.length > MAX_STDERR_TAIL_BYTES) { + stderr = stderr.slice(-MAX_STDERR_TAIL_BYTES) + } } forward.process.stderr?.on('data', onStderr) @@ -53,7 +60,7 @@ export class SystemSshPortForwardProvider implements SshPortForwardProvider { forward.process.stderr?.off('data', onStderr) options.onUnexpectedClose?.(entry, { kind: 'unexpected-exit', - detail: systemSshForwardError(code, stderr.toString()).message + detail: systemSshForwardError(code, stderr).message }) }) diff --git a/src/main/startup/configure-process.test.ts b/src/main/startup/configure-process.test.ts index 1471153953a..7d582880f28 100644 --- a/src/main/startup/configure-process.test.ts +++ b/src/main/startup/configure-process.test.ts @@ -1,8 +1,7 @@ -import { mkdtempSync, rmSync, truncateSync, writeFileSync } from 'node:fs' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { homedir, tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' -import { ORCA_PERSISTED_STATE_MAX_BYTES } from '../../shared/persisted-state-file-bounds' vi.mock('electron', () => { const paths = new Map([['appData', '/tmp/app-data']]) @@ -303,14 +302,6 @@ describe('configureElectronNetworkCompatibility', () => { expect(shouldDisableHttp2ForElectronNetworking({ env: {}, userDataPath })).toBe(false) }) - it('ignores an oversized persisted state file before early startup parsing', async () => { - const { shouldDisableHttp2ForElectronNetworking } = await import('./configure-process') - const userDataPath = createUserDataDir({ electronHttp1CompatibilityMode: true }) - truncateSync(join(userDataPath, 'orca-data.json'), ORCA_PERSISTED_STATE_MAX_BYTES + 1) - - expect(shouldDisableHttp2ForElectronNetworking({ env: {}, userDataPath })).toBe(false) - }) - it('lets the environment override force compatibility on', async () => { const { shouldDisableHttp2ForElectronNetworking } = await import('./configure-process') diff --git a/src/main/startup/configure-process.ts b/src/main/startup/configure-process.ts index 62ed97a36bf..40ec5e2efbe 100644 --- a/src/main/startup/configure-process.ts +++ b/src/main/startup/configure-process.ts @@ -1,10 +1,9 @@ import { app } from 'electron' -import { existsSync, mkdirSync } from 'node:fs' +import { existsSync, mkdirSync, readFileSync } from 'node:fs' import { homedir } from 'node:os' import { join, resolve } from 'node:path' import { getVersionManagerBinPaths } from '../codex-cli/command' import { getMainE2EConfig } from '../e2e-config' -import { readPersistedStateJsonFileSync } from '../../shared/persisted-state-file-bounds' const DEV_PARENT_SHUTDOWN_GRACE_MS = 3000 const HTTP1_COMPATIBILITY_ENV_VAR = 'ORCA_DISABLE_HTTP2' @@ -38,9 +37,9 @@ function readPersistedHttp1CompatibilityMode(userDataPath: string): boolean { } try { - const { value: parsed } = readPersistedStateJsonFileSync<{ + const parsed = JSON.parse(readFileSync(dataFile, 'utf-8')) as { settings?: { electronHttp1CompatibilityMode?: unknown } - }>(dataFile) + } return parsed.settings?.electronHttp1CompatibilityMode === true } catch { return false diff --git a/src/main/startup/ensure-virtual-display.test.ts b/src/main/startup/ensure-virtual-display.test.ts index 1b2e26e62ed..f66d79fefea 100644 --- a/src/main/startup/ensure-virtual-display.test.ts +++ b/src/main/startup/ensure-virtual-display.test.ts @@ -1,11 +1,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { spawnMock, spawnSyncMock, existsSyncMock, boundedReadMock, rmSyncMock, appMock } = +const { spawnMock, spawnSyncMock, existsSyncMock, readFileSyncMock, rmSyncMock, appMock } = vi.hoisted(() => ({ spawnMock: vi.fn(), spawnSyncMock: vi.fn(), existsSyncMock: vi.fn(), - boundedReadMock: vi.fn(), + readFileSyncMock: vi.fn(), rmSyncMock: vi.fn(), appMock: { disableHardwareAcceleration: vi.fn(), @@ -17,11 +17,9 @@ const { spawnMock, spawnSyncMock, existsSyncMock, boundedReadMock, rmSyncMock, a vi.mock('child_process', () => ({ spawn: spawnMock, spawnSync: spawnSyncMock })) vi.mock('fs', () => ({ existsSync: existsSyncMock, + readFileSync: readFileSyncMock, rmSync: rmSyncMock })) -vi.mock('../../shared/node-bounded-file-reader', () => ({ - readNodeFileSyncWithinLimit: boundedReadMock -})) vi.mock('electron', () => ({ app: appMock })) const ORIGINAL_PLATFORM = process.platform @@ -36,7 +34,7 @@ describe('ensureVirtualDisplayForHeadlessServe', () => { spawnMock.mockReset() spawnSyncMock.mockReset() existsSyncMock.mockReset() - boundedReadMock.mockReset() + readFileSyncMock.mockReset() rmSyncMock.mockReset() appMock.disableHardwareAcceleration.mockReset() appMock.commandLine.appendSwitch.mockReset() @@ -117,7 +115,7 @@ describe('ensureVirtualDisplayForHeadlessServe', () => { setPlatform('linux') spawnSyncMock.mockReturnValue({ status: 0 }) existsSyncMock.mockReturnValue(true) // :99 socket + lock present - boundedReadMock.mockReturnValue({ buffer: Buffer.from('4321\n') }) // lock holds a PID + readFileSyncMock.mockReturnValue('4321\n') // lock holds a PID const killSpy = vi.spyOn(process, 'kill').mockReturnValue(true as never) // PID alive const { ensureVirtualDisplayForHeadlessServe } = await import('./ensure-virtual-display') @@ -133,7 +131,7 @@ describe('ensureVirtualDisplayForHeadlessServe', () => { setPlatform('linux') spawnSyncMock.mockReturnValue({ status: 0 }) existsSyncMock.mockReturnValue(true) // orphan socket + lock present - boundedReadMock.mockReturnValue({ buffer: Buffer.from('9999\n') }) + readFileSyncMock.mockReturnValue('9999\n') // PID is gone: process.kill throws ESRCH. const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => { throw new Error('ESRCH') diff --git a/src/main/startup/ensure-virtual-display.ts b/src/main/startup/ensure-virtual-display.ts index bc03c083885..a4adfed6113 100644 --- a/src/main/startup/ensure-virtual-display.ts +++ b/src/main/startup/ensure-virtual-display.ts @@ -1,7 +1,6 @@ import { spawn, spawnSync, type ChildProcess } from 'node:child_process' -import { existsSync, rmSync } from 'node:fs' +import { existsSync, readFileSync, rmSync } from 'node:fs' import { app } from 'electron' -import { readNodeFileSyncWithinLimit } from '../../shared/node-bounded-file-reader' // Why: headless `orca serve` backs browser panes with offscreen BrowserWindows. // On Linux, Electron has no display platform without an X server and segfaults @@ -13,7 +12,6 @@ const XVFB_STARTUP_TIMEOUT_MS = 5_000 const XVFB_POLL_INTERVAL_MS = 50 const VIRTUAL_DISPLAY_NUMBER = 99 const VIRTUAL_DISPLAY = `:${VIRTUAL_DISPLAY_NUMBER}` -export const X_DISPLAY_LOCK_MAX_BYTES = 64 let xvfbProcess: ChildProcess | null = null @@ -43,8 +41,13 @@ function isDisplayServerAlive(displayNumber: number): boolean { // No lock means no server claimed this display; the bare socket is stale. return false } - const pid = readXDisplayLockPid(lockPath) - if (pid === null) { + let pid: number + try { + pid = Number.parseInt(readFileSync(lockPath, 'utf8').trim(), 10) + } catch { + return false + } + if (!Number.isInteger(pid) || pid <= 0) { return false } try { @@ -56,18 +59,6 @@ function isDisplayServerAlive(displayNumber: number): boolean { } } -export function readXDisplayLockPid(lockPath: string): number | null { - try { - const raw = readNodeFileSyncWithinLimit(lockPath, X_DISPLAY_LOCK_MAX_BYTES) - .buffer.toString('utf8') - .trim() - const pid = Number.parseInt(raw, 10) - return Number.isInteger(pid) && pid > 0 ? pid : null - } catch { - return null - } -} - function removeStaleDisplayArtifacts(displayNumber: number): void { for (const path of [xDisplayLockPath(displayNumber), xvfbSocketPath(displayNumber)]) { try { diff --git a/src/main/startup/gpu-fallback-marker.test.ts b/src/main/startup/gpu-fallback-marker.test.ts index 43b1d3fa99e..c52fc3c1f11 100644 --- a/src/main/startup/gpu-fallback-marker.test.ts +++ b/src/main/startup/gpu-fallback-marker.test.ts @@ -1,10 +1,9 @@ -import { existsSync, mkdtempSync, rmSync, truncateSync, writeFileSync } from 'node:fs' +import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import os from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { GPU_FALLBACK_MARKER_FILE, - MAX_GPU_FALLBACK_MARKER_FILE_BYTES, clearGpuFallbackMarker, readActiveGpuFallbackMarker, readGpuFallbackMarker, @@ -100,13 +99,4 @@ describe('gpu-fallback-marker', () => { clearGpuFallbackMarker(userDataPath) expect(readGpuFallbackMarker(userDataPath)).toBeNull() }) - - it('clears an oversized sparse marker without loading it', () => { - const path = join(userDataPath, GPU_FALLBACK_MARKER_FILE) - writeFileSync(path, '{"schemeVersion":2}') - truncateSync(path, MAX_GPU_FALLBACK_MARKER_FILE_BYTES + 1) - - expect(readActiveGpuFallbackMarker(userDataPath, environment)).toBeNull() - expect(existsSync(path)).toBe(false) - }) }) diff --git a/src/main/startup/gpu-fallback-marker.ts b/src/main/startup/gpu-fallback-marker.ts index 38bb4e62ad4..e94e654b3ec 100644 --- a/src/main/startup/gpu-fallback-marker.ts +++ b/src/main/startup/gpu-fallback-marker.ts @@ -1,6 +1,5 @@ -import { existsSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { join } from 'node:path' -import { readNodeFileSyncWithinLimit } from '../../shared/node-bounded-file-reader' /** * Persisted "disable hardware acceleration for this build" marker. @@ -13,7 +12,6 @@ import { readNodeFileSyncWithinLimit } from '../../shared/node-bounded-file-read export const GPU_FALLBACK_MARKER_FILE = 'gpu-fallback.json' export const GPU_FALLBACK_SCHEME_VERSION = 2 -export const MAX_GPU_FALLBACK_MARKER_FILE_BYTES = 8 * 1024 export type GpuFallbackEnvironment = { appVersion: string @@ -38,12 +36,9 @@ function markerPath(userDataPath: string): string { export function readGpuFallbackMarker(userDataPath: string): GpuFallbackMarker | null { try { - const parsed = JSON.parse( - readNodeFileSyncWithinLimit( - markerPath(userDataPath), - MAX_GPU_FALLBACK_MARKER_FILE_BYTES - ).buffer.toString('utf8') - ) as Partial> + const parsed = JSON.parse(readFileSync(markerPath(userDataPath), 'utf-8')) as Partial< + Record + > if (parsed.schemeVersion !== GPU_FALLBACK_SCHEME_VERSION) { return null } diff --git a/src/main/startup/hydrate-shell-path.test.ts b/src/main/startup/hydrate-shell-path.test.ts index e085506a597..7ff6ee2ae81 100644 --- a/src/main/startup/hydrate-shell-path.test.ts +++ b/src/main/startup/hydrate-shell-path.test.ts @@ -5,7 +5,6 @@ import type { ChildProcessWithoutNullStreams } from 'node:child_process' import { _resetHydrateShellPathCache, hydrateShellPath, - MAX_SHELL_PATH_STDOUT_BYTES, mergePathSegments, type HydrationResult } from './hydrate-shell-path' @@ -158,45 +157,6 @@ describe('hydrateShellPath', () => { vi.useRealTimers() } }) - - it('kills a login shell whose stdout exceeds the PATH capture limit', async () => { - const proc = createMockShellProcess() - spawnMock.mockReturnValue(proc) - - const resultPromise = hydrateShellPath({ shellOverride: '/bin/zsh', force: true }) - proc.stdout.emit('data', Buffer.alloc(1024 * 1024 + 1, 0x78)) - - await expect(resultPromise).resolves.toEqual({ - segments: [], - ok: false, - failureReason: 'empty_path' - }) - expect(proc.kill).toHaveBeenCalledWith('SIGKILL') - expect(proc.stdout.listenerCount('data')).toBe(0) - expect(proc.listenerCount('error')).toBe(0) - expect(proc.listenerCount('close')).toBe(0) - }) - - it('captures PATH after tens of thousands of tiny stdout chunks', async () => { - const proc = createMockShellProcess() - spawnMock.mockReturnValue(proc) - const delimiterText = '__ORCA_SHELL_PATH__' - const output = Buffer.from( - `${'x'.repeat(MAX_SHELL_PATH_STDOUT_BYTES - 128)}${delimiterText}/bin${delimiterText}` - ) - - const resultPromise = hydrateShellPath({ shellOverride: '/bin/zsh', force: true }) - for (let offset = 0; offset < output.byteLength; offset += 16) { - proc.stdout.emit('data', output.subarray(offset, offset + 16)) - } - proc.emit('close') - - await expect(resultPromise).resolves.toEqual({ - segments: ['/bin'], - ok: true, - failureReason: 'none' - }) - }) }) describe('mergePathSegments', () => { diff --git a/src/main/startup/hydrate-shell-path.ts b/src/main/startup/hydrate-shell-path.ts index 69bba7c0ce8..9411b0437d3 100644 --- a/src/main/startup/hydrate-shell-path.ts +++ b/src/main/startup/hydrate-shell-path.ts @@ -1,7 +1,6 @@ import { spawn } from 'node:child_process' import { delimiter } from 'node:path' import type { ShellHydrationFailureReason } from '../../shared/types' -import { GrowingByteBuffer } from '../../shared/growing-byte-buffer' // Why: GUI-launched Electron on macOS/Linux inherits a minimal PATH from launchd // that does not include dirs appended by the user's shell rc files (~/.zshrc, @@ -17,7 +16,6 @@ import { GrowingByteBuffer } from '../../shared/growing-byte-buffer' const DELIMITER = '__ORCA_SHELL_PATH__' const SPAWN_TIMEOUT_MS = 5000 -export const MAX_SHELL_PATH_STDOUT_BYTES = 1024 * 1024 // ANSI escape sequences can leak into the captured output when the user's rc // files print banners or set colored prompts. Strip them before parsing. @@ -87,7 +85,7 @@ function spawnShellAndReadPath(shell: string): Promise { // and .bashrc/.zshrc are sourced — matches what `which` in Terminal sees. const command = `printf '%s' '${DELIMITER}'; printf '%s' "$PATH"; printf '%s' '${DELIMITER}'` let finished = false - const stdout = new GrowingByteBuffer() + let stdout = '' let timer: ReturnType | null = null const child = spawn(shell, ['-ilc', command], { @@ -131,15 +129,7 @@ function spawnShellAndReadPath(shell: string): Promise { }, SPAWN_TIMEOUT_MS) const onStdoutData = (chunk: Buffer): void => { - if (chunk.byteLength > MAX_SHELL_PATH_STDOUT_BYTES - stdout.byteLength) { - try { - child.kill('SIGKILL') - } catch {} - stdout.clear() - finish({ segments: [], ok: false, failureReason: 'empty_path' }) - return - } - stdout.append(chunk) + stdout += chunk.toString('utf8') } const onError = (): void => { @@ -147,7 +137,7 @@ function spawnShellAndReadPath(shell: string): Promise { } const onClose = (): void => { - const segments = parseCapturedPath(stdout.toString('utf8')) + const segments = parseCapturedPath(stdout) if (segments.length === 0) { finish({ segments: [], ok: false, failureReason: 'empty_path' }) return diff --git a/src/main/startup/windows-user-data-acl.test.ts b/src/main/startup/windows-user-data-acl.test.ts index ae860cb9916..b49510b0bb1 100644 --- a/src/main/startup/windows-user-data-acl.test.ts +++ b/src/main/startup/windows-user-data-acl.test.ts @@ -1,12 +1,11 @@ import { EventEmitter } from 'node:events' -import { mkdtempSync, readFileSync, rmSync, statSync, truncateSync, writeFileSync } from 'node:fs' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import os from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { ensureWindowsUserDataAclGrant, WINDOWS_ACL_GRANT_MARKER_FILE, - WINDOWS_ACL_GRANT_MARKER_MAX_BYTES, WINDOWS_ACL_GRANT_SCHEME_VERSION, type WindowsAclGrantResult } from './windows-user-data-acl' @@ -86,43 +85,6 @@ describe('ensureWindowsUserDataAclGrant', () => { expect(fake.calls).toHaveLength(0) }) - it('accepts a matching ACL marker at the exact byte boundary', async () => { - const marker = JSON.stringify({ - schemeVersion: WINDOWS_ACL_GRANT_SCHEME_VERSION, - identity: 'testuser', - grantedAt: 1 - }) - writeFileSync( - join(userDataPath, WINDOWS_ACL_GRANT_MARKER_FILE), - marker + ' '.repeat(WINDOWS_ACL_GRANT_MARKER_MAX_BYTES - Buffer.byteLength(marker)) - ) - const fake = createFakeSpawn(0) - - expect( - await awaitResult(userDataPath, { - identity: 'testuser', - spawnFn: fake.spawnFn as never - }) - ).toEqual({ mode: 'marker-hit' }) - expect(fake.calls).toHaveLength(0) - }) - - it('re-grants and replaces a sparse ACL marker one byte over the boundary', async () => { - const markerPath = join(userDataPath, WINDOWS_ACL_GRANT_MARKER_FILE) - writeFileSync(markerPath, '{"schemeVersion":1,"identity":"testuser","grantedAt":1}') - truncateSync(markerPath, WINDOWS_ACL_GRANT_MARKER_MAX_BYTES + 1) - const fake = createFakeSpawn(0) - - expect( - await awaitResult(userDataPath, { - identity: 'testuser', - spawnFn: fake.spawnFn as never - }) - ).toEqual({ mode: 'granted' }) - expect(fake.calls).toHaveLength(2) - expect(statSync(markerPath).size).toBeLessThan(WINDOWS_ACL_GRANT_MARKER_MAX_BYTES) - }) - it('re-grants when the marker belongs to a different identity', async () => { writeFileSync( join(userDataPath, WINDOWS_ACL_GRANT_MARKER_FILE), diff --git a/src/main/startup/windows-user-data-acl.ts b/src/main/startup/windows-user-data-acl.ts index ef0f0834442..bf1e1d01f05 100644 --- a/src/main/startup/windows-user-data-acl.ts +++ b/src/main/startup/windows-user-data-acl.ts @@ -1,7 +1,6 @@ import { spawn } from 'node:child_process' -import { writeFileSync } from 'node:fs' +import { readFileSync, writeFileSync } from 'node:fs' import { join } from 'node:path' -import { readNodeFileSyncWithinLimit } from '../../shared/node-bounded-file-reader' import { getIcaclsExePath, resolveCurrentWindowsIdentity } from '../win32-utils' /** @@ -33,7 +32,6 @@ import { getIcaclsExePath, resolveCurrentWindowsIdentity } from '../win32-utils' export const WINDOWS_ACL_GRANT_MARKER_FILE = 'windows-acl-grant.json' export const WINDOWS_ACL_GRANT_SCHEME_VERSION = 1 -export const WINDOWS_ACL_GRANT_MARKER_MAX_BYTES = 8 * 1024 const GRANT_TIMEOUT_MS = 120_000 @@ -60,10 +58,7 @@ type EnsureOptions = { function readMarker(userDataPath: string): WindowsAclGrantMarker | null { try { const parsed = JSON.parse( - readNodeFileSyncWithinLimit( - join(userDataPath, WINDOWS_ACL_GRANT_MARKER_FILE), - WINDOWS_ACL_GRANT_MARKER_MAX_BYTES - ).buffer.toString('utf8') + readFileSync(join(userDataPath, WINDOWS_ACL_GRANT_MARKER_FILE), 'utf-8') ) as Partial if ( parsed.schemeVersion === WINDOWS_ACL_GRANT_SCHEME_VERSION && diff --git a/src/main/startup/x-display-lock-reader.test.ts b/src/main/startup/x-display-lock-reader.test.ts deleted file mode 100644 index c6ffa400737..00000000000 --- a/src/main/startup/x-display-lock-reader.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { mkdtempSync, rmSync, truncateSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' - -vi.mock('electron', () => ({ - app: { - commandLine: { appendSwitch: vi.fn() }, - disableHardwareAcceleration: vi.fn(), - once: vi.fn() - } -})) - -import { X_DISPLAY_LOCK_MAX_BYTES, readXDisplayLockPid } from './ensure-virtual-display' - -const roots: string[] = [] - -function createLock(contents: string): string { - const root = mkdtempSync(join(tmpdir(), 'orca-x-display-lock-')) - roots.push(root) - const lockPath = join(root, '.X99-lock') - writeFileSync(lockPath, contents) - return lockPath -} - -afterEach(() => { - for (const root of roots.splice(0)) { - rmSync(root, { recursive: true, force: true }) - } -}) - -describe('X display lock reader', () => { - it('accepts a PID marker at the exact byte boundary', () => { - const pid = '4321' - const lockPath = createLock(pid + ' '.repeat(X_DISPLAY_LOCK_MAX_BYTES - pid.length)) - - expect(readXDisplayLockPid(lockPath)).toBe(4321) - }) - - it('rejects a sparse PID marker one byte over the boundary', () => { - const lockPath = createLock('4321') - truncateSync(lockPath, X_DISPLAY_LOCK_MAX_BYTES + 1) - - expect(readXDisplayLockPid(lockPath)).toBeNull() - }) -}) diff --git a/src/main/stats/collector-retention.test.ts b/src/main/stats/collector-retention.test.ts deleted file mode 100644 index d6858083b55..00000000000 --- a/src/main/stats/collector-retention.test.ts +++ /dev/null @@ -1,244 +0,0 @@ -import { - closeSync, - ftruncateSync, - mkdtempSync, - openSync, - readFileSync, - rmSync, - writeFileSync -} from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -let userDataDir: string -const statsPath = (): string => join(userDataDir, 'orca-stats.json') - -vi.mock('electron', () => ({ - app: { getPath: () => userDataDir } -})) - -async function importCollector() { - return import('./collector') -} - -describe('StatsCollector retention bounds', () => { - beforeEach(() => { - userDataDir = mkdtempSync(join(tmpdir(), 'orca-stats-retention-')) - vi.resetModules() - }) - - afterEach(() => { - rmSync(userDataDir, { recursive: true, force: true }) - vi.restoreAllMocks() - }) - - it('starts fresh without reading an oversized sparse stats file', async () => { - const { StatsCollector, STATS_FILE_MAX_BYTES, initStatsPath } = await importCollector() - const descriptor = openSync(statsPath(), 'w') - ftruncateSync(descriptor, STATS_FILE_MAX_BYTES + 1) - closeSync(descriptor) - const error = vi.spyOn(console, 'error').mockImplementation(() => undefined) - initStatsPath() - - const collector = new StatsCollector() - - expect(collector.getSummary()).toEqual({ - totalAgentsSpawned: 0, - totalPRsCreated: 0, - totalAgentTimeMs: 0, - firstEventAt: null - }) - expect(error).toHaveBeenCalledWith( - '[stats] Failed to load stats, starting fresh:', - expect.objectContaining({ name: 'NodeFileReadTooLargeError' }) - ) - collector.flush() - }) - - it('normalizes loaded event and PR-dedup arrays to their persisted limits', async () => { - const { StatsCollector, STATS_COUNTED_PR_MAX_ENTRIES, STATS_EVENT_MAX_ENTRIES, initStatsPath } = - await importCollector() - writeFileSync( - statsPath(), - JSON.stringify({ - schemaVersion: 1, - events: Array.from({ length: STATS_EVENT_MAX_ENTRIES + 3 }, (_, at) => ({ - type: 'agent_start', - at - })), - aggregates: { - totalAgentsSpawned: 123, - totalPRsCreated: 456, - totalAgentTimeMs: 789, - countedPRs: Array.from( - { length: STATS_COUNTED_PR_MAX_ENTRIES + 3 }, - (_, index) => `https://example.test/pr/${index}` - ), - firstEventAt: 0 - } - }) - ) - initStatsPath() - const collector = new StatsCollector() - - collector.flush() - - const persisted = JSON.parse(readFileSync(statsPath(), 'utf8')) - expect(persisted.events).toHaveLength(STATS_EVENT_MAX_ENTRIES) - expect(persisted.events[0].at).toBe(3) - expect(persisted.aggregates.countedPRs).toHaveLength(STATS_COUNTED_PR_MAX_ENTRIES) - expect(persisted.aggregates.countedPRs[0]).toBe('https://example.test/pr/3') - expect(collector.getSummary()).toMatchObject({ - totalAgentsSpawned: 123, - totalPRsCreated: 456, - totalAgentTimeMs: 789 - }) - }) - - it('retains only the newest events immediately and persists them in order', async () => { - const { StatsCollector, STATS_EVENT_MAX_ENTRIES, initStatsPath } = await importCollector() - initStatsPath() - const collector = new StatsCollector() - const internals = collector as unknown as { events: unknown[] } - - for (let at = 0; at < STATS_EVENT_MAX_ENTRIES + 3; at++) { - collector.record({ type: 'agent_start', at }) - expect(internals.events.length).toBeLessThanOrEqual(STATS_EVENT_MAX_ENTRIES) - } - - collector.flush() - const persisted = JSON.parse(readFileSync(statsPath(), 'utf8')) - expect(persisted.events).toHaveLength(STATS_EVENT_MAX_ENTRIES) - expect(persisted.events[0].at).toBe(3) - expect(persisted.events.at(-1).at).toBe(STATS_EVENT_MAX_ENTRIES + 2) - }) - - it('accepts an exact-limit event and drops a one-byte-larger event', async () => { - const { StatsCollector, STATS_EVENT_MAX_BYTES, STATS_FILE_MAX_BYTES, initStatsPath } = - await importCollector() - initStatsPath() - const collector = new StatsCollector() - const exactEvent = { type: 'agent_start' as const, at: 1, meta: { payload: '' } } - const eventOverhead = Buffer.byteLength(JSON.stringify(exactEvent), 'utf8') - exactEvent.meta.payload = 'x'.repeat(STATS_EVENT_MAX_BYTES - eventOverhead) - const oversizedEvent = { - ...exactEvent, - meta: { payload: `${exactEvent.meta.payload}x` } - } - - expect(Buffer.byteLength(JSON.stringify(exactEvent), 'utf8')).toBe(STATS_EVENT_MAX_BYTES) - collector.record(exactEvent) - collector.record(oversizedEvent) - collector.flush() - - const persisted = JSON.parse(readFileSync(statsPath(), 'utf8')) - expect(persisted.events).toEqual([exactEvent]) - expect(collector.getSummary().totalAgentsSpawned).toBe(2) - expect(Buffer.byteLength(JSON.stringify(persisted), 'utf8')).toBeLessThanOrEqual( - STATS_FILE_MAX_BYTES - ) - }) - - it('evicts oldest events until the aggregate event-byte budget fits', async () => { - const { StatsCollector, STATS_EVENT_MAX_BYTES, STATS_EVENT_MAX_RETAINED_BYTES, initStatsPath } = - await importCollector() - initStatsPath() - const collector = new StatsCollector() - const eventCount = STATS_EVENT_MAX_RETAINED_BYTES / STATS_EVENT_MAX_BYTES + 1 - for (let at = 0; at < eventCount; at++) { - const event = { type: 'agent_start' as const, at, meta: { payload: '' } } - const overhead = Buffer.byteLength(JSON.stringify(event), 'utf8') - event.meta.payload = 'x'.repeat(STATS_EVENT_MAX_BYTES - overhead) - collector.record(event) - } - - collector.flush() - - const persisted = JSON.parse(readFileSync(statsPath(), 'utf8')) - expect(persisted.events).toHaveLength(eventCount - 1) - expect(persisted.events[0].at).toBe(1) - expect(persisted.events.at(-1).at).toBe(eventCount - 1) - }) - - it('closes the oldest live agent when the live-session ceiling is reached', async () => { - const { StatsCollector, STATS_LIVE_AGENT_MAX_ENTRIES, initStatsPath } = await importCollector() - initStatsPath() - const collector = new StatsCollector() - const internals = collector as unknown as { liveAgents: Map } - - for (let index = 0; index <= STATS_LIVE_AGENT_MAX_ENTRIES; index++) { - collector.onAgentStart(`pty-${index}`, 1_000 + index) - } - - expect(internals.liveAgents).toHaveLength(STATS_LIVE_AGENT_MAX_ENTRIES) - expect(internals.liveAgents.has('pty-0')).toBe(false) - expect(internals.liveAgents.has(`pty-${STATS_LIVE_AGENT_MAX_ENTRIES}`)).toBe(true) - expect(collector.getSummary().totalAgentTimeMs).toBe(STATS_LIVE_AGENT_MAX_ENTRIES) - - collector.onAgentStop('pty-0', 100_000) - expect(collector.getSummary().totalAgentTimeMs).toBe(STATS_LIVE_AGENT_MAX_ENTRIES) - collector.flush() - }) - - it('bounds live-agent ids by individual and aggregate bytes', async () => { - const { - StatsCollector, - STATS_LIVE_AGENT_ID_MAX_BYTES, - STATS_LIVE_AGENT_MAX_RETAINED_ID_BYTES, - initStatsPath - } = await importCollector() - initStatsPath() - const collector = new StatsCollector() - const internals = collector as unknown as { liveAgents: Map } - const retainedCount = STATS_LIVE_AGENT_MAX_RETAINED_ID_BYTES / STATS_LIVE_AGENT_ID_MAX_BYTES - const id = (index: number): string => - `${String(index).padStart(4, '0')}${'x'.repeat(STATS_LIVE_AGENT_ID_MAX_BYTES - 4)}` - - for (let index = 0; index <= retainedCount; index++) { - collector.onAgentStart(id(index), index) - } - collector.onAgentStart('x'.repeat(STATS_LIVE_AGENT_ID_MAX_BYTES + 1), 10_000) - - expect(internals.liveAgents).toHaveLength(retainedCount) - expect(internals.liveAgents.has(id(0))).toBe(false) - expect(internals.liveAgents.has(id(retainedCount))).toBe(true) - expect( - [...internals.liveAgents.keys()].reduce( - (bytes, ptyId) => bytes + Buffer.byteLength(ptyId, 'utf8'), - 0 - ) - ).toBe(STATS_LIVE_AGENT_MAX_RETAINED_ID_BYTES) - collector.flush() - }) - - it('bounds counted PR urls by individual and aggregate serialized bytes', async () => { - const { - StatsCollector, - STATS_COUNTED_PR_MAX_RETAINED_BYTES, - STATS_COUNTED_PR_URL_MAX_BYTES, - initStatsPath - } = await importCollector() - initStatsPath() - const collector = new StatsCollector() - const internals = collector as unknown as { aggregates: { countedPRs: string[] } } - const retainedCount = STATS_COUNTED_PR_MAX_RETAINED_BYTES / STATS_COUNTED_PR_URL_MAX_BYTES - const url = (index: number): string => { - const prefix = `https://example.test/${String(index).padStart(4, '0')}/` - return `${prefix}${'x'.repeat(STATS_COUNTED_PR_URL_MAX_BYTES - 2 - prefix.length)}` - } - - for (let index = 0; index <= retainedCount; index++) { - collector.record({ type: 'pr_created', at: index, meta: { prUrl: url(index) } }) - } - const oversizedUrl = 'x'.repeat(STATS_COUNTED_PR_URL_MAX_BYTES - 1) - collector.record({ type: 'pr_created', at: 10_000, meta: { prUrl: oversizedUrl } }) - - expect(Buffer.byteLength(JSON.stringify(url(0)), 'utf8')).toBe(STATS_COUNTED_PR_URL_MAX_BYTES) - expect(internals.aggregates.countedPRs).toHaveLength(retainedCount) - expect(collector.hasCountedPR(url(0))).toBe(false) - expect(collector.hasCountedPR(url(retainedCount))).toBe(true) - expect(collector.hasCountedPR(oversizedUrl)).toBe(false) - collector.flush() - }) -}) diff --git a/src/main/stats/collector.ts b/src/main/stats/collector.ts index 9cf71e74cb4..508aa0a172a 100644 --- a/src/main/stats/collector.ts +++ b/src/main/stats/collector.ts @@ -1,25 +1,17 @@ import { app } from 'electron' -import { writeFileSync, mkdirSync, existsSync, renameSync } from 'node:fs' +import { readFileSync, writeFileSync, mkdirSync, existsSync, renameSync } from 'node:fs' import { writeFile, mkdir, rm } from 'node:fs/promises' import { join, dirname } from 'node:path' import type { StatsSummary } from '../../shared/types' -import { readNodeFileSyncWithinLimit } from '../../shared/node-bounded-file-reader' -import { stringifyJsonWithinByteLimit } from '../../shared/node-bounded-json-stringify' -import { measureUtf8ByteLength } from '../../shared/utf8-byte-limits' import type { StatsEvent, StatsAggregates, StatsFile } from './types' -import { StatsAggregateTracker } from './stats-aggregate-tracker' -import { - createDefaultStatsFile, - parseLoadedStatsFile, - STATS_FILE_MAX_BYTES, - STATS_LIVE_AGENT_ID_MAX_BYTES, - STATS_LIVE_AGENT_MAX_ENTRIES, - STATS_LIVE_AGENT_MAX_RETAINED_ID_BYTES, - STATS_SCHEMA_VERSION, - StatsEventLog -} from './stats-retention' -export * from './stats-retention' +const STATS_SCHEMA_VERSION = 1 +const MAX_EVENTS = 10_000 +// Why: countedPRs is a deduplication registry that grows with every PR created +// through Orca. Without a cap, a heavily-used instance accumulates thousands of +// URL strings across months. 2000 entries is about 6-12 months of active use +// for a power user, and at ~50 chars per URL the overhead is ~100KB max. +const MAX_COUNTED_PRS = 2_000 // Why 5s instead of the main store's 300ms: stat events are infrequent // (a few per session) and not latency-sensitive for the UI. const DEBOUNCE_MS = 5_000 @@ -41,11 +33,28 @@ function getStatsFile(): string { return _statsFile } +function getDefaultAggregates(): StatsAggregates { + return { + totalAgentsSpawned: 0, + totalPRsCreated: 0, + totalAgentTimeMs: 0, + countedPRs: [], + firstEventAt: null + } +} + +function getDefaultStatsFile(): StatsFile { + return { + schemaVersion: STATS_SCHEMA_VERSION, + events: [], + aggregates: getDefaultAggregates() + } +} + export class StatsCollector { - private eventLog: StatsEventLog - private aggregateTracker: StatsAggregateTracker + private events: StatsEvent[] + private aggregates: StatsAggregates private liveAgents = new Map() // ptyId → startTimestamp - private liveAgentIdBytes = 0 private writeTimer: ReturnType | null = null // Monotonic id stamped on each prepared payload; the highest committed one // wins so a slow in-flight async write can't clobber a newer sync flush. @@ -58,16 +67,8 @@ export class StatsCollector { constructor() { const data = this.load() - this.eventLog = new StatsEventLog(data.events) - this.aggregateTracker = new StatsAggregateTracker(data.aggregates) - } - - private get aggregates(): StatsAggregates { - return this.aggregateTracker.aggregates - } - - private get events(): StatsEvent[] { - return this.eventLog.events + this.events = data.events + this.aggregates = data.aggregates } onAgentStarted(listener: (totalAgentsSpawned: number) => void): () => void { @@ -84,36 +85,15 @@ export class StatsCollector { // ── Recording ────────────────────────────────────────────────────── record(event: StatsEvent): void { - this.eventLog.retain(event) - this.aggregateTracker.record(event, this.agentStartListeners) + this.events.push(event) + this.updateAggregates(event) this.scheduleSave() } // ── Agent lifecycle (called by AgentDetector) ───────────────────── onAgentStart(ptyId: string, at: number, repoId?: string, worktreeId?: string): void { - const idMeasurement = measureUtf8ByteLength(ptyId, { - stopAfterBytes: STATS_LIVE_AGENT_ID_MAX_BYTES - }) - if (!idMeasurement.exceededLimit) { - if (!this.liveAgents.has(ptyId)) { - while ( - this.liveAgents.size >= STATS_LIVE_AGENT_MAX_ENTRIES || - this.liveAgentIdBytes + idMeasurement.byteLength > STATS_LIVE_AGENT_MAX_RETAINED_ID_BYTES - ) { - const oldest = this.liveAgents.entries().next() - if (oldest.done) { - break - } - const [oldestPtyId, oldestStartAt] = oldest.value - this.liveAgents.delete(oldestPtyId) - this.liveAgentIdBytes -= measureUtf8ByteLength(oldestPtyId).byteLength - this.recordAgentStop(oldestPtyId, oldestStartAt, at) - } - this.liveAgentIdBytes += idMeasurement.byteLength - } - this.liveAgents.set(ptyId, at) - } + this.liveAgents.set(ptyId, at) this.record({ type: 'agent_start', at, @@ -129,11 +109,6 @@ export class StatsCollector { return } this.liveAgents.delete(ptyId) - this.liveAgentIdBytes -= measureUtf8ByteLength(ptyId).byteLength - this.recordAgentStop(ptyId, startAt, at) - } - - private recordAgentStop(ptyId: string, startAt: number, at: number): void { const durationMs = Math.max(0, at - startAt) this.aggregates.totalAgentTimeMs += durationMs this.record({ @@ -152,7 +127,12 @@ export class StatsCollector { // ── Query ───────────────────────────────────────────────────────── getSummary(): StatsSummary { - return this.aggregateTracker.getSummary() + return { + totalAgentsSpawned: this.aggregates.totalAgentsSpawned, + totalPRsCreated: this.aggregates.totalPRsCreated, + totalAgentTimeMs: this.aggregates.totalAgentTimeMs, + firstEventAt: this.aggregates.firstEventAt + } } // ── Shutdown flush ──────────────────────────────────────────────── @@ -183,10 +163,17 @@ export class StatsCollector { try { const statsFile = getStatsFile() if (existsSync(statsFile)) { - const raw = readNodeFileSyncWithinLimit(statsFile, STATS_FILE_MAX_BYTES).buffer.toString( - 'utf8' - ) - return parseLoadedStatsFile(raw) + const raw = readFileSync(statsFile, 'utf-8') + const parsed = JSON.parse(raw) as StatsFile + // Merge with defaults for forward compatibility + return { + ...getDefaultStatsFile(), + ...parsed, + aggregates: { + ...getDefaultAggregates(), + ...parsed.aggregates + } + } } } catch (err) { // Why "start fresh" instead of crashing: lifetime aggregates are lost @@ -195,7 +182,47 @@ export class StatsCollector { // disk so it can be inspected for debugging. console.error('[stats] Failed to load stats, starting fresh:', err) } - return createDefaultStatsFile() + return getDefaultStatsFile() + } + + private updateAggregates(event: StatsEvent): void { + if (this.aggregates.firstEventAt === null) { + this.aggregates.firstEventAt = event.at + } + + switch (event.type) { + case 'agent_start': + this.aggregates.totalAgentsSpawned++ + // Why: notify listeners synchronously AFTER increment so observers + // see the post-increment count. Listener errors are swallowed to + // keep stat recording robust — a buggy listener must not lose the + // event from the on-disk log. + for (const listener of this.agentStartListeners) { + try { + listener(this.aggregates.totalAgentsSpawned) + } catch (err) { + console.error('[stats] agent-start listener threw:', err) + } + } + break + case 'pr_created': + this.aggregates.totalPRsCreated++ + if (event.meta?.prUrl) { + this.aggregates.countedPRs.push(String(event.meta.prUrl)) + // Why: trim oldest entries so the dedup array does not grow without + // bound. The aggregate totalPRsCreated counter remains accurate; only + // the dedup lookup for very old PRs is lost, which is acceptable + // since PRs that old would never be re-counted in practice. + if (this.aggregates.countedPRs.length > MAX_COUNTED_PRS) { + this.aggregates.countedPRs = this.aggregates.countedPRs.slice(-MAX_COUNTED_PRS) + } + } + break + // agent_stop duration is handled directly in onAgentStop() to avoid + // double-counting — the duration is added to totalAgentTimeMs there. + case 'agent_stop': + break + } } private scheduleSave(): void { @@ -223,9 +250,9 @@ export class StatsCollector { } } - // Serialize the current state and pick a unique temp path. JSON.stringify - // must see a consistent snapshot, so both writers call this synchronously - // before any await to avoid a torn snapshot. + // Serialize the current state and pick a unique temp path. Trimming mutates + // in-memory state and JSON.stringify must see a consistent snapshot, so both + // writers call this synchronously before any await to avoid a torn snapshot. // The monotonic generation lets a later write veto an earlier, still-in-flight // one so a stale rename can never win (see writeToDiskAsync). private prepareWritePayload(): { @@ -236,6 +263,11 @@ export class StatsCollector { } { const statsFile = getStatsFile() + // Trim events to bounded size before writing + if (this.events.length > MAX_EVENTS) { + this.events = this.events.slice(-MAX_EVENTS) + } + const data: StatsFile = { schemaVersion: STATS_SCHEMA_VERSION, events: this.events, @@ -246,8 +278,7 @@ export class StatsCollector { // Unique temp file so the async debounced writer and the sync shutdown // flush never write the same temp path (same pattern as persistence.ts). const tmpFile = `${statsFile}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp` - const json = stringifyJsonWithinByteLimit(data, STATS_FILE_MAX_BYTES).serialized - return { statsFile, tmpFile, json, generation } + return { statsFile, tmpFile, json: JSON.stringify(data), generation } } private writeToDiskSync(): void { diff --git a/src/main/stats/stats-aggregate-tracker.ts b/src/main/stats/stats-aggregate-tracker.ts deleted file mode 100644 index 3ed168571ad..00000000000 --- a/src/main/stats/stats-aggregate-tracker.ts +++ /dev/null @@ -1,71 +0,0 @@ -import type { StatsSummary } from '../../shared/types' -import type { StatsAggregates, StatsEvent } from './types' -import { - jsonByteLengthWithinLimit, - STATS_COUNTED_PR_MAX_ENTRIES, - STATS_COUNTED_PR_MAX_RETAINED_BYTES, - STATS_COUNTED_PR_URL_MAX_BYTES -} from './stats-retention' - -export class StatsAggregateTracker { - private countedPrRetainedBytes: number - - constructor(readonly aggregates: StatsAggregates) { - this.countedPrRetainedBytes = aggregates.countedPRs.reduce( - (total, value) => - total + (jsonByteLengthWithinLimit(value, STATS_COUNTED_PR_URL_MAX_BYTES) ?? 0), - 0 - ) - } - - record(event: StatsEvent, agentStartListeners: ((total: number) => void)[]): void { - if (this.aggregates.firstEventAt === null) { - this.aggregates.firstEventAt = event.at - } - if (event.type === 'agent_start') { - this.aggregates.totalAgentsSpawned++ - for (const listener of agentStartListeners) { - try { - listener(this.aggregates.totalAgentsSpawned) - } catch (err) { - console.error('[stats] agent-start listener threw:', err) - } - } - return - } - if (event.type !== 'pr_created') { - return - } - this.aggregates.totalPRsCreated++ - if (!event.meta?.prUrl) { - return - } - const prUrl = String(event.meta.prUrl) - const prUrlBytes = jsonByteLengthWithinLimit(prUrl, STATS_COUNTED_PR_URL_MAX_BYTES) - if (prUrlBytes === null) { - return - } - this.aggregates.countedPRs.push(prUrl) - this.countedPrRetainedBytes += prUrlBytes - while ( - this.aggregates.countedPRs.length > STATS_COUNTED_PR_MAX_ENTRIES || - this.countedPrRetainedBytes > STATS_COUNTED_PR_MAX_RETAINED_BYTES - ) { - const oldestPrUrl = this.aggregates.countedPRs.shift() - if (oldestPrUrl === undefined) { - break - } - this.countedPrRetainedBytes -= - jsonByteLengthWithinLimit(oldestPrUrl, STATS_COUNTED_PR_URL_MAX_BYTES) ?? 0 - } - } - - getSummary(): StatsSummary { - return { - totalAgentsSpawned: this.aggregates.totalAgentsSpawned, - totalPRsCreated: this.aggregates.totalPRsCreated, - totalAgentTimeMs: this.aggregates.totalAgentTimeMs, - firstEventAt: this.aggregates.firstEventAt - } - } -} diff --git a/src/main/stats/stats-file-json-admission.test.ts b/src/main/stats/stats-file-json-admission.test.ts deleted file mode 100644 index 3b4d3bc9f06..00000000000 --- a/src/main/stats/stats-file-json-admission.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { parseLoadedStatsFile } from './stats-retention' - -describe('stats file JSON admission', () => { - it('preserves ordinary stats under the structure limits', () => { - expect( - parseLoadedStatsFile('{"schemaVersion":1,"events":[],"aggregates":{}}', { - structuralTokens: 11, - nestingDepth: 2 - }) - ).toMatchObject({ schemaVersion: 1, events: [] }) - }) - - it('rejects structural amplification before parsing stats', () => { - expect(() => - parseLoadedStatsFile('{"events":[{},{}]}', { - structuralTokens: 7, - nestingDepth: 3 - }) - ).toThrow('JSON structure') - }) -}) diff --git a/src/main/stats/stats-retention.ts b/src/main/stats/stats-retention.ts deleted file mode 100644 index e750e65139d..00000000000 --- a/src/main/stats/stats-retention.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { stringifyJsonWithinByteLimit } from '../../shared/node-bounded-json-stringify' -import { - assertJsonTextStructureWithinLimits, - type JsonTextStructureLimits -} from '../../shared/json-text-structure-limit' -import type { StatsAggregates, StatsEvent, StatsFile } from './types' - -export const STATS_SCHEMA_VERSION = 1 -export const STATS_FILE_MAX_BYTES = 16 * 1024 * 1024 -export const STATS_EVENT_MAX_ENTRIES = 10_000 -export const STATS_EVENT_MAX_BYTES = 1024 * 1024 -export const STATS_EVENT_MAX_RETAINED_BYTES = 12 * 1024 * 1024 -export const STATS_LIVE_AGENT_MAX_ENTRIES = 4_096 -export const STATS_LIVE_AGENT_ID_MAX_BYTES = 4 * 1024 -export const STATS_LIVE_AGENT_MAX_RETAINED_ID_BYTES = 1024 * 1024 -export const STATS_COUNTED_PR_MAX_ENTRIES = 2_000 -export const STATS_COUNTED_PR_URL_MAX_BYTES = 8 * 1024 -export const STATS_COUNTED_PR_MAX_RETAINED_BYTES = 1024 * 1024 -export const STATS_FILE_JSON_LIMITS: JsonTextStructureLimits = { - structuralTokens: 1_000_000, - nestingDepth: 128 -} - -export function jsonByteLengthWithinLimit(value: unknown, maxBytes: number): number | null { - try { - return stringifyJsonWithinByteLimit(value, maxBytes).byteLength - } catch { - return null - } -} - -function normalizeLoadedEvents(values: unknown[]): StatsEvent[] { - const newest: StatsEvent[] = [] - let retainedBytes = 0 - for ( - let index = values.length - 1; - index >= 0 && newest.length < STATS_EVENT_MAX_ENTRIES; - index-- - ) { - const value = values[index] - if (typeof value !== 'object' || value === null || Array.isArray(value)) { - continue - } - const event = value as StatsEvent - const eventBytes = jsonByteLengthWithinLimit(event, STATS_EVENT_MAX_BYTES) - if (eventBytes === null) { - continue - } - if (retainedBytes + eventBytes > STATS_EVENT_MAX_RETAINED_BYTES) { - break - } - retainedBytes += eventBytes - newest.push(event) - } - return newest.toReversed() -} - -function normalizeCountedPrUrls(values: unknown[]): string[] { - const newest: string[] = [] - let retainedBytes = 0 - for ( - let index = values.length - 1; - index >= 0 && newest.length < STATS_COUNTED_PR_MAX_ENTRIES; - index-- - ) { - const value = values[index] - if (typeof value !== 'string') { - continue - } - const valueBytes = jsonByteLengthWithinLimit(value, STATS_COUNTED_PR_URL_MAX_BYTES) - if (valueBytes === null) { - continue - } - if (retainedBytes + valueBytes > STATS_COUNTED_PR_MAX_RETAINED_BYTES) { - break - } - retainedBytes += valueBytes - newest.push(value) - } - return newest.toReversed() -} - -export function createDefaultStatsFile(): StatsFile { - return { - schemaVersion: STATS_SCHEMA_VERSION, - events: [], - aggregates: { - totalAgentsSpawned: 0, - totalPRsCreated: 0, - totalAgentTimeMs: 0, - countedPRs: [], - firstEventAt: null - } - } -} - -export function normalizeLoadedStatsFile(parsed: unknown): StatsFile { - const candidate = - typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) - ? (parsed as Partial) - : {} - const aggregateCandidate: Partial = - typeof candidate.aggregates === 'object' && - candidate.aggregates !== null && - !Array.isArray(candidate.aggregates) - ? candidate.aggregates - : {} - const countedPRs = Array.isArray(aggregateCandidate.countedPRs) - ? normalizeCountedPrUrls(aggregateCandidate.countedPRs) - : [] - return { - schemaVersion: - typeof candidate.schemaVersion === 'number' ? candidate.schemaVersion : STATS_SCHEMA_VERSION, - events: Array.isArray(candidate.events) ? normalizeLoadedEvents(candidate.events) : [], - aggregates: { - totalAgentsSpawned: - typeof aggregateCandidate.totalAgentsSpawned === 'number' - ? aggregateCandidate.totalAgentsSpawned - : 0, - totalPRsCreated: - typeof aggregateCandidate.totalPRsCreated === 'number' - ? aggregateCandidate.totalPRsCreated - : 0, - totalAgentTimeMs: - typeof aggregateCandidate.totalAgentTimeMs === 'number' - ? aggregateCandidate.totalAgentTimeMs - : 0, - countedPRs, - firstEventAt: - typeof aggregateCandidate.firstEventAt === 'number' ? aggregateCandidate.firstEventAt : null - } - } -} - -export function parseLoadedStatsFile( - serialized: string, - structureLimits: JsonTextStructureLimits = STATS_FILE_JSON_LIMITS -): StatsFile { - assertJsonTextStructureWithinLimits(serialized, structureLimits) - return normalizeLoadedStatsFile(JSON.parse(serialized)) -} - -export class StatsEventLog { - private eventByteLengths: number[] - private retainedEventBytes: number - - constructor(readonly events: StatsEvent[]) { - this.eventByteLengths = events.map( - (event) => jsonByteLengthWithinLimit(event, STATS_EVENT_MAX_BYTES) ?? 0 - ) - this.retainedEventBytes = this.eventByteLengths.reduce((total, bytes) => total + bytes, 0) - } - - retain(event: StatsEvent): void { - const eventBytes = jsonByteLengthWithinLimit(event, STATS_EVENT_MAX_BYTES) - if (eventBytes === null) { - return - } - this.events.push(event) - this.eventByteLengths.push(eventBytes) - this.retainedEventBytes += eventBytes - while ( - this.events.length > STATS_EVENT_MAX_ENTRIES || - this.retainedEventBytes > STATS_EVENT_MAX_RETAINED_BYTES - ) { - this.events.shift() - this.retainedEventBytes -= this.eventByteLengths.shift() ?? 0 - } - } -} diff --git a/src/main/terminal-history-gc.test.ts b/src/main/terminal-history-gc.test.ts deleted file mode 100644 index 15cfb3e972d..00000000000 --- a/src/main/terminal-history-gc.test.ts +++ /dev/null @@ -1,213 +0,0 @@ -import { existsSync } from 'node:fs' -import { mkdir, mkdtemp, rm, truncate, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { - deleteWslWorktreeHistoryDirectories, - runTerminalHistoryGarbageCollection, - TERMINAL_HISTORY_GC_META_MAX_BYTES -} from './terminal-history-gc' - -let root = '' -let mainRoot = '' -let wslRoot = '' - -function oldMetadata(worktreeId: string): string { - return JSON.stringify({ - worktreeId, - createdAt: new Date(Date.now() - 10 * 60 * 1000).toISOString() - }) -} - -async function createHistoryDirectory( - historyRoot: string, - directoryName: string, - worktreeId = directoryName -): Promise { - const directory = join(historyRoot, directoryName) - await mkdir(directory, { recursive: true }) - await writeFile(join(directory, 'meta.json'), oldMetadata(worktreeId)) - return directory -} - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-terminal-history-gc-')) - mainRoot = join(root, 'terminal-history') - wslRoot = join(root, 'terminal-history-wsl') - await mkdir(mainRoot) - vi.spyOn(console, 'log').mockImplementation(() => {}) - vi.spyOn(console, 'warn').mockImplementation(() => {}) -}) - -afterEach(async () => { - vi.restoreAllMocks() - await rm(root, { recursive: true, force: true }) -}) - -describe('terminal history GC memory limits', () => { - it('preserves ordinary live/orphan and age-guard behavior below the limits', async () => { - const liveDirectory = await createHistoryDirectory(mainRoot, 'live-dir', 'live-worktree') - const orphanDirectory = await createHistoryDirectory(mainRoot, 'orphan-dir', 'orphan-worktree') - const freshDirectory = await createHistoryDirectory(mainRoot, 'fresh-dir', 'fresh-worktree') - await writeFile( - join(freshDirectory, 'meta.json'), - JSON.stringify({ worktreeId: 'fresh-worktree', createdAt: new Date().toISOString() }) - ) - - const summary = runTerminalHistoryGarbageCollection({ - mainRoot, - wslRoot, - liveWorktreeIds: new Set(['live-worktree']) - }) - - expect(summary).toMatchObject({ - capacityExceeded: false, - orphaned: 1, - pruned: 1, - totalDirs: 3 - }) - expect(existsSync(liveDirectory)).toBe(true) - expect(existsSync(freshDirectory)).toBe(true) - expect(existsSync(orphanDirectory)).toBe(false) - }) - - it('accepts metadata at the exact byte limit and skips one byte over', async () => { - const exactDirectory = await createHistoryDirectory(mainRoot, 'exact-dir', 'exact-worktree') - const exactMetaPath = join(exactDirectory, 'meta.json') - const exactJson = oldMetadata('exact-worktree') - await writeFile( - exactMetaPath, - `${exactJson}${' '.repeat(TERMINAL_HISTORY_GC_META_MAX_BYTES - exactJson.length)}` - ) - - expect( - runTerminalHistoryGarbageCollection({ - mainRoot, - wslRoot, - liveWorktreeIds: new Set() - }).pruned - ).toBe(1) - - const oversizedDirectory = await createHistoryDirectory( - mainRoot, - 'oversized-dir', - 'oversized-worktree' - ) - const oversizedMetaPath = join(oversizedDirectory, 'meta.json') - await writeFile(oversizedMetaPath, oldMetadata('oversized-worktree')) - await truncate(oversizedMetaPath, TERMINAL_HISTORY_GC_META_MAX_BYTES + 8 * 1024 * 1024) - - expect( - runTerminalHistoryGarbageCollection({ - mainRoot, - wslRoot, - liveWorktreeIds: new Set() - }).pruned - ).toBe(0) - expect(existsSync(oversizedDirectory)).toBe(true) - }) - - it('accepts the exact discovery-entry budget and stops before the next entry', async () => { - await createHistoryDirectory(mainRoot, 'one') - await createHistoryDirectory(mainRoot, 'two') - - const exact = runTerminalHistoryGarbageCollection({ - mainRoot, - wslRoot, - liveWorktreeIds: new Set(), - limits: { maxDiscoveryEntries: 4 } - }) - expect(exact).toMatchObject({ capacityExceeded: false, pruned: 2 }) - - await createHistoryDirectory(mainRoot, 'one') - await createHistoryDirectory(mainRoot, 'two') - await createHistoryDirectory(mainRoot, 'three') - const capped = runTerminalHistoryGarbageCollection({ - mainRoot, - wslRoot, - liveWorktreeIds: new Set(), - limits: { maxDiscoveryEntries: 4 } - }) - expect(capped.capacityExceeded).toBe(true) - expect( - [join(mainRoot, 'one'), join(mainRoot, 'two'), join(mainRoot, 'three')].some(existsSync) - ).toBe(true) - }) - - it('fails closed when a worktree directory exceeds its flat-file limit', async () => { - const directory = await createHistoryDirectory(mainRoot, 'many-files') - for (let index = 0; index < 100; index += 1) { - await writeFile(join(directory, `history-${index}`), 'x') - } - - const summary = runTerminalHistoryGarbageCollection({ - mainRoot, - wslRoot, - liveWorktreeIds: new Set(), - limits: { maxFilesPerWorktree: 1 } - }) - - expect(summary).toMatchObject({ capacityExceeded: false, pruned: 0 }) - expect(existsSync(directory)).toBe(true) - }) - - it('does not recursively prune an unexpected nested history tree', async () => { - const directory = await createHistoryDirectory(mainRoot, 'nested-tree') - await mkdir(join(directory, 'unexpected', 'deep'), { recursive: true }) - - const summary = runTerminalHistoryGarbageCollection({ - mainRoot, - wslRoot, - liveWorktreeIds: new Set() - }) - - expect(summary).toMatchObject({ capacityExceeded: false, pruned: 0 }) - expect(existsSync(directory)).toBe(true) - }) - - it('caps WSL distro roots for both GC and direct worktree cleanup', async () => { - const first = join(wslRoot, 'Distro-A') - const second = join(wslRoot, 'Distro-B') - let firstHistory = await createHistoryDirectory(first, 'hash', 'first-worktree') - let secondHistory = await createHistoryDirectory(second, 'hash', 'second-worktree') - - const exactSummary = runTerminalHistoryGarbageCollection({ - mainRoot, - wslRoot, - liveWorktreeIds: new Set(), - limits: { maxWslDistros: 2 } - }) - expect(exactSummary).toMatchObject({ capacityExceeded: false, pruned: 2 }) - - firstHistory = await createHistoryDirectory(first, 'hash', 'first-worktree') - secondHistory = await createHistoryDirectory(second, 'hash', 'second-worktree') - const cappedSummary = runTerminalHistoryGarbageCollection({ - mainRoot, - wslRoot, - liveWorktreeIds: new Set(), - limits: { maxWslDistros: 1 } - }) - expect(cappedSummary.capacityExceeded).toBe(true) - expect([firstHistory, secondHistory].some(existsSync)).toBe(true) - - await mkdir(firstHistory, { recursive: true }) - await mkdir(secondHistory, { recursive: true }) - deleteWslWorktreeHistoryDirectories({ - wslRoot, - worktreeHash: 'hash', - limits: { maxWslDistros: 2 } - }) - expect(existsSync(firstHistory)).toBe(false) - expect(existsSync(secondHistory)).toBe(false) - - await mkdir(firstHistory, { recursive: true }) - await mkdir(secondHistory, { recursive: true }) - deleteWslWorktreeHistoryDirectories({ - wslRoot, - worktreeHash: 'hash', - limits: { maxWslDistros: 1 } - }) - expect([firstHistory, secondHistory].some(existsSync)).toBe(true) - }) -}) diff --git a/src/main/terminal-history-gc.ts b/src/main/terminal-history-gc.ts deleted file mode 100644 index a84e907c749..00000000000 --- a/src/main/terminal-history-gc.ts +++ /dev/null @@ -1,305 +0,0 @@ -import { existsSync, opendirSync, rmSync, statSync } from 'node:fs' -import { join } from 'node:path' -import { readNodeFileSyncWithinLimit } from '../shared/node-bounded-file-reader' - -export const TERMINAL_HISTORY_GC_MAX_DISCOVERY_ENTRIES = 100_000 -export const TERMINAL_HISTORY_GC_MAX_WSL_DISTROS = 256 -export const TERMINAL_HISTORY_GC_MAX_FILES_PER_WORKTREE = 64 -export const TERMINAL_HISTORY_GC_META_MAX_BYTES = 64 * 1024 - -export type TerminalHistoryGcLimits = { - maxDiscoveryEntries: number - maxFilesPerWorktree: number - maxMetaBytes: number - maxWslDistros: number -} - -export type TerminalHistoryGcSummary = { - capacityExceeded: boolean - orphaned: number - pruned: number - totalDirs: number - totalSizeKB: number -} - -const DEFAULT_LIMITS: TerminalHistoryGcLimits = { - maxDiscoveryEntries: TERMINAL_HISTORY_GC_MAX_DISCOVERY_ENTRIES, - maxFilesPerWorktree: TERMINAL_HISTORY_GC_MAX_FILES_PER_WORKTREE, - maxMetaBytes: TERMINAL_HISTORY_GC_META_MAX_BYTES, - maxWslDistros: TERMINAL_HISTORY_GC_MAX_WSL_DISTROS -} - -// Why: the live-worktree snapshot can predate a newly created history directory. -const GC_MIN_AGE_MS = 5 * 60 * 1000 - -class TerminalHistoryGcCapacityError extends Error { - constructor( - readonly resource: 'discovery entries' | 'WSL distros', - readonly limit: number - ) { - super(`Terminal history GC exceeded ${limit} ${resource}`) - this.name = 'TerminalHistoryGcCapacityError' - } -} - -class TerminalHistoryGcBudget { - private discoveryEntries = 0 - private wslDistros = 0 - - constructor(readonly limits: TerminalHistoryGcLimits) {} - - claimDiscoveryEntry(): void { - this.discoveryEntries += 1 - if (this.discoveryEntries > this.limits.maxDiscoveryEntries) { - throw new TerminalHistoryGcCapacityError('discovery entries', this.limits.maxDiscoveryEntries) - } - } - - claimWslDistro(): void { - this.wslDistros += 1 - if (this.wslDistros > this.limits.maxWslDistros) { - throw new TerminalHistoryGcCapacityError('WSL distros', this.limits.maxWslDistros) - } - } -} - -type MutableTerminalHistoryGcSummary = Omit - -export function runTerminalHistoryGarbageCollection(options: { - mainRoot: string - wslRoot: string - liveWorktreeIds: Set - limits?: Partial -}): TerminalHistoryGcSummary { - const limits = resolveTerminalHistoryGcLimits(options.limits) - const budget = new TerminalHistoryGcBudget(limits) - const summary: TerminalHistoryGcSummary = { - capacityExceeded: false, - orphaned: 0, - pruned: 0, - totalDirs: 0, - totalSizeKB: 0 - } - - try { - scanTerminalHistoryRoot(options.mainRoot, options.liveWorktreeIds, budget, limits, summary) - scanWslHistoryRoots(options.wslRoot, options.liveWorktreeIds, budget, limits, summary) - } catch (error) { - if (!(error instanceof TerminalHistoryGcCapacityError)) { - throw error - } - summary.capacityExceeded = true - console.warn(`[pty:history:gc] ${error.message}; remaining history will be scanned next run`) - } - return summary -} - -export function deleteWslWorktreeHistoryDirectories(options: { - wslRoot: string - worktreeHash: string - limits?: Partial -}): void { - if (!existsSync(options.wslRoot)) { - return - } - const limits = resolveTerminalHistoryGcLimits(options.limits) - const budget = new TerminalHistoryGcBudget(limits) - try { - forEachDirectoryEntry(options.wslRoot, (distro) => { - budget.claimDiscoveryEntry() - const distroRoot = join(options.wslRoot, distro) - if (!statSync(distroRoot).isDirectory()) { - return - } - budget.claimWslDistro() - const historyPath = join(distroRoot, options.worktreeHash) - if (existsSync(historyPath)) { - rmSync(historyPath, { recursive: true, force: true }) - } - }) - } catch (error) { - if (error instanceof TerminalHistoryGcCapacityError) { - console.warn(`[pty:history] ${error.message}; WSL cleanup stopped at the limit`) - return - } - throw error - } -} - -function scanWslHistoryRoots( - wslRoot: string, - liveWorktreeIds: Set, - budget: TerminalHistoryGcBudget, - limits: TerminalHistoryGcLimits, - summary: TerminalHistoryGcSummary -): void { - if (!existsSync(wslRoot)) { - return - } - try { - forEachDirectoryEntry(wslRoot, (distro) => { - budget.claimDiscoveryEntry() - const distroRoot = join(wslRoot, distro) - try { - if (!statSync(distroRoot).isDirectory()) { - return - } - budget.claimWslDistro() - scanTerminalHistoryRoot(distroRoot, liveWorktreeIds, budget, limits, summary) - } catch (error) { - if (error instanceof TerminalHistoryGcCapacityError) { - throw error - } - // One unavailable distro must not discard the main-root GC result. - } - }) - } catch (error) { - if (error instanceof TerminalHistoryGcCapacityError) { - throw error - } - // WSL history is optional and may disappear while distributions stop. - } -} - -function scanTerminalHistoryRoot( - root: string, - liveWorktreeIds: Set, - budget: TerminalHistoryGcBudget, - limits: TerminalHistoryGcLimits, - summary: MutableTerminalHistoryGcSummary -): void { - if (!existsSync(root)) { - return - } - const now = Date.now() - - forEachDirectoryEntry(root, (entry) => { - budget.claimDiscoveryEntry() - const entryPath = join(root, entry) - try { - if (!statSync(entryPath).isDirectory()) { - return - } - summary.totalDirs += 1 - const sizeEstimate = estimateHistoryDirectorySize(entryPath, budget, limits) - summary.totalSizeKB += sizeEstimate.totalSizeKB - if (!sizeEstimate.complete) { - return - } - - const meta = readTerminalHistoryMetadata(join(entryPath, 'meta.json'), limits.maxMetaBytes) - if (!meta || liveWorktreeIds.has(meta.worktreeId)) { - return - } - if (meta.createdAt) { - const ageMs = now - new Date(meta.createdAt).getTime() - if (ageMs < GC_MIN_AGE_MS) { - return - } - } - - summary.orphaned += 1 - rmSync(entryPath, { recursive: true, force: true }) - summary.pruned += 1 - console.log(`[pty:history:gc] Pruned orphaned history: ${meta.worktreeId}`) - } catch (error) { - if (error instanceof TerminalHistoryGcCapacityError) { - throw error - } - // One corrupt or concurrently removed history directory must not stop GC. - } - }) -} - -function estimateHistoryDirectorySize( - directoryPath: string, - budget: TerminalHistoryGcBudget, - limits: TerminalHistoryGcLimits -): { complete: boolean; totalSizeKB: number } { - let complete = true - let fileCount = 0 - let totalSizeKB = 0 - try { - forEachDirectoryEntry(directoryPath, (file) => { - budget.claimDiscoveryEntry() - fileCount += 1 - if (fileCount > limits.maxFilesPerWorktree) { - complete = false - return false - } - const fileStat = statSync(join(directoryPath, file)) - if (fileStat.isDirectory()) { - complete = false - return false - } - totalSizeKB += Math.ceil(fileStat.size / 1024) - return undefined - }) - } catch (error) { - if (error instanceof TerminalHistoryGcCapacityError) { - throw error - } - complete = false - } - return { complete, totalSizeKB } -} - -function readTerminalHistoryMetadata( - metaPath: string, - maxBytes: number -): { worktreeId: string; createdAt?: string } | null { - if (!existsSync(metaPath)) { - return null - } - try { - const parsed = JSON.parse( - readNodeFileSyncWithinLimit(metaPath, maxBytes).buffer.toString('utf8') - ) as unknown - if ( - parsed === null || - typeof parsed !== 'object' || - !('worktreeId' in parsed) || - typeof parsed.worktreeId !== 'string' || - parsed.worktreeId.length === 0 - ) { - return null - } - const createdAt = - 'createdAt' in parsed && typeof parsed.createdAt === 'string' ? parsed.createdAt : undefined - return { worktreeId: parsed.worktreeId, ...(createdAt ? { createdAt } : {}) } - } catch { - return null - } -} - -function forEachDirectoryEntry( - directoryPath: string, - visit: (entryName: string) => false | void -): void { - const directory = opendirSync(directoryPath) - try { - for (let entry = directory.readSync(); entry !== null; entry = directory.readSync()) { - if (visit(entry.name) === false) { - return - } - } - } finally { - try { - directory.closeSync() - } catch { - // The OS may have already closed a failed directory stream. - } - } -} - -function resolveTerminalHistoryGcLimits( - overrides: Partial = {} -): TerminalHistoryGcLimits { - const limits = { ...DEFAULT_LIMITS, ...overrides } - for (const [name, value] of Object.entries(limits)) { - if (!Number.isSafeInteger(value) || value < 0) { - throw new RangeError(`${name} must be a non-negative safe integer`) - } - } - return limits -} diff --git a/src/main/terminal-history.test.ts b/src/main/terminal-history.test.ts index 1c66a77ef64..a9750bb60bc 100644 --- a/src/main/terminal-history.test.ts +++ b/src/main/terminal-history.test.ts @@ -7,7 +7,6 @@ const { readFileSyncMock, rmSyncMock, readdirSyncMock, - opendirSyncMock, statSyncMock, getPathMock } = vi.hoisted(() => ({ @@ -17,7 +16,6 @@ const { readFileSyncMock: vi.fn(), rmSyncMock: vi.fn(), readdirSyncMock: vi.fn(), - opendirSyncMock: vi.fn(), statSyncMock: vi.fn(), getPathMock: vi.fn() })) @@ -29,21 +27,9 @@ vi.mock('fs', () => ({ readFileSync: readFileSyncMock, rmSync: rmSyncMock, readdirSync: readdirSyncMock, - opendirSync: opendirSyncMock, statSync: statSyncMock })) -vi.mock('../shared/node-bounded-file-reader', () => ({ - readNodeFileSyncWithinLimit: (filePath: string, maxBytes: number) => { - const value = readFileSyncMock(filePath, 'utf8') - const buffer = Buffer.isBuffer(value) ? value : Buffer.from(String(value)) - if (buffer.byteLength > maxBytes) { - throw new Error('File too large') - } - return { buffer, stats: statSyncMock(filePath) } - } -})) - vi.mock('electron', () => ({ app: { getPath: getPathMock @@ -80,22 +66,7 @@ describe('terminal-history', () => { vi.clearAllMocks() getPathMock.mockReturnValue('/fake/userData') existsSyncMock.mockReturnValue(true) - statSyncMock.mockImplementation((filePath: string) => ({ - isDirectory: () => !filePath.endsWith('meta.json'), - size: 100 - })) - opendirSyncMock.mockImplementation((directoryPath: string) => { - const entries = (readdirSyncMock(directoryPath) ?? []) as string[] - let index = 0 - return { - closeSync: vi.fn(), - readSync: () => { - const name = entries[index] - index += 1 - return name === undefined ? null : { name } - } - } - }) + statSyncMock.mockReturnValue({ isDirectory: () => true, size: 100 }) }) describe('resolveShellKind', () => { @@ -331,10 +302,7 @@ describe('terminal-history', () => { } return ['meta.json'] }) - statSyncMock.mockImplementation((filePath: string) => ({ - isDirectory: () => !filePath.endsWith('meta.json'), - size: 100 - })) + statSyncMock.mockReturnValue({ isDirectory: () => true, size: 100 }) readFileSyncMock.mockImplementation((p: string) => { // Use a createdAt old enough to pass the GC age threshold const oldDate = new Date(Date.now() - 10 * 60 * 1000).toISOString() @@ -368,10 +336,7 @@ describe('terminal-history', () => { } return ['meta.json'] }) - statSyncMock.mockImplementation((filePath: string) => ({ - isDirectory: () => !filePath.endsWith('meta.json'), - size: 100 - })) + statSyncMock.mockReturnValue({ isDirectory: () => true, size: 100 }) // createdAt is just now — younger than the 5-minute GC threshold readFileSyncMock.mockReturnValue( JSON.stringify({ worktreeId: 'unknown-wt', createdAt: new Date().toISOString() }) diff --git a/src/main/terminal-history.ts b/src/main/terminal-history.ts index 833e82347cb..a6d09cfcaa2 100644 --- a/src/main/terminal-history.ts +++ b/src/main/terminal-history.ts @@ -1,12 +1,16 @@ import { createHash } from 'node:crypto' import { join, basename } from 'node:path' -import { mkdirSync, existsSync, writeFileSync, rmSync } from 'node:fs' +import { + mkdirSync, + existsSync, + readFileSync, + writeFileSync, + readdirSync, + rmSync, + statSync +} from 'node:fs' import { app } from 'electron' import { parseWslPath, toLinuxPath } from './wsl' -import { - deleteWslWorktreeHistoryDirectories, - runTerminalHistoryGarbageCollection -} from './terminal-history-gc' // ─── Constants ───────────────────────────────────────────────────── @@ -223,7 +227,14 @@ export function deleteWorktreeHistoryDir(worktreeId: string): void { if (process.platform === 'win32') { try { const wslRoot = join(app.getPath('userData'), HISTORY_DIR_NAME_WSL) - deleteWslWorktreeHistoryDirectories({ wslRoot, worktreeHash }) + if (existsSync(wslRoot)) { + for (const distro of readdirSync(wslRoot)) { + const wslDir = join(wslRoot, distro, worktreeHash) + if (existsSync(wslDir)) { + rmSync(wslDir, { recursive: true, force: true }) + } + } + } } catch { // Non-fatal. } @@ -232,19 +243,112 @@ export function deleteWorktreeHistoryDir(worktreeId: string): void { // ─── Garbage Collection ──────────────────────────────────────────── +// Why 5 minutes: GC runs ~10s after startup, and the live-worktree snapshot is +// taken just before. A worktree created between the snapshot and GC execution +// won't appear in liveWorktreeIds, so without an age guard GC would delete its +// freshly-created history directory (TOCTOU race). 5 minutes is generous enough +// to cover any realistic snapshot-to-scan delay. +const GC_MIN_AGE_MS = 5 * 60 * 1000 + +/** Scan a single history root directory, pruning orphaned entries. + * Returns { totalDirs, orphaned, pruned, totalSizeKB }. */ +function gcScanRoot( + root: string, + liveWorktreeIds: Set +): { totalDirs: number; orphaned: number; pruned: number; totalSizeKB: number } { + const result = { totalDirs: 0, orphaned: 0, pruned: 0, totalSizeKB: 0 } + if (!existsSync(root)) { + return result + } + + const now = Date.now() + + for (const entry of readdirSync(root)) { + const entryPath = join(root, entry) + try { + const stat = statSync(entryPath) + if (!stat.isDirectory()) { + continue + } + result.totalDirs++ + + // Estimate directory size from meta.json + history files. + try { + for (const file of readdirSync(entryPath)) { + result.totalSizeKB += Math.ceil(statSync(join(entryPath, file)).size / 1024) + } + } catch { + // Skip size estimation on error. + } + + const metaPath = join(entryPath, 'meta.json') + if (!existsSync(metaPath)) { + // No meta.json — can't determine ownership, skip. + continue + } + + const meta = JSON.parse(readFileSync(metaPath, 'utf-8')) as { + worktreeId?: string + createdAt?: string + } + if (!meta.worktreeId) { + continue + } + + if (!liveWorktreeIds.has(meta.worktreeId)) { + // Why: avoid a TOCTOU race where a worktree is created after the + // live-ID snapshot but before GC runs. Directories younger than + // GC_MIN_AGE_MS are presumed still live and skipped. + if (meta.createdAt) { + const ageMs = now - new Date(meta.createdAt).getTime() + if (ageMs < GC_MIN_AGE_MS) { + continue + } + } + + result.orphaned++ + rmSync(entryPath, { recursive: true, force: true }) + result.pruned++ + console.log(`[pty:history:gc] Pruned orphaned history: ${meta.worktreeId}`) + } + } catch { + // Skip individual entries that fail. + } + } + return result +} + /** Run background GC to prune history directories for worktrees that are no * longer in Orca's known live-worktree set. */ export function runHistoryGc(liveWorktreeIds: Set): void { try { + const main = gcScanRoot(getHistoryRoot(), liveWorktreeIds) + + // Also scan WSL history directories (each distro has its own subdirectory). const wslRoot = join(app.getPath('userData'), HISTORY_DIR_NAME_WSL) - const summary = runTerminalHistoryGarbageCollection({ - mainRoot: getHistoryRoot(), - wslRoot, - liveWorktreeIds - }) + let wslTotals = { totalDirs: 0, orphaned: 0, pruned: 0, totalSizeKB: 0 } + if (existsSync(wslRoot)) { + try { + for (const distro of readdirSync(wslRoot)) { + const distroRoot = join(wslRoot, distro) + const r = gcScanRoot(distroRoot, liveWorktreeIds) + wslTotals.totalDirs += r.totalDirs + wslTotals.orphaned += r.orphaned + wslTotals.pruned += r.pruned + wslTotals.totalSizeKB += r.totalSizeKB + } + } catch { + // Non-fatal. + } + } + + const totalDirs = main.totalDirs + wslTotals.totalDirs + const orphaned = main.orphaned + wslTotals.orphaned + const pruned = main.pruned + wslTotals.pruned + const totalSizeKB = main.totalSizeKB + wslTotals.totalSizeKB console.log( - `[pty:history:gc] totalDirs=${summary.totalDirs} orphaned=${summary.orphaned} pruned=${summary.pruned} totalSizeKB=${summary.totalSizeKB}` + `[pty:history:gc] totalDirs=${totalDirs} orphaned=${orphaned} pruned=${pruned} totalSizeKB=${totalSizeKB}` ) } catch (err) { console.warn(`[pty:history:gc] GC failed: ${err instanceof Error ? err.message : String(err)}`) diff --git a/src/main/text-generation/commit-message-text-generation.test.ts b/src/main/text-generation/commit-message-text-generation.test.ts index fe156150854..3b921daf65b 100644 --- a/src/main/text-generation/commit-message-text-generation.test.ts +++ b/src/main/text-generation/commit-message-text-generation.test.ts @@ -16,7 +16,6 @@ import { generateBranchNameFromContext, generateCommitMessageFromContext, generatePullRequestFieldsFromContext, - MAX_CONCURRENT_LOCAL_TEXT_GENERATIONS, resolveCommitMessageSettings, trimGeneratedCommitMessage } from './commit-message-text-generation' @@ -480,119 +479,6 @@ describe('discoverCommitMessageModelsLocal', () => { }) }) - it('shares one child process for concurrent identical model discovery', async () => { - const children: MockDiscoveryChild[] = [] - spawnMock.mockImplementation(() => { - const child = createMockDiscoveryChild() - children.push(child) - return child as never - }) - - const first = discoverCommitMessageModelsLocal('cursor', { TOKEN: 'secret' }) - const second = discoverCommitMessageModelsLocal('cursor', { TOKEN: 'secret' }) - - expect(spawnMock).toHaveBeenCalledTimes(1) - children[0]!.stdout.emit('data', Buffer.from('auto - Auto\n')) - children[0]!.emit('close', 0) - - const [firstResult, secondResult] = await Promise.all([first, second]) - expect(firstResult).toMatchObject({ success: true, defaultModelId: 'auto' }) - expect(secondResult).toEqual(firstResult) - - const retry = discoverCommitMessageModelsLocal('cursor', { TOKEN: 'secret' }) - expect(spawnMock).toHaveBeenCalledTimes(2) - children[1]!.stdout.emit('data', Buffer.from('auto - Auto\n')) - children[1]!.emit('close', 0) - await expect(retry).resolves.toEqual(firstResult) - }) - - it('keeps different agent parsers isolated when discovery commands match', async () => { - const children: MockDiscoveryChild[] = [] - spawnMock.mockImplementation(() => { - const child = createMockDiscoveryChild() - children.push(child) - return child as never - }) - - const cursor = discoverCommitMessageModelsLocal('cursor', undefined, 'custom-discovery') - const pi = discoverCommitMessageModelsLocal('pi', undefined, 'custom-discovery') - - expect(children).toHaveLength(2) - children[0]!.stdout.emit('data', Buffer.from('auto - Auto\n')) - children[0]!.emit('close', 0) - children[1]!.stderr.emit( - 'data', - Buffer.from( - [ - 'provider model context max-out thinking images', - 'github-copilot gpt-5.4-mini 400K 128K yes yes' - ].join('\n') - ) - ) - children[1]!.emit('close', 0) - - await expect(cursor).resolves.toMatchObject({ success: true, defaultModelId: 'auto' }) - await expect(pi).resolves.toMatchObject({ - success: true, - defaultModelId: 'github-copilot/gpt-5.4-mini' - }) - }) - - it('shares the local process cap across discovery and generation, then releases on close', async () => { - const children: MockDiscoveryChild[] = [] - spawnMock.mockImplementation(() => { - const child = createMockDiscoveryChild() - child.pid += children.length - children.push(child) - return child as never - }) - const active = Array.from({ length: MAX_CONCURRENT_LOCAL_TEXT_GENERATIONS }, (_, index) => - discoverCommitMessageModelsLocal('cursor', undefined, undefined, { - cwd: `/repo-${index}` - }) - ) - - expect(children).toHaveLength(MAX_CONCURRENT_LOCAL_TEXT_GENERATIONS) - await expect( - discoverCommitMessageModelsLocal('cursor', undefined, undefined, { - cwd: '/repo-overflow' - }) - ).resolves.toEqual({ - success: false, - error: - 'Too many local AI generations are already running. Wait for one to finish and try again.' - }) - await expect( - generateCommitMessageFromContext( - { branch: 'main', stagedSummary: 'M\tREADME.md', stagedPatch: '+hello' }, - { agentId: 'custom', model: '', customAgentCommand: 'agent' }, - { kind: 'local', cwd: '/generation-overflow' } - ) - ).resolves.toEqual({ - success: false, - error: - 'Too many local AI generations are already running. Wait for one to finish and try again.' - }) - expect(spawnMock).toHaveBeenCalledTimes(MAX_CONCURRENT_LOCAL_TEXT_GENERATIONS) - - children[0]!.stdout.emit('data', Buffer.from('auto - Auto\n')) - children[0]!.emit('close', 0) - await active[0] - - const retry = discoverCommitMessageModelsLocal('cursor', undefined, undefined, { - cwd: '/repo-overflow' - }) - expect(children).toHaveLength(MAX_CONCURRENT_LOCAL_TEXT_GENERATIONS + 1) - - for (const child of children.slice(1)) { - child.stdout.emit('data', Buffer.from('auto - Auto\n')) - child.emit('close', 0) - } - await expect(Promise.all([...active, retry])).resolves.toHaveLength( - MAX_CONCURRENT_LOCAL_TEXT_GENERATIONS + 1 - ) - }) - it('settles and detaches model discovery when timeout kill is ignored', async () => { vi.useFakeTimers() const child = createMockDiscoveryChild() @@ -612,8 +498,6 @@ describe('discoverCommitMessageModelsLocal', () => { expect(child.stdout.listenerCount('data')).toBe(0) expect(child.stderr.listenerCount('data')).toBe(0) expect(child.listenerCount('error')).toBe(0) - expect(child.listenerCount('close')).toBe(1) - child.emit('close', null) expect(child.listenerCount('close')).toBe(0) } finally { vi.useRealTimers() @@ -636,8 +520,6 @@ describe('discoverCommitMessageModelsLocal', () => { expect(child.stdout.listenerCount('data')).toBe(0) expect(child.stderr.listenerCount('data')).toBe(0) expect(child.listenerCount('error')).toBe(0) - expect(child.listenerCount('close')).toBe(1) - child.emit('close', null) expect(child.listenerCount('close')).toBe(0) }) }) @@ -1259,36 +1141,6 @@ describe('generateCommitMessageFromContext', () => { expectChildTerminated(child) }) - it('preserves local agent output delivered as 100,000 one-byte fragments', async () => { - const child = createMockDiscoveryChild() - spawnMock.mockReturnValue(child as never) - const pending = generateCommitMessageFromContext( - { - branch: 'main', - stagedSummary: 'M\tREADME.md', - stagedPatch: '+hello' - }, - { - agentId: 'custom', - model: '', - customAgentCommand: 'agent' - }, - { kind: 'local', cwd: '/fragmented-repo' } - ) - - for (let index = 0; index < 100_000; index += 1) { - child.stdout.emit('data', Buffer.from(' ')) - } - child.stdout.emit('data', Buffer.from('Update README\n')) - child.emit('close', 0) - - await expect(pending).resolves.toEqual({ - success: true, - message: 'Update README', - agentLabel: 'agent' - }) - }) - it('passes prepared provider environment to local agent subprocesses', async () => { const listeners = new Map void>() const child = { @@ -1486,207 +1338,6 @@ describe('generateCommitMessageFromContext', () => { expect(children[1]?.kill).not.toHaveBeenCalled() }) - it('cancels the previous process before replacing one local lane', async () => { - const children: { - kill: ReturnType - listeners: Map void> - }[] = [] - spawnMock.mockImplementation(() => { - const listeners = new Map void>() - const child = { - pid: 123 + children.length, - kill: vi.fn(), - stdout: { on: vi.fn((event, callback) => listeners.set(`stdout:${event}`, callback)) }, - stderr: { on: vi.fn((event, callback) => listeners.set(`stderr:${event}`, callback)) }, - stdin: { end: vi.fn() }, - on: vi.fn((event, callback) => listeners.set(event, callback)) - } - children.push({ kill: child.kill, listeners }) - return child as never - }) - const context = { branch: 'main', stagedSummary: 'M\tREADME.md', stagedPatch: '+hello' } - const params = { agentId: 'custom' as const, model: '', customAgentCommand: 'agent' } - const target = { kind: 'local' as const, cwd: '/same-repo' } - - const first = generateCommitMessageFromContext(context, params, target) - const second = generateCommitMessageFromContext(context, params, target) - - expectChildTerminated({ pid: 123, kill: children[0]!.kill }) - expect(children).toHaveLength(2) - children[0]?.listeners.get('close')?.(null) - children[1]?.listeners.get('stdout:data')?.(Buffer.from('Update README\n')) - children[1]?.listeners.get('close')?.(0) - await expect(first).resolves.toMatchObject({ success: false, canceled: true }) - await expect(second).resolves.toMatchObject({ success: true, message: 'Update README' }) - }) - - it('caps concurrent local text-generation child processes', async () => { - const children: Map void>[] = [] - spawnMock.mockImplementation(() => { - const listeners = new Map void>() - const child = { - pid: 200 + children.length, - kill: vi.fn(), - stdout: { on: vi.fn((event, callback) => listeners.set(`stdout:${event}`, callback)) }, - stderr: { on: vi.fn((event, callback) => listeners.set(`stderr:${event}`, callback)) }, - stdin: { end: vi.fn() }, - on: vi.fn((event, callback) => listeners.set(event, callback)) - } - children.push(listeners) - return child as never - }) - const context = { branch: 'main', stagedSummary: 'M\tREADME.md', stagedPatch: '+hello' } - const params = { agentId: 'custom' as const, model: '', customAgentCommand: 'agent' } - const active = Array.from({ length: MAX_CONCURRENT_LOCAL_TEXT_GENERATIONS }, (_, index) => - generateCommitMessageFromContext(context, params, { - kind: 'local', - cwd: `/repo-${index}` - }) - ) - - await expect( - generateCommitMessageFromContext(context, params, { - kind: 'local', - cwd: '/repo-overflow' - }) - ).resolves.toEqual({ - success: false, - error: - 'Too many local AI generations are already running. Wait for one to finish and try again.' - }) - expect(spawnMock).toHaveBeenCalledTimes(MAX_CONCURRENT_LOCAL_TEXT_GENERATIONS) - - for (const listeners of children) { - listeners.get('stdout:data')?.(Buffer.from('Update README\n')) - listeners.get('close')?.(0) - } - await expect(Promise.all(active)).resolves.toHaveLength(MAX_CONCURRENT_LOCAL_TEXT_GENERATIONS) - }) - - it('waits for a full-capacity lane to close before spawning its replacement', async () => { - const children: { - pid: number - kill: ReturnType - listeners: Map void> - closed: boolean - }[] = [] - spawnMock.mockImplementation(() => { - const listeners = new Map void>() - const child = { - pid: 300 + children.length, - kill: vi.fn(), - stdout: { on: vi.fn((event, callback) => listeners.set(`stdout:${event}`, callback)) }, - stderr: { on: vi.fn((event, callback) => listeners.set(`stderr:${event}`, callback)) }, - stdin: { end: vi.fn() }, - on: vi.fn((event, callback) => listeners.set(event, callback)) - } - children.push({ pid: child.pid, kill: child.kill, listeners, closed: false }) - return child as never - }) - const context = { branch: 'main', stagedSummary: 'M\tREADME.md', stagedPatch: '+hello' } - const params = { agentId: 'custom' as const, model: '', customAgentCommand: 'agent' } - const active = Array.from({ length: MAX_CONCURRENT_LOCAL_TEXT_GENERATIONS }, (_, index) => - generateCommitMessageFromContext(context, params, { - kind: 'local', - cwd: `/full-repo-${index}` - }) - ) - - const replacement = generateCommitMessageFromContext(context, params, { - kind: 'local', - cwd: '/full-repo-0' - }) - - expectChildTerminated(children[0]!) - expect(children).toHaveLength(MAX_CONCURRENT_LOCAL_TEXT_GENERATIONS) - expect(children.filter((child) => !child.closed)).toHaveLength( - MAX_CONCURRENT_LOCAL_TEXT_GENERATIONS - ) - - children[0]!.closed = true - children[0]!.listeners.get('close')?.(null) - await Promise.resolve() - await Promise.resolve() - - expect(children).toHaveLength(MAX_CONCURRENT_LOCAL_TEXT_GENERATIONS + 1) - expect(children.filter((child) => !child.closed)).toHaveLength( - MAX_CONCURRENT_LOCAL_TEXT_GENERATIONS - ) - - for (const child of children.slice(1)) { - child.listeners.get('stdout:data')?.(Buffer.from('Update README\n')) - child.closed = true - child.listeners.get('close')?.(0) - } - await expect(active[0]).resolves.toMatchObject({ success: false, canceled: true }) - await expect(Promise.all([...active.slice(1), replacement])).resolves.toHaveLength( - MAX_CONCURRENT_LOCAL_TEXT_GENERATIONS - ) - }) - - it('cancels and supersedes replacements queued behind a full lane', async () => { - const children: { - pid: number - kill: ReturnType - listeners: Map void> - }[] = [] - spawnMock.mockImplementation(() => { - const listeners = new Map void>() - const child = { - pid: 400 + children.length, - kill: vi.fn(), - stdout: { on: vi.fn((event, callback) => listeners.set(`stdout:${event}`, callback)) }, - stderr: { on: vi.fn((event, callback) => listeners.set(`stderr:${event}`, callback)) }, - stdin: { end: vi.fn() }, - on: vi.fn((event, callback) => listeners.set(event, callback)) - } - children.push({ pid: child.pid, kill: child.kill, listeners }) - return child as never - }) - const context = { branch: 'main', stagedSummary: 'M\tREADME.md', stagedPatch: '+hello' } - const params = { agentId: 'custom' as const, model: '', customAgentCommand: 'agent' } - const active = Array.from({ length: MAX_CONCURRENT_LOCAL_TEXT_GENERATIONS }, (_, index) => - generateCommitMessageFromContext(context, params, { - kind: 'local', - cwd: `/queued-repo-${index}` - }) - ) - - const superseded = generateCommitMessageFromContext(context, params, { - kind: 'local', - cwd: '/queued-repo-0' - }) - const canceled = generateCommitMessageFromContext(context, params, { - kind: 'local', - cwd: '/queued-repo-0' - }) - - await expect(superseded).resolves.toMatchObject({ success: false, canceled: true }) - expect(children).toHaveLength(MAX_CONCURRENT_LOCAL_TEXT_GENERATIONS) - cancelGenerateCommitMessageLocal('/queued-repo-0') - await expect(canceled).resolves.toMatchObject({ success: false, canceled: true }) - - const replacement = generateCommitMessageFromContext(context, params, { - kind: 'local', - cwd: '/queued-repo-0' - }) - expect(children).toHaveLength(MAX_CONCURRENT_LOCAL_TEXT_GENERATIONS) - - children[0]!.listeners.get('close')?.(null) - await Promise.resolve() - await Promise.resolve() - expect(children).toHaveLength(MAX_CONCURRENT_LOCAL_TEXT_GENERATIONS + 1) - - for (const child of children.slice(1)) { - child.listeners.get('stdout:data')?.(Buffer.from('Update README\n')) - child.listeners.get('close')?.(0) - } - await expect(active[0]).resolves.toMatchObject({ success: false, canceled: true }) - await expect(Promise.all([...active.slice(1), replacement])).resolves.toHaveLength( - MAX_CONCURRENT_LOCAL_TEXT_GENERATIONS - ) - }) - it('keeps local pull-request cancellation from stopping commit-message generation', async () => { const children: { pid: number @@ -1961,8 +1612,7 @@ describe('generateCommitMessageFromContext', () => { expect(listeners.has('stdout:data')).toBe(false) expect(listeners.has('stderr:data')).toBe(false) expect(listeners.has('error')).toBe(false) - expect(listeners.has('close')).toBe(true) - listeners.get('close')?.(null) + expect(listeners.has('close')).toBe(false) } finally { vi.useRealTimers() } diff --git a/src/main/text-generation/commit-message-text-generation.ts b/src/main/text-generation/commit-message-text-generation.ts index 0022ca03d6f..ff0c9e5fc24 100644 --- a/src/main/text-generation/commit-message-text-generation.ts +++ b/src/main/text-generation/commit-message-text-generation.ts @@ -2,8 +2,6 @@ spawn failure handling, and output normalization; keeping them together prevents those paths from drifting. */ import { exec, spawn, type ChildProcess } from 'node:child_process' -import { createHash } from 'node:crypto' -import { GrowingByteBuffer } from '../../shared/growing-byte-buffer' import type { GlobalSettings, Repo, TuiAgent } from '../../shared/types' import { buildCommitMessagePrompt, @@ -55,10 +53,6 @@ import { } from '../win32-utils' import { withMacTailscaleDnsHint } from '../network/macos-tailscale-dns-diagnostic' import { wslAwareSpawn } from '../git/runner' -import { - MAX_CONCURRENT_LOCAL_TEXT_GENERATIONS, - reserveLocalAiProcess -} from './local-ai-process-budget' const GENERATION_TIMEOUT_MS = 60_000 const MAX_AGENT_OUTPUT_BYTES = 4 * 1024 * 1024 @@ -302,29 +296,6 @@ function planModelDiscovery( } } -const inFlightLocalModelDiscoveries = new Map>() - -function localModelDiscoveryKey( - agentId: TuiAgent, - plan: CommitMessagePlan, - env: NodeJS.ProcessEnv, - options: CommitMessageModelDiscoveryLocalOptions -): string { - // Hash the full identity so environment and command secrets never remain in map keys. - return createHash('sha256') - .update( - JSON.stringify([ - agentId, - plan.binary, - plan.args, - options.cwd ?? null, - options.wslDistro ?? null, - Object.entries(env).sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey)) - ]) - ) - .digest('hex') -} - export async function discoverCommitMessageModelsLocal( agentId: TuiAgent, env: NodeJS.ProcessEnv | undefined, @@ -340,49 +311,19 @@ export async function discoverCommitMessageModelsLocal( return toModelDiscoveryCapability(spec) } - const planned = planModelDiscovery(spec, agentCommandOverride) - if (!planned.ok) { - return { success: false, error: planned.error } - } - const spawnEnv = env ?? process.env - const discoveryKey = localModelDiscoveryKey(spec.id, planned.plan, spawnEnv, options) - const inFlight = inFlightLocalModelDiscoveries.get(discoveryKey) - if (inFlight) { - return inFlight - } - const pending = runLocalModelDiscovery(spec, planned.plan, env, spawnEnv, options) - inFlightLocalModelDiscoveries.set(discoveryKey, pending) - const clearPending = (): void => { - if (inFlightLocalModelDiscoveries.get(discoveryKey) === pending) { - inFlightLocalModelDiscoveries.delete(discoveryKey) - } - } - void pending.then(clearPending, clearPending) - return pending -} - -function runLocalModelDiscovery( - spec: NonNullable>, - plan: CommitMessagePlan, - explicitEnv: NodeJS.ProcessEnv | undefined, - spawnEnv: NodeJS.ProcessEnv, - options: CommitMessageModelDiscoveryLocalOptions -): Promise { - const reservation = reserveLocalAiProcess() - if (!reservation) { - return Promise.resolve({ - success: false, - error: - 'Too many local AI generations are already running. Wait for one to finish and try again.' - }) - } return new Promise((resolve) => { let child: ChildProcess + const spawnEnv = env ?? process.env try { + const planned = planModelDiscovery(spec, agentCommandOverride) + if (!planned.ok) { + resolve({ success: false, error: planned.error }) + return + } if (process.platform === 'win32' && options.wslDistro) { - child = wslAwareSpawn(plan.binary, plan.args, { + child = wslAwareSpawn(planned.plan.binary, planned.plan.args, { cwd: options.cwd, - env: buildWslLauncherEnv(explicitEnv), + env: buildWslLauncherEnv(env), stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, wslDistro: options.wslDistro, @@ -391,11 +332,11 @@ function runLocalModelDiscovery( } else { const resolvedBinary = process.platform === 'win32' - ? resolveCliCommand(plan.binary, { + ? resolveCliCommand(planned.plan.binary, { pathEnv: spawnEnv.PATH ?? spawnEnv.Path ?? null }) - : plan.binary - const { spawnCmd, spawnArgs } = getSpawnArgsForWindows(resolvedBinary, plan.args) + : planned.plan.binary + const { spawnCmd, spawnArgs } = getSpawnArgsForWindows(resolvedBinary, planned.plan.args) child = spawn(spawnCmd, spawnArgs, { env: spawnEnv, stdio: ['ignore', 'pipe', 'pipe'], @@ -403,7 +344,6 @@ function runLocalModelDiscovery( }) } } catch (error) { - reservation.release() console.error('[commit-message] Failed to spawn model discovery:', error) resolve({ success: false, @@ -411,10 +351,9 @@ function runLocalModelDiscovery( }) return } - const owner = reservation.register(child) - const stdout = new GrowingByteBuffer() - const stderr = new GrowingByteBuffer() + let stdout = '' + let stderr = '' let outputLimitExceeded = false let settled = false let timer: ReturnType | null = null @@ -429,8 +368,6 @@ function runLocalModelDiscovery( timer = null } detachChildListeners() - stdout.clear() - stderr.clear() resolve(result) } timer = setTimeout(() => { @@ -441,18 +378,18 @@ function runLocalModelDiscovery( }) }, GENERATION_TIMEOUT_MS) - const onData = (chunk: Buffer, append: (value: Buffer) => void): void => { - if (stdout.byteLength + stderr.byteLength + chunk.byteLength > MAX_AGENT_OUTPUT_BYTES) { + const onData = (chunk: Buffer, append: (text: string) => void): void => { + if (stdout.length + stderr.length + chunk.byteLength > MAX_AGENT_OUTPUT_BYTES) { outputLimitExceeded = true killProcessTree(child) finish({ success: false, error: `${spec.label} returned too much model data.` }) return } - append(chunk) + append(chunk.toString('utf-8')) } - const onStdoutData = (chunk: Buffer): void => onData(chunk, (value) => stdout.append(value)) - const onStderrData = (chunk: Buffer): void => onData(chunk, (value) => stderr.append(value)) + const onStdoutData = (chunk: Buffer): void => onData(chunk, (text) => (stdout += text)) + const onStderrData = (chunk: Buffer): void => onData(chunk, (text) => (stderr += text)) const onError = (error: Error): void => { if ((error as NodeJS.ErrnoException).code === 'ENOENT') { finish({ @@ -467,17 +404,15 @@ function runLocalModelDiscovery( }) } const onClose = (code: number | null): void => { - child.off?.('close', onClose) - owner.release() if (outputLimitExceeded) { finish({ success: false, error: `${spec.label} returned too much model data.` }) return } if (code !== 0) { - finish(finalizeModelDiscoveryOutput(spec, stdout.toString(), stderr.toString(), code)) + finish(finalizeModelDiscoveryOutput(spec, stdout, stderr, code)) return } - finish(finalizeModelDiscoveryOutput(spec, stdout.toString(), stderr.toString(), code)) + finish(finalizeModelDiscoveryOutput(spec, stdout, stderr, code)) } child.stdout?.on('data', onStdoutData) @@ -488,6 +423,7 @@ function runLocalModelDiscovery( child.stdout?.off?.('data', onStdoutData) child.stderr?.off?.('data', onStderrData) child.off?.('error', onError) + child.off?.('close', onClose) } }) } @@ -578,8 +514,6 @@ function killProcessTree(child: ChildProcess): void { // Keying by operation plus `local:${cwd}` keeps local cancellation independent // from SSH worktrees and from other generation features in the same worktree. const cancelTokensByLane = new Map void>() -const pendingReservationCancelTokensByLane = new Map void>() -export { MAX_CONCURRENT_LOCAL_TEXT_GENERATIONS } const WSL_LAUNCHER_ENV_KEYS = [ 'ComSpec', 'COMSPEC', @@ -596,17 +530,8 @@ function localLaneKey(operation: TextGenerationOperation, cwd: string): string { return `${operation}:local:${cwd}` } -function cancelLocalGenerationLane(laneKey: string): void { - const cancelPendingReservation = pendingReservationCancelTokensByLane.get(laneKey) - if (cancelPendingReservation) { - cancelPendingReservation() - return - } - cancelTokensByLane.get(laneKey)?.() -} - export function cancelGenerateCommitMessageLocal(cwd: string): void { - cancelLocalGenerationLane(localLaneKey('commit-message', cwd)) + cancelTokensByLane.get(localLaneKey('commit-message', cwd))?.() } function buildWslLauncherEnv(explicitEnv: NodeJS.ProcessEnv | undefined): NodeJS.ProcessEnv { @@ -634,46 +559,6 @@ async function runLocalPlan( wslDistro?: string ): Promise { const { binary, args, stdinPayload, label } = plan - const laneKey = localLaneKey(operation, cwd) - pendingReservationCancelTokensByLane.get(laneKey)?.() - const reservation = reserveLocalAiProcess(laneKey) - if (!reservation) { - return { - success: false, - error: - 'Too many local AI generations are already running. Wait for one to finish and try again.' - } - } - const previousCancelToken = cancelTokensByLane.get(laneKey) - if (reservation.waitForClose) { - let canceledWhileWaiting = false - let resolvePendingCancellation = (): void => {} - const pendingCancellation = new Promise((resolve) => { - resolvePendingCancellation = resolve - }) - const cancelPendingReservation = (): void => { - if (canceledWhileWaiting) { - return - } - canceledWhileWaiting = true - reservation.release() - if (pendingReservationCancelTokensByLane.get(laneKey) === cancelPendingReservation) { - pendingReservationCancelTokensByLane.delete(laneKey) - } - resolvePendingCancellation() - } - pendingReservationCancelTokensByLane.set(laneKey, cancelPendingReservation) - previousCancelToken?.() - await Promise.race([reservation.waitForClose, pendingCancellation]) - if (pendingReservationCancelTokensByLane.get(laneKey) === cancelPendingReservation) { - pendingReservationCancelTokensByLane.delete(laneKey) - } - if (canceledWhileWaiting) { - return { success: false, error: 'Generation canceled.', canceled: true } - } - } else { - previousCancelToken?.() - } return new Promise((resolve) => { let child: ChildProcess try { @@ -701,7 +586,6 @@ async function runLocalPlan( }) } } catch (error) { - reservation.release() if (error instanceof UnsafeWindowsBatchArgumentsError) { resolve({ success: false, @@ -716,13 +600,15 @@ async function runLocalPlan( }) return } - const owner = reservation.register(child, laneKey) - const stdout = new GrowingByteBuffer() - const stderr = new GrowingByteBuffer() + let stdout = '' + let stderr = '' + let stdoutBytes = 0 + let stderrBytes = 0 let outputLimitExceeded = false let settled = false let canceledByUser = false + const laneKey = localLaneKey(operation, cwd) let cancelToken: (() => void) | null = null let timer: ReturnType | null = null let detachChildListeners = (): void => {} @@ -739,8 +625,6 @@ async function runLocalPlan( if (cancelToken && cancelTokensByLane.get(laneKey) === cancelToken) { cancelTokensByLane.delete(laneKey) } - stdout.clear() - stderr.clear() resolve(result) } @@ -762,20 +646,22 @@ async function runLocalPlan( }, GENERATION_TIMEOUT_MS) const onStdoutData = (chunk: Buffer): void => { - if (stdout.byteLength + chunk.byteLength > MAX_AGENT_OUTPUT_BYTES) { + stdoutBytes += chunk.byteLength + if (stdoutBytes > MAX_AGENT_OUTPUT_BYTES) { outputLimitExceeded = true killProcessTree(child) return } - stdout.append(chunk) + stdout += chunk.toString('utf-8') } const onStderrData = (chunk: Buffer): void => { - if (stderr.byteLength + chunk.byteLength > MAX_AGENT_OUTPUT_BYTES) { + stderrBytes += chunk.byteLength + if (stderrBytes > MAX_AGENT_OUTPUT_BYTES) { outputLimitExceeded = true killProcessTree(child) return } - stderr.append(chunk) + stderr += chunk.toString('utf-8') } const onError = (error: Error): void => { const code = (error as NodeJS.ErrnoException).code @@ -793,8 +679,6 @@ async function runLocalPlan( }) } const onClose = (code: number | null): void => { - child.off?.('close', onClose) - owner.release() if (canceledByUser) { finalize({ success: false, error: 'Generation canceled.', canceled: true }) return @@ -808,8 +692,8 @@ async function runLocalPlan( } finalizeFromAgentOutput({ code, - stdout: stdout.toString(), - stderr: stderr.toString(), + stdout, + stderr, label, emptyResultName, finalize, @@ -824,6 +708,7 @@ async function runLocalPlan( child.stdout?.off?.('data', onStdoutData) child.stderr?.off?.('data', onStderrData) child.off?.('error', onError) + child.off?.('close', onClose) } child.stdin?.end(stdinPayload ?? undefined) @@ -1014,7 +899,7 @@ export async function generateCommitMessageFromContext( } export function cancelGeneratePullRequestFieldsLocal(cwd: string): void { - cancelLocalGenerationLane(localLaneKey('pull-request-fields', cwd)) + cancelTokensByLane.get(localLaneKey('pull-request-fields', cwd))?.() } function formatPullRequestFieldsGenerationResult( diff --git a/src/main/text-generation/local-ai-process-budget.ts b/src/main/text-generation/local-ai-process-budget.ts deleted file mode 100644 index 9b51a6629ef..00000000000 --- a/src/main/text-generation/local-ai-process-budget.ts +++ /dev/null @@ -1,110 +0,0 @@ -import type { ChildProcess } from 'node:child_process' - -export const MAX_CONCURRENT_LOCAL_TEXT_GENERATIONS = 8 - -type LocalAiProcessOwner = { - child: ChildProcess - closed: Promise - release: () => void -} - -export type LocalAiProcessReservation = { - waitForClose?: Promise - register: (child: ChildProcess, laneKey?: string) => LocalAiProcessOwner - release: () => void -} - -type ReplacementReservation = { - reservation: LocalAiProcessReservation - activate: () => void -} - -const activeOwners = new Set() -const reservedSlots = new Set() -const ownerByLane = new Map() -const replacementByOwner = new Map() - -export function reserveLocalAiProcess(laneKey?: string): LocalAiProcessReservation | null { - let predecessor: LocalAiProcessOwner | undefined - if (activeOwners.size + reservedSlots.size >= MAX_CONCURRENT_LOCAL_TEXT_GENERATIONS) { - predecessor = laneKey ? ownerByLane.get(laneKey) : undefined - if (!predecessor || replacementByOwner.has(predecessor)) { - return null - } - } - - let state: 'waiting' | 'reserved' | 'registered' | 'released' = predecessor - ? 'waiting' - : 'reserved' - let reservation: LocalAiProcessReservation - const activate = (): void => { - if (state !== 'waiting') { - return - } - state = 'reserved' - reservedSlots.add(reservation) - } - reservation = { - waitForClose: predecessor?.closed, - register: (child, ownerLaneKey) => { - if (state !== 'reserved') { - throw new Error('Local AI process reservation is not ready.') - } - reservedSlots.delete(reservation) - state = 'registered' - - let released = false - let resolveClosed = (): void => {} - const closed = new Promise((resolve) => { - resolveClosed = resolve - }) - const owner: LocalAiProcessOwner = { - child, - closed, - release: () => { - if (released) { - return - } - released = true - activeOwners.delete(owner) - if (ownerLaneKey && ownerByLane.get(ownerLaneKey) === owner) { - ownerByLane.delete(ownerLaneKey) - } - const replacement = replacementByOwner.get(owner) - if (replacement) { - replacementByOwner.delete(owner) - // Preserve the claimed slot before `closed` lets the replacement resume. - replacement.activate() - } - resolveClosed() - } - } - activeOwners.add(owner) - if (ownerLaneKey) { - ownerByLane.set(ownerLaneKey, owner) - } - return owner - }, - release: () => { - if (state === 'waiting' && predecessor) { - const replacement = replacementByOwner.get(predecessor) - if (replacement?.reservation === reservation) { - replacementByOwner.delete(predecessor) - } - state = 'released' - return - } - if (state === 'reserved') { - reservedSlots.delete(reservation) - state = 'released' - } - } - } - - if (predecessor) { - replacementByOwner.set(predecessor, { reservation, activate }) - } else { - reservedSlots.add(reservation) - } - return reservation -} diff --git a/src/main/updater-changelog.test.ts b/src/main/updater-changelog.test.ts index c9a9d306627..f6a348858d0 100644 --- a/src/main/updater-changelog.test.ts +++ b/src/main/updater-changelog.test.ts @@ -1,5 +1,4 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { API_RESPONSE_MAX_BYTES, FetchResponseBodyTooLargeError } from './lib/fetch-response-body' const fetchMock = vi.fn() @@ -227,18 +226,6 @@ describe('fetchChangelog', () => { expect(result).toBeNull() }) - it('rejects an oversized successful Electron net response before reading it', async () => { - fetchMock.mockResolvedValue( - new Response('{', { - headers: { 'content-length': String(API_RESPONSE_MAX_BYTES + 1) } - }) - ) - - await expect(fetchChangelog('1.1.21', '1.1.19')).rejects.toBeInstanceOf( - FetchResponseBodyTooLargeError - ) - }) - it('prefers exact match over fallback when both have rich content', async () => { const entries = makeEntries([ { diff --git a/src/main/updater-changelog.ts b/src/main/updater-changelog.ts index ea52d9db4ff..a534075cfc6 100644 --- a/src/main/updater-changelog.ts +++ b/src/main/updater-changelog.ts @@ -1,7 +1,6 @@ import { net } from 'electron' import type { ChangelogData } from '../shared/types' import { compareVersions } from './updater-fallback' -import { readFetchResponseJsonWithinLimit } from './lib/fetch-response-body' type ChangelogEntry = { version: string @@ -49,7 +48,7 @@ export async function fetchChangelog( if (!res.ok) { return null } - const json = await readFetchResponseJsonWithinLimit(res) + const json: unknown = await res.json() // Why: the JSON endpoint is external and could serve malformed data. // Validate the shape before indexing into it to avoid runtime errors diff --git a/src/main/updater-nudge.ts b/src/main/updater-nudge.ts index 1755c2ec021..93ef8c1c105 100644 --- a/src/main/updater-nudge.ts +++ b/src/main/updater-nudge.ts @@ -1,6 +1,5 @@ import { net } from 'electron' import { compareVersions, isValidVersion } from './updater-fallback' -import { readFetchResponseJsonWithinLimit } from './lib/fetch-response-body' export type NudgeConfig = { id: string @@ -17,7 +16,7 @@ export async function fetchNudge(): Promise { return null } - const json = await readFetchResponseJsonWithinLimit(res) + const json: unknown = await res.json() if (!json || typeof json !== 'object' || Array.isArray(json)) { return null } diff --git a/src/main/updater-prerelease-feed.ts b/src/main/updater-prerelease-feed.ts index 6bc5bca20ef..21a1f397ff4 100644 --- a/src/main/updater-prerelease-feed.ts +++ b/src/main/updater-prerelease-feed.ts @@ -1,7 +1,6 @@ import { net } from 'electron' import { parse } from 'yaml' import { compareVersions, isPrereleaseVersion, isValidVersion } from './updater-fallback' -import { readFetchResponseTextWithinLimit } from './lib/fetch-response-body' const ATOM_FEED_URL = 'https://github.com/stablyai/orca/releases.atom' const RELEASES_DOWNLOAD_BASE = 'https://github.com/stablyai/orca/releases/download' @@ -62,7 +61,7 @@ async function fetchReleaseFeedTags(): Promise { if (!res.ok) { return null } - const body = await readFetchResponseTextWithinLimit(res) + const body = await res.text() const tags: ReleaseFeedTag[] = [] for (const match of body.matchAll(TAG_HREF_RE)) { @@ -129,7 +128,7 @@ async function hasReadyPlatformManifest(tag: string): Promise { if (!res.ok) { return false } - const assetNames = getManifestAssetNames(await readFetchResponseTextWithinLimit(res)) + const assetNames = getManifestAssetNames(await res.text()) if (assetNames.length === 0) { return false } diff --git a/src/main/usage-history-file-discovery.ts b/src/main/usage-history-file-discovery.ts deleted file mode 100644 index f16dfdc1d4d..00000000000 --- a/src/main/usage-history-file-discovery.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { opendir } from 'node:fs/promises' -import { join } from 'node:path' -import type { UsageHistoryScanBudget } from './usage-history-scan-budget' - -export async function walkUsageHistoryJsonlFiles( - rootPath: string, - budget: UsageHistoryScanBudget -): Promise { - const pendingDirectories = [rootPath] - const files: string[] = [] - - while (pendingDirectories.length > 0) { - const directoryPath = pendingDirectories.pop()! - let directory: Awaited> - try { - directory = await opendir(directoryPath) - } catch (error) { - if (directoryPath !== rootPath && isVanishedDirectoryError(error)) { - continue - } - throw error - } - for await (const entry of directory) { - budget.claimDiscoveryEntry() - const fullPath = join(directoryPath, entry.name) - if (entry.isDirectory()) { - budget.claimPath(fullPath) - pendingDirectories.push(fullPath) - } else if (entry.isFile() && entry.name.endsWith('.jsonl')) { - budget.claimFile(fullPath) - files.push(fullPath) - } - } - } - - return files -} - -function isVanishedDirectoryError(error: unknown): boolean { - if (error === null || typeof error !== 'object' || !('code' in error)) { - return false - } - return error.code === 'ENOENT' || error.code === 'ENOTDIR' -} diff --git a/src/main/usage-history-jsonl-reader.test.ts b/src/main/usage-history-jsonl-reader.test.ts deleted file mode 100644 index df2551b4628..00000000000 --- a/src/main/usage-history-jsonl-reader.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { mkdtemp, rm, truncate, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import { - MAX_USAGE_HISTORY_JSONL_LINE_BYTES, - UsageHistoryJsonlLineCapacityError, - readUsageHistoryJsonlLines -} from './usage-history-jsonl-reader' - -describe('readUsageHistoryJsonlLines', () => { - const temporaryDirectories: string[] = [] - - afterEach(async () => { - await Promise.all( - temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })) - ) - }) - - async function createPath(): Promise { - const directory = await mkdtemp(join(tmpdir(), 'orca-usage-jsonl-')) - temporaryDirectories.push(directory) - return join(directory, 'history.jsonl') - } - - it('preserves ordinary empty, CRLF, and unterminated records', async () => { - const filePath = await createPath() - await writeFile(filePath, 'first\r\n\nlast') - - const lines: string[] = [] - for await (const line of readUsageHistoryJsonlLines(filePath)) { - lines.push(line) - } - - expect(lines).toEqual(['first', '', 'last']) - }) - - it('accepts a record exactly at the byte limit', async () => { - const filePath = await createPath() - await writeFile(filePath, Buffer.alloc(MAX_USAGE_HISTORY_JSONL_LINE_BYTES, 0x61)) - - const lines: string[] = [] - for await (const line of readUsageHistoryJsonlLines(filePath)) { - lines.push(line) - } - - expect(Buffer.byteLength(lines[0])).toBe(MAX_USAGE_HISTORY_JSONL_LINE_BYTES) - }) - - it('rejects a sparse unterminated record before reading the whole file', async () => { - const filePath = await createPath() - await writeFile(filePath, '') - await truncate(filePath, MAX_USAGE_HISTORY_JSONL_LINE_BYTES + 8 * 1024 * 1024) - - const consume = async (): Promise => { - for await (const _line of readUsageHistoryJsonlLines(filePath)) { - // The oversized record is unterminated, so no line should be yielded. - } - } - - await expect(consume()).rejects.toThrow(UsageHistoryJsonlLineCapacityError) - }) -}) diff --git a/src/main/usage-history-jsonl-reader.ts b/src/main/usage-history-jsonl-reader.ts deleted file mode 100644 index d9e5870c6ce..00000000000 --- a/src/main/usage-history-jsonl-reader.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { createReadStream } from 'node:fs' - -export const MAX_USAGE_HISTORY_JSONL_LINE_BYTES = 4 * 1024 * 1024 - -export class UsageHistoryJsonlLineCapacityError extends Error { - constructor(readonly limit = MAX_USAGE_HISTORY_JSONL_LINE_BYTES) { - super(`Usage history JSONL record exceeded ${limit} bytes`) - this.name = 'UsageHistoryJsonlLineCapacityError' - } -} - -export async function* readUsageHistoryJsonlLines( - filePath: string, - options: { start?: number; maxLineBytes?: number } = {} -): AsyncGenerator { - const maxLineBytes = options.maxLineBytes ?? MAX_USAGE_HISTORY_JSONL_LINE_BYTES - const stream = createReadStream(filePath, { - start: options.start ?? 0, - highWaterMark: 64 * 1024 - }) - let fragments: Buffer[] = [] - let fragmentBytes = 0 - - for await (const rawChunk of stream) { - const chunk = Buffer.isBuffer(rawChunk) ? rawChunk : Buffer.from(rawChunk) - let offset = 0 - while (offset < chunk.length) { - const newlineIndex = chunk.indexOf(0x0a, offset) - const end = newlineIndex === -1 ? chunk.length : newlineIndex - const fragment = chunk.subarray(offset, end) - if (fragment.length > maxLineBytes - fragmentBytes) { - throw new UsageHistoryJsonlLineCapacityError(maxLineBytes) - } - if (fragment.length > 0) { - fragments.push(fragment) - fragmentBytes += fragment.length - } - if (newlineIndex === -1) { - break - } - yield decodeJsonlLine(fragments, fragmentBytes) - fragments = [] - fragmentBytes = 0 - offset = newlineIndex + 1 - } - } - - if (fragmentBytes > 0) { - yield decodeJsonlLine(fragments, fragmentBytes) - } -} - -function decodeJsonlLine(fragments: Buffer[], bytes: number): string { - const line = fragments.length === 1 ? fragments[0] : Buffer.concat(fragments, bytes) - const content = line.at(-1) === 0x0d ? line.subarray(0, -1) : line - return content.toString('utf8') -} diff --git a/src/main/usage-history-scan-budget.test.ts b/src/main/usage-history-scan-budget.test.ts deleted file mode 100644 index 80cff368e22..00000000000 --- a/src/main/usage-history-scan-budget.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - MAX_USAGE_HISTORY_DISCOVERY_ENTRIES, - MAX_USAGE_HISTORY_FILES, - MAX_USAGE_HISTORY_OWNERSHIP_KEYS, - MAX_USAGE_HISTORY_RECORDS, - MAX_USAGE_HISTORY_RETAINED_BYTES, - UsageHistoryScanBudget, - UsageHistoryScanCapacityError -} from './usage-history-scan-budget' - -describe('UsageHistoryScanBudget', () => { - it('publishes the production scan limits', () => { - expect({ - files: MAX_USAGE_HISTORY_FILES, - discoveryEntries: MAX_USAGE_HISTORY_DISCOVERY_ENTRIES, - records: MAX_USAGE_HISTORY_RECORDS, - ownershipKeys: MAX_USAGE_HISTORY_OWNERSHIP_KEYS, - retainedBytes: MAX_USAGE_HISTORY_RETAINED_BYTES - }).toEqual({ - files: 200_000, - discoveryEntries: 1_000_000, - records: 100_000, - ownershipKeys: 100_000, - retainedBytes: 256 * 1024 * 1024 - }) - }) - - it('accepts exact limits and fails closed before each collection can grow past them', () => { - const files = new UsageHistoryScanBudget({ files: 2, retainedBytes: 10_000 }) - files.claimFile('a') - files.claimFile('b') - expect(() => files.claimFile('c')).toThrowError( - expect.objectContaining({ resource: 'files', limit: 2 }) - ) - - const entries = new UsageHistoryScanBudget({ discoveryEntries: 2 }) - entries.claimDiscoveryEntry() - entries.claimDiscoveryEntry() - expect(() => entries.claimDiscoveryEntry()).toThrow(UsageHistoryScanCapacityError) - - const records = new UsageHistoryScanBudget({ records: 2, retainedBytes: 10_000 }) - records.claimRecord(1) - records.claimRecord(1) - expect(() => records.claimRecord(1)).toThrowError( - expect.objectContaining({ resource: 'records', limit: 2 }) - ) - - const keys = new UsageHistoryScanBudget({ ownershipKeys: 2, retainedBytes: 10_000 }) - keys.claimOwnershipKey('a') - keys.claimOwnershipKey('b') - expect(() => keys.claimOwnershipKey('c')).toThrowError( - expect.objectContaining({ resource: 'ownershipKeys', limit: 2 }) - ) - - const bytes = new UsageHistoryScanBudget({ retainedBytes: 2 }) - bytes.claimRetainedBytes(2) - expect(() => bytes.claimRetainedBytes(1)).toThrowError( - expect.objectContaining({ resource: 'retainedBytes', limit: 2 }) - ) - }) - - it('charges conservative collection overhead for many tiny values', () => { - const records = new UsageHistoryScanBudget({ records: 100, retainedBytes: 10 * 1024 }) - for (let index = 0; index < 10; index++) { - records.claimRecord(0) - } - expect(() => records.claimRecord(0)).toThrowError( - expect.objectContaining({ resource: 'retainedBytes', limit: 10 * 1024 }) - ) - - const paths = new UsageHistoryScanBudget({ files: 100, retainedBytes: 1_940 }) - for (let index = 0; index < 10; index++) { - paths.claimFile('a') - } - expect(() => paths.claimFile('b')).toThrowError( - expect.objectContaining({ resource: 'retainedBytes', limit: 1_940 }) - ) - }) -}) diff --git a/src/main/usage-history-scan-budget.ts b/src/main/usage-history-scan-budget.ts deleted file mode 100644 index d363da15f9b..00000000000 --- a/src/main/usage-history-scan-budget.ts +++ /dev/null @@ -1,111 +0,0 @@ -export const MAX_USAGE_HISTORY_FILES = 200_000 -export const MAX_USAGE_HISTORY_DISCOVERY_ENTRIES = 1_000_000 -export const MAX_USAGE_HISTORY_RECORDS = 100_000 -export const MAX_USAGE_HISTORY_OWNERSHIP_KEYS = 100_000 -export const MAX_USAGE_HISTORY_RETAINED_BYTES = 256 * 1024 * 1024 - -const FILE_COLLECTION_ENTRY_BYTES = 160 -const OWNERSHIP_COLLECTION_ENTRY_BYTES = 160 -const RECORD_PIPELINE_BYTES = 1024 -const PROJECTION_ENTRY_BYTES = 256 -const STRING_HEADER_BYTES = 32 - -type UsageHistoryScanLimits = { - files: number - discoveryEntries: number - records: number - ownershipKeys: number - retainedBytes: number -} - -const DEFAULT_LIMITS: UsageHistoryScanLimits = { - files: MAX_USAGE_HISTORY_FILES, - discoveryEntries: MAX_USAGE_HISTORY_DISCOVERY_ENTRIES, - records: MAX_USAGE_HISTORY_RECORDS, - ownershipKeys: MAX_USAGE_HISTORY_OWNERSHIP_KEYS, - retainedBytes: MAX_USAGE_HISTORY_RETAINED_BYTES -} - -export class UsageHistoryScanCapacityError extends Error { - constructor( - readonly resource: keyof UsageHistoryScanLimits, - readonly limit: number - ) { - super(`Usage history scan exceeded ${limit} ${resource}`) - this.name = 'UsageHistoryScanCapacityError' - } -} - -export class UsageHistoryScanBudget { - private readonly limits: UsageHistoryScanLimits - private readonly used: UsageHistoryScanLimits = { - files: 0, - discoveryEntries: 0, - records: 0, - ownershipKeys: 0, - retainedBytes: 0 - } - - constructor(limits: Partial = {}) { - this.limits = { ...DEFAULT_LIMITS, ...limits } - } - - claimFile(path: string): void { - this.claim('files', 1) - this.claimPath(path) - } - - claimDiscoveryEntry(): void { - this.claim('discoveryEntries', 1) - } - - claimRecord(retainedBytes: number): void { - this.claim('records', 1) - this.claim('retainedBytes', RECORD_PIPELINE_BYTES + retainedBytes) - } - - claimRecords(count: number): void { - this.claim('records', count) - this.claim('retainedBytes', RECORD_PIPELINE_BYTES * count) - } - - claimRetainedBytes(bytes: number): void { - this.claim('retainedBytes', bytes) - } - - claimPath(path: string): void { - this.claim('retainedBytes', FILE_COLLECTION_ENTRY_BYTES + getUsageHistoryRetainedBytes([path])) - } - - claimProjection(retainedStringBytes: number): void { - this.claim('retainedBytes', PROJECTION_ENTRY_BYTES + retainedStringBytes) - } - - claimOwnershipKey(key: string): void { - this.claim('ownershipKeys', 1) - this.claim( - 'retainedBytes', - OWNERSHIP_COLLECTION_ENTRY_BYTES + getUsageHistoryRetainedBytes([key]) - ) - } - - private claim(resource: keyof UsageHistoryScanLimits, amount: number): void { - if (!Number.isSafeInteger(amount) || amount < 0) { - throw new UsageHistoryScanCapacityError(resource, this.limits[resource]) - } - if (amount > this.limits[resource] - this.used[resource]) { - throw new UsageHistoryScanCapacityError(resource, this.limits[resource]) - } - this.used[resource] += amount - } -} - -export function getUsageHistoryRetainedBytes(values: readonly (string | null)[]): number { - let bytes = 0 - for (const value of values) { - if (value !== null) { - bytes += STRING_HEADER_BYTES + value.length * 2 - } - } - return bytes -} diff --git a/src/main/usage-projection-state-file.test.ts b/src/main/usage-projection-state-file.test.ts deleted file mode 100644 index 9dd28c4a2d4..00000000000 --- a/src/main/usage-projection-state-file.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { - mkdtempSync, - readFileSync, - readdirSync, - rmSync, - truncateSync, - writeFileSync -} from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import { - readUsageProjectionStateFile, - serializeUsageProjectionState, - UsageProjectionStateCapacityError, - writeUsageProjectionStateFile, - writeUsageProjectionStateFileWithRecovery -} from './usage-projection-state-file' - -const tempRoots: string[] = [] - -afterEach(() => { - for (const root of tempRoots.splice(0)) { - rmSync(root, { recursive: true, force: true }) - } -}) - -describe('usage projection state files', () => { - it('accepts exact serialized bytes and rejects the next byte', () => { - expect(serializeUsageProjectionState('x'.repeat(30), 32)).toHaveLength(32) - expect(() => serializeUsageProjectionState('x'.repeat(31), 32)).toThrow( - UsageProjectionStateCapacityError - ) - }) - - it('checks the file size before allocating or decoding it', () => { - const root = createTempRoot() - const path = join(root, 'usage.json') - writeFileSync(path, '{"ok":true}') - expect(readUsageProjectionStateFile(path, 11)).toBe('{"ok":true}') - - truncateSync(path, 12) - expect(() => readUsageProjectionStateFile(path, 11)).toThrow('File too large') - expect(readUsageProjectionStateFile(join(root, 'absent.json'), 11)).toBeNull() - }) - - it('rejects structurally amplified caches before parsing them', () => { - const root = createTempRoot() - const path = join(root, 'usage.json') - const json = '{"rows":[{},{}]}' - writeFileSync(path, json) - - expect(() => - readUsageProjectionStateFile(path, Buffer.byteLength(json), { - structuralTokens: 7, - nestingDepth: 3 - }) - ).toThrow('JSON structure') - }) - - it('writes atomically and preserves ordinary JSON semantics', () => { - const root = createTempRoot() - const path = join(root, 'nested', 'usage.json') - const state = { enabled: true, rows: [{ id: 'one' }, { id: 'two' }] } - - writeUsageProjectionStateFile(path, state, 1024) - - expect(JSON.parse(readFileSync(path, 'utf8'))).toEqual(state) - expect(readdirSync(join(root, 'nested'))).toEqual(['usage.json']) - }) - - it('replaces an oversized rebuildable projection with a bounded error state', () => { - const root = createTempRoot() - const path = join(root, 'usage.json') - const recovered = writeUsageProjectionStateFileWithRecovery<{ - enabled: boolean - rows: string | unknown[] - error?: string - }>( - path, - { enabled: true, rows: 'x'.repeat(200) }, - (error) => ({ enabled: true, rows: [], error: error.message }), - 160 - ) - - expect(recovered).toMatchObject({ enabled: true, rows: [] }) - expect(JSON.parse(readFileSync(path, 'utf8'))).toEqual(recovered) - expect(readdirSync(root)).toEqual(['usage.json']) - }) -}) - -function createTempRoot(): string { - const root = mkdtempSync(join(tmpdir(), 'orca-usage-projection-')) - tempRoots.push(root) - return root -} diff --git a/src/main/usage-projection-state-file.ts b/src/main/usage-projection-state-file.ts deleted file mode 100644 index 499a6debdb1..00000000000 --- a/src/main/usage-projection-state-file.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { mkdirSync, renameSync, unlinkSync, writeFileSync } from 'node:fs' -import { dirname } from 'node:path' -import { - JsonStringifyByteLimitError, - stringifyJsonWithinByteLimit -} from '../shared/node-bounded-json-stringify' -import { readNodeFileSyncWithinLimit } from '../shared/node-bounded-file-reader' -import { - assertJsonTextStructureWithinLimits, - type JsonTextStructureLimits -} from '../shared/json-text-structure-limit' - -export const MAX_USAGE_PROJECTION_STATE_FILE_BYTES = 64 * 1024 * 1024 -export const USAGE_PROJECTION_STATE_JSON_LIMITS: JsonTextStructureLimits = { - structuralTokens: 1_000_000, - nestingDepth: 256 -} - -export class UsageProjectionStateCapacityError extends Error { - constructor(readonly maxBytes = MAX_USAGE_PROJECTION_STATE_FILE_BYTES) { - super(`Usage analytics cache exceeds ${maxBytes} bytes and must be rebuilt.`) - this.name = 'UsageProjectionStateCapacityError' - } -} - -export function readUsageProjectionStateFile( - filePath: string, - maxBytes = MAX_USAGE_PROJECTION_STATE_FILE_BYTES, - structureLimits: JsonTextStructureLimits = USAGE_PROJECTION_STATE_JSON_LIMITS -): string | null { - try { - const serialized = readNodeFileSyncWithinLimit(filePath, maxBytes).buffer.toString('utf8') - assertJsonTextStructureWithinLimits(serialized, structureLimits) - return serialized - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') { - return null - } - throw error - } -} - -export function serializeUsageProjectionState( - state: unknown, - maxBytes = MAX_USAGE_PROJECTION_STATE_FILE_BYTES -): string { - try { - return stringifyJsonWithinByteLimit(state, maxBytes).serialized - } catch (error) { - if (error instanceof JsonStringifyByteLimitError) { - throw new UsageProjectionStateCapacityError(maxBytes) - } - throw error - } -} - -export function writeUsageProjectionStateFile( - filePath: string, - state: unknown, - maxBytes = MAX_USAGE_PROJECTION_STATE_FILE_BYTES -): void { - const payload = serializeUsageProjectionState(state, maxBytes) - mkdirSync(dirname(filePath), { recursive: true }) - const tmpFile = `${filePath}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp` - let renamed = false - try { - writeFileSync(tmpFile, payload, 'utf8') - renameSync(tmpFile, filePath) - renamed = true - } finally { - if (!renamed) { - try { - unlinkSync(tmpFile) - } catch { - // The primary write error is more useful than a best-effort cleanup failure. - } - } - } -} - -export function writeUsageProjectionStateFileWithRecovery( - filePath: string, - state: T, - recover: (error: UsageProjectionStateCapacityError) => T, - maxBytes = MAX_USAGE_PROJECTION_STATE_FILE_BYTES -): T { - try { - writeUsageProjectionStateFile(filePath, state, maxBytes) - return state - } catch (error) { - if (!(error instanceof UsageProjectionStateCapacityError)) { - throw error - } - const recovered = recover(error) - writeUsageProjectionStateFile(filePath, recovered, maxBytes) - return recovered - } -} diff --git a/src/main/warp-themes/discovery.test.ts b/src/main/warp-themes/discovery.test.ts index 911fa40d4cc..977a5b30de7 100644 --- a/src/main/warp-themes/discovery.test.ts +++ b/src/main/warp-themes/discovery.test.ts @@ -7,10 +7,10 @@ type MockDirectoryEntry = { isDirectory: () => boolean } -const opendirSyncMock = vi.hoisted(() => vi.fn()) +const readdirSyncMock = vi.hoisted(() => vi.fn<() => MockDirectoryEntry[]>(() => [])) vi.mock('fs', () => ({ - opendirSync: opendirSyncMock + readdirSync: readdirSyncMock })) vi.mock('os', () => ({ @@ -34,21 +34,13 @@ function fileEntry(name: string): MockDirectoryEntry { } } -function mockDirectory(entries: MockDirectoryEntry[]) { - let index = 0 - const readSync = vi.fn(() => entries[index++] ?? null) - const closeSync = vi.fn() - opendirSyncMock.mockReturnValue({ readSync, closeSync }) - return { readSync, closeSync } -} - describe('getWarpThemeDirectories', () => { beforeEach(() => { vi.unstubAllEnvs() platformMock.mockReset() homedirMock.mockReturnValue('/Users/alice') - opendirSyncMock.mockReset() - mockDirectory([]) + readdirSyncMock.mockReset() + readdirSyncMock.mockReturnValue([]) }) it('returns macOS Warp channel theme directories in stable-first order', () => { @@ -65,12 +57,11 @@ describe('getWarpThemeDirectories', () => { it('adds dynamic macOS .warp directories after known channels', () => { platformMock.mockReturnValue('darwin') - mockDirectory([ - directoryEntry('.warp-z'), + readdirSyncMock.mockReturnValue([ + directoryEntry('.warp-future'), fileEntry('.warp-note'), directoryEntry('.not-warp'), - directoryEntry('.warp-preview'), - directoryEntry('.warp-a') + directoryEntry('.warp-preview') ]) expect(getWarpThemeDirectories()).toEqual([ @@ -80,8 +71,7 @@ describe('getWarpThemeDirectories', () => { '/Users/alice/.warp-dev/themes', '/Users/alice/.warp-local/themes', '/Users/alice/.warp-integration/themes', - '/Users/alice/.warp-a/themes', - '/Users/alice/.warp-z/themes' + '/Users/alice/.warp-future/themes' ]) }) @@ -101,7 +91,7 @@ describe('getWarpThemeDirectories', () => { it('adds dynamic Linux warp data directories', () => { platformMock.mockReturnValue('linux') vi.stubEnv('XDG_DATA_HOME', '/data/alice') - mockDirectory([ + readdirSyncMock.mockReturnValue([ directoryEntry('warp-future'), directoryEntry('warp-terminal'), directoryEntry('not-warp'), @@ -131,8 +121,8 @@ describe('getWarpThemeDirectories', () => { '/Users/alice/.local/share/warp-terminal-local/themes', '/Users/alice/.local/share/warp-terminal-integration/themes' ]) - expect(opendirSyncMock).toHaveBeenCalledWith('/Users/alice/.local/share', { - bufferSize: 32 + expect(readdirSyncMock).toHaveBeenCalledWith('/Users/alice/.local/share', { + withFileTypes: true }) }) @@ -153,7 +143,7 @@ describe('getWarpThemeDirectories', () => { it('adds dynamic Windows Warp app data directories', () => { platformMock.mockReturnValue('win32') vi.stubEnv('APPDATA', 'C:\\Users\\alice\\AppData\\Roaming') - mockDirectory([ + readdirSyncMock.mockReturnValue([ directoryEntry('WarpFuture'), directoryEntry('WarpPreview'), fileEntry('WarpNote') @@ -169,34 +159,6 @@ describe('getWarpThemeDirectories', () => { 'C:\\Users\\alice\\AppData\\Roaming\\warp\\WarpFuture\\data\\themes' ]) }) - - it('caps dynamic discovery while closing the streamed directory', () => { - platformMock.mockReturnValue('darwin') - const { readSync, closeSync } = mockDirectory( - Array.from({ length: 1_025 }, (_, index) => - directoryEntry(`.warp-dynamic-${String(index).padStart(4, '0')}`) - ) - ) - - const directories = getWarpThemeDirectories() - - expect(directories).toHaveLength(1_030) - expect(directories).toContain('/Users/alice/.warp-dynamic-1023/themes') - expect(directories).not.toContain('/Users/alice/.warp-dynamic-1024/themes') - expect(readSync).toHaveBeenCalledTimes(1_024) - expect(closeSync).toHaveBeenCalledOnce() - }) - - it('streams past unrelated entries without consuming the match budget', () => { - platformMock.mockReturnValue('darwin') - const { readSync } = mockDirectory([ - ...Array.from({ length: 1_024 }, (_, index) => fileEntry(`note-${index}`)), - directoryEntry('.warp-future') - ]) - - expect(getWarpThemeDirectories()).toContain('/Users/alice/.warp-future/themes') - expect(readSync).toHaveBeenCalledTimes(1_026) - }) }) describe('warpThemeSourceLabelForDirectory', () => { diff --git a/src/main/warp-themes/discovery.ts b/src/main/warp-themes/discovery.ts index f92699fc61e..6f3f80b51da 100644 --- a/src/main/warp-themes/discovery.ts +++ b/src/main/warp-themes/discovery.ts @@ -1,10 +1,8 @@ -import { opendirSync, type Dir, type Dirent } from 'node:fs' +import { readdirSync } from 'node:fs' +import type { Dirent } from 'node:fs' import { homedir, platform } from 'node:os' import path from 'node:path' -const MAX_DYNAMIC_DISCOVERY_MATCHES = 1_024 -const MAX_DYNAMIC_DISCOVERY_SCANNED_ENTRIES = 100_000 - const WARP_CHANNELS = [ { macName: '.warp', linuxName: 'warp-terminal', windowsName: 'Warp' }, { macName: '.warp-preview', linuxName: 'warp-terminal-preview', windowsName: 'WarpPreview' }, @@ -18,39 +16,13 @@ const WARP_CHANNELS = [ } ] -function readDirectoryEntries( - directoryPath: string, - includeEntry: (entry: Dirent) => boolean -): Dirent[] { - let directory: Dir | undefined +function readDirectoryEntries(directoryPath: string): Dirent[] { try { - directory = opendirSync(directoryPath, { bufferSize: 32 }) - const entries: Dirent[] = [] - let scannedEntries = 0 - while ( - entries.length < MAX_DYNAMIC_DISCOVERY_MATCHES && - scannedEntries < MAX_DYNAMIC_DISCOVERY_SCANNED_ENTRIES - ) { - const entry = directory.readSync() - if (entry === null) { - break - } - scannedEntries += 1 - if (includeEntry(entry)) { - entries.push(entry) - } - } - return entries.sort((left, right) => + return readdirSync(directoryPath, { withFileTypes: true }).sort((left, right) => left.name.localeCompare(right.name, undefined, { sensitivity: 'base' }) ) } catch { return [] - } finally { - try { - directory?.closeSync() - } catch { - // A failed close cannot make already bounded discovery unsafe. - } } } @@ -85,10 +57,9 @@ function getMacWarpThemeDirectories(home: string): string[] { return warpThemeDirectoriesFromDataHomes( [ ...WARP_CHANNELS.map((channel) => pathImpl.join(home, channel.macName)), - ...readDirectoryEntries( - home, - (entry) => entry.isDirectory() && entry.name.startsWith('.warp') - ).map((entry) => pathImpl.join(home, entry.name)) + ...readDirectoryEntries(home) + .filter((entry) => entry.isDirectory() && entry.name.startsWith('.warp')) + .map((entry) => pathImpl.join(home, entry.name)) ], pathImpl ) @@ -106,11 +77,13 @@ function getLinuxWarpThemeDirectories(home: string): string[] { return warpThemeDirectoriesFromDataHomes( [ ...WARP_CHANNELS.map((channel) => pathImpl.join(dataHome, channel.linuxName)), - ...readDirectoryEntries( - dataHome, - (entry) => - entry.isDirectory() && (entry.name === 'warp-terminal' || entry.name.startsWith('warp-')) - ).map((entry) => pathImpl.join(dataHome, entry.name)) + ...readDirectoryEntries(dataHome) + .filter( + (entry) => + entry.isDirectory() && + (entry.name === 'warp-terminal' || entry.name.startsWith('warp-')) + ) + .map((entry) => pathImpl.join(dataHome, entry.name)) ], pathImpl ) @@ -129,7 +102,10 @@ function getWindowsWarpThemeDirectories(home: string): string[] { path.win32 ) } - for (const entry of readDirectoryEntries(warpAppData, (candidate) => candidate.isDirectory())) { + for (const entry of readDirectoryEntries(warpAppData)) { + if (!entry.isDirectory()) { + continue + } addDedupeDirectory( directories, seenDirectories, diff --git a/src/main/warp-themes/index.test.ts b/src/main/warp-themes/index.test.ts index a24e5212300..f70154b4079 100644 --- a/src/main/warp-themes/index.test.ts +++ b/src/main/warp-themes/index.test.ts @@ -1,10 +1,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import path from 'node:path' -import type * as BoundedFileReader from '../../shared/node-bounded-file-reader' import type * as WarpThemeDiscovery from './discovery' const opendirMock = vi.hoisted(() => vi.fn()) -const readNodeFileWithinLimitMock = vi.hoisted(() => vi.fn()) +const readFileMock = vi.hoisted(() => vi.fn()) const realpathMock = vi.hoisted(() => vi.fn((filePath: string) => Promise.resolve(filePath))) const statMock = vi.hoisted(() => vi.fn()) const getWarpThemeDirectoriesMock = vi.hoisted(() => vi.fn(() => ['/Users/alice/.warp/themes'])) @@ -18,15 +17,11 @@ vi.mock('electron', () => ({ vi.mock('fs/promises', () => ({ opendir: opendirMock, + readFile: readFileMock, realpath: realpathMock, stat: statMock })) -vi.mock('../../shared/node-bounded-file-reader', async (importOriginal) => ({ - ...(await importOriginal()), - readNodeFileWithinLimit: readNodeFileWithinLimitMock -})) - vi.mock('./discovery', async (importOriginal) => { const actual = await importOriginal() return { @@ -41,7 +36,6 @@ vi.mock('./parser-runner', () => ({ import { previewWarpThemeImport } from './index' import { parseWarpThemeYaml } from './parser' -import { NodeFileReadTooLargeError } from '../../shared/node-bounded-file-reader' import type { Store } from '../persistence' const VALID_THEME = ` @@ -103,19 +97,12 @@ function mockStat(filePath: string) { : { isFile: () => true, size: VALID_THEME.length } } -function fileRead(content: string, size = Buffer.byteLength(content)) { - return { - buffer: Buffer.from(content), - stats: { isFile: () => true, size } - } -} - describe('previewWarpThemeImport', () => { beforeEach(() => { vi.clearAllMocks() getWarpThemeDirectoriesMock.mockReturnValue(['/Users/alice/.warp/themes']) statMock.mockImplementation(mockStat) - readNodeFileWithinLimitMock.mockResolvedValue(fileRead(VALID_THEME)) + readFileMock.mockResolvedValue(VALID_THEME) realpathMock.mockImplementation((filePath: string) => Promise.resolve(filePath)) opendirMock.mockResolvedValue(mockDirectory([fileEntry('z.yml'), fileEntry('a.yml')])) parseWarpThemeYamlWithTimeoutMock.mockImplementation(parseWarpThemeYaml) @@ -128,9 +115,10 @@ describe('previewWarpThemeImport', () => { 'warp:duplicate:a-yml', 'warp:duplicate:z-yml' ]) - expect( - readNodeFileWithinLimitMock.mock.calls.map(([filePath]) => path.basename(filePath as string)) - ).toEqual(['a.yml', 'z.yml']) + expect(readFileMock.mock.calls.map(([filePath]) => path.basename(filePath as string))).toEqual([ + 'a.yml', + 'z.yml' + ]) }) it('returns an empty errorless preview when no local Warp theme folder exists', async () => { @@ -147,7 +135,7 @@ describe('previewWarpThemeImport', () => { skippedFiles: [] }) expect(preview.error).toBeUndefined() - expect(readNodeFileWithinLimitMock).not.toHaveBeenCalled() + expect(readFileMock).not.toHaveBeenCalled() }) it('returns an empty errorless preview for an empty readable local Warp theme folder', async () => { @@ -162,7 +150,7 @@ describe('previewWarpThemeImport', () => { skippedFiles: [] }) expect(preview.error).toBeUndefined() - expect(readNodeFileWithinLimitMock).not.toHaveBeenCalled() + expect(readFileMock).not.toHaveBeenCalled() }) it('merges themes from multiple readable Warp directories', async () => { @@ -182,7 +170,7 @@ describe('previewWarpThemeImport', () => { const preview = await previewWarpThemeImport({} as Store, { kind: 'auto' }) - expect(readNodeFileWithinLimitMock.mock.calls.map(([filePath]) => filePath)).toEqual([ + expect(readFileMock.mock.calls.map(([filePath]) => filePath)).toEqual([ path.join('/Users/alice/.warp/themes', 'stable.yaml'), path.join('/Users/alice/.warp-preview/themes', 'preview.yaml') ]) @@ -207,7 +195,7 @@ describe('previewWarpThemeImport', () => { const preview = await previewWarpThemeImport({} as Store, { kind: 'auto' }) expect(preview.found).toBe(true) - expect(readNodeFileWithinLimitMock.mock.calls.map(([filePath]) => filePath)).toEqual([ + expect(readFileMock.mock.calls.map(([filePath]) => filePath)).toEqual([ path.join('/Users/alice/.warp-oss/themes', 'oss.yaml') ]) expect(preview.themes.map((theme) => theme.sourceLabel)).toEqual(['.warp-oss']) @@ -232,9 +220,9 @@ describe('previewWarpThemeImport', () => { const preview = await previewWarpThemeImport({} as Store, { kind: 'auto' }) expect(preview.themes).toHaveLength(1) - expect(readNodeFileWithinLimitMock).toHaveBeenCalledWith( + expect(readFileMock).toHaveBeenCalledWith( path.join('/Users/alice/.warp/themes', 'shared.yaml'), - 1_000_000 + 'utf-8' ) expect(preview.themes[0]?.sourceLabel).toBe('.warp') expect(preview.skippedFiles).not.toContainEqual({ @@ -249,9 +237,9 @@ describe('previewWarpThemeImport', () => { const preview = await previewWarpThemeImport({} as Store, { kind: 'auto' }) expect(preview.found).toBe(true) - expect(readNodeFileWithinLimitMock).toHaveBeenCalledWith( + expect(readFileMock).toHaveBeenCalledWith( path.join('/Users/alice/.warp/themes', 'linked.yaml'), - 1_000_000 + 'utf-8' ) }) @@ -266,7 +254,7 @@ describe('previewWarpThemeImport', () => { const preview = await previewWarpThemeImport({} as Store, { kind: 'auto' }) expect(preview.themes).toHaveLength(1) - expect(readNodeFileWithinLimitMock).toHaveBeenCalledTimes(1) + expect(readFileMock).toHaveBeenCalledTimes(1) }) it('applies the theme file cap globally across merged auto-discovery directories', async () => { @@ -295,7 +283,7 @@ describe('previewWarpThemeImport', () => { const preview = await previewWarpThemeImport({} as Store, { kind: 'auto' }) expect(preview.themes).toHaveLength(200) - expect(readNodeFileWithinLimitMock).toHaveBeenCalledTimes(200) + expect(readFileMock).toHaveBeenCalledTimes(200) expect(preview.skippedFiles).toContainEqual({ label: 'Warp themes', reason: 'Only the first 200 theme files were scanned.' @@ -324,9 +312,9 @@ describe('previewWarpThemeImport', () => { const preview = await previewWarpThemeImport({} as Store, { kind: 'auto' }) expect(preview.themes).toHaveLength(200) - expect(readNodeFileWithinLimitMock).not.toHaveBeenCalledWith( + expect(readFileMock).not.toHaveBeenCalledWith( path.join('/Users/alice/.warp-preview/themes', 'preview.yaml'), - 1_000_000 + 'utf-8' ) expect(preview.skippedFiles).toContainEqual({ label: 'Warp themes', @@ -370,10 +358,7 @@ describe('previewWarpThemeImport', () => { const preview = await previewWarpThemeImport({} as Store, { kind: 'auto' }) expect(preview.themes).toHaveLength(200) - expect(readNodeFileWithinLimitMock).toHaveBeenCalledWith( - path.join(previewDirectory, 'unique.yaml'), - 1_000_000 - ) + expect(readFileMock).toHaveBeenCalledWith(path.join(previewDirectory, 'unique.yaml'), 'utf-8') }) it('reports bounded skips when local Warp folders are unreadable', async () => { @@ -481,12 +466,10 @@ describe('previewWarpThemeImport', () => { it('keeps same-basename manual file ids stable independent of dialog order', async () => { const firstPath = path.join('/Users/alice/light', 'duplicate.yaml') const secondPath = path.join('/Users/alice/dark', 'duplicate.yaml') - readNodeFileWithinLimitMock.mockImplementation((filePath: string) => - fileRead( - filePath === firstPath - ? VALID_THEME.replace("background: '#111111'", "background: '#222222'") - : VALID_THEME.replace("background: '#111111'", "background: '#333333'") - ) + readFileMock.mockImplementation((filePath: string) => + filePath === firstPath + ? VALID_THEME.replace("background: '#111111'", "background: '#222222'") + : VALID_THEME.replace("background: '#111111'", "background: '#333333'") ) showOpenDialogMock.mockResolvedValueOnce({ canceled: false, @@ -577,7 +560,7 @@ describe('previewWarpThemeImport', () => { const preview = await previewWarpThemeImport({} as Store, { kind: 'auto' }) expect(preview.found).toBe(true) - expect(readNodeFileWithinLimitMock.mock.calls.map(([filePath]) => filePath)).toEqual([ + expect(readFileMock.mock.calls.map(([filePath]) => filePath)).toEqual([ path.join('/Users/alice/.warp/themes', 'standard', 'tokyo-night.yaml'), path.join('/Users/alice/.warp/themes', 'warp_bundled', 'dracula.yml') ]) @@ -728,8 +711,8 @@ describe('previewWarpThemeImport', () => { const preview = await previewWarpThemeImport({} as Store, { kind: 'chooseFile' }) expect(preview.themes).toHaveLength(200) - expect(readNodeFileWithinLimitMock.mock.calls[0]?.[0]).toBe(themePaths[0]) - expect(readNodeFileWithinLimitMock.mock.calls.at(-1)?.[0]).toBe(themePaths[199]) + expect(readFileMock.mock.calls[0]?.[0]).toBe(themePaths[0]) + expect(readFileMock.mock.calls.at(-1)?.[0]).toBe(themePaths[199]) expect(preview.skippedFiles).toContainEqual({ label: 'Selected Warp themes', reason: 'Only the first 200 theme files were scanned.' @@ -796,41 +779,4 @@ describe('previewWarpThemeImport', () => { expect(preview.skippedFiles).toEqual([{ label: 'private.yml', reason: 'Could not read file.' }]) }) - - it('accepts a theme at the exact byte limit', async () => { - const themePath = path.join('/Users/alice/.warp/themes', 'boundary.yml') - opendirMock.mockResolvedValue(mockDirectory([fileEntry('boundary.yml')])) - statMock.mockImplementation((filePath: string) => - filePath === themePath ? { isFile: () => true, size: 1_000_000 } : mockStat(filePath) - ) - readNodeFileWithinLimitMock.mockResolvedValue(fileRead(VALID_THEME, 1_000_000)) - - const preview = await previewWarpThemeImport({} as Store, { kind: 'auto' }) - - expect(preview.found).toBe(true) - expect(readNodeFileWithinLimitMock).toHaveBeenCalledWith(themePath, 1_000_000) - expect(parseWarpThemeYamlWithTimeoutMock).toHaveBeenCalledOnce() - }) - - it('rejects theme growth beyond the limit before parsing', async () => { - const themePath = path.join('/Users/alice/.warp/themes', 'growing.yml') - opendirMock.mockResolvedValue(mockDirectory([fileEntry('growing.yml')])) - statMock.mockImplementation((filePath: string) => - filePath === themePath ? { isFile: () => true, size: 128 } : mockStat(filePath) - ) - readNodeFileWithinLimitMock.mockRejectedValue( - new NodeFileReadTooLargeError(1_000_001, 1_000_000) - ) - - const preview = await previewWarpThemeImport({} as Store, { kind: 'auto' }) - - expect(preview.found).toBe(false) - expect(preview.skippedFiles).toEqual([ - { - label: 'growing.yml', - reason: 'File is too large to import (1000001 bytes, limit 1000000).' - } - ]) - expect(parseWarpThemeYamlWithTimeoutMock).not.toHaveBeenCalled() - }) }) diff --git a/src/main/warp-themes/index.ts b/src/main/warp-themes/index.ts index f99148f6569..d192d322773 100644 --- a/src/main/warp-themes/index.ts +++ b/src/main/warp-themes/index.ts @@ -1,10 +1,6 @@ -import { stat } from 'node:fs/promises' +import { readFile, stat } from 'node:fs/promises' import type { WebContents } from 'electron' import type { Store } from '../persistence' -import { - NodeFileReadTooLargeError, - readNodeFileWithinLimit -} from '../../shared/node-bounded-file-reader' import type { WarpThemeImportPreview, WarpThemeImportSource @@ -113,20 +109,8 @@ export async function previewWarpThemeImport( }) continue } - const result = await readNodeFileWithinLimit(file.path, MAX_THEME_FILE_BYTES) - if (!result.stats.isFile()) { - skippedFiles.push({ label: file.label, reason: 'Not a file.' }) - continue - } - content = result.buffer.toString('utf8') - } catch (error) { - if (error instanceof NodeFileReadTooLargeError) { - skippedFiles.push({ - label: file.label, - reason: `File is too large to import (${error.observedBytes} bytes, limit ${MAX_THEME_FILE_BYTES}).` - }) - continue - } + content = await readFile(file.path, 'utf-8') + } catch { skippedFiles.push({ label: file.label, reason: sanitizeReadError('Could not read file.') diff --git a/src/main/window/attach-main-window-services.test.ts b/src/main/window/attach-main-window-services.test.ts index 31b004c2bfc..1ae323607c6 100644 --- a/src/main/window/attach-main-window-services.test.ts +++ b/src/main/window/attach-main-window-services.test.ts @@ -202,15 +202,17 @@ describe('attachMainWindowServices', () => { it('reloads the app renderer through main and marks expected renderer teardown', async () => { const onBeforeRendererReload = vi.fn() const mainWindow = createMainWindow() - const store = createStore() - const runtime = createRuntime() - attachMainWindowServices(mainWindow as never, store, runtime as never, undefined, undefined, { - onBeforeRendererReload - }) + attachMainWindowServices( + mainWindow as never, + createStore(), + createRuntime() as never, + undefined, + undefined, + { onBeforeRendererReload } + ) expect(removeHandlerMock).toHaveBeenCalledWith('app:reload') - expect(registerRepoHandlersMock).toHaveBeenCalledWith(mainWindow, store, runtime) const reloadHandler = handleMock.mock.calls.find(([channel]) => channel === 'app:reload')?.[1] expect(reloadHandler).toBeTypeOf('function') diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index cc9ea9f8280..0a481b209bc 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -87,7 +87,7 @@ export function attachMainWindowServices( } ): void { registerAppReloadHandler(mainWindow, options?.onBeforeRendererReload) - registerRepoHandlers(mainWindow, store, runtime) + registerRepoHandlers(mainWindow, store) registerWorktreeHandlers(mainWindow, store, runtime) // Why: repo/settings mutations resync watchers through this attached main-window context. setWorktreeBaseDirectoryWatcherSyncContext(store, mainWindow) diff --git a/src/main/window/clipboard-ipc-handlers.test.ts b/src/main/window/clipboard-ipc-handlers.test.ts index 63d089584d0..b1465beb82d 100644 --- a/src/main/window/clipboard-ipc-handlers.test.ts +++ b/src/main/window/clipboard-ipc-handlers.test.ts @@ -13,7 +13,6 @@ const { childStdinEndMock, resolveAuthorizedPathMock, fsMkdirMock, - fsOpendirMock, fsReaddirMock, fsRmMock, fsWriteFileMock, @@ -47,7 +46,6 @@ const { }), resolveAuthorizedPathMock: vi.fn(), fsMkdirMock: vi.fn(), - fsOpendirMock: vi.fn(), fsReaddirMock: vi.fn(), fsRmMock: vi.fn(), fsWriteFileMock: vi.fn(), @@ -71,7 +69,7 @@ vi.mock('node:child_process', () => ({ vi.mock('node:fs/promises', () => ({ mkdir: fsMkdirMock, - opendir: fsOpendirMock, + readdir: fsReaddirMock, rm: fsRmMock, open: fsOpenMock, stat: fsStatMock, @@ -196,13 +194,6 @@ describe('registerClipboardHandlers', () => { resolveAuthorizedPathMock.mockImplementation(async (path: string) => path) fsMkdirMock.mockReset() fsMkdirMock.mockResolvedValue(undefined) - fsOpendirMock.mockReset() - fsOpendirMock.mockImplementation(async () => ({ - async *[Symbol.asyncIterator]() { - yield* await fsReaddirMock() - }, - close: vi.fn().mockResolvedValue(undefined) - })) fsReaddirMock.mockReset() fsReaddirMock.mockResolvedValue([]) fsRmMock.mockReset() diff --git a/src/main/window/clipboard-remote-file-cleanup-memory.test.ts b/src/main/window/clipboard-remote-file-cleanup-memory.test.ts deleted file mode 100644 index 4127b4006b6..00000000000 --- a/src/main/window/clipboard-remote-file-cleanup-memory.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { opendirMock, rmMock, statMock } = vi.hoisted(() => ({ - opendirMock: vi.fn(), - rmMock: vi.fn(), - statMock: vi.fn() -})) - -vi.mock('node:fs/promises', () => ({ - mkdir: vi.fn(), - opendir: opendirMock, - rm: rmMock, - stat: statMock -})) -vi.mock('electron', () => ({ app: { getPath: () => '/tmp' } })) -vi.mock('../providers/ssh-filesystem-dispatch', () => ({ - requireSshFilesystemProvider: vi.fn() -})) -vi.mock('./clipboard-file-copy', () => ({ writeFileToClipboard: vi.fn() })) - -import { cleanupExpiredRemoteClipboardFiles } from './clipboard-remote-file-copy' - -describe('remote clipboard cleanup memory bounds', () => { - beforeEach(() => { - vi.clearAllMocks() - rmMock.mockResolvedValue(undefined) - }) - - it('streams all entries with at most eight cleanups in flight', async () => { - const nowMs = 1_760_000_000_000 - const entries = Array.from({ length: 257 }, (_, index) => ({ - name: `orca-clipboard-file-expired-${index}`, - isDirectory: () => true - })) - opendirMock.mockResolvedValue({ - async *[Symbol.asyncIterator]() { - yield* entries - }, - close: vi.fn().mockResolvedValue(undefined) - }) - let active = 0 - let peak = 0 - statMock.mockImplementation(async () => { - active += 1 - peak = Math.max(peak, active) - await new Promise((resolve) => setImmediate(resolve)) - active -= 1 - return { mtimeMs: nowMs - 60 * 60 * 1000 - 1 } - }) - - await cleanupExpiredRemoteClipboardFiles(nowMs) - - expect(rmMock).toHaveBeenCalledTimes(entries.length) - expect(peak).toBe(8) - }) -}) diff --git a/src/main/window/clipboard-remote-file-copy.ts b/src/main/window/clipboard-remote-file-copy.ts index fdb63eb11a0..052fce31ef3 100644 --- a/src/main/window/clipboard-remote-file-copy.ts +++ b/src/main/window/clipboard-remote-file-copy.ts @@ -1,5 +1,6 @@ import { randomUUID } from 'node:crypto' -import { mkdir, opendir, rm, stat } from 'node:fs/promises' +import type { Dirent } from 'node:fs' +import { mkdir, readdir, rm, stat } from 'node:fs/promises' import { join } from 'node:path' import { app } from 'electron' @@ -16,7 +17,6 @@ type RemoteClipboardFileDeps = Omit const REMOTE_CLIPBOARD_FILE_TTL_MS = 60 * 60 * 1000 const REMOTE_CLIPBOARD_FILE_PREFIX = 'orca-clipboard-file-' -const REMOTE_CLIPBOARD_CLEANUP_CONCURRENCY = 8 const WINDOWS_RESERVED_LOCAL_BASENAME = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i const LOCAL_FILENAME_REPLACEMENT_CHARS = new Set(['<', '>', ':', '"', '/', '\\', '|', '?', '*']) @@ -81,44 +81,30 @@ export async function writeRemoteFileToClipboard({ export async function cleanupExpiredRemoteClipboardFiles(nowMs = Date.now()): Promise { const tempRoot = app.getPath('temp') - let directory: Awaited> + let entries: Dirent[] try { - directory = await opendir(tempRoot) + entries = await readdir(tempRoot, { withFileTypes: true }) } catch { return } - const pending = new Set>() - try { - for await (const entry of directory) { + await Promise.all( + entries.map(async (entry) => { if (!entry.isDirectory() || !entry.name.startsWith(REMOTE_CLIPBOARD_FILE_PREFIX)) { - continue + return } const tempDir = join(tempRoot, entry.name) - const cleanup = cleanupRemoteClipboardDirectory(tempDir, nowMs) - pending.add(cleanup) - void cleanup.finally(() => pending.delete(cleanup)) - if (pending.size >= REMOTE_CLIPBOARD_CLEANUP_CONCURRENCY) { - await Promise.race(pending) + try { + const tempStats = await stat(tempDir) + if (nowMs - tempStats.mtimeMs < REMOTE_CLIPBOARD_FILE_TTL_MS) { + return + } + await rm(tempDir, { recursive: true, force: true }) + } catch { + // Why: stale staged SSH files should not make startup cleanup noisy. } - } - } catch { - // A partial best-effort startup sweep is sufficient. - } finally { - await directory.close().catch(() => undefined) - } - await Promise.all(pending) -} - -async function cleanupRemoteClipboardDirectory(tempDir: string, nowMs: number): Promise { - try { - const tempStats = await stat(tempDir) - if (nowMs - tempStats.mtimeMs >= REMOTE_CLIPBOARD_FILE_TTL_MS) { - await rm(tempDir, { recursive: true, force: true }) - } - } catch { - // Why: stale staged SSH files should not make startup cleanup noisy. - } + }) + ) } function sanitizeLocalClipboardFilename(remoteBasename: string): string { diff --git a/src/main/workspace-space-analysis-capacity.test.ts b/src/main/workspace-space-analysis-capacity.test.ts deleted file mode 100644 index 2f6b9b1d8cd..00000000000 --- a/src/main/workspace-space-analysis-capacity.test.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type * as NodeProcess from 'node:process' -import type { Repo } from '../shared/types' -import { WORKSPACE_SPACE_MAX_SCANNED_ENTRIES } from '../shared/workspace-space-scan-budget' -import type { Store } from './persistence' - -const { lstatMock, opendirMock, listRepoWorktreesMock } = vi.hoisted(() => ({ - lstatMock: vi.fn(), - opendirMock: vi.fn(), - listRepoWorktreesMock: vi.fn() -})) - -vi.mock('node:fs/promises', () => ({ - lstat: lstatMock, - opendir: opendirMock -})) - -vi.mock('node:process', async () => { - const actual = await vi.importActual('node:process') - return { ...actual, platform: 'win32' } -}) - -vi.mock('./repo-worktrees', () => ({ - createFolderWorktree: (repo: Repo) => ({ - path: repo.path, - head: '', - branch: '', - isBare: false, - isMainWorktree: true - }), - listRepoWorktrees: listRepoWorktreesMock -})) - -vi.mock('./providers/ssh-filesystem-dispatch', () => ({ - getSshFilesystemProvider: vi.fn() -})) - -vi.mock('./providers/ssh-git-dispatch', () => ({ - getSshGitProvider: vi.fn() -})) - -import { analyzeWorkspaceSpace } from './workspace-space-analysis' - -function createStore(repo: Repo): Store { - return { - getRepos: () => [repo], - getWorktreeMeta: () => undefined - } as unknown as Store -} - -describe('analyzeWorkspaceSpace portable scan capacity', () => { - beforeEach(() => { - lstatMock.mockReset() - opendirMock.mockReset() - listRepoWorktreesMock.mockReset() - }) - - it('surfaces an over-cap Windows directory as an unavailable row', async () => { - const repo: Repo = { - id: 'repo-1', - path: 'C:\\repo', - displayName: 'orca', - badgeColor: '#000', - addedAt: 0 - } - listRepoWorktreesMock.mockResolvedValue([ - { - path: repo.path, - head: 'a', - branch: 'refs/heads/main', - isBare: false, - isMainWorktree: true - } - ]) - lstatMock.mockResolvedValue({ - size: 1, - isDirectory: () => true, - isSymbolicLink: () => false - }) - opendirMock.mockResolvedValue({ - async *[Symbol.asyncIterator]() { - const entry = { name: 'repeated-entry' } - for (let index = 0; index <= WORKSPACE_SPACE_MAX_SCANNED_ENTRIES; index += 1) { - yield entry - } - } - }) - - const result = await analyzeWorkspaceSpace(createStore(repo)) - - expect(result).toMatchObject({ - scannedWorktreeCount: 0, - unavailableWorktreeCount: 1, - worktrees: [ - { - status: 'error', - error: expect.stringContaining('too large to scan safely'), - sizeBytes: 0 - } - ] - }) - expect(lstatMock).toHaveBeenCalledTimes(1) - }) -}) diff --git a/src/main/workspace-space-analysis.ts b/src/main/workspace-space-analysis.ts index c8307b8c08d..cc792202fb8 100644 --- a/src/main/workspace-space-analysis.ts +++ b/src/main/workspace-space-analysis.ts @@ -1,33 +1,24 @@ /* eslint-disable max-lines -- Why: this module keeps local and SSH directory-walk semantics paired so reclaimable-byte, symlink, and partial-failure behavior cannot drift. */ -import { lstat, opendir } from 'node:fs/promises' +import { lstat, readdir } from 'node:fs/promises' import { execFile } from 'node:child_process' import { posix, win32 } from 'node:path' import { platform } from 'node:process' import type { Dirent } from 'node:fs' import type { Store } from './persistence' import { isFolderRepo } from '../shared/repo-kind' -import type { DirEntry, GitWorktreeInfo, Repo, Worktree } from '../shared/types' +import type { GitWorktreeInfo, Repo, Worktree } from '../shared/types' import type { WorkspaceSpaceAnalysis, WorkspaceSpaceDirectoryScanResult, WorkspaceSpaceItem, + WorkspaceSpaceItemKind, WorkspaceSpaceRepoSummary, WorkspaceSpaceScanProgress, WorkspaceSpaceScanStatus, WorkspaceSpaceWorktree } from '../shared/workspace-space-types' import { compactWorkspaceSpaceItems } from '../shared/workspace-space-compaction' -import { mapWithConcurrency } from '../shared/map-with-concurrency' -import { - scanWorkspaceSpaceEntryTree, - type WorkspaceSpaceEntryScan -} from '../shared/workspace-space-entry-traversal' -import { - collectWorkspaceSpaceDirectoryEntries, - createWorkspaceSpaceScanBudget, - WorkspaceSpaceScanCapacityError -} from '../shared/workspace-space-scan-budget' import type { IFilesystemProvider } from './providers/types' import { getSshFilesystemProvider } from './providers/ssh-filesystem-dispatch' import { getSshGitProvider } from './providers/ssh-git-dispatch' @@ -40,7 +31,16 @@ const REMOTE_FS_CONCURRENCY = 10 const DU_TIMEOUT_MS = 120_000 const DU_MAX_BUFFER_BYTES = 16 * 1024 * 1024 -type ScanStats = WorkspaceSpaceEntryScan +type AsyncLimiter = (task: () => Promise) => Promise + +type ScanStats = { + name: string + path: string + kind: WorkspaceSpaceItemKind + sizeBytes: number + skippedEntryCount: number + children?: ScanStats[] +} type WorktreeListResult = | { ok: true; worktrees: GitWorktreeInfo[] } @@ -86,6 +86,67 @@ function isRelayMethodNotFoundError(error: unknown): boolean { return (error as { code?: unknown }).code === -32601 } +function createAsyncLimiter(maxConcurrent: number, signal?: AbortSignal): AsyncLimiter { + let active = 0 + const queue: { resolve: () => void; reject: (error: Error) => void }[] = [] + + const acquire = async (): Promise => { + throwIfAborted(signal) + if (active < maxConcurrent) { + active += 1 + return + } + await new Promise((resolve, reject) => { + let onAbort: (() => void) | null = null + const waiter = { + resolve: () => { + if (onAbort) { + signal?.removeEventListener('abort', onAbort) + } + resolve() + }, + reject + } + onAbort = () => { + const index = queue.indexOf(waiter) + if (index !== -1) { + queue.splice(index, 1) + } + reject(new WorkspaceSpaceScanCancelledError()) + } + queue.push(waiter) + if (signal) { + signal.addEventListener('abort', onAbort, { once: true }) + if (signal.aborted) { + onAbort() + } + } + }) + throwIfAborted(signal) + active += 1 + } + + return async (task: () => Promise): Promise => { + await acquire() + try { + return await task() + } finally { + active -= 1 + const next = queue.shift() + next?.resolve() + } + } +} + +async function mapLimit( + items: readonly T[], + maxConcurrent: number, + mapper: (item: T) => Promise +): Promise { + const limit = createAsyncLimiter(maxConcurrent) + return Promise.all(items.map((item) => limit(() => mapper(item)))) +} + function looksLikeWindowsPath(pathValue: string): boolean { return /^[A-Za-z]:[\\/]/.test(pathValue) || pathValue.startsWith('\\\\') } @@ -294,73 +355,195 @@ function createScannedWorktreeRow( async function scanLocalEntry( entryPath: string, name: string, + limit: AsyncLimiter, signal?: AbortSignal ): Promise { - return scanWorkspaceSpaceEntryTree({ - rootPath: entryPath, - rootName: name, - concurrency: LOCAL_FS_CONCURRENCY, - signal, - entryName: (entry) => entry.name, - joinPath: joinFilesystemPath, - classifyEntry: async (path) => { - const stats = await lstat(path) - throwIfAborted(signal) - if (stats.isSymbolicLink()) { - return { kind: 'symlink', sizeBytes: stats.size } + throwIfAborted(signal) + const stats = await limit(() => lstat(entryPath)) + throwIfAborted(signal) + + if (stats.isSymbolicLink()) { + return { + name, + path: entryPath, + kind: 'symlink', + // Why: symlink targets may be shared outside the worktree. Counting the + // link itself reflects what deleting this worktree can actually reclaim. + sizeBytes: stats.size, + skippedEntryCount: 0 + } + } + + if (!stats.isDirectory()) { + return { + name, + path: entryPath, + kind: 'file', + sizeBytes: stats.size, + skippedEntryCount: 0 + } + } + + let entries: Dirent[] + try { + entries = await limit(() => readdir(entryPath, { withFileTypes: true })) + } catch { + return { + name, + path: entryPath, + kind: 'directory', + sizeBytes: stats.size, + skippedEntryCount: 1 + } + } + + const childStats = await Promise.all( + entries.map(async (entry): Promise => { + try { + return await scanLocalEntry( + joinFilesystemPath(entryPath, entry.name), + entry.name, + limit, + signal + ) + } catch (error) { + if (error instanceof WorkspaceSpaceScanCancelledError) { + throw error + } + return null } - return stats.isDirectory() - ? { kind: 'directory', sizeBytes: stats.size } - : { kind: 'file', sizeBytes: stats.size } - }, - readDirectory: (path) => opendir(path), - checkCancelled: () => throwIfAborted(signal), - createCancellationError: () => new WorkspaceSpaceScanCancelledError(), - isCancellationError: (error) => error instanceof WorkspaceSpaceScanCancelledError - }) + }) + ) + + let sizeBytes = stats.size + let skippedEntryCount = 0 + for (const child of childStats) { + if (!child) { + skippedEntryCount += 1 + continue + } + sizeBytes += child.sizeBytes + skippedEntryCount += child.skippedEntryCount + } + + return { + name, + path: entryPath, + kind: 'directory', + sizeBytes, + skippedEntryCount, + children: childStats.filter((child): child is ScanStats => child !== null) + } } async function scanRemoteEntry( entryPath: string, name: string, provider: IFilesystemProvider, - signal?: AbortSignal + limit: AsyncLimiter, + signal?: AbortSignal, + knownSymlink = false ): Promise { - return scanWorkspaceSpaceEntryTree({ - rootPath: entryPath, - rootName: name, - concurrency: REMOTE_FS_CONCURRENCY, - signal, - entryName: (entry) => entry.name, - joinPath: joinFilesystemPath, - classifyEntry: async (path, sourceEntry) => { - if (sourceEntry?.isSymlink) { - return { kind: 'symlink', sizeBytes: 0 } + throwIfAborted(signal) + if (knownSymlink) { + return { + name, + path: entryPath, + kind: 'symlink', + sizeBytes: 0, + skippedEntryCount: 0 + } + } + + const stats = await limit(() => provider.stat(entryPath)) + throwIfAborted(signal) + if (stats.type === 'symlink') { + return { + name, + path: entryPath, + kind: 'symlink', + sizeBytes: stats.size, + skippedEntryCount: 0 + } + } + + if (stats.type !== 'directory') { + return { + name, + path: entryPath, + kind: 'file', + sizeBytes: stats.size, + skippedEntryCount: 0 + } + } + + let entries + try { + entries = await limit(() => provider.readDir(entryPath)) + throwIfAborted(signal) + } catch (error) { + if (error instanceof WorkspaceSpaceScanCancelledError) { + throw error + } + return { + name, + path: entryPath, + kind: 'directory', + sizeBytes: stats.size, + skippedEntryCount: 1 + } + } + + const childStats = await Promise.all( + entries.map(async (entry): Promise => { + try { + return await scanRemoteEntry( + joinFilesystemPath(entryPath, entry.name), + entry.name, + provider, + limit, + signal, + entry.isSymlink + ) + } catch (error) { + if (error instanceof WorkspaceSpaceScanCancelledError) { + throw error + } + return null } - const stats = await provider.stat(path) - throwIfAborted(signal) - if (stats.type === 'symlink') { - return { kind: 'symlink', sizeBytes: stats.size } - } - return stats.type === 'directory' - ? { kind: 'directory', sizeBytes: stats.size } - : { kind: 'file', sizeBytes: stats.size } - }, - readDirectory: (path) => provider.readDir(path), - checkCancelled: () => throwIfAborted(signal), - createCancellationError: () => new WorkspaceSpaceScanCancelledError(), - isCancellationError: (error) => error instanceof WorkspaceSpaceScanCancelledError - }) + }) + ) + + let sizeBytes = stats.size + let skippedEntryCount = 0 + for (const child of childStats) { + if (!child) { + skippedEntryCount += 1 + continue + } + sizeBytes += child.sizeBytes + skippedEntryCount += child.skippedEntryCount + } + + return { + name, + path: entryPath, + kind: 'directory', + sizeBytes, + skippedEntryCount, + children: childStats.filter((child): child is ScanStats => child !== null) + } } async function scanLocalTopLevelEntry( entryPath: string, name: string, duSizes: Map, + limit: AsyncLimiter, signal?: AbortSignal ): Promise { throwIfAborted(signal) - const stats = await lstat(entryPath) + const stats = await limit(() => lstat(entryPath)) throwIfAborted(signal) if (stats.isSymbolicLink()) { @@ -401,7 +584,13 @@ async function scanLocalWorktreeWithDu( throwIfAborted(signal) const rootStats = await lstat(worktree.path) if (!rootStats.isDirectory() || rootStats.isSymbolicLink()) { - const root = await scanLocalEntry(worktree.path, basenameFilesystemPath(worktree.path), signal) + const limit = createAsyncLimiter(LOCAL_FS_CONCURRENCY, signal) + const root = await scanLocalEntry( + worktree.path, + basenameFilesystemPath(worktree.path), + limit, + signal + ) const compact = compactWorkspaceSpaceItems((root.children ?? []).map(toWorkspaceSpaceItem)) return { ...createBaseWorktreeRow(repo, worktree, scannedAt), @@ -415,27 +604,19 @@ async function scanLocalWorktreeWithDu( } const [entries, duSizes] = await Promise.all([ - opendir(worktree.path).then((directory) => - collectWorkspaceSpaceDirectoryEntries( - directory, - worktree.path, - (entry) => entry.name, - createWorkspaceSpaceScanBudget(), - () => throwIfAborted(signal) - ) - ), + readdir(worktree.path, { withFileTypes: true }), readLocalDuDepthOne(worktree.path, signal) ]) throwIfAborted(signal) - const childStats = await mapWithConcurrency( - entries, - LOCAL_FS_CONCURRENCY, - async (entry): Promise => { + const limit = createAsyncLimiter(LOCAL_FS_CONCURRENCY, signal) + const childStats = await Promise.all( + entries.map(async (entry): Promise => { try { return await scanLocalTopLevelEntry( joinFilesystemPath(worktree.path, entry.name), entry.name, duSizes, + limit, signal ) } catch (error) { @@ -444,7 +625,7 @@ async function scanLocalWorktreeWithDu( } return null } - } + }) ) const children = childStats.filter((child): child is ScanStats => child !== null) const skippedEntryCount = childStats.length - children.length @@ -471,7 +652,13 @@ async function scanLocalWorktreeWithNode( signal?: AbortSignal ): Promise { try { - const root = await scanLocalEntry(worktree.path, basenameFilesystemPath(worktree.path), signal) + const limit = createAsyncLimiter(LOCAL_FS_CONCURRENCY, signal) + const root = await scanLocalEntry( + worktree.path, + basenameFilesystemPath(worktree.path), + limit, + signal + ) const compact = compactWorkspaceSpaceItems((root.children ?? []).map(toWorkspaceSpaceItem)) return { ...createBaseWorktreeRow(repo, worktree, scannedAt), @@ -514,16 +701,6 @@ async function scanLocalWorktree( if (error instanceof WorkspaceSpaceScanCancelledError) { throw error } - if (error instanceof WorkspaceSpaceScanCapacityError) { - const classified = classifyError(error) - return createUnavailableWorktreeRow( - repo, - worktree, - scannedAt, - classified.status, - classified.message - ) - } // Fall through to the portable scanner so unsupported du variants or // permission edge cases still produce partial rows instead of failing. } @@ -556,10 +733,12 @@ async function scanRemoteWorktree( } } + const limit = createAsyncLimiter(REMOTE_FS_CONCURRENCY, signal) const root = await scanRemoteEntry( worktree.path, basenameFilesystemPath(worktree.path), provider, + limit, signal ) const compact = compactWorkspaceSpaceItems((root.children ?? []).map(toWorkspaceSpaceItem)) @@ -683,7 +862,7 @@ async function scanRepo( options.onProgress ) const remoteProvider = repo.connectionId ? getSshFilesystemProvider(repo.connectionId) : undefined - const rows = await mapWithConcurrency(worktrees, WORKTREE_SCAN_CONCURRENCY, async (worktree) => { + const rows = await mapLimit(worktrees, WORKTREE_SCAN_CONCURRENCY, async (worktree) => { throwIfAborted(options.signal) reportProgress( progress, @@ -758,7 +937,7 @@ export async function analyzeWorkspaceSpace( currentWorktreeDisplayName: null } options.onProgress?.({ ...progress }) - const repoResults = await mapWithConcurrency(reposToScan, 2, (repo) => + const repoResults = await mapLimit(reposToScan, 2, (repo) => scanRepo(repo, scannedAt, store, progress, options) ) throwIfAborted(options.signal) diff --git a/src/main/worktree-orphan-gitdir-proof.ts b/src/main/worktree-orphan-gitdir-proof.ts index 503e6870b88..afa73041777 100644 --- a/src/main/worktree-orphan-gitdir-proof.ts +++ b/src/main/worktree-orphan-gitdir-proof.ts @@ -1,7 +1,5 @@ import { type posix, win32 } from 'node:path' -export const MAX_WORKTREE_GIT_POINTER_BYTES = 64 * 1024 - type PathOps = typeof posix export type StatPath = (path: string) => Promise export type ReadPath = (path: string) => Promise @@ -43,15 +41,13 @@ function isGitFileStat(stat: unknown): boolean { function readFileResultToText(result: unknown): string | null { if (typeof result === 'string') { - return Buffer.byteLength(result, 'utf8') <= MAX_WORKTREE_GIT_POINTER_BYTES ? result : null + return result } if (Buffer.isBuffer(result)) { - return result.byteLength <= MAX_WORKTREE_GIT_POINTER_BYTES ? result.toString('utf8') : null + return result.toString('utf8') } if (result instanceof Uint8Array) { - return result.byteLength <= MAX_WORKTREE_GIT_POINTER_BYTES - ? Buffer.from(result).toString('utf8') - : null + return Buffer.from(result).toString('utf8') } if (!result || typeof result !== 'object') { return null @@ -60,9 +56,7 @@ function readFileResultToText(result: unknown): string | null { if (remoteRead.isBinary === true || typeof remoteRead.content !== 'string') { return null } - return Buffer.byteLength(remoteRead.content, 'utf8') <= MAX_WORKTREE_GIT_POINTER_BYTES - ? remoteRead.content - : null + return remoteRead.content } function resolveGitdirPath(gitdirPath: string, basePath: string, pathOps: PathOps): string { diff --git a/src/main/worktree-removal-safety.test.ts b/src/main/worktree-removal-safety.test.ts index e1d7e97ae79..5a64b8c90e5 100644 --- a/src/main/worktree-removal-safety.test.ts +++ b/src/main/worktree-removal-safety.test.ts @@ -190,22 +190,6 @@ describe('canSafelyRemoveOrphanedWorktreeDirectory', () => { ).resolves.toBe(false) }) - it('rejects oversized git pointer metadata from filesystem providers', async () => { - await expect( - canSafelyRemoveOrphanedWorktreeDirectory( - '/workspaces/orphan', - '/repo', - makeStatPath(['/workspaces/orphan/.git'], ['/repo/.git']), - makeReadPath([ - [ - '/workspaces/orphan/.git', - `gitdir: /repo/.git/worktrees/orphan\n${'x'.repeat(64 * 1024)}` - ] - ]) - ) - ).resolves.toBe(false) - }) - it('rejects a copied .git file when the admin entry points at another candidate path', async () => { await expect( canSafelyRemoveOrphanedWorktreeDirectory( diff --git a/src/main/worktree-removal-safety.ts b/src/main/worktree-removal-safety.ts index 01c3294e8ba..a589bce9e10 100644 --- a/src/main/worktree-removal-safety.ts +++ b/src/main/worktree-removal-safety.ts @@ -1,4 +1,4 @@ -import { lstat } from 'node:fs/promises' +import { lstat, readFile } from 'node:fs/promises' import { homedir } from 'node:os' import { posix, win32 } from 'node:path' import { isWindowsAbsolutePathLike } from '../shared/cross-platform-path' @@ -6,11 +6,9 @@ import type { GitWorktreeInfo, Repo, WorktreeMeta } from '../shared/types' import { areWorktreePathsEqual } from './ipc/worktree-logic' import { gitFileProvesOrphanedWorktreeDirectory, - MAX_WORKTREE_GIT_POINTER_BYTES, type ReadPath, type StatPath } from './worktree-orphan-gitdir-proof' -import { readNodeFileWithinLimit } from '../shared/node-bounded-file-reader' type PathOps = typeof posix @@ -153,8 +151,7 @@ export async function canSafelyRemoveOrphanedWorktreeDirectory( worktreePath: string, repoPath: string, statPath: StatPath = lstat, - readPath: ReadPath = async (path) => - (await readNodeFileWithinLimit(path, MAX_WORKTREE_GIT_POINTER_BYTES)).buffer.toString('utf8') + readPath: ReadPath = (path) => readFile(path, 'utf8') ): Promise { if (isDangerousWorktreeRemovalPath(worktreePath, repoPath)) { return false diff --git a/src/main/worktree-root-preparation.test.ts b/src/main/worktree-root-preparation.test.ts index 316df5e69d5..16adf035efe 100644 --- a/src/main/worktree-root-preparation.test.ts +++ b/src/main/worktree-root-preparation.test.ts @@ -14,11 +14,7 @@ vi.mock('./ipc/filesystem-auth', () => ({ authorizeExternalPath: authorizeExternalPathMock })) -import { - LOCAL_WORKTREE_ROOT_PREPARATION_CONCURRENCY, - prepareLocalWorktreeRootForRepo, - prepareLocalWorktreeRootsForRepos -} from './worktree-root-preparation' +import { prepareLocalWorktreeRootForRepo } from './worktree-root-preparation' const repo: Repo = { id: 'repo-1', @@ -80,28 +76,4 @@ describe('prepareLocalWorktreeRootForRepo', () => { await expect(prepareLocalWorktreeRootForRepo(store as never, repo)).resolves.toBeUndefined() expect(authorizeExternalPathMock).not.toHaveBeenCalled() }) - - it('prepares every repo with a fixed-size worker pool', async () => { - const repos = Array.from({ length: 10_000 }, (_, index) => ({ - ...repo, - id: `repo-${index}`, - path: `/projects/repo-${index}` - })) - let inFlight = 0 - let peak = 0 - mkdirMock.mockImplementation(async () => { - inFlight += 1 - peak = Math.max(peak, inFlight) - await Promise.resolve() - inFlight -= 1 - }) - - await prepareLocalWorktreeRootsForRepos({ - ...store, - getRepos: () => repos - } as never) - - expect(mkdirMock).toHaveBeenCalledTimes(repos.length) - expect(peak).toBe(LOCAL_WORKTREE_ROOT_PREPARATION_CONCURRENCY) - }) }) diff --git a/src/main/worktree-root-preparation.ts b/src/main/worktree-root-preparation.ts index 95afa74fdf1..3edcf311012 100644 --- a/src/main/worktree-root-preparation.ts +++ b/src/main/worktree-root-preparation.ts @@ -1,7 +1,6 @@ import { mkdir } from 'node:fs/promises' import type { GlobalSettings, Repo } from '../shared/types' import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../shared/execution-host' -import { forEachWithConcurrency } from '../shared/map-with-concurrency' import { isFolderRepo } from '../shared/repo-kind' import { computeWorkspaceRoot, getWorktreePathSettings } from './ipc/worktree-logic' @@ -11,8 +10,6 @@ type WorktreeRootPreparationStore = { getRepos: () => Repo[] } -export const LOCAL_WORKTREE_ROOT_PREPARATION_CONCURRENCY = 8 - export async function prepareLocalWorktreeRootForRepo( store: Pick, repo: Repo @@ -34,9 +31,5 @@ export async function prepareLocalWorktreeRootForRepo( export async function prepareLocalWorktreeRootsForRepos( store: WorktreeRootPreparationStore ): Promise { - await forEachWithConcurrency( - store.getRepos(), - LOCAL_WORKTREE_ROOT_PREPARATION_CONCURRENCY, - (repo) => prepareLocalWorktreeRootForRepo(store, repo) - ) + await Promise.all(store.getRepos().map((repo) => prepareLocalWorktreeRootForRepo(store, repo))) } diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 265fa659a0c..553b8d9b5f0 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -33,7 +33,6 @@ import type { import type { MobileRelayStatus } from '../shared/mobile-relay-status' import type { MobilePairingConnectionMode } from '../shared/mobile-pairing-connection-mode' import type { SshMutationExpectation } from '../shared/ssh-types' -import type { RasterImageDimensions } from '../shared/raster-image-dimensions' import type { CreateLocalOrcaProfileArgs, CreateLocalOrcaProfileResult, @@ -2482,7 +2481,6 @@ export type PreloadApi = { isBinary: boolean isImage?: boolean mimeType?: string - imageDimensions?: RasterImageDimensions fileIdentity?: string }> readLocalLogTail: (args: LocalLogTailReadArgs) => Promise diff --git a/src/preload/emulator-stream-frame-listeners.test.ts b/src/preload/emulator-stream-frame-listeners.test.ts deleted file mode 100644 index ac2f6c1b72f..00000000000 --- a/src/preload/emulator-stream-frame-listeners.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { - subscribeFrameStreamFrames, - subscribeVideoStreamFrames -} from './emulator-stream-frame-listeners' - -type Listener = (event: Electron.IpcRendererEvent, data: never) => void - -function makeIpcRenderer() { - const listeners = new Map() - return { - ipcRenderer: { - on: vi.fn((channel: string, listener: Listener) => { - listeners.set(channel, listener) - }), - removeListener: vi.fn((channel: string, listener: Listener) => { - if (listeners.get(channel) === listener) { - listeners.delete(channel) - } - }), - send: vi.fn() - }, - listeners - } -} - -describe('emulator stream frame listeners', () => { - it('acknowledges an MJPEG frame after the renderer callback', () => { - const { ipcRenderer, listeners } = makeIpcRenderer() - const callback = vi.fn() - const unsubscribe = subscribeFrameStreamFrames(ipcRenderer as never, callback) - const bytes = new ArrayBuffer(2) - - listeners.get('emulator:frameStreamFrame')!( - {} as Electron.IpcRendererEvent, - { - streamId: 'frame-stream', - deliveryId: 7, - bytes - } as never - ) - - expect(callback).toHaveBeenCalledWith({ streamId: 'frame-stream', bytes }) - expect(ipcRenderer.send).toHaveBeenCalledWith('emulator:frameStreamFrameAck', { - streamId: 'frame-stream', - deliveryId: 7 - }) - unsubscribe() - expect(listeners.has('emulator:frameStreamFrame')).toBe(false) - }) - - it('acknowledges an H.264 frame even when the renderer callback throws', () => { - const { ipcRenderer, listeners } = makeIpcRenderer() - const callback = vi.fn(() => { - throw new Error('decode failed') - }) - subscribeVideoStreamFrames(ipcRenderer as never, callback) - - expect(() => - listeners.get('emulator:videoStreamFrame')!( - {} as Electron.IpcRendererEvent, - { - streamId: 'video-stream', - deliveryToken: 'delivery-token', - deliveryId: 9, - deviceId: 'emulator-5554', - config: false, - keyFrame: true, - bytes: new ArrayBuffer(1) - } as never - ) - ).toThrow('decode failed') - expect(ipcRenderer.send).toHaveBeenCalledWith('emulator:videoStreamFrameAck', { - streamId: 'video-stream', - deliveryToken: 'delivery-token', - deliveryId: 9 - }) - }) -}) diff --git a/src/preload/emulator-stream-frame-listeners.ts b/src/preload/emulator-stream-frame-listeners.ts deleted file mode 100644 index 4d26d42ef20..00000000000 --- a/src/preload/emulator-stream-frame-listeners.ts +++ /dev/null @@ -1,60 +0,0 @@ -type FrameStreamData = { - streamId: string - deliveryId: number - bytes: ArrayBuffer -} - -type VideoStreamData = { - streamId: string - deliveryToken: string - deliveryId: number - deviceId: string - config: boolean - keyFrame: boolean - bytes: ArrayBuffer -} - -type EmulatorStreamIpcRenderer = Pick - -export function subscribeFrameStreamFrames( - ipcRenderer: EmulatorStreamIpcRenderer, - callback: (data: Omit) => void -): () => void { - const listener = (_event: Electron.IpcRendererEvent, data: FrameStreamData): void => { - try { - callback({ streamId: data.streamId, bytes: data.bytes }) - } finally { - ipcRenderer.send('emulator:frameStreamFrameAck', { - streamId: data.streamId, - deliveryId: data.deliveryId - }) - } - } - ipcRenderer.on('emulator:frameStreamFrame', listener) - return () => ipcRenderer.removeListener('emulator:frameStreamFrame', listener) -} - -export function subscribeVideoStreamFrames( - ipcRenderer: EmulatorStreamIpcRenderer, - callback: (data: Omit) => void -): () => void { - const listener = (_event: Electron.IpcRendererEvent, data: VideoStreamData): void => { - try { - callback({ - streamId: data.streamId, - deviceId: data.deviceId, - config: data.config, - keyFrame: data.keyFrame, - bytes: data.bytes - }) - } finally { - ipcRenderer.send('emulator:videoStreamFrameAck', { - streamId: data.streamId, - deliveryToken: data.deliveryToken, - deliveryId: data.deliveryId - }) - } - } - ipcRenderer.on('emulator:videoStreamFrame', listener) - return () => ipcRenderer.removeListener('emulator:videoStreamFrame', listener) -} diff --git a/src/preload/index.ts b/src/preload/index.ts index f0a4fef1c28..f70875f26cd 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -3,10 +3,6 @@ import { contextBridge, ipcRenderer, webFrame, webUtils } from 'electron' import { electronAPI } from '@electron-toolkit/preload' import { preloadE2EConfig } from './e2e-config' import { glApi } from './gitlab' -import { - subscribeFrameStreamFrames, - subscribeVideoStreamFrames -} from './emulator-stream-frame-listeners' import type { AppIdentity } from '../shared/app-identity' import type { DashboardSnapshot, DashboardRevealAgentArgs } from '../shared/dashboard-snapshot' import type { @@ -2592,7 +2588,14 @@ const api = { ipcRenderer.invoke('emulator:frameStreamStop', args), onFrameStreamFrame: ( callback: (data: { streamId: string; bytes: ArrayBuffer }) => void - ): (() => void) => subscribeFrameStreamFrames(ipcRenderer, callback), + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { streamId: string; bytes: ArrayBuffer } + ) => callback(data) + ipcRenderer.on('emulator:frameStreamFrame', listener) + return () => ipcRenderer.removeListener('emulator:frameStreamFrame', listener) + }, onFrameStreamError: ( callback: (data: { streamId: string; message: string }) => void ): (() => void) => { @@ -2635,7 +2638,20 @@ const api = { keyFrame: boolean bytes: ArrayBuffer }) => void - ): (() => void) => subscribeVideoStreamFrames(ipcRenderer, callback), + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { + streamId: string + deviceId: string + config: boolean + keyFrame: boolean + bytes: ArrayBuffer + } + ) => callback(data) + ipcRenderer.on('emulator:videoStreamFrame', listener) + return () => ipcRenderer.removeListener('emulator:videoStreamFrame', listener) + }, onPaneFocus: (callback: (data: { worktreeId: string }) => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent, data: { worktreeId: string }) => callback(data) diff --git a/src/relay/agent-exec-handler.test.ts b/src/relay/agent-exec-handler.test.ts index 2f9b5ce5d96..c884c499dac 100644 --- a/src/relay/agent-exec-handler.test.ts +++ b/src/relay/agent-exec-handler.test.ts @@ -2,7 +2,6 @@ import { execFile, spawn } from 'node:child_process' import { beforeEach, describe, expect, it, vi } from 'vitest' import type * as ChildProcess from 'node:child_process' import { createFakeChild, createHandlers, requestContext } from './agent-exec-handler-test-harness' -import { MAX_CONCURRENT_AGENT_EXECS } from './agent-exec-handler' import { TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV } from '../shared/terminal-git-credential-guard' vi.mock('child_process', async (importOriginal) => { @@ -17,7 +16,7 @@ vi.mock('child_process', async (importOriginal) => { const spawnMock = vi.mocked(spawn) const execFileMock = vi.mocked(execFile) -type AgentExecResult = { stdout: string; exitCode: number | null; timedOut: boolean } +type AgentExecResult = { exitCode: number | null; timedOut: boolean } describe('AgentExecHandler', () => { beforeEach(() => { @@ -65,68 +64,6 @@ describe('AgentExecHandler', () => { expect(child.stdin.end).toHaveBeenCalledWith('PROMPT') }) - it('preserves output delivered as 100,000 one-byte fragments', async () => { - const child = createFakeChild() - spawnMock.mockReturnValue(child as never) - const handlers = createHandlers() - const pending = handlers.get('agent.execNonInteractive')!( - { binary: 'agent', cwd: '/repo', timeoutMs: 5_000 }, - requestContext() - ) as Promise - - for (let index = 0; index < 100_000; index += 1) { - child.stdout.emit('data', Buffer.from(index % 2 === 0 ? 'a' : 'b')) - } - child.emit('close', 0) - - const result = await pending - expect(result.stdout).toHaveLength(100_000) - expect(result.stdout.slice(0, 4)).toBe('abab') - expect(result.stdout.slice(-4)).toBe('abab') - }) - - it('caps physical agent children until close confirms release', async () => { - const children = Array.from({ length: MAX_CONCURRENT_AGENT_EXECS + 1 }, (_, index) => { - const child = createFakeChild() - child.pid += index - return child - }) - let spawnIndex = 0 - spawnMock.mockImplementation(() => children[spawnIndex++] as never) - const handlers = createHandlers() - const active = children - .slice(0, MAX_CONCURRENT_AGENT_EXECS) - .map((_, index) => - handlers.get('agent.execNonInteractive')!( - { binary: 'agent', cwd: `/repo-${index}`, timeoutMs: 5_000 }, - requestContext() - ) - ) - - await expect( - handlers.get('agent.execNonInteractive')!( - { binary: 'agent', cwd: '/overflow', timeoutMs: 5_000 }, - requestContext() - ) - ).resolves.toMatchObject({ spawnError: 'Remote agent execution capacity reached' }) - expect(spawnMock).toHaveBeenCalledTimes(MAX_CONCURRENT_AGENT_EXECS) - - children[0]!.emit('close', 0) - await active[0] - const retry = handlers.get('agent.execNonInteractive')!( - { binary: 'agent', cwd: '/retry', timeoutMs: 5_000 }, - requestContext() - ) - expect(spawnMock).toHaveBeenCalledTimes(MAX_CONCURRENT_AGENT_EXECS + 1) - - for (const child of children.slice(1)) { - child.emit('close', 0) - } - await expect(Promise.all([...active.slice(1), retry])).resolves.toHaveLength( - MAX_CONCURRENT_AGENT_EXECS - ) - }) - it('merges caller-supplied provider environment into the spawned command environment', async () => { const child = createFakeChild() spawnMock.mockReturnValue(child as never) @@ -513,8 +450,6 @@ describe('AgentExecHandler', () => { expect(child.stdout.listenerCount('data')).toBe(0) expect(child.stderr.listenerCount('data')).toBe(0) expect(child.listenerCount('error')).toBe(0) - expect(child.listenerCount('close')).toBe(1) - child.emit('close', null) expect(child.listenerCount('close')).toBe(0) } finally { vi.useRealTimers() diff --git a/src/relay/agent-exec-handler.ts b/src/relay/agent-exec-handler.ts index 9adb3d0b6c4..a82791dcf45 100644 --- a/src/relay/agent-exec-handler.ts +++ b/src/relay/agent-exec-handler.ts @@ -3,14 +3,12 @@ import { existsSync } from 'node:fs' import { delimiter, join } from 'node:path' import type { RelayDispatcher, RequestContext } from './dispatcher' import { applyTerminalGitCredentialPromptGuard } from '../shared/terminal-git-credential-guard' -import { GrowingByteBuffer } from '../shared/growing-byte-buffer' import { mergeGitConfigEnvProtocol } from '../shared/git-credential-prompt-env' import { terminateRelaySubprocessTree } from './subprocess-tree-termination' const DEFAULT_TIMEOUT_MS = 60_000 const MAX_TIMEOUT_MS = 5 * 60 * 1000 const MAX_OUTPUT_BYTES = 4 * 1024 * 1024 -export const MAX_CONCURRENT_AGENT_EXECS = 8 const WINDOWS_BATCH_UNSAFE_ARGUMENTS_ERROR = 'UNSAFE_WINDOWS_BATCH_ARGUMENTS' function getCmdExePath(): string { @@ -113,7 +111,6 @@ export class AgentExecHandler { // Why: commit-message and PR-field generation can run together for one cwd; // operation lanes let cancel target only the user-visible job that stopped. private inFlightByLane = new Map() - private activeChildren = new Set() private laneKey(cwd: string, operation: unknown): string { return laneKeyFor(cwd, operation) @@ -162,16 +159,6 @@ export class AgentExecHandler { platform: process.platform }) - if (this.activeChildren.size >= MAX_CONCURRENT_AGENT_EXECS) { - return { - stdout: '', - stderr: '', - exitCode: null, - timedOut: false, - spawnError: 'Remote agent execution capacity reached' - } - } - return new Promise((resolve) => { let child try { @@ -192,13 +179,11 @@ export class AgentExecHandler { }) return } - this.activeChildren.add(child) - child.once('close', () => { - this.activeChildren.delete(child) - }) - const stdout = new GrowingByteBuffer() - const stderr = new GrowingByteBuffer() + let stdout = '' + let stderr = '' + let stdoutBytes = 0 + let stderrBytes = 0 let timedOut = false let canceled = false let settled = false @@ -221,8 +206,6 @@ export class AgentExecHandler { if (laneKey && entry && this.inFlightByLane.get(laneKey) === entry) { this.inFlightByLane.delete(laneKey) } - stdout.clear() - stderr.clear() resolve(result) } const cancelCurrent = (): void => { @@ -248,46 +231,36 @@ export class AgentExecHandler { // also Windows wraps `.cmd` shims in cmd.exe, so the immediate child // is not the real node.exe process. terminateRelaySubprocessTree(child) - finish({ - stdout: stdout.toString(), - stderr: stderr.toString(), - exitCode: null, - timedOut, - canceled - }) + finish({ stdout, stderr, exitCode: null, timedOut, canceled }) }, timeoutMs) const onStdoutData = (chunk: Buffer): void => { - if (stdout.byteLength + chunk.byteLength > MAX_OUTPUT_BYTES) { + stdoutBytes += chunk.byteLength + if (stdoutBytes > MAX_OUTPUT_BYTES) { terminateRelaySubprocessTree(child) return } - stdout.append(chunk) + stdout += chunk.toString('utf-8') } const onStderrData = (chunk: Buffer): void => { - if (stderr.byteLength + chunk.byteLength > MAX_OUTPUT_BYTES) { + stderrBytes += chunk.byteLength + if (stderrBytes > MAX_OUTPUT_BYTES) { terminateRelaySubprocessTree(child) return } - stderr.append(chunk) + stderr += chunk.toString('utf-8') } const onError = (error: Error): void => { finish({ - stdout: stdout.toString(), - stderr: stderr.toString(), + stdout, + stderr, exitCode: null, timedOut, spawnError: error.message }) } const onClose = (code: number | null): void => { - finish({ - stdout: stdout.toString(), - stderr: stderr.toString(), - exitCode: code, - timedOut, - canceled - }) + finish({ stdout, stderr, exitCode: code, timedOut, canceled }) } child.stdout?.on('data', onStdoutData) child.stderr?.on('data', onStderrData) diff --git a/src/relay/agent-hook-server.test.ts b/src/relay/agent-hook-server.test.ts index 68f6c7c1e4c..3d73506c7bf 100644 --- a/src/relay/agent-hook-server.test.ts +++ b/src/relay/agent-hook-server.test.ts @@ -2,11 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { homedir, tmpdir } from 'node:os' import { join } from 'node:path' -import { - endpointDirForRelaySocket, - MAX_RELAY_AGENT_HOOK_STATUS_CACHE_PANES, - RelayAgentHookServer -} from './agent-hook-server' +import { endpointDirForRelaySocket, RelayAgentHookServer } from './agent-hook-server' import type { AgentHookRelayEnvelope } from '../shared/agent-hook-relay' import { makePaneKey } from '../shared/stable-pane-id' import * as agentHookListener from '../shared/agent-hook-listener' @@ -395,8 +391,10 @@ describe('RelayAgentHookServer', () => { } }) - it('caps the replay cache, evicting the least-recently-updated', async () => { - const CAP = MAX_RELAY_AGENT_HOOK_STATUS_CACHE_PANES + it('caps the replay cache at 256 panes, evicting the least-recently-updated', async () => { + // Mirrors the server's private MAX_CACHED_PANES. The WSL relay never gets a + // per-pane teardown signal, so the cache is recency-capped instead. + const CAP = 256 const forward = vi.fn<(envelope: AgentHookRelayEnvelope) => void>() const server = new RelayAgentHookServer({ endpointDir: dir, forward }) await server.start() diff --git a/src/relay/agent-hook-server.ts b/src/relay/agent-hook-server.ts index 69bacaf3544..40775b5bd6a 100644 --- a/src/relay/agent-hook-server.ts +++ b/src/relay/agent-hook-server.ts @@ -29,7 +29,6 @@ import { type AgentHookRelayEnvelope, type AgentHookSource } from '../shared/agent-hook-relay' -import { upsertBoundedAgentHookStatus } from '../shared/agent-hook-status-cache' export type RelayHookForward = (envelope: AgentHookRelayEnvelope) => void @@ -41,8 +40,9 @@ const ASSISTANT_MESSAGE_RETRY_MS = 50 // Why: cap env/version at 64 chars so a misbehaving agent CLI can't grow the meta cache unboundedly; canonical values are short. const MAX_HOOK_META_LEN = 64 -// Why: preserve the relay's existing replay-memory ceiling while main uses the renderer-aligned 500-pane cap. -export const MAX_RELAY_AGENT_HOOK_STATUS_CACHE_PANES = 256 + +// Why: WSL relay has no per-pane teardown (PTYs live on the Windows host), so the replay cache would grow forever without a recency cap. +const MAX_CACHED_PANES = 256 function defaultEndpointDir(): string { return join(homedir(), RELAY_HOOKS_DIR_NAME, RELAY_HOOKS_SUBDIR) @@ -326,14 +326,17 @@ export class RelayAgentHookServer { if (event.payload.state !== 'done' || event.payload.lastAssistantMessage) { this.clearAssistantMessageRetry(event.paneKey) } - const evicted = upsertBoundedAgentHookStatus(this.state, event, { - maxPanes: MAX_RELAY_AGENT_HOOK_STATUS_CACHE_PANES - }) + // Why: delete-then-set makes Map insertion order = recency, so the cap below evicts the longest-idle pane. + this.state.lastStatusByPaneKey.delete(event.paneKey) + this.state.lastStatusByPaneKey.set(event.paneKey, event) this.lastEnvelopeMetaByPaneKey.delete(event.paneKey) this.lastEnvelopeMetaByPaneKey.set(event.paneKey, { source, env, version }) - for (const { paneKey } of evicted) { - this.clearAssistantMessageRetry(paneKey) - this.lastEnvelopeMetaByPaneKey.delete(paneKey) + while (this.state.lastStatusByPaneKey.size > MAX_CACHED_PANES) { + const oldest = this.state.lastStatusByPaneKey.keys().next().value + if (oldest === undefined) { + break + } + this.clearPaneState(oldest) } this.forwardEvent(event, source, env, version) } diff --git a/src/relay/client-request-aborts.test.ts b/src/relay/client-request-aborts.test.ts deleted file mode 100644 index 3b4cdac75fb..00000000000 --- a/src/relay/client-request-aborts.test.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - ClientRequestAborts, - MAX_ACTIVE_RELAY_REQUEST_BYTES, - MAX_ACTIVE_RELAY_REQUEST_BYTES_PER_CLIENT, - MAX_ACTIVE_RELAY_REQUESTS, - MAX_ACTIVE_RELAY_REQUESTS_PER_CLIENT -} from './client-request-aborts' - -describe('ClientRequestAborts', () => { - it('accepts the per-client request boundary, rejects overflow, and recovers', () => { - const requests = new ClientRequestAborts() - const registrations = Array.from( - { length: MAX_ACTIVE_RELAY_REQUESTS_PER_CLIENT }, - (_, requestId) => requests.create(1, requestId) - ) - - expect(() => requests.create(1, MAX_ACTIVE_RELAY_REQUESTS_PER_CLIENT)).toThrow( - `Relay client active request limit of ${MAX_ACTIVE_RELAY_REQUESTS_PER_CLIENT} reached` - ) - - requests.delete(registrations[0].key) - expect(() => requests.create(1, MAX_ACTIVE_RELAY_REQUESTS_PER_CLIENT)).not.toThrow() - }) - - it('accepts the aggregate request boundary without evicting active clients', () => { - const requests = new ClientRequestAborts() - for (let index = 0; index < MAX_ACTIVE_RELAY_REQUESTS; index += 1) { - const clientId = Math.floor(index / MAX_ACTIVE_RELAY_REQUESTS_PER_CLIENT) + 1 - requests.create(clientId, index) - } - - expect(() => requests.create(99, MAX_ACTIVE_RELAY_REQUESTS)).toThrow( - `Relay active request limit of ${MAX_ACTIVE_RELAY_REQUESTS} reached` - ) - expect(requests.get(1, 0)?.signal.aborted).toBe(false) - - requests.delete({ clientId: 1, requestId: 0 }) - expect(() => requests.create(99, MAX_ACTIVE_RELAY_REQUESTS)).not.toThrow() - }) - - it('bounds retained payload bytes per client and releases the budget on delete', () => { - const requests = new ClientRequestAborts() - const halfBudget = MAX_ACTIVE_RELAY_REQUEST_BYTES_PER_CLIENT / 2 - const first = requests.create(1, 1, halfBudget) - requests.create(1, 2, halfBudget) - - expect(() => requests.create(1, 3, 1)).toThrow( - `Relay client active request payload limit of ${MAX_ACTIVE_RELAY_REQUEST_BYTES_PER_CLIENT} bytes exceeded` - ) - - requests.delete(first.key) - expect(() => requests.create(1, 3, 1)).not.toThrow() - }) - - it('bounds aggregate retained payload bytes and recovers after client abort', () => { - const requests = new ClientRequestAborts() - requests.create(1, 1, MAX_ACTIVE_RELAY_REQUEST_BYTES_PER_CLIENT) - requests.create( - 2, - 2, - MAX_ACTIVE_RELAY_REQUEST_BYTES - MAX_ACTIVE_RELAY_REQUEST_BYTES_PER_CLIENT - ) - - expect(() => requests.create(3, 3, 1)).toThrow( - `Relay active request payload limit of ${MAX_ACTIVE_RELAY_REQUEST_BYTES} bytes exceeded` - ) - - requests.abortClient(1) - expect(() => requests.create(3, 3, 1)).not.toThrow() - }) - - it('rejects duplicate ids and releases only the requested owners', () => { - const requests = new ClientRequestAborts() - const first = requests.create(1, 7) - const second = requests.create(2, 7) - - expect(() => requests.create(1, 7)).toThrow('Duplicate active relay request id 7') - expect(requests.get(1, 7)).toBe(first.controller) - - requests.abortClient(1) - expect(first.controller.signal.aborted).toBe(true) - expect(second.controller.signal.aborted).toBe(false) - expect(requests.get(1, 7)).toBeUndefined() - expect(requests.get(2, 7)).toBe(second.controller) - - requests.abortAll() - expect(second.controller.signal.aborted).toBe(true) - expect(requests.get(2, 7)).toBeUndefined() - }) -}) diff --git a/src/relay/client-request-aborts.ts b/src/relay/client-request-aborts.ts index b2b984ebf08..12d44363d0d 100644 --- a/src/relay/client-request-aborts.ts +++ b/src/relay/client-request-aborts.ts @@ -1,118 +1,40 @@ -export const MAX_ACTIVE_RELAY_REQUESTS_PER_CLIENT = 256 -export const MAX_ACTIVE_RELAY_REQUESTS = 1024 -// Why: handlers retain parsed params until settlement, so cardinality alone -// cannot bound a burst of near-maximum relay frames. -export const MAX_ACTIVE_RELAY_REQUEST_BYTES_PER_CLIENT = 32 * 1024 * 1024 -export const MAX_ACTIVE_RELAY_REQUEST_BYTES = 64 * 1024 * 1024 - -type ClientRequestAbortEntry = { - controller: AbortController - retainedBytes: number -} - -export type ClientRequestAbortKey = { - clientId: number - requestId: number -} - -export class RelayRequestAdmissionError extends Error { - constructor(message: string) { - super(message) - this.name = 'RelayRequestAdmissionError' - } -} - export class ClientRequestAborts { - private readonly entriesByClient = new Map>() - private activeRequestCount = 0 - private activeRequestBytes = 0 - - create( - clientId: number, - requestId: number, - retainedBytes = 0 - ): { key: ClientRequestAbortKey; controller: AbortController } { - const clientEntries = this.entriesByClient.get(clientId) - if (clientEntries?.has(requestId)) { - throw new RelayRequestAdmissionError(`Duplicate active relay request id ${requestId}`) - } - if ((clientEntries?.size ?? 0) >= MAX_ACTIVE_RELAY_REQUESTS_PER_CLIENT) { - throw new RelayRequestAdmissionError( - `Relay client active request limit of ${MAX_ACTIVE_RELAY_REQUESTS_PER_CLIENT} reached` - ) - } - if (this.activeRequestCount >= MAX_ACTIVE_RELAY_REQUESTS) { - throw new RelayRequestAdmissionError( - `Relay active request limit of ${MAX_ACTIVE_RELAY_REQUESTS} reached` - ) - } - const normalizedBytes = Math.max(0, retainedBytes) - const clientBytes = sumRetainedBytes(clientEntries?.values()) - if (clientBytes + normalizedBytes > MAX_ACTIVE_RELAY_REQUEST_BYTES_PER_CLIENT) { - throw new RelayRequestAdmissionError( - `Relay client active request payload limit of ${MAX_ACTIVE_RELAY_REQUEST_BYTES_PER_CLIENT} bytes exceeded` - ) - } - if (this.activeRequestBytes + normalizedBytes > MAX_ACTIVE_RELAY_REQUEST_BYTES) { - throw new RelayRequestAdmissionError( - `Relay active request payload limit of ${MAX_ACTIVE_RELAY_REQUEST_BYTES} bytes exceeded` - ) - } + private readonly controllers = new Map() + create(clientId: number, requestId: number): { key: string; controller: AbortController } { + const key = this.key(clientId, requestId) const controller = new AbortController() - const entries = clientEntries ?? new Map() - entries.set(requestId, { controller, retainedBytes: normalizedBytes }) - this.entriesByClient.set(clientId, entries) - this.activeRequestCount += 1 - this.activeRequestBytes += normalizedBytes - return { key: { clientId, requestId }, controller } + this.controllers.set(key, controller) + return { key, controller } } get(clientId: number, requestId: number): AbortController | undefined { - return this.entriesByClient.get(clientId)?.get(requestId)?.controller + return this.controllers.get(this.key(clientId, requestId)) } - delete(key: ClientRequestAbortKey): void { - const clientEntries = this.entriesByClient.get(key.clientId) - const entry = clientEntries?.get(key.requestId) - if (!clientEntries || !entry) { - return - } - clientEntries.delete(key.requestId) - this.activeRequestCount -= 1 - this.activeRequestBytes -= entry.retainedBytes - if (clientEntries.size === 0) { - this.entriesByClient.delete(key.clientId) - } + delete(key: string): void { + this.controllers.delete(key) } abortClient(clientId: number): void { - const clientEntries = this.entriesByClient.get(clientId) - if (!clientEntries) { - return - } - for (const [requestId, entry] of clientEntries) { - entry.controller.abort() - this.delete({ clientId, requestId }) + const prefix = `${clientId}:` + for (const [key, controller] of this.controllers) { + if (!key.startsWith(prefix)) { + continue + } + controller.abort() + this.controllers.delete(key) } } abortAll(): void { - for (const clientEntries of this.entriesByClient.values()) { - for (const entry of clientEntries.values()) { - entry.controller.abort() - } + for (const [, controller] of this.controllers) { + controller.abort() } - this.entriesByClient.clear() - this.activeRequestCount = 0 - this.activeRequestBytes = 0 + this.controllers.clear() } -} -function sumRetainedBytes(entries?: Iterable): number { - let total = 0 - for (const entry of entries ?? []) { - total += entry.retainedBytes + private key(clientId: number, requestId: number): string { + return `${clientId}:${requestId}` } - return total } diff --git a/src/relay/dispatcher.test.ts b/src/relay/dispatcher.test.ts index 66b1f81468b..87f5cb69053 100644 --- a/src/relay/dispatcher.test.ts +++ b/src/relay/dispatcher.test.ts @@ -1,17 +1,11 @@ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' -import { - MAX_PENDING_RELAY_REQUESTS, - MAX_RELAY_DISPATCHER_CLIENTS, - RelayDispatcher -} from './dispatcher' -import { MAX_ACTIVE_RELAY_REQUESTS_PER_CLIENT } from './client-request-aborts' +import { RelayDispatcher } from './dispatcher' import { encodeJsonRpcFrame, encodeKeepAliveFrame, MessageType, type JsonRpcRequest, - type JsonRpcNotification, - type JsonRpcResponse + type JsonRpcNotification } from './protocol' function decodeFirstFrame(buf: Buffer): { type: number; id: number; ack: number; payload: Buffer } { @@ -124,48 +118,6 @@ describe('RelayDispatcher', () => { expect(resp.id).toBe(5) }) - it('rejects incoming request overflow without evicting active work', async () => { - const resolutions: ((value: unknown) => void)[] = [] - const handler = vi.fn( - (_params, context) => - new Promise((resolve) => { - resolutions.push(resolve) - context.signal?.addEventListener('abort', () => resolve(null), { once: true }) - }) - ) - dispatcher.onRequest('slow.method', handler) - - for (let id = 1; id <= MAX_ACTIVE_RELAY_REQUESTS_PER_CLIENT + 1; id += 1) { - dispatcher.feed(encodeJsonRpcFrame({ jsonrpc: '2.0', id, method: 'slow.method' }, id, 0)) - } - await vi.advanceTimersByTimeAsync(0) - - expect(handler).toHaveBeenCalledTimes(MAX_ACTIVE_RELAY_REQUESTS_PER_CLIENT) - const overflowId = MAX_ACTIVE_RELAY_REQUESTS_PER_CLIENT + 1 - const overflow = written - .map(decodeFirstFrame) - .filter((frame) => frame.type === MessageType.Regular) - .map((frame) => JSON.parse(frame.payload.toString('utf-8')) as JsonRpcResponse) - .find((response) => response.id === overflowId) - expect(overflow?.error?.message).toBe( - `Relay client active request limit of ${MAX_ACTIVE_RELAY_REQUESTS_PER_CLIENT} reached` - ) - expect(resolutions).toHaveLength(MAX_ACTIVE_RELAY_REQUESTS_PER_CLIENT) - - resolutions[0]('done') - await vi.advanceTimersByTimeAsync(0) - dispatcher.feed( - encodeJsonRpcFrame( - { jsonrpc: '2.0', id: overflowId + 1, method: 'slow.method' }, - overflowId + 1, - 0 - ) - ) - await vi.advanceTimersByTimeAsync(0) - - expect(handler).toHaveBeenCalledTimes(MAX_ACTIVE_RELAY_REQUESTS_PER_CLIENT + 1) - }) - it('sends method-not-found for unknown methods', async () => { const req: JsonRpcRequest = { jsonrpc: '2.0', @@ -249,21 +201,6 @@ describe('RelayDispatcher', () => { expect(socketWritten).toHaveLength(1) }) - it('accepts the exact client cap, rejects aggregate overflow, and recovers after detach', () => { - const attachedIds = Array.from({ length: MAX_RELAY_DISPATCHER_CLIENTS - 1 }, () => - dispatcher.attachClient(() => undefined) - ) - - expect(dispatcher.connectedClientIds()).toHaveLength(MAX_RELAY_DISPATCHER_CLIENTS) - expect(() => dispatcher.attachClient(() => undefined)).toThrow( - `Relay client limit of ${MAX_RELAY_DISPATCHER_CLIENTS} reached` - ) - - dispatcher.detachClient(attachedIds[0]) - expect(() => dispatcher.attachClient(() => undefined)).not.toThrow() - expect(dispatcher.connectedClientIds()).toHaveLength(MAX_RELAY_DISPATCHER_CLIENTS) - }) - it('targets terminal ownership notifications to one attached client', () => { const firstWritten: Buffer[] = [] const secondWritten: Buffer[] = [] @@ -281,76 +218,6 @@ describe('RelayDispatcher', () => { expect(secondWritten).toHaveLength(0) }) - it('reports targeted sink saturation and waits for its drain signal', async () => { - const ownerWritten: Buffer[] = [] - let signalDrain: (() => void) | undefined - const ownerId = dispatcher.attachClient( - (data) => { - ownerWritten.push(Buffer.from(data)) - return false - }, - { - waitWriteDrain: (callback) => { - signalDrain = callback - } - } - ) - const otherWritten: Buffer[] = [] - dispatcher.attachClient((data) => { - otherWritten.push(Buffer.from(data)) - }) - - const result = dispatcher.notifyClientWithBackpressure(ownerId, 'pty.data', { - id: 'pty-1', - data: 'output' - }) - - expect(result.delivered).toBe(true) - expect(result.saturated).toBe(true) - expect(ownerWritten).toHaveLength(1) - expect(written).toHaveLength(0) - expect(otherWritten).toHaveLength(0) - let drained = false - void result.drained.then(() => { - drained = true - }) - await Promise.resolve() - expect(drained).toBe(false) - - signalDrain?.() - await expect(result.drained).resolves.toBeUndefined() - expect(drained).toBe(true) - }) - - it('reports a detached targeted client without writing', async () => { - const ownerId = dispatcher.attachClient(() => true) - dispatcher.detachClient(ownerId) - - const result = dispatcher.notifyClientWithBackpressure(ownerId, 'pty.data', { - id: 'pty-1', - data: 'output' - }) - - expect(result).toMatchObject({ delivered: false, saturated: false }) - await expect(result.drained).resolves.toBeUndefined() - expect(written).toHaveLength(0) - }) - - it('allows a targeted drain waiter to be canceled during owner cleanup', async () => { - const ownerId = dispatcher.attachClient(() => false, { - waitWriteDrain: () => {} - }) - const result = dispatcher.notifyClientWithBackpressure(ownerId, 'pty.data', { - id: 'pty-1', - data: 'output' - }) - - expect(result.saturated).toBe(true) - result.cancelDrain?.() - - await expect(result.drained).resolves.toBeUndefined() - }) - it('forwards relay-originated requests to an owning socket client instead of the caller', async () => { dispatcher.invalidateClient() const ownerWritten: Buffer[] = [] @@ -406,48 +273,6 @@ describe('RelayDispatcher', () => { await expect(pending).resolves.toEqual({ exitCode: 0 }) }) - it('bounds relay-originated pending requests and recovers after a response', async () => { - const pending = Array.from({ length: MAX_PENDING_RELAY_REQUESTS }, (_, index) => { - const request = dispatcher.requestPrimary('orca.cli', { index }) - void request.catch(() => undefined) - return request - }) - - expect(written).toHaveLength(MAX_PENDING_RELAY_REQUESTS) - await expect(dispatcher.requestPrimary('orca.cli', { overflow: true })).rejects.toThrow( - `Relay pending request limit of ${MAX_PENDING_RELAY_REQUESTS} reached` - ) - expect(written).toHaveLength(MAX_PENDING_RELAY_REQUESTS) - - const firstRequest = JSON.parse( - decodeFirstFrame(written[0]).payload.toString('utf-8') - ) as JsonRpcRequest - dispatcher.feed( - encodeJsonRpcFrame({ jsonrpc: '2.0', id: firstRequest.id, result: 'done' }, 1, 0) - ) - await expect(pending[0]).resolves.toBe('done') - - const recovered = dispatcher.requestPrimary('orca.cli', { recovered: true }) - expect(written).toHaveLength(MAX_PENDING_RELAY_REQUESTS + 1) - const recoveredRequest = JSON.parse( - decodeFirstFrame(written.at(-1)!).payload.toString('utf-8') - ) as JsonRpcRequest - dispatcher.feed( - encodeJsonRpcFrame({ jsonrpc: '2.0', id: recoveredRequest.id, result: 'recovered' }, 2, 0) - ) - await expect(recovered).resolves.toBe('recovered') - }) - - it('rejects relay-originated requests when their owning client detaches', async () => { - const ownerId = dispatcher.attachClient(() => undefined) - dispatcher.invalidateClient() - const pending = dispatcher.requestAnyClient('orca.cli') - - dispatcher.detachClient(ownerId) - - await expect(pending).rejects.toThrow('Relay client disconnected') - }) - it('isolates failed socket-client writes from other clients', () => { const goodSocketWritten: Buffer[] = [] const failingClientId = dispatcher.attachClient(() => { diff --git a/src/relay/dispatcher.ts b/src/relay/dispatcher.ts index 4b1ac665121..1f1445da2f1 100644 --- a/src/relay/dispatcher.ts +++ b/src/relay/dispatcher.ts @@ -11,7 +11,7 @@ import { type JsonRpcNotification, type JsonRpcResponse } from './protocol' -import { ClientRequestAborts, RelayRequestAdmissionError } from './client-request-aborts' +import { ClientRequestAborts } from './client-request-aborts' export type RequestContext = { clientId: number @@ -32,15 +32,6 @@ export type RelayClientWrite = (data: Buffer) => boolean | void export type RelayClientSinkOptions = { /** One-shot: invoke `cb` when the sink can accept more data (drain) or is permanently dead, so waiters never hang. */ waitWriteDrain?: (cb: () => void) => void - /** Close only this transport when bounded per-client delivery can no longer retain its backlog. */ - disconnect?: () => void -} - -export type RelayNotificationWriteResult = { - delivered: boolean - saturated: boolean - drained: Promise - cancelDrain?: () => void } type RelayClient = { @@ -48,7 +39,6 @@ type RelayClient = { decoder: FrameDecoder write: RelayClientWrite waitWriteDrain?: (cb: () => void) => void - disconnect?: () => void /** Resolvers for bulk sends stalled on sink saturation; flushed so no pump hangs. */ drainWaiters: Set<() => void> /** Serializes bulk-lane sends so only one bulk frame is admitted past the sink high-water mark at a time. */ @@ -60,16 +50,12 @@ type RelayClient = { } type PendingRelayRequest = { - clientId: number resolve: (result: unknown) => void reject: (error: Error) => void timer: ReturnType } const RELAY_TO_CLIENT_REQUEST_TIMEOUT_MS = 30_000 -export const MAX_RELAY_DISPATCHER_CLIENTS = 16 -export const MAX_RELAY_SOCKET_CONNECTIONS = MAX_RELAY_DISPATCHER_CLIENTS - 1 -export const MAX_PENDING_RELAY_REQUESTS = 256 export class RelayDispatcher { private readonly primaryClient: RelayClient @@ -94,10 +80,8 @@ export class RelayDispatcher { // Why: the new client's multiplexer restarts at seq=1, so reset seq/decoder state or acks stall and fire a false connection-dead signal. setWrite(write: RelayClientWrite, sinkOptions?: RelayClientSinkOptions): void { this.requestAborts.abortClient(this.primaryClient.id) - this.rejectPendingRequestsForClient(this.primaryClient.id, 'Relay client reconnected') this.primaryClient.write = write this.primaryClient.waitWriteDrain = sinkOptions?.waitWriteDrain - this.primaryClient.disconnect = sinkOptions?.disconnect this.primaryClient.closed = false // Why: the old sink is gone; wake stalled bulk senders to re-evaluate against the new one. this.flushDrainWaiters(this.primaryClient) @@ -107,7 +91,6 @@ export class RelayDispatcher { // Why: mark in-flight requests stale on disconnect so a late pty.spawn/fs.watch can't create unowned remote state. invalidateClient(): void { this.requestAborts.abortClient(this.primaryClient.id) - this.rejectPendingRequestsForClient(this.primaryClient.id, 'Relay client disconnected') this.primaryClient.generation++ this.primaryClient.closed = true this.flushDrainWaiters(this.primaryClient) @@ -116,12 +99,6 @@ export class RelayDispatcher { // Why: seq numbers and request ids are per SSH channel, so each attached client needs independent protocol state. attachClient(write: RelayClientWrite, sinkOptions?: RelayClientSinkOptions): number { - if (this.disposed) { - throw new Error('Relay dispatcher is disposed') - } - if (this.clients.size >= MAX_RELAY_DISPATCHER_CLIENTS) { - throw new Error(`Relay client limit of ${MAX_RELAY_DISPATCHER_CLIENTS} reached`) - } const client = this.createClient(write, sinkOptions) this.clients.set(client.id, client) return client.id @@ -133,7 +110,6 @@ export class RelayDispatcher { return } this.requestAborts.abortClient(clientId) - this.rejectPendingRequestsForClient(clientId, 'Relay client disconnected') client.generation++ client.closed = true this.flushDrainWaiters(client) @@ -141,33 +117,6 @@ export class RelayDispatcher { this.notifyClientDetached(clientId) } - connectedClientIds(): number[] { - if (this.disposed) { - return [] - } - return Array.from(this.clients.values(), (client) => client) - .filter((client) => !client.closed) - .map((client) => client.id) - } - - evictClient(clientId: number): void { - const client = this.clients.get(clientId) - if (!client || client.closed) { - return - } - const disconnect = client.disconnect - if (client === this.primaryClient) { - this.invalidateClient() - } else { - this.detachClient(clientId) - } - try { - disconnect?.() - } catch { - // Why: disconnect failures cannot restore an already-detached dispatcher. - } - } - feedClient(clientId: number, data: Buffer): void { const client = this.clients.get(clientId) if (!client) { @@ -235,35 +184,6 @@ export class RelayDispatcher { }) } - notifyClientWithBackpressure( - clientId: number, - method: string, - params?: Record - ): RelayNotificationWriteResult { - const client = this.clients.get(clientId) - if (this.disposed || !client || client.closed) { - return { delivered: false, saturated: false, drained: Promise.resolve() } - } - const accepted = this.sendFrame(client, { - jsonrpc: '2.0', - method, - ...(params !== undefined ? { params } : {}) - }) - if (client.closed || !this.clients.has(clientId)) { - return { delivered: false, saturated: false, drained: Promise.resolve() } - } - if (accepted !== false) { - return { delivered: true, saturated: false, drained: Promise.resolve() } - } - const drain = this.waitForClientDrainCancelable(client) - return { - delivered: true, - saturated: true, - drained: drain.drained, - cancelDrain: drain.cancel - } - } - /** * Bulk-lane notification: sends are serialized per client and the promise * resolves only after the sink accepted the frame (backpressure), so bulk @@ -313,18 +233,10 @@ export class RelayDispatcher { } private waitForClientDrain(client: RelayClient): Promise { - return this.waitForClientDrainCancelable(client).drained - } - - private waitForClientDrainCancelable(client: RelayClient): { - drained: Promise - cancel: () => void - } { if (this.disposed || client.closed || !client.waitWriteDrain) { - return { drained: Promise.resolve(), cancel: () => {} } + return Promise.resolve() } - let cancel = (): void => {} - const drained = new Promise((resolve) => { + return new Promise((resolve) => { let settled = false const finish = (): void => { if (settled) { @@ -334,7 +246,6 @@ export class RelayDispatcher { client.drainWaiters.delete(finish) resolve() } - cancel = finish client.drainWaiters.add(finish) try { client.waitWriteDrain!(finish) @@ -342,7 +253,6 @@ export class RelayDispatcher { finish() } }) - return { drained, cancel } } private flushDrainWaiters(client: RelayClient): void { @@ -385,11 +295,6 @@ export class RelayDispatcher { if (this.disposed || !client || client.closed) { return Promise.reject(new Error('Relay client is not connected')) } - if (this.pendingRelayRequests.size >= MAX_PENDING_RELAY_REQUESTS) { - return Promise.reject( - new Error(`Relay pending request limit of ${MAX_PENDING_RELAY_REQUESTS} reached`) - ) - } const id = this.nextRequestId++ const msg: JsonRpcRequest = { jsonrpc: '2.0', @@ -403,27 +308,11 @@ export class RelayDispatcher { this.pendingRelayRequests.delete(id) reject(new Error(`Request "${method}" timed out after ${timeoutMs}ms`)) }, timeoutMs) - this.pendingRelayRequests.set(id, { clientId, resolve, reject, timer }) + this.pendingRelayRequests.set(id, { resolve, reject, timer }) this.sendFrame(client, msg) - if (client.closed) { - clearTimeout(timer) - this.pendingRelayRequests.delete(id) - reject(new Error('Relay client disconnected')) - } }) } - private rejectPendingRequestsForClient(clientId: number, message: string): void { - for (const [id, pending] of this.pendingRelayRequests) { - if (pending.clientId !== clientId) { - continue - } - clearTimeout(pending.timer) - this.pendingRelayRequests.delete(id) - pending.reject(new Error(message)) - } - } - dispose(): void { if (this.disposed) { return @@ -452,7 +341,6 @@ export class RelayDispatcher { decoder: new FrameDecoder((frame) => this.handleFrame(client, frame)), write, waitWriteDrain: sinkOptions?.waitWriteDrain, - disconnect: sinkOptions?.disconnect, drainWaiters: new Set(), bulkChain: Promise.resolve(), nextOutgoingSeq: 1, @@ -483,7 +371,7 @@ export class RelayDispatcher { if (frame.type === MessageType.Regular) { try { const msg = parseJsonRpcMessage(frame.payload) - this.handleMessage(client, msg, frame.payload.byteLength) + this.handleMessage(client, msg) } catch (err) { process.stderr.write( `[relay] Parse error: ${err instanceof Error ? err.message : String(err)}\n` @@ -494,11 +382,10 @@ export class RelayDispatcher { private handleMessage( client: RelayClient, - msg: JsonRpcRequest | JsonRpcNotification | JsonRpcResponse, - retainedBytes: number + msg: JsonRpcRequest | JsonRpcNotification | JsonRpcResponse ): void { if ('id' in msg && 'method' in msg) { - void this.handleRequest(client, msg as JsonRpcRequest, retainedBytes) + void this.handleRequest(client, msg as JsonRpcRequest) } else if ('id' in msg && ('result' in msg || 'error' in msg)) { this.handleResponse(msg as JsonRpcResponse) } else if ('method' in msg && !('id' in msg)) { @@ -523,11 +410,7 @@ export class RelayDispatcher { pending.resolve(msg.result) } - private async handleRequest( - client: RelayClient, - req: JsonRpcRequest, - retainedBytes: number - ): Promise { + private async handleRequest(client: RelayClient, req: JsonRpcRequest): Promise { const handler = this.requestHandlers.get(req.method) if (!handler) { this.sendResponse(client, req.id, undefined, { @@ -539,17 +422,10 @@ export class RelayDispatcher { // Why: snapshot generation before the await to detect if the client disconnected mid-flight. const gen = client.generation - let registration: ReturnType - try { - registration = this.requestAborts.create(client.id, req.id, retainedBytes) - } catch (error) { - if (error instanceof RelayRequestAdmissionError) { - this.sendResponse(client, req.id, undefined, { code: -32000, message: error.message }) - return - } - throw error - } - const { key: abortKey, controller: abortController } = registration + const { key: abortKey, controller: abortController } = this.requestAborts.create( + client.id, + req.id + ) const context: RequestContext = { clientId: client.id, isStale: () => @@ -642,7 +518,6 @@ export class RelayDispatcher { client.closed = true client.generation++ this.requestAborts.abortClient(client.id) - this.rejectPendingRequestsForClient(client.id, 'Relay client disconnected') this.flushDrainWaiters(client) // Why: frames have no retransmit buffer; detach now so reconnect/PTY-reattach runs instead of waiting the ~20s keepalive timeout. if (client !== this.primaryClient) { diff --git a/src/relay/external-automations-handler.test.ts b/src/relay/external-automations-handler.test.ts index 993131f27ab..d2a19d79c2d 100644 --- a/src/relay/external-automations-handler.test.ts +++ b/src/relay/external-automations-handler.test.ts @@ -1,7 +1,4 @@ -import { mkdtemp, rm, truncate, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { ExternalAutomationsHandler } from './external-automations-handler' import type { RelayDispatcher } from './dispatcher' @@ -18,7 +15,6 @@ const execFileMock = vi.hoisted(() => vi.mock('child_process', () => ({ execFile: execFileMock })) type CapturedHandler = (params?: Record) => Promise -const tempDirs: string[] = [] function createHandlerHarness(): { handler: ExternalAutomationsHandler @@ -38,10 +34,6 @@ beforeEach(() => { execFileMock.mockClear() }) -afterEach(async () => { - await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))) -}) - describe('ExternalAutomationsHandler', () => { it('runs external lifecycle actions without shell wrapping', async () => { const { requestHandlers } = createHandlerHarness() @@ -63,32 +55,26 @@ describe('ExternalAutomationsHandler', () => { it('paginates remote Hermes run history after ref lookup', async () => { const { handler, requestHandlers } = createHandlerHarness() const handlerInternals = handler as unknown as { - readHermesRunRefs: (jobId: string) => Promise<{ - refs: { id: string; run_at: string }[] - saturated: boolean - }> + readHermesRunRefs: (jobId: string) => Promise<{ id: string; run_at: string }[]> hydrateHermesRunRef: ( jobId: string, ref: { id: string; run_at: string } ) => Promise<{ id: string; run_at: string }> } - handlerInternals.readHermesRunRefs = vi.fn().mockResolvedValue({ - refs: [ - { - id: 'cron_job-1_20260516_090000', - run_at: '2026-05-16T09:00:00' - }, - { - id: 'job-1:2026-05-15_09-00-00.md', - run_at: '2026-05-15T09:00:00' - }, - { - id: 'job-1:2026-05-14_09-00-00.md', - run_at: '2026-05-14T09:00:00' - } - ], - saturated: false - }) + handlerInternals.readHermesRunRefs = vi.fn().mockResolvedValue([ + { + id: 'cron_job-1_20260516_090000', + run_at: '2026-05-16T09:00:00' + }, + { + id: 'job-1:2026-05-15_09-00-00.md', + run_at: '2026-05-15T09:00:00' + }, + { + id: 'job-1:2026-05-14_09-00-00.md', + run_at: '2026-05-14T09:00:00' + } + ]) handlerInternals.hydrateHermesRunRef = vi.fn(async (_jobId, ref) => ref) const result = (await requestHandlers.get('externalAutomations.runs')?.({ @@ -105,80 +91,12 @@ describe('ExternalAutomationsHandler', () => { ]) }) - it('bounds remote hydration concurrency and omits page-wide output overflow', async () => { - const { handler, requestHandlers } = createHandlerHarness() - const refs = Array.from({ length: 3 }, (_, index) => ({ - id: `run-${index}`, - run_at: `2026-05-15T09:00:0${index}` - })) - let active = 0 - let maxActive = 0 - const handlerInternals = handler as unknown as { - readHermesRunRefs: () => Promise<{ refs: typeof refs; saturated: boolean }> - hydrateHermesRunRef: ( - jobId: string, - ref: (typeof refs)[number] - ) => Promise<{ id: string; output_content: string }> - } - handlerInternals.readHermesRunRefs = vi.fn().mockResolvedValue({ refs, saturated: false }) - handlerInternals.hydrateHermesRunRef = vi.fn(async (_jobId, ref) => { - active += 1 - maxActive = Math.max(maxActive, active) - await new Promise((resolve) => setTimeout(resolve, 1)) - active -= 1 - return { id: ref.id, output_content: 'x'.repeat(16 * 1024 * 1024 + 1) } - }) - - const result = (await requestHandlers.get('externalAutomations.runs')?.({ - provider: 'hermes', - jobId: 'job-1', - page: 1, - pageSize: 3 - })) as { - runs: { id: string; output_content: string | null; error?: string }[] - } - - expect(maxActive).toBe(2) - expect(result.runs.map((run) => run.id)).toEqual(refs.map((ref) => ref.id)) - expect(result.runs[0]?.output_content).toHaveLength(16 * 1024 * 1024 + 1) - expect(result.runs.slice(1)).toEqual([ - expect.objectContaining({ output_content: null, error: expect.stringContaining('omitted') }), - expect.objectContaining({ output_content: null, error: expect.stringContaining('omitted') }) - ]) - }) - - it('omits an oversized sparse remote markdown output without reading it wholesale', async () => { - const tempDir = await mkdtemp(join(tmpdir(), 'orca-relay-hermes-output-')) - tempDirs.push(tempDir) - const outputPath = join(tempDir, '2026-05-15_09-00-00.md') - await writeFile(outputPath, '', 'utf-8') - await truncate(outputPath, 64 * 1024 * 1024) - const { handler } = createHandlerHarness() - const handlerInternals = handler as unknown as { - readHermesOutputFileRun: (ref: Record) => Promise - } - - const run = await handlerInternals.readHermesOutputFileRun({ - kind: 'output', - id: 'job-1:2026-05-15_09-00-00.md', - job_id: 'job-1', - run_at: '2026-05-15T09:00:00', - run_key: '20260515_090000', - output_path: outputPath - }) - - expect(run).toMatchObject({ - output_content: null, - error: expect.stringContaining('File too large') - }) - }) - it('uses a count-only path for remote Hermes manager listing run counts', async () => { const { handler, requestHandlers } = createHandlerHarness() const handlerInternals = handler as unknown as { - readHermesRunCount: (jobId: string) => Promise<{ total: number }> + readHermesRunCount: (jobId: string) => Promise } - handlerInternals.readHermesRunCount = vi.fn().mockResolvedValue({ total: 42 }) + handlerInternals.readHermesRunCount = vi.fn().mockResolvedValue(42) const result = (await requestHandlers.get('externalAutomations.runs')?.({ provider: 'hermes', @@ -190,34 +108,12 @@ describe('ExternalAutomationsHandler', () => { expect(result).toEqual({ total: 42, runs: [] }) }) - it('reports when the remote run total is saturated', async () => { - const { handler, requestHandlers } = createHandlerHarness() - const handlerInternals = handler as unknown as { - readHermesRunCount: () => Promise<{ total: number; totalSaturated: true }> - } - handlerInternals.readHermesRunCount = vi - .fn() - .mockResolvedValue({ total: 10_000, totalSaturated: true }) - - await expect( - requestHandlers.get('externalAutomations.runs')?.({ - provider: 'hermes', - jobId: 'job-1', - page: 1, - pageSize: 0 - }) - ).resolves.toEqual({ total: 10_000, totalSaturated: true, runs: [] }) - }) - it('deduplicates concurrent remote Hermes count reads', async () => { const { handler, requestHandlers } = createHandlerHarness() - let resolveRefs: (result: { - refs: { id: string; run_at: string }[] - saturated: boolean - }) => void = () => {} + let resolveRefs: (refs: { id: string; run_at: string }[]) => void = () => {} const readHermesRunRefs = vi.fn( () => - new Promise<{ refs: { id: string; run_at: string }[]; saturated: boolean }>((resolve) => { + new Promise<{ id: string; run_at: string }[]>((resolve) => { resolveRefs = resolve }) ) @@ -240,13 +136,10 @@ describe('ExternalAutomationsHandler', () => { }) expect(readHermesRunRefs).toHaveBeenCalledTimes(1) - resolveRefs({ - refs: [ - { id: 'job-1:2026-05-15_09-00-00.md', run_at: '2026-05-15T09:00:00' }, - { id: 'job-1:2026-05-16_09-00-00.md', run_at: '2026-05-16T09:00:00' } - ], - saturated: false - }) + resolveRefs([ + { id: 'job-1:2026-05-15_09-00-00.md', run_at: '2026-05-15T09:00:00' }, + { id: 'job-1:2026-05-16_09-00-00.md', run_at: '2026-05-16T09:00:00' } + ]) await expect(Promise.all([first, second])).resolves.toEqual([ { total: 2, runs: [] }, @@ -258,17 +151,13 @@ describe('ExternalAutomationsHandler', () => { const { handler, requestHandlers } = createHandlerHarness() const readHermesRunRefs = vi .fn() - .mockResolvedValueOnce({ - refs: [{ id: 'job-1:2026-05-15_09-00-00.md', run_at: '2026-05-15T09:00:00' }], - saturated: false - }) - .mockResolvedValueOnce({ - refs: [ - { id: 'job-1:2026-05-15_09-00-00.md', run_at: '2026-05-15T09:00:00' }, - { id: 'job-1:2026-05-16_09-00-00.md', run_at: '2026-05-16T09:00:00' } - ], - saturated: false - }) + .mockResolvedValueOnce([ + { id: 'job-1:2026-05-15_09-00-00.md', run_at: '2026-05-15T09:00:00' } + ]) + .mockResolvedValueOnce([ + { id: 'job-1:2026-05-15_09-00-00.md', run_at: '2026-05-15T09:00:00' }, + { id: 'job-1:2026-05-16_09-00-00.md', run_at: '2026-05-16T09:00:00' } + ]) const handlerInternals = handler as unknown as { readHermesRunRefs: typeof readHermesRunRefs } @@ -311,11 +200,8 @@ describe('ExternalAutomationsHandler', () => { const { handler, requestHandlers } = createHandlerHarness() const readHermesRunRefs = vi.fn(async (jobId: string) => jobId === 'job-0' - ? { - refs: [{ id: 'job-0:2026-05-15_09-00-00.md', run_at: '2026-05-15T09:00:00' }], - saturated: false - } - : { refs: [], saturated: false } + ? [{ id: 'job-0:2026-05-15_09-00-00.md', run_at: '2026-05-15T09:00:00' }] + : [] ) const handlerInternals = handler as unknown as { readHermesRunRefs: typeof readHermesRunRefs diff --git a/src/relay/external-automations-handler.ts b/src/relay/external-automations-handler.ts index 34d7486a675..baaaa99481e 100644 --- a/src/relay/external-automations-handler.ts +++ b/src/relay/external-automations-handler.ts @@ -2,28 +2,11 @@ * run history, and actions must stay co-located behind one relay request handler. */ import { execFile } from 'node:child_process' import { existsSync } from 'node:fs' -import { open, opendir, realpath, stat } from 'node:fs/promises' +import { open, readdir, readFile, realpath, stat } from 'node:fs/promises' import { createRequire } from 'node:module' import { homedir } from 'node:os' import { isAbsolute, join, relative, resolve, sep } from 'node:path' import { promisify } from 'node:util' -import { readExternalAutomationJobsFile } from '../shared/external-automation-jobs-file' -import { - type BoundedHermesRunRefs, - HERMES_RUN_REF_MAX_ENTRIES, - HERMES_SESSION_RUN_REFS_SELECT_SQL, - HermesRunRefRetainer -} from '../shared/hermes-run-ref-retention' -import { - formatHermesSessionMessagesWithinLimits, - HERMES_PRIMARY_OUTPUT_MAX_BYTES, - HERMES_RUN_PAGE_MAX_RUNS, - HERMES_SESSION_TRANSCRIPT_SELECT_SQL, - HERMES_SESSION_TRANSCRIPT_TRUNCATED_ERROR, - hydrateHermesRunPageWithinLimits -} from '../shared/hermes-run-output-limits' -import { readNodeFileWithinLimit } from '../shared/node-bounded-file-reader' -import { mapWithConcurrency } from '../shared/map-with-concurrency' import type { RelayDispatcher } from './dispatcher' const execFileAsync = promisify(execFile) @@ -40,7 +23,6 @@ const HERMES_RUN_KEY_PATTERN = /^(\d{4})(\d{2})(\d{2})_(\d{2})(\d{2})(\d{2})$/ const MAX_SESSION_OUTPUT_GAP_MS = 24 * 60 * 60 * 1000 const MAX_REFERENCED_LOG_BYTES = 5 * 1024 * 1024 const HERMES_RUN_COUNT_CACHE_MAX_ENTRIES = 200 -const HERMES_JOB_RUN_COUNT_CONCURRENCY = 4 const FULL_SESSION_LOG_HEADING = '## Full session log' const REFERENCED_LOG_HEADING = '## Latest log file' const LATEST_LOG_PATH_PATTERN = @@ -48,7 +30,6 @@ const LATEST_LOG_PATH_PATTERN = type SqliteStatement = { get: (...args: unknown[]) => Record | undefined all: (...args: unknown[]) => Record[] - iterate?: (...args: unknown[]) => Iterable> } type SqliteDatabase = { prepare: (sql: string) => SqliteStatement @@ -90,10 +71,9 @@ type HermesMergedRunRef = { session: HermesSessionRunRef | null } type HermesRunCountCacheEntry = { - promise: Promise + promise: Promise expiresAt: number } -type HermesRunCount = { total: number; totalSaturated?: true } const HERMES_RUN_COUNT_CACHE_TTL_MS = 2000 @@ -126,27 +106,37 @@ export class ExternalAutomationsHandler { if (!existsSync(jobsFile)) { return [] } - const jobs = await readExternalAutomationJobsFile(jobsFile, { allowRootArray: true }) + const content = await readFile(jobsFile, 'utf-8') + const parsed = JSON.parse(content) as unknown + const jobs = Array.isArray(parsed) + ? parsed + : typeof parsed === 'object' && + parsed !== null && + !Array.isArray(parsed) && + Array.isArray((parsed as { jobs?: unknown }).jobs) + ? (parsed as { jobs: unknown[] }).jobs + : [] if (provider !== 'hermes') { return jobs } - return mapWithConcurrency(jobs, HERMES_JOB_RUN_COUNT_CONCURRENCY, async (job) => { - if (!this.isRecord(job) || typeof job.id !== 'string') { - return job - } - const runsPage = await this.listRuns({ - provider: 'hermes', - jobId: job.id, - page: 1, - pageSize: 0 + return Promise.all( + jobs.map(async (job) => { + if (!this.isRecord(job) || typeof job.id !== 'string') { + return job + } + const runsPage = await this.listRuns({ + provider: 'hermes', + jobId: job.id, + page: 1, + pageSize: 0 + }) + return { + ...job, + run_count: runsPage.total, + runs: runsPage.runs + } }) - return { - ...job, - run_count: runsPage.total, - ...(runsPage.totalSaturated ? { run_count_saturated: true } : {}), - runs: runsPage.runs - } - }) + ) } private isRecord(value: unknown): value is Record { @@ -269,10 +259,9 @@ export class ExternalAutomationsHandler { return null } if (logStat.size <= MAX_REFERENCED_LOG_BYTES) { - const { buffer } = await readNodeFileWithinLimit(logPath, MAX_REFERENCED_LOG_BYTES) return { path: logPath, - content: buffer.toString('utf-8'), + content: await readFile(logPath, 'utf-8'), truncated: false } } @@ -324,6 +313,31 @@ export class ExternalAutomationsHandler { .join('\n\n') } + private formatSessionMessages(messages: Record[]): string | null { + if (messages.length === 0) { + return null + } + return messages + .map((message) => { + const role = typeof message.role === 'string' ? message.role : 'message' + const content = typeof message.content === 'string' ? message.content.trim() : '' + const toolName = typeof message.tool_name === 'string' ? message.tool_name.trim() : '' + const reasoning = + typeof message.reasoning_content === 'string' + ? message.reasoning_content.trim() + : typeof message.reasoning === 'string' + ? message.reasoning.trim() + : '' + const parts = [ + `## ${role}${toolName ? ` / ${toolName}` : ''}`, + reasoning ? `### Reasoning\n\n${reasoning}` : null, + content || '(empty)' + ].filter(Boolean) + return parts.join('\n\n') + }) + .join('\n\n---\n\n') + } + private getRunKey(run: unknown): string | null { return this.isRecord(run) && typeof run.run_key === 'string' && run.run_key.trim() ? run.run_key @@ -521,20 +535,19 @@ export class ExternalAutomationsHandler { return databaseConstructor } - private async readHermesRunRefs( - jobId: string - ): Promise> { + private async readHermesRunRefs(jobId: string): Promise { const outputRuns = await this.readHermesOutputFileRunRefs(jobId) - const sessionRuns = this.readHermesSessionDbRunRefs(jobId) - const mergedRetainer = new HermesRunRefRetainer() - for (const ref of this.mergeHermesOutputAndSessionRunRefs(outputRuns.refs, sessionRuns.refs)) { - mergedRetainer.add(ref) - } - const merged = mergedRetainer.finish() - return { - refs: merged.refs, - saturated: outputRuns.saturated || sessionRuns.saturated || merged.saturated - } + return this.mergeHermesOutputAndSessionRunRefs( + outputRuns, + this.readHermesSessionDbRunRefs(jobId) + ).sort((a, b) => { + const aTime = this.getRawRunTime(a) + const bTime = this.getRawRunTime(b) + if (Number.isFinite(aTime) && Number.isFinite(bTime)) { + return bTime - aTime + } + return this.getRawRunId(b).localeCompare(this.getRawRunId(a)) + }) } private async hydrateHermesRunRef(jobId: string, ref: HermesMergedRunRef): Promise { @@ -551,9 +564,9 @@ export class ExternalAutomationsHandler { ) } - private async readHermesRunCount(jobId: string): Promise { + private async readHermesRunCount(jobId: string): Promise { if (!EXTERNAL_JOB_ID_PATTERN.test(jobId)) { - return { total: 0 } + return 0 } const now = Date.now() const cached = this.hermesRunCountCache.get(jobId) @@ -567,10 +580,7 @@ export class ExternalAutomationsHandler { // processes are long-lived, so stale job ids need both TTL and a hard cap. this.pruneHermesRunCountCache(now) const entry: HermesRunCountCacheEntry = { - promise: this.readHermesRunRefs(jobId).then((result) => ({ - total: result.refs.length, - ...(result.saturated ? { totalSaturated: true as const } : {}) - })), + promise: this.readHermesRunRefs(jobId).then((refs) => refs.length), expiresAt: Number.POSITIVE_INFINITY } this.hermesRunCountCache.set(jobId, entry) @@ -611,7 +621,6 @@ export class ExternalAutomationsHandler { private async listRuns(params: Record = {}): Promise<{ total: number - totalSaturated?: true runs: unknown[] }> { const provider = params.provider === 'openclaw' ? 'openclaw' : 'hermes' @@ -622,7 +631,7 @@ export class ExternalAutomationsHandler { : 1 const pageSize = typeof params.pageSize === 'number' && Number.isFinite(params.pageSize) - ? Math.min(HERMES_RUN_PAGE_MAX_RUNS, Math.max(0, Math.floor(params.pageSize))) + ? Math.min(100, Math.max(0, Math.floor(params.pageSize))) : 25 if (provider !== 'hermes') { return { total: 0, runs: [] } @@ -633,52 +642,53 @@ export class ExternalAutomationsHandler { if (pageSize === 0) { // Why: manager listing only needs a badge count; hydrating markdown logs // and full session transcripts can make opening Automations very slow. - return { ...(await this.readHermesRunCount(jobId)), runs: [] } + return { total: await this.readHermesRunCount(jobId), runs: [] } } const runRefs = await this.readHermesRunRefs(jobId) const start = (page - 1) * pageSize return { - total: runRefs.refs.length, - ...(runRefs.saturated ? { totalSaturated: true } : {}), - runs: await hydrateHermesRunPageWithinLimits( - runRefs.refs.slice(start, start + pageSize), - (ref) => this.hydrateHermesRunRef(jobId, ref) + total: runRefs.length, + runs: await Promise.all( + runRefs.slice(start, start + pageSize).map((ref) => this.hydrateHermesRunRef(jobId, ref)) ) } } - private async readHermesOutputFileRunRefs( - jobId: string - ): Promise> { + private getRawRunId(run: unknown): string { + if (this.isRecord(run) && 'id' in run) { + return String(run.id) + } + return '' + } + + private getRawRunTime(run: unknown): number { + if (!this.isRecord(run) || !('run_at' in run)) { + return Number.NaN + } + return typeof run.run_at === 'string' ? Date.parse(run.run_at) : Number.NaN + } + + private async readHermesOutputFileRunRefs(jobId: string): Promise { const outputDir = join(HERMES_OUTPUT_DIR, jobId) if (!existsSync(outputDir)) { - return { refs: [], saturated: false } + return [] } - const retainer = new HermesRunRefRetainer() - const directory = await opendir(outputDir) - for await (const entry of directory) { - if (!entry.isFile() || !HERMES_OUTPUT_FILE_PATTERN.test(entry.name)) { - continue - } - retainer.add({ + const entries = await readdir(outputDir, { withFileTypes: true }) + return entries + .filter((entry) => entry.isFile() && HERMES_OUTPUT_FILE_PATTERN.test(entry.name)) + .map((entry) => ({ kind: 'output' as const, id: `${jobId}:${entry.name}`, job_id: jobId, run_at: this.runAtFromHermesOutputFile(entry.name), run_key: this.runKeyFromHermesOutputFile(entry.name), output_path: join(outputDir, entry.name) - }) - } - return retainer.finish() + })) } private async readHermesOutputFileRun(ref: HermesOutputRunRef): Promise { try { - const { buffer } = await readNodeFileWithinLimit( - ref.output_path, - HERMES_PRIMARY_OUTPUT_MAX_BYTES - ) - const content = buffer.toString('utf-8') + const content = await readFile(ref.output_path, 'utf-8') const parsed = this.parseHermesOutput(content) const outputContent = await this.appendReferencedLogFile(parsed.outputContent) return { @@ -707,45 +717,41 @@ export class ExternalAutomationsHandler { } } - private readHermesSessionDbRunRefs(jobId: string): BoundedHermesRunRefs { + private readHermesSessionDbRunRefs(jobId: string): HermesSessionRunRef[] { if (!existsSync(HERMES_STATE_DB)) { - return { refs: [], saturated: false } + return [] } const Database = this.getDatabaseConstructor() if (!Database) { - return { refs: [], saturated: false } + return [] } try { const db = new Database(HERMES_STATE_DB, { readonly: true, fileMustExist: true }) try { const pattern = `cron\\_${this.escapeSqlLike(jobId)}\\_%` - const statement = db.prepare(HERMES_SESSION_RUN_REFS_SELECT_SQL) - const rows = - typeof statement.iterate === 'function' - ? statement.iterate(pattern) - : statement.all(pattern) - const retainer = new HermesRunRefRetainer() - let read = 0 - for (const row of rows) { - if (read >= HERMES_RUN_REF_MAX_ENTRIES + 1) { - break - } - read += 1 + const rows = db + .prepare( + `SELECT id, started_at + FROM sessions + WHERE id LIKE ? ESCAPE '\\' + ORDER BY started_at DESC` + ) + .all(pattern) as Record[] + return rows.map((row) => { const runId = typeof row.id === 'string' ? row.id : `${jobId}:${String(row.started_at)}` - retainer.add({ + return { kind: 'session', id: runId, job_id: jobId, run_at: this.runAtFromUnixSeconds(row.started_at), run_key: runId.split(`${jobId}_`).at(-1) ?? null - }) - } - return retainer.finish() + } + }) } finally { db.close() } } catch { - return { refs: [], saturated: false } + return [] } } @@ -771,12 +777,14 @@ export class ExternalAutomationsHandler { if (!row) { return null } - const messageStatement = db.prepare(HERMES_SESSION_TRANSCRIPT_SELECT_SQL) - const messages = - typeof messageStatement.iterate === 'function' - ? messageStatement.iterate(runId) - : messageStatement.all(runId) - const formattedMessages = formatHermesSessionMessagesWithinLimits(messages) + const messages = db + .prepare( + `SELECT role, content, tool_name, reasoning, reasoning_content + FROM messages + WHERE session_id = ? + ORDER BY timestamp, id` + ) + .all(runId) as Record[] const title = typeof row.title === 'string' && row.title.trim() ? row.title.trim() : null const model = typeof row.model === 'string' && row.model.trim() ? row.model.trim() : null const messageCount = typeof row.message_count === 'number' ? row.message_count : null @@ -796,8 +804,8 @@ export class ExternalAutomationsHandler { run_key: runId.split(`${jobId}_`).at(-1) ?? null, status: typeof row.ended_at === 'number' ? 'completed' : 'unknown', output_preview: summaryParts.join(' · ') || null, - output_content: formattedMessages.content, - error: formattedMessages.truncated ? HERMES_SESSION_TRANSCRIPT_TRUNCATED_ERROR : null, + output_content: this.formatSessionMessages(messages), + error: null, output_path: HERMES_STATE_DB } } finally { diff --git a/src/relay/filesystem-directory-reader.test.ts b/src/relay/filesystem-directory-reader.test.ts deleted file mode 100644 index 6a7d4387a64..00000000000 --- a/src/relay/filesystem-directory-reader.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { FILESYSTEM_DIRECTORY_LIMIT_MESSAGE } from '../shared/filesystem-directory-listing-limit' -import { collectRelayFilesystemDirectoryEntries } from './filesystem-directory-reader' - -function entry(name: string, options?: { directory?: boolean; symlink?: boolean }) { - return { - name, - isDirectory: () => options?.directory ?? false, - isSymbolicLink: () => options?.symlink ?? false - } -} - -describe('relay filesystem directory reader', () => { - it('preserves directory-first ordering and symlink-directory classification', async () => { - const classify = vi.fn(async (_dirPath, source) => - source.isSymbolicLink() ? true : source.isDirectory() - ) - - await expect( - collectRelayFilesystemDirectoryEntries( - '/repo', - [ - entry('z.txt'), - entry('linked', { symlink: true }), - entry('beta', { directory: true }), - entry('alpha', { directory: true }) - ], - undefined, - classify - ) - ).resolves.toEqual([ - { name: 'alpha', isDirectory: true, isSymlink: false }, - { name: 'beta', isDirectory: true, isSymlink: false }, - { name: 'linked', isDirectory: true, isSymlink: true }, - { name: 'z.txt', isDirectory: false, isSymlink: false } - ]) - expect(classify).toHaveBeenCalledTimes(4) - }) - - it('stops before classifying or retaining the first over-limit entry', async () => { - let enumerated = 0 - const classify = vi.fn(async () => false) - const source = { - async *[Symbol.asyncIterator]() { - while (enumerated < 100) { - enumerated += 1 - yield entry(`entry-${enumerated}`) - } - } - } - - await expect( - collectRelayFilesystemDirectoryEntries( - '/repo', - source, - { maxEntries: 3, maxRetainedBytes: 1024 }, - classify - ) - ).rejects.toThrow(FILESYSTEM_DIRECTORY_LIMIT_MESSAGE) - expect(enumerated).toBe(4) - expect(classify).toHaveBeenCalledTimes(3) - }) -}) diff --git a/src/relay/filesystem-directory-reader.ts b/src/relay/filesystem-directory-reader.ts deleted file mode 100644 index e6c6d6e46c4..00000000000 --- a/src/relay/filesystem-directory-reader.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { opendir, stat } from 'node:fs/promises' -import { join } from 'node:path' -import { - createFilesystemDirectoryLimitState, - trackFilesystemDirectoryEntry, - type FilesystemDirectoryListingLimits -} from '../shared/filesystem-directory-listing-limit' - -type RelayDirectorySourceEntry = { - name: string - isDirectory(): boolean - isSymbolicLink(): boolean -} - -export type RelayFilesystemDirectoryEntry = { - name: string - isDirectory: boolean - isSymlink: boolean -} - -type DirectoryClassifier = (dirPath: string, entry: RelayDirectorySourceEntry) => Promise - -async function isRelayFilesystemDirectoryEntry( - dirPath: string, - entry: RelayDirectorySourceEntry -): Promise { - if (entry.isDirectory()) { - return true - } - if (!entry.isSymbolicLink()) { - return false - } - try { - // Why: linked directories must remain expandable remotely. - return (await stat(join(dirPath, entry.name))).isDirectory() - } catch { - return false - } -} - -export async function readRelayFilesystemDirectory( - dirPath: string, - requestedLimits?: Partial -): Promise { - return collectRelayFilesystemDirectoryEntries(dirPath, await opendir(dirPath), requestedLimits) -} - -export async function collectRelayFilesystemDirectoryEntries( - dirPath: string, - directory: AsyncIterable | Iterable, - requestedLimits?: Partial, - classifyDirectory: DirectoryClassifier = isRelayFilesystemDirectoryEntry -): Promise { - const entries: RelayFilesystemDirectoryEntry[] = [] - const limit = createFilesystemDirectoryLimitState(requestedLimits) - for await (const entry of directory) { - trackFilesystemDirectoryEntry(limit, entry) - entries.push({ - name: entry.name, - isDirectory: await classifyDirectory(dirPath, entry), - isSymlink: entry.isSymbolicLink() - }) - } - return entries.sort((left, right) => { - if (left.isDirectory !== right.isDirectory) { - return left.isDirectory ? -1 : 1 - } - return left.name.localeCompare(right.name) - }) -} diff --git a/src/relay/fs-handler-file-read.ts b/src/relay/fs-handler-file-read.ts index 28670f1b26a..003172c08fd 100644 --- a/src/relay/fs-handler-file-read.ts +++ b/src/relay/fs-handler-file-read.ts @@ -1,12 +1,5 @@ -import { open, stat } from 'node:fs/promises' +import { open, readFile, stat } from 'node:fs/promises' import { extname } from 'node:path' -import { readNodeFileWithinLimit } from '../shared/node-bounded-file-reader' -import { - assertRasterImagePreviewWithinLimits, - isKnownRasterImageMimeType, - RASTER_IMAGE_PREVIEW_HEADER_MAX_BYTES -} from '../shared/raster-image-preview-limits' -import type { RasterImageDimensions } from '../shared/raster-image-dimensions' import type { RelayDispatcher, RequestContext } from './dispatcher' import { STREAM_ACK_WINDOW_CHUNKS, STREAM_CHUNK_SIZE, RelayErrorCode } from './protocol' import type { RelayStreamRegistry, TooManyStreamsError } from './fs-stream-registry' @@ -30,22 +23,15 @@ export async function readRelayFileContent(filePath: string) { } if (mimeType) { - const { buffer } = await readNodeFileWithinLimit(filePath, sizeLimit) - const imageDimensions = assertRasterImagePreviewWithinLimits(buffer, mimeType) - return { - content: buffer.toString('base64'), - isBinary: true, - isImage: true, - mimeType, - ...(imageDimensions ? { imageDimensions } : {}) - } + const buffer = await readFile(filePath) + return { content: buffer.toString('base64'), isBinary: true, isImage: true, mimeType } } if (stats.size > BINARY_PROBE_BYTES && (await isBinaryFilePrefix(filePath))) { return { content: '', isBinary: true } } - const { buffer } = await readNodeFileWithinLimit(filePath, sizeLimit) + const buffer = await readFile(filePath) if (isBinaryBuffer(buffer)) { return { content: '', isBinary: true } } @@ -58,7 +44,6 @@ export type StreamMetadata = { isBinary: boolean isImage?: boolean mimeType?: string - imageDimensions?: RasterImageDimensions /** On-the-wire encoding of each chunk's `data` field. Always 'base64'. */ chunkEncoding?: 'base64' /** Encoding of the assembled FileReadResult.content. */ @@ -120,38 +105,31 @@ export async function readRelayFileStreamMetadata( const handle = await open(filePath, 'r') let streamId: number try { - let imageDimensions: RasterImageDimensions | undefined - if (isKnownRasterImageMimeType(mimeType)) { - const header = Buffer.alloc(Math.min(stats.size, RASTER_IMAGE_PREVIEW_HEADER_MAX_BYTES)) - const headerBytesRead = await readFullStreamChunk(handle, header, header.length, 0) - imageDimensions = assertRasterImagePreviewWithinLimits( - header.subarray(0, headerBytesRead), - mimeType - ) - } - streamId = registry.register(handle, context.clientId) - process.stderr.write(`[relay] stream start id=${streamId} size=${stats.size}\n`) - - // Why: start after metadata returns so the subscribed client cannot miss the first chunk. - const resolvedPumpOptions = pumpOptions ?? { paceWithAcks: false } - setImmediate(() => { - void pumpChunks(streamId, stats.size, dispatcher, registry, context, resolvedPumpOptions) - }) - - return { - streamId, - totalSize: stats.size, - isBinary: !!mimeType, - isImage: mimeType ? true : undefined, - mimeType, - ...(imageDimensions ? { imageDimensions } : {}), - chunkEncoding: 'base64', - resultEncoding: mimeType ? 'base64' : 'utf-8' - } + streamId = registry.register(handle) } catch (err) { await handle.close() throw err } + + process.stderr.write(`[relay] stream start id=${streamId} size=${stats.size}\n`) + + // Why: pumpChunks owns its own try/finally for handle release; the outer + // setImmediate kicks the pump off the metadata-response task so the client + // sees the response before the first chunk frame. + const resolvedPumpOptions = pumpOptions ?? { paceWithAcks: false } + setImmediate(() => { + void pumpChunks(streamId, stats.size, dispatcher, registry, context, resolvedPumpOptions) + }) + + return { + streamId, + totalSize: stats.size, + isBinary: !!mimeType, + isImage: mimeType ? true : undefined, + mimeType, + chunkEncoding: 'base64', + resultEncoding: mimeType ? 'base64' : 'utf-8' + } } async function pumpChunks( @@ -225,7 +203,6 @@ async function pumpChunks( // Why: the bulk lane waits out sink saturation, so a flood of chunk // frames cannot pile up in the outbound pipe ahead of interactive // pty.data frames written via plain notify(). - registry.recordSent(streamId, seq) await dispatcher.notifyBulk( 'fs.streamChunk', { streamId, seq, data }, @@ -250,23 +227,14 @@ async function pumpChunks( try { if (endReason === 'end') { - if (pumpOptions.clientId !== undefined) { - dispatcher.notifyClient(pumpOptions.clientId, 'fs.streamEnd', { streamId }) - } else { - dispatcher.notify('fs.streamEnd', { streamId }) - } + dispatcher.notify('fs.streamEnd', { streamId }) process.stderr.write(`[relay] stream end id=${streamId}\n`) } else if (endReason === 'error') { - const params = { + dispatcher.notify('fs.streamError', { streamId, code: errorCode ?? 'ESTREAMERROR', message: errorMessage ?? 'stream error' - } - if (pumpOptions.clientId !== undefined) { - dispatcher.notifyClient(pumpOptions.clientId, 'fs.streamError', params) - } else { - dispatcher.notify('fs.streamError', params) - } + }) process.stderr.write(`[relay] stream error id=${streamId} code=${errorCode}\n`) } else if (endReason === 'aborted') { process.stderr.write(`[relay] stream cancel id=${streamId}\n`) diff --git a/src/relay/fs-handler-git-fallback.ts b/src/relay/fs-handler-git-fallback.ts index 66f0e48d2ad..8de2ad3394c 100644 --- a/src/relay/fs-handler-git-fallback.ts +++ b/src/relay/fs-handler-git-fallback.ts @@ -27,14 +27,6 @@ import { SEARCH_TIMEOUT_MS } from '../shared/text-search' import { buildRelayGitEnv } from './relay-command-env' -import { SearchSubprocessLineAccumulator } from '../shared/search-subprocess-lines' -import { - createQuickOpenListingBudget, - QUICK_OPEN_LISTING_MAX_PATH_BYTES, - QuickOpenSubprocessPathAccumulator, - resolveQuickOpenResultLimit, - retainQuickOpenPath -} from '../shared/quick-open-listing-limits' /** * List files using `git ls-files`. Fallback when rg is not installed. @@ -54,14 +46,9 @@ export function listFilesWithGit( if (signal?.aborted) { return Promise.reject(fileListingCancellationError(signal)) } - const resultLimit = resolveQuickOpenResultLimit(maxResults) - if (resultLimit === 0) { - return Promise.resolve([]) - } const gitPaths = new Set() const directoryPaths = new Set() const directFileCandidates = new Set() - const listingBudget = createQuickOpenListingBudget() const { primary, ignoredPass } = buildGitLsFilesArgsForQuickOpen(excludePathPrefixes) const children: { child: ReturnType @@ -72,7 +59,7 @@ export function listFilesWithGit( const runGitLsFiles = (args: string[]): Promise => { return new Promise((resolve, reject) => { - const paths = new QuickOpenSubprocessPathAccumulator(0) + let buf = '' let done = false const processPath = (path: string): boolean => { @@ -80,23 +67,27 @@ export function listFilesWithGit( return false } if (path.endsWith('/')) { - retainQuickOpenPath(directoryPaths, path, listingBudget) + directoryPaths.add(path) } else { - retainQuickOpenPath(gitPaths, path, listingBudget) - const parsed = parseQuickOpenGitLsFilesEntry(path) - const relPath = parsed.path.replace(/\/+$/, '') - if ( - !parsed.isGitlink && - !parsed.isUntrackedDir && - shouldIncludeQuickOpenPath(relPath) && - !shouldExcludeQuickOpenRelPath(relPath, excludePathPrefixes) - ) { - retainQuickOpenPath(directFileCandidates, relPath, listingBudget) + gitPaths.add(path) + if (maxResults !== undefined) { + // Why: this duplicate classification exists only to stop bounded + // scans; unbounded SSH scans must not retain another full listing. + const parsed = parseQuickOpenGitLsFilesEntry(path) + const relPath = parsed.path.replace(/\/+$/, '') + if ( + !parsed.isGitlink && + !parsed.isUntrackedDir && + shouldIncludeQuickOpenPath(relPath) && + !shouldExcludeQuickOpenRelPath(relPath, excludePathPrefixes) + ) { + directFileCandidates.add(relPath) + } } } // Why: placeholders need IO classification and can disappear; only // guaranteed final files are allowed to stop the remote Git processes. - return directFileCandidates.size >= resultLimit + return maxResults !== undefined && directFileCandidates.size >= maxResults } const child = spawn('git', ['ls-files', ...args], { @@ -120,7 +111,7 @@ export function listFilesWithGit( return } done = true - paths.clear() + buf = '' cleanup() reject(error) } @@ -129,7 +120,6 @@ export function listFilesWithGit( return } done = true - paths.clear() cleanup() resolve() } @@ -140,23 +130,20 @@ export function listFilesWithGit( resolve: resolvePass }) - function failForOutput(error: unknown): void { - child.kill() - rejectPass(error instanceof Error ? error : new Error(String(error))) - } - function handleStdoutData(chunk: Buffer | string): void { - try { - const outcome = paths.push(chunk, (path) => !processPath(path)) - if (outcome === 'stopped') { + function handleStdoutData(chunk: string): void { + buf += chunk + let start = 0 + let idx = buf.indexOf('\0', start) + while (idx !== -1) { + if (processPath(buf.substring(start, idx))) { + buf = '' finishAtLimit() - } else if (outcome === 'path-too-large') { - failForOutput( - new Error(`Quick Open file path exceeded ${QUICK_OPEN_LISTING_MAX_PATH_BYTES} bytes`) - ) + return } - } catch (error) { - failForOutput(error) + start = idx + 1 + idx = buf.indexOf('\0', start) } + buf = start < buf.length ? buf.substring(start) : '' } function handleStderrData(): void { /* drain */ @@ -175,14 +162,9 @@ export function listFilesWithGit( rejectPass(new Error(`git ls-files killed by ${signal}`)) return } - try { - const trailingPath = paths.finish() - if (trailingPath && processPath(trailingPath)) { - finishAtLimit() - return - } - } catch (error) { - failForOutput(error) + if (buf && processPath(buf)) { + buf = '' + finishAtLimit() return } if (code === 0) { @@ -195,6 +177,7 @@ export function listFilesWithGit( rejectPass(new Error(`git ls-files exited with code ${code}`)) } + child.stdout!.setEncoding('utf-8') child.stdout!.on('data', handleStdoutData) child.stderr!.on('data', handleStderrData) child.once('error', handleError) @@ -249,9 +232,12 @@ export function listFilesWithGit( ) } }) - const passes = runGitLsFiles(primary).then(() => - directFileCandidates.size < resultLimit ? runIgnoredPass() : Promise.resolve() - ) + const passes = + maxResults === undefined + ? Promise.all([runGitLsFiles(primary), runIgnoredPass()]) + : runGitLsFiles(primary).then(() => + directFileCandidates.size < maxResults ? runIgnoredPass() : Promise.resolve() + ) return passes .then(async () => { @@ -261,11 +247,11 @@ export function listFilesWithGit( directoryPaths, excludePathPrefixes, signal, - maxResults: resultLimit + maxResults }) // Why: directory placeholders are expanded after Git exits; restore // Git's path order for empty queries and fuzzy-score ties over SSH. - return files.sort().slice(0, resultLimit) + return files.sort().slice(0, maxResults) }) .catch((err) => { killSurvivors('git ls-files canceled after sibling failure') @@ -291,7 +277,7 @@ export function searchWithGitGrep( const gitArgs = buildGitGrepArgs(query, opts) const matchRegex = buildSubmatchRegex(query, opts) const acc = createAccumulator() - const stdoutLines = new SearchSubprocessLineAccumulator() + let stdoutBuffer = '' let done = false const child = spawn('git', gitArgs, { @@ -323,11 +309,12 @@ export function searchWithGitGrep( } } - function handleStdoutData(chunk: Buffer): void { - if (!stdoutLines.push(chunk, processLine)) { - acc.truncated = true - child.kill() - resolveOnce() + function handleStdoutData(chunk: string): void { + stdoutBuffer += chunk + const lines = stdoutBuffer.split('\n') + stdoutBuffer = lines.pop() ?? '' + for (const l of lines) { + processLine(l) } } @@ -340,13 +327,13 @@ export function searchWithGitGrep( } function handleClose(): void { - const trailingLine = stdoutLines.finish() - if (trailingLine !== null) { - processLine(trailingLine) + if (stdoutBuffer) { + processLine(stdoutBuffer) } resolveOnce() } + child.stdout!.setEncoding('utf-8') child.stdout!.on('data', handleStdoutData) child.stderr!.on('data', handleStderrData) child.once('error', handleError) diff --git a/src/relay/fs-handler-list-files-cancel.test.ts b/src/relay/fs-handler-list-files-cancel.test.ts index e30bf3cc498..02cd8da6afc 100644 --- a/src/relay/fs-handler-list-files-cancel.test.ts +++ b/src/relay/fs-handler-list-files-cancel.test.ts @@ -43,7 +43,7 @@ describe('relay list-files cancellation', () => { vi.useRealTimers() }) - it('listFilesWithRg kills the active rg pass and rejects when aborted mid-flight', async () => { + it('listFilesWithRg kills both rg passes and rejects when aborted mid-flight', async () => { const primaryProc = createMockProcess() const ignoredProc = createMockProcess() spawnMock.mockImplementation((_cmd: string, args: string[]) => @@ -59,8 +59,7 @@ describe('relay list-files cancellation', () => { await expect(promise).rejects.toSatisfy(isFileListingCancellation) expect(primaryProc.kill).toHaveBeenCalled() - expect(ignoredProc.kill).not.toHaveBeenCalled() - expect(spawnMock).toHaveBeenCalledTimes(1) + expect(ignoredProc.kill).toHaveBeenCalled() // Late close events after cancellation must not fire anything. primaryProc.emit('close', null, 'SIGTERM') @@ -90,16 +89,14 @@ describe('relay list-files cancellation', () => { setTimeout(() => { ;(primaryProc.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\n') primaryProc.emit('close', 0, null) - queueMicrotask(() => { - ;(ignoredProc.stdout as unknown as EventEmitter).emit('data', 'dist/out.js\n') - ignoredProc.emit('close', 0, null) - }) + ;(ignoredProc.stdout as unknown as EventEmitter).emit('data', 'dist/out.js\n') + ignoredProc.emit('close', 0, null) }, 5) await expect(promise).resolves.toEqual(['src/index.ts', 'dist/out.js']) }) - it('listFilesWithGit kills the active git pass and rejects when aborted mid-flight', async () => { + it('listFilesWithGit kills both git passes and rejects when aborted mid-flight', async () => { const procs: ChildProcess[] = [] spawnMock.mockImplementation(() => { const proc = createMockProcess() @@ -110,11 +107,12 @@ describe('relay list-files cancellation', () => { const controller = new AbortController() const promise = listFilesWithGit('/remote/root', [], { signal: controller.signal }) - expect(procs).toHaveLength(1) + expect(procs).toHaveLength(2) controller.abort() await expect(promise).rejects.toSatisfy(isFileListingCancellation) expect(procs[0].kill).toHaveBeenCalled() + expect(procs[1].kill).toHaveBeenCalled() }) it('listFilesWithGit rejects without spawning when the signal is already aborted', async () => { diff --git a/src/relay/fs-handler-list-files-ignored.test.ts b/src/relay/fs-handler-list-files-ignored.test.ts index acc1450cdc0..1ca5c2f5327 100644 --- a/src/relay/fs-handler-list-files-ignored.test.ts +++ b/src/relay/fs-handler-list-files-ignored.test.ts @@ -16,10 +16,6 @@ import { tmpdir } from 'node:os' import { listFilesWithGit } from './fs-handler-git-fallback' import { listFilesWithRg } from './fs-handler-list-files' import { searchWithRg } from './fs-handler-utils' -import { - QUICK_OPEN_LISTING_MAX_PATH_BYTES, - QUICK_OPEN_LISTING_MAX_RESULTS -} from '../shared/quick-open-listing-limits' const tempDirs: string[] = [] const SHA1 = '0123456789abcdef0123456789abcdef01234567' @@ -81,12 +77,10 @@ describe('relay quick open ignored file listing', () => { ;(primaryProc.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\n') primaryProc.emit('close', 0, null) - queueMicrotask(() => { - ;(ignoredProc.stdout as unknown as EventEmitter).emit('data', 'dist/generated.js\n') - ;(ignoredProc.stdout as unknown as EventEmitter).emit('data', 'node_modules/pkg/index.js\n') - ;(ignoredProc.stdout as unknown as EventEmitter).emit('data', 'packages/other/src/x.ts\n') - ignoredProc.emit('close', 0, null) - }) + ;(ignoredProc.stdout as unknown as EventEmitter).emit('data', 'dist/generated.js\n') + ;(ignoredProc.stdout as unknown as EventEmitter).emit('data', 'node_modules/pkg/index.js\n') + ;(ignoredProc.stdout as unknown as EventEmitter).emit('data', 'packages/other/src/x.ts\n') + ignoredProc.emit('close', 0, null) }, 10) await expect(promise).resolves.toEqual(['src/index.ts', 'dist/generated.js']) @@ -121,46 +115,6 @@ describe('relay quick open ignored file listing', () => { expect(callIndex).toBe(1) }) - it('bounds an omitted relay rg result limit without spawning the ignored pass', async () => { - const primary = createMockProcess() - const ignored = createMockProcess() - let callIndex = 0 - spawnMock.mockImplementation(() => (++callIndex === 1 ? primary : ignored)) - const promise = listFilesWithRg('/remote/root') - const paths = Array.from( - { length: QUICK_OPEN_LISTING_MAX_RESULTS + 1 }, - (_value, index) => `src/file-${index}.ts` - ) - - ;(primary.stdout as unknown as EventEmitter).emit('data', paths.join('\n')) - - const result = await promise - expect(result).toHaveLength(QUICK_OPEN_LISTING_MAX_RESULTS) - expect(primary.kill).toHaveBeenCalled() - expect(ignored.kill).not.toHaveBeenCalled() - expect(callIndex).toBe(1) - }) - - it('kills a relay rg scan whose residual path exceeds the field limit', async () => { - const primary = createMockProcess() - const ignored = createMockProcess() - let callIndex = 0 - spawnMock.mockImplementation(() => (++callIndex === 1 ? primary : ignored)) - const promise = listFilesWithRg('/remote/root') - - ;(primary.stdout as unknown as EventEmitter).emit( - 'data', - Buffer.alloc(QUICK_OPEN_LISTING_MAX_PATH_BYTES + 1, 0x61) - ) - - await expect(promise).rejects.toThrow( - `Quick Open file path exceeded ${QUICK_OPEN_LISTING_MAX_PATH_BYTES} bytes` - ) - expect(primary.kill).toHaveBeenCalled() - expect(ignored.kill).not.toHaveBeenCalled() - expect(callIndex).toBe(1) - }) - it('git fallback ignored pass includes ignored non-env files', async () => { const root = await makeTempRoot() await writeRel(root, 'dist/generated.js') @@ -186,11 +140,9 @@ describe('relay quick open ignored file listing', () => { ) primaryProc.emit('close', 0, null) - queueMicrotask(() => { - ;(ignoredProc.stdout as unknown as EventEmitter).emit('data', 'dist/\0') - ;(ignoredProc.stdout as unknown as EventEmitter).emit('data', 'packages/other/src/x.ts\0') - ignoredProc.emit('close', 0, null) - }) + ;(ignoredProc.stdout as unknown as EventEmitter).emit('data', 'dist/\0') + ;(ignoredProc.stdout as unknown as EventEmitter).emit('data', 'packages/other/src/x.ts\0') + ignoredProc.emit('close', 0, null) }, 10) await expect(promise).resolves.toEqual(['dist/generated.js', 'src/index.ts', 'tab\tfile.txt']) @@ -227,26 +179,6 @@ describe('relay quick open ignored file listing', () => { expect(callIndex).toBe(1) }) - it('bounds an omitted relay Git result limit before spawning the ignored pass', async () => { - const primary = createMockProcess() - const ignored = createMockProcess() - let callIndex = 0 - spawnMock.mockImplementation(() => (++callIndex === 1 ? primary : ignored)) - const promise = listFilesWithGit('/remote/root') - const paths = Array.from( - { length: QUICK_OPEN_LISTING_MAX_RESULTS + 1 }, - (_value, index) => `src/file-${index}.ts` - ) - - ;(primary.stdout as unknown as EventEmitter).emit('data', paths.join('\0')) - - const result = await promise - expect(result).toHaveLength(QUICK_OPEN_LISTING_MAX_RESULTS) - expect(primary.kill).toHaveBeenCalled() - expect(ignored.kill).not.toHaveBeenCalled() - expect(callIndex).toBe(1) - }) - it('does not let a discarded relay Git placeholder consume the result budget', async () => { const primaryProc = createMockProcess() spawnMock.mockReturnValue(primaryProc) @@ -287,7 +219,7 @@ describe('relay quick open ignored file listing', () => { `${staged('100644', 'README.md')}\0${staged('160000', 'packages/app')}\0packages/lib/\0` ) primaryProc.emit('close', 0, null) - queueMicrotask(() => ignoredProc.emit('close', 0, null)) + ignoredProc.emit('close', 0, null) }, 10) await expect(promise).resolves.toEqual([ @@ -315,11 +247,9 @@ describe('relay quick open ignored file listing', () => { ;(primaryProc.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\0') primaryProc.emit('close', 0, null) - queueMicrotask(() => { - // Entries streamed before the kill are kept alongside the primary pass. - ;(ignoredProc.stdout as unknown as EventEmitter).emit('data', 'dist/generated.js\0') - ignoredProc.emit('close', null, 'SIGTERM') - }) + // Entries streamed before the kill are kept alongside the primary pass. + ;(ignoredProc.stdout as unknown as EventEmitter).emit('data', 'dist/generated.js\0') + ignoredProc.emit('close', null, 'SIGTERM') }, 10) await expect(promise).resolves.toEqual(['dist/generated.js', 'src/index.ts']) @@ -347,7 +277,7 @@ describe('relay quick open ignored file listing', () => { ;(primaryProc.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\0') primaryProc.emit('close', 0, null) - queueMicrotask(() => ignoredProc.emit('close', 128, null)) + ignoredProc.emit('close', 128, null) }, 10) await expect(promise).resolves.toEqual(['src/index.ts']) @@ -372,10 +302,12 @@ describe('relay quick open ignored file listing', () => { setTimeout(() => { ;(primaryProc.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\0') primaryProc.emit('close', null, 'SIGTERM') + + ;(ignoredProc.stdout as unknown as EventEmitter).emit('data', 'dist/generated.js\0') + ignoredProc.emit('close', 0, null) }, 10) await expect(promise).rejects.toThrow('git ls-files killed by SIGTERM') - expect(callIndex).toBe(1) }) it('git fallback rejects when the primary pass exits non-zero', async () => { @@ -392,10 +324,12 @@ describe('relay quick open ignored file listing', () => { setTimeout(() => { primaryProc.emit('close', 128, null) + + ;(ignoredProc.stdout as unknown as EventEmitter).emit('data', 'dist/generated.js\0') + ignoredProc.emit('close', 0, null) }, 10) await expect(promise).rejects.toThrow('git ls-files exited with code 128') - expect(callIndex).toBe(1) }) it('git fallback rejects when a timed-out child does not emit close', async () => { @@ -421,12 +355,15 @@ describe('relay quick open ignored file listing', () => { expect(outcome).toContain('git ls-files timed out') expect(primaryProc.kill).toHaveBeenCalled() - expect(ignoredProc.kill).not.toHaveBeenCalled() - expect(callIndex).toBe(1) + expect(ignoredProc.kill).toHaveBeenCalled() expect((primaryProc.stdout as unknown as EventEmitter).listenerCount('data')).toBe(0) expect((primaryProc.stderr as unknown as EventEmitter).listenerCount('data')).toBe(0) expect(primaryProc.listenerCount('error')).toBe(0) expect(primaryProc.listenerCount('close')).toBe(0) + expect((ignoredProc.stdout as unknown as EventEmitter).listenerCount('data')).toBe(0) + expect((ignoredProc.stderr as unknown as EventEmitter).listenerCount('data')).toBe(0) + expect(ignoredProc.listenerCount('error')).toBe(0) + expect(ignoredProc.listenerCount('close')).toBe(0) } finally { vi.useRealTimers() } @@ -455,12 +392,15 @@ describe('relay quick open ignored file listing', () => { expect(outcome).toBe('rejected:rg list timed out') expect(primaryProc.kill).toHaveBeenCalled() - expect(ignoredProc.kill).not.toHaveBeenCalled() - expect(callIndex).toBe(1) + expect(ignoredProc.kill).toHaveBeenCalled() expect((primaryProc.stdout as unknown as EventEmitter).listenerCount('data')).toBe(0) expect((primaryProc.stderr as unknown as EventEmitter).listenerCount('data')).toBe(0) expect(primaryProc.listenerCount('error')).toBe(0) expect(primaryProc.listenerCount('close')).toBe(0) + expect((ignoredProc.stdout as unknown as EventEmitter).listenerCount('data')).toBe(0) + expect((ignoredProc.stderr as unknown as EventEmitter).listenerCount('data')).toBe(0) + expect(ignoredProc.listenerCount('error')).toBe(0) + expect(ignoredProc.listenerCount('close')).toBe(0) } finally { vi.useRealTimers() } diff --git a/src/relay/fs-handler-list-files.ts b/src/relay/fs-handler-list-files.ts index b6586c1af7e..c63a352786e 100644 --- a/src/relay/fs-handler-list-files.ts +++ b/src/relay/fs-handler-list-files.ts @@ -23,13 +23,6 @@ import { shouldExcludeQuickOpenRelPath, shouldIncludeQuickOpenPath } from '../shared/quick-open-filter' -import { - createQuickOpenListingBudget, - QUICK_OPEN_LISTING_MAX_PATH_BYTES, - QuickOpenSubprocessPathAccumulator, - resolveQuickOpenResultLimit, - retainQuickOpenPath -} from '../shared/quick-open-listing-limits' export const LIST_FILES_TIMEOUT_MS = 25_000 @@ -42,13 +35,8 @@ export function listFilesWithRg( if (signal?.aborted) { return Promise.reject(fileListingCancellationError(signal)) } - const resultLimit = resolveQuickOpenResultLimit(maxResults) - if (resultLimit === 0) { - return Promise.resolve([]) - } return new Promise((resolve, reject) => { const files = new Set() - const listingBudget = createQuickOpenListingBudget() let done = false const children: { child: ChildProcess @@ -78,15 +66,16 @@ export function listFilesWithRg( if (shouldExcludeQuickOpenRelPath(relPath, excludePathPrefixes)) { return true } - if (files.size < resultLimit) { - retainQuickOpenPath(files, relPath, listingBudget) + files.add(relPath) + if (maxResults !== undefined && files.size >= maxResults) { + finishAtLimit() } return true } const runPass = (args: string[]): Promise => new Promise((passResolve, passReject) => { - const paths = new QuickOpenSubprocessPathAccumulator(0x0a) + let passBuf = '' let passDone = false let passFileCount = 0 // --no-messages: permission-denied noise on the remote (e.g. .ssh, @@ -115,7 +104,7 @@ export function listFilesWithRg( return } passDone = true - paths.clear() + passBuf = '' cleanup() passReject(error) } @@ -124,7 +113,6 @@ export function listFilesWithRg( return } passDone = true - paths.clear() cleanup() passResolve() } @@ -141,30 +129,21 @@ export function listFilesWithRg( rejectPass(new Error('rg list timed out')) }, LIST_FILES_TIMEOUT_MS) - function failForOutput(error: unknown): void { - child.kill() - rejectPass(error instanceof Error ? error : new Error(String(error))) - } - function handleStdoutData(chunk: Buffer | string): void { - try { - const outcome = paths.push(chunk, (path) => { - if (processLine(path)) { - passFileCount++ - } - return files.size < resultLimit - }) - if (outcome === 'stopped') { - finishAtLimit() - } else if (outcome === 'path-too-large') { - failForOutput( - new Error( - `Quick Open file path exceeded ${QUICK_OPEN_LISTING_MAX_PATH_BYTES} bytes` - ) - ) + function handleStdoutData(chunk: string): void { + passBuf += chunk + let start = 0 + let idx = passBuf.indexOf('\n', start) + while (idx !== -1) { + if (processLine(passBuf.substring(start, idx))) { + passFileCount++ } - } catch (error) { - failForOutput(error) + if (done) { + return + } + start = idx + 1 + idx = passBuf.indexOf('\n', start) } + passBuf = start < passBuf.length ? passBuf.substring(start) : '' } function handleStderrData(): void { /* drain to prevent backpressure stalls */ @@ -184,18 +163,10 @@ export function listFilesWithRg( return } // Flush residual line only on clean exit. - try { - const trailingPath = paths.finish() - if (trailingPath && processLine(trailingPath)) { + if (passBuf) { + if (processLine(passBuf)) { passFileCount++ } - if (files.size >= resultLimit) { - finishAtLimit() - return - } - } catch (error) { - failForOutput(error) - return } // exit 0 = matches found, 1 = no files (still success for --files). // exit 2 is documented as "a subdirectory could not be searched" @@ -211,6 +182,7 @@ export function listFilesWithRg( } } + child.stdout!.setEncoding('utf-8') child.stdout!.on('data', handleStdoutData) child.stderr!.on('data', handleStderrData) child.once('error', handleError) @@ -240,7 +212,7 @@ export function listFilesWithRg( done = true signal?.removeEventListener('abort', onAbort) killSurvivors('rg list reached bounded result limit') - resolve(Array.from(files).slice(0, resultLimit)) + resolve(Array.from(files).slice(0, maxResults)) } // Why: a cancelled scan (workspace switch, superseded request) must stop @@ -256,11 +228,14 @@ export function listFilesWithRg( } signal?.addEventListener('abort', onAbort, { once: true }) - // Why: deterministic primary-first budgeting prevents a large ignored - // tree from starving ordinary source paths on a remote host. - const passes = runPass(primary).then(() => - files.size < resultLimit ? runPass(ignoredPass) : Promise.resolve() - ) + const passes = + maxResults === undefined + ? Promise.all([runPass(primary), runPass(ignoredPass)]) + : // Why: deterministic primary-first budgeting prevents a large ignored + // tree from starving ordinary source paths on a remote host. + runPass(primary).then(() => + files.size < maxResults ? runPass(ignoredPass) : Promise.resolve() + ) passes .then(() => { @@ -269,7 +244,7 @@ export function listFilesWithRg( } done = true signal?.removeEventListener('abort', onAbort) - resolve(Array.from(files).slice(0, resultLimit)) + resolve(Array.from(files)) }) .catch((err) => { if (done) { diff --git a/src/relay/fs-handler-stream.test.ts b/src/relay/fs-handler-stream.test.ts index 7490fa7d2f9..c7624afce65 100644 --- a/src/relay/fs-handler-stream.test.ts +++ b/src/relay/fs-handler-stream.test.ts @@ -5,25 +5,19 @@ import type { RelayDispatcher } from './dispatcher' import { STREAM_CHUNK_SIZE } from './protocol' import * as fs from 'node:fs/promises' import * as path from 'node:path' -import { mkdtempSync, truncateSync, writeFileSync } from 'node:fs' +import { mkdtempSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' vi.mock('@parcel/watcher', () => ({ subscribe: vi.fn() })) -type Notification = { method: string; params?: Record; clientId?: number } +type Notification = { method: string; params?: Record } function createMockDispatcher() { const requestHandlers = new Map< string, - ( - params: Record, - context?: { clientId: number; isStale: () => boolean } - ) => Promise - >() - const notificationHandlers = new Map< - string, - (params: Record, context: { clientId: number; isStale: () => boolean }) => void + (params: Record, context?: { isStale: () => boolean }) => Promise >() + const notificationHandlers = new Map) => void>() const notifications: Notification[] = [] return { onRequest: vi.fn( @@ -34,55 +28,33 @@ function createMockDispatcher() { requestHandlers.set(method, handler as never) } ), - onNotification: vi.fn( - ( - method: string, - handler: ( - params: Record, - context: { clientId: number; isStale: () => boolean } - ) => void - ) => { - notificationHandlers.set(method, handler) - } - ), + onNotification: vi.fn((method: string, handler: (params: Record) => void) => { + notificationHandlers.set(method, handler) + }), notify: vi.fn((method: string, params?: Record) => { notifications.push({ method, params }) }), - notifyClient: vi.fn( - (clientId: number, method: string, params?: Record): void => { - notifications.push({ method, params, clientId }) - } - ), - notifyBulk: vi.fn( - async ( - method: string, - params?: Record, - options?: { clientId?: number } - ): Promise => { - notifications.push({ method, params, clientId: options?.clientId }) - } - ), + notifyBulk: vi.fn(async (method: string, params?: Record): Promise => { + notifications.push({ method, params }) + }), _notifications: notifications, callRequest( method: string, params: Record = {}, - context?: { clientId?: number; isStale: () => boolean } + context?: { isStale: () => boolean } ) { const handler = requestHandlers.get(method) if (!handler) { throw new Error(`No handler for ${method}`) } - return handler( - params, - context ? { clientId: context.clientId ?? 1, isStale: context.isStale } : undefined - ) + return handler(params, context) }, - callNotification(method: string, params: Record = {}, clientId = 1) { + callNotification(method: string, params: Record = {}) { const handler = notificationHandlers.get(method) if (!handler) { throw new Error(`No handler for ${method}`) } - handler(params, { clientId, isStale: () => false }) + handler(params) } } } @@ -128,16 +100,6 @@ async function waitFor(predicate: () => boolean, timeoutMs = 5000): Promise { let dispatcher: ReturnType let handler: FsHandler @@ -154,39 +116,19 @@ describe('FsHandler readFileStream', () => { await fs.rm(tmpDir, { recursive: true, force: true }) }) - it('rejects a terminal-artifact raster dimension bomb', async () => { - const filePath = path.join(tmpDir, 'artifact-bomb.png') - writeFileSync(filePath, pngContent(24, 32_769, 1)) - - await expect( - dispatcher.callRequest('fs.readTerminalArtifact', { - filePath, - expectedRealPath: await fs.realpath(filePath), - maxBytes: 512 * 1024 - }) - ).rejects.toThrow('Image dimensions exceed the preview safety limit') - }) - it('streams a binary file in chunked notifications', async () => { const filePath = path.join(tmpDir, 'image.png') - const content = pngContent(300 * 1024) - const ownerClientId = 7 + const content = Buffer.alloc(300 * 1024, 0x42) writeFileSync(filePath, content) const meta = (await dispatcher.callRequest( 'fs.readFileStream', { filePath }, - { clientId: ownerClientId, isStale: () => false } - )) as { - streamId: number - totalSize: number - resultEncoding: string - imageDimensions: { width: number; height: number } - } + { isStale: () => false } + )) as { streamId: number; totalSize: number; resultEncoding: string } expect(meta.streamId).toBeDefined() expect(meta.totalSize).toBe(content.length) expect(meta.resultEncoding).toBe('base64') - expect(meta.imageDimensions).toEqual({ width: 1, height: 1 }) await waitFor(() => collectStream(dispatcher).end !== null) const { chunks, end, err } = collectStream(dispatcher) @@ -194,48 +136,11 @@ describe('FsHandler readFileStream', () => { expect(end).toEqual({ streamId: meta.streamId }) const reassembled = Buffer.concat(chunks.map((c) => Buffer.from(c.data, 'base64'))) expect(reassembled.equals(content)).toBe(true) - expect( - dispatcher._notifications.filter((notification) => notification.method === 'fs.streamEnd') - ).toEqual([ - { - method: 'fs.streamEnd', - params: { streamId: meta.streamId }, - clientId: ownerClientId - } - ]) - }) - - it('sends a stream error only to the requesting relay client', async () => { - const filePath = path.join(tmpDir, 'truncated.png') - const ownerClientId = 9 - writeFileSync(filePath, pngContent(STREAM_CHUNK_SIZE)) - - const meta = (await dispatcher.callRequest( - 'fs.readFileStream', - { filePath }, - { clientId: ownerClientId, isStale: () => false } - )) as { streamId: number } - truncateSync(filePath, 0) - - await waitFor(() => collectStream(dispatcher).err !== null) - expect( - dispatcher._notifications.filter((notification) => notification.method === 'fs.streamError') - ).toEqual([ - { - method: 'fs.streamError', - params: { - streamId: meta.streamId, - code: 'ESTREAMTRUNCATED', - message: `File truncated mid-stream: expected ${STREAM_CHUNK_SIZE}, got 0` - }, - clientId: ownerClientId - } - ]) }) it('fills protocol chunks when fs.read returns short before EOF', async () => { const filePath = path.join(tmpDir, 'short-read.png') - const content = pngContent(STREAM_CHUNK_SIZE + 17) + const content = Buffer.alloc(STREAM_CHUNK_SIZE + 17, 0x42) writeFileSync(filePath, content) const sampleHandle = await fs.open(filePath, 'r') @@ -337,7 +242,7 @@ describe('FsHandler readFileStream', () => { it('exits the pump and emits no further chunks when isStale flips', async () => { const filePath = path.join(tmpDir, 'big.png') - const content = pngContent(800 * 1024) + const content = Buffer.alloc(800 * 1024, 0x42) writeFileSync(filePath, content) let stale = false @@ -359,7 +264,7 @@ describe('FsHandler readFileStream', () => { it('honors fs.cancelStream by stopping the pump and emitting no end frame', async () => { const filePath = path.join(tmpDir, 'cancel.png') - writeFileSync(filePath, pngContent(2 * 1024 * 1024)) + writeFileSync(filePath, Buffer.alloc(2 * 1024 * 1024, 0x42)) const meta = (await dispatcher.callRequest( 'fs.readFileStream', @@ -378,7 +283,7 @@ describe('FsHandler readFileStream', () => { it('parks the pump at the ack credit window and resumes on fs.streamAck', async () => { const filePath = path.join(tmpDir, 'paced.png') - const content = pngContent(1536 * 1024) // 6 chunks + const content = Buffer.alloc(1536 * 1024, 0x42) // 6 chunks writeFileSync(filePath, content) const meta = (await dispatcher.callRequest( @@ -393,11 +298,6 @@ describe('FsHandler readFileStream', () => { expect(collectStream(dispatcher).chunks.length).toBe(4) expect(collectStream(dispatcher).end).toBeNull() - // A future ACK must not mint credit for chunks the relay has not sent. - dispatcher.callNotification('fs.streamAck', { streamId: meta.streamId, seq: 10_000 }) - await flush(10) - expect(collectStream(dispatcher).chunks.length).toBe(4) - // Ack one chunk → exactly one more is admitted. dispatcher.callNotification('fs.streamAck', { streamId: meta.streamId, seq: 0 }) await waitFor(() => collectStream(dispatcher).chunks.length === 5) @@ -415,38 +315,9 @@ describe('FsHandler readFileStream', () => { expect(reassembled.equals(content)).toBe(true) }) - it('ignores stream credit and cancellation from a different relay client', async () => { - const filePath = path.join(tmpDir, 'owned-paced.png') - writeFileSync(filePath, pngContent(4 * 1024 * 1024)) - const ownerClientId = 7 - const meta = (await dispatcher.callRequest( - 'fs.readFileStream', - { filePath, flowControl: 'ack' }, - { clientId: ownerClientId, isStale: () => false } - )) as { streamId: number } - - await waitFor(() => collectStream(dispatcher).chunks.length === 4) - dispatcher.callNotification('fs.streamAck', { streamId: meta.streamId, seq: 10_000 }, 8) - dispatcher.callNotification('fs.cancelStream', { streamId: meta.streamId }, 8) - await flush(10) - expect(collectStream(dispatcher).chunks).toHaveLength(4) - - dispatcher.callNotification('fs.streamAck', { streamId: meta.streamId, seq: 0 }, ownerClientId) - await waitFor(() => collectStream(dispatcher).chunks.length === 5) - dispatcher.callNotification('fs.cancelStream', { streamId: meta.streamId }, ownerClientId) - - const registry = (handler as unknown as { streamRegistry: { size(): number } }).streamRegistry - await waitFor(() => registry.size() === 0) - expect( - dispatcher._notifications - .filter((notification) => notification.method.startsWith('fs.stream')) - .every((notification) => notification.clientId === ownerClientId) - ).toBe(true) - }) - it('releases a pump parked on the ack window when the stream is cancelled', async () => { const filePath = path.join(tmpDir, 'parked-cancel.png') - writeFileSync(filePath, pngContent(4 * 1024 * 1024)) // 16 chunks + writeFileSync(filePath, Buffer.alloc(4 * 1024 * 1024, 0x42)) // 16 chunks const meta = (await dispatcher.callRequest( 'fs.readFileStream', @@ -469,7 +340,7 @@ describe('FsHandler readFileStream', () => { const paths: string[] = [] for (let i = 0; i < 17; i++) { const p = path.join(tmpDir, `s${i}.png`) - writeFileSync(p, pngContent(8 * 1024 * 1024)) + writeFileSync(p, Buffer.alloc(8 * 1024 * 1024, 0x42)) paths.push(p) } @@ -499,15 +370,4 @@ describe('FsHandler readFileStream', () => { } await flush(50) }, 20_000) - - it('rejects an oversized raster before registering or pumping a stream', async () => { - const filePath = path.join(tmpDir, 'bomb.png') - writeFileSync(filePath, pngContent(24, 32_769, 1)) - - await expect( - dispatcher.callRequest('fs.readFileStream', { filePath }, { isStale: () => false }) - ).rejects.toThrow('Image dimensions exceed the preview safety limit') - await flush() - expect(collectStream(dispatcher).chunks).toHaveLength(0) - }) }) diff --git a/src/relay/fs-handler-terminal-artifact.ts b/src/relay/fs-handler-terminal-artifact.ts index 2f5829ad0b7..02af753f134 100644 --- a/src/relay/fs-handler-terminal-artifact.ts +++ b/src/relay/fs-handler-terminal-artifact.ts @@ -10,7 +10,6 @@ import { MAX_PREVIEWABLE_BINARY_SIZE, MAX_TEXT_FILE_SIZE } from './fs-handler-utils' -import { assertRasterImagePreviewWithinLimits } from '../shared/raster-image-preview-limits' type TerminalArtifactStat = { size: number @@ -44,14 +43,7 @@ export async function readVerifiedTerminalArtifact(params: Record { const rgArgs = buildRgArgs(query, rootPath, opts) const acc = createAccumulator() - const stdoutLines = new SearchSubprocessLineAccumulator() + let buffer = '' let resolved = false // Why: spawn can throw synchronously on invalid options (e.g. bad cwd), @@ -135,11 +134,12 @@ export function searchWithRg( } } - function handleStdoutData(chunk: Buffer): void { - if (!stdoutLines.push(chunk, processLine)) { - acc.truncated = true - child.kill() - resolveOnce() + function handleStdoutData(chunk: string): void { + buffer += chunk + const lines = buffer.split('\n') + buffer = lines.pop() ?? '' + for (const line of lines) { + processLine(line) } } @@ -152,13 +152,13 @@ export function searchWithRg( } function handleClose(): void { - const trailingLine = stdoutLines.finish() - if (trailingLine !== null) { - processLine(trailingLine) + if (buffer) { + processLine(buffer) } resolveOnce() } + child.stdout!.setEncoding('utf-8') child.stdout!.on('data', handleStdoutData) child.stderr!.on('data', handleStderrData) child.once('error', handleError) diff --git a/src/relay/fs-handler.test.ts b/src/relay/fs-handler.test.ts index 3c8bbd847b9..23dd28c35cb 100644 --- a/src/relay/fs-handler.test.ts +++ b/src/relay/fs-handler.test.ts @@ -17,8 +17,6 @@ vi.mock('@parcel/watcher', () => ({ subscribe: mockSubscribe })) -const PNG_1X1 = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB', 'base64') - function createMockDispatcher() { const requestHandlers = new Map< string, @@ -140,7 +138,6 @@ describe('FsHandler', () => { it('registers all expected handlers', () => { const methods = Array.from(dispatcher._requestHandlers.keys()) expect(methods).toContain('fs.readDir') - expect(methods).toContain('fs.readDirBounded') expect(methods).toContain('fs.readFile') expect(methods).toContain('fs.tempDir') expect(methods).toContain('fs.writeFile') @@ -155,7 +152,6 @@ describe('FsHandler', () => { expect(methods).toContain('fs.realpath') expect(methods).toContain('fs.search') expect(methods).toContain('fs.listFiles') - expect(methods).toContain('fs.listMarkdownDocuments') expect(methods).toContain('fs.workspaceSpaceScan') expect(methods).toContain('fs.watch') expect(methods).toContain('fs.unwatchAndWait') @@ -168,12 +164,12 @@ describe('FsHandler', () => { await expect(dispatcher.callRequest('fs.tempDir')).resolves.toBe(tmpdir()) }) - it('bounded readDir returns sorted entries with directories first', async () => { + it('readDir returns sorted entries with directories first', async () => { mkdirSync(path.join(tmpDir, 'subdir')) writeFileSync(path.join(tmpDir, 'file.txt'), 'hello') writeFileSync(path.join(tmpDir, 'aaa.txt'), 'world') - const result = (await dispatcher.callRequest('fs.readDirBounded', { dirPath: tmpDir })) as { + const result = (await dispatcher.callRequest('fs.readDir', { dirPath: tmpDir })) as { name: string isDirectory: boolean }[] @@ -243,19 +239,17 @@ describe('FsHandler', () => { it('readFile returns base64 for image files', async () => { const filePath = path.join(tmpDir, 'test.png') - writeFileSync(filePath, PNG_1X1) + writeFileSync(filePath, Buffer.from([0x89, 0x50, 0x4e, 0x47])) const result = (await dispatcher.callRequest('fs.readFile', { filePath })) as { content: string isBinary: boolean isImage: boolean mimeType: string - imageDimensions: { width: number; height: number } } expect(result.isBinary).toBe(true) expect(result.isImage).toBe(true) expect(result.mimeType).toBe('image/png') - expect(result.imageDimensions).toEqual({ width: 1, height: 1 }) expect(result.content).toBeTruthy() }) diff --git a/src/relay/fs-handler.ts b/src/relay/fs-handler.ts index c741d8f9e99..9c146af1450 100644 --- a/src/relay/fs-handler.ts +++ b/src/relay/fs-handler.ts @@ -1,8 +1,9 @@ /* eslint-disable max-lines -- Why: relay filesystem request handling shares path expansion, file IO, search, streaming reads, and Space scans. */ -import { writeFile, stat, lstat, mkdir, rename, cp, rm, realpath } from 'node:fs/promises' +import { readdir, writeFile, stat, lstat, mkdir, rename, cp, rm, realpath } from 'node:fs/promises' import { execFile } from 'node:child_process' import { tmpdir } from 'node:os' +import { join } from 'node:path' import type { RelayDispatcher, RequestContext } from './dispatcher' import type { RelayContext } from './context' // Why: RelayContext is accepted in the constructor for protocol back-compat @@ -35,27 +36,24 @@ import { buildRelayCommandEnv } from './relay-command-env' import { assertNoClobberRenameDestinationAvailable } from '../shared/filesystem-rename-collision' import { RelayFilesystemWatchRegistry } from './relay-filesystem-watch-registry' import type { RelayWatcherProcessPool } from './relay-watcher-process-pool' -import { - MOBILE_FILE_DIRECTORY_MAX_ENTRIES, - MOBILE_FILE_DIRECTORY_MAX_RETAINED_BYTES -} from '../shared/mobile-file-directory-limit' -import { readMobileRelayDirectory } from './mobile-file-directory-reader' -import { readRelayFilesystemDirectory } from './filesystem-directory-reader' -import type { FilesystemDirectoryListingLimits } from '../shared/filesystem-directory-listing-limit' -import { listRelayMarkdownDocumentPaths } from './markdown-document-listing' -import { resolveQuickOpenResultLimit } from '../shared/quick-open-listing-limits' -function readDirectoryLimitsFromParams( - params: Record -): Partial { - const limits: Partial = {} - if (typeof params.maxEntries === 'number') { - limits.maxEntries = params.maxEntries +async function isDirectoryEntry( + dirPath: string, + entry: { name: string; isDirectory(): boolean; isSymbolicLink(): boolean } +): Promise { + if (entry.isDirectory()) { + return true } - if (typeof params.maxRetainedBytes === 'number') { - limits.maxRetainedBytes = params.maxRetainedBytes + if (!entry.isSymbolicLink()) { + return false + } + try { + // Why: the file explorer needs target type for symlinked directories so a + // workspace link to an external folder expands instead of opening as a file. + return (await stat(join(dirPath, entry.name))).isDirectory() + } catch { + return false } - return limits } function fileStatFromLstat(stats: Awaited>) { @@ -104,7 +102,6 @@ export class FsHandler { private registerHandlers(): void { this.dispatcher.onRequest('fs.readDir', (p) => this.readDir(p)) - this.dispatcher.onRequest('fs.readDirBounded', (p) => this.readDir(p)) this.dispatcher.onRequest('fs.readFile', (p) => this.readFile(p)) this.dispatcher.onRequest('fs.readFileStream', (p, c) => this.readFileStream(p, c)) this.dispatcher.onRequest('fs.readTerminalArtifact', (p) => this.readTerminalArtifact(p)) @@ -123,9 +120,6 @@ export class FsHandler { this.dispatcher.onRequest('fs.realpath', (p) => this.realpath(p)) this.dispatcher.onRequest('fs.search', (p) => this.search(p)) this.dispatcher.onRequest('fs.listFiles', (p, c) => this.listFiles(p, c)) - this.dispatcher.onRequest('fs.listMarkdownDocuments', (p, c) => - listRelayMarkdownDocumentPaths(expandTilde(p.rootPath as string), c.signal) - ) this.dispatcher.onRequest('fs.workspaceSpaceScan', (p, c) => this.workspaceSpaceScan(p, c)) this.dispatcher.onRequest('fs.watch', (p, context) => this.watchRegistry.watch( @@ -140,25 +134,21 @@ export class FsHandler { this.dispatcher.onNotification('fs.unwatch', (p, context) => this.watchRegistry.unwatch(expandTilde(p.rootPath as string), context) ) - this.dispatcher.onNotification('fs.cancelStream', (p, context) => this.cancelStream(p, context)) - this.dispatcher.onNotification('fs.streamAck', (p, context) => this.streamAck(p, context)) + this.dispatcher.onNotification('fs.cancelStream', (p) => this.cancelStream(p)) + this.dispatcher.onNotification('fs.streamAck', (p) => this.streamAck(p)) } private async readDir(params: Record) { const dirPath = expandTilde(params.dirPath as string) - if ( - params.maxEntries === MOBILE_FILE_DIRECTORY_MAX_ENTRIES && - params.maxRetainedBytes === MOBILE_FILE_DIRECTORY_MAX_RETAINED_BYTES - ) { - return this.sortDirectoryEntries(await readMobileRelayDirectory(dirPath)) - } - return readRelayFilesystemDirectory(dirPath, readDirectoryLimitsFromParams(params)) - } - - private sortDirectoryEntries( - entries: T[] - ): T[] { - return entries.sort((a, b) => { + const entries = await readdir(dirPath, { withFileTypes: true }) + const mapped = await Promise.all( + entries.map(async (entry) => ({ + name: entry.name, + isDirectory: await isDirectoryEntry(dirPath, entry), + isSymlink: entry.isSymbolicLink() + })) + ) + return mapped.sort((a, b) => { if (a.isDirectory !== b.isDirectory) { return a.isDirectory ? -1 : 1 } @@ -194,18 +184,18 @@ export class FsHandler { return tmpdir() } - private cancelStream(params: Record, context: RequestContext): void { + private cancelStream(params: Record): void { const streamId = params.streamId as number | undefined if (typeof streamId === 'number') { - this.streamRegistry.abort(streamId, context.clientId) + this.streamRegistry.abort(streamId) } } - private streamAck(params: Record, context: RequestContext): void { + private streamAck(params: Record): void { const streamId = params.streamId as number | undefined const seq = params.seq as number | undefined if (typeof streamId === 'number' && typeof seq === 'number') { - this.streamRegistry.recordAck(streamId, seq, context.clientId) + this.streamRegistry.recordAck(streamId, seq) } } @@ -366,13 +356,12 @@ export class FsHandler { private listFiles(params: Record, context?: RequestContext): Promise { const rootPath = expandTilde(params.rootPath as string) - const requestedMaxResults = + const maxResults = typeof params.maxResults === 'number' && Number.isInteger(params.maxResults) && params.maxResults > 0 - ? params.maxResults + ? Math.min(params.maxResults, 20_001) : undefined - const maxResults = resolveQuickOpenResultLimit(requestedMaxResults) // Why: the main-to-relay RPC adds excludePaths so nested linked worktrees // don't get double-scanned. The shared helper validates the shape and // normalizes into root-relative prefixes; malformed input yields [] so @@ -393,7 +382,7 @@ export class FsHandler { rootPath: string, excludePathPrefixes: string[], signal: AbortSignal, - maxResults: number + maxResults?: number ): Promise { const rgAvailable = await checkRgAvailable() throwIfFileListingCancelled(signal) diff --git a/src/relay/fs-list-files-cancel.integration.test.ts b/src/relay/fs-list-files-cancel.integration.test.ts index 4f9118e608d..588ddb5fe51 100644 --- a/src/relay/fs-list-files-cancel.integration.test.ts +++ b/src/relay/fs-list-files-cancel.integration.test.ts @@ -27,7 +27,7 @@ const { fakeListFiles } = vi.hoisted(() => { ( rootPath: string, _excludes: readonly string[] = [], - options: { signal?: AbortSignal; maxResults?: number } = {} + options: { signal?: AbortSignal } = {} ) => new Promise((resolve, reject) => { scans.push({ rootPath, signal: options.signal, resolve }) @@ -67,7 +67,6 @@ import { RelayDispatcher } from './dispatcher' import { RelayContext } from './context' import { FsHandler } from './fs-handler' import { LIST_FILES_SUPERSEDED_MESSAGE } from './fs-list-files-scan-coordinator' -import { QUICK_OPEN_LISTING_MAX_RESULTS } from '../shared/quick-open-listing-limits' async function flushPipe(): Promise { // The in-memory pipe defers each hop with setImmediate; a few macrotask @@ -124,7 +123,6 @@ describe('Integration: cancellable fs.listFiles (#7721)', () => { const scanPromise = mux.request('fs.listFiles', { rootPath: '/big/workspace' }) await flushPipe() expect(fakeListFiles.scans).toHaveLength(1) - expect(fakeListFiles.mock.calls[0][2]?.maxResults).toBe(QUICK_OPEN_LISTING_MAX_RESULTS) // The interactive request must complete while the scan is still pending. const entries = (await mux.request('fs.readDir', { dirPath: tmpDir })) as { name: string }[] diff --git a/src/relay/fs-stream-pty-echo-backpressure.integration.test.ts b/src/relay/fs-stream-pty-echo-backpressure.integration.test.ts index 3875d22a282..8cca2787891 100644 --- a/src/relay/fs-stream-pty-echo-backpressure.integration.test.ts +++ b/src/relay/fs-stream-pty-echo-backpressure.integration.test.ts @@ -37,16 +37,6 @@ const FRAMED_CHUNK_BYTES = Math.ceil((STREAM_CHUNK_SIZE * 4) / 3) + 512 // Node pipe/socket sinks report saturation via write() === false past the HWM. const SINK_HIGH_WATER_MARK = 64 * 1024 -function randomPngBytes(size: number): Buffer { - const content = randomBytes(size) - Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(content) - content.writeUInt32BE(13, 8) - content.write('IHDR', 12, 'ascii') - content.writeUInt32BE(1, 16) - content.writeUInt32BE(1, 20) - return content -} - async function waitUntil( predicate: () => boolean, what: string, @@ -192,7 +182,7 @@ describe('fs.readFileStream vs pty.data echo head-of-line blocking', () => { const harness = createHarness({ congested: true }) try { const filePath = path.join(tmpDir, 'big.png') - const original = randomPngBytes(3 * 1024 * 1024) // 12 chunks + const original = randomBytes(3 * 1024 * 1024) // 12 chunks writeFileSync(filePath, original) // Relay-side fake PTY: echoes input back immediately, mirroring @@ -232,17 +222,11 @@ describe('fs.readFileStream vs pty.data echo head-of-line blocking', () => { const harness = createHarness({ congested: false }) try { const filePath = path.join(tmpDir, 'big.png') - writeFileSync(filePath, randomPngBytes(3 * 1024 * 1024)) // 12 chunks + writeFileSync(filePath, randomBytes(3 * 1024 * 1024)) // 12 chunks const receivedSeqs: number[] = [] - let acknowledgeChunks = false - let streamId: number | null = null harness.mux.onNotificationByMethod('fs.streamChunk', (params) => { - const seq = params.seq as number - receivedSeqs.push(seq) - if (acknowledgeChunks && streamId !== null) { - harness.mux.notify('fs.streamAck', { streamId, seq }) - } + receivedSeqs.push(params.seq as number) }) let streamEnded = false harness.mux.onNotificationByMethod('fs.streamEnd', () => { @@ -256,7 +240,6 @@ describe('fs.readFileStream vs pty.data echo head-of-line blocking', () => { filePath, flowControl: 'ack' })) as { streamId: number } - streamId = metadata.streamId await waitUntil(() => receivedSeqs.length > 0, 'first chunk received') await waitUntilSettled(() => receivedSeqs.length) @@ -267,9 +250,8 @@ describe('fs.readFileStream vs pty.data echo head-of-line blocking', () => { expect(streamEnded).toBe(false) // Acking releases the window and the stream completes. - acknowledgeChunks = true const totalChunks = 12 - for (const seq of receivedSeqs) { + for (let seq = 0; seq < totalChunks; seq += 1) { harness.mux.notify('fs.streamAck', { streamId: metadata.streamId, seq }) } await waitUntil(() => streamEnded, 'stream completed after acks') @@ -283,7 +265,7 @@ describe('fs.readFileStream vs pty.data echo head-of-line blocking', () => { const harness = createHarness({ congested: false }) try { const filePath = path.join(tmpDir, 'legacy.png') - const original = randomPngBytes(1024 * 1024 + 12345) + const original = randomBytes(1024 * 1024 + 12345) writeFileSync(filePath, original) const chunks = new Map() diff --git a/src/relay/fs-stream-registry.ts b/src/relay/fs-stream-registry.ts index 09b687aa714..2bca76c9495 100644 --- a/src/relay/fs-stream-registry.ts +++ b/src/relay/fs-stream-registry.ts @@ -3,10 +3,7 @@ import { MAX_CONCURRENT_STREAMS, RelayErrorCode, STREAM_ACK_STALL_RECHECK_MS } f type StreamEntry = { handle: FileHandle - ownerClientId: number aborted: boolean - /** Highest chunk seq admitted to the outbound bulk lane. */ - sentThroughSeq: number /** Highest chunk seq the client acknowledged (in-order; -1 = none yet). */ ackedThroughSeq: number /** Pumps parked on the ack credit window. Woken by acks, abort, release, @@ -25,25 +22,23 @@ export class RelayStreamRegistry { private streams = new Map() private nextId = 1 - register(handle: FileHandle, ownerClientId: number): number { + register(handle: FileHandle): number { if (this.streams.size >= MAX_CONCURRENT_STREAMS) { throw new TooManyStreamsError() } const streamId = this.nextId++ this.streams.set(streamId, { handle, - ownerClientId, aborted: false, - sentThroughSeq: -1, ackedThroughSeq: -1, ackWaiters: new Set() }) return streamId } - abort(streamId: number, clientId: number): void { + abort(streamId: number): void { const entry = this.streams.get(streamId) - if (entry?.ownerClientId === clientId) { + if (entry) { entry.aborted = true this.wakeAckWaiters(entry) } @@ -57,22 +52,9 @@ export class RelayStreamRegistry { return this.streams.get(streamId) } - recordSent(streamId: number, seq: number): void { + recordAck(streamId: number, seq: number): void { const entry = this.streams.get(streamId) - if (entry && Number.isSafeInteger(seq) && seq === entry.sentThroughSeq + 1) { - entry.sentThroughSeq = seq - } - } - - recordAck(streamId: number, seq: number, clientId: number): void { - const entry = this.streams.get(streamId) - if ( - !entry || - entry.ownerClientId !== clientId || - !Number.isSafeInteger(seq) || - seq < 0 || - seq > entry.sentThroughSeq - ) { + if (!entry || typeof seq !== 'number' || !Number.isFinite(seq)) { return } if (seq > entry.ackedThroughSeq) { @@ -147,11 +129,7 @@ export class RelayStreamRegistry { // cleanly on the next iteration boundary instead of seeing EBADF when // release closes the handle out from under an in-flight read. for (const id of this.streams.keys()) { - const entry = this.streams.get(id) - if (entry) { - entry.aborted = true - this.wakeAckWaiters(entry) - } + this.abort(id) } const ids = Array.from(this.streams.keys()) await Promise.all(ids.map((id) => this.release(id))) diff --git a/src/relay/git-handler-status-ops-bounds.test.ts b/src/relay/git-handler-status-ops-bounds.test.ts deleted file mode 100644 index 8849ad9b334..00000000000 --- a/src/relay/git-handler-status-ops-bounds.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { afterEach, describe, expect, it } from 'vitest' -import { mkdir, mkdtemp, rm, truncate, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { resolveGitDir } from './git-handler-status-ops' - -describe('relay resolveGitDir metadata bounds', () => { - const roots: string[] = [] - - afterEach(async () => { - await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) - }) - - async function makeWorktree(): Promise { - const root = await mkdtemp(join(tmpdir(), 'orca-relay-resolve-git-dir-')) - roots.push(root) - const worktreePath = join(root, 'checkout') - await mkdir(worktreePath) - return worktreePath - } - - it('preserves a normal linked-worktree pointer', async () => { - const worktreePath = await makeWorktree() - await writeFile(join(worktreePath, '.git'), 'gitdir: ../common/.git/worktrees/checkout\n') - - await expect(resolveGitDir(worktreePath)).resolves.toBe( - join(worktreePath, '..', 'common', '.git', 'worktrees', 'checkout') - ) - }) - - it('falls back to the .git path for an oversized sparse pointer', async () => { - const worktreePath = await makeWorktree() - const dotGitPath = join(worktreePath, '.git') - await writeFile(dotGitPath, 'x') - await truncate(dotGitPath, 64 * 1024 + 1) - - await expect(resolveGitDir(worktreePath)).resolves.toBe(dotGitPath) - }) -}) diff --git a/src/relay/git-handler-status-ops.ts b/src/relay/git-handler-status-ops.ts index e802b31677f..1bd05821f50 100644 --- a/src/relay/git-handler-status-ops.ts +++ b/src/relay/git-handler-status-ops.ts @@ -4,6 +4,7 @@ */ import * as path from 'node:path' import { existsSync } from 'node:fs' +import { readFile } from 'node:fs/promises' import { parseUnmergedEntry } from './git-handler-utils' import type { GitExec } from './git-handler-ops' import type { RelayGitStreamExec } from './git-stdout-stream' @@ -23,16 +24,11 @@ import { clearGitStatusLineStatsCacheKey, reuseOrRecomputeGitStatusLineStats } from '../shared/git-status-line-stats-cache' -import { readNodeFileWithinLimit } from '../shared/node-bounded-file-reader' - -const MAX_GIT_POINTER_FILE_BYTES = 64 * 1024 export async function resolveGitDir(worktreePath: string): Promise { const dotGitPath = path.join(worktreePath, '.git') try { - const contents = ( - await readNodeFileWithinLimit(dotGitPath, MAX_GIT_POINTER_FILE_BYTES) - ).buffer.toString('utf-8') + const contents = await readFile(dotGitPath, 'utf-8') const match = contents.match(/^gitdir:\s*(.+)\s*$/m) if (match) { return path.resolve(worktreePath, match[1]) diff --git a/src/relay/git-handler-stream-serialization-memory.test.ts b/src/relay/git-handler-stream-serialization-memory.test.ts deleted file mode 100644 index 0fae5409820..00000000000 --- a/src/relay/git-handler-stream-serialization-memory.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { RelayContext } from './context' -import { RelayDispatcher, type RequestContext } from './dispatcher' -import { GitHandler } from './git-handler' -import { GIT_RESPONSE_STREAM_THRESHOLD, type GitResponseStreamMarker } from './protocol' - -type StreamableGitHandler = { - maybeStreamResponse( - result: unknown, - params: Record, - context: RequestContext - ): GitResponseStreamMarker -} - -describe('GitHandler streamed response serialization', () => { - it('registers a large serialized string without allocating a full payload Buffer', () => { - const dispatcher = new RelayDispatcher(() => true) - const handler = new GitHandler(dispatcher, new RelayContext()) - const streamable = handler as unknown as StreamableGitHandler - const result = { text: 'x'.repeat(GIT_RESPONSE_STREAM_THRESHOLD + 1) } - const fromSpy = vi.spyOn(Buffer, 'from') - - try { - const marker = streamable.maybeStreamResponse( - result, - { __streamResponse: true }, - { clientId: 1, isStale: () => false } - ) - - expect(marker.__orcaGitResponseStream.totalBytes).toBe( - Buffer.byteLength(JSON.stringify(result), 'utf8') - ) - expect(fromSpy).not.toHaveBeenCalled() - } finally { - handler.dispose() - dispatcher.dispose() - fromSpy.mockRestore() - } - }) -}) diff --git a/src/relay/git-handler-submodule-ops.test.ts b/src/relay/git-handler-submodule-ops.test.ts index 937d5d19850..15481097688 100644 --- a/src/relay/git-handler-submodule-ops.test.ts +++ b/src/relay/git-handler-submodule-ops.test.ts @@ -2,19 +2,11 @@ import { describe, expect, it } from 'vitest' import * as path from 'node:path' import type { GitExec } from './git-handler-ops' import { - MAX_SUBMODULE_PATH_CODE_UNITS, - MAX_SUBMODULE_PATHS_CACHE_CODE_UNITS, MAX_SUBMODULE_PATHS_CACHE_ENTRIES, - MAX_SUBMODULE_PATHS_CACHE_KEY_BYTES, - MAX_SUBMODULE_PATHS_OUTPUT_BYTES, - MAX_SUBMODULE_PATHS_PER_REPO, - MAX_SUBMODULE_PATHS_PER_REPO_CODE_UNITS, SUBMODULE_PATHS_CACHE_TTL_MS, clearSubmodulePathsCache, createSubmodulePathsCache, - getSubmodulePathsCacheCodeUnits, getSubmodulePathsCacheCount, - listSubmodulePaths, listSubmodulePathsCached, resolveSubmoduleWorktreePath } from './git-handler-submodule-ops' @@ -34,19 +26,6 @@ function gitmodulesExec(paths: string[]): { git: GitExec; calls: () => number } return { git, calls: () => calls } } -function pathsUsingCodeUnits(codeUnits: number): string[] { - const fullPath = 'x'.repeat(MAX_SUBMODULE_PATH_CODE_UNITS) - const paths = Array.from( - { length: Math.floor(codeUnits / MAX_SUBMODULE_PATH_CODE_UNITS) }, - () => fullPath - ) - const remainder = codeUnits % MAX_SUBMODULE_PATH_CODE_UNITS - if (remainder > 0) { - paths.push('x'.repeat(remainder)) - } - return paths -} - describe('listSubmodulePathsCached', () => { it('reads .gitmodules once for repeated diffs on the same worktree within TTL', async () => { const { git, calls } = gitmodulesExec(['vendor/lib']) @@ -60,19 +39,6 @@ describe('listSubmodulePathsCached', () => { expect(calls()).toBe(1) }) - it('keeps the existing 10 MiB command-output ceiling explicit', async () => { - let receivedOptions: Parameters[2] - const git: GitExec = async (_args, _cwd, options) => { - receivedOptions = options - return { stdout: 'submodule.lib.path vendor/lib\n', stderr: '' } - } - - await listSubmodulePaths(git, '/repo') - - expect(receivedOptions).toEqual({ maxBuffer: MAX_SUBMODULE_PATHS_OUTPUT_BYTES }) - expect(MAX_SUBMODULE_PATHS_OUTPUT_BYTES).toBe(10 * 1024 * 1024) - }) - it('re-reads after the TTL expires', async () => { const { git, calls } = gitmodulesExec(['vendor/lib']) const cache = createSubmodulePathsCache() @@ -103,7 +69,6 @@ describe('listSubmodulePathsCached', () => { await listSubmodulePathsCached(git, '/repo-c', cache, SUBMODULE_PATHS_CACHE_TTL_MS + 1) expect(getSubmodulePathsCacheCount(cache)).toBe(1) - expect(getSubmodulePathsCacheCodeUnits(cache)).toBe('/repo-c'.length + 'vendor/lib'.length) }) it('caches an empty result so a submodule-free repo is not re-read', async () => { @@ -156,69 +121,6 @@ describe('listSubmodulePathsCached', () => { expect(calls()).toBe(callsBeforeReads + 1) }) - it('does not retain worktree keys above the UTF-8 byte ceiling', async () => { - const exact = gitmodulesExec(['vendor/lib']) - const exactCache = createSubmodulePathsCache() - const exactKey = 'r'.repeat(MAX_SUBMODULE_PATHS_CACHE_KEY_BYTES) - - await listSubmodulePathsCached(exact.git, exactKey, exactCache, 1_000) - await listSubmodulePathsCached(exact.git, exactKey, exactCache, 1_001) - - expect(exact.calls()).toBe(1) - expect(getSubmodulePathsCacheCount(exactCache)).toBe(1) - - const overflow = gitmodulesExec(['vendor/lib']) - const overflowCache = createSubmodulePathsCache() - const overflowKey = '界'.repeat(Math.floor(MAX_SUBMODULE_PATHS_CACHE_KEY_BYTES / 3) + 1) - expect(Buffer.byteLength(overflowKey, 'utf8')).toBeGreaterThan( - MAX_SUBMODULE_PATHS_CACHE_KEY_BYTES - ) - - await listSubmodulePathsCached(overflow.git, overflowKey, overflowCache, 1_000) - await listSubmodulePathsCached(overflow.git, overflowKey, overflowCache, 1_001) - - expect(overflow.calls()).toBe(2) - expect(getSubmodulePathsCacheCount(overflowCache)).toBe(0) - expect(getSubmodulePathsCacheCodeUnits(overflowCache)).toBe(0) - }) - - it('evicts LRU payloads above the global retained-code-unit ceiling and recovers', async () => { - const calls = new Map() - const git: GitExec = async (_args, cwd) => { - calls.set(cwd, (calls.get(cwd) ?? 0) + 1) - const paths = - cwd === '/overflow' - ? ['vendor/lib'] - : pathsUsingCodeUnits(MAX_SUBMODULE_PATHS_PER_REPO_CODE_UNITS - cwd.length) - return { - stdout: paths - .map((submodulePath, i) => `submodule.sub${i}.path ${submodulePath}`) - .join('\n'), - stderr: '' - } - } - const cache = createSubmodulePathsCache() - - for (let i = 0; i < 4; i += 1) { - await listSubmodulePathsCached(git, `/repo-${i}`, cache, 1_000) - } - expect(getSubmodulePathsCacheCodeUnits(cache)).toBe(MAX_SUBMODULE_PATHS_CACHE_CODE_UNITS) - - await listSubmodulePathsCached(git, '/overflow', cache, 1_000) - - expect(getSubmodulePathsCacheCount(cache)).toBe(4) - expect(getSubmodulePathsCacheCodeUnits(cache)).toBeLessThan( - MAX_SUBMODULE_PATHS_CACHE_CODE_UNITS - ) - await listSubmodulePathsCached(git, '/repo-1', cache, 1_001) - expect(calls.get('/repo-1')).toBe(1) - await listSubmodulePathsCached(git, '/repo-0', cache, 1_001) - expect(calls.get('/repo-0')).toBe(2) - expect(getSubmodulePathsCacheCodeUnits(cache)).toBeLessThanOrEqual( - MAX_SUBMODULE_PATHS_CACHE_CODE_UNITS - ) - }) - it('does not let a pre-mutation SSH read repopulate the cache', async () => { let resolveOldRead: ((value: { stdout: string; stderr: string }) => void) | undefined let calls = 0 @@ -236,7 +138,6 @@ describe('listSubmodulePathsCached', () => { const oldRead = listSubmodulePathsCached(git, '/repo', cache, 1_000) expect(resolveOldRead).toBeTypeOf('function') clearSubmodulePathsCache(cache) - expect(getSubmodulePathsCacheCodeUnits(cache)).toBe(0) resolveOldRead?.({ stdout: 'submodule.lib.path old-lib\n', stderr: '' }) await expect(oldRead).resolves.toEqual(['old-lib']) @@ -248,46 +149,6 @@ describe('listSubmodulePathsCached', () => { }) }) -describe('listSubmodulePaths', () => { - it('preserves the exact path-count boundary and rejects one more', async () => { - const exactPaths = Array.from( - { length: MAX_SUBMODULE_PATHS_PER_REPO }, - (_, index) => `vendor/lib-${index}` - ) - const exact = await listSubmodulePaths(gitmodulesExec(exactPaths).git, '/repo') - const overflow = await listSubmodulePaths( - gitmodulesExec([...exactPaths, 'vendor/overflow']).git, - '/repo' - ) - - expect(exact).toHaveLength(MAX_SUBMODULE_PATHS_PER_REPO) - expect(exact.at(-1)).toBe(`vendor/lib-${MAX_SUBMODULE_PATHS_PER_REPO - 1}`) - expect(overflow).toEqual([]) - }) - - it('preserves the exact per-path boundary and rejects one more code unit', async () => { - const exactPath = 'x'.repeat(MAX_SUBMODULE_PATH_CODE_UNITS) - - await expect(listSubmodulePaths(gitmodulesExec([exactPath]).git, '/repo')).resolves.toEqual([ - exactPath - ]) - await expect( - listSubmodulePaths(gitmodulesExec([`${exactPath}x`]).git, '/repo') - ).resolves.toEqual([]) - }) - - it('preserves the exact per-repo payload boundary and rejects one more code unit', async () => { - const exactPaths = pathsUsingCodeUnits(MAX_SUBMODULE_PATHS_PER_REPO_CODE_UNITS) - const exact = await listSubmodulePaths(gitmodulesExec(exactPaths).git, '/repo') - const overflow = await listSubmodulePaths(gitmodulesExec([...exactPaths, 'x']).git, '/repo') - - expect(exact.reduce((total, submodulePath) => total + submodulePath.length, 0)).toBe( - MAX_SUBMODULE_PATHS_PER_REPO_CODE_UNITS - ) - expect(overflow).toEqual([]) - }) -}) - describe('resolveSubmoduleWorktreePath', () => { it('resolves relative submodule paths inside the selected worktree', () => { expect(resolveSubmoduleWorktreePath('/repo', 'vendor/lib')).toBe( diff --git a/src/relay/git-handler-submodule-ops.ts b/src/relay/git-handler-submodule-ops.ts index d0f5ed595f8..0409ba61177 100644 --- a/src/relay/git-handler-submodule-ops.ts +++ b/src/relay/git-handler-submodule-ops.ts @@ -11,7 +11,6 @@ import * as path from 'node:path' import { buildDiffResult } from './git-diff-result' import { parseBranchDiff } from './git-handler-utils' import { parseNumstat } from '../shared/git-uncommitted-line-stats' -import { iterateProcessOutputLines } from '../shared/process-output-field-scanner' import { readBlobAtOid, type GitBufferExec, type GitExec } from './git-handler-ops' /** @@ -21,26 +20,18 @@ import { readBlobAtOid, type GitBufferExec, type GitExec } from './git-handler-o */ export const SUBMODULE_PATHS_CACHE_TTL_MS = 5_000 export const MAX_SUBMODULE_PATHS_CACHE_ENTRIES = 512 -export const MAX_SUBMODULE_PATHS_OUTPUT_BYTES = 10 * 1024 * 1024 -export const MAX_SUBMODULE_PATHS_PER_REPO = 10_000 -export const MAX_SUBMODULE_PATH_CODE_UNITS = 64 * 1024 -export const MAX_SUBMODULE_PATHS_PER_REPO_CODE_UNITS = 4 * 1024 * 1024 -export const MAX_SUBMODULE_PATHS_CACHE_CODE_UNITS = 16 * 1024 * 1024 -export const MAX_SUBMODULE_PATHS_CACHE_KEY_BYTES = 64 * 1024 -type SubmodulePathsCacheEntry = { paths: string[]; expiresAt: number; retainedCodeUnits: number } +type SubmodulePathsCacheEntry = { paths: string[]; expiresAt: number } export type SubmodulePathsCache = { entries: Map generation: number - retainedCodeUnits: number } export function createSubmodulePathsCache(): SubmodulePathsCache { - return { entries: new Map(), generation: 0, retainedCodeUnits: 0 } + return { entries: new Map(), generation: 0 } } export function clearSubmodulePathsCache(cache: SubmodulePathsCache): void { cache.entries.clear() - cache.retainedCodeUnits = 0 // Why: a pre-mutation SSH read must not restore stale .gitmodules paths // after the mutation invalidated them. cache.generation += 1 @@ -50,19 +41,6 @@ export function getSubmodulePathsCacheCount(cache: SubmodulePathsCache): number return cache.entries.size } -export function getSubmodulePathsCacheCodeUnits(cache: SubmodulePathsCache): number { - return cache.retainedCodeUnits -} - -function deleteSubmodulePathsCacheEntry(cache: SubmodulePathsCache, worktreePath: string): void { - const entry = cache.entries.get(worktreePath) - if (!entry) { - return - } - cache.entries.delete(worktreePath) - cache.retainedCodeUnits -= entry.retainedCodeUnits -} - function getCachedSubmodulePaths( cache: SubmodulePathsCache, worktreePath: string, @@ -73,7 +51,7 @@ function getCachedSubmodulePaths( return null } if (cached.expiresAt <= now) { - deleteSubmodulePathsCacheEntry(cache, worktreePath) + cache.entries.delete(worktreePath) return null } cache.entries.delete(worktreePath) @@ -84,7 +62,7 @@ function getCachedSubmodulePaths( function pruneExpiredSubmodulePaths(cache: SubmodulePathsCache, now: number): void { for (const [worktreePath, entry] of cache.entries) { if (entry.expiresAt <= now) { - deleteSubmodulePathsCacheEntry(cache, worktreePath) + cache.entries.delete(worktreePath) } } } @@ -95,31 +73,14 @@ function rememberSubmodulePaths( paths: string[], now: number ): void { - deleteSubmodulePathsCacheEntry(cache, worktreePath) - if (Buffer.byteLength(worktreePath, 'utf8') > MAX_SUBMODULE_PATHS_CACHE_KEY_BYTES) { - return - } - // Why: JS strings retain UTF-16 code units, so byte counts understate non-ASCII heap use. - const retainedCodeUnits = - worktreePath.length + paths.reduce((total, submodulePath) => total + submodulePath.length, 0) - if (retainedCodeUnits > MAX_SUBMODULE_PATHS_CACHE_CODE_UNITS) { - return - } - cache.entries.set(worktreePath, { - paths, - expiresAt: now + SUBMODULE_PATHS_CACHE_TTL_MS, - retainedCodeUnits - }) - cache.retainedCodeUnits += retainedCodeUnits - while ( - cache.entries.size > MAX_SUBMODULE_PATHS_CACHE_ENTRIES || - cache.retainedCodeUnits > MAX_SUBMODULE_PATHS_CACHE_CODE_UNITS - ) { + cache.entries.delete(worktreePath) + cache.entries.set(worktreePath, { paths, expiresAt: now + SUBMODULE_PATHS_CACHE_TTL_MS }) + while (cache.entries.size > MAX_SUBMODULE_PATHS_CACHE_ENTRIES) { const oldestPath = cache.entries.keys().next().value if (oldestPath === undefined) { break } - deleteSubmodulePathsCacheEntry(cache, oldestPath) + cache.entries.delete(oldestPath) } } @@ -158,37 +119,20 @@ export async function listSubmodulePaths(git: GitExec, worktreePath: string): Pr try { const { stdout } = await git( ['config', '--file', '.gitmodules', '--get-regexp', '^submodule\\..*\\.path$'], - worktreePath, - { maxBuffer: MAX_SUBMODULE_PATHS_OUTPUT_BYTES } + worktreePath ) - const paths: string[] = [] - let retainedCodeUnits = 0 - for (const line of iterateProcessOutputLines(stdout)) { - if (line.length > MAX_SUBMODULE_PATH_CODE_UNITS + 4_096) { - return [] - } - const spaceIndex = line.indexOf(' ') - const submodulePath = - spaceIndex === -1 + return stdout + .split(/\r?\n/) + .map((line) => { + const spaceIndex = line.indexOf(' ') + return spaceIndex === -1 ? '' : line .slice(spaceIndex + 1) .trim() .replace(/\/+$/, '') - if (!submodulePath) { - continue - } - if ( - paths.length >= MAX_SUBMODULE_PATHS_PER_REPO || - submodulePath.length > MAX_SUBMODULE_PATH_CODE_UNITS || - submodulePath.length > MAX_SUBMODULE_PATHS_PER_REPO_CODE_UNITS - retainedCodeUnits - ) { - return [] - } - paths.push(submodulePath) - retainedCodeUnits += submodulePath.length - } - return paths + }) + .filter((value) => value.length > 0) } catch { return [] } diff --git a/src/relay/git-handler-utils.ts b/src/relay/git-handler-utils.ts index 329797b687e..ab3bb61faba 100644 --- a/src/relay/git-handler-utils.ts +++ b/src/relay/git-handler-utils.ts @@ -10,7 +10,6 @@ import * as path from 'node:path' import { decodeGitCQuotedPath } from '../shared/git-cquoted-path' import { isBinaryBuffer } from '../shared/binary-buffer' import type { GitLineStats } from '../shared/git-uncommitted-line-stats' -import { iterateNulDelimitedFields } from '../shared/nul-delimited-fields' export { isUnsupportedWorktreeListZError } from '../shared/git-worktree-command-capabilities' export function parseBranchStatusChar(char: string): string { @@ -210,7 +209,7 @@ function splitNulWorktreeList(output: string): string[][] { const blocks: string[][] = [] let currentBlock: string[] = [] - for (const field of iterateNulDelimitedFields(output)) { + for (const field of output.split('\0')) { if (field) { currentBlock.push(field) continue diff --git a/src/relay/git-handler.ts b/src/relay/git-handler.ts index 61f1728f79c..54dfa86a674 100644 --- a/src/relay/git-handler.ts +++ b/src/relay/git-handler.ts @@ -11,7 +11,6 @@ import { parseWorktreeList } from './git-handler-utils' import { parseNumstat } from '../shared/git-uncommitted-line-stats' -import { iterateNulDelimitedFields } from '../shared/nul-delimited-fields' import { computeDiff, branchCompare as branchCompareOp, @@ -78,15 +77,7 @@ import { isUnsupportedRevParsePathFormatError } from '../shared/git-worktree-command-capabilities' import { GitResponseStreamRegistry } from './git-response-stream' -import { - JsonStringifyByteLimitError, - stringifyJsonWithinByteLimit -} from '../shared/node-bounded-json-stringify' -import { - GIT_RESPONSE_STREAM_THRESHOLD, - MAX_GIT_RESPONSE_STREAM_BYTES, - RelayErrorCode -} from './protocol' +import { GIT_RESPONSE_STREAM_THRESHOLD } from './protocol' import { endSubprocessStdin } from '../shared/subprocess-stdin-write' import { clearGitStatusLineStatsCache } from '../shared/git-status-line-stats-cache' import { streamRelayGitStdout } from './git-stdout-stream' @@ -278,26 +269,11 @@ export class GitHandler { if (params.__streamResponse !== true || !context) { return result } - let serialized: string - let payloadBytes: number - try { - const bounded = stringifyJsonWithinByteLimit(result ?? null, MAX_GIT_RESPONSE_STREAM_BYTES) - serialized = bounded.serialized - payloadBytes = bounded.byteLength - } catch (error) { - if (!(error instanceof JsonStringifyByteLimitError)) { - throw error - } - const oversized = new Error( - `Git response is too large to transfer safely (more than ${MAX_GIT_RESPONSE_STREAM_BYTES} bytes)` - ) as Error & { code: number } - oversized.code = RelayErrorCode.StreamProtocolError - throw oversized - } - if (payloadBytes <= GIT_RESPONSE_STREAM_THRESHOLD) { + const payload = Buffer.from(JSON.stringify(result ?? null), 'utf-8') + if (payload.length <= GIT_RESPONSE_STREAM_THRESHOLD) { return result } - return this.responseStreams.startStream(serialized, this.dispatcher, context) + return this.responseStreams.startStream(payload, this.dispatcher, context) } private clearGitMutationReadCaches(): void { @@ -715,7 +691,7 @@ export class GitHandler { worktreePath ) // Why: a selected tracked directory can make `ls-files -z` return enough descendants for push(...split) to exceed the argument limit. - for (const trackedPathSpec of iterateNulDelimitedFields(stdout)) { + for (const trackedPathSpec of stdout.split('\0')) { if (trackedPathSpec) { trackedPathSpecs.push(trackedPathSpec) } diff --git a/src/relay/git-response-stream-ownership.test.ts b/src/relay/git-response-stream-ownership.test.ts index 758a898d8ee..c6340265238 100644 --- a/src/relay/git-response-stream-ownership.test.ts +++ b/src/relay/git-response-stream-ownership.test.ts @@ -1,9 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { RelayDispatcher, RequestContext } from './dispatcher' -import { - GitResponseStreamRegistry, - MAX_CONCURRENT_GIT_RESPONSE_STREAMS -} from './git-response-stream' +import { GitResponseStreamRegistry } from './git-response-stream' import { GIT_RESPONSE_CHUNK_SIZE, STREAM_ACK_WINDOW_CHUNKS } from './protocol' async function flushPump(): Promise { @@ -40,20 +37,10 @@ describe('GitResponseStreamRegistry client ownership', () => { await flushPump() expect(notifyBulk).toHaveBeenCalledTimes(STREAM_ACK_WINDOW_CHUNKS) - registry.recordAck(streamId, 10_000, ownerClientId) - await flushPump() - expect(notifyBulk).toHaveBeenCalledTimes(STREAM_ACK_WINDOW_CHUNKS) - registry.recordAck(streamId, 10_000, ownerClientId + 1) await flushPump() expect(notifyBulk).toHaveBeenCalledTimes(STREAM_ACK_WINDOW_CHUNKS) - for (const invalidSeq of [-1, 0.5, Number.MAX_SAFE_INTEGER + 1]) { - registry.recordAck(streamId, invalidSeq, ownerClientId) - } - await flushPump() - expect(notifyBulk).toHaveBeenCalledTimes(STREAM_ACK_WINDOW_CHUNKS) - registry.abort(streamId, ownerClientId + 1) registry.recordAck(streamId, STREAM_ACK_WINDOW_CHUNKS - 1, ownerClientId) await flushPump() @@ -78,115 +65,4 @@ describe('GitResponseStreamRegistry client ownership', () => { expect(notifyBulk.mock.calls[0]?.[0]).toBe('git.responseChunk') expect(notifyBulk.mock.calls[1]?.[0]).toBe('git.responseError') }) - - it('caps parked streams and defers base64 expansion until the pump runs', () => { - const dispatcher = { - notifyBulk: vi.fn().mockResolvedValue(undefined), - notify: vi.fn() - } as unknown as RelayDispatcher - const registry = new GitResponseStreamRegistry() - registries.push(registry) - const payload = Buffer.from('payload') - const toStringSpy = vi.spyOn(Buffer.prototype, 'toString') - - try { - for (let index = 0; index < MAX_CONCURRENT_GIT_RESPONSE_STREAMS; index += 1) { - registry.startStream(payload, dispatcher, { - clientId: 7, - isStale: () => false - }) - } - - expect(toStringSpy.mock.calls.some(([encoding]) => encoding === 'base64')).toBe(false) - expect(() => - registry.startStream(payload, dispatcher, { - clientId: 7, - isStale: () => false - }) - ).toThrow(`Too many concurrent git response streams`) - } finally { - toStringSpy.mockRestore() - } - }) - - it('caps aggregate bytes retained by concurrent parked responses', () => { - const dispatcher = { - notifyBulk: vi.fn().mockResolvedValue(undefined), - notify: vi.fn() - } as unknown as RelayDispatcher - const registry = new GitResponseStreamRegistry(10) - registries.push(registry) - const context = { clientId: 7, isStale: () => false } - - registry.startStream(Buffer.alloc(6), dispatcher, context) - registry.startStream(Buffer.alloc(4), dispatcher, context) - - expect(() => registry.startStream(Buffer.alloc(1), dispatcher, context)).toThrow( - 'Concurrent git responses exceed retained-byte limit (10 bytes)' - ) - }) - - it('rejects a serialized string before allocating any encoded chunk', () => { - const dispatcher = { - notifyBulk: vi.fn().mockResolvedValue(undefined), - notify: vi.fn() - } as unknown as RelayDispatcher - const registry = new GitResponseStreamRegistry(0) - registries.push(registry) - const fromSpy = vi.spyOn(Buffer, 'from') - - try { - expect(() => - registry.startStream('serialized response', dispatcher, { - clientId: 7, - isStale: () => false - }) - ).toThrow('Concurrent git responses exceed retained-byte limit (0 bytes)') - expect(fromSpy).not.toHaveBeenCalled() - } finally { - fromSpy.mockRestore() - } - }) - - it('streams a serialized string without changing its UTF-8 bytes', async () => { - const chunks: Buffer[] = [] - const notifyBulk = vi.fn().mockImplementation((method, params) => { - if (method === 'git.responseChunk') { - chunks.push(Buffer.from(params.data, 'base64')) - } - return Promise.resolve() - }) - const dispatcher = { notifyBulk, notify: vi.fn() } as unknown as RelayDispatcher - const registry = new GitResponseStreamRegistry() - registries.push(registry) - const serialized = JSON.stringify({ text: `boundary-${'🐋'.repeat(40_000)}` }) - - const marker = registry.startStream(serialized, dispatcher, { - clientId: 7, - isStale: () => false - }) - await flushPump() - await flushPump() - - expect(Buffer.concat(chunks)).toEqual(Buffer.from(serialized, 'utf8')) - expect(chunks).toHaveLength(marker.__orcaGitResponseStream.chunkCount) - expect(marker.__orcaGitResponseStream.totalBytes).toBe(Buffer.byteLength(serialized, 'utf8')) - }) - - it('releases aggregate bytes after completion and disposal', async () => { - const dispatcher = { - notifyBulk: vi.fn().mockResolvedValue(undefined), - notify: vi.fn() - } as unknown as RelayDispatcher - const registry = new GitResponseStreamRegistry(10) - registries.push(registry) - const context = { clientId: 7, isStale: () => false } - - registry.startStream(Buffer.alloc(10), dispatcher, context) - await flushPump() - expect(() => registry.startStream(Buffer.alloc(10), dispatcher, context)).not.toThrow() - - registry.disposeAll() - expect(() => registry.startStream(Buffer.alloc(10), dispatcher, context)).not.toThrow() - }) }) diff --git a/src/relay/git-response-stream.ts b/src/relay/git-response-stream.ts index 2cdb6a73944..bced3b47e46 100644 --- a/src/relay/git-response-stream.ts +++ b/src/relay/git-response-stream.ts @@ -6,95 +6,42 @@ import type { RelayDispatcher, RequestContext } from './dispatcher' import { GIT_RESPONSE_CHUNK_SIZE, - MAX_GIT_RESPONSE_STREAM_BYTES, - MAX_GIT_RESPONSE_STREAM_CHUNKS, - MAX_CONCURRENT_STREAMS, - RelayErrorCode, STREAM_ACK_WINDOW_CHUNKS, STREAM_ACK_STALL_RECHECK_MS, type GitResponseStreamMarker } from './protocol' -import { - encodeUtf8StringChunk, - planUtf8StringChunks, - type Utf8StringChunk -} from './git-response-utf8-chunks' type GitResponseStreamEntry = { ownerClientId: number - retainedBytes: number aborted: boolean - /** Highest chunk seq admitted to the outbound bulk lane. */ - sentThroughSeq: number /** Highest chunk seq the client acknowledged (in-order; -1 = none yet). */ ackedThroughSeq: number ackWaiters: Set<() => void> } -type PreparedGitResponse = - | { kind: 'buffer'; value: Buffer; byteLength: number; chunkCount: number } - | { - kind: 'string' - value: string - byteLength: number - chunks: Utf8StringChunk[] - chunkCount: number - } - -export const MAX_CONCURRENT_GIT_RESPONSE_STREAMS = MAX_CONCURRENT_STREAMS -export const MAX_ACTIVE_GIT_RESPONSE_STREAM_BYTES = 128 * 1024 * 1024 +/** Serialized git responses are chunked as base64 so multi-byte UTF-8 + * sequences never split across a chunk boundary (the client concatenates the + * decoded bytes and parses once). */ +function encodeChunks(payload: Buffer): string[] { + const chunks: string[] = [] + for (let offset = 0; offset < payload.length; offset += GIT_RESPONSE_CHUNK_SIZE) { + chunks.push(payload.subarray(offset, offset + GIT_RESPONSE_CHUNK_SIZE).toString('base64')) + } + return chunks +} export class GitResponseStreamRegistry { private streams = new Map() private nextId = 1 - private retainedBytes = 0 - constructor(private readonly maxRetainedBytes: number = MAX_ACTIVE_GIT_RESPONSE_STREAM_BYTES) {} - - private preparePayload(payload: Buffer | string): PreparedGitResponse { - if (Buffer.isBuffer(payload)) { - return { - kind: 'buffer', - value: payload, - byteLength: payload.length, - chunkCount: Math.ceil(payload.length / GIT_RESPONSE_CHUNK_SIZE) - } - } - const chunks = planUtf8StringChunks(payload, GIT_RESPONSE_CHUNK_SIZE) - return { - kind: 'string', - value: payload, - byteLength: Buffer.byteLength(payload, 'utf8'), - chunks, - chunkCount: chunks.length - } - } - - private register(ownerClientId: number, retainedBytes: number): number { - if (this.streams.size >= MAX_CONCURRENT_GIT_RESPONSE_STREAMS) { - const error = new Error( - `Too many concurrent git response streams (max ${MAX_CONCURRENT_GIT_RESPONSE_STREAMS})` - ) as Error & { code: number } - error.code = RelayErrorCode.TooManyStreams - throw error - } - if (retainedBytes > this.maxRetainedBytes - this.retainedBytes) { - const error = new Error( - `Concurrent git responses exceed retained-byte limit (${this.maxRetainedBytes} bytes)` - ) as Error & { code: number } - error.code = RelayErrorCode.TooManyStreams - throw error - } + private register(ownerClientId: number): number { const streamId = this.nextId++ this.streams.set(streamId, { ownerClientId, - retainedBytes, aborted: false, - sentThroughSeq: -1, ackedThroughSeq: -1, ackWaiters: new Set() }) - this.retainedBytes += retainedBytes return streamId } @@ -103,9 +50,8 @@ export class GitResponseStreamRegistry { if ( !entry || entry.ownerClientId !== clientId || - !Number.isSafeInteger(seq) || - seq < 0 || - seq > entry.sentThroughSeq + typeof seq !== 'number' || + !Number.isFinite(seq) ) { return } @@ -165,39 +111,25 @@ export class GitResponseStreamRegistry { * sentinel marker to send as the RPC result. */ startStream( - payload: Buffer | string, + payload: Buffer, dispatcher: RelayDispatcher, context: RequestContext ): GitResponseStreamMarker { - const prepared = this.preparePayload(payload) - if ( - prepared.byteLength > MAX_GIT_RESPONSE_STREAM_BYTES || - prepared.chunkCount > MAX_GIT_RESPONSE_STREAM_CHUNKS - ) { - const error = new Error( - `Git response exceeds stream limit (${prepared.byteLength} bytes, ${prepared.chunkCount} chunks)` - ) as Error & { code: number } - error.code = RelayErrorCode.StreamProtocolError - throw error - } - const streamId = this.register(context.clientId, prepared.byteLength) + const streamId = this.register(context.clientId) + const chunks = encodeChunks(payload) // Why: kick the pump off the response task so the client sees the sentinel // (and can subscribe/reassemble) before the first chunk frame arrives. setImmediate(() => { - void this.pump(streamId, prepared, dispatcher, context) + void this.pump(streamId, chunks, dispatcher, context) }) return { - __orcaGitResponseStream: { - streamId, - totalBytes: prepared.byteLength, - chunkCount: prepared.chunkCount - } + __orcaGitResponseStream: { streamId, totalBytes: payload.length, chunkCount: chunks.length } } } private async pump( streamId: number, - payload: PreparedGitResponse, + chunks: string[], dispatcher: RelayDispatcher, context: RequestContext ): Promise { @@ -209,7 +141,7 @@ export class GitResponseStreamRegistry { let seq = 0 let endReason: 'end' | 'aborted' | 'stale' = 'end' try { - for (seq = 0; seq < payload.chunkCount; seq += 1) { + for (seq = 0; seq < chunks.length; seq += 1) { if (context.isStale()) { endReason = 'stale' break @@ -235,20 +167,11 @@ export class GitResponseStreamRegistry { endReason = 'aborted' break } - const chunk = - payload.kind === 'buffer' - ? payload.value.subarray( - seq * GIT_RESPONSE_CHUNK_SIZE, - (seq + 1) * GIT_RESPONSE_CHUNK_SIZE - ) - : encodeUtf8StringChunk(payload.value, payload.chunks[seq]) - // Why: encode only the current chunk; eager base64 retained a second, - // 4/3-expanded copy of every parked response until its final ACK. - const data = chunk.toString('base64') - entry.sentThroughSeq = seq + // Why: notifyBulk waits out sink saturation so chunk frames never pile + // up in the outbound pipe ahead of interactive pty.data frames. await dispatcher.notifyBulk( 'git.responseChunk', - { streamId, seq, data }, + { streamId, seq, data: chunks[seq] }, { clientId } @@ -274,25 +197,15 @@ export class GitResponseStreamRegistry { } } } finally { - this.deleteStream(streamId) + this.streams.delete(streamId) } } - private deleteStream(streamId: number): void { - const entry = this.streams.get(streamId) - if (!entry) { - return - } - this.streams.delete(streamId) - this.retainedBytes -= entry.retainedBytes - } - disposeAll(): void { for (const entry of this.streams.values()) { entry.aborted = true this.wake(entry) } this.streams.clear() - this.retainedBytes = 0 } } diff --git a/src/relay/git-response-utf8-chunks.test.ts b/src/relay/git-response-utf8-chunks.test.ts deleted file mode 100644 index 1b2239dcb90..00000000000 --- a/src/relay/git-response-utf8-chunks.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { encodeUtf8StringChunk, planUtf8StringChunks } from './git-response-utf8-chunks' - -describe('Git response UTF-8 chunk planning', () => { - it('round-trips mixed-width text without splitting code points', () => { - const value = 'ascii-é-€-🐋-end'.repeat(20) - const plan = planUtf8StringChunks(value, 17) - const encoded = plan.map((chunk) => encodeUtf8StringChunk(value, chunk)) - - expect(Buffer.concat(encoded)).toEqual(Buffer.from(value, 'utf8')) - expect(plan.every((chunk) => chunk.byteLength <= 17)).toBe(true) - expect(plan.reduce((total, chunk) => total + chunk.byteLength, 0)).toBe( - Buffer.byteLength(value, 'utf8') - ) - }) - - it('matches Node replacement encoding for unmatched surrogate code units', () => { - const value = 'before-\ud800-after-\udc00' - const plan = planUtf8StringChunks(value, 8) - - expect(Buffer.concat(plan.map((chunk) => encodeUtf8StringChunk(value, chunk)))).toEqual( - Buffer.from(value, 'utf8') - ) - }) - - it('returns no chunks for empty text and rejects unsafe chunk sizes', () => { - expect(planUtf8StringChunks('', 4)).toEqual([]) - expect(() => planUtf8StringChunks('value', 3)).toThrow(RangeError) - }) -}) diff --git a/src/relay/git-response-utf8-chunks.ts b/src/relay/git-response-utf8-chunks.ts deleted file mode 100644 index 1c55dbeed5b..00000000000 --- a/src/relay/git-response-utf8-chunks.ts +++ /dev/null @@ -1,55 +0,0 @@ -export type Utf8StringChunk = { - start: number - end: number - byteLength: number -} - -function encodedCodePointSize(value: string, index: number): { bytes: number; codeUnits: number } { - const code = value.charCodeAt(index) - if (code <= 0x7f) { - return { bytes: 1, codeUnits: 1 } - } - if (code <= 0x7ff) { - return { bytes: 2, codeUnits: 1 } - } - if (code >= 0xd800 && code <= 0xdbff) { - const next = value.charCodeAt(index + 1) - if (next >= 0xdc00 && next <= 0xdfff) { - return { bytes: 4, codeUnits: 2 } - } - } - return { bytes: 3, codeUnits: 1 } -} - -export function planUtf8StringChunks(value: string, maxChunkBytes: number): Utf8StringChunk[] { - if (!Number.isSafeInteger(maxChunkBytes) || maxChunkBytes < 4) { - throw new RangeError('UTF-8 chunk limit must be a safe integer of at least 4 bytes') - } - - const chunks: Utf8StringChunk[] = [] - let chunkStart = 0 - let chunkBytes = 0 - let index = 0 - while (index < value.length) { - const encoded = encodedCodePointSize(value, index) - if (chunkBytes > 0 && chunkBytes + encoded.bytes > maxChunkBytes) { - chunks.push({ start: chunkStart, end: index, byteLength: chunkBytes }) - chunkStart = index - chunkBytes = 0 - } - chunkBytes += encoded.bytes - index += encoded.codeUnits - } - if (chunkBytes > 0) { - chunks.push({ start: chunkStart, end: value.length, byteLength: chunkBytes }) - } - return chunks -} - -export function encodeUtf8StringChunk(value: string, chunk: Utf8StringChunk): Buffer { - const encoded = Buffer.from(value.slice(chunk.start, chunk.end), 'utf8') - if (encoded.length !== chunk.byteLength) { - throw new Error('UTF-8 chunk plan did not match encoded byte length') - } - return encoded -} diff --git a/src/relay/git-status-upstream-negative-cache.test.ts b/src/relay/git-status-upstream-negative-cache.test.ts index e6085e3b2ce..c69fa11e4e8 100644 --- a/src/relay/git-status-upstream-negative-cache.test.ts +++ b/src/relay/git-status-upstream-negative-cache.test.ts @@ -4,7 +4,6 @@ import { clearNoEffectiveUpstreamStatusCacheEntry, getNoEffectiveUpstreamStatusCacheCountForTests, getNoEffectiveUpstreamStatusGenerationCountForTests, - MAX_NO_EFFECTIVE_UPSTREAM_CACHE_KEY_BYTES, readOrProbeNoEffectiveUpstreamStatus } from './git-status-upstream-negative-cache' @@ -328,59 +327,6 @@ describe('relay upstream negative cache', () => { expect(getNoEffectiveUpstreamStatusGenerationCountForTests()).toBe(512) }) - it('retains the exact key-byte boundary but not oversized identities', async () => { - const worktreePath = '/r' - const keyOverheadBytes = Buffer.byteLength(`${worktreePath}\0\0`, 'utf8') - const exactBranch = 'x'.repeat(MAX_NO_EFFECTIVE_UPSTREAM_CACHE_KEY_BYTES - keyOverheadBytes) - const oversizedBranch = `${exactBranch}x` - const createRunner = (branchName: string) => - vi.fn(async (args: string[]): Promise<{ stdout: string }> => { - if (args[0] === 'symbolic-ref') { - return { stdout: `${branchName}\n` } - } - if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) { - throw new Error(`fatal: no upstream configured for branch ${branchName}`) - } - if (isConfigListSnapshotCommand(args)) { - return emptyGitConfigSnapshot() - } - if (args[0] === 'rev-parse') { - throw new Error('missing remote branch') - } - throw new Error(`No upstream fixture for git ${args.join(' ')}`) - }) - const exactRunner = createRunner(exactBranch) - const oversizedRunner = createRunner(oversizedBranch) - - await readOrProbeNoEffectiveUpstreamStatus( - { worktreePath, branchName: exactBranch }, - exactRunner - ) - await readOrProbeNoEffectiveUpstreamStatus( - { worktreePath, branchName: exactBranch }, - exactRunner - ) - await readOrProbeNoEffectiveUpstreamStatus( - { worktreePath, branchName: oversizedBranch }, - oversizedRunner - ) - await readOrProbeNoEffectiveUpstreamStatus( - { worktreePath, branchName: oversizedBranch }, - oversizedRunner - ) - clearNoEffectiveUpstreamStatusCacheEntry({ - worktreePath, - branchName: oversizedBranch - }) - - expect(exactRunner.mock.calls.filter(([args]) => args[0] === 'symbolic-ref')).toHaveLength(1) - expect(oversizedRunner.mock.calls.filter(([args]) => args[0] === 'symbolic-ref')).toHaveLength( - 2 - ) - expect(getNoEffectiveUpstreamStatusCacheCountForTests()).toBe(1) - expect(getNoEffectiveUpstreamStatusGenerationCountForTests()).toBe(0) - }) - it('coalesces no-upstream config reads into one snapshot subprocess', async () => { const runGit = vi.fn(async (args: string[]): Promise<{ stdout: string }> => { if (args[0] === 'symbolic-ref') { diff --git a/src/relay/git-status-upstream-negative-cache.ts b/src/relay/git-status-upstream-negative-cache.ts index 387c2a8b060..e3ad9239cc9 100644 --- a/src/relay/git-status-upstream-negative-cache.ts +++ b/src/relay/git-status-upstream-negative-cache.ts @@ -5,7 +5,6 @@ import type { GitUpstreamStatus } from '../shared/types' const NO_EFFECTIVE_UPSTREAM_CACHE_TTL_MS = 5 * 60_000 const MAX_NO_EFFECTIVE_UPSTREAM_CACHE_ENTRIES = 512 -export const MAX_NO_EFFECTIVE_UPSTREAM_CACHE_KEY_BYTES = 64 * 1024 type NoEffectiveUpstreamCacheIdentity = { worktreePath: string @@ -27,10 +26,6 @@ function noEffectiveUpstreamCacheKey(identity: NoEffectiveUpstreamCacheIdentity) return [identity.worktreePath, identity.branchName, identity.upstreamName ?? ''].join('\0') } -function canRetainNoEffectiveUpstreamCacheKey(cacheKey: string): boolean { - return Buffer.byteLength(cacheKey, 'utf8') <= MAX_NO_EFFECTIVE_UPSTREAM_CACHE_KEY_BYTES -} - function readCachedNoEffectiveUpstreamStatus( cacheKey: string, nowMs = Date.now() @@ -71,11 +66,6 @@ function cacheNoEffectiveUpstreamStatus( writeGeneration: number, nowMs = Date.now() ): void { - if (!canRetainNoEffectiveUpstreamCacheKey(cacheKey)) { - noEffectiveUpstreamByIdentity.delete(cacheKey) - noEffectiveUpstreamWriteGeneration.delete(cacheKey) - return - } // Why: hasConfiguredPushTarget controls publish behavior; keep that signal // fresh rather than serving a stale positive from status polling. if (status.hasUpstream || status.hasConfiguredPushTarget) { @@ -164,10 +154,6 @@ export function clearNoEffectiveUpstreamStatusCacheEntry( retireNoEffectiveUpstreamProbe(cacheKey) noEffectiveUpstreamByIdentity.delete(cacheKey) noEffectiveUpstreamInFlight.delete(cacheKey) - if (!canRetainNoEffectiveUpstreamCacheKey(cacheKey)) { - noEffectiveUpstreamWriteGeneration.delete(cacheKey) - return - } noEffectiveUpstreamWriteGeneration.set( cacheKey, (noEffectiveUpstreamWriteGeneration.get(cacheKey) ?? 0) + 1 diff --git a/src/relay/git-working-file-read.test.ts b/src/relay/git-working-file-read.test.ts index 4f52b30196c..7f9824ead94 100644 --- a/src/relay/git-working-file-read.test.ts +++ b/src/relay/git-working-file-read.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, rm, truncate, writeFile } from 'node:fs/promises' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import * as path from 'node:path' import { afterEach, describe, expect, it } from 'vitest' @@ -29,8 +29,7 @@ describe('readWorkingDiffFile', () => { it('marks oversized working-tree files as binary before diffing', async () => { tmpDir = await mkdtemp(path.join(tmpdir(), 'relay-working-file-')) const filePath = path.join(tmpDir, 'large.log') - await writeFile(filePath, 'a') - await truncate(filePath, 10 * 1024 * 1024 + 1) + await writeFile(filePath, Buffer.alloc(10 * 1024 * 1024 + 1, 'a')) await expect(readWorkingDiffFile(filePath)).resolves.toEqual({ content: '', diff --git a/src/relay/git-working-file-read.ts b/src/relay/git-working-file-read.ts index 8f639189863..05cf1ebcdce 100644 --- a/src/relay/git-working-file-read.ts +++ b/src/relay/git-working-file-read.ts @@ -1,9 +1,5 @@ -import { stat } from 'node:fs/promises' +import { readFile, stat } from 'node:fs/promises' import { bufferToBlob } from './git-handler-utils' -import { - NodeFileReadTooLargeError, - readNodeFileWithinLimit -} from '../shared/node-bounded-file-reader' const MAX_RELAY_DIFF_WORKING_FILE_BYTES = 10 * 1024 * 1024 @@ -23,16 +19,16 @@ export async function readWorkingDiffFile( if (!fileStat.isFile()) { return { content: '', isBinary: false, missing: true } } + if (fileStat.size > MAX_RELAY_DIFF_WORKING_FILE_BYTES) { + // Why: mirror local git diff reads, which cap blob transfer at 10MB. + return { content: '', isBinary: true, missing: false } + } try { - const { buffer } = await readNodeFileWithinLimit(absPath, MAX_RELAY_DIFF_WORKING_FILE_BYTES) + const buffer = await readFile(absPath) // Why: bufferToBlob needs the path's extension to know an image is // previewable; omitting it made every relay-side binary diff empty. return { ...bufferToBlob(buffer, absPath), missing: false } - } catch (error) { - if (error instanceof NodeFileReadTooLargeError) { - // Why: mirror local git diff reads, which classify blobs over 10MB as binary. - return { content: '', isBinary: true, missing: false } - } + } catch { // Why: the file exists but could not be read — a read failure, not a deletion. return { content: '', isBinary: false, missing: false } } diff --git a/src/relay/integration.test.ts b/src/relay/integration.test.ts index a3fcfd75adb..71d09c78de6 100644 --- a/src/relay/integration.test.ts +++ b/src/relay/integration.test.ts @@ -23,7 +23,6 @@ import { RelayDispatcher } from './dispatcher' import { RelayContext } from './context' import { FsHandler } from './fs-handler' import { GitHandler } from './git-handler' -import { SshFilesystemProvider } from '../main/providers/ssh-filesystem-provider' function gitInit(dir: string): void { execFileSync('git', ['init'], { cwd: dir, stdio: 'pipe' }) @@ -109,17 +108,6 @@ describe('Integration: Client Mux ↔ Relay Dispatcher', () => { expect(names).toEqual(['hello.txt', 'readme.md']) }) - it('routes SSH provider directory reads through the bounded relay handler', async () => { - writeFileSync(path.join(tmpDir, 'hello.txt'), 'world') - const provider = new SshFilesystemProvider('test-connection', mux) - - await expect(provider.readDir(tmpDir)).resolves.toEqual([ - { name: 'hello.txt', isDirectory: false, isSymlink: false } - ]) - - provider.dispose() - }) - it('readFile returns text content', async () => { writeFileSync(path.join(tmpDir, 'data.txt'), 'some content') @@ -134,11 +122,6 @@ describe('Integration: Client Mux ↔ Relay Dispatcher', () => { it('readFileStream round-trip preserves a 12 MB binary file', async () => { const filePath = path.join(tmpDir, 'big.png') const original = randomBytes(12 * 1024 * 1024) - Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(original) - original.writeUInt32BE(13, 8) - original.write('IHDR', 12, 'ascii') - original.writeUInt32BE(1, 16) - original.writeUInt32BE(1, 20) writeFileSync(filePath, original) const { readFileViaStream } = await import('../main/ssh/ssh-filesystem-stream-reader') const { content } = await readFileViaStream(mux, filePath) diff --git a/src/relay/markdown-document-listing.test.ts b/src/relay/markdown-document-listing.test.ts deleted file mode 100644 index 38f93db2ee6..00000000000 --- a/src/relay/markdown-document-listing.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { EventEmitter } from 'node:events' -import type { ChildProcess } from 'node:child_process' -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { PassThrough } from 'node:stream' -import { describe, expect, it, vi } from 'vitest' -import { - MARKDOWN_DOCUMENT_LISTING_MAX_DOCUMENTS, - MARKDOWN_DOCUMENT_LISTING_MAX_PATH_BYTES, - MarkdownDocumentListingCapacityError -} from '../shared/markdown-document-listing-limits' -import { - listMarkdownPathsWithRg, - listRelayMarkdownDocumentPaths -} from './markdown-document-listing' - -function successfulChild(output: Buffer): ChildProcess { - const child = Object.assign(new EventEmitter(), { - exitCode: null, - signalCode: null, - stdout: new PassThrough(), - stderr: new PassThrough(), - kill: vi.fn(() => true) - }) as unknown as ChildProcess - queueMicrotask(() => { - child.stdout!.emit('data', output) - child.emit('close', 0, null) - }) - return child -} - -describe('relay Markdown document producer', () => { - it('returns only Markdown paths from a real under-limit workspace', async () => { - const rootPath = await mkdtemp(join(tmpdir(), 'orca-markdown-listing-')) - try { - await mkdir(join(rootPath, 'docs')) - await writeFile(join(rootPath, 'README.md'), 'readme') - await writeFile(join(rootPath, 'docs', 'Guide.MDX'), 'guide') - await writeFile(join(rootPath, 'docs', 'app.ts'), 'code') - - const result = await listRelayMarkdownDocumentPaths(rootPath) - expect(result.sort()).toEqual(['README.md', 'docs/Guide.MDX']) - } finally { - await rm(rootPath, { recursive: true, force: true }) - } - }) - - it('filters a large non-Markdown repository before applying document retention limits', async () => { - const nonMarkdown = Array.from({ length: 100_001 }, (_value, index) => `src/${index}.ts\0`) - const output = Buffer.from(`${nonMarkdown.join('')}README.md\0docs/GUIDE.MDX\0`) - const spawnProcess = vi.fn((_args: string[]) => successfulChild(output)) - - await expect(listMarkdownPathsWithRg('/repo', undefined, spawnProcess)).resolves.toEqual([ - 'README.md', - 'docs/GUIDE.MDX' - ]) - expect(spawnProcess).toHaveBeenCalledTimes(2) - expect(spawnProcess.mock.calls[0]?.[0]).toEqual( - expect.arrayContaining(['--iglob', '*.md', '--null']) - ) - }) - - it('rejects rather than truncating the first document beyond the count limit', async () => { - const output = Buffer.from( - Array.from( - { length: MARKDOWN_DOCUMENT_LISTING_MAX_DOCUMENTS + 1 }, - (_value, index) => `${index}.md\0` - ).join('') - ) - const children: ChildProcess[] = [] - const spawnProcess = vi.fn(() => { - const child = successfulChild(output) - children.push(child) - return child - }) - - await expect(listMarkdownPathsWithRg('/repo', undefined, spawnProcess)).rejects.toBeInstanceOf( - MarkdownDocumentListingCapacityError - ) - expect(children[0]?.kill).toHaveBeenCalled() - expect(spawnProcess).toHaveBeenCalledTimes(1) - }) - - it('bounds an unterminated subprocess path before decoding or retaining it', async () => { - const child = successfulChild(Buffer.alloc(MARKDOWN_DOCUMENT_LISTING_MAX_PATH_BYTES + 1, 0x61)) - - await expect(listMarkdownPathsWithRg('/repo', undefined, () => child)).rejects.toBeInstanceOf( - MarkdownDocumentListingCapacityError - ) - expect(child.kill).toHaveBeenCalled() - }) -}) diff --git a/src/relay/markdown-document-listing.ts b/src/relay/markdown-document-listing.ts deleted file mode 100644 index a4330f3442b..00000000000 --- a/src/relay/markdown-document-listing.ts +++ /dev/null @@ -1,238 +0,0 @@ -import { spawn, type ChildProcess } from 'node:child_process' -import { checkRgAvailable } from './fs-handler-utils' -import { - buildRgArgsForQuickOpen, - normalizeQuickOpenRgLine, - shouldIncludeQuickOpenPath -} from '../shared/quick-open-filter' -import { - assertMarkdownDocumentPathWithinLimit, - createMarkdownDocumentListingBudget, - isMarkdownDocumentListingCapacityError, - MARKDOWN_DOCUMENT_LISTING_ERROR_MESSAGE, - MARKDOWN_DOCUMENT_LISTING_MAX_PATH_BYTES, - MarkdownDocumentListingCapacityError, - retainMarkdownRelativePath, - visitMarkdownDocumentListingEntry -} from '../shared/markdown-document-listing-limits' -import { - discoverMarkdownRelativePaths, - isMarkdownDocumentPath -} from '../shared/node-markdown-document-discovery' -import { fileListingCancellationError } from '../shared/file-listing-cancellation' -import { RelayErrorCode } from './protocol' -import { GrowingByteBuffer } from '../shared/growing-byte-buffer' - -const MARKDOWN_LISTING_TIMEOUT_MS = 25_000 -const MARKDOWN_GLOBS = ['*.md', '*.mdx', '*.markdown'] - -class MarkdownPathFieldAccumulator { - private readonly field = new GrowingByteBuffer() - - push(chunk: Buffer, onPath: (path: string) => void): boolean { - let cursor = 0 - while (cursor < chunk.length) { - const delimiter = chunk.indexOf(0, cursor) - const end = delimiter === -1 ? chunk.length : delimiter - const segmentBytes = end - cursor - if (this.field.byteLength + segmentBytes > MARKDOWN_DOCUMENT_LISTING_MAX_PATH_BYTES) { - this.clear() - return false - } - if (delimiter !== -1 && this.field.byteLength === 0) { - onPath(chunk.toString('utf8', cursor, end)) - } else if (segmentBytes > 0) { - this.field.append(chunk.subarray(cursor, end)) - if (delimiter !== -1) { - onPath(this.take()) - } - } else if (delimiter !== -1) { - onPath(this.take()) - } - if (delimiter === -1) { - return true - } - cursor = delimiter + 1 - } - return true - } - - finish(): string | null { - return this.field.byteLength > 0 ? this.take() : null - } - - private take(): string { - return this.field.takeString() - } - - private clear(): void { - this.field.clear() - } -} - -function markdownRgArgs(args: string[]): string[] { - const target = args.at(-1) - if (!target) { - throw new Error('Markdown rg scan is missing its search root') - } - const result = [...args.slice(0, -1), '--null'] - for (const glob of MARKDOWN_GLOBS) { - result.push('--iglob', glob) - } - result.push(target) - return result -} - -function relativePathDepth(path: string): number { - let depth = 0 - for (let index = 0; index < path.length; index += 1) { - if (path[index] === '/') { - depth += 1 - } - } - return depth -} - -export async function listMarkdownPathsWithRg( - rootPath: string, - signal?: AbortSignal, - spawnProcess: (args: string[]) => ChildProcess = (args) => - spawn('rg', args, { cwd: rootPath, stdio: ['ignore', 'pipe', 'pipe'] }) -): Promise { - const paths = new Set() - const budget = createMarkdownDocumentListingBudget() - assertMarkdownDocumentPathWithinLimit(rootPath, budget.limits.maxPathBytes) - const passes = buildRgArgsForQuickOpen({ - searchRoot: '.', - excludePathPrefixes: [], - forceSlashSeparator: true - }) - - const runPass = (args: string[]): Promise => - new Promise((resolve, reject) => { - if (signal?.aborted) { - reject(fileListingCancellationError(signal)) - return - } - const child = spawnProcess(markdownRgArgs(args)) - const fields = new MarkdownPathFieldAccumulator() - let done = false - let parseablePathCount = 0 - let timer: ReturnType | null = null - - const cleanup = (): void => { - if (timer) { - clearTimeout(timer) - timer = null - } - child.stdout?.off('data', onStdout) - child.stderr?.off('data', onStderr) - child.off('error', onError) - child.off('close', onClose) - signal?.removeEventListener('abort', onAbort) - } - const finish = (error?: Error, kill = false): void => { - if (done) { - return - } - done = true - cleanup() - if (kill && child.exitCode === null && child.signalCode === null) { - child.kill() - } - if (error) { - reject(error) - } else { - resolve() - } - } - const processPath = (rawPath: string): void => { - const relativePath = normalizeQuickOpenRgLine(rawPath, { kind: 'cwd-relative' }) - if (relativePath === null) { - return - } - parseablePathCount += 1 - if ( - paths.has(relativePath) || - !shouldIncludeQuickOpenPath(relativePath) || - !isMarkdownDocumentPath(relativePath) - ) { - return - } - visitMarkdownDocumentListingEntry(budget, relativePath, relativePathDepth(relativePath)) - retainMarkdownRelativePath(budget, rootPath, relativePath) - paths.add(relativePath) - } - const onStdout = (chunk: Buffer): void => { - try { - if (!fields.push(chunk, processPath)) { - finish(new MarkdownDocumentListingCapacityError(), true) - } - } catch (error) { - finish(error instanceof Error ? error : new Error(String(error)), true) - } - } - const onStderr = (): void => {} - const onError = (error: Error): void => finish(error) - const onClose = (code: number | null, exitSignal: NodeJS.Signals | null): void => { - if (exitSignal) { - finish(new Error(`rg killed by ${exitSignal}`)) - return - } - try { - const trailingPath = fields.finish() - if (trailingPath !== null) { - processPath(trailingPath) - } - } catch (error) { - finish(error instanceof Error ? error : new Error(String(error))) - return - } - if (code === 0 || code === 1 || (code === 2 && parseablePathCount > 0)) { - finish() - } else { - finish(new Error(`rg exited with code ${code}`)) - } - } - const onAbort = (): void => finish(fileListingCancellationError(signal), true) - - child.stdout?.on('data', onStdout) - child.stderr?.on('data', onStderr) - child.once('error', onError) - child.once('close', onClose) - signal?.addEventListener('abort', onAbort, { once: true }) - timer = setTimeout( - () => finish(new Error('Markdown document listing timed out'), true), - MARKDOWN_LISTING_TIMEOUT_MS - ) - }) - - await runPass(passes.primary) - await runPass(passes.ignoredPass) - return Array.from(paths) -} - -export async function listRelayMarkdownDocumentPaths( - rootPath: string, - signal?: AbortSignal -): Promise { - try { - if (await checkRgAvailable()) { - return await listMarkdownPathsWithRg(rootPath, signal) - } - return await discoverMarkdownRelativePaths(rootPath, { - ignoreNestedDirectoryErrors: true, - shouldDescend: (relativePath) => shouldIncludeQuickOpenPath(relativePath), - signal - }) - } catch (error) { - if (!isMarkdownDocumentListingCapacityError(error)) { - throw error - } - const relayError = new Error(MARKDOWN_DOCUMENT_LISTING_ERROR_MESSAGE) as Error & { - code: number - } - relayError.code = RelayErrorCode.MarkdownDocumentListingCapacity - throw relayError - } -} diff --git a/src/relay/mobile-file-directory-reader.test.ts b/src/relay/mobile-file-directory-reader.test.ts deleted file mode 100644 index 8000dad5c00..00000000000 --- a/src/relay/mobile-file-directory-reader.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' - -const { statMock } = vi.hoisted(() => ({ statMock: vi.fn() })) - -vi.mock('node:fs/promises', () => ({ opendir: vi.fn(), stat: statMock })) - -import { collectMobileRelayDirectoryEntries } from './mobile-file-directory-reader' - -describe('mobile relay directory reader', () => { - it('stops enumeration at the mobile entry limit', async () => { - let enumerated = 0 - const directory = { - async *[Symbol.asyncIterator]() { - while (enumerated < 20_000) { - enumerated += 1 - yield { - name: 'entry', - isDirectory: () => false, - isSymbolicLink: () => false - } - } - } - } - - await expect(collectMobileRelayDirectoryEntries('/repo', directory)).rejects.toThrow( - 'This folder is too large to show safely on mobile' - ) - expect(enumerated).toBe(10_001) - }) - - it('bounds concurrent symlink stat work while preserving entry order', async () => { - let active = 0 - let maxActive = 0 - statMock.mockImplementation(async () => { - active += 1 - maxActive = Math.max(maxActive, active) - await new Promise((resolve) => setImmediate(resolve)) - active -= 1 - return { isDirectory: () => true } - }) - const directory = { - async *[Symbol.asyncIterator]() { - for (let index = 0; index < 100; index += 1) { - yield { - name: `link-${index}`, - isDirectory: () => false, - isSymbolicLink: () => true - } - } - } - } - - const entries = await collectMobileRelayDirectoryEntries('/repo', directory) - - expect(maxActive).toBe(32) - expect(entries.map((entry) => entry.name)).toEqual( - Array.from({ length: 100 }, (_, index) => `link-${index}`) - ) - expect(entries.every((entry) => entry.isDirectory && entry.isSymlink)).toBe(true) - }) -}) diff --git a/src/relay/mobile-file-directory-reader.ts b/src/relay/mobile-file-directory-reader.ts deleted file mode 100644 index 7db3cdf7bb6..00000000000 --- a/src/relay/mobile-file-directory-reader.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { opendir, stat } from 'node:fs/promises' -import { join } from 'node:path' -import { - createMobileFileDirectoryLimitState, - trackMobileFileDirectoryEntry -} from '../shared/mobile-file-directory-limit' - -type RelayDirectorySourceEntry = { - name: string - isDirectory(): boolean - isSymbolicLink(): boolean -} - -export type RelayDirectoryEntry = { - name: string - isDirectory: boolean - isSymlink: boolean -} - -const MOBILE_DIRECTORY_STAT_CONCURRENCY = 32 - -export async function isRelayDirectoryEntry( - dirPath: string, - entry: RelayDirectorySourceEntry -): Promise { - if (entry.isDirectory()) { - return true - } - if (!entry.isSymbolicLink()) { - return false - } - try { - // Why: linked workspace directories must remain expandable in the file explorer. - return (await stat(join(dirPath, entry.name))).isDirectory() - } catch { - return false - } -} - -export async function readMobileRelayDirectory(dirPath: string): Promise { - return collectMobileRelayDirectoryEntries(dirPath, await opendir(dirPath)) -} - -export async function collectMobileRelayDirectoryEntries( - dirPath: string, - directory: AsyncIterable -): Promise { - const entries: RelayDirectoryEntry[] = [] - let batch: RelayDirectorySourceEntry[] = [] - const limit = createMobileFileDirectoryLimitState() - - const flushBatch = async (): Promise => { - entries.push( - ...(await Promise.all( - batch.map(async (entry) => ({ - name: entry.name, - isDirectory: await isRelayDirectoryEntry(dirPath, entry), - isSymlink: entry.isSymbolicLink() - })) - )) - ) - batch = [] - } - - for await (const entry of directory) { - trackMobileFileDirectoryEntry(limit, entry) - batch.push(entry) - if (batch.length === MOBILE_DIRECTORY_STAT_CONCURRENCY) { - await flushBatch() - } - } - if (batch.length > 0) { - await flushBatch() - } - return entries -} diff --git a/src/relay/plugin-overlay.test.ts b/src/relay/plugin-overlay.test.ts index 11348f99b2b..9b90692cd28 100644 --- a/src/relay/plugin-overlay.test.ts +++ b/src/relay/plugin-overlay.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { existsSync, mkdirSync, @@ -11,7 +11,6 @@ import { } from 'node:fs' import { tmpdir } from 'node:os' import { basename, join } from 'node:path' -import * as configOverlayMirroring from '../main/pty/config-overlay-mirroring' import { PluginOverlayManager } from './plugin-overlay' import { resolvePiSourceAgentDir } from './plugin-overlay-env' @@ -71,30 +70,6 @@ describe('PluginOverlayManager', () => { expect(manager.materializeOpenCode('tab-missing:0', join(homeDir, 'missing'))).toBeNull() }) - it('rejects an over-capacity remote config before replacing the last good overlay', () => { - manager.setSources({ opencodePluginSource: 'first plugin' }) - const overlayDir = manager.materializeOpenCode('tab-capacity:0')! - const pluginPath = join(overlayDir, 'plugins', 'orca-opencode-status.js') - const userConfigDir = join(homeDir, 'large-opencode-config') - mkdirSync(userConfigDir) - const planSpy = vi - .spyOn(configOverlayMirroring, 'createConfigOverlayPlan') - .mockImplementation(() => { - throw new configOverlayMirroring.ConfigOverlayCapacityError('entries', 4_097, 4_096) - }) - const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) - try { - expect(manager.materializeOpenCode('tab-capacity:0', userConfigDir)).toBeNull() - expect(readFileSync(pluginPath, 'utf8')).toBe('first plugin') - expect(stderrSpy).toHaveBeenCalledWith( - expect.stringContaining('Agent config overlay entries exceeded its 4096 limit') - ) - } finally { - planSpy.mockRestore() - stderrSpy.mockRestore() - } - }) - it('installs Pi extension into the real agent extensions dir', () => { manager.setSources({ piExtensionSource: '// pi extension' }) const dir = manager.materializePi('tab-2:0') diff --git a/src/relay/plugin-overlay.ts b/src/relay/plugin-overlay.ts index e9c6dd56845..15adf7004e5 100644 --- a/src/relay/plugin-overlay.ts +++ b/src/relay/plugin-overlay.ts @@ -17,19 +17,19 @@ // Pi/OMP homes for those agents. import { createHash } from 'node:crypto' -import { existsSync, mkdirSync, unlinkSync, writeFileSync } from 'node:fs' +import { + existsSync, + mkdirSync, + readFileSync, + readdirSync, + realpathSync, + statSync, + unlinkSync, + writeFileSync +} from 'node:fs' import { homedir } from 'node:os' import { join } from 'node:path' -import { - applyConfigOverlayPlan, - createConfigOverlayPlan, - type ConfigOverlayPlan -} from '../main/pty/config-overlay-mirroring' -import { safeRemoveOverlay } from '../main/pty/overlay-mirror' -import { - isManagedPiExtensionFile, - withOrcaManagedPiExtensionMarker -} from '../main/pi/managed-extension-ownership' +import { mirrorEntry, safeRemoveOverlay } from '../main/pty/overlay-mirror' import type { PiAgentKind } from '../shared/pi-agent-kind' const RELAY_HOOKS_DIR = '.orca-relay' @@ -41,6 +41,13 @@ const PI_OVERLAY_SUBDIR_BY_KIND: Record = { const OPENCODE_PLUGIN_FILE = 'orca-opencode-status.js' const PI_EXTENSION_FILE = 'orca-agent-status.ts' const PI_AGENT_SUBDIR = 'agent' +const ORCA_MANAGED_EXTENSION_MARKER = '@orca-managed-pi-extension' + +function withOrcaManagedPiExtensionMarker(source: string): string { + return source.includes(ORCA_MANAGED_EXTENSION_MARKER) + ? source + : `// ${ORCA_MANAGED_EXTENSION_MARKER}\n${source}` +} // Why: source-dir resolution is keyed off the launching agent (Pi or OMP). // Both consume `PI_CODING_AGENT_DIR` but default to different `~/./agent` // paths on the remote disk. The renderer-chosen launch command flows in via @@ -130,16 +137,50 @@ export class PluginOverlayManager { return this.piExtensionSources[kind] ?? this.piExtensionSources.pi } + private mirrorOpenCodeConfig(sourceDir: string, overlayDir: string): void { + for (const entry of readdirSync(sourceDir, { withFileTypes: true })) { + const sourcePath = join(sourceDir, entry.name) + + if (entry.name === 'plugins') { + const isSymlink = entry.isSymbolicLink() + let isLinkPointingToDir = false + if (isSymlink) { + try { + isLinkPointingToDir = statSync(sourcePath).isDirectory() + } catch { + isLinkPointingToDir = false + } + } + + if ((!isSymlink && entry.isDirectory()) || isLinkPointingToDir) { + const resolvedSource = isLinkPointingToDir ? realpathSync(sourcePath) : sourcePath + const overlayPluginsDir = join(overlayDir, 'plugins') + mkdirSync(overlayPluginsDir, { recursive: true }) + for (const pluginEntry of readdirSync(resolvedSource, { withFileTypes: true })) { + if (pluginEntry.name === OPENCODE_PLUGIN_FILE) { + continue + } + mirrorEntry( + join(resolvedSource, pluginEntry.name), + join(overlayPluginsDir, pluginEntry.name) + ) + } + continue + } + } + + mirrorEntry(sourcePath, join(overlayDir, entry.name)) + } + } + private writeOpenCodePlugin(overlayDir: string): void { const pluginsDir = join(overlayDir, 'plugins') mkdirSync(pluginsDir, { recursive: true }) const pluginPath = join(pluginsDir, OPENCODE_PLUGIN_FILE) try { unlinkSync(pluginPath) - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { - throw error - } + } catch { + // Fresh overlay or no same-named stale symlink. } writeFileSync(pluginPath, this.opencodePluginSource!) } @@ -156,7 +197,8 @@ export class PluginOverlayManager { } const dir = join(this.opencodeRoot, safeDirName(id)) try { - let plan: ConfigOverlayPlan | null = null + safeRemoveOverlay(dir, this.opencodeRoot) + mkdirSync(dir, { recursive: true }) if (existingConfigDir) { if (!existsSync(existingConfigDir)) { return null @@ -164,16 +206,7 @@ export class PluginOverlayManager { // Why: OPENCODE_CONFIG_DIR is a single config root. Mirror the user's // remote root into the overlay before adding Orca's plugin so status // reporting does not hide their auth, models, keybinds, or plugins. - plan = createConfigOverlayPlan(existingConfigDir, { - reservedPluginFile: OPENCODE_PLUGIN_FILE - }) - } - if (!safeRemoveOverlay(dir, this.opencodeRoot)) { - throw new Error('OpenCode overlay cleanup exceeded its safety limits') - } - mkdirSync(dir, { recursive: true }) - if (plan) { - applyConfigOverlayPlan(plan, dir) + this.mirrorOpenCodeConfig(existingConfigDir, dir) } this.writeOpenCodePlugin(dir) return dir @@ -189,6 +222,14 @@ export class PluginOverlayManager { return join(this.homeDir, PI_AGENT_HOME_DIR_NAME[kind], PI_AGENT_SUBDIR) } + private canOverwritePiExtension(path: string): boolean { + try { + return readFileSync(path, 'utf8').includes(ORCA_MANAGED_EXTENSION_MARKER) + } catch { + return true + } + } + /** Install the Pi/OMP status extension into the remote real agent dir and * return that directory. `kind` selects which Pi-compatible agent's default * dir to use when `existingAgentDir` is not supplied. */ @@ -205,7 +246,7 @@ export class PluginOverlayManager { const extensionsDir = join(sourceAgentDir, 'extensions') mkdirSync(extensionsDir, { recursive: true }) const extensionPath = join(extensionsDir, PI_EXTENSION_FILE) - if (existsSync(extensionPath) && !isManagedPiExtensionFile(extensionPath)) { + if (!this.canOverwritePiExtension(extensionPath)) { return null } writeFileSync(extensionPath, extensionSource) @@ -218,8 +259,10 @@ export class PluginOverlayManager { } } - /** Drop a paneKey's overlay dirs on PTY exit. Best-effort and bounded; an - * exotic or pathological tree is logged and left for a later cleanup. */ + /** Drop a paneKey's overlay dirs on PTY exit. Best-effort; cleanup over a + * recursive tree may fail on exotic filesystems but the worst-case + * outcome is unbounded growth on a long-lived relay, which the per-pane + * caches alone do not bound. */ clearOverlay(id: string): void { if (!isUsableId(id)) { return @@ -230,11 +273,7 @@ export class PluginOverlayManager { // safeRemoveOverlay keeps each call bounded to its own tree. for (const root of [this.opencodeRoot, ...Object.values(this.piRoots)]) { try { - if (!safeRemoveOverlay(join(root, safe), root)) { - process.stderr.write( - `[plugin-overlay] overlay cleanup stopped at its safety limit: ${join(root, safe)}\n` - ) - } + safeRemoveOverlay(join(root, safe), root) } catch (err) { // Why: log the failed cleanup so a permission/IO error is observable. // The leak is the failure mode the per-pane cache eviction exists to diff --git a/src/relay/port-scan-handler.test.ts b/src/relay/port-scan-handler.test.ts index 01f4ad7300f..9df947b1aad 100644 --- a/src/relay/port-scan-handler.test.ts +++ b/src/relay/port-scan-handler.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { LINUX_PROC_LISTENING_SOCKET_MAX_ENTRIES } from '../shared/linux-proc-port-scan-limits' -import { parseHexAddress, parseLinuxProcListeningSockets } from './port-scan-handler' +import { parseHexAddress } from './port-scan-handler' import { parseWindowsNetstatOutput, parseWindowsPowerShellPortRows } from './windows-port-scan' describe('parseHexAddress', () => { @@ -69,20 +68,6 @@ describe('parseHexAddress', () => { }) }) -describe('parseLinuxProcListeningSockets', () => { - it('caps retained listeners before inode and process scans', () => { - const rows = Array.from( - { length: LINUX_PROC_LISTENING_SOCKET_MAX_ENTRIES + 5 }, - (_, index) => - ` ${index}: 0100007F:${(index + 1).toString(16).padStart(4, '0')} 00000000:0000 0A 00000000:00000000 00:00000000 00000000 1000 0 ${index + 1}` - ) - - expect(parseLinuxProcListeningSockets(['header', ...rows].join('\n'))).toHaveLength( - LINUX_PROC_LISTENING_SOCKET_MAX_ENTRIES - ) - }) -}) - describe('parseWindowsPowerShellPortRows', () => { it('parses PowerShell JSON arrays', () => { expect( diff --git a/src/relay/port-scan-handler.ts b/src/relay/port-scan-handler.ts index f3ead301c7a..a576d8921fa 100644 --- a/src/relay/port-scan-handler.ts +++ b/src/relay/port-scan-handler.ts @@ -1,12 +1,5 @@ +import { readFile, readdir, readlink } from 'node:fs/promises' import { getProcessOutputFields } from '../shared/process-output-field-scanner' -import { mapLinuxSocketInodesToPids } from '../shared/linux-proc-socket-owner-scanner' -import { - createLinuxProcTextReadBudget, - LINUX_PROC_LISTENING_SOCKET_MAX_ENTRIES, - readLinuxProcNetworkTable, - readLinuxProcTextWithinBudget, - type LinuxProcTextReadBudget -} from '../shared/linux-proc-port-scan-limits' import type { RelayDispatcher, RequestContext } from './dispatcher' import { scanWindowsListeningPorts } from './windows-port-scan' @@ -50,17 +43,16 @@ export class PortScanHandler { this.readProcNet('/proc/net/tcp6') ]) - const listeningSockets = [...tcp4, ...tcp6].slice(0, LINUX_PROC_LISTENING_SOCKET_MAX_ENTRIES) + const listeningSockets = [...tcp4, ...tcp6] if (listeningSockets.length === 0) { return [] } const inodeSet = new Set(listeningSockets.map((s) => s.inode)) - const inodeToPid = await mapLinuxSocketInodesToPids(inodeSet) + const inodeToPid = await this.mapInodesToPids(inodeSet) const seen = new Set() const results: DetectedPort[] = [] - const metadataBudget = createLinuxProcTextReadBudget() const relayPid = process.pid const relayParentPid = process.ppid @@ -80,7 +72,7 @@ export class PortScanHandler { continue } - const processName = pid != null ? await this.getProcessName(pid, metadataBudget) : undefined + const processName = pid != null ? await this.getProcessName(pid) : undefined if (processName === 'sshd') { continue @@ -103,49 +95,111 @@ export class PortScanHandler { private async readProcNet( path: string ): Promise<{ port: number; host: string; inode: number }[]> { - const content = await readLinuxProcNetworkTable(path) - return content === null ? [] : parseLinuxProcListeningSockets(content) + let content: string + try { + content = await readFile(path, 'utf-8') + } catch { + return [] + } + + const lines = content.split('\n') + const results: { port: number; host: string; inode: number }[] = [] + + for (let i = 1; i < lines.length; i++) { + const fields = getProcessOutputFields(lines[i], 10) + if (fields.length < 10) { + continue + } + + // State field (index 3): 0A = TCP_LISTEN + if (fields[3] !== '0A') { + continue + } + + const localAddress = fields[1] + const parsed = parseHexAddress(localAddress) + if (!parsed) { + continue + } + + const inode = Number.parseInt(fields[9], 10) + if (Number.isNaN(inode) || inode === 0) { + continue + } + + results.push({ port: parsed.port, host: parsed.host, inode }) + } + + return results } - private async getProcessName( - pid: number, - budget: LinuxProcTextReadBudget - ): Promise { - const cmdline = await readLinuxProcTextWithinBudget(`/proc/${pid}/cmdline`, budget) - if (!cmdline) { + private async mapInodesToPids(inodes: Set): Promise> { + const result = new Map() + if (inodes.size === 0) { + return result + } + + let pids: string[] + try { + pids = (await readdir('/proc')).filter((name) => /^\d+$/.test(name)) + } catch { + return result + } + + for (const pidStr of pids) { + const fdDir = `/proc/${pidStr}/fd` + let fds: string[] + try { + fds = await readdir(fdDir) + } catch { + continue + } + + const pid = Number.parseInt(pidStr, 10) + + for (const fd of fds) { + let link: string + try { + link = await readlink(`${fdDir}/${fd}`) + } catch { + continue + } + + const match = link.match(/^socket:\[(\d+)\]$/) + if (!match) { + continue + } + + const inode = Number.parseInt(match[1], 10) + if (inodes.has(inode)) { + result.set(inode, pid) + } + } + } + + return result + } + + private async getProcessName(pid: number): Promise { + try { + const cmdline = await readFile(`/proc/${pid}/cmdline`, 'utf-8') + if (!cmdline) { + return undefined + } + + const exe = cmdline.split('\0')[0] + if (!exe) { + return undefined + } + + const parts = exe.split('/') + return parts.at(-1) + } catch { return undefined } - - const exe = cmdline.split('\0')[0] - return exe ? exe.split('/').at(-1) : undefined } } -export function parseLinuxProcListeningSockets( - content: string -): { port: number; host: string; inode: number }[] { - const lines = content.split('\n') - const results: { port: number; host: string; inode: number }[] = [] - - for (let i = 1; i < lines.length; i++) { - const fields = getProcessOutputFields(lines[i], 10) - if (fields.length < 10 || fields[3] !== '0A') { - continue - } - const parsed = parseHexAddress(fields[1]) - const inode = Number.parseInt(fields[9], 10) - if (!parsed || !Number.isFinite(inode) || inode === 0) { - continue - } - results.push({ port: parsed.port, host: parsed.host, inode }) - if (results.length >= LINUX_PROC_LISTENING_SOCKET_MAX_ENTRIES) { - break - } - } - - return results -} - // Why: /proc/net/tcp encodes addresses as hex pairs in host-byte-order. // IPv4: 8 hex chars for address + ':' + 4 hex chars for port. // IPv6: 32 hex chars for address + ':' + 4 hex chars for port. diff --git a/src/relay/preflight-handler.test.ts b/src/relay/preflight-handler.test.ts index 9729ea14192..80bc2ba490a 100644 --- a/src/relay/preflight-handler.test.ts +++ b/src/relay/preflight-handler.test.ts @@ -32,8 +32,7 @@ import { buildCommandLookupSpecs, hasAbsoluteCommandPath, isCommandOnPathForRelay, - PreflightHandler, - RELAY_AGENT_PATH_PROBE_CONCURRENCY + PreflightHandler } from './preflight-handler' function lookupArgs(command: string, mode: '-lc' | '-ilc' = '-lc'): string[] { @@ -234,54 +233,6 @@ describe('hasAbsoluteCommandPath', () => { }) describe('PreflightHandler', () => { - it.each([ - ['at the limit', RELAY_AGENT_PATH_PROBE_CONCURRENCY], - ['above the limit', RELAY_AGENT_PATH_PROBE_CONCURRENCY + 1] - ])('bounds relay-supplied command probes %s', async (_, count) => { - let active = 0 - let peak = 0 - const releases: (() => void)[] = [] - execFileAsyncMock.mockImplementation(async (_file, args) => { - active++ - peak = Math.max(peak, active) - await new Promise((resolve) => releases.push(resolve)) - active-- - const command = String(args[1]).match(/command -v '([^']+)'/)?.[1] ?? 'agent' - return { stdout: `__ORCA_AGENT_PATH__/relay/path/${command}\n` } - }) - const requestHandlers = new Map) => Promise>() - const dispatcher = { - onRequest: vi.fn( - (method: string, handler: (params: Record) => Promise) => { - requestHandlers.set(method, handler) - } - ) - } - new PreflightHandler(dispatcher as never) - - const detection = requestHandlers.get('preflight.detectAgents')!({ - commands: Array.from({ length: count }, (_, index) => ({ - id: `agent-${index}`, - cmd: `agent-${index}` - })) - }) - await vi.waitFor(() => - expect(execFileAsyncMock).toHaveBeenCalledTimes( - Math.min(count, RELAY_AGENT_PATH_PROBE_CONCURRENCY) - ) - ) - if (count > RELAY_AGENT_PATH_PROBE_CONCURRENCY) { - releases.shift()?.() - await vi.waitFor(() => expect(execFileAsyncMock).toHaveBeenCalledTimes(count)) - } - releases.splice(0).forEach((release) => release()) - - await expect(detection).resolves.toMatchObject({ - agents: expect.arrayContaining(Array.from({ length: count }, (_, index) => `agent-${index}`)) - }) - expect(peak).toBe(Math.min(count, RELAY_AGENT_PATH_PROBE_CONCURRENCY)) - }) - it('honors required commands when reporting detected agents', async () => { execFileAsyncMock.mockImplementation(async (_file, args) => { const script = String(args[1]) diff --git a/src/relay/preflight-handler.ts b/src/relay/preflight-handler.ts index d3505614f0d..101c796137c 100644 --- a/src/relay/preflight-handler.ts +++ b/src/relay/preflight-handler.ts @@ -8,7 +8,6 @@ import { isPwshAvailable } from '../main/pwsh' import { isWslAvailable, listWslDistros } from '../main/wsl' import { isGitBashAvailable } from '../main/git-bash' import { buildPosixCommandPathLookupScript } from '../shared/posix-command-path-lookup' -import { mapWithConcurrency } from '../shared/map-with-concurrency' const execFileAsync = promisify(execFile) @@ -36,7 +35,6 @@ type AgentDetectionCommand = { const SUPPORTED_POSIX_SHELLS = new Set(['sh', 'dash', 'bash', 'zsh', 'fish']) const CONSERVATIVE_SYSTEM_SHELL_DIRS = new Set(['/bin', '/usr/bin']) const AGENT_PATH_PREFIX = '__ORCA_AGENT_PATH__' -export const RELAY_AGENT_PATH_PROBE_CONCURRENCY = 8 export class PreflightHandler { private dispatcher: RelayDispatcher @@ -69,13 +67,11 @@ export class PreflightHandler { ) ] - const results = await mapWithConcurrency( - probeCommands, - RELAY_AGENT_PATH_PROBE_CONCURRENCY, - async (cmd) => ({ + const results = await Promise.all( + probeCommands.map(async (cmd) => ({ cmd, installed: await this.isCommandOnPath(cmd) - }) + })) ) const foundCommands = new Set( results.filter((result) => result.installed).map(({ cmd }) => cmd) diff --git a/src/relay/protocol-handshake.test.ts b/src/relay/protocol-handshake.test.ts index 8cfaaea92c5..822fea3e20b 100644 --- a/src/relay/protocol-handshake.test.ts +++ b/src/relay/protocol-handshake.test.ts @@ -2,7 +2,6 @@ import { describe, expect, it } from 'vitest' import { MessageType, HEADER_LENGTH, - MAX_BUFFERED_FRAME_CHUNKS, FrameDecoder, encodeHandshakeFrame, parseHandshakeMessage, @@ -10,45 +9,6 @@ import { } from './protocol' describe('handshake framing', () => { - it('bounds retained chunk wrappers for a byte-fragmented frame', () => { - const frames: DecodedFrame[] = [] - const decoder = new FrameDecoder((frame) => frames.push(frame)) - const payloadLength = MAX_BUFFERED_FRAME_CHUNKS + 512 - const header = Buffer.alloc(HEADER_LENGTH) - header[0] = MessageType.Regular - header.writeUInt32BE(payloadLength, 9) - decoder.feed(header) - - const byte = Buffer.from('x') - const state = decoder as unknown as { chunks: Buffer[] } - for (let index = 0; index < payloadLength; index += 1) { - decoder.feed(byte) - expect(state.chunks.length).toBeLessThanOrEqual(MAX_BUFFERED_FRAME_CHUNKS) - } - - expect(frames).toHaveLength(1) - expect(frames[0].payload.equals(Buffer.alloc(payloadLength, 0x78))).toBe(true) - }) - - it('does not coalesce ordinary transport chunks while a frame is incomplete', () => { - const decoder = new FrameDecoder(() => {}) - const header = Buffer.alloc(HEADER_LENGTH) - header[0] = MessageType.Regular - header.writeUInt32BE(1024 * 1024, 9) - decoder.feed(header) - const ordinaryChunks = Array.from({ length: 16 }, () => Buffer.alloc(32 * 1024)) - - for (const chunk of ordinaryChunks) { - decoder.feed(chunk) - } - - const state = decoder as unknown as { chunks: Buffer[] } - expect(state.chunks).toHaveLength(ordinaryChunks.length + 1) - for (const [index, chunk] of ordinaryChunks.entries()) { - expect(state.chunks[index + 1]).toBe(chunk) - } - }) - it('round-trips an orca-relay-handshake envelope through the existing framing', () => { const sent = encodeHandshakeFrame({ type: 'orca-relay-handshake', @@ -105,31 +65,4 @@ describe('handshake framing', () => { expect(MessageType.Handshake).not.toBe(MessageType.Regular) expect(MessageType.Handshake).not.toBe(MessageType.KeepAlive) }) - - it('stream-discards an oversized payload instead of buffering toward its advertised size', () => { - const errors: Error[] = [] - const decoder = new FrameDecoder( - () => {}, - (error) => errors.push(error) - ) - const header = Buffer.alloc(HEADER_LENGTH) - header[0] = MessageType.Regular - header.writeUInt32BE(0xffffffff, 9) - - decoder.feed(header) - const state = decoder as unknown as { - bufferedLength: number - oversizedPayloadBytesRemaining: number - } - expect(errors).toHaveLength(1) - expect(state.bufferedLength).toBe(0) - - const payloadChunk = Buffer.alloc(64 * 1024) - for (let index = 0; index < 128; index += 1) { - decoder.feed(payloadChunk) - expect(state.bufferedLength).toBe(0) - } - expect(state.oversizedPayloadBytesRemaining).toBe(0xffffffff - 128 * payloadChunk.length) - expect(errors).toHaveLength(1) - }) }) diff --git a/src/relay/protocol-json-encoding-memory.test.ts b/src/relay/protocol-json-encoding-memory.test.ts deleted file mode 100644 index aed0fca223b..00000000000 --- a/src/relay/protocol-json-encoding-memory.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { - encodeJsonRpcFrame, - HEADER_LENGTH, - MAX_MESSAGE_SIZE, - parseJsonRpcMessage -} from './protocol' -import { RELAY_JSON_MAX_STRUCTURAL_TOKENS } from '../shared/relay-json-admission' - -describe('relay JSON frame encoding memory', () => { - it('preserves accepted JSON bytes', () => { - const message = { - jsonrpc: '2.0' as const, - id: 1, - result: { escaped: 'line\n🐋', omitted: undefined } - } - - const frame = encodeJsonRpcFrame(message, 7, 3) - - expect(frame.subarray(HEADER_LENGTH).toString('utf8')).toBe(JSON.stringify(message)) - }) - - it('rejects an oversized message before allocating its payload Buffer', () => { - const message = { - jsonrpc: '2.0' as const, - id: 1, - result: '\n'.repeat(MAX_MESSAGE_SIZE) - } - const fromSpy = vi.spyOn(Buffer, 'from') - - try { - expect(() => encodeJsonRpcFrame(message, 1, 0)).toThrow('Message too large') - expect(fromSpy).not.toHaveBeenCalled() - } finally { - fromSpy.mockRestore() - } - }) - - it('rejects structurally amplified inbound JSON before parsing', () => { - const payload = Buffer.from( - `{"jsonrpc":"2.0","id":1,"method":"x","params":{"values":[${'0,'.repeat( - RELAY_JSON_MAX_STRUCTURAL_TOKENS - )}0]}}` - ) - const parseSpy = vi.spyOn(JSON, 'parse') - try { - expect(() => parseJsonRpcMessage(payload)).toThrow(/JSON structure exceeds/) - expect(parseSpy).not.toHaveBeenCalled() - } finally { - parseSpy.mockRestore() - } - }) -}) diff --git a/src/relay/protocol.ts b/src/relay/protocol.ts index 9adda8942f0..4df0f7bd9b5 100644 --- a/src/relay/protocol.ts +++ b/src/relay/protocol.ts @@ -1,19 +1,11 @@ // Self-contained relay protocol — mirrors src/main/ssh/relay-protocol.ts // but has no Electron dependencies. Deployed standalone to remote hosts. -import { - JsonStringifyByteLimitError, - stringifyJsonWithinByteLimit -} from '../shared/node-bounded-json-stringify' -import { parseRelayJsonText } from '../shared/relay-json-admission' - export const RELAY_VERSION = '0.1.0' export const RELAY_SENTINEL = `ORCA-RELAY v${RELAY_VERSION} READY\n` export const HEADER_LENGTH = 13 export const MAX_MESSAGE_SIZE = 16 * 1024 * 1024 -export const MAX_BUFFERED_FRAME_CHUNKS = 4_096 -const MAX_COALESCED_TINY_FRAME_CHUNK_BYTES = 8 * 1024 export const MessageType = { Regular: 1, @@ -36,7 +28,7 @@ export function encodeHandshakeFrame(msg: HandshakeMessage): Buffer { } export function parseHandshakeMessage(payload: Buffer): HandshakeMessage { - const msg = parseRelayJsonText(payload.toString('utf-8')) + const msg = JSON.parse(payload.toString('utf-8')) as HandshakeMessage const t = (msg as { type?: string }).type if ( t !== 'orca-relay-handshake' && @@ -81,11 +73,6 @@ export const GIT_RESPONSE_STREAM_THRESHOLD = 256 * 1024 * the client reassembles by concatenation (it does not depend on chunk size). */ export const GIT_RESPONSE_CHUNK_SIZE = 128 * 1024 -/** Bounds retained serialized Git results while preserving responses far above - * the per-file render limit and git.exec's output ceiling. */ -export const MAX_GIT_RESPONSE_STREAM_BYTES = 64 * 1024 * 1024 -export const MAX_GIT_RESPONSE_STREAM_CHUNKS = 4_096 - /** Sentinel result returned in place of a large git response: the real payload * follows as git.responseChunk frames on the bulk lane. Old relays never emit * this, so a new client falls back to the plain result they return. */ @@ -95,8 +82,7 @@ export type GitResponseStreamMarker = { export const RelayErrorCode = { TooManyStreams: -33006, - StreamProtocolError: -33007, - MarkdownDocumentListingCapacity: -33008 + StreamProtocolError: -33007 } as const export type JsonRpcRequest = { @@ -143,16 +129,10 @@ export function encodeFrame( } export function encodeJsonRpcFrame(msg: JsonRpcMessage, id: number, ack: number): Buffer { - let serialized: string - try { - serialized = stringifyJsonWithinByteLimit(msg, MAX_MESSAGE_SIZE).serialized - } catch (error) { - if (error instanceof JsonStringifyByteLimitError) { - throw new Error(`Message too large: more than ${MAX_MESSAGE_SIZE} bytes`) - } - throw error + const payload = Buffer.from(JSON.stringify(msg), 'utf-8') + if (payload.length > MAX_MESSAGE_SIZE) { + throw new Error(`Message too large: ${payload.length} bytes`) } - const payload = Buffer.from(serialized, 'utf-8') return encodeFrame(MessageType.Regular, id, ack, payload) } @@ -167,7 +147,6 @@ export class FrameDecoder { // frame exactly once instead. private chunks: Buffer[] = [] private bufferedLength = 0 - private oversizedPayloadBytesRemaining = 0 private onFrame: (frame: DecodedFrame) => void private onError: ((err: Error) => void) | null @@ -177,18 +156,12 @@ export class FrameDecoder { } feed(chunk: Buffer | Uint8Array): void { - let buf = Buffer.isBuffer(chunk) + const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength) - if (this.oversizedPayloadBytesRemaining > 0 && buf.length > 0) { - const discarded = Math.min(this.oversizedPayloadBytesRemaining, buf.length) - this.oversizedPayloadBytesRemaining -= discarded - buf = buf.subarray(discarded) - } if (buf.length > 0) { this.chunks.push(buf) this.bufferedLength += buf.length - this.coalesceBufferedChunks() } while (this.bufferedLength >= HEADER_LENGTH) { @@ -196,28 +169,28 @@ export class FrameDecoder { const length = header.readUInt32BE(9) const totalLength = HEADER_LENGTH + length + if (this.bufferedLength < totalLength) { + // Not fully received yet (also holds oversized frames until they can + // be skipped whole, keeping the decoder synchronized). + break + } + if (length > MAX_MESSAGE_SIZE) { - // Why: retain only the header, then stream-discard the advertised - // payload so a 13-byte malicious header cannot pin up to 4 GiB. - this.discardBytes(HEADER_LENGTH) - this.oversizedPayloadBytesRemaining = length - this.discardBufferedOversizedPayload() + // Why: Throwing here would leave the buffer in a partially consumed + // state — subsequent feed() calls would try to parse the leftover + // payload bytes as a new header, corrupting every future frame. + // Instead we skip the entire oversized frame so the decoder stays + // synchronized with the stream. + this.discardBytes(totalLength) const err = new Error(`Frame payload too large: ${length} bytes — discarded`) if (this.onError) { this.onError(err) } else { process.stderr.write(`[relay] ${err.message}\n`) } - if (this.oversizedPayloadBytesRemaining > 0) { - break - } continue } - if (this.bufferedLength < totalLength) { - break - } - const framed = this.takeBytes(totalLength) const frame: DecodedFrame = { type: framed[0], @@ -232,7 +205,6 @@ export class FrameDecoder { reset(): void { this.chunks = [] this.bufferedLength = 0 - this.oversizedPayloadBytesRemaining = 0 } // Why: at the handshake → dispatcher transition, the next consumer must @@ -308,37 +280,11 @@ export class FrameDecoder { } this.bufferedLength -= count } - - private discardBufferedOversizedPayload(): void { - const discarded = Math.min(this.oversizedPayloadBytesRemaining, this.bufferedLength) - if (discarded === 0) { - return - } - this.discardBytes(discarded) - this.oversizedPayloadBytesRemaining -= discarded - } - - private coalesceBufferedChunks(): void { - // Why: merge adversarial tiny fragments without copying ordinary transport chunks. - while (this.chunks.length >= 2) { - const right = this.chunks.at(-1)! - const left = this.chunks.at(-2)! - if ( - left.length > right.length || - left.length + right.length > MAX_COALESCED_TINY_FRAME_CHUNK_BYTES - ) { - break - } - this.chunks.splice(-2, 2, Buffer.concat([left, right], left.length + right.length)) - } - if (this.chunks.length > MAX_BUFFERED_FRAME_CHUNKS) { - this.chunks = [Buffer.concat(this.chunks, this.bufferedLength)] - } - } } export function parseJsonRpcMessage(payload: Buffer): JsonRpcMessage { - const msg = parseRelayJsonText(payload.toString('utf-8')) + const text = payload.toString('utf-8') + const msg = JSON.parse(text) as JsonRpcMessage if (msg.jsonrpc !== '2.0') { throw new Error(`Invalid JSON-RPC version: ${(msg as Record).jsonrpc}`) } diff --git a/src/relay/pty-handler.test.ts b/src/relay/pty-handler.test.ts index 5369a1e80b0..3e3bbebd9fd 100644 --- a/src/relay/pty-handler.test.ts +++ b/src/relay/pty-handler.test.ts @@ -11,7 +11,6 @@ import { SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV } from '../shared/setup-agent-sequencing' import { PTY_STARTUP_INGRESS_VERSION } from '../shared/pty-startup-ingress' -import { MAX_TERMINAL_COLS } from '../shared/terminal-size-limits' const { mockPtySpawn, mockPtyInstance } = vi.hoisted(() => ({ mockPtySpawn: vi.fn(), @@ -25,9 +24,7 @@ const { mockPtySpawn, mockPtyInstance } = vi.hoisted(() => ({ write: vi.fn(), resize: vi.fn(), kill: vi.fn(), - clear: vi.fn(), - pause: vi.fn(), - resume: vi.fn() + clear: vi.fn() } })) @@ -41,11 +38,9 @@ import { PtyHandler, attachIdentityMismatches } from './pty-handler' -import { MAX_RELAY_PTY_PERSISTENCE_FIELD_BYTES } from './pty-persistence-envelope' -import type { RelayDispatcher, RelayNotificationWriteResult } from './dispatcher' +import type { RelayDispatcher } from './dispatcher' type TestRequestContext = { - clientId?: number isStale: () => boolean signal?: AbortSignal } @@ -55,18 +50,8 @@ function createMockDispatcher() { string, (params: Record, context?: TestRequestContext) => Promise >() - const notificationHandlers = new Map< - string, - (params: Record, context: TestRequestContext) => void - >() + const notificationHandlers = new Map) => void>() const notifications: { method: string; params?: Record }[] = [] - const targetedNotifications: { - clientId: number - method: string - params?: Record - }[] = [] - const clientDetachListeners = new Set<(clientId: number) => void>() - const connectedClientIds = new Set([1]) const dispatcher = { onRequest: vi.fn( @@ -77,75 +62,33 @@ function createMockDispatcher() { requestHandlers.set(method, handler) } ), - onNotification: vi.fn( - ( - method: string, - handler: (params: Record, context: TestRequestContext) => void - ) => { - notificationHandlers.set(method, handler) - } - ), - onClientDetached: vi.fn((listener: (clientId: number) => void) => { - clientDetachListeners.add(listener) - return () => clientDetachListeners.delete(listener) - }), - connectedClientIds: vi.fn(() => Array.from(connectedClientIds)), - evictClient: vi.fn((clientId: number) => { - connectedClientIds.delete(clientId) - for (const listener of clientDetachListeners) { - listener(clientId) - } + onNotification: vi.fn((method: string, handler: (params: Record) => void) => { + notificationHandlers.set(method, handler) }), notify: vi.fn((method: string, params?: Record) => { notifications.push({ method, params }) }), - notifyClientWithBackpressure: vi.fn( - ( - clientId: number, - method: string, - params?: Record - ): RelayNotificationWriteResult => { - targetedNotifications.push({ clientId, method, params }) - const { deliveryToken: _deliveryToken, ...legacyParams } = params ?? {} - dispatcher.notify(method, legacyParams) - return { delivered: true, saturated: false, drained: Promise.resolve() } - } - ), // Helpers for tests _requestHandlers: requestHandlers, _notificationHandlers: notificationHandlers, _notifications: notifications, - _targetedNotifications: targetedNotifications, async callRequest( method: string, params: Record = {}, - context: TestRequestContext = { clientId: 1, isStale: () => false } + context?: TestRequestContext ) { const handler = requestHandlers.get(method) if (!handler) { throw new Error(`No handler for ${method}`) } - if (typeof context?.clientId === 'number') { - connectedClientIds.add(context.clientId) - } return handler(params, context) }, - callNotification( - method: string, - params: Record = {}, - context: TestRequestContext = { clientId: 1, isStale: () => false } - ) { + callNotification(method: string, params: Record = {}) { const handler = notificationHandlers.get(method) if (!handler) { throw new Error(`No handler for ${method}`) } - handler(params, context) - }, - emitClientDetached(clientId: number) { - connectedClientIds.delete(clientId) - for (const listener of clientDetachListeners) { - listener(clientId) - } + handler(params) } } @@ -183,8 +126,6 @@ describe('PtyHandler', () => { mockPtyInstance.resize.mockReset() mockPtyInstance.kill.mockReset() mockPtyInstance.clear.mockReset() - mockPtyInstance.pause.mockReset() - mockPtyInstance.resume.mockReset() mockPtySpawn.mockReturnValue({ ...mockPtyInstance }) @@ -268,13 +209,6 @@ describe('PtyHandler', () => { expect(handler.activePtyCount).toBe(1) }) - it('rejects oversized spawn dimensions before native allocation', async () => { - await expect( - dispatcher.callRequest('pty.spawn', { cols: MAX_TERMINAL_COLS + 1, rows: 24 }) - ).rejects.toThrow(`1 through ${MAX_TERMINAL_COLS}`) - expect(mockPtySpawn).not.toHaveBeenCalled() - }) - it("does not forward Orca's own NODE_ENV into the spawned shell", async () => { // Why: NODE_ENV in the relay host process is a build-mode flag, not the // user's; leaking it breaks `next build` and Vitest in the terminal. @@ -480,25 +414,6 @@ describe('PtyHandler', () => { expect(handler.activePtyCount).toBe(MAX_RELAY_PTY_SESSIONS) }) - it('rejects oversized retained spawn fields before creating a native PTY', async () => { - await expect( - dispatcher.callRequest('pty.spawn', { - cwd: 'x'.repeat(MAX_RELAY_PTY_PERSISTENCE_FIELD_BYTES + 1) - }) - ).rejects.toThrow(`exceeds ${MAX_RELAY_PTY_PERSISTENCE_FIELD_BYTES} bytes`) - - expect(mockPtySpawn).not.toHaveBeenCalled() - expect(handler.activePtyCount).toBe(0) - }) - - it('bounds the requested PTY serialization id list', async () => { - await expect( - dispatcher.callRequest('pty.serialize', { - ids: Array.from({ length: MAX_RELAY_PTY_SESSIONS + 1 }, (_, index) => `pty-${index}`) - }) - ).rejects.toThrow(`PTY persistence request exceeds ${MAX_RELAY_PTY_SESSIONS} entries`) - }) - it('spawns a PTY without post-Node-18 array copy methods', async () => { const descriptor = Object.getOwnPropertyDescriptor(Array.prototype, 'toReversed') Reflect.deleteProperty(Array.prototype, 'toReversed') @@ -2423,7 +2338,7 @@ describe('PtyHandler', () => { expect(mockPtySpawn).not.toHaveBeenCalled() }) - it('rejects untrusted revive state above the physical PTY cap transactionally', async () => { + it('applies the physical PTY cap to untrusted revive state', async () => { const state = JSON.stringify( Array.from({ length: MAX_RELAY_PTY_SESSIONS + 1 }, (_, index) => ({ id: `pty-${index + 1}`, @@ -2434,11 +2349,17 @@ describe('PtyHandler', () => { worktreeId: 'repo-id::/repo' })) ) - await expect(dispatcher.callRequest('pty.revive', { state })).rejects.toThrow( - `PTY persistence state exceeds ${MAX_RELAY_PTY_SESSIONS} entries` - ) - expect(mockPtySpawn).not.toHaveBeenCalled() - expect(handler.activePtyCount).toBe(0) + const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true) + try { + await expect(dispatcher.callRequest('pty.revive', { state })).rejects.toThrow( + 'Maximum number of PTY sessions reached (50)' + ) + } finally { + killSpy.mockRestore() + } + + expect(mockPtySpawn).toHaveBeenCalledTimes(MAX_RELAY_PTY_SESSIONS) + expect(handler.activePtyCount).toBe(MAX_RELAY_PTY_SESSIONS) }) it('deduplicates concurrent revive requests for the same physical PTY id', async () => { @@ -2960,40 +2881,6 @@ describe('PtyHandler', () => { expect(vi.getTimerCount()).toBe(0) }) - it('keeps output delivery registered when relay disposal cannot kill a live PTY', async () => { - let onDataCb: ((data: string) => void) | undefined - let rejectKill = true - const mockKill = vi.fn(() => { - if (rejectKill) { - throw new Error('persistent dispose kill failure') - } - }) - mockPtySpawn.mockReturnValue({ - ...mockPtyInstance, - kill: mockKill, - onData: vi.fn((cb: (data: string) => void) => { - onDataCb = cb - }) - }) - - await dispatcher.callRequest('pty.spawn', {}) - const dispose = handler.dispose({ waitForPhysicalExit: false }) - const rejected = expect(dispose).rejects.toThrow('persistent dispose kill failure') - await vi.advanceTimersByTimeAsync(250) - await rejected - - onDataCb?.('still alive') - await vi.advanceTimersByTimeAsync(8) - expect(dispatcher._notifications).toContainEqual({ - method: 'pty.data', - params: { id: 'pty-1', data: 'still alive' } - }) - - rejectKill = false - await handler.dispose({ waitForPhysicalExit: false }) - expect(handler.activePtyCount).toBe(0) - }) - it('takes ownership when dispose overlaps a queued graceful force-kill retry', async () => { let onExitCb: ((evt: { exitCode: number }) => void) | undefined let forceAttempts = 0 diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index 361cc3eecca..ce2059e30aa 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -59,25 +59,6 @@ import { isAgentSessionSurfaceBinding, type AgentSessionOwnerBinding } from '../shared/agent-session-host-authority' -import { PtyOutputBroadcast } from './pty-output-broadcast' -import { - assertRelayPtyPersistenceFieldWithinLimit, - assertRelayPtyRetainedFieldsWithinLimits, - parseRelayPtyPersistenceEnvelope, - parseRelayPtyPersistenceIds, - sanitizeRelayPtyEnvToDelete, - serializeRelayPtyPersistenceEnvelope, - type RelayPtyIdentity, - type RelayPtyPersistenceEntry -} from './pty-persistence-envelope' -import { - MAX_TERMINAL_COLS, - MAX_TERMINAL_ROWS, - normalizeTerminalSize, - terminalSizeAdmissionError -} from '../shared/terminal-size-limits' - -export { PTY_OUTPUT_HIGH_WATER_CHARS, PTY_OUTPUT_LOW_WATER_CHARS } from './pty-output-broadcast' function isMissingNodePtyNativeBinding(error: unknown): boolean { return ( @@ -129,6 +110,13 @@ const AGENT_SESSION_CREATE_OPERATION_ID_PATTERN = /^[A-Za-z0-9_-]{43}$/ const AGENT_SESSION_CREATE_OPERATION_RETENTION_MS = 24 * 60 * 60 * 1000 const AGENT_SESSION_CREATE_OPERATION_LIMIT = 4_096 +type PendingPtyOutput = { + data: string + rawLength?: number + transformed?: boolean + seq?: number +} + type ManagedStartupCommand = { command: string delivered: boolean @@ -185,9 +173,13 @@ const DEFAULT_GRACE_TIME_MS = DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS * 1000 export const IMMEDIATE_PTY_EXIT_TIMEOUT_MS = 8_000 export const MAX_RELAY_PTY_SESSIONS = 50 export const REPLAY_BUFFER_MAX = 100 * 1024 +const PTY_OUTPUT_BATCH_INTERVAL_MS = 8 +const PTY_OUTPUT_DRAIN_CONTINUE_MS = 1 +const PTY_OUTPUT_FLUSH_CHUNK_CHARS = 16 * 1024 +const PTY_OUTPUT_FLUSH_MAX_WRITES = 2 const INTERACTIVE_OUTPUT_WINDOW_MS = 100 const INTERACTIVE_OUTPUT_MAX_CHARS = 1024 -const INTERACTIVE_REDRAW_MAX_CHARS = 16 * 1024 +const INTERACTIVE_REDRAW_MAX_CHARS = PTY_OUTPUT_FLUSH_CHUNK_CHARS const INTERACTIVE_OUTPUT_BUDGET_CHARS = 32 * 1024 const STARTUP_COMMAND_WRITE_DELAY_MS = 50 const STARTUP_COMMAND_SHELL_READY_FALLBACK_MS = 1500 @@ -241,9 +233,35 @@ type PtyProcessSummary = { agentSessionOwners?: AgentSessionOwnerBinding[] } +type SerializedPtyEntry = { + id: string + pid: number + cols: number + rows: number + cwd: string + paneKey?: string + tabId?: string + attachIdentity?: PtyIdentity + worktreeId?: string + terminalHandle?: string + explicitTerm?: string + envToDelete?: string[] + /** Optional for state serialized by relays predating the credential guard. */ + gitCredentialPromptGuarded?: boolean + agentSessionOwners?: AgentSessionOwnerBinding[] +} + +function sanitizeEnvToDelete(value: unknown): string[] { + return Array.isArray(value) + ? value + .filter((key): key is string => typeof key === 'string' && key.length > 0) + .slice(0, 1_024) + : [] +} + export type PtyExitListener = (event: { id: string; paneKey?: string }) => void -type PtyIdentity = RelayPtyIdentity +type PtyIdentity = { paneKey?: string; tabId?: string } /** * True when a reattach's expected pane identity contradicts the target PTY's own. @@ -274,9 +292,10 @@ export class PtyHandler { private ptys = new Map() private nextId = 1 private dispatcher: RelayDispatcher - private readonly outputBroadcast: PtyOutputBroadcast private graceTimeMs: number private graceTimer: ReturnType | null = null + private outputFlushTimer: ReturnType | null = null + private pendingOutputByPty = new Map() private lastInputAtByPty = new Map() private interactiveOutputCharsByPty = new Map() private pendingSpawnCount = 0 @@ -300,7 +319,6 @@ export class PtyHandler { constructor(dispatcher: RelayDispatcher, graceTimeMs = DEFAULT_GRACE_TIME_MS) { this.dispatcher = dispatcher - this.outputBroadcast = new PtyOutputBroadcast(dispatcher) this.graceTimeMs = graceTimeMs this.registerHandlers() } @@ -505,7 +523,6 @@ export class PtyHandler { private wireAndStore(managed: ManagedPty): void { managed.physicalExit = new PhysicalExitTracker() this.ptys.set(managed.id, managed) - this.outputBroadcast.register(managed.id, managed.pty) const emitIngressData = (emission: PtyIngressEmission): void => { const rawLength = emission.rawEndSeq - emission.rawStartSeq this.appendReplayBuffer(managed, emission.data) @@ -550,7 +567,7 @@ export class PtyHandler { } this.clearStartupCommandTimer(managed) this.releaseRelayIngress(managed) - this.outputBroadcast.flushForExit(managed.id) + this.flushPtyOutput(managed.id) this.dispatcher.notify('pty.exit', { id: managed.id, code: exitCode, @@ -595,7 +612,7 @@ export class PtyHandler { private registerHandlers(): void { this.dispatcher.onRequest('pty.spawn', (p, context) => this.spawn(p, context)) - this.dispatcher.onRequest('pty.attach', (p, context) => this.attach(p, context)) + this.dispatcher.onRequest('pty.attach', (p) => this.attach(p)) this.dispatcher.onRequest('pty.shutdown', (p) => this.shutdown(p)) this.dispatcher.onRequest('pty.sendSignal', (p) => this.sendSignal(p)) this.dispatcher.onRequest('pty.getCwd', (p) => this.getCwd(p)) @@ -621,9 +638,9 @@ export class PtyHandler { this.dispatcher.onNotification('pty.data', (p) => this.writeData(p)) this.dispatcher.onNotification('pty.resize', (p) => this.resize(p)) - this.dispatcher.onNotification('pty.ackData', (p, context) => - this.outputBroadcast.acknowledge(p, context) - ) + this.dispatcher.onNotification('pty.ackData', (_p) => { + /* flow control ack -- not yet enforced */ + }) } private isLikelyInteractiveRedraw(data: string): boolean { @@ -669,21 +686,105 @@ export class PtyHandler { data: string, meta: { rawLength?: number; transformed?: boolean; seq?: number } = {} ): void { - const managed = this.ptys.get(id) - if (!managed || managed.disposed) { + const existing = this.pendingOutputByPty.get(id) + if (meta.transformed === true) { + // Why: transformed spans lack a raw-to-clean slice mapping, so they can't be folded into the output batch. + if (existing) { + this.flushPtyOutput(id) + } + this.dispatcher.notify('pty.data', { id, data, ...meta }) return } - this.outputBroadcast.enqueue( - id, - { data, ...meta }, - meta.transformed === true || this.shouldSendInteractiveOutputNow(id, data) - ) + const pending: PendingPtyOutput = { data: (existing?.data ?? '') + data } + if (existing?.rawLength !== undefined || meta.rawLength !== undefined) { + pending.rawLength = + (existing?.rawLength ?? existing?.data.length ?? 0) + (meta.rawLength ?? data.length) + } + if (meta.seq !== undefined) { + pending.seq = meta.seq + } + if (this.shouldSendInteractiveOutputNow(id, pending.data)) { + this.pendingOutputByPty.delete(id) + this.clearOutputFlushTimerIfIdle() + // Why: send interactive echo immediately — batching must not add visible input delay for TUIs. + this.dispatcher.notify('pty.data', { id, ...pending }) + return + } + this.pendingOutputByPty.set(id, pending) + this.scheduleOutputFlush(PTY_OUTPUT_BATCH_INTERVAL_MS) + } + + private scheduleOutputFlush(delayMs: number): void { + if (this.outputFlushTimer !== null) { + return + } + this.outputFlushTimer = setTimeout(() => this.flushPendingOutput(), delayMs) + } + + private flushPendingOutput(): void { + this.outputFlushTimer = null + let writes = 0 + for (const [id, pending] of Array.from(this.pendingOutputByPty.entries())) { + if (writes >= PTY_OUTPUT_FLUSH_MAX_WRITES) { + break + } + this.pendingOutputByPty.delete(id) + const chunk = pending.transformed + ? pending.data + : pending.data.slice(0, PTY_OUTPUT_FLUSH_CHUNK_CHARS) + const remaining = pending.transformed ? '' : pending.data.slice(PTY_OUTPUT_FLUSH_CHUNK_CHARS) + if (remaining) { + this.pendingOutputByPty.set(id, { + data: remaining, + ...(pending.rawLength === undefined ? {} : { rawLength: remaining.length }), + seq: pending.seq + }) + } + const chunkRawLength = pending.transformed + ? pending.rawLength + : pending.rawLength === undefined + ? undefined + : chunk.length + const chunkSeq = + pending.seq === undefined ? undefined : pending.seq - (pending.data.length - chunk.length) + this.dispatcher.notify('pty.data', { + id, + data: chunk, + ...(chunkSeq === undefined ? {} : { seq: chunkSeq }), + ...(chunkRawLength === undefined ? {} : { rawLength: chunkRawLength }), + ...(pending.transformed ? { transformed: true } : {}) + }) + writes++ + } + if (this.pendingOutputByPty.size > 0 && writes > 0) { + // Why: yield between slices of a large chunk so client input and control frames can interleave. + this.scheduleOutputFlush(PTY_OUTPUT_DRAIN_CONTINUE_MS) + } + } + + private flushPtyOutput(id: string): void { + const pending = this.pendingOutputByPty.get(id) + if (!pending) { + return + } + this.pendingOutputByPty.delete(id) + this.dispatcher.notify('pty.data', { id, ...pending }) + this.clearOutputFlushTimerIfIdle() + } + + private clearOutputFlushTimerIfIdle(): void { + if (this.pendingOutputByPty.size > 0 || this.outputFlushTimer === null) { + return + } + clearTimeout(this.outputFlushTimer) + this.outputFlushTimer = null } private clearPtyFlowState(id: string): void { - this.outputBroadcast.unregister(id) + this.pendingOutputByPty.delete(id) this.lastInputAtByPty.delete(id) this.interactiveOutputCharsByPty.delete(id) + this.clearOutputFlushTimerIfIdle() } private beginPtyCreation(operationPaths: readonly (string | undefined)[]): () => void { @@ -800,13 +901,9 @@ export class PtyHandler { context?: RequestContext ): Promise { const env = params.env as Record | undefined - const worktreeId = typeof env?.ORCA_WORKTREE_ID === 'string' ? env.ORCA_WORKTREE_ID : undefined - if (worktreeId !== undefined) { - assertRelayPtyPersistenceFieldWithinLimit('worktreeId', worktreeId) - } + const worktreeId = env?.ORCA_WORKTREE_ID const worktreePath = worktreeId ? splitWorktreeId(worktreeId)?.worktreePath : undefined const cwd = typeof params.cwd === 'string' ? params.cwd : resolveDefaultCwd() - assertRelayPtyPersistenceFieldWithinLimit('cwd', cwd) const finishCreation = this.beginPtyCreation([worktreePath, cwd]) let physicalSpawnCommitted = false const markPhysicalSpawnCommitted = (): void => { @@ -892,21 +989,16 @@ export class PtyHandler { context?: RequestContext, onPhysicalSpawnCommitted?: () => void ): Promise<{ id: string; incarnationId: string }> { - const sizeError = terminalSizeAdmissionError(params.cols, params.rows, 'pty.spawn', { - allowMissing: true - }) - if (sizeError) { - throw new Error(sizeError) - } const pty = await this.loadPty() if (!pty) { throw new Error('node-pty is not available on this remote host') } - const { cols, rows } = normalizeTerminalSize(params.cols, params.rows) + const cols = (params.cols as number) || 80 + const rows = (params.rows as number) || 24 const cwd = (params.cwd as string) || resolveDefaultCwd() const env = params.env as Record | undefined - const envToDelete = sanitizeRelayPtyEnvToDelete(params.envToDelete) + const envToDelete = sanitizeEnvToDelete(params.envToDelete) const explicitTerm = !envToDelete.includes('TERM') && env && @@ -929,23 +1021,6 @@ export class PtyHandler { // Why: kept so a restarted runtime can re-adopt this PTY under its original handle (survives revive). const terminalHandle = typeof env?.ORCA_TERMINAL_HANDLE === 'string' ? env.ORCA_TERMINAL_HANDLE : undefined - const tabId = typeof env?.ORCA_TAB_ID === 'string' ? env.ORCA_TAB_ID : undefined - const attachIdentity = { - paneKey: typeof params.paneKey === 'string' ? params.paneKey : paneKey, - tabId: typeof params.tabId === 'string' ? params.tabId : tabId - } - const worktreeId = typeof env?.ORCA_WORKTREE_ID === 'string' ? env.ORCA_WORKTREE_ID : undefined - assertRelayPtyRetainedFieldsWithinLimits({ - id, - cwd, - paneKey, - tabId, - attachIdentity, - worktreeId, - terminalHandle, - explicitTerm, - envToDelete - }) const command = typeof params.command === 'string' ? params.command : undefined const terminalWindowsWslDistro = typeof params.terminalWindowsWslDistro === 'string' ? params.terminalWindowsWslDistro : null @@ -1004,6 +1079,12 @@ export class PtyHandler { onPhysicalSpawnCommitted?.() // Why: capture paneKey so the exit listener can evict per-pane caches without a separate ptyId→paneKey map. + const tabId = typeof env?.ORCA_TAB_ID === 'string' ? env.ORCA_TAB_ID : undefined + const attachIdentity = { + paneKey: typeof params.paneKey === 'string' ? params.paneKey : paneKey, + tabId: typeof params.tabId === 'string' ? params.tabId : tabId + } + const worktreeId = typeof env?.ORCA_WORKTREE_ID === 'string' ? env.ORCA_WORKTREE_ID : undefined const startupIngressIntent = params.startupIngressVersion === PTY_STARTUP_INGRESS_VERSION ? parsePtyStartupIngressIntent(params.startupIngress) @@ -1062,8 +1143,7 @@ export class PtyHandler { } private async attach( - params: Record, - context?: RequestContext + params: Record ): Promise<{ incarnationId: string; replay?: string }> { const id = params.id as string const managed = this.ptys.get(id) @@ -1076,7 +1156,7 @@ export class PtyHandler { if (managed.pty.pid && !isProcessAlive(managed.pty.pid)) { managed.physicalExit?.markExited() this.releaseRelayIngress(managed) - this.outputBroadcast.flushForExit(id) + this.flushPtyOutput(id) this.notifyExitListener(managed) this.agentSessionOwners.release(managed.id) disposeManagedPty(managed) @@ -1097,14 +1177,14 @@ export class PtyHandler { throw new Error(`PTY "${id}" not found (identity mismatch)`) } - if (typeof context?.clientId === 'number') { - this.outputBroadcast.resetClient(id, context.clientId) - } managed.startupIngress?.snapshotBarrier() // Why: renderer hasn't registered replay handlers yet during spawn, so return to the caller instead of notifying too early. // Why: buffer intentionally NOT cleared after replay (client clears xterm first) so later restarts still replay full history. if (managed.buffered) { + // Why: drop pending batched bytes already in the replay buffer so attach doesn't render them twice. + this.pendingOutputByPty.delete(id) + this.clearOutputFlushTimerIfIdle() if (params.suppressReplayNotification) { return { incarnationId: managed.incarnationId, replay: managed.buffered } } @@ -1129,8 +1209,8 @@ export class PtyHandler { private resize(params: Record): void { const id = params.id as string - const cols = Math.max(1, Math.min(MAX_TERMINAL_COLS, Math.floor(Number(params.cols) || 80))) - const rows = Math.max(1, Math.min(MAX_TERMINAL_ROWS, Math.floor(Number(params.rows) || 24))) + const cols = Math.max(1, Math.min(500, Math.floor(Number(params.cols) || 80))) + const rows = Math.max(1, Math.min(500, Math.floor(Number(params.rows) || 24))) const managed = this.ptys.get(id) if (managed && !managed.disposed) { managed.pty.resize(cols, rows) @@ -1157,6 +1237,7 @@ export class PtyHandler { if (immediate) { this.releaseStartupCommand(managed) + this.flushPtyOutput(id) this.requestForceKill(managed) // Why: remote Git deletion must not race the child's native handles; on timeout keep the map entry so onExit/retry still owns it. await this.waitForPhysicalExit(managed, IMMEDIATE_PTY_EXIT_TIMEOUT_MS) @@ -1198,7 +1279,6 @@ export class PtyHandler { if (managed.gracefulKillSent) { return } - this.outputBroadcast.flushForExit(managed.id) managed.gracefulKillSent = true if (process.platform === 'win32') { // Why: ConPTY's bare kill is already force-final; block any later close of the handle. @@ -1253,7 +1333,6 @@ export class PtyHandler { if (managed.forceKillSent || (process.platform === 'win32' && managed.gracefulKillSent)) { return } - this.outputBroadcast.flushForExit(managed.id) managed.forceKillSent = true try { killPtyProcess(managed.pty, 'SIGKILL') @@ -1348,8 +1427,8 @@ export class PtyHandler { } private async serialize(params: Record): Promise { - const ids = parseRelayPtyPersistenceIds(params.ids, MAX_RELAY_PTY_SESSIONS) - const entries: RelayPtyPersistenceEntry[] = [] + const ids = params.ids as string[] + const entries: SerializedPtyEntry[] = [] for (const id of ids) { const managed = this.ptys.get(id) if (!managed) { @@ -1372,11 +1451,12 @@ export class PtyHandler { ...(managed.terminalHandle ? { terminalHandle: managed.terminalHandle } : {}) }) } - return serializeRelayPtyPersistenceEnvelope(entries, MAX_RELAY_PTY_SESSIONS) + return JSON.stringify(entries) } private async revive(params: Record): Promise { - const entries = parseRelayPtyPersistenceEnvelope(params.state, MAX_RELAY_PTY_SESSIONS) + const state = params.state as string + const entries = JSON.parse(state) as SerializedPtyEntry[] for (const entry of entries) { if (this.ptys.has(entry.id) || this.pendingReviveIds.has(entry.id)) { @@ -1402,7 +1482,7 @@ export class PtyHandler { } } - private async reviveEntry(entry: RelayPtyPersistenceEntry): Promise { + private async reviveEntry(entry: SerializedPtyEntry): Promise { const ptyMod = await this.loadPty() if (!ptyMod) { return @@ -1429,7 +1509,7 @@ export class PtyHandler { revivedEnv.TERM = explicitTerm } // Why: serialized state may come from an older/untrusted client; reapply fresh-spawn bounds. - const envToDelete = sanitizeRelayPtyEnvToDelete(entry.envToDelete) + const envToDelete = sanitizeEnvToDelete(entry.envToDelete) const shell = resolveDefaultShell() const spawnEnv = this.buildSpawnEnv( revivedEnv, @@ -1516,8 +1596,16 @@ export class PtyHandler { this.cancelGraceTimer() await this.waitForPendingPtyCreations() for (const managed of this.ptys.values()) { - this.outputBroadcast.flushForExit(managed.id) + this.releaseRelayIngress(managed) + this.flushPtyOutput(managed.id) } + if (this.outputFlushTimer !== null) { + clearTimeout(this.outputFlushTimer) + this.outputFlushTimer = null + } + this.pendingOutputByPty.clear() + this.lastInputAtByPty.clear() + this.interactiveOutputCharsByPty.clear() const results = await Promise.allSettled( [...this.ptys.values()].map((managed) => this.disposePtyForRelayShutdown(managed, waitForPhysicalExit) @@ -1529,9 +1617,6 @@ export class PtyHandler { if (rejected) { throw rejected.reason } - this.outputBroadcast.dispose() - this.lastInputAtByPty.clear() - this.interactiveOutputCharsByPty.clear() } private async disposePtyForRelayShutdown( @@ -1543,9 +1628,9 @@ export class PtyHandler { managed.killTimer = undefined } this.clearStartupCommandTimer(managed) + this.releaseRelayIngress(managed) // Why: retain the native owner until SIGKILL is accepted (one bounded retry) or onExit proves it gone. await this.requestForceKillForRelayShutdown(managed) - this.releaseRelayIngress(managed) if (waitForPhysicalExit && this.ptys.get(managed.id) === managed && !managed.disposed) { try { await this.waitForPhysicalExit(managed, IMMEDIATE_PTY_EXIT_TIMEOUT_MS) diff --git a/src/relay/pty-output-broadcast.test.ts b/src/relay/pty-output-broadcast.test.ts deleted file mode 100644 index 79f3947d37d..00000000000 --- a/src/relay/pty-output-broadcast.test.ts +++ /dev/null @@ -1,245 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { RelayDispatcher, RelayNotificationWriteResult } from './dispatcher' -import { PTY_OUTPUT_HIGH_WATER_CHARS, PtyOutputBroadcast } from './pty-output-broadcast' - -type Delivery = { - clientId: number - data: string - deliveryToken: string -} - -function createDispatcher(clientIds: number[]) { - const connected = new Set(clientIds) - const detachListeners = new Set<(clientId: number) => void>() - const deliveries: Delivery[] = [] - const evicted: number[] = [] - const writers = new Map< - number, - (delivery: Delivery) => RelayNotificationWriteResult | undefined - >() - const dispatcher = { - connectedClientIds: () => Array.from(connected), - onClientDetached: (listener: (clientId: number) => void) => { - detachListeners.add(listener) - return () => detachListeners.delete(listener) - }, - notifyClientWithBackpressure: ( - clientId: number, - _method: string, - params: Record = {} - ): RelayNotificationWriteResult => { - const delivery = { - clientId, - data: String(params.data ?? ''), - deliveryToken: String(params.deliveryToken ?? '') - } - deliveries.push(delivery) - return ( - writers.get(clientId)?.(delivery) ?? { - delivered: true, - saturated: false, - drained: Promise.resolve() - } - ) - }, - evictClient: (clientId: number) => { - if (!connected.delete(clientId)) { - return - } - evicted.push(clientId) - for (const listener of detachListeners) { - listener(clientId) - } - } - } as unknown as RelayDispatcher - return { dispatcher, deliveries, evicted, writers } -} - -function deliveryData(deliveries: Delivery[], clientId: number): string { - return deliveries - .filter((delivery) => delivery.clientId === clientId) - .map((delivery) => delivery.data) - .join('') -} - -describe('PtyOutputBroadcast', () => { - beforeEach(() => { - vi.useFakeTimers() - }) - - afterEach(() => { - vi.useRealTimers() - }) - - it('broadcasts identical ordered output to every healthy client', async () => { - const { dispatcher, deliveries } = createDispatcher([1, 2]) - const output = new PtyOutputBroadcast(dispatcher) - output.register('pty-1', { pause: vi.fn(), resume: vi.fn() }) - - output.enqueue('pty-1', { data: 'hello ' }) - output.enqueue('pty-1', { data: 'world' }) - await vi.advanceTimersByTimeAsync(20) - - expect(deliveryData(deliveries, 1)).toBe('hello world') - expect(deliveryData(deliveries, 2)).toBe('hello world') - }) - - it('lets a transiently saturated client drain its exact backlog without stalling peers', async () => { - const { dispatcher, deliveries, writers } = createDispatcher([1, 2]) - let signalDrain!: () => void - let firstWrite = true - writers.set(1, () => { - if (!firstWrite) { - return undefined - } - firstWrite = false - return { - delivered: true, - saturated: true, - drained: new Promise((resolve) => { - signalDrain = resolve - }) - } - }) - const output = new PtyOutputBroadcast(dispatcher) - output.register('pty-1', { pause: vi.fn(), resume: vi.fn() }) - - output.enqueue('pty-1', { data: 'first' }) - await vi.advanceTimersByTimeAsync(8) - output.enqueue('pty-1', { data: ' second' }) - await vi.advanceTimersByTimeAsync(8) - - expect(deliveryData(deliveries, 1)).toBe('first') - expect(deliveryData(deliveries, 2)).toBe('first second') - - signalDrain() - await Promise.resolve() - - expect(deliveryData(deliveries, 1)).toBe('first second') - expect(deliveryData(deliveries, 2)).toBe('first second') - }) - - it('evicts only a permanently slow observer at the shared backlog bound', async () => { - const { dispatcher, deliveries, evicted, writers } = createDispatcher([1, 2]) - writers.set(1, () => ({ - delivered: true, - saturated: true, - drained: new Promise(() => {}) - })) - const pause = vi.fn() - const output = new PtyOutputBroadcast(dispatcher) - output.register('pty-1', { pause, resume: vi.fn() }) - const chunk = 'x'.repeat(16 * 1024) - - for (let index = 0; index <= PTY_OUTPUT_HIGH_WATER_CHARS / chunk.length; index++) { - output.enqueue('pty-1', { data: chunk }) - await vi.advanceTimersByTimeAsync(8) - const healthyDelivery = deliveries.findLast((delivery) => delivery.clientId === 2) - output.acknowledge( - { - id: 'pty-1', - charCount: chunk.length, - deliveryToken: healthyDelivery?.deliveryToken - }, - { clientId: 2, isStale: () => false } - ) - } - - expect(evicted).toEqual([1]) - expect(deliveryData(deliveries, 2)).toHaveLength(PTY_OUTPUT_HIGH_WATER_CHARS + chunk.length) - expect(pause).not.toHaveBeenCalled() - - output.enqueue('pty-1', { data: 'tail' }) - await vi.advanceTimersByTimeAsync(20) - expect(deliveryData(deliveries, 2).endsWith('tail')).toBe(true) - }) - - it('pauses all-blocked producers and rejects cross-client or stale credit', async () => { - const { dispatcher, deliveries } = createDispatcher([7]) - const pause = vi.fn() - const resume = vi.fn() - const output = new PtyOutputBroadcast(dispatcher) - output.register('pty-1', { pause, resume }) - - output.enqueue('pty-1', { data: 'x'.repeat(PTY_OUTPUT_HIGH_WATER_CHARS) }) - const deliveryToken = deliveries[0]?.deliveryToken - - expect(pause).toHaveBeenCalledOnce() - output.acknowledge( - { id: 'pty-1', charCount: PTY_OUTPUT_HIGH_WATER_CHARS, deliveryToken }, - { clientId: 8, isStale: () => false } - ) - output.acknowledge( - { id: 'pty-1', charCount: PTY_OUTPUT_HIGH_WATER_CHARS, deliveryToken: 'stale' }, - { clientId: 7, isStale: () => false } - ) - expect(resume).not.toHaveBeenCalled() - - output.acknowledge( - { id: 'pty-1', charCount: PTY_OUTPUT_HIGH_WATER_CHARS, deliveryToken }, - { clientId: 7, isStale: () => false } - ) - expect(resume).toHaveBeenCalledOnce() - }) - - it('rotates only the reattaching client token and preserves other observers', async () => { - const { dispatcher, deliveries } = createDispatcher([1, 2]) - const output = new PtyOutputBroadcast(dispatcher) - output.register('pty-1', { pause: vi.fn(), resume: vi.fn() }) - - output.enqueue('pty-1', { data: 'before' }) - await vi.advanceTimersByTimeAsync(20) - const oldOne = deliveries.find((delivery) => delivery.clientId === 1)?.deliveryToken - const oldTwo = deliveries.find((delivery) => delivery.clientId === 2)?.deliveryToken - - output.resetClient('pty-1', 1) - output.enqueue('pty-1', { data: 'after' }) - await vi.advanceTimersByTimeAsync(20) - const latestOne = deliveries.findLast((delivery) => delivery.clientId === 1)?.deliveryToken - const latestTwo = deliveries.findLast((delivery) => delivery.clientId === 2)?.deliveryToken - - expect(latestOne).not.toBe(oldOne) - expect(latestTwo).toBe(oldTwo) - expect(deliveryData(deliveries, 2)).toBe('beforeafter') - }) - - it('cancels drain waiters and releases a paused producer on cleanup', async () => { - const { dispatcher, writers } = createDispatcher([1]) - const cancelDrain = vi.fn() - writers.set(1, () => ({ - delivered: true, - saturated: true, - drained: new Promise(() => {}), - cancelDrain - })) - const pause = vi.fn() - const resume = vi.fn() - const output = new PtyOutputBroadcast(dispatcher) - output.register('pty-1', { pause, resume }) - - output.enqueue('pty-1', { data: 'blocked' }) - await vi.advanceTimersByTimeAsync(8) - expect(pause).toHaveBeenCalledOnce() - - output.unregister('pty-1') - - expect(resume).toHaveBeenCalledOnce() - expect(cancelDrain).toHaveBeenCalledOnce() - }) - - it('queues the bounded final tail before exit even when normal credit is exhausted', () => { - const { dispatcher, deliveries } = createDispatcher([1, 2]) - const output = new PtyOutputBroadcast(dispatcher) - output.register('pty-1', { pause: vi.fn(), resume: vi.fn() }) - const data = `${'x'.repeat(PTY_OUTPUT_HIGH_WATER_CHARS)}final tail` - - output.enqueue('pty-1', { data }) - expect(deliveryData(deliveries, 1)).toHaveLength(PTY_OUTPUT_HIGH_WATER_CHARS) - expect(deliveryData(deliveries, 2)).toHaveLength(PTY_OUTPUT_HIGH_WATER_CHARS) - - output.flushForExit('pty-1') - - expect(deliveryData(deliveries, 1)).toBe(data) - expect(deliveryData(deliveries, 2)).toBe(data) - }) -}) diff --git a/src/relay/pty-output-broadcast.ts b/src/relay/pty-output-broadcast.ts deleted file mode 100644 index 379539a4ee6..00000000000 --- a/src/relay/pty-output-broadcast.ts +++ /dev/null @@ -1,300 +0,0 @@ -import type { IPty } from 'node-pty' -import type { RelayDispatcher, RequestContext } from './dispatcher' -import { - appendPtyOutput, - cancelPtyOutputDrains, - canSendPtyOutput, - createPtyOutputState, - ensurePtyOutputClient, - evictablePtyOutputClientIds, - hasSendablePtyOutput, - pendingPtyOutputCharCount, - PTY_OUTPUT_HIGH_WATER_CHARS, - relayPtyOutputCharCount, - removePtyOutputClient, - resumePtyOutputProducer, - updatePtyOutputProducer, - type PtyOutputClientFlow, - type PtyOutputState, - type RelayPtyOutput -} from './pty-output-flow-state' - -export { - PTY_OUTPUT_HIGH_WATER_CHARS, - PTY_OUTPUT_LOW_WATER_CHARS, - type RelayPtyOutput -} from './pty-output-flow-state' - -const PTY_OUTPUT_BATCH_INTERVAL_MS = 8 -const PTY_OUTPUT_DRAIN_CONTINUE_MS = 1 -const PTY_OUTPUT_FLUSH_MAX_WRITES = 2 - -export class PtyOutputBroadcast { - private readonly states = new Map() - private flushTimer: ReturnType | null = null - private unsubscribeClientDetach: (() => void) | null - - constructor(private readonly dispatcher: RelayDispatcher) { - this.unsubscribeClientDetach = dispatcher.onClientDetached((clientId) => { - this.detachClient(clientId) - }) - } - - register(id: string, producer: Pick): void { - this.unregister(id) - this.states.set(id, createPtyOutputState(id, producer)) - } - - enqueue(id: string, output: RelayPtyOutput, immediate = false): void { - const state = this.states.get(id) - if (!state || (output.data.length === 0 && relayPtyOutputCharCount(output) === 0)) { - return - } - const clientIds = this.dispatcher.connectedClientIds() - if (clientIds.length === 0) { - return - } - for (const clientId of clientIds) { - ensurePtyOutputClient(state, clientId) - } - appendPtyOutput(state, output, new Set(clientIds)) - if (pendingPtyOutputCharCount(state) >= PTY_OUTPUT_HIGH_WATER_CHARS) { - this.enforceBacklogBound(state) - } else { - updatePtyOutputProducer(state) - } - if (immediate || output.transformed === true) { - this.flushState(state, PTY_OUTPUT_FLUSH_MAX_WRITES) - this.scheduleContinuationIfNeeded() - return - } - this.scheduleFlush(PTY_OUTPUT_BATCH_INTERVAL_MS) - } - - acknowledge(params: Record, context: RequestContext): void { - const id = params.id - const charCount = params.charCount - const deliveryToken = params.deliveryToken - if ( - typeof id !== 'string' || - typeof charCount !== 'number' || - !Number.isFinite(charCount) || - charCount <= 0 || - typeof deliveryToken !== 'string' - ) { - return - } - const state = this.states.get(id) - const flow = state?.clients.get(context.clientId) - if (!state || !flow || flow.deliveryToken !== deliveryToken) { - return - } - flow.unackedChars = Math.max(0, flow.unackedChars - Math.floor(charCount)) - this.flushState(state, PTY_OUTPUT_FLUSH_MAX_WRITES) - this.scheduleContinuationIfNeeded() - } - - resetClient(id: string, clientId: number): void { - const state = this.states.get(id) - if (!state) { - return - } - removePtyOutputClient(state, clientId) - if (this.dispatcher.connectedClientIds().includes(clientId)) { - ensurePtyOutputClient(state, clientId) - } - updatePtyOutputProducer(state) - this.clearFlushTimerIfIdle() - } - - flushForExit(id: string): void { - const state = this.states.get(id) - if (!state) { - return - } - // Why bypass normal credit: the bounded final tail must be queued before pty.exit tears down the client-side terminal. - this.flushState(state, Number.POSITIVE_INFINITY, true) - } - - unregister(id: string): void { - const state = this.states.get(id) - if (!state) { - return - } - resumePtyOutputProducer(state) - for (const flow of state.clients.values()) { - cancelPtyOutputDrains(flow) - } - state.clients.clear() - state.pending.length = 0 - this.states.delete(id) - this.clearFlushTimerIfIdle() - } - - dispose(): void { - this.unsubscribeClientDetach?.() - this.unsubscribeClientDetach = null - for (const id of Array.from(this.states.keys())) { - this.unregister(id) - } - if (this.flushTimer) { - clearTimeout(this.flushTimer) - this.flushTimer = null - } - } - - private flushPending(): void { - this.flushTimer = null - let writesRemaining = PTY_OUTPUT_FLUSH_MAX_WRITES - for (const state of this.states.values()) { - if (writesRemaining <= 0) { - break - } - writesRemaining -= this.flushState(state, 1) - } - if (this.hasSendablePendingOutput()) { - this.scheduleFlush(PTY_OUTPUT_DRAIN_CONTINUE_MS) - } - } - - private flushState(state: PtyOutputState, maxWrites: number, force = false): number { - let writes = 0 - const blockedClientIds = new Set() - for (let entryIndex = 0; entryIndex < state.pending.length; ) { - if (writes >= maxWrites) { - break - } - const pending = state.pending[entryIndex] - for (const clientId of Array.from(pending.pendingClientIds)) { - if (writes >= maxWrites) { - break - } - if (blockedClientIds.has(clientId)) { - continue - } - const flow = state.clients.get(clientId) - if (!flow) { - pending.pendingClientIds.delete(clientId) - continue - } - if (!force && !canSendPtyOutput(flow)) { - blockedClientIds.add(clientId) - continue - } - pending.pendingClientIds.delete(clientId) - this.sendToClient(state, flow, pending) - writes++ - if (state.pending[entryIndex] !== pending) { - break - } - if (!force && !canSendPtyOutput(flow)) { - blockedClientIds.add(clientId) - } - } - if (state.pending[entryIndex] !== pending) { - continue - } - if (pending.pendingClientIds.size === 0) { - state.pending.splice(entryIndex, 1) - } else { - entryIndex++ - } - } - if (pendingPtyOutputCharCount(state) >= PTY_OUTPUT_HIGH_WATER_CHARS) { - this.evictLaggingClientsWhenHealthy(state) - } - updatePtyOutputProducer(state) - this.clearFlushTimerIfIdle() - return writes - } - - private sendToClient( - state: PtyOutputState, - flow: PtyOutputClientFlow, - output: RelayPtyOutput - ): void { - const result = this.dispatcher.notifyClientWithBackpressure(flow.clientId, 'pty.data', { - id: state.id, - data: output.data, - ...(output.rawLength === undefined ? {} : { rawLength: output.rawLength }), - ...(output.transformed ? { transformed: true } : {}), - ...(output.seq === undefined ? {} : { seq: output.seq }), - deliveryToken: flow.deliveryToken - }) - if (!result.delivered || state.clients.get(flow.clientId) !== flow) { - removePtyOutputClient(state, flow.clientId) - return - } - flow.unackedChars += relayPtyOutputCharCount(output) - if (!result.saturated) { - return - } - flow.sinkBackpressured = true - if (result.cancelDrain) { - flow.drainCancellations.add(result.cancelDrain) - } - void result.drained.then(() => { - if (result.cancelDrain) { - flow.drainCancellations.delete(result.cancelDrain) - } - if (state.clients.get(flow.clientId) !== flow) { - return - } - flow.sinkBackpressured = false - this.flushState(state, PTY_OUTPUT_FLUSH_MAX_WRITES) - this.scheduleContinuationIfNeeded() - }) - } - - private detachClient(clientId: number): void { - for (const state of this.states.values()) { - removePtyOutputClient(state, clientId) - updatePtyOutputProducer(state) - } - this.clearFlushTimerIfIdle() - } - - private enforceBacklogBound(state: PtyOutputState): void { - this.flushState(state, Number.POSITIVE_INFINITY) - if (pendingPtyOutputCharCount(state) < PTY_OUTPUT_HIGH_WATER_CHARS) { - return - } - this.evictLaggingClientsWhenHealthy(state) - updatePtyOutputProducer(state) - } - - private evictLaggingClientsWhenHealthy(state: PtyOutputState): void { - for (const clientId of evictablePtyOutputClientIds(state)) { - this.dispatcher.evictClient(clientId) - this.detachClient(clientId) - } - } - - private hasSendablePendingOutput(): boolean { - return hasSendablePtyOutput(this.states.values()) - } - - private scheduleFlush(delayMs: number): void { - if (this.flushTimer) { - return - } - this.flushTimer = setTimeout(() => this.flushPending(), delayMs) - } - - private scheduleContinuationIfNeeded(): void { - if (this.hasSendablePendingOutput()) { - this.scheduleFlush(PTY_OUTPUT_DRAIN_CONTINUE_MS) - } - } - - private clearFlushTimerIfIdle(): void { - if ( - this.flushTimer === null || - Array.from(this.states.values()).some((s) => s.pending.length) - ) { - return - } - clearTimeout(this.flushTimer) - this.flushTimer = null - } -} diff --git a/src/relay/pty-output-flow-state.ts b/src/relay/pty-output-flow-state.ts deleted file mode 100644 index f0761985c03..00000000000 --- a/src/relay/pty-output-flow-state.ts +++ /dev/null @@ -1,240 +0,0 @@ -import { randomUUID } from 'node:crypto' -import type { IPty } from 'node-pty' - -export const PTY_OUTPUT_HIGH_WATER_CHARS = 256 * 1024 -export const PTY_OUTPUT_LOW_WATER_CHARS = 32 * 1024 - -const PTY_OUTPUT_FLUSH_CHUNK_CHARS = 16 * 1024 - -export type RelayPtyOutput = { - data: string - rawLength?: number - transformed?: boolean - seq?: number -} - -export type PtyOutputClientFlow = { - clientId: number - deliveryToken: string - unackedChars: number - sinkBackpressured: boolean - drainCancellations: Set<() => void> -} - -export type PendingPtyOutput = RelayPtyOutput & { - pendingClientIds: Set -} - -export type PtyOutputState = { - id: string - producer: Pick - clients: Map - pending: PendingPtyOutput[] - producerPaused: boolean -} - -export function createPtyOutputState( - id: string, - producer: Pick -): PtyOutputState { - return { id, producer, clients: new Map(), pending: [], producerPaused: false } -} - -export function relayPtyOutputCharCount(output: RelayPtyOutput): number { - return typeof output.rawLength === 'number' && Number.isFinite(output.rawLength) - ? Math.max(0, Math.floor(output.rawLength)) - : output.data.length -} - -function setsEqual(left: ReadonlySet, right: ReadonlySet): boolean { - if (left.size !== right.size) { - return false - } - for (const value of left) { - if (!right.has(value)) { - return false - } - } - return true -} - -function appendChunk( - state: PtyOutputState, - output: RelayPtyOutput, - pendingClientIds: Set -): void { - const tail = state.pending.at(-1) - const tailChars = tail ? relayPtyOutputCharCount(tail) : 0 - const addedChars = relayPtyOutputCharCount(output) - if ( - tail && - tail.transformed === output.transformed && - setsEqual(tail.pendingClientIds, pendingClientIds) && - tailChars + addedChars <= PTY_OUTPUT_FLUSH_CHUNK_CHARS - ) { - const previousLength = tail.data.length - tail.data += output.data - if (tail.rawLength !== undefined || output.rawLength !== undefined) { - tail.rawLength = (tail.rawLength ?? previousLength) + addedChars - } - if (output.seq !== undefined) { - tail.seq = output.seq - } - return - } - state.pending.push({ ...output, pendingClientIds }) -} - -export function appendPtyOutput( - state: PtyOutputState, - output: RelayPtyOutput, - pendingClientIds: Set -): void { - if (output.transformed || output.data.length <= PTY_OUTPUT_FLUSH_CHUNK_CHARS) { - appendChunk(state, output, pendingClientIds) - return - } - for (let offset = 0; offset < output.data.length; offset += PTY_OUTPUT_FLUSH_CHUNK_CHARS) { - const data = output.data.slice(offset, offset + PTY_OUTPUT_FLUSH_CHUNK_CHARS) - const remainingChars = output.data.length - offset - data.length - appendChunk( - state, - { - data, - ...(output.rawLength === undefined ? {} : { rawLength: data.length }), - ...(output.seq === undefined ? {} : { seq: output.seq - remainingChars }) - }, - new Set(pendingClientIds) - ) - } -} - -export function ensurePtyOutputClient( - state: PtyOutputState, - clientId: number -): PtyOutputClientFlow { - const existing = state.clients.get(clientId) - if (existing) { - return existing - } - const flow: PtyOutputClientFlow = { - clientId, - deliveryToken: randomUUID(), - unackedChars: 0, - sinkBackpressured: false, - drainCancellations: new Set() - } - state.clients.set(clientId, flow) - return flow -} - -export function cancelPtyOutputDrains(flow: PtyOutputClientFlow): void { - for (const cancel of flow.drainCancellations) { - cancel() - } - flow.drainCancellations.clear() -} - -export function removePtyOutputClient(state: PtyOutputState, clientId: number): void { - const flow = state.clients.get(clientId) - if (flow) { - cancelPtyOutputDrains(flow) - state.clients.delete(clientId) - } - for (let index = state.pending.length - 1; index >= 0; index--) { - const pending = state.pending[index] - pending.pendingClientIds.delete(clientId) - if (pending.pendingClientIds.size === 0) { - state.pending.splice(index, 1) - } - } -} - -export function canSendPtyOutput(flow: PtyOutputClientFlow): boolean { - return !flow.sinkBackpressured && flow.unackedChars < PTY_OUTPUT_HIGH_WATER_CHARS -} - -export function pendingPtyOutputCharCount(state: PtyOutputState, clientId?: number): number { - return state.pending.reduce( - (total, pending) => - total + - (clientId === undefined || pending.pendingClientIds.has(clientId) - ? relayPtyOutputCharCount(pending) - : 0), - 0 - ) -} - -export function resumePtyOutputProducer(state: PtyOutputState): void { - if (!state.producerPaused) { - return - } - try { - state.producer.resume() - } catch { - /* PTY already destroyed */ - } - state.producerPaused = false -} - -export function updatePtyOutputProducer(state: PtyOutputState): void { - const flows = Array.from(state.clients.values()) - if (flows.length === 0) { - resumePtyOutputProducer(state) - return - } - if (flows.every((flow) => !canSendPtyOutput(flow))) { - if (!state.producerPaused) { - try { - state.producer.pause() - state.producerPaused = true - } catch { - /* PTY already destroyed */ - } - } - return - } - if ( - state.producerPaused && - flows.some( - (flow) => - !flow.sinkBackpressured && - flow.unackedChars + pendingPtyOutputCharCount(state, flow.clientId) <= - PTY_OUTPUT_LOW_WATER_CHARS - ) - ) { - resumePtyOutputProducer(state) - } -} - -export function hasSendablePtyOutput(states: Iterable): boolean { - for (const state of states) { - for (const pending of state.pending) { - for (const clientId of pending.pendingClientIds) { - const flow = state.clients.get(clientId) - if (flow && canSendPtyOutput(flow)) { - return true - } - } - } - } - return false -} - -export function evictablePtyOutputClientIds(state: PtyOutputState): number[] { - const laggingClientIds = new Set() - for (const pending of state.pending) { - for (const clientId of pending.pendingClientIds) { - laggingClientIds.add(clientId) - } - } - if ( - laggingClientIds.size === 0 || - !Array.from(state.clients.values()).some( - (flow) => !laggingClientIds.has(flow.clientId) && canSendPtyOutput(flow) - ) - ) { - return [] - } - return Array.from(laggingClientIds) -} diff --git a/src/relay/pty-persistence-envelope.test.ts b/src/relay/pty-persistence-envelope.test.ts deleted file mode 100644 index 3e01c635641..00000000000 --- a/src/relay/pty-persistence-envelope.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - MAX_RELAY_PTY_PERSISTENCE_FIELD_BYTES, - MAX_RELAY_PTY_PERSISTENCE_RETAINED_BYTES, - MAX_RELAY_PTY_PERSISTENCE_STATE_BYTES, - parseRelayPtyPersistenceEnvelope, - serializeRelayPtyPersistenceEnvelope, - type RelayPtyPersistenceEntry -} from './pty-persistence-envelope' -import { MAX_TERMINAL_COLS } from '../shared/terminal-size-limits' - -function entry(index: number, overrides: Partial = {}) { - return { - id: `pty-${index}`, - pid: 100 + index, - cols: 80, - rows: 24, - cwd: '/repo', - envToDelete: [], - gitCredentialPromptGuarded: false, - ...overrides - } -} - -describe('relay PTY persistence envelope', () => { - it('round-trips normal state without changing retained fields', () => { - const entries = [ - entry(1, { - paneKey: 'tab-1:leaf-1', - attachIdentity: { paneKey: 'tab-1:leaf-1', tabId: 'tab-1' }, - worktreeId: 'repo::/repo', - terminalHandle: 'terminal-1', - explicitTerm: 'screen-256color', - envToDelete: ['ORCA_ATTRIBUTION_SHIM_DIR'], - gitCredentialPromptGuarded: true - }) - ] - - const serialized = serializeRelayPtyPersistenceEnvelope(entries, 50) - - expect(parseRelayPtyPersistenceEnvelope(serialized, 50)).toEqual(entries) - }) - - it('accepts the field limit and rejects limit plus one', () => { - expect(() => - serializeRelayPtyPersistenceEnvelope( - [entry(1, { cwd: 'x'.repeat(MAX_RELAY_PTY_PERSISTENCE_FIELD_BYTES) })], - 50 - ) - ).not.toThrow() - expect(() => - serializeRelayPtyPersistenceEnvelope( - [entry(1, { cwd: 'x'.repeat(MAX_RELAY_PTY_PERSISTENCE_FIELD_BYTES + 1) })], - 50 - ) - ).toThrow(`exceeds ${MAX_RELAY_PTY_PERSISTENCE_FIELD_BYTES} bytes`) - }) - - it('rejects aggregate retained fields before serialization allocates the output', () => { - const field = 'x'.repeat(63 * 1024) - const entries = Array.from({ length: 49 }, (_, index) => - entry(index, { cwd: field, paneKey: field }) - ) - - expect(() => serializeRelayPtyPersistenceEnvelope(entries, 50)).toThrow( - `exceeds ${MAX_RELAY_PTY_PERSISTENCE_RETAINED_BYTES} retained bytes` - ) - }) - - it('rejects oversized and deeply nested input before JSON.parse', () => { - expect(() => - parseRelayPtyPersistenceEnvelope(' '.repeat(MAX_RELAY_PTY_PERSISTENCE_STATE_BYTES + 1), 50) - ).toThrow(`exceeds ${MAX_RELAY_PTY_PERSISTENCE_STATE_BYTES} bytes`) - - const deeplyNested = JSON.stringify([ - { - ...entry(1), - ignored: [[[[[[[[]]]]]]]] - } - ]) - expect(() => parseRelayPtyPersistenceEnvelope(deeplyNested, 50)).toThrow( - 'JSON nesting exceeds 8 levels' - ) - }) - - it('rejects entry-count overflow transactionally', () => { - const serialized = JSON.stringify([entry(1), entry(2)]) - expect(() => parseRelayPtyPersistenceEnvelope(serialized, 1)).toThrow( - 'PTY persistence state exceeds 1 entries' - ) - }) - - it('rejects oversized terminal dimensions on writes and reads', () => { - const oversized = entry(1, { cols: MAX_TERMINAL_COLS + 1 }) - expect(() => serializeRelayPtyPersistenceEnvelope([oversized], 50)).toThrow( - `1 through ${MAX_TERMINAL_COLS}` - ) - expect(() => parseRelayPtyPersistenceEnvelope(JSON.stringify([oversized]), 50)).toThrow( - `1 through ${MAX_TERMINAL_COLS}` - ) - }) -}) diff --git a/src/relay/pty-persistence-envelope.ts b/src/relay/pty-persistence-envelope.ts deleted file mode 100644 index 65380233190..00000000000 --- a/src/relay/pty-persistence-envelope.ts +++ /dev/null @@ -1,243 +0,0 @@ -import { assertJsonTextStructureWithinLimits } from '../shared/json-text-structure-limit' -import { stringifyJsonWithinByteLimit } from '../shared/node-bounded-json-stringify' -import { terminalSizeAdmissionError } from '../shared/terminal-size-limits' - -export const MAX_RELAY_PTY_PERSISTENCE_STATE_BYTES = 8 * 1024 * 1024 -export const MAX_RELAY_PTY_PERSISTENCE_FIELD_BYTES = 64 * 1024 -export const MAX_RELAY_PTY_PERSISTENCE_ENTRY_BYTES = 128 * 1024 -export const MAX_RELAY_PTY_PERSISTENCE_RETAINED_BYTES = 6 * 1024 * 1024 -export const MAX_RELAY_PTY_ENV_DELETE_KEYS = 1_024 - -const PTY_PERSISTENCE_JSON_STRUCTURE_LIMITS = { - structuralTokens: 131_072, - nestingDepth: 8 -} as const - -export type RelayPtyIdentity = { - paneKey?: string - tabId?: string -} - -export type RelayPtyPersistenceEntry = { - id: string - pid: number - cols: number - rows: number - cwd: string - paneKey?: string - tabId?: string - attachIdentity?: RelayPtyIdentity - worktreeId?: string - terminalHandle?: string - explicitTerm?: string - envToDelete?: string[] - gitCredentialPromptGuarded?: boolean -} - -export type RelayPtyRetainedFields = Pick< - RelayPtyPersistenceEntry, - | 'id' - | 'cwd' - | 'paneKey' - | 'tabId' - | 'attachIdentity' - | 'worktreeId' - | 'terminalHandle' - | 'explicitTerm' - | 'envToDelete' -> - -export function sanitizeRelayPtyEnvToDelete(value: unknown): string[] { - return Array.isArray(value) - ? value - .filter((key): key is string => typeof key === 'string' && key.length > 0) - .slice(0, MAX_RELAY_PTY_ENV_DELETE_KEYS) - : [] -} - -export function assertRelayPtyRetainedFieldsWithinLimits(fields: RelayPtyRetainedFields): number { - let retainedBytes = 0 - const add = (field: string, value: string | undefined): void => { - if (value === undefined) { - return - } - const bytes = Buffer.byteLength(value, 'utf8') - if (bytes > MAX_RELAY_PTY_PERSISTENCE_FIELD_BYTES) { - throw new Error( - `PTY persistence field "${field}" exceeds ${MAX_RELAY_PTY_PERSISTENCE_FIELD_BYTES} bytes` - ) - } - retainedBytes += bytes - } - - add('id', fields.id) - add('cwd', fields.cwd) - add('paneKey', fields.paneKey) - add('tabId', fields.tabId) - add('attachIdentity.paneKey', fields.attachIdentity?.paneKey) - add('attachIdentity.tabId', fields.attachIdentity?.tabId) - add('worktreeId', fields.worktreeId) - add('terminalHandle', fields.terminalHandle) - add('explicitTerm', fields.explicitTerm) - for (const key of fields.envToDelete ?? []) { - add('envToDelete', key) - } - if (retainedBytes > MAX_RELAY_PTY_PERSISTENCE_ENTRY_BYTES) { - throw new Error( - `PTY persistence entry exceeds ${MAX_RELAY_PTY_PERSISTENCE_ENTRY_BYTES} retained bytes` - ) - } - return retainedBytes -} - -export function serializeRelayPtyPersistenceEnvelope( - entries: readonly RelayPtyPersistenceEntry[], - maxEntries: number -): string { - assertEnvelopeEntryCount(entries, maxEntries) - assertEnvelopeRetainedBytes(entries) - return stringifyJsonWithinByteLimit(entries, MAX_RELAY_PTY_PERSISTENCE_STATE_BYTES).serialized -} - -export function parseRelayPtyPersistenceEnvelope( - state: unknown, - maxEntries: number -): RelayPtyPersistenceEntry[] { - if (typeof state !== 'string') { - throw new Error('PTY persistence state must be JSON text') - } - if (Buffer.byteLength(state, 'utf8') > MAX_RELAY_PTY_PERSISTENCE_STATE_BYTES) { - throw new Error(`PTY persistence state exceeds ${MAX_RELAY_PTY_PERSISTENCE_STATE_BYTES} bytes`) - } - assertJsonTextStructureWithinLimits(state, PTY_PERSISTENCE_JSON_STRUCTURE_LIMITS) - return normalizeEnvelope(JSON.parse(state) as unknown, maxEntries) -} - -export function parseRelayPtyPersistenceIds(value: unknown, maxEntries: number): string[] { - if (!Array.isArray(value) || value.length > maxEntries) { - throw new Error(`PTY persistence request exceeds ${maxEntries} entries`) - } - return value.map((id) => requiredString(id, 'id')) -} - -function normalizeEnvelope(value: unknown, maxEntries: number): RelayPtyPersistenceEntry[] { - if (!Array.isArray(value)) { - throw new Error(`PTY persistence state exceeds ${maxEntries} entries`) - } - assertEnvelopeEntryCount(value, maxEntries) - - const entries = value.map((entry, index) => normalizeEntry(entry, index)) - assertEnvelopeRetainedBytes(entries) - return entries -} - -function assertEnvelopeEntryCount(value: readonly unknown[], maxEntries: number): void { - if (!Number.isSafeInteger(maxEntries) || maxEntries < 0) { - throw new RangeError('PTY persistence entry limit must be a non-negative safe integer') - } - if (value.length > maxEntries) { - throw new Error(`PTY persistence state exceeds ${maxEntries} entries`) - } -} - -function assertEnvelopeRetainedBytes(entries: readonly RelayPtyRetainedFields[]): void { - let retainedBytes = 0 - for (const entry of entries) { - const dimensions = entry as Partial - const sizeError = terminalSizeAdmissionError( - dimensions.cols, - dimensions.rows, - 'PTY persistence entry', - { allowMissing: true } - ) - if (sizeError) { - throw new Error(sizeError) - } - retainedBytes += assertRelayPtyRetainedFieldsWithinLimits(entry) - if (retainedBytes > MAX_RELAY_PTY_PERSISTENCE_RETAINED_BYTES) { - throw new Error( - `PTY persistence state exceeds ${MAX_RELAY_PTY_PERSISTENCE_RETAINED_BYTES} retained bytes` - ) - } - } -} - -function normalizeEntry(value: unknown, index: number): RelayPtyPersistenceEntry { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - throw new Error(`PTY persistence entry ${index} must be an object`) - } - const entry = value as Record - const attachIdentity = normalizeIdentity(entry.attachIdentity) - const paneKey = optionalString(entry.paneKey, 'paneKey') - const tabId = optionalString(entry.tabId, 'tabId') - const worktreeId = optionalString(entry.worktreeId, 'worktreeId') - const terminalHandle = optionalString(entry.terminalHandle, 'terminalHandle') - const explicitTerm = optionalString(entry.explicitTerm, 'explicitTerm') - const envToDelete = sanitizeRelayPtyEnvToDelete(entry.envToDelete) - const cols = positiveSafeIntegerOrDefault(entry.cols, 'cols', 80) - const rows = positiveSafeIntegerOrDefault(entry.rows, 'rows', 24) - const sizeError = terminalSizeAdmissionError(cols, rows, 'PTY persistence entry') - if (sizeError) { - throw new Error(sizeError) - } - - return { - id: requiredString(entry.id, 'id'), - pid: positiveSafeInteger(entry.pid, 'pid'), - cols, - rows, - cwd: requiredString(entry.cwd, 'cwd'), - ...(paneKey === undefined ? {} : { paneKey }), - ...(tabId === undefined ? {} : { tabId }), - ...(attachIdentity === undefined ? {} : { attachIdentity }), - ...(worktreeId === undefined ? {} : { worktreeId }), - ...(terminalHandle === undefined ? {} : { terminalHandle }), - ...(explicitTerm === undefined ? {} : { explicitTerm }), - ...(Array.isArray(entry.envToDelete) ? { envToDelete } : {}), - gitCredentialPromptGuarded: entry.gitCredentialPromptGuarded === true - } -} - -function normalizeIdentity(value: unknown): RelayPtyIdentity | undefined { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - return undefined - } - const identity = value as Record - const paneKey = optionalString(identity.paneKey, 'attachIdentity.paneKey') - const tabId = optionalString(identity.tabId, 'attachIdentity.tabId') - return paneKey === undefined && tabId === undefined ? undefined : { paneKey, tabId } -} - -function requiredString(value: unknown, field: string): string { - if (typeof value !== 'string') { - throw new Error(`PTY persistence field "${field}" must be a string`) - } - assertRelayPtyPersistenceFieldWithinLimit(field, value) - return value -} - -function optionalString(value: unknown, field: string): string | undefined { - if (value === undefined) { - return undefined - } - return requiredString(value, field) -} - -export function assertRelayPtyPersistenceFieldWithinLimit(field: string, value: string): void { - if (Buffer.byteLength(value, 'utf8') > MAX_RELAY_PTY_PERSISTENCE_FIELD_BYTES) { - throw new Error( - `PTY persistence field "${field}" exceeds ${MAX_RELAY_PTY_PERSISTENCE_FIELD_BYTES} bytes` - ) - } -} - -function positiveSafeInteger(value: unknown, field: string): number { - if (!Number.isSafeInteger(value) || (value as number) <= 0) { - throw new Error(`PTY persistence field "${field}" must be a positive safe integer`) - } - return value as number -} - -function positiveSafeIntegerOrDefault(value: unknown, field: string, fallback: number): number { - return value === undefined ? fallback : positiveSafeInteger(value, field) -} diff --git a/src/relay/pty-shell-launch.test.ts b/src/relay/pty-shell-launch.test.ts index fb8fc4dc693..c4c5b5f11ad 100644 --- a/src/relay/pty-shell-launch.test.ts +++ b/src/relay/pty-shell-launch.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, mkdirSync, readFileSync, rmSync, truncateSync, writeFileSync } from 'node:fs' +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { spawnSync } from 'node:child_process' @@ -137,24 +137,6 @@ describe('getRelayShellLaunchConfig', () => { ) }) - it.skipIf(process.platform === 'win32')( - 'replaces a sparse oversized wrapper without reading its payload', - () => { - const zshRoot = join(homeDir, '.orca-relay', 'shell-ready', 'zsh') - const wrapperPath = join(zshRoot, '.zshenv') - mkdirSync(zshRoot, { recursive: true }) - writeFileSync(wrapperPath, '') - truncateSync(wrapperPath, 1024 * 1024 * 1024) - - getRelayShellLaunchConfig('/bin/zsh', { - HOME: homeDir, - ORCA_OPENCODE_CONFIG_DIR: '/tmp/orca-opencode-overlay' - }) - - expect(readFileSync(wrapperPath, 'utf8')).toContain('export ORCA_USER_ZDOTDIR=') - } - ) - it.skipIf(process.platform === 'win32')( 'wraps zsh when MiMo home must survive shell startup', () => { diff --git a/src/relay/pty-shell-launch.ts b/src/relay/pty-shell-launch.ts index 34ba6883044..5f58b89702c 100644 --- a/src/relay/pty-shell-launch.ts +++ b/src/relay/pty-shell-launch.ts @@ -1,4 +1,4 @@ -import { chmodSync, mkdirSync, writeFileSync } from 'node:fs' +import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { homedir } from 'node:os' import { dirname, join } from 'node:path' import { getPosixOmpShellWrapper } from '../main/pty/omp-shell-wrapper' @@ -7,7 +7,6 @@ import { getZshShellReadyMarkerRegistrationBlock, getZshStartupFileSourceBlock } from '../main/shell-templates' -import { readNodeFileSyncWithinLimit } from '../shared/node-bounded-file-reader' const RELAY_SHELL_READY_DIR = '.orca-relay/shell-ready' const POSIX_LOGIN_ARGS = ['-l'] @@ -247,10 +246,7 @@ trap '__orca_osc133_preexec' DEBUG mkdirSync(dirname(path), { recursive: true }) let existing: string | null = null try { - existing = readNodeFileSyncWithinLimit( - path, - Buffer.byteLength(content, 'utf8') - ).buffer.toString('utf8') + existing = readFileSync(path, 'utf8') } catch { existing = null } diff --git a/src/relay/pty-shell-utils.ts b/src/relay/pty-shell-utils.ts index 23ce31500fb..6d589b8c6a0 100644 --- a/src/relay/pty-shell-utils.ts +++ b/src/relay/pty-shell-utils.ts @@ -1,9 +1,8 @@ import { execFile as execFileCb } from 'node:child_process' -import { existsSync } from 'node:fs' +import { existsSync, readFileSync } from 'node:fs' import { homedir } from 'node:os' import { win32 as pathWin32 } from 'node:path' import { promisify } from 'node:util' -import { readNodeFileSyncWithinLimit } from '../shared/node-bounded-file-reader' import { isAgentForegroundWrapperProcess, isExpectedAgentProcess, @@ -23,7 +22,6 @@ import { } from '../main/providers/windows-agent-foreground-process' const execFile = promisify(execFileCb) -const MAX_ETC_SHELLS_BYTES = 64 * 1024 export function resolveWindowsDefaultShell( env: NodeJS.ProcessEnv = process.env, @@ -313,10 +311,7 @@ export function listShellProfiles(): { name: string; path: string }[] { const seen = new Set() try { - const content = readNodeFileSyncWithinLimit( - '/etc/shells', - MAX_ETC_SHELLS_BYTES - ).buffer.toString('utf8') + const content = readFileSync('/etc/shells', 'utf-8') for (const line of content.split('\n')) { const trimmed = line.trim() if (!trimmed || trimmed.startsWith('#')) { diff --git a/src/relay/relay-filesystem-watch-registry.test.ts b/src/relay/relay-filesystem-watch-registry.test.ts index 800cb43fd86..9a21fb9eace 100644 --- a/src/relay/relay-filesystem-watch-registry.test.ts +++ b/src/relay/relay-filesystem-watch-registry.test.ts @@ -11,7 +11,6 @@ import type { import type { RelayDispatcher, RequestContext } from './dispatcher' import { RelayFilesystemWatchRegistry } from './relay-filesystem-watch-registry' import { createRelayWatcherProcessPool } from './relay-watcher-process-pool' -import { MAX_RELAY_WATCH_ROOT_KEY_BYTES } from './relay-watcher-root-capacity' type InstalledWatch = { rootPath: string @@ -87,21 +86,6 @@ describe('RelayFilesystemWatchRegistry', () => { registry = new RelayFilesystemWatchRegistry(dispatcher as unknown as RelayDispatcher, pool) }) - it('rejects an oversized raw root before retaining a pending setup', async () => { - await expect( - registry.watch('/'.repeat(MAX_RELAY_WATCH_ROOT_KEY_BYTES + 1), context(1)) - ).rejects.toThrow('File watcher root path is too long') - - expect(pool.installed).toHaveLength(0) - expect( - ( - registry as unknown as { - pendingSetups: Map - } - ).pendingSetups.size - ).toBe(0) - }) - it('emits overflow around child replacement and resumes ordered event delivery', async () => { await registry.watch('/repo', context(1)) const first = pool.installed[0] diff --git a/src/relay/relay-filesystem-watch-registry.ts b/src/relay/relay-filesystem-watch-registry.ts index 94977c01ab2..4acb786fa23 100644 --- a/src/relay/relay-filesystem-watch-registry.ts +++ b/src/relay/relay-filesystem-watch-registry.ts @@ -16,10 +16,7 @@ import { type RelayWatcherTeardownState } from './relay-watcher-teardown-tracker' import { emitRelayWatcherTerminalFailure } from './relay-watcher-terminal-notifier' -import { - assertRelayWatcherRootCapacity, - assertRelayWatcherRootKeyCapacity -} from './relay-watcher-root-capacity' +import { assertRelayWatcherRootCapacity } from './relay-watcher-root-capacity' import { normalizeRuntimePathForComparison } from '../shared/cross-platform-path' import { trackRelayWatcherSetup, @@ -88,7 +85,6 @@ export class RelayFilesystemWatchRegistry { context?: RequestContext, watchId?: number ): Promise { - assertRelayWatcherRootKeyCapacity(rootKey, rootPath) const staleTeardown = releaseStaleRelayWatches(this.watches.values(), (state) => this.closeWatch(state) ) diff --git a/src/relay/relay-handshake.ts b/src/relay/relay-handshake.ts index b1a7da5b5dc..e021c392561 100644 --- a/src/relay/relay-handshake.ts +++ b/src/relay/relay-handshake.ts @@ -1,9 +1,8 @@ // Wire-level handshake helpers for the Orca relay. import { dirname, join } from 'node:path' -import { existsSync, realpathSync } from 'node:fs' +import { existsSync, readFileSync, realpathSync } from 'node:fs' import type { Socket } from 'node:net' -import { readRelayVersionMarkerSync } from '../shared/relay-version-marker' import { RELAY_VERSION, MessageType, @@ -35,7 +34,7 @@ export function readLaunchVersion(): string { } const versionFile = join(dir, '.version') if (existsSync(versionFile)) { - const v = readRelayVersionMarkerSync(versionFile) + const v = readFileSync(versionFile, 'utf-8').trim() if (v) { return v } diff --git a/src/relay/relay-watcher-removal-fence.ts b/src/relay/relay-watcher-removal-fence.ts index fdda69abc67..9b0b62ab780 100644 --- a/src/relay/relay-watcher-removal-fence.ts +++ b/src/relay/relay-watcher-removal-fence.ts @@ -6,9 +6,6 @@ import type { RelayDispatcher } from './dispatcher' import { emitRelayWatcherTerminalFailure } from './relay-watcher-terminal-notifier' import { isPathInsideOrEqual } from '../shared/cross-platform-path' import type { RelayWatcherPendingSetup } from './relay-watcher-setup-tracking' -import { forEachWithConcurrency, mapSettledWithConcurrency } from '../shared/map-with-concurrency' - -const RELAY_WATCH_CLOSE_CONCURRENCY = 8 export class RelayWatcherRemovalFence { private readonly roots = new Set() @@ -105,9 +102,7 @@ export class RelayWatcherRemovalFence { const pending = [...this.pendingSetups.entries()].filter(([setupRoot]) => isPathInsideOrEqual(rootKey, setupRoot) ) - await forEachWithConcurrency(pending, RELAY_WATCH_CLOSE_CONCURRENCY, async ([, setup]) => { - await setup.promise.catch(() => undefined) - }) + await Promise.all(pending.map(([, setup]) => setup.promise.catch(() => undefined))) const states = [...this.watches.entries()] .filter(([watchRoot]) => isPathInsideOrEqual(rootKey, watchRoot)) .map(([, state]) => state) @@ -116,15 +111,7 @@ export class RelayWatcherRemovalFence { state.clients.clear() state.clientWatchIds.clear() } - const closes = await mapSettledWithConcurrency(states, RELAY_WATCH_CLOSE_CONCURRENCY, (state) => - this.closeWatch(state) - ) - const failedClose = closes.find( - (result): result is PromiseRejectedResult => result.status === 'rejected' - ) - if (failedClose) { - throw failedClose.reason - } + await Promise.all(states.map((state) => this.closeWatch(state))) const trackedRoots = this.teardownTracker .rootPaths() .filter((trackedRoot) => isPathInsideOrEqual(rootKey, trackedRoot)) diff --git a/src/relay/relay-watcher-root-capacity.test.ts b/src/relay/relay-watcher-root-capacity.test.ts deleted file mode 100644 index e8434a82011..00000000000 --- a/src/relay/relay-watcher-root-capacity.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - MAX_RELAY_WATCH_ROOT_KEY_BYTES, - MAX_RELAY_WATCH_ROOT_KEYS_BYTES, - MAX_RELAY_WATCH_ROOTS, - assertRelayWatcherRootCapacity -} from './relay-watcher-root-capacity' - -function keyWithBytes(label: string, bytes: number): string { - return `${label}${'x'.repeat(bytes - Buffer.byteLength(label))}` -} - -describe('relay watcher root capacity', () => { - it('accepts a root key at the exact UTF-8 byte boundary', () => { - const root = keyWithBytes('/', MAX_RELAY_WATCH_ROOT_KEY_BYTES) - - expect(() => assertRelayWatcherRootCapacity([], [], [], root)).not.toThrow() - }) - - it('rejects a root key one byte over the boundary', () => { - const root = keyWithBytes('/', MAX_RELAY_WATCH_ROOT_KEY_BYTES + 1) - - expect(() => assertRelayWatcherRootCapacity([], [], [], root)).toThrow( - 'File watcher root path is too long' - ) - }) - - it('rejects an oversized raw path even when normalization made its key small', () => { - const rawPath = '/'.repeat(MAX_RELAY_WATCH_ROOT_KEY_BYTES + 1) - - expect(() => assertRelayWatcherRootCapacity([], [], [], '/', rawPath)).toThrow( - 'File watcher root path is too long' - ) - }) - - it('caps aggregate keys retained across active, pending, and failed teardowns', () => { - const retained = Array.from({ length: 4 }, (_, index) => - keyWithBytes(`/${index}/`, MAX_RELAY_WATCH_ROOT_KEY_BYTES - 1) - ) - const exactProspective = keyWithBytes('/p', 4) - expect( - retained.reduce((total, root) => total + Buffer.byteLength(root), 0) + - Buffer.byteLength(exactProspective) - ).toBe(MAX_RELAY_WATCH_ROOT_KEYS_BYTES) - - expect(() => - assertRelayWatcherRootCapacity( - [retained[0]!], - [retained[1]!], - [retained[2]!, retained[3]!], - exactProspective - ) - ).not.toThrow() - expect(() => - assertRelayWatcherRootCapacity( - [retained[0]!], - [retained[1]!], - [retained[2]!, retained[3]!], - `${exactProspective}x` - ) - ).toThrow('Maximum file watcher root path memory reached') - }) - - it('keeps the existing physical watcher count boundary', () => { - const retained = Array.from({ length: MAX_RELAY_WATCH_ROOTS }, (_, index) => `/${index}`) - - expect(() => assertRelayWatcherRootCapacity(retained, [], [], retained[0]!)).not.toThrow() - expect(() => assertRelayWatcherRootCapacity(retained, [], [], '/overflow')).toThrow( - 'Maximum number of file watchers reached' - ) - }) -}) diff --git a/src/relay/relay-watcher-root-capacity.ts b/src/relay/relay-watcher-root-capacity.ts index 4f51b1eeafe..025462e7bd3 100644 --- a/src/relay/relay-watcher-root-capacity.ts +++ b/src/relay/relay-watcher-root-capacity.ts @@ -1,37 +1,13 @@ -export const MAX_RELAY_WATCH_ROOTS = 20 -export const MAX_RELAY_WATCH_ROOT_KEY_BYTES = 64 * 1024 -export const MAX_RELAY_WATCH_ROOT_KEYS_BYTES = 256 * 1024 - -export function assertRelayWatcherRootKeyCapacity(rootKey: string, rootPath = rootKey): void { - if ( - Buffer.byteLength(rootKey, 'utf8') > MAX_RELAY_WATCH_ROOT_KEY_BYTES || - Buffer.byteLength(rootPath, 'utf8') > MAX_RELAY_WATCH_ROOT_KEY_BYTES - ) { - throw new Error('File watcher root path is too long') - } -} +const MAX_RELAY_WATCH_ROOTS = 20 export function assertRelayWatcherRootCapacity( activeRoots: Iterable, pendingRoots: Iterable, teardownRoots: Iterable, - prospectiveRoot: string, - prospectiveRootPath = prospectiveRoot + prospectiveRoot: string ): void { - assertRelayWatcherRootKeyCapacity(prospectiveRoot, prospectiveRootPath) const physicalRoots = new Set([...activeRoots, ...pendingRoots, ...teardownRoots]) physicalRoots.add(prospectiveRoot) - let retainedKeyBytes = 0 - for (const root of physicalRoots) { - const keyBytes = Buffer.byteLength(root, 'utf8') - if (keyBytes > MAX_RELAY_WATCH_ROOT_KEY_BYTES) { - throw new Error('File watcher root path is too long') - } - retainedKeyBytes += keyBytes - if (retainedKeyBytes > MAX_RELAY_WATCH_ROOT_KEYS_BYTES) { - throw new Error('Maximum file watcher root path memory reached') - } - } if (physicalRoots.size > MAX_RELAY_WATCH_ROOTS) { throw new Error('Maximum number of file watchers reached') } diff --git a/src/relay/relay-watcher-teardown-tracker.ts b/src/relay/relay-watcher-teardown-tracker.ts index 6e250dbbd52..91b1460054d 100644 --- a/src/relay/relay-watcher-teardown-tracker.ts +++ b/src/relay/relay-watcher-teardown-tracker.ts @@ -15,7 +15,6 @@ export type RelayWatcherTeardownState = { } export class RelayWatcherTeardownTracker { - // Why: these entries own native handles and cannot be evicted; watcher admission bounds their count and aggregate root-key bytes. private readonly pending = new Map>() private readonly failed = new Map() diff --git a/src/relay/relay.ts b/src/relay/relay.ts index 08bbb9f9aad..e279bec49c7 100644 --- a/src/relay/relay.ts +++ b/src/relay/relay.ts @@ -14,7 +14,6 @@ import { resolve, join } from 'node:path' import { unlinkSync, existsSync, statSync } from 'node:fs' import { RELAY_SENTINEL, - MAX_MESSAGE_SIZE, FrameDecoder, MessageType, encodeJsonRpcFrame, @@ -23,7 +22,7 @@ import { type JsonRpcResponse } from './protocol' import { readLaunchVersion, runConnectHandshake, setupDaemonHandshake } from './relay-handshake' -import { MAX_RELAY_SOCKET_CONNECTIONS, RelayDispatcher } from './dispatcher' +import { RelayDispatcher } from './dispatcher' import { RelayContext } from './context' import { PtyHandler } from './pty-handler' import { FsHandler } from './fs-handler' @@ -54,10 +53,6 @@ import { relayLogLine } from './relay-diagnostic-log' import { remoteCliRequestTimeoutMs } from './remote-cli-timeout' import { shouldReadRemoteCliStdin } from './remote-cli-stdin' import { registerManagedHookInstaller } from './managed-hook-installer' -import { - NodeReadableTextTooLargeError, - readNodeReadableTextWithinLimit -} from '../shared/node-readable-text' const DEFAULT_GRACE_MS = DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS * 1000 const SOCK_NAME = 'relay.sock' @@ -288,14 +283,11 @@ async function readOrcaCliStdin(): Promise { if (process.stdin.isTTY) { return undefined } - try { - return await readNodeReadableTextWithinLimit(process.stdin, MAX_MESSAGE_SIZE) - } catch (error) { - if (error instanceof NodeReadableTextTooLargeError) { - throw new Error(`Remote CLI stdin exceeds ${MAX_MESSAGE_SIZE} byte relay frame limit`) - } - throw error + const chunks: Buffer[] = [] + for await (const chunk of process.stdin) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk))) } + return Buffer.concat(chunks).toString('utf8') } // ── Normal mode ────────────────────────────────────────────────────── @@ -385,12 +377,6 @@ async function main(): Promise { return } stdoutDrainWaiters.add(cb) - }, - disconnect: () => { - stdoutAlive = false - flushStdoutDrainWaiters() - process.stdin.destroy() - process.stdout.destroy() } } ) @@ -626,6 +612,17 @@ async function main(): Promise { } function attachAcceptedSocket(sock: Socket, leftover: Buffer): void { + // Why: remove the initial stdin data listener once a socket client is accepted, so stale SSH-channel bytes can't interleave. + process.stdin.pause() + process.stdin.removeAllListeners('data') + + hasAcceptedSocketClient = true + acceptedSocketConnections++ + relayLogLine( + `[relay] Socket client accepted (clients=${socketClients.size + 1}, accepted=${acceptedSocketConnections})` + ) + cancelGrace('socket client accepted') + // Why: same backpressure surface as stdout — bulk frames wait for socket drain so they can't bury interactive PTY frames. const sockDrainWaiters = new Set<() => void>() const flushSockDrainWaiters = (): void => { @@ -637,44 +634,23 @@ async function main(): Promise { sock.on('drain', flushSockDrainWaiters) sock.on('close', flushSockDrainWaiters) sock.on('error', flushSockDrainWaiters) - let clientId: number - try { - clientId = dispatcher.attachClient( - (data) => { - if (sock.destroyed) { - return undefined - } + const clientId = dispatcher.attachClient( + (data) => { + if (!sock.destroyed) { return sock.write(data) - }, - { - waitWriteDrain: (cb) => { - if (sock.destroyed) { - cb() - return - } - sockDrainWaiters.add(cb) - }, - disconnect: () => sock.destroy() } - ) - } catch (error) { - relayLogLine( - `[relay] Socket client rejected: ${error instanceof Error ? error.message : String(error)}` - ) - sock.destroy() - return - } - - // Why: remove the initial stdin data listener once a socket client is accepted, so stale SSH-channel bytes can't interleave. - process.stdin.pause() - process.stdin.removeAllListeners('data') - - hasAcceptedSocketClient = true - acceptedSocketConnections++ - relayLogLine( - `[relay] Socket client accepted (clients=${socketClients.size + 1}, accepted=${acceptedSocketConnections})` + return undefined + }, + { + waitWriteDrain: (cb) => { + if (sock.destroyed) { + cb() + return + } + sockDrainWaiters.add(cb) + } + } ) - cancelGrace('socket client accepted') socketClients.set(sock, clientId) // Why: feed handshake-buffered leftover bytes before wiring sock.on('data') so frame ordering is preserved. @@ -716,7 +692,6 @@ async function main(): Promise { } }) }) - server.maxConnections = MAX_RELAY_SOCKET_CONNECTIONS // Why: umask 0o177 before listen makes the socket 0o600 atomically, closing the chmod-after-listen TOCTOU window. const shouldSetSocketUmask = !isWindowsNamedPipePath(sockPath) @@ -886,7 +861,6 @@ async function main(): Promise { if (detached) { // Why: detached stdin is /dev/null, so listening would EOF → grace → shutdown before --connect arrives; use the socket instead. stdoutAlive = false - dispatcher.invalidateClient() startGrace('detached startup') } else { process.stdin.on('data', (chunk: Buffer) => { diff --git a/src/relay/workspace-session-handler.test.ts b/src/relay/workspace-session-handler.test.ts index 40baa7a9155..7c3917e9c2a 100644 --- a/src/relay/workspace-session-handler.test.ts +++ b/src/relay/workspace-session-handler.test.ts @@ -1,15 +1,9 @@ -import { closeSync, ftruncateSync, mkdtempSync, openSync, rmSync, writeFileSync } from 'node:fs' +import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { RelayDispatcher } from './dispatcher' -import { - MAX_PRESENCE_CLIENTS_PER_NAMESPACE, - MAX_PRESENCE_NAMESPACES, - MAX_WORKSPACE_SESSION_SNAPSHOT_BYTES, - MAX_WORKSPACE_SESSION_SNAPSHOT_STRUCTURAL_TOKENS, - WorkspaceSessionHandler -} from './workspace-session-handler' +import { WorkspaceSessionHandler } from './workspace-session-handler' import { encodeJsonRpcFrame, MessageType, type JsonRpcRequest } from './protocol' function decodeJsonFrames(written: Buffer[]): unknown[] { @@ -40,7 +34,6 @@ async function sendRequest( describe('WorkspaceSessionHandler', () => { let baseDir: string let dispatcher: RelayDispatcher - let handler: WorkspaceSessionHandler let written: Buffer[] beforeEach(() => { @@ -49,12 +42,11 @@ describe('WorkspaceSessionHandler', () => { dispatcher = new RelayDispatcher((data) => { written.push(Buffer.from(data)) }) - handler = new WorkspaceSessionHandler(dispatcher, baseDir) + new WorkspaceSessionHandler(dispatcher, baseDir) }) afterEach(() => { dispatcher.dispose() - vi.useRealTimers() rmSync(baseDir, { recursive: true, force: true }) }) @@ -112,40 +104,6 @@ describe('WorkspaceSessionHandler', () => { expect(staleResponse.result.snapshot.revision).toBe(1) }) - it('starts fresh when a persisted snapshot exceeds the file cap', async () => { - const snapshotPath = join(baseDir, 'oversized.json') - const file = openSync(snapshotPath, 'w') - ftruncateSync(file, MAX_WORKSPACE_SESSION_SNAPSHOT_BYTES + 1) - closeSync(file) - - await sendRequest(dispatcher, 'workspace.get', { namespace: 'oversized' }, 1) - - const response = decodeJsonFrames(written).find( - (frame) => (frame as { id?: number }).id === 1 - ) as { result: { revision: number; session: { activeRepoId: unknown } } } - expect(response.result.revision).toBe(0) - expect(response.result.session.activeRepoId).toBeNull() - }) - - it('starts fresh when a bounded snapshot amplifies structure before parsing', () => { - writeFileSync( - join(baseDir, 'amplified.json'), - `[${'0,'.repeat(MAX_WORKSPACE_SESSION_SNAPSHOT_STRUCTURAL_TOKENS)}0]` - ) - const parseSpy = vi.spyOn(JSON, 'parse') - - const snapshot = ( - handler as unknown as { - read(namespace: string): { revision: number; session: { activeRepoId: unknown } } - } - ).read('amplified') - - expect(snapshot.revision).toBe(0) - expect(snapshot.session.activeRepoId).toBeNull() - expect(parseSpy).not.toHaveBeenCalled() - parseSpy.mockRestore() - }) - it('tracks presence per namespace', async () => { await sendRequest( dispatcher, @@ -181,83 +139,4 @@ describe('WorkspaceSessionHandler', () => { 'Laptop A' ) }) - - it('does not retain namespaces for presence queries without a client id', async () => { - for (let index = 0; index < MAX_PRESENCE_NAMESPACES + 10; index += 1) { - await sendRequest( - dispatcher, - 'workspace.presence', - { namespace: `query-${index}` }, - index + 1 - ) - } - - const namespaces = (handler as unknown as { clientsByNamespace: Map }) - .clientsByNamespace - expect(namespaces.size).toBe(0) - }) - - it('sweeps expired clients from every namespace on any heartbeat', async () => { - vi.useFakeTimers() - vi.setSystemTime(new Date('2026-01-01T00:00:00Z')) - await sendRequest(dispatcher, 'workspace.presence', { namespace: 'a', clientId: 'a' }, 1) - await sendRequest(dispatcher, 'workspace.presence', { namespace: 'b', clientId: 'b' }, 2) - - vi.setSystemTime(new Date('2026-01-01T00:01:00Z')) - await sendRequest(dispatcher, 'workspace.presence', { namespace: 'c', clientId: 'c' }, 3) - - const namespaces = (handler as unknown as { clientsByNamespace: Map }) - .clientsByNamespace - expect(Array.from(namespaces.keys())).toEqual(['c']) - }) - - it('bounds clients per namespace by evicting the oldest heartbeat', async () => { - vi.useFakeTimers() - vi.setSystemTime(new Date('2026-01-01T00:00:00Z')) - for (let index = 0; index <= MAX_PRESENCE_CLIENTS_PER_NAMESPACE; index += 1) { - vi.setSystemTime(Date.now() + 1) - await sendRequest( - dispatcher, - 'workspace.presence', - { namespace: 'team', clientId: `client-${String(index).padStart(3, '0')}` }, - index + 1 - ) - } - - const clients = ( - handler as unknown as { - clientsByNamespace: Map> - } - ).clientsByNamespace.get('team') - expect(clients?.size).toBe(MAX_PRESENCE_CLIENTS_PER_NAMESPACE) - expect(clients?.has('client-000')).toBe(false) - expect( - clients?.has(`client-${String(MAX_PRESENCE_CLIENTS_PER_NAMESPACE).padStart(3, '0')}`) - ).toBe(true) - }) - - it('bounds namespaces by evicting the least recently active one', async () => { - vi.useFakeTimers() - vi.setSystemTime(new Date('2026-01-01T00:00:00Z')) - for (let index = 0; index <= MAX_PRESENCE_NAMESPACES; index += 1) { - vi.setSystemTime(Date.now() + 1) - await sendRequest( - dispatcher, - 'workspace.presence', - { - namespace: `space-${String(index).padStart(3, '0')}`, - clientId: `client-${index}` - }, - index + 1 - ) - } - - const namespaces = (handler as unknown as { clientsByNamespace: Map }) - .clientsByNamespace - expect(namespaces.size).toBe(MAX_PRESENCE_NAMESPACES) - expect(namespaces.has('space-000')).toBe(false) - expect(namespaces.has(`space-${MAX_PRESENCE_NAMESPACES}`)).toBe(true) - }) }) - -type ConnectedClientForTest = { clientId: string; name: string; lastSeenAt: number } diff --git a/src/relay/workspace-session-handler.ts b/src/relay/workspace-session-handler.ts index f42e9e80de8..c87a8b6076f 100644 --- a/src/relay/workspace-session-handler.ts +++ b/src/relay/workspace-session-handler.ts @@ -1,9 +1,6 @@ -import { existsSync, mkdirSync, renameSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs' import { homedir } from 'node:os' import { dirname, join } from 'node:path' -import { assertJsonTextStructureWithinLimits } from '../shared/json-text-structure-limit' -import { readNodeFileSyncWithinLimit } from '../shared/node-bounded-file-reader' -import { stringifyJsonWithinByteLimit } from '../shared/node-bounded-json-stringify' import type { RelayDispatcher } from './dispatcher' type RemoteWorkspaceSnapshot = { @@ -31,11 +28,6 @@ type PatchResult = const SNAPSHOT_SCHEMA_VERSION = 1 const PRESENCE_TTL_MS = 45_000 -export const MAX_PRESENCE_NAMESPACES = 256 -export const MAX_PRESENCE_CLIENTS_PER_NAMESPACE = 64 -export const MAX_WORKSPACE_SESSION_SNAPSHOT_BYTES = 16 * 1024 * 1024 -export const MAX_WORKSPACE_SESSION_SNAPSHOT_STRUCTURAL_TOKENS = 1_000_000 -export const MAX_WORKSPACE_SESSION_SNAPSHOT_NESTING_DEPTH = 128 function emptySession(): Record { return { @@ -89,15 +81,7 @@ export class WorkspaceSessionHandler { } try { - const content = readNodeFileSyncWithinLimit( - path, - MAX_WORKSPACE_SESSION_SNAPSHOT_BYTES - ).buffer.toString('utf8') - assertJsonTextStructureWithinLimits(content, { - structuralTokens: MAX_WORKSPACE_SESSION_SNAPSHOT_STRUCTURAL_TOKENS, - nestingDepth: MAX_WORKSPACE_SESSION_SNAPSHOT_NESTING_DEPTH - }) - const parsed = JSON.parse(content) as Partial + const parsed = JSON.parse(readFileSync(path, 'utf-8')) as Partial return { namespace, revision: @@ -132,11 +116,7 @@ export class WorkspaceSessionHandler { const path = this.snapshotPath(snapshot.namespace) mkdirSync(dirname(path), { recursive: true, mode: 0o700 }) const tmpPath = `${path}.tmp` - const serialized = stringifyJsonWithinByteLimit( - snapshot, - MAX_WORKSPACE_SESSION_SNAPSHOT_BYTES - ).serialized - writeFileSync(tmpPath, serialized, { mode: 0o600 }) + writeFileSync(tmpPath, JSON.stringify(snapshot, null, 2), { mode: 0o600 }) renameSync(tmpPath, path) } @@ -183,72 +163,25 @@ export class WorkspaceSessionHandler { const namespace = sanitizeNamespace(params.namespace) const clientId = typeof params.clientId === 'string' ? sanitizeClientId(params.clientId) : '' const name = typeof params.clientName === 'string' ? sanitizeClientName(params.clientName) : '' + const clients = this.clientsByNamespace.get(namespace) ?? new Map() + this.clientsByNamespace.set(namespace, clients) + const now = Date.now() - this.sweepPresence(now) - - let clients = this.clientsByNamespace.get(namespace) - if (!clientId) { - return { clients: this.sortedClients(clients) } - } - if (!clients) { - if (this.clientsByNamespace.size >= MAX_PRESENCE_NAMESPACES) { - this.evictOldestNamespace() - } - clients = new Map() - this.clientsByNamespace.set(namespace, clients) - } - if (!clients.has(clientId) && clients.size >= MAX_PRESENCE_CLIENTS_PER_NAMESPACE) { - this.evictOldestClient(clients) - } - clients.set(clientId, { - clientId, - name: name || 'Unknown device', - lastSeenAt: now - }) - - return { clients: this.sortedClients(clients) } - } - - private sweepPresence(now: number): void { - for (const [namespace, clients] of this.clientsByNamespace) { - for (const [id, client] of clients) { - if (now - client.lastSeenAt > PRESENCE_TTL_MS) { - clients.delete(id) - } - } - if (clients.size === 0) { - this.clientsByNamespace.delete(namespace) + for (const [id, client] of clients) { + if (now - client.lastSeenAt > PRESENCE_TTL_MS) { + clients.delete(id) } } - } - - private evictOldestNamespace(): void { - let oldest: { namespace: string; lastSeenAt: number } | undefined - for (const [namespace, clients] of this.clientsByNamespace) { - const lastSeenAt = Math.max(...Array.from(clients.values(), (client) => client.lastSeenAt)) - if ( - !oldest || - lastSeenAt < oldest.lastSeenAt || - (lastSeenAt === oldest.lastSeenAt && namespace < oldest.namespace) - ) { - oldest = { namespace, lastSeenAt } - } + if (clientId) { + clients.set(clientId, { + clientId, + name: name || 'Unknown device', + lastSeenAt: now + }) } - if (oldest) { - this.clientsByNamespace.delete(oldest.namespace) - } - } - private evictOldestClient(clients: Map): void { - const oldest = Array.from(clients.values()).sort( - (a, b) => a.lastSeenAt - b.lastSeenAt || a.clientId.localeCompare(b.clientId) - )[0] - if (oldest) { - clients.delete(oldest.clientId) + return { + clients: Array.from(clients.values()).sort((a, b) => b.lastSeenAt - a.lastSeenAt) } } - - private sortedClients(clients: Map | undefined): ConnectedClient[] { - return Array.from(clients?.values() ?? []).sort((a, b) => b.lastSeenAt - a.lastSeenAt) - } } diff --git a/src/relay/workspace-space-scan.ts b/src/relay/workspace-space-scan.ts index d2ffb8af378..cc823457142 100644 --- a/src/relay/workspace-space-scan.ts +++ b/src/relay/workspace-space-scan.ts @@ -2,25 +2,16 @@ cancellation, symlink, and top-level compaction semantics in one scanner. */ import { execFile } from 'node:child_process' import type { Dirent } from 'node:fs' -import { lstat, opendir } from 'node:fs/promises' +import { lstat, readdir } from 'node:fs/promises' import { basename, join } from 'node:path' import { platform } from 'node:process' import { promisify } from 'node:util' import type { WorkspaceSpaceDirectoryScanResult, - WorkspaceSpaceItem + WorkspaceSpaceItem, + WorkspaceSpaceItemKind } from '../shared/workspace-space-types' import { compactWorkspaceSpaceItems } from '../shared/workspace-space-compaction' -import { mapWithConcurrency } from '../shared/map-with-concurrency' -import { - scanWorkspaceSpaceEntryTree, - type WorkspaceSpaceEntryScan -} from '../shared/workspace-space-entry-traversal' -import { - collectWorkspaceSpaceDirectoryEntries, - createWorkspaceSpaceScanBudget, - WorkspaceSpaceScanCapacityError -} from '../shared/workspace-space-scan-budget' import type { RequestContext } from './dispatcher' const RELAY_FS_CONCURRENCY = 48 @@ -28,7 +19,15 @@ const DU_TIMEOUT_MS = 120_000 const DU_MAX_BUFFER_BYTES = 16 * 1024 * 1024 const execFileAsync = promisify(execFile) -type ScanStats = WorkspaceSpaceEntryScan +type AsyncLimiter = (task: () => Promise) => Promise + +type ScanStats = { + name: string + path: string + kind: WorkspaceSpaceItemKind + sizeBytes: number + skippedEntryCount: number +} class RelayWorkspaceSpaceScanCancelledError extends Error { constructor() { @@ -43,6 +42,57 @@ function throwIfCancelled(context: RequestContext): void { } } +function createAsyncLimiter(maxConcurrent: number, context: RequestContext): AsyncLimiter { + let active = 0 + const queue: { resolve: () => void }[] = [] + + const acquire = async (): Promise => { + throwIfCancelled(context) + if (active < maxConcurrent) { + active += 1 + return + } + await new Promise((resolve, reject) => { + let onAbort: (() => void) | null = null + const waiter = { + resolve: () => { + if (onAbort) { + context.signal?.removeEventListener('abort', onAbort) + } + resolve() + } + } + onAbort = () => { + const index = queue.indexOf(waiter) + if (index !== -1) { + queue.splice(index, 1) + } + reject(new RelayWorkspaceSpaceScanCancelledError()) + } + queue.push(waiter) + if (context.signal) { + context.signal.addEventListener('abort', onAbort, { once: true }) + if (context.signal.aborted) { + onAbort() + } + } + }) + throwIfCancelled(context) + active += 1 + } + + return async (task: () => Promise): Promise => { + await acquire() + try { + return await task() + } finally { + active -= 1 + const next = queue.shift() + next?.resolve() + } + } +} + function normalizeDuPath(pathValue: string): string { const trimmed = pathValue.replace(/\/+$/, '') return trimmed.length > 0 ? trimmed : pathValue @@ -92,10 +142,11 @@ async function scanTopLevelEntryWithDu( entryPath: string, name: string, duSizes: Map, + limit: AsyncLimiter, context: RequestContext ): Promise { throwIfCancelled(context) - const stats = await lstat(entryPath) + const stats = await limit(() => lstat(entryPath)) throwIfCancelled(context) if (stats.isSymbolicLink()) { @@ -130,30 +181,77 @@ async function scanTopLevelEntryWithDu( async function scanEntryAggregate( entryPath: string, name: string, + limit: AsyncLimiter, context: RequestContext ): Promise { - return scanWorkspaceSpaceEntryTree({ - rootPath: entryPath, - rootName: name, - concurrency: RELAY_FS_CONCURRENCY, - signal: context.signal, - entryName: (entry) => entry.name, - joinPath: join, - classifyEntry: async (path) => { - const stats = await lstat(path) - throwIfCancelled(context) - if (stats.isSymbolicLink()) { - return { kind: 'symlink', sizeBytes: stats.size } + throwIfCancelled(context) + const stats = await limit(() => lstat(entryPath)) + throwIfCancelled(context) + + if (stats.isSymbolicLink()) { + return { + name, + path: entryPath, + kind: 'symlink', + sizeBytes: stats.size, + skippedEntryCount: 0 + } + } + + if (!stats.isDirectory()) { + return { + name, + path: entryPath, + kind: 'file', + sizeBytes: stats.size, + skippedEntryCount: 0 + } + } + + let entries: Dirent[] + try { + entries = await limit(() => readdir(entryPath, { withFileTypes: true })) + } catch { + return { + name, + path: entryPath, + kind: 'directory', + sizeBytes: stats.size, + skippedEntryCount: 1 + } + } + + const childStats = await Promise.all( + entries.map(async (entry): Promise => { + try { + return await scanEntryAggregate(join(entryPath, entry.name), entry.name, limit, context) + } catch (error) { + if (error instanceof RelayWorkspaceSpaceScanCancelledError) { + throw error + } + return null } - return stats.isDirectory() - ? { kind: 'directory', sizeBytes: stats.size } - : { kind: 'file', sizeBytes: stats.size } - }, - readDirectory: (path) => opendir(path), - checkCancelled: () => throwIfCancelled(context), - createCancellationError: () => new RelayWorkspaceSpaceScanCancelledError(), - isCancellationError: (error) => error instanceof RelayWorkspaceSpaceScanCancelledError - }) + }) + ) + + let sizeBytes = stats.size + let skippedEntryCount = 0 + for (const child of childStats) { + if (!child) { + skippedEntryCount += 1 + continue + } + sizeBytes += child.sizeBytes + skippedEntryCount += child.skippedEntryCount + } + + return { + name, + path: entryPath, + kind: 'directory', + sizeBytes, + skippedEntryCount + } } async function scanDirectoryWithDu( @@ -168,27 +266,19 @@ async function scanDirectoryWithDu( } const [entries, duSizes] = await Promise.all([ - opendir(rootPath).then((directory) => - collectWorkspaceSpaceDirectoryEntries( - directory, - rootPath, - (entry) => entry.name, - createWorkspaceSpaceScanBudget(), - () => throwIfCancelled(context) - ) - ), + readdir(rootPath, { withFileTypes: true }), readDuDepthOne(rootPath, context) ]) throwIfCancelled(context) - const childStats = await mapWithConcurrency( - entries, - RELAY_FS_CONCURRENCY, - async (entry): Promise => { + const limit = createAsyncLimiter(RELAY_FS_CONCURRENCY, context) + const childStats = await Promise.all( + entries.map(async (entry): Promise => { try { return await scanTopLevelEntryWithDu( join(rootPath, entry.name), entry.name, duSizes, + limit, context ) } catch (error) { @@ -197,7 +287,7 @@ async function scanDirectoryWithDu( } return null } - } + }) ) const children = childStats.filter((child): child is ScanStats => child !== null) const compact = compactWorkspaceSpaceItems(children.map(toWorkspaceSpaceItem)) @@ -215,13 +305,55 @@ async function scanDirectoryWithNode( rootPath: string, context: RequestContext ): Promise { - const root = await scanEntryAggregate(rootPath, basename(rootPath), context) - const children = root.children ?? [] + throwIfCancelled(context) + const limit = createAsyncLimiter(RELAY_FS_CONCURRENCY, context) + const rootStats = await lstat(rootPath) + throwIfCancelled(context) + if (!rootStats.isDirectory() || rootStats.isSymbolicLink()) { + const root = await scanEntryAggregate(rootPath, basename(rootPath), limit, context) + return { + sizeBytes: root.sizeBytes, + skippedEntryCount: root.skippedEntryCount, + topLevelItems: [], + omittedTopLevelItemCount: 0, + omittedTopLevelSizeBytes: 0 + } + } + + let entries: Dirent[] + try { + entries = await readdir(rootPath, { withFileTypes: true }) + } catch { + return { + sizeBytes: rootStats.size, + skippedEntryCount: 1, + topLevelItems: [], + omittedTopLevelItemCount: 0, + omittedTopLevelSizeBytes: 0 + } + } + + const childStats = await Promise.all( + entries.map(async (entry): Promise => { + try { + return await scanEntryAggregate(join(rootPath, entry.name), entry.name, limit, context) + } catch (error) { + if (error instanceof RelayWorkspaceSpaceScanCancelledError) { + throw error + } + return null + } + }) + ) + const children = childStats.filter((child): child is ScanStats => child !== null) const compact = compactWorkspaceSpaceItems(children.map(toWorkspaceSpaceItem)) return { - sizeBytes: root.sizeBytes, - skippedEntryCount: root.skippedEntryCount, + sizeBytes: rootStats.size + children.reduce((sum, child) => sum + child.sizeBytes, 0), + skippedEntryCount: + children.reduce((sum, child) => sum + child.skippedEntryCount, 0) + + childStats.length - + children.length, ...compact } } @@ -234,10 +366,7 @@ export async function scanWorkspaceSpaceDirectory( try { return await scanDirectoryWithDu(rootPath, context) } catch (error) { - if ( - error instanceof RelayWorkspaceSpaceScanCancelledError || - error instanceof WorkspaceSpaceScanCapacityError - ) { + if (error instanceof RelayWorkspaceSpaceScanCancelledError) { throw error } } diff --git a/src/relay/wsl-hook-fs-bridge.test.ts b/src/relay/wsl-hook-fs-bridge.test.ts index 8ca16770b41..f656b4aa3bd 100644 --- a/src/relay/wsl-hook-fs-bridge.test.ts +++ b/src/relay/wsl-hook-fs-bridge.test.ts @@ -2,14 +2,13 @@ // `posix.resolve` against a POSIX guest home. On win32 `posix.resolve` of a // Windows tmpdir yields an invalid path, so the whole suite is skipped there // (the bridge only ever runs inside a Linux WSL guest). -import { mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { mkdtempSync, rmSync, statSync } from 'node:fs' import { posix } from 'node:path' import { tmpdir } from 'node:os' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { registerWslHookFsHandlers } from './wsl-hook-fs-bridge' import type { MethodHandler, RelayDispatcher, RequestContext } from './dispatcher' -import { estimateFilesystemDirectoryEntryBytes } from '../shared/filesystem-directory-listing-limit' import { WSL_HOOK_FS_METHODS, type WslFsResult } from '../shared/wsl-hook-relay-contract' describe.skipIf(process.platform === 'win32')('registerWslHookFsHandlers (WSL fs bridge)', () => { @@ -58,22 +57,6 @@ describe.skipIf(process.platform === 'win32')('registerWslHookFsHandlers (WSL fs expect(read).toEqual({ ok: true, content: 'hello guest' }) }) - it('accepts an exact byte-limit read and rejects an oversized file before materializing it', async () => { - const path = posix.join(home, 'bounded.txt') - writeFileSync(path, '🐋') - - await expect( - call<{ content: string }>(WSL_HOOK_FS_METHODS.readFile, { path, maxBytes: 4 }) - ).resolves.toEqual({ ok: true, content: '🐋' }) - await expect( - call<{ content: string }>(WSL_HOOK_FS_METHODS.readFile, { path, maxBytes: 3 }) - ).resolves.toMatchObject({ - ok: false, - errno: 'EFBIG', - fileCapacity: { observedBytes: 4, maxBytes: 3 } - }) - }) - it('refuses writeFile to an absolute path outside home', async () => { const result = await call(WSL_HOOK_FS_METHODS.writeFile, { path: '/etc/orca-evil.txt', @@ -117,48 +100,6 @@ describe.skipIf(process.platform === 'win32')('registerWslHookFsHandlers (WSL fs expect(root.ok).toBe(true) }) - it('streams directory entries through requested entry and retained-byte limits', async () => { - const directory = posix.join(home, 'bounded-dir') - mkdirSync(directory) - writeFileSync(posix.join(directory, 'alpha'), '') - writeFileSync(posix.join(directory, 'beta'), '') - - const exactEntries = await call<{ entries: { filename: string }[] }>( - WSL_HOOK_FS_METHODS.readdir, - { path: directory, maxEntries: 2, maxRetainedBytes: 4096 } - ) - expect(exactEntries).toMatchObject({ ok: true }) - if (exactEntries.ok) { - expect(exactEntries.entries).toHaveLength(2) - } - await expect( - call(WSL_HOOK_FS_METHODS.readdir, { - path: directory, - maxEntries: 1, - maxRetainedBytes: 4096 - }) - ).resolves.toMatchObject({ ok: false }) - - const single = posix.join(home, 'single-entry-dir') - mkdirSync(single) - writeFileSync(posix.join(single, 'one'), '') - const exactRetainedBytes = estimateFilesystemDirectoryEntryBytes({ name: 'one' }) - await expect( - call(WSL_HOOK_FS_METHODS.readdir, { - path: single, - maxEntries: 1, - maxRetainedBytes: exactRetainedBytes - }) - ).resolves.toMatchObject({ ok: true }) - await expect( - call(WSL_HOOK_FS_METHODS.readdir, { - path: single, - maxEntries: 1, - maxRetainedBytes: exactRetainedBytes - 1 - }) - ).resolves.toMatchObject({ ok: false }) - }) - it('refuses readdir on a non-ancestor dir outside home', async () => { const result = await call(WSL_HOOK_FS_METHODS.readdir, { path: '/etc' }) expect(result).toMatchObject({ ok: false, errno: 'EACCES' }) diff --git a/src/relay/wsl-hook-fs-bridge.ts b/src/relay/wsl-hook-fs-bridge.ts index 482554451f3..3ebb6047b1b 100644 --- a/src/relay/wsl-hook-fs-bridge.ts +++ b/src/relay/wsl-hook-fs-bridge.ts @@ -7,54 +7,17 @@ import { promises as fs } from 'node:fs' import { posix } from 'node:path' import type { RelayDispatcher } from './dispatcher' -import { - createFilesystemDirectoryLimitState, - trackFilesystemDirectoryEntry -} from '../shared/filesystem-directory-listing-limit' -import { - NodeFileReadTooLargeError, - readNodeFileWithinLimit -} from '../shared/node-bounded-file-reader' import { WSL_HOOK_FS_METHODS, - WSL_HOOK_FS_MAX_DIRECTORY_ENTRIES, - WSL_HOOK_FS_MAX_DIRECTORY_RETAINED_BYTES, - WSL_HOOK_FS_MAX_READ_BYTES, type WslFsFailure, type WslFsResult } from '../shared/wsl-hook-relay-contract' function failure(err: unknown): WslFsFailure { - if (err instanceof NodeFileReadTooLargeError) { - return { - ok: false, - errno: 'EFBIG', - message: err.message, - fileCapacity: { observedBytes: err.observedBytes, maxBytes: err.maxBytes } - } - } const e = err as NodeJS.ErrnoException return { ok: false, errno: e?.code ?? 'EUNKNOWN', message: e?.message ?? String(err) } } -function requestedLimit(value: unknown, maximum: number): number { - if (value === undefined) { - return maximum - } - if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) { - throw Object.assign(new Error('invalid capacity limit'), { code: 'EINVAL' }) - } - return Math.min(value, maximum) -} - -function requestedDirectoryLimit(value: unknown, maximum: number): number { - const limit = requestedLimit(value, maximum) - if (limit === 0) { - throw Object.assign(new Error('directory capacity limit must be positive'), { code: 'EINVAL' }) - } - return limit -} - export function registerWslHookFsHandlers( dispatcher: RelayDispatcher, home: string, @@ -83,7 +46,7 @@ export function registerWslHookFsHandlers( return resolved } // Why: the installers' mkdir-p walks top-down from `/`, probing every - // ancestor of home before it ever creates a dir. Allow + // ancestor of home with readdir before it ever creates a dir. Allow // read-only existence probes on those ancestors; everything else stays // home-scoped. const scopedProbe = (rawPath: unknown): string => { @@ -105,10 +68,7 @@ export function registerWslHookFsHandlers( WSL_HOOK_FS_METHODS.readFile, async (params): Promise> => { try { - const maxBytes = requestedLimit(params.maxBytes, WSL_HOOK_FS_MAX_READ_BYTES) - const content = ( - await readNodeFileWithinLimit(scoped(params.path), maxBytes) - ).buffer.toString('utf8') + const content = await fs.readFile(scoped(params.path), 'utf8') return { ok: true, content } } catch (err) { return failure(err) @@ -133,7 +93,7 @@ export function registerWslHookFsHandlers( WSL_HOOK_FS_METHODS.stat, async (params): Promise> => { try { - const stats = await fs.stat(scopedProbe(params.path)) + const stats = await fs.stat(scoped(params.path)) return { ok: true, mode: stats.mode } } catch (err) { return failure(err) @@ -173,39 +133,11 @@ export function registerWslHookFsHandlers( dispatcher.onRequest( WSL_HOOK_FS_METHODS.readdir, async (params): Promise> => { - let directory: Awaited> | undefined try { - const limits = { - maxEntries: requestedDirectoryLimit(params.maxEntries, WSL_HOOK_FS_MAX_DIRECTORY_ENTRIES), - maxRetainedBytes: requestedDirectoryLimit( - params.maxRetainedBytes, - WSL_HOOK_FS_MAX_DIRECTORY_RETAINED_BYTES - ) - } - const limit = createFilesystemDirectoryLimitState(limits) - const entries: { filename: string }[] = [] - const directoryPath = scopedProbe(params.path) - directory = await fs.opendir(directoryPath, { bufferSize: 32 }) - // Ancestors outside home are authorized only as existence probes. - if ( - directoryPath !== homeRoot && - (directoryPath === '/' || homeRoot.startsWith(`${directoryPath}/`)) - ) { - return { ok: true, entries } - } - for (;;) { - const entry = await directory.read() - if (entry === null) { - break - } - trackFilesystemDirectoryEntry(limit, { name: entry.name }) - entries.push({ filename: entry.name }) - } - return { ok: true, entries } + const names = await fs.readdir(scopedProbe(params.path)) + return { ok: true, entries: names.map((filename) => ({ filename })) } } catch (err) { return failure(err) - } finally { - await directory?.close().catch(() => undefined) } } ) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index adc405a4219..1fc07a04057 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -145,7 +145,7 @@ import { timeRendererStartupStep, timeRendererStartupSyncStep } from './startup/startup-diagnostics' -import { reconnectSshTargetsForRendererStartup } from './startup/ssh-startup-reconnect' +import { reconnectSshTargetForRendererStartup } from './startup/ssh-startup-reconnect' import { shouldRenderPetOverlay } from './components/pet/pet-overlay-visibility' import { applyDocumentTheme } from './lib/document-theme' import { getSystemPrefersDark } from './lib/terminal-theme' @@ -984,18 +984,26 @@ function App(): React.JSX.Element { } // Why: treat timed-out eager targets as deferred so their PTYs reattach on tab focus (ssh.connect keeps running in main and likely finishes by then). - const timedOutTargets = await timeRendererStartupStep( + const timedOutTargets: string[] = [] + await timeRendererStartupStep( 'ssh-reconnect', () => - reconnectSshTargetsForRendererStartup({ - targetIds: eagerTargets.map((target) => target.targetId), - timeoutMs: SSH_RECONNECT_TIMEOUT_MS, - connect: (id) => window.api.ssh.connect({ targetId: id }), - publishState: actions.setSshConnectionState, - onFailure: (id, error) => { - console.warn(`SSH auto-reconnect failed for ${id}:`, error) - } - }), + Promise.all( + eagerTargets.map(async ({ targetId }) => { + const result = await reconnectSshTargetForRendererStartup({ + targetId, + timeoutMs: SSH_RECONNECT_TIMEOUT_MS, + connect: (id) => window.api.ssh.connect({ targetId: id }), + publishState: actions.setSshConnectionState, + onFailure: (id, error) => { + console.warn(`SSH auto-reconnect failed for ${id}:`, error) + } + }) + if (result.timedOut) { + timedOutTargets.push(targetId) + } + }) + ), { eagerTargets: eagerTargets.length, deferredTargets: deferredTargets.length diff --git a/src/renderer/src/components/GitHubItemDialog.tsx b/src/renderer/src/components/GitHubItemDialog.tsx index a10c688a1ac..815c8dac7e4 100644 --- a/src/renderer/src/components/GitHubItemDialog.tsx +++ b/src/renderer/src/components/GitHubItemDialog.tsx @@ -6,7 +6,8 @@ import React, { useLayoutEffect, useMemo, useRef, - useState + useState, + useSyncExternalStore } from 'react' import { lazyWithRetry as lazy } from '@/lib/lazy-with-retry' import { useVirtualizer } from '@tanstack/react-virtual' @@ -89,8 +90,6 @@ import { } from '@/components/editor/diff-section-layout' import type { DiffSection } from '@/components/editor/diff-section-types' import { removeDiffSectionMeasuredHeight } from '@/components/editor/diff-section-height-cache' -import { createCombinedDiffLoadScheduler } from '@/components/editor/combined-diff-load-scheduler' -import { retainCombinedDiffSectionText } from '@/components/editor/combined-diff-text-retention' import { MAX_RENDERED_DIFF_COMBINED_CHARACTERS, MAX_RENDERED_DIFF_LINES_PER_SIDE, @@ -161,18 +160,10 @@ import { getCommentBodySubmitState, hasBoundedCommentBodyText } from '@/lib/comment-body-submit-state' -import { emitGitHubWorkItemDetailsCacheMutation } from '@/lib/github-work-item-details-cache-events' import { - WORK_ITEM_DETAILS_FRESH_MS, - WORK_ITEM_DETAILS_UNAVAILABLE_MESSAGE, - getWorkItemDetailsCacheEntry, - getWorkItemDetailsCacheGeneration, - getWorkItemDetailsCacheKey, - invalidateWorkItemDetailsCacheByMatch, - invalidateWorkItemDetailsCacheForKey, - touchWorkItemDetailsCache, - useWorkItemDetailsCacheEntry -} from '@/lib/github-work-item-details-cache' + emitGitHubWorkItemDetailsCacheMutation, + onGitHubWorkItemDetailsCacheMutation +} from '@/lib/github-work-item-details-cache-events' import { lookupGitHubWorkItemDetailsForSource } from '@/lib/github-work-item-source-lookup' import { canUseGitHubRepoContext, @@ -1418,12 +1409,102 @@ function isPRFileViewed(file: GitHubPRFile): boolean { return file.viewerViewedState === 'VIEWED' } +// Why: SWR cache for work-item details so reopening paints instantly instead of paying IPC + `gh` startup; keyed to avoid source/type collisions, LRU-bounded, FRESH_MS refetch on open. See docs/gh-work-item-drawer-cache.md. +const WORK_ITEM_DETAILS_CACHE_MAX = 50 +const WORK_ITEM_DETAILS_FRESH_MS = 30_000 +const WORK_ITEM_DETAILS_UNAVAILABLE_MESSAGE = 'Unable to load details for this GitHub item.' +type WorkItemDetailsCacheEntry = { + details: GitHubWorkItemDetails | null + fetchedAt: number + pending?: Promise + error?: string +} +const workItemDetailsCache = new Map() + +// Why: drawers subscribe via useSyncExternalStore so a cached item paints synchronously; snapshot stability relies on every write replacing entry identity (delete+set). +const workItemDetailsCacheListeners = new Set<() => void>() +function subscribeWorkItemDetailsCache(listener: () => void): () => void { + workItemDetailsCacheListeners.add(listener) + return () => { + workItemDetailsCacheListeners.delete(listener) + } +} +function notifyWorkItemDetailsCache(): void { + for (const listener of workItemDetailsCacheListeners) { + listener() + } +} + +function getWorkItemDetailsCacheKey(args: { + repoPath: string + repoId: string + issueSourcePreference: string | undefined + sourceCacheScope?: string | null + type: 'issue' | 'pr' + number: number +}): string { + // Why: key on every axis that changes which (repo, item) the IPC resolves to; `\0` separator avoids ambiguity with fields containing `:` or `/`. + const keyParts = args.sourceCacheScope + ? [args.repoId, args.sourceCacheScope, args.issueSourcePreference ?? 'auto', args.type] + : [args.repoId, args.issueSourcePreference ?? 'auto', args.type] + return [...keyParts, args.number].join('\0') +} + +function touchWorkItemDetailsCache(key: string, entry: WorkItemDetailsCacheEntry): void { + // Why: re-insert to move to MRU position; Map insertion order keeps the oldest key first for eviction. + workItemDetailsCache.delete(key) + workItemDetailsCache.set(key, entry) + while (workItemDetailsCache.size > WORK_ITEM_DETAILS_CACHE_MAX) { + const oldest = workItemDetailsCache.keys().next().value + if (oldest === undefined) { + break + } + workItemDetailsCache.delete(oldest) + } + notifyWorkItemDetailsCache() +} + +// Why: exposed so mutation handlers can drop a stale entry after a local mutation; cross-window invalidation arrives via the gh:workItemMutated listener below. +export function invalidateWorkItemDetailsCacheForKey(key: string): void { + // Why: bump generation so a fetch launched before this invalidation won't write its stale result back. + workItemDetailsCacheGeneration += 1 + const existed = workItemDetailsCache.delete(key) + if (existed) { + notifyWorkItemDetailsCache() + } +} + +// Why: bumped on every invalidation so an in-flight refetch started before a mutation can detect its result is stale and skip writing it back. +let workItemDetailsCacheGeneration = 0 + +// Why: without the exact key (e.g. a cross-window event carries only repoPath+number+type), drop every entry matching that tuple regardless of source preference. +function invalidateWorkItemDetailsCacheByMatch(args: { + repoPath: string + repoId?: string + type: 'issue' | 'pr' + number: number +}): void { + const suffix = `\0${args.type}\0${args.number}` + const prefix = `${args.repoId ?? args.repoPath}\0` + let removed = false + for (const key of Array.from(workItemDetailsCache.keys())) { + if (key.startsWith(prefix) && key.endsWith(suffix)) { + workItemDetailsCache.delete(key) + removed = true + } + } + if (removed) { + workItemDetailsCacheGeneration += 1 + notifyWorkItemDetailsCache() + } +} + function patchCachedPRFileViewedState( cacheKey: string, path: string, viewerViewedState: GitHubPRFileViewedState ): GitHubPRFileViewedState | undefined { - const prev = getWorkItemDetailsCacheEntry(cacheKey) + const prev = workItemDetailsCache.get(cacheKey) const files = prev?.details?.files if (!prev?.details || !files) { return undefined @@ -1448,7 +1529,7 @@ function patchCachedPRFileViewedState( } function patchCachedPRChecks(cacheKey: string, checks: PRCheckDetail[]): void { - const prev = getWorkItemDetailsCacheEntry(cacheKey) + const prev = workItemDetailsCache.get(cacheKey) if (!prev?.details) { return } @@ -1464,7 +1545,7 @@ function patchCachedPRReviewRequests( cacheKey: string, reviewRequests: GitHubAssignableUser[] ): void { - const prev = getWorkItemDetailsCacheEntry(cacheKey) + const prev = workItemDetailsCache.get(cacheKey) if (!prev?.details) { return } @@ -1480,7 +1561,7 @@ function patchCachedPRReviewRequests( } function patchCachedWorkItemBody(cacheKey: string, body: string): void { - const prev = getWorkItemDetailsCacheEntry(cacheKey) + const prev = workItemDetailsCache.get(cacheKey) if (!prev?.details) { return } @@ -1492,6 +1573,29 @@ function patchCachedWorkItemBody(cacheKey: string, body: string): void { }) } +// Why: install once — all dialogs share the cache; track unsubscribe so Vite HMR doesn't accumulate listeners across dev reloads. +let workItemMutatedUnsub: (() => void) | undefined +let workItemDetailsCacheEventUnsub: (() => void) | undefined +if (typeof window !== 'undefined' && window.api?.gh?.onWorkItemMutated) { + workItemMutatedUnsub = window.api.gh.onWorkItemMutated((payload) => { + invalidateWorkItemDetailsCacheByMatch({ + repoPath: payload.repoPath, + repoId: payload.repoId, + type: payload.type, + number: payload.number + }) + }) + workItemDetailsCacheEventUnsub = onGitHubWorkItemDetailsCacheMutation((payload) => { + invalidateWorkItemDetailsCacheByMatch(payload) + }) +} +if (typeof import.meta !== 'undefined' && import.meta.hot) { + import.meta.hot.dispose(() => { + workItemMutatedUnsub?.() + workItemDetailsCacheEventUnsub?.() + }) +} + // Why: bounded LRU so opening many PRs with many files doesn't grow this module-level map unboundedly until reload. const PR_FILE_CONTENT_CACHE_MAX = 64 // Why: overflow is a sentinel; force reported size past the render budget so downstream checks reliably pick fallback mode. @@ -2173,47 +2277,13 @@ function PRFilesCombinedDiffViewer({ const loadedIndicesRef = useRef>(new Set()) const loadingIndicesRef = useRef>(new Set()) const sectionsRef = useRef([]) - const protectedSectionKeysRef = useRef>(new Set()) const generationRef = useRef(0) const modifiedEditorsRef = useRef>(new Map()) const handleSectionSaveRef = useRef<(index: number) => Promise>(async () => {}) - const loadSectionRef = useRef<(index: number) => Promise>(async () => {}) - const loadSchedulerRef = useRef( - createCombinedDiffLoadScheduler({ - loadSection: (index) => loadSectionRef.current(index), - // Why: keep hosted fetch latency responsive without stacking a PR worth of response bodies. - maxConcurrent: 2 - }) - ) - const applySectionTextRetention = useCallback( - (nextSections: DiffSection[], additionallyProtectedKey?: string): DiffSection[] => { - const protectedSectionKeys = new Set(protectedSectionKeysRef.current) - if (additionallyProtectedKey) { - protectedSectionKeys.add(additionallyProtectedKey) - } - const retained = retainCombinedDiffSectionText({ - sections: nextSections, - loadedIndices: loadedIndicesRef.current, - protectedSectionKeys - }) - for (const index of retained.evictedIndices) { - loadedIndicesRef.current.delete(index) - } - return retained.sections - }, - [] - ) sectionsRef.current = sections - useEffect(() => { - const scheduler = loadSchedulerRef.current - scheduler.reset() - return () => scheduler.dispose() - }, []) - useEffect(() => { generationRef.current += 1 - loadSchedulerRef.current.reset() loadedIndicesRef.current.clear() loadingIndicesRef.current.clear() setSectionHeights({}) @@ -2238,10 +2308,10 @@ function PRFilesCombinedDiffViewer({ ) }, [entries, entrySignature]) - const loadSectionNow = useCallback( - async (index: number) => { + const loadSection = useCallback( + (index: number) => { const section = sectionsRef.current[index] - if (!section || section.collapsed || !protectedSectionKeysRef.current.has(section.key)) { + if (!section || section.collapsed) { return } if (loadedIndicesRef.current.has(index) || loadingIndicesRef.current.has(index)) { @@ -2298,86 +2368,74 @@ function PRFilesCombinedDiffViewer({ return { result: getPRFileDiffResult(contents), resultContents: contents } } - const { result, resultContents, error } = await load().catch((error) => ({ - result: { - kind: 'text', - originalContent: '', - modifiedContent: '', - originalIsBinary: false, - modifiedIsBinary: false - } as GitDiffResult, - resultContents: undefined, - error: error instanceof Error ? error.message : 'Failed to load diff.' - })) - loadingIndicesRef.current.delete(index) - if (generationRef.current !== generation) { - return - } - const largeDiffRenderLimit = - !error && result.kind === 'text' && resultContents - ? getPRFileContentsRenderLimit(resultContents) - : null - const storedContent = getStoredTextDiffContent(result, largeDiffRenderLimit) - const storedResult = getStoredTextDiffResult(result, largeDiffRenderLimit) - loadedIndicesRef.current.add(index) - setSections((prev) => { - const nextSections = prev.map((current, currentIndex) => - currentIndex === index - ? { - ...current, - diffResult: storedResult, - originalContent: storedContent.originalContent, - modifiedContent: storedContent.modifiedContent, - loading: false, - error, - largeDiffRenderLimit - } - : current - ) - return applySectionTextRetention(nextSections, nextSections[index]?.key) - }) + load() + .catch((error) => ({ + result: { + kind: 'text', + originalContent: '', + modifiedContent: '', + originalIsBinary: false, + modifiedIsBinary: false + } as GitDiffResult, + resultContents: undefined, + error: error instanceof Error ? error.message : 'Failed to load diff.' + })) + .then(({ result, resultContents, error }) => { + loadingIndicesRef.current.delete(index) + if (generationRef.current !== generation) { + return + } + const largeDiffRenderLimit = + !error && result.kind === 'text' && resultContents + ? getPRFileContentsRenderLimit(resultContents) + : null + const storedContent = getStoredTextDiffContent(result, largeDiffRenderLimit) + const storedResult = getStoredTextDiffResult(result, largeDiffRenderLimit) + loadedIndicesRef.current.add(index) + setSections((prev) => + prev.map((current, currentIndex) => + currentIndex === index + ? { + ...current, + diffResult: storedResult, + originalContent: storedContent.originalContent, + modifiedContent: storedContent.modifiedContent, + loading: false, + error, + largeDiffRenderLimit + } + : current + ) + ) + }) }, - [ - applySectionTextRetention, - baseSha, - fileByPath, - headSha, - prNumber, - prRepo, - repoId, - repoPath, - sourceContext - ] + [baseSha, fileByPath, headSha, prNumber, prRepo, repoId, repoPath, sourceContext] ) - loadSectionRef.current = loadSectionNow - const loadSection = useCallback((index: number) => { - if (!sectionsRef.current[index]?.collapsed) { - loadSchedulerRef.current.request(index) - } - }, []) - - const retrySection = useCallback((index: number) => { - loadedIndicesRef.current.delete(index) - loadingIndicesRef.current.delete(index) - setSectionHeights((prev) => removeDiffSectionMeasuredHeight(prev, index)) - setSections((prev) => - prev.map((section, sectionIndex) => - sectionIndex === index - ? { - ...section, - diffResult: null, - originalContent: '', - modifiedContent: '', - loading: true, - error: undefined, - largeDiffRenderLimit: null - } - : section + const retrySection = useCallback( + (index: number) => { + loadedIndicesRef.current.delete(index) + loadingIndicesRef.current.delete(index) + setSectionHeights((prev) => removeDiffSectionMeasuredHeight(prev, index)) + setSections((prev) => + prev.map((section, sectionIndex) => + sectionIndex === index + ? { + ...section, + diffResult: null, + originalContent: '', + modifiedContent: '', + loading: true, + error: undefined, + largeDiffRenderLimit: null + } + : section + ) ) - ) - loadSchedulerRef.current.rerequest(index) - }, []) + loadSection(index) + }, + [loadSection] + ) const toggleSection = useCallback( (index: number) => { @@ -2394,11 +2452,17 @@ function PRFilesCombinedDiffViewer({ [loadSection] ) - const setAllSectionsCollapsed = useCallback((collapsed: boolean) => { - // Why: expanded virtual rows load on mount; prefetching every offscreen file - // can queue an entire large PR's response bodies before retention runs. - setSections((prev) => prev.map((section) => ({ ...section, collapsed }))) - }, []) + const setAllSectionsCollapsed = useCallback( + (collapsed: boolean) => { + setSections((prev) => prev.map((section) => ({ ...section, collapsed }))) + if (!collapsed) { + window.requestAnimationFrame(() => { + sectionsRef.current.forEach((_, index) => loadSection(index)) + }) + } + }, + [loadSection] + ) const allSectionsCollapsed = sections.length > 0 && sections.every((section) => section.collapsed) const sectionIndexByKey = useMemo(() => createCombinedDiffSectionIndexMap(sections), [sections]) @@ -2437,18 +2501,6 @@ function PRFilesCombinedDiffViewer({ : `${index}:${entrySignature}` } }) - const protectedSectionKeys = [ - ...virtualizer - .getVirtualItems() - .map((item) => sections[item.index]?.key) - .filter((key): key is string => key !== undefined), - ...(activeTreeSectionKey ? [activeTreeSectionKey] : []) - ] - protectedSectionKeysRef.current = new Set(protectedSectionKeys) - const protectedSectionSignature = protectedSectionKeys.join('\0') - useLayoutEffect(() => { - setSections((current) => applySectionTextRetention(current)) - }, [applySectionTextRetention, protectedSectionSignature, sections]) useLayoutEffect(() => { virtualizer.measure() @@ -6822,8 +6874,14 @@ export default function GitHubItemDialog({ } }, [workItem]) - // Why: rejected oversized results stay local to the mounted drawer without re-entering retention. - const cachedEntry = useWorkItemDetailsCacheEntry(detailsCacheKey) + // Why: subscribe to the module-level cache so reopening a cached item paints synchronously on first render. + const cachedEntry = useSyncExternalStore( + subscribeWorkItemDetailsCache, + useCallback( + () => (detailsCacheKey ? workItemDetailsCache.get(detailsCacheKey) : undefined), + [detailsCacheKey] + ) + ) // Why: bumped on cold open (no cached details yet) so the details memo re-runs and surfaces the optimistic comment before the fetch lands. const [optimisticTick, setOptimisticTick] = useState(0) @@ -6890,7 +6948,7 @@ export default function GitHubItemDialog({ prevItemIdRef.current = workItem.id setTab(normalizeItemDialogTab(workItem, initialTab)) - const cached = getWorkItemDetailsCacheEntry(detailsCacheKey) + const cached = workItemDetailsCache.get(detailsCacheKey) const now = Date.now() const hasFreshData = cached?.details && now - cached.fetchedAt <= WORK_ITEM_DETAILS_FRESH_MS @@ -6910,7 +6968,7 @@ export default function GitHubItemDialog({ }) // Why: snapshot the invalidation generation; if it advances before resolve, a mid-flight mutation invalidated the entry — don't write back. - const launchedAtGeneration = getWorkItemDetailsCacheGeneration() + const launchedAtGeneration = workItemDetailsCacheGeneration if (!cached?.pending) { touchWorkItemDetailsCache(detailsCacheKey, { @@ -6923,8 +6981,8 @@ export default function GitHubItemDialog({ inflight .then((result) => { - const invalidatedMidFlight = getWorkItemDetailsCacheGeneration() !== launchedAtGeneration - const prev = getWorkItemDetailsCacheEntry(detailsCacheKey) + const invalidatedMidFlight = workItemDetailsCacheGeneration !== launchedAtGeneration + const prev = workItemDetailsCache.get(detailsCacheKey) if (invalidatedMidFlight && prev?.pending !== inflight) { // Why: entry was deliberately dropped (or later repopulated) — don't recreate or touch it. return @@ -6952,8 +7010,8 @@ export default function GitHubItemDialog({ }) .catch((err) => { const message = err instanceof Error ? err.message : 'Failed to load details' - const invalidatedMidFlight = getWorkItemDetailsCacheGeneration() !== launchedAtGeneration - const prev = getWorkItemDetailsCacheEntry(detailsCacheKey) + const invalidatedMidFlight = workItemDetailsCacheGeneration !== launchedAtGeneration + const prev = workItemDetailsCache.get(detailsCacheKey) if (invalidatedMidFlight && prev?.pending !== inflight) { return } @@ -7057,7 +7115,7 @@ export default function GitHubItemDialog({ optimisticCommentsRef.current.push(comment) // Why: write through the module cache so concurrent drawers re-render; mark fetchedAt stale (0) so next open refetches server fields. if (detailsCacheKey) { - const prev = getWorkItemDetailsCacheEntry(detailsCacheKey) + const prev = workItemDetailsCache.get(detailsCacheKey) if (prev?.details) { const ids = new Set(prev.details.comments.map((c) => c.id)) if (!ids.has(comment.id)) { diff --git a/src/renderer/src/components/PullRequestPage.tsx b/src/renderer/src/components/PullRequestPage.tsx index 8c8bdf1e4f5..5ae19bf9303 100644 --- a/src/renderer/src/components/PullRequestPage.tsx +++ b/src/renderer/src/components/PullRequestPage.tsx @@ -6,7 +6,8 @@ import React, { useLayoutEffect, useMemo, useRef, - useState + useState, + useSyncExternalStore } from 'react' import { lazyWithRetry as lazy } from '@/lib/lazy-with-retry' import { useVirtualizer } from '@tanstack/react-virtual' @@ -84,12 +85,6 @@ import { } from '@/components/editor/diff-section-layout' import type { DiffSection } from '@/components/editor/diff-section-types' import { removeDiffSectionMeasuredHeight } from '@/components/editor/diff-section-height-cache' -import { createCombinedDiffLoadScheduler } from '@/components/editor/combined-diff-load-scheduler' -import { - COMBINED_DIFF_VIEW_STATE_CACHE_MAX_ENTRIES, - retainCombinedDiffSectionText, - retainCombinedDiffViewStateText -} from '@/components/editor/combined-diff-text-retention' import { MAX_RENDERED_DIFF_COMBINED_CHARACTERS, MAX_RENDERED_DIFF_LINES_PER_SIDE, @@ -168,18 +163,10 @@ import { getCommentBodySubmitState, hasBoundedCommentBodyText } from '@/lib/comment-body-submit-state' -import { emitGitHubWorkItemDetailsCacheMutation } from '@/lib/github-work-item-details-cache-events' import { - WORK_ITEM_DETAILS_FRESH_MS, - WORK_ITEM_DETAILS_UNAVAILABLE_MESSAGE, - getWorkItemDetailsCacheEntry, - getWorkItemDetailsCacheGeneration, - getWorkItemDetailsCacheKey, - invalidateWorkItemDetailsCacheByMatch, - invalidateWorkItemDetailsCacheForKey, - touchWorkItemDetailsCache, - useWorkItemDetailsCacheEntry -} from '@/lib/github-work-item-details-cache' + emitGitHubWorkItemDetailsCacheMutation, + onGitHubWorkItemDetailsCacheMutation +} from '@/lib/github-work-item-details-cache-events' import { lookupGitHubWorkItemDetailsForSource } from '@/lib/github-work-item-source-lookup' import { canUseGitHubRepoContext, @@ -1465,12 +1452,102 @@ function isPRFileViewed(file: GitHubPRFile): boolean { return file.viewerViewedState === 'VIEWED' } +// SWR cache: reopening a drawer paints cached data instantly while a background refetch reconciles. See docs/gh-work-item-drawer-cache.md. +const WORK_ITEM_DETAILS_CACHE_MAX = 50 +const WORK_ITEM_DETAILS_FRESH_MS = 30_000 +const WORK_ITEM_DETAILS_UNAVAILABLE_MESSAGE = 'Unable to load details for this GitHub item.' +type WorkItemDetailsCacheEntry = { + details: GitHubWorkItemDetails | null + fetchedAt: number + pending?: Promise + error?: string +} +const workItemDetailsCache = new Map() + +// Why: useSyncExternalStore snapshot stability relies on every cache write replacing the entry object identity (delete+set). +const workItemDetailsCacheListeners = new Set<() => void>() +function subscribeWorkItemDetailsCache(listener: () => void): () => void { + workItemDetailsCacheListeners.add(listener) + return () => { + workItemDetailsCacheListeners.delete(listener) + } +} +function notifyWorkItemDetailsCache(): void { + for (const listener of workItemDetailsCacheListeners) { + listener() + } +} + +function getWorkItemDetailsCacheKey(args: { + repoPath: string + repoId: string + issueSourcePreference: string | undefined + sourceCacheScope?: string | null + type: 'issue' | 'pr' + number: number +}): string { + // Why: `\0` separator avoids collisions between key fields that may contain `:` or `/`. + const keyParts = args.sourceCacheScope + ? [args.repoId, args.sourceCacheScope, args.issueSourcePreference ?? 'auto', args.type] + : [args.repoId, args.issueSourcePreference ?? 'auto', args.type] + return [...keyParts, args.number].join('\0') +} + +function touchWorkItemDetailsCache(key: string, entry: WorkItemDetailsCacheEntry): void { + // Why: re-insert moves the key to MRU; Map insertion order keeps the oldest key first when evicting. + workItemDetailsCache.delete(key) + workItemDetailsCache.set(key, entry) + while (workItemDetailsCache.size > WORK_ITEM_DETAILS_CACHE_MAX) { + const oldest = workItemDetailsCache.keys().next().value + if (oldest === undefined) { + break + } + workItemDetailsCache.delete(oldest) + } + notifyWorkItemDetailsCache() +} + +// Exposed so mutation handlers can drop a stale entry after a local mutation (cross-window invalidation arrives via the `gh:workItemMutated` listener below). +export function invalidateWorkItemDetailsCacheForKey(key: string): void { + // Why: bump generation so an in-flight fetch launched before this invalidation won't write its stale result back. + workItemDetailsCacheGeneration += 1 + const existed = workItemDetailsCache.delete(key) + if (existed) { + notifyWorkItemDetailsCache() + } +} + +// Why: monotonic counter so an in-flight refetch that started before a mutation detects its result is stale and skips the write-back. +let workItemDetailsCacheGeneration = 0 + +// Why: without the exact key (cross-window events carry only repoPath+number+type), drop every entry matching that tuple regardless of source preference. +function invalidateWorkItemDetailsCacheByMatch(args: { + repoPath: string + repoId?: string + type: 'issue' | 'pr' + number: number +}): void { + const suffix = `\0${args.type}\0${args.number}` + const prefix = `${args.repoId ?? args.repoPath}\0` + let removed = false + for (const key of Array.from(workItemDetailsCache.keys())) { + if (key.startsWith(prefix) && key.endsWith(suffix)) { + workItemDetailsCache.delete(key) + removed = true + } + } + if (removed) { + workItemDetailsCacheGeneration += 1 + notifyWorkItemDetailsCache() + } +} + function patchCachedPRFileViewedState( cacheKey: string, path: string, viewerViewedState: GitHubPRFileViewedState ): GitHubPRFileViewedState | undefined { - const prev = getWorkItemDetailsCacheEntry(cacheKey) + const prev = workItemDetailsCache.get(cacheKey) const files = prev?.details?.files if (!prev?.details || !files) { return undefined @@ -1495,7 +1572,7 @@ function patchCachedPRFileViewedState( } function patchCachedPRChecks(cacheKey: string, checks: PRCheckDetail[]): void { - const prev = getWorkItemDetailsCacheEntry(cacheKey) + const prev = workItemDetailsCache.get(cacheKey) if (!prev?.details) { return } @@ -1511,7 +1588,7 @@ function patchCachedPRReviewRequests( cacheKey: string, reviewRequests: GitHubAssignableUser[] ): void { - const prev = getWorkItemDetailsCacheEntry(cacheKey) + const prev = workItemDetailsCache.get(cacheKey) if (!prev?.details) { return } @@ -1527,7 +1604,7 @@ function patchCachedPRReviewRequests( } function patchCachedWorkItemBody(cacheKey: string, body: string): void { - const prev = getWorkItemDetailsCacheEntry(cacheKey) + const prev = workItemDetailsCache.get(cacheKey) if (!prev?.details) { return } @@ -1539,6 +1616,29 @@ function patchCachedWorkItemBody(cacheKey: string, body: string): void { }) } +// Install once at module load (all dialogs share the cache); track the unsubscribe so Vite HMR doesn't accumulate listeners across reloads. +let workItemMutatedUnsub: (() => void) | undefined +let workItemDetailsCacheEventUnsub: (() => void) | undefined +if (typeof window !== 'undefined' && window.api?.gh?.onWorkItemMutated) { + workItemMutatedUnsub = window.api.gh.onWorkItemMutated((payload) => { + invalidateWorkItemDetailsCacheByMatch({ + repoPath: payload.repoPath, + repoId: payload.repoId, + type: payload.type, + number: payload.number + }) + }) + workItemDetailsCacheEventUnsub = onGitHubWorkItemDetailsCacheMutation((payload) => { + invalidateWorkItemDetailsCacheByMatch(payload) + }) +} +if (typeof import.meta !== 'undefined' && import.meta.hot) { + import.meta.hot.dispose(() => { + workItemMutatedUnsub?.() + workItemDetailsCacheEventUnsub?.() + }) +} + // Why: bounded LRU so a session of opening many PR files can't grow this module map without bound. const PR_FILE_CONTENT_CACHE_MAX = 64 // Why: overflow sentinel — force reported size past the render budget so downstream reliably picks fallback mode. @@ -2239,48 +2339,14 @@ function PRFilesCombinedDiffViewer({ const loadedIndicesRef = useRef>(new Set()) const loadingIndicesRef = useRef>(new Set()) const sectionsRef = useRef([]) - const protectedSectionKeysRef = useRef>(new Set()) const generationRef = useRef(0) const modifiedEditorsRef = useRef>(new Map()) const handleSectionSaveRef = useRef<(index: number) => Promise>(async () => {}) - const loadSectionRef = useRef<(index: number) => Promise>(async () => {}) - const loadSchedulerRef = useRef( - createCombinedDiffLoadScheduler({ - loadSection: (index) => loadSectionRef.current(index), - // Why: keep hosted fetch latency responsive without stacking a PR worth of response bodies. - maxConcurrent: 2 - }) - ) - const applySectionTextRetention = useCallback( - (nextSections: DiffSection[], additionallyProtectedKey?: string): DiffSection[] => { - const protectedSectionKeys = new Set(protectedSectionKeysRef.current) - if (additionallyProtectedKey) { - protectedSectionKeys.add(additionallyProtectedKey) - } - const retained = retainCombinedDiffSectionText({ - sections: nextSections, - loadedIndices: loadedIndicesRef.current, - protectedSectionKeys - }) - for (const index of retained.evictedIndices) { - loadedIndicesRef.current.delete(index) - } - return retained.sections - }, - [] - ) sectionsRef.current = sections - useEffect(() => { - const scheduler = loadSchedulerRef.current - scheduler.reset() - return () => scheduler.dispose() - }, []) - useEffect(() => { // Why: bump generation so stale async diff loads from the previous view can't patch the restored sections. generationRef.current += 1 - loadSchedulerRef.current.reset() const cached = prFilesDiffViewStateCache.get(viewStateKey) if (cached && cached.entrySignature === entrySignature) { const restoredSections = cached.sections @@ -2323,10 +2389,10 @@ function PRFilesCombinedDiffViewer({ ) }, [entries, entrySignature, viewStateKey]) - const loadSectionNow = useCallback( - async (index: number) => { + const loadSection = useCallback( + (index: number) => { const section = sectionsRef.current[index] - if (!section || section.collapsed || !protectedSectionKeysRef.current.has(section.key)) { + if (!section || section.collapsed) { return } if (loadedIndicesRef.current.has(index) || loadingIndicesRef.current.has(index)) { @@ -2383,86 +2449,74 @@ function PRFilesCombinedDiffViewer({ return { result: getPRFileDiffResult(contents), resultContents: contents } } - const { result, resultContents, error } = await load().catch((error) => ({ - result: { - kind: 'text', - originalContent: '', - modifiedContent: '', - originalIsBinary: false, - modifiedIsBinary: false - } as GitDiffResult, - resultContents: undefined, - error: error instanceof Error ? error.message : 'Failed to load diff.' - })) - loadingIndicesRef.current.delete(index) - if (generationRef.current !== generation) { - return - } - const largeDiffRenderLimit = - !error && result.kind === 'text' && resultContents - ? getPRFileContentsRenderLimit(resultContents) - : null - const storedContent = getStoredTextDiffContent(result, largeDiffRenderLimit) - const storedResult = getStoredTextDiffResult(result, largeDiffRenderLimit) - loadedIndicesRef.current.add(index) - setSections((prev) => { - const nextSections = prev.map((current, currentIndex) => - currentIndex === index - ? { - ...current, - diffResult: storedResult, - originalContent: storedContent.originalContent, - modifiedContent: storedContent.modifiedContent, - loading: false, - error, - largeDiffRenderLimit - } - : current - ) - return applySectionTextRetention(nextSections, nextSections[index]?.key) - }) + load() + .catch((error) => ({ + result: { + kind: 'text', + originalContent: '', + modifiedContent: '', + originalIsBinary: false, + modifiedIsBinary: false + } as GitDiffResult, + resultContents: undefined, + error: error instanceof Error ? error.message : 'Failed to load diff.' + })) + .then(({ result, resultContents, error }) => { + loadingIndicesRef.current.delete(index) + if (generationRef.current !== generation) { + return + } + const largeDiffRenderLimit = + !error && result.kind === 'text' && resultContents + ? getPRFileContentsRenderLimit(resultContents) + : null + const storedContent = getStoredTextDiffContent(result, largeDiffRenderLimit) + const storedResult = getStoredTextDiffResult(result, largeDiffRenderLimit) + loadedIndicesRef.current.add(index) + setSections((prev) => + prev.map((current, currentIndex) => + currentIndex === index + ? { + ...current, + diffResult: storedResult, + originalContent: storedContent.originalContent, + modifiedContent: storedContent.modifiedContent, + loading: false, + error, + largeDiffRenderLimit + } + : current + ) + ) + }) }, - [ - applySectionTextRetention, - baseSha, - fileByPath, - headSha, - prNumber, - prRepo, - repoId, - repoPath, - sourceContext - ] + [baseSha, fileByPath, headSha, prNumber, prRepo, repoId, repoPath, sourceContext] ) - loadSectionRef.current = loadSectionNow - const loadSection = useCallback((index: number) => { - if (!sectionsRef.current[index]?.collapsed) { - loadSchedulerRef.current.request(index) - } - }, []) - - const retrySection = useCallback((index: number) => { - loadedIndicesRef.current.delete(index) - loadingIndicesRef.current.delete(index) - setSectionHeights((prev) => removeDiffSectionMeasuredHeight(prev, index)) - setSections((prev) => - prev.map((section, sectionIndex) => - sectionIndex === index - ? { - ...section, - diffResult: null, - originalContent: '', - modifiedContent: '', - loading: true, - error: undefined, - largeDiffRenderLimit: null - } - : section + const retrySection = useCallback( + (index: number) => { + loadedIndicesRef.current.delete(index) + loadingIndicesRef.current.delete(index) + setSectionHeights((prev) => removeDiffSectionMeasuredHeight(prev, index)) + setSections((prev) => + prev.map((section, sectionIndex) => + sectionIndex === index + ? { + ...section, + diffResult: null, + originalContent: '', + modifiedContent: '', + loading: true, + error: undefined, + largeDiffRenderLimit: null + } + : section + ) ) - ) - loadSchedulerRef.current.rerequest(index) - }, []) + loadSection(index) + }, + [loadSection] + ) const toggleSection = useCallback( (index: number) => { @@ -2479,11 +2533,17 @@ function PRFilesCombinedDiffViewer({ [loadSection] ) - const setAllSectionsCollapsed = useCallback((collapsed: boolean) => { - // Why: expanded virtual rows load on mount; prefetching every offscreen file - // can queue an entire large PR's response bodies before retention runs. - setSections((prev) => prev.map((section) => ({ ...section, collapsed }))) - }, []) + const setAllSectionsCollapsed = useCallback( + (collapsed: boolean) => { + setSections((prev) => prev.map((section) => ({ ...section, collapsed }))) + if (!collapsed) { + window.requestAnimationFrame(() => { + sectionsRef.current.forEach((_, index) => loadSection(index)) + }) + } + }, + [loadSection] + ) const allSectionsCollapsed = sections.length > 0 && sections.every((section) => section.collapsed) const sectionIndexByKey = useMemo(() => createCombinedDiffSectionIndexMap(sections), [sections]) @@ -2522,18 +2582,6 @@ function PRFilesCombinedDiffViewer({ : `${index}:${entrySignature}` } }) - const protectedSectionKeys = [ - ...virtualizer - .getVirtualItems() - .map((item) => sections[item.index]?.key) - .filter((key): key is string => key !== undefined), - ...(activeTreeSectionKey ? [activeTreeSectionKey] : []) - ] - protectedSectionKeysRef.current = new Set(protectedSectionKeys) - const protectedSectionSignature = protectedSectionKeys.join('\0') - useLayoutEffect(() => { - setSections((current) => applySectionTextRetention(current)) - }, [applySectionTextRetention, protectedSectionSignature, sections]) useLayoutEffect(() => { virtualizer.measure() @@ -2545,24 +2593,18 @@ function PRFilesCombinedDiffViewer({ } const preservedScrollTop = prFilesDiffScrollTopCache.get(viewStateKey) ?? scrollContainerRef.current?.scrollTop ?? 0 - setWithLRU( - prFilesDiffViewStateCache, - viewStateKey, - { - entrySignature, - sections, - sectionHeights, - loadedIndices: Array.from(loadedIndicesRef.current).filter( - (index) => !sections[index]?.loading - ), - scrollTop: preservedScrollTop, - sideBySide, - fileTreeCollapsed, - activeTreeSectionKey - }, - COMBINED_DIFF_VIEW_STATE_CACHE_MAX_ENTRIES - ) - retainCombinedDiffViewStateText(prFilesDiffViewStateCache) + setWithLRU(prFilesDiffViewStateCache, viewStateKey, { + entrySignature, + sections, + sectionHeights, + loadedIndices: Array.from(loadedIndicesRef.current).filter( + (index) => !sections[index]?.loading + ), + scrollTop: preservedScrollTop, + sideBySide, + fileTreeCollapsed, + activeTreeSectionKey + }) }, [ activeTreeSectionKey, entries.length, @@ -6577,8 +6619,14 @@ export default function PullRequestPage({ } }, [workItem]) - // Why: rejected oversized results stay local to the mounted page without re-entering retention. - const cachedEntry = useWorkItemDetailsCacheEntry(detailsCacheKey) + // Why: subscribe to the module cache so reopening a cached item paints synchronously; writes replace entry identity (delete+set), so Map.get is a stable snapshot. + const cachedEntry = useSyncExternalStore( + subscribeWorkItemDetailsCache, + useCallback( + () => (detailsCacheKey ? workItemDetailsCache.get(detailsCacheKey) : undefined), + [detailsCacheKey] + ) + ) // Why: bumped on cold open (no cached details) so the details memo re-runs and surfaces the optimistic comment via the loading shell; cache-notify handles the warm case. const [optimisticTick, setOptimisticTick] = useState(0) @@ -6632,7 +6680,7 @@ export default function PullRequestPage({ } prevItemIdRef.current = workItem.id - const cached = getWorkItemDetailsCacheEntry(detailsCacheKey) + const cached = workItemDetailsCache.get(detailsCacheKey) const now = Date.now() const hasFreshData = cached?.details && now - cached.fetchedAt <= WORK_ITEM_DETAILS_FRESH_MS @@ -6652,7 +6700,7 @@ export default function PullRequestPage({ }) // Why: snapshot the generation so a mid-flight invalidation (generation advance) blocks writing a stale result back. - const launchedAtGeneration = getWorkItemDetailsCacheGeneration() + const launchedAtGeneration = workItemDetailsCacheGeneration if (!cached?.pending) { touchWorkItemDetailsCache(detailsCacheKey, { @@ -6665,8 +6713,8 @@ export default function PullRequestPage({ inflight .then((result) => { - const invalidatedMidFlight = getWorkItemDetailsCacheGeneration() !== launchedAtGeneration - const prev = getWorkItemDetailsCacheEntry(detailsCacheKey) + const invalidatedMidFlight = workItemDetailsCacheGeneration !== launchedAtGeneration + const prev = workItemDetailsCache.get(detailsCacheKey) if (invalidatedMidFlight && prev?.pending !== inflight) { // Why: entry was deliberately dropped (or later repopulated) — don't recreate or clobber it. return @@ -6694,8 +6742,8 @@ export default function PullRequestPage({ }) .catch((err) => { const message = err instanceof Error ? err.message : 'Failed to load details' - const invalidatedMidFlight = getWorkItemDetailsCacheGeneration() !== launchedAtGeneration - const prev = getWorkItemDetailsCacheEntry(detailsCacheKey) + const invalidatedMidFlight = workItemDetailsCacheGeneration !== launchedAtGeneration + const prev = workItemDetailsCache.get(detailsCacheKey) if (invalidatedMidFlight && prev?.pending !== inflight) { return } @@ -6806,7 +6854,7 @@ export default function PullRequestPage({ optimisticCommentsRef.current.push(comment) // Why: write through the shared cache so subscribers re-render; fetchedAt=0 forces a background refresh next open for server-side fields. if (detailsCacheKey) { - const prev = getWorkItemDetailsCacheEntry(detailsCacheKey) + const prev = workItemDetailsCache.get(detailsCacheKey) if (prev?.details) { const ids = new Set(prev.details.comments.map((c) => c.id)) if (!ids.has(comment.id)) { diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index 0eafd37caae..2430505d6cc 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -124,7 +124,6 @@ import { getLinearStatePillStyle } from '@/components/linear-state-pill-style' import { parseTaskQuery, stripRepoQualifiers, withQualifier } from '../../../shared/task-query' -import { mapSettledWithConcurrency } from '../../../shared/map-with-concurrency' import { githubProjectHost } from '../../../shared/github-project-identity' import { buildLinearTeamUrl, @@ -385,7 +384,6 @@ const TASK_SEARCH_DEBOUNCE_MS = 300 const LINEAR_ITEM_LIMIT = 36 const JIRA_ITEM_LIMIT = 50 const PR_CHECKS_EAGER_PREFETCH_LIMIT = 20 -const GITLAB_REPO_FETCH_CONCURRENCY = 8 const GITHUB_TASK_GRID_CLASS = 'min-w-[790px] grid-cols-[72px_minmax(320px,1fr)_84px_100px_92px_122px]' @@ -4911,12 +4909,7 @@ export default function TaskPage(): React.JSX.Element { return { repoId: repo.id, items: typed.items, error } }) - void mapSettledWithConcurrency(eligibleRepos, GITLAB_REPO_FETCH_CONCURRENCY, async (repo) => { - if (stale) { - return { repoId: repo.id, items: [] as GitLabWorkItem[], error: undefined } - } - return fetchItems(repo) - }) + void Promise.allSettled(eligibleRepos.map(fetchItems)) .then((results) => { if (stale) { return diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index 6724c05ef45..410b3bc8243 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -12,7 +12,6 @@ import { } from '@/constants/terminal' import { useAppStore } from '../store' import { folderWorkspaceKey } from '../../../shared/workspace-scope' -import { mapWithConcurrency } from '../../../shared/map-with-concurrency' import { useAllWorktrees } from '../store/selectors' import { getConnectionId } from '../lib/connection-context' import { basename } from '../lib/path' @@ -142,7 +141,6 @@ const EditorPanel = lazy(() => import('./editor/EditorPanel')) // Why: gate handler runs after a dialog advances so a stray carry-over click can't act on the next dialog; ~200ms absorbs a physical double-click while staying responsive. const CLOSE_DIALOG_DEBOUNCE_MS = 200 -const CHILD_PROCESS_CHECK_CONCURRENCY = 8 const EDITOR_TAB_CONTENT_TYPES = new Set([ 'editor', 'diff', @@ -449,15 +447,15 @@ function Terminal(): React.JSX.Element | null { } ) if (localPtyIds.length > 0) { - void mapWithConcurrency(localPtyIds, CHILD_PROCESS_CHECK_CONCURRENCY, (id) => - window.api.pty.hasChildProcesses(id) - ).then((results) => { - if (results.some(Boolean)) { - setWindowCloseDialogOpen(true) - } else { - confirmNativeWindowClose() + void Promise.all(localPtyIds.map((id) => window.api.pty.hasChildProcesses(id))).then( + (results) => { + if (results.some(Boolean)) { + setWindowCloseDialogOpen(true) + } else { + confirmNativeWindowClose() + } } - }) + ) return } } diff --git a/src/renderer/src/components/automations/AutomationsPage.tsx b/src/renderer/src/components/automations/AutomationsPage.tsx index a9ecba818fe..d55ee012dae 100644 --- a/src/renderer/src/components/automations/AutomationsPage.tsx +++ b/src/renderer/src/components/automations/AutomationsPage.tsx @@ -1926,8 +1926,7 @@ export default function AutomationsPage(): React.JSX.Element { async ({ manager, job, page, pageSize }) => { const fallbackRunsPage = { runs: job.runs.slice(page * pageSize, page * pageSize + pageSize), - totalCount: job.runCount, - totalCountSaturated: job.runCountSaturated === true + totalCount: job.runCount } const listExternalRuns = ( window.api.automations as Partial> @@ -1946,8 +1945,7 @@ export default function AutomationsPage(): React.JSX.Element { }) return { runs: result.runs, - totalCount: result.total, - totalCountSaturated: result.totalSaturated === true + totalCount: result.total } } catch (error) { if (isMissingExternalRunsApiError(error)) { @@ -2668,7 +2666,7 @@ export default function AutomationsPage(): React.JSX.Element { · {entry.manager.provider === 'hermes' - ? `${entry.job.runCount}${entry.job.runCountSaturated ? '+' : ''} ${entry.job.runCount === 1 ? 'run' : 'runs'}` + ? `${entry.job.runCount} ${entry.job.runCount === 1 ? 'run' : 'runs'}` : entry.manager.canManage ? translate( 'auto.components.automations.AutomationsPage.aecdc3681f', diff --git a/src/renderer/src/components/automations/ExternalAutomationManagers.test.tsx b/src/renderer/src/components/automations/ExternalAutomationManagers.test.tsx index 3b53d84d514..8d21b62da6f 100644 --- a/src/renderer/src/components/automations/ExternalAutomationManagers.test.tsx +++ b/src/renderer/src/components/automations/ExternalAutomationManagers.test.tsx @@ -151,13 +151,6 @@ describe('ExternalAutomationManagers toggle', () => { expect(getSwitch().getAttribute('aria-checked')).toBe('false') }) - it('marks a capped run count as a lower bound', () => { - renderManagers([ - makeManager({ jobs: [makeJob({ runCount: 10_000, runCountSaturated: true })] }) - ]) - expect(container.textContent).toContain('10000+ runs found') - }) - it('dispatches pause when toggling an enabled job', () => { const manager = makeManager({ jobs: [makeJob({ enabled: true })] }) const { onAction } = renderManagers([manager]) diff --git a/src/renderer/src/components/automations/ExternalAutomationManagers.tsx b/src/renderer/src/components/automations/ExternalAutomationManagers.tsx index 8e993b69de6..bd6e1b67549 100644 --- a/src/renderer/src/components/automations/ExternalAutomationManagers.tsx +++ b/src/renderer/src/components/automations/ExternalAutomationManagers.tsx @@ -241,8 +241,7 @@ export function ExternalAutomationManagers({
{manager.provider === 'hermes' ? (
- {job.runCount} - {job.runCountSaturated ? '+' : ''}{' '} + {job.runCount}{' '} {job.runCount === 1 ? translate( 'auto.components.automations.ExternalAutomationManagers.8e9165af08', diff --git a/src/renderer/src/components/automations/ExternalAutomationRunTable.tsx b/src/renderer/src/components/automations/ExternalAutomationRunTable.tsx index 38200c6bbd2..f6e5f693371 100644 --- a/src/renderer/src/components/automations/ExternalAutomationRunTable.tsx +++ b/src/renderer/src/components/automations/ExternalAutomationRunTable.tsx @@ -24,7 +24,6 @@ const PAGE_SIZE = 8 export type ExternalAutomationRunPage = { runs: ExternalAutomationRun[] totalCount?: number - totalCountSaturated?: boolean } export type FetchExternalAutomationRuns = (input: { @@ -111,14 +110,7 @@ export function ExternalAutomationRunTable({ // before paint so stale fetched rows/selection never flash for the new job. setTableState(resolvedTableState) } - const { - page, - selectedRunId, - fetchedRuns, - fetchedTotalCount, - fetchedTotalCountSaturated, - fetchError - } = resolvedTableState + const { page, selectedRunId, fetchedRuns, fetchedTotalCount, fetchError } = resolvedTableState useEffect(() => { if (!onFetchRuns) { @@ -151,7 +143,6 @@ export function ExternalAutomationRunTable({ ...resolveExternalAutomationRunTableState(current, jobRef.current), fetchedRuns: null, fetchedTotalCount: null, - fetchedTotalCountSaturated: false, fetchError: error instanceof Error ? error.message : 'Failed to load runs.' })) } @@ -171,11 +162,6 @@ export function ExternalAutomationRunTable({ ? (fetchedRuns ?? fallbackRuns.slice(page * PAGE_SIZE, page * PAGE_SIZE + PAGE_SIZE)) : fallbackRuns.slice(page * PAGE_SIZE, page * PAGE_SIZE + PAGE_SIZE) const totalCount = onFetchRuns ? (fetchedTotalCount ?? job.runCount) : job.runCount - const totalCountSaturated = onFetchRuns - ? fetchedTotalCount === null - ? job.runCountSaturated === true - : fetchedTotalCountSaturated - : job.runCountSaturated === true const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE)) const selectedRun = useMemo( () => @@ -213,8 +199,7 @@ export function ExternalAutomationRunTable({ ) : null}
- {totalCount} - {totalCountSaturated ? '+' : ''}{' '} + {totalCount}{' '} {totalCount === 1 ? translate('auto.components.automations.ExternalAutomationRunTable.872d032d05', 'run') : translate( @@ -305,7 +290,6 @@ export function ExternalAutomationRunTable({ {pageStart}-{pageEnd}{' '} {translate('auto.components.automations.ExternalAutomationRunTable.7475c0ce96', 'of')}{' '} {totalCount} - {totalCountSaturated ? '+' : ''}
diff --git a/src/renderer/src/components/automations/automation-run-output-snapshot.test.ts b/src/renderer/src/components/automations/automation-run-output-snapshot.test.ts index 68f1bf0926b..32f3b4cac75 100644 --- a/src/renderer/src/components/automations/automation-run-output-snapshot.test.ts +++ b/src/renderer/src/components/automations/automation-run-output-snapshot.test.ts @@ -86,18 +86,6 @@ describe('automation run output snapshot buffer', () => { expect(snapshot?.truncated).toBe(true) }) - it('preserves output delivered as 100,000 one-character chunks', () => { - const buffer = createAutomationRunOutputSnapshotBuffer() - - for (let index = 0; index < 100_000; index += 1) { - buffer.append(String.fromCharCode(97 + (index % 26))) - } - - const snapshot = buffer.snapshot() - expect(snapshot?.content).toHaveLength(100_000) - expect(snapshot?.truncated).toBe(false) - }) - it('creates a saved snapshot from agent transcript text', () => { expect(createAutomationRunOutputSnapshotFromText('\nFinal summary.\n')).toEqual({ format: 'plain_text', diff --git a/src/renderer/src/components/automations/automation-run-output-snapshot.ts b/src/renderer/src/components/automations/automation-run-output-snapshot.ts index c29dd2fc329..a7b9151fbfc 100644 --- a/src/renderer/src/components/automations/automation-run-output-snapshot.ts +++ b/src/renderer/src/components/automations/automation-run-output-snapshot.ts @@ -1,6 +1,5 @@ /* eslint-disable no-control-regex -- terminal snapshots normalize ANSI/control output. */ import type { AutomationRunOutputSnapshot } from '../../../../shared/automations-types' -import { appendCompactedStringChunk } from '../../../../shared/string-chunk-compaction' const MAX_OUTPUT_SNAPSHOT_CHARS = 256 * 1024 @@ -64,7 +63,7 @@ export function createAutomationRunOutputSnapshotBuffer(): AutomationRunOutputSn if (!chunk) { return } - appendCompactedStringChunk(chunks, chunk) + chunks.push(chunk) totalChars += chunk.length let overflowChars = totalChars - MAX_OUTPUT_SNAPSHOT_CHARS while (overflowChars > 0 && chunks.length > 0) { diff --git a/src/renderer/src/components/automations/external-automation-run-table-state.test.ts b/src/renderer/src/components/automations/external-automation-run-table-state.test.ts index 6d6bcc05485..10da77d348f 100644 --- a/src/renderer/src/components/automations/external-automation-run-table-state.test.ts +++ b/src/renderer/src/components/automations/external-automation-run-table-state.test.ts @@ -53,7 +53,6 @@ describe('external automation run table state', () => { selectedRunId: 'run-2', fetchedRuns: null, fetchedTotalCount: null, - fetchedTotalCountSaturated: false, fetchError: null }) }) @@ -84,14 +83,12 @@ describe('external automation run table state', () => { expect( resolveExternalAutomationFetchedRuns(current, job('job-1', current.sourceRuns), { runs: [run('run-1'), run('run-2')], - totalCount: 12, - totalCountSaturated: true + totalCount: 12 }) ).toMatchObject({ selectedRunId: 'run-2', fetchedRuns: [run('run-1'), run('run-2')], - fetchedTotalCount: 12, - fetchedTotalCountSaturated: true + fetchedTotalCount: 12 }) }) diff --git a/src/renderer/src/components/automations/external-automation-run-table-state.ts b/src/renderer/src/components/automations/external-automation-run-table-state.ts index 99f13a2c973..ef25c085049 100644 --- a/src/renderer/src/components/automations/external-automation-run-table-state.ts +++ b/src/renderer/src/components/automations/external-automation-run-table-state.ts @@ -8,7 +8,6 @@ type ExternalAutomationRunTableJob = { type ExternalAutomationRunPageResult = { runs: ExternalAutomationRun[] totalCount?: number - totalCountSaturated?: boolean } export type ExternalAutomationRunTableState = { @@ -18,7 +17,6 @@ export type ExternalAutomationRunTableState = { selectedRunId: string | null fetchedRuns: ExternalAutomationRun[] | null fetchedTotalCount: number | null - fetchedTotalCountSaturated: boolean fetchError: string | null } @@ -32,7 +30,6 @@ export function createExternalAutomationRunTableState( selectedRunId: job.runs[0]?.id ?? null, fetchedRuns: null, fetchedTotalCount: null, - fetchedTotalCountSaturated: false, fetchError: null } } @@ -73,7 +70,6 @@ export function resolveExternalAutomationFetchedRuns( ...resolved, fetchedRuns: result.runs, fetchedTotalCount: result.totalCount ?? null, - fetchedTotalCountSaturated: result.totalCountSaturated === true, selectedRunId } } diff --git a/src/renderer/src/components/editor/CombinedDiffViewer.tsx b/src/renderer/src/components/editor/CombinedDiffViewer.tsx index 4920a510dc4..c8999e17e11 100644 --- a/src/renderer/src/components/editor/CombinedDiffViewer.tsx +++ b/src/renderer/src/components/editor/CombinedDiffViewer.tsx @@ -75,12 +75,6 @@ import { getInitialCombinedDiffSectionLoadIndices } from './combined-diff-initia import { removeDiffSectionMeasuredHeight } from './diff-section-height-cache' import { createCombinedDiffLoadScheduler } from './combined-diff-load-scheduler' import { combinedDiffSectionsMatchEntryMetadata } from './combined-diff-section-cache-match' -import { - COMBINED_DIFF_VIEW_STATE_CACHE_MAX_ENTRIES, - getCombinedDiffViewedSectionKeys, - retainCombinedDiffSectionText, - retainCombinedDiffViewStateText -} from './combined-diff-text-retention' import { beginCombinedDiffScrollbarDrag, type CombinedDiffScrollbarDragCleanup @@ -300,28 +294,9 @@ export default function CombinedDiffViewer({ const loadedIndicesRef = useRef>(new Set()) const loadingIndicesRef = useRef>(new Set()) const sectionsRef = useRef([]) - const protectedSectionKeysRef = useRef>(new Set()) const generationRef = useRef(0) const loadSectionRef = useRef<(index: number) => Promise>(async () => {}) const retrySectionRef = useRef<(index: number) => void>(() => {}) - const applySectionTextRetention = useCallback( - (nextSections: DiffSection[], additionallyProtectedKey?: string): DiffSection[] => { - const protectedSectionKeys = new Set(protectedSectionKeysRef.current) - if (additionallyProtectedKey) { - protectedSectionKeys.add(additionallyProtectedKey) - } - const retained = retainCombinedDiffSectionText({ - sections: nextSections, - loadedIndices: loadedIndicesRef.current, - protectedSectionKeys - }) - for (const index of retained.evictedIndices) { - loadedIndicesRef.current.delete(index) - } - return retained.sections - }, - [] - ) const updateCombinedDiffScrollbar = useCallback(() => { const container = scrollContainerRef.current if (!container || container.scrollHeight <= container.clientHeight + 1) { @@ -714,7 +689,7 @@ export default function CombinedDiffViewer({ const storedResult = getStoredTextDiffResult(result, largeDiffRenderLimit) loadedIndicesRef.current.add(index) setSections((prev) => { - const nextSections = prev.map((s, i) => + return prev.map((s, i) => i === index ? { ...s, @@ -727,7 +702,6 @@ export default function CombinedDiffViewer({ } : s ) - return applySectionTextRetention(nextSections, nextSections[index]?.key) }) }, // eslint-disable-next-line react-hooks/exhaustive-deps @@ -745,8 +719,7 @@ export default function CombinedDiffViewer({ isBranchMode, isCommitMode, renderableBranchEntries, - uncommittedEntries, - applySectionTextRetention + uncommittedEntries ] ) loadSectionRef.current = loadSectionNow @@ -1014,20 +987,8 @@ export default function CombinedDiffViewer({ // Why: the tree highlight belongs to one entry set; reset now so it can't flash on another before an Effect would. setActiveTreeSectionState({ entrySignature, key: null }) } - const protectedSectionKeys = [ - ...virtualizer - .getVirtualItems() - .map((item) => sections[item.index]?.key) - .filter((key): key is string => key !== undefined), - ...(activeTreeSectionKey ? [activeTreeSectionKey] : []) - ] - protectedSectionKeysRef.current = new Set(protectedSectionKeys) - const protectedSectionSignature = protectedSectionKeys.join('\0') - useLayoutEffect(() => { - setSections((current) => applySectionTextRetention(current)) - }, [applySectionTextRetention, protectedSectionSignature, sections]) const viewedSectionKeys = React.useMemo( - () => getCombinedDiffViewedSectionKeys(sections), + () => new Set(sections.filter((section) => !section.loading).map((section) => section.key)), [sections] ) const handleTreeNavigate = useCallback( @@ -1242,8 +1203,8 @@ export default function CombinedDiffViewer({ content ) setSectionHeights((prev) => removeDiffSectionMeasuredHeight(prev, index)) - setSections((prev) => { - const nextSections = prev.map((s, i) => { + setSections((prev) => + prev.map((s, i) => { if (i !== index) { return s } @@ -1273,20 +1234,12 @@ export default function CombinedDiffViewer({ largeDiffRenderLimit: nextLargeDiffRenderLimit } }) - return applySectionTextRetention(nextSections, nextSections[index]?.key) - }) + ) } catch (err) { console.error('Save failed:', err) } }, - [ - applySectionTextRetention, - file.filePath, - file.operationProvenance, - file.runtimeEnvironmentId, - file.worktreeId, - sections - ] + [file.filePath, file.operationProvenance, file.runtimeEnvironmentId, file.worktreeId, sections] ) const handleSectionSaveRef = useRef(handleSectionSave) @@ -1298,23 +1251,17 @@ export default function CombinedDiffViewer({ } const preservedScrollTop = combinedDiffScrollTopCache.get(viewStateKey) ?? scrollContainerRef.current?.scrollTop ?? 0 - setWithLRU( - combinedDiffViewStateCache, - viewStateKey, - { - entrySignature, - gitStatusSignature: combinedGitStatusSignature, - sections, - sectionHeights, - loadedIndices: Array.from(loadedIndicesRef.current).filter( - (index) => !sections[index]?.loading - ), - scrollTop: preservedScrollTop, - sideBySide - }, - COMBINED_DIFF_VIEW_STATE_CACHE_MAX_ENTRIES - ) - retainCombinedDiffViewStateText(combinedDiffViewStateCache) + setWithLRU(combinedDiffViewStateCache, viewStateKey, { + entrySignature, + gitStatusSignature: combinedGitStatusSignature, + sections, + sectionHeights, + loadedIndices: Array.from(loadedIndicesRef.current).filter( + (index) => !sections[index]?.loading + ), + scrollTop: preservedScrollTop, + sideBySide + }) }, [ combinedGitStatusSignature, entries.length, diff --git a/src/renderer/src/components/editor/CsvViewer.tsx b/src/renderer/src/components/editor/CsvViewer.tsx index d042fd1e31e..e424a98c938 100644 --- a/src/renderer/src/components/editor/CsvViewer.tsx +++ b/src/renderer/src/components/editor/CsvViewer.tsx @@ -1,6 +1,6 @@ import React, { useMemo, useRef } from 'react' import { useVirtualizer } from '@tanstack/react-virtual' -import { CSV_PARSE_LIMITS, detectCsvDelimiter, parseCsv } from './csv-parse' +import { detectCsvDelimiter, parseCsv } from './csv-parse' import { translate } from '@/i18n/i18n' type CsvViewerProps = { @@ -14,40 +14,6 @@ const MIN_COL_PX = 80 const MAX_COL_PX = 320 const ROW_NUMBER_COL_PX = 48 const CHAR_PX = 7 -const numberFormatter = new Intl.NumberFormat() - -function CsvLimitFallback({ filePath }: { filePath: string }): React.JSX.Element { - return ( -
-
-
- {translate( - 'auto.components.editor.CsvViewer.2b65aa2913', - 'This CSV is too large to display safely.' - )} -
-
{filePath}
-
- {translate( - 'auto.components.editor.CsvViewer.d5e8a2b453', - 'Table view is limited to {{rows}} rows, {{columns}} columns, and {{cells}} cells.', - { - rows: numberFormatter.format(CSV_PARSE_LIMITS.rows), - columns: numberFormatter.format(CSV_PARSE_LIMITS.columnsPerRow), - cells: numberFormatter.format(CSV_PARSE_LIMITS.cells) - } - )} -
-
- {translate( - 'auto.components.editor.CsvViewer.59cfb175a2', - 'Switch to source mode to inspect the raw text.' - )} -
-
-
- ) -} // Why: CsvViewer is the table counterpart to source-mode Monaco for .csv/.tsv // files. Row virtualization via @tanstack/react-virtual keeps large files @@ -123,10 +89,6 @@ export default function CsvViewer({ content, filePath }: CsvViewerProps): React. getItemKey: (index) => index }) - if (parsed.limitExceeded) { - return - } - if (parsed.rows.length === 0) { return (
diff --git a/src/renderer/src/components/editor/EditorContent.tsx b/src/renderer/src/components/editor/EditorContent.tsx index 027c05f0ffd..270622fdaf3 100644 --- a/src/renderer/src/components/editor/EditorContent.tsx +++ b/src/renderer/src/components/editor/EditorContent.tsx @@ -316,7 +316,6 @@ export function EditorContent({ onContentChange={activeFile.readOnly === true ? noopEditorContentChange : handleContentChange} onSave={activeFile.readOnly === true ? noopEditorSave : isMarkdown ? md.mdSave : handleSave} worktreeId={activeFile.worktreeId} - runtimeEnvironmentId={activeFile.runtimeEnvironmentId} markdownAnnotationsEnabled={markdownAnnotationsEnabled && isMarkdown} conflictDecorationsEnabled={activeFile.conflict?.conflictStatus === 'unresolved'} revealLine={ diff --git a/src/renderer/src/components/editor/ImageViewer.test.tsx b/src/renderer/src/components/editor/ImageViewer.test.tsx index 222d26701eb..1639a638de1 100644 --- a/src/renderer/src/components/editor/ImageViewer.test.tsx +++ b/src/renderer/src/components/editor/ImageViewer.test.tsx @@ -144,16 +144,6 @@ async function renderExpandedImageViewer(content: string): Promise { ) } -function pngBase64(width: number): string { - const bytes = Buffer.alloc(24) - Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(bytes) - bytes.writeUInt32BE(13, 8) - bytes.write('IHDR', 12, 'ascii') - bytes.writeUInt32BE(width, 16) - bytes.writeUInt32BE(1, 20) - return bytes.toString('base64') -} - describe('ImageViewer preview source retry', () => { beforeEach(() => { reactHookRuntime.states = [] @@ -162,8 +152,8 @@ describe('ImageViewer preview source retry', () => { }) it('retries an earlier failed source after a later source loads successfully', async () => { - const failedContent = pngBase64(1) - const loadedContent = pngBase64(2) + const failedContent = 'failed-source' + const loadedContent = 'loaded-source' const firstRender = await renderExpandedImageViewer(failedContent) const firstImage = findPreviewImage(firstRender) @@ -186,11 +176,4 @@ describe('ImageViewer preview source retry', () => { const retryImage = findPreviewImage(retryRender) expect(retryImage.props.src).toBe(`data:image/png;base64,${failedContent}`) }) - - it('shows a failure instead of loading an unsafe raster forever', async () => { - const rendered = await renderExpandedImageViewer(pngBase64(32_769)) - - expect(findElementsByType(rendered, 'Image')).toHaveLength(1) - expect(findElementsByType(rendered, 'img')).toHaveLength(0) - }) }) diff --git a/src/renderer/src/components/editor/ImageViewer.tsx b/src/renderer/src/components/editor/ImageViewer.tsx index 07f49750b2d..88b57b092b6 100644 --- a/src/renderer/src/components/editor/ImageViewer.tsx +++ b/src/renderer/src/components/editor/ImageViewer.tsx @@ -63,9 +63,7 @@ export default function ImageViewer({ () => buildImageDataUri(mimeType, cleanedContent), [cleanedContent, mimeType] ) - const imageError = - (previewSrc === null && cleanedContent.length > 0) || - (previewSrc !== null && failedPreviewSrc === previewSrc) + const imageError = previewSrc !== null && failedPreviewSrc === previewSrc const estimatedSize = useMemo(() => { const bytes = Math.floor((cleanedContent.length * 3) / 4) if (bytes < 1024) { diff --git a/src/renderer/src/components/editor/IpynbViewer.tsx b/src/renderer/src/components/editor/IpynbViewer.tsx index 8ed2d5902f3..bbf7ded32ed 100644 --- a/src/renderer/src/components/editor/IpynbViewer.tsx +++ b/src/renderer/src/components/editor/IpynbViewer.tsx @@ -75,7 +75,6 @@ import { type IpynbOutputItem } from './ipynb-parse' import { translate } from '@/i18n/i18n' -import { buildImageDataUri } from '../../../../shared/image-data-uri' type IpynbViewerProps = { content: string @@ -150,7 +149,7 @@ function dataUriForImage(item: IpynbOutputItem): string | null { if (item.mime === 'image/svg+xml') { return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(valueToText(item.value))}` } - return buildImageDataUri(item.mime, value) + return `data:${item.mime};base64,${value}` } function NotebookCellHeader({ diff --git a/src/renderer/src/components/editor/MonacoEditor.tsx b/src/renderer/src/components/editor/MonacoEditor.tsx index 7f37014fe46..c6996ad341e 100644 --- a/src/renderer/src/components/editor/MonacoEditor.tsx +++ b/src/renderer/src/components/editor/MonacoEditor.tsx @@ -81,7 +81,6 @@ type MonacoEditorProps = { revealMatchLength?: number markdownDocuments?: MarkdownDocument[] worktreeId?: string - runtimeEnvironmentId?: string | null markdownAnnotationsEnabled?: boolean conflictDecorationsEnabled?: boolean readOnly?: boolean @@ -107,7 +106,6 @@ export default function MonacoEditor({ revealMatchLength, markdownDocuments, worktreeId, - runtimeEnvironmentId, markdownAnnotationsEnabled = false, conflictDecorationsEnabled = false, readOnly = false, @@ -206,15 +204,11 @@ export default function MonacoEditor({ return } if (language === 'markdown' && markdownDocuments) { - setMarkdownDocCompletionDocuments( - modelKey, - JSON.stringify([runtimeEnvironmentId ?? '', worktreeId ?? modelKey]), - markdownDocuments - ) + setMarkdownDocCompletionDocuments(modelKey, markdownDocuments) } else { clearMarkdownDocCompletionDocuments(modelKey) } - }, [language, markdownDocuments, runtimeEnvironmentId, worktreeId]) + }, [language, markdownDocuments]) const shouldShowMarkdownAnnotations = markdownAnnotationsEnabled && language === 'markdown' && Boolean(worktreeId) diff --git a/src/renderer/src/components/editor/base64-byte-decoder.test.ts b/src/renderer/src/components/editor/base64-byte-decoder.test.ts deleted file mode 100644 index 373f6942997..00000000000 --- a/src/renderer/src/components/editor/base64-byte-decoder.test.ts +++ /dev/null @@ -1,13 +0,0 @@ -// @vitest-environment happy-dom - -import { describe, expect, it } from 'vitest' -import { decodeBase64Bytes } from './base64-byte-decoder' - -describe('decodeBase64Bytes', () => { - it('decodes large and whitespace-delimited input without changing bytes', () => { - const expected = new Uint8Array(40_000).map((_, index) => index % 251) - const encoded = btoa(String.fromCharCode(...expected)).replace(/.{100}/g, '$&\n') - - expect(decodeBase64Bytes(encoded)).toEqual(expected) - }) -}) diff --git a/src/renderer/src/components/editor/base64-byte-decoder.ts b/src/renderer/src/components/editor/base64-byte-decoder.ts deleted file mode 100644 index 364c33e327e..00000000000 --- a/src/renderer/src/components/editor/base64-byte-decoder.ts +++ /dev/null @@ -1,18 +0,0 @@ -const BASE64_DECODE_CHUNK_CHARS = 32 * 1024 - -export function decodeBase64Bytes(base64: string): Uint8Array { - const normalized = /\s/.test(base64) ? base64.replace(/\s/g, '') : base64 - const padding = normalized.endsWith('==') ? 2 : normalized.endsWith('=') ? 1 : 0 - const bytes = new Uint8Array(Math.floor((normalized.length * 3) / 4) - padding) - let targetOffset = 0 - for (let sourceOffset = 0; sourceOffset < normalized.length; ) { - const sourceEnd = Math.min(sourceOffset + BASE64_DECODE_CHUNK_CHARS, normalized.length) - const binary = atob(normalized.slice(sourceOffset, sourceEnd)) - for (let index = 0; index < binary.length; index += 1) { - bytes[targetOffset] = binary.charCodeAt(index) - targetOffset += 1 - } - sourceOffset = sourceEnd - } - return bytes -} diff --git a/src/renderer/src/components/editor/combined-diff-load-scheduler.test.ts b/src/renderer/src/components/editor/combined-diff-load-scheduler.test.ts index 302a76cbdb6..a982d6b692c 100644 --- a/src/renderer/src/components/editor/combined-diff-load-scheduler.test.ts +++ b/src/renderer/src/components/editor/combined-diff-load-scheduler.test.ts @@ -183,70 +183,6 @@ describe('combined diff load scheduler', () => { expect(started).toEqual([1, 3]) }) - it('counts stale in-flight loads against concurrency across repeated resets', async () => { - const blockers = new Map([1, 2, 3, 5].map((index) => [index, deferred()])) - const started: number[] = [] - let active = 0 - let maxActive = 0 - const scheduler = createCombinedDiffLoadScheduler({ - maxConcurrent: 2, - schedule: (callback) => callback(), - loadSection: async (index) => { - started.push(index) - active += 1 - maxActive = Math.max(maxActive, active) - await blockers.get(index)!.promise - active -= 1 - } - }) - - scheduler.request(1) - scheduler.request(2) - scheduler.reset() - scheduler.request(3) - scheduler.request(4) - expect(started).toEqual([1, 2]) - - blockers.get(1)!.resolve() - await flushMicrotasks() - expect(started).toEqual([1, 2, 3]) - - scheduler.reset() - scheduler.request(5) - blockers.get(2)!.resolve() - await flushMicrotasks() - expect(started).toEqual([1, 2, 3, 5]) - expect(maxActive).toBe(2) - - blockers.get(3)!.resolve() - blockers.get(5)!.resolve() - await flushMicrotasks() - }) - - it('keeps a rerequest deduped while the previous load settles', async () => { - const first = deferred() - const second = deferred() - const started: number[] = [] - const scheduler = createCombinedDiffLoadScheduler({ - schedule: (callback) => callback(), - loadSection: async (index) => { - started.push(index) - await (started.length === 1 ? first.promise : second.promise) - } - }) - - scheduler.request(4) - scheduler.rerequest(4) - scheduler.request(4) - first.resolve() - await flushMicrotasks() - scheduler.request(4) - - expect(started).toEqual([4, 4]) - second.resolve() - await flushMicrotasks() - }) - it('revives after dispose when reset for a StrictMode remount', async () => { const started: number[] = [] const scheduler = createCombinedDiffLoadScheduler({ diff --git a/src/renderer/src/components/editor/combined-diff-load-scheduler.ts b/src/renderer/src/components/editor/combined-diff-load-scheduler.ts index 45a344b7c6b..61c480dd716 100644 --- a/src/renderer/src/components/editor/combined-diff-load-scheduler.ts +++ b/src/renderer/src/components/editor/combined-diff-load-scheduler.ts @@ -5,11 +5,6 @@ export type CombinedDiffLoadScheduler = { dispose: () => void } -type PendingLoad = { - index: number - requestId: number -} - export function createCombinedDiffLoadScheduler({ loadSection, schedule = (callback) => queueMicrotask(callback), @@ -21,12 +16,11 @@ export function createCombinedDiffLoadScheduler({ schedule?: (callback: () => void) => void maxConcurrent?: number }): CombinedDiffLoadScheduler { - const pending: PendingLoad[] = [] - const queuedRequestByIndex = new Map() + const pending: number[] = [] + const queued = new Set() let active = 0 let disposed = false let version = 0 - let nextRequestId = 0 const drain = (drainVersion: number): void => { if (disposed || drainVersion !== version) { @@ -34,33 +28,29 @@ export function createCombinedDiffLoadScheduler({ } while (active < maxConcurrent) { - const next = pending.shift() - if (!next) { + const nextIndex = pending.shift() + if (nextIndex === undefined) { return } active += 1 - void loadSection(next.index).finally(() => { - active = Math.max(0, active - 1) - if (queuedRequestByIndex.get(next.index) === next.requestId) { - queuedRequestByIndex.delete(next.index) - } - if (disposed) { + void loadSection(nextIndex).finally(() => { + queued.delete(nextIndex) + if (disposed || drainVersion !== version) { return } - const currentVersion = version - schedule(() => drain(currentVersion)) + active = Math.max(0, active - 1) + schedule(() => drain(drainVersion)) }) } } const enqueue = (index: number): void => { - if (disposed || queuedRequestByIndex.has(index)) { + if (disposed || queued.has(index)) { return } - const requestId = ++nextRequestId - queuedRequestByIndex.set(index, requestId) - pending.push({ index, requestId }) + queued.add(index) + pending.push(index) const requestVersion = version schedule(() => drain(requestVersion)) } @@ -73,9 +63,8 @@ export function createCombinedDiffLoadScheduler({ if (disposed) { return } - const requestId = queuedRequestByIndex.get(index) - queuedRequestByIndex.delete(index) - const pendingIndex = pending.findIndex((load) => load.requestId === requestId) + queued.delete(index) + const pendingIndex = pending.indexOf(index) if (pendingIndex !== -1) { pending.splice(pendingIndex, 1) } @@ -84,14 +73,14 @@ export function createCombinedDiffLoadScheduler({ reset() { disposed = false version += 1 - // Why: don't reset `active`; stale loads still consume memory and I/O until they settle. pending.length = 0 - queuedRequestByIndex.clear() + queued.clear() + active = 0 }, dispose() { disposed = true pending.length = 0 - queuedRequestByIndex.clear() + queued.clear() } } } diff --git a/src/renderer/src/components/editor/combined-diff-text-retention.test.ts b/src/renderer/src/components/editor/combined-diff-text-retention.test.ts deleted file mode 100644 index 99243dc238f..00000000000 --- a/src/renderer/src/components/editor/combined-diff-text-retention.test.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { MAX_RENDERED_DIFF_COMBINED_CHARACTERS } from '../../../../shared/large-diff-render-limit' -import type { DiffSection } from './diff-section-types' -import { - COMBINED_DIFF_VIEW_STATE_CACHE_MAX_ENTRIES, - MAX_RETAINED_COMBINED_DIFF_TEXT_BYTES, - MAX_RETAINED_COMBINED_DIFF_TEXT_CHARACTERS, - getCombinedDiffViewedSectionKeys, - inspectCombinedDiffTextRetention, - retainCombinedDiffSectionText, - retainCombinedDiffViewStateText -} from './combined-diff-text-retention' - -function textSection(key: string, content: string): DiffSection { - return { - key, - path: `${key}.ts`, - status: 'modified', - originalContent: content, - modifiedContent: '', - collapsed: false, - loading: false, - dirty: false, - diffResult: { - kind: 'text', - originalContent: content, - modifiedContent: '', - originalIsBinary: false, - modifiedIsBinary: false - }, - largeDiffRenderLimit: null - } -} - -describe('combined diff text retention', () => { - it('bounds many individually renderable files by evicting the oldest offscreen bodies', () => { - const justUnderPerFileLimit = 'x'.repeat(MAX_RENDERED_DIFF_COMBINED_CHARACTERS - 1) - const sections = Array.from({ length: 10 }, (_, index) => - textSection(`section-${index}`, justUnderPerFileLimit) - ) - const loadedIndices = sections.map((_, index) => index) - - const result = retainCombinedDiffSectionText({ - sections, - loadedIndices, - protectedSectionKeys: new Set(['section-9']) - }) - - const inspection = inspectCombinedDiffTextRetention(result.sections) - expect(inspection.characters).toBeLessThanOrEqual(MAX_RETAINED_COMBINED_DIFF_TEXT_CHARACTERS) - expect(inspection.approximateBytes).toBeLessThanOrEqual(MAX_RETAINED_COMBINED_DIFF_TEXT_BYTES) - expect(result.evictedIndices[0]).toBe(0) - expect(result.sections[9].originalContent).toBe(justUnderPerFileLimit) - expect(result.loadedIndices).not.toContain(result.evictedIndices[0]) - for (const index of result.evictedIndices) { - expect(result.sections[index]).toMatchObject({ - originalContent: '', - modifiedContent: '', - loading: true, - diffResult: null - }) - } - }) - - it('makes an evicted section reloadable and retains its refetched body when revisited', () => { - const firstPass = retainCombinedDiffSectionText({ - sections: [textSection('old', '12345678'), textSection('active', 'abcdefgh')], - loadedIndices: [0, 1], - protectedSectionKeys: new Set(['active']), - maxCharacters: 10 - }) - - expect(firstPass.evictedIndices).toEqual([0]) - expect(firstPass.loadedIndices).toEqual([1]) - expect(getCombinedDiffViewedSectionKeys(firstPass.sections)).toEqual(new Set(['old', 'active'])) - - const refetched = firstPass.sections.map((section, index) => - index === 0 ? textSection('old', 'refetched') : section - ) - const secondPass = retainCombinedDiffSectionText({ - sections: refetched, - loadedIndices: [...firstPass.loadedIndices, 0], - protectedSectionKeys: new Set(['old']), - maxCharacters: 10 - }) - - expect(secondPass.sections[0].originalContent).toBe('refetched') - expect(secondPass.loadedIndices).toContain(0) - expect(secondPass.evictedIndices).toEqual([1]) - }) - - it('keeps the full view-state LRU within one aggregate text budget', () => { - const body = 'x'.repeat(Math.floor(MAX_RETAINED_COMBINED_DIFF_TEXT_CHARACTERS / 2)) - const viewStates = new Map( - Array.from({ length: COMBINED_DIFF_VIEW_STATE_CACHE_MAX_ENTRIES }, (_, index) => [ - `view-${index}`, - { sections: [textSection(`section-${index}`, body)], loadedIndices: [0] } - ]) - ) - - const inspection = retainCombinedDiffViewStateText(viewStates) - - expect(inspection.characters).toBeLessThanOrEqual(MAX_RETAINED_COMBINED_DIFF_TEXT_CHARACTERS) - expect(viewStates.get('view-0')?.sections[0].originalContent).toBe('') - expect(viewStates.get('view-19')?.sections[0].originalContent).toBe(body) - }) - - it('never evicts active, loading, or unsaved section text', () => { - const dirty = { ...textSection('dirty', 'dirty text'), dirty: true } - const loading = { ...textSection('loading', 'loading text'), loading: true } - const result = retainCombinedDiffSectionText({ - sections: [ - textSection('old', 'old text'), - dirty, - loading, - textSection('active', 'active text') - ], - loadedIndices: [0, 1, 2, 3], - protectedSectionKeys: new Set(['active']), - maxCharacters: 0 - }) - - expect(result.evictedIndices).toEqual([0]) - expect(result.sections[1].originalContent).toBe('dirty text') - expect(result.sections[2].originalContent).toBe('loading text') - expect(result.sections[3].originalContent).toBe('active text') - }) -}) diff --git a/src/renderer/src/components/editor/combined-diff-text-retention.ts b/src/renderer/src/components/editor/combined-diff-text-retention.ts deleted file mode 100644 index 137eec85c2c..00000000000 --- a/src/renderer/src/components/editor/combined-diff-text-retention.ts +++ /dev/null @@ -1,205 +0,0 @@ -import type { DiffSection } from './diff-section-types' - -export const COMBINED_DIFF_TEXT_BYTES_PER_CHARACTER = 2 -// Why: bound raw UTF-16 text before Monaco creates its own models; inactive -// snapshots split the same budget and may refetch an oversized file on revisit. -export const MAX_RETAINED_COMBINED_DIFF_TEXT_BYTES = 24 * 1024 * 1024 -export const MAX_RETAINED_COMBINED_DIFF_TEXT_CHARACTERS = Math.floor( - MAX_RETAINED_COMBINED_DIFF_TEXT_BYTES / COMBINED_DIFF_TEXT_BYTES_PER_CHARACTER -) -export const COMBINED_DIFF_VIEW_STATE_CACHE_MAX_ENTRIES = 20 - -export type CombinedDiffTextRetentionInspection = { - characters: number - approximateBytes: number - sectionsWithText: number -} - -export type CombinedDiffTextRetentionResult = CombinedDiffTextRetentionInspection & { - sections: DiffSection[] - loadedIndices: number[] - evictedIndices: number[] -} - -type CombinedDiffTextViewState = { - sections: DiffSection[] - loadedIndices: number[] -} - -function getSectionTextCharacters(section: DiffSection): number { - if (section.diffResult?.kind !== 'text') { - return 0 - } - return section.originalContent.length + section.modifiedContent.length -} - -export function inspectCombinedDiffTextRetention( - sections: readonly DiffSection[] -): CombinedDiffTextRetentionInspection { - let characters = 0 - let sectionsWithText = 0 - for (const section of sections) { - const sectionCharacters = getSectionTextCharacters(section) - if (sectionCharacters === 0) { - continue - } - characters += sectionCharacters - sectionsWithText += 1 - } - return { - characters, - approximateBytes: characters * COMBINED_DIFF_TEXT_BYTES_PER_CHARACTER, - sectionsWithText - } -} - -function isReloadableSection( - section: DiffSection, - protectedSectionKeys: ReadonlySet -): boolean { - return ( - getSectionTextCharacters(section) > 0 && - !section.loading && - !section.dirty && - !section.error && - !protectedSectionKeys.has(section.key) - ) -} - -function releaseSectionText(section: DiffSection): DiffSection { - return { - ...section, - originalContent: '', - modifiedContent: '', - diffResult: null, - loading: true, - contentGeneration: (section.contentGeneration ?? 0) + 1, - textEvictedForRetention: true - } -} - -export function getCombinedDiffViewedSectionKeys( - sections: readonly DiffSection[] -): ReadonlySet { - return new Set( - sections - .filter((section) => !section.loading || section.textEvictedForRetention === true) - .map((section) => section.key) - ) -} - -export function retainCombinedDiffSectionText({ - sections, - loadedIndices, - protectedSectionKeys = new Set(), - maxCharacters = MAX_RETAINED_COMBINED_DIFF_TEXT_CHARACTERS -}: { - sections: DiffSection[] - loadedIndices: Iterable - protectedSectionKeys?: ReadonlySet - maxCharacters?: number -}): CombinedDiffTextRetentionResult { - const inspection = inspectCombinedDiffTextRetention(sections) - const loadedIndexOrder = Array.from(loadedIndices) - if (inspection.characters <= maxCharacters) { - return { - ...inspection, - sections, - loadedIndices: loadedIndexOrder, - evictedIndices: [] - } - } - - // Why: loaded indices preserve load order; a refetched section rejoins at the tail. - const orderedCandidates = loadedIndexOrder.filter((index) => { - const section = sections[index] - return section !== undefined && isReloadableSection(section, protectedSectionKeys) - }) - const orderedCandidateSet = new Set(orderedCandidates) - for (let index = 0; index < sections.length; index += 1) { - const section = sections[index] - if ( - !orderedCandidateSet.has(index) && - section !== undefined && - isReloadableSection(section, protectedSectionKeys) - ) { - orderedCandidates.push(index) - } - } - - let retainedCharacters = inspection.characters - let retainedSections = sections - const evictedIndices: number[] = [] - for (const index of orderedCandidates) { - if (retainedCharacters <= maxCharacters) { - break - } - const section = retainedSections[index] - const sectionCharacters = getSectionTextCharacters(section) - if (sectionCharacters === 0) { - continue - } - if (retainedSections === sections) { - retainedSections = [...sections] - } - retainedSections[index] = releaseSectionText(section) - retainedCharacters -= sectionCharacters - evictedIndices.push(index) - } - - const evictedIndexSet = new Set(evictedIndices) - return { - characters: retainedCharacters, - approximateBytes: retainedCharacters * COMBINED_DIFF_TEXT_BYTES_PER_CHARACTER, - sectionsWithText: inspection.sectionsWithText - evictedIndices.length, - sections: retainedSections, - loadedIndices: loadedIndexOrder.filter((index) => !evictedIndexSet.has(index)), - evictedIndices - } -} - -export function inspectCombinedDiffViewStateTextRetention( - viewStates: Iterable -): CombinedDiffTextRetentionInspection { - let characters = 0 - let sectionsWithText = 0 - for (const viewState of viewStates) { - const inspection = inspectCombinedDiffTextRetention(viewState.sections) - characters += inspection.characters - sectionsWithText += inspection.sectionsWithText - } - return { - characters, - approximateBytes: characters * COMBINED_DIFF_TEXT_BYTES_PER_CHARACTER, - sectionsWithText - } -} - -export function retainCombinedDiffViewStateText( - viewStates: Map, - maxCharacters = MAX_RETAINED_COMBINED_DIFF_TEXT_CHARACTERS -): CombinedDiffTextRetentionInspection { - let retainedCharacters = inspectCombinedDiffViewStateTextRetention(viewStates.values()).characters - // Why: view-state Maps are LRUs, so iteration releases the oldest inactive view first. - for (const [key, viewState] of viewStates) { - if (retainedCharacters <= maxCharacters) { - break - } - const viewStateCharacters = inspectCombinedDiffTextRetention(viewState.sections).characters - const retained = retainCombinedDiffSectionText({ - sections: viewState.sections, - loadedIndices: viewState.loadedIndices, - maxCharacters: Math.max(0, viewStateCharacters - (retainedCharacters - maxCharacters)) - }) - if (retained.evictedIndices.length === 0) { - continue - } - viewStates.set(key, { - ...viewState, - sections: retained.sections, - loadedIndices: retained.loadedIndices - }) - retainedCharacters -= viewStateCharacters - retained.characters - } - return inspectCombinedDiffViewStateTextRetention(viewStates.values()) -} diff --git a/src/renderer/src/components/editor/csv-parse.test.ts b/src/renderer/src/components/editor/csv-parse.test.ts index 34581559c61..267be8b1028 100644 --- a/src/renderer/src/components/editor/csv-parse.test.ts +++ b/src/renderer/src/components/editor/csv-parse.test.ts @@ -1,15 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { - CSV_DELIMITER_SNIFF_SCAN_CODE_UNITS, - CSV_PARSE_LIMITS, - type CsvParseLimits, - detectCsvDelimiter, - parseCsv -} from './csv-parse' - -function parseLimits(overrides: Partial): CsvParseLimits { - return { ...CSV_PARSE_LIMITS, ...overrides } -} +import { CSV_DELIMITER_SNIFF_SCAN_CODE_UNITS, detectCsvDelimiter, parseCsv } from './csv-parse' afterEach(() => { vi.restoreAllMocks() @@ -78,82 +68,8 @@ describe('parseCsv', () => { const { rows } = parseCsv('""') expect(rows).toEqual([['']]) }) - - it.each([ - { - name: 'source code units', - source: 'a,b\n', - limits: parseLimits({ sourceCodeUnits: 3 }), - reason: 'source-code-units' - }, - { - name: 'rows', - source: 'a\nb\nc', - limits: parseLimits({ rows: 2 }), - reason: 'rows' - }, - { - name: 'columns per row', - source: 'a,b,c', - limits: parseLimits({ columnsPerRow: 2 }), - reason: 'columns-per-row' - }, - { - name: 'total cells', - source: 'a,b\nc,d,e', - limits: parseLimits({ cells: 4 }), - reason: 'cells' - }, - { - name: 'retained cell text', - source: 'a,bcd', - limits: parseLimits({ retainedCellCodeUnits: 3 }), - reason: 'retained-cell-code-units' - } - ])('fails closed when $name exceeds its memory limit', ({ source, limits, reason }) => { - expect(parseCsv(source, ',', limits)).toEqual({ - rows: [], - maxColumns: 0, - limitExceeded: { reason, limit: limits[reasonToLimitKey(reason)] } - }) - }) - - it('preserves exact output at every parser boundary', () => { - const source = 'a,b\nc,d' - const limits = parseLimits({ - sourceCodeUnits: source.length, - rows: 2, - columnsPerRow: 2, - cells: 4, - retainedCellCodeUnits: 4 - }) - - expect(parseCsv(source, ',', limits)).toEqual({ - rows: [ - ['a', 'b'], - ['c', 'd'] - ], - maxColumns: 2, - limitExceeded: null - }) - }) }) -function reasonToLimitKey(reason: string): keyof CsvParseLimits { - switch (reason) { - case 'source-code-units': - return 'sourceCodeUnits' - case 'rows': - return 'rows' - case 'columns-per-row': - return 'columnsPerRow' - case 'cells': - return 'cells' - default: - return 'retainedCellCodeUnits' - } -} - describe('detectCsvDelimiter', () => { it('uses tab for .tsv files regardless of content', () => { expect(detectCsvDelimiter('data.tsv', 'a,b,c')).toBe('\t') diff --git a/src/renderer/src/components/editor/csv-parse.ts b/src/renderer/src/components/editor/csv-parse.ts index 095b76779f7..009e9646320 100644 --- a/src/renderer/src/components/editor/csv-parse.ts +++ b/src/renderer/src/components/editor/csv-parse.ts @@ -1,37 +1,9 @@ export type CsvParseResult = { rows: string[][] maxColumns: number - limitExceeded: CsvParseLimitExceeded | null } export const CSV_DELIMITER_SNIFF_SCAN_CODE_UNITS = 64 * 1024 -export const CSV_PARSE_LIMITS = { - sourceCodeUnits: 50 * 1024 * 1024, - rows: 250_000, - columnsPerRow: 1024, - cells: 1_000_000, - retainedCellCodeUnits: 50 * 1024 * 1024 -} as const - -export type CsvParseLimitReason = - | 'source-code-units' - | 'rows' - | 'columns-per-row' - | 'cells' - | 'retained-cell-code-units' - -export type CsvParseLimitExceeded = { - reason: CsvParseLimitReason - limit: number -} - -export type CsvParseLimits = Readonly<{ - sourceCodeUnits: number - rows: number - columnsPerRow: number - cells: number - retainedCellCodeUnits: number -}> const LINE_FEED_CODE_UNIT = 10 const CARRIAGE_RETURN_CODE_UNIT = 13 @@ -40,21 +12,7 @@ const CARRIAGE_RETURN_CODE_UNIT = 13 // A hand-rolled parser avoids pulling a new dependency (papaparse) for what is // a small, well-specified grammar. Inline state machine keeps the hot path // allocation-light for large files. -export function parseCsv( - source: string, - delimiter: string = ',', - limits: CsvParseLimits = CSV_PARSE_LIMITS -): CsvParseResult { - const limited = (reason: CsvParseLimitReason, limit: number): CsvParseResult => ({ - rows: [], - maxColumns: 0, - limitExceeded: { reason, limit } - }) - - if (source.length > limits.sourceCodeUnits) { - return limited('source-code-units', limits.sourceCodeUnits) - } - +export function parseCsv(source: string, delimiter: string = ','): CsvParseResult { // Why: strip a leading UTF-8 BOM (U+FEFF). Excel and other spreadsheet tools // prepend a BOM to exported CSVs; without this, the BOM contaminates the // first header cell and breaks column-name lookups for downstream consumers. @@ -67,8 +25,6 @@ export function parseCsv( let field = '' let inQuotes = false let maxColumns = 0 - let cellCount = 0 - let retainedCellCodeUnits = 0 // Why: track whether the current record has produced any content — including // a quoted-but-empty field like `""`. Without this flag, the EOF flush // condition `field.length > 0 || row.length > 0` would drop a record whose @@ -76,41 +32,18 @@ export function parseCsv( // delimiter/newline ever pushed it onto the row. let recordHasContent = false - const pushField = (): CsvParseResult | null => { - if (row.length >= limits.columnsPerRow) { - return limited('columns-per-row', limits.columnsPerRow) - } - if (cellCount >= limits.cells) { - return limited('cells', limits.cells) - } + const pushField = (): void => { row.push(field) - cellCount += 1 field = '' - return null } - const pushRow = (): CsvParseResult | null => { - if (rows.length >= limits.rows) { - return limited('rows', limits.rows) - } - const fieldLimit = pushField() - if (fieldLimit) { - return fieldLimit - } + const pushRow = (): void => { + pushField() if (row.length > maxColumns) { maxColumns = row.length } rows.push(row) row = [] recordHasContent = false - return null - } - const appendToField = (value: string): CsvParseResult | null => { - if (retainedCellCodeUnits + value.length > limits.retainedCellCodeUnits) { - return limited('retained-cell-code-units', limits.retainedCellCodeUnits) - } - field += value - retainedCellCodeUnits += value.length - return null } for (let i = 0; i < source.length; i += 1) { @@ -119,19 +52,13 @@ export function parseCsv( if (inQuotes) { if (ch === '"') { if (source[i + 1] === '"') { - const retainedLimit = appendToField('"') - if (retainedLimit) { - return retainedLimit - } + field += '"' i += 1 } else { inQuotes = false } } else { - const retainedLimit = appendToField(ch) - if (retainedLimit) { - return retainedLimit - } + field += ch } continue } @@ -144,10 +71,7 @@ export function parseCsv( continue } if (ch === delimiter) { - const fieldLimit = pushField() - if (fieldLimit) { - return fieldLimit - } + pushField() recordHasContent = true continue } @@ -155,23 +79,14 @@ export function parseCsv( if (source[i + 1] === '\n') { i += 1 } - const rowLimit = pushRow() - if (rowLimit) { - return rowLimit - } + pushRow() continue } if (ch === '\n') { - const rowLimit = pushRow() - if (rowLimit) { - return rowLimit - } + pushRow() continue } - const retainedLimit = appendToField(ch) - if (retainedLimit) { - return retainedLimit - } + field += ch recordHasContent = true } @@ -182,13 +97,10 @@ export function parseCsv( // empty and nothing has been pushed to `row`, but the record is real and // must not be dropped. if (field.length > 0 || row.length > 0 || recordHasContent) { - const rowLimit = pushRow() - if (rowLimit) { - return rowLimit - } + pushRow() } - return { rows, maxColumns, limitExceeded: null } + return { rows, maxColumns } } export function detectCsvDelimiter(filePath: string, content: string): string { diff --git a/src/renderer/src/components/editor/diff-section-types.ts b/src/renderer/src/components/editor/diff-section-types.ts index 0d23aafe0a5..0c5d659f4be 100644 --- a/src/renderer/src/components/editor/diff-section-types.ts +++ b/src/renderer/src/components/editor/diff-section-types.ts @@ -20,6 +20,4 @@ export type DiffSection = { // Why: combined sections keep Monaco models by path; bump on reload so // refetched git content does not replay through keepCurrent* model reuse. contentGeneration?: number - // Why: releasing a reviewed body must not make its file-tree entry appear unviewed. - textEvictedForRetention?: boolean } diff --git a/src/renderer/src/components/editor/editor-autosave-controller.test.ts b/src/renderer/src/components/editor/editor-autosave-controller.test.ts index 131270c7e1c..734170badf1 100644 --- a/src/renderer/src/components/editor/editor-autosave-controller.test.ts +++ b/src/renderer/src/components/editor/editor-autosave-controller.test.ts @@ -7,10 +7,7 @@ import { ORCA_EDITOR_SAVE_DIRTY_FILES_EVENT } from '../../../../shared/editor-save-events' import { requestEditorFileSave, requestEditorSaveQuiesce } from './editor-autosave' -import { - attachEditorAutosaveController, - EDITOR_BULK_SAVE_CONCURRENCY -} from './editor-autosave-controller' +import { attachEditorAutosaveController } from './editor-autosave-controller' import { registerPendingEditorFlush } from './editor-pending-flush' import { __clearSelfWriteRegistryForTests, hasRecentSelfWrite } from './editor-self-write-registry' import { @@ -201,70 +198,6 @@ describe('attachEditorAutosaveController', () => { } }) - it.each([ - ['at the limit', EDITOR_BULK_SAVE_CONCURRENCY], - ['above the limit', EDITOR_BULK_SAVE_CONCURRENCY + 1] - ])('bounds dirty-file writes %s', async (_, count) => { - let active = 0 - let peak = 0 - const releases: (() => void)[] = [] - const writeFile = vi.fn(async () => { - active++ - peak = Math.max(peak, active) - await new Promise((resolve) => releases.push(resolve)) - active-- - }) - const eventTarget = new EventTarget() - vi.stubGlobal('window', { - addEventListener: eventTarget.addEventListener.bind(eventTarget), - removeEventListener: eventTarget.removeEventListener.bind(eventTarget), - dispatchEvent: eventTarget.dispatchEvent.bind(eventTarget), - setTimeout: globalThis.setTimeout.bind(globalThis), - clearTimeout: globalThis.clearTimeout.bind(globalThis), - api: { fs: { writeFile } } - } satisfies WindowStub) - const store = createEditorStore() - store.setState({ - settings: { editorAutoSave: false, editorAutoSaveDelayMs: 1000 } as never - }) - for (let index = 0; index < count; index++) { - const filePath = `/repo/file-${index}.ts` - store.getState().openFile({ - filePath, - relativePath: `file-${index}.ts`, - worktreeId: 'wt-1', - language: 'typescript', - mode: 'edit' - }) - store.getState().setEditorDraft(filePath, `draft ${index}`) - store.getState().markFileDirty(filePath, true) - } - - const cleanup = attachEditorAutosaveController(store) - try { - const save = requestDirtyFileSave() - for ( - let turn = 0; - turn < 10 && writeFile.mock.calls.length < Math.min(count, EDITOR_BULK_SAVE_CONCURRENCY); - turn++ - ) { - await Promise.resolve() - } - expect(writeFile).toHaveBeenCalledTimes(Math.min(count, EDITOR_BULK_SAVE_CONCURRENCY)) - if (count > EDITOR_BULK_SAVE_CONCURRENCY) { - releases.shift()?.() - await vi.waitFor(() => expect(writeFile).toHaveBeenCalledTimes(count)) - } - releases.splice(0).forEach((release) => release()) - await save - - expect(peak).toBe(Math.min(count, EDITOR_BULK_SAVE_CONCURRENCY)) - expect(store.getState().openFiles.filter((file) => file.isDirty)).toEqual([]) - } finally { - cleanup() - } - }) - it('saves folder workspace files through the path-specific SSH connection', async () => { const writeFile = vi.fn().mockResolvedValue(undefined) const eventTarget = new EventTarget() diff --git a/src/renderer/src/components/editor/editor-autosave-controller.ts b/src/renderer/src/components/editor/editor-autosave-controller.ts index ccf3e6261f5..3326d93492d 100644 --- a/src/renderer/src/components/editor/editor-autosave-controller.ts +++ b/src/renderer/src/components/editor/editor-autosave-controller.ts @@ -43,22 +43,8 @@ import { type EditorPrepareHotExitDetail, type EditorSaveDirtyFilesDetail } from '../../../../shared/editor-save-events' -import { - mapSettledWithConcurrency, - mapWithConcurrency -} from '../../../../shared/map-with-concurrency' type AppStoreApi = Pick, 'getState' | 'subscribe'> -export const EDITOR_BULK_SAVE_CONCURRENCY = 4 - -function throwFirstRejected(results: readonly PromiseSettledResult[]): void { - const failure = results.find( - (result): result is PromiseRejectedResult => result.status === 'rejected' - ) - if (failure) { - throw failure.reason - } -} export function attachEditorAutosaveController(store: AppStoreApi): () => void { const autoSaveTimers = new Map() @@ -267,18 +253,15 @@ export function attachEditorAutosaveController(store: AppStoreApi): () => void { return } - const saves = await mapSettledWithConcurrency( - dirtyFiles, - EDITOR_BULK_SAVE_CONCURRENCY, - async (file) => { + await Promise.all( + dirtyFiles.map(async (file) => { const content = getLatestWritableContent(file) if (content === null) { throw new Error(`Missing editor buffer for ${file.relativePath}`) } await queueSave(file, content) - } + }) ) - throwFirstRejected(saves) detail.resolve() } catch (error) { detail.reject(String((error as Error)?.message ?? error)) @@ -295,9 +278,7 @@ export function attachEditorAutosaveController(store: AppStoreApi): () => void { detail.claim() const initiallyDirtyFiles = store.getState().openFiles.filter((file) => file.isDirty) - await mapWithConcurrency(initiallyDirtyFiles, EDITOR_BULK_SAVE_CONCURRENCY, (file) => - quiesceFileSave(file.id) - ) + await Promise.all(initiallyDirtyFiles.map((file) => quiesceFileSave(file.id))) const state = store.getState() const dirtyFiles = state.openFiles.filter((file) => file.isDirty) @@ -388,9 +369,7 @@ export function attachEditorAutosaveController(store: AppStoreApi): () => void { ? store.getState().openFiles.filter((file) => file.id === detail.fileId) : getOpenFilesForExternalFileChange(store.getState().openFiles, detail) - await mapWithConcurrency(matchingFiles, EDITOR_BULK_SAVE_CONCURRENCY, (file) => - quiesceFileSave(file.id) - ) + await Promise.all(matchingFiles.map((file) => quiesceFileSave(file.id))) detail.resolve() } diff --git a/src/renderer/src/components/editor/editor-pending-flush.test.ts b/src/renderer/src/components/editor/editor-pending-flush.test.ts deleted file mode 100644 index b50b1996501..00000000000 --- a/src/renderer/src/components/editor/editor-pending-flush.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { flushPendingEditorChange, registerPendingEditorFlush } from './editor-pending-flush' - -describe('editor pending flush registry', () => { - it('releases a registered callback through its lifecycle disposer', () => { - const flush = vi.fn() - const unregister = registerPendingEditorFlush('/repo/file.md', flush) - - flushPendingEditorChange('/repo/file.md') - unregister() - flushPendingEditorChange('/repo/file.md') - - expect(flush).toHaveBeenCalledTimes(1) - }) - - it('does not let an older disposer remove a replacement callback', () => { - const olderFlush = vi.fn() - const newerFlush = vi.fn() - const unregisterOlder = registerPendingEditorFlush('/repo/file.md', olderFlush) - const unregisterNewer = registerPendingEditorFlush('/repo/file.md', newerFlush) - - unregisterOlder() - flushPendingEditorChange('/repo/file.md') - unregisterNewer() - flushPendingEditorChange('/repo/file.md') - - expect(olderFlush).not.toHaveBeenCalled() - expect(newerFlush).toHaveBeenCalledTimes(1) - }) -}) diff --git a/src/renderer/src/components/editor/ipynb-json-admission.test.ts b/src/renderer/src/components/editor/ipynb-json-admission.test.ts deleted file mode 100644 index 354cf2002f2..00000000000 --- a/src/renderer/src/components/editor/ipynb-json-admission.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - assertIpynbJsonWithinMemoryLimits, - assertIpynbShapeWithinMemoryLimits, - IPYNB_MEMORY_LIMITS, - type IpynbMemoryLimits -} from './ipynb-json-admission' - -function limits(overrides: Partial): IpynbMemoryLimits { - return { ...IPYNB_MEMORY_LIMITS, ...overrides } -} - -describe('notebook memory admission', () => { - it('preserves JSON at exact source, structure, and nesting boundaries', () => { - const content = '{"cells":[]}' - expect(() => - assertIpynbJsonWithinMemoryLimits( - content, - limits({ sourceCodeUnits: content.length, structuralTokens: 5, nestingDepth: 2 }) - ) - ).not.toThrow() - }) - - it.each([ - ['source size', '{"cells":[]}', { sourceCodeUnits: 11 }, 'source size'], - ['JSON structure', '{"cells":[]}', { structuralTokens: 4 }, 'JSON structure'], - ['JSON nesting', '{"cells":[]}', { nestingDepth: 1 }, 'JSON nesting'] - ] as const)('rejects %s one step past its limit', (_name, content, override, message) => { - expect(() => assertIpynbJsonWithinMemoryLimits(content, limits(override))).toThrow(message) - }) - - it('ignores structural characters inside strings', () => { - const content = '{"cells":[{"source":"[{,:}]"}]}' - expect(() => - assertIpynbJsonWithinMemoryLimits(content, limits({ structuralTokens: 9 })) - ).not.toThrow() - }) - - it.each([ - ['cells', [{}, {}], { cells: 1 }, 'cell'], - ['outputs', [{ outputs: [{}, {}] }], { outputs: 1 }, 'output'], - [ - 'display items', - [{ outputs: [{ data: { one: 1, two: 2 } }] }], - { displayItems: 1 }, - 'display item' - ], - ['multiline parts', [{ source: ['one', 'two'] }], { multilineParts: 1 }, 'fragment'] - ] as const)('rejects too many %s before render projection', (_name, cells, override, message) => { - expect(() => assertIpynbShapeWithinMemoryLimits(cells, limits(override))).toThrow(message) - }) -}) diff --git a/src/renderer/src/components/editor/ipynb-json-admission.ts b/src/renderer/src/components/editor/ipynb-json-admission.ts deleted file mode 100644 index 606b7162179..00000000000 --- a/src/renderer/src/components/editor/ipynb-json-admission.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { assertJsonTextStructureWithinLimits } from '../../../../shared/json-text-structure-limit' - -export const IPYNB_MEMORY_LIMITS = { - sourceCodeUnits: 50 * 1024 * 1024, - structuralTokens: 500_000, - nestingDepth: 256, - cells: 1000, - outputs: 10_000, - displayItems: 20_000, - multilineParts: 100_000 -} as const - -export type IpynbMemoryLimits = Readonly<{ - sourceCodeUnits: number - structuralTokens: number - nestingDepth: number - cells: number - outputs: number - displayItems: number - multilineParts: number -}> - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} - -export function assertIpynbJsonWithinMemoryLimits( - content: string, - limits: IpynbMemoryLimits = IPYNB_MEMORY_LIMITS -): void { - if (content.length > limits.sourceCodeUnits) { - throw new Error('Notebook exceeds the safe source size limit') - } - - assertJsonTextStructureWithinLimits(content, { - structuralTokens: limits.structuralTokens, - nestingDepth: limits.nestingDepth - }) -} - -export function assertIpynbShapeWithinMemoryLimits( - cells: readonly unknown[], - limits: IpynbMemoryLimits = IPYNB_MEMORY_LIMITS -): void { - if (cells.length > limits.cells) { - throw new Error('Notebook exceeds the safe cell limit') - } - - let outputCount = 0 - let displayItemCount = 0 - let multilinePartCount = 0 - const claimMultilineParts = (value: unknown): void => { - if (!Array.isArray(value)) { - return - } - multilinePartCount += value.length - if (multilinePartCount > limits.multilineParts) { - throw new Error('Notebook exceeds the safe multiline fragment limit') - } - } - - for (const cell of cells) { - if (!isRecord(cell)) { - continue - } - claimMultilineParts(cell.source) - if (!Array.isArray(cell.outputs)) { - continue - } - outputCount += cell.outputs.length - if (outputCount > limits.outputs) { - throw new Error('Notebook exceeds the safe output limit') - } - for (const output of cell.outputs) { - if (!isRecord(output)) { - continue - } - claimMultilineParts(output.text) - claimMultilineParts(output.traceback) - if (!isRecord(output.data)) { - continue - } - for (const key in output.data) { - if (!Object.prototype.hasOwnProperty.call(output.data, key)) { - continue - } - displayItemCount += 1 - if (displayItemCount > limits.displayItems) { - throw new Error('Notebook exceeds the safe display item limit') - } - } - } - } -} diff --git a/src/renderer/src/components/editor/ipynb-parse.test.ts b/src/renderer/src/components/editor/ipynb-parse.test.ts index e7835526c2e..8178bed2355 100644 --- a/src/renderer/src/components/editor/ipynb-parse.test.ts +++ b/src/renderer/src/components/editor/ipynb-parse.test.ts @@ -11,7 +11,6 @@ import { updateIpynbCellSource, updateIpynbCellSources } from './ipynb-parse' -import { IPYNB_MEMORY_LIMITS } from './ipynb-json-admission' afterEach(() => { vi.restoreAllMocks() @@ -90,18 +89,6 @@ describe('ipynb parsing', () => { expect(() => parseIpynb('{}')).toThrow('Notebook is missing a cells array') }) - it('rejects renderer-amplifying cell counts before projecting cells', () => { - const content = JSON.stringify({ - cells: Array.from({ length: IPYNB_MEMORY_LIMITS.cells + 1 }, () => ({ - cell_type: 'raw', - metadata: {}, - source: [] - })) - }) - - expect(() => parseIpynb(content)).toThrow('safe cell limit') - }) - it('serializes cell source edits while preserving notebook metadata', () => { const content = JSON.stringify({ nbformat: 4, diff --git a/src/renderer/src/components/editor/ipynb-parse.ts b/src/renderer/src/components/editor/ipynb-parse.ts index a5ca9e80525..b10aa5f2df5 100644 --- a/src/renderer/src/components/editor/ipynb-parse.ts +++ b/src/renderer/src/components/editor/ipynb-parse.ts @@ -2,11 +2,6 @@ in one module makes nbformat preservation easier to audit while the notebook editor model is still small. */ import { createBrowserUuid } from '@/lib/browser-uuid' -import { - assertIpynbJsonWithinMemoryLimits, - assertIpynbShapeWithinMemoryLimits, - IPYNB_MEMORY_LIMITS -} from './ipynb-json-admission' export type IpynbCellKind = 'code' | 'markdown' | 'raw' @@ -70,12 +65,12 @@ function isRecord(value: unknown): value is Record { export function concatIpynbMultilineString(value: unknown): string { if (Array.isArray(value)) { - const parts: string[] = [] + let result = '' for (let i = 0; i < value.length; i += 1) { const item = String(value[i] ?? '') - parts.push(i < value.length - 1 && !item.endsWith('\n') ? `${item}\n` : item) + result += i < value.length - 1 && !item.endsWith('\n') ? `${item}\n` : item } - return parts.join('').replace(/\r\n/g, '\n') + return result.replace(/\r\n/g, '\n') } return String(value ?? '').replace(/\r\n/g, '\n') } @@ -188,10 +183,16 @@ function parseCell(rawCell: unknown, fallbackLanguage: string): IpynbCell | null } export function parseIpynb(content: string): ParsedIpynb { - const parsed = parseNotebookRoot(content) + const parsed = JSON.parse(content) as unknown + if (!isRecord(parsed)) { + throw new Error('Notebook root must be a JSON object') + } + if (!Array.isArray(parsed.cells)) { + throw new Error('Notebook is missing a cells array') + } const language = getPreferredLanguage(parsed) - const cells = (parsed.cells as unknown[]) + const cells = parsed.cells .map((cell) => parseCell(cell, language)) .filter((cell): cell is IpynbCell => cell !== null) @@ -207,9 +208,6 @@ export function parseIpynb(content: string): ParsedIpynb { } function splitIpynbSource(source: string): string[] { - if (source.length > IPYNB_MEMORY_LIMITS.sourceCodeUnits) { - throw new Error('Notebook cell source exceeds the safe size limit') - } if (!source) { return [] } @@ -220,22 +218,15 @@ function splitIpynbSource(source: string): string[] { continue } lines.push(source.slice(lineStart, index + 1)) - if (lines.length > IPYNB_MEMORY_LIMITS.multilineParts) { - throw new Error('Notebook cell source exceeds the safe line limit') - } lineStart = index + 1 } if (lineStart < source.length) { lines.push(source.slice(lineStart)) - if (lines.length > IPYNB_MEMORY_LIMITS.multilineParts) { - throw new Error('Notebook cell source exceeds the safe line limit') - } } return lines } function parseNotebookRoot(content: string): Record { - assertIpynbJsonWithinMemoryLimits(content) const parsed = JSON.parse(content) as unknown if (!isRecord(parsed)) { throw new Error('Notebook root must be a JSON object') @@ -243,7 +234,6 @@ function parseNotebookRoot(content: string): Record { if (!Array.isArray(parsed.cells)) { throw new Error('Notebook is missing a cells array') } - assertIpynbShapeWithinMemoryLimits(parsed.cells) return parsed } @@ -256,9 +246,7 @@ function ensureCell(root: Record, index: number): Record): string { - const serialized = `${JSON.stringify(root, null, 1)}\n` - assertIpynbJsonWithinMemoryLimits(serialized) - return serialized + return `${JSON.stringify(root, null, 1)}\n` } export function updateIpynbCellSource(content: string, index: number, source: string): string { @@ -312,9 +300,6 @@ export function insertIpynbCell( ): string { const root = parseNotebookRoot(content) const cells = root.cells as unknown[] - if (cells.length >= IPYNB_MEMORY_LIMITS.cells) { - throw new Error('Notebook exceeds the safe cell limit') - } const nextCell: Record = { cell_type: kind, id: createBrowserUuid(), diff --git a/src/renderer/src/components/editor/local-image-blob-retention.test.ts b/src/renderer/src/components/editor/local-image-blob-retention.test.ts deleted file mode 100644 index b16e2dea103..00000000000 --- a/src/renderer/src/components/editor/local-image-blob-retention.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { LocalImageBlobRetention, MAX_LOCAL_IMAGE_BLOB_BYTES } from './local-image-blob-retention' - -describe('LocalImageBlobRetention', () => { - it('evicts oldest blobs until aggregate retained bytes are bounded', () => { - const revoke = vi.fn() - const retention = new LocalImageBlobRetention(revoke) - - retention.set('first', { url: 'blob:first', bytes: MAX_LOCAL_IMAGE_BLOB_BYTES - 1 }) - retention.set('second', { url: 'blob:second', bytes: 2 }) - - expect(retention.has('first')).toBe(false) - expect(retention.get('second')).toBe('blob:second') - expect(retention.retainedBytes).toBe(2) - expect(revoke).toHaveBeenCalledWith('blob:first') - }) -}) diff --git a/src/renderer/src/components/editor/local-image-blob-retention.ts b/src/renderer/src/components/editor/local-image-blob-retention.ts deleted file mode 100644 index 479bfdec4b4..00000000000 --- a/src/renderer/src/components/editor/local-image-blob-retention.ts +++ /dev/null @@ -1,61 +0,0 @@ -export const MAX_LOCAL_IMAGE_BLOB_ENTRIES = 100 -export const MAX_LOCAL_IMAGE_BLOB_BYTES = 128 * 1024 * 1024 - -export type RetainedLocalImageBlob = { - url: string - bytes: number -} - -export class LocalImageBlobRetention { - private readonly entries = new Map() - private retained = 0 - - constructor(private readonly revoke: (url: string) => void) {} - - get(key: string): string | undefined { - return this.entries.get(key)?.url - } - - has(key: string): boolean { - return this.entries.has(key) - } - - set(key: string, entry: RetainedLocalImageBlob): void { - const previous = this.entries.get(key) - if (previous) { - this.entries.delete(key) - this.retained -= previous.bytes - if (previous.url !== entry.url) { - this.revoke(previous.url) - } - } - this.entries.set(key, entry) - this.retained += entry.bytes - while ( - this.entries.size > MAX_LOCAL_IMAGE_BLOB_ENTRIES || - this.retained > MAX_LOCAL_IMAGE_BLOB_BYTES - ) { - const oldestKey = this.entries.keys().next().value - if (oldestKey === undefined) { - break - } - const oldest = this.entries.get(oldestKey) - this.entries.delete(oldestKey) - this.retained -= oldest?.bytes ?? 0 - if (oldest) { - this.revoke(oldest.url) - } - } - } - - clear(): RetainedLocalImageBlob[] { - const stale = Array.from(this.entries.values()) - this.entries.clear() - this.retained = 0 - return stale - } - - get retainedBytes(): number { - return this.retained - } -} diff --git a/src/renderer/src/components/editor/local-image-load-admission.test.ts b/src/renderer/src/components/editor/local-image-load-admission.test.ts deleted file mode 100644 index c6ec0416412..00000000000 --- a/src/renderer/src/components/editor/local-image-load-admission.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { - LocalImageLoadAdmission, - MAX_ACTIVE_LOCAL_IMAGE_LOADS, - MAX_ADMITTED_LOCAL_IMAGE_LOADS -} from './local-image-load-admission' - -async function flushMicrotasks(): Promise { - for (let index = 0; index < 6; index += 1) { - await Promise.resolve() - } -} - -describe('LocalImageLoadAdmission', () => { - it('starts only the bounded number of image reads concurrently', async () => { - const admission = new LocalImageLoadAdmission() - const releases: (() => void)[] = [] - const task = vi.fn( - () => - new Promise((resolve) => { - releases.push(() => resolve(releases.length)) - }) - ) - - const loads = Array.from({ length: MAX_ACTIVE_LOCAL_IMAGE_LOADS + 1 }, () => - admission.admit(task) - ) - await flushMicrotasks() - expect(task).toHaveBeenCalledTimes(MAX_ACTIVE_LOCAL_IMAGE_LOADS) - - releases[0]?.() - await flushMicrotasks() - expect(task).toHaveBeenCalledTimes(MAX_ACTIVE_LOCAL_IMAGE_LOADS + 1) - - for (const release of releases) { - release() - } - await Promise.all(loads) - }) - - it('rejects excess admission and can release every queued load', async () => { - const admission = new LocalImageLoadAdmission() - const releases: (() => void)[] = [] - const admitted = Array.from({ length: MAX_ADMITTED_LOCAL_IMAGE_LOADS }, () => - admission.admit( - () => - new Promise((resolve) => { - releases.push(resolve) - }) - ) - ) - - await flushMicrotasks() - expect(admission.admit(() => Promise.resolve())).toBeNull() - admission.clearPending() - for (const release of releases) { - release() - } - await Promise.all(admitted) - }) -}) diff --git a/src/renderer/src/components/editor/local-image-load-admission.ts b/src/renderer/src/components/editor/local-image-load-admission.ts deleted file mode 100644 index 0e60b1bc034..00000000000 --- a/src/renderer/src/components/editor/local-image-load-admission.ts +++ /dev/null @@ -1,62 +0,0 @@ -export const MAX_ACTIVE_LOCAL_IMAGE_LOADS = 2 -export const MAX_ADMITTED_LOCAL_IMAGE_LOADS = 100 - -type PendingLoad = { - start: () => void - cancel: () => void -} - -export class LocalImageLoadAdmission { - private active = 0 - private readonly pending: PendingLoad[] = [] - - admit(task: () => Promise): Promise | null { - if (this.active + this.pending.length >= MAX_ADMITTED_LOCAL_IMAGE_LOADS) { - return null - } - let resolveResult!: (value: T | null) => void - let rejectResult!: (error: unknown) => void - const result = new Promise((resolve, reject) => { - resolveResult = resolve - rejectResult = reject - }) - const pendingLoad = { - start: () => { - this.active += 1 - let operation: Promise - try { - operation = task() - } catch (error) { - this.active -= 1 - rejectResult(error) - this.drain() - return - } - void operation.then(resolveResult, rejectResult).finally(() => { - this.active -= 1 - this.drain() - }) - }, - cancel: () => resolveResult(null) - } - this.pending.push(pendingLoad) - this.drain() - return result - } - - clearPending(): void { - for (const load of this.pending.splice(0)) { - load.cancel() - } - } - - private drain(): void { - while (this.active < MAX_ACTIVE_LOCAL_IMAGE_LOADS) { - const next = this.pending.shift() - if (!next) { - return - } - next.start() - } - } -} diff --git a/src/renderer/src/components/editor/markdown-doc-links.test.ts b/src/renderer/src/components/editor/markdown-doc-links.test.ts index 0c4b5c00912..d5cfaade6e7 100644 --- a/src/renderer/src/components/editor/markdown-doc-links.test.ts +++ b/src/renderer/src/components/editor/markdown-doc-links.test.ts @@ -71,10 +71,6 @@ describe('splitMarkdownDocLinkText', () => { }) describe('resolveMarkdownDocLink', () => { - it('shares one immutable index for consumers of the same document snapshot', () => { - expect(createMarkdownDocumentIndex(documents)).toBe(createMarkdownDocumentIndex(documents)) - }) - it('resolves basename links', () => { const result = resolveMarkdownDocLink('setup-guide', createMarkdownDocumentIndex(documents)) expect(result.status).toBe('resolved') diff --git a/src/renderer/src/components/editor/markdown-doc-links.ts b/src/renderer/src/components/editor/markdown-doc-links.ts index 37c8ce93836..82cd7133b38 100644 --- a/src/renderer/src/components/editor/markdown-doc-links.ts +++ b/src/renderer/src/components/editor/markdown-doc-links.ts @@ -101,13 +101,7 @@ function resolveMatches(matches: MarkdownDocument[] | undefined): MarkdownDocLin : { status: 'ambiguous', matches } } -const markdownDocumentIndexes = new WeakMap() - export function createMarkdownDocumentIndex(documents: MarkdownDocument[]): MarkdownDocumentIndex { - const cached = markdownDocumentIndexes.get(documents) - if (cached) { - return cached - } const byName = new Map() const byRelativePath = new Map() const byRelativePathWithoutExtension = new Map() @@ -122,9 +116,7 @@ export function createMarkdownDocumentIndex(documents: MarkdownDocument[]): Mark ) } - const index = { byName, byRelativePath, byRelativePathWithoutExtension } - markdownDocumentIndexes.set(documents, index) - return index + return { byName, byRelativePath, byRelativePathWithoutExtension } } export function resolveMarkdownDocLink( diff --git a/src/renderer/src/components/editor/markdown-document-list-request.test.ts b/src/renderer/src/components/editor/markdown-document-list-request.test.ts index 96e6bd1c528..085b8599fb8 100644 --- a/src/renderer/src/components/editor/markdown-document-list-request.test.ts +++ b/src/renderer/src/components/editor/markdown-document-list-request.test.ts @@ -3,10 +3,8 @@ import type { MarkdownDocument } from '../../../../shared/types' import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client' import { getMarkdownDocumentListRequestKey, - MARKDOWN_DOCUMENT_LIST_MAX_IN_FLIGHT, requestSharedMarkdownDocumentList } from './markdown-document-list-request' -import { MarkdownDocumentListingCapacityError } from '../../../../shared/markdown-document-listing-limits' function context(overrides: Partial = {}): RuntimeFileOperationArgs { return { @@ -175,29 +173,4 @@ describe('shared Markdown document list requests', () => { ) expect(load).toHaveBeenCalledTimes(2) }) - - it('rejects the first distinct scan beyond the global in-flight cap', async () => { - const pending = Array.from({ length: MARKDOWN_DOCUMENT_LIST_MAX_IN_FLIGHT }, () => - deferred() - ) - const load = vi.fn((_context: RuntimeFileOperationArgs, rootPath: string) => { - const index = Number(rootPath.slice('/route-'.length)) - return pending[index].promise - }) - const requests = pending.map((_entry, index) => - requestSharedMarkdownDocumentList(context(), `/route-${index}`, {}, load) - ) - - await expect( - requestSharedMarkdownDocumentList(context(), '/one-too-many', {}, load) - ).rejects.toBeInstanceOf(MarkdownDocumentListingCapacityError) - expect(load).toHaveBeenCalledTimes(MARKDOWN_DOCUMENT_LIST_MAX_IN_FLIGHT) - - for (const entry of pending) { - entry.resolve([]) - } - await expect(Promise.all(requests)).resolves.toEqual( - Array.from({ length: MARKDOWN_DOCUMENT_LIST_MAX_IN_FLIGHT }, () => []) - ) - }) }) diff --git a/src/renderer/src/components/editor/markdown-document-list-request.ts b/src/renderer/src/components/editor/markdown-document-list-request.ts index 9d55a502637..7e9cc0f32b3 100644 --- a/src/renderer/src/components/editor/markdown-document-list-request.ts +++ b/src/renderer/src/components/editor/markdown-document-list-request.ts @@ -3,7 +3,6 @@ import { listRuntimeMarkdownDocuments, type RuntimeFileOperationArgs } from '@/runtime/runtime-file-client' -import { MarkdownDocumentListingCapacityError } from '../../../../shared/markdown-document-listing-limits' type MarkdownDocumentListLoader = ( context: RuntimeFileOperationArgs, @@ -20,9 +19,7 @@ type InFlightMarkdownDocumentList = { } const MARKDOWN_DOCUMENT_LIST_JOIN_WINDOW_MS = 30_000 -export const MARKDOWN_DOCUMENT_LIST_MAX_IN_FLIGHT = 16 const inFlightMarkdownDocumentLists = new Map() -let activeMarkdownDocumentListLoads = 0 export function getMarkdownDocumentListRequestKey( context: RuntimeFileOperationArgs, @@ -57,22 +54,10 @@ export function requestSharedMarkdownDocumentList( if (existing && !options.requireFresh) { return existing.request } - if (activeMarkdownDocumentListLoads >= MARKDOWN_DOCUMENT_LIST_MAX_IN_FLIGHT) { - return Promise.reject(new MarkdownDocumentListingCapacityError()) - } // Why: split Markdown panes mount together and otherwise launch identical // whole-worktree local/SSH scans; mutation refreshes bypass older snapshots. - activeMarkdownDocumentListLoads += 1 - let loaded: Promise - try { - loaded = load(context, rootPath) - } catch (error) { - activeMarkdownDocumentListLoads -= 1 - return Promise.reject(error) - } - const request = loaded.finally(() => { - activeMarkdownDocumentListLoads -= 1 + const request = load(context, rootPath).finally(() => { if (inFlightMarkdownDocumentLists.get(key)?.request === request) { inFlightMarkdownDocumentLists.delete(key) } diff --git a/src/renderer/src/components/editor/markdown-document-worktree-retention.test.ts b/src/renderer/src/components/editor/markdown-document-worktree-retention.test.ts deleted file mode 100644 index 50e22b39ccf..00000000000 --- a/src/renderer/src/components/editor/markdown-document-worktree-retention.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { MarkdownDocument } from '../../../../shared/types' -import { - retainMarkdownDocumentWorktreeSnapshot, - type MarkdownDocumentWorktreeSnapshot -} from './markdown-document-worktree-retention' - -function documents(name: string): MarkdownDocument[] { - return [ - { - filePath: `/repo/${name}.md`, - relativePath: `${name}.md`, - basename: `${name}.md`, - name - } - ] -} - -describe('Markdown document worktree retention', () => { - it('preserves recent under-limit worktrees and refreshes their LRU position', () => { - let snapshots = new Map() - snapshots = retainMarkdownDocumentWorktreeSnapshot(snapshots, 'a', documents('a'), { - maxSnapshots: 2, - maxRetainedBytes: 10_000 - }) - snapshots = retainMarkdownDocumentWorktreeSnapshot(snapshots, 'b', documents('b'), { - maxSnapshots: 2, - maxRetainedBytes: 10_000 - }) - snapshots = retainMarkdownDocumentWorktreeSnapshot(snapshots, 'a', documents('a-new'), { - maxSnapshots: 2, - maxRetainedBytes: 10_000 - }) - snapshots = retainMarkdownDocumentWorktreeSnapshot(snapshots, 'c', documents('c'), { - maxSnapshots: 2, - maxRetainedBytes: 10_000 - }) - - expect(Array.from(snapshots.keys())).toEqual(['a', 'c']) - expect(snapshots.get('a')?.documents[0]?.name).toBe('a-new') - }) - - it('evicts oldest snapshots when their aggregate byte budget is exceeded', () => { - let snapshots = new Map() - snapshots = retainMarkdownDocumentWorktreeSnapshot(snapshots, 'a', documents('a'), { - maxSnapshots: 10, - maxRetainedBytes: 10_000 - }) - const oneSnapshotBytes = snapshots.get('a')?.retainedBytes ?? 0 - snapshots = retainMarkdownDocumentWorktreeSnapshot(snapshots, 'b', documents('b'), { - maxSnapshots: 10, - maxRetainedBytes: oneSnapshotBytes - }) - - expect(Array.from(snapshots.keys())).toEqual(['b']) - }) -}) diff --git a/src/renderer/src/components/editor/markdown-document-worktree-retention.ts b/src/renderer/src/components/editor/markdown-document-worktree-retention.ts deleted file mode 100644 index 4b3db84647f..00000000000 --- a/src/renderer/src/components/editor/markdown-document-worktree-retention.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { MarkdownDocument } from '../../../../shared/types' -import { assertMarkdownDocumentsWithinLimit } from '../../../../shared/markdown-document-listing-limits' - -export const MARKDOWN_DOCUMENT_WORKTREE_MAX_SNAPSHOTS = 8 -export const MARKDOWN_DOCUMENT_WORKTREE_MAX_RETAINED_BYTES = 32 * 1024 * 1024 - -export type MarkdownDocumentWorktreeSnapshot = { - documents: MarkdownDocument[] - retainedBytes: number -} - -export function retainMarkdownDocumentWorktreeSnapshot( - previous: ReadonlyMap, - worktreeId: string, - documents: MarkdownDocument[], - limits: { maxSnapshots: number; maxRetainedBytes: number } = { - maxSnapshots: MARKDOWN_DOCUMENT_WORKTREE_MAX_SNAPSHOTS, - maxRetainedBytes: MARKDOWN_DOCUMENT_WORKTREE_MAX_RETAINED_BYTES - } -): Map { - const next = new Map(previous) - next.delete(worktreeId) - next.set(worktreeId, { - documents, - retainedBytes: assertMarkdownDocumentsWithinLimit(documents) - }) - - let retainedBytes = 0 - for (const snapshot of next.values()) { - retainedBytes += snapshot.retainedBytes - } - while (next.size > limits.maxSnapshots || retainedBytes > limits.maxRetainedBytes) { - const oldestKey = next.keys().next().value - if (typeof oldestKey !== 'string') { - break - } - retainedBytes -= next.get(oldestKey)?.retainedBytes ?? 0 - next.delete(oldestKey) - } - return next -} diff --git a/src/renderer/src/components/editor/markdown-export-extract.test.ts b/src/renderer/src/components/editor/markdown-export-extract.test.ts index 9494b10accf..f10a8289c44 100644 --- a/src/renderer/src/components/editor/markdown-export-extract.test.ts +++ b/src/renderer/src/components/editor/markdown-export-extract.test.ts @@ -1,7 +1,7 @@ // @vitest-environment happy-dom import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { getActiveMarkdownExportPayload, inlineBlobImageSources } from './markdown-export-extract' +import { getActiveMarkdownExportPayload } from './markdown-export-extract' vi.mock('@/store', () => ({ useAppStore: { @@ -14,11 +14,10 @@ describe('getActiveMarkdownExportPayload', () => { vi.clearAllMocks() vi.stubGlobal( 'fetch', - vi - .fn() - .mockResolvedValue( - new Response(new Uint8Array([1, 2, 3]), { headers: { 'content-type': 'image/png' } }) - ) + vi.fn().mockResolvedValue({ + ok: true, + blob: async () => new Blob([new Uint8Array([1, 2, 3])], { type: 'image/png' }) + }) ) const { useAppStore } = await import('@/store') vi.mocked(useAppStore.getState).mockReturnValue({ @@ -67,30 +66,4 @@ describe('getActiveMarkdownExportPayload', () => { }) ).rejects.toThrow('Failed to inline image for PDF export') }) - - it('rejects streamed image bytes before the rendered fragment can exceed its cap', async () => { - let cancelled = false - vi.mocked(fetch).mockResolvedValue( - new Response( - new ReadableStream({ - start(controller) { - controller.enqueue(new Uint8Array(12)) - controller.enqueue(new Uint8Array(12)) - }, - cancel() { - cancelled = true - } - }), - { headers: { 'content-type': 'image/png' } } - ) - ) - const root = document.createElement('div') - root.innerHTML = '' - const prefixOnly = ''.length - - await expect(inlineBlobImageSources(root, prefixOnly + 16)).rejects.toThrow( - 'HTML export exceeds the PDF memory limit' - ) - expect(cancelled).toBe(true) - }) }) diff --git a/src/renderer/src/components/editor/markdown-export-extract.ts b/src/renderer/src/components/editor/markdown-export-extract.ts index 8b5a8985073..a0ec5ebb2fe 100644 --- a/src/renderer/src/components/editor/markdown-export-extract.ts +++ b/src/renderer/src/components/editor/markdown-export-extract.ts @@ -1,15 +1,5 @@ import { useAppStore } from '@/store' import { detectLanguage } from '@/lib/language-detect' -import { - assertHtmlToPdfInputWithinMemoryLimit, - HTML_TO_PDF_MAX_INPUT_BYTES, - HTML_TO_PDF_MEMORY_LIMIT_ERROR -} from '../../../../shared/html-to-pdf-memory-limit' -import { - FetchResponseBodyTooLargeError, - readFetchResponseBytesWithinLimit -} from '../../../../shared/fetch-response-body' -import { measureUtf8ByteLength } from '../../../../shared/utf8-byte-limits' import { buildMarkdownExportHtml } from './markdown-export-html' export type MarkdownExportPayload = { @@ -75,15 +65,6 @@ export async function getActiveMarkdownExportPayload({ return null } - const title = basenameWithoutExt(activeFile.relativePath || activeFile.filePath) - const wrapper = buildMarkdownExportHtml({ title, renderedHtml: '' }) - const wrapperBytes = measureUtf8ByteLength(wrapper, { - stopAfterBytes: HTML_TO_PDF_MAX_INPUT_BYTES - }) - if (wrapperBytes.exceededLimit) { - throw new Error(HTML_TO_PDF_MEMORY_LIMIT_ERROR) - } - const clone = subtree.cloneNode(true) as Element for (const selector of UI_ONLY_SELECTORS) { for (const node of clone.querySelectorAll(selector)) { @@ -92,77 +73,51 @@ export async function getActiveMarkdownExportPayload({ } // Why: local-image previews use renderer-scoped blob URLs; the hidden PDF // window cannot dereference them, so embed the bytes before export. - await inlineBlobImageSources(clone, HTML_TO_PDF_MAX_INPUT_BYTES - wrapperBytes.byteLength) + await inlineBlobImageSources(clone) const renderedHtml = clone.innerHTML.trim() if (!renderedHtml) { return null } + const title = basenameWithoutExt(activeFile.relativePath || activeFile.filePath) const html = buildMarkdownExportHtml({ title, renderedHtml }) - assertHtmlToPdfInputWithinMemoryLimit(html) return { title, html } } -export async function inlineBlobImageSources( - root: Element, - maxFragmentBytes: number -): Promise { - for (const image of root.querySelectorAll('img[src^="blob:"]')) { - const src = image.getAttribute('src') - if (!src) { - continue - } - await inlineBlobImageSource(root, image, src, maxFragmentBytes) - } +async function inlineBlobImageSources(root: Element): Promise { + const images = Array.from(root.querySelectorAll('img[src^="blob:"]')) + await Promise.all( + images.map(async (image) => { + const src = image.getAttribute('src') + if (!src) { + return + } + image.setAttribute('src', await readBlobImageAsDataUrl(src)) + }) + ) } -async function inlineBlobImageSource( - root: Element, - image: HTMLImageElement, - src: string, - maxFragmentBytes: number -): Promise { +async function readBlobImageAsDataUrl(src: string): Promise { try { const response = await fetch(src) if (!response.ok) { throw new Error('Unable to fetch blob image') } - const mediaType = normalizedMediaType(response.headers.get('content-type')) - const prefix = `data:${mediaType};base64,` - image.setAttribute('src', prefix) - const prefixMeasurement = measureUtf8ByteLength(root.innerHTML, { - stopAfterBytes: maxFragmentBytes - }) - if (prefixMeasurement.exceededLimit) { - throw new Error(HTML_TO_PDF_MEMORY_LIMIT_ERROR) - } - const availableBase64Characters = maxFragmentBytes - prefixMeasurement.byteLength - const maxImageBytes = Math.floor(availableBase64Characters / 4) * 3 - const bytes = await readFetchResponseBytesWithinLimit(response, maxImageBytes) - image.setAttribute('src', `${prefix}${bytesToBase64(bytes)}`) + const blob = await response.blob() + const bytes = new Uint8Array(await blob.arrayBuffer()) + return `data:${blob.type || 'application/octet-stream'};base64,${bytesToBase64(bytes)}` } catch (error) { - if ( - error instanceof FetchResponseBodyTooLargeError || - (error instanceof Error && error.message === HTML_TO_PDF_MEMORY_LIMIT_ERROR) - ) { - throw new Error(HTML_TO_PDF_MEMORY_LIMIT_ERROR) - } const message = error instanceof Error ? error.message : String(error) throw new Error(`Failed to inline image for PDF export: ${message}`) } } -function normalizedMediaType(value: string | null): string { - return new Blob([], { type: value ?? '' }).type || 'application/octet-stream' -} - function bytesToBase64(bytes: Uint8Array): string { - const chunks: string[] = [] - const chunkSize = 3 * 8192 + let binary = '' + const chunkSize = 0x8000 for (let index = 0; index < bytes.length; index += chunkSize) { - const binary = String.fromCharCode(...bytes.subarray(index, index + chunkSize)) - chunks.push(btoa(binary)) + binary += String.fromCharCode(...bytes.subarray(index, index + chunkSize)) } - return chunks.join('') + return btoa(binary) } diff --git a/src/renderer/src/components/editor/monaco-markdown-doc-completions.test.ts b/src/renderer/src/components/editor/monaco-markdown-doc-completions.test.ts deleted file mode 100644 index a13470cb515..00000000000 --- a/src/renderer/src/components/editor/monaco-markdown-doc-completions.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { afterEach, describe, expect, it } from 'vitest' -import type { MarkdownDocument } from '../../../../shared/types' -import { assertMarkdownDocumentsWithinLimit } from '../../../../shared/markdown-document-listing-limits' -import { - clearMarkdownDocCompletionDocuments, - getMarkdownCompletionRetentionForTests, - MARKDOWN_COMPLETION_MAX_MODELS, - MARKDOWN_COMPLETION_MAX_SCOPES, - resetMarkdownCompletionRetentionForTests, - setMarkdownDocCompletionDocuments -} from './monaco-markdown-doc-completions' - -function documents(scope: string): MarkdownDocument[] { - return [ - { - filePath: `/repo/${scope}.md`, - relativePath: `${scope}.md`, - basename: `${scope}.md`, - name: scope - } - ] -} - -afterEach(() => { - resetMarkdownCompletionRetentionForTests() -}) - -describe('Monaco Markdown completion retention', () => { - it('stores one document snapshot for every mounted model in the same worktree scope', () => { - setMarkdownDocCompletionDocuments('model-a', 'worktree-a', documents('old')) - const freshDocuments = documents('fresh') - setMarkdownDocCompletionDocuments('model-b', 'worktree-a', freshDocuments) - - expect(getMarkdownCompletionRetentionForTests()).toEqual({ - models: 2, - scopes: 1, - retainedBytes: assertMarkdownDocumentsWithinLimit(freshDocuments) - }) - - clearMarkdownDocCompletionDocuments('model-a') - expect(getMarkdownCompletionRetentionForTests()).toMatchObject({ models: 1, scopes: 1 }) - clearMarkdownDocCompletionDocuments('model-b') - expect(getMarkdownCompletionRetentionForTests()).toEqual({ - models: 0, - scopes: 0, - retainedBytes: 0 - }) - }) - - it('evicts oldest scopes and model associations at their exact caps', () => { - for (let index = 0; index <= MARKDOWN_COMPLETION_MAX_SCOPES; index += 1) { - setMarkdownDocCompletionDocuments(`model-${index}`, `scope-${index}`, documents(`${index}`)) - } - expect(getMarkdownCompletionRetentionForTests()).toMatchObject({ - models: MARKDOWN_COMPLETION_MAX_SCOPES, - scopes: MARKDOWN_COMPLETION_MAX_SCOPES - }) - - resetMarkdownCompletionRetentionForTests() - for (let index = 0; index <= MARKDOWN_COMPLETION_MAX_MODELS; index += 1) { - setMarkdownDocCompletionDocuments(`model-${index}`, 'shared-scope', documents('shared')) - } - expect(getMarkdownCompletionRetentionForTests()).toMatchObject({ - models: MARKDOWN_COMPLETION_MAX_MODELS, - scopes: 1 - }) - }) -}) diff --git a/src/renderer/src/components/editor/monaco-markdown-doc-completions.ts b/src/renderer/src/components/editor/monaco-markdown-doc-completions.ts index d457004553f..882b011e9b3 100644 --- a/src/renderer/src/components/editor/monaco-markdown-doc-completions.ts +++ b/src/renderer/src/components/editor/monaco-markdown-doc-completions.ts @@ -5,72 +5,12 @@ import { getMarkdownDocCompletionContext, getMarkdownDocCompletionDocuments } from './markdown-doc-completions' -import { assertMarkdownDocumentsWithinLimit } from '../../../../shared/markdown-document-listing-limits' type MonacoApi = Parameters[1] -type CompletionScope = { - documents: MarkdownDocument[] - modelKeys: Set - retainedBytes: number -} - -export const MARKDOWN_COMPLETION_MAX_MODELS = 256 -export const MARKDOWN_COMPLETION_MAX_SCOPES = 32 -export const MARKDOWN_COMPLETION_MAX_RETAINED_BYTES = 64 * 1024 * 1024 - let provider: IDisposable | null = null let providerMonaco: MonacoApi | null = null -const scopeKeyByModel = new Map() -const completionScopes = new Map() -let retainedBytes = 0 - -function deleteCompletionScope(scopeKey: string, scope: CompletionScope): void { - completionScopes.delete(scopeKey) - retainedBytes -= scope.retainedBytes - for (const modelKey of scope.modelKeys) { - scopeKeyByModel.delete(modelKey) - } -} - -function removeModel(modelKey: string): void { - const scopeKey = scopeKeyByModel.get(modelKey) - if (!scopeKey) { - return - } - scopeKeyByModel.delete(modelKey) - const scope = completionScopes.get(scopeKey) - scope?.modelKeys.delete(modelKey) - if (scope && scope.modelKeys.size === 0) { - deleteCompletionScope(scopeKey, scope) - } -} - -function enforceCompletionRetentionLimits(): void { - while (scopeKeyByModel.size > MARKDOWN_COMPLETION_MAX_MODELS) { - const oldestModelKey = scopeKeyByModel.keys().next().value - if (typeof oldestModelKey !== 'string') { - break - } - removeModel(oldestModelKey) - } - while ( - completionScopes.size > MARKDOWN_COMPLETION_MAX_SCOPES || - retainedBytes > MARKDOWN_COMPLETION_MAX_RETAINED_BYTES - ) { - const oldest = completionScopes.entries().next().value - if (!oldest) { - break - } - deleteCompletionScope(oldest[0], oldest[1]) - } -} - -function clearCompletionRetention(): void { - scopeKeyByModel.clear() - completionScopes.clear() - retainedBytes = 0 -} +const documentsByModel = new Map() export function ensureMarkdownDocCompletionProvider(monaco: MonacoApi): void { // Why: if Monaco was torn down and re-created (e.g. window reload), the old @@ -81,7 +21,7 @@ export function ensureMarkdownDocCompletionProvider(monaco: MonacoApi): void { } if (provider) { provider.dispose() - clearCompletionRetention() + documentsByModel.clear() } providerMonaco = monaco @@ -94,8 +34,7 @@ export function ensureMarkdownDocCompletionProvider(monaco: MonacoApi): void { return { suggestions: [] } } - const scopeKey = scopeKeyByModel.get(model.uri.toString()) - const documents = scopeKey ? (completionScopes.get(scopeKey)?.documents ?? []) : [] + const documents = documentsByModel.get(model.uri.toString()) ?? [] const suffix = line.slice(position.column - 1) const range = { startLineNumber: position.lineNumber, @@ -121,49 +60,11 @@ export function ensureMarkdownDocCompletionProvider(monaco: MonacoApi): void { export function setMarkdownDocCompletionDocuments( modelKey: string, - scopeKey: string, documents: MarkdownDocument[] ): void { - removeModel(modelKey) - let nextRetainedBytes: number - try { - nextRetainedBytes = assertMarkdownDocumentsWithinLimit(documents) - } catch { - return - } - - let scope = completionScopes.get(scopeKey) - if (scope) { - completionScopes.delete(scopeKey) - retainedBytes -= scope.retainedBytes - scope.documents = documents - scope.retainedBytes = nextRetainedBytes - } else { - scope = { documents, modelKeys: new Set(), retainedBytes: nextRetainedBytes } - } - scope.modelKeys.add(modelKey) - completionScopes.set(scopeKey, scope) - scopeKeyByModel.set(modelKey, scopeKey) - retainedBytes += nextRetainedBytes - enforceCompletionRetentionLimits() + documentsByModel.set(modelKey, documents) } export function clearMarkdownDocCompletionDocuments(modelKey: string): void { - removeModel(modelKey) -} - -export function getMarkdownCompletionRetentionForTests(): { - models: number - scopes: number - retainedBytes: number -} { - return { - models: scopeKeyByModel.size, - scopes: completionScopes.size, - retainedBytes - } -} - -export function resetMarkdownCompletionRetentionForTests(): void { - clearCompletionRetention() + documentsByModel.delete(modelKey) } diff --git a/src/renderer/src/components/editor/rich-markdown-local-image.test.ts b/src/renderer/src/components/editor/rich-markdown-local-image.test.ts index 0d4a629a6bb..e343e918619 100644 --- a/src/renderer/src/components/editor/rich-markdown-local-image.test.ts +++ b/src/renderer/src/components/editor/rich-markdown-local-image.test.ts @@ -22,7 +22,7 @@ describe('rich markdown local images', () => { ...globalThis.window.api, fs: { readFile: vi.fn().mockResolvedValue({ - content: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB', + content: 'AA==', isBinary: true, mimeType: 'image/png' }) diff --git a/src/renderer/src/components/editor/useLocalImageSrc.test.ts b/src/renderer/src/components/editor/useLocalImageSrc.test.ts index b8aa8221bff..98907f2709a 100644 --- a/src/renderer/src/components/editor/useLocalImageSrc.test.ts +++ b/src/renderer/src/components/editor/useLocalImageSrc.test.ts @@ -31,17 +31,7 @@ function deferred(): { return { promise, reject, resolve } } -function pngBase64(width = 1, height = 1): string { - const bytes = Buffer.alloc(24) - Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(bytes) - bytes.writeUInt32BE(13, 8) - bytes.write('IHDR', 12, 'ascii') - bytes.writeUInt32BE(width, 16) - bytes.writeUInt32BE(height, 20) - return bytes.toString('base64') -} - -function binaryPreview(content = pngBase64()): PreviewResult { +function binaryPreview(content = 'AA=='): PreviewResult { return { content, isBinary: true, mimeType: 'image/png' } } @@ -161,13 +151,6 @@ describe('loadLocalImageSrc', () => { ).resolves.toBeNull() }) - it('rejects an inline raster dimension bomb before assigning it to an image', async () => { - const src = `data:image/png;base64,${pngBase64(32_769, 1)}` - - await expect(loadLocalImageSrc(src, '/repo/docs/readme.md')).resolves.toBeNull() - expect(URL.createObjectURL).not.toHaveBeenCalled() - }) - it('suppresses a stale pending completion after cache invalidation', async () => { const firstRead = deferred() const readFile = vi @@ -203,9 +186,9 @@ describe('loadLocalImageSrc', () => { invalidateLocalImageSrcCacheForTests() const newerLoad = loadLocalImageSrc('diagram.png', '/repo/docs/readme.md') - secondRead.resolve(binaryPreview(pngBase64(2, 1))) + secondRead.resolve(binaryPreview('AQ==')) await expect(newerLoad).resolves.toBe('blob:newer') - firstRead.resolve(binaryPreview(pngBase64(3, 1))) + firstRead.resolve(binaryPreview('Ag==')) await expect(staleLoad).resolves.toBeNull() await expect(loadLocalImageSrc('diagram.png', '/repo/docs/readme.md')).resolves.toBe( 'blob:newer' @@ -214,14 +197,6 @@ describe('loadLocalImageSrc', () => { expect(URL.revokeObjectURL).not.toHaveBeenCalledWith('blob:newer') }) - it('rejects an oversized raster header before creating a blob URL', async () => { - const readFile = vi.fn().mockResolvedValue(binaryPreview(pngBase64(32_769, 1))) - setReadFile(readFile) - - await expect(loadLocalImageSrc('bomb.png', '/repo/docs/readme.md')).resolves.toBeNull() - expect(URL.createObjectURL).not.toHaveBeenCalled() - }) - it('keeps runtime owners in separate image cache entries', async () => { const readFile = vi.fn().mockResolvedValue(binaryPreview()) vi.spyOn(URL, 'createObjectURL') diff --git a/src/renderer/src/components/editor/useLocalImageSrc.ts b/src/renderer/src/components/editor/useLocalImageSrc.ts index 3eeaad65dc3..d99c55913ff 100644 --- a/src/renderer/src/components/editor/useLocalImageSrc.ts +++ b/src/renderer/src/components/editor/useLocalImageSrc.ts @@ -2,24 +2,15 @@ import { useEffect, useState } from 'react' import { resolveImageAbsolutePath } from './markdown-preview-links' import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client' import { readRuntimeFilePreview } from '@/runtime/runtime-file-client' -import { - LocalImageBlobRetention, - MAX_LOCAL_IMAGE_BLOB_BYTES, - type RetainedLocalImageBlob -} from './local-image-blob-retention' -import { LocalImageLoadAdmission } from './local-image-load-admission' -import { decodeBase64Bytes } from './base64-byte-decoder' -import { assertRasterImagePreviewWithinLimits } from '../../../../shared/raster-image-preview-limits' -import { validateRasterImageDataUri } from '../../../../shared/image-data-uri' // Why: the renderer is served from http://localhost in dev mode, so file:// // URLs in tags are blocked by cross-origin restrictions. Loading images // via the existing fs.readFile IPC and converting to blob URLs bypasses this // limitation and works identically in both dev and production modes. -const blobUrlCache = new LocalImageBlobRetention((url) => URL.revokeObjectURL(url)) +const BLOB_URL_CACHE_MAX_SIZE = 100 +const blobUrlCache = new Map() const inFlightBlobUrlLoads = new Map>() -const imageLoadAdmission = new LocalImageLoadAdmission() export function getLocalImageCacheKey( absolutePath: string, @@ -40,45 +31,53 @@ export function getLocalImageCacheKey( // the cache grows without bound and leaks memory. We evict the oldest entry // (Map iteration order is insertion order) and revoke its blob URL so the // browser can free the underlying data. -function cacheBlobUrl(key: string, entry: RetainedLocalImageBlob): void { - blobUrlCache.set(key, entry) +function cacheBlobUrl(key: string, url: string): void { + const previousUrl = blobUrlCache.get(key) + if (previousUrl !== undefined) { + blobUrlCache.delete(key) + if (previousUrl !== url) { + // Why: cache replacements must release the superseded Blob even when + // they come from rare stale state or future loader changes. + URL.revokeObjectURL(previousUrl) + } + } + blobUrlCache.set(key, url) + if (blobUrlCache.size > BLOB_URL_CACHE_MAX_SIZE) { + const oldest = blobUrlCache.keys().next().value + if (oldest !== undefined) { + const oldUrl = blobUrlCache.get(oldest) + blobUrlCache.delete(oldest) + if (oldUrl) { + URL.revokeObjectURL(oldUrl) + } + } + } } const cacheListeners = new Set<() => void>() let cacheGeneration = 0 -const pendingBlobUrlRevocations = new Map() -let pendingBlobUrlRevocationBytes = 0 +const pendingBlobUrlRevocations = new Set() let pendingBlobUrlRevocationTimer: ReturnType | null = null -function base64ToBlobUrl(base64: string, mimeType: string): RetainedLocalImageBlob { - const bytes = decodeBase64Bytes(base64) - assertRasterImagePreviewWithinLimits(bytes, mimeType) - return { url: URL.createObjectURL(new Blob([bytes], { type: mimeType })), bytes: bytes.length } +function base64ToBlobUrl(base64: string, mimeType: string): string { + const binary = atob(base64.replace(/\s/g, '')) + const bytes = new Uint8Array(binary.length) + for (let i = 0; i < binary.length; i += 1) { + bytes[i] = binary.charCodeAt(i) + } + return URL.createObjectURL(new Blob([bytes], { type: mimeType })) } function revokePendingBlobUrls(): void { pendingBlobUrlRevocationTimer = null - for (const url of pendingBlobUrlRevocations.keys()) { + for (const url of pendingBlobUrlRevocations) { URL.revokeObjectURL(url) } pendingBlobUrlRevocations.clear() - pendingBlobUrlRevocationBytes = 0 } -function scheduleBlobUrlRevocation(entries: RetainedLocalImageBlob[]): void { - for (const { url, bytes } of entries) { - const previousBytes = pendingBlobUrlRevocations.get(url) ?? 0 - pendingBlobUrlRevocations.set(url, bytes) - pendingBlobUrlRevocationBytes += bytes - previousBytes - } - while (pendingBlobUrlRevocationBytes > MAX_LOCAL_IMAGE_BLOB_BYTES) { - const oldest = pendingBlobUrlRevocations.entries().next().value - if (!oldest) { - break - } - const [url, bytes] = oldest - pendingBlobUrlRevocations.delete(url) - pendingBlobUrlRevocationBytes -= bytes - URL.revokeObjectURL(url) +function scheduleBlobUrlRevocation(urls: string[]): void { + for (const url of urls) { + pendingBlobUrlRevocations.add(url) } if (pendingBlobUrlRevocationTimer !== null || pendingBlobUrlRevocations.size === 0) { return @@ -93,9 +92,9 @@ function scheduleBlobUrlRevocation(entries: RetainedLocalImageBlob[]): void { // display the old data while the fresh IPC load completes, avoiding a visible // flash. The 30-second window is generous enough for even slow IPC reads. function invalidateImageCache(): void { - const staleUrls = blobUrlCache.clear() + const staleUrls = Array.from(blobUrlCache.values()) + blobUrlCache.clear() inFlightBlobUrlLoads.clear() - imageLoadAdmission.clearPending() cacheGeneration += 1 for (const listener of cacheListeners) { listener() @@ -110,7 +109,6 @@ function invalidateImageCache(): void { } function disposeImageCacheModuleState(): void { - cacheGeneration += 1 if (typeof window !== 'undefined') { window.removeEventListener('focus', invalidateImageCache) } @@ -119,10 +117,10 @@ function disposeImageCacheModuleState(): void { pendingBlobUrlRevocationTimer = null } revokePendingBlobUrls() - for (const { url } of blobUrlCache.clear()) { + for (const url of blobUrlCache.values()) { URL.revokeObjectURL(url) } - imageLoadAdmission.clearPending() + blobUrlCache.clear() inFlightBlobUrlLoads.clear() cacheListeners.clear() } @@ -148,13 +146,13 @@ export function onImageCacheInvalidated(listener: () => void): () => void { } } -function resolveExternalImageUrl(src: string): string | null { - if (src.startsWith('data:')) { - return validateRasterImageDataUri(src) - } - return src.startsWith('http://') || src.startsWith('https://') || src.startsWith('blob:') - ? src - : null +function isExternalUrl(src: string): boolean { + return ( + src.startsWith('http://') || + src.startsWith('https://') || + src.startsWith('data:') || + src.startsWith('blob:') + ) } /** @@ -179,9 +177,8 @@ export function useLocalImageSrc( if (!rawSrc) { return undefined } - const externalSrc = resolveExternalImageUrl(rawSrc) - if (externalSrc) { - return externalSrc + if (isExternalUrl(rawSrc)) { + return rawSrc } const absolutePath = resolveImageAbsolutePath(rawSrc, filePath) if (absolutePath) { @@ -199,9 +196,8 @@ export function useLocalImageSrc( return } - const externalSrc = resolveExternalImageUrl(rawSrc) - if (externalSrc) { - setDisplaySrc(externalSrc) + if (isExternalUrl(rawSrc)) { + setDisplaySrc(rawSrc) return } @@ -251,9 +247,13 @@ export async function loadLocalImageSrc( connectionId?: string | null, runtimeContext?: Omit & { connectionId?: string | null } ): Promise { - const externalSrc = resolveExternalImageUrl(rawSrc) - if (externalSrc) { - return externalSrc + if ( + rawSrc.startsWith('http://') || + rawSrc.startsWith('https://') || + rawSrc.startsWith('data:') || + rawSrc.startsWith('blob:') + ) { + return rawSrc } const absolutePath = resolveImageAbsolutePath(rawSrc, filePath) @@ -287,26 +287,20 @@ export function loadLocalImageAbsolutePath( } const readGeneration = cacheGeneration - const admitted = imageLoadAdmission.admit(() => - readImagePreview(absolutePath, connectionId, runtimeContext) - ) - if (!admitted) { - return Promise.resolve(null) - } - const loadPromise = admitted + const loadPromise = readImagePreview(absolutePath, connectionId, runtimeContext) .then((result) => { - if (!result?.isBinary || !result.content || cacheGeneration !== readGeneration) { + if (!result.isBinary || !result.content || cacheGeneration !== readGeneration) { // Why: local image paths must stay behind IPC/runtime authorization; // handing raw file: or relative paths back to Chromium can escape it. return null } - const entry = base64ToBlobUrl(result.content, result.mimeType ?? 'image/png') + const url = base64ToBlobUrl(result.content, result.mimeType ?? 'image/png') if (cacheGeneration !== readGeneration) { - URL.revokeObjectURL(entry.url) + URL.revokeObjectURL(url) return null } - cacheBlobUrl(cacheKey, entry) - return entry.url + cacheBlobUrl(cacheKey, url) + return url }) .catch(() => null) .finally(() => { @@ -324,14 +318,13 @@ export function resetLocalImageSrcStateForTests(): void { pendingBlobUrlRevocationTimer = null } revokePendingBlobUrls() - for (const { url } of blobUrlCache.clear()) { + for (const url of blobUrlCache.values()) { URL.revokeObjectURL(url) } - imageLoadAdmission.clearPending() + blobUrlCache.clear() inFlightBlobUrlLoads.clear() - cacheGeneration += 1 + cacheGeneration = 0 pendingBlobUrlRevocations.clear() - pendingBlobUrlRevocationBytes = 0 cacheListeners.clear() } diff --git a/src/renderer/src/components/editor/useMarkdownDocuments.test.tsx b/src/renderer/src/components/editor/useMarkdownDocuments.test.tsx deleted file mode 100644 index b0b41dd9799..00000000000 --- a/src/renderer/src/components/editor/useMarkdownDocuments.test.tsx +++ /dev/null @@ -1,96 +0,0 @@ -// @vitest-environment happy-dom -import { act, renderHook, waitFor } from '@testing-library/react' -import { afterEach, describe, expect, it, vi } from 'vitest' -import type { MarkdownDocument } from '../../../../shared/types' -import { MarkdownDocumentListingCapacityError } from '../../../../shared/markdown-document-listing-limits' -import type { OpenFile } from '@/store/slices/editor' -import { useMarkdownDocuments } from './useMarkdownDocuments' - -const mocks = vi.hoisted(() => ({ - list: vi.fn(), - openFile: vi.fn(), - openMarkdownPreview: vi.fn(), - toastError: vi.fn() -})) - -vi.mock('sonner', () => ({ toast: { error: mocks.toastError } })) -vi.mock('@/lib/connection-context', () => ({ getConnectionId: () => null })) -vi.mock('@/runtime/runtime-file-client', () => ({ statRuntimePath: vi.fn() })) -vi.mock('@/runtime/runtime-rpc-client', () => ({ - settingsForRuntimeOwner: () => ({ activeRuntimeEnvironmentId: null }) -})) -vi.mock('./markdown-document-list-request', () => ({ - requestSharedMarkdownDocumentList: mocks.list -})) -vi.mock('./markdown-document-worktree-path-selector', () => ({ - selectMarkdownDocumentWorktreePath: (_state: unknown, worktreeId: string) => `/repo/${worktreeId}` -})) -vi.mock('@/store', () => { - const state = { - settings: {}, - openFile: mocks.openFile, - openMarkdownPreview: mocks.openMarkdownPreview - } - const useAppStore = Object.assign( - (selector: (value: typeof state) => unknown) => selector(state), - { getState: () => state } - ) - return { useAppStore } -}) - -function file(worktreeId: string): OpenFile { - return { - id: `/repo/${worktreeId}/README.md`, - filePath: `/repo/${worktreeId}/README.md`, - relativePath: 'README.md', - worktreeId, - language: 'markdown', - isDirty: false, - mode: 'edit' - } -} - -function deferred(): { - promise: Promise - reject: (error: Error) => void - resolve: (value: T) => void -} { - let reject!: (error: Error) => void - let resolve!: (value: T) => void - const promise = new Promise((resolvePromise, rejectPromise) => { - resolve = resolvePromise - reject = rejectPromise - }) - return { promise, reject, resolve } -} - -afterEach(() => { - vi.clearAllMocks() - vi.restoreAllMocks() -}) - -describe('useMarkdownDocuments', () => { - it('does not toast when a superseded worktree scan later exceeds capacity', async () => { - vi.spyOn(console, 'error').mockImplementation(() => {}) - const first = deferred() - const second = deferred() - mocks.list.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise) - - const hook = renderHook( - ({ activeFile }) => useMarkdownDocuments(activeFile, true, 'source', vi.fn()), - { initialProps: { activeFile: file('first') } } - ) - await waitFor(() => expect(mocks.list).toHaveBeenCalledTimes(1)) - - hook.rerender({ activeFile: file('second') }) - await waitFor(() => expect(mocks.list).toHaveBeenCalledTimes(2)) - - await act(async () => { - first.reject(new MarkdownDocumentListingCapacityError()) - second.resolve([]) - await Promise.allSettled([first.promise, second.promise]) - }) - - expect(mocks.toastError).not.toHaveBeenCalled() - }) -}) diff --git a/src/renderer/src/components/editor/useMarkdownDocuments.ts b/src/renderer/src/components/editor/useMarkdownDocuments.ts index 842f826a62a..5e8e5a17fb8 100644 --- a/src/renderer/src/components/editor/useMarkdownDocuments.ts +++ b/src/renderer/src/components/editor/useMarkdownDocuments.ts @@ -1,10 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { toast } from 'sonner' import type { MarkdownDocument } from '../../../../shared/types' -import { - isMarkdownDocumentListingCapacityError, - MARKDOWN_DOCUMENT_LISTING_ERROR_MESSAGE -} from '../../../../shared/markdown-document-listing-limits' import { useAppStore } from '@/store' import { getConnectionId } from '@/lib/connection-context' import { statRuntimePath } from '@/runtime/runtime-file-client' @@ -17,10 +12,6 @@ import { } from './markdown-doc-links' import { selectMarkdownDocumentWorktreePath } from './markdown-document-worktree-path-selector' import { requestSharedMarkdownDocumentList } from './markdown-document-list-request' -import { - retainMarkdownDocumentWorktreeSnapshot, - type MarkdownDocumentWorktreeSnapshot -} from './markdown-document-worktree-retention' type OpenMarkdownDocumentOptions = { anchor?: string | null @@ -69,8 +60,8 @@ export function useMarkdownDocuments( const openFile = useAppStore((s) => s.openFile) const openMarkdownPreview = useAppStore((s) => s.openMarkdownPreview) const [markdownDocumentsByWorktree, setMarkdownDocumentsByWorktree] = useState< - Map - >(() => new Map()) + Record + >({}) const requestRef = useRef(0) const connectionId = getConnectionId(worktreeId) @@ -100,20 +91,17 @@ export function useMarkdownDocuments( if (requestRef.current !== requestId) { return } - setMarkdownDocumentsByWorktree((prev) => - retainMarkdownDocumentWorktreeSnapshot(prev, worktreeId, documents) - ) + setMarkdownDocumentsByWorktree((prev) => ({ + ...prev, + [worktreeId]: documents + })) } catch (err) { console.error('Failed to list markdown documents:', err) if (requestRef.current === requestId) { - if (isMarkdownDocumentListingCapacityError(err)) { - toast.error(MARKDOWN_DOCUMENT_LISTING_ERROR_MESSAGE, { - id: `markdown-document-listing-capacity:${worktreeId}` - }) - } - setMarkdownDocumentsByWorktree((prev) => - retainMarkdownDocumentWorktreeSnapshot(prev, worktreeId, []) - ) + setMarkdownDocumentsByWorktree((prev) => ({ + ...prev, + [worktreeId]: [] + })) } } }, @@ -194,7 +182,7 @@ export function useMarkdownDocuments( }, [activeFile.id, isMarkdown, viewMode, refreshMarkdownDocuments]) const markdownDocuments = useMemo( - () => (worktreeId ? (markdownDocumentsByWorktree.get(worktreeId)?.documents ?? []) : []), + () => (worktreeId ? (markdownDocumentsByWorktree[worktreeId] ?? []) : []), [worktreeId, markdownDocumentsByWorktree] ) diff --git a/src/renderer/src/components/github-item-dialog-source-boundary.test.ts b/src/renderer/src/components/github-item-dialog-source-boundary.test.ts index 697a28397f6..6eaab34ec3e 100644 --- a/src/renderer/src/components/github-item-dialog-source-boundary.test.ts +++ b/src/renderer/src/components/github-item-dialog-source-boundary.test.ts @@ -81,25 +81,24 @@ describe('GitHubItemDialog source host boundaries', () => { it('uses source-aware details routing and cache identity', () => { const source = componentSource('GitHubItemDialog.tsx') - const cacheSource = componentSource('../lib/github-work-item-details-cache.ts') const cacheKeySection = sourceBetween( - cacheSource, - 'export function getWorkItemDetailsCacheKey', - 'export function getWorkItemDetailsCacheEntry' + source, + 'function getWorkItemDetailsCacheKey', + 'function touchWorkItemDetailsCache' ) const matchInvalidationSection = sourceBetween( - cacheSource, - 'export function invalidateWorkItemDetailsCacheByMatch', - 'export function clearWorkItemDetailsCacheForTests' + source, + 'function invalidateWorkItemDetailsCacheByMatch', + 'function patchCachedPRFileViewedState' ) expect(source).toContain('lookupGitHubWorkItemDetailsForSource({') expect(source).toContain('sourceContext,') expect(cacheKeySection).toContain('sourceCacheScope') expect(source).toContain('getTaskSourceCacheScope(sourceContext)') - expect(source).toContain('useWorkItemDetailsCacheEntry(detailsCacheKey)') - expect(source).not.toContain('new Map') - expect(matchInvalidationSection).toContain('cacheGeneration += 1') + expect(matchInvalidationSection).toContain( + 'if (removed) {\n workItemDetailsCacheGeneration += 1' + ) }) it('treats null details as unavailable while preserving empty detail payloads', () => { @@ -116,8 +115,6 @@ describe('GitHubItemDialog source host boundaries', () => { expect(resultSection).toContain('} else if (result === null) {') expect(resultSection).toContain('error: WORK_ITEM_DETAILS_UNAVAILABLE_MESSAGE') expect(resultSection).toContain('details: result') - expect(resultSection).toContain('getWorkItemDetailsCacheGeneration() !== launchedAtGeneration') - expect(resultSection).toContain('prev?.pending !== inflight') }) it('routes PR file viewed mutations through the task source context', () => { @@ -163,7 +160,6 @@ describe('GitHubItemDialog source host boundaries', () => { it('routes PR file contents and runtime viewed invalidations through the task source context', () => { const source = componentSource('GitHubItemDialog.tsx') - const cacheSource = componentSource('../lib/github-work-item-details-cache.ts') const fileContentsSection = sourceBetween( source, 'function loadPRFileContents', @@ -174,11 +170,7 @@ describe('GitHubItemDialog source host boundaries', () => { 'function getPRFileContentCacheKey', 'function loadPRFileContents' ) - const listenerSection = sourceBetween( - cacheSource, - 'let workItemMutatedUnsub', - "if (typeof import.meta !== 'undefined'" - ) + const listenerSection = sourceBetween(source, 'let workItemMutatedUnsub', '// Why: bounded LRU') expect(fileContentsCacheKeySection).toContain( 'source:${getTaskSourceCacheScope(args.sourceContext)}' @@ -190,7 +182,6 @@ describe('GitHubItemDialog source host boundaries', () => { expect(fileContentsSection).toContain('sourceContext: args.sourceContext') expect(fileContentsSection).toContain('sourceContext,') expect(listenerSection).toContain('onGitHubWorkItemDetailsCacheMutation') - expect(listenerSection).toContain('invalidateWorkItemDetailsCacheByMatch') expect(source).toContain('emitGitHubWorkItemDetailsCacheMutation(args)') expect(source).toContain('options.local !== false') expect(source).toContain('notifyWorkItemMutated({') diff --git a/src/renderer/src/components/native-chat/native-chat-composer-scope-cache.test.ts b/src/renderer/src/components/native-chat/native-chat-composer-scope-cache.test.ts index 08b58a4cd27..07867e8a5c4 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-scope-cache.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-scope-cache.test.ts @@ -1,173 +1,35 @@ -import { describe, expect, it } from 'vitest' +import { describe, it, expect } from 'vitest' import { NATIVE_CHAT_COMPOSER_SCOPE_CACHE_MAX, - createNativeChatScopeCacheController + setBoundedScopeCacheEntry } from './native-chat-composer-scope-cache' -function createController( - overrides: Partial[0]> = {} -): ReturnType { - return createNativeChatScopeCacheController({ - maxEntriesPerCache: 128, - maxAggregateBytes: 100, - maxValueBytes: 80, - maxKeyBytes: 20, - ...overrides - }) -} - -describe('native-chat scope cache retention', () => { - it('retains values and keys exactly at their individual limits', () => { - const controller = createController({ maxValueBytes: 4, maxKeyBytes: 4 }) - const valueCache = new Map() - const keyCache = new Map() - - expect(controller.set(valueCache, 'v', '1234')).toBe(true) - expect(controller.set(keyCache, '1234', 'v')).toBe(true) - expect(controller.get(valueCache, 'v')).toBe('1234') - expect(controller.get(keyCache, '1234')).toBe('v') - }) - - it('rejects a key or value one byte over its limit', () => { - const controller = createController({ maxValueBytes: 4, maxKeyBytes: 4 }) - const cache = new Map() - - expect(controller.set(cache, '12345', 'v')).toBe(false) - expect(controller.set(cache, 'v', '12345')).toBe(false) - expect(cache.size).toBe(0) - expect(controller.getRetainedBytes()).toBe(0) - }) - - it('measures multibyte scope keys as UTF-8', () => { - const controller = createController({ maxKeyBytes: 4 }) - const cache = new Map() - - expect(controller.set(cache, '😀', 'exact')).toBe(true) - expect(controller.set(cache, '😀a', 'too large')).toBe(false) - expect(cache.has('😀')).toBe(true) - expect(cache.has('😀a')).toBe(false) - }) - - it('retains an entry exactly at the aggregate limit', () => { - const controller = createController({ - maxAggregateBytes: 5, - maxValueBytes: 5, - maxKeyBytes: 1 - }) - const cache = new Map() - - expect(controller.set(cache, 'k', '1234')).toBe(true) - expect(controller.getRetainedBytes()).toBe(5) - expect(controller.get(cache, 'k')).toBe('1234') - }) - - it('rejects one entry larger than the aggregate limit', () => { - const controller = createController({ - maxAggregateBytes: 5, - maxValueBytes: 5, - maxKeyBytes: 1 - }) - const cache = new Map() - - expect(controller.set(cache, 'k', '12345')).toBe(false) - expect(cache.size).toBe(0) - }) - - it('globally evicts the least-recently-used entry across caches', () => { - const controller = createController({ - maxAggregateBytes: 10, - maxValueBytes: 4, - maxKeyBytes: 1 - }) - const first = new Map() - const second = new Map() - const third = new Map() - - controller.set(first, 'a', '1111') - controller.set(second, 'b', '2222') - controller.set(third, 'c', '3333') - - expect(first.has('a')).toBe(false) - expect(second.get('b')).toBe('2222') - expect(third.get('c')).toBe('3333') - expect(controller.getRetainedBytes()).toBe(10) - }) - - it('refreshes global LRU order when an entry is read', () => { - const controller = createController({ - maxAggregateBytes: 10, - maxValueBytes: 4, - maxKeyBytes: 1 - }) - const first = new Map() - const second = new Map() - const third = new Map() - - controller.set(first, 'a', '1111') - controller.set(second, 'b', '2222') - expect(controller.get(first, 'a')).toBe('1111') - controller.set(third, 'c', '3333') - - expect(first.get('a')).toBe('1111') - expect(second.has('b')).toBe(false) - expect(third.get('c')).toBe('3333') - }) - - it('releases retained-byte accounting on delete and clear', () => { - const controller = createController() - const first = new Map() - const second = new Map() - - controller.set(first, 'a', '111') - controller.set(first, 'b', '22') - controller.set(second, 'c', '3') - expect(controller.getRetainedBytes()).toBe(9) - - controller.delete(first, 'a') - expect(controller.getRetainedBytes()).toBe(5) - controller.clear(first) - expect(controller.getRetainedBytes()).toBe(2) - controller.clear(second) - expect(controller.getRetainedBytes()).toBe(0) - }) - - it('removes a stale value when an update is inadmissible', () => { - const controller = createController({ maxValueBytes: 4 }) - const cache = new Map() - - controller.set(cache, 'key', 'old') - expect(controller.set(cache, 'key', '12345')).toBe(false) - - expect(controller.get(cache, 'key')).toBeUndefined() - expect(controller.getRetainedBytes()).toBe(0) - }) - - it('measures nested values without looping on repeated references', () => { - const controller = createController({ maxValueBytes: 64 }) - const cache = new Map() - const value: { label: string; self?: unknown } = { label: 'cycle' } - value.self = value - - expect(controller.set(cache, 'key', value)).toBe(true) - expect(controller.get(cache, 'key')).toBe(value) - }) - - it('preserves the 128-entry per-cache cap', () => { - const controller = createNativeChatScopeCacheController() +describe('setBoundedScopeCacheEntry', () => { + it('bounds the cache with LRU eviction, keeping re-set keys', () => { const cache = new Map() + setBoundedScopeCacheEntry(cache, 'keep', 1) - controller.set(cache, 'keep', 1) const total = NATIVE_CHAT_COMPOSER_SCOPE_CACHE_MAX + 20 - for (let index = 0; index < total; index += 1) { - controller.set(cache, `scope-${index}`, index) - if (index % 10 === 0) { - controller.set(cache, 'keep', 1) + for (let i = 0; i < total; i += 1) { + setBoundedScopeCacheEntry(cache, `scope-${i}`, i) + if (i % 10 === 0) { + setBoundedScopeCacheEntry(cache, 'keep', 1) } } expect(cache.size).toBe(NATIVE_CHAT_COMPOSER_SCOPE_CACHE_MAX) - expect(cache.has('scope-0')).toBe(false) - expect(cache.has('keep')).toBe(true) - expect(cache.has(`scope-${total - 1}`)).toBe(true) + expect(cache.has('scope-0')).toBe(false) // oldest untouched entry evicted + expect(cache.has('keep')).toBe(true) // periodically re-set → retained + expect(cache.has(`scope-${total - 1}`)).toBe(true) // most recent retained + }) + + it('moves a re-set key to most-recent and updates its value', () => { + const cache = new Map() + setBoundedScopeCacheEntry(cache, 'a', 1) + setBoundedScopeCacheEntry(cache, 'b', 2) + setBoundedScopeCacheEntry(cache, 'a', 3) + + expect([...cache.keys()]).toEqual(['b', 'a']) + expect(cache.get('a')).toBe(3) }) }) diff --git a/src/renderer/src/components/native-chat/native-chat-composer-scope-cache.ts b/src/renderer/src/components/native-chat/native-chat-composer-scope-cache.ts index 449b505d0da..fc26e303a9f 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-scope-cache.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-scope-cache.ts @@ -1,322 +1,24 @@ -import { measureUtf8ByteLength } from '../../../../shared/utf8-byte-limits' - +// Shared LRU bound for the native-chat composer's per-scope caches (draft text +// and image attachments), both keyed by stable pane identity. The +// caches exist so an in-progress message survives the composer unmounting on a +// TUI/GUI toggle, but a scope key for a permanently-removed pane is never +// revisited, so without a bound its unsent entry would linger for the renderer's +// whole session. delete-then-set keeps the actively-edited scope most-recent so +// eviction only sheds the oldest untouched scopes. export const NATIVE_CHAT_COMPOSER_SCOPE_CACHE_MAX = 128 -export const NATIVE_CHAT_SCOPE_CACHE_MAX_AGGREGATE_BYTES = 32 * 1024 * 1024 -export const NATIVE_CHAT_SCOPE_CACHE_MAX_VALUE_BYTES = 16 * 1024 * 1024 -export const NATIVE_CHAT_SCOPE_CACHE_MAX_KEY_BYTES = 4 * 1024 - -const CONTAINER_BYTES = 8 -const CONTAINER_ENTRY_BYTES = 8 - -export type NativeChatScopeCacheLimits = { - maxEntriesPerCache: number - maxAggregateBytes: number - maxValueBytes: number - maxKeyBytes: number -} - -export type NativeChatScopeCacheController = { - set(cache: Map, scopeKey: string, value: T): boolean - get(cache: Map, scopeKey: string): T | undefined - delete(cache: Map, scopeKey: string): boolean - clear(cache: Map): void - getRetainedBytes(): number -} - -type RetainedEntry = { - cacheIdentity: object - scopeKey: string - retainedBytes: number - deleteCachedValue: () => void -} - -type MeasurementFrame = - | { kind: 'value'; value: unknown } - | { kind: 'array'; value: readonly unknown[]; index: number } - | { - kind: 'record' - value: Record - keys: Generator - } - -const DEFAULT_LIMITS: NativeChatScopeCacheLimits = { - maxEntriesPerCache: NATIVE_CHAT_COMPOSER_SCOPE_CACHE_MAX, - maxAggregateBytes: NATIVE_CHAT_SCOPE_CACHE_MAX_AGGREGATE_BYTES, - maxValueBytes: NATIVE_CHAT_SCOPE_CACHE_MAX_VALUE_BYTES, - maxKeyBytes: NATIVE_CHAT_SCOPE_CACHE_MAX_KEY_BYTES -} - -function addMeasuredBytes( - currentBytes: number, - additionalBytes: number, - maxBytes: number -): { bytes: number; exceeded: boolean } { - const bytes = currentBytes + additionalBytes - return { bytes, exceeded: bytes > maxBytes } -} - -function measureRetainedString( - value: string, - currentBytes: number, - maxBytes: number -): { bytes: number; exceeded: boolean } { - const remaining = Math.max(0, maxBytes - currentBytes) - const measured = measureUtf8ByteLength(value, { stopAfterBytes: remaining }) - const next = addMeasuredBytes(currentBytes, measured.byteLength, maxBytes) - return { bytes: next.bytes, exceeded: measured.exceededLimit || next.exceeded } -} - -function isPlainRecord(value: object): value is Record { - const prototype = Object.getPrototypeOf(value) - return prototype === Object.prototype || prototype === null -} - -function* iterateOwnEnumerableKeys(value: Record): Generator { - for (const key in value) { - if (Object.hasOwn(value, key)) { - yield key - } - } -} - -function measureRetainedValue(value: unknown, maxBytes: number): number | null { - const seen = new WeakSet() - const frames: MeasurementFrame[] = [{ kind: 'value', value }] - let bytes = 0 - - while (frames.length > 0) { - const frame = frames.pop() - if (!frame) { - break - } - if (frame.kind === 'array') { - if (frame.index >= frame.value.length) { - continue - } - const index = frame.index - frame.index += 1 - frames.push(frame) - if (Object.hasOwn(frame.value, index)) { - frames.push({ kind: 'value', value: frame.value[index] }) - } - continue - } - if (frame.kind === 'record') { - const nextKey = frame.keys.next() - if (nextKey.done) { - continue - } - const entry = addMeasuredBytes(bytes, CONTAINER_ENTRY_BYTES, maxBytes) - if (entry.exceeded) { - return null - } - const keyMeasurement = measureRetainedString(nextKey.value, entry.bytes, maxBytes) - if (keyMeasurement.exceeded) { - return null - } - bytes = keyMeasurement.bytes - frames.push(frame) - frames.push({ kind: 'value', value: frame.value[nextKey.value] }) - continue - } - - const current = frame.value - if (current === null || current === undefined) { - continue - } - if (typeof current === 'string') { - const measured = measureRetainedString(current, bytes, maxBytes) - if (measured.exceeded) { - return null - } - bytes = measured.bytes - continue - } - if (typeof current === 'number') { - const measured = addMeasuredBytes(bytes, 8, maxBytes) - if (measured.exceeded) { - return null - } - bytes = measured.bytes - continue - } - if (typeof current === 'boolean') { - const measured = addMeasuredBytes(bytes, 1, maxBytes) - if (measured.exceeded) { - return null - } - bytes = measured.bytes - continue - } - if (typeof current !== 'object' || seen.has(current)) { - if (typeof current !== 'object') { - return null - } - continue - } - - seen.add(current) - const container = addMeasuredBytes(bytes, CONTAINER_BYTES, maxBytes) - if (container.exceeded) { - return null - } - bytes = container.bytes - if (Array.isArray(current)) { - const entries = addMeasuredBytes(bytes, current.length * CONTAINER_ENTRY_BYTES, maxBytes) - if (entries.exceeded) { - return null - } - bytes = entries.bytes - frames.push({ kind: 'array', value: current, index: 0 }) - continue - } - if (!isPlainRecord(current)) { - return null - } - frames.push({ kind: 'record', value: current, keys: iterateOwnEnumerableKeys(current) }) - } - - return bytes -} - -export function createNativeChatScopeCacheController( - limitOverrides: Partial = {} -): NativeChatScopeCacheController { - const limits = { ...DEFAULT_LIMITS, ...limitOverrides } - const entriesByCache = new Map>() - const globalLru = new Map() - let retainedBytes = 0 - - const removeEntry = (entry: RetainedEntry, deleteCachedValue: boolean): void => { - const cacheEntries = entriesByCache.get(entry.cacheIdentity) - cacheEntries?.delete(entry.scopeKey) - if (cacheEntries?.size === 0) { - entriesByCache.delete(entry.cacheIdentity) - } - if (globalLru.delete(entry)) { - retainedBytes -= entry.retainedBytes - } - if (deleteCachedValue) { - entry.deleteCachedValue() - } - } - - const evictOldest = (entries: Map): void => { - const oldest = entries.keys().next().value - if (oldest) { - removeEntry(oldest, true) - } - } - - const controller: NativeChatScopeCacheController = { - set: (cache: Map, scopeKey: string, value: T): boolean => { - controller.delete(cache, scopeKey) - const keyMeasurement = measureUtf8ByteLength(scopeKey, { - stopAfterBytes: limits.maxKeyBytes - }) - if (keyMeasurement.exceededLimit) { - return false - } - const valueBytes = measureRetainedValue(value, limits.maxValueBytes) - if (valueBytes === null) { - return false - } - const entryBytes = keyMeasurement.byteLength + valueBytes - if (entryBytes > limits.maxAggregateBytes) { - return false - } - - cache.set(scopeKey, value) - const cacheIdentity = cache as object - const cacheEntries = entriesByCache.get(cacheIdentity) ?? new Map() - entriesByCache.set(cacheIdentity, cacheEntries) - const entry: RetainedEntry = { - cacheIdentity, - scopeKey, - retainedBytes: entryBytes, - deleteCachedValue: () => { - cache.delete(scopeKey) - } - } - cacheEntries.set(scopeKey, entry) - globalLru.set(entry, true) - retainedBytes += entryBytes - - while (cacheEntries.size > limits.maxEntriesPerCache) { - const oldest = cacheEntries.values().next().value - if (!oldest) { - break - } - removeEntry(oldest, true) - } - while (retainedBytes > limits.maxAggregateBytes) { - evictOldest(globalLru) - } - return cacheEntries.get(scopeKey) === entry - }, - get: (cache: Map, scopeKey: string): T | undefined => { - const cacheEntries = entriesByCache.get(cache as object) - const entry = cacheEntries?.get(scopeKey) - if (!entry) { - return cache.get(scopeKey) - } - if (!cache.has(scopeKey)) { - removeEntry(entry, false) - return undefined - } - const value = cache.get(scopeKey) - cache.delete(scopeKey) - cache.set(scopeKey, value as T) - cacheEntries?.delete(scopeKey) - cacheEntries?.set(scopeKey, entry) - globalLru.delete(entry) - globalLru.set(entry, true) - return value - }, - delete: (cache: Map, scopeKey: string): boolean => { - const entry = entriesByCache.get(cache as object)?.get(scopeKey) - if (!entry) { - return cache.delete(scopeKey) - } - const hadValue = cache.has(scopeKey) - removeEntry(entry, true) - return hadValue - }, - clear: (cache: Map): void => { - const cacheEntries = entriesByCache.get(cache as object) - if (cacheEntries) { - for (const entry of cacheEntries.values()) { - removeEntry(entry, false) - } - } - cache.clear() - }, - getRetainedBytes: () => retainedBytes - } - return controller -} - -const sharedController = createNativeChatScopeCacheController() export function setBoundedScopeCacheEntry( cache: Map, scopeKey: string, value: T -): boolean { - return sharedController.set(cache, scopeKey, value) -} - -export function getBoundedScopeCacheEntry( - cache: Map, - scopeKey: string -): T | undefined { - return sharedController.get(cache, scopeKey) -} - -export function deleteBoundedScopeCacheEntry(cache: Map, scopeKey: string): boolean { - return sharedController.delete(cache, scopeKey) -} - -export function clearBoundedScopeCache(cache: Map): void { - sharedController.clear(cache) +): void { + cache.delete(scopeKey) + cache.set(scopeKey, value) + while (cache.size > NATIVE_CHAT_COMPOSER_SCOPE_CACHE_MAX) { + const oldest = cache.keys().next().value + if (oldest === undefined) { + break + } + cache.delete(oldest) + } } diff --git a/src/renderer/src/components/native-chat/native-chat-draft-cache.test.ts b/src/renderer/src/components/native-chat/native-chat-draft-cache.test.ts index 1ea6b183090..7aa0f658517 100644 --- a/src/renderer/src/components/native-chat/native-chat-draft-cache.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-draft-cache.test.ts @@ -4,11 +4,7 @@ import { readNativeChatDraftCache, writeNativeChatDraftCache } from './native-chat-draft-cache' -import { - NATIVE_CHAT_COMPOSER_SCOPE_CACHE_MAX, - NATIVE_CHAT_SCOPE_CACHE_MAX_KEY_BYTES, - NATIVE_CHAT_SCOPE_CACHE_MAX_VALUE_BYTES -} from './native-chat-composer-scope-cache' +import { NATIVE_CHAT_COMPOSER_SCOPE_CACHE_MAX } from './native-chat-composer-scope-cache' afterEach(() => { clearNativeChatDraftCacheForTests() @@ -32,28 +28,6 @@ describe('native-chat draft cache', () => { expect(readNativeChatDraftCache('pty-1')).toBe('') }) - it('retains a draft exactly at the production value limit', () => { - const draft = 'x'.repeat(NATIVE_CHAT_SCOPE_CACHE_MAX_VALUE_BYTES) - - writeNativeChatDraftCache('pty-1', draft) - - expect(readNativeChatDraftCache('pty-1')).toBe(draft) - }) - - it('does not retain a draft one byte over the production value limit', () => { - writeNativeChatDraftCache('pty-1', 'x'.repeat(NATIVE_CHAT_SCOPE_CACHE_MAX_VALUE_BYTES + 1)) - - expect(readNativeChatDraftCache('pty-1')).toBe('') - }) - - it('does not retain a draft under an oversized multibyte scope key', () => { - const scopeKey = '😀'.repeat(NATIVE_CHAT_SCOPE_CACHE_MAX_KEY_BYTES / 4 + 1) - - writeNativeChatDraftCache(scopeKey, 'still available to the mounted composer') - - expect(readNativeChatDraftCache(scopeKey)).toBe('') - }) - it('bounds the cache so unsent drafts for removed panes cannot accumulate', () => { writeNativeChatDraftCache('keep', 'hot') diff --git a/src/renderer/src/components/native-chat/native-chat-draft-cache.ts b/src/renderer/src/components/native-chat/native-chat-draft-cache.ts index 6454e99c8f9..07e33774de9 100644 --- a/src/renderer/src/components/native-chat/native-chat-draft-cache.ts +++ b/src/renderer/src/components/native-chat/native-chat-draft-cache.ts @@ -4,24 +4,19 @@ // draft would be lost on every TUI/GUI round-trip. Mirrors the attachment cache // so both halves of an unsent message survive toggles and reconnects. -import { - clearBoundedScopeCache, - deleteBoundedScopeCacheEntry, - getBoundedScopeCacheEntry, - setBoundedScopeCacheEntry -} from './native-chat-composer-scope-cache' +import { setBoundedScopeCacheEntry } from './native-chat-composer-scope-cache' const draftCache = new Map() export function readNativeChatDraftCache(scopeKey: string): string { - return getBoundedScopeCacheEntry(draftCache, scopeKey) ?? '' + return draftCache.get(scopeKey) ?? '' } export function writeNativeChatDraftCache(scopeKey: string, draft: string): void { // An empty draft carries no state worth retaining; drop the entry so a stale // scope key never resurrects cleared text. if (draft === '') { - deleteBoundedScopeCacheEntry(draftCache, scopeKey) + draftCache.delete(scopeKey) return } // LRU-bounded so unsent drafts for permanently-removed panes can't accumulate. @@ -29,5 +24,5 @@ export function writeNativeChatDraftCache(scopeKey: string, draft: string): void } export function clearNativeChatDraftCacheForTests(): void { - clearBoundedScopeCache(draftCache) + draftCache.clear() } diff --git a/src/renderer/src/components/native-chat/native-chat-pending.test.ts b/src/renderer/src/components/native-chat/native-chat-pending.test.ts index 20137861ef6..b0cea78417d 100644 --- a/src/renderer/src/components/native-chat/native-chat-pending.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-pending.test.ts @@ -21,7 +21,6 @@ import { type NativeChatPendingSend } from './native-chat-pending' import { stripNoiseMessages } from './native-chat-noise' -import { NATIVE_CHAT_SCOPE_CACHE_MAX_VALUE_BYTES } from './native-chat-composer-scope-cache' function userMessage(id: string, text: string): NativeChatMessage { return { @@ -418,36 +417,6 @@ describe('pending send cache', () => { expect(readPendingSendCache(scope)).toEqual([]) }) - - it('returns an oversized pending send for the mounted view without retaining it', () => { - clearPendingSendCacheForTests() - const scope = { paneKey: 'tab-a:leaf-a', agent: 'codex' } - const entry = pendingOf('p1', 'x'.repeat(NATIVE_CHAT_SCOPE_CACHE_MAX_VALUE_BYTES)) - - const current = writePendingSendCache(scope, [entry]) - - expect(current).toEqual([entry]) - expect(readPendingSendCache(scope)).toEqual([]) - }) - - it('still caps an admissible pending-send list to the latest eight', () => { - clearPendingSendCacheForTests() - const scope = { paneKey: 'tab-a:leaf-a', agent: 'codex' } - const pending = Array.from({ length: 10 }, (_, index) => - pendingOf(`p${index}`, `prompt ${index}`) - ) - - expect(writePendingSendCache(scope, pending).map(({ id }) => id)).toEqual([ - 'p2', - 'p3', - 'p4', - 'p5', - 'p6', - 'p7', - 'p8', - 'p9' - ]) - }) }) describe('isPendingMessageId', () => { @@ -519,17 +488,6 @@ describe('command marker cache', () => { '/cmd-9' ]) }) - - it('returns an oversized marker for the mounted view without retaining it', () => { - clearCommandMarkerCacheForTests() - const scope = { paneKey: 'tab-a:leaf-a', agent: 'codex', sessionId: 'session-1' } - const command = 'x'.repeat(NATIVE_CHAT_SCOPE_CACHE_MAX_VALUE_BYTES) - - const current = appendCommandMarkerCache(scope, command, 10) - - expect(current).toEqual([{ id: '10-1', command, sentAt: 10 }]) - expect(readCommandMarkerCache(scope)).toEqual([]) - }) }) describe('applyCommandMarkerBoundaries', () => { diff --git a/src/renderer/src/components/native-chat/native-chat-pending.ts b/src/renderer/src/components/native-chat/native-chat-pending.ts index 177e0a54db3..466ec16cd8c 100644 --- a/src/renderer/src/components/native-chat/native-chat-pending.ts +++ b/src/renderer/src/components/native-chat/native-chat-pending.ts @@ -4,12 +4,7 @@ // rule (match on normalized user-message content) is unit-testable without React. import type { NativeChatMessage } from '../../../../shared/native-chat-types' -import { - clearBoundedScopeCache, - deleteBoundedScopeCacheEntry, - getBoundedScopeCacheEntry, - setBoundedScopeCacheEntry -} from './native-chat-composer-scope-cache' +import { setBoundedScopeCacheEntry } from './native-chat-composer-scope-cache' import type { NativeChatLaunchPrompt } from '@/lib/native-chat-launch-prompt' import { advancedNativeChatUserContentCounts, @@ -59,7 +54,7 @@ function pendingSendScopeKey(scope: NativeChatPendingSendScope): string { } export function readPendingSendCache(scope: NativeChatPendingSendScope): NativeChatPendingSend[] { - return [...(getBoundedScopeCacheEntry(pendingSendCache, pendingSendScopeKey(scope)) ?? [])] + return [...(pendingSendCache.get(pendingSendScopeKey(scope)) ?? [])] } export function writePendingSendCache( @@ -69,7 +64,7 @@ export function writePendingSendCache( const next = pending.slice(-PENDING_SEND_LIMIT) const key = pendingSendScopeKey(scope) if (next.length === 0) { - deleteBoundedScopeCacheEntry(pendingSendCache, key) + pendingSendCache.delete(key) } else { // Why: the empty-drain path above clears keys on the normal confirm flow, // but a pane closed with an unconfirmed send (agent crash / early close) @@ -89,7 +84,7 @@ export function appendPendingSendCache( } export function clearPendingSendCacheForTests(): void { - clearBoundedScopeCache(pendingSendCache) + pendingSendCache.clear() pendingSendCounter = 0 } @@ -309,7 +304,7 @@ function commandMarkerScopeKey(scope: NativeChatCommandMarkerScope): string { export function readCommandMarkerCache( scope: NativeChatCommandMarkerScope ): NativeChatCommandMarker[] { - return [...(getBoundedScopeCacheEntry(commandMarkerCache, commandMarkerScopeKey(scope)) ?? [])] + return [...(commandMarkerCache.get(commandMarkerScopeKey(scope)) ?? [])] } export function appendCommandMarkerCache( @@ -322,7 +317,7 @@ export function appendCommandMarkerCache( // Why: native/TUI view switches remount the chat surface, but slash commands // are not transcript turns, so their local feedback needs a pane-scoped cache. const next = [ - ...(getBoundedScopeCacheEntry(commandMarkerCache, key) ?? []), + ...(commandMarkerCache.get(key) ?? []), { id: `${sentAt}-${commandMarkerCounter}`, command, sentAt } ].slice(-COMMAND_MARKER_LIMIT) // Why: the per-key array is capped at 8, but the KEY (paneKey\0agent\0sessionId, @@ -334,7 +329,7 @@ export function appendCommandMarkerCache( } export function clearCommandMarkerCacheForTests(): void { - clearBoundedScopeCache(commandMarkerCache) + commandMarkerCache.clear() commandMarkerCounter = 0 } diff --git a/src/renderer/src/components/native-chat/native-chat-pty-session-options.test.ts b/src/renderer/src/components/native-chat/native-chat-pty-session-options.test.ts index b80cd20e65c..8475167186f 100644 --- a/src/renderer/src/components/native-chat/native-chat-pty-session-options.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-pty-session-options.test.ts @@ -5,7 +5,6 @@ import { seedNativeChatAppliedSessionOptions } from './native-chat-session-option-cache' import { createNativeChatPtySessionOptions } from './native-chat-pty-session-options' -import { NATIVE_CHAT_SCOPE_CACHE_MAX_VALUE_BYTES } from './native-chat-composer-scope-cache' describe('native chat PTY session options', () => { beforeEach(() => clearNativeChatSessionOptionCacheForTests()) @@ -49,28 +48,6 @@ describe('native chat PTY session options', () => { ) }) - it('keeps oversized reported options live without retaining them for remount', () => { - const model = 'x'.repeat(NATIVE_CHAT_SCOPE_CACHE_MAX_VALUE_BYTES) - const surface = createNativeChatPtySessionOptions({ - agent: 'claude', - scopeKey: 'pty-1', - mode: 'live', - reportedValues: { model }, - dispatchCommand: vi.fn() - })! - - expect(surface.getSnapshot()[0]?.kind).toMatchObject({ currentValue: model }) - expect(readNativeChatSessionOptionCache('pty-1')).toBeNull() - - const remounted = createNativeChatPtySessionOptions({ - agent: 'claude', - scopeKey: 'pty-1', - mode: 'live', - dispatchCommand: vi.fn() - })! - expect(remounted.getSnapshot()[0]?.kind).not.toHaveProperty('currentValue') - }) - it('restores launch-backed values through the tab-to-PTY cache handoff', () => { seedNativeChatAppliedSessionOptions('tab-1', 'claude', { model: 'opus', diff --git a/src/renderer/src/components/native-chat/native-chat-session-option-cache.ts b/src/renderer/src/components/native-chat/native-chat-session-option-cache.ts index 5eed444b4e4..ae9418cd650 100644 --- a/src/renderer/src/components/native-chat/native-chat-session-option-cache.ts +++ b/src/renderer/src/components/native-chat/native-chat-session-option-cache.ts @@ -3,11 +3,7 @@ import type { SessionOptionValue, SessionOptionValueSource } from '../../../../shared/native-chat-session-options' -import { - clearBoundedScopeCache, - getBoundedScopeCacheEntry, - setBoundedScopeCacheEntry -} from './native-chat-composer-scope-cache' +import { setBoundedScopeCacheEntry } from './native-chat-composer-scope-cache' export type TrackedNativeChatSessionOption = { value: SessionOptionValue @@ -47,9 +43,7 @@ export function readNativeChatSessionOptionCache( scopeKey: string, fallbackScopeKey?: string ): NativeChatSessionOptionRecord | null { - const record = - getBoundedScopeCacheEntry(sessionOptionCache, scopeKey) ?? - getBoundedScopeCacheEntry(sessionOptionCache, fallbackScopeKey ?? '') + const record = sessionOptionCache.get(scopeKey) ?? sessionOptionCache.get(fallbackScopeKey ?? '') return record ? cloneNativeChatSessionOptionRecord(record) : null } @@ -86,5 +80,5 @@ export function seedNativeChatAppliedSessionOptions( } export function clearNativeChatSessionOptionCacheForTests(): void { - clearBoundedScopeCache(sessionOptionCache) + sessionOptionCache.clear() } diff --git a/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.test.ts b/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.test.ts index 0bdc626dfc2..008d857ec9c 100644 --- a/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.test.ts @@ -3,8 +3,6 @@ import type { CatalogModel } from '../../../../shared/agent-session-option-catal import { clearNativeChatModelEnrichmentForTests, ensureNativeChatModelEnrichment, - NATIVE_CHAT_MODEL_ENRICHMENT_CACHE_MAX, - NATIVE_CHAT_MODEL_ENRICHMENT_PENDING_MAX, readNativeChatEnrichedModels, subscribeNativeChatEnrichedModels } from './native-chat-session-option-enrichment' @@ -12,12 +10,6 @@ import { describe('native chat session option enrichment', () => { beforeEach(() => clearNativeChatModelEnrichmentForTests()) - async function flushMicrotasks(): Promise { - for (let index = 0; index < 8; index += 1) { - await Promise.resolve() - } - } - it('keeps reads synchronous while one host-scoped probe is in flight', async () => { let resolveDiscovery: ((models: CatalogModel[]) => void) | undefined const discover = vi.fn( @@ -66,61 +58,4 @@ describe('native chat session option enrichment', () => { ensureNativeChatModelEnrichment({ agent: 'claude', hostKey: 'local', discover }) expect(discover).not.toHaveBeenCalled() }) - - it('evicts old host model arrays instead of retaining every host for the renderer lifetime', async () => { - for (let index = 0; index <= NATIVE_CHAT_MODEL_ENRICHMENT_CACHE_MAX; index += 1) { - ensureNativeChatModelEnrichment({ - agent: 'cursor', - hostKey: `ssh:${index}`, - discover: async () => [{ id: `host-model-${index}`, label: `Host ${index}`, options: [] }] - }) - await flushMicrotasks() - } - - expect(readNativeChatEnrichedModels('cursor', 'ssh:0')).toBeNull() - expect( - readNativeChatEnrichedModels('cursor', `ssh:${NATIVE_CHAT_MODEL_ENRICHMENT_CACHE_MAX}`) - ).toEqual( - expect.arrayContaining([ - expect.objectContaining({ id: `host-model-${NATIVE_CHAT_MODEL_ENRICHMENT_CACHE_MAX}` }) - ]) - ) - }) - - it('bounds hung host discovery promises without building a hidden probe queue', async () => { - const resolvers: ((models: CatalogModel[]) => void)[] = [] - const discovers = Array.from({ length: NATIVE_CHAT_MODEL_ENRICHMENT_PENDING_MAX + 1 }, () => - vi.fn( - () => - new Promise((resolve) => { - resolvers.push(resolve) - }) - ) - ) - - for (const [index, discover] of discovers.entries()) { - ensureNativeChatModelEnrichment({ agent: 'cursor', hostKey: `hung:${index}`, discover }) - } - await flushMicrotasks() - expect( - discovers - .slice(0, NATIVE_CHAT_MODEL_ENRICHMENT_PENDING_MAX) - .every((probe) => probe.mock.calls.length === 1) - ).toBe(true) - expect(discovers.at(-1)).not.toHaveBeenCalled() - - resolvers[0]?.([{ id: 'released', label: 'Released', options: [] }]) - await flushMicrotasks() - ensureNativeChatModelEnrichment({ - agent: 'cursor', - hostKey: `hung:${NATIVE_CHAT_MODEL_ENRICHMENT_PENDING_MAX}`, - discover: discovers.at(-1)! - }) - await flushMicrotasks() - expect(discovers.at(-1)).toHaveBeenCalledOnce() - for (const resolve of resolvers) { - resolve([]) - } - await flushMicrotasks() - }) }) diff --git a/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.ts b/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.ts index 5df3ce23bcd..5fde5625c52 100644 --- a/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.ts +++ b/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.ts @@ -12,27 +12,6 @@ type CatalogEnrichmentEntry = { } const enrichmentByAgentHost = new Map() -export const NATIVE_CHAT_MODEL_ENRICHMENT_CACHE_MAX = 128 -export const NATIVE_CHAT_MODEL_ENRICHMENT_PENDING_MAX = 8 -let pendingEnrichmentCount = 0 - -function rememberEnrichment(key: string, entry: CatalogEnrichmentEntry): void { - enrichmentByAgentHost.delete(key) - enrichmentByAgentHost.set(key, entry) - let inactiveEntries = Array.from(enrichmentByAgentHost.values()).filter( - (candidate) => candidate.state === 'settled' && candidate.listeners.size === 0 - ).length - while (inactiveEntries > NATIVE_CHAT_MODEL_ENRICHMENT_CACHE_MAX) { - const oldestInactive = Array.from(enrichmentByAgentHost).find( - ([, candidate]) => candidate.state === 'settled' && candidate.listeners.size === 0 - ) - if (!oldestInactive) { - break - } - enrichmentByAgentHost.delete(oldestInactive[0]) - inactiveEntries -= 1 - } -} function enrichmentKey(agent: AgentType, hostKey: string): string { return JSON.stringify([agent, hostKey]) @@ -42,12 +21,7 @@ export function readNativeChatEnrichedModels( agent: AgentType, hostKey: string ): CatalogModel[] | null { - const key = enrichmentKey(agent, hostKey) - const entry = enrichmentByAgentHost.get(key) - if (entry) { - rememberEnrichment(key, entry) - } - const models = entry?.models + const models = enrichmentByAgentHost.get(enrichmentKey(agent, hostKey))?.models return models ? [...models] : null } @@ -63,15 +37,8 @@ export function subscribeNativeChatEnrichedModels( listeners: new Set<(models: CatalogModel[]) => void>() } entry.listeners.add(listener) - rememberEnrichment(key, entry) - return () => { - entry.listeners.delete(listener) - if (entry.state === 'idle' && enrichmentByAgentHost.get(key) === entry) { - enrichmentByAgentHost.delete(key) - } else if (enrichmentByAgentHost.get(key) === entry) { - rememberEnrichment(key, entry) - } - } + enrichmentByAgentHost.set(key, entry) + return () => entry.listeners.delete(listener) } export function ensureNativeChatModelEnrichment(args: { @@ -86,10 +53,6 @@ export function ensureNativeChatModelEnrichment(args: { const key = enrichmentKey(args.agent, args.hostKey) const existing = enrichmentByAgentHost.get(key) if (existing?.state === 'pending' || existing?.state === 'settled') { - rememberEnrichment(key, existing) - return - } - if (pendingEnrichmentCount >= NATIVE_CHAT_MODEL_ENRICHMENT_PENDING_MAX) { return } const entry: CatalogEnrichmentEntry = existing ?? { @@ -98,23 +61,12 @@ export function ensureNativeChatModelEnrichment(args: { listeners: new Set() } entry.state = 'pending' - pendingEnrichmentCount += 1 - rememberEnrichment(key, entry) + enrichmentByAgentHost.set(key, entry) // Why: model discovery must never delay rendering or launching; the seed is // immediately usable while this once-per-host probe runs in the background. - let discovery: Promise - try { - discovery = args.discover() - } catch { - entry.state = 'settled' - if (enrichmentByAgentHost.get(key) === entry) { - rememberEnrichment(key, entry) - } - pendingEnrichmentCount = Math.max(0, pendingEnrichmentCount - 1) - return - } - void discovery + void args + .discover() .then((discovered) => { entry.state = 'settled' if (!discovered || discovered.length === 0) { @@ -128,15 +80,8 @@ export function ensureNativeChatModelEnrichment(args: { .catch(() => { entry.state = 'settled' }) - .finally(() => { - if (enrichmentByAgentHost.get(key) === entry) { - rememberEnrichment(key, entry) - } - pendingEnrichmentCount = Math.max(0, pendingEnrichmentCount - 1) - }) } export function clearNativeChatModelEnrichmentForTests(): void { enrichmentByAgentHost.clear() - pendingEnrichmentCount = 0 } diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.test.tsx index a4c199707c8..eb92f1746f6 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.test.tsx @@ -9,7 +9,6 @@ import { useNativeChatComposerAttachments } from './use-native-chat-composer-attachments' import type { NativeChatResolvedTarget } from './native-chat-composer-target' -import { NATIVE_CHAT_SCOPE_CACHE_MAX_VALUE_BYTES } from './native-chat-composer-scope-cache' vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback @@ -114,23 +113,6 @@ describe('useNativeChatComposerAttachments', () => { act(() => second.root.unmount()) }) - it('keeps an oversized attachment in the mounted view without retaining it', async () => { - const first = await renderProbe('pty-1') - const path = `${'x'.repeat(NATIVE_CHAT_SCOPE_CACHE_MAX_VALUE_BYTES)}.png` - - await act(async () => { - first.latest().attachResolvedPaths([path]) - }) - - expect(first.latest().imageAttachments).toMatchObject([{ path }]) - expect(readNativeChatAttachmentCache('pty-1')).toEqual([]) - - act(() => first.root.unmount()) - const second = await renderProbe('pty-1') - expect(second.latest().imageAttachments).toEqual([]) - act(() => second.root.unmount()) - }) - it('removes an attached image chip cleanly', async () => { const probe = await renderProbe('pty-1') await act(async () => { diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts index f3d81d6ecfa..60ecf44ff5a 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts @@ -7,12 +7,7 @@ import { type NativeChatResolvedTarget } from './native-chat-composer-target' import type { NativeChatComposerImageAttachment } from './NativeChatComposerField' -import { - clearBoundedScopeCache, - deleteBoundedScopeCacheEntry, - getBoundedScopeCacheEntry, - setBoundedScopeCacheEntry -} from './native-chat-composer-scope-cache' +import { setBoundedScopeCacheEntry } from './native-chat-composer-scope-cache' export type UseNativeChatComposerAttachmentsArgs = { attachmentScopeKey: string @@ -151,7 +146,7 @@ const attachmentCache = new Map() export function readNativeChatAttachmentCache( scopeKey: string ): NativeChatComposerImageAttachment[] { - return [...(getBoundedScopeCacheEntry(attachmentCache, scopeKey) ?? [])] + return [...(attachmentCache.get(scopeKey) ?? [])] } function writeNativeChatAttachmentCache( @@ -159,7 +154,7 @@ function writeNativeChatAttachmentCache( attachments: readonly NativeChatComposerImageAttachment[] ): void { if (attachments.length === 0) { - deleteBoundedScopeCacheEntry(attachmentCache, scopeKey) + attachmentCache.delete(scopeKey) return } // LRU-bounded so pending attachments for permanently-removed panes can't accumulate. @@ -167,5 +162,5 @@ function writeNativeChatAttachmentCache( } export function clearNativeChatAttachmentCacheForTests(): void { - clearBoundedScopeCache(attachmentCache) + attachmentCache.clear() } diff --git a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx index f5b6234a578..a655d98d1e7 100644 --- a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx +++ b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx @@ -83,7 +83,6 @@ import { } from '../../../../shared/task-source-context' import { parseExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' import { githubRepoIdentityKey } from '../../../../shared/github-repository-identity-key' -import { mapWithConcurrency } from '../../../../shared/map-with-concurrency' import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' import { getGitHubRuntimeRepoId, @@ -128,7 +127,6 @@ export type SmartWorkspaceNameSelection = { const SEARCH_DEBOUNCE_MS = 200 const RESULT_LIMIT = 12 -export const PROJECT_GROUP_LOOKUP_CONCURRENCY = 4 export function canUseGitLabSmartSource({ localGitlabAvailable, @@ -672,10 +670,8 @@ export default function SmartWorkspaceNameField({ type: directLink.type } : { kind: 'hash-number' as const, number: directNumber } - const request = mapWithConcurrency( - repoBackedSearchTargets, - PROJECT_GROUP_LOOKUP_CONCURRENCY, - (target) => + const request = Promise.all( + repoBackedSearchTargets.map((target) => lookupSmartGitHubSubmitItem({ repoPath: target.repo.path, repoId: target.repo.id, @@ -684,6 +680,7 @@ export default function SmartWorkspaceNameField({ workItem: lookupGitHubWorkItemForSource, workItemByOwnerRepo: lookupGitHubWorkItemByOwnerRepoForSource }).catch(() => null) + ) ).then((items) => items .filter((item): item is GitHubWorkItem => item !== null) @@ -927,17 +924,19 @@ export default function SmartWorkspaceNameField({ } let stale = false setGitlabLoading(true) - void mapWithConcurrency(repoBackedSearchTargets, PROJECT_GROUP_LOOKUP_CONCURRENCY, (target) => - lookupGitLabWorkItemByPathForSource({ - repoPath: target.repo.path, - repoId: target.repo.id, - sourceContext: target.gitlabSourceContext, - // Why: self-hosted GitLab URLs must resolve against their pasted hostname, not gitlab.com. - host: parsedGlLink.slug.host, - path: parsedGlLink.slug.path, - iid: parsedGlLink.number, - type: parsedGlLink.type - }).catch(() => null) + void Promise.all( + repoBackedSearchTargets.map((target) => + lookupGitLabWorkItemByPathForSource({ + repoPath: target.repo.path, + repoId: target.repo.id, + sourceContext: target.gitlabSourceContext, + // Why: self-hosted GitLab URLs must resolve against their pasted hostname, not gitlab.com. + host: parsedGlLink.slug.host, + path: parsedGlLink.slug.path, + iid: parsedGlLink.number, + type: parsedGlLink.type + }).catch(() => null) + ) ) .then((items) => { if (stale) { @@ -982,16 +981,18 @@ export default function SmartWorkspaceNameField({ setGitlabLoading(true) // Why: thread the typed query so the GitLab API filters MRs by name/number (shouldQueryGitlab already gates oversized queries). const trimmedQuery = debouncedQuery.trim() || undefined - void mapWithConcurrency(repoBackedSearchTargets, PROJECT_GROUP_LOOKUP_CONCURRENCY, (target) => - listGitLabMRsForSource({ - repoPath: target.repo.path, - repoId: target.repo.id, - sourceContext: target.gitlabSourceContext, - state: mrStateFilter, - page: 1, - perPage: RESULT_LIMIT, - query: trimmedQuery - }).catch(() => ({ items: [], hasMore: false })) + void Promise.all( + repoBackedSearchTargets.map((target) => + listGitLabMRsForSource({ + repoPath: target.repo.path, + repoId: target.repo.id, + sourceContext: target.gitlabSourceContext, + state: mrStateFilter, + page: 1, + perPage: RESULT_LIMIT, + query: trimmedQuery + }).catch(() => ({ items: [], hasMore: false })) + ) ) .then((results) => { if (stale) { diff --git a/src/renderer/src/components/pet/custom-pet-bundle-processing.ts b/src/renderer/src/components/pet/custom-pet-bundle-processing.ts deleted file mode 100644 index 512e08a852a..00000000000 --- a/src/renderer/src/components/pet/custom-pet-bundle-processing.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { isCustomPetSheetSizeSafe } from '../../../../shared/custom-pet-media-limits' -import { detectFramesFromImageData } from './sprite-frame-detection' -import type { DetectedSpriteCacheEntry } from './custom-pet-media-types' - -export type ProcessedCustomPetBundle = - | { kind: 'rejected' } - | { - kind: 'processed' - url: string - detected: DetectedSpriteCacheEntry | null - retainedBytes: number - } - -function closeDetectedSprite(entry: DetectedSpriteCacheEntry | null): void { - for (const bitmap of entry?.bitmaps ?? []) { - bitmap.close() - } -} - -function estimateDetectedSpriteBytes(entry: DetectedSpriteCacheEntry | null): number { - if (!entry) { - return 0 - } - return entry.frames.reduce((bytes, frame) => bytes + frame.w * frame.h * 4, 0) -} - -export async function processCustomPetBundleSheet( - srcUrl: string, - spriteFps?: number, - skipDetection?: boolean -): Promise { - let detected: DetectedSpriteCacheEntry | null = null - try { - const img = await loadImage(srcUrl) - if (!isCustomPetSheetSizeSafe(img.naturalWidth, img.naturalHeight)) { - return { kind: 'rejected' } - } - const canvas = document.createElement('canvas') - canvas.width = img.naturalWidth - canvas.height = img.naturalHeight - const ctx = canvas.getContext('2d') - if (!ctx) { - return null - } - ctx.drawImage(img, 0, 0) - const data = ctx.getImageData(0, 0, canvas.width, canvas.height) - keyMagenta(data.data) - ctx.putImageData(data, 0, 0) - // Why: detection needs keyed transparent gutters; manifest-backed sheets - // skip bitmap crops because CSS reads the declared grid directly. - const sprite = skipDetection ? null : detectFramesFromImageData(data) - if (sprite && sprite.frames.length >= 1) { - const results = await Promise.allSettled( - sprite.frames.map((frame) => createImageBitmap(canvas, frame.x, frame.y, frame.w, frame.h)) - ) - const rejected = results.some((result) => result.status === 'rejected') - if (rejected) { - // Why: a partial crop failure still permits the keyed static sheet, - // but every successful orphan bitmap must be closed first. - for (const result of results) { - if (result.status === 'fulfilled') { - result.value.close() - } - } - } else { - const bitmaps = results.map( - (result) => (result as PromiseFulfilledResult).value - ) - detected = { frames: sprite.frames, bitmaps, fps: spriteFps ?? 8 } - } - } - const output = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png')) - if (!output) { - closeDetectedSprite(detected) - return null - } - try { - return { - kind: 'processed', - url: URL.createObjectURL(output), - detected, - retainedBytes: output.size + estimateDetectedSpriteBytes(detected) - } - } catch { - closeDetectedSprite(detected) - return null - } - } catch { - closeDetectedSprite(detected) - return null - } -} - -// Why: compressed magenta keys leave gradient halos, so clear strongly -// magenta pixels and proportionally fade the antialiased edge family. -function magentaScore(red: number, green: number, blue: number): number { - const minimumRedBlue = Math.min(red, blue) - if (green >= minimumRedBlue) { - return 0 - } - const dominance = (minimumRedBlue - green) / 255 - return dominance <= 0.4 ? 0 : Math.max(0, Math.min(1, dominance * 1.4)) -} - -function keyMagenta(pixels: Uint8ClampedArray): void { - for (let index = 0; index < pixels.length; index += 4) { - const score = magentaScore(pixels[index], pixels[index + 1], pixels[index + 2]) - if (score <= 0) { - continue - } - if (score >= 0.5) { - pixels[index] = 0 - pixels[index + 1] = 0 - pixels[index + 2] = 0 - pixels[index + 3] = 0 - continue - } - pixels[index + 3] = Math.round(pixels[index + 3] * Math.max(0, 1 - score * 2)) - } -} - -function loadImage(url: string): Promise { - return new Promise((resolve, reject) => { - const image = new Image() - image.onload = () => resolve(image) - image.onerror = () => reject(new Error('image load failed')) - image.src = url - }) -} diff --git a/src/renderer/src/components/pet/custom-pet-load-queue.test.ts b/src/renderer/src/components/pet/custom-pet-load-queue.test.ts deleted file mode 100644 index e01398e03ee..00000000000 --- a/src/renderer/src/components/pet/custom-pet-load-queue.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - CustomPetLoadQueue, - MAX_CONCURRENT_CUSTOM_PET_LOADS, - MAX_PENDING_CUSTOM_PET_LOADS -} from './custom-pet-load-queue' - -function deferred(): { promise: Promise; resolve: () => void } { - let resolve!: () => void - const promise = new Promise((done) => { - resolve = done - }) - return { promise, resolve } -} - -describe('CustomPetLoadQueue', () => { - it('bounds active and retained pending loads', async () => { - const queue = new CustomPetLoadQueue() - const loads = Array.from({ length: MAX_PENDING_CUSTOM_PET_LOADS }, deferred) - const pending = loads.map((load) => queue.run(() => load.promise)) - - expect(queue.inspect()).toEqual({ - active: MAX_CONCURRENT_CUSTOM_PET_LOADS, - pending: MAX_PENDING_CUSTOM_PET_LOADS - }) - await expect(queue.run(() => Promise.resolve())).rejects.toThrow('Too many pending') - - loads.forEach((load) => load.resolve()) - await Promise.all(pending) - expect(queue.inspect()).toEqual({ active: 0, pending: 0 }) - }) -}) diff --git a/src/renderer/src/components/pet/custom-pet-load-queue.ts b/src/renderer/src/components/pet/custom-pet-load-queue.ts deleted file mode 100644 index 5fba96548dd..00000000000 --- a/src/renderer/src/components/pet/custom-pet-load-queue.ts +++ /dev/null @@ -1,53 +0,0 @@ -type QueuedCustomPetLoad = { - run: () => Promise - resolve: (value: unknown) => void - reject: (error: unknown) => void -} - -export const MAX_CONCURRENT_CUSTOM_PET_LOADS = 2 -export const MAX_PENDING_CUSTOM_PET_LOADS = 16 - -export class CustomPetLoadQueue { - private active = 0 - private pending = 0 - private readonly queue: QueuedCustomPetLoad[] = [] - - run(task: () => Promise): Promise { - if (this.pending >= MAX_PENDING_CUSTOM_PET_LOADS) { - return Promise.reject( - new Error(`Too many pending custom pet loads (max ${MAX_PENDING_CUSTOM_PET_LOADS}).`) - ) - } - this.pending += 1 - const promise = new Promise((resolve, reject) => { - this.queue.push({ run: task, resolve, reject }) - this.pump() - }) - return promise as Promise - } - - inspect(): { active: number; pending: number } { - return { active: this.active, pending: this.pending } - } - - private pump(): void { - while (this.active < MAX_CONCURRENT_CUSTOM_PET_LOADS) { - const next = this.queue.shift() - if (!next) { - return - } - this.active += 1 - let load: Promise - try { - load = next.run() - } catch (error) { - load = Promise.reject(error) - } - void load.then(next.resolve, next.reject).finally(() => { - this.active -= 1 - this.pending -= 1 - this.pump() - }) - } - } -} diff --git a/src/renderer/src/components/pet/custom-pet-media-types.ts b/src/renderer/src/components/pet/custom-pet-media-types.ts deleted file mode 100644 index d729cf13c4d..00000000000 --- a/src/renderer/src/components/pet/custom-pet-media-types.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { DetectedFrame } from './sprite-frame-detection' - -export type DetectedSpriteCacheEntry = { - frames: DetectedFrame[] - /** Per-frame image bitmaps drawn from the keyed canvas. */ - bitmaps: ImageBitmap[] - /** Manifest playback speed; the overlay defaults to 8 fps when absent. */ - fps: number -} diff --git a/src/renderer/src/components/pet/pet-blob-cache.test.ts b/src/renderer/src/components/pet/pet-blob-cache.test.ts index 9ac4629eecc..eb655fe62ff 100644 --- a/src/renderer/src/components/pet/pet-blob-cache.test.ts +++ b/src/renderer/src/components/pet/pet-blob-cache.test.ts @@ -2,14 +2,11 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { blobUrlCache, CUSTOM_PET_BLOB_CACHE_MAX, - CUSTOM_PET_MEDIA_CACHE_MAX_BYTES, detectedSpriteCache, - inspectCustomPetMediaCache, loadCustomBlobUrl, retainCustomPetBlobCacheEntry, revokeCustomPetBlobUrl } from './pet-blob-cache' -import { MAX_CUSTOM_PET_SHEET_PIXELS } from '../../../../shared/custom-pet-media-limits' const TEST_PET_IDS = ['pet', 'late-pet', 'bundle-pet'] @@ -46,7 +43,7 @@ describe('loadCustomBlobUrl', () => { expect(blobUrlCache.get('pet')).toBe('blob:pet') }) - it('does not create a blob URL after the custom pet was removed', async () => { + it('revokes a blob URL created after the custom pet was removed', async () => { let resolveRead: (buffer: ArrayBuffer) => void = () => {} const read = vi.fn( () => @@ -56,14 +53,14 @@ describe('loadCustomBlobUrl', () => { ) stubPetRead(read) vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:late-pet') - vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}) + const revokeObjectURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}) const load = loadCustomBlobUrl('late-pet', 'pet.png', 'image/png') revokeCustomPetBlobUrl('late-pet') resolveRead(new Uint8Array([4, 5, 6]).buffer) await expect(load).resolves.toBeNull() - expect(URL.createObjectURL).not.toHaveBeenCalled() + expect(revokeObjectURL).toHaveBeenCalledWith('blob:late-pet') expect(blobUrlCache.has('late-pet')).toBe(false) }) @@ -111,50 +108,6 @@ describe('loadCustomBlobUrl', () => { expect(detectedSpriteCache.has('bundle-pet')).toBe(false) }) - it('counts retained detected bitmaps alongside the processed blob', async () => { - stubPetRead(vi.fn().mockResolvedValue(new Uint8Array([7, 8, 9]).buffer)) - vi.spyOn(URL, 'createObjectURL') - .mockReturnValueOnce('blob:bundle-input') - .mockReturnValueOnce('blob:bundle-output') - vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}) - const bitmap = { close: vi.fn() } as unknown as ImageBitmap - vi.stubGlobal('createImageBitmap', vi.fn().mockResolvedValue(bitmap)) - vi.stubGlobal( - 'Image', - class { - naturalWidth = 8 - naturalHeight = 8 - onload: (() => void) | null = null - set src(_value: string) { - queueMicrotask(() => this.onload?.()) - } - } - ) - const pixels = new Uint8ClampedArray(8 * 8 * 4) - for (let index = 3; index < pixels.length; index += 4) { - pixels[index] = 255 - } - const output = new Blob([new Uint8Array(10)]) - const canvas = { - width: 0, - height: 0, - getContext: vi.fn(() => ({ - drawImage: vi.fn(), - getImageData: vi.fn(() => ({ data: pixels, width: 8, height: 8 }) as ImageData), - putImageData: vi.fn() - })), - toBlob: vi.fn((callback: BlobCallback) => callback(output)) - } as unknown as HTMLCanvasElement - vi.stubGlobal('document', { createElement: vi.fn(() => canvas) }) - - await expect( - loadCustomBlobUrl('bundle-counted', 'sheet.png', 'image/png', 'bundle') - ).resolves.toBe('blob:bundle-output') - - expect(inspectCustomPetMediaCache().retainedBytes).toBe(output.size + 8 * 8 * 4) - expect(detectedSpriteCache.get('bundle-counted')?.bitmaps).toEqual([bitmap]) - }) - it('evicts least-recent custom pet blobs and closes detected sprite bitmaps', async () => { const read = vi.fn().mockResolvedValue(new Uint8Array([1, 2, 3]).buffer) stubPetRead(read) @@ -186,54 +139,6 @@ describe('loadCustomBlobUrl', () => { expect(evictedBitmap.close).toHaveBeenCalledTimes(1) }) - it('evicts by aggregate encoded bytes before the entry-count cap', async () => { - const entryBytes = Math.floor(CUSTOM_PET_MEDIA_CACHE_MAX_BYTES / 2) + 1 - class SizedBlob { - size = entryBytes - } - vi.stubGlobal('Blob', SizedBlob) - stubPetRead(vi.fn().mockResolvedValue(new Uint8Array([1]).buffer)) - vi.spyOn(URL, 'createObjectURL') - .mockReturnValueOnce('blob:large-0') - .mockReturnValueOnce('blob:large-1') - const revokeObjectURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}) - - await expect(loadCustomBlobUrl('large-0', 'pet.png', 'image/png')).resolves.toBe('blob:large-0') - await expect(loadCustomBlobUrl('large-1', 'pet.png', 'image/png')).resolves.toBe('blob:large-1') - - expect(blobUrlCache.has('large-0')).toBe(false) - expect(blobUrlCache.get('large-1')).toBe('blob:large-1') - expect(inspectCustomPetMediaCache().retainedBytes).toBe(entryBytes) - expect(revokeObjectURL).toHaveBeenCalledWith('blob:large-0') - }) - - it('rejects an extreme bundle before allocating a canvas', async () => { - stubPetRead(vi.fn().mockResolvedValue(new Uint8Array([1]).buffer)) - vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:oversized-sheet') - const revokeObjectURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}) - vi.stubGlobal( - 'Image', - class { - naturalWidth = 2_048 - naturalHeight = Math.floor(MAX_CUSTOM_PET_SHEET_PIXELS / this.naturalWidth) + 1 - onload: (() => void) | null = null - set src(_value: string) { - queueMicrotask(() => this.onload?.()) - } - } - ) - const createElement = vi.fn() - vi.stubGlobal('document', { createElement }) - - await expect( - loadCustomBlobUrl('oversized-sheet', 'sheet.png', 'image/png', 'bundle') - ).resolves.toBeNull() - - expect(createElement).not.toHaveBeenCalled() - expect(revokeObjectURL).toHaveBeenCalledWith('blob:oversized-sheet') - expect(blobUrlCache.has('oversized-sheet')).toBe(false) - }) - it('does not let stale load completions evict retained active media', async () => { const resolvers = new Map void>() const read = vi.fn( @@ -247,30 +152,19 @@ describe('loadCustomBlobUrl', () => { vi.spyOn(URL, 'createObjectURL').mockImplementation(() => `blob:pet-${blobIndex++}`) const revokeObjectURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}) const releaseActive = retainCustomPetBlobCacheEntry('pet-16') - const activeLoad = loadCustomBlobUrl('pet-16', 'pet.png', 'image/png') - const staleLoads = Array.from({ length: 15 }, (_, index) => + const loads = Array.from({ length: 17 }, (_, index) => loadCustomBlobUrl(`pet-${index}`, 'pet.png', 'image/png') ) - const resolveLoad = async ( - id: string, - load: Promise, - value: number - ): Promise => { - while (!resolvers.has(id)) { - await Promise.resolve() - } - resolvers.get(id)!(new Uint8Array([value]).buffer) - resolvers.delete(id) - return load - } try { - const activeUrl = await resolveLoad('pet-16', activeLoad, 16) + resolvers.get('pet-16')!(new Uint8Array([16]).buffer) + const activeUrl = await loads[16] const activeBitmap = { close: vi.fn() } as unknown as ImageBitmap detectedSpriteCache.set('pet-16', { bitmaps: [activeBitmap], fps: 8, frames: [] }) - for (let index = 0; index < staleLoads.length; index += 1) { - await resolveLoad(`pet-${index}`, staleLoads[index], index) + for (let index = 0; index < 16; index += 1) { + resolvers.get(`pet-${index}`)!(new Uint8Array([index]).buffer) + await loads[index] } expect(blobUrlCache.get('pet-16')).toBe(activeUrl) @@ -278,13 +172,10 @@ describe('loadCustomBlobUrl', () => { expect(activeBitmap.close).not.toHaveBeenCalled() expect(blobUrlCache.size).toBe(CUSTOM_PET_BLOB_CACHE_MAX) - const overflowLoad = loadCustomBlobUrl('pet-17', 'pet.png', 'image/png') - await resolveLoad('pet-17', overflowLoad, 17) - expect(blobUrlCache.get('pet-16')).toBe(activeUrl) - releaseActive() - const nextLoad = loadCustomBlobUrl('pet-18', 'pet.png', 'image/png') - await resolveLoad('pet-18', nextLoad, 18) + const nextLoad = loadCustomBlobUrl('pet-17', 'pet.png', 'image/png') + resolvers.get('pet-17')!(new Uint8Array([17]).buffer) + await nextLoad expect(blobUrlCache.has('pet-16')).toBe(false) expect(revokeObjectURL).toHaveBeenCalledWith(activeUrl) @@ -294,7 +185,7 @@ describe('loadCustomBlobUrl', () => { } }) - it('rejects a retained overflow instead of exceeding the hard cache cap', async () => { + it('shrinks a temporarily retained overflow when a consumer releases', async () => { const read = vi.fn().mockResolvedValue(new Uint8Array([1, 2, 3]).buffer) stubPetRead(read) let blobIndex = 0 @@ -305,18 +196,14 @@ describe('loadCustomBlobUrl', () => { ) try { - for (let index = 0; index < CUSTOM_PET_BLOB_CACHE_MAX; index += 1) { + for (let index = 0; index < 17; index += 1) { await loadCustomBlobUrl(`retained-${index}`, 'pet.png', 'image/png') } - await expect( - loadCustomBlobUrl(`retained-${CUSTOM_PET_BLOB_CACHE_MAX}`, 'pet.png', 'image/png') - ).resolves.toBeNull() - expect(blobUrlCache.size).toBe(CUSTOM_PET_BLOB_CACHE_MAX) + expect(blobUrlCache.size).toBe(17) releases[0]() - await expect( - loadCustomBlobUrl(`retained-${CUSTOM_PET_BLOB_CACHE_MAX}`, 'pet.png', 'image/png') - ).resolves.toBe(`blob:retained-${CUSTOM_PET_BLOB_CACHE_MAX + 1}`) + + expect(blobUrlCache.size).toBe(CUSTOM_PET_BLOB_CACHE_MAX) expect(blobUrlCache.has('retained-0')).toBe(false) expect(revokeObjectURL).toHaveBeenCalledWith('blob:retained-0') } finally { diff --git a/src/renderer/src/components/pet/pet-blob-cache.ts b/src/renderer/src/components/pet/pet-blob-cache.ts index d570c2aa42d..59f0fdb529b 100644 --- a/src/renderer/src/components/pet/pet-blob-cache.ts +++ b/src/renderer/src/components/pet/pet-blob-cache.ts @@ -2,16 +2,7 @@ // without importing usePetUrl (which itself imports the store). Keeps // the dependency graph acyclic. -import { - CustomPetLoadQueue, - MAX_CONCURRENT_CUSTOM_PET_LOADS, - MAX_PENDING_CUSTOM_PET_LOADS -} from './custom-pet-load-queue' -import { processCustomPetBundleSheet } from './custom-pet-bundle-processing' -import type { DetectedSpriteCacheEntry } from './custom-pet-media-types' - -export { MAX_CONCURRENT_CUSTOM_PET_LOADS, MAX_PENDING_CUSTOM_PET_LOADS } -export type { DetectedSpriteCacheEntry } from './custom-pet-media-types' +import { detectFramesFromImageData, type DetectedFrame } from './sprite-frame-detection' // Why: sandbox=true + webSecurity=true block the renderer from reading user // files directly. For custom pet images we fetch the bytes over IPC and @@ -20,31 +11,22 @@ export type { DetectedSpriteCacheEntry } from './custom-pet-media-types' // doesn't re-fetch from main. export const blobUrlCache = new Map() export const CUSTOM_PET_BLOB_CACHE_MAX = 16 -export const CUSTOM_PET_MEDIA_CACHE_MAX_BYTES = 128 * 1024 * 1024 +export type DetectedSpriteCacheEntry = { + frames: DetectedFrame[] + /** Per-frame image bitmaps drawn from the keyed canvas. The overlay paints + * these onto its own canvas one at a time, so we can crop irregular sheets + * without forcing the manifest to declare a uniform grid. */ + bitmaps: ImageBitmap[] + /** Manifest-declared playback speed; the overlay falls back to 8 fps when + * the bundle didn't declare one. */ + fps: number +} export const detectedSpriteCache = new Map() const customPetBlobUrlLoads = new Map>() const customPetBlobCacheEpoch = new Map() const customPetBlobActiveLoadCounts = new Map() const customPetBlobRetainCounts = new Map() -const customPetMediaBytes = new Map() -const customPetLoadQueue = new CustomPetLoadQueue() -let retainedCustomPetMediaBytes = 0 - -export function inspectCustomPetMediaCache(): { - activeLoads: number - cachedEntries: number - pendingLoads: number - retainedBytes: number -} { - const loads = customPetLoadQueue.inspect() - return { - activeLoads: loads.active, - cachedEntries: blobUrlCache.size, - pendingLoads: loads.pending, - retainedBytes: retainedCustomPetMediaBytes - } -} export function retainCustomPetBlobCacheEntry(id: string): () => void { customPetBlobRetainCounts.set(id, (customPetBlobRetainCounts.get(id) ?? 0) + 1) @@ -94,32 +76,22 @@ export async function loadCustomBlobUrl( if (pending) { return pending } - // Why: reject before allocating tracking Maps/promises for overflow IDs; - // the queue's own guard remains the race-proof backstop. - if (customPetLoadQueue.inspect().pending >= MAX_PENDING_CUSTOM_PET_LOADS) { - return null - } const loadEpoch = customPetBlobCacheEpoch.get(id) ?? 0 incrementCustomPetBlobActiveLoadCount(id) - const load = customPetLoadQueue - .run(() => - loadCustomBlobUrlUncached( - id, - fileName, - mimeType, - kind, - spriteFps, - hasManifestSprite, - loadEpoch - ) - ) - .catch(() => null) - .finally(() => { - if (customPetBlobUrlLoads.get(id) === load) { - customPetBlobUrlLoads.delete(id) - } - decrementCustomPetBlobActiveLoadCount(id) - }) + const load = loadCustomBlobUrlUncached( + id, + fileName, + mimeType, + kind, + spriteFps, + hasManifestSprite, + loadEpoch + ).finally(() => { + if (customPetBlobUrlLoads.get(id) === load) { + customPetBlobUrlLoads.delete(id) + } + decrementCustomPetBlobActiveLoadCount(id) + }) customPetBlobUrlLoads.set(id, load) return load } @@ -147,14 +119,11 @@ async function loadCustomBlobUrlUncached( hasManifestSprite: boolean | undefined, loadEpoch: number ): Promise { - if ((customPetBlobCacheEpoch.get(id) ?? 0) !== loadEpoch) { - return null - } // Why: defensively clear any stale entry so we don't leak a prior blob URL // or ImageBitmap[] when re-populating after a cache miss. clearCustomPetBlobCacheEntry(id) const buffer = await window.api.pet.read(id, fileName, kind) - if (!buffer || (customPetBlobCacheEpoch.get(id) ?? 0) !== loadEpoch) { + if (!buffer) { return null } // Why: MIME comes from CustomPet.mimeType — required especially for @@ -163,7 +132,6 @@ async function loadCustomBlobUrlUncached( const blob = new Blob([buffer], { type: mimeType }) let url = URL.createObjectURL(blob) let detected: DetectedSpriteCacheEntry | null = null - let retainedBytes = blob.size // Why: pet bundles often ship spritesheets with a magenta chroma-key as // the background instead of true alpha (common in pixel-art tooling). // Strip it once at load and replace the cached URL with a transparent PNG @@ -173,16 +141,13 @@ async function loadCustomBlobUrlUncached( // renderer reads the `sprite` branch of usePetUrl and never touches // detectedSpriteCache — so skipping detection (and the per-frame // ImageBitmap allocations) avoids a per-bundle memory leak. - const processed = await processCustomPetBundleSheet(url, spriteFps, hasManifestSprite === true) - if (processed?.kind === 'rejected') { - URL.revokeObjectURL(url) - return null - } - if (processed?.kind === 'processed') { + const processed = await processBundleSheet(url, spriteFps, hasManifestSprite === true) + if (processed) { URL.revokeObjectURL(url) url = processed.url - detected = processed.detected - retainedBytes = processed.retainedBytes + if (processed.detected) { + detected = processed.detected + } } } if ((customPetBlobCacheEpoch.get(id) ?? 0) !== loadEpoch) { @@ -190,47 +155,27 @@ async function loadCustomBlobUrlUncached( closeDetectedSpriteCacheEntry(detected) return null } - if (!cacheCustomPetBlobUrl(id, url, detected, retainedBytes)) { - URL.revokeObjectURL(url) - closeDetectedSpriteCacheEntry(detected) - return null - } + cacheCustomPetBlobUrl(id, url, detected) return url } function cacheCustomPetBlobUrl( id: string, url: string, - detected: DetectedSpriteCacheEntry | null, - retainedBytes: number -): boolean { + detected: DetectedSpriteCacheEntry | null +): void { clearCustomPetBlobCacheEntry(id) - if (!Number.isSafeInteger(retainedBytes) || retainedBytes < 0) { - return false - } - evictInactiveCustomPetBlobUrls(retainedBytes, 1) - if ( - blobUrlCache.size >= CUSTOM_PET_BLOB_CACHE_MAX || - retainedCustomPetMediaBytes + retainedBytes > CUSTOM_PET_MEDIA_CACHE_MAX_BYTES - ) { - return false - } blobUrlCache.set(id, url) - customPetMediaBytes.set(id, retainedBytes) - retainedCustomPetMediaBytes += retainedBytes if (detected) { detectedSpriteCache.set(id, detected) } - return true + evictInactiveCustomPetBlobUrls() } -function evictInactiveCustomPetBlobUrls(incomingBytes = 0, incomingEntries = 0): void { +function evictInactiveCustomPetBlobUrls(): void { // Why: users can import many custom pets; inactive blob URLs and sprite // bitmaps should not stay resident for the whole renderer session. - while ( - blobUrlCache.size + incomingEntries > CUSTOM_PET_BLOB_CACHE_MAX || - retainedCustomPetMediaBytes + incomingBytes > CUSTOM_PET_MEDIA_CACHE_MAX_BYTES - ) { + while (blobUrlCache.size > CUSTOM_PET_BLOB_CACHE_MAX) { let evicted = false for (const id of blobUrlCache.keys()) { if (customPetBlobRetainCounts.has(id)) { @@ -246,6 +191,117 @@ function evictInactiveCustomPetBlobUrls(incomingBytes = 0, incomingEntries = 0): } } +async function processBundleSheet( + srcUrl: string, + spriteFps?: number, + skipDetection?: boolean +): Promise<{ url: string; detected: DetectedSpriteCacheEntry | null } | null> { + let detected: DetectedSpriteCacheEntry | null = null + try { + const img = await loadImage(srcUrl) + const canvas = document.createElement('canvas') + canvas.width = img.naturalWidth + canvas.height = img.naturalHeight + const ctx = canvas.getContext('2d') + if (!ctx) { + return null + } + ctx.drawImage(img, 0, 0) + const data = ctx.getImageData(0, 0, canvas.width, canvas.height) + keyMagenta(data.data) + ctx.putImageData(data, 0, 0) + // Why: detect frames *after* keying so transparent gutters between + // sprites are visible to the band/column scanner. Without this the whole + // sheet collapses into one giant frame. + const sprite = skipDetection ? null : detectFramesFromImageData(data) + if (sprite && sprite.frames.length >= 1) { + // Why: allSettled so a single failed crop doesn't leak the bitmaps that + // did succeed — close fulfilled ones before bailing out. + const results = await Promise.allSettled( + sprite.frames.map((f) => createImageBitmap(canvas, f.x, f.y, f.w, f.h)) + ) + const rejected = results.some((r) => r.status === 'rejected') + if (rejected) { + // Why: don't discard the keyed canvas when only the per-frame crops + // failed — fall through to emit the keyed PNG so the caller still gets + // the chroma-keyed sheet instead of falling back to the un-keyed url. + for (const r of results) { + if (r.status === 'fulfilled') { + r.value.close() + } + } + } else { + const bitmaps = results.map((r) => (r as PromiseFulfilledResult).value) + detected = { frames: sprite.frames, bitmaps, fps: spriteFps ?? 8 } + } + } + const out = await new Promise((res) => canvas.toBlob(res, 'image/png')) + if (!out) { + closeDetectedSpriteCacheEntry(detected) + return null + } + try { + return { url: URL.createObjectURL(out), detected } + } catch { + closeDetectedSpriteCacheEntry(detected) + return null + } + } catch { + closeDetectedSpriteCacheEntry(detected) + return null + } +} + +// Why: WebP/JPEG-compressed magenta keys leave wide gradient halos around +// each sprite, so a tight RGB-distance check leaves ugly fringing. We use a +// hue-style test instead: the magenta family has R and B much greater than +// G. Anything matching gets fully cleared; anything close gets proportional +// alpha so antialiased edges fade smoothly. +function magentaScore(r: number, g: number, b: number): number { + // 0 = not magenta, 1 = pure magenta key. Restricted to near-pure magenta + // (saturated R+B, very low G) so legitimate purples and pinks (e.g. + // 128,0,128 or 255,128,200) aren't keyed out of imported sprite art. + const minRB = Math.min(r, b) + if (g >= minRB) { + return 0 + } + const dom = (minRB - g) / 255 // how much R and B dominate green + // Why: require a strong R+B dominance over G so purples/pinks (e.g. + // 128,0,128 or 255,128,200) aren't keyed, while still letting antialiased + // edge pixels (e.g. 255,128,255 → dom≈0.5) fade with proportional alpha. + if (dom <= 0.4) { + return 0 + } + return Math.max(0, Math.min(1, dom * 1.4)) +} + +function keyMagenta(px: Uint8ClampedArray): void { + for (let i = 0; i < px.length; i += 4) { + const score = magentaScore(px[i], px[i + 1], px[i + 2]) + if (score <= 0) { + continue + } + if (score >= 0.5) { + px[i + 3] = 0 + px[i] = 0 + px[i + 1] = 0 + px[i + 2] = 0 + } else { + const keep = 1 - score * 2 + px[i + 3] = Math.round(px[i + 3] * Math.max(0, keep)) + } + } +} + +function loadImage(url: string): Promise { + return new Promise((resolve, reject) => { + const img = new Image() + img.onload = () => resolve(img) + img.onerror = () => reject(new Error('image load failed')) + img.src = url + }) +} + // Why: the store invokes this on removeCustomPet so the underlying Blob // is released; otherwise the blob: URL keeps it alive for the rest of the // session, wasting memory per imported image. @@ -259,9 +315,6 @@ export function revokeCustomPetBlobUrl(id: string): void { } function clearCustomPetBlobCacheEntry(id: string): void { - const retainedBytes = customPetMediaBytes.get(id) ?? 0 - customPetMediaBytes.delete(id) - retainedCustomPetMediaBytes = Math.max(0, retainedCustomPetMediaBytes - retainedBytes) const url = blobUrlCache.get(id) if (url) { URL.revokeObjectURL(url) diff --git a/src/renderer/src/components/pet/sprite-frame-detection.test.ts b/src/renderer/src/components/pet/sprite-frame-detection.test.ts deleted file mode 100644 index 77d89afc274..00000000000 --- a/src/renderer/src/components/pet/sprite-frame-detection.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { MAX_CUSTOM_PET_DETECTED_FRAMES } from '../../../../shared/custom-pet-media-limits' -import { detectFramesFromImageData } from './sprite-frame-detection' - -function stripedImage(frameCount: number): ImageData { - const frameWidth = 8 - const width = frameCount * (frameWidth + 1) - const height = 8 - const data = new Uint8ClampedArray(width * height * 4) - for (let frame = 0; frame < frameCount; frame += 1) { - const startX = frame * (frameWidth + 1) - for (let y = 0; y < height; y += 1) { - for (let x = startX; x < startX + frameWidth; x += 1) { - data[(y * width + x) * 4 + 3] = 255 - } - } - } - return { data, width, height } as ImageData -} - -describe('detectFramesFromImageData', () => { - it('keeps ordinary auto-detected sprite animations unchanged', () => { - expect(detectFramesFromImageData(stripedImage(3))?.frames).toHaveLength(3) - }) - - it('falls back to a static sheet above the bitmap fan-out cap', () => { - expect(detectFramesFromImageData(stripedImage(MAX_CUSTOM_PET_DETECTED_FRAMES + 1))).toBeNull() - }) -}) diff --git a/src/renderer/src/components/pet/sprite-frame-detection.ts b/src/renderer/src/components/pet/sprite-frame-detection.ts index d693b8f994e..d622634ea94 100644 --- a/src/renderer/src/components/pet/sprite-frame-detection.ts +++ b/src/renderer/src/components/pet/sprite-frame-detection.ts @@ -5,8 +5,6 @@ // split on empty columns to get one frame per sprite. The result drives the // canvas-based animation in the overlay. -import { MAX_CUSTOM_PET_DETECTED_FRAMES } from '../../../../shared/custom-pet-media-limits' - export type DetectedFrame = { x: number; y: number; w: number; h: number } export type DetectedSprite = { frames: DetectedFrame[] } @@ -54,7 +52,7 @@ function framesInBand( data: Uint8ClampedArray, width: number, band: { y0: number; y1: number } -): DetectedFrame[] | null { +): DetectedFrame[] { const colEmpty = new Uint8Array(width) for (let x = 0; x < width; x++) { let empty = 1 @@ -72,23 +70,14 @@ function framesInBand( if (!colEmpty[x] && start < 0) { start = x } else if (colEmpty[x] && start >= 0) { - const frame = { x: start, y: band.y0, w: x - start, h: band.y1 - band.y0 + 1 } - if (frame.w >= MIN_DIM && frame.h >= MIN_DIM) { - frames.push(frame) - if (frames.length > MAX_CUSTOM_PET_DETECTED_FRAMES) { - return null - } - } + frames.push({ x: start, y: band.y0, w: x - start, h: band.y1 - band.y0 + 1 }) start = -1 } } if (start >= 0) { - const frame = { x: start, y: band.y0, w: width - start, h: band.y1 - band.y0 + 1 } - if (frame.w >= MIN_DIM && frame.h >= MIN_DIM) { - frames.push(frame) - } + frames.push({ x: start, y: band.y0, w: width - start, h: band.y1 - band.y0 + 1 }) } - return frames.length <= MAX_CUSTOM_PET_DETECTED_FRAMES ? frames : null + return frames.filter((f) => f.w >= MIN_DIM && f.h >= MIN_DIM) } export function detectFramesFromImageData(image: ImageData): DetectedSprite | null { @@ -104,9 +93,6 @@ export function detectFramesFromImageData(image: ImageData): DetectedSprite | nu let best: DetectedFrame[] = [] for (const band of bands) { const candidate = framesInBand(data, width, band) - if (!candidate) { - return null - } if (candidate.length > best.length) { best = candidate } diff --git a/src/renderer/src/components/ports/WorkspacePortScanner.test.tsx b/src/renderer/src/components/ports/WorkspacePortScanner.test.tsx index fd915bb5fae..be1c69cbac7 100644 --- a/src/renderer/src/components/ports/WorkspacePortScanner.test.tsx +++ b/src/renderer/src/components/ports/WorkspacePortScanner.test.tsx @@ -12,16 +12,11 @@ import { import type { WorkspacePortScanResult } from '../../../../shared/workspace-ports' import { clearRuntimeCompatibilityCache, - markRuntimeEnvironmentCompatible, - type RuntimeClientTarget + markRuntimeEnvironmentCompatible } from '@/runtime/runtime-rpc-client' import { getWorkspacePortsByWorktreeId } from '@/lib/workspace-port-groups' import { useAppStore } from '@/store' -import { - scanWorkspacePortTargets, - WorkspacePortScanner, - WORKSPACE_PORT_SCAN_CONCURRENCY -} from './WorkspacePortScanner' +import { WorkspacePortScanner } from './WorkspacePortScanner' const localScan = vi.fn() const runtimeEnvironmentCall = vi.fn() @@ -224,40 +219,6 @@ afterEach(() => { }) describe('WorkspacePortScanner', () => { - it.each([ - ['at the limit', WORKSPACE_PORT_SCAN_CONCURRENCY], - ['above the limit', WORKSPACE_PORT_SCAN_CONCURRENCY + 1] - ])('bounds execution-host scans %s', async (_, count) => { - const targets: RuntimeClientTarget[] = Array.from({ length: count }, (_, index) => ({ - kind: 'environment', - environmentId: `env-${index}` - })) - let active = 0 - let peak = 0 - const releases: (() => void)[] = [] - const scanTarget = vi.fn(async () => { - active++ - peak = Math.max(peak, active) - await new Promise((resolve) => releases.push(resolve)) - active-- - return emptyScan - }) - - const scans = scanWorkspacePortTargets(targets, scanTarget) - expect(scanTarget).toHaveBeenCalledTimes(Math.min(count, WORKSPACE_PORT_SCAN_CONCURRENCY)) - if (count > WORKSPACE_PORT_SCAN_CONCURRENCY) { - releases.shift()?.() - for (let turn = 0; turn < 5 && scanTarget.mock.calls.length < count; turn++) { - await Promise.resolve() - } - expect(scanTarget).toHaveBeenCalledTimes(count) - } - releases.splice(0).forEach((release) => release()) - - await expect(scans).resolves.toHaveLength(count) - expect(peak).toBe(Math.min(count, WORKSPACE_PORT_SCAN_CONCURRENCY)) - }) - it('turns a missing runtime scan payload into an unavailable result', async () => { runtimeEnvironmentCall.mockResolvedValue({ ok: true, result: undefined }) diff --git a/src/renderer/src/components/ports/WorkspacePortScanner.tsx b/src/renderer/src/components/ports/WorkspacePortScanner.tsx index e85b4d88a62..f2805eb88a7 100644 --- a/src/renderer/src/components/ports/WorkspacePortScanner.tsx +++ b/src/renderer/src/components/ports/WorkspacePortScanner.tsx @@ -15,11 +15,9 @@ import { } from '@/lib/workspace-port-scan-debounce' import type { WorkspacePortScanResult } from '../../../../shared/workspace-ports' import { buildExecutionHostRegistry } from '../../../../shared/execution-host-registry' -import { mapWithConcurrency } from '../../../../shared/map-with-concurrency' const WORKSPACE_PORT_SCAN_INTERVAL_MS = 30_000 const WORKSPACE_PORT_ADVERTISED_URL_SETTLE_MS = 1_000 -export const WORKSPACE_PORT_SCAN_CONCURRENCY = 4 type WorkspacePortScannerRefreshOptions = { force?: boolean targets?: readonly RuntimeClientTarget[] @@ -36,21 +34,6 @@ function makeUnavailableScan(reason: string): WorkspacePortScanResult { } } -export function scanWorkspacePortTargets( - targets: readonly RuntimeClientTarget[], - scanTarget = scanWorkspacePortsForTarget -): Promise<{ key: string; result: WorkspacePortScanResult }[]> { - return mapWithConcurrency(targets, WORKSPACE_PORT_SCAN_CONCURRENCY, async (target) => { - const key = workspacePortScanKeyForTarget(target) - try { - return { key, result: await scanTarget(target) } - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - return { key, result: makeUnavailableScan(message || 'Workspace port scan failed.') } - } - }) -} - export function WorkspacePortScanner({ enabled = true }: { enabled?: boolean }): null { const settings = useAppStore((s) => s.settings) const repos = useAppStore((s) => s.repos) @@ -124,7 +107,18 @@ export function WorkspacePortScanner({ enabled = true }: { enabled?: boolean }): const generation = generationRef.current setWorkspacePortScanRefreshing(true) - const promise = scanWorkspacePortTargets(targets) + const promise = Promise.all( + targets.map(async (target) => { + const key = workspacePortScanKeyForTarget(target) + try { + const result = await scanWorkspacePortsForTarget(target) + return { key, result } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return { key, result: makeUnavailableScan(message || 'Workspace port scan failed.') } + } + }) + ) .then((results) => { if (generation === generationRef.current) { const activeTargetKeys = new Set( diff --git a/src/renderer/src/components/pull-request-page-host-boundary.test.ts b/src/renderer/src/components/pull-request-page-host-boundary.test.ts index 1bb473f3a75..507f20d2c87 100644 --- a/src/renderer/src/components/pull-request-page-host-boundary.test.ts +++ b/src/renderer/src/components/pull-request-page-host-boundary.test.ts @@ -98,21 +98,20 @@ describe('PullRequestPage host boundaries', () => { it('uses source-aware initial details routing and cache identity', () => { const source = componentSource('PullRequestPage.tsx') - const cacheSource = componentSource('../lib/github-work-item-details-cache.ts') const propsSection = sourceBetween( source, 'type PullRequestPageProps', 'function formatRelativeTime' ) const cacheKeySection = sourceBetween( - cacheSource, - 'export function getWorkItemDetailsCacheKey', - 'export function getWorkItemDetailsCacheEntry' + source, + 'function getWorkItemDetailsCacheKey', + 'function touchWorkItemDetailsCache' ) const matchInvalidationSection = sourceBetween( - cacheSource, - 'export function invalidateWorkItemDetailsCacheByMatch', - 'export function clearWorkItemDetailsCacheForTests' + source, + 'function invalidateWorkItemDetailsCacheByMatch', + 'function patchCachedPRFileViewedState' ) expect(propsSection).toContain('sourceContext?: TaskSourceContext | null') @@ -120,9 +119,9 @@ describe('PullRequestPage host boundaries', () => { expect(source).toContain('sourceContext,') expect(cacheKeySection).toContain('sourceCacheScope') expect(source).toContain('getTaskSourceCacheScope(sourceContext)') - expect(source).toContain('useWorkItemDetailsCacheEntry(detailsCacheKey)') - expect(source).not.toContain('new Map') - expect(matchInvalidationSection).toContain('cacheGeneration += 1') + expect(matchInvalidationSection).toContain( + 'if (removed) {\n workItemDetailsCacheGeneration += 1' + ) }) it('treats null details as unavailable while preserving empty detail payloads', () => { @@ -139,8 +138,6 @@ describe('PullRequestPage host boundaries', () => { expect(resultSection).toContain('} else if (result === null) {') expect(resultSection).toContain('error: WORK_ITEM_DETAILS_UNAVAILABLE_MESSAGE') expect(resultSection).toContain('details: result') - expect(resultSection).toContain('getWorkItemDetailsCacheGeneration() !== launchedAtGeneration') - expect(resultSection).toContain('prev?.pending !== inflight') }) it('routes file viewed mutations through the PR source context', () => { @@ -204,7 +201,6 @@ describe('PullRequestPage host boundaries', () => { it('routes PR file contents and runtime viewed invalidations through the PR source context', () => { const source = componentSource('PullRequestPage.tsx') - const cacheSource = componentSource('../lib/github-work-item-details-cache.ts') const fileContentsSection = sourceBetween( source, 'function loadPRFileContents', @@ -215,11 +211,7 @@ describe('PullRequestPage host boundaries', () => { 'function getPRFileContentCacheKey', 'function loadPRFileContents' ) - const listenerSection = sourceBetween( - cacheSource, - 'let workItemMutatedUnsub', - "if (typeof import.meta !== 'undefined'" - ) + const listenerSection = sourceBetween(source, 'let workItemMutatedUnsub', '// Why: bounded LRU') const commentContextSection = sourceBetween( source, 'function CommentCodeContext', @@ -236,7 +228,6 @@ describe('PullRequestPage host boundaries', () => { expect(fileContentsSection).toContain('sourceContext: args.sourceContext') expect(fileContentsSection).toContain('sourceContext,') expect(listenerSection).toContain('onGitHubWorkItemDetailsCacheMutation') - expect(listenerSection).toContain('invalidateWorkItemDetailsCacheByMatch') expect(source).toContain('emitGitHubWorkItemDetailsCacheMutation(args)') expect(source).toContain('options.local !== false') expect(source).toContain('notifyWorkItemMutated({') diff --git a/src/renderer/src/components/right-sidebar/SourceControl.tsx b/src/renderer/src/components/right-sidebar/SourceControl.tsx index 16b6cf95d66..07988244eab 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControl.tsx @@ -43,7 +43,6 @@ import { basename, dirname, joinPath } from '@/lib/path' import { cn } from '@/lib/utils' import { WORKSPACE_FILE_PATH_MIME } from '@/lib/workspace-file-drag' import { isFolderRepo } from '../../../../shared/repo-kind' -import { mapSettledWithConcurrency } from '../../../../shared/map-with-concurrency' import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '@/components/ui/tooltip' import { Button } from '@/components/ui/button' import { @@ -534,7 +533,6 @@ const SOURCE_CONTROL_TREE_INDENT_PX = 12 const SOURCE_CONTROL_TREE_DIRECTORY_PADDING_PX = 8 const SOURCE_CONTROL_TREE_FILE_PADDING_PX = 20 const CAPPED_STATUS_RETRY_TIMEOUT_MS = 15_000 -const SOURCE_CONTROL_QUIESCE_CONCURRENCY = 8 const EMPTY_GIT_HISTORY_STATE: GitHistoryPanelState = { status: 'idle' } const DEFAULT_COLLAPSED_SECTIONS = ['history'] as const const SUBMODULE_WORKTREE_ONLY_LABEL = 'Stage inside submodule' @@ -5248,23 +5246,16 @@ function SourceControlInner(): React.JSX.Element { const runtimeEnvironmentId = useAppStore.getState().settings?.activeRuntimeEnvironmentId?.trim() || null // Why: quiesce matching editor autosaves first so a delayed save can't recreate edits after git mutates the files. - const quiesces = await mapSettledWithConcurrency( - filePaths, - SOURCE_CONTROL_QUIESCE_CONCURRENCY, - (relativePath) => + await Promise.all( + filePaths.map((relativePath) => requestEditorSaveQuiesce({ worktreeId: activeWorktreeId, worktreePath, relativePath, runtimeEnvironmentId }) + ) ) - const failedQuiesce = quiesces.find( - (result): result is PromiseRejectedResult => result.status === 'rejected' - ) - if (failedQuiesce) { - throw failedQuiesce.reason - } const connectionId = getConnectionId(activeWorktreeId) ?? undefined await bulkDiscardRuntimeGitPaths( { diff --git a/src/renderer/src/components/right-sidebar/checks-panel-review-copy.ts b/src/renderer/src/components/right-sidebar/checks-panel-review-copy.ts index 9a9b839887e..b4486bec769 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel-review-copy.ts +++ b/src/renderer/src/components/right-sidebar/checks-panel-review-copy.ts @@ -263,17 +263,6 @@ const SKIPPED_COPY: Partial< 'Orca could not refresh {{reviewLabel}} status for this remote context. Retry after the host is available.' }, recovery: ['retry'] - }, - capacity: { - title: { - key: 'auto.components.right.sidebar.checks.panel.review.skipped.capacity.title', - fallback: 'Refresh queue busy' - }, - body: { - key: 'auto.components.right.sidebar.checks.panel.review.skipped.capacity.body', - fallback: 'Orca is handling many review updates. Retry this refresh in a moment.' - }, - recovery: ['retry'] } } diff --git a/src/renderer/src/components/right-sidebar/fileExplorerUndoRedo.test.ts b/src/renderer/src/components/right-sidebar/fileExplorerUndoRedo.test.ts deleted file mode 100644 index cc1464950ad..00000000000 --- a/src/renderer/src/components/right-sidebar/fileExplorerUndoRedo.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { - clearFileExplorerUndoHistory, - commitFileExplorerOp, - FILE_EXPLORER_UNDO_MAX_ENTRY_BYTES, - fileExplorerHasUndo, - getFileExplorerUndoRetainedBytesForTests, - undoFileExplorer -} from './fileExplorerUndoRedo' - -beforeEach(() => { - clearFileExplorerUndoHistory() -}) - -describe('file explorer undo retention', () => { - it('rejects an operation whose captured payload exceeds the per-entry budget', () => { - expect( - commitFileExplorerOp({ - retainedBytes: FILE_EXPLORER_UNDO_MAX_ENTRY_BYTES + 1, - undo: vi.fn(), - redo: vi.fn() - }) - ).toBe(false) - expect(fileExplorerHasUndo()).toBe(false) - }) - - it('evicts the oldest operation to stay within the aggregate budget', async () => { - const oldestUndo = vi.fn() - const retainedPerOperation = 12 * 1024 * 1024 - commitFileExplorerOp({ - retainedBytes: retainedPerOperation, - undo: oldestUndo, - redo: vi.fn() - }) - for (let index = 0; index < 2; index += 1) { - commitFileExplorerOp({ - retainedBytes: retainedPerOperation, - undo: vi.fn(), - redo: vi.fn() - }) - } - - expect(getFileExplorerUndoRetainedBytesForTests()).toBe(retainedPerOperation * 2) - await undoFileExplorer() - await undoFileExplorer() - expect(oldestUndo).not.toHaveBeenCalled() - }) -}) diff --git a/src/renderer/src/components/right-sidebar/fileExplorerUndoRedo.ts b/src/renderer/src/components/right-sidebar/fileExplorerUndoRedo.ts index ee40f9f310b..be3c9bd79ee 100644 --- a/src/renderer/src/components/right-sidebar/fileExplorerUndoRedo.ts +++ b/src/renderer/src/components/right-sidebar/fileExplorerUndoRedo.ts @@ -5,45 +5,26 @@ * (which is not exposed in a portable way here). */ const MAX_STEPS = 50 -export const FILE_EXPLORER_UNDO_MAX_ENTRY_BYTES = 16 * 1024 * 1024 -export const FILE_EXPLORER_UNDO_MAX_AGGREGATE_BYTES = 32 * 1024 * 1024 type ExplorerOp = { undo: () => Promise redo: () => Promise - retainedBytes?: number } const past: ExplorerOp[] = [] const future: ExplorerOp[] = [] -let retainedBytes = 0 -export function commitFileExplorerOp(op: ExplorerOp): boolean { - const opBytes = Math.max(0, op.retainedBytes ?? 0) - if (opBytes > FILE_EXPLORER_UNDO_MAX_ENTRY_BYTES) { - return false - } - for (const futureOp of future) { - retainedBytes -= Math.max(0, futureOp.retainedBytes ?? 0) +export function commitFileExplorerOp(op: ExplorerOp): void { + past.push(op) + if (past.length > MAX_STEPS) { + past.shift() } future.length = 0 - while (past.length > 0 && retainedBytes + opBytes > FILE_EXPLORER_UNDO_MAX_AGGREGATE_BYTES) { - const expired = past.shift() - retainedBytes -= Math.max(0, expired?.retainedBytes ?? 0) - } - past.push(op) - retainedBytes += opBytes - if (past.length > MAX_STEPS) { - const expired = past.shift() - retainedBytes -= Math.max(0, expired?.retainedBytes ?? 0) - } - return true } export function clearFileExplorerUndoHistory(): void { past.length = 0 future.length = 0 - retainedBytes = 0 } export async function undoFileExplorer(): Promise { @@ -73,7 +54,3 @@ export function fileExplorerHasUndo(): boolean { export function fileExplorerHasRedo(): boolean { return future.length > 0 } - -export function getFileExplorerUndoRetainedBytesForTests(): number { - return retainedBytes -} diff --git a/src/renderer/src/components/right-sidebar/useFileDeletion.ts b/src/renderer/src/components/right-sidebar/useFileDeletion.ts index f0c062c09da..4c5a0c9ec3f 100644 --- a/src/renderer/src/components/right-sidebar/useFileDeletion.ts +++ b/src/renderer/src/components/right-sidebar/useFileDeletion.ts @@ -24,7 +24,6 @@ import { writeRuntimeFile } from '@/runtime/runtime-file-client' import { translate } from '@/i18n/i18n' -import { measureUtf8ByteLength } from '../../../../shared/utf8-byte-limits' type UseFileDeletionParams = { activeWorktreeId: string | null @@ -164,7 +163,6 @@ export function useFileDeletion({ if (undoContent !== undefined) { commitFileExplorerOp({ - retainedBytes: measureUtf8ByteLength(undoContent).byteLength, undo: async () => { const currentRoute = operationGuard.assertCurrent() await writeRuntimeFile( diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerTree.test.ts b/src/renderer/src/components/right-sidebar/useFileExplorerTree.test.ts index 3c9ead09b1d..b98e61c825e 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerTree.test.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerTree.test.ts @@ -3,10 +3,7 @@ import type { SetStateAction } from 'react' import type { DirEntry } from '../../../../shared/types' import type { DirCache } from './file-explorer-types' import { createFileExplorerDirLoadTracker } from './file-explorer-dir-load-tracker' -import { - FILE_EXPLORER_REFRESH_CONCURRENCY, - refreshFileExplorerExpandedDirs -} from './useFileExplorerTree' +import { refreshFileExplorerExpandedDirs } from './useFileExplorerTree' type CacheUpdate = SetStateAction> @@ -15,46 +12,6 @@ function entry(name: string, isDirectory = false): DirEntry { } describe('refreshFileExplorerExpandedDirs', () => { - it.each([ - ['at the limit', FILE_EXPLORER_REFRESH_CONCURRENCY], - ['above the limit', FILE_EXPLORER_REFRESH_CONCURRENCY + 1] - ])('bounds expanded-directory reads %s', async (_, count) => { - let cache: Record = {} - let active = 0 - let peak = 0 - let started = 0 - const releases: (() => void)[] = [] - const refresh = refreshFileExplorerExpandedDirs({ - dirs: Array.from({ length: count }, (_, index) => ({ - dirPath: `/repo/dir-${index}`, - depth: 0 - })), - worktreePath: '/repo', - dirLoadTracker: createFileExplorerDirLoadTracker(), - setDirCache: (update) => { - cache = typeof update === 'function' ? update(cache) : update - }, - readDirectory: async () => { - started++ - active++ - peak = Math.max(peak, active) - await new Promise((resolve) => releases.push(resolve)) - active-- - return { entries: [], operationOwner: { kind: 'local' as const } } - } - }) - - await vi.waitFor(() => expect(started).toBe(Math.min(count, FILE_EXPLORER_REFRESH_CONCURRENCY))) - if (count > FILE_EXPLORER_REFRESH_CONCURRENCY) { - releases.shift()?.() - await vi.waitFor(() => expect(started).toBe(count)) - } - releases.splice(0).forEach((release) => release()) - - await expect(refresh).resolves.toBe(true) - expect(peak).toBe(Math.min(count, FILE_EXPLORER_REFRESH_CONCURRENCY)) - }) - it('reloads expanded directories with one loading cache commit and one result cache commit', async () => { let cache: Record = { '/repo': { diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerTree.ts b/src/renderer/src/components/right-sidebar/useFileExplorerTree.ts index e0121c45a80..591d9fd4d9f 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerTree.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerTree.ts @@ -1,6 +1,5 @@ import type { Dispatch, SetStateAction } from 'react' import { useCallback, useRef, useState } from 'react' -import { mapWithConcurrency } from '../../../../shared/map-with-concurrency' import type { DirCache } from './file-explorer-types' import { splitPathSegments } from './path-tree' import { statRuntimePath } from '@/runtime/runtime-file-client' @@ -49,8 +48,6 @@ type RefreshFileExplorerExpandedDirsParams = { readDirectory: (dirPath: string) => Promise } -export const FILE_EXPLORER_REFRESH_CONCURRENCY = 8 - export async function refreshFileExplorerExpandedDirs({ dirs, worktreePath, @@ -81,10 +78,8 @@ export async function refreshFileExplorerExpandedDirs({ return next }) - const results = await mapWithConcurrency( - uniqueDirs, - FILE_EXPLORER_REFRESH_CONCURRENCY, - async ({ dirPath, depth }) => { + const results = await Promise.all( + uniqueDirs.map(async ({ dirPath, depth }) => { const loadToken = loadTokens.get(dirPath)! try { const listing = await readDirectory(dirPath) @@ -116,7 +111,7 @@ export async function refreshFileExplorerExpandedDirs({ cache: { children: [], loading: false } } } - } + }) ) // Why: the batch commits only after the slowest read, so a dir can be diff --git a/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx b/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx index e1afbe0b0f3..5012fedaed2 100644 --- a/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx +++ b/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx @@ -61,11 +61,9 @@ import { getRemoteServerManualUpdateHelp, RemoteServerUpdateStatus } from './RemoteServerUpdateStatus' -import { mapWithConcurrency } from '../../../../shared/map-with-concurrency' const LOCAL_RUNTIME_VALUE = '__local__' const NO_RUNTIME_VALUE = '__none__' -export const RUNTIME_ENVIRONMENT_REFRESH_CONCURRENCY = 5 type RuntimeEnvironmentsPaneProps = { settings: GlobalSettings @@ -335,10 +333,8 @@ export function RuntimeEnvironmentsPane({ return next }) } - await mapWithConcurrency( - visibleEnvironments, - RUNTIME_ENVIRONMENT_REFRESH_CONCURRENCY, - async (environment) => { + await Promise.allSettled( + visibleEnvironments.map(async (environment) => { try { const response = await window.api.runtimeEnvironments.getStatus({ selector: environment.id, @@ -383,7 +379,7 @@ export function RuntimeEnvironmentsPane({ } })) } - } + }) ) } catch (error) { if (mountedRef.current) { @@ -639,9 +635,7 @@ export function RuntimeEnvironmentsPane({ // Why: Connect is not the Active Server selector anymore, but connected // hosts should still contribute their projects/workspaces to the sidebar. const repos = await store.fetchRuntimeEnvironmentRepos(environment.id) - await mapWithConcurrency(repos, RUNTIME_ENVIRONMENT_REFRESH_CONCURRENCY, async (repo) => - useAppStore.getState().fetchWorktrees(repo.id) - ) + await Promise.all(repos.map((repo) => useAppStore.getState().fetchWorktrees(repo.id))) await useAppStore.getState().fetchWorktreeLineage() if (mountedRef.current) { toast.success( diff --git a/src/renderer/src/components/settings/Settings.tsx b/src/renderer/src/components/settings/Settings.tsx index d81f3fe745a..ac0578fa38b 100644 --- a/src/renderer/src/components/settings/Settings.tsx +++ b/src/renderer/src/components/settings/Settings.tsx @@ -82,7 +82,6 @@ import { import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { getShortcutPlatform } from '@/lib/shortcut-platform' import { keybindingMatchesAction } from '../../../../shared/keybindings' -import { forEachWithConcurrency } from '../../../../shared/map-with-concurrency' import { isWebClientLocation, useSettingsNavigationMetadata @@ -174,7 +173,6 @@ const SETTINGS_NAV_GROUP_BY_ID = new Map( const SHORTCUTS_ESCAPE_CONFIRM_TOAST_ID = 'shortcuts-escape-confirm' const SHORTCUTS_ESCAPE_CONFIRM_WINDOW_MS = 2200 -const REPO_HOOK_PROBE_CONCURRENCY = 4 function getSettingsSectionId( pane: SettingsNavTarget, @@ -911,62 +909,61 @@ function Settings(): React.JSX.Element { const requestSeq = ++repoHooksRequestSeqRef.current const liveRepoHostIdentities = new Set(repos.map(getRepoHostIdentity)) - void forEachWithConcurrency(neededRepos, REPO_HOOK_PROBE_CONCURRENCY, async (repo) => { - if (stale || requestSeq !== repoHooksRequestSeqRef.current) { - return - } - const repoHostIdentity = getRepoHostIdentity(repo) - if (isFolderRepo(repo)) { - setRepoHooksMap((previous) => { - if (previous[repoHostIdentity]) { - return previous - } - return { - ...previous, - [repoHostIdentity]: { hasHooks: false, hooks: null, mayNeedUpdate: false } - } - }) - return - } - try { - const hostId = getRepoExecutionHostId(repo) - const parsedHost = parseExecutionHostId(hostId) - const result = await checkRuntimeHooks( - { - activeRuntimeEnvironmentId: - parsedHost?.kind === 'runtime' ? parsedHost.environmentId : null - }, - repo.id, - hostId - ) - if (stale || requestSeq !== repoHooksRequestSeqRef.current) { + void Promise.all( + neededRepos.map(async (repo) => { + const repoHostIdentity = getRepoHostIdentity(repo) + if (isFolderRepo(repo)) { + setRepoHooksMap((previous) => { + if (previous[repoHostIdentity]) { + return previous + } + return { + ...previous, + [repoHostIdentity]: { hasHooks: false, hooks: null, mayNeedUpdate: false } + } + }) return } - setRepoHooksMap((previous) => { - if (!liveRepoHostIdentities.has(repoHostIdentity)) { - return previous + try { + const hostId = getRepoExecutionHostId(repo) + const parsedHost = parseExecutionHostId(hostId) + const result = await checkRuntimeHooks( + { + activeRuntimeEnvironmentId: + parsedHost?.kind === 'runtime' ? parsedHost.environmentId : null + }, + repo.id, + hostId + ) + if (stale || requestSeq !== repoHooksRequestSeqRef.current) { + return } - return { ...previous, [repoHostIdentity]: result } - }) - } catch { - // Keep last known value on transient failures. - if (stale || requestSeq !== repoHooksRequestSeqRef.current) { - return + setRepoHooksMap((previous) => { + if (!liveRepoHostIdentities.has(repoHostIdentity)) { + return previous + } + return { ...previous, [repoHostIdentity]: result } + }) + } catch { + // Keep last known value on transient failures. + if (stale || requestSeq !== repoHooksRequestSeqRef.current) { + return + } + setRepoHooksMap((previous) => { + if (!liveRepoHostIdentities.has(repoHostIdentity)) { + return previous + } + if (previous[repoHostIdentity]) { + return previous + } + return { + ...previous, + [repoHostIdentity]: { hasHooks: false, hooks: null, mayNeedUpdate: false } + } + }) } - setRepoHooksMap((previous) => { - if (!liveRepoHostIdentities.has(repoHostIdentity)) { - return previous - } - if (previous[repoHostIdentity]) { - return previous - } - return { - ...previous, - [repoHostIdentity]: { hasHooks: false, hooks: null, mayNeedUpdate: false } - } - }) - } - }) + }) + ) return () => { stale = true diff --git a/src/renderer/src/components/settings/mcp-config-inspection.test.ts b/src/renderer/src/components/settings/mcp-config-inspection.test.ts deleted file mode 100644 index cab8c630231..00000000000 --- a/src/renderer/src/components/settings/mcp-config-inspection.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { MCP_CONFIG_INSPECTION_MAX_BYTES } from '../../../../shared/mcp-config-inspection-limits' -import { loadMcpConfigInspections } from './mcp-config-inspection' - -function installFilesystemApi(size: number) { - const readFile = vi.fn().mockResolvedValue({ - content: '{"mcpServers":{"local":{"command":"node"}}}', - isBinary: false - }) - vi.stubGlobal('window', { - api: { - fs: { - readDir: vi - .fn() - .mockResolvedValue([{ name: '.mcp.json', isDirectory: false, isSymlink: false }]), - stat: vi.fn().mockResolvedValue({ size, isDirectory: false, mtime: 0 }), - readFile - } - } - }) - return { readFile } -} - -afterEach(() => { - vi.unstubAllGlobals() -}) - -describe('loadMcpConfigInspections file admission', () => { - it('reads a candidate at the exact byte boundary', async () => { - const fs = installFilesystemApi(MCP_CONFIG_INSPECTION_MAX_BYTES) - - const inspections = await loadMcpConfigInspections('/repo', undefined) - - expect(inspections[0]).toMatchObject({ - exists: true, - status: 'valid', - servers: [{ name: 'local', command: 'node' }] - }) - expect(fs.readFile).toHaveBeenCalledOnce() - }) - - it('rejects a +1 candidate before renderer IPC materialization', async () => { - const fs = installFilesystemApi(MCP_CONFIG_INSPECTION_MAX_BYTES + 1) - - const inspections = await loadMcpConfigInspections('/repo', 'ssh-1') - - expect(inspections[0]).toMatchObject({ - exists: true, - status: 'invalid', - error: 'MCP config exceeds the inspection size limit.' - }) - expect(fs.readFile).not.toHaveBeenCalled() - }) -}) diff --git a/src/renderer/src/components/settings/mcp-config-inspection.ts b/src/renderer/src/components/settings/mcp-config-inspection.ts index 9e163cce8b1..50d5b59b1c7 100644 --- a/src/renderer/src/components/settings/mcp-config-inspection.ts +++ b/src/renderer/src/components/settings/mcp-config-inspection.ts @@ -6,7 +6,6 @@ import { selectExistingMcpConfigCandidates, type McpConfigDirectoryEntry } from '../../../../shared/mcp-config' -import { MCP_CONFIG_INSPECTION_MAX_BYTES } from '../../../../shared/mcp-config-inspection-limits' import { joinPath } from '../../lib/path' import { extractIpcErrorMessage } from '../../lib/ipc-error' import type { LoadedMcpConfigInspection } from './McpConfigFileRow' @@ -75,23 +74,6 @@ export async function loadMcpConfigInspections( } try { - const fileStat = await window.api.fs.stat({ filePath: absolutePath, connectionId }) - if ( - fileStat.isDirectory || - !Number.isSafeInteger(fileStat.size) || - fileStat.size < 0 || - fileStat.size > MCP_CONFIG_INSPECTION_MAX_BYTES - ) { - return { - ...inspectMcpConfigContent(candidate, null), - exists: true, - status: 'invalid', - error: fileStat.isDirectory - ? 'MCP config path is a directory.' - : 'MCP config exceeds the inspection size limit.', - absolutePath - } - } const result = await window.api.fs.readFile({ filePath: absolutePath, connectionId }) const inspection = inspectMcpConfigContent(candidate, result.isBinary ? '' : result.content) return { ...inspection, absolutePath } diff --git a/src/renderer/src/components/settings/settings-repo-hook-fanout-boundary.test.ts b/src/renderer/src/components/settings/settings-repo-hook-fanout-boundary.test.ts deleted file mode 100644 index 64286555e33..00000000000 --- a/src/renderer/src/components/settings/settings-repo-hook-fanout-boundary.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' -import { describe, expect, it } from 'vitest' - -const SETTINGS_SOURCE = readFileSync(join(__dirname, 'Settings.tsx'), 'utf8') - -function sourceBetween(startPattern: string, endPattern: string): string { - const start = SETTINGS_SOURCE.indexOf(startPattern) - expect(start).toBeGreaterThanOrEqual(0) - const end = SETTINGS_SOURCE.indexOf(endPattern, start + startPattern.length) - expect(end).toBeGreaterThan(start) - return SETTINGS_SOURCE.slice(start, end) -} - -describe('settings repository-hook fanout boundary', () => { - it('bounds hook probes while retaining request-generation and repository guards', () => { - const section = sourceBetween( - 'const requestSeq = ++repoHooksRequestSeqRef.current', - 'const scrollTargetId = pendingScrollTargetRef.current' - ) - - expect(SETTINGS_SOURCE).toContain('const REPO_HOOK_PROBE_CONCURRENCY = 4') - expect(section).toContain('forEachWithConcurrency(neededRepos, REPO_HOOK_PROBE_CONCURRENCY') - expect(section).not.toMatch(/Promise\.all\(\s*neededRepos\.map/) - expect(section).toContain('requestSeq !== repoHooksRequestSeqRef.current') - expect(section).toContain('liveRepoHostIdentities.has(repoHostIdentity)') - }) -}) diff --git a/src/renderer/src/components/sidebar/AddRepoSteps.tsx b/src/renderer/src/components/sidebar/AddRepoSteps.tsx index a7bce5bdc27..5ab96be88ca 100644 --- a/src/renderer/src/components/sidebar/AddRepoSteps.tsx +++ b/src/renderer/src/components/sidebar/AddRepoSteps.tsx @@ -5,13 +5,10 @@ import { useMountedRef } from '@/hooks/useMountedRef' import type { NestedRepoScanResult } from '../../../../shared/types' import type { SshTarget, SshConnectionState } from '../../../../shared/ssh-types' import { createNestedRepoTelemetryAttemptId } from '../../../../shared/nested-repo-telemetry' -import { mapWithConcurrency } from '../../../../shared/map-with-concurrency' import { translate } from '@/i18n/i18n' import { extractIpcErrorMessage } from '@/lib/ipc-error' import { upsertAddedRepoWithProjectHostSetup } from './add-repo-store-upsert' -export const SSH_TARGET_STATE_READ_CONCURRENCY = 8 - // ── SSH host project hook ─────────────────────────────────────────── export function useRemoteRepo( @@ -76,15 +73,13 @@ export function useRemoteRepo( if (gen !== remoteGenRef.current) { return } - const withState = await mapWithConcurrency( - targets, - SSH_TARGET_STATE_READ_CONCURRENCY, - async (t) => { + const withState = await Promise.all( + targets.map(async (t) => { const state = (await window.api.ssh.getState({ targetId: t.id })) as SshConnectionState | null return { ...t, state: state ?? undefined } - } + }) ) if (gen !== remoteGenRef.current) { return diff --git a/src/renderer/src/components/sidebar/CommentMarkdown.test.tsx b/src/renderer/src/components/sidebar/CommentMarkdown.test.tsx index 3d26cb7e321..4ca6323a8a2 100644 --- a/src/renderer/src/components/sidebar/CommentMarkdown.test.tsx +++ b/src/renderer/src/components/sidebar/CommentMarkdown.test.tsx @@ -2,9 +2,6 @@ import { renderToStaticMarkup } from 'react-dom/server' import { describe, expect, it } from 'vitest' import CommentMarkdown, { remarkGitHubReferences } from './CommentMarkdown' -const PNG_1X1_BASE64 = - 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=' - describe('CommentMarkdown', () => { it('marks compact headings so a parent can opt into block flow', () => { const markup = renderToStaticMarkup( @@ -84,33 +81,12 @@ describe('CommentMarkdown', () => { it('renders trusted compact markdown images inline', () => { const markup = renderToStaticMarkup( - + ) expect(markup).toContain(' { - const markup = renderToStaticMarkup( - - ) - - expect(markup).not.toContain('Image #1') - }) - - it('renders malformed document raster data as alt text instead of decoding it', () => { - const markup = renderToStaticMarkup( - - ) - - expect(markup).not.toContain('Image #1') + expect(markup).toContain('src="data:image/png;base64,abc123"') }) it('renders bare GitHub user attachment links as document videos', () => { diff --git a/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx b/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx index cea657c9229..5a67e5450ec 100644 --- a/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx +++ b/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx @@ -36,7 +36,6 @@ import type { AppState } from '@/store/types' import { useAllWorktrees, useRepoById, useRepoMap, useWorktreeMap } from '@/store/selectors' import { cn } from '@/lib/utils' import type { Repo, Worktree } from '../../../../shared/types' -import { mapSettledWithConcurrency } from '../../../../shared/map-with-concurrency' import { runWorktreeBatchDelete, runWorktreeDelete } from './delete-worktree-flow' import { runSleepWorktrees } from './sleep-worktree-flow' import { activateAndRevealWorktree } from '@/lib/worktree-activation' @@ -75,16 +74,6 @@ const WORKTREE_NATIVE_CONTEXT_MENU_ATTR = 'data-worktree-native-context-menu' const CONTEXT_MENU_CLICK_SUPPRESSION_MS = 500 const DELETE_POSITION_RESTORE_MAX_FRAMES = 180 const DELETE_POSITION_RESTORE_STABLE_FRAMES = 6 -const WORKTREE_META_MUTATION_CONCURRENCY = 8 - -function rethrowFirstMutationFailure(results: readonly PromiseSettledResult[]): void { - const failure = results.find( - (result): result is PromiseRejectedResult => result.status === 'rejected' - ) - if (failure) { - throw failure.reason - } -} // Why: stable empty sentinels let closed menu wrappers subscribe to a referentially // stable value instead of the high-churn maps that delete teardown replaces. The @@ -515,14 +504,13 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({ const handleAssignWorkspaceStatus = useCallback( (status: string) => { setMenuOpenState(false) - void mapSettledWithConcurrency( - activeContextWorktrees, - WORKTREE_META_MUTATION_CONCURRENCY, - (item) => + void Promise.all( + activeContextWorktrees.map((item) => getWorkspaceStatus(item, workspaceStatuses) === status ? Promise.resolve() : updateWorktreeMeta(item.id, { workspaceStatus: status }) - ).then(rethrowFirstMutationFailure) + ) + ) }, [activeContextWorktrees, setMenuOpenState, updateWorktreeMeta, workspaceStatuses] ) @@ -636,11 +624,9 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({ ) const handleRemoveParentLink = useCallback(() => { - void mapSettledWithConcurrency( - activeContextWorktrees, - WORKTREE_META_MUTATION_CONCURRENCY, - (item) => updateWorktreeLineage(item.id, { noParent: true }) - ).then(rethrowFirstMutationFailure) + void Promise.all( + activeContextWorktrees.map((item) => updateWorktreeLineage(item.id, { noParent: true })) + ) }, [activeContextWorktrees, updateWorktreeLineage]) const suppressOpeningPointerEvent = useCallback((event: React.SyntheticEvent) => { diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index 831c56fb106..90372e7cf1d 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -232,7 +232,6 @@ import { ProjectGroupNameDialog } from './ProjectGroupNameDialog' import { ProjectGroupDeleteDialog } from './ProjectGroupDeleteDialog' import { selectProjectGroupRemovalTargets } from '@/store/slices/project-group-removal-targets' import { isGitRepoKind } from '../../../../shared/repo-kind' -import { mapSettledWithConcurrency } from '../../../../shared/map-with-concurrency' import { effectiveExternalWorktreeVisibility, isLegacyRepoForExternalWorktreeVisibility @@ -338,7 +337,6 @@ const EMPTY_TERMINAL_LAYOUTS_BY_TAB_ID: AppState['terminalLayoutsByTabId'] = {} const EMPTY_PTY_IDS_BY_TAB_ID: AppState['ptyIdsByTabId'] = {} const EMPTY_RUNTIME_PANE_TITLES_BY_TAB_ID: AppState['runtimePaneTitlesByTabId'] = {} const EXPANDING_CARD_MEASUREMENT_ADJUSTMENT_SUPPRESS_MS = 300 -const WORKTREE_LINEAGE_MUTATION_CONCURRENCY = 8 const NOOP_WORKSPACE_BOARD_DRAG_PREVIEW_CALLBACK = (): void => {} const WORKTREE_SIDEBAR_SCROLL_STYLE: React.CSSProperties = { // Why: TanStack Virtual owns scroll correction; native overflow anchoring fights it and causes jumps. @@ -347,15 +345,6 @@ const WORKTREE_SIDEBAR_SCROLL_STYLE: React.CSSProperties = { const recordKeyCountCache = new WeakMap, number>() -function rethrowFirstLineageFailure(results: readonly PromiseSettledResult[]): void { - const failure = results.find( - (result): result is PromiseRejectedResult => result.status === 'rejected' - ) - if (failure) { - throw failure.reason - } -} - export function countRecordKeysByReference(record: Record): number { const cached = recordKeyCountCache.get(record) if (cached !== undefined) { @@ -2731,19 +2720,17 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp if (!target.lineageParentId) { return false } - void mapSettledWithConcurrency(draggedIds, WORKTREE_LINEAGE_MUTATION_CONCURRENCY, (id) => - assignWorktreeParent(id, { parentWorktreeId: parentId }) - ) - .then(rethrowFirstLineageFailure) - .catch((err) => { - console.error('Failed to nest workspace:', err) - toast.error( - translate( - 'auto.components.sidebar.WorktreeList.failedNestWorkspace', - 'Failed to nest workspace' - ) + void Promise.all( + draggedIds.map((id) => assignWorktreeParent(id, { parentWorktreeId: parentId })) + ).catch((err) => { + console.error('Failed to nest workspace:', err) + toast.error( + translate( + 'auto.components.sidebar.WorktreeList.failedNestWorkspace', + 'Failed to nest workspace' ) - }) + ) + }) return true }, [assignWorktreeParent, getEligibleLineageDropTarget] @@ -2766,11 +2753,8 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp return } // Why: dropping a nested card on a reorder line is the un-nest escape hatch; clear only the dragged children. - void mapSettledWithConcurrency(ids, WORKTREE_LINEAGE_MUTATION_CONCURRENCY, (id) => - updateWorktreeLineage(id, { noParent: true }) - ) - .then(rethrowFirstLineageFailure) - .catch((err) => { + void Promise.all(ids.map((id) => updateWorktreeLineage(id, { noParent: true }))).catch( + (err) => { console.error('Failed to unnest workspace:', err) toast.error( translate( @@ -2778,7 +2762,8 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp 'Failed to unnest workspace' ) ) - }) + } + ) }, [cyclicLineageIds, updateWorktreeLineage, worktreeDragGroups, worktreeLineageById, worktreeMap] ) diff --git a/src/renderer/src/components/sidebar/comment-markdown-element-renderers.tsx b/src/renderer/src/components/sidebar/comment-markdown-element-renderers.tsx index 2989fb56c7b..18cbf924865 100644 --- a/src/renderer/src/components/sidebar/comment-markdown-element-renderers.tsx +++ b/src/renderer/src/components/sidebar/comment-markdown-element-renderers.tsx @@ -7,7 +7,6 @@ import { isGitHubUserAttachmentUrl, isGitHubUserAttachmentVideoLink } from './comment-markdown-github-attachment-media' -import { validateRasterImageDataUri } from '../../../../shared/image-data-uri' export type CommentMarkdownLinkClickHandler = ( event: React.MouseEvent, @@ -20,9 +19,7 @@ export function isTrustedCompactImageSrc(src: string | undefined): src is string } const normalized = src.trim().toLowerCase() return ( - normalized.startsWith('blob:') || - (/^data:image\/(?:png|jpe?g|gif|webp);base64,/.test(normalized) && - validateRasterImageDataUri(src) !== null) + normalized.startsWith('blob:') || /^data:image\/(?:png|jpe?g|gif|webp);base64,/.test(normalized) ) } @@ -259,13 +256,6 @@ export function createDocumentCommentMarkdownComponents( ), img: ({ alt, src }) => { - if ( - !src || - (src.trim().toLowerCase().startsWith('data:image/') && - validateRasterImageDataUri(src) === null) - ) { - return alt ? {alt} : null - } if (isGitHubUserAttachmentUrl(src)) { // Why: private-repo attachment images fail as cross-origin loads; a // top-level link opens them in a GitHub-authenticated tab, and falls diff --git a/src/renderer/src/components/sidebar/delete-worktree-flow.ts b/src/renderer/src/components/sidebar/delete-worktree-flow.ts index 5aac5b596db..f5a653a42b9 100644 --- a/src/renderer/src/components/sidebar/delete-worktree-flow.ts +++ b/src/renderer/src/components/sidebar/delete-worktree-flow.ts @@ -13,7 +13,6 @@ import { normalizeRuntimePathForComparison } from '../../../../shared/cross-platform-path' import type { Worktree } from '../../../../shared/types' -import { mapWithConcurrency } from '../../../../shared/map-with-concurrency' import { translate } from '@/i18n/i18n' type WorktreeBatchDeleteOptions = { @@ -28,8 +27,6 @@ type WorktreeDeleteWithToastOptions = { focusSuccessorOnDelete?: boolean } -export const CROSS_REPO_DELETE_CONCURRENCY = 4 - // Why: a failed delete usually means unresolved changes, so land on the diff panel, not just focus the worktree. function viewWorktreeDiff(worktreeId: string): void { activateAndRevealWorktree(worktreeId) @@ -70,10 +67,8 @@ export async function runWorktreeDeletesInParallel( // Why: delete nested children first — else the parent delete is rejected while it still contains a registered worktree. group.sort((a, b) => b.path.length - a.path.length) } - const groupResults = await mapWithConcurrency( - Array.from(groups.values()), - CROSS_REPO_DELETE_CONCURRENCY, - async (group) => { + const groupResults = await Promise.all( + Array.from(groups.values()).map(async (group) => { const deletedInGroup: string[] = [] const failedInGroup: (typeof group)[number][] = [] for (const target of group) { @@ -93,7 +88,7 @@ export async function runWorktreeDeletesInParallel( } } return deletedInGroup - } + }) ) const deletedSet = new Set(groupResults.flat()) // Why: focus a survivor once after the batch settles — an intermediate focus could spawn a terminal in a to-be-deleted workspace. diff --git a/src/renderer/src/components/sidebar/delete-worktree-parallel-flow.test.ts b/src/renderer/src/components/sidebar/delete-worktree-parallel-flow.test.ts index 40ae3940f12..e20214d6633 100644 --- a/src/renderer/src/components/sidebar/delete-worktree-parallel-flow.test.ts +++ b/src/renderer/src/components/sidebar/delete-worktree-parallel-flow.test.ts @@ -46,7 +46,7 @@ vi.mock('sonner', () => ({ })) import { toast } from 'sonner' -import { CROSS_REPO_DELETE_CONCURRENCY, runWorktreeDeletesInParallel } from './delete-worktree-flow' +import { runWorktreeDeletesInParallel } from './delete-worktree-flow' function deferredDeleteResult(): { promise: Promise<{ ok: true }> @@ -69,45 +69,6 @@ describe('runWorktreeDeletesInParallel', () => { vi.mocked(toast.info).mockClear() }) - it.each([ - ['at the limit', CROSS_REPO_DELETE_CONCURRENCY], - ['above the limit', CROSS_REPO_DELETE_CONCURRENCY + 1] - ])('bounds cross-project deletes %s', async (_, count) => { - let active = 0 - let peak = 0 - const pending: ReturnType[] = [] - mocks.state.removeWorktree.mockImplementation(() => { - const result = deferredDeleteResult() - pending.push(result) - active++ - peak = Math.max(peak, active) - return result.promise.finally(() => { - active-- - }) - }) - const targets = Array.from({ length: count }, (_, index) => ({ - id: `wt-${index}`, - displayName: `workspace ${index}`, - repoId: `repo-${index}`, - path: `/workspaces/${index}` - })) - - const deleted = runWorktreeDeletesInParallel(targets) - await vi.waitFor(() => - expect(mocks.state.removeWorktree).toHaveBeenCalledTimes( - Math.min(count, CROSS_REPO_DELETE_CONCURRENCY) - ) - ) - if (count > CROSS_REPO_DELETE_CONCURRENCY) { - pending.shift()?.resolve({ ok: true }) - await vi.waitFor(() => expect(mocks.state.removeWorktree).toHaveBeenCalledTimes(count)) - } - pending.splice(0).forEach((result) => result.resolve({ ok: true })) - - await expect(deleted).resolves.toEqual(targets.map((target) => target.id)) - expect(peak).toBe(Math.min(count, CROSS_REPO_DELETE_CONCURRENCY)) - }) - it('starts every selected delete before waiting for earlier deletes to finish', async () => { const first = deferredDeleteResult() const second = deferredDeleteResult() diff --git a/src/renderer/src/components/status-bar/SshStatusSegment.test.ts b/src/renderer/src/components/status-bar/SshStatusSegment.test.ts index 7b5cd64bbbe..406de54c6f6 100644 --- a/src/renderer/src/components/status-bar/SshStatusSegment.test.ts +++ b/src/renderer/src/components/status-bar/SshStatusSegment.test.ts @@ -2,20 +2,9 @@ import { describe, expect, it, vi } from 'vitest' import { connectRuntimeHostForNavigation, isConnectedRuntimeHostState, - RUNTIME_HOST_CATALOG_FETCH_CONCURRENCY, runtimeStatusForOverall } from './SshStatusSegment' -function deferred(): { promise: Promise; resolve: () => void } { - let resolve!: () => void - return { - promise: new Promise((nextResolve) => { - resolve = nextResolve - }), - resolve - } -} - describe('SshStatusSegment host status helpers', () => { it('counts connected remote servers as connected hosts', () => { // Why: "connected" = attached/reachable (active-agnostic), matching Settings. @@ -67,36 +56,4 @@ describe('connectRuntimeHostForNavigation', () => { ).resolves.toBe(false) expect(fetchRepos).not.toHaveBeenCalled() }) - - it('bounds catalog worktree fetches for large remote hosts', async () => { - const count = RUNTIME_HOST_CATALOG_FETCH_CONCURRENCY + 1 - const releases = Array.from({ length: count }, deferred) - let active = 0 - let peak = 0 - const fetchWorktrees = vi.fn(async (_repoId: string) => { - const release = releases[fetchWorktrees.mock.calls.length - 1] - active += 1 - peak = Math.max(peak, active) - await release.promise - active -= 1 - }) - const loading = connectRuntimeHostForNavigation({ - environmentId: 'windows-2', - refreshStatus: vi.fn().mockResolvedValue(true), - fetchRepos: vi - .fn() - .mockResolvedValue(Array.from({ length: count }, (_, index) => ({ id: `repo-${index}` }))), - fetchWorktrees, - fetchLineage: vi.fn().mockResolvedValue(undefined) - }) - - await vi.waitFor(() => - expect(fetchWorktrees).toHaveBeenCalledTimes(RUNTIME_HOST_CATALOG_FETCH_CONCURRENCY) - ) - releases[0].resolve() - await vi.waitFor(() => expect(fetchWorktrees).toHaveBeenCalledTimes(count)) - releases.slice(1).forEach(({ resolve }) => resolve()) - await expect(loading).resolves.toBe(true) - expect(peak).toBe(RUNTIME_HOST_CATALOG_FETCH_CONCURRENCY) - }) }) diff --git a/src/renderer/src/components/status-bar/SshStatusSegment.tsx b/src/renderer/src/components/status-bar/SshStatusSegment.tsx index c6d5d6b61b7..e6785870c87 100644 --- a/src/renderer/src/components/status-bar/SshStatusSegment.tsx +++ b/src/renderer/src/components/status-bar/SshStatusSegment.tsx @@ -20,9 +20,6 @@ import { isUserManagedRuntimeEnvironment } from '../../../../shared/runtime-envi import { RuntimeHostStatusRow, type RuntimeHostConnectionState } from './RuntimeHostStatusRow' import { SshTargetStatusRow } from './SshTargetStatusRow' import type { RemoteRuntimeSharedConnectionDiagnostics } from '../../../../shared/remote-runtime-shared-control-types' -import { forEachWithConcurrency } from '../../../../shared/map-with-concurrency' - -export const RUNTIME_HOST_CATALOG_FETCH_CONCURRENCY = 4 function isConnecting(status: SshConnectionStatus): boolean { return ['connecting', 'deploying-relay', 'reconnecting'].includes(status) @@ -161,9 +158,7 @@ export async function connectRuntimeHostForNavigation(args: { return false } const repos = await args.fetchRepos(args.environmentId) - await forEachWithConcurrency(repos, RUNTIME_HOST_CATALOG_FETCH_CONCURRENCY, async (repo) => { - await args.fetchWorktrees(repo.id) - }) + await Promise.all(repos.map((repo) => args.fetchWorktrees(repo.id))) await args.fetchLineage() return true } diff --git a/src/renderer/src/components/task-page-gitlab-fanout-boundary.test.ts b/src/renderer/src/components/task-page-gitlab-fanout-boundary.test.ts deleted file mode 100644 index 32161e06f73..00000000000 --- a/src/renderer/src/components/task-page-gitlab-fanout-boundary.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' -import { describe, expect, it } from 'vitest' - -const TASK_PAGE_SOURCE = readFileSync(join(__dirname, 'TaskPage.tsx'), 'utf8') - -function sourceBetween(startPattern: string, endPattern: string): string { - const start = TASK_PAGE_SOURCE.indexOf(startPattern) - expect(start).toBeGreaterThanOrEqual(0) - const end = TASK_PAGE_SOURCE.indexOf(endPattern, start + startPattern.length) - expect(end).toBeGreaterThan(start) - return TASK_PAGE_SOURCE.slice(start, end) -} - -describe('TaskPage GitLab request fanout', () => { - it('bounds per-repository requests without replacing all-settled handling', () => { - const section = sourceBetween( - '// Why: fetch GitLab Issues and MRs separately', - '// Why: Todos fetch has its own effect' - ) - - expect(TASK_PAGE_SOURCE).toContain('const GITLAB_REPO_FETCH_CONCURRENCY = 8') - expect(section).toContain('mapSettledWithConcurrency(') - expect(section).toContain('GITLAB_REPO_FETCH_CONCURRENCY') - expect(section).toContain('return fetchItems(repo)') - expect(section).not.toContain('Promise.allSettled(eligibleRepos.map(fetchItems))') - expect(section).toContain("if (r.status !== 'fulfilled')") - }) -}) diff --git a/src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts b/src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts index 2a5632eab7b..ddf856f05ae 100644 --- a/src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts @@ -7,7 +7,6 @@ import { TERMINAL_INPUT_CHUNK_MAX_BYTES, TERMINAL_INPUT_MAX_BYTES } from '../../../../shared/terminal-input' -import { CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS } from '../../../../shared/clipboard-text' const WHEEL_UP_REPORT = '\x1b[<64;60;20M' @@ -155,41 +154,4 @@ describe('pty input write queue', () => { expect(writes.length).toBe(1) expect(writes.map((write) => write.data).join('')).not.toContain('tail') }) - - it('rejects producer backlog beyond the retained item and text budgets', async () => { - const writes: WriteRecord[] = [] - const pendingYields: (() => void)[] = [] - const large = 'y'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES * 2) - const queue = createPtyInputWriteQueue({ - isWritable: () => true, - write: (id, data) => writes.push({ id, data }), - yieldBetweenWrites: () => - new Promise((resolve) => { - pendingYields.push(resolve) - }), - maxPendingItems: 2, - maxPendingCodeUnits: large.length + 4 - }) - - expect(queue.enqueue('pty-1', large)).toBe(true) - expect(queue.enqueue('pty-1', 'tail')).toBe(true) - expect(queue.enqueue('pty-1', '!')).toBe(false) - - queue.clear() - pendingYields.shift()?.() - await queue.waitForDrain() - - expect(writes).toEqual([{ id: 'pty-1', data: large.slice(0, TERMINAL_INPUT_CHUNK_MAX_BYTES) }]) - }) - - it('does not write deferred input after clear', async () => { - const { writes, queue } = createRecordingQueue() - const deferred = 'x'.repeat(CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS + 1) - - expect(queue.enqueue('pty-1', deferred)).toBe(true) - queue.clear() - await queue.waitForDrain() - - expect(writes).toEqual([]) - }) }) diff --git a/src/renderer/src/components/terminal-pane/pty-input-write-queue.ts b/src/renderer/src/components/terminal-pane/pty-input-write-queue.ts index 9857a0ff0b9..b20e764df15 100644 --- a/src/renderer/src/components/terminal-pane/pty-input-write-queue.ts +++ b/src/renderer/src/components/terminal-pane/pty-input-write-queue.ts @@ -1,5 +1,4 @@ import { - TERMINAL_INPUT_MAX_BYTES, isTerminalInputTooLargeWithDeferredMeasurement, iterateTerminalInputChunks } from '../../../../shared/terminal-input' @@ -8,7 +7,6 @@ import { // 16KB TERMINAL_INPUT_CHUNK_MAX_BYTES cap without paying byte measurement on // the hot input path. export const TERMINAL_INPUT_COALESCE_MAX_CODE_UNITS = 4096 -export const PTY_INPUT_WRITE_QUEUE_MAX_PENDING_ITEMS = 4_096 type PendingPtyInputWrite = { id: string @@ -28,8 +26,6 @@ export type PtyInputWriteQueueDeps = { isWritable: (id: string) => boolean write: (id: string, data: string) => void yieldBetweenWrites?: () => Promise - maxPendingItems?: number - maxPendingCodeUnits?: number } function defaultYieldBetweenWrites(): Promise { @@ -42,47 +38,28 @@ function isCoalescibleText(text: string): boolean { export function createPtyInputWriteQueue(deps: PtyInputWriteQueueDeps): PtyInputWriteQueue { const yieldBetweenWrites = deps.yieldBetweenWrites ?? defaultYieldBetweenWrites - const maxPendingItems = positiveLimit( - deps.maxPendingItems, - PTY_INPUT_WRITE_QUEUE_MAX_PENDING_ITEMS - ) - const maxPendingCodeUnits = positiveLimit(deps.maxPendingCodeUnits, TERMINAL_INPUT_MAX_BYTES) let pending: PendingPtyInputWrite[] = [] - let pendingCodeUnits = 0 let drainPromise: Promise | null = null - let clearVersion = 0 - - function shiftPending(): PendingPtyInputWrite | undefined { - const shifted = pending.shift() - if (shifted) { - pendingCodeUnits -= shifted.text.length - } - return shifted - } async function drain(): Promise { while (pending.length > 0) { const next = pending[0] if (!next) { - shiftPending() + pending.shift() continue } if (!deps.isWritable(next.id)) { - shiftPending() + pending.shift() continue } if (next.tooLarge !== false) { - const validationVersion = clearVersion next.tooLarge = await Promise.resolve(next.tooLarge).catch(() => true) - if (validationVersion !== clearVersion) { - continue - } if (next.tooLarge) { - shiftPending() + pending.shift() continue } if (!deps.isWritable(next.id)) { - shiftPending() + pending.shift() continue } } @@ -95,7 +72,7 @@ export function createPtyInputWriteQueue(deps: PtyInputWriteQueueDeps): PtyInput // the PTY byte stream identical while draining the backlog in one turn. if (next.chunks === undefined && isCoalescibleText(next.text)) { let payload = next.text - shiftPending() + pending.shift() while (pending.length > 0) { const peek = pending[0] if ( @@ -109,7 +86,7 @@ export function createPtyInputWriteQueue(deps: PtyInputWriteQueueDeps): PtyInput break } payload += peek.text - shiftPending() + pending.shift() } deps.write(next.id, payload) if (pending.length > 0) { @@ -122,13 +99,13 @@ export function createPtyInputWriteQueue(deps: PtyInputWriteQueueDeps): PtyInput next.nextChunk === undefined ? next.chunks.next() : { done: false, value: next.nextChunk } next.nextChunk = undefined if (chunk.done) { - shiftPending() + pending.shift() continue } deps.write(next.id, chunk.value) const following = next.chunks.next() if (following.done) { - shiftPending() + pending.shift() } else { next.nextChunk = following.value } @@ -157,14 +134,7 @@ export function createPtyInputWriteQueue(deps: PtyInputWriteQueueDeps): PtyInput if (tooLarge === true) { return false } - if ( - pending.length >= maxPendingItems || - pendingCodeUnits + data.length > maxPendingCodeUnits - ) { - return false - } pending.push({ id, text: data, tooLarge }) - pendingCodeUnits += data.length scheduleDrain() return true } catch { @@ -180,12 +150,6 @@ export function createPtyInputWriteQueue(deps: PtyInputWriteQueueDeps): PtyInput clear(): void { pending = [] - pendingCodeUnits = 0 - clearVersion += 1 } } } - -function positiveLimit(value: number | undefined, fallback: number): number { - return Number.isFinite(value) && (value ?? 0) > 0 ? Math.floor(value ?? fallback) : fallback -} diff --git a/src/renderer/src/components/terminal-pane/pty-shutdown-data-suspension.test.ts b/src/renderer/src/components/terminal-pane/pty-shutdown-data-suspension.test.ts index b77d5c5c1ef..45846d08143 100644 --- a/src/renderer/src/components/terminal-pane/pty-shutdown-data-suspension.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-shutdown-data-suspension.test.ts @@ -5,7 +5,6 @@ import { drainRolledBackPtyShutdownData, ptyDataHandlers, ptyReplayHandlers, - PTY_SHUTDOWN_OUTPUT_MAX_EVENTS, ptyShutdownLifecycleHandlers, ptyTeardownHandlers, unregisterPtyDataHandlers @@ -75,20 +74,3 @@ it('retains ordered rollback output across detach and another pending shutdown', ptyTeardownHandlers.delete(ptyId) ptyShutdownLifecycleHandlers.delete(ptyId) }) - -it('bounds zero-byte shutdown events independently of buffered text', () => { - const ptyId = 'pty-shutdown-empty-event-flood' - const delivered = vi.fn() - ptyDataHandlers.set(ptyId, delivered) - ptyReplayHandlers.set(ptyId, vi.fn()) - - const [snapshot] = unregisterPtyDataHandlers([ptyId]) - for (let index = 0; index < PTY_SHUTDOWN_OUTPUT_MAX_EVENTS + 10; index += 1) { - expect(bufferPtyShutdownData(ptyId, '')).toBe(true) - } - snapshot.rollback() - - expect(delivered).toHaveBeenCalledTimes(PTY_SHUTDOWN_OUTPUT_MAX_EVENTS) - ptyDataHandlers.delete(ptyId) - ptyReplayHandlers.delete(ptyId) -}) diff --git a/src/renderer/src/components/terminal-pane/pty-shutdown-data-suspension.ts b/src/renderer/src/components/terminal-pane/pty-shutdown-data-suspension.ts index 9dea97306a7..0bc654bfe8e 100644 --- a/src/renderer/src/components/terminal-pane/pty-shutdown-data-suspension.ts +++ b/src/renderer/src/components/terminal-pane/pty-shutdown-data-suspension.ts @@ -40,7 +40,6 @@ type PtyShutdownOutputEvent = const rolledBackShutdownEvents = new Map() const ROLLED_BACK_SHUTDOWN_REPLAY_MAX_PTYS = 64 -export const PTY_SHUTDOWN_OUTPUT_MAX_EVENTS = 4_096 const shutdownBufferTextEncoder = new TextEncoder() /** Suspend delivery until every overlapping shutdown owner commits or rolls back. */ @@ -118,8 +117,7 @@ function bufferPtyShutdownOutput(ptyId: string, event: PtyShutdownOutputEvent): pending.events.push({ ...event, data: clamped.data }) pending.bufferedBytes += clamped.bytes while ( - (pending.bufferedBytes > TERMINAL_SCROLLBACK_SESSION_BUFFER_BYTE_LIMIT || - pending.events.length > PTY_SHUTDOWN_OUTPUT_MAX_EVENTS) && + pending.bufferedBytes > TERMINAL_SCROLLBACK_SESSION_BUFFER_BYTE_LIMIT && pending.events.length > 1 ) { pending.bufferedBytes -= shutdownBufferTextEncoder.encode( diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-batching.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-batching.test.ts index 974ec388eba..d07bd2900af 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-batching.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-batching.test.ts @@ -119,54 +119,6 @@ describe('createRemoteRuntimePtyTextBatcher', () => { } }) - it('bounds strings retained behind deferred validation without changing accepted order', async () => { - vi.useFakeTimers() - try { - const text = 'x'.repeat(CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS + 1) - const batcher = createRemoteRuntimePtyTextBatcher(1_000, () => {}, { - maxPendingBytes: text.length + 10, - maxValidationQueuedCodeUnits: text.length + 4, - maxValidationQueuedEntries: 3 - }) - - expect(batcher.push(text)).toBe(true) - expect(batcher.push('tail')).toBe(true) - expect(batcher.push('!')).toBe(false) - - const drained = batcher.drain() - await vi.advanceTimersByTimeAsync(0) - await drained - - expect(batcher.takePending()).toBe(`${text}tail`) - } finally { - vi.useRealTimers() - } - }) - - it('bounds tiny entries retained behind deferred validation', async () => { - vi.useFakeTimers() - try { - const text = 'x'.repeat(CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS + 1) - const batcher = createRemoteRuntimePtyTextBatcher(1_000, () => {}, { - maxPendingBytes: text.length + 10, - maxValidationQueuedCodeUnits: text.length + 10, - maxValidationQueuedEntries: 2 - }) - - expect(batcher.push(text)).toBe(true) - expect(batcher.push('a')).toBe(true) - expect(batcher.push('b')).toBe(false) - - const drained = batcher.drain() - await vi.advanceTimersByTimeAsync(0) - await drained - - expect(batcher.takePending()).toBe(`${text}a`) - } finally { - vi.useRealTimers() - } - }) - it('drops asynchronously oversized input without flushing clipboard content', async () => { vi.useFakeTimers() try { diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-batching.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-batching.ts index c2d293f934b..33561b06953 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-batching.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-batching.ts @@ -24,12 +24,8 @@ export type RemoteRuntimeViewportBatcher = { export type RemoteRuntimePtyTextBatcherOptions = { maxPendingBytes?: number maxBytes?: number - maxValidationQueuedCodeUnits?: number - maxValidationQueuedEntries?: number } -export const REMOTE_RUNTIME_PTY_VALIDATION_QUEUE_MAX_ENTRIES = 4_096 - export function createRemoteRuntimePtyTextBatcher( delayMs: number, onFlush: (text: string) => void, @@ -40,21 +36,11 @@ export function createRemoteRuntimePtyTextBatcher( TERMINAL_INPUT_CHUNK_MAX_BYTES ) const maxBytes = getPositiveByteLimit(options.maxBytes, TERMINAL_INPUT_MAX_BYTES) - const maxValidationQueuedCodeUnits = getPositiveByteLimit( - options.maxValidationQueuedCodeUnits, - maxBytes - ) - const maxValidationQueuedEntries = getPositiveByteLimit( - options.maxValidationQueuedEntries, - REMOTE_RUNTIME_PTY_VALIDATION_QUEUE_MAX_ENTRIES - ) let pending = '' let pendingBytes = 0 let timer: ReturnType | null = null let validationTail: Promise | null = null let validationVersion = 0 - let validationQueuedCodeUnits = 0 - let validationQueuedEntries = 0 const clearTimer = (): void => { if (timer) { @@ -69,8 +55,6 @@ export function createRemoteRuntimePtyTextBatcher( pendingBytes = 0 validationVersion += 1 validationTail = null - validationQueuedCodeUnits = 0 - validationQueuedEntries = 0 } const flush = (): void => { @@ -112,16 +96,8 @@ export function createRemoteRuntimePtyTextBatcher( } } - const enqueueValidatedInput = (data: string, tooLarge: false | Promise): boolean => { - if ( - validationQueuedEntries >= maxValidationQueuedEntries || - validationQueuedCodeUnits + data.length > maxValidationQueuedCodeUnits - ) { - return false - } + const enqueueValidatedInput = (data: string, tooLarge: false | Promise): void => { const queuedVersion = validationVersion - validationQueuedEntries += 1 - validationQueuedCodeUnits += data.length const previousTail = validationTail ?? Promise.resolve() const guardedTail = previousTail.then(async () => { if (validationVersion !== queuedVersion) { @@ -137,16 +113,11 @@ export function createRemoteRuntimePtyTextBatcher( const nextTail = guardedTail .catch(() => {}) .finally(() => { - if (validationVersion === queuedVersion) { - validationQueuedEntries -= 1 - validationQueuedCodeUnits -= data.length - } if (validationTail === nextTail) { validationTail = null } }) validationTail = nextTail - return true } const drain = async (): Promise => { @@ -172,7 +143,8 @@ export function createRemoteRuntimePtyTextBatcher( return true } - return enqueueValidatedInput(data, tooLarge) + enqueueValidatedInput(data, tooLarge) + return true }, // Why: earlier input can be mid async byte-length validation and not yet in // `pending`. `takePending()` cannot see it, so callers that must preserve diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts index 3ca3cb964a6..9615f4e493a 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts @@ -49,7 +49,6 @@ import { createRemoteRuntimePtyTextBatcher, createRemoteRuntimeViewportBatcher } from './remote-runtime-pty-batching' -import { createRemoteRuntimeViewportClaimInput } from './remote-runtime-viewport-claim-input' import { REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS, RemoteRuntimePtyRecoveryState @@ -174,7 +173,7 @@ export function createRemoteRuntimePtyTransport( }) let lastRecoveryStateKey = '' let pendingViewportClaim = false - const pendingClaimInput = createRemoteRuntimeViewportClaimInput() + let pendingClaimInput = '' let terminalCreateRetryWait: { timer: ReturnType resolve: (continueRetrying: boolean) => void @@ -197,7 +196,7 @@ export function createRemoteRuntimePtyTransport( const viewportClaimReadyWaiters = new Set<(ready: boolean) => void>() const clearPendingViewportClaim = (): void => { pendingViewportClaim = false - pendingClaimInput.clear() + pendingClaimInput = '' for (const resolve of viewportClaimReadyWaiters) { resolve(false) } @@ -976,7 +975,7 @@ export function createRemoteRuntimePtyTransport( } if (pendingViewportClaim) { // Why: a claim during subscribe/reconnect has no stream record yet; hold its input so the stream emits claim+input in one order. - pendingClaimInput.append(text) + pendingClaimInput += text return } void callRuntime('terminal.send', { @@ -1457,7 +1456,8 @@ export function createRemoteRuntimePtyTransport( if (pendingViewportClaim && desiredViewport) { nextStream.claimViewport(desiredViewport.cols, desiredViewport.rows) pendingViewportClaim = false - const queuedInput = pendingClaimInput.take() + const queuedInput = pendingClaimInput + pendingClaimInput = '' if (queuedInput) { nextStream.sendInput(queuedInput) } @@ -1862,7 +1862,8 @@ export function createRemoteRuntimePtyTransport( return true } if (pendingViewportClaim) { - return pendingClaimInput.append(text) + pendingClaimInput += text + return true } void callRuntime('terminal.send', { terminal: targetHandle, diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-viewport-claim-input.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-viewport-claim-input.test.ts deleted file mode 100644 index a098271eeb2..00000000000 --- a/src/renderer/src/components/terminal-pane/remote-runtime-viewport-claim-input.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { createRemoteRuntimeViewportClaimInput } from './remote-runtime-viewport-claim-input' - -describe('remote runtime viewport-claim input', () => { - it('preserves accepted input order and resets its byte charge on take', () => { - const input = createRemoteRuntimeViewportClaimInput(4) - - expect(input.append('ab')).toBe(true) - expect(input.append('cd')).toBe(true) - expect(input.take()).toBe('abcd') - expect(input.append('next')).toBe(true) - }) - - it('rejects producer backlog before concatenating beyond the byte budget', () => { - const input = createRemoteRuntimeViewportClaimInput(4) - - expect(input.append('é')).toBe(true) - expect(input.append('ab')).toBe(true) - expect(input.append('!')).toBe(false) - expect(input.take()).toBe('éab') - }) -}) diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-viewport-claim-input.ts b/src/renderer/src/components/terminal-pane/remote-runtime-viewport-claim-input.ts deleted file mode 100644 index 2ea9332066c..00000000000 --- a/src/renderer/src/components/terminal-pane/remote-runtime-viewport-claim-input.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { - getTerminalInputByteLength, - TERMINAL_INPUT_MAX_BYTES -} from '../../../../shared/terminal-input' - -export function createRemoteRuntimeViewportClaimInput(maxBytes = TERMINAL_INPUT_MAX_BYTES) { - let text = '' - let bytes = 0 - - return { - append(value: string): boolean { - const valueBytes = getTerminalInputByteLength(value) - if (bytes + valueBytes > maxBytes) { - return false - } - text += value - bytes += valueBytes - return true - }, - clear(): void { - text = '' - bytes = 0 - }, - take(): string { - const value = text - text = '' - bytes = 0 - return value - } - } -} diff --git a/src/renderer/src/components/terminal-pane/terminal-layout-leaf-ids.ts b/src/renderer/src/components/terminal-pane/terminal-layout-leaf-ids.ts index d9d0942fa91..81e4efd6af5 100644 --- a/src/renderer/src/components/terminal-pane/terminal-layout-leaf-ids.ts +++ b/src/renderer/src/components/terminal-pane/terminal-layout-leaf-ids.ts @@ -217,32 +217,6 @@ export function collectLeafIdsInOrder(node: TerminalPaneLayoutNode | null | unde return [...collectLeafIdsInOrder(node.first), ...collectLeafIdsInOrder(node.second)] } -export function collectTerminalLayoutLeafIds( - snapshot: TerminalLayoutSnapshot | null | undefined -): string[] { - if (!snapshot) { - return [] - } - const ids = new Set(collectLeafIdsInOrder(snapshot.root)) - if (snapshot.activeLeafId) { - ids.add(snapshot.activeLeafId) - } - if (snapshot.expandedLeafId) { - ids.add(snapshot.expandedLeafId) - } - for (const record of [ - snapshot.ptyIdsByLeafId, - snapshot.buffersByLeafId, - snapshot.scrollbackRefsByLeafId, - snapshot.titlesByLeafId - ]) { - for (const leafId of Object.keys(record ?? {})) { - ids.add(leafId) - } - } - return [...ids] -} - export function resolvePtyBoundActiveLeafId(args: { root: TerminalPaneLayoutNode | null | undefined activeLeafId: string | null | undefined diff --git a/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts b/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts index 713c8bf1bde..578f225ac3b 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts @@ -11,8 +11,7 @@ import { installFilePathLinkClickFallback, isTerminalLinkActivation, openFilePathLinkAtBufferPosition, - openDetectedFilePath, - TERMINAL_FILE_LINK_PROBE_CONCURRENCY + openDetectedFilePath } from './terminal-link-handlers' import { TERMINAL_PATH_EXISTS_CACHE_MAX_ENTRIES } from './terminal-path-exists-cache' import { handleOscLink } from './terminal-osc-link-routing' @@ -1233,43 +1232,6 @@ describe('createFilePathLinkProvider range bounds', () => { expect(shellPathExists).toHaveBeenCalled() }) - it.each([ - ['at the limit', TERMINAL_FILE_LINK_PROBE_CONCURRENCY], - ['above the limit', TERMINAL_FILE_LINK_PROBE_CONCURRENCY + 1] - ])('bounds path-existence probes %s', async (_, count) => { - let active = 0 - let peak = 0 - const releases: (() => void)[] = [] - const shellPathExists = vi.mocked(window.api.shell.pathExists) - shellPathExists.mockImplementation(async () => { - active++ - peak = Math.max(peak, active) - await new Promise((resolve) => releases.push(resolve)) - active-- - return true - }) - const text = Array.from({ length: count }, (_, index) => `file-${index}.ts`).join(' ') - const { provider } = createProviderSetup([makeBufferLine(text)], new Map()) - const links = new Promise((resolve) => { - provider.provideLinks(1, (provided) => resolve(provided ?? [])) - }) - - expect(shellPathExists).toHaveBeenCalledTimes( - Math.min(count, TERMINAL_FILE_LINK_PROBE_CONCURRENCY) - ) - if (count > TERMINAL_FILE_LINK_PROBE_CONCURRENCY) { - releases.shift()?.() - for (let turn = 0; turn < 5 && shellPathExists.mock.calls.length < count; turn++) { - await Promise.resolve() - } - expect(shellPathExists).toHaveBeenCalledTimes(count) - } - releases.splice(0).forEach((release) => release()) - - await expect(links).resolves.toHaveLength(count) - expect(peak).toBe(Math.min(count, TERMINAL_FILE_LINK_PROBE_CONCURRENCY)) - }) - it('does not invoke the xterm callback twice when the callback throws', async () => { const { provider } = createProviderSetup([makeBufferLine('CLAUDE.md')]) const callback = vi.fn(() => { diff --git a/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts b/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts index 22e570eae82..78e50b97940 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts @@ -1,5 +1,4 @@ import type { IDisposable, ILink, ILinkProvider, Terminal } from '@xterm/xterm' -import { mapWithConcurrency } from '../../../../shared/map-with-concurrency' import { extractTerminalFileLinkCandidates, extractTerminalFileLinks, @@ -57,9 +56,10 @@ export type LinkHandlerDeps = { getRuntimeEnvironmentIdForPane?: (paneId: number) => string | null } -type ProvidedFileLink = { link: ILink; logicalLine: WrappedLogicalLine } - -export const TERMINAL_FILE_LINK_PROBE_CONCURRENCY = 8 +type ProvidedFileLink = { + link: ILink + logicalLine: WrappedLogicalLine +} function rangesOverlap(left: ILink['range'], right: ILink['range']): boolean { const leftStartsAfterRightEnds = @@ -121,99 +121,99 @@ export function createFilePathLinkProvider( return } - const candidates = logicalLines.flatMap((logicalLine) => - extractTerminalFileLinkCandidates(logicalLine.text).map((parsed) => ({ - logicalLine, - parsed - })) - ) - void mapWithConcurrency( - candidates, - TERMINAL_FILE_LINK_PROBE_CONCURRENCY, - async ({ logicalLine, parsed }): Promise => { - const paneLinkCwd = deps.getPaneLinkCwd?.(paneId) ?? startupCwd - const resolved = paneLinkCwd - ? resolveTerminalFileLink(parsed, paneLinkCwd, deps.terminalHomePath) - : null - if (!resolved) { - return null - } - const range = rangeForParsedFileLink(logicalLine, parsed.startIndex, parsed.endIndex) - if (!range) { - return null - } + void Promise.all( + logicalLines.flatMap((logicalLine) => + extractTerminalFileLinkCandidates(logicalLine.text).map( + async (parsed): Promise => { + const paneLinkCwd = deps.getPaneLinkCwd?.(paneId) ?? startupCwd + const resolved = paneLinkCwd + ? resolveTerminalFileLink(parsed, paneLinkCwd, deps.terminalHomePath) + : null + if (!resolved) { + return null + } + const range = rangeForParsedFileLink(logicalLine, parsed.startIndex, parsed.endIndex) + if (!range) { + return null + } - const runtimeEnvironmentId = - deps.getRuntimeEnvironmentIdForPane?.(paneId) ?? deps.runtimeEnvironmentId ?? null - const fileContext = getTerminalFileContext(worktreeId, worktreePath, runtimeEnvironmentId) - const isRemoteRuntimePath = isRemoteRuntimeFileOperation( - fileContext, - resolved.absolutePath - ) - const cacheKey = getTerminalPathExistsCacheKey({ - absolutePath: resolved.absolutePath, - connectionId: fileContext.connectionId, - isRemoteRuntimePath, - runtimeEnvironmentId - }) - const worktreeRootLink = resolveKnownWorktreeRootPathLink(resolved.absolutePath) - if (/[\\/]$/.test(parsed.pathText) && !worktreeRootLink) { - return null - } - // Why: exact known workspace roots must stay clickable for SSH or - // stale local paths even when filesystem probing says "missing". - if (!worktreeRootLink) { - const cachedExists = readTerminalPathExistsCache(pathExistsCache, cacheKey) - const exists = - cachedExists ?? - (fileContext.connectionId || isRemoteRuntimePath - ? await runtimePathExists(fileContext, resolved.absolutePath) - : await window.api.shell.pathExists(resolved.absolutePath)) - writeTerminalPathExistsCache(pathExistsCache, cacheKey, exists) - if (!exists) { - return null - } - } + const runtimeEnvironmentId = + deps.getRuntimeEnvironmentIdForPane?.(paneId) ?? deps.runtimeEnvironmentId ?? null + const fileContext = getTerminalFileContext( + worktreeId, + worktreePath, + runtimeEnvironmentId + ) + const isRemoteRuntimePath = isRemoteRuntimeFileOperation( + fileContext, + resolved.absolutePath + ) + const cacheKey = getTerminalPathExistsCacheKey({ + absolutePath: resolved.absolutePath, + connectionId: fileContext.connectionId, + isRemoteRuntimePath, + runtimeEnvironmentId + }) + const worktreeRootLink = resolveKnownWorktreeRootPathLink(resolved.absolutePath) + if (/[\\/]$/.test(parsed.pathText) && !worktreeRootLink) { + return null + } + // Why: exact known workspace roots must stay clickable for SSH or + // stale local paths even when filesystem probing says "missing". + if (!worktreeRootLink) { + const cachedExists = readTerminalPathExistsCache(pathExistsCache, cacheKey) + const exists = + cachedExists ?? + (fileContext.connectionId || isRemoteRuntimePath + ? await runtimePathExists(fileContext, resolved.absolutePath) + : await window.api.shell.pathExists(resolved.absolutePath)) + writeTerminalPathExistsCache(pathExistsCache, cacheKey, exists) + if (!exists) { + return null + } + } - return { - logicalLine, - link: { - range, - text: parsed.displayText, - activate: (event) => { - if (!isTerminalLinkActivation(event)) { - return + return { + logicalLine, + link: { + range, + text: parsed.displayText, + activate: (event) => { + if (!isTerminalLinkActivation(event)) { + return + } + openDetectedFilePath(resolved.absolutePath, resolved.line, resolved.column, { + worktreeId, + worktreePath, + runtimeEnvironmentId, + openWithSystemDefault: Boolean(event.shiftKey) + }) + }, + hover: () => { + // Why: only local paths can offer the Shift+modifier system + // default escape hatch; remote paths may not exist locally. + const canOpenWithSystemDefault = shouldOpenTerminalFileWithSystemDefault( + fileContext, + resolved.absolutePath + ) + const hint = worktreeRootLink + ? getTerminalWorktreePathOpenHint(canOpenWithSystemDefault) + : canOpenWithSystemDefault + ? isHtmlFilePath(resolved.absolutePath) + ? getTerminalHtmlFileOpenHint() + : openLinkHint + : getTerminalOrcaFileOpenHint() + linkTooltip.textContent = `${resolved.absolutePath} (${hint})` + linkTooltip.style.display = '' + }, + leave: () => { + linkTooltip.style.display = 'none' + } } - openDetectedFilePath(resolved.absolutePath, resolved.line, resolved.column, { - worktreeId, - worktreePath, - runtimeEnvironmentId, - openWithSystemDefault: Boolean(event.shiftKey) - }) - }, - hover: () => { - // Why: only local paths can offer the Shift+modifier system - // default escape hatch; remote paths may not exist locally. - const canOpenWithSystemDefault = shouldOpenTerminalFileWithSystemDefault( - fileContext, - resolved.absolutePath - ) - const hint = worktreeRootLink - ? getTerminalWorktreePathOpenHint(canOpenWithSystemDefault) - : canOpenWithSystemDefault - ? isHtmlFilePath(resolved.absolutePath) - ? getTerminalHtmlFileOpenHint() - : openLinkHint - : getTerminalOrcaFileOpenHint() - linkTooltip.textContent = `${resolved.absolutePath} (${hint})` - linkTooltip.style.display = '' - }, - leave: () => { - linkTooltip.style.display = 'none' } } - } - } + ) + ) ) .then( (resolvedLinks) => { diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-recovery-retirement.test.ts b/src/renderer/src/components/terminal-pane/terminal-pane-recovery-retirement.test.ts deleted file mode 100644 index d07b613146f..00000000000 --- a/src/renderer/src/components/terminal-pane/terminal-pane-recovery-retirement.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { - forgetRetiredTerminalPaneRecovery, - registerTerminalPaneRecoveryRetirementHandler -} from './terminal-pane-recovery-retirement' - -describe('terminal pane recovery retirement bridge', () => { - let unregister: (() => void) | undefined - - afterEach(() => unregister?.()) - - it('routes authoritative retirement without coupling the store to recovery state', () => { - const handler = vi.fn() - unregister = registerTerminalPaneRecoveryRetirementHandler(handler) - - forgetRetiredTerminalPaneRecovery('tab-1') - - expect(handler).toHaveBeenCalledWith('tab-1') - }) - - it('does not retain a retired module handler', () => { - const handler = vi.fn() - unregister = registerTerminalPaneRecoveryRetirementHandler(handler) - unregister() - unregister = undefined - - forgetRetiredTerminalPaneRecovery('tab-1') - - expect(handler).not.toHaveBeenCalled() - }) -}) diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-recovery-retirement.ts b/src/renderer/src/components/terminal-pane/terminal-pane-recovery-retirement.ts deleted file mode 100644 index cf3b6040f33..00000000000 --- a/src/renderer/src/components/terminal-pane/terminal-pane-recovery-retirement.ts +++ /dev/null @@ -1,18 +0,0 @@ -type TerminalPaneRecoveryRetirementHandler = (tabId: string) => void - -let retirementHandler: TerminalPaneRecoveryRetirementHandler | null = null - -export function registerTerminalPaneRecoveryRetirementHandler( - handler: TerminalPaneRecoveryRetirementHandler -): () => void { - retirementHandler = handler - return () => { - if (retirementHandler === handler) { - retirementHandler = null - } - } -} - -export function forgetRetiredTerminalPaneRecovery(tabId: string): void { - retirementHandler?.(tabId) -} diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-recovery.test.ts b/src/renderer/src/components/terminal-pane/terminal-pane-recovery.test.ts index e9bb49db467..7e0486523ef 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-recovery.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-recovery.test.ts @@ -2,7 +2,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { _resetTerminalPaneRecoveryForTests, captureTerminalPaneRecoveryGeneration, - forgetTerminalPaneRecovery, registerTerminalPaneRecoveryInstance, requestTerminalPaneRecovery } from './terminal-pane-recovery' @@ -273,36 +272,6 @@ describe('requestTerminalPaneRecovery', () => { healthySuccessor.unregister() }) - it('forgets recovery history and pending work only after authoritative tab retirement', async () => { - vi.useFakeTimers() - vi.setSystemTime(0) - const instance = registerTerminalPaneRecoveryInstance('tab-1') - await requestTerminalPaneRecovery({ - tabId: 'tab-1', - ptyId: 'pty-1', - reason: 'write-stalled', - terminalRecoveryGeneration: captureTerminalPaneRecoveryGeneration('tab-1'), - terminalRecoveryInstanceId: instance.id - }) - const recoveredGeneration = captureTerminalPaneRecoveryGeneration('tab-1') - await requestTerminalPaneRecovery({ - tabId: 'tab-1', - ptyId: 'pty-1', - reason: 'replay-wedged', - terminalRecoveryGeneration: recoveredGeneration, - terminalRecoveryInstanceId: instance.id - }) - - expect(recoveredGeneration).toBe(1) - expect(vi.getTimerCount()).toBe(1) - forgetTerminalPaneRecovery('tab-1') - expect(captureTerminalPaneRecoveryGeneration('tab-1')).toBe(0) - expect(vi.getTimerCount()).toBe(0) - - await vi.advanceTimersByTimeAsync(600_000) - expect(mocks.remountTerminalTabForRecovery).toHaveBeenCalledTimes(1) - }) - it('keeps a sibling pane retry when the first requesting split is disposed', async () => { vi.useFakeTimers() vi.setSystemTime(0) diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-recovery.ts b/src/renderer/src/components/terminal-pane/terminal-pane-recovery.ts index 1c56b9f17b2..791234b232b 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-recovery.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-recovery.ts @@ -1,6 +1,5 @@ import { useAppStore } from '@/store' import { recordRendererCrashBreadcrumb } from '@/lib/crash-breadcrumb-recorder' -import { registerTerminalPaneRecoveryRetirementHandler } from './terminal-pane-recovery-retirement' // Why this module exists: a terminal pane can die renderer-side while its PTY // stays alive — a wedged xterm WriteBuffer (issue #2836), a disposed xterm @@ -51,7 +50,6 @@ const RECOVERY_COOLDOWN_MS = 15_000 const recoveryTimestampsByTabId = new Map() const recoveryGenerationByTabId = new Map() const activeTerminalRecoveryInstanceIds = new Set() -const terminalRecoveryTabIdByInstanceId = new Map() const pendingRetryByTabId = new Map< string, { @@ -100,12 +98,10 @@ export function registerTerminalPaneRecoveryInstance(tabId: string): { } { const id = ++nextTerminalRecoveryInstanceId activeTerminalRecoveryInstanceIds.add(id) - terminalRecoveryTabIdByInstanceId.set(id, tabId) return { id, unregister: () => { activeTerminalRecoveryInstanceIds.delete(id) - terminalRecoveryTabIdByInstanceId.delete(id) const pendingRetry = pendingRetryByTabId.get(tabId) pendingRetry?.requestsByInstanceId.delete(id) if (pendingRetry?.requestsByInstanceId.size === 0) { @@ -275,30 +271,10 @@ export async function requestTerminalPaneRecovery(request: RecoveryRequest): Pro return true } -export function forgetTerminalPaneRecovery(tabId: string): void { - recoveryTimestampsByTabId.delete(tabId) - recoveryGenerationByTabId.delete(tabId) - cancelPendingRecoveryRetry(tabId) - for (const [instanceId, instanceTabId] of terminalRecoveryTabIdByInstanceId) { - if (instanceTabId === tabId) { - terminalRecoveryTabIdByInstanceId.delete(instanceId) - activeTerminalRecoveryInstanceIds.delete(instanceId) - } - } -} - -const unregisterTerminalPaneRecoveryRetirement = registerTerminalPaneRecoveryRetirementHandler( - forgetTerminalPaneRecovery -) -if (import.meta.hot) { - import.meta.hot.dispose(unregisterTerminalPaneRecoveryRetirement) -} - export function _resetTerminalPaneRecoveryForTests(): void { recoveryTimestampsByTabId.clear() recoveryGenerationByTabId.clear() activeTerminalRecoveryInstanceIds.clear() - terminalRecoveryTabIdByInstanceId.clear() nextTerminalRecoveryInstanceId = 0 for (const pendingRetry of pendingRetryByTabId.values()) { clearTimeout(pendingRetry.timer) diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index b12f066cd45..4c71bf66a54 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -110,10 +110,7 @@ import { getConnectionId } from '@/lib/connection-context' import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' import { isPaneReplaying, type ReplayingPanesRef } from './replay-guard' import { fitAndFocusPanes, fitPanes } from './pane-helpers' -import { - markTerminalPinnedViewport, - releaseTerminalScrollIntentKeys -} from '@/lib/pane-manager/terminal-scroll-intent' +import { markTerminalPinnedViewport } from '@/lib/pane-manager/terminal-scroll-intent' import { syncTerminalScrollIntentSoon } from '@/lib/pane-manager/terminal-scroll-intent-settle' import { registerRuntimeTerminalTab, scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph' import { captureParkedTerminalPaneCandidates } from './terminal-parked-tab-watchers' @@ -1225,7 +1222,6 @@ export function useTerminalPaneLifecycle({ } const leafId = closedPane?.leafId if (leafId && !isDetachedToTab) { - releaseTerminalScrollIntentKeys([leafId]) // Why: revoke only this pane's authority; an exact tombstone blocks queued hooks without suppressing siblings. const paneKey = makePaneKey(tabId, leafId) useAppStore.getState().retireAgentPaneAuthority(paneKey) diff --git a/src/renderer/src/hooks/installed-skill-discovery-cache.ts b/src/renderer/src/hooks/installed-skill-discovery-cache.ts deleted file mode 100644 index c9cd4542723..00000000000 --- a/src/renderer/src/hooks/installed-skill-discovery-cache.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { SkillDiscoveryResult } from '../../../shared/skills' - -export const MAX_CACHED_SKILL_DISCOVERY_TARGETS = 32 -export const MAX_PENDING_SKILL_DISCOVERY_TARGETS = 16 - -export class InstalledSkillDiscoveryCache { - private entries = new Map() - - get(key: string): SkillDiscoveryResult | undefined { - const cached = this.entries.get(key) - if (cached) { - this.entries.delete(key) - this.entries.set(key, cached) - } - return cached - } - - set(key: string, result: SkillDiscoveryResult): void { - this.entries.delete(key) - this.entries.set(key, result) - while (this.entries.size > MAX_CACHED_SKILL_DISCOVERY_TARGETS) { - const oldestKey = this.entries.keys().next().value - if (oldestKey === undefined) { - break - } - this.entries.delete(oldestKey) - } - } - - clear(): void { - this.entries.clear() - } - - get size(): number { - return this.entries.size - } -} diff --git a/src/renderer/src/hooks/installed-skill-discovery-coordinator.ts b/src/renderer/src/hooks/installed-skill-discovery-coordinator.ts deleted file mode 100644 index 82d1b0d5de5..00000000000 --- a/src/renderer/src/hooks/installed-skill-discovery-coordinator.ts +++ /dev/null @@ -1,87 +0,0 @@ -import type { SkillDiscoveryResult } from '../../../shared/skills' -import { - InstalledSkillDiscoveryCache, - MAX_PENDING_SKILL_DISCOVERY_TARGETS -} from './installed-skill-discovery-cache' - -export class InstalledSkillDiscoveryCoordinator { - private readonly cache = new InstalledSkillDiscoveryCache() - private readonly pending = new Map>() - private readonly pendingSatisfiesForcedRefresh = new Map() - private cacheGeneration = 0 - - getCached(key: string): SkillDiscoveryResult | undefined { - return this.cache.get(key) - } - - clearCache(): void { - this.cacheGeneration += 1 - this.cache.clear() - } - - sizes(): { cached: number; pending: number } { - return { cached: this.cache.size, pending: this.pending.size } - } - - async discover({ - force, - key, - run - }: { - force: boolean - key: string - run: () => Promise - }): Promise { - const cached = this.cache.get(key) - if (!force && cached) { - return cached - } - const inFlight = this.pending.get(key) - if (inFlight) { - if (!force || this.pendingSatisfiesForcedRefresh.get(key)) { - return inFlight - } - try { - await inFlight - } catch { - // Why: an explicit re-check still needs current disk state after a background failure. - } - const replacement = this.pending.get(key) - if (replacement && replacement !== inFlight) { - return replacement - } - } - return this.start(force, key, run) - } - - private start( - force: boolean, - key: string, - run: () => Promise - ): Promise { - if (!this.pending.has(key) && this.pending.size >= MAX_PENDING_SKILL_DISCOVERY_TARGETS) { - return Promise.reject( - new Error( - `Too many concurrent installed-skill discovery targets (max ${MAX_PENDING_SKILL_DISCOVERY_TARGETS})` - ) - ) - } - const cacheGeneration = this.cacheGeneration - const discovery = run() - .then((result) => { - if (cacheGeneration === this.cacheGeneration) { - this.cache.set(key, result) - } - return result - }) - .finally(() => { - if (this.pending.get(key) === discovery) { - this.pending.delete(key) - this.pendingSatisfiesForcedRefresh.delete(key) - } - }) - this.pending.set(key, discovery) - this.pendingSatisfiesForcedRefresh.set(key, force) - return discovery - } -} diff --git a/src/renderer/src/hooks/metadata-request-cache.test.ts b/src/renderer/src/hooks/metadata-request-cache.test.ts index efd8cc4e687..7770aca9637 100644 --- a/src/renderer/src/hooks/metadata-request-cache.test.ts +++ b/src/renderer/src/hooks/metadata-request-cache.test.ts @@ -3,11 +3,7 @@ import { clearMetadataRequestStore, createMetadataRequestStore, getFreshMetadata, - loadMetadata, - MAX_METADATA_ERROR_SUMMARY_BYTES, - MAX_METADATA_INFLIGHT_ENTRIES, - MAX_METADATA_KEY_BYTES, - MAX_METADATA_VALUE_BYTES + loadMetadata } from './metadata-request-cache' describe('metadata-request-cache', () => { @@ -92,7 +88,7 @@ describe('metadata-request-cache', () => { expect(store.failures.has('repo:labels')).toBe(false) }) - it('keeps failure entries isolated per key', async () => { + it('keeps failure entries isolated per key and bounded', async () => { const store = createMetadataRequestStore() await expect( loadMetadata( @@ -110,25 +106,6 @@ describe('metadata-request-cache', () => { expect(fetcherB).toHaveBeenCalledTimes(1) }) - it('bounds retained failure entries', async () => { - const store = createMetadataRequestStore() - - for (let index = 0; index <= 200; index += 1) { - await expect( - loadMetadata( - store, - `repo-${index}:labels`, - () => Promise.reject(new Error(`down-${index}`)), - () => index - ) - ).rejects.toThrow(`down-${index}`) - } - - expect(store.failures.size).toBe(200) - expect(store.failures.has('repo-0:labels')).toBe(false) - expect(store.failures.has('repo-200:labels')).toBe(true) - }) - it('does not record failures from a cleared generation', async () => { const store = createMetadataRequestStore() let rejectRequest: (error: Error) => void = () => {} @@ -146,7 +123,6 @@ describe('metadata-request-cache', () => { rejectRequest(new Error('stale failure')) await expect(pending).rejects.toThrow('stale failure') expect(store.failures.size).toBe(0) - expect(store.retainedBytes).toBe(0) const fetcher = vi.fn(() => Promise.resolve(['fresh'])) await expect(loadMetadata(store, 'repo:labels', fetcher, () => 1_500)).resolves.toEqual([ @@ -174,41 +150,6 @@ describe('metadata-request-cache', () => { await expect(pending).resolves.toEqual(['old-user']) expect(getFreshMetadata(store, 'team:members', 1_100)).toBeNull() - expect(store.retainedBytes).toBe(0) - }) - - it('does not let a cleared request release a newer request for the same key', async () => { - const store = createMetadataRequestStore() - let resolveStale: (value: string) => void = () => {} - let resolveCurrent: (value: string) => void = () => {} - const stale = loadMetadata( - store, - 'same', - () => - new Promise((resolve) => { - resolveStale = resolve - }) - ) - - clearMetadataRequestStore(store) - const current = loadMetadata( - store, - 'same', - () => - new Promise((resolve) => { - resolveCurrent = resolve - }) - ) - - resolveStale('stale') - await expect(stale).resolves.toBe('stale') - expect(store.inflight.has('same')).toBe(true) - expect(store.retainedBytes).toBe(4) - - resolveCurrent('current') - await expect(current).resolves.toBe('current') - expect(store.cache.get('same')?.data).toBe('current') - expect(store.retainedBytes).toBe(11) }) it('prunes stale cache entries when they age past the metadata ttl', async () => { @@ -242,140 +183,4 @@ describe('metadata-request-cache', () => { expect(store.cache.has('repo-0:labels')).toBe(false) expect(store.cache.get('repo-500:labels')?.data).toEqual(['label-500']) }) - - it('rejects distinct hung fetches beyond the in-flight bound', async () => { - const store = createMetadataRequestStore() - const pending = Array.from({ length: MAX_METADATA_INFLIGHT_ENTRIES }, (_, index) => - loadMetadata(store, `repo-${index}:labels`, () => new Promise(() => {})) - ) - - await expect( - loadMetadata(store, 'overflow:labels', () => Promise.resolve(['unexpected'])) - ).rejects.toThrow('queue is full') - expect(store.inflight.size).toBe(MAX_METADATA_INFLIGHT_ENTRIES) - - clearMetadataRequestStore(store) - void pending - }) - - it('accepts an exact-limit key and rejects an oversized key before fetching', async () => { - const store = createMetadataRequestStore() - const exactKey = '🙂'.repeat(MAX_METADATA_KEY_BYTES / 4) - await expect(loadMetadata(store, exactKey, () => Promise.resolve(''))).resolves.toBe('') - expect(store.cache.has(exactKey)).toBe(true) - - const fetcher = vi.fn(() => Promise.resolve('unexpected')) - await expect(loadMetadata(store, `${exactKey}x`, fetcher)).rejects.toThrow( - `exceeds ${MAX_METADATA_KEY_BYTES} bytes` - ) - expect(fetcher).not.toHaveBeenCalled() - }) - - it('retains an exact-limit value and lets an oversized value recover without retention', async () => { - const exactStore = createMetadataRequestStore() - const exactValue = 'v'.repeat(MAX_METADATA_VALUE_BYTES) - await expect( - loadMetadata(exactStore, 'exact', () => Promise.resolve(exactValue)) - ).resolves.toBe(exactValue) - expect(exactStore.cache.get('exact')?.data).toBe(exactValue) - - const oversizedStore = createMetadataRequestStore() - const oversizedValue = `${exactValue}x` - await expect( - loadMetadata(oversizedStore, 'recoverable', () => Promise.resolve(oversizedValue)) - ).resolves.toBe(oversizedValue) - expect(oversizedStore.cache.has('recoverable')).toBe(false) - - await expect( - loadMetadata(oversizedStore, 'recoverable', () => Promise.resolve('small')) - ).resolves.toBe('small') - expect(oversizedStore.cache.get('recoverable')?.data).toBe('small') - }) - - it('stores only an exact bounded error summary and reuses that summary', async () => { - const store = createMetadataRequestStore() - const error = new Error('oversized remote failure') - error.name = 'RemoteError' - error.message = 'x'.repeat(MAX_METADATA_ERROR_SUMMARY_BYTES - error.name.length + 1_000) - - await expect( - loadMetadata( - store, - 'failure', - () => Promise.reject(error), - () => 1_000 - ) - ).rejects.toBe(error) - - const cached = store.failures.get('failure') - expect((cached?.error.name.length ?? 0) + (cached?.error.message.length ?? 0)).toBe( - MAX_METADATA_ERROR_SUMMARY_BYTES - ) - expect(cached?.error.stack).toBeUndefined() - await expect( - loadMetadata( - store, - 'failure', - () => Promise.resolve('unexpected'), - () => 1_001 - ) - ).rejects.toThrow(cached?.error.message) - }) - - it('fills the aggregate budget exactly and evicts oldest retained data for recovery', async () => { - const store = createMetadataRequestStore({ maxRetainedBytes: 20 }) - - await loadMetadata( - store, - 'a', - () => Promise.resolve('x'.repeat(9)), - () => 1 - ) - await loadMetadata( - store, - 'b', - () => Promise.resolve('y'.repeat(9)), - () => 2 - ) - expect(store.retainedBytes).toBe(20) - - await loadMetadata( - store, - 'c', - () => Promise.resolve('z'.repeat(9)), - () => 3 - ) - expect(store.retainedBytes).toBe(20) - expect(store.cache.has('a')).toBe(false) - expect(store.cache.has('b')).toBe(true) - expect(store.cache.has('c')).toBe(true) - }) - - it('rejects in-flight aggregate overload and accepts work after memory is released', async () => { - const store = createMetadataRequestStore({ maxRetainedBytes: 4 }) - let resolveFirst: (value: string) => void = () => {} - const first = loadMetadata( - store, - 'aa', - () => - new Promise((resolve) => { - resolveFirst = resolve - }) - ) - const second = loadMetadata(store, 'bb', () => new Promise(() => {})) - - expect(store.retainedBytes).toBe(4) - await expect(loadMetadata(store, 'c', () => Promise.resolve(''))).rejects.toThrow( - 'memory budget is full' - ) - - resolveFirst('') - await expect(first).resolves.toBe('') - await expect(loadMetadata(store, 'c', () => Promise.resolve(''))).resolves.toBe('') - expect(store.retainedBytes).toBeLessThanOrEqual(4) - - clearMetadataRequestStore(store) - expect(store.retainedBytes).toBe(0) - void second - }) }) diff --git a/src/renderer/src/hooks/metadata-request-cache.ts b/src/renderer/src/hooks/metadata-request-cache.ts index 127c553a8a9..033f5e984c3 100644 --- a/src/renderer/src/hooks/metadata-request-cache.ts +++ b/src/renderer/src/hooks/metadata-request-cache.ts @@ -1,6 +1,3 @@ -import { measureUtf8ByteLength } from '../../../shared/utf8-byte-limits' -import { boundedMetadataFailure, measureMetadataValueBytes } from './metadata-retention-measurement' - const METADATA_TTL = 300_000 // 5 min const MAX_METADATA_CACHE_ENTRIES = 500 // Why: an unreachable provider/runtime fails every consumer render; without a @@ -9,40 +6,23 @@ const MAX_METADATA_CACHE_ENTRIES = 500 // short enough that a recovered provider is picked up within seconds. const METADATA_FAILURE_TTL = 10_000 const MAX_METADATA_FAILURE_ENTRIES = 200 -export const MAX_METADATA_INFLIGHT_ENTRIES = 100 -export const MAX_METADATA_KEY_BYTES = 4 * 1024 -export const MAX_METADATA_VALUE_BYTES = 512 * 1024 -export const MAX_METADATA_ERROR_SUMMARY_BYTES = 4 * 1024 -export const MAX_METADATA_RETAINED_BYTES = 16 * 1024 * 1024 -type CachedMetadata = { data: T; fetchedAt: number; retainedBytes: number } -type CachedMetadataFailure = { error: Error; failedAt: number; retainedBytes: number } - -type MetadataRequestStoreOptions = { - maxRetainedBytes?: number -} +type CachedMetadata = { data: T; fetchedAt: number } +type CachedMetadataFailure = { error: unknown; failedAt: number } export type MetadataRequestStore = { cache: Map> inflight: Map> - inflightEntryBytes: Map failures: Map generation: number - retainedBytes: number - maxRetainedBytes: number } -export function createMetadataRequestStore( - options: MetadataRequestStoreOptions = {} -): MetadataRequestStore { +export function createMetadataRequestStore(): MetadataRequestStore { return { cache: new Map(), inflight: new Map(), - inflightEntryBytes: new Map(), failures: new Map(), - generation: 0, - retainedBytes: 0, - maxRetainedBytes: clampRetainedByteLimit(options.maxRetainedBytes) + generation: 0 } } @@ -50,49 +30,7 @@ export function clearMetadataRequestStore(store: MetadataRequestStore): vo store.generation += 1 store.cache.clear() store.inflight.clear() - store.inflightEntryBytes.clear() store.failures.clear() - store.retainedBytes = 0 -} - -function clampRetainedByteLimit(value: number | undefined): number { - if (value === undefined || !Number.isSafeInteger(value) || value <= 0) { - return MAX_METADATA_RETAINED_BYTES - } - return Math.min(value, MAX_METADATA_RETAINED_BYTES) -} - -function metadataKeyBytes(key: string): number | null { - const measured = measureUtf8ByteLength(key, { stopAfterBytes: MAX_METADATA_KEY_BYTES }) - return measured.exceededLimit ? null : measured.byteLength -} - -function deleteCachedMetadata(store: MetadataRequestStore, key: string): void { - const entry = store.cache.get(key) - if (entry && store.cache.delete(key)) { - store.retainedBytes -= entry.retainedBytes - } -} - -function deleteMetadataFailure(store: MetadataRequestStore, key: string): void { - const entry = store.failures.get(key) - if (entry && store.failures.delete(key)) { - store.retainedBytes -= entry.retainedBytes - } -} - -function releaseMetadataInflight( - store: MetadataRequestStore, - key: string, - promise: Promise -): void { - if (store.inflight.get(key) !== promise) { - return - } - store.inflight.delete(key) - const retainedBytes = store.inflightEntryBytes.get(key) ?? 0 - store.inflightEntryBytes.delete(key) - store.retainedBytes -= retainedBytes } function pruneMetadataCache( @@ -102,7 +40,7 @@ function pruneMetadataCache( ): void { for (const [key, entry] of store.cache) { if (now - entry.fetchedAt >= METADATA_TTL) { - deleteCachedMetadata(store, key) + store.cache.delete(key) } } if (store.cache.size <= maxEntries) { @@ -110,7 +48,7 @@ function pruneMetadataCache( } const sorted = [...store.cache.entries()].sort((a, b) => b[1].fetchedAt - a[1].fetchedAt) for (const [key] of sorted.slice(maxEntries)) { - deleteCachedMetadata(store, key) + store.cache.delete(key) } } @@ -119,10 +57,6 @@ export function getFreshMetadata( key: string, now = Date.now() ): CachedMetadata | null { - if (metadataKeyBytes(key) === null) { - deleteCachedMetadata(store, key) - return null - } pruneMetadataCache(store, now) const entry = store.cache.get(key) if (!entry || now - entry.fetchedAt >= METADATA_TTL) { @@ -138,7 +72,7 @@ function pruneMetadataFailures( ): void { for (const [key, entry] of store.failures) { if (now - entry.failedAt >= METADATA_FAILURE_TTL) { - deleteMetadataFailure(store, key) + store.failures.delete(key) } } if (store.failures.size <= maxEntries) { @@ -146,7 +80,7 @@ function pruneMetadataFailures( } const sorted = [...store.failures.entries()].sort((a, b) => b[1].failedAt - a[1].failedAt) for (const [key] of sorted.slice(maxEntries)) { - deleteMetadataFailure(store, key) + store.failures.delete(key) } } @@ -155,11 +89,6 @@ export function getRecentMetadataFailure( key: string, now = Date.now() ): CachedMetadataFailure | null { - if (metadataKeyBytes(key) === null) { - deleteMetadataFailure(store, key) - return null - } - pruneMetadataFailures(store, now) const entry = store.failures.get(key) if (!entry || now - entry.failedAt >= METADATA_FAILURE_TTL) { return null @@ -167,56 +96,12 @@ export function getRecentMetadataFailure( return entry } -function oldestRetainedEntry( - store: MetadataRequestStore -): { key: string; kind: 'cache' | 'failure'; retainedAt: number } | null { - let oldest: { key: string; kind: 'cache' | 'failure'; retainedAt: number } | null = null - for (const [key, entry] of store.failures) { - if (!oldest || entry.failedAt < oldest.retainedAt) { - oldest = { key, kind: 'failure', retainedAt: entry.failedAt } - } - } - for (const [key, entry] of store.cache) { - if (!oldest || entry.fetchedAt < oldest.retainedAt) { - oldest = { key, kind: 'cache', retainedAt: entry.fetchedAt } - } - } - return oldest -} - -function reserveMetadataRetention( - store: MetadataRequestStore, - retainedBytes: number, - now: number -): boolean { - pruneMetadataCache(store, now) - pruneMetadataFailures(store, now) - while (store.retainedBytes + retainedBytes > store.maxRetainedBytes) { - const oldest = oldestRetainedEntry(store) - if (!oldest) { - return false - } - if (oldest.kind === 'cache') { - deleteCachedMetadata(store, oldest.key) - } else { - deleteMetadataFailure(store, oldest.key) - } - } - return true -} - export function loadMetadata( store: MetadataRequestStore, key: string, fetcher: () => Promise, now = Date.now ): Promise { - const keyBytes = metadataKeyBytes(key) - if (keyBytes === null) { - return Promise.reject( - new Error(`Metadata request key exceeds ${MAX_METADATA_KEY_BYTES} bytes.`) - ) - } const cached = getFreshMetadata(store, key, now()) if (cached) { return Promise.resolve(cached.data) @@ -232,66 +117,36 @@ export function loadMetadata( return Promise.reject(recentFailure.error) } - if (store.inflight.size >= MAX_METADATA_INFLIGHT_ENTRIES) { - return Promise.reject(new Error('Metadata request queue is full; retry after requests finish.')) - } - if (!reserveMetadataRetention(store, keyBytes, now())) { - return Promise.reject(new Error('Metadata request memory budget is full; retry later.')) - } - // Why: clearMetadataRequestStore invalidates auth/repo boundaries; late // responses from the previous generation must not repopulate the cache. const generation = store.generation - let fetched: Promise - try { - fetched = fetcher() - } catch (error) { - return Promise.reject(error) - } - let promise!: Promise - promise = fetched - .then( - (data) => { - releaseMetadataInflight(store, key, promise) - if (store.generation === generation) { - const fetchedAt = now() - const valueBytes = measureMetadataValueBytes(data, MAX_METADATA_VALUE_BYTES) - deleteCachedMetadata(store, key) - deleteMetadataFailure(store, key) - if ( - valueBytes !== null && - reserveMetadataRetention(store, keyBytes + valueBytes, fetchedAt) - ) { - const retainedBytes = keyBytes + valueBytes - store.cache.set(key, { data, fetchedAt, retainedBytes }) - store.retainedBytes += retainedBytes - pruneMetadataCache(store, fetchedAt) - } - } - return data - }, - (error: unknown) => { - releaseMetadataInflight(store, key, promise) - if (store.generation === generation) { - const failedAt = now() - const bounded = boundedMetadataFailure(error, MAX_METADATA_ERROR_SUMMARY_BYTES) - const retainedBytes = keyBytes + bounded.bytes - deleteMetadataFailure(store, key) - if (reserveMetadataRetention(store, retainedBytes, failedAt)) { - store.failures.set(key, { error: bounded.error, failedAt, retainedBytes }) - store.retainedBytes += retainedBytes - pruneMetadataFailures(store, failedAt) - } - } - throw error + const promise = fetcher() + .then((data) => { + if (store.generation === generation) { + const fetchedAt = now() + store.cache.set(key, { data, fetchedAt }) + store.failures.delete(key) + // Why: these module-level stores are reused across dialogs and + // repo/runtime keys; TTL controls freshness but also needs pruning so + // long sessions do not retain stale metadata indefinitely. + pruneMetadataCache(store, fetchedAt) } - ) + return data + }) + .catch((error: unknown) => { + if (store.generation === generation) { + const failedAt = now() + store.failures.set(key, { error, failedAt }) + pruneMetadataFailures(store, failedAt) + } + throw error + }) .finally(() => { - releaseMetadataInflight(store, key, promise) + if (store.inflight.get(key) === promise) { + store.inflight.delete(key) + } }) store.inflight.set(key, promise) - store.inflightEntryBytes.set(key, keyBytes) - store.retainedBytes += keyBytes return promise } diff --git a/src/renderer/src/hooks/metadata-retention-measurement.ts b/src/renderer/src/hooks/metadata-retention-measurement.ts deleted file mode 100644 index 364125829d0..00000000000 --- a/src/renderer/src/hooks/metadata-retention-measurement.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { measureUtf8ByteLength } from '../../../shared/utf8-byte-limits' - -const MAX_METADATA_VALUE_DEPTH = 64 -const MAX_METADATA_ERROR_NAME_BYTES = 256 - -export function measureMetadataValueBytes(value: unknown, maxBytes: number): number | null { - let retainedBytes = 0 - const visited = new WeakSet() - const addBytes = (bytes: number): boolean => { - if (!Number.isSafeInteger(bytes) || bytes < 0 || bytes > maxBytes - retainedBytes) { - return false - } - retainedBytes += bytes - return true - } - const visit = (candidate: unknown, depth: number): boolean => { - if (candidate === null || candidate === undefined) { - return addBytes(4) - } - if (typeof candidate === 'string') { - const measured = measureUtf8ByteLength(candidate, { - stopAfterBytes: maxBytes - retainedBytes - }) - return !measured.exceededLimit && addBytes(measured.byteLength) - } - if (typeof candidate === 'number' || typeof candidate === 'bigint') { - return addBytes(8) - } - if (typeof candidate === 'boolean') { - return addBytes(4) - } - if (typeof candidate !== 'object' || depth > MAX_METADATA_VALUE_DEPTH) { - return false - } - if (visited.has(candidate)) { - return true - } - visited.add(candidate) - if (candidate instanceof ArrayBuffer) { - return addBytes(candidate.byteLength) - } - if (ArrayBuffer.isView(candidate)) { - return addBytes(candidate.byteLength) - } - if (typeof Blob !== 'undefined' && candidate instanceof Blob) { - return addBytes(candidate.size) - } - if (!addBytes(32)) { - return false - } - if (Array.isArray(candidate) && !addBytes(candidate.length * 8)) { - return false - } - if (candidate instanceof Map) { - if (!addBytes(candidate.size * 16)) { - return false - } - for (const [key, item] of candidate) { - if (!visit(key, depth + 1) || !visit(item, depth + 1)) { - return false - } - } - return true - } - if (candidate instanceof Set) { - if (!addBytes(candidate.size * 8)) { - return false - } - for (const item of candidate) { - if (!visit(item, depth + 1)) { - return false - } - } - return true - } - for (const key in candidate) { - if (!Object.prototype.hasOwnProperty.call(candidate, key)) { - continue - } - const keyMeasurement = measureUtf8ByteLength(key, { - stopAfterBytes: maxBytes - retainedBytes - }) - if ( - keyMeasurement.exceededLimit || - !addBytes(keyMeasurement.byteLength) || - !addBytes(16) || - !visit((candidate as Record)[key], depth + 1) - ) { - return false - } - } - return true - } - - try { - return visit(value, 0) ? retainedBytes : null - } catch { - return null - } -} - -function takeUtf8Prefix(value: string, maxBytes: number): { text: string; bytes: number } { - let bytes = 0 - let end = 0 - while (end < value.length) { - const codePoint = value.codePointAt(end) ?? 0 - const codePointText = String.fromCodePoint(codePoint) - const codePointBytes = measureUtf8ByteLength(codePointText).byteLength - if (bytes + codePointBytes > maxBytes) { - break - } - bytes += codePointBytes - end += codePoint > 0xffff ? 2 : 1 - } - return { text: value.slice(0, end), bytes } -} - -export function boundedMetadataFailure( - error: unknown, - maxSummaryBytes: number -): { error: Error; bytes: number } { - const rawName = error instanceof Error && error.name ? error.name : 'Error' - const rawMessage = - error instanceof Error - ? error.message - : typeof error === 'string' - ? error - : 'Metadata request failed' - const name = takeUtf8Prefix(rawName, Math.min(MAX_METADATA_ERROR_NAME_BYTES, maxSummaryBytes)) - const message = takeUtf8Prefix(rawMessage, Math.max(0, maxSummaryBytes - name.bytes)) - const cachedError = new Error(message.text) - cachedError.name = name.text || 'Error' - cachedError.stack = undefined - return { error: cachedError, bytes: name.bytes + message.bytes } -} diff --git a/src/renderer/src/hooks/runtime-project-refresh-scheduler.test.ts b/src/renderer/src/hooks/runtime-project-refresh-scheduler.test.ts index 10842c84b74..55cb0f28674 100644 --- a/src/renderer/src/hooks/runtime-project-refresh-scheduler.test.ts +++ b/src/renderer/src/hooks/runtime-project-refresh-scheduler.test.ts @@ -102,58 +102,4 @@ describe('createRuntimeProjectRefreshScheduler', () => { expect(refresh).not.toHaveBeenCalled() }) - - it('bounds environment records while refreshes are stalled', async () => { - const refresh = vi.fn(() => new Promise(() => {})) - const scheduler = createRuntimeProjectRefreshScheduler({ - refresh, - debounceMs: 100, - minIntervalMs: 1_000, - maxEntries: 1 - }) - - scheduler.request('env-1') - scheduler.request('env-2') - await vi.advanceTimersByTimeAsync(100) - - expect(refresh).toHaveBeenCalledTimes(1) - expect(refresh).toHaveBeenCalledWith('env-1') - scheduler.stop() - }) - - it('recovers capacity by evicting an idle environment record', async () => { - const refresh = vi.fn().mockResolvedValue(undefined) - const scheduler = createRuntimeProjectRefreshScheduler({ - refresh, - debounceMs: 100, - minIntervalMs: 1_000, - maxEntries: 1 - }) - - scheduler.request('env-1') - await vi.advanceTimersByTimeAsync(100) - scheduler.request('env-2') - await vi.advanceTimersByTimeAsync(100) - - expect(refresh.mock.calls).toEqual([['env-1'], ['env-2']]) - scheduler.stop() - }) - - it('admits exact-limit environment ids and ignores oversized ids', async () => { - const refresh = vi.fn().mockResolvedValue(undefined) - const scheduler = createRuntimeProjectRefreshScheduler({ - refresh, - debounceMs: 100, - minIntervalMs: 1_000, - maxEnvironmentIdBytes: 4 - }) - - scheduler.request('🐋') - scheduler.request('🐋x') - await vi.advanceTimersByTimeAsync(100) - - expect(refresh).toHaveBeenCalledTimes(1) - expect(refresh).toHaveBeenCalledWith('🐋') - scheduler.stop() - }) }) diff --git a/src/renderer/src/hooks/runtime-project-refresh-scheduler.ts b/src/renderer/src/hooks/runtime-project-refresh-scheduler.ts index feb5ac18896..46bf354667f 100644 --- a/src/renderer/src/hooks/runtime-project-refresh-scheduler.ts +++ b/src/renderer/src/hooks/runtime-project-refresh-scheduler.ts @@ -1,13 +1,9 @@ -import { measureUtf8ByteLength } from '../../../shared/utf8-byte-limits' - export type RuntimeProjectRefreshSchedulerDeps = { refresh: (environmentId: string) => Promise debounceMs?: number minIntervalMs?: number now?: () => number onError?: (error: unknown) => void - maxEntries?: number - maxEnvironmentIdBytes?: number } export type RuntimeProjectRefreshScheduler = { @@ -20,13 +16,10 @@ type RefreshEntry = { lastStartedAt: number pending: boolean timer: ReturnType | null - expiryTimer: ReturnType | null } const DEFAULT_DEBOUNCE_MS = 250 const DEFAULT_MIN_INTERVAL_MS = 5_000 -export const RUNTIME_PROJECT_REFRESH_MAX_ENTRIES = 256 -export const RUNTIME_PROJECT_REFRESH_MAX_ENVIRONMENT_ID_BYTES = 16 * 1024 export function createRuntimeProjectRefreshScheduler( deps: RuntimeProjectRefreshSchedulerDeps @@ -34,84 +27,27 @@ export function createRuntimeProjectRefreshScheduler( const debounceMs = deps.debounceMs ?? DEFAULT_DEBOUNCE_MS const minIntervalMs = deps.minIntervalMs ?? DEFAULT_MIN_INTERVAL_MS const now = deps.now ?? Date.now - const maxEntries = deps.maxEntries ?? RUNTIME_PROJECT_REFRESH_MAX_ENTRIES - const maxEnvironmentIdBytes = - deps.maxEnvironmentIdBytes ?? RUNTIME_PROJECT_REFRESH_MAX_ENVIRONMENT_ID_BYTES const entries = new Map() let stopped = false - const clearExpiryTimer = (entry: RefreshEntry): void => { - if (entry.expiryTimer) { - clearTimeout(entry.expiryTimer) - entry.expiryTimer = null - } - } - - const evictIdleEntry = (): boolean => { - for (const [environmentId, entry] of entries) { - if (entry.inFlight || entry.pending || entry.timer) { - continue - } - clearExpiryTimer(entry) - entries.delete(environmentId) - return true - } - return false - } - - const getEntry = (environmentId: string): RefreshEntry | null => { + const getEntry = (environmentId: string): RefreshEntry => { let entry = entries.get(environmentId) - if (entry) { - entries.delete(environmentId) + if (!entry) { + entry = { + inFlight: false, + lastStartedAt: 0, + pending: false, + timer: null + } entries.set(environmentId, entry) - clearExpiryTimer(entry) - return entry } - if (entries.size >= maxEntries && !evictIdleEntry()) { - return null - } - entry = { - inFlight: false, - lastStartedAt: 0, - pending: false, - timer: null, - expiryTimer: null - } - entries.set(environmentId, entry) return entry } - const expireWhenThrottleWindowEnds = (environmentId: string, entry: RefreshEntry): void => { - if (stopped || entry.inFlight || entry.pending || entry.timer) { - return - } - clearExpiryTimer(entry) - const elapsed = entry.lastStartedAt > 0 ? now() - entry.lastStartedAt : minIntervalMs - const delay = Math.max(0, minIntervalMs - elapsed) - if (delay === 0) { - if (entries.get(environmentId) === entry) { - entries.delete(environmentId) - } - return - } - entry.expiryTimer = setTimeout(() => { - entry.expiryTimer = null - if ( - entries.get(environmentId) === entry && - !entry.inFlight && - !entry.pending && - !entry.timer - ) { - entries.delete(environmentId) - } - }, delay) - } - const schedule = (environmentId: string, entry: RefreshEntry): void => { if (stopped || entry.inFlight || entry.timer) { return } - clearExpiryTimer(entry) const elapsed = entry.lastStartedAt > 0 ? now() - entry.lastStartedAt : minIntervalMs const throttleDelay = Math.max(0, minIntervalMs - elapsed) const delay = Math.max(debounceMs, throttleDelay) @@ -138,27 +74,16 @@ export function createRuntimeProjectRefreshScheduler( // Why: runtime repo events can be noisy while a remote server is merely // connected; keep discovery live without letting it drive the renderer. schedule(environmentId, entry) - } else { - expireWhenThrottleWindowEnds(environmentId, entry) } } } const request = (environmentId: string): void => { - if ( - stopped || - measureUtf8ByteLength(environmentId, { stopAfterBytes: maxEnvironmentIdBytes }).exceededLimit - ) { - return - } const trimmedEnvironmentId = environmentId.trim() - if (!trimmedEnvironmentId) { + if (!trimmedEnvironmentId || stopped) { return } const entry = getEntry(trimmedEnvironmentId) - if (!entry) { - return - } entry.pending = true schedule(trimmedEnvironmentId, entry) } @@ -169,7 +94,6 @@ export function createRuntimeProjectRefreshScheduler( if (entry.timer) { clearTimeout(entry.timer) } - clearExpiryTimer(entry) } entries.clear() } diff --git a/src/renderer/src/hooks/unknown-ssh-target-state-coordinator.test.ts b/src/renderer/src/hooks/unknown-ssh-target-state-coordinator.test.ts deleted file mode 100644 index 8e5d6d64d98..00000000000 --- a/src/renderer/src/hooks/unknown-ssh-target-state-coordinator.test.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import type { SshTargetSummary } from '../../../shared/ssh-types' -import { - UnknownSshTargetStateCoordinator, - UNKNOWN_SSH_TARGET_STATE_MAX_PENDING -} from './unknown-ssh-target-state-coordinator' - -function deferredTargets(): { - promise: Promise - resolve: (targets: SshTargetSummary[]) => void - reject: (error: unknown) => void -} { - let resolve!: (targets: SshTargetSummary[]) => void - let reject!: (error: unknown) => void - const promise = new Promise((resolvePromise, rejectPromise) => { - resolve = resolvePromise - reject = rejectPromise - }) - return { promise, resolve, reject } -} - -function state(targetId: string, status: 'connecting' | 'error', error: string | null = null) { - return { targetId, status, error, reconnectAttempt: status === 'error' ? 1 : 0 } -} - -function setup(listTargets: () => Promise) { - const dependencies = { - listTargets: vi.fn(listTargets), - setTargetsMetadata: vi.fn(), - applyState: vi.fn(), - clearRemovedTargetState: vi.fn() - } - return { - dependencies, - coordinator: new UnknownSshTargetStateCoordinator(dependencies) - } -} - -describe('unknown SSH target state coordinator', () => { - it('keeps one target refresh in flight and applies only the latest state per target', async () => { - const refresh = deferredTargets() - const { coordinator, dependencies } = setup(() => refresh.promise) - - coordinator.enqueue('ssh-a', state('ssh-a', 'connecting')) - coordinator.enqueue('ssh-a', state('ssh-a', 'error', 'failed')) - coordinator.enqueue('ssh-removed', state('ssh-removed', 'error', 'gone')) - expect(dependencies.listTargets).toHaveBeenCalledOnce() - - refresh.resolve([{ id: 'ssh-a', label: 'Remote A' }]) - await refresh.promise - await vi.waitFor(() => { - expect(dependencies.clearRemovedTargetState).toHaveBeenCalledWith('ssh-removed') - }) - - expect(dependencies.setTargetsMetadata).toHaveBeenCalledWith([ - { id: 'ssh-a', label: 'Remote A' } - ]) - expect(dependencies.applyState).toHaveBeenCalledOnce() - expect(dependencies.applyState).toHaveBeenCalledWith( - 'ssh-a', - expect.objectContaining({ status: 'error', error: 'failed' }) - ) - }) - - it('retries once and falls back to the latest state after both refreshes fail', async () => { - const { coordinator, dependencies } = setup(() => Promise.reject(new Error('offline'))) - - coordinator.enqueue('ssh-a', state('ssh-a', 'connecting')) - await Promise.resolve() - await Promise.resolve() - await Promise.resolve() - await Promise.resolve() - await Promise.resolve() - - expect(dependencies.listTargets).toHaveBeenCalledTimes(2) - expect(dependencies.applyState).toHaveBeenCalledWith( - 'ssh-a', - expect.objectContaining({ status: 'connecting' }) - ) - }) - - it('rechecks a target first seen after an in-flight snapshot began', async () => { - const first = deferredTargets() - const second = deferredTargets() - const { coordinator, dependencies } = setup( - vi.fn().mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise) - ) - coordinator.enqueue('ssh-a', state('ssh-a', 'connecting')) - coordinator.enqueue('ssh-b', state('ssh-b', 'connecting')) - - first.resolve([{ id: 'ssh-a', label: 'Remote A' }]) - await first.promise - await Promise.resolve() - - expect(dependencies.applyState).toHaveBeenCalledWith( - 'ssh-a', - expect.objectContaining({ status: 'connecting' }) - ) - expect(dependencies.clearRemovedTargetState).not.toHaveBeenCalledWith('ssh-b') - expect(dependencies.listTargets).toHaveBeenCalledTimes(2) - - second.resolve([ - { id: 'ssh-a', label: 'Remote A' }, - { id: 'ssh-b', label: 'Remote B' } - ]) - await second.promise - await Promise.resolve() - - expect(dependencies.applyState).toHaveBeenCalledWith( - 'ssh-b', - expect.objectContaining({ status: 'connecting' }) - ) - }) - - it('caps pending state while a shared target refresh hangs', async () => { - const refresh = deferredTargets() - const { coordinator, dependencies } = setup(() => refresh.promise) - for (let index = 0; index <= UNKNOWN_SSH_TARGET_STATE_MAX_PENDING; index += 1) { - const targetId = `ssh-${index}` - coordinator.enqueue(targetId, state(targetId, 'connecting')) - } - - expect(coordinator.evidence()).toEqual({ - pending: UNKNOWN_SSH_TARGET_STATE_MAX_PENDING, - refreshInFlight: true - }) - expect(dependencies.listTargets).toHaveBeenCalledOnce() - - refresh.resolve( - Array.from({ length: UNKNOWN_SSH_TARGET_STATE_MAX_PENDING + 1 }, (_, index) => ({ - id: `ssh-${index}`, - label: `Remote ${index}` - })) - ) - await refresh.promise - await vi.waitFor(() => { - expect(dependencies.applyState).toHaveBeenCalledTimes(UNKNOWN_SSH_TARGET_STATE_MAX_PENDING) - }) - expect(dependencies.applyState).not.toHaveBeenCalledWith('ssh-0', expect.anything()) - }) - - it('drops malformed state without starting a refresh', () => { - const { coordinator, dependencies } = setup(() => Promise.resolve([])) - - expect(coordinator.enqueue('ssh-a', { status: 'connected' })).toBe(false) - expect(dependencies.listTargets).not.toHaveBeenCalled() - }) -}) diff --git a/src/renderer/src/hooks/unknown-ssh-target-state-coordinator.ts b/src/renderer/src/hooks/unknown-ssh-target-state-coordinator.ts deleted file mode 100644 index b723ff55ad2..00000000000 --- a/src/renderer/src/hooks/unknown-ssh-target-state-coordinator.ts +++ /dev/null @@ -1,113 +0,0 @@ -import type { SshConnectionState, SshTargetSummary } from '../../../shared/ssh-types' -import { admitSshConnectionState } from '../../../shared/ssh-retained-payload-admission' - -export const UNKNOWN_SSH_TARGET_STATE_MAX_PENDING = 256 - -type UnknownSshTargetStateCoordinatorDependencies = { - listTargets: () => Promise - setTargetsMetadata: (targets: SshTargetSummary[]) => void - applyState: (targetId: string, state: SshConnectionState) => void - clearRemovedTargetState: (targetId: string) => void -} - -export class UnknownSshTargetStateCoordinator { - private readonly pending = new Map() - private refreshInFlight = false - private disposed = false - private nextEventId = 0 - - constructor( - private readonly dependencies: UnknownSshTargetStateCoordinatorDependencies, - private readonly maxPending = UNKNOWN_SSH_TARGET_STATE_MAX_PENDING - ) {} - - enqueue(targetId: string, value: unknown): boolean { - const state = admitSshConnectionState(value, targetId) - if (!state || this.disposed) { - return false - } - const previous = this.pending.get(targetId) - if (previous) { - this.pending.delete(targetId) - } else if (this.pending.size >= this.maxPending) { - const oldestTargetId = this.pending.keys().next().value - if (oldestTargetId !== undefined) { - this.pending.delete(oldestTargetId) - } - } - this.pending.set(targetId, { - state, - eventId: ++this.nextEventId - }) - this.ensureRefresh() - return true - } - - forget(targetId: string): void { - this.pending.delete(targetId) - } - - dispose(): void { - this.disposed = true - this.pending.clear() - } - - evidence(): { pending: number; refreshInFlight: boolean } { - return { pending: this.pending.size, refreshInFlight: this.refreshInFlight } - } - - private ensureRefresh(): void { - if (this.refreshInFlight || this.disposed) { - return - } - this.refreshInFlight = true - const requestStartEventId = this.nextEventId - void this.dependencies - .listTargets() - // Why: one retry preserves the prior transient-failure behavior without one request per event. - .catch(() => this.dependencies.listTargets()) - .then( - (targets) => this.finish(targets, requestStartEventId), - () => this.finish(null, requestStartEventId) - ) - } - - private finish(targets: SshTargetSummary[] | null, requestStartEventId: number): void { - if (this.disposed) { - return - } - this.refreshInFlight = false - if (targets) { - const targetIds = new Set(targets.map((target) => target.id)) - const applicable = Array.from(this.pending).filter( - ([, entry]) => entry.eventId <= requestStartEventId - ) - for (const [targetId] of applicable) { - this.pending.delete(targetId) - } - if (applicable.length > 0) { - this.dependencies.setTargetsMetadata(targets) - } - for (const [targetId, entry] of applicable) { - if (targetIds.has(targetId)) { - this.dependencies.applyState(targetId, entry.state) - } else { - this.dependencies.clearRemovedTargetState(targetId) - } - } - } else { - const applicable = Array.from(this.pending).filter( - ([, entry]) => entry.eventId <= requestStartEventId - ) - for (const [targetId] of applicable) { - this.pending.delete(targetId) - } - for (const [targetId, entry] of applicable) { - this.dependencies.applyState(targetId, entry.state) - } - } - if (this.pending.size > 0) { - this.ensureRefresh() - } - } -} diff --git a/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts b/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts index 3081aa512a8..ec3473d5f22 100644 --- a/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts +++ b/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts @@ -3,7 +3,6 @@ import { join } from 'node:path' import { describe, expect, it } from 'vitest' import { canResolveFolderSmartGitHubSubmit, - COMPOSER_PROJECT_LOOKUP_CONCURRENCY, getInitialAutoManagedWorkspaceName, isExplicitWorkspaceNameInput, resolveSmartGitHubCreateNames, @@ -403,10 +402,7 @@ describe('useComposerState host-context boundaries', () => { ) expect(lookupSection).toContain('isProjectGroupTarget') expect(lookupSection).toContain('folderSourceRepos.filter(isGitRepoKind)') - expect(COMPOSER_PROJECT_LOOKUP_CONCURRENCY).toBe(4) - expect(lookupSection).toContain('await mapWithConcurrency(') - expect(lookupSection).toContain('COMPOSER_PROJECT_LOOKUP_CONCURRENCY') - expect(lookupSection).not.toContain('Promise.all') + expect(lookupSection).toContain('Promise.all') expect(lookupSection).toContain('buildTaskSourceContextFromRepo') const section = sourceBetween( diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index 55033253c47..467f20feef2 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -144,13 +144,11 @@ import { type ExecutionHostId } from '../../../shared/execution-host' import { getHostDisplayLabelOverrides } from '../../../shared/host-setting-overrides' -import { mapWithConcurrency } from '../../../shared/map-with-concurrency' import { queueNewWorkspaceTerminalFocus } from '@/lib/new-workspace-terminal-focus' import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner' import { getSuggestedCreatureName } from '@/components/sidebar/worktree-name-suggestions' import type { SmartWorkspaceNameSelection } from '@/components/new-workspace/SmartWorkspaceNameField' import type { SmartNameMode } from '@/components/new-workspace/smart-workspace-source-results' - import { getForkPushWarning } from './fork-push-warning' import { buildWorkspaceSourceSelection, @@ -187,8 +185,6 @@ import { } from './composer-drop-upload-result' import { translate } from '@/i18n/i18n' -export const COMPOSER_PROJECT_LOOKUP_CONCURRENCY = 4 - export function canResolveFolderSmartGitHubSubmit({ hasFolderSourceRepos }: { @@ -2070,10 +2066,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS const item = isProjectGroupTarget ? ( - await mapWithConcurrency( - folderSourceRepos.filter(isGitRepoKind), - COMPOSER_PROJECT_LOOKUP_CONCURRENCY, - (repo) => + await Promise.all( + folderSourceRepos.filter(isGitRepoKind).map((repo) => lookupSmartGitHubSubmitItem({ repoPath: repo.path, repoId: repo.id, @@ -2086,6 +2080,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS workItem: lookupGitHubWorkItemForSource, workItemByOwnerRepo: lookupGitHubWorkItemByOwnerRepoForSource }).catch(() => null) + ) ) ) .filter((candidate): candidate is GitHubWorkItem => candidate !== null) diff --git a/src/renderer/src/hooks/useInstalledAgentSkills.test.ts b/src/renderer/src/hooks/useInstalledAgentSkills.test.ts index 6458cd6a152..28453f00489 100644 --- a/src/renderer/src/hooks/useInstalledAgentSkills.test.ts +++ b/src/renderer/src/hooks/useInstalledAgentSkills.test.ts @@ -3,13 +3,10 @@ import type { DiscoveredSkill, SkillDiscoveryResult } from '../../../shared/skil import type { ProjectExecutionRuntimeResolution } from '../../../shared/project-execution-runtime' import { GLOBAL_AGENT_SKILL_SOURCE_KINDS, - MAX_CACHED_SKILL_DISCOVERY_TARGETS, - MAX_PENDING_SKILL_DISCOVERY_TARGETS, _installedAgentSkillDiscoveryInternalsForTests, hasInstalledAgentSkill, hasInstalledAgentSkillNamed } from './useInstalledAgentSkills' -import { InstalledSkillDiscoveryCoordinator } from './installed-skill-discovery-coordinator' afterEach(() => { _installedAgentSkillDiscoveryInternalsForTests.reset() @@ -311,105 +308,4 @@ describe('discoverInstalledAgentSkills', () => { projectRuntime: projectHostRuntime }) }) - - it('evicts the least recently used historical runtime result', async () => { - const discover = vi.fn().mockImplementation(() => Promise.resolve(discoveryResult())) - vi.stubGlobal('window', { api: { skills: { discover } } }) - - for (let index = 0; index <= MAX_CACHED_SKILL_DISCOVERY_TARGETS; index += 1) { - await _installedAgentSkillDiscoveryInternalsForTests.discoverInstalledAgentSkills(false, { - projectRuntime: { - status: 'resolved', - runtime: { - kind: 'windows-host', - hostPlatform: 'win32', - projectId: `repo-${index}`, - reason: 'project-override', - cacheKey: `runtime-${index}` - } - } - }) - } - - expect(_installedAgentSkillDiscoveryInternalsForTests.cacheSizes().cached).toBe( - MAX_CACHED_SKILL_DISCOVERY_TARGETS - ) - await _installedAgentSkillDiscoveryInternalsForTests.discoverInstalledAgentSkills(false, { - projectRuntime: { - status: 'resolved', - runtime: { - kind: 'windows-host', - hostPlatform: 'win32', - projectId: 'repo-0', - reason: 'project-override', - cacheKey: 'runtime-0' - } - } - }) - expect(discover).toHaveBeenCalledTimes(MAX_CACHED_SKILL_DISCOVERY_TARGETS + 2) - }) - - it('rejects excess concurrent target scans instead of retaining unbounded work', async () => { - const scans = Array.from({ length: MAX_PENDING_SKILL_DISCOVERY_TARGETS }, () => - deferred() - ) - const discover = vi.fn().mockImplementation(() => scans[discover.mock.calls.length - 1].promise) - vi.stubGlobal('window', { api: { skills: { discover } } }) - const pending = scans.map((_, index) => - _installedAgentSkillDiscoveryInternalsForTests.discoverInstalledAgentSkills(false, { - projectRuntime: { - status: 'resolved', - runtime: { - kind: 'windows-host', - hostPlatform: 'win32', - projectId: `repo-${index}`, - reason: 'project-override', - cacheKey: `pending-${index}` - } - } - }) - ) - - await expect( - _installedAgentSkillDiscoveryInternalsForTests.discoverInstalledAgentSkills(false, { - projectRuntime: { - status: 'resolved', - runtime: { - kind: 'windows-host', - hostPlatform: 'win32', - projectId: 'overflow', - reason: 'project-override', - cacheKey: 'pending-overflow' - } - } - }) - ).rejects.toThrow(/Too many concurrent/) - expect(_installedAgentSkillDiscoveryInternalsForTests.cacheSizes().pending).toBe( - MAX_PENDING_SKILL_DISCOVERY_TARGETS - ) - - scans.forEach((scan) => scan.resolve(discoveryResult())) - await Promise.all(pending) - }) -}) - -describe('InstalledSkillDiscoveryCoordinator invalidation', () => { - it('does not let a scan started before cache invalidation repopulate stale data', async () => { - const coordinator = new InstalledSkillDiscoveryCoordinator() - const staleScan = deferred() - const staleResult = discoveryResult([]) - const freshResult = discoveryResult([skill({ name: 'fresh-skill' })]) - const run = vi.fn().mockReturnValueOnce(staleScan.promise).mockResolvedValueOnce(freshResult) - - const pending = coordinator.discover({ force: false, key: 'host', run }) - coordinator.clearCache() - staleScan.resolve(staleResult) - await expect(pending).resolves.toBe(staleResult) - - expect(coordinator.getCached('host')).toBeUndefined() - await expect(coordinator.discover({ force: false, key: 'host', run })).resolves.toBe( - freshResult - ) - expect(run).toHaveBeenCalledTimes(2) - }) }) diff --git a/src/renderer/src/hooks/useInstalledAgentSkills.ts b/src/renderer/src/hooks/useInstalledAgentSkills.ts index 004934e65ff..e19bac655b1 100644 --- a/src/renderer/src/hooks/useInstalledAgentSkills.ts +++ b/src/renderer/src/hooks/useInstalledAgentSkills.ts @@ -9,12 +9,6 @@ import { ORCHESTRATION_SKILL_NAME } from '@/lib/agent-feature-install-commands' import { markOrchestrationSetupComplete } from '@/lib/orchestration-setup-state' import { INSTALLED_AGENT_SKILLS_CHANGED_EVENT } from './installed-agent-skills-change-event' import { useMountedRef } from './useMountedRef' -import { InstalledSkillDiscoveryCoordinator } from './installed-skill-discovery-coordinator' - -export { - MAX_CACHED_SKILL_DISCOVERY_TARGETS, - MAX_PENDING_SKILL_DISCOVERY_TARGETS -} from './installed-skill-discovery-cache' export const GLOBAL_AGENT_SKILL_SOURCE_KINDS = [ 'home' @@ -38,7 +32,9 @@ export type InstalledAgentSkillState = { refresh: () => Promise } -let skillDiscoveryCoordinator = new InstalledSkillDiscoveryCoordinator() +let cachedDiscoveryByTarget = new Map() +let pendingDiscoveryByTarget = new Map>() +let pendingDiscoverySatisfiesForcedRefreshByTarget = new Map() function normalizeSkillName(value: string): string { return value.trim().toLowerCase() @@ -81,7 +77,7 @@ export function hasInstalledAgentSkillNamed( } export function notifyInstalledAgentSkillsChanged(): void { - skillDiscoveryCoordinator.clearCache() + cachedDiscoveryByTarget.clear() if (typeof window !== 'undefined') { window.dispatchEvent(new CustomEvent(INSTALLED_AGENT_SKILLS_CHANGED_EVENT)) } @@ -124,26 +120,67 @@ function getSkillDiscoveryTargetKey(target: SkillDiscoveryTarget | undefined): s return normalizedTarget?.runtime === 'wsl' ? `wsl:${normalizedTarget.wslDistro ?? ''}` : 'host' } -function discoverInstalledAgentSkills( +function startInstalledAgentSkillDiscovery( + force: boolean, + target: SkillDiscoveryTarget | undefined +): Promise { + const key = getSkillDiscoveryTargetKey(target) + const normalizedTarget = normalizeSkillDiscoveryTarget(target) + const discovery = window.api.skills + .discover(normalizedTarget) + .then((result) => { + cachedDiscoveryByTarget.set(key, result) + return result + }) + .finally(() => { + if (pendingDiscoveryByTarget.get(key) === discovery) { + pendingDiscoveryByTarget.delete(key) + pendingDiscoverySatisfiesForcedRefreshByTarget.delete(key) + } + }) + pendingDiscoveryByTarget.set(key, discovery) + pendingDiscoverySatisfiesForcedRefreshByTarget.set(key, force) + return discovery +} + +async function discoverInstalledAgentSkills( force: boolean, target?: SkillDiscoveryTarget ): Promise { const key = getSkillDiscoveryTargetKey(target) - const normalizedTarget = normalizeSkillDiscoveryTarget(target) - return skillDiscoveryCoordinator.discover({ - force, - key, - run: () => window.api.skills.discover(normalizedTarget) - }) + const cachedDiscovery = cachedDiscoveryByTarget.get(key) + if (!force && cachedDiscovery) { + return cachedDiscovery + } + + const inFlightDiscovery = pendingDiscoveryByTarget.get(key) + if (inFlightDiscovery) { + if (!force || pendingDiscoverySatisfiesForcedRefreshByTarget.get(key)) { + return inFlightDiscovery + } + try { + await inFlightDiscovery + } catch { + // Why: an explicit re-check should still read current disk state even if + // the older background scan failed. + } + const nextPendingDiscovery = pendingDiscoveryByTarget.get(key) + if (nextPendingDiscovery && nextPendingDiscovery !== inFlightDiscovery) { + return nextPendingDiscovery + } + } + + return startInstalledAgentSkillDiscovery(force, target) } export const _installedAgentSkillDiscoveryInternalsForTests = { discoverInstalledAgentSkills, getSkillDiscoveryTargetKey, isOrchestrationSkillName, - cacheSizes: () => skillDiscoveryCoordinator.sizes(), reset(): void { - skillDiscoveryCoordinator = new InstalledSkillDiscoveryCoordinator() + cachedDiscoveryByTarget = new Map() + pendingDiscoveryByTarget = new Map() + pendingDiscoverySatisfiesForcedRefreshByTarget = new Map() } } @@ -162,7 +199,7 @@ export function useInstalledAgentSkillNames( const skillNamesKey = skillNames.map(normalizeSkillName).join('\n') const candidateSkillNames = useMemo(() => skillNamesKey.split('\n'), [skillNamesKey]) const discoveryTargetKey = getSkillDiscoveryTargetKey(discoveryTarget) - const cachedDiscovery = skillDiscoveryCoordinator.getCached(discoveryTargetKey) ?? null + const cachedDiscovery = cachedDiscoveryByTarget.get(discoveryTargetKey) ?? null const [result, setResult] = useState(cachedDiscovery) const [loading, setLoading] = useState(enabled && !cachedDiscovery) const [error, setError] = useState(null) @@ -180,7 +217,7 @@ export function useInstalledAgentSkillNames( stateResetInputRef.current.discoveryTargetKey !== discoveryTargetKey || stateResetInputRef.current.enabled !== enabled ) { - const nextCachedDiscovery = skillDiscoveryCoordinator.getCached(discoveryTargetKey) ?? null + const nextCachedDiscovery = cachedDiscoveryByTarget.get(discoveryTargetKey) ?? null const nextLoading = enabled && !nextCachedDiscovery stateResetInputRef.current = { discoveryTargetKey, enabled } resultForRender = nextCachedDiscovery diff --git a/src/renderer/src/hooks/useIpcEvents.test.ts b/src/renderer/src/hooks/useIpcEvents.test.ts index fe4036b4888..cf1a370abf6 100644 --- a/src/renderer/src/hooks/useIpcEvents.test.ts +++ b/src/renderer/src/hooks/useIpcEvents.test.ts @@ -1704,14 +1704,14 @@ describe('useIpcEvents updater integration', () => { state: errorState }) - expect(pendingListTargets).toHaveLength(1) - const resolveFirstTargets = pendingListTargets.shift()!.resolve + expect(pendingListTargets).toHaveLength(2) + const resolveConnectingTargets = pendingListTargets.shift()!.resolve + const resolveErrorTargets = pendingListTargets.shift()!.resolve const targets = [{ id: 'conn-new', label: 'New remote' }] - resolveFirstTargets(targets) + resolveErrorTargets(targets) await Promise.resolve() await Promise.resolve() - expect(pendingListTargets).toHaveLength(1) - pendingListTargets.shift()!.resolve(targets) + resolveConnectingTargets(targets) await Promise.resolve() await Promise.resolve() diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 6bb00674ea7..b31d4068ea6 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -32,7 +32,6 @@ import type { } from '../../../shared/remote-workspace-types' import type { RateLimitState } from '../../../shared/rate-limit-types' import type { SshConnectionState } from '../../../shared/ssh-types' -import { admitSshConnectionState } from '../../../shared/ssh-retained-payload-admission' import { isWslHookRelayConnectionId } from '../../../shared/wsl-hook-relay-contract' import type { RuntimeBrowserDriverState, @@ -90,8 +89,6 @@ import { import { attachMobileMarkdownBridge } from '@/runtime/mobile-markdown-bridge' import { closeMobileSessionTabInStore } from '@/runtime/mobile-session-tab-close' import { createWorktreeChangeRefreshQueue } from './worktree-change-refresh-queue' -import { recentlyRenamedWorktreeGrace } from './worktree-rename-grace-retention' -import { UnknownSshTargetStateCoordinator } from './unknown-ssh-target-state-coordinator' import { subscribeRuntimeClientEvents } from '@/runtime/runtime-client-events' import { subscribeToUnpairedDeviceAuthNotification } from './unpaired-device-auth-notification' import { @@ -104,7 +101,6 @@ import { detectLanguage } from '@/lib/language-detect' import { makePaneKey, parsePaneKey } from '../../../shared/stable-pane-id' import { collectLeafIdsInOrder } from '@/components/terminal-pane/layout-serialization' import { track } from '@/lib/telemetry' -import { TransientAgentStatusClearRegistry } from '@/lib/transient-agent-status-clear-retention' import { singlePaneLayoutSnapshot } from '@/store/slices/terminal-helpers' import { buildWorkspaceSessionPayload } from '@/lib/workspace-session' import { persistWorkspaceSessionByHost } from '@/lib/workspace-session-host-persistence' @@ -254,6 +250,7 @@ const MAX_PENDING_AGENT_STATUS_EVENTS = 100 const MAX_PENDING_MOBILE_STATE_EVENTS = 300 // Why: a rename's event burst lags the on-disk move; shield both ids from the deletion diff for a grace window. const WORKTREE_RENAME_PURGE_GRACE_MS = 20_000 +const recentlyRenamedWorktreeIdExpiry = new Map() let remoteWorkspaceSnapshotApplyDepth = 0 let remoteWorkspaceSnapshotWriteSuppressUntil = 0 const REMOTE_WORKSPACE_SNAPSHOT_WRITE_SUPPRESS_MS = 1000 @@ -440,7 +437,7 @@ async function prepareRemoteWorkspaceTarget(targetId: string): Promise await store.fetchRepos() repos = useAppStore.getState().repos.filter((repo) => repo.connectionId === targetId) } - await refreshRuntimeProjectWorktrees(repos) + await Promise.all(repos.map((repo) => useAppStore.getState().fetchWorktrees(repo.id))) await useAppStore.getState().fetchWorktreeLineage() return true } @@ -850,7 +847,7 @@ export function useIpcEvents(): void { } type AgentStatusApplyResult = 'applied' | 'pending' | 'dropped' const pendingAgentStatusEvents: PendingAgentStatusEvent[] = [] - const transientClearWatermarks = new TransientAgentStatusClearRegistry() + const transientClearWatermarkByConnectionId = new Map() let agentStatusEffectDisposed = false let pendingAgentStatusRetryTimer: ReturnType | null = null // Why: setAgentStatus notifies synchronously and re-enters this flush mid-drain; guard re-entrancy (crash 9fc89529). @@ -871,10 +868,8 @@ export function useIpcEvents(): void { if (renamed) { // Shield both ids from the deletion diff across the rename's event burst — the worktree list lags the on-disk move. const expiry = Date.now() + WORKTREE_RENAME_PURGE_GRACE_MS - recentlyRenamedWorktreeGrace.remember( - [renamed.oldWorktreeId, renamed.newWorktreeId], - expiry - ) + recentlyRenamedWorktreeIdExpiry.set(renamed.oldWorktreeId, expiry) + recentlyRenamedWorktreeIdExpiry.set(renamed.newWorktreeId, expiry) useAppStore.getState().migrateWorktreeIdentity(renamed.oldWorktreeId, renamed.newWorktreeId) } // Why: diff before/after fetch to catch out-of-band deletions and purge worktree state, else zombie ptyId entries leak (design §2c, §4.4). @@ -893,7 +888,14 @@ export function useIpcEvents(): void { if (renamedWasActive && renamed) { useAppStore.getState().setActiveWorktree(renamed.newWorktreeId) } + // Sweep expired rename-grace entries before any early return, else forced-local + // (or non-authoritative) events let the map grow for the session. const now = Date.now() + for (const [id, expiry] of recentlyRenamedWorktreeIdExpiry) { + if (expiry <= now) { + recentlyRenamedWorktreeIdExpiry.delete(id) + } + } // Why: the deletion diff below is repo-wide, but a forced-local scan overlapping // a runtime cannot prove remote absence (legacy runtime rows may lack hostId). // fetchWorktrees still purges removed local rows host-scoped; accepted gap: the @@ -915,7 +917,8 @@ export function useIpcEvents(): void { continue } // A recently renamed worktree's old/new id isn't a deletion — its state moved to the new id; the list just lags. - if (recentlyRenamedWorktreeGrace.protects(id, now)) { + const graceExpiry = recentlyRenamedWorktreeIdExpiry.get(id) + if (graceExpiry != null && graceExpiry > now) { continue } removed.push(id) @@ -2745,7 +2748,7 @@ export function useIpcEvents(): void { } if (state.status === 'connected') { - void refreshRuntimeProjectWorktrees(remoteRepos).then(async () => { + void Promise.all(remoteRepos.map((r) => store.fetchWorktrees(r.id))).then(async () => { await useAppStore.getState().fetchWorktreeLineage() // Why: panes that never spawned (no PTY provider at cold start) or whose deferred reattach never ran sit inert. // Bumping generation remounts TerminalPane so the deferred-connect gate reattaches or spawns fresh now that the provider exists. @@ -2785,28 +2788,44 @@ export function useIpcEvents(): void { } } - const unknownSshTargetStates = new UnknownSshTargetStateCoordinator({ - listTargets: () => window.api.ssh.listTargets(), - setTargetsMetadata: (targets) => useAppStore.getState().setSshTargetsMetadata(targets), - applyState: applySshConnectionStateChange, - clearRemovedTargetState: (targetId) => - useAppStore.getState().clearRemovedSshTargetState(targetId) - }) - unsubs.push(() => unknownSshTargetStates.dispose()) + let sshTargetStateEventId = 0 + const latestSshTargetStateEventByTargetId = new Map() handleSshStateChangedEvent = (data: { targetId: string; state: unknown }): void => { - const state = admitSshConnectionState(data.state, data.targetId) - if (!state) { - return - } const store = useAppStore.getState() + const state = data.state as SshConnectionState + const stateEventId = ++sshTargetStateEventId + latestSshTargetStateEventByTargetId.set(data.targetId, stateEventId) if (!store.sshTargetLabels.has(data.targetId)) { // Why: unknown target id could be a post-boot add or a removed target racing disconnect; confirm with main first. - unknownSshTargetStates.enqueue(data.targetId, state) + window.api.ssh + .listTargets() + // Why: refresh doubles as a deletion guard; retry once so a transient IPC failure doesn't drop a real added-target event. + .catch(() => window.api.ssh.listTargets()) + .then((targets) => { + if (latestSshTargetStateEventByTargetId.get(data.targetId) !== stateEventId) { + return + } + latestSshTargetStateEventByTargetId.delete(data.targetId) + const latestStore = useAppStore.getState() + if (!targets.some((target) => target.id === data.targetId)) { + // Why: state events can race after target removal; absence from main's target list means deletion, not a new target. + latestStore.clearRemovedSshTargetState(data.targetId) + return + } + latestStore.setSshTargetsMetadata(targets) + applySshConnectionStateChange(data.targetId, state) + }) + .catch(() => { + if (latestSshTargetStateEventByTargetId.get(data.targetId) === stateEventId) { + latestSshTargetStateEventByTargetId.delete(data.targetId) + applySshConnectionStateChange(data.targetId, state) + } + }) return } - unknownSshTargetStates.forget(data.targetId) + latestSshTargetStateEventByTargetId.delete(data.targetId) applySshConnectionStateChange(data.targetId, state) } @@ -3020,7 +3039,7 @@ export function useIpcEvents(): void { : data.connectionId const transientClearWatermark = typeof data.connectionId === 'string' - ? transientClearWatermarks.get(data.connectionId) + ? transientClearWatermarkByConnectionId.get(data.connectionId) : undefined // Why: delayed snapshots/queued relay events must not resurrect a status cleared by a newer disconnect on this connection. if (transientClearWatermark !== undefined && data.receivedAt <= transientClearWatermark) { @@ -3216,13 +3235,10 @@ export function useIpcEvents(): void { ) { return } - const effectiveWatermark = transientClearWatermarks.remember( - data.connectionId, - data.clearedAt - ) - if (effectiveWatermark === null) { - return - } + const previousWatermark = + transientClearWatermarkByConnectionId.get(data.connectionId) ?? -1 + const effectiveWatermark = Math.max(previousWatermark, data.clearedAt) + transientClearWatermarkByConnectionId.set(data.connectionId, effectiveWatermark) for (let index = pendingAgentStatusEvents.length - 1; index >= 0; index -= 1) { const pending = pendingAgentStatusEvents[index].data if ( diff --git a/src/renderer/src/hooks/worktree-change-refresh-queue.test.ts b/src/renderer/src/hooks/worktree-change-refresh-queue.test.ts index 10374138988..7c04b18cfca 100644 --- a/src/renderer/src/hooks/worktree-change-refresh-queue.test.ts +++ b/src/renderer/src/hooks/worktree-change-refresh-queue.test.ts @@ -198,56 +198,4 @@ describe('createWorktreeChangeRefreshQueue', () => { expect(handler).not.toHaveBeenCalled() }) - - it('bounds a stalled repo queue and converges with one full refresh after overflow', async () => { - const firstRefresh = deferred() - const handler = vi.fn().mockReturnValueOnce(firstRefresh.promise).mockResolvedValue(undefined) - const queue = createWorktreeChangeRefreshQueue(handler, { - maxRepoStates: 2, - maxQueuedPerRepo: 2, - maxQueuedTotal: 2 - }) - const firstRename = { oldWorktreeId: 'wt-1', newWorktreeId: 'wt-2' } - const secondRename = { oldWorktreeId: 'wt-2', newWorktreeId: 'wt-3' } - - queue.enqueue({ repoId: 'repo-1' }) - queue.enqueue({ repoId: 'repo-1', renamed: firstRename }) - queue.enqueue({ repoId: 'repo-1', renamed: secondRename }) - for (let index = 0; index < 100; index++) { - queue.enqueue({ - repoId: 'repo-1', - renamed: { oldWorktreeId: `overflow-${index}`, newWorktreeId: `next-${index}` } - }) - } - - firstRefresh.resolve() - await vi.waitFor(() => expect(handler).toHaveBeenCalledTimes(4)) - - expect(handler).toHaveBeenNthCalledWith(2, 'repo-1', firstRename, { - forceLocalOwner: undefined - }) - expect(handler).toHaveBeenNthCalledWith(3, 'repo-1', secondRename, { - forceLocalOwner: undefined - }) - expect(handler).toHaveBeenNthCalledWith(4, 'repo-1', undefined, { - forceLocalOwner: undefined - }) - }) - - it('bounds repo records while refresh handlers are stalled', () => { - const stalled = deferred() - const handler = vi.fn(() => stalled.promise) - const queue = createWorktreeChangeRefreshQueue(handler, { - maxRepoStates: 1, - maxQueuedPerRepo: 2, - maxQueuedTotal: 2 - }) - - queue.enqueue({ repoId: 'repo-1' }) - queue.enqueue({ repoId: 'repo-2' }) - - expect(handler).toHaveBeenCalledTimes(1) - expect(handler).toHaveBeenCalledWith('repo-1', undefined, { forceLocalOwner: undefined }) - queue.dispose() - }) }) diff --git a/src/renderer/src/hooks/worktree-change-refresh-queue.ts b/src/renderer/src/hooks/worktree-change-refresh-queue.ts index c0ef5522ea5..ef69da6dd05 100644 --- a/src/renderer/src/hooks/worktree-change-refresh-queue.ts +++ b/src/renderer/src/hooks/worktree-change-refresh-queue.ts @@ -25,56 +25,26 @@ type QueuedWorktreeChange = { type RepoRefreshState = { running: boolean queue: QueuedWorktreeChange[] - overflowedDefault: boolean - overflowedLocal: boolean } -export const WORKTREE_REFRESH_MAX_REPO_STATES = 1024 -export const WORKTREE_REFRESH_MAX_QUEUED_PER_REPO = 4096 -export const WORKTREE_REFRESH_MAX_QUEUED_TOTAL = 16_384 - export type WorktreeChangeRefreshQueue = { dispose: () => void enqueue: (event: WorktreeChangeEvent) => void } export function createWorktreeChangeRefreshQueue( - handler: WorktreeChangeRefreshHandler, - options?: { - maxRepoStates?: number - maxQueuedPerRepo?: number - maxQueuedTotal?: number - } + handler: WorktreeChangeRefreshHandler ): WorktreeChangeRefreshQueue { const states = new Map() - const maxRepoStates = options?.maxRepoStates ?? WORKTREE_REFRESH_MAX_REPO_STATES - const maxQueuedPerRepo = options?.maxQueuedPerRepo ?? WORKTREE_REFRESH_MAX_QUEUED_PER_REPO - const maxQueuedTotal = options?.maxQueuedTotal ?? WORKTREE_REFRESH_MAX_QUEUED_TOTAL - let queuedCount = 0 let disposed = false const drain = async (repoId: string, state: RepoRefreshState): Promise => { state.running = true try { - while ( - !disposed && - (state.queue.length > 0 || state.overflowedDefault || state.overflowedLocal) - ) { + while (!disposed && state.queue.length > 0) { const next = state.queue.shift() - let overflowForceLocalOwner: boolean | undefined - if (next) { - queuedCount = Math.max(0, queuedCount - 1) - } else if (state.overflowedDefault) { - state.overflowedDefault = false - } else { - state.overflowedLocal = false - overflowForceLocalOwner = true - } try { - // Why: one full refresh per owner route converges changes shed during extreme bursts. - await handler(repoId, next?.renamed, { - forceLocalOwner: next?.forceLocalOwner ?? overflowForceLocalOwner - }) + await handler(repoId, next?.renamed, { forceLocalOwner: next?.forceLocalOwner }) } catch (error) { console.error('Failed to refresh changed worktrees:', error) } @@ -93,7 +63,6 @@ export function createWorktreeChangeRefreshQueue( dispose() { disposed = true states.clear() - queuedCount = 0 }, enqueue(event) { @@ -102,33 +71,12 @@ export function createWorktreeChangeRefreshQueue( } let state = states.get(event.repoId) if (!state) { - if (states.size >= maxRepoStates) { - return - } - state = { - running: false, - queue: [], - overflowedDefault: false, - overflowedLocal: false - } + state = { running: false, queue: [] } states.set(event.repoId, state) } - if (state.queue.length >= maxQueuedPerRepo || queuedCount >= maxQueuedTotal) { - if (event.forceLocalOwner) { - state.overflowedLocal = true - } else { - state.overflowedDefault = true - } - if (!state.running) { - void drain(event.repoId, state) - } - return - } - if (event.renamed) { state.queue.push({ renamed: event.renamed, forceLocalOwner: event.forceLocalOwner }) - queuedCount += 1 } else { const lastQueued = state.queue.at(-1) // Why: Windows/OneDrive can emit a burst for one checkout change. Keep a @@ -141,7 +89,6 @@ export function createWorktreeChangeRefreshQueue( Boolean(lastQueued.forceLocalOwner) !== Boolean(event.forceLocalOwner) ) { state.queue.push({ forceLocalOwner: event.forceLocalOwner }) - queuedCount += 1 } } diff --git a/src/renderer/src/hooks/worktree-rename-grace-retention.test.ts b/src/renderer/src/hooks/worktree-rename-grace-retention.test.ts deleted file mode 100644 index 9b1e6c160a9..00000000000 --- a/src/renderer/src/hooks/worktree-rename-grace-retention.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { WorktreeRenameGraceRetention } from './worktree-rename-grace-retention' - -describe('worktree rename grace retention', () => { - beforeEach(() => { - vi.useFakeTimers() - vi.setSystemTime(1000) - }) - - afterEach(() => { - vi.useRealTimers() - }) - - it('sweeps expired IDs even when no async refresh completes', async () => { - const retention = new WorktreeRenameGraceRetention() - retention.remember(['old-id', 'new-id'], 2000) - - expect(retention.evidence().entries).toBe(2) - await vi.advanceTimersByTimeAsync(1000) - expect(retention.evidence()).toEqual({ entries: 0, idBytes: 0 }) - retention.dispose() - }) - - it('bounds retained IDs and suppresses destructive purges during overflow', () => { - const retention = new WorktreeRenameGraceRetention({ - maxEntries: 2, - maxIdBytes: 8, - maxTotalIdBytes: 16 - }) - - retention.remember(['old-a', 'new-a', 'overflow'], 2000) - - expect(retention.evidence()).toEqual({ - entries: 2, - idBytes: 10, - suppressAllUntil: 2000 - }) - expect(retention.protects('untracked', 1500)).toBe(true) - expect(retention.protects('untracked', 2000)).toBe(false) - retention.dispose() - }) - - it('does not retain an oversized worktree ID', () => { - const retention = new WorktreeRenameGraceRetention({ - maxEntries: 2, - maxIdBytes: 4, - maxTotalIdBytes: 8 - }) - - retention.remember(['oversized'], 2000) - - expect(retention.evidence()).toEqual({ - entries: 0, - idBytes: 0, - suppressAllUntil: 2000 - }) - retention.dispose() - }) -}) diff --git a/src/renderer/src/hooks/worktree-rename-grace-retention.ts b/src/renderer/src/hooks/worktree-rename-grace-retention.ts deleted file mode 100644 index a6153474e79..00000000000 --- a/src/renderer/src/hooks/worktree-rename-grace-retention.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { measureUtf8ByteLength } from '../../../shared/utf8-byte-limits' - -export const WORKTREE_RENAME_GRACE_MAX_ENTRIES = 4096 -export const WORKTREE_RENAME_GRACE_MAX_ID_UTF8_BYTES = 64 * 1024 -export const WORKTREE_RENAME_GRACE_MAX_TOTAL_ID_UTF8_BYTES = 8 * 1024 * 1024 - -type RetainedRenameId = { - expiresAt: number - idBytes: number -} - -type WorktreeRenameGraceBounds = { - maxEntries: number - maxIdBytes: number - maxTotalIdBytes: number -} - -const DEFAULT_BOUNDS: WorktreeRenameGraceBounds = { - maxEntries: WORKTREE_RENAME_GRACE_MAX_ENTRIES, - maxIdBytes: WORKTREE_RENAME_GRACE_MAX_ID_UTF8_BYTES, - maxTotalIdBytes: WORKTREE_RENAME_GRACE_MAX_TOTAL_ID_UTF8_BYTES -} - -export class WorktreeRenameGraceRetention { - private readonly entries = new Map() - private retainedIdBytes = 0 - private suppressAllUntil = Number.NEGATIVE_INFINITY - private sweepTimer: ReturnType | null = null - private sweepDueAt = Number.POSITIVE_INFINITY - - constructor( - private readonly bounds: WorktreeRenameGraceBounds = DEFAULT_BOUNDS, - private readonly now: () => number = Date.now - ) {} - - remember(ids: readonly string[], expiresAt: number): void { - const now = this.now() - this.sweepExpired(now) - if (!Number.isFinite(expiresAt) || expiresAt <= now) { - return - } - for (const id of ids) { - const measured = measureUtf8ByteLength(id, { - stopAfterBytes: this.bounds.maxIdBytes - }) - const previous = this.entries.get(id) - if (previous) { - previous.expiresAt = Math.max(previous.expiresAt, expiresAt) - continue - } - if ( - id.length === 0 || - measured.exceededLimit || - this.entries.size >= this.bounds.maxEntries || - this.retainedIdBytes + measured.byteLength > this.bounds.maxTotalIdBytes - ) { - // Why: overload must defer destructive purge rather than misclassify an unretained rename as deletion. - this.suppressAllUntil = Math.max(this.suppressAllUntil, expiresAt) - continue - } - this.entries.set(id, { expiresAt, idBytes: measured.byteLength }) - this.retainedIdBytes += measured.byteLength - } - this.scheduleSweep() - } - - protects(id: string, now = this.now()): boolean { - this.sweepExpired(now) - if (this.suppressAllUntil > now) { - return true - } - return (this.entries.get(id)?.expiresAt ?? Number.NEGATIVE_INFINITY) > now - } - - evidence(): { entries: number; idBytes: number; suppressAllUntil?: number } { - return { - entries: this.entries.size, - idBytes: this.retainedIdBytes, - ...(Number.isFinite(this.suppressAllUntil) ? { suppressAllUntil: this.suppressAllUntil } : {}) - } - } - - dispose(): void { - if (this.sweepTimer !== null) { - clearTimeout(this.sweepTimer) - } - this.sweepTimer = null - this.sweepDueAt = Number.POSITIVE_INFINITY - this.entries.clear() - this.retainedIdBytes = 0 - this.suppressAllUntil = Number.NEGATIVE_INFINITY - } - - private sweepExpired(now: number): void { - for (const [id, entry] of this.entries) { - if (entry.expiresAt > now) { - continue - } - this.entries.delete(id) - this.retainedIdBytes -= entry.idBytes - } - if (this.suppressAllUntil <= now) { - this.suppressAllUntil = Number.NEGATIVE_INFINITY - } - } - - private scheduleSweep(): void { - let dueAt = Number.isFinite(this.suppressAllUntil) - ? this.suppressAllUntil - : Number.POSITIVE_INFINITY - for (const entry of this.entries.values()) { - dueAt = Math.min(dueAt, entry.expiresAt) - } - if (!Number.isFinite(dueAt) || (this.sweepTimer !== null && this.sweepDueAt <= dueAt)) { - return - } - if (this.sweepTimer !== null) { - clearTimeout(this.sweepTimer) - } - this.sweepDueAt = dueAt - this.sweepTimer = setTimeout( - () => { - this.sweepTimer = null - this.sweepDueAt = Number.POSITIVE_INFINITY - this.sweepExpired(this.now()) - this.scheduleSweep() - }, - Math.max(0, dueAt - this.now()) - ) - } -} - -export const recentlyRenamedWorktreeGrace = new WorktreeRenameGraceRetention() diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 726b9eb545f..13cff077a7b 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -11947,10 +11947,7 @@ "CsvViewer": { "eedd0d37a7": "columns", "ac31d2cd60": "rows", - "a233d55b77": "Empty file", - "2b65aa2913": "This CSV is too large to display safely.", - "59cfb175a2": "Switch to source mode to inspect the raw text.", - "d5e8a2b453": "Table view is limited to {{rows}} rows, {{columns}} columns, and {{cells}} cells." + "a233d55b77": "Empty file" }, "DiffNotesSendMenu": { "f1aa04b5cf": "This file", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 88e5d60ad6d..c2060e8564c 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -11924,10 +11924,7 @@ "CsvViewer": { "eedd0d37a7": "columnas", "ac31d2cd60": "filas", - "a233d55b77": "Archivo vacío", - "2b65aa2913": "Este CSV es demasiado grande para mostrarse de forma segura.", - "59cfb175a2": "Cambia al modo de código fuente para inspeccionar el texto sin procesar.", - "d5e8a2b453": "La vista de tabla está limitada a {{rows}} filas, {{columns}} columnas y {{cells}} celdas." + "a233d55b77": "Archivo vacío" }, "DiffNotesSendMenu": { "f1aa04b5cf": "Este archivo", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index e378400baf2..3cb7f0b9770 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -11924,10 +11924,7 @@ "CsvViewer": { "eedd0d37a7": "列", "ac31d2cd60": "行", - "a233d55b77": "空のファイル", - "2b65aa2913": "この CSV は大きすぎるため、安全に表示できません。", - "59cfb175a2": "元のテキストを確認するには、ソースモードに切り替えてください。", - "d5e8a2b453": "テーブル表示は {{rows}} 行、{{columns}} 列、{{cells}} セルまでに制限されています。" + "a233d55b77": "空のファイル" }, "DiffNotesSendMenu": { "f1aa04b5cf": "このファイル", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index c45be88bbfe..4412b04a375 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -11924,10 +11924,7 @@ "CsvViewer": { "eedd0d37a7": "열", "ac31d2cd60": "행", - "a233d55b77": "빈 파일", - "2b65aa2913": "이 CSV는 너무 커서 안전하게 표시할 수 없습니다.", - "59cfb175a2": "원본 텍스트를 확인하려면 소스 모드로 전환하세요.", - "d5e8a2b453": "표 보기는 {{rows}}개 행, {{columns}}개 열, {{cells}}개 셀로 제한됩니다." + "a233d55b77": "빈 파일" }, "DiffNotesSendMenu": { "f1aa04b5cf": "이 파일", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index f1516a82782..b203414bcdd 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -11924,10 +11924,7 @@ "CsvViewer": { "eedd0d37a7": "列", "ac31d2cd60": "行", - "a233d55b77": "空文件", - "2b65aa2913": "此 CSV 过大,无法安全显示。", - "59cfb175a2": "切换到源代码模式以查看原始文本。", - "d5e8a2b453": "表格视图最多显示 {{rows}} 行、{{columns}} 列和 {{cells}} 个单元格。" + "a233d55b77": "空文件" }, "DiffNotesSendMenu": { "f1aa04b5cf": "这个文件", diff --git a/src/renderer/src/lib/agent-hibernation-coordinator.test.ts b/src/renderer/src/lib/agent-hibernation-coordinator.test.ts index 849c4acd9eb..5f7f71ca94a 100644 --- a/src/renderer/src/lib/agent-hibernation-coordinator.test.ts +++ b/src/renderer/src/lib/agent-hibernation-coordinator.test.ts @@ -7,7 +7,6 @@ import { useAppStore } from '@/store' import { DEFAULT_AGENT_HIBERNATION_IDLE_MS } from './agent-hibernation-planner' import { resetAgentHibernationCoordinatorForTests, - RUNTIME_LIVENESS_READ_CONCURRENCY, runAgentHibernationTick, startAgentHibernationCoordinator } from './agent-hibernation-coordinator' @@ -184,61 +183,6 @@ afterEach(() => { }) describe('agent sleep coordinator', () => { - it.each([ - ['at the limit', RUNTIME_LIVENESS_READ_CONCURRENCY], - ['above the limit', RUNTIME_LIVENESS_READ_CONCURRENCY + 1] - ])('bounds runtime liveness reads %s', async (_, count) => { - const worktrees = Array.from({ length: count }, (_, index) => ({ - id: `repo::/worktree-${index}`, - repoId: 'repo', - hostId: `runtime:env-${index}` - })) - useAppStore.setState({ - tabsByWorktree: Object.fromEntries(worktrees.map((worktree) => [worktree.id, []])), - worktreesByRepo: { repo: worktrees } as never, - repos: [], - settings: { experimentalAgentHibernation: true } as never - }) - let active = 0 - let peak = 0 - let started = 0 - const releases: (() => void)[] = [] - mockRuntimeEnvironmentCall.mockImplementation((args: { method: string }) => { - const compatible = createCompatibleRuntimeStatusResponseIfNeeded(args) - if (compatible) { - return Promise.resolve(compatible) - } - if (args.method === 'terminal.list') { - started++ - active++ - peak = Math.max(peak, active) - return new Promise((resolve) => { - releases.push(() => { - active-- - resolve({ - id: 'terminal-list', - ok: true, - result: runtimeListResult([]), - _meta: { runtimeId: 'runtime-1' } - }) - }) - }) - } - return Promise.resolve({ id: 'default', ok: true, result: {} }) - }) - - const tick = runAgentHibernationTick() - await vi.waitFor(() => expect(started).toBe(Math.min(count, RUNTIME_LIVENESS_READ_CONCURRENCY))) - if (count > RUNTIME_LIVENESS_READ_CONCURRENCY) { - releases.shift()?.() - await vi.waitFor(() => expect(started).toBe(count)) - } - releases.splice(0).forEach((release) => release()) - await tick - - expect(peak).toBe(Math.min(count, RUNTIME_LIVENESS_READ_CONCURRENCY)) - }) - it('hibernates an eligible background worktree after two stable ticks', async () => { vi.useFakeTimers() const shutdown = installEligibleState(vi.fn().mockResolvedValue(undefined)) diff --git a/src/renderer/src/lib/agent-hibernation-coordinator.ts b/src/renderer/src/lib/agent-hibernation-coordinator.ts index 1f4ae6382b7..d410a12781b 100644 --- a/src/renderer/src/lib/agent-hibernation-coordinator.ts +++ b/src/renderer/src/lib/agent-hibernation-coordinator.ts @@ -22,10 +22,8 @@ import type { RuntimeTerminalListResult, RuntimeTerminalSummary } from '../../../shared/runtime-types' -import { mapWithConcurrency } from '../../../shared/map-with-concurrency' export const AGENT_HIBERNATION_TICK_MS = 60 * 1000 -export const RUNTIME_LIVENESS_READ_CONCURRENCY = 2 type IntervalHandle = ReturnType @@ -105,10 +103,8 @@ async function collectRuntimePtyLiveness(state: AppState): Promise = {} const runtimeLivenessRequiredWorktreeIds = [...targets.keys()] - await mapWithConcurrency( - [...targets], - RUNTIME_LIVENESS_READ_CONCURRENCY, - async ([worktreeId, runtimeEnvironmentId]) => { + await Promise.all( + [...targets].map(async ([worktreeId, runtimeEnvironmentId]) => { try { const result = await callRuntimeRpc( { kind: 'environment', environmentId: runtimeEnvironmentId }, @@ -138,7 +134,7 @@ async function collectRuntimePtyLiveness(state: AppState): Promise { }) }) - it.each([ - ['at the limit', CODEX_PTY_INSPECTION_CONCURRENCY], - ['above the limit', CODEX_PTY_INSPECTION_CONCURRENCY + 1] - ])('bounds live PTY inspections %s', async (_, count) => { - const ptyIds = Array.from({ length: count }, (_, index) => `pty-${index}`) - useAppStore.setState({ - ptyIdsByTabId: { 'tab-1': ptyIds } - }) - let active = 0 - let peak = 0 - let started = 0 - const releases: (() => void)[] = [] - vi.mocked(window.api.pty.inspectProcess).mockImplementation(async () => { - started++ - active++ - peak = Math.max(peak, active) - await new Promise((resolve) => releases.push(resolve)) - active-- - return { foregroundProcess: 'codex', hasChildProcesses: false } - }) - - const marking = markLiveCodexSessionsForRestart({ - previousAccountLabel: ACCOUNT_A, - nextAccountLabel: ACCOUNT_B - }) - await vi.waitFor(() => expect(started).toBe(Math.min(count, CODEX_PTY_INSPECTION_CONCURRENCY))) - if (count > CODEX_PTY_INSPECTION_CONCURRENCY) { - releases.shift()?.() - await vi.waitFor(() => expect(started).toBe(count)) - } - releases.splice(0).forEach((release) => release()) - await marking - - expect(peak).toBe(Math.min(count, CODEX_PTY_INSPECTION_CONCURRENCY)) - expect(Object.keys(useAppStore.getState().codexRestartNoticeByPtyId)).toHaveLength(count) - }) - it('inspects remote runtime PTYs through the active runtime environment', async () => { useAppStore.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never, diff --git a/src/renderer/src/lib/codex-session-restart.ts b/src/renderer/src/lib/codex-session-restart.ts index d3e7f4a9463..36778b7beed 100644 --- a/src/renderer/src/lib/codex-session-restart.ts +++ b/src/renderer/src/lib/codex-session-restart.ts @@ -1,9 +1,6 @@ import type { AppState } from '@/store' import { useAppStore } from '@/store' import { inspectRuntimeTerminalProcess } from '@/runtime/runtime-terminal-inspection' -import { mapWithConcurrency } from '../../../shared/map-with-concurrency' - -export const CODEX_PTY_INSPECTION_CONCURRENCY = 8 function normalizeProcessName(processName: string | null): string | null { if (!processName) { @@ -26,20 +23,31 @@ function isCodexForegroundProcess(processName: string | null): boolean { async function getLiveCodexSessionPtyIds(state: AppState): Promise { const tabs = Object.values(state.tabsByWorktree).flat() - const ptyIds = tabs.flatMap((tab) => state.ptyIdsByTabId[tab.id] ?? []) + const checks = await Promise.all( + tabs.map(async (tab) => { + const ptyIds = state.ptyIdsByTabId[tab.id] ?? [] + if (ptyIds.length === 0) { + return [] as string[] + } - // Why: Codex sessions are not reliably discoverable from tab labels. - const foregroundProcesses = await mapWithConcurrency( - ptyIds, - CODEX_PTY_INSPECTION_CONCURRENCY, - (ptyId) => - inspectRuntimeTerminalProcess(state.settings, ptyId).then( - (inspection) => inspection.foregroundProcess, - // Why: one stale remote pane must not hide notices for other confirmed Codex panes. - () => null + // Why: Codex sessions are not reliably discoverable from tab labels. + // Tabs keep fallback names until a CLI emits an OSC title, and Codex + // does not always do that. The foreground PTY process is the stable + // source of truth for whether this live tab is actually running Codex. + const foregroundProcesses = await Promise.all( + ptyIds.map((ptyId) => + inspectRuntimeTerminalProcess(state.settings, ptyId).then( + (inspection) => inspection.foregroundProcess, + // Why: one stale remote pane must not hide restart notices for other confirmed Codex panes. + () => null + ) + ) ) + return ptyIds.filter((_, index) => isCodexForegroundProcess(foregroundProcesses[index])) + }) ) - return ptyIds.filter((_, index) => isCodexForegroundProcess(foregroundProcesses[index])) + + return checks.flat() } export async function markLiveCodexSessionsForRestart(args: { diff --git a/src/renderer/src/lib/github-work-item-details-cache.test.tsx b/src/renderer/src/lib/github-work-item-details-cache.test.tsx deleted file mode 100644 index 2cbe64d594e..00000000000 --- a/src/renderer/src/lib/github-work-item-details-cache.test.tsx +++ /dev/null @@ -1,204 +0,0 @@ -// @vitest-environment happy-dom -import { act, createElement } from 'react' -import { createRoot, type Root } from 'react-dom/client' -import { afterEach, describe, expect, it } from 'vitest' -import type { GitHubWorkItemDetails } from '../../../shared/types' -import { - WORK_ITEM_DETAILS_CACHE_MAX, - WORK_ITEM_DETAILS_CACHE_MAX_KEY_BYTES, - WORK_ITEM_DETAILS_CACHE_MAX_VALUE_BYTES, - clearWorkItemDetailsCacheForTests, - createWorkItemDetailsCacheController, - getWorkItemDetailsCacheEntry, - getWorkItemDetailsCacheGeneration, - getWorkItemDetailsCacheKey, - invalidateWorkItemDetailsCacheByMatch, - invalidateWorkItemDetailsCacheForKey, - touchWorkItemDetailsCache, - useWorkItemDetailsCacheEntry, - type WorkItemDetailsCacheEntry -} from './github-work-item-details-cache' -import { measureWorkItemDetailsCacheEntryBytes } from './github-work-item-details-retained-bytes' - -function details(body = ''): GitHubWorkItemDetails { - return { - item: { - id: 'issue-1', - type: 'issue', - number: 1, - title: 'Issue', - state: 'open', - url: 'https://github.com/acme/repo/issues/1', - labels: [], - updatedAt: '2026-01-01T00:00:00.000Z', - author: 'octocat' - }, - body, - comments: [] - } -} - -function entry(body = ''): WorkItemDetailsCacheEntry { - return { details: details(body), fetchedAt: 1 } -} - -function entryBytes(value: WorkItemDetailsCacheEntry, maxBytes = 1_000_000): number { - const measured = measureWorkItemDetailsCacheEntryBytes(value, maxBytes) - if (measured === null) { - throw new Error('Fixture exceeded its measurement limit') - } - return measured -} - -afterEach(() => { - clearWorkItemDetailsCacheForTests() - document.body.replaceChildren() -}) - -describe('work-item details cache controller', () => { - it('retains a settled value exactly at the production limit', () => { - const controller = createWorkItemDetailsCacheController() - const baseBytes = entryBytes(entry(), WORK_ITEM_DETAILS_CACHE_MAX_VALUE_BYTES) - const exact = entry('x'.repeat(WORK_ITEM_DETAILS_CACHE_MAX_VALUE_BYTES - baseBytes)) - - expect( - measureWorkItemDetailsCacheEntryBytes(exact, WORK_ITEM_DETAILS_CACHE_MAX_VALUE_BYTES) - ).toBe(WORK_ITEM_DETAILS_CACHE_MAX_VALUE_BYTES) - expect(controller.set('exact', exact)).toBe(true) - expect(controller.get('exact')).toBe(exact) - }) - - it('rejects one byte over the value limit and removes the stale entry', () => { - const maxValueBytes = 512 - const controller = createWorkItemDetailsCacheController({ - maxValueBytes, - maxAggregateBytes: 2_000 - }) - const baseBytes = entryBytes(entry(), maxValueBytes) - const exact = entry('x'.repeat(maxValueBytes - baseBytes)) - const over = entry('x'.repeat(maxValueBytes - baseBytes + 1)) - - expect(controller.set('key', exact)).toBe(true) - expect(controller.set('key', over)).toBe(false) - expect(controller.get('key')).toBeUndefined() - expect(controller.getRetainedBytes()).toBe(0) - }) - - it('measures the 4 KiB key limit in UTF-8 bytes', () => { - const controller = createWorkItemDetailsCacheController({ - maxKeyBytes: WORK_ITEM_DETAILS_CACHE_MAX_KEY_BYTES - }) - const exactKey = '😀'.repeat(WORK_ITEM_DETAILS_CACHE_MAX_KEY_BYTES / 4) - const oversizedKey = `${exactKey}a` - - expect(controller.set(exactKey, entry())).toBe(true) - expect(controller.set(oversizedKey, entry())).toBe(false) - expect(controller.get(exactKey)).toBeDefined() - expect(controller.get(oversizedKey)).toBeUndefined() - }) - - it('globally evicts the least-recently-used settled entry under aggregate pressure', () => { - const value = entry('payload') - const retainedValueBytes = entryBytes(value) - const controller = createWorkItemDetailsCacheController({ - maxAggregateBytes: 2 * (retainedValueBytes + 1), - maxValueBytes: retainedValueBytes - }) - - controller.set('a', value) - controller.set('b', value) - expect(controller.getRetainedBytes()).toBe(2 * (retainedValueBytes + 1)) - expect(controller.get('a')).toBe(value) - controller.set('c', value) - - expect(controller.get('a')).toBe(value) - expect(controller.get('b')).toBeUndefined() - expect(controller.get('c')).toBe(value) - }) - - it('keeps pending promises count-bounded without measuring the promise object', () => { - const controller = createWorkItemDetailsCacheController() - const pending = Promise.resolve(null) - - for (let index = 0; index <= WORK_ITEM_DETAILS_CACHE_MAX; index += 1) { - expect( - controller.set(`pending-${index}`, { - details: null, - fetchedAt: 0, - pending - }) - ).toBe(true) - } - - expect(controller.getSize()).toBe(WORK_ITEM_DETAILS_CACHE_MAX) - expect(controller.get('pending-0')).toBeUndefined() - expect(controller.get(`pending-${WORK_ITEM_DETAILS_CACHE_MAX}`)?.pending).toBe(pending) - }) - - it('releases aggregate accounting when matching entries are invalidated', () => { - const controller = createWorkItemDetailsCacheController() - controller.set('repo-a\u0000auto\u0000issue\u00001', entry('first')) - controller.set('repo-b\u0000auto\u0000issue\u00001', entry('second')) - const retainedBefore = controller.getRetainedBytes() - - expect(controller.deleteMatching((key) => key.startsWith('repo-a\u0000'))).toEqual([ - 'repo-a\u0000auto\u0000issue\u00001' - ]) - expect(controller.getRetainedBytes()).toBeLessThan(retainedBefore) - controller.clear() - expect(controller.getRetainedBytes()).toBe(0) - }) -}) - -describe('shared work-item details cache', () => { - it('preserves exact-key and match invalidation generation semantics', () => { - const key = getWorkItemDetailsCacheKey({ - repoPath: '/repo', - repoId: 'repo-1', - issueSourcePreference: undefined, - type: 'issue', - number: 1 - }) - touchWorkItemDetailsCache(key, entry()) - const initialGeneration = getWorkItemDetailsCacheGeneration() - - invalidateWorkItemDetailsCacheByMatch({ - repoPath: '/repo', - repoId: 'repo-1', - type: 'issue', - number: 1 - }) - expect(getWorkItemDetailsCacheGeneration()).toBe(initialGeneration + 1) - expect(getWorkItemDetailsCacheEntry(key)).toBeUndefined() - - invalidateWorkItemDetailsCacheForKey(key) - expect(getWorkItemDetailsCacheGeneration()).toBe(initialGeneration + 2) - }) - - it('renders an oversized result only while its consumer remains mounted', () => { - const key = 'mounted' - const oversized = entry('x'.repeat(WORK_ITEM_DETAILS_CACHE_MAX_VALUE_BYTES)) - const container = document.createElement('div') - document.body.append(container) - const root = createRoot(container) - - function Probe(): React.JSX.Element { - const cached = useWorkItemDetailsCacheEntry(key) - return createElement('span', null, cached?.details?.body.length ?? 'missing') - } - - act(() => root.render(createElement(Probe))) - act(() => { - expect(touchWorkItemDetailsCache(key, oversized)).toBe(false) - }) - - expect(container.textContent).toBe(String(oversized.details?.body.length)) - expect(getWorkItemDetailsCacheEntry(key)).toBeUndefined() - - act(() => root.unmount()) - const remountedRoot: Root = createRoot(container) - act(() => remountedRoot.render(createElement(Probe))) - expect(container.textContent).toBe('missing') - act(() => remountedRoot.unmount()) - }) -}) diff --git a/src/renderer/src/lib/github-work-item-details-cache.ts b/src/renderer/src/lib/github-work-item-details-cache.ts deleted file mode 100644 index 6f6158f44c3..00000000000 --- a/src/renderer/src/lib/github-work-item-details-cache.ts +++ /dev/null @@ -1,263 +0,0 @@ -import type { GitHubWorkItemDetails } from '../../../shared/types' -import { measureUtf8ByteLength } from '../../../shared/utf8-byte-limits' -import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react' -import { - onGitHubWorkItemDetailsCacheMutation, - type GitHubWorkItemDetailsCacheMutation -} from './github-work-item-details-cache-events' -import { measureWorkItemDetailsCacheEntryBytes } from './github-work-item-details-retained-bytes' - -export const WORK_ITEM_DETAILS_CACHE_MAX = 50 -export const WORK_ITEM_DETAILS_CACHE_MAX_AGGREGATE_BYTES = 32 * 1024 * 1024 -export const WORK_ITEM_DETAILS_CACHE_MAX_VALUE_BYTES = 16 * 1024 * 1024 -export const WORK_ITEM_DETAILS_CACHE_MAX_KEY_BYTES = 4 * 1024 -export const WORK_ITEM_DETAILS_FRESH_MS = 30_000 -export const WORK_ITEM_DETAILS_UNAVAILABLE_MESSAGE = 'Unable to load details for this GitHub item.' - -export type WorkItemDetailsCacheEntry = { - details: GitHubWorkItemDetails | null - fetchedAt: number - pending?: Promise - error?: string -} - -type WorkItemDetailsCacheLimits = { - maxEntries: number - maxAggregateBytes: number - maxValueBytes: number - maxKeyBytes: number -} - -type WorkItemDetailsCacheActivity = - | { type: 'write'; key: string; entry: WorkItemDetailsCacheEntry; retained: boolean } - | { type: 'invalidate-key'; key: string } - | { type: 'invalidate-match'; match: GitHubWorkItemDetailsCacheMutation } - -const DEFAULT_LIMITS: WorkItemDetailsCacheLimits = { - maxEntries: WORK_ITEM_DETAILS_CACHE_MAX, - maxAggregateBytes: WORK_ITEM_DETAILS_CACHE_MAX_AGGREGATE_BYTES, - maxValueBytes: WORK_ITEM_DETAILS_CACHE_MAX_VALUE_BYTES, - maxKeyBytes: WORK_ITEM_DETAILS_CACHE_MAX_KEY_BYTES -} - -export function createWorkItemDetailsCacheController( - overrides: Partial = {} -) { - const limits = { ...DEFAULT_LIMITS, ...overrides } - const entries = new Map() - let retainedBytes = 0 - const remove = (key: string): boolean => { - const existing = entries.get(key) - if (!existing) { - return false - } - retainedBytes -= existing.bytes - return entries.delete(key) - } - const evictOldest = (): void => { - const oldest = entries.keys().next().value - if (oldest !== undefined) { - remove(oldest) - } - } - return { - get: (key) => { - const retained = entries.get(key) - if (!retained) { - return undefined - } - entries.delete(key) - entries.set(key, retained) - return retained.entry - }, - set: (key, entry) => { - remove(key) - const keyBytes = measureUtf8ByteLength(key, { stopAfterBytes: limits.maxKeyBytes }) - if (keyBytes.exceededLimit) { - return false - } - const valueBytes = measureWorkItemDetailsCacheEntryBytes(entry, limits.maxValueBytes) - if (valueBytes === null || keyBytes.byteLength + valueBytes > limits.maxAggregateBytes) { - return false - } - const bytes = keyBytes.byteLength + valueBytes - entries.set(key, { entry, bytes }) - retainedBytes += bytes - while (entries.size > limits.maxEntries || retainedBytes > limits.maxAggregateBytes) { - evictOldest() - } - return entries.get(key)?.entry === entry - }, - delete: remove, - deleteMatching: (predicate) => { - const removed: string[] = [] - for (const key of entries.keys()) { - if (predicate(key) && remove(key)) { - removed.push(key) - } - } - return removed - }, - clear: () => { - entries.clear() - retainedBytes = 0 - }, - getRetainedBytes: () => retainedBytes, - getSize: () => entries.size - } -} - -const sharedCache = createWorkItemDetailsCacheController() -const cacheListeners = new Set<() => void>() -const activityListeners = new Set<(activity: WorkItemDetailsCacheActivity) => boolean | void>() -let cacheGeneration = 0 - -function notifyCache(): void { - for (const listener of cacheListeners) { - listener() - } -} - -function emitActivity(activity: WorkItemDetailsCacheActivity): boolean { - let matched = false - for (const listener of activityListeners) { - matched = listener(activity) === true || matched - } - return matched -} - -export function getWorkItemDetailsCacheKey(args: { - repoPath: string - repoId: string - issueSourcePreference: string | undefined - sourceCacheScope?: string | null - type: 'issue' | 'pr' - number: number -}): string { - const keyParts = args.sourceCacheScope - ? [args.repoId, args.sourceCacheScope, args.issueSourcePreference ?? 'auto', args.type] - : [args.repoId, args.issueSourcePreference ?? 'auto', args.type] - return [...keyParts, args.number].join('\0') -} - -export function getWorkItemDetailsCacheEntry(key: string): WorkItemDetailsCacheEntry | undefined { - return sharedCache.get(key) -} - -export function touchWorkItemDetailsCache(key: string, entry: WorkItemDetailsCacheEntry): boolean { - const retained = sharedCache.set(key, entry) - emitActivity({ type: 'write', key, entry, retained }) - notifyCache() - return retained -} - -export function subscribeWorkItemDetailsCache(listener: () => void): () => void { - cacheListeners.add(listener) - return () => cacheListeners.delete(listener) -} - -export function subscribeWorkItemDetailsCacheActivity( - listener: (activity: WorkItemDetailsCacheActivity) => boolean | void -): () => void { - activityListeners.add(listener) - return () => activityListeners.delete(listener) -} - -export function getWorkItemDetailsCacheGeneration(): number { - return cacheGeneration -} - -export function matchesWorkItemDetailsCacheInvalidation( - key: string, - args: GitHubWorkItemDetailsCacheMutation -): boolean { - const prefix = `${args.repoId ?? args.repoPath}\0` - return key.startsWith(prefix) && key.endsWith(`\0${args.type}\0${args.number}`) -} - -export function useWorkItemDetailsCacheEntry( - key: string | null -): WorkItemDetailsCacheEntry | undefined { - const retainedEntry = useSyncExternalStore( - subscribeWorkItemDetailsCache, - useCallback(() => (key ? getWorkItemDetailsCacheEntry(key) : undefined), [key]) - ) - const unretainedRef = useRef<{ key: string; entry: WorkItemDetailsCacheEntry } | null>(null) - const lastKeyRef = useRef(key) - const [, setRevision] = useState(0) - if (lastKeyRef.current !== key) { - lastKeyRef.current = key - unretainedRef.current = null - } - useEffect( - () => - subscribeWorkItemDetailsCacheActivity((activity) => { - if (!key) { - return false - } - if (activity.type === 'write' && activity.key === key) { - unretainedRef.current = activity.retained ? null : { key, entry: activity.entry } - setRevision((current) => current + 1) - return false - } - const invalidated = - (activity.type === 'invalidate-key' && activity.key === key) || - (activity.type === 'invalidate-match' && - matchesWorkItemDetailsCacheInvalidation(key, activity.match)) - if (!invalidated) { - return false - } - const hadUnretainedEntry = unretainedRef.current?.key === key - unretainedRef.current = null - setRevision((current) => current + 1) - return hadUnretainedEntry - }), - [key] - ) - return ( - retainedEntry ?? (unretainedRef.current?.key === key ? unretainedRef.current.entry : undefined) - ) -} - -export function invalidateWorkItemDetailsCacheForKey(key: string): void { - cacheGeneration += 1 - const existed = sharedCache.delete(key) - emitActivity({ type: 'invalidate-key', key }) - if (existed) { - notifyCache() - } -} - -export function invalidateWorkItemDetailsCacheByMatch( - args: GitHubWorkItemDetailsCacheMutation -): void { - const removed = sharedCache.deleteMatching((key) => - matchesWorkItemDetailsCacheInvalidation(key, args) - ) - const transientMatched = emitActivity({ type: 'invalidate-match', match: args }) - if (removed.length > 0 || transientMatched) { - cacheGeneration += 1 - } - if (removed.length > 0) { - notifyCache() - } -} - -export function clearWorkItemDetailsCacheForTests(): void { - sharedCache.clear() - cacheGeneration = 0 - notifyCache() -} - -let workItemMutatedUnsub: (() => void) | undefined -let cacheEventUnsub: (() => void) | undefined -if (typeof window !== 'undefined' && window.api?.gh?.onWorkItemMutated) { - workItemMutatedUnsub = window.api.gh.onWorkItemMutated(invalidateWorkItemDetailsCacheByMatch) - cacheEventUnsub = onGitHubWorkItemDetailsCacheMutation(invalidateWorkItemDetailsCacheByMatch) -} -if (typeof import.meta !== 'undefined' && import.meta.hot) { - import.meta.hot.dispose(() => { - workItemMutatedUnsub?.() - cacheEventUnsub?.() - }) -} diff --git a/src/renderer/src/lib/github-work-item-details-retained-bytes.ts b/src/renderer/src/lib/github-work-item-details-retained-bytes.ts deleted file mode 100644 index a8037067462..00000000000 --- a/src/renderer/src/lib/github-work-item-details-retained-bytes.ts +++ /dev/null @@ -1,139 +0,0 @@ -import type { GitHubWorkItemDetails } from '../../../shared/types' -import { measureUtf8ByteLength } from '../../../shared/utf8-byte-limits' - -const CONTAINER_BYTES = 8 -const CONTAINER_ENTRY_BYTES = 8 -const CACHE_ENTRY_BYTES = 16 - -type MeasuredWorkItemDetailsCacheEntry = { - details: GitHubWorkItemDetails | null - error?: string -} - -type MeasurementFrame = - | { kind: 'value'; value: unknown } - | { kind: 'array'; value: readonly unknown[]; index: number } - | { kind: 'record'; value: Record; keys: Generator } - -function* ownEnumerableKeys(value: Record): Generator { - for (const key in value) { - if (Object.hasOwn(value, key)) { - yield key - } - } -} - -function addBytes(current: number, additional: number, maxBytes: number): number | null { - const next = current + additional - return next <= maxBytes ? next : null -} - -function addStringBytes(current: number, value: string, maxBytes: number): number | null { - const measured = measureUtf8ByteLength(value, { - stopAfterBytes: Math.max(0, maxBytes - current) - }) - if (measured.exceededLimit) { - return null - } - return addBytes(current, measured.byteLength, maxBytes) -} - -function measureRetainedValue(value: unknown, maxBytes: number, initialBytes = 0): number | null { - const seen = new WeakSet() - const frames: MeasurementFrame[] = [{ kind: 'value', value }] - let bytes = initialBytes - while (frames.length > 0) { - const frame = frames.pop() - if (!frame) { - break - } - if (frame.kind === 'array') { - if (frame.index >= frame.value.length) { - continue - } - const index = frame.index - frame.index += 1 - frames.push(frame) - if (Object.hasOwn(frame.value, index)) { - frames.push({ kind: 'value', value: frame.value[index] }) - } - continue - } - if (frame.kind === 'record') { - const nextKey = frame.keys.next() - if (nextKey.done) { - continue - } - const withEntry = addBytes(bytes, CONTAINER_ENTRY_BYTES, maxBytes) - const withKey = withEntry === null ? null : addStringBytes(withEntry, nextKey.value, maxBytes) - if (withKey === null) { - return null - } - bytes = withKey - frames.push(frame) - frames.push({ kind: 'value', value: frame.value[nextKey.value] }) - continue - } - - const current = frame.value - if (current === null || current === undefined) { - continue - } - if (typeof current === 'string') { - const measured = addStringBytes(bytes, current, maxBytes) - if (measured === null) { - return null - } - bytes = measured - continue - } - if (typeof current === 'number' || typeof current === 'boolean') { - const measured = addBytes(bytes, typeof current === 'number' ? 8 : 1, maxBytes) - if (measured === null) { - return null - } - bytes = measured - continue - } - if (typeof current !== 'object' || seen.has(current)) { - if (typeof current !== 'object') { - return null - } - continue - } - - seen.add(current) - const withContainer = addBytes(bytes, CONTAINER_BYTES, maxBytes) - if (withContainer === null) { - return null - } - bytes = withContainer - if (Array.isArray(current)) { - const withEntries = addBytes(bytes, current.length * CONTAINER_ENTRY_BYTES, maxBytes) - if (withEntries === null) { - return null - } - bytes = withEntries - frames.push({ kind: 'array', value: current, index: 0 }) - continue - } - const prototype = Object.getPrototypeOf(current) - if (prototype !== Object.prototype && prototype !== null) { - return null - } - const record = current as Record - frames.push({ kind: 'record', value: record, keys: ownEnumerableKeys(record) }) - } - return bytes -} - -export function measureWorkItemDetailsCacheEntryBytes( - entry: MeasuredWorkItemDetailsCacheEntry, - maxBytes: number -): number | null { - const detailsBytes = measureRetainedValue(entry.details, maxBytes, CACHE_ENTRY_BYTES) - if (detailsBytes === null || !entry.error) { - return detailsBytes - } - return addStringBytes(detailsBytes, entry.error, maxBytes) -} diff --git a/src/renderer/src/lib/operation-generation-registry.test.ts b/src/renderer/src/lib/operation-generation-registry.test.ts deleted file mode 100644 index 8631fbafeeb..00000000000 --- a/src/renderer/src/lib/operation-generation-registry.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { OperationGenerationRegistry } from './operation-generation-registry' - -const BOUNDS = { - maxEntries: 2, - maxKeyBytes: 4, - maxTotalKeyBytes: 6 -} - -describe('OperationGenerationRegistry', () => { - it('keeps a stable token until the owner advances', () => { - const generations = new OperationGenerationRegistry(BOUNDS) - const initial = generations.get('a') - - expect(generations.get('a')).toBe(initial) - expect(generations.advance('a')).not.toBe(initial) - }) - - it('bounds retained owners and invalidates an evicted capture', () => { - const generations = new OperationGenerationRegistry(BOUNDS) - const evicted = generations.get('a') - generations.get('bb') - generations.get('ccc') - - expect(generations.evidence()).toEqual({ entries: 2, keyBytes: 5 }) - expect(generations.get('a')).not.toBe(evicted) - expect(generations.evidence().entries).toBe(2) - }) - - it('bounds aggregate retained key bytes', () => { - const generations = new OperationGenerationRegistry({ - maxEntries: 3, - maxKeyBytes: 4, - maxTotalKeyBytes: 3 - }) - const evicted = generations.get('aa') - generations.get('b') - generations.get('cc') - - expect(generations.evidence()).toEqual({ entries: 2, keyBytes: 3 }) - expect(generations.get('aa')).not.toBe(evicted) - }) - - it('fails closed for a key that cannot be retained', () => { - const generations = new OperationGenerationRegistry(BOUNDS) - const first = generations.get('oversized') - - expect(generations.get('oversized')).not.toBe(first) - expect(generations.evidence()).toEqual({ entries: 0, keyBytes: 0 }) - }) - - it('invalidates a capture when its owner is explicitly forgotten', () => { - const generations = new OperationGenerationRegistry(BOUNDS) - const captured = generations.get('a') - generations.delete('a') - - expect(generations.get('a')).not.toBe(captured) - }) -}) diff --git a/src/renderer/src/lib/operation-generation-registry.ts b/src/renderer/src/lib/operation-generation-registry.ts deleted file mode 100644 index 864194fd995..00000000000 --- a/src/renderer/src/lib/operation-generation-registry.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { measureUtf8ByteLength } from '../../../shared/utf8-byte-limits' - -export type OperationGenerationRegistryBounds = { - maxEntries: number - maxKeyBytes: number - maxTotalKeyBytes: number -} - -export const DEFAULT_OPERATION_GENERATION_REGISTRY_BOUNDS: OperationGenerationRegistryBounds = { - maxEntries: 8_192, - maxKeyBytes: 64 * 1024, - maxTotalKeyBytes: 4 * 1024 * 1024 -} - -type RetainedGeneration = { - generation: number - keyBytes: number -} - -export class OperationGenerationRegistry { - private readonly generations = new Map() - private retainedKeyBytes = 0 - private nextGeneration = 0 - - constructor( - private readonly bounds: OperationGenerationRegistryBounds = DEFAULT_OPERATION_GENERATION_REGISTRY_BOUNDS - ) { - if ( - !Number.isSafeInteger(bounds.maxEntries) || - bounds.maxEntries < 1 || - !Number.isSafeInteger(bounds.maxKeyBytes) || - bounds.maxKeyBytes < 1 || - !Number.isSafeInteger(bounds.maxTotalKeyBytes) || - bounds.maxTotalKeyBytes < 1 - ) { - throw new RangeError('Operation generation registry bounds must be positive integers') - } - } - - get(key: string): number { - const retained = this.generations.get(key) - if (!retained) { - // Why: a miss may be an eviction, so reusing zero could admit a stale capture. - return this.replace(key) - } - this.generations.delete(key) - this.generations.set(key, retained) - return retained.generation - } - - advance(key: string): number { - return this.replace(key) - } - - delete(key: string): void { - const retained = this.generations.get(key) - if (!retained) { - return - } - this.generations.delete(key) - this.retainedKeyBytes -= retained.keyBytes - } - - evidence(): { entries: number; keyBytes: number } { - return { - entries: this.generations.size, - keyBytes: this.retainedKeyBytes - } - } - - private replace(key: string): number { - this.delete(key) - const generation = ++this.nextGeneration - const measurement = measureUtf8ByteLength(key, { - stopAfterBytes: this.bounds.maxKeyBytes - }) - if (measurement.exceededLimit || measurement.byteLength > this.bounds.maxTotalKeyBytes) { - return generation - } - while ( - this.generations.size >= this.bounds.maxEntries || - this.retainedKeyBytes + measurement.byteLength > this.bounds.maxTotalKeyBytes - ) { - const oldest = this.generations.keys().next().value - if (oldest === undefined) { - return generation - } - this.delete(oldest) - } - this.generations.set(key, { - generation, - keyBytes: measurement.byteLength - }) - this.retainedKeyBytes += measurement.byteLength - return generation - } -} diff --git a/src/renderer/src/lib/pane-manager/terminal-scroll-intent.ts b/src/renderer/src/lib/pane-manager/terminal-scroll-intent.ts index 3a77b2c7332..8fa96d62c69 100644 --- a/src/renderer/src/lib/pane-manager/terminal-scroll-intent.ts +++ b/src/renderer/src/lib/pane-manager/terminal-scroll-intent.ts @@ -75,11 +75,7 @@ function writeIntentSnapshot( nextTerminalScrollIntentRevision += 1 terminalScrollIntentByTerminal.set(terminal, intent) const key = terminalScrollIntentKeyByTerminal.get(terminal) - if ( - key && - terminalScrollIntentKeyBindingByTerminal.get(terminal) === - terminalScrollIntentBindingByKey.get(key) - ) { + if (key) { terminalScrollIntentByKey.set(key, intent) } return intent @@ -126,13 +122,6 @@ export function isTerminalScrollIntentKeyBindingCurrent( ) } -export function releaseTerminalScrollIntentKeys(keys: Iterable): void { - for (const key of keys) { - terminalScrollIntentByKey.delete(key) - terminalScrollIntentBindingByKey.delete(key) - } -} - export function markTerminalFollowOutput(terminal: TerminalScrollIntentTarget): void { writeIntent(terminal, 'followOutput') } diff --git a/src/renderer/src/lib/react-error-boundary-reporting.ts b/src/renderer/src/lib/react-error-boundary-reporting.ts index 46c6437937e..3b525f7c6b3 100644 --- a/src/renderer/src/lib/react-error-boundary-reporting.ts +++ b/src/renderer/src/lib/react-error-boundary-reporting.ts @@ -100,25 +100,13 @@ function rememberRendererErrorKey(key: string): boolean { } function getRendererErrorKey(args: ReactErrorBoundaryReportArgs): string { - // Why: error text can be arbitrarily large; retain only a fixed-size dedupe identity. - let first = 2166136261 - let second = 2246822507 - for (const value of [ - args.boundaryId, - args.surface, - args.errorName, - args.errorMessage, - args.componentStack ?? '' - ]) { - for (let index = 0; index < value.length; index += 1) { - const code = value.charCodeAt(index) - first = Math.imul(first ^ code, 16777619) - second = Math.imul(second ^ code, 3266489909) - } - first = Math.imul(first ^ value.length, 16777619) - second = Math.imul(second ^ value.length, 668265263) - } - return `${first >>> 0}:${second >>> 0}` + return JSON.stringify({ + boundaryId: args.boundaryId, + surface: args.surface, + errorName: args.errorName, + errorMessage: args.errorMessage, + componentStack: args.componentStack + }) } export function takePendingReactErrorBoundaryReport(): CrashReportRecord | null { diff --git a/src/renderer/src/lib/repo-slug-cache.test.ts b/src/renderer/src/lib/repo-slug-cache.test.ts index 8cf79dd6662..dd0f9e4bd28 100644 --- a/src/renderer/src/lib/repo-slug-cache.test.ts +++ b/src/renderer/src/lib/repo-slug-cache.test.ts @@ -2,8 +2,6 @@ import { beforeEach, describe, expect, it } from 'vitest' import type { Repo } from '../../../shared/types' import { githubRepoIdentityKey } from '../../../shared/github-repository-identity-key' import { - MAX_REPO_SLUG_CACHE_ENTRIES, - REPO_SLUG_CACHE_MAX_ENTRY_BYTES, REPO_SLUG_FAILURE_TTL_MS, clearRepoSlugCacheValues, nextRepoSlugFailureRetryDelay, @@ -56,46 +54,4 @@ describe('repo slug cache host identity', () => { expect(nextRepoSlugFailureRetryDelay(new Set([key]), 1_000)).toBe(REPO_SLUG_FAILURE_TTL_MS) expect(readRepoSlugCache(key, 1_000 + REPO_SLUG_FAILURE_TTL_MS)).toEqual({ hit: false }) }) - - it('retains only the newest repo slug results after pathological repo churn', () => { - for (let index = 0; index <= MAX_REPO_SLUG_CACHE_ENTRIES; index += 1) { - rememberRepoSlug(`local:repo-${index}`, `owner/repo-${index}`) - } - - expect(slugByRepoId).toHaveLength(MAX_REPO_SLUG_CACHE_ENTRIES) - expect(readRepoSlugCache('local:repo-0')).toEqual({ hit: false }) - expect(readRepoSlugCache('local:repo-1')).toEqual({ - hit: true, - value: 'owner/repo-1' - }) - expect(readRepoSlugCache(`local:repo-${MAX_REPO_SLUG_CACHE_ENTRIES}`)).toEqual({ - hit: true, - value: `owner/repo-${MAX_REPO_SLUG_CACHE_ENTRIES}` - }) - }) - - it('does not refresh insertion order when an existing slug changes', () => { - for (let index = 0; index < MAX_REPO_SLUG_CACHE_ENTRIES; index += 1) { - rememberRepoSlug(`local:repo-${index}`, `owner/repo-${index}`) - } - rememberRepoSlug('local:repo-0', 'owner/renamed') - rememberRepoSlug('local:overflow', 'owner/overflow') - - expect(readRepoSlugCache('local:repo-0')).toEqual({ hit: false }) - expect(readRepoSlugCache('local:repo-1')).toEqual({ - hit: true, - value: 'owner/repo-1' - }) - }) - - it('accepts an exact-byte entry and rejects an oversized entry', () => { - const key = 'local:repo-1' - const exactValue = 'x'.repeat(REPO_SLUG_CACHE_MAX_ENTRY_BYTES - key.length) - - rememberRepoSlug(key, exactValue) - rememberRepoSlug('local:oversized', 'x'.repeat(REPO_SLUG_CACHE_MAX_ENTRY_BYTES)) - - expect(readRepoSlugCache(key)).toEqual({ hit: true, value: exactValue }) - expect(readRepoSlugCache('local:oversized')).toEqual({ hit: false }) - }) }) diff --git a/src/renderer/src/lib/repo-slug-cache.ts b/src/renderer/src/lib/repo-slug-cache.ts index ec96cec0d74..38ea5d32995 100644 --- a/src/renderer/src/lib/repo-slug-cache.ts +++ b/src/renderer/src/lib/repo-slug-cache.ts @@ -5,7 +5,6 @@ import type { GlobalSettings, Repo } from '../../../shared/types' import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { getSettingsForRepoRuntimeOwner } from './repo-runtime-owner' import { githubRepoIdentityKey } from '../../../shared/github-repository-identity-key' -import { measureUtf8ByteLength } from '../../../shared/utf8-byte-limits' /** Lowercased `owner/repo` → Repo[]. */ export type SlugIndex = Map @@ -16,22 +15,6 @@ export type SlugIndex = Map export const slugByRepoId = new Map() const slugFailureExpiresAtByRepoId = new Map() export const REPO_SLUG_FAILURE_TTL_MS = 60_000 -export const MAX_REPO_SLUG_CACHE_ENTRIES = 2_048 -export const REPO_SLUG_CACHE_MAX_ENTRY_BYTES = 8 * 1024 - -function repoSlugCacheEntryFits(cacheKey: string, value: string | null): boolean { - const keyBytes = measureUtf8ByteLength(cacheKey, { - stopAfterBytes: REPO_SLUG_CACHE_MAX_ENTRY_BYTES - }) - if (keyBytes.exceededLimit) { - return false - } - return value === null - ? true - : !measureUtf8ByteLength(value, { - stopAfterBytes: REPO_SLUG_CACHE_MAX_ENTRY_BYTES - keyBytes.byteLength - }).exceededLimit -} export function readRepoSlugCache( cacheKey: string, @@ -51,16 +34,6 @@ export function readRepoSlugCache( } export function rememberRepoSlug(cacheKey: string, value: string | null, now = Date.now()): void { - if (!repoSlugCacheEntryFits(cacheKey, value)) { - deleteRepoSlugCacheKey(cacheKey) - return - } - if (!slugByRepoId.has(cacheKey) && slugByRepoId.size >= MAX_REPO_SLUG_CACHE_ENTRIES) { - const oldestCacheKey = slugByRepoId.keys().next().value - if (oldestCacheKey !== undefined) { - deleteRepoSlugCacheKey(oldestCacheKey) - } - } slugByRepoId.set(cacheKey, value) if (value === null) { slugFailureExpiresAtByRepoId.set(cacheKey, now + REPO_SLUG_FAILURE_TTL_MS) diff --git a/src/renderer/src/lib/repo-slug-index-retention.test.ts b/src/renderer/src/lib/repo-slug-index-retention.test.ts deleted file mode 100644 index 5a39f272701..00000000000 --- a/src/renderer/src/lib/repo-slug-index-retention.test.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { Repo } from '../../../shared/types' -import { rememberRepoSlug } from './repo-slug-cache' -import { - buildRepoSlugIndexForTests, - buildSharedRepoSlugIndexForTests, - clearRepoSlugCache, - clearRepoSlugCacheEntry, - getRepoSlugResolutionStateSizesForTests, - REPO_SLUG_RESOLUTION_CONCURRENCY -} from './repo-slug-index' - -function repo(id: string): Repo { - return { - id, - path: `/${id}`, - displayName: id, - badgeColor: '#000000', - addedAt: 1, - executionHostId: 'local' - } -} - -describe('repo slug resolution retention', () => { - beforeEach(() => clearRepoSlugCache()) - - it('does not retain invalidation state for removed repo ids', () => { - for (let index = 0; index < 3_000; index += 1) { - const repoId = `repo-${index}` - rememberRepoSlug(`local:${repoId}`, `owner/${repoId}`) - clearRepoSlugCacheEntry(repoId) - } - - expect(getRepoSlugResolutionStateSizesForTests()).toEqual({ inFlight: 0, tokens: 0 }) - }) - - it('shares one in-flight index build across identical consumers', async () => { - const repos = Array.from({ length: 20 }, (_, index) => repo(`repo-${index}`)) - let calls = 0 - const resolver = async (candidate: Repo): Promise => { - calls += 1 - return `owner/${candidate.id}` - } - - const first = buildSharedRepoSlugIndexForTests(repos, resolver) - const second = buildSharedRepoSlugIndexForTests(repos, resolver) - const [firstIndex, secondIndex] = await Promise.all([first, second]) - - expect(calls).toBe(repos.length) - expect(firstIndex).toBe(secondIndex) - }) - - it('cancels queued work when a shared build is superseded', async () => { - const staleResolver = vi.fn(async (candidate: Repo) => `owner/${candidate.id}`) - const currentResolver = vi.fn(async (candidate: Repo) => `owner/${candidate.id}`) - const stale = buildSharedRepoSlugIndexForTests([repo('stale')], staleResolver) - const current = buildSharedRepoSlugIndexForTests([repo('current')], currentResolver) - - const [staleResult, currentResult] = await Promise.allSettled([stale, current]) - - expect(staleResult).toMatchObject({ - status: 'rejected', - reason: expect.objectContaining({ message: 'Repo slug index resolution was cancelled.' }) - }) - expect(currentResult).toMatchObject({ status: 'fulfilled' }) - expect(staleResolver).not.toHaveBeenCalled() - expect(currentResolver).toHaveBeenCalledTimes(1) - }) - - it('shares the fixed worker pool across overlapping index builds', async () => { - const repos = Array.from({ length: REPO_SLUG_RESOLUTION_CONCURRENCY * 3 }, (_, index) => - repo(`first-${index}`) - ) - const overlappingRepos = Array.from( - { length: REPO_SLUG_RESOLUTION_CONCURRENCY * 3 }, - (_, index) => repo(`second-${index}`) - ) - let release: () => void = () => {} - const gate = new Promise((resolve) => { - release = resolve - }) - let active = 0 - let peak = 0 - let started = 0 - - const resolver = async (candidate: Repo): Promise => { - active += 1 - started += 1 - peak = Math.max(peak, active) - await gate - active -= 1 - return `owner/${candidate.id}` - } - const building = buildRepoSlugIndexForTests(repos, resolver) - const overlappingBuild = buildRepoSlugIndexForTests(overlappingRepos, resolver) - - await Promise.resolve() - expect(started).toBe(REPO_SLUG_RESOLUTION_CONCURRENCY) - expect(peak).toBe(REPO_SLUG_RESOLUTION_CONCURRENCY) - release() - - const [index, overlappingIndex] = await Promise.all([building, overlappingBuild]) - expect(index.size).toBe(repos.length) - expect(overlappingIndex.size).toBe(overlappingRepos.length) - expect(peak).toBe(REPO_SLUG_RESOLUTION_CONCURRENCY) - }) - - it('preserves input ordering when resolutions settle out of order', async () => { - const repos = ['a', 'b', 'c', 'd'].map(repo) - const completions = new Map void>() - const building = buildRepoSlugIndexForTests( - repos, - (candidate) => - new Promise((resolve) => { - completions.set(candidate.id, resolve) - }) - ) - - await Promise.resolve() - completions.get('d')?.('owner/shared') - completions.get('c')?.(null) - completions.get('b')?.('owner/shared') - completions.get('a')?.('owner/other') - - const index = await building - expect(Array.from(index.keys())).toEqual(['owner/other', 'owner/shared']) - expect(index.get('owner/shared')?.map((candidate) => candidate.id)).toEqual(['b', 'd']) - }) -}) diff --git a/src/renderer/src/lib/repo-slug-index.ts b/src/renderer/src/lib/repo-slug-index.ts index 92169c8c8d2..34c88bec363 100644 --- a/src/renderer/src/lib/repo-slug-index.ts +++ b/src/renderer/src/lib/repo-slug-index.ts @@ -1,5 +1,18 @@ -// Project rows carry slugs while repo state carries paths, so repo-context -// actions need a lazily resolved slug → Repo[] index. +// Why: Project mode rows carry a GitHub `owner/repo` slug, but Orca's +// `state.repos` stores only absolute paths. Before any repo-context action +// (opening the item dialog in repo-backed mode, launching a worktree) can +// dispatch correctly, we need a renderer-side index mapping slug → Repo[]. +// +// The index is built lazily from `window.api.gh.repoSlug({ repoPath })` — +// the main-process resolver that reads `git remote` and classifies the +// remote into `owner/repo`. Repos whose slug cannot be resolved (no GitHub +// remote, SSH lookup failure) are excluded; the design doc (§Row actions) +// says to keep the unknown-repo fallback in that case. +// +// The index rebuilds only when `state.repos` changes — adding or removing +// a repo is rare enough that a full re-resolution is simpler than per-id +// invalidation, and the underlying IPC result is itself cached by the main +// process (`repoSlug` reads `.git/config`). import { useEffect, useMemo, useRef, useState } from 'react' import { useAppStore } from '@/store' import type { Repo } from '../../../shared/types' @@ -17,35 +30,22 @@ import { type SlugIndex } from './repo-slug-cache' import { githubRepoIdentityKey } from '../../../shared/github-repository-identity-key' -import { resolveRepoSlugsWithFixedWorkers } from './repo-slug-resolution-pool' export { lookupReposBySlugFromCache } from './repo-slug-cache' -export { REPO_SLUG_RESOLUTION_CONCURRENCY } from './repo-slug-resolution-pool' const slugResolutionInFlight = new Map>() -const slugResolutionTokenByCacheKey = new Map() - -type RepoSlugResolver = ( - repo: Repo, - settings: Pick | null | undefined -) => Promise - -type RepoSlugIndexBuildResult = { index: SlugIndex; retryDelayMs: number | null } - -let sharedIndexBuild: - | { - repos: readonly Repo[] - settings: GlobalSettings | null | undefined - resolver: RepoSlugResolver - controller: AbortController - promise: Promise - } - | undefined +// Why: an invalidation (repo removed, remote changed) can land while a +// resolution is in-flight — before it ever wrote to `slugByRepoId`. Deleting +// the in-flight promise doesn't stop its pending `rememberRepoSlug` write, so a +// stale slug would repopulate the cache after invalidation. Bump the key's +// generation on every invalidation and commit a result only if the generation +// it started with is still current. +const slugResolutionGeneration = new Map() function invalidateSlugResolution(cacheKey: string): void { slugResolutionInFlight.delete(cacheKey) - slugResolutionTokenByCacheKey.delete(cacheKey) + slugResolutionGeneration.set(cacheKey, (slugResolutionGeneration.get(cacheKey) ?? 0) + 1) } // Why: clear after remove/remote-change so the next index build re-resolves. @@ -72,21 +72,11 @@ export function clearRepoSlugCacheEntry(repoId: string): void { /** Clear the entire slug cache. Useful for tests or full repo-list resets. */ export function clearRepoSlugCache(): void { - sharedIndexBuild?.controller.abort() - sharedIndexBuild = undefined clearRepoSlugCacheValues() - slugResolutionTokenByCacheKey.clear() - slugResolutionInFlight.clear() -} - -export function getRepoSlugResolutionStateSizesForTests(): { - inFlight: number - tokens: number -} { - return { - inFlight: slugResolutionInFlight.size, - tokens: slugResolutionTokenByCacheKey.size + for (const key of slugResolutionInFlight.keys()) { + slugResolutionGeneration.set(key, (slugResolutionGeneration.get(key) ?? 0) + 1) } + slugResolutionInFlight.clear() } async function resolveRepoSlug( @@ -102,13 +92,12 @@ async function resolveRepoSlug( if (inFlight) { return inFlight } - const resolutionToken = {} - slugResolutionTokenByCacheKey.set(cacheKey, resolutionToken) + const generation = slugResolutionGeneration.get(cacheKey) ?? 0 const resolution = (async () => { // Why: only write the resolved value if this key wasn't invalidated // mid-flight; otherwise a stale slug would repopulate the cache. const commit = (value: string | null): string | null => { - if (slugResolutionTokenByCacheKey.get(cacheKey) === resolutionToken) { + if ((slugResolutionGeneration.get(cacheKey) ?? 0) === generation) { rememberRepoSlug(cacheKey, value) } return value @@ -142,109 +131,39 @@ async function resolveRepoSlug( if (slugResolutionInFlight.get(cacheKey) === resolution) { slugResolutionInFlight.delete(cacheKey) } - if (slugResolutionTokenByCacheKey.get(cacheKey) === resolutionToken) { - slugResolutionTokenByCacheKey.delete(cacheKey) - } } } -function indexResolvedRepoSlugs( - repos: readonly Repo[], - slugs: readonly (string | null)[] -): SlugIndex { - const index: SlugIndex = new Map() - for (let repoIndex = 0; repoIndex < repos.length; repoIndex += 1) { - const slug = slugs[repoIndex] - if (slug) { - const repo = repos[repoIndex] - const matches = index.get(slug) - if (matches) { - matches.push(repo) - } else { - index.set(slug, [repo]) - } - } - } - return index -} - -/** @internal - fixed-worker regression coverage only. */ -export async function buildRepoSlugIndexForTests( - repos: readonly Repo[], - resolver: (repo: Repo) => Promise -): Promise { - const slugs = await resolveRepoSlugsWithFixedWorkers(repos, resolver) - return indexResolvedRepoSlugs(repos, slugs) -} - async function buildIndex( repos: Repo[], - settings: GlobalSettings | null | undefined, - resolver: RepoSlugResolver = resolveRepoSlug, - signal?: AbortSignal -): Promise { + settings: Pick | null | undefined +): Promise<{ index: SlugIndex; retryDelayMs: number | null }> { // Why: evict cached entries for repos that no longer exist in state so // the cache cannot grow unbounded across long sessions where users add // and remove repos. Without this, every removed repo's id (and its // negative-cached null) lingers forever. - const liveKeys = new Set() - for (const repo of repos) { - liveKeys.add(slugCacheKey(repo.id, settingsForRepoOwner(repo, settings))) - } + const liveKeys = new Set(repos.map((r) => slugCacheKey(r.id, settingsForRepoOwner(r, settings)))) for (const key of slugByRepoId.keys()) { if (!liveKeys.has(key)) { deleteRepoSlugCacheKey(key) invalidateSlugResolution(key) } } - for (const key of slugResolutionInFlight.keys()) { - if (!liveKeys.has(key)) { - invalidateSlugResolution(key) - } - } - // Why: repo fleets can be large; only a fixed number of IPC/RPC slug probes - // should own promises and provider response buffers at once. - const slugs = await resolveRepoSlugsWithFixedWorkers( - repos, - (repo) => resolver(repo, settingsForRepoOwner(repo, settings)), - signal + const next: SlugIndex = new Map() + const results = await Promise.all( + repos.map(async (r) => ({ + repo: r, + // Why: the project slug index spans repos from multiple hosts; each + // repo's remote metadata must be read from its owner. + slug: await resolveRepoSlug(r, settingsForRepoOwner(r, settings)) + })) ) - const next = indexResolvedRepoSlugs(repos, slugs) - return { index: next, retryDelayMs: nextRepoSlugFailureRetryDelay(liveKeys) } -} - -function getSharedRepoSlugIndexBuild( - repos: Repo[], - settings: GlobalSettings | null | undefined, - resolver: RepoSlugResolver -): Promise { - if ( - sharedIndexBuild?.repos === repos && - sharedIndexBuild.settings === settings && - sharedIndexBuild.resolver === resolver - ) { - return sharedIndexBuild.promise - } - sharedIndexBuild?.controller.abort() - const controller = new AbortController() - const promise = buildIndex(repos, settings, resolver, controller.signal) - const entry = { repos, settings, resolver, controller, promise } - sharedIndexBuild = entry - const release = (): void => { - if (sharedIndexBuild === entry) { - sharedIndexBuild = undefined + for (const { repo, slug } of results) { + if (slug) { + next.set(slug, [...(next.get(slug) ?? []), repo]) } } - void promise.then(release, release) - return promise -} - -/** @internal - shared-build regression coverage only. */ -export function buildSharedRepoSlugIndexForTests( - repos: Repo[], - resolver: RepoSlugResolver -): Promise { - return getSharedRepoSlugIndexBuild(repos, null, resolver).then((result) => result.index) + return { index: next, retryDelayMs: nextRepoSlugFailureRetryDelay(liveKeys) } } export type RepoSlugIndexState = { @@ -269,19 +188,16 @@ export function useRepoSlugIndex(): RepoSlugIndexState { const gen = ++generationRef.current let retryTimer: ReturnType | undefined setReady(false) - void getSharedRepoSlugIndexBuild(repos, settings, resolveRepoSlug).then( - ({ index: next, retryDelayMs }) => { - if (gen !== generationRef.current) { - return - } - setIndex(next) - setReady(true) - if (retryDelayMs !== null) { - retryTimer = setTimeout(() => setRetryGeneration((value) => value + 1), retryDelayMs) - } - }, - () => {} - ) + void buildIndex(repos, settings).then(({ index: next, retryDelayMs }) => { + if (gen !== generationRef.current) { + return + } + setIndex(next) + setReady(true) + if (retryDelayMs !== null) { + retryTimer = setTimeout(() => setRetryGeneration((value) => value + 1), retryDelayMs) + } + }) return () => { generationRef.current += 1 if (retryTimer) { diff --git a/src/renderer/src/lib/repo-slug-resolution-pool.test.ts b/src/renderer/src/lib/repo-slug-resolution-pool.test.ts deleted file mode 100644 index f5dfaa90b20..00000000000 --- a/src/renderer/src/lib/repo-slug-resolution-pool.test.ts +++ /dev/null @@ -1,207 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { - getRepoSlugResolutionPoolStateForTests, - MAX_REPO_SLUG_RESOLUTION_WAITERS, - REPO_SLUG_RESOLUTION_CONCURRENCY, - resolveRepoSlugsWithFixedWorkers -} from './repo-slug-resolution-pool' - -function deferred(): { promise: Promise; resolve: () => void } { - let resolve!: () => void - return { - promise: new Promise((nextResolve) => { - resolve = nextResolve - }), - resolve - } -} - -async function occupyAllSlots(gate: Promise): Promise<(string | null)[]> { - return resolveRepoSlugsWithFixedWorkers( - Array.from({ length: REPO_SLUG_RESOLUTION_CONCURRENCY }, (_, index) => index), - async (index) => { - await gate - return `owner/repo-${index}` - } - ) -} - -describe('repo slug resolution pool', () => { - it('rejects one-over saturation without retaining another waiter', async () => { - const activeGate = deferred() - const active = occupyAllSlots(activeGate.promise) - await vi.waitFor(() => - expect(getRepoSlugResolutionPoolStateForTests().active).toBe(REPO_SLUG_RESOLUTION_CONCURRENCY) - ) - - const controllers = Array.from( - { length: MAX_REPO_SLUG_RESOLUTION_WAITERS }, - () => new AbortController() - ) - const queued = controllers.map((controller, index) => - resolveRepoSlugsWithFixedWorkers( - [index], - async () => `owner/queued-${index}`, - controller.signal - ) - ) - await vi.waitFor(() => - expect(getRepoSlugResolutionPoolStateForTests().waiters).toBe( - MAX_REPO_SLUG_RESOLUTION_WAITERS - ) - ) - - await expect( - resolveRepoSlugsWithFixedWorkers([0], async () => 'owner/overflow') - ).rejects.toThrow('Too many queued repo slug resolutions.') - expect(getRepoSlugResolutionPoolStateForTests().waiters).toBe(MAX_REPO_SLUG_RESOLUTION_WAITERS) - - controllers.forEach((controller) => controller.abort()) - await expect(Promise.allSettled(queued)).resolves.toSatisfy((results) => - results.every((result) => result.status === 'rejected') - ) - expect(getRepoSlugResolutionPoolStateForTests()).toEqual({ - active: REPO_SLUG_RESOLUTION_CONCURRENCY, - waiters: 0 - }) - - activeGate.resolve() - await active - expect(getRepoSlugResolutionPoolStateForTests()).toEqual({ active: 0, waiters: 0 }) - }) - - it('releases an aborted waiter so later work can use its queue slot', async () => { - const activeGate = deferred() - const active = occupyAllSlots(activeGate.promise) - await vi.waitFor(() => - expect(getRepoSlugResolutionPoolStateForTests().active).toBe(REPO_SLUG_RESOLUTION_CONCURRENCY) - ) - - const cancelledResolver = vi.fn(async () => 'owner/cancelled') - const controller = new AbortController() - const cancelled = resolveRepoSlugsWithFixedWorkers([0], cancelledResolver, controller.signal) - await vi.waitFor(() => expect(getRepoSlugResolutionPoolStateForTests().waiters).toBe(1)) - controller.abort() - await expect(cancelled).rejects.toThrow('Repo slug index resolution was cancelled.') - expect(cancelledResolver).not.toHaveBeenCalled() - expect(getRepoSlugResolutionPoolStateForTests().waiters).toBe(0) - - const replacementResolver = vi.fn(async () => 'owner/replacement') - const replacement = resolveRepoSlugsWithFixedWorkers([0], replacementResolver) - await vi.waitFor(() => expect(getRepoSlugResolutionPoolStateForTests().waiters).toBe(1)) - activeGate.resolve() - - await expect(replacement).resolves.toEqual(['owner/replacement']) - await active - expect(replacementResolver).toHaveBeenCalledOnce() - expect(getRepoSlugResolutionPoolStateForTests()).toEqual({ active: 0, waiters: 0 }) - }) - - it('removes partially admitted workers when their build hits the waiter cap', async () => { - const activeGate = deferred() - const active = occupyAllSlots(activeGate.promise) - await vi.waitFor(() => - expect(getRepoSlugResolutionPoolStateForTests().active).toBe(REPO_SLUG_RESOLUTION_CONCURRENCY) - ) - const controllers = Array.from( - { length: MAX_REPO_SLUG_RESOLUTION_WAITERS - 1 }, - () => new AbortController() - ) - const queued = controllers.map((controller, index) => - resolveRepoSlugsWithFixedWorkers( - [index], - async () => `owner/queued-${index}`, - controller.signal - ) - ) - await vi.waitFor(() => - expect(getRepoSlugResolutionPoolStateForTests().waiters).toBe( - MAX_REPO_SLUG_RESOLUTION_WAITERS - 1 - ) - ) - - await expect( - resolveRepoSlugsWithFixedWorkers( - Array.from({ length: REPO_SLUG_RESOLUTION_CONCURRENCY }, (_, index) => index), - async (index) => `owner/overflow-${index}` - ) - ).rejects.toThrow('Too many queued repo slug resolutions.') - expect(getRepoSlugResolutionPoolStateForTests().waiters).toBe( - MAX_REPO_SLUG_RESOLUTION_WAITERS - 1 - ) - - controllers.forEach((controller) => controller.abort()) - await Promise.allSettled(queued) - activeGate.resolve() - await active - expect(getRepoSlugResolutionPoolStateForTests()).toEqual({ active: 0, waiters: 0 }) - }) - - it('releases a failed active slot and runs the oldest queued worker', async () => { - const failureGate = deferred() - const activeResolvers = Array.from({ length: REPO_SLUG_RESOLUTION_CONCURRENCY }, (_, index) => - resolveRepoSlugsWithFixedWorkers([index], async () => { - await failureGate.promise - if (index === 0) { - throw new Error('resolution failed') - } - return `owner/active-${index}` - }) - ) - await vi.waitFor(() => - expect(getRepoSlugResolutionPoolStateForTests().active).toBe(REPO_SLUG_RESOLUTION_CONCURRENCY) - ) - const queuedResolver = vi.fn(async () => 'owner/queued') - const queued = resolveRepoSlugsWithFixedWorkers([0], queuedResolver) - await vi.waitFor(() => expect(getRepoSlugResolutionPoolStateForTests().waiters).toBe(1)) - - failureGate.resolve() - const activeResults = await Promise.allSettled(activeResolvers) - await expect(queued).resolves.toEqual(['owner/queued']) - - expect(activeResults.filter((result) => result.status === 'rejected')).toHaveLength(1) - expect(queuedResolver).toHaveBeenCalledOnce() - expect(getRepoSlugResolutionPoolStateForTests()).toEqual({ active: 0, waiters: 0 }) - }) - - it('removes sibling waiters when one worker in their build fails', async () => { - const activeGates = Array.from({ length: REPO_SLUG_RESOLUTION_CONCURRENCY }, deferred) - const active = activeGates.map((gate, index) => - resolveRepoSlugsWithFixedWorkers([index], async () => { - await gate.promise - return `owner/active-${index}` - }) - ) - await vi.waitFor(() => - expect(getRepoSlugResolutionPoolStateForTests().active).toBe(REPO_SLUG_RESOLUTION_CONCURRENCY) - ) - - const queuedResolver = vi.fn(async (index: number) => { - if (index === 0) { - throw new Error('queued resolution failed') - } - return `owner/queued-${index}` - }) - const queued = resolveRepoSlugsWithFixedWorkers( - Array.from({ length: REPO_SLUG_RESOLUTION_CONCURRENCY }, (_, index) => index), - queuedResolver - ) - await vi.waitFor(() => - expect(getRepoSlugResolutionPoolStateForTests().waiters).toBe( - REPO_SLUG_RESOLUTION_CONCURRENCY - ) - ) - - activeGates[0].resolve() - await expect(queued).rejects.toThrow('queued resolution failed') - expect(queuedResolver).toHaveBeenCalledTimes(1) - expect(getRepoSlugResolutionPoolStateForTests()).toEqual({ - active: REPO_SLUG_RESOLUTION_CONCURRENCY - 1, - waiters: 0 - }) - - activeGates.slice(1).forEach((gate) => gate.resolve()) - await Promise.all(active) - expect(getRepoSlugResolutionPoolStateForTests()).toEqual({ active: 0, waiters: 0 }) - }) -}) diff --git a/src/renderer/src/lib/repo-slug-resolution-pool.ts b/src/renderer/src/lib/repo-slug-resolution-pool.ts deleted file mode 100644 index 8e4b891b2ab..00000000000 --- a/src/renderer/src/lib/repo-slug-resolution-pool.ts +++ /dev/null @@ -1,126 +0,0 @@ -export const REPO_SLUG_RESOLUTION_CONCURRENCY = 8 -export const MAX_REPO_SLUG_RESOLUTION_WAITERS = 64 - -let activeResolutions = 0 -const slotWaiters = new Set<() => void>() -const CANCELLED_RESOLUTION = Symbol('cancelled repo slug resolution') -const REJECTED_RESOLUTION = Symbol('rejected repo slug resolution') - -type SlotAdmission = 'acquired' | 'cancelled' | 'rejected' - -function acquireResolutionSlot(signal?: AbortSignal): Promise { - if (signal?.aborted) { - return Promise.resolve('cancelled') - } - if (activeResolutions < REPO_SLUG_RESOLUTION_CONCURRENCY) { - activeResolutions += 1 - return Promise.resolve('acquired') - } - if (slotWaiters.size >= MAX_REPO_SLUG_RESOLUTION_WAITERS) { - return Promise.resolve('rejected') - } - return new Promise((resolve) => { - const resume = (): void => { - signal?.removeEventListener('abort', cancel) - resolve('acquired') - } - const cancel = (): void => { - if (slotWaiters.delete(resume)) { - resolve('cancelled') - } - } - slotWaiters.add(resume) - signal?.addEventListener('abort', cancel, { once: true }) - }) -} - -function releaseResolutionSlot(): void { - activeResolutions -= 1 - const next = slotWaiters.values().next().value - if (next) { - slotWaiters.delete(next) - activeResolutions += 1 - next() - } -} - -async function resolveWithSharedSlot( - resolver: () => Promise, - onFailure: () => void, - signal?: AbortSignal -): Promise { - const admission = await acquireResolutionSlot(signal) - if (admission === 'cancelled') { - return CANCELLED_RESOLUTION - } - if (admission === 'rejected') { - return REJECTED_RESOLUTION - } - if (signal?.aborted) { - releaseResolutionSlot() - return CANCELLED_RESOLUTION - } - try { - return await resolver() - } catch (error) { - onFailure() - throw error - } finally { - releaseResolutionSlot() - } -} - -export async function resolveRepoSlugsWithFixedWorkers( - repos: readonly T[], - resolver: (repo: T) => Promise, - signal?: AbortSignal -): Promise<(string | null)[]> { - const results = Array(repos.length) - let nextIndex = 0 - const buildController = new AbortController() - const cancelBuild = (): void => buildController.abort() - if (signal?.aborted) { - buildController.abort() - } else { - signal?.addEventListener('abort', cancelBuild, { once: true }) - } - const worker = async (): Promise => { - while (nextIndex < repos.length && !buildController.signal.aborted) { - const index = nextIndex - nextIndex += 1 - const slug = await resolveWithSharedSlot( - () => resolver(repos[index]), - () => buildController.abort(), - buildController.signal - ) - if (slug === CANCELLED_RESOLUTION) { - return - } - if (slug === REJECTED_RESOLUTION) { - buildController.abort() - throw new Error('Too many queued repo slug resolutions.') - } - results[index] = slug - } - } - const workerCount = Math.min(REPO_SLUG_RESOLUTION_CONCURRENCY, repos.length) - try { - await Promise.all(Array.from({ length: workerCount }, worker)) - if (signal?.aborted) { - throw new Error('Repo slug index resolution was cancelled.') - } - return results - } catch (error) { - buildController.abort() - throw error - } finally { - signal?.removeEventListener('abort', cancelBuild) - } -} - -export function getRepoSlugResolutionPoolStateForTests(): { - active: number - waiters: number -} { - return { active: activeResolutions, waiters: slotWaiters.size } -} diff --git a/src/renderer/src/lib/transient-agent-status-clear-retention.test.ts b/src/renderer/src/lib/transient-agent-status-clear-retention.test.ts deleted file mode 100644 index d7be65c3b99..00000000000 --- a/src/renderer/src/lib/transient-agent-status-clear-retention.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - retainTransientAgentStatusClearedConnection, - TransientAgentStatusClearRegistry, - TRANSIENT_AGENT_STATUS_CLEAR_MAX_CONNECTIONS, - TRANSIENT_AGENT_STATUS_CLEAR_MAX_ID_UTF8_BYTES -} from './transient-agent-status-clear-retention' - -describe('transient agent status clear retention', () => { - it('keeps the newest watermark for an ordinary connection', () => { - const registry = new TransientAgentStatusClearRegistry() - - expect(registry.remember('ssh-a', 20)).toBe(20) - expect(registry.remember('ssh-a', 10)).toBe(20) - expect(registry.get('ssh-a')).toBe(20) - }) - - it('caps connection keys and fails closed for an evicted watermark', () => { - const registry = new TransientAgentStatusClearRegistry() - for (let index = 0; index <= TRANSIENT_AGENT_STATUS_CLEAR_MAX_CONNECTIONS; index += 1) { - registry.remember(`ssh-${index}`, index + 1) - } - - expect(registry.evidence().connections).toBe(TRANSIENT_AGENT_STATUS_CLEAR_MAX_CONNECTIONS) - expect(registry.get('ssh-0')).toBe(1) - expect(registry.get('never-seen')).toBe(1) - }) - - it('does not retain an oversized id and preserves its cutoff as a fail-closed floor', () => { - const registry = new TransientAgentStatusClearRegistry() - const oversized = 'x'.repeat(TRANSIENT_AGENT_STATUS_CLEAR_MAX_ID_UTF8_BYTES + 1) - - expect(registry.remember(oversized, 50)).toBeNull() - expect(registry.evidence()).toEqual({ - connections: 0, - idBytes: 0, - overflowWatermark: 50 - }) - expect(registry.get(oversized)).toBe(50) - }) - - it('caps the store routing blocks while retaining the newest connection', () => { - let retained: Record = {} - for (let index = 0; index <= TRANSIENT_AGENT_STATUS_CLEAR_MAX_CONNECTIONS; index += 1) { - retained = retainTransientAgentStatusClearedConnection(retained, `ssh-${index}`) - } - - expect(Object.keys(retained)).toHaveLength(TRANSIENT_AGENT_STATUS_CLEAR_MAX_CONNECTIONS) - expect(retained['ssh-0']).toBeUndefined() - expect(retained[`ssh-${TRANSIENT_AGENT_STATUS_CLEAR_MAX_CONNECTIONS}`]).toBe(true) - }) -}) diff --git a/src/renderer/src/lib/transient-agent-status-clear-retention.ts b/src/renderer/src/lib/transient-agent-status-clear-retention.ts deleted file mode 100644 index bb204fe74af..00000000000 --- a/src/renderer/src/lib/transient-agent-status-clear-retention.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { measureUtf8ByteLength } from '../../../shared/utf8-byte-limits' - -export const TRANSIENT_AGENT_STATUS_CLEAR_MAX_CONNECTIONS = 1024 -export const TRANSIENT_AGENT_STATUS_CLEAR_MAX_ID_UTF8_BYTES = 1024 -export const TRANSIENT_AGENT_STATUS_CLEAR_MAX_TOTAL_ID_UTF8_BYTES = 1024 * 1024 - -type WatermarkEntry = { - watermark: number - idBytes: number -} - -export class TransientAgentStatusClearRegistry { - private readonly watermarks = new Map() - private retainedIdBytes = 0 - private overflowWatermark = Number.NEGATIVE_INFINITY - - remember(connectionId: string, clearedAt: number): number | null { - if (!Number.isFinite(clearedAt)) { - return null - } - const measured = measureUtf8ByteLength(connectionId, { - stopAfterBytes: TRANSIENT_AGENT_STATUS_CLEAR_MAX_ID_UTF8_BYTES - }) - if (connectionId.length === 0 || measured.exceededLimit) { - this.overflowWatermark = Math.max(this.overflowWatermark, clearedAt) - return null - } - - const previous = this.watermarks.get(connectionId) - if (previous) { - const watermark = Math.max(previous.watermark, clearedAt, this.overflowWatermark) - this.watermarks.delete(connectionId) - this.watermarks.set(connectionId, { ...previous, watermark }) - return watermark - } - - while ( - this.watermarks.size >= TRANSIENT_AGENT_STATUS_CLEAR_MAX_CONNECTIONS || - this.retainedIdBytes + measured.byteLength > - TRANSIENT_AGENT_STATUS_CLEAR_MAX_TOTAL_ID_UTF8_BYTES - ) { - const oldestId = this.watermarks.keys().next().value - if (oldestId === undefined) { - this.overflowWatermark = Math.max(this.overflowWatermark, clearedAt) - return null - } - const oldest = this.watermarks.get(oldestId) - this.watermarks.delete(oldestId) - this.retainedIdBytes -= oldest?.idBytes ?? 0 - this.overflowWatermark = Math.max( - this.overflowWatermark, - oldest?.watermark ?? Number.NEGATIVE_INFINITY - ) - } - - const watermark = Math.max(clearedAt, this.overflowWatermark) - this.watermarks.set(connectionId, { watermark, idBytes: measured.byteLength }) - this.retainedIdBytes += measured.byteLength - return watermark - } - - get(connectionId: string): number | undefined { - const entry = this.watermarks.get(connectionId) - return entry?.watermark ?? finiteOrUndefined(this.overflowWatermark) - } - - evidence(): { connections: number; idBytes: number; overflowWatermark?: number } { - return { - connections: this.watermarks.size, - idBytes: this.retainedIdBytes, - ...(Number.isFinite(this.overflowWatermark) - ? { overflowWatermark: this.overflowWatermark } - : {}) - } - } -} - -export function retainTransientAgentStatusClearedConnection( - current: Record, - connectionId: string -): Record { - if ( - Object.prototype.hasOwnProperty.call(current, connectionId) || - connectionId.length === 0 || - measureUtf8ByteLength(connectionId, { - stopAfterBytes: TRANSIENT_AGENT_STATUS_CLEAR_MAX_ID_UTF8_BYTES - }).exceededLimit - ) { - return current - } - const next = { ...current } - const keys = Object.keys(next) - const removeCount = Math.max(0, keys.length - TRANSIENT_AGENT_STATUS_CLEAR_MAX_CONNECTIONS + 1) - for (let index = 0; index < removeCount; index += 1) { - delete next[keys[index]] - } - next[connectionId] = true - return next -} - -function finiteOrUndefined(value: number): number | undefined { - return Number.isFinite(value) ? value : undefined -} diff --git a/src/renderer/src/lib/workspace-session-host-persistence.test.ts b/src/renderer/src/lib/workspace-session-host-persistence.test.ts index 49dc74e96a8..d86fc3ba927 100644 --- a/src/renderer/src/lib/workspace-session-host-persistence.test.ts +++ b/src/renderer/src/lib/workspace-session-host-persistence.test.ts @@ -10,52 +10,10 @@ import { patchWorkspaceSessionByHost, persistWorkspaceSessionByHost, persistWorkspaceSessionByHostSync, - WORKSPACE_SESSION_HOST_IO_CONCURRENCY, type HostPersistenceState } from './workspace-session-host-persistence' describe('fetchWorkspaceSessionFromHosts', () => { - it.each([ - ['at the limit', WORKSPACE_SESSION_HOST_IO_CONCURRENCY], - ['above the limit', WORKSPACE_SESSION_HOST_IO_CONCURRENCY + 1] - ])('bounds runtime host partition reads %s', async (_, count) => { - let active = 0 - let peak = 0 - let started = 0 - const releases: (() => void)[] = [] - const get = vi.fn((hostId?: string) => { - if (!hostId) { - return Promise.resolve(getDefaultWorkspaceSession()) - } - started++ - active++ - peak = Math.max(peak, active) - return new Promise((resolve) => { - releases.push(() => { - active-- - resolve(getDefaultWorkspaceSession()) - }) - }) - }) - const hostIds = Array.from( - { length: count }, - (_, index) => `runtime:env-${index}` as `runtime:${string}` - ) - - const read = fetchWorkspaceSessionFromHosts({ get }, [], hostIds) - await vi.waitFor(() => - expect(started).toBe(Math.min(count, WORKSPACE_SESSION_HOST_IO_CONCURRENCY)) - ) - if (count > WORKSPACE_SESSION_HOST_IO_CONCURRENCY) { - releases.shift()?.() - await vi.waitFor(() => expect(started).toBe(count)) - } - releases.splice(0).forEach((release) => release()) - await read - - expect(peak).toBe(Math.min(count, WORKSPACE_SESSION_HOST_IO_CONCURRENCY)) - }) - it('reads saved runtime host partitions before runtime repos are loaded', async () => { const worktreeId = 'remote-repo::/srv/remote-wt' const localSession: WorkspaceSessionState = { diff --git a/src/renderer/src/lib/workspace-session-host-persistence.ts b/src/renderer/src/lib/workspace-session-host-persistence.ts index d0355ef4ebb..35c2dfc3fed 100644 --- a/src/renderer/src/lib/workspace-session-host-persistence.ts +++ b/src/renderer/src/lib/workspace-session-host-persistence.ts @@ -7,11 +7,6 @@ import { } from '../../../shared/execution-host' import { parseWorkspaceKey } from '../../../shared/workspace-scope' import { getRepoIdFromWorktreeId } from '../../../shared/worktree-id' -import { - forEachWithConcurrency, - mapSettledWithConcurrency, - mapWithConcurrency -} from '../../../shared/map-with-concurrency' import { mergeWorkspaceSessionsFromHosts, splitWorkspaceSessionByHost, @@ -52,8 +47,6 @@ export type WorkspaceSessionHostSnapshot = { hostId?: ExecutionHostId } -export const WORKSPACE_SESSION_HOST_IO_CONCURRENCY = 4 - const WORKSPACE_SESSION_KEYED_FIELDS = [ 'tabsByWorktree', 'openFilesByWorktree', @@ -232,16 +225,12 @@ export function patchWorkspaceSessionByHost( ) const local = (slices[LOCAL_EXECUTION_HOST_ID] ?? patch) as WorkspaceSessionPatch const localWrite = api.patch(local) - void forEachWithConcurrency( - nonLocalEntries(slices), - WORKSPACE_SESSION_HOST_IO_CONCURRENCY, - async ([hostId, slice]) => { - // Why: a failed runtime-partition write must not reject the local chain. - await api.patch(slice as WorkspaceSessionPatch, hostId).catch((err) => { - console.warn(`[session] host partition patch failed for ${hostId}:`, err) - }) - } - ) + for (const [hostId, slice] of nonLocalEntries(slices)) { + // Why: a failed runtime-partition write must not reject the local chain. + void api.patch(slice as WorkspaceSessionPatch, hostId).catch((err) => { + console.warn(`[session] host partition patch failed for ${hostId}:`, err) + }) + } return localWrite } @@ -253,19 +242,12 @@ export async function persistWorkspaceSessionByHost( payload: WorkspaceSessionState, state: HostPersistenceState ): Promise { - const snapshots = buildWorkspaceSessionHostSnapshots(payload, state) - const writes = await mapSettledWithConcurrency( - snapshots, - WORKSPACE_SESSION_HOST_IO_CONCURRENCY, - ({ state: snapshot, hostId }) => - hostId === undefined ? api.set(snapshot) : api.set(snapshot, hostId) - ) - const failedWrite = writes.find( - (result): result is PromiseRejectedResult => result.status === 'rejected' - ) - if (failedWrite) { - throw failedWrite.reason + const slices = splitWorkspaceSessionByHost(payload, buildHostIdByWorktreeId(state)) + const writes: Promise[] = [api.set(slices[LOCAL_EXECUTION_HOST_ID] ?? payload)] + for (const [hostId, slice] of nonLocalEntries(slices)) { + writes.push(api.set(slice, hostId)) } + await Promise.all(writes) await api.flush() } @@ -336,16 +318,14 @@ export async function fetchWorkspaceSessionWithRuntimeHostOwners( ...listKnownRuntimeHostIds(repos), ...additionalRuntimeHostIds ]) - await mapWithConcurrency( - [...runtimeHostIds], - WORKSPACE_SESSION_HOST_IO_CONCURRENCY, - async (hostId) => { + await Promise.all( + [...runtimeHostIds].map(async (hostId) => { try { slices[hostId] = await api.get(hostId) } catch (err) { console.warn(`[session] skipping unreadable host partition ${hostId}:`, err) } - } + }) ) return { session: mergeWorkspaceSessionsFromHosts(slices), diff --git a/src/renderer/src/runtime/remote-server-update-batch.ts b/src/renderer/src/runtime/remote-server-update-batch.ts index bb87b811a59..c2fec1977eb 100644 --- a/src/renderer/src/runtime/remote-server-update-batch.ts +++ b/src/renderer/src/runtime/remote-server-update-batch.ts @@ -1,25 +1,21 @@ import type { RemoteServerUpdateEntry } from './remote-server-update-coordinator' -import { forEachWithConcurrency } from '../../../shared/map-with-concurrency' - -export const REMOTE_SERVER_CHECK_CONCURRENCY = 4 export async function runRemoteServerUpdateBatch( entries: readonly RemoteServerUpdateEntry[], maxConcurrent: number, worker: (entry: RemoteServerUpdateEntry) => Promise ): Promise { - await forEachWithConcurrency(entries, maxConcurrent, worker) -} - -export async function runRemoteServerCheckBatch( - entries: readonly T[], - worker: (entry: T) => Promise -): Promise { - await forEachWithConcurrency(entries, REMOTE_SERVER_CHECK_CONCURRENCY, async (entry) => { - try { - await worker(entry) - } catch { - // One unreachable server must not suppress checks for the rest. + const pending = [...entries] + const workers = Array.from( + { length: Math.min(Math.max(1, maxConcurrent), pending.length) }, + async () => { + while (pending.length > 0) { + const entry = pending.shift() + if (entry) { + await worker(entry) + } + } } - }) + ) + await Promise.all(workers) } diff --git a/src/renderer/src/runtime/remote-server-update-coordinator.test.ts b/src/renderer/src/runtime/remote-server-update-coordinator.test.ts index 613bb622169..824fdf6fbdd 100644 --- a/src/renderer/src/runtime/remote-server-update-coordinator.test.ts +++ b/src/renderer/src/runtime/remote-server-update-coordinator.test.ts @@ -11,11 +11,7 @@ import { type RemoteServerUpdateEntry, type RemoteServerUpdateTransport } from './remote-server-update-coordinator' -import { - REMOTE_SERVER_CHECK_CONCURRENCY, - runRemoteServerCheckBatch, - runRemoteServerUpdateBatch -} from './remote-server-update-batch' +import { runRemoteServerUpdateBatch } from './remote-server-update-batch' const environment: PublicKnownRuntimeEnvironment = { id: 'server-1', @@ -319,33 +315,4 @@ describe('remote server update execution', () => { await running expect(peak).toBe(2) }) - - it('bounds checks and continues after one server rejects', async () => { - const count = REMOTE_SERVER_CHECK_CONCURRENCY + 1 - const releases: (() => void)[] = [] - let active = 0 - let peak = 0 - let started = 0 - const checking = runRemoteServerCheckBatch( - Array.from({ length: count }, (_, index) => index), - async (index) => { - started += 1 - active += 1 - peak = Math.max(peak, active) - await new Promise((resolve) => releases.push(resolve)) - active -= 1 - if (index === 0) { - throw new Error('offline') - } - } - ) - - await vi.waitFor(() => expect(started).toBe(REMOTE_SERVER_CHECK_CONCURRENCY)) - releases.shift()?.() - await vi.waitFor(() => expect(started).toBe(count)) - releases.splice(0).forEach((release) => release()) - - await expect(checking).resolves.toBeUndefined() - expect(peak).toBe(REMOTE_SERVER_CHECK_CONCURRENCY) - }) }) diff --git a/src/renderer/src/runtime/runtime-file-client.test.ts b/src/renderer/src/runtime/runtime-file-client.test.ts index 6d885dddd0a..bf8f9203846 100644 --- a/src/renderer/src/runtime/runtime-file-client.test.ts +++ b/src/renderer/src/runtime/runtime-file-client.test.ts @@ -2078,14 +2078,7 @@ describe('runtime file client', () => { runtimeEnvironmentCall.mockResolvedValue({ id: 'rpc-1', ok: true, - result: [ - { - filePath: '/remote/repo/readme.md', - relativePath: 'readme.md', - basename: 'readme.md', - name: 'readme' - } - ], + result: [{ relativePath: 'readme.md' }], _meta: { runtimeId: 'remote-runtime' } }) const context = { diff --git a/src/renderer/src/runtime/runtime-file-client.ts b/src/renderer/src/runtime/runtime-file-client.ts index 2868f77a99f..e1119da7476 100644 --- a/src/renderer/src/runtime/runtime-file-client.ts +++ b/src/renderer/src/runtime/runtime-file-client.ts @@ -37,7 +37,6 @@ import { captureRuntimeEnvironmentRequestRevision, getRuntimeEnvironmentRevision } from './runtime-environment-revision' -import { assertMarkdownDocumentsWithinLimit } from '../../../shared/markdown-document-listing-limits' export type RuntimeReadableFileContent = { content: string @@ -970,20 +969,18 @@ export async function listRuntimeMarkdownDocuments( rootPath: string ): Promise { const target = getActiveRuntimeTarget(context.settings) - const documents = - target.kind !== 'environment' || !context.worktreeId - ? await window.api.fs.listMarkdownDocuments({ - rootPath, - connectionId: context.connectionId - }) - : await callRuntimeRpc( - target, - 'files.listMarkdownDocuments', - { worktree: toRuntimeWorktreeSelector(context.worktreeId) }, - { timeoutMs: 15_000 } - ) - assertMarkdownDocumentsWithinLimit(documents) - return documents + if (target.kind !== 'environment' || !context.worktreeId) { + return window.api.fs.listMarkdownDocuments({ + rootPath, + connectionId: context.connectionId + }) + } + return callRuntimeRpc( + target, + 'files.listMarkdownDocuments', + { worktree: toRuntimeWorktreeSelector(context.worktreeId) }, + { timeoutMs: 15_000 } + ) } export async function statRuntimePath( diff --git a/src/renderer/src/runtime/web-session-snapshot-recovery-pool.test.ts b/src/renderer/src/runtime/web-session-snapshot-recovery-pool.test.ts deleted file mode 100644 index 39e9c3b47b4..00000000000 --- a/src/renderer/src/runtime/web-session-snapshot-recovery-pool.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { - mapWebSessionSnapshotRecoveries, - WEB_SESSION_SNAPSHOT_RECOVERY_CONCURRENCY -} from './web-session-snapshot-recovery-pool' - -function deferred(): { promise: Promise; resolve: () => void } { - let resolve!: () => void - return { - promise: new Promise((nextResolve) => { - resolve = nextResolve - }), - resolve - } -} - -describe('web session snapshot recovery pool', () => { - it.each([ - ['at the limit', WEB_SESSION_SNAPSHOT_RECOVERY_CONCURRENCY], - ['above the limit', WEB_SESSION_SNAPSHOT_RECOVERY_CONCURRENCY + 1] - ])('bounds recovery concurrency %s', async (_label, count) => { - const releases = Array.from({ length: count }, deferred) - let active = 0 - let peak = 0 - let started = 0 - const recover = vi.fn(async (value: number) => { - const release = releases[started] - started += 1 - active += 1 - peak = Math.max(peak, active) - await release.promise - active -= 1 - return value * 2 - }) - - const recovery = mapWebSessionSnapshotRecoveries( - Array.from({ length: count }, (_, index) => index), - recover - ) - await vi.waitFor(() => - expect(started).toBe(Math.min(count, WEB_SESSION_SNAPSHOT_RECOVERY_CONCURRENCY)) - ) - if (count > WEB_SESSION_SNAPSHOT_RECOVERY_CONCURRENCY) { - releases[0].resolve() - await vi.waitFor(() => expect(started).toBe(count)) - } - releases.forEach(({ resolve }) => resolve()) - - await expect(recovery).resolves.toEqual(Array.from({ length: count }, (_, index) => index * 2)) - expect(peak).toBe(Math.min(count, WEB_SESSION_SNAPSHOT_RECOVERY_CONCURRENCY)) - }) -}) diff --git a/src/renderer/src/runtime/web-session-snapshot-recovery-pool.ts b/src/renderer/src/runtime/web-session-snapshot-recovery-pool.ts deleted file mode 100644 index d44c4c2e9c1..00000000000 --- a/src/renderer/src/runtime/web-session-snapshot-recovery-pool.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { mapWithConcurrency } from '../../../shared/map-with-concurrency' - -export const WEB_SESSION_SNAPSHOT_RECOVERY_CONCURRENCY = 4 - -export function mapWebSessionSnapshotRecoveries( - snapshots: readonly T[], - recover: (snapshot: T, index: number) => Promise -): Promise { - return mapWithConcurrency(snapshots, WEB_SESSION_SNAPSHOT_RECOVERY_CONCURRENCY, recover) -} diff --git a/src/renderer/src/runtime/web-session-tabs-sync.ts b/src/renderer/src/runtime/web-session-tabs-sync.ts index ae6bd325c30..66f02af8726 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.ts @@ -83,7 +83,6 @@ import { resolveWebAgentSessionHandoff } from './web-agent-session-handoff' import { getRuntimeEnvironmentRevision } from './runtime-environment-revision' -import { mapWebSessionSnapshotRecoveries } from './web-session-snapshot-recovery-pool' const WEB_SESSION_GROUP_PREFIX = 'web-session-tabs:' @@ -348,7 +347,6 @@ function clearWebSessionTabsTrackingForWorktree(environmentId: string, worktreeI replayableSessionTabsSnapshotByWorktree.delete(key) lastHostTerminalTabCountByWorktree.delete(key) clearWebRuntimeWakeTerminalRespawnForWorktree(worktreeId) - clearWebSessionFocusIntent({ environmentId }, worktreeId) clearWebSessionReorderIntentsForWorktree({ environmentId }, worktreeId) clearWebSessionCloseIntentsForWorktree({ environmentId }, worktreeId) clearWebAgentSessionHandoffsForWorktree(environmentId, worktreeId) @@ -2703,17 +2701,16 @@ export function useWebSessionTabsSync(): void { console.warn('[web-session-tabs-sync] initial listAll returned an invalid payload') return } - const recovered = await mapWebSessionSnapshotRecoveries(result.snapshots, (snapshot) => - recoverWebSessionTerminalOrphansBeforeApply( - useAppStore.getState(), - snapshot, - environmentId + const recovered = await Promise.all( + result.snapshots.map((snapshot) => + recoverWebSessionTerminalOrphansBeforeApply( + useAppStore.getState(), + snapshot, + environmentId + ) ) ) - if ( - disposed || - getRuntimeEnvironmentRevision(environmentId) !== expectedEnvironmentPairingRevision - ) { + if (disposed) { return } const applicable = recovered.filter( @@ -2759,19 +2756,17 @@ export function useWebSessionTabsSync(): void { const event = response.result as SessionTabsStreamEvent const replayed = isRuntimeSubscriptionReplayResponse(response) if (event.type === 'snapshots') { - void mapWebSessionSnapshotRecoveries(event.snapshots, (snapshot) => - recoverWebSessionTerminalOrphansBeforeApply( - useAppStore.getState(), - snapshot, - environmentId + void Promise.all( + event.snapshots.map((snapshot) => + recoverWebSessionTerminalOrphansBeforeApply( + useAppStore.getState(), + snapshot, + environmentId + ) ) ) .then((recovered) => { - if ( - !disposed && - getRuntimeEnvironmentRevision(environmentId) === - expectedEnvironmentPairingRevision - ) { + if (!disposed) { const applicable = recovered.filter( (snapshot): snapshot is RuntimeMobileSessionTabsResult => snapshot !== null ) @@ -2801,12 +2796,7 @@ export function useWebSessionTabsSync(): void { environmentId ) .then((recovered) => { - if ( - !disposed && - recovered && - getRuntimeEnvironmentRevision(environmentId) === - expectedEnvironmentPairingRevision - ) { + if (!disposed && recovered) { if (replayed) { acceptReplayedWebSessionTabsSnapshot(environmentId, recovered.worktree) } @@ -2892,11 +2882,7 @@ export function useWebSessionTabsSync(): void { event, environmentId ) - if ( - disposed || - !recovered || - getRuntimeEnvironmentRevision(environmentId) !== expectedEnvironmentPairingRevision - ) { + if (disposed || !recovered) { return } if (isRuntimeSubscriptionReplayResponse(response)) { diff --git a/src/renderer/src/runtime/web-session-terminal-orphan-recovery-admission.integration.test.ts b/src/renderer/src/runtime/web-session-terminal-orphan-recovery-admission.integration.test.ts deleted file mode 100644 index 840a0c4980b..00000000000 --- a/src/renderer/src/runtime/web-session-terminal-orphan-recovery-admission.integration.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { - clearWebSessionTerminalOrphanRecoveryForTests, - recoverWebSessionTerminalOrphansBeforeApply, - WEB_SESSION_ORPHAN_RECOVERY_MAX_SNAPSHOT_BYTES -} from './web-session-terminal-orphan-recovery' - -describe('web session terminal orphan recovery admission integration', () => { - beforeEach(() => clearWebSessionTerminalOrphanRecoveryForTests()) - - it('rejects an oversized snapshot before RPC recovery work', async () => { - const call = vi.fn() - const snapshot = { - worktree: 'repo::/worktree', - publicationEpoch: 'x'.repeat(WEB_SESSION_ORPHAN_RECOVERY_MAX_SNAPSHOT_BYTES + 1), - snapshotVersion: 1, - activeGroupId: null, - activeTabId: null, - activeTabType: null, - tabs: [] - } - - await expect( - recoverWebSessionTerminalOrphansBeforeApply({} as never, snapshot, 'runtime', call) - ).resolves.toBeNull() - expect(call).not.toHaveBeenCalled() - }) -}) diff --git a/src/renderer/src/runtime/web-session-terminal-orphan-recovery-admission.test.ts b/src/renderer/src/runtime/web-session-terminal-orphan-recovery-admission.test.ts deleted file mode 100644 index e0ed83ed157..00000000000 --- a/src/renderer/src/runtime/web-session-terminal-orphan-recovery-admission.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { WebSessionTerminalOrphanRecoveryAdmission } from './web-session-terminal-orphan-recovery-admission' - -function deferred() { - let resolve!: (value: T) => void - return { - promise: new Promise((nextResolve) => { - resolve = nextResolve - }), - resolve - } -} - -const LIMITS = { - maxActive: 2, - maxOwners: 3, - maxOwnerKeyBytes: 8, - maxTotalKeyBytes: 8, - maxRetainedBytes: 10 -} - -describe('web session terminal orphan recovery admission', () => { - it('bounds global concurrency and releases retained bytes after completion', async () => { - const admission = new WebSessionTerminalOrphanRecoveryAdmission(LIMITS) - const releases = [deferred(), deferred(), deferred()] - const results = releases.map((release, index) => - admission.schedule(String(index), 2, () => release.promise) - ) - - expect(admission.evidence()).toMatchObject({ - active: 2, - owners: 3, - queued: 1, - retainedBytes: 6 - }) - releases[0].resolve(10) - await expect(results[0]).resolves.toBe(10) - expect(admission.evidence()).toMatchObject({ active: 2, owners: 2, queued: 0 }) - releases[1].resolve(11) - releases[2].resolve(12) - await expect(Promise.all(results)).resolves.toEqual([10, 11, 12]) - expect(admission.evidence()).toEqual({ - active: 0, - owners: 0, - queued: 0, - retainedBytes: 0, - retainedKeyBytes: 0 - }) - }) - - it('coalesces a stalled owner to its latest pending snapshot', async () => { - const admission = new WebSessionTerminalOrphanRecoveryAdmission(LIMITS) - const active = deferred() - const first = admission.schedule('owner', 2, () => active.promise) - const superseded = admission.schedule('owner', 3, async () => 2) - const latest = admission.schedule('owner', 4, async () => 3) - - await expect(superseded).resolves.toBeNull() - expect(admission.evidence()).toMatchObject({ active: 1, owners: 1, retainedBytes: 6 }) - active.resolve(1) - await expect(first).resolves.toBe(1) - await expect(latest).resolves.toBe(3) - expect(admission.evidence().retainedBytes).toBe(0) - }) - - it('rejects owner, key, and aggregate payload overflow without running work', async () => { - const admission = new WebSessionTerminalOrphanRecoveryAdmission(LIMITS) - const active = deferred() - const first = admission.schedule('a', 6, () => active.promise) - - await expect(admission.schedule('oversized', 1, async () => 2)).resolves.toBeNull() - await expect(admission.schedule('b', 5, async () => 2)).resolves.toBeNull() - active.resolve(1) - await expect(first).resolves.toBe(1) - }) -}) diff --git a/src/renderer/src/runtime/web-session-terminal-orphan-recovery-admission.ts b/src/renderer/src/runtime/web-session-terminal-orphan-recovery-admission.ts deleted file mode 100644 index 8820d515781..00000000000 --- a/src/renderer/src/runtime/web-session-terminal-orphan-recovery-admission.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { measureUtf8ByteLength } from '../../../shared/utf8-byte-limits' - -export const WEB_SESSION_ORPHAN_RECOVERY_MAX_ACTIVE = 4 -export const WEB_SESSION_ORPHAN_RECOVERY_MAX_OWNERS = 64 -export const WEB_SESSION_ORPHAN_RECOVERY_MAX_OWNER_KEY_BYTES = 64 * 1024 -export const WEB_SESSION_ORPHAN_RECOVERY_MAX_TOTAL_KEY_BYTES = 1024 * 1024 -export const WEB_SESSION_ORPHAN_RECOVERY_MAX_RETAINED_BYTES = 32 * 1024 * 1024 - -type RecoveryAdmissionLimits = { - maxActive: number - maxOwners: number - maxOwnerKeyBytes: number - maxTotalKeyBytes: number - maxRetainedBytes: number -} - -type RecoveryJob = { - retainedBytes: number - run: () => Promise - resolve: (value: T | null) => void -} - -type RecoveryOwner = { - activeBytes: number - keyBytes: number - pending: RecoveryJob | null - queued: boolean - running: boolean -} - -const DEFAULT_LIMITS: RecoveryAdmissionLimits = { - maxActive: WEB_SESSION_ORPHAN_RECOVERY_MAX_ACTIVE, - maxOwners: WEB_SESSION_ORPHAN_RECOVERY_MAX_OWNERS, - maxOwnerKeyBytes: WEB_SESSION_ORPHAN_RECOVERY_MAX_OWNER_KEY_BYTES, - maxTotalKeyBytes: WEB_SESSION_ORPHAN_RECOVERY_MAX_TOTAL_KEY_BYTES, - maxRetainedBytes: WEB_SESSION_ORPHAN_RECOVERY_MAX_RETAINED_BYTES -} - -export class WebSessionTerminalOrphanRecoveryAdmission { - private readonly owners = new Map>() - private readonly readyOwnerKeys: string[] = [] - private active = 0 - private retainedBytes = 0 - private retainedKeyBytes = 0 - - constructor(private readonly limits: RecoveryAdmissionLimits = DEFAULT_LIMITS) { - for (const value of Object.values(limits)) { - if (!Number.isSafeInteger(value) || value < 1) { - throw new RangeError('Orphan recovery admission limits must be positive integers') - } - } - } - - schedule(key: string, retainedBytes: number, run: () => Promise): Promise { - if (!Number.isSafeInteger(retainedBytes) || retainedBytes < 0) { - return Promise.resolve(null) - } - const keyMeasurement = measureUtf8ByteLength(key, { - stopAfterBytes: this.limits.maxOwnerKeyBytes - }) - if (key.length === 0 || keyMeasurement.exceededLimit) { - return Promise.resolve(null) - } - - const existing = this.owners.get(key) - if (existing) { - const replacedBytes = existing.pending?.retainedBytes ?? 0 - if (retainedBytes > this.limits.maxRetainedBytes - (this.retainedBytes - replacedBytes)) { - return Promise.resolve(null) - } - return new Promise((resolve) => { - if (existing.pending) { - this.retainedBytes -= existing.pending.retainedBytes - existing.pending.resolve(null) - } - existing.pending = { retainedBytes, run, resolve } - this.retainedBytes += retainedBytes - this.enqueue(key, existing) - }) - } - - if ( - this.owners.size >= this.limits.maxOwners || - keyMeasurement.byteLength > this.limits.maxTotalKeyBytes - this.retainedKeyBytes || - retainedBytes > this.limits.maxRetainedBytes - this.retainedBytes - ) { - return Promise.resolve(null) - } - - return new Promise((resolve) => { - const owner: RecoveryOwner = { - activeBytes: 0, - keyBytes: keyMeasurement.byteLength, - pending: { retainedBytes, run, resolve }, - queued: false, - running: false - } - this.owners.set(key, owner) - this.retainedBytes += retainedBytes - this.retainedKeyBytes += keyMeasurement.byteLength - this.enqueue(key, owner) - }) - } - - evidence(): { - active: number - owners: number - queued: number - retainedBytes: number - retainedKeyBytes: number - } { - return { - active: this.active, - owners: this.owners.size, - queued: this.readyOwnerKeys.length, - retainedBytes: this.retainedBytes, - retainedKeyBytes: this.retainedKeyBytes - } - } - - reset(): void { - this.readyOwnerKeys.length = 0 - for (const [key, owner] of this.owners) { - owner.queued = false - if (owner.pending) { - this.retainedBytes -= owner.pending.retainedBytes - owner.pending.resolve(null) - owner.pending = null - } - if (!owner.running) { - this.owners.delete(key) - this.retainedKeyBytes -= owner.keyBytes - } - } - } - - private enqueue(key: string, owner: RecoveryOwner): void { - if (!owner.running && !owner.queued && owner.pending) { - owner.queued = true - this.readyOwnerKeys.push(key) - } - this.drain() - } - - private drain(): void { - while (this.active < this.limits.maxActive) { - const key = this.readyOwnerKeys.shift() - if (key === undefined) { - return - } - const owner = this.owners.get(key) - if (!owner || !owner.queued || owner.running || !owner.pending) { - continue - } - owner.queued = false - void this.run(key, owner) - } - } - - private async run(key: string, owner: RecoveryOwner): Promise { - const job = owner.pending - if (!job) { - return - } - owner.pending = null - owner.running = true - owner.activeBytes = job.retainedBytes - this.active += 1 - try { - job.resolve(await job.run()) - } catch { - job.resolve(null) - } finally { - this.active -= 1 - this.retainedBytes -= owner.activeBytes - owner.activeBytes = 0 - owner.running = false - if (owner.pending) { - this.enqueue(key, owner) - } else if (this.owners.get(key) === owner) { - this.owners.delete(key) - this.retainedKeyBytes -= owner.keyBytes - } - this.drain() - } - } -} diff --git a/src/renderer/src/runtime/web-session-terminal-orphan-recovery.ts b/src/renderer/src/runtime/web-session-terminal-orphan-recovery.ts index 2bf58527750..9f44597561d 100644 --- a/src/renderer/src/runtime/web-session-terminal-orphan-recovery.ts +++ b/src/renderer/src/runtime/web-session-terminal-orphan-recovery.ts @@ -12,8 +12,6 @@ import { buildWebTerminalOrphanTopologyProposal, type WebTerminalOrphanTopologyState } from './web-session-terminal-orphan-topology' -import { measureMetadataValueBytes } from '../hooks/metadata-retention-measurement' -import { WebSessionTerminalOrphanRecoveryAdmission } from './web-session-terminal-orphan-recovery-admission' type TerminalOrphanRecoveryState = WebTerminalOrphanTopologyState & { tabsByWorktree: Record @@ -26,9 +24,7 @@ type RuntimeCall = (args: { timeoutMs: number }) => Promise> -export const WEB_SESSION_ORPHAN_RECOVERY_MAX_SNAPSHOT_BYTES = 4 * 1024 * 1024 -const recoveryAdmission = - new WebSessionTerminalOrphanRecoveryAdmission() +const inFlightRecoveryByWorktree = new Map>() function recoveryKey(environmentId: string, worktreeId: string): string { return `${environmentId}\0${worktreeId}` @@ -188,18 +184,20 @@ export function recoverWebSessionTerminalOrphansBeforeApply( call: RuntimeCall = (args) => window.api.runtimeEnvironments.call(args) ): Promise { const key = recoveryKey(environmentId, snapshot.worktree) - const retainedBytes = measureMetadataValueBytes( - snapshot, - WEB_SESSION_ORPHAN_RECOVERY_MAX_SNAPSHOT_BYTES - ) - if (retainedBytes === null) { - return Promise.resolve(null) - } - return recoveryAdmission.schedule(key, retainedBytes, () => - recoverTerminalOrphans(state, snapshot, environmentId, call) - ) + const existing = inFlightRecoveryByWorktree.get(key) + const recovery = (existing ?? Promise.resolve(null)) + .catch(() => null) + .then(() => recoverTerminalOrphans(state, snapshot, environmentId, call)) + .catch(() => null) + .finally(() => { + if (inFlightRecoveryByWorktree.get(key) === recovery) { + inFlightRecoveryByWorktree.delete(key) + } + }) + inFlightRecoveryByWorktree.set(key, recovery) + return recovery } export function clearWebSessionTerminalOrphanRecoveryForTests(): void { - recoveryAdmission.reset() + inFlightRecoveryByWorktree.clear() } diff --git a/src/renderer/src/startup/ssh-startup-reconnect-fanout.test.ts b/src/renderer/src/startup/ssh-startup-reconnect-fanout.test.ts deleted file mode 100644 index 2e4bab2a16c..00000000000 --- a/src/renderer/src/startup/ssh-startup-reconnect-fanout.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import type { SshConnectionState } from '../../../shared/ssh-types' -import { reconnectSshTargetsForRendererStartup } from './ssh-startup-reconnect' - -function connectedState(targetId: string): SshConnectionState { - return { - targetId, - status: 'connected', - error: null, - reconnectAttempt: 0, - remotePlatform: 'linux' - } -} - -afterEach(() => { - vi.useRealTimers() -}) - -describe('SSH startup reconnect fanout', () => { - it('bounds a large target list and publishes every successful result', async () => { - const targetIds = Array.from({ length: 100 }, (_, index) => `ssh-${index}`) - let inFlight = 0 - let peak = 0 - const connect = vi.fn(async (targetId: string) => { - inFlight += 1 - peak = Math.max(peak, inFlight) - await Promise.resolve() - inFlight -= 1 - return connectedState(targetId) - }) - const publishState = vi.fn() - - const timedOut = await reconnectSshTargetsForRendererStartup({ - targetIds, - timeoutMs: 1_000, - connect, - publishState, - onFailure: vi.fn() - }) - - expect(peak).toBe(4) - expect(timedOut).toEqual([]) - expect(connect).toHaveBeenCalledTimes(targetIds.length) - expect(publishState).toHaveBeenCalledTimes(targetIds.length) - }) - - it('defers queued targets without extending the shared startup deadline', async () => { - vi.useFakeTimers() - const targetIds = Array.from({ length: 10 }, (_, index) => `ssh-${index}`) - const connect = vi.fn(() => new Promise(() => {})) - const result = reconnectSshTargetsForRendererStartup({ - targetIds, - timeoutMs: 1_000, - connect, - publishState: vi.fn(), - onFailure: vi.fn() - }) - - await vi.advanceTimersByTimeAsync(1_000) - - await expect(result).resolves.toEqual(targetIds) - expect(connect).toHaveBeenCalledTimes(4) - }) -}) diff --git a/src/renderer/src/startup/ssh-startup-reconnect.ts b/src/renderer/src/startup/ssh-startup-reconnect.ts index 3a0c913a9f7..d312239a878 100644 --- a/src/renderer/src/startup/ssh-startup-reconnect.ts +++ b/src/renderer/src/startup/ssh-startup-reconnect.ts @@ -1,7 +1,4 @@ import type { SshConnectionState } from '../../../shared/ssh-types' -import { mapWithConcurrency } from '../../../shared/map-with-concurrency' - -const SSH_STARTUP_RECONNECT_CONCURRENCY = 4 export type SshStartupReconnectResult = { timedOut: boolean @@ -38,35 +35,3 @@ export async function reconnectSshTargetForRendererStartup(args: { } } } - -export async function reconnectSshTargetsForRendererStartup(args: { - targetIds: readonly string[] - timeoutMs: number - connect: (targetId: string) => Promise - publishState: (targetId: string, state: SshConnectionState) => void - onFailure: (targetId: string, error: unknown) => void -}): Promise { - const { targetIds, timeoutMs, connect, publishState, onFailure } = args - // Why: batching must not multiply the existing startup wait ceiling. - const deadline = Date.now() + timeoutMs - const results = await mapWithConcurrency( - targetIds, - SSH_STARTUP_RECONNECT_CONCURRENCY, - async (targetId) => { - const remainingMs = deadline - Date.now() - if (remainingMs <= 0) { - onFailure(targetId, new Error('SSH reconnect timeout')) - return targetId - } - const result = await reconnectSshTargetForRendererStartup({ - targetId, - timeoutMs: remainingMs, - connect, - publishState, - onFailure - }) - return result.timedOut ? targetId : null - } - ) - return results.filter((targetId): targetId is string => targetId !== null) -} diff --git a/src/renderer/src/store/slices/agent-status.ts b/src/renderer/src/store/slices/agent-status.ts index 44ff696a26a..36fd63744cd 100644 --- a/src/renderer/src/store/slices/agent-status.ts +++ b/src/renderer/src/store/slices/agent-status.ts @@ -45,7 +45,6 @@ import { transferAgentPaneAuthorityAlias } from './agent-pane-authority' import { createFreshnessScheduler } from './agent-status-freshness-scheduler' -import { retainTransientAgentStatusClearedConnection } from '@/lib/transient-agent-status-clear-retention' /** Snapshot of a finished/vanished agent status entry, kept so the dashboard and sidebar hover * keep showing the completion until the user clicks the worktree. `worktreeId` is stamped at @@ -2236,11 +2235,8 @@ export const createAgentStatusSlice: StateCreator { - it('rejects the 257th waiter, drains FIFO across compaction, and recovers', async () => { - const slots = new GitHubWorkItemRequestSlots() - await Promise.all( - Array.from({ length: GITHUB_WORK_ITEM_FETCH_CONCURRENCY }, () => slots.acquire()) - ) - const started: number[] = [] - const waiters = Array.from({ length: GITHUB_WORK_ITEM_FETCH_MAX_WAITERS }, (_, index) => - slots.acquire().then(() => { - started.push(index) - slots.release() - }) - ) - - await expect(slots.acquire()).rejects.toThrow('GitHub work-item request queue is full') - for (let index = 0; index < GITHUB_WORK_ITEM_FETCH_CONCURRENCY; index += 1) { - slots.release() - } - await Promise.all(waiters) - - expect(started).toEqual( - Array.from({ length: GITHUB_WORK_ITEM_FETCH_MAX_WAITERS }, (_, index) => index) - ) - await expect(slots.acquire()).resolves.toBeUndefined() - slots.release() - }) - - it('processes 10,000 repositories with eight workers and preserves result order', async () => { - const slots = new GitHubWorkItemRequestSlots() - let active = 0 - let peak = 0 - const repoIndexes = Array.from({ length: 10_000 }, (_, index) => index) - - const results = await mapWithConcurrency( - repoIndexes, - GITHUB_WORK_ITEM_FETCH_CONCURRENCY, - async (index) => { - await slots.acquire() - try { - active += 1 - peak = Math.max(peak, active) - await Promise.resolve() - return index - } finally { - active -= 1 - slots.release() - } - } - ) - - expect(peak).toBe(GITHUB_WORK_ITEM_FETCH_CONCURRENCY) - expect(results).toEqual(repoIndexes) - }) -}) diff --git a/src/renderer/src/store/slices/github-work-item-request-slots.ts b/src/renderer/src/store/slices/github-work-item-request-slots.ts deleted file mode 100644 index 5e70f597c49..00000000000 --- a/src/renderer/src/store/slices/github-work-item-request-slots.ts +++ /dev/null @@ -1,42 +0,0 @@ -export const GITHUB_WORK_ITEM_FETCH_CONCURRENCY = 8 -export const GITHUB_WORK_ITEM_FETCH_MAX_WAITERS = 256 -const GITHUB_WORK_ITEM_FETCH_WAITER_COMPACTION_HEAD = 64 - -export class GitHubWorkItemRequestSlots { - private active = 0 - private readonly waiters: ((() => void) | undefined)[] = [] - private waiterHead = 0 - - constructor( - private readonly concurrency = GITHUB_WORK_ITEM_FETCH_CONCURRENCY, - private readonly maxWaiters = GITHUB_WORK_ITEM_FETCH_MAX_WAITERS - ) {} - - async acquire(): Promise { - if (this.active < this.concurrency) { - this.active += 1 - return - } - if (this.waiters.length - this.waiterHead >= this.maxWaiters) { - throw new Error('GitHub work-item request queue is full; retry after active requests finish.') - } - await new Promise((resolve) => this.waiters.push(resolve)) - } - - release(): void { - const next = this.waiters[this.waiterHead] - if (next) { - this.waiters[this.waiterHead] = undefined - this.waiterHead += 1 - if (this.waiterHead >= GITHUB_WORK_ITEM_FETCH_WAITER_COMPACTION_HEAD) { - this.waiters.splice(0, this.waiterHead) - this.waiterHead = 0 - } - next() - return - } - this.waiters.length = 0 - this.waiterHead = 0 - this.active = Math.max(0, this.active - 1) - } -} diff --git a/src/renderer/src/store/slices/github.ts b/src/renderer/src/store/slices/github.ts index 1b672bb38fd..4f197565874 100644 --- a/src/renderer/src/store/slices/github.ts +++ b/src/renderer/src/store/slices/github.ts @@ -4,12 +4,6 @@ import { toast } from 'sonner' import type { AppState } from '../types' import { githubRepoIdentityKey } from '../../../../shared/github-repository-identity-key' import { githubProjectIdentityKey } from '../../../../shared/github-project-identity' -import { mapWithConcurrency } from '../../../../shared/map-with-concurrency' -import { PR_REFRESH_VISIBLE_CANDIDATE_LIMIT } from '../../../../shared/pr-refresh-memory-limits' -import { - GITHUB_WORK_ITEM_FETCH_CONCURRENCY, - GitHubWorkItemRequestSlots -} from './github-work-item-request-slots' import type { ClassifiedError, GitHubOwnerRepo, @@ -704,15 +698,28 @@ export function _clearGitHubPRRefreshStartedEntriesForTest(): void { prRefreshStartedHostedReviewEntries.clear() } -// Why: the main-side gate is behind IPC, so renderer fan-out must be bounded before promises retain every request. -const workItemRequestSlots = new GitHubWorkItemRequestSlots() +// Why: cap fan-out at the renderer boundary (main-side gate is behind IPC, can't stop a stampede in time); 8 balances responsiveness vs gh rate limits. +const WORK_ITEM_FETCH_CONCURRENCY = 8 +let workItemFetchInFlight = 0 +const workItemFetchWaiters: (() => void)[] = [] -function acquireWorkItemSlot(): Promise { - return workItemRequestSlots.acquire() +async function acquireWorkItemSlot(): Promise { + if (workItemFetchInFlight < WORK_ITEM_FETCH_CONCURRENCY) { + workItemFetchInFlight += 1 + return + } + await new Promise((resolve) => workItemFetchWaiters.push(resolve)) + // Why: the resolver already claimed the slot on our behalf, so don't re-increment here. } function releaseWorkItemSlot(): void { - workItemRequestSlots.release() + const next = workItemFetchWaiters.shift() + if (next) { + // Hand the slot off directly (net count unchanged) so a third caller can't race into the cap between decrement and resolve. + next() + return + } + workItemFetchInFlight -= 1 } export function workItemsCacheKey( @@ -2653,10 +2660,8 @@ export const createGitHubSlice: StateCreator = (s } const request = (async () => { - let acquiredSlot = false + await acquireWorkItemSlot() try { - await acquireWorkItemSlot() - acquiredSlot = true const envelope = await listGitHubWorkItemsForRepo(requestContext, { limit, query: query || undefined, @@ -2708,9 +2713,7 @@ export const createGitHubSlice: StateCreator = (s } throw err } finally { - if (acquiredSlot) { - releaseWorkItemSlot() - } + releaseWorkItemSlot() inflightWorkItemsRequests.delete(inflightKey) } })() @@ -2732,10 +2735,8 @@ export const createGitHubSlice: StateCreator = (s let requestFailureCount = 0 let unavailableFailureCount = 0 let skippedSourceCount = 0 - const perProjectResults = await mapWithConcurrency( - repos, - GITHUB_WORK_ITEM_FETCH_CONCURRENCY, - async (r) => { + const perProjectResults = await Promise.all( + repos.map(async (r) => { try { return await state.fetchWorkItems(r.repoId, r.path, perRepoLimit, query, { ...options, @@ -2770,7 +2771,7 @@ export const createGitHubSlice: StateCreator = (s failedCount += 1 return [] as GitHubWorkItem[] } - } + }) ) const merged = sortWorkItemsByNumber(perProjectResults.flat()).slice(0, displayLimit) // Why: only claim global unavailability when every eligible source failed for a reachability reason; skipped SSH repos aren't GitHub sources here. @@ -2786,28 +2787,24 @@ export const createGitHubSlice: StateCreator = (s return { items: [], failedCount: 0 } } let failedCount = 0 - const perProjectResults = await mapWithConcurrency( - repos, - GITHUB_WORK_ITEM_FETCH_CONCURRENCY, - async (r) => { - let acquiredSlot = false + const perProjectResults = await Promise.all( + repos.map(async (r) => { + const requestState = get() + const repo = findRepoForGitHubOwner(requestState, r.repoId, r.path) + const requestSettings = getGitHubWorkItemSourceSettings( + requestState.settings, + repo, + r.sourceContext + ) + const requestContext = getGitHubWorkItemRequestContext( + requestState, + requestSettings, + r.repoId, + r.path, + r.sourceContext + ) + await acquireWorkItemSlot() try { - await acquireWorkItemSlot() - acquiredSlot = true - const requestState = get() - const repo = findRepoForGitHubOwner(requestState, r.repoId, r.path) - const requestSettings = getGitHubWorkItemSourceSettings( - requestState.settings, - repo, - r.sourceContext - ) - const requestContext = getGitHubWorkItemRequestContext( - requestState, - requestSettings, - r.repoId, - r.path, - r.sourceContext - ) const envelope = await listGitHubWorkItemsForRepo(requestContext, { limit: perRepoLimit, query: query || undefined, @@ -2829,11 +2826,9 @@ export const createGitHubSlice: StateCreator = (s failedCount += 1 return [] as GitHubWorkItem[] } finally { - if (acquiredSlot) { - releaseWorkItemSlot() - } + releaseWorkItemSlot() } - } + }) ) const merged = sortWorkItemsByNumber(perProjectResults.flat()).slice(0, displayLimit) return { items: merged, failedCount } @@ -2844,15 +2839,11 @@ export const createGitHubSlice: StateCreator = (s return { totalCount: 0, totalPages: 0 } } const normalizedLimit = Math.max(1, Math.floor(perRepoLimit)) - const counts = await mapWithConcurrency( - repos, - GITHUB_WORK_ITEM_FETCH_CONCURRENCY, - async (r) => { + const counts = await Promise.all( + repos.map(async (r) => { // Why: same stampede cap as item-fetch — without a slot a 90-repo selection fires 90 concurrent count IPCs before the main-side rate-limit guard sees the first 403. - let acquiredSlot = false + await acquireWorkItemSlot() try { - await acquireWorkItemSlot() - acquiredSlot = true const requestState = get() const repo = findRepoForGitHubOwner(requestState, r.repoId, r.path) const requestSettings = getGitHubWorkItemSourceSettings( @@ -2871,11 +2862,9 @@ export const createGitHubSlice: StateCreator = (s } catch { return 0 } finally { - if (acquiredSlot) { - releaseWorkItemSlot() - } + releaseWorkItemSlot() } - } + }) ) return { totalCount: counts.reduce((sum, count) => sum + count, 0), @@ -3916,18 +3905,14 @@ export const createGitHubSlice: StateCreator = (s reportVisibleGitHubPRRefreshCandidates: (worktreeIds, generation) => { const state = get() + const candidates = worktreeIds + .map((id) => { + const worktree = findWorktreeById(state, id) + return worktree ? buildPRRefreshCandidate(state, worktree) : null + }) + .filter((candidate): candidate is GitHubPRRefreshCandidate => candidate !== null) const localCandidates: GitHubPRRefreshCandidate[] = [] - let candidateCount = 0 - for (const id of worktreeIds) { - const worktree = findWorktreeById(state, id) - if (!worktree) { - continue - } - const candidate = buildPRRefreshCandidate(state, worktree) - if (!candidate) { - continue - } - candidateCount += 1 + for (const candidate of candidates) { if (getPRRefreshRuntimeRepoTarget(state, candidate)) { void get().fetchPRForBranch(candidate.repoPath, candidate.branch, { repoId: candidate.repoId, @@ -3936,11 +3921,10 @@ export const createGitHubSlice: StateCreator = (s fallbackPRNumber: candidate.fallbackPRNumber ?? null, fallbackPRSource: candidate.fallbackPRSource ?? null }) - } else if (shouldEnqueueLocalPRRefresh(candidate)) { - localCandidates.push(candidate) + continue } - if (candidateCount >= PR_REFRESH_VISIBLE_CANDIDATE_LIMIT) { - break + if (shouldEnqueueLocalPRRefresh(candidate)) { + localCandidates.push(candidate) } } const reportVisible = window.api.gh.reportVisiblePRRefreshCandidates diff --git a/src/renderer/src/store/slices/remote-server-updates.ts b/src/renderer/src/store/slices/remote-server-updates.ts index 47a228182f2..909c05e3190 100644 --- a/src/renderer/src/store/slices/remote-server-updates.ts +++ b/src/renderer/src/store/slices/remote-server-updates.ts @@ -13,10 +13,7 @@ import { type RemoteServerUpdateEntry, type RemoteServerUpdateTransport } from '@/runtime/remote-server-update-coordinator' -import { - runRemoteServerCheckBatch, - runRemoteServerUpdateBatch -} from '@/runtime/remote-server-update-batch' +import { runRemoteServerUpdateBatch } from '@/runtime/remote-server-update-batch' const MAX_CONCURRENT_REMOTE_SERVER_UPDATES = 2 @@ -109,19 +106,21 @@ export const createRemoteServerUpdatesSlice: StateCreator< ) set({ remoteServerUpdates: initial }) const clientVersion = await window.api.updater.getVersion() - await runRemoteServerCheckBatch(environments, async (environment) => { - const entry = await inspectRemoteServerUpdate( - environment, - clientVersion, - transport, - checkOptions - ) - set((state) => { - const next = new Map(state.remoteServerUpdates) - next.set(environment.id, entry) - return { remoteServerUpdates: next } + await Promise.allSettled( + environments.map(async (environment) => { + const entry = await inspectRemoteServerUpdate( + environment, + clientVersion, + transport, + checkOptions + ) + set((state) => { + const next = new Map(state.remoteServerUpdates) + next.set(environment.id, entry) + return { remoteServerUpdates: next } + }) }) - }) + ) set({ remoteServerUpdatesLastCheckedAt: Date.now() }) } finally { set({ remoteServerUpdatesChecking: false }) diff --git a/src/renderer/src/store/slices/remote-workspace-sync-status-admission.test.ts b/src/renderer/src/store/slices/remote-workspace-sync-status-admission.test.ts deleted file mode 100644 index 9ca725be75d..00000000000 --- a/src/renderer/src/store/slices/remote-workspace-sync-status-admission.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { getUtf8ByteLength } from '../../../../shared/utf8-byte-limits' -import { - admitRemoteWorkspaceSyncStatus, - REMOTE_WORKSPACE_SYNC_MESSAGE_MAX_UTF8_BYTES -} from './remote-workspace-sync-status-admission' - -describe('remote workspace sync status admission', () => { - it('preserves ordinary status objects by reference', () => { - const status = { phase: 'error' as const, message: 'Workspace sync failed' } - - expect(admitRemoteWorkspaceSyncStatus(status)).toBe(status) - }) - - it('caps a retained error message without splitting a UTF-8 code point', () => { - const status = admitRemoteWorkspaceSyncStatus({ - phase: 'error', - message: `${'x'.repeat(REMOTE_WORKSPACE_SYNC_MESSAGE_MAX_UTF8_BYTES - 1)}🙂tail` - }) - - expect(getUtf8ByteLength(status.message ?? '')).toBeLessThanOrEqual( - REMOTE_WORKSPACE_SYNC_MESSAGE_MAX_UTF8_BYTES - ) - expect(status.message?.endsWith('\ud83d')).toBe(false) - }) -}) diff --git a/src/renderer/src/store/slices/remote-workspace-sync-status-admission.ts b/src/renderer/src/store/slices/remote-workspace-sync-status-admission.ts deleted file mode 100644 index 73958c40146..00000000000 --- a/src/renderer/src/store/slices/remote-workspace-sync-status-admission.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { clampUtf8TextPrefix, measureUtf8ByteLength } from '../../../../shared/utf8-byte-limits' -import type { RemoteWorkspaceSyncStatus } from './ssh' - -export const REMOTE_WORKSPACE_SYNC_MESSAGE_MAX_UTF8_BYTES = 16 * 1024 - -export function admitRemoteWorkspaceSyncStatus( - status: RemoteWorkspaceSyncStatus -): RemoteWorkspaceSyncStatus { - if (typeof status.message !== 'string') { - return status - } - const measured = measureUtf8ByteLength(status.message, { - stopAfterBytes: REMOTE_WORKSPACE_SYNC_MESSAGE_MAX_UTF8_BYTES - }) - if (!measured.exceededLimit) { - return status - } - return { - ...status, - message: clampUtf8TextPrefix(status.message, REMOTE_WORKSPACE_SYNC_MESSAGE_MAX_UTF8_BYTES) - } -} diff --git a/src/renderer/src/store/slices/repos.ts b/src/renderer/src/store/slices/repos.ts index b828c45f420..4d4a2b65e61 100644 --- a/src/renderer/src/store/slices/repos.ts +++ b/src/renderer/src/store/slices/repos.ts @@ -45,7 +45,6 @@ import { applyManualRepoOrder, getManualRepoOrder } from '../../../../shared/man import { getProjectGroupSubtreeIds } from '../../../../shared/project-groups' import { isPathInsideOrEqual } from '../../../../shared/cross-platform-path' import { getRepoIdFromWorktreeId } from '../../../../shared/worktree-id' -import { forEachWithConcurrency } from '../../../../shared/map-with-concurrency' import { selectProjectGroupRemovalTargets } from './project-group-removal-targets' import { reconcileFetchedRepos } from './repo-identity-reconcile' import { @@ -89,13 +88,11 @@ import { folderWorkspaceKey, parseWorkspaceKey } from '../../../../shared/worksp import { formatFolderWorkspaceCreateError } from '../../lib/folder-workspace-path-status' import { getEnvironmentSshStateGeneration } from './runtime-environment-ssh' import { getRuntimeEnvironmentConnectionGeneration } from './runtime-status' -import { SafeAutoForkSyncAttempts } from './safe-auto-fork-sync-attempts' -import { RuntimeRepoFetchTracker } from './runtime-repo-fetch-tracker' const ERROR_TOAST_DURATION = 60_000 -const RUNTIME_CATALOG_FETCH_CONCURRENCY = 4 -const safeAutoForkSyncAttempts = new SafeAutoForkSyncAttempts() -const runtimeRepoFetches = new RuntimeRepoFetchTracker() +const SAFE_AUTO_FORK_SYNC_COOLDOWN_MS = 10 * 60 * 1000 +const safeAutoForkSyncAttempts = new Map }>() +const runtimeRepoFetchGenerationByEnvironment = new Map() export type RepoUpdate = Partial< Pick< @@ -321,28 +318,39 @@ async function warnIfProjectKnownInAnotherProfile( function scheduleSafeAutoForkSync(get: () => AppState, repos: readonly Repo[]): void { for (const repo of repos) { - const upstream = repo.upstream - if (repo.kind === 'folder' || repo.forkSyncMode !== 'safe-auto' || !upstream) { + if (repo.kind === 'folder' || repo.forkSyncMode !== 'safe-auto' || !repo.upstream) { continue } const key = getSafeAutoForkSyncKey(repo) + const existingAttempt = safeAutoForkSyncAttempts.get(key) const now = Date.now() - safeAutoForkSyncAttempts.run(key, now, () => - syncRuntimeGitForkDefaultBranch( - { - settings: settingsForRepoOwner(get(), repo.id), - worktreeId: repo.id, - worktreePath: repo.path, - connectionId: repo.connectionId ?? undefined - }, - upstream - ) - .then(() => undefined) - .catch((error) => { - // Why: safe-auto is opportunistic; auth/protection/divergence failures shouldn't add startup noise (Sync Now handles explicit diagnosis). - console.info('Safe fork auto-sync skipped', error) - }) + if ( + existingAttempt?.promise || + (existingAttempt && now - existingAttempt.attemptedAt < SAFE_AUTO_FORK_SYNC_COOLDOWN_MS) + ) { + continue + } + const promise = syncRuntimeGitForkDefaultBranch( + { + settings: settingsForRepoOwner(get(), repo.id), + worktreeId: repo.id, + worktreePath: repo.path, + connectionId: repo.connectionId ?? undefined + }, + repo.upstream ) + .then(() => undefined) + .catch((error) => { + // Why: safe-auto is opportunistic; auth/protection/divergence failures shouldn't add startup noise (Sync Now handles explicit diagnosis). + console.info('Safe fork auto-sync skipped', error) + }) + .finally(() => { + const current = safeAutoForkSyncAttempts.get(key) + if (current?.promise === promise) { + safeAutoForkSyncAttempts.set(key, { attemptedAt: now }) + } + }) + safeAutoForkSyncAttempts.set(key, { attemptedAt: now, promise }) } } @@ -1602,17 +1610,15 @@ export const createRepoSlice: StateCreator = (set, }, fetchRuntimeEnvironmentRepos: async (environmentId) => { - const requestToken = runtimeRepoFetches.begin(environmentId) - if (!requestToken) { - return [] - } + const requestGeneration = (runtimeRepoFetchGenerationByEnvironment.get(environmentId) ?? 0) + 1 + runtimeRepoFetchGenerationByEnvironment.set(environmentId, requestGeneration) const connectionGeneration = getEnvironmentSshStateGeneration(environmentId) const runtimeConnectionGeneration = getRuntimeEnvironmentConnectionGeneration(environmentId) try { const target = { kind: 'environment' as const, environmentId } const catalog = await fetchRepoCatalogForTarget(target) if ( - !runtimeRepoFetches.isCurrent(environmentId, requestToken) || + runtimeRepoFetchGenerationByEnvironment.get(environmentId) !== requestGeneration || getEnvironmentSshStateGeneration(environmentId) !== connectionGeneration || getRuntimeEnvironmentConnectionGeneration(environmentId) !== runtimeConnectionGeneration ) { @@ -1621,7 +1627,7 @@ export const createRepoSlice: StateCreator = (set, let finalizedHostRepos: Repo[] = [] set((s) => { if ( - !runtimeRepoFetches.isCurrent(environmentId, requestToken) || + runtimeRepoFetchGenerationByEnvironment.get(environmentId) !== requestGeneration || getEnvironmentSshStateGeneration(environmentId) !== connectionGeneration || getRuntimeEnvironmentConnectionGeneration(environmentId) !== runtimeConnectionGeneration ) { @@ -1673,8 +1679,6 @@ export const createRepoSlice: StateCreator = (set, } catch (err) { console.error(`Failed to fetch repos for runtime environment ${environmentId}:`, err) return [] - } finally { - runtimeRepoFetches.end(environmentId, requestToken) } }, @@ -1762,14 +1766,9 @@ export const createRepoSlice: StateCreator = (set, } const environments = await listRuntimeEnvironmentsForAllHostLoad() - // Why: bound slow remote loads while merging each result without clobbering peers. - await forEachWithConcurrency( - environments, - RUNTIME_CATALOG_FETCH_CONCURRENCY, - async (environment) => { - if (get().reposFetchGeneration !== generation) { - return - } + // Why: unreachable remotes can spend the full connect timeout; merge each resolved host via the state updater so parallel loads don't clobber. + await Promise.all( + environments.map(async (environment) => { try { applyCatalog( await fetchRepoCatalogForTarget({ @@ -1781,7 +1780,7 @@ export const createRepoSlice: StateCreator = (set, failed = true console.warn(`Skipped repos for runtime environment ${environment.id}:`, err) } - } + }) ) // Why: validate repo-scoped UI only after every host answers; first-paint loads only local repos, so an offline runtime would erase its saved filters. if (!failed && get().reposFetchGeneration === generation) { @@ -1821,10 +1820,8 @@ export const createRepoSlice: StateCreator = (set, } const environments = await listRuntimeEnvironmentsForAllHostLoad() - await forEachWithConcurrency( - environments, - RUNTIME_CATALOG_FETCH_CONCURRENCY, - async (environment) => { + await Promise.all( + environments.map(async (environment) => { try { applyCatalog( await fetchProjectGroupCatalogForTarget({ @@ -1835,7 +1832,7 @@ export const createRepoSlice: StateCreator = (set, } catch (err) { console.warn(`Skipped project groups for runtime environment ${environment.id}:`, err) } - } + }) ) }, @@ -1878,10 +1875,8 @@ export const createRepoSlice: StateCreator = (set, } const environments = await listRuntimeEnvironmentsForAllHostLoad() - await forEachWithConcurrency( - environments, - RUNTIME_CATALOG_FETCH_CONCURRENCY, - async (environment) => { + await Promise.all( + environments.map(async (environment) => { try { applyCatalog( await fetchFolderWorkspaceCatalogForTarget({ @@ -1893,7 +1888,7 @@ export const createRepoSlice: StateCreator = (set, failed = true console.warn(`Skipped folder workspaces for runtime environment ${environment.id}:`, err) } - } + }) ) if (!failed) { set((s) => ({ diff --git a/src/renderer/src/store/slices/runtime-catalog-fanout-boundary.test.ts b/src/renderer/src/store/slices/runtime-catalog-fanout-boundary.test.ts deleted file mode 100644 index 20f617d92f0..00000000000 --- a/src/renderer/src/store/slices/runtime-catalog-fanout-boundary.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' -import { describe, expect, it } from 'vitest' - -const REPOS_SOURCE = readFileSync(join(__dirname, 'repos.ts'), 'utf8') -const RUNTIME_STATUS_SOURCE = readFileSync(join(__dirname, 'runtime-status.ts'), 'utf8') - -function sourceBetween(source: string, startPattern: string, endPattern: string): string { - const start = source.indexOf(startPattern) - expect(start).toBeGreaterThanOrEqual(0) - const end = source.indexOf(endPattern, start + startPattern.length) - expect(end).toBeGreaterThan(start) - return source.slice(start, end) -} - -describe('runtime environment fanout boundaries', () => { - it('bounds every all-host catalog fetch', () => { - const sections = [ - sourceBetween(REPOS_SOURCE, 'fetchReposForAllHosts: async', 'fetchProjectGroups: async'), - sourceBetween( - REPOS_SOURCE, - 'fetchProjectGroupsForAllHosts: async', - 'fetchFolderWorkspaces: async' - ), - sourceBetween( - REPOS_SOURCE, - 'fetchFolderWorkspacesForAllHosts: async', - 'getFolderWorkspacePathStatusCacheKey:' - ) - ] - - expect(REPOS_SOURCE).toContain('const RUNTIME_CATALOG_FETCH_CONCURRENCY = 4') - for (const section of sections) { - expect(section).toContain('forEachWithConcurrency(') - expect(section).toContain('RUNTIME_CATALOG_FETCH_CONCURRENCY') - expect(section).not.toMatch(/Promise\.all\(\s*environments\.map/) - } - }) - - it('bounds startup status probes while retaining all-settled behavior', () => { - const section = sourceBetween( - RUNTIME_STATUS_SOURCE, - 'hydrateRuntimeEnvironmentStatuses: async', - '\n }\n})' - ) - - expect(RUNTIME_STATUS_SOURCE).toContain('const RUNTIME_STATUS_PROBE_CONCURRENCY = 4') - expect(section).toContain('mapSettledWithConcurrency(') - expect(section).toContain('RUNTIME_STATUS_PROBE_CONCURRENCY') - expect(section).not.toMatch(/Promise\.allSettled\(\s*environments\.map/) - }) -}) diff --git a/src/renderer/src/store/slices/runtime-environment-ssh.ts b/src/renderer/src/store/slices/runtime-environment-ssh.ts index 52e86527929..b55bfee8b7c 100644 --- a/src/renderer/src/store/slices/runtime-environment-ssh.ts +++ b/src/renderer/src/store/slices/runtime-environment-ssh.ts @@ -6,7 +6,6 @@ import type { SshTargetSummary } from '../../../../shared/ssh-types' import { sshConnectionStatesEqual, sshTargetLabelsEqual } from './ssh-target-cleanup' -import { OperationGenerationRegistry } from '@/lib/operation-generation-registry' /** * SSH state of one remote Orca server's own SSH targets, mirrored on this @@ -65,8 +64,8 @@ const EMPTY_BUCKET: RuntimeEnvironmentSshBucket = { targetsHydrated: false } -const stateGenerations = new OperationGenerationRegistry() -const targetConnectionGenerations = new OperationGenerationRegistry() +const stateGenerationByEnvironment = new Map() +const targetConnectionGenerationByEnvironment = new Map() function targetGenerationKey(environmentId: string, targetId: string): string { return `${environmentId}\0${targetId}` @@ -76,22 +75,31 @@ export function getEnvironmentSshTargetConnectionGeneration( environmentId: string, targetId: string ): number { - return targetConnectionGenerations.get(targetGenerationKey(environmentId, targetId)) + return ( + targetConnectionGenerationByEnvironment.get(targetGenerationKey(environmentId, targetId)) ?? 0 + ) } function advanceEnvironmentSshTargetConnectionGeneration( environmentId: string, targetId: string ): void { - targetConnectionGenerations.advance(targetGenerationKey(environmentId, targetId)) + const key = targetGenerationKey(environmentId, targetId) + targetConnectionGenerationByEnvironment.set( + key, + getEnvironmentSshTargetConnectionGeneration(environmentId, targetId) + 1 + ) } export function getEnvironmentSshStateGeneration(environmentId: string): number { - return stateGenerations.get(environmentId) + return stateGenerationByEnvironment.get(environmentId) ?? 0 } function advanceEnvironmentSshStateGeneration(environmentId: string): void { - stateGenerations.advance(environmentId) + stateGenerationByEnvironment.set( + environmentId, + getEnvironmentSshStateGeneration(environmentId) + 1 + ) } function generationIsCurrent(environmentId: string, generation: number | undefined): boolean { diff --git a/src/renderer/src/store/slices/runtime-repo-fetch-tracker.test.ts b/src/renderer/src/store/slices/runtime-repo-fetch-tracker.test.ts deleted file mode 100644 index c9ef9f34c01..00000000000 --- a/src/renderer/src/store/slices/runtime-repo-fetch-tracker.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { RuntimeRepoFetchTracker } from './runtime-repo-fetch-tracker' - -const BOUNDS = { - maxEnvironments: 2, - maxEnvironmentIdBytes: 4, - maxTotalEnvironmentIdBytes: 6 -} - -describe('RuntimeRepoFetchTracker', () => { - it('releases completed environments during sequential churn', () => { - const tracker = new RuntimeRepoFetchTracker(BOUNDS) - for (let index = 0; index < 100; index++) { - const environmentId = String(index % 10) - const token = tracker.begin(environmentId) - expect(token).not.toBeNull() - tracker.end(environmentId, token!) - } - - expect(tracker.evidence()).toEqual({ environments: 0, keyBytes: 0 }) - }) - - it('lets a replacement supersede an older request without releasing the replacement', () => { - const tracker = new RuntimeRepoFetchTracker(BOUNDS) - const first = tracker.begin('a')! - const replacement = tracker.begin('a')! - - expect(tracker.isCurrent('a', first)).toBe(false) - expect(tracker.isCurrent('a', replacement)).toBe(true) - tracker.end('a', first) - expect(tracker.isCurrent('a', replacement)).toBe(true) - }) - - it('rejects a new owner at capacity while allowing an existing owner replacement', () => { - const tracker = new RuntimeRepoFetchTracker(BOUNDS) - tracker.begin('a') - tracker.begin('bb') - - expect(tracker.begin('c')).toBeNull() - expect(tracker.begin('a')).not.toBeNull() - expect(tracker.evidence()).toEqual({ environments: 2, keyBytes: 3 }) - }) - - it('rejects a new owner beyond the aggregate id budget', () => { - const tracker = new RuntimeRepoFetchTracker({ - maxEnvironments: 3, - maxEnvironmentIdBytes: 4, - maxTotalEnvironmentIdBytes: 3 - }) - tracker.begin('aa') - tracker.begin('b') - - expect(tracker.begin('c')).toBeNull() - expect(tracker.evidence()).toEqual({ environments: 2, keyBytes: 3 }) - }) - - it('rejects environment ids outside the byte budget', () => { - const tracker = new RuntimeRepoFetchTracker(BOUNDS) - - expect(tracker.begin('oversized')).toBeNull() - expect(tracker.evidence()).toEqual({ environments: 0, keyBytes: 0 }) - }) -}) diff --git a/src/renderer/src/store/slices/runtime-repo-fetch-tracker.ts b/src/renderer/src/store/slices/runtime-repo-fetch-tracker.ts deleted file mode 100644 index c42b035c3b3..00000000000 --- a/src/renderer/src/store/slices/runtime-repo-fetch-tracker.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { measureUtf8ByteLength } from '../../../../shared/utf8-byte-limits' - -export type RuntimeRepoFetchTrackerBounds = { - maxEnvironments: number - maxEnvironmentIdBytes: number - maxTotalEnvironmentIdBytes: number -} - -export const DEFAULT_RUNTIME_REPO_FETCH_TRACKER_BOUNDS: RuntimeRepoFetchTrackerBounds = { - maxEnvironments: 8_192, - maxEnvironmentIdBytes: 64 * 1024, - maxTotalEnvironmentIdBytes: 1024 * 1024 -} - -type RuntimeRepoFetchClaim = { - token: symbol - keyBytes: number -} - -export class RuntimeRepoFetchTracker { - private readonly claims = new Map() - private retainedKeyBytes = 0 - - constructor( - private readonly bounds: RuntimeRepoFetchTrackerBounds = DEFAULT_RUNTIME_REPO_FETCH_TRACKER_BOUNDS - ) { - if ( - !Number.isSafeInteger(bounds.maxEnvironments) || - bounds.maxEnvironments < 1 || - !Number.isSafeInteger(bounds.maxEnvironmentIdBytes) || - bounds.maxEnvironmentIdBytes < 1 || - !Number.isSafeInteger(bounds.maxTotalEnvironmentIdBytes) || - bounds.maxTotalEnvironmentIdBytes < 1 - ) { - throw new RangeError('Runtime repo fetch tracker bounds must be positive integers') - } - } - - begin(environmentId: string): symbol | null { - const existing = this.claims.get(environmentId) - if (existing) { - const token = Symbol() - this.claims.set(environmentId, { ...existing, token }) - return token - } - const measurement = measureUtf8ByteLength(environmentId, { - stopAfterBytes: this.bounds.maxEnvironmentIdBytes - }) - if ( - measurement.exceededLimit || - this.claims.size >= this.bounds.maxEnvironments || - this.retainedKeyBytes + measurement.byteLength > this.bounds.maxTotalEnvironmentIdBytes - ) { - return null - } - const token = Symbol() - this.claims.set(environmentId, { token, keyBytes: measurement.byteLength }) - this.retainedKeyBytes += measurement.byteLength - return token - } - - isCurrent(environmentId: string, token: symbol): boolean { - return this.claims.get(environmentId)?.token === token - } - - end(environmentId: string, token: symbol): void { - const current = this.claims.get(environmentId) - if (current?.token !== token) { - return - } - this.claims.delete(environmentId) - this.retainedKeyBytes -= current.keyBytes - } - - evidence(): { environments: number; keyBytes: number } { - return { - environments: this.claims.size, - keyBytes: this.retainedKeyBytes - } - } -} diff --git a/src/renderer/src/store/slices/runtime-status.ts b/src/renderer/src/store/slices/runtime-status.ts index fdcd10e661c..13386fcd12f 100644 --- a/src/renderer/src/store/slices/runtime-status.ts +++ b/src/renderer/src/store/slices/runtime-status.ts @@ -8,10 +8,6 @@ import { unwrapRuntimeRpcResult } from '@/runtime/runtime-rpc-client' import { replaceRuntimeEnvironmentRevisions } from '@/runtime/runtime-environment-revision' -import { mapSettledWithConcurrency } from '../../../../shared/map-with-concurrency' -import { OperationGenerationRegistry } from '@/lib/operation-generation-registry' - -const RUNTIME_STATUS_PROBE_CONCURRENCY = 4 /** Live status for one saved runtime environment, as last observed by the * renderer. `status === null` records a probe that failed or timed out so the @@ -54,14 +50,16 @@ export type RuntimeStatusSlice = { hydrateRuntimeEnvironmentStatuses: () => Promise } -const connectionGenerations = new OperationGenerationRegistry() +const connectionGenerationByEnvironment = new Map() export function getRuntimeEnvironmentConnectionGeneration(environmentId: string): number { - return connectionGenerations.get(environmentId) + return connectionGenerationByEnvironment.get(environmentId) ?? 0 } function advanceRuntimeEnvironmentConnectionGeneration(environmentId: string): number { - return connectionGenerations.advance(environmentId) + const next = getRuntimeEnvironmentConnectionGeneration(environmentId) + 1 + connectionGenerationByEnvironment.set(environmentId, next) + return next } export const createRuntimeStatusSlice: StateCreator = ( @@ -235,9 +233,10 @@ export const createRuntimeStatusSlice: StateCreator - get().refreshRuntimeEnvironmentStatus(environment.id) + // Why: fire-and-forget per env; one unreachable server must not block the + // others, and a failure records a null status rather than nothing. + await Promise.allSettled( + environments.map((environment) => get().refreshRuntimeEnvironmentStatus(environment.id)) ) } }) diff --git a/src/renderer/src/store/slices/safe-auto-fork-sync-attempts.test.ts b/src/renderer/src/store/slices/safe-auto-fork-sync-attempts.test.ts deleted file mode 100644 index 73ae09d4ff6..00000000000 --- a/src/renderer/src/store/slices/safe-auto-fork-sync-attempts.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { SafeAutoForkSyncAttempts } from './safe-auto-fork-sync-attempts' - -describe('safe auto-fork sync attempts', () => { - it('starts every distinct attempt through the exact cap', () => { - const attempts = new SafeAutoForkSyncAttempts(100, 3) - const start = vi.fn(() => new Promise(() => undefined)) - - expect(attempts.run('a', 0, start)).toBe(true) - expect(attempts.run('b', 0, start)).toBe(true) - expect(attempts.run('c', 0, start)).toBe(true) - - expect(start).toHaveBeenCalledTimes(3) - expect(attempts.evidence()).toEqual({ entries: 3, inFlight: 3 }) - }) - - it('rejects one over the cap when every retained attempt is in flight', () => { - const attempts = new SafeAutoForkSyncAttempts(100, 2) - const start = vi.fn(() => new Promise(() => undefined)) - attempts.run('a', 0, start) - attempts.run('b', 0, start) - - expect(attempts.run('overflow', 0, start)).toBe(false) - expect(start).toHaveBeenCalledTimes(2) - expect(attempts.evidence()).toEqual({ entries: 2, inFlight: 2 }) - }) - - it('evicts the oldest completed cooldown entry before admitting a new key', async () => { - const attempts = new SafeAutoForkSyncAttempts(100, 2) - attempts.run('oldest', 0, async () => undefined) - attempts.run('newer', 1, async () => undefined) - await Promise.resolve() - - expect(attempts.run('replacement', 2, async () => undefined)).toBe(true) - expect(attempts.evidence().entries).toBe(2) - expect(attempts.run('oldest', 3, async () => undefined)).toBe(true) - expect(attempts.run('newer', 3, async () => undefined)).toBe(false) - }) - - it('preserves the cooldown below its boundary and expires it exactly at the boundary', async () => { - const attempts = new SafeAutoForkSyncAttempts(100, 2) - const start = vi.fn(async () => undefined) - attempts.run('repo', 0, start) - await Promise.resolve() - - expect(attempts.run('repo', 99, start)).toBe(false) - expect(attempts.run('repo', 100, start)).toBe(true) - expect(start).toHaveBeenCalledTimes(2) - }) -}) diff --git a/src/renderer/src/store/slices/safe-auto-fork-sync-attempts.ts b/src/renderer/src/store/slices/safe-auto-fork-sync-attempts.ts deleted file mode 100644 index 179f0906817..00000000000 --- a/src/renderer/src/store/slices/safe-auto-fork-sync-attempts.ts +++ /dev/null @@ -1,82 +0,0 @@ -export const SAFE_AUTO_FORK_SYNC_COOLDOWN_MS = 10 * 60 * 1000 -export const SAFE_AUTO_FORK_SYNC_MAX_ATTEMPTS = 512 - -type SafeAutoForkSyncAttempt = { - attemptedAt: number - promise: Promise | null -} - -export class SafeAutoForkSyncAttempts { - private readonly attempts = new Map() - - constructor( - private readonly cooldownMs = SAFE_AUTO_FORK_SYNC_COOLDOWN_MS, - private readonly maxAttempts = SAFE_AUTO_FORK_SYNC_MAX_ATTEMPTS - ) { - if (!Number.isSafeInteger(cooldownMs) || cooldownMs < 0) { - throw new RangeError('Safe auto-fork sync cooldown must be a non-negative integer') - } - if (!Number.isSafeInteger(maxAttempts) || maxAttempts < 1) { - throw new RangeError('Safe auto-fork sync attempt cap must be a positive integer') - } - } - - run(key: string, attemptedAt: number, start: () => Promise): boolean { - this.pruneExpired(attemptedAt) - const existing = this.attempts.get(key) - if (existing?.promise || (existing && attemptedAt - existing.attemptedAt < this.cooldownMs)) { - return false - } - if (!this.reserveSlot()) { - return false - } - const promise = start() - this.attempts.set(key, { attemptedAt, promise }) - const settle = (): void => { - const current = this.attempts.get(key) - if (current?.promise === promise) { - this.attempts.set(key, { attemptedAt, promise: null }) - } - } - void promise.then(settle, settle) - return true - } - - evidence(): { entries: number; inFlight: number } { - let inFlight = 0 - for (const attempt of this.attempts.values()) { - if (attempt.promise) { - inFlight += 1 - } - } - return { entries: this.attempts.size, inFlight } - } - - private pruneExpired(now: number): void { - for (const [key, attempt] of this.attempts) { - if (!attempt.promise && now - attempt.attemptedAt >= this.cooldownMs) { - this.attempts.delete(key) - } - } - } - - private reserveSlot(): boolean { - if (this.attempts.size < this.maxAttempts) { - return true - } - let oldestCompleted: { key: string; attemptedAt: number } | null = null - for (const [key, attempt] of this.attempts) { - if ( - !attempt.promise && - (!oldestCompleted || attempt.attemptedAt < oldestCompleted.attemptedAt) - ) { - oldestCompleted = { key, attemptedAt: attempt.attemptedAt } - } - } - if (!oldestCompleted) { - return false - } - this.attempts.delete(oldestCompleted.key) - return true - } -} diff --git a/src/renderer/src/store/slices/ssh-credential-request-retention.test.ts b/src/renderer/src/store/slices/ssh-credential-request-retention.test.ts deleted file mode 100644 index 8dc4e44dc27..00000000000 --- a/src/renderer/src/store/slices/ssh-credential-request-retention.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { getUtf8ByteLength } from '../../../../shared/utf8-byte-limits' -import { - retainSshCredentialRequest, - type SshCredentialRequest, - type SshCredentialRequestRetentionBounds -} from './ssh-credential-request-retention' - -const TEST_BOUNDS: SshCredentialRequestRetentionBounds = { - maxRequests: 2, - maxRequestIdBytes: 8, - maxTargetIdBytes: 8, - maxDetailBytes: 8 -} - -function request(requestId: string, detail = 'prompt'): SshCredentialRequest { - return { - requestId, - targetId: 'target', - kind: 'password', - detail - } -} - -describe('SSH credential request retention', () => { - it('preserves ordinary requests in FIFO order', () => { - const first = retainSshCredentialRequest([], request('first'), TEST_BOUNDS) - const second = retainSshCredentialRequest(first.queue, request('second'), TEST_BOUNDS) - - expect(second.queue.map((entry) => entry.requestId)).toEqual(['first', 'second']) - expect(second.notice).toBeUndefined() - }) - - it('deduplicates repeated request IDs without replacing their FIFO position', () => { - const original = [request('same', 'original')] - const result = retainSshCredentialRequest(original, request('same', 'replacement'), TEST_BOUNDS) - - expect(result.queue).toBe(original) - expect(result.notice).toEqual({ - action: 'dropped', - reason: 'duplicate-request-id', - limit: TEST_BOUNDS.maxRequests - }) - }) - - it('drops new requests after the simultaneous prompt cap', () => { - const original = [request('first'), request('second')] - const result = retainSshCredentialRequest(original, request('third'), TEST_BOUNDS) - - expect(result.queue).toBe(original) - expect(result.notice).toEqual({ - action: 'dropped', - reason: 'queue-full', - limit: TEST_BOUNDS.maxRequests - }) - }) - - it.each([ - ['requestId', 'oversized-request-id'], - ['targetId', 'oversized-target-id'] - ] as const)('drops requests with an oversized %s', (field, reason) => { - const oversized = { ...request('id'), [field]: '😀😀😀' } - const result = retainSshCredentialRequest([], oversized, TEST_BOUNDS) - - expect(result.queue).toEqual([]) - expect(result.notice).toMatchObject({ action: 'dropped', reason }) - }) - - it('truncates display detail on a UTF-8 code point boundary', () => { - const result = retainSshCredentialRequest([], request('id', 'a😀b😀c'), TEST_BOUNDS) - - expect(result.queue[0]?.detail).toBe('a😀b') - expect(getUtf8ByteLength(result.queue[0]?.detail ?? '')).toBeLessThanOrEqual( - TEST_BOUNDS.maxDetailBytes - ) - expect(result.notice).toEqual({ - action: 'truncated', - field: 'detail', - limit: TEST_BOUNDS.maxDetailBytes - }) - }) -}) diff --git a/src/renderer/src/store/slices/ssh-credential-request-retention.ts b/src/renderer/src/store/slices/ssh-credential-request-retention.ts deleted file mode 100644 index 3547f73e277..00000000000 --- a/src/renderer/src/store/slices/ssh-credential-request-retention.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { clampUtf8TextPrefix, measureUtf8ByteLength } from '../../../../shared/utf8-byte-limits' -import { - SSH_CREDENTIAL_DETAIL_MAX_UTF8_BYTES, - SSH_RETAINED_IDENTIFIER_MAX_UTF8_BYTES -} from '../../../../shared/ssh-retained-payload-admission' - -export type SshCredentialRequest = { - requestId: string - targetId: string - kind: 'passphrase' | 'password' - detail: string -} - -export type SshCredentialRequestRetentionBounds = { - maxRequests: number - maxRequestIdBytes: number - maxTargetIdBytes: number - maxDetailBytes: number -} - -export const DEFAULT_SSH_CREDENTIAL_REQUEST_RETENTION_BOUNDS: SshCredentialRequestRetentionBounds = - { - maxRequests: 64, - maxRequestIdBytes: 1024, - maxTargetIdBytes: SSH_RETAINED_IDENTIFIER_MAX_UTF8_BYTES, - maxDetailBytes: SSH_CREDENTIAL_DETAIL_MAX_UTF8_BYTES - } - -export type SshCredentialRequestRetentionNotice = - | { - action: 'dropped' - reason: 'duplicate-request-id' | 'queue-full' | 'oversized-request-id' | 'oversized-target-id' - limit: number - } - | { - action: 'truncated' - field: 'detail' - limit: number - } - -export type SshCredentialRequestRetentionResult = { - queue: SshCredentialRequest[] - notice?: SshCredentialRequestRetentionNotice -} - -export function retainSshCredentialRequest( - queue: SshCredentialRequest[], - request: SshCredentialRequest, - bounds: SshCredentialRequestRetentionBounds = DEFAULT_SSH_CREDENTIAL_REQUEST_RETENTION_BOUNDS -): SshCredentialRequestRetentionResult { - if (queue.some((entry) => entry.requestId === request.requestId)) { - return { - queue, - notice: { - action: 'dropped', - reason: 'duplicate-request-id', - limit: bounds.maxRequests - } - } - } - if ( - measureUtf8ByteLength(request.requestId, { - stopAfterBytes: bounds.maxRequestIdBytes - }).exceededLimit - ) { - return { - queue, - notice: { - action: 'dropped', - reason: 'oversized-request-id', - limit: bounds.maxRequestIdBytes - } - } - } - if ( - measureUtf8ByteLength(request.targetId, { - stopAfterBytes: bounds.maxTargetIdBytes - }).exceededLimit - ) { - return { - queue, - notice: { - action: 'dropped', - reason: 'oversized-target-id', - limit: bounds.maxTargetIdBytes - } - } - } - if (queue.length >= bounds.maxRequests) { - return { - queue, - notice: { - action: 'dropped', - reason: 'queue-full', - limit: bounds.maxRequests - } - } - } - - const boundedDetail = clampUtf8TextPrefix(request.detail, bounds.maxDetailBytes) - return { - queue: [ - ...queue, - { - ...request, - detail: boundedDetail - } - ], - ...(boundedDetail !== request.detail - ? { - notice: { - action: 'truncated' as const, - field: 'detail' as const, - limit: bounds.maxDetailBytes - } - } - : {}) - } -} diff --git a/src/renderer/src/store/slices/ssh.test.ts b/src/renderer/src/store/slices/ssh.test.ts index 5cc589bae33..f12f12bca82 100644 --- a/src/renderer/src/store/slices/ssh.test.ts +++ b/src/renderer/src/store/slices/ssh.test.ts @@ -1,11 +1,6 @@ import { describe, expect, it } from 'vitest' import { toAppSshPtyId } from '../../../../shared/ssh-pty-id' -import { - SSH_CONNECTION_ERROR_MAX_UTF8_BYTES, - SSH_DETECTED_PORTS_MAX_ENTRIES -} from '../../../../shared/ssh-retained-payload-admission' import { createTestStore, makeTab, makeWorktree, TEST_REPO } from './store-test-helpers' -import { REMOTE_WORKSPACE_SYNC_MESSAGE_MAX_UTF8_BYTES } from './remote-workspace-sync-status-admission' describe('createSshSlice', () => { it('clears renderer state and deferred reconnect metadata for a removed SSH target', () => { @@ -262,43 +257,6 @@ describe('createSshSlice', () => { expect(store.getState().sshConnectedGeneration).toBe(1) }) - it('admits bounded SSH state and detected-port payloads at the store boundary', () => { - const store = createTestStore() - store.getState().setSshConnectionState('ssh-1', { - targetId: 'ssh-1', - status: 'error', - error: 'x'.repeat(SSH_CONNECTION_ERROR_MAX_UTF8_BYTES + 100), - reconnectAttempt: 0 - }) - store.getState().setDetectedPorts( - 'ssh-1', - Array.from({ length: SSH_DETECTED_PORTS_MAX_ENTRIES + 10 }, (_, index) => ({ - port: 3000 + index, - host: '127.0.0.1' - })) - ) - - expect(store.getState().sshConnectionStates.get('ssh-1')?.error).toHaveLength( - SSH_CONNECTION_ERROR_MAX_UTF8_BYTES - ) - expect(store.getState().detectedPortsByConnection['ssh-1']).toHaveLength( - SSH_DETECTED_PORTS_MAX_ENTRIES - ) - }) - - it('caps remote workspace messages at their retained-state boundary', () => { - const store = createTestStore() - - store.getState().setRemoteWorkspaceSyncStatus('ssh-1', { - phase: 'error', - message: 'x'.repeat(REMOTE_WORKSPACE_SYNC_MESSAGE_MAX_UTF8_BYTES + 100) - }) - - expect(store.getState().remoteWorkspaceSyncStatusByTargetId['ssh-1'].message).toHaveLength( - REMOTE_WORKSPACE_SYNC_MESSAGE_MAX_UTF8_BYTES - ) - }) - it('does not publish state when cleanup finds no removed SSH target state', () => { const store = createTestStore() const previousState = store.getState() diff --git a/src/renderer/src/store/slices/ssh.ts b/src/renderer/src/store/slices/ssh.ts index 69c6a67834c..9e15c81ea40 100644 --- a/src/renderer/src/store/slices/ssh.ts +++ b/src/renderer/src/store/slices/ssh.ts @@ -11,20 +11,6 @@ import { sshConnectionStatesEqual, sshTargetLabelsEqual } from './ssh-target-cleanup' -import { - admitSshConnectionState, - admitSshDetectedPorts, - isSshRetainedIdentifier -} from '../../../../shared/ssh-retained-payload-admission' -import { OperationGenerationRegistry } from '@/lib/operation-generation-registry' -import { - retainSshCredentialRequest, - type SshCredentialRequest, - type SshCredentialRequestRetentionNotice -} from './ssh-credential-request-retention' -import { admitRemoteWorkspaceSyncStatus } from './remote-workspace-sync-status-admission' - -export type { SshCredentialRequest } from './ssh-credential-request-retention' export type RemoteWorkspaceSyncStatus = { phase: 'idle' | 'pulling' | 'pushing' | 'synced' | 'conflict' | 'error' | 'offline' @@ -35,6 +21,13 @@ export type RemoteWorkspaceSyncStatus = { message?: string } +export type SshCredentialRequest = { + requestId: string + targetId: string + kind: 'passphrase' | 'password' + detail: string +} + export type SshSlice = { sshConnectionStates: Map /** Maps target IDs to their user-facing labels. Populated during hydration @@ -79,31 +72,14 @@ export type SshSlice = { setDetectedPorts: (targetId: string, ports: EnrichedDetectedPort[]) => void } -const targetConnectionGenerations = new OperationGenerationRegistry() -const SSH_CREDENTIAL_RETENTION_REPORT_INTERVAL_MS = 30_000 -let lastSshCredentialRetentionReportAt = Number.NEGATIVE_INFINITY -let suppressedSshCredentialRetentionReports = 0 - -function reportSshCredentialRetention(notice: SshCredentialRequestRetentionNotice): void { - const now = Date.now() - if (now - lastSshCredentialRetentionReportAt < SSH_CREDENTIAL_RETENTION_REPORT_INTERVAL_MS) { - suppressedSshCredentialRetentionReports += 1 - return - } - console.warn('[ssh-credential-queue] request retention limit applied', { - ...notice, - suppressedSinceLastReport: suppressedSshCredentialRetentionReports - }) - lastSshCredentialRetentionReportAt = now - suppressedSshCredentialRetentionReports = 0 -} +const targetConnectionGeneration = new Map() export function getLocalSshTargetConnectionGeneration(targetId: string): number { - return targetConnectionGenerations.get(targetId) + return targetConnectionGeneration.get(targetId) ?? 0 } function advanceLocalSshTargetConnectionGeneration(targetId: string): void { - targetConnectionGenerations.advance(targetId) + targetConnectionGeneration.set(targetId, getLocalSshTargetConnectionGeneration(targetId) + 1) } export const createSshSlice: StateCreator = (set) => ({ @@ -120,18 +96,14 @@ export const createSshSlice: StateCreator = (set) => setSshConnectionState: (targetId, state) => set((s) => { - const admittedState = admitSshConnectionState(state, targetId) - if (!admittedState) { - return s - } const next = new Map(s.sshConnectionStates) const previous = next.get(targetId) - if (sshConnectionStatesEqual(previous, admittedState)) { + if (sshConnectionStatesEqual(previous, state)) { return s } advanceLocalSshTargetConnectionGeneration(targetId) - next.set(targetId, admittedState) - const didReconnect = previous?.status !== 'connected' && admittedState.status === 'connected' + next.set(targetId, state) + const didReconnect = previous?.status !== 'connected' && state.status === 'connected' let blockedConnections = s.transientClearedAgentStatusConnectionIds if (didReconnect && targetId in blockedConnections) { blockedConnections = { ...blockedConnections } @@ -176,24 +148,14 @@ export const createSshSlice: StateCreator = (set) => return { remoteWorkspaceHydratedTargetIds: next } }), setRemoteWorkspaceSyncStatus: (targetId, status) => - set((s) => - isSshRetainedIdentifier(targetId) - ? { - remoteWorkspaceSyncStatusByTargetId: { - ...s.remoteWorkspaceSyncStatusByTargetId, - [targetId]: admitRemoteWorkspaceSyncStatus(status) - } - } - : s - ), - enqueueSshCredentialRequest: (req) => - set((s) => { - const retained = retainSshCredentialRequest(s.sshCredentialQueue, req) - if (retained.notice) { - reportSshCredentialRetention(retained.notice) + set((s) => ({ + remoteWorkspaceSyncStatusByTargetId: { + ...s.remoteWorkspaceSyncStatusByTargetId, + [targetId]: status } - return retained.queue === s.sshCredentialQueue ? s : { sshCredentialQueue: retained.queue } - }), + })), + enqueueSshCredentialRequest: (req) => + set((s) => ({ sshCredentialQueue: [...s.sshCredentialQueue, req] })), removeSshCredentialRequest: (requestId) => set((s) => ({ sshCredentialQueue: s.sshCredentialQueue.filter((req) => req.requestId !== requestId) @@ -218,13 +180,9 @@ export const createSshSlice: StateCreator = (set) => setDetectedPorts: (targetId, ports) => set((s) => { - if (!isSshRetainedIdentifier(targetId)) { - return s - } - const admittedPorts = admitSshDetectedPorts(ports) const next = { ...s.detectedPortsByConnection } - if (admittedPorts.length > 0) { - next[targetId] = admittedPorts + if (ports.length > 0) { + next[targetId] = ports } else { delete next[targetId] } diff --git a/src/renderer/src/store/slices/store-cascades.test.ts b/src/renderer/src/store/slices/store-cascades.test.ts index 43d18c5d9f0..0bcedd99a8a 100644 --- a/src/renderer/src/store/slices/store-cascades.test.ts +++ b/src/renderer/src/store/slices/store-cascades.test.ts @@ -12,7 +12,6 @@ import { toast } from 'sonner' const mockUnregisterPtyDataHandlers = vi.hoisted(() => vi.fn<() => unknown[]>(() => [])) const mockRestorePtyDataHandlersAfterFailedShutdown = vi.hoisted(() => vi.fn()) -const mockForgetRetiredTerminalPaneRecovery = vi.hoisted(() => vi.fn()) // Mock sonner (imported by repos.ts) vi.mock('sonner', () => ({ @@ -24,10 +23,6 @@ vi.mock('@/components/terminal-pane/pty-dispatcher', () => ({ unregisterPtyDataHandlers: mockUnregisterPtyDataHandlers })) -vi.mock('@/components/terminal-pane/terminal-pane-recovery-retirement', () => ({ - forgetRetiredTerminalPaneRecovery: mockForgetRetiredTerminalPaneRecovery -})) - // Mock agent-status (imported by terminal-helpers) vi.mock('@/lib/agent-status', async (importOriginal) => { const actual = await importOriginal() @@ -195,7 +190,6 @@ describe('removeWorktree cascade', () => { expect(s.activeTabTypeByWorktree[worktreeId]).toBeUndefined() expect(s.rightSidebarExplorerViewByWorktree[worktreeId]).toBeUndefined() expect(loadSessionCommitDrafts()).toEqual({ 'repo1::/path/wt2': 'fix: keep draft' }) - expect(mockForgetRetiredTerminalPaneRecovery.mock.calls).toEqual([['tab1'], ['tab2']]) }) it('warns when workspace removal keeps the local branch', async () => { @@ -2507,7 +2501,6 @@ describe('setActiveWorktree', () => { expect(s.unreadTerminalTabs[closing.id]).toBeUndefined() // Siblings untouched. expect(s.unreadTerminalTabs[surviving.id]).toBe(true) - expect(mockForgetRetiredTerminalPaneRecovery).toHaveBeenCalledWith(closing.id) }) // Why: focus events that normally clear unread never arrive for dead PTYs, so the shutdown path must drop the flags itself. diff --git a/src/renderer/src/store/slices/terminal-tab-retirement-store.test.ts b/src/renderer/src/store/slices/terminal-tab-retirement-store.test.ts index d7e47cb0145..5727f5474c6 100644 --- a/src/renderer/src/store/slices/terminal-tab-retirement-store.test.ts +++ b/src/renderer/src/store/slices/terminal-tab-retirement-store.test.ts @@ -21,11 +21,6 @@ import { capturedPanesByTabId, parkedWatchersByTabId } from '@/components/terminal-pane/terminal-parked-watcher-registry' -import { - bindTerminalScrollIntentKey, - getTerminalScrollIntentKind, - markTerminalPinnedViewport -} from '@/lib/pane-manager/terminal-scroll-intent' import { createTestStore, makeWorktree, @@ -125,48 +120,6 @@ describe('terminal tab retirement store boundary', () => { expect(capturedPanesByTabId.has('tab-1')).toBe(false) }) - it('retires durable scroll intent for every historical layout leaf', () => { - const store = createTestStore() - const retiredTerminal = { - buffer: { active: { type: 'normal' as const, viewportY: 4, baseY: 20 } } - } - bindTerminalScrollIntentKey(retiredTerminal, 'leaf-retired') - markTerminalPinnedViewport(retiredTerminal) - const recordOnlyTerminal = { - buffer: { active: { type: 'normal' as const, viewportY: 8, baseY: 20 } } - } - bindTerminalScrollIntentKey(recordOnlyTerminal, 'leaf-record-only') - markTerminalPinnedViewport(recordOnlyTerminal) - seedStore(store, { - tabsByWorktree: { - 'wt-1': [makeTab({ id: 'tab-1', worktreeId: 'wt-1' })] - }, - terminalLayoutsByTabId: { - 'tab-1': { - root: null, - activeLeafId: 'leaf-retired', - expandedLeafId: null, - ptyIdsByLeafId: { 'leaf-record-only': 'pty-record-only' } - } - } - }) - - store.getState().closeTab('tab-1', { reason: 'pty-exit' }) - markTerminalPinnedViewport(retiredTerminal) - markTerminalPinnedViewport(recordOnlyTerminal) - - const reusedTerminal = { - buffer: { active: { type: 'normal' as const, viewportY: 20, baseY: 20 } } - } - bindTerminalScrollIntentKey(reusedTerminal, 'leaf-retired') - expect(getTerminalScrollIntentKind(reusedTerminal)).toBe('followOutput') - const recordOnlyReuse = { - buffer: { active: { type: 'normal' as const, viewportY: 20, baseY: 20 } } - } - bindTerminalScrollIntentKey(recordOnlyReuse, 'leaf-record-only') - expect(getTerminalScrollIntentKind(recordOnlyReuse)).toBe('followOutput') - }) - it('routes runtime handles to runtime close and preserves shared PTYs', async () => { const store = createRetirementStore() seedStore(store, { diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index 4e857129adf..1fde52a31e2 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -71,7 +71,6 @@ import { disposeParkedTerminalWatchersForPtyIds, retireParkedTerminalTab } from '@/components/terminal-pane/terminal-parked-watcher-registry' -import { forgetRetiredTerminalPaneRecovery } from '@/components/terminal-pane/terminal-pane-recovery-retirement' import { clearCommittedPtyShutdownSettlements, hasCommittedPtyShutdownSettlement, @@ -80,11 +79,9 @@ import { settleDeferredPtyShutdownExits } from '@/components/terminal-pane/pty-shutdown-exit-deferral' import { - collectTerminalLayoutLeafIds, normalizeTerminalLayoutSnapshot, resolvePtyBoundActiveLeafId } from '@/components/terminal-pane/terminal-layout-leaf-ids' -import { releaseTerminalScrollIntentKeys } from '@/lib/pane-manager/terminal-scroll-intent' import { shutdownBufferCaptures } from '@/components/terminal-pane/shutdown-buffer-captures' import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' import { parseRemoteRuntimePtyId, toRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream' @@ -1164,14 +1161,10 @@ export const createTerminalSlice: StateCreator opts?.precomputedRetirementPlan?.tabId === tabId ? opts.precomputedRetirementPlan : buildTerminalTabRetirementPlan(get(), tabId) - const retiringScrollIntentLeafIds = collectTerminalLayoutLeafIds( - get().terminalLayoutsByTabId[tabId] - ) let closingWorktreeId: string | null = null // Why: a parked tab has no mounted TerminalPane cleanup, so revoke its observer/candidate state before provider exit races. retireParkedTerminalTab(tabId) - forgetRetiredTerminalPaneRecovery(tabId) if (retiresSession) { const fallbackWorktreeRoute = retirementPlan.worktreeId ? resolveTerminalWorktreeRoute(get(), retirementPlan.worktreeId) @@ -1412,7 +1405,6 @@ export const createTerminalSlice: StateCreator : {}) } }) - releaseTerminalScrollIntentKeys(retiringScrollIntentLeafIds) // Why: closing a tab sweeps live and retained agent-status for it; use dropAgentStatusByTabPrefix so retention suppressors block a same-frame live→gone re-snapshot. // Why: Pi can leave a completed row keyed under an already-missing tab id; pass the worktree to sweep that orphan while preserving active pre-render child rows. get().dropAgentStatusByTabPrefix( diff --git a/src/renderer/src/store/slices/workspace-cleanup-enrichment-concurrency.test.ts b/src/renderer/src/store/slices/workspace-cleanup-enrichment-concurrency.test.ts deleted file mode 100644 index a33c6795a55..00000000000 --- a/src/renderer/src/store/slices/workspace-cleanup-enrichment-concurrency.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import type { AppState } from '../types' -import { - enrichWorkspaceCleanupCandidates, - WORKSPACE_CLEANUP_ENRICHMENT_CONCURRENCY -} from './workspace-cleanup' -import { makeCandidate, makeState } from './workspace-cleanup-slice-test-harness' - -describe('workspace cleanup enrichment concurrency', () => { - afterEach(() => { - vi.unstubAllGlobals() - }) - - it('preserves order while bounding terminal probes across a large scan', async () => { - const candidateCount = 1_000 - const candidates = Array.from({ length: candidateCount }, (_, index) => - makeCandidate({ - worktreeId: `repo-${index}::/workspace/${index}`, - repoId: `repo-${index}`, - path: `/workspace/${index}` - }) - ) - const tabsByWorktree = Object.fromEntries( - candidates.map((candidate, index) => [ - candidate.worktreeId, - [{ id: `tab-${index}`, title: 'shell' }] - ]) - ) as AppState['tabsByWorktree'] - const ptyIdsByTabId = Object.fromEntries( - candidates.map((_, index) => [`tab-${index}`, [`pty-${index}`]]) - ) - let activeProbes = 0 - let peakProbes = 0 - - vi.stubGlobal('window', { - api: { - pty: { - hasChildProcesses: vi.fn(async () => { - activeProbes += 1 - peakProbes = Math.max(peakProbes, activeProbes) - await Promise.resolve() - activeProbes -= 1 - return false - }), - getForegroundProcess: vi.fn().mockResolvedValue(null) - } - } - }) - - const enriched = await enrichWorkspaceCleanupCandidates( - candidates, - makeState({ tabsByWorktree, ptyIdsByTabId }), - { applyDismissals: false } - ) - - expect(peakProbes).toBe(WORKSPACE_CLEANUP_ENRICHMENT_CONCURRENCY) - expect(enriched.map((candidate) => candidate.worktreeId)).toEqual( - candidates.map((candidate) => candidate.worktreeId) - ) - }) -}) diff --git a/src/renderer/src/store/slices/workspace-cleanup-view-retention.test.ts b/src/renderer/src/store/slices/workspace-cleanup-view-retention.test.ts deleted file mode 100644 index cd13649468e..00000000000 --- a/src/renderer/src/store/slices/workspace-cleanup-view-retention.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import type { AppState } from '../types' -import { MAX_WORKSPACE_CLEANUP_VIEWED_CANDIDATES } from './workspace-cleanup' -import { createCleanupTestStore, makeCandidate } from './workspace-cleanup-slice-test-harness' - -describe('workspace cleanup viewed-candidate retention', () => { - afterEach(() => { - vi.restoreAllMocks() - }) - - it('prunes expired records before recording a new view', () => { - vi.spyOn(Date, 'now').mockReturnValue(10 * 60 * 60 * 1000) - const store = createCleanupTestStore(vi.fn()) - store.setState({ - workspaceCleanupViewedCandidates: Object.fromEntries( - Array.from({ length: 1_000 }, (_, index) => [ - `expired-${index}`, - { viewedAt: 0, fingerprint: `old-${index}`, wasSuggested: true } - ]) - ) - } as Partial) - - store.getState().markWorkspaceCleanupCandidateViewed(makeCandidate()) - - expect(Object.keys(store.getState().workspaceCleanupViewedCandidates)).toEqual([ - makeCandidate().worktreeId - ]) - }) - - it('keeps only the newest bounded set of recent records', () => { - const now = 10 * 60 * 60 * 1000 - vi.spyOn(Date, 'now').mockReturnValue(now) - const store = createCleanupTestStore(vi.fn()) - store.setState({ - workspaceCleanupViewedCandidates: Object.fromEntries( - Array.from({ length: MAX_WORKSPACE_CLEANUP_VIEWED_CANDIDATES }, (_, index) => [ - `recent-${index}`, - { viewedAt: now - index - 1, fingerprint: `recent-${index}`, wasSuggested: true } - ]) - ) - } as Partial) - - store.getState().markWorkspaceCleanupCandidateViewed(makeCandidate()) - - const retained = store.getState().workspaceCleanupViewedCandidates - expect(Object.keys(retained)).toHaveLength(MAX_WORKSPACE_CLEANUP_VIEWED_CANDIDATES) - expect(retained['recent-0']).toBeDefined() - expect(retained[`recent-${MAX_WORKSPACE_CLEANUP_VIEWED_CANDIDATES - 1}`]).toBeUndefined() - }) -}) diff --git a/src/renderer/src/store/slices/workspace-cleanup.ts b/src/renderer/src/store/slices/workspace-cleanup.ts index 3afa7a18a77..bffe5fb93d4 100644 --- a/src/renderer/src/store/slices/workspace-cleanup.ts +++ b/src/renderer/src/store/slices/workspace-cleanup.ts @@ -79,8 +79,6 @@ type WorkspaceCleanupEnrichmentCacheEntry = { const RECENT_VISIBLE_CONTEXT_MS = 24 * 60 * 60 * 1000 const VIEWED_FROM_CLEANUP_MS = 2 * 60 * 60 * 1000 -export const MAX_WORKSPACE_CLEANUP_VIEWED_CANDIDATES = 512 -export const WORKSPACE_CLEANUP_ENRICHMENT_CONCURRENCY = 8 const WORKSPACE_CLEANUP_PREFLIGHT_CONCURRENCY = 4 // Why: dirty-files/unpushed-commits are concrete known work at risk; unknown-base // and git-status-error only mean "couldn't verify". A row approved while @@ -238,27 +236,16 @@ export const createWorkspaceCleanupSlice: StateCreator { - set((state) => { - const now = Date.now() - const recentViews = Object.entries(state.workspaceCleanupViewedCandidates) - .filter( - ([worktreeId, viewed]) => - worktreeId !== candidate.worktreeId && now - viewed.viewedAt <= VIEWED_FROM_CLEANUP_MS - ) - .sort(([, a], [, b]) => b.viewedAt - a.viewedAt) - .slice(0, MAX_WORKSPACE_CLEANUP_VIEWED_CANDIDATES - 1) - return { - workspaceCleanupViewedCandidates: { - ...Object.fromEntries(recentViews), - [candidate.worktreeId]: { - viewedAt: now, - fingerprint: candidate.fingerprint, - wasSuggested: - candidate.tier === 'ready' && canSelectWorkspaceCleanupCandidate(candidate) - } + set((state) => ({ + workspaceCleanupViewedCandidates: { + ...state.workspaceCleanupViewedCandidates, + [candidate.worktreeId]: { + viewedAt: Date.now(), + fingerprint: candidate.fingerprint, + wasSuggested: candidate.tier === 'ready' && canSelectWorkspaceCleanupCandidate(candidate) } } - }) + })) }, dismissWorkspaceCleanupCandidates: async (candidates) => { @@ -361,25 +348,17 @@ export const createWorkspaceCleanupSlice: StateCreator 0) { invalidateWorkspaceCleanupScanProgress() const removedIdSet = new Set(removedIds) - set((state) => { - const viewedCandidates = Object.fromEntries( - Object.entries(state.workspaceCleanupViewedCandidates).filter( - ([worktreeId]) => !removedIdSet.has(worktreeId) - ) - ) - return { - workspaceCleanupLoading: false, - workspaceCleanupViewedCandidates: viewedCandidates, - workspaceCleanupScan: state.workspaceCleanupScan - ? { - ...state.workspaceCleanupScan, - candidates: state.workspaceCleanupScan.candidates.filter( - (candidate) => !removedIdSet.has(candidate.worktreeId) - ) - } - : state.workspaceCleanupScan - } - }) + set((state) => ({ + workspaceCleanupLoading: false, + workspaceCleanupScan: state.workspaceCleanupScan + ? { + ...state.workspaceCleanupScan, + candidates: state.workspaceCleanupScan.candidates.filter( + (candidate) => !removedIdSet.has(candidate.worktreeId) + ) + } + : state.workspaceCleanupScan + })) } return { removedIds, failures } @@ -625,10 +604,8 @@ export async function enrichWorkspaceCleanupCandidates( state: AppState, options: EnrichOptions = {} ): Promise { - return mapWithConcurrency( - candidates, - WORKSPACE_CLEANUP_ENRICHMENT_CONCURRENCY, - async (candidate) => enrichWorkspaceCleanupCandidate(candidate, state, options) + return Promise.all( + candidates.map((candidate) => enrichWorkspaceCleanupCandidate(candidate, state, options)) ) } @@ -638,10 +615,8 @@ async function enrichWorkspaceCleanupCandidatesWithCache( cache: Map, options: EnrichOptions = {} ): Promise { - return mapWithConcurrency( - candidates, - WORKSPACE_CLEANUP_ENRICHMENT_CONCURRENCY, - async (candidate) => { + return Promise.all( + candidates.map(async (candidate) => { const inputSignature = getWorkspaceCleanupCandidateInputSignature(candidate) const localSignature = getWorkspaceCleanupLocalStateSignature( candidate.worktreeId, @@ -660,7 +635,7 @@ async function enrichWorkspaceCleanupCandidatesWithCache( candidate: enriched }) return enriched - } + }) ) } diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index 946955acf9a..8d17d166ace 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -39,7 +39,6 @@ import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed' import { cleanupEphemeralVmRuntimesForDeleted } from '@/lib/ephemeral-vm-runtime-cleanup' import { tabHasLivePty } from '@/lib/tab-has-live-pty' import { disposeRemovedWorktreeParkedTerminalWatchers } from '../../components/terminal-pane/terminal-parked-watcher-registry' -import { forgetRetiredTerminalPaneRecovery } from '../../components/terminal-pane/terminal-pane-recovery-retirement' import { callRuntimeRpc, getActiveRuntimeTarget, @@ -3347,9 +3346,6 @@ export const createWorktreeSlice: StateCreator detachedHeadAutoDerivedDisplayNames.delete(worktreeId) forgetForegroundTerminalTabs(tabIds) forgetAgentStartupDeliveriesForTabs(tabIds) - for (const tabId of tabIds) { - forgetRetiredTerminalPaneRecovery(tabId) - } // Why: snapshot the sidebar top-row anchor in the same tick we remove the row; recording at click time goes stale across the await. requestVirtualizedScrollAnchorRecord('[data-worktree-sidebar]') diff --git a/src/renderer/src/web/web-e2ee.test.ts b/src/renderer/src/web/web-e2ee.test.ts deleted file mode 100644 index 7313e053652..00000000000 --- a/src/renderer/src/web/web-e2ee.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { MAX_E2EE_ENCRYPTED_BASE64_CHARACTERS } from '../../../shared/e2ee-crypto' -import { decrypt, publicKeyFromBase64, WEB_E2EE_PUBLIC_KEY_MAX_BASE64_CHARACTERS } from './web-e2ee' - -afterEach(() => { - vi.unstubAllGlobals() -}) - -describe('web E2EE decode admission', () => { - it('rejects an oversized public key before base64 decoding', () => { - const atob = vi.fn(() => '') - vi.stubGlobal('window', { atob }) - - expect(() => - publicKeyFromBase64('A'.repeat(WEB_E2EE_PUBLIC_KEY_MAX_BASE64_CHARACTERS + 1)) - ).toThrow('encoded value is too large') - expect(atob).not.toHaveBeenCalled() - }) - - it('admits encrypted text at the boundary and rejects one extra character before decoding', () => { - const atob = vi.fn(() => '') - vi.stubGlobal('window', { atob }) - const sharedKey = new Uint8Array(32) - - expect(decrypt('A'.repeat(MAX_E2EE_ENCRYPTED_BASE64_CHARACTERS), sharedKey)).toBeNull() - expect(atob).toHaveBeenCalledOnce() - atob.mockClear() - - expect(decrypt('A'.repeat(MAX_E2EE_ENCRYPTED_BASE64_CHARACTERS + 1), sharedKey)).toBeNull() - expect(atob).not.toHaveBeenCalled() - }) -}) diff --git a/src/renderer/src/web/web-e2ee.ts b/src/renderer/src/web/web-e2ee.ts index 86f1c697996..933a745ee17 100644 --- a/src/renderer/src/web/web-e2ee.ts +++ b/src/renderer/src/web/web-e2ee.ts @@ -1,7 +1,4 @@ import nacl from 'tweetnacl' -import { MAX_E2EE_ENCRYPTED_BASE64_CHARACTERS } from '../../../shared/e2ee-crypto' - -export const WEB_E2EE_PUBLIC_KEY_MAX_BASE64_CHARACTERS = 44 if (globalThis.crypto?.getRandomValues) { nacl.setPRNG((bytes, count) => { @@ -18,9 +15,6 @@ export function deriveSharedKey(ourSecretKey: Uint8Array, peerPublicKey: Uint8Ar } export function publicKeyFromBase64(b64: string): Uint8Array { - if (b64.length > WEB_E2EE_PUBLIC_KEY_MAX_BASE64_CHARACTERS) { - throw new Error('Invalid public key: encoded value is too large') - } const key = base64ToBytes(b64) if (key.length !== 32) { throw new Error(`Invalid public key: expected 32 bytes, got ${key.length}`) @@ -37,9 +31,6 @@ export function encrypt(plaintext: string, sharedKey: Uint8Array): string { } export function decrypt(encrypted: string, sharedKey: Uint8Array): string | null { - if (encrypted.length > MAX_E2EE_ENCRYPTED_BASE64_CHARACTERS) { - return null - } const plaintext = decryptBytes(base64ToBytes(encrypted), sharedKey) return plaintext ? new TextDecoder().decode(plaintext) : null } diff --git a/src/renderer/src/web/web-local-storage-json.test.ts b/src/renderer/src/web/web-local-storage-json.test.ts deleted file mode 100644 index ef74e96643b..00000000000 --- a/src/renderer/src/web/web-local-storage-json.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { - parseWebLocalStorageJson, - stringifyWebLocalStorageJson, - WEB_LOCAL_STORAGE_JSON_LIMITS, - type WebLocalStorageJsonLimits -} from './web-local-storage-json' - -describe('browser-local JSON memory admission', () => { - it('preserves ordinary JSON serialization and parsing', () => { - const value = { - escaped: '\u0000\n', - nested: [1, true, null, { emoji: '🐋' }], - omitted: undefined - } - const serialized = JSON.stringify(value) - - expect(stringifyWebLocalStorageJson(value)).toBe(serialized) - expect(parseWebLocalStorageJson(serialized)).toEqual(JSON.parse(serialized)) - }) - - it('accepts exact UTF-8 bytes and rejects the next byte', () => { - const limits = createLimits({ maxBytes: 6 }) - - expect(parseWebLocalStorageJson('"🐋"', limits)).toBe('🐋') - expect(stringifyWebLocalStorageJson('🐋', limits)).toBe('"🐋"') - expect(() => parseWebLocalStorageJson('"🐋x"', limits)).toThrow( - 'Browser-local JSON exceeds 6 bytes' - ) - expect(() => stringifyWebLocalStorageJson('🐋x', limits)).toThrow( - 'Browser-local JSON exceeds 6 bytes' - ) - }) - - it('admits exact structure and depth while rejecting the next token or level', () => { - const structuralLimits = createLimits({ structuralTokens: 4 }) - const depthLimits = createLimits({ nestingDepth: 3 }) - - expect(parseWebLocalStorageJson('[0,0,0]', structuralLimits)).toEqual([0, 0, 0]) - expect(stringifyWebLocalStorageJson([0, 0, 0], structuralLimits)).toBe('[0,0,0]') - expect(() => parseWebLocalStorageJson('[0,0,0,0]', structuralLimits)).toThrow( - 'JSON structure exceeds 4 tokens' - ) - expect(() => stringifyWebLocalStorageJson([0, 0, 0, 0], structuralLimits)).toThrow( - 'JSON structure exceeds 4 tokens' - ) - expect(parseWebLocalStorageJson('[[[]]]', depthLimits)).toEqual([[[]]]) - expect(() => parseWebLocalStorageJson('[[[[]]]]', depthLimits)).toThrow( - 'JSON nesting exceeds 3 levels' - ) - }) - - it('rejects amplified input before invoking JSON.parse', () => { - const parse = vi.spyOn(JSON, 'parse') - - expect(() => - parseWebLocalStorageJson('[0,0,0,0]', createLimits({ structuralTokens: 4 })) - ).toThrow('JSON structure exceeds 4 tokens') - expect(parse).not.toHaveBeenCalled() - }) - - it('stops output traversal once the byte limit is exceeded', () => { - const readAfter = vi.fn(() => 'late') - const value = { - payload: 'x'.repeat(100), - get after() { - return readAfter() - } - } - - expect(() => stringifyWebLocalStorageJson(value, createLimits({ maxBytes: 32 }))).toThrow( - 'Browser-local JSON exceeds 32 bytes' - ) - expect(readAfter).not.toHaveBeenCalled() - }) - - it('keeps a finite default persistence envelope', () => { - expect(WEB_LOCAL_STORAGE_JSON_LIMITS).toEqual({ - maxBytes: 8 * 1024 * 1024, - structuralTokens: 1_000_000, - nestingDepth: 128 - }) - }) -}) - -function createLimits(overrides: Partial): WebLocalStorageJsonLimits { - return { - maxBytes: 1_000, - structuralTokens: 100, - nestingDepth: 10, - ...overrides - } -} diff --git a/src/renderer/src/web/web-local-storage-json.ts b/src/renderer/src/web/web-local-storage-json.ts deleted file mode 100644 index 57f15b3dbf5..00000000000 --- a/src/renderer/src/web/web-local-storage-json.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { - assertJsonTextStructureWithinLimits, - type JsonTextStructureLimits -} from '../../../shared/json-text-structure-limit' -import { measureUtf8ByteLength } from '../../../shared/utf8-byte-limits' -import { - stringifyWebRuntimeOutboundJson, - WebRuntimeOutboundJsonLimitError -} from './web-runtime-outbound-json' - -export type WebLocalStorageJsonLimits = JsonTextStructureLimits & { - maxBytes: number -} - -export const WEB_LOCAL_STORAGE_JSON_LIMITS: WebLocalStorageJsonLimits = { - maxBytes: 8 * 1024 * 1024, - structuralTokens: 1_000_000, - nestingDepth: 128 -} - -export class WebLocalStorageJsonByteCapacityError extends Error { - constructor(readonly maxBytes: number) { - super(`Browser-local JSON exceeds ${maxBytes} bytes`) - this.name = 'WebLocalStorageJsonByteCapacityError' - } -} - -export function parseWebLocalStorageJson( - content: string, - limits: WebLocalStorageJsonLimits = WEB_LOCAL_STORAGE_JSON_LIMITS -): T { - assertWebLocalStorageJsonBytes(content, limits.maxBytes) - assertJsonTextStructureWithinLimits(content, limits) - return JSON.parse(content) as T -} - -export function stringifyWebLocalStorageJson( - value: unknown, - limits: WebLocalStorageJsonLimits = WEB_LOCAL_STORAGE_JSON_LIMITS -): string { - let serialized: string | undefined - try { - serialized = stringifyWebRuntimeOutboundJson(value, limits.maxBytes).serialized - } catch (error) { - if (error instanceof WebRuntimeOutboundJsonLimitError) { - throw new WebLocalStorageJsonByteCapacityError(limits.maxBytes) - } - throw error - } - if (serialized === undefined) { - throw new TypeError('Browser-local JSON value is not serializable') - } - assertJsonTextStructureWithinLimits(serialized, limits) - return serialized -} - -function assertWebLocalStorageJsonBytes(content: string, maxBytes: number): void { - if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) { - throw new RangeError('Browser-local JSON byte limit must be a positive safe integer') - } - if (measureUtf8ByteLength(content, { stopAfterBytes: maxBytes }).exceededLimit) { - throw new WebLocalStorageJsonByteCapacityError(maxBytes) - } -} diff --git a/src/renderer/src/web/web-pairing.test.ts b/src/renderer/src/web/web-pairing.test.ts index f37485d7f22..6840527d227 100644 --- a/src/renderer/src/web/web-pairing.test.ts +++ b/src/renderer/src/web/web-pairing.test.ts @@ -1,11 +1,4 @@ -import { describe, expect, it, vi } from 'vitest' -import { - PAIRING_CODE_MAX_CHARACTERS, - PAIRING_DEVICE_TOKEN_MAX_CHARACTERS, - PAIRING_ENDPOINT_MAX_CHARACTERS, - PAIRING_INPUT_MAX_CHARACTERS, - PAIRING_PUBLIC_KEY_MAX_CHARACTERS -} from '../../../shared/mobile-relay-pairing-offer' +import { describe, expect, it } from 'vitest' import { decideWebPairingStartup, parseWebPairingInput, type WebPairingOffer } from './web-pairing' describe('web pairing input', () => { @@ -58,23 +51,6 @@ describe('web pairing input', () => { expect(parseWebPairingInput(`orca://pair-extra?code=${encodeOffer()}`)).toBeNull() }) - it('rejects oversized input before base64 decoding', () => { - const atob = vi.spyOn(globalThis, 'atob') - - expect(parseWebPairingInput('A'.repeat(PAIRING_INPUT_MAX_CHARACTERS + 1))).toBeNull() - expect(parseWebPairingInput('A'.repeat(PAIRING_CODE_MAX_CHARACTERS + 1))).toBeNull() - expect(atob).not.toHaveBeenCalled() - }) - - it.each([ - ['endpoint', PAIRING_ENDPOINT_MAX_CHARACTERS], - ['deviceToken', PAIRING_DEVICE_TOKEN_MAX_CHARACTERS], - ['publicKeyB64', PAIRING_PUBLIC_KEY_MAX_CHARACTERS] - ] as const)('accepts %s at its limit and rejects one extra character', (field, limit) => { - expect(parseWebPairingInput(encodeOffer({ [field]: 'x'.repeat(limit) }))).not.toBeNull() - expect(parseWebPairingInput(encodeOffer({ [field]: 'x'.repeat(limit + 1) }))).toBeNull() - }) - it('auto-saves scoped runtime offers during web startup', () => { const input = `orca://pair?code=${encodeOffer({ scope: 'runtime' })}` expect( diff --git a/src/renderer/src/web/web-pairing.ts b/src/renderer/src/web/web-pairing.ts index 165fe5ac8f4..f29465cc803 100644 --- a/src/renderer/src/web/web-pairing.ts +++ b/src/renderer/src/web/web-pairing.ts @@ -1,11 +1,4 @@ import type { DeviceScope } from '../../../shared/runtime-types' -import { - PAIRING_CODE_MAX_CHARACTERS, - PAIRING_DEVICE_TOKEN_MAX_CHARACTERS, - PAIRING_ENDPOINT_MAX_CHARACTERS, - PAIRING_INPUT_MAX_CHARACTERS, - PAIRING_PUBLIC_KEY_MAX_CHARACTERS -} from '../../../shared/mobile-relay-pairing-offer' const PAIRING_OFFER_VERSION = 2 @@ -23,9 +16,6 @@ export type WebPairingStartupDecision = | { kind: 'use-stored-environment' } export function parseWebPairingInput(input: string): WebPairingOffer | null { - if (input.length > PAIRING_INPUT_MAX_CHARACTERS) { - return null - } const trimmed = input.trim() if (!trimmed) { return null @@ -43,9 +33,6 @@ export function parseWebPairingInput(input: string): WebPairingOffer | null { } export function readPairingInputFromLocation(location: Location): string | null { - if (location.search.length + location.hash.length > PAIRING_INPUT_MAX_CHARACTERS) { - return null - } const search = new URLSearchParams(location.search) for (const key of ['pairing', 'pair', 'code', 'token']) { const value = search.get(key) @@ -98,26 +85,16 @@ export function clearPairingInputFromAddressBar(): void { } function decodePairingPayload(base64url: string): WebPairingOffer | null { - if ( - base64url.length === 0 || - base64url.length > PAIRING_CODE_MAX_CHARACTERS || - !/^[A-Za-z0-9+/_-]+={0,2}$/.test(base64url) - ) { - return null - } const json = new TextDecoder().decode(base64UrlToBytes(base64url)) const parsed = JSON.parse(json) as Partial if ( parsed.v !== PAIRING_OFFER_VERSION || typeof parsed.endpoint !== 'string' || parsed.endpoint.length === 0 || - parsed.endpoint.length > PAIRING_ENDPOINT_MAX_CHARACTERS || typeof parsed.deviceToken !== 'string' || parsed.deviceToken.length === 0 || - parsed.deviceToken.length > PAIRING_DEVICE_TOKEN_MAX_CHARACTERS || typeof parsed.publicKeyB64 !== 'string' || - parsed.publicKeyB64.length === 0 || - parsed.publicKeyB64.length > PAIRING_PUBLIC_KEY_MAX_CHARACTERS + parsed.publicKeyB64.length === 0 ) { return null } diff --git a/src/renderer/src/web/web-preload-api.test.ts b/src/renderer/src/web/web-preload-api.test.ts index 786b8222577..60f2f636f97 100644 --- a/src/renderer/src/web/web-preload-api.test.ts +++ b/src/renderer/src/web/web-preload-api.test.ts @@ -439,21 +439,6 @@ describe('web keybindings preload API', () => { unsubscribe() }) - - it('caps keybinding listeners and releases their storage handlers on reinstall', async () => { - const { api, window } = await installApi('Linux') - const { installWebPreloadApi, WEB_PRELOAD_MAX_KEYBINDING_LISTENERS } = - await import('./web-preload-api') - for (let index = 0; index < WEB_PRELOAD_MAX_KEYBINDING_LISTENERS; index += 1) { - api.keybindings.onChanged(vi.fn()) - } - - expect(() => api.keybindings.onChanged(vi.fn())).toThrow('listener capacity reached') - installWebPreloadApi() - - expect(window.removeEventListener).toHaveBeenCalledTimes(WEB_PRELOAD_MAX_KEYBINDING_LISTENERS) - expect(() => window.api.keybindings.onChanged(vi.fn())).not.toThrow() - }) }) describe('web settings preload API', () => { diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 122535e751e..526beea3bc9 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -108,9 +108,7 @@ import { } from './web-runtime-environment' import { parseWebPairingInput } from './web-pairing' import { WebRuntimeClient } from './web-runtime-client' -import { parseWebLocalStorageJson, stringifyWebLocalStorageJson } from './web-local-storage-json' import { RuntimeRpcCallQueuePool } from '../../../shared/runtime-rpc-call-queue' -import { mapWithConcurrency } from '../../../shared/map-with-concurrency' import { assertClipboardTextWriteWithinLimitWithYield, assertClipboardTextWithinLimitWithYield, @@ -125,7 +123,6 @@ import { assertClipboardImageDimensionsWithinLimit } from '../../../shared/clipboard-image' import { sanitizeWebRuntimeWorkspaceSession } from './web-workspace-session' -import { WebPreloadRequestOwners } from './web-preload-request-owners' import { normalizeFeatureInteractions, type FeatureInteractionId, @@ -148,14 +145,12 @@ const GITHUB_CACHE_STORAGE_KEY = 'orca.web.githubCache.v1' const KEYBINDINGS_STORAGE_KEY = 'orca.web.keybindings.v1' // Why: paired clients need parity for large dev sessions; the runtime default stays capped for lower-level RPC callers. const WEB_RUNTIME_WORKTREE_LIST_LIMIT = 10_000 -export const WEB_RUNTIME_REPO_DISCOVERY_CONCURRENCY = 8 const MAX_CLIPBOARD_IMAGE_BASE64_CHARS = CLIPBOARD_IMAGE_MAX_BASE64_CHARS export const MAX_CLIPBOARD_IMAGE_SOURCE_BYTES = CLIPBOARD_IMAGE_MAX_SOURCE_BYTES export const MAX_CLIPBOARD_IMAGE_PIXELS = CLIPBOARD_IMAGE_MAX_PIXELS export const CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS = 512 * 1024 export const CLIPBOARD_IMAGE_SINGLE_FRAME_FALLBACK_BASE64_CHARS = 256 * 1024 const CLIPBOARD_IMAGE_SAVE_TIMEOUT_MS = 30_000 -export const WEB_PRELOAD_MAX_KEYBINDING_LISTENERS = 64 let activeEnvironment: StoredWebRuntimeEnvironment | null = readStoredWebRuntimeEnvironment() let activeClient: WebRuntimeClient | null = null @@ -479,17 +474,9 @@ export const GITLAB_WEB_RPC_METHODS = { } as const satisfies Record const WEB_KEYBINDING_PLATFORMS: readonly KeybindingPlatform[] = ['darwin', 'linux', 'win32'] -type WebKeybindingSubscription = { - target: Window - onStorage: (event: StorageEvent) => void -} -const webKeybindingSubscriptions = new Map< - (snapshot: KeybindingFileSnapshot) => void, - WebKeybindingSubscription ->() +const webKeybindingListeners = new Set<(snapshot: KeybindingFileSnapshot) => void>() export function installWebPreloadApi(): void { - disposeWebPreloadOwnedState() activeEnvironment = readStoredWebRuntimeEnvironment() const webWindow = window as unknown as { __ORCA_WEB_CLIENT__?: boolean } webWindow.__ORCA_WEB_CLIENT__ = true @@ -1114,7 +1101,7 @@ function writeWebKeybindingAction( } function notifyWebKeybindingListeners(snapshot: KeybindingFileSnapshot): void { - for (const listener of webKeybindingSubscriptions.keys()) { + for (const listener of webKeybindingListeners) { listener(snapshot) } } @@ -1132,44 +1119,21 @@ function createWebKeybindingsApi(): WebKeybindingsApi { openFile: () => Promise.resolve(getWebKeybindingSnapshot()), revealFile: () => Promise.resolve(getWebKeybindingSnapshot()), onChanged: (callback) => { - const existing = webKeybindingSubscriptions.get(callback) - if (existing) { - return () => releaseWebKeybindingSubscription(callback, existing) - } - if (webKeybindingSubscriptions.size >= WEB_PRELOAD_MAX_KEYBINDING_LISTENERS) { - throw new Error('Web keybinding listener capacity reached; remove a listener and retry.') - } - const target = window + webKeybindingListeners.add(callback) const onStorage = (event: StorageEvent): void => { if (event.key === KEYBINDINGS_STORAGE_KEY) { callback(getWebKeybindingSnapshot()) } } - const subscription = { target, onStorage } - webKeybindingSubscriptions.set(callback, subscription) - target.addEventListener('storage', onStorage) - return () => releaseWebKeybindingSubscription(callback, subscription) + window.addEventListener('storage', onStorage) + return () => { + webKeybindingListeners.delete(callback) + window.removeEventListener('storage', onStorage) + } } } } -function releaseWebKeybindingSubscription( - callback: (snapshot: KeybindingFileSnapshot) => void, - expected: WebKeybindingSubscription -): void { - if (webKeybindingSubscriptions.get(callback) !== expected) { - return - } - webKeybindingSubscriptions.delete(callback) - expected.target.removeEventListener('storage', expected.onStorage) -} - -function disposeWebKeybindingSubscriptions(): void { - for (const [callback, subscription] of webKeybindingSubscriptions) { - releaseWebKeybindingSubscription(callback, subscription) - } -} - // Why: web has no IPC for native-chat transcripts, so route readSession/subscribe through runtime RPC (as mobile does). function createNativeChatApi(): NativeChatApi { return { @@ -1799,14 +1763,16 @@ function createFileApi(): NonNullable['fs']> { } // Why: track the in-flight abortable status request per token so cancelStatus can abort it and close its remote context. -const webGitStatusRequestOwners = new WebPreloadRequestOwners() +const webGitStatusAbortControllers = new Map() async function callAbortableRuntimeStatus( requestToken: string, params: unknown ): Promise { const environment = requireActiveEnvironment() - const controller = webGitStatusRequestOwners.replace(requestToken) + webGitStatusAbortControllers.get(requestToken)?.abort() + const controller = new AbortController() + webGitStatusAbortControllers.set(requestToken, controller) try { const response = await callAbortableRuntimeEnvironment( environment.id, @@ -1821,7 +1787,9 @@ async function callAbortableRuntimeStatus( } return response.result as TResult } finally { - webGitStatusRequestOwners.release(requestToken, controller) + if (webGitStatusAbortControllers.get(requestToken) === controller) { + webGitStatusAbortControllers.delete(requestToken) + } } } @@ -1848,7 +1816,7 @@ function createGitApi(): NonNullable['git']> { return callAbortableRuntimeStatus(requestToken, params) }, cancelStatus: async ({ requestToken }) => { - webGitStatusRequestOwners.abort(requestToken) + webGitStatusAbortControllers.get(requestToken)?.abort() }, submoduleStatus: async ({ worktreePath, submodulePath, area }) => { const worktree = await resolveRuntimeWorktreeByPath(worktreePath) @@ -3307,18 +3275,12 @@ function getClientForEnvironment(environment: StoredWebRuntimeEnvironment): WebR } function closeActiveRuntimeClients(): void { - webGitStatusRequestOwners.abortAll() activeClient?.close() activeClient = null activeClientEnvironmentId = null invalidateRuntimeWorktreeCaches() } -function disposeWebPreloadOwnedState(): void { - disposeWebKeybindingSubscriptions() - closeActiveRuntimeClients() -} - function disconnectActiveRuntimeEnvironment(): void { closeActiveRuntimeClients() clearStoredWebRuntimeEnvironment() @@ -3369,8 +3331,8 @@ function updateEnvironmentFromResponse( function getStoredSettings(): GlobalSettings { activeEnvironment = activeEnvironment ?? readStoredWebRuntimeEnvironment() const defaults = getDefaultSettings('~') - const storedResult = readJsonWithMetadata>(SETTINGS_STORAGE_KEY, {}) - const stored = storedResult.value + const rawStoredSettings = window.localStorage.getItem(SETTINGS_STORAGE_KEY) + const stored = readJson>(SETTINGS_STORAGE_KEY, {}) const migratedStored = { ...stored, ...normalizeAutoRenameBranchFromWorkDefaultOn(stored), @@ -3379,7 +3341,7 @@ function getStoredSettings(): GlobalSettings { uiLanguage: normalizeUiLanguage(stored.uiLanguage) } if ( - storedResult.parsedPlainObject && + rawStoredSettings && (stored.autoRenameBranchFromWork !== migratedStored.autoRenameBranchFromWork || stored.autoRenameBranchFromWorkDefaultedOn !== migratedStored.autoRenameBranchFromWorkDefaultedOn || @@ -3389,7 +3351,14 @@ function getStoredSettings(): GlobalSettings { stored.terminalCustomThemes !== migratedStored.terminalCustomThemes || stored.uiLanguage !== migratedStored.uiLanguage) ) { - writeJson(SETTINGS_STORAGE_KEY, migratedStored) + try { + const parsed = JSON.parse(rawStoredSettings) as unknown + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + writeJson(SETTINGS_STORAGE_KEY, migratedStored) + } + } catch { + // Keep readJson's invalid-JSON fallback non-destructive. + } } return mergeSettings( { @@ -3756,10 +3725,10 @@ async function listAllRuntimeDetectedWorktrees( assertActiveEnvironment(expectedEnvironmentId) const repos = (await callResult<{ repos: Repo[] }>('repo.list')).repos - const detectedLists = await mapWithConcurrency( - repos, - WEB_RUNTIME_REPO_DISCOVERY_CONCURRENCY, - (repo) => callRuntimeDetectedWorktrees(repo.id, expectedEnvironmentId, callResult, callEnvelope) + const detectedLists = await Promise.all( + repos.map((repo) => + callRuntimeDetectedWorktrees(repo.id, expectedEnvironmentId, callResult, callEnvelope) + ) ) const worktrees = detectedLists.flatMap((result) => result.worktrees) assertActiveEnvironment(expectedEnvironmentId) @@ -3958,34 +3927,23 @@ function getBrowserPlatform(): NodeJS.Platform { } function readJson(key: string, fallback: T): T { - return readJsonWithMetadata(key, fallback).value -} - -function readJsonWithMetadata( - key: string, - fallback: T -): { value: T; parsedPlainObject: boolean } { const raw = window.localStorage.getItem(key) if (!raw) { - return { value: cloneJson(fallback), parsedPlainObject: false } + return cloneJson(fallback) } try { - const parsed = parseWebLocalStorageJson(raw) - return { - value: { ...cloneJson(fallback), ...(parsed as object) } as T, - parsedPlainObject: parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed) - } + return { ...cloneJson(fallback), ...JSON.parse(raw) } as T } catch { - return { value: cloneJson(fallback), parsedPlainObject: false } + return cloneJson(fallback) } } function writeJson(key: string, value: T): void { - window.localStorage.setItem(key, stringifyWebLocalStorageJson(value)) + window.localStorage.setItem(key, JSON.stringify(value)) } function cloneJson(value: T): T { - return parseWebLocalStorageJson(stringifyWebLocalStorageJson(value)) + return JSON.parse(JSON.stringify(value)) as T } function withFallback(target: T, path: string[]): T { diff --git a/src/renderer/src/web/web-preload-request-owners.test.ts b/src/renderer/src/web/web-preload-request-owners.test.ts deleted file mode 100644 index 31be17cfa36..00000000000 --- a/src/renderer/src/web/web-preload-request-owners.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - WEB_PRELOAD_MAX_ABORTABLE_REQUESTS, - WEB_PRELOAD_MAX_REQUEST_TOKEN_BYTES, - WebPreloadRequestOwners -} from './web-preload-request-owners' - -describe('web preload request owners', () => { - it('caps unique request tokens and recovers after release', () => { - const owners = new WebPreloadRequestOwners() - const first = owners.replace('request-0') - for (let index = 1; index < WEB_PRELOAD_MAX_ABORTABLE_REQUESTS; index += 1) { - owners.replace(`request-${index}`) - } - - expect(() => owners.replace('overflow')).toThrow('capacity reached') - owners.release('request-0', first) - expect(() => owners.replace('recovered')).not.toThrow() - }) - - it('replaces same-token ownership without growing or letting stale cleanup win', () => { - const owners = new WebPreloadRequestOwners() - const first = owners.replace('stable') - const replacement = owners.replace('stable') - - expect(first.signal.aborted).toBe(true) - expect(owners.size()).toBe(1) - owners.release('stable', first) - expect(owners.size()).toBe(1) - owners.release('stable', replacement) - expect(owners.size()).toBe(0) - }) - - it('bounds multibyte request tokens before retaining them', () => { - const owners = new WebPreloadRequestOwners() - const exact = 'é'.repeat(WEB_PRELOAD_MAX_REQUEST_TOKEN_BYTES / 2) - - expect(() => owners.replace(exact)).not.toThrow() - expect(() => owners.replace(`${exact}x`)).toThrow( - `between 1 and ${WEB_PRELOAD_MAX_REQUEST_TOKEN_BYTES} UTF-8 bytes` - ) - }) - - it('aborts and releases every owner on teardown', () => { - const owners = new WebPreloadRequestOwners() - const first = owners.replace('first') - const second = owners.replace('second') - - owners.abortAll() - - expect(first.signal.aborted).toBe(true) - expect(second.signal.aborted).toBe(true) - expect(owners.size()).toBe(0) - }) -}) diff --git a/src/renderer/src/web/web-preload-request-owners.ts b/src/renderer/src/web/web-preload-request-owners.ts deleted file mode 100644 index c93e774f730..00000000000 --- a/src/renderer/src/web/web-preload-request-owners.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { measureUtf8ByteLength } from '../../../shared/utf8-byte-limits' - -export const WEB_PRELOAD_MAX_ABORTABLE_REQUESTS = 256 -export const WEB_PRELOAD_MAX_REQUEST_TOKEN_BYTES = 4 * 1024 - -export class WebPreloadRequestOwners { - private readonly controllers = new Map() - - replace(requestToken: string): AbortController { - const measured = measureUtf8ByteLength(requestToken, { - stopAfterBytes: WEB_PRELOAD_MAX_REQUEST_TOKEN_BYTES - }) - if (requestToken.length === 0 || measured.exceededLimit) { - throw new Error( - `Web request token must be between 1 and ${WEB_PRELOAD_MAX_REQUEST_TOKEN_BYTES} UTF-8 bytes.` - ) - } - const existing = this.controllers.get(requestToken) - if (!existing && this.controllers.size >= WEB_PRELOAD_MAX_ABORTABLE_REQUESTS) { - throw new Error('Web request capacity reached; wait for an active request to finish.') - } - existing?.abort() - const controller = new AbortController() - this.controllers.set(requestToken, controller) - return controller - } - - abort(requestToken: string): void { - this.controllers.get(requestToken)?.abort() - } - - release(requestToken: string, expected: AbortController): void { - if (this.controllers.get(requestToken) === expected) { - this.controllers.delete(requestToken) - } - } - - abortAll(): void { - const controllers = [...this.controllers.values()] - this.controllers.clear() - for (const controller of controllers) { - controller.abort() - } - } - - size(): number { - return this.controllers.size - } -} diff --git a/src/renderer/src/web/web-preload-runtime-discovery-concurrency.test.ts b/src/renderer/src/web/web-preload-runtime-discovery-concurrency.test.ts deleted file mode 100644 index 01fd3b02ebf..00000000000 --- a/src/renderer/src/web/web-preload-runtime-discovery-concurrency.test.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' - -class MemoryStorage implements Storage { - private readonly values = new Map() - - get length(): number { - return this.values.size - } - - clear(): void { - this.values.clear() - } - - getItem(key: string): string | null { - return this.values.get(key) ?? null - } - - key(index: number): string | null { - return Array.from(this.values.keys())[index] ?? null - } - - removeItem(key: string): void { - this.values.delete(key) - } - - setItem(key: string, value: string): void { - this.values.set(key, value) - } -} - -function installBrowserGlobals(): MemoryStorage { - const storage = new MemoryStorage() - vi.stubGlobal('window', { - localStorage: storage, - location: { protocol: 'http:', reload: vi.fn() }, - addEventListener: vi.fn(), - removeEventListener: vi.fn() - }) - vi.stubGlobal('navigator', { userAgent: 'Linux', hardwareConcurrency: 8 }) - return storage -} - -function writeStoredRuntimeEnvironment(storage: Storage): void { - storage.setItem( - 'orca.web.runtimeEnvironment.v1', - JSON.stringify({ - id: 'web-env-1', - name: 'Test runtime', - createdAt: 1, - updatedAt: 1, - lastUsedAt: null, - runtimeId: null, - preferredEndpointId: 'ws-web-env-1', - endpoints: [ - { - id: 'ws-web-env-1', - kind: 'websocket', - label: 'WebSocket', - endpoint: 'ws://127.0.0.1:1234', - deviceToken: 'token', - publicKeyB64: 'public-key' - } - ] - }) - ) -} - -describe('web runtime repository discovery concurrency', () => { - afterEach(() => { - vi.resetModules() - vi.unstubAllGlobals() - vi.doUnmock('./web-runtime-client') - }) - - it('resolves a 300-repository catalog without overflowing the shared RPC queue', async () => { - const repoCount = 300 - let activeDetectedCalls = 0 - let peakDetectedCalls = 0 - let detectedCallCount = 0 - - vi.doMock('./web-runtime-client', () => ({ - WebRuntimeClient: class { - call(method: string, params?: unknown): Promise> { - if (method === 'repo.list') { - return Promise.resolve({ - id: 'repo-list', - ok: true, - result: { - repos: Array.from({ length: repoCount }, (_, index) => ({ - id: `repo-${index}` - })) - }, - _meta: { runtimeId: 'runtime-1' } - }) - } - if (method === 'worktree.detectedList') { - const repoId = (params as { repo: string }).repo - const index = Number(repoId.slice('repo-'.length)) - activeDetectedCalls += 1 - detectedCallCount += 1 - peakDetectedCalls = Math.max(peakDetectedCalls, activeDetectedCalls) - return Promise.resolve({ - id: `detected-${index}`, - ok: true as const, - result: { - repoId, - authoritative: true, - worktrees: [ - { - id: `worktree-${index}`, - repoId, - path: `/workspace/repo-${index}` - } - ] - }, - _meta: { runtimeId: 'runtime-1' } - }).finally(() => { - activeDetectedCalls -= 1 - }) - } - if (method === 'files.stat') { - return Promise.resolve({ - id: 'file-stat', - ok: true, - result: { size: 1 }, - _meta: { runtimeId: 'runtime-1' } - }) - } - throw new Error(`Unexpected method: ${method}`) - } - - close(): void {} - } - })) - - const storage = installBrowserGlobals() - writeStoredRuntimeEnvironment(storage) - const { installWebPreloadApi, WEB_RUNTIME_REPO_DISCOVERY_CONCURRENCY } = - await import('./web-preload-api') - installWebPreloadApi() - - await expect( - window.api.fs.pathExists({ filePath: '/workspace/repo-299/file.txt' }) - ).resolves.toBe(true) - expect(detectedCallCount).toBe(repoCount) - expect(peakDetectedCalls).toBe(WEB_RUNTIME_REPO_DISCOVERY_CONCURRENCY) - }) -}) diff --git a/src/renderer/src/web/web-runtime-client-memory-bounds.test.ts b/src/renderer/src/web/web-runtime-client-memory-bounds.test.ts deleted file mode 100644 index 8f82389abc5..00000000000 --- a/src/renderer/src/web/web-runtime-client-memory-bounds.test.ts +++ /dev/null @@ -1,290 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { - WEB_RUNTIME_MAX_BINARY_FRAME_BYTES, - WEB_RUNTIME_MAX_CHILD_CLIENTS, - WEB_RUNTIME_MAX_CONNECTION_WAITERS, - WEB_RUNTIME_MAX_OUTBOUND_BINARY_FRAME_BYTES, - WEB_RUNTIME_MAX_PENDING_REQUESTS, - WEB_RUNTIME_MAX_RPC_METHOD_BYTES, - WEB_RUNTIME_MAX_SUBSCRIPTION_PARAM_BYTES, - WEB_RUNTIME_MAX_SUBSCRIPTIONS, - WebRuntimeClient -} from './web-runtime-client' -import { createWebRuntimeOutboundMemoryBudget } from './web-runtime-outbound-memory-budget' - -class FakeWebSocket { - static readonly CONNECTING = 0 - static readonly OPEN = 1 - readyState = FakeWebSocket.CONNECTING - bufferedAmount = 0 - binaryType = 'arraybuffer' - onopen: (() => void) | null = null - onmessage: ((event: { data: unknown }) => void) | null = null - onclose: (() => void) | null = null - onerror: (() => void) | null = null - close = vi.fn() - send = vi.fn() -} - -function createClient(): WebRuntimeClient { - return new WebRuntimeClient({ - v: 2, - endpoint: 'ws://127.0.0.1:6768', - deviceToken: 'token', - publicKeyB64: Buffer.alloc(32).toString('base64') - }) -} - -function acceptedOutboundSend(): { accepted: true; queued: false; cancel: () => false } { - return { accepted: true, queued: false, cancel: () => false } -} - -describe('WebRuntimeClient memory admission', () => { - beforeEach(() => { - vi.stubGlobal('window', { - setTimeout, - clearTimeout, - setInterval, - clearInterval, - atob: (value: string) => Buffer.from(value, 'base64').toString('binary'), - btoa: (value: string) => Buffer.from(value, 'binary').toString('base64') - }) - vi.stubGlobal('WebSocket', FakeWebSocket) - }) - - afterEach(() => { - vi.unstubAllGlobals() - }) - - it('rejects connection fan-out after the waiter cap', async () => { - const client = createClient() - const waiting = Array.from({ length: WEB_RUNTIME_MAX_CONNECTION_WAITERS }, () => - client.call('status.get').catch((error: unknown) => error) - ) - - await expect(client.call('status.get')).rejects.toThrow('client is busy') - - client.close() - await Promise.all(waiting) - }) - - it('rejects request fan-out after the connected pending cap', async () => { - const client = createClient() - const internals = client as unknown as { - state: string - sendEncryptedSerialized: (serialized: string) => ReturnType - pending: Map - } - internals.state = 'connected' - vi.spyOn(internals, 'sendEncryptedSerialized').mockReturnValue(acceptedOutboundSend()) - const pending = Array.from({ length: WEB_RUNTIME_MAX_PENDING_REQUESTS }, () => - client.call('status.get').catch((error: unknown) => error) - ) - await vi.waitFor(() => expect(internals.pending.size).toBe(WEB_RUNTIME_MAX_PENDING_REQUESTS)) - - await expect(client.call('status.get')).rejects.toThrow('client is busy') - - client.close() - await Promise.all(pending) - }) - - it('caps active subscription records and child sockets', async () => { - const client = createClient() - const internals = client as unknown as { - state: string - subscriptions: Map - childClients: Set<{ close: () => void }> - subscribeOnCurrentConnection: ( - method: string, - params: unknown, - callbacks: { onResponse: () => void } - ) => Promise - } - internals.state = 'connected' - for (let index = 0; index < WEB_RUNTIME_MAX_SUBSCRIPTIONS; index++) { - internals.subscriptions.set(`subscription-${index}`, {}) - } - - await expect( - internals.subscribeOnCurrentConnection('files.watch', {}, { onResponse: vi.fn() }) - ).rejects.toThrow('client is busy') - - internals.subscriptions.clear() - for (let index = 0; index < WEB_RUNTIME_MAX_CHILD_CLIENTS; index++) { - internals.childClients.add({ close: vi.fn() }) - } - await expect( - client.subscribe('terminal.multiplex', {}, { onResponse: vi.fn() }) - ).rejects.toThrow('client is busy') - - client.close() - }) - - it('rejects an oversized Blob before copying it into an ArrayBuffer', async () => { - const client = createClient() - const onBinary = vi.fn() - const arrayBuffer = vi.fn() - const oversizedBlob = Object.create(Blob.prototype) as Blob - Object.defineProperties(oversizedBlob, { - size: { value: WEB_RUNTIME_MAX_BINARY_FRAME_BYTES + 1 }, - arrayBuffer: { value: arrayBuffer } - }) - const internals = client as unknown as { - state: string - sharedKey: Uint8Array - subscriptions: Map - handleSocketMessage: (rawData: unknown) => Promise - } - internals.state = 'connected' - internals.sharedKey = new Uint8Array(32) - internals.subscriptions.set('stream-1', { callbacks: { onBinary } }) - - await internals.handleSocketMessage(oversizedBlob) - - expect(arrayBuffer).not.toHaveBeenCalled() - expect(onBinary).not.toHaveBeenCalled() - client.close() - }) - - it('bounds RPC method keys before they enter connection waiters', async () => { - const client = createClient() - const internals = client as unknown as { waiters: unknown[] } - - await expect(client.call('x'.repeat(WEB_RUNTIME_MAX_RPC_METHOD_BYTES + 1))).rejects.toThrow( - 'RPC method exceeds' - ) - expect(internals.waiters).toHaveLength(0) - client.close() - }) - - it('accepts exact subscription parameters and releases their retained bytes', async () => { - const client = createClient() - const internals = client as unknown as { - state: string - sendEncryptedSerialized: (serialized: string) => ReturnType - subscriptions: Map - subscribeOnCurrentConnection: ( - method: string, - params: unknown, - callbacks: { onResponse: () => void } - ) => Promise<{ unsubscribe: () => void }> - } - internals.state = 'connected' - vi.spyOn(internals, 'sendEncryptedSerialized').mockReturnValue(acceptedOutboundSend()) - const handle = await internals.subscribeOnCurrentConnection( - 'files.watch', - 'x'.repeat(WEB_RUNTIME_MAX_SUBSCRIPTION_PARAM_BYTES - 2), - { onResponse: vi.fn() } - ) - - expect(internals.subscriptions.size).toBe(1) - handle.unsubscribe() - expect(internals.subscriptions.size).toBe(0) - await expect( - internals.subscribeOnCurrentConnection( - 'files.watch', - 'x'.repeat(WEB_RUNTIME_MAX_SUBSCRIPTION_PARAM_BYTES - 1), - { onResponse: vi.fn() } - ) - ).rejects.toThrow('JSON payload exceeds') - expect(internals.subscriptions.size).toBe(0) - client.close() - }) - - it('does not retain a subscription when parameter serialization throws', async () => { - const client = createClient() - const cyclic: { self?: unknown } = {} - cyclic.self = cyclic - const internals = client as unknown as { - subscriptions: Map - subscribeOnCurrentConnection: ( - method: string, - params: unknown, - callbacks: { onResponse: () => void } - ) => Promise - } - - await expect( - internals.subscribeOnCurrentConnection('files.watch', cyclic, { onResponse: vi.fn() }) - ).rejects.toThrow('circular') - expect(internals.subscriptions.size).toBe(0) - client.close() - }) - - it('releases a subscription when encryption fails after admission', async () => { - const client = createClient() - const internals = client as unknown as { - state: string - sharedKey: Uint8Array - ws: FakeWebSocket - subscriptions: Map - subscribeOnCurrentConnection: ( - method: string, - params: unknown, - callbacks: { onResponse: () => void } - ) => Promise - } - internals.state = 'connected' - internals.sharedKey = new Uint8Array(32) - internals.ws.readyState = FakeWebSocket.OPEN - const runtimeWindow = window as unknown as { btoa: (value: string) => string } - runtimeWindow.btoa = () => { - throw new Error('encryption allocation failed') - } - - await expect( - internals.subscribeOnCurrentConnection('files.watch', {}, { onResponse: vi.fn() }) - ).rejects.toThrow('could not send the subscription') - expect(internals.subscriptions.size).toBe(0) - client.close() - }) - - it('closes an overloaded socket after the aggregate queued-frame cap', () => { - const budget = createWebRuntimeOutboundMemoryBudget({ - maxBufferedBytes: 1, - maxQueuedBytes: 1_024, - maxQueuedFrames: 2 - }) - const client = new WebRuntimeClient( - { - v: 2, - endpoint: 'ws://127.0.0.1:6768', - deviceToken: 'token', - publicKeyB64: Buffer.alloc(32).toString('base64') - }, - budget - ) - const internals = client as unknown as { - state: string - sharedKey: Uint8Array - ws: FakeWebSocket - sendEncrypted: (message: unknown) => boolean - } - internals.state = 'connected' - internals.sharedKey = new Uint8Array(32) - internals.ws.readyState = FakeWebSocket.OPEN - internals.ws.bufferedAmount = 1 - - expect(internals.sendEncrypted({ value: 1 })).toBe(true) - expect(internals.sendEncrypted({ value: 2 })).toBe(true) - expect(internals.sendEncrypted({ value: 3 })).toBe(false) - expect(internals.ws).toBeNull() - }) - - it('closes before encrypting an oversized outbound binary frame', () => { - const client = createClient() - const internals = client as unknown as { - ws: FakeWebSocket | null - sendEncryptedBinary: (bytes: Uint8Array) => boolean - } - const socket = internals.ws! - socket.readyState = FakeWebSocket.OPEN - const oversized = { - byteLength: WEB_RUNTIME_MAX_OUTBOUND_BINARY_FRAME_BYTES + 1 - } as Uint8Array - - expect(internals.sendEncryptedBinary(oversized)).toBe(false) - expect(socket.close).toHaveBeenCalledTimes(1) - expect(internals.ws).toBeNull() - }) -}) diff --git a/src/renderer/src/web/web-runtime-client.test.ts b/src/renderer/src/web/web-runtime-client.test.ts index 8b6ba6fe8fe..4a15fcce2ef 100644 --- a/src/renderer/src/web/web-runtime-client.test.ts +++ b/src/renderer/src/web/web-runtime-client.test.ts @@ -12,18 +12,6 @@ import { } from '../../../shared/e2ee-crypto' import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' -type TestSubscriptionCallbacks = { - onResponse: (response: RuntimeRpcResponse) => void -} - -type SubscribePreparedForTest = ( - method: string, - preparedInput: unknown, - releaseRetainedBytes: () => void, - callbacks: TestSubscriptionCallbacks, - options?: unknown -) => Promise<{ unsubscribe: () => void; sendBinary: (bytes: Uint8Array) => void }> - const fakeSockets: FakeWebSocket[] = [] class FakeWebSocket { @@ -103,31 +91,6 @@ describe('WebRuntimeClient', () => { expect(child.close).toHaveBeenCalledWith({ notifySubscriptions: false }) }) - it('closes the socket when handshake JSON exceeds the nesting cap', async () => { - const client = new WebRuntimeClient({ - v: 2, - endpoint: 'ws://127.0.0.1:6768', - deviceToken: 'token', - publicKeyB64: Buffer.alloc(32).toString('base64') - }) - const socket = fakeSockets[0]! - const internals = client as unknown as { - state: 'handshaking' - sharedKey: Uint8Array - handleSocketMessage: (rawData: unknown, sourceWs?: WebSocket) => Promise - } - internals.state = 'handshaking' - internals.sharedKey = new Uint8Array(32) - - await internals.handleSocketMessage( - `${'['.repeat(129)}0${']'.repeat(129)}`, - socket as unknown as WebSocket - ) - - expect(socket.close).toHaveBeenCalledOnce() - client.close() - }) - it('does not report locally closed subscriptions as remote closes', () => { const client = new WebRuntimeClient({ v: 2, @@ -295,24 +258,23 @@ describe('WebRuntimeClient', () => { const handle = { unsubscribe: vi.fn(), sendBinary: vi.fn() } const internals = client as unknown as { childClients: Set - subscribePreparedOnCurrentConnection: SubscribePreparedForTest + subscribeOnCurrentConnection: WebRuntimeClient['subscribe'] } - const subscribePrepared = vi - .spyOn(internals, 'subscribePreparedOnCurrentConnection') + const subscribeOnCurrentConnection = vi + .spyOn(internals, 'subscribeOnCurrentConnection') .mockResolvedValue(handle) const onResponse = vi.fn() const subscription = await client.subscribe('files.watch', { worktree: 'wt-1' }, { onResponse }) - expect(subscribePrepared).toHaveBeenCalledWith( + expect(subscribeOnCurrentConnection).toHaveBeenCalledWith( 'files.watch', - expect.objectContaining({ paramsByteLength: 19, worktree: 'wt-1' }), - expect.any(Function), + { worktree: 'wt-1' }, expect.objectContaining({ onResponse: expect.any(Function) }), undefined ) expect(internals.childClients.size).toBe(0) - subscribePrepared.mock.calls[0]?.[3].onResponse({ + subscribeOnCurrentConnection.mock.calls[0]?.[2].onResponse({ id: 'watch', ok: true, streaming: true, @@ -335,10 +297,10 @@ describe('WebRuntimeClient', () => { }) const localHandle = { unsubscribe: vi.fn(), sendBinary: vi.fn() } const internals = client as unknown as { - subscribePreparedOnCurrentConnection: SubscribePreparedForTest + subscribeOnCurrentConnection: WebRuntimeClient['subscribe'] } - const subscribePrepared = vi - .spyOn(internals, 'subscribePreparedOnCurrentConnection') + const subscribeOnCurrentConnection = vi + .spyOn(internals, 'subscribeOnCurrentConnection') .mockResolvedValue(localHandle) const unwatch = vi.spyOn(client, 'call').mockImplementation(() => { expect(localHandle.unsubscribe).not.toHaveBeenCalled() @@ -352,7 +314,7 @@ describe('WebRuntimeClient', () => { const onResponse = vi.fn() const subscription = await client.subscribe('files.watch', { worktree: 'wt-1' }, { onResponse }) - const wrappedCallbacks = subscribePrepared.mock.calls[0]?.[3] + const wrappedCallbacks = subscribeOnCurrentConnection.mock.calls[0]?.[2] wrappedCallbacks?.onResponse({ id: 'watch', ok: true, @@ -382,10 +344,10 @@ describe('WebRuntimeClient', () => { }) const localHandle = { unsubscribe: vi.fn(), sendBinary: vi.fn() } const internals = client as unknown as { - subscribePreparedOnCurrentConnection: SubscribePreparedForTest + subscribeOnCurrentConnection: WebRuntimeClient['subscribe'] } - const subscribePrepared = vi - .spyOn(internals, 'subscribePreparedOnCurrentConnection') + const subscribeOnCurrentConnection = vi + .spyOn(internals, 'subscribeOnCurrentConnection') .mockResolvedValue(localHandle) const unwatch = vi .spyOn(client, 'call') @@ -408,7 +370,7 @@ describe('WebRuntimeClient', () => { { worktree: 'wt-1' }, { onResponse: vi.fn() } ) - const wrappedCallbacks = subscribePrepared.mock.calls[0]?.[3] + const wrappedCallbacks = subscribeOnCurrentConnection.mock.calls[0]?.[2] wrappedCallbacks?.onResponse({ id: 'watch', ok: true, @@ -434,7 +396,7 @@ describe('WebRuntimeClient', () => { await client.subscribe('files.watch', { worktree: 'wt-1' }, { onResponse: vi.fn() }) expect(unwatch).toHaveBeenCalledTimes(2) expect(localHandle.unsubscribe).toHaveBeenCalledTimes(1) - expect(subscribePrepared).toHaveBeenCalledTimes(2) + expect(subscribeOnCurrentConnection).toHaveBeenCalledTimes(2) } finally { client.close() warn.mockRestore() @@ -453,10 +415,10 @@ describe('WebRuntimeClient', () => { sendBinary: vi.fn() })) const internals = client as unknown as { - subscribePreparedOnCurrentConnection: SubscribePreparedForTest + subscribeOnCurrentConnection: WebRuntimeClient['subscribe'] } - const subscribePrepared = vi - .spyOn(internals, 'subscribePreparedOnCurrentConnection') + const subscribeOnCurrentConnection = vi + .spyOn(internals, 'subscribeOnCurrentConnection') .mockResolvedValueOnce(handles[0]) .mockResolvedValueOnce(handles[1]) .mockResolvedValueOnce(handles[2]) @@ -483,7 +445,7 @@ describe('WebRuntimeClient', () => { { onResponse: vi.fn() } ) for (const [index, subscriptionId] of ['watch-a', 'watch-b'].entries()) { - subscribePrepared.mock.calls[index]?.[3].onResponse({ + subscribeOnCurrentConnection.mock.calls[index]?.[2].onResponse({ id: subscriptionId, ok: true, streaming: true, @@ -500,7 +462,7 @@ describe('WebRuntimeClient', () => { expect(unwatch).toHaveBeenCalledTimes(4) expect(handles[0].unsubscribe).toHaveBeenCalledTimes(1) expect(handles[1].unsubscribe).toHaveBeenCalledTimes(1) - expect(subscribePrepared).toHaveBeenCalledTimes(3) + expect(subscribeOnCurrentConnection).toHaveBeenCalledTimes(3) } finally { client.close() warn.mockRestore() @@ -516,10 +478,10 @@ describe('WebRuntimeClient', () => { }) const localHandle = { unsubscribe: vi.fn(), sendBinary: vi.fn() } const internals = client as unknown as { - subscribePreparedOnCurrentConnection: SubscribePreparedForTest + subscribeOnCurrentConnection: WebRuntimeClient['subscribe'] } - const subscribePrepared = vi - .spyOn(internals, 'subscribePreparedOnCurrentConnection') + const subscribeOnCurrentConnection = vi + .spyOn(internals, 'subscribeOnCurrentConnection') .mockResolvedValue(localHandle) const unwatch = vi.spyOn(client, 'call').mockResolvedValue({ id: 'unwatch', @@ -530,7 +492,7 @@ describe('WebRuntimeClient', () => { const onResponse = vi.fn() const subscription = await client.subscribe('files.watch', { worktree: 'wt-1' }, { onResponse }) - const wrappedCallbacks = subscribePrepared.mock.calls[0]?.[3] + const wrappedCallbacks = subscribeOnCurrentConnection.mock.calls[0]?.[2] subscription.unsubscribe() expect(unwatch).not.toHaveBeenCalled() @@ -570,10 +532,10 @@ describe('WebRuntimeClient', () => { }) const localHandle = { unsubscribe: vi.fn(), sendBinary: vi.fn() } const internals = client as unknown as { - subscribePreparedOnCurrentConnection: SubscribePreparedForTest + subscribeOnCurrentConnection: WebRuntimeClient['subscribe'] } - const subscribePrepared = vi - .spyOn(internals, 'subscribePreparedOnCurrentConnection') + const subscribeOnCurrentConnection = vi + .spyOn(internals, 'subscribeOnCurrentConnection') .mockResolvedValue(localHandle) const unwatch = vi.spyOn(client, 'call').mockResolvedValue({ id: 'unwatch', @@ -596,7 +558,7 @@ describe('WebRuntimeClient', () => { await vi.advanceTimersByTimeAsync(300_000) expect(localHandle.unsubscribe).not.toHaveBeenCalled() - const wrappedCallbacks = subscribePrepared.mock.calls[0]?.[3] + const wrappedCallbacks = subscribeOnCurrentConnection.mock.calls[0]?.[2] wrappedCallbacks?.onResponse({ id: 'watch', ok: true, @@ -731,11 +693,6 @@ describe('WebRuntimeClient', () => { const internals = client as unknown as { waitForConnected: (timeoutMs?: number) => Promise sendEncrypted: (message: unknown) => boolean - sendEncryptedSerialized: (serialized: string) => { - accepted: boolean - queued: boolean - cancel: () => boolean - } subscribeOnCurrentConnection: ( method: string, params: unknown, @@ -744,11 +701,6 @@ describe('WebRuntimeClient', () => { ) => Promise<{ unsubscribe: () => void }> } vi.spyOn(internals, 'waitForConnected').mockResolvedValue(undefined) - vi.spyOn(internals, 'sendEncryptedSerialized').mockReturnValue({ - accepted: true, - queued: false, - cancel: () => false - }) const sent: unknown[] = [] vi.spyOn(internals, 'sendEncrypted').mockImplementation((message) => { sent.push(message) diff --git a/src/renderer/src/web/web-runtime-client.ts b/src/renderer/src/web/web-runtime-client.ts index 5553cf8c603..70574350400 100644 --- a/src/renderer/src/web/web-runtime-client.ts +++ b/src/renderer/src/web/web-runtime-client.ts @@ -1,13 +1,6 @@ /* eslint-disable max-lines -- Why: one transport boundary — E2EE WebSocket state machine, JSON-RPC routing, streaming, binary frame forwarding. */ import type { RuntimeRpcResponse, RuntimeRpcSuccess } from '../../../shared/runtime-rpc-envelope' import { isKeepaliveFrame } from '../../../shared/runtime-rpc-envelope' -import { measureUtf8ByteLength } from '../../../shared/utf8-byte-limits' -import { MAX_E2EE_ENCRYPTED_BASE64_CHARACTERS } from '../../../shared/e2ee-crypto' -import { - createWsOutboundBackpressureQueue, - type WsOutboundEnqueueResult, - type WsOutboundBackpressureQueue -} from '../../../shared/ws-outbound-backpressure-queue' import type { WebPairingOffer } from './web-pairing' import { installWindowVisibilityInterval } from '../lib/window-visibility-interval' import { withRemoteRuntimeTailscaleHint } from '../../../shared/remote-runtime-tailscale-hint' @@ -21,21 +14,6 @@ import { publicKeyFromBase64, publicKeyToBase64 } from './web-e2ee' -import { - createWebRuntimeOutboundMemoryBudget, - WEB_RUNTIME_OUTBOUND_MAX_QUEUED_BYTES, - WEB_RUNTIME_OUTBOUND_MAX_QUEUED_FRAMES, - type WebRuntimeOutboundMemoryBudget, - type WebRuntimeOutboundSocketMemory -} from './web-runtime-outbound-memory-budget' -import { - stringifyWebRuntimeOutboundJson, - WebRuntimeOutboundJsonLimitError -} from './web-runtime-outbound-json' -import { - isWebRuntimeJsonStructureCapacityError, - parseWebRuntimeInboundJson -} from './web-runtime-inbound-json' type WebRuntimeConnectionState = | 'disconnected' @@ -45,7 +23,6 @@ type WebRuntimeConnectionState = | 'auth-failed' type PendingRequest = { - cancelQueuedFrame: () => boolean method: string resolve: (response: RuntimeRpcResponse) => void reject: (error: Error) => void @@ -64,25 +41,9 @@ type SubscriptionCallbacks = { type RuntimeSubscription = { id: string method: string - paramsJson: string | undefined - paramsByteLength: number + params: unknown callbacks: SubscriptionCallbacks needsReplay: boolean - releaseRetainedBytes: () => void - cancelQueuedFrame: () => boolean -} - -type PreparedSubscriptionInput = { - paramsJson: string | undefined - paramsByteLength: number - retainedBytes: number - teardownKey: string - worktree: string -} - -type WebRuntimeOutboundFrame = { - bytes: number - payload: string | Uint8Array } export type WebRuntimeSubscriptionHandle = { @@ -105,29 +66,6 @@ const SHARED_CONNECTION_SUBSCRIPTION_METHODS = new Set(['files.watch']) const HEARTBEAT_INTERVAL_MS = 10_000 const HEARTBEAT_IDLE_MS = 25_000 const HEARTBEAT_PROBE_GRACE_MS = 20_000 -export const WEB_RUNTIME_MAX_CONNECTION_WAITERS = 256 -export const WEB_RUNTIME_MAX_PENDING_REQUESTS = 256 -export const WEB_RUNTIME_MAX_SUBSCRIPTIONS = 256 -export const WEB_RUNTIME_MAX_CHILD_CLIENTS = 64 -export const WEB_RUNTIME_MAX_BINARY_FRAME_BYTES = 64 * 1024 * 1024 -export const WEB_RUNTIME_MAX_ENCRYPTED_TEXT_FRAME_BYTES = MAX_E2EE_ENCRYPTED_BASE64_CHARACTERS -export const WEB_RUNTIME_MAX_OUTBOUND_JSON_BYTES = 4 * 1024 * 1024 -export const WEB_RUNTIME_MAX_SUBSCRIPTION_PARAM_BYTES = 1024 * 1024 -export const WEB_RUNTIME_MAX_RPC_METHOD_BYTES = 8 * 1024 -export const WEB_RUNTIME_MAX_OUTBOUND_BINARY_FRAME_BYTES = 8 * 1024 * 1024 - -const WEB_RUNTIME_OUTBOUND_SOCKET_SOFT_CAP_BYTES = 8 * 1024 * 1024 -const WEB_RUNTIME_MAX_OUTBOUND_WIRE_FRAME_BYTES = WEB_RUNTIME_MAX_OUTBOUND_BINARY_FRAME_BYTES + 64 - -const WEB_RUNTIME_BUSY_MESSAGE = 'Remote Orca runtime client is busy; retry after requests finish.' -const RPC_PARAMS_MEMBER_PREFIX = ',"params":' -const cancelNothing = (): boolean => false -const releaseNothing = (): void => undefined -const REJECTED_OUTBOUND_ENQUEUE: WsOutboundEnqueueResult = { - accepted: false, - queued: false, - cancel: cancelNothing -} export class WebRuntimeClient { private ws: WebSocket | null = null @@ -151,15 +89,8 @@ export class WebRuntimeClient { private readonly childClients = new Set() private readonly waiters: { resolve: () => void; reject: (error: Error) => void }[] = [] private readonly serverPublicKey: Uint8Array - private outboundQueue: WsOutboundBackpressureQueue | null = null - private outboundSocketMemory: WebRuntimeOutboundSocketMemory | null = null - private activeCallAdmissions = 0 - private pendingSubscriptionAdmissions = 0 - constructor( - private readonly pairing: WebPairingOffer, - private readonly outboundMemoryBudget: WebRuntimeOutboundMemoryBudget = createWebRuntimeOutboundMemoryBudget() - ) { + constructor(private readonly pairing: WebPairingOffer) { this.serverPublicKey = publicKeyFromBase64(pairing.publicKeyB64) this.openConnection() } @@ -169,66 +100,21 @@ export class WebRuntimeClient { params?: unknown, options?: { timeoutMs?: number } ): Promise> { - assertRpcMethodWithinLimit(method) - const releaseCallAdmission = this.claimCallAdmission() - let releasePreparedBytes: (() => void) | null = null - let serialized: string | undefined - try { + await this.waitForConnected(options?.timeoutMs) + return new Promise((resolve, reject) => { const id = this.nextId() - serialized = stringifyWebRuntimeOutboundJson( - { id, deviceToken: this.pairing.deviceToken, method, params }, - WEB_RUNTIME_MAX_OUTBOUND_JSON_BYTES - ).serialized - params = undefined - releasePreparedBytes = this.outboundMemoryBudget.claimPreparedRpcBytes( - retainedPreparedFrameBytes(serialized, method) - ) - if (!releasePreparedBytes) { - throw new Error(WEB_RUNTIME_BUSY_MESSAGE) - } - await this.waitForConnected(options?.timeoutMs) - return await new Promise((resolve, reject) => { - if (this.pending.size >= WEB_RUNTIME_MAX_PENDING_REQUESTS) { - reject(new Error(WEB_RUNTIME_BUSY_MESSAGE)) - return - } - const timeoutMs = options?.timeoutMs ?? REQUEST_TIMEOUT_MS - const pending: PendingRequest = { - cancelQueuedFrame: cancelNothing, - method, - resolve, - reject, - timeout: 0 - } - const timeout = window.setTimeout(() => { - if (this.pending.get(id) !== pending) { - return - } - pending.cancelQueuedFrame() - this.pending.delete(id) - reject(new Error(`Request timed out: ${method}`)) - }, timeoutMs) - pending.timeout = timeout - this.pending.set(id, pending) - const sent = serialized - ? this.sendEncryptedSerialized(serialized) - : REJECTED_OUTBOUND_ENQUEUE - serialized = undefined - pending.cancelQueuedFrame = sent.cancel - releasePreparedBytes?.() - releasePreparedBytes = null - if (sent.accepted) { - return - } + const timeoutMs = options?.timeoutMs ?? REQUEST_TIMEOUT_MS + const timeout = window.setTimeout(() => { + this.pending.delete(id) + reject(new Error(`Request timed out: ${method}`)) + }, timeoutMs) + this.pending.set(id, { method, resolve, reject, timeout }) + if (!this.sendEncrypted({ id, deviceToken: this.pairing.deviceToken, method, params })) { this.pending.delete(id) window.clearTimeout(timeout) - reject(new Error('Remote Orca runtime could not send the request.')) - }) - } finally { - serialized = undefined - releasePreparedBytes?.() - releaseCallAdmission() - } + reject(new Error('Remote Orca runtime is not connected.')) + } + }) } async subscribe( @@ -237,97 +123,56 @@ export class WebRuntimeClient { callbacks: SubscriptionCallbacks, options?: SubscribeOptions ): Promise { - assertRpcMethodWithinLimit(method) - const sharedConnection = SHARED_CONNECTION_SUBSCRIPTION_METHODS.has(method) - if (!sharedConnection && this.childClients.size >= WEB_RUNTIME_MAX_CHILD_CLIENTS) { - throw new Error(WEB_RUNTIME_BUSY_MESSAGE) + if (SHARED_CONNECTION_SUBSCRIPTION_METHODS.has(method)) { + // Why: sharing the main socket for file watches avoids exhausting the server's WebSocket connection cap. + return this.subscribeSharedFileWatch(params, callbacks, options) + } + const client = new WebRuntimeClient(this.pairing) + this.childClients.add(client) + const closeChild = (notifySubscriptions = false): void => { + this.childClients.delete(client) + client.close({ notifySubscriptions }) } - const releaseAdmission = this.claimSubscriptionAdmission() - let releaseRetainedBytes: (() => void) | null = null - let ownershipTransferred = false try { - const preparedInput = prepareSubscriptionInput(method, params) - params = undefined - releaseRetainedBytes = this.outboundMemoryBudget.claimSubscriptionBytes( - preparedInput.retainedBytes - ) - if (!releaseRetainedBytes) { - throw new Error(WEB_RUNTIME_BUSY_MESSAGE) - } - if (sharedConnection) { - // Why: sharing the main socket for file watches avoids exhausting the server's WebSocket connection cap. - const handle = await this.subscribeSharedFileWatch( - preparedInput, - releaseRetainedBytes, - callbacks, - options - ) - ownershipTransferred = true - return handle - } - const client = new WebRuntimeClient(this.pairing, this.outboundMemoryBudget) - this.childClients.add(client) - const closeChild = (notifySubscriptions = false): void => { - this.childClients.delete(client) - client.close({ notifySubscriptions }) - } const wrappedCallbacks: SubscriptionCallbacks = { ...callbacks, onError: (error) => { + callbacks.onError?.(error) closeChild() - invokeConsumerCallback(() => callbacks.onError?.(error)) }, onClose: () => { + callbacks.onClose?.() closeChild() - invokeConsumerCallback(() => callbacks.onClose?.()) } } - let handle: WebRuntimeSubscriptionHandle - try { - handle = await client.subscribePreparedOnCurrentConnection( - method, - preparedInput, - releaseRetainedBytes, - wrappedCallbacks, - options - ) - } catch (error) { - closeChild() - throw error - } - ownershipTransferred = true - preparedInput.paramsJson = undefined - preparedInput.teardownKey = '' + const handle = await client.subscribeOnCurrentConnection( + method, + params, + wrappedCallbacks, + options + ) return { unsubscribe: () => { // Why: emit the teardown RPC before closing the child socket so the server reaps the fs-watcher on view-toggle. - try { - handle.unsubscribe() - } finally { - closeChild() - } + handle.unsubscribe() + closeChild() }, sendBinary: (bytes) => handle.sendBinary(bytes) } - } finally { - if (!ownershipTransferred) { - releaseRetainedBytes?.() - } - releaseAdmission() + } catch (error) { + closeChild() + throw error } } private async subscribeSharedFileWatch( - preparedInput: PreparedSubscriptionInput, - releaseRetainedBytes: () => void, + params: unknown, callbacks: SubscriptionCallbacks, options?: { timeoutMs?: number } ): Promise { - const initialTeardownKey = preparedInput.teardownKey - let teardownKey: string | null = initialTeardownKey - const worktree = preparedInput.worktree + const teardownKey = JSON.stringify(params) ?? String(params) await Promise.all( - Array.from(this.fileWatchTeardownRetries.get(initialTeardownKey) ?? [], (retry) => retry()) + Array.from(this.fileWatchTeardownRetries.get(teardownKey) ?? [], (retry) => retry()) ) let stopped = false let remoteSubscriptionId: string | null = null @@ -353,14 +198,12 @@ export class WebRuntimeClient { if (response.ok === false) { throw new Error(`${response.error.code}: ${response.error.message}`) } - const key = teardownKey - const retries = key ? this.fileWatchTeardownRetries.get(key) : undefined + const retries = this.fileWatchTeardownRetries.get(teardownKey) retries?.delete(retryRemoteUnwatch) if (retries?.size === 0) { - this.fileWatchTeardownRetries.delete(key!) + this.fileWatchTeardownRetries.delete(teardownKey) } dropLocalSubscription() - teardownKey = null }) .catch((error: unknown) => { console.warn('Failed to unwatch remote file subscription:', error) @@ -382,14 +225,9 @@ export class WebRuntimeClient { return } // Why: retain the callback and retry until the server acks physical teardown; a new watch joins this barrier. - const key = teardownKey - if (!key) { - dropLocalSubscription() - return - } - const retries = this.fileWatchTeardownRetries.get(key) ?? new Set() + const retries = this.fileWatchTeardownRetries.get(teardownKey) ?? new Set() retries.add(retryRemoteUnwatch) - this.fileWatchTeardownRetries.set(key, retries) + this.fileWatchTeardownRetries.set(teardownKey, retries) void retryRemoteUnwatch().catch(() => {}) } const wrappedCallbacks: SubscriptionCallbacks = { @@ -409,13 +247,11 @@ export class WebRuntimeClient { return } if (!stopped) { - invokeConsumerCallback(() => callbacks.onResponse(response)) + callbacks.onResponse(response) if (pendingReplayResync && nextSubscriptionId && response.ok) { pendingReplayResync = false // Why: a replayed watch only reports events after its own setup, so consumers must re-scan the reconnect gap. - invokeConsumerCallback(() => - callbacks.onResponse(createFileWatchReplayOverflowResponse(response, worktree)) - ) + callbacks.onResponse(createFileWatchReplayOverflowResponse(response, params)) } } else if (response.ok === false) { dropLocalSubscription() @@ -423,12 +259,12 @@ export class WebRuntimeClient { }, onError: (error) => { if (!stopped) { - invokeConsumerCallback(() => callbacks.onError?.(error)) + callbacks.onError?.(error) } }, onClose: () => { if (!stopped) { - invokeConsumerCallback(() => callbacks.onClose?.()) + callbacks.onClose?.() } }, onTransportInterrupted: () => { @@ -437,30 +273,25 @@ export class WebRuntimeClient { if (!stopped) { return } - const key = teardownKey - const retries = key ? this.fileWatchTeardownRetries.get(key) : undefined + const retries = this.fileWatchTeardownRetries.get(teardownKey) retries?.delete(retryRemoteUnwatch) if (retries?.size === 0) { - this.fileWatchTeardownRetries.delete(key!) + this.fileWatchTeardownRetries.delete(teardownKey) } // Why: socket close physically releases the server subscription — a stopped watch must not replay on the replacement. dropLocalSubscription() - teardownKey = null }, onTransportReplayed: () => { transportInterrupted = false pendingReplayResync = true } } - handle = await this.subscribePreparedOnCurrentConnection( + handle = await this.subscribeOnCurrentConnection( 'files.watch', - preparedInput, - releaseRetainedBytes, + params, wrappedCallbacks, options ) - preparedInput.paramsJson = undefined - preparedInput.teardownKey = '' return { unsubscribe: () => { @@ -480,88 +311,25 @@ export class WebRuntimeClient { } } - protected async subscribeOnCurrentConnection( + private async subscribeOnCurrentConnection( method: string, params: unknown, callbacks: SubscriptionCallbacks, options?: SubscribeOptions - ): Promise { - assertRpcMethodWithinLimit(method) - const releaseAdmission = this.claimSubscriptionAdmission() - let releaseRetainedBytes: (() => void) | null = null - let ownershipTransferred = false - try { - const preparedInput = prepareSubscriptionInput(method, params) - params = undefined - releaseRetainedBytes = this.outboundMemoryBudget.claimSubscriptionBytes( - preparedInput.retainedBytes - ) - if (!releaseRetainedBytes) { - throw new Error(WEB_RUNTIME_BUSY_MESSAGE) - } - const handle = await this.subscribePreparedOnCurrentConnection( - method, - preparedInput, - releaseRetainedBytes, - callbacks, - options - ) - ownershipTransferred = true - preparedInput.paramsJson = undefined - preparedInput.teardownKey = '' - return handle - } finally { - if (!ownershipTransferred) { - releaseRetainedBytes?.() - } - releaseAdmission() - } - } - - private async subscribePreparedOnCurrentConnection( - method: string, - preparedInput: PreparedSubscriptionInput, - releaseRetainedBytes: () => void, - callbacks: SubscriptionCallbacks, - options?: SubscribeOptions ): Promise { await this.waitForConnected(options?.timeoutMs) - if (this.subscriptions.size >= WEB_RUNTIME_MAX_SUBSCRIPTIONS) { - throw new Error(WEB_RUNTIME_BUSY_MESSAGE) - } const id = this.nextId() - const serialized = serializePreparedRpcFrame({ - id, - deviceToken: this.pairing.deviceToken, - method, - paramsJson: preparedInput.paramsJson, - paramsByteLength: preparedInput.paramsByteLength - }) - const subscription: RuntimeSubscription = { - id, - method, - paramsJson: preparedInput.paramsJson, - paramsByteLength: preparedInput.paramsByteLength, - callbacks, - needsReplay: false, - releaseRetainedBytes, - cancelQueuedFrame: cancelNothing - } + const subscription: RuntimeSubscription = { id, method, params, callbacks, needsReplay: false } this.subscriptions.set(id, subscription) - const sent = this.sendEncryptedSerialized(serialized) - subscription.cancelQueuedFrame = sent.cancel - if (!sent.accepted) { - this.removeSubscription(id, subscription) - throw new Error('Remote Orca runtime could not send the subscription.') + if (!this.sendEncrypted({ id, deviceToken: this.pairing.deviceToken, method, params })) { + this.subscriptions.delete(id) + throw new Error('Remote Orca runtime is not connected.') } return { unsubscribe: () => { - const paramsJson = subscription.paramsJson - if (!this.removeSubscription(subscription.id, subscription)) { - return - } + this.subscriptions.delete(subscription.id) // Tell the server to reap its keyed cleanup before the socket closes; best-effort (a closed socket already reaps). - const teardown = options?.buildUnsubscribe?.(parseSerializedParams(paramsJson)) + const teardown = options?.buildUnsubscribe?.(params) if (teardown) { this.sendEncrypted({ id: this.nextId(), @@ -580,36 +348,25 @@ export class WebRuntimeClient { close(options: { notifySubscriptions?: boolean } = {}): void { const shouldNotifySubscriptions = options.notifySubscriptions ?? true this.intentionallyClosed = true - const children = Array.from(this.childClients) + for (const child of Array.from(this.childClients)) { + child.close({ notifySubscriptions: shouldNotifySubscriptions }) + } this.childClients.clear() this.fileWatchTeardownRetries.clear() this.clearTimers() - const ws = this.ws - this.ws = null - this.sharedKey = null - this.disposeOutboundTransport() - if (ws) { - try { - ws.close() - } catch { - // The client is already detached; a browser close failure must not retain transport state. - } - } this.rejectAllPending('Remote Orca runtime connection closed.') this.rejectAllWaiters(new Error('Remote Orca runtime connection closed.')) - this.setState('disconnected') - for (const child of children) { - try { - child.close({ notifySubscriptions: shouldNotifySubscriptions }) - } catch { - // Continue closing sibling transports even if one child cleanup fails. - } - } if (shouldNotifySubscriptions) { this.notifySubscriptionsClosed() } else { - this.clearSubscriptions() + this.subscriptions.clear() } + if (this.ws) { + this.ws.close() + this.ws = null + } + this.sharedKey = null + this.setState('disconnected') } private openConnection(): void { @@ -624,15 +381,6 @@ export class WebRuntimeClient { this.scheduleReconnect() return } - try { - this.outboundSocketMemory = this.outboundMemoryBudget.registerBufferedAmount( - () => ws.bufferedAmount - ) - } catch { - ws.close() - this.scheduleReconnect() - return - } ws.binaryType = 'arraybuffer' this.ws = ws @@ -654,7 +402,6 @@ export class WebRuntimeClient { this.setState('handshaking') const keyPair = generateKeyPair() this.sharedKey = deriveSharedKey(keyPair.secretKey, this.serverPublicKey) - this.ensureOutboundQueue(ws) ws.send( JSON.stringify({ type: 'e2ee_hello', @@ -696,28 +443,17 @@ export class WebRuntimeClient { private async handleSocketMessage(rawData: unknown, sourceWs?: WebSocket): Promise { const raw = typeof rawData === 'string' ? rawData : null - if (raw !== null && raw.length > WEB_RUNTIME_MAX_ENCRYPTED_TEXT_FRAME_BYTES) { - const offendingSocket = sourceWs ?? this.ws - if (offendingSocket) { - this.failOutboundSocket(offendingSocket) - } - return - } if (this.state === 'handshaking') { if (raw === null || !this.sharedKey) { return } try { - const control = parseWebRuntimeInboundJson<{ type?: unknown }>(raw) + const control = JSON.parse(raw) as { type?: unknown } if (control.type === 'e2ee_ready') { this.sendEncrypted({ type: 'e2ee_auth', deviceToken: this.pairing.deviceToken }) return } - } catch (error) { - if (isWebRuntimeJsonStructureCapacityError(error)) { - this.failInboundJsonCapacity(sourceWs) - return - } + } catch { // The authenticated control frame is encrypted, so non-JSON is normal here. } @@ -726,22 +462,22 @@ export class WebRuntimeClient { return } try { - const control = parseWebRuntimeInboundJson<{ + const control = JSON.parse(plaintext) as { type?: unknown error?: { code?: string; message?: string } - }>(plaintext) + } if (control.type === 'e2ee_authenticated') { this.clearHandshakeTimer() this.reconnectAttempt = 0 this.setState('connected') } else if (control.type === 'e2ee_error' || control.error?.code === 'unauthorized') { - this.handleAuthenticationFailure() - } - } catch (error) { - if (isWebRuntimeJsonStructureCapacityError(error)) { - this.failInboundJsonCapacity(sourceWs) - return + this.intentionallyClosed = true + this.setState('auth-failed') + this.rejectAllPending('Unauthorized. Pair this web client again.') + this.notifySubscriptionsError('unauthorized', 'Unauthorized. Pair this web client again.') + this.ws?.close() } + } catch { // Ignore malformed handshake payloads; the server will close on timeout. } return @@ -763,8 +499,8 @@ export class WebRuntimeClient { if (!plaintext) { return } - for (const subscription of Array.from(this.subscriptions.values())) { - invokeConsumerCallback(() => subscription.callbacks.onBinary?.(plaintext)) + for (const subscription of this.subscriptions.values()) { + subscription.callbacks.onBinary?.(plaintext) } return } @@ -776,13 +512,8 @@ export class WebRuntimeClient { let response: RuntimeRpcResponse | Record try { - response = parseWebRuntimeInboundJson | Record>( - plaintext - ) - } catch (error) { - if (isWebRuntimeJsonStructureCapacityError(error)) { - this.failInboundJsonCapacity(sourceWs) - } + response = JSON.parse(plaintext) as RuntimeRpcResponse | Record + } catch { return } if (isKeepaliveFrame(response)) { @@ -792,23 +523,26 @@ export class WebRuntimeClient { return } if (isRuntimeFailureResponse(response) && response.error.code === 'unauthorized') { - this.handleAuthenticationFailure() + this.intentionallyClosed = true + this.setState('auth-failed') + this.rejectAllPending('Unauthorized. Pair this web client again.') + this.notifySubscriptionsError('unauthorized', 'Unauthorized. Pair this web client again.') + this.ws?.close() return } const subscription = this.subscriptions.get(response.id) if (subscription) { const subscriptionResponse = response as RuntimeRpcResponse - subscription.cancelQueuedFrame = cancelNothing - const ended = subscriptionResponse.ok && isEndResult(subscriptionResponse.result) - // Why: terminal subscriptions must release replay payloads before consumer code can throw or reconnect. - if (subscriptionResponse.ok === false || ended) { - this.removeSubscription(response.id, subscription) + // Why: setup failures must be evicted before callbacks so reconnect cannot replay them. + if (subscriptionResponse.ok === false) { + this.subscriptions.delete(response.id) } // Why: subscription-backed unary RPCs can return ordinary success frames. - invokeConsumerCallback(() => subscription.callbacks.onResponse(subscriptionResponse)) - if (ended) { - invokeConsumerCallback(() => subscription.callbacks.onClose?.()) + subscription.callbacks.onResponse(subscriptionResponse) + if (subscriptionResponse.ok && isEndResult(subscriptionResponse.result)) { + this.subscriptions.delete(response.id) + subscription.callbacks.onClose?.() } return } @@ -817,61 +551,27 @@ export class WebRuntimeClient { if (!pending) { return } - pending.cancelQueuedFrame() - pending.cancelQueuedFrame = cancelNothing this.pending.delete(response.id) window.clearTimeout(pending.timeout) pending.resolve(response as RuntimeRpcResponse) } private sendEncrypted(message: unknown): boolean { - try { - const { serialized } = stringifyWebRuntimeOutboundJson( - message, - WEB_RUNTIME_MAX_OUTBOUND_JSON_BYTES - ) - return serialized !== undefined && this.sendEncryptedSerialized(serialized).accepted - } catch { - return false - } - } - - private sendEncryptedSerialized(serialized: string): WsOutboundEnqueueResult { const ws = this.ws if (!ws || ws.readyState !== WebSocket.OPEN || !this.sharedKey) { - return REJECTED_OUTBOUND_ENQUEUE - } - const queue = this.ensureOutboundQueue(ws) - if (!queue) { - return REJECTED_OUTBOUND_ENQUEUE - } - try { - const payload = encrypt(serialized, this.sharedKey) - return queue.enqueueCancelable({ payload, bytes: payload.length }) - } catch { - return REJECTED_OUTBOUND_ENQUEUE + return false } + ws.send(encrypt(JSON.stringify(message), this.sharedKey)) + return true } private sendEncryptedBinary(bytes: Uint8Array): boolean { const ws = this.ws - if (ws && bytes.byteLength > WEB_RUNTIME_MAX_OUTBOUND_BINARY_FRAME_BYTES) { - this.failOutboundSocket(ws) - return false - } if (!ws || ws.readyState !== WebSocket.OPEN || !this.sharedKey) { return false } - const queue = this.ensureOutboundQueue(ws) - if (!queue) { - return false - } - try { - const payload = encryptBytes(bytes, this.sharedKey) - return queue.enqueue({ payload, bytes: payload.byteLength }) - } catch { - return false - } + ws.send(encryptBytes(bytes, this.sharedKey)) + return true } private waitForConnected(timeoutMs = REQUEST_TIMEOUT_MS): Promise { @@ -884,9 +584,6 @@ export class WebRuntimeClient { if (this.intentionallyClosed) { return Promise.reject(new Error('Remote Orca runtime connection closed.')) } - if (this.waiters.length >= WEB_RUNTIME_MAX_CONNECTION_WAITERS) { - return Promise.reject(new Error(WEB_RUNTIME_BUSY_MESSAGE)) - } return new Promise((resolve, reject) => { const timeout = window.setTimeout(() => { const index = this.waiters.findIndex((waiter) => waiter.resolve === resolve) @@ -915,50 +612,10 @@ export class WebRuntimeClient { }) } - private claimCallAdmission(): () => void { - if (this.activeCallAdmissions >= WEB_RUNTIME_MAX_PENDING_REQUESTS) { - throw new Error(WEB_RUNTIME_BUSY_MESSAGE) - } - this.activeCallAdmissions += 1 - return releaseOnce(() => { - this.activeCallAdmissions -= 1 - }) - } - - private claimSubscriptionAdmission(): () => void { - if (this.pendingSubscriptionAdmissions >= WEB_RUNTIME_MAX_SUBSCRIPTIONS) { - throw new Error(WEB_RUNTIME_BUSY_MESSAGE) - } - this.pendingSubscriptionAdmissions += 1 - return releaseOnce(() => { - this.pendingSubscriptionAdmissions -= 1 - }) - } - - private handleAuthenticationFailure(): void { - this.intentionallyClosed = true - this.clearTimers() - this.setState('auth-failed') - const ws = this.ws - this.ws = null - this.sharedKey = null - this.disposeOutboundTransport() - if (ws) { - try { - ws.close() - } catch { - // Authentication failure already detached the socket and released its memory claims. - } - } - this.rejectAllPending('Unauthorized. Pair this web client again.') - this.notifySubscriptionsError('unauthorized', 'Unauthorized. Pair this web client again.') - } - private handleSocketClosed(closedWs: WebSocket): void { if (this.ws !== closedWs) { return } - this.disposeOutboundTransport() this.ws = null this.sharedKey = null this.clearConnectTimer() @@ -974,69 +631,6 @@ export class WebRuntimeClient { this.scheduleReconnect() } - private ensureOutboundQueue( - ws: WebSocket - ): WsOutboundBackpressureQueue | null { - if (this.outboundQueue) { - return this.outboundQueue - } - if (!this.outboundSocketMemory) { - try { - this.outboundSocketMemory = this.outboundMemoryBudget.registerBufferedAmount( - () => ws.bufferedAmount - ) - } catch { - return null - } - } - const socketMemory = this.outboundSocketMemory - this.outboundQueue = createWsOutboundBackpressureQueue({ - send: (frame) => { - try { - ws.send(frame.payload) - } catch { - this.failOutboundSocket(ws) - } - }, - byteLengthOf: (frame) => frame.bytes, - getBufferedAmount: () => ws.bufferedAmount, - isWritable: () => this.ws === ws && ws.readyState === WebSocket.OPEN && !!this.sharedKey, - canSend: (bytes) => socketMemory.canSend(bytes), - claimQueuedBytes: (bytes) => this.outboundMemoryBudget.claimQueuedBytes(bytes), - softCapBytes: WEB_RUNTIME_OUTBOUND_SOCKET_SOFT_CAP_BYTES, - maxQueuedBytes: WEB_RUNTIME_OUTBOUND_MAX_QUEUED_BYTES, - maxQueuedFrames: WEB_RUNTIME_OUTBOUND_MAX_QUEUED_FRAMES, - maxFrameBytes: WEB_RUNTIME_MAX_OUTBOUND_WIRE_FRAME_BYTES, - onOverflow: () => this.failOutboundSocket(ws) - }) - return this.outboundQueue - } - - private failOutboundSocket(ws: WebSocket): void { - if (this.ws !== ws) { - return - } - try { - ws.close() - } finally { - this.handleSocketClosed(ws) - } - } - - private failInboundJsonCapacity(sourceWs?: WebSocket): void { - const ws = sourceWs ?? this.ws - if (ws) { - this.failOutboundSocket(ws) - } - } - - private disposeOutboundTransport(): void { - this.outboundQueue?.dispose() - this.outboundQueue = null - this.outboundSocketMemory?.release() - this.outboundSocketMemory = null - } - private scheduleReconnect(): void { if (this.reconnectTimer || this.intentionallyClosed) { return @@ -1073,8 +667,6 @@ export class WebRuntimeClient { for (const [id, pending] of this.pending) { this.pending.delete(id) window.clearTimeout(pending.timeout) - pending.cancelQueuedFrame() - pending.cancelQueuedFrame = cancelNothing pending.reject(error) } } @@ -1085,44 +677,22 @@ export class WebRuntimeClient { } } - private removeSubscription(id: string, expected?: RuntimeSubscription): boolean { - const subscription = this.subscriptions.get(id) - if (!subscription || (expected && subscription !== expected)) { - return false - } - this.subscriptions.delete(id) - subscription.cancelQueuedFrame?.() - subscription.cancelQueuedFrame = cancelNothing - subscription.paramsJson = undefined - subscription.paramsByteLength = 0 - subscription.releaseRetainedBytes?.() - subscription.releaseRetainedBytes = releaseNothing - return true - } - - private clearSubscriptions(): void { - for (const [id, subscription] of Array.from(this.subscriptions)) { - this.removeSubscription(id, subscription) - } - } - private notifySubscriptionsClosed(): void { const subscriptions = Array.from(this.subscriptions.values()) - this.clearSubscriptions() + this.subscriptions.clear() for (const subscription of subscriptions) { - invokeConsumerCallback(() => subscription.callbacks.onClose?.()) + subscription.callbacks.onClose?.() } } private handleInterruptedSubscriptions(): void { for (const [id, subscription] of Array.from(this.subscriptions)) { if (!SHARED_CONNECTION_SUBSCRIPTION_METHODS.has(subscription.method)) { - this.removeSubscription(id, subscription) - invokeConsumerCallback(() => subscription.callbacks.onClose?.()) + this.subscriptions.delete(id) + subscription.callbacks.onClose?.() continue } - subscription.cancelQueuedFrame = cancelNothing - invokeConsumerCallback(() => subscription.callbacks.onTransportInterrupted?.()) + subscription.callbacks.onTransportInterrupted?.() if (this.subscriptions.get(subscription.id) === subscription) { subscription.needsReplay = true } @@ -1138,22 +708,15 @@ export class WebRuntimeClient { subscription.id = this.nextId() subscription.needsReplay = false this.subscriptions.set(subscription.id, subscription) - let sent = REJECTED_OUTBOUND_ENQUEUE - try { - const serialized = serializePreparedRpcFrame({ + if ( + this.sendEncrypted({ id: subscription.id, deviceToken: this.pairing.deviceToken, method: subscription.method, - paramsJson: subscription.paramsJson, - paramsByteLength: subscription.paramsByteLength + params: subscription.params }) - sent = this.sendEncryptedSerialized(serialized) - } catch { - // A previously admitted subscription stays replayable if a replacement frame cannot be prepared. - } - subscription.cancelQueuedFrame = sent.cancel - if (sent.accepted) { - invokeConsumerCallback(() => subscription.callbacks.onTransportReplayed?.()) + ) { + subscription.callbacks.onTransportReplayed?.() } else { subscription.needsReplay = true } @@ -1162,9 +725,9 @@ export class WebRuntimeClient { private notifySubscriptionsError(code: string, message: string): void { const subscriptions = Array.from(this.subscriptions.values()) - this.clearSubscriptions() + this.subscriptions.clear() for (const subscription of subscriptions) { - invokeConsumerCallback(() => subscription.callbacks.onError?.({ code, message })) + subscription.callbacks.onError?.({ code, message }) } } @@ -1273,81 +836,6 @@ export class WebRuntimeClient { } } -function assertRpcMethodWithinLimit(method: string): void { - if ( - measureUtf8ByteLength(method, { stopAfterBytes: WEB_RUNTIME_MAX_RPC_METHOD_BYTES }) - .exceededLimit - ) { - throw new Error(`Remote runtime RPC method exceeds ${WEB_RUNTIME_MAX_RPC_METHOD_BYTES} bytes`) - } -} - -function prepareSubscriptionInput(method: string, params: unknown): PreparedSubscriptionInput { - const prepared = stringifyWebRuntimeOutboundJson(params, WEB_RUNTIME_MAX_SUBSCRIPTION_PARAM_BYTES) - const canonicalParams = parseSerializedParams(prepared.serialized) - const worktree = (canonicalParams as { worktree?: unknown } | null)?.worktree - const normalizedWorktree = typeof worktree === 'string' ? worktree : '' - return { - paramsJson: prepared.serialized, - paramsByteLength: prepared.byteLength, - retainedBytes: - retainedPreparedFrameBytes(prepared.serialized, method) + normalizedWorktree.length * 2, - teardownKey: prepared.serialized ?? String(canonicalParams), - worktree: normalizedWorktree - } -} - -function serializePreparedRpcFrame(input: { - id: string - deviceToken: string - method: string - paramsJson: string | undefined - paramsByteLength: number -}): string { - const header = stringifyWebRuntimeOutboundJson( - { id: input.id, deviceToken: input.deviceToken, method: input.method }, - WEB_RUNTIME_MAX_OUTBOUND_JSON_BYTES - ) - if (header.serialized === undefined) { - throw new WebRuntimeOutboundJsonLimitError(WEB_RUNTIME_MAX_OUTBOUND_JSON_BYTES) - } - if (input.paramsJson === undefined) { - return header.serialized - } - const totalBytes = header.byteLength + RPC_PARAMS_MEMBER_PREFIX.length + input.paramsByteLength - if (totalBytes > WEB_RUNTIME_MAX_OUTBOUND_JSON_BYTES) { - throw new WebRuntimeOutboundJsonLimitError(WEB_RUNTIME_MAX_OUTBOUND_JSON_BYTES) - } - return `${header.serialized.slice(0, -1)}${RPC_PARAMS_MEMBER_PREFIX}${input.paramsJson}}` -} - -function parseSerializedParams(serialized: string | undefined): unknown { - return serialized === undefined ? undefined : JSON.parse(serialized) -} - -function retainedPreparedFrameBytes(serialized: string | undefined, method: string): number { - return (serialized?.length ?? 0) * 2 + method.length * 2 + 256 -} - -function releaseOnce(release: () => void): () => void { - let released = false - return () => { - if (released) { - return - } - released = true - release() - } -} - -function invokeConsumerCallback(callback: () => void): void { - try { - callback() - } catch { - // One consumer must not block transport cleanup, replay, or sibling notifications. - } -} - function isRuntimeFailureResponse( response: RuntimeRpcResponse | Record ): response is RuntimeRpcResponse & { ok: false } { @@ -1375,18 +863,19 @@ function getFileWatchSubscriptionId(response: RuntimeRpcResponse): stri function createFileWatchReplayOverflowResponse( readyResponse: RuntimeRpcSuccess, - worktree: string + params: unknown ): RuntimeRpcSuccess<{ type: 'changed' worktree: string events: { kind: 'overflow'; absolutePath: string }[] }> { + const worktree = (params as { worktree?: unknown } | null)?.worktree return { id: readyResponse.id, ok: true, result: { type: 'changed', - worktree, + worktree: typeof worktree === 'string' ? worktree : '', // Why: overflow consumers re-scan the whole root and ignore the path (client lacks the server-side root here). events: [{ kind: 'overflow', absolutePath: '' }] }, @@ -1413,15 +902,12 @@ async function websocketPayloadToUint8( value: unknown ): Promise | null> { if (value instanceof Uint8Array) { - return value.byteLength <= WEB_RUNTIME_MAX_BINARY_FRAME_BYTES ? value : null + return value } if (value instanceof ArrayBuffer) { - return value.byteLength <= WEB_RUNTIME_MAX_BINARY_FRAME_BYTES ? new Uint8Array(value) : null + return new Uint8Array(value) } if (value instanceof Blob) { - if (value.size > WEB_RUNTIME_MAX_BINARY_FRAME_BYTES) { - return null - } return new Uint8Array(await value.arrayBuffer()) } return null diff --git a/src/renderer/src/web/web-runtime-environment.ts b/src/renderer/src/web/web-runtime-environment.ts index 22bb0edab42..7aa48f5711d 100644 --- a/src/renderer/src/web/web-runtime-environment.ts +++ b/src/renderer/src/web/web-runtime-environment.ts @@ -2,7 +2,6 @@ import type { PublicKnownRuntimeEnvironment } from '../../../shared/runtime-envi import type { WebPairingOffer } from './web-pairing' import { createBrowserUuid } from '@/lib/browser-uuid' import { translate } from '@/i18n/i18n' -import { parseWebLocalStorageJson, stringifyWebLocalStorageJson } from './web-local-storage-json' export type StoredWebRuntimeEnvironment = Omit & { compatibleEnvironmentIds?: string[] @@ -24,7 +23,7 @@ export function readStoredWebRuntimeEnvironment(): StoredWebRuntimeEnvironment | return null } try { - const parsed = parseWebLocalStorageJson(raw) + const parsed = JSON.parse(raw) as StoredWebRuntimeEnvironment if ( !parsed.id || !parsed.name || @@ -49,7 +48,7 @@ export function readStoredWebRuntimeEnvironment(): StoredWebRuntimeEnvironment | } export function saveStoredWebRuntimeEnvironment(environment: StoredWebRuntimeEnvironment): void { - window.localStorage.setItem(ENVIRONMENT_STORAGE_KEY, stringifyWebLocalStorageJson(environment)) + window.localStorage.setItem(ENVIRONMENT_STORAGE_KEY, JSON.stringify(environment)) } export function clearStoredWebRuntimeEnvironment(): void { diff --git a/src/renderer/src/web/web-runtime-inbound-json.test.ts b/src/renderer/src/web/web-runtime-inbound-json.test.ts deleted file mode 100644 index 9864ba19158..00000000000 --- a/src/renderer/src/web/web-runtime-inbound-json.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - isWebRuntimeJsonStructureCapacityError, - parseWebRuntimeInboundJson -} from './web-runtime-inbound-json' - -describe('web runtime inbound JSON admission', () => { - it('accepts the exact structural-token limit and rejects one more', () => { - const limits = { structuralTokens: 4, nestingDepth: 2 } - - expect(parseWebRuntimeInboundJson('[0,0,0]', limits)).toEqual([0, 0, 0]) - expect(() => parseWebRuntimeInboundJson('[0,0,0,0]', limits)).toThrow( - 'JSON structure exceeds 4 tokens' - ) - }) - - it('accepts the exact nesting limit and rejects one more', () => { - const limits = { structuralTokens: 20, nestingDepth: 3 } - - expect(parseWebRuntimeInboundJson('[[[]]]', limits)).toEqual([[[]]]) - expect(() => parseWebRuntimeInboundJson('[[[[]]]]', limits)).toThrow( - 'JSON nesting exceeds 3 levels' - ) - }) - - it('identifies capacity failures separately from malformed JSON', () => { - const capacityError = captureError(() => - parseWebRuntimeInboundJson('[0,0]', { - structuralTokens: 2, - nestingDepth: 2 - }) - ) - const syntaxError = captureError(() => - parseWebRuntimeInboundJson('[', { - structuralTokens: 2, - nestingDepth: 2 - }) - ) - - expect(isWebRuntimeJsonStructureCapacityError(capacityError)).toBe(true) - expect(isWebRuntimeJsonStructureCapacityError(syntaxError)).toBe(false) - }) -}) - -function captureError(run: () => void): unknown { - try { - run() - return null - } catch (error) { - return error - } -} diff --git a/src/renderer/src/web/web-runtime-inbound-json.ts b/src/renderer/src/web/web-runtime-inbound-json.ts deleted file mode 100644 index 2ba8d2e06e3..00000000000 --- a/src/renderer/src/web/web-runtime-inbound-json.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { - assertJsonTextStructureWithinLimits, - JsonTextStructureCapacityError, - type JsonTextStructureLimits -} from '../../../shared/json-text-structure-limit' - -export const WEB_RUNTIME_INBOUND_JSON_STRUCTURE_LIMITS: JsonTextStructureLimits = { - structuralTokens: 1_000_000, - nestingDepth: 128 -} - -export function parseWebRuntimeInboundJson( - content: string, - limits: JsonTextStructureLimits = WEB_RUNTIME_INBOUND_JSON_STRUCTURE_LIMITS -): T { - assertJsonTextStructureWithinLimits(content, limits) - return JSON.parse(content) as T -} - -export function isWebRuntimeJsonStructureCapacityError( - error: unknown -): error is JsonTextStructureCapacityError { - return error instanceof JsonTextStructureCapacityError -} diff --git a/src/renderer/src/web/web-runtime-outbound-json.test.ts b/src/renderer/src/web/web-runtime-outbound-json.test.ts deleted file mode 100644 index b70a5701904..00000000000 --- a/src/renderer/src/web/web-runtime-outbound-json.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { - stringifyWebRuntimeOutboundJson, - WebRuntimeOutboundJsonLimitError -} from './web-runtime-outbound-json' - -describe('bounded web runtime JSON serialization', () => { - it('preserves ordinary JSON values and exact byte accounting', () => { - const value = { text: 'hello', escaped: '\u0000\n', items: [1, true, null] } - const serialized = JSON.stringify(value) - - expect(stringifyWebRuntimeOutboundJson(value, 1_000)).toEqual({ - serialized, - byteLength: new TextEncoder().encode(serialized).byteLength - }) - }) - - it('accepts the exact limit and rejects one byte over it', () => { - expect(stringifyWebRuntimeOutboundJson('🐋', 6)).toEqual({ - serialized: '"🐋"', - byteLength: 6 - }) - expect(() => stringifyWebRuntimeOutboundJson('🐋x', 6)).toThrow( - WebRuntimeOutboundJsonLimitError - ) - }) - - it('stops traversal immediately after an oversized member', () => { - const readAfter = vi.fn(() => 'late') - const value = { - payload: 'x'.repeat(100), - get after() { - return readAfter() - } - } - - expect(() => stringifyWebRuntimeOutboundJson(value, 32)).toThrow( - 'Remote runtime JSON payload exceeds 32 bytes' - ) - expect(readAfter).not.toHaveBeenCalled() - }) - - it('matches JSON omission, array null, escaping, and boxed primitive behavior', () => { - const omitted = { ignored: undefined, kept: 1 } - const array = [undefined, () => 'ignored', Symbol('ignored')] - - expect(stringifyWebRuntimeOutboundJson(omitted, 10).serialized).toBe('{"kept":1}') - expect(stringifyWebRuntimeOutboundJson(array, 16).serialized).toBe('[null,null,null]') - expect(stringifyWebRuntimeOutboundJson(new String('\u0000'), 8).serialized).toBe('"\\u0000"') - }) - - it('accepts shared containers at the exact serialized limit', () => { - const sharedObject = { value: 1 } - const sharedArray = [1, 2] - for (const value of [ - [sharedObject, sharedObject], - [sharedArray, sharedArray] - ]) { - const serialized = JSON.stringify(value) - const byteLength = new TextEncoder().encode(serialized).byteLength - expect(stringifyWebRuntimeOutboundJson(value, byteLength)).toEqual({ - serialized, - byteLength - }) - } - }) - - it('rejects oversized raw JSON before visiting later members', () => { - const json = JSON as typeof JSON & { rawJSON?: (value: string) => unknown } - if (!json.rawJSON) { - return - } - const readAfter = vi.fn(() => 1) - const value = { - raw: json.rawJSON(`"${'x'.repeat(100)}"`), - get after() { - return readAfter() - } - } - - expect(() => stringifyWebRuntimeOutboundJson(value, 32)).toThrow( - WebRuntimeOutboundJsonLimitError - ) - expect(readAfter).not.toHaveBeenCalled() - }) - - it('reports root values omitted by JSON.stringify without retaining output', () => { - expect(stringifyWebRuntimeOutboundJson(undefined, 1)).toEqual({ - serialized: undefined, - byteLength: 0 - }) - }) -}) diff --git a/src/renderer/src/web/web-runtime-outbound-json.ts b/src/renderer/src/web/web-runtime-outbound-json.ts deleted file mode 100644 index cffa3711969..00000000000 --- a/src/renderer/src/web/web-runtime-outbound-json.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { measureUtf8ByteLength } from '../../../shared/utf8-byte-limits' - -export class WebRuntimeOutboundJsonLimitError extends Error { - constructor(readonly maxBytes: number) { - super(`Remote runtime JSON payload exceeds ${maxBytes} bytes`) - this.name = 'WebRuntimeOutboundJsonLimitError' - } -} - -export type WebRuntimeOutboundJson = { - byteLength: number - serialized: string | undefined -} - -export function stringifyWebRuntimeOutboundJson( - value: unknown, - maxBytes: number -): WebRuntimeOutboundJson { - if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) { - throw new Error('Remote runtime JSON limit must be a positive safe integer') - } - let estimatedBytes = 0 - let root = true - const emittedMembers = new WeakMap() - const serialized = JSON.stringify(value, function (key, item: unknown) { - const isRoot = root - root = false - const parent = this as object - const inArray = Array.isArray(parent) - if (isRoot && isOmittedObjectValue(item)) { - return item - } - if (!isRoot && !inArray && isOmittedObjectValue(item)) { - return item - } - if (!isRoot) { - const emitted = emittedMembers.get(parent) ?? 0 - estimatedBytes += emitted > 0 ? 1 : 0 - if (!inArray) { - estimatedBytes += escapedJsonStringBytes(key) + 1 - } - emittedMembers.set(parent, emitted + 1) - } - estimatedBytes += - inArray && isOmittedObjectValue(item) - ? 4 - : jsonValueBytes(item, Math.max(0, maxBytes - estimatedBytes)) - if (estimatedBytes > maxBytes) { - throw new WebRuntimeOutboundJsonLimitError(maxBytes) - } - if (typeof item === 'object' && item !== null) { - // Why: the same container can appear twice, and each traversal starts with no emitted members. - emittedMembers.set(item, 0) - } - return item - }) - if (serialized === undefined) { - return { serialized, byteLength: 0 } - } - const measured = measureUtf8ByteLength(serialized, { stopAfterBytes: maxBytes }) - if (measured.exceededLimit) { - throw new WebRuntimeOutboundJsonLimitError(maxBytes) - } - return { serialized, byteLength: measured.byteLength } -} - -function isOmittedObjectValue(value: unknown): boolean { - return value === undefined || typeof value === 'function' || typeof value === 'symbol' -} - -function jsonValueBytes(value: unknown, stopAfterBytes: number): number { - if (value === null) { - return 4 - } - if (typeof value === 'string') { - return escapedJsonStringBytes(value) - } - if (typeof value === 'boolean') { - return value ? 4 : 5 - } - if (typeof value === 'number') { - return Number.isFinite(value) ? String(value).length : 4 - } - if (typeof value === 'object' && value !== null) { - return rawJsonBytes(value, stopAfterBytes) ?? boxedPrimitiveJsonBytes(value) ?? 2 - } - return 4 -} - -function rawJsonBytes(value: object, stopAfterBytes: number): number | null { - const json = JSON as typeof JSON & { isRawJSON?: (candidate: unknown) => boolean } - if (json.isRawJSON?.(value) !== true) { - return null - } - const rawJson = (value as { rawJSON?: unknown }).rawJSON - if (typeof rawJson !== 'string') { - return stopAfterBytes + 1 - } - return measureUtf8ByteLength(rawJson, { stopAfterBytes }).byteLength -} - -function boxedPrimitiveJsonBytes(value: object): number | null { - try { - return escapedJsonStringBytes(String.prototype.valueOf.call(value)) - } catch {} - try { - const number = Number.prototype.valueOf.call(value) - return Number.isFinite(number) ? String(number).length : 4 - } catch {} - try { - return Boolean.prototype.valueOf.call(value) ? 4 : 5 - } catch { - return null - } -} - -function escapedJsonStringBytes(value: string): number { - let bytes = 2 - for (let index = 0; index < value.length; index += 1) { - const code = value.charCodeAt(index) - if ( - code === 0x22 || - code === 0x5c || - code === 0x08 || - code === 0x09 || - code === 0x0a || - code === 0x0c || - code === 0x0d - ) { - bytes += 2 - } else if ( - code <= 0x1f || - (code >= 0xd800 && code <= 0xdfff && !isSurrogatePair(value, index)) - ) { - bytes += 6 - } else if (code <= 0x7f) { - bytes += 1 - } else if (code <= 0x7ff) { - bytes += 2 - } else if (code >= 0xd800 && code <= 0xdbff) { - bytes += 4 - index += 1 - } else { - bytes += 3 - } - } - return bytes -} - -function isSurrogatePair(value: string, index: number): boolean { - const code = value.charCodeAt(index) - if (code >= 0xd800 && code <= 0xdbff) { - const next = value.charCodeAt(index + 1) - return next >= 0xdc00 && next <= 0xdfff - } - if (code >= 0xdc00 && code <= 0xdfff) { - const previous = value.charCodeAt(index - 1) - return previous >= 0xd800 && previous <= 0xdbff - } - return false -} diff --git a/src/renderer/src/web/web-runtime-outbound-memory-budget.test.ts b/src/renderer/src/web/web-runtime-outbound-memory-budget.test.ts deleted file mode 100644 index 08ff5fbf9ec..00000000000 --- a/src/renderer/src/web/web-runtime-outbound-memory-budget.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { createWebRuntimeOutboundMemoryBudget } from './web-runtime-outbound-memory-budget' - -describe('web runtime outbound memory budget', () => { - it('bounds aggregate queued bytes and frames with capacity recovery', () => { - const budget = createWebRuntimeOutboundMemoryBudget({ - maxQueuedBytes: 10, - maxQueuedFrames: 2 - }) - const first = budget.claimQueuedBytes(5) - const second = budget.claimQueuedBytes(5) - - expect(first).not.toBeNull() - expect(second).not.toBeNull() - expect(budget.claimQueuedBytes(0)).toBeNull() - first?.() - expect(budget.claimQueuedBytes(5)).not.toBeNull() - }) - - it('accepts exact retained subscription bytes and releases them', () => { - const budget = createWebRuntimeOutboundMemoryBudget({ maxSubscriptionBytes: 10 }) - const release = budget.claimSubscriptionBytes(10) - - expect(release).not.toBeNull() - expect(budget.claimSubscriptionBytes(1)).toBeNull() - release?.() - expect(budget.claimSubscriptionBytes(10)).not.toBeNull() - }) - - it('bounds prepared RPC bytes across connection waiters and releases them', () => { - const budget = createWebRuntimeOutboundMemoryBudget({ maxPreparedRpcBytes: 10 }) - const release = budget.claimPreparedRpcBytes(10) - - expect(release).not.toBeNull() - expect(budget.claimPreparedRpcBytes(1)).toBeNull() - release?.() - expect(budget.claimPreparedRpcBytes(10)).not.toBeNull() - }) - - it('accounts native buffered amounts across registered child sockets', () => { - const budget = createWebRuntimeOutboundMemoryBudget({ - maxBufferedBytes: 10, - maxSocketSources: 2 - }) - let firstBytes = 6 - let secondBytes = 3 - const first = budget.registerBufferedAmount(() => firstBytes) - const second = budget.registerBufferedAmount(() => secondBytes) - - expect(first.canSend(1)).toBe(true) - expect(first.canSend(2)).toBe(false) - secondBytes = 0 - expect(first.canSend(4)).toBe(true) - firstBytes = 0 - second.release() - expect(first.canSend(10)).toBe(true) - }) - - it('caps tracked sockets and recovers when a socket closes', () => { - const budget = createWebRuntimeOutboundMemoryBudget({ maxSocketSources: 1 }) - const socket = budget.registerBufferedAmount(() => 0) - - expect(() => budget.registerBufferedAmount(() => 0)).toThrow('socket tracking limit') - socket.release() - expect(() => budget.registerBufferedAmount(() => 0)).not.toThrow() - }) -}) diff --git a/src/renderer/src/web/web-runtime-outbound-memory-budget.ts b/src/renderer/src/web/web-runtime-outbound-memory-budget.ts deleted file mode 100644 index 48c19e87b05..00000000000 --- a/src/renderer/src/web/web-runtime-outbound-memory-budget.ts +++ /dev/null @@ -1,125 +0,0 @@ -export const WEB_RUNTIME_OUTBOUND_MAX_BUFFERED_BYTES = 16 * 1024 * 1024 -export const WEB_RUNTIME_OUTBOUND_MAX_QUEUED_BYTES = 32 * 1024 * 1024 -export const WEB_RUNTIME_OUTBOUND_MAX_QUEUED_FRAMES = 4_096 -export const WEB_RUNTIME_OUTBOUND_MAX_SOCKET_SOURCES = 65 -export const WEB_RUNTIME_MAX_RETAINED_SUBSCRIPTION_BYTES = 16 * 1024 * 1024 -export const WEB_RUNTIME_MAX_PREPARED_RPC_BYTES = 32 * 1024 * 1024 - -export type WebRuntimeOutboundSocketMemory = { - canSend: (bytes: number) => boolean - release: () => void -} - -export type WebRuntimeOutboundMemoryBudget = { - claimQueuedBytes: (bytes: number) => (() => void) | null - claimPreparedRpcBytes: (bytes: number) => (() => void) | null - claimSubscriptionBytes: (bytes: number) => (() => void) | null - registerBufferedAmount: (readBufferedAmount: () => number) => WebRuntimeOutboundSocketMemory -} - -export function createWebRuntimeOutboundMemoryBudget(options?: { - maxBufferedBytes?: number - maxQueuedBytes?: number - maxQueuedFrames?: number - maxPreparedRpcBytes?: number - maxSocketSources?: number - maxSubscriptionBytes?: number -}): WebRuntimeOutboundMemoryBudget { - const maxBufferedBytes = options?.maxBufferedBytes ?? WEB_RUNTIME_OUTBOUND_MAX_BUFFERED_BYTES - const maxQueuedBytes = options?.maxQueuedBytes ?? WEB_RUNTIME_OUTBOUND_MAX_QUEUED_BYTES - const maxQueuedFrames = options?.maxQueuedFrames ?? WEB_RUNTIME_OUTBOUND_MAX_QUEUED_FRAMES - const maxPreparedRpcBytes = options?.maxPreparedRpcBytes ?? WEB_RUNTIME_MAX_PREPARED_RPC_BYTES - const maxSocketSources = options?.maxSocketSources ?? WEB_RUNTIME_OUTBOUND_MAX_SOCKET_SOURCES - const maxSubscriptionBytes = - options?.maxSubscriptionBytes ?? WEB_RUNTIME_MAX_RETAINED_SUBSCRIPTION_BYTES - const bufferedSources = new Set<() => number>() - let queuedBytes = 0 - let queuedFrames = 0 - let preparedRpcBytes = 0 - let subscriptionBytes = 0 - - const bufferedBytes = (): number => { - let total = 0 - for (const read of bufferedSources) { - try { - const value = read() - if (Number.isFinite(value) && value > 0) { - total += value - } - } catch { - // Closed browser sockets can reject late reads before their close callback releases the source. - } - } - return total - } - - return { - claimQueuedBytes(bytes): (() => void) | null { - if ( - !Number.isFinite(bytes) || - bytes < 0 || - queuedFrames >= maxQueuedFrames || - queuedBytes + bytes > maxQueuedBytes - ) { - return null - } - queuedBytes += bytes - queuedFrames += 1 - return createRelease(() => { - queuedBytes -= bytes - queuedFrames -= 1 - }) - }, - claimPreparedRpcBytes(bytes): (() => void) | null { - if (!Number.isFinite(bytes) || bytes < 0 || preparedRpcBytes + bytes > maxPreparedRpcBytes) { - return null - } - preparedRpcBytes += bytes - return createRelease(() => { - preparedRpcBytes -= bytes - }) - }, - claimSubscriptionBytes(bytes): (() => void) | null { - if ( - !Number.isFinite(bytes) || - bytes < 0 || - subscriptionBytes + bytes > maxSubscriptionBytes - ) { - return null - } - subscriptionBytes += bytes - return createRelease(() => { - subscriptionBytes -= bytes - }) - }, - registerBufferedAmount(readBufferedAmount) { - if (bufferedSources.size >= maxSocketSources) { - throw new Error('Remote runtime outbound socket tracking limit exceeded') - } - bufferedSources.add(readBufferedAmount) - let registered = true - return { - canSend: (bytes): boolean => - registered && - Number.isFinite(bytes) && - bytes >= 0 && - bytes <= maxBufferedBytes - bufferedBytes(), - release: createRelease(() => { - registered = false - bufferedSources.delete(readBufferedAmount) - }) - } - } - } -} - -function createRelease(release: () => void): () => void { - let released = false - return () => { - if (released) { - return - } - released = true - release() - } -} diff --git a/src/shared/agent-detection.test.ts b/src/shared/agent-detection.test.ts index 53ab82f1fbc..7f2f1308d89 100644 --- a/src/shared/agent-detection.test.ts +++ b/src/shared/agent-detection.test.ts @@ -7,7 +7,6 @@ import { getAgentLabel, isCursorAgentTitle, MAX_OSC_TITLE_CHARS, - MAX_OSC_TITLES_PER_CHUNK, normalizeTerminalTitle } from './agent-detection' import { @@ -66,19 +65,6 @@ describe('OSC title extraction', () => { expect(extracted?.endsWith('b'.repeat(MAX_OSC_TITLE_CHARS / 2))).toBe(true) expect(extractAllOscTitles(data)).toEqual([extracted]) }) - - it('retains only the newest titles when one chunk contains limit +1', () => { - const data = Array.from( - { length: MAX_OSC_TITLES_PER_CHUNK + 1 }, - (_, index) => `\x1b]0;title-${index}\x07` - ).join('') - - const titles = extractAllOscTitles(data) - - expect(titles).toHaveLength(MAX_OSC_TITLES_PER_CHUNK) - expect(titles[0]).toBe('title-1') - expect(titles.at(-1)).toBe(`title-${MAX_OSC_TITLES_PER_CHUNK}`) - }) }) describe('MiMo title detection', () => { diff --git a/src/shared/agent-detection.ts b/src/shared/agent-detection.ts index 7097a415264..4fbfff86f24 100644 --- a/src/shared/agent-detection.ts +++ b/src/shared/agent-detection.ts @@ -32,7 +32,6 @@ export { AGENT_NAMES, titleHasAgentName } from './agent-name-token-match' export { extractAllOscTitles, extractLastOscTitle, - MAX_OSC_TITLE_CHARS, - MAX_OSC_TITLES_PER_CHUNK + MAX_OSC_TITLE_CHARS } from './osc-title-extraction' export { isShellProcess } from './shell-process-detection' diff --git a/src/shared/agent-hook-endpoint-temp-cleanup.test.ts b/src/shared/agent-hook-endpoint-temp-cleanup.test.ts deleted file mode 100644 index 85b4ef98516..00000000000 --- a/src/shared/agent-hook-endpoint-temp-cleanup.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const fsMocks = vi.hoisted(() => ({ - closeSync: vi.fn(), - readSync: vi.fn(), - statSync: vi.fn(), - unlinkSync: vi.fn() -})) - -vi.mock('node:fs', () => ({ - opendirSync: () => ({ - closeSync: fsMocks.closeSync, - readSync: fsMocks.readSync - }), - statSync: fsMocks.statSync, - unlinkSync: fsMocks.unlinkSync -})) - -import { - AGENT_HOOK_ENDPOINT_SWEEP_MAX_ENTRIES, - sweepStaleAgentHookEndpointTemps -} from './agent-hook-endpoint-temp-cleanup' - -describe('agent hook endpoint temp cleanup', () => { - beforeEach(() => { - vi.clearAllMocks() - fsMocks.statSync.mockReturnValue({ mtimeMs: 0 }) - }) - - it('stops an unbounded directory source at the scan cap', () => { - let entry = 0 - fsMocks.readSync.mockImplementation(() => ({ - name: `.endpoint-${(entry += 1)}.tmp` - })) - - sweepStaleAgentHookEndpointTemps('/endpoint', 10 * 60 * 1000) - - expect(fsMocks.readSync).toHaveBeenCalledTimes(AGENT_HOOK_ENDPOINT_SWEEP_MAX_ENTRIES) - expect(fsMocks.unlinkSync).toHaveBeenCalledTimes(AGENT_HOOK_ENDPOINT_SWEEP_MAX_ENTRIES) - expect(fsMocks.closeSync).toHaveBeenCalledOnce() - }) - - it('only removes stale endpoint temp files', () => { - fsMocks.readSync - .mockReturnValueOnce({ name: '.endpoint-stale.tmp' }) - .mockReturnValueOnce({ name: '.endpoint-fresh.tmp' }) - .mockReturnValueOnce({ name: 'endpoint.env' }) - .mockReturnValueOnce(null) - fsMocks.statSync - .mockReturnValueOnce({ mtimeMs: 0 }) - .mockReturnValueOnce({ mtimeMs: 9 * 60 * 1000 }) - - sweepStaleAgentHookEndpointTemps('/endpoint', 10 * 60 * 1000) - - expect(fsMocks.unlinkSync).toHaveBeenCalledOnce() - expect(fsMocks.unlinkSync).toHaveBeenCalledWith(expect.stringContaining('.endpoint-stale.tmp')) - }) -}) diff --git a/src/shared/agent-hook-endpoint-temp-cleanup.ts b/src/shared/agent-hook-endpoint-temp-cleanup.ts deleted file mode 100644 index 7a297f5104a..00000000000 --- a/src/shared/agent-hook-endpoint-temp-cleanup.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { opendirSync, statSync, unlinkSync } from 'node:fs' -import { join } from 'node:path' - -export const AGENT_HOOK_ENDPOINT_SWEEP_MAX_ENTRIES = 1024 -const AGENT_HOOK_ENDPOINT_STALE_MS = 5 * 60 * 1000 - -export function sweepStaleAgentHookEndpointTemps(endpointDir: string, now = Date.now()): void { - let directory: ReturnType | undefined - try { - directory = opendirSync(endpointDir, { bufferSize: 32 }) - const cutoff = now - AGENT_HOOK_ENDPOINT_STALE_MS - for (let scanned = 0; scanned < AGENT_HOOK_ENDPOINT_SWEEP_MAX_ENTRIES; scanned += 1) { - const entry = directory.readSync() - if (entry === null) { - break - } - if (!entry.name.startsWith('.endpoint-') || !entry.name.endsWith('.tmp')) { - continue - } - const entryPath = join(endpointDir, entry.name) - try { - if (statSync(entryPath).mtimeMs < cutoff) { - unlinkSync(entryPath) - } - } catch { - // best-effort sweep - } - } - } catch { - // Endpoint publication must still proceed on exotic filesystems. - } finally { - try { - directory?.closeSync() - } catch { - // already closed - } - } -} diff --git a/src/shared/agent-hook-listener-roster-retention.test.ts b/src/shared/agent-hook-listener-roster-retention.test.ts deleted file mode 100644 index 648792f4d53..00000000000 --- a/src/shared/agent-hook-listener-roster-retention.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - createHookListenerState, - normalizeHookPayload, - type HookListenerState -} from './agent-hook-listener' -import { MAX_AGENT_HOOK_STATUS_CACHE_PANES } from './agent-hook-status-cache' -import { makePaneKey } from './stable-pane-id' - -const LEAF_ID = '11111111-1111-4111-8111-111111111111' - -function claudeEvent( - state: HookListenerState, - paneKey: string, - payload: Record -): ReturnType { - return normalizeHookPayload(state, 'claude', { paneKey, payload }, 'production') -} - -describe('Claude hook roster retention', () => { - it('does not retain rosters for malformed lifecycle events across unique panes', () => { - const state = createHookListenerState() - for (let index = 0; index <= MAX_AGENT_HOOK_STATUS_CACHE_PANES; index += 1) { - const paneKey = makePaneKey(`malformed-${index}`, LEAF_ID) - expect(claudeEvent(state, paneKey, { hook_event_name: 'TeammateIdle' })).toBeNull() - } - - expect(state.lastStatusByPaneKey.size).toBe(0) - expect(state.claudeSubagentRosterByPaneKey.size).toBe(0) - }) - - it('preserves ordinary teammate lifecycle updates', () => { - const state = createHookListenerState() - const paneKey = makePaneKey('valid-lifecycle', LEAF_ID) - claudeEvent(state, paneKey, { hook_event_name: 'UserPromptSubmit', prompt: 'spawn reviewer' }) - claudeEvent(state, paneKey, { hook_event_name: 'Stop', background_tasks: [] }) - - const started = claudeEvent(state, paneKey, { - hook_event_name: 'SubagentStart', - agent_id: 'areviewer-6d3cb5b52120b7bf', - agent_type: 'security-reviewer' - }) - expect(started?.payload.subagents).toEqual([ - expect.objectContaining({ id: 'areviewer-6d3cb5b52120b7bf', state: 'working' }) - ]) - - const idled = claudeEvent(state, paneKey, { - hook_event_name: 'TeammateIdle', - teammate_name: 'reviewer' - }) - expect(idled?.payload.state).toBe('done') - expect(idled?.payload.subagents).toEqual([ - expect.objectContaining({ id: 'areviewer-6d3cb5b52120b7bf', state: 'idle' }) - ]) - expect(state.claudeSubagentRosterByPaneKey.size).toBe(1) - }) -}) diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index 3f043a0a245..1e44aaf6359 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -9,6 +9,7 @@ import { closeSync, mkdirSync, openSync, + readdirSync, readSync, renameSync, statSync, @@ -63,21 +64,9 @@ import { resolveGrokChatHistoryPathSync, resolveGrokSessionsDir } from './grok-session-paths' -import { sweepStaleAgentHookEndpointTemps } from './agent-hook-endpoint-temp-cleanup' -import { assertJsonTextStructureWithinLimits } from './json-text-structure-limit' /** Maximum request body size accepted by the listener (1 MB). */ export const HOOK_REQUEST_MAX_BYTES = 1_000_000 -const HOOK_REQUEST_INITIAL_BUFFER_BYTES = 4 * 1024 -const AGENT_HOOK_JSON_STRUCTURE_LIMITS = { - structuralTokens: 128 * 1024, - nestingDepth: 64 -} as const - -function parseAgentHookJson(content: string): unknown { - assertJsonTextStructureWithinLimits(content, AGENT_HOOK_JSON_STRUCTURE_LIMITS) - return JSON.parse(content) as unknown -} /** Bound the warn-once Sets so a client varying `version`/`env` per request can't grow them unbounded. */ const MAX_WARNED_KEYS = 32 @@ -314,7 +303,7 @@ export function parseFormEncodedBody(body: string): Record { export function readRequestBody(req: IncomingMessage): Promise { return new Promise((resolve, reject) => { - let retained = Buffer.alloc(0) + const chunks: Buffer[] = [] let byteLength = 0 let settled = false const cleanup = (): void => { @@ -343,30 +332,21 @@ export function readRequestBody(req: IncomingMessage): Promise { } const onData = (chunk: Buffer): void => { // Why: bound by bytes (not UTF-16 units) and stop accumulating after rejection so a client can't push memory past the cap. - const nextByteLength = byteLength + chunk.length - if (nextByteLength > HOOK_REQUEST_MAX_BYTES) { + if (byteLength + chunk.length > HOOK_REQUEST_MAX_BYTES) { settleReject(new Error('payload too large')) req.destroy() return } - if (retained.length < nextByteLength) { - const nextCapacity = Math.min( - HOOK_REQUEST_MAX_BYTES, - Math.max(HOOK_REQUEST_INITIAL_BUFFER_BYTES, retained.length * 2, nextByteLength) - ) - const next = Buffer.allocUnsafe(nextCapacity) - retained.copy(next, 0, 0, byteLength) - retained = next - } - chunk.copy(retained, byteLength) - byteLength = nextByteLength + byteLength += chunk.length + chunks.push(chunk) } const onEnd = (): void => { try { - const body = retained.toString('utf8', 0, byteLength) + // Why: Buffer.concat before decode so multi-byte UTF-8 straddling a chunk boundary reassembles correctly. + const body = chunks.length > 0 ? Buffer.concat(chunks).toString('utf8') : '' const contentType = req.headers['content-type'] ?? '' if (typeof contentType === 'string' && contentType.includes('application/json')) { - settleResolve(body ? parseAgentHookJson(body) : {}) + settleResolve(body ? JSON.parse(body) : {}) return } if ( @@ -377,7 +357,7 @@ export function readRequestBody(req: IncomingMessage): Promise { return } // Why: managed scripts POST JSON, updated POSIX scripts form-encoded; default to JSON for unknown content types. - settleResolve(body ? parseAgentHookJson(body) : {}) + settleResolve(body ? JSON.parse(body) : {}) } catch (error) { settleReject(error) } @@ -799,7 +779,7 @@ function parseJsonObjectString(value: unknown): Record | undefi return undefined } try { - const parsed = parseAgentHookJson(value) + const parsed = JSON.parse(value) as unknown return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) ? (parsed as Record) : undefined @@ -844,7 +824,7 @@ const GROK_HOME_ENVELOPE_MAX_LENGTH = 4096 function extractAssistantTextFromLine(line: string): string | undefined { let entry: unknown try { - entry = parseAgentHookJson(line) + entry = JSON.parse(line) } catch { return undefined } @@ -910,7 +890,7 @@ function extractAntigravityUserRequest(content: string): string | undefined { function extractUserPromptTextFromLine(line: string): string | undefined { let entry: unknown try { - entry = parseAgentHookJson(line) + entry = JSON.parse(line) } catch { return undefined } @@ -945,7 +925,7 @@ function readLastUserPromptFromTranscript(transcriptPath: unknown): string | und function extractCommandCodeUserPromptFromLine(line: string): string | undefined { let entry: unknown try { - entry = parseAgentHookJson(line) + entry = JSON.parse(line) } catch { return undefined } @@ -1041,7 +1021,7 @@ function* iterateTranscriptLinesWithByteOffsets( function extractCommandCodeAssistantTextFromLine(line: string): string | undefined { let entry: unknown try { - entry = parseAgentHookJson(line) + entry = JSON.parse(line) } catch { return undefined } @@ -1088,7 +1068,7 @@ function parseHookBodyPayloadRecord(body: unknown): Record | nu typeof rawPayload === 'string' ? (() => { try { - return parseAgentHookJson(rawPayload) + return JSON.parse(rawPayload) as unknown } catch { return null } @@ -2364,21 +2344,22 @@ function normalizeClaudeSubagentLifecycleEvent( paneKey: string, hookPayload: Record ): ParsedAgentStatusPayload | null { - const lifecycleField = eventName === 'TeammateIdle' ? 'teammate_name' : 'agent_id' - const lifecycleId = readString(hookPayload, lifecycleField) - if (!lifecycleId) { - return null - } const roster = getOrCreateClaudeSubagentRoster(state, paneKey) if (eventName === 'TeammateIdle') { - const teammateName = lifecycleId + const teammateName = readString(hookPayload, 'teammate_name') + if (!teammateName) { + return null + } // Why: on claude 2.1.21x teammates are turn-based — TeammateIdle means "turn over, awaiting mail", not finished. The row parks as idle (confirmed teammate) instead of leaving, so the sidebar keeps showing resumable children. idleClaudeTeammateByName(roster, teammateName) clearClaudePendingWaitForAgent(state, paneKey, (waitingAgentId) => claudeTeammateIdMatchesName(waitingAgentId, teammateName) ) } else { - const agentId = lifecycleId + const agentId = readString(hookPayload, 'agent_id') + if (!agentId) { + return null + } if (eventName === 'SubagentStart') { upsertWorkingClaudeSubagent( roster, @@ -3800,7 +3781,7 @@ export function normalizeHookPayload( typeof rawPayload === 'string' ? (() => { try { - return parseAgentHookJson(rawPayload) + return JSON.parse(rawPayload) } catch { return null } @@ -4084,8 +4065,26 @@ export function writeEndpointFile( // best-effort } } - // Why: crash-orphan cleanup must not materialize a tampered, enormous directory. - sweepStaleAgentHookEndpointTemps(endpointDir) + // Why: sweep stale .endpoint-*.tmp orphans (crash between write and rename) so the dir can't grow unbounded. + try { + const entries = readdirSync(endpointDir) + const cutoff = Date.now() - 5 * 60 * 1000 + for (const entry of entries) { + if (!entry.startsWith('.endpoint-') || !entry.endsWith('.tmp')) { + continue + } + const entryPath = join(endpointDir, entry) + try { + if (statSync(entryPath).mtimeMs < cutoff) { + unlinkSync(entryPath) + } + } catch { + // best-effort sweep + } + } + } catch { + // readdirSync can fail on exotic filesystems + } const separator = process.platform === 'win32' ? '\r\n' : '\n' writeFileSync(tmpPath, lines.join(separator), { mode: 0o600 }) tmpWritten = true diff --git a/src/shared/agent-hook-request-body-memory.test.ts b/src/shared/agent-hook-request-body-memory.test.ts deleted file mode 100644 index 0fae9ff87c4..00000000000 --- a/src/shared/agent-hook-request-body-memory.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { EventEmitter } from 'node:events' -import type { IncomingHttpHeaders, IncomingMessage } from 'node:http' -import { describe, expect, it, vi } from 'vitest' -import { readRequestBody } from './agent-hook-listener' - -type FakeIncomingMessage = EventEmitter & { - headers: IncomingHttpHeaders - destroy: ReturnType -} - -function createReadableRequest(): FakeIncomingMessage { - const request = new EventEmitter() as FakeIncomingMessage - request.headers = { 'content-type': 'application/json' } - request.destroy = vi.fn(() => request.emit('close')) - return request -} - -describe('agent hook request body retention', () => { - it('accepts adversarial one-byte events without per-event retained buffers', async () => { - const request = createReadableRequest() - const reading = readRequestBody(request as unknown as IncomingMessage) - const value = 'x'.repeat(100_000) - const body = Buffer.from(JSON.stringify({ value })) - - for (let index = 0; index < body.length; index += 1) { - request.emit('data', body.subarray(index, index + 1)) - } - request.emit('end') - - await expect(reading).resolves.toEqual({ value }) - }) -}) diff --git a/src/shared/agent-hook-status-cache.test.ts b/src/shared/agent-hook-status-cache.test.ts deleted file mode 100644 index fc7238cfbdc..00000000000 --- a/src/shared/agent-hook-status-cache.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { createHookListenerState, type AgentHookEventPayload } from './agent-hook-listener' -import { upsertBoundedAgentHookStatus } from './agent-hook-status-cache' -import { AGENT_STATUS_STALE_AFTER_MS } from './agent-status-types' - -function status( - paneKey: string, - state: AgentHookEventPayload['payload']['state'], - receivedAt: number -): AgentHookEventPayload { - return { - paneKey, - connectionId: null, - payload: { state, prompt: paneKey, agentType: 'claude' }, - receivedAt - } as AgentHookEventPayload -} - -describe('bounded agent hook status cache', () => { - it('preserves the current row and falls back to least-recently-updated eviction', () => { - const listener = createHookListenerState() - const now = Date.now() - upsertBoundedAgentHookStatus(listener, status('oldest', 'working', now), { maxPanes: 2, now }) - upsertBoundedAgentHookStatus(listener, status('newer', 'working', now), { maxPanes: 2, now }) - - const evicted = upsertBoundedAgentHookStatus(listener, status('current', 'working', now), { - maxPanes: 2, - now - }) - - expect(evicted.map(({ paneKey }) => paneKey)).toEqual(['oldest']) - expect([...listener.lastStatusByPaneKey.keys()]).toEqual(['newer', 'current']) - }) - - it('prefers the oldest completed or stale row and clears its related pane caches', () => { - const listener = createHookListenerState() - const now = Date.now() - upsertBoundedAgentHookStatus(listener, status('fresh-oldest', 'working', now), { - maxPanes: 3, - now - }) - upsertBoundedAgentHookStatus(listener, status('stale', 'working', now), { - maxPanes: 3, - now - }) - upsertBoundedAgentHookStatus(listener, status('done', 'done', now), { maxPanes: 3, now }) - const stale = listener.lastStatusByPaneKey.get('stale') as AgentHookEventPayload & { - receivedAt: number - } - stale.receivedAt = now - AGENT_STATUS_STALE_AFTER_MS - 1 - listener.lastPromptByPaneKey.set('stale', 'cached prompt') - listener.lastToolByPaneKey.set('stale\0tool', {} as never) - - const evicted = upsertBoundedAgentHookStatus(listener, status('current', 'working', now), { - maxPanes: 3, - now - }) - - expect(evicted.map(({ paneKey }) => paneKey)).toEqual(['stale']) - expect(listener.lastStatusByPaneKey.has('fresh-oldest')).toBe(true) - expect(listener.lastStatusByPaneKey.has('done')).toBe(true) - expect(listener.lastStatusByPaneKey.has('current')).toBe(true) - expect(listener.lastPromptByPaneKey.has('stale')).toBe(false) - expect(listener.lastToolByPaneKey.has('stale\0tool')).toBe(false) - }) -}) diff --git a/src/shared/agent-hook-status-cache.ts b/src/shared/agent-hook-status-cache.ts deleted file mode 100644 index 283e6d5589d..00000000000 --- a/src/shared/agent-hook-status-cache.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { - clearPaneCacheState, - type AgentHookEventPayload, - type HookListenerState -} from './agent-hook-listener' -import { AGENT_STATUS_STALE_AFTER_MS } from './agent-status-types' - -export const MAX_AGENT_HOOK_STATUS_CACHE_PANES = 500 - -export type AgentHookStatusCacheEviction = { - paneKey: string - entry: AgentHookEventPayload -} - -export function upsertBoundedAgentHookStatus( - state: HookListenerState, - entry: AgentHookEventPayload, - options: { maxPanes?: number; now?: number } = {} -): AgentHookStatusCacheEviction[] { - const maxPanes = options.maxPanes ?? MAX_AGENT_HOOK_STATUS_CACHE_PANES - if (!Number.isSafeInteger(maxPanes) || maxPanes < 1) { - throw new RangeError('Agent hook status cache limit must be a positive safe integer') - } - - state.lastStatusByPaneKey.delete(entry.paneKey) - state.lastStatusByPaneKey.set(entry.paneKey, entry) - const evicted: AgentHookStatusCacheEviction[] = [] - const now = options.now ?? Date.now() - while (state.lastStatusByPaneKey.size > maxPanes) { - const paneKey = selectEvictionCandidate(state, entry.paneKey, now) - if (!paneKey) { - break - } - const cached = state.lastStatusByPaneKey.get(paneKey) - if (!cached) { - break - } - evicted.push({ paneKey, entry: cached }) - clearPaneCacheState(state, paneKey) - } - return evicted -} - -function selectEvictionCandidate( - state: HookListenerState, - currentPaneKey: string, - now: number -): string | undefined { - let oldestFallback: string | undefined - for (const [paneKey, entry] of state.lastStatusByPaneKey) { - if (paneKey === currentPaneKey) { - continue - } - oldestFallback ??= paneKey - if (entry.payload.state === 'done' || isStaleStatus(entry, now)) { - return paneKey - } - } - return oldestFallback -} - -function isStaleStatus(entry: AgentHookEventPayload, now: number): boolean { - const receivedAt = (entry as AgentHookEventPayload & { receivedAt?: unknown }).receivedAt - return ( - typeof receivedAt === 'number' && - Number.isFinite(receivedAt) && - now - receivedAt > AGENT_STATUS_STALE_AFTER_MS - ) -} diff --git a/src/shared/agent-status-types.test.ts b/src/shared/agent-status-types.test.ts index f6128a075d1..b413741479e 100644 --- a/src/shared/agent-status-types.test.ts +++ b/src/shared/agent-status-types.test.ts @@ -3,7 +3,6 @@ import { agentSubagentsEqual, parseAgentStatusPayload, normalizeAgentStatusPayload, - AGENT_STATUS_JSON_STRUCTURE_LIMITS, AGENT_STATUS_MAX_FIELD_LENGTH, AGENT_STATUS_MAX_SUBAGENTS, AGENT_STATUS_TOOL_NAME_MAX_LENGTH, @@ -56,17 +55,6 @@ describe('parseAgentStatusPayload', () => { expect(parseAgentStatusPayload('')).toBeNull() }) - it('rejects excessive nesting before JSON.parse', () => { - const parseSpy = vi.spyOn(JSON, 'parse') - const depth = AGENT_STATUS_JSON_STRUCTURE_LIMITS.nestingDepth + 1 - try { - expect(parseAgentStatusPayload(`${'['.repeat(depth)}0${']'.repeat(depth)}`)).toBeNull() - expect(parseSpy).not.toHaveBeenCalled() - } finally { - parseSpy.mockRestore() - } - }) - it('returns null for non-object JSON', () => { expect(parseAgentStatusPayload('"just a string"')).toBeNull() expect(parseAgentStatusPayload('42')).toBeNull() diff --git a/src/shared/agent-status-types.ts b/src/shared/agent-status-types.ts index 1968ee0890f..11a6385c764 100644 --- a/src/shared/agent-status-types.ts +++ b/src/shared/agent-status-types.ts @@ -9,7 +9,6 @@ import { normalizeOptionalMultilineField, normalizePromptField } from './agent-status-field-normalization' -import { assertJsonTextStructureWithinLimits } from './json-text-structure-limit' export { AGENT_STATUS_MAX_FIELD_LENGTH } from './agent-status-field-normalization' @@ -241,10 +240,6 @@ export const AGENT_MODEL_MAX_LENGTH = 120 /** Maximum subagent child rows carried per status entry. Bounds per-pane cache * and IPC fanout against a runaway spawner. */ export const AGENT_STATUS_MAX_SUBAGENTS = 32 -export const AGENT_STATUS_JSON_STRUCTURE_LIMITS = { - structuralTokens: 4096, - nestingDepth: 16 -} as const const AGENT_SUBAGENT_ID_MAX_LENGTH = 64 function normalizeSubagentSnapshot(value: unknown): AgentSubagentSnapshot | null { @@ -381,7 +376,6 @@ export function normalizeAgentStatusPayload(payload: unknown): ParsedAgentStatus */ export function parseAgentStatusPayload(json: string): ParsedAgentStatusPayload | null { try { - assertJsonTextStructureWithinLimits(json, AGENT_STATUS_JSON_STRUCTURE_LIMITS) return normalizeAgentStatusObject(JSON.parse(json)) } catch { return null diff --git a/src/shared/automations-types.ts b/src/shared/automations-types.ts index d2572020ba8..a41b364ef2b 100644 --- a/src/shared/automations-types.ts +++ b/src/shared/automations-types.ts @@ -253,7 +253,6 @@ export type ExternalAutomationJob = { lastError: string | null workdir: string | null runCount: number - runCountSaturated?: true runs: ExternalAutomationRun[] } @@ -278,7 +277,6 @@ export type ExternalAutomationRunsPage = { page: number pageSize: number total: number - totalSaturated?: true runs: ExternalAutomationRun[] } diff --git a/src/shared/bounded-secure-json-file.ts b/src/shared/bounded-secure-json-file.ts deleted file mode 100644 index c9da2b9ce6f..00000000000 --- a/src/shared/bounded-secure-json-file.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { stringifyJsonWithinByteLimit } from './node-bounded-json-stringify' -import { writeSecureFile } from './secure-file' - -export function writeSecureJsonFileWithinLimit( - targetPath: string, - value: unknown, - maxBytes: number -): void { - writeSecureFile(targetPath, stringifyJsonWithinByteLimit(value, maxBytes).serialized) -} diff --git a/src/shared/browser-screencast-protocol.test.ts b/src/shared/browser-screencast-protocol.test.ts index a2a9bec083f..c667646af05 100644 --- a/src/shared/browser-screencast-protocol.test.ts +++ b/src/shared/browser-screencast-protocol.test.ts @@ -1,7 +1,5 @@ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import { - BROWSER_SCREENCAST_MAX_METADATA_BYTES, - BROWSER_SCREENCAST_METADATA_JSON_STRUCTURE_LIMITS, BrowserScreencastOpcode, decodeBrowserScreencastFrame, encodeBrowserScreencastFrame @@ -76,39 +74,6 @@ describe('browser screencast binary protocol', () => { expect(decodeBrowserScreencastFrame(encoded)).toBeNull() }) - it('rejects oversized metadata before decoding it', () => { - const encoded = new Uint8Array(16 + BROWSER_SCREENCAST_MAX_METADATA_BYTES + 1) - const view = new DataView(encoded.buffer) - encoded[0] = 0x62 - encoded[1] = 1 - encoded[2] = BrowserScreencastOpcode.Frame - encoded[3] = 1 - view.setUint32(8, BROWSER_SCREENCAST_MAX_METADATA_BYTES + 1, true) - - expect(decodeBrowserScreencastFrame(encoded)).toBeNull() - }) - - it('rejects excessive metadata nesting before JSON.parse', () => { - const parseSpy = vi.spyOn(JSON, 'parse') - try { - const depth = BROWSER_SCREENCAST_METADATA_JSON_STRUCTURE_LIMITS.nestingDepth + 1 - const metadata = new TextEncoder().encode(`${'['.repeat(depth)}0${']'.repeat(depth)}`) - const encoded = new Uint8Array(16 + metadata.byteLength) - const view = new DataView(encoded.buffer) - encoded[0] = 0x62 - encoded[1] = 1 - encoded[2] = BrowserScreencastOpcode.Frame - encoded[3] = 1 - view.setUint32(8, metadata.byteLength, true) - encoded.set(metadata, 16) - - expect(decodeBrowserScreencastFrame(encoded)).toBeNull() - expect(parseSpy).not.toHaveBeenCalled() - } finally { - parseSpy.mockRestore() - } - }) - it('rejects frames with nonzero reserved header bytes', () => { const encoded = encodeBrowserScreencastFrame({ opcode: BrowserScreencastOpcode.Frame, diff --git a/src/shared/browser-screencast-protocol.ts b/src/shared/browser-screencast-protocol.ts index 842fe86cb3d..cae15b3e22c 100644 --- a/src/shared/browser-screencast-protocol.ts +++ b/src/shared/browser-screencast-protocol.ts @@ -1,13 +1,6 @@ -import { assertJsonTextStructureWithinLimits } from './json-text-structure-limit' - const BROWSER_SCREENCAST_KIND = 0x62 const BROWSER_SCREENCAST_VERSION = 1 const HEADER_BYTES = 16 -export const BROWSER_SCREENCAST_MAX_METADATA_BYTES = 64 * 1024 -export const BROWSER_SCREENCAST_METADATA_JSON_STRUCTURE_LIMITS = { - structuralTokens: 512, - nestingDepth: 8 -} as const const METADATA_KEYS = [ 'offsetTop', 'pageScaleFactor', @@ -65,13 +58,8 @@ function encodeJson(value: unknown): Uint8Array { } function decodeJson(bytes: Uint8Array): unknown { - if (bytes.byteLength > BROWSER_SCREENCAST_MAX_METADATA_BYTES) { - return null - } try { - const content = new TextDecoder().decode(bytes) - assertJsonTextStructureWithinLimits(content, BROWSER_SCREENCAST_METADATA_JSON_STRUCTURE_LIMITS) - return JSON.parse(content) as unknown + return JSON.parse(new TextDecoder().decode(bytes)) as unknown } catch { return null } @@ -133,9 +121,6 @@ export function decodeBrowserScreencastFrame(bytes: Uint8Array): BrowserScreenca } const seq = view.getUint32(4, true) const metadataLength = view.getUint32(8, true) - if (metadataLength > BROWSER_SCREENCAST_MAX_METADATA_BYTES) { - return null - } if (view.getUint32(12, true) !== 0) { return null } diff --git a/src/shared/commit-message-agent-spec.test.ts b/src/shared/commit-message-agent-spec.test.ts index 3d8eac29c81..7a3356e2eb2 100644 --- a/src/shared/commit-message-agent-spec.test.ts +++ b/src/shared/commit-message-agent-spec.test.ts @@ -1,7 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { COMMIT_MESSAGE_AGENT_SPECS, - COMMIT_MESSAGE_MODEL_JSON_STRUCTURE_LIMITS, CUSTOM_AGENT_ID, DEFAULT_COMMIT_MESSAGE_AGENT_ID, getCommitMessageAgentCapability, @@ -221,17 +220,6 @@ describe('model discovery parsers', () => { ]) }) - it('rejects excessive Codex model nesting before JSON.parse', () => { - const parseSpy = vi.spyOn(JSON, 'parse') - const depth = COMMIT_MESSAGE_MODEL_JSON_STRUCTURE_LIMITS.nestingDepth + 1 - try { - expect(parseCodexModels(`${'['.repeat(depth)}0${']'.repeat(depth)}`)).toEqual([]) - expect(parseSpy).not.toHaveBeenCalled() - } finally { - parseSpy.mockRestore() - } - }) - it('parses one-model-per-line output', () => { expect(parseLineModels('opencode/gpt-5.4-mini\n\nopenai/gpt-5.5\n').map((m) => m.id)).toEqual([ 'opencode/gpt-5.4-mini', diff --git a/src/shared/commit-message-agent-spec.ts b/src/shared/commit-message-agent-spec.ts index 98ed09d681e..df6f0d27584 100644 --- a/src/shared/commit-message-agent-spec.ts +++ b/src/shared/commit-message-agent-spec.ts @@ -1,6 +1,5 @@ import type { TuiAgent } from './types' import { isTuiAgentEnabled } from './tui-agent-selection' -import { assertJsonTextStructureWithinLimits } from './json-text-structure-limit' /* eslint-disable max-lines -- Why: this is the single registry for non-interactive commit-message agents, their model discovery parsers, and UI capabilities. */ @@ -58,11 +57,6 @@ export type CommitMessageAgentCapability = { defaultModelId: string } -export const COMMIT_MESSAGE_MODEL_JSON_STRUCTURE_LIMITS = { - structuralTokens: 64 * 1024, - nestingDepth: 16 -} as const - const BASIC_THINKING_LEVELS: ThinkingLevel[] = [ { id: 'low', label: 'Low' }, { id: 'medium', label: 'Medium' }, @@ -141,7 +135,6 @@ function withOpenAiThinking( export function parseCodexModels(stdout: string): CommitMessageModel[] { try { - assertJsonTextStructureWithinLimits(stdout, COMMIT_MESSAGE_MODEL_JSON_STRUCTURE_LIMITS) const parsed = JSON.parse(stdout) as { models?: { slug?: string diff --git a/src/shared/custom-pet-media-limits.ts b/src/shared/custom-pet-media-limits.ts deleted file mode 100644 index f3778069e40..00000000000 --- a/src/shared/custom-pet-media-limits.ts +++ /dev/null @@ -1,19 +0,0 @@ -export const MAX_CUSTOM_PET_FILE_BYTES = 64 * 1024 * 1024 - -// Why: sprite processing holds decoded image, canvas, ImageData, PNG, and -// optional bitmap copies at once, so encoded bytes alone are not a safe bound. -export const MAX_CUSTOM_PET_SHEET_PIXELS = 4 * 1024 * 1024 -export const MAX_CUSTOM_PET_SHEET_DIMENSION = 8_192 -export const MAX_CUSTOM_PET_DETECTED_FRAMES = 128 - -export function isCustomPetSheetSizeSafe(width: number, height: number): boolean { - return ( - Number.isSafeInteger(width) && - Number.isSafeInteger(height) && - width > 0 && - height > 0 && - width <= MAX_CUSTOM_PET_SHEET_DIMENSION && - height <= MAX_CUSTOM_PET_SHEET_DIMENSION && - width * height <= MAX_CUSTOM_PET_SHEET_PIXELS - ) -} diff --git a/src/shared/ephemeral-vm-recipe-process.test.ts b/src/shared/ephemeral-vm-recipe-process.test.ts index 0933305f689..80d1ac589d0 100644 --- a/src/shared/ephemeral-vm-recipe-process.test.ts +++ b/src/shared/ephemeral-vm-recipe-process.test.ts @@ -23,33 +23,6 @@ function nodeCommand(scriptPath: string): string { } describe('runRecipeCommand', () => { - it.each([ - { output: 'abcdef', maxCaptureBytes: 4, expected: 'cdef' }, - { output: 'A😀B', maxCaptureBytes: 5, expected: '😀B' }, - { output: '😀😀😀', maxCaptureBytes: 5, expected: '😀' } - ])( - 'retains a complete UTF-8 tail within $maxCaptureBytes bytes', - async ({ output, maxCaptureBytes, expected }) => { - const repoPath = makeRepo() - const scriptPath = join(repoPath, 'output.js') - writeFileSync(scriptPath, `process.stdout.write(${JSON.stringify(output)})`) - - const result = await runRecipeCommand({ - command: nodeCommand(scriptPath), - repoPath, - mode: 'create', - context: { - recipeId: 'cloud-sandbox', - repoPath - }, - maxCaptureBytes - }) - - expect(result.stdout).toBe(expected) - expect(Buffer.byteLength(result.stdout, 'utf8')).toBeLessThanOrEqual(maxCaptureBytes) - } - ) - it.skipIf(process.platform === 'win32')( 'cancels shell child processes without waiting for long-running descendants', async () => { diff --git a/src/shared/ephemeral-vm-recipe-process.ts b/src/shared/ephemeral-vm-recipe-process.ts index 5c0c24c82b9..40b725558c7 100644 --- a/src/shared/ephemeral-vm-recipe-process.ts +++ b/src/shared/ephemeral-vm-recipe-process.ts @@ -143,24 +143,9 @@ function buildRecipeEnv( } function appendBounded(current: string, chunk: string, maxBytes: number): string { - if (maxBytes <= 0) { - return '' + const next = current + chunk + if (Buffer.byteLength(next, 'utf8') <= maxBytes) { + return next } - const chunkBytes = Buffer.byteLength(chunk, 'utf8') - if (chunkBytes >= maxBytes) { - return utf8Tail(chunk, maxBytes) - } - return utf8Tail(current, maxBytes - chunkBytes) + chunk -} - -function utf8Tail(value: string, maxBytes: number): string { - const bytes = Buffer.from(value, 'utf8') - if (bytes.byteLength <= maxBytes) { - return value - } - let start = bytes.byteLength - maxBytes - while (start < bytes.byteLength && (bytes[start]! & 0xc0) === 0x80) { - start += 1 - } - return bytes.subarray(start).toString('utf8') + return next.slice(-maxBytes) } diff --git a/src/shared/ephemeral-vm-recipes.test.ts b/src/shared/ephemeral-vm-recipes.test.ts index b074c438327..9f0ee4fa15b 100644 --- a/src/shared/ephemeral-vm-recipes.test.ts +++ b/src/shared/ephemeral-vm-recipes.test.ts @@ -1,7 +1,6 @@ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import { encodePairingOffer, PAIRING_OFFER_VERSION } from './pairing' import { - EPHEMERAL_VM_RECIPE_JSON_STRUCTURE_LIMITS, getEphemeralVmRecipeResultWarnings, parseEphemeralVmRecipeResult, redactEphemeralVmRecipeDiagnosticText, @@ -155,22 +154,6 @@ describe('parseEphemeralVmRecipeResult', () => { }) }) - it('rejects excessive nesting before JSON.parse', () => { - const parseSpy = vi.spyOn(JSON, 'parse') - try { - const depth = EPHEMERAL_VM_RECIPE_JSON_STRUCTURE_LIMITS.nestingDepth + 1 - const amplified = `${'['.repeat(depth)}0${']'.repeat(depth)}` - - expect(parseEphemeralVmRecipeResult(amplified)).toEqual({ - ok: false, - error: 'Recipe stdout must be one JSON object.' - }) - expect(parseSpy).not.toHaveBeenCalled() - } finally { - parseSpy.mockRestore() - } - }) - it('rejects invalid pairing codes', () => { expect( parseEphemeralVmRecipeResult( diff --git a/src/shared/ephemeral-vm-recipes.ts b/src/shared/ephemeral-vm-recipes.ts index 0f68bc92f84..65729a261f3 100644 --- a/src/shared/ephemeral-vm-recipes.ts +++ b/src/shared/ephemeral-vm-recipes.ts @@ -5,7 +5,6 @@ import { MAX_SSH_RELAY_GRACE_PERIOD_SECONDS, MIN_SSH_RELAY_GRACE_PERIOD_SECONDS } from './ssh-types' -import { assertJsonTextStructureWithinLimits } from './json-text-structure-limit' // Why: ephemeral-vm-recipe-doctor imports Node's fs/path, so it must NOT be // re-exported through this barrel — the renderer/web-client imports this module // and would otherwise pull Node built-ins into the browser bundle (build fails). @@ -36,11 +35,6 @@ export type JsonValue = | JsonValue[] | { [key: string]: JsonValue } -export const EPHEMERAL_VM_RECIPE_JSON_STRUCTURE_LIMITS = { - structuralTokens: 256 * 1024, - nestingDepth: 64 -} as const - const SavedPortForwardSchema = z .object({ localPort: z.number().int().min(1).max(65535), @@ -157,7 +151,6 @@ export function parseEphemeralVmRecipeResult(stdout: string): EphemeralVmRecipeR } let parsed: unknown try { - assertJsonTextStructureWithinLimits(trimmed, EPHEMERAL_VM_RECIPE_JSON_STRUCTURE_LIMITS) parsed = JSON.parse(trimmed) } catch { return { ok: false, error: 'Recipe stdout must be one JSON object.' } diff --git a/src/shared/ephemeral-vm-runtime-store.test.ts b/src/shared/ephemeral-vm-runtime-store.test.ts index a252ffe53f1..2aea7ff4bfc 100644 --- a/src/shared/ephemeral-vm-runtime-store.test.ts +++ b/src/shared/ephemeral-vm-runtime-store.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, rmSync, truncateSync, writeFileSync } from 'node:fs' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' @@ -7,7 +7,6 @@ import { EphemeralVmRuntimeStoreError, getEphemeralVmRuntimeStorePath, listEphemeralVmRuntimes, - MAX_EPHEMERAL_VM_RUNTIME_STORE_FILE_BYTES, removeEphemeralVmRuntime, updateEphemeralVmRuntimeStatus, upsertEphemeralVmRuntime @@ -174,27 +173,4 @@ describe('ephemeral VM runtime store', () => { expect(() => listEphemeralVmRuntimes(userDataPath)).toThrow(EphemeralVmRuntimeStoreError) }) - - it('rejects an oversized sparse runtime store before parsing it', () => { - const userDataPath = makeUserDataPath() - const path = getEphemeralVmRuntimeStorePath(userDataPath) - writeFileSync(path, '{"version":1,"runtimes":[]}', 'utf8') - truncateSync(path, MAX_EPHEMERAL_VM_RUNTIME_STORE_FILE_BYTES + 1) - - expect(() => listEphemeralVmRuntimes(userDataPath)).toThrow(EphemeralVmRuntimeStoreError) - }) - - it('rejects an oversized write without publishing a partial runtime record', () => { - const userDataPath = makeUserDataPath() - - expect(() => - upsertEphemeralVmRuntime( - userDataPath, - runtimeRecord({ - cleanupLastError: 'x'.repeat(MAX_EPHEMERAL_VM_RUNTIME_STORE_FILE_BYTES) - }) - ) - ).toThrow(EphemeralVmRuntimeStoreError) - expect(listEphemeralVmRuntimes(userDataPath)).toEqual([]) - }) }) diff --git a/src/shared/ephemeral-vm-runtime-store.ts b/src/shared/ephemeral-vm-runtime-store.ts index ad26bb2950e..73e8ce785b2 100644 --- a/src/shared/ephemeral-vm-runtime-store.ts +++ b/src/shared/ephemeral-vm-runtime-store.ts @@ -1,9 +1,6 @@ -import { existsSync } from 'node:fs' +import { existsSync, readFileSync } from 'node:fs' import { join } from 'node:path' -import { JsonStringifyByteLimitError } from './node-bounded-json-stringify' -import { readNodeFileSyncWithinLimit } from './node-bounded-file-reader' -import { writeSecureJsonFileWithinLimit } from './bounded-secure-json-file' -import { hardenExistingSecureFile } from './secure-file' +import { hardenExistingSecureFile, writeSecureJsonFile } from './secure-file' import { EphemeralVmRuntimeRecordSchema, EphemeralVmRuntimeStoreSchema, @@ -14,7 +11,6 @@ import { } from './ephemeral-vm-runtimes' const EPHEMERAL_VM_RUNTIMES_FILE = 'orca-ephemeral-vm-runtimes.json' -export const MAX_EPHEMERAL_VM_RUNTIME_STORE_FILE_BYTES = 1024 * 1024 export type EphemeralVmRuntimeStoreErrorCode = 'invalid_argument' | 'runtime_error' @@ -140,14 +136,7 @@ function readEphemeralVmRuntimeStore(userDataPath: string): EphemeralVmRuntimeSt } try { hardenExistingSecureFile(path) - const parsed = EphemeralVmRuntimeStoreSchema.parse( - JSON.parse( - readNodeFileSyncWithinLimit( - path, - MAX_EPHEMERAL_VM_RUNTIME_STORE_FILE_BYTES - ).buffer.toString('utf8') - ) - ) + const parsed = EphemeralVmRuntimeStoreSchema.parse(JSON.parse(readFileSync(path, 'utf8'))) return { version: 1, runtimes: parsed.runtimes @@ -164,21 +153,7 @@ function readEphemeralVmRuntimeStore(userDataPath: string): EphemeralVmRuntimeSt function writeEphemeralVmRuntimeStore(userDataPath: string, store: EphemeralVmRuntimeStore): void { const path = getEphemeralVmRuntimeStorePath(userDataPath) - try { - writeSecureJsonFileWithinLimit( - path, - EphemeralVmRuntimeStoreSchema.parse(store), - MAX_EPHEMERAL_VM_RUNTIME_STORE_FILE_BYTES - ) - } catch (error) { - if (error instanceof JsonStringifyByteLimitError) { - throw new EphemeralVmRuntimeStoreError( - 'runtime_error', - `Could not write Orca ephemeral VM runtimes at ${path}; the store exceeds its durable capacity.` - ) - } - throw error - } + writeSecureJsonFile(path, EphemeralVmRuntimeStoreSchema.parse(store)) } function compareRuntimeRecords(a: EphemeralVmRuntimeRecord, b: EphemeralVmRuntimeRecord): number { diff --git a/src/shared/external-automation-jobs-file.test.ts b/src/shared/external-automation-jobs-file.test.ts deleted file mode 100644 index 13b8a7078bb..00000000000 --- a/src/shared/external-automation-jobs-file.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { mkdtemp, rm, truncate, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import { - EXTERNAL_AUTOMATION_JOBS_FILE_MAX_BYTES, - EXTERNAL_AUTOMATION_JOBS_MAX_ENTRIES, - readExternalAutomationJobsFile -} from './external-automation-jobs-file' - -const tempDirs: string[] = [] - -afterEach(async () => { - await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))) -}) - -async function tempFile(name: string): Promise { - const dir = await mkdtemp(join(tmpdir(), 'orca-automation-jobs-')) - tempDirs.push(dir) - return join(dir, name) -} - -describe('readExternalAutomationJobsFile', () => { - it('preserves supported jobs shapes below the limits', async () => { - const wrappedPath = await tempFile('wrapped.json') - const rootPath = await tempFile('root.json') - await writeFile(wrappedPath, JSON.stringify({ jobs: [{ id: 'one' }] })) - await writeFile(rootPath, JSON.stringify([{ id: 'two' }])) - - await expect( - readExternalAutomationJobsFile(wrappedPath, { allowRootArray: false }) - ).resolves.toEqual([{ id: 'one' }]) - await expect( - readExternalAutomationJobsFile(rootPath, { allowRootArray: true }) - ).resolves.toEqual([{ id: 'two' }]) - }) - - it('rejects oversized sparse files before reading them wholesale', async () => { - const path = await tempFile('oversized.json') - await writeFile(path, '') - await truncate(path, EXTERNAL_AUTOMATION_JOBS_FILE_MAX_BYTES + 64 * 1024 * 1024) - - await expect(readExternalAutomationJobsFile(path, { allowRootArray: true })).rejects.toThrow( - 'jobs file exceeds the 8 MiB memory limit' - ) - }) - - it('rejects excessive job counts explicitly', async () => { - const path = await tempFile('too-many.json') - await writeFile( - path, - JSON.stringify(Array.from({ length: EXTERNAL_AUTOMATION_JOBS_MAX_ENTRIES + 1 }, () => null)) - ) - - await expect(readExternalAutomationJobsFile(path, { allowRootArray: true })).rejects.toThrow( - 'more than 10,000 jobs' - ) - }) - - it('rejects structural amplification before parsing jobs', async () => { - const path = await tempFile('amplified.json') - await writeFile(path, '{"jobs":[{},{}]}') - - await expect( - readExternalAutomationJobsFile(path, { - allowRootArray: false, - structureLimits: { structuralTokens: 7, nestingDepth: 3 } - }) - ).rejects.toThrow('JSON structure') - }) -}) diff --git a/src/shared/external-automation-jobs-file.ts b/src/shared/external-automation-jobs-file.ts deleted file mode 100644 index 1013b8a0a53..00000000000 --- a/src/shared/external-automation-jobs-file.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { NodeFileReadTooLargeError, readNodeFileWithinLimit } from './node-bounded-file-reader' -import { - assertJsonTextStructureWithinLimits, - type JsonTextStructureLimits -} from './json-text-structure-limit' - -export const EXTERNAL_AUTOMATION_JOBS_FILE_MAX_BYTES = 8 * 1024 * 1024 -export const EXTERNAL_AUTOMATION_JOBS_MAX_ENTRIES = 10_000 -export const EXTERNAL_AUTOMATION_JOBS_JSON_LIMITS: JsonTextStructureLimits = { - structuralTokens: 1_000_000, - nestingDepth: 128 -} - -type ExternalAutomationJobsFileOptions = { - allowRootArray: boolean - structureLimits?: JsonTextStructureLimits -} - -export async function readExternalAutomationJobsFile( - filePath: string, - options: ExternalAutomationJobsFileOptions -): Promise { - let buffer: Buffer - try { - const result = await readNodeFileWithinLimit(filePath, EXTERNAL_AUTOMATION_JOBS_FILE_MAX_BYTES) - buffer = result.buffer - } catch (error) { - if (error instanceof NodeFileReadTooLargeError) { - throw new Error( - `External automation jobs file exceeds the ${EXTERNAL_AUTOMATION_JOBS_FILE_MAX_BYTES / 1024 / 1024} MiB memory limit: ${filePath}` - ) - } - throw error - } - - const serialized = buffer.toString('utf-8') - assertJsonTextStructureWithinLimits( - serialized, - options.structureLimits ?? EXTERNAL_AUTOMATION_JOBS_JSON_LIMITS - ) - const parsed = JSON.parse(serialized) as unknown - const jobs = - options.allowRootArray && Array.isArray(parsed) - ? parsed - : isRecord(parsed) && Array.isArray(parsed.jobs) - ? parsed.jobs - : [] - if (jobs.length > EXTERNAL_AUTOMATION_JOBS_MAX_ENTRIES) { - throw new Error( - `External automation jobs file contains more than ${EXTERNAL_AUTOMATION_JOBS_MAX_ENTRIES.toLocaleString()} jobs and cannot be loaded safely: ${filePath}` - ) - } - return jobs -} - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} diff --git a/src/shared/fetch-response-body.ts b/src/shared/fetch-response-body.ts deleted file mode 100644 index e9467fd3a2d..00000000000 --- a/src/shared/fetch-response-body.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { - assertJsonTextStructureWithinLimits, - type JsonTextStructureLimits -} from './json-text-structure-limit' - -const INITIAL_RESPONSE_CAPACITY_BYTES = 64 * 1024 - -export const API_RESPONSE_MAX_BYTES = 16 * 1024 * 1024 -export const API_RESPONSE_JSON_LIMITS: JsonTextStructureLimits = { - structuralTokens: 1_000_000, - nestingDepth: 128 -} - -export class FetchResponseBodyTooLargeError extends Error { - constructor( - readonly observedBytes: number, - readonly maxBytes: number - ) { - super(`Response body exceeds ${maxBytes} byte limit`) - this.name = 'FetchResponseBodyTooLargeError' - } -} - -function parseContentLength(response: Response): number | null { - const raw = response.headers.get('content-length') - if (!raw || !/^\d+$/.test(raw)) { - return null - } - const parsed = Number(raw) - return Number.isSafeInteger(parsed) ? parsed : null -} - -function isHighLevelOnlyResponse(response: Response): boolean { - const partial = response as Partial - // Injected request adapters may expose only the high-level method they implement. - return partial.headers === undefined && partial.body === undefined -} - -async function cancelReader(reader: ReadableStreamDefaultReader): Promise { - try { - await reader.cancel() - } catch { - // An already-errored or closed response needs no further draining. - } -} - -export async function readFetchResponseBytesWithinLimit( - response: Response, - maxBytes = API_RESPONSE_MAX_BYTES -): Promise { - if (!Number.isSafeInteger(maxBytes) || maxBytes < 0) { - throw new RangeError('Response body limit must be a non-negative safe integer') - } - - const contentLength = parseContentLength(response) - if (contentLength !== null && contentLength > maxBytes) { - await response.body?.cancel().catch(() => undefined) - throw new FetchResponseBodyTooLargeError(contentLength, maxBytes) - } - if (!response.body) { - return new Uint8Array() - } - - const reader = response.body.getReader() - let output = new Uint8Array(Math.min(maxBytes, INITIAL_RESPONSE_CAPACITY_BYTES)) - let byteLength = 0 - try { - while (true) { - const { done, value } = await reader.read() - if (done) { - return output.subarray(0, byteLength) - } - const nextLength = byteLength + value.byteLength - if (!Number.isSafeInteger(nextLength) || nextLength > maxBytes) { - await cancelReader(reader) - throw new FetchResponseBodyTooLargeError(nextLength, maxBytes) - } - if (nextLength > output.byteLength) { - const nextCapacity = Math.min( - maxBytes, - Math.max(INITIAL_RESPONSE_CAPACITY_BYTES, output.byteLength * 2, nextLength) - ) - const expanded = new Uint8Array(nextCapacity) - expanded.set(output.subarray(0, byteLength)) - output = expanded - } - output.set(value, byteLength) - byteLength = nextLength - } - } finally { - reader.releaseLock() - } -} - -export async function readFetchResponseTextWithinLimit( - response: Response, - maxBytes = API_RESPONSE_MAX_BYTES -): Promise { - if (isHighLevelOnlyResponse(response)) { - return response.text() - } - return new TextDecoder().decode(await readFetchResponseBytesWithinLimit(response, maxBytes)) -} - -export async function readFetchResponseJsonWithinLimit( - response: Response, - maxBytes = API_RESPONSE_MAX_BYTES, - structureLimits: JsonTextStructureLimits = API_RESPONSE_JSON_LIMITS -): Promise { - if (isHighLevelOnlyResponse(response)) { - return response.json() as Promise - } - const content = await readFetchResponseTextWithinLimit(response, maxBytes) - assertJsonTextStructureWithinLimits(content, structureLimits) - return JSON.parse(content) as T -} diff --git a/src/shared/filesystem-directory-listing-limit.test.ts b/src/shared/filesystem-directory-listing-limit.test.ts deleted file mode 100644 index 73e9feaadf7..00000000000 --- a/src/shared/filesystem-directory-listing-limit.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - assertFilesystemDirectoryWithinLimit, - createFilesystemDirectoryLimitState, - FILESYSTEM_DIRECTORY_LIMIT_MESSAGE, - FILESYSTEM_DIRECTORY_MAX_ENTRIES, - FILESYSTEM_DIRECTORY_MAX_RETAINED_BYTES, - resolveFilesystemDirectoryListingLimits, - trackFilesystemDirectoryEntry -} from './filesystem-directory-listing-limit' - -describe('filesystem directory listing limit', () => { - it('accepts ordinary complete listings', () => { - expect(() => - assertFilesystemDirectoryWithinLimit([ - { name: 'src' }, - { name: 'README.md' }, - { name: '文件.txt' } - ]) - ).not.toThrow() - }) - - it('rejects the first entry beyond the count limit', () => { - const state = createFilesystemDirectoryLimitState({ - maxEntries: 2, - maxRetainedBytes: FILESYSTEM_DIRECTORY_MAX_RETAINED_BYTES - }) - - trackFilesystemDirectoryEntry(state, { name: 'one' }) - trackFilesystemDirectoryEntry(state, { name: 'two' }) - expect(() => trackFilesystemDirectoryEntry(state, { name: 'three' })).toThrow( - FILESYSTEM_DIRECTORY_LIMIT_MESSAGE - ) - expect(state.entries).toBe(3) - }) - - it('rejects names beyond the retained-byte limit', () => { - const state = createFilesystemDirectoryLimitState({ - maxEntries: FILESYSTEM_DIRECTORY_MAX_ENTRIES, - maxRetainedBytes: 100 - }) - - expect(() => trackFilesystemDirectoryEntry(state, { name: 'xx' })).toThrow( - FILESYSTEM_DIRECTORY_LIMIT_MESSAGE - ) - }) - - it('never lets callers raise the process-wide ceilings', () => { - expect( - resolveFilesystemDirectoryListingLimits({ - maxEntries: Number.MAX_SAFE_INTEGER, - maxRetainedBytes: Number.MAX_SAFE_INTEGER - }) - ).toEqual({ - maxEntries: FILESYSTEM_DIRECTORY_MAX_ENTRIES, - maxRetainedBytes: FILESYSTEM_DIRECTORY_MAX_RETAINED_BYTES - }) - }) -}) diff --git a/src/shared/filesystem-directory-listing-limit.ts b/src/shared/filesystem-directory-listing-limit.ts deleted file mode 100644 index c43f0eebbcf..00000000000 --- a/src/shared/filesystem-directory-listing-limit.ts +++ /dev/null @@ -1,75 +0,0 @@ -export const FILESYSTEM_DIRECTORY_MAX_ENTRIES = 100_000 -export const FILESYSTEM_DIRECTORY_MAX_RETAINED_BYTES = 12 * 1024 * 1024 -export const FILESYSTEM_DIRECTORY_LIMIT_MESSAGE = - 'This folder is too large to list safely (limit: 100,000 items or a 12 MB listing).' - -export type FilesystemDirectoryListingLimits = { - maxEntries: number - maxRetainedBytes: number -} - -type NamedDirectoryEntry = { name: string } - -export type FilesystemDirectoryLimitState = { - entries: number - retainedBytes: number - limits: FilesystemDirectoryListingLimits -} - -export function resolveFilesystemDirectoryListingLimits( - requested?: Partial -): FilesystemDirectoryListingLimits { - return { - maxEntries: clampLimit(requested?.maxEntries, FILESYSTEM_DIRECTORY_MAX_ENTRIES), - maxRetainedBytes: clampLimit( - requested?.maxRetainedBytes, - FILESYSTEM_DIRECTORY_MAX_RETAINED_BYTES - ) - } -} - -export function createFilesystemDirectoryLimitState( - requested?: Partial -): FilesystemDirectoryLimitState { - return { - entries: 0, - retainedBytes: 0, - limits: resolveFilesystemDirectoryListingLimits(requested) - } -} - -export function trackFilesystemDirectoryEntry( - state: FilesystemDirectoryLimitState, - entry: NamedDirectoryEntry -): void { - state.entries += 1 - state.retainedBytes += estimateFilesystemDirectoryEntryBytes(entry) - if ( - state.entries > state.limits.maxEntries || - state.retainedBytes > state.limits.maxRetainedBytes - ) { - throw new Error(FILESYSTEM_DIRECTORY_LIMIT_MESSAGE) - } -} - -export function assertFilesystemDirectoryWithinLimit( - entries: readonly NamedDirectoryEntry[], - requested?: Partial -): void { - const state = createFilesystemDirectoryLimitState(requested) - for (const entry of entries) { - trackFilesystemDirectoryEntry(state, entry) - } -} - -export function estimateFilesystemDirectoryEntryBytes(entry: NamedDirectoryEntry): number { - // Why: this covers worst-case JSON escaping plus each result object's fixed overhead. - return entry.name.length * 6 + 96 -} - -function clampLimit(value: number | undefined, maximum: number): number { - if (typeof value !== 'number' || !Number.isSafeInteger(value) || value <= 0) { - return maximum - } - return Math.min(value, maximum) -} diff --git a/src/shared/git-check-ignore-stdio.ts b/src/shared/git-check-ignore-stdio.ts index 17d6ce8ebd8..40eb3c5cac6 100644 --- a/src/shared/git-check-ignore-stdio.ts +++ b/src/shared/git-check-ignore-stdio.ts @@ -38,12 +38,5 @@ export function splitGitCheckIgnorePathsByStdinBytes( } export function parseGitCheckIgnorePaths(stdout: string): string[] { - const paths: string[] = [] - for (const path of iterateNulDelimitedFields(stdout)) { - if (path) { - paths.push(path) - } - } - return paths + return stdout.split('\0').filter((path) => path.length > 0) } -import { iterateNulDelimitedFields } from './nul-delimited-fields' diff --git a/src/shared/git-config-snapshot-runner.ts b/src/shared/git-config-snapshot-runner.ts index 2f3e56a7bf6..0eb5a3694dd 100644 --- a/src/shared/git-config-snapshot-runner.ts +++ b/src/shared/git-config-snapshot-runner.ts @@ -27,7 +27,7 @@ function canonicalizeGitConfigLookupKey(key: string): string { function parseGitConfigListSnapshot(stdout: string): GitConfigSnapshot { const snapshot: GitConfigSnapshot = new Map() - for (const record of iterateNulDelimitedFields(stdout)) { + for (const record of stdout.split('\0')) { if (!record.trim()) { continue } @@ -101,4 +101,3 @@ export function createGitConfigSnapshotRunner(runGit: GitCommandRunner): GitComm return { stdout: values.at(-1) ?? '' } } } -import { iterateNulDelimitedFields } from './nul-delimited-fields' diff --git a/src/shared/git-discard-path-safety.ts b/src/shared/git-discard-path-safety.ts index 84a0cdd7414..de049a90d64 100644 --- a/src/shared/git-discard-path-safety.ts +++ b/src/shared/git-discard-path-safety.ts @@ -1,8 +1,5 @@ import { lstat, realpath } from 'node:fs/promises' import * as path from 'node:path' -import { forEachWithConcurrency } from './map-with-concurrency' - -const DISCARD_PATH_VALIDATION_CONCURRENCY = 16 function isENOENT(error: unknown): boolean { return ( @@ -114,16 +111,16 @@ export async function removeSafeUntrackedDiscardTargets( removePaths: (filePaths: readonly string[]) => Promise, beforeRemove?: () => Promise ): Promise { - await forEachWithConcurrency(filePaths, DISCARD_PATH_VALIDATION_CONCURRENCY, async (filePath) => { - await validateUntrackedDiscardTarget(worktreePath, filePath) - }) + await Promise.all( + filePaths.map((filePath) => validateUntrackedDiscardTarget(worktreePath, filePath)) + ) // Why: bulk discard must validate every untracked path before mutating // tracked files, then recheck before the caller's Git-bounded cleanup runs. await beforeRemove?.() - await forEachWithConcurrency(filePaths, DISCARD_PATH_VALIDATION_CONCURRENCY, async (filePath) => { - await validateUntrackedDiscardTarget(worktreePath, filePath) - }) + await Promise.all( + filePaths.map((filePath) => validateUntrackedDiscardTarget(worktreePath, filePath)) + ) await removePaths(filePaths) } diff --git a/src/shared/git-history-log-parser.ts b/src/shared/git-history-log-parser.ts index 8f34002f0cc..cfda6a2b4ab 100644 --- a/src/shared/git-history-log-parser.ts +++ b/src/shared/git-history-log-parser.ts @@ -1,5 +1,4 @@ import type { GitHistoryItem, GitHistoryItemRef } from './git-history-types' -import { iterateNulDelimitedFields } from './nul-delimited-fields' const GIT_HISTORY_DECORATION_SEPARATOR = '\x1f' @@ -99,7 +98,7 @@ export function compareGitHistoryItemRefsByCategory( export function parseGitHistoryLog(stdout: string): GitHistoryItem[] { const items: GitHistoryItem[] = [] - for (const rawRecord of iterateNulDelimitedFields(stdout)) { + for (const rawRecord of stdout.split('\0')) { const record = rawRecord.replace(/^\n+/, '') if (!record.trim()) { continue diff --git a/src/shared/git-uncommitted-line-stats.test.ts b/src/shared/git-uncommitted-line-stats.test.ts index 0e2d549868f..96556be8800 100644 --- a/src/shared/git-uncommitted-line-stats.test.ts +++ b/src/shared/git-uncommitted-line-stats.test.ts @@ -5,14 +5,7 @@ const { lstatMock, readFileMock } = vi.hoisted(() => ({ readFileMock: vi.fn() })) -vi.mock('fs/promises', () => ({ lstat: lstatMock })) - -vi.mock('./node-bounded-file-reader', () => ({ - readNodeFileWithinLimit: async (path: string) => ({ - buffer: await readFileMock(path), - stats: mockFileStat(0) - }) -})) +vi.mock('fs/promises', () => ({ lstat: lstatMock, readFile: readFileMock })) import { applyLineStats, diff --git a/src/shared/git-uncommitted-line-stats.ts b/src/shared/git-uncommitted-line-stats.ts index 3896d9354c3..ac46b928486 100644 --- a/src/shared/git-uncommitted-line-stats.ts +++ b/src/shared/git-uncommitted-line-stats.ts @@ -1,10 +1,8 @@ -import { lstat } from 'node:fs/promises' +import { lstat, readFile } from 'node:fs/promises' import * as path from 'node:path' import { isBinaryBuffer } from './binary-buffer' import { decodeGitCQuotedPath } from './git-cquoted-path' import { DEFAULT_GIT_STATUS_LIMIT } from './git-status-limit' -import { iterateNulDelimitedFields } from './nul-delimited-fields' -import { readNodeFileWithinLimit } from './node-bounded-file-reader' export type GitLineStats = { added?: number; removed?: number } @@ -80,9 +78,9 @@ export function parseNumstat(stdout: string): Map { function parseNulDelimitedNumstat(stdout: string): Map { const stats = new Map() - const records = iterateNulDelimitedFields(stdout)[Symbol.iterator]() - for (let next = records.next(); !next.done; next = records.next()) { - const record = next.value + const records = stdout.split('\0') + for (let i = 0; i < records.length; i += 1) { + const record = records[i] if (!record) { continue } @@ -91,9 +89,9 @@ function parseNulDelimitedNumstat(stdout: string): Map { let path = rawPath if (!path) { // Git -z emits rename paths as: "addedremoved\0old\0new\0". - // The empty header path is followed by the preimage and postimage. - records.next() - path = records.next().value ?? '' + // The split record has an empty path in the header; the postimage is next. + i += 2 + path = records[i] ?? '' } if (!path) { continue @@ -129,7 +127,7 @@ async function countFileAdditions(absolutePath: string): Promise { if (!fileStat.isFile() || fileStat.size > MAX_UNTRACKED_LINE_COUNT_BYTES) { return rememberUntrackedStats(absolutePath, fileStat, {}) } - const { buffer } = await readNodeFileWithinLimit(absolutePath, MAX_UNTRACKED_LINE_COUNT_BYTES) + const buffer = await readFile(absolutePath) if (isBinaryBuffer(buffer)) { return rememberUntrackedStats(absolutePath, fileStat, {}) } diff --git a/src/shared/growing-byte-buffer.test.ts b/src/shared/growing-byte-buffer.test.ts deleted file mode 100644 index 8dc477ae680..00000000000 --- a/src/shared/growing-byte-buffer.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { GrowingByteBuffer } from './growing-byte-buffer' - -describe('GrowingByteBuffer', () => { - it('retains 100,000 one-byte fragments in one growable allocation', () => { - const buffer = new GrowingByteBuffer() - const expected = Buffer.alloc(100_000) - - for (let index = 0; index < expected.byteLength; index += 1) { - const value = index % 251 - expected[index] = value - buffer.append(Uint8Array.of(value)) - } - - expect(buffer.byteLength).toBe(expected.byteLength) - expect(buffer.takeString('latin1')).toBe(expected.toString('latin1')) - expect(buffer.byteLength).toBe(0) - }) - - it('consumes delimited prefixes and retains a bounded suffix', () => { - const buffer = new GrowingByteBuffer() - for (const byte of Buffer.from('first\nsecond-tail')) { - buffer.append(Uint8Array.of(byte)) - } - - const newline = buffer.indexOfByte(0x0a) - expect(buffer.takePrefixString(newline)).toBe('first') - buffer.discardPrefix(1) - buffer.retainSuffix(4) - - expect(buffer.toString()).toBe('tail') - }) - - it('appends only a bounded copy from an oversized source chunk', () => { - const buffer = new GrowingByteBuffer() - buffer.append(Buffer.from('old')) - const source = Buffer.from('discard-prefix-tail') - - buffer.appendRetainedSuffix(source, 4) - source.fill(0) - - expect(buffer.byteLength).toBe(4) - expect(buffer.toString()).toBe('tail') - }) - - it('keeps the newest bytes across bounded suffix appends', () => { - const buffer = new GrowingByteBuffer() - - buffer.appendRetainedSuffix(Buffer.from('1234'), 6) - buffer.appendRetainedSuffix(Buffer.from('5678'), 6) - - expect(buffer.toString()).toBe('345678') - }) -}) diff --git a/src/shared/growing-byte-buffer.ts b/src/shared/growing-byte-buffer.ts deleted file mode 100644 index 48c5d90de8b..00000000000 --- a/src/shared/growing-byte-buffer.ts +++ /dev/null @@ -1,100 +0,0 @@ -export class GrowingByteBuffer { - private storage = Buffer.alloc(0) - private length = 0 - - get byteLength(): number { - return this.length - } - - append(bytes: Buffer | Uint8Array): void { - if (bytes.byteLength === 0) { - return - } - const required = this.length + bytes.byteLength - if (required > this.storage.byteLength) { - const capacity = Math.max(required, Math.max(256, this.storage.byteLength * 2)) - const next = Buffer.allocUnsafe(capacity) - this.storage.copy(next, 0, 0, this.length) - this.storage = next - } - const source = Buffer.isBuffer(bytes) - ? bytes - : Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength) - source.copy(this.storage, this.length) - this.length = required - } - - appendRetainedSuffix(bytes: Buffer | Uint8Array, maxBytes: number): void { - if (!Number.isSafeInteger(maxBytes) || maxBytes < 0) { - throw new RangeError('Retained suffix limit must be a non-negative safe integer') - } - if (maxBytes === 0) { - this.clear() - return - } - const source = Buffer.isBuffer(bytes) - ? bytes - : Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength) - if (source.byteLength >= maxBytes) { - this.storage = Buffer.from(source.subarray(source.byteLength - maxBytes)) - this.length = maxBytes - return - } - const retainedBytes = Math.min(this.length, maxBytes - source.byteLength) - if (retainedBytes < this.length) { - this.storage.copy(this.storage, 0, this.length - retainedBytes, this.length) - this.length = retainedBytes - } - this.append(source) - } - - indexOfByte(value: number, byteOffset = 0): number { - return this.storage.subarray(0, this.length).indexOf(value, byteOffset) - } - - takePrefixString(byteLength: number, encoding: BufferEncoding = 'utf8'): string { - if (!Number.isSafeInteger(byteLength) || byteLength < 0 || byteLength > this.length) { - throw new RangeError('Prefix length exceeds retained bytes') - } - const value = this.storage.toString(encoding, 0, byteLength) - this.discardPrefix(byteLength) - return value - } - - discardPrefix(byteLength: number): void { - if (!Number.isSafeInteger(byteLength) || byteLength < 0 || byteLength > this.length) { - throw new RangeError('Prefix length exceeds retained bytes') - } - if (byteLength === 0) { - return - } - this.storage.copy(this.storage, 0, byteLength, this.length) - this.length -= byteLength - } - - retainSuffix(maxBytes: number): void { - if (!Number.isSafeInteger(maxBytes) || maxBytes < 0) { - throw new RangeError('Suffix limit must be a non-negative safe integer') - } - if (this.length <= maxBytes) { - return - } - this.storage.copy(this.storage, 0, this.length - maxBytes, this.length) - this.length = maxBytes - } - - toString(encoding: BufferEncoding = 'utf8'): string { - return this.storage.toString(encoding, 0, this.length) - } - - takeString(encoding: BufferEncoding = 'utf8'): string { - const value = this.toString(encoding) - this.clear() - return value - } - - clear(): void { - this.storage = Buffer.alloc(0) - this.length = 0 - } -} diff --git a/src/shared/hermes-run-output-limits.test.ts b/src/shared/hermes-run-output-limits.test.ts deleted file mode 100644 index 1f583a83ca9..00000000000 --- a/src/shared/hermes-run-output-limits.test.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { DatabaseSync } from 'node:sqlite' -import { - formatHermesSessionMessagesWithinLimits, - HERMES_RUN_PAGE_OUTPUT_OMITTED_ERROR, - HERMES_SESSION_RUN_METADATA_MAX_BYTES, - HERMES_SESSION_RUN_SELECT_SQL, - HERMES_SESSION_TRANSCRIPT_TRUNCATED_ERROR, - hydrateHermesRunPageWithinLimits -} from './hermes-run-output-limits' - -describe('HERMES_SESSION_RUN_SELECT_SQL', () => { - it('preserves exact-limit text metadata and rejects oversized non-numeric fields in SQL', () => { - const database = new DatabaseSync(':memory:') - database.exec(`CREATE TABLE sessions ( - id TEXT PRIMARY KEY, - title TEXT, - started_at, - ended_at, - model TEXT, - message_count, - input_tokens, - output_tokens - )`) - const exactTitle = 't'.repeat(HERMES_SESSION_RUN_METADATA_MAX_BYTES) - const exactModel = 'm'.repeat(HERMES_SESSION_RUN_METADATA_MAX_BYTES) - database - .prepare( - `INSERT INTO sessions ( - id, title, started_at, ended_at, model, message_count, input_tokens, output_tokens - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)` - ) - .run('run-1', exactTitle, 1, 2, exactModel, 3, 4, 5) - - const exact = database.prepare(HERMES_SESSION_RUN_SELECT_SQL).get('run-1') - expect(exact).toEqual({ - title: exactTitle, - started_at: 1, - ended_at: 2, - model: exactModel, - message_count: 3, - input_tokens: 4, - output_tokens: 5 - }) - - database.exec(`UPDATE sessions SET - title = title || 'overflow', - model = model || 'overflow', - started_at = zeroblob(1048576), - ended_at = zeroblob(1048576), - message_count = zeroblob(1048576), - input_tokens = zeroblob(1048576), - output_tokens = zeroblob(1048576) - WHERE id = 'run-1'`) - const bounded = database.prepare(HERMES_SESSION_RUN_SELECT_SQL).get('run-1') - - expect(Buffer.byteLength(String(bounded?.title))).toBe(HERMES_SESSION_RUN_METADATA_MAX_BYTES) - expect(Buffer.byteLength(String(bounded?.model))).toBe(HERMES_SESSION_RUN_METADATA_MAX_BYTES) - expect(bounded).toMatchObject({ - started_at: null, - ended_at: null, - message_count: null, - input_tokens: null, - output_tokens: null - }) - database.close() - }) -}) - -describe('formatHermesSessionMessagesWithinLimits', () => { - it('preserves transcript formatting below the limits', () => { - const result = formatHermesSessionMessagesWithinLimits([ - { - role: 'assistant', - tool_name: ' terminal ', - reasoning_content: ' because ', - content: ' done ' - }, - { role: null, content: ' ' } - ]) - - expect(result).toEqual({ - content: [ - '## assistant / terminal', - '', - '### Reasoning', - '', - 'because', - '', - 'done', - '', - '---', - '', - '## message', - '', - '(empty)' - ].join('\n'), - truncated: false - }) - }) - - it('keeps UTF-8 output within the byte budget and explains truncation', () => { - const result = formatHermesSessionMessagesWithinLimits( - [{ role: 'assistant', content: '🐋'.repeat(100) }], - { maxBytes: 160 } - ) - - expect(result.truncated).toBe(true) - expect(Buffer.byteLength(result.content ?? '')).toBeLessThanOrEqual(160) - expect(result.content).toContain(HERMES_SESSION_TRANSCRIPT_TRUNCATED_ERROR) - expect(result.content).not.toContain('\ufffd') - }) - - it('stops pulling rows after the message limit', () => { - let rowsPulled = 0 - function* messages(): Generator> { - for (let index = 0; index < 100; index += 1) { - rowsPulled += 1 - yield { role: 'user', content: `message ${index}` } - } - } - - const result = formatHermesSessionMessagesWithinLimits(messages(), { - maxBytes: 1024, - maxMessages: 2 - }) - - expect(rowsPulled).toBe(3) - expect(result.truncated).toBe(true) - expect(result.content).toContain('message 0') - expect(result.content).toContain('message 1') - expect(result.content).not.toContain('message 2') - }) -}) - -describe('hydrateHermesRunPageWithinLimits', () => { - it('bounds concurrency, preserves order, and omits aggregate overflow', async () => { - let active = 0 - let maxActive = 0 - const refs = ['first', 'second', 'third', 'fourth'] - - const runs = (await hydrateHermesRunPageWithinLimits( - refs, - async (id) => { - active += 1 - maxActive = Math.max(maxActive, active) - await new Promise((resolve) => setTimeout(resolve, 1)) - active -= 1 - return { id, output_content: id.slice(0, 3) } - }, - { maxConcurrent: 2, maxRetainedBytes: 5 } - )) as { id: string; output_content: string | null; error?: string }[] - - expect(maxActive).toBe(2) - expect(runs.map((run) => run.id)).toEqual(refs) - expect(runs[0]?.output_content).toBe('fir') - expect(runs.slice(1)).toEqual( - refs.slice(1).map((id) => ({ - id, - output_content: null, - error: HERMES_RUN_PAGE_OUTPUT_OMITTED_ERROR - })) - ) - }) -}) diff --git a/src/shared/hermes-run-output-limits.ts b/src/shared/hermes-run-output-limits.ts deleted file mode 100644 index 99d16feaff0..00000000000 --- a/src/shared/hermes-run-output-limits.ts +++ /dev/null @@ -1,254 +0,0 @@ -export const HERMES_PRIMARY_OUTPUT_MAX_BYTES = 5 * 1024 * 1024 -export const HERMES_SESSION_TRANSCRIPT_MAX_BYTES = 8 * 1024 * 1024 -export const HERMES_SESSION_TRANSCRIPT_MAX_MESSAGES = 10_000 -export const HERMES_RUN_PAGE_MAX_RETAINED_BYTES = 32 * 1024 * 1024 -export const HERMES_RUN_PAGE_MAX_RUNS = 100 -export const HERMES_RUN_HYDRATION_CONCURRENCY = 2 -export const HERMES_SESSION_RUN_METADATA_MAX_BYTES = 16 * 1024 -export const HERMES_RUN_PAGE_OUTPUT_OMITTED_ERROR = - 'Run output omitted because this history page exceeds the memory limit' -export const HERMES_SESSION_TRANSCRIPT_TRUNCATED_ERROR = - 'Session transcript truncated because it exceeds the 8 MiB or 10,000-message history limit' - -const HERMES_SESSION_TRANSCRIPT_FIELD_MAX_BYTES = HERMES_SESSION_TRANSCRIPT_MAX_BYTES + 1 -const HERMES_SESSION_TRANSCRIPT_TRUNCATION_NOTICE = `[${HERMES_SESSION_TRANSCRIPT_TRUNCATED_ERROR}.]` -const HERMES_SESSION_TRANSCRIPT_SOURCE_TRUNCATED_COLUMN = 'orca_source_truncated' - -export const HERMES_SESSION_RUN_SELECT_SQL = `SELECT - CASE WHEN typeof(title) = 'text' - THEN CAST(substr(CAST(title AS BLOB), 1, ${HERMES_SESSION_RUN_METADATA_MAX_BYTES}) AS TEXT) - ELSE NULL END AS title, - CASE WHEN typeof(started_at) IN ('integer', 'real') THEN started_at ELSE NULL END AS started_at, - CASE WHEN typeof(ended_at) IN ('integer', 'real') THEN ended_at ELSE NULL END AS ended_at, - CASE WHEN typeof(model) = 'text' - THEN CAST(substr(CAST(model AS BLOB), 1, ${HERMES_SESSION_RUN_METADATA_MAX_BYTES}) AS TEXT) - ELSE NULL END AS model, - CASE WHEN typeof(message_count) IN ('integer', 'real') THEN message_count ELSE NULL END AS message_count, - CASE WHEN typeof(input_tokens) IN ('integer', 'real') THEN input_tokens ELSE NULL END AS input_tokens, - CASE WHEN typeof(output_tokens) IN ('integer', 'real') THEN output_tokens ELSE NULL END AS output_tokens -FROM sessions -WHERE id = ?` - -export const HERMES_SESSION_TRANSCRIPT_SELECT_SQL = `SELECT - CAST(substr(CAST(role AS BLOB), 1, ${HERMES_SESSION_TRANSCRIPT_FIELD_MAX_BYTES}) AS TEXT) AS role, - CAST(substr(CAST(content AS BLOB), 1, ${HERMES_SESSION_TRANSCRIPT_FIELD_MAX_BYTES}) AS TEXT) AS content, - CAST(substr(CAST(tool_name AS BLOB), 1, ${HERMES_SESSION_TRANSCRIPT_FIELD_MAX_BYTES}) AS TEXT) AS tool_name, - CAST(substr(CAST(reasoning AS BLOB), 1, ${HERMES_SESSION_TRANSCRIPT_FIELD_MAX_BYTES}) AS TEXT) AS reasoning, - CAST(substr(CAST(reasoning_content AS BLOB), 1, ${HERMES_SESSION_TRANSCRIPT_FIELD_MAX_BYTES}) AS TEXT) AS reasoning_content, - CASE WHEN - length(CAST(role AS BLOB)) > ${HERMES_SESSION_TRANSCRIPT_MAX_BYTES} - OR length(CAST(content AS BLOB)) > ${HERMES_SESSION_TRANSCRIPT_MAX_BYTES} - OR length(CAST(tool_name AS BLOB)) > ${HERMES_SESSION_TRANSCRIPT_MAX_BYTES} - OR length(CAST(reasoning AS BLOB)) > ${HERMES_SESSION_TRANSCRIPT_MAX_BYTES} - OR length(CAST(reasoning_content AS BLOB)) > ${HERMES_SESSION_TRANSCRIPT_MAX_BYTES} - THEN 1 ELSE 0 END AS ${HERMES_SESSION_TRANSCRIPT_SOURCE_TRUNCATED_COLUMN} -FROM messages -WHERE session_id = ? -ORDER BY timestamp, id -LIMIT ${HERMES_SESSION_TRANSCRIPT_MAX_MESSAGES + 1}` - -type HermesRunPageLimits = { - maxConcurrent?: number - maxRetainedBytes?: number -} - -type HermesSessionTranscriptLimits = { - maxBytes?: number - maxMessages?: number -} - -export type FormattedHermesSessionMessages = { - content: string | null - truncated: boolean -} - -export async function hydrateHermesRunPageWithinLimits( - refs: readonly T[], - hydrate: (ref: T) => Promise, - limits: HermesRunPageLimits = {} -): Promise { - const maxConcurrent = clampFiniteLimit(limits.maxConcurrent, 1, HERMES_RUN_HYDRATION_CONCURRENCY) - const maxRetainedBytes = clampFiniteLimit( - limits.maxRetainedBytes, - 0, - HERMES_RUN_PAGE_MAX_RETAINED_BYTES - ) - const results: unknown[] = [] - let retainedBytes = 0 - for (let start = 0; start < refs.length; start += maxConcurrent) { - const batch = await Promise.all(refs.slice(start, start + maxConcurrent).map(hydrate)) - for (const run of batch) { - const runBytes = hermesRunOutputByteLength(run) - if (retainedBytes + runBytes <= maxRetainedBytes) { - results.push(run) - retainedBytes += runBytes - } else { - results.push(omitHermesRunOutput(run)) - } - } - } - return results -} - -export function formatHermesSessionMessagesWithinLimits( - messages: Iterable>, - limits: HermesSessionTranscriptLimits = {} -): FormattedHermesSessionMessages { - const maxBytes = clampFiniteLimit(limits.maxBytes, 0, HERMES_SESSION_TRANSCRIPT_MAX_BYTES) - const maxMessages = clampFiniteLimit( - limits.maxMessages, - 0, - HERMES_SESSION_TRANSCRIPT_MAX_MESSAGES - ) - const chunks: string[] = [] - let retainedBytes = 0 - let messageCount = 0 - let truncated = false - - transcript: for (const message of messages) { - if (messageCount >= maxMessages) { - truncated = true - break - } - for (const part of formatHermesSessionMessageParts(message, messageCount > 0)) { - const partBytes = Buffer.byteLength(part) - if (retainedBytes + partBytes <= maxBytes) { - chunks.push(part) - retainedBytes += partBytes - continue - } - const prefix = takeUtf8Prefix(part, maxBytes - retainedBytes) - if (prefix) { - chunks.push(prefix) - retainedBytes += Buffer.byteLength(prefix) - } - truncated = true - break transcript - } - messageCount += 1 - if (message[HERMES_SESSION_TRANSCRIPT_SOURCE_TRUNCATED_COLUMN] === 1) { - truncated = true - break - } - } - - if (truncated) { - appendTranscriptTruncationNotice(chunks, retainedBytes, maxBytes) - } - return { content: chunks.length > 0 ? chunks.join('') : null, truncated } -} - -function clampFiniteLimit(value: number | undefined, minimum: number, maximum: number): number { - if (value === undefined || !Number.isFinite(value)) { - return maximum - } - return Math.max(minimum, Math.min(maximum, Math.floor(value))) -} - -function hermesRunOutputByteLength(run: unknown): number { - if (!run || typeof run !== 'object' || Array.isArray(run)) { - return 0 - } - const content = (run as Record).output_content - return typeof content === 'string' ? Buffer.byteLength(content) : 0 -} - -function formatHermesSessionMessageParts( - message: Record, - includeSeparator: boolean -): string[] { - const role = typeof message.role === 'string' ? message.role : 'message' - const content = typeof message.content === 'string' ? message.content.trim() : '' - const toolName = typeof message.tool_name === 'string' ? message.tool_name.trim() : '' - const reasoning = - typeof message.reasoning_content === 'string' - ? message.reasoning_content.trim() - : typeof message.reasoning === 'string' - ? message.reasoning.trim() - : '' - const parts = [ - includeSeparator ? '\n\n---\n\n' : '', - `## ${role}${toolName ? ` / ${toolName}` : ''}` - ] - if (reasoning) { - parts.push('\n\n### Reasoning\n\n', reasoning) - } - parts.push('\n\n', content || '(empty)') - return parts -} - -function appendTranscriptTruncationNotice( - chunks: string[], - retainedBytes: number, - maxBytes: number -): void { - const separator = chunks.length > 0 ? '\n\n---\n\n' : '' - const notice = `${separator}${HERMES_SESSION_TRANSCRIPT_TRUNCATION_NOTICE}` - const boundedNotice = takeUtf8Prefix(notice, maxBytes) - const noticeBytes = Buffer.byteLength(boundedNotice) - const targetBytes = maxBytes - noticeBytes - trimUtf8Chunks(chunks, retainedBytes, targetBytes) - if (boundedNotice) { - chunks.push(boundedNotice) - } -} - -function trimUtf8Chunks(chunks: string[], retainedBytes: number, targetBytes: number): void { - let bytes = retainedBytes - while (bytes > targetBytes && chunks.length > 0) { - const lastIndex = chunks.length - 1 - const last = chunks[lastIndex]! - const lastBytes = Buffer.byteLength(last) - const allowedBytes = Math.max(0, lastBytes - (bytes - targetBytes)) - if (allowedBytes === 0) { - chunks.pop() - bytes -= lastBytes - continue - } - const prefix = takeUtf8Prefix(last, allowedBytes) - chunks[lastIndex] = prefix - bytes = bytes - lastBytes + Buffer.byteLength(prefix) - } -} - -function takeUtf8Prefix(value: string, maxBytes: number): string { - if (maxBytes <= 0) { - return '' - } - if (Buffer.byteLength(value) <= maxBytes) { - return value - } - let low = 0 - let high = Math.min(value.length, maxBytes) - while (low < high) { - const middle = Math.ceil((low + high) / 2) - if (Buffer.byteLength(value.slice(0, middle)) <= maxBytes) { - low = middle - } else { - high = middle - 1 - } - } - if (low > 0 && isHighSurrogate(value.charCodeAt(low - 1))) { - low -= 1 - } - return value.slice(0, low) -} - -function isHighSurrogate(code: number): boolean { - return code >= 0xd800 && code <= 0xdbff -} - -function omitHermesRunOutput(run: unknown): unknown { - if (!run || typeof run !== 'object' || Array.isArray(run)) { - return { output_content: null, error: HERMES_RUN_PAGE_OUTPUT_OMITTED_ERROR } - } - const record = run as Record - const existingError = typeof record.error === 'string' && record.error ? record.error : null - return { - ...record, - output_content: null, - error: existingError - ? `${existingError}; ${HERMES_RUN_PAGE_OUTPUT_OMITTED_ERROR}` - : HERMES_RUN_PAGE_OUTPUT_OMITTED_ERROR - } -} diff --git a/src/shared/hermes-run-ref-retention.test.ts b/src/shared/hermes-run-ref-retention.test.ts deleted file mode 100644 index 669d41bda99..00000000000 --- a/src/shared/hermes-run-ref-retention.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { HermesRunRefRetainer } from './hermes-run-ref-retention' - -describe('HermesRunRefRetainer', () => { - it('preserves every ref and exact order below the limit', () => { - const retainer = new HermesRunRefRetainer<{ id: string; run_at: string }>(3) - retainer.add({ id: 'middle', run_at: '2026-05-15T09:00:00Z' }) - retainer.add({ id: 'newest', run_at: '2026-05-16T09:00:00Z' }) - retainer.add({ id: 'oldest', run_at: '2026-05-14T09:00:00Z' }) - - expect(retainer.finish()).toEqual({ - refs: [ - { id: 'newest', run_at: '2026-05-16T09:00:00Z' }, - { id: 'middle', run_at: '2026-05-15T09:00:00Z' }, - { id: 'oldest', run_at: '2026-05-14T09:00:00Z' } - ], - saturated: false - }) - }) - - it('retains the exact newest window and reports saturation', () => { - const retainer = new HermesRunRefRetainer<{ id: string; run_at: string }>(3) - for (const day of [1, 5, 2, 6, 3, 4]) { - retainer.add({ id: `day-${day}`, run_at: `2026-05-0${day}T09:00:00Z` }) - } - - expect(retainer.finish()).toEqual({ - refs: [ - { id: 'day-6', run_at: '2026-05-06T09:00:00Z' }, - { id: 'day-5', run_at: '2026-05-05T09:00:00Z' }, - { id: 'day-4', run_at: '2026-05-04T09:00:00Z' } - ], - saturated: true - }) - }) -}) diff --git a/src/shared/hermes-run-ref-retention.ts b/src/shared/hermes-run-ref-retention.ts deleted file mode 100644 index 15aaefe3b13..00000000000 --- a/src/shared/hermes-run-ref-retention.ts +++ /dev/null @@ -1,78 +0,0 @@ -export const HERMES_RUN_REF_MAX_ENTRIES = 10_000 -export const HERMES_RUN_REF_ID_MAX_BYTES = 4 * 1024 - -export const HERMES_SESSION_RUN_REFS_SELECT_SQL = `SELECT - CASE WHEN length(CAST(id AS BLOB)) <= ${HERMES_RUN_REF_ID_MAX_BYTES} THEN id ELSE NULL END AS id, - CASE WHEN typeof(started_at) IN ('integer', 'real') THEN started_at ELSE NULL END AS started_at -FROM sessions -WHERE id LIKE ? ESCAPE '\\' -ORDER BY started_at DESC -LIMIT ${HERMES_RUN_REF_MAX_ENTRIES + 1}` - -export type HermesSortableRunRef = { - id: string - run_at: string | null -} - -export type BoundedHermesRunRefs = { - refs: T[] - saturated: boolean -} - -export class HermesRunRefRetainer { - private readonly retained: T[] = [] - private readonly pending: T[] = [] - private readonly maxEntries: number - private seen = 0 - - constructor(maxEntries = HERMES_RUN_REF_MAX_ENTRIES) { - this.maxEntries = Number.isFinite(maxEntries) - ? Math.max(0, Math.min(HERMES_RUN_REF_MAX_ENTRIES, Math.floor(maxEntries))) - : HERMES_RUN_REF_MAX_ENTRIES - } - - add(ref: T): void { - this.seen += 1 - if (this.maxEntries === 0) { - return - } - this.pending.push(ref) - if (this.pending.length >= this.maxEntries) { - this.flush() - } - } - - finish(): BoundedHermesRunRefs { - this.flush() - return { - refs: this.retained.slice(), - saturated: this.seen > this.maxEntries - } - } - - private flush(): void { - if (this.pending.length === 0) { - return - } - for (const ref of this.pending) { - this.retained.push(ref) - } - this.pending.length = 0 - this.retained.sort(compareHermesRunRefsNewestFirst) - if (this.retained.length > this.maxEntries) { - this.retained.length = this.maxEntries - } - } -} - -export function compareHermesRunRefsNewestFirst( - left: HermesSortableRunRef, - right: HermesSortableRunRef -): number { - const leftTime = left.run_at ? Date.parse(left.run_at) : Number.NaN - const rightTime = right.run_at ? Date.parse(right.run_at) : Number.NaN - if (Number.isFinite(leftTime) && Number.isFinite(rightTime)) { - return rightTime - leftTime - } - return right.id.localeCompare(left.id) -} diff --git a/src/shared/html-to-pdf-memory-limit.ts b/src/shared/html-to-pdf-memory-limit.ts deleted file mode 100644 index 061d60eb430..00000000000 --- a/src/shared/html-to-pdf-memory-limit.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { measureUtf8ByteLength } from './utf8-byte-limits' - -export const HTML_TO_PDF_MAX_INPUT_BYTES = 32 * 1024 * 1024 -export const HTML_TO_PDF_MEMORY_LIMIT_ERROR = 'HTML export exceeds the PDF memory limit' - -export function assertHtmlToPdfInputWithinMemoryLimit( - html: string, - maxBytes = HTML_TO_PDF_MAX_INPUT_BYTES -): void { - if (measureUtf8ByteLength(html, { stopAfterBytes: maxBytes }).exceededLimit) { - throw new Error(HTML_TO_PDF_MEMORY_LIMIT_ERROR) - } -} diff --git a/src/shared/image-data-uri.test.ts b/src/shared/image-data-uri.test.ts index 46a9126591d..8244d2d652b 100644 --- a/src/shared/image-data-uri.test.ts +++ b/src/shared/image-data-uri.test.ts @@ -1,26 +1,15 @@ import { describe, expect, it } from 'vitest' -import { buildImageDataUri, validateRasterImageDataUri } from './image-data-uri' - -function pngBase64(width = 1, height = 1): string { - const bytes = Buffer.alloc(24) - Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(bytes) - bytes.writeUInt32BE(13, 8) - bytes.write('IHDR', 12, 'ascii') - bytes.writeUInt32BE(width, 16) - bytes.writeUInt32BE(height, 20) - return bytes.toString('base64') -} +import { buildImageDataUri } from './image-data-uri' describe('buildImageDataUri', () => { it('builds a data URI from base64 image bytes', () => { - const content = pngBase64() - expect(buildImageDataUri('image/png', content)).toBe(`data:image/png;base64,${content}`) + expect(buildImageDataUri('image/png', 'bmV3')).toBe('data:image/png;base64,bmV3') }) it('strips whitespace from line-wrapped base64 payloads', () => { - const content = pngBase64() - const wrapped = `${content.slice(0, 8)}\n${content.slice(8, 20)}\t ${content.slice(20)}\r\n` - expect(buildImageDataUri('image/png', wrapped)).toBe(`data:image/png;base64,${content}`) + expect(buildImageDataUri('image/png', 'bm\nV3\t bmV3\r\n')).toBe( + 'data:image/png;base64,bmV3bmV3' + ) }) it('returns null for an empty payload', () => { @@ -38,31 +27,4 @@ describe('buildImageDataUri', () => { it('returns null for a non-image mime such as application/octet-stream', () => { expect(buildImageDataUri('application/octet-stream', 'AAAA')).toBeNull() }) - - it('rejects malformed and oversized known rasters before native decode', () => { - expect(buildImageDataUri('image/png', 'bmV3')).toBeNull() - expect(buildImageDataUri('image/png', pngBase64(32_769, 1))).toBeNull() - expect(buildImageDataUri('image/png', pngBase64(8192, 8192))).toBeNull() - }) - - it('preserves SVG behavior because vectors do not have encoded raster dimensions', () => { - expect(buildImageDataUri('image/svg+xml', 'PHN2Zy8+')).toBe( - 'data:image/svg+xml;base64,PHN2Zy8+' - ) - }) -}) - -describe('validateRasterImageDataUri', () => { - it('accepts a safe inline raster and rejects an oversized one', () => { - const safe = `data:image/png;base64,${pngBase64()}` - expect(validateRasterImageDataUri(safe)).toBe(safe) - expect(validateRasterImageDataUri(`data:image/png;base64,${pngBase64(32_769, 1)}`)).toBeNull() - }) - - it('preserves non-raster data URIs and rejects non-base64 raster data', () => { - expect(validateRasterImageDataUri('data:image/svg+xml,%3Csvg/%3E')).toBe( - 'data:image/svg+xml,%3Csvg/%3E' - ) - expect(validateRasterImageDataUri('data:image/png,not-base64')).toBeNull() - }) }) diff --git a/src/shared/image-data-uri.ts b/src/shared/image-data-uri.ts index e8d5f66c623..1501473560f 100644 --- a/src/shared/image-data-uri.ts +++ b/src/shared/image-data-uri.ts @@ -1,6 +1,3 @@ -import { readRasterImagePreviewDimensionsFromBase64 } from './raster-image-base64-preview' -import { isKnownRasterImageMimeType } from './raster-image-preview-limits' - // Builds an inline `data:` URI for base64 image bytes, shared by the desktop // editor ImageViewer and the mobile file preview so both decode images the same // way. Strips whitespace from the payload (base64 from git diffs and SSH streams @@ -19,21 +16,5 @@ export function buildImageDataUri( if (!cleaned) { return null } - if (readRasterImagePreviewDimensionsFromBase64(cleaned, mimeType) === null) { - return null - } return `data:${mimeType};base64,${cleaned}` } - -/** Preserves non-raster data URIs and rejects unsafe known-raster data URIs. */ -export function validateRasterImageDataUri(dataUri: string): string | null { - const match = /^data:([^;,]+)((?:;[^,]*)*),([\s\S]*)$/i.exec(dataUri) - if (!match || !isKnownRasterImageMimeType(match[1])) { - return dataUri - } - const parameters = match[2].split(';').filter(Boolean) - if (!parameters.some((parameter) => parameter.toLowerCase() === 'base64')) { - return null - } - return buildImageDataUri(match[1], match[3]) -} diff --git a/src/shared/in-flight-promise-dedupe.test.ts b/src/shared/in-flight-promise-dedupe.test.ts index 96f36ec69a8..fcc1005a591 100644 --- a/src/shared/in-flight-promise-dedupe.test.ts +++ b/src/shared/in-flight-promise-dedupe.test.ts @@ -1,18 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { - InFlightPromiseDedupe, - MAX_IN_FLIGHT_PROMISE_DEDUPE_ENTRIES, - MAX_IN_FLIGHT_PROMISE_DEDUPE_KEY_CODE_UNITS, - stableInFlightKey -} from './in-flight-promise-dedupe' - -function deferred(): { promise: Promise; resolve: (value: T) => void } { - let resolve!: (value: T) => void - const promise = new Promise((nextResolve) => { - resolve = nextResolve - }) - return { promise, resolve } -} +import { InFlightPromiseDedupe, stableInFlightKey } from './in-flight-promise-dedupe' describe('InFlightPromiseDedupe', () => { it('coalesces only while in flight and retries after rejection', async () => { @@ -93,72 +80,4 @@ describe('InFlightPromiseDedupe', () => { vi.useRealTimers() } }) - - it('admits exactly the entry limit and bypasses new keys above it', async () => { - const dedupe = new InFlightPromiseDedupe() - const pending = Array.from({ length: MAX_IN_FLIGHT_PROMISE_DEDUPE_ENTRIES }, () => - deferred() - ) - const admittedLoads = pending.map((item) => vi.fn(() => item.promise)) - - for (let index = 0; index < pending.length; index += 1) { - const first = dedupe.run(`key-${index}`, admittedLoads[index]) - const second = dedupe.run(`key-${index}`, admittedLoads[index]) - expect(second).toBe(first) - } - - const overflowLoad = vi.fn(async () => 'overflow') - const firstOverflow = dedupe.run('overflow', overflowLoad) - const secondOverflow = dedupe.run('overflow', overflowLoad) - expect(secondOverflow).not.toBe(firstOverflow) - await expect(Promise.all([firstOverflow, secondOverflow])).resolves.toEqual([ - 'overflow', - 'overflow' - ]) - expect(overflowLoad).toHaveBeenCalledTimes(2) - - pending.forEach((item) => item.resolve('settled')) - await Promise.all(pending.map((item) => item.promise)) - }) - - it('admits a new key after a retained entry settles', async () => { - const dedupe = new InFlightPromiseDedupe(30_000, 1) - const firstPending = deferred() - const first = dedupe.run('first', () => firstPending.promise) - firstPending.resolve('first-result') - await expect(first).resolves.toBe('first-result') - - const nextPending = deferred() - const load = vi.fn(() => nextPending.promise) - const next = dedupe.run('next', load) - expect(dedupe.run('next', load)).toBe(next) - nextPending.resolve('next-result') - await expect(next).resolves.toBe('next-result') - expect(load).toHaveBeenCalledTimes(1) - }) - - it('keeps stable keys inline through the size limit and digests larger keys', () => { - const exactInput = 'x'.repeat(MAX_IN_FLIGHT_PROMISE_DEDUPE_KEY_CODE_UNITS - 4) - const oversizedInput = `${exactInput}x` - - expect(stableInFlightKey([exactInput])).toBe(JSON.stringify([exactInput])) - const firstDigest = stableInFlightKey([oversizedInput]) - expect(firstDigest).toMatch(/^sha256:[a-f0-9]{64}$/) - expect(stableInFlightKey([oversizedInput])).toBe(firstDigest) - expect(stableInFlightKey([`${oversizedInput}x`])).not.toBe(firstDigest) - }) - - it('coalesces direct oversized keys through their bounded identity', async () => { - const dedupe = new InFlightPromiseDedupe() - const pending = deferred() - const load = vi.fn(() => pending.promise) - const oversizedKey = 'x'.repeat(MAX_IN_FLIGHT_PROMISE_DEDUPE_KEY_CODE_UNITS + 1) - - const first = dedupe.run(oversizedKey, load) - expect(dedupe.run(oversizedKey, load)).toBe(first) - pending.resolve('result') - - await expect(first).resolves.toBe('result') - expect(load).toHaveBeenCalledTimes(1) - }) }) diff --git a/src/shared/in-flight-promise-dedupe.ts b/src/shared/in-flight-promise-dedupe.ts index a66420cbbc1..0ad90f372c2 100644 --- a/src/shared/in-flight-promise-dedupe.ts +++ b/src/shared/in-flight-promise-dedupe.ts @@ -1,54 +1,28 @@ -import { createHash } from 'node:crypto' - -export const MAX_IN_FLIGHT_PROMISE_DEDUPE_ENTRIES = 128 -export const MAX_IN_FLIGHT_PROMISE_DEDUPE_KEY_CODE_UNITS = 64 * 1024 - -function boundInFlightKey(key: string): string { - if (key.length <= MAX_IN_FLIGHT_PROMISE_DEDUPE_KEY_CODE_UNITS) { - return key - } - return `sha256:${createHash('sha256').update(key).digest('hex')}` -} - export class InFlightPromiseDedupe { private readonly entries = new Map< string, { promise: Promise; timeout: ReturnType | null } >() - private readonly maxEntries: number - constructor( - private readonly maxInFlightMs = 30_000, - maxEntries = MAX_IN_FLIGHT_PROMISE_DEDUPE_ENTRIES - ) { - this.maxEntries = Number.isFinite(maxEntries) - ? Math.min(MAX_IN_FLIGHT_PROMISE_DEDUPE_ENTRIES, Math.max(0, Math.floor(maxEntries))) - : MAX_IN_FLIGHT_PROMISE_DEDUPE_ENTRIES - } + constructor(private readonly maxInFlightMs = 30_000) {} run(key: string, load: () => Promise): Promise { - const retainedKey = boundInFlightKey(key) - const existing = this.entries.get(retainedKey) + const existing = this.entries.get(key) if (existing) { return existing.promise } - if (this.entries.size >= this.maxEntries) { - // Why: evicting active work would let later identical calls duplicate it; - // overflow calls still run but cannot extend this object's retention. - return Promise.resolve().then(load) - } // Why: this is in-flight coalescing only; the next read after settle must // observe fresh git state instead of a cached diff. const promise = Promise.resolve() .then(load) .finally(() => { - const entry = this.entries.get(retainedKey) + const entry = this.entries.get(key) if (entry?.promise === promise) { if (entry.timeout) { clearTimeout(entry.timeout) } - this.entries.delete(retainedKey) + this.entries.delete(key) } }) const entry = { @@ -58,13 +32,13 @@ export class InFlightPromiseDedupe { timeout: this.maxInFlightMs > 0 ? setTimeout(() => { - if (this.entries.get(retainedKey)?.promise === promise) { - this.entries.delete(retainedKey) + if (this.entries.get(key)?.promise === promise) { + this.entries.delete(key) } }, this.maxInFlightMs) : null } - this.entries.set(retainedKey, entry) + this.entries.set(key, entry) return promise } @@ -79,5 +53,5 @@ export class InFlightPromiseDedupe { } export function stableInFlightKey(parts: readonly unknown[]): string { - return boundInFlightKey(JSON.stringify(parts)) + return JSON.stringify(parts) } diff --git a/src/shared/linux-proc-port-scan-limits.test.ts b/src/shared/linux-proc-port-scan-limits.test.ts deleted file mode 100644 index 9d382c7a31d..00000000000 --- a/src/shared/linux-proc-port-scan-limits.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { - createLinuxProcTextReadBudget, - LINUX_PROC_NETWORK_TABLE_MAX_BYTES, - readLinuxProcNetworkTable, - readLinuxProcTextWithinBudget -} from './linux-proc-port-scan-limits' - -describe('Linux proc port scan limits', () => { - it('applies the network-table byte cap before retaining content', async () => { - const readFile = vi.fn(async () => Buffer.from('table')) - - await expect(readLinuxProcNetworkTable('/proc/net/tcp', readFile)).resolves.toBe('table') - expect(readFile).toHaveBeenCalledWith('/proc/net/tcp', LINUX_PROC_NETWORK_TABLE_MAX_BYTES) - }) - - it('shares one retained-byte budget across process metadata files', async () => { - const budget = createLinuxProcTextReadBudget(5) - const readFile = vi - .fn<(filePath: string, maxBytes: number) => Promise>() - .mockResolvedValueOnce(Buffer.from('abc')) - .mockResolvedValueOnce(Buffer.from('de')) - - await expect(readLinuxProcTextWithinBudget('/proc/1/comm', budget, readFile, 4)).resolves.toBe( - 'abc' - ) - await expect( - readLinuxProcTextWithinBudget('/proc/1/cmdline', budget, readFile, 4) - ).resolves.toBe('de') - await expect( - readLinuxProcTextWithinBudget('/proc/2/cmdline', budget, readFile, 4) - ).resolves.toBeUndefined() - - expect(readFile.mock.calls).toEqual([ - ['/proc/1/comm', 4], - ['/proc/1/cmdline', 2] - ]) - expect(budget.remainingBytes).toBe(0) - }) - - it('does not debit failed metadata reads', async () => { - const budget = createLinuxProcTextReadBudget(4) - const readFile = vi.fn(async () => { - throw new Error('oversized') - }) - - await expect( - readLinuxProcTextWithinBudget('/proc/1/cmdline', budget, readFile) - ).resolves.toBeUndefined() - expect(budget.remainingBytes).toBe(4) - }) -}) diff --git a/src/shared/linux-proc-port-scan-limits.ts b/src/shared/linux-proc-port-scan-limits.ts deleted file mode 100644 index dd1f9fd5c6e..00000000000 --- a/src/shared/linux-proc-port-scan-limits.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { readNodeFileWithinLimit } from './node-bounded-file-reader' - -export const LINUX_PROC_NETWORK_TABLE_MAX_BYTES = 8 * 1024 * 1024 -export const LINUX_PROC_LISTENING_SOCKET_MAX_ENTRIES = 2_048 -export const LINUX_PROC_PROCESS_METADATA_MAX_BYTES = 8 * 1024 * 1024 -export const LINUX_PROC_PROCESS_METADATA_FILE_MAX_BYTES = 64 * 1024 - -export type LinuxProcTextReadBudget = { remainingBytes: number } - -type LinuxProcTextReader = (filePath: string, maxBytes: number) => Promise - -const readBoundedNodeFile: LinuxProcTextReader = async (filePath, maxBytes) => - (await readNodeFileWithinLimit(filePath, maxBytes)).buffer - -export function createLinuxProcTextReadBudget( - maxBytes = LINUX_PROC_PROCESS_METADATA_MAX_BYTES -): LinuxProcTextReadBudget { - if (!Number.isSafeInteger(maxBytes) || maxBytes < 0) { - throw new RangeError('Linux proc text budget must be a non-negative safe integer') - } - return { remainingBytes: maxBytes } -} - -export async function readLinuxProcNetworkTable( - filePath: string, - readFile: LinuxProcTextReader = readBoundedNodeFile -): Promise { - try { - return (await readFile(filePath, LINUX_PROC_NETWORK_TABLE_MAX_BYTES)).toString('utf8') - } catch { - return null - } -} - -export async function readLinuxProcTextWithinBudget( - filePath: string, - budget: LinuxProcTextReadBudget, - readFile: LinuxProcTextReader = readBoundedNodeFile, - perFileMaxBytes = LINUX_PROC_PROCESS_METADATA_FILE_MAX_BYTES -): Promise { - const maxBytes = Math.min(perFileMaxBytes, budget.remainingBytes) - if (maxBytes <= 0) { - return undefined - } - try { - const content = await readFile(filePath, maxBytes) - if (content.byteLength > maxBytes) { - return undefined - } - budget.remainingBytes -= content.byteLength - return content.toString('utf8') - } catch { - return undefined - } -} diff --git a/src/shared/linux-proc-socket-owner-scanner.test.ts b/src/shared/linux-proc-socket-owner-scanner.test.ts deleted file mode 100644 index a299539025e..00000000000 --- a/src/shared/linux-proc-socket-owner-scanner.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { mapLinuxSocketInodesToPids } from './linux-proc-socket-owner-scanner' - -async function* names(values: readonly string[]): AsyncGenerator { - yield* values -} - -describe('mapLinuxSocketInodesToPids', () => { - it('streams process and descriptor directories while preserving owner resolution', async () => { - const visitedDirectories: string[] = [] - const result = await mapLinuxSocketInodesToPids(new Set([101, 202]), { - readDirectoryNames: (directoryPath) => { - visitedDirectories.push(directoryPath) - if (directoryPath === '/proc') { - return names(['self', '41', '42']) - } - return names(directoryPath.endsWith('/41/fd') ? ['1', '2'] : ['3']) - }, - readLink: async (filePath) => { - if (filePath.endsWith('/41/fd/1')) { - return 'socket:[101]' - } - if (filePath.endsWith('/42/fd/3')) { - return 'socket:[202]' - } - return 'pipe:[9]' - } - }) - - expect(result).toEqual( - new Map([ - [101, 41], - [202, 42] - ]) - ) - expect(visitedDirectories).toEqual(['/proc', '/proc/41/fd', '/proc/42/fd']) - }) - - it('does not retain an arbitrarily large process-name listing', async () => { - let yielded = 0 - const result = await mapLinuxSocketInodesToPids(new Set([7]), { - readDirectoryNames: (directoryPath) => { - if (directoryPath !== '/proc') { - return names([]) - } - return (async function* () { - for (let pid = 1; pid <= 20_000; pid += 1) { - yielded += 1 - yield String(pid) - } - })() - }, - readLink: async () => 'socket:[7]' - }) - - expect(yielded).toBe(20_000) - expect(result.size).toBe(0) - }) -}) diff --git a/src/shared/linux-proc-socket-owner-scanner.ts b/src/shared/linux-proc-socket-owner-scanner.ts deleted file mode 100644 index 43b8cd80872..00000000000 --- a/src/shared/linux-proc-socket-owner-scanner.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { opendir, readlink } from 'node:fs/promises' -import { posix } from 'node:path' - -type LinuxProcSocketOwnerScannerDependencies = { - readDirectoryNames: (directoryPath: string) => AsyncIterable - readLink: (filePath: string) => Promise -} - -async function* readNodeDirectoryNames(directoryPath: string): AsyncGenerator { - try { - const directory = await opendir(directoryPath) - for await (const entry of directory) { - yield entry.name - } - } catch {} -} - -const defaultDependencies: LinuxProcSocketOwnerScannerDependencies = { - readDirectoryNames: readNodeDirectoryNames, - readLink: readlink -} - -export async function mapLinuxSocketInodesToPids( - inodes: ReadonlySet, - dependencies: LinuxProcSocketOwnerScannerDependencies = defaultDependencies -): Promise> { - const result = new Map() - if (inodes.size === 0) { - return result - } - - try { - for await (const pidText of dependencies.readDirectoryNames('/proc')) { - if (!/^\d+$/.test(pidText)) { - continue - } - const pid = Number.parseInt(pidText, 10) - const fdDirectory = posix.join('/proc', pidText, 'fd') - try { - for await (const fd of dependencies.readDirectoryNames(fdDirectory)) { - let link: string - try { - link = await dependencies.readLink(posix.join(fdDirectory, fd)) - } catch { - continue - } - const match = /^socket:\[(\d+)\]$/.exec(link) - if (!match) { - continue - } - const inode = Number.parseInt(match[1], 10) - if (inodes.has(inode)) { - result.set(inode, pid) - } - } - } catch { - continue - } - } - } catch { - return result - } - return result -} diff --git a/src/shared/map-settled-with-concurrency.test.ts b/src/shared/map-settled-with-concurrency.test.ts deleted file mode 100644 index 506d7a4d3e0..00000000000 --- a/src/shared/map-settled-with-concurrency.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { mapSettledWithConcurrency } from './map-with-concurrency' - -describe('mapSettledWithConcurrency', () => { - it('preserves all-settled results and order while bounding a large fanout', async () => { - const limit = 7 - const items = Array.from({ length: 1_000 }, (_, index) => index) - let inFlight = 0 - let peak = 0 - - const results = await mapSettledWithConcurrency(items, limit, async (item) => { - inFlight += 1 - peak = Math.max(peak, inFlight) - await Promise.resolve() - for (let turn = 0; turn < item % 5; turn += 1) { - await Promise.resolve() - } - inFlight -= 1 - if (item % 97 === 0) { - throw `rejected-${item}` - } - return `fulfilled-${item}` - }) - - expect(peak).toBe(limit) - expect(results).toEqual( - items.map( - (item): PromiseSettledResult => - item % 97 === 0 - ? { status: 'rejected', reason: `rejected-${item}` } - : { status: 'fulfilled', value: `fulfilled-${item}` } - ) - ) - }) -}) diff --git a/src/shared/markdown-document-listing-limits.test.ts b/src/shared/markdown-document-listing-limits.test.ts deleted file mode 100644 index 574e146b4ee..00000000000 --- a/src/shared/markdown-document-listing-limits.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { MarkdownDocument } from './types' -import { - assertMarkdownDocumentsWithinLimit, - createMarkdownDocumentListingBudget, - isMarkdownDocumentListingCapacityError, - MARKDOWN_DOCUMENT_LISTING_ERROR_CODE, - MARKDOWN_DOCUMENT_LISTING_ERROR_MESSAGE, - MarkdownDocumentListingCapacityError, - retainMarkdownDocument, - visitMarkdownDocumentListingEntry -} from './markdown-document-listing-limits' - -function document(path: string): MarkdownDocument { - return { - filePath: `/repo/${path}`, - relativePath: path, - basename: path, - name: path - } -} - -describe('Markdown document listing limits', () => { - it('preserves an under-limit listing and reports its retained estimate', () => { - const documents = [document('README.md'), document('docs/guide.mdx')] - - expect(assertMarkdownDocumentsWithinLimit(documents)).toBeGreaterThan(0) - }) - - it('rejects the first document beyond the count limit with a typed error', () => { - const budget = createMarkdownDocumentListingBudget({ maxDocuments: 2 }) - retainMarkdownDocument(budget, document('one.md')) - retainMarkdownDocument(budget, document('two.md')) - - expect(() => retainMarkdownDocument(budget, document('three.md'))).toThrow( - MarkdownDocumentListingCapacityError - ) - expect(() => retainMarkdownDocument(budget, document('three.md'))).toThrow( - expect.objectContaining({ code: MARKDOWN_DOCUMENT_LISTING_ERROR_CODE }) - ) - }) - - it('rejects aggregate metadata, visited-entry, path, and depth overflow', () => { - expect(() => - assertMarkdownDocumentsWithinLimit([document('a'.repeat(100))], { - maxMetadataBytes: 100 - }) - ).toThrow(MarkdownDocumentListingCapacityError) - - const visited = createMarkdownDocumentListingBudget({ - maxVisitedEntries: 1, - maxPathBytes: 4, - maxDepth: 1 - }) - visitMarkdownDocumentListingEntry(visited, 'a', 1) - expect(() => visitMarkdownDocumentListingEntry(visited, 'b', 1)).toThrow( - MarkdownDocumentListingCapacityError - ) - - const path = createMarkdownDocumentListingBudget({ maxPathBytes: 4 }) - expect(() => visitMarkdownDocumentListingEntry(path, 'ééé', 1)).toThrow( - MarkdownDocumentListingCapacityError - ) - - const depth = createMarkdownDocumentListingBudget({ maxDepth: 1 }) - expect(() => visitMarkdownDocumentListingEntry(depth, 'a/b', 2)).toThrow( - MarkdownDocumentListingCapacityError - ) - }) - - it('recognizes structured runtime and Electron-wrapped capacity failures', () => { - const structured = Object.assign(new Error('remote listing rejected'), { - code: MARKDOWN_DOCUMENT_LISTING_ERROR_CODE - }) - const electronWrapped = new Error( - `Error invoking remote method 'fs:listMarkdownDocuments': Error: ${MARKDOWN_DOCUMENT_LISTING_ERROR_MESSAGE}` - ) - - expect(isMarkdownDocumentListingCapacityError(structured)).toBe(true) - expect(isMarkdownDocumentListingCapacityError(electronWrapped)).toBe(true) - expect(isMarkdownDocumentListingCapacityError(new Error('unrelated failure'))).toBe(false) - }) -}) diff --git a/src/shared/markdown-document-listing-limits.ts b/src/shared/markdown-document-listing-limits.ts deleted file mode 100644 index 9c9b39c49b3..00000000000 --- a/src/shared/markdown-document-listing-limits.ts +++ /dev/null @@ -1,170 +0,0 @@ -import type { MarkdownDocument } from './types' -import { measureUtf8ByteLength } from './utf8-byte-limits' - -export const MARKDOWN_DOCUMENT_LISTING_MAX_DOCUMENTS = 20_000 -export const MARKDOWN_DOCUMENT_LISTING_MAX_METADATA_BYTES = 8 * 1024 * 1024 -export const MARKDOWN_DOCUMENT_LISTING_MAX_PATH_BYTES = 64 * 1024 -export const MARKDOWN_DOCUMENT_LISTING_MAX_VISITED_ENTRIES = 100_000 -export const MARKDOWN_DOCUMENT_LISTING_MAX_DEPTH = 256 -export const MARKDOWN_DOCUMENT_LISTING_ERROR_CODE = 'markdown_document_listing_capacity' -export const MARKDOWN_DOCUMENT_LISTING_ERROR_MESSAGE = - 'Workspace is too large for Markdown link completion.' - -const MARKDOWN_DOCUMENT_RETAINED_OVERHEAD_BYTES = 256 - -export type MarkdownDocumentListingLimits = { - maxDocuments: number - maxMetadataBytes: number - maxPathBytes: number - maxVisitedEntries: number - maxDepth: number -} - -export type MarkdownDocumentListingBudget = { - documents: number - metadataBytes: number - visitedEntries: number - limits: MarkdownDocumentListingLimits -} - -export class MarkdownDocumentListingCapacityError extends Error { - readonly code = MARKDOWN_DOCUMENT_LISTING_ERROR_CODE - - constructor() { - super(MARKDOWN_DOCUMENT_LISTING_ERROR_MESSAGE) - this.name = 'MarkdownDocumentListingCapacityError' - } -} - -export function isMarkdownDocumentListingCapacityError(error: unknown): boolean { - if (!(error instanceof Error)) { - return false - } - return ( - ('code' in error && - (error as { code?: unknown }).code === MARKDOWN_DOCUMENT_LISTING_ERROR_CODE) || - error.message.includes(MARKDOWN_DOCUMENT_LISTING_ERROR_MESSAGE) - ) -} - -export function createMarkdownDocumentListingBudget( - requested: Partial = {} -): MarkdownDocumentListingBudget { - return { - documents: 0, - metadataBytes: 0, - visitedEntries: 0, - limits: { - maxDocuments: clampLimit(requested.maxDocuments, MARKDOWN_DOCUMENT_LISTING_MAX_DOCUMENTS), - maxMetadataBytes: clampLimit( - requested.maxMetadataBytes, - MARKDOWN_DOCUMENT_LISTING_MAX_METADATA_BYTES - ), - maxPathBytes: clampLimit(requested.maxPathBytes, MARKDOWN_DOCUMENT_LISTING_MAX_PATH_BYTES), - maxVisitedEntries: clampLimit( - requested.maxVisitedEntries, - MARKDOWN_DOCUMENT_LISTING_MAX_VISITED_ENTRIES - ), - maxDepth: clampLimit(requested.maxDepth, MARKDOWN_DOCUMENT_LISTING_MAX_DEPTH) - } - } -} - -export function assertMarkdownDocumentPathWithinLimit( - path: string, - maxPathBytes = MARKDOWN_DOCUMENT_LISTING_MAX_PATH_BYTES -): void { - if (measureUtf8ByteLength(path, { stopAfterBytes: maxPathBytes }).exceededLimit) { - throw new MarkdownDocumentListingCapacityError() - } -} - -export function visitMarkdownDocumentListingEntry( - budget: MarkdownDocumentListingBudget, - path: string, - depth: number -): void { - assertMarkdownDocumentPathWithinLimit(path, budget.limits.maxPathBytes) - if (budget.visitedEntries >= budget.limits.maxVisitedEntries || depth > budget.limits.maxDepth) { - throw new MarkdownDocumentListingCapacityError() - } - budget.visitedEntries += 1 -} - -export function estimateMarkdownDocumentRetainedBytes(document: MarkdownDocument): number { - return ( - (document.filePath.length + - document.relativePath.length + - document.basename.length + - document.name.length) * - 2 + - MARKDOWN_DOCUMENT_RETAINED_OVERHEAD_BYTES - ) -} - -export function retainMarkdownDocument( - budget: MarkdownDocumentListingBudget, - document: MarkdownDocument -): void { - if ( - !document || - typeof document.filePath !== 'string' || - typeof document.relativePath !== 'string' || - typeof document.basename !== 'string' || - typeof document.name !== 'string' - ) { - throw new MarkdownDocumentListingCapacityError() - } - assertMarkdownDocumentPathWithinLimit(document.filePath, budget.limits.maxPathBytes) - assertMarkdownDocumentPathWithinLimit(document.relativePath, budget.limits.maxPathBytes) - const retainedBytes = estimateMarkdownDocumentRetainedBytes(document) - if ( - budget.documents >= budget.limits.maxDocuments || - budget.metadataBytes + retainedBytes > budget.limits.maxMetadataBytes - ) { - throw new MarkdownDocumentListingCapacityError() - } - budget.documents += 1 - budget.metadataBytes += retainedBytes -} - -export function retainMarkdownRelativePath( - budget: MarkdownDocumentListingBudget, - rootPath: string, - relativePath: string -): void { - const normalizedRoot = rootPath.replace(/[\\/]+$/, '') - const normalizedRelativePath = relativePath.replaceAll('\\', '/') - const basename = normalizedRelativePath.slice(normalizedRelativePath.lastIndexOf('/') + 1) - const extensionIndex = basename.lastIndexOf('.') - retainMarkdownDocument(budget, { - filePath: `${normalizedRoot}/${normalizedRelativePath}`, - relativePath: normalizedRelativePath, - basename, - name: extensionIndex > 0 ? basename.slice(0, extensionIndex) : basename - }) -} - -export function assertMarkdownDocumentsWithinLimit( - documents: unknown, - requested: Partial = {} -): number { - const budget = createMarkdownDocumentListingBudget(requested) - if (!Array.isArray(documents)) { - throw new MarkdownDocumentListingCapacityError() - } - if (documents.length > budget.limits.maxDocuments) { - throw new MarkdownDocumentListingCapacityError() - } - for (const document of documents) { - retainMarkdownDocument(budget, document as MarkdownDocument) - } - return budget.metadataBytes -} - -function clampLimit(value: number | undefined, maximum: number): number { - if (typeof value !== 'number' || !Number.isSafeInteger(value) || value <= 0) { - return maximum - } - return Math.min(value, maximum) -} diff --git a/src/shared/mcp-config-inspection-limits.ts b/src/shared/mcp-config-inspection-limits.ts deleted file mode 100644 index 221a350825a..00000000000 --- a/src/shared/mcp-config-inspection-limits.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { measureUtf8ByteLength } from './utf8-byte-limits' - -export const MCP_CONFIG_INSPECTION_MAX_BYTES = 256 * 1024 -export const MCP_CONFIG_INSPECTION_MAX_CODE_UNITS = 256 * 1024 -export const MCP_CONFIG_INSPECTION_MAX_SERVERS = 256 -export const MCP_CONFIG_INSPECTION_MAX_ENV_FIELDS = 256 -export const MCP_CONFIG_INSPECTION_MAX_NAME_BYTES = 4 * 1024 -export const MCP_CONFIG_INSPECTION_MAX_NAME_CODE_UNITS = 4 * 1024 -export const MCP_CONFIG_INSPECTION_MAX_FIELD_BYTES = 64 * 1024 -export const MCP_CONFIG_INSPECTION_MAX_FIELD_CODE_UNITS = 64 * 1024 - -export function isMcpConfigInspectionTextWithinLimit(content: string): boolean { - return isTextWithinLimits( - content, - MCP_CONFIG_INSPECTION_MAX_BYTES, - MCP_CONFIG_INSPECTION_MAX_CODE_UNITS - ) -} - -export function isMcpConfigInspectionNameWithinLimit(value: string): boolean { - return isTextWithinLimits( - value, - MCP_CONFIG_INSPECTION_MAX_NAME_BYTES, - MCP_CONFIG_INSPECTION_MAX_NAME_CODE_UNITS - ) -} - -export function isMcpConfigInspectionFieldWithinLimit(value: string): boolean { - return isTextWithinLimits( - value, - MCP_CONFIG_INSPECTION_MAX_FIELD_BYTES, - MCP_CONFIG_INSPECTION_MAX_FIELD_CODE_UNITS - ) -} - -function isTextWithinLimits(value: string, maxBytes: number, maxCodeUnits: number): boolean { - return ( - value.length <= maxCodeUnits && - !measureUtf8ByteLength(value, { stopAfterBytes: maxBytes }).exceededLimit - ) -} diff --git a/src/shared/mcp-config.test.ts b/src/shared/mcp-config.test.ts index f89d03463e3..e16dc9f3660 100644 --- a/src/shared/mcp-config.test.ts +++ b/src/shared/mcp-config.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import { canInspectLocalMcpConfigRoot, getMcpConfigCandidateParentDir, @@ -9,17 +9,6 @@ import { MCP_STARTER_CONFIG, selectExistingMcpConfigCandidates } from './mcp-config' -import { - MCP_CONFIG_INSPECTION_MAX_BYTES, - MCP_CONFIG_INSPECTION_MAX_ENV_FIELDS, - MCP_CONFIG_INSPECTION_MAX_FIELD_BYTES, - MCP_CONFIG_INSPECTION_MAX_FIELD_CODE_UNITS, - MCP_CONFIG_INSPECTION_MAX_SERVERS -} from './mcp-config-inspection-limits' - -afterEach(() => { - vi.restoreAllMocks() -}) describe('mcp-config', () => { const workspaceCandidate = MCP_CONFIG_CANDIDATES[0] @@ -152,92 +141,6 @@ describe('mcp-config', () => { }) }) - it('parses the exact input boundary and rejects +1 before JSON parsing', () => { - const parse = vi.spyOn(JSON, 'parse') - const exact = `${' '.repeat(MCP_CONFIG_INSPECTION_MAX_BYTES - 2)}{}` - - expect(inspectMcpConfigContent(workspaceCandidate, exact).status).toBe('valid') - expect(parse).toHaveBeenCalledOnce() - - parse.mockClear() - expect(inspectMcpConfigContent(workspaceCandidate, `${exact} `).status).toBe('invalid') - expect(parse).not.toHaveBeenCalled() - parse.mockRestore() - }) - - it('rejects multibyte input over the byte cap before JSON parsing', () => { - const parse = vi.spyOn(JSON, 'parse') - - expect( - inspectMcpConfigContent( - workspaceCandidate, - 'é'.repeat(MCP_CONFIG_INSPECTION_MAX_BYTES / 2 + 1) - ).status - ).toBe('invalid') - expect(parse).not.toHaveBeenCalled() - parse.mockRestore() - }) - - it('admits the exact server cardinality and rejects +1', () => { - const servers = Object.fromEntries( - Array.from({ length: MCP_CONFIG_INSPECTION_MAX_SERVERS }, (_, index) => [ - `server-${index}`, - { command: 'node' } - ]) - ) - - expect( - inspectMcpConfigContent(workspaceCandidate, JSON.stringify({ mcpServers: servers })).servers - ).toHaveLength(MCP_CONFIG_INSPECTION_MAX_SERVERS) - servers.overflow = { command: 'node' } - expect( - inspectMcpConfigContent(workspaceCandidate, JSON.stringify({ mcpServers: servers })) - ).toMatchObject({ status: 'invalid', servers: [] }) - }) - - it('admits an exact-size command and rejects the field at +1', () => { - const exact = 'x'.repeat(MCP_CONFIG_INSPECTION_MAX_FIELD_CODE_UNITS) - const exactUtf8 = 'é'.repeat(MCP_CONFIG_INSPECTION_MAX_FIELD_BYTES / 2) - const inspectCommand = (command: string) => - inspectMcpConfigContent( - workspaceCandidate, - JSON.stringify({ mcpServers: { bounded: { command } } }) - ).servers[0] - - expect(inspectCommand(exact)).toMatchObject({ status: 'enabled', command: exact }) - expect(inspectCommand(`${exact}x`)).toMatchObject({ - status: 'invalid', - issue: 'Command exceeds the MCP inspection field limit.' - }) - expect(inspectCommand(exactUtf8)).toMatchObject({ status: 'enabled', command: exactUtf8 }) - expect(inspectCommand(`${exactUtf8}é`)).toMatchObject({ - status: 'invalid', - issue: 'Command exceeds the MCP inspection field limit.' - }) - }) - - it('admits the exact env cardinality and rejects +1 without retaining env values', () => { - const env = Object.fromEntries( - Array.from({ length: MCP_CONFIG_INSPECTION_MAX_ENV_FIELDS }, (_, index) => [ - `KEY_${index}`, - 'value' - ]) - ) - const inspectEnv = () => - inspectMcpConfigContent( - workspaceCandidate, - JSON.stringify({ mcpServers: { bounded: { command: 'node', env } } }) - ).servers[0] - - expect(Object.keys(inspectEnv()?.env ?? {})).toHaveLength(MCP_CONFIG_INSPECTION_MAX_ENV_FIELDS) - env.OVERFLOW = 'value' - expect(inspectEnv()).toMatchObject({ - status: 'invalid', - issue: 'Environment exceeds the MCP inspection field limits.' - }) - expect(inspectEnv()?.env).toBeUndefined() - }) - it('plans directory discovery before reading candidate files', () => { expect(getMcpConfigParentDirs()).toEqual(['.cursor', '.claude']) expect( diff --git a/src/shared/mcp-config.ts b/src/shared/mcp-config.ts index 0cc0ce60d3b..369dd101fa8 100644 --- a/src/shared/mcp-config.ts +++ b/src/shared/mcp-config.ts @@ -1,12 +1,3 @@ -import { - isMcpConfigInspectionNameWithinLimit, - isMcpConfigInspectionTextWithinLimit, - MCP_CONFIG_INSPECTION_MAX_SERVERS -} from './mcp-config-inspection-limits' -import { summarizeMcpServer } from './mcp-server-inspection' - -export { maskMcpEnv } from './mcp-server-inspection' - export type McpConfigFormat = 'workspace' | 'cursor' | 'claude' export type McpConfigCandidate = { @@ -109,6 +100,11 @@ export function canInspectLocalMcpConfigRoot(rootPath: string, isWindowsHost: bo return !/^(?:[A-Za-z]:[\\/]|[\\/]{2}[^\\/]+[\\/][^\\/]+)/.test(rootPath) } +const SENSITIVE_ENV_KEY_PATTERN = + /(api[_-]?key|auth|bearer|cookie|credential|password|private[_-]?key|secret|session|token)/i +const SENSITIVE_ENV_VALUE_PATTERN = + /(sk-[A-Za-z0-9_-]{12,}|gh[pousr]_[A-Za-z0-9_]{12,}|xox[baprs]-[A-Za-z0-9-]{12,})/ + export function inspectMcpConfigContent( candidate: McpConfigCandidate, content: string | null @@ -116,15 +112,6 @@ export function inspectMcpConfigContent( if (content === null) { return { candidate, exists: false, status: 'missing', servers: [] } } - if (!isMcpConfigInspectionTextWithinLimit(content)) { - return { - candidate, - exists: true, - status: 'invalid', - servers: [], - error: 'MCP config exceeds the inspection size limit.' - } - } let parsed: unknown try { @@ -143,40 +130,29 @@ export function inspectMcpConfigContent( if (!rawServers) { return { candidate, exists: true, status: 'valid', servers: [] } } - const serverEntries = collectMcpServerEntries(rawServers) - if (!serverEntries) { - return { - candidate, - exists: true, - status: 'invalid', - servers: [], - error: 'MCP server collection exceeds the inspection limits.' - } - } return { candidate, exists: true, status: 'valid', - servers: serverEntries.map(([name, entry]) => summarizeMcpServer(name, entry)) + servers: Object.entries(rawServers).map(([name, entry]) => summarizeMcpServer(name, entry)) } } -function collectMcpServerEntries(rawServers: Record): [string, unknown][] | null { - const entries: [string, unknown][] = [] - for (const name in rawServers) { - if (!Object.prototype.hasOwnProperty.call(rawServers, name)) { - continue - } - if ( - entries.length >= MCP_CONFIG_INSPECTION_MAX_SERVERS || - !isMcpConfigInspectionNameWithinLimit(name) - ) { - return null - } - entries.push([name, rawServers[name]]) +export function maskMcpEnv(env: unknown): Record | undefined { + if (!env || typeof env !== 'object' || Array.isArray(env)) { + return undefined } - return entries + + const masked: Record = {} + for (const [key, rawValue] of Object.entries(env)) { + const value = typeof rawValue === 'string' ? rawValue : String(rawValue) + masked[key] = + SENSITIVE_ENV_KEY_PATTERN.test(key) || SENSITIVE_ENV_VALUE_PATTERN.test(value) + ? '••••••••' + : value + } + return masked } function getRelativeParentDir(relativePath: string): string { @@ -206,3 +182,94 @@ function extractObjectAtPath( ? (current as Record) : null } + +function summarizeMcpServer(name: string, entry: unknown): McpServerSummary { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { + return { + name, + transport: 'unknown', + status: 'invalid', + issue: 'Server entry must be an object.' + } + } + + const raw = entry as Record + const command = readCommand(raw) + const url = readUrl(raw) + const transport = resolveTransport(raw, command, url) + const enabled = raw.enabled !== false && raw.disabled !== true + const env = maskMcpEnv(raw.env) + + if (transport === 'unknown') { + return { + name, + transport, + status: 'invalid', + env, + issue: 'Missing command or URL.' + } + } + + if (transport === 'http' && !url) { + return { + name, + transport, + status: 'invalid', + env, + issue: 'Missing URL.' + } + } + + if (transport === 'stdio' && !command) { + return { + name, + transport, + status: 'invalid', + env, + issue: 'Missing command.' + } + } + + return { + name, + transport, + status: enabled ? 'enabled' : 'disabled', + command, + url, + env + } +} + +function readCommand(raw: Record): string | undefined { + if (typeof raw.command === 'string') { + return raw.command + } + if (Array.isArray(raw.command) && typeof raw.command[0] === 'string') { + return raw.command[0] + } + return undefined +} + +function readUrl(raw: Record): string | undefined { + if (typeof raw.url === 'string') { + return raw.url + } + if (typeof raw.httpUrl === 'string') { + return raw.httpUrl + } + return undefined +} + +function resolveTransport( + raw: Record, + command: string | undefined, + url: string | undefined +): McpServerTransport { + if (raw.type === 'http' || raw.type === 'remote' || url) { + return 'http' + } + if (raw.type === 'local' || command) { + return 'stdio' + } + return 'unknown' +} diff --git a/src/shared/mcp-server-inspection.ts b/src/shared/mcp-server-inspection.ts deleted file mode 100644 index ab7a79cf40b..00000000000 --- a/src/shared/mcp-server-inspection.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { - isMcpConfigInspectionFieldWithinLimit, - isMcpConfigInspectionNameWithinLimit, - MCP_CONFIG_INSPECTION_MAX_ENV_FIELDS -} from './mcp-config-inspection-limits' -import type { McpServerSummary, McpServerTransport } from './mcp-config' - -const SENSITIVE_ENV_KEY_PATTERN = - /(api[_-]?key|auth|bearer|cookie|credential|password|private[_-]?key|secret|session|token)/i -const SENSITIVE_ENV_VALUE_PATTERN = - /(sk-[A-Za-z0-9_-]{12,}|gh[pousr]_[A-Za-z0-9_]{12,}|xox[baprs]-[A-Za-z0-9-]{12,})/ - -type BoundedString = { value?: string; oversized: boolean } -type BoundedEnv = { value?: Record; oversized: boolean } - -export function summarizeMcpServer(name: string, entry: unknown): McpServerSummary { - if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { - return invalidServer(name, 'Server entry must be an object.') - } - - const raw = entry as Record - const command = readCommand(raw) - const url = readUrl(raw) - const env = inspectMcpEnv(raw.env) - if (command.oversized) { - return invalidServer(name, 'Command exceeds the MCP inspection field limit.') - } - if (url.oversized) { - return invalidServer(name, 'URL exceeds the MCP inspection field limit.') - } - if (env.oversized) { - return invalidServer(name, 'Environment exceeds the MCP inspection field limits.') - } - - const transport = resolveTransport(raw, command.value, url.value) - const enabled = raw.enabled !== false && raw.disabled !== true - if (transport === 'unknown') { - return invalidServer(name, 'Missing command or URL.', env.value) - } - if (transport === 'http' && !url.value) { - return invalidServer(name, 'Missing URL.', env.value, transport) - } - if (transport === 'stdio' && !command.value) { - return invalidServer(name, 'Missing command.', env.value, transport) - } - - return { - name, - transport, - status: enabled ? 'enabled' : 'disabled', - command: command.value, - url: url.value, - env: env.value - } -} - -export function maskMcpEnv(env: unknown): Record | undefined { - return inspectMcpEnv(env).value -} - -function inspectMcpEnv(env: unknown): BoundedEnv { - if (!env || typeof env !== 'object' || Array.isArray(env)) { - return { oversized: false } - } - - const masked: Record = {} - let fields = 0 - for (const key in env) { - if (!Object.prototype.hasOwnProperty.call(env, key)) { - continue - } - fields += 1 - if ( - fields > MCP_CONFIG_INSPECTION_MAX_ENV_FIELDS || - !isMcpConfigInspectionNameWithinLimit(key) - ) { - return { oversized: true } - } - const rawValue = (env as Record)[key] - const value = typeof rawValue === 'string' ? rawValue : String(rawValue) - if (!isMcpConfigInspectionFieldWithinLimit(value)) { - return { oversized: true } - } - masked[key] = - SENSITIVE_ENV_KEY_PATTERN.test(key) || SENSITIVE_ENV_VALUE_PATTERN.test(value) - ? '••••••••' - : value - } - return { value: masked, oversized: false } -} - -function readCommand(raw: Record): BoundedString { - const value = - typeof raw.command === 'string' - ? raw.command - : Array.isArray(raw.command) && typeof raw.command[0] === 'string' - ? raw.command[0] - : undefined - return boundedString(value) -} - -function readUrl(raw: Record): BoundedString { - const value = - typeof raw.url === 'string' - ? raw.url - : typeof raw.httpUrl === 'string' - ? raw.httpUrl - : undefined - return boundedString(value) -} - -function boundedString(value: string | undefined): BoundedString { - return value === undefined || isMcpConfigInspectionFieldWithinLimit(value) - ? { value, oversized: false } - : { oversized: true } -} - -function invalidServer( - name: string, - issue: string, - env?: Record, - transport: McpServerTransport = 'unknown' -): McpServerSummary { - return { name, transport, status: 'invalid', env, issue } -} - -function resolveTransport( - raw: Record, - command: string | undefined, - url: string | undefined -): McpServerTransport { - if (raw.type === 'http' || raw.type === 'remote' || url) { - return 'http' - } - if (raw.type === 'local' || command) { - return 'stdio' - } - return 'unknown' -} diff --git a/src/shared/mobile-e2ee-v2-contract.test.ts b/src/shared/mobile-e2ee-v2-contract.test.ts index 64d461ca56b..4e263c96089 100644 --- a/src/shared/mobile-e2ee-v2-contract.test.ts +++ b/src/shared/mobile-e2ee-v2-contract.test.ts @@ -1,5 +1,5 @@ import { createHash } from 'node:crypto' -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import { encodeMobileE2EEV2Transcript, validateMobileE2EEV2Handshake @@ -29,25 +29,6 @@ describe('mobile E2EE v2 contract', () => { ).toBeNull() }) - it('rejects wrong-length handshake fields before base64 decoding', () => { - const { hello, ready } = createMobileE2EEV2Fixture() - const oversized = 'A'.repeat(45) - const decode = vi.spyOn(globalThis, 'atob') - - expect( - validateMobileE2EEV2Handshake( - { ...hello, clientPublicKeyB64: oversized, clientNonceB64: oversized }, - { - ...ready, - desktopPublicKeyB64: oversized, - clientNonceB64: oversized, - desktopNonceB64: oversized - } - ) - ).toBeNull() - expect(decode).not.toHaveBeenCalled() - }) - it('rejects context and capability-selection changes', () => { const { hello, ready } = createMobileE2EEV2Fixture() expect( diff --git a/src/shared/mobile-e2ee-v2-contract.ts b/src/shared/mobile-e2ee-v2-contract.ts index 907e22d3f4d..d687779b828 100644 --- a/src/shared/mobile-e2ee-v2-contract.ts +++ b/src/shared/mobile-e2ee-v2-contract.ts @@ -211,7 +211,6 @@ function contextsEqual(left: MobileE2EEV2Context, right: MobileE2EEV2Context): b function decodeCanonicalBase64Bytes(value: unknown, length: number): Uint8Array | null { if ( typeof value !== 'string' || - value.length !== Math.ceil(length / 3) * 4 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value) ) { return null diff --git a/src/shared/mobile-file-directory-limit.test.ts b/src/shared/mobile-file-directory-limit.test.ts deleted file mode 100644 index 2d2228c2af7..00000000000 --- a/src/shared/mobile-file-directory-limit.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - assertMobileFileDirectoryWithinLimit, - MOBILE_FILE_DIRECTORY_LIMIT_MESSAGE, - MOBILE_FILE_DIRECTORY_MAX_ENTRIES, - MOBILE_FILE_DIRECTORY_MAX_RETAINED_BYTES -} from './mobile-file-directory-limit' - -describe('mobile file directory limit', () => { - it('accepts a complete directory listing within both limits', () => { - expect(() => - assertMobileFileDirectoryWithinLimit([ - { name: 'src' }, - { name: 'README.md' }, - { name: 'package.json' } - ]) - ).not.toThrow() - }) - - it('rejects rather than truncating an excessive entry count', () => { - const entries = Array.from({ length: MOBILE_FILE_DIRECTORY_MAX_ENTRIES + 1 }, () => ({ - name: 'x' - })) - - expect(() => assertMobileFileDirectoryWithinLimit(entries)).toThrow( - MOBILE_FILE_DIRECTORY_LIMIT_MESSAGE - ) - }) - - it('rejects a listing whose names exceed the retained-byte limit', () => { - const name = 'x'.repeat(MOBILE_FILE_DIRECTORY_MAX_RETAINED_BYTES / 2) - - expect(() => assertMobileFileDirectoryWithinLimit([{ name }])).toThrow( - MOBILE_FILE_DIRECTORY_LIMIT_MESSAGE - ) - }) -}) diff --git a/src/shared/mobile-file-directory-limit.ts b/src/shared/mobile-file-directory-limit.ts deleted file mode 100644 index 9860f078c8d..00000000000 --- a/src/shared/mobile-file-directory-limit.ts +++ /dev/null @@ -1,43 +0,0 @@ -// Why: normal repositories stay complete while pathological fan-out/name payloads fail before retention. -export const MOBILE_FILE_DIRECTORY_MAX_ENTRIES = 10_000 -export const MOBILE_FILE_DIRECTORY_MAX_RETAINED_BYTES = 4 * 1024 * 1024 -export const MOBILE_FILE_DIRECTORY_LIMIT_MESSAGE = - 'This folder is too large to show safely on mobile (limit: 10,000 items or a 4 MB listing).' - -type NamedDirectoryEntry = { name: string } - -export type MobileFileDirectoryLimitState = { - entries: number - retainedBytes: number -} - -export function createMobileFileDirectoryLimitState(): MobileFileDirectoryLimitState { - return { entries: 0, retainedBytes: 0 } -} - -export function trackMobileFileDirectoryEntry( - state: MobileFileDirectoryLimitState, - entry: NamedDirectoryEntry -): void { - state.entries += 1 - state.retainedBytes += estimateMobileDirectoryEntryBytes(entry) - if ( - state.entries > MOBILE_FILE_DIRECTORY_MAX_ENTRIES || - state.retainedBytes > MOBILE_FILE_DIRECTORY_MAX_RETAINED_BYTES - ) { - throw new Error(MOBILE_FILE_DIRECTORY_LIMIT_MESSAGE) - } -} - -export function assertMobileFileDirectoryWithinLimit( - entries: readonly NamedDirectoryEntry[] -): void { - const state = createMobileFileDirectoryLimitState() - for (const entry of entries) { - trackMobileFileDirectoryEntry(state, entry) - } -} - -export function estimateMobileDirectoryEntryBytes(entry: NamedDirectoryEntry): number { - return entry.name.length * 2 + 64 -} diff --git a/src/shared/mobile-relay-pairing-offer.ts b/src/shared/mobile-relay-pairing-offer.ts index 97419d9114e..df16dad35a2 100644 --- a/src/shared/mobile-relay-pairing-offer.ts +++ b/src/shared/mobile-relay-pairing-offer.ts @@ -1,22 +1,14 @@ import { z } from 'zod' export const PAIRING_OFFER_VERSION = 2 -export const PAIRING_CODE_MAX_CHARACTERS = 128 * 1024 -export const PAIRING_INPUT_MAX_CHARACTERS = PAIRING_CODE_MAX_CHARACTERS + 1024 -export const PAIRING_ENDPOINT_MAX_CHARACTERS = 16 * 1024 -export const PAIRING_DEVICE_TOKEN_MAX_CHARACTERS = 64 * 1024 -export const PAIRING_PUBLIC_KEY_MAX_CHARACTERS = 4 * 1024 -export const PAIRING_RELAY_URL_MAX_CHARACTERS = 2048 const PairingScopeSchema = z.enum(['mobile', 'runtime']) const BASE64URL_16_PATTERN = /^[A-Za-z0-9_-]{16}$/ const BASE64URL_43_PATTERN = /^[A-Za-z0-9_-]{43}$/ +const MAX_RELAY_URL_BYTES = 2048 const MAX_INVITE_TTL_MS = 10 * 60 * 1000 function isCanonicalHttpsOrigin(value: string): boolean { - if ( - value.length > PAIRING_RELAY_URL_MAX_CHARACTERS || - new TextEncoder().encode(value).length > PAIRING_RELAY_URL_MAX_CHARACTERS - ) { + if (new TextEncoder().encode(value).length > MAX_RELAY_URL_BYTES) { return false } try { @@ -63,11 +55,11 @@ export function createPairingOfferSchema(now: () => number = () => Date.now()) { return z .object({ v: z.literal(PAIRING_OFFER_VERSION), - endpoint: z.string().min(1).max(PAIRING_ENDPOINT_MAX_CHARACTERS), - deviceToken: z.string().min(1).max(PAIRING_DEVICE_TOKEN_MAX_CHARACTERS), + endpoint: z.string().min(1), + deviceToken: z.string().min(1), // Why: the desktop's Curve25519 public key is pinned by the pairing // offer, while relayHostId is verified from its decoded bytes later. - publicKeyB64: z.string().min(1).max(PAIRING_PUBLIC_KEY_MAX_CHARACTERS), + publicKeyB64: z.string().min(1), scope: PairingScopeSchema.optional(), relay: relaySchema.optional() }) diff --git a/src/shared/node-bounded-file-reader-sync.test.ts b/src/shared/node-bounded-file-reader-sync.test.ts deleted file mode 100644 index 748969e13ba..00000000000 --- a/src/shared/node-bounded-file-reader-sync.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { closeSync, ftruncateSync, mkdtempSync, openSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import { NodeFileReadTooLargeError, readNodeFileSyncWithinLimit } from './node-bounded-file-reader' - -const tempDirectories: string[] = [] - -function createTempFile(content: string): string { - const directory = mkdtempSync(join(tmpdir(), 'orca-bounded-sync-read-')) - tempDirectories.push(directory) - const path = join(directory, 'input') - writeFileSync(path, content) - return path -} - -afterEach(() => { - for (const directory of tempDirectories.splice(0)) { - rmSync(directory, { recursive: true }) - } -}) - -describe('readNodeFileSyncWithinLimit', () => { - it('returns stable bytes without changing them', () => { - const path = createTempFile('stable 🐋 bytes') - - expect(readNodeFileSyncWithinLimit(path, 1024).buffer.toString('utf8')).toBe('stable 🐋 bytes') - }) - - it('rejects an oversized sparse file before allocating its declared size', () => { - const path = createTempFile('') - const descriptor = openSync(path, 'r+') - ftruncateSync(descriptor, 1025) - closeSync(descriptor) - - expect(() => readNodeFileSyncWithinLimit(path, 1024)).toThrow(NodeFileReadTooLargeError) - }) -}) diff --git a/src/shared/node-file-content-equality.test.ts b/src/shared/node-file-content-equality.test.ts deleted file mode 100644 index af50287b96e..00000000000 --- a/src/shared/node-file-content-equality.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { mkdtempSync, rmSync, truncateSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import { nodeFileContentsEqual, nodeFileContentsEqualSync } from './node-file-content-equality' - -const roots: string[] = [] - -function createFile(contents: string): string { - const root = mkdtempSync(join(tmpdir(), 'orca-file-content-equality-')) - roots.push(root) - const filePath = join(root, 'owned-launcher') - writeFileSync(filePath, contents) - return filePath -} - -afterEach(() => { - for (const root of roots.splice(0)) { - rmSync(root, { recursive: true, force: true }) - } -}) - -describe('Node file content equality', () => { - it('compares UTF-8 content without changing its bytes', async () => { - const filePath = createFile('launch 🐋\n') - - await expect(nodeFileContentsEqual(filePath, 'launch 🐋\n')).resolves.toBe(true) - expect(nodeFileContentsEqualSync(filePath, 'launch 🐋\n')).toBe(true) - await expect(nodeFileContentsEqual(filePath, 'different\n')).resolves.toBe(false) - expect(nodeFileContentsEqualSync(filePath, 'different\n')).toBe(false) - }) - - it('rejects a large sparse replacement from metadata without reading its payload', async () => { - const filePath = createFile('owned launcher\n') - truncateSync(filePath, 256 * 1024 * 1024) - - await expect(nodeFileContentsEqual(filePath, 'owned launcher\n')).resolves.toBe(false) - expect(nodeFileContentsEqualSync(filePath, 'owned launcher\n')).toBe(false) - }) -}) diff --git a/src/shared/node-file-content-equality.ts b/src/shared/node-file-content-equality.ts deleted file mode 100644 index 5f7ccb72937..00000000000 --- a/src/shared/node-file-content-equality.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { closeSync, fstatSync, openSync, readSync } from 'node:fs' -import { open } from 'node:fs/promises' - -export const NODE_FILE_CONTENT_COMPARE_CHUNK_BYTES = 64 * 1024 - -function expectedBytes(contents: string | Buffer): Buffer { - return typeof contents === 'string' ? Buffer.from(contents, 'utf8') : contents -} - -export async function nodeFileContentsEqual( - filePath: string, - expectedContents: string | Buffer -): Promise { - const expected = expectedBytes(expectedContents) - const handle = await open(filePath, 'r') - try { - if ((await handle.stat()).size !== expected.length) { - return false - } - const chunk = Buffer.allocUnsafe( - Math.min(NODE_FILE_CONTENT_COMPARE_CHUNK_BYTES, expected.length) - ) - let offset = 0 - while (offset < expected.length) { - const length = Math.min(chunk.length, expected.length - offset) - const { bytesRead } = await handle.read(chunk, 0, length, offset) - if ( - bytesRead === 0 || - !chunk.subarray(0, bytesRead).equals(expected.subarray(offset, offset + bytesRead)) - ) { - return false - } - offset += bytesRead - } - const probe = Buffer.allocUnsafe(1) - return (await handle.read(probe, 0, 1, offset)).bytesRead === 0 - } finally { - await handle.close() - } -} - -export function nodeFileContentsEqualSync( - filePath: string, - expectedContents: string | Buffer -): boolean { - const expected = expectedBytes(expectedContents) - const descriptor = openSync(filePath, 'r') - try { - if (fstatSync(descriptor).size !== expected.length) { - return false - } - const chunk = Buffer.allocUnsafe( - Math.min(NODE_FILE_CONTENT_COMPARE_CHUNK_BYTES, expected.length) - ) - let offset = 0 - while (offset < expected.length) { - const length = Math.min(chunk.length, expected.length - offset) - const bytesRead = readSync(descriptor, chunk, 0, length, offset) - if ( - bytesRead === 0 || - !chunk.subarray(0, bytesRead).equals(expected.subarray(offset, offset + bytesRead)) - ) { - return false - } - offset += bytesRead - } - const probe = Buffer.allocUnsafe(1) - return readSync(descriptor, probe, 0, 1, offset) === 0 - } finally { - closeSync(descriptor) - } -} diff --git a/src/shared/node-markdown-document-discovery.test.ts b/src/shared/node-markdown-document-discovery.test.ts deleted file mode 100644 index e151f25ad90..00000000000 --- a/src/shared/node-markdown-document-discovery.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import type { Dirent } from 'node:fs' -import { describe, expect, it } from 'vitest' -import { MarkdownDocumentListingCapacityError } from './markdown-document-listing-limits' -import { discoverMarkdownRelativePaths } from './node-markdown-document-discovery' - -function entry(name: string, kind: 'directory' | 'file' | 'symlink' = 'file'): Dirent { - return { - name, - isDirectory: () => kind === 'directory', - isFile: () => kind === 'file', - isSymbolicLink: () => kind === 'symlink' - } as Dirent -} - -function reader(entriesByPath: Record) { - return async (path: string): Promise> => ({ - async *[Symbol.asyncIterator]() { - yield* entriesByPath[path] ?? [] - } - }) -} - -describe('bounded Markdown document discovery', () => { - it('preserves depth-first discovery and skips excluded and symlinked directories', async () => { - const result = await discoverMarkdownRelativePaths('/repo', { - readDirectory: reader({ - '/repo': [ - entry('README.md'), - entry('.git', 'directory'), - entry('docs', 'directory'), - entry('linked', 'symlink') - ], - '/repo/docs': [entry('guide.mdx'), entry('app.ts')] - }), - shouldDescend: (_relativePath, name) => name !== '.git' - }) - - expect(result).toEqual(['README.md', 'docs/guide.mdx']) - }) - - it('stops consuming a wide directory at the visited-entry limit', async () => { - let yielded = 0 - const readDirectory = async (): Promise> => ({ - async *[Symbol.asyncIterator]() { - for (let index = 0; index < 10_000; index += 1) { - yielded += 1 - yield entry(`source-${index}.ts`) - } - } - }) - - await expect( - discoverMarkdownRelativePaths('/repo', { - limits: { maxVisitedEntries: 2 }, - readDirectory, - shouldDescend: () => true - }) - ).rejects.toBeInstanceOf(MarkdownDocumentListingCapacityError) - expect(yielded).toBe(3) - }) - - it('rejects a directory deeper than the configured traversal limit', async () => { - await expect( - discoverMarkdownRelativePaths('/repo', { - limits: { maxDepth: 1 }, - readDirectory: reader({ - '/repo': [entry('one', 'directory')], - '/repo/one': [entry('two', 'directory')] - }), - shouldDescend: () => true - }) - ).rejects.toBeInstanceOf(MarkdownDocumentListingCapacityError) - }) -}) diff --git a/src/shared/node-markdown-document-discovery.ts b/src/shared/node-markdown-document-discovery.ts deleted file mode 100644 index 06631c6fc3b..00000000000 --- a/src/shared/node-markdown-document-discovery.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { opendir } from 'node:fs/promises' -import type { Dirent, Dir } from 'node:fs' -import { join } from 'node:path' -import { - assertMarkdownDocumentPathWithinLimit, - createMarkdownDocumentListingBudget, - MarkdownDocumentListingCapacityError, - retainMarkdownRelativePath, - visitMarkdownDocumentListingEntry, - type MarkdownDocumentListingLimits -} from './markdown-document-listing-limits' - -type MarkdownDirectoryReader = (path: string) => Promise> - -export type MarkdownDocumentDiscoveryOptions = { - shouldDescend: (relativePath: string, name: string) => boolean - ignoreNestedDirectoryErrors?: boolean - limits?: Partial - readDirectory?: MarkdownDirectoryReader - signal?: AbortSignal -} - -export function isMarkdownDocumentPath(path: string): boolean { - const lowerPath = path.toLowerCase() - return lowerPath.endsWith('.md') || lowerPath.endsWith('.mdx') || lowerPath.endsWith('.markdown') -} - -export async function discoverMarkdownRelativePaths( - rootPath: string, - options: MarkdownDocumentDiscoveryOptions -): Promise { - const budget = createMarkdownDocumentListingBudget(options.limits) - const documents: string[] = [] - const readDirectory = options.readDirectory ?? opendir - assertMarkdownDocumentPathWithinLimit(rootPath, budget.limits.maxPathBytes) - - const visitDirectory = async ( - absoluteDirectoryPath: string, - relativeDirectoryPath: string, - depth: number - ): Promise => { - throwIfAborted(options.signal) - let directory: Dir | AsyncIterable - try { - directory = await readDirectory(absoluteDirectoryPath) - } catch (error) { - if (depth > 0 && options.ignoreNestedDirectoryErrors) { - return - } - throw error - } - - for await (const entry of directory) { - throwIfAborted(options.signal) - const relativePath = relativeDirectoryPath - ? `${relativeDirectoryPath}/${entry.name}` - : entry.name - const nextDepth = depth + 1 - const shouldDescend = entry.isDirectory() && options.shouldDescend(relativePath, entry.name) - visitMarkdownDocumentListingEntry(budget, relativePath, shouldDescend ? nextDepth : depth) - if (entry.isSymbolicLink()) { - continue - } - if (entry.isDirectory()) { - if (shouldDescend) { - await visitDirectory(join(absoluteDirectoryPath, entry.name), relativePath, nextDepth) - } - continue - } - if (entry.isFile() && isMarkdownDocumentPath(entry.name)) { - retainMarkdownRelativePath(budget, rootPath, relativePath) - documents.push(relativePath) - } - } - } - - await visitDirectory(rootPath, '', 0) - return documents -} - -function throwIfAborted(signal: AbortSignal | undefined): void { - if (!signal?.aborted) { - return - } - throw signal.reason instanceof Error ? signal.reason : new MarkdownDocumentListingCapacityError() -} diff --git a/src/shared/node-readable-text.test.ts b/src/shared/node-readable-text.test.ts deleted file mode 100644 index 5205b589ddc..00000000000 --- a/src/shared/node-readable-text.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - NodeReadableTextTooLargeError, - readNodeReadableTextWithinLimit -} from './node-readable-text' - -async function* chunks(values: unknown[]): AsyncGenerator { - yield* values -} - -describe('readNodeReadableTextWithinLimit', () => { - it('preserves accepted UTF-8 bytes split across chunks', async () => { - const encoded = Buffer.from('hello 🌍') - - await expect( - readNodeReadableTextWithinLimit( - chunks([encoded.subarray(0, 8), encoded.subarray(8)]), - encoded.byteLength - ) - ).resolves.toBe('hello 🌍') - }) - - it('accepts input exactly at the byte limit', async () => { - await expect( - readNodeReadableTextWithinLimit(chunks(['ab', Buffer.from('cd')]), 4) - ).resolves.toBe('abcd') - }) - - it('rejects before retaining input beyond the byte limit', async () => { - await expect(readNodeReadableTextWithinLimit(chunks(['1234', '5']), 4)).rejects.toEqual( - new NodeReadableTextTooLargeError(5, 4) - ) - }) - - it('does not let an unlimited sequence of empty chunks grow retained state', async () => { - await expect( - readNodeReadableTextWithinLimit(chunks(Array.from({ length: 10_000 }, () => '')), 0) - ).resolves.toBe('') - }) -}) diff --git a/src/shared/node-readable-text.ts b/src/shared/node-readable-text.ts deleted file mode 100644 index d73370629eb..00000000000 --- a/src/shared/node-readable-text.ts +++ /dev/null @@ -1,42 +0,0 @@ -const INITIAL_READ_CAPACITY_BYTES = 64 * 1024 - -export class NodeReadableTextTooLargeError extends Error { - constructor( - readonly observedBytes: number, - readonly maxBytes: number - ) { - super(`Input exceeds ${maxBytes} byte limit (${observedBytes} bytes received)`) - this.name = 'NodeReadableTextTooLargeError' - } -} - -export async function readNodeReadableTextWithinLimit( - readable: AsyncIterable, - maxBytes: number -): Promise { - if (!Number.isSafeInteger(maxBytes) || maxBytes < 0) { - throw new RangeError('Readable text limit must be a non-negative safe integer') - } - - let buffer = Buffer.allocUnsafe(Math.min(INITIAL_READ_CAPACITY_BYTES, maxBytes)) - let bytes = 0 - for await (const value of readable) { - const chunk = Buffer.isBuffer(value) ? value : Buffer.from(String(value)) - const observedBytes = bytes + chunk.byteLength - if (!Number.isSafeInteger(observedBytes) || observedBytes > maxBytes) { - throw new NodeReadableTextTooLargeError(observedBytes, maxBytes) - } - if (observedBytes > buffer.byteLength) { - const nextCapacity = Math.min( - maxBytes, - Math.max(observedBytes, INITIAL_READ_CAPACITY_BYTES, buffer.byteLength * 2) - ) - const expanded = Buffer.allocUnsafe(nextCapacity) - buffer.copy(expanded, 0, 0, bytes) - buffer = expanded - } - chunk.copy(buffer, bytes) - bytes = observedBytes - } - return buffer.subarray(0, bytes).toString('utf8') -} diff --git a/src/shared/node-source-copy-content-equality.test.ts b/src/shared/node-source-copy-content-equality.test.ts deleted file mode 100644 index cebe42e99d4..00000000000 --- a/src/shared/node-source-copy-content-equality.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { - closeSync, - mkdtempSync, - openSync, - rmSync, - truncateSync, - writeFileSync, - writeSync -} from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import { - NODE_FILE_CONTENT_COMPARE_CHUNK_BYTES, - nodeSourceAndCopyContentsEqualSync -} from './node-source-copy-content-equality' - -describe('Node source/copy content equality', () => { - const roots: string[] = [] - - afterEach(() => { - for (const root of roots.splice(0)) { - rmSync(root, { recursive: true, force: true }) - } - }) - - it('compares multi-megabyte sparse files with fixed-size chunks', () => { - const root = mkdtempSync(join(tmpdir(), 'orca-resource-compare-')) - roots.push(root) - const sourcePath = join(root, 'source.md') - const copyPath = join(root, 'copy.md') - const sparseBytes = NODE_FILE_CONTENT_COMPARE_CHUNK_BYTES * 128 - for (const path of [sourcePath, copyPath]) { - writeFileSync(path, 'same-prefix') - truncateSync(path, sparseBytes) - } - - expect(nodeSourceAndCopyContentsEqualSync(sourcePath, copyPath)).toBe(true) - - const descriptor = openSync(copyPath, 'r+') - try { - writeSync(descriptor, Buffer.from('x'), 0, 1, sparseBytes - 1) - } finally { - closeSync(descriptor) - } - expect(nodeSourceAndCopyContentsEqualSync(sourcePath, copyPath)).toBe(false) - }) - - it('rejects a non-file copy without attempting to consume it', () => { - const root = mkdtempSync(join(tmpdir(), 'orca-resource-compare-dir-')) - roots.push(root) - const sourcePath = join(root, 'source.md') - writeFileSync(sourcePath, 'contents') - - expect(nodeSourceAndCopyContentsEqualSync(sourcePath, root)).toBe(false) - }) -}) diff --git a/src/shared/node-source-copy-content-equality.ts b/src/shared/node-source-copy-content-equality.ts deleted file mode 100644 index 7c17a1eef38..00000000000 --- a/src/shared/node-source-copy-content-equality.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { closeSync, lstatSync, openSync, readSync, statSync } from 'node:fs' - -export const NODE_FILE_CONTENT_COMPARE_CHUNK_BYTES = 64 * 1024 - -function readChunk(descriptor: number, buffer: Buffer): number { - let offset = 0 - while (offset < buffer.length) { - const bytesRead = readSync(descriptor, buffer, offset, buffer.length - offset, null) - if (bytesRead === 0) { - break - } - offset += bytesRead - } - return offset -} - -export function nodeSourceAndCopyContentsEqualSync(sourcePath: string, copyPath: string): boolean { - try { - // Why: source links are intentional, but an owned copy must remain a regular file. - if (!statSync(sourcePath).isFile() || !lstatSync(copyPath).isFile()) { - return false - } - } catch { - return false - } - - let sourceDescriptor: number | null = null - let copyDescriptor: number | null = null - let matches = false - let failed = false - try { - sourceDescriptor = openSync(sourcePath, 'r') - copyDescriptor = openSync(copyPath, 'r') - const sourceBuffer = Buffer.allocUnsafe(NODE_FILE_CONTENT_COMPARE_CHUNK_BYTES) - const copyBuffer = Buffer.allocUnsafe(NODE_FILE_CONTENT_COMPARE_CHUNK_BYTES) - while (true) { - const sourceBytes = readChunk(sourceDescriptor, sourceBuffer) - const copyBytes = readChunk(copyDescriptor, copyBuffer) - if (sourceBytes !== copyBytes) { - break - } - if (sourceBytes === 0) { - matches = true - break - } - if (!sourceBuffer.subarray(0, sourceBytes).equals(copyBuffer.subarray(0, copyBytes))) { - break - } - } - } catch { - failed = true - } - for (const descriptor of [sourceDescriptor, copyDescriptor]) { - if (descriptor === null) { - continue - } - try { - closeSync(descriptor) - } catch { - failed = true - } - } - return matches && !failed -} diff --git a/src/shared/nul-delimited-fields.test.ts b/src/shared/nul-delimited-fields.test.ts deleted file mode 100644 index 59f8f751b0a..00000000000 --- a/src/shared/nul-delimited-fields.test.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { iterateNulDelimitedFields } from './nul-delimited-fields' - -describe('iterateNulDelimitedFields', () => { - it('preserves empty and trailing fields without materializing a split array', () => { - expect([...iterateNulDelimitedFields('one\0\0three\0')]).toEqual(['one', '', 'three', '']) - }) -}) diff --git a/src/shared/nul-delimited-fields.ts b/src/shared/nul-delimited-fields.ts deleted file mode 100644 index 64bbf04ded5..00000000000 --- a/src/shared/nul-delimited-fields.ts +++ /dev/null @@ -1,12 +0,0 @@ -export function* iterateNulDelimitedFields(value: string): Generator { - let start = 0 - while (start <= value.length) { - const end = value.indexOf('\0', start) - if (end === -1) { - yield value.slice(start) - return - } - yield value.slice(start, end) - start = end + 1 - } -} diff --git a/src/shared/orca-yaml-alias-bounds.test.ts b/src/shared/orca-yaml-alias-bounds.test.ts deleted file mode 100644 index 611c69a1fe0..00000000000 --- a/src/shared/orca-yaml-alias-bounds.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { parseOrcaYaml } from './orca-yaml' - -describe('orca.yaml alias expansion', () => { - it('preserves an ordinary shared scalar', () => { - expect( - parseOrcaYaml(` -setupCommand: &setupCommand pnpm install -scripts: - setup: *setupCommand -`) - ).toMatchObject({ scripts: { setup: 'pnpm install' } }) - }) - - it('rejects alias expansion beyond the explicit conversion cap', () => { - const aliases = Array.from({ length: 21 }, () => '*items').join(', ') - - expect( - parseOrcaYaml(` -items: &items [one, two] -expanded: [${aliases}] -scripts: - setup: pnpm install -`) - ).toBeNull() - }) -}) diff --git a/src/shared/orca-yaml-bounds.test.ts b/src/shared/orca-yaml-bounds.test.ts deleted file mode 100644 index b2e6b6194ed..00000000000 --- a/src/shared/orca-yaml-bounds.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const parseDocumentMock = vi.hoisted(() => vi.fn()) - -vi.mock('yaml', () => ({ - parseDocument: parseDocumentMock -})) - -import { - MAX_ORCA_YAML_ALIAS_COUNT, - MAX_ORCA_YAML_BYTES, - MAX_ORCA_YAML_COLLECTION_ENTRIES, - MAX_ORCA_YAML_FIELD_BYTES, - MAX_ORCA_YAML_FIELD_CODE_UNITS -} from './orca-yaml-file-limit' -import { parseOrcaYaml } from './orca-yaml' - -function returnYamlRoot(root: unknown): void { - parseDocumentMock.mockReturnValue({ - errors: [], - toJS: vi.fn(() => root) - }) -} - -describe('orca.yaml parse bounds', () => { - beforeEach(() => { - parseDocumentMock.mockReset() - returnYamlRoot({ scripts: { setup: 'pnpm install' } }) - }) - - it('admits the exact UTF-8 input boundary and rejects +1 before YAML parsing', () => { - expect(parseOrcaYaml(' '.repeat(MAX_ORCA_YAML_BYTES))).toMatchObject({ - scripts: { setup: 'pnpm install' } - }) - expect(parseDocumentMock).toHaveBeenCalledOnce() - - parseDocumentMock.mockClear() - expect(parseOrcaYaml(' '.repeat(MAX_ORCA_YAML_BYTES + 1))).toBeNull() - expect(parseDocumentMock).not.toHaveBeenCalled() - }) - - it('rejects a multibyte input over the byte cap before YAML parsing', () => { - const content = 'é'.repeat(MAX_ORCA_YAML_BYTES / 2 + 1) - - expect(parseOrcaYaml(content)).toBeNull() - expect(parseDocumentMock).not.toHaveBeenCalled() - }) - - it('passes an explicit alias expansion cap to YAML conversion', () => { - const toJS = vi.fn(() => ({ scripts: { setup: 'pnpm install' } })) - parseDocumentMock.mockReturnValue({ errors: [], toJS }) - - expect(parseOrcaYaml('scripts: {}')).not.toBeNull() - expect(toJS).toHaveBeenCalledWith({ maxAliasCount: MAX_ORCA_YAML_ALIAS_COUNT }) - }) - - it('preserves exact-size fields and drops a field at +1 code unit', () => { - const exact = 'x'.repeat(MAX_ORCA_YAML_FIELD_CODE_UNITS) - returnYamlRoot({ scripts: { setup: exact } }) - expect(parseOrcaYaml('exact')).toMatchObject({ scripts: { setup: exact } }) - - returnYamlRoot({ scripts: { setup: `${exact}x` } }) - expect(parseOrcaYaml('overflow')).toBeNull() - - const exactUtf8 = 'é'.repeat(MAX_ORCA_YAML_FIELD_BYTES / 2) - returnYamlRoot({ scripts: { setup: exactUtf8 } }) - expect(parseOrcaYaml('exact-utf8')).toMatchObject({ scripts: { setup: exactUtf8 } }) - - returnYamlRoot({ scripts: { setup: `${exactUtf8}é` } }) - expect(parseOrcaYaml('overflow-utf8')).toBeNull() - }) - - it('admits the exact collection boundary and rejects +1 entries', () => { - const tabs = Array.from({ length: MAX_ORCA_YAML_COLLECTION_ENTRIES }, (_, index) => ({ - title: `tab-${index}` - })) - returnYamlRoot({ defaultTabs: tabs }) - expect(parseOrcaYaml('exact')?.defaultTabs).toHaveLength(MAX_ORCA_YAML_COLLECTION_ENTRIES) - - returnYamlRoot({ defaultTabs: [...tabs, { title: 'overflow' }] }) - expect(parseOrcaYaml('overflow')).toBeNull() - }) -}) diff --git a/src/shared/orca-yaml-file-limit.ts b/src/shared/orca-yaml-file-limit.ts deleted file mode 100644 index 8f2641637a1..00000000000 --- a/src/shared/orca-yaml-file-limit.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { measureUtf8ByteLength } from './utf8-byte-limits' - -export const MAX_ORCA_YAML_BYTES = 256 * 1024 -export const MAX_ORCA_YAML_CODE_UNITS = 256 * 1024 -export const MAX_ORCA_YAML_FIELD_BYTES = 64 * 1024 -export const MAX_ORCA_YAML_FIELD_CODE_UNITS = 64 * 1024 -export const MAX_ORCA_YAML_COLLECTION_ENTRIES = 256 -export const MAX_ORCA_YAML_ALIAS_COUNT = 20 - -export function isOrcaYamlTextWithinLimit(content: string): boolean { - return ( - content.length <= MAX_ORCA_YAML_CODE_UNITS && - !measureUtf8ByteLength(content, { stopAfterBytes: MAX_ORCA_YAML_BYTES }).exceededLimit - ) -} - -export function isOrcaYamlFieldWithinLimit(value: string): boolean { - return ( - value.length <= MAX_ORCA_YAML_FIELD_CODE_UNITS && - !measureUtf8ByteLength(value, { stopAfterBytes: MAX_ORCA_YAML_FIELD_BYTES }).exceededLimit - ) -} diff --git a/src/shared/orca-yaml.ts b/src/shared/orca-yaml.ts index e8cb15e0ad8..c9179c6d606 100644 --- a/src/shared/orca-yaml.ts +++ b/src/shared/orca-yaml.ts @@ -1,16 +1,10 @@ -import { parseDocument } from 'yaml' +import { parse } from 'yaml' import type { OrcaDefaultTabTemplate, OrcaHooks, OrcaVmRecipe, OrcaVmRecipeDiagnostic } from './types' -import { - isOrcaYamlFieldWithinLimit, - isOrcaYamlTextWithinLimit, - MAX_ORCA_YAML_ALIAS_COUNT, - MAX_ORCA_YAML_COLLECTION_ENTRIES -} from './orca-yaml-file-limit' function asRecord(value: unknown): Record | null { return value !== null && typeof value === 'object' && !Array.isArray(value) @@ -19,11 +13,7 @@ function asRecord(value: unknown): Record | null { } function asTrimmedString(value: unknown): string | undefined { - if (typeof value !== 'string' || !isOrcaYamlFieldWithinLimit(value)) { - return undefined - } - const trimmed = value.trim() - return trimmed || undefined + return typeof value === 'string' && value.trim() ? value.trim() : undefined } const DEFAULT_TAB_COLOR_RE = /^#[0-9a-fA-F]{3}(?:[0-9a-fA-F]{3})?$/ @@ -32,7 +22,7 @@ export const ORCA_VM_RECIPE_ID_RULE = 'Use 1-64 lowercase letters, numbers, dots, underscores, or hyphens, starting with a letter or number.' function normalizeDefaultTabs(value: unknown): OrcaDefaultTabTemplate[] { - if (!Array.isArray(value) || value.length > MAX_ORCA_YAML_COLLECTION_ENTRIES) { + if (!Array.isArray(value)) { return [] } @@ -68,17 +58,6 @@ function normalizeVmRecipes(value: unknown): VmRecipeParseResult { if (!Array.isArray(value)) { return { recipes: [], diagnostics } } - if (value.length > MAX_ORCA_YAML_COLLECTION_ENTRIES) { - return { - recipes: [], - diagnostics: [ - { - index: MAX_ORCA_YAML_COLLECTION_ENTRIES, - message: `At most ${MAX_ORCA_YAML_COLLECTION_ENTRIES} environment recipes are supported.` - } - ] - } - } const seenIds = new Set() const recipes = value @@ -147,22 +126,9 @@ function normalizeVmRecipes(value: unknown): VmRecipeParseResult { * Parse the supported project defaults from `orca.yaml`. */ export function parseOrcaYaml(content: string): OrcaHooks | null { - if (!isOrcaYamlTextWithinLimit(content)) { - return null - } - let root: unknown try { - const document = parseDocument(content, { - keepSourceTokens: false, - logLevel: 'silent', - prettyErrors: false, - uniqueKeys: true - }) - if (document.errors.length > 0) { - return null - } - root = document.toJS({ maxAliasCount: MAX_ORCA_YAML_ALIAS_COUNT }) + root = parse(content) } catch { return null } diff --git a/src/shared/osc-title-extraction.ts b/src/shared/osc-title-extraction.ts index 20760457237..e4bbe53508a 100644 --- a/src/shared/osc-title-extraction.ts +++ b/src/shared/osc-title-extraction.ts @@ -5,7 +5,6 @@ const BACKSLASH_CODE_UNIT = 0x5c const SEMICOLON_CODE_UNIT = 0x3b const OSC_TITLE_COMMANDS = new Set([0x30, 0x31, 0x32]) export const MAX_OSC_TITLE_CHARS = 1024 -export const MAX_OSC_TITLES_PER_CHUNK = 4096 type OscTitleParseResult = | { kind: 'title'; title: string; nextIndex: number } @@ -105,7 +104,6 @@ export function extractAllOscTitles(data: string): string[] { } const titles: string[] = [] - let oldestTitleIndex = 0 let searchStart = 0 while (searchStart < data.length) { const start = data.indexOf('\x1b]', searchStart) @@ -117,18 +115,11 @@ export function extractAllOscTitles(data: string): string[] { break } if (parsed.kind === 'title') { - if (titles.length < MAX_OSC_TITLES_PER_CHUNK) { - titles.push(parsed.title) - } else { - titles[oldestTitleIndex] = parsed.title - oldestTitleIndex = (oldestTitleIndex + 1) % MAX_OSC_TITLES_PER_CHUNK - } + titles.push(parsed.title) searchStart = parsed.nextIndex continue } searchStart = parsed.nextIndex } - return oldestTitleIndex === 0 - ? titles - : [...titles.slice(oldestTitleIndex), ...titles.slice(0, oldestTitleIndex)] + return titles } diff --git a/src/shared/pairing.ts b/src/shared/pairing.ts index 4a4b6350fa6..c39595bbef2 100644 --- a/src/shared/pairing.ts +++ b/src/shared/pairing.ts @@ -1,6 +1,4 @@ import { - PAIRING_CODE_MAX_CHARACTERS, - PAIRING_INPUT_MAX_CHARACTERS, PAIRING_OFFER_VERSION, PairingOfferSchema, type PairingOffer @@ -10,24 +8,18 @@ export { PAIRING_OFFER_VERSION, PairingOfferSchema } export type { PairingOffer } export function encodePairingOffer(offer: PairingOffer): string { - const json = JSON.stringify(PairingOfferSchema.parse(offer)) + const json = JSON.stringify(offer) const base64url = Buffer.from(json, 'utf-8') .toString('base64') .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=+$/, '') - if (base64url.length > PAIRING_CODE_MAX_CHARACTERS) { - throw new Error('Pairing offer exceeds safe size') - } // Why: Android camera intents and Expo Router preserve query params more // reliably than URL fragments when launching a custom-scheme app. return `orca://pair?code=${base64url}` } export function decodePairingOffer(url: string): PairingOffer { - if (url.length > PAIRING_INPUT_MAX_CHARACTERS) { - throw new Error('Invalid pairing URL: pairing code exceeds safe size') - } const code = extractPairingCodeFromUrl(url) if (!code) { throw new Error('Invalid pairing URL: must start with orca://pair and include a pairing code') @@ -61,9 +53,6 @@ function extractPairingCodeFromUrl(url: string): string | null { // string so the mobile paste-pair flow can take whichever the user // actually copied from desktop. export function parsePairingCode(input: string): PairingOffer | null { - if (input.length > PAIRING_INPUT_MAX_CHARACTERS) { - return null - } const trimmed = input.trim() if (!trimmed) { return null @@ -79,13 +68,6 @@ export function parsePairingCode(input: string): PairingOffer | null { } function decodePairingBase64(base64url: string): PairingOffer { - if ( - base64url.length === 0 || - base64url.length > PAIRING_CODE_MAX_CHARACTERS || - !/^[A-Za-z0-9+/_-]+={0,2}$/.test(base64url) - ) { - throw new Error('Invalid pairing code') - } const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/') const json = Buffer.from(base64, 'base64').toString('utf-8') return PairingOfferSchema.parse(JSON.parse(json)) diff --git a/src/shared/persisted-state-file-bounds.test.ts b/src/shared/persisted-state-file-bounds.test.ts deleted file mode 100644 index e6ee2de1878..00000000000 --- a/src/shared/persisted-state-file-bounds.test.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { createHash } from 'node:crypto' -import { - mkdtempSync, - readFileSync, - readdirSync, - rmSync, - truncateSync, - writeFileSync -} from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { NodeFileReadTooLargeError } from './node-bounded-file-reader' -import { JsonStringifyByteLimitError } from './node-bounded-json-stringify' -import { - PersistedStateSecretCapacityError, - assertPersistedStateSecretWithinLimit, - readPersistedStateJsonFileSync, - replacePersistedStateJsonWithinLimit, - restorePersistedStateBackupSync, - stringifyPrettyPersistedStateWithinLimit, - stringifyPersistedStateWithinLimit, - updatePersistedStateHashWithJsonRange -} from './persisted-state-file-bounds' - -describe('persisted state file bounds', () => { - let root = '' - - beforeEach(() => { - root = mkdtempSync(join(tmpdir(), 'orca-state-bounds-')) - }) - - afterEach(() => { - rmSync(root, { recursive: true, force: true }) - }) - - it('reads and parses a state file exactly at the byte limit', () => { - const path = join(root, 'state.json') - const json = `{"value":"${'x'.repeat(20)}"}` - writeFileSync(path, json) - - expect( - readPersistedStateJsonFileSync<{ value: string }>(path, Buffer.byteLength(json)) - ).toEqual({ - byteLength: Buffer.byteLength(json), - value: { value: 'x'.repeat(20) } - }) - }) - - it('rejects an oversized sparse state file before reading its body', () => { - const path = join(root, 'state.json') - writeFileSync(path, '') - truncateSync(path, 1025) - - expect(() => readPersistedStateJsonFileSync(path, 1024)).toThrow(NodeFileReadTooLargeError) - }) - - it('rejects structurally amplified state before parsing it', () => { - const path = join(root, 'state.json') - const json = '{"rows":[{},{}]}' - writeFileSync(path, json) - - expect(() => - readPersistedStateJsonFileSync(path, Buffer.byteLength(json), { - structuralTokens: 7, - nestingDepth: 3 - }) - ).toThrow('JSON structure') - }) - - it('matches native compact JSON exactly at the output boundary', () => { - const state = { quote: '"', unicode: '🐋', nested: [1, true, null] } - const native = JSON.stringify(state) - - expect(stringifyPersistedStateWithinLimit(state, Buffer.byteLength(native))).toEqual({ - byteLength: Buffer.byteLength(native), - serialized: native - }) - expect(() => stringifyPersistedStateWithinLimit(state, Buffer.byteLength(native) - 1)).toThrow( - JsonStringifyByteLimitError - ) - }) - - it('matches native pretty JSON and enforces its whitespace-inclusive boundary', () => { - const state = { nested: { value: 'x' }, list: [1, 2] } - const native = JSON.stringify(state, null, 2) - - expect(stringifyPrettyPersistedStateWithinLimit(state, Buffer.byteLength(native))).toEqual({ - byteLength: Buffer.byteLength(native), - serialized: native - }) - expect(() => - stringifyPrettyPersistedStateWithinLimit(state, Buffer.byteLength(native) - 1) - ).toThrow(JsonStringifyByteLimitError) - }) - - it('bounds secret plaintext before encryption can expand it', () => { - assertPersistedStateSecretWithinLimit('🐋', 4) - - expect(() => assertPersistedStateSecretWithinLimit('🐋x', 4)).toThrow( - PersistedStateSecretCapacityError - ) - }) - - it('checks replacement growth before constructing the next payload', () => { - const serialized = '{"value":"slot"}' - const exactBytes = - Buffer.byteLength(serialized) - Buffer.byteLength('slot') + Buffer.byteLength('expanded') - - expect( - replacePersistedStateJsonWithinLimit({ - serialized, - currentBytes: Buffer.byteLength(serialized), - search: 'slot', - replacement: 'expanded', - maxBytes: exactBytes - }) - ).toEqual({ byteLength: exactBytes, serialized: '{"value":"expanded"}' }) - expect(() => - replacePersistedStateJsonWithinLimit({ - serialized, - currentBytes: Buffer.byteLength(serialized), - search: 'slot', - replacement: 'expanded', - maxBytes: exactBytes - 1 - }) - ).toThrow(JsonStringifyByteLimitError) - }) - - it('hashes bounded string ranges without splitting UTF-16 surrogate pairs', () => { - const value = `prefix-${'x'.repeat(8)}🐋-${'y'.repeat(8)}-suffix` - const expected = createHash('sha1').update(value).digest('hex') - const actual = createHash('sha1') - - updatePersistedStateHashWithJsonRange(actual, value, 0, value.length, 2) - - expect(actual.digest('hex')).toBe(expected) - }) - - it('atomically restores only a valid in-limit backup', () => { - const backupPath = join(root, 'backup.json') - const targetPath = join(root, 'profile', 'orca-data.json') - writeFileSync(backupPath, '{"repos":[{"id":"recovered"}]}') - - restorePersistedStateBackupSync(backupPath, targetPath, 1024) - - expect(JSON.parse(readFileSync(targetPath, 'utf8'))).toEqual({ - repos: [{ id: 'recovered' }] - }) - const originalTarget = readFileSync(targetPath) - writeFileSync(backupPath, '{{invalid') - expect(() => restorePersistedStateBackupSync(backupPath, targetPath, 1024)).toThrow() - expect(readFileSync(targetPath)).toEqual(originalTarget) - expect( - readdirSync(join(root, 'profile')).filter((name) => name.endsWith('.recovery.tmp')) - ).toEqual([]) - }) - - it('leaves the target untouched when a backup exceeds the cap', () => { - const backupPath = join(root, 'backup.json') - const targetPath = join(root, 'orca-data.json') - writeFileSync(targetPath, '{"original":true}') - writeFileSync(backupPath, '') - truncateSync(backupPath, 1025) - - expect(() => restorePersistedStateBackupSync(backupPath, targetPath, 1024)).toThrow( - NodeFileReadTooLargeError - ) - expect(readFileSync(targetPath, 'utf8')).toBe('{"original":true}') - }) -}) diff --git a/src/shared/persisted-state-file-bounds.ts b/src/shared/persisted-state-file-bounds.ts deleted file mode 100644 index 4fe8a977bb3..00000000000 --- a/src/shared/persisted-state-file-bounds.ts +++ /dev/null @@ -1,222 +0,0 @@ -import { randomUUID, type Hash } from 'node:crypto' -import { mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs' -import { dirname } from 'node:path' -import { - NodeFileReadTooLargeError, - readNodeFileSyncWithinLimit, - type BoundedNodeFileRead -} from './node-bounded-file-reader' -import { - JsonStringifyByteLimitError, - stringifyJsonWithinByteLimit -} from './node-bounded-json-stringify' -import { - assertJsonTextStructureWithinLimits, - type JsonTextStructureLimits -} from './json-text-structure-limit' - -export const ORCA_PERSISTED_STATE_MAX_BYTES = 64 * 1024 * 1024 -export const ORCA_PERSISTED_STATE_SECRET_MAX_BYTES = 4 * 1024 * 1024 -export const ORCA_PERSISTED_STATE_HASH_CHUNK_CODE_UNITS = 64 * 1024 -export const ORCA_PERSISTED_STATE_JSON_LIMITS: JsonTextStructureLimits = { - structuralTokens: 4_000_000, - nestingDepth: 256 -} - -export type PersistedStateJsonRead = { - byteLength: number - value: T -} - -export class PersistedStateSecretCapacityError extends Error { - constructor( - readonly observedBytes: number, - readonly maxBytes = ORCA_PERSISTED_STATE_SECRET_MAX_BYTES - ) { - super(`Persisted state secret exceeds ${maxBytes} bytes`) - this.name = 'PersistedStateSecretCapacityError' - } -} - -export function isPersistedStateFileCapacityError( - error: unknown -): error is NodeFileReadTooLargeError { - return error instanceof NodeFileReadTooLargeError -} - -export function readPersistedStateJsonFileSync( - filePath: string, - maxBytes = ORCA_PERSISTED_STATE_MAX_BYTES, - structureLimits: JsonTextStructureLimits = ORCA_PERSISTED_STATE_JSON_LIMITS -): PersistedStateJsonRead { - const { buffer } = readPersistedStateFileBytesSync(filePath, maxBytes) - return { - byteLength: buffer.byteLength, - value: parsePersistedStateJsonBuffer(buffer, structureLimits) - } -} - -export function readPersistedStateFileBytesSync( - filePath: string, - maxBytes = ORCA_PERSISTED_STATE_MAX_BYTES -): BoundedNodeFileRead { - return readNodeFileSyncWithinLimit(filePath, maxBytes) -} - -export function parsePersistedStateJsonBuffer( - buffer: Buffer, - structureLimits: JsonTextStructureLimits = ORCA_PERSISTED_STATE_JSON_LIMITS -): T { - const serialized = buffer.toString('utf8') - assertJsonTextStructureWithinLimits(serialized, structureLimits) - return JSON.parse(serialized) as T -} - -export function stringifyPersistedStateWithinLimit( - value: unknown, - maxBytes = ORCA_PERSISTED_STATE_MAX_BYTES -): { byteLength: number; serialized: string } { - return stringifyJsonWithinByteLimit(value, maxBytes) -} - -export function stringifyPrettyPersistedStateWithinLimit( - value: unknown, - maxBytes = ORCA_PERSISTED_STATE_MAX_BYTES -): { byteLength: number; serialized: string } { - return stringifyJsonWithinByteLimit(value, maxBytes, 2) -} - -export function encodePersistedStateJsonStringContent( - value: string, - maxBytes = ORCA_PERSISTED_STATE_MAX_BYTES -): string { - const { serialized } = stringifyJsonWithinByteLimit(value, maxBytes) - return serialized.slice(1, -1) -} - -export function assertPersistedStateSecretWithinLimit( - value: string, - maxBytes = ORCA_PERSISTED_STATE_SECRET_MAX_BYTES -): void { - const observedBytes = Buffer.byteLength(value, 'utf8') - if (observedBytes > maxBytes) { - throw new PersistedStateSecretCapacityError(observedBytes, maxBytes) - } -} - -export function replacedPersistedStateJsonByteLength(options: { - currentBytes: number - maxBytes?: number - replacement: string - search: string -}): number { - const maxBytes = options.maxBytes ?? ORCA_PERSISTED_STATE_MAX_BYTES - if (!Number.isSafeInteger(maxBytes) || maxBytes < 0) { - throw new RangeError('Persisted state JSON byte limit must be a non-negative safe integer') - } - if (!Number.isSafeInteger(options.currentBytes) || options.currentBytes < 0) { - throw new RangeError('Persisted state JSON byte count must be a non-negative safe integer') - } - if (options.currentBytes > maxBytes) { - throw new JsonStringifyByteLimitError(options.currentBytes, maxBytes) - } - const nextBytes = - options.currentBytes - - Buffer.byteLength(options.search, 'utf8') + - Buffer.byteLength(options.replacement, 'utf8') - if (!Number.isSafeInteger(nextBytes) || nextBytes < 0 || nextBytes > maxBytes) { - throw new JsonStringifyByteLimitError(nextBytes, maxBytes) - } - return nextBytes -} - -export function replacePersistedStateJsonWithinLimit(options: { - currentBytes: number - maxBytes?: number - replacement: string - search: string - serialized: string -}): { byteLength: number; serialized: string } { - const byteLength = replacedPersistedStateJsonByteLength(options) - const searchIndex = options.serialized.indexOf(options.search) - if (searchIndex === -1) { - throw new Error('Persisted state JSON replacement slot is missing') - } - if (options.serialized.includes(options.search, searchIndex + options.search.length)) { - throw new Error('Persisted state JSON replacement slot is ambiguous') - } - return { - byteLength, - serialized: options.serialized.replace(options.search, () => options.replacement) - } -} - -export function updatePersistedStateHashWithJsonRange( - hash: Pick, - value: string, - start = 0, - end = value.length, - chunkCodeUnits = ORCA_PERSISTED_STATE_HASH_CHUNK_CODE_UNITS -): void { - if ( - !Number.isSafeInteger(start) || - !Number.isSafeInteger(end) || - start < 0 || - end < start || - end > value.length - ) { - throw new RangeError('Persisted state hash range is invalid') - } - if (!Number.isSafeInteger(chunkCodeUnits) || chunkCodeUnits <= 0) { - throw new RangeError('Persisted state hash chunk size must be a positive safe integer') - } - - let offset = start - while (offset < end) { - let nextOffset = Math.min(end, offset + chunkCodeUnits) - if ( - nextOffset < end && - isHighSurrogate(value.charCodeAt(nextOffset - 1)) && - isLowSurrogate(value.charCodeAt(nextOffset)) - ) { - nextOffset += 1 - } - hash.update(value.slice(offset, nextOffset), 'utf8') - offset = nextOffset - } -} - -export function restorePersistedStateBackupSync( - sourcePath: string, - targetPath: string, - maxBytes = ORCA_PERSISTED_STATE_MAX_BYTES -): number { - const read = readValidatedPersistedStateBytesSync(sourcePath, maxBytes) - mkdirSync(dirname(targetPath), { recursive: true }) - const temporaryPath = `${targetPath}.${process.pid}.${randomUUID()}.recovery.tmp` - try { - writeFileSync(temporaryPath, read.buffer) - renameSync(temporaryPath, targetPath) - } catch (error) { - rmSync(temporaryPath, { force: true }) - throw error - } - return read.buffer.byteLength -} - -function readValidatedPersistedStateBytesSync( - filePath: string, - maxBytes: number -): BoundedNodeFileRead { - const read = readPersistedStateFileBytesSync(filePath, maxBytes) - parsePersistedStateJsonBuffer(read.buffer) - return read -} - -function isHighSurrogate(code: number): boolean { - return code >= 0xd800 && code <= 0xdbff -} - -function isLowSurrogate(code: number): boolean { - return code >= 0xdc00 && code <= 0xdfff -} diff --git a/src/shared/pr-refresh-memory-limits.ts b/src/shared/pr-refresh-memory-limits.ts deleted file mode 100644 index 8b695adede7..00000000000 --- a/src/shared/pr-refresh-memory-limits.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const PR_REFRESH_QUEUE_ENTRY_LIMIT = 1_024 -export const PR_REFRESH_ALIAS_LIMIT = 64 -export const PR_REFRESH_VISIBLE_CANDIDATE_LIMIT = 1_024 -export const PR_REFRESH_RETRY_STATE_LIMIT = 1_024 -export const PR_REFRESH_ACTIVE_SCOPE_LIMIT = 1_024 diff --git a/src/shared/pull-request-generation.test.ts b/src/shared/pull-request-generation.test.ts index f0350e0a0c2..8f47c385df8 100644 --- a/src/shared/pull-request-generation.test.ts +++ b/src/shared/pull-request-generation.test.ts @@ -1,7 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { buildPullRequestFieldsPrompt, - GENERATED_PULL_REQUEST_JSON_STRUCTURE_LIMITS, parseGeneratedPullRequestFields, type PullRequestDraftContext } from './pull-request-generation' @@ -94,17 +93,4 @@ describe('parseGeneratedPullRequestFields', () => { draft: false }) }) - - it('rejects excessive nesting before JSON.parse', () => { - const parseSpy = vi.spyOn(JSON, 'parse') - const depth = GENERATED_PULL_REQUEST_JSON_STRUCTURE_LIMITS.nestingDepth + 1 - try { - expect(() => - parseGeneratedPullRequestFields(`${'['.repeat(depth)}0${']'.repeat(depth)}`, context) - ).toThrow(/JSON nesting exceeds/) - expect(parseSpy).not.toHaveBeenCalled() - } finally { - parseSpy.mockRestore() - } - }) }) diff --git a/src/shared/pull-request-generation.ts b/src/shared/pull-request-generation.ts index fd927b82ae7..c67a463fcc4 100644 --- a/src/shared/pull-request-generation.ts +++ b/src/shared/pull-request-generation.ts @@ -1,10 +1,4 @@ import { truncateDiffForPrompt } from './commit-message-prompt' -import { assertJsonTextStructureWithinLimits } from './json-text-structure-limit' - -export const GENERATED_PULL_REQUEST_JSON_STRUCTURE_LIMITS = { - structuralTokens: 64, - nestingDepth: 8 -} as const export type PullRequestDraftContext = { branch: string | null @@ -158,9 +152,7 @@ export function parseGeneratedPullRequestFields( raw: string, fallback: Pick ): GeneratedPullRequestFields { - const content = stripJsonFence(raw) - assertJsonTextStructureWithinLimits(content, GENERATED_PULL_REQUEST_JSON_STRUCTURE_LIMITS) - const parsed = JSON.parse(content) as unknown + const parsed = JSON.parse(stripJsonFence(raw)) as unknown if (!parsed || typeof parsed !== 'object') { throw new Error('Expected a JSON object.') } diff --git a/src/shared/quick-open-directory-reader.test.ts b/src/shared/quick-open-directory-reader.test.ts deleted file mode 100644 index fcd11985523..00000000000 --- a/src/shared/quick-open-directory-reader.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { lstatMock, opendirMock } = vi.hoisted(() => ({ - lstatMock: vi.fn(), - opendirMock: vi.fn() -})) - -vi.mock('node:fs/promises', () => ({ - lstat: lstatMock, - opendir: opendirMock -})) - -import { readQuickOpenDirectoryEntries } from './quick-open-directory-reader' -import { createQuickOpenReaddirBudget } from './quick-open-readdir-budget' - -beforeEach(() => { - vi.clearAllMocks() - lstatMock.mockResolvedValue({ - isDirectory: () => true, - isSymbolicLink: () => false - }) -}) - -describe('quick-open streaming directory reader', () => { - it('stops a huge directory one entry beyond the exact cap and closes its iterator', async () => { - let produced = 0 - let closed = false - opendirMock.mockResolvedValue({ - async *[Symbol.asyncIterator]() { - try { - while (produced < 1_000_000) { - produced += 1 - yield { - name: `directory-${produced}`, - isDirectory: () => true, - isFile: () => false, - isSymbolicLink: () => false - } - } - } finally { - closed = true - } - } - }) - - await expect( - readQuickOpenDirectoryEntries({ - absPath: '/streamed', - allowSymlinkedRoot: false, - budget: createQuickOpenReaddirBudget({ maxEntries: 3 }) - }) - ).rejects.toThrow('File listing exceeded 3 entries') - expect(produced).toBe(4) - expect(closed).toBe(true) - }) -}) diff --git a/src/shared/quick-open-directory-reader.ts b/src/shared/quick-open-directory-reader.ts deleted file mode 100644 index cc53d8d6438..00000000000 --- a/src/shared/quick-open-directory-reader.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { lstat, opendir } from 'node:fs/promises' -import { isFileListingCancellation, throwIfFileListingCancelled } from './file-listing-cancellation' -import { isQuickOpenReadableDirectory } from './quick-open-directory-validation' -import { - assertQuickOpenReaddirDeadline, - consumeQuickOpenReaddirEntryBudget, - consumeQuickOpenReaddirPathBudget, - isQuickOpenReaddirBudgetError, - type QuickOpenReaddirBudget -} from './quick-open-readdir-budget' - -export type QuickOpenDirectoryEntry = { - name: string - kind: 'directory' | 'file' | 'symlink' | 'other' -} - -export async function readQuickOpenDirectoryEntries(opts: { - absPath: string - allowSymlinkedRoot: boolean - budget: QuickOpenReaddirBudget - signal?: AbortSignal -}): Promise { - try { - const stat = await lstat(opts.absPath) - if (!isQuickOpenReadableDirectory(stat, opts.allowSymlinkedRoot)) { - return [] - } - - const entries: QuickOpenDirectoryEntry[] = [] - const directory = await opendir(opts.absPath) - throwIfFileListingCancelled(opts.signal) - assertQuickOpenReaddirDeadline(opts.budget) - for await (const entry of directory) { - throwIfFileListingCancelled(opts.signal) - assertQuickOpenReaddirDeadline(opts.budget) - consumeQuickOpenReaddirEntryBudget(opts.budget) - consumeQuickOpenReaddirPathBudget(opts.budget, entry.name) - entries.push({ - name: entry.name, - kind: entry.isDirectory() - ? 'directory' - : entry.isFile() - ? 'file' - : entry.isSymbolicLink() - ? 'symlink' - : 'other' - }) - } - entries.sort((left, right) => (left.name < right.name ? -1 : left.name > right.name ? 1 : 0)) - - // Why: discard buffered names if the path became a symlink while its - // directory handle was open; descendants must never escape the root. - const statAfterRead = await lstat(opts.absPath) - return isQuickOpenReadableDirectory(statAfterRead, opts.allowSymlinkedRoot) ? entries : [] - } catch (error) { - if (isQuickOpenReaddirBudgetError(error) || isFileListingCancellation(error)) { - throw error - } - // Permission denied or a vanished subtree must not hide readable siblings. - return [] - } -} diff --git a/src/shared/quick-open-git-entry-classification.ts b/src/shared/quick-open-git-entry-classification.ts deleted file mode 100644 index 971a4106dce..00000000000 --- a/src/shared/quick-open-git-entry-classification.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { lstat } from 'node:fs/promises' -import { join } from 'node:path' - -export type QuickOpenGitEntryKind = 'keep' | 'fill-nested-repo' | 'drop-placeholder' - -export type QuickOpenGitLsFilesEntry = { - path: string - isGitlink: boolean - isUntrackedDir: boolean -} - -const GIT_LS_FILES_STAGE_ENTRY = /^([0-7]{6}) [0-9a-f]{40,64} [0-3]\t/ - -export function parseQuickOpenGitLsFilesEntry(entry: string): QuickOpenGitLsFilesEntry { - const match = GIT_LS_FILES_STAGE_ENTRY.exec(entry) - if (match) { - return { - path: entry.slice(match[0].length), - isGitlink: match[1] === '160000', - isUntrackedDir: false - } - } - return { - path: entry, - isGitlink: false, - isUntrackedDir: entry.endsWith('/') - } -} - -function joinQuickOpenRootPath(rootPath: string, relPath: string): string { - return join(rootPath, ...relPath.split('/').filter(Boolean)) -} - -async function hasGitEntry(absPath: string): Promise { - try { - const stat = await lstat(join(absPath, '.git')) - return stat.isDirectory() || stat.isFile() - } catch { - return false - } -} - -export async function classifyQuickOpenGitEntry( - rootPath: string, - entry: string -): Promise<{ kind: QuickOpenGitEntryKind; relPath: string }> { - const parsed = parseQuickOpenGitLsFilesEntry(entry) - const relPath = parsed.path.replace(/\/+$/, '') - if (!relPath) { - return { kind: 'drop-placeholder', relPath } - } - if (!parsed.isGitlink && !parsed.isUntrackedDir) { - return { kind: 'keep', relPath } - } - - let stat - try { - stat = await lstat(joinQuickOpenRootPath(rootPath, relPath)) - } catch { - return { kind: 'drop-placeholder', relPath } - } - if (!stat.isDirectory()) { - return { kind: 'drop-placeholder', relPath } - } - return (await hasGitEntry(joinQuickOpenRootPath(rootPath, relPath))) - ? { kind: 'fill-nested-repo', relPath } - : { kind: 'drop-placeholder', relPath } -} diff --git a/src/shared/quick-open-install-rg.ts b/src/shared/quick-open-install-rg.ts index 7815d7d6e34..5ba70bc8f0c 100644 --- a/src/shared/quick-open-install-rg.ts +++ b/src/shared/quick-open-install-rg.ts @@ -1,10 +1,9 @@ -import { readNodeFileWithinLimit } from './node-bounded-file-reader' +import { readFile } from 'node:fs/promises' import { getProcessOutputFields, iterateProcessOutputLines } from './process-output-field-scanner' const GENERIC_LINUX_RIPGREP_INSTALL = 'install ripgrep via your package manager (e.g. apt/dnf/pacman)' const OS_RELEASE_ID_LIKE_MAX_FIELDS = 16 -const MAX_OS_RELEASE_BYTES = 64 * 1024 export async function detectInstallCommand(): Promise { if (process.platform === 'darwin') { @@ -12,9 +11,7 @@ export async function detectInstallCommand(): Promise { } if (process.platform === 'linux') { try { - const osRelease = ( - await readNodeFileWithinLimit('/etc/os-release', MAX_OS_RELEASE_BYTES) - ).buffer.toString('utf8') + const osRelease = await readFile('/etc/os-release', 'utf-8') return detectLinuxInstallCommandFromOsRelease(osRelease) } catch { /* fall through to generic guidance */ diff --git a/src/shared/quick-open-listing-limits.test.ts b/src/shared/quick-open-listing-limits.test.ts deleted file mode 100644 index bd24c80da6c..00000000000 --- a/src/shared/quick-open-listing-limits.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { - createQuickOpenListingBudget, - QuickOpenSubprocessPathAccumulator, - resolveQuickOpenResultLimit, - retainQuickOpenPath, - QUICK_OPEN_LISTING_MAX_PATH_BYTES, - QUICK_OPEN_LISTING_MAX_RETAINED_PATH_BYTES, - QUICK_OPEN_LISTING_MAX_RETAINED_PATHS, - QUICK_OPEN_LISTING_MAX_RESULTS -} from './quick-open-listing-limits' - -describe('Quick Open listing limits', () => { - it('uses one hard result cap while preserving smaller requested limits', () => { - expect(resolveQuickOpenResultLimit()).toBe(QUICK_OPEN_LISTING_MAX_RESULTS) - expect(resolveQuickOpenResultLimit(17)).toBe(17) - expect(resolveQuickOpenResultLimit(QUICK_OPEN_LISTING_MAX_RESULTS + 1)).toBe( - QUICK_OPEN_LISTING_MAX_RESULTS - ) - expect(resolveQuickOpenResultLimit(0)).toBe(0) - }) - - it('keeps the production memory ceilings explicit', () => { - expect(QUICK_OPEN_LISTING_MAX_RESULTS).toBe(20_001) - expect(QUICK_OPEN_LISTING_MAX_RETAINED_PATHS).toBe(100_000) - expect(QUICK_OPEN_LISTING_MAX_RETAINED_PATH_BYTES).toBe(32 * 1024 * 1024) - expect(QUICK_OPEN_LISTING_MAX_PATH_BYTES).toBe(64 * 1024) - }) - - it('accepts the exact retained path boundaries without charging duplicates', () => { - const paths = new Set() - const budget = createQuickOpenListingBudget({ - maxRetainedPaths: 2, - maxRetainedPathBytes: 3, - maxPathBytes: 2 - }) - - expect(retainQuickOpenPath(paths, 'ab', budget)).toBe(true) - expect(retainQuickOpenPath(paths, 'ab', budget)).toBe(false) - expect(retainQuickOpenPath(paths, 'c', budget)).toBe(true) - expect(budget).toMatchObject({ retainedPathCount: 2, retainedPathBytes: 3 }) - expect(() => retainQuickOpenPath(paths, '', budget)).toThrow('2 retained paths') - }) - - it('rejects path-byte overflow without mutating the retained budget', () => { - const paths = new Set() - const budget = createQuickOpenListingBudget({ - maxRetainedPaths: 3, - maxRetainedPathBytes: 2, - maxPathBytes: 2 - }) - retainQuickOpenPath(paths, 'ab', budget) - - expect(() => retainQuickOpenPath(paths, 'c', budget)).toThrow('2 retained path bytes') - expect(paths).toEqual(new Set(['ab'])) - expect(budget).toMatchObject({ retainedPathCount: 1, retainedPathBytes: 2 }) - }) - - it('bounds one fragmented subprocess path and recovers after overflow', () => { - const onPath = vi.fn(() => true) - const fields = new QuickOpenSubprocessPathAccumulator(0, 3) - - expect(fields.push(Buffer.from('ab'), onPath)).toBe('continue') - expect(fields.push(Buffer.from('cd'), onPath)).toBe('path-too-large') - expect(fields.push(Buffer.from('ok\0'), onPath)).toBe('continue') - expect(onPath).toHaveBeenCalledTimes(1) - expect(onPath).toHaveBeenCalledWith('ok') - }) - - it('stops within a multi-path chunk without visiting later fields', () => { - const visited: string[] = [] - const fields = new QuickOpenSubprocessPathAccumulator(0, 16) - - expect( - fields.push(Buffer.from('one\0two\0three\0'), (path) => { - visited.push(path) - return path !== 'two' - }) - ).toBe('stopped') - expect(visited).toEqual(['one', 'two']) - }) -}) diff --git a/src/shared/quick-open-listing-limits.ts b/src/shared/quick-open-listing-limits.ts deleted file mode 100644 index 0e28f8b1d3a..00000000000 --- a/src/shared/quick-open-listing-limits.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { GrowingByteBuffer } from './growing-byte-buffer' - -export const QUICK_OPEN_LISTING_MAX_RESULTS = 20_001 -export const QUICK_OPEN_LISTING_MAX_RETAINED_PATHS = 100_000 -export const QUICK_OPEN_LISTING_MAX_RETAINED_PATH_BYTES = 32 * 1024 * 1024 -export const QUICK_OPEN_LISTING_MAX_PATH_BYTES = 64 * 1024 - -export type QuickOpenListingBudget = { - retainedPathCount: number - retainedPathBytes: number - maxRetainedPaths: number - maxRetainedPathBytes: number - maxPathBytes: number -} - -export function resolveQuickOpenResultLimit(requested?: number): number { - if (requested === undefined || requested === Number.POSITIVE_INFINITY) { - return QUICK_OPEN_LISTING_MAX_RESULTS - } - if (!Number.isFinite(requested)) { - return 0 - } - return Math.min(Math.max(Math.trunc(requested), 0), QUICK_OPEN_LISTING_MAX_RESULTS) -} - -export function createQuickOpenListingBudget( - limits: Partial< - Pick - > = {} -): QuickOpenListingBudget { - const maxRetainedPaths = limits.maxRetainedPaths ?? QUICK_OPEN_LISTING_MAX_RETAINED_PATHS - const maxRetainedPathBytes = - limits.maxRetainedPathBytes ?? QUICK_OPEN_LISTING_MAX_RETAINED_PATH_BYTES - const maxPathBytes = limits.maxPathBytes ?? QUICK_OPEN_LISTING_MAX_PATH_BYTES - for (const [name, value] of Object.entries({ - maxRetainedPaths, - maxRetainedPathBytes, - maxPathBytes - })) { - if (!Number.isSafeInteger(value) || value < 0) { - throw new RangeError(`${name} must be a non-negative safe integer`) - } - } - return { - retainedPathCount: 0, - retainedPathBytes: 0, - maxRetainedPaths, - maxRetainedPathBytes, - maxPathBytes - } -} - -export function retainQuickOpenPath( - paths: Set, - path: string, - budget: QuickOpenListingBudget -): boolean { - if (paths.has(path)) { - return false - } - const pathBytes = Buffer.byteLength(path, 'utf8') - if (pathBytes > budget.maxPathBytes) { - throw new Error(`Quick Open file path exceeded ${budget.maxPathBytes} bytes`) - } - if (budget.retainedPathCount >= budget.maxRetainedPaths) { - throw new Error(`Quick Open file listing exceeded ${budget.maxRetainedPaths} retained paths`) - } - if (pathBytes > budget.maxRetainedPathBytes - budget.retainedPathBytes) { - throw new Error( - `Quick Open file listing exceeded ${budget.maxRetainedPathBytes} retained path bytes` - ) - } - budget.retainedPathCount++ - budget.retainedPathBytes += pathBytes - paths.add(path) - return true -} - -export type QuickOpenPathAccumulatorResult = 'continue' | 'stopped' | 'path-too-large' - -export class QuickOpenSubprocessPathAccumulator { - private readonly field = new GrowingByteBuffer() - - constructor( - private readonly delimiter: number, - private readonly maxPathBytes = QUICK_OPEN_LISTING_MAX_PATH_BYTES - ) { - if (!Number.isInteger(delimiter) || delimiter < 0 || delimiter > 0xff) { - throw new RangeError('Quick Open path delimiter must be one byte') - } - if (!Number.isSafeInteger(maxPathBytes) || maxPathBytes < 0) { - throw new RangeError('Quick Open path limit must be a non-negative safe integer') - } - } - - push( - rawChunk: Buffer | string, - onPath: (path: string) => boolean - ): QuickOpenPathAccumulatorResult { - const chunk = Buffer.isBuffer(rawChunk) ? rawChunk : Buffer.from(rawChunk, 'utf8') - let cursor = 0 - while (cursor < chunk.length) { - const delimiter = chunk.indexOf(this.delimiter, cursor) - const end = delimiter === -1 ? chunk.length : delimiter - const segmentBytes = end - cursor - if (this.field.byteLength + segmentBytes > this.maxPathBytes) { - this.clear() - return 'path-too-large' - } - if (delimiter !== -1 && this.field.byteLength === 0) { - if (!onPath(chunk.toString('utf8', cursor, end))) { - return 'stopped' - } - } else if (segmentBytes > 0) { - // Why: copying prevents a short residual path from retaining the whole read buffer. - this.field.append(chunk.subarray(cursor, end)) - if (delimiter !== -1 && !onPath(this.take())) { - return 'stopped' - } - } else if (delimiter !== -1 && !onPath(this.take())) { - return 'stopped' - } - if (delimiter === -1) { - return 'continue' - } - cursor = delimiter + 1 - } - return 'continue' - } - - finish(): string | null { - return this.field.byteLength > 0 ? this.take() : null - } - - clear(): void { - this.field.clear() - } - - private take(): string { - return this.field.takeString() - } -} diff --git a/src/shared/quick-open-readdir-budget.ts b/src/shared/quick-open-readdir-budget.ts index 991e02694f1..14fd599cf64 100644 --- a/src/shared/quick-open-readdir-budget.ts +++ b/src/shared/quick-open-readdir-budget.ts @@ -1,60 +1,16 @@ export const QUICK_OPEN_READDIR_MAX_FILES = 10_000 -export const QUICK_OPEN_READDIR_MAX_ENTRIES = 50_000 -export const QUICK_OPEN_READDIR_MAX_DIRECTORIES = 25_000 -export const QUICK_OPEN_READDIR_MAX_DEPTH = 256 -export const QUICK_OPEN_READDIR_MAX_PATH_CODE_UNITS = 16 * 1024 * 1024 export const QUICK_OPEN_READDIR_TIMEOUT_MS = 10_000 export type QuickOpenReaddirBudget = { remainingFiles: number - remainingEntries: number - remainingDirectories: number - remainingPathCodeUnits: number - maxFiles: number - maxEntries: number - maxDirectories: number - maxDepth: number - maxPathCodeUnits: number deadlineMs: number } export function createQuickOpenReaddirBudget( - opts: { - maxFiles?: number - maxEntries?: number - maxDirectories?: number - maxDepth?: number - maxPathCodeUnits?: number - timeoutMs?: number - nowMs?: number - } = {} + opts: { maxFiles?: number; timeoutMs?: number; nowMs?: number } = {} ): QuickOpenReaddirBudget { - const maxFiles = opts.maxFiles ?? QUICK_OPEN_READDIR_MAX_FILES - const maxEntries = opts.maxEntries ?? QUICK_OPEN_READDIR_MAX_ENTRIES - const maxDirectories = opts.maxDirectories ?? QUICK_OPEN_READDIR_MAX_DIRECTORIES - const maxDepth = opts.maxDepth ?? QUICK_OPEN_READDIR_MAX_DEPTH - const maxPathCodeUnits = opts.maxPathCodeUnits ?? QUICK_OPEN_READDIR_MAX_PATH_CODE_UNITS - for (const [name, value] of Object.entries({ - maxFiles, - maxEntries, - maxDirectories, - maxDepth, - maxPathCodeUnits - })) { - if (!Number.isSafeInteger(value) || value < 0) { - throw new RangeError(`${name} must be a non-negative safe integer`) - } - } return { - remainingFiles: maxFiles, - remainingEntries: maxEntries, - remainingDirectories: maxDirectories, - remainingPathCodeUnits: maxPathCodeUnits, - maxFiles, - maxEntries, - maxDirectories, - maxDepth, - maxPathCodeUnits, + remainingFiles: opts.maxFiles ?? QUICK_OPEN_READDIR_MAX_FILES, deadlineMs: (opts.nowMs ?? Date.now()) + (opts.timeoutMs ?? QUICK_OPEN_READDIR_TIMEOUT_MS) } } @@ -76,37 +32,7 @@ export function assertQuickOpenReaddirDeadline(budget: QuickOpenReaddirBudget): export function consumeQuickOpenReaddirFileBudget(budget: QuickOpenReaddirBudget): void { if (budget.remainingFiles <= 0) { - throw new Error(`${FILE_LISTING_EXCEEDED_PREFIX} ${budget.maxFiles} files`) + throw new Error(`${FILE_LISTING_EXCEEDED_PREFIX} ${QUICK_OPEN_READDIR_MAX_FILES} files`) } budget.remainingFiles-- } - -export function consumeQuickOpenReaddirEntryBudget(budget: QuickOpenReaddirBudget): void { - if (budget.remainingEntries <= 0) { - throw new Error(`${FILE_LISTING_EXCEEDED_PREFIX} ${budget.maxEntries} entries`) - } - budget.remainingEntries-- -} - -export function consumeQuickOpenReaddirDirectoryBudget(budget: QuickOpenReaddirBudget): void { - if (budget.remainingDirectories <= 0) { - throw new Error(`${FILE_LISTING_EXCEEDED_PREFIX} ${budget.maxDirectories} directories`) - } - budget.remainingDirectories-- -} - -export function assertQuickOpenReaddirDepth(budget: QuickOpenReaddirBudget, depth: number): void { - if (depth > budget.maxDepth) { - throw new Error(`${FILE_LISTING_EXCEEDED_PREFIX} depth ${budget.maxDepth}`) - } -} - -export function consumeQuickOpenReaddirPathBudget( - budget: QuickOpenReaddirBudget, - path: string -): void { - if (path.length > budget.remainingPathCodeUnits) { - throw new Error(`${FILE_LISTING_EXCEEDED_PREFIX} ${budget.maxPathCodeUnits} path code units`) - } - budget.remainingPathCodeUnits -= path.length -} diff --git a/src/shared/quick-open-readdir-memory.test.ts b/src/shared/quick-open-readdir-memory.test.ts deleted file mode 100644 index 4929f33dc96..00000000000 --- a/src/shared/quick-open-readdir-memory.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import { - createQuickOpenReaddirBudget, - listQuickOpenFilesWithReaddir -} from './quick-open-readdir-walk' - -const tempRoots: string[] = [] - -async function makeRoot(): Promise { - const root = await mkdtemp(join(tmpdir(), 'orca-quick-open-budget-')) - tempRoots.push(root) - return root -} - -afterEach(async () => { - await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) -}) - -describe('quick-open readdir memory limits', () => { - it('accepts the exact entry limit and rejects the next zero-file entry', async () => { - const root = await makeRoot() - await mkdir(join(root, 'a')) - await mkdir(join(root, 'b')) - - await expect( - listQuickOpenFilesWithReaddir(root, { - budget: createQuickOpenReaddirBudget({ maxEntries: 2 }) - }) - ).resolves.toEqual([]) - - await mkdir(join(root, 'c')) - await expect( - listQuickOpenFilesWithReaddir(root, { - budget: createQuickOpenReaddirBudget({ maxEntries: 2 }) - }) - ).rejects.toThrow('File listing exceeded 2 entries') - }) - - it('caps retained directory paths even when the tree contains no files', async () => { - const root = await makeRoot() - await mkdir(join(root, 'a')) - await mkdir(join(root, 'b')) - - await expect( - listQuickOpenFilesWithReaddir(root, { - budget: createQuickOpenReaddirBudget({ maxDirectories: 3 }) - }) - ).resolves.toEqual([]) - await expect( - listQuickOpenFilesWithReaddir(root, { - budget: createQuickOpenReaddirBudget({ maxDirectories: 2 }) - }) - ).rejects.toThrow('File listing exceeded 2 directories') - }) - - it('accepts the exact depth limit and rejects a deeper directory', async () => { - const root = await makeRoot() - await mkdir(join(root, 'a', 'b'), { recursive: true }) - - await expect( - listQuickOpenFilesWithReaddir(root, { - budget: createQuickOpenReaddirBudget({ maxDepth: 2 }) - }) - ).resolves.toEqual([]) - await expect( - listQuickOpenFilesWithReaddir(root, { - budget: createQuickOpenReaddirBudget({ maxDepth: 1 }) - }) - ).rejects.toThrow('File listing exceeded depth 1') - }) - - it('bounds aggregate path storage without changing exact-boundary output', async () => { - const root = await makeRoot() - const fileName = 'a.ts' - await writeFile(join(root, fileName), 'x') - const exactPathCodeUnits = root.length + fileName.length * 2 - - await expect( - listQuickOpenFilesWithReaddir(root, { - budget: createQuickOpenReaddirBudget({ maxPathCodeUnits: exactPathCodeUnits }) - }) - ).resolves.toEqual([fileName]) - await expect( - listQuickOpenFilesWithReaddir(root, { - budget: createQuickOpenReaddirBudget({ maxPathCodeUnits: exactPathCodeUnits - 1 }) - }) - ).rejects.toThrow(`File listing exceeded ${exactPathCodeUnits - 1} path code units`) - }) -}) diff --git a/src/shared/quick-open-readdir-walk.test.ts b/src/shared/quick-open-readdir-walk.test.ts index 1c3ae1adf34..e6894bd815c 100644 --- a/src/shared/quick-open-readdir-walk.test.ts +++ b/src/shared/quick-open-readdir-walk.test.ts @@ -1,18 +1,18 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -const { lstatMock, opendirMock } = vi.hoisted(() => ({ +const { lstatMock, readdirMock } = vi.hoisted(() => ({ lstatMock: vi.fn(), - opendirMock: vi.fn() + readdirMock: vi.fn() })) vi.mock('fs/promises', async () => { const actual = await vi.importActual('fs/promises') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() lstatMock.mockImplementation(actual.lstat) - opendirMock.mockImplementation(actual.opendir) + readdirMock.mockImplementation(actual.readdir) return { ...actual, lstat: lstatMock, - opendir: opendirMock + readdir: readdirMock } }) @@ -238,7 +238,7 @@ describe('quick-open readdir walk', () => { }) ).resolves.toEqual(['.local/config.toml', 'dist/generated.js']) - const walkedPaths = opendirMock.mock.calls.map(([path]) => path) + const walkedPaths = readdirMock.mock.calls.map(([path]) => path) expect(walkedPaths).toContain(join(root, 'dist')) expect(walkedPaths).toContain(join(root, '.local')) expect(walkedPaths).not.toContain(join(root, '.local', 'share')) @@ -256,12 +256,12 @@ describe('quick-open readdir walk', () => { const actual = await vi.importActual('node:fs/promises') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() let activeReads = 0 let maxActiveReads = 0 - opendirMock.mockImplementation(async (...args: Parameters) => { + readdirMock.mockImplementation(async (...args: Parameters) => { activeReads++ maxActiveReads = Math.max(maxActiveReads, activeReads) await new Promise((resolve) => setTimeout(resolve, 5)) try { - return await actual.opendir(...args) + return await actual.readdir(...args) } finally { activeReads-- } @@ -277,7 +277,7 @@ describe('quick-open readdir walk', () => { expect(maxActiveReads).toBeGreaterThan(1) expect(maxActiveReads).toBeLessThanOrEqual(32) } finally { - opendirMock.mockImplementation(actual.opendir) + readdirMock.mockImplementation(actual.readdir) } }) @@ -328,7 +328,7 @@ describe('quick-open readdir walk', () => { ).resolves.toEqual([]) }) - it('discards entries when a collapsed directory changes during opendir', async () => { + it('discards entries when a collapsed directory changes during readdir', async () => { const root = await makeTempRoot() const outsideRoot = await makeTempRoot() await mkdirRel(root, 'dist') @@ -336,13 +336,13 @@ describe('quick-open readdir walk', () => { const actual = await vi.importActual('node:fs/promises') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() const distPath = join(root, 'dist') let swapped = false - opendirMock.mockImplementation(async (...args: Parameters) => { + readdirMock.mockImplementation(async (...args: Parameters) => { if (!swapped && args[0] === distPath) { swapped = true await rename(distPath, join(root, 'old-dist')) await symlink(outsideRoot, distPath, 'dir') } - return actual.opendir(...args) + return actual.readdir(...args) }) try { @@ -354,7 +354,7 @@ describe('quick-open readdir walk', () => { }) ).resolves.toEqual([]) } finally { - opendirMock.mockImplementation(actual.opendir) + readdirMock.mockImplementation(actual.readdir) } }) @@ -373,7 +373,7 @@ describe('quick-open readdir walk', () => { ).resolves.toEqual(['foo/a.ts', 'foo/bar/b.ts']) expect( - opendirMock.mock.calls.filter(([path]) => path === join(root, 'foo', 'bar')) + readdirMock.mock.calls.filter(([path]) => path === join(root, 'foo', 'bar')) ).toHaveLength(1) }) @@ -395,18 +395,14 @@ describe('quick-open readdir walk', () => { it('keeps the default safety cap for a very large collapsed directory', async () => { const root = await makeTempRoot() await mkdirRel(root, 'dist') - opendirMock.mockResolvedValueOnce({ - async *[Symbol.asyncIterator]() { - for (let index = 0; index <= QUICK_OPEN_READDIR_MAX_FILES; index += 1) { - yield { - name: `file-${index}.ts`, - isDirectory: () => false, - isFile: () => true, - isSymbolicLink: () => false - } - } - } - }) + readdirMock.mockResolvedValueOnce( + Array.from({ length: QUICK_OPEN_READDIR_MAX_FILES + 1 }, (_, index) => ({ + name: `file-${index}.ts`, + isDirectory: () => false, + isFile: () => true, + isSymbolicLink: () => false + })) + ) // Why: directory collapse prevents generated trees from flooding the relay; // the Git fallback must reject rather than silently return a partial list. @@ -433,7 +429,7 @@ describe('quick-open readdir walk', () => { await expect( listQuickOpenFilesWithReaddir(root, { - budget: createQuickOpenReaddirBudget({ nowMs: Date.now() - 2_000, timeoutMs: 1_000 }) + budget: { remainingFiles: 10, deadlineMs: Date.now() - 1_000 } }) ).rejects.toThrow('File listing timed out') }) @@ -533,12 +529,12 @@ describe('quick-open readdir walk', () => { ).rejects.toSatisfy(isFileListingCancellation) }) - it('rejects when cancellation lands during an empty opendir batch', async () => { + it('rejects when cancellation lands during an empty readdir batch', async () => { const root = await makeTempRoot() const controller = new AbortController() const actual = await vi.importActual('node:fs/promises') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() - opendirMock.mockImplementationOnce(async (...args: Parameters) => { - const entries = await actual.opendir(...args) + readdirMock.mockImplementationOnce(async (...args: Parameters) => { + const entries = await actual.readdir(...args) controller.abort() return entries }) diff --git a/src/shared/quick-open-readdir-walk.ts b/src/shared/quick-open-readdir-walk.ts index 2fcdbb84377..fbbb24963cc 100644 --- a/src/shared/quick-open-readdir-walk.ts +++ b/src/shared/quick-open-readdir-walk.ts @@ -1,44 +1,55 @@ +import { lstat, readdir } from 'node:fs/promises' import { join, relative } from 'node:path' import { throwIfFileListingCancelled } from './file-listing-cancellation' -import { readQuickOpenDirectoryEntries } from './quick-open-directory-reader' +import { isQuickOpenReadableDirectory } from './quick-open-directory-validation' import { collapseQuickOpenExpansionPaths } from './quick-open-expansion-paths' -import { classifyQuickOpenGitEntry } from './quick-open-git-entry-classification' import { HIDDEN_DIR_BLOCKLIST, shouldExcludeQuickOpenRelPath, shouldIncludeQuickOpenPath } from './quick-open-filter' import { - assertQuickOpenReaddirDepth, assertQuickOpenReaddirDeadline, - consumeQuickOpenReaddirDirectoryBudget, - consumeQuickOpenReaddirEntryBudget, consumeQuickOpenReaddirFileBudget, - consumeQuickOpenReaddirPathBudget, createQuickOpenReaddirBudget, type QuickOpenReaddirBudget } from './quick-open-readdir-budget' -export { - classifyQuickOpenGitEntry, - parseQuickOpenGitLsFilesEntry, - type QuickOpenGitEntryKind, - type QuickOpenGitLsFilesEntry -} from './quick-open-git-entry-classification' - export { createQuickOpenReaddirBudget, isQuickOpenReaddirBudgetError, - QUICK_OPEN_READDIR_MAX_DEPTH, - QUICK_OPEN_READDIR_MAX_DIRECTORIES, - QUICK_OPEN_READDIR_MAX_ENTRIES, QUICK_OPEN_READDIR_MAX_FILES, - QUICK_OPEN_READDIR_MAX_PATH_CODE_UNITS, QUICK_OPEN_READDIR_TIMEOUT_MS } from './quick-open-readdir-budget' const QUICK_OPEN_READDIR_CONCURRENCY = 32 +export type QuickOpenGitEntryKind = 'keep' | 'fill-nested-repo' | 'drop-placeholder' + +export type QuickOpenGitLsFilesEntry = { + path: string + isGitlink: boolean + isUntrackedDir: boolean +} + +const GIT_LS_FILES_STAGE_ENTRY = /^([0-7]{6}) [0-9a-f]{40,64} [0-3]\t/ + +export function parseQuickOpenGitLsFilesEntry(entry: string): QuickOpenGitLsFilesEntry { + const match = GIT_LS_FILES_STAGE_ENTRY.exec(entry) + if (match) { + return { + path: entry.slice(match[0].length), + isGitlink: match[1] === '160000', + isUntrackedDir: false + } + } + return { + path: entry, + isGitlink: false, + isUntrackedDir: entry.endsWith('/') + } +} + function shouldDescend(name: string): boolean { return name !== 'node_modules' && !HIDDEN_DIR_BLOCKLIST.has(name) } @@ -74,6 +85,47 @@ function rebaseExcludePrefixesForSubtree( return rebased } +async function hasGitEntry(absPath: string): Promise { + try { + const stat = await lstat(join(absPath, '.git')) + return stat.isDirectory() || stat.isFile() + } catch { + return false + } +} + +export async function classifyQuickOpenGitEntry( + rootPath: string, + entry: string +): Promise<{ kind: QuickOpenGitEntryKind; relPath: string }> { + const parsed = parseQuickOpenGitLsFilesEntry(entry) + const relPath = normalizeGitEntry(parsed.path) + if (!relPath) { + return { kind: 'drop-placeholder', relPath } + } + + if (!parsed.isGitlink && !parsed.isUntrackedDir) { + return { kind: 'keep', relPath } + } + + let stat + try { + stat = await lstat(joinRootRel(rootPath, relPath)) + } catch { + return { kind: 'drop-placeholder', relPath } + } + + if (!stat.isDirectory()) { + return { kind: 'drop-placeholder', relPath } + } + + if (await hasGitEntry(joinRootRel(rootPath, relPath))) { + return { kind: 'fill-nested-repo', relPath } + } + + return { kind: 'drop-placeholder', relPath } +} + export async function listQuickOpenFilesWithReaddir( rootPath: string, opts: { @@ -112,25 +164,17 @@ async function listQuickOpenFilesFromRoots( roots: readonly QuickOpenReaddirRoot[], budget: QuickOpenReaddirBudget, signal?: AbortSignal, - maxResults?: number, - knownFiles?: ReadonlySet + maxResults?: number ): Promise { const files: string[] = [] if (maxResults !== undefined && maxResults <= 0) { return files } - let pendingDirectories: { - root: QuickOpenReaddirRoot - absPath: string - depth: number - isRoot: boolean - }[] = [] - for (const root of roots) { - assertQuickOpenReaddirDepth(budget, 0) - consumeQuickOpenReaddirDirectoryBudget(budget) - consumeQuickOpenReaddirPathBudget(budget, root.rootPath) - pendingDirectories.push({ root, absPath: root.rootPath, depth: 0, isRoot: true }) - } + let pendingDirectories = roots.map((root) => ({ + root, + absPath: root.rootPath, + isRoot: true + })) while (pendingDirectories.length > 0) { const nextDirectories: typeof pendingDirectories = [] @@ -145,29 +189,32 @@ async function listQuickOpenFilesFromRoots( throwIfFileListingCancelled(signal) assertQuickOpenReaddirDeadline(budget) const batch = pendingDirectories.slice(offset, offset + QUICK_OPEN_READDIR_CONCURRENCY) - const readResults = await Promise.allSettled( + const entryGroups = await Promise.all( batch.map(async (pending) => { - const entries = await readQuickOpenDirectoryEntries({ - absPath: pending.absPath, - allowSymlinkedRoot: Boolean(pending.isRoot && pending.root.allowRootSymlink), - budget, - signal - }) - return { pending, entries } + try { + // Why: Git's placeholder may have been replaced with a symlink + // before expansion. Never let readdir follow it outside the root. + const stat = await lstat(pending.absPath) + const allowSymlinkedRoot = pending.isRoot && pending.root.allowRootSymlink + if (!isQuickOpenReadableDirectory(stat, allowSymlinkedRoot)) { + return { pending, entries: [] } + } + const entries = await readdir(pending.absPath, { withFileTypes: true }) + // Why: close the ordinary check/use race. If the directory became + // a symlink while readdir was pending, discard everything read. + const statAfterRead = await lstat(pending.absPath) + if (!isQuickOpenReadableDirectory(statAfterRead, allowSymlinkedRoot)) { + return { pending, entries: [] } + } + return { pending, entries } + } catch { + // Why: permission denied on one subtree is common for broad roots. + return { pending, entries: [] } + } }) ) - const entryGroups: { - pending: (typeof pendingDirectories)[number] - entries: Awaited> - }[] = [] - for (const result of readResults) { - if (result.status === 'rejected') { - throw result.reason - } - entryGroups.push(result.value) - } // Why: an empty directory has no per-entry checkpoint below. Cancellation - // or timeout that lands during opendir must still reject, never resolve []. + // or timeout that lands during readdir must still reject, never resolve []. throwIfFileListingCancelled(signal) assertQuickOpenReaddirDeadline(budget) @@ -185,29 +232,22 @@ async function listQuickOpenFilesFromRoots( if (shouldExcludeQuickOpenRelPath(relPath, pending.root.excludePathPrefixes)) { continue } - if (entry.kind === 'directory') { + if (entry.isDirectory()) { if (shouldDescend(name) && shouldIncludeQuickOpenPath(workspaceRelPath)) { - const depth = pending.depth + 1 - assertQuickOpenReaddirDepth(budget, depth) - consumeQuickOpenReaddirDirectoryBudget(budget) - consumeQuickOpenReaddirPathBudget(budget, absPath) - nextDirectories.push({ root: pending.root, absPath, depth, isRoot: false }) + nextDirectories.push({ root: pending.root, absPath, isRoot: false }) } continue } if ( - (entry.kind === 'file' || (pending.root.includeSymlinks && entry.kind === 'symlink')) && + (entry.isFile() || (pending.root.includeSymlinks && entry.isSymbolicLink())) && shouldIncludeQuickOpenPath(workspaceRelPath) ) { - const outputPath = pending.root.outputPathPrefix - ? `${pending.root.outputPathPrefix}/${relPath}` - : relPath - if (knownFiles?.has(outputPath)) { - continue - } consumeQuickOpenReaddirFileBudget(budget) - consumeQuickOpenReaddirPathBudget(budget, outputPath) - files.push(outputPath) + files.push( + pending.root.outputPathPrefix + ? `${pending.root.outputPathPrefix}/${relPath}` + : relPath + ) // Why: a caller result limit is a successful bounded prefix, while // the separate traversal budget still rejects incomplete scans. if (maxResults !== undefined && files.length >= maxResults) { @@ -232,9 +272,6 @@ export async function expandQuickOpenGitFileListing(opts: { maxResults?: number signal?: AbortSignal }): Promise { - if (opts.maxResults !== undefined && opts.maxResults <= 0) { - return [] - } const files = new Set() const excludePathPrefixes = opts.excludePathPrefixes ?? [] const budget = opts.budget ?? createQuickOpenReaddirBudget() @@ -265,8 +302,6 @@ export async function expandQuickOpenGitFileListing(opts: { continue } - consumeQuickOpenReaddirEntryBudget(budget) - consumeQuickOpenReaddirPathBudget(budget, relPath) expansionPaths.set(relPath, expansionPaths.get(relPath) ?? false) } @@ -285,8 +320,6 @@ export async function expandQuickOpenGitFileListing(opts: { continue } - consumeQuickOpenReaddirEntryBudget(budget) - consumeQuickOpenReaddirPathBudget(budget, relPath) // Why: before directory collapse, Git returned untracked symlink entries // without following them. Preserve those paths when expanding placeholders. expansionPaths.set(relPath, true) @@ -306,8 +339,7 @@ export async function expandQuickOpenGitFileListing(opts: { })), budget, opts.signal, - opts.maxResults === undefined ? undefined : Math.max(0, opts.maxResults - files.size), - files + opts.maxResults === undefined ? undefined : Math.max(0, opts.maxResults - files.size) ) for (const expandedFile of expandedFiles) { addFinalPath(expandedFile) diff --git a/src/shared/raster-image-base64-preview.ts b/src/shared/raster-image-base64-preview.ts deleted file mode 100644 index 549abc360d4..00000000000 --- a/src/shared/raster-image-base64-preview.ts +++ /dev/null @@ -1,141 +0,0 @@ -import type { RasterImageDimensions } from './raster-image-dimensions' -import { - assertRasterImagePreviewWithinLimits, - isKnownRasterImageMimeType, - RASTER_IMAGE_PREVIEW_HEADER_MAX_BYTES -} from './raster-image-preview-limits' - -const BASE64_PADDING = -2 -const INVALID_BASE64 = -1 - -function base64Value(code: number): number { - if (code >= 65 && code <= 90) { - return code - 65 - } - if (code >= 97 && code <= 122) { - return code - 71 - } - if (code >= 48 && code <= 57) { - return code + 4 - } - if (code === 43) { - return 62 - } - if (code === 47) { - return 63 - } - if (code === 61) { - return BASE64_PADDING - } - return INVALID_BASE64 -} - -function isWhitespace(code: number): boolean { - return code === 9 || code === 10 || code === 12 || code === 13 || code === 32 -} - -function writeQuartet( - output: Uint8Array, - offset: number, - quartet: readonly number[] -): { bytesWritten: number; padded: boolean } | null { - const [a, b, c, d] = quartet - if (a === undefined || b === undefined || a < 0 || b < 0) { - return null - } - if (c === BASE64_PADDING) { - if (d !== BASE64_PADDING) { - return null - } - if (offset < output.length) { - output[offset] = (a << 2) | (b >> 4) - } - return { bytesWritten: Math.min(1, output.length - offset), padded: true } - } - if (c === undefined || c < 0) { - return null - } - if (offset < output.length) { - output[offset] = (a << 2) | (b >> 4) - } - if (offset + 1 < output.length) { - output[offset + 1] = ((b & 15) << 4) | (c >> 2) - } - if (d === BASE64_PADDING) { - return { bytesWritten: Math.min(2, output.length - offset), padded: true } - } - if (d === undefined || d < 0) { - return null - } - if (offset + 2 < output.length) { - output[offset + 2] = ((c & 3) << 6) | d - } - return { bytesWritten: Math.min(3, output.length - offset), padded: false } -} - -function decodeBase64Prefix(content: string, maxBytes: number): Uint8Array | null { - const capacity = Math.min(maxBytes, Math.ceil(content.length / 4) * 3) - const output = new Uint8Array(capacity) - const quartet: number[] = [] - let outputLength = 0 - let padded = false - - for (let index = 0; index < content.length && outputLength < capacity; index += 1) { - const code = content.charCodeAt(index) - if (isWhitespace(code)) { - continue - } - if (padded) { - return null - } - const value = base64Value(code) - if (value === INVALID_BASE64) { - return null - } - quartet.push(value) - if (quartet.length !== 4) { - continue - } - const decoded = writeQuartet(output, outputLength, quartet) - if (!decoded) { - return null - } - outputLength += decoded.bytesWritten - padded = decoded.padded - quartet.length = 0 - } - - if (!padded && outputLength < capacity && quartet.length > 0) { - if (quartet.length === 1 || quartet.includes(BASE64_PADDING)) { - return null - } - while (quartet.length < 4) { - quartet.push(BASE64_PADDING) - } - const decoded = writeQuartet(output, outputLength, quartet) - if (!decoded) { - return null - } - outputLength += decoded.bytesWritten - } - return output.subarray(0, outputLength) -} - -/** Returns undefined for non-raster MIME types and null for rejected raster bytes. */ -export function readRasterImagePreviewDimensionsFromBase64( - content: string, - mimeType: string | undefined -): RasterImageDimensions | null | undefined { - if (!isKnownRasterImageMimeType(mimeType)) { - return undefined - } - const prefix = decodeBase64Prefix(content, RASTER_IMAGE_PREVIEW_HEADER_MAX_BYTES) - if (!prefix) { - return null - } - try { - return assertRasterImagePreviewWithinLimits(prefix, mimeType) ?? null - } catch { - return null - } -} diff --git a/src/shared/raster-image-dimensions.test.ts b/src/shared/raster-image-dimensions.test.ts deleted file mode 100644 index 17e20d68da5..00000000000 --- a/src/shared/raster-image-dimensions.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { readRasterImageDimensions } from './raster-image-dimensions' - -function pngHeader(width: number, height: number): Buffer { - const png = Buffer.alloc(24) - Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(png) - png.writeUInt32BE(13, 8) - png.write('IHDR', 12, 'ascii') - png.writeUInt32BE(width, 16) - png.writeUInt32BE(height, 20) - return png -} - -function bmpHeader(width: number, height: number): Buffer { - const bmp = Buffer.alloc(26) - bmp.write('BM', 0, 'ascii') - bmp.writeUInt32LE(40, 14) - bmp.writeInt32LE(width, 18) - bmp.writeInt32LE(height, 22) - return bmp -} - -function icoWithPayload(payload: Buffer, width = 1, height = 1): Buffer { - const header = Buffer.alloc(22) - header.writeUInt16LE(1, 2) - header.writeUInt16LE(1, 4) - header[6] = width === 256 ? 0 : width - header[7] = height === 256 ? 0 : height - header.writeUInt32LE(payload.byteLength, 14) - header.writeUInt32LE(header.byteLength, 18) - return Buffer.concat([header, payload]) -} - -describe('readRasterImageDimensions', () => { - it('reads BMP dimensions including top-down images', () => { - expect(readRasterImageDimensions(bmpHeader(640, -480))).toEqual({ - width: 640, - height: 480 - }) - }) - - it('uses embedded ICO image dimensions instead of forgeable directory values', () => { - expect(readRasterImageDimensions(icoWithPayload(pngHeader(40_000, 2)))).toEqual({ - width: 40_000, - height: 2 - }) - }) - - it('reads a Uint8Array view without depending on its backing-buffer offset', () => { - const wrapped = Buffer.concat([Buffer.from('prefix'), pngHeader(320, 240), Buffer.from('tail')]) - const view = wrapped.subarray(6, 30) - - expect(readRasterImageDimensions(view)).toEqual({ width: 320, height: 240 }) - }) - - it('rejects truncated ICO payloads and zero raster dimensions', () => { - const truncated = icoWithPayload(pngHeader(16, 16)).subarray(0, 30) - - expect(readRasterImageDimensions(truncated)).toBeNull() - expect(readRasterImageDimensions(bmpHeader(0, 16))).toBeNull() - }) -}) diff --git a/src/shared/raster-image-dimensions.ts b/src/shared/raster-image-dimensions.ts deleted file mode 100644 index 4ea3c27c751..00000000000 --- a/src/shared/raster-image-dimensions.ts +++ /dev/null @@ -1,252 +0,0 @@ -export type RasterImageDimensions = { width: number; height: number } - -const JPEG_DIMENSION_SCAN_MAX_BYTES = 1024 * 1024 -const JPEG_DIMENSION_SCAN_MAX_MARKERS = 4_096 -const ICO_MAX_IMAGES = 1_024 -const JPEG_START_OF_FRAME_MARKERS = new Set([ - 0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf -]) -const PNG_SIGNATURE = [137, 80, 78, 71, 13, 10, 26, 10] - -function hasBytes(bytes: Uint8Array, offset: number, length: number): boolean { - return offset >= 0 && length >= 0 && offset + length <= bytes.byteLength -} - -function matchesBytes(bytes: Uint8Array, offset: number, expected: readonly number[]): boolean { - return ( - hasBytes(bytes, offset, expected.length) && - expected.every((value, index) => bytes[offset + index] === value) - ) -} - -function matchesAscii(bytes: Uint8Array, offset: number, expected: string): boolean { - if (!hasBytes(bytes, offset, expected.length)) { - return false - } - for (let index = 0; index < expected.length; index += 1) { - if (bytes[offset + index] !== expected.charCodeAt(index)) { - return false - } - } - return true -} - -function readUint16Le(bytes: Uint8Array, offset: number): number { - return bytes[offset]! | (bytes[offset + 1]! << 8) -} - -function readUint16Be(bytes: Uint8Array, offset: number): number { - return (bytes[offset]! << 8) | bytes[offset + 1]! -} - -function readUint24Le(bytes: Uint8Array, offset: number): number { - return bytes[offset]! | (bytes[offset + 1]! << 8) | (bytes[offset + 2]! << 16) -} - -function readUint32Le(bytes: Uint8Array, offset: number): number { - return ( - (bytes[offset]! | - (bytes[offset + 1]! << 8) | - (bytes[offset + 2]! << 16) | - (bytes[offset + 3]! << 24)) >>> - 0 - ) -} - -function readUint32Be(bytes: Uint8Array, offset: number): number { - return ( - (((bytes[offset]! << 24) >>> 0) | - (bytes[offset + 1]! << 16) | - (bytes[offset + 2]! << 8) | - bytes[offset + 3]!) >>> - 0 - ) -} - -function readInt32Le(bytes: Uint8Array, offset: number): number { - return readUint32Le(bytes, offset) | 0 -} - -function positiveDimensions(width: number, height: number): RasterImageDimensions | null { - return Number.isSafeInteger(width) && Number.isSafeInteger(height) && width > 0 && height > 0 - ? { width, height } - : null -} - -function readPngDimensions(bytes: Uint8Array): RasterImageDimensions | null { - if ( - !matchesBytes(bytes, 0, PNG_SIGNATURE) || - !hasBytes(bytes, 8, 16) || - readUint32Be(bytes, 8) !== 13 || - !matchesAscii(bytes, 12, 'IHDR') - ) { - return null - } - return positiveDimensions(readUint32Be(bytes, 16), readUint32Be(bytes, 20)) -} - -function readGifDimensions(bytes: Uint8Array): RasterImageDimensions | null { - if ( - !hasBytes(bytes, 0, 10) || - (!matchesAscii(bytes, 0, 'GIF87a') && !matchesAscii(bytes, 0, 'GIF89a')) - ) { - return null - } - return positiveDimensions(readUint16Le(bytes, 6), readUint16Le(bytes, 8)) -} - -function readJpegDimensions(bytes: Uint8Array): RasterImageDimensions | null { - if (!hasBytes(bytes, 0, 4) || bytes[0] !== 0xff || bytes[1] !== 0xd8) { - return null - } - let offset = 2 - let markersRead = 0 - const scanEnd = Math.min(bytes.byteLength, JPEG_DIMENSION_SCAN_MAX_BYTES) - while (offset < scanEnd && markersRead < JPEG_DIMENSION_SCAN_MAX_MARKERS) { - while (offset < scanEnd && bytes[offset] === 0xff) { - offset += 1 - } - const marker = bytes[offset] - offset += 1 - markersRead += 1 - if (marker === undefined || marker === 0x00 || marker === 0xd9 || marker === 0xda) { - return null - } - if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd8)) { - continue - } - if (!hasBytes(bytes, offset, 2)) { - return null - } - const segmentLength = readUint16Be(bytes, offset) - if (segmentLength < 2 || offset + segmentLength > scanEnd) { - return null - } - if (JPEG_START_OF_FRAME_MARKERS.has(marker)) { - return segmentLength >= 7 - ? positiveDimensions(readUint16Be(bytes, offset + 5), readUint16Be(bytes, offset + 3)) - : null - } - offset += segmentLength - } - return null -} - -function readWebpDimensions(bytes: Uint8Array): RasterImageDimensions | null { - if ( - !hasBytes(bytes, 0, 20) || - !matchesAscii(bytes, 0, 'RIFF') || - !matchesAscii(bytes, 8, 'WEBP') - ) { - return null - } - - let offset = 12 - while (hasBytes(bytes, offset, 8)) { - const chunkSize = readUint32Le(bytes, offset + 4) - const dataOffset = offset + 8 - const dataEnd = dataOffset + chunkSize - - if (matchesAscii(bytes, offset, 'VP8X') && chunkSize >= 10 && hasBytes(bytes, dataOffset, 10)) { - return positiveDimensions( - readUint24Le(bytes, dataOffset + 4) + 1, - readUint24Le(bytes, dataOffset + 7) + 1 - ) - } - if ( - matchesAscii(bytes, offset, 'VP8L') && - chunkSize >= 5 && - hasBytes(bytes, dataOffset, 5) && - bytes[dataOffset] === 0x2f - ) { - const b0 = bytes[dataOffset + 1]! - const b1 = bytes[dataOffset + 2]! - const b2 = bytes[dataOffset + 3]! - const b3 = bytes[dataOffset + 4]! - return positiveDimensions( - 1 + (((b1 & 0x3f) << 8) | b0), - 1 + (((b3 & 0x0f) << 10) | (b2 << 2) | ((b1 & 0xc0) >> 6)) - ) - } - if ( - matchesAscii(bytes, offset, 'VP8 ') && - chunkSize >= 10 && - hasBytes(bytes, dataOffset, 10) && - bytes[dataOffset + 3] === 0x9d && - bytes[dataOffset + 4] === 0x01 && - bytes[dataOffset + 5] === 0x2a - ) { - return positiveDimensions( - readUint16Le(bytes, dataOffset + 6) & 0x3fff, - readUint16Le(bytes, dataOffset + 8) & 0x3fff - ) - } - if (dataEnd > bytes.byteLength) { - return null - } - offset = dataEnd + (chunkSize % 2) - } - return null -} - -function readDibDimensions(bytes: Uint8Array, offset: number): RasterImageDimensions | null { - if (!hasBytes(bytes, offset, 12)) { - return null - } - const headerSize = readUint32Le(bytes, offset) - if (headerSize === 12) { - return positiveDimensions(readUint16Le(bytes, offset + 4), readUint16Le(bytes, offset + 6)) - } - if (headerSize < 40 || !hasBytes(bytes, offset, 12)) { - return null - } - return positiveDimensions( - Math.abs(readInt32Le(bytes, offset + 4)), - Math.abs(readInt32Le(bytes, offset + 8)) - ) -} - -function readBmpDimensions(bytes: Uint8Array): RasterImageDimensions | null { - return matchesAscii(bytes, 0, 'BM') ? readDibDimensions(bytes, 14) : null -} - -function readIcoDimensions(bytes: Uint8Array): RasterImageDimensions | null { - if (!hasBytes(bytes, 0, 6) || readUint16Le(bytes, 0) !== 0 || readUint16Le(bytes, 2) !== 1) { - return null - } - const imageCount = readUint16Le(bytes, 4) - if (imageCount <= 0 || imageCount > ICO_MAX_IMAGES || !hasBytes(bytes, 6, imageCount * 16)) { - return null - } - - let maxWidth = 0 - let maxHeight = 0 - for (let index = 0; index < imageCount; index += 1) { - const entryOffset = 6 + index * 16 - const encodedSize = readUint32Le(bytes, entryOffset + 8) - const imageOffset = readUint32Le(bytes, entryOffset + 12) - if (encodedSize <= 0 || !hasBytes(bytes, imageOffset, encodedSize)) { - return null - } - const payload = bytes.subarray(imageOffset, imageOffset + encodedSize) - const embedded = readPngDimensions(payload) ?? readDibDimensions(payload, 0) - const width = embedded?.width ?? (bytes[entryOffset] === 0 ? 256 : bytes[entryOffset]!) - const height = - embedded?.height ?? (bytes[entryOffset + 1] === 0 ? 256 : bytes[entryOffset + 1]!) - maxWidth = Math.max(maxWidth, width) - maxHeight = Math.max(maxHeight, height) - } - return positiveDimensions(maxWidth, maxHeight) -} - -/** Reads encoded raster dimensions without invoking a native or browser image decoder. */ -export function readRasterImageDimensions(bytes: Uint8Array): RasterImageDimensions | null { - return ( - readPngDimensions(bytes) ?? - readGifDimensions(bytes) ?? - readJpegDimensions(bytes) ?? - readWebpDimensions(bytes) ?? - readBmpDimensions(bytes) ?? - readIcoDimensions(bytes) - ) -} diff --git a/src/shared/raster-image-preview-limits.test.ts b/src/shared/raster-image-preview-limits.test.ts deleted file mode 100644 index 24f43ca4751..00000000000 --- a/src/shared/raster-image-preview-limits.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - INVALID_RASTER_IMAGE_PREVIEW_ERROR, - MAX_RASTER_IMAGE_PREVIEW_DIMENSION_PX, - RASTER_IMAGE_PREVIEW_TOO_LARGE_ERROR, - assertRasterImagePreviewWithinLimits, - isKnownRasterImageMimeType -} from './raster-image-preview-limits' - -function pngHeader(width: number, height: number): Buffer { - const bytes = Buffer.alloc(24) - Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(bytes) - bytes.writeUInt32BE(13, 8) - bytes.write('IHDR', 12, 'ascii') - bytes.writeUInt32BE(width, 16) - bytes.writeUInt32BE(height, 20) - return bytes -} - -describe('raster image preview limits', () => { - it('accepts ordinary 8K images and returns their dimensions', () => { - expect(assertRasterImagePreviewWithinLimits(pngHeader(7680, 4320), 'image/png')).toEqual({ - width: 7680, - height: 4320 - }) - }) - - it('rejects oversized edges and total pixel counts before decode', () => { - expect(() => - assertRasterImagePreviewWithinLimits( - pngHeader(MAX_RASTER_IMAGE_PREVIEW_DIMENSION_PX + 1, 1), - 'image/png' - ) - ).toThrow(RASTER_IMAGE_PREVIEW_TOO_LARGE_ERROR) - expect(() => assertRasterImagePreviewWithinLimits(pngHeader(8192, 8192), 'image/png')).toThrow( - RASTER_IMAGE_PREVIEW_TOO_LARGE_ERROR - ) - }) - - it('rejects invalid known raster bytes but leaves SVG and PDF unchanged', () => { - expect(() => assertRasterImagePreviewWithinLimits(new Uint8Array([1]), 'image/gif')).toThrow( - INVALID_RASTER_IMAGE_PREVIEW_ERROR - ) - expect( - assertRasterImagePreviewWithinLimits(new Uint8Array([1]), 'image/svg+xml') - ).toBeUndefined() - expect( - assertRasterImagePreviewWithinLimits(new Uint8Array([1]), 'application/pdf') - ).toBeUndefined() - }) - - it('recognizes supported MIME aliases case-insensitively', () => { - expect(isKnownRasterImageMimeType('IMAGE/JPEG; charset=binary')).toBe(true) - expect(isKnownRasterImageMimeType('image/vnd.microsoft.icon')).toBe(true) - expect(isKnownRasterImageMimeType('image/tiff')).toBe(false) - }) -}) diff --git a/src/shared/raster-image-preview-limits.ts b/src/shared/raster-image-preview-limits.ts deleted file mode 100644 index a4b44d0fb69..00000000000 --- a/src/shared/raster-image-preview-limits.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { readRasterImageDimensions, type RasterImageDimensions } from './raster-image-dimensions' - -export const MAX_RASTER_IMAGE_PREVIEW_DIMENSION_PX = 32_768 -export const MAX_RASTER_IMAGE_PREVIEW_PIXELS = 32 * 1024 * 1024 -export const RASTER_IMAGE_PREVIEW_HEADER_MAX_BYTES = 1024 * 1024 -export const INVALID_RASTER_IMAGE_PREVIEW_ERROR = - 'Image preview has invalid or unsupported raster dimensions' -export const RASTER_IMAGE_PREVIEW_TOO_LARGE_ERROR = - 'Image dimensions exceed the preview safety limit' - -const RASTER_IMAGE_MIME_TYPES = new Set([ - 'image/apng', - 'image/bmp', - 'image/gif', - 'image/ico', - 'image/jpeg', - 'image/jpg', - 'image/pjpeg', - 'image/png', - 'image/vnd.microsoft.icon', - 'image/webp', - 'image/x-bmp', - 'image/x-icon', - 'image/x-ms-bmp' -]) - -function normalizeMimeType(mimeType: string | undefined): string | null { - const normalized = mimeType?.split(';', 1)[0]?.trim().toLowerCase() - return normalized || null -} - -export function isKnownRasterImageMimeType(mimeType: string | undefined): boolean { - const normalized = normalizeMimeType(mimeType) - return normalized !== null && RASTER_IMAGE_MIME_TYPES.has(normalized) -} - -export function isRasterImagePreviewDimensions(value: unknown): value is RasterImageDimensions { - if (!value || typeof value !== 'object') { - return false - } - const dimensions = value as Partial - return ( - Number.isSafeInteger(dimensions.width) && - Number.isSafeInteger(dimensions.height) && - dimensions.width! > 0 && - dimensions.height! > 0 && - dimensions.width! <= MAX_RASTER_IMAGE_PREVIEW_DIMENSION_PX && - dimensions.height! <= MAX_RASTER_IMAGE_PREVIEW_DIMENSION_PX && - dimensions.width! <= Math.floor(MAX_RASTER_IMAGE_PREVIEW_PIXELS / dimensions.height!) - ) -} - -/** Validates encoded raster dimensions without invoking a native image decoder. */ -export function assertRasterImagePreviewWithinLimits( - bytes: Uint8Array, - mimeType: string | undefined -): RasterImageDimensions | undefined { - if (!isKnownRasterImageMimeType(mimeType)) { - return undefined - } - const dimensions = readRasterImageDimensions(bytes) - if (!dimensions) { - throw new Error(INVALID_RASTER_IMAGE_PREVIEW_ERROR) - } - if (!isRasterImagePreviewDimensions(dimensions)) { - throw new Error(RASTER_IMAGE_PREVIEW_TOO_LARGE_ERROR) - } - return dimensions -} diff --git a/src/shared/relay-json-admission.ts b/src/shared/relay-json-admission.ts deleted file mode 100644 index a9d256ba904..00000000000 --- a/src/shared/relay-json-admission.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { assertJsonTextStructureWithinLimits } from './json-text-structure-limit' - -export const RELAY_JSON_MAX_STRUCTURAL_TOKENS = 1_000_000 -export const RELAY_JSON_MAX_NESTING_DEPTH = 128 - -export function parseRelayJsonText(text: string): T { - assertJsonTextStructureWithinLimits(text, { - structuralTokens: RELAY_JSON_MAX_STRUCTURAL_TOKENS, - nestingDepth: RELAY_JSON_MAX_NESTING_DEPTH - }) - return JSON.parse(text) as T -} diff --git a/src/shared/relay-version-marker.test.ts b/src/shared/relay-version-marker.test.ts deleted file mode 100644 index 36955399518..00000000000 --- a/src/shared/relay-version-marker.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { mkdtempSync, rmSync, truncateSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import { NodeFileReadTooLargeError } from './node-bounded-file-reader' -import { RELAY_VERSION_MARKER_MAX_BYTES, readRelayVersionMarkerSync } from './relay-version-marker' - -const roots: string[] = [] - -function createVersionFile(contents: string): string { - const root = mkdtempSync(join(tmpdir(), 'orca-relay-version-marker-')) - roots.push(root) - const filePath = join(root, '.version') - writeFileSync(filePath, contents) - return filePath -} - -afterEach(() => { - for (const root of roots.splice(0)) { - rmSync(root, { recursive: true, force: true }) - } -}) - -describe('relay version marker', () => { - it('accepts a trimmed marker at the exact byte boundary', () => { - const version = '1.2.3+deadbeef' - const filePath = createVersionFile( - version + ' '.repeat(RELAY_VERSION_MARKER_MAX_BYTES - Buffer.byteLength(version)) - ) - - expect(readRelayVersionMarkerSync(filePath)).toBe(version) - }) - - it('rejects a sparse marker one byte over the boundary', () => { - const filePath = createVersionFile('1.2.3') - truncateSync(filePath, RELAY_VERSION_MARKER_MAX_BYTES + 1) - - expect(() => readRelayVersionMarkerSync(filePath)).toThrow(NodeFileReadTooLargeError) - }) -}) diff --git a/src/shared/relay-version-marker.ts b/src/shared/relay-version-marker.ts deleted file mode 100644 index 68be008ff8d..00000000000 --- a/src/shared/relay-version-marker.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { readNodeFileSyncWithinLimit } from './node-bounded-file-reader' - -export const RELAY_VERSION_MARKER_MAX_BYTES = 4 * 1024 - -export function readRelayVersionMarkerSync(versionFile: string): string { - return readNodeFileSyncWithinLimit(versionFile, RELAY_VERSION_MARKER_MAX_BYTES) - .buffer.toString('utf8') - .trim() -} diff --git a/src/shared/remote-runtime-client.ts b/src/shared/remote-runtime-client.ts index b82f515a07c..15ec38e4005 100644 --- a/src/shared/remote-runtime-client.ts +++ b/src/shared/remote-runtime-client.ts @@ -24,18 +24,6 @@ import { // unaffected; the class lives in a ws-free module so type-only consumers // (and mobile's typecheck) don't compile this file's Node-only deps. import { RemoteRuntimeClientError } from './remote-runtime-client-error' -import { - isRemoteRuntimeBinaryFrameWithinLimit, - REMOTE_RUNTIME_MAX_WEBSOCKET_FRAME_BYTES, - serializeRemoteRuntimePayload, - serializeRemoteRuntimeRpcRequest -} from './remote-runtime-memory-limits' -import { - prepareRemoteRuntimeRequest, - releaseRemoteRuntimePreparedRequest, - takeRemoteRuntimePreparedRequest -} from './remote-runtime-prepared-request-admission' -import { parseRemoteRuntimeJsonText } from './remote-runtime-request-frames' import { startRemoteRuntimeSocketLiveness, type RemoteRuntimeSocketLivenessMonitor, @@ -82,23 +70,8 @@ export async function sendRemoteRuntimeRequest( params: unknown, timeoutMs: number ): Promise> { - const requestId = randomUUID() - const serializedAuth = serializeRemoteRuntimePayload({ - type: 'e2ee_auth', - deviceToken: pairing.deviceToken - }) - const pendingRequest = { - preparedRequest: prepareRemoteRuntimeRequest(new Map(), () => - serializeRemoteRuntimeRpcRequest({ - requestId, - deviceToken: pairing.deviceToken, - method, - params - }) - ) - } - let serializedRequest = takeRemoteRuntimePreparedRequest(pendingRequest) - return await new Promise>((resolve, reject) => { + return await new Promise((resolve, reject) => { + const requestId = randomUUID() const keyPair = generateKeyPair() const serverPublicKey = publicKeyFromBase64(pairing.publicKeyB64) const sharedKey = deriveSharedKey(keyPair.secretKey, serverPublicKey) @@ -168,7 +141,7 @@ export async function sendRemoteRuntimeRequest( } try { - ws = new WebSocket(pairing.endpoint, { maxPayload: REMOTE_RUNTIME_MAX_WEBSOCKET_FRAME_BYTES }) + ws = new WebSocket(pairing.endpoint) } catch (error) { const message = error instanceof Error ? error.message : String(error) finish({ @@ -261,7 +234,7 @@ export async function sendRemoteRuntimeRequest( function handleReadyFrame(frame: string): void { let ready: unknown try { - ready = parseRemoteRuntimeJsonText(frame) + ready = JSON.parse(frame) } catch { finish({ ok: false, @@ -287,13 +260,15 @@ export async function sendRemoteRuntimeRequest( return } state = 'awaiting_authenticated' - ws?.send(encrypt(serializedAuth, sharedKey)) + ws?.send( + encrypt(JSON.stringify({ type: 'e2ee_auth', deviceToken: pairing.deviceToken }), sharedKey) + ) } function handleAuthenticatedFrame(plaintext: string): void { let authenticated: unknown try { - authenticated = parseRemoteRuntimeJsonText(plaintext) + authenticated = JSON.parse(plaintext) } catch { finish({ ok: false, @@ -322,25 +297,23 @@ export async function sendRemoteRuntimeRequest( return } state = 'ready' - const request = serializedRequest - serializedRequest = null - if (request === null) { - finish({ - ok: false, - error: new RemoteRuntimeClientError( - 'remote_runtime_unavailable', - 'Remote Orca runtime request was released before it could be sent.' - ) - }) - return - } - ws?.send(encrypt(request, sharedKey)) + ws?.send( + encrypt( + JSON.stringify({ + id: requestId, + deviceToken: pairing.deviceToken, + method, + params + }), + sharedKey + ) + ) } function handleRpcFrame(plaintext: string): void { let raw: unknown try { - raw = parseRemoteRuntimeJsonText(plaintext) + raw = JSON.parse(plaintext) } catch { finish({ ok: false, @@ -379,7 +352,7 @@ export async function sendRemoteRuntimeRequest( } finish({ ok: true, response }) } - }).finally(() => releaseRemoteRuntimePreparedRequest(pendingRequest)) + }) } export async function subscribeRemoteRuntimeRequest( @@ -390,18 +363,8 @@ export async function subscribeRemoteRuntimeRequest( callbacks: RemoteRuntimeSubscriptionCallbacks, livenessOptions?: RemoteRuntimeSocketLivenessOptions ): Promise { - const requestId = randomUUID() - const serializedRequest = serializeRemoteRuntimeRpcRequest({ - requestId, - deviceToken: pairing.deviceToken, - method, - params - }) - const serializedAuth = serializeRemoteRuntimePayload({ - type: 'e2ee_auth', - deviceToken: pairing.deviceToken - }) return await new Promise((resolve, reject) => { + const requestId = randomUUID() const keyPair = generateKeyPair() const serverPublicKey = publicKeyFromBase64(pairing.publicKeyB64) const sharedKey = deriveSharedKey(keyPair.secretKey, serverPublicKey) @@ -487,12 +450,7 @@ export async function subscribeRemoteRuntimeRequest( } const sendBinary = (bytes: Uint8Array): boolean => { - if ( - !isRemoteRuntimeBinaryFrameWithinLimit(bytes) || - state !== 'ready' || - !ws || - ws.readyState !== WebSocket.OPEN - ) { + if (state !== 'ready' || !ws || ws.readyState !== WebSocket.OPEN) { return false } ensureSendQueue(ws).enqueue(Buffer.from(encryptBytes(bytes, sharedKey))) @@ -525,7 +483,7 @@ export async function subscribeRemoteRuntimeRequest( } try { - ws = new WebSocket(pairing.endpoint, { maxPayload: REMOTE_RUNTIME_MAX_WEBSOCKET_FRAME_BYTES }) + ws = new WebSocket(pairing.endpoint) } catch (error) { const message = error instanceof Error ? error.message : String(error) fail(new RemoteRuntimeClientError('invalid_argument', `Invalid remote endpoint: ${message}`)) @@ -642,7 +600,7 @@ export async function subscribeRemoteRuntimeRequest( function handleReadyFrame(frame: string): void { let ready: unknown try { - ready = parseRemoteRuntimeJsonText(frame) + ready = JSON.parse(frame) } catch { fail( new RemoteRuntimeClientError( @@ -666,13 +624,15 @@ export async function subscribeRemoteRuntimeRequest( return } state = 'awaiting_authenticated' - ws?.send(encrypt(serializedAuth, sharedKey)) + ws?.send( + encrypt(JSON.stringify({ type: 'e2ee_auth', deviceToken: pairing.deviceToken }), sharedKey) + ) } function handleAuthenticatedFrame(plaintext: string): void { let authenticated: unknown try { - authenticated = parseRemoteRuntimeJsonText(plaintext) + authenticated = JSON.parse(plaintext) } catch { fail( new RemoteRuntimeClientError( @@ -694,14 +654,24 @@ export async function subscribeRemoteRuntimeRequest( return } state = 'ready' - ws?.send(encrypt(serializedRequest, sharedKey)) + ws?.send( + encrypt( + JSON.stringify({ + id: requestId, + deviceToken: pairing.deviceToken, + method, + params + }), + sharedKey + ) + ) succeed() } function handleRpcFrame(plaintext: string): void { let raw: unknown try { - raw = parseRemoteRuntimeJsonText(plaintext) + raw = JSON.parse(plaintext) } catch { fail( new RemoteRuntimeClientError( diff --git a/src/shared/remote-runtime-outbound-admission.test.ts b/src/shared/remote-runtime-outbound-admission.test.ts deleted file mode 100644 index f536549ca46..00000000000 --- a/src/shared/remote-runtime-outbound-admission.test.ts +++ /dev/null @@ -1,371 +0,0 @@ -import type { AddressInfo } from 'node:net' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { WebSocketServer } from 'ws' -import { generateKeyPair, publicKeyToBase64 } from './e2ee-crypto' -import { encodePairingOffer, parsePairingCode, type PairingOffer } from './pairing' -import { sendRemoteRuntimeRequest, subscribeRemoteRuntimeRequest } from './remote-runtime-client' -import { - REMOTE_RUNTIME_MAX_PENDING_REQUESTS, - REMOTE_RUNTIME_MAX_PENDING_RPC_BYTES, - REMOTE_RUNTIME_MAX_PROCESS_PENDING_REQUESTS, - REMOTE_RUNTIME_MAX_PROCESS_PENDING_RPC_BYTES, - REMOTE_RUNTIME_MAX_READY_WAITERS, - REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES, - REMOTE_RUNTIME_MAX_RETAINED_SUBSCRIPTION_BYTES, - REMOTE_RUNTIME_MAX_SUBSCRIPTIONS, - retainedRemoteRuntimeJsonStringBytes, - serializeRemoteRuntimeRpcRequest -} from './remote-runtime-memory-limits' -import { getRemoteRuntimeRequestAdmissionEvidence } from './remote-runtime-prepared-request-admission' -import { RemoteRuntimeRequestConnection } from './remote-runtime-request-connection' -import { RemoteRuntimeSharedControlConnection } from './remote-runtime-shared-control-connection' -import { waitForSharedControlReadyWithTimeout } from './remote-runtime-shared-control-ready' - -type InspectableRequestConnection = { - close: () => void - request: (method: string, params: unknown, timeoutMs: number) => Promise -} - -type RequestAdmissionState = { - pendingRequests: Map< - string, - { preparedRequest?: { retainedBytes: number; serializedRequest?: string | null } | null } - > - readyWaiters: unknown[] -} - -const servers: WebSocketServer[] = [] - -afterEach(async () => { - await Promise.all( - servers.splice(0).map( - (server) => - new Promise((resolve) => { - for (const client of server.clients) { - client.close() - } - server.close(() => resolve()) - }) - ) - ) - expect(getRemoteRuntimeRequestAdmissionEvidence()).toEqual({ - pendingRequestCount: 0, - retainedBytes: 0 - }) -}) - -describe('remote runtime outbound admission', () => { - it('rejects oversized requests before opening any desktop transport socket', async () => { - const { pairing, server } = await createServer() - const oversizedParams = { value: 'x'.repeat(REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES) } - const cached = new RemoteRuntimeRequestConnection(pairing) - const shared = new RemoteRuntimeSharedControlConnection(pairing) - - await expect( - sendRemoteRuntimeRequest(pairing, 'status.get', oversizedParams, 1000) - ).rejects.toThrow('JSON payload exceeds') - await expect(cached.request('status.get', oversizedParams, 1000)).rejects.toThrow( - 'JSON payload exceeds' - ) - await expect(shared.request('status.get', oversizedParams, 1000)).rejects.toThrow( - 'JSON payload exceeds' - ) - await expect( - subscribeRemoteRuntimeRequest(pairing, 'terminal.subscribe', oversizedParams, 1000, { - onResponse: vi.fn(), - onError: vi.fn() - }) - ).rejects.toThrow('JSON payload exceeds') - - await new Promise((resolve) => setTimeout(resolve, 10)) - expect(server.clients.size).toBe(0) - cached.close() - shared.close() - }) - - it('rejects shared-control subscription count and byte overload before connecting', async () => { - const { pairing, server } = await createServer() - const connection = new RemoteRuntimeSharedControlConnection(pairing) - const subscriptions = ( - connection as unknown as { - subscriptions: Map - } - ).subscriptions - for (let index = 0; index < REMOTE_RUNTIME_MAX_SUBSCRIPTIONS; index += 1) { - subscriptions.set(`subscription-${index}`, { retainedParamsBytes: 0 }) - } - - await expect( - connection.subscribe('files.watch', null, 1000, { - onResponse: vi.fn(), - onError: vi.fn() - }) - ).rejects.toThrow('subscription limit reached') - - subscriptions.clear() - subscriptions.set('aggregate', { - retainedParamsBytes: REMOTE_RUNTIME_MAX_RETAINED_SUBSCRIPTION_BYTES - }) - await expect( - connection.subscribe('files.watch', null, 1000, { - onResponse: vi.fn(), - onError: vi.fn() - }) - ).rejects.toThrow('subscription memory limit reached') - - expect(server.clients.size).toBe(0) - subscriptions.clear() - connection.close() - }) - - it('bounds aggregate prepared bytes across stalled one-shot sockets', async () => { - const { pairing, server } = await createServer() - const params = { value: 'x'.repeat(3 * 1024 * 1024) } - const retainedBytes = retainedRemoteRuntimeJsonStringBytes( - serializeRemoteRuntimeRpcRequest({ - requestId: '00000000-0000-4000-8000-000000000000', - deviceToken: pairing.deviceToken, - method: 'status.large', - params - }) - ) - const admittedCount = Math.floor(REMOTE_RUNTIME_MAX_PROCESS_PENDING_RPC_BYTES / retainedBytes) - const requests = Array.from({ length: admittedCount }, () => - sendRemoteRuntimeRequest(pairing, 'status.large', params, 60_000).catch(() => undefined) - ) - - await expect( - sendRemoteRuntimeRequest(pairing, 'status.overflow', params, 60_000) - ).rejects.toMatchObject({ code: 'remote_runtime_busy' }) - expect(getRemoteRuntimeRequestAdmissionEvidence().pendingRequestCount).toBe(admittedCount) - - await vi.waitFor(() => expect(server.clients.size).toBe(admittedCount)) - for (const client of server.clients) { - client.close() - } - await Promise.all(requests) - expect(getRemoteRuntimeRequestAdmissionEvidence()).toEqual({ - pendingRequestCount: 0, - retainedBytes: 0 - }) - }) - - it('bounds pending requests and ready waiters while both handshakes stall', async () => { - const { pairing } = await createServer() - const connections: InspectableRequestConnection[] = [ - new RemoteRuntimeRequestConnection(pairing), - new RemoteRuntimeSharedControlConnection(pairing) - ] - - for (const connection of connections) { - const requests = Array.from({ length: REMOTE_RUNTIME_MAX_PENDING_REQUESTS }, (_, index) => - connection.request(`status.${index}`, undefined, 60_000).catch(() => undefined) - ) - await expect(connection.request('status.overflow', undefined, 60_000)).rejects.toMatchObject({ - code: 'remote_runtime_busy' - }) - const state = connection as unknown as RequestAdmissionState - expect(state.pendingRequests.size).toBe(REMOTE_RUNTIME_MAX_PENDING_REQUESTS) - expect(state.readyWaiters).toHaveLength(REMOTE_RUNTIME_MAX_PENDING_REQUESTS) - - connection.close() - await Promise.all(requests) - expect(state.pendingRequests.size).toBe(0) - expect(state.readyWaiters).toHaveLength(0) - } - }) - - it('bounds aggregate prepared request text while both handshakes stall', async () => { - const { pairing } = await createServer() - const params = { value: 'x'.repeat(3 * 1024 * 1024) } - const retainedBytes = retainedRemoteRuntimeJsonStringBytes( - serializeRemoteRuntimeRpcRequest({ - requestId: '00000000-0000-4000-8000-000000000000', - deviceToken: pairing.deviceToken, - method: 'status.large', - params - }) - ) - const admittedCount = Math.floor(REMOTE_RUNTIME_MAX_PENDING_RPC_BYTES / retainedBytes) - expect(admittedCount).toBeGreaterThan(0) - - for (const connection of [ - new RemoteRuntimeRequestConnection(pairing), - new RemoteRuntimeSharedControlConnection(pairing) - ] satisfies InspectableRequestConnection[]) { - const requests = Array.from({ length: admittedCount }, () => - connection.request('status.large', params, 60_000).catch(() => undefined) - ) - await expect(connection.request('status.overflow', params, 60_000)).rejects.toMatchObject({ - code: 'remote_runtime_busy' - }) - const state = connection as unknown as RequestAdmissionState - const retainedTotal = Array.from(state.pendingRequests.values()).reduce( - (total, pending) => total + (pending.preparedRequest?.retainedBytes ?? 0), - 0 - ) - expect(retainedTotal).toBeLessThanOrEqual(REMOTE_RUNTIME_MAX_PENDING_RPC_BYTES) - - connection.close() - await Promise.all(requests) - expect(state.pendingRequests.size).toBe(0) - expect(state.readyWaiters).toHaveLength(0) - } - }) - - it('bounds pending request count across stalled environment connections', async () => { - const { pairing } = await createServer() - const connections: InspectableRequestConnection[] = [ - new RemoteRuntimeRequestConnection(pairing), - new RemoteRuntimeSharedControlConnection(pairing) - ] - const requests = Array.from( - { length: REMOTE_RUNTIME_MAX_PROCESS_PENDING_REQUESTS }, - (_, index) => - connections[index % connections.length]!.request( - `status.${index}`, - undefined, - 60_000 - ).catch(() => undefined) - ) - const overflow = new RemoteRuntimeRequestConnection(pairing) - - await expect(overflow.request('status.overflow', undefined, 60_000)).rejects.toMatchObject({ - code: 'remote_runtime_busy' - }) - await expect( - sendRemoteRuntimeRequest(pairing, 'status.one-shot-overflow', undefined, 60_000) - ).rejects.toMatchObject({ code: 'remote_runtime_busy' }) - expect(getRemoteRuntimeRequestAdmissionEvidence().pendingRequestCount).toBe( - REMOTE_RUNTIME_MAX_PROCESS_PENDING_REQUESTS - ) - - overflow.close() - connections.forEach((connection) => connection.close()) - await Promise.all(requests) - expect(getRemoteRuntimeRequestAdmissionEvidence()).toEqual({ - pendingRequestCount: 0, - retainedBytes: 0 - }) - }) - - it('releases one-shot process admission after a stalled handshake times out', async () => { - const { pairing } = await createServer() - const request = sendRemoteRuntimeRequest(pairing, 'status.timeout', undefined, 25) - - expect(getRemoteRuntimeRequestAdmissionEvidence().pendingRequestCount).toBe(1) - await expect(request).rejects.toMatchObject({ code: 'runtime_timeout' }) - expect(getRemoteRuntimeRequestAdmissionEvidence()).toEqual({ - pendingRequestCount: 0, - retainedBytes: 0 - }) - }) - - it('bounds retained request bytes across stalled environment connections', async () => { - const { pairing } = await createServer() - const params = { value: 'x'.repeat(1024 * 1024) } - const retainedBytes = retainedRemoteRuntimeJsonStringBytes( - serializeRemoteRuntimeRpcRequest({ - requestId: '00000000-0000-4000-8000-000000000000', - deviceToken: pairing.deviceToken, - method: 'status.large', - params - }) - ) - const admittedCount = Math.floor(REMOTE_RUNTIME_MAX_PROCESS_PENDING_RPC_BYTES / retainedBytes) - const connections: InspectableRequestConnection[] = [ - new RemoteRuntimeRequestConnection(pairing), - new RemoteRuntimeSharedControlConnection(pairing), - new RemoteRuntimeRequestConnection(pairing) - ] - expect(Math.ceil(admittedCount / connections.length) * retainedBytes).toBeLessThan( - REMOTE_RUNTIME_MAX_PENDING_RPC_BYTES - ) - const requests = Array.from({ length: admittedCount }, (_, index) => - connections[index % connections.length]!.request('status.large', params, 60_000).catch( - () => undefined - ) - ) - - await expect( - connections[admittedCount % connections.length]!.request('status.overflow', params, 60_000) - ).rejects.toMatchObject({ code: 'remote_runtime_busy' }) - const evidence = getRemoteRuntimeRequestAdmissionEvidence() - expect(evidence.pendingRequestCount).toBe(admittedCount) - expect(evidence.retainedBytes).toBeLessThanOrEqual(REMOTE_RUNTIME_MAX_PROCESS_PENDING_RPC_BYTES) - - connections.forEach((connection) => connection.close()) - await Promise.all(requests) - expect(getRemoteRuntimeRequestAdmissionEvidence()).toEqual({ - pendingRequestCount: 0, - retainedBytes: 0 - }) - }) - - it('releases pending state and ready waiters when stalled handshakes time out', async () => { - const { pairing } = await createServer() - - for (const connection of [ - new RemoteRuntimeRequestConnection(pairing), - new RemoteRuntimeSharedControlConnection(pairing) - ] satisfies InspectableRequestConnection[]) { - const request = connection.request('status.timeout', { value: 'x'.repeat(1024) }, 100) - const state = connection as unknown as RequestAdmissionState - expect(state.pendingRequests.size).toBe(1) - expect(state.readyWaiters).toHaveLength(1) - expect( - Array.from(state.pendingRequests.values())[0]?.preparedRequest?.retainedBytes - ).toBeGreaterThan(0) - - await expect(request).rejects.toBeInstanceOf(Error) - await vi.waitFor(() => expect(state.readyWaiters).toHaveLength(0)) - expect(state.pendingRequests.size).toBe(0) - connection.close() - } - }) - - it('rejects ready waiters beyond the combined request and subscription bound', async () => { - const readyWaiters: Parameters[0]['readyWaiters'] = - [] - const admitted = Array.from({ length: REMOTE_RUNTIME_MAX_READY_WAITERS }, () => - waitForSharedControlReadyWithTimeout({ - readyWaiters, - timeoutMs: 60_000, - open: () => undefined - }).catch(() => undefined) - ) - const open = vi.fn() - - await expect( - waitForSharedControlReadyWithTimeout({ readyWaiters, timeoutMs: 1000, open }) - ).rejects.toMatchObject({ code: 'remote_runtime_busy' }) - expect(readyWaiters).toHaveLength(REMOTE_RUNTIME_MAX_READY_WAITERS) - expect(open).not.toHaveBeenCalled() - - for (const waiter of readyWaiters.splice(0)) { - waiter.reject(new Error('test cleanup')) - } - await Promise.all(admitted) - expect(readyWaiters).toHaveLength(0) - }) -}) - -async function createServer(): Promise<{ pairing: PairingOffer; server: WebSocketServer }> { - const keyPair = generateKeyPair() - const server = new WebSocketServer({ port: 0 }) - servers.push(server) - await new Promise((resolve) => server.once('listening', resolve)) - const address = server.address() as AddressInfo - const pairing = parsePairingCode( - encodePairingOffer({ - v: 2, - endpoint: `ws://127.0.0.1:${address.port}`, - deviceToken: 'device-token', - publicKeyB64: publicKeyToBase64(keyPair.publicKey) - }) - ) - if (!pairing) { - throw new Error('Failed to create test pairing') - } - return { pairing, server } -} diff --git a/src/shared/remote-runtime-prepared-request-admission.ts b/src/shared/remote-runtime-prepared-request-admission.ts deleted file mode 100644 index 5584d4d5103..00000000000 --- a/src/shared/remote-runtime-prepared-request-admission.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { RemoteRuntimeClientError } from './remote-runtime-client-error' -import { - REMOTE_RUNTIME_MAX_PENDING_REQUESTS, - REMOTE_RUNTIME_MAX_PENDING_RPC_BYTES, - REMOTE_RUNTIME_MAX_PROCESS_PENDING_REQUESTS, - REMOTE_RUNTIME_MAX_PROCESS_PENDING_RPC_BYTES, - retainedRemoteRuntimeJsonStringBytes -} from './remote-runtime-memory-limits' -import type { RuntimeRpcResponse } from './runtime-rpc-envelope' - -export type RemoteRuntimePreparedRequest = { - retainedBytes: number - serializedRequest: string | null - releaseProcessAdmission: () => void -} - -export type RemoteRuntimePendingRequest = { - resolve: (response: RuntimeRpcResponse) => void - reject: (error: Error) => void - timeout: ReturnType - preparedRequest: RemoteRuntimePreparedRequest | null -} - -type PendingPreparedRequest = { - preparedRequest?: RemoteRuntimePreparedRequest | null -} - -type ProcessRequestAdmission = { - retainedBytes: number -} - -const processRequestAdmissions = new Set() - -export function prepareRemoteRuntimeRequest( - pendingRequests: ReadonlyMap, - serialize: () => string -): RemoteRuntimePreparedRequest { - if ( - pendingRequests.size >= REMOTE_RUNTIME_MAX_PENDING_REQUESTS || - processRequestAdmissions.size >= REMOTE_RUNTIME_MAX_PROCESS_PENDING_REQUESTS - ) { - throw remoteRuntimeRequestBusyError() - } - const serializedRequest = serialize() - const retainedBytes = retainedRemoteRuntimeJsonStringBytes(serializedRequest) - let alreadyRetainedBytes = 0 - for (const pending of pendingRequests.values()) { - alreadyRetainedBytes += pending.preparedRequest?.retainedBytes ?? 0 - } - if (retainedBytes > REMOTE_RUNTIME_MAX_PENDING_RPC_BYTES - alreadyRetainedBytes) { - throw remoteRuntimeRequestBusyError() - } - const releaseProcessAdmission = reserveProcessRequestAdmission(retainedBytes) - if (!releaseProcessAdmission) { - throw remoteRuntimeRequestBusyError() - } - return { retainedBytes, serializedRequest, releaseProcessAdmission } -} - -export function takeRemoteRuntimePreparedRequest(pending: PendingPreparedRequest): string | null { - const prepared = pending.preparedRequest - if (!prepared || prepared.serializedRequest === null) { - return null - } - const serializedRequest = prepared.serializedRequest - prepared.serializedRequest = null - return serializedRequest -} - -export function releaseRemoteRuntimePreparedRequest(pending: PendingPreparedRequest): void { - const prepared = pending.preparedRequest - if (!prepared) { - return - } - prepared.serializedRequest = null - prepared.releaseProcessAdmission() - prepared.retainedBytes = 0 - pending.preparedRequest = null -} - -export function getRemoteRuntimeRequestAdmissionEvidence(): { - pendingRequestCount: number - retainedBytes: number -} { - let retainedBytes = 0 - for (const admission of processRequestAdmissions) { - retainedBytes += admission.retainedBytes - } - return { pendingRequestCount: processRequestAdmissions.size, retainedBytes } -} - -export function toRemoteRuntimeRequestError(error: unknown): Error { - if (error instanceof Error) { - return error - } - return new RemoteRuntimeClientError('runtime_error', String(error)) -} - -function remoteRuntimeRequestBusyError(): RemoteRuntimeClientError { - return new RemoteRuntimeClientError( - 'remote_runtime_busy', - 'Remote runtime request limit reached; retry after pending requests finish.' - ) -} - -function reserveProcessRequestAdmission(retainedBytes: number): (() => void) | null { - let alreadyRetainedBytes = 0 - for (const admission of processRequestAdmissions) { - alreadyRetainedBytes += admission.retainedBytes - } - if ( - processRequestAdmissions.size >= REMOTE_RUNTIME_MAX_PROCESS_PENDING_REQUESTS || - retainedBytes > REMOTE_RUNTIME_MAX_PROCESS_PENDING_RPC_BYTES - alreadyRetainedBytes - ) { - return null - } - const admission = { retainedBytes } - processRequestAdmissions.add(admission) - return () => { - admission.retainedBytes = 0 - processRequestAdmissions.delete(admission) - } -} diff --git a/src/shared/remote-runtime-request-connection-stale.test.ts b/src/shared/remote-runtime-request-connection-stale.test.ts index aa5f6f89713..765840b8e35 100644 --- a/src/shared/remote-runtime-request-connection-stale.test.ts +++ b/src/shared/remote-runtime-request-connection-stale.test.ts @@ -2,7 +2,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import WebSocket from 'ws' import type { PairingOffer } from './pairing' import { decrypt, encrypt } from './e2ee-crypto' -import { getRemoteRuntimeRequestAdmissionEvidence } from './remote-runtime-prepared-request-admission' import type { RemoteRuntimeWebSocketCallbacks } from './remote-runtime-request-websocket' const opens: FakeOpenedSocket[] = [] @@ -88,33 +87,6 @@ describe('RemoteRuntimeRequestConnection stale socket callbacks', () => { expect(socket.ws.close).toHaveBeenCalledTimes(1) }) - it('releases a pending request when the cached socket send throws', async () => { - const { RemoteRuntimeRequestConnection } = - await import('./remote-runtime-request-connection.js') - const connection = new RemoteRuntimeRequestConnection({ - v: 2, - endpoint: 'ws://127.0.0.1:6768', - deviceToken: 'device-token', - publicKeyB64: Buffer.from(new Uint8Array(32).fill(9)).toString('base64') - }) - const request = connection.request('status.get', undefined, 1000) - const socket = opens[0]! - authenticate(socket) - socket.ws.send = (() => { - throw new Error('send failed') - }) as WebSocket['send'] - - await expect(request).rejects.toThrow('send failed') - expect( - (connection as unknown as { pendingRequests: Map }).pendingRequests.size - ).toBe(0) - expect(getRemoteRuntimeRequestAdmissionEvidence()).toEqual({ - pendingRequestCount: 0, - retainedBytes: 0 - }) - connection.close() - }) - it('ignores stale socket errors and text frames after a replacement socket opens', async () => { vi.useFakeTimers() try { @@ -132,10 +104,6 @@ describe('RemoteRuntimeRequestConnection stale socket callbacks', () => { const firstRejected = expect(first).rejects.toThrow('Timed out') await vi.advanceTimersByTimeAsync(11) await firstRejected - expect(getRemoteRuntimeRequestAdmissionEvidence()).toEqual({ - pendingRequestCount: 0, - retainedBytes: 0 - }) const second = connection.request('status.get', undefined, 1000) authenticate(opens[1]!) @@ -165,10 +133,6 @@ describe('RemoteRuntimeRequestConnection stale socket callbacks', () => { ok: true, result: { state: 'ok' } }) - expect(getRemoteRuntimeRequestAdmissionEvidence()).toEqual({ - pendingRequestCount: 0, - retainedBytes: 0 - }) } finally { vi.useRealTimers() } diff --git a/src/shared/remote-runtime-request-connection.ts b/src/shared/remote-runtime-request-connection.ts index 00acb5492d5..7bd39f09ffb 100644 --- a/src/shared/remote-runtime-request-connection.ts +++ b/src/shared/remote-runtime-request-connection.ts @@ -3,18 +3,7 @@ import WebSocket from 'ws' import type { PairingOffer } from './pairing' import { decrypt, encrypt } from './e2ee-crypto' import type { RuntimeRpcResponse } from './runtime-rpc-envelope' -import { - serializeRemoteRuntimePayload, - serializeRemoteRuntimeRpcRequest -} from './remote-runtime-memory-limits' -import { - prepareRemoteRuntimeRequest, - releaseRemoteRuntimePreparedRequest, - takeRemoteRuntimePreparedRequest, - toRemoteRuntimeRequestError, - type RemoteRuntimePendingRequest, - type RemoteRuntimePreparedRequest -} from './remote-runtime-prepared-request-admission' +import { RemoteRuntimeClientError } from './remote-runtime-client' import { invalidRemoteRuntimeResponseError, parseAuthenticatedFrame, @@ -23,49 +12,44 @@ import { remoteRuntimeTimeoutError, remoteRuntimeUnavailableError } from './remote-runtime-request-frames' -import { - rejectRemoteRuntimeRequestReadyWaiters, - resolveRemoteRuntimeRequestReadyWaiters, - waitForRemoteRuntimeRequestReady, - type RemoteRuntimeRequestReadyWaiter -} from './remote-runtime-request-ready-waiters' import { openRemoteRuntimeWebSocket } from './remote-runtime-request-websocket' type ConnectionState = 'closed' | 'awaiting_ready' | 'awaiting_authenticated' | 'ready' +type PendingRequest = { + resolve: (response: RuntimeRpcResponse) => void + reject: (error: Error) => void + timeout: ReturnType +} + +type ReadyWaiter = { + resolve: () => void + reject: (error: Error) => void +} + const IDLE_CLOSE_MS = 60_000 export class RemoteRuntimeRequestConnection { + private readonly pairing: PairingOffer private state: ConnectionState = 'closed' private ws: WebSocket | null = null private sharedKey: Uint8Array | null = null private socketCleanup: (() => void) | null = null - private readonly pendingRequests = new Map>() - private readonly readyWaiters: RemoteRuntimeRequestReadyWaiter[] = [] + private readonly pendingRequests = new Map>() + private readonly readyWaiters: ReadyWaiter[] = [] private idleCloseTimer: ReturnType | null = null - constructor(private readonly pairing: PairingOffer) {} + constructor(pairing: PairingOffer) { + this.pairing = pairing + } request( method: string, params: unknown, timeoutMs: number ): Promise> { - const requestId = randomUUID() - let preparedRequest: RemoteRuntimePreparedRequest - try { - preparedRequest = prepareRemoteRuntimeRequest(this.pendingRequests, () => - serializeRemoteRuntimeRpcRequest({ - requestId, - deviceToken: this.pairing.deviceToken, - method, - params - }) - ) - } catch (error) { - return Promise.reject(toRemoteRuntimeRequestError(error)) - } this.clearIdleCloseTimer() + const requestId = randomUUID() return new Promise>((resolve, reject) => { const timeout = setTimeout(() => { const pending = this.pendingRequests.get(requestId) @@ -73,7 +57,6 @@ export class RemoteRuntimeRequestConnection { return } this.pendingRequests.delete(requestId) - releaseRemoteRuntimePreparedRequest(pending) const error = remoteRuntimeTimeoutError() pending.reject(error) this.close(error) @@ -81,13 +64,12 @@ export class RemoteRuntimeRequestConnection { this.pendingRequests.set(requestId, { resolve: resolve as (response: RuntimeRpcResponse) => void, reject, - timeout, - preparedRequest + timeout }) void this.ensureReady().then( - () => this.sendRequest(requestId), - (error) => this.rejectPendingRequest(requestId, toRemoteRuntimeRequestError(error)) + () => this.sendRequest(requestId, method, params), + (error) => this.rejectPendingRequest(requestId, toClientError(error)) ) }) } @@ -95,17 +77,17 @@ export class RemoteRuntimeRequestConnection { close(error?: Error): void { const ws = this.ws const cleanup = this.socketCleanup - this.ws = this.sharedKey = null + this.ws = null + this.sharedKey = null this.socketCleanup = null this.state = 'closed' this.clearIdleCloseTimer() const closeError = error ?? remoteRuntimeUnavailableError() - rejectRemoteRuntimeRequestReadyWaiters(this.readyWaiters, closeError) + this.rejectReadyWaiters(closeError) for (const [requestId, pending] of this.pendingRequests) { clearTimeout(pending.timeout) this.pendingRequests.delete(requestId) - releaseRemoteRuntimePreparedRequest(pending) pending.reject(closeError) } @@ -123,14 +105,12 @@ export class RemoteRuntimeRequestConnection { return Promise.resolve() } - const promise = waitForRemoteRuntimeRequestReady(this.readyWaiters) + const promise = new Promise((resolve, reject) => { + this.readyWaiters.push({ resolve, reject }) + }) if (!ws || ws.readyState === WebSocket.CLOSED || ws.readyState === WebSocket.CLOSING) { - try { - this.open() - } catch (error) { - this.close(toRemoteRuntimeRequestError(error)) - } + this.open() } return promise @@ -203,10 +183,7 @@ export class RemoteRuntimeRequestConnection { } this.ws?.send( encrypt( - serializeRemoteRuntimePayload({ - type: 'e2ee_auth', - deviceToken: this.pairing.deviceToken - }), + JSON.stringify({ type: 'e2ee_auth', deviceToken: this.pairing.deviceToken }), sharedKey ) ) @@ -219,7 +196,7 @@ export class RemoteRuntimeRequestConnection { return } this.state = 'ready' - resolveRemoteRuntimeRequestReadyWaiters(this.readyWaiters) + this.resolveReadyWaiters() this.scheduleIdleCloseIfUnused() } @@ -240,12 +217,11 @@ export class RemoteRuntimeRequestConnection { } this.pendingRequests.delete(response.id) clearTimeout(pending.timeout) - releaseRemoteRuntimePreparedRequest(pending) pending.resolve(response) this.scheduleIdleCloseIfUnused() } - private sendRequest(requestId: string): void { + private sendRequest(requestId: string, method: string, params: unknown): void { const pending = this.pendingRequests.get(requestId) const ws = this.ws const sharedKey = this.sharedKey @@ -256,16 +232,17 @@ export class RemoteRuntimeRequestConnection { this.rejectPendingRequest(requestId, remoteRuntimeUnavailableError()) return } - const serializedRequest = takeRemoteRuntimePreparedRequest(pending) - if (serializedRequest === null) { - this.rejectPendingRequest(requestId, remoteRuntimeUnavailableError()) - return - } - try { - ws.send(encrypt(serializedRequest, sharedKey)) - } catch (error) { - this.rejectPendingRequest(requestId, toRemoteRuntimeRequestError(error)) - } + ws.send( + encrypt( + JSON.stringify({ + id: requestId, + deviceToken: this.pairing.deviceToken, + method, + params + }), + sharedKey + ) + ) } private rejectPendingRequest(requestId: string, error: Error): void { @@ -275,11 +252,24 @@ export class RemoteRuntimeRequestConnection { } this.pendingRequests.delete(requestId) clearTimeout(pending.timeout) - releaseRemoteRuntimePreparedRequest(pending) pending.reject(error) this.scheduleIdleCloseIfUnused() } + private resolveReadyWaiters(): void { + const waiters = this.readyWaiters.splice(0) + for (const waiter of waiters) { + waiter.resolve() + } + } + + private rejectReadyWaiters(error: Error): void { + const waiters = this.readyWaiters.splice(0) + for (const waiter of waiters) { + waiter.reject(error) + } + } + private scheduleIdleCloseIfUnused(): void { if (this.pendingRequests.size > 0 || this.readyWaiters.length > 0 || this.state !== 'ready') { return @@ -298,3 +288,10 @@ export class RemoteRuntimeRequestConnection { } } } + +function toClientError(error: unknown): Error { + if (error instanceof Error) { + return error + } + return new RemoteRuntimeClientError('runtime_error', String(error)) +} diff --git a/src/shared/remote-runtime-request-ready-waiters.ts b/src/shared/remote-runtime-request-ready-waiters.ts deleted file mode 100644 index 42b7be1ef89..00000000000 --- a/src/shared/remote-runtime-request-ready-waiters.ts +++ /dev/null @@ -1,29 +0,0 @@ -export type RemoteRuntimeRequestReadyWaiter = { - resolve: () => void - reject: (error: Error) => void -} - -export function waitForRemoteRuntimeRequestReady( - waiters: RemoteRuntimeRequestReadyWaiter[] -): Promise { - return new Promise((resolve, reject) => { - waiters.push({ resolve, reject }) - }) -} - -export function resolveRemoteRuntimeRequestReadyWaiters( - waiters: RemoteRuntimeRequestReadyWaiter[] -): void { - for (const waiter of waiters.splice(0)) { - waiter.resolve() - } -} - -export function rejectRemoteRuntimeRequestReadyWaiters( - waiters: RemoteRuntimeRequestReadyWaiter[], - error: Error -): void { - for (const waiter of waiters.splice(0)) { - waiter.reject(error) - } -} diff --git a/src/shared/remote-runtime-shared-control-admission.ts b/src/shared/remote-runtime-shared-control-admission.ts deleted file mode 100644 index e526ba1bb95..00000000000 --- a/src/shared/remote-runtime-shared-control-admission.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { RemoteRuntimeClientError } from './remote-runtime-client-error' -import { - measureRemoteRuntimeSubscriptionParams, - REMOTE_RUNTIME_MAX_RETAINED_SUBSCRIPTION_BYTES, - REMOTE_RUNTIME_MAX_SUBSCRIPTIONS, - serializeRemoteRuntimeRpcRequest -} from './remote-runtime-memory-limits' -import type { SharedControlLogicalSubscription } from './remote-runtime-shared-control-types' - -export function admitSharedControlSubscription(args: { - subscriptions: Map> - deviceToken: string - method: string - params: unknown -}): number { - if (args.subscriptions.size >= REMOTE_RUNTIME_MAX_SUBSCRIPTIONS) { - throw new RemoteRuntimeClientError( - 'remote_runtime_busy', - 'Remote runtime subscription limit reached; close a subscription and retry.' - ) - } - const retainedParamsBytes = measureRemoteRuntimeSubscriptionParams(args.params) - if ( - retainedSubscriptionBytes(args.subscriptions) + retainedParamsBytes > - REMOTE_RUNTIME_MAX_RETAINED_SUBSCRIPTION_BYTES - ) { - throw new RemoteRuntimeClientError( - 'remote_runtime_busy', - 'Remote runtime subscription memory limit reached; close a subscription and retry.' - ) - } - serializeRequest(args) - return retainedParamsBytes -} - -function serializeRequest(args: { deviceToken: string; method: string; params: unknown }): void { - serializeRemoteRuntimeRpcRequest({ - requestId: '00000000-0000-4000-8000-000000000000', - deviceToken: args.deviceToken, - method: args.method, - params: args.params - }) -} - -function retainedSubscriptionBytes( - subscriptions: Map> -): number { - let bytes = 0 - for (const subscription of subscriptions.values()) { - bytes += subscription.retainedParamsBytes - } - return bytes -} diff --git a/src/shared/remote-runtime-shared-control-connection.test.ts b/src/shared/remote-runtime-shared-control-connection.test.ts index 4af6e4441ea..9e929894a5f 100644 --- a/src/shared/remote-runtime-shared-control-connection.test.ts +++ b/src/shared/remote-runtime-shared-control-connection.test.ts @@ -11,12 +11,6 @@ import { publicKeyToBase64 } from './e2ee-crypto' import { encodePairingOffer, parsePairingCode, type PairingOffer } from './pairing' -import { - REMOTE_RUNTIME_MAX_PENDING_RPC_BYTES, - retainedRemoteRuntimeJsonStringBytes, - serializeRemoteRuntimeRpcRequest -} from './remote-runtime-memory-limits' -import { getRemoteRuntimeRequestAdmissionEvidence } from './remote-runtime-prepared-request-admission' import { RemoteRuntimeSharedControlConnection } from './remote-runtime-shared-control-connection' import * as sharedControlProtocol from './remote-runtime-shared-control-protocol' import { isRuntimeSubscriptionReplayResponse } from './runtime-subscription-replay' @@ -69,40 +63,6 @@ describe('RemoteRuntimeSharedControlConnection', () => { expect('sendSharedControlEncryptedBinary' in sharedControlProtocol).toBe(false) }) - it('releases a pending request when the socket send throws', async () => { - const connection = new RemoteRuntimeSharedControlConnection({ - v: 2, - endpoint: 'ws://127.0.0.1:1', - deviceToken: 'token', - publicKeyB64: Buffer.from(new Uint8Array(32).fill(1)).toString('base64') - }) - const unsafe = connection as unknown as { - state: string - ws: { readyState: number; send: () => void; close: () => void } | null - sharedKey: Uint8Array | null - pendingRequests: Map - } - unsafe.state = 'ready' - unsafe.ws = { - readyState: 1, - send: () => { - throw new Error('send failed') - }, - close: vi.fn() - } - unsafe.sharedKey = new Uint8Array(32).fill(2) - - await expect(connection.request('worktree.ps', undefined, 1000)).rejects.toMatchObject({ - code: 'remote_runtime_unavailable' - }) - expect(unsafe.pendingRequests.size).toBe(0) - expect(getRemoteRuntimeRequestAdmissionEvidence()).toEqual({ - pendingRequestCount: 0, - retainedBytes: 0 - }) - connection.close() - }) - it('replaces a stuck pre-ready socket when a one-shot probe proves reachability', () => { const connection = new RemoteRuntimeSharedControlConnection({ v: 2, @@ -532,15 +492,6 @@ describe('RemoteRuntimeSharedControlConnection', () => { delayedMethods: ['worktree.ps'] }) const connection = new RemoteRuntimeSharedControlConnection(server.pairing) - const unsafe = connection as unknown as { - pendingRequests: Map< - string, - { - method: string - preparedRequest?: { retainedBytes: number; serializedRequest: string | null } | null - } - > - } const timedOut = connection.request('worktree.hang', undefined, 250) void timedOut.catch(() => undefined) @@ -554,82 +505,22 @@ describe('RemoteRuntimeSharedControlConnection', () => { await vi.waitFor(() => expect(server.requests.map(({ method }) => method)).toContain('worktree.ps') ) - expect( - Array.from(unsafe.pendingRequests.values()).every( - (pending) => - pending.preparedRequest?.serializedRequest === null && - pending.preparedRequest.retainedBytes > 0 - ) - ).toBe(true) - expect(getRemoteRuntimeRequestAdmissionEvidence().pendingRequestCount).toBe(2) await expect(timedOut).rejects.toThrow('Timed out') // Why: a single slow method is not evidence that a shared socket is dead; // liveness monitoring owns connection-wide failure detection. expect(connection.getDiagnostics()).toMatchObject({ state: 'ready', pendingRequestCount: 1 }) - expect(getRemoteRuntimeRequestAdmissionEvidence().pendingRequestCount).toBe(1) server.flushDelayedResponses() await expect(survivor).resolves.toMatchObject({ ok: true, response: { ok: true, result: { method: 'worktree.ps' } } }) - expect(unsafe.pendingRequests.size).toBe(0) - expect(getRemoteRuntimeRequestAdmissionEvidence()).toEqual({ - pendingRequestCount: 0, - retainedBytes: 0 - }) expect(server.connectionCount()).toBe(1) connection.close() }) - it('keeps sent request bytes admitted while a ready socket stops responding', async () => { - const server = await createServer({ silentMethods: ['worktree.large'] }) - const connection = new RemoteRuntimeSharedControlConnection(server.pairing) - const params = { value: 'x'.repeat(3 * 1024 * 1024) } - const retainedBytes = retainedRemoteRuntimeJsonStringBytes( - serializeRemoteRuntimeRpcRequest({ - requestId: '00000000-0000-4000-8000-000000000000', - deviceToken: server.pairing.deviceToken, - method: 'worktree.large', - params - }) - ) - const admittedCount = Math.floor(REMOTE_RUNTIME_MAX_PENDING_RPC_BYTES / retainedBytes) - const pendingRequests = ( - connection as unknown as { - pendingRequests: Map< - string, - { preparedRequest?: { serializedRequest: string | null } | null } - > - } - ).pendingRequests - const requests = Array.from({ length: admittedCount }, () => - connection.request('worktree.large', params, 60_000).catch(() => undefined) - ) - await vi.waitFor(() => expect(server.requests).toHaveLength(admittedCount)) - - expect( - Array.from(pendingRequests.values()).every( - (pending) => pending.preparedRequest?.serializedRequest === null - ) - ).toBe(true) - await expect(connection.request('worktree.large', params, 60_000)).rejects.toMatchObject({ - code: 'remote_runtime_busy' - }) - expect(getRemoteRuntimeRequestAdmissionEvidence().retainedBytes).toBeLessThanOrEqual( - REMOTE_RUNTIME_MAX_PENDING_RPC_BYTES - ) - - connection.close() - await Promise.all(requests) - expect(getRemoteRuntimeRequestAdmissionEvidence()).toEqual({ - pendingRequestCount: 0, - retainedBytes: 0 - }) - }) - it('rejects pending requests and records close diagnostics when the socket closes', async () => { const server = await createServer({ closeBeforeResponse: true }) const connection = new RemoteRuntimeSharedControlConnection(server.pairing) diff --git a/src/shared/remote-runtime-shared-control-connection.ts b/src/shared/remote-runtime-shared-control-connection.ts index e96c7007e5c..aa624e7a162 100644 --- a/src/shared/remote-runtime-shared-control-connection.ts +++ b/src/shared/remote-runtime-shared-control-connection.ts @@ -5,7 +5,7 @@ import type { RemoteRuntimeClientError } from './remote-runtime-client-error' import { remoteRuntimeUnavailableError } from './remote-runtime-request-frames' import { openSharedControlSocket } from './remote-runtime-shared-control-open' import { handleSharedControlTextFrame } from './remote-runtime-shared-control-frame-handler' -import * as sharedControlProtocol from './remote-runtime-shared-control-protocol' +import { sendSharedControlEncrypted } from './remote-runtime-shared-control-protocol' import { isSharedControlReady, waitForSharedControlReadyWithTimeout @@ -14,7 +14,10 @@ import { SharedControlReconnectScheduler } from './remote-runtime-shared-control import { requestSharedControl } from './remote-runtime-shared-control-requests' import { SharedControlReadyStableResetTimer } from './remote-runtime-shared-control-stability' import * as sharedControlState from './remote-runtime-shared-control-state' -import * as sharedControlSend from './remote-runtime-shared-control-send' +import { + sendSharedControlRequest, + sendSharedControlSubscription +} from './remote-runtime-shared-control-send' import { closeSharedControlSocket } from './remote-runtime-shared-control-socket-close' import type { RemoteRuntimeSocketLivenessOptions } from './remote-runtime-socket-liveness' import * as sharedControlSubscriptions from './remote-runtime-shared-control-subscriptions' @@ -67,12 +70,12 @@ export class RemoteRuntimeSharedControlConnection { ): Promise> { return requestSharedControl({ pendingRequests: this.pendingRequests, - deviceToken: this.pairing.deviceToken, method, params, timeoutMs, ensureReady: () => this.ensureReadyWithTimeout(timeoutMs), - send: (requestId) => this.sendRequest(requestId) + send: (requestId, requestMethod, requestParams) => + this.sendRequest(requestId, requestMethod, requestParams) }) } @@ -84,7 +87,6 @@ export class RemoteRuntimeSharedControlConnection { ): Promise { return startSharedControlSubscription({ subscriptions: this.subscriptions, - deviceToken: this.pairing.deviceToken, method, params, callbacks, @@ -211,24 +213,21 @@ export class RemoteRuntimeSharedControlConnection { }) } - private sendRequest(requestId: string): void { - sharedControlSend.sendSharedControlRequest({ + private sendRequest(requestId: string, method: string, params: unknown): void { + sendSharedControlRequest({ pendingRequests: this.pendingRequests, requestId, - send: (serialized) => - sharedControlProtocol.sendSharedControlEncryptedSerialized({ - state: this.state, - ws: this.ws, - sharedKey: this.sharedKey, - serialized - }), + deviceToken: this.pairing.deviceToken, + method, + params, + send: (payload) => this.sendEncrypted(payload), reject: (id, error) => sharedControlState.rejectSharedControlPendingRequest(this.pendingRequests, id, error) }) } private sendSubscription(subscription: SharedControlLogicalSubscription): void { - sharedControlSend.sendSharedControlSubscription({ + sendSharedControlSubscription({ subscriptions: this.subscriptions, subscription, deviceToken: this.pairing.deviceToken, @@ -255,7 +254,7 @@ export class RemoteRuntimeSharedControlConnection { } private sendEncrypted(payload: unknown): boolean { - return sharedControlProtocol.sendSharedControlEncrypted({ + return sendSharedControlEncrypted({ state: this.state, ws: this.ws, sharedKey: this.sharedKey, @@ -303,7 +302,8 @@ export class RemoteRuntimeSharedControlConnection { error, clearReadyStableTimer: () => this.readyStableReset.clear() }) - this.ws = this.sharedKey = null + this.ws = null + this.sharedKey = null this.socketCleanup = null this.state = 'closed' } diff --git a/src/shared/remote-runtime-shared-control-keepalive-refresh.test.ts b/src/shared/remote-runtime-shared-control-keepalive-refresh.test.ts index ab2ebc433e1..2f49bc082d1 100644 --- a/src/shared/remote-runtime-shared-control-keepalive-refresh.test.ts +++ b/src/shared/remote-runtime-shared-control-keepalive-refresh.test.ts @@ -26,7 +26,6 @@ describe('shared control keepalive timeout refresh semantics', () => { const pendingRequests = new Map>() const promise = requestSharedControl({ pendingRequests, - deviceToken: 'device-token', method: 'git.status', params: undefined, timeoutMs: 1000, diff --git a/src/shared/remote-runtime-shared-control-protocol.ts b/src/shared/remote-runtime-shared-control-protocol.ts index e4caf38dac6..31ffe89916b 100644 --- a/src/shared/remote-runtime-shared-control-protocol.ts +++ b/src/shared/remote-runtime-shared-control-protocol.ts @@ -2,7 +2,6 @@ import { decrypt } from './e2ee-crypto' import { encrypt } from './e2ee-crypto' import type WebSocket from 'ws' import { RemoteRuntimeClientError } from './remote-runtime-client' -import { serializeRemoteRuntimePayload } from './remote-runtime-memory-limits' import { invalidRemoteRuntimeResponseError, parseRemoteRuntimeRpcFrame @@ -122,35 +121,8 @@ export function sendSharedControlEncrypted(args: { if (!args.ws || args.ws.readyState !== 1 || !args.sharedKey) { return false } - let serialized: string - try { - serialized = serializeRemoteRuntimePayload(args.payload) - } catch { - return false - } - return sendSharedControlEncryptedSerialized({ ...args, serialized }) -} - -export function sendSharedControlEncryptedSerialized(args: { - state: SharedControlConnectionState - ws: WebSocket | null - sharedKey: Uint8Array | null - serialized: string -}): boolean { - if ( - (args.state !== 'ready' && args.state !== 'awaiting_authenticated') || - !args.ws || - args.ws.readyState !== 1 || - !args.sharedKey - ) { - return false - } - try { - args.ws.send(encrypt(args.serialized, args.sharedKey)) - return true - } catch { - return false - } + args.ws.send(encrypt(JSON.stringify(args.payload), args.sharedKey)) + return true } export function toRemoteRuntimeClientError(error: unknown): RemoteRuntimeClientError { diff --git a/src/shared/remote-runtime-shared-control-ready.ts b/src/shared/remote-runtime-shared-control-ready.ts index 23461245d0d..e75244a939d 100644 --- a/src/shared/remote-runtime-shared-control-ready.ts +++ b/src/shared/remote-runtime-shared-control-ready.ts @@ -1,6 +1,4 @@ import WebSocket from 'ws' -import { RemoteRuntimeClientError } from './remote-runtime-client-error' -import { REMOTE_RUNTIME_MAX_READY_WAITERS } from './remote-runtime-memory-limits' import { remoteRuntimeUnavailableError } from './remote-runtime-request-frames' import type { SharedControlConnectionState, @@ -20,14 +18,6 @@ export function waitForSharedControlReadyWithTimeout(args: { timeoutMs: number open: () => void }): Promise { - if (args.readyWaiters.length >= REMOTE_RUNTIME_MAX_READY_WAITERS) { - return Promise.reject( - new RemoteRuntimeClientError( - 'remote_runtime_busy', - 'Remote runtime connection wait limit reached; retry after pending work finishes.' - ) - ) - } return new Promise((resolve, reject) => { let settled = false let waiter!: SharedControlReadyWaiter @@ -61,14 +51,6 @@ export function waitForSharedControlReadyWithTimeout(args: { } } args.readyWaiters.push(waiter) - try { - args.open() - } catch (error) { - const index = args.readyWaiters.indexOf(waiter) - if (index >= 0) { - args.readyWaiters.splice(index, 1) - } - waiter.reject(error instanceof Error ? error : remoteRuntimeUnavailableError(String(error))) - } + args.open() }) } diff --git a/src/shared/remote-runtime-shared-control-requests.ts b/src/shared/remote-runtime-shared-control-requests.ts index f7a026f745f..759ac4f8757 100644 --- a/src/shared/remote-runtime-shared-control-requests.ts +++ b/src/shared/remote-runtime-shared-control-requests.ts @@ -1,70 +1,45 @@ import { randomUUID } from 'node:crypto' -import { serializeRemoteRuntimeRpcRequest } from './remote-runtime-memory-limits' -import { - prepareRemoteRuntimeRequest, - releaseRemoteRuntimePreparedRequest, - type RemoteRuntimePreparedRequest -} from './remote-runtime-prepared-request-admission' import { remoteRuntimeTimeoutError } from './remote-runtime-request-frames' import type { RuntimeRpcResponse } from './runtime-rpc-envelope' import { toRemoteRuntimeClientError } from './remote-runtime-shared-control-protocol' import { rejectSharedControlPendingRequest } from './remote-runtime-shared-control-state' import type { SharedControlPendingRequest } from './remote-runtime-shared-control-types' -const MAX_RETAINED_METHOD_CHARS = 256 - export function requestSharedControl(args: { pendingRequests: Map> - deviceToken: string method: string params: unknown timeoutMs: number ensureReady: () => Promise - send: (requestId: string) => void + send: (requestId: string, method: string, params: unknown) => void // Why: default off — ordinary short RPCs keep an absolute deadline. Only // long-polls routed through this path opt in so keepalives extend them. refreshTimeoutOnKeepalive?: boolean }): Promise> { - const { ensureReady, pendingRequests, send } = args const requestId = randomUUID() - let preparedRequest: RemoteRuntimePreparedRequest - try { - preparedRequest = prepareRemoteRuntimeRequest(pendingRequests, () => - serializeRemoteRuntimeRpcRequest({ - requestId, - deviceToken: args.deviceToken, - method: args.method, - params: args.params - }) - ) - } catch (error) { - return Promise.reject(error) - } return new Promise>((resolve, reject) => { const timeout = setTimeout(() => { - const pending = pendingRequests.get(requestId) + const pending = args.pendingRequests.get(requestId) if (!pending) { return } - pendingRequests.delete(requestId) - releaseRemoteRuntimePreparedRequest(pending) + args.pendingRequests.delete(requestId) // Why: one stalled method does not prove the shared socket is dead; // socket liveness owns connection-wide teardown so other RPCs survive. pending.reject(remoteRuntimeTimeoutError()) }, args.timeoutMs) - pendingRequests.set(requestId, { - method: args.method.slice(0, MAX_RETAINED_METHOD_CHARS), + args.pendingRequests.set(requestId, { + method: args.method, resolve: resolve as (response: RuntimeRpcResponse) => void, reject, timeout, - preparedRequest, refreshTimeoutOnKeepalive: args.refreshTimeoutOnKeepalive ?? false }) - void ensureReady().then( - () => send(requestId), + void args.ensureReady().then( + () => args.send(requestId, args.method, args.params), (error) => rejectSharedControlPendingRequest( - pendingRequests, + args.pendingRequests, requestId, toRemoteRuntimeClientError(error) ) diff --git a/src/shared/remote-runtime-shared-control-send.ts b/src/shared/remote-runtime-shared-control-send.ts index 2a7ff16cfa5..2830eb78be4 100644 --- a/src/shared/remote-runtime-shared-control-send.ts +++ b/src/shared/remote-runtime-shared-control-send.ts @@ -1,5 +1,4 @@ import { remoteRuntimeUnavailableError } from './remote-runtime-request-frames' -import { takeRemoteRuntimePreparedRequest } from './remote-runtime-prepared-request-admission' import { finishSharedControlSubscription } from './remote-runtime-shared-control-state' import type { SharedControlLogicalSubscription, @@ -9,15 +8,23 @@ import type { export function sendSharedControlRequest(args: { pendingRequests: Map> requestId: string - send: (serializedRequest: string) => boolean + deviceToken: string + method: string + params: unknown + send: (payload: unknown) => boolean reject: (requestId: string, error: Error) => void }): void { - const pending = args.pendingRequests.get(args.requestId) - if (!pending) { + if (!args.pendingRequests.has(args.requestId)) { return } - const serializedRequest = takeRemoteRuntimePreparedRequest(pending) - if (serializedRequest === null || !args.send(serializedRequest)) { + if ( + !args.send({ + id: args.requestId, + deviceToken: args.deviceToken, + method: args.method, + params: args.params + }) + ) { args.reject(args.requestId, remoteRuntimeUnavailableError()) } } diff --git a/src/shared/remote-runtime-shared-control-socket-generation.test.ts b/src/shared/remote-runtime-shared-control-socket-generation.test.ts index 37a95f0c12a..21a85734d05 100644 --- a/src/shared/remote-runtime-shared-control-socket-generation.test.ts +++ b/src/shared/remote-runtime-shared-control-socket-generation.test.ts @@ -19,7 +19,6 @@ describe('SharedControlSocketGeneration', () => { requestId: 'subscription-1', method: 'session.tabs.subscribeAll', params: null, - retainedParamsBytes: 0, callbacks: { onResponse: vi.fn(), onError }, sent: true, closed: false, @@ -33,7 +32,6 @@ describe('SharedControlSocketGeneration', () => { requestId: 'subscription-2', method: 'runtime.clientEvents.subscribe', params: null, - retainedParamsBytes: 0, callbacks: { onResponse: vi.fn(), onError: throwingOnError }, sent: true, closed: false, diff --git a/src/shared/remote-runtime-shared-control-state.ts b/src/shared/remote-runtime-shared-control-state.ts index 7a6a32437cd..0190398e3c8 100644 --- a/src/shared/remote-runtime-shared-control-state.ts +++ b/src/shared/remote-runtime-shared-control-state.ts @@ -1,5 +1,4 @@ import type { RemoteRuntimeClientError } from './remote-runtime-client-error' -import { releaseRemoteRuntimePreparedRequest } from './remote-runtime-prepared-request-admission' import { remoteRuntimeUnavailableError } from './remote-runtime-request-frames' import type { RuntimeRpcResponse } from './runtime-rpc-envelope' import type { @@ -44,7 +43,6 @@ export function rejectSharedControlPendingRequest( } pendingRequests.delete(requestId) clearTimeout(pending.timeout) - releaseRemoteRuntimePreparedRequest(pending) pending.reject(error) } @@ -59,7 +57,6 @@ export function resolveSharedControlPendingResponse( } pendingRequests.delete(requestId) clearTimeout(pending.timeout) - releaseRemoteRuntimePreparedRequest(pending) pending.resolve(response) } @@ -101,7 +98,6 @@ export function rejectAllSharedControlPendingRequests( for (const [requestId, pending] of pendingRequests) { clearTimeout(pending.timeout) pendingRequests.delete(requestId) - releaseRemoteRuntimePreparedRequest(pending) pending.reject(closeError) } } diff --git a/src/shared/remote-runtime-shared-control-subscription-start.ts b/src/shared/remote-runtime-shared-control-subscription-start.ts index 2b22aaabc5b..cdc198f54c3 100644 --- a/src/shared/remote-runtime-shared-control-subscription-start.ts +++ b/src/shared/remote-runtime-shared-control-subscription-start.ts @@ -1,6 +1,5 @@ import { randomUUID } from 'node:crypto' import { remoteRuntimeUnavailableError } from './remote-runtime-request-frames' -import { admitSharedControlSubscription } from './remote-runtime-shared-control-admission' import { createSharedControlSubscription } from './remote-runtime-shared-control-subscriptions' import { finishSharedControlSubscription } from './remote-runtime-shared-control-state' import type { @@ -11,7 +10,6 @@ import type { export async function startSharedControlSubscription(args: { subscriptions: Map> - deviceToken: string method: string params: unknown callbacks: SharedControlSubscriptionCallbacks @@ -19,18 +17,11 @@ export async function startSharedControlSubscription(args: { sendSubscription: (subscription: SharedControlLogicalSubscription) => void closeSubscription: (requestId: string) => void }): Promise { - const retainedParamsBytes = admitSharedControlSubscription({ - subscriptions: args.subscriptions, - deviceToken: args.deviceToken, - method: args.method, - params: args.params - }) const requestId = randomUUID() const subscription = createSharedControlSubscription({ requestId, method: args.method, params: args.params, - retainedParamsBytes, callbacks: args.callbacks }) args.subscriptions.set(requestId, subscription as SharedControlLogicalSubscription) diff --git a/src/shared/remote-runtime-shared-control-subscriptions.test.ts b/src/shared/remote-runtime-shared-control-subscriptions.test.ts index de5d5718b55..a1b843f7488 100644 --- a/src/shared/remote-runtime-shared-control-subscriptions.test.ts +++ b/src/shared/remote-runtime-shared-control-subscriptions.test.ts @@ -17,7 +17,6 @@ function makeSubscriptions(): { requestId: 'req-1', method: 'runtime.clientEvents.subscribe', params: null, - retainedParamsBytes: 0, callbacks: { onResponse: vi.fn(), onError: vi.fn() } }) subscriptions.set(subscription.requestId, subscription) diff --git a/src/shared/remote-runtime-shared-control-subscriptions.ts b/src/shared/remote-runtime-shared-control-subscriptions.ts index 5b18ce801f0..54e35a784a2 100644 --- a/src/shared/remote-runtime-shared-control-subscriptions.ts +++ b/src/shared/remote-runtime-shared-control-subscriptions.ts @@ -14,14 +14,12 @@ export function createSharedControlSubscription(args: { requestId: string method: string params: unknown - retainedParamsBytes: number callbacks: SharedControlSubscriptionCallbacks }): SharedControlLogicalSubscription { return { requestId: args.requestId, method: args.method, params: args.params, - retainedParamsBytes: args.retainedParamsBytes, callbacks: args.callbacks, sent: false, closed: false, diff --git a/src/shared/remote-runtime-shared-control-types.ts b/src/shared/remote-runtime-shared-control-types.ts index c6e55134762..98babc9d5a1 100644 --- a/src/shared/remote-runtime-shared-control-types.ts +++ b/src/shared/remote-runtime-shared-control-types.ts @@ -1,6 +1,5 @@ import type { RuntimeRpcResponse } from './runtime-rpc-envelope' import type { RemoteRuntimeClientError } from './remote-runtime-client-error' -import type { RemoteRuntimePreparedRequest } from './remote-runtime-prepared-request-admission' export type SharedControlConnectionState = | 'closed' @@ -13,7 +12,6 @@ export type SharedControlPendingRequest = { resolve: (response: RuntimeRpcResponse) => void reject: (error: Error) => void timeout: ReturnType - preparedRequest?: RemoteRuntimePreparedRequest | null // Why: keepalives on the shared socket are armed for an unrelated long-poll, // not this request. Only requests that opt in (long-polls issued via the // short-RPC path) may have their deadline refreshed by a keepalive; ordinary @@ -33,7 +31,6 @@ export type SharedControlLogicalSubscription = { requestId: string method: string params: unknown - retainedParamsBytes: number callbacks: SharedControlSubscriptionCallbacks sent: boolean closed: boolean diff --git a/src/shared/repo-icon.test.ts b/src/shared/repo-icon.test.ts index f8d54c437f0..095855e9ca1 100644 --- a/src/shared/repo-icon.test.ts +++ b/src/shared/repo-icon.test.ts @@ -1,19 +1,6 @@ import { describe, expect, it } from 'vitest' import { githubAvatarIcon, sanitizeRepoIcon } from './repo-icon' -const PNG_1X1_BASE64 = - 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=' - -function pngBase64(width: number, height: number): string { - const bytes = Buffer.alloc(24) - Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(bytes) - bytes.writeUInt32BE(13, 8) - bytes.write('IHDR', 12, 'ascii') - bytes.writeUInt32BE(width, 16) - bytes.writeUInt32BE(height, 20) - return bytes.toString('base64') -} - describe('sanitizeRepoIcon', () => { it('accepts lucide, emoji, and supported image icons', () => { expect(sanitizeRepoIcon({ type: 'lucide', name: 'Folder' })).toEqual({ @@ -64,23 +51,23 @@ describe('sanitizeRepoIcon', () => { expect( sanitizeRepoIcon({ type: 'image', - src: `data:image/png;base64,${PNG_1X1_BASE64}`, + src: 'data:image/png;base64,aGVsbG8=', source: 'upload' }) ).toEqual({ type: 'image', - src: `data:image/png;base64,${PNG_1X1_BASE64}`, + src: 'data:image/png;base64,aGVsbG8=', source: 'upload' }) expect( sanitizeRepoIcon({ type: 'image', - src: `data:image/png;base64,${PNG_1X1_BASE64}`, + src: 'data:image/png;base64,aGVsbG8=', source: 'file' }) ).toEqual({ type: 'image', - src: `data:image/png;base64,${PNG_1X1_BASE64}`, + src: 'data:image/png;base64,aGVsbG8=', source: 'file' }) }) @@ -97,13 +84,6 @@ describe('sanitizeRepoIcon', () => { source: 'favicon' }) ).toBeUndefined() - expect( - sanitizeRepoIcon({ - type: 'image', - src: `data:image/png;base64,${pngBase64(32_769, 1)}`, - source: 'upload' - }) - ).toBeUndefined() expect( sanitizeRepoIcon({ type: 'image', diff --git a/src/shared/repo-icon.ts b/src/shared/repo-icon.ts index 55e9e29fb76..110448f5023 100644 --- a/src/shared/repo-icon.ts +++ b/src/shared/repo-icon.ts @@ -1,5 +1,3 @@ -import { validateRasterImageDataUri } from './image-data-uri' - export type RepoIconImageSource = 'upload' | 'file' | 'favicon' | 'github' export type RepoIcon = @@ -65,10 +63,7 @@ function normalizeGitHubAvatarHost(rawHost?: string): string { function isSupportedImageSrc(src: string, source: RepoIconImageSource): boolean { if (source === 'upload' || source === 'file') { - return ( - /^data:image\/png;base64,[A-Za-z0-9+/=\s]+$/i.test(src) && - validateRasterImageDataUri(src) !== null - ) + return /^data:image\/png;base64,[A-Za-z0-9+/=\s]+$/i.test(src) } let url: URL diff --git a/src/shared/runtime-bootstrap.ts b/src/shared/runtime-bootstrap.ts index efa224d749b..993ab5e023d 100644 --- a/src/shared/runtime-bootstrap.ts +++ b/src/shared/runtime-bootstrap.ts @@ -1,5 +1,4 @@ import { join } from 'node:path' -import { assertJsonTextStructureWithinLimits } from './json-text-structure-limit' export type RuntimeTransportMetadata = | { @@ -23,18 +22,6 @@ export type RuntimeMetadata = { startedAt: number } -export const MAX_RUNTIME_METADATA_FILE_BYTES = 64 * 1024 -export const MAX_RUNTIME_METADATA_JSON_STRUCTURAL_TOKENS = 16 * 1024 -export const MAX_RUNTIME_METADATA_JSON_NESTING_DEPTH = 32 - -export function parseRuntimeMetadataJson(serialized: string): RuntimeMetadata { - assertJsonTextStructureWithinLimits(serialized, { - structuralTokens: MAX_RUNTIME_METADATA_JSON_STRUCTURAL_TOKENS, - nestingDepth: MAX_RUNTIME_METADATA_JSON_NESTING_DEPTH - }) - return JSON.parse(serialized) as RuntimeMetadata -} - // Why: the CLI must handle metadata files written by older Orca versions that // used a singular `transport` field. This helper extracts the first transport // matching the given kinds from either the new `transports` array or the diff --git a/src/shared/runtime-environment-store.test.ts b/src/shared/runtime-environment-store.test.ts index f58093337fa..0397b0e1aaa 100644 --- a/src/shared/runtime-environment-store.test.ts +++ b/src/shared/runtime-environment-store.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, rmSync, truncateSync, writeFileSync } from 'node:fs' +import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' @@ -6,9 +6,7 @@ import { encodePairingOffer } from './pairing' import { RuntimeEnvironmentStoreError, addEnvironmentFromPairingCode, - getEnvironmentStorePath, listEnvironments, - MAX_RUNTIME_ENVIRONMENT_STORE_FILE_BYTES, markEnvironmentUsed, updateEnvironmentFromPairingCode } from './runtime-environment-store' @@ -127,31 +125,4 @@ describe('runtime environment store', () => { runtimeId: 'runtime-2' }) }) - - it('rejects an oversized sparse environment store before parsing it', () => { - const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-env-store-bound-')) - tempDirs.push(userDataPath) - const path = getEnvironmentStorePath(userDataPath) - writeFileSync(path, '{"version":1,"environments":[]}') - truncateSync(path, MAX_RUNTIME_ENVIRONMENT_STORE_FILE_BYTES + 1) - - expect(() => listEnvironments(userDataPath)).toThrow(RuntimeEnvironmentStoreError) - }) - - it('rejects an oversized write without replacing the durable environment list', () => { - const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-env-store-write-bound-')) - tempDirs.push(userDataPath) - const first = addEnvironmentFromPairingCode(userDataPath, { - name: 'dev box', - pairingCode: pairingCode() - }) - - expect(() => - addEnvironmentFromPairingCode(userDataPath, { - name: 'x'.repeat(MAX_RUNTIME_ENVIRONMENT_STORE_FILE_BYTES), - pairingCode: pairingCode('ws://192.0.2.10:6768') - }) - ).toThrow(RuntimeEnvironmentStoreError) - expect(listEnvironments(userDataPath)).toEqual([first]) - }) }) diff --git a/src/shared/runtime-environment-store.ts b/src/shared/runtime-environment-store.ts index 4211eb18da8..a546940597a 100644 --- a/src/shared/runtime-environment-store.ts +++ b/src/shared/runtime-environment-store.ts @@ -1,11 +1,8 @@ import { randomUUID } from 'node:crypto' -import { existsSync } from 'node:fs' +import { existsSync, readFileSync } from 'node:fs' import { join } from 'node:path' -import { JsonStringifyByteLimitError } from './node-bounded-json-stringify' -import { readNodeFileSyncWithinLimit } from './node-bounded-file-reader' import { parsePairingCode, type PairingOffer } from './pairing' -import { writeSecureJsonFileWithinLimit } from './bounded-secure-json-file' -import { hardenExistingSecureFile } from './secure-file' +import { hardenExistingSecureFile, writeSecureJsonFile } from './secure-file' import { createEnvironmentFromPairingOffer, getPreferredPairingOffer, @@ -17,7 +14,6 @@ import { } from './runtime-environments' const ENVIRONMENTS_FILE = 'orca-environments.json' -export const MAX_RUNTIME_ENVIRONMENT_STORE_FILE_BYTES = 1024 * 1024 export type RuntimeEnvironmentStoreErrorCode = 'invalid_argument' | 'runtime_error' @@ -204,13 +200,7 @@ function readEnvironmentStore(userDataPath: string): RuntimeEnvironmentStore { } try { hardenExistingSecureFile(path) - const parsed = RuntimeEnvironmentStoreSchema.parse( - JSON.parse( - readNodeFileSyncWithinLimit(path, MAX_RUNTIME_ENVIRONMENT_STORE_FILE_BYTES).buffer.toString( - 'utf8' - ) - ) - ) + const parsed = RuntimeEnvironmentStoreSchema.parse(JSON.parse(readFileSync(path, 'utf8'))) return { version: 1, environments: parsed.environments @@ -227,19 +217,5 @@ function readEnvironmentStore(userDataPath: string): RuntimeEnvironmentStore { function writeEnvironmentStore(userDataPath: string, store: RuntimeEnvironmentStore): void { const path = getEnvironmentStorePath(userDataPath) - try { - writeSecureJsonFileWithinLimit( - path, - RuntimeEnvironmentStoreSchema.parse(store), - MAX_RUNTIME_ENVIRONMENT_STORE_FILE_BYTES - ) - } catch (error) { - if (error instanceof JsonStringifyByteLimitError) { - throw new RuntimeEnvironmentStoreError( - 'runtime_error', - `Could not write Orca environments at ${path}; the store exceeds its durable capacity.` - ) - } - throw error - } + writeSecureJsonFile(path, RuntimeEnvironmentStoreSchema.parse(store)) } diff --git a/src/shared/runtime-rpc-call-queue.test.ts b/src/shared/runtime-rpc-call-queue.test.ts index b0c43e24078..5a3fcdf9348 100644 --- a/src/shared/runtime-rpc-call-queue.test.ts +++ b/src/shared/runtime-rpc-call-queue.test.ts @@ -1,9 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { - isBackgroundRuntimeMethod, - RuntimeRpcCallQueueOverloadError, - RuntimeRpcCallQueuePool -} from './runtime-rpc-call-queue' +import { isBackgroundRuntimeMethod, RuntimeRpcCallQueuePool } from './runtime-rpc-call-queue' describe('runtime RPC call queue', () => { it('classifies per-worktree decoration lookups as background work', () => { @@ -83,86 +79,4 @@ describe('runtime RPC call queue', () => { ) expect(started).toEqual(Array.from({ length: 71 }, (_, index) => index)) }) - - it('rejects per-selector overload and accepts work after the queue drains', async () => { - const queue = new RuntimeRpcCallQueuePool(1, 1, 2, 10) - let releaseFirst: () => void = () => {} - const first = queue.enqueue('runtime-a', 'status.get', async () => { - await new Promise((resolve) => { - releaseFirst = resolve - }) - return 'first' - }) - const second = queue.enqueue('runtime-a', 'status.get', async () => 'second') - const third = queue.enqueue('runtime-a', 'status.get', async () => 'third') - - await expect(queue.enqueue('runtime-a', 'status.get', async () => 'overflow')).rejects.toEqual( - expect.objectContaining({ - code: 'runtime_rpc_queue_overloaded', - scope: 'selector' - }) - ) - - releaseFirst() - await expect(Promise.all([first, second, third])).resolves.toEqual(['first', 'second', 'third']) - await expect(queue.enqueue('runtime-a', 'status.get', async () => 'recovered')).resolves.toBe( - 'recovered' - ) - }) - - it('caps queued calls across selectors and recovers after draining', async () => { - const queue = new RuntimeRpcCallQueuePool(1, 1, 10, 2) - const releases: (() => void)[] = [] - const blockers = ['runtime-a', 'runtime-b'].map((selector) => - queue.enqueue(selector, 'status.get', async () => { - await new Promise((resolve) => releases.push(resolve)) - }) - ) - const queuedA = queue.enqueue('runtime-a', 'status.get', async () => 'queued-a') - const queuedB = queue.enqueue('runtime-b', 'status.get', async () => 'queued-b') - - const overload = queue.enqueue('runtime-c', 'status.get', async () => 'overflow') - await expect(overload).rejects.toBeInstanceOf(RuntimeRpcCallQueueOverloadError) - await expect(overload).rejects.toMatchObject({ scope: 'global' }) - - releases.splice(0).forEach((release) => release()) - await expect(Promise.all([...blockers, queuedA, queuedB])).resolves.toEqual([ - undefined, - undefined, - 'queued-a', - 'queued-b' - ]) - await expect(queue.enqueue('runtime-c', 'status.get', async () => 'recovered')).resolves.toBe( - 'recovered' - ) - }) - - it('caps retained call bytes across active and queued work, then recovers', async () => { - const queue = new RuntimeRpcCallQueuePool(1, 1, 10, 10, 10) - let releaseFirst: () => void = () => {} - let firstStarted = false - const first = queue.enqueue( - 'runtime-a', - 'status.get', - async () => { - firstStarted = true - await new Promise((resolve) => { - releaseFirst = resolve - }) - return 'first' - }, - 10 - ) - - await vi.waitFor(() => expect(firstStarted).toBe(true)) - await expect( - queue.enqueue('runtime-b', 'status.get', async () => 'overflow', 1) - ).rejects.toMatchObject({ scope: 'memory' }) - - releaseFirst() - await expect(first).resolves.toBe('first') - await expect( - queue.enqueue('runtime-b', 'status.get', async () => 'recovered', 10) - ).resolves.toBe('recovered') - }) }) diff --git a/src/shared/runtime-rpc-call-queue.ts b/src/shared/runtime-rpc-call-queue.ts index b9653e46493..4e21970afce 100644 --- a/src/shared/runtime-rpc-call-queue.ts +++ b/src/shared/runtime-rpc-call-queue.ts @@ -1,23 +1,8 @@ -import { REMOTE_RUNTIME_MAX_PREPARED_RPC_BYTES } from './remote-runtime-memory-limits' - const DEFAULT_REMOTE_RUNTIME_CALL_CONCURRENCY = 8 const DEFAULT_REMOTE_RUNTIME_BACKGROUND_CALL_CONCURRENCY = 2 -export const RUNTIME_RPC_MAX_QUEUED_CALLS_PER_SELECTOR = 256 -export const RUNTIME_RPC_MAX_QUEUED_CALLS_TOTAL = 2_048 -export const RUNTIME_RPC_QUEUE_OVERLOAD_CODE = 'runtime_rpc_queue_overloaded' - -export class RuntimeRpcCallQueueOverloadError extends Error { - readonly code = RUNTIME_RPC_QUEUE_OVERLOAD_CODE - - constructor(readonly scope: 'selector' | 'global' | 'memory') { - super('Remote runtime call queue is full; retry after current calls finish.') - this.name = 'RuntimeRpcCallQueueOverloadError' - } -} type QueuedRuntimeCall = { background: boolean - retainedBytes: number run: () => Promise resolve: (value: T) => void reject: (error: unknown) => void @@ -49,51 +34,23 @@ export function isBackgroundRuntimeMethod(method: string): boolean { export class RuntimeRpcCallQueuePool { private readonly queues = new Map() - private queuedCallCount = 0 - private retainedCallBytes = 0 constructor( private readonly concurrency = DEFAULT_REMOTE_RUNTIME_CALL_CONCURRENCY, - private readonly backgroundConcurrency = DEFAULT_REMOTE_RUNTIME_BACKGROUND_CALL_CONCURRENCY, - private readonly maxQueuedPerSelector = RUNTIME_RPC_MAX_QUEUED_CALLS_PER_SELECTOR, - private readonly maxQueuedTotal = RUNTIME_RPC_MAX_QUEUED_CALLS_TOTAL, - private readonly maxRetainedBytes = REMOTE_RUNTIME_MAX_PREPARED_RPC_BYTES + private readonly backgroundConcurrency = DEFAULT_REMOTE_RUNTIME_BACKGROUND_CALL_CONCURRENCY ) {} - enqueue( - selector: string, - method: string, - run: () => Promise, - retainedBytes = 0 - ): Promise { - if (this.queuedCallCount >= this.maxQueuedTotal) { - return Promise.reject(new RuntimeRpcCallQueueOverloadError('global')) - } - const existingQueue = this.queues.get(selector) - if (existingQueue && this.queuedCount(existingQueue) >= this.maxQueuedPerSelector) { - return Promise.reject(new RuntimeRpcCallQueueOverloadError('selector')) - } - if ( - !Number.isSafeInteger(retainedBytes) || - retainedBytes < 0 || - this.retainedCallBytes + retainedBytes > this.maxRetainedBytes - ) { - return Promise.reject(new RuntimeRpcCallQueueOverloadError('memory')) - } - + enqueue(selector: string, method: string, run: () => Promise): Promise { const queue = this.getQueue(selector) return new Promise((resolve, reject) => { const call: QueuedRuntimeCall = { background: isBackgroundRuntimeMethod(method), - retainedBytes, run, resolve, reject } const targetQueue = call.background ? queue.background : queue.foreground targetQueue.push(call as QueuedRuntimeCall) - this.queuedCallCount += 1 - this.retainedCallBytes += retainedBytes this.pump(selector, queue) }) } @@ -139,7 +96,6 @@ export class RuntimeRpcCallQueuePool { runPromise = Promise.reject(error) } void runPromise.then(call.resolve, call.reject).finally(() => { - this.retainedCallBytes = Math.max(0, this.retainedCallBytes - call.retainedBytes) queue.active = Math.max(0, queue.active - 1) if (call.background) { queue.backgroundActive = Math.max(0, queue.backgroundActive - 1) @@ -159,7 +115,6 @@ export class RuntimeRpcCallQueuePool { } const call = queue.foreground[queue.foregroundHead] queue.foregroundHead += 1 - this.queuedCallCount = Math.max(0, this.queuedCallCount - 1) this.compactForeground(queue) return call } @@ -170,7 +125,6 @@ export class RuntimeRpcCallQueuePool { } const call = queue.background[queue.backgroundHead] queue.backgroundHead += 1 - this.queuedCallCount = Math.max(0, this.queuedCallCount - 1) this.compactBackground(queue) return call } @@ -199,13 +153,4 @@ export class RuntimeRpcCallQueuePool { queue.backgroundHead >= queue.background.length ) } - - private queuedCount(queue: RuntimeCallQueue): number { - return ( - queue.foreground.length - - queue.foregroundHead + - queue.background.length - - queue.backgroundHead - ) - } } diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index 46679391536..c90762c65e8 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -40,7 +40,6 @@ import type { StartupCommandDelivery } from './codex-startup-delivery' import type { RemoteServerUpdateSupport } from './remote-server-update' import type { ExecutionHostId } from './execution-host' import type { PtyIncarnationId } from './pty-incarnation' -import type { RasterImageDimensions } from './raster-image-dimensions' export type { RuntimeMarkdownReadTabResult, RuntimeMarkdownSaveTabResult } @@ -408,7 +407,6 @@ export type RuntimeFilePreviewResult = { isBinary: boolean isImage?: boolean mimeType?: string - imageDimensions?: RasterImageDimensions } export type RuntimeFileReadChunkResult = { diff --git a/src/shared/search-subprocess-lines.test.ts b/src/shared/search-subprocess-lines.test.ts deleted file mode 100644 index 3344776072e..00000000000 --- a/src/shared/search-subprocess-lines.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { SearchSubprocessLineAccumulator } from './search-subprocess-lines' - -describe('SearchSubprocessLineAccumulator', () => { - it('preserves UTF-8 records split across raw byte chunks', () => { - const parser = new SearchSubprocessLineAccumulator(32) - const bytes = Buffer.from('first🐋\nsecond') - const lines: string[] = [] - - expect(parser.push(bytes.subarray(0, 7), (line) => lines.push(line))).toBe(true) - expect(parser.push(bytes.subarray(7), (line) => lines.push(line))).toBe(true) - - expect(lines).toEqual(['first🐋']) - expect(parser.finish()).toBe('second') - }) - - it('accepts an exact byte limit and rejects the next byte without decoding it', () => { - const parser = new SearchSubprocessLineAccumulator(4) - const lines: string[] = [] - - expect(parser.push(Buffer.from('four\n'), (line) => lines.push(line))).toBe(true) - expect(parser.push(Buffer.from('fives'), (line) => lines.push(line))).toBe(false) - - expect(lines).toEqual(['four']) - expect(parser.finish()).toBeNull() - }) - - it('preserves empty lines and line order within one chunk', () => { - const parser = new SearchSubprocessLineAccumulator(8) - const lines: string[] = [] - - expect(parser.push(Buffer.from('\na\n\n'), (line) => lines.push(line))).toBe(true) - - expect(lines).toEqual(['', 'a', '']) - }) - - it('retains one growable buffer for adversarial one-byte fragments', () => { - const parser = new SearchSubprocessLineAccumulator(256 * 1024) - const byte = Buffer.from('x') - let accepted = true - - for (let index = 0; index < 200_000; index += 1) { - accepted = parser.push(byte, () => {}) && accepted - } - - expect(accepted).toBe(true) - expect(Reflect.get(parser, 'buffer')).toBeInstanceOf(Buffer) - expect(parser.finish()).toBe('x'.repeat(200_000)) - expect(Reflect.get(parser, 'buffer')).toBeNull() - }) - - it('rejects invalid byte limits', () => { - expect(() => new SearchSubprocessLineAccumulator(-1)).toThrow(RangeError) - }) -}) diff --git a/src/shared/search-subprocess-lines.ts b/src/shared/search-subprocess-lines.ts deleted file mode 100644 index 5c04d9e8292..00000000000 --- a/src/shared/search-subprocess-lines.ts +++ /dev/null @@ -1,75 +0,0 @@ -export const SEARCH_SUBPROCESS_MAX_LINE_BYTES = 64 * 1024 * 1024 -const SEARCH_SUBPROCESS_INITIAL_LINE_BUFFER_BYTES = 4 * 1024 - -export class SearchSubprocessLineAccumulator { - private buffer: Buffer | null = null - private bytes = 0 - - constructor(private readonly maxLineBytes = SEARCH_SUBPROCESS_MAX_LINE_BYTES) { - if (!Number.isSafeInteger(maxLineBytes) || maxLineBytes < 0) { - throw new RangeError('Search line limit must be a non-negative safe integer') - } - } - - push(rawChunk: Buffer | string, onLine: (line: string) => void): boolean { - const chunk = Buffer.isBuffer(rawChunk) ? rawChunk : Buffer.from(rawChunk, 'utf8') - let cursor = 0 - while (cursor < chunk.length) { - const newline = chunk.indexOf(0x0a, cursor) - const end = newline === -1 ? chunk.length : newline - const segmentBytes = end - cursor - if (this.bytes + segmentBytes > this.maxLineBytes) { - this.clear() - return false - } - - if (newline !== -1 && this.bytes === 0) { - onLine(chunk.toString('utf8', cursor, end)) - } else if (segmentBytes > 0) { - this.append(chunk.subarray(cursor, end)) - if (newline !== -1) { - onLine(this.takeLine()) - } - } else if (newline !== -1) { - onLine(this.takeLine()) - } - - if (newline === -1) { - return true - } - cursor = newline + 1 - } - return true - } - - finish(): string | null { - return this.bytes > 0 ? this.takeLine() : null - } - - clear(): void { - this.buffer = null - this.bytes = 0 - } - - private append(segment: Buffer): void { - const requiredBytes = this.bytes + segment.length - if (!this.buffer || this.buffer.length < requiredBytes) { - const doubledCapacity = this.buffer?.length ? this.buffer.length * 2 : 0 - const nextCapacity = Math.min( - this.maxLineBytes, - Math.max(SEARCH_SUBPROCESS_INITIAL_LINE_BUFFER_BYTES, doubledCapacity, requiredBytes) - ) - const next = Buffer.allocUnsafe(nextCapacity) - this.buffer?.copy(next, 0, 0, this.bytes) - this.buffer = next - } - segment.copy(this.buffer, this.bytes) - this.bytes = requiredBytes - } - - private takeLine(): string { - const line = this.buffer?.toString('utf8', 0, this.bytes) ?? '' - this.clear() - return line - } -} diff --git a/src/shared/secure-file.test.ts b/src/shared/secure-file.test.ts index d7b7e9e4415..4aa8e0f60c4 100644 --- a/src/shared/secure-file.test.ts +++ b/src/shared/secure-file.test.ts @@ -1,10 +1,9 @@ import { execFile, execFileSync } from 'node:child_process' -import { chmodSync, mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { chmodSync, mkdtempSync, rmSync, statSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { - __getSecureFileHardeningCacheStateForTests, __resetSecureFileHardenedPathsForTests, __resetSecureFileWindowsUserSidForTests, hardenExistingSecureFile, @@ -146,65 +145,6 @@ describe('hardenSecurePath', () => { expect(getPowerShellCalls().map(getPowerShellTarget)).toEqual([userDataPath, targetPath]) }) - it('LRU-evicts Windows file hardening entries and safely re-hardens an evicted path', () => { - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - __resetSecureFileHardenedPathsForTests({ - maxEntries: 2, - maxKeyBytes: 4096, - maxTotalKeyBytes: 8192 - }) - const userDataPath = mkdtempSync(join(tmpdir(), 'orca-secure-file-')) - tempDirs.push(userDataPath) - const paths = ['first.json', 'second.json', 'third.json'].map((name) => - join(userDataPath, name) - ) - for (const path of paths) { - writeFileSync(path, '{}') - hardenExistingSecureFile(path) - } - - hardenExistingSecureFile(paths[0]!) - - const fileTargets = getPowerShellCalls() - .map(getPowerShellTarget) - .filter((path) => paths.includes(path)) - expect(fileTargets).toEqual([...paths, paths[0]]) - expect(__getSecureFileHardeningCacheStateForTests().paths).toMatchObject({ - entries: 2 - }) - }) - - it('LRU-evicts Windows directory hardening entries instead of retaining every path', () => { - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - __resetSecureFileHardenedPathsForTests({ - maxEntries: 2, - maxKeyBytes: 4096, - maxTotalKeyBytes: 8192 - }) - const root = mkdtempSync(join(tmpdir(), 'orca-secure-file-')) - tempDirs.push(root) - const directories = ['first', 'second', 'third'].map((name) => join(root, name)) - const files = directories.map((dir) => { - mkdirSync(dir) - const file = join(dir, 'secret.json') - writeFileSync(file, '{}') - return file - }) - for (const file of files) { - hardenExistingSecureFile(file) - } - - hardenExistingSecureFile(files[0]!) - - const directoryTargets = getPowerShellCalls() - .map(getPowerShellTarget) - .filter((path) => directories.includes(path)) - expect(directoryTargets).toEqual([...directories, directories[0]]) - expect(__getSecureFileHardeningCacheStateForTests().directories).toMatchObject({ - entries: 2 - }) - }) - it('re-hardens an existing file when its metadata changes after caching', async () => { Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) const userDataPath = mkdtempSync(join(tmpdir(), 'orca-secure-file-')) @@ -410,34 +350,6 @@ describe('hardenSecurePath', () => { expect(statMode(userDataPath)).toBe(0o700) }) - - posixModeIt('LRU-bounds POSIX hardening entries while keeping recent paths cached', () => { - Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) - __resetSecureFileHardenedPathsForTests({ - maxEntries: 2, - maxKeyBytes: 4096, - maxTotalKeyBytes: 8192 - }) - const userDataPath = mkdtempSync(join(tmpdir(), 'orca-secure-file-')) - tempDirs.push(userDataPath) - const firstPath = join(userDataPath, 'first.json') - const secondPath = join(userDataPath, 'second.json') - writeFileSync(firstPath, '{}') - writeFileSync(secondPath, '{}') - - hardenExistingSecureFile(firstPath) - hardenExistingSecureFile(secondPath) - expect(__getSecureFileHardeningCacheStateForTests().paths.paths).toEqual([ - userDataPath, - secondPath - ]) - - hardenExistingSecureFile(firstPath) - expect(__getSecureFileHardeningCacheStateForTests().paths.paths).toEqual([ - userDataPath, - firstPath - ]) - }) }) const POWERSHELL_SUFFIX = 'WindowsPowerShell\\v1.0\\powershell.exe' diff --git a/src/shared/secure-file.ts b/src/shared/secure-file.ts index 36231de6835..90bfd5d6df9 100644 --- a/src/shared/secure-file.ts +++ b/src/shared/secure-file.ts @@ -1,3 +1,4 @@ +import { execFile, execFileSync } from 'node:child_process' import { randomBytes } from 'node:crypto' import { chmodSync, @@ -8,16 +9,9 @@ import { statSync, writeFileSync } from 'node:fs' -import { dirname } from 'node:path' -import { - SecurePathHardeningCache, - type SecurePathHardeningCacheBounds -} from './secure-path-hardening-cache' -import { - bestEffortRestrictWindowsPath, - resetSecureFileWindowsUserSidForTests, - restrictWindowsPathSync -} from './secure-path-windows-acl' +import { dirname, win32 as pathWin32 } from 'node:path' + +let cachedWindowsUserSid: string | null | undefined type HardenedPathCacheEntry = { isDirectory: boolean @@ -30,35 +24,21 @@ type HardenedPathCacheEntry = { birthtimeMs: number } -export const SECURE_PATH_HARDENING_CACHE_MAX_ENTRIES = 1024 -export const SECURE_PATH_HARDENING_CACHE_KEY_MAX_BYTES = 64 * 1024 -export const SECURE_PATH_HARDENING_CACHE_KEYS_MAX_BYTES = 512 * 1024 - -const DEFAULT_HARDENING_CACHE_BOUNDS: SecurePathHardeningCacheBounds = { - maxEntries: SECURE_PATH_HARDENING_CACHE_MAX_ENTRIES, - maxKeyBytes: SECURE_PATH_HARDENING_CACHE_KEY_MAX_BYTES, - maxTotalKeyBytes: SECURE_PATH_HARDENING_CACHE_KEYS_MAX_BYTES -} - // Why: PowerShell hardening (~1-1.5s) stalls the main thread, so cache idempotent re-hardens per process. -let hardenedPathsThisProcess = new SecurePathHardeningCache( - DEFAULT_HARDENING_CACHE_BOUNDS -) +const hardenedPathsThisProcess = new Map() // Why: child writes constantly bump a dir's mtime, so cache dirs by path (not metadata) to avoid a PowerShell spawn every read (#4901). // Limitation: a dir deleted+recreated in-process won't re-harden; fine since we never delete our secure dirs at runtime. -let hardenedDirectoryPathsThisProcess = new SecurePathHardeningCache( - DEFAULT_HARDENING_CACHE_BOUNDS -) +const hardenedDirectoryPathsThisProcess = new Set() function hardenSecureDirectoryOnce(dirPath: string): void { // Why: dir hardening stays async — re-applying it stormed the main thread (#4901); files inside are hardened synchronously anyway. - if (hardenedDirectoryPathsThisProcess.get(dirPath)) { + if (hardenedDirectoryPathsThisProcess.has(dirPath)) { return } applySecurePathRestriction(dirPath, true, process.platform, false) // Cache even though the async ACL may still be in flight — dir restriction is best-effort, no retry. - hardenedDirectoryPathsThisProcess.set(dirPath, true) + hardenedDirectoryPathsThisProcess.add(dirPath) } function hardenSecurePathOnce(targetPath: string, isDirectory: boolean): boolean { @@ -216,23 +196,153 @@ function hardenedPathCacheEntriesMatch( ) } -export function __resetSecureFileWindowsUserSidForTests(): void { - resetSecureFileWindowsUserSidForTests() +function buildWindowsRestrictAclArgs( + targetPath: string, + currentUserSid: string, + isDirectory: boolean +): string[] { + return [ + '-NoProfile', + '-NonInteractive', + '-ExecutionPolicy', + 'Bypass', + '-Command', + WINDOWS_RESTRICT_ACL_SCRIPT, + targetPath, + currentUserSid, + isDirectory ? '1' : '0' + ] } -export function __resetSecureFileHardenedPathsForTests( - bounds: SecurePathHardeningCacheBounds = DEFAULT_HARDENING_CACHE_BOUNDS -): void { - hardenedPathsThisProcess = new SecurePathHardeningCache(bounds) - hardenedDirectoryPathsThisProcess = new SecurePathHardeningCache(bounds) +function bestEffortRestrictWindowsPath(targetPath: string, isDirectory: boolean): void { + const currentUserSid = getCurrentWindowsUserSid() + if (!currentUserSid) { + return + } + // Why: async to avoid blocking the main thread — sync PowerShell cold-start (~1-1.5s) on the frequent read path stormed it (#4901). + execFile( + getWindowsSystemToolPath('WindowsPowerShell\\v1.0\\powershell.exe'), + buildWindowsRestrictAclArgs(targetPath, currentUserSid, isDirectory), + { + windowsHide: true, + timeout: 5000 + }, + () => { + // Why: ignore errors — hardening is best-effort; PowerShell ACL APIs may be unavailable or locked down. + } + ) } -export function __getSecureFileHardeningCacheStateForTests(): { - paths: ReturnType['state']> - directories: ReturnType['state']> -} { - return { - paths: hardenedPathsThisProcess.state(), - directories: hardenedDirectoryPathsThisProcess.state() +function restrictWindowsPathSync(targetPath: string, isDirectory: boolean): boolean { + const currentUserSid = getCurrentWindowsUserSid() + if (!currentUserSid) { + return false + } + // Why: file must not be published until its ACL is actually restricted, so block and report real success (read path stays async, #4901). + try { + execFileSync( + getWindowsSystemToolPath('WindowsPowerShell\\v1.0\\powershell.exe'), + buildWindowsRestrictAclArgs(targetPath, currentUserSid, isDirectory), + { + stdio: ['ignore', 'ignore', 'ignore'], + windowsHide: true, + timeout: 5000 + } + ) + return true + } catch { + // Why: best-effort — a failed ACL apply must not crash the write; false leaves the path uncached to retry later. + return false } } + +const WINDOWS_RESTRICT_ACL_SCRIPT = ` +$ErrorActionPreference = 'Stop' +$path = $args[0] +$currentUserSid = $args[1] +$isDirectory = $args[2] -eq '1' +$allowedSidTexts = @($currentUserSid, 'S-1-5-18', 'S-1-5-32-544') +$allowedSids = @{} +foreach ($sidText in $allowedSidTexts) { + $allowedSids[$sidText] = $true +} +$acl = Get-Acl -LiteralPath $path +$acl.SetAccessRuleProtection($true, $false) +foreach ($rule in @($acl.Access)) { + [void]$acl.RemoveAccessRuleSpecific($rule) +} +$inheritanceFlags = [System.Security.AccessControl.InheritanceFlags]::None +if ($isDirectory) { + $inheritanceFlags = [System.Security.AccessControl.InheritanceFlags]::ContainerInherit -bor [System.Security.AccessControl.InheritanceFlags]::ObjectInherit +} +foreach ($sidText in $allowedSidTexts) { + $sid = [System.Security.Principal.SecurityIdentifier]::new($sidText) + $rule = [System.Security.AccessControl.FileSystemAccessRule]::new( + $sid, + [System.Security.AccessControl.FileSystemRights]::FullControl, + $inheritanceFlags, + [System.Security.AccessControl.PropagationFlags]::None, + [System.Security.AccessControl.AccessControlType]::Allow + ) + [void]$acl.AddAccessRule($rule) +} +Set-Acl -LiteralPath $path -AclObject $acl +$verifiedAcl = Get-Acl -LiteralPath $path +if (-not $verifiedAcl.AreAccessRulesProtected) { + throw 'ACL inheritance is still enabled' +} +$fullControl = [System.Security.AccessControl.FileSystemRights]::FullControl +foreach ($rule in @($verifiedAcl.Access)) { + $sid = $rule.IdentityReference.Translate([System.Security.Principal.SecurityIdentifier]).Value + if (-not $allowedSids.ContainsKey($sid)) { + throw "Unexpected ACL entry $sid" + } + if ($rule.AccessControlType -ne [System.Security.AccessControl.AccessControlType]::Allow) { + throw "Unexpected ACL deny entry $sid" + } + if (($rule.FileSystemRights -band $fullControl) -ne $fullControl) { + throw "ACL entry $sid does not grant FullControl" + } +} +`.trim() + +function getCurrentWindowsUserSid(): string | null { + if (cachedWindowsUserSid !== undefined) { + return cachedWindowsUserSid + } + try { + const output = execFileSync( + getWindowsSystemToolPath('whoami.exe'), + ['/user', '/fo', 'csv', '/nh'], + { + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'ignore'], + windowsHide: true, + timeout: 5000 + } + ).trim() + const columns = parseCsvLine(output) + cachedWindowsUserSid = columns[1] ?? null + } catch { + cachedWindowsUserSid = null + } + return cachedWindowsUserSid +} + +function getWindowsSystemToolPath(relativeSystem32Path: string): string { + const systemRoot = process.env.SystemRoot || process.env.WINDIR || 'C:\\Windows' + return pathWin32.join(systemRoot, 'System32', relativeSystem32Path) +} + +function parseCsvLine(line: string): string[] { + return line.split(/","/).map((part) => part.replace(/^"/, '').replace(/"$/, '')) +} + +export function __resetSecureFileWindowsUserSidForTests(): void { + cachedWindowsUserSid = undefined +} + +export function __resetSecureFileHardenedPathsForTests(): void { + hardenedPathsThisProcess.clear() + hardenedDirectoryPathsThisProcess.clear() +} diff --git a/src/shared/secure-path-hardening-cache.test.ts b/src/shared/secure-path-hardening-cache.test.ts deleted file mode 100644 index 4d5460921ee..00000000000 --- a/src/shared/secure-path-hardening-cache.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { SecurePathHardeningCache } from './secure-path-hardening-cache' - -describe('SecurePathHardeningCache', () => { - it('accepts a UTF-8 key at the exact per-key boundary', () => { - const cache = new SecurePathHardeningCache({ - maxEntries: 2, - maxKeyBytes: 6, - maxTotalKeyBytes: 6 - }) - - expect(cache.set('界界', 1)).toBe(true) - expect(cache.get('界界')).toBe(1) - expect(cache.state()).toMatchObject({ entries: 1, keyBytes: 6 }) - }) - - it('rejects one byte beyond the per-key boundary without evicting retained state', () => { - const cache = new SecurePathHardeningCache({ - maxEntries: 2, - maxKeyBytes: 6, - maxTotalKeyBytes: 12 - }) - cache.set('kept', 1) - - expect(cache.set('1234567', 2)).toBe(false) - expect(cache.state().paths).toEqual(['kept']) - }) - - it('evicts the least-recently-used entry at the count boundary', () => { - const cache = new SecurePathHardeningCache({ - maxEntries: 2, - maxKeyBytes: 32, - maxTotalKeyBytes: 64 - }) - cache.set('old', 1) - cache.set('hot', 2) - expect(cache.get('old')).toBe(1) - - cache.set('new', 3) - - expect(cache.state().paths).toEqual(['old', 'new']) - expect(cache.get('hot')).toBeUndefined() - }) - - it('evicts LRU entries until aggregate UTF-8 key bytes fit', () => { - const cache = new SecurePathHardeningCache({ - maxEntries: 10, - maxKeyBytes: 12, - maxTotalKeyBytes: 12 - }) - cache.set('aaaa', 1) - cache.set('bbbb', 2) - - expect(cache.set('界界', 3)).toBe(true) - expect(cache.state()).toEqual({ - entries: 2, - keyBytes: 10, - paths: ['bbbb', '界界'] - }) - }) -}) diff --git a/src/shared/secure-path-hardening-cache.ts b/src/shared/secure-path-hardening-cache.ts deleted file mode 100644 index 3ec32de4f8b..00000000000 --- a/src/shared/secure-path-hardening-cache.ts +++ /dev/null @@ -1,74 +0,0 @@ -export type SecurePathHardeningCacheBounds = { - maxEntries: number - maxKeyBytes: number - maxTotalKeyBytes: number -} - -type RetainedSecurePath = { - value: T - keyBytes: number -} - -export class SecurePathHardeningCache { - private readonly entries = new Map>() - private retainedKeyBytes = 0 - - constructor(private readonly bounds: SecurePathHardeningCacheBounds) {} - - get(path: string): T | undefined { - const retained = this.entries.get(path) - if (!retained) { - return undefined - } - this.entries.delete(path) - this.entries.set(path, retained) - return retained.value - } - - set(path: string, value: T): boolean { - const keyBytes = Buffer.byteLength(path, 'utf8') - this.delete(path) - if ( - keyBytes > this.bounds.maxKeyBytes || - keyBytes > this.bounds.maxTotalKeyBytes || - this.bounds.maxEntries <= 0 - ) { - return false - } - while ( - this.entries.size >= this.bounds.maxEntries || - this.retainedKeyBytes + keyBytes > this.bounds.maxTotalKeyBytes - ) { - const oldest = this.entries.keys().next().value - if (oldest === undefined) { - return false - } - this.delete(oldest) - } - this.entries.set(path, { value, keyBytes }) - this.retainedKeyBytes += keyBytes - return true - } - - delete(path: string): void { - const retained = this.entries.get(path) - if (!retained) { - return - } - this.entries.delete(path) - this.retainedKeyBytes -= retained.keyBytes - } - - clear(): void { - this.entries.clear() - this.retainedKeyBytes = 0 - } - - state(): { entries: number; keyBytes: number; paths: string[] } { - return { - entries: this.entries.size, - keyBytes: this.retainedKeyBytes, - paths: [...this.entries.keys()] - } - } -} diff --git a/src/shared/secure-path-windows-acl.ts b/src/shared/secure-path-windows-acl.ts deleted file mode 100644 index 4d61dff79e1..00000000000 --- a/src/shared/secure-path-windows-acl.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { execFile, execFileSync } from 'node:child_process' -import { win32 as pathWin32 } from 'node:path' - -let cachedWindowsUserSid: string | null | undefined - -function buildWindowsRestrictAclArgs( - targetPath: string, - currentUserSid: string, - isDirectory: boolean -): string[] { - return [ - '-NoProfile', - '-NonInteractive', - '-ExecutionPolicy', - 'Bypass', - '-Command', - WINDOWS_RESTRICT_ACL_SCRIPT, - targetPath, - currentUserSid, - isDirectory ? '1' : '0' - ] -} - -export function bestEffortRestrictWindowsPath(targetPath: string, isDirectory: boolean): void { - const currentUserSid = getCurrentWindowsUserSid() - if (!currentUserSid) { - return - } - // Why: async to avoid blocking the main thread — sync PowerShell cold-start (~1-1.5s) on the frequent read path stormed it (#4901). - execFile( - getWindowsSystemToolPath('WindowsPowerShell\\v1.0\\powershell.exe'), - buildWindowsRestrictAclArgs(targetPath, currentUserSid, isDirectory), - { - windowsHide: true, - timeout: 5000 - }, - () => { - // Why: ignore errors — hardening is best-effort; PowerShell ACL APIs may be unavailable or locked down. - } - ) -} - -export function restrictWindowsPathSync(targetPath: string, isDirectory: boolean): boolean { - const currentUserSid = getCurrentWindowsUserSid() - if (!currentUserSid) { - return false - } - // Why: file must not be published until its ACL is actually restricted, so block and report real success (read path stays async, #4901). - try { - execFileSync( - getWindowsSystemToolPath('WindowsPowerShell\\v1.0\\powershell.exe'), - buildWindowsRestrictAclArgs(targetPath, currentUserSid, isDirectory), - { - stdio: ['ignore', 'ignore', 'ignore'], - windowsHide: true, - timeout: 5000 - } - ) - return true - } catch { - // Why: best-effort — a failed ACL apply must not crash the write; false leaves the path uncached to retry later. - return false - } -} - -const WINDOWS_RESTRICT_ACL_SCRIPT = ` -$ErrorActionPreference = 'Stop' -$path = $args[0] -$currentUserSid = $args[1] -$isDirectory = $args[2] -eq '1' -$allowedSidTexts = @($currentUserSid, 'S-1-5-18', 'S-1-5-32-544') -$allowedSids = @{} -foreach ($sidText in $allowedSidTexts) { - $allowedSids[$sidText] = $true -} -$acl = Get-Acl -LiteralPath $path -$acl.SetAccessRuleProtection($true, $false) -foreach ($rule in @($acl.Access)) { - [void]$acl.RemoveAccessRuleSpecific($rule) -} -$inheritanceFlags = [System.Security.AccessControl.InheritanceFlags]::None -if ($isDirectory) { - $inheritanceFlags = [System.Security.AccessControl.InheritanceFlags]::ContainerInherit -bor [System.Security.AccessControl.InheritanceFlags]::ObjectInherit -} -foreach ($sidText in $allowedSidTexts) { - $sid = [System.Security.Principal.SecurityIdentifier]::new($sidText) - $rule = [System.Security.AccessControl.FileSystemAccessRule]::new( - $sid, - [System.Security.AccessControl.FileSystemRights]::FullControl, - $inheritanceFlags, - [System.Security.AccessControl.PropagationFlags]::None, - [System.Security.AccessControl.AccessControlType]::Allow - ) - [void]$acl.AddAccessRule($rule) -} -Set-Acl -LiteralPath $path -AclObject $acl -$verifiedAcl = Get-Acl -LiteralPath $path -if (-not $verifiedAcl.AreAccessRulesProtected) { - throw 'ACL inheritance is still enabled' -} -$fullControl = [System.Security.AccessControl.FileSystemRights]::FullControl -foreach ($rule in @($verifiedAcl.Access)) { - $sid = $rule.IdentityReference.Translate([System.Security.Principal.SecurityIdentifier]).Value - if (-not $allowedSids.ContainsKey($sid)) { - throw "Unexpected ACL entry $sid" - } - if ($rule.AccessControlType -ne [System.Security.AccessControl.AccessControlType]::Allow) { - throw "Unexpected ACL deny entry $sid" - } - if (($rule.FileSystemRights -band $fullControl) -ne $fullControl) { - throw "ACL entry $sid does not grant FullControl" - } -} -`.trim() - -function getCurrentWindowsUserSid(): string | null { - if (cachedWindowsUserSid !== undefined) { - return cachedWindowsUserSid - } - try { - const output = execFileSync( - getWindowsSystemToolPath('whoami.exe'), - ['/user', '/fo', 'csv', '/nh'], - { - encoding: 'utf-8', - stdio: ['ignore', 'pipe', 'ignore'], - windowsHide: true, - timeout: 5000 - } - ).trim() - const columns = parseCsvLine(output) - cachedWindowsUserSid = columns[1] ?? null - } catch { - cachedWindowsUserSid = null - } - return cachedWindowsUserSid -} - -function getWindowsSystemToolPath(relativeSystem32Path: string): string { - const systemRoot = process.env.SystemRoot || process.env.WINDIR || 'C:\\Windows' - return pathWin32.join(systemRoot, 'System32', relativeSystem32Path) -} - -function parseCsvLine(line: string): string[] { - return line.split(/","/).map((part) => part.replace(/^"/, '').replace(/"$/, '')) -} - -export function resetSecureFileWindowsUserSidForTests(): void { - cachedWindowsUserSid = undefined -} diff --git a/src/shared/serve-update-handoff.ts b/src/shared/serve-update-handoff.ts index 3dddd2a38da..4d2e1934852 100644 --- a/src/shared/serve-update-handoff.ts +++ b/src/shared/serve-update-handoff.ts @@ -1,11 +1,7 @@ import { join } from 'node:path' -import { assertJsonTextStructureWithinLimits } from './json-text-structure-limit' export const SERVE_UPDATE_HANDOFF_PATH_ENV = 'ORCA_SERVE_UPDATE_HANDOFF_PATH' export const SERVE_UPDATE_HANDOFF_FILE = 'serve-update-handoff.json' -export const MAX_SERVE_UPDATE_HANDOFF_FILE_BYTES = 64 * 1024 -export const MAX_SERVE_UPDATE_HANDOFF_JSON_STRUCTURAL_TOKENS = 16 * 1024 -export const MAX_SERVE_UPDATE_HANDOFF_JSON_NESTING_DEPTH = 32 export type ServeUpdateHandoffState = | { @@ -65,14 +61,6 @@ export function parseServeUpdateHandoffState(value: unknown): ServeUpdateHandoff return state as ServeUpdateHandoffState } -export function parseServeUpdateHandoffJson(serialized: string): ServeUpdateHandoffState | null { - assertJsonTextStructureWithinLimits(serialized, { - structuralTokens: MAX_SERVE_UPDATE_HANDOFF_JSON_STRUCTURAL_TOKENS, - nestingDepth: MAX_SERVE_UPDATE_HANDOFF_JSON_NESTING_DEPTH - }) - return parseServeUpdateHandoffState(JSON.parse(serialized)) -} - export function parseServeSupervisorMessage(value: unknown): ServeSupervisorMessage | null { if (!value || typeof value !== 'object') { return null diff --git a/src/shared/setup-script-import-codex-environment.ts b/src/shared/setup-script-import-codex-environment.ts index 77a8e48296d..45d8fd9ba2c 100644 --- a/src/shared/setup-script-import-codex-environment.ts +++ b/src/shared/setup-script-import-codex-environment.ts @@ -1,12 +1,4 @@ import type { SetupScriptImportCandidate, SetupScriptImportFileRead } from './setup-script-imports' -import { - isSetupScriptImportFieldWithinLimit, - SETUP_SCRIPT_IMPORT_MAX_FIELD_BYTES, - SETUP_SCRIPT_IMPORT_MAX_FIELD_CODE_UNITS, - SETUP_SCRIPT_IMPORT_MAX_TOML_LINES, - SETUP_SCRIPT_IMPORT_MAX_UNSUPPORTED_FIELDS -} from './setup-script-import-limits' -import { measureUtf8ByteLength } from './utf8-byte-limits' const CODEX_ENVIRONMENT_PATH = '.codex/environments/environment.toml' @@ -25,7 +17,7 @@ export async function inspectCodexEnvironmentConfig( } const parsed = parseCodexEnvironmentToml(content) - const setup = normalizeCodexScript(parsed.setupScript) + const setup = parsed.setupScript?.trim() if (!setup) { return null } @@ -35,15 +27,12 @@ export async function inspectCodexEnvironmentConfig( label: 'Codex environment', files: [CODEX_ENVIRONMENT_PATH], setup, - archive: normalizeCodexScript(parsed.cleanupScript) || undefined, + archive: parsed.cleanupScript?.trim() || undefined, unsupportedFields: parsed.unsupportedFields } } function parseCodexEnvironmentToml(content: string): CodexEnvironmentToml { - if (countTomlLines(content) > SETUP_SCRIPT_IMPORT_MAX_TOML_LINES) { - return { unsupportedFields: [] } - } const lines = content.split(/\r?\n/) const unsupportedFields: string[] = [] let section = '' @@ -54,13 +43,13 @@ function parseCodexEnvironmentToml(content: string): CodexEnvironmentToml { const line = lines[index] const trimmed = line.trim() if (/^actions\s*=/.test(trimmed)) { - pushUnsupportedField(unsupportedFields, 'actions') + unsupportedFields.push('actions') } const sectionMatch = trimmed.match(/^\[([A-Za-z0-9_.-]+)\]\s*(?:#.*)?$/) if (sectionMatch) { section = sectionMatch[1] if (section === 'actions' || section.startsWith('actions.')) { - pushUnsupportedField(unsupportedFields, `[${section}]`) + unsupportedFields.push(`[${section}]`) } continue } @@ -111,49 +100,22 @@ function parseTomlMultilineString( firstLineRemainder: string, delimiter: '"""' | "'''" ): { value: string; endLineIndex: number } { - const chunks: string[] = [] - let retainedBytes = 0 - let retainedCodeUnits = 0 + let content = '' let remainder = firstLineRemainder - let oversized = false - const append = (value: string): boolean => { - if (retainedCodeUnits + value.length > SETUP_SCRIPT_IMPORT_MAX_FIELD_CODE_UNITS) { - return false - } - const measurement = measureUtf8ByteLength(value, { - stopAfterBytes: SETUP_SCRIPT_IMPORT_MAX_FIELD_BYTES - retainedBytes - }) - if (measurement.exceededLimit) { - return false - } - chunks.push(value) - retainedBytes += measurement.byteLength - retainedCodeUnits += value.length - return true - } for (let index = startLineIndex; index < lines.length; index++) { if (index > startLineIndex) { remainder = lines[index] } const closeIndex = remainder.indexOf(delimiter) if (closeIndex >= 0) { - if (!oversized && !append(remainder.slice(0, closeIndex))) { - oversized = true - } return { - value: oversized ? '' : chunks.join(''), + value: content + remainder.slice(0, closeIndex), endLineIndex: index } } - if (!oversized && !append(`${remainder}\n`)) { - oversized = true - chunks.length = 0 - } - } - return { - value: oversized ? '' : chunks.join('').trimEnd(), - endLineIndex: lines.length - 1 + content += `${remainder}\n` } + return { value: content.trimEnd(), endLineIndex: lines.length - 1 } } function parseTomlBasicString(value: string): string { @@ -189,26 +151,3 @@ function isEscaped(value: string, index: number): boolean { } return slashCount % 2 === 1 } - -function normalizeCodexScript(value: string | undefined): string { - if (!value || !isSetupScriptImportFieldWithinLimit(value)) { - return '' - } - return value.trim() -} - -function countTomlLines(content: string): number { - let lines = 1 - for (let index = 0; index < content.length; index++) { - if (content.charCodeAt(index) === 10 && ++lines > SETUP_SCRIPT_IMPORT_MAX_TOML_LINES) { - return lines - } - } - return lines -} - -function pushUnsupportedField(fields: string[], value: string): void { - if (fields.length < SETUP_SCRIPT_IMPORT_MAX_UNSUPPORTED_FIELDS) { - fields.push(value) - } -} diff --git a/src/shared/setup-script-import-command-limits.ts b/src/shared/setup-script-import-command-limits.ts deleted file mode 100644 index 16eacd0843e..00000000000 --- a/src/shared/setup-script-import-command-limits.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { - isSetupScriptImportFieldWithinLimit, - SETUP_SCRIPT_IMPORT_MAX_COMMAND_PARTS, - SETUP_SCRIPT_IMPORT_MAX_FIELD_CODE_UNITS, - SETUP_SCRIPT_IMPORT_MAX_UNSUPPORTED_FIELDS -} from './setup-script-import-limits' - -export function normalizeSetupScriptImportCommand(value: unknown): string { - if (typeof value === 'string') { - return normalizeCommandString(value) - } - if (!Array.isArray(value) || value.length > SETUP_SCRIPT_IMPORT_MAX_COMMAND_PARTS) { - return '' - } - const commands: string[] = [] - for (const item of value) { - const command = typeof item === 'string' ? normalizeCommandString(item) : '' - if (command) { - commands.push(command) - } - } - return joinSetupScriptImportCommands(commands) -} - -export function joinSetupScriptImportCommands(parts: string[]): string { - let command = '' - for (const part of parts) { - const next = command ? `${command}\n${part}` : part - if (!isSetupScriptImportFieldWithinLimit(next)) { - return '' - } - command = next - } - return command -} - -export function pushSetupScriptImportUnsupportedField(fields: string[], value: string): void { - if (fields.length < SETUP_SCRIPT_IMPORT_MAX_UNSUPPORTED_FIELDS) { - fields.push(value) - } -} - -function normalizeCommandString(value: string): string { - if (value.length > SETUP_SCRIPT_IMPORT_MAX_FIELD_CODE_UNITS) { - return '' - } - const trimmed = value.trim() - return trimmed && isSetupScriptImportFieldWithinLimit(trimmed) ? trimmed : '' -} diff --git a/src/shared/setup-script-import-limits.ts b/src/shared/setup-script-import-limits.ts deleted file mode 100644 index d409586db85..00000000000 --- a/src/shared/setup-script-import-limits.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { measureUtf8ByteLength } from './utf8-byte-limits' - -export const SETUP_SCRIPT_IMPORT_FILE_MAX_BYTES = 256 * 1024 -export const SETUP_SCRIPT_IMPORT_MAX_CODE_UNITS = 256 * 1024 -export const SETUP_SCRIPT_IMPORT_MAX_FIELD_BYTES = 64 * 1024 -export const SETUP_SCRIPT_IMPORT_MAX_FIELD_CODE_UNITS = 64 * 1024 -export const SETUP_SCRIPT_IMPORT_MAX_COMMAND_PARTS = 256 -export const SETUP_SCRIPT_IMPORT_MAX_CMUX_COMMANDS = 256 -export const SETUP_SCRIPT_IMPORT_MAX_KEYWORDS = 64 -export const SETUP_SCRIPT_IMPORT_MAX_UNSUPPORTED_FIELDS = 128 -export const SETUP_SCRIPT_IMPORT_MAX_TOML_LINES = 4_096 - -export function isSetupScriptImportTextWithinLimit(content: string): boolean { - return isTextWithinLimits( - content, - SETUP_SCRIPT_IMPORT_FILE_MAX_BYTES, - SETUP_SCRIPT_IMPORT_MAX_CODE_UNITS - ) -} - -export function isSetupScriptImportFieldWithinLimit(value: string): boolean { - return isTextWithinLimits( - value, - SETUP_SCRIPT_IMPORT_MAX_FIELD_BYTES, - SETUP_SCRIPT_IMPORT_MAX_FIELD_CODE_UNITS - ) -} - -function isTextWithinLimits(value: string, maxBytes: number, maxCodeUnits: number): boolean { - return ( - value.length <= maxCodeUnits && - !measureUtf8ByteLength(value, { stopAfterBytes: maxBytes }).exceededLimit - ) -} diff --git a/src/shared/setup-script-imports.test.ts b/src/shared/setup-script-imports.test.ts index 3215ab815df..97e62d6917f 100644 --- a/src/shared/setup-script-imports.test.ts +++ b/src/shared/setup-script-imports.test.ts @@ -1,126 +1,11 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import { inspectSetupScriptImportCandidates } from './setup-script-imports' -import { - SETUP_SCRIPT_IMPORT_FILE_MAX_BYTES, - SETUP_SCRIPT_IMPORT_MAX_CMUX_COMMANDS, - SETUP_SCRIPT_IMPORT_MAX_COMMAND_PARTS, - SETUP_SCRIPT_IMPORT_MAX_FIELD_BYTES, - SETUP_SCRIPT_IMPORT_MAX_FIELD_CODE_UNITS, - SETUP_SCRIPT_IMPORT_MAX_TOML_LINES -} from './setup-script-import-limits' function makeReader(files: Record) { return async (relativePath: string): Promise => files[relativePath] ?? null } -afterEach(() => { - vi.restoreAllMocks() -}) - describe('inspectSetupScriptImportCandidates', () => { - it('parses the exact input boundary and rejects +1 before JSON parsing', async () => { - const parse = vi.spyOn(JSON, 'parse') - const suffix = '{"setup":"pnpm install"}' - const exact = `${' '.repeat(SETUP_SCRIPT_IMPORT_FILE_MAX_BYTES - suffix.length)}${suffix}` - - await expect( - inspectSetupScriptImportCandidates(makeReader({ '.superset/config.json': exact })) - ).resolves.toHaveLength(1) - expect(parse).toHaveBeenCalledOnce() - - parse.mockClear() - await expect( - inspectSetupScriptImportCandidates(makeReader({ '.superset/config.json': `${exact} ` })) - ).resolves.toEqual([]) - expect(parse).not.toHaveBeenCalled() - }) - - it('rejects multibyte input over the byte cap before JSON parsing', async () => { - const parse = vi.spyOn(JSON, 'parse') - - await expect( - inspectSetupScriptImportCandidates( - makeReader({ - '.superset/config.json': 'é'.repeat(SETUP_SCRIPT_IMPORT_FILE_MAX_BYTES / 2 + 1) - }) - ) - ).resolves.toEqual([]) - expect(parse).not.toHaveBeenCalled() - }) - - it('admits the exact command-part cardinality and rejects +1', async () => { - const inspect = (setup: string[]) => - inspectSetupScriptImportCandidates( - makeReader({ '.superset/config.json': JSON.stringify({ setup }) }) - ) - const exact = Array.from({ length: SETUP_SCRIPT_IMPORT_MAX_COMMAND_PARTS }, () => 'x') - - await expect(inspect(exact)).resolves.toMatchObject([{ setup: exact.join('\n') }]) - await expect(inspect([...exact, 'overflow'])).resolves.toEqual([]) - }) - - it('admits an exact-size script field and rejects +1', async () => { - const inspect = (setup: string) => - inspectSetupScriptImportCandidates( - makeReader({ '.superset/config.json': JSON.stringify({ setup }) }) - ) - const exact = 'x'.repeat(SETUP_SCRIPT_IMPORT_MAX_FIELD_CODE_UNITS) - const exactUtf8 = 'é'.repeat(SETUP_SCRIPT_IMPORT_MAX_FIELD_BYTES / 2) - - await expect(inspect(exact)).resolves.toMatchObject([{ setup: exact }]) - await expect(inspect(`${exact}x`)).resolves.toEqual([]) - await expect(inspect(exactUtf8)).resolves.toMatchObject([{ setup: exactUtf8 }]) - await expect(inspect(`${exactUtf8}é`)).resolves.toEqual([]) - }) - - it('bounds Codex multiline script accumulation at the exact field limit', async () => { - const inspect = (setup: string) => - inspectSetupScriptImportCandidates( - makeReader({ - '.codex/environments/environment.toml': `[setup]\nscript = """${setup}"""` - }) - ) - const exact = 'x'.repeat(SETUP_SCRIPT_IMPORT_MAX_FIELD_CODE_UNITS) - - await expect(inspect(exact)).resolves.toMatchObject([{ provider: 'codex', setup: exact }]) - await expect(inspect(`${exact}x`)).resolves.toEqual([]) - }) - - it('bounds cmux command scans and Codex TOML line splitting', async () => { - const commands = Array.from({ length: SETUP_SCRIPT_IMPORT_MAX_CMUX_COMMANDS }, (_, index) => ({ - name: index === SETUP_SCRIPT_IMPORT_MAX_CMUX_COMMANDS - 1 ? 'Setup' : 'Build', - command: 'pnpm install' - })) - await expect( - inspectSetupScriptImportCandidates( - makeReader({ '.cmux/cmux.json': JSON.stringify({ commands }) }) - ) - ).resolves.toMatchObject([{ provider: 'cmux' }]) - await expect( - inspectSetupScriptImportCandidates( - makeReader({ - '.cmux/cmux.json': JSON.stringify({ - commands: [...commands, { name: 'Overflow', command: 'true' }] - }) - }) - ) - ).resolves.toEqual([]) - - const exactToml = `[setup]\nscript = "pnpm install"${'\n'.repeat( - SETUP_SCRIPT_IMPORT_MAX_TOML_LINES - 2 - )}` - await expect( - inspectSetupScriptImportCandidates( - makeReader({ '.codex/environments/environment.toml': exactToml }) - ) - ).resolves.toMatchObject([{ provider: 'codex' }]) - await expect( - inspectSetupScriptImportCandidates( - makeReader({ '.codex/environments/environment.toml': `${exactToml}\n` }) - ) - ).resolves.toEqual([]) - }) - it('imports setup and teardown commands from Superset config', async () => { const candidates = await inspectSetupScriptImportCandidates( makeReader({ diff --git a/src/shared/setup-script-imports.ts b/src/shared/setup-script-imports.ts index 5aa698d13d1..1d32d9a7622 100644 --- a/src/shared/setup-script-imports.ts +++ b/src/shared/setup-script-imports.ts @@ -1,18 +1,6 @@ import { inspectCodexEnvironmentConfig } from './setup-script-import-codex-environment' import { inspectPackageManagerSetupCandidate } from './setup-script-package-manager-suggestion' import type { SetupScriptImportProvider } from './setup-script-import-providers' -import { - isSetupScriptImportFieldWithinLimit, - isSetupScriptImportTextWithinLimit, - SETUP_SCRIPT_IMPORT_MAX_CMUX_COMMANDS, - SETUP_SCRIPT_IMPORT_MAX_KEYWORDS, - SETUP_SCRIPT_IMPORT_MAX_UNSUPPORTED_FIELDS -} from './setup-script-import-limits' -import { - joinSetupScriptImportCommands, - normalizeSetupScriptImportCommand, - pushSetupScriptImportUnsupportedField -} from './setup-script-import-command-limits' export type SetupScriptImportCandidate = { provider: SetupScriptImportProvider @@ -35,16 +23,12 @@ export async function inspectSetupScriptImportCandidates( readFile: SetupScriptImportFileRead, options?: { fileExists?: SetupScriptImportFileExists } ): Promise { - const boundedReadFile: SetupScriptImportFileRead = async (relativePath) => { - const content = await readFile(relativePath) - return content !== null && isSetupScriptImportTextWithinLimit(content) ? content : null - } const candidates = await Promise.all([ - inspectSupersetConfig(boundedReadFile), - inspectConductorConfig(boundedReadFile), - inspectCodexEnvironmentConfig(boundedReadFile), - inspectCmuxConfig(boundedReadFile), - inspectPackageManagerSetupCandidate(boundedReadFile, options?.fileExists) + inspectSupersetConfig(readFile), + inspectConductorConfig(readFile), + inspectCodexEnvironmentConfig(readFile), + inspectCmuxConfig(readFile), + inspectPackageManagerSetupCandidate(readFile, options?.fileExists) ]) return candidates.filter( (candidate): candidate is SetupScriptImportCandidate => candidate != null @@ -110,7 +94,7 @@ async function inspectConductorConfig( return null } - const setup = normalizeSetupScriptImportCommand(scripts.setup) + const setup = normalizeCommandValue(scripts.setup) if (!setup) { return null } @@ -120,7 +104,7 @@ async function inspectConductorConfig( 'runScriptMode' ]) for (const field of ['run', 'teardown'] as const) { - if (normalizeSetupScriptImportCommand(scripts[field])) { + if (normalizeCommandValue(scripts[field])) { unsupportedFields.push(`scripts.${field}`) } } @@ -130,7 +114,7 @@ async function inspectConductorConfig( label: 'Conductor', files: [CONDUCTOR_CONFIG_PATH], setup, - archive: normalizeSetupScriptImportCommand(scripts.archive) || undefined, + archive: normalizeCommandValue(scripts.archive) || undefined, unsupportedFields } } @@ -165,41 +149,48 @@ function asRecord(value: unknown): Record | null { : null } +function normalizeCommandValue(value: unknown): string { + if (typeof value === 'string') { + return value.trim() + } + if (!Array.isArray(value)) { + return '' + } + const commands = value + .map((item) => (typeof item === 'string' ? item.trim() : '')) + .filter(Boolean) + return commands.join('\n') +} + function resolveSupersetScriptValue( baseValue: unknown, localValue: unknown, key: 'setup' | 'teardown', unsupportedFields: string[] ): string { - const baseCommand = normalizeSetupScriptImportCommand(baseValue) + const baseCommand = normalizeCommandValue(baseValue) if (localValue === undefined) { return baseCommand } if (typeof localValue === 'string' || Array.isArray(localValue)) { - return normalizeSetupScriptImportCommand(localValue) + return normalizeCommandValue(localValue) } const localRecord = asRecord(localValue) if (!localRecord) { - pushSetupScriptImportUnsupportedField(unsupportedFields, `config.local.${key}`) + unsupportedFields.push(`config.local.${key}`) return baseCommand } - for (const field in localRecord) { - if (!Object.prototype.hasOwnProperty.call(localRecord, field)) { - continue - } + for (const field of Object.keys(localRecord)) { if (field !== 'before' && field !== 'after') { - pushSetupScriptImportUnsupportedField(unsupportedFields, `config.local.${key}.${field}`) - if (unsupportedFields.length >= SETUP_SCRIPT_IMPORT_MAX_UNSUPPORTED_FIELDS) { - break - } + unsupportedFields.push(`config.local.${key}.${field}`) } } - const beforeCommand = normalizeSetupScriptImportCommand(localRecord.before) - const afterCommand = normalizeSetupScriptImportCommand(localRecord.after) - return joinSetupScriptImportCommands([beforeCommand, baseCommand, afterCommand].filter(Boolean)) + const beforeCommand = normalizeCommandValue(localRecord.before) + const afterCommand = normalizeCommandValue(localRecord.after) + return [beforeCommand, baseCommand, afterCommand].filter(Boolean).join('\n') } function buildCmuxSetupCandidate( @@ -207,16 +198,13 @@ function buildCmuxSetupCandidate( config: Record ): SetupScriptImportCandidate | null { const commands = Array.isArray(config.commands) ? config.commands : [] - if (commands.length > SETUP_SCRIPT_IMPORT_MAX_CMUX_COMMANDS) { - return null - } for (let index = 0; index < commands.length; index++) { const command = asRecord(commands[index]) if (!command || !isCmuxSetupCommand(command)) { continue } - const setup = normalizeSetupScriptImportCommand(command.command) + const setup = normalizeCommandValue(command.command) if (!setup) { continue } @@ -233,11 +221,7 @@ function buildCmuxSetupCandidate( } function isCmuxSetupCommand(command: Record): boolean { - if ( - typeof command.command !== 'string' || - !isSetupScriptImportFieldWithinLimit(command.command) || - !command.command.trim() - ) { + if (typeof command.command !== 'string' || !command.command.trim()) { return false } @@ -265,13 +249,11 @@ function isCmuxSetupCommand(command: Record): boolean { } function normalizeMatchText(value: unknown): string { - return typeof value === 'string' && isSetupScriptImportFieldWithinLimit(value) - ? value.trim().toLowerCase().replace(/\s+/g, ' ') - : '' + return typeof value === 'string' ? value.trim().toLowerCase().replace(/\s+/g, ' ') : '' } function getStringArray(value: unknown): string[] { - return Array.isArray(value) && value.length <= SETUP_SCRIPT_IMPORT_MAX_KEYWORDS + return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : [] } @@ -281,19 +263,9 @@ function collectUnsupportedCmuxCommandFields( commandIndex: number ): string[] { const supportedFields = new Set(['name', 'title', 'description', 'keywords', 'command']) - const unsupportedFields: string[] = [] - for (const field in command) { - if (!Object.prototype.hasOwnProperty.call(command, field)) { - continue - } - if (!supportedFields.has(field)) { - pushSetupScriptImportUnsupportedField(unsupportedFields, `commands.${commandIndex}.${field}`) - if (unsupportedFields.length >= SETUP_SCRIPT_IMPORT_MAX_UNSUPPORTED_FIELDS) { - break - } - } - } - return unsupportedFields + return Object.keys(command) + .filter((field) => !supportedFields.has(field)) + .map((field) => `commands.${commandIndex}.${field}`) } function collectUnsupportedFields( @@ -314,7 +286,7 @@ function collectUnsupportedScriptObjectFields( } for (const field of ['before', 'after'] as const) { if (record[field] !== undefined) { - pushSetupScriptImportUnsupportedField(unsupportedFields, `${prefix}.${field}`) + unsupportedFields.push(`${prefix}.${field}`) } } } diff --git a/src/shared/setup-script-package-manager-suggestion.ts b/src/shared/setup-script-package-manager-suggestion.ts index 5ef8428379a..b2050c91125 100644 --- a/src/shared/setup-script-package-manager-suggestion.ts +++ b/src/shared/setup-script-package-manager-suggestion.ts @@ -3,7 +3,6 @@ import type { SetupScriptImportFileExists, SetupScriptImportFileRead } from './setup-script-imports' -import { isSetupScriptImportFieldWithinLimit } from './setup-script-import-limits' const PACKAGE_JSON_PATH = 'package.json' type PackageManagerName = 'pnpm' | 'bun' | 'yarn' | 'npm' @@ -82,7 +81,7 @@ function parsePackageJson(content: string | null): Record | nul } function getPackageManagerName(value: unknown): PackageManagerName | null { - if (typeof value !== 'string' || !isSetupScriptImportFieldWithinLimit(value)) { + if (typeof value !== 'string') { return null } const packageManager = value.trim().toLowerCase() diff --git a/src/shared/ssh-retained-payload-admission.test.ts b/src/shared/ssh-retained-payload-admission.test.ts deleted file mode 100644 index 92a4e6c90db..00000000000 --- a/src/shared/ssh-retained-payload-admission.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { getUtf8ByteLength } from './utf8-byte-limits' -import { - admitSshConnectionState, - admitSshDetectedPorts, - SSH_CONNECTION_ERROR_MAX_UTF8_BYTES, - SSH_DETECTED_PORTS_MAX_ENTRIES, - SSH_DETECTED_PORT_ADVERTISED_URL_MAX_UTF8_BYTES, - SSH_DETECTED_PORT_PROCESS_NAME_MAX_UTF8_BYTES, - SSH_RETAINED_IDENTIFIER_MAX_UTF8_BYTES -} from './ssh-retained-payload-admission' - -describe('SSH retained payload admission', () => { - it('keeps ordinary connection state while stripping unknown payload fields', () => { - const admitted = admitSshConnectionState( - { - targetId: 'ssh-a', - status: 'connected', - error: null, - reconnectAttempt: 2, - connectionGeneration: 3, - supportsFolderDownload: true, - remotePlatform: 'linux', - unexpected: 'x'.repeat(1024) - }, - 'ssh-a' - ) - - expect(admitted).toEqual({ - targetId: 'ssh-a', - status: 'connected', - error: null, - reconnectAttempt: 2, - connectionGeneration: 3, - supportsFolderDownload: true, - remotePlatform: 'linux' - }) - }) - - it('caps connection errors without splitting a UTF-8 code point', () => { - const admitted = admitSshConnectionState( - { - targetId: 'ssh-a', - status: 'error', - error: `${'x'.repeat(SSH_CONNECTION_ERROR_MAX_UTF8_BYTES - 1)}🙂tail`, - reconnectAttempt: 0 - }, - 'ssh-a' - ) - - expect(admitted).not.toBeNull() - expect(getUtf8ByteLength(admitted?.error ?? '')).toBeLessThanOrEqual( - SSH_CONNECTION_ERROR_MAX_UTF8_BYTES - ) - expect(admitted?.error?.endsWith('\ud83d')).toBe(false) - }) - - it('rejects mismatched and oversized target identifiers', () => { - const state = { - targetId: 'ssh-a', - status: 'connected', - error: null, - reconnectAttempt: 0 - } - - expect(admitSshConnectionState(state, 'ssh-b')).toBeNull() - expect( - admitSshConnectionState( - { ...state, targetId: 'x'.repeat(SSH_RETAINED_IDENTIFIER_MAX_UTF8_BYTES + 1) }, - 'x'.repeat(SSH_RETAINED_IDENTIFIER_MAX_UTF8_BYTES + 1) - ) - ).toBeNull() - }) - - it('caps port rows and their retained strings', () => { - const rows = Array.from({ length: SSH_DETECTED_PORTS_MAX_ENTRIES + 10 }, (_, index) => ({ - port: 1000 + index, - host: '127.0.0.1', - pid: index + 1, - processName: '🙂'.repeat(SSH_DETECTED_PORT_PROCESS_NAME_MAX_UTF8_BYTES), - advertisedUrl: `https://example.test/${'x'.repeat( - SSH_DETECTED_PORT_ADVERTISED_URL_MAX_UTF8_BYTES - )}`, - unexpected: 'retained only without admission' - })) - - const admitted = admitSshDetectedPorts(rows) - - expect(admitted).toHaveLength(SSH_DETECTED_PORTS_MAX_ENTRIES) - expect(getUtf8ByteLength(admitted[0].processName ?? '')).toBeLessThanOrEqual( - SSH_DETECTED_PORT_PROCESS_NAME_MAX_UTF8_BYTES - ) - expect(admitted[0].advertisedUrl).toBeUndefined() - expect(admitted[0]).not.toHaveProperty('unexpected') - }) - - it('drops malformed rows instead of retaining their payloads', () => { - expect( - admitSshDetectedPorts([ - { port: 0, host: '127.0.0.1' }, - { port: 3000, host: '' }, - { port: 3001, host: '127.0.0.1', processName: 'node' } - ]) - ).toEqual([{ port: 3001, host: '127.0.0.1', processName: 'node' }]) - }) -}) diff --git a/src/shared/ssh-retained-payload-admission.ts b/src/shared/ssh-retained-payload-admission.ts deleted file mode 100644 index 21b51787a1d..00000000000 --- a/src/shared/ssh-retained-payload-admission.ts +++ /dev/null @@ -1,138 +0,0 @@ -import type { EnrichedDetectedPort, SshConnectionState, SshConnectionStatus } from './ssh-types' -import { clampUtf8TextPrefix, measureUtf8ByteLength } from './utf8-byte-limits' - -export const SSH_RETAINED_IDENTIFIER_MAX_UTF8_BYTES = 1024 -export const SSH_CONNECTION_ERROR_MAX_UTF8_BYTES = 16 * 1024 -export const SSH_CREDENTIAL_DETAIL_MAX_UTF8_BYTES = 16 * 1024 -export const SSH_DETECTED_PORTS_MAX_ENTRIES = 50 -export const SSH_DETECTED_PORT_HOST_MAX_UTF8_BYTES = 1024 -export const SSH_DETECTED_PORT_PROCESS_NAME_MAX_UTF8_BYTES = 4 * 1024 -export const SSH_DETECTED_PORT_ADVERTISED_URL_MAX_UTF8_BYTES = 2048 - -const CONNECTION_STATUSES = new Set([ - 'disconnected', - 'connecting', - 'auth-failed', - 'deploying-relay', - 'connected', - 'reconnecting', - 'reconnection-failed', - 'error' -]) - -export function isSshRetainedIdentifier(value: unknown): value is string { - return ( - typeof value === 'string' && - value.length > 0 && - !measureUtf8ByteLength(value, { - stopAfterBytes: SSH_RETAINED_IDENTIFIER_MAX_UTF8_BYTES - }).exceededLimit - ) -} - -export function admitSshConnectionState( - value: unknown, - expectedTargetId: string -): SshConnectionState | null { - if (!value || typeof value !== 'object' || !isSshRetainedIdentifier(expectedTargetId)) { - return null - } - const input = value as Record - if ( - (input.targetId !== undefined && - (!isSshRetainedIdentifier(input.targetId) || input.targetId !== expectedTargetId)) || - typeof input.status !== 'string' || - !CONNECTION_STATUSES.has(input.status as SshConnectionStatus) || - !isNonNegativeSafeInteger(input.reconnectAttempt) || - (input.error !== null && typeof input.error !== 'string') - ) { - return null - } - - const error = clampSshConnectionError(input.error) - return { - targetId: expectedTargetId, - status: input.status as SshConnectionStatus, - error, - reconnectAttempt: input.reconnectAttempt, - ...(isNonNegativeSafeInteger(input.connectionGeneration) - ? { connectionGeneration: input.connectionGeneration } - : {}), - ...(typeof input.supportsFolderDownload === 'boolean' - ? { supportsFolderDownload: input.supportsFolderDownload } - : {}), - ...(input.remotePlatform === 'linux' || - input.remotePlatform === 'darwin' || - input.remotePlatform === 'win32' - ? { remotePlatform: input.remotePlatform } - : {}) - } -} - -export function clampSshConnectionError(error: string | null): string | null { - return typeof error === 'string' - ? clampUtf8TextPrefix(error, SSH_CONNECTION_ERROR_MAX_UTF8_BYTES) - : null -} - -export function admitSshDetectedPorts(value: unknown): EnrichedDetectedPort[] { - if (!Array.isArray(value)) { - return [] - } - const retained: EnrichedDetectedPort[] = [] - const scanLimit = Math.min(value.length, SSH_DETECTED_PORTS_MAX_ENTRIES) - for (let index = 0; index < scanLimit; index += 1) { - const port = admitDetectedPort(value[index]) - if (port) { - retained.push(port) - } - } - return retained -} - -function admitDetectedPort(value: unknown): EnrichedDetectedPort | null { - if (!value || typeof value !== 'object') { - return null - } - const input = value as Record - if ( - !Number.isSafeInteger(input.port) || - (input.port as number) < 1 || - (input.port as number) > 65_535 || - !isStringWithinLimit(input.host, SSH_DETECTED_PORT_HOST_MAX_UTF8_BYTES) - ) { - return null - } - const processName = - typeof input.processName === 'string' - ? clampUtf8TextPrefix(input.processName, SSH_DETECTED_PORT_PROCESS_NAME_MAX_UTF8_BYTES) - : undefined - const advertisedUrl = isStringWithinLimit( - input.advertisedUrl, - SSH_DETECTED_PORT_ADVERTISED_URL_MAX_UTF8_BYTES - ) - ? input.advertisedUrl - : undefined - return { - port: input.port as number, - host: input.host, - ...(isNonNegativeSafeInteger(input.pid) && input.pid > 0 ? { pid: input.pid } : {}), - ...(processName ? { processName } : {}), - ...(advertisedUrl ? { advertisedUrl } : {}), - ...(input.advertisedProtocol === 'http' || input.advertisedProtocol === 'https' - ? { advertisedProtocol: input.advertisedProtocol } - : {}) - } -} - -function isNonNegativeSafeInteger(value: unknown): value is number { - return Number.isSafeInteger(value) && (value as number) >= 0 -} - -function isStringWithinLimit(value: unknown, maxBytes: number): value is string { - return ( - typeof value === 'string' && - value.length > 0 && - !measureUtf8ByteLength(value, { stopAfterBytes: maxBytes }).exceededLimit - ) -} diff --git a/src/shared/string-chunk-compaction.test.ts b/src/shared/string-chunk-compaction.test.ts deleted file mode 100644 index dad53ba1ddf..00000000000 --- a/src/shared/string-chunk-compaction.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { appendCompactedStringChunk, RETAINED_STRING_CHUNK_LIMIT } from './string-chunk-compaction' - -describe('appendCompactedStringChunk', () => { - it('preserves 100,000 fragments within the retained chunk limit', () => { - const chunks: string[] = [] - let maxRetainedChunks = 0 - - for (let index = 0; index < 100_000; index += 1) { - appendCompactedStringChunk(chunks, String.fromCharCode(97 + (index % 26))) - maxRetainedChunks = Math.max(maxRetainedChunks, chunks.length) - } - - expect(maxRetainedChunks).toBeLessThanOrEqual(RETAINED_STRING_CHUNK_LIMIT) - expect(chunks.join('')).toHaveLength(100_000) - }) -}) diff --git a/src/shared/string-chunk-compaction.ts b/src/shared/string-chunk-compaction.ts deleted file mode 100644 index 1acb98ead89..00000000000 --- a/src/shared/string-chunk-compaction.ts +++ /dev/null @@ -1,11 +0,0 @@ -export const RETAINED_STRING_CHUNK_LIMIT = 1_024 - -export function appendCompactedStringChunk(chunks: string[], value: string): void { - chunks.push(value) - if (chunks.length <= RETAINED_STRING_CHUNK_LIMIT) { - return - } - const compacted = chunks.join('') - chunks.length = 0 - chunks.push(compacted) -} diff --git a/src/shared/terminal-size-limits.ts b/src/shared/terminal-size-limits.ts deleted file mode 100644 index fca3d9c8754..00000000000 --- a/src/shared/terminal-size-limits.ts +++ /dev/null @@ -1,34 +0,0 @@ -export const MAX_TERMINAL_COLS = 500 -export const MAX_TERMINAL_ROWS = 500 - -export function terminalSizeAdmissionError( - cols: unknown, - rows: unknown, - field: string, - options: { allowMissing?: boolean } = {} -): string | null { - for (const [name, value, max] of [ - ['cols', cols, MAX_TERMINAL_COLS], - ['rows', rows, MAX_TERMINAL_ROWS] - ] as const) { - if (options.allowMissing && value === undefined) { - continue - } - if (!Number.isSafeInteger(value) || (value as number) < 1 || (value as number) > max) { - return `${field}.${name} must be an integer from 1 through ${max}` - } - } - return null -} - -export function isValidTerminalSize(cols: unknown, rows: unknown): boolean { - return terminalSizeAdmissionError(cols, rows, 'terminal size') === null -} - -export function normalizeTerminalSize( - cols: unknown, - rows: unknown, - fallback: { cols: number; rows: number } = { cols: 80, rows: 24 } -): { cols: number; rows: number } { - return isValidTerminalSize(cols, rows) ? { cols: cols as number, rows: rows as number } : fallback -} diff --git a/src/shared/terminal-stream-protocol.test.ts b/src/shared/terminal-stream-protocol.test.ts index f8784458aac..07de145031f 100644 --- a/src/shared/terminal-stream-protocol.test.ts +++ b/src/shared/terminal-stream-protocol.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import { TerminalStreamOpcode, decodeTerminalStreamFrame, @@ -6,8 +6,7 @@ import { decodeTerminalStreamText, encodeTerminalStreamFrame, encodeTerminalStreamJson, - encodeTerminalStreamText, - TERMINAL_STREAM_JSON_STRUCTURE_LIMITS + encodeTerminalStreamText } from './terminal-stream-protocol' describe('terminal-stream-protocol', () => { @@ -139,19 +138,6 @@ describe('terminal-stream-protocol', () => { expect(ack && decodeTerminalStreamJson(ack.payload)).toEqual({ bytes: 4096 }) }) - it('rejects excessive JSON nesting before JSON.parse', () => { - const parseSpy = vi.spyOn(JSON, 'parse') - try { - const depth = TERMINAL_STREAM_JSON_STRUCTURE_LIMITS.nestingDepth + 1 - const payload = new TextEncoder().encode(`${'['.repeat(depth)}0${']'.repeat(depth)}`) - - expect(decodeTerminalStreamJson(payload)).toBeNull() - expect(parseSpy).not.toHaveBeenCalled() - } finally { - parseSpy.mockRestore() - } - }) - it('rejects unknown frame versions and opcodes', () => { const encoded = encodeTerminalStreamFrame({ opcode: TerminalStreamOpcode.Output, diff --git a/src/shared/terminal-stream-protocol.ts b/src/shared/terminal-stream-protocol.ts index 76e82eafce9..bcfa3b46d9c 100644 --- a/src/shared/terminal-stream-protocol.ts +++ b/src/shared/terminal-stream-protocol.ts @@ -1,13 +1,6 @@ -import { assertJsonTextStructureWithinLimits } from './json-text-structure-limit' - const TERMINAL_STREAM_KIND = 0x74 const TERMINAL_STREAM_VERSION = 1 const HEADER_BYTES = 16 -export const TERMINAL_STREAM_JSON_MAX_BYTES = 8 * 1024 * 1024 -export const TERMINAL_STREAM_JSON_STRUCTURE_LIMITS = { - structuralTokens: 256 * 1024, - nestingDepth: 32 -} as const export enum TerminalStreamOpcode { Output = 1, @@ -80,13 +73,8 @@ export function encodeTerminalStreamJson(value: unknown): Uint8Array { } export function decodeTerminalStreamJson(payload: Uint8Array): T | null { - if (payload.byteLength > TERMINAL_STREAM_JSON_MAX_BYTES) { - return null - } try { - const content = new TextDecoder().decode(payload) - assertJsonTextStructureWithinLimits(content, TERMINAL_STREAM_JSON_STRUCTURE_LIMITS) - return JSON.parse(content) as T + return JSON.parse(new TextDecoder().decode(payload)) as T } catch { return null } diff --git a/src/shared/text-search.test.ts b/src/shared/text-search.test.ts index 108d07a76c5..9562a366aff 100644 --- a/src/shared/text-search.test.ts +++ b/src/shared/text-search.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import { execFileSync } from 'node:child_process' import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' @@ -13,7 +13,6 @@ import { ingestRgJsonLine, MAX_LINE_CONTENT_LENGTH, normalizeRelativePath, - SEARCH_JSON_STRUCTURE_LIMITS, splitSearchGlobPatterns, toGitGlobPathspec } from './text-search' @@ -118,22 +117,6 @@ describe('ingestRgJsonLine', () => { expect(acc.totalMatches).toBe(0) }) - it('rejects excessive nesting before JSON.parse', () => { - const parseSpy = vi.spyOn(JSON, 'parse') - const acc = createAccumulator() - try { - const amplified = `${'['.repeat(SEARCH_JSON_STRUCTURE_LIMITS.nestingDepth + 1)}0${']'.repeat( - SEARCH_JSON_STRUCTURE_LIMITS.nestingDepth + 1 - )}` - - expect(ingestRgJsonLine(amplified, '/root', acc, 100)).toBe('continue') - expect(parseSpy).not.toHaveBeenCalled() - expect(acc.totalMatches).toBe(0) - } finally { - parseSpy.mockRestore() - } - }) - it('creates a navigable fallback match when rg omits submatch ranges', () => { const acc = createAccumulator() const verdict = ingestRgJsonLine(makeMatch('/root/a.ts', 4, [], 'foobar'), '/root', acc, 100) diff --git a/src/shared/text-search.ts b/src/shared/text-search.ts index d3d9b40226a..0543360d229 100644 --- a/src/shared/text-search.ts +++ b/src/shared/text-search.ts @@ -11,7 +11,6 @@ * Design doc: docs/design/share-text-search.md. */ import { posix, win32 } from 'node:path' -import { assertJsonTextStructureWithinLimits } from './json-text-structure-limit' import { normalizeSearchResult } from './search-match-count' import { escapeRegex } from './string-utils' import type { SearchFileResult, SearchMatch, SearchOptions, SearchResult } from './types' @@ -55,10 +54,6 @@ function joinSearchRoot(rootPath: string, relPath: string): string { export const MAX_MATCHES_PER_FILE = 100 export const DEFAULT_SEARCH_MAX_RESULTS = 2000 export const SEARCH_TIMEOUT_MS = 15_000 -export const SEARCH_JSON_STRUCTURE_LIMITS = { - structuralTokens: 32 * 1024, - nestingDepth: 16 -} as const // Why: keep search cheaper than opening a file; the editor read path has a larger cap (Monaco large-file handling). const SEARCH_MAX_FILE_SIZE = 5 * 1024 * 1024 @@ -250,7 +245,6 @@ export function ingestRgJsonLine( } } try { - assertJsonTextStructureWithinLimits(line, SEARCH_JSON_STRUCTURE_LIMITS) msg = JSON.parse(line) } catch { return 'continue' diff --git a/src/shared/types.ts b/src/shared/types.ts index 7e51cf92518..32f3f473154 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1310,7 +1310,6 @@ export type GitHubPRRefreshSkippedReason = | 'disconnected' | 'remote' | 'rate-limit' - | 'capacity' type GitHubPRRefreshEventBase = { sequence: number @@ -3035,7 +3034,6 @@ export type NotificationDispatchResult = { | 'not-supported' | 'not-displayed' | 'blocked-by-system' - | 'invalid-request' } export type NotificationDismissResult = { diff --git a/src/shared/utf8-byte-limits.ts b/src/shared/utf8-byte-limits.ts index c49be3fff44..39c41e3f653 100644 --- a/src/shared/utf8-byte-limits.ts +++ b/src/shared/utf8-byte-limits.ts @@ -52,24 +52,6 @@ export function clampUtf8TextTail(text: string, maxBytes: number): Utf8TextTail return { text: text.slice(start), bytes } } -export function clampUtf8TextPrefix(text: string, maxBytes: number): string { - if (!text || maxBytes <= 0) { - return '' - } - let bytes = 0 - let end = 0 - while (end < text.length) { - const codePoint = text.codePointAt(end) ?? 0 - const codePointBytes = getUtf8ByteLengthForCodePoint(codePoint) - if (bytes + codePointBytes > maxBytes) { - break - } - bytes += codePointBytes - end += codePoint > 0xffff ? 2 : 1 - } - return end === text.length ? text : text.slice(0, end) -} - export function getUtf8ByteLengthForCodePoint(codePoint: number): number { if (codePoint <= 0x7f) { return 1 diff --git a/src/shared/workspace-space-entry-traversal.test.ts b/src/shared/workspace-space-entry-traversal.test.ts deleted file mode 100644 index a74ba33f022..00000000000 --- a/src/shared/workspace-space-entry-traversal.test.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { scanWorkspaceSpaceEntryTree } from './workspace-space-entry-traversal' -import { WorkspaceSpaceScanCapacityError } from './workspace-space-scan-budget' - -type Entry = { name: string } - -function makeTraversal( - directories: ReadonlyMap, - classifyEntry: (path: string) => Promise<{ - kind: 'directory' | 'file' | 'symlink' - sizeBytes: number - }>, - limits?: { maxEntries?: number; maxRetainedBytes?: number } -) { - return scanWorkspaceSpaceEntryTree({ - rootPath: '/root', - rootName: 'root', - concurrency: 5, - entryName: (entry: Entry) => entry.name, - joinPath: (parent, child) => `${parent}/${child}`, - classifyEntry: (path) => classifyEntry(path), - readDirectory: async (path) => { - const entries = directories.get(path) - if (!entries) { - throw new Error(`unreadable ${path}`) - } - return entries - }, - checkCancelled: () => undefined, - createCancellationError: () => new Error('cancelled'), - isCancellationError: (error) => error instanceof Error && error.message === 'cancelled', - limits - }) -} - -describe('scanWorkspaceSpaceEntryTree', () => { - it('uses a fixed worker pool and preserves source order', async () => { - const entries = Array.from({ length: 200 }, (_, index) => ({ name: `file-${index}` })) - let release!: () => void - const gate = new Promise((resolve) => { - release = resolve - }) - let active = 0 - let peak = 0 - let started = 0 - let saturated!: () => void - const saturation = new Promise((resolve) => { - saturated = resolve - }) - - const scan = makeTraversal(new Map([['/root', entries]]), async (path) => { - if (path === '/root') { - return { kind: 'directory', sizeBytes: 1 } - } - active += 1 - started += 1 - peak = Math.max(peak, active) - if (started === 5) { - saturated() - } - await gate - active -= 1 - return { kind: 'file', sizeBytes: 1 } - }) - - await saturation - expect(started).toBe(5) - expect(peak).toBe(5) - release() - - const result = await scan - expect(result.children?.map((child) => child.name)).toEqual(entries.map((entry) => entry.name)) - expect(result.sizeBytes).toBe(201) - }) - - it('preserves aggregate sizes and partial-failure accounting', async () => { - const directories = new Map([ - ['/root', [{ name: 'directory' }, { name: 'missing' }, { name: 'link' }, { name: 'file' }]], - ['/root/directory', [{ name: 'nested' }, { name: 'unreadable' }]], - ['/root/directory/unreadable', []] - ]) - directories.delete('/root/directory/unreadable') - - const result = await makeTraversal(directories, async (path) => { - if (path === '/root') { - return { kind: 'directory', sizeBytes: 10 } - } - if (path === '/root/directory') { - return { kind: 'directory', sizeBytes: 5 } - } - if (path === '/root/directory/nested') { - return { kind: 'file', sizeBytes: 100 } - } - if (path === '/root/directory/unreadable') { - return { kind: 'directory', sizeBytes: 7 } - } - if (path === '/root/missing') { - throw new Error('missing') - } - if (path === '/root/link') { - return { kind: 'symlink', sizeBytes: 2 } - } - return { kind: 'file', sizeBytes: 20 } - }) - - expect(result).toMatchObject({ sizeBytes: 144, skippedEntryCount: 2 }) - expect(result.children?.map((child) => child.name)).toEqual(['directory', 'link', 'file']) - expect(result.children?.[0]).toMatchObject({ - sizeBytes: 112, - skippedEntryCount: 1 - }) - }) - - it('accepts the exact entry cap without changing order or totals', async () => { - const entries = [{ name: 'first' }, { name: 'second' }] - const result = await makeTraversal( - new Map([['/root', entries]]), - async (path) => ({ kind: path === '/root' ? 'directory' : 'file', sizeBytes: 1 }), - { maxEntries: entries.length } - ) - - expect(result.children?.map((child) => child.name)).toEqual(['first', 'second']) - expect(result.sizeBytes).toBe(3) - }) - - it('fails closed instead of retaining entries beyond the scan cap', async () => { - const entries = [{ name: 'first' }, { name: 'second' }, { name: 'overflow' }] - const scan = makeTraversal( - new Map([['/root', entries]]), - async (path) => ({ kind: path === '/root' ? 'directory' : 'file', sizeBytes: 1 }), - { maxEntries: entries.length - 1 } - ) - - await expect(scan).rejects.toBeInstanceOf(WorkspaceSpaceScanCapacityError) - }) - - it('aggregates a deep chain exactly at the entry cap without recursive unwinding', async () => { - const depth = 256 - const directories = new Map() - let path = '/root' - for (let index = 0; index < depth; index += 1) { - const name = `directory-${index}` - directories.set(path, [{ name }]) - path = `${path}/${name}` - } - directories.set(path, []) - - const result = await makeTraversal( - directories, - async () => ({ kind: 'directory', sizeBytes: 1 }), - { maxEntries: depth } - ) - - expect(result.sizeBytes).toBe(depth + 1) - expect(result.children).toEqual([ - expect.objectContaining({ name: 'directory-0', sizeBytes: depth }) - ]) - }) - - it('fails closed when a deep chain crosses the cumulative entry cap', async () => { - const directories = new Map() - let path = '/root' - for (let index = 0; index < 5; index += 1) { - const name = `directory-${index}` - directories.set(path, [{ name }]) - path = `${path}/${name}` - } - directories.set(path, []) - - const scan = makeTraversal(directories, async () => ({ kind: 'directory', sizeBytes: 1 }), { - maxEntries: 4 - }) - - await expect(scan).rejects.toBeInstanceOf(WorkspaceSpaceScanCapacityError) - }) -}) diff --git a/src/shared/workspace-space-entry-traversal.ts b/src/shared/workspace-space-entry-traversal.ts deleted file mode 100644 index 037df0562b2..00000000000 --- a/src/shared/workspace-space-entry-traversal.ts +++ /dev/null @@ -1,318 +0,0 @@ -import type { WorkspaceSpaceItemKind } from './workspace-space-types' -import { - collectWorkspaceSpaceDirectoryEntries, - createWorkspaceSpaceScanBudget, - WorkspaceSpaceScanCapacityError, - type WorkspaceSpaceScanBudget, - type WorkspaceSpaceScanLimits -} from './workspace-space-scan-budget' - -type ScannableWorkspaceSpaceItemKind = Exclude - -export type WorkspaceSpaceEntryScan = { - name: string - path: string - kind: ScannableWorkspaceSpaceItemKind - sizeBytes: number - skippedEntryCount: number - children?: WorkspaceSpaceEntryScan[] -} - -type WorkspaceSpaceEntryIdentity = { - kind: ScannableWorkspaceSpaceItemKind - sizeBytes: number -} - -type WorkspaceSpaceEntryTraversalOptions = { - rootPath: string - rootName: string - concurrency: number - signal?: AbortSignal - entryName: (entry: TEntry) => string - joinPath: (parent: string, child: string) => string - classifyEntry: (path: string, sourceEntry: TEntry | null) => Promise - readDirectory: (path: string) => Promise | Iterable> - checkCancelled: () => void - createCancellationError: () => Error - isCancellationError: (error: unknown) => boolean - limits?: Partial -} - -type ParentSlot = { - frame: DirectoryFrame - index: number -} - -type DirectoryFrame = { - result: WorkspaceSpaceEntryScan - entries: readonly TEntry[] - nextIndex: number - remainingChildren: number - childResults?: (WorkspaceSpaceEntryScan | null | undefined)[] - parentSlot?: ParentSlot -} - -type EntryJob = { - frame: DirectoryFrame - index: number - entry: TEntry - name: string - path: string -} - -function createEntryScan( - path: string, - name: string, - identity: WorkspaceSpaceEntryIdentity -): WorkspaceSpaceEntryScan { - return { - name, - path, - kind: identity.kind, - sizeBytes: identity.sizeBytes, - skippedEntryCount: 0 - } -} - -async function readDirectoryOrNull( - path: string, - options: WorkspaceSpaceEntryTraversalOptions, - budget: WorkspaceSpaceScanBudget -): Promise { - try { - const directory = await options.readDirectory(path) - const entries = await collectWorkspaceSpaceDirectoryEntries( - directory, - path, - options.entryName, - budget, - options.checkCancelled - ) - options.checkCancelled() - return entries - } catch (error) { - if (options.isCancellationError(error) || error instanceof WorkspaceSpaceScanCapacityError) { - throw error - } - return null - } -} - -/** - * Scans one directory tree with a fixed worker pool. Directory frames retain - * the source arrays returned by readdir, but never allocate one promise or - * queued closure per entry; only the configured workers own live entry jobs. - */ -export async function scanWorkspaceSpaceEntryTree( - options: WorkspaceSpaceEntryTraversalOptions -): Promise { - const budget = createWorkspaceSpaceScanBudget(options.limits) - options.checkCancelled() - const rootIdentity = await options.classifyEntry(options.rootPath, null) - options.checkCancelled() - const root = createEntryScan(options.rootPath, options.rootName, rootIdentity) - if (root.kind !== 'directory') { - return root - } - - const rootEntries = await readDirectoryOrNull(options.rootPath, options, budget) - if (rootEntries === null) { - root.skippedEntryCount = 1 - return root - } - if (rootEntries.length === 0) { - root.children = [] - return root - } - - const rootFrame: DirectoryFrame = { - result: root, - entries: rootEntries, - nextIndex: 0, - remainingChildren: rootEntries.length, - childResults: Array.from({ length: rootEntries.length }, () => undefined) - } - const availableFrames: DirectoryFrame[] = [rootFrame] - const waiters = new Set<() => void>() - let outstandingEntries = rootEntries.length - let fatalError: unknown = null - - const wakeWorkers = (): void => { - for (const wake of waiters) { - wake() - } - } - const fail = (error: unknown): void => { - fatalError ??= error - wakeWorkers() - } - const onAbort = (): void => fail(options.createCancellationError()) - options.signal?.addEventListener('abort', onAbort, { once: true }) - if (options.signal?.aborted) { - onAbort() - } - - const takeAvailableJob = (): EntryJob | null => { - while (availableFrames.length > 0) { - const frame = availableFrames.at(-1)! - if (frame.nextIndex >= frame.entries.length) { - availableFrames.pop() - continue - } - const index = frame.nextIndex - frame.nextIndex += 1 - if (frame.nextIndex >= frame.entries.length) { - availableFrames.pop() - } - const entry = frame.entries[index] - const name = options.entryName(entry) - return { - frame, - index, - entry, - name, - path: options.joinPath(frame.result.path, name) - } - } - return null - } - - const waitForJob = async (): Promise | null> => { - while (fatalError === null) { - options.checkCancelled() - const job = takeAvailableJob() - if (job) { - return job - } - if (outstandingEntries === 0) { - return null - } - await new Promise((resolve) => { - const wake = (): void => { - waiters.delete(wake) - resolve() - } - waiters.add(wake) - }) - } - return null - } - - const completeChild = ( - initialFrame: DirectoryFrame, - initialIndex: number, - initialResult: WorkspaceSpaceEntryScan | null - ): void => { - let frame = initialFrame - let index = initialIndex - let result = initialResult - while (true) { - if (frame.childResults) { - frame.childResults[index] = result - } - if (result) { - frame.result.sizeBytes += result.sizeBytes - frame.result.skippedEntryCount += result.skippedEntryCount - } else { - frame.result.skippedEntryCount += 1 - } - frame.remainingChildren -= 1 - outstandingEntries -= 1 - if (frame.remainingChildren > 0) { - break - } - if (frame.childResults) { - frame.result.children = frame.childResults.filter( - (child): child is WorkspaceSpaceEntryScan => child != null - ) - } - if (!frame.parentSlot) { - break - } - result = frame.result - index = frame.parentSlot.index - frame = frame.parentSlot.frame - } - wakeWorkers() - } - - const expandDirectory = ( - job: EntryJob, - result: WorkspaceSpaceEntryScan, - entries: readonly TEntry[] - ): void => { - if (entries.length === 0) { - completeChild(job.frame, job.index, result) - return - } - outstandingEntries += entries.length - availableFrames.push({ - result, - entries, - nextIndex: 0, - remainingChildren: entries.length, - parentSlot: { frame: job.frame, index: job.index } - }) - wakeWorkers() - } - - const processJob = async (job: EntryJob): Promise => { - let identity: WorkspaceSpaceEntryIdentity - try { - identity = await options.classifyEntry(job.path, job.entry) - options.checkCancelled() - } catch (error) { - if (options.isCancellationError(error)) { - throw error - } - completeChild(job.frame, job.index, null) - return - } - - const result = createEntryScan(job.path, job.name, identity) - if (result.kind !== 'directory') { - completeChild(job.frame, job.index, result) - return - } - const entries = await readDirectoryOrNull(job.path, options, budget) - if (entries === null) { - result.skippedEntryCount = 1 - completeChild(job.frame, job.index, result) - return - } - expandDirectory(job, result, entries) - } - - const worker = async (): Promise => { - while (fatalError === null) { - let job: EntryJob | null - try { - job = await waitForJob() - } catch (error) { - fail(error) - return - } - if (!job) { - return - } - try { - await processJob(job) - } catch (error) { - fail(error) - return - } - } - } - - const workerCount = Math.max(1, Math.floor(options.concurrency)) - try { - await Promise.all(Array.from({ length: workerCount }, worker)) - } finally { - options.signal?.removeEventListener('abort', onAbort) - wakeWorkers() - } - if (fatalError !== null) { - throw fatalError - } - return root -} diff --git a/src/shared/workspace-space-scan-budget.test.ts b/src/shared/workspace-space-scan-budget.test.ts deleted file mode 100644 index 69b34b2a86c..00000000000 --- a/src/shared/workspace-space-scan-budget.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - collectWorkspaceSpaceDirectoryEntries, - createWorkspaceSpaceScanBudget, - estimateWorkspaceSpaceEntryRetainedBytes, - WorkspaceSpaceScanCapacityError -} from './workspace-space-scan-budget' - -describe('workspace space scan budget', () => { - it('preserves entries exactly at the retained-byte cap', async () => { - const entries = [{ name: 'first' }, { name: 'second' }] - const parentPath = '/workspace' - const exactBytes = entries.reduce( - (total, entry) => total + estimateWorkspaceSpaceEntryRetainedBytes(parentPath, entry.name), - 0 - ) - - await expect( - collectWorkspaceSpaceDirectoryEntries( - entries, - parentPath, - (entry) => entry.name, - createWorkspaceSpaceScanBudget({ maxRetainedBytes: exactBytes }), - () => undefined - ) - ).resolves.toEqual(entries) - }) - - it('closes an async directory iterator when the next entry exceeds the budget', async () => { - let closed = false - async function* directory() { - try { - yield { name: 'accepted' } - yield { name: 'overflow' } - } finally { - closed = true - } - } - - await expect( - collectWorkspaceSpaceDirectoryEntries( - directory(), - '/workspace', - (entry) => entry.name, - createWorkspaceSpaceScanBudget({ maxEntries: 1 }), - () => undefined - ) - ).rejects.toBeInstanceOf(WorkspaceSpaceScanCapacityError) - expect(closed).toBe(true) - }) -}) diff --git a/src/shared/workspace-space-scan-budget.ts b/src/shared/workspace-space-scan-budget.ts deleted file mode 100644 index 693fd0241bc..00000000000 --- a/src/shared/workspace-space-scan-budget.ts +++ /dev/null @@ -1,87 +0,0 @@ -export const WORKSPACE_SPACE_MAX_SCANNED_ENTRIES = 100_000 -export const WORKSPACE_SPACE_MAX_RETAINED_SCAN_BYTES = 64 * 1024 * 1024 - -const WORKSPACE_SPACE_ENTRY_OVERHEAD_BYTES = 512 - -export type WorkspaceSpaceScanLimits = { - maxEntries: number - maxRetainedBytes: number -} - -export type WorkspaceSpaceScanBudget = { - entries: number - retainedBytes: number - limits: WorkspaceSpaceScanLimits -} - -export class WorkspaceSpaceScanCapacityError extends Error { - constructor() { - super( - 'Workspace is too large to scan safely (limit: 100,000 entries or 64 MiB retained scan state)' - ) - this.name = 'WorkspaceSpaceScanCapacityError' - } -} - -export function createWorkspaceSpaceScanBudget( - requested?: Partial -): WorkspaceSpaceScanBudget { - return { - entries: 0, - retainedBytes: 0, - limits: { - maxEntries: clampLimit(requested?.maxEntries, WORKSPACE_SPACE_MAX_SCANNED_ENTRIES), - maxRetainedBytes: clampLimit( - requested?.maxRetainedBytes, - WORKSPACE_SPACE_MAX_RETAINED_SCAN_BYTES - ) - } - } -} - -export function estimateWorkspaceSpaceEntryRetainedBytes( - parentPath: string, - entryName: string -): number { - return (parentPath.length + entryName.length) * 2 + WORKSPACE_SPACE_ENTRY_OVERHEAD_BYTES -} - -export function retainWorkspaceSpaceScanEntry( - budget: WorkspaceSpaceScanBudget, - parentPath: string, - entryName: string -): void { - const retainedBytes = - budget.retainedBytes + estimateWorkspaceSpaceEntryRetainedBytes(parentPath, entryName) - if ( - budget.entries >= budget.limits.maxEntries || - retainedBytes > budget.limits.maxRetainedBytes - ) { - throw new WorkspaceSpaceScanCapacityError() - } - budget.entries += 1 - budget.retainedBytes = retainedBytes -} - -export async function collectWorkspaceSpaceDirectoryEntries( - directory: AsyncIterable | Iterable, - parentPath: string, - entryName: (entry: TEntry) => string, - budget: WorkspaceSpaceScanBudget, - checkCancelled: () => void -): Promise { - const entries: TEntry[] = [] - for await (const entry of directory) { - checkCancelled() - retainWorkspaceSpaceScanEntry(budget, parentPath, entryName(entry)) - entries.push(entry) - } - return entries -} - -function clampLimit(value: number | undefined, maximum: number): number { - if (typeof value !== 'number' || !Number.isSafeInteger(value) || value <= 0) { - return maximum - } - return Math.min(value, maximum) -} diff --git a/src/shared/wsl-hook-relay-contract.ts b/src/shared/wsl-hook-relay-contract.ts index ed6e147de18..ea1d763d657 100644 --- a/src/shared/wsl-hook-relay-contract.ts +++ b/src/shared/wsl-hook-relay-contract.ts @@ -44,25 +44,10 @@ export const WSL_HOOK_FS_METHODS = { mkdir: 'wslfs.mkdir' } as const -/** Hard guest-side ceilings. Host requests may lower them but cannot raise them. */ -export const WSL_HOOK_FS_MAX_READ_BYTES = 64 * 1024 * 1024 -export const WSL_HOOK_FS_MAX_DIRECTORY_ENTRIES = 10_000 -export const WSL_HOOK_FS_MAX_DIRECTORY_RETAINED_BYTES = 2 * 1024 * 1024 - -export type WslHookFsDirectoryLimits = { - maxEntries: number - maxRetainedBytes: number -} - /** Result envelope for every fs-bridge method. Errors travel as data (not * JSON-RPC faults) so the host adapter can map POSIX errno onto the ssh2 * status codes the shared installer error-classifiers already understand. */ -export type WslFsFailure = { - ok: false - errno: string - message: string - fileCapacity?: { observedBytes: number; maxBytes: number } -} +export type WslFsFailure = { ok: false; errno: string; message: string } export type WslFsResult = ({ ok: true } & T) | WslFsFailure /** Where the guest relay publishes its endpoint file. Keyed by the stable