fix(cli): bound telemetry shutdown on signals

Refs #1356
This commit is contained in:
fz
2026-08-03 22:08:47 +08:00
parent 8eb97ab47e
commit ef23b88ad4
22 changed files with 454 additions and 70 deletions

View File

@@ -0,0 +1,14 @@
/** Test-only Cordis plugin whose disposer announces entry and never settles. */
/**
* Register a disposer that keeps process shutdown pending until it is forced.
* @param {import('cordis').Context} ctx - loader-mounted test plugin context.
*/
export function apply(ctx) {
const keepAlive = setInterval(() => {}, 60_000)
ctx.effect(() => async () => {
clearInterval(keepAlive)
process.stderr.write('dsh-test: never-dispose started\n')
await new Promise(() => {})
})
}

View File

@@ -0,0 +1,43 @@
import { mkdir, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { describe, expect, it } from 'vitest'
import { LOADER_SMOKE_TEST_TIMEOUT_MS } from '@deepseek-ai/dsh-loader-smoke'
import { runTuiPtySmoke } from './pty-harness.ts'
const dshBinScript = fileURLToPath(new URL('../src/bin.ts', import.meta.url))
const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
const neverDisposePlugin = pathToFileURL(
fileURLToPath(new URL('./fixtures/never-dispose.mjs', import.meta.url)),
).href
describe.skipIf(process.platform === 'win32')('headless process shutdown (real Loader tree in a PTY)', () => {
it('lets a second Ctrl+C force exit while the first signal is draining', async () => {
const output = await runTuiPtySmoke({
label: 'dsh headless repeated Ctrl+C',
tempDirPrefix: 'dsh-headless-shutdown-',
binScript: dshBinScript,
tsconfigPath,
configArgs: ['-p', 'never complete'],
env: { DEEPSEEK_API_KEY: 'keyless-shutdown-no-call', DSH_TELEMETRY_DISABLED: '1' },
expectedExitCode: 130,
timeoutMs: 15_000,
prepare: async (cwd) => {
const home = join(cwd, '.dsh')
await mkdir(home, { recursive: true })
await writeFile(join(home, 'config.yaml'), [
'- insert:',
' - id: never-dispose',
` name: '${neverDisposePlugin}'`,
'',
].join('\n'))
},
actions: [
{ waitFor: 'dsh: observing at ', send: '\u0003' },
{ waitFor: 'dsh-test: never-dispose started', send: '\u0003' },
],
})
expect(output).toContain('dsh: observing at ')
expect(output).toContain('dsh-test: never-dispose started')
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
})

View File

@@ -0,0 +1,102 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
createProcessShutdown,
PROCESS_SHUTDOWN_TIMEOUT_MS,
} from '../src/process-shutdown.ts'
function deferred(): { promise: Promise<void>; resolve: () => void; reject: (error: Error) => void } {
let resolve!: () => void
let reject!: (error: Error) => void
const promise = new Promise<void>((accept, fail) => {
resolve = accept
reject = fail
})
return { promise, resolve, reject }
}
afterEach(() => { vi.useRealTimers() })
describe('process shutdown', () => {
it('exits once after graceful disposal resolves or rejects', async () => {
const resolvedExit = vi.fn()
const resolved = createProcessShutdown(() => Promise.resolve(), resolvedExit)
await resolved.shutdown(0)
expect(resolvedExit).toHaveBeenCalledOnce()
expect(resolvedExit).toHaveBeenCalledWith(0)
const rejectedExit = vi.fn()
const rejected = createProcessShutdown(() => Promise.reject(new Error('dispose failed')), rejectedExit)
await rejected.shutdown(1)
expect(rejectedExit).toHaveBeenCalledOnce()
expect(rejectedExit).toHaveBeenCalledWith(1)
})
it('forces exit when graceful disposal reaches its bound', async () => {
vi.useFakeTimers()
const disposal = deferred()
const exit = vi.fn()
const shutdown = createProcessShutdown(() => disposal.promise, exit)
const pending = shutdown.shutdown(0)
await vi.advanceTimersByTimeAsync(PROCESS_SHUTDOWN_TIMEOUT_MS - 1)
expect(exit).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(1)
expect(exit).toHaveBeenCalledOnce()
expect(exit).toHaveBeenCalledWith(0)
disposal.resolve()
await pending
expect(exit).toHaveBeenCalledOnce()
})
it('lets Ctrl+C force a normal shutdown already stuck in disposal', async () => {
const disposal = deferred()
const exit = vi.fn()
const shutdown = createProcessShutdown(() => disposal.promise, exit)
const pending = shutdown.shutdown(0)
shutdown.interrupt(130)
expect(exit).toHaveBeenCalledOnce()
expect(exit).toHaveBeenCalledWith(130)
disposal.resolve()
await pending
expect(exit).toHaveBeenCalledOnce()
})
it('drains on the first signal and forces on the second signal', async () => {
const disposal = deferred()
const dispose = vi.fn(() => disposal.promise)
const exit = vi.fn()
const shutdown = createProcessShutdown(dispose, exit)
shutdown.interrupt(143)
await Promise.resolve()
expect(dispose).toHaveBeenCalledOnce()
expect(exit).not.toHaveBeenCalled()
shutdown.interrupt(130)
expect(exit).toHaveBeenCalledOnce()
expect(exit).toHaveBeenCalledWith(130)
disposal.resolve()
await shutdown.shutdown(0)
expect(exit).toHaveBeenCalledOnce()
})
it('coalesces normal shutdown calls without treating them as escalation', async () => {
const disposal = deferred()
const exit = vi.fn()
const shutdown = createProcessShutdown(() => disposal.promise, exit)
const first = shutdown.shutdown(0)
const second = shutdown.shutdown(1)
expect(second).toBe(first)
expect(exit).not.toHaveBeenCalled()
disposal.resolve()
await first
expect(exit).toHaveBeenCalledOnce()
expect(exit).toHaveBeenCalledWith(0)
})
})