Files
deepseek-harness/packages/lsp/lsp-local/tests/connection.spec.ts
T
Tianyi Cui 3672cd25b4 feat(subprocess): migrate lsp-local, subagent-acp, and the env scrubs onto the seam
Review direction (tianyicui, PR #660): in a stacked PR, change all other
process-running places to use the new service.

- lsp-local: LspConnection spawns through ctx.subprocess (piped protocol
  streams + a no-spill collected stderr tail); its private process-tree
  helpers (POSIX group signalling, Windows taskkill, liveness polling) are
  deleted in favor of the seam's handle verbs, and its buildChildEnv now
  rides scrubbedParentEnv (LSP children also stop inheriting stale DSH_*).
  The plugin injects 'subprocess'; compositions/tests mount
  dsh-subprocess-local.
- subagent-acp: the ACP child spawns through the seam (piped ndjson streams,
  inherited stderr); spawn failure surfaces through done-rejection into the
  same startup race; disposal is handle.dispose with the plugin's configured
  graces. dsh-subagent-subprocess is DELETED — its dispose ladder and scrub
  are the seam's, and the isolated-config-dir helper had no consumer.
- mcp-client, pty-local, sdk-helper: adopt scrubbedParentEnv as the one
  scrub definition (their spawns stay put by ownership: the MCP SDK and
  node-pty own those calls; the SDK wizard runs outside any composition).
- Coverage: per-file 100% over every touched src file, with each v8 ignore
  carrying a platform or contract reason; new suites cover stdio
  dispositions, the dispose ladder tiers, injected-win32 tree semantics,
  waitForExit, settled-kill/terminate no-ops, and spawn-failure disposal.
- Docs: consumer-migration Agent Note (en; zh follows in this PR), seam note
  updated in place, subprocess.md rewritten for the reshaped vocabulary
  (type-equiv re-registered), READMEs and SERVICE_ROLES updated, taskkill
  added to knip ignoreBinaries.
2026-07-26 15:27:59 +08:00

252 lines
12 KiB
TypeScript

import { afterEach, describe, expect, it } from 'vitest'
import { fileURLToPath } from 'node:url'
import { LspConnection } from '@deepseek-ai/dsh-lsp-local'
import type { ConnectionWriter } from '@deepseek-ai/dsh-lsp-local/src/connection.ts'
import { spawnSubprocess } from '@deepseek-ai/dsh-subprocess-local/src/spawn.ts'
const fixtureServer = fileURLToPath(new URL('./fixture-server.ts', import.meta.url))
/** A recorded server→client request the test's handler saw. */
interface SeenRequest { method: string; params: unknown }
let open: LspConnection[] = []
afterEach(async () => {
for (const conn of open) {
conn.kill()
await conn.closed
}
open = []
})
/** Spawn the fixture as a raw connection, with a scripted server-request handler. */
function connect(
env: Record<string, string>,
onServerRequest: (method: string, params: unknown) => Promise<unknown> = () => Promise.resolve(null),
seen?: SeenRequest[],
): LspConnection {
const conn = new LspConnection({
command: process.execPath,
args: [fixtureServer],
cwd: process.cwd(),
env: { ...process.env as Record<string, string>, ...env },
maxMessageBytes: 16_000_000,
maxStderrBytes: 100_000,
pipeDrainGraceMs: 3_000,
configuration: { setting: 42 },
}, spawnSubprocess, (method, params) => {
seen?.push({ method, params })
return onServerRequest(method, params)
})
open.push(conn)
return conn
}
describe('LspConnection', () => {
it('completes an initialize request/response round-trip and exposes a pid', async () => {
const conn = connect({})
const result = await conn.request('initialize', { capabilities: {} })
expect(result).toMatchObject({ capabilities: { hoverProvider: true } })
expect(conn.pid).toBeGreaterThan(0)
})
it('rejects a request when the server replies with an error', async () => {
const conn = connect({ LSP_FAKE_ERROR: '1' })
await conn.request('initialize', { capabilities: {} })
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(
{ LSP_FAKE_ON_OPEN: 'configuration' },
(method, params) => {
if (method === 'workspace/configuration') {
const items = (params as { items: unknown[] }).items
return Promise.resolve(items.map(() => ({ setting: 42 })))
}
return Promise.resolve(null)
},
seen,
)
await conn.request('initialize', { capabilities: {} })
await conn.notify('textDocument/didOpen', { textDocument: { uri: 'file:///x', languageId: 'ts', version: 1, text: '' } })
await waitFor(() => seen.some(s => s.method === 'workspace/configuration'))
expect(seen[0]?.method).toBe('workspace/configuration')
})
it('drops a server→client notification without replying', async () => {
const conn = connect({ LSP_FAKE_ON_OPEN: 'notification' })
await conn.request('initialize', { capabilities: {} })
await conn.notify('textDocument/didOpen', { textDocument: { uri: 'file:///x', languageId: 'ts', version: 1, text: '' } })
// No throw and the connection stays usable.
await expect(conn.request('textDocument/hover', {})).resolves.toBeDefined()
})
it('sends an error response when the server-request handler rejects', async () => {
const seen: SeenRequest[] = []
const conn = connect(
{ LSP_FAKE_ON_OPEN: 'applyEdit' },
method => method === 'workspace/applyEdit' ? Promise.reject(new Error('not permitted')) : Promise.resolve(null),
seen,
)
await conn.request('initialize', { capabilities: {} })
await conn.notify('textDocument/didOpen', { textDocument: { uri: 'file:///x', languageId: 'ts', version: 1, text: '' } })
await waitFor(() => seen.some(s => s.method === 'workspace/applyEdit'))
// The connection remains healthy after emitting the error response.
await expect(conn.request('textDocument/hover', {})).resolves.toBeDefined()
})
it('fails all pending requests and kills the process on a framing error', async () => {
const conn = connect({ LSP_FAKE_GARBAGE: '1' })
// The garbage byte precedes a valid initialize reply; unframed bytes are tolerated until a
// Content-Length header, so initialize still resolves. This exercises the decoder's resilience.
await expect(conn.request('initialize', { capabilities: {} })).resolves.toBeDefined()
})
it('rejects a new request issued after the process closes', async () => {
const conn = connect({})
await conn.request('initialize', { capabilities: {} })
conn.terminate()
await conn.closed
await expect(conn.request('textDocument/hover', {})).rejects.toThrow(/exited|closed/)
})
it('cancel is a no-op-safe write after close', async () => {
const conn = connect({})
await conn.request('initialize', { capabilities: {} })
conn.terminate()
await conn.closed
expect(() => { conn.cancel(1) }).not.toThrow()
})
it('caps the retained stderr tail', async () => {
const conn = connect({})
await conn.request('initialize', { capabilities: {} })
expect(conn.stderrTail.length).toBeLessThanOrEqual(100_000)
})
})
/** Spawn a raw connection running an inline node script as the "server". */
function connectScript(script: string, maxStderrBytes = 100_000, writer?: ConnectionWriter): LspConnection {
const conn = new LspConnection({
command: process.execPath,
args: ['-e', script],
cwd: process.cwd(),
env: { ...process.env as Record<string, string> },
maxMessageBytes: 16_000_000,
maxStderrBytes,
pipeDrainGraceMs: 3_000,
configuration: null,
}, spawnSubprocess, () => Promise.resolve(null), writer)
open.push(conn)
return conn
}
describe('LspConnection edge behavior', () => {
it('fails a request when the command cannot be spawned', async () => {
const conn = new LspConnection({
command: '/definitely/not/a/real/binary/xyz',
args: [],
cwd: process.cwd(),
env: {},
maxMessageBytes: 1000,
maxStderrBytes: 1000,
pipeDrainGraceMs: 3_000,
configuration: null,
}, spawnSubprocess, () => Promise.resolve(null))
open.push(conn)
await expect(conn.request('initialize', {})).rejects.toThrow()
})
it('kills the process and fails pending requests on a framing error', async () => {
// Emit an invalid Content-Length header, corrupting the stream irrecoverably.
const conn = connectScript('process.stdout.write("Content-Length: abc\\r\\n\\r\\n{}"); setInterval(()=>{}, 1000)')
await expect(conn.request('initialize', {})).rejects.toThrow()
})
it('ignores a framed non-object message', async () => {
// Send a framed JSON number and a framed null (both non-objects) then a proper response to id 1.
const script = 'let b=Buffer.alloc(0);'
+ 'const fr=(s)=>{const x=Buffer.from(s);return Buffer.concat([Buffer.from(`Content-Length: ${x.length}\\r\\n\\r\\n`),x]);};'
+ 'process.stdout.write(fr("42"));process.stdout.write(fr("null"));'
+ 'process.stdin.on("data",c=>{b=Buffer.concat([b,c]);const s=b.indexOf("\\r\\n\\r\\n");if(s<0)return;const len=Number(/(\\d+)/.exec(b.toString("ascii",0,s))[1]);const body=JSON.parse(b.toString("utf8",s+4,s+4+len));process.stdout.write(fr(JSON.stringify({jsonrpc:"2.0",id:body.id,result:{ok:true}})));});'
const conn = connectScript(script)
await expect(conn.request('initialize', {})).resolves.toEqual({ ok: true })
})
it('drops a response for an unknown id', async () => {
// Emit a response for id 999 (never sent), then answer our real request.
const script = 'let b=Buffer.alloc(0);'
+ 'const fr=(s)=>{const x=Buffer.from(s);return Buffer.concat([Buffer.from(`Content-Length: ${x.length}\\r\\n\\r\\n`),x]);};'
+ 'process.stdout.write(fr(JSON.stringify({jsonrpc:"2.0",id:999,result:{stray:true}})));'
+ 'process.stdin.on("data",c=>{b=Buffer.concat([b,c]);const s=b.indexOf("\\r\\n\\r\\n");if(s<0)return;const len=Number(/(\\d+)/.exec(b.toString("ascii",0,s))[1]);const body=JSON.parse(b.toString("utf8",s+4,s+4+len));process.stdout.write(fr(JSON.stringify({jsonrpc:"2.0",id:body.id,result:{ok:true}})));});'
const conn = connectScript(script)
await expect(conn.request('initialize', {})).resolves.toEqual({ ok: true })
})
it('caps the retained stderr tail at maxStderrBytes across chunks', async () => {
// Write stderr repeatedly so a later chunk arrives after the cap is already reached.
const conn = connectScript('setInterval(()=>process.stderr.write("E".repeat(200)), 5); setInterval(()=>{}, 1000)', 100)
await waitFor(() => conn.stderrTail.length >= 100)
await new Promise<void>(resolve => setTimeout(resolve, 50))
expect(conn.stderrTail.length).toBe(100)
})
it('caps the retained stderr tail by bytes for multibyte UTF-8', async () => {
const conn = connectScript('process.stderr.write("😀😀")', 4)
await conn.closed
expect(conn.stderrTail).toBe('😀')
expect(Buffer.byteLength(conn.stderrTail)).toBe(4)
})
it('rejects with a fallback message when the error response has no message string', async () => {
const script = 'let b=Buffer.alloc(0);'
+ 'const fr=(s)=>{const x=Buffer.from(s);return Buffer.concat([Buffer.from(`Content-Length: ${x.length}\\r\\n\\r\\n`),x]);};'
+ 'process.stdin.on("data",c=>{b=Buffer.concat([b,c]);const s=b.indexOf("\\r\\n\\r\\n");if(s<0)return;const len=Number(/(\\d+)/.exec(b.toString("ascii",0,s))[1]);const body=JSON.parse(b.toString("utf8",s+4,s+4+len));process.stdout.write(fr(JSON.stringify({jsonrpc:"2.0",id:body.id,error:{code:-1}})));});'
const conn = connectScript(script)
await expect(conn.request('initialize', {})).rejects.toThrow(/LSP error response/)
})
it('rejects a pending request when the process exits mid-flight', async () => {
// Never responds, then exits shortly: the pending request must reject on close.
const conn = connectScript('setTimeout(()=>process.exit(0), 100)')
await expect(conn.request('initialize', {})).rejects.toThrow(/exited|closed/)
})
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 () => {
// A frame with a string id and no method: not dispatchable; the client must ignore it and still
// answer our real request.
const script = 'let b=Buffer.alloc(0);'
+ 'const fr=(s)=>{const x=Buffer.from(s);return Buffer.concat([Buffer.from(`Content-Length: ${x.length}\\r\\n\\r\\n`),x]);};'
+ 'process.stdout.write(fr(JSON.stringify({jsonrpc:"2.0",id:"str-id"})));'
+ 'process.stdin.on("data",c=>{b=Buffer.concat([b,c]);const s=b.indexOf("\\r\\n\\r\\n");if(s<0)return;const len=Number(/(\\d+)/.exec(b.toString("ascii",0,s))[1]);const body=JSON.parse(b.toString("utf8",s+4,s+4+len));process.stdout.write(fr(JSON.stringify({jsonrpc:"2.0",id:body.id,result:{ok:true}})));});'
const conn = connectScript(script)
await expect(conn.request('initialize', {})).resolves.toEqual({ ok: true })
})
})
/** Poll a predicate until it holds or a deadline elapses. */
async function waitFor(predicate: () => boolean, timeoutMs = 3000): Promise<void> {
const start = Date.now()
while (!predicate()) {
if (Date.now() - start > timeoutMs) throw new Error('waitFor timed out')
await new Promise<void>(resolve => setTimeout(resolve, 10))
}
}