fix(markdown): bound Markdown document listing and renderer retention

Markdown link completion listed every .md/.mdx/.markdown file under a
workspace root with no ceiling on count, aggregate metadata, path length,
directories visited, or recursion depth, and the renderer retained one
document array per worktree forever. A large monorepo — or a symlink-free
but very deep tree — could grow both the main-process listing and the
renderer cache without limit.

- Add a shared listing budget (20,000 documents / 8 MiB metadata /
  64 KiB path / 100,000 visited entries / depth 256) enforced by the
  local, SSH, and relay producers alike. Caller-supplied limits are
  clamped down, never up, so the ceiling is enforced rather than advisory.
- Stream directory entries with opendir instead of materializing each
  directory's full entry array.
- Bound renderer retention to an LRU of 8 worktree snapshots capped at
  32 MiB aggregate.
- Surface capacity failures as a typed error (relay code -33008) and a
  single toast rather than a silent empty list.
This commit is contained in:
Neil
2026-07-27 12:58:12 -07:00
parent 9b8c7b417d
commit 08470f8b8e
30 changed files with 1540 additions and 92 deletions
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { parseInline, parseMarkdownBlocks } from './markdown-blocks'
import { COMMENT_MARKDOWN_LIMITS, parseInline, parseMarkdownBlocks } from './markdown-blocks'
describe('parseMarkdownBlocks', () => {
it('classifies headings, fenced code, quotes, lists, hr, and paragraphs', () => {
@@ -100,6 +100,42 @@ 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,6 +40,20 @@ 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.
@@ -48,9 +62,56 @@ 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')
return parseSegment(cleaned)
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}` }]
}
// Splits a segment at top-level <details>/<blockquote> regions (preserving order),
@@ -120,8 +181,17 @@ 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() !== '') {
rows.push(splitTableRow(lines[i]))
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)
i += 1
}
blocks.push({ kind: 'table', headers, rows, align })
@@ -207,12 +277,18 @@ 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
}
@@ -0,0 +1,106 @@
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)
})
})
@@ -0,0 +1,147 @@
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])
}
+4
View File
@@ -114,6 +114,10 @@ vi.mock('electron', () => ({
vi.mock('fs/promises', () => ({
readdir: readdirMock,
// Why: markdown discovery streams directory entries via opendir instead of
// materializing them. `for await` accepts a plain array, so delegating keeps
// the readdir fixtures below as the single source of directory contents.
opendir: (dirPath: string) => readdirMock(dirPath, { withFileTypes: true }),
readFile: readFileMock,
writeFile: writeFileMock,
stat: statMock,
+54 -1
View File
@@ -1,5 +1,9 @@
import { describe, expect, it } from 'vitest'
import { markdownDocumentFromFilePath } from './markdown-documents'
import {
markdownDocumentFromFilePath,
markdownDocumentsFromRelativePaths
} from './markdown-documents'
import { MarkdownDocumentListingCapacityError } from '../../shared/markdown-document-listing-limits'
describe('markdownDocumentFromFilePath', () => {
it('keeps in-root path segments that merely start with parent traversal text', () => {
@@ -24,3 +28,52 @@ describe('markdownDocumentFromFilePath', () => {
})
})
})
describe('markdownDocumentsFromRelativePaths', () => {
it('preserves filtering and sorted output below every limit', () => {
expect(
markdownDocumentsFromRelativePaths('/workspace', [
'z-last.markdown',
'src/app.ts',
'../outside.md',
'docs/Guide.MDX',
'README.md'
])
).toEqual([
{
filePath: '/workspace/docs/Guide.MDX',
relativePath: 'docs/Guide.MDX',
basename: 'Guide.MDX',
name: 'Guide'
},
{
filePath: '/workspace/README.md',
relativePath: 'README.md',
basename: 'README.md',
name: 'README'
},
{
filePath: '/workspace/z-last.markdown',
relativePath: 'z-last.markdown',
basename: 'z-last.markdown',
name: 'z-last'
}
])
})
it('rejects count, metadata, and UTF-8 path overflow before retaining another result', () => {
expect(() =>
markdownDocumentsFromRelativePaths('/workspace', ['one.md', 'two.md'], {
maxDocuments: 1
})
).toThrow(MarkdownDocumentListingCapacityError)
expect(() =>
markdownDocumentsFromRelativePaths('/workspace', ['metadata.md'], {
maxMetadataBytes: 1
})
).toThrow(MarkdownDocumentListingCapacityError)
expect(() =>
markdownDocumentsFromRelativePaths('/workspace', [`${'é'.repeat(40_000)}.md`])
).toThrow(MarkdownDocumentListingCapacityError)
})
})
+44 -39
View File
@@ -1,14 +1,22 @@
import { readdir } from 'node:fs/promises'
import { basename as pathBasename, extname, isAbsolute, join, relative, resolve } from 'node:path'
import { basename as pathBasename, extname, isAbsolute, relative, resolve } from 'node:path'
import type { MarkdownDocument } from '../../shared/types'
import {
assertMarkdownDocumentPathWithinLimit,
createMarkdownDocumentListingBudget,
retainMarkdownDocument,
type MarkdownDocumentListingLimits
} from '../../shared/markdown-document-listing-limits'
import {
discoverMarkdownRelativePaths,
isMarkdownDocumentPath
} from '../../shared/node-markdown-document-discovery'
function normalizeRelativePath(path: string): string {
return path.replace(/[\\/]+/g, '/').replace(/^\/+/, '')
}
export function isMarkdownDocumentName(name: string): boolean {
const extension = extname(name).toLowerCase()
return extension === '.md' || extension === '.mdx' || extension === '.markdown'
return isMarkdownDocumentPath(name)
}
function basenameFromRelativePath(relativePath: string): string {
@@ -39,6 +47,8 @@ export function markdownDocumentFromFilePath(
filePath: string,
options: { outsideRootRelativePath?: 'basename' | 'relative' } = {}
): MarkdownDocument {
assertMarkdownDocumentPathWithinLimit(rootPath)
assertMarkdownDocumentPathWithinLimit(filePath)
const basename = pathBasename(filePath)
const extension = extname(basename)
const relativePath =
@@ -46,18 +56,23 @@ export function markdownDocumentFromFilePath(
(options.outsideRootRelativePath === 'basename'
? basename
: normalizeRelativePath(relative(rootPath, filePath)))
return {
const document = {
filePath,
relativePath,
basename,
name: extension ? basename.slice(0, -extension.length) : basename
}
const budget = createMarkdownDocumentListingBudget()
retainMarkdownDocument(budget, document)
return document
}
export function markdownDocumentFromRelativePath(
rootPath: string,
relativePath: string
): MarkdownDocument | null {
assertMarkdownDocumentPathWithinLimit(rootPath)
assertMarkdownDocumentPathWithinLimit(relativePath)
const normalizedRelativePath = normalizeRelativePath(relativePath)
// Why: SSH providers should return root-relative paths; reject escape
// segments before building a synthetic absolute path for renderer use.
@@ -70,52 +85,42 @@ export function markdownDocumentFromRelativePath(
}
const extension = extname(basename)
const normalizedRoot = rootPath.replace(/[\\/]+$/, '')
return {
const document = {
filePath: `${normalizedRoot}/${normalizedRelativePath}`,
relativePath: normalizedRelativePath,
basename,
name: extension ? basename.slice(0, -extension.length) : basename
}
const budget = createMarkdownDocumentListingBudget()
retainMarkdownDocument(budget, document)
return document
}
export function markdownDocumentsFromRelativePaths(
rootPath: string,
relativePaths: string[]
relativePaths: readonly string[],
limits: Partial<MarkdownDocumentListingLimits> = {}
): MarkdownDocument[] {
return relativePaths
.map((relativePath) => markdownDocumentFromRelativePath(rootPath, relativePath))
.filter((document): document is MarkdownDocument => document !== null)
.sort((a, b) => a.relativePath.localeCompare(b.relativePath))
}
export async function listMarkdownDocuments(rootPath: string): Promise<MarkdownDocument[]> {
const budget = createMarkdownDocumentListingBudget(limits)
const documents: MarkdownDocument[] = []
async function visitDirectory(dirPath: string): Promise<void> {
const entries = await readdir(dirPath, { withFileTypes: true })
for (const entry of entries) {
if (entry.isSymbolicLink()) {
continue
}
const entryPath = join(dirPath, entry.name)
if (entry.isDirectory()) {
if (entry.name === '.git' || entry.name === 'node_modules') {
continue
}
if (entry.name.startsWith('.') && entry.name !== '.github') {
continue
}
await visitDirectory(entryPath)
continue
}
if (entry.isFile() && isMarkdownDocumentName(entry.name)) {
documents.push(markdownDocumentFromFilePath(rootPath, entryPath))
}
for (const relativePath of relativePaths) {
const document = markdownDocumentFromRelativePath(rootPath, relativePath)
if (document) {
retainMarkdownDocument(budget, document)
documents.push(document)
}
}
await visitDirectory(rootPath)
return documents.sort((a, b) => a.relativePath.localeCompare(b.relativePath))
}
export async function listMarkdownDocuments(
rootPath: string,
limits: Partial<MarkdownDocumentListingLimits> = {}
): Promise<MarkdownDocument[]> {
const relativePaths = await discoverMarkdownRelativePaths(rootPath, {
limits,
shouldDescend: (_relativePath, name) =>
name !== '.git' && name !== 'node_modules' && (!name.startsWith('.') || name === '.github')
})
return markdownDocumentsFromRelativePaths(rootPath, relativePaths, limits)
}
@@ -3,4 +3,5 @@ export type FilesystemPathListingProvider = {
rootPath: string,
options?: { excludePaths?: string[]; signal?: AbortSignal; maxResults?: number }
): Promise<string[]>
listMarkdownDocuments?(rootPath: string): Promise<string[]>
}
@@ -26,6 +26,7 @@ import { routeSshFilesystemWatchNotification } from './ssh-filesystem-watch-noti
import type { WorkspaceSpaceDirectoryScanResult } from '../../shared/workspace-space-types'
import { isWindowsRemoteHost, type RemoteHostPlatform } from '../ssh/ssh-remote-platform'
import { SshFilesystemDirectoryReader } from './ssh-filesystem-directory-reader'
import { requestSshMarkdownDocumentPaths } from './ssh-markdown-document-listing'
const WORKSPACE_SPACE_SCAN_TIMEOUT_MS = 130_000
export class SshFilesystemProvider implements IFilesystemProvider {
@@ -325,6 +326,8 @@ export class SshFilesystemProvider implements IFilesystemProvider {
})) as string[]
}
listMarkdownDocuments = (rootPath: string) => requestSshMarkdownDocumentPaths(this.mux, rootPath)
async watch(
rootPath: string,
callback: (events: FsChangeEvent[]) => void,
@@ -0,0 +1,42 @@
import { describe, expect, it, vi } from 'vitest'
import { MarkdownDocumentListingCapacityError } from '../../shared/markdown-document-listing-limits'
import { JsonRpcErrorCode, RelayErrorCode } from '../ssh/relay-protocol'
import { requestSshMarkdownDocumentPaths } from './ssh-markdown-document-listing'
describe('SSH Markdown document listing', () => {
it('requests producer-filtered Markdown paths', async () => {
const request = vi.fn().mockResolvedValue(['README.md', 'docs/guide.mdx'])
await expect(
requestSshMarkdownDocumentPaths({ request } as never, '/home/user/project')
).resolves.toEqual(['README.md', 'docs/guide.mdx'])
expect(request).toHaveBeenCalledWith('fs.listMarkdownDocuments', {
rootPath: '/home/user/project'
})
})
it('surfaces typed producer capacity failures', async () => {
const request = vi.fn().mockRejectedValue(
Object.assign(new Error('capacity'), {
code: RelayErrorCode.MarkdownDocumentListingCapacity
})
)
await expect(
requestSshMarkdownDocumentPaths({ request } as never, '/home/user/project')
).rejects.toBeInstanceOf(MarkdownDocumentListingCapacityError)
})
it('requires reconnect instead of using an unbounded old-relay fallback', async () => {
const request = vi
.fn()
.mockRejectedValue(
Object.assign(new Error('missing'), { code: JsonRpcErrorCode.MethodNotFound })
)
await expect(
requestSshMarkdownDocumentPaths({ request } as never, '/home/user/project')
).rejects.toThrow('Reconnect the SSH target')
expect(request).toHaveBeenCalledTimes(1)
})
})
@@ -0,0 +1,27 @@
import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer'
import { isMethodNotFoundError } from '../ssh/ssh-filesystem-stream-reader'
import { RelayErrorCode } from '../ssh/relay-protocol'
import { MarkdownDocumentListingCapacityError } from '../../shared/markdown-document-listing-limits'
export async function requestSshMarkdownDocumentPaths(
mux: SshChannelMultiplexer,
rootPath: string
): Promise<string[]> {
try {
return (await mux.request('fs.listMarkdownDocuments', { rootPath })) as string[]
} catch (error) {
if (
error instanceof Error &&
'code' in error &&
(error as { code?: unknown }).code === RelayErrorCode.MarkdownDocumentListingCapacity
) {
throw new MarkdownDocumentListingCapacityError()
}
if (isMethodNotFoundError(error)) {
throw new Error(
'Remote Markdown link discovery is unavailable. Reconnect the SSH target and retry.'
)
}
throw error
}
}
+2 -1
View File
@@ -43,7 +43,8 @@ export const RelayErrorCode = {
PtyAllocationFailed: -33004,
DiskFull: -33005,
TooManyStreams: -33006,
StreamProtocolError: -33007
StreamProtocolError: -33007,
MarkdownDocumentListingCapacity: -33008
} as const
export const JsonRpcErrorCode = {
@@ -0,0 +1,93 @@
import { EventEmitter } from 'node:events'
import type { ChildProcess } from 'node:child_process'
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { PassThrough } from 'node:stream'
import { describe, expect, it, vi } from 'vitest'
import {
MARKDOWN_DOCUMENT_LISTING_MAX_DOCUMENTS,
MARKDOWN_DOCUMENT_LISTING_MAX_PATH_BYTES,
MarkdownDocumentListingCapacityError
} from '../shared/markdown-document-listing-limits'
import {
listMarkdownPathsWithRg,
listRelayMarkdownDocumentPaths
} from './markdown-document-listing'
function successfulChild(output: Buffer): ChildProcess {
const child = Object.assign(new EventEmitter(), {
exitCode: null,
signalCode: null,
stdout: new PassThrough(),
stderr: new PassThrough(),
kill: vi.fn(() => true)
}) as unknown as ChildProcess
queueMicrotask(() => {
child.stdout!.emit('data', output)
child.emit('close', 0, null)
})
return child
}
describe('relay Markdown document producer', () => {
it('returns only Markdown paths from a real under-limit workspace', async () => {
const rootPath = await mkdtemp(join(tmpdir(), 'orca-markdown-listing-'))
try {
await mkdir(join(rootPath, 'docs'))
await writeFile(join(rootPath, 'README.md'), 'readme')
await writeFile(join(rootPath, 'docs', 'Guide.MDX'), 'guide')
await writeFile(join(rootPath, 'docs', 'app.ts'), 'code')
const result = await listRelayMarkdownDocumentPaths(rootPath)
expect(result.sort()).toEqual(['README.md', 'docs/Guide.MDX'])
} finally {
await rm(rootPath, { recursive: true, force: true })
}
})
it('filters a large non-Markdown repository before applying document retention limits', async () => {
const nonMarkdown = Array.from({ length: 100_001 }, (_value, index) => `src/${index}.ts\0`)
const output = Buffer.from(`${nonMarkdown.join('')}README.md\0docs/GUIDE.MDX\0`)
const spawnProcess = vi.fn((_args: string[]) => successfulChild(output))
await expect(listMarkdownPathsWithRg('/repo', undefined, spawnProcess)).resolves.toEqual([
'README.md',
'docs/GUIDE.MDX'
])
expect(spawnProcess).toHaveBeenCalledTimes(2)
expect(spawnProcess.mock.calls[0]?.[0]).toEqual(
expect.arrayContaining(['--iglob', '*.md', '--null'])
)
})
it('rejects rather than truncating the first document beyond the count limit', async () => {
const output = Buffer.from(
Array.from(
{ length: MARKDOWN_DOCUMENT_LISTING_MAX_DOCUMENTS + 1 },
(_value, index) => `${index}.md\0`
).join('')
)
const children: ChildProcess[] = []
const spawnProcess = vi.fn(() => {
const child = successfulChild(output)
children.push(child)
return child
})
await expect(listMarkdownPathsWithRg('/repo', undefined, spawnProcess)).rejects.toBeInstanceOf(
MarkdownDocumentListingCapacityError
)
expect(children[0]?.kill).toHaveBeenCalled()
expect(spawnProcess).toHaveBeenCalledTimes(1)
})
it('bounds an unterminated subprocess path before decoding or retaining it', async () => {
const child = successfulChild(Buffer.alloc(MARKDOWN_DOCUMENT_LISTING_MAX_PATH_BYTES + 1, 0x61))
await expect(listMarkdownPathsWithRg('/repo', undefined, () => child)).rejects.toBeInstanceOf(
MarkdownDocumentListingCapacityError
)
expect(child.kill).toHaveBeenCalled()
})
})
+238
View File
@@ -0,0 +1,238 @@
import { spawn, type ChildProcess } from 'node:child_process'
import { checkRgAvailable } from './fs-handler-utils'
import {
buildRgArgsForQuickOpen,
normalizeQuickOpenRgLine,
shouldIncludeQuickOpenPath
} from '../shared/quick-open-filter'
import {
assertMarkdownDocumentPathWithinLimit,
createMarkdownDocumentListingBudget,
isMarkdownDocumentListingCapacityError,
MARKDOWN_DOCUMENT_LISTING_ERROR_MESSAGE,
MARKDOWN_DOCUMENT_LISTING_MAX_PATH_BYTES,
MarkdownDocumentListingCapacityError,
retainMarkdownRelativePath,
visitMarkdownDocumentListingEntry
} from '../shared/markdown-document-listing-limits'
import {
discoverMarkdownRelativePaths,
isMarkdownDocumentPath
} from '../shared/node-markdown-document-discovery'
import { fileListingCancellationError } from '../shared/file-listing-cancellation'
import { RelayErrorCode } from './protocol'
import { GrowingByteBuffer } from '../shared/growing-byte-buffer'
const MARKDOWN_LISTING_TIMEOUT_MS = 25_000
const MARKDOWN_GLOBS = ['*.md', '*.mdx', '*.markdown']
class MarkdownPathFieldAccumulator {
private readonly field = new GrowingByteBuffer()
push(chunk: Buffer, onPath: (path: string) => void): boolean {
let cursor = 0
while (cursor < chunk.length) {
const delimiter = chunk.indexOf(0, cursor)
const end = delimiter === -1 ? chunk.length : delimiter
const segmentBytes = end - cursor
if (this.field.byteLength + segmentBytes > MARKDOWN_DOCUMENT_LISTING_MAX_PATH_BYTES) {
this.clear()
return false
}
if (delimiter !== -1 && this.field.byteLength === 0) {
onPath(chunk.toString('utf8', cursor, end))
} else if (segmentBytes > 0) {
this.field.append(chunk.subarray(cursor, end))
if (delimiter !== -1) {
onPath(this.take())
}
} else if (delimiter !== -1) {
onPath(this.take())
}
if (delimiter === -1) {
return true
}
cursor = delimiter + 1
}
return true
}
finish(): string | null {
return this.field.byteLength > 0 ? this.take() : null
}
private take(): string {
return this.field.takeString()
}
private clear(): void {
this.field.clear()
}
}
function markdownRgArgs(args: string[]): string[] {
const target = args.at(-1)
if (!target) {
throw new Error('Markdown rg scan is missing its search root')
}
const result = [...args.slice(0, -1), '--null']
for (const glob of MARKDOWN_GLOBS) {
result.push('--iglob', glob)
}
result.push(target)
return result
}
function relativePathDepth(path: string): number {
let depth = 0
for (let index = 0; index < path.length; index += 1) {
if (path[index] === '/') {
depth += 1
}
}
return depth
}
export async function listMarkdownPathsWithRg(
rootPath: string,
signal?: AbortSignal,
spawnProcess: (args: string[]) => ChildProcess = (args) =>
spawn('rg', args, { cwd: rootPath, stdio: ['ignore', 'pipe', 'pipe'] })
): Promise<string[]> {
const paths = new Set<string>()
const budget = createMarkdownDocumentListingBudget()
assertMarkdownDocumentPathWithinLimit(rootPath, budget.limits.maxPathBytes)
const passes = buildRgArgsForQuickOpen({
searchRoot: '.',
excludePathPrefixes: [],
forceSlashSeparator: true
})
const runPass = (args: string[]): Promise<void> =>
new Promise((resolve, reject) => {
if (signal?.aborted) {
reject(fileListingCancellationError(signal))
return
}
const child = spawnProcess(markdownRgArgs(args))
const fields = new MarkdownPathFieldAccumulator()
let done = false
let parseablePathCount = 0
let timer: ReturnType<typeof setTimeout> | null = null
const cleanup = (): void => {
if (timer) {
clearTimeout(timer)
timer = null
}
child.stdout?.off('data', onStdout)
child.stderr?.off('data', onStderr)
child.off('error', onError)
child.off('close', onClose)
signal?.removeEventListener('abort', onAbort)
}
const finish = (error?: Error, kill = false): void => {
if (done) {
return
}
done = true
cleanup()
if (kill && child.exitCode === null && child.signalCode === null) {
child.kill()
}
if (error) {
reject(error)
} else {
resolve()
}
}
const processPath = (rawPath: string): void => {
const relativePath = normalizeQuickOpenRgLine(rawPath, { kind: 'cwd-relative' })
if (relativePath === null) {
return
}
parseablePathCount += 1
if (
paths.has(relativePath) ||
!shouldIncludeQuickOpenPath(relativePath) ||
!isMarkdownDocumentPath(relativePath)
) {
return
}
visitMarkdownDocumentListingEntry(budget, relativePath, relativePathDepth(relativePath))
retainMarkdownRelativePath(budget, rootPath, relativePath)
paths.add(relativePath)
}
const onStdout = (chunk: Buffer): void => {
try {
if (!fields.push(chunk, processPath)) {
finish(new MarkdownDocumentListingCapacityError(), true)
}
} catch (error) {
finish(error instanceof Error ? error : new Error(String(error)), true)
}
}
const onStderr = (): void => {}
const onError = (error: Error): void => finish(error)
const onClose = (code: number | null, exitSignal: NodeJS.Signals | null): void => {
if (exitSignal) {
finish(new Error(`rg killed by ${exitSignal}`))
return
}
try {
const trailingPath = fields.finish()
if (trailingPath !== null) {
processPath(trailingPath)
}
} catch (error) {
finish(error instanceof Error ? error : new Error(String(error)))
return
}
if (code === 0 || code === 1 || (code === 2 && parseablePathCount > 0)) {
finish()
} else {
finish(new Error(`rg exited with code ${code}`))
}
}
const onAbort = (): void => finish(fileListingCancellationError(signal), true)
child.stdout?.on('data', onStdout)
child.stderr?.on('data', onStderr)
child.once('error', onError)
child.once('close', onClose)
signal?.addEventListener('abort', onAbort, { once: true })
timer = setTimeout(
() => finish(new Error('Markdown document listing timed out'), true),
MARKDOWN_LISTING_TIMEOUT_MS
)
})
await runPass(passes.primary)
await runPass(passes.ignoredPass)
return Array.from(paths)
}
export async function listRelayMarkdownDocumentPaths(
rootPath: string,
signal?: AbortSignal
): Promise<string[]> {
try {
if (await checkRgAvailable()) {
return await listMarkdownPathsWithRg(rootPath, signal)
}
return await discoverMarkdownRelativePaths(rootPath, {
ignoreNestedDirectoryErrors: true,
shouldDescend: (relativePath) => shouldIncludeQuickOpenPath(relativePath),
signal
})
} catch (error) {
if (!isMarkdownDocumentListingCapacityError(error)) {
throw error
}
const relayError = new Error(MARKDOWN_DOCUMENT_LISTING_ERROR_MESSAGE) as Error & {
code: number
}
relayError.code = RelayErrorCode.MarkdownDocumentListingCapacity
throw relayError
}
}
+2 -1
View File
@@ -82,7 +82,8 @@ export type GitResponseStreamMarker = {
export const RelayErrorCode = {
TooManyStreams: -33006,
StreamProtocolError: -33007
StreamProtocolError: -33007,
MarkdownDocumentListingCapacity: -33008
} as const
export type JsonRpcRequest = {
@@ -84,6 +84,7 @@ type MonacoEditorProps = {
revealMatchLength?: number
markdownDocuments?: MarkdownDocument[]
worktreeId?: string
runtimeEnvironmentId?: string | null
markdownAnnotationsEnabled?: boolean
conflictDecorationsEnabled?: boolean
readOnly?: boolean
@@ -110,6 +111,7 @@ export default function MonacoEditor({
revealMatchLength,
markdownDocuments,
worktreeId,
runtimeEnvironmentId,
markdownAnnotationsEnabled = false,
conflictDecorationsEnabled = false,
readOnly = false,
@@ -208,11 +210,15 @@ export default function MonacoEditor({
return
}
if (language === 'markdown' && markdownDocuments) {
setMarkdownDocCompletionDocuments(modelKey, markdownDocuments)
setMarkdownDocCompletionDocuments(
modelKey,
JSON.stringify([runtimeEnvironmentId ?? '', worktreeId ?? modelKey]),
markdownDocuments
)
} else {
clearMarkdownDocCompletionDocuments(modelKey)
}
}, [language, markdownDocuments])
}, [language, markdownDocuments, runtimeEnvironmentId, worktreeId])
const shouldShowMarkdownAnnotations =
markdownAnnotationsEnabled && language === 'markdown' && Boolean(worktreeId)
@@ -71,6 +71,10 @@ describe('splitMarkdownDocLinkText', () => {
})
describe('resolveMarkdownDocLink', () => {
it('shares one immutable index for consumers of the same document snapshot', () => {
expect(createMarkdownDocumentIndex(documents)).toBe(createMarkdownDocumentIndex(documents))
})
it('resolves basename links', () => {
const result = resolveMarkdownDocLink('setup-guide', createMarkdownDocumentIndex(documents))
expect(result.status).toBe('resolved')
@@ -101,7 +101,13 @@ function resolveMatches(matches: MarkdownDocument[] | undefined): MarkdownDocLin
: { status: 'ambiguous', matches }
}
const markdownDocumentIndexes = new WeakMap<MarkdownDocument[], MarkdownDocumentIndex>()
export function createMarkdownDocumentIndex(documents: MarkdownDocument[]): MarkdownDocumentIndex {
const cached = markdownDocumentIndexes.get(documents)
if (cached) {
return cached
}
const byName = new Map<string, MarkdownDocument[]>()
const byRelativePath = new Map<string, MarkdownDocument[]>()
const byRelativePathWithoutExtension = new Map<string, MarkdownDocument[]>()
@@ -116,7 +122,9 @@ export function createMarkdownDocumentIndex(documents: MarkdownDocument[]): Mark
)
}
return { byName, byRelativePath, byRelativePathWithoutExtension }
const index = { byName, byRelativePath, byRelativePathWithoutExtension }
markdownDocumentIndexes.set(documents, index)
return index
}
export function resolveMarkdownDocLink(
@@ -3,8 +3,10 @@ import type { MarkdownDocument } from '../../../../shared/types'
import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client'
import {
getMarkdownDocumentListRequestKey,
MARKDOWN_DOCUMENT_LIST_MAX_IN_FLIGHT,
requestSharedMarkdownDocumentList
} from './markdown-document-list-request'
import { MarkdownDocumentListingCapacityError } from '../../../../shared/markdown-document-listing-limits'
function context(overrides: Partial<RuntimeFileOperationArgs> = {}): RuntimeFileOperationArgs {
return {
@@ -173,4 +175,29 @@ describe('shared Markdown document list requests', () => {
)
expect(load).toHaveBeenCalledTimes(2)
})
it('rejects the first distinct scan beyond the global in-flight cap', async () => {
const pending = Array.from({ length: MARKDOWN_DOCUMENT_LIST_MAX_IN_FLIGHT }, () =>
deferred<MarkdownDocument[]>()
)
const load = vi.fn((_context: RuntimeFileOperationArgs, rootPath: string) => {
const index = Number(rootPath.slice('/route-'.length))
return pending[index].promise
})
const requests = pending.map((_entry, index) =>
requestSharedMarkdownDocumentList(context(), `/route-${index}`, {}, load)
)
await expect(
requestSharedMarkdownDocumentList(context(), '/one-too-many', {}, load)
).rejects.toBeInstanceOf(MarkdownDocumentListingCapacityError)
expect(load).toHaveBeenCalledTimes(MARKDOWN_DOCUMENT_LIST_MAX_IN_FLIGHT)
for (const entry of pending) {
entry.resolve([])
}
await expect(Promise.all(requests)).resolves.toEqual(
Array.from({ length: MARKDOWN_DOCUMENT_LIST_MAX_IN_FLIGHT }, () => [])
)
})
})
@@ -3,6 +3,7 @@ import {
listRuntimeMarkdownDocuments,
type RuntimeFileOperationArgs
} from '@/runtime/runtime-file-client'
import { MarkdownDocumentListingCapacityError } from '../../../../shared/markdown-document-listing-limits'
type MarkdownDocumentListLoader = (
context: RuntimeFileOperationArgs,
@@ -19,7 +20,9 @@ type InFlightMarkdownDocumentList = {
}
const MARKDOWN_DOCUMENT_LIST_JOIN_WINDOW_MS = 30_000
export const MARKDOWN_DOCUMENT_LIST_MAX_IN_FLIGHT = 16
const inFlightMarkdownDocumentLists = new Map<string, InFlightMarkdownDocumentList>()
let activeMarkdownDocumentListLoads = 0
export function getMarkdownDocumentListRequestKey(
context: RuntimeFileOperationArgs,
@@ -54,10 +57,22 @@ export function requestSharedMarkdownDocumentList(
if (existing && !options.requireFresh) {
return existing.request
}
if (activeMarkdownDocumentListLoads >= MARKDOWN_DOCUMENT_LIST_MAX_IN_FLIGHT) {
return Promise.reject(new MarkdownDocumentListingCapacityError())
}
// Why: split Markdown panes mount together and otherwise launch identical
// whole-worktree local/SSH scans; mutation refreshes bypass older snapshots.
const request = load(context, rootPath).finally(() => {
activeMarkdownDocumentListLoads += 1
let loaded: Promise<MarkdownDocument[]>
try {
loaded = load(context, rootPath)
} catch (error) {
activeMarkdownDocumentListLoads -= 1
return Promise.reject(error)
}
const request = loaded.finally(() => {
activeMarkdownDocumentListLoads -= 1
if (inFlightMarkdownDocumentLists.get(key)?.request === request) {
inFlightMarkdownDocumentLists.delete(key)
}
@@ -0,0 +1,104 @@
import { describe, expect, it } from 'vitest'
import type { MarkdownDocument } from '../../../../shared/types'
import {
retainMarkdownDocumentWorktreeSnapshot,
type MarkdownDocumentWorktreeSnapshot
} from './markdown-document-worktree-retention'
function documents(name: string): MarkdownDocument[] {
return [
{
filePath: `/repo/${name}.md`,
relativePath: `${name}.md`,
basename: `${name}.md`,
name
}
]
}
describe('Markdown document worktree retention', () => {
it('preserves recent under-limit worktrees and refreshes their LRU position', () => {
let snapshots = new Map<string, MarkdownDocumentWorktreeSnapshot>()
snapshots = retainMarkdownDocumentWorktreeSnapshot(snapshots, 'a', documents('a'), {
maxSnapshots: 2,
maxRetainedBytes: 10_000
})
snapshots = retainMarkdownDocumentWorktreeSnapshot(snapshots, 'b', documents('b'), {
maxSnapshots: 2,
maxRetainedBytes: 10_000
})
snapshots = retainMarkdownDocumentWorktreeSnapshot(snapshots, 'a', documents('a-new'), {
maxSnapshots: 2,
maxRetainedBytes: 10_000
})
snapshots = retainMarkdownDocumentWorktreeSnapshot(snapshots, 'c', documents('c'), {
maxSnapshots: 2,
maxRetainedBytes: 10_000
})
expect(Array.from(snapshots.keys())).toEqual(['a', 'c'])
expect(snapshots.get('a')?.documents[0]?.name).toBe('a-new')
})
it('retains at most 8 worktree snapshots by default', () => {
// Why: the cases here pass explicit limits, which never exercise the shipped defaults. Asserted
// against the literal ceiling so raising the constant fails rather than silently retaining more.
let snapshots = new Map<string, MarkdownDocumentWorktreeSnapshot>()
for (let index = 0; index < 12; index += 1) {
snapshots = retainMarkdownDocumentWorktreeSnapshot(
snapshots,
`worktree-${index}`,
documents(`doc-${index}`)
)
}
expect(snapshots.size).toBe(8)
expect(Array.from(snapshots.keys())).toEqual(
Array.from({ length: 8 }, (_unused, index) => `worktree-${index + 4}`)
)
})
it('caps aggregate retained bytes at 32 MiB by default', () => {
// A single snapshot cannot exceed the 8 MiB per-listing ceiling, so the aggregate cap only
// binds across several worktrees — which is exactly the case it exists for.
const largeDocuments = Array.from({ length: 8_000 }, (_unused, index) => ({
filePath: `/repo/${'nested/'.repeat(20)}doc-${index}.md`,
relativePath: `${'nested/'.repeat(20)}doc-${index}.md`,
basename: `doc-${index}.md`,
name: `doc-${index}`
}))
let snapshots = new Map<string, MarkdownDocumentWorktreeSnapshot>()
snapshots = retainMarkdownDocumentWorktreeSnapshot(snapshots, 'w-0', largeDocuments)
const perSnapshotBytes = snapshots.get('w-0')?.retainedBytes ?? 0
// Five of these exceed 32 MiB while each stays under the per-listing ceiling.
expect(perSnapshotBytes).toBeGreaterThan(6 * 1024 * 1024)
for (let index = 1; index < 5; index += 1) {
snapshots = retainMarkdownDocumentWorktreeSnapshot(snapshots, `w-${index}`, largeDocuments)
}
// Well under the 8-snapshot cap, so eviction here can only come from the byte ceiling.
expect(snapshots.size).toBeLessThan(5)
const retainedBytes = Array.from(snapshots.values()).reduce(
(total, snapshot) => total + snapshot.retainedBytes,
0
)
expect(retainedBytes).toBeLessThanOrEqual(32 * 1024 * 1024)
})
it('evicts oldest snapshots when their aggregate byte budget is exceeded', () => {
let snapshots = new Map<string, MarkdownDocumentWorktreeSnapshot>()
snapshots = retainMarkdownDocumentWorktreeSnapshot(snapshots, 'a', documents('a'), {
maxSnapshots: 10,
maxRetainedBytes: 10_000
})
const oneSnapshotBytes = snapshots.get('a')?.retainedBytes ?? 0
snapshots = retainMarkdownDocumentWorktreeSnapshot(snapshots, 'b', documents('b'), {
maxSnapshots: 10,
maxRetainedBytes: oneSnapshotBytes
})
expect(Array.from(snapshots.keys())).toEqual(['b'])
})
})
@@ -0,0 +1,41 @@
import type { MarkdownDocument } from '../../../../shared/types'
import { assertMarkdownDocumentsWithinLimit } from '../../../../shared/markdown-document-listing-limits'
export const MARKDOWN_DOCUMENT_WORKTREE_MAX_SNAPSHOTS = 8
export const MARKDOWN_DOCUMENT_WORKTREE_MAX_RETAINED_BYTES = 32 * 1024 * 1024
export type MarkdownDocumentWorktreeSnapshot = {
documents: MarkdownDocument[]
retainedBytes: number
}
export function retainMarkdownDocumentWorktreeSnapshot(
previous: ReadonlyMap<string, MarkdownDocumentWorktreeSnapshot>,
worktreeId: string,
documents: MarkdownDocument[],
limits: { maxSnapshots: number; maxRetainedBytes: number } = {
maxSnapshots: MARKDOWN_DOCUMENT_WORKTREE_MAX_SNAPSHOTS,
maxRetainedBytes: MARKDOWN_DOCUMENT_WORKTREE_MAX_RETAINED_BYTES
}
): Map<string, MarkdownDocumentWorktreeSnapshot> {
const next = new Map(previous)
next.delete(worktreeId)
next.set(worktreeId, {
documents,
retainedBytes: assertMarkdownDocumentsWithinLimit(documents)
})
let retainedBytes = 0
for (const snapshot of next.values()) {
retainedBytes += snapshot.retainedBytes
}
while (next.size > limits.maxSnapshots || retainedBytes > limits.maxRetainedBytes) {
const oldestKey = next.keys().next().value
if (typeof oldestKey !== 'string') {
break
}
retainedBytes -= next.get(oldestKey)?.retainedBytes ?? 0
next.delete(oldestKey)
}
return next
}
@@ -1,7 +1,7 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { getActiveMarkdownExportPayload } from './markdown-export-extract'
import { getActiveMarkdownExportPayload, inlineBlobImageSources } from './markdown-export-extract'
vi.mock('@/store', () => ({
useAppStore: {
@@ -14,10 +14,11 @@ describe('getActiveMarkdownExportPayload', () => {
vi.clearAllMocks()
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
blob: async () => new Blob([new Uint8Array([1, 2, 3])], { type: 'image/png' })
})
vi
.fn()
.mockResolvedValue(
new Response(new Uint8Array([1, 2, 3]), { headers: { 'content-type': 'image/png' } })
)
)
const { useAppStore } = await import('@/store')
vi.mocked(useAppStore.getState).mockReturnValue({
@@ -66,4 +67,30 @@ describe('getActiveMarkdownExportPayload', () => {
})
).rejects.toThrow('Failed to inline image for PDF export')
})
it('rejects streamed image bytes before the rendered fragment can exceed its cap', async () => {
let cancelled = false
vi.mocked(fetch).mockResolvedValue(
new Response(
new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new Uint8Array(12))
controller.enqueue(new Uint8Array(12))
},
cancel() {
cancelled = true
}
}),
{ headers: { 'content-type': 'image/png' } }
)
)
const root = document.createElement('div')
root.innerHTML = '<img src="blob:large">'
const prefixOnly = '<img src="data:image/png;base64,">'.length
await expect(inlineBlobImageSources(root, prefixOnly + 16)).rejects.toThrow(
'HTML export exceeds the PDF memory limit'
)
expect(cancelled).toBe(true)
})
})
@@ -1,5 +1,15 @@
import { useAppStore } from '@/store'
import { detectLanguage } from '@/lib/language-detect'
import {
assertHtmlToPdfInputWithinMemoryLimit,
HTML_TO_PDF_MAX_INPUT_BYTES,
HTML_TO_PDF_MEMORY_LIMIT_ERROR
} from '../../../../shared/html-to-pdf-memory-limit'
import {
FetchResponseBodyTooLargeError,
readFetchResponseBytesWithinLimit
} from '../../../../shared/fetch-response-body'
import { measureUtf8ByteLength } from '../../../../shared/memory-safety/utf8-byte-limits'
import { buildMarkdownExportHtml } from './markdown-export-html'
export type MarkdownExportPayload = {
@@ -65,6 +75,15 @@ export async function getActiveMarkdownExportPayload({
return null
}
const title = basenameWithoutExt(activeFile.relativePath || activeFile.filePath)
const wrapper = buildMarkdownExportHtml({ title, renderedHtml: '' })
const wrapperBytes = measureUtf8ByteLength(wrapper, {
stopAfterBytes: HTML_TO_PDF_MAX_INPUT_BYTES
})
if (wrapperBytes.exceededLimit) {
throw new Error(HTML_TO_PDF_MEMORY_LIMIT_ERROR)
}
const clone = subtree.cloneNode(true) as Element
for (const selector of UI_ONLY_SELECTORS) {
for (const node of clone.querySelectorAll(selector)) {
@@ -73,51 +92,77 @@ export async function getActiveMarkdownExportPayload({
}
// Why: local-image previews use renderer-scoped blob URLs; the hidden PDF
// window cannot dereference them, so embed the bytes before export.
await inlineBlobImageSources(clone)
await inlineBlobImageSources(clone, HTML_TO_PDF_MAX_INPUT_BYTES - wrapperBytes.byteLength)
const renderedHtml = clone.innerHTML.trim()
if (!renderedHtml) {
return null
}
const title = basenameWithoutExt(activeFile.relativePath || activeFile.filePath)
const html = buildMarkdownExportHtml({ title, renderedHtml })
assertHtmlToPdfInputWithinMemoryLimit(html)
return { title, html }
}
async function inlineBlobImageSources(root: Element): Promise<void> {
const images = Array.from(root.querySelectorAll<HTMLImageElement>('img[src^="blob:"]'))
await Promise.all(
images.map(async (image) => {
const src = image.getAttribute('src')
if (!src) {
return
}
image.setAttribute('src', await readBlobImageAsDataUrl(src))
})
)
export async function inlineBlobImageSources(
root: Element,
maxFragmentBytes: number
): Promise<void> {
for (const image of root.querySelectorAll<HTMLImageElement>('img[src^="blob:"]')) {
const src = image.getAttribute('src')
if (!src) {
continue
}
await inlineBlobImageSource(root, image, src, maxFragmentBytes)
}
}
async function readBlobImageAsDataUrl(src: string): Promise<string> {
async function inlineBlobImageSource(
root: Element,
image: HTMLImageElement,
src: string,
maxFragmentBytes: number
): Promise<void> {
try {
const response = await fetch(src)
if (!response.ok) {
throw new Error('Unable to fetch blob image')
}
const blob = await response.blob()
const bytes = new Uint8Array(await blob.arrayBuffer())
return `data:${blob.type || 'application/octet-stream'};base64,${bytesToBase64(bytes)}`
const mediaType = normalizedMediaType(response.headers.get('content-type'))
const prefix = `data:${mediaType};base64,`
image.setAttribute('src', prefix)
const prefixMeasurement = measureUtf8ByteLength(root.innerHTML, {
stopAfterBytes: maxFragmentBytes
})
if (prefixMeasurement.exceededLimit) {
throw new Error(HTML_TO_PDF_MEMORY_LIMIT_ERROR)
}
const availableBase64Characters = maxFragmentBytes - prefixMeasurement.byteLength
const maxImageBytes = Math.floor(availableBase64Characters / 4) * 3
const bytes = await readFetchResponseBytesWithinLimit(response, maxImageBytes)
image.setAttribute('src', `${prefix}${bytesToBase64(bytes)}`)
} catch (error) {
if (
error instanceof FetchResponseBodyTooLargeError ||
(error instanceof Error && error.message === HTML_TO_PDF_MEMORY_LIMIT_ERROR)
) {
throw new Error(HTML_TO_PDF_MEMORY_LIMIT_ERROR)
}
const message = error instanceof Error ? error.message : String(error)
throw new Error(`Failed to inline image for PDF export: ${message}`)
}
}
function normalizedMediaType(value: string | null): string {
return new Blob([], { type: value ?? '' }).type || 'application/octet-stream'
}
function bytesToBase64(bytes: Uint8Array): string {
let binary = ''
const chunkSize = 0x8000
const chunks: string[] = []
const chunkSize = 3 * 8192
for (let index = 0; index < bytes.length; index += chunkSize) {
binary += String.fromCharCode(...bytes.subarray(index, index + chunkSize))
const binary = String.fromCharCode(...bytes.subarray(index, index + chunkSize))
chunks.push(btoa(binary))
}
return btoa(binary)
return chunks.join('')
}
@@ -0,0 +1,68 @@
import { afterEach, describe, expect, it } from 'vitest'
import type { MarkdownDocument } from '../../../../shared/types'
import { assertMarkdownDocumentsWithinLimit } from '../../../../shared/markdown-document-listing-limits'
import {
clearMarkdownDocCompletionDocuments,
getMarkdownCompletionRetentionForTests,
MARKDOWN_COMPLETION_MAX_MODELS,
MARKDOWN_COMPLETION_MAX_SCOPES,
resetMarkdownCompletionRetentionForTests,
setMarkdownDocCompletionDocuments
} from './monaco-markdown-doc-completions'
function documents(scope: string): MarkdownDocument[] {
return [
{
filePath: `/repo/${scope}.md`,
relativePath: `${scope}.md`,
basename: `${scope}.md`,
name: scope
}
]
}
afterEach(() => {
resetMarkdownCompletionRetentionForTests()
})
describe('Monaco Markdown completion retention', () => {
it('stores one document snapshot for every mounted model in the same worktree scope', () => {
setMarkdownDocCompletionDocuments('model-a', 'worktree-a', documents('old'))
const freshDocuments = documents('fresh')
setMarkdownDocCompletionDocuments('model-b', 'worktree-a', freshDocuments)
expect(getMarkdownCompletionRetentionForTests()).toEqual({
models: 2,
scopes: 1,
retainedBytes: assertMarkdownDocumentsWithinLimit(freshDocuments)
})
clearMarkdownDocCompletionDocuments('model-a')
expect(getMarkdownCompletionRetentionForTests()).toMatchObject({ models: 1, scopes: 1 })
clearMarkdownDocCompletionDocuments('model-b')
expect(getMarkdownCompletionRetentionForTests()).toEqual({
models: 0,
scopes: 0,
retainedBytes: 0
})
})
it('evicts oldest scopes and model associations at their exact caps', () => {
for (let index = 0; index <= MARKDOWN_COMPLETION_MAX_SCOPES; index += 1) {
setMarkdownDocCompletionDocuments(`model-${index}`, `scope-${index}`, documents(`${index}`))
}
expect(getMarkdownCompletionRetentionForTests()).toMatchObject({
models: MARKDOWN_COMPLETION_MAX_SCOPES,
scopes: MARKDOWN_COMPLETION_MAX_SCOPES
})
resetMarkdownCompletionRetentionForTests()
for (let index = 0; index <= MARKDOWN_COMPLETION_MAX_MODELS; index += 1) {
setMarkdownDocCompletionDocuments(`model-${index}`, 'shared-scope', documents('shared'))
}
expect(getMarkdownCompletionRetentionForTests()).toMatchObject({
models: MARKDOWN_COMPLETION_MAX_MODELS,
scopes: 1
})
})
})
@@ -5,12 +5,72 @@ import {
getMarkdownDocCompletionContext,
getMarkdownDocCompletionDocuments
} from './markdown-doc-completions'
import { assertMarkdownDocumentsWithinLimit } from '../../../../shared/markdown-document-listing-limits'
type MonacoApi = Parameters<OnMount>[1]
type CompletionScope = {
documents: MarkdownDocument[]
modelKeys: Set<string>
retainedBytes: number
}
export const MARKDOWN_COMPLETION_MAX_MODELS = 256
export const MARKDOWN_COMPLETION_MAX_SCOPES = 32
export const MARKDOWN_COMPLETION_MAX_RETAINED_BYTES = 64 * 1024 * 1024
let provider: IDisposable | null = null
let providerMonaco: MonacoApi | null = null
const documentsByModel = new Map<string, MarkdownDocument[]>()
const scopeKeyByModel = new Map<string, string>()
const completionScopes = new Map<string, CompletionScope>()
let retainedBytes = 0
function deleteCompletionScope(scopeKey: string, scope: CompletionScope): void {
completionScopes.delete(scopeKey)
retainedBytes -= scope.retainedBytes
for (const modelKey of scope.modelKeys) {
scopeKeyByModel.delete(modelKey)
}
}
function removeModel(modelKey: string): void {
const scopeKey = scopeKeyByModel.get(modelKey)
if (!scopeKey) {
return
}
scopeKeyByModel.delete(modelKey)
const scope = completionScopes.get(scopeKey)
scope?.modelKeys.delete(modelKey)
if (scope && scope.modelKeys.size === 0) {
deleteCompletionScope(scopeKey, scope)
}
}
function enforceCompletionRetentionLimits(): void {
while (scopeKeyByModel.size > MARKDOWN_COMPLETION_MAX_MODELS) {
const oldestModelKey = scopeKeyByModel.keys().next().value
if (typeof oldestModelKey !== 'string') {
break
}
removeModel(oldestModelKey)
}
while (
completionScopes.size > MARKDOWN_COMPLETION_MAX_SCOPES ||
retainedBytes > MARKDOWN_COMPLETION_MAX_RETAINED_BYTES
) {
const oldest = completionScopes.entries().next().value
if (!oldest) {
break
}
deleteCompletionScope(oldest[0], oldest[1])
}
}
function clearCompletionRetention(): void {
scopeKeyByModel.clear()
completionScopes.clear()
retainedBytes = 0
}
export function ensureMarkdownDocCompletionProvider(monaco: MonacoApi): void {
// Why: if Monaco was torn down and re-created (e.g. window reload), the old
@@ -21,7 +81,7 @@ export function ensureMarkdownDocCompletionProvider(monaco: MonacoApi): void {
}
if (provider) {
provider.dispose()
documentsByModel.clear()
clearCompletionRetention()
}
providerMonaco = monaco
@@ -34,7 +94,8 @@ export function ensureMarkdownDocCompletionProvider(monaco: MonacoApi): void {
return { suggestions: [] }
}
const documents = documentsByModel.get(model.uri.toString()) ?? []
const scopeKey = scopeKeyByModel.get(model.uri.toString())
const documents = scopeKey ? (completionScopes.get(scopeKey)?.documents ?? []) : []
const suffix = line.slice(position.column - 1)
const range = {
startLineNumber: position.lineNumber,
@@ -60,11 +121,49 @@ export function ensureMarkdownDocCompletionProvider(monaco: MonacoApi): void {
export function setMarkdownDocCompletionDocuments(
modelKey: string,
scopeKey: string,
documents: MarkdownDocument[]
): void {
documentsByModel.set(modelKey, documents)
removeModel(modelKey)
let nextRetainedBytes: number
try {
nextRetainedBytes = assertMarkdownDocumentsWithinLimit(documents)
} catch {
return
}
let scope = completionScopes.get(scopeKey)
if (scope) {
completionScopes.delete(scopeKey)
retainedBytes -= scope.retainedBytes
scope.documents = documents
scope.retainedBytes = nextRetainedBytes
} else {
scope = { documents, modelKeys: new Set(), retainedBytes: nextRetainedBytes }
}
scope.modelKeys.add(modelKey)
completionScopes.set(scopeKey, scope)
scopeKeyByModel.set(modelKey, scopeKey)
retainedBytes += nextRetainedBytes
enforceCompletionRetentionLimits()
}
export function clearMarkdownDocCompletionDocuments(modelKey: string): void {
documentsByModel.delete(modelKey)
removeModel(modelKey)
}
export function getMarkdownCompletionRetentionForTests(): {
models: number
scopes: number
retainedBytes: number
} {
return {
models: scopeKeyByModel.size,
scopes: completionScopes.size,
retainedBytes
}
}
export function resetMarkdownCompletionRetentionForTests(): void {
clearCompletionRetention()
}
@@ -22,7 +22,7 @@ describe('rich markdown local images', () => {
...globalThis.window.api,
fs: {
readFile: vi.fn().mockResolvedValue({
content: 'AA==',
content: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB',
isBinary: true,
mimeType: 'image/png'
})
@@ -0,0 +1,96 @@
// @vitest-environment happy-dom
import { act, renderHook, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { MarkdownDocument } from '../../../../shared/types'
import { MarkdownDocumentListingCapacityError } from '../../../../shared/markdown-document-listing-limits'
import type { OpenFile } from '@/store/slices/editor'
import { useMarkdownDocuments } from './useMarkdownDocuments'
const mocks = vi.hoisted(() => ({
list: vi.fn(),
openFile: vi.fn(),
openMarkdownPreview: vi.fn(),
toastError: vi.fn()
}))
vi.mock('sonner', () => ({ toast: { error: mocks.toastError } }))
vi.mock('@/lib/connection-context', () => ({ getConnectionId: () => null }))
vi.mock('@/runtime/runtime-file-client', () => ({ statRuntimePath: vi.fn() }))
vi.mock('@/runtime/runtime-rpc-client', () => ({
settingsForRuntimeOwner: () => ({ activeRuntimeEnvironmentId: null })
}))
vi.mock('./markdown-document-list-request', () => ({
requestSharedMarkdownDocumentList: mocks.list
}))
vi.mock('./markdown-document-worktree-path-selector', () => ({
selectMarkdownDocumentWorktreePath: (_state: unknown, worktreeId: string) => `/repo/${worktreeId}`
}))
vi.mock('@/store', () => {
const state = {
settings: {},
openFile: mocks.openFile,
openMarkdownPreview: mocks.openMarkdownPreview
}
const useAppStore = Object.assign(
(selector: (value: typeof state) => unknown) => selector(state),
{ getState: () => state }
)
return { useAppStore }
})
function file(worktreeId: string): OpenFile {
return {
id: `/repo/${worktreeId}/README.md`,
filePath: `/repo/${worktreeId}/README.md`,
relativePath: 'README.md',
worktreeId,
language: 'markdown',
isDirty: false,
mode: 'edit'
}
}
function deferred<T>(): {
promise: Promise<T>
reject: (error: Error) => void
resolve: (value: T) => void
} {
let reject!: (error: Error) => void
let resolve!: (value: T) => void
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise
reject = rejectPromise
})
return { promise, reject, resolve }
}
afterEach(() => {
vi.clearAllMocks()
vi.restoreAllMocks()
})
describe('useMarkdownDocuments', () => {
it('does not toast when a superseded worktree scan later exceeds capacity', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
const first = deferred<MarkdownDocument[]>()
const second = deferred<MarkdownDocument[]>()
mocks.list.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise)
const hook = renderHook(
({ activeFile }) => useMarkdownDocuments(activeFile, true, 'source', vi.fn()),
{ initialProps: { activeFile: file('first') } }
)
await waitFor(() => expect(mocks.list).toHaveBeenCalledTimes(1))
hook.rerender({ activeFile: file('second') })
await waitFor(() => expect(mocks.list).toHaveBeenCalledTimes(2))
await act(async () => {
first.reject(new MarkdownDocumentListingCapacityError())
second.resolve([])
await Promise.allSettled([first.promise, second.promise])
})
expect(mocks.toastError).not.toHaveBeenCalled()
})
})
@@ -1,5 +1,10 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { toast } from 'sonner'
import type { MarkdownDocument } from '../../../../shared/types'
import {
isMarkdownDocumentListingCapacityError,
MARKDOWN_DOCUMENT_LISTING_ERROR_MESSAGE
} from '../../../../shared/markdown-document-listing-limits'
import { useAppStore } from '@/store'
import { getConnectionId } from '@/lib/connection-context'
import { statRuntimePath } from '@/runtime/runtime-file-client'
@@ -12,6 +17,10 @@ import {
} from './markdown-doc-links'
import { selectMarkdownDocumentWorktreePath } from './markdown-document-worktree-path-selector'
import { requestSharedMarkdownDocumentList } from './markdown-document-list-request'
import {
retainMarkdownDocumentWorktreeSnapshot,
type MarkdownDocumentWorktreeSnapshot
} from './markdown-document-worktree-retention'
type OpenMarkdownDocumentOptions = {
anchor?: string | null
@@ -60,8 +69,8 @@ export function useMarkdownDocuments(
const openFile = useAppStore((s) => s.openFile)
const openMarkdownPreview = useAppStore((s) => s.openMarkdownPreview)
const [markdownDocumentsByWorktree, setMarkdownDocumentsByWorktree] = useState<
Record<string, MarkdownDocument[]>
>({})
Map<string, MarkdownDocumentWorktreeSnapshot>
>(() => new Map())
const requestRef = useRef(0)
const connectionId = getConnectionId(worktreeId)
@@ -91,17 +100,20 @@ export function useMarkdownDocuments(
if (requestRef.current !== requestId) {
return
}
setMarkdownDocumentsByWorktree((prev) => ({
...prev,
[worktreeId]: documents
}))
setMarkdownDocumentsByWorktree((prev) =>
retainMarkdownDocumentWorktreeSnapshot(prev, worktreeId, documents)
)
} catch (err) {
console.error('Failed to list markdown documents:', err)
if (requestRef.current === requestId) {
setMarkdownDocumentsByWorktree((prev) => ({
...prev,
[worktreeId]: []
}))
if (isMarkdownDocumentListingCapacityError(err)) {
toast.error(MARKDOWN_DOCUMENT_LISTING_ERROR_MESSAGE, {
id: `markdown-document-listing-capacity:${worktreeId}`
})
}
setMarkdownDocumentsByWorktree((prev) =>
retainMarkdownDocumentWorktreeSnapshot(prev, worktreeId, [])
)
}
}
},
@@ -182,7 +194,7 @@ export function useMarkdownDocuments(
}, [activeFile.id, isMarkdown, viewMode, refreshMarkdownDocuments])
const markdownDocuments = useMemo(
() => (worktreeId ? (markdownDocumentsByWorktree[worktreeId] ?? []) : []),
() => (worktreeId ? (markdownDocumentsByWorktree.get(worktreeId)?.documents ?? []) : []),
[worktreeId, markdownDocumentsByWorktree]
)
@@ -6,6 +6,11 @@ import {
isMarkdownDocumentListingCapacityError,
MARKDOWN_DOCUMENT_LISTING_ERROR_CODE,
MARKDOWN_DOCUMENT_LISTING_ERROR_MESSAGE,
MARKDOWN_DOCUMENT_LISTING_MAX_DEPTH,
MARKDOWN_DOCUMENT_LISTING_MAX_DOCUMENTS,
MARKDOWN_DOCUMENT_LISTING_MAX_METADATA_BYTES,
MARKDOWN_DOCUMENT_LISTING_MAX_PATH_BYTES,
MARKDOWN_DOCUMENT_LISTING_MAX_VISITED_ENTRIES,
MarkdownDocumentListingCapacityError,
retainMarkdownDocument,
visitMarkdownDocumentListingEntry
@@ -68,6 +73,64 @@ describe('Markdown document listing limits', () => {
)
})
it('applies the default visited-entry ceiling of 100,000 when none is requested', () => {
// Why: the cases above pass explicit small limits, which only exercise clamping. Seeding
// relative to the constant would also pass at any constant value, so use the literal ceiling —
// raising the module constant has to fail here.
const belowCeiling = createMarkdownDocumentListingBudget()
belowCeiling.visitedEntries = 99_998
visitMarkdownDocumentListingEntry(belowCeiling, 'last.md', 1)
expect(belowCeiling.visitedEntries).toBe(99_999)
const atCeiling = createMarkdownDocumentListingBudget()
atCeiling.visitedEntries = 100_000
expect(() => visitMarkdownDocumentListingEntry(atCeiling, 'overflow.md', 1)).toThrow(
MarkdownDocumentListingCapacityError
)
})
it('applies the default metadata ceiling of 8 MiB when none is requested', () => {
const belowCeiling = createMarkdownDocumentListingBudget()
belowCeiling.metadataBytes = 8 * 1024 * 1024 - 4_096
retainMarkdownDocument(belowCeiling, document('fits.md'))
const atCeiling = createMarkdownDocumentListingBudget()
atCeiling.metadataBytes = 8 * 1024 * 1024
expect(() => retainMarkdownDocument(atCeiling, document('overflow.md'))).toThrow(
MarkdownDocumentListingCapacityError
)
})
it('applies the default depth ceiling of 256 when none is requested', () => {
const budget = createMarkdownDocumentListingBudget()
const path = `${'nested/'.repeat(256)}deep.md`
visitMarkdownDocumentListingEntry(budget, path, 256)
expect(() => visitMarkdownDocumentListingEntry(budget, path, 257)).toThrow(
MarkdownDocumentListingCapacityError
)
})
it('clamps a caller request above the module ceiling rather than honoring it', () => {
// Why: limits arrive from the renderer and the relay. A caller asking for a larger budget than
// the module allows must be clamped down, or the ceiling is advisory rather than enforced.
const budget = createMarkdownDocumentListingBudget({
maxDocuments: Number.MAX_SAFE_INTEGER,
maxMetadataBytes: Number.MAX_SAFE_INTEGER,
maxPathBytes: Number.MAX_SAFE_INTEGER,
maxVisitedEntries: Number.MAX_SAFE_INTEGER,
maxDepth: Number.MAX_SAFE_INTEGER
})
expect(budget.limits).toEqual({
maxDocuments: MARKDOWN_DOCUMENT_LISTING_MAX_DOCUMENTS,
maxMetadataBytes: MARKDOWN_DOCUMENT_LISTING_MAX_METADATA_BYTES,
maxPathBytes: MARKDOWN_DOCUMENT_LISTING_MAX_PATH_BYTES,
maxVisitedEntries: MARKDOWN_DOCUMENT_LISTING_MAX_VISITED_ENTRIES,
maxDepth: MARKDOWN_DOCUMENT_LISTING_MAX_DEPTH
})
})
it('recognizes structured runtime and Electron-wrapped capacity failures', () => {
const structured = Object.assign(new Error('remote listing rejected'), {
code: MARKDOWN_DOCUMENT_LISTING_ERROR_CODE