@@ -0,0 +1,131 @@
|
||||
// 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)
|
||||
})
|
||||
Reference in New Issue
Block a user