Merge remote-tracking branch 'origin/master' into worktree/schedule-conversational-after

This commit is contained in:
Tianyi Cui
2026-08-09 23:12:14 +08:00
40 changed files with 542 additions and 131 deletions

View File

@@ -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 {
boot,
@@ -168,6 +168,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).
@@ -196,11 +201,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()
})
@@ -243,33 +253,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 }
}

View File

@@ -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 {