Merge remote-tracking branch 'origin/worktree-process-service-seam' into subprocess-simpl/d-one-env-channel

# Conflicts:
#	docs/cordis-catalog/services.md
#	docs/core-data-structures/subprocess.i18n.yaml
#	packages/lsp/lsp-local/src/connection.ts
#	packages/subagent/subagent-acp/README.i18n.yaml
#	packages/subprocess/subprocess-local/README.i18n.yaml
#	packages/subprocess/subprocess-local/src/spawn.ts
#	packages/subprocess/subprocess/README.i18n.yaml
#	packages/subprocess/subprocess/README.md
#	packages/subprocess/subprocess/README.zh.md
#	packages/subprocess/subprocess/tests/service.spec.ts
This commit is contained in:
Tianyi Cui
2026-07-27 11:31:38 +08:00
40 changed files with 288 additions and 428 deletions

View File

@@ -29,11 +29,11 @@ export interface ConnectionSpec {
/** Largest stderr tail retained for diagnostics. */
readonly maxStderrBytes: number
/**
* Bound (ms) for draining pipes a surviving helper still holds after the
* server exits; the instance passes its kill grace so exit observation is
* never slower than the escalation it feeds.
* The subprocess spec's `graceMs`: the SIGTERM→SIGKILL window of
* {@link LspConnection.terminate}'s escalation, and the bound for draining
* pipes a surviving helper still holds after the server exits.
*/
readonly pipeDrainGraceMs: number
readonly killGraceMs: number
/** Static answer to every `workspace/configuration` item. */
readonly configuration: unknown
}
@@ -97,7 +97,7 @@ export class LspConnection {
stdout: 'pipe',
stderr: { maxBytes: spec.maxStderrBytes },
},
graceMs: spec.pipeDrainGraceMs,
graceMs: spec.killGraceMs,
// spec.env mixes the scrubbed base with explicit config entries; the
// seam merges the whole map after its own ambient scrub, so a
// configured DSH_* fact reaches the child.
@@ -210,14 +210,9 @@ export class LspConnection {
return this.nextId
}
/** Request termination of the server's process tree (SIGTERM, no escalation). */
/** Terminate the server's process tree (the seam's SIGTERM→grace→SIGKILL escalation; idempotent). */
terminate(): void {
this.handle.kill('SIGTERM')
}
/** Force termination of the server's process tree. */
kill(): void {
this.handle.kill('SIGKILL')
this.handle.terminate()
}
/**
@@ -235,9 +230,10 @@ export class LspConnection {
messages = this.decoder.push(chunk)
} catch (error) {
// 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.
// terminate the whole group so helper processes don't outlive the leader (SIGTERM first, then
// the kill grace's SIGKILL — a misbehaving server still gets its bounded flush window).
this.fail(asError(error))
this.handle.kill('SIGKILL')
this.handle.terminate()
return
}
for (const message of messages) this.dispatch(message)

View File

@@ -285,8 +285,6 @@ class LocalLspProvider implements LspProvider {
initializationOptions: this.config.initializationOptions,
maxMessageBytes: this.config.maxMessageBytes,
maxStderrBytes: this.config.maxStderrBytes,
// Exit observation must never be slower than the escalation it feeds.
pipeDrainGraceMs: this.config.killGraceMs,
shutdownTimeoutMs: this.config.shutdownTimeoutMs,
killGraceMs: this.config.killGraceMs,
}

View File

@@ -35,17 +35,6 @@ export interface InstanceSpec extends ConnectionSpec {
readonly initializationOptions: unknown
/** Graceful `shutdown`/`exit` budget before escalation (ms). */
readonly shutdownTimeoutMs: number
/** SIGTERM→SIGKILL grace after graceful shutdown fails (ms). */
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()
}
/**
@@ -311,17 +300,14 @@ export class LspInstance {
await abortable(this.connection.closed, signal)
}
/** Terminate the tree, escalate after `killGraceMs`, then await leader and helper exit. */
/**
* Terminate the tree (the seam escalates SIGTERM→`killGraceMs`→SIGKILL),
* then await leader and helper exit. The awaits are unbounded on purpose:
* the seam's escalation already committed to SIGKILL, so quiescence — not
* another timer — is the postcondition disposal owes its callers.
*/
private async forceTerminate(): Promise<void> {
this.connection.terminate()
const graceDeadline = deadline(undefined, this.spec.killGraceMs, 'LSP_KILL_GRACE')
let treeExited: boolean
try {
treeExited = await this.connection.waitForProcessTreeExit(graceDeadline.signal)
} finally {
graceDeadline[Symbol.dispose]()
}
escalateProcessTree(treeExited, this.connection.kill.bind(this.connection))
await Promise.all([
this.connection.closed,
this.connection.waitForProcessTreeExit(),

View File

@@ -14,7 +14,7 @@ let open: LspConnection[] = []
afterEach(async () => {
for (const conn of open) {
conn.kill()
conn.terminate()
await conn.closed
}
open = []
@@ -33,7 +33,7 @@ function connect(
env: { ...scrubbedParentEnv(), ...env },
maxMessageBytes: 16_000_000,
maxStderrBytes: 100_000,
pipeDrainGraceMs: 3_000,
killGraceMs: 3_000,
configuration: { setting: 42 },
}, spawnSubprocess, (method, params) => {
seen?.push({ method, params })
@@ -66,10 +66,10 @@ 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 () => {
it('treats terminating an already-closed child as a teardown race', async () => {
const conn = connectScript('')
await conn.closed
expect(() => { conn.kill() }).not.toThrow()
expect(() => { conn.terminate() }).not.toThrow()
})
it('answers a server workspace/configuration request from static config', async () => {
@@ -152,7 +152,7 @@ function connectScript(script: string, maxStderrBytes = 100_000, writer?: Connec
env: scrubbedParentEnv(),
maxMessageBytes: 16_000_000,
maxStderrBytes,
pipeDrainGraceMs: 3_000,
killGraceMs: 3_000,
configuration: null,
}, spawnSubprocess, () => Promise.resolve(null), writer)
open.push(conn)
@@ -168,7 +168,7 @@ describe('LspConnection edge behavior', () => {
env: {},
maxMessageBytes: 1000,
maxStderrBytes: 1000,
pipeDrainGraceMs: 3_000,
killGraceMs: 3_000,
configuration: null,
}, spawnSubprocess, () => Promise.resolve(null))
open.push(conn)

View File

@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mkdtemp, mkdir, readFile, rm, writeFile, realpath } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@@ -6,7 +6,6 @@ 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'
import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess'
@@ -45,7 +44,6 @@ function makeInstance(
initializationOptions: { init: true },
maxMessageBytes: 16_000_000,
maxStderrBytes: 100_000,
pipeDrainGraceMs: 200,
shutdownTimeoutMs: 200,
killGraceMs: 200,
...overrides,
@@ -75,7 +73,6 @@ function scriptInstance(script: string, overrides: Partial<InstanceSpec> = {}):
initializationOptions: null,
maxMessageBytes: 16_000_000,
maxStderrBytes: 100_000,
pipeDrainGraceMs: 150,
shutdownTimeoutMs: 150,
killGraceMs: 150,
...overrides,
@@ -258,14 +255,6 @@ 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({