3672cd25b4
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.
252 lines
12 KiB
TypeScript
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))
|
|
}
|
|
}
|