harnery 0.45.0 → 0.45.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.
Files changed (235) hide show
  1. package/dist/commander.js +0 -1
  2. package/dist/commands/files.d.ts +2 -2
  3. package/dist/commands/files.d.ts.map +1 -1
  4. package/dist/commands/files.js +4 -7
  5. package/dist/commands/servers.d.ts.map +1 -1
  6. package/dist/commands/servers.js +17 -11
  7. package/dist/commands/tunnel.d.ts +0 -15
  8. package/dist/commands/tunnel.d.ts.map +1 -1
  9. package/dist/commands/tunnel.js +153 -453
  10. package/dist/commands/workflow.d.ts.map +1 -1
  11. package/dist/commands/workflow.js +6 -45
  12. package/dist/core/agents/coord-client.d.ts.map +1 -1
  13. package/dist/core/agents/coord-client.js +0 -18
  14. package/dist/core/agents/live-authority-v3.d.ts +0 -1
  15. package/dist/core/agents/live-authority-v3.d.ts.map +1 -1
  16. package/dist/core/agents/live-authority-v3.js +0 -11
  17. package/dist/core/agents/live-lifecycle-v3.d.ts +2 -4
  18. package/dist/core/agents/live-lifecycle-v3.d.ts.map +1 -1
  19. package/dist/core/agents/live-lifecycle-v3.js +7 -28
  20. package/dist/core/agents/state/heartbeat-writer.d.ts.map +1 -1
  21. package/dist/core/agents/state/heartbeat-writer.js +14 -25
  22. package/dist/core/agents/state/live-coordination-writer.d.ts.map +1 -1
  23. package/dist/core/agents/state/live-coordination-writer.js +0 -5
  24. package/dist/core/agents/state/pidmap.d.ts.map +1 -1
  25. package/dist/core/agents/state/pidmap.js +2 -7
  26. package/dist/core/agents/state/proc-start.d.ts +20 -31
  27. package/dist/core/agents/state/proc-start.d.ts.map +1 -1
  28. package/dist/core/agents/state/proc-start.js +37 -70
  29. package/dist/core/agents/state/service-process.d.ts +61 -0
  30. package/dist/core/agents/state/service-process.d.ts.map +1 -0
  31. package/dist/core/agents/state/service-process.js +143 -0
  32. package/dist/core/agents/state/stale-sweep.d.ts.map +1 -1
  33. package/dist/core/agents/state/stale-sweep.js +70 -180
  34. package/dist/core/artifacts/activity.d.ts +4 -1
  35. package/dist/core/artifacts/activity.d.ts.map +1 -1
  36. package/dist/core/artifacts/activity.js +8 -2
  37. package/dist/core/artifacts/delivery-card.d.ts.map +1 -1
  38. package/dist/core/artifacts/delivery-card.js +1 -2
  39. package/dist/core/artifacts/index.d.ts.map +1 -1
  40. package/dist/core/artifacts/index.js +38 -12
  41. package/dist/core/context/index.d.ts +6 -4
  42. package/dist/core/context/index.d.ts.map +1 -1
  43. package/dist/core/context/index.js +194 -256
  44. package/dist/core/context/observation.d.ts +9 -9
  45. package/dist/core/context/observation.d.ts.map +1 -1
  46. package/dist/core/context/observation.js +37 -6
  47. package/dist/core/events/v3/authority-outbox.d.ts.map +1 -1
  48. package/dist/core/events/v3/authority-outbox.js +0 -29
  49. package/dist/core/events/v3/producers/coordination-recorder.d.ts +0 -1
  50. package/dist/core/events/v3/producers/coordination-recorder.d.ts.map +1 -1
  51. package/dist/core/events/v3/producers/coordination-recorder.js +0 -7
  52. package/dist/core/events/v3/producers/recorder.d.ts.map +1 -1
  53. package/dist/core/events/v3/producers/recorder.js +64 -77
  54. package/dist/core/events/v3/runtime-build.d.ts +14 -0
  55. package/dist/core/events/v3/runtime-build.d.ts.map +1 -1
  56. package/dist/core/events/v3/runtime-build.js +44 -0
  57. package/dist/core/governor/index.d.ts +2 -1
  58. package/dist/core/governor/index.d.ts.map +1 -1
  59. package/dist/core/governor/index.js +2 -1
  60. package/dist/core/governor/service-read.d.ts +3 -10
  61. package/dist/core/governor/service-read.d.ts.map +1 -1
  62. package/dist/core/governor/service-read.js +10 -94
  63. package/dist/core/governor/service.d.ts +3 -7
  64. package/dist/core/governor/service.d.ts.map +1 -1
  65. package/dist/core/governor/service.js +17 -182
  66. package/dist/core/hooks/cli.js +78 -48
  67. package/dist/core/hooks/resolve/owner.js +2 -2
  68. package/dist/core/resources/sampler.js +2 -2
  69. package/dist/core/resources/service-status.d.ts +2 -14
  70. package/dist/core/resources/service-status.d.ts.map +1 -1
  71. package/dist/core/resources/service-status.js +3 -23
  72. package/dist/core/resources/service.d.ts +3 -7
  73. package/dist/core/resources/service.d.ts.map +1 -1
  74. package/dist/core/resources/service.js +12 -101
  75. package/dist/core/resources/status.d.ts.map +1 -1
  76. package/dist/core/resources/status.js +3 -2
  77. package/dist/core/semantic/service.d.ts.map +1 -1
  78. package/dist/core/semantic/service.js +9 -2
  79. package/dist/core/servers/index.d.ts +11 -10
  80. package/dist/core/servers/index.d.ts.map +1 -1
  81. package/dist/core/servers/index.js +82 -192
  82. package/dist/core/servers/tunnels.d.ts +3 -5
  83. package/dist/core/servers/tunnels.d.ts.map +1 -1
  84. package/dist/core/servers/tunnels.js +2 -45
  85. package/dist/core/storage/durable-history.js +56 -22
  86. package/dist/core/storage/segments.d.ts.map +1 -1
  87. package/dist/core/storage/segments.js +73 -17
  88. package/dist/core/supervisor/service.d.ts +4 -7
  89. package/dist/core/supervisor/service.d.ts.map +1 -1
  90. package/dist/core/supervisor/service.js +23 -122
  91. package/dist/core/supervisor/services.js +2 -2
  92. package/dist/core/supervisor/status.d.ts +3 -17
  93. package/dist/core/supervisor/status.d.ts.map +1 -1
  94. package/dist/core/supervisor/status.js +7 -70
  95. package/dist/core/work/state.js +7 -7
  96. package/dist/core/workflow/engine.d.ts.map +1 -1
  97. package/dist/core/workflow/engine.js +23 -66
  98. package/dist/core/workflow/index.d.ts +3 -4
  99. package/dist/core/workflow/index.d.ts.map +1 -1
  100. package/dist/core/workflow/index.js +2 -2
  101. package/dist/core/workflow/live-session-v3.d.ts +0 -6
  102. package/dist/core/workflow/live-session-v3.d.ts.map +1 -1
  103. package/dist/core/workflow/live-session-v3.js +5 -126
  104. package/dist/core/workflow/proof.d.ts +4 -4
  105. package/dist/core/workflow/proof.d.ts.map +1 -1
  106. package/dist/core/workflow/proof.js +34 -86
  107. package/dist/core/workflow/resume-lock.d.ts +17 -0
  108. package/dist/core/workflow/resume-lock.d.ts.map +1 -0
  109. package/dist/core/workflow/resume-lock.js +158 -0
  110. package/dist/core/workflow/run-state.d.ts +0 -21
  111. package/dist/core/workflow/run-state.d.ts.map +1 -1
  112. package/dist/core/workflow/run-state.js +3 -174
  113. package/dist/core/workflow/spawn-claude.d.ts.map +1 -1
  114. package/dist/core/workflow/spawn-claude.js +0 -19
  115. package/dist/core/workflow/spawn-codex.d.ts.map +1 -1
  116. package/dist/core/workflow/spawn-codex.js +0 -13
  117. package/dist/core/workflow/spawn-cursor.d.ts.map +1 -1
  118. package/dist/core/workflow/spawn-cursor.js +0 -19
  119. package/dist/core/workflow/spawn-opencode.d.ts.map +1 -1
  120. package/dist/core/workflow/spawn-opencode.js +0 -14
  121. package/dist/core/workflow/types.d.ts +11 -54
  122. package/dist/core/workflow/types.d.ts.map +1 -1
  123. package/dist/core/workflow/workspaces/leases.d.ts +4 -0
  124. package/dist/core/workflow/workspaces/leases.d.ts.map +1 -1
  125. package/dist/core/workflow/workspaces/leases.js +1 -1
  126. package/dist/core/workflow/workspaces/local-git.js +91 -210
  127. package/dist/core/workflow/workspaces/ownership.d.ts +0 -7
  128. package/dist/core/workflow/workspaces/ownership.d.ts.map +1 -1
  129. package/dist/core/workflow/workspaces/ownership.js +3 -130
  130. package/dist/core/workflow/workspaces/reclaim.d.ts +4 -7
  131. package/dist/core/workflow/workspaces/reclaim.d.ts.map +1 -1
  132. package/dist/core/workflow/workspaces/reclaim.js +0 -4
  133. package/dist/core/workflow/workspaces/recovery.d.ts +1 -1
  134. package/dist/core/workflow/workspaces/recovery.d.ts.map +1 -1
  135. package/dist/core/workflow/workspaces/recovery.js +1 -1
  136. package/dist/core/workflow/workspaces/state.d.ts.map +1 -1
  137. package/dist/core/workflow/workspaces/state.js +1 -4
  138. package/dist/lib/identities/assume.d.ts.map +1 -1
  139. package/dist/lib/identities/assume.js +0 -4
  140. package/dist/lib/tunnel/state.d.ts +6 -13
  141. package/dist/lib/tunnel/state.d.ts.map +1 -1
  142. package/dist/lib/tunnel/state.js +12 -58
  143. package/package.json +1 -6
  144. package/src/commander.ts +0 -10
  145. package/src/commands/files.ts +5 -13
  146. package/src/commands/servers.ts +23 -12
  147. package/src/commands/tunnel.ts +166 -533
  148. package/src/commands/workflow.ts +8 -58
  149. package/src/core/agents/coord-client.ts +0 -20
  150. package/src/core/agents/live-authority-v3.ts +0 -17
  151. package/src/core/agents/live-lifecycle-v3.ts +14 -52
  152. package/src/core/agents/state/heartbeat-writer.ts +12 -29
  153. package/src/core/agents/state/live-coordination-writer.ts +0 -29
  154. package/src/core/agents/state/pidmap.ts +2 -15
  155. package/src/core/agents/state/proc-start.ts +41 -69
  156. package/src/core/agents/state/service-process.ts +217 -0
  157. package/src/core/agents/state/stale-sweep.ts +82 -247
  158. package/src/core/artifacts/activity.ts +9 -2
  159. package/src/core/artifacts/delivery-card.ts +1 -7
  160. package/src/core/artifacts/index.ts +45 -12
  161. package/src/core/context/index.ts +207 -272
  162. package/src/core/context/observation.ts +40 -9
  163. package/src/core/events/v3/authority-outbox.ts +0 -56
  164. package/src/core/events/v3/producers/coordination-recorder.ts +0 -15
  165. package/src/core/events/v3/producers/recorder.ts +68 -94
  166. package/src/core/events/v3/runtime-build.ts +62 -0
  167. package/src/core/governor/index.ts +1 -2
  168. package/src/core/governor/service-read.ts +15 -114
  169. package/src/core/governor/service.ts +36 -220
  170. package/src/core/hooks/cli.ts +85 -47
  171. package/src/core/hooks/resolve/owner.ts +2 -2
  172. package/src/core/resources/sampler.ts +3 -2
  173. package/src/core/resources/service-status.ts +6 -35
  174. package/src/core/resources/service.ts +23 -133
  175. package/src/core/resources/status.ts +3 -2
  176. package/src/core/semantic/service.ts +8 -2
  177. package/src/core/servers/index.ts +86 -228
  178. package/src/core/servers/tunnels.ts +3 -55
  179. package/src/core/storage/durable-history.ts +52 -20
  180. package/src/core/storage/segments.ts +79 -17
  181. package/src/core/supervisor/service.ts +33 -155
  182. package/src/core/supervisor/services.ts +2 -2
  183. package/src/core/supervisor/status.ts +10 -85
  184. package/src/core/work/state.ts +7 -7
  185. package/src/core/workflow/engine.ts +22 -74
  186. package/src/core/workflow/index.ts +2 -13
  187. package/src/core/workflow/live-session-v3.ts +5 -146
  188. package/src/core/workflow/proof.ts +40 -104
  189. package/src/core/workflow/resume-lock.ts +179 -0
  190. package/src/core/workflow/run-state.ts +3 -207
  191. package/src/core/workflow/spawn-claude.ts +0 -18
  192. package/src/core/workflow/spawn-codex.ts +0 -13
  193. package/src/core/workflow/spawn-cursor.ts +0 -18
  194. package/src/core/workflow/spawn-opencode.ts +0 -14
  195. package/src/core/workflow/types.ts +11 -41
  196. package/src/core/workflow/workspaces/leases.ts +1 -1
  197. package/src/core/workflow/workspaces/local-git.ts +120 -239
  198. package/src/core/workflow/workspaces/ownership.ts +3 -158
  199. package/src/core/workflow/workspaces/reclaim.ts +3 -9
  200. package/src/core/workflow/workspaces/recovery.ts +1 -1
  201. package/src/core/workflow/workspaces/state.ts +1 -5
  202. package/src/lib/identities/assume.ts +0 -4
  203. package/src/lib/tunnel/state.ts +15 -70
  204. package/dist/commands/turn-rollback.d.ts +0 -5
  205. package/dist/commands/turn-rollback.d.ts.map +0 -1
  206. package/dist/commands/turn-rollback.js +0 -76
  207. package/dist/core/agents/state/process-handle.d.ts +0 -26
  208. package/dist/core/agents/state/process-handle.d.ts.map +0 -1
  209. package/dist/core/agents/state/process-handle.js +0 -121
  210. package/dist/core/agents/state/unchanged-file.d.ts +0 -44
  211. package/dist/core/agents/state/unchanged-file.d.ts.map +0 -1
  212. package/dist/core/agents/state/unchanged-file.js +0 -390
  213. package/dist/core/events/v3/generation-custody.d.ts +0 -5
  214. package/dist/core/events/v3/generation-custody.d.ts.map +0 -1
  215. package/dist/core/events/v3/generation-custody.js +0 -50
  216. package/dist/core/turn-rollback/content.d.ts +0 -53
  217. package/dist/core/turn-rollback/content.d.ts.map +0 -1
  218. package/dist/core/turn-rollback/content.js +0 -396
  219. package/dist/core/turn-rollback/index.d.ts +0 -72
  220. package/dist/core/turn-rollback/index.d.ts.map +0 -1
  221. package/dist/core/turn-rollback/index.js +0 -443
  222. package/dist/core/workflow/failure-evidence.d.ts +0 -16
  223. package/dist/core/workflow/failure-evidence.d.ts.map +0 -1
  224. package/dist/core/workflow/failure-evidence.js +0 -114
  225. package/dist/core/workflow/recovery-audit.d.ts +0 -30
  226. package/dist/core/workflow/recovery-audit.d.ts.map +0 -1
  227. package/dist/core/workflow/recovery-audit.js +0 -116
  228. package/src/commands/turn-rollback.ts +0 -111
  229. package/src/core/agents/state/process-handle.ts +0 -165
  230. package/src/core/agents/state/unchanged-file.ts +0 -456
  231. package/src/core/events/v3/generation-custody.ts +0 -59
  232. package/src/core/turn-rollback/content.ts +0 -499
  233. package/src/core/turn-rollback/index.ts +0 -580
  234. package/src/core/workflow/failure-evidence.ts +0 -141
  235. package/src/core/workflow/recovery-audit.ts +0 -181
