Revert "fix(memory): bound OOM-prone accumulators (#10179)" (#10255)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Neil
2026-07-23 18:35:31 -07:00
committed by GitHub
co-authored by Orca
parent 6eb70d8370
commit aab112933e
1577 changed files with 15166 additions and 91654 deletions
-4
View File
@@ -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
@@ -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
)
}
}
})
-8
View File
@@ -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('')
-8
View File
@@ -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",
+59 -62
View File
@@ -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<Map<string, { x: number; width: number }>>(new Map())
const [markdownDocs, setMarkdownDocs] = useState<Map<string, MarkdownDocState>>(new Map())
const markdownDocsRef = useRef<Map<string, MarkdownDocState>>(new Map())
const markdownDocLifecycleRef = useRef(new MobileSessionMarkdownDocLifecycle())
const [fileDocs, setFileDocs] = useState<Map<string, FileDocState>>(new Map())
const fileDocLifecycleRef = useRef(new MobileSessionFileDocLifecycle())
const [diffComments, setDiffComments] = useState<DiffComment[]>([])
const diffCommentsRef = useRef<DiffComment[]>([])
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) {
+89 -87
View File
@@ -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<Record<string, string>>({})
const [expandedPrFilePath, setExpandedPrFilePath] = useState<string | null>(null)
const [prFileContents, setPrFileContents] = useState<Record<string, GitHubPRFileContents>>({})
const [prFileLoadingPath, setPrFileLoadingPath] = useState<string | null>(null)
const [prFileCommentDrafts, setPrFileCommentDrafts] = useState<Record<string, string>>({})
const [copiedLinkKey, setCopiedLinkKey] = useState<string | null>(null)
const copiedLinkResetTimerRef = useRef<ReturnType<typeof setTimeout> | 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] ? (
<GitHubPrFileDiff
filePath={file.path}
contents={prFileContents[file.path]!}
contents={prFileContents[file.path]}
commentDrafts={prFileCommentDrafts}
disabled={projectMutating}
onCommentDraftChange={(draftKey, next) =>
@@ -13266,7 +13268,7 @@ export default function MobileTasksScreen() {
) : prFileContents[file.path] ? (
<GitHubPrFileDiff
filePath={file.path}
contents={prFileContents[file.path]!}
contents={prFileContents[file.path]}
commentDrafts={prFileCommentDrafts}
disabled={mutatingStatus}
onCommentDraftChange={(draftKey, next) =>
+4 -9
View File
@@ -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') {
@@ -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
}
-36
View File
@@ -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()
}
}
@@ -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) => {
+14 -43
View File
@@ -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 {
+61 -16
View File
@@ -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<string, BrowserFrameCacheEntry>()
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
@@ -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()
})
})
@@ -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<string, RetainedFrame>()
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')}`
}
@@ -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')
})
})
@@ -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<string, MobileBrowserViewMode>()
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
}
-80
View File
@@ -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()
})
})
+1 -50
View File
@@ -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<HomeSnapshot | null> {
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<HomeSnapshot | null> {
// 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
}
}
-50
View File
@@ -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: []
})
})
})
-126
View File
@@ -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<string, CacheEntry>()
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
}
+2 -20
View File
@@ -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()
})
})
+24 -18
View File
@@ -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<string, CachedRepos>()
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
}
+2 -20
View File
@@ -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()
})
})
+27 -18
View File
@@ -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<string, CachedWorktrees>()
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
}
+24 -15
View File
@@ -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<CustomKey[]> {
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<void> {
await AsyncStorage.setItem(CUSTOM_ACCESSORY_KEYS_STORAGE_KEY, JSON.stringify(keys))
}
export function CustomKeyModal({ visible, onClose, onKeysChanged, onManageShortcuts }: Props) {
const [step, setStep] = useState<Step>('choose-type')
const [shortcutKey, setShortcutKey] = useState('c')
@@ -94,7 +104,8 @@ export function CustomKeyModal({ visible, onClose, onKeysChanged, onManageShortc
async (key: Omit<CustomKey, 'id'>) => {
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}
/>
<Text style={styles.fieldLabel}>Command</Text>
<TextInput
@@ -372,7 +382,6 @@ export function CustomKeyModal({ visible, onClose, onKeysChanged, onManageShortc
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoCorrect={false}
maxLength={CUSTOM_ACCESSORY_KEY_MAX_BYTES_CHARACTERS - 1}
/>
<View style={styles.switchRow}>
<Text style={styles.switchLabel}>Press Enter</Text>
@@ -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
@@ -167,10 +167,7 @@ function TableBlock({
block: Extract<MarkdownBlock, { kind: 'table' }>
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 (
<ScrollView
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { COMMENT_MARKDOWN_LIMITS, parseInline, parseMarkdownBlocks } from './markdown-blocks'
import { parseInline, parseMarkdownBlocks } from './markdown-blocks'
describe('parseMarkdownBlocks', () => {
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 = '<br>'.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]
@@ -40,20 +40,6 @@ const ORDERED = /^\s*\d+[.)]\s+(.*)$/
const HTML_BLOCK = /<(details|blockquote)\b[^>]*>([\s\S]*?)<\/\1>/i
const SUMMARY = /<summary\b[^>]*>([\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 <b>/<kbd>/<sub> etc. don't
// show literally. Conservative: only matches `<tag ...>` / `</tag>` 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 <br> before block parsing.
const cleaned = content.replace(/<!--[\s\S]*?-->/g, '').replace(/<br\s*\/?>/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 <details>/<blockquote> 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
}
@@ -132,27 +132,6 @@ describe('DictationSetupPollController', () => {
poller.dispose()
})
it('shares one completion while repeated manual refreshes are pending', async () => {
const requests = [deferred<boolean>(), deferred<boolean>()]
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)
@@ -11,7 +11,7 @@ export class DictationSetupPollController {
private timer: ReturnType<typeof setTimeout> | null = null
private inFlight = false
private immediateRefreshPending = false
private refreshCompletion: { promise: Promise<void>; 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<void>((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<PollState>): 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()
}
}
}
@@ -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<RpcResponse> => {
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<RpcResponse> => {
+46 -84
View File
@@ -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<DirectoryLoadRevisions>(createDirectoryLoadRevisions())
const pendingDirectoryRetriesRef = useRef<Set<string>>(new Set())
const directoryCacheRef = useRef<DirectoryCache>({})
const directoryCacheAccessRef = useRef(0)
const [directoryCache, setDirectoryCache] = useState<DirectoryCache>({})
const [expanded, setExpanded] = useState<Set<string>>(() => new Set())
const expandedRef = useRef(expanded)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(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(
@@ -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)
})
})
@@ -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,
+11 -153
View File
@@ -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'
)
})
})
+35 -167
View File
@@ -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<string, Map<string, MobileDirEntry>>
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<string, Map<string, boolean>>()
const ensureDir = (path: string): Map<string, boolean> => {
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<string, ReadonlyMap<string, MobileDirEntry>>
): 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
})
)
}
])
)
}
-1
View File
@@ -22,7 +22,6 @@ export type DirectoryState = {
entries: MobileDirEntry[]
loading?: boolean
error?: string
lastAccess?: number
}
export type DirectoryCache = Record<string, DirectoryState | undefined>
@@ -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
@@ -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'
)
})
})
@@ -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<string>,
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<string>,
evictedPaths: readonly string[]
): Set<string> {
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<string, unknown>
return (
typeof entry.name === 'string' &&
typeof entry.isDirectory === 'boolean' &&
(entry.isSymlink === undefined || typeof entry.isSymlink === 'boolean')
)
}
function directoryAncestors(relativePath: string): Set<string> {
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<string>,
essentialPaths: ReadonlySet<string>
): 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
}
@@ -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(
@@ -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}`
}
}
+4 -24
View File
@@ -31,16 +31,6 @@ function clientOf(byMethod: Record<string, RpcResponse>): {
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'])
})
@@ -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<never>(() => undefined))
const failActiveDictation = vi.fn()
const pendingChunks = new Set<Promise<void>>()
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()
})
})
@@ -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
@@ -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()
@@ -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<void> = Promise.resolve()
@@ -52,34 +51,7 @@ function withNativeCallTimeout(nativeCall: Promise<void>): Promise<void> {
})
}
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<void> {
assertTrackedTagCapacity(tag)
const nativeActivation = activateKeepAwakeAsync(tag)
try {
await withNativeCallTimeout(nativeActivation)
@@ -262,12 +234,3 @@ export function createMobileDictationKeepAwakeOwner(): MobileDictationKeepAwakeO
export function drainMobileDictationKeepAwakeCleanup(): Promise<void> {
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()
}
@@ -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<NotificationPermissionState> {
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<boolean> {
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<void> {
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<string | null> {
// 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<string> {
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<void> {
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<void> | 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()
})
}
@@ -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
)
})
})
@@ -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
}
}
@@ -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<string>()
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
+230 -70
View File
@@ -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<string | null>
dismissAfterSchedule?: boolean
}
const scheduledNotificationsByHostAndNotificationId = new Map<string, ScheduledNotificationState>()
// 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<NotificationPermissionState> {
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<boolean> {
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<void> {
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<void> {
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<void> {
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)
}
})
@@ -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<string | null>
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<string, MobileScheduledNotificationState>()
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
}
}
@@ -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
}
@@ -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'))
})
})
@@ -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
}
}
@@ -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)
})
})
@@ -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<string> {
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<string> {
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<PickedMobileImage | null> {
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<PickedMobileImage | null> {
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<PickedMobileImage | null> {
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)
}
@@ -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<string, TestFileDoc>()
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<MobileFileTabDoc>((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)
})
})
@@ -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<string, string>
activeRequestIdByTabId: Map<string, number>
}
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<string, MobileSessionFileDoc>) => Map<string, MobileSessionFileDoc>
) => 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<MobileFileTabDoc>
): Promise<void> {
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<string> {
const nextIdentityById = new Map(tabs.map((tab) => [tab.id, getMobileFileTabIdentity(tab)]))
const replacedTabIds = new Set<string>()
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<T>(
docs: Map<string, T>,
lifecycle: MobileFileDocLifecycle,
replacedTabIds?: ReadonlySet<string>
): Map<string, T> {
let next: Map<string, T> | 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<T>(docs: Map<string, T>, tabId: string): Map<string, T> {
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"
}
@@ -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<MobileSessionTab, { type: 'markdown' }>
type ReadyMarkdownDoc = Extract<MarkdownDocState, { status: 'ready' }>
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<string, MarkdownDocState>([
['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<string, MarkdownDocState>([['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<string, MarkdownDocState>()
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<ReadyMarkdownDoc>((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<string, MarkdownDocState>()
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<ReadyMarkdownDoc>((resolve) => {
resolveRead = resolve
})
)
lifecycle.reconcile([markdownTab('same', 'new.md')], updateDocs)
resolveRead(readyDoc('old payload'))
await pending
expect(docs.size).toBe(0)
})
})
@@ -1,147 +0,0 @@
import type {
MarkdownDocState,
MobileSessionTab
} from '../../app/h/[hostId]/session/mobile-session-route-types'
type MobileMarkdownTab = Extract<MobileSessionTab, { type: 'markdown' }>
type MobileMarkdownDocUpdater = (
update: (current: Map<string, MarkdownDocState>) => Map<string, MarkdownDocState>
) => 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<string, string>()
private readonly activeRequestIdByTabId = new Map<string, number>()
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<string>()
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<Extract<MarkdownDocState, { status: 'ready' }>>
): Promise<void> {
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<string, MarkdownDocState>,
liveIdentityById: ReadonlyMap<string, string>,
replacedTabIds: ReadonlySet<string>
): Map<string, MarkdownDocState> {
let next: Map<string, MarkdownDocState> | 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<string, MarkdownDocState>,
tabId: string
): Map<string, MarkdownDocState> {
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])
}
@@ -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()
})
})
@@ -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<T>(map: Map<string, T>, 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)
}
}
+1 -34
View File
@@ -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<T>() {
@@ -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<GitHubPrMutationOutcome>()
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)
})
})
-23
View File
@@ -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<boolean> {
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<boolean>(this.cfg.onChange)
this.reviewerFields.set(login, f)
}
return f
}
private releaseReviewerFieldIfIdle(login: string, field: OptimisticField<boolean>): 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
}
}
@@ -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<typeof useMobileNativeChatFileSearch>
@@ -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'])
})
})
@@ -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') {
+1 -130
View File
@@ -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<T>(): { promise: Promise<T>; 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<string, 'chat'>,
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<void>()
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', () => {
+10 -51
View File
@@ -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<void> {
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<Set<string>> {
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<string>): Promise<void> {
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<unknown>)
: []
const retained = new Map<string, string>()
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]))
}
+16 -111
View File
@@ -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<void> | null = null
const overrideUpdateBarriers = new Map<string, Promise<void>>()
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>): 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<string, MobileSessionView>()
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<string, MobileSessionView>()
const record = parsed as Record<string, unknown>
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<string, unknown>).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<SessionViewOverridesPreference> {
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<void> {
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<string, MobileSessionView>,
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, MobileSessionView>
): 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()
}
@@ -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')
+77 -61
View File
@@ -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 }
}
@@ -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
}
@@ -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({})
})
})
@@ -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<Record<string, GitHubPRFileContents | undefined>>
}
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<string, CacheEntry>()
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<Record<string, GitHubPRFileContents | undefined>> {
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
}
@@ -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<unknown>
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<Record<string, GitHubPRFileContents | undefined>>
load: (
scope: string,
file: MobilePrFileContentKeyInput,
loadContents: MobilePrFileContentLoad,
setError: MobilePrFileContentErrorSetter
) => Promise<void>
loadingPath: string | null
} {
const [cache] = useState(() => new MobilePrFileContentCache())
const [snapshot, setSnapshot] = useState(() => cache.snapshot())
const [loadingPath, setLoadingPath] = useState<string | null>(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<void> => {
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<GitHubPRFileContents>
return (
typeof contents.original === 'string' &&
typeof contents.modified === 'string' &&
typeof contents.originalIsBinary === 'boolean' &&
typeof contents.modifiedIsBinary === 'boolean'
)
}
@@ -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([])
})
})
@@ -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<CustomKey[]> {
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<CustomKey[]> {
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<CustomKey>
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 }
}
@@ -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()
@@ -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>): string[] {
const seen = new Set<string>()
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<TerminalAccessoryLa
if (!raw) {
return defaultPreference()
}
if (raw.length > 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<TerminalAccessoryLa
export async function saveTerminalAccessoryLayout(layout: TerminalAccessoryLayout): Promise<void> {
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))
}
@@ -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'
}
+41 -36
View File
@@ -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 = `<!DOCTYPE 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;
@@ -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<typeof createTerminalWebViewPendingMessages>) {
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
})
})
})
@@ -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<TerminalWebViewCommand, { type: 'init' }>) => {
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) => {
@@ -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')
@@ -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', () => {
@@ -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()
@@ -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
}
@@ -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('')
})
})
@@ -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)
})
})
@@ -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
}
@@ -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'])
})
})
+12 -156
View File
@@ -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<ConnectionLogByteLimits> = {}
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<string, RetainedHostLog>()
const entriesByHost = new Map<string, ConnectionLogEntry[]>()
const listenersByHost = new Map<string, Set<() => 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<string, readonly ConnectionLogEntry[]>()
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()
}
}
}
}
}
+3 -16
View File
@@ -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
}
@@ -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<void>(() => {}))
const replacement = registry.register('host-a', new Promise<void>(() => {}))
const second = registry.register('host-b', new Promise<void>(() => {}))
const third = registry.register('host-c', new Promise<void>(() => {}))
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)
})
})
@@ -3,35 +3,15 @@ export type HostClientOpenTicket = {
promise: Promise<void>
}
export const HOST_CLIENT_OPEN_MAX_PENDING = 64
export class HostClientOpenRegistry {
private readonly pending = new Map<string, HostClientOpenTicket>()
constructor(private readonly maxPending = HOST_CLIENT_OPEN_MAX_PENDING) {}
getActivePromise(hostId: string): Promise<void> | null {
const ticket = this.pending.get(hostId)
return ticket && !ticket.cancelled ? ticket.promise : null
}
register(hostId: string, promise: Promise<void>): 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
@@ -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<void>(() => 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)
})
})
@@ -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<void>
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<string>()
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<vo
if (sameIdList(current.ids, next)) {
return
}
await AsyncStorage.setItem(PENDING_STORAGE_KEY, serializePendingIds(next))
await AsyncStorage.setItem(PENDING_STORAGE_KEY, JSON.stringify(next))
notifyPendingListeners()
})
pendingMutation = mutation.catch(() => {})
@@ -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<boolean> {
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<CleanupAttemptResult[]> {
const outcomes: CleanupAttemptResult[] = []
let nextIndex = 0
const worker = async (): Promise<void> => {
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()

Some files were not shown because too many files have changed in this diff Show More