refactor(e2b): group remote providers
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/README.md
|
||||
README.md: 74ed729d97013de3d7f502f728e3abe62348846f
|
||||
README.zh.md: 5a08d54ca6c54d8355454fc3021a0da48772000b
|
||||
README.md: d8bc6047e7eadeb2993f1114564b41913b2970dc
|
||||
README.zh.md: d838af346626bb96299a3b702df0dabb6231c15a
|
||||
|
||||
@@ -8,9 +8,9 @@ The language-server capability seam: an abstract LSP interface, a generic stdio
|
||||
|---|---|---|
|
||||
| `lsp/` | Abstract LSP seam (provider registry by branded id + extension mapping, per-query selection, vocabulary, `LspError`) | `ctx.lsp` |
|
||||
| [`lsp-local/`](lsp-local/README.md) | Generic multi-server local backend (spawn, JSON-RPC, transient-open queries) | (registers providers on `ctx.lsp`) |
|
||||
| [`lsp-e2b/`](lsp-e2b/README.md) | Remote E2B backend (remote source reads and servers, byte-framed stdio bridge) | (registers providers on `ctx.lsp`) |
|
||||
| [`e2b/lsp-e2b`](../e2b/lsp-e2b/README.md) | Remote E2B backend (remote source reads and servers, byte-framed stdio bridge) | (registers providers on `ctx.lsp`) |
|
||||
| `tool-lsp/` | Model-facing `lsp` tool (four operations, one-based UTF-16 cursor coordinates) | (registers on `ctx.tools`) |
|
||||
|
||||
The interface lives at `lsp/lsp/`. The seam exposes exactly four semantic operations — `goToDefinition`, `findReferences`, `goToImplementation`, `hover` — and no generic JSON-RPC escape hatch, so a provider swap does not change how the model asks for navigation and no protocol payload or unreviewed mutation reaches the model contract. Providers register **capabilities**, not tools; `tool-lsp` is the only owner of the model-facing name, schema, prompt guidance, and presentation.
|
||||
|
||||
See the [LSP capability seam Agent Note](../../.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md) for the protocol design and the [E2B extension note](../../.agents/notes/implemented/feature/2026-07-28-e2b-interactive-semantic-code-runtime-poc.md) for the remote process/filesystem boundary.
|
||||
See the [LSP capability seam Agent Note](../../.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md) for the protocol design and the [shared E2B runtime note](../../.agents/notes/implemented/feature/2026-07-27-e2b-remote-runtime-poc.md) for the remote process/filesystem boundary.
|
||||
|
||||
@@ -8,9 +8,9 @@
|
||||
|---|---|---|
|
||||
| `lsp/` | 抽象 LSP seam(按品牌化 id + 扩展名映射组织的提供方注册表、逐查询选择、词汇、`LspError`) | `ctx.lsp` |
|
||||
| [`lsp-local/`](lsp-local/README.md) | 通用多服务器本地后端(spawn、JSON-RPC、临时打开查询) | (在 `ctx.lsp` 上注册提供方) |
|
||||
| [`lsp-e2b/`](lsp-e2b/README.md) | 远程 E2B 后端(在远程读取源文件并运行服务器、采用字节分帧的 stdio 桥) | (在 `ctx.lsp` 上注册提供方) |
|
||||
| [`e2b/lsp-e2b`](../e2b/lsp-e2b/README.md) | 远程 E2B 后端(在远程读取源文件并运行服务器、采用字节分帧的 stdio 桥) | (在 `ctx.lsp` 上注册提供方) |
|
||||
| `tool-lsp/` | 面向模型的 `lsp` 工具(四种操作、从 1 开始的 UTF-16 光标坐标) | (注册到 `ctx.tools`) |
|
||||
|
||||
接口位于 `lsp/lsp/`。该 seam 恰好公开四种语义操作:`goToDefinition`、`findReferences`、`goToImplementation`、`hover`,且不提供通用 JSON-RPC 逃生口;因此,替换提供方不会改变模型请求导航的方式,也不会让协议载荷或未经评审的修改进入模型契约。提供方注册的是**能力** 而非工具;`tool-lsp` 是面向模型名称、schema、提示词指引和呈现的唯一 owner。
|
||||
|
||||
协议设计见 [LSP 能力 seam Agent Note](../../.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md),远程进程/文件系统边界见 [E2B 扩展 Agent Note](../../.agents/notes/implemented/feature/2026-07-28-e2b-interactive-semantic-code-runtime-poc.md)。
|
||||
协议设计见 [LSP 能力 seam Agent Note](../../.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md),远程进程/文件系统边界见 [共享 E2B 运行时 Agent Note](../../.agents/notes/implemented/feature/2026-07-27-e2b-remote-runtime-poc.md)。
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# 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-e2b/README.md
|
||||
README.md: 968487b7c5f63b2165cf66797a4182642507a8e9
|
||||
README.zh.md: 2b937f01156c3194f64ed9fb54478fec5c77305b
|
||||
@@ -1,47 +0,0 @@
|
||||
# @deepseek-ai/dsh-lsp-e2b
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Generic E2B language-server backend for [`ctx.lsp`](../lsp/README.md). It runs configured stdio servers and reads their source documents inside the shared `ctx.e2b` sandbox; the provider registry, normalized query results, queues, and protocol connection state remain on the host.
|
||||
|
||||
## Plugin and configuration
|
||||
|
||||
The `lsp-e2b` plugin injects `e2b`, `lsp`, and the concrete `dsh-subprocess-e2b` service. `servers` is a non-empty provider-id table:
|
||||
|
||||
| Server key | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `command` | required | Remote executable, absolute or resolved on the sandbox PATH at load. |
|
||||
| `args` | `[]` | Remote server arguments. |
|
||||
| `env` | `{}` | Explicit environment entries passed through the subprocess adapter. |
|
||||
| `extensionToLanguage` | required | Lowercase leading-dot extension to LSP language id. |
|
||||
| `initializationOptions` / `configuration` | `null` / `null` | Static initialize options and `workspace/configuration` answer. |
|
||||
| `maxMessageBytes` | `16000000` | Largest LSP message accepted from the server. |
|
||||
| `maxStderrBytes` | `1000000` | Retained raw server stderr tail. |
|
||||
| `maxDocumentBytes` | `4000000` | Largest remote source opened for one query. |
|
||||
| `shutdownTimeoutMs` | `5000` | Graceful protocol-shutdown budget. |
|
||||
| `killGraceMs` | `2000` | Request-cancel and TERM-to-KILL grace. |
|
||||
|
||||
Provider ids and commands are non-empty; numeric bounds are positive safe integers, and timer values cannot exceed Node's maximum timer delay. Setup uploads one owner-private proxy under `ctx.e2b.runtimeRoot`, resolves Node and every configured server executable remotely, then registers all providers atomically.
|
||||
|
||||
## Remote protocol and filesystem
|
||||
|
||||
E2B command callbacks are text, while LSP is byte-framed. The installed proxy therefore base64-frames raw server stdout, stderr, and stdin as newline-delimited ASCII JSON; the host validates and decodes every frame before handing bytes to the shared `LspInstance` protocol engine. `initialize.processId` is `null` because host and server do not share a process namespace.
|
||||
|
||||
One language-server process is pooled per provider and canonical remote workspace. Queries serialize per workspace but different workspaces run concurrently. Each query canonicalizes the remote workspace and source with `realpath`, rejects paths outside that workspace, requires a regular file, enforces the size bound before and after reading, decodes strict UTF-8, and uses the ordinary transient `didOpen` / request / `didClose` lifecycle. A transport failure disposes the instance and retries the read-only query once on a fresh remote process.
|
||||
|
||||
The subprocess adapter owns process groups and escalation, so cancellation and disposal await remote server quiescence. The host owns LSP request ids, pending requests, provider queues, and normalized results.
|
||||
|
||||
## Model Experience
|
||||
|
||||
Indirectly, through `@deepseek-ai/dsh-tool-lsp`, which exposes normalized semantic navigation and hover results without changing its model-facing schema.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
No direct invalidation; `dsh-tool-lsp` owns request-prefix changes.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Configured servers only** — this package does not install language servers, select presets, or synchronize a host workspace into E2B.
|
||||
- **Host protocol state is not reconnectable** — retaining a sandbox does not restore provider queues, JSON-RPC requests, subprocess handles, or document lifecycle state.
|
||||
- **SDK output retention remains** — ASCII framing preserves protocol bytes, but E2B and the subprocess adapter still retain callback output in host memory.
|
||||
- **Sandbox policy is template-owned** — this provider adds no volume, snapshot, credential, or network-policy layer.
|
||||
@@ -1,47 +0,0 @@
|
||||
# @deepseek-ai/dsh-lsp-e2b
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
用于 [`ctx.lsp`](../lsp/README.md) 的通用 E2B 语言服务器后端。它在共享的 `ctx.e2b` 沙箱内运行已配置的 stdio 服务器并读取其源文档;提供方注册表、规范化查询结果、队列和协议连接状态仍保留在宿主侧。
|
||||
|
||||
## 插件与配置
|
||||
|
||||
`lsp-e2b` 插件注入 `e2b`、`lsp` 和具体的 `dsh-subprocess-e2b` 服务。`servers` 是一张非空的提供方 id 表:
|
||||
|
||||
| 服务器键 | 默认值 | 含义 |
|
||||
|---|---|---|
|
||||
| `command` | 必填 | 远程可执行文件:绝对路径,或在加载时通过沙箱 PATH 解析。 |
|
||||
| `args` | `[]` | 远程服务器参数。 |
|
||||
| `env` | `{}` | 经由进程管理适配器传入的显式环境条目。 |
|
||||
| `extensionToLanguage` | 必填 | 小写、以点开头的扩展名到 LSP language id 的映射。 |
|
||||
| `initializationOptions` / `configuration` | `null` / `null` | 静态初始化选项和 `workspace/configuration` 应答。 |
|
||||
| `maxMessageBytes` | `16000000` | 从服务器接受的 LSP 消息大小上限。 |
|
||||
| `maxStderrBytes` | `1000000` | 保留的服务器原始 stderr 尾部上限。 |
|
||||
| `maxDocumentBytes` | `4000000` | 单次查询可打开的最大远程源文件。 |
|
||||
| `shutdownTimeoutMs` | `5000` | 协议优雅关闭预算。 |
|
||||
| `killGraceMs` | `2000` | 请求取消与 TERM 到 KILL 升级的宽限期。 |
|
||||
|
||||
提供方 id 与命令必须非空;数值上限必须是正的安全整数,定时器取值不得超过 Node 的最大定时器延迟。设置阶段会在 `ctx.e2b.runtimeRoot` 下上传一个仅供所有者使用的私有代理,在远程解析 Node 和每个已配置服务器的可执行文件,再以原子方式注册所有提供方。
|
||||
|
||||
## 远程协议与文件系统
|
||||
|
||||
E2B 命令回调交付的是文本,而 LSP 按字节分帧。因此,已安装的代理会把服务器 stdout、stderr 和 stdin 的原始字节进行 base64 编码,封装为以换行分隔的 ASCII JSON 帧;宿主会验证并解码每一帧,再把字节交给共享的 `LspInstance` 协议引擎。`initialize.processId` 为 `null`,因为宿主与服务器不共享进程命名空间。
|
||||
|
||||
每个提供方与规范化远程工作区的组合共享一个池化语言服务器进程。同一工作区的查询串行执行,不同工作区的查询并发运行。每项查询都会使用 `realpath` 规范化远程工作区与源文件,拒绝工作区外的路径,要求源文件为普通文件,在读取前后都检查大小上限,使用严格的 UTF-8 解码,并采用常规的临时 `didOpen`/请求/`didClose` 生命周期。传输失败会 dispose(资源释放)该实例,并在全新的远程进程上重试一次只读查询。
|
||||
|
||||
进程管理适配器负责进程组和终止升级,因此取消与资源释放都会等待远程服务器完全停稳。宿主负责 LSP 请求 id、待完成请求、提供方队列和规范化结果。
|
||||
|
||||
## 模型体验
|
||||
|
||||
通过 `@deepseek-ai/dsh-tool-lsp` 间接影响模型;该包会公开规范化的语义导航与悬停结果,而不改变其面向模型的 schema。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
不会直接失效;请求前缀变更由 `dsh-tool-lsp` 负责。
|
||||
|
||||
## 已知限制与暂缓工作
|
||||
|
||||
- **仅支持已配置的服务器**:本包不会安装语言服务器、选择 preset,或把宿主工作区同步到 E2B。
|
||||
- **宿主协议状态不可重连**:保留沙箱并不会恢复提供方队列、JSON-RPC 请求、进程管理句柄或文档生命周期状态。
|
||||
- **SDK 仍会保留输出**:ASCII 分帧能保留协议字节,但 E2B 和进程管理适配器仍会在宿主内存中保留回调输出。
|
||||
- **沙箱策略归模板负责**:本提供方不会额外增加卷、快照、凭据或网络策略层。
|
||||
@@ -1,52 +0,0 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-lsp-e2b",
|
||||
"description": "E2B language-server provider for DeepSeek Harness",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-e2b": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-lsp": "^0.0.1",
|
||||
"@deepseek-ai/dsh-lsp-local": "^0.0.1",
|
||||
"@deepseek-ai/dsh-subprocess": "^0.0.1",
|
||||
"@deepseek-ai/dsh-subprocess-e2b": "^0.0.1",
|
||||
"@deepseek-ai/dsh-timeout": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-e2b": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-lsp": "workspace:^",
|
||||
"@deepseek-ai/dsh-lsp-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-subprocess": "workspace:^",
|
||||
"@deepseek-ai/dsh-subprocess-e2b": "workspace:^",
|
||||
"@deepseek-ai/dsh-timeout": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
@@ -1,358 +0,0 @@
|
||||
/** E2B filesystem and process backend for the harness LSP capability seam. */
|
||||
|
||||
import { Buffer } from 'node:buffer'
|
||||
import { posix } from 'node:path'
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import {
|
||||
FileType,
|
||||
quoteE2BShellArg,
|
||||
resolveE2BExecutable,
|
||||
} from '@deepseek-ai/dsh-e2b'
|
||||
import type { Sandbox } from '@deepseek-ai/dsh-e2b'
|
||||
import { LspError, LspProviderId } from '@deepseek-ai/dsh-lsp'
|
||||
import type { LspProvider, LspProviderQuery, LspQueryResult } from '@deepseek-ai/dsh-lsp'
|
||||
import { LspInstance } from '@deepseek-ai/dsh-lsp-local'
|
||||
import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
|
||||
import E2BSubprocessService from '@deepseek-ai/dsh-subprocess-e2b'
|
||||
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
|
||||
import { LSP_PROXY_SOURCE } from './proxy-source.ts'
|
||||
import { E2BLspTransport } from './transport.ts'
|
||||
|
||||
export { E2BLspTransport } from './transport.ts'
|
||||
|
||||
/** Cordis plugin name. */
|
||||
export const name = 'lsp-e2b'
|
||||
/** Services required by the remote provider. */
|
||||
export const inject = ['e2b', 'lsp', 'subprocess']
|
||||
|
||||
const DEFAULT_MAX_MESSAGE_BYTES = 16_000_000
|
||||
const DEFAULT_MAX_STDERR_BYTES = 1_000_000
|
||||
const DEFAULT_MAX_DOCUMENT_BYTES = 4_000_000
|
||||
const DEFAULT_SHUTDOWN_TIMEOUT_MS = 5_000
|
||||
const DEFAULT_KILL_GRACE_MS = 2_000
|
||||
|
||||
/* jscpd:ignore-start -- Loader requires each backend to expose its own statically walkable server schema. */
|
||||
/** One configured language server inside the shared E2B sandbox. */
|
||||
export interface LspE2BServerConfig {
|
||||
/** Remote executable, absolute or resolved on the sandbox PATH. */
|
||||
command: string
|
||||
/** Lowercase leading-dot extension to LSP language id. */
|
||||
extensionToLanguage: Record<string, string>
|
||||
/** Remote executable arguments. */
|
||||
args?: string[]
|
||||
/** Explicit remote environment overrides. */
|
||||
env?: Record<string, string>
|
||||
/** Static `initialize` options. */
|
||||
initializationOptions?: unknown
|
||||
/** Static answer to every `workspace/configuration` item. */
|
||||
configuration?: unknown
|
||||
/** Largest LSP message accepted from the server. */
|
||||
maxMessageBytes?: number
|
||||
/** Largest remote stderr tail retained for diagnostics. */
|
||||
maxStderrBytes?: number
|
||||
/** Largest remote source opened for one query. */
|
||||
maxDocumentBytes?: number
|
||||
/** Graceful LSP shutdown budget. */
|
||||
shutdownTimeoutMs?: number
|
||||
/** Request-cancel and TERM-to-KILL grace. */
|
||||
killGraceMs?: number
|
||||
}
|
||||
|
||||
/** Plugin configuration. */
|
||||
export interface Config {
|
||||
/** Non-empty provider-id to remote-server table. */
|
||||
servers: Record<string, LspE2BServerConfig>
|
||||
}
|
||||
|
||||
type ResolvedServerConfig = Required<LspE2BServerConfig>
|
||||
|
||||
const ServerConfig: z<LspE2BServerConfig> = z.object({
|
||||
command: z.string().required(),
|
||||
args: z.array(String).default([]),
|
||||
env: z.dict(String).default({}),
|
||||
extensionToLanguage: z.dict(String).required(),
|
||||
initializationOptions: z.any().default(null),
|
||||
configuration: z.any().default(null),
|
||||
maxMessageBytes: z.number().default(DEFAULT_MAX_MESSAGE_BYTES),
|
||||
maxStderrBytes: z.number().default(DEFAULT_MAX_STDERR_BYTES),
|
||||
maxDocumentBytes: z.number().default(DEFAULT_MAX_DOCUMENT_BYTES),
|
||||
shutdownTimeoutMs: z.number().max(MAX_TIMER_DELAY_MS).default(DEFAULT_SHUTDOWN_TIMEOUT_MS),
|
||||
killGraceMs: z.number().max(MAX_TIMER_DELAY_MS).default(DEFAULT_KILL_GRACE_MS),
|
||||
})
|
||||
|
||||
/** Schemastery plugin configuration. */
|
||||
export const Config: z<Config> = z.object({
|
||||
servers: z.dict(ServerConfig).required(),
|
||||
})
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
interface RemoteSource {
|
||||
canonicalPath: string
|
||||
text: string
|
||||
}
|
||||
|
||||
function abortReason(signal: AbortSignal): unknown {
|
||||
try {
|
||||
signal.throwIfAborted()
|
||||
} catch (error: unknown) {
|
||||
return error
|
||||
}
|
||||
return new DOMException('The operation was aborted', 'AbortError')
|
||||
}
|
||||
|
||||
function abortable<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
|
||||
if (signal === undefined) return promise
|
||||
// AbortSignal permits opaque reasons, and callers observe the exact reason.
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
|
||||
if (signal.aborted) return Promise.reject(abortReason(signal))
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const onAbort = (): void => {
|
||||
cleanup()
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- Preserve the signal's exact opaque reason.
|
||||
reject(abortReason(signal))
|
||||
}
|
||||
const cleanup = (): void => { signal.removeEventListener('abort', onAbort) }
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
void promise.then(
|
||||
(value) => { cleanup(); resolve(value) },
|
||||
(error: unknown) => { cleanup(); reject(error instanceof Error ? error : new Error(String(error))) },
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function validateServerConfig(providerId: string, config: ResolvedServerConfig): void {
|
||||
if (config.command.length === 0) throw new Error(`lsp-e2b: servers.${providerId}.command must be non-empty`)
|
||||
for (const name of ['maxMessageBytes', 'maxStderrBytes', 'maxDocumentBytes', 'shutdownTimeoutMs', 'killGraceMs'] as const) {
|
||||
const value = config[name]
|
||||
if (!Number.isSafeInteger(value) || value <= 0 || (name.endsWith('Ms') && value > MAX_TIMER_DELAY_MS)) {
|
||||
throw new Error(`lsp-e2b: servers.${providerId}.${name} must be a positive safe integer${name.endsWith('Ms') ? ` no greater than ${MAX_TIMER_DELAY_MS}` : ''}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function canonicalRemotePath(sandbox: Sandbox, path: string, signal?: AbortSignal): Promise<string> {
|
||||
signal?.throwIfAborted()
|
||||
const result = await sandbox.commands.run(`realpath -e -- ${quoteE2BShellArg(path)}`, signal === undefined ? {} : { signal })
|
||||
signal?.throwIfAborted()
|
||||
const canonical = result.stdout.trim()
|
||||
if (!posix.isAbsolute(canonical) || canonical.includes('\n')) throw new Error(`remote path ${JSON.stringify(path)} did not resolve canonically`)
|
||||
return canonical
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonicalize and validate one workspace inside E2B.
|
||||
* @param sandbox - Shared sandbox that owns the workspace.
|
||||
* @param workspaceRoot - Remote workspace path supplied by the query.
|
||||
* @param signal - Optional query cancellation signal.
|
||||
* @returns The canonical remote directory path.
|
||||
*/
|
||||
export async function canonicalizeE2BWorkspace(
|
||||
sandbox: Sandbox,
|
||||
workspaceRoot: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<string> {
|
||||
const canonical = await canonicalRemotePath(sandbox, workspaceRoot, signal)
|
||||
const info = await sandbox.files.getInfo(canonical, signal === undefined ? {} : { signal })
|
||||
signal?.throwIfAborted()
|
||||
if (info.type !== FileType.DIR) throw new Error(`workspace root ${JSON.stringify(workspaceRoot)} is not a directory`)
|
||||
return canonical
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve, contain, and read one UTF-8 query source inside E2B.
|
||||
* @param sandbox - Shared sandbox that owns the source.
|
||||
* @param filePath - Absolute path or path relative to the canonical workspace.
|
||||
* @param workspace - Canonical remote workspace directory.
|
||||
* @param maxDocumentBytes - Maximum source size before and after reading.
|
||||
* @param signal - Optional query cancellation signal.
|
||||
* @returns The canonical source path and decoded text.
|
||||
*/
|
||||
export async function readE2BSource(
|
||||
sandbox: Sandbox,
|
||||
filePath: string,
|
||||
workspace: string,
|
||||
maxDocumentBytes: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<RemoteSource> {
|
||||
const requested = posix.isAbsolute(filePath) ? filePath : posix.resolve(workspace, filePath)
|
||||
const canonicalPath = await canonicalRemotePath(sandbox, requested, signal)
|
||||
const relative = posix.relative(workspace, canonicalPath)
|
||||
if (relative === '..' || relative.startsWith('../') || posix.isAbsolute(relative)) {
|
||||
throw new Error(`source ${JSON.stringify(filePath)} resolves outside the workspace`)
|
||||
}
|
||||
const info = await sandbox.files.getInfo(canonicalPath, signal === undefined ? {} : { signal })
|
||||
if (info.type !== FileType.FILE) throw new Error(`source ${JSON.stringify(filePath)} is not a regular file`)
|
||||
if (info.size > maxDocumentBytes) {
|
||||
throw new Error(`source ${JSON.stringify(filePath)} is ${info.size} bytes, over the ${maxDocumentBytes}-byte limit`)
|
||||
}
|
||||
const bytes = await sandbox.files.read(canonicalPath, { format: 'bytes', ...signal === undefined ? {} : { signal } })
|
||||
signal?.throwIfAborted()
|
||||
if (bytes.length > maxDocumentBytes) {
|
||||
throw new Error(`source ${JSON.stringify(filePath)} grew past the ${maxDocumentBytes}-byte limit while reading`)
|
||||
}
|
||||
let text: string
|
||||
try {
|
||||
text = new TextDecoder('utf-8', { fatal: true }).decode(bytes)
|
||||
} catch (error: unknown) {
|
||||
throw new Error(`source ${JSON.stringify(filePath)} is not valid UTF-8 text`, { cause: error })
|
||||
}
|
||||
return { canonicalPath, text }
|
||||
}
|
||||
|
||||
/* jscpd:ignore-start -- Provider identity mirrors the seam while remote source and process ownership stay local. */
|
||||
/** One pooled remote provider with an isolated server per canonical workspace. */
|
||||
export class E2BLspProvider implements LspProvider {
|
||||
readonly id: LspProviderId
|
||||
readonly extensionToLanguage: Readonly<Record<string, string>>
|
||||
private readonly instances = new Map<string, LspInstance>()
|
||||
private readonly queues = new Map<string, Promise<void>>()
|
||||
private disposed = false
|
||||
|
||||
constructor(
|
||||
providerId: string,
|
||||
private readonly sandbox: Sandbox,
|
||||
private readonly subprocess: E2BSubprocessService,
|
||||
private readonly config: ResolvedServerConfig,
|
||||
private readonly executable: string,
|
||||
private readonly nodeExecutable: string,
|
||||
private readonly proxyPath: string,
|
||||
) {
|
||||
this.id = LspProviderId(providerId)
|
||||
this.extensionToLanguage = config.extensionToLanguage
|
||||
}
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
async query(request: LspProviderQuery, signal?: AbortSignal): Promise<LspQueryResult> {
|
||||
this.assertActive(signal)
|
||||
const workspace = await canonicalizeE2BWorkspace(this.sandbox, request.workspaceRoot, signal)
|
||||
this.assertActive(signal)
|
||||
return this.enqueue(workspace, signal, async () => {
|
||||
this.assertActive(signal)
|
||||
const source = await readE2BSource(this.sandbox, request.filePath, workspace, this.config.maxDocumentBytes, signal)
|
||||
this.assertActive(signal)
|
||||
let instance = this.instanceFor(workspace)
|
||||
try {
|
||||
return await instance.query(request, source, signal)
|
||||
} catch (error: unknown) {
|
||||
if (!instance.isTransportFailure(error)) throw error
|
||||
await instance.dispose()
|
||||
this.evict(workspace, instance)
|
||||
this.assertActive(signal)
|
||||
instance = this.instanceFor(workspace)
|
||||
return await instance.query(request, source, signal)
|
||||
} finally {
|
||||
if (instance.dead) {
|
||||
await instance.dispose()
|
||||
this.evict(workspace, instance)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/* jscpd:ignore-start -- Queue and pooling semantics are shared; transport failure and disposal identities differ. */
|
||||
/** Stop accepting work and await every remote server and queued query. */
|
||||
async disposeAll(): Promise<void> {
|
||||
this.disposed = true
|
||||
const instances = [...this.instances.values()]
|
||||
const queues = [...this.queues.values()]
|
||||
this.instances.clear()
|
||||
await Promise.all([...instances.map(instance => instance.dispose()), ...queues])
|
||||
this.queues.clear()
|
||||
}
|
||||
|
||||
private assertActive(signal?: AbortSignal): void {
|
||||
if (this.disposed) throw new LspError('lsp-e2b provider is disposed', 'LSP_DISPOSED')
|
||||
signal?.throwIfAborted()
|
||||
}
|
||||
|
||||
private enqueue<T>(workspace: string, signal: AbortSignal | undefined, run: () => Promise<T>): Promise<T> {
|
||||
const previous = this.queues.get(workspace) ?? Promise.resolve()
|
||||
const result = abortable(previous, signal).then(run)
|
||||
const tail = previous.then(() => result).then(() => undefined, () => undefined)
|
||||
this.queues.set(workspace, tail)
|
||||
void tail.then(() => { if (this.queues.get(workspace) === tail) this.queues.delete(workspace) })
|
||||
return result
|
||||
}
|
||||
|
||||
private instanceFor(workspace: string): LspInstance {
|
||||
this.assertActive()
|
||||
const existing = this.instances.get(workspace)
|
||||
if (existing !== undefined) return existing
|
||||
const created = this.createInstance(workspace)
|
||||
this.instances.set(workspace, created)
|
||||
return created
|
||||
}
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
private createInstance(workspace: string): LspInstance {
|
||||
return new LspInstance({
|
||||
command: this.executable,
|
||||
args: this.config.args,
|
||||
cwd: workspace,
|
||||
env: this.config.env,
|
||||
configuration: this.config.configuration,
|
||||
initializationOptions: this.config.initializationOptions,
|
||||
maxMessageBytes: this.config.maxMessageBytes,
|
||||
maxStderrBytes: this.config.maxStderrBytes,
|
||||
shutdownTimeoutMs: this.config.shutdownTimeoutMs,
|
||||
killGraceMs: this.config.killGraceMs,
|
||||
clientProcessId: null,
|
||||
}, (spec: SubprocessSpawnSpec) => {
|
||||
const originalArgv = Buffer.from(JSON.stringify(spec.argv)).toString('base64')
|
||||
const inner = this.subprocess.spawn({
|
||||
...spec,
|
||||
argv: [this.nodeExecutable, this.proxyPath, originalArgv],
|
||||
stdio: {
|
||||
stdin: 'pipe',
|
||||
stdout: 'pipe',
|
||||
stderr: { maxBytes: this.config.maxStderrBytes },
|
||||
},
|
||||
})
|
||||
const rawBound = Math.max(this.config.maxMessageBytes, this.config.maxStderrBytes)
|
||||
return new E2BLspTransport(inner, rawBound * 2 + 1024, this.config.maxStderrBytes)
|
||||
})
|
||||
}
|
||||
|
||||
private evict(workspace: string, instance: LspInstance): void {
|
||||
if (this.instances.get(workspace) === instance) this.instances.delete(workspace)
|
||||
}
|
||||
}
|
||||
|
||||
/** Install the proxy, resolve remote commands, and atomically register providers. */
|
||||
export async function apply(ctx: Context, config: Config): Promise<void> {
|
||||
if (!(ctx.subprocess instanceof E2BSubprocessService)) {
|
||||
throw new Error('lsp-e2b requires @deepseek-ai/dsh-subprocess-e2b as ctx.subprocess')
|
||||
}
|
||||
const subprocess = ctx.subprocess
|
||||
const entries = Object.entries(config.servers)
|
||||
if (entries.length === 0) throw new Error('lsp-e2b: servers must contain at least one server')
|
||||
const sandbox = await ctx.e2b.getSandbox()
|
||||
const proxyPath = posix.join(ctx.e2b.runtimeRoot, 'lsp-stdio-proxy.mjs')
|
||||
await sandbox.files.write([{ path: proxyPath, data: LSP_PROXY_SOURCE }])
|
||||
await sandbox.commands.run(`chmod 600 -- ${quoteE2BShellArg(proxyPath)}`)
|
||||
const nodeExecutable = await resolveE2BExecutable(sandbox, 'node')
|
||||
const providers = await Promise.all(entries.map(async ([providerId, raw]) => {
|
||||
if (providerId.trim() === '') throw new Error('lsp-e2b: server ids must be non-empty strings')
|
||||
const resolved = raw as ResolvedServerConfig
|
||||
validateServerConfig(providerId, resolved)
|
||||
const executable = await resolveE2BExecutable(sandbox, resolved.command)
|
||||
return new E2BLspProvider(providerId, sandbox, subprocess, resolved, executable, nodeExecutable, proxyPath)
|
||||
}))
|
||||
|
||||
/* jscpd:ignore-start -- Every provider table publishes atomically through the same registry contract. */
|
||||
ctx.effect(() => {
|
||||
const disposers: Array<() => void> = []
|
||||
try {
|
||||
for (const provider of providers) disposers.push(ctx.lsp.registerProvider(provider))
|
||||
} catch (error: unknown) {
|
||||
for (const dispose of disposers.reverse()) dispose()
|
||||
throw error
|
||||
}
|
||||
return async () => {
|
||||
for (const dispose of disposers.reverse()) dispose()
|
||||
await Promise.all(providers.map(provider => provider.disposeAll()))
|
||||
}
|
||||
}, 'lsp-e2b.registerProviders')
|
||||
/* jscpd:ignore-end */
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
/** Package-owned invariant companion for `@deepseek-ai/dsh-lsp-e2b`. */
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-lsp-e2b'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'lsp-e2b-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/** No runtime invariant: the LSP registry owns provider publication. */
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/** Register this package's invariant companion. */
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
@@ -1,55 +0,0 @@
|
||||
/** Dependency-free remote stdio proxy installed inside the E2B sandbox. */
|
||||
|
||||
/**
|
||||
* Node program that base64-frames raw child stdio so E2B's text callbacks
|
||||
* never decode the language server's byte stream.
|
||||
*/
|
||||
export const LSP_PROXY_SOURCE = String.raw`import { Buffer } from 'node:buffer'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { createInterface } from 'node:readline'
|
||||
|
||||
const emit = (message) => {
|
||||
process.stdout.write(Buffer.from(JSON.stringify(message)).toString('base64') + '\n')
|
||||
}
|
||||
|
||||
let argv
|
||||
try {
|
||||
argv = JSON.parse(Buffer.from(process.argv[2], 'base64').toString('utf8'))
|
||||
if (!Array.isArray(argv) || argv.length === 0 || argv.some(value => typeof value !== 'string')) throw new Error('invalid argv')
|
||||
} catch (error) {
|
||||
process.stderr.write('lsp-e2b proxy argv error: ' + String(error) + '\n')
|
||||
process.exitCode = 125
|
||||
process.stdin.destroy()
|
||||
}
|
||||
|
||||
if (argv) {
|
||||
const child = spawn(argv[0], argv.slice(1), { stdio: ['pipe', 'pipe', 'pipe'], env: process.env })
|
||||
const input = createInterface({ input: process.stdin, crlfDelay: Infinity })
|
||||
child.stdout.on('data', data => { emit({ type: 'stdout', data: data.toString('base64') }) })
|
||||
child.stderr.on('data', data => { emit({ type: 'stderr', data: data.toString('base64') }) })
|
||||
child.on('error', error => {
|
||||
emit({ type: 'stderr', data: Buffer.from('language server spawn failed: ' + error.message).toString('base64') })
|
||||
})
|
||||
child.on('close', (code, signal) => {
|
||||
emit({ type: 'exit', code, signal })
|
||||
input.close()
|
||||
process.stdin.destroy()
|
||||
process.exitCode = code === null ? 1 : code
|
||||
})
|
||||
input.on('line', line => {
|
||||
input.pause()
|
||||
try {
|
||||
const message = JSON.parse(Buffer.from(line, 'base64').toString('utf8'))
|
||||
if (!message || message.type !== 'stdin' || typeof message.data !== 'string') throw new Error('invalid stdin frame')
|
||||
const data = Buffer.from(message.data, 'base64')
|
||||
if (data.toString('base64') !== message.data) throw new Error('invalid stdin base64')
|
||||
if (child.stdin.write(data)) input.resume()
|
||||
else child.stdin.once('drain', () => { input.resume() })
|
||||
} catch (error) {
|
||||
process.stderr.write('lsp-e2b proxy stdin error: ' + String(error) + '\n')
|
||||
child.kill('SIGTERM')
|
||||
}
|
||||
})
|
||||
input.on('close', () => { child.stdin.end() })
|
||||
}
|
||||
`
|
||||
@@ -1,183 +0,0 @@
|
||||
/** Byte-faithful stdio transport over an E2B subprocess and ASCII/base64 frames. */
|
||||
|
||||
import { Buffer } from 'node:buffer'
|
||||
import { PassThrough, Writable } from 'node:stream'
|
||||
import { E2BFrameDecoder, encodeE2BFrame } from '@deepseek-ai/dsh-e2b'
|
||||
import type {
|
||||
SubprocessHandle,
|
||||
SubprocessOutcome,
|
||||
SubprocessOutputRead,
|
||||
SubprocessOutputReader,
|
||||
} from '@deepseek-ai/dsh-subprocess'
|
||||
|
||||
function asError(error: unknown): Error {
|
||||
return error instanceof Error ? error : new Error(String(error))
|
||||
}
|
||||
|
||||
class ByteTailReader implements SubprocessOutputReader {
|
||||
private chunks: Buffer[] = []
|
||||
private totalBytes = 0
|
||||
private retainedBytes = 0
|
||||
private dropped = false
|
||||
|
||||
constructor(private readonly maxBytes: number) {}
|
||||
|
||||
append(data: Buffer): void {
|
||||
if (data.length === 0) return
|
||||
this.chunks.push(data)
|
||||
this.totalBytes += data.length
|
||||
this.retainedBytes += data.length
|
||||
while (this.retainedBytes > this.maxBytes && this.chunks.length > 0) {
|
||||
const first = this.chunks[0] as Buffer
|
||||
const excess = this.retainedBytes - this.maxBytes
|
||||
if (first.length <= excess) {
|
||||
this.chunks.shift()
|
||||
this.retainedBytes -= first.length
|
||||
} else {
|
||||
this.chunks[0] = first.subarray(excess)
|
||||
this.retainedBytes -= excess
|
||||
}
|
||||
this.dropped = true
|
||||
}
|
||||
}
|
||||
|
||||
readFrom(fromByte: number): SubprocessOutputRead {
|
||||
if (!Number.isSafeInteger(fromByte) || fromByte < 0) {
|
||||
throw new Error('subprocess output offset must be a non-negative safe integer')
|
||||
}
|
||||
const retainedStart = this.totalBytes - this.retainedBytes
|
||||
const lossy = fromByte < retainedStart
|
||||
const start = lossy ? 0 : Math.min(this.retainedBytes, fromByte - retainedStart)
|
||||
const bytes = Buffer.concat(this.chunks).subarray(start)
|
||||
return { text: bytes.toString('utf8'), nextOffset: this.totalBytes, lossy: lossy || this.dropped && fromByte === 0 }
|
||||
}
|
||||
}
|
||||
|
||||
class FramedInput extends Writable {
|
||||
constructor(private readonly target: Writable) {
|
||||
super()
|
||||
target.on('error', (error: Error) => { this.destroy(error) })
|
||||
}
|
||||
|
||||
override _write(chunk: Buffer, _encoding: BufferEncoding, callback: (error?: Error | null) => void): void {
|
||||
this.target.write(encodeE2BFrame({ type: 'stdin', data: chunk.toString('base64') }), callback)
|
||||
}
|
||||
|
||||
override _final(callback: (error?: Error | null) => void): void {
|
||||
this.target.end(callback)
|
||||
}
|
||||
}
|
||||
|
||||
/** Subprocess handle that decodes a remote proxy's stdout/stderr byte frames. */
|
||||
export class E2BLspTransport implements SubprocessHandle {
|
||||
readonly stdin: Writable
|
||||
readonly stdout = new PassThrough()
|
||||
readonly stderr = undefined
|
||||
readonly collected: SubprocessHandle['collected']
|
||||
readonly done: Promise<SubprocessOutcome>
|
||||
private readonly stderrTail: ByteTailReader
|
||||
private readonly decoder: E2BFrameDecoder
|
||||
private failed: Error | undefined
|
||||
|
||||
/**
|
||||
* @param inner - E2B subprocess running the installed proxy.
|
||||
* @param maxFrameBytes - Maximum decoded proxy frame size.
|
||||
* @param maxStderrBytes - Retained raw language-server stderr tail.
|
||||
*/
|
||||
constructor(
|
||||
private readonly inner: SubprocessHandle,
|
||||
maxFrameBytes: number,
|
||||
maxStderrBytes: number,
|
||||
) {
|
||||
if (inner.stdin === undefined || inner.stdout === undefined) {
|
||||
inner.terminate()
|
||||
throw new Error('lsp-e2b: proxy subprocess dropped a piped stream')
|
||||
}
|
||||
this.stdin = new FramedInput(inner.stdin)
|
||||
this.stderrTail = new ByteTailReader(maxStderrBytes)
|
||||
this.collected = { stderr: this.stderrTail }
|
||||
this.decoder = new E2BFrameDecoder(maxFrameBytes)
|
||||
inner.stdout.on('data', (chunk: Buffer) => { this.onProxyData(chunk) })
|
||||
inner.stdout.on('error', (error: Error) => { this.fail(error) })
|
||||
this.done = inner.done.then(
|
||||
(outcome) => {
|
||||
this.finishFrames()
|
||||
this.captureProxyStderr()
|
||||
this.stdout.end()
|
||||
if (this.failed !== undefined) throw this.failed
|
||||
return outcome
|
||||
},
|
||||
(error: unknown) => {
|
||||
this.captureProxyStderr()
|
||||
this.stdout.end()
|
||||
throw error
|
||||
},
|
||||
)
|
||||
void this.done.catch(() => {})
|
||||
}
|
||||
|
||||
get pid(): number {
|
||||
return this.inner.pid
|
||||
}
|
||||
|
||||
terminate(): void {
|
||||
this.inner.terminate()
|
||||
}
|
||||
|
||||
async waitForExit(signal?: AbortSignal): Promise<boolean> {
|
||||
return await this.inner.waitForExit(signal)
|
||||
}
|
||||
|
||||
private onProxyData(chunk: Buffer): void {
|
||||
if (this.failed !== undefined) return
|
||||
let frames: unknown[]
|
||||
try {
|
||||
frames = this.decoder.push(chunk.toString('utf8'))
|
||||
} catch (error: unknown) {
|
||||
this.fail(asError(error))
|
||||
return
|
||||
}
|
||||
for (const frame of frames) this.dispatch(frame)
|
||||
}
|
||||
|
||||
private dispatch(frame: unknown): void {
|
||||
if (typeof frame !== 'object' || frame === null) {
|
||||
this.fail(new Error('lsp-e2b: proxy emitted a malformed frame'))
|
||||
return
|
||||
}
|
||||
const record = frame as Record<string, unknown>
|
||||
if (record.type === 'exit' && (record.code === null || typeof record.code === 'number') && (record.signal === null || typeof record.signal === 'string')) return
|
||||
if ((record.type !== 'stdout' && record.type !== 'stderr') || typeof record.data !== 'string') {
|
||||
this.fail(new Error('lsp-e2b: proxy emitted a malformed frame'))
|
||||
return
|
||||
}
|
||||
const data = Buffer.from(record.data, 'base64')
|
||||
if (data.toString('base64') !== record.data) {
|
||||
this.fail(new Error('lsp-e2b: proxy emitted invalid base64'))
|
||||
return
|
||||
}
|
||||
if (record.type === 'stdout') this.stdout.write(data)
|
||||
else this.stderrTail.append(data)
|
||||
}
|
||||
|
||||
private finishFrames(): void {
|
||||
if (this.failed !== undefined) return
|
||||
try {
|
||||
this.decoder.finish()
|
||||
} catch (error: unknown) {
|
||||
this.fail(asError(error))
|
||||
}
|
||||
}
|
||||
|
||||
private captureProxyStderr(): void {
|
||||
const diagnostic = this.inner.collected.stderr?.readFrom(0).text
|
||||
if (diagnostic !== undefined && diagnostic.length > 0) this.stderrTail.append(Buffer.from(diagnostic))
|
||||
}
|
||||
|
||||
private fail(error: Error): void {
|
||||
if (this.failed !== undefined) return
|
||||
this.failed = error
|
||||
this.inner.terminate()
|
||||
this.stdout.end()
|
||||
}
|
||||
}
|
||||
@@ -1,397 +0,0 @@
|
||||
import { PassThrough } from 'node:stream'
|
||||
import { Context } from 'cordis'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
FileType,
|
||||
type Sandbox,
|
||||
} from '@deepseek-ai/dsh-e2b'
|
||||
import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
|
||||
import E2BSubprocessService from '@deepseek-ai/dsh-subprocess-e2b'
|
||||
|
||||
const mockedLsp = vi.hoisted(() => {
|
||||
interface Plan {
|
||||
query?: (...args: unknown[]) => unknown
|
||||
transportFailure?: unknown
|
||||
dead?: boolean
|
||||
deadAfterQuery?: boolean
|
||||
disposeError?: unknown
|
||||
}
|
||||
|
||||
class FakeLspInstance {
|
||||
static readonly instances: FakeLspInstance[] = []
|
||||
static readonly plans: Plan[] = []
|
||||
readonly plan: Plan
|
||||
readonly transport: unknown
|
||||
readonly queries: unknown[][] = []
|
||||
dead: boolean
|
||||
disposals = 0
|
||||
|
||||
constructor(
|
||||
readonly spec: Record<string, unknown>,
|
||||
spawner: (spec: SubprocessSpawnSpec) => unknown,
|
||||
) {
|
||||
this.plan = FakeLspInstance.plans.shift() ?? {}
|
||||
this.dead = this.plan.dead === true
|
||||
this.transport = spawner({
|
||||
argv: [String(spec.command), ...(spec.args as string[])],
|
||||
cwd: String(spec.cwd),
|
||||
env: spec.env as Record<string, string>,
|
||||
graceMs: Number(spec.killGraceMs),
|
||||
stdio: { stdin: 'pipe', stdout: 'pipe', stderr: { maxBytes: Number(spec.maxStderrBytes) } },
|
||||
})
|
||||
FakeLspInstance.instances.push(this)
|
||||
}
|
||||
|
||||
async query(...args: unknown[]): Promise<unknown> {
|
||||
this.queries.push(args)
|
||||
const result = await Promise.resolve(this.plan.query?.(...args) ?? { kind: 'hover', hover: null })
|
||||
if (this.plan.deadAfterQuery === true) this.dead = true
|
||||
return result
|
||||
}
|
||||
|
||||
isTransportFailure(error: unknown): boolean {
|
||||
return error === this.plan.transportFailure
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
this.disposals += 1
|
||||
this.dead = true
|
||||
if (this.plan.disposeError !== undefined) throw this.plan.disposeError
|
||||
}
|
||||
}
|
||||
|
||||
return { FakeLspInstance }
|
||||
})
|
||||
|
||||
vi.mock('@deepseek-ai/dsh-lsp-local', () => ({ LspInstance: mockedLsp.FakeLspInstance }))
|
||||
|
||||
import {
|
||||
E2BLspProvider,
|
||||
apply,
|
||||
canonicalizeE2BWorkspace,
|
||||
readE2BSource,
|
||||
} from '@deepseek-ai/dsh-lsp-e2b'
|
||||
import type { LspE2BServerConfig } from '@deepseek-ai/dsh-lsp-e2b'
|
||||
import * as E2BLspInvariant from '../src/invariant.ts'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
class FakeInnerHandle implements SubprocessHandle {
|
||||
readonly pid = 777
|
||||
readonly stdin = new PassThrough()
|
||||
readonly stdout = new PassThrough()
|
||||
readonly stderr = undefined
|
||||
readonly collected = { stderr: { readFrom: () => ({ text: '', nextOffset: 0, lossy: false }) } }
|
||||
readonly done = Promise.resolve({ exitCode: 0, signal: null })
|
||||
terminate(): void {}
|
||||
async waitForExit(): Promise<boolean> { return true }
|
||||
}
|
||||
|
||||
class FakeRemote {
|
||||
readonly writes: Array<Array<{ path: string; data: string }>> = []
|
||||
readonly commands: string[] = []
|
||||
readonly infos = new Map<string, { type: FileType; size: number }>()
|
||||
readonly contents = new Map<string, Uint8Array>()
|
||||
readonly realpaths = new Map<string, string>()
|
||||
forcedRealpath: string | undefined
|
||||
|
||||
constructor() {
|
||||
this.infos.set('/workspace', { type: FileType.DIR, size: 0 })
|
||||
this.infos.set('/workspace/file.ts', { type: FileType.FILE, size: 12 })
|
||||
this.contents.set('/workspace/file.ts', Buffer.from('const x = 1'))
|
||||
}
|
||||
|
||||
readonly sandbox = {
|
||||
commands: {
|
||||
run: async (command: string) => {
|
||||
this.commands.push(command)
|
||||
if (command.startsWith('realpath ')) {
|
||||
const match = /'([^']*)'$/.exec(command)
|
||||
const requested = match?.[1] ?? ''
|
||||
return { exitCode: 0, stdout: `${this.forcedRealpath ?? this.realpaths.get(requested) ?? requested}\n`, stderr: '' }
|
||||
}
|
||||
if (command.startsWith('command -v')) return { exitCode: 0, stdout: '/usr/bin/node\n', stderr: '' }
|
||||
return { exitCode: 0, stdout: '', stderr: '' }
|
||||
},
|
||||
},
|
||||
files: {
|
||||
write: async (files: Array<{ path: string; data: string }>) => {
|
||||
this.writes.push(files)
|
||||
return files.map(() => ({}))
|
||||
},
|
||||
getInfo: async (path: string) => {
|
||||
const info = this.infos.get(path)
|
||||
if (info === undefined) throw new Error(`missing info for ${path}`)
|
||||
return info
|
||||
},
|
||||
read: async (path: string) => this.contents.get(path) ?? new Uint8Array(),
|
||||
},
|
||||
} as unknown as Sandbox
|
||||
}
|
||||
|
||||
function subprocess(spawn = vi.fn((_spec: SubprocessSpawnSpec) => new FakeInnerHandle())): E2BSubprocessService {
|
||||
const service = Object.create(E2BSubprocessService.prototype) as E2BSubprocessService
|
||||
Object.defineProperty(service, 'spawn', { value: spawn })
|
||||
return service
|
||||
}
|
||||
|
||||
function server(overrides: Partial<LspE2BServerConfig> = {}): Required<LspE2BServerConfig> {
|
||||
return {
|
||||
command: '/usr/bin/server', args: ['--stdio'], env: {},
|
||||
extensionToLanguage: { '.ts': 'typescript' },
|
||||
initializationOptions: null, configuration: null,
|
||||
maxMessageBytes: 1_024, maxStderrBytes: 128, maxDocumentBytes: 1_024,
|
||||
shutdownTimeoutMs: 100, killGraceMs: 50,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function provider(remote = new FakeRemote(), service = subprocess()): E2BLspProvider {
|
||||
return new E2BLspProvider(
|
||||
'fixture', remote.sandbox, service, server(),
|
||||
'/usr/bin/server', '/usr/bin/node', '/workspace/.dsh-e2b/lsp-proxy.mjs',
|
||||
)
|
||||
}
|
||||
|
||||
function query(workspaceRoot = '/workspace') {
|
||||
return {
|
||||
operation: 'hover' as const,
|
||||
filePath: 'file.ts',
|
||||
position: { line: 0, character: 1 },
|
||||
workspaceRoot,
|
||||
languageId: 'typescript',
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockedLsp.FakeLspInstance.instances.length = 0
|
||||
mockedLsp.FakeLspInstance.plans.length = 0
|
||||
})
|
||||
|
||||
describe('E2B LSP filesystem boundary', () => {
|
||||
it('canonicalizes a directory and reads a contained UTF-8 source', async () => {
|
||||
const remote = new FakeRemote()
|
||||
await expect(canonicalizeE2BWorkspace(remote.sandbox, '/workspace')).resolves.toBe('/workspace')
|
||||
await expect(readE2BSource(remote.sandbox, 'file.ts', '/workspace', 1_024)).resolves.toEqual({
|
||||
canonicalPath: '/workspace/file.ts',
|
||||
text: 'const x = 1',
|
||||
})
|
||||
await expect(readE2BSource(remote.sandbox, '/workspace/file.ts', '/workspace', 1_024)).resolves.toMatchObject({
|
||||
canonicalPath: '/workspace/file.ts',
|
||||
})
|
||||
const signal = new AbortController().signal
|
||||
await expect(canonicalizeE2BWorkspace(remote.sandbox, '/workspace', signal)).resolves.toBe('/workspace')
|
||||
await expect(readE2BSource(remote.sandbox, 'file.ts', '/workspace', 1_024, signal)).resolves.toMatchObject({
|
||||
canonicalPath: '/workspace/file.ts',
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects malformed workspaces and source containment/type/size/encoding failures', async () => {
|
||||
const malformed = new FakeRemote()
|
||||
malformed.forcedRealpath = 'relative'
|
||||
await expect(canonicalizeE2BWorkspace(malformed.sandbox, '/workspace')).rejects.toThrow('did not resolve canonically')
|
||||
malformed.forcedRealpath = '/workspace\nother'
|
||||
await expect(canonicalizeE2BWorkspace(malformed.sandbox, '/workspace')).rejects.toThrow('did not resolve canonically')
|
||||
|
||||
const notDirectory = new FakeRemote()
|
||||
notDirectory.infos.set('/workspace', { type: FileType.FILE, size: 0 })
|
||||
await expect(canonicalizeE2BWorkspace(notDirectory.sandbox, '/workspace')).rejects.toThrow('not a directory')
|
||||
|
||||
const outside = new FakeRemote()
|
||||
outside.realpaths.set('/workspace/file.ts', '/outside/file.ts')
|
||||
await expect(readE2BSource(outside.sandbox, 'file.ts', '/workspace', 20)).rejects.toThrow('outside the workspace')
|
||||
|
||||
const notFile = new FakeRemote()
|
||||
notFile.infos.set('/workspace/file.ts', { type: FileType.DIR, size: 0 })
|
||||
await expect(readE2BSource(notFile.sandbox, 'file.ts', '/workspace', 20)).rejects.toThrow('not a regular file')
|
||||
|
||||
const tooLarge = new FakeRemote()
|
||||
tooLarge.infos.set('/workspace/file.ts', { type: FileType.FILE, size: 21 })
|
||||
await expect(readE2BSource(tooLarge.sandbox, 'file.ts', '/workspace', 20)).rejects.toThrow('over the 20-byte limit')
|
||||
|
||||
const grew = new FakeRemote()
|
||||
grew.infos.set('/workspace/file.ts', { type: FileType.FILE, size: 1 })
|
||||
grew.contents.set('/workspace/file.ts', Buffer.alloc(21))
|
||||
await expect(readE2BSource(grew.sandbox, 'file.ts', '/workspace', 20)).rejects.toThrow('grew past')
|
||||
|
||||
const invalid = new FakeRemote()
|
||||
invalid.infos.set('/workspace/file.ts', { type: FileType.FILE, size: 1 })
|
||||
invalid.contents.set('/workspace/file.ts', Uint8Array.from([0xff]))
|
||||
await expect(readE2BSource(invalid.sandbox, 'file.ts', '/workspace', 20)).rejects.toThrow('not valid UTF-8')
|
||||
|
||||
await expect(canonicalizeE2BWorkspace(new FakeRemote().sandbox, '/workspace', AbortSignal.abort('stop')))
|
||||
.rejects.toBe('stop')
|
||||
})
|
||||
})
|
||||
|
||||
describe('E2BLspProvider pooling and lifecycle', () => {
|
||||
it('reuses one canonical-workspace instance and constructs the remote proxy transport', async () => {
|
||||
const spawn = vi.fn((_spec: SubprocessSpawnSpec) => new FakeInnerHandle())
|
||||
const remote = new FakeRemote()
|
||||
mockedLsp.FakeLspInstance.plans.push({ query: async () => ({ kind: 'hover', hover: { contents: 'ok' } }) })
|
||||
const current = provider(remote, subprocess(spawn))
|
||||
|
||||
await expect(current.query(query())).resolves.toEqual({ kind: 'hover', hover: { contents: 'ok' } })
|
||||
await expect(current.query(query())).resolves.toEqual({ kind: 'hover', hover: { contents: 'ok' } })
|
||||
expect(mockedLsp.FakeLspInstance.instances).toHaveLength(1)
|
||||
expect(mockedLsp.FakeLspInstance.instances[0]?.spec).toMatchObject({ clientProcessId: null, cwd: '/workspace' })
|
||||
expect(spawn).toHaveBeenCalledWith(expect.objectContaining({
|
||||
argv: ['/usr/bin/node', '/workspace/.dsh-e2b/lsp-proxy.mjs', expect.any(String)],
|
||||
stdio: { stdin: 'pipe', stdout: 'pipe', stderr: { maxBytes: 128 } },
|
||||
}))
|
||||
expect(current.id).toBe('fixture')
|
||||
expect(current.extensionToLanguage).toEqual({ '.ts': 'typescript' })
|
||||
await current.disposeAll()
|
||||
expect(mockedLsp.FakeLspInstance.instances[0]?.disposals).toBe(1)
|
||||
})
|
||||
|
||||
it('replaces one transport failure, but preserves ordinary query errors', async () => {
|
||||
const transportFailure = new Error('transport failed')
|
||||
mockedLsp.FakeLspInstance.plans.push(
|
||||
{ transportFailure, query: async () => { throw transportFailure } },
|
||||
{ query: async () => ({ kind: 'hover', hover: { contents: 'retried' } }) },
|
||||
)
|
||||
const retried = provider()
|
||||
await expect(retried.query(query())).resolves.toMatchObject({ hover: { contents: 'retried' } })
|
||||
expect(mockedLsp.FakeLspInstance.instances[0]?.disposals).toBe(1)
|
||||
expect(mockedLsp.FakeLspInstance.instances).toHaveLength(2)
|
||||
|
||||
const ordinary = new Error('ordinary failure')
|
||||
mockedLsp.FakeLspInstance.plans.push(
|
||||
{ query: async () => { throw ordinary }, dead: true },
|
||||
{ query: async () => ({ kind: 'hover', hover: null }) },
|
||||
)
|
||||
const failed = provider()
|
||||
await expect(failed.query(query())).rejects.toBe(ordinary)
|
||||
await expect(failed.query(query())).resolves.toMatchObject({ kind: 'hover' })
|
||||
})
|
||||
|
||||
it('evicts a server that dies after a successful query', async () => {
|
||||
mockedLsp.FakeLspInstance.plans.push(
|
||||
{ deadAfterQuery: true, query: async () => ({ kind: 'hover', hover: null }) },
|
||||
{ query: async () => ({ kind: 'hover', hover: null }) },
|
||||
)
|
||||
const current = provider()
|
||||
await current.query(query())
|
||||
await current.query(query())
|
||||
expect(mockedLsp.FakeLspInstance.instances).toHaveLength(2)
|
||||
expect(mockedLsp.FakeLspInstance.instances[0]?.disposals).toBe(1)
|
||||
})
|
||||
|
||||
it('serializes a workspace queue, observes queued abort, and awaits work on disposal', async () => {
|
||||
const first = Promise.withResolvers<unknown>()
|
||||
mockedLsp.FakeLspInstance.plans.push({ query: () => first.promise })
|
||||
const current = provider()
|
||||
const running = current.query(query())
|
||||
const controller = new AbortController()
|
||||
const queued = current.query(query(), controller.signal)
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
controller.abort('queued stop')
|
||||
await expect(queued).rejects.toBe('queued stop')
|
||||
|
||||
const disposing = current.disposeAll()
|
||||
first.resolve({ kind: 'hover', hover: null })
|
||||
await expect(running).resolves.toMatchObject({ kind: 'hover' })
|
||||
await disposing
|
||||
await expect(current.query(query())).rejects.toMatchObject({ code: 'LSP_DISPOSED' })
|
||||
})
|
||||
|
||||
it('covers pre-abort, synthetic abort, resolve, and rejection in the queue race', async () => {
|
||||
const current = provider()
|
||||
const internal = current as unknown as {
|
||||
queues: Map<string, Promise<void>>
|
||||
enqueue<T>(workspace: string, signal: AbortSignal | undefined, run: () => Promise<T>): Promise<T>
|
||||
}
|
||||
await expect(internal.enqueue('pre', AbortSignal.abort('pre-stop'), async () => 'unused')).rejects.toBe('pre-stop')
|
||||
|
||||
const signal = new AbortController().signal
|
||||
await expect(internal.enqueue('resolve', signal, async () => 'ok')).resolves.toBe('ok')
|
||||
|
||||
const failure = new Error('queue failed')
|
||||
const rejected = Promise.reject<undefined>(failure)
|
||||
void rejected.catch(() => {})
|
||||
internal.queues.set('reject', rejected)
|
||||
await expect(internal.enqueue('reject', signal, async () => 'unused')).rejects.toBe(failure)
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- Exercise normalization at the promise boundary.
|
||||
const opaque = Promise.reject<undefined>('opaque queue failure')
|
||||
void opaque.catch(() => {})
|
||||
internal.queues.set('opaque', opaque)
|
||||
await expect(internal.enqueue('opaque', signal, async () => 'unused')).rejects.toEqual(new Error('opaque queue failure'))
|
||||
|
||||
const synthetic = {
|
||||
aborted: false,
|
||||
reason: undefined,
|
||||
throwIfAborted() {},
|
||||
addEventListener(_type: string, listener: () => void) { listener() },
|
||||
removeEventListener() {},
|
||||
} as unknown as AbortSignal
|
||||
await expect(internal.enqueue('synthetic', synthetic, async () => 'unused')).rejects.toMatchObject({ name: 'AbortError' })
|
||||
await current.disposeAll()
|
||||
})
|
||||
})
|
||||
|
||||
describe('lsp-e2b plugin composition', () => {
|
||||
function pluginContext(
|
||||
remote: FakeRemote,
|
||||
service: E2BSubprocessService,
|
||||
registerProvider = vi.fn(() => vi.fn()),
|
||||
) {
|
||||
const effects: Array<() => void | Promise<void>> = []
|
||||
const ctx = {
|
||||
subprocess: service,
|
||||
e2b: {
|
||||
runtimeRoot: '/workspace/.dsh-e2b',
|
||||
getSandbox: async () => remote.sandbox,
|
||||
},
|
||||
lsp: { registerProvider },
|
||||
effect: (callback: () => (() => void | Promise<void>)) => { effects.push(callback()) },
|
||||
} as unknown as Context
|
||||
return { ctx, effects, registerProvider }
|
||||
}
|
||||
|
||||
it('installs one proxy, resolves commands, registers providers, and disposes them', async () => {
|
||||
const remote = new FakeRemote()
|
||||
const fixture = pluginContext(remote, subprocess())
|
||||
await apply(fixture.ctx, { servers: { one: server(), two: server({ command: 'server-two' }) } })
|
||||
expect(remote.writes).toHaveLength(1)
|
||||
expect(remote.writes[0]?.[0]?.path).toBe('/workspace/.dsh-e2b/lsp-stdio-proxy.mjs')
|
||||
expect(remote.commands).toContain("chmod 600 -- '/workspace/.dsh-e2b/lsp-stdio-proxy.mjs'")
|
||||
expect(fixture.registerProvider).toHaveBeenCalledTimes(2)
|
||||
await fixture.effects[0]?.()
|
||||
})
|
||||
|
||||
it('rolls back partial registration and rejects invalid composition/configuration', async () => {
|
||||
const remote = new FakeRemote()
|
||||
const firstDispose = vi.fn()
|
||||
const register = vi.fn()
|
||||
.mockReturnValueOnce(firstDispose)
|
||||
.mockImplementationOnce(() => { throw new Error('duplicate provider') })
|
||||
const rollback = pluginContext(remote, subprocess(), register)
|
||||
await expect(apply(rollback.ctx, { servers: { one: server(), two: server() } })).rejects.toThrow('duplicate provider')
|
||||
expect(firstDispose).toHaveBeenCalledOnce()
|
||||
|
||||
const wrong = pluginContext(remote, {} as E2BSubprocessService)
|
||||
await expect(apply(wrong.ctx, { servers: { one: server() } })).rejects.toThrow('dsh-subprocess-e2b')
|
||||
|
||||
const empty = pluginContext(remote, subprocess())
|
||||
await expect(apply(empty.ctx, { servers: {} })).rejects.toThrow('at least one server')
|
||||
|
||||
for (const [id, config] of [
|
||||
['', server()],
|
||||
['one', server({ command: '' })],
|
||||
['one', server({ maxMessageBytes: 0 })],
|
||||
['one', server({ maxStderrBytes: 1.5 })],
|
||||
['one', server({ shutdownTimeoutMs: 2_147_483_648 })],
|
||||
] as const) {
|
||||
const fixture = pluginContext(new FakeRemote(), subprocess())
|
||||
await expect(apply(fixture.ctx, { servers: { [id]: config } })).rejects.toThrow()
|
||||
}
|
||||
})
|
||||
|
||||
it('registers the package-owned invariant companion', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(InvariantService, { enabled: true })
|
||||
const fiber = await ctx.plugin(E2BLspInvariant).await()
|
||||
await fiber.dispose()
|
||||
})
|
||||
})
|
||||
@@ -1,181 +0,0 @@
|
||||
import { once } from 'node:events'
|
||||
import { PassThrough } from 'node:stream'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { encodeE2BFrame } from '@deepseek-ai/dsh-e2b'
|
||||
import type { SubprocessHandle, SubprocessOutcome } from '@deepseek-ai/dsh-subprocess'
|
||||
import { E2BLspTransport } from '@deepseek-ai/dsh-lsp-e2b'
|
||||
|
||||
class FakeHandle implements SubprocessHandle {
|
||||
readonly pid = 321
|
||||
readonly stdin: PassThrough | undefined
|
||||
readonly stdout: PassThrough | undefined
|
||||
readonly stderr = undefined
|
||||
readonly collected: SubprocessHandle['collected']
|
||||
readonly result = Promise.withResolvers<SubprocessOutcome>()
|
||||
readonly done = this.result.promise
|
||||
terminated = 0
|
||||
waitResult = true
|
||||
|
||||
constructor(options: { stdin?: boolean; stdout?: boolean; diagnostic?: string } = {}) {
|
||||
this.stdin = options.stdin === false ? undefined : new PassThrough()
|
||||
this.stdout = options.stdout === false ? undefined : new PassThrough()
|
||||
this.collected = options.diagnostic === undefined
|
||||
? {}
|
||||
: { stderr: { readFrom: () => ({ text: options.diagnostic as string, nextOffset: 0, lossy: false }) } }
|
||||
}
|
||||
|
||||
terminate(): void {
|
||||
this.terminated += 1
|
||||
}
|
||||
|
||||
async waitForExit(): Promise<boolean> {
|
||||
return this.waitResult
|
||||
}
|
||||
|
||||
resolve(outcome: SubprocessOutcome = { exitCode: 0, signal: null }): void {
|
||||
this.stdout?.end()
|
||||
this.result.resolve(outcome)
|
||||
}
|
||||
|
||||
reject(error: unknown): void {
|
||||
this.stdout?.end()
|
||||
this.result.reject(error)
|
||||
}
|
||||
}
|
||||
|
||||
function proxyFrame(type: 'stdout' | 'stderr', data: string | Buffer): string {
|
||||
return encodeE2BFrame({ type, data: Buffer.from(data).toString('base64') })
|
||||
}
|
||||
|
||||
describe('E2BLspTransport', () => {
|
||||
it('frames stdin, decodes split byte output, and exposes handle lifecycle', async () => {
|
||||
const inner = new FakeHandle({ diagnostic: 'proxy diagnostic' })
|
||||
const transport = new E2BLspTransport(inner, 1_024, 64)
|
||||
let stdin = ''
|
||||
inner.stdin?.on('data', (chunk) => { stdin += String(chunk) })
|
||||
let stdout = Buffer.alloc(0)
|
||||
transport.stdout.on('data', (chunk) => { stdout = Buffer.concat([stdout, chunk]) })
|
||||
|
||||
transport.stdin.write(Buffer.from([0, 0xff]))
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
const encodedInput = stdin.trim()
|
||||
const input = JSON.parse(Buffer.from(encodedInput, 'base64').toString('utf8')) as Record<string, string>
|
||||
expect(input).toEqual({ type: 'stdin', data: 'AP8=' })
|
||||
|
||||
const frames = proxyFrame('stdout', Buffer.from([0, 0xff]))
|
||||
+ proxyFrame('stderr', 'server diagnostic')
|
||||
+ encodeE2BFrame({ type: 'exit', code: 0, signal: null })
|
||||
inner.stdout?.write(frames.slice(0, 7))
|
||||
inner.stdout?.write(frames.slice(7))
|
||||
inner.stdout?.write(proxyFrame('stderr', ''))
|
||||
inner.stdout?.write(encodeE2BFrame({ type: 'exit', code: null, signal: 'SIGTERM' }))
|
||||
inner.resolve()
|
||||
|
||||
await expect(transport.done).resolves.toEqual({ exitCode: 0, signal: null })
|
||||
expect(stdout).toEqual(Buffer.from([0, 0xff]))
|
||||
expect(transport.collected.stderr?.readFrom(0).text).toBe('server diagnosticproxy diagnostic')
|
||||
expect(transport.pid).toBe(321)
|
||||
inner.waitResult = false
|
||||
await expect(transport.waitForExit()).resolves.toBe(false)
|
||||
transport.terminate()
|
||||
expect(inner.terminated).toBe(1)
|
||||
})
|
||||
|
||||
it('ends the inner stdin and retains a bounded byte tail with independent offsets', async () => {
|
||||
const inner = new FakeHandle()
|
||||
const transport = new E2BLspTransport(inner, 1_024, 4)
|
||||
const finished = once(inner.stdin!, 'finish')
|
||||
transport.stdin.end()
|
||||
await finished
|
||||
|
||||
inner.stdout?.write(proxyFrame('stderr', 'ab'))
|
||||
inner.stdout?.write(proxyFrame('stderr', 'cdef'))
|
||||
const reader = transport.collected.stderr!
|
||||
expect(reader.readFrom(0)).toEqual({ text: 'cdef', nextOffset: 6, lossy: true })
|
||||
expect(reader.readFrom(2)).toEqual({ text: 'cdef', nextOffset: 6, lossy: false })
|
||||
expect(reader.readFrom(5)).toEqual({ text: 'f', nextOffset: 6, lossy: false })
|
||||
expect(reader.readFrom(99)).toEqual({ text: '', nextOffset: 6, lossy: false })
|
||||
expect(() => reader.readFrom(-1)).toThrow('non-negative safe integer')
|
||||
expect(() => reader.readFrom(1.5)).toThrow('non-negative safe integer')
|
||||
inner.resolve()
|
||||
await transport.done
|
||||
|
||||
const partialInner = new FakeHandle()
|
||||
const partial = new E2BLspTransport(partialInner, 1_024, 4)
|
||||
partialInner.stdout?.write(proxyFrame('stderr', 'abcdef'))
|
||||
expect(partial.collected.stderr?.readFrom(0)).toEqual({ text: 'cdef', nextOffset: 6, lossy: true })
|
||||
partialInner.resolve()
|
||||
await partial.done
|
||||
})
|
||||
|
||||
it.each([
|
||||
['non-object', encodeE2BFrame(null), 'malformed frame'],
|
||||
['wrong type', encodeE2BFrame({ type: 'other', data: '' }), 'malformed frame'],
|
||||
['invalid exit', encodeE2BFrame({ type: 'exit', code: 'zero', signal: null }), 'malformed frame'],
|
||||
['missing data', encodeE2BFrame({ type: 'stdout' }), 'malformed frame'],
|
||||
['invalid base64', encodeE2BFrame({ type: 'stdout', data: 'abc' }), 'invalid base64'],
|
||||
['invalid outer frame', 'not-base64\n', 'invalid base64'],
|
||||
['non-ASCII outer frame', 'é', 'non-ASCII'],
|
||||
])('fails %s proxy traffic and terminates the group', async (_name, frame, message) => {
|
||||
const inner = new FakeHandle()
|
||||
const transport = new E2BLspTransport(inner, 1_024, 32)
|
||||
inner.stdout?.write(frame)
|
||||
inner.stdout?.write(proxyFrame('stdout', 'ignored'))
|
||||
inner.stdout?.emit('error', new Error('second failure'))
|
||||
inner.resolve()
|
||||
await expect(transport.done).rejects.toThrow(message)
|
||||
expect(inner.terminated).toBe(1)
|
||||
})
|
||||
|
||||
it('fails truncated frames and preserves inner spawn rejection', async () => {
|
||||
const truncatedInner = new FakeHandle()
|
||||
const truncated = new E2BLspTransport(truncatedInner, 1_024, 32)
|
||||
truncatedInner.stdout?.write('YQ==')
|
||||
truncatedInner.resolve()
|
||||
await expect(truncated.done).rejects.toThrow('mid-frame')
|
||||
|
||||
const rejectedInner = new FakeHandle({ diagnostic: 'tail' })
|
||||
const rejected = new E2BLspTransport(rejectedInner, 1_024, 32)
|
||||
rejectedInner.reject(new Error('spawn failed'))
|
||||
await expect(rejected.done).rejects.toThrow('spawn failed')
|
||||
expect(rejected.collected.stderr?.readFrom(0).text).toBe('tail')
|
||||
})
|
||||
|
||||
it('forwards output and input stream errors without an unhandled inner error', async () => {
|
||||
const outputInner = new FakeHandle()
|
||||
const output = new E2BLspTransport(outputInner, 1_024, 32)
|
||||
outputInner.stdout?.emit('error', new Error('proxy stdout failed'))
|
||||
outputInner.resolve()
|
||||
await expect(output.done).rejects.toThrow('proxy stdout failed')
|
||||
|
||||
const inputInner = new FakeHandle()
|
||||
const input = new E2BLspTransport(inputInner, 1_024, 32)
|
||||
const outerError = once(input.stdin, 'error')
|
||||
inputInner.stdin?.emit('error', new Error('proxy stdin failed'))
|
||||
await expect(outerError).resolves.toMatchObject([{ message: 'proxy stdin failed' }])
|
||||
inputInner.resolve()
|
||||
await input.done
|
||||
})
|
||||
|
||||
it('normalizes a non-Error decoder throw', async () => {
|
||||
const inner = new FakeHandle()
|
||||
const transport = new E2BLspTransport(inner, 1_024, 32)
|
||||
const internal = transport as unknown as {
|
||||
decoder: { push(chunk: string): unknown[] }
|
||||
onProxyData(chunk: Buffer): void
|
||||
}
|
||||
internal.decoder = { push: () => { throw 'raw decoder failure' } }
|
||||
internal.onProxyData(Buffer.from('x'))
|
||||
inner.resolve()
|
||||
await expect(transport.done).rejects.toThrow('raw decoder failure')
|
||||
})
|
||||
|
||||
it('rejects a subprocess that drops either required pipe', () => {
|
||||
const missingStdin = new FakeHandle({ stdin: false })
|
||||
const missingStdout = new FakeHandle({ stdout: false })
|
||||
expect(() => new E2BLspTransport(missingStdin, 10, 10)).toThrow('dropped a piped stream')
|
||||
expect(() => new E2BLspTransport(missingStdout, 10, 10)).toThrow('dropped a piped stream')
|
||||
expect(missingStdin.terminated).toBe(1)
|
||||
expect(missingStdout.terminated).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{ "path": "../../../vendor/cosmokit" },
|
||||
{ "path": "../../../vendor/cordis" },
|
||||
{ "path": "../../../vendor/schemastery" },
|
||||
{ "path": "../../e2b/e2b" },
|
||||
{ "path": "../lsp" },
|
||||
{ "path": "../lsp-local" },
|
||||
{ "path": "../../subprocess/subprocess" },
|
||||
{ "path": "../../subprocess/subprocess-e2b" },
|
||||
{ "path": "../../util/timeout" },
|
||||
{ "path": "../../support/invariants" }
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user