@saptools/cf-debugger 0.2.1 → 0.2.2

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.
@@ -12,6 +12,32 @@ var CfDebuggerError = class extends Error {
12
12
  }
13
13
  };
14
14
 
15
+ // src/input-validation.ts
16
+ function hasControlCharacter(value) {
17
+ for (let index = 0; index < value.length; index += 1) {
18
+ const code = value.charCodeAt(index);
19
+ if (code < 32 || code === 127) {
20
+ return true;
21
+ }
22
+ }
23
+ return false;
24
+ }
25
+ function validateCfCliOperand(value, label) {
26
+ if (typeof value !== "string" || value.length === 0) {
27
+ throw new CfDebuggerError("UNSAFE_INPUT", `${label} must be a non-empty string.`);
28
+ }
29
+ if (value.trim() !== value) {
30
+ throw new CfDebuggerError("UNSAFE_INPUT", `${label} must not contain surrounding whitespace.`);
31
+ }
32
+ if (value.startsWith("-")) {
33
+ throw new CfDebuggerError("UNSAFE_INPUT", `${label} must not start with a hyphen.`);
34
+ }
35
+ if (hasControlCharacter(value)) {
36
+ throw new CfDebuggerError("UNSAFE_INPUT", `${label} must not contain control characters.`);
37
+ }
38
+ return value;
39
+ }
40
+
15
41
  // src/regions.ts
16
42
  var REGION_KEY_PATTERN = /^[a-z]{2}\d{2}(?:-\d{3})?$/;
