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