Merge commit '404501a41ccf2a3b638b1b737087948fe08d5c4c' into codex/product-subagent-presets
# Conflicts: # packages/subagent/subagent-claude-code/tests/real-product.spec.ts
This commit is contained in:
@@ -5,54 +5,73 @@ export const PROCESS_SHUTDOWN_TIMEOUT_MS = 5_000
|
||||
|
||||
/** Process-exit controller shared by normal completion and Unix signal handlers. */
|
||||
export interface ProcessShutdown {
|
||||
/** Start or join graceful disposal before exiting with `code`. */
|
||||
/** Start or join graceful disposal before allowing natural completion with `code`. */
|
||||
shutdown(code: number): Promise<void>
|
||||
/** Start graceful disposal, or force exit when a shutdown is already running. */
|
||||
/** Start graceful disposal followed by exit, or force exit when shutdown is already running. */
|
||||
interrupt(code: number): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Create one process-exit controller around an application disposer.
|
||||
* @param dispose - Whole-application teardown that resolves at quiescence.
|
||||
* @param exit - Process exit boundary, replaceable by tests.
|
||||
* @param forceExit - Forced process exit boundary, replaceable by tests.
|
||||
* @param complete - Natural process completion boundary, replaceable by tests.
|
||||
* @param timeoutMs - Grace before forced exit, replaceable by tests.
|
||||
* @returns A controller whose normal calls coalesce and whose repeated signal call escalates.
|
||||
*/
|
||||
export function createProcessShutdown(
|
||||
dispose: () => Promise<void>,
|
||||
exit: (code: number) => void = (code) => { process.exit(code) },
|
||||
forceExit: (code: number) => void = (code) => { process.exit(code) },
|
||||
complete: (code: number) => void = (code) => { process.exitCode = code },
|
||||
timeoutMs = PROCESS_SHUTDOWN_TIMEOUT_MS,
|
||||
): ProcessShutdown {
|
||||
let pending: Promise<void> | undefined
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined
|
||||
let exited = false
|
||||
let completed = false
|
||||
let forceExited = false
|
||||
|
||||
const exitOnce = (code: number): void => {
|
||||
if (exited) return
|
||||
exited = true
|
||||
const clearExitTimeout = (): void => {
|
||||
/* v8 ignore else -- shutdown() arms the timer before any asynchronous exit path can run. */
|
||||
if (timeout !== undefined) clearTimeout(timeout)
|
||||
exit(code)
|
||||
}
|
||||
|
||||
const shutdown = (code: number): Promise<void> => {
|
||||
const forceExitOnce = (code: number): void => {
|
||||
if (forceExited) return
|
||||
forceExited = true
|
||||
clearExitTimeout()
|
||||
forceExit(code)
|
||||
}
|
||||
|
||||
const completeOnce = (code: number): void => {
|
||||
if (completed || forceExited) return
|
||||
completed = true
|
||||
clearExitTimeout()
|
||||
complete(code)
|
||||
}
|
||||
|
||||
const start = (code: number, forceAfterDispose: boolean): Promise<void> => {
|
||||
if (pending !== undefined) return pending
|
||||
timeout = setTimeout(() => { exitOnce(code) }, timeoutMs)
|
||||
timeout = setTimeout(() => { forceExitOnce(code) }, timeoutMs)
|
||||
pending = Promise.resolve().then(dispose).then(
|
||||
() => { exitOnce(code) },
|
||||
() => { exitOnce(code) },
|
||||
() => {
|
||||
if (forceAfterDispose) forceExitOnce(code)
|
||||
else completeOnce(code)
|
||||
},
|
||||
() => { forceExitOnce(code) },
|
||||
)
|
||||
return pending
|
||||
}
|
||||
|
||||
return {
|
||||
shutdown,
|
||||
shutdown(code) {
|
||||
return start(code, false)
|
||||
},
|
||||
interrupt(code) {
|
||||
if (pending !== undefined) {
|
||||
exitOnce(code)
|
||||
forceExitOnce(code)
|
||||
return
|
||||
}
|
||||
void shutdown(code)
|
||||
void start(code, true)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
import { writeFileSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { Context } from 'cordis'
|
||||
import { FiberState, type Context } from 'cordis'
|
||||
import type { PatchOptions } from '@cordisjs/plugin-include'
|
||||
import { dshHomePath } from '@deepseek-ai/dsh-paths'
|
||||
import {
|
||||
@@ -193,6 +193,11 @@ export interface RunProfileOptions {
|
||||
environment: EnvironmentSnapshot
|
||||
}
|
||||
|
||||
/** Re-throw setup failures unless this invocation's signal already owns shutdown. */
|
||||
function suppressSignalShutdownError(signal: AbortSignal, error: unknown): void {
|
||||
if (!signal.aborted) throw error
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot one profile invocation end to end and leave process lifetime to the
|
||||
* mounted plugins (or to the one-shot runner when `task` is present).
|
||||
@@ -221,11 +226,16 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
|
||||
|
||||
const app: { current?: Context } = {}
|
||||
const shutdown = createProcessShutdown(async () => { await app.current?.fiber.dispose() })
|
||||
const signalShutdown = new AbortController()
|
||||
const interrupt = (code: number): void => {
|
||||
signalShutdown.abort()
|
||||
shutdown.interrupt(code)
|
||||
}
|
||||
// Signals own teardown throughout the startup window, not only after boot()
|
||||
// settles: an inserted front door can publish readiness before sibling rows
|
||||
// finish mounting.
|
||||
process.on('SIGTERM', () => { shutdown.interrupt(options.task === undefined ? 0 : 143) })
|
||||
process.on('SIGINT', () => { shutdown.interrupt(130) })
|
||||
process.on('SIGTERM', () => { interrupt(options.task === undefined ? 0 : 143) })
|
||||
process.on('SIGINT', () => { interrupt(130) })
|
||||
installFailLoud(NAME, process, async () => {
|
||||
await app.current?.fiber.dispose()
|
||||
})
|
||||
@@ -268,33 +278,41 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
|
||||
await options.prepare?.(hostCtx, composed.rows)
|
||||
})
|
||||
app.current = ctx
|
||||
// A surface can dispose the whole tree while startup was still in flight
|
||||
// (early SIGTERM); the Loader service goes with it and there is nothing to
|
||||
// keep live.
|
||||
if (watchProfilePatch && ctx.get('loader') !== undefined) {
|
||||
// Config-only HMR for the live profile patch layer: the web bundle
|
||||
// disables the shared module-reload `hmr` row (its reload lifecycle is
|
||||
// untested), so when the composition leaves no HMR service, mount a
|
||||
// watch-only instance with no module roots — cordis.patch.yml edits stay
|
||||
// live on every long-lived surface. A silent skip would break the
|
||||
// documented hot-reload contract. HMR injects the timer service, which a
|
||||
// bare custom profile may not mount either.
|
||||
if (ctx.get('hmr') === undefined) {
|
||||
if (ctx.get('timer') === undefined) {
|
||||
await ctx.loader.create({ name: '@cordisjs/plugin-timer' })
|
||||
// A surface can dispose the whole tree while startup or this post-boot
|
||||
// watcher setup is still in flight. Loader presence and fiber state own
|
||||
// liveness; the local signal fact distinguishes that expected exit race
|
||||
// from a real HMR error.
|
||||
if (watchProfilePatch
|
||||
&& !signalShutdown.signal.aborted
|
||||
&& ctx.fiber.state === FiberState.ACTIVE
|
||||
&& ctx.get('loader') !== undefined) {
|
||||
try {
|
||||
// Config-only HMR for the live profile patch layer: the web bundle
|
||||
// disables the shared module-reload `hmr` row (its reload lifecycle is
|
||||
// untested), so when the composition leaves no HMR service, mount a
|
||||
// watch-only instance with no module roots — cordis.patch.yml edits stay
|
||||
// live on every long-lived surface. A silent skip would break the
|
||||
// documented hot-reload contract. HMR injects the timer service, which a
|
||||
// bare custom profile may not mount either.
|
||||
if (ctx.get('hmr') === undefined) {
|
||||
if (ctx.get('timer') === undefined) {
|
||||
await ctx.loader.create({ name: '@cordisjs/plugin-timer' })
|
||||
}
|
||||
await ctx.loader.create({ name: '@cordisjs/plugin-hmr', config: { root: [] } })
|
||||
}
|
||||
await ctx.loader.create({ name: '@cordisjs/plugin-hmr', config: { root: [] } })
|
||||
await watchUserPatches(ctx, {
|
||||
binName: NAME,
|
||||
filename: composed.profile.patchPath,
|
||||
compose: composeLive,
|
||||
})
|
||||
await watchUserPatches(ctx, {
|
||||
binName: NAME,
|
||||
filename: homePatchPath(),
|
||||
compose: composeLive,
|
||||
})
|
||||
} catch (error) {
|
||||
suppressSignalShutdownError(signalShutdown.signal, error)
|
||||
}
|
||||
await watchUserPatches(ctx, {
|
||||
binName: NAME,
|
||||
filename: composed.profile.patchPath,
|
||||
compose: composeLive,
|
||||
})
|
||||
await watchUserPatches(ctx, {
|
||||
binName: NAME,
|
||||
filename: homePatchPath(),
|
||||
compose: composeLive,
|
||||
})
|
||||
}
|
||||
return { ctx, shutdown }
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ interface ProfileLifecycleFixture {
|
||||
ready: string
|
||||
settled: string
|
||||
disposed: string
|
||||
interrupt: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -61,16 +62,23 @@ function createProfileLifecycleFixture(): ProfileLifecycleFixture {
|
||||
const ready = join(home, 'ready')
|
||||
const settled = join(home, 'settled')
|
||||
const disposed = join(home, 'disposed')
|
||||
const interrupt = join(home, 'interrupt')
|
||||
const bundleDir = join(home, 'lifecycle-bundle')
|
||||
mkdirSync(bundleDir, { recursive: true })
|
||||
writeFileSync(join(bundleDir, 'plugin.mjs'), [
|
||||
"import { writeFileSync } from 'node:fs'",
|
||||
"import { existsSync, writeFileSync } from 'node:fs'",
|
||||
"import { join } from 'node:path'",
|
||||
"export const name = 'profile-lifecycle-fixture'",
|
||||
'export function apply(ctx, config = {}) {',
|
||||
' let active = true',
|
||||
' // Keep the event loop alive so process lifetime is signal-owned, like a real surface.',
|
||||
' const heartbeat = setInterval(() => {}, 1000)',
|
||||
' // Windows has no deliverable SIGTERM; the marker emits the same process event there.',
|
||||
' let interrupted = false',
|
||||
' const heartbeat = setInterval(() => {',
|
||||
' if (interrupted || !existsSync(process.env.RAW_INTERRUPT_FILE)) return',
|
||||
' interrupted = true',
|
||||
" process.emit('SIGTERM')",
|
||||
' }, 20)',
|
||||
' // Echo the mounted generation so the hot-reload e2e can assert both an',
|
||||
' // applied override and its removal reverting to this bundle default.',
|
||||
" writeFileSync(join(process.env.DSH_HOME, 'config-echo'), String(config.generation ?? 'bundle-default'))",
|
||||
@@ -118,7 +126,7 @@ function createProfileLifecycleFixture(): ProfileLifecycleFixture {
|
||||
for (const file of ['package.json', 'cordis.patch.yml', 'plugin.mjs']) {
|
||||
writeFileSync(join(linkTarget, file), readFileSync(join(bundleDir, file)))
|
||||
}
|
||||
return { home, ready, settled, disposed }
|
||||
return { home, ready, settled, disposed, interrupt }
|
||||
}
|
||||
|
||||
function startProfileLifecycle(fixture: ProfileLifecycleFixture) {
|
||||
@@ -131,10 +139,22 @@ function startProfileLifecycle(fixture: ProfileLifecycleFixture) {
|
||||
RAW_READY_FILE: fixture.ready,
|
||||
RAW_SETTLED_FILE: fixture.settled,
|
||||
RAW_DISPOSED_FILE: fixture.disposed,
|
||||
RAW_INTERRUPT_FILE: fixture.interrupt,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function requestProfileShutdown(
|
||||
child: ReturnType<typeof startProfileLifecycle>,
|
||||
fixture: ProfileLifecycleFixture,
|
||||
): void {
|
||||
if (process.platform === 'win32') {
|
||||
writeFileSync(fixture.interrupt, 'interrupt')
|
||||
return
|
||||
}
|
||||
child.kill('SIGTERM')
|
||||
}
|
||||
|
||||
function createEnvironmentProbeProfile(home: string, project: string): void {
|
||||
const pluginFile = join(project, 'environment-probe.mjs')
|
||||
writeFileSync(pluginFile, [
|
||||
@@ -152,7 +172,8 @@ function createEnvironmentProbeProfile(home: string, project: string): void {
|
||||
" if (chunk.type === 'text-delta') text += chunk.text",
|
||||
' }',
|
||||
' process.stdout.write(`${text}\\n`)',
|
||||
" process.kill(process.pid, 'SIGTERM')",
|
||||
" if (process.platform === 'win32') process.emit('SIGTERM')",
|
||||
" else process.kill(process.pid, 'SIGTERM')",
|
||||
' })',
|
||||
'}',
|
||||
'',
|
||||
@@ -321,9 +342,9 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
const child = startProfileLifecycle(fixture)
|
||||
try {
|
||||
await waitForFile(fixture.ready)
|
||||
child.kill('SIGTERM')
|
||||
requestProfileShutdown(child, fixture)
|
||||
const result = await child
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.exitCode, `${result.stderr}\nstdout:\n${result.stdout}\nsignal: ${String(result.signal)}`).toBe(0)
|
||||
expect(result.signal).toBeUndefined()
|
||||
expect(existsSync(fixture.disposed)).toBe(true)
|
||||
} finally {
|
||||
@@ -373,9 +394,9 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
].join('\n'))
|
||||
await waitForFile(fixture.ready)
|
||||
expect(readFileSync(configFile, 'utf8')).toBe('home')
|
||||
child.kill('SIGTERM')
|
||||
requestProfileShutdown(child, fixture)
|
||||
const result = await child
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.exitCode, `${result.stderr}\nstdout:\n${result.stdout}\nsignal: ${String(result.signal)}`).toBe(0)
|
||||
expect(result.signal).toBeUndefined()
|
||||
expect(existsSync(fixture.disposed)).toBe(true)
|
||||
} finally {
|
||||
|
||||
@@ -20,35 +20,50 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('process shutdown', () => {
|
||||
it('exits once after graceful disposal resolves or rejects', async () => {
|
||||
it('completes naturally after disposal resolves and forces exit when it rejects', async () => {
|
||||
const resolvedExit = vi.fn()
|
||||
const resolved = createProcessShutdown(() => Promise.resolve(), resolvedExit)
|
||||
const resolvedComplete = vi.fn()
|
||||
const resolved = createProcessShutdown(() => Promise.resolve(), resolvedExit, resolvedComplete)
|
||||
await resolved.shutdown(0)
|
||||
expect(resolvedExit).toHaveBeenCalledOnce()
|
||||
expect(resolvedExit).toHaveBeenCalledWith(0)
|
||||
expect(resolvedComplete).toHaveBeenCalledOnce()
|
||||
expect(resolvedComplete).toHaveBeenCalledWith(0)
|
||||
expect(resolvedExit).not.toHaveBeenCalled()
|
||||
|
||||
const rejectedExit = vi.fn()
|
||||
const rejected = createProcessShutdown(() => Promise.reject(new Error('dispose failed')), rejectedExit)
|
||||
const rejectedComplete = vi.fn()
|
||||
const rejected = createProcessShutdown(
|
||||
() => Promise.reject(new Error('dispose failed')),
|
||||
rejectedExit,
|
||||
rejectedComplete,
|
||||
)
|
||||
await rejected.shutdown(1)
|
||||
expect(rejectedExit).toHaveBeenCalledOnce()
|
||||
expect(rejectedExit).toHaveBeenCalledWith(1)
|
||||
expect(rejectedComplete).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses process.exit as the default process boundary', async () => {
|
||||
it('uses process.exitCode for default normal completion', async () => {
|
||||
const exit = vi.spyOn(process, 'exit').mockImplementation(_code => undefined as never)
|
||||
const originalExitCode = process.exitCode
|
||||
process.exitCode = undefined
|
||||
const shutdown = createProcessShutdown(() => Promise.resolve())
|
||||
|
||||
await shutdown.shutdown(7)
|
||||
try {
|
||||
await shutdown.shutdown(7)
|
||||
|
||||
expect(exit).toHaveBeenCalledOnce()
|
||||
expect(exit).toHaveBeenCalledWith(7)
|
||||
expect(process.exitCode).toBe(7)
|
||||
expect(exit).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
process.exitCode = originalExitCode
|
||||
}
|
||||
})
|
||||
|
||||
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 complete = vi.fn()
|
||||
const shutdown = createProcessShutdown(() => disposal.promise, exit, complete)
|
||||
const pending = shutdown.shutdown(0)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(PROCESS_SHUTDOWN_TIMEOUT_MS - 1)
|
||||
@@ -60,13 +75,14 @@ describe('process shutdown', () => {
|
||||
disposal.resolve()
|
||||
await pending
|
||||
expect(exit).toHaveBeenCalledOnce()
|
||||
expect(complete).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('honors a caller-supplied grace period', async () => {
|
||||
vi.useFakeTimers()
|
||||
const disposal = deferred()
|
||||
const exit = vi.fn()
|
||||
const shutdown = createProcessShutdown(() => disposal.promise, exit, 25)
|
||||
const shutdown = createProcessShutdown(() => disposal.promise, exit, vi.fn(), 25)
|
||||
const pending = shutdown.shutdown(0)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(24)
|
||||
@@ -81,7 +97,8 @@ describe('process shutdown', () => {
|
||||
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 complete = vi.fn()
|
||||
const shutdown = createProcessShutdown(() => disposal.promise, exit, complete)
|
||||
const pending = shutdown.shutdown(0)
|
||||
|
||||
shutdown.interrupt(130)
|
||||
@@ -91,13 +108,29 @@ describe('process shutdown', () => {
|
||||
disposal.resolve()
|
||||
await pending
|
||||
expect(exit).toHaveBeenCalledOnce()
|
||||
expect(complete).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('forces exit after disposal started by a signal', async () => {
|
||||
const disposal = deferred()
|
||||
const exit = vi.fn()
|
||||
const complete = vi.fn()
|
||||
const shutdown = createProcessShutdown(() => disposal.promise, exit, complete)
|
||||
|
||||
shutdown.interrupt(143)
|
||||
disposal.resolve()
|
||||
await shutdown.shutdown(0)
|
||||
|
||||
expect(exit).toHaveBeenCalledOnce()
|
||||
expect(exit).toHaveBeenCalledWith(143)
|
||||
expect(complete).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
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)
|
||||
const shutdown = createProcessShutdown(dispose, exit, vi.fn())
|
||||
|
||||
shutdown.interrupt(143)
|
||||
await Promise.resolve()
|
||||
@@ -116,7 +149,8 @@ describe('process shutdown', () => {
|
||||
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 complete = vi.fn()
|
||||
const shutdown = createProcessShutdown(() => disposal.promise, exit, complete)
|
||||
|
||||
const first = shutdown.shutdown(0)
|
||||
const second = shutdown.shutdown(1)
|
||||
@@ -125,7 +159,21 @@ describe('process shutdown', () => {
|
||||
|
||||
disposal.resolve()
|
||||
await first
|
||||
expect(complete).toHaveBeenCalledOnce()
|
||||
expect(complete).toHaveBeenCalledWith(0)
|
||||
expect(exit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('lets a signal force exit while natural completion drains remaining handles', async () => {
|
||||
const exit = vi.fn()
|
||||
const complete = vi.fn()
|
||||
const shutdown = createProcessShutdown(() => Promise.resolve(), exit, complete)
|
||||
|
||||
await shutdown.shutdown(0)
|
||||
shutdown.interrupt(130)
|
||||
|
||||
expect(complete).toHaveBeenCalledOnce()
|
||||
expect(exit).toHaveBeenCalledOnce()
|
||||
expect(exit).toHaveBeenCalledWith(0)
|
||||
expect(exit).toHaveBeenCalledWith(130)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user