@classytic/arc-testkit 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/mongo.mjs CHANGED
@@ -1,5 +1,286 @@
1
- import { cpus, freemem, totalmem } from "node:os";
2
- //#region src/mongo.ts
1
+ import { t as registerProcessCleanup } from "./process-cleanup-1iBIS_o_.mjs";
2
+ import { rmSync } from "node:fs";
3
+ import { join, resolve } from "node:path";
4
+ import { cpus, freemem, tmpdir, totalmem } from "node:os";
5
+ import { mkdir, mkdtemp, readFile, readdir, rm, stat } from "node:fs/promises";
6
+ // src/mongo-tmp.ts
7
+ /** Prefix mongodb-memory-server gives its data dirs (and that ours use). */
8
+ const MONGO_MEM_DIR_PREFIX = "mongo-mem-";
9
+ /** Replica-set keyfile dirs: no lock file to prove them dead, so never swept. */
10
+ const KEYFILE_DIR_PREFIX = "mongo-mem-keyfile-";
11
+ const HOUR_MS = 36e5;
12
+ const DEFAULT_MAX_AGE_HOURS = 6;
13
+ /** The dedicated base dir from `TEST_MONGO_TMPDIR`, resolved, or undefined. */
14
+ function mongoMemoryTmpBase() {
15
+ const raw = process.env.TEST_MONGO_TMPDIR?.trim();
16
+ return raw ? resolve(raw) : void 0;
17
+ }
18
+ /** Minimum age (ms) of a dir the sweep may remove; `null` when the sweep is off. */
19
+ function staleMongoMemoryAgeMs() {
20
+ const raw = process.env.TEST_MONGO_SWEEP_HOURS?.trim().toLowerCase();
21
+ if (raw === "0" || raw === "off" || raw === "false") return null;
22
+ const hours = Number(raw);
23
+ return raw && Number.isFinite(hours) && hours > 0 ? hours * HOUR_MS : DEFAULT_MAX_AGE_HOURS * HOUR_MS;
24
+ }
25
+ /** Whether a process with this pid exists. EPERM means it exists but is not ours. */
26
+ function isPidAlive(pid) {
27
+ if (!Number.isInteger(pid) || pid <= 0) return false;
28
+ try {
29
+ process.kill(pid, 0);
30
+ return true;
31
+ } catch (err) {
32
+ return err.code === "EPERM";
33
+ }
34
+ }
35
+ /** The pid in a data dir's `mongod.lock`. mongod empties it on a clean shutdown. */
36
+ async function lockPid(dir) {
37
+ try {
38
+ const pid = Number.parseInt((await readFile(join(dir, "mongod.lock"), "utf8")).trim(), 10);
39
+ return Number.isInteger(pid) && pid > 0 ? pid : void 0;
40
+ } catch {
41
+ return;
42
+ }
43
+ }
44
+ /**
45
+ * Newest mtime of the dir and its direct entries. The dir's own mtime changes
46
+ * only when an entry is added or removed; a live mongod's activity shows in its
47
+ * files (WiredTiger.wt, sizeStorer.wt, the journal), so those count too.
48
+ */
49
+ async function newestMtimeMs(dir) {
50
+ let newest = (await stat(dir)).mtimeMs;
51
+ let names = [];
52
+ try {
53
+ names = await readdir(dir);
54
+ } catch {}
55
+ for (const name of names) try {
56
+ const { mtimeMs } = await stat(join(dir, name));
57
+ if (mtimeMs > newest) newest = mtimeMs;
58
+ } catch {}
59
+ return newest;
60
+ }
61
+ function defaultSweepDirs() {
62
+ const base = mongoMemoryTmpBase();
63
+ return base ? [tmpdir(), base] : [tmpdir()];
64
+ }
65
+ /**
66
+ * Remove abandoned `mongo-mem-*` data dirs.
67
+ *
68
+ * A dir is removed only when BOTH hold: its `mongod.lock` names no live process
69
+ * (or is empty or missing), and nothing in it changed for `maxAgeMs`. Keyfile
70
+ * dirs and anything without the prefix are never touched. Errors are collected,
71
+ * never thrown: a sweep must not fail a test run.
72
+ */
73
+ async function sweepStaleMongoMemoryDirs(options = {}) {
74
+ const result = {
75
+ removed: [],
76
+ kept: [],
77
+ failed: []
78
+ };
79
+ const maxAgeMs = options.maxAgeMs === void 0 ? staleMongoMemoryAgeMs() : options.maxAgeMs;
80
+ if (maxAgeMs === null) return result;
81
+ const now = options.now ?? Date.now();
82
+ const alive = options.isAlive ?? isPidAlive;
83
+ const bases = new Set((options.dirs ?? defaultSweepDirs()).map((d) => resolve(d)));
84
+ for (const base of bases) {
85
+ let entries;
86
+ try {
87
+ entries = await readdir(base, { withFileTypes: true });
88
+ } catch {
89
+ continue;
90
+ }
91
+ for (const entry of entries) {
92
+ if (!entry.isDirectory()) continue;
93
+ if (!entry.name.startsWith("mongo-mem-")) continue;
94
+ if (entry.name.startsWith(KEYFILE_DIR_PREFIX)) continue;
95
+ const path = join(base, entry.name);
96
+ try {
97
+ const pid = await lockPid(path);
98
+ if (pid !== void 0 && alive(pid)) {
99
+ result.kept.push({
100
+ path,
101
+ reason: "live-process"
102
+ });
103
+ continue;
104
+ }
105
+ if (now - await newestMtimeMs(path) < maxAgeMs) {
106
+ result.kept.push({
107
+ path,
108
+ reason: "recent"
109
+ });
110
+ continue;
111
+ }
112
+ await rm(path, {
113
+ recursive: true,
114
+ force: true,
115
+ maxRetries: 2,
116
+ retryDelay: 100
117
+ });
118
+ result.removed.push(path);
119
+ } catch (err) {
120
+ result.failed.push({
121
+ path,
122
+ error: err.message
123
+ });
124
+ }
125
+ }
126
+ }
127
+ return result;
128
+ }
129
+ let processSweep;
130
+ /**
131
+ * Run the sweep at most once per process, in the background. Started before a
132
+ * process boots its first server; never awaited there, because a first sweep
133
+ * after a long leak can have gigabytes to delete. Logs one line when it removed
134
+ * anything, so a machine that keeps leaking is noticed.
135
+ */
136
+ function sweepStaleMongoMemoryDirsOnce(label = "arc-testkit") {
137
+ processSweep ??= sweepStaleMongoMemoryDirs().then((r) => {
138
+ if (r.removed.length > 0) console.warn(`[${label}] removed ${r.removed.length} abandoned mongo-mem-* temp dir(s) left by killed or crashed test runs`);
139
+ return r;
140
+ }, () => ({
141
+ removed: [],
142
+ kept: [],
143
+ failed: []
144
+ }));
145
+ return processSweep;
146
+ }
147
+ async function newDataDir(base) {
148
+ await mkdir(base, { recursive: true });
149
+ return mkdtemp(join(base, MONGO_MEM_DIR_PREFIX));
150
+ }
151
+ /**
152
+ * Point every member at a fresh dir under `base`, unless the caller set its own
153
+ * `dbPath` / `instanceOpts` (then the caller owns that location).
154
+ */
155
+ async function withDedicatedDataDirs(kind, opts, base, owned) {
156
+ if (kind === "standalone") {
157
+ const instance = opts.instance ?? {};
158
+ if (instance.dbPath !== void 0) return opts;
159
+ const dbPath = await newDataDir(base);
160
+ owned.push(dbPath);
161
+ return {
162
+ ...opts,
163
+ instance: {
164
+ ...instance,
165
+ dbPath
166
+ }
167
+ };
168
+ }
169
+ const existing = opts.instanceOpts;
170
+ if (existing && existing.length > 0) return opts;
171
+ const count = Number(opts.replSet?.count ?? 1);
172
+ const instanceOpts = [];
173
+ for (let i = 0; i < Math.max(1, count); i += 1) {
174
+ const dbPath = await newDataDir(base);
175
+ owned.push(dbPath);
176
+ instanceOpts.push({ dbPath });
177
+ }
178
+ return {
179
+ ...opts,
180
+ instanceOpts
181
+ };
182
+ }
183
+ function instancesOf(server) {
184
+ const members = server.servers;
185
+ return Array.isArray(members) ? members : [server];
186
+ }
187
+ const sleepCell = new Int32Array(new SharedArrayBuffer(4));
188
+ /** Synchronous sleep, for the `exit` path where no timer fires. */
189
+ function sleepSync(ms) {
190
+ Atomics.wait(sleepCell, 0, 0, ms);
191
+ }
192
+ /**
193
+ * Remove a dir synchronously, retrying for up to `timeoutMs`. A killed mongod
194
+ * is reported gone before Windows releases its file handles, so the first
195
+ * attempts can fail with EPERM / EBUSY.
196
+ */
197
+ function removeDirSync(dir, timeoutMs = 0) {
198
+ const deadline = Date.now() + timeoutMs;
199
+ for (;;) try {
200
+ rmSync(dir, {
201
+ recursive: true,
202
+ force: true
203
+ });
204
+ return;
205
+ } catch {
206
+ if (Date.now() >= deadline) return;
207
+ sleepSync(50);
208
+ }
209
+ }
210
+ /** Block until none of `pids` is alive, at most `timeoutMs`. */
211
+ function waitForExitSync(pids, timeoutMs) {
212
+ const deadline = Date.now() + timeoutMs;
213
+ while (pids.some(isPidAlive) && Date.now() < deadline) sleepSync(25);
214
+ }
215
+ /**
216
+ * Create and start a `MongoMemoryServer` (`standalone`) or `MongoMemoryReplSet`
217
+ * (`replset`) with the cleanup guarantees described at the top of this file.
218
+ *
219
+ * `Ctor` is the class itself, so a caller that loads mongodb-memory-server its
220
+ * own way (by resolved path, through an alias) keeps doing so.
221
+ */
222
+ async function launchMongoMemory(Ctor, kind, createOptions = {}) {
223
+ const base = mongoMemoryTmpBase();
224
+ const ownedDirs = [];
225
+ const opts = base ? await withDedicatedDataDirs(kind, createOptions, base, ownedDirs) : createOptions;
226
+ const cleanup = {
227
+ doCleanup: true,
228
+ force: ownedDirs.length > 0
229
+ };
230
+ const server = new Ctor(opts);
231
+ try {
232
+ await server.start();
233
+ } catch (err) {
234
+ await server.stop(cleanup).catch(() => void 0);
235
+ for (const dir of ownedDirs) removeDirSync(dir, 2e3);
236
+ throw err;
237
+ }
238
+ let stopped = false;
239
+ return {
240
+ server,
241
+ ownedDirs,
242
+ async stop() {
243
+ if (stopped) return;
244
+ stopped = true;
245
+ if (await server.stop(cleanup) !== false) for (const dir of ownedDirs) removeDirSync(dir);
246
+ },
247
+ stopSync() {
248
+ if (stopped) return;
249
+ stopped = true;
250
+ const dirs = new Set(ownedDirs);
251
+ const pids = [];
252
+ for (const member of instancesOf(server)) {
253
+ const info = member.instanceInfo;
254
+ const pid = info?.instance?.mongodProcess?.pid;
255
+ if (pid) {
256
+ pids.push(pid);
257
+ try {
258
+ process.kill(pid, "SIGKILL");
259
+ } catch {}
260
+ }
261
+ if (info?.tmpDir) dirs.add(info.tmpDir);
262
+ }
263
+ waitForExitSync(pids, 5e3);
264
+ for (const dir of dirs) removeDirSync(dir, 5e3);
265
+ }
266
+ };
267
+ }
268
+ /**
269
+ * Stop a launched server when the process ends without its teardown: Ctrl-C /
270
+ * SIGTERM stop it properly, then re-raise so the exit code stays truthful; an
271
+ * `exit` (including an uncaught exception) kills mongod and removes the dir
272
+ * synchronously. Returns an untrack function for the orderly path.
273
+ *
274
+ * Uses the package's ONE shared process hook, so tracking many servers adds no
275
+ * listeners beyond one per event.
276
+ */
277
+ function stopOnProcessExit(handle) {
278
+ return registerProcessCleanup({
279
+ stop: () => handle.stop(),
280
+ stopSync: () => handle.stopSync()
281
+ });
282
+ }
283
+ // src/mongo.ts
3
284
  /**
4
285
  * `@classytic/arc-testkit/mongo` — the Mongo adapter for the driver-agnostic pool.
5
286
  *
@@ -138,16 +419,22 @@ function mongoServerProvider(options = {}) {
138
419
  * half-the-machine cache default exactly where nobody is watching.
139
420
  */
