@serfab/cadre-host 0.12.0 → 1.0.0

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 (70) hide show
  1. package/README.md +6 -4
  2. package/dist/bin/host.js +9 -1
  3. package/dist/bin/host.js.map +1 -1
  4. package/dist/donation/__tests__/fake-orchestrator.js +1 -1
  5. package/dist/donation/__tests__/fake-orchestrator.js.map +1 -1
  6. package/dist/donation/donation-service.d.ts +7 -2
  7. package/dist/donation/donation-service.js +16 -2
  8. package/dist/donation/donation-service.js.map +1 -1
  9. package/dist/donation/donation-supervisor.d.ts +12 -2
  10. package/dist/donation/donation-supervisor.js +25 -2
  11. package/dist/donation/donation-supervisor.js.map +1 -1
  12. package/dist/donation/types.d.ts +2 -1
  13. package/dist/donation/types.js.map +1 -1
  14. package/dist/installer/index.d.ts +9 -1
  15. package/dist/installer/index.js +5 -1
  16. package/dist/installer/index.js.map +1 -1
  17. package/dist/orchestrator/host-process-orchestrator.d.ts +71 -11
  18. package/dist/orchestrator/host-process-orchestrator.js +173 -75
  19. package/dist/orchestrator/host-process-orchestrator.js.map +1 -1
  20. package/dist/orchestrator/port-allocator.d.ts +44 -2
  21. package/dist/orchestrator/port-allocator.js +63 -5
  22. package/dist/orchestrator/port-allocator.js.map +1 -1
  23. package/dist/orchestrator/port-probe.d.ts +15 -0
  24. package/dist/orchestrator/port-probe.js +22 -0
  25. package/dist/orchestrator/port-probe.js.map +1 -0
  26. package/dist/orchestrator/types.d.ts +13 -1
  27. package/dist/orchestrator/types.js.map +1 -1
  28. package/dist/server/routes/bootstrap-node-validation.d.ts +115 -0
  29. package/dist/server/routes/bootstrap-node-validation.js +197 -0
  30. package/dist/server/routes/bootstrap-node-validation.js.map +1 -0
  31. package/dist/server/routes/grants.js +8 -25
  32. package/dist/server/routes/grants.js.map +1 -1
  33. package/dist/server/routes/provision-request-validation.d.ts +45 -0
  34. package/dist/server/routes/provision-request-validation.js +97 -0
  35. package/dist/server/routes/provision-request-validation.js.map +1 -0
  36. package/dist/server/server.d.ts +4 -1
  37. package/dist/server/server.js +36 -2
  38. package/dist/server/server.js.map +1 -1
  39. package/dist/ui/assets/{index-C-iks41M.js → index-CEoNRwM_.js} +3 -3
  40. package/dist/ui/assets/{index-C-iks41M.js.map → index-CEoNRwM_.js.map} +1 -1
  41. package/dist/ui/index.html +1 -1
  42. package/package.json +5 -4
  43. package/src/__tests__/orchestrator-env-scrub.test.ts +22 -0
  44. package/src/__tests__/orchestrator-node-identity.test.ts +4 -4
  45. package/src/__tests__/orchestrator-owner.test.ts +45 -2
  46. package/src/__tests__/orchestrator-ports.test.ts +172 -0
  47. package/src/__tests__/orchestrator.test.ts +1062 -951
  48. package/src/bin/host.ts +9 -1
  49. package/src/donation/__tests__/donation-service.test.ts +94 -7
  50. package/src/donation/__tests__/donation-supervisor.test.ts +50 -12
  51. package/src/donation/__tests__/fake-orchestrator.test.ts +168 -168
  52. package/src/donation/__tests__/fake-orchestrator.ts +1 -1
  53. package/src/donation/donation-service.ts +22 -3
  54. package/src/donation/donation-supervisor.ts +25 -2
  55. package/src/donation/types.ts +2 -1
  56. package/src/installer/__tests__/installer.smoke.test.ts +263 -218
  57. package/src/installer/index.ts +327 -314
  58. package/src/orchestrator/host-process-orchestrator.ts +1250 -1138
  59. package/src/orchestrator/port-allocator.ts +148 -91
  60. package/src/orchestrator/port-probe.ts +31 -0
  61. package/src/orchestrator/types.ts +13 -1
  62. package/src/server/__tests__/bootstrap-node-validation.test.ts +138 -0
  63. package/src/server/__tests__/grants-route.test.ts +394 -263
  64. package/src/server/__tests__/nodes-route.test.ts +2 -2
  65. package/src/server/__tests__/publishers.test.ts +1 -1
  66. package/src/server/__tests__/status-route.test.ts +1 -1
  67. package/src/server/routes/bootstrap-node-validation.ts +206 -0
  68. package/src/server/routes/grants.ts +9 -26
  69. package/src/server/routes/provision-request-validation.ts +105 -0
  70. package/src/server/server.ts +157 -122
