@saptools/cf-debugger 0.1.13 → 0.1.15
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 +1740 -517
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +38 -3
- package/dist/index.js +1590 -436
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/cli.ts
|
|
4
|
-
import
|
|
4
|
+
import process7 from "process";
|
|
5
5
|
import { Command } from "commander";
|
|
6
6
|
|
|
7
7
|
// src/cloud-foundry/commands.ts
|
|
@@ -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,73 +410,370 @@ 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_inspector_owner() {",
|
|
534
|
+
` socket_inode="$(awk '$2 ~ /:240D$/ && $4 == "0A" { print $10; exit }' /proc/net/tcp /proc/net/tcp6 2>/dev/null)"`,
|
|
535
|
+
' [ -n "$socket_inode" ] || return 0',
|
|
536
|
+
" for pid_dir in /proc/[0-9]*; do",
|
|
537
|
+
' [ -d "$pid_dir/fd" ] || continue',
|
|
538
|
+
' for fd_path in "$pid_dir"/fd/*; do',
|
|
539
|
+
' fd_target="$(readlink "$fd_path" 2>/dev/null || true)"',
|
|
540
|
+
' if [ "$fd_target" = "socket:[$socket_inode]" ]; then',
|
|
541
|
+
` printf '%s' "\${pid_dir##*/}"`,
|
|
542
|
+
" return 0",
|
|
543
|
+
" fi",
|
|
544
|
+
" done",
|
|
545
|
+
" done",
|
|
546
|
+
"}",
|
|
547
|
+
'owner_pid="$(find_inspector_owner)"',
|
|
548
|
+
'selected_pid=""',
|
|
549
|
+
'if [ -n "$requested_node_pid" ]; then',
|
|
550
|
+
' if ! is_node_pid "$requested_node_pid"; then',
|
|
551
|
+
' echo "saptools-inspector-node-invalid=$requested_node_pid"',
|
|
552
|
+
" exit 0",
|
|
553
|
+
" fi",
|
|
554
|
+
' selected_pid="$requested_node_pid"',
|
|
555
|
+
'elif [ -n "$owner_pid" ] && is_node_pid "$owner_pid"; then',
|
|
556
|
+
' selected_pid="$owner_pid"',
|
|
557
|
+
"else",
|
|
558
|
+
' candidate_pids=""',
|
|
559
|
+
" candidate_count=0",
|
|
560
|
+
" for pid_dir in /proc/[0-9]*; do",
|
|
561
|
+
' candidate_pid="${pid_dir##*/}"',
|
|
562
|
+
' is_node_pid "$candidate_pid" || continue',
|
|
563
|
+
" candidate_count=$((candidate_count + 1))",
|
|
564
|
+
' candidate_pids="${candidate_pids}${candidate_pids:+,}$candidate_pid"',
|
|
565
|
+
' selected_pid="$candidate_pid"',
|
|
566
|
+
" done",
|
|
567
|
+
' if [ "$candidate_count" -eq 0 ]; then echo saptools-inspector-node-not-found; exit 0; fi',
|
|
568
|
+
' if [ "$candidate_count" -ne 1 ]; then echo "saptools-inspector-node-ambiguous=$candidate_pids"; exit 0; fi',
|
|
569
|
+
"fi",
|
|
570
|
+
'if [ -n "$owner_pid" ]; then',
|
|
571
|
+
' if [ "$owner_pid" != "$selected_pid" ]; then echo "saptools-inspector-owner-mismatch=$selected_pid:$owner_pid"; exit 0; fi',
|
|
572
|
+
' echo "saptools-inspector-node-pid=$selected_pid"',
|
|
573
|
+
' echo "saptools-inspector-owner-pid=$owner_pid"',
|
|
574
|
+
" echo saptools-inspector-ready",
|
|
575
|
+
" exit 0",
|
|
576
|
+
"fi",
|
|
577
|
+
'if ! kill -USR1 "$selected_pid" 2>/dev/null; then echo "saptools-inspector-signal-failed=$selected_pid"; exit 0; fi',
|
|
578
|
+
"attempt=0",
|
|
579
|
+
'while [ "$attempt" -lt 20 ]; do',
|
|
580
|
+
' owner_pid="$(find_inspector_owner)"',
|
|
581
|
+
' if [ -n "$owner_pid" ]; then',
|
|
582
|
+
' if [ "$owner_pid" != "$selected_pid" ]; then echo "saptools-inspector-owner-mismatch=$selected_pid:$owner_pid"; exit 0; fi',
|
|
583
|
+
' echo "saptools-inspector-node-pid=$selected_pid"',
|
|
584
|
+
' echo "saptools-inspector-owner-pid=$owner_pid"',
|
|
585
|
+
" echo saptools-inspector-ready",
|
|
586
|
+
" exit 0",
|
|
587
|
+
" fi",
|
|
588
|
+
" attempt=$((attempt + 1))",
|
|
589
|
+
" sleep 0.25",
|
|
590
|
+
"done",
|
|
591
|
+
'echo "saptools-inspector-not-ready=$selected_pid"'
|
|
592
|
+
].join("\n");
|
|
593
|
+
|
|
594
|
+
// src/cloud-foundry/ssh.ts
|
|
595
|
+
var DEFAULT_MAX_OUTPUT_BYTES = 65536;
|
|
596
|
+
function buildCfSshArgs(appName, target, tail) {
|
|
597
|
+
const resolved = resolveNodeTarget(target);
|
|
598
|
+
const processArgs = resolved.process === DEFAULT_CF_PROCESS ? [] : ["--process", resolved.process];
|
|
599
|
+
return [
|
|
600
|
+
"ssh",
|
|
601
|
+
appName,
|
|
602
|
+
...processArgs,
|
|
603
|
+
"-i",
|
|
604
|
+
resolved.instance.toString(),
|
|
605
|
+
...tail
|
|
606
|
+
];
|
|
607
|
+
}
|
|
608
|
+
async function cfSshOneShot(appName, command, context, rawOptions = DEFAULT_CF_COMMAND_TIMEOUT_MS) {
|
|
609
|
+
const options = resolveSshOptions(rawOptions);
|
|
610
|
+
const args = buildCfSshArgs(appName, options.target, [
|
|
611
|
+
"--disable-pseudo-tty",
|
|
612
|
+
"-c",
|
|
613
|
+
command
|
|
614
|
+
]);
|
|
615
|
+
return await runSshOneShot(args, context, options);
|
|
616
|
+
}
|
|
617
|
+
function resolveSshOptions(raw) {
|
|
618
|
+
const input = typeof raw === "number" ? { timeoutMs: raw } : raw;
|
|
619
|
+
const timeoutMs = input.timeoutMs ?? DEFAULT_CF_COMMAND_TIMEOUT_MS;
|
|
620
|
+
const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
|
|
621
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) {
|
|
622
|
+
throw new RangeError("timeoutMs must be a positive safe integer");
|
|
623
|
+
}
|
|
624
|
+
if (!Number.isSafeInteger(maxOutputBytes) || maxOutputBytes <= 0) {
|
|
625
|
+
throw new RangeError("maxOutputBytes must be a positive safe integer");
|
|
626
|
+
}
|
|
627
|
+
return { target: input, timeoutMs, maxOutputBytes };
|
|
628
|
+
}
|
|
629
|
+
function createBoundedOutput() {
|
|
630
|
+
return { chunks: [], bytes: 0, truncated: false };
|
|
631
|
+
}
|
|
632
|
+
function appendBounded(output, data, limit) {
|
|
633
|
+
const incoming = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
|
634
|
+
const remaining = Math.max(0, limit - output.bytes);
|
|
635
|
+
if (incoming.byteLength > remaining) {
|
|
636
|
+
output.truncated = true;
|
|
637
|
+
}
|
|
638
|
+
if (remaining === 0) {
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
const next = incoming.subarray(0, remaining);
|
|
642
|
+
output.chunks.push(next);
|
|
643
|
+
output.bytes += next.byteLength;
|
|
644
|
+
}
|
|
645
|
+
function outputText(output) {
|
|
646
|
+
return Buffer.concat(output.chunks, output.bytes).toString("utf8");
|
|
647
|
+
}
|
|
648
|
+
function createResult(exitCode, stdout, stderr) {
|
|
649
|
+
return {
|
|
650
|
+
exitCode,
|
|
651
|
+
stdout: outputText(stdout),
|
|
652
|
+
stderr: outputText(stderr),
|
|
653
|
+
outputTruncated: stdout.truncated || stderr.truncated
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
function createSshExecutionState() {
|
|
657
|
+
return {
|
|
658
|
+
stdout: createBoundedOutput(),
|
|
659
|
+
stderr: createBoundedOutput(),
|
|
660
|
+
settled: false,
|
|
661
|
+
aborted: false,
|
|
662
|
+
timedOut: false
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
function terminateSshExecution(child, state) {
|
|
666
|
+
signalChild(child, "SIGTERM");
|
|
667
|
+
state.forceKillTimer ??= setTimeout(() => {
|
|
668
|
+
signalChild(child, "SIGKILL");
|
|
669
|
+
}, 1e3);
|
|
670
|
+
}
|
|
671
|
+
function createSshSettler(state, options, signal, onAbort, resolve, reject) {
|
|
672
|
+
return (result) => {
|
|
673
|
+
if (state.settled) {
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
state.settled = true;
|
|
677
|
+
if (state.timeoutTimer !== void 0) {
|
|
678
|
+
clearTimeout(state.timeoutTimer);
|
|
679
|
+
}
|
|
680
|
+
if (state.forceKillTimer !== void 0) {
|
|
681
|
+
clearTimeout(state.forceKillTimer);
|
|
682
|
+
}
|
|
683
|
+
signal?.removeEventListener("abort", onAbort);
|
|
684
|
+
if (state.aborted) {
|
|
685
|
+
reject(new CfDebuggerError("ABORTED", "Operation aborted by caller"));
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
resolve(state.timedOut ? { ...result, timedOutAfterMs: options.timeoutMs } : result);
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
function attachSshExecution(child, context, options, resolve, reject) {
|
|
692
|
+
const state = createSshExecutionState();
|
|
693
|
+
const onAbort = () => {
|
|
694
|
+
state.aborted = true;
|
|
695
|
+
terminateSshExecution(child, state);
|
|
696
|
+
};
|
|
697
|
+
const settle = createSshSettler(state, options, context.signal, onAbort, resolve, reject);
|
|
698
|
+
state.timeoutTimer = setTimeout(() => {
|
|
699
|
+
state.timedOut = true;
|
|
700
|
+
terminateSshExecution(child, state);
|
|
701
|
+
}, options.timeoutMs);
|
|
702
|
+
if (context.signal?.aborted) {
|
|
703
|
+
onAbort();
|
|
704
|
+
} else {
|
|
705
|
+
context.signal?.addEventListener("abort", onAbort, { once: true });
|
|
706
|
+
}
|
|
707
|
+
child.stdout.on("data", (data) => {
|
|
708
|
+
appendBounded(state.stdout, data, options.maxOutputBytes);
|
|
709
|
+
});
|
|
710
|
+
child.stderr.on("data", (data) => {
|
|
711
|
+
appendBounded(state.stderr, data, options.maxOutputBytes);
|
|
712
|
+
});
|
|
713
|
+
child.on("close", (code, signal) => {
|
|
714
|
+
const base = createResult(code, state.stdout, state.stderr);
|
|
715
|
+
settle(state.timedOut || signal === null ? base : { ...base, signal });
|
|
716
|
+
});
|
|
717
|
+
child.on("error", (error) => {
|
|
718
|
+
appendBounded(state.stderr, error.message, options.maxOutputBytes);
|
|
719
|
+
settle(createResult(null, state.stdout, state.stderr));
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
function runSshOneShot(args, context, options) {
|
|
723
|
+
if (context.signal?.aborted) {
|
|
724
|
+
return Promise.reject(new CfDebuggerError("ABORTED", "Operation aborted by caller"));
|
|
725
|
+
}
|
|
726
|
+
return new Promise((resolve, reject) => {
|
|
727
|
+
const child = spawn(resolveBin(context), [...args], {
|
|
287
728
|
env: buildEnv(context.cfHome),
|
|
729
|
+
detached: process.platform !== "win32",
|
|
288
730
|
stdio: ["ignore", "pipe", "pipe"]
|
|
289
731
|
});
|
|
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
|
-
});
|
|
732
|
+
attachSshExecution(child, context, options, resolve, reject);
|
|
324
733
|
});
|
|
325
734
|
}
|
|
735
|
+
function signalChild(child, signal) {
|
|
736
|
+
if (process.platform !== "win32" && child.pid !== void 0) {
|
|
737
|
+
try {
|
|
738
|
+
process.kill(-child.pid, signal);
|
|
739
|
+
return;
|
|
740
|
+
} catch {
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
try {
|
|
744
|
+
child.kill(signal);
|
|
745
|
+
} catch {
|
|
746
|
+
}
|
|
747
|
+
}
|
|
326
748
|
function isSshDisabledError(stderr) {
|
|
327
749
|
const lower = stderr.toLowerCase();
|
|
328
750
|
return lower.includes("not authorized") || lower.includes("ssh support is disabled");
|
|
329
751
|
}
|
|
330
|
-
function spawnSshTunnel(appName, localPort, remotePort, context) {
|
|
752
|
+
function spawnSshTunnel(appName, localPort, remotePort, context, target = {}) {
|
|
331
753
|
const tunnelArg = `${localPort.toString()}:localhost:${remotePort.toString()}`;
|
|
332
|
-
const
|
|
333
|
-
|
|
754
|
+
const args = buildCfSshArgs(appName, target, ["-N", "-L", tunnelArg]);
|
|
755
|
+
const child = spawn(resolveBin(context), [...args], {
|
|
334
756
|
env: buildEnv(context.cfHome),
|
|
335
|
-
|
|
336
|
-
|
|
757
|
+
detached: process.platform !== "win32",
|
|
758
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
337
759
|
});
|
|
760
|
+
child.stdout.resume();
|
|
761
|
+
child.stderr.resume();
|
|
762
|
+
return child;
|
|
338
763
|
}
|
|
339
764
|
|
|
340
765
|
// src/debug-session/start.ts
|
|
341
|
-
import { mkdir as mkdir3, rm } from "fs/promises";
|
|
342
|
-
import
|
|
766
|
+
import { chmod as chmod3, mkdir as mkdir3, rm as rm2 } from "fs/promises";
|
|
767
|
+
import process5 from "process";
|
|
343
768
|
|
|
344
769
|
// src/paths.ts
|
|
345
770
|
import { homedir } from "os";
|
|
346
|
-
import { join } from "path";
|
|
771
|
+
import { isAbsolute, join } from "path";
|
|
347
772
|
var SAPTOOLS_DIR_NAME = ".saptools";
|
|
348
|
-
var CF_DEBUGGER_STATE_FILENAME = "cf-debugger-state.json";
|
|
349
|
-
var CF_DEBUGGER_LOCK_FILENAME = "cf-debugger-state.lock";
|
|
350
|
-
var CF_DEBUGGER_HOMES_DIRNAME = "cf-debugger-homes";
|
|
773
|
+
var CF_DEBUGGER_STATE_FILENAME = "cf-debugger-state-v2.json";
|
|
774
|
+
var CF_DEBUGGER_LOCK_FILENAME = "cf-debugger-state-v2.lock";
|
|
775
|
+
var CF_DEBUGGER_HOMES_DIRNAME = "cf-debugger-homes-v2";
|
|
776
|
+
var SESSION_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
351
777
|
function saptoolsDir() {
|
|
352
778
|
return join(homedir(), SAPTOOLS_DIR_NAME);
|
|
353
779
|
}
|
|
@@ -357,9 +783,21 @@ function stateFilePath() {
|
|
|
357
783
|
function stateLockPath() {
|
|
358
784
|
return join(saptoolsDir(), CF_DEBUGGER_LOCK_FILENAME);
|
|
359
785
|
}
|
|
786
|
+
function isSafeSessionId(sessionId) {
|
|
787
|
+
return SESSION_ID_PATTERN.test(sessionId);
|
|
788
|
+
}
|
|
360
789
|
function sessionCfHomeDir(sessionId) {
|
|
790
|
+
if (!isSafeSessionId(sessionId)) {
|
|
791
|
+
throw new Error("Invalid debugger session ID.");
|
|
792
|
+
}
|
|
361
793
|
return join(saptoolsDir(), CF_DEBUGGER_HOMES_DIRNAME, sessionId);
|
|
362
794
|
}
|
|
795
|
+
function isOwnedSessionCfHomeDir(sessionId, candidate) {
|
|
796
|
+
if (!isSafeSessionId(sessionId) || !isAbsolute(candidate)) {
|
|
797
|
+
return false;
|
|
798
|
+
}
|
|
799
|
+
return candidate === sessionCfHomeDir(sessionId);
|
|
800
|
+
}
|
|
363
801
|
|
|
364
802
|
// src/network/ports.ts
|
|
365
803
|
import { execFile as execFile3 } from "child_process";
|
|
@@ -482,207 +920,554 @@ async function isPortFree(port) {
|
|
|
482
920
|
server.listen(port, "127.0.0.1");
|
|
483
921
|
});
|
|
484
922
|
}
|
|
485
|
-
async function
|
|
923
|
+
async function isPortListening(port, timeoutMs = 200) {
|
|
924
|
+
return await new Promise((resolve) => {
|
|
925
|
+
const socket = createConnection({ port, host: "127.0.0.1" });
|
|
926
|
+
socket.setTimeout(timeoutMs);
|
|
927
|
+
socket.once("connect", () => {
|
|
928
|
+
socket.destroy();
|
|
929
|
+
resolve(true);
|
|
930
|
+
});
|
|
931
|
+
socket.once("error", () => {
|
|
932
|
+
socket.destroy();
|
|
933
|
+
resolve(false);
|
|
934
|
+
});
|
|
935
|
+
socket.once("timeout", () => {
|
|
936
|
+
socket.destroy();
|
|
937
|
+
resolve(false);
|
|
938
|
+
});
|
|
939
|
+
});
|
|
940
|
+
}
|
|
941
|
+
function throwIfAborted(signal) {
|
|
942
|
+
if (signal?.aborted) {
|
|
943
|
+
throw new CfDebuggerError("ABORTED", "Operation aborted by caller");
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
function waitForNextProbe(delayMs, signal) {
|
|
947
|
+
throwIfAborted(signal);
|
|
948
|
+
return new Promise((resolve, reject) => {
|
|
949
|
+
const onAbort = () => {
|
|
950
|
+
clearTimeout(timer);
|
|
951
|
+
reject(new CfDebuggerError("ABORTED", "Operation aborted by caller"));
|
|
952
|
+
};
|
|
953
|
+
const timer = setTimeout(() => {
|
|
954
|
+
signal?.removeEventListener("abort", onAbort);
|
|
955
|
+
resolve();
|
|
956
|
+
}, delayMs);
|
|
957
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
958
|
+
});
|
|
959
|
+
}
|
|
960
|
+
async function probeTunnelReady(port, timeoutMs, signal) {
|
|
486
961
|
const pollIntervalMs = 250;
|
|
487
962
|
const started = Date.now();
|
|
963
|
+
throwIfAborted(signal);
|
|
488
964
|
while (Date.now() - started < timeoutMs) {
|
|
489
|
-
const connected = await
|
|
490
|
-
const socket = createConnection({ port, host: "127.0.0.1" });
|
|
491
|
-
socket.setTimeout(200);
|
|
492
|
-
socket.once("connect", () => {
|
|
493
|
-
socket.destroy();
|
|
494
|
-
resolve(true);
|
|
495
|
-
});
|
|
496
|
-
socket.once("error", () => {
|
|
497
|
-
socket.destroy();
|
|
498
|
-
resolve(false);
|
|
499
|
-
});
|
|
500
|
-
socket.once("timeout", () => {
|
|
501
|
-
socket.destroy();
|
|
502
|
-
resolve(false);
|
|
503
|
-
});
|
|
504
|
-
});
|
|
965
|
+
const connected = await isPortListening(port);
|
|
505
966
|
if (connected) {
|
|
506
967
|
return true;
|
|
507
968
|
}
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
});
|
|
969
|
+
const remainingMs = timeoutMs - (Date.now() - started);
|
|
970
|
+
await waitForNextProbe(Math.min(pollIntervalMs, Math.max(0, remainingMs)), signal);
|
|
511
971
|
}
|
|
972
|
+
throwIfAborted(signal);
|
|
512
973
|
return false;
|
|
513
974
|
}
|
|
514
975
|
async function findListeningProcessId(port) {
|
|
515
976
|
const pids = await findListeningPids(port);
|
|
516
977
|
return pids[0];
|
|
517
978
|
}
|
|
518
|
-
async function killProcessOnPort(port) {
|
|
519
|
-
const portStr = port.toString();
|
|
520
|
-
if (process.platform === "win32") {
|
|
521
|
-
try {
|
|
522
|
-
const pids = await findListeningPidsWithNetstat(port);
|
|
523
|
-
for (const pid of pids) {
|
|
524
|
-
try {
|
|
525
|
-
await execFileAsync3("taskkill", ["/F", "/PID", pid.toString()]);
|
|
526
|
-
} catch {
|
|
527
|
-
}
|
|
528
|
-
}
|
|
529
|
-
} catch {
|
|
530
|
-
}
|
|
531
|
-
return;
|
|
532
|
-
}
|
|
533
|
-
try {
|
|
534
|
-
const { stdout } = await execFileAsync3("lsof", ["-t", "-i", `tcp:${portStr}`]);
|
|
535
|
-
const lines = stdout.trim().split("\n").filter((line) => line.length > 0);
|
|
536
|
-
for (const line of lines) {
|
|
537
|
-
const pid = Number.parseInt(line, 10);
|
|
538
|
-
if (Number.isNaN(pid)) {
|
|
539
|
-
continue;
|
|
540
|
-
}
|
|
541
|
-
try {
|
|
542
|
-
process.kill(pid, "SIGKILL");
|
|
543
|
-
} catch {
|
|
544
|
-
}
|
|
545
|
-
}
|
|
546
|
-
} catch {
|
|
547
|
-
}
|
|
548
|
-
}
|
|
549
979
|
|
|
550
980
|
// src/session-state/store.ts
|
|
551
|
-
import { randomUUID } from "crypto";
|
|
552
|
-
import { mkdir as mkdir2, readFile as
|
|
981
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
982
|
+
import { chmod as chmod2, mkdir as mkdir2, readFile as readFile3, rename, unlink as unlink2, writeFile } from "fs/promises";
|
|
553
983
|
import { hostname as getHostname } from "os";
|
|
554
|
-
import { dirname as dirname2 } from "path";
|
|
555
|
-
import
|
|
984
|
+
import { dirname as dirname2, isAbsolute as isAbsolute3 } from "path";
|
|
985
|
+
import process3 from "process";
|
|
556
986
|
|
|
557
987
|
// src/lock.ts
|
|
558
|
-
import {
|
|
988
|
+
import { randomUUID } from "crypto";
|
|
989
|
+
import { chmod, mkdir, open, readFile as readFile2, stat, unlink } from "fs/promises";
|
|
990
|
+
import { hostname } from "os";
|
|
559
991
|
import { dirname } from "path";
|
|
992
|
+
import process2 from "process";
|
|
560
993
|
var DEFAULT_POLL_MS = 50;
|
|
994
|
+
var DEFAULT_STALE_MS = 6e4;
|
|
561
995
|
var DEFAULT_TIMEOUT_MS = 1e4;
|
|
562
996
|
function sleep(ms) {
|
|
563
997
|
return new Promise((resolve) => {
|
|
564
998
|
setTimeout(resolve, ms);
|
|
565
999
|
});
|
|
566
1000
|
}
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
for (; ; ) {
|
|
571
|
-
try {
|
|
572
|
-
return await open(lockPath, "wx");
|
|
573
|
-
} catch (err) {
|
|
574
|
-
const code = err.code;
|
|
575
|
-
if (code !== "EEXIST") {
|
|
576
|
-
throw err;
|
|
577
|
-
}
|
|
578
|
-
}
|
|
579
|
-
if (Date.now() > deadline) {
|
|
580
|
-
throw new Error(`Timed out acquiring file lock at ${lockPath}`);
|
|
581
|
-
}
|
|
582
|
-
await sleep(pollMs);
|
|
1001
|
+
function errorCode(error) {
|
|
1002
|
+
if (typeof error !== "object" || error === null) {
|
|
1003
|
+
return void 0;
|
|
583
1004
|
}
|
|
1005
|
+
const code = Reflect.get(error, "code");
|
|
1006
|
+
return typeof code === "string" ? code : void 0;
|
|
584
1007
|
}
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
await unlink(lockPath).catch((err) => {
|
|
588
|
-
const code = err.code;
|
|
589
|
-
if (code !== "ENOENT") {
|
|
590
|
-
throw err;
|
|
591
|
-
}
|
|
592
|
-
});
|
|
593
|
-
}
|
|
594
|
-
async function withFileLock(lockPath, work, options) {
|
|
595
|
-
const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
596
|
-
const pollMs = options?.pollMs ?? DEFAULT_POLL_MS;
|
|
597
|
-
const handle = await acquireFileLock(lockPath, timeoutMs, pollMs);
|
|
598
|
-
try {
|
|
599
|
-
return await work();
|
|
600
|
-
} finally {
|
|
601
|
-
await releaseFileLock(lockPath, handle);
|
|
602
|
-
}
|
|
1008
|
+
function field(value, key) {
|
|
1009
|
+
return Reflect.get(value, key);
|
|
603
1010
|
}
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
async function readJsonFile(path) {
|
|
607
|
-
let raw;
|
|
608
|
-
try {
|
|
609
|
-
raw = await readFile2(path, "utf8");
|
|
610
|
-
} catch (err) {
|
|
611
|
-
const code = err.code;
|
|
612
|
-
if (code === "ENOENT") {
|
|
613
|
-
return void 0;
|
|
614
|
-
}
|
|
615
|
-
throw err;
|
|
616
|
-
}
|
|
1011
|
+
function parseLockOwner(raw) {
|
|
1012
|
+
let value;
|
|
617
1013
|
try {
|
|
618
|
-
|
|
1014
|
+
value = JSON.parse(raw);
|
|
619
1015
|
} catch {
|
|
620
|
-
process2.stderr.write(
|
|
621
|
-
`[cf-debugger] warning: state file at ${path} is not valid JSON; resetting to empty.
|
|
622
|
-
`
|
|
623
|
-
);
|
|
624
1016
|
return void 0;
|
|
625
1017
|
}
|
|
626
|
-
}
|
|
627
|
-
async function writeJsonFileAtomic(path, value) {
|
|
628
|
-
const tempPath = `${path}.${randomUUID()}.tmp`;
|
|
629
|
-
await mkdir2(dirname2(path), { recursive: true });
|
|
630
|
-
await writeFile(tempPath, `${JSON.stringify(value, null, 2)}
|
|
631
|
-
`, "utf8");
|
|
632
|
-
await rename(tempPath, path);
|
|
633
|
-
}
|
|
634
|
-
function emptyState() {
|
|
635
|
-
return { version: "1", sessions: [] };
|
|
636
|
-
}
|
|
637
|
-
function isValidState(value) {
|
|
638
1018
|
if (typeof value !== "object" || value === null) {
|
|
639
|
-
return
|
|
1019
|
+
return void 0;
|
|
1020
|
+
}
|
|
1021
|
+
const lockHostname = field(value, "hostname");
|
|
1022
|
+
const pid = field(value, "pid");
|
|
1023
|
+
const token = field(value, "token");
|
|
1024
|
+
const version = field(value, "version");
|
|
1025
|
+
if (typeof lockHostname !== "string" || typeof pid !== "number" || !Number.isSafeInteger(pid) || pid <= 0) {
|
|
1026
|
+
return void 0;
|
|
1027
|
+
}
|
|
1028
|
+
if (typeof token !== "string" || version !== "1") {
|
|
1029
|
+
return void 0;
|
|
640
1030
|
}
|
|
641
|
-
|
|
642
|
-
return candidate.version === "1" && Array.isArray(candidate.sessions);
|
|
1031
|
+
return { hostname: lockHostname, pid, token, version };
|
|
643
1032
|
}
|
|
644
|
-
function
|
|
1033
|
+
function isProcessAlive(pid) {
|
|
645
1034
|
try {
|
|
646
1035
|
process2.kill(pid, 0);
|
|
647
1036
|
return true;
|
|
648
|
-
} catch (
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
1037
|
+
} catch (error) {
|
|
1038
|
+
return errorCode(error) !== "ESRCH";
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
async function readLockOwner(lockPath) {
|
|
1042
|
+
try {
|
|
1043
|
+
await chmod(lockPath, 384);
|
|
1044
|
+
return parseLockOwner(await readFile2(lockPath, "utf8"));
|
|
1045
|
+
} catch (error) {
|
|
1046
|
+
if (errorCode(error) === "ENOENT") {
|
|
1047
|
+
return void 0;
|
|
652
1048
|
}
|
|
653
|
-
|
|
1049
|
+
throw error;
|
|
654
1050
|
}
|
|
655
1051
|
}
|
|
656
|
-
function
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
1052
|
+
async function isStaleLock(lockPath, staleMs) {
|
|
1053
|
+
let modifiedAt;
|
|
1054
|
+
try {
|
|
1055
|
+
modifiedAt = (await stat(lockPath)).mtimeMs;
|
|
1056
|
+
} catch (error) {
|
|
1057
|
+
if (errorCode(error) === "ENOENT") {
|
|
660
1058
|
return true;
|
|
661
1059
|
}
|
|
662
|
-
|
|
663
|
-
});
|
|
664
|
-
}
|
|
665
|
-
async function readStateRaw() {
|
|
666
|
-
const parsed = await readJsonFile(stateFilePath());
|
|
667
|
-
if (!isValidState(parsed)) {
|
|
668
|
-
return emptyState();
|
|
1060
|
+
throw error;
|
|
669
1061
|
}
|
|
670
|
-
|
|
1062
|
+
const owner = await readLockOwner(lockPath);
|
|
1063
|
+
if (owner?.hostname === hostname()) {
|
|
1064
|
+
return !isProcessAlive(owner.pid);
|
|
1065
|
+
}
|
|
1066
|
+
return owner === void 0 && Date.now() - modifiedAt > staleMs;
|
|
1067
|
+
}
|
|
1068
|
+
async function reclaimAbandonedRecoveryLock(recoveryPath, staleMs) {
|
|
1069
|
+
if (!await isStaleLock(recoveryPath, staleMs)) {
|
|
1070
|
+
return;
|
|
1071
|
+
}
|
|
1072
|
+
const owner = await readLockOwner(recoveryPath);
|
|
1073
|
+
if (owner !== void 0) {
|
|
1074
|
+
await removeOwnedLock(recoveryPath, owner.token);
|
|
1075
|
+
return;
|
|
1076
|
+
}
|
|
1077
|
+
await unlink(recoveryPath).catch((error) => {
|
|
1078
|
+
if (errorCode(error) !== "ENOENT") {
|
|
1079
|
+
throw error;
|
|
1080
|
+
}
|
|
1081
|
+
});
|
|
1082
|
+
}
|
|
1083
|
+
async function reclaimStaleLock(lockPath, staleMs) {
|
|
1084
|
+
const recoveryPath = `${lockPath}.recovery`;
|
|
1085
|
+
let recoveryLock;
|
|
1086
|
+
try {
|
|
1087
|
+
recoveryLock = await createFileLock(recoveryPath);
|
|
1088
|
+
} catch (error) {
|
|
1089
|
+
if (errorCode(error) === "EEXIST") {
|
|
1090
|
+
await reclaimAbandonedRecoveryLock(recoveryPath, staleMs);
|
|
1091
|
+
return false;
|
|
1092
|
+
}
|
|
1093
|
+
throw error;
|
|
1094
|
+
}
|
|
1095
|
+
try {
|
|
1096
|
+
if (!await isStaleLock(lockPath, staleMs)) {
|
|
1097
|
+
return false;
|
|
1098
|
+
}
|
|
1099
|
+
try {
|
|
1100
|
+
await unlink(lockPath);
|
|
1101
|
+
} catch (error) {
|
|
1102
|
+
if (errorCode(error) !== "ENOENT") {
|
|
1103
|
+
throw error;
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
return true;
|
|
1107
|
+
} finally {
|
|
1108
|
+
await releaseFileLock(recoveryPath, recoveryLock);
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
async function removeOwnedLock(lockPath, token) {
|
|
1112
|
+
const owner = await readLockOwner(lockPath);
|
|
1113
|
+
if (owner?.token !== token) {
|
|
1114
|
+
return;
|
|
1115
|
+
}
|
|
1116
|
+
await unlink(lockPath).catch((error) => {
|
|
1117
|
+
if (errorCode(error) !== "ENOENT") {
|
|
1118
|
+
throw error;
|
|
1119
|
+
}
|
|
1120
|
+
});
|
|
1121
|
+
}
|
|
1122
|
+
async function createFileLock(lockPath) {
|
|
1123
|
+
const handle = await open(lockPath, "wx", 384);
|
|
1124
|
+
const token = randomUUID();
|
|
1125
|
+
try {
|
|
1126
|
+
await handle.writeFile(`${JSON.stringify({ hostname: hostname(), pid: process2.pid, token, version: "1" })}
|
|
1127
|
+
`, "utf8");
|
|
1128
|
+
return { handle, token };
|
|
1129
|
+
} catch (error) {
|
|
1130
|
+
await handle.close();
|
|
1131
|
+
await unlink(lockPath).catch(() => false);
|
|
1132
|
+
throw error;
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
async function acquireFileLock(lockPath, timeoutMs, pollMs, staleMs) {
|
|
1136
|
+
const deadline = Date.now() + timeoutMs;
|
|
1137
|
+
const parentDir = dirname(lockPath);
|
|
1138
|
+
await mkdir(parentDir, { recursive: true, mode: 448 });
|
|
1139
|
+
await chmod(parentDir, 448);
|
|
1140
|
+
for (; ; ) {
|
|
1141
|
+
try {
|
|
1142
|
+
return await createFileLock(lockPath);
|
|
1143
|
+
} catch (error) {
|
|
1144
|
+
if (errorCode(error) !== "EEXIST") {
|
|
1145
|
+
throw error;
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
if (await reclaimStaleLock(lockPath, staleMs)) {
|
|
1149
|
+
continue;
|
|
1150
|
+
}
|
|
1151
|
+
if (Date.now() > deadline) {
|
|
1152
|
+
throw new Error(`Timed out acquiring file lock at ${lockPath}`);
|
|
1153
|
+
}
|
|
1154
|
+
await sleep(pollMs);
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
async function releaseFileLock(lockPath, lock) {
|
|
1158
|
+
await lock.handle.close();
|
|
1159
|
+
await removeOwnedLock(lockPath, lock.token);
|
|
1160
|
+
}
|
|
1161
|
+
async function withFileLock(lockPath, work, options) {
|
|
1162
|
+
const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
1163
|
+
const pollMs = options?.pollMs ?? DEFAULT_POLL_MS;
|
|
1164
|
+
const staleMs = options?.staleMs ?? DEFAULT_STALE_MS;
|
|
1165
|
+
const lock = await acquireFileLock(lockPath, timeoutMs, pollMs, staleMs);
|
|
1166
|
+
try {
|
|
1167
|
+
return await work();
|
|
1168
|
+
} finally {
|
|
1169
|
+
await releaseFileLock(lockPath, lock);
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
// src/session-state/decoder.ts
|
|
1174
|
+
import { isAbsolute as isAbsolute2 } from "path";
|
|
1175
|
+
var INVALID_SESSION = new Error("Invalid persisted debugger session");
|
|
1176
|
+
var VALID_STATUSES = /* @__PURE__ */ new Set([
|
|
1177
|
+
"starting",
|
|
1178
|
+
"logging-in",
|
|
1179
|
+
"targeting",
|
|
1180
|
+
"ssh-enabling",
|
|
1181
|
+
"ssh-restarting",
|
|
1182
|
+
"signaling",
|
|
1183
|
+
"tunneling",
|
|
1184
|
+
"ready",
|
|
1185
|
+
"stopping",
|
|
1186
|
+
"stopped",
|
|
1187
|
+
"error"
|
|
1188
|
+
]);
|
|
1189
|
+
function field2(value, key) {
|
|
1190
|
+
return Reflect.get(value, key);
|
|
1191
|
+
}
|
|
1192
|
+
function requireString(value, key) {
|
|
1193
|
+
const candidate = field2(value, key);
|
|
1194
|
+
if (typeof candidate !== "string" || candidate.length === 0) {
|
|
1195
|
+
throw INVALID_SESSION;
|
|
1196
|
+
}
|
|
1197
|
+
return candidate;
|
|
1198
|
+
}
|
|
1199
|
+
function optionalString2(value, key) {
|
|
1200
|
+
const candidate = field2(value, key);
|
|
1201
|
+
if (candidate === void 0) {
|
|
1202
|
+
return void 0;
|
|
1203
|
+
}
|
|
1204
|
+
if (typeof candidate !== "string") {
|
|
1205
|
+
throw INVALID_SESSION;
|
|
1206
|
+
}
|
|
1207
|
+
return candidate;
|
|
1208
|
+
}
|
|
1209
|
+
function requireInteger(value, key, minimum, maximum) {
|
|
1210
|
+
const candidate = field2(value, key);
|
|
1211
|
+
if (!Number.isSafeInteger(candidate) || typeof candidate !== "number") {
|
|
1212
|
+
throw INVALID_SESSION;
|
|
1213
|
+
}
|
|
1214
|
+
if (candidate < minimum || candidate > maximum) {
|
|
1215
|
+
throw INVALID_SESSION;
|
|
1216
|
+
}
|
|
1217
|
+
return candidate;
|
|
1218
|
+
}
|
|
1219
|
+
function optionalInteger(value, key, minimum) {
|
|
1220
|
+
const candidate = field2(value, key);
|
|
1221
|
+
if (candidate === void 0) {
|
|
1222
|
+
return void 0;
|
|
1223
|
+
}
|
|
1224
|
+
if (!Number.isSafeInteger(candidate) || typeof candidate !== "number" || candidate < minimum) {
|
|
1225
|
+
throw INVALID_SESSION;
|
|
1226
|
+
}
|
|
1227
|
+
return candidate;
|
|
1228
|
+
}
|
|
1229
|
+
function requireSessionId(value) {
|
|
1230
|
+
const sessionId = requireString(value, "sessionId");
|
|
1231
|
+
if (!isSafeSessionId(sessionId)) {
|
|
1232
|
+
throw INVALID_SESSION;
|
|
1233
|
+
}
|
|
1234
|
+
return sessionId;
|
|
1235
|
+
}
|
|
1236
|
+
function requireAbsolutePath(value, key) {
|
|
1237
|
+
const path = requireString(value, key);
|
|
1238
|
+
if (!isAbsolute2(path)) {
|
|
1239
|
+
throw INVALID_SESSION;
|
|
1240
|
+
}
|
|
1241
|
+
return path;
|
|
1242
|
+
}
|
|
1243
|
+
function requireTimestamp(value) {
|
|
1244
|
+
const timestamp = requireString(value, "startedAt");
|
|
1245
|
+
if (Number.isNaN(Date.parse(timestamp))) {
|
|
1246
|
+
throw INVALID_SESSION;
|
|
1247
|
+
}
|
|
1248
|
+
return timestamp;
|
|
1249
|
+
}
|
|
1250
|
+
function optionalTimestamp(value, key) {
|
|
1251
|
+
const timestamp = optionalString2(value, key);
|
|
1252
|
+
if (timestamp !== void 0 && Number.isNaN(Date.parse(timestamp))) {
|
|
1253
|
+
throw INVALID_SESSION;
|
|
1254
|
+
}
|
|
1255
|
+
return timestamp;
|
|
1256
|
+
}
|
|
1257
|
+
function isSessionStatus(value) {
|
|
1258
|
+
return VALID_STATUSES.has(value);
|
|
1259
|
+
}
|
|
1260
|
+
function requireStatus(value) {
|
|
1261
|
+
const status = requireString(value, "status");
|
|
1262
|
+
if (!isSessionStatus(status)) {
|
|
1263
|
+
throw INVALID_SESSION;
|
|
1264
|
+
}
|
|
1265
|
+
return status;
|
|
1266
|
+
}
|
|
1267
|
+
function decodeSession(value) {
|
|
1268
|
+
if (typeof value !== "object" || value === null) {
|
|
1269
|
+
return void 0;
|
|
1270
|
+
}
|
|
1271
|
+
try {
|
|
1272
|
+
const processName = requireString(value, "process");
|
|
1273
|
+
const instance = requireInteger(value, "instance", 0, Number.MAX_SAFE_INTEGER);
|
|
1274
|
+
const nodePid = optionalInteger(value, "nodePid", 1);
|
|
1275
|
+
const target = resolveNodeTarget({
|
|
1276
|
+
process: processName,
|
|
1277
|
+
instance,
|
|
1278
|
+
...nodePid === void 0 ? {} : { nodePid }
|
|
1279
|
+
});
|
|
1280
|
+
const pid = requireInteger(value, "pid", 1, Number.MAX_SAFE_INTEGER);
|
|
1281
|
+
const controllerPid = requireInteger(value, "controllerPid", 1, Number.MAX_SAFE_INTEGER);
|
|
1282
|
+
const tunnelPid = optionalInteger(value, "tunnelPid", 1);
|
|
1283
|
+
const remoteNodePid = optionalInteger(value, "remoteNodePid", 1);
|
|
1284
|
+
const stopRequestedAt = optionalTimestamp(value, "stopRequestedAt");
|
|
1285
|
+
const message = optionalString2(value, "message");
|
|
1286
|
+
const status = requireStatus(value);
|
|
1287
|
+
if (pid !== (tunnelPid ?? controllerPid) || status === "ready" && tunnelPid === void 0) {
|
|
1288
|
+
throw INVALID_SESSION;
|
|
1289
|
+
}
|
|
1290
|
+
return {
|
|
1291
|
+
sessionId: requireSessionId(value),
|
|
1292
|
+
pid,
|
|
1293
|
+
controllerPid,
|
|
1294
|
+
...tunnelPid === void 0 ? {} : { tunnelPid },
|
|
1295
|
+
hostname: requireString(value, "hostname"),
|
|
1296
|
+
region: requireString(value, "region"),
|
|
1297
|
+
org: requireString(value, "org"),
|
|
1298
|
+
space: requireString(value, "space"),
|
|
1299
|
+
app: requireString(value, "app"),
|
|
1300
|
+
process: target.process,
|
|
1301
|
+
instance: target.instance,
|
|
1302
|
+
...nodePid === void 0 ? {} : { nodePid },
|
|
1303
|
+
apiEndpoint: requireString(value, "apiEndpoint"),
|
|
1304
|
+
localPort: requireInteger(value, "localPort", 1, 65535),
|
|
1305
|
+
remotePort: requireInteger(value, "remotePort", 1, 65535),
|
|
1306
|
+
cfHomeDir: requireAbsolutePath(value, "cfHomeDir"),
|
|
1307
|
+
startedAt: requireTimestamp(value),
|
|
1308
|
+
status,
|
|
1309
|
+
...remoteNodePid === void 0 ? {} : { remoteNodePid },
|
|
1310
|
+
...stopRequestedAt === void 0 ? {} : { stopRequestedAt },
|
|
1311
|
+
...message === void 0 ? {} : { message }
|
|
1312
|
+
};
|
|
1313
|
+
} catch {
|
|
1314
|
+
return void 0;
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
function decodeStateFile(value) {
|
|
1318
|
+
if (typeof value !== "object" || value === null || field2(value, "version") !== "2") {
|
|
1319
|
+
return void 0;
|
|
1320
|
+
}
|
|
1321
|
+
const rawSessions = field2(value, "sessions");
|
|
1322
|
+
if (!Array.isArray(rawSessions)) {
|
|
1323
|
+
return void 0;
|
|
1324
|
+
}
|
|
1325
|
+
const sessionIds = /* @__PURE__ */ new Set();
|
|
1326
|
+
const sessions = [];
|
|
1327
|
+
for (const rawSession of rawSessions) {
|
|
1328
|
+
const session = decodeSession(rawSession);
|
|
1329
|
+
if (session === void 0 || sessionIds.has(session.sessionId)) {
|
|
1330
|
+
return void 0;
|
|
1331
|
+
}
|
|
1332
|
+
sessionIds.add(session.sessionId);
|
|
1333
|
+
sessions.push(session);
|
|
1334
|
+
}
|
|
1335
|
+
return { version: "2", sessions };
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
// src/session-state/store.ts
|
|
1339
|
+
function errorCode2(error) {
|
|
1340
|
+
if (typeof error !== "object" || error === null) {
|
|
1341
|
+
return void 0;
|
|
1342
|
+
}
|
|
1343
|
+
const code = Reflect.get(error, "code");
|
|
1344
|
+
return typeof code === "string" ? code : void 0;
|
|
1345
|
+
}
|
|
1346
|
+
async function readJsonFile(path) {
|
|
1347
|
+
let raw;
|
|
1348
|
+
try {
|
|
1349
|
+
await chmod2(path, 384);
|
|
1350
|
+
raw = await readFile3(path, "utf8");
|
|
1351
|
+
} catch (err) {
|
|
1352
|
+
if (errorCode2(err) === "ENOENT") {
|
|
1353
|
+
return void 0;
|
|
1354
|
+
}
|
|
1355
|
+
throw err;
|
|
1356
|
+
}
|
|
1357
|
+
try {
|
|
1358
|
+
return JSON.parse(raw);
|
|
1359
|
+
} catch {
|
|
1360
|
+
process3.stderr.write(
|
|
1361
|
+
`[cf-debugger] warning: state file at ${path} is not valid JSON; resetting to empty.
|
|
1362
|
+
`
|
|
1363
|
+
);
|
|
1364
|
+
return void 0;
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
async function writeJsonFileAtomic(path, value) {
|
|
1368
|
+
const tempPath = `${path}.${randomUUID2()}.tmp`;
|
|
1369
|
+
const parentDir = dirname2(path);
|
|
1370
|
+
await mkdir2(parentDir, { recursive: true, mode: 448 });
|
|
1371
|
+
await chmod2(parentDir, 448);
|
|
1372
|
+
let renamed = false;
|
|
1373
|
+
try {
|
|
1374
|
+
await writeFile(tempPath, `${JSON.stringify(value, null, 2)}
|
|
1375
|
+
`, {
|
|
1376
|
+
encoding: "utf8",
|
|
1377
|
+
flag: "wx",
|
|
1378
|
+
mode: 384
|
|
1379
|
+
});
|
|
1380
|
+
await rename(tempPath, path);
|
|
1381
|
+
renamed = true;
|
|
1382
|
+
await chmod2(path, 384);
|
|
1383
|
+
} finally {
|
|
1384
|
+
if (!renamed) {
|
|
1385
|
+
await unlink2(tempPath).catch(() => false);
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
function emptyState() {
|
|
1390
|
+
return { version: "2", sessions: [] };
|
|
1391
|
+
}
|
|
1392
|
+
function isPidAlive(pid) {
|
|
1393
|
+
try {
|
|
1394
|
+
process3.kill(pid, 0);
|
|
1395
|
+
return true;
|
|
1396
|
+
} catch (err) {
|
|
1397
|
+
if (errorCode2(err) === "ESRCH") {
|
|
1398
|
+
return false;
|
|
1399
|
+
}
|
|
1400
|
+
return true;
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
function isPidOrGroupAlive(pid) {
|
|
1404
|
+
if (isPidAlive(pid)) {
|
|
1405
|
+
return true;
|
|
1406
|
+
}
|
|
1407
|
+
return isProcessGroupAlive(pid);
|
|
1408
|
+
}
|
|
1409
|
+
function isProcessGroupAlive(pid) {
|
|
1410
|
+
return process3.platform !== "win32" && isPidAlive(-pid);
|
|
1411
|
+
}
|
|
1412
|
+
async function isSessionHealthy(session, host) {
|
|
1413
|
+
if (session.hostname !== host) {
|
|
1414
|
+
return false;
|
|
1415
|
+
}
|
|
1416
|
+
if (session.status !== "ready" && isPidAlive(session.controllerPid ?? session.pid)) {
|
|
1417
|
+
return true;
|
|
1418
|
+
}
|
|
1419
|
+
if (session.tunnelPid !== void 0 && isPidOrGroupAlive(session.tunnelPid)) {
|
|
1420
|
+
return true;
|
|
1421
|
+
}
|
|
1422
|
+
return await isPortListening(session.localPort);
|
|
1423
|
+
}
|
|
1424
|
+
async function filterStaleSessions(sessions) {
|
|
1425
|
+
const host = getHostname();
|
|
1426
|
+
const checks = await Promise.all(
|
|
1427
|
+
sessions.map(async (session) => [
|
|
1428
|
+
session,
|
|
1429
|
+
await isSessionHealthy(session, host)
|
|
1430
|
+
])
|
|
1431
|
+
);
|
|
1432
|
+
return checks.filter(([, healthy]) => healthy).map(([session]) => session);
|
|
1433
|
+
}
|
|
1434
|
+
async function readStateRaw() {
|
|
1435
|
+
const path = stateFilePath();
|
|
1436
|
+
const parsed = await readJsonFile(path);
|
|
1437
|
+
if (parsed === void 0) {
|
|
1438
|
+
return emptyState();
|
|
1439
|
+
}
|
|
1440
|
+
const decoded = decodeStateFile(parsed);
|
|
1441
|
+
if (decoded !== void 0) {
|
|
1442
|
+
return decoded;
|
|
1443
|
+
}
|
|
1444
|
+
process3.stderr.write(
|
|
1445
|
+
`[cf-debugger] warning: state file at ${path} has an invalid structure; resetting to empty.
|
|
1446
|
+
`
|
|
1447
|
+
);
|
|
1448
|
+
return emptyState();
|
|
671
1449
|
}
|
|
672
1450
|
async function writeState(state) {
|
|
673
1451
|
await writeJsonFileAtomic(stateFilePath(), state);
|
|
674
1452
|
}
|
|
675
1453
|
async function readAndPruneLocked() {
|
|
676
1454
|
const raw = await readStateRaw();
|
|
677
|
-
const
|
|
678
|
-
const
|
|
1455
|
+
const host = getHostname();
|
|
1456
|
+
const remote = raw.sessions.filter((session) => session.hostname !== host);
|
|
1457
|
+
const local = raw.sessions.filter((session) => session.hostname === host);
|
|
1458
|
+
const pruned = await filterStaleSessions(local);
|
|
1459
|
+
const removed = local.filter(
|
|
679
1460
|
(session) => !pruned.some((active) => active.sessionId === session.sessionId)
|
|
680
1461
|
);
|
|
681
1462
|
if (removed.length > 0) {
|
|
682
|
-
await writeState({ version: "
|
|
1463
|
+
await writeState({ version: "2", sessions: [...remote, ...pruned] });
|
|
683
1464
|
}
|
|
684
1465
|
return { sessions: pruned, removed };
|
|
685
1466
|
}
|
|
1467
|
+
async function readActiveSessions() {
|
|
1468
|
+
const result = await withFileLock(stateLockPath(), readAndPruneLocked);
|
|
1469
|
+
return result.sessions;
|
|
1470
|
+
}
|
|
686
1471
|
async function readSessionSnapshot() {
|
|
687
1472
|
return await withFileLock(stateLockPath(), async () => {
|
|
688
1473
|
const raw = await readStateRaw();
|
|
@@ -693,10 +1478,28 @@ async function readAndPruneActiveSessions() {
|
|
|
693
1478
|
return await withFileLock(stateLockPath(), readAndPruneLocked);
|
|
694
1479
|
}
|
|
695
1480
|
function sessionKeyString(key) {
|
|
696
|
-
|
|
1481
|
+
const base = `${key.region}:${key.org}:${key.space}:${key.app}`;
|
|
1482
|
+
if (key.process === void 0 && key.instance === void 0) {
|
|
1483
|
+
return base;
|
|
1484
|
+
}
|
|
1485
|
+
const target = resolveNodeTarget(key);
|
|
1486
|
+
return `${base}:${target.process}:${target.instance.toString()}`;
|
|
697
1487
|
}
|
|
698
1488
|
function matchesKey(session, key) {
|
|
699
|
-
|
|
1489
|
+
const sessionTarget = resolveNodeTarget(session);
|
|
1490
|
+
const keyTarget = resolveNodeTarget(key);
|
|
1491
|
+
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);
|
|
1492
|
+
}
|
|
1493
|
+
function matchesRegistrationTarget(session, input, target) {
|
|
1494
|
+
return matchesKey(session, {
|
|
1495
|
+
region: input.region,
|
|
1496
|
+
org: input.org,
|
|
1497
|
+
space: input.space,
|
|
1498
|
+
app: input.app,
|
|
1499
|
+
process: target.process,
|
|
1500
|
+
instance: target.instance,
|
|
1501
|
+
apiEndpoint: input.apiEndpoint
|
|
1502
|
+
});
|
|
700
1503
|
}
|
|
701
1504
|
var DEFAULT_BASE_PORT = 2e4;
|
|
702
1505
|
var DEFAULT_MAX_PORT = 20999;
|
|
@@ -725,9 +1528,13 @@ async function pickPort(preferred, reserved, probe, basePort, maxPort) {
|
|
|
725
1528
|
);
|
|
726
1529
|
}
|
|
727
1530
|
async function registerNewSession(input) {
|
|
1531
|
+
const target = resolveNodeTarget(input);
|
|
728
1532
|
return await withFileLock(stateLockPath(), async () => {
|
|
729
1533
|
const pruneResult = await readAndPruneLocked();
|
|
730
|
-
const
|
|
1534
|
+
const persisted = await readStateRaw();
|
|
1535
|
+
const host = getHostname();
|
|
1536
|
+
const remoteSessions = persisted.sessions.filter((session2) => session2.hostname !== host);
|
|
1537
|
+
const existing = pruneResult.sessions.find((session2) => matchesRegistrationTarget(session2, input, target));
|
|
731
1538
|
if (existing) {
|
|
732
1539
|
return { session: existing, existing };
|
|
733
1540
|
}
|
|
@@ -739,28 +1546,41 @@ async function registerNewSession(input) {
|
|
|
739
1546
|
input.basePort ?? DEFAULT_BASE_PORT,
|
|
740
1547
|
input.maxPort ?? DEFAULT_MAX_PORT
|
|
741
1548
|
);
|
|
742
|
-
const
|
|
743
|
-
const
|
|
744
|
-
|
|
745
|
-
sessionId,
|
|
746
|
-
pid: process2.pid,
|
|
747
|
-
hostname: getHostname(),
|
|
748
|
-
region: input.region,
|
|
749
|
-
org: input.org,
|
|
750
|
-
space: input.space,
|
|
751
|
-
app: input.app,
|
|
752
|
-
apiEndpoint: input.apiEndpoint,
|
|
753
|
-
localPort,
|
|
754
|
-
remotePort: 9229,
|
|
755
|
-
cfHomeDir,
|
|
756
|
-
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
757
|
-
status: "starting"
|
|
758
|
-
};
|
|
759
|
-
const nextSessions = [...pruneResult.sessions, session];
|
|
760
|
-
await writeState({ version: "1", sessions: nextSessions });
|
|
1549
|
+
const session = createRegisteredSession(input, target, localPort);
|
|
1550
|
+
const nextSessions = [...remoteSessions, ...pruneResult.sessions, session];
|
|
1551
|
+
await writeState({ version: "2", sessions: nextSessions });
|
|
761
1552
|
return { session };
|
|
762
1553
|
});
|
|
763
1554
|
}
|
|
1555
|
+
function createRegisteredSession(input, target, localPort) {
|
|
1556
|
+
const sessionId = (input.sessionIdFactory ?? randomUUID2)();
|
|
1557
|
+
if (!isSafeSessionId(sessionId)) {
|
|
1558
|
+
throw new CfDebuggerError("UNSAFE_INPUT", "Generated debugger session ID is invalid.");
|
|
1559
|
+
}
|
|
1560
|
+
const cfHomeDir = input.cfHomeForSession(sessionId);
|
|
1561
|
+
if (!isAbsolute3(cfHomeDir)) {
|
|
1562
|
+
throw new CfDebuggerError("UNSAFE_INPUT", "Debugger CF home must be an absolute path.");
|
|
1563
|
+
}
|
|
1564
|
+
return {
|
|
1565
|
+
sessionId,
|
|
1566
|
+
pid: process3.pid,
|
|
1567
|
+
controllerPid: process3.pid,
|
|
1568
|
+
hostname: getHostname(),
|
|
1569
|
+
region: input.region,
|
|
1570
|
+
org: input.org,
|
|
1571
|
+
space: input.space,
|
|
1572
|
+
app: input.app,
|
|
1573
|
+
process: target.process,
|
|
1574
|
+
instance: target.instance,
|
|
1575
|
+
...target.nodePid === void 0 ? {} : { nodePid: target.nodePid },
|
|
1576
|
+
apiEndpoint: input.apiEndpoint,
|
|
1577
|
+
localPort,
|
|
1578
|
+
remotePort: 9229,
|
|
1579
|
+
cfHomeDir,
|
|
1580
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1581
|
+
status: "starting"
|
|
1582
|
+
};
|
|
1583
|
+
}
|
|
764
1584
|
async function updateSessionStatus(sessionId, status, message) {
|
|
765
1585
|
return await withFileLock(stateLockPath(), async () => {
|
|
766
1586
|
const raw = await readStateRaw();
|
|
@@ -769,32 +1589,62 @@ async function updateSessionStatus(sessionId, status, message) {
|
|
|
769
1589
|
if (session.sessionId !== sessionId) {
|
|
770
1590
|
return session;
|
|
771
1591
|
}
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
startedAt: session.startedAt,
|
|
785
|
-
status
|
|
786
|
-
};
|
|
787
|
-
const next = message === void 0 ? base : { ...base, message };
|
|
1592
|
+
if (status !== "stopping" && startupMutationBlocked(session)) {
|
|
1593
|
+
updated = session;
|
|
1594
|
+
return session;
|
|
1595
|
+
}
|
|
1596
|
+
if (status === "ready" && session.tunnelPid === void 0) {
|
|
1597
|
+
throw new CfDebuggerError(
|
|
1598
|
+
"SESSION_STATE_CONFLICT",
|
|
1599
|
+
"A debugger session cannot become ready before its tunnel PID is recorded."
|
|
1600
|
+
);
|
|
1601
|
+
}
|
|
1602
|
+
const base = withoutMessage(session);
|
|
1603
|
+
const next = message === void 0 ? { ...base, status } : { ...base, status, message };
|
|
788
1604
|
updated = next;
|
|
789
1605
|
return next;
|
|
790
1606
|
});
|
|
791
1607
|
if (updated) {
|
|
792
|
-
await writeState({ version: "
|
|
1608
|
+
await writeState({ version: "2", sessions: nextSessions });
|
|
793
1609
|
}
|
|
794
1610
|
return updated;
|
|
795
1611
|
});
|
|
796
1612
|
}
|
|
797
1613
|
async function updateSessionPid(sessionId, pid) {
|
|
1614
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) {
|
|
1615
|
+
throw new CfDebuggerError("UNSAFE_INPUT", "Tunnel PID must be a positive safe integer.");
|
|
1616
|
+
}
|
|
1617
|
+
return await withFileLock(stateLockPath(), async () => {
|
|
1618
|
+
const raw = await readStateRaw();
|
|
1619
|
+
let updated;
|
|
1620
|
+
const nextSessions = raw.sessions.map((session) => {
|
|
1621
|
+
if (session.sessionId !== sessionId) {
|
|
1622
|
+
return session;
|
|
1623
|
+
}
|
|
1624
|
+
if (startupMutationBlocked(session)) {
|
|
1625
|
+
updated = session;
|
|
1626
|
+
return session;
|
|
1627
|
+
}
|
|
1628
|
+
const next = { ...session, pid, tunnelPid: pid };
|
|
1629
|
+
updated = next;
|
|
1630
|
+
return next;
|
|
1631
|
+
});
|
|
1632
|
+
if (updated !== void 0) {
|
|
1633
|
+
await writeState({ version: "2", sessions: nextSessions });
|
|
1634
|
+
}
|
|
1635
|
+
return updated;
|
|
1636
|
+
});
|
|
1637
|
+
}
|
|
1638
|
+
function withoutMessage(session) {
|
|
1639
|
+
const { message, ...clone } = session;
|
|
1640
|
+
void message;
|
|
1641
|
+
return clone;
|
|
1642
|
+
}
|
|
1643
|
+
function startupMutationBlocked(session) {
|
|
1644
|
+
return session.status === "stopping" || session.stopRequestedAt !== void 0;
|
|
1645
|
+
}
|
|
1646
|
+
async function updateSessionRemoteNodePid(sessionId, remoteNodePid) {
|
|
1647
|
+
resolveNodeTarget({ nodePid: remoteNodePid });
|
|
798
1648
|
return await withFileLock(stateLockPath(), async () => {
|
|
799
1649
|
const raw = await readStateRaw();
|
|
800
1650
|
let updated;
|
|
@@ -802,27 +1652,16 @@ async function updateSessionPid(sessionId, pid) {
|
|
|
802
1652
|
if (session.sessionId !== sessionId) {
|
|
803
1653
|
return session;
|
|
804
1654
|
}
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
org: session.org,
|
|
811
|
-
space: session.space,
|
|
812
|
-
app: session.app,
|
|
813
|
-
apiEndpoint: session.apiEndpoint,
|
|
814
|
-
localPort: session.localPort,
|
|
815
|
-
remotePort: session.remotePort,
|
|
816
|
-
cfHomeDir: session.cfHomeDir,
|
|
817
|
-
startedAt: session.startedAt,
|
|
818
|
-
status: session.status,
|
|
819
|
-
...session.message === void 0 ? {} : { message: session.message }
|
|
820
|
-
};
|
|
1655
|
+
if (startupMutationBlocked(session)) {
|
|
1656
|
+
updated = session;
|
|
1657
|
+
return session;
|
|
1658
|
+
}
|
|
1659
|
+
const next = { ...session, remoteNodePid };
|
|
821
1660
|
updated = next;
|
|
822
1661
|
return next;
|
|
823
1662
|
});
|
|
824
1663
|
if (updated !== void 0) {
|
|
825
|
-
await writeState({ version: "
|
|
1664
|
+
await writeState({ version: "2", sessions: nextSessions });
|
|
826
1665
|
}
|
|
827
1666
|
return updated;
|
|
828
1667
|
});
|
|
@@ -835,72 +1674,145 @@ async function removeSession(sessionId) {
|
|
|
835
1674
|
return void 0;
|
|
836
1675
|
}
|
|
837
1676
|
const remaining = raw.sessions.filter((session) => session.sessionId !== sessionId);
|
|
838
|
-
await writeState({ version: "
|
|
1677
|
+
await writeState({ version: "2", sessions: remaining });
|
|
839
1678
|
return target;
|
|
840
1679
|
});
|
|
841
1680
|
}
|
|
1681
|
+
async function requestSessionStop(sessionId) {
|
|
1682
|
+
return await withFileLock(stateLockPath(), async () => {
|
|
1683
|
+
const raw = await readStateRaw();
|
|
1684
|
+
const target = raw.sessions.find((session) => session.sessionId === sessionId);
|
|
1685
|
+
if (target === void 0) {
|
|
1686
|
+
return void 0;
|
|
1687
|
+
}
|
|
1688
|
+
if (target.status === "ready" || target.stopRequestedAt !== void 0) {
|
|
1689
|
+
return { session: target, previousStatus: target.status };
|
|
1690
|
+
}
|
|
1691
|
+
const requested = { ...target, stopRequestedAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
1692
|
+
const sessions = raw.sessions.map(
|
|
1693
|
+
(session) => session.sessionId === sessionId ? requested : session
|
|
1694
|
+
);
|
|
1695
|
+
await writeState({ version: "2", sessions });
|
|
1696
|
+
return { session: requested, previousStatus: target.status };
|
|
1697
|
+
});
|
|
1698
|
+
}
|
|
842
1699
|
|
|
843
1700
|
// src/debug-session/constants.ts
|
|
844
1701
|
var DEFAULT_TUNNEL_READY_TIMEOUT_MS = 18e4;
|
|
845
1702
|
var POST_USR1_DELAY_MS = 300;
|
|
846
|
-
var PORT_CLEANUP_DELAY_MS = 600;
|
|
847
1703
|
var CHILD_SIGTERM_GRACE_MS = 2e3;
|
|
848
|
-
var
|
|
1704
|
+
var CHILD_SIGKILL_GRACE_MS = 1e3;
|
|
849
1705
|
var PID_TERMINATION_POLL_MS = 100;
|
|
850
1706
|
|
|
851
1707
|
// src/debug-session/orphans.ts
|
|
1708
|
+
import { rm } from "fs/promises";
|
|
852
1709
|
import { hostname as getHostname2 } from "os";
|
|
853
1710
|
async function pruneAndCleanupOrphans() {
|
|
854
1711
|
const result = await readAndPruneActiveSessions();
|
|
855
1712
|
const host = getHostname2();
|
|
856
1713
|
for (const removed of result.removed) {
|
|
857
|
-
if (removed.hostname === host) {
|
|
858
|
-
|
|
1714
|
+
if (removed.hostname === host && isOwnedSessionCfHomeDir(removed.sessionId, removed.cfHomeDir)) {
|
|
1715
|
+
try {
|
|
1716
|
+
await rm(removed.cfHomeDir, { recursive: true, force: true });
|
|
1717
|
+
} catch {
|
|
1718
|
+
}
|
|
859
1719
|
}
|
|
860
1720
|
}
|
|
861
|
-
return result
|
|
1721
|
+
return result;
|
|
862
1722
|
}
|
|
863
1723
|
|
|
864
1724
|
// src/debug-session/processes.ts
|
|
865
|
-
import
|
|
866
|
-
function
|
|
867
|
-
const
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
return;
|
|
872
|
-
} catch {
|
|
873
|
-
}
|
|
1725
|
+
import process4 from "process";
|
|
1726
|
+
async function terminatePidOrGroup(pid, timeoutMs = CHILD_SIGTERM_GRACE_MS, pinnedTarget) {
|
|
1727
|
+
const targetKind = pinnedTarget ?? (isProcessGroupAlive(pid) ? "group" : "pid");
|
|
1728
|
+
const targetAlive = () => targetKind === "group" ? isProcessGroupAlive(pid) : isPidAlive(pid);
|
|
1729
|
+
if (!targetAlive()) {
|
|
1730
|
+
return "terminated";
|
|
874
1731
|
}
|
|
875
1732
|
try {
|
|
876
|
-
|
|
1733
|
+
process4.kill(targetKind === "group" ? -pid : pid, "SIGTERM");
|
|
877
1734
|
} catch {
|
|
878
1735
|
}
|
|
879
|
-
}
|
|
880
|
-
async function terminatePidOrGroup(pid, timeoutMs = CHILD_SIGTERM_GRACE_MS) {
|
|
881
|
-
if (!isPidAlive(pid)) {
|
|
882
|
-
return;
|
|
883
|
-
}
|
|
884
|
-
signalPidOrGroup(pid, "SIGTERM");
|
|
885
1736
|
const startedAt = Date.now();
|
|
886
1737
|
while (Date.now() - startedAt < timeoutMs) {
|
|
887
|
-
if (!
|
|
888
|
-
return;
|
|
1738
|
+
if (!targetAlive()) {
|
|
1739
|
+
return "terminated";
|
|
889
1740
|
}
|
|
890
1741
|
await new Promise((resolve) => {
|
|
891
1742
|
setTimeout(resolve, PID_TERMINATION_POLL_MS);
|
|
892
1743
|
});
|
|
893
1744
|
}
|
|
894
|
-
|
|
1745
|
+
try {
|
|
1746
|
+
process4.kill(targetKind === "group" ? -pid : pid, "SIGKILL");
|
|
1747
|
+
} catch {
|
|
1748
|
+
}
|
|
1749
|
+
const forceStartedAt = Date.now();
|
|
1750
|
+
while (Date.now() - forceStartedAt < CHILD_SIGKILL_GRACE_MS) {
|
|
1751
|
+
if (!targetAlive()) {
|
|
1752
|
+
return "terminated";
|
|
1753
|
+
}
|
|
1754
|
+
await new Promise((resolve) => {
|
|
1755
|
+
setTimeout(resolve, PID_TERMINATION_POLL_MS);
|
|
1756
|
+
});
|
|
1757
|
+
}
|
|
1758
|
+
return targetAlive() ? "still-alive" : "terminated";
|
|
895
1759
|
}
|
|
896
1760
|
async function killProcessGroupOrProc(child, timeoutMs = CHILD_SIGTERM_GRACE_MS) {
|
|
897
1761
|
if (child.pid === void 0) {
|
|
898
|
-
return;
|
|
1762
|
+
return "terminated";
|
|
899
1763
|
}
|
|
900
|
-
|
|
901
|
-
|
|
1764
|
+
const childClosed = child.exitCode !== null || child.signalCode !== null;
|
|
1765
|
+
if (childClosed && process4.platform === "win32") {
|
|
1766
|
+
return "terminated";
|
|
1767
|
+
}
|
|
1768
|
+
return await terminatePidOrGroup(child.pid, timeoutMs, childClosed ? "group" : void 0);
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
// src/debug-session/startup-cancellation.ts
|
|
1772
|
+
var STOP_REQUEST_POLL_MS = 50;
|
|
1773
|
+
function cancellationRequested(sessions, sessionId) {
|
|
1774
|
+
const session = sessions.find((candidate) => candidate.sessionId === sessionId);
|
|
1775
|
+
return session === void 0 || session.stopRequestedAt !== void 0;
|
|
1776
|
+
}
|
|
1777
|
+
function createStartupCancellation(sessionId, callerSignal) {
|
|
1778
|
+
const controller = new AbortController();
|
|
1779
|
+
let active = true;
|
|
1780
|
+
let timer;
|
|
1781
|
+
const onCallerAbort = () => {
|
|
1782
|
+
controller.abort();
|
|
1783
|
+
};
|
|
1784
|
+
const schedule = () => {
|
|
1785
|
+
timer = setTimeout(() => {
|
|
1786
|
+
void poll();
|
|
1787
|
+
}, STOP_REQUEST_POLL_MS);
|
|
1788
|
+
timer.unref();
|
|
1789
|
+
};
|
|
1790
|
+
const poll = async () => {
|
|
1791
|
+
try {
|
|
1792
|
+
const sessions = await readSessionSnapshot();
|
|
1793
|
+
if (active && cancellationRequested(sessions, sessionId)) {
|
|
1794
|
+
controller.abort();
|
|
1795
|
+
}
|
|
1796
|
+
} catch {
|
|
1797
|
+
}
|
|
1798
|
+
if (active && !controller.signal.aborted) {
|
|
1799
|
+
schedule();
|
|
1800
|
+
}
|
|
1801
|
+
};
|
|
1802
|
+
callerSignal?.addEventListener("abort", onCallerAbort, { once: true });
|
|
1803
|
+
if (callerSignal?.aborted === true) {
|
|
1804
|
+
controller.abort();
|
|
1805
|
+
} else {
|
|
1806
|
+
void poll();
|
|
902
1807
|
}
|
|
903
|
-
|
|
1808
|
+
return {
|
|
1809
|
+
signal: controller.signal,
|
|
1810
|
+
dispose: () => {
|
|
1811
|
+
active = false;
|
|
1812
|
+
clearTimeout(timer);
|
|
1813
|
+
callerSignal?.removeEventListener("abort", onCallerAbort);
|
|
1814
|
+
}
|
|
1815
|
+
};
|
|
904
1816
|
}
|
|
905
1817
|
|
|
906
1818
|
// src/debug-session/start.ts
|
|
@@ -922,9 +1834,30 @@ function checkAbort(signal) {
|
|
|
922
1834
|
throw new CfDebuggerError("ABORTED", "Operation aborted by caller");
|
|
923
1835
|
}
|
|
924
1836
|
}
|
|
1837
|
+
function requireStartupState(state, expectedStatus) {
|
|
1838
|
+
if (state === void 0) {
|
|
1839
|
+
throw new CfDebuggerError(
|
|
1840
|
+
"SESSION_STATE_LOST",
|
|
1841
|
+
"Debugger session ownership state disappeared during startup."
|
|
1842
|
+
);
|
|
1843
|
+
}
|
|
1844
|
+
if (state.stopRequestedAt !== void 0 || state.status === "stopping") {
|
|
1845
|
+
throw new CfDebuggerError("ABORTED", "Debugger session stop was requested during startup.");
|
|
1846
|
+
}
|
|
1847
|
+
if (expectedStatus !== void 0 && state.status !== expectedStatus) {
|
|
1848
|
+
throw new CfDebuggerError(
|
|
1849
|
+
"SESSION_STATE_CONFLICT",
|
|
1850
|
+
`Debugger session state did not transition to ${expectedStatus}.`
|
|
1851
|
+
);
|
|
1852
|
+
}
|
|
1853
|
+
return state;
|
|
1854
|
+
}
|
|
1855
|
+
async function transitionStartupStatus(sessionId, status, message) {
|
|
1856
|
+
return requireStartupState(await updateSessionStatus(sessionId, status, message), status);
|
|
1857
|
+
}
|
|
925
1858
|
function requireCredentials(options) {
|
|
926
|
-
const email = options.email ??
|
|
927
|
-
const password = options.password ??
|
|
1859
|
+
const email = options.email ?? process5.env["SAP_EMAIL"];
|
|
1860
|
+
const password = options.password ?? process5.env["SAP_PASSWORD"];
|
|
928
1861
|
if (email === void 0 || email === "") {
|
|
929
1862
|
throw new CfDebuggerError(
|
|
930
1863
|
"MISSING_CREDENTIALS",
|
|
@@ -939,12 +1872,15 @@ function requireCredentials(options) {
|
|
|
939
1872
|
}
|
|
940
1873
|
return { email, password };
|
|
941
1874
|
}
|
|
942
|
-
async function registerSession(options, apiEndpoint) {
|
|
1875
|
+
async function registerSession(options, target, apiEndpoint) {
|
|
943
1876
|
const registration = await registerNewSession({
|
|
944
1877
|
region: options.region,
|
|
945
1878
|
org: options.org,
|
|
946
1879
|
space: options.space,
|
|
947
1880
|
app: options.app,
|
|
1881
|
+
process: target.process,
|
|
1882
|
+
instance: target.instance,
|
|
1883
|
+
...target.nodePid === void 0 ? {} : { nodePid: target.nodePid },
|
|
948
1884
|
apiEndpoint,
|
|
949
1885
|
...options.preferredPort === void 0 ? {} : { preferredPort: options.preferredPort },
|
|
950
1886
|
portProbe: isPortFree,
|
|
@@ -960,50 +1896,55 @@ async function registerSession(options, apiEndpoint) {
|
|
|
960
1896
|
}
|
|
961
1897
|
async function loginAndTarget(options, apiEndpoint, email, password, context, sessionId, emit) {
|
|
962
1898
|
emit("logging-in");
|
|
963
|
-
await
|
|
1899
|
+
await transitionStartupStatus(sessionId, "logging-in");
|
|
964
1900
|
await cfLogin(apiEndpoint, email, password, context);
|
|
965
|
-
checkAbort(
|
|
1901
|
+
checkAbort(context.signal);
|
|
966
1902
|
emit("targeting");
|
|
967
|
-
await
|
|
1903
|
+
await transitionStartupStatus(sessionId, "targeting");
|
|
968
1904
|
await cfTarget(options.org, options.space, context);
|
|
969
|
-
checkAbort(
|
|
1905
|
+
checkAbort(context.signal);
|
|
970
1906
|
}
|
|
971
|
-
async function signalRemoteNode(options, context, sessionId, emit) {
|
|
1907
|
+
async function signalRemoteNode(options, target, context, sessionId, emit) {
|
|
972
1908
|
emit("signaling");
|
|
973
|
-
await
|
|
974
|
-
const signalResult = await
|
|
1909
|
+
await transitionStartupStatus(sessionId, "signaling");
|
|
1910
|
+
const signalResult = await executeRemoteSignal(options.app, target, context);
|
|
975
1911
|
if (!isSshDisabledError(signalResult.stderr)) {
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
1912
|
+
return parseSignalResult(options.app, signalResult);
|
|
1913
|
+
}
|
|
1914
|
+
if (options.allowSshEnableRestart === false) {
|
|
979
1915
|
throw new CfDebuggerError(
|
|
980
|
-
"
|
|
981
|
-
`
|
|
1916
|
+
"SSH_NOT_ENABLED",
|
|
1917
|
+
`SSH is disabled for ${options.app}; automatic SSH enable and app restart are not allowed.`,
|
|
982
1918
|
signalResult.stderr
|
|
983
1919
|
);
|
|
984
1920
|
}
|
|
1921
|
+
await enableSshAndRestart(options, target, context, sessionId, emit);
|
|
1922
|
+
return await retryRemoteSignal(options, target, context, sessionId, emit);
|
|
1923
|
+
}
|
|
1924
|
+
async function enableSshAndRestart(options, target, context, sessionId, emit) {
|
|
1925
|
+
if (target.nodePid !== void 0) {
|
|
1926
|
+
throw new CfDebuggerError(
|
|
1927
|
+
"NODE_PID_RESTART_UNSAFE",
|
|
1928
|
+
`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.`
|
|
1929
|
+
);
|
|
1930
|
+
}
|
|
985
1931
|
const alreadyEnabled = await cfSshEnabled(options.app, context);
|
|
986
1932
|
if (!alreadyEnabled) {
|
|
987
1933
|
emit("ssh-enabling", "Enabling SSH on the app");
|
|
988
|
-
await
|
|
1934
|
+
await transitionStartupStatus(sessionId, "ssh-enabling");
|
|
989
1935
|
await cfEnableSsh(options.app, context);
|
|
990
1936
|
}
|
|
991
1937
|
emit("ssh-restarting", "Restarting app so SSH becomes active");
|
|
992
|
-
await
|
|
1938
|
+
await transitionStartupStatus(sessionId, "ssh-restarting");
|
|
993
1939
|
await cfRestartApp(options.app, context);
|
|
994
|
-
checkAbort(
|
|
995
|
-
await retryRemoteSignal(options, context, sessionId, emit);
|
|
1940
|
+
checkAbort(context.signal);
|
|
996
1941
|
}
|
|
997
|
-
async function retryRemoteSignal(options, context, sessionId, emit) {
|
|
1942
|
+
async function retryRemoteSignal(options, target, context, sessionId, emit) {
|
|
998
1943
|
emit("signaling");
|
|
999
|
-
await
|
|
1000
|
-
const retrySignalResult = await
|
|
1001
|
-
options.app,
|
|
1002
|
-
`kill -s USR1 $(pidof node)`,
|
|
1003
|
-
context
|
|
1004
|
-
);
|
|
1944
|
+
await transitionStartupStatus(sessionId, "signaling");
|
|
1945
|
+
const retrySignalResult = await executeRemoteSignal(options.app, target, context);
|
|
1005
1946
|
if (retrySignalResult.exitCode === 0) {
|
|
1006
|
-
return;
|
|
1947
|
+
return parseSignalResult(options.app, retrySignalResult);
|
|
1007
1948
|
}
|
|
1008
1949
|
throw new CfDebuggerError(
|
|
1009
1950
|
"USR1_SIGNAL_FAILED",
|
|
@@ -1011,36 +1952,77 @@ async function retryRemoteSignal(options, context, sessionId, emit) {
|
|
|
1011
1952
|
retrySignalResult.stderr
|
|
1012
1953
|
);
|
|
1013
1954
|
}
|
|
1014
|
-
async function
|
|
1015
|
-
await
|
|
1016
|
-
|
|
1955
|
+
async function executeRemoteSignal(appName, target, context) {
|
|
1956
|
+
return await cfSshOneShot(appName, buildNodeInspectorCommand(target.nodePid), context, {
|
|
1957
|
+
process: target.process,
|
|
1958
|
+
instance: target.instance
|
|
1017
1959
|
});
|
|
1018
|
-
checkAbort(signal);
|
|
1019
1960
|
}
|
|
1020
|
-
|
|
1021
|
-
if (
|
|
1022
|
-
|
|
1961
|
+
function parseSignalResult(appName, result) {
|
|
1962
|
+
if (result.exitCode !== 0) {
|
|
1963
|
+
throw new CfDebuggerError(
|
|
1964
|
+
"USR1_SIGNAL_FAILED",
|
|
1965
|
+
`Failed to send SIGUSR1 to the Node.js process on ${appName}: ${signalFailureDetail(result)}`,
|
|
1966
|
+
result.stderr
|
|
1967
|
+
);
|
|
1023
1968
|
}
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1969
|
+
if (result.outputTruncated) {
|
|
1970
|
+
throw new CfDebuggerError(
|
|
1971
|
+
"INSPECTOR_OUTPUT_TOO_LARGE",
|
|
1972
|
+
"Inspector startup output exceeded the configured capture limit."
|
|
1973
|
+
);
|
|
1974
|
+
}
|
|
1975
|
+
return parseNodeInspectorMarkers(result.stdout).remoteNodePid;
|
|
1976
|
+
}
|
|
1977
|
+
async function waitAfterSignal(signal) {
|
|
1978
|
+
if (signal?.aborted) {
|
|
1979
|
+
throw new CfDebuggerError("ABORTED", "Operation aborted by caller");
|
|
1980
|
+
}
|
|
1981
|
+
await new Promise((resolve, reject) => {
|
|
1982
|
+
const onAbort = () => {
|
|
1983
|
+
clearTimeout(timer);
|
|
1984
|
+
reject(new CfDebuggerError("ABORTED", "Operation aborted by caller"));
|
|
1985
|
+
};
|
|
1986
|
+
const timer = setTimeout(() => {
|
|
1987
|
+
signal?.removeEventListener("abort", onAbort);
|
|
1988
|
+
resolve();
|
|
1989
|
+
}, POST_USR1_DELAY_MS);
|
|
1990
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
1027
1991
|
});
|
|
1992
|
+
}
|
|
1993
|
+
async function ensurePortAvailable(localPort) {
|
|
1028
1994
|
if (!await isPortFree(localPort)) {
|
|
1029
1995
|
throw new CfDebuggerError(
|
|
1030
1996
|
"PORT_UNAVAILABLE",
|
|
1031
|
-
`Local port ${localPort.toString()}
|
|
1997
|
+
`Local port ${localPort.toString()} was taken before the tunnel could start.`
|
|
1032
1998
|
);
|
|
1033
1999
|
}
|
|
1034
2000
|
}
|
|
1035
|
-
async function openReadyTunnel(options, session, context, tunnelReadyTimeoutMs, onChild) {
|
|
2001
|
+
async function openReadyTunnel(options, target, session, context, tunnelReadyTimeoutMs, onChild) {
|
|
1036
2002
|
await ensurePortAvailable(session.localPort);
|
|
1037
|
-
|
|
2003
|
+
checkAbort(context.signal);
|
|
2004
|
+
const child = spawnSshTunnel(options.app, session.localPort, session.remotePort, context, {
|
|
2005
|
+
process: target.process,
|
|
2006
|
+
instance: target.instance
|
|
2007
|
+
});
|
|
1038
2008
|
onChild(child);
|
|
1039
|
-
|
|
1040
|
-
|
|
2009
|
+
const childPid = child.pid;
|
|
2010
|
+
if (childPid === void 0) {
|
|
2011
|
+
throw new CfDebuggerError("TUNNEL_PROCESS_MISSING", "The CF SSH tunnel process did not expose a PID.");
|
|
1041
2012
|
}
|
|
1042
|
-
const
|
|
1043
|
-
|
|
2013
|
+
const pidState = requireStartupState(await updateSessionPid(session.sessionId, childPid));
|
|
2014
|
+
if (pidState.tunnelPid !== childPid || pidState.pid !== childPid) {
|
|
2015
|
+
throw new CfDebuggerError(
|
|
2016
|
+
"SESSION_STATE_CONFLICT",
|
|
2017
|
+
"Debugger session did not retain ownership of the spawned tunnel process."
|
|
2018
|
+
);
|
|
2019
|
+
}
|
|
2020
|
+
const ready = await probeTunnelReady(
|
|
2021
|
+
session.localPort,
|
|
2022
|
+
tunnelReadyTimeoutMs,
|
|
2023
|
+
context.signal
|
|
2024
|
+
);
|
|
2025
|
+
checkAbort(context.signal);
|
|
1044
2026
|
if (!ready) {
|
|
1045
2027
|
throw new CfDebuggerError(
|
|
1046
2028
|
"TUNNEL_NOT_READY",
|
|
@@ -1048,15 +2030,21 @@ async function openReadyTunnel(options, session, context, tunnelReadyTimeoutMs,
|
|
|
1048
2030
|
);
|
|
1049
2031
|
}
|
|
1050
2032
|
const listeningPid = await findListeningProcessId(session.localPort);
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
2033
|
+
if (listeningPid === void 0) {
|
|
2034
|
+
throw new CfDebuggerError(
|
|
2035
|
+
"TUNNEL_OWNER_UNVERIFIED",
|
|
2036
|
+
`Could not verify the owner of local tunnel port ${session.localPort.toString()}.`
|
|
2037
|
+
);
|
|
2038
|
+
}
|
|
2039
|
+
if (listeningPid !== childPid) {
|
|
2040
|
+
throw new CfDebuggerError(
|
|
2041
|
+
"TUNNEL_OWNER_MISMATCH",
|
|
2042
|
+
`Local tunnel port ${session.localPort.toString()} is owned by an unexpected process.`
|
|
2043
|
+
);
|
|
1054
2044
|
}
|
|
1055
|
-
return { child, activePid };
|
|
1056
2045
|
}
|
|
1057
|
-
function attachTunnelEvents(child,
|
|
2046
|
+
function attachTunnelEvents(child, resolveExit, emit) {
|
|
1058
2047
|
child.on("close", (code) => {
|
|
1059
|
-
markClosed();
|
|
1060
2048
|
resolveExit(code);
|
|
1061
2049
|
});
|
|
1062
2050
|
child.on("error", (err) => {
|
|
@@ -1068,126 +2056,289 @@ function createHandle(session, emit, finalize, exitPromise) {
|
|
|
1068
2056
|
return {
|
|
1069
2057
|
session,
|
|
1070
2058
|
dispose: async () => {
|
|
1071
|
-
disposePromise
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
2059
|
+
const attempt = disposePromise ?? (async () => {
|
|
2060
|
+
await runCleanupActions([
|
|
2061
|
+
() => {
|
|
2062
|
+
emit("stopping");
|
|
2063
|
+
},
|
|
2064
|
+
async () => {
|
|
2065
|
+
await updateSessionStatus(session.sessionId, "stopping");
|
|
2066
|
+
},
|
|
2067
|
+
finalize
|
|
2068
|
+
], "Debugger disposal failed");
|
|
1075
2069
|
})();
|
|
1076
|
-
|
|
2070
|
+
disposePromise = attempt;
|
|
2071
|
+
try {
|
|
2072
|
+
await attempt;
|
|
2073
|
+
} catch (error) {
|
|
2074
|
+
if (disposePromise === attempt) {
|
|
2075
|
+
disposePromise = void 0;
|
|
2076
|
+
}
|
|
2077
|
+
throw error;
|
|
2078
|
+
}
|
|
1077
2079
|
},
|
|
1078
2080
|
waitForExit: async () => {
|
|
1079
2081
|
return await exitPromise;
|
|
1080
2082
|
}
|
|
1081
2083
|
};
|
|
1082
2084
|
}
|
|
1083
|
-
async function
|
|
1084
|
-
|
|
1085
|
-
const
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
2085
|
+
async function runCleanupActions(actions, aggregateMessage) {
|
|
2086
|
+
let errors = [];
|
|
2087
|
+
for (const action of actions) {
|
|
2088
|
+
try {
|
|
2089
|
+
await action();
|
|
2090
|
+
} catch (error) {
|
|
2091
|
+
errors = [...errors, error];
|
|
2092
|
+
}
|
|
2093
|
+
}
|
|
2094
|
+
if (errors.length === 1) {
|
|
2095
|
+
throw errors[0];
|
|
2096
|
+
}
|
|
2097
|
+
if (errors.length > 1) {
|
|
2098
|
+
throw new AggregateError(errors, aggregateMessage);
|
|
2099
|
+
}
|
|
2100
|
+
}
|
|
2101
|
+
async function prepareCfHome(cfHomeDir) {
|
|
2102
|
+
await mkdir3(cfHomeDir, { recursive: true, mode: 448 });
|
|
2103
|
+
await chmod3(cfHomeDir, 448);
|
|
2104
|
+
}
|
|
2105
|
+
function createTunnelLifecycle(session, emit) {
|
|
1094
2106
|
let child;
|
|
1095
|
-
let tunnelClosed = false;
|
|
1096
2107
|
let exitResolve = (_code) => {
|
|
1097
2108
|
throw new Error("Exit resolver was used before initialization");
|
|
1098
2109
|
};
|
|
1099
2110
|
const exitPromise = new Promise((resolve) => {
|
|
1100
2111
|
exitResolve = resolve;
|
|
1101
2112
|
});
|
|
2113
|
+
const observeChild = (tunnelChild) => {
|
|
2114
|
+
child = tunnelChild;
|
|
2115
|
+
attachTunnelEvents(tunnelChild, exitResolve, emit);
|
|
2116
|
+
};
|
|
1102
2117
|
const finalize = async () => {
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
}, PORT_CLEANUP_DELAY_MS);
|
|
2118
|
+
const termination = child === void 0 ? "terminated" : await killProcessGroupOrProc(child);
|
|
2119
|
+
const portListening = child !== void 0 && await isPortListening(session.localPort);
|
|
2120
|
+
if (termination === "still-alive" || portListening) {
|
|
2121
|
+
throw new CfDebuggerError(
|
|
2122
|
+
"TUNNEL_TERMINATION_FAILED",
|
|
2123
|
+
`Tunnel for session ${session.sessionId} did not terminate; state and CF home were retained.`
|
|
2124
|
+
);
|
|
1111
2125
|
}
|
|
1112
|
-
await
|
|
1113
|
-
|
|
2126
|
+
await runCleanupActions([
|
|
2127
|
+
async () => {
|
|
2128
|
+
await removeSession(session.sessionId);
|
|
2129
|
+
},
|
|
2130
|
+
async () => {
|
|
2131
|
+
await cleanupFilesystem(session.cfHomeDir);
|
|
2132
|
+
}
|
|
2133
|
+
], "Debugger resource cleanup failed");
|
|
1114
2134
|
emit("stopped");
|
|
1115
2135
|
};
|
|
2136
|
+
return { exitPromise, finalize, observeChild };
|
|
2137
|
+
}
|
|
2138
|
+
async function establishDebuggerSession(inputs) {
|
|
2139
|
+
const { options, target, session, context, credentials, timeoutMs, lifecycle, emit } = inputs;
|
|
2140
|
+
await prepareCfHome(session.cfHomeDir);
|
|
2141
|
+
await loginAndTarget(
|
|
2142
|
+
options,
|
|
2143
|
+
session.apiEndpoint,
|
|
2144
|
+
credentials.email,
|
|
2145
|
+
credentials.password,
|
|
2146
|
+
context,
|
|
2147
|
+
session.sessionId,
|
|
2148
|
+
emit
|
|
2149
|
+
);
|
|
2150
|
+
await ensurePortAvailable(session.localPort);
|
|
2151
|
+
const remoteNodePid = await signalRemoteNode(options, target, context, session.sessionId, emit);
|
|
2152
|
+
const remoteState = requireStartupState(
|
|
2153
|
+
await updateSessionRemoteNodePid(session.sessionId, remoteNodePid)
|
|
2154
|
+
);
|
|
2155
|
+
if (remoteState.remoteNodePid !== remoteNodePid) {
|
|
2156
|
+
throw new CfDebuggerError(
|
|
2157
|
+
"SESSION_STATE_CONFLICT",
|
|
2158
|
+
"Debugger session did not retain the selected remote Node PID."
|
|
2159
|
+
);
|
|
2160
|
+
}
|
|
2161
|
+
await waitAfterSignal(context.signal);
|
|
2162
|
+
emit("tunneling");
|
|
2163
|
+
await transitionStartupStatus(session.sessionId, "tunneling");
|
|
2164
|
+
await openReadyTunnel(
|
|
2165
|
+
options,
|
|
2166
|
+
target,
|
|
2167
|
+
session,
|
|
2168
|
+
context,
|
|
2169
|
+
timeoutMs,
|
|
2170
|
+
lifecycle.observeChild
|
|
2171
|
+
);
|
|
2172
|
+
emit("ready");
|
|
2173
|
+
return await transitionStartupStatus(session.sessionId, "ready");
|
|
2174
|
+
}
|
|
2175
|
+
async function failAfterStartupCleanup(error, finalize, emit) {
|
|
1116
2176
|
try {
|
|
1117
|
-
await
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
2177
|
+
await runCleanupActions([
|
|
2178
|
+
() => {
|
|
2179
|
+
emit("error", error instanceof Error ? error.message : String(error));
|
|
2180
|
+
},
|
|
2181
|
+
finalize
|
|
2182
|
+
], "Debugger startup failure reporting and cleanup failed");
|
|
2183
|
+
} catch (cleanupError) {
|
|
2184
|
+
throw new AggregateError(
|
|
2185
|
+
[error, cleanupError],
|
|
2186
|
+
"Debugger startup failed and resource cleanup was incomplete",
|
|
2187
|
+
{ cause: cleanupError }
|
|
2188
|
+
);
|
|
2189
|
+
}
|
|
2190
|
+
throw error;
|
|
2191
|
+
}
|
|
2192
|
+
async function startDebugger(options) {
|
|
2193
|
+
const target = resolveNodeTarget(options);
|
|
2194
|
+
const credentials = requireCredentials(options);
|
|
2195
|
+
const apiEndpoint = resolveApiEndpoint(options.region, options.apiEndpoint);
|
|
2196
|
+
const tunnelReadyTimeoutMs = options.tunnelReadyTimeoutMs ?? DEFAULT_TUNNEL_READY_TIMEOUT_MS;
|
|
2197
|
+
const emit = (status, message) => {
|
|
2198
|
+
options.onStatus?.(status, message);
|
|
2199
|
+
};
|
|
2200
|
+
checkAbort(options.signal);
|
|
2201
|
+
await pruneAndCleanupOrphans();
|
|
2202
|
+
const session = await registerSession(options, target, apiEndpoint);
|
|
2203
|
+
const cancellation = createStartupCancellation(session.sessionId, options.signal);
|
|
2204
|
+
const context = {
|
|
2205
|
+
cfHome: session.cfHomeDir,
|
|
2206
|
+
signal: cancellation.signal
|
|
2207
|
+
};
|
|
2208
|
+
const lifecycle = createTunnelLifecycle(session, emit);
|
|
2209
|
+
try {
|
|
2210
|
+
const activeSession = await establishDebuggerSession({
|
|
1128
2211
|
options,
|
|
2212
|
+
target,
|
|
1129
2213
|
session,
|
|
1130
2214
|
context,
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
);
|
|
1139
|
-
child = tunnel.child;
|
|
1140
|
-
emit("ready");
|
|
1141
|
-
const readySession = await updateSessionStatus(session.sessionId, "ready");
|
|
1142
|
-
const activeSession = readySession ?? { ...session, pid: tunnel.activePid, status: "ready" };
|
|
1143
|
-
return createHandle(activeSession, emit, finalize, exitPromise);
|
|
2215
|
+
credentials,
|
|
2216
|
+
timeoutMs: tunnelReadyTimeoutMs,
|
|
2217
|
+
lifecycle,
|
|
2218
|
+
emit
|
|
2219
|
+
});
|
|
2220
|
+
cancellation.dispose();
|
|
2221
|
+
return createHandle(activeSession, emit, lifecycle.finalize, lifecycle.exitPromise);
|
|
1144
2222
|
} catch (err) {
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
await finalize();
|
|
1148
|
-
throw err;
|
|
2223
|
+
cancellation.dispose();
|
|
2224
|
+
return await failAfterStartupCleanup(err, lifecycle.finalize, emit);
|
|
1149
2225
|
}
|
|
1150
2226
|
}
|
|
1151
2227
|
async function cleanupFilesystem(cfHomeDir) {
|
|
1152
|
-
|
|
1153
|
-
await rm(cfHomeDir, { recursive: true, force: true });
|
|
1154
|
-
} catch {
|
|
1155
|
-
}
|
|
2228
|
+
await rm2(cfHomeDir, { recursive: true, force: true });
|
|
1156
2229
|
}
|
|
1157
2230
|
|
|
1158
2231
|
// src/debug-session/sessions.ts
|
|
1159
|
-
import { rm as
|
|
1160
|
-
import
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
let target;
|
|
2232
|
+
import { rm as rm3 } from "fs/promises";
|
|
2233
|
+
import { hostname as getHostname3 } from "os";
|
|
2234
|
+
import process6 from "process";
|
|
2235
|
+
function findMatchingSession(sessions, options) {
|
|
1164
2236
|
if (options.sessionId !== void 0) {
|
|
1165
|
-
|
|
1166
|
-
}
|
|
2237
|
+
return sessions.find((s) => s.sessionId === options.sessionId);
|
|
2238
|
+
}
|
|
2239
|
+
if (options.key !== void 0) {
|
|
1167
2240
|
const key = options.key;
|
|
1168
|
-
|
|
2241
|
+
const matches = sessions.filter((session) => matchesKey(session, key));
|
|
2242
|
+
if (matches.length > 1) {
|
|
2243
|
+
throw new CfDebuggerError(
|
|
2244
|
+
"SESSION_AMBIGUOUS",
|
|
2245
|
+
"Multiple debugger sessions match this target. Pass an exact session ID, API endpoint, or Node PID."
|
|
2246
|
+
);
|
|
2247
|
+
}
|
|
2248
|
+
return matches[0];
|
|
1169
2249
|
}
|
|
1170
|
-
|
|
1171
|
-
|
|
2250
|
+
return void 0;
|
|
2251
|
+
}
|
|
2252
|
+
async function ownsRecordedTunnel(target) {
|
|
2253
|
+
if (target.tunnelPid === void 0 || !await isPortListening(target.localPort)) {
|
|
2254
|
+
return false;
|
|
1172
2255
|
}
|
|
1173
|
-
|
|
2256
|
+
return await findListeningProcessId(target.localPort) === target.tunnelPid;
|
|
2257
|
+
}
|
|
2258
|
+
async function terminateVerifiedTunnel(target) {
|
|
2259
|
+
if (target.tunnelPid !== void 0 && target.tunnelPid !== process6.pid) {
|
|
1174
2260
|
try {
|
|
1175
|
-
await terminatePidOrGroup(target.
|
|
2261
|
+
return await terminatePidOrGroup(target.tunnelPid);
|
|
1176
2262
|
} catch {
|
|
2263
|
+
return "still-alive";
|
|
1177
2264
|
}
|
|
1178
2265
|
}
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
2266
|
+
return target.tunnelPid === process6.pid ? "still-alive" : "terminated";
|
|
2267
|
+
}
|
|
2268
|
+
async function terminateVerifiedTunnelAndConfirm(target) {
|
|
2269
|
+
const termination = await terminateVerifiedTunnel(target);
|
|
2270
|
+
if (termination === "still-alive" || await ownsRecordedTunnel(target)) {
|
|
2271
|
+
throw new CfDebuggerError(
|
|
2272
|
+
"TUNNEL_TERMINATION_FAILED",
|
|
2273
|
+
`Tunnel process for session ${target.sessionId} did not terminate; state was retained.`
|
|
2274
|
+
);
|
|
2275
|
+
}
|
|
2276
|
+
}
|
|
2277
|
+
async function cleanupOwnedCfHome(target, locallyOwned) {
|
|
2278
|
+
if (locallyOwned && isOwnedSessionCfHomeDir(target.sessionId, target.cfHomeDir)) {
|
|
2279
|
+
try {
|
|
2280
|
+
await rm3(target.cfHomeDir, { recursive: true, force: true });
|
|
2281
|
+
} catch {
|
|
2282
|
+
}
|
|
2283
|
+
}
|
|
2284
|
+
}
|
|
2285
|
+
async function removeOwnedSession(target, stale) {
|
|
1182
2286
|
const removed = await removeSession(target.sessionId);
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
2287
|
+
await cleanupOwnedCfHome(target, true);
|
|
2288
|
+
return { ...removed ?? target, stale, pending: false };
|
|
2289
|
+
}
|
|
2290
|
+
function ownershipError(target) {
|
|
2291
|
+
return new CfDebuggerError(
|
|
2292
|
+
"TUNNEL_OWNERSHIP_UNVERIFIED",
|
|
2293
|
+
`Cannot safely stop session ${target.sessionId}: local tunnel ownership could not be verified.`
|
|
2294
|
+
);
|
|
2295
|
+
}
|
|
2296
|
+
async function stopReadySession(target) {
|
|
2297
|
+
if (await ownsRecordedTunnel(target)) {
|
|
2298
|
+
await terminateVerifiedTunnelAndConfirm(target);
|
|
2299
|
+
return await removeOwnedSession(target, false);
|
|
1186
2300
|
}
|
|
1187
|
-
|
|
2301
|
+
const tunnelDead = target.tunnelPid !== void 0 && !isPidOrGroupAlive(target.tunnelPid);
|
|
2302
|
+
if (tunnelDead && !await isPortListening(target.localPort)) {
|
|
2303
|
+
return await removeOwnedSession(target, true);
|
|
2304
|
+
}
|
|
2305
|
+
throw ownershipError(target);
|
|
2306
|
+
}
|
|
2307
|
+
async function stopStartingSession(target) {
|
|
2308
|
+
if (target.status === "stopping" && target.tunnelPid !== void 0 && !isPidOrGroupAlive(target.tunnelPid) && !await isPortListening(target.localPort)) {
|
|
2309
|
+
return await removeOwnedSession(target, true);
|
|
2310
|
+
}
|
|
2311
|
+
if (isPidAlive(target.controllerPid ?? target.pid)) {
|
|
2312
|
+
return { ...target, stale: false, pending: true };
|
|
2313
|
+
}
|
|
2314
|
+
if (await ownsRecordedTunnel(target)) {
|
|
2315
|
+
await terminateVerifiedTunnelAndConfirm(target);
|
|
2316
|
+
return await removeOwnedSession(target, false);
|
|
2317
|
+
}
|
|
2318
|
+
const tunnelAlive = target.tunnelPid !== void 0 && isPidOrGroupAlive(target.tunnelPid);
|
|
2319
|
+
if (!tunnelAlive && !await isPortListening(target.localPort)) {
|
|
2320
|
+
return await removeOwnedSession(target, true);
|
|
2321
|
+
}
|
|
2322
|
+
throw ownershipError(target);
|
|
2323
|
+
}
|
|
2324
|
+
async function stopDebugger(options) {
|
|
2325
|
+
const localSessions = (await readSessionSnapshot()).filter(
|
|
2326
|
+
(session) => session.hostname === getHostname3()
|
|
2327
|
+
);
|
|
2328
|
+
const target = findMatchingSession(localSessions, options);
|
|
2329
|
+
if (target === void 0) {
|
|
2330
|
+
return void 0;
|
|
2331
|
+
}
|
|
2332
|
+
const claim = await requestSessionStop(target.sessionId);
|
|
2333
|
+
if (claim === void 0) {
|
|
2334
|
+
return void 0;
|
|
2335
|
+
}
|
|
2336
|
+
return claim.previousStatus === "ready" ? await stopReadySession(claim.session) : await stopStartingSession(claim.session);
|
|
1188
2337
|
}
|
|
1189
2338
|
async function stopAllDebuggers() {
|
|
1190
|
-
const sessions = await
|
|
2339
|
+
const sessions = (await readSessionSnapshot()).filter(
|
|
2340
|
+
(session) => session.hostname === getHostname3()
|
|
2341
|
+
);
|
|
1191
2342
|
let stopped = 0;
|
|
1192
2343
|
for (const session of sessions) {
|
|
1193
2344
|
const result = await stopDebugger({ sessionId: session.sessionId });
|
|
@@ -1198,19 +2349,19 @@ async function stopAllDebuggers() {
|
|
|
1198
2349
|
return stopped;
|
|
1199
2350
|
}
|
|
1200
2351
|
async function listSessions() {
|
|
1201
|
-
return await
|
|
2352
|
+
return await readActiveSessions();
|
|
1202
2353
|
}
|
|
1203
2354
|
async function getSession(key) {
|
|
1204
|
-
const sessions = await
|
|
1205
|
-
return sessions
|
|
2355
|
+
const sessions = await readActiveSessions();
|
|
2356
|
+
return findMatchingSession(sessions, { key });
|
|
1206
2357
|
}
|
|
1207
2358
|
|
|
1208
2359
|
// src/cli.ts
|
|
1209
2360
|
function readRequiredOption(value, flag) {
|
|
1210
2361
|
if (value === void 0 || value === "") {
|
|
1211
|
-
|
|
2362
|
+
process7.stderr.write(`Missing required option ${flag}
|
|
1212
2363
|
`);
|
|
1213
|
-
|
|
2364
|
+
process7.exit(1);
|
|
1214
2365
|
}
|
|
1215
2366
|
return value;
|
|
1216
2367
|
}
|
|
@@ -1220,9 +2371,9 @@ function parseOptionalPort(raw) {
|
|
|
1220
2371
|
}
|
|
1221
2372
|
const port = Number.parseInt(raw, 10);
|
|
1222
2373
|
if (Number.isNaN(port) || port <= 0 || port > 65535) {
|
|
1223
|
-
|
|
2374
|
+
process7.stderr.write(`Invalid port: ${raw}
|
|
1224
2375
|
`);
|
|
1225
|
-
|
|
2376
|
+
process7.exit(1);
|
|
1226
2377
|
}
|
|
1227
2378
|
return port;
|
|
1228
2379
|
}
|
|
@@ -1232,16 +2383,32 @@ function parseOptionalTimeout(raw) {
|
|
|
1232
2383
|
}
|
|
1233
2384
|
const seconds = Number.parseInt(raw, 10);
|
|
1234
2385
|
if (Number.isNaN(seconds) || seconds <= 0) {
|
|
1235
|
-
|
|
2386
|
+
process7.stderr.write(`Invalid timeout: ${raw}
|
|
1236
2387
|
`);
|
|
1237
|
-
|
|
2388
|
+
process7.exit(1);
|
|
1238
2389
|
}
|
|
1239
2390
|
return seconds * 1e3;
|
|
1240
2391
|
}
|
|
2392
|
+
function parseOptionalInteger(raw, label, minimum) {
|
|
2393
|
+
if (raw === void 0) {
|
|
2394
|
+
return void 0;
|
|
2395
|
+
}
|
|
2396
|
+
if (!/^\d+$/.test(raw)) {
|
|
2397
|
+
throw new CfDebuggerError("UNSAFE_INPUT", `${label} must be an integer.`);
|
|
2398
|
+
}
|
|
2399
|
+
const value = Number(raw);
|
|
2400
|
+
if (!Number.isSafeInteger(value) || value < minimum) {
|
|
2401
|
+
throw new CfDebuggerError(
|
|
2402
|
+
"UNSAFE_INPUT",
|
|
2403
|
+
`${label} must be at least ${minimum.toString()} and within the safe integer range.`
|
|
2404
|
+
);
|
|
2405
|
+
}
|
|
2406
|
+
return value;
|
|
2407
|
+
}
|
|
1241
2408
|
function logStatus(verbose, status, message) {
|
|
1242
2409
|
if (verbose) {
|
|
1243
2410
|
const suffix = message === void 0 ? "" : `: ${message}`;
|
|
1244
|
-
|
|
2411
|
+
process7.stdout.write(`[cf-debugger] ${status}${suffix}
|
|
1245
2412
|
`);
|
|
1246
2413
|
}
|
|
1247
2414
|
}
|
|
@@ -1258,84 +2425,106 @@ function mergeSelector(selector, opts) {
|
|
|
1258
2425
|
}
|
|
1259
2426
|
throw new CfDebuggerError("UNSAFE_INPUT", "Invalid app selector format. Expected <app> or <region>/<org>/<space>/<app>.");
|
|
1260
2427
|
}
|
|
1261
|
-
|
|
1262
|
-
const
|
|
1263
|
-
const
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
const preferredPort = parseOptionalPort(opts.port);
|
|
1267
|
-
const tunnelReadyTimeoutMs = parseOptionalTimeout(opts.timeout);
|
|
1268
|
-
const abortController = new AbortController();
|
|
1269
|
-
const onStartupSignal = (exitCode) => () => {
|
|
1270
|
-
abortController.abort();
|
|
1271
|
-
process6.stderr.write(`
|
|
2428
|
+
function startupAbort(app) {
|
|
2429
|
+
const controller = new AbortController();
|
|
2430
|
+
const handler = (exitCode) => () => {
|
|
2431
|
+
controller.abort();
|
|
2432
|
+
process7.stderr.write(`
|
|
1272
2433
|
Aborting startup for ${app}...
|
|
1273
2434
|
`);
|
|
1274
2435
|
setTimeout(() => {
|
|
1275
|
-
|
|
2436
|
+
process7.exit(exitCode);
|
|
1276
2437
|
}, 5e3).unref();
|
|
1277
2438
|
};
|
|
1278
|
-
const
|
|
1279
|
-
const
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
...tunnelReadyTimeoutMs === void 0 ? {} : { tunnelReadyTimeoutMs },
|
|
1293
|
-
onStatus: (status, message) => {
|
|
1294
|
-
logStatus(verbose, status, message);
|
|
1295
|
-
}
|
|
1296
|
-
});
|
|
1297
|
-
} finally {
|
|
1298
|
-
process6.off("SIGINT", startupSigint);
|
|
1299
|
-
process6.off("SIGTERM", startupSigterm);
|
|
1300
|
-
}
|
|
1301
|
-
process6.stdout.write(
|
|
2439
|
+
const onSigint = handler(130);
|
|
2440
|
+
const onSigterm = handler(143);
|
|
2441
|
+
process7.on("SIGINT", onSigint);
|
|
2442
|
+
process7.on("SIGTERM", onSigterm);
|
|
2443
|
+
return {
|
|
2444
|
+
signal: controller.signal,
|
|
2445
|
+
dispose: () => {
|
|
2446
|
+
process7.off("SIGINT", onSigint);
|
|
2447
|
+
process7.off("SIGTERM", onSigterm);
|
|
2448
|
+
}
|
|
2449
|
+
};
|
|
2450
|
+
}
|
|
2451
|
+
function writeReady(app, key, handle) {
|
|
2452
|
+
process7.stdout.write(
|
|
1302
2453
|
`Debugger ready for ${app} (${key.region}/${key.org}/${key.space}).
|
|
2454
|
+
Process: ${key.process}
|
|
2455
|
+
Instance: ${key.instance.toString()}
|
|
1303
2456
|
Local port: ${handle.session.localPort.toString()}
|
|
1304
2457
|
Remote port: ${handle.session.remotePort.toString()}
|
|
1305
2458
|
Session id: ${handle.session.sessionId}
|
|
1306
|
-
PID:
|
|
2459
|
+
Tunnel PID: ${handle.session.pid.toString()}
|
|
2460
|
+
Node PID: ${handle.session.remoteNodePid?.toString() ?? "unknown"}
|
|
1307
2461
|
Press Ctrl+C to stop.
|
|
1308
2462
|
`
|
|
1309
2463
|
);
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
2464
|
+
}
|
|
2465
|
+
function handleDisposer(app, handle) {
|
|
2466
|
+
let pending;
|
|
2467
|
+
return async () => {
|
|
2468
|
+
pending ??= (async () => {
|
|
2469
|
+
process7.stdout.write(`
|
|
1314
2470
|
Stopping debugger for ${app}...
|
|
1315
2471
|
`);
|
|
1316
2472
|
try {
|
|
1317
2473
|
await handle.dispose();
|
|
1318
|
-
} catch (
|
|
1319
|
-
const
|
|
1320
|
-
|
|
2474
|
+
} catch (error) {
|
|
2475
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2476
|
+
process7.stderr.write(`Error during stop: ${message}
|
|
1321
2477
|
`);
|
|
1322
2478
|
}
|
|
1323
2479
|
})();
|
|
1324
|
-
await
|
|
2480
|
+
await pending;
|
|
1325
2481
|
};
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
});
|
|
1331
|
-
process6.on("SIGTERM", () => {
|
|
2482
|
+
}
|
|
2483
|
+
async function waitForHandle(app, handle) {
|
|
2484
|
+
const dispose = handleDisposer(app, handle);
|
|
2485
|
+
const stop = (exitCode) => () => {
|
|
1332
2486
|
void dispose().then(() => {
|
|
1333
|
-
|
|
2487
|
+
process7.exit(exitCode);
|
|
1334
2488
|
});
|
|
1335
|
-
}
|
|
2489
|
+
};
|
|
2490
|
+
process7.on("SIGINT", stop(130));
|
|
2491
|
+
process7.on("SIGTERM", stop(143));
|
|
1336
2492
|
const code = await handle.waitForExit();
|
|
1337
2493
|
await dispose();
|
|
1338
|
-
|
|
2494
|
+
process7.exit(code ?? 0);
|
|
2495
|
+
}
|
|
2496
|
+
async function handleStart(selector, rawOpts) {
|
|
2497
|
+
const opts = mergeSelector(selector, rawOpts);
|
|
2498
|
+
const app = readRequiredOption(opts.app, "--app or selector");
|
|
2499
|
+
const key = await resolveSessionKey({ ...opts, app });
|
|
2500
|
+
const verbose = opts.verbose ?? false;
|
|
2501
|
+
const preferredPort = parseOptionalPort(opts.port);
|
|
2502
|
+
const tunnelReadyTimeoutMs = parseOptionalTimeout(opts.timeout);
|
|
2503
|
+
const nodePid = parseOptionalInteger(opts.nodePid, "nodePid", 1);
|
|
2504
|
+
const abort = startupAbort(app);
|
|
2505
|
+
let handle;
|
|
2506
|
+
try {
|
|
2507
|
+
handle = await startDebugger({
|
|
2508
|
+
region: key.region,
|
|
2509
|
+
org: key.org,
|
|
2510
|
+
space: key.space,
|
|
2511
|
+
app,
|
|
2512
|
+
process: key.process,
|
|
2513
|
+
instance: key.instance,
|
|
2514
|
+
verbose,
|
|
2515
|
+
signal: abort.signal,
|
|
2516
|
+
...preferredPort === void 0 ? {} : { preferredPort },
|
|
2517
|
+
...tunnelReadyTimeoutMs === void 0 ? {} : { tunnelReadyTimeoutMs },
|
|
2518
|
+
...nodePid === void 0 ? {} : { nodePid },
|
|
2519
|
+
onStatus: (status, message) => {
|
|
2520
|
+
logStatus(verbose, status, message);
|
|
2521
|
+
}
|
|
2522
|
+
});
|
|
2523
|
+
} finally {
|
|
2524
|
+
abort.dispose();
|
|
2525
|
+
}
|
|
2526
|
+
writeReady(app, key, handle);
|
|
2527
|
+
await waitForHandle(app, handle);
|
|
1339
2528
|
}
|
|
1340
2529
|
function hasText(value) {
|
|
1341
2530
|
return optionalText(value) !== void 0;
|
|
@@ -1345,7 +2534,7 @@ function optionalText(value) {
|
|
|
1345
2534
|
return trimmed === void 0 || trimmed.length === 0 ? void 0 : trimmed;
|
|
1346
2535
|
}
|
|
1347
2536
|
function currentCfOptions() {
|
|
1348
|
-
const command =
|
|
2537
|
+
const command = process7.env["CF_DEBUGGER_CF_BIN"];
|
|
1349
2538
|
return command === void 0 ? void 0 : { command };
|
|
1350
2539
|
}
|
|
1351
2540
|
async function resolveSessionKey(opts) {
|
|
@@ -1353,12 +2542,20 @@ async function resolveSessionKey(opts) {
|
|
|
1353
2542
|
const region = optionalText(opts.region);
|
|
1354
2543
|
const org = optionalText(opts.org);
|
|
1355
2544
|
const space = optionalText(opts.space);
|
|
2545
|
+
const processName = opts.process;
|
|
2546
|
+
const instance = parseOptionalInteger(opts.instance, "instance", 0);
|
|
2547
|
+
const target = resolveNodeTarget({
|
|
2548
|
+
...processName === void 0 ? {} : { process: processName },
|
|
2549
|
+
...instance === void 0 ? {} : { instance }
|
|
2550
|
+
});
|
|
1356
2551
|
if (region !== void 0 && org !== void 0 && space !== void 0) {
|
|
1357
2552
|
return {
|
|
1358
2553
|
region,
|
|
1359
2554
|
org,
|
|
1360
2555
|
space,
|
|
1361
|
-
app
|
|
2556
|
+
app,
|
|
2557
|
+
process: target.process,
|
|
2558
|
+
instance: target.instance
|
|
1362
2559
|
};
|
|
1363
2560
|
}
|
|
1364
2561
|
const current = await readCurrentCfTarget(currentCfOptions()).catch((error) => {
|
|
@@ -1378,7 +2575,9 @@ async function resolveSessionKey(opts) {
|
|
|
1378
2575
|
region: region ?? requireCurrentCfRegion(current),
|
|
1379
2576
|
org: org ?? current.org,
|
|
1380
2577
|
space: space ?? current.space,
|
|
1381
|
-
app
|
|
2578
|
+
app,
|
|
2579
|
+
process: target.process,
|
|
2580
|
+
instance: target.instance
|
|
1382
2581
|
};
|
|
1383
2582
|
}
|
|
1384
2583
|
async function resolveOptionalSessionKey(opts) {
|
|
@@ -1394,7 +2593,7 @@ async function handleStop(selector, rawOpts) {
|
|
|
1394
2593
|
const opts = mergeSelector(selector, rawOpts);
|
|
1395
2594
|
if (opts.all === true) {
|
|
1396
2595
|
const count = await stopAllDebuggers();
|
|
1397
|
-
|
|
2596
|
+
process7.stdout.write(`Stop requested for ${count.toString()} session(s).
|
|
1398
2597
|
`);
|
|
1399
2598
|
return;
|
|
1400
2599
|
}
|
|
@@ -1404,59 +2603,83 @@ async function handleStop(selector, rawOpts) {
|
|
|
1404
2603
|
...key === void 0 ? {} : { key }
|
|
1405
2604
|
});
|
|
1406
2605
|
if (result === void 0) {
|
|
1407
|
-
|
|
1408
|
-
|
|
2606
|
+
process7.stderr.write(
|
|
2607
|
+
"No matching session found. Use `cf-debugger list` and pass --session-id or region/org/space/app if the current CF target differs.\n"
|
|
2608
|
+
);
|
|
2609
|
+
process7.exit(1);
|
|
2610
|
+
}
|
|
2611
|
+
if (result.pending) {
|
|
2612
|
+
process7.stdout.write(
|
|
2613
|
+
`Stop requested for session ${result.sessionId} (${result.app}, startup phase ${result.status}).
|
|
2614
|
+
`
|
|
2615
|
+
);
|
|
2616
|
+
return;
|
|
2617
|
+
}
|
|
2618
|
+
if (result.stale) {
|
|
2619
|
+
process7.stdout.write(
|
|
2620
|
+
`Removed stale session ${result.sessionId} (${result.app}, port ${result.localPort.toString()}).
|
|
2621
|
+
`
|
|
2622
|
+
);
|
|
2623
|
+
return;
|
|
1409
2624
|
}
|
|
1410
|
-
|
|
2625
|
+
process7.stdout.write(
|
|
1411
2626
|
`Stopped session ${result.sessionId} (${result.app}, port ${result.localPort.toString()}).
|
|
1412
2627
|
`
|
|
1413
2628
|
);
|
|
1414
2629
|
}
|
|
1415
2630
|
async function handleList() {
|
|
1416
2631
|
const sessions = await listSessions();
|
|
1417
|
-
|
|
2632
|
+
process7.stdout.write(`${JSON.stringify(sessions, null, 2)}
|
|
1418
2633
|
`);
|
|
1419
2634
|
}
|
|
1420
2635
|
async function handleStatus(selector, rawOpts) {
|
|
1421
2636
|
const opts = mergeSelector(selector, rawOpts);
|
|
1422
2637
|
const session = await getSession(await resolveSessionKey(opts));
|
|
1423
|
-
|
|
2638
|
+
process7.stdout.write(`${JSON.stringify(session ?? null, null, 2)}
|
|
1424
2639
|
`);
|
|
1425
2640
|
}
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
program.name("cf-debugger").description("Open an SSH debug tunnel to a SAP BTP Cloud Foundry app's Node.js inspector");
|
|
1429
|
-
program.command("start").description("Open a debug tunnel for one app").argument("[selector]", "Optional app selector: `<app>` or `region/org/space/app`").option("--region <key>", "CF region key (default: current cf target)").option("--org <name>", "CF org name (default: current cf target)").option("--space <name>", "CF space name (default: current cf target)").option("--app <name>", "CF app name").option("--port <number>", "Preferred local port (auto-assigned if omitted)").option("--timeout <seconds>", "Tunnel-ready timeout in seconds (default: 180)").option("--verbose", "Print status transitions", false).action(async (selector, opts) => {
|
|
2641
|
+
function registerStartCommand(program) {
|
|
2642
|
+
program.command("start").description("Open a debug tunnel for one app").argument("[selector]", "Optional app selector: `<app>` or `region/org/space/app`").option("--region <key>", "CF region key (default: current cf target)").option("--org <name>", "CF org name (default: current cf target)").option("--space <name>", "CF space name (default: current cf target)").option("--app <name>", "CF app name").option("--process <name>", "CF process name", "web").option("-i, --instance <index>", "CF process instance index", "0").option("--node-pid <pid>", "Explicit remote Node.js PID").option("--port <number>", "Preferred local port (auto-assigned if omitted)").option("--timeout <seconds>", "Tunnel-ready timeout in seconds (default: 180)").option("--verbose", "Print status transitions", false).action(async (selector, opts) => {
|
|
1430
2643
|
await handleStart(selector, opts);
|
|
1431
2644
|
});
|
|
1432
|
-
|
|
2645
|
+
}
|
|
2646
|
+
function registerStopCommand(program) {
|
|
2647
|
+
program.command("stop").description("Stop one session (by key or id) or all sessions with --all").argument("[selector]", "Optional app selector: `<app>` or `region/org/space/app`").option("--region <key>").option("--org <name>").option("--space <name>").option("--app <name>").option("--process <name>", "CF process name", "web").option("-i, --instance <index>", "CF process instance index", "0").option("--session-id <id>").option("--all", "Stop every active session", false).action(async (selector, opts) => {
|
|
1433
2648
|
await handleStop(selector, opts);
|
|
1434
2649
|
});
|
|
2650
|
+
}
|
|
2651
|
+
function registerReadCommands(program) {
|
|
1435
2652
|
program.command("list").description("Print every active debugger session as JSON").action(async () => {
|
|
1436
2653
|
await handleList();
|
|
1437
2654
|
});
|
|
1438
|
-
program.command("status").description("Print one session by key as JSON (null if not active)").argument("[selector]", "Optional app selector: `<app>` or `region/org/space/app`").option("--region <key>").option("--org <name>").option("--space <name>").option("--app <name>").action(async (selector, opts) => {
|
|
2655
|
+
program.command("status").description("Print one session by key as JSON (null if not active)").argument("[selector]", "Optional app selector: `<app>` or `region/org/space/app`").option("--region <key>").option("--org <name>").option("--space <name>").option("--app <name>").option("--process <name>", "CF process name", "web").option("-i, --instance <index>", "CF process instance index", "0").action(async (selector, opts) => {
|
|
1439
2656
|
await handleStatus(selector, opts);
|
|
1440
2657
|
});
|
|
2658
|
+
}
|
|
2659
|
+
async function main(argv) {
|
|
2660
|
+
const program = new Command().name("cf-debugger").description("Open an SSH debug tunnel to a SAP BTP Cloud Foundry app's Node.js inspector");
|
|
2661
|
+
registerStartCommand(program);
|
|
2662
|
+
registerStopCommand(program);
|
|
2663
|
+
registerReadCommands(program);
|
|
1441
2664
|
await program.parseAsync([...argv]);
|
|
1442
2665
|
}
|
|
1443
2666
|
try {
|
|
1444
|
-
await main(
|
|
2667
|
+
await main(process7.argv);
|
|
1445
2668
|
} catch (err) {
|
|
1446
2669
|
if (err instanceof CfDebuggerError) {
|
|
1447
2670
|
if (err.code === "ABORTED") {
|
|
1448
|
-
|
|
2671
|
+
process7.stderr.write(`Aborted: ${err.message}
|
|
1449
2672
|
`);
|
|
1450
|
-
|
|
2673
|
+
process7.exit(130);
|
|
1451
2674
|
}
|
|
1452
|
-
|
|
2675
|
+
process7.stderr.write(`Error [${err.code}]: ${err.message}
|
|
1453
2676
|
`);
|
|
1454
2677
|
} else {
|
|
1455
2678
|
const msg = err instanceof Error ? err.message : String(err);
|
|
1456
|
-
|
|
2679
|
+
process7.stderr.write(`Error: ${msg}
|
|
1457
2680
|
`);
|
|
1458
2681
|
}
|
|
1459
|
-
|
|
2682
|
+
process7.exit(1);
|
|
1460
2683
|
}
|
|
1461
2684
|
export {
|
|
1462
2685
|
main
|