@classytic/arc-testkit 0.6.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.d.mts CHANGED
@@ -1,9 +1,121 @@
1
1
  import { TestServerProvider } from "./pool.mjs";
2
2
  import { Connection } from "mongoose";
3
+ /**
4
+ * mongodb-memory-server temp-dir hygiene: start, stop and sweep so that a test
5
+ * run that is killed, crashes or times out does not leave its data dir behind.
6
+ *
7
+ * ## Why this exists
8
+ *
9
+ * mongodb-memory-server creates `mongo-mem-XXXXXX` under `os.tmpdir()` for every
10
+ * server and removes it in `stop()` (`doCleanup: true` is the default since v9).
11
+ * That only covers the orderly path. A run killed from an IDE, a CI cancel, a
12
+ * worker terminated on timeout or a hard crash never reaches `stop()`, and the
13
+ * dir (a full WiredTiger data set, ~15 MB at minimum) stays forever. On one
14
+ * development machine that came to 3,698 dirs, about 57 GB, in `%TEMP%`.
15
+ * mongodb-memory-server also stops a server that FAILED to start with
16
+ * `doCleanup: false` ("for investigation"), so a failed boot leaks one too.
17
+ *
18
+ * Three measures, each one usable on its own:
19
+ *
20
+ * 1. `launchMongoMemory` always stops with cleanup, cleans up after a failed
21
+ * start, and has a synchronous `stopSync()` for a process `exit` handler,
22
+ * where nothing async completes.
23
+ * 2. `sweepStaleMongoMemoryDirs` removes `mongo-mem-*` dirs that are old AND
24
+ * whose `mongod.lock` names no live process. Both conditions, always: a live
25
+ * mongod rewrites its WiredTiger files on every checkpoint (60 s), so its dir
26
+ * is never old, and its lock names a live pid.
27
+ * 3. `TEST_MONGO_TMPDIR` puts the data dirs under a dedicated base (for example
28
+ * a project-local `.tmp`), so whatever does leak is contained and visible.
29
+ *
30
+ * Environment:
31
+ * - `TEST_MONGO_TMPDIR`: base dir for new data dirs. Unset: mongodb-memory-server's
32
+ * own default (`os.tmpdir()`).
33
+ * - `TEST_MONGO_SWEEP_HOURS`: minimum age of a dir the sweep may remove. Default 6.
34
+ * `0`, `off` or `false` turns the sweep off.
35
+ */
36
+ /** Prefix mongodb-memory-server gives its data dirs (and that ours use). */
37
+ export declare const MONGO_MEM_DIR_PREFIX = "mongo-mem-";
38
+ /** The dedicated base dir from `TEST_MONGO_TMPDIR`, resolved, or undefined. */
39
+ export declare function mongoMemoryTmpBase(): string | undefined;
40
+ /** Minimum age (ms) of a dir the sweep may remove; `null` when the sweep is off. */
41
+ export declare function staleMongoMemoryAgeMs(): number | null;
42
+ /** Whether a process with this pid exists. EPERM means it exists but is not ours. */
43
+ export declare function isPidAlive(pid: number): boolean;
44
+ interface SweepMongoMemoryOptions {
45
+ /** Dirs to scan. Default: `os.tmpdir()` plus `TEST_MONGO_TMPDIR` when set. */
46
+ readonly dirs?: readonly string[];
47
+ /** Minimum age in ms; `null` turns the sweep off. Default: `staleMongoMemoryAgeMs()`. */
48
+ readonly maxAgeMs?: number | null;
49
+ /** Clock, for tests. Default `Date.now()`. */
50
+ readonly now?: number;
51
+ /** Liveness probe, for tests. Default `isPidAlive`. */
52
+ readonly isAlive?: (pid: number) => boolean;
53
+ }
54
+ interface SweepMongoMemoryResult {
55
+ readonly removed: string[];
56
+ readonly kept: Array<{
57
+ path: string;
58
+ reason: "live-process" | "recent";
59
+ }>;
60
+ readonly failed: Array<{
61
+ path: string;
62
+ error: string;
63
+ }>;
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
+ export declare function sweepStaleMongoMemoryDirs(options?: SweepMongoMemoryOptions): Promise<SweepMongoMemoryResult>;
74
+ /** The parts of `MongoMemoryServer` / `MongoMemoryReplSet` this module uses. */
75
+ interface MmsServerLike {
76
+ getUri(dbName?: string): string;
77
+ start(): Promise<unknown>;
78
+ stop(cleanup?: {
79
+ doCleanup?: boolean;
80
+ force?: boolean;
81
+ }): Promise<unknown>;
82
+ }
83
+ type MongoMemoryKind = "standalone" | "replset";
84
+ interface LaunchedMongoMemory<S extends MmsServerLike> {
85
+ readonly server: S;
86
+ /** Data dirs this module created under `TEST_MONGO_TMPDIR` (empty when unset). */
87
+ readonly ownedDirs: readonly string[];
88
+ /** Stop and remove the data dirs. Idempotent. */
89
+ stop(): Promise<void>;
90
+ /**
91
+ * Best-effort SYNCHRONOUS stop for a process `exit` handler, where no async
92
+ * work completes: kill the mongod processes and remove their data dirs.
93
+ * Idempotent, never throws.
94
+ */
95
+ stopSync(): void;
96
+ }
97
+ /**
98
+ * Create and start a `MongoMemoryServer` (`standalone`) or `MongoMemoryReplSet`
99
+ * (`replset`) with the cleanup guarantees described at the top of this file.
100
+ *
101
+ * `Ctor` is the class itself, so a caller that loads mongodb-memory-server its
102
+ * own way (by resolved path, through an alias) keeps doing so.
103
+ */
104
+ export declare function launchMongoMemory<S extends MmsServerLike>(Ctor: new (opts?: never) => S, kind: MongoMemoryKind, createOptions?: Record<string, unknown>): Promise<LaunchedMongoMemory<S>>;
105
+ /**
106
+ * Stop a launched server when the process ends without its teardown: Ctrl-C /
107
+ * SIGTERM stop it properly, then re-raise so the exit code stays truthful; an
108
+ * `exit` (including an uncaught exception) kills mongod and removes the dir
109
+ * synchronously. Returns an untrack function for the orderly path.
110
+ *
111
+ * Uses the package's ONE shared process hook, so tracking many servers adds no
112
+ * listeners beyond one per event.
113
+ */
114
+ export declare function stopOnProcessExit(handle: LaunchedMongoMemory<MmsServerLike>): () => void;
3
115
  /** WiredTiger cache per server, in GB. 0.25 is mongod's own floor. */
4
- declare function cacheSizeGB(): number;
116
+ export declare function cacheSizeGB(): number;
5
117
  /** mongod CLI args that bound a test server's footprint. */
6
- declare function mongodMemoryArgs(): string[];
118
+ export declare function mongodMemoryArgs(): string[];
7
119
  /**
8
120
  * Worker count bounded by cores AND memory.
9
121
  *
@@ -11,11 +123,11 @@ declare function mongodMemoryArgs(): string[];
11
123
  * app plus every server it holds live. `envVar` lets a host keep its existing
12
124
  * override name rather than forcing a rename through every CI config.
13
125
  */
14
- declare function safeWorkerCount(envVar?: string): number;
126
+ export declare function safeWorkerCount(envVar?: string): number;
15
127
  /** How many servers the run may boot, bounded by FREE memory at call time. */
16
- declare function safePoolSize(requested: number): number;
128
+ export declare function safePoolSize(requested: number): number;
17
129
  /** Splice a database name into a mongodb:// URI, preserving its query string. */
18
- declare function withDbName(baseUri: string, dbName: string): string;
130
+ export declare function withDbName(baseUri: string, dbName: string): string;
19
131
  /**
20
132
  * Block until every registered model's index build has finished.
21
133
  *
@@ -36,14 +148,14 @@ declare function withDbName(baseUri: string, dbName: string): string;
36
148
  * that does not exist, and it would pass for the wrong reason — precisely the
37
149
  * failure this function was added to remove.
38
150
  */
39
- declare function awaitIndexBuilds(connection?: {
151
+ export declare function awaitIndexBuilds(connection?: {
40
152
  readyState: number;
41
153
  models: Record<string, {
42
154
  init(): Promise<unknown>;
43
155
  }>;
44
156
  }): Promise<void>;
45
157
  /** The subset of the mongoose module this adapter uses. */
46
- interface MongooseLike {
158
+ export interface MongooseLike {
47
159
  connection: {
48
160
  readyState: number;
49
161
  models: Record<string, {
@@ -55,7 +167,7 @@ interface MongooseLike {
55
167
  connect(uri: string): Promise<unknown>;
56
168
  disconnect(): Promise<unknown>;
57
169
  }
58
- interface MongoServerProviderOptions {
170
+ export interface MongoServerProviderOptions {
59
171
  /** Extra mongod args, appended after the memory bounds. */
60
172
  readonly args?: readonly string[];
61
173
  /** Await index builds after each lease connects. Default true — see above. */
@@ -81,5 +193,5 @@ interface MongoServerProviderOptions {
81
193
  * transactions, and a strict superset of a standalone. That is what lets the
82
194
  * pool serve both kinds from one process when sharing is on.
83
195
  */
84
- declare function mongoServerProvider(options?: MongoServerProviderOptions): TestServerProvider<Connection>;
85
- export { MongoServerProviderOptions, MongooseLike, awaitIndexBuilds, cacheSizeGB, mongoServerProvider, mongodMemoryArgs, safePoolSize, safeWorkerCount, withDbName };
196
+ export declare function mongoServerProvider(options?: MongoServerProviderOptions): TestServerProvider<Connection>;
197
+ export type { LaunchedMongoMemory, MmsServerLike, MongoMemoryKind, SweepMongoMemoryOptions, SweepMongoMemoryResult };
package/dist/mongo.mjs CHANGED
@@ -1,4 +1,285 @@
1
- import { cpus, freemem, totalmem } from "node:os";
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
+ }
2
283
  // src/mongo.ts
3
284
  /**
4
285
  * `@classytic/arc-testkit/mongo` — the Mongo adapter for the driver-agnostic pool.
@@ -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,4 +512,4 @@ function mongoServerProvider(options = {}) {
225
512
  }
226
513
  };
227
514
  }
228
- 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,11 +1,17 @@
1
1
  import { TestDatabase, TestDatabaseFactory } from "@classytic/arc/testing";
2
2
  /** Which server shape a suite needs. */
3
- type ServerKind = "standalone" | "transactional";
3
+ export type ServerKind = "standalone" | "transactional";
4
4
  /** A running server the pool may hand out namespaces on. */
5
- interface TestServerHandle {
5
+ export interface TestServerHandle {
6
6
  /** Base connection string, WITHOUT a namespace selected. */
7
7
  readonly uri: string;
8
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;
9
15
  }
10
16
  /**
11
17
  * What a driver must supply. Two methods, deliberately.
@@ -14,7 +20,7 @@ interface TestServerHandle {
14
20
  * is the same in every driver, so putting it here would duplicate it per driver
15
21
  * — which is precisely the failure this module exists to end.
16
22
  */
17
- interface TestServerProvider<TConn> {
23
+ export interface TestServerProvider<TConn> {
18
24
  /** Human name, used only in diagnostics. */
19
25
  readonly name: string;
20
26
  /**
@@ -66,7 +72,7 @@ interface TestServerProvider<TConn> {
66
72
  */
67
73
  afterBoot?(): Promise<void>;
68
74
  }
69
- interface TestDatabasePoolOptions {
75
+ export interface TestDatabasePoolOptions {
70
76
  /**
71
77
  * Env-var prefix for the cross-worker handshake. Two runs with different
72
78
  * prefixes (a spine run and a host run) can share a machine without either
@@ -144,7 +150,7 @@ interface TestDatabasePoolOptions {
144
150
  /** Escape hatch: force per-suite private servers (the unpooled behaviour). */
145
151
  readonly disabled?: boolean;
146
152
  }
147
- interface TestDatabasePool<TConn> {
153
+ export interface TestDatabasePool<TConn> {
148
154
  /** Hand to `bootModuleApp({ database })`. */
149
155
  readonly factory: TestDatabaseFactory<TConn>;
150
156
  /** Wrap a boot function so it defaults to the pool. */
@@ -177,12 +183,11 @@ interface TestDatabasePool<TConn> {
177
183
  live: number;
178
184
  };
179
185
  }
180
- declare function createTestDatabasePool<TConn>(provider: TestServerProvider<TConn>, options?: TestDatabasePoolOptions): TestDatabasePool<TConn>;
186
+ export declare function createTestDatabasePool<TConn>(provider: TestServerProvider<TConn>, options?: TestDatabasePoolOptions): TestDatabasePool<TConn>;
181
187
  /**
182
188
  * Publish a started server so other worker PROCESSES reuse it.
183
189
  *
184
190
  * Only a process that owns servers calls this — typically an eager
185
191
  * `globalSetup` in a run that prefers boot-cost-up-front over laziness.
186
192
  */
187
- declare function publishPoolServers(servers: Partial<Record<ServerKind, readonly string[]>>, envPrefix?: string): void;
188
- 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,3 +1,4 @@
1
+ import { n as unregisterProcessCleanup, t as registerProcessCleanup } from "./process-cleanup-1iBIS_o_.mjs";
1
2
  import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
3
  import { join } from "node:path";
3
4
  import { randomBytes } from "node:crypto";
@@ -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) => {
@@ -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 };