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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,但不保证路径并发替换期间的稳定句柄身份。结果位置可以在外部,但外部路径不能成为查询源。部署必须为同一执行环境挂载文件系统与子进程提供方;分裂执行环境的组合无效。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -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(''),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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/)
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
|
||||
import type { GenericCallView } from '@deepseek-ai/dsh-tools'
|
||||
import type { LspHover, LspLocation, LspOperation, LspPosition } from '@deepseek-ai/dsh-lsp'
|
||||
import { posix, win32 } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
/** The four operations the tool exposes, as a runtime tuple for schema enum + validation. */
|
||||
export const LSP_OPERATIONS: readonly LspOperation[] = ['goToDefinition', 'findReferences', 'goToImplementation', 'hover']
|
||||
@@ -144,52 +146,31 @@ export function renderUri(uri: string, workspaceUri: string): string {
|
||||
return uri
|
||||
}
|
||||
if (workspace.protocol !== 'file:') return uri
|
||||
const targetSegments = decodeFileSegments(target)
|
||||
const workspaceSegments = decodeFileSegments(workspace)
|
||||
if (targetSegments === undefined || workspaceSegments === undefined) return uri
|
||||
const sameAuthority = target.hostname === workspace.hostname
|
||||
const windowsWorld = isWindowsFileWorld(workspace, workspaceSegments)
|
||||
if (windowsWorld && [...targetSegments, ...workspaceSegments].some(segment => segment.includes('\\'))) return uri
|
||||
const inside = sameAuthority
|
||||
&& targetSegments.length >= workspaceSegments.length
|
||||
&& workspaceSegments.every((segment, index) => samePathSegment(segment, targetSegments[index] as string, windowsWorld))
|
||||
if (inside) {
|
||||
const relative = targetSegments.slice(workspaceSegments.length)
|
||||
return relative.length === 0 ? '.' : relative.join('/')
|
||||
}
|
||||
return absoluteUriPath(target, targetSegments, windowsWorld)
|
||||
const drivePath = /^\/[a-z](?::|%3A)/iu
|
||||
const windowsWorld = workspace.hostname.length > 0 || drivePath.test(workspace.pathname)
|
||||
const targetWindowsWorld = windowsWorld && (target.hostname.length > 0 || drivePath.test(target.pathname))
|
||||
const workspacePath = filePath(workspace, windowsWorld)
|
||||
const targetPath = filePath(target, targetWindowsWorld)
|
||||
if (workspacePath === undefined || targetPath === undefined) return uri
|
||||
if (windowsWorld !== targetWindowsWorld) return targetPath
|
||||
const path = windowsWorld ? win32 : posix
|
||||
const relative = path.relative(workspacePath, targetPath)
|
||||
const outside = relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)
|
||||
const rendered = relative === '' ? '.' : outside ? targetPath : relative
|
||||
return windowsWorld ? rendered.replaceAll('\\', '/') : rendered
|
||||
}
|
||||
|
||||
/** Whether a canonical file URI names a drive path or UNC path in a Windows execution world. */
|
||||
function isWindowsFileWorld(url: URL, segments: readonly string[]): boolean {
|
||||
return url.hostname.length > 0 || /^[A-Za-z]:$/.test(segments[0] ?? '')
|
||||
}
|
||||
|
||||
/** Decode URI path segments while rejecting encoded POSIX separators and NUL. */
|
||||
function decodeFileSegments(url: URL): string[] | undefined {
|
||||
/** Decode a file URL for its execution world while containing malformed URL failures. */
|
||||
function filePath(url: URL, windows: boolean): string | undefined {
|
||||
try {
|
||||
const decoded = url.pathname.split('/').map(segment => decodeURIComponent(segment))
|
||||
if (decoded.some(segment => /[/\0]/u.test(segment))) return undefined
|
||||
while (decoded.at(-1) === '') decoded.pop()
|
||||
decoded.shift()
|
||||
return decoded
|
||||
const path = fileURLToPath(url, { windows })
|
||||
return path.includes('\0') ? undefined : path
|
||||
} catch {
|
||||
// `fileURLToPath` rejects malformed escapes, authorities, and encoded path separators.
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** Windows execution-world path segments are case-insensitive even on a non-Windows harness host. */
|
||||
function samePathSegment(left: string, right: string, windowsWorld: boolean): boolean {
|
||||
return windowsWorld ? left.toUpperCase() === right.toUpperCase() : left === right
|
||||
}
|
||||
|
||||
/** Render an external file URL according to the execution-world style implied by its workspace URI. */
|
||||
function absoluteUriPath(target: URL, segments: readonly string[], windowsWorld: boolean): string {
|
||||
if (target.hostname.length > 0) return `//${target.hostname}/${segments.join('/')}`
|
||||
if (windowsWorld && /^[A-Za-z]:$/.test(segments[0] ?? '')) return segments.join('/')
|
||||
return `/${segments.join('/')}`
|
||||
}
|
||||
|
||||
/**
|
||||
* UI presentation for a pending `lsp` call. Uses a generic search card; the title carries the
|
||||
* operation and one-based cursor, and `locations` focuses the queried line. The shared location
|
||||
|
||||
@@ -103,6 +103,7 @@ describe('renderUri', () => {
|
||||
it('keeps a malformed file: URI verbatim when it cannot be parsed to a path', () => {
|
||||
// An encoded path separator is invalid on every platform and must remain verbatim.
|
||||
expect(renderUri('file:///bad%2Fpath', WS_URI)).toBe('file:///bad%2Fpath')
|
||||
expect(renderUri('file:///bad%00path', WS_URI)).toBe('file:///bad%00path')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user