14
apps/cli/tests/fixtures/never-dispose.mjs
vendored
Normal file
14
apps/cli/tests/fixtures/never-dispose.mjs
vendored
Normal 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(() => {})
|
||||
})
|
||||
}
|
||||
43
apps/cli/tests/headless-shutdown.e2e.ts
Normal file
43
apps/cli/tests/headless-shutdown.e2e.ts
Normal 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)
|
||||
})
|
||||
102
apps/cli/tests/process-shutdown.spec.ts
Normal file
102
apps/cli/tests/process-shutdown.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user