@deque/axe-auth 1.4.0 → 1.5.0-next.7a5a136d

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.
@@ -0,0 +1,219 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ var __importDefault = (this && this.__importDefault) || function (mod) {
36
+ return (mod && mod.__esModule) ? mod : { "default": mod };
37
+ };
38
+ Object.defineProperty(exports, "__esModule", { value: true });
39
+ exports.default = runSession;
40
+ const node_crypto_1 = require("node:crypto");
41
+ const errors_1 = require("../oauth/errors");
42
+ const getValidAccessToken_1 = require("../oauth/getValidAccessToken");
43
+ const tokenStore_1 = require("../oauth/tokenStore");
44
+ const errors_2 = require("../cli/errors");
45
+ const supervise_1 = __importStar(require("./supervise"));
46
+ const pushToken_1 = __importDefault(require("./pushToken"));
47
+ /**
48
+ * Cadence of the refresh-and-push loop. Must be shorter than the access-token
49
+ * TTL: each tick mints (which refreshes when the token is near expiry) and
50
+ * pushes any change, so the token only lapses if a whole TTL elapses between
51
+ * ticks. 60s suits typical Keycloak access-token lifetimes (minutes); a
52
+ * deployment issuing sub-minute tokens would need a shorter interval.
53
+ */
54
+ const DEFAULT_REFRESH_INTERVAL_MS = 60_000;
55
+ /** Consecutive mid-session refresh failures before escalating from a per-tick line to one loud diagnostic. */
56
+ const CONSECUTIVE_FAILURE_WARNING_THRESHOLD = 3;
57
+ /**
58
+ * Extra time, on top of {@link MAX_TEARDOWN_MS}, given to `supervise`'s exit
59
+ * promise to settle after `teardown.abort()` before this function stops
60
+ * waiting on it. Covers the gap between the ladder's last signal firing and
61
+ * the child's `exit` event actually reaching us (process teardown, event-loop
62
+ * scheduling) — not more ladder; `supervise` itself is what bounds that.
63
+ */
64
+ const TEARDOWN_SETTLE_BUFFER_MS = 500;
65
+ const realScheduler = (intervalMs, tick) => {
66
+ const timer = setInterval(tick, intervalMs);
67
+ timer.unref();
68
+ return () => clearInterval(timer);
69
+ };
70
+ /**
71
+ * Supervise the wrapped server command for a session: mint an initial access
72
+ * token, inject it plus the refresh secret and port into the child's
73
+ * environment, and keep the running server's token fresh by refreshing and
74
+ * pushing before expiry. Resolves with the child's exit code.
75
+ *
76
+ * Fails fast with a `NOT_AUTHENTICATED` {@link CLIError} if there are no stored
77
+ * credentials to mint from, and with `REFRESH_UNREACHABLE` if the very first
78
+ * push cannot reach the server (almost always a misconfigured port/secret) —
79
+ * exiting so the MCP client sees the failure rather than letting the session
80
+ * silently die at expiry. Once a push has succeeded, a later refresh failure is
81
+ * logged (the server keeps its last good token) rather than tearing the session
82
+ * down, with one louder diagnostic after {@link CONSECUTIVE_FAILURE_WARNING_THRESHOLD}
83
+ * consecutive failures.
84
+ */
85
+ async function runSession(options) {
86
+ const { command, commandArgs, port, portWasAutoSelected = false, secret = (0, node_crypto_1.randomBytes)(24).toString("hex"), refreshIntervalMs = DEFAULT_REFRESH_INTERVAL_MS, getToken = getValidAccessToken_1.getValidAccessToken, tokenStore = new tokenStore_1.KeyringTokenStore(), push = pushToken_1.default, supervise = supervise_1.default, scheduler = realScheduler, env = process.env, stdin, stdout, stderr = process.stderr, onSignal, } = options;
87
+ const loaded = await tokenStore.load();
88
+ const coordinates = {
89
+ issuerURL: loaded.ok ? loaded.entry.issuerURL : "",
90
+ clientId: loaded.ok ? loaded.entry.clientId : "",
91
+ allowInsecureIssuer: loaded.ok ? loaded.entry.allowInsecureIssuer : false,
92
+ };
93
+ // No `loadedEntry`: each call re-reads the store so it picks up the rotated
94
+ // refresh token from the previous refresh instead of replaying a stale one.
95
+ const mint = async () => {
96
+ try {
97
+ return await getToken({ ...coordinates, tokenStore });
98
+ }
99
+ catch (err) {
100
+ if (err instanceof errors_1.OAuthFlowError && err.code === "NOT_AUTHENTICATED") {
101
+ throw new errors_2.CLIError("NOT_AUTHENTICATED", err.message);
102
+ }
103
+ throw err;
104
+ }
105
+ };
106
+ const initialToken = await mint();
107
+ const childEnv = {
108
+ ...env,
109
+ AXE_ACCESS_TOKEN: initialToken,
110
+ AXE_TOKEN_REFRESH_PORT: String(port),
111
+ AXE_TOKEN_REFRESH_SECRET: secret,
112
+ // Only a container needs a non-loopback bind, and a container always pins
113
+ // its port. On an auto-picked port a stale `0.0.0.0` inherited from the
114
+ // environment would expose the token endpoint off-host, so pin loopback.
115
+ ...(portWasAutoSelected ? { AXE_TOKEN_REFRESH_HOST: "127.0.0.1" } : {}),
116
+ };
117
+ let lastPushed = initialToken;
118
+ let pushedSuccessfully = false;
119
+ let consecutiveFailures = 0;
120
+ let refreshing = false;
121
+ // Lets a fatal first-push failure interrupt the awaited `supervise` below.
122
+ let rejectFatal;
123
+ const fatal = new Promise((_, reject) => {
124
+ rejectFatal = reject;
125
+ });
126
+ // When `fatal` wins the race below, nothing else would ever tear the child
127
+ // down: this process exits while the wrapped server keeps the refresh port.
128
+ const teardown = new AbortController();
129
+ // A recoverable refresh failure: log it, and once failures pile up emit one
130
+ // louder line (they will keep failing every tick otherwise). The session
131
+ // stays alive on its last good token.
132
+ const noteRecoverableFailure = (message) => {
133
+ stderr.write(`axe-auth run: token refresh failed: ${message}\n`);
134
+ consecutiveFailures += 1;
135
+ if (consecutiveFailures === CONSECUTIVE_FAILURE_WARNING_THRESHOLD) {
136
+ stderr.write(`axe-auth run: token refresh has failed ${consecutiveFailures} times in a row; the server's access token will expire and calls will start failing — check that the refresh listener is reachable\n`);
137
+ }
138
+ };
139
+ const cancel = scheduler(refreshIntervalMs, () => {
140
+ if (refreshing) {
141
+ return;
142
+ }
143
+ refreshing = true;
144
+ void (async () => {
145
+ try {
146
+ let fresh;
147
+ try {
148
+ fresh = await mint();
149
+ }
150
+ catch (err) {
151
+ // Mint failure (e.g. a transient Keycloak blip): the injected token
152
+ // may still be valid, so keep the session alive rather than exiting.
153
+ noteRecoverableFailure((0, errors_2.describeError)(err));
154
+ return;
155
+ }
156
+ if (fresh === lastPushed) {
157
+ consecutiveFailures = 0;
158
+ return;
159
+ }
160
+ try {
161
+ await push({ port, secret, accessToken: fresh });
162
+ }
163
+ catch (err) {
164
+ if (!pushedSuccessfully && !portWasAutoSelected) {
165
+ // The first push never reached a port the user chose — almost
166
+ // always a misconfigured port/secret. Fail so the client sees it.
167
+ rejectFatal(new errors_2.CLIError("REFRESH_UNREACHABLE", `token refresh could not reach the server on port ${port} (${(0, errors_2.describeError)(err)}); check the refresh port and secret`));
168
+ return;
169
+ }
170
+ noteRecoverableFailure((0, errors_2.describeError)(err));
171
+ if (!pushedSuccessfully) {
172
+ // Nothing ever answered on a port we chose, so the server did not
173
+ // get it. Stop rather than re-offering a token every interval to
174
+ // whatever else may hold it (#1028). The session keeps running;
175
+ // it just has no refresh.
176
+ cancel();
177
+ stderr.write("axe-auth run: no token-refresh listener answered on the port this session picked; continuing without refresh\n");
178
+ }
179
+ return;
180
+ }
181
+ lastPushed = fresh;
182
+ pushedSuccessfully = true;
183
+ consecutiveFailures = 0;
184
+ }
185
+ finally {
186
+ refreshing = false;
187
+ }
188
+ })();
189
+ });
190
+ // Held separately so the `finally` can await it: if `fatal` wins the race,
191
+ // this is still pending with the wrapped server mid-teardown.
192
+ const superviseResult = supervise({
193
+ command,
194
+ args: commandArgs,
195
+ env: childEnv,
196
+ stdin,
197
+ stdout,
198
+ stderr,
199
+ onSignal,
200
+ signal: teardown.signal,
201
+ });
202
+ try {
203
+ return await Promise.race([superviseResult, fatal]);
204
+ }
205
+ finally {
206
+ cancel();
207
+ teardown.abort();
208
+ // The entrypoint calls `process.exit` with no macrotask in between, so
209
+ // without this the ladder's timers never run and the child is orphaned.
210
+ // Bounded so a `supervise` bug cannot hang the CLI.
211
+ await Promise.race([
212
+ superviseResult.catch(() => { }),
213
+ new Promise((resolve) => {
214
+ const settleTimer = setTimeout(resolve, supervise_1.MAX_TEARDOWN_MS + TEARDOWN_SETTLE_BUFFER_MS);
215
+ settleTimer.unref();
216
+ }),
217
+ ]);
218
+ }
219
+ }
@@ -0,0 +1,72 @@
1
+ import type { Readable, Writable } from "node:stream";
2
+ /**
3
+ * Grace the wrapped server gets before `SIGKILL`. Must exceed that server's
4
+ * own teardown budget or its graceful path never completes: axe-mcp-server
5
+ * publishes {@link SERVER_TEARDOWN_BUDGET_MS}, so this leaves a second of
6
+ * margin. Raising that budget means raising this.
7
+ *
8
+ * TODO(nayanrajDQ): measure a live Chromium teardown against this grace.
9
+ */
10
+ export declare const TEARDOWN_GRACE_MS = 4000;
11
+ /**
12
+ * The budget axe-mcp-server publishes as `MAX_TEARDOWN_BUDGET_MS`. Restated
13
+ * here because the two are separately released packages with no shared code;
14
+ * a test pins each side so raising one without the other fails loudly.
15
+ */
16
+ export declare const SERVER_TEARDOWN_BUDGET_MS = 3000;
17
+ /** Each rung's signal and how long to wait *before* sending it. */
18
+ export type TeardownLadder = ReadonlyArray<{
19
+ signal: NodeJS.Signals;
20
+ delayBeforeMs: number;
21
+ }>;
22
+ /**
23
+ * Upper bound on the ladder once teardown starts. Exported so `runSession`
24
+ * bounds its wait on the ladder's arithmetic, not a constant of its own.
25
+ */
26
+ export declare const MAX_TEARDOWN_MS: number;
27
+ /** Options for {@link supervise}. */
28
+ export interface SuperviseOptions {
29
+ /** Executable to run (e.g. `docker`, `npx`). */
30
+ command: string;
31
+ /** Arguments passed to the executable. */
32
+ args: string[];
33
+ /** Environment for the child. Defaults to `process.env`. */
34
+ env?: NodeJS.ProcessEnv;
35
+ /** Stream feeding the child's stdin. Defaults to `process.stdin`. */
36
+ stdin?: Readable;
37
+ /** Stream the child's stdout is written to. Defaults to `process.stdout`. */
38
+ stdout?: Writable;
39
+ /** Stream the child's stderr is written to. Defaults to `process.stderr`. */
40
+ stderr?: Writable;
41
+ /** Registers a termination-signal handler. Injectable for tests; defaults to `process.on`. */
42
+ onSignal?: (signal: NodeJS.Signals, handler: () => void) => void;
43
+ /** Registers a parent-death watchdog. Injectable for tests; defaults to polling {@link SuperviseOptions.readParentPID}. */
44
+ onParentExit?: (handler: () => void) => void;
45
+ /** Reads the launching process's pid. Injectable for tests; defaults to `process.ppid`. */
46
+ readParentPID?: () => number;
47
+ /** Tears the child down when aborted, e.g. because the caller hit a fatal error. */
48
+ signal?: AbortSignal;
49
+ /**
50
+ * Signal-and-wait rungs teardown escalates through. Injectable so tests can
51
+ * exercise an escalation without sleeping out the shipped grace; defaults to
52
+ * the real ladder, which one test keeps covered at its true timings.
53
+ */
54
+ ladder?: TeardownLadder;
55
+ }
56
+ /**
57
+ * Spawn `command` and transparently bridge this process's stdio to it: the
58
+ * parent's stdin drives the child's stdin (so the MCP client's JSON-RPC stream
59
+ * reaches the wrapped server untouched), the child's stdout and stderr flow
60
+ * back out, termination signals are forwarded, and the child's exit is
61
+ * propagated. Resolves with the child's exit code, or `128 + signal` when the
62
+ * child is killed by a signal (shell convention); rejects only if the child
63
+ * fails to spawn.
64
+ *
65
+ * stdin EOF, a forwarded signal, {@link SuperviseOptions.signal}, and the
66
+ * launching process vanishing each put the child on a bounded teardown ladder.
67
+ *
68
+ * A client that leaks stdin open without signalling or exiting fires none of
69
+ * those. The wrapped server catches that itself by probing the client, and its
70
+ * exit unwinds this supervisor.
71
+ */
72
+ export default function supervise(options: SuperviseOptions): Promise<number>;
@@ -0,0 +1,234 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.MAX_TEARDOWN_MS = exports.SERVER_TEARDOWN_BUDGET_MS = exports.TEARDOWN_GRACE_MS = void 0;
7
+ exports.default = supervise;
8
+ const cross_spawn_1 = __importDefault(require("cross-spawn"));
9
+ const node_os_1 = require("node:os");
10
+ const node_path_1 = require("node:path");
11
+ /** Signals forwarded from this process to the supervised child. */
12
+ const FORWARDED_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"];
13
+ /**
14
+ * Grace the wrapped server gets before `SIGKILL`. Must exceed that server's
15
+ * own teardown budget or its graceful path never completes: axe-mcp-server
16
+ * publishes {@link SERVER_TEARDOWN_BUDGET_MS}, so this leaves a second of
17
+ * margin. Raising that budget means raising this.
18
+ *
19
+ * TODO(nayanrajDQ): measure a live Chromium teardown against this grace.
20
+ */
21
+ exports.TEARDOWN_GRACE_MS = 4_000;
22
+ /**
23
+ * The budget axe-mcp-server publishes as `MAX_TEARDOWN_BUDGET_MS`. Restated
24
+ * here because the two are separately released packages with no shared code;
25
+ * a test pins each side so raising one without the other fails loudly.
26
+ */
27
+ exports.SERVER_TEARDOWN_BUDGET_MS = 3_000;
28
+ /**
29
+ * The shipped POSIX ladder. On a forwarded signal the caller's signal is the
30
+ * polite rung, so only the SIGKILL wait runs.
31
+ */
32
+ const TEARDOWN_LADDER = [
33
+ { signal: "SIGTERM", delayBeforeMs: 2_000 },
34
+ { signal: "SIGKILL", delayBeforeMs: exports.TEARDOWN_GRACE_MS },
35
+ ];
36
+ /**
37
+ * Windows has no signalable process group and no polite signal, so the whole
38
+ * ladder is one rung: close the child's stdin, wait out the same grace, then
39
+ * force-kill the tree. Sharing {@link TEARDOWN_LADDER} would force-kill at its
40
+ * first rung, giving the server less grace than it publishes.
41
+ */
42
+ const WINDOWS_TEARDOWN_LADDER = [
43
+ { signal: "SIGKILL", delayBeforeMs: exports.TEARDOWN_GRACE_MS },
44
+ ];
45
+ /**
46
+ * Upper bound on the ladder once teardown starts. Exported so `runSession`
47
+ * bounds its wait on the ladder's arithmetic, not a constant of its own.
48
+ */
49
+ exports.MAX_TEARDOWN_MS = TEARDOWN_LADDER.reduce((total, rung) => total + rung.delayBeforeMs, 0);
50
+ /**
51
+ * How often the watchdog samples `process.ppid`. A launcher that vanishes
52
+ * without closing stdin or signalling is only observable by polling.
53
+ */
54
+ const PARENT_POLL_INTERVAL_MS = 1_000;
55
+ /**
56
+ * Spawn `command` and transparently bridge this process's stdio to it: the
57
+ * parent's stdin drives the child's stdin (so the MCP client's JSON-RPC stream
58
+ * reaches the wrapped server untouched), the child's stdout and stderr flow
59
+ * back out, termination signals are forwarded, and the child's exit is
60
+ * propagated. Resolves with the child's exit code, or `128 + signal` when the
61
+ * child is killed by a signal (shell convention); rejects only if the child
62
+ * fails to spawn.
63
+ *
64
+ * stdin EOF, a forwarded signal, {@link SuperviseOptions.signal}, and the
65
+ * launching process vanishing each put the child on a bounded teardown ladder.
66
+ *
67
+ * A client that leaks stdin open without signalling or exiting fires none of
68
+ * those. The wrapped server catches that itself by probing the client, and its
69
+ * exit unwinds this supervisor.
70
+ */
71
+ function supervise(options) {
72
+ const { command, args, env = process.env, stdin = process.stdin, stdout = process.stdout, stderr = process.stderr, onSignal = (signal, handler) => {
73
+ process.on(signal, handler);
74
+ }, readParentPID = () => process.ppid, onParentExit = (handler) => {
75
+ const parentPID = readParentPID();
76
+ const timer = setInterval(() => {
77
+ // Reparenting is the only portable signal that the parent died.
78
+ if (readParentPID() !== parentPID) {
79
+ handler();
80
+ }
81
+ }, PARENT_POLL_INTERVAL_MS);
82
+ timer.unref();
83
+ }, signal: abortSignal, ladder = process.platform === "win32"
84
+ ? WINDOWS_TEARDOWN_LADDER
85
+ : TEARDOWN_LADDER, } = options;
86
+ return new Promise((resolve, reject) => {
87
+ const child = (0, cross_spawn_1.default)(command, args, {
88
+ env,
89
+ stdio: ["pipe", "pipe", "pipe"],
90
+ // POSIX only: own process group so a kill reaches the whole tree, since
91
+ // a dead child's children reparent away and cannot be walked after. The
92
+ // pipes are untouched, so EOF still arrives if this process is SIGKILLed.
93
+ // Windows gets the same reach via `taskkill /T`; see `signalTree`.
94
+ detached: process.platform !== "win32",
95
+ });
96
+ let exited = false;
97
+ let tearingDown = false;
98
+ let rungTimer;
99
+ /**
100
+ * Signal the child's whole process tree.
101
+ *
102
+ * TODO(stephenmathieson): a container runtime's container is a child of
103
+ * the daemon, so `SIGKILL` reaches only the client and can leave the
104
+ * container running on its published port (#1027).
105
+ *
106
+ * Windows has no signalable process group and `process.kill` there maps
107
+ * every signal onto `TerminateProcess`, which kills the direct child and
108
+ * orphans the grandchild holding the port. `taskkill /T` walks the tree.
109
+ *
110
+ * Both rungs pass `/F`: Windows has no graceful termination to ask for,
111
+ * and `/T` alone leaves a console process running.
112
+ */
113
+ const signalTree = (signal) => {
114
+ if (exited || child.pid === undefined) {
115
+ return;
116
+ }
117
+ if (process.platform === "win32") {
118
+ // Absolute path: Windows resolution searches the working directory
119
+ // first, and we launch from the user's project folder, so a planted
120
+ // `taskkill.exe` would win.
121
+ const taskkill = process.env.SystemRoot
122
+ ? (0, node_path_1.join)(process.env.SystemRoot, "System32", "taskkill.exe")
123
+ : "taskkill";
124
+ const killer = (0, cross_spawn_1.default)(taskkill, ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore" });
125
+ killer.on("error", () => { });
126
+ killer.unref();
127
+ return;
128
+ }
129
+ try {
130
+ process.kill(-child.pid, signal);
131
+ }
132
+ catch {
133
+ // Already reaped, or the signal means nothing on this platform.
134
+ }
135
+ };
136
+ /** Walk the remaining rungs of the ladder, stopping as soon as the child exits. */
137
+ const escalate = (rungs) => {
138
+ const [next, ...rest] = rungs;
139
+ if (exited || next === undefined) {
140
+ return;
141
+ }
142
+ rungTimer = setTimeout(() => {
143
+ if (exited) {
144
+ return;
145
+ }
146
+ // No pid means spawn failed after the ladder was armed. Nothing to
147
+ // kill, so don't claim one happened.
148
+ if (next.signal === "SIGKILL" && child.pid !== undefined) {
149
+ stderr.write(process.platform === "win32"
150
+ ? "axe-auth run: wrapped server did not stop when asked; killing its process tree\n"
151
+ : "axe-auth run: wrapped server did not stop when asked; killing its process group\n");
152
+ }
153
+ signalTree(next.signal);
154
+ escalate(rest);
155
+ }, next.delayBeforeMs);
156
+ };
157
+ /**
158
+ * Start the teardown ladder. `asked` is the signal already sent for the
159
+ * caller, or `undefined` on the stdin-EOF path where the closed pipe is
160
+ * itself the polite request.
161
+ */
162
+ const beginTeardown = (asked) => {
163
+ if (exited || tearingDown) {
164
+ return;
165
+ }
166
+ tearingDown = true;
167
+ if (process.platform === "win32") {
168
+ // No polite signal exists here: `signalTree` is always a forced tree
169
+ // kill, so forwarding the caller's signal would skip the grace
170
+ // entirely. The closed pipe is the only graceful rung, and it is what
171
+ // the wrapped server shuts down on.
172
+ child.stdin?.end();
173
+ escalate(ladder);
174
+ return;
175
+ }
176
+ if (asked) {
177
+ // The caller's signal was the polite rung; only harder ones remain.
178
+ signalTree(asked);
179
+ escalate(ladder.slice(1));
180
+ return;
181
+ }
182
+ // The closed pipe was the polite request; run the full ladder.
183
+ escalate(ladder);
184
+ };
185
+ child.once("error", reject);
186
+ if (child.stdin) {
187
+ stdin.pipe(child.stdin);
188
+ // Writing to a child that already exited throws EPIPE otherwise.
189
+ child.stdin.on("error", () => { });
190
+ }
191
+ // The server shuts down on the closed pipe, but may not obey.
192
+ stdin.once("end", () => {
193
+ beginTeardown(undefined);
194
+ });
195
+ // `end: false` so the child cannot close the parent's shared streams.
196
+ child.stdout?.pipe(stdout, { end: false });
197
+ child.stderr?.pipe(stderr, { end: false });
198
+ for (const signal of FORWARDED_SIGNALS) {
199
+ onSignal(signal, () => {
200
+ beginTeardown(signal);
201
+ });
202
+ }
203
+ onParentExit(() => {
204
+ if (exited || tearingDown) {
205
+ return;
206
+ }
207
+ stderr.write("axe-auth run: the process that launched this session is gone; shutting down the wrapped server\n");
208
+ beginTeardown("SIGTERM");
209
+ });
210
+ // An already-aborted signal fires no event, so subscribing alone would
211
+ // spawn the child and never tear it down.
212
+ if (abortSignal?.aborted) {
213
+ beginTeardown("SIGTERM");
214
+ }
215
+ else {
216
+ abortSignal?.addEventListener("abort", () => {
217
+ beginTeardown("SIGTERM");
218
+ }, { once: true });
219
+ }
220
+ child.once("exit", (code, signal) => {
221
+ exited = true;
222
+ clearTimeout(rungTimer);
223
+ if (child.stdin) {
224
+ stdin.unpipe(child.stdin);
225
+ }
226
+ if (code !== null) {
227
+ resolve(code);
228
+ }
229
+ else {
230
+ resolve(128 + (node_os_1.constants.signals[signal ?? "SIGTERM"] ?? 0));
231
+ }
232
+ });
233
+ });
234
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * How long a test waits on a teardown before calling it hung. Derived, so
3
+ * moving a ladder rung cannot turn these assertions into flakes.
4
+ */
5
+ export declare const MAX_TEARDOWN_WAIT_MS: number;
6
+ /** Whether `pid` still exists (a reaped process's signal 0 throws ESRCH). */
7
+ export declare function alive(pid: number): boolean;
8
+ /** Poll until `predicate` holds or time runs out. */
9
+ export declare function waitFor(predicate: () => boolean, timeoutMs?: number): Promise<void>;
10
+ /**
11
+ * The resolved value, or `"hung"` if `work` was still pending after `ms`.
12
+ * A bounded assertion is the point: an unbounded `await` on a supervisor that
13
+ * never tears down just hangs the test run instead of failing it.
14
+ */
15
+ export declare function settleWithin<T>(work: Promise<T>, ms?: number): Promise<T | "hung">;
@@ -0,0 +1,43 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.MAX_TEARDOWN_WAIT_MS = void 0;
4
+ exports.alive = alive;
5
+ exports.waitFor = waitFor;
6
+ exports.settleWithin = settleWithin;
7
+ const supervise_1 = require("./supervise");
8
+ /**
9
+ * How long a test waits on a teardown before calling it hung. Derived, so
10
+ * moving a ladder rung cannot turn these assertions into flakes.
11
+ */
12
+ exports.MAX_TEARDOWN_WAIT_MS = supervise_1.MAX_TEARDOWN_MS + 2_000;
13
+ /** Whether `pid` still exists (a reaped process's signal 0 throws ESRCH). */
14
+ function alive(pid) {
15
+ try {
16
+ process.kill(pid, 0);
17
+ return true;
18
+ }
19
+ catch {
20
+ return false;
21
+ }
22
+ }
23
+ /** Poll until `predicate` holds or time runs out. */
24
+ async function waitFor(predicate, timeoutMs = exports.MAX_TEARDOWN_WAIT_MS) {
25
+ const start = Date.now();
26
+ while (!predicate()) {
27
+ if (Date.now() - start > timeoutMs) {
28
+ throw new Error("waitFor timed out");
29
+ }
30
+ await new Promise((r) => setTimeout(r, 20));
31
+ }
32
+ }
33
+ /**
34
+ * The resolved value, or `"hung"` if `work` was still pending after `ms`.
35
+ * A bounded assertion is the point: an unbounded `await` on a supervisor that
36
+ * never tears down just hangs the test run instead of failing it.
37
+ */
38
+ async function settleWithin(work, ms = exports.MAX_TEARDOWN_WAIT_MS) {
39
+ return await Promise.race([
40
+ work,
41
+ new Promise((r) => setTimeout(() => r("hung"), ms).unref()),
42
+ ]);
43
+ }