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,18 +1,15 @@
1
1
  import { 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 { resolveCoordRoot } from "../core/agents/coord-client.js";
7
5
  import { resolveBinName } from "../core/config.js";
8
- import { acquireNoClobberLease, createTunnelReadinessVerifier, pidAlive, processStartToken, readServer, serversDir, signalIfSameGeneration, tryRegisterServer, tunnelServerId, tunnelServerInput, unregisterServer, verifyProcessGeneration, withServerRegistrationCustody, } from "../core/servers/index.js";
9
- import { writePrivateJsonAtomic } from "../core/storage/atomic-json.js";
6
+ import { tryRegisterServer, tunnelServerId, tunnelServerInput, unregisterServer, } from "../core/servers/index.js";
10
7
  import { processLogDestination, runProcessLogWorker, runRotatingProcessSync, spawnRotatingProcess, } from "../core/storage/process-log.js";
11
8
  import { normalizeAllowEntry } from "../lib/tunnel/allowlist.js";
12
9
  import { detectPublicAddresses, planCurrentAddresses, } from "../lib/tunnel/current-address.js";
13
10
  import { ALLOW_PATHS_ENV, normalizeAllowPaths } from "../lib/tunnel/path-scope.js";
14
- import { connectProbe, listeningPorts, portListening } from "../lib/tunnel/processes.js";
15
- import { clearState, DEFAULT_INSTANCE, ensureCloudflared, gateLogFile, isProcessAlive, providerLogFile, readConfig, tunnelDir, tunnelReadiness, writeConfig, writeState, } from "../lib/tunnel/state.js";
11
+ import { connectProbe, findPidsByCommandLine, listeningPorts, portListening, } from "../lib/tunnel/processes.js";
12
+ import { clearState, DEFAULT_INSTANCE, ensureCloudflared, gateLogFile, isProcessAlive, isTunnelStateLive, listStates, providerLogFile, readConfig, readState, writeConfig, writeState, } from "../lib/tunnel/state.js";
16
13
  /**
17
14
  * `tunnel`: provider-backed tunnel in front of a local upstream.
18
15
  *
@@ -221,13 +218,57 @@ function tailscaleAvailable() {
221
218
  function sleep(ms) {
222
219
  return new Promise((r) => setTimeout(r, ms));
223
220
  }
221
+ /**
222
+ * Kill every process whose command line matches `pattern` (an extended regular
223
+ * expression), skipping our own PID and any already-killed. Returns the count
224
+ * killed. Used as a fallback so orphaned gate/cloudflared processes get cleaned
225
+ * even when the state file was lost (which otherwise left them squatting on the
226
+ * port).
227
+ */
228
+ function killByPattern(pattern, alreadyKilled) {
229
+ let killed = 0;
230
+ for (const pid of findPidsByCommandLine(pattern)) {
231
+ if (!pid || pid === process.pid || alreadyKilled.has(pid))
232
+ continue;
233
+ try {
234
+ process.kill(pid);
235
+ alreadyKilled.add(pid);
236
+ killed++;
237
+ }
238
+ catch {
239
+ /* race: already gone */
240
+ }
241
+ }
242
+ return killed;
243
+ }
244
+ /**
245
+ * Command-line pattern for ONE instance's gate, identified by its port. Matches
246
+ * both launch forms: `bun run .../gate.ts --name N --port P` and a host CLI
247
+ * re-executing itself as `<cli> tunnel gate --name N --port P`. Port boundary is
248
+ * guarded with `( |$)` so port 9001 doesn't match 90011.
249
+ */
250
+ function gatePattern(gatePort) {
251
+ return `(gate\\.ts|tunnel gate) .*--port ${gatePort}( |$)`;
252
+ }
253
+ /**
254
+ * Sweep stray gate + cloudflared processes for ONE instance, identified by its
255
+ * gate port. Both signatures are port-scoped so tearing down one tunnel never
256
+ * touches another:
257
+ * - gate: see gatePattern (the port is on the gate's argv)
258
+ * - cloudflared: `--url http://localhost:<port>` (order-independent, so it
259
+ * matches regardless of the `--protocol http2` flag we also pass).
260
+ */
261
+ function sweepStrays(gatePort, alreadyKilled) {
262
+ return (killByPattern(gatePattern(gatePort), alreadyKilled) +
263
+ killByPattern(`--url http://localhost:${gatePort}( |$)`, alreadyKilled));
264
+ }
224
265
  /**
225
266
  * Pick a gate port for a new instance. An explicit `--gate-port` is honored
226
267
  * (and rejected if it's already taken); otherwise scan upward from 9001 for the
227
268
  * first port that's neither held by a live instance nor currently listening.
228
269
  */
229
270
  async function allocateGatePort(preferred) {
230
- const used = new Set(listSelectedStates()
271
+ const used = new Set(listStates()
231
272
  .filter((s) => isProcessAlive(s.gate_pid))
232
273
  .map((s) => s.gate_port));
233
274
  // No discovery tool (null) is not "every port free": probe candidates by connecting.
@@ -342,16 +383,14 @@ function runTailscaleShare(mode, targetUrl, path, httpsPort, logPath) {
342
383
  }
343
384
  }
344
385
  function stopTailscaleShare(state) {
345
- if (state.provider !== "tailscale")
386
+ if (state.provider !== "tailscale" || !state.tailscale_mode)
346
387
  return true;
347
- if (!originalTailscaleRoute(state))
348
- return false;
349
388
  const cmd = tailscaleCommand(state.tailscale_mode);
350
389
  const logPath = tunnelLogDestinations(state.name, "tailscale").provider;
351
390
  const args = [
352
391
  cmd,
353
- `--https=${state.tailscale_https_port}`,
354
- `--set-path=${state.tailscale_path}`,
392
+ `--https=${state.tailscale_https_port ?? 443}`,
393
+ `--set-path=${state.tailscale_path ?? "/"}`,
355
394
  "off",
356
395
  ];
357
396
  const status = runRotatingProcessSync({
@@ -363,9 +402,9 @@ function stopTailscaleShare(state) {
363
402
  // The gate is torn down and its port is freed for reuse regardless, so a
364
403
  // surviving serve/funnel mapping could later re-expose whatever next binds
365
404
  // that port. Surface it loudly so the operator can clear it by hand.
366
- emit.text(`\n ⚠ Failed to remove the Tailscale ${cmd} mapping (path ${state.tailscale_path}).\n` +
405
+ emit.text(`\n ⚠ Failed to remove the Tailscale ${cmd} mapping (path ${state.tailscale_path ?? "/"}).\n` +
367
406
  ` It may still be exposing this machine. Clear it with:\n` +
368
- ` tailscale ${cmd} --https=${state.tailscale_https_port} --set-path=${state.tailscale_path} off\n` +
407
+ ` tailscale ${cmd} --https=${state.tailscale_https_port ?? 443} --set-path=${state.tailscale_path ?? "/"} off\n` +
369
408
  ` Log: ${logPath}\n\n`);
370
409
  return false;
371
410
  }
@@ -378,39 +417,13 @@ function contextVhost() {
378
417
  return resolved ?? null;
379
418
  }
380
419
  function providerIsAlive(state) {
381
- if (!canonicalProviderFields(state))
382
- return false;
383
420
  if (state.provider === "tailscale")
384
- return state.provider_state === "ready";
385
- const pid = state.cloudflared_pid;
421
+ return true;
422
+ const pid = state.cloudflared_pid ?? state.provider_pid;
386
423
  return typeof pid === "number" && isProcessAlive(pid);
387
424
  }
388
- function canonicalProviderFields(state) {
389
- return (!Object.hasOwn(state, "provider_pid") &&
390
- !Object.hasOwn(state, "provider_start_token") &&
391
- (state.provider !== "tailscale" ||
392
- (state.cloudflared_pid === undefined && state.cloudflared_start_token === undefined)));
393
- }
394
- function originalProcessWitnesses(state) {
395
- if (!canonicalProviderFields(state) ||
396
- !Number.isSafeInteger(state.gate_pid) ||
397
- state.gate_pid < 1 ||
398
- typeof state.gate_start_token !== "string" ||
399
- !state.gate_start_token ||
400
- typeof state.registration_id !== "string" ||
401
- !state.registration_id)
402
- return false;
403
- if (state.provider === "tailscale")
404
- return (originalTailscaleRoute(state) &&
405
- (state.provider_state === "ready" || state.provider_state === "pending"));
406
- return (state.provider === "cloudflare" &&
407
- Number.isSafeInteger(state.cloudflared_pid) &&
408
- state.cloudflared_pid > 0 &&
409
- typeof state.cloudflared_start_token === "string" &&
410
- !!state.cloudflared_start_token);
411
- }
412
- function readinessOf(state) {
413
- return tunnelReadiness(state, createTunnelReadinessVerifier({ coordRoot: registryRoot() }));
425
+ function tunnelIsAlive(state) {
426
+ return isTunnelStateLive(state);
414
427
  }
415
428
  /**
416
429
  * Spawn the detached gate worker.
@@ -421,7 +434,8 @@ function readinessOf(state) {
421
434
  * re-reads — that snapshot is precisely why `reload` has to exist.
422
435
  */
423
436
  function spawnGate(o, launch) {
424
- // Keep the instance name and port visible in process diagnostics.
437
+ // `--name`/`--port` on argv mirror the env vars; they're what makes the gate
438
+ // process distinguishable per-instance in a command-line search (see sweepStrays).
425
439
  const gateProc = spawnRotatingProcess({
426
440
  path: o.gateLogPath,
427
441
  command: launch.command,
@@ -440,79 +454,7 @@ function spawnGate(o, launch) {
440
454
  gateProc.unref();
441
455
  return gateProc;
442
456
  }
443
- function originalTailscaleRoute(state) {
444
- return ((state.tailscale_mode === "serve" || state.tailscale_mode === "funnel") &&
445
- typeof state.tailscale_path === "string" &&
446
- state.tailscale_path.startsWith("/") &&
447
- Number.isSafeInteger(state.tailscale_https_port) &&
448
- state.tailscale_https_port > 0 &&
449
- state.tailscale_https_port <= 65535);
450
- }
451
- function routeOf(state) {
452
- return state?.provider === "tailscale" && originalTailscaleRoute(state)
453
- ? { path: state.tailscale_path, httpsPort: state.tailscale_https_port }
454
- : null;
455
- }
456
- function routeKey(route) {
457
- return `${route.httpsPort}:${route.path}`;
458
- }
459
- export function withTunnelLifecycleCustody(name, requested, effect) {
460
- if (!/^[a-z0-9][a-z0-9-]*$/i.test(name))
461
- throw new Error("invalid tunnel lifecycle name");
462
- const leases = [];
463
- const release = () => {
464
- for (const lease of leases.splice(0).reverse())
465
- lease.release();
466
- };
467
- const acquire = (category, key) => {
468
- const scope = `tunnel-${category}-${createHash("sha256").update(key).digest("hex")}`;
469
- leases.push(acquireNoClobberLease({
470
- path: resolve(tunnelDir(), ".custody", category, createHash("sha256").update(key).digest("hex")),
471
- scope,
472
- authoritySha256: createHash("sha256").update(scope).digest("hex"),
473
- staleAfterMs: 1,
474
- }));
475
- };
476
- try {
477
- acquire("instance", name.toLowerCase());
478
- const retained = listSelectedStates();
479
- const existing = routeOf(retained.find((state) => state.name === name) ?? null);
480
- const routes = [
481
- ...new Set([existing, requested].filter((route) => !!route).map(routeKey)),
482
- ].sort();
483
- for (const key of routes)
484
- acquire("route", key);
485
- for (const other of retained) {
486
- if (other.name.toLowerCase() === name.toLowerCase()) {
487
- if (other.name !== name)
488
- throw new Error(`tunnel instance ${name} aliases retained spelling ${other.name}; lifecycle custody is unavailable`);
489
- continue;
490
- }
491
- const route = routeOf(other);
492
- if (route && routes.includes(routeKey(route)))
493
- throw new Error(`Tailscale route ${routeKey(route)} is retained by tunnel ${other.name}; provider custody is unavailable`);
494
- }
495
- const result = effect();
496
- if (result !== null &&
497
- (typeof result === "object" || typeof result === "function") &&
498
- typeof result.then === "function")
499
- return Promise.resolve(result).finally(release);
500
- release();
501
- return result;
502
- }
503
- catch (error) {
504
- release();
505
- throw error;
506
- }
507
- }
508
457
  async function up(opts) {
509
- const name = resolveName(opts.name);
510
- const requested = resolveProvider(opts.provider) === "tailscale"
511
- ? { path: resolveTailscalePath(opts.path, name), httpsPort: resolveHttpsPort(opts.httpsPort) }
512
- : null;
513
- return withTunnelLifecycleCustody(name, requested, () => upWithCustody(opts));
514
- }
515
- async function upWithCustody(opts) {
516
458
  const launch = currentGateLaunch();
517
459
  if (!launch) {
518
460
  emit.error({ code: "tunnel_requires_bun", message: requiresBunMessage() });
@@ -528,17 +470,8 @@ async function upWithCustody(opts) {
528
470
  const tailscalePath = provider === "tailscale" ? resolveTailscalePath(opts.path, name) : undefined;
529
471
  const tailscaleHttpsPort = provider === "tailscale" ? resolveHttpsPort(opts.httpsPort) : undefined;
530
472
  const allowPaths = resolveAllowPaths(opts.allowPath);
531
- const existing = readSelectedState(name);
532
- const readiness = existing ? readinessOf(existing) : null;
533
- if (readiness === "unknown") {
534
- emit.error({
535
- code: "tunnel_custody_unknown",
536
- message: `Tunnel [${name}] readiness is unknown; retained its state and refused cleanup or replacement.`,
537
- });
538
- process.exit(1);
539
- return;
540
- }
541
- if (existing && readiness === "ready") {
473
+ const existing = readState(name);
474
+ if (existing && tunnelIsAlive(existing)) {
542
475
  if (existing.provider !== provider) {
543
476
  emit.error({
544
477
  code: "tunnel_instance_in_use",
@@ -560,19 +493,15 @@ async function upWithCustody(opts) {
560
493
  emit.text(` Paths: ${formatPaths(existing.allow_paths)}\n`);
561
494
  return;
562
495
  }
563
- if (existing) {
564
- downOneWithCustody(name, new Set());
565
- if (readSelectedState(name)) {
566
- emit.error({
567
- code: "tunnel_custody_unknown",
568
- message: `Tunnel [${name}] has a live or unverifiable process; retained its state and refused a replacement.`,
569
- });
570
- process.exit(1);
571
- }
572
- }
496
+ if (existing)
497
+ downOne(name, new Set());
573
498
  // Allocate the gate port (after clearing dead state so its old port frees up
574
499
  // for reuse). Explicit --gate-port is validated; otherwise auto-scan.
575
500
  const gatePort = await allocateGatePort(opts.gatePort ? Number(opts.gatePort) : undefined);
501
+ // Self-heal: clear any orphaned gate/cloudflared on THIS instance's port from
502
+ // a prior crashed or state-cleared run so the gate port is free before we bind.
503
+ if (sweepStrays(gatePort, new Set()))
504
+ await sleep(500);
576
505
  const cfg = readConfig();
577
506
  if (provider === "cloudflare" && cfg.allowed_ips.length === 0) {
578
507
  emit.error({
@@ -614,12 +543,17 @@ async function upWithCustody(opts) {
614
543
  allowPaths,
615
544
  gateLogPath,
616
545
  }, launch);
617
- const gateStartToken = processStartToken(gateProc.pid);
618
546
  await sleep(800);
619
547
  // A host CLI that re-executes itself takes longer to load than a bare script,
620
548
  // so wait for the gate to actually listen before the provider forwards to it.
621
549
  if (!isProcessAlive(gateProc.pid) || !(await waitForPortBound(gatePort, 15_000))) {
622
- signalIfSameGeneration(gateProc.pid, gateStartToken, "SIGTERM");
550
+ try {
551
+ process.kill(gateProc.pid);
552
+ }
553
+ catch {
554
+ /* already dead */
555
+ }
556
+ sweepStrays(gatePort, new Set());
623
557
  emit.error({
624
558
  code: "tunnel_gate_failed",
625
559
  message: `Gate failed to start. Check log: ${gateLogPath}`,
@@ -629,8 +563,6 @@ async function upWithCustody(opts) {
629
563
  let url;
630
564
  let registered = true;
631
565
  let cloudflaredPid;
632
- let cloudflaredStartToken;
633
- let pendingReceipt = null;
634
566
  if (provider === "cloudflare") {
635
567
  // Force HTTP/2 transport. The default QUIC transport wedges at precheck on
636
568
  // constrained hosts (e.g. WSL, where UDP receive buffers can't grow and ICMP
@@ -645,12 +577,21 @@ async function upWithCustody(opts) {
645
577
  });
646
578
  cfdProc.unref();
647
579
  cloudflaredPid = cfdProc.pid;
648
- cloudflaredStartToken = processStartToken(cloudflaredPid) ?? undefined;
649
580
  const ready = await waitForReady(providerLogPath, 30_000);
650
581
  registered = ready.registered;
651
582
  if (!ready.url) {
652
- signalIfSameGeneration(gateProc.pid, gateStartToken, "SIGTERM");
653
- signalIfSameGeneration(cloudflaredPid, cloudflaredStartToken, "SIGTERM");
583
+ try {
584
+ process.kill(gateProc.pid);
585
+ }
586
+ catch {
587
+ /* already dead */
588
+ }
589
+ try {
590
+ process.kill(cloudflaredPid);
591
+ }
592
+ catch {
593
+ /* already dead */
594
+ }
654
595
  emit.error({
655
596
  code: "tunnel_url_timeout",
656
597
  message: `Failed to obtain tunnel URL within 30s. Check log: ${providerLogPath}`,
@@ -667,38 +608,13 @@ async function upWithCustody(opts) {
667
608
  // `heal` would then be unable to see or clean up.
668
609
  url = tailscaleUrl(tailscalePath, tailscaleHttpsPort);
669
610
  const gateTarget = `http://127.0.0.1:${gatePort}`;
670
- const pending = {
671
- name,
672
- provider,
673
- url,
674
- gate_pid: gateProc.pid,
675
- gate_start_token: gateStartToken ?? undefined,
676
- started_at: new Date().toISOString(),
677
- target,
678
- vhost,
679
- gate_port: gatePort,
680
- allow_paths: allowPaths,
681
- tailscale_mode: tailscaleMode,
682
- tailscale_path: tailscalePath,
683
- tailscale_https_port: tailscaleHttpsPort,
684
- provider_state: "pending",
685
- };
686
- pendingReceipt = recordTunnelServer(pending);
687
- if (!pendingReceipt) {
688
- const evidence = recordRefusedTunnelLaunch(pending);
689
- signalIfSameGeneration(pending.gate_pid, pending.gate_start_token, "SIGTERM");
690
- throw new Error(`Tailscale admission refused before provider effect; retained witnesses at ${evidence}`);
691
- }
692
- beginTailscaleRoute(pending, pendingReceipt, () => runTailscaleShare(tailscaleMode, gateTarget, tailscalePath, tailscaleHttpsPort, providerLogPath));
611
+ runTailscaleShare(tailscaleMode, gateTarget, tailscalePath, tailscaleHttpsPort, providerLogPath);
693
612
  }
694
613
  const state = {
695
614
  name,
696
615
  provider,
697
616
  url,
698
- ...(provider === "tailscale" ? { provider_state: "ready" } : {}),
699
617
  gate_pid: gateProc.pid,
700
- gate_start_token: gateStartToken ?? undefined,
701
- cloudflared_start_token: cloudflaredStartToken,
702
618
  cloudflared_pid: cloudflaredPid,
703
619
  started_at: new Date().toISOString(),
704
620
  target,
@@ -709,25 +625,8 @@ async function upWithCustody(opts) {
709
625
  tailscale_path: tailscalePath,
710
626
  tailscale_https_port: tailscaleHttpsPort,
711
627
  };
712
- const receipt = provider === "tailscale" ? pendingReceipt : recordTunnelServer(state);
713
- if (receipt)
714
- state.registration_id = receipt.registration_id;
715
- try {
716
- if (!receipt)
717
- throw new Error("registration refused");
718
- publishTunnelState(state, receipt);
719
- }
720
- catch {
721
- const evidence = recordRefusedTunnelLaunch(state);
722
- signalIfSameGeneration(state.gate_pid, state.gate_start_token, "SIGTERM");
723
- if (state.cloudflared_pid)
724
- signalIfSameGeneration(state.cloudflared_pid, state.cloudflared_start_token, "SIGTERM");
725
- emit.error({
726
- code: "tunnel_registration_refused",
727
- message: `Tunnel [${name}] registration custody was refused; saved process witnesses at ${evidence}.`,
728
- });
729
- process.exit(1);
730
- }
628
+ writeState(state);
629
+ recordTunnelServer(state);
731
630
  const bin = resolveBinName();
732
631
  const stopHint = name === DEFAULT_INSTANCE ? `${bin} tunnel down` : `${bin} tunnel down --name ${name}`;
733
632
  emit.text(`\n Instance: ${name}\n`);
@@ -755,211 +654,72 @@ async function upWithCustody(opts) {
755
654
  function registryRoot() {
756
655
  return resolveCoordRoot() ?? process.cwd();
757
656
  }
758
- function unknownTunnelFrame(name) {
759
- return Object.assign(new Error(`Tunnel [${name}] state is unreadable or its custody is unknown; retained the frame.`), {
760
- code: "tunnel_custody_unknown",
761
- });
762
- }
763
- function readSelectedState(name) {
764
- const path = resolve(tunnelDir(), name === DEFAULT_INSTANCE ? "state.json" : `state-${name}.json`);
765
- let bytes;
766
- try {
767
- bytes = readFileSync(path, "utf8");
768
- }
769
- catch (error) {
770
- if (error.code === "ENOENT")
771
- return null;
772
- throw unknownTunnelFrame(name);
773
- }
774
- try {
775
- const raw = JSON.parse(bytes);
776
- if (!raw ||
777
- typeof raw !== "object" ||
778
- Array.isArray(raw) ||
779
- raw.name !== name ||
780
- (raw.provider !== "cloudflare" && raw.provider !== "tailscale") ||
781
- (raw.provider === "tailscale" && !originalTailscaleRoute(raw)) ||
782
- typeof raw.url !== "string" ||
783
- !raw.url ||
784
- typeof raw.target !== "string" ||
785
- !raw.target ||
786
- typeof raw.vhost !== "string" ||
787
- !Number.isSafeInteger(raw.gate_pid) ||
788
- raw.gate_pid < 1 ||
789
- !Number.isSafeInteger(raw.gate_port) ||
790
- raw.gate_port < 1 ||
791
- raw.gate_port > 65535 ||
792
- !Array.isArray(raw.allow_paths) ||
793
- raw.allow_paths.some((path) => typeof path !== "string"))
794
- throw unknownTunnelFrame(name);
795
- return raw;
796
- }
797
- catch {
798
- throw unknownTunnelFrame(name);
799
- }
800
- }
801
- function listSelectedStates() {
802
- let files;
803
- try {
804
- files = readdirSync(tunnelDir());
805
- }
806
- catch (error) {
807
- if (error.code === "ENOENT")
808
- return [];
809
- throw unknownTunnelFrame("inventory");
810
- }
811
- const states = [];
812
- for (const file of files) {
813
- const name = file === "state.json" ? DEFAULT_INSTANCE : /^state-(.+)\.json$/.exec(file)?.[1];
814
- if (!name)
815
- continue;
816
- const state = readSelectedState(name);
817
- if (state)
818
- states.push(state);
819
- }
820
- return states.sort((a, b) => (typeof b.started_at === "string" ? b.started_at : "").localeCompare(typeof a.started_at === "string" ? a.started_at : ""));
821
- }
822
- function matchesTunnelReceipt(state, receipt) {
823
- return (originalProcessWitnesses(state) &&
824
- tunnelServerId(state.name) === receipt.id &&
825
- receipt.host === hostname() &&
826
- receipt.kind === "service" &&
827
- receipt.type === "tunnel" &&
828
- receipt.port === state.gate_port &&
829
- receipt.url === state.url &&
830
- receipt.registration_id === state.registration_id &&
831
- receipt.pid === state.gate_pid &&
832
- receipt.start_token === state.gate_start_token);
833
- }
834
- /** Retain a refused launch without overwriting another launch's canonical state. */
835
- export function recordRefusedTunnelLaunch(state) {
836
- const path = resolve(tunnelDir(), `failed-${state.name}-${randomUUID()}.json`);
837
- writePrivateJsonAtomic(path, state);
838
- return path;
839
- }
840
- /** A canonical state frame may be published only by its current registration. */
841
- export function publishTunnelState(state, receipt) {
842
- if (!matchesTunnelReceipt(state, receipt))
843
- throw new Error("tunnel state registration custody does not match");
844
- withServerRegistrationCustody(receipt, { coordRoot: registryRoot() }, () => writeState(state));
845
- }
846
- /** Retain pending route intent before asking the provider to change external state. */
847
- export function beginTailscaleRoute(state, receipt, effect) {
848
- if (state.provider !== "tailscale" || state.provider_state !== "pending")
849
- throw new Error("Tailscale route intent is not pending");
850
- publishTunnelState(state, receipt);
851
- return withServerRegistrationCustody(receipt, { coordRoot: registryRoot() }, effect);
852
- }
853
657
  function recordTunnelServer(state) {
854
- if (!state.gate_start_token)
855
- return null;
856
658
  const coordRoot = registryRoot();
857
- const record = tryRegisterServer(tunnelServerInput(state, coordRoot), { coordRoot });
858
- if (record)
859
- state.registration_id = record.registration_id;
860
- return record;
659
+ tryRegisterServer(tunnelServerInput(state, coordRoot), { coordRoot });
861
660
  }
862
- function tunnelRegistration(state) {
863
- if (!originalProcessWitnesses(state))
864
- return { known: false, record: null };
865
- const coordRoot = registryRoot();
866
- const id = tunnelServerId(state.name);
867
- let record;
868
- try {
869
- record = readServer(id, { coordRoot });
870
- }
871
- catch {
872
- return { known: false, record: null };
873
- }
874
- const present = existsSync(resolve(serversDir(coordRoot), `${id}.json`));
875
- if (!present)
876
- return { known: false, record: null };
877
- return { known: !!record && matchesTunnelReceipt(state, record), record };
661
+ /** Tear down a single instance by name. Returns the number of processes killed. */
662
+ function portHeldByOtherLiveInstance(port, name) {
663
+ return listStates().some((s) => s.name !== name && s.gate_port === port && isProcessAlive(s.gate_pid));
878
664
  }
879
- /** Unknown saved identities retain state and never authorize a signal. */
880
665
  export function downOne(name, killed) {
881
- try {
882
- return withTunnelLifecycleCustody(name, null, () => downOneWithCustody(name, killed));
883
- }
884
- catch {
885
- return 0;
886
- }
887
- }
888
- function downOneWithCustody(name, killed) {
889
666
  const before = killed.size;
890
- const state = readSelectedState(name);
891
- if (!state)
892
- return 0;
893
- const registration = tunnelRegistration(state);
894
- if (!registration.known)
895
- return 0;
896
- const targets = [
897
- { pid: state.gate_pid, token: state.gate_start_token },
898
- { pid: state.cloudflared_pid, token: state.cloudflared_start_token },
899
- ].filter((target) => typeof target.pid === "number");
900
- for (const target of targets) {
901
- if (pidAlive(target.pid) && verifyProcessGeneration(target.pid, target.token) !== "same")
902
- return 0;
903
- }
904
- if (!registration.record)
905
- return 0;
906
- let cleared = false;
907
- try {
908
- withServerRegistrationCustody(registration.record, { coordRoot: registryRoot() }, () => {
909
- for (const target of targets) {
910
- if (!pidAlive(target.pid) || killed.has(target.pid))
911
- continue;
912
- const result = signalIfSameGeneration(target.pid, target.token, "SIGTERM");
913
- if (!result.sent)
914
- throw new Error("tunnel process custody is unknown");
915
- killed.add(target.pid);
667
+ const state = readState(name);
668
+ if (state) {
669
+ if (state.provider === "tailscale")
670
+ stopTailscaleShare(state);
671
+ for (const pid of [state.gate_pid, state.cloudflared_pid, state.provider_pid]) {
672
+ if (typeof pid !== "number")
673
+ continue;
674
+ if (isProcessAlive(pid)) {
675
+ try {
676
+ process.kill(pid);
677
+ killed.add(pid);
678
+ }
679
+ catch {
680
+ /* race: already gone */
681
+ }
916
682
  }
917
- // Observation and deletion share custody; an old stopper cannot clear a winner.
918
- if (targets.some((target) => pidAlive(target.pid)))
919
- return;
920
- if (JSON.stringify(readSelectedState(name)) !== JSON.stringify(state))
921
- return;
922
- if (state.provider === "tailscale" && !stopTailscaleShare(state))
923
- return;
924
- clearState(name);
925
- cleared = true;
926
- });
927
- }
928
- catch {
929
- return killed.size - before;
683
+ }
930
684
  }
931
- if (cleared)
932
- unregisterServer(registration.record, { coordRoot: registryRoot() });
685
+ // Fallback: sweep orphans on this instance's gate port, even when state was
686
+ // lost; they'd otherwise squat on the port and break the next `up`. Skip it
687
+ // when another live instance now owns that port: a stale record's port is
688
+ // reused after a reboot, and sweeping it would kill the live tunnel there.
689
+ const port = state?.gate_port ?? DEFAULT_GATE_PORT;
690
+ if (!portHeldByOtherLiveInstance(port, name))
691
+ sweepStrays(port, killed);
692
+ clearState(name);
693
+ unregisterServer(tunnelServerId(name), { coordRoot: registryRoot() });
933
694
  return killed.size - before;
934
695
  }
935
696
  function down(opts) {
936
697
  const killed = new Set();
937
698
  if (opts.all) {
938
- const states = listSelectedStates();
699
+ const states = listStates();
939
700
  if (states.length === 0) {
940
701
  emit.text("No tunnels up. Nothing to stop.\n");
941
702
  return;
942
703
  }
943
704
  for (const s of states)
944
705
  downOne(s.name, killed);
945
- const retained = states.filter((state) => readSelectedState(state.name)).length;
946
- emit.text(`Requested stop for ${states.length} tunnel(s); ${killed.size} process signal(s), ${retained} state record(s) retained pending verified exit.\n`);
706
+ emit.text(`Stopped ${states.length} tunnel(s) [${states.map((s) => s.name).join(", ")}], ${killed.size} process(es).\n`);
947
707
  return;
948
708
  }
949
709
  const name = resolveName(opts.name);
950
710
  // Bare `down` targets the default instance. If it's not up but named ones
951
711
  // are, don't silently no-op; point the operator at them.
952
- if (name === DEFAULT_INSTANCE && !readSelectedState(DEFAULT_INSTANCE)) {
953
- const others = listSelectedStates();
712
+ if (name === DEFAULT_INSTANCE && !readState(DEFAULT_INSTANCE)) {
713
+ const others = listStates();
954
714
  if (others.length > 0) {
955
715
  emit.text(`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`);
956
716
  return;
957
717
  }
958
718
  }
959
719
  downOne(name, killed);
960
- emit.text(readSelectedState(name)
961
- ? `Tunnel [${name}] state retained pending verified process exit or custody; ${killed.size} signal(s) sent.\n`
962
- : `Tunnel [${name}] has no remaining recorded processes.\n`);
720
+ emit.text(killed.size === 0
721
+ ? `No tunnel processes found for [${name}]. Nothing to stop.\n`
722
+ : `Stopped ${killed.size} process(es). Tunnel [${name}] down.\n`);
963
723
  }
964
724
  /** Poll until `port` has no LISTEN socket (or the deadline passes). */
965
725
  async function waitForPortFree(port, timeoutMs) {
@@ -993,25 +753,9 @@ async function waitForPortBound(port, timeoutMs) {
993
753
  * edge reconnects without noticing.
994
754
  */
995
755
  export async function reloadOne(state) {
996
- try {
997
- return await withTunnelLifecycleCustody(state.name, routeOf(state), () => reloadOneWithCustody(state));
998
- }
999
- catch {
1000
- return {
1001
- ok: false,
1002
- message: `[${state.name}] lifecycle or provider route custody is unavailable; retained the tunnel.`,
1003
- };
1004
- }
1005
- }
1006
- async function reloadOneWithCustody(state) {
1007
756
  const bin = resolveBinName();
1008
757
  const name = state.name;
1009
758
  const gatePort = state.gate_port;
1010
- if (!canonicalProviderFields(state))
1011
- return {
1012
- ok: false,
1013
- message: `[${name}] obsolete or ambiguous provider authority is unknown; retained the tunnel.`,
1014
- };
1015
759
  if (!providerIsAlive(state)) {
1016
760
  return {
1017
761
  ok: false,
@@ -1034,51 +778,27 @@ async function reloadOneWithCustody(state) {
1034
778
  `\`${bin} tunnel up --name ${name} --allow-path <prefix>\`.`,
1035
779
  };
1036
780
  }
1037
- const registration = tunnelRegistration(state);
1038
- if (!registration.known || !registration.record)
1039
- return { ok: false, message: `[${name}] registration custody changed; retained the tunnel.` };
1040
- const stopGate = async () => {
1041
- if (state.provider === "cloudflare" &&
1042
- state.cloudflared_pid &&
1043
- verifyProcessGeneration(state.cloudflared_pid, state.cloudflared_start_token) !== "same")
1044
- return {
1045
- ok: false,
1046
- message: `[${name}] provider generation is unknown or changed; retained the tunnel.`,
1047
- };
1048
- if (pidAlive(state.gate_pid)) {
1049
- if (!registration.record)
1050
- return {
1051
- ok: false,
1052
- message: `[${name}] registration custody is unavailable; retained the tunnel.`,
1053
- };
1054
- const result = signalIfSameGeneration(state.gate_pid, state.gate_start_token, "SIGTERM");
1055
- if (!result.sent)
1056
- return {
1057
- ok: false,
1058
- message: `[${name}] gate generation is unknown or changed; retained the tunnel.`,
1059
- };
781
+ // Kill ONLY the gate. sweepStrays() is deliberately avoided here: it also
782
+ // matches `--url http://localhost:<port>`, which is the provider process we
783
+ // are going out of our way to keep alive.
784
+ const killed = new Set();
785
+ if (isProcessAlive(state.gate_pid)) {
786
+ try {
787
+ process.kill(state.gate_pid);
788
+ killed.add(state.gate_pid);
789
+ }
790
+ catch {
791
+ /* race: already gone */
1060
792
  }
1061
- if (!(await waitForPortFree(gatePort, 5_000)))
1062
- return {
1063
- ok: false,
1064
- message: `[${name}] port ${gatePort} never released; retained the tunnel state.`,
1065
- };
1066
- return { ok: true, message: "gate released" };
1067
- };
1068
- let released;
1069
- try {
1070
- released = await withServerRegistrationCustody(registration.record, { coordRoot: registryRoot() }, stopGate);
1071
793
  }
1072
- catch {
794
+ killByPattern(gatePattern(gatePort), killed);
795
+ if (!(await waitForPortFree(gatePort, 5_000))) {
1073
796
  return {
1074
797
  ok: false,
1075
- message: `[${name}] registration custody is unavailable; retained the tunnel.`,
798
+ message: `[${name}] port ${gatePort} never released, so the old gate looks wedged. ` +
799
+ `Tunnel is down; check \`${bin} tunnel status\`.`,
1076
800
  };
1077
801
  }
1078
- if (!released.ok)
1079
- return released;
1080
- if (registration.record)
1081
- unregisterServer(registration.record, { coordRoot: registryRoot() });
1082
802
  const cfg = readConfig();
1083
803
  const gateProc = spawnGate({
1084
804
  name,
@@ -1090,7 +810,6 @@ async function reloadOneWithCustody(state) {
1090
810
  allowPaths: state.allow_paths,
1091
811
  gateLogPath: tunnelLogDestinations(name, state.provider).gate,
1092
812
  }, launch);
1093
- const gateStartToken = processStartToken(gateProc.pid);
1094
813
  await sleep(800);
1095
814
  const pid = gateProc.pid;
1096
815
  if (typeof pid !== "number" ||
@@ -1102,33 +821,15 @@ async function reloadOneWithCustody(state) {
1102
821
  `Tunnel is down; check \`${bin} tunnel logs --name ${name}\`.`,
1103
822
  };
1104
823
  }
1105
- const next = {
1106
- ...state,
1107
- gate_pid: pid,
1108
- gate_start_token: gateStartToken ?? undefined,
1109
- registration_id: undefined,
1110
- };
1111
- const nextReceipt = recordTunnelServer(next);
1112
- try {
1113
- if (!nextReceipt)
1114
- throw new Error("registration refused");
1115
- publishTunnelState(next, nextReceipt);
1116
- }
1117
- catch {
1118
- const evidence = recordRefusedTunnelLaunch(next);
1119
- signalIfSameGeneration(pid, gateStartToken, "SIGTERM");
1120
- return {
1121
- ok: false,
1122
- message: `[${name}] new gate registration custody was refused; retained witnesses at ${evidence}.`,
1123
- };
1124
- }
824
+ writeState({ ...state, gate_pid: pid });
825
+ recordTunnelServer({ ...state, gate_pid: pid });
1125
826
  const allowNote = state.provider === "cloudflare" ? ` Allowlist: ${cfg.allowed_ips.length} IP(s).` : "";
1126
827
  return { ok: true, message: `[${name}] gate reloaded, URL unchanged: ${state.url}.${allowNote}` };
1127
828
  }
1128
829
  async function reload(opts) {
1129
830
  const bin = resolveBinName();
1130
831
  if (opts.all) {
1131
- const all = listSelectedStates();
832
+ const all = listStates();
1132
833
  // Only live tunnels are reloadable, and a box commonly accumulates stale
1133
834
  // state files from long-dead runs. Reporting those as failures would make
1134
835
  // the common post-`allow add` path exit non-zero for no real reason.
@@ -1154,7 +855,7 @@ async function reload(opts) {
1154
855
  return;
1155
856
  }
1156
857
  const name = resolveName(opts.name);
1157
- const state = readSelectedState(name);
858
+ const state = readState(name);
1158
859
  if (!state) {
1159
860
  emit.error({
1160
861
  code: "tunnel_not_found",
@@ -1169,8 +870,7 @@ async function reload(opts) {
1169
870
  process.exit(1);
1170
871
  }
1171
872
  function instanceState(state) {
1172
- const readiness = readinessOf(state);
1173
- return readiness === "ready" ? "up" : readiness === "dead" ? "stale" : "unknown";
873
+ return tunnelIsAlive(state) ? "up" : "stale";
1174
874
  }
1175
875
  function fmtUptime(startedAt) {
1176
876
  const secs = Math.floor((Date.now() - new Date(startedAt).getTime()) / 1000);
@@ -1186,9 +886,9 @@ function fmtUptime(startedAt) {
1186
886
  function statusDetail(state) {
1187
887
  const gateAlive = isProcessAlive(state.gate_pid);
1188
888
  const providerAlive = providerIsAlive(state);
1189
- const providerPid = state.cloudflared_pid;
889
+ const providerPid = state.cloudflared_pid ?? state.provider_pid;
1190
890
  const cfg = readConfig();
1191
- emit.text(`${instanceState(state)} [${state.name}]\n`);
891
+ emit.text(`${gateAlive && providerAlive ? "up" : "stale"} [${state.name}]\n`);
1192
892
  emit.text(` Provider: ${state.provider}${state.tailscale_mode ? ` (${state.tailscale_mode})` : ""}\n`);
1193
893
  emit.text(` URL: ${state.url}\n`);
1194
894
  emit.text(` Forwarding: ${state.target} (Host: ${state.vhost})\n`);
@@ -1209,7 +909,7 @@ function statusDetail(state) {
1209
909
  function status(opts) {
1210
910
  // Named → detailed single block.
1211
911
  if (opts.name) {
1212
- const state = readSelectedState(resolveName(opts.name));
912
+ const state = readState(resolveName(opts.name));
1213
913
  if (!state) {
1214
914
  emit.text(`down [${resolveName(opts.name)}]\n`);
1215
915
  return;
@@ -1218,7 +918,7 @@ function status(opts) {
1218
918
  return;
1219
919
  }
1220
920
  // No name → table of every instance.
1221
- const states = listSelectedStates();
921
+ const states = listStates();
1222
922
  if (states.length === 0) {
1223
923
  emit.text("down\n");
1224
924
  return;
@@ -1255,7 +955,7 @@ function status(opts) {
1255
955
  }
1256
956
  function logs(opts) {
1257
957
  const name = resolveName(opts.name);
1258
- const state = readSelectedState(name);
958
+ const state = readState(name);
1259
959
  const provider = state?.provider ?? "cloudflare";
1260
960
  const destinations = tunnelLogDestinations(name, provider);
1261
961
  const path = opts.provider ? destinations.provider : destinations.gate;
@@ -1289,7 +989,7 @@ const sameEntryIn = (list, entry) => list.some((e) => canonicalEntry(e) === cano
1289
989
  * restarts were owed.
1290
990
  */
1291
991
  function allowChangedHint() {
1292
- const live = listSelectedStates().filter((s) => s.provider === "cloudflare" && providerIsAlive(s));
992
+ const live = listStates().filter((s) => s.provider === "cloudflare" && providerIsAlive(s));
1293
993
  if (live.length === 0)
1294
994
  return;
1295
995
  emit.text(`Each gate reads the allowlist once at start, so this applies on reload ` +
@@ -1396,7 +1096,7 @@ export async function refreshCurrentAddress(options) {
1396
1096
  }
1397
1097
  if (!plan.changed || !options.reload)
1398
1098
  return result;
1399
- const live = listSelectedStates().filter((s) => s.provider === "cloudflare" && providerIsAlive(s));
1099
+ const live = listStates().filter((s) => s.provider === "cloudflare" && providerIsAlive(s));
1400
1100
  for (const s of live) {
1401
1101
  const r = await reloadOne(s);
1402
1102
  emit.text(`${r.ok ? "ok " : "FAIL"} ${r.message}\n`);