Skip to content

Commit d5dece4

Browse files
committed
fix(compat/node): Runtime loading and graceful shutdown (#5)
- fix packaged Node runtime module resolution by anchoring `createRequire(...)` to cwd-backed runtime files and add a packaging assertion to catch DNT import-meta ponyfill regressions - fix graceful `SIGINT` shutdown in Node-style hosts by preserving signal listeners during graceful teardown and explicitly exiting the process once teardown completes - add unit, integration, and subprocess smoke coverage for first-press graceful shutdown behavior - [x] `deno test src/index.test.ts src/services/runtime-teardown.test.ts src/services/runtime-teardown.smoke.test.ts src/services/session-mcp-runtime.test.ts` - [x] `deno test --allow-run --allow-read=\"src/services/runtime-teardown.smoke-fixture.ts\" src/services/runtime-teardown.smoke.test.ts` - [x] `deno task check` - [x] `deno task lint` - [x] `deno task fmt --check`
1 parent f1c6612 commit d5dece4

8 files changed

Lines changed: 457 additions & 91 deletions

packaging.test.ts

Lines changed: 116 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,23 @@
11
import { assertEquals } from "jsr:@std/assert@^1.0.0";
2+
import { fromFileUrl } from "jsr:@std/path@^1.0.0/from-file-url";
23
import { join } from "node:path";
34
import { pathToFileURL } from "node:url";
45

56
const workspaceRoot = new URL(".", import.meta.url);
6-
const workspacePath = workspaceRoot.pathname;
7+
const workspacePath = fromFileUrl(workspaceRoot);
8+
const packagingRunPermissions = await Promise.all([
9+
Deno.permissions.query({ name: "run", command: "deno" }),
10+
Deno.permissions.query({ name: "run", command: "node" }),
11+
Deno.permissions.query({
12+
name: "run",
13+
command: Deno.build.os === "windows" ? "where" : "which",
14+
}),
15+
Deno.permissions.query({ name: "run", command: "bun" }),
16+
]);
17+
const packagingRunPermissionGranted = packagingRunPermissions.every(
18+
(permission, index) => index === 3 || permission.state === "granted",
19+
);
20+
const bunRunPermissionGranted = packagingRunPermissions[3]?.state === "granted";
721

822
const decodeText = (value: Uint8Array): string =>
923
new TextDecoder().decode(value);
@@ -40,91 +54,114 @@ const run = async (
4054
};
4155
};
4256

