@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/README.md +26 -5
- package/dist/conformance.d.mts +129 -70
- package/dist/conformance.mjs +207 -61
- package/dist/index.d.mts +5 -6
- package/dist/mongo-commands.d.mts +6 -6
- package/dist/mongo-explain.d.mts +57 -5
- package/dist/mongo-explain.mjs +71 -1
- package/dist/mongo.d.mts +122 -10
- package/dist/mongo.mjs +295 -8
- package/dist/pool.d.mts +13 -8
- package/dist/pool.mjs +24 -21
- package/dist/process-cleanup-1iBIS_o_.mjs +66 -0
- package/dist/story.d.mts +38 -38
- package/llms.txt +2 -2
- package/package.json +6 -3
- package/skills/arc-testkit/SKILL.md +3 -4
- package/skills/arc-testkit/references/conformance.md +15 -14
- package/skills/arc-testkit/references/fixtures.md +12 -0
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 {
|
|
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 {
|
|
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
|
-
|
|
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
|
|
433
|
+
} }) : await launchMongoMemory(mms.MongoMemoryServer, "standalone", create ?? { instance: { args } });
|
|
146
434
|
return {
|
|
147
|
-
uri: server.getUri(),
|
|
148
|
-
stop:
|
|
149
|
-
|
|
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;
|
|
211
|
-
*
|
|
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
|
-
|
|
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 };
|