@@ -1,168 +1,168 @@
1
- /**
2
- * Contract tests for `FakeOrchestrator` itself — the handle lifecycle it borrows
3
- * from `HostProcessOrchestrator`. The donation suites lean on these semantics to
4
- * express races that only exist because the real class drops and rejects
5
- * handles, so this file exists to stop a later relaxation of the fake from
6
- * quietly turning one of those tests green. Each case names the real behaviour
7
- * it mirrors.
8
- */
9
-
10
- import { describe, expect, it } from 'vitest';
11
-
12
- import type { OrchestratorCreateRequest } from '@serfab/cadre-provider';
13
-
14
- import { FakeOrchestrator } from './fake-orchestrator.js';
15
-
16
- const request = (containerId: string): OrchestratorCreateRequest => ({
17
- containerId,
18
- partyId: 'party-P',
19
- bootstrapNodes: ['/ip4/127.0.0.1/tcp/4001/p2p/12D3KooReq'],
20
- profile: 'storage',
21
- });
22
-
23
- describe('FakeOrchestrator handle lifecycle', () => {
24
- it('drops the prior handle when a re-spawn succeeds (mirrors dropStaleHandle)', async () => {
25
- const orch = new FakeOrchestrator();
26
- const first = await orch.createContainer(request('grn_1'));
27
- const second = await orch.createContainer(request('grn_1'));
28
-
29
- await expect(orch.stopContainer(first.dockerId)).rejects.toThrow(
30
- `Container not found: ${first.dockerId}`,
31
- );
32
- // The rejected call left no trace: `stopped` records handles acted on, not
33
- // calls attempted.
34
- expect(orch.stopped).toEqual([]);
35
- // …and the container now resolves only to the new spawn.
36
- expect(orch.resolveDockerId('grn_1')).toBe(second.dockerId);
37
-
38
- await orch.stopContainer(second.dockerId);
39
- expect(orch.stopped).toEqual([second.dockerId]);
40
- });
41
-
42
- it('drops only the re-spawned container, leaving another container untouched', async () => {
43
- const orch = new FakeOrchestrator();
44
- const other = await orch.createContainer(request('grn_2'));
45
- await orch.createContainer(request('grn_1'));
46
- await orch.createContainer(request('grn_1'));
47
-
48
- // The real drop filters on containerId; a fake that cleared the whole map
49
- // would still pass every same-container case above.
50
- await expect(orch.stopContainer(other.dockerId)).resolves.toBeUndefined();
51
- expect(orch.resolveDockerId('grn_2')).toBe(other.dockerId);
52
- });
53
-
54
- it('keeps the prior handle live across the whole pre-drop await window', async () => {
55
- const orch = new FakeOrchestrator();
56
- const first = await orch.createContainer(request('grn_1'));
57
-
58
- // The invariant the donation race tests rest on: a concurrent stop started
59
- // from `onCreate` still finds the old handle. Deferring it onto a microtask
60
- // puts it strictly after `onCreate` returns and strictly before the
61
- // `createDelayMs` timer, so this one case pins the drop behind BOTH — with
62
- // no dependence on wall-clock timing. The outcome is captured rather than
63
- // rethrown so a regression fails this assertion instead of surfacing as an
64
- // unhandled rejection.
65
- orch.createDelayMs = 1;
66
- let outcome: Promise<string> | undefined;
67
- orch.onCreate = () => {
68
- outcome ??= Promise.resolve()
69
- .then(() => orch.stopContainer(first.dockerId))
70
- .then(() => 'stopped', (err: Error) => `rejected: ${err.message}`);
71
- };
72
-
73
- const second = await orch.createContainer(request('grn_1'));
74
-
75
- expect(await outcome).toBe('stopped');
76
- expect(orch.stopped).toEqual([first.dockerId]);
77
- expect(orch.resolveDockerId('grn_1')).toBe(second.dockerId);
78
- });
79
-
80
- it('leaves the prior handle in place when the re-spawn fails (mirrors restoreDroppedHandles)', async () => {
81
- const orch = new FakeOrchestrator();
82
- const first = await orch.createContainer(request('grn_1'));
83
-
84
- orch.failCreate = true;
85
- await expect(orch.createContainer(request('grn_1'))).rejects.toThrow('spawn boom');
86
-
87
- // Host state is exactly as the failed attempt found it — which is what lets
88
- // `DonationSupervisor.giveUp` still stop the child it knows about.
89
- await expect(orch.stopContainer(first.dockerId)).resolves.toBeUndefined();
90
- expect(orch.stopped).toEqual([first.dockerId]);
91
- expect(orch.resolveDockerId('grn_1')).toBe(first.dockerId);
92
- });
93
-
94
- it('rejects a never-issued dockerId (mirrors requireHandle)', async () => {
95
- const orch = new FakeOrchestrator();
96
-
97
- await expect(orch.removeContainer('dock_nope')).rejects.toThrow(
98
- 'Container not found: dock_nope',
99
- );
100
- expect(orch.removed).toEqual([]);
101
- });
102
-
103
- it('rejects a second call once a handle has been removed (mirrors removeContainer deleting it)', async () => {
104
- const orch = new FakeOrchestrator();
105
- const { dockerId } = await orch.createContainer(request('grn_1'));
106
-
107
- await orch.removeContainer(dockerId);
108
- expect(orch.removed).toEqual([dockerId]);
109
-
110
- await expect(orch.stopContainer(dockerId)).rejects.toThrow(
111
- `Container not found: ${dockerId}`,
112
- );
113
- expect(orch.stopped).toEqual([]);
114
- });
115
-
116
- it('fires onStop for a rejected stop, so a test can still observe the attempt', async () => {
117
- const orch = new FakeOrchestrator();
118
- const attempted: string[] = [];
119
- orch.onStop = (id) => { attempted.push(id); };
120
-
121
- await expect(orch.stopContainer('dock_nope')).rejects.toThrow('Container not found');
122
- expect(attempted).toEqual(['dock_nope']);
123
- expect(orch.stopped).toEqual([]);
124
- });
125
-
126
- it('refuses to reclaim the workdir of a container that still resolves (mirrors the live-handle guard)', async () => {
127
- const orch = new FakeOrchestrator();
128
- await orch.createContainer(request('grn_1'));
129
-
130
- // A live handle owns that directory; only `removeContainer` — which stops
131
- // the child first — may delete it.
132
- expect(orch.reclaimWorkdir('grn_1')).toBe(false);
133
- expect(orch.reclaimedWorkdirs).toEqual([]);
134
- });
135
-
136
- it('reclaims the workdir of a container no handle owns', async () => {
137
- const orch = new FakeOrchestrator();
138
-
139
- // Never spawned — the stuck-`provisioning` record whose host died before a
140
- // handle ever existed.
141
- expect(orch.reclaimWorkdir('grn_orphan')).toBe(true);
142
- expect(orch.reclaimedWorkdirs).toEqual(['grn_orphan']);
143
-
144
- // …and once the container's own handle is removed, its id becomes
145
- // reclaimable too.
146
- const { dockerId } = await orch.createContainer(request('grn_1'));
147
- expect(orch.reclaimWorkdir('grn_1')).toBe(false);
148
- await orch.removeContainer(dockerId);
149
- expect(orch.reclaimWorkdir('grn_1')).toBe(true);
150
- expect(orch.reclaimedWorkdirs).toEqual(['grn_orphan', 'grn_1']);
151
- });
152
-
153
- it('fires onSpawned after the drop, with the new handle live and the old one gone', async () => {
154
- const orch = new FakeOrchestrator();
155
- const first = await orch.createContainer(request('grn_1'));
156
-
157
- let observed: { spawned: string; resolves: string | undefined } | undefined;
158
- orch.onSpawned = (dockerId) => {
159
- observed = { spawned: dockerId, resolves: orch.resolveDockerId('grn_1') };
160
- };
161
- const second = await orch.createContainer(request('grn_1'));
162
-
163
- // The post-drop world: the container already resolves to the new handle, so
164
- // work driven from here sees what `abandonRespawn`'s caller would see.
165
- expect(observed).toEqual({ spawned: second.dockerId, resolves: second.dockerId });
166
- expect(second.dockerId).not.toBe(first.dockerId);
167
- });
168
- });
1
+ /**
2
+ * Contract tests for `FakeOrchestrator` itself — the handle lifecycle it borrows
3
+ * from `HostProcessOrchestrator`. The donation suites lean on these semantics to
4
+ * express races that only exist because the real class drops and rejects
5
+ * handles, so this file exists to stop a later relaxation of the fake from
6
+ * quietly turning one of those tests green. Each case names the real behaviour
7
+ * it mirrors.
8
+ */
9
+
10
+ import { describe, expect, it } from 'vitest';
11
+
12
+ import type { OrchestratorCreateRequest } from '@serfab/cadre-provider';
13
+
14
+ import { FakeOrchestrator } from './fake-orchestrator.js';
15
+
16
+ const request = (containerId: string): OrchestratorCreateRequest => ({
17
+ containerId,
18
+ partyId: 'party-P',
19
+ bootstrapNodes: ['/ip4/127.0.0.1/tcp/4001/p2p/12D3KooWA9hbnKrRnPRSPTRkzXqTHzGE8YpJ3JHZmQ5tGwLRTMmp'],
20
+ profile: 'storage',
21
+ });
22
+
23
+ describe('FakeOrchestrator handle lifecycle', () => {
24
+ it('drops the prior handle when a re-spawn succeeds (mirrors dropStaleHandle)', async () => {
25
+ const orch = new FakeOrchestrator();
26
+ const first = await orch.createContainer(request('grn_1'));
27
+ const second = await orch.createContainer(request('grn_1'));
28
+
29
+ await expect(orch.stopContainer(first.dockerId)).rejects.toThrow(
30
+ `Container not found: ${first.dockerId}`,
31
+ );
32
+ // The rejected call left no trace: `stopped` records handles acted on, not
33
+ // calls attempted.
34
+ expect(orch.stopped).toEqual([]);
35
+ // …and the container now resolves only to the new spawn.
36
+ expect(orch.resolveDockerId('grn_1')).toBe(second.dockerId);
37
+
38
+ await orch.stopContainer(second.dockerId);
39
+ expect(orch.stopped).toEqual([second.dockerId]);
40
+ });
41
+
42
+ it('drops only the re-spawned container, leaving another container untouched', async () => {
43
+ const orch = new FakeOrchestrator();
44
+ const other = await orch.createContainer(request('grn_2'));
45
+ await orch.createContainer(request('grn_1'));
46
+ await orch.createContainer(request('grn_1'));
47
+
48
+ // The real drop filters on containerId; a fake that cleared the whole map
49
+ // would still pass every same-container case above.
50
+ await expect(orch.stopContainer(other.dockerId)).resolves.toBeUndefined();
51
+ expect(orch.resolveDockerId('grn_2')).toBe(other.dockerId);
52
+ });
53
+
54
+ it('keeps the prior handle live across the whole pre-drop await window', async () => {
55
+ const orch = new FakeOrchestrator();
56
+ const first = await orch.createContainer(request('grn_1'));
57
+
58
+ // The invariant the donation race tests rest on: a concurrent stop started
59
+ // from `onCreate` still finds the old handle. Deferring it onto a microtask
60
+ // puts it strictly after `onCreate` returns and strictly before the
61
+ // `createDelayMs` timer, so this one case pins the drop behind BOTH — with
62
+ // no dependence on wall-clock timing. The outcome is captured rather than
63
+ // rethrown so a regression fails this assertion instead of surfacing as an
64
+ // unhandled rejection.
65
+ orch.createDelayMs = 1;
66
+ let outcome: Promise<string> | undefined;
67
+ orch.onCreate = () => {
68
+ outcome ??= Promise.resolve()
69
+ .then(() => orch.stopContainer(first.dockerId))
70
+ .then(() => 'stopped', (err: Error) => `rejected: ${err.message}`);
71
+ };
72
+
73
+ const second = await orch.createContainer(request('grn_1'));
74
+
75
+ expect(await outcome).toBe('stopped');
76
+ expect(orch.stopped).toEqual([first.dockerId]);
77
+ expect(orch.resolveDockerId('grn_1')).toBe(second.dockerId);
78
+ });
79
+
80
+ it('leaves the prior handle in place when the re-spawn fails (mirrors restoreDroppedHandles)', async () => {
81
+ const orch = new FakeOrchestrator();
82
+ const first = await orch.createContainer(request('grn_1'));
83
+
84
+ orch.failCreate = true;
85
+ await expect(orch.createContainer(request('grn_1'))).rejects.toThrow('spawn boom');
86
+
87
+ // Host state is exactly as the failed attempt found it — which is what lets
88
+ // `DonationSupervisor.giveUp` still stop the child it knows about.
89
+ await expect(orch.stopContainer(first.dockerId)).resolves.toBeUndefined();
90
+ expect(orch.stopped).toEqual([first.dockerId]);
91
+ expect(orch.resolveDockerId('grn_1')).toBe(first.dockerId);
92
+ });
93
+
94
+ it('rejects a never-issued dockerId (mirrors requireHandle)', async () => {
95
+ const orch = new FakeOrchestrator();
96
+
97
+ await expect(orch.removeContainer('dock_nope')).rejects.toThrow(
98
+ 'Container not found: dock_nope',
99
+ );
100
+ expect(orch.removed).toEqual([]);
101
+ });
102
+
103
+ it('rejects a second call once a handle has been removed (mirrors removeContainer deleting it)', async () => {
104
+ const orch = new FakeOrchestrator();
105
+ const { dockerId } = await orch.createContainer(request('grn_1'));
106
+
107
+ await orch.removeContainer(dockerId);
108
+ expect(orch.removed).toEqual([dockerId]);
109
+
110
+ await expect(orch.stopContainer(dockerId)).rejects.toThrow(
111
+ `Container not found: ${dockerId}`,
112
+ );
113
+ expect(orch.stopped).toEqual([]);
114
+ });
115
+
116
+ it('fires onStop for a rejected stop, so a test can still observe the attempt', async () => {
117
+ const orch = new FakeOrchestrator();
118
+ const attempted: string[] = [];
119
+ orch.onStop = (id) => { attempted.push(id); };
120
+
121
+ await expect(orch.stopContainer('dock_nope')).rejects.toThrow('Container not found');
122
+ expect(attempted).toEqual(['dock_nope']);
123
+ expect(orch.stopped).toEqual([]);
124
+ });
125
+
126
+ it('refuses to reclaim the workdir of a container that still resolves (mirrors the live-handle guard)', async () => {
127
+ const orch = new FakeOrchestrator();
128
+ await orch.createContainer(request('grn_1'));
129
+
130
+ // A live handle owns that directory; only `removeContainer` — which stops
131
+ // the child first — may delete it.
132
+ expect(orch.reclaimWorkdir('grn_1')).toBe(false);
133
+ expect(orch.reclaimedWorkdirs).toEqual([]);
134
+ });
135
+
136
+ it('reclaims the workdir of a container no handle owns', async () => {
137
+ const orch = new FakeOrchestrator();
138
+
139
+ // Never spawned — the stuck-`provisioning` record whose host died before a
140
+ // handle ever existed.
141
+ expect(orch.reclaimWorkdir('grn_orphan')).toBe(true);
142
+ expect(orch.reclaimedWorkdirs).toEqual(['grn_orphan']);
143
+
144
+ // …and once the container's own handle is removed, its id becomes
145
+ // reclaimable too.
146
+ const { dockerId } = await orch.createContainer(request('grn_1'));
147
+ expect(orch.reclaimWorkdir('grn_1')).toBe(false);
148
+ await orch.removeContainer(dockerId);
149
+ expect(orch.reclaimWorkdir('grn_1')).toBe(true);
150
+ expect(orch.reclaimedWorkdirs).toEqual(['grn_orphan', 'grn_1']);
151
+ });
152
+
153
+ it('fires onSpawned after the drop, with the new handle live and the old one gone', async () => {
154
+ const orch = new FakeOrchestrator();
155
+ const first = await orch.createContainer(request('grn_1'));
156
+
157
+ let observed: { spawned: string; resolves: string | undefined } | undefined;
158
+ orch.onSpawned = (dockerId) => {
159
+ observed = { spawned: dockerId, resolves: orch.resolveDockerId('grn_1') };
160
+ };
161
+ const second = await orch.createContainer(request('grn_1'));
162
+
163
+ // The post-drop world: the container already resolves to the new handle, so
164
+ // work driven from here sees what `abandonRespawn`'s caller would see.
165
+ expect(observed).toEqual({ spawned: second.dockerId, resolves: second.dockerId });
166
+ expect(second.dockerId).not.toBe(first.dockerId);
167
+ });
168
+ });
@@ -220,6 +220,6 @@ function toNodeInfo(dockerId: string, child: FakeChild): ManagedNodeInfo {
220
220
  status: child.running ? 'running' : 'stopped',
221
221
  spawnedAt: new Date(0).toISOString(),
222
222
  workdir: `/fake/${child.containerId}`,
223
- ports: { health: 0, metrics: 0, p2p: 0, admin: 0 },
223
+ ports: { health: 0, metrics: 0, p2p: 0, admin: 0, ws: 0 },
224
224
  };
