|
| 1 | +// @effect-diagnostics nodeBuiltinImport:off |
1 | 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
2 | 3 | import { assert, it } from "@effect/vitest"; |
3 | 4 | import * as Effect from "effect/Effect"; |
4 | 5 | import * as FileSystem from "effect/FileSystem"; |
5 | 6 | import * as Path from "effect/Path"; |
| 7 | +import * as NodeChildProcess from "node:child_process"; |
6 | 8 |
|
7 | | -import { Launcher, readServiceState, writeServiceState } from "./serviceLauncher.ts"; |
| 9 | +import { |
| 10 | + Launcher, |
| 11 | + readServiceState, |
| 12 | + requestGracefulChildStop, |
| 13 | + writeServiceState, |
| 14 | +} from "./serviceLauncher.ts"; |
8 | 15 | import { |
9 | 16 | compareExactServiceVersions, |
10 | 17 | decodeServiceState, |
@@ -360,3 +367,57 @@ if (context.update?.status === "pending") { |
360 | 367 | }), |
361 | 368 | ); |
362 | 369 | }); |
| 370 | + |
| 371 | +it.layer(NodeServices.layer)("whole-service graceful stop over IPC", (it) => { |
| 372 | + it.effect("drains the managed child over IPC before force-termination", () => |
| 373 | + Effect.gen(function* () { |
| 374 | + // POSIX keeps its signal-driven finalizer path; the IPC drain is the |
| 375 | + // Windows path where a signal would be a hard kill. The production helper |
| 376 | + // is exercised against a real Node child at the real IPC boundary. |
| 377 | + const fs = yield* FileSystem.FileSystem; |
| 378 | + const path = yield* Path.Path; |
| 379 | + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-launcher-graceful-" }); |
| 380 | + const drainMarker = path.join(root, "drained.txt"); |
| 381 | + // @effect-diagnostics-next-line preferSchemaOverJson:off - embeds a path in the child source. |
| 382 | + const encodedDrainMarker = JSON.stringify(drainMarker); |
| 383 | + const childSource = |
| 384 | + `const { writeFileSync } = require("node:fs");\n` + |
| 385 | + `process.on("message", (message) => {\n` + |
| 386 | + ` if (message && message.type === "stop") {\n` + |
| 387 | + ` setTimeout(() => {\n` + |
| 388 | + ` writeFileSync(${encodedDrainMarker}, "drained");\n` + |
| 389 | + ` process.send({ type: "stopped" });\n` + |
| 390 | + ` process.exit(0);\n` + |
| 391 | + ` }, 25);\n` + |
| 392 | + ` }\n` + |
| 393 | + `});\n` + |
| 394 | + `setInterval(() => {}, 1_000);\n`; |
| 395 | + const child = NodeChildProcess.spawn(process.execPath, ["-e", childSource], { |
| 396 | + stdio: ["ignore", "ignore", "inherit", "ipc"], |
| 397 | + }); |
| 398 | + yield* Effect.promise(() => new Promise<void>((r) => child.once("spawn", () => r()))); |
| 399 | + yield* Effect.promise(() => requestGracefulChildStop(child)); |
| 400 | + yield* Effect.promise(() => new Promise<void>((r) => child.once("exit", () => r()))); |
| 401 | + |
| 402 | + // The child ran its own drain before the helper resolved. |
| 403 | + assert.isTrue(yield* fs.exists(drainMarker)); |
| 404 | + }), |
| 405 | + ); |
| 406 | + |
| 407 | + it.effect("does not wait for a child without a live IPC channel", () => |
| 408 | + Effect.gen(function* () { |
| 409 | + const child = NodeChildProcess.spawn( |
| 410 | + process.execPath, |
| 411 | + ["-e", "setInterval(() => {}, 1_000);"], |
| 412 | + { |
| 413 | + stdio: ["ignore", "ignore", "inherit"], |
| 414 | + }, |
| 415 | + ); |
| 416 | + yield* Effect.promise(() => new Promise<void>((r) => child.once("spawn", () => r()))); |
| 417 | + // No `ipc` stdio: the helper must return without driving a drain and the |
| 418 | + // caller falls back to its bounded force-termination. |
| 419 | + yield* Effect.promise(() => requestGracefulChildStop(child)); |
| 420 | + child.kill(); |
| 421 | + }), |
| 422 | + ); |
| 423 | +}); |
0 commit comments