@saptools/cf-debugger 0.1.14 → 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/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 process4 from "process";
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 = 18e4;
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
- async function runCf(args, context, timeoutMs = DEFAULT_CF_COMMAND_TIMEOUT_MS) {
122
- try {
123
- const { stdout } = await execFileAsync(resolveBin(context), [...args], {
124
- env: buildEnv(context.cfHome),
125
- maxBuffer: MAX_BUFFER,
126
- timeout: timeoutMs
127
- });
128
- return stdout;
129
- } catch (err) {
130
- const e = err;
131
- const redactionValues = sensitiveArgs(args);
132
- const stderr = redactText(e.stderr?.trim() ?? "", redactionValues);
133
- const fallbackMessage = redactText(e.message, redactionValues);
134
- throw new CfDebuggerError(
135
- "CF_CLI_FAILED",
136
- `cf ${formatArgsForError(args)} failed: ${stderr.length > 0 ? stderr : fallbackMessage}`,
137
- stderr
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", email, password], context);
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 new Promise((resolve) => {
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((field) => field !== void 0)
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
- async function cfSshOneShot(appName, command, context, timeoutMs = DEFAULT_CF_COMMAND_TIMEOUT_MS) {
285
- return await new Promise((resolve) => {
286
- const child = spawn(resolveBin(context), ["ssh", appName, "-c", command], {
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
- let stderrBuf = "";
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 isWindows = process.platform === "win32";
333
- return spawn(resolveBin(context), ["ssh", appName, "-N", "-L", tunnelArg], {
754
+ const args = buildCfSshArgs(appName, target, ["-N", "-L", tunnelArg]);
755
+ const child = spawn(resolveBin(context), [...args], {
334
756
  env: buildEnv(context.cfHome),
335
- shell: isWindows,
336
- detached: !isWindows
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";
@@ -496,177 +934,488 @@ async function isPortListening(port, timeoutMs = 200) {
496
934
  });
497
935
  });
498
936
  }
499
- async function probeTunnelReady(port, timeoutMs) {
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) {
500
957
  const pollIntervalMs = 250;
501
958
  const started = Date.now();
959
+ throwIfAborted(signal);
502
960
  while (Date.now() - started < timeoutMs) {
503
961
  const connected = await isPortListening(port);
504
962
  if (connected) {
505
963
  return true;
506
964
  }
507
- await new Promise((resolve) => {
508
- setTimeout(resolve, pollIntervalMs);
509
- });
965
+ const remainingMs = timeoutMs - (Date.now() - started);
966
+ await waitForNextProbe(Math.min(pollIntervalMs, Math.max(0, remainingMs)), signal);
510
967
  }
968
+ throwIfAborted(signal);
511
969
  return false;
512
970
  }
513
971
  async function findListeningProcessId(port) {
514
972
  const pids = await findListeningPids(port);
515
973
  return pids[0];
516
974
  }
517
- async function killProcessOnPort(port) {
518
- const portStr = port.toString();
519
- if (process.platform === "win32") {
520
- try {
521
- const pids = await findListeningPidsWithNetstat(port);
522
- for (const pid of pids) {
523
- try {
524
- await execFileAsync3("taskkill", ["/F", "/PID", pid.toString()]);
525
- } catch {
526
- }
527
- }
528
- } catch {
529
- }
530
- return;
531
- }
532
- try {
533
- const { stdout } = await execFileAsync3("lsof", ["-t", "-i", `tcp:${portStr}`]);
534
- const lines = stdout.trim().split("\n").filter((line) => line.length > 0);
535
- for (const line of lines) {
536
- const pid = Number.parseInt(line, 10);
537
- if (Number.isNaN(pid)) {
538
- continue;
539
- }
540
- try {
541
- process.kill(pid, "SIGKILL");
542
- } catch {
543
- }
544
- }
545
- } catch {
546
- }
547
- }
548
975
 
549
976
  // src/session-state/store.ts
550
- import { randomUUID } from "crypto";
551
- import { mkdir as mkdir2, readFile as readFile2, rename, writeFile } from "fs/promises";
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";
552
979
  import { hostname as getHostname } from "os";
553
- import { dirname as dirname2 } from "path";
554
- import process2 from "process";
980
+ import { dirname as dirname2, isAbsolute as isAbsolute3 } from "path";
981
+ import process3 from "process";
555
982
 
556
983
  // src/lock.ts
557
- import { mkdir, open, unlink } from "fs/promises";
984
+ import { randomUUID } from "crypto";
985
+ import { chmod, mkdir, open, readFile as readFile2, stat, unlink } from "fs/promises";
986
+ import { hostname } from "os";
558
987
  import { dirname } from "path";
988
+ import process2 from "process";
559
989
  var DEFAULT_POLL_MS = 50;
990
+ var DEFAULT_STALE_MS = 6e4;
560
991
  var DEFAULT_TIMEOUT_MS = 1e4;
561
992
  function sleep(ms) {
562
993
  return new Promise((resolve) => {
563
994
  setTimeout(resolve, ms);
564
995
  });
565
996
  }
566
- async function acquireFileLock(lockPath, timeoutMs, pollMs) {
567
- const deadline = Date.now() + timeoutMs;
568
- await mkdir(dirname(lockPath), { recursive: true });
569
- for (; ; ) {
570
- try {
571
- return await open(lockPath, "wx");
572
- } catch (err) {
573
- const code = err.code;
574
- if (code !== "EEXIST") {
575
- throw err;
576
- }
577
- }
578
- if (Date.now() > deadline) {
579
- throw new Error(`Timed out acquiring file lock at ${lockPath}`);
580
- }
581
- await sleep(pollMs);
997
+ function errorCode(error) {
998
+ if (typeof error !== "object" || error === null) {
999
+ return void 0;
582
1000
  }
1001
+ const code = Reflect.get(error, "code");
1002
+ return typeof code === "string" ? code : void 0;
583
1003
  }
584
- async function releaseFileLock(lockPath, handle) {
585
- await handle.close();
586
- await unlink(lockPath).catch((err) => {
587
- const code = err.code;
588
- if (code !== "ENOENT") {
589
- throw err;
590
- }
591
- });
1004
+ function field(value, key) {
1005
+ return Reflect.get(value, key);
592
1006
  }
593
- async function withFileLock(lockPath, work, options) {
594
- const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
595
- const pollMs = options?.pollMs ?? DEFAULT_POLL_MS;
596
- const handle = await acquireFileLock(lockPath, timeoutMs, pollMs);
1007
+ function parseLockOwner(raw) {
1008
+ let value;
597
1009
  try {
598
- return await work();
599
- } finally {
600
- await releaseFileLock(lockPath, handle);
1010
+ value = JSON.parse(raw);
1011
+ } catch {
1012
+ return void 0;
1013
+ }
1014
+ if (typeof value !== "object" || value === null) {
1015
+ return void 0;
1016
+ }
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;
601
1026
  }
1027
+ return { hostname: lockHostname, pid, token, version };
602
1028
  }
603
-
604
- // src/session-state/store.ts
605
- async function readJsonFile(path) {
606
- let raw;
1029
+ function isProcessAlive(pid) {
607
1030
  try {
608
- raw = await readFile2(path, "utf8");
609
- } catch (err) {
610
- const code = err.code;
611
- if (code === "ENOENT") {
1031
+ process2.kill(pid, 0);
1032
+ return true;
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") {
612
1043
  return void 0;
613
1044
  }
614
- throw err;
1045
+ throw error;
615
1046
  }
1047
+ }
1048
+ async function isStaleLock(lockPath, staleMs) {
1049
+ let modifiedAt;
616
1050
  try {
617
- return JSON.parse(raw);
618
- } catch {
619
- process2.stderr.write(
620
- `[cf-debugger] warning: state file at ${path} is not valid JSON; resetting to empty.
621
- `
622
- );
623
- return void 0;
1051
+ modifiedAt = (await stat(lockPath)).mtimeMs;
1052
+ } catch (error) {
1053
+ if (errorCode(error) === "ENOENT") {
1054
+ return true;
1055
+ }
1056
+ throw error;
624
1057
  }
1058
+ const owner = await readLockOwner(lockPath);
1059
+ if (owner?.hostname === hostname()) {
1060
+ return !isProcessAlive(owner.pid);
1061
+ }
1062
+ return owner === void 0 && Date.now() - modifiedAt > staleMs;
625
1063
  }
626
- async function writeJsonFileAtomic(path, value) {
627
- const tempPath = `${path}.${randomUUID()}.tmp`;
628
- await mkdir2(dirname2(path), { recursive: true });
629
- await writeFile(tempPath, `${JSON.stringify(value, null, 2)}
630
- `, "utf8");
631
- await rename(tempPath, path);
632
- }
633
- function emptyState() {
634
- return { version: "1", sessions: [] };
635
- }
636
- function isValidState(value) {
637
- if (typeof value !== "object" || value === null) {
638
- return false;
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;
639
1072
  }
640
- const candidate = value;
641
- return candidate.version === "1" && Array.isArray(candidate.sessions);
1073
+ await unlink(recoveryPath).catch((error) => {
1074
+ if (errorCode(error) !== "ENOENT") {
1075
+ throw error;
1076
+ }
1077
+ });
642
1078
  }
643
- function isPidAlive(pid) {
1079
+ async function reclaimStaleLock(lockPath, staleMs) {
1080
+ const recoveryPath = `${lockPath}.recovery`;
1081
+ let recoveryLock;
644
1082
  try {
645
- process2.kill(pid, 0);
646
- return true;
647
- } catch (err) {
648
- const code = err.code;
649
- if (code === "ESRCH") {
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)) {
650
1093
  return false;
651
1094
  }
1095
+ try {
1096
+ await unlink(lockPath);
1097
+ } catch (error) {
1098
+ if (errorCode(error) !== "ENOENT") {
1099
+ throw error;
1100
+ }
1101
+ }
652
1102
  return true;
1103
+ } finally {
1104
+ await releaseFileLock(recoveryPath, recoveryLock);
653
1105
  }
654
1106
  }
655
- async function isSessionHealthy(session, host) {
656
- if (session.hostname !== host) {
657
- return true;
1107
+ async function removeOwnedLock(lockPath, token) {
1108
+ const owner = await readLockOwner(lockPath);
1109
+ if (owner?.token !== token) {
1110
+ return;
658
1111
  }
659
- if (!isPidAlive(session.pid)) {
660
- return false;
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
+ }
661
1383
  }
662
- if (session.status !== "ready") {
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)) {
663
1401
  return true;
664
1402
  }
665
- if (!await isPortListening(session.localPort)) {
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) {
666
1410
  return false;
667
1411
  }
668
- const listeningPid = await findListeningProcessId(session.localPort);
669
- return listeningPid === void 0 || listeningPid === session.pid;
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);
670
1419
  }
671
1420
  async function filterStaleSessions(sessions) {
672
1421
  const host = getHostname();
@@ -679,23 +1428,35 @@ async function filterStaleSessions(sessions) {
679
1428
  return checks.filter(([, healthy]) => healthy).map(([session]) => session);
680
1429
  }
681
1430
  async function readStateRaw() {
682
- const parsed = await readJsonFile(stateFilePath());
683
- if (!isValidState(parsed)) {
1431
+ const path = stateFilePath();
1432
+ const parsed = await readJsonFile(path);
1433
+ if (parsed === void 0) {
684
1434
  return emptyState();
685
1435
  }
686
- return parsed;
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();
687
1445
  }
688
1446
  async function writeState(state) {
689
1447
  await writeJsonFileAtomic(stateFilePath(), state);
690
1448
  }
691
1449
  async function readAndPruneLocked() {
692
1450
  const raw = await readStateRaw();
693
- const pruned = await filterStaleSessions(raw.sessions);
694
- const removed = raw.sessions.filter(
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(
695
1456
  (session) => !pruned.some((active) => active.sessionId === session.sessionId)
696
1457
  );
697
1458
  if (removed.length > 0) {
698
- await writeState({ version: "1", sessions: pruned });
1459
+ await writeState({ version: "2", sessions: [...remote, ...pruned] });
699
1460
  }
700
1461
  return { sessions: pruned, removed };
701
1462
  }
@@ -703,14 +1464,38 @@ async function readActiveSessions() {
703
1464
  const result = await withFileLock(stateLockPath(), readAndPruneLocked);
704
1465
  return result.sessions;
705
1466
  }
1467
+ async function readSessionSnapshot() {
1468
+ return await withFileLock(stateLockPath(), async () => {
1469
+ const raw = await readStateRaw();
1470
+ return raw.sessions;
1471
+ });
1472
+ }
706
1473
  async function readAndPruneActiveSessions() {
707
1474
  return await withFileLock(stateLockPath(), readAndPruneLocked);
708
1475
  }
709
1476
  function sessionKeyString(key) {
710
- return `${key.region}:${key.org}:${key.space}:${key.app}`;
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()}`;
711
1483
  }
712
1484
  function matchesKey(session, key) {
713
- return session.region === key.region && session.org === key.org && session.space === key.space && session.app === key.app;
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
+ });
714
1499
  }
715
1500
  var DEFAULT_BASE_PORT = 2e4;
716
1501
  var DEFAULT_MAX_PORT = 20999;
@@ -739,9 +1524,13 @@ async function pickPort(preferred, reserved, probe, basePort, maxPort) {
739
1524
  );
740
1525
  }
741
1526
  async function registerNewSession(input) {
1527
+ const target = resolveNodeTarget(input);
742
1528
  return await withFileLock(stateLockPath(), async () => {
743
1529
  const pruneResult = await readAndPruneLocked();
744
- const existing = pruneResult.sessions.find((session2) => matchesKey(session2, input));
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));
745
1534
  if (existing) {
746
1535
  return { session: existing, existing };
747
1536
  }
@@ -753,28 +1542,41 @@ async function registerNewSession(input) {
753
1542
  input.basePort ?? DEFAULT_BASE_PORT,
754
1543
  input.maxPort ?? DEFAULT_MAX_PORT
755
1544
  );
756
- const sessionId = (input.sessionIdFactory ?? randomUUID)();
757
- const cfHomeDir = input.cfHomeForSession(sessionId);
758
- const session = {
759
- sessionId,
760
- pid: process2.pid,
761
- hostname: getHostname(),
762
- region: input.region,
763
- org: input.org,
764
- space: input.space,
765
- app: input.app,
766
- apiEndpoint: input.apiEndpoint,
767
- localPort,
768
- remotePort: 9229,
769
- cfHomeDir,
770
- startedAt: (/* @__PURE__ */ new Date()).toISOString(),
771
- status: "starting"
772
- };
773
- const nextSessions = [...pruneResult.sessions, session];
774
- await writeState({ version: "1", sessions: nextSessions });
1545
+ const session = createRegisteredSession(input, target, localPort);
1546
+ const nextSessions = [...remoteSessions, ...pruneResult.sessions, session];
1547
+ await writeState({ version: "2", sessions: nextSessions });
775
1548
  return { session };
776
1549
  });
777
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
+ }
778
1580
  async function updateSessionStatus(sessionId, status, message) {
779
1581
  return await withFileLock(stateLockPath(), async () => {
780
1582
  const raw = await readStateRaw();
@@ -783,32 +1585,31 @@ async function updateSessionStatus(sessionId, status, message) {
783
1585
  if (session.sessionId !== sessionId) {
784
1586
  return session;
785
1587
  }
786
- const base = {
787
- sessionId: session.sessionId,
788
- pid: session.pid,
789
- hostname: session.hostname,
790
- region: session.region,
791
- org: session.org,
792
- space: session.space,
793
- app: session.app,
794
- apiEndpoint: session.apiEndpoint,
795
- localPort: session.localPort,
796
- remotePort: session.remotePort,
797
- cfHomeDir: session.cfHomeDir,
798
- startedAt: session.startedAt,
799
- status
800
- };
801
- 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 };
802
1600
  updated = next;
803
1601
  return next;
804
1602
  });
805
1603
  if (updated) {
806
- await writeState({ version: "1", sessions: nextSessions });
1604
+ await writeState({ version: "2", sessions: nextSessions });
807
1605
  }
808
1606
  return updated;
809
1607
  });
810
1608
  }
811
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
+ }
812
1613
  return await withFileLock(stateLockPath(), async () => {
813
1614
  const raw = await readStateRaw();
814
1615
  let updated;
@@ -816,27 +1617,47 @@ async function updateSessionPid(sessionId, pid) {
816
1617
  if (session.sessionId !== sessionId) {
817
1618
  return session;
818
1619
  }
819
- const next = {
820
- sessionId: session.sessionId,
821
- pid,
822
- hostname: session.hostname,
823
- region: session.region,
824
- org: session.org,
825
- space: session.space,
826
- app: session.app,
827
- apiEndpoint: session.apiEndpoint,
828
- localPort: session.localPort,
829
- remotePort: session.remotePort,
830
- cfHomeDir: session.cfHomeDir,
831
- startedAt: session.startedAt,
832
- status: session.status,
833
- ...session.message === void 0 ? {} : { message: session.message }
834
- };
1620
+ if (startupMutationBlocked(session)) {
1621
+ updated = session;
1622
+ return session;
1623
+ }
1624
+ const next = { ...session, pid, tunnelPid: pid };
835
1625
  updated = next;
836
1626
  return next;
837
1627
  });
838
1628
  if (updated !== void 0) {
839
- await writeState({ version: "1", sessions: nextSessions });
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 });
1644
+ return await withFileLock(stateLockPath(), async () => {
1645
+ const raw = await readStateRaw();
1646
+ let updated;
1647
+ const nextSessions = raw.sessions.map((session) => {
1648
+ if (session.sessionId !== sessionId) {
1649
+ return session;
1650
+ }
1651
+ if (startupMutationBlocked(session)) {
1652
+ updated = session;
1653
+ return session;
1654
+ }
1655
+ const next = { ...session, remoteNodePid };
1656
+ updated = next;
1657
+ return next;
1658
+ });
1659
+ if (updated !== void 0) {
1660
+ await writeState({ version: "2", sessions: nextSessions });
840
1661
  }
841
1662
  return updated;
842
1663
  });
@@ -849,72 +1670,145 @@ async function removeSession(sessionId) {
849
1670
  return void 0;
850
1671
  }
851
1672
  const remaining = raw.sessions.filter((session) => session.sessionId !== sessionId);
852
- await writeState({ version: "1", sessions: remaining });
1673
+ await writeState({ version: "2", sessions: remaining });
853
1674
  return target;
854
1675
  });
855
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
+ }
856
1695
 
857
1696
  // src/debug-session/constants.ts
858
1697
  var DEFAULT_TUNNEL_READY_TIMEOUT_MS = 18e4;
859
1698
  var POST_USR1_DELAY_MS = 300;
860
- var PORT_CLEANUP_DELAY_MS = 600;
861
1699
  var CHILD_SIGTERM_GRACE_MS = 2e3;
862
- var PORT_RECLAIM_DELAY_MS = 250;
1700
+ var CHILD_SIGKILL_GRACE_MS = 1e3;
863
1701
  var PID_TERMINATION_POLL_MS = 100;
864
1702
 
865
1703
  // src/debug-session/orphans.ts
1704
+ import { rm } from "fs/promises";
866
1705
  import { hostname as getHostname2 } from "os";
867
1706
  async function pruneAndCleanupOrphans() {
868
1707
  const result = await readAndPruneActiveSessions();
869
1708
  const host = getHostname2();
870
1709
  for (const removed of result.removed) {
871
- if (removed.hostname === host) {
872
- void killProcessOnPort(removed.localPort);
1710
+ if (removed.hostname === host && isOwnedSessionCfHomeDir(removed.sessionId, removed.cfHomeDir)) {
1711
+ try {
1712
+ await rm(removed.cfHomeDir, { recursive: true, force: true });
1713
+ } catch {
1714
+ }
873
1715
  }
874
1716
  }
875
1717
  return result;
876
1718
  }
877
1719
 
878
1720
  // src/debug-session/processes.ts
879
- import process3 from "process";
880
- function signalPidOrGroup(pid, signal) {
881
- const isWindows = process3.platform === "win32";
882
- if (!isWindows) {
883
- try {
884
- process3.kill(-pid, signal);
885
- return;
886
- } catch {
887
- }
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";
888
1727
  }
889
1728
  try {
890
- process3.kill(pid, signal);
1729
+ process4.kill(targetKind === "group" ? -pid : pid, "SIGTERM");
891
1730
  } catch {
892
1731
  }
893
- }
894
- async function terminatePidOrGroup(pid, timeoutMs = CHILD_SIGTERM_GRACE_MS) {
895
- if (!isPidAlive(pid)) {
896
- return;
897
- }
898
- signalPidOrGroup(pid, "SIGTERM");
899
1732
  const startedAt = Date.now();
900
1733
  while (Date.now() - startedAt < timeoutMs) {
901
- if (!isPidAlive(pid)) {
902
- return;
1734
+ if (!targetAlive()) {
1735
+ return "terminated";
903
1736
  }
904
1737
  await new Promise((resolve) => {
905
1738
  setTimeout(resolve, PID_TERMINATION_POLL_MS);
906
1739
  });
907
1740
  }
908
- signalPidOrGroup(pid, "SIGKILL");
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";
1749
+ }
1750
+ await new Promise((resolve) => {
1751
+ setTimeout(resolve, PID_TERMINATION_POLL_MS);
1752
+ });
1753
+ }
1754
+ return targetAlive() ? "still-alive" : "terminated";
909
1755
  }
910
1756
  async function killProcessGroupOrProc(child, timeoutMs = CHILD_SIGTERM_GRACE_MS) {
911
1757
  if (child.pid === void 0) {
912
- return;
1758
+ return "terminated";
913
1759
  }
914
- if (child.exitCode !== null || child.signalCode !== null) {
915
- return;
1760
+ const childClosed = child.exitCode !== null || child.signalCode !== null;
1761
+ if (childClosed && process4.platform === "win32") {
1762
+ return "terminated";
916
1763
  }
917
- 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
+ };
918
1812
  }
919
1813
 
920
1814
  // src/debug-session/start.ts
@@ -936,9 +1830,30 @@ function checkAbort(signal) {
936
1830
  throw new CfDebuggerError("ABORTED", "Operation aborted by caller");
937
1831
  }
938
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
+ }
939
1854
  function requireCredentials(options) {
940
- const email = options.email ?? process4.env["SAP_EMAIL"];
941
- const password = options.password ?? process4.env["SAP_PASSWORD"];
1855
+ const email = options.email ?? process5.env["SAP_EMAIL"];
1856
+ const password = options.password ?? process5.env["SAP_PASSWORD"];
942
1857
  if (email === void 0 || email === "") {
943
1858
  throw new CfDebuggerError(
944
1859
  "MISSING_CREDENTIALS",
@@ -953,12 +1868,15 @@ function requireCredentials(options) {
953
1868
  }
954
1869
  return { email, password };
955
1870
  }
956
- async function registerSession(options, apiEndpoint) {
1871
+ async function registerSession(options, target, apiEndpoint) {
957
1872
  const registration = await registerNewSession({
958
1873
  region: options.region,
959
1874
  org: options.org,
960
1875
  space: options.space,
961
1876
  app: options.app,
1877
+ process: target.process,
1878
+ instance: target.instance,
1879
+ ...target.nodePid === void 0 ? {} : { nodePid: target.nodePid },
962
1880
  apiEndpoint,
963
1881
  ...options.preferredPort === void 0 ? {} : { preferredPort: options.preferredPort },
964
1882
  portProbe: isPortFree,
@@ -974,50 +1892,55 @@ async function registerSession(options, apiEndpoint) {
974
1892
  }
975
1893
  async function loginAndTarget(options, apiEndpoint, email, password, context, sessionId, emit) {
976
1894
  emit("logging-in");
977
- await updateSessionStatus(sessionId, "logging-in");
1895
+ await transitionStartupStatus(sessionId, "logging-in");
978
1896
  await cfLogin(apiEndpoint, email, password, context);
979
- checkAbort(options.signal);
1897
+ checkAbort(context.signal);
980
1898
  emit("targeting");
981
- await updateSessionStatus(sessionId, "targeting");
1899
+ await transitionStartupStatus(sessionId, "targeting");
982
1900
  await cfTarget(options.org, options.space, context);
983
- checkAbort(options.signal);
1901
+ checkAbort(context.signal);
984
1902
  }
985
- async function signalRemoteNode(options, context, sessionId, emit) {
1903
+ async function signalRemoteNode(options, target, context, sessionId, emit) {
986
1904
  emit("signaling");
987
- await updateSessionStatus(sessionId, "signaling");
988
- const signalResult = await cfSshOneShot(options.app, `kill -s USR1 $(pidof node)`, context);
1905
+ await transitionStartupStatus(sessionId, "signaling");
1906
+ const signalResult = await executeRemoteSignal(options.app, target, context);
989
1907
  if (!isSshDisabledError(signalResult.stderr)) {
990
- if (signalResult.exitCode === 0) {
991
- return;
992
- }
1908
+ return parseSignalResult(options.app, signalResult);
1909
+ }
1910
+ if (options.allowSshEnableRestart === false) {
993
1911
  throw new CfDebuggerError(
994
- "USR1_SIGNAL_FAILED",
995
- `Failed to send SIGUSR1 to the Node.js process on ${options.app}: ${signalFailureDetail(signalResult)}`,
1912
+ "SSH_NOT_ENABLED",
1913
+ `SSH is disabled for ${options.app}; automatic SSH enable and app restart are not allowed.`,
996
1914
  signalResult.stderr
997
1915
  );
998
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
+ }
999
1927
  const alreadyEnabled = await cfSshEnabled(options.app, context);
1000
1928
  if (!alreadyEnabled) {
1001
1929
  emit("ssh-enabling", "Enabling SSH on the app");
1002
- await updateSessionStatus(sessionId, "ssh-enabling");
1930
+ await transitionStartupStatus(sessionId, "ssh-enabling");
1003
1931
  await cfEnableSsh(options.app, context);
1004
1932
  }
1005
1933
  emit("ssh-restarting", "Restarting app so SSH becomes active");
1006
- await updateSessionStatus(sessionId, "ssh-restarting");
1934
+ await transitionStartupStatus(sessionId, "ssh-restarting");
1007
1935
  await cfRestartApp(options.app, context);
1008
- checkAbort(options.signal);
1009
- await retryRemoteSignal(options, context, sessionId, emit);
1936
+ checkAbort(context.signal);
1010
1937
  }
1011
- async function retryRemoteSignal(options, context, sessionId, emit) {
1938
+ async function retryRemoteSignal(options, target, context, sessionId, emit) {
1012
1939
  emit("signaling");
1013
- await updateSessionStatus(sessionId, "signaling");
1014
- const retrySignalResult = await cfSshOneShot(
1015
- options.app,
1016
- `kill -s USR1 $(pidof node)`,
1017
- context
1018
- );
1940
+ await transitionStartupStatus(sessionId, "signaling");
1941
+ const retrySignalResult = await executeRemoteSignal(options.app, target, context);
1019
1942
  if (retrySignalResult.exitCode === 0) {
1020
- return;
1943
+ return parseSignalResult(options.app, retrySignalResult);
1021
1944
  }
1022
1945
  throw new CfDebuggerError(
1023
1946
  "USR1_SIGNAL_FAILED",
@@ -1025,36 +1948,77 @@ async function retryRemoteSignal(options, context, sessionId, emit) {
1025
1948
  retrySignalResult.stderr
1026
1949
  );
1027
1950
  }
1028
- async function waitAfterSignal(signal) {
1029
- await new Promise((resolve) => {
1030
- setTimeout(resolve, POST_USR1_DELAY_MS);
1951
+ async function executeRemoteSignal(appName, target, context) {
1952
+ return await cfSshOneShot(appName, buildNodeInspectorCommand(target.nodePid), context, {
1953
+ process: target.process,
1954
+ instance: target.instance
1031
1955
  });
1032
- checkAbort(signal);
1033
1956
  }
1034
- async function ensurePortAvailable(localPort) {
1035
- if (await isPortFree(localPort)) {
1036
- return;
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
+ );
1037
1964
  }
1038
- await killProcessOnPort(localPort);
1039
- await new Promise((resolve) => {
1040
- setTimeout(resolve, PORT_RECLAIM_DELAY_MS);
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");
1976
+ }
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 });
1041
1987
  });
1988
+ }
1989
+ async function ensurePortAvailable(localPort) {
1042
1990
  if (!await isPortFree(localPort)) {
1043
1991
  throw new CfDebuggerError(
1044
1992
  "PORT_UNAVAILABLE",
1045
- `Local port ${localPort.toString()} is in use and could not be reclaimed for the tunnel.`
1993
+ `Local port ${localPort.toString()} was taken before the tunnel could start.`
1046
1994
  );
1047
1995
  }
1048
1996
  }
1049
- async function openReadyTunnel(options, session, context, tunnelReadyTimeoutMs, onChild) {
1997
+ async function openReadyTunnel(options, target, session, context, tunnelReadyTimeoutMs, onChild) {
1050
1998
  await ensurePortAvailable(session.localPort);
1051
- const child = spawnSshTunnel(options.app, session.localPort, session.remotePort, context);
1999
+ checkAbort(context.signal);
2000
+ const child = spawnSshTunnel(options.app, session.localPort, session.remotePort, context, {
2001
+ process: target.process,
2002
+ instance: target.instance
2003
+ });
1052
2004
  onChild(child);
1053
- if (child.pid !== void 0) {
1054
- await updateSessionPid(session.sessionId, child.pid);
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.");
1055
2008
  }
1056
- const ready = await probeTunnelReady(session.localPort, tunnelReadyTimeoutMs);
1057
- checkAbort(options.signal);
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);
1058
2022
  if (!ready) {
1059
2023
  throw new CfDebuggerError(
1060
2024
  "TUNNEL_NOT_READY",
@@ -1062,15 +2026,21 @@ async function openReadyTunnel(options, session, context, tunnelReadyTimeoutMs,
1062
2026
  );
1063
2027
  }
1064
2028
  const listeningPid = await findListeningProcessId(session.localPort);
1065
- const activePid = listeningPid ?? child.pid ?? session.pid;
1066
- if (activePid !== session.pid) {
1067
- await updateSessionPid(session.sessionId, activePid);
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
+ );
1068
2040
  }
1069
- return { child, activePid };
1070
2041
  }
1071
- function attachTunnelEvents(child, markClosed, resolveExit, emit) {
2042
+ function attachTunnelEvents(child, resolveExit, emit) {
1072
2043
  child.on("close", (code) => {
1073
- markClosed();
1074
2044
  resolveExit(code);
1075
2045
  });
1076
2046
  child.on("error", (err) => {
@@ -1082,139 +2052,291 @@ function createHandle(session, emit, finalize, exitPromise) {
1082
2052
  return {
1083
2053
  session,
1084
2054
  dispose: async () => {
1085
- disposePromise ??= (async () => {
1086
- emit("stopping");
1087
- await updateSessionStatus(session.sessionId, "stopping");
1088
- await finalize();
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");
1089
2065
  })();
1090
- await disposePromise;
2066
+ disposePromise = attempt;
2067
+ try {
2068
+ await attempt;
2069
+ } catch (error) {
2070
+ if (disposePromise === attempt) {
2071
+ disposePromise = void 0;
2072
+ }
2073
+ throw error;
2074
+ }
1091
2075
  },
1092
2076
  waitForExit: async () => {
1093
2077
  return await exitPromise;
1094
2078
  }
1095
2079
  };
1096
2080
  }
1097
- async function startDebugger(options) {
1098
- const { email, password } = requireCredentials(options);
1099
- const apiEndpoint = resolveApiEndpoint(options.region, options.apiEndpoint);
1100
- const tunnelReadyTimeoutMs = options.tunnelReadyTimeoutMs ?? DEFAULT_TUNNEL_READY_TIMEOUT_MS;
1101
- const emit = (status, message) => {
1102
- options.onStatus?.(status, message);
1103
- };
1104
- checkAbort(options.signal);
1105
- await pruneAndCleanupOrphans();
1106
- const session = await registerSession(options, apiEndpoint);
1107
- const context = { cfHome: session.cfHomeDir };
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) {
1108
2102
  let child;
1109
- let tunnelClosed = false;
1110
2103
  let exitResolve = (_code) => {
1111
2104
  throw new Error("Exit resolver was used before initialization");
1112
2105
  };
1113
2106
  const exitPromise = new Promise((resolve) => {
1114
2107
  exitResolve = resolve;
1115
2108
  });
2109
+ const observeChild = (tunnelChild) => {
2110
+ child = tunnelChild;
2111
+ attachTunnelEvents(tunnelChild, exitResolve, emit);
2112
+ };
1116
2113
  const finalize = async () => {
1117
- if (!tunnelClosed) {
1118
- tunnelClosed = true;
1119
- if (child) {
1120
- await killProcessGroupOrProc(child);
1121
- }
1122
- setTimeout(() => {
1123
- void killProcessOnPort(session.localPort);
1124
- }, 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
+ );
1125
2121
  }
1126
- await removeSession(session.sessionId);
1127
- await cleanupFilesystem(session.cfHomeDir);
2122
+ await runCleanupActions([
2123
+ async () => {
2124
+ await removeSession(session.sessionId);
2125
+ },
2126
+ async () => {
2127
+ await cleanupFilesystem(session.cfHomeDir);
2128
+ }
2129
+ ], "Debugger resource cleanup failed");
1128
2130
  emit("stopped");
1129
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) {
1130
2172
  try {
1131
- await mkdir3(session.cfHomeDir, { recursive: true });
1132
- await loginAndTarget(options, apiEndpoint, email, password, context, session.sessionId, emit);
1133
- await killProcessOnPort(session.localPort);
1134
- await new Promise((resolve) => {
1135
- setTimeout(resolve, 200);
1136
- });
1137
- await signalRemoteNode(options, context, session.sessionId, emit);
1138
- await waitAfterSignal(options.signal);
1139
- emit("tunneling");
1140
- await updateSessionStatus(session.sessionId, "tunneling");
1141
- const tunnel = await openReadyTunnel(
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({
1142
2207
  options,
2208
+ target,
1143
2209
  session,
1144
2210
  context,
1145
- tunnelReadyTimeoutMs,
1146
- (tunnelChild) => {
1147
- child = tunnelChild;
1148
- attachTunnelEvents(tunnelChild, () => {
1149
- tunnelClosed = true;
1150
- }, exitResolve, emit);
1151
- }
1152
- );
1153
- child = tunnel.child;
1154
- emit("ready");
1155
- const readySession = await updateSessionStatus(session.sessionId, "ready");
1156
- const activeSession = readySession ?? { ...session, pid: tunnel.activePid, status: "ready" };
1157
- return createHandle(activeSession, emit, finalize, exitPromise);
2211
+ credentials,
2212
+ timeoutMs: tunnelReadyTimeoutMs,
2213
+ lifecycle,
2214
+ emit
2215
+ });
2216
+ cancellation.dispose();
2217
+ return createHandle(activeSession, emit, lifecycle.finalize, lifecycle.exitPromise);
1158
2218
  } catch (err) {
1159
- const message = err instanceof Error ? err.message : String(err);
1160
- emit("error", message);
1161
- await finalize();
1162
- throw err;
2219
+ cancellation.dispose();
2220
+ return await failAfterStartupCleanup(err, lifecycle.finalize, emit);
1163
2221
  }
1164
2222
  }
1165
2223
  async function cleanupFilesystem(cfHomeDir) {
1166
- try {
1167
- await rm(cfHomeDir, { recursive: true, force: true });
1168
- } catch {
1169
- }
2224
+ await rm2(cfHomeDir, { recursive: true, force: true });
1170
2225
  }
1171
2226
 
1172
2227
  // src/debug-session/sessions.ts
1173
- import { rm as rm2 } from "fs/promises";
1174
- import process5 from "process";
2228
+ import { rm as rm3 } from "fs/promises";
2229
+ import { hostname as getHostname3 } from "os";
2230
+ import process6 from "process";
1175
2231
  function findMatchingSession(sessions, options) {
1176
2232
  if (options.sessionId !== void 0) {
1177
2233
  return sessions.find((s) => s.sessionId === options.sessionId);
1178
2234
  }
1179
2235
  if (options.key !== void 0) {
1180
2236
  const key = options.key;
1181
- return sessions.find((s) => matchesKey(s, key));
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];
1182
2245
  }
1183
2246
  return void 0;
1184
2247
  }
1185
- async function cleanupSession(target, stale) {
1186
- if (!stale && target.pid !== process5.pid) {
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) {
1187
2256
  try {
1188
- await terminatePidOrGroup(target.pid);
2257
+ return await terminatePidOrGroup(target.tunnelPid);
1189
2258
  } catch {
2259
+ return "still-alive";
1190
2260
  }
1191
2261
  }
1192
- setTimeout(() => {
1193
- void killProcessOnPort(target.localPort);
1194
- }, PORT_CLEANUP_DELAY_MS);
1195
- const removed = stale ? void 0 : await removeSession(target.sessionId);
1196
- try {
1197
- await rm2(target.cfHomeDir, { recursive: true, force: true });
1198
- } catch {
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
+ );
1199
2271
  }
1200
- return { ...removed ?? target, stale };
2272
+ }
2273
+ async function cleanupOwnedCfHome(target, locallyOwned) {
2274
+ if (locallyOwned && isOwnedSessionCfHomeDir(target.sessionId, target.cfHomeDir)) {
2275
+ try {
2276
+ await rm3(target.cfHomeDir, { recursive: true, force: true });
2277
+ } catch {
2278
+ }
2279
+ }
2280
+ }
2281
+ async function removeOwnedSession(target, stale) {
2282
+ const removed = await removeSession(target.sessionId);
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);
2306
+ }
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);
1201
2319
  }
1202
2320
  async function stopDebugger(options) {
1203
- const pruneResult = await pruneAndCleanupOrphans();
1204
- const activeTarget = findMatchingSession(pruneResult.sessions, options);
1205
- if (activeTarget !== void 0) {
1206
- return await cleanupSession(activeTarget, false);
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;
1207
2327
  }
1208
- const staleTarget = findMatchingSession(pruneResult.removed, options);
1209
- if (staleTarget !== void 0) {
1210
- return await cleanupSession(staleTarget, true);
2328
+ const claim = await requestSessionStop(target.sessionId);
2329
+ if (claim === void 0) {
2330
+ return void 0;
1211
2331
  }
1212
- return void 0;
2332
+ return claim.previousStatus === "ready" ? await stopReadySession(claim.session) : await stopStartingSession(claim.session);
1213
2333
  }
1214
2334
  async function stopAllDebuggers() {
1215
- const pruneResult = await pruneAndCleanupOrphans();
1216
- let stopped = pruneResult.removed.length;
1217
- for (const session of pruneResult.sessions) {
2335
+ const sessions = (await readSessionSnapshot()).filter(
2336
+ (session) => session.hostname === getHostname3()
2337
+ );
2338
+ let stopped = 0;
2339
+ for (const session of sessions) {
1218
2340
  const result = await stopDebugger({ sessionId: session.sessionId });
1219
2341
  if (result) {
1220
2342
  stopped += 1;
@@ -1227,17 +2349,20 @@ async function listSessions() {
1227
2349
  }
1228
2350
  async function getSession(key) {
1229
2351
  const sessions = await readActiveSessions();
1230
- return sessions.find((s) => matchesKey(s, key));
2352
+ return findMatchingSession(sessions, { key });
1231
2353
  }
1232
2354
  export {
1233
2355
  CfDebuggerError,
2356
+ buildNodeInspectorCommand,
1234
2357
  getSession,
1235
2358
  listKnownRegionKeys,
1236
2359
  listSessions,
1237
2360
  parseCurrentCfTarget,
2361
+ parseNodeInspectorMarkers,
1238
2362
  readCurrentCfTarget,
1239
2363
  requireCurrentCfRegion,
1240
2364
  resolveApiEndpoint,
2365
+ resolveNodeTarget,
1241
2366
  sessionKeyString,
1242
2367
  startDebugger,
1243
2368
  stopAllDebuggers,