refactor(runtime): collapse speculative portability layers

Remove the one-consumer bounded-read primitive and shared terminal lifecycle controller, make terminal cleanup one awaited provider operation, and reuse one Code Runtime contract suite. Keep only reproduced cancellation and policy fixes; defer unproven replacement, prompt-attribution, and streaming-frame concerns to scoped markers.
This commit is contained in:
Tianyi Cui
2026-07-29 18:26:21 +08:00
parent 4fecc54998
commit c1d550de58
65 changed files with 1265 additions and 948 deletions

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 packages/lsp/lsp-local/README.md
README.md: c96c4febc9d047b41789f2b7a73e3eaf4d35012b
README.zh.md: 5bc2c3c8bb7a89afb673797f5a3b25bb9fc06748
README.md: ad8f4bc2318a58202f9596a18d402d2c6d45dae1
README.zh.md: ff2686856a9e518a7fa846edf700da1b32e8a114

View File

@@ -10,11 +10,11 @@ Namespace plugin (`name` / `inject` / `Config` / `apply`, no default export).
- Resolves every server-local setting before registration; an invalid mapping or registration conflict rolls back earlier entries, so a failed load leaves no provider routes.
- Lazily single-flights one server process per `(server id, canonical workspace target)`. A live server error is not replayed; if the selected pooled transport fails before or during a read-only query, the provider awaits its disposal and retries that query once on a fresh process.
- Uses a compatibility-first **transient-open** sequence per query: resolve and boundedly read the source through `ctx.fs`, `textDocument/didOpen` (version 1, full text), the requested request, then `textDocument/didClose` in `finally`. A failed or canceled `didOpen` write terminates the instance before the pool can reuse it. Documents close after each call, so the first version needs no `didChange`, content cache, or document LRU.
- Uses a compatibility-first **transient-open** sequence per query: resolve and byte-bound the source while streaming it through `ctx.fs`, `textDocument/didOpen` (version 1, full text), the requested request, then `textDocument/didClose` in `finally`. A failed or canceled `didOpen` write terminates the instance before the pool can reuse it. Documents close after each call, so the first version needs no `didChange`, content cache, or document LRU.
- Serializes each source-read/open/query/close lifecycle through one abortable per-workspace queue so queued calls read current source only when their turn starts; distinct workspaces run in parallel. Provider disposal aborts filesystem and protocol work, awaits workspace lookups that have not entered a queue, then drains every queue and server.
- After protocol shutdown fails, terminates the server's descendant tree through the subprocess seam (POSIX process-group signaling; Windows `taskkill /T /F`). Tree-kill delivery is contained like every group signal — it races server exit — and quiescence is confirmed by the handle's tree-liveness wait rather than by the kill's own outcome.
- Resolves the server executable, cwd, process, and protocol streams through `ctx.subprocess`; `initialize.processId` is `null` because another machine or PID namespace must not monitor the harness process.
- Uses `ctx.fs` canonical containment, file URIs, and stable bounded reads, but emits no `fs/observed`: only the LSP result is model-visible, so a query does not satisfy read-before-write policy.
- Uses `ctx.fs` canonical containment, file URIs, and streamed text validation, but emits no `fs/observed`: only the LSP result is model-visible, so a query does not satisfy read-before-write policy.
## Configuration
@@ -42,7 +42,7 @@ Initialization advertises `general.positionEncodings: ['utf-16']`, `workspace: {
## Security boundary
The provider trusts its configured server and claims no sandbox confinement. It delegates canonical identity, containment, no-follow/stable bounded reads, UTF-8 validation, and file-URI encoding to `ctx.fs`; it rejects missing, non-regular, non-UTF-8, oversized, or canonically out-of-workspace query sources before server startup. Result locations may be external, but an external path cannot become a query source. A deployment must mount filesystem and subprocess providers for the same execution world; split-world composition is invalid.
The provider trusts its configured server and claims no sandbox confinement. It delegates canonical identity, containment, regular-file streaming, UTF-8 validation, and file-URI encoding to `ctx.fs`; it rejects missing, non-regular, non-UTF-8, oversized, or canonically out-of-workspace query sources before server startup. Containment is evaluated before the stream opens and does not promise stable-handle identity across concurrent path replacement. Result locations may be external, but an external path cannot become a query source. A deployment must mount filesystem and subprocess providers for the same execution world; split-world composition is invalid.
## Model Experience

View File

@@ -10,11 +10,11 @@ Namespace 插件(`name`/`inject`/`Config`/`apply`,无默认导出)
- 在注册前解析每项服务器局部设置;无效映射或注册冲突会回滚较早配置项,因此加载失败不会留下提供方路由。
- 每个 `(server id, canonical workspace target)` 惰性 single-flight 一个服务器进程。存活服务器错误不会回放;如果选中的池化传输在只读查询之前或期间失败,提供方会等待其释放,并在新进程上重试该查询一次。
- 每次查询都使用兼容性优先的**临时打开** 序列:通过 `ctx.fs` 解析源文件并进行有界读取、`textDocument/didOpen`(版本 1、完整文本)、所请求操作,然后执行 `textDocument/didClose`,该操作位于 `finally` 中。写入 `didOpen` 失败或取消时,会先终止实例再允许池复用。文档在每次调用后关闭,因此第一版不需要 `didChange`、内容 cache 或文档 LRU。
- 每次查询都使用兼容性优先的**临时打开** 序列:通过 `ctx.fs` 解析并流式读取源文件,同时执行字节上限;随后执行 `textDocument/didOpen`(版本 1、完整文本)、所请求操作,以及位于 `finally` 中的 `textDocument/didClose`。写入 `didOpen` 失败或取消时,会先终止实例再允许池复用。文档在每次调用后关闭,因此第一版不需要 `didChange`、内容 cache 或文档 LRU。
- 通过一条逐 Workspace、可中止的队列,串行执行每个源读取/打开/查询/关闭生命周期,因此排队调用只会在轮到自身时读取当前源;不同 Workspace 并行运行。提供方资源释放会中止文件系统与协议工作,等待尚未进入队列的 Workspace 查找结算,再排空所有队列并等待所有服务器结算。
- 协议 shutdown 失败后,经由进程管理器 seam 终止服务器后代树(POSIX 进程组信号;Windows `taskkill /T /F`)。树终止的投递结果与所有进程组信号一样被就地吸收,不向外抛出(投递与服务器退出存在竞态);服务器是否完全停稳,由句柄的进程树存活等待确认,而非由这次终止自身的结果确认。
- 通过 `ctx.subprocess` 解析服务器可执行文件、cwd、进程与协议流;`initialize.processId` 为 `null`,因为另一台机器或 PID 命名空间不得监控 harness 进程。
- 使用 `ctx.fs` 提供的规范 containment、文件 URI 与稳定有界读取,但不发出 `fs/observed`:只有 LSP 结果对模型可见,因此查询不满足先读后写策略。
- 使用 `ctx.fs` 提供的规范 containment、文件 URI 与流式文本校验,但不发出 `fs/observed`:只有 LSP 结果对模型可见,因此查询不满足先读后写策略。
## 配置
@@ -42,7 +42,7 @@ Namespace 插件(`name`/`inject`/`Config`/`apply`,无默认导出)
## 安全边界
提供方信任其配置的服务器,不声明任何沙箱限制。它把规范身份、containment、不跟随符号链接的稳定有界读取、UTF-8 校验与文件 URI 编码委托给 `ctx.fs`;服务器启动前,系统会拒绝缺失、非普通文件、非 UTF-8、过大或规范路径位于工作区外的查询源。结果位置可以在外部,但外部路径不能成为查询源。部署必须为同一执行环境挂载文件系统与子进程提供方;分裂执行环境的组合无效。
提供方信任其配置的服务器,不声明任何沙箱限制。它把规范身份、containment、普通文件流式读取、UTF-8 校验与文件 URI 编码委托给 `ctx.fs`;服务器启动前,系统会拒绝缺失、非普通文件、非 UTF-8、过大或规范路径位于工作区外的查询源。系统在打开流之前检查 containment,但不保证路径并发替换期间的稳定句柄身份。结果位置可以在外部,但外部路径不能成为查询源。部署必须为同一执行环境挂载文件系统与子进程提供方;分裂执行环境的组合无效。
## 模型体验

View File

@@ -1,5 +1,6 @@
/** Filesystem-seam source access for the generic stdio LSP provider. */
import { Buffer } from 'node:buffer'
import type { FileSystem, FsTarget } from '@deepseek-ai/dsh-fs'
import { throwIfAborted } from './abort.ts'
@@ -58,9 +59,9 @@ export async function canonicalizeWorkspace(
}
/**
* Resolve, contain, and atomically read one bounded query source through
* `ctx.fs`. The provider's bounded read owns stable-handle and no-follow
* mechanics; this layer owns only LSP-facing validation and messages.
* Resolve, contain, and read one byte-bounded query source through `ctx.fs`.
* This layer owns the LSP-specific complete-document cap while the filesystem
* provider owns streaming, regular-file checks, and UTF-8 validation.
* @param fs - filesystem provider sharing the server's execution world.
* @param filePath - absolute source path or path relative to `workspace`.
* @param workspace - already-canonical workspace.
@@ -90,17 +91,28 @@ export async function readHostSource(
if (!fs.contains(workspace.target, target)) {
throw new Error(`source "${filePath}" resolves outside the workspace`)
}
let text: string
const chunks: string[] = []
let bytes = 0
try {
text = await fs.readTextBounded(target, maxDocumentBytes, signal)
// XXX(lsp-source-replacement): Revisit stable-handle identity only if a real query observes
// replacement between canonical containment and the provider opening this stream.
const stream = await fs.streamText(target, signal)
for await (const chunk of stream) {
throwIfAborted(signal)
bytes += Buffer.byteLength(chunk)
if (bytes > maxDocumentBytes) {
throw new Error(`source "${filePath}" exceeds the ${maxDocumentBytes}-byte limit`)
}
chunks.push(chunk)
}
} catch (error: unknown) {
throwIfAborted(signal)
throw new Error(`source "${filePath}" could not be opened safely: ${messageOf(error)}`, { cause: error })
throw new Error(`source "${filePath}" could not be read: ${messageOf(error)}`, { cause: error })
}
throwIfAborted(signal)
return {
fileUrl: fs.fileUrl(target),
text,
text: chunks.join(''),
}
}

View File

@@ -161,6 +161,12 @@ describe('readHostSource', () => {
await expect(readSource('big.ts', 10)).rejects.toThrow(/10-byte limit/)
})
it('counts the complete UTF-8 byte length at the configured boundary', async () => {
await writeFile(join(ws, 'multibyte.ts'), '€abc')
await expect(readSource('multibyte.ts', 6)).resolves.toMatchObject({ text: '€abc' })
await expect(readSource('multibyte.ts', 5)).rejects.toThrow(/5-byte limit/)
})
it('rejects a non-UTF-8 source', async () => {
await writeFile(join(ws, 'bin.ts'), Buffer.from([0xff, 0xfe, 0x00]))
await expect(readSource('bin.ts')).rejects.toThrow(/invalid UTF-8|binary file/)

View File

@@ -328,14 +328,17 @@ describe('lsp-local end to end over a fake server', () => {
await expect(disposing).resolves.toBeUndefined()
})
it('aborts a queued source read when the provider is disposed', async () => {
it('aborts a queued source stream when the provider is disposed', async () => {
const ctx = await mount({ LSP_FAKE_DEF: 'null' })
const fs = ctx.fs
const started = Promise.withResolvers<AbortSignal>()
vi.spyOn(fs, 'readTextBounded').mockImplementation(async (_target, _maxBytes, signal) => {
vi.spyOn(fs, 'streamText').mockImplementation(async (_target, signal) => {
if (signal === undefined) throw new Error('source read missing provider lifetime signal')
started.resolve(signal)
return await rejectWhenAborted(signal)
return (async function* () {
await rejectWhenAborted(signal)
yield ''
})()
})
const pending = ctx.lsp.query(query('goToDefinition'))