fix(rebase): migrate the replayed E2B branch onto the rebased parent
The linear replay carried old-lineage content over parent-owned files; this checkpoint restores them and adapts the branch to the parent's post-rebase seam: - restore all pty/lsp/subprocess/code-runtime surfaces to the parent's exact content (this branch claims none of them) and drop the net-zero code-runtime-e2b/pty-e2b/lsp-e2b residue and its registrations - widen serializeRemoteEnvironment to the seam's NodeJS.ProcessEnv tombstone contract: an explicit undefined removes an ambient entry - migrate the two E2B fixture Agent stubs to the Inbox-model interface and Session.create - re-apply the branch's gen-doc-graphs roles, THIRD_PARTY_NOTICES e2b row, and packages/README group row (trimmed to the doc budget); regenerate catalogs and re-record bilingual pairings
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: d8bc6047e7eadeb2993f1114564b41913b2970dc
|
||||
README.zh.md: d838af346626bb96299a3b702df0dabb6231c15a
|
||||
README.md: 7fbdf071735673fb0158f6fa66148be1c644a433
|
||||
README.zh.md: e059dbd80b7e38c0e447e54178162316dfd127c7
|
||||
@@ -7,10 +7,9 @@ The language-server capability seam: an abstract LSP interface, a generic stdio
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
| `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`) |
|
||||
| [`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`) |
|
||||
| `lsp-local/` | Generic multi-server stdio backend over `ctx.fs` and `ctx.subprocess` (JSON-RPC, transient-open queries) | (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 [shared E2B runtime note](../../.agents/notes/implemented/feature/2026-07-27-e2b-remote-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 design rationale, including why documents open transiently per query, why the stdio host consumes the shared filesystem/subprocess execution world, and why extension ownership is exclusive within one runtime.
|
||||
@@ -7,10 +7,9 @@
|
||||
| 包 | 职责 | ctx key |
|
||||
|---|---|---|
|
||||
| `lsp/` | 抽象 LSP seam(按品牌化 id + 扩展名映射组织的提供方注册表、逐查询选择、词汇、`LspError`) | `ctx.lsp` |
|
||||
| [`lsp-local/`](lsp-local/README.md) | 通用多服务器本地后端(spawn、JSON-RPC、临时打开查询) | (在 `ctx.lsp` 上注册提供方) |
|
||||
| [`e2b/lsp-e2b`](../e2b/lsp-e2b/README.md) | 远程 E2B 后端(在远程读取源文件并运行服务器、采用字节分帧的 stdio 桥) | (在 `ctx.lsp` 上注册提供方) |
|
||||
| `lsp-local/` | 基于 `ctx.fs` 与 `ctx.subprocess` 的通用多服务器 stdio 后端(JSON-RPC、临时打开查询) | (在 `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-27-e2b-remote-runtime-poc.md)。
|
||||
设计原理见 [LSP 能力 seam Agent Note](../../.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md),其中也解释了文档为何在每次查询时临时打开、stdio 主机为何使用共享的文件系统/子进程执行环境,以及扩展名归属为何在同一运行时内互斥。
|
||||
@@ -1,19 +1,16 @@
|
||||
/**
|
||||
* Generic stdio language-server backend for `ctx.lsp`. One plugin instance configures a named table
|
||||
* of server commands and registers one isolated provider for each entry. Every provider lazily
|
||||
* single-flights one server process per canonical workspace realpath, serves transient-open queries
|
||||
* single-flights one server process per canonical workspace target, serves transient-open queries
|
||||
* through it, and replaces a selected transport that fails before or during the next read-only
|
||||
* query. Providers read sources through Node APIs in the host namespace (not `ctx.fs`)
|
||||
* and trust their configured servers — no sandbox confinement.
|
||||
* query. Providers read sources through `ctx.fs` and launch servers through
|
||||
* `ctx.subprocess`, so both local and remote implementations share one host.
|
||||
*
|
||||
* Namespace plugin (named exports, no default export). Lifecycle is effect-scoped: disposal
|
||||
* unregisters from `ctx.lsp` and tears down every live server.
|
||||
* @module @deepseek-ai/dsh-lsp-local
|
||||
*/
|
||||
|
||||
import { accessSync, constants, statSync } from 'node:fs'
|
||||
import { delimiter, isAbsolute, join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { LspError, LspProviderId } from '@deepseek-ai/dsh-lsp'
|
||||
@@ -25,9 +22,9 @@ import type {
|
||||
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
|
||||
import { abortable, abortError } from './abort.ts'
|
||||
import { canonicalizeWorkspace, readHostSource } from './host.ts'
|
||||
import type { HostWorkspace } from './host.ts'
|
||||
import { LspInstance } from './instance.ts'
|
||||
import type { ConnectionSpawner } from './connection.ts'
|
||||
import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess'
|
||||
import type { InstanceSpec } from './instance.ts'
|
||||
|
||||
export { canonicalizeWorkspace, readHostSource } from './host.ts'
|
||||
@@ -47,10 +44,7 @@ export { LspConnection } from './connection.ts'
|
||||
export const name = 'lsp-local'
|
||||
|
||||
/** Services required by this plugin. */
|
||||
export const inject = ['lsp', 'subprocess']
|
||||
|
||||
/** Credential-shaped ambient env vars are NOT forwarded to the child by default. */
|
||||
|
||||
export const inject = ['fs', 'lsp', 'subprocess']
|
||||
|
||||
const DEFAULT_MAX_MESSAGE_BYTES = 16_000_000
|
||||
const DEFAULT_MAX_STDERR_BYTES = 1_000_000
|
||||
@@ -92,6 +86,7 @@ export interface Config {
|
||||
|
||||
/** One server config after schemastery fills every default. */
|
||||
type ResolvedServerConfig = Required<LspLocalServerConfig>
|
||||
type WorkspaceKey = HostWorkspace['target']['targetKey']
|
||||
|
||||
const LspLocalServerConfig: z<LspLocalServerConfig> = z.object({
|
||||
command: z.string().required(),
|
||||
@@ -111,27 +106,67 @@ export const Config: z<Config> = z.object({
|
||||
servers: z.dict(LspLocalServerConfig).required(),
|
||||
})
|
||||
|
||||
/** Propagate teardown failures only after every sibling has settled. */
|
||||
function throwTeardownFailures(results: readonly PromiseSettledResult<void>[], message: string): void {
|
||||
const failures: unknown[] = []
|
||||
for (const result of results) {
|
||||
if (result.status === 'rejected') failures.push(result.reason)
|
||||
}
|
||||
if (failures.length === 1) throw failures[0]
|
||||
if (failures.length > 1) throw new AggregateError(failures, message)
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the configured stdio LSP providers. Resolves every executable at load (after credential
|
||||
* scrubbing) before publishing any provider; each process launches lazily on its first matching
|
||||
* query.
|
||||
* @param ctx - the plugin context (must inject `lsp`).
|
||||
* @param ctx - the plugin context carrying `fs`, `lsp`, and `subprocess`.
|
||||
* @param config - the resolved plugin configuration (schemastery has filled every default).
|
||||
*/
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
export async function apply(ctx: Context, config: Config): Promise<void> {
|
||||
const entries = Object.entries(config.servers)
|
||||
if (entries.length === 0) throw new Error('lsp-local: servers must contain at least one server')
|
||||
|
||||
const setupAbort = new AbortController()
|
||||
const stopSetupCancellation = ctx.on('internal/plugin', (fiber) => {
|
||||
// An async plugin callback must observe its own disposal before Cordis can
|
||||
// run effect cleanup, because unload otherwise waits for this callback.
|
||||
if (fiber === ctx.fiber && fiber.uid === null) {
|
||||
setupAbort.abort(new Error('lsp-local setup disposed'))
|
||||
}
|
||||
})
|
||||
|
||||
// Resolve every server-local setting before registration so a bad later command or bound cannot
|
||||
// publish an earlier provider. Registry-level mapping conflicts are rolled back below.
|
||||
const providers = entries.map(([providerId, rawConfig]) => {
|
||||
if (providerId.trim() === '') throw new Error('lsp-local: server ids must be non-empty strings')
|
||||
const resolved = rawConfig as ResolvedServerConfig
|
||||
validateServerConfig(providerId, resolved)
|
||||
const childEnv = buildChildEnv(resolved.env)
|
||||
const executable = resolveExecutable(resolved.command, childEnv)
|
||||
return new LocalLspProvider(providerId, resolved, childEnv, executable, spec => ctx.subprocess.spawn(spec))
|
||||
})
|
||||
const providers = await (async () => {
|
||||
const lookups = entries.map(async ([providerId, rawConfig]) => {
|
||||
if (providerId.trim() === '') throw new Error('lsp-local: server ids must be non-empty strings')
|
||||
const resolved = rawConfig as ResolvedServerConfig
|
||||
validateServerConfig(providerId, resolved)
|
||||
const executable = await ctx.subprocess.resolveExecutable(
|
||||
resolved.command,
|
||||
resolved.env,
|
||||
setupAbort.signal,
|
||||
)
|
||||
setupAbort.signal.throwIfAborted()
|
||||
return new LocalLspProvider(
|
||||
providerId,
|
||||
ctx.fs,
|
||||
resolved,
|
||||
executable,
|
||||
spec => ctx.subprocess.spawn(spec),
|
||||
)
|
||||
})
|
||||
try {
|
||||
return await Promise.all(lookups)
|
||||
} catch (error: unknown) {
|
||||
setupAbort.abort(error)
|
||||
await Promise.allSettled(lookups)
|
||||
throw error
|
||||
} finally {
|
||||
stopSetupCancellation()
|
||||
}
|
||||
})()
|
||||
|
||||
ctx.effect(() => {
|
||||
const disposers: Array<() => void> = []
|
||||
@@ -144,7 +179,8 @@ export function apply(ctx: Context, config: Config): void {
|
||||
return async () => {
|
||||
// Remove every route before process teardown so no new query can enter a draining provider.
|
||||
for (const dispose of disposers.reverse()) dispose()
|
||||
await Promise.all(providers.map(provider => provider.disposeAll()))
|
||||
const results = await Promise.allSettled(providers.map(provider => provider.disposeAll()))
|
||||
throwTeardownFailures(results, 'lsp-local provider teardown failed')
|
||||
}
|
||||
}, 'lsp-local.registerProviders')
|
||||
}
|
||||
@@ -181,16 +217,19 @@ function assertPositiveInteger(providerId: string, name: string, value: number):
|
||||
class LocalLspProvider implements LspProvider {
|
||||
readonly id: LspProviderId
|
||||
readonly extensionToLanguage: Readonly<Record<string, string>>
|
||||
/** One live instance per canonical workspace realpath. */
|
||||
private readonly instances = new Map<string, LspInstance>()
|
||||
/** One live instance per stable canonical workspace identity. */
|
||||
private readonly instances = new Map<WorkspaceKey, LspInstance>()
|
||||
/** One complete source-read→open→query→close serialization tail per canonical workspace. */
|
||||
private readonly queues = new Map<string, Promise<void>>()
|
||||
private readonly queues = new Map<WorkspaceKey, Promise<void>>()
|
||||
/** Workspace canonicalizations that have not entered a provider-owned queue yet. */
|
||||
private readonly workspaceLookups = new Set<Promise<void>>()
|
||||
private readonly lifetime = new AbortController()
|
||||
private disposed = false
|
||||
|
||||
constructor(
|
||||
providerId: string,
|
||||
private readonly fs: Context['fs'],
|
||||
private readonly config: ResolvedServerConfig,
|
||||
private readonly childEnv: Record<string, string>,
|
||||
private readonly executable: string,
|
||||
private readonly spawner: ConnectionSpawner,
|
||||
) {
|
||||
@@ -211,43 +250,60 @@ class LocalLspProvider implements LspProvider {
|
||||
if (signal?.aborted) throw abortError(signal)
|
||||
}
|
||||
|
||||
/** Fuse caller cancellation with provider disposal for every filesystem and protocol await. */
|
||||
private querySignal(signal?: AbortSignal): AbortSignal {
|
||||
return signal === undefined
|
||||
? this.lifetime.signal
|
||||
: AbortSignal.any([signal, this.lifetime.signal])
|
||||
}
|
||||
|
||||
async query(request: LspProviderQuery, signal?: AbortSignal): Promise<LspQueryResult> {
|
||||
// Honor an already-aborted signal before host I/O so a canceled request never starts a server.
|
||||
// Honor an already-aborted signal before provider I/O so a canceled request never starts a server.
|
||||
this.assertActive(signal)
|
||||
const workspace = await canonicalizeWorkspace(request.workspaceRoot, signal)
|
||||
this.assertActive(signal)
|
||||
return this.enqueue(workspace, signal, async () => {
|
||||
this.assertActive(signal)
|
||||
const querySignal = this.querySignal(signal)
|
||||
const workspaceResult = canonicalizeWorkspace(this.fs, request.workspaceRoot, querySignal)
|
||||
const workspaceLookup = workspaceResult.then(() => undefined, () => undefined)
|
||||
this.workspaceLookups.add(workspaceLookup)
|
||||
let workspace: HostWorkspace
|
||||
try {
|
||||
workspace = await workspaceResult
|
||||
} finally {
|
||||
this.workspaceLookups.delete(workspaceLookup)
|
||||
}
|
||||
this.assertActive(querySignal)
|
||||
const workspaceKey = workspace.target.targetKey
|
||||
return this.enqueue(workspaceKey, querySignal, async () => {
|
||||
this.assertActive(querySignal)
|
||||
// Read inside the workspace queue but before spawning: a queued query sees current bytes when
|
||||
// its turn starts, while an invalid source still cannot leave an idle process pooled.
|
||||
const source = await readHostSource(request.filePath, workspace, this.config.maxDocumentBytes, signal)
|
||||
const source = await readHostSource(this.fs, request.filePath, workspace, this.config.maxDocumentBytes, querySignal)
|
||||
// Disposal may have snapshotted the instance map while host I/O was pending. Re-check before a
|
||||
// synchronous get-or-create so every spawned process remains owned by teardown.
|
||||
this.assertActive(signal)
|
||||
let instance = this.instanceFor(workspace)
|
||||
this.assertActive(querySignal)
|
||||
let instance = this.instanceFor(workspaceKey, workspace)
|
||||
try {
|
||||
return await instance.query(request, source, signal)
|
||||
return await instance.query(request, source, querySignal)
|
||||
} catch (error) {
|
||||
// A selected child can have died while idle or fail during the next write. Queries are
|
||||
// read-only, so replace that transport once and retry transparently.
|
||||
if (!instance.isTransportFailure(error)) throw error
|
||||
await instance.dispose()
|
||||
this.evictIfCurrent(workspace, instance)
|
||||
this.assertActive(signal)
|
||||
instance = this.instanceFor(workspace)
|
||||
return await instance.query(request, source, signal)
|
||||
this.evictIfCurrent(workspaceKey, instance)
|
||||
this.assertActive(querySignal)
|
||||
instance = this.instanceFor(workspaceKey, workspace)
|
||||
return await instance.query(request, source, querySignal)
|
||||
} finally {
|
||||
// Reach quiescence before dropping a dead slot; a replacement must survive this ownership check.
|
||||
if (instance.dead) {
|
||||
await instance.dispose()
|
||||
this.evictIfCurrent(workspace, instance)
|
||||
this.evictIfCurrent(workspaceKey, instance)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Serialize one complete query lifecycle for a canonical workspace. */
|
||||
private enqueue<T>(workspace: string, signal: AbortSignal | undefined, run: () => Promise<T>): Promise<T> {
|
||||
private enqueue<T>(workspace: WorkspaceKey, signal: AbortSignal | undefined, run: () => Promise<T>): Promise<T> {
|
||||
const previous = this.queues.get(workspace) ?? Promise.resolve()
|
||||
const result = abortable(previous, signal).then(run)
|
||||
// The tail follows the actual prior work even when this caller aborts its wait. It never rejects,
|
||||
@@ -261,34 +317,34 @@ class LocalLspProvider implements LspProvider {
|
||||
}
|
||||
|
||||
/** Return or synchronously publish the one instance for a canonical workspace. */
|
||||
private instanceFor(workspace: string): LspInstance {
|
||||
private instanceFor(workspaceKey: WorkspaceKey, workspace: HostWorkspace): LspInstance {
|
||||
this.assertActive()
|
||||
const existing = this.instances.get(workspace)
|
||||
const existing = this.instances.get(workspaceKey)
|
||||
if (existing !== undefined) return existing
|
||||
const created = this.createInstance(workspace)
|
||||
this.instances.set(workspace, created)
|
||||
this.instances.set(workspaceKey, created)
|
||||
return created
|
||||
}
|
||||
|
||||
/** Drop the slot iff it still contains this instance. */
|
||||
private evictIfCurrent(workspace: string, instance: LspInstance): void {
|
||||
private evictIfCurrent(workspace: WorkspaceKey, instance: LspInstance): void {
|
||||
/* v8 ignore next -- mismatch requires another query to replace the slot before this finally runs. */
|
||||
if (this.instances.get(workspace) === instance) this.instances.delete(workspace)
|
||||
}
|
||||
|
||||
private createInstance(workspace: string): LspInstance {
|
||||
private createInstance(workspace: HostWorkspace): LspInstance {
|
||||
const spec: InstanceSpec = {
|
||||
command: this.executable,
|
||||
args: this.config.args,
|
||||
cwd: workspace,
|
||||
env: this.childEnv,
|
||||
cwd: workspace.canonicalPath,
|
||||
workspaceUri: workspace.fileUrl,
|
||||
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,
|
||||
pathToFileUri: path => pathToFileURL(path).href,
|
||||
}
|
||||
return new LspInstance(spec, this.spawner)
|
||||
}
|
||||
@@ -296,51 +352,18 @@ class LocalLspProvider implements LspProvider {
|
||||
/** Dispose every live instance and block further queries. */
|
||||
async disposeAll(): Promise<void> {
|
||||
this.disposed = true
|
||||
this.lifetime.abort(new LspError('lsp-local provider is disposed', 'LSP_DISPOSED'))
|
||||
const live = [...this.instances.values()]
|
||||
const draining = [...this.queues.values()]
|
||||
const resolving = [...this.workspaceLookups]
|
||||
this.instances.clear()
|
||||
await Promise.all([
|
||||
const results = await Promise.allSettled([
|
||||
...live.map(instance => instance.dispose()),
|
||||
...draining,
|
||||
...resolving,
|
||||
])
|
||||
this.queues.clear()
|
||||
}
|
||||
}
|
||||
|
||||
/** The seam's scrubbed parent env (credential-shaped and DSH_* names dropped), plus the config's explicit env. */
|
||||
function buildChildEnv(extra: Record<string, string>): Record<string, string> {
|
||||
return { ...scrubbedParentEnv(), ...extra }
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the server executable to an absolute path: an absolute command is verified directly; a
|
||||
* bare command is looked up on the child's PATH. Fails loudly when nothing is executable.
|
||||
*/
|
||||
function resolveExecutable(command: string, childEnv: Record<string, string>): string {
|
||||
if (isAbsolute(command)) {
|
||||
// Verify an absolute command too, so an unavailable one fails at load, not on the first query.
|
||||
if (!isExecutableFileSync(command)) {
|
||||
throw new Error(`lsp-local: command "${command}" is not an executable file`)
|
||||
}
|
||||
return command
|
||||
}
|
||||
/* v8 ignore next -- buildChildEnv always sets PATH from the ambient env; the further fallbacks are defensive. */
|
||||
const pathValue = childEnv.PATH ?? process.env.PATH ?? ''
|
||||
for (const dir of pathValue.split(delimiter)) {
|
||||
if (dir === '') continue
|
||||
const candidate = join(dir, command)
|
||||
if (isExecutableFileSync(candidate)) return candidate
|
||||
}
|
||||
throw new Error(`lsp-local: command "${command}" was not found on PATH`)
|
||||
}
|
||||
|
||||
/** Synchronous regular-file and executable check used only at load-time resolution. */
|
||||
function isExecutableFileSync(path: string): boolean {
|
||||
try {
|
||||
if (!statSync(path).isFile()) return false
|
||||
accessSync(path, constants.X_OK)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
this.workspaceLookups.clear()
|
||||
throwTeardownFailures(results, 'lsp-local instance teardown failed')
|
||||
}
|
||||
}
|
||||
@@ -30,18 +30,12 @@ import {
|
||||
|
||||
/** Everything an instance needs beyond the connection spec. */
|
||||
export interface InstanceSpec extends ConnectionSpec {
|
||||
/** Canonical workspace file URI supplied by the filesystem provider. */
|
||||
readonly workspaceUri: string
|
||||
/** Static `initialize` options forwarded to the server. */
|
||||
readonly initializationOptions: unknown
|
||||
/** Graceful `shutdown`/`exit` budget before escalation (ms). */
|
||||
readonly shutdownTimeoutMs: number
|
||||
/** PID advertised to the server; `null` when client and server do not share a process namespace. */
|
||||
readonly clientProcessId?: number | null
|
||||
/**
|
||||
* Encode one implementation-native absolute path as a file URI.
|
||||
* @param path - Canonical workspace or source path.
|
||||
* @returns A file URI interpreted in the server's filesystem namespace.
|
||||
*/
|
||||
readonly pathToFileUri: (path: string) => string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -115,9 +109,11 @@ export class LspInstance {
|
||||
|
||||
private async initialize(): Promise<void> {
|
||||
const initializeResult = await this.connection.request('initialize', {
|
||||
processId: this.spec.clientProcessId === undefined ? process.pid : this.spec.clientProcessId,
|
||||
rootUri: this.spec.pathToFileUri(this.spec.cwd),
|
||||
workspaceFolders: [{ uri: this.spec.pathToFileUri(this.spec.cwd), name: 'workspace' }],
|
||||
// A subprocess provider may run in another PID namespace or machine;
|
||||
// the host PID would let the server monitor an unrelated process.
|
||||
processId: null,
|
||||
rootUri: this.spec.workspaceUri,
|
||||
workspaceFolders: [{ uri: this.spec.workspaceUri, name: 'workspace' }],
|
||||
capabilities: CLIENT_CAPABILITIES,
|
||||
initializationOptions: this.spec.initializationOptions,
|
||||
}) as WireInitializeResult
|
||||
@@ -154,7 +150,7 @@ export class LspInstance {
|
||||
throw new LspError('server does not support the transient textDocument/didOpen this host requires', 'LSP_UNSUPPORTED_OPERATION')
|
||||
}
|
||||
|
||||
const uri = this.spec.pathToFileUri(source.canonicalPath)
|
||||
const uri = source.fileUrl
|
||||
let opened = false
|
||||
try {
|
||||
/* v8 ignore next -- guards an abort landing between the ready wait and didOpen; not deterministically reproducible. */
|
||||
@@ -248,10 +244,9 @@ export class LspInstance {
|
||||
if (operation === 'hover') {
|
||||
return { kind: 'hover', hover: normalizeHover(payload) }
|
||||
}
|
||||
// `spec.cwd` is the canonical workspace realpath (the provider canonicalizes before spawning),
|
||||
// and every `file:` location URI is relative to it — so it is the root a caller must relativize
|
||||
// display paths against, not the request's possibly-symlinked workspaceRoot.
|
||||
return { kind: 'locations', locations: normalizeLocations(payload), resolvedWorkspaceRoot: this.spec.cwd }
|
||||
// The filesystem provider owns URI syntax for the execution platform, which may differ from the
|
||||
// harness host. Preserve that coordinate through rendering instead of reparsing `spec.cwd` there.
|
||||
return { kind: 'locations', locations: normalizeLocations(payload), resolvedWorkspaceUri: this.spec.workspaceUri }
|
||||
}
|
||||
|
||||
private answerServerRequest(method: string, params: unknown): Promise<unknown> {
|
||||
|
||||
@@ -56,7 +56,6 @@ function makeInstance(
|
||||
maxStderrBytes: 100_000,
|
||||
shutdownTimeoutMs: 200,
|
||||
killGraceMs: 200,
|
||||
pathToFileUri: path => pathToFileURL(path).href,
|
||||
...overrides,
|
||||
}, spawnSubprocess, writer)
|
||||
live.push(instance)
|
||||
@@ -92,7 +91,6 @@ function scriptInstance(script: string, overrides: Partial<InstanceSpec> = {}):
|
||||
maxStderrBytes: 100_000,
|
||||
shutdownTimeoutMs: 150,
|
||||
killGraceMs: 150,
|
||||
pathToFileUri: path => pathToFileURL(path).href,
|
||||
...overrides,
|
||||
}, spawnSubprocess)
|
||||
live.push(instance)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 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
|
||||
README.md: f96fc67ec8cb95f423eff9b312b7b591ec9d3008
|
||||
README.zh.md: 13ae9700e284ff238147538a571622066efc5747
|
||||
# pnpm run verify-translation-pairing --write packages/lsp/lsp/README.md
|
||||
README.md: 5c1044be50368acf13d8c36a15d5b2bd99d02701
|
||||
README.zh.md: cc412333e63b9469319240d67269bf0192ad3858
|
||||
Reference in New Issue
Block a user