@saptools/cf-debugger 0.2.0 → 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,14 +982,15 @@ 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 nodeProcess2 from "process";
985
+ import { spawn as spawn2 } from "child_process";
986
+ import nodeProcess4 from "process";
850
987
  import { StringDecoder } from "string_decoder";
988
+
989
+ // src/cloud-foundry/ssh-shared.ts
851
990
  var DEFAULT_MAX_OUTPUT_BYTES = 65536;
852
991
  var LIVE_LINE_LIMIT_BYTES = 65536;
853
992
  var MAX_REDACTION_OVERLAP_BYTES = 4096;
854
993
  var SENSITIVE_OUTPUT_OMITTED = "[diagnostic output omitted to protect a sensitive value]";
855
- var tunnelCaptures = /* @__PURE__ */ new WeakMap();
856
994
  function buildCfSshArgs(appName, target, tail) {
857
995
  const resolved = resolveNodeTarget(target);
858
996
  const processArgs = resolved.process === DEFAULT_CF_PROCESS ? [] : ["--process", resolved.process];
@@ -865,35 +1003,6 @@ function buildCfSshArgs(appName, target, tail) {
865
1003
  ...tail
866
1004
  ];
867
1005
  }
868
- async function cfSshOneShot(appName, command, context, rawOptions = DEFAULT_CF_COMMAND_TIMEOUT_MS) {
869
- if (context.signal?.aborted || isDeadlineExpired(context)) {
870
- throw sshAbortError(context);
871
- }
872
- const options = resolveSshOptions(rawOptions, context);
873
- const args = buildCfSshArgs(appName, options.target, [
874
- "--disable-pseudo-tty",
875
- "-c",
876
- command
877
- ]);
878
- return await runSshOneShot(args, context, options);
879
- }
880
- function resolveSshOptions(raw, context) {
881
- const input = typeof raw === "number" ? { timeoutMs: raw } : raw;
882
- const requestedTimeoutMs = input.timeoutMs ?? DEFAULT_CF_COMMAND_TIMEOUT_MS;
883
- const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
884
- if (!Number.isSafeInteger(requestedTimeoutMs) || requestedTimeoutMs <= 0) {
885
- throw new CfDebuggerError("UNSAFE_INPUT", "timeoutMs must be a positive safe integer.");
886
- }
887
- if (!Number.isSafeInteger(maxOutputBytes) || maxOutputBytes <= 0) {
888
- throw new CfDebuggerError("UNSAFE_INPUT", "maxOutputBytes must be a positive safe integer.");
889
- }
890
- const remainingMs = context.deadlineAt === void 0 ? requestedTimeoutMs : Math.max(1, context.deadlineAt - Date.now());
891
- return {
892
- target: input,
893
- timeoutMs: Math.min(requestedTimeoutMs, remainingMs),
894
- maxOutputBytes
895
- };
896
- }
897
1006
  function createBoundedOutput() {
898
1007
  return { chunks: [], bytes: 0, truncated: false };
899
1008
  }
@@ -934,9 +1043,6 @@ function appendTail(output, data, outputLimit, retainedLimit) {
934
1043
  }
935
1044
  }
936
1045
  }