@@ -1,7 +1,5 @@
1
1
  import { type ChildProcess, spawnSync } from "node:child_process";
2
- import { createHash, randomUUID } from "node:crypto";
3
- import { existsSync, readdirSync, readFileSync } from "node:fs";
4
- import { hostname } from "node:os";
2
+ import { existsSync, readFileSync } from "node:fs";
5
3
  import { resolve } from "node:path";
6
4
  import type { Command } from "commander";
7
5
  import type { EmitContext, HarneryProgramContext } from "../commander.ts";
@@ -9,22 +7,11 @@ import type { EmitContext, HarneryProgramContext } from "../commander.ts";
9
7
  import { resolveCoordRoot } from "../core/agents/coord-client.ts";
10
8
  import { resolveBinName } from "../core/config.ts";
11
9
  import {
12
- acquireNoClobberLease,
13
- createTunnelReadinessVerifier,
14
- pidAlive,
15
- processStartToken,
16
- readServer,
17
- type ServerRecord,
18
- serversDir,
19
- signalIfSameGeneration,
20
10
  tryRegisterServer,
21
11
  tunnelServerId,
22
12
  tunnelServerInput,
23
13
  unregisterServer,
24
- verifyProcessGeneration,
25
- withServerRegistrationCustody,
26
14
  } from "../core/servers/index.ts";
