Merge branch 'codex/tool-json-schema-dsl' into codex/canonical-tool-output
This commit is contained in:
@@ -7,9 +7,10 @@ Namespace plugin (`name` / `inject` / `Config` / `apply`, no default export).
|
||||
## What it does
|
||||
|
||||
- Resolves every server-local setting before registration; an invalid mapping or registration conflict rolls back earlier entries, so a failed load leaves no provider routes.
|
||||
- Lazily single-flights one server process per `(server id, canonical workspace realpath)`. A crash fails the active query without replay; a later query may replace the process.
|
||||
- Lazily single-flights one server process per `(server id, canonical workspace realpath)`. A live server error is not replayed; if the selected pooled transport fails before or during a read-only query, the provider awaits its disposal and retries that query once on a fresh process.
|
||||
- Uses a compatibility-first **transient-open** sequence per query: canonicalize and read the source with Node APIs, `textDocument/didOpen` (version 1, full text), the requested request, then `textDocument/didClose` in `finally`. A failed or canceled `didOpen` write terminates the instance before the pool can reuse it. Documents close after each call, so the first version needs no `didChange`, content cache, or document LRU.
|
||||
- Serializes each source-read/open/query/close lifecycle through one abortable per-workspace queue so queued calls read current source only when their turn starts; distinct workspaces run in parallel.
|
||||
- After protocol shutdown fails, terminates the server's descendant tree through POSIX process-group signaling or synchronous Windows `taskkill /T /F`. Windows suppresses only taskkill's already-absent-tree result; command, permission, and other tree-kill failures remain visible.
|
||||
- Reads sources through Node filesystem APIs in the subprocess's host namespace — NOT `ctx.fs`, and emits no `fs/observed`: only the LSP result is model-visible, so a query does not satisfy read-before-write policy.
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
|
||||
import type { ChildProcessByStdio } from 'node:child_process'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { spawn, spawnSync } from 'node:child_process'
|
||||
import type { Readable, Writable } from 'node:stream'
|
||||
import { setImmediate as yieldToEventLoop } from 'node:timers/promises'
|
||||
import { encodeMessage, MessageDecoder } from './framing.ts'
|
||||
@@ -36,6 +36,132 @@ interface Pending {
|
||||
reject: (error: Error) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Write one JSON-RPC message to the child stdin.
|
||||
* @param stdin - the spawned server stdin.
|
||||
* @param message - the unencoded JSON-RPC message.
|
||||
* @param done - callback that reports asynchronous stream settlement.
|
||||
*/
|
||||
export type ConnectionWriter = (
|
||||
stdin: Writable,
|
||||
message: unknown,
|
||||
done: (error?: Error | null) => void,
|
||||
) => void
|
||||
|
||||
/** Host operations used to signal a detached process tree. */
|
||||
export interface ProcessTreeOperations {
|
||||
/** Signal a POSIX process group. */
|
||||
readonly signal: (target: number, signal: NodeJS.Signals) => void
|
||||
/** Signal the direct child when POSIX group signaling is unavailable. */
|
||||
readonly killChild: (signal: NodeJS.Signals) => void
|
||||
/** Terminate a Windows process tree by root pid. */
|
||||
readonly taskkill: (pid: number) => void
|
||||
}
|
||||
|
||||
/** Narrow taskkill runner result used by the Windows process-tree adapter. */
|
||||
export interface TaskkillResult {
|
||||
/** Process exit status, or null when spawning failed. */
|
||||
readonly status: number | null
|
||||
/** Spawn failure, when the executable could not run. */
|
||||
readonly error?: Error
|
||||
}
|
||||
|
||||
/** Invoke a command synchronously for the Windows taskkill adapter. */
|
||||
export type TaskkillRunner = (
|
||||
command: string,
|
||||
args: string[],
|
||||
options: { stdio: 'ignore' },
|
||||
) => TaskkillResult
|
||||
|
||||
/** Invoke the host process-signal primitive for a POSIX process group. */
|
||||
export type ProcessSignalRunner = (target: number, signal: NodeJS.Signals) => boolean
|
||||
|
||||
const processSignalRunner: ProcessSignalRunner = process.kill.bind(process)
|
||||
|
||||
/** taskkill status for "process not found": the requested process tree is already absent. */
|
||||
const TASKKILL_TREE_NOT_FOUND_STATUS = 128
|
||||
|
||||
const writeConnectionMessage: ConnectionWriter = (stdin, message, done) => {
|
||||
stdin.write(encodeMessage(message), done)
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminate one Windows process tree and wait for taskkill to finish.
|
||||
* @param pid - root process id.
|
||||
* @param run - command runner; tests inject results without requiring Windows.
|
||||
*/
|
||||
export function taskkillProcessTree(
|
||||
pid: number,
|
||||
run: TaskkillRunner = spawnSync,
|
||||
): void {
|
||||
const result = run('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' })
|
||||
if (result.error !== undefined) throw result.error
|
||||
if (result.status === TASKKILL_TREE_NOT_FOUND_STATUS) return
|
||||
if (result.status !== 0) throw new Error(`taskkill exited with status ${String(result.status)}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Signal one POSIX process group through an injectable host primitive.
|
||||
* @param target - negative process-group id.
|
||||
* @param signal - requested signal.
|
||||
* @param run - host signal runner; tests inject it without touching real processes.
|
||||
*/
|
||||
export function signalProcessGroup(
|
||||
target: number,
|
||||
signal: NodeJS.Signals,
|
||||
run: ProcessSignalRunner = processSignalRunner,
|
||||
): void {
|
||||
run(target, signal)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait until a process-tree liveness probe reports exit.
|
||||
* @param isAlive - process-tree liveness probe.
|
||||
* @param signal - optional bound for the wait.
|
||||
* @param yieldNow - event-loop yield primitive.
|
||||
* @returns `true` when the tree exited, or `false` when the signal aborted first.
|
||||
*/
|
||||
export async function waitForTreeExit(
|
||||
isAlive: () => boolean,
|
||||
signal?: AbortSignal,
|
||||
yieldNow: () => Promise<unknown> = yieldToEventLoop,
|
||||
): Promise<boolean> {
|
||||
while (isAlive()) {
|
||||
if (signal?.aborted) return false
|
||||
await yieldNow()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Signal a detached process tree with platform-correct semantics. POSIX falls back to the direct
|
||||
* child; Windows requires taskkill to reach the full tree.
|
||||
* @param platform - host platform.
|
||||
* @param pid - detached root process id.
|
||||
* @param signal - requested termination signal.
|
||||
* @param operations - host operations.
|
||||
*/
|
||||
export function signalProcessTree(
|
||||
platform: NodeJS.Platform,
|
||||
pid: number,
|
||||
signal: NodeJS.Signals,
|
||||
operations: ProcessTreeOperations,
|
||||
): void {
|
||||
if (platform === 'win32') {
|
||||
operations.taskkill(pid)
|
||||
return
|
||||
}
|
||||
try {
|
||||
operations.signal(-pid, signal)
|
||||
} catch {
|
||||
try {
|
||||
operations.killChild(signal)
|
||||
} catch {
|
||||
// The direct child already exited; teardown remains idempotent.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A live JSON-RPC endpoint bound to one child process. */
|
||||
export class LspConnection {
|
||||
private readonly child: ChildProcessByStdio<Writable, Readable, Readable>
|
||||
@@ -50,14 +176,16 @@ export class LspConnection {
|
||||
/**
|
||||
* @param spec - how to launch the server and answer its config requests.
|
||||
* @param onServerRequest - answers a server→client request; rejects to send an error response.
|
||||
* @param writer - message writer; tests inject callback failures without relying on OS pipe races.
|
||||
*/
|
||||
constructor(
|
||||
private readonly spec: ConnectionSpec,
|
||||
private readonly onServerRequest: (method: string, params: unknown) => Promise<unknown>,
|
||||
private readonly writer: ConnectionWriter = writeConnectionMessage,
|
||||
) {
|
||||
this.decoder = new MessageDecoder(spec.maxMessageBytes)
|
||||
// `detached` puts the server in its own process group so teardown can signal the WHOLE group
|
||||
// (via `process.kill(-pid)`), reaching helper processes a language server spawns (e.g. tsserver).
|
||||
// `detached` gives teardown a process-tree root: POSIX signals its negative process-group id,
|
||||
// while Windows passes the root pid to taskkill /T so helpers such as tsserver cannot outlive it.
|
||||
this.child = spawn(spec.command, [...spec.args], {
|
||||
cwd: spec.cwd,
|
||||
env: spec.env,
|
||||
@@ -94,6 +222,20 @@ export class LspConnection {
|
||||
return this.stderr.toString('utf8')
|
||||
}
|
||||
|
||||
/** Whether the transport has failed even if the child close event has not arrived yet. */
|
||||
get failed(): boolean {
|
||||
return this.closeReason !== undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Test whether a caught error is this connection's retained fatal transport cause.
|
||||
* @param error - error caught by the instance or provider.
|
||||
* @returns `true` only when this connection produced that exact failure.
|
||||
*/
|
||||
failedWith(error: unknown): boolean {
|
||||
return this.closeReason === error
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a request and await its result.
|
||||
* @param method - the JSON-RPC method.
|
||||
@@ -147,50 +289,38 @@ export class LspConnection {
|
||||
return this.nextId
|
||||
}
|
||||
|
||||
/** Send SIGTERM to the server's process group (idempotent-safe; a dead group ignores it). */
|
||||
/** Request termination of the server's process tree. */
|
||||
terminate(): void {
|
||||
this.signalGroup('SIGTERM')
|
||||
this.signalTree('SIGTERM')
|
||||
}
|
||||
|
||||
/** Send SIGKILL to the server's process group. */
|
||||
/** Force termination of the server's process tree. */
|
||||
kill(): void {
|
||||
this.signalGroup('SIGKILL')
|
||||
this.signalTree('SIGKILL')
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait until the owned process group has no members.
|
||||
* Wait until the owned process tree has exited.
|
||||
* @param signal - optional bound for the wait.
|
||||
* @returns `true` when the group exited, or `false` when the signal aborted first.
|
||||
* @returns `true` when the tree exited, or `false` when the signal aborted first.
|
||||
*/
|
||||
async waitForProcessGroupExit(signal?: AbortSignal): Promise<boolean> {
|
||||
while (this.processGroupAlive()) {
|
||||
if (signal?.aborted) return false
|
||||
await yieldToEventLoop()
|
||||
}
|
||||
return true
|
||||
async waitForProcessTreeExit(signal?: AbortSignal): Promise<boolean> {
|
||||
return await waitForTreeExit(this.processTreeAlive.bind(this), signal)
|
||||
}
|
||||
|
||||
/**
|
||||
* Signal the whole process group (negative pid) so helper processes are reached; fall back to the
|
||||
* direct child if the group send fails. Never throws — teardown races process exit.
|
||||
*/
|
||||
private signalGroup(sig: NodeJS.Signals): void {
|
||||
/** Signal the whole process tree. */
|
||||
private signalTree(sig: NodeJS.Signals): void {
|
||||
const pid = this.child.pid
|
||||
if (pid === undefined) return
|
||||
try {
|
||||
process.kill(-pid, sig)
|
||||
} catch {
|
||||
// The group is gone (already exited) or could not be signalled; try the direct child.
|
||||
try {
|
||||
this.child.kill(sig)
|
||||
} catch {
|
||||
// Already dead; nothing to signal.
|
||||
}
|
||||
}
|
||||
signalProcessTree(process.platform, pid, sig, {
|
||||
signal: signalProcessGroup,
|
||||
killChild: this.child.kill.bind(this.child),
|
||||
taskkill: taskkillProcessTree,
|
||||
})
|
||||
}
|
||||
|
||||
/** Whether the detached process group still has at least one member. */
|
||||
private processGroupAlive(): boolean {
|
||||
/** Whether the detached tree's root or POSIX process group is still alive. */
|
||||
private processTreeAlive(): boolean {
|
||||
const pid = this.child.pid
|
||||
/* v8 ignore next -- only an asynchronous spawn failure omits pid; its close path owns cleanup. */
|
||||
if (pid === undefined) return false
|
||||
@@ -218,7 +348,7 @@ export class LspConnection {
|
||||
// A framing/JSON failure corrupts the stream position irrecoverably: fail the instance and
|
||||
// SIGKILL the whole group so helper processes don't outlive the leader.
|
||||
this.fail(asError(error))
|
||||
this.signalGroup('SIGKILL')
|
||||
this.signalTree('SIGKILL')
|
||||
return
|
||||
}
|
||||
for (const message of messages) this.dispatch(message)
|
||||
@@ -293,7 +423,7 @@ export class LspConnection {
|
||||
reject(error)
|
||||
}
|
||||
try {
|
||||
this.child.stdin.write(encodeMessage(message), done)
|
||||
this.writer(this.child.stdin, message, done)
|
||||
/* v8 ignore start -- Node stream write failures are callback-delivered; this guards a
|
||||
nonconforming Writable implementation throwing synchronously. */
|
||||
} catch (error) {
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
* 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
|
||||
* through it, and evicts a crashed process so a later query can replace it. Providers read sources
|
||||
* through Node APIs in the host namespace (not `ctx.fs`) and trust their configured servers — no
|
||||
* sandbox confinement.
|
||||
* 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.
|
||||
*
|
||||
* Namespace plugin (named exports, no default export). Lifecycle is effect-scoped: disposal
|
||||
* unregisters from `ctx.lsp` and tears down every live server.
|
||||
@@ -221,15 +221,23 @@ class LocalLspProvider implements LspProvider {
|
||||
// synchronous get-or-create so every spawned process remains owned by teardown.
|
||||
this.assertActive(signal)
|
||||
let instance = this.instanceFor(workspace)
|
||||
if (instance.dead) {
|
||||
this.evictIfCurrent(workspace, instance)
|
||||
instance = this.instanceFor(workspace)
|
||||
}
|
||||
try {
|
||||
return await instance.query(request, source, signal)
|
||||
} 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)
|
||||
} finally {
|
||||
// Drop a crashed slot only when it still owns this instance; a replacement must survive.
|
||||
if (instance.dead) this.evictIfCurrent(workspace, instance)
|
||||
// Reach quiescence before dropping a dead slot; a replacement must survive this ownership check.
|
||||
if (instance.dead) {
|
||||
await instance.dispose()
|
||||
this.evictIfCurrent(workspace, instance)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import type {
|
||||
import { deadline } from '@deepseek-ai/dsh-timeout'
|
||||
import { abortable, abortError } from './abort.ts'
|
||||
import { LspConnection } from './connection.ts'
|
||||
import type { ConnectionSpec } from './connection.ts'
|
||||
import type { ConnectionSpec, ConnectionWriter } from './connection.ts'
|
||||
import type { HostSource } from './host.ts'
|
||||
import type { WireInitializeResult, WireServerCapabilities } from './protocol.ts'
|
||||
import {
|
||||
@@ -39,6 +39,15 @@ export interface InstanceSpec extends ConnectionSpec {
|
||||
readonly killGraceMs: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Force-kill a process tree only when graceful termination did not make it exit.
|
||||
* @param treeExited - whether the tree exited within its grace period.
|
||||
* @param forceKill - forceful process-tree termination primitive.
|
||||
*/
|
||||
export function escalateProcessTree(treeExited: boolean, forceKill: () => void): void {
|
||||
if (!treeExited) forceKill()
|
||||
}
|
||||
|
||||
/**
|
||||
* A single initialized server process. Not exported as a provider — the provider single-flights and
|
||||
* pools these. `query()` serializes; `dispose()` rejects queued work and tears the process down.
|
||||
@@ -58,9 +67,10 @@ export class LspInstance {
|
||||
|
||||
/**
|
||||
* @param spec - the launch, initialize, and teardown parameters.
|
||||
* @param writer - optional connection writer used by transport conformance tests.
|
||||
*/
|
||||
constructor(private readonly spec: InstanceSpec) {
|
||||
this.connection = new LspConnection(spec, (method, params) => this.answerServerRequest(method, params))
|
||||
constructor(private readonly spec: InstanceSpec, writer?: ConnectionWriter) {
|
||||
this.connection = new LspConnection(spec, (method, params) => this.answerServerRequest(method, params), writer)
|
||||
this.ready = this.initialize()
|
||||
// A handshake rejection must not surface as an unhandled rejection before the first query awaits
|
||||
// it; queries attach the real handler.
|
||||
@@ -70,7 +80,16 @@ export class LspInstance {
|
||||
|
||||
/** Synchronous liveness check: true once the process has closed or the instance was disposed. */
|
||||
get dead(): boolean {
|
||||
return this.processClosed || this.disposed
|
||||
return this.processClosed || this.disposed || this.connection.failed
|
||||
}
|
||||
|
||||
/**
|
||||
* Test whether a caught query error came from this instance's transport.
|
||||
* @param error - error caught by the provider.
|
||||
* @returns `true` only for the connection's retained fatal transport cause.
|
||||
*/
|
||||
isTransportFailure(error: unknown): boolean {
|
||||
return this.connection.failedWith(error)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -84,7 +103,12 @@ export class LspInstance {
|
||||
// Serialize behind prior work, but observe abort DURING the queue wait too: if an earlier query
|
||||
// hangs (e.g. a signal-less seam caller), a later tool's timeout must still be able to give up
|
||||
// rather than block on the shared tail forever.
|
||||
const run = abortable(this.queue, signal).then(() => this.runQuery(request, source, signal))
|
||||
const run = abortable(this.queue, signal)
|
||||
.then(() => this.runQuery(request, source, signal))
|
||||
.catch(async (error: unknown) => {
|
||||
if (this.isTransportFailure(error)) await this.startTeardown()
|
||||
throw error
|
||||
})
|
||||
// Keep the tail alive regardless of this query's outcome so the next caller still serializes. The
|
||||
// tail follows the ACTUAL prior work (this.queue), not the abortable view, so a caller giving up
|
||||
// on the wait does not deserialize the queue.
|
||||
@@ -272,7 +296,7 @@ export class LspInstance {
|
||||
try {
|
||||
await this.gracefulShutdown(shutdownDeadline.signal)
|
||||
} catch {
|
||||
// Graceful shutdown failed or timed out; process-group cleanup below remains authoritative.
|
||||
// Graceful shutdown failed or timed out; process-tree cleanup below remains authoritative.
|
||||
} finally {
|
||||
shutdownDeadline[Symbol.dispose]()
|
||||
}
|
||||
@@ -286,20 +310,20 @@ export class LspInstance {
|
||||
await abortable(this.connection.closed, signal)
|
||||
}
|
||||
|
||||
/** SIGTERM the group, escalate after `killGraceMs`, then await leader and helper exit. */
|
||||
/** Terminate the tree, escalate after `killGraceMs`, then await leader and helper exit. */
|
||||
private async forceTerminate(): Promise<void> {
|
||||
this.connection.terminate()
|
||||
const graceDeadline = deadline(undefined, this.spec.killGraceMs, 'LSP_KILL_GRACE')
|
||||
let groupExited: boolean
|
||||
let treeExited: boolean
|
||||
try {
|
||||
groupExited = await this.connection.waitForProcessGroupExit(graceDeadline.signal)
|
||||
treeExited = await this.connection.waitForProcessTreeExit(graceDeadline.signal)
|
||||
} finally {
|
||||
graceDeadline[Symbol.dispose]()
|
||||
}
|
||||
if (!groupExited) this.connection.kill()
|
||||
escalateProcessTree(treeExited, this.connection.kill.bind(this.connection))
|
||||
await Promise.all([
|
||||
this.connection.closed,
|
||||
this.connection.waitForProcessGroupExit(),
|
||||
this.connection.waitForProcessTreeExit(),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { LspConnection } from '@deepseek-ai/dsh-lsp-local'
|
||||
import {
|
||||
signalProcessGroup,
|
||||
signalProcessTree,
|
||||
taskkillProcessTree,
|
||||
waitForTreeExit,
|
||||
} from '@deepseek-ai/dsh-lsp-local/src/connection.ts'
|
||||
import type {
|
||||
ConnectionWriter,
|
||||
ProcessSignalRunner,
|
||||
ProcessTreeOperations,
|
||||
TaskkillRunner,
|
||||
} from '@deepseek-ai/dsh-lsp-local/src/connection.ts'
|
||||
|
||||
const fixtureServer = fileURLToPath(new URL('./fixture-server.ts', import.meta.url))
|
||||
|
||||
@@ -53,6 +65,12 @@ describe('LspConnection', () => {
|
||||
await expect(conn.request('textDocument/hover', {})).rejects.toThrow(/server refused the request/)
|
||||
})
|
||||
|
||||
it('treats signaling an already-closed child as a teardown race', async () => {
|
||||
const conn = connectScript('')
|
||||
await conn.closed
|
||||
expect(() => { conn.kill() }).not.toThrow()
|
||||
})
|
||||
|
||||
it('answers a server workspace/configuration request from static config', async () => {
|
||||
const seen: SeenRequest[] = []
|
||||
const conn = connect(
|
||||
@@ -125,7 +143,7 @@ describe('LspConnection', () => {
|
||||
})
|
||||
|
||||
/** Spawn a raw connection running an inline node script as the "server". */
|
||||
function connectScript(script: string, maxStderrBytes = 100_000): LspConnection {
|
||||
function connectScript(script: string, maxStderrBytes = 100_000, writer?: ConnectionWriter): LspConnection {
|
||||
const conn = new LspConnection({
|
||||
command: process.execPath,
|
||||
args: ['-e', script],
|
||||
@@ -134,7 +152,7 @@ function connectScript(script: string, maxStderrBytes = 100_000): LspConnection
|
||||
maxMessageBytes: 16_000_000,
|
||||
maxStderrBytes,
|
||||
configuration: null,
|
||||
}, () => Promise.resolve(null))
|
||||
}, () => Promise.resolve(null), writer)
|
||||
open.push(conn)
|
||||
return conn
|
||||
}
|
||||
@@ -209,13 +227,13 @@ describe('LspConnection edge behavior', () => {
|
||||
await expect(conn.request('initialize', {})).rejects.toThrow(/exited|closed/)
|
||||
})
|
||||
|
||||
it('rejects a pending request when child stdin closes but the process stays alive', async () => {
|
||||
const conn = connectScript('require("node:fs").closeSync(0); setInterval(()=>{}, 1000)')
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 100))
|
||||
const timeout = new Promise<never>((_resolve, reject) => {
|
||||
setTimeout(() => { reject(new Error('request timed out')) }, 1000)
|
||||
})
|
||||
await expect(Promise.race([conn.request('initialize', {}), timeout])).rejects.not.toThrow(/timed out/)
|
||||
it('rejects a pending request when child stdin fails but the process stays alive', async () => {
|
||||
const failure = new Error('fixture stdin failure')
|
||||
const writer: ConnectionWriter = (_stdin, _message, done) => {
|
||||
queueMicrotask(() => { done(failure) })
|
||||
}
|
||||
const conn = connectScript('setInterval(()=>{}, 1000)', 100_000, writer)
|
||||
await expect(conn.request('initialize', {})).rejects.toThrow(/fixture stdin failure/)
|
||||
})
|
||||
|
||||
it('ignores a frame that is neither a valid request nor a numeric-id response', async () => {
|
||||
@@ -230,6 +248,72 @@ describe('LspConnection edge behavior', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('process-tree signaling', () => {
|
||||
it('forwards POSIX process-group signals through the host runner', () => {
|
||||
const run: ProcessSignalRunner = vi.fn(() => true)
|
||||
signalProcessGroup(-42, 'SIGKILL', run)
|
||||
expect(run).toHaveBeenCalledWith(-42, 'SIGKILL')
|
||||
})
|
||||
|
||||
it('waits for tree exit and stops when its bound aborts', async () => {
|
||||
const isAlive = vi.fn()
|
||||
.mockReturnValueOnce(true)
|
||||
.mockReturnValue(false)
|
||||
const yieldNow = vi.fn(() => Promise.resolve())
|
||||
await expect(waitForTreeExit(isAlive, undefined, yieldNow)).resolves.toBe(true)
|
||||
expect(yieldNow).toHaveBeenCalledOnce()
|
||||
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
await expect(waitForTreeExit(() => true, controller.signal, yieldNow)).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('uses taskkill for a Windows tree and a negative pid for a POSIX group', () => {
|
||||
const operations = fakeProcessTreeOperations()
|
||||
signalProcessTree('win32', 42, 'SIGTERM', operations)
|
||||
expect(operations.taskkill).toHaveBeenCalledWith(42)
|
||||
expect(operations.signal).not.toHaveBeenCalled()
|
||||
|
||||
signalProcessTree('linux', 42, 'SIGKILL', operations)
|
||||
expect(operations.signal).toHaveBeenCalledWith(-42, 'SIGKILL')
|
||||
})
|
||||
|
||||
it('surfaces a Windows taskkill failure without downgrading to the direct child', () => {
|
||||
const fallback = fakeProcessTreeOperations()
|
||||
vi.mocked(fallback.taskkill).mockImplementation(() => { throw new Error('taskkill unavailable') })
|
||||
expect(() => { signalProcessTree('win32', 42, 'SIGTERM', fallback) }).toThrow(/taskkill unavailable/)
|
||||
expect(fallback.killChild).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('tolerates a POSIX tree-signaling race after the direct child is already gone', () => {
|
||||
const posixGone = fakeProcessTreeOperations()
|
||||
vi.mocked(posixGone.signal).mockImplementation(() => { throw new Error('group gone') })
|
||||
vi.mocked(posixGone.killChild).mockImplementation(() => { throw new Error('child gone') })
|
||||
expect(() => { signalProcessTree('linux', 42, 'SIGKILL', posixGone) }).not.toThrow()
|
||||
})
|
||||
|
||||
it('runs taskkill for the full tree, accepts an absent tree, and rejects command failures', () => {
|
||||
const success: TaskkillRunner = vi.fn(() => ({ status: 0 }))
|
||||
taskkillProcessTree(42, success)
|
||||
expect(success).toHaveBeenCalledWith('taskkill', ['/PID', '42', '/T', '/F'], { stdio: 'ignore' })
|
||||
|
||||
expect(() => { taskkillProcessTree(42, () => ({ status: 128 })) }).not.toThrow()
|
||||
|
||||
const spawnFailure = new Error('cannot spawn taskkill')
|
||||
expect(() => { taskkillProcessTree(42, () => ({ status: null, error: spawnFailure })) }).toThrow(spawnFailure)
|
||||
expect(() => { taskkillProcessTree(42, () => ({ status: 1 })) }).toThrow(/status 1/)
|
||||
})
|
||||
})
|
||||
|
||||
/** Create observable process-tree operations without touching host processes. */
|
||||
function fakeProcessTreeOperations(): ProcessTreeOperations {
|
||||
return {
|
||||
signal: vi.fn(),
|
||||
killChild: vi.fn(),
|
||||
taskkill: vi.fn(),
|
||||
}
|
||||
}
|
||||
|
||||
/** Poll a predicate until it holds or a deadline elapses. */
|
||||
async function waitFor(predicate: () => boolean, timeoutMs = 3000): Promise<void> {
|
||||
const start = Date.now()
|
||||
|
||||
@@ -16,8 +16,6 @@
|
||||
* - LSP_FAKE_OPEN_MARKER: appends each didOpen document text as one JSON line to this path.
|
||||
* - LSP_FAKE_INITIALIZED_MARKER: records when the initialized notification is received.
|
||||
* - LSP_FAKE_PAUSE_STDIN_AFTER_INITIALIZED: "1" stops consuming stdin after initialized.
|
||||
* - LSP_FAKE_CLOSE_STDIN_AFTER_INITIALIZED: "1" closes fd 0 after the initialized notification.
|
||||
* - LSP_FAKE_CLOSE_STDIN_AFTER_REPLY: "1" closes fd 0 before sending the first query response.
|
||||
* - LSP_FAKE_EXIT_DELAY_MS / LSP_FAKE_EXIT_MARKER: delay protocol exit and record exit/termination.
|
||||
* - LSP_FAKE_NO_SHUTDOWN: "1" ignores the shutdown request (forces kill escalation).
|
||||
* - LSP_FAKE_ON_OPEN: server→client request to emit when a didOpen arrives, one of
|
||||
@@ -28,7 +26,7 @@
|
||||
* Run: node fixture-server.ts (Node's erasable TypeScript syntax support).
|
||||
*/
|
||||
|
||||
import { appendFileSync, closeSync } from 'node:fs'
|
||||
import { appendFileSync } from 'node:fs'
|
||||
|
||||
const enc = process.env.LSP_FAKE_ENCODING ?? 'utf-16'
|
||||
const sync: unknown = process.env.LSP_FAKE_SYNC !== undefined ? JSON.parse(process.env.LSP_FAKE_SYNC) : 1
|
||||
@@ -40,8 +38,6 @@ const replyDelayMs = Number(process.env.LSP_FAKE_REPLY_DELAY_MS ?? 0)
|
||||
const openMarker = process.env.LSP_FAKE_OPEN_MARKER
|
||||
const initializedMarker = process.env.LSP_FAKE_INITIALIZED_MARKER
|
||||
const pauseStdinAfterInitialized = process.env.LSP_FAKE_PAUSE_STDIN_AFTER_INITIALIZED === '1'
|
||||
const closeStdinAfterInitialized = process.env.LSP_FAKE_CLOSE_STDIN_AFTER_INITIALIZED === '1'
|
||||
const closeStdinAfterReply = process.env.LSP_FAKE_CLOSE_STDIN_AFTER_REPLY === '1'
|
||||
const exitDelayMs = Number(process.env.LSP_FAKE_EXIT_DELAY_MS ?? 0)
|
||||
const exitMarker = process.env.LSP_FAKE_EXIT_MARKER
|
||||
const noShutdown = process.env.LSP_FAKE_NO_SHUTDOWN === '1'
|
||||
@@ -146,14 +142,12 @@ function handle(message: { id?: number; method?: string; params?: unknown; resul
|
||||
if (method === 'initialized') {
|
||||
if (initializedMarker !== undefined) appendFileSync(initializedMarker, 'INITIALIZED\n')
|
||||
if (pauseStdinAfterInitialized) process.stdin.pause()
|
||||
if (closeStdinAfterInitialized) closeSync(0)
|
||||
return
|
||||
}
|
||||
if (method === 'textDocument/didClose') return
|
||||
if (method?.startsWith('textDocument/')) {
|
||||
if (hang) return
|
||||
const reply = (): void => {
|
||||
if (closeStdinAfterReply) closeSync(0)
|
||||
if (errorReply) {
|
||||
send({ id, error: { code: -32000, message: 'server refused the request' } })
|
||||
} else {
|
||||
@@ -202,6 +196,6 @@ function send(message: Record<string, unknown>): void {
|
||||
|
||||
// Keep the event loop alive.
|
||||
process.stdin.resume()
|
||||
if (pauseStdinAfterInitialized || closeStdinAfterInitialized || closeStdinAfterReply) {
|
||||
if (pauseStdinAfterInitialized) {
|
||||
setInterval(() => {}, 1000)
|
||||
}
|
||||
|
||||
@@ -92,7 +92,8 @@ describe('readHostSource', () => {
|
||||
await expect(readHostSource('dir', ws, BIG)).rejects.toThrow(/not a regular file/)
|
||||
})
|
||||
|
||||
it('rejects a FIFO with no writer without blocking in open', async () => {
|
||||
// Windows has no filesystem FIFO; the directory case above pins non-regular rejection there.
|
||||
it.skipIf(process.platform === 'win32')('rejects a FIFO with no writer without blocking in open', async () => {
|
||||
const fifo = join(ws, 'pipe.ts')
|
||||
await execFileAsync('mkfifo', [fifo])
|
||||
using d = deadline(undefined, 1000, 'FIFO_READ_TIMEOUT')
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mkdtemp, mkdir, readFile, rm, writeFile, realpath } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL, fileURLToPath } from 'node:url'
|
||||
import { LspInstance, readHostSource } from '@deepseek-ai/dsh-lsp-local'
|
||||
import { encodeMessage } from '@deepseek-ai/dsh-lsp-local'
|
||||
import type { ConnectionWriter } from '@deepseek-ai/dsh-lsp-local/src/connection.ts'
|
||||
import { escalateProcessTree } from '@deepseek-ai/dsh-lsp-local/src/instance.ts'
|
||||
import type { InstanceSpec } from '@deepseek-ai/dsh-lsp-local/src/instance.ts'
|
||||
import type { LspProviderQuery, LspQueryResult } from '@deepseek-ai/dsh-lsp'
|
||||
|
||||
@@ -26,7 +29,11 @@ afterEach(async () => {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function makeInstance(env: Record<string, string> = {}, overrides: Partial<InstanceSpec> = {}): LspInstance {
|
||||
function makeInstance(
|
||||
env: Record<string, string> = {},
|
||||
overrides: Partial<InstanceSpec> = {},
|
||||
writer?: ConnectionWriter,
|
||||
): LspInstance {
|
||||
const instance = new LspInstance({
|
||||
command: process.execPath,
|
||||
args: [fixtureServer],
|
||||
@@ -39,7 +46,7 @@ function makeInstance(env: Record<string, string> = {}, overrides: Partial<Insta
|
||||
shutdownTimeoutMs: 200,
|
||||
killGraceMs: 200,
|
||||
...overrides,
|
||||
})
|
||||
}, writer)
|
||||
live.push(instance)
|
||||
return instance
|
||||
}
|
||||
@@ -201,17 +208,25 @@ describe('LspInstance query and abort', () => {
|
||||
})
|
||||
|
||||
it('terminates when stdin fails during the didOpen write', async () => {
|
||||
// Closing stdin after initialized makes a large didOpen fail before `opened` can arm didClose;
|
||||
// the instance must still become dead so its provider can replace it.
|
||||
await writeFile(join(ws, 'a.ts'), 'x'.repeat(2_000_000))
|
||||
const instance = makeInstance({ LSP_FAKE_CLOSE_STDIN_AFTER_INITIALIZED: '1' }, {
|
||||
const instance = makeInstance({}, {
|
||||
shutdownTimeoutMs: 100,
|
||||
killGraceMs: 100,
|
||||
})
|
||||
}, failingWriter('textDocument/didOpen'))
|
||||
await expect(run(instance, 'goToDefinition')).rejects.toThrow()
|
||||
expect(instance.dead).toBe(true)
|
||||
})
|
||||
|
||||
it('awaits process exit before rejecting a request write failure', async () => {
|
||||
const instance = makeInstance({}, {
|
||||
shutdownTimeoutMs: 100,
|
||||
killGraceMs: 100,
|
||||
}, failingWriter('textDocument/definition'))
|
||||
// The pid is observed only to prove the owned subprocess reached quiescence before rejection.
|
||||
const pid = (instance as unknown as { connection: { pid: number } }).connection.pid
|
||||
await expect(run(instance, 'goToDefinition')).rejects.toThrow(/fixture textDocument\/definition failure/)
|
||||
expect(processAlive(pid)).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects when the server lacks the operation capability', async () => {
|
||||
const instance = makeInstance({ LSP_FAKE_CAPS: JSON.stringify({ definitionProvider: false }), LSP_FAKE_DEF: 'null' })
|
||||
await expect(run(instance, 'goToDefinition')).rejects.toThrow(/does not support goToDefinition/)
|
||||
@@ -228,8 +243,7 @@ describe('LspInstance query and abort', () => {
|
||||
it('keeps a settled result but awaits teardown when didClose cannot be written', async () => {
|
||||
const instance = makeInstance({
|
||||
LSP_FAKE_DEF: 'null',
|
||||
LSP_FAKE_CLOSE_STDIN_AFTER_REPLY: '1',
|
||||
}, { shutdownTimeoutMs: 100, killGraceMs: 100 })
|
||||
}, { shutdownTimeoutMs: 100, killGraceMs: 100 }, failingWriter('textDocument/didClose'))
|
||||
await expect(run(instance, 'goToDefinition')).resolves.toEqual({
|
||||
kind: 'locations',
|
||||
locations: [],
|
||||
@@ -240,6 +254,14 @@ describe('LspInstance query and abort', () => {
|
||||
})
|
||||
|
||||
describe('LspInstance disposal', () => {
|
||||
it('escalates only when the process tree survives its grace period', () => {
|
||||
const forceKill = vi.fn()
|
||||
escalateProcessTree(false, forceKill)
|
||||
expect(forceKill).toHaveBeenCalledOnce()
|
||||
escalateProcessTree(true, forceKill)
|
||||
expect(forceKill).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('lets a server finish protocol exit before signal escalation', async () => {
|
||||
const marker = join(root, 'graceful-exit.log')
|
||||
const instance = makeInstance({
|
||||
@@ -281,7 +303,7 @@ describe('LspInstance disposal', () => {
|
||||
await expect(instance.dispose()).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('awaits a surviving process-group helper on every concurrent dispose', async () => {
|
||||
it('awaits a surviving process-tree helper on every concurrent dispose', async () => {
|
||||
const marker = join(root, 'helper.pid')
|
||||
const helper = 'process.on("SIGTERM",()=>{});setInterval(()=>{},1000);'
|
||||
const script = 'const{spawn}=require("node:child_process");const{writeFileSync}=require("node:fs");'
|
||||
@@ -298,6 +320,7 @@ describe('LspInstance disposal', () => {
|
||||
await first
|
||||
} finally {
|
||||
if (processAlive(helperPid)) process.kill(helperPid, 'SIGKILL')
|
||||
await waitForProcessExit(helperPid)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -322,6 +345,26 @@ function processAlive(pid: number): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
/** Wait until a process id disappears so temporary-workspace cleanup cannot race handle release. */
|
||||
async function waitForProcessExit(pid: number, timeoutMs = 3_000): Promise<void> {
|
||||
const started = Date.now()
|
||||
while (processAlive(pid)) {
|
||||
if (Date.now() - started > timeoutMs) throw new Error(`process ${pid} did not exit`)
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 10))
|
||||
}
|
||||
}
|
||||
|
||||
/** Write normally except for one method whose callback receives a deterministic transport error. */
|
||||
function failingWriter(method: string): ConnectionWriter {
|
||||
return (stdin, message, done) => {
|
||||
if ((message as { method?: unknown }).method === method) {
|
||||
queueMicrotask(() => { done(new Error(`fixture ${method} failure`)) })
|
||||
return
|
||||
}
|
||||
stdin.write(encodeMessage(message), done)
|
||||
}
|
||||
}
|
||||
|
||||
/** Wait until a fixture marker exists, bounded so a broken handshake cannot hang the test. */
|
||||
async function waitForFile(path: string, timeoutMs = 3000): Promise<void> {
|
||||
const started = Date.now()
|
||||
|
||||
@@ -122,9 +122,15 @@ describe('lsp-local end to end over a fake server', () => {
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('rejects a non-utf-16 position encoding at initialize', async () => {
|
||||
const ctx = await mount({ LSP_FAKE_ENCODING: 'utf-8', LSP_FAKE_DEF: 'null' })
|
||||
it('rejects a non-utf-16 position encoding at initialize without retrying', async () => {
|
||||
const marker = join(root, 'initialize-rejection-exit.log')
|
||||
const ctx = await mount({
|
||||
LSP_FAKE_ENCODING: 'utf-8',
|
||||
LSP_FAKE_DEF: 'null',
|
||||
LSP_FAKE_EXIT_MARKER: marker,
|
||||
})
|
||||
await expect(ctx.lsp.query(query('goToDefinition'))).rejects.toThrow(/unsupported position encoding/)
|
||||
expect(await readFile(marker, 'utf8')).toBe('EXIT\nCLEAN\n')
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { chmod, mkdtemp, mkdir, rm, writeFile, realpath } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { delimiter, join } from 'node:path'
|
||||
import { Context } from 'cordis'
|
||||
import Lsp, { type LspQueryRequest } from '@deepseek-ai/dsh-lsp'
|
||||
import * as LspLocal from '@deepseek-ai/dsh-lsp-local'
|
||||
@@ -57,7 +57,7 @@ describe('lsp-local provider resolution', () => {
|
||||
await expect(ctx.plugin(LspLocal, config('nope', {
|
||||
command: 'fake-lsp',
|
||||
args: [],
|
||||
env: { PATH: `::${join(root, 'empty')}` },
|
||||
env: { PATH: `${delimiter}${delimiter}${join(root, 'empty')}` },
|
||||
extensionToLanguage: { '.ts': 'typescript' },
|
||||
}))).rejects.toThrow(/was not found on PATH/)
|
||||
await ctx.fiber.dispose()
|
||||
@@ -116,7 +116,8 @@ describe('lsp-local provider resolution', () => {
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('rejects an absolute command that is not executable at load', async () => {
|
||||
// Node's X_OK probe is an existence check on Windows, which has no executable mode bit.
|
||||
it.skipIf(process.platform === 'win32')('rejects an absolute command that is not executable at load', async () => {
|
||||
const notExe = join(root, 'not-exe.txt')
|
||||
await writeFile(notExe, 'plain text, not executable')
|
||||
const ctx = new Context()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
import { join, resolve } from 'node:path'
|
||||
import {
|
||||
DEFAULT_MAX_LOCATIONS,
|
||||
DEFAULT_MAX_RESULT_CHARS,
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
} from '@deepseek-ai/dsh-tool-lsp'
|
||||
import type { LspLocation } from '@deepseek-ai/dsh-lsp'
|
||||
|
||||
const WS = '/home/u/proj'
|
||||
const WS = resolve('/home/u/proj')
|
||||
|
||||
function loc(uri: string, line: number, character = 0): LspLocation {
|
||||
return { uri, range: { start: { line, character }, end: { line, character: character + 1 } } }
|
||||
@@ -52,8 +52,9 @@ describe('renderUri', () => {
|
||||
})
|
||||
|
||||
it('returns an absolute path for a file: URI outside the workspace', () => {
|
||||
const uri = pathToFileURL('/other/lib/b.ts').href
|
||||
expect(renderUri(uri, WS)).toBe('/other/lib/b.ts')
|
||||
const outside = resolve(WS, '..', 'other', 'lib', 'b.ts')
|
||||
const uri = pathToFileURL(outside).href
|
||||
expect(renderUri(uri, WS)).toBe(outside)
|
||||
})
|
||||
|
||||
it('renders the workspace root itself as "."', () => {
|
||||
@@ -72,8 +73,8 @@ describe('renderUri', () => {
|
||||
})
|
||||
|
||||
it('keeps a malformed file: URI verbatim when it cannot be parsed to a path', () => {
|
||||
// A file: URI with a host that fileURLToPath rejects falls through to the verbatim path.
|
||||
expect(renderUri('file://host/notlocal', WS)).toBe('file://host/notlocal')
|
||||
// An encoded path separator is invalid on every platform and must remain verbatim.
|
||||
expect(renderUri('file:///bad%2Fpath', WS)).toBe('file:///bad%2Fpath')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { Context } from 'cordis'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
@@ -40,8 +42,11 @@ async function mount(
|
||||
|
||||
let seq = 0
|
||||
const testToolSignal = new AbortController().signal
|
||||
const workspaceRoot = resolve('/virtual/workspace')
|
||||
const resolvedWorkspaceRoot = resolve('/virtual/real-workspace')
|
||||
const workspaceAlias = resolve('/virtual/workspace-alias')
|
||||
/** `cwd: null` means "no agent" (tests LSP_WORKSPACE_REQUIRED); a string is the session cwd. */
|
||||
function call(ctx: Context, args: unknown, cwd: string | null = '/ws') {
|
||||
function call(ctx: Context, args: unknown, cwd: string | null = workspaceRoot) {
|
||||
return ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: `c-${++seq}` as never,
|
||||
@@ -53,8 +58,8 @@ function call(ctx: Context, args: unknown, cwd: string | null = '/ws') {
|
||||
|
||||
const okLocations: LspQueryResult = {
|
||||
kind: 'locations',
|
||||
locations: [{ uri: 'file:///ws/a.ts', range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } } }],
|
||||
resolvedWorkspaceRoot: '/ws',
|
||||
locations: [{ uri: pathToFileURL(join(workspaceRoot, 'a.ts')).href, range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } } }],
|
||||
resolvedWorkspaceRoot: workspaceRoot,
|
||||
}
|
||||
|
||||
describe('tool-lsp registration', () => {
|
||||
@@ -107,19 +112,19 @@ describe('tool-lsp execution', () => {
|
||||
it('converts one-based coordinates and passes the session cwd as workspaceRoot', async () => {
|
||||
const provider = stubProvider(() => okLocations)
|
||||
const { ctx } = await mount(provider)
|
||||
const result = await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 3, character: 5 }, '/ws')
|
||||
const result = await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 3, character: 5 }, workspaceRoot)
|
||||
expect(result.isError).toBe(false)
|
||||
expect(provider.seen[0]).toMatchObject({
|
||||
operation: 'goToDefinition',
|
||||
filePath: 'a.ts',
|
||||
position: { line: 2, character: 4 },
|
||||
workspaceRoot: '/ws',
|
||||
workspaceRoot,
|
||||
})
|
||||
})
|
||||
|
||||
it('renders locations relative to the workspace', async () => {
|
||||
const { ctx } = await mount(stubProvider(() => okLocations))
|
||||
const result = await call(ctx, { operation: 'findReferences', file_path: 'a.ts', line: 1, character: 1 }, '/ws')
|
||||
const result = await call(ctx, { operation: 'findReferences', file_path: 'a.ts', line: 1, character: 1 }, workspaceRoot)
|
||||
expect(result.content[0]).toEqual({ type: 'text', text: 'a.ts:1:1' })
|
||||
expect(result).toMatchObject({ isError: false, value: okLocations })
|
||||
})
|
||||
@@ -146,23 +151,22 @@ describe('tool-lsp execution', () => {
|
||||
})
|
||||
|
||||
it('relativizes against the provider resolvedWorkspaceRoot, not the session cwd', async () => {
|
||||
// A symlinked session cwd (`/alias`) resolves to a real path (`/real/ws`) that the provider's
|
||||
// location URIs are under. Relativizing against the alias would misclassify the location as
|
||||
// external and print an absolute path; the tool must use resolvedWorkspaceRoot.
|
||||
// A symlinked session cwd resolves to the real path that contains the provider's location URIs.
|
||||
// Relativizing against the alias would misclassify the location as external.
|
||||
const provider = stubProvider(() => ({
|
||||
kind: 'locations',
|
||||
locations: [{ uri: 'file:///real/ws/a.ts', range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } } }],
|
||||
resolvedWorkspaceRoot: '/real/ws',
|
||||
locations: [{ uri: pathToFileURL(join(resolvedWorkspaceRoot, 'a.ts')).href, range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } } }],
|
||||
resolvedWorkspaceRoot,
|
||||
}))
|
||||
const { ctx } = await mount(provider)
|
||||
const result = await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 1, character: 1 }, '/alias')
|
||||
expect(provider.seen[0]).toMatchObject({ workspaceRoot: '/alias' })
|
||||
const result = await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 1, character: 1 }, workspaceAlias)
|
||||
expect(provider.seen[0]).toMatchObject({ workspaceRoot: workspaceAlias })
|
||||
expect(result.content[0]).toEqual({ type: 'text', text: 'a.ts:1:1' })
|
||||
})
|
||||
|
||||
it('renders hover content', async () => {
|
||||
const { ctx } = await mount(stubProvider(() => ({ kind: 'hover', hover: { contents: 'number' } })))
|
||||
const result = await call(ctx, { operation: 'hover', file_path: 'a.ts', line: 1, character: 1 }, '/ws')
|
||||
const result = await call(ctx, { operation: 'hover', file_path: 'a.ts', line: 1, character: 1 }, workspaceRoot)
|
||||
expect(result.content[0]).toEqual({ type: 'text', text: 'number' })
|
||||
expect(result).toMatchObject({ isError: false, value: { kind: 'hover', hover: { contents: 'number' } } })
|
||||
})
|
||||
@@ -190,14 +194,14 @@ describe('tool-lsp execution', () => {
|
||||
|
||||
it('surfaces a structured LSP_UNAVAILABLE when no provider handles the file', async () => {
|
||||
const { ctx } = await mount(stubProvider(() => okLocations, { '.py': 'python' }))
|
||||
const result = await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 1, character: 1 }, '/ws')
|
||||
const result = await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 1, character: 1 }, workspaceRoot)
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error?.info?.code).toBe('LSP_UNAVAILABLE')
|
||||
})
|
||||
|
||||
it('returns a structured INVALID_ARGS on a bad operation', async () => {
|
||||
const { ctx } = await mount(stubProvider(() => okLocations))
|
||||
const result = await call(ctx, { operation: 'rename', file_path: 'a.ts', line: 1, character: 1 }, '/ws')
|
||||
const result = await call(ctx, { operation: 'rename', file_path: 'a.ts', line: 1, character: 1 }, workspaceRoot)
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error?.info?.code).toBe('INVALID_ARGS')
|
||||
})
|
||||
@@ -213,7 +217,7 @@ describe('tool-lsp execution', () => {
|
||||
},
|
||||
}
|
||||
const { ctx } = await mount(provider)
|
||||
await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 1, character: 1 }, '/ws')
|
||||
await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 1, character: 1 }, workspaceRoot)
|
||||
// The timeout policy is not mounted here, so the signal is whatever the registry passes (may be
|
||||
// undefined); the point is the tool threads it through without throwing.
|
||||
expect(seen).toHaveLength(1)
|
||||
|
||||
Reference in New Issue
Block a user