17
43
  var REGION_API_ENDPOINTS = {
@@ -80,9 +106,61 @@ function synthesizeApiEndpoint(regionKey) {
80
106
  const domain = regionKey.startsWith("cn") ? "platform.sapcloud.cn" : "hana.ondemand.com";
81
107
  return `https://api.cf.${regionKey}.${domain}`;
82
108
  }
109
+ function rejectApiEndpoint(raw, reason) {
110
+ return new CfDebuggerError(
111
+ "UNSAFE_INPUT",
112
+ `Invalid --api-endpoint ${JSON.stringify(raw)}: ${reason}. Expected an absolute https URL such as https://api.cf.<region>.hana.ondemand.com.`
113
+ );
114
+ }
115
+ function validateApiEndpointOverride(raw) {
116
+ if (typeof raw !== "string") {
117
+ throw new CfDebuggerError(
118
+ "UNSAFE_INPUT",
119
+ "Invalid --api-endpoint value: it must be a string."
120
+ );
121
+ }
122
+ const value = raw.trim();
123
+ if (value.length === 0) {
124
+ throw rejectApiEndpoint(raw, "the value is empty or whitespace only");
125
+ }
126
+ if (value !== raw) {
127
+ throw rejectApiEndpoint(raw, "it contains surrounding whitespace");
128
+ }
129
+ if (value.startsWith("-")) {
130
+ throw rejectApiEndpoint(raw, "a leading hyphen would be parsed as a cf CLI flag");
131
+ }
132
+ if (hasControlCharacter(value)) {
133
+ throw rejectApiEndpoint(raw, "it contains control characters");
134
+ }
135
+ let parsed;
136
+ try {
137
+ parsed = new URL(value);
138
+ } catch {
139
+ throw rejectApiEndpoint(raw, "it is not a parseable absolute URL");
140
+ }
141
+ if (parsed.protocol !== "https:") {
142
+ throw rejectApiEndpoint(
143
+ raw,
144
+ `the scheme must be https, not ${parsed.protocol.replace(":", "")} (credentials are sent to this endpoint)`
145
+ );
146
+ }
147
+ if (parsed.username !== "" || parsed.password !== "") {
148
+ throw rejectApiEndpoint(raw, "it must not embed userinfo credentials");
149
+ }
150
+ if (parsed.search !== "" || parsed.hash !== "") {
151
+ throw rejectApiEndpoint(raw, "it must not carry a query string or fragment");
152
+ }
153
+ if (parsed.pathname !== "" && parsed.pathname !== "/") {
154
+ throw rejectApiEndpoint(raw, "it must not carry a path");
155
+ }
156
+ if (parsed.hostname === "") {
157
+ throw rejectApiEndpoint(raw, "it has no host");
158
+ }
159
+ return value;
160
+ }
83
161
  function resolveApiEndpoint(regionKey, override, onWarning) {
84
- if (override !== void 0 && override !== "") {
85
- return override;
162
+ if (override !== void 0) {
163
+ return validateApiEndpointOverride(override);
86
164
  }
87
165
  if (!REGION_KEY_PATTERN.test(regionKey)) {
88
166
  throw new CfDebuggerError(
@@ -110,6 +188,8 @@ import nodeProcess from "process";
110
188
  var MAX_BUFFER = 16 * 1024 * 1024;
111
189
  var DEFAULT_CF_COMMAND_TIMEOUT_MS = 6e4;
112
190
  var DEFAULT_CF_OPERATION_TIMEOUT_MS = 3e5;
191
+ var MUTATING_CF_ATTEMPT_TIMEOUT_MS = 18e4;
192
+ var MUTATION_DIAGNOSTIC_RESERVE_MS = 250;
113
193
  var REDACTED_ARG = "<redacted>";
114
194
  var MAX_RETRY_DELAY_MS = 1e4;
115
195
  function buildEnv(cfHome) {
@@ -159,12 +239,12 @@ function optionalString(value) {
159
239
  }
160
240
  function readFailureDetails(error) {
161
241
  const object = typeof error === "object" && error !== null ? error : void 0;
162
- const field3 = (key) => object === void 0 ? void 0 : Reflect.get(object, key);
242
+ const field4 = (key) => object === void 0 ? void 0 : Reflect.get(object, key);
163
243
  return {
164
- code: optionalString(field3("code")),
165
- killed: field3("killed") === true,
244
+ code: optionalString(field4("code")),
245
+ killed: field4("killed") === true,
166
246
  message: error instanceof Error ? error.message : String(error),
167
- stderr: optionalString(field3("stderr"))
247
+ stderr: optionalString(field4("stderr"))
168
248
  };
169
249
  }
170
250
  function hasTransientDiagnostic(error) {
@@ -268,57 +348,72 @@ function signalCfChild(child, signal) {
268
348
  } catch {
269
349
  }
270
350
  }
351
+ function rejectBoundedExecution(settlement, cleanup, reject, failure) {
352
+ if (settlement.settled) {
353
+ return;
354
+ }
355
+ settlement.settled = true;
356
+ cleanup();
357
+ reject(failure);
358
+ }
359
+ function handleBoundedCompletion(settlement, cleanup, resolve, reject, signal, error, stdout, stderr) {
360
+ if (settlement.settled) {
361
+ return;
362
+ }
363
+ if (error === null && signal?.aborted !== true) {
364
+ settlement.settled = true;
365
+ cleanup();
366
+ resolve({ stderr, stdout });
367
+ return;
368
+ }
369
+ const failure = error ?? new Error("Command was aborted.");
370
+ Reflect.set(failure, "stderr", stderr);
371
+ rejectBoundedExecution(settlement, cleanup, reject, failure);
372
+ }
373
+ function terminateBoundedExecution(settlement, cleanup, reject, child, code, message) {
374
+ if (settlement.settled) {
375
+ return;
376
+ }
377
+ signalCfChild(child, "SIGKILL");
378
+ child.stdout?.destroy();
379
+ child.stderr?.destroy();
380
+ const failure = new Error(message);
381
+ Reflect.set(failure, "code", code);
382
+ Reflect.set(failure, "killed", true);
383
+ Reflect.set(failure, "stderr", "");
384
+ rejectBoundedExecution(settlement, cleanup, reject, failure);
385
+ }
271
386
  async function executeFileBounded(command, args, options) {
272
387
  return await new Promise((resolve, reject) => {
273
- let settled = false;
388
+ const settlement = { settled: false };
274
389
  const cleanup = () => {
275
390
  clearTimeout(timer);
276
391
  options.signal?.removeEventListener("abort", onAbort);
277
392
  };
278
- const rejectOnce = (error) => {
279
- if (settled) {
280
- return;
281
- }
282
- settled = true;
283
- cleanup();
284
- reject(error);
285
- };
286
393
  const child = execFile(command, [...args], {
287
394
  env: options.env,
288
395
  maxBuffer: options.maxBuffer ?? MAX_BUFFER,
289
396
  encoding: "utf8"
290
397
  }, (error, stdout, stderr) => {
291
- if (settled) {
292
- return;
293
- }
294
- settled = true;
295
- cleanup();
296
- if (error === null && options.signal?.aborted !== true) {
297
- resolve({ stderr, stdout });
298
- return;
299
- }
300
- const failure = error ?? new Error("Command was aborted.");
301
- Reflect.set(failure, "stderr", stderr);
302
- reject(failure);
398
+ handleBoundedCompletion(
399
+ settlement,
400
+ cleanup,
401
+ resolve,
402
+ reject,
403
+ options.signal,
404
+ error,
405
+ stdout,
406
+ stderr
407
+ );
303
408
  });
304
- const terminateAndReject = (code, message) => {
305
- if (settled) {
306
- return;
307
- }
308
- signalCfChild(child, "SIGKILL");
309
- child.stdout?.destroy();
310
- child.stderr?.destroy();
311
- const failure = new Error(message);
312
- Reflect.set(failure, "code", code);
313
- Reflect.set(failure, "killed", true);
314
- Reflect.set(failure, "stderr", "");
315
- rejectOnce(failure);
409
+ const terminate = (code, message) => {
410
+ terminateBoundedExecution(settlement, cleanup, reject, child, code, message);
316
411
  };
317
412
  const onAbort = () => {
318
- terminateAndReject("ABORT_ERR", "Command was aborted.");
413
+ terminate("ABORT_ERR", "Command was aborted.");
319
414
  };
320
415
  const timer = setTimeout(() => {
321
- terminateAndReject("ETIMEDOUT", "Command exceeded its execution deadline.");
416
+ terminate("ETIMEDOUT", "Command exceeded its execution deadline.");
322
417
  }, options.timeoutMs);
323
418
  options.signal?.addEventListener("abort", onAbort, { once: true });
324
419
  if (options.signal?.aborted) {
@@ -339,6 +434,13 @@ function createCfCliError(args, failure, redactionValues) {
339
434
  stderr
340
435
  );
341
436
  }
437
+ function mutationTimeoutError(args, redactionValues) {
438
+ const verb = args[0] ?? "command";
439
+ return new CfDebuggerError(
440
+ "CF_MUTATION_TIMEOUT",
441
+ `cf ${formatArgsForError(args, redactionValues)} did not complete within its deadline and was not retried, because it mutates the deployed application. The ${verb} may still be completing on the platform; check the application state before retrying.`
442
+ );
443
+ }
342
444
  function timeoutError(context, args, redactionValues) {
343
445
  if (context.deadlineAt !== void 0) {
344
446
  const timeoutMs = context.startupTimeoutMs;
@@ -361,46 +463,69 @@ function reportRetry(context, args, attempt, delayMs, remainingMs, redactionValu
361
463
  remainingMs
362
464
  });
363
465
  }
364
- async function runCf(args, context, input = {}) {
466
+ function planCfAttempt(command, options, context) {
467
+ const remainingMs = options.deadlineAt - Date.now();
468
+ if (remainingMs <= 0) {
469
+ throw timeoutError(context, command.args, options.redactionValues);
470
+ }
471
+ const attemptCap = command.retryPolicy === "mutation" ? Math.min(MUTATING_CF_ATTEMPT_TIMEOUT_MS, options.attemptTimeoutMs * 3) : options.attemptTimeoutMs;
472
+ const attemptBudget = command.retryPolicy === "mutation" ? Math.max(1, remainingMs - MUTATION_DIAGNOSTIC_RESERVE_MS) : remainingMs;
473
+ return {
474
+ // A mutating child must time out before the overall startup signal so the
475
+ // user receives the specific "mutation may still be in flight" diagnostic.
476
+ timeoutMs: Math.max(1, Math.min(attemptCap, attemptBudget))
477
+ };
478
+ }
479
+ async function handleCfAttemptFailure(error, command, context, options, attempt, attemptTimeoutMs, startedAt) {
480
+ const failure = readFailureDetails(error);
481
+ if (context.signal?.aborted || failure.code === "ABORT_ERR") {
482
+ throw abortError(context);
483
+ }
484
+ const elapsedMs = Date.now() - startedAt;
485
+ const attemptTimedOut = failure.killed && elapsedMs + 100 >= attemptTimeoutMs;
486
+ if (command.retryPolicy === "mutation") {
487
+ throw attemptTimedOut ? mutationTimeoutError(command.args, options.redactionValues) : createCfCliError(command.args, failure, options.redactionValues);
488
+ }
489
+ if (isCredentialRejection(command.args, failure) || !isTransientNetworkError(failure, attemptTimedOut)) {
490
+ throw createCfCliError(command.args, failure, options.redactionValues);
491
+ }
492
+ const delayMs = retryDelayMs(attempt);
493
+ const remainingMs = options.deadlineAt - Date.now();
494
+ if (remainingMs <= delayMs) {
495
+ throw timeoutError(context, command.args, options.redactionValues);
496
+ }
497
+ reportRetry(
498
+ context,
499
+ command.args,
500
+ attempt,
501
+ delayMs,
502
+ remainingMs,
503
+ options.redactionValues
504
+ );
505
+ await waitForRetry(delayMs, context);
506
+ }
507
+ async function runCf(command, context, input = {}) {
365
508
  if (context.signal?.aborted) {
366
509
  throw abortError(context);
367
510
  }
368
511
  const options = resolveRunOptions(context, input);
369
512
  let attempt = 0;
370
513
  for (; ; ) {
371
- const remainingMs = options.deadlineAt - Date.now();
372
- if (remainingMs <= 0) {
373
- throw timeoutError(context, args, options.redactionValues);
374
- }
514
+ const plan = planCfAttempt(command, options, context);
375
515
  attempt += 1;
376
- const attemptTimeoutMs = Math.max(1, Math.min(options.attemptTimeoutMs, remainingMs));
377
516
  const startedAt = Date.now();
378
517
  try {
379
- return await executeCfAttempt(args, context, options, attemptTimeoutMs);
518
+ return await executeCfAttempt(command.args, context, options, plan.timeoutMs);
380
519
  } catch (error) {
381
- const failure = readFailureDetails(error);
382
- if (context.signal?.aborted || failure.code === "ABORT_ERR") {
383
- throw abortError(context);
384
- }
385
- const elapsedMs = Date.now() - startedAt;
386
- const attemptTimedOut = failure.killed && elapsedMs + 100 >= attemptTimeoutMs;
387
- if (isCredentialRejection(args, failure) || !isTransientNetworkError(failure, attemptTimedOut)) {
388
- throw createCfCliError(args, failure, options.redactionValues);
389
- }
390
- const delayMs = retryDelayMs(attempt);
391
- const afterFailureMs = options.deadlineAt - Date.now();
392
- if (afterFailureMs <= delayMs) {
393
- throw timeoutError(context, args, options.redactionValues);
394
- }
395
- reportRetry(
520
+ await handleCfAttemptFailure(
521
+ error,
522
+ command,
396
523
  context,
397
- args,
524
+ options,
398
525
  attempt,
399
- delayMs,
400
- afterFailureMs,
401
- options.redactionValues
526
+ plan.timeoutMs,
527
+ startedAt
402
528
  );
403
- await waitForRetry(delayMs, context);
404
529
  }
405
530
  }
406
531
  }
@@ -408,16 +533,22 @@ async function runCf(args, context, input = {}) {
408
533
  // src/cloud-foundry/commands.ts
409
534
  var CURRENT_TARGET_TIMEOUT_MS = 3e4;
410
535
  function rethrowControlFlowError(error) {
411
- if (error instanceof CfDebuggerError && (error.code === "ABORTED" || error.code === "STARTUP_TIMEOUT")) {
536
+ if (error instanceof CfDebuggerError && (error.code === "ABORTED" || error.code === "CF_MUTATION_TIMEOUT" || error.code === "STARTUP_TIMEOUT")) {
412
537
  throw error;
413
538
  }
414
539
  }
415
540
  async function cfApi(apiEndpoint, context) {
416
- await runCf(["api", apiEndpoint], context);
541
+ await runCf({
542
+ args: ["api", apiEndpoint],
543
+ retryPolicy: "retry-transient"
544
+ }, context);
417
545
  }
418
546
  async function cfAuth(email, password, context) {
419
547
  try {
420
- await runCf(["auth"], context, {
548
+ await runCf({
549
+ args: ["auth"],
550
+ retryPolicy: "retry-transient"
551
+ }, context, {
421
552
  env: { CF_PASSWORD: password, CF_USERNAME: email },
422
553
  sensitiveValues: [email, password]
423
554
  });
@@ -447,7 +578,10 @@ async function cfLogin(apiEndpoint, email, password, context) {
447
578
  }
448
579
  async function cfTarget(org, space, context) {
449
580
  try {
450
- await runCf(["target", "-o", org, "-s", space], context);
581
+ await runCf({
582
+ args: ["target", "-o", org, "-s", space],
583
+ retryPolicy: "retry-transient"
584
+ }, context);
451
585
  } catch (err) {
452
586
  rethrowControlFlowError(err);
453
587
  if (err instanceof CfDebuggerError) {
@@ -458,7 +592,10 @@ async function cfTarget(org, space, context) {
458
592
  }
459
593
  async function cfAppExists(appName, context) {
460
594
  try {
461
- await runCf(["app", appName], context);
595
+ await runCf({
596
+ args: ["app", appName],
597
+ retryPolicy: "retry-transient"
598
+ }, context);
462
599
  return true;
463
600
  } catch (err) {
464
601
  rethrowControlFlowError(err);
@@ -471,7 +608,10 @@ async function cfAppExists(appName, context) {
471
608
  }
472
609
  async function cfSshEnabled(appName, context) {
473
610
  try {
474
- const stdout = await runCf(["ssh-enabled", appName], context);
611
+ const stdout = await runCf({
612
+ args: ["ssh-enabled", appName],
613
+ retryPolicy: "retry-transient"
614
+ }, context);
475
615
  const normalized = stdout.toLowerCase();
476
616
  if (normalized.includes("ssh support is enabled")) {
477
617
  return "enabled";
@@ -487,7 +627,10 @@ async function cfSshEnabled(appName, context) {
487
627
  }
488
628
  async function cfEnableSsh(appName, context) {
489
629
  try {
490
- await runCf(["enable-ssh", appName], context);
630
+ await runCf({
631
+ args: ["enable-ssh", appName],
632
+ retryPolicy: "mutation"
633
+ }, context);
491
634
  } catch (err) {
492
635
  rethrowControlFlowError(err);
493
636
  if (err instanceof CfDebuggerError) {
@@ -497,7 +640,10 @@ async function cfEnableSsh(appName, context) {
497
640
  }
498
641
  }
499
642
  async function cfRestartApp(appName, context) {
500
- await runCf(["restart", appName], context);
643
+ await runCf({
644
+ args: ["restart", appName],
645
+ retryPolicy: "mutation"
646
+ }, context);
501
647
  }
502
648
  async function readCurrentCfTarget(options = {}) {
503
649
  try {
@@ -556,7 +702,7 @@ function parseTargetFields(stdout) {
556
702
  line.slice(0, separator).trim().toLowerCase(),
557
703
  line.slice(separator + 1).trim()
558
704
  ];
559
- }).filter((field3) => field3 !== void 0)
705
+ }).filter((field4) => field4 !== void 0)
560
706
  );
561
707
  }
562
708
  function regionKeyForApiEndpoint(apiEndpoint) {
@@ -622,15 +768,6 @@ function resolveNodeTarget(input) {
622
768
  validateNodePid(input.nodePid);
623
769
  return input.nodePid === void 0 ? { process: processName, instance } : { process: processName, instance, nodePid: input.nodePid };
624
770
  }
625
- function hasControlCharacter(value) {
626
- for (let index = 0; index < value.length; index += 1) {
627
- const code = value.charCodeAt(index);
628
- if (code < 32 || code === 127) {
629
- return true;
630
- }
631
- }
632
- return false;
633
- }
634
771
  function buildNodeInspectorCommand(nodePid, remotePort = DEFAULT_NODE_INSPECTOR_PORT) {
635
772
  validateNodePid(nodePid);
636
773
  validateRemotePort(remotePort);
@@ -845,16 +982,124 @@ var NODE_INSPECTOR_SCRIPT = [
845
982
  ].join("\n");
846
983
 
847
984
  // src/cloud-foundry/ssh.ts
848
- import { spawn } from "child_process";
849
- import nodeProcess3 from "process";
985
+ import { spawn as spawn2 } from "child_process";
986
+ import nodeProcess4 from "process";
850
987
  import { StringDecoder } from "string_decoder";
851
988
 
989
+ // src/cloud-foundry/ssh-shared.ts
990
+ var DEFAULT_MAX_OUTPUT_BYTES = 65536;
991
+ var LIVE_LINE_LIMIT_BYTES = 65536;
992
+ var MAX_REDACTION_OVERLAP_BYTES = 4096;
993
+ var SENSITIVE_OUTPUT_OMITTED = "[diagnostic output omitted to protect a sensitive value]";
994
+ function buildCfSshArgs(appName, target, tail) {
995
+ const resolved = resolveNodeTarget(target);
996
+ const processArgs = resolved.process === DEFAULT_CF_PROCESS ? [] : ["--process", resolved.process];
997
+ return [
998
+ "ssh",
999
+ appName,
1000
+ ...processArgs,
1001
+ "-i",
1002
+ resolved.instance.toString(),
1003
+ ...tail
1004
+ ];
1005
+ }
1006
+ function createBoundedOutput() {
1007
+ return { chunks: [], bytes: 0, truncated: false };
1008
+ }
1009
+ function appendHead(output, data, outputLimit, retainedLimit) {
1010
+ const incoming = Buffer.isBuffer(data) ? data : Buffer.from(data);
1011
+ if (incoming.byteLength === 0) {
1012
+ return;
1013
+ }
1014
+ output.truncated ||= output.bytes + incoming.byteLength > outputLimit;
1015
+ const remaining = Math.max(0, retainedLimit - output.bytes);
1016
+ if (remaining === 0) {
1017
+ return;
1018
+ }
1019
+ const next = incoming.byteLength <= remaining ? incoming : Buffer.from(incoming.subarray(0, remaining));
1020
+ output.chunks.push(next);
1021
+ output.bytes += next.byteLength;
1022
+ }
1023
+ function appendTail(output, data, outputLimit, retainedLimit) {
1024
+ const incoming = Buffer.isBuffer(data) ? data : Buffer.from(data);
1025
+ if (incoming.byteLength === 0) {
1026
+ return;
1027
+ }
1028
+ output.chunks.push(incoming);
1029
+ output.bytes += incoming.byteLength;
1030
+ output.truncated ||= output.bytes > outputLimit;
1031
+ while (output.bytes > retainedLimit && output.chunks.length > 0) {
1032
+ const first = output.chunks[0];
1033
+ if (first === void 0) {
1034
+ break;
1035
+ }
1036
+ const excess = output.bytes - retainedLimit;
1037
+ if (first.byteLength <= excess) {
1038
+ output.chunks.shift();
1039
+ output.bytes -= first.byteLength;
1040
+ } else {
1041
+ output.chunks[0] = Buffer.from(first.subarray(excess));
1042
+ output.bytes -= excess;
1043
+ }
1044
+ }
1045
+ }
1046
+ function createRedactionPolicy(values) {
1047
+ const sensitiveValues = normalizeSensitiveValues(values);
1048
+ const overlapBytes = sensitiveValues.reduce(
1049
+ (largest, value) => Math.max(largest, Buffer.byteLength(value)),
1050
+ 0
1051
+ );
1052
+ return {
1053
+ safeToSurface: overlapBytes <= MAX_REDACTION_OVERLAP_BYTES && !sensitiveValues.some((value) => /[\r\n]/.test(value)),
1054
+ sensitiveValues,
1055
+ overlapBytes: Math.min(overlapBytes, MAX_REDACTION_OVERLAP_BYTES)
1056
+ };
1057
+ }
1058
+ function outputText(output) {
1059
+ return Buffer.concat(output.chunks, output.bytes).toString("utf8");
1060
+ }
1061
+ function limitText(text, limit, keep) {
1062
+ const buffer = Buffer.from(text);
1063
+ if (buffer.byteLength <= limit) {
1064
+ return text;
1065
+ }
1066
+ const limited = keep === "head" ? buffer.subarray(0, limit) : buffer.subarray(buffer.byteLength - limit);
1067
+ return limited.toString("utf8");
1068
+ }
1069
+ function safeOutputText(output, policy, limit, keep) {
1070
+ if (!policy.safeToSurface && output.bytes > 0) {
1071
+ return SENSITIVE_OUTPUT_OMITTED;
1072
+ }
1073
+ const redacted = redactSensitiveText(outputText(output), policy.sensitiveValues);
1074
+ return limitText(redacted, limit, keep);
1075
+ }
1076
+ function sshAbortError(context) {
1077
+ if (context.deadlineAt !== void 0 && Date.now() >= context.deadlineAt) {
1078
+ return new CfDebuggerError(
1079
+ "STARTUP_TIMEOUT",
1080
+ `Debugger startup exceeded its configured deadline during ${context.phase ?? "remote signalling"}.`
1081
+ );
1082
+ }
1083
+ return new CfDebuggerError("ABORTED", "Operation aborted by caller");
1084
+ }
1085
+ function isDeadlineExpired(context) {
1086
+ return context.deadlineAt !== void 0 && Date.now() >= context.deadlineAt;
1087
+ }
1088
+
1089
+ // src/cloud-foundry/ssh-one-shot.ts
1090
+ import {
1091
+ spawn
1092
+ } from "child_process";
1093
+ import nodeProcess3 from "process";
1094
+
852
1095
  // src/debug-session/process-identity.ts
853
1096
  import { execFile as execFile2 } from "child_process";
854
1097
  import { readFile } from "fs/promises";
855
1098
  import nodeProcess2 from "process";
856
1099
  import { promisify } from "util";
857
1100
  var execFileAsync = promisify(execFile2);
1101
+ var PROCESS_IDENTITY_VERSION = "v1";
1102
+ var DARWIN_START_TIME_PATTERN = /^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun) (?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) +(?:[1-9]|[12]\d|3[01]) \d{2}:\d{2}:\d{2} \d{4}$/;
858
1103
  function errorCode(error) {
859
1104
  if (typeof error !== "object" || error === null) {
860
1105
  return void 0;
@@ -883,7 +1128,7 @@ async function linuxProcessIdentity(pid, signal) {
883
1128
  ...signal === void 0 ? {} : { signal }
884
1129
  });
885
1130
  const startTime = parseLinuxProcessStartTime(statLine);
886
- return startTime === void 0 ? void 0 : `linux:${startTime}`;
1131
+ return startTime === void 0 ? void 0 : `linux:${PROCESS_IDENTITY_VERSION}:${startTime}`;
887
1132
  } catch (error) {
888
1133
  throwIfAborted(signal);
889
1134
  if (errorCode(error) === "ENOENT") {
@@ -892,14 +1137,22 @@ async function linuxProcessIdentity(pid, signal) {
892
1137
  return void 0;
893
1138
  }
894
1139
  }
1140
+ function darwinIdentityEnvironment(environment) {
1141
+ return {
1142
+ ...environment,
1143
+ LC_ALL: "C",
1144
+ TZ: "UTC"
1145
+ };
1146
+ }
895
1147
  async function darwinProcessIdentity(pid, signal) {
896
1148
  try {
897
1149
  const { stdout } = await execFileAsync("ps", ["-p", pid.toString(), "-o", "lstart="], {
1150
+ env: darwinIdentityEnvironment(nodeProcess2.env),
898
1151
  ...signal === void 0 ? {} : { signal },
899
1152
  timeout: 2e3
900
1153
  });
901
1154
  const startedAt = stdout.trim();
902
- return startedAt.length === 0 ? void 0 : `darwin:${startedAt}`;
1155
+ return startedAt.length === 0 ? void 0 : `darwin:${PROCESS_IDENTITY_VERSION}:${startedAt}`;
903
1156
  } catch {
904
1157
  throwIfAborted(signal);
905
1158
  return void 0;
@@ -918,36 +1171,29 @@ async function readProcessIdentity(pid, signal) {
918
1171
  }
919
1172
  return void 0;
920
1173
  }
1174
+ function isCurrentProcessIdentity(identity) {
1175
+ if (/^linux:v1:\d+$/.test(identity)) {
1176
+ return true;
1177
+ }
1178
+ const darwinPrefix = `darwin:${PROCESS_IDENTITY_VERSION}:`;
1179
+ return identity.startsWith(darwinPrefix) && DARWIN_START_TIME_PATTERN.test(identity.slice(darwinPrefix.length));
1180
+ }
921
1181
  async function inspectProcessIdentity(pid, expected, signal) {
922
1182
  throwIfAborted(signal);
923
1183
  if (expected === void 0) {
924
1184
  return "match";
925
1185
  }
1186
+ if (!isCurrentProcessIdentity(expected)) {
1187
+ return "unavailable";
1188
+ }
926
1189
  const current = await readProcessIdentity(pid, signal);
927
- if (current === void 0) {
1190
+ if (current === void 0 || !isCurrentProcessIdentity(current)) {
928
1191
  return "unavailable";
929
1192
  }
930
1193
  return current === expected ? "match" : "mismatch";
931
1194
  }
932
1195
 
933
- // src/cloud-foundry/ssh.ts
934
- var DEFAULT_MAX_OUTPUT_BYTES = 65536;
935
- var LIVE_LINE_LIMIT_BYTES = 65536;
936
- var MAX_REDACTION_OVERLAP_BYTES = 4096;
937
- var SENSITIVE_OUTPUT_OMITTED = "[diagnostic output omitted to protect a sensitive value]";
938
- var tunnelCaptures = /* @__PURE__ */ new WeakMap();
939
- function buildCfSshArgs(appName, target, tail) {
940
- const resolved = resolveNodeTarget(target);
941
- const processArgs = resolved.process === DEFAULT_CF_PROCESS ? [] : ["--process", resolved.process];
942
- return [
943
- "ssh",
944
- appName,
945
- ...processArgs,
946
- "-i",
947
- resolved.instance.toString(),
948
- ...tail
949
- ];
950
- }
1196
+ // src/cloud-foundry/ssh-one-shot.ts
951
1197
  async function cfSshOneShot(appName, command, context, rawOptions = DEFAULT_CF_COMMAND_TIMEOUT_MS) {
952
1198
  if (context.signal?.aborted || isDeadlineExpired(context)) {
953
1199
  throw sshAbortError(context);
@@ -977,87 +1223,17 @@ function resolveSshOptions(raw, context) {
977
1223
  maxOutputBytes
978
1224
  };
979
1225
  }
980
- function createBoundedOutput() {
981
- return { chunks: [], bytes: 0, truncated: false };
982
- }
983
- function appendHead(output, data, outputLimit, retainedLimit) {
984
- const incoming = Buffer.isBuffer(data) ? data : Buffer.from(data);
985
- if (incoming.byteLength === 0) {
986
- return;
987
- }
988
- output.truncated ||= output.bytes + incoming.byteLength > outputLimit;
989
- const remaining = Math.max(0, retainedLimit - output.bytes);
990
- if (remaining === 0) {
991
- return;
992
- }
993
- const next = incoming.byteLength <= remaining ? incoming : Buffer.from(incoming.subarray(0, remaining));
994
- output.chunks.push(next);
995
- output.bytes += next.byteLength;
996
- }
997
- function appendTail(output, data, outputLimit, retainedLimit) {
998
- const incoming = Buffer.isBuffer(data) ? data : Buffer.from(data);
999
- if (incoming.byteLength === 0) {
1000
- return;
1001
- }
1002
- output.chunks.push(incoming);
1003
- output.bytes += incoming.byteLength;
1004
- output.truncated ||= output.bytes > outputLimit;
1005
- while (output.bytes > retainedLimit && output.chunks.length > 0) {
1006
- const first = output.chunks[0];
1007
- if (first === void 0) {
1008
- break;
1009
- }
1010
- const excess = output.bytes - retainedLimit;
1011
- if (first.byteLength <= excess) {
1012
- output.chunks.shift();
1013
- output.bytes -= first.byteLength;
1014
- } else {
1015
- output.chunks[0] = Buffer.from(first.subarray(excess));
1016
- output.bytes -= excess;
1017
- }
1018
- }
1019
- }
1020
- function outputText(output) {
1021
- return Buffer.concat(output.chunks, output.bytes).toString("utf8");
1022
- }
1023
- function createRedactionPolicy(values) {
1024
- const sensitiveValues = normalizeSensitiveValues(values);
1025
- const overlapBytes = sensitiveValues.reduce(
1026
- (largest, value) => Math.max(largest, Buffer.byteLength(value)),
1027
- 0
1028
- );
1029
- return {
1030
- safeToSurface: overlapBytes <= MAX_REDACTION_OVERLAP_BYTES && !sensitiveValues.some((value) => /[\r\n]/.test(value)),
1031
- sensitiveValues,
1032
- overlapBytes: Math.min(overlapBytes, MAX_REDACTION_OVERLAP_BYTES)
1033
- };
1034
- }
1035
- function limitText(text, limit, keep) {
1036
- const buffer = Buffer.from(text);
1037
- if (buffer.byteLength <= limit) {
1038
- return text;
1039
- }
1040
- const limited = keep === "head" ? buffer.subarray(0, limit) : buffer.subarray(buffer.byteLength - limit);
1041
- return limited.toString("utf8");
1042
- }
1043
- function safeOutputText(output, policy, limit, keep) {
1044
- if (!policy.safeToSurface && output.bytes > 0) {
1045
- return SENSITIVE_OUTPUT_OMITTED;
1046
- }
1047
- const redacted = redactSensitiveText(outputText(output), policy.sensitiveValues);
1048
- return limitText(redacted, limit, keep);
1049
- }
1050
- function createResult(exitCode, state) {
1051
- const stdout = safeOutputText(state.stdout, state.redaction, state.outputLimit, "head");
1052
- const stderr = safeOutputText(state.stderr, state.redaction, state.outputLimit, "head");
1053
- return {
1054
- exitCode,
1055
- stdout,
1056
- stderr,
1057
- outputTruncated: state.stdout.truncated || state.stderr.truncated,
1058
- stdoutTruncated: state.stdout.truncated,
1059
- stderrTruncated: state.stderr.truncated
1060
- };
1226
+ function createResult(exitCode, state) {
1227
+ const stdout = safeOutputText(state.stdout, state.redaction, state.outputLimit, "head");
1228
+ const stderr = safeOutputText(state.stderr, state.redaction, state.outputLimit, "head");
1229
+ return {
1230
+ exitCode,
1231
+ stdout,
1232
+ stderr,
1233
+ outputTruncated: state.stdout.truncated || state.stderr.truncated,
1234
+ stdoutTruncated: state.stdout.truncated,
1235
+ stderrTruncated: state.stderr.truncated
1236
+ };
1061
1237
  }
1062
1238
  function createSshExecutionState(context, outputLimit) {
1063
1239
  const redaction = createRedactionPolicy(context.sensitiveValues ?? []);
@@ -1086,16 +1262,13 @@ async function captureSshChildIdentity(child) {
1086
1262
  return;
1087
1263
  }
1088
1264
  }
1089
- async function canSignalSshChild(child, childIdentity) {
1265
+ async function canSignalSshChild(child, childIdentity, signal) {
1090
1266
  if (!childIsOpen(child) || child.pid === void 0) {
1091
1267
  return false;
1092
1268
  }
1093
- if (nodeProcess3.platform === "win32") {
1094
- return true;
1095
- }
1096
1269
  const expectedIdentity = await childIdentity;
1097
1270
  if (expectedIdentity === void 0) {
1098
- return false;
1271
+ return signal === "SIGTERM" && childIsOpen(child);
1099
1272
  }
1100
1273
  return await inspectProcessIdentity(child.pid, expectedIdentity) === "match" && childIsOpen(child);
1101
1274
  }
@@ -1125,14 +1298,14 @@ function terminateSshExecution(child, childIdentity, state, settle) {
1125
1298
  }
1126
1299
  };
1127
1300
  void (async () => {
1128
- if (!await canSignalSshChild(child, childIdentity) || state.settled) {
1301
+ if (!await canSignalSshChild(child, childIdentity, "SIGTERM") || state.settled) {
1129
1302
  settleUnverified();
1130
1303
  return;
1131
1304
  }
1132
1305
  signalChild(child, "SIGTERM");
1133
1306
  state.forceKillTimer = setTimeout(() => {
1134
1307
  void (async () => {
1135
- if (!await canSignalSshChild(child, childIdentity) || state.settled) {
1308
+ if (!await canSignalSshChild(child, childIdentity, "SIGKILL") || state.settled) {
1136
1309
  settleUnverified();
1137
1310
  return;
1138
1311
  }
@@ -1141,18 +1314,6 @@ function terminateSshExecution(child, childIdentity, state, settle) {
1141
1314
  }, 1e3);
1142
1315
  })().catch(settleUnverified);
1143
1316
  }
1144
- function sshAbortError(context) {
1145
- if (context.deadlineAt !== void 0 && Date.now() >= context.deadlineAt) {
1146
- return new CfDebuggerError(
1147
- "STARTUP_TIMEOUT",
1148
- `Debugger startup exceeded its configured deadline during ${context.phase ?? "remote signalling"}.`
1149
- );
1150
- }
1151
- return new CfDebuggerError("ABORTED", "Operation aborted by caller");
1152
- }
1153
- function isDeadlineExpired(context) {
1154
- return context.deadlineAt !== void 0 && Date.now() >= context.deadlineAt;
1155
- }
1156
1317
  function createSshSettler(state, options, context, onAbort, resolve, reject) {
1157
1318
  return (result) => {
1158
1319
  if (state.settled) {
@@ -1227,6 +1388,9 @@ function signalChild(child, signal) {
1227
1388
  } catch {
1228
1389
  }
1229
1390
  }
1391
+
1392
+ // src/cloud-foundry/ssh.ts
1393
+ var tunnelCaptures = /* @__PURE__ */ new WeakMap();
1230
1394
  function isSshDisabledError(stderr) {
1231
1395
  return stderr.toLowerCase().includes("ssh support is disabled");
1232
1396
  }
@@ -1391,21 +1555,41 @@ function spawnSshTunnel(appName, localPort, remotePort, context, target = {}) {
1391
1555
  }
1392
1556
  const tunnelArg = `${localPort.toString()}:localhost:${remotePort.toString()}`;
1393
1557
  const args = buildCfSshArgs(appName, target, ["-N", "-L", tunnelArg]);
1394
- const child = spawn(resolveBin(context), [...args], {
1558
+ const child = spawn2(resolveBin(context), [...args], {
1395
1559
  env: buildEnv(context.cfHome),
1396
- detached: nodeProcess3.platform !== "win32",
1560
+ detached: nodeProcess4.platform !== "win32",
1397
1561
  stdio: ["ignore", "pipe", "pipe"]
1398
1562
  });
1399
1563
  attachTunnelCapture(child, context);
1400
1564
  return child;
1401
1565
  }
1402
1566
 
1567
+ // src/restart-policy.ts
1568
+ function parseRestartEnvironment(value) {
1569
+ if (value === void 0) {
1570
+ return "unset";
1571
+ }
1572
+ if (value === "0") {
1573
+ return "forbid";
1574
+ }
1575
+ if (value === "1") {
1576
+ return "allow";
1577
+ }
1578
+ throw new CfDebuggerError(
1579
+ "UNSAFE_INPUT",
1580
+ "CF_DEBUGGER_ALLOW_RESTART must be 0 or 1."
1581
+ );
1582
+ }
1583
+ function applyRestartEnvironmentVeto(requested, value) {
1584
+ return parseRestartEnvironment(value) === "forbid" ? false : requested === true;
1585
+ }
1586
+
1403
1587
  // src/session-state/store.ts
1404
- import { randomUUID as randomUUID2 } from "crypto";
1405
- import { chmod as chmod3, mkdir as mkdir3, readFile as readFile4, rename, unlink as unlink3, writeFile as writeFile2 } from "fs/promises";
1588
+ import { createHash, randomUUID as randomUUID2 } from "crypto";
1589
+ import { chmod as chmod3, mkdir as mkdir3, readFile as readFile5, rename, unlink as unlink3, writeFile as writeFile2 } from "fs/promises";
1406
1590
  import { hostname as getHostname2 } from "os";
1407
1591
  import { dirname as dirname2, isAbsolute as isAbsolute3 } from "path";
1408
- import nodeProcess5 from "process";
1592
+ import nodeProcess6 from "process";
1409
1593
 
1410
1594
  // src/debug-session/constants.ts
1411
1595
  var DEFAULT_TUNNEL_READY_TIMEOUT_MS = 18e4;
@@ -1504,18 +1688,7 @@ async function readLockOwner(lockPath) {
1504
1688
  throw error;
1505
1689
  }
1506
1690
  }
1507
- async function isStaleLock(lockPath, staleMs) {
1508
- let modifiedAt2;
1509
- try {
1510
- modifiedAt2 = (await stat(lockPath)).mtimeMs;
1511
- } catch (error) {
1512
- if (errorCode2(error) === "ENOENT") {
1513
- return true;
1514
- }
1515
- throw error;
1516
- }
1517
- const owner = await readLockOwner(lockPath);
1518
- const ageMs = Date.now() - modifiedAt2;
1691
+ async function lockOwnerIsStale(owner, ageMs, staleMs) {
1519
1692
  if (owner?.hostname === hostname()) {
1520
1693
  if (!isProcessAlive(owner.pid)) {
1521
1694
  return true;
@@ -1536,20 +1709,54 @@ async function isStaleLock(lockPath, staleMs) {
1536
1709
  }
1537
1710
  return ageMs > staleMs * FOREIGN_HOST_STALE_MULTIPLIER;
1538
1711
  }
1539
- async function reclaimAbandonedRecoveryLock(recoveryPath, staleMs) {
1540
- if (!await isStaleLock(recoveryPath, staleMs)) {
1541
- return;
1712
+ async function inspectLockStaleness(lockPath, staleMs) {
1713
+ const owner = await readLockOwner(lockPath);
1714
+ try {
1715
+ const stats = await stat(lockPath);
1716
+ return {
1717
+ fingerprint: lockFingerprint(stats),
1718
+ owner,
1719
+ stale: await lockOwnerIsStale(owner, Date.now() - stats.mtimeMs, staleMs),
1720
+ status: "present"
1721
+ };
1722
+ } catch (error) {
1723
+ if (errorCode2(error) === "ENOENT") {
1724
+ return { status: "missing" };
1725
+ }
1726
+ throw error;
1542
1727
  }
1543
- const owner = await readLockOwner(recoveryPath);
1544
- if (owner !== void 0) {
1545
- await removeOwnedLock(recoveryPath, owner.token);
1546
- return;
1728
+ }
1729
+ function lockFingerprint(stats) {
1730
+ return [
1731
+ stats.dev.toString(),
1732
+ stats.ino.toString(),
1733
+ stats.size.toString(),
1734
+ stats.mtimeMs.toString()
1735
+ ].join(":");
1736
+ }
1737
+ async function removeObservedLock(lockPath, observation) {
1738
+ if (observation.owner !== void 0) {
1739
+ return await removeOwnedLock(lockPath, observation.owner.token);
1547
1740
  }
1548
- await unlink(recoveryPath).catch((error) => {
1549
- if (errorCode2(error) !== "ENOENT") {
1550
- throw error;
1741
+ try {
1742
+ if (lockFingerprint(await stat(lockPath)) !== observation.fingerprint) {
1743
+ return false;
1551
1744
  }
1552
- });
1745
+ await unlink(lockPath);
1746
+ return true;
1747
+ } catch (error) {
1748
+ if (errorCode2(error) === "ENOENT") {
1749
+ return false;
1750
+ }
1751
+ throw error;
1752
+ }
1753
+ }
1754
+ async function reclaimAbandonedRecoveryLock(recoveryPath, staleMs) {
1755
+ const observation = await inspectLockStaleness(recoveryPath, staleMs);
1756
+ if (observation.status !== "present" || !observation.stale || observation.owner === void 0) {
1757
+ return;
1758
+ }
1759
+ await removeOwnedLock(recoveryPath, observation.owner.token);
1553
1760
  }
1554
1761
  async function reclaimStaleLock(lockPath, staleMs) {
1555
1762
  const recoveryPath = `${lockPath}.recovery`;
@@ -1564,17 +1771,11 @@ async function reclaimStaleLock(lockPath, staleMs) {
1564
1771
  throw error;
1565
1772
  }
1566
1773
  try {
1567
- if (!await isStaleLock(lockPath, staleMs)) {
1774
+ const observation = await inspectLockStaleness(lockPath, staleMs);
1775
+ if (observation.status !== "present" || !observation.stale) {
1568
1776
  return false;
1569
1777
  }
1570
- try {
1571
- await unlink(lockPath);
1572
- } catch (error) {
1573
- if (errorCode2(error) !== "ENOENT") {
1574
- throw error;
1575
- }
1576
- }
1577
- return true;
1778
+ return await removeObservedLock(lockPath, observation);
1578
1779
  } finally {
1579
1780
  await releaseFileLock(recoveryPath, recoveryLock);
1580
1781
  }
@@ -1582,12 +1783,13 @@ async function reclaimStaleLock(lockPath, staleMs) {
1582
1783
  async function removeOwnedLock(lockPath, token) {
1583
1784
  const owner = await readLockOwner(lockPath);
1584
1785
  if (owner?.token !== token) {
1585
- return;
1786
+ return false;
1586
1787
  }
1587
- await unlink(lockPath).catch((error) => {
1788
+ return await unlink(lockPath).then(() => true).catch((error) => {
1588
1789
  if (errorCode2(error) !== "ENOENT") {
1589
1790
  throw error;
1590
1791
  }
1792
+ return false;
1591
1793
  });
1592
1794
  }
1593
1795
  async function createFileLock(lockPath) {
@@ -1860,16 +2062,24 @@ function decodeSession(value) {
1860
2062
  return void 0;
1861
2063
  }
1862
2064
  }
1863
- function duplicateSessionIds(sessions) {
2065
+ function decodeSessions(rawSessions) {
1864
2066
  const seen = /* @__PURE__ */ new Set();
1865
- const duplicates = /* @__PURE__ */ new Set();
1866
- for (const session of sessions) {
2067
+ const sessions = [];
2068
+ const dropped = [];
2069
+ for (const [index, raw] of rawSessions.entries()) {
2070
+ const session = decodeSession(raw);
2071
+ if (session === void 0) {
2072
+ dropped.push(`session[${index.toString()}]: invalid entry`);
2073
+ continue;
2074
+ }
1867
2075
  if (seen.has(session.sessionId)) {
1868
- duplicates.add(session.sessionId);
2076
+ dropped.push(`session[${index.toString()}]: duplicate sessionId ${session.sessionId}`);
2077
+ continue;
1869
2078
  }
1870
2079
  seen.add(session.sessionId);
2080
+ sessions.push(session);
1871
2081
  }
1872
- return duplicates;
2082
+ return { dropped, sessions };
1873
2083
  }
1874
2084
  function decodeStateFileDetailed(value) {
1875
2085
  if (typeof value !== "object" || value === null) {
@@ -1882,36 +2092,46 @@ function decodeStateFileDetailed(value) {
1882
2092
  if (!Array.isArray(rawSessions)) {
1883
2093
  return { kind: "invalid-file", reason: "sessions is not an array" };
1884
2094
  }
1885
- const decoded = rawSessions.map((raw) => decodeSession(raw));
1886
- const valid = decoded.filter((session) => session !== void 0);
1887
- const duplicates = duplicateSessionIds(valid);
1888
- const sessions = valid.filter((session) => !duplicates.has(session.sessionId));
1889
- const dropped = decoded.flatMap((session, index) => {
1890
- if (session === void 0) {
1891
- return [`session[${index.toString()}]: invalid entry`];
1892
- }
1893
- return duplicates.has(session.sessionId) ? [`session[${index.toString()}]: duplicate sessionId ${session.sessionId}`] : [];
1894
- });
2095
+ const decoded = decodeSessions(rawSessions);
1895
2096
  return {
1896
2097
  kind: "decoded",
1897
- state: { version: "2", sessions },
1898
- dropped
2098
+ state: { version: "2", sessions: decoded.sessions },
2099
+ dropped: decoded.dropped
1899
2100
  };
1900
2101
  }
1901
2102
 
1902
2103
  // src/session-state/health.ts
1903
2104
  import { hostname as getHostname } from "os";
1904
- import nodeProcess4 from "process";
2105
+ import nodeProcess5 from "process";
2106
+
2107
+ // src/debug-session/session-process.ts
2108
+ function startupAgeLimit(session) {
2109
+ return (session.startupTimeoutMs ?? MAX_STARTUP_TIMEOUT_MS) + STARTUP_STALE_SLACK_MS;
2110
+ }
2111
+ function startupExpired(session) {
2112
+ const startedAt = Date.parse(session.startedAt);
2113
+ return Number.isNaN(startedAt) || Date.now() - startedAt > startupAgeLimit(session);
2114
+ }
2115
+ async function inspectRecordedProcess(pid, identity, isAlive, signal) {
2116
+ if (signal?.aborted) {
2117
+ throw new CfDebuggerError("ABORTED", "Session health inspection was aborted.");
2118
+ }
2119
+ if (!isAlive(pid)) {
2120
+ return "dead";
2121
+ }
2122
+ return await inspectProcessIdentity(pid, identity, signal);
2123
+ }
1905
2124
 
1906
2125
  // src/network/ports.ts
1907
2126
  import { execFile as execFile3 } from "child_process";
1908
2127
  import { readdir, readFile as readFile3, readlink } from "fs/promises";
1909
2128
  import { get as httpGet } from "http";
1910
- import { createConnection, createServer } from "net";
2129
+ import { createServer, Socket } from "net";
1911
2130
  import { promisify as promisify2 } from "util";
1912
2131
  var execFileAsync2 = promisify2(execFile3);
1913
2132
  var PROBE_INTERVAL_MS = 250;
1914
- var INSPECTOR_ATTEMPT_TIMEOUT_MS = 2500;
2133
+ var INITIAL_INSPECTOR_ATTEMPT_TIMEOUT_MS = 2500;
2134
+ var MAX_INSPECTOR_ATTEMPT_TIMEOUT_MS = 1e4;
1915
2135
  var MAX_INSPECTOR_RESPONSE_BYTES = 64 * 1024;
1916
2136
  var OWNER_COMMAND_TIMEOUT_MS = 5e3;
1917
2137
  function sortedUniquePids(pids) {
@@ -2167,22 +2387,32 @@ async function isPortFree(port, signal) {
2167
2387
  }
2168
2388
  async function isPortListening(port, timeoutMs = 200) {
2169
2389
  return await new Promise((resolve) => {
2170
- const socket = createConnection({ port, host: "127.0.0.1" });
2171
- socket.setTimeout(timeoutMs);
2172
- socket.once("connect", () => {
2390
+ const socket = new Socket();
2391
+ let settled = false;
2392
+ const finish = (listening) => {
2393
+ if (settled) {
2394
+ return;
2395
+ }
2396
+ settled = true;
2173
2397
  socket.destroy();
2174
- resolve(true);
2398
+ resolve(listening);
2399
+ };
2400
+ socket.once("connect", () => {
2401
+ finish(true);
2175
2402
  });
2176
2403
  socket.once("error", () => {
2177
- socket.destroy();
2178
- resolve(false);
2404
+ finish(false);
2179
2405
  });
2180
2406
  socket.once("timeout", () => {
2181
- socket.destroy();
2182
- resolve(false);
2407
+ finish(false);
2183
2408
  });
2409
+ socket.setTimeout(positiveTimeout(timeoutMs));
2410
+ socket.connect({ port, host: "127.0.0.1" });
2184
2411
  });
2185
2412
  }
2413
+ function positiveTimeout(timeoutMs) {
2414
+ return Number.isFinite(timeoutMs) ? Math.max(1, Math.floor(timeoutMs)) : 1;
2415
+ }
2186
2416
  function throwIfAborted2(signal) {
2187
2417
  if (signal?.aborted) {
2188
2418
  throw new CfDebuggerError("ABORTED", "Operation aborted by caller");
@@ -2210,7 +2440,7 @@ async function probeTunnelReady(port, timeoutMs, signal) {
2210
2440
  throwIfAborted2(signal);
2211
2441
  while (Date.now() < deadline) {
2212
2442
  const remainingMs = deadline - Date.now();
2213
- if (await isPortListening(port, Math.min(200, remainingMs))) {
2443
+ if (await isPortListening(port, positiveTimeout(Math.min(200, remainingMs)))) {
2214
2444
  return true;
2215
2445
  }
2216
2446
  const waitMs = Math.min(PROBE_INTERVAL_MS, Math.max(0, deadline - Date.now()));
@@ -2279,21 +2509,28 @@ function readInspectorResponse(response, finish) {
2279
2509
  finish(false);
2280
2510
  });
2281
2511
  }
2512
+ function finishInspectorAttempt(state, ready, resolve) {
2513
+ if (state.settled) {
2514
+ return;
2515
+ }
2516
+ state.settled = true;
2517
+ clearTimeout(state.timer);
2518
+ state.removeAbortListener?.();
2519
+ resolve(ready);
2520
+ }
2282
2521
  function probeInspectorAttempt(port, timeoutMs, signal) {
2283
2522
  throwIfAborted2(signal);
2284
2523
  return new Promise((resolve, reject) => {
2285
2524
  const state = { settled: false };
2286
2525
  const finish = (ready) => {
2287
- if (!state.settled) {
2288
- state.settled = true;
2289
- clearTimeout(state.timer);
2290
- state.removeAbortListener?.();
2291
- resolve(ready);
2292
- }
2526
+ finishInspectorAttempt(state, ready, resolve);
2293
2527
  };
2294
- const request = httpGet({ host: "127.0.0.1", port, path: "/json/list" }, (response) => {
2295
- readInspectorResponse(response, finish);
2296
- });
2528
+ const request = httpGet(
2529
+ { agent: false, host: "127.0.0.1", port, path: "/json/list" },
2530
+ (response) => {
2531
+ readInspectorResponse(response, finish);
2532
+ }
2533
+ );
2297
2534
  const onAbort = () => {
2298
2535
  request.destroy();
2299
2536
  if (!state.settled) {
@@ -2314,7 +2551,7 @@ function probeInspectorAttempt(port, timeoutMs, signal) {
2314
2551
  state.timer = setTimeout(() => {
2315
2552
  request.destroy();
2316
2553
  finish(false);
2317
- }, timeoutMs);
2554
+ }, positiveTimeout(timeoutMs));
2318
2555
  request.once("error", () => {
2319
2556
  if (signal?.aborted) {
2320
2557
  onAbort();
@@ -2324,18 +2561,25 @@ function probeInspectorAttempt(port, timeoutMs, signal) {
2324
2561
  });
2325
2562
  });
2326
2563
  }
2564
+ function inspectorAttemptTimeout(attempt, remainingMs) {
2565
+ const growingCap = Math.min(
2566
+ MAX_INSPECTOR_ATTEMPT_TIMEOUT_MS,
2567
+ INITIAL_INSPECTOR_ATTEMPT_TIMEOUT_MS * 2 ** attempt
2568
+ );
2569
+ const retryShare = Math.ceil(remainingMs / 2);
2570
+ return positiveTimeout(Math.min(growingCap, retryShare, remainingMs));
2571
+ }
2327
2572
  async function probeInspectorReady(port, timeoutMs, signal) {
2328
2573
  const deadline = Date.now() + timeoutMs;
2574
+ let attempt = 0;
2329
2575
  throwIfAborted2(signal);
2330
2576
  while (Date.now() < deadline) {
2331
2577
  const remainingMs = deadline - Date.now();
2332
- const attemptTimeoutMs = Math.max(
2333
- 1,
2334
- Math.min(INSPECTOR_ATTEMPT_TIMEOUT_MS, remainingMs)
2335
- );
2578
+ const attemptTimeoutMs = inspectorAttemptTimeout(attempt, remainingMs);
2336
2579
  if (await probeInspectorAttempt(port, attemptTimeoutMs, signal)) {
2337
2580
  return { status: "ready" };
2338
2581
  }
2582
+ attempt += 1;
2339
2583
  const waitMs = Math.min(PROBE_INTERVAL_MS, Math.max(0, deadline - Date.now()));
2340
2584
  if (waitMs > 0) {
2341
2585
  await waitForNextProbe(waitMs, signal);
@@ -2355,34 +2599,18 @@ function errorCode4(error) {
2355
2599
  }
2356
2600
  function isPidAlive(pid) {
2357
2601
  try {
2358
- nodeProcess4.kill(pid, 0);
2602
+ nodeProcess5.kill(pid, 0);
2359
2603
  return true;
2360
2604
  } catch (error) {
2361
2605
  return errorCode4(error) !== "ESRCH";
2362
2606
  }
2363
2607
  }
2364
2608
  function isProcessGroupAlive(pid) {
2365
- return nodeProcess4.platform !== "win32" && isPidAlive(-pid);
2609
+ return nodeProcess5.platform !== "win32" && isPidAlive(-pid);
2366
2610
  }
2367
2611
  function isPidOrGroupAlive(pid) {
2368
2612
  return isPidAlive(pid) || isProcessGroupAlive(pid);
2369
2613
  }
2370
- function startupAgeLimit(session) {
2371
- return (session.startupTimeoutMs ?? MAX_STARTUP_TIMEOUT_MS) + STARTUP_STALE_SLACK_MS;
2372
- }
2373
- function startupExpired(session) {
2374
- const startedAt = Date.parse(session.startedAt);
2375
- return Number.isNaN(startedAt) || Date.now() - startedAt > startupAgeLimit(session);
2376
- }
2377
- async function inspectRecordedProcess(pid, identity, signal) {
2378
- if (signal?.aborted) {
2379
- throw new CfDebuggerError("ABORTED", "Session health inspection was aborted.");
2380
- }
2381
- if (!isPidAlive(pid)) {
2382
- return "dead";
2383
- }
2384
- return await inspectProcessIdentity(pid, identity, signal);
2385
- }
2386
2614
  async function readySessionHealth(session, signal) {
2387
2615
  const tunnelPid = session.tunnelPid;
2388
2616
  if (tunnelPid === void 0) {
@@ -2391,6 +2619,7 @@ async function readySessionHealth(session, signal) {
2391
2619
  const processVerdict = await inspectRecordedProcess(
2392
2620
  tunnelPid,
2393
2621
  session.tunnelProcessIdentity,
2622
+ isPidAlive,
2394
2623
  signal
2395
2624
  );
2396
2625
  if (processVerdict === "dead" && isProcessGroupAlive(tunnelPid)) {
@@ -2428,6 +2657,7 @@ async function startingSessionHealth(session, signal) {
2428
2657
  const processVerdict = await inspectRecordedProcess(
2429
2658
  controllerPid,
2430
2659
  session.controllerProcessIdentity,
2660
+ isPidAlive,
2431
2661
  signal
2432
2662
  );
2433
2663
  if (processVerdict === "match") {
@@ -2447,19 +2677,16 @@ async function inspectSessionHealth(session, host = getHostname(), signal) {
2447
2677
  }
2448
2678
  return session.status === "ready" ? await readySessionHealth(session, signal) : await startingSessionHealth(session, signal);
2449
2679
  }
2450
- async function filterStaleSessions(sessions, signal) {
2451
- const host = getHostname();
2452
- const verdicts = await Promise.all(
2453
- sessions.map(async (session) => [
2454
- session,
2455
- await inspectSessionHealth(session, host, signal)
2456
- ])
2457
- );
2458
- return verdicts.filter(([, verdict]) => verdict.status !== "stale").map(([session]) => session);
2459
- }
2460
2680
 
2461
2681
  // src/session-state/stop-intent.ts
2462
- import { access, chmod as chmod2, mkdir as mkdir2, unlink as unlink2, writeFile } from "fs/promises";
2682
+ import {
2683
+ access,
2684
+ chmod as chmod2,
2685
+ mkdir as mkdir2,
2686
+ readFile as readFile4,
2687
+ unlink as unlink2,
2688
+ writeFile
2689
+ } from "fs/promises";
2463
2690
  function errorCode5(error) {
2464
2691
  if (typeof error !== "object" || error === null) {
2465
2692
  return void 0;
@@ -2494,6 +2721,29 @@ async function hasSessionStopIntent(sessionId) {
2494
2721
  throw error;
2495
2722
  }
2496
2723
  }
2724
+ async function inspectSessionStateStopIntent(sessionId) {
2725
+ let raw;
2726
+ try {
2727
+ raw = await readFile4(stateFilePath(), "utf8");
2728
+ } catch (error) {
2729
+ return errorCode5(error) === "ENOENT" ? "missing" : "unavailable";
2730
+ }
2731
+ let parsed;
2732
+ try {
2733
+ parsed = JSON.parse(raw);
2734
+ } catch {
2735
+ return "unavailable";
2736
+ }
2737
+ const decoded = decodeStateFileDetailed(parsed);
2738
+ if (decoded.kind === "invalid-file") {
2739
+ return "unavailable";
2740
+ }
2741
+ const session = decoded.state.sessions.find((candidate) => candidate.sessionId === sessionId);
2742
+ if (session === void 0) {
2743
+ return decoded.dropped.length > 0 ? "unavailable" : "missing";
2744
+ }
2745
+ return session.stopRequestedAt !== void 0 || session.status === "stopping" ? "requested" : "active";
2746
+ }
2497
2747
  async function clearSessionStopIntent(sessionId) {
2498
2748
  await unlink2(sessionStopIntentPath(sessionId)).catch((error) => {
2499
2749
  if (errorCode5(error) !== "ENOENT") {
@@ -2515,7 +2765,7 @@ function errorCode6(error) {
2515
2765
  async function readFileIfPresent(path) {
2516
2766
  try {
2517
2767
  await chmod3(path, 384);
2518
- return await readFile4(path, "utf8");
2768
+ return await readFile5(path, "utf8");
2519
2769
  } catch (error) {
2520
2770
  if (errorCode6(error) === "ENOENT") {
2521
2771
  return void 0;
@@ -2556,7 +2806,7 @@ async function preserveCorruptState(path, reason) {
2556
2806
  const backup = corruptBackupPath(path);
2557
2807
  await rename(path, backup);
2558
2808
  await chmod3(backup, 384);
2559
- nodeProcess5.stderr.write(
2809
+ nodeProcess6.stderr.write(
2560
2810
  `[cf-debugger] warning: preserved invalid state at ${backup} (${reason}).
2561
2811
  `
2562
2812
  );
@@ -2587,7 +2837,7 @@ async function readStateRaw() {
2587
2837
  if (decoded.dropped.length > 0) {
2588
2838
  await preserveCorruptState(path, decoded.dropped.join("; "));
2589
2839
  await writeJsonFileAtomic(path, decoded.state);
2590
- nodeProcess5.stderr.write(
2840
+ nodeProcess6.stderr.write(
2591
2841
  `[cf-debugger] warning: dropped ${decoded.dropped.length.toString()} invalid state entr${decoded.dropped.length === 1 ? "y" : "ies"}; valid sessions were retained.
2592
2842
  `
2593
2843
  );
@@ -2597,22 +2847,62 @@ async function readStateRaw() {
2597
2847
  async function writeState(state) {
2598
2848
  await writeJsonFileAtomic(stateFilePath(), state);
2599
2849
  }
2600
- async function readAndPruneLocked(signal) {
2601
- const raw = await readStateRaw();
2850
+ function samePersistedSession(left, right) {
2851
+ return JSON.stringify(left) === JSON.stringify(right);
2852
+ }
2853
+ async function inspectPruneCandidates(raw, signal) {
2602
2854
  const host = getHostname2();
2603
- const remote = raw.sessions.filter((session) => session.hostname !== host);
2604
2855
  const local = raw.sessions.filter((session) => session.hostname === host);
2605
- const sessions = await filterStaleSessions(local, signal);
2606
- const activeIds = new Set(sessions.map((session) => session.sessionId));
2607
- const removed = local.filter((session) => !activeIds.has(session.sessionId));
2608
- const persisted = { version: "2", sessions: [...remote, ...sessions] };
2856
+ const verdicts = await Promise.all(local.map(async (session) => ({
2857
+ session,
2858
+ verdict: await inspectSessionHealth(session, host, signal)
2859
+ })));
2860
+ return {
2861
+ snapshotById: new Map(local.map((session) => [session.sessionId, session])),
2862
+ staleIds: new Set(
2863
+ verdicts.filter(({ verdict }) => verdict.status === "stale").map(({ session }) => session.sessionId)
2864
+ )
2865
+ };
2866
+ }
2867
+ async function persistPruneInspection(inspection, signal) {
2868
+ const raw = await readStateRaw();
2869
+ const host = getHostname2();
2870
+ const removed = [];
2871
+ const sessions = [];
2872
+ for (const session of raw.sessions.filter((entry) => entry.hostname === host)) {
2873
+ const snapshot = inspection.snapshotById.get(session.sessionId);
2874
+ if (snapshot === void 0 || !inspection.staleIds.has(session.sessionId) || !samePersistedSession(snapshot, session)) {
2875
+ sessions.push(session);
2876
+ continue;
2877
+ }
2878
+ const verdict = await inspectSessionHealth(session, host, signal);
2879
+ if (verdict.status === "stale") {
2880
+ removed.push(session);
2881
+ } else {
2882
+ sessions.push(session);
2883
+ }
2884
+ }
2609
2885
  if (removed.length > 0) {
2610
- await writeState(persisted);
2886
+ const remote = raw.sessions.filter((session) => session.hostname !== host);
2887
+ await writeState({ version: "2", sessions: [...remote, ...sessions] });
2611
2888
  }
2612
- return { sessions, removed, persisted };
2889
+ return { sessions, removed };
2890
+ }
2891
+ async function inspectAndPrune(options) {
2892
+ const snapshot = await withFileLock(
2893
+ stateLockPath(),
2894
+ readStateRaw,
2895
+ options
2896
+ );
2897
+ const inspection = await inspectPruneCandidates(snapshot, options?.signal);
2898
+ return await withFileLock(
2899
+ stateLockPath(),
2900
+ async () => await persistPruneInspection(inspection, options?.signal),
2901
+ options
2902
+ );
2613
2903
  }
2614
2904
  async function readActiveSessions() {
2615
- const result = await withFileLock(stateLockPath(), readAndPruneLocked);
2905
+ const result = await inspectAndPrune();
2616
2906
  return result.sessions;
2617
2907
  }
2618
2908
  async function readSessionSnapshot(options) {
@@ -2621,12 +2911,7 @@ async function readSessionSnapshot(options) {
2621
2911
  }, options);
2622
2912
  }
2623
2913
  async function readAndPruneActiveSessions(options) {
2624
- const result = await withFileLock(
2625
- stateLockPath(),
2626
- async () => await readAndPruneLocked(options?.signal),
2627
- options
2628
- );
2629
- return { sessions: result.sessions, removed: result.removed };
2914
+ return await inspectAndPrune(options);
2630
2915
  }
2631
2916
  function sessionKeyString(key) {
2632
2917
  const base = `${key.region}:${key.org}:${key.space}:${key.app}`;
@@ -2659,34 +2944,41 @@ function matchesRegistrationTarget(session, input, target) {
2659
2944
  apiEndpoint: input.apiEndpoint
2660
2945
  });
2661
2946
  }
2662
- async function pickPort(preferred, reserved, probe, basePort, maxPort) {
2947
+ function portCandidates(preferred, range, scanOffset) {
2663
2948
  const candidates = preferred === void 0 ? [] : [preferred];
2664
- for (let port = basePort; port <= maxPort; port += 1) {
2949
+ const size = range.maxPort - range.basePort + 1;
2950
+ for (let index = 0; index < size; index += 1) {
2951
+ const port = range.basePort + (scanOffset + index) % size;
2665
2952
  if (port !== preferred) {
2666
2953
  candidates.push(port);
2667
2954
  }
2668
2955
  }
2669
- for (const port of candidates) {
2956
+ return candidates;
2957
+ }
2958
+ async function pickPort(preferred, reserved, probe, range, scanOffset) {
2959
+ for (const port of portCandidates(preferred, range, scanOffset)) {
2670
2960
  if (!reserved.has(port) && await probe(port)) {
2671
2961
  return port;
2672
2962
  }
2673
2963
  }
2674
2964
  throw new CfDebuggerError(
2675
2965
  "PORT_UNAVAILABLE",
2676
- `No free local port available in range ${basePort.toString()}\u2013${maxPort.toString()}`
2966
+ `No free local port available in range ${range.basePort.toString()}\u2013${range.maxPort.toString()}`
2677
2967
  );
2678
2968
  }
2679
- async function selectRegistrationCandidate(input, target, excluded) {
2969
+ async function selectRegistrationCandidate(input, target, excluded, range, scanOffset) {
2970
+ await readAndPruneActiveSessions(input.stateAccess);
2680
2971
  return await withFileLock(stateLockPath(), async () => {
2681
- const current = await readAndPruneLocked(input.stateAccess?.signal);
2682
- const existing = current.sessions.find(
2972
+ const raw = await readStateRaw();
2973
+ const local = raw.sessions.filter((session) => session.hostname === getHostname2());
2974
+ const existing = local.find(
2683
2975
  (session) => matchesRegistrationTarget(session, input, target)
2684
2976
  );
2685
2977
  if (existing !== void 0) {
2686
2978
  return { existing };
2687
2979
  }
2688
2980
  const reserved = /* @__PURE__ */ new Set([
2689
- ...current.sessions.map((session) => session.localPort),
2981
+ ...local.map((session) => session.localPort),
2690
2982
  ...excluded
2691
2983
  ]);
2692
2984
  return {
@@ -2694,20 +2986,58 @@ async function selectRegistrationCandidate(input, target, excluded) {
2694
2986
  input.preferredPort,
2695
2987
  reserved,
2696
2988
  () => Promise.resolve(true),
2697
- input.basePort ?? DEFAULT_BASE_PORT,
2698
- input.maxPort ?? DEFAULT_MAX_PORT
2989
+ range,
2990
+ scanOffset
2699
2991
  )
2700
2992
  };
2701
2993
  }, input.stateAccess);
2702
2994
  }
2995
+ function validateTcpPort(value, label) {
2996
+ if (!Number.isSafeInteger(value) || value <= 0 || value > 65535) {
2997
+ throw new CfDebuggerError("UNSAFE_INPUT", `${label} must be from 1 to 65535.`);
2998
+ }
2999
+ }
3000
+ function validatePortRange(input) {
3001
+ const basePort = input.basePort ?? DEFAULT_BASE_PORT;
3002
+ const maxPort = input.maxPort ?? DEFAULT_MAX_PORT;
3003
+ validateTcpPort(basePort, "Local port range start");
3004
+ validateTcpPort(maxPort, "Local port range end");
3005
+ if (basePort > maxPort) {
3006
+ throw new CfDebuggerError(
3007
+ "UNSAFE_INPUT",
3008
+ "Local port range start must not exceed its end."
3009
+ );
3010
+ }
3011
+ if (input.preferredPort !== void 0) {
3012
+ validateTcpPort(input.preferredPort, "Preferred local port");
3013
+ }
3014
+ return { basePort, maxPort };
3015
+ }
2703
3016
  function validateSessionInput(input) {
3017
+ validateCfCliOperand(input.region, "Region");
3018
+ validateCfCliOperand(input.org, "Cloud Foundry org");
3019
+ validateCfCliOperand(input.space, "Cloud Foundry space");
3020
+ validateCfCliOperand(input.app, "Cloud Foundry app");
3021
+ validateCfCliOperand(input.apiEndpoint, "Cloud Foundry API endpoint");
2704
3022
  const remotePort = input.remotePort ?? DEFAULT_NODE_INSPECTOR_PORT;
2705
- if (!Number.isSafeInteger(remotePort) || remotePort <= 0 || remotePort > 65535) {
2706
- throw new CfDebuggerError("UNSAFE_INPUT", "Remote inspector port must be from 1 to 65535.");
2707
- }
3023
+ validateTcpPort(remotePort, "Remote inspector port");
2708
3024
  if (input.startupTimeoutMs !== void 0 && (!Number.isSafeInteger(input.startupTimeoutMs) || input.startupTimeoutMs <= 0 || input.startupTimeoutMs > MAX_STARTUP_TIMEOUT_MS)) {
2709
3025
  throw new CfDebuggerError("UNSAFE_INPUT", "Persisted startup timeout is outside the supported range.");
2710
3026
  }
3027
+ return validatePortRange(input);
3028
+ }
3029
+ function registrationScanOffset(input, target, range) {
3030
+ const identity = JSON.stringify([
3031
+ input.apiEndpoint,
3032
+ input.region,
3033
+ input.org,
3034
+ input.space,
3035
+ input.app,
3036
+ target.process,
3037
+ target.instance
3038
+ ]);
3039
+ const hash = createHash("sha256").update(identity).digest().readUInt32BE(0);
3040
+ return hash % (range.maxPort - range.basePort + 1);
2711
3041
  }
2712
3042
  function createRegisteredSession(input, target, localPort, controllerProcessIdentity) {
2713
3043
  const sessionId = (input.sessionIdFactory ?? randomUUID2)();
@@ -2720,8 +3050,8 @@ function createRegisteredSession(input, target, localPort, controllerProcessIden
2720
3050
  }
2721
3051
  return {
2722
3052
  sessionId,
2723
- pid: nodeProcess5.pid,
2724
- controllerPid: nodeProcess5.pid,
3053
+ pid: nodeProcess6.pid,
3054
+ controllerPid: nodeProcess6.pid,
2725
3055
  ...controllerProcessIdentity === void 0 ? {} : { controllerProcessIdentity },
2726
3056
  hostname: getHostname2(),
2727
3057
  region: input.region,
@@ -2741,37 +3071,46 @@ function createRegisteredSession(input, target, localPort, controllerProcessIden
2741
3071
  };
2742
3072
  }
2743
3073
  async function persistRegistration(input, target, candidate, controllerProcessIdentity) {
3074
+ await readAndPruneActiveSessions(input.stateAccess);
2744
3075
  return await withFileLock(stateLockPath(), async () => {
2745
- const current = await readAndPruneLocked(input.stateAccess?.signal);
2746
- const existing = current.sessions.find(
3076
+ const raw = await readStateRaw();
3077
+ const local = raw.sessions.filter((session2) => session2.hostname === getHostname2());
3078
+ const existing = local.find(
2747
3079
  (session2) => matchesRegistrationTarget(session2, input, target)
2748
3080
  );
2749
3081
  if (existing !== void 0) {
2750
3082
  return { session: existing, existing };
2751
3083
  }
2752
- const reserved = current.sessions.some((session2) => session2.localPort === candidate);
3084
+ const reserved = local.some((session2) => session2.localPort === candidate);
2753
3085
  if (reserved || !await input.portProbe(candidate)) {
2754
3086
  return void 0;
2755
3087
  }
2756
3088
  const session = createRegisteredSession(input, target, candidate, controllerProcessIdentity);
2757
3089
  await writeState({
2758
3090
  version: "2",
2759
- sessions: [...current.persisted.sessions, session]
3091
+ sessions: [...raw.sessions, session]
2760
3092
  });
2761
3093
  return { session };
2762
3094
  }, input.stateAccess);
2763
3095
  }
2764
3096
  async function registerNewSession(input) {
2765
- validateSessionInput(input);
3097
+ const range = validateSessionInput(input);
2766
3098
  const target = resolveNodeTarget(input);
3099
+ const scanOffset = registrationScanOffset(input, target, range);
2767
3100
  const controllerIdentity = await readProcessIdentity(
2768
- nodeProcess5.pid,
3101
+ nodeProcess6.pid,
2769
3102
  input.stateAccess?.signal
2770
3103
  );
2771
3104
  const excluded = /* @__PURE__ */ new Set();
2772
- const maximumAttempts = (input.maxPort ?? DEFAULT_MAX_PORT) - (input.basePort ?? DEFAULT_BASE_PORT) + 2;
3105
+ const maximumAttempts = range.maxPort - range.basePort + 2;
2773
3106
  for (let attempt = 0; attempt < maximumAttempts; attempt += 1) {
2774
- const selection = await selectRegistrationCandidate(input, target, excluded);
3107
+ const selection = await selectRegistrationCandidate(
3108
+ input,
3109
+ target,
3110
+ excluded,
3111
+ range,
3112
+ scanOffset
3113
+ );
2775
3114
  if (selection.existing !== void 0) {
2776
3115
  return { session: selection.existing, existing: selection.existing };
2777
3116
  }
@@ -2885,9 +3224,6 @@ async function requestSessionStop(sessionId) {
2885
3224
  if (target === void 0) {
2886
3225
  return void 0;
2887
3226
  }
2888
- if (target.status === "ready") {
2889
- return { session: target, previousStatus: target.status };
2890
- }
2891
3227
  await writeSessionStopIntent(sessionId);
2892
3228
  if (target.stopRequestedAt !== void 0) {
2893
3229
  return { session: target, previousStatus: target.status };
@@ -2941,51 +3277,59 @@ function cliErrorExitCode(error) {
2941
3277
  }
2942
3278
  return hasCleanupFailure(error) ? CLEANUP_FAILURE_EXIT_CODE : 1;
2943
3279
  }
3280
+ function stopAllExitCode(errors) {
3281
+ if (errors.length === 0) {
3282
+ return void 0;
3283
+ }
3284
+ return errors.some((error) => hasTunnelTerminationFailure(error)) ? CLEANUP_FAILURE_EXIT_CODE : 1;
3285
+ }
2944
3286
 
2945
3287
  // src/debug-session/lifecycle.ts
2946
3288
  import { constants as osConstants } from "os";
2947
3289
 
2948
3290
  // src/debug-session/processes.ts
2949
3291
  import process3 from "process";
2950
- async function terminatePidOrGroup(pid, timeoutMs = CHILD_SIGTERM_GRACE_MS, pinnedTarget, verifyBeforeSignal) {
2951
- const targetKind = pinnedTarget ?? (isProcessGroupAlive(pid) ? "group" : "pid");
2952
- const targetAlive = () => targetKind === "group" ? isProcessGroupAlive(pid) : isPidAlive(pid);
2953
- if (!targetAlive()) {
2954
- return "terminated";
2955
- }
2956
- if (verifyBeforeSignal !== void 0 && !await verifyBeforeSignal("SIGTERM")) {
2957
- return "ownership-lost";
2958
- }
3292
+ function terminationTargetAlive(pid, targetKind) {
3293
+ return targetKind === "group" ? isProcessGroupAlive(pid) : isPidAlive(pid);
3294
+ }
3295
+ function signalTerminationTarget(pid, targetKind, signal) {
2959
3296
  try {
2960
- process3.kill(targetKind === "group" ? -pid : pid, "SIGTERM");
3297
+ process3.kill(targetKind === "group" ? -pid : pid, signal);
2961
3298
  } catch {
2962
3299
  }
3300
+ }
3301
+ async function waitForTargetExit(pid, targetKind, timeoutMs) {
2963
3302
  const startedAt = Date.now();
2964
3303
  while (Date.now() - startedAt < timeoutMs) {
2965
- if (!targetAlive()) {
2966
- return "terminated";
3304
+ if (!terminationTargetAlive(pid, targetKind)) {
3305
+ return true;
2967
3306
  }
2968
3307
  await new Promise((resolve) => {
2969
3308
  setTimeout(resolve, PID_TERMINATION_POLL_MS);
2970
3309
  });
2971
3310
  }
2972
- if (verifyBeforeSignal !== void 0 && !await verifyBeforeSignal("SIGKILL")) {
3311
+ return !terminationTargetAlive(pid, targetKind);
3312
+ }
3313
+ async function signalIsAuthorized(verifier, signal) {
3314
+ return verifier === void 0 || await verifier(signal);
3315
+ }
3316
+ async function terminatePidOrGroup(pid, timeoutMs = CHILD_SIGTERM_GRACE_MS, pinnedTarget, verifyBeforeSignal) {
3317
+ const targetKind = pinnedTarget ?? (isProcessGroupAlive(pid) ? "group" : "pid");
3318
+ if (!terminationTargetAlive(pid, targetKind)) {
3319
+ return "terminated";
3320
+ }
3321
+ if (!await signalIsAuthorized(verifyBeforeSignal, "SIGTERM")) {
2973
3322
  return "ownership-lost";
2974
3323
  }
2975
- try {
2976
- process3.kill(targetKind === "group" ? -pid : pid, "SIGKILL");
2977
- } catch {
3324
+ signalTerminationTarget(pid, targetKind, "SIGTERM");
3325
+ if (await waitForTargetExit(pid, targetKind, timeoutMs)) {
3326
+ return "terminated";
2978
3327
  }
2979
- const forceStartedAt = Date.now();
2980
- while (Date.now() - forceStartedAt < CHILD_SIGKILL_GRACE_MS) {
2981
- if (!targetAlive()) {
2982
- return "terminated";
2983
- }
2984
- await new Promise((resolve) => {
2985
- setTimeout(resolve, PID_TERMINATION_POLL_MS);
2986
- });
3328
+ if (!await signalIsAuthorized(verifyBeforeSignal, "SIGKILL")) {
3329
+ return "ownership-lost";
2987
3330
  }
2988
- return targetAlive() ? "still-alive" : "terminated";
3331
+ signalTerminationTarget(pid, targetKind, "SIGKILL");
3332
+ return await waitForTargetExit(pid, targetKind, CHILD_SIGKILL_GRACE_MS) ? "terminated" : "still-alive";
2989
3333
  }
2990
3334
  async function killProcessGroupOrProc(child, verifyBeforeSignal, timeoutMs = CHILD_SIGTERM_GRACE_MS) {
2991
3335
  if (child.pid === void 0) {
@@ -3004,7 +3348,39 @@ async function killProcessGroupOrProc(child, verifyBeforeSignal, timeoutMs = CHI
3004
3348
  }
3005
3349
 
3006
3350
  // src/debug-session/session-home.ts
3007
- import { rm } from "fs/promises";
3351
+ import { lstat, rm } from "fs/promises";
3352
+ import { dirname as dirname3 } from "path";
3353
+ function errorCode7(error) {
3354
+ if (typeof error !== "object" || error === null) {
3355
+ return void 0;
3356
+ }
3357
+ const code = Reflect.get(error, "code");
3358
+ return typeof code === "string" ? code : void 0;
3359
+ }
3360
+ async function inspectSessionHomesRoot(root) {
3361
+ try {
3362
+ const stats = await lstat(root);
3363
+ if (stats.isSymbolicLink()) {
3364
+ return {
3365
+ status: "unsafe",
3366
+ reason: `Debugger CF homes root ${root} is a symbolic link; refusing to traverse it.`
3367
+ };
3368
+ }
3369
+ return stats.isDirectory() ? { status: "safe" } : {
3370
+ status: "unsafe",
3371
+ reason: `Debugger CF homes root ${root} is not a directory; refusing to traverse it.`
3372
+ };
3373
+ } catch (error) {
3374
+ if (errorCode7(error) === "ENOENT") {
3375
+ return { status: "absent" };
3376
+ }
3377
+ const code = errorCode7(error) ?? "unknown error";
3378
+ return {
3379
+ status: "unsafe",
3380
+ reason: `Debugger CF homes root ${root} could not be inspected (${code}); refusing to traverse it.`
3381
+ };
3382
+ }
3383
+ }
3008
3384
  async function removeOwnedSessionCfHome(sessionId, candidate) {
3009
3385
  if (!isOwnedSessionCfHomeDir(sessionId, candidate)) {
3010
3386
  throw new CfDebuggerError(
@@ -3012,6 +3388,10 @@ async function removeOwnedSessionCfHome(sessionId, candidate) {
3012
3388
  `Refusing to remove unowned debugger CF home for session ${sessionId}.`
3013
3389
  );
3014
3390
  }
3391
+ const rootInspection = await inspectSessionHomesRoot(dirname3(candidate));
3392
+ if (rootInspection.status === "unsafe") {
3393
+ throw new CfDebuggerError("UNSAFE_INPUT", rootInspection.reason);
3394
+ }
3015
3395
  await rm(candidate, { recursive: true, force: true });
3016
3396
  }
3017
3397
  async function tryRemoveOwnedSessionCfHome(sessionId, candidate) {
@@ -3026,13 +3406,30 @@ async function tryRemoveOwnedSessionCfHome(sessionId, candidate) {
3026
3406
  }
3027
3407
  }
3028
3408
 
3409
+ // src/debug-session/session-cleanup.ts
3410
+ async function forgetSessionThenCleanupHome(session) {
3411
+ const removed = await removeSession(session.sessionId);
3412
+ if (!isOwnedSessionCfHomeDir(session.sessionId, session.cfHomeDir)) {
3413
+ return { homeStatus: "unowned", removed };
3414
+ }
3415
+ const homeRemoved = await tryRemoveOwnedSessionCfHome(
3416
+ session.sessionId,
3417
+ session.cfHomeDir
3418
+ );
3419
+ return {
3420
+ homeStatus: homeRemoved ? "removed" : "retained",
3421
+ removed
3422
+ };
3423
+ }
3424
+
3029
3425
  // src/debug-session/lifecycle.ts
3030
3426
  var handleLifecycles = /* @__PURE__ */ new WeakMap();
3031
3427
  function childIsOpen2(child) {
3032
3428
  return child.exitCode === null && child.signalCode === null;
3033
3429
  }
3034
3430
  function signalExitCode(signal) {
3035
- return 128 + osConstants.signals[signal];
3431
+ const signum = osConstants.signals[signal];
3432
+ return typeof signum === "number" && Number.isFinite(signum) ? 128 + signum : 1;
3036
3433
  }
3037
3434
  function unexpectedExitCode(code, signal) {
3038
3435
  if (signal !== null) {
@@ -3046,6 +3443,8 @@ function emitSafely(emit, status, message) {
3046
3443
  } catch {
3047
3444
  }
3048
3445
  }
3446
+ function ignoreCleanupFailure() {
3447
+ }
3049
3448
  async function runCleanupActions(actions, aggregateMessage) {
3050
3449
  const errors = [];
3051
3450
  for (const action of actions) {
@@ -3063,10 +3462,10 @@ async function runCleanupActions(actions, aggregateMessage) {
3063
3462
  }
3064
3463
  }
3065
3464
  async function handleTunnelClose(sessionId, code, signal, child, resolveExit, isFinalizing, hasFailed, markFailed, emit) {
3066
- const stopRequested = await hasSessionStopIntent(sessionId);
3465
+ const stopRequested = isFinalizing() ? false : await hasExpectedStopSignal(sessionId);
3067
3466
  const expected = isFinalizing() || stopRequested;
3068
3467
  if (stopRequested) {
3069
- await clearSessionStopIntent(sessionId);
3468
+ await clearSessionStopIntent(sessionId).catch(ignoreCleanupFailure);
3070
3469
  }
3071
3470
  if (!expected && !hasFailed()) {
3072
3471
  const detail = signal === null ? code === null ? "no exit status" : `exit code ${code.toString()}` : `signal ${signal}`;
@@ -3081,6 +3480,19 @@ async function handleTunnelClose(sessionId, code, signal, child, resolveExit, is
3081
3480
  }
3082
3481
  resolveExit(expected && !hasFailed() ? 0 : unexpectedExitCode(code, signal));
3083
3482
  }
3483
+ async function hasExpectedStopSignal(sessionId) {
3484
+ let sidecarRequested = false;
3485
+ try {
3486
+ sidecarRequested = await hasSessionStopIntent(sessionId);
3487
+ } catch {
3488
+ }
3489
+ try {
3490
+ const verdict = await inspectSessionStateStopIntent(sessionId);
3491
+ return verdict === "requested" || verdict === "missing" && sidecarRequested;
3492
+ } catch {
3493
+ return false;
3494
+ }
3495
+ }
3084
3496
  function attachTunnelEvents(sessionId, child, resolveExit, isFinalizing, hasFailed, markFailed, emit) {
3085
3497
  child.once("close", (code, signal) => {
3086
3498
  void handleTunnelClose(
@@ -3123,87 +3535,126 @@ async function expectedTunnelStillOwned(session, child) {
3123
3535
  return ownership.status === "unverified" ? "unverified" : ownership.status === "owned";
3124
3536
  }
3125
3537
  async function cleanupFinishedSession(session) {
3126
- await removeOwnedSessionCfHome(session.sessionId, session.cfHomeDir);
3127
- await removeSession(session.sessionId);
3128
- await clearSessionStopIntent(session.sessionId);
3538
+ const cleanup = await forgetSessionThenCleanupHome(session);
3539
+ await clearSessionStopIntent(session.sessionId).catch(ignoreCleanupFailure);
3540
+ if (cleanup.homeStatus !== "removed") {
3541
+ throw new CfDebuggerError(
3542
+ "CF_HOME_CLEANUP_FAILED",
3543
+ cleanup.homeStatus === "retained" ? `Session state was removed, but owned CF home ${session.cfHomeDir} could not be deleted. It may contain a live CF refresh token; remove it manually or run \`cf-debugger doctor --cleanup\`.` : `Session state was removed, but unowned CF home ${session.cfHomeDir} was not deleted.`
3544
+ );
3545
+ }
3129
3546
  }
3130
- function createTunnelLifecycle(session, emit) {
3131
- let child;
3132
- let childIdentity;
3133
- let childFailed = false;
3134
- let tunnelError;
3135
- let finalizing = false;
3547
+ function initializeTunnelLifecycle() {
3136
3548
  let exitResolve = (_code) => {
3137
3549
  throw new Error("Tunnel exit resolver was used before initialization.");
3138
3550
  };
3139
3551
  const exitPromise = new Promise((resolve) => {
3140
3552
  exitResolve = resolve;
3141
3553
  });
3142
- const observeChild = (tunnelChild) => {
3143
- child = tunnelChild;
3144
- childIdentity = tunnelChild.pid === void 0 ? void 0 : readProcessIdentity(tunnelChild.pid);
3145
- attachTunnelEvents(
3146
- session.sessionId,
3147
- tunnelChild,
3148
- exitResolve,
3149
- () => finalizing,
3150
- () => childFailed,
3151
- (error) => {
3152
- if (!childFailed) {
3153
- childFailed = true;
3154
- tunnelError = error;
3155
- }
3156
- },
3157
- emit
3158
- );
3159
- };
3160
- const verifyBeforeSignal = async (signal) => {
3161
- const tunnelChild = child;
3162
- const childPid = tunnelChild?.pid;
3163
- if (tunnelChild === void 0 || childPid === void 0 || !childIsOpen2(tunnelChild)) {
3164
- return false;
3165
- }
3166
- const expectedIdentity = await childIdentity;
3167
- if (expectedIdentity !== void 0) {
3168
- return await inspectProcessIdentity(childPid, expectedIdentity) === "match" && childIsOpen2(tunnelChild);
3169
- }
3170
- if (signal === "SIGKILL") {
3171
- return await expectedTunnelStillOwned(session, tunnelChild) === true;
3554
+ return {
3555
+ exitPromise,
3556
+ state: {
3557
+ child: void 0,
3558
+ childIdentity: void 0,
3559
+ childFailed: false,
3560
+ tunnelError: void 0,
3561
+ finalizing: false,
3562
+ exitResolve
3172
3563
  }
3173
- return true;
3174
3564
  };
3175
- const finalize = async (emitStopped) => {
3176
- finalizing = true;
3177
- const termination = child === void 0 ? "terminated" : await killProcessGroupOrProc(child, verifyBeforeSignal);
3178
- const stillOwned = await expectedTunnelStillOwned(session, child);
3179
- if (termination !== "terminated" || stillOwned === true || stillOwned === "unverified") {
3180
- const stderr = child === void 0 ? void 0 : formatTunnelDiagnostics(getTunnelDiagnostics(child));
3181
- throw new CfDebuggerError(
3182
- "TUNNEL_TERMINATION_FAILED",
3183
- `Tunnel for session ${session.sessionId} did not terminate cleanly; state and CF home were retained.`,
3184
- stderr
3185
- );
3186
- }
3187
- await cleanupFinishedSession(session);
3188
- if (emitStopped) {
3189
- emitSafely(emit, "stopped");
3190
- }
3565
+ }
3566
+ function recordTunnelFailure(state, error) {
3567
+ if (!state.childFailed) {
3568
+ state.childFailed = true;
3569
+ state.tunnelError = error;
3570
+ }
3571
+ }
3572
+ function observeTunnelChild(state, sessionId, tunnelChild, emit) {
3573
+ state.child = tunnelChild;
3574
+ state.childIdentity = tunnelChild.pid === void 0 ? void 0 : readProcessIdentity(tunnelChild.pid);
3575
+ attachTunnelEvents(
3576
+ sessionId,
3577
+ tunnelChild,
3578
+ state.exitResolve,
3579
+ () => state.finalizing,
3580
+ () => state.childFailed,
3581
+ (error) => {
3582
+ recordTunnelFailure(state, error);
3583
+ },
3584
+ emit
3585
+ );
3586
+ }
3587
+ async function verifyTunnelBeforeSignal(state, session, signal) {
3588
+ const tunnelChild = state.child;
3589
+ const childPid = tunnelChild?.pid;
3590
+ if (tunnelChild === void 0 || childPid === void 0 || !childIsOpen2(tunnelChild)) {
3591
+ return false;
3592
+ }
3593
+ const expectedIdentity = await state.childIdentity;
3594
+ if (expectedIdentity !== void 0) {
3595
+ return await inspectProcessIdentity(childPid, expectedIdentity) === "match" && childIsOpen2(tunnelChild);
3596
+ }
3597
+ if (signal === "SIGKILL") {
3598
+ return await expectedTunnelStillOwned(session, tunnelChild) === true;
3599
+ }
3600
+ return true;
3601
+ }
3602
+ function tunnelTerminationError(session, child) {
3603
+ const stderr = child === void 0 ? void 0 : formatTunnelDiagnostics(getTunnelDiagnostics(child));
3604
+ return new CfDebuggerError(
3605
+ "TUNNEL_TERMINATION_FAILED",
3606
+ `Tunnel for session ${session.sessionId} did not terminate cleanly; state and CF home were retained.`,
3607
+ stderr
3608
+ );
3609
+ }
3610
+ async function finalizeTunnelLifecycle(state, session, emit, emitStopped) {
3611
+ state.finalizing = true;
3612
+ const verify = async (signal) => {
3613
+ return await verifyTunnelBeforeSignal(state, session, signal);
3191
3614
  };
3615
+ const termination = state.child === void 0 ? "terminated" : await killProcessGroupOrProc(state.child, verify);
3616
+ const stillOwned = await expectedTunnelStillOwned(session, state.child);
3617
+ if (termination !== "terminated" || stillOwned === true) {
3618
+ throw tunnelTerminationError(session, state.child);
3619
+ }
3620
+ if (stillOwned === "unverified" && emitStopped) {
3621
+ emitSafely(
3622
+ emit,
3623
+ "stopping",
3624
+ "Tunnel process termination was confirmed, but local port ownership could not be rechecked. On macOS, install lsof to restore that diagnostic."
3625
+ );
3626
+ }
3627
+ await cleanupFinishedSession(session);
3628
+ if (emitStopped) {
3629
+ emitSafely(emit, "stopped");
3630
+ }
3631
+ }
3632
+ function assertTunnelRunning(state, session) {
3633
+ if (state.child !== void 0 && childIsOpen2(state.child) && !state.childFailed) {
3634
+ return;
3635
+ }
3636
+ throw new CfDebuggerError(
3637
+ "TUNNEL_NOT_READY",
3638
+ `SSH tunnel for session ${session.sessionId} exited before readiness could be committed.`,
3639
+ state.child === void 0 ? void 0 : formatTunnelDiagnostics(getTunnelDiagnostics(state.child))
3640
+ );
3641
+ }
3642
+ function createTunnelLifecycle(session, emit) {
3643
+ const initialized = initializeTunnelLifecycle();
3644
+ const { state } = initialized;
3192
3645
  return {
3193
- exitPromise,
3646
+ exitPromise: initialized.exitPromise,
3194
3647
  assertRunning: () => {
3195
- if (child === void 0 || !childIsOpen2(child) || childFailed) {
3196
- throw new CfDebuggerError(
3197
- "TUNNEL_NOT_READY",
3198
- `SSH tunnel for session ${session.sessionId} exited before readiness could be committed.`,
3199
- child === void 0 ? void 0 : formatTunnelDiagnostics(getTunnelDiagnostics(child))
3200
- );
3201
- }
3648
+ assertTunnelRunning(state, session);
3202
3649
  },
3203
- finalize,
3204
- observeChild,
3205
- failed: () => childFailed,
3206
- error: () => tunnelError
3650
+ finalize: async (emitStopped) => {
3651
+ await finalizeTunnelLifecycle(state, session, emit, emitStopped);
3652
+ },
3653
+ observeChild: (child) => {
3654
+ observeTunnelChild(state, session.sessionId, child, emit);
3655
+ },
3656
+ failed: () => state.childFailed,
3657
+ error: () => state.tunnelError
3207
3658
  };
3208
3659
  }
3209
3660
  function createDebuggerHandle(session, emit, lifecycle) {
@@ -3311,7 +3762,7 @@ function throwIfStartupAborted(signal, expiresAt, timeoutMs, phase) {
3311
3762
 
3312
3763
  // src/debug-session/start.ts
3313
3764
  import { chmod as chmod4, mkdir as mkdir4 } from "fs/promises";
3314
- import nodeProcess6 from "process";
3765
+ import nodeProcess7 from "process";
3315
3766
 
3316
3767
  // src/debug-session/orphans.ts
3317
3768
  import { readdir as readdir2, stat as stat2, unlink as unlink4 } from "fs/promises";
@@ -3354,6 +3805,16 @@ async function pruneAndCleanupOrphans(stateAccess) {
3354
3805
 
3355
3806
  // src/debug-session/startup-cancellation.ts
3356
3807
  var STOP_REQUEST_POLL_MS = 500;
3808
+ async function startupStopWasRequested(sessionId) {
3809
+ try {
3810
+ if (await hasSessionStopIntent(sessionId)) {
3811
+ return true;
3812
+ }
3813
+ } catch {
3814
+ }
3815
+ const stateVerdict = await inspectSessionStateStopIntent(sessionId);
3816
+ return stateVerdict === "missing" || stateVerdict === "requested";
3817
+ }
3357
3818
  function createStartupCancellation(sessionId, callerSignal) {
3358
3819
  const controller = new AbortController();
3359
3820
  let active = true;
@@ -3369,7 +3830,7 @@ function createStartupCancellation(sessionId, callerSignal) {
3369
3830
  };
3370
3831
  const poll = async () => {
3371
3832
  try {
3372
- if (active && await hasSessionStopIntent(sessionId)) {
3833
+ if (active && await startupStopWasRequested(sessionId)) {
3373
3834
  controller.abort();
3374
3835
  }
3375
3836
  } catch {
@@ -3553,6 +4014,8 @@ async function signalRemoteNode(inputs) {
3553
4014
  }
3554
4015
 
3555
4016
  // src/debug-session/startup-tunnel.ts
4017
+ var INSPECTOR_READY_RESERVE_MS = 1e4;
4018
+ var OWNER_READY_ATTEMPT_MAX_MS = 5e3;
3556
4019
  function linkAbortSignals(signals) {
3557
4020
  const controller = new AbortController();
3558
4021
  const subscriptions = [];
@@ -3576,6 +4039,35 @@ function linkAbortSignals(signals) {
3576
4039
  }
3577
4040
  };
3578
4041
  }
4042
+ function observeChildExit(child) {
4043
+ let listening = true;
4044
+ let resolveExit;
4045
+ const onClose = () => {
4046
+ listening = false;
4047
+ resolveExit?.();
4048
+ };
4049
+ if (child.exitCode !== null || child.signalCode !== null) {
4050
+ return {
4051
+ promise: Promise.resolve(),
4052
+ dispose: () => {
4053
+ child.removeListener("close", onClose);
4054
+ }
4055
+ };
4056
+ }
4057
+ const promise = new Promise((resolve) => {
4058
+ resolveExit = resolve;
4059
+ child.once("close", onClose);
4060
+ });
4061
+ return {
4062
+ promise,
4063
+ dispose: () => {
4064
+ if (listening) {
4065
+ listening = false;
4066
+ child.removeListener("close", onClose);
4067
+ }
4068
+ }
4069
+ };
4070
+ }
3579
4071
  function diagnosticsStderr(child) {
3580
4072
  return formatTunnelDiagnostics(getTunnelDiagnostics(child));
3581
4073
  }
@@ -3596,6 +4088,33 @@ function remainingReadyTimeout(inputs) {
3596
4088
  Math.min(inputs.tunnelReadyTimeoutMs, inputs.context.deadlineAt - Date.now())
3597
4089
  );
3598
4090
  }
4091
+ function createReadyWindow(timeoutMs) {
4092
+ const totalMs = Math.max(1, timeoutMs);
4093
+ const inspectorReserveMs = Math.min(
4094
+ INSPECTOR_READY_RESERVE_MS,
4095
+ Math.floor(totalMs / 2)
4096
+ );
4097
+ const ownerReserveMs = Math.min(
4098
+ OWNER_READY_ATTEMPT_MAX_MS * 2,
4099
+ Math.floor((totalMs - inspectorReserveMs) / 2)
4100
+ );
4101
+ const ownerTimeoutMs = Math.max(1, Math.floor(ownerReserveMs / 2));
4102
+ return {
4103
+ deadlineAt: Date.now() + totalMs,
4104
+ finalOwnerReserveMs: ownerTimeoutMs,
4105
+ localTimeoutMs: Math.max(1, totalMs - inspectorReserveMs - ownerReserveMs),
4106
+ ownerTimeoutMs
4107
+ };
4108
+ }
4109
+ function remainingWindowMs(window) {
4110
+ return Math.max(0, window.deadlineAt - Date.now());
4111
+ }
4112
+ function remainingInspectorMs(window) {
4113
+ return Math.max(
4114
+ 1,
4115
+ remainingWindowMs(window) - window.finalOwnerReserveMs
4116
+ );
4117
+ }
3599
4118
  function startupStateAccess(inputs) {
3600
4119
  return {
3601
4120
  ...inputs.context.signal === void 0 ? {} : { signal: inputs.context.signal },
@@ -3620,6 +4139,7 @@ function ownerVerificationError(localPort, inspection, child) {
3620
4139
  }
3621
4140
  async function waitForLocalTunnel(inputs, child, timeoutMs) {
3622
4141
  const childEnded = new AbortController();
4142
+ const childExit = observeChildExit(child);
3623
4143
  const linked = linkAbortSignals([
3624
4144
  ...inputs.context.signal === void 0 ? [] : [inputs.context.signal],
3625
4145
  childEnded.signal
@@ -3628,7 +4148,7 @@ async function waitForLocalTunnel(inputs, child, timeoutMs) {
3628
4148
  const probe = probeTunnelReady(inputs.session.localPort, timeoutMs, linked.signal);
3629
4149
  const winner = await Promise.race([
3630
4150
  probe.then((ready) => ({ kind: "probe", ready })),
3631
- childExitPromise(child).then(() => ({ kind: "child-exit" }))
4151
+ childExit.promise.then(() => ({ kind: "child-exit" }))
3632
4152
  ]);
3633
4153
  if (winner.kind === "probe" && winner.ready) {
3634
4154
  return;
@@ -3638,6 +4158,7 @@ async function waitForLocalTunnel(inputs, child, timeoutMs) {
3638
4158
  await probe.catch(() => false);
3639
4159
  }
3640
4160
  } finally {
4161
+ childExit.dispose();
3641
4162
  linked.dispose();
3642
4163
  }
3643
4164
  throw new CfDebuggerError(
@@ -3646,28 +4167,38 @@ async function waitForLocalTunnel(inputs, child, timeoutMs) {
3646
4167
  diagnosticsStderr(child)
3647
4168
  );
3648
4169
  }
3649
- async function verifyLocalOwner(inputs, child, childPid) {
3650
- const ownership = await inspectPortOwnership(
3651
- inputs.session.localPort,
3652
- childPid,
3653
- inputs.context.signal
3654
- );
4170
+ async function verifyLocalOwner(inputs, child, childPid, timeoutMs) {
4171
+ const phaseTimeout = AbortSignal.timeout(Math.max(1, Math.floor(timeoutMs)));
4172
+ const linked = linkAbortSignals([
4173
+ ...inputs.context.signal === void 0 ? [] : [inputs.context.signal],
4174
+ phaseTimeout
4175
+ ]);
4176
+ let ownership;
4177
+ try {
4178
+ ownership = await inspectPortOwnership(
4179
+ inputs.session.localPort,
4180
+ childPid,
4181
+ linked.signal
4182
+ );
4183
+ } catch (error) {
4184
+ if (phaseTimeout.aborted && inputs.context.signal?.aborted !== true) {
4185
+ ownership = {
4186
+ status: "unverified",
4187
+ reason: `ownership inspection exceeded its ${Math.round(timeoutMs).toString()}ms readiness budget`
4188
+ };
4189
+ } else {
4190
+ throw error;
4191
+ }
4192
+ } finally {
4193
+ linked.dispose();
4194
+ }
3655
4195
  if (ownership.status !== "owned") {
3656
4196
  throw ownerVerificationError(inputs.session.localPort, ownership, child);
3657
4197
  }
3658
4198
  }
3659
- function childExitPromise(child) {
3660
- if (child.exitCode !== null || child.signalCode !== null) {
3661
- return Promise.resolve();
3662
- }
3663
- return new Promise((resolve) => {
3664
- child.once("close", () => {
3665
- resolve();
3666
- });
3667
- });
3668
- }
3669
4199
  async function verifyInspector(inputs, child, timeoutMs) {
3670
4200
  const childEnded = new AbortController();
4201
+ const childExit = observeChildExit(child);
3671
4202
  const linked = linkAbortSignals([
3672
4203
  ...inputs.context.signal === void 0 ? [] : [inputs.context.signal],
3673
4204
  childEnded.signal
@@ -3676,7 +4207,7 @@ async function verifyInspector(inputs, child, timeoutMs) {
3676
4207
  const probe = probeInspectorReady(inputs.session.localPort, timeoutMs, linked.signal);
3677
4208
  const winner = await Promise.race([
3678
4209
  probe.then((result) => ({ kind: "probe", result })),
3679
- childExitPromise(child).then(() => ({ kind: "child-exit" }))
4210
+ childExit.promise.then(() => ({ kind: "child-exit" }))
3680
4211
  ]);
3681
4212
  if (winner.kind === "probe" && winner.result.status === "ready") {
3682
4213
  return;
@@ -3686,6 +4217,7 @@ async function verifyInspector(inputs, child, timeoutMs) {
3686
4217
  await probe.catch(() => ({ status: "unreachable" }));
3687
4218
  }
3688
4219
  } finally {
4220
+ childExit.dispose();
3689
4221
  linked.dispose();
3690
4222
  }
3691
4223
  throw new CfDebuggerError(
@@ -3743,15 +4275,14 @@ async function openReadyTunnel(inputs) {
3743
4275
  inputs.onChild(child);
3744
4276
  const childPid = requireChildPid(child);
3745
4277
  await recordTunnelPid(inputs, childPid);
3746
- const timeoutMs = remainingReadyTimeout(inputs);
3747
- const readyDeadline = Date.now() + timeoutMs;
3748
- await waitForLocalTunnel(inputs, child, timeoutMs);
4278
+ const readyWindow = createReadyWindow(remainingReadyTimeout(inputs));
4279
+ await waitForLocalTunnel(inputs, child, readyWindow.localTimeoutMs);
3749
4280
  ensureStartupActive(inputs, "local tunnel binding");
3750
- await verifyLocalOwner(inputs, child, childPid);
4281
+ await verifyLocalOwner(inputs, child, childPid, readyWindow.ownerTimeoutMs);
3751
4282
  ensureStartupActive(inputs, "local tunnel ownership verification");
3752
- await verifyInspector(inputs, child, Math.max(1, readyDeadline - Date.now()));
4283
+ await verifyInspector(inputs, child, remainingInspectorMs(readyWindow));
3753
4284
  ensureStartupActive(inputs, "inspector readiness verification");
3754
- await verifyLocalOwner(inputs, child, childPid);
4285
+ await verifyLocalOwner(inputs, child, childPid, remainingWindowMs(readyWindow));
3755
4286
  ensureStartupActive(inputs, "final local tunnel ownership verification");
3756
4287
  if (child.exitCode !== null || child.signalCode !== null) {
3757
4288
  throw new CfDebuggerError(
@@ -3763,9 +4294,17 @@ async function openReadyTunnel(inputs) {
3763
4294
  }
3764
4295
 
3765
4296
  // src/debug-session/start.ts
4297
+ function validateStartTarget(options) {
4298
+ return {
4299
+ ...options,
4300
+ app: validateCfCliOperand(options.app, "app"),
4301
+ org: validateCfCliOperand(options.org, "org"),
4302
+ space: validateCfCliOperand(options.space, "space")
4303
+ };
4304
+ }
3766
4305
  function requireCredentials(options) {
3767
- const email = options.email ?? nodeProcess6.env["SAP_EMAIL"];
3768
- const password = options.password ?? nodeProcess6.env["SAP_PASSWORD"];
4306
+ const email = options.email ?? nodeProcess7.env["SAP_EMAIL"];
4307
+ const password = options.password ?? nodeProcess7.env["SAP_PASSWORD"];
3769
4308
  if (email === void 0 || email.length === 0) {
3770
4309
  throw new CfDebuggerError(
3771
4310
  "MISSING_CREDENTIALS",
@@ -3951,11 +4490,11 @@ async function establishDebuggerSession(inputs) {
3951
4490
  return readySession;
3952
4491
  }
3953
4492
  function writeWarning(message) {
3954
- nodeProcess6.stderr.write(`[cf-debugger] warning: ${message}
4493
+ nodeProcess7.stderr.write(`[cf-debugger] warning: ${message}
3955
4494
  `);
3956
4495
  }
3957
4496
  function writeTunnelOutput(stream, text) {
3958
- nodeProcess6.stderr.write(`[cf-debugger tunnel ${stream}] ${text}
4497
+ nodeProcess7.stderr.write(`[cf-debugger tunnel ${stream}] ${text}
3959
4498
  `);
3960
4499
  }
3961
4500
  function retryMessage(status) {
@@ -3997,59 +4536,102 @@ function normalizeStartupError(error, expiresAt, timeoutMs) {
3997
4536
  }
3998
4537
  return error;
3999
4538
  }
4539
+ function resolveStartupPlan(options) {
4540
+ const validatedOptions = validateStartTarget(options);
4541
+ const restartAllowed = applyRestartEnvironmentVeto(
4542
+ validatedOptions.allowSshEnableRestart,
4543
+ nodeProcess7.env["CF_DEBUGGER_ALLOW_RESTART"]
4544
+ );
4545
+ const effectiveOptions = restartAllowed === validatedOptions.allowSshEnableRestart ? validatedOptions : { ...validatedOptions, allowSshEnableRestart: restartAllowed };
4546
+ const target = resolveNodeTarget(effectiveOptions);
4547
+ const credentials = requireCredentials(effectiveOptions);
4548
+ const apiEndpoint = resolveApiEndpoint(
4549
+ effectiveOptions.region,
4550
+ effectiveOptions.apiEndpoint,
4551
+ writeWarning
4552
+ );
4553
+ const tunnelReadyTimeoutMs = resolveTunnelReadyTimeoutMs(
4554
+ effectiveOptions.tunnelReadyTimeoutMs
4555
+ );
4556
+ return {
4557
+ apiEndpoint,
4558
+ credentials,
4559
+ options: effectiveOptions,
4560
+ target,
4561
+ tracker: createStatusTracker(effectiveOptions),
4562
+ tunnelReadyTimeoutMs
4563
+ };
4564
+ }
4565
+ async function completeStartup(plan, deadline, resources) {
4566
+ throwIfStartupAborted(
4567
+ deadline.signal,
4568
+ deadline.expiresAt,
4569
+ deadline.timeoutMs,
4570
+ "initialization"
4571
+ );
4572
+ await pruneAndCleanupOrphans(startupStateAccess2(deadline.signal, deadline.expiresAt));
4573
+ throwIfStartupAborted(
4574
+ deadline.signal,
4575
+ deadline.expiresAt,
4576
+ deadline.timeoutMs,
4577
+ "state cleanup"
4578
+ );
4579
+ const session = await registerSession(
4580
+ plan.options,
4581
+ plan.target,
4582
+ plan.apiEndpoint,
4583
+ deadline
4584
+ );
4585
+ resources.cancellation = createStartupCancellation(session.sessionId, deadline.signal);
4586
+ const context = createCfContext(
4587
+ session,
4588
+ plan.credentials,
4589
+ resources.cancellation,
4590
+ deadline,
4591
+ plan.tracker,
4592
+ plan.options.verbose === true
4593
+ );
4594
+ resources.lifecycle = createTunnelLifecycle(session, plan.tracker.emit);
4595
+ const activeSession = await establishDebuggerSession({
4596
+ options: { ...plan.options, remotePort: session.remotePort },
4597
+ target: plan.target,
4598
+ session,
4599
+ context,
4600
+ credentials: plan.credentials,
4601
+ tunnelReadyTimeoutMs: plan.tunnelReadyTimeoutMs,
4602
+ emit: plan.tracker.emit,
4603
+ transition: createTransition(session.sessionId, context),
4604
+ lifecycle: resources.lifecycle
4605
+ });
4606
+ resources.cancellation.dispose();
4607
+ deadline.dispose();
4608
+ return createDebuggerHandle(activeSession, plan.tracker.emit, resources.lifecycle);
4609
+ }
4610
+ async function handleStartupFailure(error, plan, resources, deadline) {
4611
+ resources.cancellation?.dispose();
4612
+ deadline.dispose();
4613
+ const normalized = normalizeStartupError(
4614
+ error,
4615
+ deadline.expiresAt,
4616
+ deadline.timeoutMs
4617
+ );
4618
+ if (resources.lifecycle !== void 0) {
4619
+ return await cleanupFailedStartup(normalized, resources.lifecycle, plan.tracker.emit);
4620
+ }
4621
+ plan.tracker.emit(
4622
+ "error",
4623
+ normalized instanceof Error ? normalized.message : String(normalized)
4624
+ );
4625
+ throw normalized;
4626
+ }
4000
4627
  async function startDebuggerUsingDeadline(options, deadline) {
4001
- const target = resolveNodeTarget(options);
4002
- const credentials = requireCredentials(options);
4003
- const apiEndpoint = resolveApiEndpoint(options.region, options.apiEndpoint, writeWarning);
4004
- const startupTimeoutMs = deadline.timeoutMs;
4005
- const tunnelReadyTimeoutMs = resolveTunnelReadyTimeoutMs(options.tunnelReadyTimeoutMs);
4006
- const tracker = createStatusTracker(options);
4007
- tracker.emit("starting");
4008
- let cancellation;
4009
- let lifecycle;
4628
+ const plan = resolveStartupPlan(options);
4629
+ const resources = {};
4630
+ plan.tracker.emit("starting");
4010
4631
  try {
4011
- throwIfStartupAborted(deadline.signal, deadline.expiresAt, startupTimeoutMs, "initialization");
4012
- await pruneAndCleanupOrphans(
4013
- startupStateAccess2(deadline.signal, deadline.expiresAt)
4014
- );
4015
- throwIfStartupAborted(deadline.signal, deadline.expiresAt, startupTimeoutMs, "state cleanup");
4016
- const session = await registerSession(options, target, apiEndpoint, deadline);
4017
- cancellation = createStartupCancellation(session.sessionId, deadline.signal);
4018
- const context = createCfContext(
4019
- session,
4020
- credentials,
4021
- cancellation,
4022
- deadline,
4023
- tracker,
4024
- options.verbose === true
4025
- );
4026
- lifecycle = createTunnelLifecycle(session, tracker.emit);
4027
- const activeSession = await establishDebuggerSession({
4028
- options: { ...options, remotePort: session.remotePort },
4029
- target,
4030
- session,
4031
- context,
4032
- credentials,
4033
- tunnelReadyTimeoutMs,
4034
- emit: tracker.emit,
4035
- transition: createTransition(session.sessionId, context),
4036
- lifecycle
4037
- });
4038
- cancellation.dispose();
4039
- deadline.dispose();
4040
- return createDebuggerHandle(activeSession, tracker.emit, lifecycle);
4632
+ return await completeStartup(plan, deadline, resources);
4041
4633
  } catch (error) {
4042
- cancellation?.dispose();
4043
- deadline.dispose();
4044
- const normalized = normalizeStartupError(error, deadline.expiresAt, startupTimeoutMs);
4045
- if (lifecycle === void 0) {
4046
- tracker.emit(
4047
- "error",
4048
- normalized instanceof Error ? normalized.message : String(normalized)
4049
- );
4050
- throw normalized;
4051
- }
4052
- return await cleanupFailedStartup(normalized, lifecycle, tracker.emit);
4634
+ return await handleStartupFailure(error, plan, resources, deadline);
4053
4635
  }
4054
4636
  }
4055
4637
  async function startDebuggerWithinDeadline(options, deadline) {
@@ -4068,31 +4650,261 @@ async function startDebugger(options) {
4068
4650
 
4069
4651
  // src/debug-session/doctor.ts
4070
4652
  import {
4071
- lstat,
4072
- readFile as readFile5,
4073
- readdir as readdir3,
4653
+ lstat as lstat3,
4654
+ readFile as readFile7,
4655
+ readdir as readdir5,
4074
4656
  unlink as unlink5
4075
4657
  } from "fs/promises";
4076
- import { hostname as hostname2 } from "os";
4658
+ import { hostname as hostname4 } from "os";
4659
+ import { join as join5 } from "path";
4660
+ import nodeProcess10 from "process";
4661
+
4662
+ // src/debug-session/doctor-homes.ts
4663
+ import { readdir as readdir3 } from "fs/promises";
4077
4664
  import { join as join3 } from "path";
4078
- import nodeProcess7 from "process";
4665
+ function entryReason(symbolicLink, directory, cleanupEligible) {
4666
+ if (symbolicLink) {
4667
+ return "entry is a symbolic link, not a debugger CF home directory";
4668
+ }
4669
+ if (!directory) {
4670
+ return "entry is not a directory";
4671
+ }
4672
+ return cleanupEligible ? void 0 : "entry name is not a safe debugger session ID";
4673
+ }
4674
+ async function discoverOrphanHomes(sessions) {
4675
+ const root = join3(saptoolsDir(), CF_DEBUGGER_HOMES_DIRNAME);
4676
+ const rootInspection = await inspectSessionHomesRoot(root);
4677
+ if (rootInspection.status === "unsafe") {
4678
+ return { candidates: [], warnings: [rootInspection.reason] };
4679
+ }
4680
+ if (rootInspection.status === "absent") {
4681
+ return { candidates: [], warnings: [] };
4682
+ }
4683
+ const claimed = new Set(sessions.map((session) => session.sessionId));
4684
+ const entries = await readdir3(root, { withFileTypes: true });
4685
+ const candidates = entries.filter((entry) => !entry.isDirectory() || !claimed.has(entry.name)).sort((left, right) => left.name.localeCompare(right.name)).map((entry) => {
4686
+ const path = join3(root, entry.name);
4687
+ const cleanupEligible = entry.isDirectory() && isOwnedSessionCfHomeDir(entry.name, path);
4688
+ const reason = entryReason(
4689
+ entry.isSymbolicLink(),
4690
+ entry.isDirectory(),
4691
+ cleanupEligible
4692
+ );
4693
+ return {
4694
+ sessionId: entry.name,
4695
+ path,
4696
+ cleanupEligible,
4697
+ ...reason === void 0 ? {} : { reason }
4698
+ };
4699
+ });
4700
+ return { candidates, warnings: [] };
4701
+ }
4702
+
4703
+ // src/debug-session/doctor-legacy.ts
4704
+ import { lstat as lstat2, readFile as readFile6, readdir as readdir4 } from "fs/promises";
4705
+ import { hostname as hostname2 } from "os";
4706
+ import { join as join4 } from "path";
4707
+ import nodeProcess8 from "process";
4708
+ var LEGACY_STATE_FILENAME = "cf-debugger-state.json";
4709
+ var LEGACY_LOCK_FILENAME = "cf-debugger-state.lock";
4710
+ var LEGACY_HOMES_DIRNAME = "cf-debugger-homes";
4711
+ function errorCode8(error) {
4712
+ if (typeof error !== "object" || error === null) {
4713
+ return void 0;
4714
+ }
4715
+ const code = Reflect.get(error, "code");
4716
+ return typeof code === "string" ? code : void 0;
4717
+ }
4718
+ function errorMessage(error) {
4719
+ return error instanceof Error ? error.message : String(error);
4720
+ }
4721
+ function field3(value, key) {
4722
+ return Reflect.get(value, key);
4723
+ }
4724
+ function optionalText(value, key) {
4725
+ const candidate = field3(value, key);
4726
+ return typeof candidate === "string" && candidate.length > 0 ? candidate : void 0;
4727
+ }
4728
+ function optionalInteger2(value, key, maximum = Number.MAX_SAFE_INTEGER) {
4729
+ const candidate = field3(value, key);
4730
+ return typeof candidate === "number" && Number.isSafeInteger(candidate) && candidate > 0 && candidate <= maximum ? candidate : void 0;
4731
+ }
4732
+ function inspectLegacyPid(pid) {
4733
+ try {
4734
+ nodeProcess8.kill(pid, 0);
4735
+ return {
4736
+ liveness: "alive",
4737
+ reason: "PID exists, but v1 state cannot prove process identity or tunnel ownership"
4738
+ };
4739
+ } catch (error) {
4740
+ if (errorCode8(error) === "ESRCH") {
4741
+ return { liveness: "not-running" };
4742
+ }
4743
+ return {
4744
+ liveness: "unverified",
4745
+ reason: `PID liveness check failed: ${errorMessage(error)}`
4746
+ };
4747
+ }
4748
+ }
4749
+ function parseLegacySession(value, index) {
4750
+ if (typeof value !== "object" || value === null) {
4751
+ return { index, liveness: "unverified", reason: "legacy session entry is not an object" };
4752
+ }
4753
+ const sessionId = optionalText(value, "sessionId");
4754
+ const pid = optionalInteger2(value, "pid");
4755
+ const localPort = optionalInteger2(value, "localPort", 65535);
4756
+ const ownerHostname = optionalText(value, "hostname");
4757
+ const fields = {
4758
+ index,
4759
+ ...sessionId === void 0 ? {} : { sessionId },
4760
+ ...pid === void 0 ? {} : { pid },
4761
+ ...localPort === void 0 ? {} : { localPort },
4762
+ ...ownerHostname === void 0 ? {} : { hostname: ownerHostname }
4763
+ };
4764
+ if (pid === void 0 || ownerHostname === void 0) {
4765
+ return {
4766
+ ...fields,
4767
+ liveness: "unverified",
4768
+ reason: pid === void 0 ? "legacy session has no valid PID" : "legacy session has no valid hostname"
4769
+ };
4770
+ }
4771
+ if (ownerHostname !== hostname2()) {
4772
+ return {
4773
+ ...fields,
4774
+ liveness: "unverified",
4775
+ reason: `legacy session belongs to host ${ownerHostname}; local PID was not probed`
4776
+ };
4777
+ }
4778
+ return { ...fields, ...inspectLegacyPid(pid) };
4779
+ }
4780
+ async function inspectLegacyState(path) {
4781
+ let raw;
4782
+ try {
4783
+ raw = await readFile6(path, "utf8");
4784
+ } catch (error) {
4785
+ return errorCode8(error) === "ENOENT" ? { present: false } : { present: true, warning: `Could not read legacy v1 state: ${errorMessage(error)}` };
4786
+ }
4787
+ let parsed;
4788
+ try {
4789
+ parsed = JSON.parse(raw);
4790
+ } catch {
4791
+ return { present: true, warning: "Legacy v1 state contains invalid JSON." };
4792
+ }
4793
+ if (typeof parsed !== "object" || parsed === null) {
4794
+ return { present: true, warning: "Legacy v1 state is not an object." };
4795
+ }
4796
+ const sessions = field3(parsed, "sessions");
4797
+ if (field3(parsed, "version") !== "1" || !Array.isArray(sessions)) {
4798
+ return { present: true, warning: "Legacy v1 state has an invalid structure." };
4799
+ }
4800
+ return {
4801
+ present: true,
4802
+ sessions: sessions.map(parseLegacySession)
4803
+ };
4804
+ }
4805
+ async function inspectLegacyHomes(path) {
4806
+ let stats;
4807
+ try {
4808
+ stats = await lstat2(path);
4809
+ } catch (error) {
4810
+ return errorCode8(error) === "ENOENT" ? { present: false, homeCount: 0 } : { present: true, warning: `Could not inspect legacy v1 homes: ${errorMessage(error)}` };
4811
+ }
4812
+ if (stats.isSymbolicLink() || !stats.isDirectory()) {
4813
+ return {
4814
+ present: true,
4815
+ warning: "Legacy v1 homes root is not a real directory; it was not traversed."
4816
+ };
4817
+ }
4818
+ try {
4819
+ const entries = await readdir4(path, { withFileTypes: true });
4820
+ return {
4821
+ present: true,
4822
+ homeCount: entries.filter((entry) => entry.isDirectory()).length
4823
+ };
4824
+ } catch (error) {
4825
+ return {
4826
+ present: true,
4827
+ warning: `Could not enumerate legacy v1 homes: ${errorMessage(error)}`
4828
+ };
4829
+ }
4830
+ }
4831
+ function shellQuoteForCommand(value) {
4832
+ return `'${value.replaceAll("'", `'"'"'`)}'`;
4833
+ }
4834
+ async function findLegacyArtifacts() {
4835
+ const statePath = join4(saptoolsDir(), LEGACY_STATE_FILENAME);
4836
+ const lockPath = join4(saptoolsDir(), LEGACY_LOCK_FILENAME);
4837
+ const homesPath = join4(saptoolsDir(), LEGACY_HOMES_DIRNAME);
4838
+ const [state, homes] = await Promise.all([
4839
+ inspectLegacyState(statePath),
4840
+ inspectLegacyHomes(homesPath)
4841
+ ]);
4842
+ const inspectionWarnings = [state.warning, homes.warning].filter(
4843
+ (warning) => warning !== void 0
4844
+ );
4845
+ const base = {
4846
+ statePath,
4847
+ statePresent: state.present,
4848
+ homesPath,
4849
+ homesPresent: homes.present,
4850
+ ...homes.homeCount === void 0 ? {} : { homeCount: homes.homeCount },
4851
+ ...state.sessions === void 0 ? {} : { sessions: state.sessions },
4852
+ ...inspectionWarnings.length === 0 ? {} : { inspectionWarnings }
4853
+ };
4854
+ if (!state.present && !homes.present) {
4855
+ return base;
4856
+ }
4857
+ return {
4858
+ ...base,
4859
+ warning: "Legacy v1 debugger homes may contain live CF refresh and access tokens. Confirm no reported v1 tunnel is running before removing them.",
4860
+ manualRemovalCommand: `rm -rf -- ${shellQuoteForCommand(homesPath)} ${shellQuoteForCommand(statePath)} ${shellQuoteForCommand(lockPath)}`
4861
+ };
4862
+ }
4863
+
4864
+ // src/debug-session/doctor-session-health.ts
4865
+ import { hostname as hostname3 } from "os";
4866
+ import nodeProcess9 from "process";
4867
+ function missingIdentityCaveat(session) {
4868
+ const identity = session.status === "ready" ? session.tunnelProcessIdentity : session.controllerProcessIdentity;
4869
+ return (nodeProcess9.platform === "linux" || nodeProcess9.platform === "darwin") && identity === void 0 ? "process identity token is absent, so PID-only compatibility is in use; an older cf-debugger sharing this state file may have stripped the additive field" : void 0;
4870
+ }
4871
+ async function inspectDoctorSessions(sessions) {
4872
+ const local = sessions.filter((session) => session.hostname === hostname3());
4873
+ return await Promise.all(local.map(async (session) => {
4874
+ try {
4875
+ const health = await inspectSessionHealth(session);
4876
+ const caveat = missingIdentityCaveat(session);
4877
+ return {
4878
+ session,
4879
+ health: caveat === void 0 ? health : { ...health, reason: `${health.reason}; ${caveat}` }
4880
+ };
4881
+ } catch (error) {
4882
+ return {
4883
+ session,
4884
+ health: {
4885
+ status: "unverified",
4886
+ reason: `health inspection failed: ${error instanceof Error ? error.message : String(error)}`
4887
+ }
4888
+ };
4889
+ }
4890
+ }));
4891
+ }
4892
+
4893
+ // src/debug-session/doctor.ts
4079
4894
  var MANAGED_PORT_MIN = 2e4;
4080
4895
  var MANAGED_PORT_MAX = 20999;
4081
4896
  var PORT_SCAN_CONCURRENCY = 32;
4082
4897
  var STALE_TEMP_AGE_MS2 = 24 * 60 * 60 * 1e3;
4083
4898
  var STALE_LOCK_AGE_MS = 60 * 60 * 1e3;
4084
4899
  var FOREIGN_OR_MALFORMED_LOCK_AGE_MS = 24 * 60 * 60 * 1e3;
4085
- var LEGACY_STATE_FILENAME = "cf-debugger-state.json";
4086
- var LEGACY_LOCK_FILENAME = "cf-debugger-state.lock";
4087
- var LEGACY_HOMES_DIRNAME = "cf-debugger-homes";
4088
- function errorCode7(error) {
4900
+ function errorCode9(error) {
4089
4901
  if (typeof error !== "object" || error === null) {
4090
4902
  return void 0;
4091
4903
  }
4092
4904
  const value = Reflect.get(error, "code");
4093
4905
  return typeof value === "string" ? value : void 0;
4094
4906
  }
4095
- function errorMessage(error) {
4907
+ function errorMessage2(error) {
4096
4908
  return error instanceof Error ? error.message : String(error);
4097
4909
  }
4098
4910
  function emptyState2() {
@@ -4101,14 +4913,14 @@ function emptyState2() {
4101
4913
  async function readDoctorState() {
4102
4914
  let raw;
4103
4915
  try {
4104
- raw = await readFile5(stateFilePath(), "utf8");
4916
+ raw = await readFile7(stateFilePath(), "utf8");
4105
4917
  } catch (error) {
4106
- if (errorCode7(error) === "ENOENT") {
4918
+ if (errorCode9(error) === "ENOENT") {
4107
4919
  return { state: emptyState2(), warnings: [], homeCleanupSafe: true };
4108
4920
  }
4109
4921
  return {
4110
4922
  state: emptyState2(),
4111
- warnings: [`Could not read debugger state: ${errorMessage(error)}`],
4923
+ warnings: [`Could not read debugger state: ${errorMessage2(error)}`],
4112
4924
  homeCleanupSafe: false
4113
4925
  };
4114
4926
  }
@@ -4136,54 +4948,58 @@ async function readDoctorState() {
4136
4948
  homeCleanupSafe: decoded.dropped.length === 0
4137
4949
  };
4138
4950
  }
4139
- async function inspectSessions(sessions) {
4140
- const local = sessions.filter((session) => session.hostname === hostname2());
4141
- return await Promise.all(local.map(async (session) => {
4142
- try {
4143
- return { session, health: await inspectSessionHealth(session) };
4144
- } catch (error) {
4145
- return {
4146
- session,
4147
- health: {
4148
- status: "unverified",
4149
- reason: `health inspection failed: ${errorMessage(error)}`
4150
- }
4151
- };
4152
- }
4153
- }));
4154
- }
4155
4951
  async function readDirectory(path) {
4156
4952
  try {
4157
- return await readdir3(path, { withFileTypes: true });
4953
+ return await readdir5(path, { withFileTypes: true });
4158
4954
  } catch (error) {
4159
- if (errorCode7(error) === "ENOENT") {
4955
+ if (errorCode9(error) === "ENOENT") {
4160
4956
  return [];
4161
4957
  }
4162
4958
  throw error;
4163
4959
  }
4164
4960
  }
4165
- async function findOrphanHomes(sessions, cleanup) {
4166
- const root = join3(saptoolsDir(), CF_DEBUGGER_HOMES_DIRNAME);
4167
- const claimed = new Set(sessions.map((session) => session.sessionId));
4168
- const entries = await readDirectory(root);
4169
- const orphans = entries.filter((entry) => entry.isDirectory() && !claimed.has(entry.name)).sort((left, right) => left.name.localeCompare(right.name));
4170
- return await Promise.all(orphans.map(async (entry) => {
4171
- const path = join3(root, entry.name);
4172
- const cleanupEligible = isOwnedSessionCfHomeDir(entry.name, path);
4173
- if (!cleanup) {
4174
- return { sessionId: entry.name, path, cleanupEligible, cleanupStatus: "not-requested" };
4175
- }
4176
- if (!cleanupEligible) {
4177
- return { sessionId: entry.name, path, cleanupEligible, cleanupStatus: "not-eligible" };
4961
+ async function findOrphanHomes(sessions, cleanupRequested, cleanupSafe) {
4962
+ const discovery = await discoverOrphanHomes(sessions);
4963
+ const findings = await Promise.all(discovery.candidates.map(
4964
+ async (candidate) => {
4965
+ const { cleanupEligible, path, reason, sessionId } = candidate;
4966
+ if (!cleanupRequested) {
4967
+ return {
4968
+ sessionId,
4969
+ path,
4970
+ cleanupEligible,
4971
+ cleanupStatus: "not-requested",
4972
+ ...reason === void 0 ? {} : { reason }
4973
+ };
4974
+ }
4975
+ if (!cleanupSafe) {
4976
+ return {
4977
+ sessionId,
4978
+ path,
4979
+ cleanupEligible,
4980
+ cleanupStatus: cleanupEligible ? "skipped" : "not-eligible",
4981
+ ...reason === void 0 ? {} : { reason }
4982
+ };
4983
+ }
4984
+ if (!cleanupEligible) {
4985
+ return {
4986
+ sessionId,
4987
+ path,
4988
+ cleanupEligible,
4989
+ cleanupStatus: "not-eligible",
4990
+ ...reason === void 0 ? {} : { reason }
4991
+ };
4992
+ }
4993
+ const cleanupStatus = await cleanupOrphanHome(sessionId, path);
4994
+ return {
4995
+ sessionId,
4996
+ path,
4997
+ cleanupEligible,
4998
+ cleanupStatus
4999
+ };
4178
5000
  }
4179
- const cleanupStatus = await cleanupOrphanHome(entry.name, path);
4180
- return {
4181
- sessionId: entry.name,
4182
- path,
4183
- cleanupEligible,
4184
- cleanupStatus
4185
- };
4186
- }));
5001
+ ));
5002
+ return { findings, warnings: discovery.warnings };
4187
5003
  }
4188
5004
  async function cleanupOrphanHome(sessionId, path) {
4189
5005
  try {
@@ -4224,7 +5040,7 @@ async function mapConcurrent(values, concurrency, work) {
4224
5040
  }
4225
5041
  function unclaimedManagedPorts(sessions) {
4226
5042
  const localClaims = new Set(
4227
- sessions.filter((session) => session.hostname === hostname2()).map((session) => session.localPort)
5043
+ sessions.filter((session) => session.hostname === hostname4()).map((session) => session.localPort)
4228
5044
  );
4229
5045
  const ports = [];
4230
5046
  for (let port = MANAGED_PORT_MIN; port <= MANAGED_PORT_MAX; port += 1) {
@@ -4301,15 +5117,15 @@ function parseLockOwner2(raw) {
4301
5117
  }
4302
5118
  function isPidAlive2(pid) {
4303
5119
  try {
4304
- nodeProcess7.kill(pid, 0);
5120
+ nodeProcess10.kill(pid, 0);
4305
5121
  return true;
4306
5122
  } catch (error) {
4307
- return errorCode7(error) !== "ESRCH";
5123
+ return errorCode9(error) !== "ESRCH";
4308
5124
  }
4309
5125
  }
4310
5126
  async function readLockOwner2(path) {
4311
5127
  try {
4312
- return parseLockOwner2(await readFile5(path, "utf8"));
5128
+ return parseLockOwner2(await readFile7(path, "utf8"));
4313
5129
  } catch {
4314
5130
  return void 0;
4315
5131
  }
@@ -4319,7 +5135,7 @@ async function lockCleanupEligible(path, ageMs) {
4319
5135
  return false;
4320
5136
  }
4321
5137
  const owner = await readLockOwner2(path);
4322
- if (owner?.hostname === hostname2()) {
5138
+ if (owner?.hostname === hostname4()) {
4323
5139
  if (!isPidAlive2(owner.pid)) {
4324
5140
  return true;
4325
5141
  }
@@ -4349,18 +5165,21 @@ async function inspectArtifact(entry, now, claimedSessionIds, stopIntentCleanupS
4349
5165
  if (kind === void 0) {
4350
5166
  return void 0;
4351
5167
  }
4352
- const path = join3(saptoolsDir(), entry.name);
4353
- const stats = await lstat(path);
5168
+ const path = join5(saptoolsDir(), entry.name);
5169
+ const stats = await lstat3(path);
4354
5170
  const ageMs = Math.max(0, now - stats.mtimeMs);
4355
5171
  const regularFile = stats.isFile();
4356
5172
  let sessionId;
4357
5173
  let cleanupEligible = regularFile && kind === "state-temp" && ageMs >= STALE_TEMP_AGE_MS2;
5174
+ let cleanupBlocked = false;
4358
5175
  if (regularFile && kind === "stop-intent") {
4359
5176
  sessionId = entry.name.slice(
4360
5177
  CF_DEBUGGER_STOP_INTENT_PREFIX.length,
4361
5178
  -".stop".length
4362
5179
  );
4363
- cleanupEligible = stopIntentCleanupSafe && ageMs >= STALE_TEMP_AGE_MS2 && !claimedSessionIds.has(sessionId);
5180
+ const otherwiseEligible = ageMs >= STALE_TEMP_AGE_MS2 && !claimedSessionIds.has(sessionId);
5181
+ cleanupEligible = stopIntentCleanupSafe && otherwiseEligible;
5182
+ cleanupBlocked = !stopIntentCleanupSafe && otherwiseEligible;
4364
5183
  }
4365
5184
  if (regularFile && (kind === "state-lock" || kind === "state-recovery")) {
4366
5185
  cleanupEligible = await lockCleanupEligible(path, ageMs);
@@ -4370,13 +5189,14 @@ async function inspectArtifact(entry, now, claimedSessionIds, stopIntentCleanupS
4370
5189
  path,
4371
5190
  ageMs,
4372
5191
  cleanupEligible,
5192
+ cleanupBlocked,
4373
5193
  fingerprint: artifactFingerprint(stats),
4374
5194
  ...sessionId === void 0 ? {} : { sessionId }
4375
5195
  };
4376
5196
  }
4377
5197
  async function candidateStillMatches(candidate) {
4378
5198
  try {
4379
- const stats = await lstat(candidate.path);
5199
+ const stats = await lstat3(candidate.path);
4380
5200
  return artifactFingerprint(stats) === candidate.fingerprint;
4381
5201
  } catch {
4382
5202
  return false;
@@ -4408,12 +5228,20 @@ async function cleanupArtifact(candidate) {
4408
5228
  await unlink5(candidate.path);
4409
5229
  return { status: "removed" };
4410
5230
  } catch (error) {
4411
- if (errorCode7(error) === "ENOENT") {
5231
+ if (errorCode9(error) === "ENOENT") {
4412
5232
  return { status: "skipped" };
4413
5233
  }
4414
- return { status: "failed", error: errorMessage(error) };
5234
+ return { status: "failed", error: errorMessage2(error) };
4415
5235
  }
4416
5236
  }
5237
+ async function resolveArtifactCleanup(candidate, cleanup) {
5238
+ if (!cleanup) {
5239
+ return {
5240
+ status: candidate.cleanupEligible ? "not-requested" : "not-eligible"
5241
+ };
5242
+ }
5243
+ return candidate.cleanupBlocked ? { status: "skipped" } : await cleanupArtifact(candidate);
5244
+ }
4417
5245
  async function findArtifacts(cleanup, sessions, stopIntentCleanupSafe) {
4418
5246
  const entries = await readDirectory(saptoolsDir());
4419
5247
  const claimedSessionIds = new Set(sessions.map((session) => session.sessionId));
@@ -4427,55 +5255,22 @@ async function findArtifacts(cleanup, sessions, stopIntentCleanupSafe) {
4427
5255
  ).sort((left, right) => left.path.localeCompare(right.path));
4428
5256
  const findings = [];
4429
5257
  for (const candidate of candidates) {
4430
- const result = cleanup ? await cleanupArtifact(candidate) : {
4431
- status: candidate.cleanupEligible ? "not-requested" : "not-eligible"
4432
- };
5258
+ const result = await resolveArtifactCleanup(candidate, cleanup);
4433
5259
  findings.push({
4434
5260
  kind: candidate.kind,
4435
5261
  path: candidate.path,
4436
5262
  ageMs: candidate.ageMs,
4437
5263
  cleanupEligible: candidate.cleanupEligible,
4438
5264
  cleanupStatus: result.status,
4439
- ...result.error === void 0 ? {} : { cleanupError: result.error }
5265
+ ...result.error === void 0 ? {} : { cleanupError: result.error },
5266
+ ...candidate.kind === "corrupt-backup" ? {
5267
+ note: "Preserved recovery evidence; it may contain debugger session metadata such as target names, PIDs, ports, and CF home paths. Inspect it before manual removal.",
5268
+ manualRemovalCommand: `rm -- ${shellQuoteForCommand(candidate.path)}`
5269
+ } : {}
4440
5270
  });
4441
5271
  }
4442
5272
  return findings;
4443
5273
  }
4444
- async function pathExists(path) {
4445
- try {
4446
- await lstat(path);
4447
- return true;
4448
- } catch (error) {
4449
- if (errorCode7(error) === "ENOENT") {
4450
- return false;
4451
- }
4452
- throw error;
4453
- }
4454
- }
4455
- function shellQuote(value) {
4456
- return `'${value.replaceAll("'", `'"'"'`)}'`;
4457
- }
4458
- async function findLegacyArtifacts() {
4459
- const statePath = join3(saptoolsDir(), LEGACY_STATE_FILENAME);
4460
- const lockPath = join3(saptoolsDir(), LEGACY_LOCK_FILENAME);
4461
- const homesPath = join3(saptoolsDir(), LEGACY_HOMES_DIRNAME);
4462
- const [statePresent, homesPresent] = await Promise.all([
4463
- pathExists(statePath),
4464
- pathExists(homesPath)
4465
- ]);
4466
- if (!statePresent && !homesPresent) {
4467
- return { statePath, statePresent, homesPath, homesPresent };
4468
- }
4469
- const command = `rm -rf -- ${shellQuote(homesPath)} ${shellQuote(statePath)} ${shellQuote(lockPath)}`;
4470
- return {
4471
- statePath,
4472
- statePresent,
4473
- homesPath,
4474
- homesPresent,
4475
- warning: "Legacy v1 debugger homes may contain live CF refresh and access tokens. Confirm no v1 tunnel is running before removing them.",
4476
- manualRemovalCommand: command
4477
- };
4478
- }
4479
5274
  function reportWarnings(stateWarnings, orphanHomes, unclaimedPorts, legacy) {
4480
5275
  const warnings = [...stateWarnings];
4481
5276
  if (orphanHomes.length > 0) {
@@ -4492,14 +5287,14 @@ function reportWarnings(stateWarnings, orphanHomes, unclaimedPorts, legacy) {
4492
5287
  async function runDoctor(options = {}) {
4493
5288
  const cleanup = options.cleanup === true;
4494
5289
  const stateResult = await readDoctorState();
4495
- const homeCleanup = cleanup && stateResult.homeCleanupSafe;
4496
- const [sessions, orphanHomes, unclaimedPorts, artifacts, legacy] = await Promise.all([
4497
- inspectSessions(stateResult.state.sessions),
4498
- findOrphanHomes(stateResult.state.sessions, homeCleanup),
5290
+ const [sessions, homeInspection, unclaimedPorts, artifacts, legacy] = await Promise.all([
5291
+ inspectDoctorSessions(stateResult.state.sessions),
5292
+ findOrphanHomes(stateResult.state.sessions, cleanup, stateResult.homeCleanupSafe),
4499
5293
  findUnclaimedPorts(stateResult.state.sessions),
4500
5294
  findArtifacts(cleanup, stateResult.state.sessions, stateResult.homeCleanupSafe),
4501
5295
  findLegacyArtifacts()
4502
5296
  ]);
5297
+ const orphanHomes = homeInspection.findings;
4503
5298
  const cleanedPaths = [
4504
5299
  ...orphanHomes.filter((finding) => finding.cleanupStatus === "removed").map((finding) => finding.path),
4505
5300
  ...artifacts.filter((finding) => finding.cleanupStatus === "removed").map((finding) => finding.path)
@@ -4512,6 +5307,7 @@ async function runDoctor(options = {}) {
4512
5307
  legacy,
4513
5308
  warnings: [
4514
5309
  ...reportWarnings(stateResult.warnings, orphanHomes, unclaimedPorts, legacy),
5310
+ ...homeInspection.warnings,
4515
5311
  ...cleanup && !stateResult.homeCleanupSafe ? [
4516
5312
  "Skipped orphan-home and stop-intent cleanup because debugger state was incomplete or invalid."
4517
5313
  ] : []
@@ -4522,7 +5318,7 @@ async function runDoctor(options = {}) {
4522
5318
 
4523
5319
  // src/debug-session/sessions.ts
4524
5320
  import { hostname as getHostname4 } from "os";
4525
- import nodeProcess8 from "process";
5321
+ import nodeProcess11 from "process";
4526
5322
  function findMatchingSession(sessions, options) {
4527
5323
  if (options.sessionId !== void 0) {
4528
5324
  return sessions.find((session) => session.sessionId === options.sessionId);
@@ -4540,50 +5336,55 @@ function findMatchingSession(sessions, options) {
4540
5336
  }
4541
5337
  return matches[0];
4542
5338
  }
4543
- function startupAgeLimit2(session) {
4544
- return (session.startupTimeoutMs ?? MAX_STARTUP_TIMEOUT_MS) + STARTUP_STALE_SLACK_MS;
4545
- }
4546
- function startupExpired2(session) {
4547
- const startedAt = Date.parse(session.startedAt);
4548
- return Number.isNaN(startedAt) || Date.now() - startedAt > startupAgeLimit2(session);
4549
- }
4550
- async function inspectRecordedProcess2(pid, identity) {
4551
- if (!isPidAlive(pid)) {
4552
- return "dead";
4553
- }
4554
- return await inspectProcessIdentity(pid, identity);
4555
- }
4556
5339
  async function inspectController(target) {
4557
5340
  const controllerPid = target.controllerPid ?? target.pid;
4558
- return await inspectRecordedProcess2(controllerPid, target.controllerProcessIdentity);
5341
+ return await inspectRecordedProcess(
5342
+ controllerPid,
5343
+ target.controllerProcessIdentity,
5344
+ isPidAlive
5345
+ );
4559
5346
  }
4560
5347
  async function ownsRecordedTunnel(target) {
4561
5348
  const tunnelPid = target.tunnelPid;
4562
5349
  if (tunnelPid === void 0) {
4563
5350
  return false;
4564
5351
  }
4565
- const processVerdict = await inspectRecordedProcess2(
5352
+ const processVerdict = await inspectRecordedProcess(
4566
5353
  tunnelPid,
4567
- target.tunnelProcessIdentity
5354
+ target.tunnelProcessIdentity,
5355
+ isPidAlive
4568
5356
  );
4569
5357
  if (processVerdict !== "match") {
4570
5358
  return false;
4571
5359
  }
4572
5360
  return (await inspectPortOwnership(target.localPort, tunnelPid)).status === "owned";
4573
5361
  }
4574
- async function terminateVerifiedTunnel(target) {
5362
+ async function terminateVerifiedTunnel(target, recordExpectedStop) {
4575
5363
  const tunnelPid = target.tunnelPid;
4576
- if (tunnelPid === void 0 || tunnelPid === nodeProcess8.pid) {
4577
- return tunnelPid === nodeProcess8.pid ? "still-alive" : "terminated";
5364
+ if (tunnelPid === void 0 || tunnelPid === nodeProcess11.pid) {
5365
+ return tunnelPid === nodeProcess11.pid ? "still-alive" : "terminated";
4578
5366
  }
5367
+ let stopIntentRecorded = false;
4579
5368
  try {
4580
5369
  const verifyBeforeSignal = async (signal) => {
4581
5370
  if (signal === "SIGTERM" || target.tunnelProcessIdentity === void 0) {
4582
- return await ownsRecordedTunnel(target);
5371
+ if (!await ownsRecordedTunnel(target)) {
5372
+ return false;
5373
+ }
5374
+ if (recordExpectedStop && !stopIntentRecorded) {
5375
+ const claim = await requestSessionStop(target.sessionId);
5376
+ if (claim === void 0) {
5377
+ return false;
5378
+ }
5379
+ stopIntentRecorded = true;
5380
+ return await ownsRecordedTunnel(target);
5381
+ }
5382
+ return true;
4583
5383
  }
4584
- return await inspectRecordedProcess2(
5384
+ return await inspectRecordedProcess(
4585
5385
  tunnelPid,
4586
- target.tunnelProcessIdentity
5386
+ target.tunnelProcessIdentity,
5387
+ isPidAlive
4587
5388
  ) === "match";
4588
5389
  };
4589
5390
  return await terminatePidOrGroup(
@@ -4597,13 +5398,10 @@ async function terminateVerifiedTunnel(target) {
4597
5398
  }
4598
5399
  }
4599
5400
  async function terminateVerifiedTunnelAndConfirm(target, recordExpectedStop = false) {
4600
- if (recordExpectedStop) {
4601
- await writeSessionStopIntent(target.sessionId);
4602
- }
4603
5401
  if (!await ownsRecordedTunnel(target)) {
4604
5402
  throw ownershipError(target, "recorded tunnel no longer owns the local port");
4605
5403
  }
4606
- const termination = await terminateVerifiedTunnel(target);
5404
+ const termination = await terminateVerifiedTunnel(target, recordExpectedStop);
4607
5405
  if (termination !== "terminated" || await ownsRecordedTunnel(target)) {
4608
5406
  throw new CfDebuggerError(
4609
5407
  "TUNNEL_TERMINATION_FAILED",
@@ -4613,18 +5411,26 @@ async function terminateVerifiedTunnelAndConfirm(target, recordExpectedStop = fa
4613
5411
  }
4614
5412
  async function removeOwnedSession(target, stale, forced = false, warning, preserveStopIntent = false) {
4615
5413
  let resultWarning = warning;
4616
- if (isOwnedSessionCfHomeDir(target.sessionId, target.cfHomeDir)) {
4617
- await removeOwnedSessionCfHome(target.sessionId, target.cfHomeDir);
4618
- } else {
5414
+ const cleanup = await forgetSessionThenCleanupHome(target);
5415
+ if (cleanup.homeStatus === "retained") {
5416
+ const retained = `The session record was removed, but its owned CF home ${target.cfHomeDir} could not be deleted and may still hold a live CF refresh token; remove it manually or run \`cf-debugger doctor --cleanup\`.`;
5417
+ resultWarning = appendWarning(resultWarning, retained);
5418
+ } else if (cleanup.homeStatus === "unowned") {
4619
5419
  const skipped = `State referenced unowned CF home ${target.cfHomeDir}; it was not deleted.`;
4620
- resultWarning = resultWarning === void 0 ? skipped : `${resultWarning} ${skipped}`;
5420
+ resultWarning = appendWarning(resultWarning, skipped);
4621
5421
  }
4622
- const removed = await removeSession(target.sessionId);
4623
5422
  if (!preserveStopIntent) {
4624
- await clearSessionStopIntent(target.sessionId);
5423
+ try {
5424
+ await clearSessionStopIntent(target.sessionId);
5425
+ } catch {
5426
+ resultWarning = appendWarning(
5427
+ resultWarning,
5428
+ `The session record was removed, but its stop-intent artifact could not be deleted; run \`cf-debugger doctor --cleanup\` after resolving the filesystem error.`
5429
+ );
5430
+ }
4625
5431
  }
4626
5432
  return {
4627
- ...removed ?? target,
5433
+ ...cleanup.removed ?? target,
4628
5434
  stale,
4629
5435
  pending: false,
4630
5436
  forced,
@@ -4640,6 +5446,9 @@ function ownershipError(target, detail) {
4640
5446
  function forcedWarning(target, detail) {
4641
5447
  return `Forced state cleanup abandoned PID ${String(target.tunnelPid ?? target.controllerPid ?? target.pid)} and local port ${target.localPort.toString()}: ${detail}. No unverified process was signalled.`;
4642
5448
  }
5449
+ function appendWarning(current, next) {
5450
+ return current === void 0 ? next : `${current} ${next}`;
5451
+ }
4643
5452
  async function forceRemoveUnverified(target, detail, preserveStopIntent = false) {
4644
5453
  return await removeOwnedSession(
4645
5454
  target,
@@ -4654,20 +5463,24 @@ async function stopReadySession(target, force) {
4654
5463
  await terminateVerifiedTunnelAndConfirm(target, true);
4655
5464
  return await removeOwnedSession(target, false, false, void 0, true);
4656
5465
  }
4657
- const tunnelVerdict = target.tunnelPid === void 0 ? "dead" : await inspectRecordedProcess2(target.tunnelPid, target.tunnelProcessIdentity);
5466
+ const tunnelVerdict = target.tunnelPid === void 0 ? "dead" : await inspectRecordedProcess(
5467
+ target.tunnelPid,
5468
+ target.tunnelProcessIdentity,
5469
+ isPidAlive
5470
+ );
4658
5471
  const tunnelDead = target.tunnelPid === void 0 || !isPidOrGroupAlive(target.tunnelPid) || tunnelVerdict === "mismatch";
4659
5472
  const ownership = target.tunnelPid === void 0 ? await inspectListeningProcesses(target.localPort) : await inspectPortOwnership(target.localPort, target.tunnelPid);
4660
5473
  if (tunnelDead && ownership.status === "not-listening") {
4661
- return await removeOwnedSession(target, true, false, void 0, true);
5474
+ return await removeOwnedSession(target, true);
4662
5475
  }
4663
5476
  const detail = ownership.status === "unverified" ? ownership.reason : ownership.status === "not-owned" ? `local port ${target.localPort.toString()} belongs to PID(s) ${ownership.pids.join(", ")}` : tunnelVerdict === "unavailable" ? "the recorded tunnel identity could not be inspected" : "the recorded tunnel could not be proven dead and owned";
4664
5477
  if (force) {
4665
- return await forceRemoveUnverified(target, detail, true);
5478
+ return await forceRemoveUnverified(target, detail);
4666
5479
  }
4667
5480
  throw ownershipError(target, detail);
4668
5481
  }
4669
5482
  async function stopStartingSession(target, force) {
4670
- const expired = startupExpired2(target);
5483
+ const expired = startupExpired(target);
4671
5484
  const controllerVerdict = await inspectController(target);
4672
5485
  if (!force && !expired && controllerVerdict === "match") {
4673
5486
  return { ...target, stale: false, pending: true, forced: false };
@@ -4699,17 +5512,20 @@ async function stopDebugger(options) {
4699
5512
  if (target === void 0) {
4700
5513
  return void 0;
4701
5514
  }
5515
+ if (target.status === "ready") {
5516
+ return await stopReadySession(target, options.force === true);
5517
+ }
4702
5518
  const claim = await requestSessionStop(target.sessionId);
4703
5519
  if (claim === void 0) {
4704
5520
  return void 0;
4705
5521
  }
4706
- return claim.previousStatus === "ready" ? await stopReadySession(claim.session, options.force === true) : await stopStartingSession(claim.session, options.force === true);
5522
+ return await stopStartingSession(claim.session, options.force === true);
4707
5523
  }
4708
5524
  function outcomeForResult(result) {
4709
5525
  return {
4710
5526
  sessionId: result.sessionId,
4711
5527
  app: result.app,
4712
- status: result.pending ? "pending" : result.stale ? "stale" : "stopped",
5528
+ status: result.pending ? "pending" : result.forced ? "forced" : result.stale ? "stale" : "stopped",
4713
5529
  result
4714
5530
  };
4715
5531
  }
@@ -4737,6 +5553,7 @@ function summarizeOutcomes(outcomes) {
4737
5553
  return {
4738
5554
  outcomes,
4739
5555
  failed: count("failed"),
5556
+ forced: count("forced"),
4740
5557
  pending: count("pending"),
4741
5558
  stale: count("stale"),
4742
5559
  stopped: count("stopped")
@@ -4769,6 +5586,7 @@ async function getSession(key) {
4769
5586
 
4770
5587
  export {
4771
5588
  CfDebuggerError,
5589
+ validateApiEndpointOverride,
4772
5590
  resolveApiEndpoint,
4773
5591
  listKnownRegionKeys,
4774
5592
  readCurrentCfTarget,
@@ -4780,10 +5598,12 @@ export {
4780
5598
  resolveNodeTarget,
4781
5599
  buildNodeInspectorCommand,
4782
5600
  parseNodeInspectorMarkers,
5601
+ parseRestartEnvironment,
4783
5602
  sessionKeyString,
4784
5603
  CLEANUP_FAILURE_EXIT_CODE,
4785
5604
  hasTunnelTerminationFailure,
4786
5605
  cliErrorExitCode,
5606
+ stopAllExitCode,
4787
5607
  getDebuggerHandleTunnelError,
4788
5608
  resolveStartupTimeoutMs,
4789
5609
  remainingStartupMs,
@@ -4797,4 +5617,4 @@ export {
4797
5617
  listSessions,
4798
5618
  getSession
4799
5619
  };
4800
- //# sourceMappingURL=chunk-AKQR4Y74.js.map
5620
+ //# sourceMappingURL=chunk-QBB4N2WN.js.map