140
421
  const create = opts?.createOptions;
141
- const server = kind === "transactional" ? await mms.MongoMemoryReplSet.create(create ?? { replSet: {
422
+ sweepStaleMongoMemoryDirsOnce();
423
+ /**
424
+ * Started through `launchMongoMemory`, not `create()`: it always stops
425
+ * with cleanup, cleans up a failed start (mongodb-memory-server keeps
426
+ * that dir "for investigation"), honours `TEST_MONGO_TMPDIR`, and gives
427
+ * the pool a synchronous `stopSync` for its process `exit` handler.
428
+ */
429
+ const launched = kind === "transactional" ? await launchMongoMemory(mms.MongoMemoryReplSet, "replset", create ?? { replSet: {
142
430
  count: 1,
143
431
  storageEngine: "wiredTiger",
144
432
  args
145
- } }) : await mms.MongoMemoryServer.create(create ?? { instance: { args } });
433
+ } }) : await launchMongoMemory(mms.MongoMemoryServer, "standalone", create ?? { instance: { args } });
146
434
  return {
147
- uri: server.getUri(),
148
- stop: async () => {
149
- await server.stop();
150
- }
435
+ uri: launched.server.getUri(),
436
+ stop: () => launched.stop(),
437
+ stopSync: () => launched.stopSync()
151
438
  };