27
- import { writePrivateJsonAtomic } from "../core/storage/atomic-json.ts";
28
15
  import {
29
16
  processLogDestination,
30
17
  runProcessLogWorker,
@@ -38,20 +25,26 @@ import {
38
25
  planCurrentAddresses,
39
26
  } from "../lib/tunnel/current-address.ts";
40
27
  import { ALLOW_PATHS_ENV, normalizeAllowPaths } from "../lib/tunnel/path-scope.ts";
41
- import { connectProbe, listeningPorts, portListening } from "../lib/tunnel/processes.ts";
28
+ import {
29
+ connectProbe,
30
+ findPidsByCommandLine,
31
+ listeningPorts,
32
+ portListening,
33
+ } from "../lib/tunnel/processes.ts";
42
34
  import {
43
35
  clearState,
44
36
  DEFAULT_INSTANCE,
45
37
  ensureCloudflared,
46
38
  gateLogFile,
47
39
  isProcessAlive,
40
+ isTunnelStateLive,
41
+ listStates,
48
42
  providerLogFile,
49
43
  readConfig,
44
+ readState,
50
45
  type TailscaleMode,
51
46
  type TunnelProvider,
52
47
  type TunnelState,
53
- tunnelDir,
54
- tunnelReadiness,
55
48
  writeConfig,
56
49
  writeState,
57
50
  } from "../lib/tunnel/state.ts";
@@ -342,6 +335,53 @@ function sleep(ms: number): Promise<void> {
342
335
  return new Promise((r) => setTimeout(r, ms));
343
336
  }
344
337
 
338
+ /**
339
+ * Kill every process whose command line matches `pattern` (an extended regular
340
+ * expression), skipping our own PID and any already-killed. Returns the count
341
+ * killed. Used as a fallback so orphaned gate/cloudflared processes get cleaned
342
+ * even when the state file was lost (which otherwise left them squatting on the
343
+ * port).
344
+ */
345
+ function killByPattern(pattern: string, alreadyKilled: Set<number>): number {
346
+ let killed = 0;
347
+ for (const pid of findPidsByCommandLine(pattern)) {
348
+ if (!pid || pid === process.pid || alreadyKilled.has(pid)) continue;
349
+ try {
350
+ process.kill(pid);
351
+ alreadyKilled.add(pid);
352
+ killed++;
353
+ } catch {
354
+ /* race: already gone */
355
+ }
356
+ }
357
+ return killed;
358
+ }
359
+
360
+ /**
361
+ * Command-line pattern for ONE instance's gate, identified by its port. Matches
362
+ * both launch forms: `bun run .../gate.ts --name N --port P` and a host CLI
363
+ * re-executing itself as `<cli> tunnel gate --name N --port P`. Port boundary is
364
+ * guarded with `( |$)` so port 9001 doesn't match 90011.
365
+ */
366
+ function gatePattern(gatePort: number): string {
367
+ return `(gate\\.ts|tunnel gate) .*--port ${gatePort}( |$)`;
368
+ }
369
+
370
+ /**
371
+ * Sweep stray gate + cloudflared processes for ONE instance, identified by its
372
+ * gate port. Both signatures are port-scoped so tearing down one tunnel never
373
+ * touches another:
374
+ * - gate: see gatePattern (the port is on the gate's argv)
375
+ * - cloudflared: `--url http://localhost:<port>` (order-independent, so it
376
+ * matches regardless of the `--protocol http2` flag we also pass).
377
+ */
378
+ function sweepStrays(gatePort: number, alreadyKilled: Set<number>): number {
379
+ return (
380
+ killByPattern(gatePattern(gatePort), alreadyKilled) +
381
+ killByPattern(`--url http://localhost:${gatePort}( |$)`, alreadyKilled)
382
+ );
383
+ }
384
+
345
385
  /**
346
386
  * Pick a gate port for a new instance. An explicit `--gate-port` is honored
347
387
  * (and rejected if it's already taken); otherwise scan upward from 9001 for the
@@ -349,7 +389,7 @@ function sleep(ms: number): Promise<void> {
349
389
  */
350
390
  async function allocateGatePort(preferred: number | undefined): Promise<number> {
351
391
  const used = new Set<number>(
352
- listSelectedStates()
392
+ listStates()
353
393
  .filter((s) => isProcessAlive(s.gate_pid))
354
394
  .map((s) => s.gate_port),
355
395
  );
@@ -482,14 +522,13 @@ function runTailscaleShare(
482
522
  }
483
523
 
484
524
  function stopTailscaleShare(state: TunnelState): boolean {
485
- if (state.provider !== "tailscale") return true;
486
- if (!originalTailscaleRoute(state)) return false;
487
- const cmd = tailscaleCommand(state.tailscale_mode!);
525
+ if (state.provider !== "tailscale" || !state.tailscale_mode) return true;
526
+ const cmd = tailscaleCommand(state.tailscale_mode);
488
527
  const logPath = tunnelLogDestinations(state.name, "tailscale").provider;
489
528
  const args = [
490
529
  cmd,
491
- `--https=${state.tailscale_https_port}`,
492
- `--set-path=${state.tailscale_path}`,
530
+ `--https=${state.tailscale_https_port ?? 443}`,
531
+ `--set-path=${state.tailscale_path ?? "/"}`,
493
532
  "off",
494
533
  ];
495
534
  const status = runRotatingProcessSync({
@@ -502,9 +541,9 @@ function stopTailscaleShare(state: TunnelState): boolean {
502
541
  // surviving serve/funnel mapping could later re-expose whatever next binds
503
542
  // that port. Surface it loudly so the operator can clear it by hand.
504
543
  emit.text(
505
- `\n ⚠ Failed to remove the Tailscale ${cmd} mapping (path ${state.tailscale_path}).\n` +
544
+ `\n ⚠ Failed to remove the Tailscale ${cmd} mapping (path ${state.tailscale_path ?? "/"}).\n` +
506
545
  ` It may still be exposing this machine. Clear it with:\n` +
507
- ` tailscale ${cmd} --https=${state.tailscale_https_port} --set-path=${state.tailscale_path} off\n` +
546
+ ` tailscale ${cmd} --https=${state.tailscale_https_port ?? 443} --set-path=${state.tailscale_path ?? "/"} off\n` +
508
547
  ` Log: ${logPath}\n\n`,
509
548
  );
510
549
  return false;
@@ -520,48 +559,13 @@ function contextVhost(): string | null {
520
559
  }
521
560
 
522
561
  function providerIsAlive(state: TunnelState): boolean {
523
- if (!canonicalProviderFields(state)) return false;
524
- if (state.provider === "tailscale") return state.provider_state === "ready";
525
- const pid = state.cloudflared_pid;
562
+ if (state.provider === "tailscale") return true;
563
+ const pid = state.cloudflared_pid ?? state.provider_pid;
526
564
  return typeof pid === "number" && isProcessAlive(pid);
527
565
  }
528
566
 
529
- function canonicalProviderFields(state: TunnelState): boolean {
530
- return (
531
- !Object.hasOwn(state, "provider_pid") &&
532
- !Object.hasOwn(state, "provider_start_token") &&
533
- (state.provider !== "tailscale" ||
534
- (state.cloudflared_pid === undefined && state.cloudflared_start_token === undefined))
535
- );
536
- }
537
-
538
- function originalProcessWitnesses(state: TunnelState): boolean {
539
- if (
540
- !canonicalProviderFields(state) ||
541
- !Number.isSafeInteger(state.gate_pid) ||
542
- state.gate_pid < 1 ||
543
- typeof state.gate_start_token !== "string" ||
544
- !state.gate_start_token ||
545
- typeof state.registration_id !== "string" ||
546
- !state.registration_id
547
- )
548
- return false;
549
- if (state.provider === "tailscale")
550
- return (
551
- originalTailscaleRoute(state) &&
552
- (state.provider_state === "ready" || state.provider_state === "pending")
553
- );
554
- return (
555
- state.provider === "cloudflare" &&
556
- Number.isSafeInteger(state.cloudflared_pid) &&
557
- state.cloudflared_pid! > 0 &&
558
- typeof state.cloudflared_start_token === "string" &&
559
- !!state.cloudflared_start_token
560
- );
561
- }
562
-
563
- function readinessOf(state: TunnelState) {
564
- return tunnelReadiness(state, createTunnelReadinessVerifier({ coordRoot: registryRoot() }));
567
+ function tunnelIsAlive(state: TunnelState): boolean {
568
+ return isTunnelStateLive(state);
565
569
  }
566
570
 
567
571
  interface GateSpawnOpts {
@@ -584,7 +588,8 @@ interface GateSpawnOpts {
584
588
  * re-reads — that snapshot is precisely why `reload` has to exist.
585
589
  */
586
590
  function spawnGate(o: GateSpawnOpts, launch: GateLaunch): ChildProcess {
587
- // Keep the instance name and port visible in process diagnostics.
591
+ // `--name`/`--port` on argv mirror the env vars; they're what makes the gate
592
+ // process distinguishable per-instance in a command-line search (see sweepStrays).
588
593
  const gateProc = spawnRotatingProcess({
589
594
  path: o.gateLogPath,
590
595
  command: launch.command,
@@ -605,115 +610,7 @@ function spawnGate(o: GateSpawnOpts, launch: GateLaunch): ChildProcess {
605
610
  return gateProc;
606
611
  }
607
612
 
608
- export interface TunnelRoute {
609
- path: string;
610
- httpsPort: number;
611
- }
612
- function originalTailscaleRoute(state: TunnelState): boolean {
613
- return (
614
- (state.tailscale_mode === "serve" || state.tailscale_mode === "funnel") &&
615
- typeof state.tailscale_path === "string" &&
616
- state.tailscale_path.startsWith("/") &&
617
- Number.isSafeInteger(state.tailscale_https_port) &&
618
- state.tailscale_https_port! > 0 &&
619
- state.tailscale_https_port! <= 65535
620
- );
621
- }
622
- function routeOf(state: TunnelState | null): TunnelRoute | null {
623
- return state?.provider === "tailscale" && originalTailscaleRoute(state)
624
- ? { path: state.tailscale_path!, httpsPort: state.tailscale_https_port! }
625
- : null;
626
- }
627
- function routeKey(route: TunnelRoute): string {
628
- return `${route.httpsPort}:${route.path}`;
629
- }
630
-
631
- /** Serialize the instance and every shared provider route before any external effect. */
632
- export function withTunnelLifecycleCustody<T>(
633
- name: string,
634
- requested: TunnelRoute | null,
635
- effect: () => PromiseLike<T>,
636
- ): Promise<T>;
637
- export function withTunnelLifecycleCustody<T>(
638
- name: string,
639
- requested: TunnelRoute | null,
640
- effect: () => T,
641
- ): T;
642
- export function withTunnelLifecycleCustody<T>(
643
- name: string,
644
- requested: TunnelRoute | null,
645
- effect: () => T | PromiseLike<T>,
646
- ): T | Promise<T> {
647
- if (!/^[a-z0-9][a-z0-9-]*$/i.test(name)) throw new Error("invalid tunnel lifecycle name");
648
- const leases: Array<ReturnType<typeof acquireNoClobberLease>> = [];
649
- const release = () => {
650
- for (const lease of leases.splice(0).reverse()) lease.release();
651
- };
652
- const acquire = (category: string, key: string) => {
653
- const scope = `tunnel-${category}-${createHash("sha256").update(key).digest("hex")}`;
654
- leases.push(
655
- acquireNoClobberLease({
656
- path: resolve(
657
- tunnelDir(),
658
- ".custody",
659
- category,
660
- createHash("sha256").update(key).digest("hex"),
661
- ),
662
- scope,
663
- authoritySha256: createHash("sha256").update(scope).digest("hex"),
664
- staleAfterMs: 1,
665
- }),
666
- );
667
- };
668
- try {
669
- acquire("instance", name.toLowerCase());
670
- const retained = listSelectedStates();
671
- const existing = routeOf(retained.find((state) => state.name === name) ?? null);
672
- const routes = [
673
- ...new Set(
674
- [existing, requested].filter((route): route is TunnelRoute => !!route).map(routeKey),
675
- ),
676
- ].sort();
677
- for (const key of routes) acquire("route", key);
678
- for (const other of retained) {
679
- if (other.name.toLowerCase() === name.toLowerCase()) {
680
- if (other.name !== name)
681
- throw new Error(
682
- `tunnel instance ${name} aliases retained spelling ${other.name}; lifecycle custody is unavailable`,
683
- );
684
- continue;
685
- }
686
- const route = routeOf(other);
687
- if (route && routes.includes(routeKey(route)))
688
- throw new Error(
689
- `Tailscale route ${routeKey(route)} is retained by tunnel ${other.name}; provider custody is unavailable`,
690
- );
691
- }
692
- const result = effect();
693
- if (
694
- result !== null &&
695
- (typeof result === "object" || typeof result === "function") &&
696
- typeof (result as PromiseLike<T>).then === "function"
697
- )
698
- return Promise.resolve(result).finally(release);
699
- release();
700
- return result as T;
701
- } catch (error) {
702
- release();
703
- throw error;
704
- }
705
- }
706
-
707
613
  async function up(opts: UpOpts): Promise<void> {
708
- const name = resolveName(opts.name);
709
- const requested =
710
- resolveProvider(opts.provider) === "tailscale"
711
- ? { path: resolveTailscalePath(opts.path, name), httpsPort: resolveHttpsPort(opts.httpsPort) }
712
- : null;
713
- return withTunnelLifecycleCustody(name, requested, () => upWithCustody(opts));
714
- }
715
-
716
- async function upWithCustody(opts: UpOpts): Promise<void> {
717
614
  const launch = currentGateLaunch();
718
615
  if (!launch) {
719
616
  emit.error({ code: "tunnel_requires_bun", message: requiresBunMessage() });
@@ -733,17 +630,8 @@ async function upWithCustody(opts: UpOpts): Promise<void> {
733
630
  provider === "tailscale" ? resolveHttpsPort(opts.httpsPort) : undefined;
734
631
  const allowPaths = resolveAllowPaths(opts.allowPath);
735
632
 
736
- const existing = readSelectedState(name);
737
- const readiness = existing ? readinessOf(existing) : null;
738
- if (readiness === "unknown") {
739
- emit.error({
740
- code: "tunnel_custody_unknown",
741
- message: `Tunnel [${name}] readiness is unknown; retained its state and refused cleanup or replacement.`,
742
- });
743
- process.exit(1);
744
- return;
745
- }
746
- if (existing && readiness === "ready") {
633
+ const existing = readState(name);
634
+ if (existing && tunnelIsAlive(existing)) {
747
635
  if (existing.provider !== provider) {
748
636
  emit.error({
749
637
  code: "tunnel_instance_in_use",
@@ -766,21 +654,16 @@ async function upWithCustody(opts: UpOpts): Promise<void> {
766
654
  emit.text(` Paths: ${formatPaths(existing.allow_paths)}\n`);
767
655
  return;
768
656
  }
769
- if (existing) {
770
- downOneWithCustody(name, new Set());
771
- if (readSelectedState(name)) {
772
- emit.error({
773
- code: "tunnel_custody_unknown",
774
- message: `Tunnel [${name}] has a live or unverifiable process; retained its state and refused a replacement.`,
775
- });
776
- process.exit(1);
777
- }
778
- }
657
+ if (existing) downOne(name, new Set());
779
658
 
780
659
  // Allocate the gate port (after clearing dead state so its old port frees up
781
660
  // for reuse). Explicit --gate-port is validated; otherwise auto-scan.
782
661
  const gatePort = await allocateGatePort(opts.gatePort ? Number(opts.gatePort) : undefined);
783
662
 
663
+ // Self-heal: clear any orphaned gate/cloudflared on THIS instance's port from
664
+ // a prior crashed or state-cleared run so the gate port is free before we bind.
665
+ if (sweepStrays(gatePort, new Set())) await sleep(500);
666
+
784
667
  const cfg = readConfig();
785
668
  if (provider === "cloudflare" && cfg.allowed_ips.length === 0) {
786
669
  emit.error({
@@ -828,13 +711,17 @@ async function upWithCustody(opts: UpOpts): Promise<void> {
828
711
  launch,
829
712
  );
830
713
 
831
- const gateStartToken = processStartToken(gateProc.pid!);
832
714
  await sleep(800);
833
715
 
834
716
  // A host CLI that re-executes itself takes longer to load than a bare script,
835
717
  // so wait for the gate to actually listen before the provider forwards to it.
836
718
  if (!isProcessAlive(gateProc.pid!) || !(await waitForPortBound(gatePort, 15_000))) {
837
- signalIfSameGeneration(gateProc.pid!, gateStartToken, "SIGTERM");
719
+ try {
720
+ process.kill(gateProc.pid!);
721
+ } catch {
722
+ /* already dead */
723
+ }
724
+ sweepStrays(gatePort, new Set());
838
725
  emit.error({
839
726
  code: "tunnel_gate_failed",
840
727
  message: `Gate failed to start. Check log: ${gateLogPath}`,
@@ -845,8 +732,6 @@ async function upWithCustody(opts: UpOpts): Promise<void> {
845
732
  let url: string;
846
733
  let registered = true;
847
734
  let cloudflaredPid: number | undefined;
848
- let cloudflaredStartToken: string | undefined;
849
- let pendingReceipt: ServerRecord | null = null;
850
735
 
851
736
  if (provider === "cloudflare") {
852
737
  // Force HTTP/2 transport. The default QUIC transport wedges at precheck on
@@ -862,13 +747,20 @@ async function upWithCustody(opts: UpOpts): Promise<void> {
862
747
  });
863
748
  cfdProc.unref();
864
749
  cloudflaredPid = cfdProc.pid!;
865
- cloudflaredStartToken = processStartToken(cloudflaredPid) ?? undefined;
866
750
 
867
751
  const ready = await waitForReady(providerLogPath, 30_000);
868
752
  registered = ready.registered;
869
753
  if (!ready.url) {
870
- signalIfSameGeneration(gateProc.pid!, gateStartToken, "SIGTERM");
871
- signalIfSameGeneration(cloudflaredPid, cloudflaredStartToken, "SIGTERM");
754
+ try {
755
+ process.kill(gateProc.pid!);
756
+ } catch {
757
+ /* already dead */
758
+ }
759
+ try {
760
+ process.kill(cloudflaredPid);
761
+ } catch {
762
+ /* already dead */
763
+ }
872
764
  emit.error({
873
765
  code: "tunnel_url_timeout",
874
766
  message: `Failed to obtain tunnel URL within 30s. Check log: ${providerLogPath}`,
@@ -884,38 +776,12 @@ async function upWithCustody(opts: UpOpts): Promise<void> {
884
776
  // `heal` would then be unable to see or clean up.
885
777
  url = tailscaleUrl(tailscalePath!, tailscaleHttpsPort!);
886
778
  const gateTarget = `http://127.0.0.1:${gatePort}`;
887
- const pending: TunnelState = {
888
- name,
889
- provider,
890
- url,
891
- gate_pid: gateProc.pid!,
892
- gate_start_token: gateStartToken ?? undefined,
893
- started_at: new Date().toISOString(),
894
- target,
895
- vhost,
896
- gate_port: gatePort,
897
- allow_paths: allowPaths,
898
- tailscale_mode: tailscaleMode,
899
- tailscale_path: tailscalePath,
900
- tailscale_https_port: tailscaleHttpsPort,
901
- provider_state: "pending",
902
- };
903
- pendingReceipt = recordTunnelServer(pending);
904
- if (!pendingReceipt) {
905
- const evidence = recordRefusedTunnelLaunch(pending);
906
- signalIfSameGeneration(pending.gate_pid, pending.gate_start_token, "SIGTERM");
907
- throw new Error(
908
- `Tailscale admission refused before provider effect; retained witnesses at ${evidence}`,
909
- );
910
- }
911
- beginTailscaleRoute(pending, pendingReceipt, () =>
912
- runTailscaleShare(
913
- tailscaleMode!,
914
- gateTarget,
915
- tailscalePath!,
916
- tailscaleHttpsPort!,
917
- providerLogPath,
918
- ),
779
+ runTailscaleShare(
780
+ tailscaleMode!,
781
+ gateTarget,
782
+ tailscalePath!,
783
+ tailscaleHttpsPort!,
784
+ providerLogPath,
919
785
  );
920
786
  }
921
787
 
@@ -923,10 +789,7 @@ async function upWithCustody(opts: UpOpts): Promise<void> {
923
789
  name,
924
790
  provider,
925
791
  url,
926
- ...(provider === "tailscale" ? { provider_state: "ready" as const } : {}),
927
792
  gate_pid: gateProc.pid!,
928
- gate_start_token: gateStartToken ?? undefined,
929
- cloudflared_start_token: cloudflaredStartToken,
930
793
  cloudflared_pid: cloudflaredPid,
931
794
  started_at: new Date().toISOString(),
932
795
  target,
@@ -937,22 +800,8 @@ async function upWithCustody(opts: UpOpts): Promise<void> {
937
800
  tailscale_path: tailscalePath,
938
801
  tailscale_https_port: tailscaleHttpsPort,
939
802
  };
940
- const receipt = provider === "tailscale" ? pendingReceipt : recordTunnelServer(state);
941
- if (receipt) state.registration_id = receipt.registration_id;
942
- try {
943
- if (!receipt) throw new Error("registration refused");
944
- publishTunnelState(state, receipt);
945
- } catch {
946
- const evidence = recordRefusedTunnelLaunch(state);
947
- signalIfSameGeneration(state.gate_pid, state.gate_start_token, "SIGTERM");
948
- if (state.cloudflared_pid)
949
- signalIfSameGeneration(state.cloudflared_pid, state.cloudflared_start_token, "SIGTERM");
950
- emit.error({
951
- code: "tunnel_registration_refused",
952
- message: `Tunnel [${name}] registration custody was refused; saved process witnesses at ${evidence}.`,
953
- });
954
- process.exit(1);
955
- }
803
+ writeState(state);
804
+ recordTunnelServer(state);
956
805
 
957
806
  const bin = resolveBinName();
958
807
  const stopHint =
@@ -987,191 +836,43 @@ function registryRoot(): string {
987
836
  return resolveCoordRoot() ?? process.cwd();
988
837
  }
989
838
 
990
- function unknownTunnelFrame(name: string): Error {
991
- return Object.assign(
992
- new Error(
993
- `Tunnel [${name}] state is unreadable or its custody is unknown; retained the frame.`,
994
- ),
995
- {
996
- code: "tunnel_custody_unknown",
997
- },
998
- );
999
- }
1000
-
1001
- function readSelectedState(name: string): TunnelState | null {
1002
- const path = resolve(
1003
- tunnelDir(),
1004
- name === DEFAULT_INSTANCE ? "state.json" : `state-${name}.json`,
1005
- );
1006
- let bytes: string;
1007
- try {
1008
- bytes = readFileSync(path, "utf8");
1009
- } catch (error) {
1010
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
1011
- throw unknownTunnelFrame(name);
1012
- }
1013
- try {
1014
- const raw = JSON.parse(bytes) as TunnelState;
1015
- if (
1016
- !raw ||
1017
- typeof raw !== "object" ||
1018
- Array.isArray(raw) ||
1019
- raw.name !== name ||
1020
- (raw.provider !== "cloudflare" && raw.provider !== "tailscale") ||
1021
- (raw.provider === "tailscale" && !originalTailscaleRoute(raw)) ||
1022
- typeof raw.url !== "string" ||
1023
- !raw.url ||
1024
- typeof raw.target !== "string" ||
1025
- !raw.target ||
1026
- typeof raw.vhost !== "string" ||
1027
- !Number.isSafeInteger(raw.gate_pid) ||
1028
- raw.gate_pid < 1 ||
1029
- !Number.isSafeInteger(raw.gate_port) ||
1030
- raw.gate_port < 1 ||
1031
- raw.gate_port > 65535 ||
1032
- !Array.isArray(raw.allow_paths) ||
1033
- raw.allow_paths.some((path) => typeof path !== "string")
1034
- )
1035
- throw unknownTunnelFrame(name);
1036
- return raw;
1037
- } catch {
1038
- throw unknownTunnelFrame(name);
1039
- }
1040
- }
1041
-
1042
- function listSelectedStates(): TunnelState[] {
1043
- let files: string[];
1044
- try {
1045
- files = readdirSync(tunnelDir());
1046
- } catch (error) {
1047
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
1048
- throw unknownTunnelFrame("inventory");
1049
- }
1050
- const states: TunnelState[] = [];
1051
- for (const file of files) {
1052
- const name = file === "state.json" ? DEFAULT_INSTANCE : /^state-(.+)\.json$/.exec(file)?.[1];
1053
- if (!name) continue;
1054
- const state = readSelectedState(name);
1055
- if (state) states.push(state);
1056
- }
1057
- return states.sort((a, b) =>
1058
- (typeof b.started_at === "string" ? b.started_at : "").localeCompare(
1059
- typeof a.started_at === "string" ? a.started_at : "",
1060
- ),
1061
- );
1062
- }
1063
-
1064
- function matchesTunnelReceipt(state: TunnelState, receipt: ServerRecord): boolean {
1065
- return (
1066
- originalProcessWitnesses(state) &&
1067
- tunnelServerId(state.name) === receipt.id &&
1068
- receipt.host === hostname() &&
1069
- receipt.kind === "service" &&
1070
- receipt.type === "tunnel" &&
1071
- receipt.port === state.gate_port &&
1072
- receipt.url === state.url &&
1073
- receipt.registration_id === state.registration_id &&
1074
- receipt.pid === state.gate_pid &&
1075
- receipt.start_token === state.gate_start_token
1076
- );
1077
- }
1078
-
1079
- /** Retain a refused launch without overwriting another launch's canonical state. */
1080
- export function recordRefusedTunnelLaunch(state: TunnelState): string {
1081
- const path = resolve(tunnelDir(), `failed-${state.name}-${randomUUID()}.json`);
1082
- writePrivateJsonAtomic(path, state);
1083
- return path;
1084
- }
1085
-
1086
- /** A canonical state frame may be published only by its current registration. */
1087
- export function publishTunnelState(state: TunnelState, receipt: ServerRecord): void {
1088
- if (!matchesTunnelReceipt(state, receipt))
1089
- throw new Error("tunnel state registration custody does not match");
1090
- withServerRegistrationCustody(receipt, { coordRoot: registryRoot() }, () => writeState(state));
1091
- }
1092
-
1093
- /** Retain pending route intent before asking the provider to change external state. */
1094
- export function beginTailscaleRoute<T>(
1095
- state: TunnelState,
1096
- receipt: ServerRecord,
1097
- effect: () => T,
1098
- ): T {
1099
- if (state.provider !== "tailscale" || state.provider_state !== "pending")
1100
- throw new Error("Tailscale route intent is not pending");
1101
- publishTunnelState(state, receipt);
1102
- return withServerRegistrationCustody(receipt, { coordRoot: registryRoot() }, effect);
1103
- }
1104
-
1105
- function recordTunnelServer(state: TunnelState): ServerRecord | null {
1106
- if (!state.gate_start_token) return null;
839
+ function recordTunnelServer(state: TunnelState): void {
1107
840
  const coordRoot = registryRoot();
1108
- const record = tryRegisterServer(tunnelServerInput(state, coordRoot), { coordRoot });
1109
- if (record) state.registration_id = record.registration_id;
1110
- return record;
841
+ tryRegisterServer(tunnelServerInput(state, coordRoot), { coordRoot });
1111
842
  }
1112
843
 
1113
- function tunnelRegistration(state: TunnelState): { known: boolean; record: ServerRecord | null } {
1114
- if (!originalProcessWitnesses(state)) return { known: false, record: null };
1115
- const coordRoot = registryRoot();
1116
- const id = tunnelServerId(state.name);
1117
- let record: ServerRecord | null;
1118
- try {
1119
- record = readServer(id, { coordRoot });
1120
- } catch {
1121
- return { known: false, record: null };
1122
- }
1123
- const present = existsSync(resolve(serversDir(coordRoot), `${id}.json`));
1124
- if (!present) return { known: false, record: null };
1125
- return { known: !!record && matchesTunnelReceipt(state, record), record };
844
+ /** Tear down a single instance by name. Returns the number of processes killed. */
845
+ function portHeldByOtherLiveInstance(port: number, name: string): boolean {
846
+ return listStates().some(
847
+ (s) => s.name !== name && s.gate_port === port && isProcessAlive(s.gate_pid),
848
+ );
1126
849
  }
1127
850
 
1128
- /** Unknown saved identities retain state and never authorize a signal. */
1129
851
  export function downOne(name: string, killed: Set<number>): number {
1130
- try {
1131
- return withTunnelLifecycleCustody(name, null, () => downOneWithCustody(name, killed));
1132
- } catch {
1133
- return 0;
1134
- }
1135
- }
1136
-
1137
- function downOneWithCustody(name: string, killed: Set<number>): number {
1138
852
  const before = killed.size;
1139
- const state = readSelectedState(name);
1140
- if (!state) return 0;
1141
- const registration = tunnelRegistration(state);
1142
- if (!registration.known) return 0;
1143
- const targets = [
1144
- { pid: state.gate_pid, token: state.gate_start_token },
1145
- { pid: state.cloudflared_pid, token: state.cloudflared_start_token },
1146
- ].filter(
1147
- (target): target is { pid: number; token: string | undefined } =>
1148
- typeof target.pid === "number",
1149
- );
1150
- for (const target of targets) {
1151
- if (pidAlive(target.pid) && verifyProcessGeneration(target.pid, target.token) !== "same")
1152
- return 0;
1153
- }
1154
- if (!registration.record) return 0;
1155
- let cleared = false;
1156
- try {
1157
- withServerRegistrationCustody(registration.record, { coordRoot: registryRoot() }, () => {
1158
- for (const target of targets) {
1159
- if (!pidAlive(target.pid) || killed.has(target.pid)) continue;
1160
- const result = signalIfSameGeneration(target.pid, target.token, "SIGTERM");
1161
- if (!result.sent) throw new Error("tunnel process custody is unknown");
1162
- killed.add(target.pid);
853
+ const state = readState(name);
854
+ if (state) {
855
+ if (state.provider === "tailscale") stopTailscaleShare(state);
856
+ for (const pid of [state.gate_pid, state.cloudflared_pid, state.provider_pid]) {
857
+ if (typeof pid !== "number") continue;
858
+ if (isProcessAlive(pid)) {
859
+ try {
860
+ process.kill(pid);
861
+ killed.add(pid);
862
+ } catch {
863
+ /* race: already gone */
864
+ }
1163
865
  }
1164
- // Observation and deletion share custody; an old stopper cannot clear a winner.
1165
- if (targets.some((target) => pidAlive(target.pid))) return;
1166
- if (JSON.stringify(readSelectedState(name)) !== JSON.stringify(state)) return;
1167
- if (state.provider === "tailscale" && !stopTailscaleShare(state)) return;
1168
- clearState(name);
1169
- cleared = true;
1170
- });
1171
- } catch {
1172
- return killed.size - before;
866
+ }
1173
867
  }
1174
- if (cleared) unregisterServer(registration.record, { coordRoot: registryRoot() });
868
+ // Fallback: sweep orphans on this instance's gate port, even when state was
869
+ // lost; they'd otherwise squat on the port and break the next `up`. Skip it
870
+ // when another live instance now owns that port: a stale record's port is
871
+ // reused after a reboot, and sweeping it would kill the live tunnel there.
872
+ const port = state?.gate_port ?? DEFAULT_GATE_PORT;
873
+ if (!portHeldByOtherLiveInstance(port, name)) sweepStrays(port, killed);
874
+ clearState(name);
875
+ unregisterServer(tunnelServerId(name), { coordRoot: registryRoot() });
1175
876
  return killed.size - before;
1176
877
  }
1177
878
 
@@ -1179,15 +880,14 @@ function down(opts: DownOpts): void {
1179
880
  const killed = new Set<number>();
1180
881
 
1181
882
  if (opts.all) {
1182
- const states = listSelectedStates();
883
+ const states = listStates();
1183
884
  if (states.length === 0) {
1184
885
  emit.text("No tunnels up. Nothing to stop.\n");
1185
886
  return;
1186
887
  }
1187
888
  for (const s of states) downOne(s.name, killed);
1188
- const retained = states.filter((state) => readSelectedState(state.name)).length;
1189
889
  emit.text(
1190
- `Requested stop for ${states.length} tunnel(s); ${killed.size} process signal(s), ${retained} state record(s) retained pending verified exit.\n`,
890
+ `Stopped ${states.length} tunnel(s) [${states.map((s) => s.name).join(", ")}], ${killed.size} process(es).\n`,
1191
891
  );
1192
892
  return;
1193
893
  }
@@ -1195,8 +895,8 @@ function down(opts: DownOpts): void {
1195
895
  const name = resolveName(opts.name);
1196
896
  // Bare `down` targets the default instance. If it's not up but named ones
1197
897
  // are, don't silently no-op; point the operator at them.
1198
- if (name === DEFAULT_INSTANCE && !readSelectedState(DEFAULT_INSTANCE)) {
1199
- const others = listSelectedStates();
898
+ if (name === DEFAULT_INSTANCE && !readState(DEFAULT_INSTANCE)) {
899
+ const others = listStates();
1200
900
  if (others.length > 0) {
1201
901
  emit.text(
1202
902
  `No default tunnel running. Other tunnels up: ${others.map((s) => s.name).join(", ")}.\nUse \`${resolveBinName()} tunnel down --name <name>\` or \`${resolveBinName()} tunnel down --all\`.\n`,
@@ -1207,9 +907,9 @@ function down(opts: DownOpts): void {
1207
907
 
1208
908
  downOne(name, killed);
1209
909
  emit.text(
1210
- readSelectedState(name)
1211
- ? `Tunnel [${name}] state retained pending verified process exit or custody; ${killed.size} signal(s) sent.\n`
1212
- : `Tunnel [${name}] has no remaining recorded processes.\n`,
910
+ killed.size === 0
911
+ ? `No tunnel processes found for [${name}]. Nothing to stop.\n`
912
+ : `Stopped ${killed.size} process(es). Tunnel [${name}] down.\n`,
1213
913
  );
1214
914
  }
1215
915
 
@@ -1245,29 +945,10 @@ async function waitForPortBound(port: number, timeoutMs: number): Promise<boolea
1245
945
  * edge reconnects without noticing.
1246
946
  */
1247
947
  export async function reloadOne(state: TunnelState): Promise<{ ok: boolean; message: string }> {
1248
- try {
1249
- return await withTunnelLifecycleCustody(state.name, routeOf(state), () =>
1250
- reloadOneWithCustody(state),
1251
- );
1252
- } catch {
1253
- return {
1254
- ok: false,
1255
- message: `[${state.name}] lifecycle or provider route custody is unavailable; retained the tunnel.`,
1256
- };
1257
- }
1258
- }
1259
-
1260
- async function reloadOneWithCustody(state: TunnelState): Promise<{ ok: boolean; message: string }> {
1261
948
  const bin = resolveBinName();
1262
949
  const name = state.name;
1263
950
  const gatePort = state.gate_port;
1264
951
 
1265
- if (!canonicalProviderFields(state))
1266
- return {
1267
- ok: false,
1268
- message: `[${name}] obsolete or ambiguous provider authority is unknown; retained the tunnel.`,
1269
- };
1270
-
1271
952
  if (!providerIsAlive(state)) {
1272
953
  return {
1273
954
  ok: false,
@@ -1293,54 +974,28 @@ async function reloadOneWithCustody(state: TunnelState): Promise<{ ok: boolean;
1293
974
  };
1294
975
  }
1295
976
 
1296
- const registration = tunnelRegistration(state);
1297
- if (!registration.known || !registration.record)
1298
- return { ok: false, message: `[${name}] registration custody changed; retained the tunnel.` };
1299
- const stopGate = async (): Promise<{ ok: boolean; message: string }> => {
1300
- if (
1301
- state.provider === "cloudflare" &&
1302
- state.cloudflared_pid &&
1303
- verifyProcessGeneration(state.cloudflared_pid, state.cloudflared_start_token) !== "same"
1304
- )
1305
- return {
1306
- ok: false,
1307
- message: `[${name}] provider generation is unknown or changed; retained the tunnel.`,
1308
- };
1309
- if (pidAlive(state.gate_pid)) {
1310
- if (!registration.record)
1311
- return {
1312
- ok: false,
1313
- message: `[${name}] registration custody is unavailable; retained the tunnel.`,
1314
- };
1315
- const result = signalIfSameGeneration(state.gate_pid, state.gate_start_token, "SIGTERM");
1316
- if (!result.sent)
1317
- return {
1318
- ok: false,
1319
- message: `[${name}] gate generation is unknown or changed; retained the tunnel.`,
1320
- };
977
+ // Kill ONLY the gate. sweepStrays() is deliberately avoided here: it also
978
+ // matches `--url http://localhost:<port>`, which is the provider process we
979
+ // are going out of our way to keep alive.
980
+ const killed = new Set<number>();
981
+ if (isProcessAlive(state.gate_pid)) {
982
+ try {
983
+ process.kill(state.gate_pid);
984
+ killed.add(state.gate_pid);
985
+ } catch {
986
+ /* race: already gone */
1321
987
  }
1322
- if (!(await waitForPortFree(gatePort, 5_000)))
1323
- return {
1324
- ok: false,
1325
- message: `[${name}] port ${gatePort} never released; retained the tunnel state.`,
1326
- };
1327
- return { ok: true, message: "gate released" };
1328
- };
1329
- let released: { ok: boolean; message: string };
1330
- try {
1331
- released = await withServerRegistrationCustody(
1332
- registration.record,
1333
- { coordRoot: registryRoot() },
1334
- stopGate,
1335
- );
1336
- } catch {
988
+ }
989
+ killByPattern(gatePattern(gatePort), killed);
990
+
991
+ if (!(await waitForPortFree(gatePort, 5_000))) {
1337
992
  return {
1338
993
  ok: false,
1339
- message: `[${name}] registration custody is unavailable; retained the tunnel.`,
994
+ message:
995
+ `[${name}] port ${gatePort} never released, so the old gate looks wedged. ` +
996
+ `Tunnel is down; check \`${bin} tunnel status\`.`,
1340
997
  };
1341
998
  }
1342
- if (!released.ok) return released;
1343
- if (registration.record) unregisterServer(registration.record, { coordRoot: registryRoot() });
1344
999
 
1345
1000
  const cfg = readConfig();
1346
1001
  const gateProc = spawnGate(
@@ -1357,7 +1012,6 @@ async function reloadOneWithCustody(state: TunnelState): Promise<{ ok: boolean;
1357
1012
  launch,
1358
1013
  );
1359
1014
 
1360
- const gateStartToken = processStartToken(gateProc.pid!);
1361
1015
  await sleep(800);
1362
1016
  const pid = gateProc.pid;
1363
1017
  if (
@@ -1373,24 +1027,8 @@ async function reloadOneWithCustody(state: TunnelState): Promise<{ ok: boolean;
1373
1027
  };
1374
1028
  }
1375
1029
 
1376
- const next = {
1377
- ...state,
1378
- gate_pid: pid,
1379
- gate_start_token: gateStartToken ?? undefined,
1380
- registration_id: undefined,
1381
- };
1382
- const nextReceipt = recordTunnelServer(next);
1383
- try {
1384
- if (!nextReceipt) throw new Error("registration refused");
1385
- publishTunnelState(next, nextReceipt);
1386
- } catch {
1387
- const evidence = recordRefusedTunnelLaunch(next);
1388
- signalIfSameGeneration(pid, gateStartToken, "SIGTERM");
1389
- return {
1390
- ok: false,
1391
- message: `[${name}] new gate registration custody was refused; retained witnesses at ${evidence}.`,
1392
- };
1393
- }
1030
+ writeState({ ...state, gate_pid: pid });
1031
+ recordTunnelServer({ ...state, gate_pid: pid });
1394
1032
  const allowNote =
1395
1033
  state.provider === "cloudflare" ? ` Allowlist: ${cfg.allowed_ips.length} IP(s).` : "";
1396
1034
  return { ok: true, message: `[${name}] gate reloaded, URL unchanged: ${state.url}.${allowNote}` };
@@ -1400,7 +1038,7 @@ async function reload(opts: ReloadOpts): Promise<void> {
1400
1038
  const bin = resolveBinName();
1401
1039
 
1402
1040
  if (opts.all) {
1403
- const all = listSelectedStates();
1041
+ const all = listStates();
1404
1042
  // Only live tunnels are reloadable, and a box commonly accumulates stale
1405
1043
  // state files from long-dead runs. Reporting those as failures would make
1406
1044
  // the common post-`allow add` path exit non-zero for no real reason.
@@ -1429,7 +1067,7 @@ async function reload(opts: ReloadOpts): Promise<void> {
1429
1067
  }
1430
1068
 
1431
1069
  const name = resolveName(opts.name);
1432
- const state = readSelectedState(name);
1070
+ const state = readState(name);
1433
1071
  if (!state) {
1434
1072
  emit.error({
1435
1073
  code: "tunnel_not_found",
@@ -1443,9 +1081,8 @@ async function reload(opts: ReloadOpts): Promise<void> {
1443
1081
  if (!r.ok) process.exit(1);
1444
1082
  }
1445
1083
 
1446
- function instanceState(state: TunnelState): "up" | "stale" | "unknown" {
1447
- const readiness = readinessOf(state);
1448
- return readiness === "ready" ? "up" : readiness === "dead" ? "stale" : "unknown";
1084
+ function instanceState(state: TunnelState): "up" | "stale" {
1085
+ return tunnelIsAlive(state) ? "up" : "stale";
1449
1086
  }
1450
1087
 
1451
1088
  function fmtUptime(startedAt: string): string {
@@ -1460,9 +1097,9 @@ function fmtUptime(startedAt: string): string {
1460
1097
  function statusDetail(state: TunnelState): void {
1461
1098
  const gateAlive = isProcessAlive(state.gate_pid);
1462
1099
  const providerAlive = providerIsAlive(state);
1463
- const providerPid = state.cloudflared_pid;
1100
+ const providerPid = state.cloudflared_pid ?? state.provider_pid;
1464
1101
  const cfg = readConfig();
1465
- emit.text(`${instanceState(state)} [${state.name}]\n`);
1102
+ emit.text(`${gateAlive && providerAlive ? "up" : "stale"} [${state.name}]\n`);
1466
1103
  emit.text(
1467
1104
  ` Provider: ${state.provider}${state.tailscale_mode ? ` (${state.tailscale_mode})` : ""}\n`,
1468
1105
  );
@@ -1485,7 +1122,7 @@ function statusDetail(state: TunnelState): void {
1485
1122
  function status(opts: StatusOpts): void {
1486
1123
  // Named → detailed single block.
1487
1124
  if (opts.name) {
1488
- const state = readSelectedState(resolveName(opts.name));
1125
+ const state = readState(resolveName(opts.name));
1489
1126
  if (!state) {
1490
1127
  emit.text(`down [${resolveName(opts.name)}]\n`);
1491
1128
  return;
@@ -1495,7 +1132,7 @@ function status(opts: StatusOpts): void {
1495
1132
  }
1496
1133
 
1497
1134
  // No name → table of every instance.
1498
- const states = listSelectedStates();
1135
+ const states = listStates();
1499
1136
  if (states.length === 0) {
1500
1137
  emit.text("down\n");
1501
1138
  return;
@@ -1538,7 +1175,7 @@ function status(opts: StatusOpts): void {
1538
1175
 
1539
1176
  function logs(opts: LogsOpts): void {
1540
1177
  const name = resolveName(opts.name);
1541
- const state = readSelectedState(name);
1178
+ const state = readState(name);
1542
1179
  const provider = state?.provider ?? "cloudflare";
1543
1180
  const destinations = tunnelLogDestinations(name, provider);
1544
1181
  const path = opts.provider ? destinations.provider : destinations.gate;
@@ -1575,9 +1212,7 @@ const sameEntryIn = (list: readonly string[], entry: string) =>
1575
1212
  * restarts were owed.
1576
1213
  */
1577
1214
  function allowChangedHint(): void {
1578
- const live = listSelectedStates().filter(
1579
- (s) => s.provider === "cloudflare" && providerIsAlive(s),
1580
- );
1215
+ const live = listStates().filter((s) => s.provider === "cloudflare" && providerIsAlive(s));
1581
1216
  if (live.length === 0) return;
1582
1217
  emit.text(
1583
1218
  `Each gate reads the allowlist once at start, so this applies on reload ` +
@@ -1719,9 +1354,7 @@ export async function refreshCurrentAddress(
1719
1354
  }
1720
1355
  if (!plan.changed || !options.reload) return result;
1721
1356
 
1722
- const live = listSelectedStates().filter(
1723
- (s) => s.provider === "cloudflare" && providerIsAlive(s),
1724
- );
1357
+ const live = listStates().filter((s) => s.provider === "cloudflare" && providerIsAlive(s));
1725
1358
  for (const s of live) {
1726
1359
  const r = await reloadOne(s);
1727
1360
  emit.text(`${r.ok ? "ok " : "FAIL"} ${r.message}\n`);