225
225
  }
@@ -78,7 +78,12 @@ export interface DonationProvisionRequest {
78
78
  grantToken: string;
79
79
  /** The REQUESTER's cadre — the party the donated node joins. */
80
80
  partyId: string;
81
- /** Requester control-network bootstrap multiaddrs (dialable, with `/p2p/`). */
81
+ /**
82
+ * Requester control-network bootstrap multiaddrs (dialable, with `/p2p/`). Empty
83
+ * means the requester dials the node itself: a phone has no address to give, so it
84
+ * reaches the node at the WebSocket address {@link DonationService.getPeer} reports
85
+ * rather than being dialed by it.
86
+ */
82
87
  bootstrapNodes: string[];
83
88
  /**
84
89
  * The requester's owner public key(s), base64url. Pinned as cold-start
@@ -450,6 +455,18 @@ export class DonationService {
450
455
  } catch (err) {
451
456
  throw new DonationError('seed_failed', `Donated node unreachable: ${errorMessage(err)}`);
452
457
  }
458
+ if (res.status === 401) {
459
+ // The node refused the host's bearer, not the requester's seed — the node
460
+ // never looked at the seed. So the fault is the host's own token
461
+ // bookkeeping (the record names a token the running child was not started
462
+ // with), and the message must not read as a trust-policy rejection.
463
+ //
464
+ // Still `seed_failed`, deliberately: the phone retries that code, and a
465
+ // bookkeeping gap that is mid-repair (a respawn about to write its new
466
+ // token) can clear inside that retry window.
467
+ log('donated node for donation %s refused the host seed credential (401)', id);
468
+ throw new DonationError('seed_failed', 'Donated node rejected the host\'s seed credential (401)');
469
+ }
453
470
  if (!res.ok) {
454
471
  const body = await res.text();
455
472
  throw new DonationError('seed_failed', `Donated node /seed returned ${res.status}: ${body}`);
@@ -524,7 +541,9 @@ export class DonationService {
524
541
  `Donation ${id} cannot be respawned in status ${donation.status}`,
525
542
  );
526
543
  }
527
- if (!donation.bootstrapNodes?.length || !donation.ownerKeys?.length) {
544
+ // An empty `bootstrapNodes` is a real spawn input (a requester that dials the node
545
+ // itself); only a missing field marks a record written before inputs were persisted.
546
+ if (donation.bootstrapNodes === undefined || !donation.ownerKeys?.length) {
528
547
  log('donation %s is not respawnable (record predates persisted spawn inputs)', id);
529
548
  return { outcome: 'not_respawnable' };
530
549
  }
@@ -613,7 +632,7 @@ export class DonationService {
613
632
  * (`<rootDir>/<containerId>`), reclaiming here would delete exactly what
614
633
  * `giveUp` meant to keep.
615
634
  *
616
- * NOTE: that skipped reclaim leaks the new spawn's four ports — the record
635
+ * NOTE: that skipped reclaim leaks the new spawn's ports — the record
617
636
  * still names the *previous* `dockerId`, so a later `terminate` cleans up the
618
637
  * old handle, not this one. Unreachable today (`giveUp` is only called from
619
638
  * the supervisor's serialized pass, so it cannot overlap a respawn); if a
@@ -23,7 +23,8 @@ export const DONATION_RESPAWN_BACKOFF_BASE_MS = 5_000;
23
23
  *
24
24
  * NOTE: not reachable at the current {@link DONATION_RESPAWN_MAX_ATTEMPTS} of 5
25
25
  * — the longest wait actually served is the one before the 5th attempt,
26
- * `5s * 2^4` = 80s, and the 5th failure gives up. The cap only starts clamping
26
+ * `5s * 2^4` = 80s, and once that attempt fails or its node dies the supervisor
27
+ * gives up rather than waiting again. The cap only starts clamping
27
28
  * once a record can reach 6 recorded attempts (`5s * 2^6` = 320s), so raising
28
29
  * the attempt cap without revisiting this one changes nothing.
29
30
  */
@@ -33,6 +34,12 @@ export const DONATION_RESPAWN_BACKOFF_MAX_MS = 5 * 60_000;
33
34
  * Consecutive respawn attempts after which the host stops trying and marks the
34
35
  * donation `error` — a node that will not stay up is a host-side fault the
35
36
  * borrower cannot see, so surface it instead of spinning forever.
37
+ *
38
+ * Every attempt counts, whether its spawn failed or succeeded: a node that spawns
39
+ * and then dies before {@link DONATION_RESPAWN_HEALTHY_MS} refills the budget is
40
+ * as much a crash loop as one that never spawns. Give-up happens on whichever
41
+ * comes first — an attempt that throws with the cap reached, or a pass that finds
42
+ * the node down with the cap already reached.
36
43
  */
37
44
  export const DONATION_RESPAWN_MAX_ATTEMPTS = 5;
38
45
 
@@ -232,6 +239,19 @@ export class DonationSupervisor {
232
239
  this.refillBudgetIfHealthy(donation);
233
240
  return undefined;
234
241
  }
242
+ // The budget is spent, and the last attempt did not stay up long enough to
243
+ // refill it. Checked here and not only when `respawn` throws: a spawn that
244
+ // succeeds and then dies at once (a crash on boot, a port clash) never
245
+ // throws, so a catch-only check would respawn it forever.
246
+ // NOTE: a record at the cap whose last respawn was still up but not yet
247
+ // refilled when the host went down is given up on host restart rather than
248
+ // tried once more; if that is ever seen, skip this check when the host
249
+ // started after `respawn.lastAttemptAt`.
250
+ const attempts = donation.respawn?.attempts ?? 0;
251
+ if (attempts >= DONATION_RESPAWN_MAX_ATTEMPTS) {
252
+ await this.giveUp(donation.id, attempts, 'the node did not stay running after its last respawn');
253
+ return undefined;
254
+ }
235
255
  if (!this.backoffElapsed(donation)) {
236
256
  log('donation %s is down but still inside its respawn backoff', donation.id);
237
257
  return undefined;
@@ -294,7 +314,10 @@ export class DonationSupervisor {
294
314
  return this.now().getTime() - last > DONATION_RESPAWN_HEALTHY_MS;
295
315
  }
296
316
 
297
- /** One respawn attempt, with the give-up check on failure. */
317
+ /**
318
+ * One respawn attempt, with the give-up check on failure. (The check for an
319
+ * attempt that spawned but then died lives in {@link reconcileOne}.)
320
+ */
298
321
  private async attemptRespawn(donation: Donation): Promise<string | undefined> {
299
322
  try {
300
323
  const result = await this.service.respawn(donation.id);
@@ -149,7 +149,8 @@ export interface Donation {
149
149
  /** The REQUESTER's cadre (a foreign party this host merely hosts a node for). */
150
150
  partyId: string;
151
151
  /**
152
- * Requester control-network bootstrap multiaddrs — replayed on respawn.
152
+ * Requester control-network bootstrap multiaddrs — replayed on respawn. Empty when
153
+ * the requester dials the node itself (see `DonationProvisionRequest.bootstrapNodes`).
153
154
  * Absent on records written before respawn support; those are not respawnable.
154
155
  */
155
156
  bootstrapNodes?: string[];