222 lines
8.8 KiB
TypeScript
222 lines
8.8 KiB
TypeScript
// 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)
|
|
})
|