43-
Deno.test("built npm package loads in node through the published ESM entrypoint", async () => {
44-
const build = await run("deno", ["task", "build"]);
45-
assertEquals(build.code, 0, build.stderr || build.stdout);
57+
Deno.test({
58+
name: "built npm package loads in node through the published ESM entrypoint",
59+
ignore: !packagingRunPermissionGranted,
60+
fn: async () => {
61+
const build = await run("deno", ["task", "build"]);
62+
assertEquals(build.code, 0, build.stderr || build.stdout);
4663

47-
const builtPackage = JSON.parse(
48-
await Deno.readTextFile(join(workspacePath, "dist/package.json")),
49-
) as {
50-
dependencies?: Record<string, string>;
51-
devDependencies?: Record<string, string>;
52-
};
53-
assertEquals(
54-
builtPackage.dependencies?.cosmiconfig,
55-
"^9.0.0",
56-
"generated npm package must declare cosmiconfig for runtime config loading",
57-
);
58-
assertEquals(
59-
typeof builtPackage.devDependencies?.["@types/node"],
60-
"string",
61-
"generated npm package must declare Node typings for dnt typecheck",
62-
);
64+
const builtPackage = JSON.parse(
65+
await Deno.readTextFile(join(workspacePath, "dist/package.json")),
66+
) as {
67+
dependencies?: Record<string, string>;
68+
devDependencies?: Record<string, string>;
69+
};
70+
const builtConfig = await Deno.readTextFile(
71+
join(workspacePath, "dist/esm/src/config.js"),
72+
);
73+
assertEquals(
74+
builtPackage.dependencies?.cosmiconfig,
75+
"^9.0.0",
76+
"generated npm package must declare cosmiconfig for runtime config loading",
77+
);
78+
assertEquals(
79+
typeof builtPackage.devDependencies?.["@types/node"],
80+
"string",
81+
"generated npm package must declare Node typings for dnt typecheck",
82+
);
83+
assertEquals(
84+
builtConfig.includes("import-meta-ponyfill-esmodule"),
85+
false,
86+
"generated config loader should not depend on DNT import-meta ponyfill",
87+
);
6388

64-
const tempDir = await Deno.makeTempDir();
65-
try {
66-
const esmRunnerPath = join(tempDir, "load-esm.mjs");
67-
const bunRunnerPath = join(tempDir, "load-bun.mjs");
68-
const esmEntrypoint =
69-
pathToFileURL(join(workspacePath, "dist/esm/mod.js")).href;
70-
const packageDir = join(tempDir, "node_modules", "opencode-graphiti");
71-
const isolatedHome = join(tempDir, "home");
72-
const isolatedConfig = join(isolatedHome, ".config", "opencode");
89+
const tempDir = await Deno.makeTempDir();
90+
try {
91+
let optionalOpenCodePath: string | undefined;
92+
try {
93+
optionalOpenCodePath = Deno.env.get("OPENCODE_BIN") ?? undefined;
94+
} catch {
95+
optionalOpenCodePath = undefined;
96+
}
7397

74-
await Deno.mkdir(join(tempDir, "node_modules"), { recursive: true });
75-
await Deno.mkdir(isolatedConfig, { recursive: true });
76-
await Deno.symlink(join(workspacePath, "dist"), packageDir, {
77-
type: "dir",
78-
});
98+
const esmRunnerPath = join(tempDir, "load-esm.mjs");
99+
const bunRunnerPath = join(tempDir, "load-bun.mjs");
100+
const esmEntrypoint =
101+
pathToFileURL(join(workspacePath, "dist/esm/mod.js")).href;
102+
const packageDir = join(tempDir, "node_modules", "opencode-graphiti");
103+
const isolatedHome = join(tempDir, "home");
104+
const isolatedConfig = join(isolatedHome, ".config", "opencode");
79105

80-
await Deno.writeTextFile(
81-
esmRunnerPath,
82-
`import * as plugin from ${
83-
JSON.stringify(esmEntrypoint)
84-
};\nconsole.log(JSON.stringify(Object.keys(plugin).sort()));\n`,
85-
);
86-
await Deno.writeTextFile(
87-
bunRunnerPath,
88-
'import * as plugin from "opencode-graphiti";\n' +
89-
"console.log(JSON.stringify(Object.keys(plugin).sort()));\n",
90-
);
106+
await Deno.mkdir(join(tempDir, "node_modules"), { recursive: true });
107+
await Deno.mkdir(isolatedConfig, { recursive: true });
108+
await Deno.symlink(join(workspacePath, "dist"), packageDir, {
109+
type: "dir",
110+
});
91111

92-
const esmLoad = await run("node", [esmRunnerPath]);
93-
assertEquals(esmLoad.code, 0, esmLoad.stderr || esmLoad.stdout);
94-
assertEquals(esmLoad.stdout.trim(), '["graphiti"]');
112+
await Deno.writeTextFile(
113+
esmRunnerPath,
114+
`import * as plugin from ${
115+
JSON.stringify(esmEntrypoint)
116+
};\nconsole.log(JSON.stringify(Object.keys(plugin).sort()));\n`,
117+
);
118+
await Deno.writeTextFile(
119+
bunRunnerPath,
120+
'import * as plugin from "opencode-graphiti";\n' +
121+
"console.log(JSON.stringify(Object.keys(plugin).sort()));\n",
122+
);
95123

96-
if (await commandExists("bun")) {
97-
const bunLoad = await run("bun", [bunRunnerPath], tempDir);
98-
assertEquals(bunLoad.code, 0, bunLoad.stderr || bunLoad.stdout);
99-
assertEquals(bunLoad.stdout.trim(), '["graphiti"]');
100-
}
124+
const esmLoad = await run("node", [esmRunnerPath]);
125+
assertEquals(esmLoad.code, 0, esmLoad.stderr || esmLoad.stdout);
126+
assertEquals(esmLoad.stdout.trim(), '["graphiti"]');
101127

102-
const localOpenCodePath = "/Users/vicary/.opencode/bin/opencode";
103-
try {
104-
const opencodeInfo = await Deno.stat(localOpenCodePath);
105-
if (opencodeInfo.isFile) {
106-
const isolatedOpenCode = await new Deno.Command(localOpenCodePath, {
107-
args: ["--print-logs", "stats"],
108-
cwd: workspacePath,
109-
env: {
110-
HOME: isolatedHome,
111-
XDG_CONFIG_HOME: join(isolatedHome, ".config"),
112-
},
113-
stdout: "piped",
114-
stderr: "piped",
115-
}).output();
116-
const isolatedOpenCodeOutput = decodeText(isolatedOpenCode.stdout) +
117-
decodeText(isolatedOpenCode.stderr);
118-
assertEquals(
119-
isolatedOpenCodeOutput.includes("Missing 'default' export"),
120-
false,
121-
isolatedOpenCodeOutput,
122-
);
128+
if (bunRunPermissionGranted && await commandExists("bun")) {
129+
const bunLoad = await run("bun", [bunRunnerPath], tempDir);
130+
assertEquals(bunLoad.code, 0, bunLoad.stderr || bunLoad.stdout);
131+
assertEquals(bunLoad.stdout.trim(), '["graphiti"]');
123132
}
124-
} catch {
125-
// OpenCode is not available in CI; keep the portable package checks above.
133+
134+
if (optionalOpenCodePath) {
135+
try {
136+
const opencodeInfo = await Deno.stat(optionalOpenCodePath);
137+
if (opencodeInfo.isFile) {
138+
const isolatedOpenCode = await new Deno.Command(
139+
optionalOpenCodePath,
140+
{
141+
args: ["--print-logs", "stats"],
142+
cwd: workspacePath,
143+
env: {
144+
HOME: isolatedHome,
145+
XDG_CONFIG_HOME: join(isolatedHome, ".config"),
146+
},
147+
stdout: "piped",
148+
stderr: "piped",
149+
},
150+
).output();
151+
const isolatedOpenCodeOutput = decodeText(isolatedOpenCode.stdout) +
152+
decodeText(isolatedOpenCode.stderr);
153+
assertEquals(
154+
isolatedOpenCodeOutput.includes("Missing 'default' export"),
155+
false,
156+
isolatedOpenCodeOutput,
157+
);
158+
}
159+
} catch {
160+
// OPENCODE_BIN is optional; keep the portable package checks above.
161+
}
162+
}
163+
} finally {
164+
await Deno.remove(tempDir, { recursive: true }).catch(() => undefined);
126165
}
127-
} finally {
128-
await Deno.remove(tempDir, { recursive: true }).catch(() => undefined);
129-
}
166+
},
130167
});

src/config.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import os from "node:os";
22
import { createRequire } from "node:module";
33
import { join } from "node:path";
4+
import process from "node:process";
45
import { redactEndpointUserInfo } from "./services/endpoint-redaction.ts";
56
import { notifyPluginWarning } from "./services/opencode-warning.ts";
67
import type { GraphitiConfig, RawGraphitiConfig } from "./types/index.ts";
@@ -60,7 +61,9 @@ export interface ConfigExplorerAdapter {
6061

6162
type ConfigExplorerFactory = () => ConfigExplorerAdapter;
6263

63-
const nodeRequire = createRequire(import.meta.url);
64+
const nodeRequire = createRequire(
65+
join(process.cwd(), "graphiti.config.runtime.cjs"),
66+
);
6467

6568
const isRecord = (value: unknown): value is Record<string, unknown> =>
6669
!!value && typeof value === "object" && !Array.isArray(value);

src/index.test.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
warnOnRedisStartupUnavailable,
1111
} from "./index.ts";
1212
import { logger } from "./services/logger.ts";
13+
import { registerRuntimeTeardown } from "./services/runtime-teardown.ts";
1314
import {
1415
setOpenCodeClient,
1516
setWarningTaskScheduler,
@@ -1359,5 +1360,75 @@ describe("index", () => {
13591360
"redis",
13601361
]);
13611362
});
1363+
1364+
it("gracefully shuts down on first SIGINT in a node-style host runtime", async () => {
1365+
const { input, records, dependencies } = createEntrypointHarness(true);
1366+
const signalHandlers = new Map<"SIGINT" | "SIGTERM", () => void>();
1367+
const processEventHandlers = new Map<"beforeExit" | "exit", () => void>();
1368+
const exitCalls: number[] = [];
1369+
let exitReject!: (reason?: unknown) => void;
1370+
const exitPromise = new Promise<never>((_, reject) => {
1371+
exitReject = reject;
1372+
});
1373+
1374+
const runtime = {
1375+
process: {
1376+
on(event: string, handler: () => void) {
1377+
if (event === "SIGINT" || event === "SIGTERM") {
1378+
signalHandlers.set(event, handler);
1379+
return;
1380+
}
1381+
if (event === "beforeExit" || event === "exit") {
1382+
processEventHandlers.set(event, handler);
1383+
}
1384+
},
1385+
off(event: string, _handler: () => void) {
1386+
if (event === "SIGINT" || event === "SIGTERM") {
1387+
signalHandlers.delete(event);
1388+
return;
1389+
}
1390+
if (event === "beforeExit" || event === "exit") {
1391+
processEventHandlers.delete(event);
1392+
}
1393+
},
1394+
exit(code?: number) {
1395+
exitCalls.push(code ?? 0);
1396+
exitReject(new Error(`exit:${code ?? 0}`));
1397+
return undefined as never;
1398+
},
1399+
exitCode: undefined,
1400+
},
1401+
};
1402+
1403+
await invokeGraphiti(input, {
1404+
...dependencies,
1405+
registerRuntimeTeardown: (
1406+
tasks: Array<{
1407+
name: string;
1408+
run: () => void | Promise<void>;
1409+
}>,
1410+
) => registerRuntimeTeardown(tasks, runtime),
1411+
});
1412+
1413+
await assertRejects(
1414+
async () => {
1415+
signalHandlers.get("SIGINT")?.();
1416+
await exitPromise;
1417+
},
1418+
Error,
1419+
"exit:130",
1420+
);
1421+
1422+
assertEquals(records.teardownTaskRuns, [
1423+
"graphiti-drain-flush",
1424+
"graphiti-async",
1425+
"session-mcp-runtime",
1426+
"graphiti",
1427+
"redis",
1428+
]);
1429+
assertEquals(exitCalls, [130]);
1430+
assertEquals(signalHandlers.size, 0);
1431+
assertEquals(processEventHandlers.size, 0);
1432+
});
13621433
});
13631434
});

