harnery 0.45.0 → 0.45.1

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 (128) 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/core/agents/live-authority-v3.d.ts +0 -1
  11. package/dist/core/agents/live-authority-v3.d.ts.map +1 -1
  12. package/dist/core/agents/live-authority-v3.js +0 -11
  13. package/dist/core/agents/live-lifecycle-v3.d.ts +2 -4
  14. package/dist/core/agents/live-lifecycle-v3.d.ts.map +1 -1
  15. package/dist/core/agents/live-lifecycle-v3.js +7 -28
  16. package/dist/core/agents/state/heartbeat-writer.d.ts.map +1 -1
  17. package/dist/core/agents/state/heartbeat-writer.js +14 -25
  18. package/dist/core/agents/state/live-coordination-writer.d.ts.map +1 -1
  19. package/dist/core/agents/state/live-coordination-writer.js +0 -5
  20. package/dist/core/agents/state/pidmap.d.ts.map +1 -1
  21. package/dist/core/agents/state/pidmap.js +0 -5
  22. package/dist/core/agents/state/stale-sweep.d.ts.map +1 -1
  23. package/dist/core/agents/state/stale-sweep.js +70 -180
  24. package/dist/core/artifacts/activity.d.ts +4 -1
  25. package/dist/core/artifacts/activity.d.ts.map +1 -1
  26. package/dist/core/artifacts/activity.js +8 -2
  27. package/dist/core/artifacts/delivery-card.d.ts.map +1 -1
  28. package/dist/core/artifacts/delivery-card.js +1 -2
  29. package/dist/core/artifacts/index.d.ts.map +1 -1
  30. package/dist/core/artifacts/index.js +38 -12
  31. package/dist/core/events/v3/authority-outbox.d.ts.map +1 -1
  32. package/dist/core/events/v3/authority-outbox.js +0 -29
  33. package/dist/core/events/v3/producers/coordination-recorder.d.ts +0 -1
  34. package/dist/core/events/v3/producers/coordination-recorder.d.ts.map +1 -1
  35. package/dist/core/events/v3/producers/coordination-recorder.js +0 -7
  36. package/dist/core/events/v3/producers/recorder.d.ts.map +1 -1
  37. package/dist/core/events/v3/producers/recorder.js +64 -77
  38. package/dist/core/hooks/cli.js +78 -48
  39. package/dist/core/servers/index.d.ts +13 -10
  40. package/dist/core/servers/index.d.ts.map +1 -1
  41. package/dist/core/servers/index.js +97 -192
  42. package/dist/core/servers/tunnels.d.ts +3 -5
  43. package/dist/core/servers/tunnels.d.ts.map +1 -1
  44. package/dist/core/servers/tunnels.js +2 -45
  45. package/dist/core/storage/durable-history.js +56 -22
  46. package/dist/core/storage/segments.d.ts.map +1 -1
  47. package/dist/core/storage/segments.js +73 -17
  48. package/dist/core/work/state.js +7 -7
  49. package/dist/core/workflow/engine.d.ts.map +1 -1
  50. package/dist/core/workflow/engine.js +21 -65
  51. package/dist/core/workflow/live-session-v3.d.ts +0 -6
  52. package/dist/core/workflow/live-session-v3.d.ts.map +1 -1
  53. package/dist/core/workflow/live-session-v3.js +5 -126
  54. package/dist/core/workflow/proof.d.ts +4 -4
  55. package/dist/core/workflow/proof.d.ts.map +1 -1
  56. package/dist/core/workflow/proof.js +4 -12
  57. package/dist/core/workflow/spawn-claude.d.ts.map +1 -1
  58. package/dist/core/workflow/spawn-claude.js +0 -19
  59. package/dist/core/workflow/spawn-codex.d.ts.map +1 -1
  60. package/dist/core/workflow/spawn-codex.js +0 -13
  61. package/dist/core/workflow/spawn-cursor.d.ts.map +1 -1
  62. package/dist/core/workflow/spawn-cursor.js +0 -19
  63. package/dist/core/workflow/spawn-opencode.d.ts.map +1 -1
  64. package/dist/core/workflow/spawn-opencode.js +0 -14
  65. package/dist/core/workflow/types.d.ts +11 -54
  66. package/dist/core/workflow/types.d.ts.map +1 -1
  67. package/dist/lib/identities/assume.d.ts.map +1 -1
  68. package/dist/lib/identities/assume.js +0 -4
  69. package/dist/lib/tunnel/state.d.ts +6 -13
  70. package/dist/lib/tunnel/state.d.ts.map +1 -1
  71. package/dist/lib/tunnel/state.js +12 -58
  72. package/package.json +1 -6
  73. package/src/commander.ts +0 -10
  74. package/src/commands/files.ts +5 -13
  75. package/src/commands/servers.ts +23 -12
  76. package/src/commands/tunnel.ts +166 -533
  77. package/src/core/agents/live-authority-v3.ts +0 -17
  78. package/src/core/agents/live-lifecycle-v3.ts +14 -52
  79. package/src/core/agents/state/heartbeat-writer.ts +12 -29
  80. package/src/core/agents/state/live-coordination-writer.ts +0 -29
  81. package/src/core/agents/state/pidmap.ts +0 -13
  82. package/src/core/agents/state/stale-sweep.ts +82 -247
  83. package/src/core/artifacts/activity.ts +9 -2
  84. package/src/core/artifacts/delivery-card.ts +1 -7
  85. package/src/core/artifacts/index.ts +45 -12
  86. package/src/core/events/v3/authority-outbox.ts +0 -56
  87. package/src/core/events/v3/producers/coordination-recorder.ts +0 -15
  88. package/src/core/events/v3/producers/recorder.ts +68 -94
  89. package/src/core/hooks/cli.ts +85 -47
  90. package/src/core/servers/index.ts +106 -228
  91. package/src/core/servers/tunnels.ts +3 -55
  92. package/src/core/storage/durable-history.ts +52 -20
  93. package/src/core/storage/segments.ts +79 -17
  94. package/src/core/work/state.ts +7 -7
  95. package/src/core/workflow/engine.ts +21 -73
  96. package/src/core/workflow/live-session-v3.ts +5 -146
  97. package/src/core/workflow/proof.ts +5 -21
  98. package/src/core/workflow/spawn-claude.ts +0 -18
  99. package/src/core/workflow/spawn-codex.ts +0 -13
  100. package/src/core/workflow/spawn-cursor.ts +0 -18
  101. package/src/core/workflow/spawn-opencode.ts +0 -14
  102. package/src/core/workflow/types.ts +11 -41
  103. package/src/lib/identities/assume.ts +0 -4
  104. package/src/lib/tunnel/state.ts +15 -70
  105. package/dist/commands/turn-rollback.d.ts +0 -5
  106. package/dist/commands/turn-rollback.d.ts.map +0 -1
  107. package/dist/commands/turn-rollback.js +0 -76
  108. package/dist/core/agents/state/unchanged-file.d.ts +0 -44
  109. package/dist/core/agents/state/unchanged-file.d.ts.map +0 -1
  110. package/dist/core/agents/state/unchanged-file.js +0 -390
  111. package/dist/core/events/v3/generation-custody.d.ts +0 -5
  112. package/dist/core/events/v3/generation-custody.d.ts.map +0 -1
  113. package/dist/core/events/v3/generation-custody.js +0 -50
  114. package/dist/core/turn-rollback/content.d.ts +0 -53
  115. package/dist/core/turn-rollback/content.d.ts.map +0 -1
  116. package/dist/core/turn-rollback/content.js +0 -396
  117. package/dist/core/turn-rollback/index.d.ts +0 -72
  118. package/dist/core/turn-rollback/index.d.ts.map +0 -1
  119. package/dist/core/turn-rollback/index.js +0 -443
  120. package/dist/core/workflow/failure-evidence.d.ts +0 -16
  121. package/dist/core/workflow/failure-evidence.d.ts.map +0 -1
  122. package/dist/core/workflow/failure-evidence.js +0 -114
  123. package/src/commands/turn-rollback.ts +0 -111
  124. package/src/core/agents/state/unchanged-file.ts +0 -456
  125. package/src/core/events/v3/generation-custody.ts +0 -59
  126. package/src/core/turn-rollback/content.ts +0 -499
  127. package/src/core/turn-rollback/index.ts +0 -580
  128. package/src/core/workflow/failure-evidence.ts +0 -141