937
- function outputText(output) {
938
- return Buffer.concat(output.chunks, output.bytes).toString("utf8");
939
- }
940
1046
  function createRedactionPolicy(values) {
941
1047
  const sensitiveValues = normalizeSensitiveValues(values);
942
1048
  const overlapBytes = sensitiveValues.reduce(
@@ -949,6 +1055,9 @@ function createRedactionPolicy(values) {
949
1055
  overlapBytes: Math.min(overlapBytes, MAX_REDACTION_OVERLAP_BYTES)
950
1056
  };
951
1057
  }
1058
+ function outputText(output) {
1059
+ return Buffer.concat(output.chunks, output.bytes).toString("utf8");
1060
+ }
952
1061
  function limitText(text, limit, keep) {
953
1062
  const buffer = Buffer.from(text);
954
1063
  if (buffer.byteLength <= limit) {
@@ -964,37 +1073,6 @@ function safeOutputText(output, policy, limit, keep) {
964
1073
  const redacted = redactSensitiveText(outputText(output), policy.sensitiveValues);
965
1074
  return limitText(redacted, limit, keep);
966
1075
  }
967
- function createResult(exitCode, state) {
968
- const stdout = safeOutputText(state.stdout, state.redaction, state.outputLimit, "head");
969
- const stderr = safeOutputText(state.stderr, state.redaction, state.outputLimit, "head");
970
- return {
971
- exitCode,
972
- stdout,
973
- stderr,
974
- outputTruncated: state.stdout.truncated || state.stderr.truncated,
975
- stdoutTruncated: state.stdout.truncated,
976
- stderrTruncated: state.stderr.truncated
977
- };
978
- }
979
- function createSshExecutionState(context, outputLimit) {
980
- const redaction = createRedactionPolicy(context.sensitiveValues ?? []);
981
- return {
982
- stdout: createBoundedOutput(),
983
- stderr: createBoundedOutput(),
984
- outputLimit,
985
- retainedLimit: outputLimit + redaction.overlapBytes,
986
- redaction,
987
- settled: false,
988
- aborted: false,
989
- timedOut: false
990
- };
991
- }
992
- function terminateSshExecution(child, state) {
993
- signalChild(child, "SIGTERM");
994
- state.forceKillTimer ??= setTimeout(() => {
995
- signalChild(child, "SIGKILL");
996
- }, 1e3);
997
- }
998
1076
  function sshAbortError(context) {
999
1077
  if (context.deadlineAt !== void 0 && Date.now() >= context.deadlineAt) {
1000
1078
  return new CfDebuggerError(
@@ -1007,79 +1085,312 @@ function sshAbortError(context) {
1007
1085
  function isDeadlineExpired(context) {
1008
1086
  return context.deadlineAt !== void 0 && Date.now() >= context.deadlineAt;
1009
1087
  }
1010
- function createSshSettler(state, options, context, onAbort, resolve, reject) {
1011
- return (result) => {
1012
- if (state.settled) {
1013
- return;
1014
- }
1015
- state.settled = true;
1016
- clearTimeout(state.timeoutTimer);
1017
- clearTimeout(state.forceKillTimer);
1018
- context.signal?.removeEventListener("abort", onAbort);
1019
- if (state.aborted) {
1020
- reject(sshAbortError(context));
1021
- return;
1022
- }
1023
- resolve(state.timedOut ? { ...result, timedOutAfterMs: options.timeoutMs } : result);
1024
- };
1025
- }
1026
- function attachSshExecution(child, context, options, resolve, reject) {
1027
- const state = createSshExecutionState(context, options.maxOutputBytes);
1028
- const onAbort = () => {
1029
- state.aborted = true;
1030
- terminateSshExecution(child, state);
1031
- };
1032
- const settle = createSshSettler(state, options, context, onAbort, resolve, reject);
1033
- state.timeoutTimer = setTimeout(() => {
1034
- state.timedOut = true;
1035
- terminateSshExecution(child, state);
1036
- }, options.timeoutMs);
1037
- if (context.signal?.aborted) {
1038
- onAbort();
1039
- } else {
1040
- context.signal?.addEventListener("abort", onAbort, { once: true });
1088
+
1089
+ // src/cloud-foundry/ssh-one-shot.ts
1090
+ import {
1091
+ spawn
1092
+ } from "child_process";
1093
+ import nodeProcess3 from "process";
1094
+
1095
+ // src/debug-session/process-identity.ts
1096
+ import { execFile as execFile2 } from "child_process";
1097
+ import { readFile } from "fs/promises";
1098
+ import nodeProcess2 from "process";
1099
+ import { promisify } from "util";
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}$/;
1103
+ function errorCode(error) {
1104
+ if (typeof error !== "object" || error === null) {
1105
+ return void 0;
1041
1106
  }
1042
- child.stdout.on("data", (data) => {
1043
- appendHead(state.stdout, data, state.outputLimit, state.retainedLimit);
1044
- });
1045
- child.stderr.on("data", (data) => {
1046
- appendHead(state.stderr, data, state.outputLimit, state.retainedLimit);
1047
- });
1048
- child.on("close", (code, signal) => {
1049
- const base = createResult(code, state);
1050
- settle(state.timedOut || signal === null ? base : { ...base, signal });
1051
- });
1052
- child.on("error", (error) => {
1053
- appendHead(state.stderr, error.message, state.outputLimit, state.retainedLimit);
1054
- settle(createResult(null, state));
1055
- });
1107
+ const code = Reflect.get(error, "code");
1108
+ return typeof code === "string" ? code : void 0;
1056
1109
  }
1057
- function runSshOneShot(args, context, options) {
1058
- if (context.signal?.aborted || isDeadlineExpired(context)) {
1059
- return Promise.reject(sshAbortError(context));
1110
+ function parseLinuxProcessStartTime(statLine) {
1111
+ const commandEnd = statLine.lastIndexOf(")");
1112
+ if (commandEnd < 0) {
1113
+ return void 0;
1060
1114
  }
1061
- return new Promise((resolve, reject) => {
1062
- const child = spawn(resolveBin(context), [...args], {
1063
- env: buildEnv(context.cfHome),
1064
- detached: nodeProcess2.platform !== "win32",
1065
- stdio: ["ignore", "pipe", "pipe"]
1066
- });
1067
- attachSshExecution(child, context, options, resolve, reject);
1068
- });
1115
+ const fieldsAfterCommand = statLine.slice(commandEnd + 1).trim().split(/\s+/);
1116
+ const startTime = fieldsAfterCommand[19];
1117
+ return startTime !== void 0 && /^\d+$/.test(startTime) ? startTime : void 0;
1069
1118
  }
1070
- function signalChild(child, signal) {
1071
- if (nodeProcess2.platform !== "win32" && child.pid !== void 0) {
1072
- try {
1073
- nodeProcess2.kill(-child.pid, signal);
1074
- return;
1075
- } catch {
1076
- }
1119
+ function throwIfAborted(signal) {
1120
+ if (signal?.aborted) {
1121
+ throw new CfDebuggerError("ABORTED", "Process identity inspection was aborted.");
1077
1122
  }
1123
+ }
1124
+ async function linuxProcessIdentity(pid, signal) {
1078
1125
  try {
1079
- child.kill(signal);
1080
- } catch {
1126
+ const statLine = await readFile(`/proc/${pid.toString()}/stat`, {
1127
+ encoding: "utf8",
1128
+ ...signal === void 0 ? {} : { signal }
1129
+ });
1130
+ const startTime = parseLinuxProcessStartTime(statLine);
1131
+ return startTime === void 0 ? void 0 : `linux:${PROCESS_IDENTITY_VERSION}:${startTime}`;
1132
+ } catch (error) {
1133
+ throwIfAborted(signal);
1134
+ if (errorCode(error) === "ENOENT") {
1135
+ return void 0;
1136
+ }
1137
+ return void 0;
1081
1138
  }
1082
1139
  }
1140
+ function darwinIdentityEnvironment(environment) {
1141
+ return {
1142
+ ...environment,
1143
+ LC_ALL: "C",
1144
+ TZ: "UTC"
1145
+ };
1146
+ }
1147
+ async function darwinProcessIdentity(pid, signal) {
1148
+ try {
1149
+ const { stdout } = await execFileAsync("ps", ["-p", pid.toString(), "-o", "lstart="], {
1150
+ env: darwinIdentityEnvironment(nodeProcess2.env),
1151
+ ...signal === void 0 ? {} : { signal },
1152
+ timeout: 2e3
1153
+ });
1154
+ const startedAt = stdout.trim();
1155
+ return startedAt.length === 0 ? void 0 : `darwin:${PROCESS_IDENTITY_VERSION}:${startedAt}`;
1156
+ } catch {
1157
+ throwIfAborted(signal);
1158
+ return void 0;
1159
+ }
1160
+ }
1161
+ async function readProcessIdentity(pid, signal) {
1162
+ throwIfAborted(signal);
1163
+ if (!Number.isSafeInteger(pid) || pid <= 0) {
1164
+ return void 0;
1165
+ }
1166
+ if (nodeProcess2.platform === "linux") {
1167
+ return await linuxProcessIdentity(pid, signal);
1168
+ }
1169
+ if (nodeProcess2.platform === "darwin") {
1170
+ return await darwinProcessIdentity(pid, signal);
1171
+ }
1172
+ return void 0;
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
+ }
1181
+ async function inspectProcessIdentity(pid, expected, signal) {
1182
+ throwIfAborted(signal);
1183
+ if (expected === void 0) {
1184
+ return "match";
1185
+ }
1186
+ if (!isCurrentProcessIdentity(expected)) {
1187
+ return "unavailable";
1188
+ }
1189
+ const current = await readProcessIdentity(pid, signal);
1190
+ if (current === void 0 || !isCurrentProcessIdentity(current)) {
1191
+ return "unavailable";
1192
+ }
1193
+ return current === expected ? "match" : "mismatch";
1194
+ }
1195
+
1196
+ // src/cloud-foundry/ssh-one-shot.ts
1197
+ async function cfSshOneShot(appName, command, context, rawOptions = DEFAULT_CF_COMMAND_TIMEOUT_MS) {
1198
+ if (context.signal?.aborted || isDeadlineExpired(context)) {
1199
+ throw sshAbortError(context);
1200
+ }
1201
+ const options = resolveSshOptions(rawOptions, context);
1202
+ const args = buildCfSshArgs(appName, options.target, [
1203
+ "--disable-pseudo-tty",
1204
+ "-c",
1205
+ command
1206
+ ]);
1207
+ return await runSshOneShot(args, context, options);
1208
+ }
1209
+ function resolveSshOptions(raw, context) {
1210
+ const input = typeof raw === "number" ? { timeoutMs: raw } : raw;
1211
+ const requestedTimeoutMs = input.timeoutMs ?? DEFAULT_CF_COMMAND_TIMEOUT_MS;
1212
+ const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
1213
+ if (!Number.isSafeInteger(requestedTimeoutMs) || requestedTimeoutMs <= 0) {
1214
+ throw new CfDebuggerError("UNSAFE_INPUT", "timeoutMs must be a positive safe integer.");
1215
+ }
1216
+ if (!Number.isSafeInteger(maxOutputBytes) || maxOutputBytes <= 0) {
1217
+ throw new CfDebuggerError("UNSAFE_INPUT", "maxOutputBytes must be a positive safe integer.");
1218
+ }
1219
+ const remainingMs = context.deadlineAt === void 0 ? requestedTimeoutMs : Math.max(1, context.deadlineAt - Date.now());
1220
+ return {
1221
+ target: input,
1222
+ timeoutMs: Math.min(requestedTimeoutMs, remainingMs),
1223
+ maxOutputBytes
1224
+ };
1225
+ }
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
+ };
1237
+ }
1238
+ function createSshExecutionState(context, outputLimit) {
1239
+ const redaction = createRedactionPolicy(context.sensitiveValues ?? []);
1240
+ return {
1241
+ stdout: createBoundedOutput(),
1242
+ stderr: createBoundedOutput(),
1243
+ outputLimit,
1244
+ retainedLimit: outputLimit + redaction.overlapBytes,
1245
+ redaction,
1246
+ settled: false,
1247
+ aborted: false,
1248
+ timedOut: false,
1249
+ terminationStarted: false
1250
+ };
1251
+ }
1252
+ function childIsOpen(child) {
1253
+ return child.exitCode === null && child.signalCode === null;
1254
+ }
1255
+ async function captureSshChildIdentity(child) {
1256
+ if (nodeProcess3.platform === "win32" || child.pid === void 0) {
1257
+ return;
1258
+ }
1259
+ try {
1260
+ return await readProcessIdentity(child.pid);
1261
+ } catch {
1262
+ return;
1263
+ }
1264
+ }
1265
+ async function canSignalSshChild(child, childIdentity, signal) {
1266
+ if (!childIsOpen(child) || child.pid === void 0) {
1267
+ return false;
1268
+ }
1269
+ const expectedIdentity = await childIdentity;
1270
+ if (expectedIdentity === void 0) {
1271
+ return signal === "SIGTERM" && childIsOpen(child);
1272
+ }
1273
+ return await inspectProcessIdentity(child.pid, expectedIdentity) === "match" && childIsOpen(child);
1274
+ }
1275
+ function abandonUnverifiedSshChild(child) {
1276
+ try {
1277
+ child.stdout?.destroy();
1278
+ } catch {
1279
+ }
1280
+ try {
1281
+ child.stderr?.destroy();
1282
+ } catch {
1283
+ }
1284
+ try {
1285
+ child.unref();
1286
+ } catch {
1287
+ }
1288
+ }
1289
+ function terminateSshExecution(child, childIdentity, state, settle) {
1290
+ if (state.settled || state.terminationStarted) {
1291
+ return;
1292
+ }
1293
+ state.terminationStarted = true;
1294
+ const settleUnverified = () => {
1295
+ if (!state.settled) {
1296
+ abandonUnverifiedSshChild(child);
1297
+ settle(createResult(null, state));
1298
+ }
1299
+ };
1300
+ void (async () => {
1301
+ if (!await canSignalSshChild(child, childIdentity, "SIGTERM") || state.settled) {
1302
+ settleUnverified();
1303
+ return;
1304
+ }
1305
+ signalChild(child, "SIGTERM");
1306
+ state.forceKillTimer = setTimeout(() => {
1307
+ void (async () => {
1308
+ if (!await canSignalSshChild(child, childIdentity, "SIGKILL") || state.settled) {
1309
+ settleUnverified();
1310
+ return;
1311
+ }
1312
+ signalChild(child, "SIGKILL");
1313
+ })().catch(settleUnverified);
1314
+ }, 1e3);
1315
+ })().catch(settleUnverified);
1316
+ }
1317
+ function createSshSettler(state, options, context, onAbort, resolve, reject) {
1318
+ return (result) => {
1319
+ if (state.settled) {
1320
+ return;
1321
+ }
1322
+ state.settled = true;
1323
+ clearTimeout(state.timeoutTimer);
1324
+ clearTimeout(state.forceKillTimer);
1325
+ context.signal?.removeEventListener("abort", onAbort);
1326
+ if (state.aborted) {
1327
+ reject(sshAbortError(context));
1328
+ return;
1329
+ }
1330
+ resolve(state.timedOut ? { ...result, timedOutAfterMs: options.timeoutMs } : result);
1331
+ };
1332
+ }
1333
+ function attachSshExecution(child, childIdentity, context, options, resolve, reject) {
1334
+ const state = createSshExecutionState(context, options.maxOutputBytes);
1335
+ const onAbort = () => {
1336
+ state.aborted = true;
1337
+ terminateSshExecution(child, childIdentity, state, settle);
1338
+ };
1339
+ const settle = createSshSettler(state, options, context, onAbort, resolve, reject);
1340
+ state.timeoutTimer = setTimeout(() => {
1341
+ state.timedOut = true;
1342
+ terminateSshExecution(child, childIdentity, state, settle);
1343
+ }, options.timeoutMs);
1344
+ if (context.signal?.aborted) {
1345
+ onAbort();
1346
+ } else {
1347
+ context.signal?.addEventListener("abort", onAbort, { once: true });
1348
+ }
1349
+ child.stdout.on("data", (data) => {
1350
+ appendHead(state.stdout, data, state.outputLimit, state.retainedLimit);
1351
+ });
1352
+ child.stderr.on("data", (data) => {
1353
+ appendHead(state.stderr, data, state.outputLimit, state.retainedLimit);
1354
+ });
1355
+ child.on("close", (code, signal) => {
1356
+ const base = createResult(code, state);
1357
+ settle(state.timedOut || signal === null ? base : { ...base, signal });
1358
+ });
1359
+ child.on("error", (error) => {
1360
+ appendHead(state.stderr, error.message, state.outputLimit, state.retainedLimit);
1361
+ settle(createResult(null, state));
1362
+ });
1363
+ }
1364
+ function runSshOneShot(args, context, options) {
1365
+ if (context.signal?.aborted || isDeadlineExpired(context)) {
1366
+ return Promise.reject(sshAbortError(context));
1367
+ }
1368
+ return new Promise((resolve, reject) => {
1369
+ const child = spawn(resolveBin(context), [...args], {
1370
+ env: buildEnv(context.cfHome),
1371
+ detached: nodeProcess3.platform !== "win32",
1372
+ stdio: ["ignore", "pipe", "pipe"]
1373
+ });
1374
+ const childIdentity = captureSshChildIdentity(child);
1375
+ attachSshExecution(child, childIdentity, context, options, resolve, reject);
1376
+ });
1377
+ }
1378
+ function signalChild(child, signal) {
1379
+ if (nodeProcess3.platform !== "win32" && child.pid !== void 0) {
1380
+ try {
1381
+ nodeProcess3.kill(-child.pid, signal);
1382
+ return;
1383
+ } catch {
1384
+ }
1385
+ }
1386
+ try {
1387
+ child.kill(signal);
1388
+ } catch {
1389
+ }
1390
+ }
1391
+
1392
+ // src/cloud-foundry/ssh.ts
1393
+ var tunnelCaptures = /* @__PURE__ */ new WeakMap();
1083
1394
  function isSshDisabledError(stderr) {
1084
1395
  return stderr.toLowerCase().includes("ssh support is disabled");
1085
1396
  }
@@ -1244,21 +1555,41 @@ function spawnSshTunnel(appName, localPort, remotePort, context, target = {}) {
1244
1555
  }
1245
1556
  const tunnelArg = `${localPort.toString()}:localhost:${remotePort.toString()}`;
1246
1557
  const args = buildCfSshArgs(appName, target, ["-N", "-L", tunnelArg]);
1247
- const child = spawn(resolveBin(context), [...args], {
1558
+ const child = spawn2(resolveBin(context), [...args], {
1248
1559
  env: buildEnv(context.cfHome),
1249
- detached: nodeProcess2.platform !== "win32",
1560
+ detached: nodeProcess4.platform !== "win32",
1250
1561
  stdio: ["ignore", "pipe", "pipe"]
1251
1562
  });
1252
1563
  attachTunnelCapture(child, context);
1253
1564
  return child;
1254
1565
  }
1255
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
+
1256
1587
  // src/session-state/store.ts
1257
- import { randomUUID as randomUUID2 } from "crypto";
1258
- 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";
1259
1590
  import { hostname as getHostname2 } from "os";
1260
1591
  import { dirname as dirname2, isAbsolute as isAbsolute3 } from "path";
1261
- import nodeProcess5 from "process";
1592
+ import nodeProcess6 from "process";
1262
1593
 
1263
1594
  // src/debug-session/constants.ts
1264
1595
  var DEFAULT_TUNNEL_READY_TIMEOUT_MS = 18e4;
@@ -1269,87 +1600,6 @@ var CHILD_SIGTERM_GRACE_MS = 2e3;
1269
1600
  var CHILD_SIGKILL_GRACE_MS = 1e3;
1270
1601
  var PID_TERMINATION_POLL_MS = 100;
1271
1602
 
1272
- // src/debug-session/process-identity.ts
1273
- import { execFile as execFile2 } from "child_process";
1274
- import { readFile } from "fs/promises";
1275
- import nodeProcess3 from "process";
1276
- import { promisify } from "util";
1277
- var execFileAsync = promisify(execFile2);
1278
- function errorCode(error) {
1279
- if (typeof error !== "object" || error === null) {
1280
- return void 0;
1281
- }
1282
- const code = Reflect.get(error, "code");
1283
- return typeof code === "string" ? code : void 0;
1284
- }
1285
- function parseLinuxProcessStartTime(statLine) {
1286
- const commandEnd = statLine.lastIndexOf(")");
1287
- if (commandEnd < 0) {
1288
- return void 0;
1289
- }
1290
- const fieldsAfterCommand = statLine.slice(commandEnd + 1).trim().split(/\s+/);
1291
- const startTime = fieldsAfterCommand[19];
1292
- return startTime !== void 0 && /^\d+$/.test(startTime) ? startTime : void 0;
1293
- }
1294
- function throwIfAborted(signal) {
1295
- if (signal?.aborted) {
1296
- throw new CfDebuggerError("ABORTED", "Process identity inspection was aborted.");
1297
- }
1298
- }
1299
- async function linuxProcessIdentity(pid, signal) {
1300
- try {
1301
- const statLine = await readFile(`/proc/${pid.toString()}/stat`, {
1302
- encoding: "utf8",
1303
- ...signal === void 0 ? {} : { signal }
1304
- });
1305
- const startTime = parseLinuxProcessStartTime(statLine);
1306
- return startTime === void 0 ? void 0 : `linux:${startTime}`;
1307
- } catch (error) {
1308
- throwIfAborted(signal);
1309
- if (errorCode(error) === "ENOENT") {
1310
- return void 0;
1311
- }
1312
- return void 0;
1313
- }
1314
- }
1315
- async function darwinProcessIdentity(pid, signal) {
1316
- try {
1317
- const { stdout } = await execFileAsync("ps", ["-p", pid.toString(), "-o", "lstart="], {
1318
- ...signal === void 0 ? {} : { signal },
1319
- timeout: 2e3
1320
- });
1321
- const startedAt = stdout.trim();
1322
- return startedAt.length === 0 ? void 0 : `darwin:${startedAt}`;
1323
- } catch {
1324
- throwIfAborted(signal);
1325
- return void 0;
1326
- }
1327
- }
1328
- async function readProcessIdentity(pid, signal) {
1329
- throwIfAborted(signal);
1330
- if (!Number.isSafeInteger(pid) || pid <= 0) {
1331
- return void 0;
1332
- }
1333
- if (nodeProcess3.platform === "linux") {
1334
- return await linuxProcessIdentity(pid, signal);
1335
- }
1336
- if (nodeProcess3.platform === "darwin") {
1337
- return await darwinProcessIdentity(pid, signal);
1338
- }
1339
- return void 0;
1340
- }
1341
- async function inspectProcessIdentity(pid, expected, signal) {
1342
- throwIfAborted(signal);
1343
- if (expected === void 0) {
1344
- return "match";
1345
- }
1346
- const current = await readProcessIdentity(pid, signal);
1347
- if (current === void 0) {
1348
- return "unavailable";
1349
- }
1350
- return current === expected ? "match" : "mismatch";
1351
- }
1352
-
1353
1603
  // src/lock.ts
1354
1604
  import { randomUUID } from "crypto";
1355
1605
  import { chmod, mkdir, open, readFile as readFile2, stat, unlink } from "fs/promises";
@@ -1438,18 +1688,7 @@ async function readLockOwner(lockPath) {
1438
1688
  throw error;
1439
1689
  }
1440
1690
  }
1441
- async function isStaleLock(lockPath, staleMs) {
1442
- let modifiedAt2;
1443
- try {
1444
- modifiedAt2 = (await stat(lockPath)).mtimeMs;
1445
- } catch (error) {
1446
- if (errorCode2(error) === "ENOENT") {
1447
- return true;
1448
- }
1449
- throw error;
1450
- }
1451
- const owner = await readLockOwner(lockPath);
1452
- const ageMs = Date.now() - modifiedAt2;
1691
+ async function lockOwnerIsStale(owner, ageMs, staleMs) {
1453
1692
  if (owner?.hostname === hostname()) {
1454
1693
  if (!isProcessAlive(owner.pid)) {
1455
1694
  return true;
@@ -1470,20 +1709,54 @@ async function isStaleLock(lockPath, staleMs) {
1470
1709
  }
1471
1710
  return ageMs > staleMs * FOREIGN_HOST_STALE_MULTIPLIER;
1472
1711
  }
1473
- async function reclaimAbandonedRecoveryLock(recoveryPath, staleMs) {
1474
- if (!await isStaleLock(recoveryPath, staleMs)) {
1475
- 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;
1476
1727
  }
1477
- const owner = await readLockOwner(recoveryPath);
1478
- if (owner !== void 0) {
1479
- await removeOwnedLock(recoveryPath, owner.token);
1480
- 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);
1481
1740
  }
1482
- await unlink(recoveryPath).catch((error) => {
1483
- if (errorCode2(error) !== "ENOENT") {
1484
- throw error;
1741
+ try {
1742
+ if (lockFingerprint(await stat(lockPath)) !== observation.fingerprint) {
1743
+ return false;
1485
1744
  }
1486
- });
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);
1487
1760
  }
1488
1761
  async function reclaimStaleLock(lockPath, staleMs) {
1489
1762
  const recoveryPath = `${lockPath}.recovery`;
@@ -1498,17 +1771,11 @@ async function reclaimStaleLock(lockPath, staleMs) {
1498
1771
  throw error;
1499
1772
  }
1500
1773
  try {
1501
- if (!await isStaleLock(lockPath, staleMs)) {
1774
+ const observation = await inspectLockStaleness(lockPath, staleMs);
1775
+ if (observation.status !== "present" || !observation.stale) {
1502
1776
  return false;
1503
1777
  }
1504
- try {
1505
- await unlink(lockPath);
1506
- } catch (error) {
1507
- if (errorCode2(error) !== "ENOENT") {
1508
- throw error;
1509
- }
1510
- }
1511
- return true;
1778
+ return await removeObservedLock(lockPath, observation);
1512
1779
  } finally {
1513
1780
  await releaseFileLock(recoveryPath, recoveryLock);
1514
1781
  }
@@ -1516,12 +1783,13 @@ async function reclaimStaleLock(lockPath, staleMs) {
1516
1783
  async function removeOwnedLock(lockPath, token) {
1517
1784
  const owner = await readLockOwner(lockPath);
1518
1785
  if (owner?.token !== token) {
1519
- return;
1786
+ return false;
1520
1787
  }
1521
- await unlink(lockPath).catch((error) => {
1788
+ return await unlink(lockPath).then(() => true).catch((error) => {
1522
1789
  if (errorCode2(error) !== "ENOENT") {
1523
1790
  throw error;
1524
1791
  }
1792
+ return false;
1525
1793
  });
1526
1794
  }
1527
1795
  async function createFileLock(lockPath) {
@@ -1794,16 +2062,24 @@ function decodeSession(value) {
1794
2062
  return void 0;
1795
2063
  }
1796
2064
  }
1797
- function duplicateSessionIds(sessions) {
2065
+ function decodeSessions(rawSessions) {
1798
2066
  const seen = /* @__PURE__ */ new Set();
1799
- const duplicates = /* @__PURE__ */ new Set();
1800
- 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
+ }
1801
2075
  if (seen.has(session.sessionId)) {
1802
- duplicates.add(session.sessionId);
2076
+ dropped.push(`session[${index.toString()}]: duplicate sessionId ${session.sessionId}`);
2077
+ continue;
1803
2078
  }
1804
2079
  seen.add(session.sessionId);
2080
+ sessions.push(session);
1805
2081
  }
1806
- return duplicates;
2082
+ return { dropped, sessions };
1807
2083
  }
1808
2084
  function decodeStateFileDetailed(value) {
1809
2085
  if (typeof value !== "object" || value === null) {
@@ -1816,36 +2092,46 @@ function decodeStateFileDetailed(value) {
1816
2092
  if (!Array.isArray(rawSessions)) {
1817
2093
  return { kind: "invalid-file", reason: "sessions is not an array" };
1818
2094
  }
1819
- const decoded = rawSessions.map((raw) => decodeSession(raw));
1820
- const valid = decoded.filter((session) => session !== void 0);
1821
- const duplicates = duplicateSessionIds(valid);
1822
- const sessions = valid.filter((session) => !duplicates.has(session.sessionId));
1823
- const dropped = decoded.flatMap((session, index) => {
1824
- if (session === void 0) {
1825
- return [`session[${index.toString()}]: invalid entry`];
1826
- }
1827
- return duplicates.has(session.sessionId) ? [`session[${index.toString()}]: duplicate sessionId ${session.sessionId}`] : [];
1828
- });
2095
+ const decoded = decodeSessions(rawSessions);
1829
2096
  return {
1830
2097
  kind: "decoded",
1831
- state: { version: "2", sessions },
1832
- dropped
2098
+ state: { version: "2", sessions: decoded.sessions },
2099
+ dropped: decoded.dropped
1833
2100
  };
1834
2101
  }
1835
2102
 
1836
2103
  // src/session-state/health.ts
1837
2104
  import { hostname as getHostname } from "os";
1838
- 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
+ }
1839
2124
 
1840
2125
  // src/network/ports.ts
1841
2126
  import { execFile as execFile3 } from "child_process";
1842
2127
  import { readdir, readFile as readFile3, readlink } from "fs/promises";
1843
2128
  import { get as httpGet } from "http";
1844
- import { createConnection, createServer } from "net";
2129
+ import { createServer, Socket } from "net";
1845
2130
  import { promisify as promisify2 } from "util";
1846
2131
  var execFileAsync2 = promisify2(execFile3);
1847
2132
  var PROBE_INTERVAL_MS = 250;
1848
- var INSPECTOR_ATTEMPT_TIMEOUT_MS = 2500;
2133
+ var INITIAL_INSPECTOR_ATTEMPT_TIMEOUT_MS = 2500;
2134
+ var MAX_INSPECTOR_ATTEMPT_TIMEOUT_MS = 1e4;
1849
2135
  var MAX_INSPECTOR_RESPONSE_BYTES = 64 * 1024;
1850
2136
  var OWNER_COMMAND_TIMEOUT_MS = 5e3;
1851
2137
  function sortedUniquePids(pids) {
@@ -2101,22 +2387,32 @@ async function isPortFree(port, signal) {
2101
2387
  }
2102
2388
  async function isPortListening(port, timeoutMs = 200) {
2103
2389
  return await new Promise((resolve) => {
2104
- const socket = createConnection({ port, host: "127.0.0.1" });
2105
- socket.setTimeout(timeoutMs);
2106
- 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;
2107
2397
  socket.destroy();
2108
- resolve(true);
2398
+ resolve(listening);
2399
+ };
2400
+ socket.once("connect", () => {
2401
+ finish(true);
2109
2402
  });
2110
2403
  socket.once("error", () => {
2111
- socket.destroy();
2112
- resolve(false);
2404
+ finish(false);
2113
2405
  });
2114
2406
  socket.once("timeout", () => {
2115
- socket.destroy();
2116
- resolve(false);
2407
+ finish(false);
2117
2408
  });
2409
+ socket.setTimeout(positiveTimeout(timeoutMs));
2410
+ socket.connect({ port, host: "127.0.0.1" });
2118
2411
  });
2119
2412
  }
2413
+ function positiveTimeout(timeoutMs) {
2414
+ return Number.isFinite(timeoutMs) ? Math.max(1, Math.floor(timeoutMs)) : 1;
2415
+ }
2120
2416
  function throwIfAborted2(signal) {
2121
2417
  if (signal?.aborted) {
2122
2418
  throw new CfDebuggerError("ABORTED", "Operation aborted by caller");
@@ -2144,7 +2440,7 @@ async function probeTunnelReady(port, timeoutMs, signal) {
2144
2440
  throwIfAborted2(signal);
2145
2441
  while (Date.now() < deadline) {
2146
2442
  const remainingMs = deadline - Date.now();
2147
- if (await isPortListening(port, Math.min(200, remainingMs))) {
2443
+ if (await isPortListening(port, positiveTimeout(Math.min(200, remainingMs)))) {
2148
2444
  return true;
2149
2445
  }
2150
2446
  const waitMs = Math.min(PROBE_INTERVAL_MS, Math.max(0, deadline - Date.now()));
@@ -2213,21 +2509,28 @@ function readInspectorResponse(response, finish) {
2213
2509
  finish(false);
2214
2510
  });
2215
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
+ }
2216
2521
  function probeInspectorAttempt(port, timeoutMs, signal) {
2217
2522
  throwIfAborted2(signal);
2218
2523
  return new Promise((resolve, reject) => {
2219
2524
  const state = { settled: false };
2220
2525
  const finish = (ready) => {
2221
- if (!state.settled) {
2222
- state.settled = true;
2223
- clearTimeout(state.timer);
2224
- state.removeAbortListener?.();
2225
- resolve(ready);
2226
- }
2526
+ finishInspectorAttempt(state, ready, resolve);
2227
2527
  };
2228
- const request = httpGet({ host: "127.0.0.1", port, path: "/json/list" }, (response) => {
2229
- readInspectorResponse(response, finish);
2230
- });
2528
+ const request = httpGet(
2529
+ { agent: false, host: "127.0.0.1", port, path: "/json/list" },
2530
+ (response) => {
2531
+ readInspectorResponse(response, finish);
2532
+ }
2533
+ );
2231
2534
  const onAbort = () => {
2232
2535
  request.destroy();
2233
2536
  if (!state.settled) {
@@ -2248,7 +2551,7 @@ function probeInspectorAttempt(port, timeoutMs, signal) {
2248
2551
  state.timer = setTimeout(() => {
2249
2552
  request.destroy();
2250
2553
  finish(false);
2251
- }, timeoutMs);
2554
+ }, positiveTimeout(timeoutMs));
2252
2555
  request.once("error", () => {
2253
2556
  if (signal?.aborted) {
2254
2557
  onAbort();
@@ -2258,18 +2561,25 @@ function probeInspectorAttempt(port, timeoutMs, signal) {
2258
2561
  });
2259
2562
  });
2260
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
+ }
2261
2572
  async function probeInspectorReady(port, timeoutMs, signal) {
2262
2573
  const deadline = Date.now() + timeoutMs;
2574
+ let attempt = 0;
2263
2575
  throwIfAborted2(signal);
2264
2576
  while (Date.now() < deadline) {
2265
2577
  const remainingMs = deadline - Date.now();
2266
- const attemptTimeoutMs = Math.max(
2267
- 1,
2268
- Math.min(INSPECTOR_ATTEMPT_TIMEOUT_MS, remainingMs)
2269
- );
2578
+ const attemptTimeoutMs = inspectorAttemptTimeout(attempt, remainingMs);
2270
2579
  if (await probeInspectorAttempt(port, attemptTimeoutMs, signal)) {
2271
2580
  return { status: "ready" };
2272
2581
  }
2582
+ attempt += 1;
2273
2583
  const waitMs = Math.min(PROBE_INTERVAL_MS, Math.max(0, deadline - Date.now()));
2274
2584
  if (waitMs > 0) {
2275
2585
  await waitForNextProbe(waitMs, signal);
@@ -2289,34 +2599,18 @@ function errorCode4(error) {
2289
2599
  }
2290
2600
  function isPidAlive(pid) {
2291
2601
  try {
2292
- nodeProcess4.kill(pid, 0);
2602
+ nodeProcess5.kill(pid, 0);
2293
2603
  return true;
2294
2604
  } catch (error) {
2295
2605
  return errorCode4(error) !== "ESRCH";
2296
2606
  }
2297
2607
  }
2298
2608
  function isProcessGroupAlive(pid) {
2299
- return nodeProcess4.platform !== "win32" && isPidAlive(-pid);
2609
+ return nodeProcess5.platform !== "win32" && isPidAlive(-pid);
2300
2610
  }
2301
2611
  function isPidOrGroupAlive(pid) {
2302
2612
  return isPidAlive(pid) || isProcessGroupAlive(pid);
2303
2613
  }
2304
- function startupAgeLimit(session) {
2305
- return (session.startupTimeoutMs ?? MAX_STARTUP_TIMEOUT_MS) + STARTUP_STALE_SLACK_MS;
2306
- }
2307
- function startupExpired(session) {
2308
- const startedAt = Date.parse(session.startedAt);
2309
- return Number.isNaN(startedAt) || Date.now() - startedAt > startupAgeLimit(session);
2310
- }
2311
- async function inspectRecordedProcess(pid, identity, signal) {
2312
- if (signal?.aborted) {
2313
- throw new CfDebuggerError("ABORTED", "Session health inspection was aborted.");
2314
- }
2315
- if (!isPidAlive(pid)) {
2316
- return "dead";
2317
- }
2318
- return await inspectProcessIdentity(pid, identity, signal);
2319
- }
2320
2614
  async function readySessionHealth(session, signal) {
2321
2615
  const tunnelPid = session.tunnelPid;
2322
2616
  if (tunnelPid === void 0) {
@@ -2325,6 +2619,7 @@ async function readySessionHealth(session, signal) {
2325
2619
  const processVerdict = await inspectRecordedProcess(
2326
2620
  tunnelPid,
2327
2621
  session.tunnelProcessIdentity,
2622
+ isPidAlive,
2328
2623
  signal
2329
2624
  );
2330
2625
  if (processVerdict === "dead" && isProcessGroupAlive(tunnelPid)) {
@@ -2362,6 +2657,7 @@ async function startingSessionHealth(session, signal) {
2362
2657
  const processVerdict = await inspectRecordedProcess(
2363
2658
  controllerPid,
2364
2659
  session.controllerProcessIdentity,
2660
+ isPidAlive,
2365
2661
  signal
2366
2662
  );
2367
2663
  if (processVerdict === "match") {
@@ -2381,19 +2677,16 @@ async function inspectSessionHealth(session, host = getHostname(), signal) {
2381
2677
  }
2382
2678
  return session.status === "ready" ? await readySessionHealth(session, signal) : await startingSessionHealth(session, signal);
2383
2679
  }
2384
- async function filterStaleSessions(sessions, signal) {
2385
- const host = getHostname();
2386
- const verdicts = await Promise.all(
2387
- sessions.map(async (session) => [
2388
- session,
2389
- await inspectSessionHealth(session, host, signal)
2390
- ])
2391
- );
2392
- return verdicts.filter(([, verdict]) => verdict.status !== "stale").map(([session]) => session);
2393
- }
2394
2680
 
2395
2681
  // src/session-state/stop-intent.ts
2396
- 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";
2397
2690
  function errorCode5(error) {
2398
2691
  if (typeof error !== "object" || error === null) {
2399
2692
  return void 0;
@@ -2428,6 +2721,29 @@ async function hasSessionStopIntent(sessionId) {
2428
2721
  throw error;
2429
2722
  }
2430
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
+ }
2431
2747
  async function clearSessionStopIntent(sessionId) {
2432
2748
  await unlink2(sessionStopIntentPath(sessionId)).catch((error) => {
2433
2749
  if (errorCode5(error) !== "ENOENT") {
@@ -2449,7 +2765,7 @@ function errorCode6(error) {
2449
2765
  async function readFileIfPresent(path) {
2450
2766
  try {
2451
2767
  await chmod3(path, 384);
2452
- return await readFile4(path, "utf8");
2768
+ return await readFile5(path, "utf8");
2453
2769
  } catch (error) {
2454
2770
  if (errorCode6(error) === "ENOENT") {
2455
2771
  return void 0;
@@ -2490,7 +2806,7 @@ async function preserveCorruptState(path, reason) {
2490
2806
  const backup = corruptBackupPath(path);
2491
2807
  await rename(path, backup);
2492
2808
  await chmod3(backup, 384);
2493
- nodeProcess5.stderr.write(
2809
+ nodeProcess6.stderr.write(
2494
2810
  `[cf-debugger] warning: preserved invalid state at ${backup} (${reason}).
2495
2811
  `
2496
2812
  );
@@ -2521,7 +2837,7 @@ async function readStateRaw() {
2521
2837
  if (decoded.dropped.length > 0) {
2522
2838
  await preserveCorruptState(path, decoded.dropped.join("; "));
2523
2839
  await writeJsonFileAtomic(path, decoded.state);
2524
- nodeProcess5.stderr.write(
2840
+ nodeProcess6.stderr.write(
2525
2841
  `[cf-debugger] warning: dropped ${decoded.dropped.length.toString()} invalid state entr${decoded.dropped.length === 1 ? "y" : "ies"}; valid sessions were retained.
2526
2842
  `
2527
2843
  );
@@ -2531,22 +2847,62 @@ async function readStateRaw() {
2531
2847
  async function writeState(state) {
2532
2848
  await writeJsonFileAtomic(stateFilePath(), state);
2533
2849
  }
2534
- async function readAndPruneLocked(signal) {
2535
- const raw = await readStateRaw();
2850
+ function samePersistedSession(left, right) {
2851
+ return JSON.stringify(left) === JSON.stringify(right);
2852
+ }
2853
+ async function inspectPruneCandidates(raw, signal) {
2536
2854
  const host = getHostname2();
2537
- const remote = raw.sessions.filter((session) => session.hostname !== host);
2538
2855
  const local = raw.sessions.filter((session) => session.hostname === host);
2539
- const sessions = await filterStaleSessions(local, signal);
2540
- const activeIds = new Set(sessions.map((session) => session.sessionId));
2541
- const removed = local.filter((session) => !activeIds.has(session.sessionId));
2542
- 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
+ }
2543
2885
  if (removed.length > 0) {
2544
- await writeState(persisted);
2886
+ const remote = raw.sessions.filter((session) => session.hostname !== host);
2887
+ await writeState({ version: "2", sessions: [...remote, ...sessions] });
2545
2888
  }
2546
- 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
+ );
2547
2903
  }
2548
2904
  async function readActiveSessions() {
2549
- const result = await withFileLock(stateLockPath(), readAndPruneLocked);
2905
+ const result = await inspectAndPrune();
2550
2906
  return result.sessions;
2551
2907
  }
2552
2908
  async function readSessionSnapshot(options) {
@@ -2555,12 +2911,7 @@ async function readSessionSnapshot(options) {
2555
2911
  }, options);
2556
2912
  }
2557
2913
  async function readAndPruneActiveSessions(options) {
2558
- const result = await withFileLock(
2559
- stateLockPath(),
2560
- async () => await readAndPruneLocked(options?.signal),
2561
- options
2562
- );
2563
- return { sessions: result.sessions, removed: result.removed };
2914
+ return await inspectAndPrune(options);
2564
2915
  }
2565
2916
  function sessionKeyString(key) {
2566
2917
  const base = `${key.region}:${key.org}:${key.space}:${key.app}`;
@@ -2593,34 +2944,41 @@ function matchesRegistrationTarget(session, input, target) {
2593
2944
  apiEndpoint: input.apiEndpoint
2594
2945
  });
2595
2946
  }
2596
- async function pickPort(preferred, reserved, probe, basePort, maxPort) {
2947
+ function portCandidates(preferred, range, scanOffset) {
2597
2948
  const candidates = preferred === void 0 ? [] : [preferred];
2598
- 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;
2599
2952
  if (port !== preferred) {
2600
2953
  candidates.push(port);
2601
2954
  }
2602
2955
  }
2603
- 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)) {
2604
2960
  if (!reserved.has(port) && await probe(port)) {
2605
2961
  return port;
2606
2962
  }
2607
2963
  }
2608
2964
  throw new CfDebuggerError(
2609
2965
  "PORT_UNAVAILABLE",
2610
- `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()}`
2611
2967
  );
2612
2968
  }
2613
- async function selectRegistrationCandidate(input, target, excluded) {
2969
+ async function selectRegistrationCandidate(input, target, excluded, range, scanOffset) {
2970
+ await readAndPruneActiveSessions(input.stateAccess);
2614
2971
  return await withFileLock(stateLockPath(), async () => {
2615
- const current = await readAndPruneLocked(input.stateAccess?.signal);
2616
- 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(
2617
2975
  (session) => matchesRegistrationTarget(session, input, target)
2618
2976
  );
2619
2977
  if (existing !== void 0) {
2620
2978
  return { existing };
2621
2979
  }
2622
2980
  const reserved = /* @__PURE__ */ new Set([
2623
- ...current.sessions.map((session) => session.localPort),
2981
+ ...local.map((session) => session.localPort),
2624
2982
  ...excluded
2625
2983
  ]);
2626
2984
  return {
@@ -2628,20 +2986,58 @@ async function selectRegistrationCandidate(input, target, excluded) {
2628
2986
  input.preferredPort,
2629
2987
  reserved,
2630
2988
  () => Promise.resolve(true),
2631
- input.basePort ?? DEFAULT_BASE_PORT,
2632
- input.maxPort ?? DEFAULT_MAX_PORT
2989
+ range,
2990
+ scanOffset
2633
2991
  )
2634
2992
  };
2635
2993
  }, input.stateAccess);
2636
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
+ }
2637
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");
2638
3022
  const remotePort = input.remotePort ?? DEFAULT_NODE_INSPECTOR_PORT;
2639
- if (!Number.isSafeInteger(remotePort) || remotePort <= 0 || remotePort > 65535) {
2640
- throw new CfDebuggerError("UNSAFE_INPUT", "Remote inspector port must be from 1 to 65535.");
2641
- }
3023
+ validateTcpPort(remotePort, "Remote inspector port");
2642
3024
  if (input.startupTimeoutMs !== void 0 && (!Number.isSafeInteger(input.startupTimeoutMs) || input.startupTimeoutMs <= 0 || input.startupTimeoutMs > MAX_STARTUP_TIMEOUT_MS)) {
2643
3025
  throw new CfDebuggerError("UNSAFE_INPUT", "Persisted startup timeout is outside the supported range.");
2644
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);
2645
3041
  }
2646
3042
  function createRegisteredSession(input, target, localPort, controllerProcessIdentity) {
2647
3043
  const sessionId = (input.sessionIdFactory ?? randomUUID2)();
@@ -2654,8 +3050,8 @@ function createRegisteredSession(input, target, localPort, controllerProcessIden
2654
3050
  }
2655
3051
  return {
2656
3052
  sessionId,
2657
- pid: nodeProcess5.pid,
2658
- controllerPid: nodeProcess5.pid,
3053
+ pid: nodeProcess6.pid,
3054
+ controllerPid: nodeProcess6.pid,
2659
3055
  ...controllerProcessIdentity === void 0 ? {} : { controllerProcessIdentity },
2660
3056
  hostname: getHostname2(),
2661
3057
  region: input.region,
@@ -2675,37 +3071,46 @@ function createRegisteredSession(input, target, localPort, controllerProcessIden
2675
3071
  };
2676
3072
  }
2677
3073
  async function persistRegistration(input, target, candidate, controllerProcessIdentity) {
3074
+ await readAndPruneActiveSessions(input.stateAccess);
2678
3075
  return await withFileLock(stateLockPath(), async () => {
2679
- const current = await readAndPruneLocked(input.stateAccess?.signal);
2680
- 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(
2681
3079
  (session2) => matchesRegistrationTarget(session2, input, target)
2682
3080
  );
2683
3081
  if (existing !== void 0) {
2684
3082
  return { session: existing, existing };
2685
3083
  }
2686
- const reserved = current.sessions.some((session2) => session2.localPort === candidate);
3084
+ const reserved = local.some((session2) => session2.localPort === candidate);
2687
3085
  if (reserved || !await input.portProbe(candidate)) {
2688
3086
  return void 0;
2689
3087
  }
2690
3088
  const session = createRegisteredSession(input, target, candidate, controllerProcessIdentity);
2691
3089
  await writeState({
2692
3090
  version: "2",
2693
- sessions: [...current.persisted.sessions, session]
3091
+ sessions: [...raw.sessions, session]
2694
3092
  });
2695
3093
  return { session };
2696
3094
  }, input.stateAccess);
2697
3095
  }
2698
3096
  async function registerNewSession(input) {
2699
- validateSessionInput(input);
3097
+ const range = validateSessionInput(input);
2700
3098
  const target = resolveNodeTarget(input);
3099
+ const scanOffset = registrationScanOffset(input, target, range);
2701
3100
  const controllerIdentity = await readProcessIdentity(
2702
- nodeProcess5.pid,
3101
+ nodeProcess6.pid,
2703
3102
  input.stateAccess?.signal
2704
3103
  );
2705
3104
  const excluded = /* @__PURE__ */ new Set();
2706
- const maximumAttempts = (input.maxPort ?? DEFAULT_MAX_PORT) - (input.basePort ?? DEFAULT_BASE_PORT) + 2;
3105
+ const maximumAttempts = range.maxPort - range.basePort + 2;
2707
3106
  for (let attempt = 0; attempt < maximumAttempts; attempt += 1) {
2708
- const selection = await selectRegistrationCandidate(input, target, excluded);
3107
+ const selection = await selectRegistrationCandidate(
3108
+ input,
3109
+ target,
3110
+ excluded,
3111
+ range,
3112
+ scanOffset
3113
+ );
2709
3114
  if (selection.existing !== void 0) {
2710
3115
  return { session: selection.existing, existing: selection.existing };
2711
3116
  }
@@ -2819,9 +3224,6 @@ async function requestSessionStop(sessionId) {
2819
3224
  if (target === void 0) {
2820
3225
  return void 0;
2821
3226
  }
2822
- if (target.status === "ready") {
2823
- return { session: target, previousStatus: target.status };
2824
- }
2825
3227
  await writeSessionStopIntent(sessionId);
2826
3228
  if (target.stopRequestedAt !== void 0) {
2827
3229
  return { session: target, previousStatus: target.status };
@@ -2875,51 +3277,59 @@ function cliErrorExitCode(error) {
2875
3277
  }
2876
3278
  return hasCleanupFailure(error) ? CLEANUP_FAILURE_EXIT_CODE : 1;
2877
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
+ }
2878
3286
 
2879
3287
  // src/debug-session/lifecycle.ts
2880
3288
  import { constants as osConstants } from "os";
2881
3289
 
2882
3290
  // src/debug-session/processes.ts
2883
3291
  import process3 from "process";
2884
- async function terminatePidOrGroup(pid, timeoutMs = CHILD_SIGTERM_GRACE_MS, pinnedTarget, verifyBeforeSignal) {
2885
- const targetKind = pinnedTarget ?? (isProcessGroupAlive(pid) ? "group" : "pid");
2886
- const targetAlive = () => targetKind === "group" ? isProcessGroupAlive(pid) : isPidAlive(pid);
2887
- if (!targetAlive()) {
2888
- return "terminated";
2889
- }
2890
- if (verifyBeforeSignal !== void 0 && !await verifyBeforeSignal("SIGTERM")) {
2891
- return "ownership-lost";
2892
- }
3292
+ function terminationTargetAlive(pid, targetKind) {
3293
+ return targetKind === "group" ? isProcessGroupAlive(pid) : isPidAlive(pid);
3294
+ }
3295
+ function signalTerminationTarget(pid, targetKind, signal) {
2893
3296
  try {
2894
- process3.kill(targetKind === "group" ? -pid : pid, "SIGTERM");
3297
+ process3.kill(targetKind === "group" ? -pid : pid, signal);
2895
3298
  } catch {
2896
3299
  }
3300
+ }
3301
+ async function waitForTargetExit(pid, targetKind, timeoutMs) {
2897
3302
  const startedAt = Date.now();
2898
3303
  while (Date.now() - startedAt < timeoutMs) {
2899
- if (!targetAlive()) {
2900
- return "terminated";
3304
+ if (!terminationTargetAlive(pid, targetKind)) {
3305
+ return true;
2901
3306
  }
2902
3307
  await new Promise((resolve) => {
2903
3308
  setTimeout(resolve, PID_TERMINATION_POLL_MS);
2904
3309
  });
2905
3310
  }
2906
- 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")) {
2907
3322
  return "ownership-lost";
2908
3323
  }
2909
- try {
2910
- process3.kill(targetKind === "group" ? -pid : pid, "SIGKILL");
2911
- } catch {
3324
+ signalTerminationTarget(pid, targetKind, "SIGTERM");
3325
+ if (await waitForTargetExit(pid, targetKind, timeoutMs)) {
3326
+ return "terminated";
2912
3327
  }
2913
- const forceStartedAt = Date.now();
2914
- while (Date.now() - forceStartedAt < CHILD_SIGKILL_GRACE_MS) {
2915
- if (!targetAlive()) {
2916
- return "terminated";
2917
- }
2918
- await new Promise((resolve) => {
2919
- setTimeout(resolve, PID_TERMINATION_POLL_MS);
2920
- });
3328
+ if (!await signalIsAuthorized(verifyBeforeSignal, "SIGKILL")) {
3329
+ return "ownership-lost";
2921
3330
  }
2922
- return targetAlive() ? "still-alive" : "terminated";
3331
+ signalTerminationTarget(pid, targetKind, "SIGKILL");
3332
+ return await waitForTargetExit(pid, targetKind, CHILD_SIGKILL_GRACE_MS) ? "terminated" : "still-alive";
2923
3333
  }
2924
3334
  async function killProcessGroupOrProc(child, verifyBeforeSignal, timeoutMs = CHILD_SIGTERM_GRACE_MS) {
2925
3335
  if (child.pid === void 0) {
@@ -2938,7 +3348,39 @@ async function killProcessGroupOrProc(child, verifyBeforeSignal, timeoutMs = CHI
2938
3348
  }
2939
3349
 
2940
3350
  // src/debug-session/session-home.ts
2941
- 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
+ }
2942
3384
  async function removeOwnedSessionCfHome(sessionId, candidate) {
2943
3385
  if (!isOwnedSessionCfHomeDir(sessionId, candidate)) {
2944
3386
  throw new CfDebuggerError(
@@ -2946,6 +3388,10 @@ async function removeOwnedSessionCfHome(sessionId, candidate) {
2946
3388
  `Refusing to remove unowned debugger CF home for session ${sessionId}.`
2947
3389
  );
2948
3390
  }
3391
+ const rootInspection = await inspectSessionHomesRoot(dirname3(candidate));
3392
+ if (rootInspection.status === "unsafe") {
3393
+ throw new CfDebuggerError("UNSAFE_INPUT", rootInspection.reason);
3394
+ }
2949
3395
  await rm(candidate, { recursive: true, force: true });
2950
3396
  }
2951
3397
  async function tryRemoveOwnedSessionCfHome(sessionId, candidate) {
@@ -2960,13 +3406,30 @@ async function tryRemoveOwnedSessionCfHome(sessionId, candidate) {
2960
3406
  }
2961
3407
  }
2962
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
+
2963
3425
  // src/debug-session/lifecycle.ts
2964
3426
  var handleLifecycles = /* @__PURE__ */ new WeakMap();
2965
- function childIsOpen(child) {
3427
+ function childIsOpen2(child) {
2966
3428
  return child.exitCode === null && child.signalCode === null;
2967
3429
  }
2968
3430
  function signalExitCode(signal) {
2969
- return 128 + osConstants.signals[signal];
3431
+ const signum = osConstants.signals[signal];
3432
+ return typeof signum === "number" && Number.isFinite(signum) ? 128 + signum : 1;
2970
3433
  }
2971
3434
  function unexpectedExitCode(code, signal) {
2972
3435
  if (signal !== null) {
@@ -2980,6 +3443,8 @@ function emitSafely(emit, status, message) {
2980
3443
  } catch {
2981
3444
  }
2982
3445
  }
3446
+ function ignoreCleanupFailure() {
3447
+ }
2983
3448
  async function runCleanupActions(actions, aggregateMessage) {
2984
3449
  const errors = [];
2985
3450
  for (const action of actions) {
@@ -2997,10 +3462,10 @@ async function runCleanupActions(actions, aggregateMessage) {
2997
3462
  }
2998
3463
  }
2999
3464
  async function handleTunnelClose(sessionId, code, signal, child, resolveExit, isFinalizing, hasFailed, markFailed, emit) {
3000
- const stopRequested = await hasSessionStopIntent(sessionId);
3465
+ const stopRequested = isFinalizing() ? false : await hasExpectedStopSignal(sessionId);
3001
3466
  const expected = isFinalizing() || stopRequested;
3002
3467
  if (stopRequested) {
3003
- await clearSessionStopIntent(sessionId);
3468
+ await clearSessionStopIntent(sessionId).catch(ignoreCleanupFailure);
3004
3469
  }
3005
3470
  if (!expected && !hasFailed()) {
3006
3471
  const detail = signal === null ? code === null ? "no exit status" : `exit code ${code.toString()}` : `signal ${signal}`;
@@ -3015,6 +3480,19 @@ async function handleTunnelClose(sessionId, code, signal, child, resolveExit, is
3015
3480
  }
3016
3481
  resolveExit(expected && !hasFailed() ? 0 : unexpectedExitCode(code, signal));
3017
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
+ }
3018
3496
  function attachTunnelEvents(sessionId, child, resolveExit, isFinalizing, hasFailed, markFailed, emit) {
3019
3497
  child.once("close", (code, signal) => {
3020
3498
  void handleTunnelClose(
@@ -3057,87 +3535,126 @@ async function expectedTunnelStillOwned(session, child) {
3057
3535
  return ownership.status === "unverified" ? "unverified" : ownership.status === "owned";
3058
3536
  }
3059
3537
  async function cleanupFinishedSession(session) {
3060
- await removeOwnedSessionCfHome(session.sessionId, session.cfHomeDir);
3061
- await removeSession(session.sessionId);
3062
- 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
+ }
3063
3546
  }
3064
- function createTunnelLifecycle(session, emit) {
3065
- let child;
3066
- let childIdentity;
3067
- let childFailed = false;
3068
- let tunnelError;
3069
- let finalizing = false;
3547
+ function initializeTunnelLifecycle() {
3070
3548
  let exitResolve = (_code) => {
3071
3549
  throw new Error("Tunnel exit resolver was used before initialization.");
3072
3550
  };
3073
3551
  const exitPromise = new Promise((resolve) => {
3074
3552
  exitResolve = resolve;
3075
3553
  });
3076
- const observeChild = (tunnelChild) => {
3077
- child = tunnelChild;
3078
- childIdentity = tunnelChild.pid === void 0 ? void 0 : readProcessIdentity(tunnelChild.pid);
3079
- attachTunnelEvents(
3080
- session.sessionId,
3081
- tunnelChild,
3082
- exitResolve,
3083
- () => finalizing,
3084
- () => childFailed,
3085
- (error) => {
3086
- if (!childFailed) {
3087
- childFailed = true;
3088
- tunnelError = error;
3089
- }
3090
- },
3091
- emit
3092
- );
3093
- };
3094
- const verifyBeforeSignal = async (signal) => {
3095
- const tunnelChild = child;
3096
- const childPid = tunnelChild?.pid;
3097
- if (tunnelChild === void 0 || childPid === void 0 || !childIsOpen(tunnelChild)) {
3098
- return false;
3099
- }
3100
- const expectedIdentity = await childIdentity;
3101
- if (expectedIdentity !== void 0) {
3102
- return await inspectProcessIdentity(childPid, expectedIdentity) === "match" && childIsOpen(tunnelChild);
3103
- }
3104
- if (signal === "SIGKILL") {
3105
- 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
3106
3563
  }
3107
- return true;
3108
3564
  };
3109
- const finalize = async (emitStopped) => {
3110
- finalizing = true;
3111
- const termination = child === void 0 ? "terminated" : await killProcessGroupOrProc(child, verifyBeforeSignal);
3112
- const stillOwned = await expectedTunnelStillOwned(session, child);
3113
- if (termination !== "terminated" || stillOwned === true || stillOwned === "unverified") {
3114
- const stderr = child === void 0 ? void 0 : formatTunnelDiagnostics(getTunnelDiagnostics(child));
3115
- throw new CfDebuggerError(
3116
- "TUNNEL_TERMINATION_FAILED",
3117
- `Tunnel for session ${session.sessionId} did not terminate cleanly; state and CF home were retained.`,
3118
- stderr
3119
- );
3120
- }
3121
- await cleanupFinishedSession(session);
3122
- if (emitStopped) {
3123
- emitSafely(emit, "stopped");
3124
- }
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);
3125
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;
3126
3645
  return {
3127
- exitPromise,
3646
+ exitPromise: initialized.exitPromise,
3128
3647
  assertRunning: () => {
3129
- if (child === void 0 || !childIsOpen(child) || childFailed) {
3130
- throw new CfDebuggerError(
3131
- "TUNNEL_NOT_READY",
3132
- `SSH tunnel for session ${session.sessionId} exited before readiness could be committed.`,
3133
- child === void 0 ? void 0 : formatTunnelDiagnostics(getTunnelDiagnostics(child))
3134
- );
3135
- }
3648
+ assertTunnelRunning(state, session);
3649
+ },
3650
+ finalize: async (emitStopped) => {
3651
+ await finalizeTunnelLifecycle(state, session, emit, emitStopped);
3652
+ },
3653
+ observeChild: (child) => {
3654
+ observeTunnelChild(state, session.sessionId, child, emit);
3136
3655
  },
3137
- finalize,
3138
- observeChild,
3139
- failed: () => childFailed,
3140
- error: () => tunnelError
3656
+ failed: () => state.childFailed,
3657
+ error: () => state.tunnelError
3141
3658
  };
3142
3659
  }
3143
3660
  function createDebuggerHandle(session, emit, lifecycle) {
@@ -3245,7 +3762,7 @@ function throwIfStartupAborted(signal, expiresAt, timeoutMs, phase) {
3245
3762
 
3246
3763
  // src/debug-session/start.ts
3247
3764
  import { chmod as chmod4, mkdir as mkdir4 } from "fs/promises";
3248
- import nodeProcess6 from "process";
3765
+ import nodeProcess7 from "process";
3249
3766
 
3250
3767
  // src/debug-session/orphans.ts
3251
3768
  import { readdir as readdir2, stat as stat2, unlink as unlink4 } from "fs/promises";
@@ -3288,6 +3805,16 @@ async function pruneAndCleanupOrphans(stateAccess) {
3288
3805
 
3289
3806
  // src/debug-session/startup-cancellation.ts
3290
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
+ }
3291
3818
  function createStartupCancellation(sessionId, callerSignal) {
3292
3819
  const controller = new AbortController();
3293
3820
  let active = true;
@@ -3303,7 +3830,7 @@ function createStartupCancellation(sessionId, callerSignal) {
3303
3830
  };
3304
3831
  const poll = async () => {
3305
3832
  try {
3306
- if (active && await hasSessionStopIntent(sessionId)) {
3833
+ if (active && await startupStopWasRequested(sessionId)) {
3307
3834
  controller.abort();
3308
3835
  }
3309
3836
  } catch {
@@ -3487,6 +4014,8 @@ async function signalRemoteNode(inputs) {
3487
4014
  }
3488
4015
 
3489
4016
  // src/debug-session/startup-tunnel.ts
4017
+ var INSPECTOR_READY_RESERVE_MS = 1e4;
4018
+ var OWNER_READY_ATTEMPT_MAX_MS = 5e3;
3490
4019
  function linkAbortSignals(signals) {
3491
4020
  const controller = new AbortController();
3492
4021
  const subscriptions = [];
@@ -3510,6 +4039,35 @@ function linkAbortSignals(signals) {
3510
4039
  }
3511
4040
  };
3512
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
+ }
3513
4071
  function diagnosticsStderr(child) {
3514
4072
  return formatTunnelDiagnostics(getTunnelDiagnostics(child));
3515
4073
  }
@@ -3530,6 +4088,33 @@ function remainingReadyTimeout(inputs) {
3530
4088
  Math.min(inputs.tunnelReadyTimeoutMs, inputs.context.deadlineAt - Date.now())
3531
4089
  );
3532
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
+ }
3533
4118
  function startupStateAccess(inputs) {
3534
4119
  return {
3535
4120
  ...inputs.context.signal === void 0 ? {} : { signal: inputs.context.signal },
@@ -3554,6 +4139,7 @@ function ownerVerificationError(localPort, inspection, child) {
3554
4139
  }
3555
4140
  async function waitForLocalTunnel(inputs, child, timeoutMs) {
3556
4141
  const childEnded = new AbortController();
4142
+ const childExit = observeChildExit(child);
3557
4143
  const linked = linkAbortSignals([
3558
4144
  ...inputs.context.signal === void 0 ? [] : [inputs.context.signal],
3559
4145
  childEnded.signal
@@ -3562,7 +4148,7 @@ async function waitForLocalTunnel(inputs, child, timeoutMs) {
3562
4148
  const probe = probeTunnelReady(inputs.session.localPort, timeoutMs, linked.signal);
3563
4149
  const winner = await Promise.race([
3564
4150
  probe.then((ready) => ({ kind: "probe", ready })),
3565
- childExitPromise(child).then(() => ({ kind: "child-exit" }))
4151
+ childExit.promise.then(() => ({ kind: "child-exit" }))
3566
4152
  ]);
3567
4153
  if (winner.kind === "probe" && winner.ready) {
3568
4154
  return;
@@ -3571,37 +4157,48 @@ async function waitForLocalTunnel(inputs, child, timeoutMs) {
3571
4157
  childEnded.abort();
3572
4158
  await probe.catch(() => false);
3573
4159
  }
4160
+ } finally {
4161
+ childExit.dispose();
4162
+ linked.dispose();
4163
+ }
4164
+ throw new CfDebuggerError(
4165
+ "TUNNEL_NOT_READY",
4166
+ `SSH tunnel on local port ${inputs.session.localPort.toString()} did not bind within ${Math.round(timeoutMs / 1e3).toString()}s.`,
4167
+ diagnosticsStderr(child)
4168
+ );
4169
+ }
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
+ }
3574
4192
  } finally {
3575
4193
  linked.dispose();
3576
4194
  }
3577
- throw new CfDebuggerError(
3578
- "TUNNEL_NOT_READY",
3579
- `SSH tunnel on local port ${inputs.session.localPort.toString()} did not bind within ${Math.round(timeoutMs / 1e3).toString()}s.`,
3580
- diagnosticsStderr(child)
3581
- );
3582
- }
3583
- async function verifyLocalOwner(inputs, child, childPid) {
3584
- const ownership = await inspectPortOwnership(
3585
- inputs.session.localPort,
3586
- childPid,
3587
- inputs.context.signal
3588
- );
3589
4195
  if (ownership.status !== "owned") {
3590
4196
  throw ownerVerificationError(inputs.session.localPort, ownership, child);
3591
4197
  }
3592
4198
  }
3593
- function childExitPromise(child) {
3594
- if (child.exitCode !== null || child.signalCode !== null) {
3595
- return Promise.resolve();
3596
- }
3597
- return new Promise((resolve) => {
3598
- child.once("close", () => {
3599
- resolve();
3600
- });
3601
- });
3602
- }
3603
4199
  async function verifyInspector(inputs, child, timeoutMs) {
3604
4200
  const childEnded = new AbortController();
4201
+ const childExit = observeChildExit(child);
3605
4202
  const linked = linkAbortSignals([
3606
4203
  ...inputs.context.signal === void 0 ? [] : [inputs.context.signal],
3607
4204
  childEnded.signal
@@ -3610,7 +4207,7 @@ async function verifyInspector(inputs, child, timeoutMs) {
3610
4207
  const probe = probeInspectorReady(inputs.session.localPort, timeoutMs, linked.signal);
3611
4208
  const winner = await Promise.race([
3612
4209
  probe.then((result) => ({ kind: "probe", result })),
3613
- childExitPromise(child).then(() => ({ kind: "child-exit" }))
4210
+ childExit.promise.then(() => ({ kind: "child-exit" }))
3614
4211
  ]);
3615
4212
  if (winner.kind === "probe" && winner.result.status === "ready") {
3616
4213
  return;
@@ -3620,6 +4217,7 @@ async function verifyInspector(inputs, child, timeoutMs) {
3620
4217
  await probe.catch(() => ({ status: "unreachable" }));
3621
4218
  }
3622
4219
  } finally {
4220
+ childExit.dispose();
3623
4221
  linked.dispose();
3624
4222
  }
3625
4223
  throw new CfDebuggerError(
@@ -3677,15 +4275,14 @@ async function openReadyTunnel(inputs) {
3677
4275
  inputs.onChild(child);
3678
4276
  const childPid = requireChildPid(child);
3679
4277
  await recordTunnelPid(inputs, childPid);
3680
- const timeoutMs = remainingReadyTimeout(inputs);
3681
- const readyDeadline = Date.now() + timeoutMs;
3682
- await waitForLocalTunnel(inputs, child, timeoutMs);
4278
+ const readyWindow = createReadyWindow(remainingReadyTimeout(inputs));
4279
+ await waitForLocalTunnel(inputs, child, readyWindow.localTimeoutMs);
3683
4280
  ensureStartupActive(inputs, "local tunnel binding");
3684
- await verifyLocalOwner(inputs, child, childPid);
4281
+ await verifyLocalOwner(inputs, child, childPid, readyWindow.ownerTimeoutMs);
3685
4282
  ensureStartupActive(inputs, "local tunnel ownership verification");
3686
- await verifyInspector(inputs, child, Math.max(1, readyDeadline - Date.now()));
4283
+ await verifyInspector(inputs, child, remainingInspectorMs(readyWindow));
3687
4284
  ensureStartupActive(inputs, "inspector readiness verification");
3688
- await verifyLocalOwner(inputs, child, childPid);
4285
+ await verifyLocalOwner(inputs, child, childPid, remainingWindowMs(readyWindow));
3689
4286
  ensureStartupActive(inputs, "final local tunnel ownership verification");
3690
4287
  if (child.exitCode !== null || child.signalCode !== null) {
3691
4288
  throw new CfDebuggerError(
@@ -3697,9 +4294,17 @@ async function openReadyTunnel(inputs) {
3697
4294
  }
3698
4295
 
3699
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
+ }
3700
4305
  function requireCredentials(options) {
3701
- const email = options.email ?? nodeProcess6.env["SAP_EMAIL"];
3702
- 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"];
3703
4308
  if (email === void 0 || email.length === 0) {
3704
4309
  throw new CfDebuggerError(
3705
4310
  "MISSING_CREDENTIALS",
@@ -3885,11 +4490,11 @@ async function establishDebuggerSession(inputs) {
3885
4490
  return readySession;
3886
4491
  }
3887
4492
  function writeWarning(message) {
3888
- nodeProcess6.stderr.write(`[cf-debugger] warning: ${message}
4493
+ nodeProcess7.stderr.write(`[cf-debugger] warning: ${message}
3889
4494
  `);
3890
4495
  }
3891
4496
  function writeTunnelOutput(stream, text) {
3892
- nodeProcess6.stderr.write(`[cf-debugger tunnel ${stream}] ${text}
4497
+ nodeProcess7.stderr.write(`[cf-debugger tunnel ${stream}] ${text}
3893
4498
  `);
3894
4499
  }
3895
4500
  function retryMessage(status) {
@@ -3931,59 +4536,102 @@ function normalizeStartupError(error, expiresAt, timeoutMs) {
3931
4536
  }
3932
4537
  return error;
3933
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
+ }
3934
4627
  async function startDebuggerUsingDeadline(options, deadline) {
3935
- const target = resolveNodeTarget(options);
3936
- const credentials = requireCredentials(options);
3937
- const apiEndpoint = resolveApiEndpoint(options.region, options.apiEndpoint, writeWarning);
3938
- const startupTimeoutMs = deadline.timeoutMs;
3939
- const tunnelReadyTimeoutMs = resolveTunnelReadyTimeoutMs(options.tunnelReadyTimeoutMs);
3940
- const tracker = createStatusTracker(options);
3941
- tracker.emit("starting");
3942
- let cancellation;
3943
- let lifecycle;
4628
+ const plan = resolveStartupPlan(options);
4629
+ const resources = {};
4630
+ plan.tracker.emit("starting");
3944
4631
  try {
3945
- throwIfStartupAborted(deadline.signal, deadline.expiresAt, startupTimeoutMs, "initialization");
3946
- await pruneAndCleanupOrphans(
3947
- startupStateAccess2(deadline.signal, deadline.expiresAt)
3948
- );
3949
- throwIfStartupAborted(deadline.signal, deadline.expiresAt, startupTimeoutMs, "state cleanup");
3950
- const session = await registerSession(options, target, apiEndpoint, deadline);
3951
- cancellation = createStartupCancellation(session.sessionId, deadline.signal);
3952
- const context = createCfContext(
3953
- session,
3954
- credentials,
3955
- cancellation,
3956
- deadline,
3957
- tracker,
3958
- options.verbose === true
3959
- );
3960
- lifecycle = createTunnelLifecycle(session, tracker.emit);
3961
- const activeSession = await establishDebuggerSession({
3962
- options: { ...options, remotePort: session.remotePort },
3963
- target,
3964
- session,
3965
- context,
3966
- credentials,
3967
- tunnelReadyTimeoutMs,
3968
- emit: tracker.emit,
3969
- transition: createTransition(session.sessionId, context),
3970
- lifecycle
3971
- });
3972
- cancellation.dispose();
3973
- deadline.dispose();
3974
- return createDebuggerHandle(activeSession, tracker.emit, lifecycle);
4632
+ return await completeStartup(plan, deadline, resources);
3975
4633
  } catch (error) {
3976
- cancellation?.dispose();
3977
- deadline.dispose();
3978
- const normalized = normalizeStartupError(error, deadline.expiresAt, startupTimeoutMs);
3979
- if (lifecycle === void 0) {
3980
- tracker.emit(
3981
- "error",
3982
- normalized instanceof Error ? normalized.message : String(normalized)
3983
- );
3984
- throw normalized;
3985
- }
3986
- return await cleanupFailedStartup(normalized, lifecycle, tracker.emit);
4634
+ return await handleStartupFailure(error, plan, resources, deadline);
3987
4635
  }
3988
4636
  }
3989
4637
  async function startDebuggerWithinDeadline(options, deadline) {
@@ -4002,31 +4650,261 @@ async function startDebugger(options) {
4002
4650
 
4003
4651
  // src/debug-session/doctor.ts
4004
4652
  import {
4005
- lstat,
4006
- readFile as readFile5,
4007
- readdir as readdir3,
4653
+ lstat as lstat3,
4654
+ readFile as readFile7,
4655
+ readdir as readdir5,
4008
4656
  unlink as unlink5
4009
4657
  } from "fs/promises";
4010
- 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";
4011
4664
  import { join as join3 } from "path";
4012
- 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
4013
4894
  var MANAGED_PORT_MIN = 2e4;
4014
4895
  var MANAGED_PORT_MAX = 20999;
4015
4896
  var PORT_SCAN_CONCURRENCY = 32;
4016
4897
  var STALE_TEMP_AGE_MS2 = 24 * 60 * 60 * 1e3;
4017
4898
  var STALE_LOCK_AGE_MS = 60 * 60 * 1e3;
4018
4899
  var FOREIGN_OR_MALFORMED_LOCK_AGE_MS = 24 * 60 * 60 * 1e3;
4019
- var LEGACY_STATE_FILENAME = "cf-debugger-state.json";
4020
- var LEGACY_LOCK_FILENAME = "cf-debugger-state.lock";
4021
- var LEGACY_HOMES_DIRNAME = "cf-debugger-homes";
4022
- function errorCode7(error) {
4900
+ function errorCode9(error) {
4023
4901
  if (typeof error !== "object" || error === null) {
4024
4902
  return void 0;
4025
4903
  }
4026
4904
  const value = Reflect.get(error, "code");
4027
4905
  return typeof value === "string" ? value : void 0;
4028
4906
  }
4029
- function errorMessage(error) {
4907
+ function errorMessage2(error) {
4030
4908
  return error instanceof Error ? error.message : String(error);
4031
4909
  }
4032
4910
  function emptyState2() {
@@ -4035,14 +4913,14 @@ function emptyState2() {
4035
4913
  async function readDoctorState() {
4036
4914
  let raw;
4037
4915
  try {
4038
- raw = await readFile5(stateFilePath(), "utf8");
4916
+ raw = await readFile7(stateFilePath(), "utf8");
4039
4917
  } catch (error) {
4040
- if (errorCode7(error) === "ENOENT") {
4918
+ if (errorCode9(error) === "ENOENT") {
4041
4919
  return { state: emptyState2(), warnings: [], homeCleanupSafe: true };
4042
4920
  }
4043
4921
  return {
4044
4922
  state: emptyState2(),
4045
- warnings: [`Could not read debugger state: ${errorMessage(error)}`],
4923
+ warnings: [`Could not read debugger state: ${errorMessage2(error)}`],
4046
4924
  homeCleanupSafe: false
4047
4925
  };
4048
4926
  }
@@ -4070,54 +4948,58 @@ async function readDoctorState() {
4070
4948
  homeCleanupSafe: decoded.dropped.length === 0
4071
4949
  };
4072
4950
  }
4073
- async function inspectSessions(sessions) {
4074
- const local = sessions.filter((session) => session.hostname === hostname2());
4075
- return await Promise.all(local.map(async (session) => {
4076
- try {
4077
- return { session, health: await inspectSessionHealth(session) };
4078
- } catch (error) {
4079
- return {
4080
- session,
4081
- health: {
4082
- status: "unverified",
4083
- reason: `health inspection failed: ${errorMessage(error)}`
4084
- }
4085
- };
4086
- }
4087
- }));
4088
- }
4089
4951
  async function readDirectory(path) {
4090
4952
  try {
4091
- return await readdir3(path, { withFileTypes: true });
4953
+ return await readdir5(path, { withFileTypes: true });
4092
4954
  } catch (error) {
4093
- if (errorCode7(error) === "ENOENT") {
4955
+ if (errorCode9(error) === "ENOENT") {
4094
4956
  return [];
4095
4957
  }
4096
4958
  throw error;
4097
4959
  }
4098
4960
  }
4099
- async function findOrphanHomes(sessions, cleanup) {
4100
- const root = join3(saptoolsDir(), CF_DEBUGGER_HOMES_DIRNAME);
4101
- const claimed = new Set(sessions.map((session) => session.sessionId));
4102
- const entries = await readDirectory(root);
4103
- const orphans = entries.filter((entry) => entry.isDirectory() && !claimed.has(entry.name)).sort((left, right) => left.name.localeCompare(right.name));
4104
- return await Promise.all(orphans.map(async (entry) => {
4105
- const path = join3(root, entry.name);
4106
- const cleanupEligible = isOwnedSessionCfHomeDir(entry.name, path);
4107
- if (!cleanup) {
4108
- return { sessionId: entry.name, path, cleanupEligible, cleanupStatus: "not-requested" };
4109
- }
4110
- if (!cleanupEligible) {
4111
- 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
+ };
4112
5000
  }
4113
- const cleanupStatus = await cleanupOrphanHome(entry.name, path);
4114
- return {
4115
- sessionId: entry.name,
4116
- path,
4117
- cleanupEligible,
4118
- cleanupStatus
4119
- };
4120
- }));
5001
+ ));
5002
+ return { findings, warnings: discovery.warnings };
4121
5003
  }
4122
5004
  async function cleanupOrphanHome(sessionId, path) {
4123
5005
  try {
@@ -4158,7 +5040,7 @@ async function mapConcurrent(values, concurrency, work) {
4158
5040
  }
4159
5041
  function unclaimedManagedPorts(sessions) {
4160
5042
  const localClaims = new Set(
4161
- sessions.filter((session) => session.hostname === hostname2()).map((session) => session.localPort)
5043
+ sessions.filter((session) => session.hostname === hostname4()).map((session) => session.localPort)
4162
5044
  );
4163
5045
  const ports = [];
4164
5046
  for (let port = MANAGED_PORT_MIN; port <= MANAGED_PORT_MAX; port += 1) {
@@ -4235,15 +5117,15 @@ function parseLockOwner2(raw) {
4235
5117
  }
4236
5118
  function isPidAlive2(pid) {
4237
5119
  try {
4238
- nodeProcess7.kill(pid, 0);
5120
+ nodeProcess10.kill(pid, 0);
4239
5121
  return true;
4240
5122
  } catch (error) {
4241
- return errorCode7(error) !== "ESRCH";
5123
+ return errorCode9(error) !== "ESRCH";
4242
5124
  }
4243
5125
  }
4244
5126
  async function readLockOwner2(path) {
4245
5127
  try {
4246
- return parseLockOwner2(await readFile5(path, "utf8"));
5128
+ return parseLockOwner2(await readFile7(path, "utf8"));
4247
5129
  } catch {
4248
5130
  return void 0;
4249
5131
  }
@@ -4253,7 +5135,7 @@ async function lockCleanupEligible(path, ageMs) {
4253
5135
  return false;
4254
5136
  }
4255
5137
  const owner = await readLockOwner2(path);
4256
- if (owner?.hostname === hostname2()) {
5138
+ if (owner?.hostname === hostname4()) {
4257
5139
  if (!isPidAlive2(owner.pid)) {
4258
5140
  return true;
4259
5141
  }
@@ -4283,18 +5165,21 @@ async function inspectArtifact(entry, now, claimedSessionIds, stopIntentCleanupS
4283
5165
  if (kind === void 0) {
4284
5166
  return void 0;
4285
5167
  }
4286
- const path = join3(saptoolsDir(), entry.name);
4287
- const stats = await lstat(path);
5168
+ const path = join5(saptoolsDir(), entry.name);
5169
+ const stats = await lstat3(path);
4288
5170
  const ageMs = Math.max(0, now - stats.mtimeMs);
4289
5171
  const regularFile = stats.isFile();
4290
5172
  let sessionId;
4291
5173
  let cleanupEligible = regularFile && kind === "state-temp" && ageMs >= STALE_TEMP_AGE_MS2;
5174
+ let cleanupBlocked = false;
4292
5175
  if (regularFile && kind === "stop-intent") {
4293
5176
  sessionId = entry.name.slice(
4294
5177
  CF_DEBUGGER_STOP_INTENT_PREFIX.length,
4295
5178
  -".stop".length
4296
5179
  );
4297
- 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;
4298
5183
  }
4299
5184
  if (regularFile && (kind === "state-lock" || kind === "state-recovery")) {
4300
5185
  cleanupEligible = await lockCleanupEligible(path, ageMs);
@@ -4304,13 +5189,14 @@ async function inspectArtifact(entry, now, claimedSessionIds, stopIntentCleanupS
4304
5189
  path,
4305
5190
  ageMs,
4306
5191
  cleanupEligible,
5192
+ cleanupBlocked,
4307
5193
  fingerprint: artifactFingerprint(stats),
4308
5194
  ...sessionId === void 0 ? {} : { sessionId }
4309
5195
  };
4310
5196
  }
4311
5197
  async function candidateStillMatches(candidate) {
4312
5198
  try {
4313
- const stats = await lstat(candidate.path);
5199
+ const stats = await lstat3(candidate.path);
4314
5200
  return artifactFingerprint(stats) === candidate.fingerprint;
4315
5201
  } catch {
4316
5202
  return false;
@@ -4342,11 +5228,19 @@ async function cleanupArtifact(candidate) {
4342
5228
  await unlink5(candidate.path);
4343
5229
  return { status: "removed" };
4344
5230
  } catch (error) {
4345
- if (errorCode7(error) === "ENOENT") {
5231
+ if (errorCode9(error) === "ENOENT") {
4346
5232
  return { status: "skipped" };
4347
5233
  }
4348
- return { status: "failed", error: errorMessage(error) };
5234
+ return { status: "failed", error: errorMessage2(error) };
5235
+ }
5236
+ }
5237
+ async function resolveArtifactCleanup(candidate, cleanup) {
5238
+ if (!cleanup) {
5239
+ return {
5240
+ status: candidate.cleanupEligible ? "not-requested" : "not-eligible"
5241
+ };
4349
5242
  }
5243
+ return candidate.cleanupBlocked ? { status: "skipped" } : await cleanupArtifact(candidate);
4350
5244
  }
4351
5245
  async function findArtifacts(cleanup, sessions, stopIntentCleanupSafe) {
4352
5246
  const entries = await readDirectory(saptoolsDir());
@@ -4361,55 +5255,22 @@ async function findArtifacts(cleanup, sessions, stopIntentCleanupSafe) {
4361
5255
  ).sort((left, right) => left.path.localeCompare(right.path));
4362
5256
  const findings = [];
4363
5257
  for (const candidate of candidates) {
4364
- const result = cleanup ? await cleanupArtifact(candidate) : {
4365
- status: candidate.cleanupEligible ? "not-requested" : "not-eligible"
4366
- };
5258
+ const result = await resolveArtifactCleanup(candidate, cleanup);
4367
5259
  findings.push({
4368
5260
  kind: candidate.kind,
4369
5261
  path: candidate.path,
4370
5262
  ageMs: candidate.ageMs,
4371
5263
  cleanupEligible: candidate.cleanupEligible,
4372
5264
  cleanupStatus: result.status,
4373
- ...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
+ } : {}
4374
5270
  });
4375
5271
  }
4376
5272
  return findings;
4377
5273
  }
4378
- async function pathExists(path) {
4379
- try {
4380
- await lstat(path);
4381
- return true;
4382
- } catch (error) {
4383
- if (errorCode7(error) === "ENOENT") {
4384
- return false;
4385
- }
4386
- throw error;
4387
- }
4388
- }
4389
- function shellQuote(value) {
4390
- return `'${value.replaceAll("'", `'"'"'`)}'`;
4391
- }
4392
- async function findLegacyArtifacts() {
4393
- const statePath = join3(saptoolsDir(), LEGACY_STATE_FILENAME);
4394
- const lockPath = join3(saptoolsDir(), LEGACY_LOCK_FILENAME);
4395
- const homesPath = join3(saptoolsDir(), LEGACY_HOMES_DIRNAME);
4396
- const [statePresent, homesPresent] = await Promise.all([
4397
- pathExists(statePath),
4398
- pathExists(homesPath)
4399
- ]);
4400
- if (!statePresent && !homesPresent) {
4401
- return { statePath, statePresent, homesPath, homesPresent };
4402
- }
4403
- const command = `rm -rf -- ${shellQuote(homesPath)} ${shellQuote(statePath)} ${shellQuote(lockPath)}`;
4404
- return {
4405
- statePath,
4406
- statePresent,
4407
- homesPath,
4408
- homesPresent,
4409
- warning: "Legacy v1 debugger homes may contain live CF refresh and access tokens. Confirm no v1 tunnel is running before removing them.",
4410
- manualRemovalCommand: command
4411
- };
4412
- }
4413
5274
  function reportWarnings(stateWarnings, orphanHomes, unclaimedPorts, legacy) {
4414
5275
  const warnings = [...stateWarnings];
4415
5276
  if (orphanHomes.length > 0) {
@@ -4426,14 +5287,14 @@ function reportWarnings(stateWarnings, orphanHomes, unclaimedPorts, legacy) {
4426
5287
  async function runDoctor(options = {}) {
4427
5288
  const cleanup = options.cleanup === true;
4428
5289
  const stateResult = await readDoctorState();
4429
- const homeCleanup = cleanup && stateResult.homeCleanupSafe;
4430
- const [sessions, orphanHomes, unclaimedPorts, artifacts, legacy] = await Promise.all([
4431
- inspectSessions(stateResult.state.sessions),
4432
- 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),
4433
5293
  findUnclaimedPorts(stateResult.state.sessions),
4434
5294
  findArtifacts(cleanup, stateResult.state.sessions, stateResult.homeCleanupSafe),
4435
5295
  findLegacyArtifacts()
4436
5296
  ]);
5297
+ const orphanHomes = homeInspection.findings;
4437
5298
  const cleanedPaths = [
4438
5299
  ...orphanHomes.filter((finding) => finding.cleanupStatus === "removed").map((finding) => finding.path),
4439
5300
  ...artifacts.filter((finding) => finding.cleanupStatus === "removed").map((finding) => finding.path)
@@ -4446,6 +5307,7 @@ async function runDoctor(options = {}) {
4446
5307
  legacy,
4447
5308
  warnings: [
4448
5309
  ...reportWarnings(stateResult.warnings, orphanHomes, unclaimedPorts, legacy),
5310
+ ...homeInspection.warnings,
4449
5311
  ...cleanup && !stateResult.homeCleanupSafe ? [
4450
5312
  "Skipped orphan-home and stop-intent cleanup because debugger state was incomplete or invalid."
4451
5313
  ] : []
@@ -4456,7 +5318,7 @@ async function runDoctor(options = {}) {
4456
5318
 
4457
5319
  // src/debug-session/sessions.ts
4458
5320
  import { hostname as getHostname4 } from "os";
4459
- import nodeProcess8 from "process";
5321
+ import nodeProcess11 from "process";
4460
5322
  function findMatchingSession(sessions, options) {
4461
5323
  if (options.sessionId !== void 0) {
4462
5324
  return sessions.find((session) => session.sessionId === options.sessionId);
@@ -4474,50 +5336,55 @@ function findMatchingSession(sessions, options) {
4474
5336
  }
4475
5337
  return matches[0];
4476
5338
  }
4477
- function startupAgeLimit2(session) {
4478
- return (session.startupTimeoutMs ?? MAX_STARTUP_TIMEOUT_MS) + STARTUP_STALE_SLACK_MS;
4479
- }
4480
- function startupExpired2(session) {
4481
- const startedAt = Date.parse(session.startedAt);
4482
- return Number.isNaN(startedAt) || Date.now() - startedAt > startupAgeLimit2(session);
4483
- }
4484
- async function inspectRecordedProcess2(pid, identity) {
4485
- if (!isPidAlive(pid)) {
4486
- return "dead";
4487
- }
4488
- return await inspectProcessIdentity(pid, identity);
4489
- }
4490
5339
  async function inspectController(target) {
4491
5340
  const controllerPid = target.controllerPid ?? target.pid;
4492
- return await inspectRecordedProcess2(controllerPid, target.controllerProcessIdentity);
5341
+ return await inspectRecordedProcess(
5342
+ controllerPid,
5343
+ target.controllerProcessIdentity,
5344
+ isPidAlive
5345
+ );
4493
5346
  }
4494
5347
  async function ownsRecordedTunnel(target) {
4495
5348
  const tunnelPid = target.tunnelPid;
4496
5349
  if (tunnelPid === void 0) {
4497
5350
  return false;
4498
5351
  }
4499
- const processVerdict = await inspectRecordedProcess2(
5352
+ const processVerdict = await inspectRecordedProcess(
4500
5353
  tunnelPid,
4501
- target.tunnelProcessIdentity
5354
+ target.tunnelProcessIdentity,
5355
+ isPidAlive
4502
5356
  );
4503
5357
  if (processVerdict !== "match") {
4504
5358
  return false;
4505
5359
  }
4506
5360
  return (await inspectPortOwnership(target.localPort, tunnelPid)).status === "owned";
4507
5361
  }
4508
- async function terminateVerifiedTunnel(target) {
5362
+ async function terminateVerifiedTunnel(target, recordExpectedStop) {
4509
5363
  const tunnelPid = target.tunnelPid;
4510
- if (tunnelPid === void 0 || tunnelPid === nodeProcess8.pid) {
4511
- return tunnelPid === nodeProcess8.pid ? "still-alive" : "terminated";
5364
+ if (tunnelPid === void 0 || tunnelPid === nodeProcess11.pid) {
5365
+ return tunnelPid === nodeProcess11.pid ? "still-alive" : "terminated";
4512
5366
  }
5367
+ let stopIntentRecorded = false;
4513
5368
  try {
4514
5369
  const verifyBeforeSignal = async (signal) => {
4515
5370
  if (signal === "SIGTERM" || target.tunnelProcessIdentity === void 0) {
4516
- 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;
4517
5383
  }
4518
- return await inspectRecordedProcess2(
5384
+ return await inspectRecordedProcess(
4519
5385
  tunnelPid,
4520
- target.tunnelProcessIdentity
5386
+ target.tunnelProcessIdentity,
5387
+ isPidAlive
4521
5388
  ) === "match";
4522
5389
  };
4523
5390
  return await terminatePidOrGroup(
@@ -4531,13 +5398,10 @@ async function terminateVerifiedTunnel(target) {
4531
5398
  }
4532
5399
  }
4533
5400
  async function terminateVerifiedTunnelAndConfirm(target, recordExpectedStop = false) {
4534
- if (recordExpectedStop) {
4535
- await writeSessionStopIntent(target.sessionId);
4536
- }
4537
5401
  if (!await ownsRecordedTunnel(target)) {
4538
5402
  throw ownershipError(target, "recorded tunnel no longer owns the local port");
4539
5403
  }
4540
- const termination = await terminateVerifiedTunnel(target);
5404
+ const termination = await terminateVerifiedTunnel(target, recordExpectedStop);
4541
5405
  if (termination !== "terminated" || await ownsRecordedTunnel(target)) {
4542
5406
  throw new CfDebuggerError(
4543
5407
  "TUNNEL_TERMINATION_FAILED",
@@ -4547,18 +5411,26 @@ async function terminateVerifiedTunnelAndConfirm(target, recordExpectedStop = fa
4547
5411
  }
4548
5412
  async function removeOwnedSession(target, stale, forced = false, warning, preserveStopIntent = false) {
4549
5413
  let resultWarning = warning;
4550
- if (isOwnedSessionCfHomeDir(target.sessionId, target.cfHomeDir)) {
4551
- await removeOwnedSessionCfHome(target.sessionId, target.cfHomeDir);
4552
- } 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") {
4553
5419
  const skipped = `State referenced unowned CF home ${target.cfHomeDir}; it was not deleted.`;
4554
- resultWarning = resultWarning === void 0 ? skipped : `${resultWarning} ${skipped}`;
5420
+ resultWarning = appendWarning(resultWarning, skipped);
4555
5421
  }
4556
- const removed = await removeSession(target.sessionId);
4557
5422
  if (!preserveStopIntent) {
4558
- 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
+ }
4559
5431
  }
4560
5432
  return {
4561
- ...removed ?? target,
5433
+ ...cleanup.removed ?? target,
4562
5434
  stale,
4563
5435
  pending: false,
4564
5436
  forced,
@@ -4574,6 +5446,9 @@ function ownershipError(target, detail) {
4574
5446
  function forcedWarning(target, detail) {
4575
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.`;
4576
5448
  }
5449
+ function appendWarning(current, next) {
5450
+ return current === void 0 ? next : `${current} ${next}`;
5451
+ }
4577
5452
  async function forceRemoveUnverified(target, detail, preserveStopIntent = false) {
4578
5453
  return await removeOwnedSession(
4579
5454
  target,
@@ -4588,20 +5463,24 @@ async function stopReadySession(target, force) {
4588
5463
  await terminateVerifiedTunnelAndConfirm(target, true);
4589
5464
  return await removeOwnedSession(target, false, false, void 0, true);
4590
5465
  }
4591
- 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
+ );
4592
5471
  const tunnelDead = target.tunnelPid === void 0 || !isPidOrGroupAlive(target.tunnelPid) || tunnelVerdict === "mismatch";
4593
5472
  const ownership = target.tunnelPid === void 0 ? await inspectListeningProcesses(target.localPort) : await inspectPortOwnership(target.localPort, target.tunnelPid);
4594
5473
  if (tunnelDead && ownership.status === "not-listening") {
4595
- return await removeOwnedSession(target, true, false, void 0, true);
5474
+ return await removeOwnedSession(target, true);
4596
5475
  }
4597
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";
4598
5477
  if (force) {
4599
- return await forceRemoveUnverified(target, detail, true);
5478
+ return await forceRemoveUnverified(target, detail);
4600
5479
  }
4601
5480
  throw ownershipError(target, detail);
4602
5481
  }
4603
5482
  async function stopStartingSession(target, force) {
4604
- const expired = startupExpired2(target);
5483
+ const expired = startupExpired(target);
4605
5484
  const controllerVerdict = await inspectController(target);
4606
5485
  if (!force && !expired && controllerVerdict === "match") {
4607
5486
  return { ...target, stale: false, pending: true, forced: false };
@@ -4633,17 +5512,20 @@ async function stopDebugger(options) {
4633
5512
  if (target === void 0) {
4634
5513
  return void 0;
4635
5514
  }
5515
+ if (target.status === "ready") {
5516
+ return await stopReadySession(target, options.force === true);
5517
+ }
4636
5518
  const claim = await requestSessionStop(target.sessionId);
4637
5519
  if (claim === void 0) {
4638
5520
  return void 0;
4639
5521
  }
4640
- 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);
4641
5523
  }
4642
5524
  function outcomeForResult(result) {
4643
5525
  return {
4644
5526
  sessionId: result.sessionId,
4645
5527
  app: result.app,
4646
- status: result.pending ? "pending" : result.stale ? "stale" : "stopped",
5528
+ status: result.pending ? "pending" : result.forced ? "forced" : result.stale ? "stale" : "stopped",
4647
5529
  result
4648
5530
  };
4649
5531
  }
@@ -4671,6 +5553,7 @@ function summarizeOutcomes(outcomes) {
4671
5553
  return {
4672
5554
  outcomes,
4673
5555
  failed: count("failed"),
5556
+ forced: count("forced"),
4674
5557
  pending: count("pending"),
4675
5558
  stale: count("stale"),
4676
5559
  stopped: count("stopped")
@@ -4703,6 +5586,7 @@ async function getSession(key) {
4703
5586
 
4704
5587
  export {
4705
5588
  CfDebuggerError,
5589
+ validateApiEndpointOverride,
4706
5590
  resolveApiEndpoint,
4707
5591
  listKnownRegionKeys,
4708
5592
  readCurrentCfTarget,
@@ -4714,10 +5598,12 @@ export {
4714
5598
  resolveNodeTarget,
4715
5599
  buildNodeInspectorCommand,
4716
5600
  parseNodeInspectorMarkers,
5601
+ parseRestartEnvironment,
4717
5602
  sessionKeyString,
4718
5603
  CLEANUP_FAILURE_EXIT_CODE,
4719
5604
  hasTunnelTerminationFailure,
4720
5605
  cliErrorExitCode,
5606
+ stopAllExitCode,
4721
5607
  getDebuggerHandleTunnelError,
4722
5608
  resolveStartupTimeoutMs,
4723
5609
  remainingStartupMs,
@@ -4731,4 +5617,4 @@ export {
4731
5617
  listSessions,
4732
5618
  getSession
4733
5619
  };
4734
- //# sourceMappingURL=chunk-ZWAQD7KL.js.map
5620
+ //# sourceMappingURL=chunk-QBB4N2WN.js.map