152
439
  },
153
440
  /**
@@ -225,5 +512,4 @@ function mongoServerProvider(options = {}) {
225
512
  }
226
513
  };
227
514
  }
228
- //#endregion
229
- export { awaitIndexBuilds, cacheSizeGB, mongoServerProvider, mongodMemoryArgs, safePoolSize, safeWorkerCount, withDbName };
515
+ export { MONGO_MEM_DIR_PREFIX, awaitIndexBuilds, cacheSizeGB, isPidAlive, launchMongoMemory, mongoMemoryTmpBase, mongoServerProvider, mongodMemoryArgs, safePoolSize, safeWorkerCount, staleMongoMemoryAgeMs, stopOnProcessExit, sweepStaleMongoMemoryDirs, withDbName };
package/dist/pool.d.mts CHANGED
@@ -1,12 +1,17 @@
1
1
  import { TestDatabase, TestDatabaseFactory } from "@classytic/arc/testing";
2
- //#region src/pool.d.ts
3
2
  /** Which server shape a suite needs. */
4
- type ServerKind = "standalone" | "transactional";
3
+ export type ServerKind = "standalone" | "transactional";
5
4
  /** A running server the pool may hand out namespaces on. */
6
- interface TestServerHandle {
5
+ export interface TestServerHandle {
7
6
  /** Base connection string, WITHOUT a namespace selected. */
8
7
  readonly uri: string;
9
8
  stop(): Promise<void>;
9
+ /**
10
+ * Optional: best-effort SYNCHRONOUS stop, for a process `exit` handler where
11
+ * no async work completes. Without it an `exit` only starts `stop()`, the
12
+ * process ends before it finishes, and the server's data dir is left behind.
13
+ */
14
+ stopSync?(): void;
10
15
  }
11
16
  /**
12
17
  * What a driver must supply. Two methods, deliberately.
@@ -15,7 +20,7 @@ interface TestServerHandle {
15
20
  * is the same in every driver, so putting it here would duplicate it per driver
16
21
  * — which is precisely the failure this module exists to end.
17
22
  */
18
- interface TestServerProvider<TConn> {
23
+ export interface TestServerProvider<TConn> {
19
24
  /** Human name, used only in diagnostics. */
20
25
  readonly name: string;
21
26
  /**
@@ -67,7 +72,7 @@ interface TestServerProvider<TConn> {
67
72
  */
68
73
  afterBoot?(): Promise<void>;
69
74
  }
70
- interface TestDatabasePoolOptions {
75
+ export interface TestDatabasePoolOptions {
71
76
  /**
72
77
  * Env-var prefix for the cross-worker handshake. Two runs with different
73
78
  * prefixes (a spine run and a host run) can share a machine without either
@@ -145,7 +150,7 @@ interface TestDatabasePoolOptions {
145
150
  /** Escape hatch: force per-suite private servers (the unpooled behaviour). */
146
151
  readonly disabled?: boolean;
147
152
  }
148
- interface TestDatabasePool<TConn> {
153
+ export interface TestDatabasePool<TConn> {
149
154
  /** Hand to `bootModuleApp({ database })`. */
150
155
  readonly factory: TestDatabaseFactory<TConn>;
151
156
  /** Wrap a boot function so it defaults to the pool. */
@@ -178,13 +183,11 @@ interface TestDatabasePool<TConn> {
178
183
  live: number;
179
184
  };
180
185
  }
181
- declare function createTestDatabasePool<TConn>(provider: TestServerProvider<TConn>, options?: TestDatabasePoolOptions): TestDatabasePool<TConn>;
186
+ export declare function createTestDatabasePool<TConn>(provider: TestServerProvider<TConn>, options?: TestDatabasePoolOptions): TestDatabasePool<TConn>;
182
187
  /**
183
188
  * Publish a started server so other worker PROCESSES reuse it.
184
189
  *
185
190
  * Only a process that owns servers calls this — typically an eager
186
191
  * `globalSetup` in a run that prefers boot-cost-up-front over laziness.
187
192
  */
188
- declare function publishPoolServers(servers: Partial<Record<ServerKind, readonly string[]>>, envPrefix?: string): void;
189
- //#endregion
190
- export { ServerKind, TestDatabasePool, TestDatabasePoolOptions, TestServerHandle, TestServerProvider, createTestDatabasePool, publishPoolServers };
193
+ export declare function publishPoolServers(servers: Partial<Record<ServerKind, readonly string[]>>, envPrefix?: string): void;
package/dist/pool.mjs CHANGED
@@ -1,8 +1,9 @@
1
- import { randomBytes } from "node:crypto";
1
+ import { n as unregisterProcessCleanup, t as registerProcessCleanup } from "./process-cleanup-1iBIS_o_.mjs";
2
2
  import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
- import { tmpdir } from "node:os";
4
3
  import { join } from "node:path";
5
- //#region src/pool.ts
4
+ import { randomBytes } from "node:crypto";
5
+ import { tmpdir } from "node:os";
6
+ // src/pool.ts
6
7
  /**
7
8
  * `@classytic/arc-testkit/pool` — a POOLED `TestDatabaseFactory`, driver-agnostic.
8
9
  *
@@ -207,35 +208,37 @@ function createTestDatabasePool(provider, options = {}) {
207
208
  * started on a machine with less memory than the one before.
208
209
  *
209
210
  * `exit` handlers must be SYNCHRONOUS, so the async `stop()` is not awaited
210
- * there; it is a best-effort kill on the way out. The signal handlers get to
211
- * await properly, which is the path that actually matters for Ctrl-C.
211
+ * there; a handle with `stopSync()` (the Mongo adapter's) is killed and its
212
+ * data dir removed synchronously, any other gets a best-effort `stop()`.
213
+ * The signal handlers get to await properly, which is the path that actually
214
+ * matters for Ctrl-C.
215
+ */
216
+ const processCleanup = {
217
+ stop: () => stopAll(),
218
+ stopSync: () => {
219
+ const handles = [...started.values(), ...privateHandles];
220
+ started.clear();
221
+ starting.clear();
222
+ privateHandles.clear();
223
+ for (const handle of handles) if (handle.stopSync) handle.stopSync();
224
+ else handle.stop();
225
+ }
226
+ };
227
+ /**
228
+ * Registered with the package's ONE shared process hook rather than adding
229
+ * listeners per pool: a suite that builds many pools would otherwise pass
230
+ * Node's listener limit (`MaxListenersExceededWarning`). Unregistered by
231
+ * `stopAll()`, re-registered on the next server this pool owns.
212
232
  */
213
- let exitHooked = false;
214
233
  function hookProcessExit() {
215
- if (exitHooked) return;
216
- exitHooked = true;
217
- const onSignal = (signal) => {
218
- stopAll().finally(() => {
219
- process.removeListener(signal, onSignal);
220
- process.kill(process.pid, signal);
221
- });
222
- };
223
- for (const signal of [
224
- "SIGINT",
225
- "SIGTERM",
226
- "SIGHUP",
227
- "SIGBREAK"
228
- ]) process.once(signal, onSignal);
229
- process.once("exit", () => {
230
- for (const handle of started.values()) handle.stop();
231
- for (const handle of privateHandles) handle.stop();
232
- });
234
+ registerProcessCleanup(processCleanup);
233
235
  }
234
236
  async function stopAll() {
235
237
  const handles = [...started.values(), ...privateHandles];
236
238
  started.clear();
237
239
  starting.clear();
238
240
  privateHandles.clear();
241
+ unregisterProcessCleanup(processCleanup);
239
242
  await Promise.all(handles.map((h) => h.stop().catch(() => void 0)));
240
243
  }
241
244
  const factory = async (opts) => {
@@ -406,5 +409,4 @@ function publishPoolServers(servers, envPrefix = "ARC_TESTKIT") {
406
409
  }
407
410
  writeFileSync(file, JSON.stringify(servers));
408
411
  }
409
- //#endregion
410
412
  export { createTestDatabasePool, publishPoolServers };
@@ -0,0 +1,66 @@
1
+ // src/process-cleanup.ts
2
+ /** Signals that end a test run; SIGBREAK is Windows Ctrl-Break. */
3
+ const CLEANUP_SIGNALS = [
4
+ "SIGINT",
5
+ "SIGTERM",
6
+ "SIGHUP",
7
+ "SIGBREAK"
8
+ ];
9
+ const STATE_KEY = Symbol.for("@classytic/arc-testkit/process-cleanup");
10
+ function state() {
11
+ const g = globalThis;
12
+ const existing = g[STATE_KEY];
13
+ if (existing) return existing;
14
+ const entries = /* @__PURE__ */ new Set();
15
+ const created = {
16
+ entries,
17
+ hooked: false,
18
+ onSignal: (signal) => {
19
+ const all = [...entries];
20
+ entries.clear();
21
+ unhook(created);
22
+ Promise.all(all.map((e) => e.stop().catch(() => void 0))).finally(() => {
23
+ process.kill(process.pid, signal);
24
+ });
25
+ },
26
+ onExit: () => {
27
+ const all = [...entries];
28
+ entries.clear();
29
+ unhook(created);
30
+ for (const e of all) try {
31
+ e.stopSync();
32
+ } catch {}
33
+ }
34
+ };
35
+ g[STATE_KEY] = created;
36
+ return created;
37
+ }
38
+ function hook(s) {
39
+ if (s.hooked) return;
40
+ s.hooked = true;
41
+ for (const signal of CLEANUP_SIGNALS) process.on(signal, s.onSignal);
42
+ process.on("exit", s.onExit);
43
+ }
44
+ function unhook(s) {
45
+ if (!s.hooked) return;
46
+ s.hooked = false;
47
+ for (const signal of CLEANUP_SIGNALS) process.removeListener(signal, s.onSignal);
48
+ process.removeListener("exit", s.onExit);
49
+ }
50
+ /**
51
+ * Stop `entry` if the process ends before it is unregistered. Registering the
52
+ * same entry twice is a no-op. Returns the unregister function for the
53
+ * orderly path; once nothing is registered the process listeners are removed.
54
+ */
55
+ function registerProcessCleanup(entry) {
56
+ const s = state();
57
+ s.entries.add(entry);
58
+ hook(s);
59
+ return () => unregisterProcessCleanup(entry);
60
+ }
61
+ function unregisterProcessCleanup(entry) {
62
+ const s = state();
63
+ s.entries.delete(entry);
64
+ if (s.entries.size === 0) unhook(s);
65
+ }
66
+ export { unregisterProcessCleanup as n, registerProcessCleanup as t };