132 lines
5.2 KiB
TypeScript
132 lines
5.2 KiB
TypeScript
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
|
// SIGTERM, which the platform sends before it replaces a container. The real `server.mjs` runs against a stand-in
|
|
// for the Start build: the order is stop taking requests, finish the ones in flight, drain the runtime (usage queue,
|
|
// exporters, pools), and only then exit. A process that exits on its own signal handler loses the queue.
|
|
|
|
import { type ChildProcess, spawn } from "node:child_process"
|
|
import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"
|
|
import { join } from "node:path"
|
|
import { fileURLToPath } from "node:url"
|
|
import { afterEach, describe, expect, it } from "vitest"
|
|
|
|
const WEB = fileURLToPath(new URL("../apps/web/", import.meta.url))
|
|
|
|
/** The stand-in: `/slow` holds a request, `/start` builds the "runtime", which registers its drain as app.ts does. */
|
|
const BUILD = `
|
|
import { appendFileSync } from "node:fs"
|
|
const log = process.env.SHUTDOWN_LOG
|
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
const note = (line) => appendFileSync(log, line + "\\n")
|
|
const drainMs = Number(process.env.SHUTDOWN_DRAIN_MS ?? 300)
|
|
export default {
|
|
async fetch(request) {
|
|
const { pathname } = new URL(request.url)
|
|
if (pathname === "/start") {
|
|
globalThis[Symbol.for("conusai.app.drain")] = async () => {
|
|
note("drain began")
|
|
await sleep(drainMs)
|
|
note("drain ended")
|
|
}
|
|
}
|
|
if (pathname === "/stream") {
|
|
return new Response(new ReadableStream({ start(controller) { controller.enqueue("open\\n") } }))
|
|
}
|
|
if (pathname === "/slow") {
|
|
await sleep(400)
|
|
note("request finished")
|
|
}
|
|
return new Response("ok")
|
|
},
|
|
}
|
|
`
|
|
|
|
let directory: string | undefined
|
|
let server: ChildProcess | undefined
|
|
|
|
afterEach(() => {
|
|
server?.kill("SIGKILL")
|
|
if (directory) rmSync(directory, { recursive: true, force: true })
|
|
directory = undefined
|
|
})
|
|
|
|
/** Starts the real server.mjs beside the stand-in, inside apps/web so that hono resolves as it does in the image. */
|
|
async function boot(
|
|
env: Record<string, string> = {},
|
|
): Promise<{ origin: string; log: string; exited: Promise<number> }> {
|
|
directory = mkdtempSync(join(WEB, ".shutdown-test-"))
|
|
mkdirSync(join(directory, "dist", "server"), { recursive: true })
|
|
mkdirSync(join(directory, "dist", "client"), { recursive: true })
|
|
copyFileSync(join(WEB, "server.mjs"), join(directory, "server.mjs"))
|
|
writeFileSync(join(directory, "dist", "server", "server.js"), BUILD)
|
|
const log = join(directory, "shutdown.log")
|
|
writeFileSync(log, "")
|
|
const port = 34_000 + Math.floor(Math.random() * 20_000)
|
|
const child = spawn(process.execPath, ["server.mjs", "--port", String(port)], {
|
|
cwd: directory,
|
|
env: { ...process.env, HOSTNAME: "127.0.0.1", SHUTDOWN_LOG: log, ...env },
|
|
stdio: "ignore",
|
|
})
|
|
server = child
|
|
const exited = new Promise<number>((resolve) => child.once("exit", (code) => resolve(code ?? -1)))
|
|
const origin = `http://127.0.0.1:${port}`
|
|
for (let attempt = 0; attempt < 100; attempt += 1) {
|
|
if (
|
|
await fetch(`${origin}/health`).then(
|
|
(answer) => answer.ok,
|
|
() => false,
|
|
)
|
|
)
|
|
return { origin, log, exited }
|
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
}
|
|
throw new Error("server.mjs did not start")
|
|
}
|
|
|
|
const lines = (log: string): string[] => readFileSync(log, "utf8").split("\n").filter(Boolean)
|
|
|
|
describe("the web server on SIGTERM", () => {
|
|
it("drains the runtime to its end before the process exits", async () => {
|
|
const { origin, log, exited } = await boot()
|
|
await fetch(`${origin}/start`)
|
|
server?.kill("SIGTERM")
|
|
expect(await exited).toBe(0)
|
|
expect(lines(log)).toEqual(["drain began", "drain ended"])
|
|
}, 15_000)
|
|
|
|
it("finishes the request in flight before it drains, so its usage is in the queue", async () => {
|
|
const { origin, log, exited } = await boot()
|
|
await fetch(`${origin}/start`)
|
|
const slow = fetch(`${origin}/slow`)
|
|
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
server?.kill("SIGTERM")
|
|
expect((await slow).status).toBe(200)
|
|
expect(await exited).toBe(0)
|
|
expect(lines(log)).toEqual(["request finished", "drain began", "drain ended"])
|
|
}, 15_000)
|
|
|
|
it("does not let a stream that never ends hold the drain past its bound", async () => {
|
|
const { origin, log, exited } = await boot({ SHUTDOWN_REQUESTS_MS: "300" })
|
|
await fetch(`${origin}/start`)
|
|
const stream = await fetch(`${origin}/stream`)
|
|
expect(stream.status).toBe(200)
|
|
server?.kill("SIGTERM")
|
|
expect(await exited).toBe(0)
|
|
expect(lines(log)).toEqual(["drain began", "drain ended"])
|
|
}, 15_000)
|
|
|
|
it("exits at once when no request ever built a runtime", async () => {
|
|
const { log, exited } = await boot()
|
|
server?.kill("SIGTERM")
|
|
expect(await exited).toBe(0)
|
|
expect(lines(log)).toEqual([])
|
|
}, 15_000)
|
|
|
|
it("exits non-zero rather than hang when the drain does not finish", async () => {
|
|
const { origin, log, exited } = await boot({ SHUTDOWN_DRAIN_MS: "60000", SHUTDOWN_FLUSH_MS: "300" })
|
|
await fetch(`${origin}/start`)
|
|
server?.kill("SIGTERM")
|
|
expect(await exited).toBe(1)
|
|
expect(lines(log)).toEqual(["drain began"])
|
|
}, 15_000)
|
|
})
|