@saptools/cf-debugger 0.1.14 → 0.1.16
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 +73 -27
- package/dist/cli.js +1712 -510
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +34 -2
- package/dist/index.js +1557 -415
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -15,8 +15,8 @@ var CfDebuggerError = class extends Error {
|
|
|
15
15
|
};
|
|
16
16
|
|
|
17
17
|
// src/debug-session/start.ts
|
|
18
|
-
import { mkdir as mkdir3, rm } from "fs/promises";
|
|
19
|
-
import
|
|
18
|
+
import { chmod as chmod3, mkdir as mkdir3, rm as rm2 } from "fs/promises";
|
|
19
|
+
import process5 from "process";
|
|
20
20
|
|
|
21
21
|
// src/cloud-foundry/commands.ts
|
|
22
22
|
import { execFile as execFile2 } from "child_process";
|
|
@@ -95,8 +95,9 @@ import { execFile } from "child_process";
|
|
|
95
95
|
import { promisify } from "util";
|
|
96
96
|
var execFileAsync = promisify(execFile);
|
|
97
97
|
var MAX_BUFFER = 16 * 1024 * 1024;
|
|
98
|
-
var DEFAULT_CF_COMMAND_TIMEOUT_MS =
|
|
98
|
+
var DEFAULT_CF_COMMAND_TIMEOUT_MS = 3e5;
|
|
99
99
|
var REDACTED_ARG = "<redacted>";
|
|
100
|
+
var MAX_RETRIES = 3;
|
|
100
101
|
function buildEnv(cfHome) {
|
|
101
102
|
return { ...process.env, CF_HOME: cfHome };
|
|
102
103
|
}
|
|
@@ -112,30 +113,129 @@ function sensitiveArgs(args) {
|
|
|
112
113
|
function redactText(text, values) {
|
|
113
114
|
return values.reduce((current, value) => current.split(value).join(REDACTED_ARG), text);
|
|
114
115
|
}
|
|
116
|
+
function normalizeSensitiveValues(values) {
|
|
117
|
+
return [...new Set(values.filter((value) => value.length > 0))].sort((left, right) => right.length - left.length);
|
|
118
|
+
}
|
|
119
|
+
function waitForRetry(delayMs, signal) {
|
|
120
|
+
if (signal?.aborted) {
|
|
121
|
+
return Promise.reject(new CfDebuggerError("ABORTED", "Operation aborted by caller"));
|
|
122
|
+
}
|
|
123
|
+
return new Promise((resolve, reject) => {
|
|
124
|
+
const onAbort = () => {
|
|
125
|
+
clearTimeout(timer);
|
|
126
|
+
reject(new CfDebuggerError("ABORTED", "Operation aborted by caller"));
|
|
127
|
+
};
|
|
128
|
+
const timer = setTimeout(() => {
|
|
129
|
+
signal?.removeEventListener("abort", onAbort);
|
|
130
|
+
resolve();
|
|
131
|
+
}, delayMs);
|
|
132
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
133
|
+
});
|
|
134
|
+
}
|
|
115
135
|
function formatArgsForError(args) {
|
|
116
136
|
if (args[0] !== "auth") {
|
|
117
137
|
return args.join(" ");
|
|
118
138
|
}
|
|
119
139
|
return args.map((arg, index) => index === 0 ? arg : REDACTED_ARG).join(" ");
|
|
120
140
|
}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
141
|
+
function optionalString(value) {
|
|
142
|
+
return typeof value === "string" ? value : void 0;
|
|
143
|
+
}
|
|
144
|
+
function readFailureDetails(error) {
|
|
145
|
+
const errorObject = typeof error === "object" && error !== null ? error : void 0;
|
|
146
|
+
const field3 = (key) => errorObject === void 0 ? void 0 : Reflect.get(errorObject, key);
|
|
147
|
+
return {
|
|
148
|
+
code: optionalString(field3("code")),
|
|
149
|
+
killed: field3("killed") === true,
|
|
150
|
+
message: error instanceof Error ? error.message : String(error),
|
|
151
|
+
stderr: optionalString(field3("stderr")),
|
|
152
|
+
stdout: optionalString(field3("stdout"))
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
function isTransientNetworkError(error) {
|
|
156
|
+
if (error.killed && error.code === void 0) {
|
|
157
|
+
return true;
|
|
158
|
+
}
|
|
159
|
+
const code = error.code ?? "";
|
|
160
|
+
const networkCodes = ["ETIMEDOUT", "ECONNRESET", "ENOTFOUND", "ECONNREFUSED", "EHOSTUNREACH", "ENETUNREACH"];
|
|
161
|
+
if (networkCodes.includes(code)) {
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
const output = `${error.message} ${error.stderr ?? ""} ${error.stdout ?? ""}`.toLowerCase();
|
|
165
|
+
const transientPhrases = [
|
|
166
|
+
"error performing request",
|
|
167
|
+
"timeout exceeded",
|
|
168
|
+
"connection reset by peer",
|
|
169
|
+
"service unavailable",
|
|
170
|
+
"bad gateway",
|
|
171
|
+
"gateway timeout",
|
|
172
|
+
"dial tcp",
|
|
173
|
+
"i/o timeout"
|
|
174
|
+
];
|
|
175
|
+
if (transientPhrases.some((phrase) => output.includes(phrase))) {
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
const transientRegexes = [/\b502\b/, /\b503\b/, /\b504\b/, /\btimeout\b/];
|
|
179
|
+
return transientRegexes.some((regex) => regex.test(output));
|
|
180
|
+
}
|
|
181
|
+
function resolveRunOptions(args, input) {
|
|
182
|
+
const options = typeof input === "number" ? { timeoutMs: input } : input;
|
|
183
|
+
return {
|
|
184
|
+
env: options.env,
|
|
185
|
+
redactionValues: normalizeSensitiveValues([
|
|
186
|
+
...sensitiveArgs(args),
|
|
187
|
+
...options.sensitiveValues ?? []
|
|
188
|
+
]),
|
|
189
|
+
timeoutMs: options.timeoutMs ?? DEFAULT_CF_COMMAND_TIMEOUT_MS
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
async function executeCfAttempt(args, context, options) {
|
|
193
|
+
const { stdout } = await execFileAsync(resolveBin(context), [...args], {
|
|
194
|
+
env: { ...buildEnv(context.cfHome), ...options.env },
|
|
195
|
+
maxBuffer: MAX_BUFFER,
|
|
196
|
+
...context.signal === void 0 ? {} : { signal: context.signal },
|
|
197
|
+
timeout: options.timeoutMs
|
|
198
|
+
});
|
|
199
|
+
return stdout;
|
|
200
|
+
}
|
|
201
|
+
function retryDelayMs(failure, attempt) {
|
|
202
|
+
if (!isTransientNetworkError(failure) || attempt > MAX_RETRIES) {
|
|
203
|
+
return void 0;
|
|
204
|
+
}
|
|
205
|
+
return Math.min(1e3 * 2 ** (attempt - 1), 1e4);
|
|
206
|
+
}
|
|
207
|
+
function createCfCliError(args, failure, redactionValues) {
|
|
208
|
+
const stderr = redactText(failure.stderr?.trim() ?? "", redactionValues);
|
|
209
|
+
const fallbackMessage = redactText(failure.message, redactionValues);
|
|
210
|
+
const detail = stderr.length > 0 ? stderr : fallbackMessage;
|
|
211
|
+
return new CfDebuggerError(
|
|
212
|
+
"CF_CLI_FAILED",
|
|
213
|
+
`cf ${formatArgsForError(args)} failed: ${detail}`,
|
|
214
|
+
stderr
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
async function runCf(args, context, input = {}) {
|
|
218
|
+
if (context.signal?.aborted) {
|
|
219
|
+
throw new CfDebuggerError("ABORTED", "Operation aborted by caller");
|
|
220
|
+
}
|
|
221
|
+
const options = resolveRunOptions(args, input);
|
|
222
|
+
let attempt = 0;
|
|
223
|
+
for (; ; ) {
|
|
224
|
+
try {
|
|
225
|
+
return await executeCfAttempt(args, context, options);
|
|
226
|
+
} catch (err) {
|
|
227
|
+
attempt += 1;
|
|
228
|
+
const failure = readFailureDetails(err);
|
|
229
|
+
if (context.signal?.aborted || failure.code === "ABORT_ERR") {
|
|
230
|
+
throw new CfDebuggerError("ABORTED", "Operation aborted by caller");
|
|
231
|
+
}
|
|
232
|
+
const delayMs = retryDelayMs(failure, attempt);
|
|
233
|
+
if (delayMs !== void 0) {
|
|
234
|
+
await waitForRetry(delayMs, context.signal);
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
throw createCfCliError(args, failure, options.redactionValues);
|
|
238
|
+
}
|
|
139
239
|
}
|
|
140
240
|
}
|
|
141
241
|
|
|
@@ -143,6 +243,27 @@ async function runCf(args, context, timeoutMs = DEFAULT_CF_COMMAND_TIMEOUT_MS) {
|
|
|
143
243
|
var execFileAsync2 = promisify2(execFile2);
|
|
144
244
|
var CF_AUTH_MAX_ATTEMPTS = 3;
|
|
145
245
|
var CURRENT_TARGET_TIMEOUT_MS = 3e4;
|
|
246
|
+
function rethrowCallerAbort(error) {
|
|
247
|
+
if (error instanceof CfDebuggerError && error.code === "ABORTED") {
|
|
248
|
+
throw error;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
function waitForAuthRetry(delayMs, signal) {
|
|
252
|
+
if (signal?.aborted) {
|
|
253
|
+
return Promise.reject(new CfDebuggerError("ABORTED", "Operation aborted by caller"));
|
|
254
|
+
}
|
|
255
|
+
return new Promise((resolve, reject) => {
|
|
256
|
+
const onAbort = () => {
|
|
257
|
+
clearTimeout(timer);
|
|
258
|
+
reject(new CfDebuggerError("ABORTED", "Operation aborted by caller"));
|
|
259
|
+
};
|
|
260
|
+
const timer = setTimeout(() => {
|
|
261
|
+
signal?.removeEventListener("abort", onAbort);
|
|
262
|
+
resolve();
|
|
263
|
+
}, delayMs);
|
|
264
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
265
|
+
});
|
|
266
|
+
}
|
|
146
267
|
async function cfApi(apiEndpoint, context) {
|
|
147
268
|
await runCf(["api", apiEndpoint], context);
|
|
148
269
|
}
|
|
@@ -150,14 +271,18 @@ async function cfAuth(email, password, context) {
|
|
|
150
271
|
let lastError;
|
|
151
272
|
for (let attempt = 0; attempt < CF_AUTH_MAX_ATTEMPTS; attempt++) {
|
|
152
273
|
try {
|
|
153
|
-
await runCf(["auth",
|
|
274
|
+
await runCf(["auth"], context, {
|
|
275
|
+
env: { CF_PASSWORD: password, CF_USERNAME: email },
|
|
276
|
+
sensitiveValues: [email, password]
|
|
277
|
+
});
|
|
154
278
|
return;
|
|
155
279
|
} catch (err) {
|
|
156
280
|
lastError = err;
|
|
281
|
+
if (err instanceof CfDebuggerError && err.code === "ABORTED") {
|
|
282
|
+
throw err;
|
|
283
|
+
}
|
|
157
284
|
if (attempt < CF_AUTH_MAX_ATTEMPTS - 1) {
|
|
158
|
-
await
|
|
159
|
-
setTimeout(resolve, 1e3 * (attempt + 1));
|
|
160
|
-
});
|
|
285
|
+
await waitForAuthRetry(1e3 * (attempt + 1), context.signal);
|
|
161
286
|
}
|
|
162
287
|
}
|
|
163
288
|
}
|
|
@@ -171,6 +296,7 @@ async function cfLogin(apiEndpoint, email, password, context) {
|
|
|
171
296
|
await cfApi(apiEndpoint, context);
|
|
172
297
|
await cfAuth(email, password, context);
|
|
173
298
|
} catch (err) {
|
|
299
|
+
rethrowCallerAbort(err);
|
|
174
300
|
if (err instanceof CfDebuggerError) {
|
|
175
301
|
throw new CfDebuggerError("CF_LOGIN_FAILED", err.message, err.stderr);
|
|
176
302
|
}
|
|
@@ -181,6 +307,7 @@ async function cfTarget(org, space, context) {
|
|
|
181
307
|
try {
|
|
182
308
|
await runCf(["target", "-o", org, "-s", space], context);
|
|
183
309
|
} catch (err) {
|
|
310
|
+
rethrowCallerAbort(err);
|
|
184
311
|
if (err instanceof CfDebuggerError) {
|
|
185
312
|
throw new CfDebuggerError("CF_TARGET_FAILED", err.message, err.stderr);
|
|
186
313
|
}
|
|
@@ -191,7 +318,8 @@ async function cfSshEnabled(appName, context) {
|
|
|
191
318
|
try {
|
|
192
319
|
const stdout = await runCf(["ssh-enabled", appName], context);
|
|
193
320
|
return stdout.toLowerCase().includes("ssh support is enabled");
|
|
194
|
-
} catch {
|
|
321
|
+
} catch (err) {
|
|
322
|
+
rethrowCallerAbort(err);
|
|
195
323
|
return false;
|
|
196
324
|
}
|
|
197
325
|
}
|
|
@@ -199,6 +327,7 @@ async function cfEnableSsh(appName, context) {
|
|
|
199
327
|
try {
|
|
200
328
|
await runCf(["enable-ssh", appName], context);
|
|
201
329
|
} catch (err) {
|
|
330
|
+
rethrowCallerAbort(err);
|
|
202
331
|
if (err instanceof CfDebuggerError) {
|
|
203
332
|
throw new CfDebuggerError("SSH_NOT_ENABLED", err.message, err.stderr);
|
|
204
333
|
}
|
|
@@ -257,7 +386,7 @@ function parseTargetFields(stdout) {
|
|
|
257
386
|
line.slice(0, separator).trim().toLowerCase(),
|
|
258
387
|
line.slice(separator + 1).trim()
|
|
259
388
|
];
|
|
260
|
-
}).filter((
|
|
389
|
+
}).filter((field3) => field3 !== void 0)
|
|
261
390
|
);
|
|
262
391
|
}
|
|
263
392
|
function regionKeyForApiEndpoint(apiEndpoint) {
|
|
@@ -281,69 +410,383 @@ function isPresent(value) {
|
|
|
281
410
|
|
|
282
411
|
// src/cloud-foundry/ssh.ts
|
|
283
412
|
import { spawn } from "child_process";
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
413
|
+
|
|
414
|
+
// src/cloud-foundry/node-process.ts
|
|
415
|
+
var DEFAULT_CF_PROCESS = "web";
|
|
416
|
+
var DEFAULT_CF_INSTANCE = 0;
|
|
417
|
+
var MAX_MARKER_BYTES = 65536;
|
|
418
|
+
var PID_LIST_PATTERN = /^\d+(?:,\d+)*$/;
|
|
419
|
+
var PID_PATTERN = /^\d+$/;
|
|
420
|
+
function validateProcessName(processName) {
|
|
421
|
+
if (processName.length === 0 || processName.startsWith("-") || hasControlCharacter(processName)) {
|
|
422
|
+
throw new CfDebuggerError(
|
|
423
|
+
"UNSAFE_INPUT",
|
|
424
|
+
"process must be non-empty, must not start with a hyphen, and must contain no control characters."
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
function validateInstance(instance) {
|
|
429
|
+
if (!Number.isSafeInteger(instance) || instance < 0) {
|
|
430
|
+
throw new CfDebuggerError("UNSAFE_INPUT", "instance must be a non-negative safe integer.");
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
function validateNodePid(nodePid) {
|
|
434
|
+
if (nodePid !== void 0 && (!Number.isSafeInteger(nodePid) || nodePid <= 0)) {
|
|
435
|
+
throw new CfDebuggerError("UNSAFE_INPUT", "nodePid must be a positive safe integer.");
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
function resolveNodeTarget(input) {
|
|
439
|
+
const processName = (input.process ?? DEFAULT_CF_PROCESS).trim();
|
|
440
|
+
const instance = input.instance ?? DEFAULT_CF_INSTANCE;
|
|
441
|
+
validateProcessName(processName);
|
|
442
|
+
validateInstance(instance);
|
|
443
|
+
validateNodePid(input.nodePid);
|
|
444
|
+
return input.nodePid === void 0 ? { process: processName, instance } : { process: processName, instance, nodePid: input.nodePid };
|
|
445
|
+
}
|
|
446
|
+
function hasControlCharacter(value) {
|
|
447
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
448
|
+
const code = value.charCodeAt(index);
|
|
449
|
+
if (code < 32 || code === 127) {
|
|
450
|
+
return true;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
return false;
|
|
454
|
+
}
|
|
455
|
+
function buildNodeInspectorCommand(nodePid) {
|
|
456
|
+
if (nodePid !== void 0 && (!Number.isSafeInteger(nodePid) || nodePid <= 0)) {
|
|
457
|
+
throw new CfDebuggerError("UNSAFE_INPUT", "nodePid must be a positive safe integer.");
|
|
458
|
+
}
|
|
459
|
+
return NODE_INSPECTOR_SCRIPT.replace("__REQUESTED_NODE_PID__", nodePid?.toString() ?? "");
|
|
460
|
+
}
|
|
461
|
+
function parseNodeInspectorMarkers(stdout) {
|
|
462
|
+
if (Buffer.byteLength(stdout, "utf8") > MAX_MARKER_BYTES) {
|
|
463
|
+
throw new CfDebuggerError("INSPECTOR_OUTPUT_TOO_LARGE", "Inspector startup output exceeded 65536 bytes.");
|
|
464
|
+
}
|
|
465
|
+
const markers = parseMarkers(stdout);
|
|
466
|
+
throwForFailureMarker(markers);
|
|
467
|
+
const remoteNodePid = readMarkerPid(markers, "saptools-inspector-node-pid");
|
|
468
|
+
const ownerPid = readMarkerPid(markers, "saptools-inspector-owner-pid");
|
|
469
|
+
if (!markers.has("saptools-inspector-ready") || remoteNodePid === void 0 || ownerPid !== remoteNodePid) {
|
|
470
|
+
throw new CfDebuggerError("INSPECTOR_NOT_READY", "Remote Node inspector did not report a verified owner.");
|
|
471
|
+
}
|
|
472
|
+
return { remoteNodePid };
|
|
473
|
+
}
|
|
474
|
+
function parseMarkers(stdout) {
|
|
475
|
+
const markers = /* @__PURE__ */ new Map();
|
|
476
|
+
for (const rawLine of stdout.split(/\r?\n/)) {
|
|
477
|
+
const line = rawLine.trim();
|
|
478
|
+
if (!line.startsWith("saptools-inspector-")) {
|
|
479
|
+
continue;
|
|
480
|
+
}
|
|
481
|
+
const separator = line.indexOf("=");
|
|
482
|
+
markers.set(separator < 0 ? line : line.slice(0, separator), separator < 0 ? "" : line.slice(separator + 1));
|
|
483
|
+
}
|
|
484
|
+
return markers;
|
|
485
|
+
}
|
|
486
|
+
function throwForFailureMarker(markers) {
|
|
487
|
+
if (markers.has("saptools-inspector-node-not-found")) {
|
|
488
|
+
throw new CfDebuggerError("NODE_PROCESS_NOT_FOUND", "No Node.js process was found in the selected CF instance.");
|
|
489
|
+
}
|
|
490
|
+
const ambiguous = markers.get("saptools-inspector-node-ambiguous");
|
|
491
|
+
if (ambiguous !== void 0) {
|
|
492
|
+
const candidates = PID_LIST_PATTERN.test(ambiguous) ? ambiguous.split(",").join(", ") : "unknown";
|
|
493
|
+
throw new CfDebuggerError("NODE_PROCESS_AMBIGUOUS", `Multiple Node.js processes were found: ${candidates}. Pass nodePid explicitly.`);
|
|
494
|
+
}
|
|
495
|
+
const invalid = markers.get("saptools-inspector-node-invalid");
|
|
496
|
+
if (invalid !== void 0) {
|
|
497
|
+
throw new CfDebuggerError("NODE_PID_INVALID", `Remote PID ${safePidText(invalid)} is not a Node.js process.`);
|
|
498
|
+
}
|
|
499
|
+
throwForRuntimeFailure(markers);
|
|
500
|
+
}
|
|
501
|
+
function throwForRuntimeFailure(markers) {
|
|
502
|
+
const mismatch = markers.get("saptools-inspector-owner-mismatch");
|
|
503
|
+
if (mismatch !== void 0) {
|
|
504
|
+
const [selected = "unknown", owner = "unknown"] = mismatch.split(":", 2).map(safePidText);
|
|
505
|
+
throw new CfDebuggerError("INSPECTOR_OWNER_MISMATCH", `Selected Node PID ${selected}, but inspector port 9229 is owned by PID ${owner}.`);
|
|
506
|
+
}
|
|
507
|
+
const signalFailed = markers.get("saptools-inspector-signal-failed");
|
|
508
|
+
if (signalFailed !== void 0) {
|
|
509
|
+
throw new CfDebuggerError("USR1_SIGNAL_FAILED", `Failed to signal remote Node PID ${safePidText(signalFailed)}.`);
|
|
510
|
+
}
|
|
511
|
+
if (markers.has("saptools-inspector-not-ready")) {
|
|
512
|
+
throw new CfDebuggerError("INSPECTOR_NOT_READY", "Remote Node inspector did not become ready on port 9229.");
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
function readMarkerPid(markers, name) {
|
|
516
|
+
const raw = markers.get(name);
|
|
517
|
+
if (raw === void 0 || !PID_PATTERN.test(raw)) {
|
|
518
|
+
return void 0;
|
|
519
|
+
}
|
|
520
|
+
const value = Number.parseInt(raw, 10);
|
|
521
|
+
return Number.isSafeInteger(value) && value > 0 ? value : void 0;
|
|
522
|
+
}
|
|
523
|
+
function safePidText(raw) {
|
|
524
|
+
return PID_PATTERN.test(raw) ? raw : "unknown";
|
|
525
|
+
}
|
|
526
|
+
var NODE_INSPECTOR_SCRIPT = [
|
|
527
|
+
"requested_node_pid=__REQUESTED_NODE_PID__",
|
|
528
|
+
"is_node_pid() {",
|
|
529
|
+
' candidate_pid="$1"',
|
|
530
|
+
' candidate_exe="$(readlink "/proc/$candidate_pid/exe" 2>/dev/null || true)"',
|
|
531
|
+
' [ "${candidate_exe##*/}" = node ] || [ "${candidate_exe##*/}" = nodejs ]',
|
|
532
|
+
"}",
|
|
533
|
+
"find_listener_pid() {",
|
|
534
|
+
' listener_hex="$1"',
|
|
535
|
+
' [ -n "$listener_hex" ] || return 0',
|
|
536
|
+
` socket_inode="$(awk -v ph=":$listener_hex" '$4 == "0A" && $2 ~ (ph "$") { print $10; exit }' /proc/net/tcp /proc/net/tcp6 2>/dev/null)"`,
|
|
537
|
+
' [ -n "$socket_inode" ] || return 0',
|
|
538
|
+
" for pid_dir in /proc/[0-9]*; do",
|
|
539
|
+
' [ -d "$pid_dir/fd" ] || continue',
|
|
540
|
+
' for fd_path in "$pid_dir"/fd/*; do',
|
|
541
|
+
' fd_target="$(readlink "$fd_path" 2>/dev/null || true)"',
|
|
542
|
+
' if [ "$fd_target" = "socket:[$socket_inode]" ]; then',
|
|
543
|
+
` printf '%s' "\${pid_dir##*/}"`,
|
|
544
|
+
" return 0",
|
|
545
|
+
" fi",
|
|
546
|
+
" done",
|
|
547
|
+
" done",
|
|
548
|
+
"}",
|
|
549
|
+
"find_inspector_owner() {",
|
|
550
|
+
" find_listener_pid 240D",
|
|
551
|
+
"}",
|
|
552
|
+
"find_app_port_listener() {",
|
|
553
|
+
' [ -n "${PORT:-}" ] || return 0',
|
|
554
|
+
` app_port_hex="$(printf '%04X' "$PORT" 2>/dev/null || true)"`,
|
|
555
|
+
' find_listener_pid "$app_port_hex"',
|
|
556
|
+
"}",
|
|
557
|
+
'owner_pid="$(find_inspector_owner)"',
|
|
558
|
+
'selected_pid=""',
|
|
559
|
+
'if [ -n "$requested_node_pid" ]; then',
|
|
560
|
+
' if ! is_node_pid "$requested_node_pid"; then',
|
|
561
|
+
' echo "saptools-inspector-node-invalid=$requested_node_pid"',
|
|
562
|
+
" exit 0",
|
|
563
|
+
" fi",
|
|
564
|
+
' selected_pid="$requested_node_pid"',
|
|
565
|
+
'elif [ -n "$owner_pid" ] && is_node_pid "$owner_pid"; then',
|
|
566
|
+
' selected_pid="$owner_pid"',
|
|
567
|
+
"else",
|
|
568
|
+
' candidate_pids=""',
|
|
569
|
+
" candidate_count=0",
|
|
570
|
+
" for pid_dir in /proc/[0-9]*; do",
|
|
571
|
+
' candidate_pid="${pid_dir##*/}"',
|
|
572
|
+
' is_node_pid "$candidate_pid" || continue',
|
|
573
|
+
" candidate_count=$((candidate_count + 1))",
|
|
574
|
+
' candidate_pids="${candidate_pids}${candidate_pids:+,}$candidate_pid"',
|
|
575
|
+
' selected_pid="$candidate_pid"',
|
|
576
|
+
" done",
|
|
577
|
+
' if [ "$candidate_count" -eq 0 ]; then echo saptools-inspector-node-not-found; exit 0; fi',
|
|
578
|
+
' if [ "$candidate_count" -ne 1 ]; then',
|
|
579
|
+
' app_port_pid="$(find_app_port_listener)"',
|
|
580
|
+
' if [ -n "$app_port_pid" ] && is_node_pid "$app_port_pid"; then',
|
|
581
|
+
' selected_pid="$app_port_pid"',
|
|
582
|
+
" else",
|
|
583
|
+
' echo "saptools-inspector-node-ambiguous=$candidate_pids"; exit 0',
|
|
584
|
+
" fi",
|
|
585
|
+
" fi",
|
|
586
|
+
"fi",
|
|
587
|
+
'if [ -n "$owner_pid" ]; then',
|
|
588
|
+
' if [ "$owner_pid" != "$selected_pid" ]; then echo "saptools-inspector-owner-mismatch=$selected_pid:$owner_pid"; exit 0; fi',
|
|
589
|
+
' echo "saptools-inspector-node-pid=$selected_pid"',
|
|
590
|
+
' echo "saptools-inspector-owner-pid=$owner_pid"',
|
|
591
|
+
" echo saptools-inspector-ready",
|
|
592
|
+
" exit 0",
|
|
593
|
+
"fi",
|
|
594
|
+
'if ! kill -USR1 "$selected_pid" 2>/dev/null; then echo "saptools-inspector-signal-failed=$selected_pid"; exit 0; fi',
|
|
595
|
+
"attempt=0",
|
|
596
|
+
'while [ "$attempt" -lt 20 ]; do',
|
|
597
|
+
' owner_pid="$(find_inspector_owner)"',
|
|
598
|
+
' if [ -n "$owner_pid" ]; then',
|
|
599
|
+
' if [ "$owner_pid" != "$selected_pid" ]; then echo "saptools-inspector-owner-mismatch=$selected_pid:$owner_pid"; exit 0; fi',
|
|
600
|
+
' echo "saptools-inspector-node-pid=$selected_pid"',
|
|
601
|
+
' echo "saptools-inspector-owner-pid=$owner_pid"',
|
|
602
|
+
" echo saptools-inspector-ready",
|
|
603
|
+
" exit 0",
|
|
604
|
+
" fi",
|
|
605
|
+
" attempt=$((attempt + 1))",
|
|
606
|
+
" sleep 0.25",
|
|
607
|
+
"done",
|
|
608
|
+
'echo "saptools-inspector-not-ready=$selected_pid"'
|
|
609
|
+
].join("\n");
|
|
610
|
+
|
|
611
|
+
// src/cloud-foundry/ssh.ts
|
|
612
|
+
var DEFAULT_MAX_OUTPUT_BYTES = 65536;
|
|
613
|
+
function buildCfSshArgs(appName, target, tail) {
|
|
614
|
+
const resolved = resolveNodeTarget(target);
|
|
615
|
+
const processArgs = resolved.process === DEFAULT_CF_PROCESS ? [] : ["--process", resolved.process];
|
|
616
|
+
return [
|
|
617
|
+
"ssh",
|
|
618
|
+
appName,
|
|
619
|
+
...processArgs,
|
|
620
|
+
"-i",
|
|
621
|
+
resolved.instance.toString(),
|
|
622
|
+
...tail
|
|
623
|
+
];
|
|
624
|
+
}
|
|
625
|
+
async function cfSshOneShot(appName, command, context, rawOptions = DEFAULT_CF_COMMAND_TIMEOUT_MS) {
|
|
626
|
+
const options = resolveSshOptions(rawOptions);
|
|
627
|
+
const args = buildCfSshArgs(appName, options.target, [
|
|
628
|
+
"--disable-pseudo-tty",
|
|
629
|
+
"-c",
|
|
630
|
+
command
|
|
631
|
+
]);
|
|
632
|
+
return await runSshOneShot(args, context, options);
|
|
633
|
+
}
|
|
634
|
+
function resolveSshOptions(raw) {
|
|
635
|
+
const input = typeof raw === "number" ? { timeoutMs: raw } : raw;
|
|
636
|
+
const timeoutMs = input.timeoutMs ?? DEFAULT_CF_COMMAND_TIMEOUT_MS;
|
|
637
|
+
const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
|
|
638
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) {
|
|
639
|
+
throw new RangeError("timeoutMs must be a positive safe integer");
|
|
640
|
+
}
|
|
641
|
+
if (!Number.isSafeInteger(maxOutputBytes) || maxOutputBytes <= 0) {
|
|
642
|
+
throw new RangeError("maxOutputBytes must be a positive safe integer");
|
|
643
|
+
}
|
|
644
|
+
return { target: input, timeoutMs, maxOutputBytes };
|
|
645
|
+
}
|
|
646
|
+
function createBoundedOutput() {
|
|
647
|
+
return { chunks: [], bytes: 0, truncated: false };
|
|
648
|
+
}
|
|
649
|
+
function appendBounded(output, data, limit) {
|
|
650
|
+
const incoming = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
|
651
|
+
const remaining = Math.max(0, limit - output.bytes);
|
|
652
|
+
if (incoming.byteLength > remaining) {
|
|
653
|
+
output.truncated = true;
|
|
654
|
+
}
|
|
655
|
+
if (remaining === 0) {
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
const next = incoming.subarray(0, remaining);
|
|
659
|
+
output.chunks.push(next);
|
|
660
|
+
output.bytes += next.byteLength;
|
|
661
|
+
}
|
|
662
|
+
function outputText(output) {
|
|
663
|
+
return Buffer.concat(output.chunks, output.bytes).toString("utf8");
|
|
664
|
+
}
|
|
665
|
+
function createResult(exitCode, stdout, stderr) {
|
|
666
|
+
return {
|
|
667
|
+
exitCode,
|
|
668
|
+
stdout: outputText(stdout),
|
|
669
|
+
stderr: outputText(stderr),
|
|
670
|
+
outputTruncated: stdout.truncated || stderr.truncated
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
function createSshExecutionState() {
|
|
674
|
+
return {
|
|
675
|
+
stdout: createBoundedOutput(),
|
|
676
|
+
stderr: createBoundedOutput(),
|
|
677
|
+
settled: false,
|
|
678
|
+
aborted: false,
|
|
679
|
+
timedOut: false
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
function terminateSshExecution(child, state) {
|
|
683
|
+
signalChild(child, "SIGTERM");
|
|
684
|
+
state.forceKillTimer ??= setTimeout(() => {
|
|
685
|
+
signalChild(child, "SIGKILL");
|
|
686
|
+
}, 1e3);
|
|
687
|
+
}
|
|
688
|
+
function createSshSettler(state, options, signal, onAbort, resolve, reject) {
|
|
689
|
+
return (result) => {
|
|
690
|
+
if (state.settled) {
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
state.settled = true;
|
|
694
|
+
if (state.timeoutTimer !== void 0) {
|
|
695
|
+
clearTimeout(state.timeoutTimer);
|
|
696
|
+
}
|
|
697
|
+
if (state.forceKillTimer !== void 0) {
|
|
698
|
+
clearTimeout(state.forceKillTimer);
|
|
699
|
+
}
|
|
700
|
+
signal?.removeEventListener("abort", onAbort);
|
|
701
|
+
if (state.aborted) {
|
|
702
|
+
reject(new CfDebuggerError("ABORTED", "Operation aborted by caller"));
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
resolve(state.timedOut ? { ...result, timedOutAfterMs: options.timeoutMs } : result);
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
function attachSshExecution(child, context, options, resolve, reject) {
|
|
709
|
+
const state = createSshExecutionState();
|
|
710
|
+
const onAbort = () => {
|
|
711
|
+
state.aborted = true;
|
|
712
|
+
terminateSshExecution(child, state);
|
|
713
|
+
};
|
|
714
|
+
const settle = createSshSettler(state, options, context.signal, onAbort, resolve, reject);
|
|
715
|
+
state.timeoutTimer = setTimeout(() => {
|
|
716
|
+
state.timedOut = true;
|
|
717
|
+
terminateSshExecution(child, state);
|
|
718
|
+
}, options.timeoutMs);
|
|
719
|
+
if (context.signal?.aborted) {
|
|
720
|
+
onAbort();
|
|
721
|
+
} else {
|
|
722
|
+
context.signal?.addEventListener("abort", onAbort, { once: true });
|
|
723
|
+
}
|
|
724
|
+
child.stdout.on("data", (data) => {
|
|
725
|
+
appendBounded(state.stdout, data, options.maxOutputBytes);
|
|
726
|
+
});
|
|
727
|
+
child.stderr.on("data", (data) => {
|
|
728
|
+
appendBounded(state.stderr, data, options.maxOutputBytes);
|
|
729
|
+
});
|
|
730
|
+
child.on("close", (code, signal) => {
|
|
731
|
+
const base = createResult(code, state.stdout, state.stderr);
|
|
732
|
+
settle(state.timedOut || signal === null ? base : { ...base, signal });
|
|
733
|
+
});
|
|
734
|
+
child.on("error", (error) => {
|
|
735
|
+
appendBounded(state.stderr, error.message, options.maxOutputBytes);
|
|
736
|
+
settle(createResult(null, state.stdout, state.stderr));
|
|
737
|
+
});
|
|
738
|
+
}
|
|
739
|
+
function runSshOneShot(args, context, options) {
|
|
740
|
+
if (context.signal?.aborted) {
|
|
741
|
+
return Promise.reject(new CfDebuggerError("ABORTED", "Operation aborted by caller"));
|
|
742
|
+
}
|
|
743
|
+
return new Promise((resolve, reject) => {
|
|
744
|
+
const child = spawn(resolveBin(context), [...args], {
|
|
287
745
|
env: buildEnv(context.cfHome),
|
|
746
|
+
detached: process.platform !== "win32",
|
|
288
747
|
stdio: ["ignore", "pipe", "pipe"]
|
|
289
748
|
});
|
|
290
|
-
|
|
291
|
-
let settled = false;
|
|
292
|
-
const timeout = setTimeout(() => {
|
|
293
|
-
if (settled) {
|
|
294
|
-
return;
|
|
295
|
-
}
|
|
296
|
-
settled = true;
|
|
297
|
-
try {
|
|
298
|
-
child.kill();
|
|
299
|
-
} catch {
|
|
300
|
-
}
|
|
301
|
-
resolve({ exitCode: null, stderr: stderrBuf, timedOutAfterMs: timeoutMs });
|
|
302
|
-
}, timeoutMs);
|
|
303
|
-
child.stderr.on("data", (data) => {
|
|
304
|
-
stderrBuf += data.toString();
|
|
305
|
-
});
|
|
306
|
-
child.on("close", (code, signal) => {
|
|
307
|
-
if (settled) {
|
|
308
|
-
return;
|
|
309
|
-
}
|
|
310
|
-
settled = true;
|
|
311
|
-
clearTimeout(timeout);
|
|
312
|
-
resolve(
|
|
313
|
-
signal === null ? { exitCode: code, stderr: stderrBuf } : { exitCode: code, stderr: stderrBuf, signal }
|
|
314
|
-
);
|
|
315
|
-
});
|
|
316
|
-
child.on("error", (err) => {
|
|
317
|
-
if (settled) {
|
|
318
|
-
return;
|
|
319
|
-
}
|
|
320
|
-
settled = true;
|
|
321
|
-
clearTimeout(timeout);
|
|
322
|
-
resolve({ exitCode: null, stderr: err.message });
|
|
323
|
-
});
|
|
749
|
+
attachSshExecution(child, context, options, resolve, reject);
|
|
324
750
|
});
|
|
325
751
|
}
|
|
752
|
+
function signalChild(child, signal) {
|
|
753
|
+
if (process.platform !== "win32" && child.pid !== void 0) {
|
|
754
|
+
try {
|
|
755
|
+
process.kill(-child.pid, signal);
|
|
756
|
+
return;
|
|
757
|
+
} catch {
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
try {
|
|
761
|
+
child.kill(signal);
|
|
762
|
+
} catch {
|
|
763
|
+
}
|
|
764
|
+
}
|
|
326
765
|
function isSshDisabledError(stderr) {
|
|
327
766
|
const lower = stderr.toLowerCase();
|
|
328
767
|
return lower.includes("not authorized") || lower.includes("ssh support is disabled");
|
|
329
768
|
}
|
|
330
|
-
function spawnSshTunnel(appName, localPort, remotePort, context) {
|
|
769
|
+
function spawnSshTunnel(appName, localPort, remotePort, context, target = {}) {
|
|
331
770
|
const tunnelArg = `${localPort.toString()}:localhost:${remotePort.toString()}`;
|
|
332
|
-
const
|
|
333
|
-
|
|
771
|
+
const args = buildCfSshArgs(appName, target, ["-N", "-L", tunnelArg]);
|
|
772
|
+
const child = spawn(resolveBin(context), [...args], {
|
|
334
773
|
env: buildEnv(context.cfHome),
|
|
335
|
-
|
|
336
|
-
|
|
774
|
+
detached: process.platform !== "win32",
|
|
775
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
337
776
|
});
|
|
777
|
+
child.stdout.resume();
|
|
778
|
+
child.stderr.resume();
|
|
779
|
+
return child;
|
|
338
780
|
}
|
|
339
781
|
|
|
340
782
|
// src/paths.ts
|
|
341
783
|
import { homedir } from "os";
|
|
342
|
-
import { join } from "path";
|
|
784
|
+
import { isAbsolute, join } from "path";
|
|
343
785
|
var SAPTOOLS_DIR_NAME = ".saptools";
|
|
344
|
-
var CF_DEBUGGER_STATE_FILENAME = "cf-debugger-state.json";
|
|
345
|
-
var CF_DEBUGGER_LOCK_FILENAME = "cf-debugger-state.lock";
|
|
346
|
-
var CF_DEBUGGER_HOMES_DIRNAME = "cf-debugger-homes";
|
|
786
|
+
var CF_DEBUGGER_STATE_FILENAME = "cf-debugger-state-v2.json";
|
|
787
|
+
var CF_DEBUGGER_LOCK_FILENAME = "cf-debugger-state-v2.lock";
|
|
788
|
+
var CF_DEBUGGER_HOMES_DIRNAME = "cf-debugger-homes-v2";
|
|
789
|
+
var SESSION_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
347
790
|
function saptoolsDir() {
|
|
348
791
|
return join(homedir(), SAPTOOLS_DIR_NAME);
|
|
349
792
|
}
|
|
@@ -353,9 +796,21 @@ function stateFilePath() {
|
|
|
353
796
|
function stateLockPath() {
|
|
354
797
|
return join(saptoolsDir(), CF_DEBUGGER_LOCK_FILENAME);
|
|
355
798
|
}
|
|
799
|
+
function isSafeSessionId(sessionId) {
|
|
800
|
+
return SESSION_ID_PATTERN.test(sessionId);
|
|
801
|
+
}
|
|
356
802
|
function sessionCfHomeDir(sessionId) {
|
|
803
|
+
if (!isSafeSessionId(sessionId)) {
|
|
804
|
+
throw new Error("Invalid debugger session ID.");
|
|
805
|
+
}
|
|
357
806
|
return join(saptoolsDir(), CF_DEBUGGER_HOMES_DIRNAME, sessionId);
|
|
358
807
|
}
|
|
808
|
+
function isOwnedSessionCfHomeDir(sessionId, candidate) {
|
|
809
|
+
if (!isSafeSessionId(sessionId) || !isAbsolute(candidate)) {
|
|
810
|
+
return false;
|
|
811
|
+
}
|
|
812
|
+
return candidate === sessionCfHomeDir(sessionId);
|
|
813
|
+
}
|
|
359
814
|
|
|
360
815
|
// src/network/ports.ts
|
|
361
816
|
import { execFile as execFile3 } from "child_process";
|
|
@@ -496,177 +951,488 @@ async function isPortListening(port, timeoutMs = 200) {
|
|
|
496
951
|
});
|
|
497
952
|
});
|
|
498
953
|
}
|
|
499
|
-
|
|
954
|
+
function throwIfAborted(signal) {
|
|
955
|
+
if (signal?.aborted) {
|
|
956
|
+
throw new CfDebuggerError("ABORTED", "Operation aborted by caller");
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
function waitForNextProbe(delayMs, signal) {
|
|
960
|
+
throwIfAborted(signal);
|
|
961
|
+
return new Promise((resolve, reject) => {
|
|
962
|
+
const onAbort = () => {
|
|
963
|
+
clearTimeout(timer);
|
|
964
|
+
reject(new CfDebuggerError("ABORTED", "Operation aborted by caller"));
|
|
965
|
+
};
|
|
966
|
+
const timer = setTimeout(() => {
|
|
967
|
+
signal?.removeEventListener("abort", onAbort);
|
|
968
|
+
resolve();
|
|
969
|
+
}, delayMs);
|
|
970
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
971
|
+
});
|
|
972
|
+
}
|
|
973
|
+
async function probeTunnelReady(port, timeoutMs, signal) {
|
|
500
974
|
const pollIntervalMs = 250;
|
|
501
975
|
const started = Date.now();
|
|
976
|
+
throwIfAborted(signal);
|
|
502
977
|
while (Date.now() - started < timeoutMs) {
|
|
503
978
|
const connected = await isPortListening(port);
|
|
504
979
|
if (connected) {
|
|
505
980
|
return true;
|
|
506
981
|
}
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
});
|
|
982
|
+
const remainingMs = timeoutMs - (Date.now() - started);
|
|
983
|
+
await waitForNextProbe(Math.min(pollIntervalMs, Math.max(0, remainingMs)), signal);
|
|
510
984
|
}
|
|
985
|
+
throwIfAborted(signal);
|
|
511
986
|
return false;
|
|
512
987
|
}
|
|
513
988
|
async function findListeningProcessId(port) {
|
|
514
989
|
const pids = await findListeningPids(port);
|
|
515
990
|
return pids[0];
|
|
516
991
|
}
|
|
517
|
-
async function killProcessOnPort(port) {
|
|
518
|
-
const portStr = port.toString();
|
|
519
|
-
if (process.platform === "win32") {
|
|
520
|
-
try {
|
|
521
|
-
const pids = await findListeningPidsWithNetstat(port);
|
|
522
|
-
for (const pid of pids) {
|
|
523
|
-
try {
|
|
524
|
-
await execFileAsync3("taskkill", ["/F", "/PID", pid.toString()]);
|
|
525
|
-
} catch {
|
|
526
|
-
}
|
|
527
|
-
}
|
|
528
|
-
} catch {
|
|
529
|
-
}
|
|
530
|
-
return;
|
|
531
|
-
}
|
|
532
|
-
try {
|
|
533
|
-
const { stdout } = await execFileAsync3("lsof", ["-t", "-i", `tcp:${portStr}`]);
|
|
534
|
-
const lines = stdout.trim().split("\n").filter((line) => line.length > 0);
|
|
535
|
-
for (const line of lines) {
|
|
536
|
-
const pid = Number.parseInt(line, 10);
|
|
537
|
-
if (Number.isNaN(pid)) {
|
|
538
|
-
continue;
|
|
539
|
-
}
|
|
540
|
-
try {
|
|
541
|
-
process.kill(pid, "SIGKILL");
|
|
542
|
-
} catch {
|
|
543
|
-
}
|
|
544
|
-
}
|
|
545
|
-
} catch {
|
|
546
|
-
}
|
|
547
|
-
}
|
|
548
992
|
|
|
549
993
|
// src/session-state/store.ts
|
|
550
|
-
import { randomUUID } from "crypto";
|
|
551
|
-
import { mkdir as mkdir2, readFile as
|
|
994
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
995
|
+
import { chmod as chmod2, mkdir as mkdir2, readFile as readFile3, rename, unlink as unlink2, writeFile } from "fs/promises";
|
|
552
996
|
import { hostname as getHostname } from "os";
|
|
553
|
-
import { dirname as dirname2 } from "path";
|
|
554
|
-
import
|
|
997
|
+
import { dirname as dirname2, isAbsolute as isAbsolute3 } from "path";
|
|
998
|
+
import process3 from "process";
|
|
555
999
|
|
|
556
1000
|
// src/lock.ts
|
|
557
|
-
import {
|
|
1001
|
+
import { randomUUID } from "crypto";
|
|
1002
|
+
import { chmod, mkdir, open, readFile as readFile2, stat, unlink } from "fs/promises";
|
|
1003
|
+
import { hostname } from "os";
|
|
558
1004
|
import { dirname } from "path";
|
|
1005
|
+
import process2 from "process";
|
|
559
1006
|
var DEFAULT_POLL_MS = 50;
|
|
1007
|
+
var DEFAULT_STALE_MS = 6e4;
|
|
560
1008
|
var DEFAULT_TIMEOUT_MS = 1e4;
|
|
561
1009
|
function sleep(ms) {
|
|
562
1010
|
return new Promise((resolve) => {
|
|
563
1011
|
setTimeout(resolve, ms);
|
|
564
1012
|
});
|
|
565
1013
|
}
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
for (; ; ) {
|
|
570
|
-
try {
|
|
571
|
-
return await open(lockPath, "wx");
|
|
572
|
-
} catch (err) {
|
|
573
|
-
const code = err.code;
|
|
574
|
-
if (code !== "EEXIST") {
|
|
575
|
-
throw err;
|
|
576
|
-
}
|
|
577
|
-
}
|
|
578
|
-
if (Date.now() > deadline) {
|
|
579
|
-
throw new Error(`Timed out acquiring file lock at ${lockPath}`);
|
|
580
|
-
}
|
|
581
|
-
await sleep(pollMs);
|
|
1014
|
+
function errorCode(error) {
|
|
1015
|
+
if (typeof error !== "object" || error === null) {
|
|
1016
|
+
return void 0;
|
|
582
1017
|
}
|
|
1018
|
+
const code = Reflect.get(error, "code");
|
|
1019
|
+
return typeof code === "string" ? code : void 0;
|
|
583
1020
|
}
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
await unlink(lockPath).catch((err) => {
|
|
587
|
-
const code = err.code;
|
|
588
|
-
if (code !== "ENOENT") {
|
|
589
|
-
throw err;
|
|
590
|
-
}
|
|
591
|
-
});
|
|
1021
|
+
function field(value, key) {
|
|
1022
|
+
return Reflect.get(value, key);
|
|
592
1023
|
}
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
const pollMs = options?.pollMs ?? DEFAULT_POLL_MS;
|
|
596
|
-
const handle = await acquireFileLock(lockPath, timeoutMs, pollMs);
|
|
1024
|
+
function parseLockOwner(raw) {
|
|
1025
|
+
let value;
|
|
597
1026
|
try {
|
|
598
|
-
|
|
599
|
-
}
|
|
600
|
-
|
|
1027
|
+
value = JSON.parse(raw);
|
|
1028
|
+
} catch {
|
|
1029
|
+
return void 0;
|
|
1030
|
+
}
|
|
1031
|
+
if (typeof value !== "object" || value === null) {
|
|
1032
|
+
return void 0;
|
|
1033
|
+
}
|
|
1034
|
+
const lockHostname = field(value, "hostname");
|
|
1035
|
+
const pid = field(value, "pid");
|
|
1036
|
+
const token = field(value, "token");
|
|
1037
|
+
const version = field(value, "version");
|
|
1038
|
+
if (typeof lockHostname !== "string" || typeof pid !== "number" || !Number.isSafeInteger(pid) || pid <= 0) {
|
|
1039
|
+
return void 0;
|
|
1040
|
+
}
|
|
1041
|
+
if (typeof token !== "string" || version !== "1") {
|
|
1042
|
+
return void 0;
|
|
601
1043
|
}
|
|
1044
|
+
return { hostname: lockHostname, pid, token, version };
|
|
602
1045
|
}
|
|
603
|
-
|
|
604
|
-
// src/session-state/store.ts
|
|
605
|
-
async function readJsonFile(path) {
|
|
606
|
-
let raw;
|
|
1046
|
+
function isProcessAlive(pid) {
|
|
607
1047
|
try {
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
1048
|
+
process2.kill(pid, 0);
|
|
1049
|
+
return true;
|
|
1050
|
+
} catch (error) {
|
|
1051
|
+
return errorCode(error) !== "ESRCH";
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
async function readLockOwner(lockPath) {
|
|
1055
|
+
try {
|
|
1056
|
+
await chmod(lockPath, 384);
|
|
1057
|
+
return parseLockOwner(await readFile2(lockPath, "utf8"));
|
|
1058
|
+
} catch (error) {
|
|
1059
|
+
if (errorCode(error) === "ENOENT") {
|
|
612
1060
|
return void 0;
|
|
613
1061
|
}
|
|
614
|
-
throw
|
|
1062
|
+
throw error;
|
|
615
1063
|
}
|
|
1064
|
+
}
|
|
1065
|
+
async function isStaleLock(lockPath, staleMs) {
|
|
1066
|
+
let modifiedAt;
|
|
616
1067
|
try {
|
|
617
|
-
|
|
618
|
-
} catch {
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
return void 0;
|
|
1068
|
+
modifiedAt = (await stat(lockPath)).mtimeMs;
|
|
1069
|
+
} catch (error) {
|
|
1070
|
+
if (errorCode(error) === "ENOENT") {
|
|
1071
|
+
return true;
|
|
1072
|
+
}
|
|
1073
|
+
throw error;
|
|
624
1074
|
}
|
|
1075
|
+
const owner = await readLockOwner(lockPath);
|
|
1076
|
+
if (owner?.hostname === hostname()) {
|
|
1077
|
+
return !isProcessAlive(owner.pid);
|
|
1078
|
+
}
|
|
1079
|
+
return owner === void 0 && Date.now() - modifiedAt > staleMs;
|
|
625
1080
|
}
|
|
626
|
-
async function
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
return { version: "1", sessions: [] };
|
|
635
|
-
}
|
|
636
|
-
function isValidState(value) {
|
|
637
|
-
if (typeof value !== "object" || value === null) {
|
|
638
|
-
return false;
|
|
1081
|
+
async function reclaimAbandonedRecoveryLock(recoveryPath, staleMs) {
|
|
1082
|
+
if (!await isStaleLock(recoveryPath, staleMs)) {
|
|
1083
|
+
return;
|
|
1084
|
+
}
|
|
1085
|
+
const owner = await readLockOwner(recoveryPath);
|
|
1086
|
+
if (owner !== void 0) {
|
|
1087
|
+
await removeOwnedLock(recoveryPath, owner.token);
|
|
1088
|
+
return;
|
|
639
1089
|
}
|
|
640
|
-
|
|
641
|
-
|
|
1090
|
+
await unlink(recoveryPath).catch((error) => {
|
|
1091
|
+
if (errorCode(error) !== "ENOENT") {
|
|
1092
|
+
throw error;
|
|
1093
|
+
}
|
|
1094
|
+
});
|
|
642
1095
|
}
|
|
643
|
-
function
|
|
1096
|
+
async function reclaimStaleLock(lockPath, staleMs) {
|
|
1097
|
+
const recoveryPath = `${lockPath}.recovery`;
|
|
1098
|
+
let recoveryLock;
|
|
644
1099
|
try {
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
1100
|
+
recoveryLock = await createFileLock(recoveryPath);
|
|
1101
|
+
} catch (error) {
|
|
1102
|
+
if (errorCode(error) === "EEXIST") {
|
|
1103
|
+
await reclaimAbandonedRecoveryLock(recoveryPath, staleMs);
|
|
1104
|
+
return false;
|
|
1105
|
+
}
|
|
1106
|
+
throw error;
|
|
1107
|
+
}
|
|
1108
|
+
try {
|
|
1109
|
+
if (!await isStaleLock(lockPath, staleMs)) {
|
|
650
1110
|
return false;
|
|
651
1111
|
}
|
|
1112
|
+
try {
|
|
1113
|
+
await unlink(lockPath);
|
|
1114
|
+
} catch (error) {
|
|
1115
|
+
if (errorCode(error) !== "ENOENT") {
|
|
1116
|
+
throw error;
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
652
1119
|
return true;
|
|
1120
|
+
} finally {
|
|
1121
|
+
await releaseFileLock(recoveryPath, recoveryLock);
|
|
653
1122
|
}
|
|
654
1123
|
}
|
|
655
|
-
async function
|
|
656
|
-
|
|
657
|
-
|
|
1124
|
+
async function removeOwnedLock(lockPath, token) {
|
|
1125
|
+
const owner = await readLockOwner(lockPath);
|
|
1126
|
+
if (owner?.token !== token) {
|
|
1127
|
+
return;
|
|
658
1128
|
}
|
|
659
|
-
|
|
660
|
-
|
|
1129
|
+
await unlink(lockPath).catch((error) => {
|
|
1130
|
+
if (errorCode(error) !== "ENOENT") {
|
|
1131
|
+
throw error;
|
|
1132
|
+
}
|
|
1133
|
+
});
|
|
1134
|
+
}
|
|
1135
|
+
async function createFileLock(lockPath) {
|
|
1136
|
+
const handle = await open(lockPath, "wx", 384);
|
|
1137
|
+
const token = randomUUID();
|
|
1138
|
+
try {
|
|
1139
|
+
await handle.writeFile(`${JSON.stringify({ hostname: hostname(), pid: process2.pid, token, version: "1" })}
|
|
1140
|
+
`, "utf8");
|
|
1141
|
+
return { handle, token };
|
|
1142
|
+
} catch (error) {
|
|
1143
|
+
await handle.close();
|
|
1144
|
+
await unlink(lockPath).catch(() => false);
|
|
1145
|
+
throw error;
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
async function acquireFileLock(lockPath, timeoutMs, pollMs, staleMs) {
|
|
1149
|
+
const deadline = Date.now() + timeoutMs;
|
|
1150
|
+
const parentDir = dirname(lockPath);
|
|
1151
|
+
await mkdir(parentDir, { recursive: true, mode: 448 });
|
|
1152
|
+
await chmod(parentDir, 448);
|
|
1153
|
+
for (; ; ) {
|
|
1154
|
+
try {
|
|
1155
|
+
return await createFileLock(lockPath);
|
|
1156
|
+
} catch (error) {
|
|
1157
|
+
if (errorCode(error) !== "EEXIST") {
|
|
1158
|
+
throw error;
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
if (await reclaimStaleLock(lockPath, staleMs)) {
|
|
1162
|
+
continue;
|
|
1163
|
+
}
|
|
1164
|
+
if (Date.now() > deadline) {
|
|
1165
|
+
throw new Error(`Timed out acquiring file lock at ${lockPath}`);
|
|
1166
|
+
}
|
|
1167
|
+
await sleep(pollMs);
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
async function releaseFileLock(lockPath, lock) {
|
|
1171
|
+
await lock.handle.close();
|
|
1172
|
+
await removeOwnedLock(lockPath, lock.token);
|
|
1173
|
+
}
|
|
1174
|
+
async function withFileLock(lockPath, work, options) {
|
|
1175
|
+
const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
1176
|
+
const pollMs = options?.pollMs ?? DEFAULT_POLL_MS;
|
|
1177
|
+
const staleMs = options?.staleMs ?? DEFAULT_STALE_MS;
|
|
1178
|
+
const lock = await acquireFileLock(lockPath, timeoutMs, pollMs, staleMs);
|
|
1179
|
+
try {
|
|
1180
|
+
return await work();
|
|
1181
|
+
} finally {
|
|
1182
|
+
await releaseFileLock(lockPath, lock);
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
// src/session-state/decoder.ts
|
|
1187
|
+
import { isAbsolute as isAbsolute2 } from "path";
|
|
1188
|
+
var INVALID_SESSION = new Error("Invalid persisted debugger session");
|
|
1189
|
+
var VALID_STATUSES = /* @__PURE__ */ new Set([
|
|
1190
|
+
"starting",
|
|
1191
|
+
"logging-in",
|
|
1192
|
+
"targeting",
|
|
1193
|
+
"ssh-enabling",
|
|
1194
|
+
"ssh-restarting",
|
|
1195
|
+
"signaling",
|
|
1196
|
+
"tunneling",
|
|
1197
|
+
"ready",
|
|
1198
|
+
"stopping",
|
|
1199
|
+
"stopped",
|
|
1200
|
+
"error"
|
|
1201
|
+
]);
|
|
1202
|
+
function field2(value, key) {
|
|
1203
|
+
return Reflect.get(value, key);
|
|
1204
|
+
}
|
|
1205
|
+
function requireString(value, key) {
|
|
1206
|
+
const candidate = field2(value, key);
|
|
1207
|
+
if (typeof candidate !== "string" || candidate.length === 0) {
|
|
1208
|
+
throw INVALID_SESSION;
|
|
1209
|
+
}
|
|
1210
|
+
return candidate;
|
|
1211
|
+
}
|
|
1212
|
+
function optionalString2(value, key) {
|
|
1213
|
+
const candidate = field2(value, key);
|
|
1214
|
+
if (candidate === void 0) {
|
|
1215
|
+
return void 0;
|
|
1216
|
+
}
|
|
1217
|
+
if (typeof candidate !== "string") {
|
|
1218
|
+
throw INVALID_SESSION;
|
|
1219
|
+
}
|
|
1220
|
+
return candidate;
|
|
1221
|
+
}
|
|
1222
|
+
function requireInteger(value, key, minimum, maximum) {
|
|
1223
|
+
const candidate = field2(value, key);
|
|
1224
|
+
if (!Number.isSafeInteger(candidate) || typeof candidate !== "number") {
|
|
1225
|
+
throw INVALID_SESSION;
|
|
1226
|
+
}
|
|
1227
|
+
if (candidate < minimum || candidate > maximum) {
|
|
1228
|
+
throw INVALID_SESSION;
|
|
1229
|
+
}
|
|
1230
|
+
return candidate;
|
|
1231
|
+
}
|
|
1232
|
+
function optionalInteger(value, key, minimum) {
|
|
1233
|
+
const candidate = field2(value, key);
|
|
1234
|
+
if (candidate === void 0) {
|
|
1235
|
+
return void 0;
|
|
1236
|
+
}
|
|
1237
|
+
if (!Number.isSafeInteger(candidate) || typeof candidate !== "number" || candidate < minimum) {
|
|
1238
|
+
throw INVALID_SESSION;
|
|
1239
|
+
}
|
|
1240
|
+
return candidate;
|
|
1241
|
+
}
|
|
1242
|
+
function requireSessionId(value) {
|
|
1243
|
+
const sessionId = requireString(value, "sessionId");
|
|
1244
|
+
if (!isSafeSessionId(sessionId)) {
|
|
1245
|
+
throw INVALID_SESSION;
|
|
1246
|
+
}
|
|
1247
|
+
return sessionId;
|
|
1248
|
+
}
|
|
1249
|
+
function requireAbsolutePath(value, key) {
|
|
1250
|
+
const path = requireString(value, key);
|
|
1251
|
+
if (!isAbsolute2(path)) {
|
|
1252
|
+
throw INVALID_SESSION;
|
|
1253
|
+
}
|
|
1254
|
+
return path;
|
|
1255
|
+
}
|
|
1256
|
+
function requireTimestamp(value) {
|
|
1257
|
+
const timestamp = requireString(value, "startedAt");
|
|
1258
|
+
if (Number.isNaN(Date.parse(timestamp))) {
|
|
1259
|
+
throw INVALID_SESSION;
|
|
1260
|
+
}
|
|
1261
|
+
return timestamp;
|
|
1262
|
+
}
|
|
1263
|
+
function optionalTimestamp(value, key) {
|
|
1264
|
+
const timestamp = optionalString2(value, key);
|
|
1265
|
+
if (timestamp !== void 0 && Number.isNaN(Date.parse(timestamp))) {
|
|
1266
|
+
throw INVALID_SESSION;
|
|
1267
|
+
}
|
|
1268
|
+
return timestamp;
|
|
1269
|
+
}
|
|
1270
|
+
function isSessionStatus(value) {
|
|
1271
|
+
return VALID_STATUSES.has(value);
|
|
1272
|
+
}
|
|
1273
|
+
function requireStatus(value) {
|
|
1274
|
+
const status = requireString(value, "status");
|
|
1275
|
+
if (!isSessionStatus(status)) {
|
|
1276
|
+
throw INVALID_SESSION;
|
|
1277
|
+
}
|
|
1278
|
+
return status;
|
|
1279
|
+
}
|
|
1280
|
+
function decodeSession(value) {
|
|
1281
|
+
if (typeof value !== "object" || value === null) {
|
|
1282
|
+
return void 0;
|
|
1283
|
+
}
|
|
1284
|
+
try {
|
|
1285
|
+
const processName = requireString(value, "process");
|
|
1286
|
+
const instance = requireInteger(value, "instance", 0, Number.MAX_SAFE_INTEGER);
|
|
1287
|
+
const nodePid = optionalInteger(value, "nodePid", 1);
|
|
1288
|
+
const target = resolveNodeTarget({
|
|
1289
|
+
process: processName,
|
|
1290
|
+
instance,
|
|
1291
|
+
...nodePid === void 0 ? {} : { nodePid }
|
|
1292
|
+
});
|
|
1293
|
+
const pid = requireInteger(value, "pid", 1, Number.MAX_SAFE_INTEGER);
|
|
1294
|
+
const controllerPid = requireInteger(value, "controllerPid", 1, Number.MAX_SAFE_INTEGER);
|
|
1295
|
+
const tunnelPid = optionalInteger(value, "tunnelPid", 1);
|
|
1296
|
+
const remoteNodePid = optionalInteger(value, "remoteNodePid", 1);
|
|
1297
|
+
const stopRequestedAt = optionalTimestamp(value, "stopRequestedAt");
|
|
1298
|
+
const message = optionalString2(value, "message");
|
|
1299
|
+
const status = requireStatus(value);
|
|
1300
|
+
if (pid !== (tunnelPid ?? controllerPid) || status === "ready" && tunnelPid === void 0) {
|
|
1301
|
+
throw INVALID_SESSION;
|
|
1302
|
+
}
|
|
1303
|
+
return {
|
|
1304
|
+
sessionId: requireSessionId(value),
|
|
1305
|
+
pid,
|
|
1306
|
+
controllerPid,
|
|
1307
|
+
...tunnelPid === void 0 ? {} : { tunnelPid },
|
|
1308
|
+
hostname: requireString(value, "hostname"),
|
|
1309
|
+
region: requireString(value, "region"),
|
|
1310
|
+
org: requireString(value, "org"),
|
|
1311
|
+
space: requireString(value, "space"),
|
|
1312
|
+
app: requireString(value, "app"),
|
|
1313
|
+
process: target.process,
|
|
1314
|
+
instance: target.instance,
|
|
1315
|
+
...nodePid === void 0 ? {} : { nodePid },
|
|
1316
|
+
apiEndpoint: requireString(value, "apiEndpoint"),
|
|
1317
|
+
localPort: requireInteger(value, "localPort", 1, 65535),
|
|
1318
|
+
remotePort: requireInteger(value, "remotePort", 1, 65535),
|
|
1319
|
+
cfHomeDir: requireAbsolutePath(value, "cfHomeDir"),
|
|
1320
|
+
startedAt: requireTimestamp(value),
|
|
1321
|
+
status,
|
|
1322
|
+
...remoteNodePid === void 0 ? {} : { remoteNodePid },
|
|
1323
|
+
...stopRequestedAt === void 0 ? {} : { stopRequestedAt },
|
|
1324
|
+
...message === void 0 ? {} : { message }
|
|
1325
|
+
};
|
|
1326
|
+
} catch {
|
|
1327
|
+
return void 0;
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
function decodeStateFile(value) {
|
|
1331
|
+
if (typeof value !== "object" || value === null || field2(value, "version") !== "2") {
|
|
1332
|
+
return void 0;
|
|
1333
|
+
}
|
|
1334
|
+
const rawSessions = field2(value, "sessions");
|
|
1335
|
+
if (!Array.isArray(rawSessions)) {
|
|
1336
|
+
return void 0;
|
|
1337
|
+
}
|
|
1338
|
+
const sessionIds = /* @__PURE__ */ new Set();
|
|
1339
|
+
const sessions = [];
|
|
1340
|
+
for (const rawSession of rawSessions) {
|
|
1341
|
+
const session = decodeSession(rawSession);
|
|
1342
|
+
if (session === void 0 || sessionIds.has(session.sessionId)) {
|
|
1343
|
+
return void 0;
|
|
1344
|
+
}
|
|
1345
|
+
sessionIds.add(session.sessionId);
|
|
1346
|
+
sessions.push(session);
|
|
1347
|
+
}
|
|
1348
|
+
return { version: "2", sessions };
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
// src/session-state/store.ts
|
|
1352
|
+
function errorCode2(error) {
|
|
1353
|
+
if (typeof error !== "object" || error === null) {
|
|
1354
|
+
return void 0;
|
|
1355
|
+
}
|
|
1356
|
+
const code = Reflect.get(error, "code");
|
|
1357
|
+
return typeof code === "string" ? code : void 0;
|
|
1358
|
+
}
|
|
1359
|
+
async function readJsonFile(path) {
|
|
1360
|
+
let raw;
|
|
1361
|
+
try {
|
|
1362
|
+
await chmod2(path, 384);
|
|
1363
|
+
raw = await readFile3(path, "utf8");
|
|
1364
|
+
} catch (err) {
|
|
1365
|
+
if (errorCode2(err) === "ENOENT") {
|
|
1366
|
+
return void 0;
|
|
1367
|
+
}
|
|
1368
|
+
throw err;
|
|
1369
|
+
}
|
|
1370
|
+
try {
|
|
1371
|
+
return JSON.parse(raw);
|
|
1372
|
+
} catch {
|
|
1373
|
+
process3.stderr.write(
|
|
1374
|
+
`[cf-debugger] warning: state file at ${path} is not valid JSON; resetting to empty.
|
|
1375
|
+
`
|
|
1376
|
+
);
|
|
1377
|
+
return void 0;
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
async function writeJsonFileAtomic(path, value) {
|
|
1381
|
+
const tempPath = `${path}.${randomUUID2()}.tmp`;
|
|
1382
|
+
const parentDir = dirname2(path);
|
|
1383
|
+
await mkdir2(parentDir, { recursive: true, mode: 448 });
|
|
1384
|
+
await chmod2(parentDir, 448);
|
|
1385
|
+
let renamed = false;
|
|
1386
|
+
try {
|
|
1387
|
+
await writeFile(tempPath, `${JSON.stringify(value, null, 2)}
|
|
1388
|
+
`, {
|
|
1389
|
+
encoding: "utf8",
|
|
1390
|
+
flag: "wx",
|
|
1391
|
+
mode: 384
|
|
1392
|
+
});
|
|
1393
|
+
await rename(tempPath, path);
|
|
1394
|
+
renamed = true;
|
|
1395
|
+
await chmod2(path, 384);
|
|
1396
|
+
} finally {
|
|
1397
|
+
if (!renamed) {
|
|
1398
|
+
await unlink2(tempPath).catch(() => false);
|
|
1399
|
+
}
|
|
661
1400
|
}
|
|
662
|
-
|
|
1401
|
+
}
|
|
1402
|
+
function emptyState() {
|
|
1403
|
+
return { version: "2", sessions: [] };
|
|
1404
|
+
}
|
|
1405
|
+
function isPidAlive(pid) {
|
|
1406
|
+
try {
|
|
1407
|
+
process3.kill(pid, 0);
|
|
1408
|
+
return true;
|
|
1409
|
+
} catch (err) {
|
|
1410
|
+
if (errorCode2(err) === "ESRCH") {
|
|
1411
|
+
return false;
|
|
1412
|
+
}
|
|
1413
|
+
return true;
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
function isPidOrGroupAlive(pid) {
|
|
1417
|
+
if (isPidAlive(pid)) {
|
|
663
1418
|
return true;
|
|
664
1419
|
}
|
|
665
|
-
|
|
1420
|
+
return isProcessGroupAlive(pid);
|
|
1421
|
+
}
|
|
1422
|
+
function isProcessGroupAlive(pid) {
|
|
1423
|
+
return process3.platform !== "win32" && isPidAlive(-pid);
|
|
1424
|
+
}
|
|
1425
|
+
async function isSessionHealthy(session, host) {
|
|
1426
|
+
if (session.hostname !== host) {
|
|
666
1427
|
return false;
|
|
667
1428
|
}
|
|
668
|
-
|
|
669
|
-
|
|
1429
|
+
if (session.status !== "ready" && isPidAlive(session.controllerPid ?? session.pid)) {
|
|
1430
|
+
return true;
|
|
1431
|
+
}
|
|
1432
|
+
if (session.tunnelPid !== void 0 && isPidOrGroupAlive(session.tunnelPid)) {
|
|
1433
|
+
return true;
|
|
1434
|
+
}
|
|
1435
|
+
return await isPortListening(session.localPort);
|
|
670
1436
|
}
|
|
671
1437
|
async function filterStaleSessions(sessions) {
|
|
672
1438
|
const host = getHostname();
|
|
@@ -679,23 +1445,35 @@ async function filterStaleSessions(sessions) {
|
|
|
679
1445
|
return checks.filter(([, healthy]) => healthy).map(([session]) => session);
|
|
680
1446
|
}
|
|
681
1447
|
async function readStateRaw() {
|
|
682
|
-
const
|
|
683
|
-
|
|
1448
|
+
const path = stateFilePath();
|
|
1449
|
+
const parsed = await readJsonFile(path);
|
|
1450
|
+
if (parsed === void 0) {
|
|
684
1451
|
return emptyState();
|
|
685
1452
|
}
|
|
686
|
-
|
|
1453
|
+
const decoded = decodeStateFile(parsed);
|
|
1454
|
+
if (decoded !== void 0) {
|
|
1455
|
+
return decoded;
|
|
1456
|
+
}
|
|
1457
|
+
process3.stderr.write(
|
|
1458
|
+
`[cf-debugger] warning: state file at ${path} has an invalid structure; resetting to empty.
|
|
1459
|
+
`
|
|
1460
|
+
);
|
|
1461
|
+
return emptyState();
|
|
687
1462
|
}
|
|
688
1463
|
async function writeState(state) {
|
|
689
1464
|
await writeJsonFileAtomic(stateFilePath(), state);
|
|
690
1465
|
}
|
|
691
1466
|
async function readAndPruneLocked() {
|
|
692
1467
|
const raw = await readStateRaw();
|
|
693
|
-
const
|
|
694
|
-
const
|
|
1468
|
+
const host = getHostname();
|
|
1469
|
+
const remote = raw.sessions.filter((session) => session.hostname !== host);
|
|
1470
|
+
const local = raw.sessions.filter((session) => session.hostname === host);
|
|
1471
|
+
const pruned = await filterStaleSessions(local);
|
|
1472
|
+
const removed = local.filter(
|
|
695
1473
|
(session) => !pruned.some((active) => active.sessionId === session.sessionId)
|
|
696
1474
|
);
|
|
697
1475
|
if (removed.length > 0) {
|
|
698
|
-
await writeState({ version: "
|
|
1476
|
+
await writeState({ version: "2", sessions: [...remote, ...pruned] });
|
|
699
1477
|
}
|
|
700
1478
|
return { sessions: pruned, removed };
|
|
701
1479
|
}
|
|
@@ -703,14 +1481,38 @@ async function readActiveSessions() {
|
|
|
703
1481
|
const result = await withFileLock(stateLockPath(), readAndPruneLocked);
|
|
704
1482
|
return result.sessions;
|
|
705
1483
|
}
|
|
1484
|
+
async function readSessionSnapshot() {
|
|
1485
|
+
return await withFileLock(stateLockPath(), async () => {
|
|
1486
|
+
const raw = await readStateRaw();
|
|
1487
|
+
return raw.sessions;
|
|
1488
|
+
});
|
|
1489
|
+
}
|
|
706
1490
|
async function readAndPruneActiveSessions() {
|
|
707
1491
|
return await withFileLock(stateLockPath(), readAndPruneLocked);
|
|
708
1492
|
}
|
|
709
1493
|
function sessionKeyString(key) {
|
|
710
|
-
|
|
1494
|
+
const base = `${key.region}:${key.org}:${key.space}:${key.app}`;
|
|
1495
|
+
if (key.process === void 0 && key.instance === void 0) {
|
|
1496
|
+
return base;
|
|
1497
|
+
}
|
|
1498
|
+
const target = resolveNodeTarget(key);
|
|
1499
|
+
return `${base}:${target.process}:${target.instance.toString()}`;
|
|
711
1500
|
}
|
|
712
1501
|
function matchesKey(session, key) {
|
|
713
|
-
|
|
1502
|
+
const sessionTarget = resolveNodeTarget(session);
|
|
1503
|
+
const keyTarget = resolveNodeTarget(key);
|
|
1504
|
+
return session.region === key.region && session.org === key.org && session.space === key.space && session.app === key.app && sessionTarget.process === keyTarget.process && sessionTarget.instance === keyTarget.instance && (key.apiEndpoint === void 0 || session.apiEndpoint === key.apiEndpoint) && (key.nodePid === void 0 || sessionTarget.nodePid === keyTarget.nodePid);
|
|
1505
|
+
}
|
|
1506
|
+
function matchesRegistrationTarget(session, input, target) {
|
|
1507
|
+
return matchesKey(session, {
|
|
1508
|
+
region: input.region,
|
|
1509
|
+
org: input.org,
|
|
1510
|
+
space: input.space,
|
|
1511
|
+
app: input.app,
|
|
1512
|
+
process: target.process,
|
|
1513
|
+
instance: target.instance,
|
|
1514
|
+
apiEndpoint: input.apiEndpoint
|
|
1515
|
+
});
|
|
714
1516
|
}
|
|
715
1517
|
var DEFAULT_BASE_PORT = 2e4;
|
|
716
1518
|
var DEFAULT_MAX_PORT = 20999;
|
|
@@ -739,9 +1541,13 @@ async function pickPort(preferred, reserved, probe, basePort, maxPort) {
|
|
|
739
1541
|
);
|
|
740
1542
|
}
|
|
741
1543
|
async function registerNewSession(input) {
|
|
1544
|
+
const target = resolveNodeTarget(input);
|
|
742
1545
|
return await withFileLock(stateLockPath(), async () => {
|
|
743
1546
|
const pruneResult = await readAndPruneLocked();
|
|
744
|
-
const
|
|
1547
|
+
const persisted = await readStateRaw();
|
|
1548
|
+
const host = getHostname();
|
|
1549
|
+
const remoteSessions = persisted.sessions.filter((session2) => session2.hostname !== host);
|
|
1550
|
+
const existing = pruneResult.sessions.find((session2) => matchesRegistrationTarget(session2, input, target));
|
|
745
1551
|
if (existing) {
|
|
746
1552
|
return { session: existing, existing };
|
|
747
1553
|
}
|
|
@@ -753,28 +1559,41 @@ async function registerNewSession(input) {
|
|
|
753
1559
|
input.basePort ?? DEFAULT_BASE_PORT,
|
|
754
1560
|
input.maxPort ?? DEFAULT_MAX_PORT
|
|
755
1561
|
);
|
|
756
|
-
const
|
|
757
|
-
const
|
|
758
|
-
|
|
759
|
-
sessionId,
|
|
760
|
-
pid: process2.pid,
|
|
761
|
-
hostname: getHostname(),
|
|
762
|
-
region: input.region,
|
|
763
|
-
org: input.org,
|
|
764
|
-
space: input.space,
|
|
765
|
-
app: input.app,
|
|
766
|
-
apiEndpoint: input.apiEndpoint,
|
|
767
|
-
localPort,
|
|
768
|
-
remotePort: 9229,
|
|
769
|
-
cfHomeDir,
|
|
770
|
-
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
771
|
-
status: "starting"
|
|
772
|
-
};
|
|
773
|
-
const nextSessions = [...pruneResult.sessions, session];
|
|
774
|
-
await writeState({ version: "1", sessions: nextSessions });
|
|
1562
|
+
const session = createRegisteredSession(input, target, localPort);
|
|
1563
|
+
const nextSessions = [...remoteSessions, ...pruneResult.sessions, session];
|
|
1564
|
+
await writeState({ version: "2", sessions: nextSessions });
|
|
775
1565
|
return { session };
|
|
776
1566
|
});
|
|
777
1567
|
}
|
|
1568
|
+
function createRegisteredSession(input, target, localPort) {
|
|
1569
|
+
const sessionId = (input.sessionIdFactory ?? randomUUID2)();
|
|
1570
|
+
if (!isSafeSessionId(sessionId)) {
|
|
1571
|
+
throw new CfDebuggerError("UNSAFE_INPUT", "Generated debugger session ID is invalid.");
|
|
1572
|
+
}
|
|
1573
|
+
const cfHomeDir = input.cfHomeForSession(sessionId);
|
|
1574
|
+
if (!isAbsolute3(cfHomeDir)) {
|
|
1575
|
+
throw new CfDebuggerError("UNSAFE_INPUT", "Debugger CF home must be an absolute path.");
|
|
1576
|
+
}
|
|
1577
|
+
return {
|
|
1578
|
+
sessionId,
|
|
1579
|
+
pid: process3.pid,
|
|
1580
|
+
controllerPid: process3.pid,
|
|
1581
|
+
hostname: getHostname(),
|
|
1582
|
+
region: input.region,
|
|
1583
|
+
org: input.org,
|
|
1584
|
+
space: input.space,
|
|
1585
|
+
app: input.app,
|
|
1586
|
+
process: target.process,
|
|
1587
|
+
instance: target.instance,
|
|
1588
|
+
...target.nodePid === void 0 ? {} : { nodePid: target.nodePid },
|
|
1589
|
+
apiEndpoint: input.apiEndpoint,
|
|
1590
|
+
localPort,
|
|
1591
|
+
remotePort: 9229,
|
|
1592
|
+
cfHomeDir,
|
|
1593
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1594
|
+
status: "starting"
|
|
1595
|
+
};
|
|
1596
|
+
}
|
|
778
1597
|
async function updateSessionStatus(sessionId, status, message) {
|
|
779
1598
|
return await withFileLock(stateLockPath(), async () => {
|
|
780
1599
|
const raw = await readStateRaw();
|
|
@@ -783,32 +1602,31 @@ async function updateSessionStatus(sessionId, status, message) {
|
|
|
783
1602
|
if (session.sessionId !== sessionId) {
|
|
784
1603
|
return session;
|
|
785
1604
|
}
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
startedAt: session.startedAt,
|
|
799
|
-
status
|
|
800
|
-
};
|
|
801
|
-
const next = message === void 0 ? base : { ...base, message };
|
|
1605
|
+
if (status !== "stopping" && startupMutationBlocked(session)) {
|
|
1606
|
+
updated = session;
|
|
1607
|
+
return session;
|
|
1608
|
+
}
|
|
1609
|
+
if (status === "ready" && session.tunnelPid === void 0) {
|
|
1610
|
+
throw new CfDebuggerError(
|
|
1611
|
+
"SESSION_STATE_CONFLICT",
|
|
1612
|
+
"A debugger session cannot become ready before its tunnel PID is recorded."
|
|
1613
|
+
);
|
|
1614
|
+
}
|
|
1615
|
+
const base = withoutMessage(session);
|
|
1616
|
+
const next = message === void 0 ? { ...base, status } : { ...base, status, message };
|
|
802
1617
|
updated = next;
|
|
803
1618
|
return next;
|
|
804
1619
|
});
|
|
805
1620
|
if (updated) {
|
|
806
|
-
await writeState({ version: "
|
|
1621
|
+
await writeState({ version: "2", sessions: nextSessions });
|
|
807
1622
|
}
|
|
808
1623
|
return updated;
|
|
809
1624
|
});
|
|
810
1625
|
}
|
|
811
1626
|
async function updateSessionPid(sessionId, pid) {
|
|
1627
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) {
|
|
1628
|
+
throw new CfDebuggerError("UNSAFE_INPUT", "Tunnel PID must be a positive safe integer.");
|
|
1629
|
+
}
|
|
812
1630
|
return await withFileLock(stateLockPath(), async () => {
|
|
813
1631
|
const raw = await readStateRaw();
|
|
814
1632
|
let updated;
|
|
@@ -816,27 +1634,47 @@ async function updateSessionPid(sessionId, pid) {
|
|
|
816
1634
|
if (session.sessionId !== sessionId) {
|
|
817
1635
|
return session;
|
|
818
1636
|
}
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
org: session.org,
|
|
825
|
-
space: session.space,
|
|
826
|
-
app: session.app,
|
|
827
|
-
apiEndpoint: session.apiEndpoint,
|
|
828
|
-
localPort: session.localPort,
|
|
829
|
-
remotePort: session.remotePort,
|
|
830
|
-
cfHomeDir: session.cfHomeDir,
|
|
831
|
-
startedAt: session.startedAt,
|
|
832
|
-
status: session.status,
|
|
833
|
-
...session.message === void 0 ? {} : { message: session.message }
|
|
834
|
-
};
|
|
1637
|
+
if (startupMutationBlocked(session)) {
|
|
1638
|
+
updated = session;
|
|
1639
|
+
return session;
|
|
1640
|
+
}
|
|
1641
|
+
const next = { ...session, pid, tunnelPid: pid };
|
|
835
1642
|
updated = next;
|
|
836
1643
|
return next;
|
|
837
1644
|
});
|
|
838
1645
|
if (updated !== void 0) {
|
|
839
|
-
await writeState({ version: "
|
|
1646
|
+
await writeState({ version: "2", sessions: nextSessions });
|
|
1647
|
+
}
|
|
1648
|
+
return updated;
|
|
1649
|
+
});
|
|
1650
|
+
}
|
|
1651
|
+
function withoutMessage(session) {
|
|
1652
|
+
const { message, ...clone } = session;
|
|
1653
|
+
void message;
|
|
1654
|
+
return clone;
|
|
1655
|
+
}
|
|
1656
|
+
function startupMutationBlocked(session) {
|
|
1657
|
+
return session.status === "stopping" || session.stopRequestedAt !== void 0;
|
|
1658
|
+
}
|
|
1659
|
+
async function updateSessionRemoteNodePid(sessionId, remoteNodePid) {
|
|
1660
|
+
resolveNodeTarget({ nodePid: remoteNodePid });
|
|
1661
|
+
return await withFileLock(stateLockPath(), async () => {
|
|
1662
|
+
const raw = await readStateRaw();
|
|
1663
|
+
let updated;
|
|
1664
|
+
const nextSessions = raw.sessions.map((session) => {
|
|
1665
|
+
if (session.sessionId !== sessionId) {
|
|
1666
|
+
return session;
|
|
1667
|
+
}
|
|
1668
|
+
if (startupMutationBlocked(session)) {
|
|
1669
|
+
updated = session;
|
|
1670
|
+
return session;
|
|
1671
|
+
}
|
|
1672
|
+
const next = { ...session, remoteNodePid };
|
|
1673
|
+
updated = next;
|
|
1674
|
+
return next;
|
|
1675
|
+
});
|
|
1676
|
+
if (updated !== void 0) {
|
|
1677
|
+
await writeState({ version: "2", sessions: nextSessions });
|
|
840
1678
|
}
|
|
841
1679
|
return updated;
|
|
842
1680
|
});
|
|
@@ -849,72 +1687,145 @@ async function removeSession(sessionId) {
|
|
|
849
1687
|
return void 0;
|
|
850
1688
|
}
|
|
851
1689
|
const remaining = raw.sessions.filter((session) => session.sessionId !== sessionId);
|
|
852
|
-
await writeState({ version: "
|
|
1690
|
+
await writeState({ version: "2", sessions: remaining });
|
|
853
1691
|
return target;
|
|
854
1692
|
});
|
|
855
1693
|
}
|
|
1694
|
+
async function requestSessionStop(sessionId) {
|
|
1695
|
+
return await withFileLock(stateLockPath(), async () => {
|
|
1696
|
+
const raw = await readStateRaw();
|
|
1697
|
+
const target = raw.sessions.find((session) => session.sessionId === sessionId);
|
|
1698
|
+
if (target === void 0) {
|
|
1699
|
+
return void 0;
|
|
1700
|
+
}
|
|
1701
|
+
if (target.status === "ready" || target.stopRequestedAt !== void 0) {
|
|
1702
|
+
return { session: target, previousStatus: target.status };
|
|
1703
|
+
}
|
|
1704
|
+
const requested = { ...target, stopRequestedAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
1705
|
+
const sessions = raw.sessions.map(
|
|
1706
|
+
(session) => session.sessionId === sessionId ? requested : session
|
|
1707
|
+
);
|
|
1708
|
+
await writeState({ version: "2", sessions });
|
|
1709
|
+
return { session: requested, previousStatus: target.status };
|
|
1710
|
+
});
|
|
1711
|
+
}
|
|
856
1712
|
|
|
857
1713
|
// src/debug-session/constants.ts
|
|
858
1714
|
var DEFAULT_TUNNEL_READY_TIMEOUT_MS = 18e4;
|
|
859
1715
|
var POST_USR1_DELAY_MS = 300;
|
|
860
|
-
var PORT_CLEANUP_DELAY_MS = 600;
|
|
861
1716
|
var CHILD_SIGTERM_GRACE_MS = 2e3;
|
|
862
|
-
var
|
|
1717
|
+
var CHILD_SIGKILL_GRACE_MS = 1e3;
|
|
863
1718
|
var PID_TERMINATION_POLL_MS = 100;
|
|
864
1719
|
|
|
865
1720
|
// src/debug-session/orphans.ts
|
|
1721
|
+
import { rm } from "fs/promises";
|
|
866
1722
|
import { hostname as getHostname2 } from "os";
|
|
867
1723
|
async function pruneAndCleanupOrphans() {
|
|
868
1724
|
const result = await readAndPruneActiveSessions();
|
|
869
1725
|
const host = getHostname2();
|
|
870
1726
|
for (const removed of result.removed) {
|
|
871
|
-
if (removed.hostname === host) {
|
|
872
|
-
|
|
1727
|
+
if (removed.hostname === host && isOwnedSessionCfHomeDir(removed.sessionId, removed.cfHomeDir)) {
|
|
1728
|
+
try {
|
|
1729
|
+
await rm(removed.cfHomeDir, { recursive: true, force: true });
|
|
1730
|
+
} catch {
|
|
1731
|
+
}
|
|
873
1732
|
}
|
|
874
1733
|
}
|
|
875
1734
|
return result;
|
|
876
1735
|
}
|
|
877
1736
|
|
|
878
1737
|
// src/debug-session/processes.ts
|
|
879
|
-
import
|
|
880
|
-
function
|
|
881
|
-
const
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
return;
|
|
886
|
-
} catch {
|
|
887
|
-
}
|
|
1738
|
+
import process4 from "process";
|
|
1739
|
+
async function terminatePidOrGroup(pid, timeoutMs = CHILD_SIGTERM_GRACE_MS, pinnedTarget) {
|
|
1740
|
+
const targetKind = pinnedTarget ?? (isProcessGroupAlive(pid) ? "group" : "pid");
|
|
1741
|
+
const targetAlive = () => targetKind === "group" ? isProcessGroupAlive(pid) : isPidAlive(pid);
|
|
1742
|
+
if (!targetAlive()) {
|
|
1743
|
+
return "terminated";
|
|
888
1744
|
}
|
|
889
1745
|
try {
|
|
890
|
-
|
|
1746
|
+
process4.kill(targetKind === "group" ? -pid : pid, "SIGTERM");
|
|
891
1747
|
} catch {
|
|
892
1748
|
}
|
|
893
|
-
}
|
|
894
|
-
async function terminatePidOrGroup(pid, timeoutMs = CHILD_SIGTERM_GRACE_MS) {
|
|
895
|
-
if (!isPidAlive(pid)) {
|
|
896
|
-
return;
|
|
897
|
-
}
|
|
898
|
-
signalPidOrGroup(pid, "SIGTERM");
|
|
899
1749
|
const startedAt = Date.now();
|
|
900
1750
|
while (Date.now() - startedAt < timeoutMs) {
|
|
901
|
-
if (!
|
|
902
|
-
return;
|
|
1751
|
+
if (!targetAlive()) {
|
|
1752
|
+
return "terminated";
|
|
903
1753
|
}
|
|
904
1754
|
await new Promise((resolve) => {
|
|
905
1755
|
setTimeout(resolve, PID_TERMINATION_POLL_MS);
|
|
906
1756
|
});
|
|
907
1757
|
}
|
|
908
|
-
|
|
1758
|
+
try {
|
|
1759
|
+
process4.kill(targetKind === "group" ? -pid : pid, "SIGKILL");
|
|
1760
|
+
} catch {
|
|
1761
|
+
}
|
|
1762
|
+
const forceStartedAt = Date.now();
|
|
1763
|
+
while (Date.now() - forceStartedAt < CHILD_SIGKILL_GRACE_MS) {
|
|
1764
|
+
if (!targetAlive()) {
|
|
1765
|
+
return "terminated";
|
|
1766
|
+
}
|
|
1767
|
+
await new Promise((resolve) => {
|
|
1768
|
+
setTimeout(resolve, PID_TERMINATION_POLL_MS);
|
|
1769
|
+
});
|
|
1770
|
+
}
|
|
1771
|
+
return targetAlive() ? "still-alive" : "terminated";
|
|
909
1772
|
}
|
|
910
1773
|
async function killProcessGroupOrProc(child, timeoutMs = CHILD_SIGTERM_GRACE_MS) {
|
|
911
1774
|
if (child.pid === void 0) {
|
|
912
|
-
return;
|
|
1775
|
+
return "terminated";
|
|
913
1776
|
}
|
|
914
|
-
|
|
915
|
-
|
|
1777
|
+
const childClosed = child.exitCode !== null || child.signalCode !== null;
|
|
1778
|
+
if (childClosed && process4.platform === "win32") {
|
|
1779
|
+
return "terminated";
|
|
916
1780
|
}
|
|
917
|
-
await terminatePidOrGroup(child.pid, timeoutMs);
|
|
1781
|
+
return await terminatePidOrGroup(child.pid, timeoutMs, childClosed ? "group" : void 0);
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
// src/debug-session/startup-cancellation.ts
|
|
1785
|
+
var STOP_REQUEST_POLL_MS = 50;
|
|
1786
|
+
function cancellationRequested(sessions, sessionId) {
|
|
1787
|
+
const session = sessions.find((candidate) => candidate.sessionId === sessionId);
|
|
1788
|
+
return session === void 0 || session.stopRequestedAt !== void 0;
|
|
1789
|
+
}
|
|
1790
|
+
function createStartupCancellation(sessionId, callerSignal) {
|
|
1791
|
+
const controller = new AbortController();
|
|
1792
|
+
let active = true;
|
|
1793
|
+
let timer;
|
|
1794
|
+
const onCallerAbort = () => {
|
|
1795
|
+
controller.abort();
|
|
1796
|
+
};
|
|
1797
|
+
const schedule = () => {
|
|
1798
|
+
timer = setTimeout(() => {
|
|
1799
|
+
void poll();
|
|
1800
|
+
}, STOP_REQUEST_POLL_MS);
|
|
1801
|
+
timer.unref();
|
|
1802
|
+
};
|
|
1803
|
+
const poll = async () => {
|
|
1804
|
+
try {
|
|
1805
|
+
const sessions = await readSessionSnapshot();
|
|
1806
|
+
if (active && cancellationRequested(sessions, sessionId)) {
|
|
1807
|
+
controller.abort();
|
|
1808
|
+
}
|
|
1809
|
+
} catch {
|
|
1810
|
+
}
|
|
1811
|
+
if (active && !controller.signal.aborted) {
|
|
1812
|
+
schedule();
|
|
1813
|
+
}
|
|
1814
|
+
};
|
|
1815
|
+
callerSignal?.addEventListener("abort", onCallerAbort, { once: true });
|
|
1816
|
+
if (callerSignal?.aborted === true) {
|
|
1817
|
+
controller.abort();
|
|
1818
|
+
} else {
|
|
1819
|
+
void poll();
|
|
1820
|
+
}
|
|
1821
|
+
return {
|
|
1822
|
+
signal: controller.signal,
|
|
1823
|
+
dispose: () => {
|
|
1824
|
+
active = false;
|
|
1825
|
+
clearTimeout(timer);
|
|
1826
|
+
callerSignal?.removeEventListener("abort", onCallerAbort);
|
|
1827
|
+
}
|
|
1828
|
+
};
|
|
918
1829
|
}
|
|
919
1830
|
|
|
920
1831
|
// src/debug-session/start.ts
|
|
@@ -936,9 +1847,30 @@ function checkAbort(signal) {
|
|
|
936
1847
|
throw new CfDebuggerError("ABORTED", "Operation aborted by caller");
|
|
937
1848
|
}
|
|
938
1849
|
}
|
|
1850
|
+
function requireStartupState(state, expectedStatus) {
|
|
1851
|
+
if (state === void 0) {
|
|
1852
|
+
throw new CfDebuggerError(
|
|
1853
|
+
"SESSION_STATE_LOST",
|
|
1854
|
+
"Debugger session ownership state disappeared during startup."
|
|
1855
|
+
);
|
|
1856
|
+
}
|
|
1857
|
+
if (state.stopRequestedAt !== void 0 || state.status === "stopping") {
|
|
1858
|
+
throw new CfDebuggerError("ABORTED", "Debugger session stop was requested during startup.");
|
|
1859
|
+
}
|
|
1860
|
+
if (expectedStatus !== void 0 && state.status !== expectedStatus) {
|
|
1861
|
+
throw new CfDebuggerError(
|
|
1862
|
+
"SESSION_STATE_CONFLICT",
|
|
1863
|
+
`Debugger session state did not transition to ${expectedStatus}.`
|
|
1864
|
+
);
|
|
1865
|
+
}
|
|
1866
|
+
return state;
|
|
1867
|
+
}
|
|
1868
|
+
async function transitionStartupStatus(sessionId, status, message) {
|
|
1869
|
+
return requireStartupState(await updateSessionStatus(sessionId, status, message), status);
|
|
1870
|
+
}
|
|
939
1871
|
function requireCredentials(options) {
|
|
940
|
-
const email = options.email ??
|
|
941
|
-
const password = options.password ??
|
|
1872
|
+
const email = options.email ?? process5.env["SAP_EMAIL"];
|
|
1873
|
+
const password = options.password ?? process5.env["SAP_PASSWORD"];
|
|
942
1874
|
if (email === void 0 || email === "") {
|
|
943
1875
|
throw new CfDebuggerError(
|
|
944
1876
|
"MISSING_CREDENTIALS",
|
|
@@ -953,12 +1885,15 @@ function requireCredentials(options) {
|
|
|
953
1885
|
}
|
|
954
1886
|
return { email, password };
|
|
955
1887
|
}
|
|
956
|
-
async function registerSession(options, apiEndpoint) {
|
|
1888
|
+
async function registerSession(options, target, apiEndpoint) {
|
|
957
1889
|
const registration = await registerNewSession({
|
|
958
1890
|
region: options.region,
|
|
959
1891
|
org: options.org,
|
|
960
1892
|
space: options.space,
|
|
961
1893
|
app: options.app,
|
|
1894
|
+
process: target.process,
|
|
1895
|
+
instance: target.instance,
|
|
1896
|
+
...target.nodePid === void 0 ? {} : { nodePid: target.nodePid },
|
|
962
1897
|
apiEndpoint,
|
|
963
1898
|
...options.preferredPort === void 0 ? {} : { preferredPort: options.preferredPort },
|
|
964
1899
|
portProbe: isPortFree,
|
|
@@ -974,50 +1909,55 @@ async function registerSession(options, apiEndpoint) {
|
|
|
974
1909
|
}
|
|
975
1910
|
async function loginAndTarget(options, apiEndpoint, email, password, context, sessionId, emit) {
|
|
976
1911
|
emit("logging-in");
|
|
977
|
-
await
|
|
1912
|
+
await transitionStartupStatus(sessionId, "logging-in");
|
|
978
1913
|
await cfLogin(apiEndpoint, email, password, context);
|
|
979
|
-
checkAbort(
|
|
1914
|
+
checkAbort(context.signal);
|
|
980
1915
|
emit("targeting");
|
|
981
|
-
await
|
|
1916
|
+
await transitionStartupStatus(sessionId, "targeting");
|
|
982
1917
|
await cfTarget(options.org, options.space, context);
|
|
983
|
-
checkAbort(
|
|
1918
|
+
checkAbort(context.signal);
|
|
984
1919
|
}
|
|
985
|
-
async function signalRemoteNode(options, context, sessionId, emit) {
|
|
1920
|
+
async function signalRemoteNode(options, target, context, sessionId, emit) {
|
|
986
1921
|
emit("signaling");
|
|
987
|
-
await
|
|
988
|
-
const signalResult = await
|
|
1922
|
+
await transitionStartupStatus(sessionId, "signaling");
|
|
1923
|
+
const signalResult = await executeRemoteSignal(options.app, target, context);
|
|
989
1924
|
if (!isSshDisabledError(signalResult.stderr)) {
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
1925
|
+
return parseSignalResult(options.app, signalResult);
|
|
1926
|
+
}
|
|
1927
|
+
if (options.allowSshEnableRestart === false) {
|
|
993
1928
|
throw new CfDebuggerError(
|
|
994
|
-
"
|
|
995
|
-
`
|
|
1929
|
+
"SSH_NOT_ENABLED",
|
|
1930
|
+
`SSH is disabled for ${options.app}; automatic SSH enable and app restart are not allowed.`,
|
|
996
1931
|
signalResult.stderr
|
|
997
1932
|
);
|
|
998
1933
|
}
|
|
1934
|
+
await enableSshAndRestart(options, target, context, sessionId, emit);
|
|
1935
|
+
return await retryRemoteSignal(options, target, context, sessionId, emit);
|
|
1936
|
+
}
|
|
1937
|
+
async function enableSshAndRestart(options, target, context, sessionId, emit) {
|
|
1938
|
+
if (target.nodePid !== void 0) {
|
|
1939
|
+
throw new CfDebuggerError(
|
|
1940
|
+
"NODE_PID_RESTART_UNSAFE",
|
|
1941
|
+
`Cannot automatically restart ${options.app} while targeting remote Node PID ${target.nodePid.toString()}. Enable SSH and restart the app first, then retry with its new PID.`
|
|
1942
|
+
);
|
|
1943
|
+
}
|
|
999
1944
|
const alreadyEnabled = await cfSshEnabled(options.app, context);
|
|
1000
1945
|
if (!alreadyEnabled) {
|
|
1001
1946
|
emit("ssh-enabling", "Enabling SSH on the app");
|
|
1002
|
-
await
|
|
1947
|
+
await transitionStartupStatus(sessionId, "ssh-enabling");
|
|
1003
1948
|
await cfEnableSsh(options.app, context);
|
|
1004
1949
|
}
|
|
1005
1950
|
emit("ssh-restarting", "Restarting app so SSH becomes active");
|
|
1006
|
-
await
|
|
1951
|
+
await transitionStartupStatus(sessionId, "ssh-restarting");
|
|
1007
1952
|
await cfRestartApp(options.app, context);
|
|
1008
|
-
checkAbort(
|
|
1009
|
-
await retryRemoteSignal(options, context, sessionId, emit);
|
|
1953
|
+
checkAbort(context.signal);
|
|
1010
1954
|
}
|
|
1011
|
-
async function retryRemoteSignal(options, context, sessionId, emit) {
|
|
1955
|
+
async function retryRemoteSignal(options, target, context, sessionId, emit) {
|
|
1012
1956
|
emit("signaling");
|
|
1013
|
-
await
|
|
1014
|
-
const retrySignalResult = await
|
|
1015
|
-
options.app,
|
|
1016
|
-
`kill -s USR1 $(pidof node)`,
|
|
1017
|
-
context
|
|
1018
|
-
);
|
|
1957
|
+
await transitionStartupStatus(sessionId, "signaling");
|
|
1958
|
+
const retrySignalResult = await executeRemoteSignal(options.app, target, context);
|
|
1019
1959
|
if (retrySignalResult.exitCode === 0) {
|
|
1020
|
-
return;
|
|
1960
|
+
return parseSignalResult(options.app, retrySignalResult);
|
|
1021
1961
|
}
|
|
1022
1962
|
throw new CfDebuggerError(
|
|
1023
1963
|
"USR1_SIGNAL_FAILED",
|
|
@@ -1025,36 +1965,77 @@ async function retryRemoteSignal(options, context, sessionId, emit) {
|
|
|
1025
1965
|
retrySignalResult.stderr
|
|
1026
1966
|
);
|
|
1027
1967
|
}
|
|
1028
|
-
async function
|
|
1029
|
-
await
|
|
1030
|
-
|
|
1968
|
+
async function executeRemoteSignal(appName, target, context) {
|
|
1969
|
+
return await cfSshOneShot(appName, buildNodeInspectorCommand(target.nodePid), context, {
|
|
1970
|
+
process: target.process,
|
|
1971
|
+
instance: target.instance
|
|
1031
1972
|
});
|
|
1032
|
-
checkAbort(signal);
|
|
1033
1973
|
}
|
|
1034
|
-
|
|
1035
|
-
if (
|
|
1036
|
-
|
|
1974
|
+
function parseSignalResult(appName, result) {
|
|
1975
|
+
if (result.exitCode !== 0) {
|
|
1976
|
+
throw new CfDebuggerError(
|
|
1977
|
+
"USR1_SIGNAL_FAILED",
|
|
1978
|
+
`Failed to send SIGUSR1 to the Node.js process on ${appName}: ${signalFailureDetail(result)}`,
|
|
1979
|
+
result.stderr
|
|
1980
|
+
);
|
|
1037
1981
|
}
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1982
|
+
if (result.outputTruncated) {
|
|
1983
|
+
throw new CfDebuggerError(
|
|
1984
|
+
"INSPECTOR_OUTPUT_TOO_LARGE",
|
|
1985
|
+
"Inspector startup output exceeded the configured capture limit."
|
|
1986
|
+
);
|
|
1987
|
+
}
|
|
1988
|
+
return parseNodeInspectorMarkers(result.stdout).remoteNodePid;
|
|
1989
|
+
}
|
|
1990
|
+
async function waitAfterSignal(signal) {
|
|
1991
|
+
if (signal?.aborted) {
|
|
1992
|
+
throw new CfDebuggerError("ABORTED", "Operation aborted by caller");
|
|
1993
|
+
}
|
|
1994
|
+
await new Promise((resolve, reject) => {
|
|
1995
|
+
const onAbort = () => {
|
|
1996
|
+
clearTimeout(timer);
|
|
1997
|
+
reject(new CfDebuggerError("ABORTED", "Operation aborted by caller"));
|
|
1998
|
+
};
|
|
1999
|
+
const timer = setTimeout(() => {
|
|
2000
|
+
signal?.removeEventListener("abort", onAbort);
|
|
2001
|
+
resolve();
|
|
2002
|
+
}, POST_USR1_DELAY_MS);
|
|
2003
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
1041
2004
|
});
|
|
2005
|
+
}
|
|
2006
|
+
async function ensurePortAvailable(localPort) {
|
|
1042
2007
|
if (!await isPortFree(localPort)) {
|
|
1043
2008
|
throw new CfDebuggerError(
|
|
1044
2009
|
"PORT_UNAVAILABLE",
|
|
1045
|
-
`Local port ${localPort.toString()}
|
|
2010
|
+
`Local port ${localPort.toString()} was taken before the tunnel could start.`
|
|
1046
2011
|
);
|
|
1047
2012
|
}
|
|
1048
2013
|
}
|
|
1049
|
-
async function openReadyTunnel(options, session, context, tunnelReadyTimeoutMs, onChild) {
|
|
2014
|
+
async function openReadyTunnel(options, target, session, context, tunnelReadyTimeoutMs, onChild) {
|
|
1050
2015
|
await ensurePortAvailable(session.localPort);
|
|
1051
|
-
|
|
2016
|
+
checkAbort(context.signal);
|
|
2017
|
+
const child = spawnSshTunnel(options.app, session.localPort, session.remotePort, context, {
|
|
2018
|
+
process: target.process,
|
|
2019
|
+
instance: target.instance
|
|
2020
|
+
});
|
|
1052
2021
|
onChild(child);
|
|
1053
|
-
|
|
1054
|
-
|
|
2022
|
+
const childPid = child.pid;
|
|
2023
|
+
if (childPid === void 0) {
|
|
2024
|
+
throw new CfDebuggerError("TUNNEL_PROCESS_MISSING", "The CF SSH tunnel process did not expose a PID.");
|
|
1055
2025
|
}
|
|
1056
|
-
const
|
|
1057
|
-
|
|
2026
|
+
const pidState = requireStartupState(await updateSessionPid(session.sessionId, childPid));
|
|
2027
|
+
if (pidState.tunnelPid !== childPid || pidState.pid !== childPid) {
|
|
2028
|
+
throw new CfDebuggerError(
|
|
2029
|
+
"SESSION_STATE_CONFLICT",
|
|
2030
|
+
"Debugger session did not retain ownership of the spawned tunnel process."
|
|
2031
|
+
);
|
|
2032
|
+
}
|
|
2033
|
+
const ready = await probeTunnelReady(
|
|
2034
|
+
session.localPort,
|
|
2035
|
+
tunnelReadyTimeoutMs,
|
|
2036
|
+
context.signal
|
|
2037
|
+
);
|
|
2038
|
+
checkAbort(context.signal);
|
|
1058
2039
|
if (!ready) {
|
|
1059
2040
|
throw new CfDebuggerError(
|
|
1060
2041
|
"TUNNEL_NOT_READY",
|
|
@@ -1062,15 +2043,21 @@ async function openReadyTunnel(options, session, context, tunnelReadyTimeoutMs,
|
|
|
1062
2043
|
);
|
|
1063
2044
|
}
|
|
1064
2045
|
const listeningPid = await findListeningProcessId(session.localPort);
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
2046
|
+
if (listeningPid === void 0) {
|
|
2047
|
+
throw new CfDebuggerError(
|
|
2048
|
+
"TUNNEL_OWNER_UNVERIFIED",
|
|
2049
|
+
`Could not verify the owner of local tunnel port ${session.localPort.toString()}.`
|
|
2050
|
+
);
|
|
2051
|
+
}
|
|
2052
|
+
if (listeningPid !== childPid) {
|
|
2053
|
+
throw new CfDebuggerError(
|
|
2054
|
+
"TUNNEL_OWNER_MISMATCH",
|
|
2055
|
+
`Local tunnel port ${session.localPort.toString()} is owned by an unexpected process.`
|
|
2056
|
+
);
|
|
1068
2057
|
}
|
|
1069
|
-
return { child, activePid };
|
|
1070
2058
|
}
|
|
1071
|
-
function attachTunnelEvents(child,
|
|
2059
|
+
function attachTunnelEvents(child, resolveExit, emit) {
|
|
1072
2060
|
child.on("close", (code) => {
|
|
1073
|
-
markClosed();
|
|
1074
2061
|
resolveExit(code);
|
|
1075
2062
|
});
|
|
1076
2063
|
child.on("error", (err) => {
|
|
@@ -1082,139 +2069,291 @@ function createHandle(session, emit, finalize, exitPromise) {
|
|
|
1082
2069
|
return {
|
|
1083
2070
|
session,
|
|
1084
2071
|
dispose: async () => {
|
|
1085
|
-
disposePromise
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
2072
|
+
const attempt = disposePromise ?? (async () => {
|
|
2073
|
+
await runCleanupActions([
|
|
2074
|
+
() => {
|
|
2075
|
+
emit("stopping");
|
|
2076
|
+
},
|
|
2077
|
+
async () => {
|
|
2078
|
+
await updateSessionStatus(session.sessionId, "stopping");
|
|
2079
|
+
},
|
|
2080
|
+
finalize
|
|
2081
|
+
], "Debugger disposal failed");
|
|
1089
2082
|
})();
|
|
1090
|
-
|
|
2083
|
+
disposePromise = attempt;
|
|
2084
|
+
try {
|
|
2085
|
+
await attempt;
|
|
2086
|
+
} catch (error) {
|
|
2087
|
+
if (disposePromise === attempt) {
|
|
2088
|
+
disposePromise = void 0;
|
|
2089
|
+
}
|
|
2090
|
+
throw error;
|
|
2091
|
+
}
|
|
1091
2092
|
},
|
|
1092
2093
|
waitForExit: async () => {
|
|
1093
2094
|
return await exitPromise;
|
|
1094
2095
|
}
|
|
1095
2096
|
};
|
|
1096
2097
|
}
|
|
1097
|
-
async function
|
|
1098
|
-
|
|
1099
|
-
const
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
2098
|
+
async function runCleanupActions(actions, aggregateMessage) {
|
|
2099
|
+
let errors = [];
|
|
2100
|
+
for (const action of actions) {
|
|
2101
|
+
try {
|
|
2102
|
+
await action();
|
|
2103
|
+
} catch (error) {
|
|
2104
|
+
errors = [...errors, error];
|
|
2105
|
+
}
|
|
2106
|
+
}
|
|
2107
|
+
if (errors.length === 1) {
|
|
2108
|
+
throw errors[0];
|
|
2109
|
+
}
|
|
2110
|
+
if (errors.length > 1) {
|
|
2111
|
+
throw new AggregateError(errors, aggregateMessage);
|
|
2112
|
+
}
|
|
2113
|
+
}
|
|
2114
|
+
async function prepareCfHome(cfHomeDir) {
|
|
2115
|
+
await mkdir3(cfHomeDir, { recursive: true, mode: 448 });
|
|
2116
|
+
await chmod3(cfHomeDir, 448);
|
|
2117
|
+
}
|
|
2118
|
+
function createTunnelLifecycle(session, emit) {
|
|
1108
2119
|
let child;
|
|
1109
|
-
let tunnelClosed = false;
|
|
1110
2120
|
let exitResolve = (_code) => {
|
|
1111
2121
|
throw new Error("Exit resolver was used before initialization");
|
|
1112
2122
|
};
|
|
1113
2123
|
const exitPromise = new Promise((resolve) => {
|
|
1114
2124
|
exitResolve = resolve;
|
|
1115
2125
|
});
|
|
2126
|
+
const observeChild = (tunnelChild) => {
|
|
2127
|
+
child = tunnelChild;
|
|
2128
|
+
attachTunnelEvents(tunnelChild, exitResolve, emit);
|
|
2129
|
+
};
|
|
1116
2130
|
const finalize = async () => {
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
}, PORT_CLEANUP_DELAY_MS);
|
|
2131
|
+
const termination = child === void 0 ? "terminated" : await killProcessGroupOrProc(child);
|
|
2132
|
+
const portListening = child !== void 0 && await isPortListening(session.localPort);
|
|
2133
|
+
if (termination === "still-alive" || portListening) {
|
|
2134
|
+
throw new CfDebuggerError(
|
|
2135
|
+
"TUNNEL_TERMINATION_FAILED",
|
|
2136
|
+
`Tunnel for session ${session.sessionId} did not terminate; state and CF home were retained.`
|
|
2137
|
+
);
|
|
1125
2138
|
}
|
|
1126
|
-
await
|
|
1127
|
-
|
|
2139
|
+
await runCleanupActions([
|
|
2140
|
+
async () => {
|
|
2141
|
+
await removeSession(session.sessionId);
|
|
2142
|
+
},
|
|
2143
|
+
async () => {
|
|
2144
|
+
await cleanupFilesystem(session.cfHomeDir);
|
|
2145
|
+
}
|
|
2146
|
+
], "Debugger resource cleanup failed");
|
|
1128
2147
|
emit("stopped");
|
|
1129
2148
|
};
|
|
2149
|
+
return { exitPromise, finalize, observeChild };
|
|
2150
|
+
}
|
|
2151
|
+
async function establishDebuggerSession(inputs) {
|
|
2152
|
+
const { options, target, session, context, credentials, timeoutMs, lifecycle, emit } = inputs;
|
|
2153
|
+
await prepareCfHome(session.cfHomeDir);
|
|
2154
|
+
await loginAndTarget(
|
|
2155
|
+
options,
|
|
2156
|
+
session.apiEndpoint,
|
|
2157
|
+
credentials.email,
|
|
2158
|
+
credentials.password,
|
|
2159
|
+
context,
|
|
2160
|
+
session.sessionId,
|
|
2161
|
+
emit
|
|
2162
|
+
);
|
|
2163
|
+
await ensurePortAvailable(session.localPort);
|
|
2164
|
+
const remoteNodePid = await signalRemoteNode(options, target, context, session.sessionId, emit);
|
|
2165
|
+
const remoteState = requireStartupState(
|
|
2166
|
+
await updateSessionRemoteNodePid(session.sessionId, remoteNodePid)
|
|
2167
|
+
);
|
|
2168
|
+
if (remoteState.remoteNodePid !== remoteNodePid) {
|
|
2169
|
+
throw new CfDebuggerError(
|
|
2170
|
+
"SESSION_STATE_CONFLICT",
|
|
2171
|
+
"Debugger session did not retain the selected remote Node PID."
|
|
2172
|
+
);
|
|
2173
|
+
}
|
|
2174
|
+
await waitAfterSignal(context.signal);
|
|
2175
|
+
emit("tunneling");
|
|
2176
|
+
await transitionStartupStatus(session.sessionId, "tunneling");
|
|
2177
|
+
await openReadyTunnel(
|
|
2178
|
+
options,
|
|
2179
|
+
target,
|
|
2180
|
+
session,
|
|
2181
|
+
context,
|
|
2182
|
+
timeoutMs,
|
|
2183
|
+
lifecycle.observeChild
|
|
2184
|
+
);
|
|
2185
|
+
emit("ready");
|
|
2186
|
+
return await transitionStartupStatus(session.sessionId, "ready");
|
|
2187
|
+
}
|
|
2188
|
+
async function failAfterStartupCleanup(error, finalize, emit) {
|
|
1130
2189
|
try {
|
|
1131
|
-
await
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
2190
|
+
await runCleanupActions([
|
|
2191
|
+
() => {
|
|
2192
|
+
emit("error", error instanceof Error ? error.message : String(error));
|
|
2193
|
+
},
|
|
2194
|
+
finalize
|
|
2195
|
+
], "Debugger startup failure reporting and cleanup failed");
|
|
2196
|
+
} catch (cleanupError) {
|
|
2197
|
+
throw new AggregateError(
|
|
2198
|
+
[error, cleanupError],
|
|
2199
|
+
"Debugger startup failed and resource cleanup was incomplete",
|
|
2200
|
+
{ cause: cleanupError }
|
|
2201
|
+
);
|
|
2202
|
+
}
|
|
2203
|
+
throw error;
|
|
2204
|
+
}
|
|
2205
|
+
async function startDebugger(options) {
|
|
2206
|
+
const target = resolveNodeTarget(options);
|
|
2207
|
+
const credentials = requireCredentials(options);
|
|
2208
|
+
const apiEndpoint = resolveApiEndpoint(options.region, options.apiEndpoint);
|
|
2209
|
+
const tunnelReadyTimeoutMs = options.tunnelReadyTimeoutMs ?? DEFAULT_TUNNEL_READY_TIMEOUT_MS;
|
|
2210
|
+
const emit = (status, message) => {
|
|
2211
|
+
options.onStatus?.(status, message);
|
|
2212
|
+
};
|
|
2213
|
+
checkAbort(options.signal);
|
|
2214
|
+
await pruneAndCleanupOrphans();
|
|
2215
|
+
const session = await registerSession(options, target, apiEndpoint);
|
|
2216
|
+
const cancellation = createStartupCancellation(session.sessionId, options.signal);
|
|
2217
|
+
const context = {
|
|
2218
|
+
cfHome: session.cfHomeDir,
|
|
2219
|
+
signal: cancellation.signal
|
|
2220
|
+
};
|
|
2221
|
+
const lifecycle = createTunnelLifecycle(session, emit);
|
|
2222
|
+
try {
|
|
2223
|
+
const activeSession = await establishDebuggerSession({
|
|
1142
2224
|
options,
|
|
2225
|
+
target,
|
|
1143
2226
|
session,
|
|
1144
2227
|
context,
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
);
|
|
1153
|
-
child = tunnel.child;
|
|
1154
|
-
emit("ready");
|
|
1155
|
-
const readySession = await updateSessionStatus(session.sessionId, "ready");
|
|
1156
|
-
const activeSession = readySession ?? { ...session, pid: tunnel.activePid, status: "ready" };
|
|
1157
|
-
return createHandle(activeSession, emit, finalize, exitPromise);
|
|
2228
|
+
credentials,
|
|
2229
|
+
timeoutMs: tunnelReadyTimeoutMs,
|
|
2230
|
+
lifecycle,
|
|
2231
|
+
emit
|
|
2232
|
+
});
|
|
2233
|
+
cancellation.dispose();
|
|
2234
|
+
return createHandle(activeSession, emit, lifecycle.finalize, lifecycle.exitPromise);
|
|
1158
2235
|
} catch (err) {
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
await finalize();
|
|
1162
|
-
throw err;
|
|
2236
|
+
cancellation.dispose();
|
|
2237
|
+
return await failAfterStartupCleanup(err, lifecycle.finalize, emit);
|
|
1163
2238
|
}
|
|
1164
2239
|
}
|
|
1165
2240
|
async function cleanupFilesystem(cfHomeDir) {
|
|
1166
|
-
|
|
1167
|
-
await rm(cfHomeDir, { recursive: true, force: true });
|
|
1168
|
-
} catch {
|
|
1169
|
-
}
|
|
2241
|
+
await rm2(cfHomeDir, { recursive: true, force: true });
|
|
1170
2242
|
}
|
|
1171
2243
|
|
|
1172
2244
|
// src/debug-session/sessions.ts
|
|
1173
|
-
import { rm as
|
|
1174
|
-
import
|
|
2245
|
+
import { rm as rm3 } from "fs/promises";
|
|
2246
|
+
import { hostname as getHostname3 } from "os";
|
|
2247
|
+
import process6 from "process";
|
|
1175
2248
|
function findMatchingSession(sessions, options) {
|
|
1176
2249
|
if (options.sessionId !== void 0) {
|
|
1177
2250
|
return sessions.find((s) => s.sessionId === options.sessionId);
|
|
1178
2251
|
}
|
|
1179
2252
|
if (options.key !== void 0) {
|
|
1180
2253
|
const key = options.key;
|
|
1181
|
-
|
|
2254
|
+
const matches = sessions.filter((session) => matchesKey(session, key));
|
|
2255
|
+
if (matches.length > 1) {
|
|
2256
|
+
throw new CfDebuggerError(
|
|
2257
|
+
"SESSION_AMBIGUOUS",
|
|
2258
|
+
"Multiple debugger sessions match this target. Pass an exact session ID, API endpoint, or Node PID."
|
|
2259
|
+
);
|
|
2260
|
+
}
|
|
2261
|
+
return matches[0];
|
|
1182
2262
|
}
|
|
1183
2263
|
return void 0;
|
|
1184
2264
|
}
|
|
1185
|
-
async function
|
|
1186
|
-
if (
|
|
2265
|
+
async function ownsRecordedTunnel(target) {
|
|
2266
|
+
if (target.tunnelPid === void 0 || !await isPortListening(target.localPort)) {
|
|
2267
|
+
return false;
|
|
2268
|
+
}
|
|
2269
|
+
return await findListeningProcessId(target.localPort) === target.tunnelPid;
|
|
2270
|
+
}
|
|
2271
|
+
async function terminateVerifiedTunnel(target) {
|
|
2272
|
+
if (target.tunnelPid !== void 0 && target.tunnelPid !== process6.pid) {
|
|
1187
2273
|
try {
|
|
1188
|
-
await terminatePidOrGroup(target.
|
|
2274
|
+
return await terminatePidOrGroup(target.tunnelPid);
|
|
1189
2275
|
} catch {
|
|
2276
|
+
return "still-alive";
|
|
1190
2277
|
}
|
|
1191
2278
|
}
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
const
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
2279
|
+
return target.tunnelPid === process6.pid ? "still-alive" : "terminated";
|
|
2280
|
+
}
|
|
2281
|
+
async function terminateVerifiedTunnelAndConfirm(target) {
|
|
2282
|
+
const termination = await terminateVerifiedTunnel(target);
|
|
2283
|
+
if (termination === "still-alive" || await ownsRecordedTunnel(target)) {
|
|
2284
|
+
throw new CfDebuggerError(
|
|
2285
|
+
"TUNNEL_TERMINATION_FAILED",
|
|
2286
|
+
`Tunnel process for session ${target.sessionId} did not terminate; state was retained.`
|
|
2287
|
+
);
|
|
1199
2288
|
}
|
|
1200
|
-
|
|
2289
|
+
}
|
|
2290
|
+
async function cleanupOwnedCfHome(target, locallyOwned) {
|
|
2291
|
+
if (locallyOwned && isOwnedSessionCfHomeDir(target.sessionId, target.cfHomeDir)) {
|
|
2292
|
+
try {
|
|
2293
|
+
await rm3(target.cfHomeDir, { recursive: true, force: true });
|
|
2294
|
+
} catch {
|
|
2295
|
+
}
|
|
2296
|
+
}
|
|
2297
|
+
}
|
|
2298
|
+
async function removeOwnedSession(target, stale) {
|
|
2299
|
+
const removed = await removeSession(target.sessionId);
|
|
2300
|
+
await cleanupOwnedCfHome(target, true);
|
|
2301
|
+
return { ...removed ?? target, stale, pending: false };
|
|
2302
|
+
}
|
|
2303
|
+
function ownershipError(target) {
|
|
2304
|
+
return new CfDebuggerError(
|
|
2305
|
+
"TUNNEL_OWNERSHIP_UNVERIFIED",
|
|
2306
|
+
`Cannot safely stop session ${target.sessionId}: local tunnel ownership could not be verified.`
|
|
2307
|
+
);
|
|
2308
|
+
}
|
|
2309
|
+
async function stopReadySession(target) {
|
|
2310
|
+
if (await ownsRecordedTunnel(target)) {
|
|
2311
|
+
await terminateVerifiedTunnelAndConfirm(target);
|
|
2312
|
+
return await removeOwnedSession(target, false);
|
|
2313
|
+
}
|
|
2314
|
+
const tunnelDead = target.tunnelPid !== void 0 && !isPidOrGroupAlive(target.tunnelPid);
|
|
2315
|
+
if (tunnelDead && !await isPortListening(target.localPort)) {
|
|
2316
|
+
return await removeOwnedSession(target, true);
|
|
2317
|
+
}
|
|
2318
|
+
throw ownershipError(target);
|
|
2319
|
+
}
|
|
2320
|
+
async function stopStartingSession(target) {
|
|
2321
|
+
if (target.status === "stopping" && target.tunnelPid !== void 0 && !isPidOrGroupAlive(target.tunnelPid) && !await isPortListening(target.localPort)) {
|
|
2322
|
+
return await removeOwnedSession(target, true);
|
|
2323
|
+
}
|
|
2324
|
+
if (isPidAlive(target.controllerPid ?? target.pid)) {
|
|
2325
|
+
return { ...target, stale: false, pending: true };
|
|
2326
|
+
}
|
|
2327
|
+
if (await ownsRecordedTunnel(target)) {
|
|
2328
|
+
await terminateVerifiedTunnelAndConfirm(target);
|
|
2329
|
+
return await removeOwnedSession(target, false);
|
|
2330
|
+
}
|
|
2331
|
+
const tunnelAlive = target.tunnelPid !== void 0 && isPidOrGroupAlive(target.tunnelPid);
|
|
2332
|
+
if (!tunnelAlive && !await isPortListening(target.localPort)) {
|
|
2333
|
+
return await removeOwnedSession(target, true);
|
|
2334
|
+
}
|
|
2335
|
+
throw ownershipError(target);
|
|
1201
2336
|
}
|
|
1202
2337
|
async function stopDebugger(options) {
|
|
1203
|
-
const
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
2338
|
+
const localSessions = (await readSessionSnapshot()).filter(
|
|
2339
|
+
(session) => session.hostname === getHostname3()
|
|
2340
|
+
);
|
|
2341
|
+
const target = findMatchingSession(localSessions, options);
|
|
2342
|
+
if (target === void 0) {
|
|
2343
|
+
return void 0;
|
|
1207
2344
|
}
|
|
1208
|
-
const
|
|
1209
|
-
if (
|
|
1210
|
-
return
|
|
2345
|
+
const claim = await requestSessionStop(target.sessionId);
|
|
2346
|
+
if (claim === void 0) {
|
|
2347
|
+
return void 0;
|
|
1211
2348
|
}
|
|
1212
|
-
return
|
|
2349
|
+
return claim.previousStatus === "ready" ? await stopReadySession(claim.session) : await stopStartingSession(claim.session);
|
|
1213
2350
|
}
|
|
1214
2351
|
async function stopAllDebuggers() {
|
|
1215
|
-
const
|
|
1216
|
-
|
|
1217
|
-
|
|
2352
|
+
const sessions = (await readSessionSnapshot()).filter(
|
|
2353
|
+
(session) => session.hostname === getHostname3()
|
|
2354
|
+
);
|
|
2355
|
+
let stopped = 0;
|
|
2356
|
+
for (const session of sessions) {
|
|
1218
2357
|
const result = await stopDebugger({ sessionId: session.sessionId });
|
|
1219
2358
|
if (result) {
|
|
1220
2359
|
stopped += 1;
|
|
@@ -1227,17 +2366,20 @@ async function listSessions() {
|
|
|
1227
2366
|
}
|
|
1228
2367
|
async function getSession(key) {
|
|
1229
2368
|
const sessions = await readActiveSessions();
|
|
1230
|
-
return sessions
|
|
2369
|
+
return findMatchingSession(sessions, { key });
|
|
1231
2370
|
}
|
|
1232
2371
|
export {
|
|
1233
2372
|
CfDebuggerError,
|
|
2373
|
+
buildNodeInspectorCommand,
|
|
1234
2374
|
getSession,
|
|
1235
2375
|
listKnownRegionKeys,
|
|
1236
2376
|
listSessions,
|
|
1237
2377
|
parseCurrentCfTarget,
|
|
2378
|
+
parseNodeInspectorMarkers,
|
|
1238
2379
|
readCurrentCfTarget,
|
|
1239
2380
|
requireCurrentCfRegion,
|
|
1240
2381
|
resolveApiEndpoint,
|
|
2382
|
+
resolveNodeTarget,
|
|
1241
2383
|
sessionKeyString,
|
|
1242
2384
|
startDebugger,
|
|
1243
2385
|
stopAllDebuggers,
|