src/services/connection-manager.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { createRequire } from "node:module";
2+
import { join } from "node:path";
23
import { pathToFileURL } from "node:url";
4+
import process from "node:process";
35
import manifest from "../../deno.json" with { type: "json" };
46
import { isAbortError } from "../utils.ts";
57
import { redactEndpointUserInfo } from "./endpoint-redaction.ts";
@@ -26,7 +28,9 @@ type McpRuntimeModules = {
2628
StreamableHTTPClientTransport: McpTransportConstructor;
2729
};
2830

29-
const nodeRequire = createRequire(import.meta.url);
31+
const nodeRequire = createRequire(
32+
pathToFileURL(join(process.cwd(), "graphiti.runtime.cjs")).href,
33+
);
3034
let mcpRuntimeModulesPromise: Promise<McpRuntimeModules> | null = null;
3135

3236
const importResolvedModule = async <T>(specifier: string): Promise<T> => {
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import process from "node:process";
2+
import { registerRuntimeTeardown } from "./runtime-teardown.ts";
3+
4+
const keepAlive = setInterval(() => {}, 1_000);
5+
6+
registerRuntimeTeardown([
7+
{
8+
name: "flush",
9+
run: async () => {
10+
await new Promise((resolve) => setTimeout(resolve, 25));
11+
clearInterval(keepAlive);
12+
process.stdout.write("teardown-run\n");
13+
},
14+
},
15+
], {
16+
process: {
17+
on: process.on.bind(process),
18+
off: process.off.bind(process),
19+
exit: process.exit.bind(process),
20+
},
21+
});
22+
23+
process.stdout.write("ready\n");

0 commit comments

Comments
 (0)