refactor(workspace-files): reuse bounded reads and return raw Host bytes

This commit is contained in:
yudshj committed 2026-09-17 08:44:54 +08:00
1 parent 2f98700396
commit fdd8197674
12 files changed
+65 -37

No files matched your search

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.md
2026-09-15-bounded-office-conversion.md: 6a86fc8d24185d8f4d327e0407f21d55dc860a53
2026-09-15-bounded-office-conversion.zh.md: d267c9ccb2b388c87c3823a9c998074ff9f01df8
2026-09-15-bounded-office-conversion.md: af24cee4bc73c40135a15f70cff9201e59280da7
2026-09-15-bounded-office-conversion.zh.md: 29411e117653755e2fd7ddce8e2fe516ffdf226f
@@ -30,8 +30,10 @@ Foreground preview and explicit QA requests precede background work. Disabling b
**Separate service-definition and provider packages for the sole LibreOffice implementation.** They evolve together and have no independent alternative implementation. One `office-to-pdf` package supplies the mountable service without duplicated package, dependency, and release configuration. Native and WASM engine selection remains inside the kit; a second independent implementation can justify extracting an interface from actual consumer needs.
Host workspace-file reads return raw bytes within the reserved capacity and delegate complete bounded reading to `fs.readBytes`. Base64 encoding belongs to Remote responses, so Host conversion does not allocate an encoded source string or a decoded copy.
## Consequences
The cache is transient and cannot bypass source authorization. Oversized PDFs can be returned without retention, and failed or canceled conversions are retried on a later explicit request. Source reservations measure binary bytes; base64 expansion, engine RSS, caller-retained output, and PDF.js page memory remain outside those limits. With one configured conversion slot, foreground work waits for an already-running background conversion to finish.
The cache is transient and cannot bypass source authorization. Oversized PDFs can be returned without retention, and failed or canceled conversions are retried on a later explicit request. Source reservations measure binary bytes; Remote base64 expansion, engine RSS, caller-retained output, and PDF.js page memory remain outside those limits. With one configured conversion slot, foreground work waits for an already-running background conversion to finish.
Controlled source and engine completions verify pre-read admission, content joining, priority, cancellation isolation, delayed resource release, LRU/alias limits, stale versions, and converter replacement. Loader composition and native conversion checks exercise the shared provider independently of presentation consumers.
@@ -30,8 +30,10 @@ Office 预览和显式文档检查可能请求相同转换。仅缓存已完成
**为唯一的 LibreOffice 实现拆分服务定义和提供方包。** 两者共同演化,没有独立的替代实现。单个 `office-to-pdf` 包直接提供可挂载服务,避免重复维护包、依赖和发布配置。原生与 WASM 引擎选择仍由 kit 负责;若出现独立的第二种实现,再根据实际消费者提取接口。
Host 工作区文件读取在预留容量内返回原始字节,并将完整有界读取交给 `fs.readBytes`。base64 编码由 Remote 响应负责,因此 Host 转换无需分配编码后的源字符串或解码副本。
## 后果
缓存为临时数据,不能绕过源授权。超出缓存上限的 PDF 可返回而不保留;失败或取消的转换在后续显式请求时重试。源预留按二进制字节计量;base64 膨胀、引擎 RSS、调用方保留的输出和 PDF.js 页面内存不计入这些限制。仅配置一个转换槽位时,前台工作等待已运行的后台转换结束。
缓存为临时数据,不能绕过源授权。超出缓存上限的 PDF 可返回而不保留;失败或取消的转换在后续显式请求时重试。源预留按二进制字节计量;Remote base64 膨胀、引擎 RSS、调用方保留的输出和 PDF.js 页面内存不计入这些限制。仅配置一个转换槽位时,前台工作等待已运行的后台转换结束。
受控的源读取和引擎完成验证读取前准入、内容合并、优先级、取消隔离、延迟资源释放、LRU 与别名限额、过期版本及转换器替换。Loader 组合与原生转换检查独立于展示消费者验证共享提供方。
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/workspace.md
workspace.md: 50db26be228bf39880891c3c4ca7d47c097b9d3e
workspace.zh.md: fd4110041fe1b22e580b17835e4a896b96d062c8
workspace.md: 9633bd74b6997554e0d25824746117b4760fac29
workspace.zh.md: fd749cec14383ea746efb096437f841f57291962
+2 -2
View File
@@ -392,9 +392,9 @@ Host Remote file reads and workspace directory observations over the composed fi
* @param path - absolute or workspace-relative file path.
* @param maxBytes - positive reserved capacity; the configured full-file cap still applies.
* @param signal - caller cancellation.
* @returns complete base64 bytes; reads at most the effective limit plus one overflow sentinel.
* @returns complete raw bytes and metadata from before the read; the filesystem enforces the effective limit.
*/
async readAllBounded( workspaceFileScope: WorkspaceFileScope, path: string, maxBytes: number, signal: AbortSignal, ): Promise<WorkspaceFileBytes>
async readAllBounded( workspaceFileScope: WorkspaceFileScope, path: string, maxBytes: number, signal: AbortSignal, ): Promise<WorkspaceFileStat & { readonly data: Uint8Array }>
/**
* Read a complete file relative to another file's directory, including outside the workspace.
+2 -2
View File
@@ -392,9 +392,9 @@ Host Remote file reads and workspace directory observations over the composed fi
* @param path - absolute or workspace-relative file path.
* @param maxBytes - positive reserved capacity; the configured full-file cap still applies.
* @param signal - caller cancellation.
* @returns complete base64 bytes; reads at most the effective limit plus one overflow sentinel.
* @returns complete raw bytes and metadata from before the read; the filesystem enforces the effective limit.
*/
async readAllBounded( workspaceFileScope: WorkspaceFileScope, path: string, maxBytes: number, signal: AbortSignal, ): Promise<WorkspaceFileBytes>
async readAllBounded( workspaceFileScope: WorkspaceFileScope, path: string, maxBytes: number, signal: AbortSignal, ): Promise<WorkspaceFileStat & { readonly data: Uint8Array }>
/**
* Read a complete file relative to another file's directory, including outside the workspace.
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/api/workspace-files/README.md
README.md: 9da773d65220e355c5dc41e372bc2bb0ca868d91
README.zh.md: 56276e5da266b3ba81bfa83358df94d19e103ee4
README.md: a3ccbc24ca37b5b933c34390773d10a223e99d36
README.zh.md: a7e59c07d5d99216a2e8f6659ed3dc3201846a62
+1 -1
View File
@@ -82,7 +82,7 @@ The provider waits for the Host's `ready` frame before its first `stat`, queues
One supervised `changes` stream serves every followed file in a Session. Followers match absolute paths with backslashes normalized to slashes. Carrier loss reconnects through the Gateway supervisor; a Host-ended or terminally failed feed ends its followers and leaves their last metadata readable until reopened. The last follower leaving disposes the stream, a successor waits for that disposal, and plugin teardown awaits all pending closes. The provider declares `ResourceProtocolMap.file`; the text preview declares its Sidebar line-navigation parameters.
Host consumers can call `readAllBounded(scope, path, maxBytes, signal)` after reserving input capacity. The smaller of that reservation and `maxFileBytes` applies; the read allocates at most one additional overflow sentinel byte.
Host consumers can call `readAllBounded(scope, path, maxBytes, signal)` after reserving input capacity. It returns raw `Uint8Array` data with file metadata. The filesystem enforces the smaller of that reservation and `maxFileBytes`; `readAll()` encodes the result as base64 for Remote callers.
-----
+1 -1
View File
@@ -82,7 +82,7 @@ kind: "package-reference"
每个 Session 的所有被跟随文件共用一条受监督的 `changes` 流。跟随者按反斜杠归一为斜杠的绝对路径匹配。载体掉线由 Gateway 监督器重连;Host 结束或终态失败的流会结束其跟随者,最后的元数据仍可读取,直到重新打开。最后一个跟随者离开时释放流,后继流等待该释放完成,插件拆除等待所有在途关闭。提供者声明 `ResourceProtocolMap.file`;文本预览声明其 Sidebar 行号导航参数。
Host 消费者可在预留输入容量后调用 `readAllBounded(scope, path, maxBytes, signal)`。采用预留值与 `maxFileBytes` 的较小值;读取最多额外分配一个超限哨兵字节。
Host 消费者可在预留输入容量后调用 `readAllBounded(scope, path, maxBytes, signal)`。它返回原始 `Uint8Array` 数据及文件元数据。文件系统执行预留值与 `maxFileBytes` 中较小的上限;`readAll()` 为 Remote 调用方将结果编码为 base64。
-----
+11 -11
View File
@@ -276,7 +276,8 @@ export class WorkspaceFiles extends TypertRemoteService {
*/
@Remote
async readAll(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise<WorkspaceFileBytes> {
return this.readAllBounded(workspaceFileScope, path, this.config.maxFileBytes, signal)
const { data, ...stat } = await this.readAllBounded(workspaceFileScope, path, this.config.maxFileBytes, signal)
return { ...stat, offset: 0, data: Buffer.from(data).toString('base64'), eof: true }
}
/**
@@ -285,21 +286,20 @@ export class WorkspaceFiles extends TypertRemoteService {
* @param path - absolute or workspace-relative file path.
* @param maxBytes - positive reserved capacity; the configured full-file cap still applies.
* @param signal - caller cancellation.
* @returns complete base64 bytes; reads at most the effective limit plus one overflow sentinel.
* @returns complete raw bytes and metadata from before the read; the filesystem enforces the effective limit.
*/
async readAllBounded(
workspaceFileScope: WorkspaceFileScope, path: string, maxBytes: number, signal: AbortSignal,
): Promise<WorkspaceFileBytes> {
): Promise<WorkspaceFileStat & { readonly data: Uint8Array }> {
const { target, info } = await this.locateFile(workspaceFileScope, path, signal)
const limit = Math.min(this.config.maxFileBytes, maxBytes)
if (info.size !== undefined && info.size > limit) {
throw new RemoteError('workspace-file/too-large', `"${path}" exceeds the ${limit} byte full-file cap`, { path, limit })
}
const data = await this.ctx.fs.readByteRange(target, { offset: 0, length: limit + 1 }, signal)
if (data.length > limit) {
throw new RemoteError('workspace-file/too-large', `"${path}" exceeds the ${limit} byte full-file cap`, { path, limit })
}
return { ...this.statOf(target, info), offset: 0, data: Buffer.from(data).toString('base64'), eof: true }
const data = await this.ctx.fs.readBytes(target, signal, limit).catch((cause: unknown) => {
if (typeof cause === 'object' && cause !== null && 'code' in cause && cause.code === 'FS_TOO_LARGE') {
throw new RemoteError('workspace-file/too-large', `"${path}" exceeds the ${limit} byte full-file cap`, { path, limit }, { cause })
}
throw cause
})
return { ...this.statOf(target, info), data }
}
/**
@@ -24,13 +24,11 @@ describe('workspaceFiles.readAll', () => {
expect(await harness.endpoint().readAll(harness.scope, 'empty', signal())).toMatchObject({ data: '', offset: 0, eof: true, bytes: 0 })
})
it.each(['workspace', 'outside'] as const)('rejects a known oversized %s file before reading bytes', async (location) => {
it.each(['workspace', 'outside'] as const)('maps the filesystem size refusal for a %s file', async (location) => {
const path = join(harness[location], 'large')
await writeFile(path, 'abcde')
const read = vi.spyOn(harness.ctx.fs, 'readByteRange')
expect(await failureOf(harness.endpoint({ maxFileBytes: 4 }).readAll(harness.scope, path, signal())))
.toEqual({ code: 'workspace-file/too-large', details: { path, limit: 4 } })
expect(read).not.toHaveBeenCalled()
})
it.each([undefined, 1])('checks the actual bytes when stat reports %s', async (size) => {
@@ -51,15 +49,41 @@ describe('workspaceFiles.readAll', () => {
})
})
it('applies the smaller Host reservation before reading a complete file', async () => {
it.each([[8, 3], [3, 8]])('applies the smaller cap from deployment %s and Host reservation %s', async (maxFileBytes, reservation) => {
await writeFile(join(harness.workspace, 'reserved'), '1234')
const read = vi.spyOn(harness.ctx.fs, 'readByteRange')
const files = harness.endpoint({ maxFileBytes: 8 })
expect(await failureOf(files.readAllBounded(harness.scope, 'reserved', 3, signal())))
const read = vi.spyOn(harness.ctx.fs, 'readBytes')
const files = harness.endpoint({ maxFileBytes })
const caller = signal()
expect(await failureOf(files.readAllBounded(harness.scope, 'reserved', reservation, caller)))
.toEqual({ code: 'workspace-file/too-large', details: { path: 'reserved', limit: 3 } })
expect(read).not.toHaveBeenCalled()
expect((await files.readAllBounded(harness.scope, 'reserved', 4, signal())).bytes).toBe(4)
expect(read.mock.calls[0]![1]).toEqual({ offset: 0, length: 5 })
expect(read).toHaveBeenCalledExactlyOnceWith(expect.anything(), caller, 3)
})
it('returns the filesystem byte array directly to Host consumers at the exact cap', async () => {
await writeFile(join(harness.workspace, 'reserved'), '1234')
const read = vi.spyOn(harness.ctx.fs, 'readBytes')
const result = await harness.endpoint({ maxFileBytes: 8 }).readAllBounded(harness.scope, 'reserved', 4, signal())
expect(result.data).toBe(await read.mock.results[0]!.value)
expect(result.data).toEqual(Buffer.from('1234'))
expect(result.bytes).toBe(4)
expect(result).not.toHaveProperty('offset')
expect(result).not.toHaveProperty('eof')
})
it.each([new Error('Read denied'), new DOMException('Cancelled', 'AbortError'), null, 'backend failure', { code: 'FS_NOT_FOUND' }])(
'preserves non-size filesystem failures: %s', async (failure) => {
await writeFile(join(harness.workspace, 'file'), '1234')
vi.spyOn(harness.ctx.fs, 'readBytes').mockRejectedValueOnce(failure)
await expect(harness.endpoint().readAllBounded(harness.scope, 'file', 4, signal())).rejects.toBe(failure)
},
)
it('maps a size refusal by code without requiring a shared error class', async () => {
await writeFile(join(harness.workspace, 'file'), '1234')
const failure = { code: 'FS_TOO_LARGE' }
vi.spyOn(harness.ctx.fs, 'readBytes').mockRejectedValueOnce(failure)
await expect(harness.endpoint().readAllBounded(harness.scope, 'file', 4, signal()))
.rejects.toMatchObject({ code: 'workspace-file/too-large', details: { path: 'file', limit: 4 }, cause: failure })
})
describe('workspaceFiles.readRelated', () => {
@@ -3241,10 +3241,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
returns: 'one complete base64 window with offset zero and eof true; oversized files fail with too-large.',
},
{
signature: 'async readAllBounded( workspaceFileScope: WorkspaceFileScope, path: string, maxBytes: number, signal: AbortSignal, ): Promise<WorkspaceFileBytes>',
signature: 'async readAllBounded( workspaceFileScope: WorkspaceFileScope, path: string, maxBytes: number, signal: AbortSignal, ): Promise<WorkspaceFileStat & { readonly data: Uint8Array }>',
description: 'Read a complete authorized file within a Host consumer\'s reserved byte capacity.',
parameters: [{ name: 'workspaceFileScope', description: 'Session authorization and execution scope.' }, { name: 'path', description: 'absolute or workspace-relative file path.' }, { name: 'maxBytes', description: 'positive reserved capacity; the configured full-file cap still applies.' }, { name: 'signal', description: 'caller cancellation.' }],
returns: 'complete base64 bytes; reads at most the effective limit plus one overflow sentinel.',
returns: 'complete raw bytes and metadata from before the read; the filesystem enforces the effective limit.',
},
{
signature: '@Remote async readRelated( workspaceFileScope: WorkspaceFileScope, path: string, relativePath: string, signal: AbortSignal, ): Promise<WorkspaceFileBytes>',