@@ -20,7 +20,7 @@
20
20
  * automatically.
21
21
  */
22
22
 
23
- import { createHash, randomUUID } from "node:crypto";
23
+ import { createHash } from "node:crypto";
24
24
  import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs";
25
25
  import { hostname } from "node:os";
26
26
  import { basename, isAbsolute, join, relative, resolve } from "node:path";
@@ -41,7 +41,6 @@ import {
41
41
  import { liveInstanceIdV3 } from "../events/v3/live-route-observer.ts";
42
42
  import { resolveOwner } from "../hooks/resolve/owner.ts";
43
43
  import { writePrivateJsonAtomic } from "../storage/atomic-json.ts";
44
- import { acquireNoClobberLease } from "../workflow/workspaces/leases.ts";
45
44
  import {
46
45
  ancestry,
47
46
  establishedConnectionCounts,
@@ -53,6 +52,7 @@ import {
53
52
  scanSupport,
54
53
  } from "./net.ts";
55
54
 
55
+ // Host supervisors verify and signal their own children with the same checks.
56
56
  export {
57
57
  processStartToken,
58
58
  signalIfSameGeneration,
@@ -74,9 +74,9 @@ export {
74
74
  readProcessInfo,
75
75
  scanSupport,
76
76
  } from "./net.ts";
77
- export { createTunnelReadinessVerifier, tunnelServerId, tunnelServerInput } from "./tunnels.ts";
77
+ export { tunnelServerId, tunnelServerInput } from "./tunnels.ts";
78
78
 
79
- export const SERVER_RECORD_SCHEMA_VERSION = 2;
79
+ export const SERVER_RECORD_SCHEMA_VERSION = 1;
80
80
  export const SERVER_DEFAULT_IDLE_HOURS = 2;
81
81
  export const SERVER_DEFAULT_OWNER_STALE_HOURS = 24;
82
82
  const TOUCH_INTERVAL_MS = 60_000;
@@ -86,7 +86,6 @@ export type ServerKind = "service" | "session";
86
86
  export interface ServerRecord {
87
87
  schema_version: typeof SERVER_RECORD_SCHEMA_VERSION;
88
88
  id: string;
89
- registration_id: string;
90
89
  kind: ServerKind;
91
90
  /** Free-form family name: `preview`, `tunnel`, `dashboard`, ... */
92
91
  type: string;
@@ -217,57 +216,39 @@ export function registerServer(
217
216
  )
218
217
  throw new Error(`invalid server port: ${input.port}`);
219
218
  const id = input.id ?? serverId(input.type, input.scope);
220
- return withServerMutation(coordRoot, id, () => {
221
- const path = recordPath(coordRoot, id);
222
- const now = new Date().toISOString();
223
- const startToken = input.start_token ?? processStartToken(pid);
224
- const pids = [...new Set(input.pids?.filter((p) => Number.isSafeInteger(p) && p > 0))];
225
- const pidsStartTokens: Record<string, string> = {};
226
- for (const auxiliaryPid of pids) {
227
- const token = processStartToken(auxiliaryPid);
228
- if (token) pidsStartTokens[auxiliaryPid] = token;
229
- }
230
- const owner = input.owner === undefined ? callingOwner(coordRoot) : (input.owner ?? undefined);
231
- const record: ServerRecord = {
232
- schema_version: SERVER_RECORD_SCHEMA_VERSION,
233
- id,
234
- registration_id: randomUUID(),
235
- kind: input.kind,
236
- type: input.type,
237
- label: input.label ?? id,
238
- ...(input.url ? { url: input.url } : {}),
239
- ...(input.port ? { port: input.port } : {}),
240
- pid,
241
- ...(startToken ? { start_token: startToken } : {}),
242
- ...(pids.length ? { pids, pids_start_tokens: pidsStartTokens } : {}),
243
- host: hostname(),
244
- ...(input.scope ? { scope: resolve(input.scope) } : {}),
245
- ...(input.cwd ? { cwd: resolve(input.cwd) } : {}),
246
- ...(input.log ? { log: resolve(input.log) } : {}),
247
- ...(owner ? { owner } : {}),
248
- started_at: input.started_at ?? now,
249
- registered_at: now,
250
- last_active_at: now,
251
- };
252
- if (
253
- input.start_token !== undefined &&
254
- verifyProcessGeneration(pid, input.start_token) !== "same"
255
- )
256
- throw new Error(`pid ${pid} generation could not be verified before registration`);
257
- if (existsSync(path)) {
258
- const existing = readRecord(path);
259
- if (!existing)
260
- throw new Error(
261
- `registration ${id} custody is unknown: invalid or older server record retained`,
262
- );
263
- if (sameProcessCustody(existing, record) && serverState(existing) === "running")
264
- return existing;
265
- if (existing.host !== hostname() || !allProcessesAbsent(existing))
266
- throw new Error(`registration ${id} custody is held by a live or unverifiable server`);
267
- }
268
- writePrivateJsonAtomic(path, record);
269
- return record;
270
- });
219
+ const now = new Date().toISOString();
220
+ const startToken = input.start_token ?? processStartToken(pid);
221
+ const pids = [...new Set(input.pids?.filter((p) => Number.isSafeInteger(p) && p > 0))];
222
+ const pidsStartTokens: Record<string, string> = {};
223
+ for (const auxiliaryPid of pids) {
224
+ const token = processStartToken(auxiliaryPid);
225
+ if (token) pidsStartTokens[auxiliaryPid] = token;
226
+ }
227
+ const owner = input.owner === undefined ? callingOwner(coordRoot) : (input.owner ?? undefined);
228
+ const record: ServerRecord = {
229
+ schema_version: SERVER_RECORD_SCHEMA_VERSION,
230
+ id,
231
+ kind: input.kind,
232
+ type: input.type,
233
+ label: input.label ?? id,
234
+ ...(input.url ? { url: input.url } : {}),
235
+ ...(input.port ? { port: input.port } : {}),
236
+ pid,
237
+ ...(startToken ? { start_token: startToken } : {}),
238
+ ...(pids.length ? { pids, pids_start_tokens: pidsStartTokens } : {}),
239
+ host: hostname(),
240
+ ...(input.scope ? { scope: resolve(input.scope) } : {}),
241
+ ...(input.cwd ? { cwd: resolve(input.cwd) } : {}),
242
+ ...(input.log ? { log: resolve(input.log) } : {}),
243
+ ...(owner ? { owner } : {}),
244
+ started_at: input.started_at ?? now,
245
+ registered_at: now,
246
+ last_active_at: now,
247
+ };
248
+ if (input.start_token !== undefined && verifyProcessGeneration(pid, input.start_token) !== "same")
249
+ throw new Error(`pid ${pid} generation could not be verified before registration`);
250
+ writePrivateJsonAtomic(recordPath(coordRoot, id), record);
251
+ return record;
271
252
  }
272
253
 
273
254
  /** Best-effort registration that never throws (for server start paths). */
@@ -293,132 +274,39 @@ function callingOwner(coordRoot: string): ServerRecord["owner"] {
293
274
  }
294
275
  }
295
276
 
296
- /** Remove only the exact registration receipt observed by the caller. */
297
- export function unregisterServer(record: ServerRecord, options?: ServerOptions): boolean {
298
- const coordRoot = rootOf(options);
299
- if (!coordRoot || !record || !isValidServerId(record.id)) return false;
300
- return withServerMutation(coordRoot, record.id, () => removeServerReceipt(coordRoot, record));
301
- }
302
-
303
- /** Hold the exact registration receipt until a synchronous or asynchronous effect settles. */
304
- export function withServerRegistrationCustody<T>(
305
- record: ServerRecord,
306
- options: ServerOptions,
307
- effect: () => PromiseLike<T>,
308
- ): Promise<T>;
309
- export function withServerRegistrationCustody<T>(
310
- record: ServerRecord,
311
- options: ServerOptions,
312
- effect: () => T,
313
- ): T;
314
- export function withServerRegistrationCustody<T>(
315
- record: ServerRecord,
316
- options: ServerOptions,
317
- effect: () => T | PromiseLike<T>,
318
- ): T | Promise<T> {
277
+ /**
278
+ * Remove a server's record. With `pid`, only a record still naming that pid
279
+ * is removed, so a replacement that re-registered the same id survives.
280
+ */
281
+ export function unregisterServer(id: string, options?: ServerOptions & { pid?: number }): boolean {
319
282
  const coordRoot = rootOf(options);
320
- if (!coordRoot) throw new Error("registration custody root is unavailable");
321
- const lease = serverMutationLease(coordRoot, record.id);
322
- try {
323
- const current = readServer(record.id, { coordRoot });
324
- if (
325
- !current ||
326
- current.registration_id !== record.registration_id ||
327
- !sameProcessCustody(current, record)
328
- )
329
- throw new Error(`registration ${record.id} custody changed`);
330
- const result = effect();
331
- if (
332
- result !== null &&
333
- (typeof result === "object" || typeof result === "function") &&
334
- typeof (result as PromiseLike<T>).then === "function"
335
- )
336
- return Promise.resolve(result).finally(() => lease.release());
337
- lease.release();
338
- return result as T;
339
- } catch (error) {
340
- lease.release();
341
- throw error;
283
+ if (!coordRoot || !isValidServerId(id)) return false;
284
+ const path = recordPath(coordRoot, id);
285
+ if (options?.pid !== undefined) {
286
+ const record = readRecord(path);
287
+ if (record && record.pid !== options.pid) return false;
342
288
  }
343
- }
344
-
345
- function removeServerReceipt(coordRoot: string, record: ServerRecord): boolean {
346
- const path = recordPath(coordRoot, record.id);
347
- const current = readRecord(path);
348
- if (
349
- !current ||
350
- current.registration_id !== record.registration_id ||
351
- !sameProcessCustody(current, record)
352
- )
353
- return false;
354
- rmSync(path);
289
+ if (!existsSync(path)) return false;
290
+ rmSync(path, { force: true });
355
291
  return true;
356
292
  }
357
293
 
358
- function serverMutationLease(coordRoot: string, id: string) {
359
- recordPath(coordRoot, id);
360
- const scope = `server-registration-${id}`;
361
- return acquireNoClobberLease({
362
- path: join(serversDir(coordRoot), ".custody", id),
363
- scope,
364
- authoritySha256: createHash("sha256").update(scope).digest("hex"),
365
- staleAfterMs: 1,
366
- });
367
- }
368
-
369
- function withServerMutation<T>(coordRoot: string, id: string, operation: () => T): T {
370
- const lease = serverMutationLease(coordRoot, id);
371
- try {
372
- return operation();
373
- } finally {
374
- lease.release();
375
- }
376
- }
377
-
378
- function sameProcessCustody(a: ServerRecord, b: ServerRecord): boolean {
379
- const helpers = (record: ServerRecord) =>
380
- [...new Set(record.pids ?? [])]
381
- .sort((x, y) => x - y)
382
- .map((pid) => [pid, record.pids_start_tokens?.[pid] ?? null]);
383
- return (
384
- a.schema_version === SERVER_RECORD_SCHEMA_VERSION &&
385
- b.schema_version === SERVER_RECORD_SCHEMA_VERSION &&
386
- a.id === b.id &&
387
- a.pid === b.pid &&
388
- a.start_token === b.start_token &&
389
- a.host === b.host &&
390
- a.kind === b.kind &&
391
- a.type === b.type &&
392
- a.scope === b.scope &&
393
- a.owner?.instance_id === b.owner?.instance_id &&
394
- JSON.stringify(helpers(a)) === JSON.stringify(helpers(b))
395
- );
396
- }
397
-
398
294
  export function readServer(id: string, options?: ServerOptions): ServerRecord | null {
399
295
  const coordRoot = rootOf(options);
400
296
  if (!coordRoot || !isValidServerId(id)) return null;
401
- const path = recordPath(coordRoot, id);
402
- const record = readRecord(path);
403
- if (!record && existsSync(path))
404
- throw new Error(
405
- `server record ${id} is invalid or uses an older schema; retained without custody`,
406
- );
407
- return record;
297
+ return readRecord(recordPath(coordRoot, id));
408
298
  }
409
299
 
410
300
  /** Mark a server as used now. */
411
301
  export function touchServer(id: string, options?: ServerOptions & { now?: Date }): void {
412
302
  const coordRoot = rootOf(options);
413
303
  if (!coordRoot || !isValidServerId(id)) return;
414
- withServerMutation(coordRoot, id, () => {
415
- const path = recordPath(coordRoot, id);
416
- const record = readRecord(path);
417
- if (!record) return;
418
- writePrivateJsonAtomic(path, {
419
- ...record,
420
- last_active_at: (options?.now ?? new Date()).toISOString(),
421
- });
304
+ const path = recordPath(coordRoot, id);
305
+ const record = readRecord(path);
306
+ if (!record) return;
307
+ writePrivateJsonAtomic(path, {
308
+ ...record,
309
+ last_active_at: (options?.now ?? new Date()).toISOString(),
422
310
  });
423
311
  }
424
312
 
@@ -447,8 +335,6 @@ function readRecord(path: string): ServerRecord | null {
447
335
  if (
448
336
  value?.schema_version !== SERVER_RECORD_SCHEMA_VERSION ||
449
337
  typeof value.id !== "string" ||
450
- typeof value.registration_id !== "string" ||
451
- !/^[0-9a-f-]{36}$/.test(value.registration_id) ||
452
338
  !Number.isSafeInteger(value.pid) ||
453
339
  (value.kind !== "service" && value.kind !== "session")
454
340
  )
@@ -466,14 +352,8 @@ export function readServerRecords(options?: ServerOptions): ServerRecord[] {
466
352
  if (!existsSync(dir)) return [];
467
353
  return readdirSync(dir)
468
354
  .filter((name) => name.endsWith(".json"))
469
- .map((name) => {
470
- const record = readRecord(join(dir, name));
471
- if (!record)
472
- throw new Error(
473
- `server record ${name} is invalid or uses an older schema; retained without custody`,
474
- );
475
- return record;
476
- })
355
+ .map((name) => readRecord(join(dir, name)))
356
+ .filter((record): record is ServerRecord => record !== null)
477
357
  .sort(
478
358
  (a, b) =>
479
359
  a.kind.localeCompare(b.kind) || a.type.localeCompare(b.type) || a.id.localeCompare(b.id),
@@ -567,7 +447,7 @@ export function listServers(options?: ListServersOptions): ServersReport {
567
447
  records.forEach((record, i) => {
568
448
  const state = states[i]!;
569
449
  if (state === "dead" && allProcessesAbsent(record) && options?.prune !== false) {
570
- unregisterServer(record, { coordRoot });
450
+ unregisterServer(record.id, { coordRoot, pid: record.pid });
571
451
  pruned.push(record.id);
572
452
  return;
573
453
  }
@@ -718,12 +598,32 @@ export interface StopResult {
718
598
 
719
599
  export interface ServerProcessDependencies extends ProcessGenerationSignalDependencies {
720
600
  probePid?: (pid: number) => boolean;
601
+ /** Numeric signal, used for the process group and in tests. */
602
+ kill?: (pid: number, signal: NodeJS.Signals) => void;
603
+ }
604
+
605
+ /** Recheck the generation, then signal. A supplied `kill` replaces the handle path. */
606
+ function signalVerified(
607
+ pid: number,
608
+ token: string | undefined,
609
+ name: NodeJS.Signals,
610
+ dependencies: ServerProcessDependencies,
611
+ ): { sent: boolean; verdict: ProcessGenerationVerdict } {
612
+ if (!dependencies.kill) return signalIfSameGeneration(pid, token, name, dependencies);
613
+ const verdict = verifyProcessGeneration(pid, token, dependencies.readToken);
614
+ if (verdict !== "same") return { sent: false, verdict };
615
+ try {
616
+ dependencies.kill(pid, name);
617
+ return { sent: true, verdict };
618
+ } catch {
619
+ return { sent: false, verdict };
620
+ }
721
621
  }
722
622
 
723
623
  /**
724
624
  * Stop a verified server with SIGTERM, then SIGKILL after the grace period.
725
- * Every signal targets one retained process handle, including auxiliary processes.
726
- * Each target requires a fresh generation check. Unknown or different generations retain the record.
625
+ * Each signal, including the process group and auxiliary processes, requires
626
+ * a fresh generation check. Unknown or different generations retain the record.
727
627
  */
728
628
  export async function stopServer(
729
629
  record: ServerRecord,
@@ -733,47 +633,6 @@ export async function stopServer(
733
633
  },
734
634
  ): Promise<StopResult> {
735
635
  const coordRoot = rootOf(options);
736
- if (!coordRoot)
737
- return {
738
- id: record.id,
739
- stopped: false,
740
- method: "refused",
741
- generation: "unknown",
742
- detail: "registration custody root is unavailable",
743
- };
744
- let lease: ReturnType<typeof serverMutationLease>;
745
- try {
746
- lease = serverMutationLease(coordRoot, record.id);
747
- } catch {
748
- return {
749
- id: record.id,
750
- stopped: false,
751
- method: "refused",
752
- generation: "unknown",
753
- detail: "registration custody is unavailable",
754
- };
755
- }
756
- try {
757
- const current = readServer(record.id, { coordRoot });
758
- if (!current || current.registration_id !== record.registration_id)
759
- return {
760
- id: record.id,
761
- stopped: false,
762
- method: "refused",
763
- generation: "unknown",
764
- detail: "registration custody changed; retained current server",
765
- };
766
- return await stopServerWithCustody(record, coordRoot, options);
767
- } finally {
768
- lease.release();
769
- }
770
- }
771
-
772
- async function stopServerWithCustody(
773
- record: ServerRecord,
774
- coordRoot: string,
775
- options?: ServerOptions & { graceMs?: number; signalDependencies?: ServerProcessDependencies },
776
- ): Promise<StopResult> {
777
636
  const graceMs = options?.graceMs ?? 5_000;
778
637
  const dependencies = options?.signalDependencies ?? {};
779
638
  const targets = [
@@ -790,7 +649,8 @@ async function stopServerWithCustody(
790
649
  detail: `pid ${pid} generation is ${generation}; left the server record in place`,
791
650
  });
792
651
  const finish = (result: StopResult): StopResult => {
793
- if (result.stopped || result.method === "not-running") removeServerReceipt(coordRoot, record);
652
+ if (result.stopped || result.method === "not-running")
653
+ unregisterServer(record.id, { coordRoot, pid: record.pid });
794
654
  return result;
795
655
  };
796
656
  if (record.host !== hostname())
@@ -812,16 +672,16 @@ async function stopServerWithCustody(
812
672
  if (targets.every((target) => processPresence(target.pid, dependencies.probePid) === "absent"))
813
673
  return finish({ id: record.id, stopped: false, method: "not-running" });
814
674
 
815
- const current = readServer(record.id, { coordRoot });
816
- if (!current || !sameProcessCustody(current, record)) return refuse(record.pid, "unknown");
817
675
  for (const name of ["SIGTERM", "SIGKILL"] as const) {
818
676
  for (const target of targets) {
819
677
  const presence = processPresence(target.pid, dependencies.probePid);
820
678
  if (presence === "absent") continue;
821
679
  if (presence === "unknown") return refuse(target.pid, "unknown");
822
- const result = signalIfSameGeneration(target.pid, target.token, name, dependencies);
823
- if (!result.sent || result.verdict !== "same")
824
- return refuse(target.pid, result.verdict === "same" ? "unknown" : result.verdict);
680
+ const result =
681
+ target.pid === record.pid
682
+ ? signalServerLeader(target.pid, target.token, name, dependencies)
683
+ : signalVerified(target.pid, target.token, name, dependencies);
684
+ if (result.verdict !== "same") return refuse(target.pid, result.verdict);
825
685
  }
826
686
  const observed = await waitForExit(targets, name === "SIGTERM" ? graceMs : 2_000, dependencies);
827
687
  if (observed.refused) return refuse(observed.refused.pid, observed.refused.verdict);
@@ -837,6 +697,24 @@ async function stopServerWithCustody(
837
697
  };
838
698
  }
839
699
 
700
+ function signalServerLeader(
701
+ pid: number,
702
+ token: string | undefined,
703
+ name: NodeJS.Signals,
704
+ dependencies: ServerProcessDependencies,
705
+ ): { sent: boolean; verdict: ProcessGenerationVerdict } {
706
+ // The leader's generation authorizes its group. A failed group signal must
707
+ // not reuse that observation for the direct-PID fallback.
708
+ const kill = dependencies.kill ?? process.kill;
709
+ const group = signalVerified(pid, token, name, {
710
+ readToken: dependencies.readToken,
711
+ kill: (leader, signal) => kill(-leader, signal),
712
+ });
713
+ return group.sent || group.verdict !== "same"
714
+ ? group
715
+ : signalVerified(pid, token, name, dependencies);
716
+ }
717
+
840
718
  async function waitForExit(
841
719
  targets: Array<{ pid: number; token: string | undefined }>,
842
720
  waitMs: number,
@@ -2,59 +2,12 @@
2
2
  * Tunnels as registry services.
3
3
  *
4
4
  * `tunnel up` and `tunnel reload` register the new gate; `tunnel down` removes
5
- * its record. Missing original witnesses remain unknown and cannot authorize actions.
5
+ * its record. Older tunnels need an explicit adoption of an observed listener.
6
6
  */
7
7
 
8
- import { hostname } from "node:os";
9
8
  import { resolve } from "node:path";
10
- import {
11
- gateLogFile,
12
- type TunnelReadinessVerifier,
13
- type TunnelState,
14
- } from "../../lib/tunnel/state.ts";
15
- import {
16
- type RegisterServerInput,
17
- readServer,
18
- type ServerOptions,
19
- verifyProcessGeneration,
20
- withServerRegistrationCustody,
21
- } from "./index.ts";
22
-
23
- /** Verify only witnesses saved by the original tunnel registration. */
24
- export function createTunnelReadinessVerifier(options: ServerOptions): TunnelReadinessVerifier {
25
- return {
26
- observeProcess(pid, originalToken) {
27
- try {
28
- process.kill(pid, 0);
29
- } catch (error) {
30
- const code = (error as NodeJS.ErrnoException).code;
31
- if (code === "ESRCH") return "absent";
32
- return "unknown";
33
- }
34
- return verifyProcessGeneration(pid, originalToken);
35
- },
36
- withRegistration(state, inspect) {
37
- try {
38
- const original = readServer(tunnelServerId(state.name), options);
39
- if (
40
- !original ||
41
- original.host !== hostname() ||
42
- original.kind !== "service" ||
43
- original.type !== "tunnel" ||
44
- original.registration_id !== state.registration_id ||
45
- original.pid !== state.gate_pid ||
46
- original.start_token !== state.gate_start_token ||
47
- original.port !== state.gate_port ||
48
- original.url !== state.url
49
- )
50
- return "unknown";
51
- return withServerRegistrationCustody(original, options, inspect);
52
- } catch {
53
- return "unknown";
54
- }
55
- },
56
- };
57
- }
9
+ import { gateLogFile, type TunnelState } from "../../lib/tunnel/state.ts";
10
+ import type { RegisterServerInput } from "./index.ts";
58
11
 
59
12
  export function tunnelServerId(name: string): string {
60
13
  return `tunnel-${name.toLowerCase().replace(/[^a-z0-9._-]+/g, "-")}`;
@@ -62,10 +15,6 @@ export function tunnelServerId(name: string): string {
62
15
 
63
16
  /** Register the new gate; saved helper PIDs have no generation witnesses. */
64
17
  export function tunnelServerInput(state: TunnelState, coordRoot: string): RegisterServerInput {
65
- if (Object.hasOwn(state, "provider_pid") || Object.hasOwn(state, "provider_start_token"))
66
- throw new Error("obsolete tunnel provider authority is unknown; state retained");
67
- if (!state.gate_start_token)
68
- throw new Error("tunnel gate generation witness is unavailable; state retained");
69
18
  return {
70
19
  id: tunnelServerId(state.name),
71
20
  kind: "service",
@@ -74,7 +23,6 @@ export function tunnelServerInput(state: TunnelState, coordRoot: string): Regist
74
23
  url: state.url,
75
24
  port: state.gate_port,
76
25
  pid: state.gate_pid,
77
- start_token: state.gate_start_token,
78
26
  log: resolve(coordRoot, ".cache", "tunnel", gateLogFile(state.name)),
79
27
  cwd: coordRoot,
80
28
  started_at: state.started_at,
@@ -521,28 +521,49 @@ function acquireLease(lease: string): LeaseOwner {
521
521
  pid: process.pid,
522
522
  acquired_at: new Date().toISOString(),
523
523
  };
524
- let claimed: DirectoryIdentity | undefined;
524
+ if (claimLease(lease, parent, owner)) return owner;
525
+ if (recoverStaleLease(lease, DURABLE_HISTORY_LEASE_STALE_MS)) continue;
526
+ throw new Error(`durable history lease busy: ${lease}`);
527
+ }
528
+ throw new Error(`durable history lease busy: ${lease}`);
529
+ }
530
+
531
+ /**
532
+ * Publish a complete lease in one rename. The owner record is written and synced
533
+ * in a private claim directory first, so a writer stopped at any point leaves
534
+ * either no lease or one with an owner. A held lease always has an owner record,
535
+ * here and in the earlier create-then-write protocol, so a rename that replaces
536
+ * an empty lease directory never takes a held lease. A lease with any entry makes
537
+ * the rename fail and the caller reports it busy.
538
+ */
539
+ function claimLease(lease: string, parent: DirectoryIdentity, owner: LeaseOwner): boolean {
540
+ const claim = `${lease}.claim-${process.pid}-${owner.owner_id}`;
541
+ try {
542
+ mkdirSync(claim, { mode: stateDirMode() });
543
+ assertDirectoryWithinParent(claim, parent);
544
+ const claimed = captureDirectoryIdentity(claim);
545
+ writeOwner(join(claim, "owner.json"), owner);
546
+ syncDirectory(claimed);
547
+ renameSync(claim, lease);
548
+ } catch {
549
+ return false;
550
+ } finally {
525
551
  try {
526
- mkdirSync(lease, { mode: stateDirMode() });
527
- assertDirectoryWithinParent(lease, parent);
528
- claimed = captureDirectoryIdentity(lease);
529
- writeOwner(join(lease, "owner.json"), owner);
530
- syncDirectory(claimed);
531
- return owner;
552
+ rmSync(claim, { recursive: true, force: true });
532
553
  } catch {
533
- if (claimed) {
534
- try {
535
- assertDirectoryIdentity(claimed);
536
- rmSync(lease, { recursive: true, force: false });
537
- } catch {
538
- // Never remove a lease after its directory identity changes.
539
- }
540
- }
541
- if (recoverStaleLease(lease, DURABLE_HISTORY_LEASE_STALE_MS)) continue;
542
- throw new Error(`durable history lease busy: ${lease}`);
554
+ // A leftover claim directory never blocks a lease.
543
555
  }
544
556
  }
545
- throw new Error(`durable history lease busy: ${lease}`);
557
+ try {
558
+ assertDirectoryWithinParent(lease, parent);
559
+ const current = JSON.parse(completeJsonl(join(lease, "owner.json"))) as LeaseOwner;
560
+ if (current.owner_id !== owner.owner_id) return false;
561
+ syncDirectory(parent);
562
+ return true;
563
+ } catch {
564
+ releaseOwnedLease(lease, owner);
565
+ return false;
566
+ }
546
567
  }
547
568
 
548
569
  function writeOwner(path: string, owner: LeaseOwner): void {
@@ -595,7 +616,7 @@ function recoverStaleLease(lease: string, staleMs: number): boolean {
595
616
  try {
596
617
  assertDirectoryIdentity(leaseIdentity);
597
618
  if (completeJsonl(ownerPath) !== raw) return false;
598
- rmSync(lease, { recursive: true, force: false });
619
+ retireLease(lease, randomUUID());
599
620
  return true;
600
621
  } catch {
601
622
  return false;
@@ -606,12 +627,23 @@ function releaseOwnedLease(lease: string, owner: LeaseOwner): void {
606
627
  try {
607
628
  const current = JSON.parse(completeJsonl(join(lease, "owner.json"))) as LeaseOwner;
608
629
  if (current.owner_id !== owner.owner_id) return;
609
- rmSync(lease, { recursive: true, force: false });
630
+ retireLease(lease, owner.owner_id);
610
631
  } catch {
611
632
  // A missing or replaced lease does not belong to this operation.
612
633
  }
613
634
  }
614
635
 
636
+ /**
637
+ * Move a lease aside before deleting it. Its path goes from held to absent in
638
+ * one rename and is never an empty directory that a concurrent claim could
639
+ * replace while a recursive removal is still running.
640
+ */
641
+ function retireLease(lease: string, tag: string): void {
642
+ const retired = `${lease}.retired-${process.pid}-${tag}`;
643
+ renameSync(lease, retired);
644
+ rmSync(retired, { recursive: true, force: true });
645
+ }
646
+
615
647
  function processIsAlive(pid: number): boolean {
616
648
  try {
617
649
  process.kill(pid, 0);