@@ -0,0 +1,221 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// Run the real dev entry point with synthetic build/database/platform dependencies.
|
||||
import { type ChildProcess, spawn } from "node:child_process"
|
||||
import {
|
||||
chmodSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
writeFileSync,
|
||||
} from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { afterEach, expect, it } from "vitest"
|
||||
|
||||
const source = readFileSync(fileURLToPath(new URL("../scripts/dev.mjs", import.meta.url)), "utf8")
|
||||
let directory: string
|
||||
let parent: ChildProcess | undefined
|
||||
const owned = new Set<number>()
|
||||
const alive = (pid: number) => {
|
||||
try {
|
||||
process.kill(pid, 0)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
const delay = () => new Promise((resolve) => setTimeout(resolve, 20))
|
||||
async function until(check: () => boolean, milliseconds = 5_000) {
|
||||
const deadline = Date.now() + milliseconds
|
||||
while (!check()) {
|
||||
if (Date.now() > deadline) throw new Error("synthetic dev process did not reach the expected state")
|
||||
await delay()
|
||||
}
|
||||
}
|
||||
afterEach(async () => {
|
||||
parent?.kill("SIGKILL")
|
||||
for (const pid of owned) {
|
||||
try {
|
||||
process.kill(pid, "SIGKILL")
|
||||
} catch {
|
||||
// Only synthetic task-owned children; a completed child may already be gone.
|
||||
}
|
||||
}
|
||||
owned.clear()
|
||||
if (directory) rmSync(directory, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function boot(
|
||||
stage: "migrate" | "seed",
|
||||
outcome: "wait" | "fail" | "signal" | "missing" | "stubborn" | "orphan" = "wait",
|
||||
probeError?: "EPERM" | "EIO",
|
||||
) {
|
||||
directory = mkdtempSync(join(tmpdir(), "app-dev-shutdown-"))
|
||||
for (const folder of ["scripts", "bin", "apps/worker/dist"]) mkdirSync(join(directory, folder), { recursive: true })
|
||||
// Only substitute the external platform dependency; all process management is unchanged.
|
||||
writeFileSync(
|
||||
join(directory, "scripts/dev.mjs"),
|
||||
source.replace('"@conusai-kit/testing/fake-platform"', '"./platform.mjs"'),
|
||||
)
|
||||
writeFileSync(
|
||||
join(directory, "scripts/platform.mjs"),
|
||||
`
|
||||
import { writeFileSync } from "node:fs"
|
||||
export const fakeNativeServices = () => ({ deployment: {} })
|
||||
export const validReply = () => ({})
|
||||
export const startFakePlatform = async () => ({
|
||||
url: "synthetic", ai: { respond() {} },
|
||||
async close() { writeFileSync(${JSON.stringify(join(directory, "closed"))}, "closed") }
|
||||
})
|
||||
`,
|
||||
)
|
||||
const pnpm = join(directory, "bin/pnpm")
|
||||
writeFileSync(pnpm, `#!${process.execPath}\nprocess.exit(0)\n`)
|
||||
chmodSync(pnpm, 0o755)
|
||||
if (outcome !== "missing") symlinkSync(process.execPath, join(directory, "bin/node"))
|
||||
const pidFile = join(directory, "child.pid")
|
||||
const grandchildFile = join(directory, "grandchild.pid")
|
||||
const onTerm =
|
||||
outcome === "stubborn"
|
||||
? "() => {}"
|
||||
: `() => setTimeout(() => {
|
||||
require("node:fs").writeFileSync(${JSON.stringify(join(directory, "child-ended"))}, "ended")
|
||||
process.exit(0)
|
||||
}, 30)`
|
||||
const grandchild = `
|
||||
${outcome === "stubborn" || outcome === "orphan" ? 'process.on("SIGTERM", () => {});' : ""}
|
||||
require("node:fs").writeFileSync(${JSON.stringify(grandchildFile)}, String(process.pid))
|
||||
setInterval(() => {}, 1000)
|
||||
`
|
||||
const waiting = `
|
||||
const { writeFileSync } = require("node:fs")
|
||||
const { spawn } = require("node:child_process")
|
||||
process.on("SIGTERM", ${onTerm})
|
||||
writeFileSync(${JSON.stringify(pidFile)}, String(process.pid))
|
||||
spawn(process.execPath, ["-e", ${JSON.stringify(grandchild)}], { stdio: "inherit" })
|
||||
setInterval(() => {}, 1000)
|
||||
`
|
||||
const body =
|
||||
outcome === "fail" ? "process.exit(7)" : outcome === "signal" ? 'process.kill(process.pid, "SIGTERM")' : waiting
|
||||
writeFileSync(join(directory, "apps/worker/dist/migrate.js"), stage === "migrate" ? body : "process.exit(0)")
|
||||
writeFileSync(
|
||||
join(directory, "apps/worker/dist/seed-main.js"),
|
||||
`require("node:fs").writeFileSync(${JSON.stringify(join(directory, "seed-started"))}, "started");\n${body}`,
|
||||
)
|
||||
const probeLog = join(directory, "probes")
|
||||
const preload = join(directory, "probe.mjs")
|
||||
if (probeError) {
|
||||
// Inject only an existence-probe error for a group the actual entry point has signalled.
|
||||
// Real spawning, TERM delivery, child termination and the production wait bounds remain intact.
|
||||
writeFileSync(
|
||||
preload,
|
||||
`
|
||||
import { appendFileSync } from "node:fs"
|
||||
const kill = process.kill.bind(process)
|
||||
let group
|
||||
let denied = 0
|
||||
process.kill = (pid, signal) => {
|
||||
if (pid === group && signal === 0 && denied < 3) {
|
||||
denied += 1
|
||||
appendFileSync(${JSON.stringify(probeLog)}, ${JSON.stringify(`${probeError}\n`)})
|
||||
throw Object.assign(new Error("synthetic existence probe failure"), { code: ${JSON.stringify(probeError)} })
|
||||
}
|
||||
const result = kill(pid, signal)
|
||||
if (pid < 0 && signal === "SIGTERM") group = pid
|
||||
return result
|
||||
}
|
||||
`,
|
||||
)
|
||||
}
|
||||
const args = [...(probeError ? ["--import", preload] : []), join(directory, "scripts/dev.mjs")]
|
||||
const child = spawn(process.execPath, args, {
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: join(directory, "bin"),
|
||||
POSTGRES_URL: "synthetic",
|
||||
POSTGRES_READER_URL: "synthetic",
|
||||
POSTGRES_MIGRATE_URL: "synthetic",
|
||||
},
|
||||
stdio: ["ignore", "ignore", "pipe"],
|
||||
})
|
||||
let stderr = ""
|
||||
child.stderr?.on("data", (chunk) => {
|
||||
stderr += String(chunk)
|
||||
})
|
||||
parent = child
|
||||
const exited = new Promise<number | null>((resolve, reject) => {
|
||||
child.once("exit", resolve)
|
||||
child.once("error", reject)
|
||||
})
|
||||
return { child, exited, pidFile, grandchildFile, probeLog, stderr: () => stderr }
|
||||
}
|
||||
|
||||
it.each([
|
||||
["migrate", "SIGTERM"],
|
||||
["seed", "SIGTERM"],
|
||||
["migrate", "SIGINT"],
|
||||
["seed", "SIGINT"],
|
||||
] as const)("%s cancellation by %s stops its child and descendants before further startup", async (stage, signal) => {
|
||||
const { child, exited, pidFile, grandchildFile } = boot(stage)
|
||||
await until(() => existsSync(pidFile) && existsSync(grandchildFile))
|
||||
const pids = [pidFile, grandchildFile].map((file) => Number(readFileSync(file, "utf8")))
|
||||
for (const pid of pids) owned.add(pid)
|
||||
child.kill(signal)
|
||||
expect(await exited).toBe(0)
|
||||
expect(existsSync(join(directory, "child-ended"))).toBe(true)
|
||||
await until(() => pids.every((pid) => !alive(pid)))
|
||||
expect(existsSync(join(directory, "closed"))).toBe(true)
|
||||
if (stage === "migrate") expect(existsSync(join(directory, "seed-started"))).toBe(false)
|
||||
})
|
||||
|
||||
it.each(["fail", "signal", "missing"] as const)(
|
||||
"preserves failure handling when the startup child has outcome %s",
|
||||
async (outcome) => {
|
||||
const { exited } = boot("migrate", outcome)
|
||||
expect(await exited).toBe(outcome === "fail" ? 7 : 1)
|
||||
expect(existsSync(join(directory, "seed-started"))).toBe(false)
|
||||
},
|
||||
)
|
||||
|
||||
// Use the actual production grace (35s), not a shortened test-only shutdown policy.
|
||||
it.each(["stubborn", "orphan"] as const)(
|
||||
"escalates cancellation when the startup process is %s",
|
||||
async (outcome) => {
|
||||
const { child, exited, pidFile, grandchildFile } = boot("migrate", outcome)
|
||||
await until(() => existsSync(pidFile) && existsSync(grandchildFile))
|
||||
const pids = [pidFile, grandchildFile].map((file) => Number(readFileSync(file, "utf8")))
|
||||
for (const pid of pids) owned.add(pid)
|
||||
const sentinel = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { detached: true, stdio: "ignore" })
|
||||
if (sentinel.pid === undefined) throw new Error("synthetic unrelated process did not start")
|
||||
owned.add(sentinel.pid)
|
||||
const began = performance.now()
|
||||
child.kill("SIGTERM")
|
||||
await until(() => child.exitCode !== null || child.signalCode !== null, 42_000)
|
||||
expect(await exited).toBe(0)
|
||||
expect(pids.every((pid) => !alive(pid))).toBe(true)
|
||||
expect(performance.now() - began).toBeGreaterThanOrEqual(35_000)
|
||||
expect(alive(sentinel.pid)).toBe(true)
|
||||
expect(existsSync(join(directory, "seed-started"))).toBe(false)
|
||||
expect(existsSync(join(directory, "closed"))).toBe(true)
|
||||
},
|
||||
50_000,
|
||||
)
|
||||
|
||||
it.each(["EPERM", "EIO"] as const)("handles existence-probe %s without treating it as absence", async (code) => {
|
||||
const { child, exited, pidFile, grandchildFile, probeLog, stderr } = boot("migrate", "wait", code)
|
||||
await until(() => existsSync(pidFile) && existsSync(grandchildFile))
|
||||
for (const file of [pidFile, grandchildFile]) owned.add(Number(readFileSync(file, "utf8")))
|
||||
child.kill("SIGTERM")
|
||||
expect(await exited, stderr()).toBe(code === "EPERM" ? 0 : 1)
|
||||
expect(existsSync(probeLog)).toBe(true)
|
||||
if (code === "EPERM") {
|
||||
// EPERM must keep the group pending, not let cleanup declare it absent immediately.
|
||||
expect(readFileSync(probeLog, "utf8").trim().split("\n")).toEqual(["EPERM", "EPERM", "EPERM"])
|
||||
expect(existsSync(join(directory, "closed"))).toBe(true)
|
||||
}
|
||||
expect(existsSync(join(directory, "seed-started"))).toBe(false)
|
||||
})
|
||||
Reference in New Issue
Block a user