Files
Termix/src/backend/tests/plugins/shutdown.test.ts
T

159 lines
4.7 KiB
TypeScript

/**
* Core owns shutdown.
*
* gracefulShutdown in starter.ts calls shutdownPlugins(), which runs every
* plugin's deactivate and disposes what it registered. A plugin does not need
* its own process signal handler, and must not have one: the docker console
* used to install a SIGTERM listener that called process.exit(0), which
* skipped the rest of the sequence including other plugins and the database
* flush.
*/
import { afterEach, describe, expect, it, vi } from "vitest";
import fs from "node:fs";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import { PluginLoader } from "../../plugins/loader.js";
import { createFixturePlugin } from "./fixture-plugin.js";
vi.mock("../../utils/logger.js", () => ({
pluginLogger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
success: vi.fn(),
},
}));
vi.mock("../../plugins/permissions.js", () => ({
assertCapability: vi.fn().mockResolvedValue(undefined),
hasCapability: vi.fn().mockResolvedValue(true),
PluginCapabilityError: class extends Error {},
invalidatePluginPermissionCache: vi.fn(),
}));
// A fixed port can land in a range Windows reserves for Hyper-V or Docker.
function freePort(): Promise<number> {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
const { port } = server.address() as net.AddressInfo;
server.close(() => resolve(port));
});
});
}
const CONSOLE_PORT = await freePort();
const cleanups: Array<() => void> = [];
function tempRoot(prefix: string): string {
const root = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
cleanups.push(() => fs.rmSync(root, { recursive: true, force: true }));
return root;
}
function isListening(port: number): Promise<boolean> {
return new Promise((resolve) => {
const socket = net.connect({ port, host: "127.0.0.1" });
socket.once("connect", () => {
socket.destroy();
resolve(true);
});
socket.once("error", () => resolve(false));
});
}
afterEach(() => {
delete process.env.TERMIX_BUNDLED_PLUGINS_DIR;
delete process.env.DATA_DIR;
while (cleanups.length) cleanups.pop()?.();
});
/**
* Shaped like the docker plugin: a REST server and a console WebSocket
* server, both started inside activate and closed through disposables.
*/
const DOCKER_LIKE = `
import net from "node:net";
export async function activate(ctx) {
const console = net.createServer();
await new Promise((resolve) =>
console.listen(${CONSOLE_PORT}, "127.0.0.1", resolve),
);
ctx.disposables.add(
() => new Promise((resolve) => console.close(() => resolve())),
);
}
`;
describe("shutdownPlugins closes what plugins own", () => {
it("closes a console-style WebSocket server on shutdown", async () => {
const bundled = tempRoot("termix-bundled-");
const user = tempRoot("termix-data-");
createFixturePlugin({
id: "docker",
root: bundled,
backendSource: DOCKER_LIKE,
});
process.env.TERMIX_BUNDLED_PLUGINS_DIR = bundled;
process.env.DATA_DIR = user;
const loader = new PluginLoader();
await loader.loadAll();
await loader.activate("docker");
expect(await isListening(CONSOLE_PORT)).toBe(true);
await loader.shutdown();
expect(await isListening(CONSOLE_PORT)).toBe(false);
});
// One plugin failing to shut down cleanly must not strand the others,
// which is the other half of why core drives this rather than each plugin.
it("keeps going when one plugin's deactivate throws", async () => {
const marker = path.join(tempRoot("termix-marker-"), "stopped.txt");
const bundled = tempRoot("termix-bundled-");
const user = tempRoot("termix-data-");
createFixturePlugin({
id: "alpha",
root: bundled,
backendSource: `
export async function activate() {}
export async function deactivate() {
throw new Error("alpha refuses to stop");
}
`,
});
createFixturePlugin({
id: "beta",
root: bundled,
backendSource: `
import fs from "node:fs";
export async function activate() {}
export async function deactivate() {
fs.appendFileSync(${JSON.stringify(marker)}, "beta stopped\\n");
}
`,
});
process.env.TERMIX_BUNDLED_PLUGINS_DIR = bundled;
process.env.DATA_DIR = user;
const loader = new PluginLoader();
await loader.loadAll();
await loader.activateAll(["alpha", "beta"]);
await loader.shutdown();
expect(loader.get("alpha")?.state).toBe("stopped");
expect(loader.get("beta")?.state).toBe("stopped");
expect(fs.readFileSync(marker, "utf8")).toBe("beta stopped\n");
});
});