@serfab/cadre-core 1.3.0 → 1.5.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.
- package/README.md +5 -3
- package/dist/cadre-node.d.ts +70 -34
- package/dist/cadre-node.js +197 -60
- package/dist/cadre-node.js.map +1 -1
- package/dist/control-database.d.ts +24 -10
- package/dist/control-database.js +48 -16
- package/dist/control-database.js.map +1 -1
- package/dist/control-formation-recorder.d.ts +4 -4
- package/dist/control-formation-recorder.js +6 -6
- package/dist/control-formation-recorder.js.map +1 -1
- package/dist/control-read-retry.d.ts +12 -0
- package/dist/control-read-retry.js +12 -0
- package/dist/control-read-retry.js.map +1 -1
- package/dist/control-write-retry.d.ts +5 -4
- package/dist/control-write-retry.js +24 -7
- package/dist/control-write-retry.js.map +1 -1
- package/dist/index.d.ts +5 -4
- package/dist/index.js +10 -5
- package/dist/index.js.map +1 -1
- package/dist/link-budget.d.ts +182 -0
- package/dist/link-budget.js +195 -0
- package/dist/link-budget.js.map +1 -0
- package/dist/membership-connection-gater.d.ts +18 -5
- package/dist/membership-connection-gater.js +50 -10
- package/dist/membership-connection-gater.js.map +1 -1
- package/dist/peer-dial.d.ts +36 -17
- package/dist/peer-dial.js +37 -17
- package/dist/peer-dial.js.map +1 -1
- package/dist/peer-join-backfill.d.ts +58 -4
- package/dist/peer-join-backfill.js +165 -20
- package/dist/peer-join-backfill.js.map +1 -1
- package/dist/relay-reservation.d.ts +59 -11
- package/dist/relay-reservation.js +169 -43
- package/dist/relay-reservation.js.map +1 -1
- package/dist/seed-bootstrap.js +2 -1
- package/dist/seed-bootstrap.js.map +1 -1
- package/dist/storage-scope.d.ts +50 -12
- package/dist/storage-scope.js +85 -12
- package/dist/storage-scope.js.map +1 -1
- package/dist/strand-first-sync-gate.d.ts +43 -5
- package/dist/strand-first-sync-gate.js +55 -7
- package/dist/strand-first-sync-gate.js.map +1 -1
- package/dist/strand-formation-manager.js +2 -1
- package/dist/strand-formation-manager.js.map +1 -1
- package/dist/strand-id.d.ts +35 -0
- package/dist/strand-id.js +46 -0
- package/dist/strand-id.js.map +1 -0
- package/dist/strand-instance-manager.d.ts +27 -2
- package/dist/strand-instance-manager.js +75 -15
- package/dist/strand-instance-manager.js.map +1 -1
- package/dist/strand-membership-reconciler.d.ts +200 -45
- package/dist/strand-membership-reconciler.js +302 -86
- package/dist/strand-membership-reconciler.js.map +1 -1
- package/dist/strand-membership-writer.d.ts +3 -2
- package/dist/strand-membership-writer.js +67 -90
- package/dist/strand-membership-writer.js.map +1 -1
- package/dist/strand-network-config.d.ts +3 -3
- package/dist/strand-network-config.js +3 -3
- package/dist/strand-solicitation.js +2 -1
- package/dist/strand-solicitation.js.map +1 -1
- package/dist/strand-watcher.d.ts +45 -0
- package/dist/strand-watcher.js +74 -13
- package/dist/strand-watcher.js.map +1 -1
- package/dist/types.d.ts +190 -24
- package/dist/types.js +73 -4
- package/dist/types.js.map +1 -1
- package/package.json +8 -8
- package/src/cadre-node.ts +7445 -7302
- package/src/control-database.ts +48 -16
- package/src/control-formation-recorder.ts +6 -6
- package/src/control-read-retry.ts +214 -202
- package/src/control-write-retry.ts +24 -7
- package/src/index.ts +29 -2
- package/src/link-budget.ts +217 -0
- package/src/membership-connection-gater.ts +56 -11
- package/src/peer-dial.ts +39 -17
- package/src/peer-join-backfill.ts +192 -23
- package/src/relay-reservation.ts +1119 -951
- package/src/seed-bootstrap.ts +2 -1
- package/src/storage-scope.ts +163 -78
- package/src/strand-first-sync-gate.ts +55 -7
- package/src/strand-formation-manager.ts +647 -646
- package/src/strand-id.ts +48 -0
- package/src/strand-instance-manager.ts +1516 -1440
- package/src/strand-membership-reconciler.ts +334 -92
- package/src/strand-membership-writer.ts +1962 -1987
- package/src/strand-network-config.ts +250 -250
- package/src/strand-solicitation.ts +500 -499
- package/src/strand-watcher.ts +388 -325
- package/src/types.ts +192 -23
package/src/seed-bootstrap.ts
CHANGED
|
@@ -811,7 +811,8 @@ export class SeedBootstrapService {
|
|
|
811
811
|
// (`dialPeerAddrs`), so an owner whose first address never answers neither
|
|
812
812
|
// stalls seed application nor goes undialed at its other addresses.
|
|
813
813
|
// NOTE: `handleSeedStream` acks only after this loop, so an unreachable owner
|
|
814
|
-
// can hold the ack for up to `dialBudget.totalMs`
|
|
814
|
+
// can hold the ack for up to `dialBudget.totalMs` each — 32 s at the default declared link
|
|
815
|
+
// round trip, more on a host that declared a slower one (`link-budget.ts`) — past a sender's
|
|
815
816
|
// 10 s `seedDeliverTimeoutMs`; the seed is still applied. If senders start
|
|
816
817
|
// reporting accepted seeds as timed out, ack before dialing.
|
|
817
818
|
const selfPeerId = this.libp2pNode.peerId?.toString();
|
package/src/storage-scope.ts
CHANGED
|
@@ -1,78 +1,163 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* storage-scope.ts — the scope keys `CadreNodeConfig.storage.provider` is called with.
|
|
3
|
-
*
|
|
4
|
-
* A provider is a factory cadre-core invokes once per **scope**: once for the control
|
|
5
|
-
* database and once for each strand. A strand's scope key is its strand id; the control
|
|
6
|
-
* database's scope key is what this module mints.
|
|
7
|
-
*
|
|
8
|
-
* WHY THE CONTROL SCOPE CARRIES THE PARTY ID. The control database holds the party's
|
|
9
|
-
* own records — its strands, owner keys, peers, invitations, revocations. Two parties
|
|
10
|
-
* on one device must therefore not share one control store, or a node started for
|
|
11
|
-
* party B reads party A's rows as if party A had written them for B. Every other
|
|
12
|
-
* party-scoped store in this codebase already knows this: `PersistentTrustedOwnerStore`,
|
|
13
|
-
* `PersistentBootstrapPeerStore` and `PersistentEnrolledMachineStore` each take a
|
|
14
|
-
* `partyId` and fail closed on a mismatch. Putting the party id in the key here means
|
|
15
|
-
* no embedder has to know it belongs there, and none can get it wrong.
|
|
16
|
-
*
|
|
17
|
-
* WHY IT IS ENCODED. A party id is arbitrary text — nothing in cadre-core validates
|
|
18
|
-
* its shape, and the React Native reference app lets a user type one into Settings.
|
|
19
|
-
* Scope keys reach real namespaces unescaped: cadre-cli builds `${config.path}/${scope}`,
|
|
20
|
-
* React Native a LevelDB filename, the browser an IndexedDB database name. A party id
|
|
21
|
-
* containing `/` or `..` would escape the CLI's storage directory. Base64url keeps every
|
|
22
|
-
* key inside `[A-Za-z0-9._-]` — the charset embedders already rely on, and the same
|
|
23
|
-
* encoding the React Native app's `secureStoreKeySegment` uses on a party id for the
|
|
24
|
-
* same reason.
|
|
25
|
-
*
|
|
26
|
-
* THE STRAND ARM OF THAT CHARSET RULE IS
|
|
27
|
-
* `StrandRow.Id`, and
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
* provider.
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
1
|
+
/**
|
|
2
|
+
* storage-scope.ts — the scope keys `CadreNodeConfig.storage.provider` is called with.
|
|
3
|
+
*
|
|
4
|
+
* A provider is a factory cadre-core invokes once per **scope**: once for the control
|
|
5
|
+
* database and once for each strand. A strand's scope key is its strand id; the control
|
|
6
|
+
* database's scope key is what this module mints.
|
|
7
|
+
*
|
|
8
|
+
* WHY THE CONTROL SCOPE CARRIES THE PARTY ID. The control database holds the party's
|
|
9
|
+
* own records — its strands, owner keys, peers, invitations, revocations. Two parties
|
|
10
|
+
* on one device must therefore not share one control store, or a node started for
|
|
11
|
+
* party B reads party A's rows as if party A had written them for B. Every other
|
|
12
|
+
* party-scoped store in this codebase already knows this: `PersistentTrustedOwnerStore`,
|
|
13
|
+
* `PersistentBootstrapPeerStore` and `PersistentEnrolledMachineStore` each take a
|
|
14
|
+
* `partyId` and fail closed on a mismatch. Putting the party id in the key here means
|
|
15
|
+
* no embedder has to know it belongs there, and none can get it wrong.
|
|
16
|
+
*
|
|
17
|
+
* WHY IT IS ENCODED. A party id is arbitrary text — nothing in cadre-core validates
|
|
18
|
+
* its shape, and the React Native reference app lets a user type one into Settings.
|
|
19
|
+
* Scope keys reach real namespaces unescaped: cadre-cli builds `${config.path}/${scope}`,
|
|
20
|
+
* React Native a LevelDB filename, the browser an IndexedDB database name. A party id
|
|
21
|
+
* containing `/` or `..` would escape the CLI's storage directory. Base64url keeps every
|
|
22
|
+
* key inside `[A-Za-z0-9._-]` — the charset embedders already rely on, and the same
|
|
23
|
+
* encoding the React Native app's `secureStoreKeySegment` uses on a party id for the
|
|
24
|
+
* same reason.
|
|
25
|
+
*
|
|
26
|
+
* THE STRAND ARM OF THAT CHARSET RULE IS ENFORCED HERE. A strand's scope key is
|
|
27
|
+
* `StrandRow.Id`, and a strand row replicated into the control database by another
|
|
28
|
+
* node in the party carries whatever id THAT node wrote. `assertStrandScopeKey`
|
|
29
|
+
* (below) is the check that makes the charset true of strand keys too; it runs
|
|
30
|
+
* unconditionally at the top of `StrandInstanceManager.startStrand`, so no strand id
|
|
31
|
+
* reaches an embedder's provider — or a libp2p protocol prefix — unvalidated. Ids
|
|
32
|
+
* cadre-core mints come from `strand-id.ts`, which is built on the same predicate.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { toString as uint8ArrayToString, fromString as uint8ArrayFromString } from 'uint8arrays';
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Prefix marking a scope key as a control database rather than a strand.
|
|
39
|
+
*
|
|
40
|
+
* Not exported: `controlStorageScope` mints these keys and `isControlStorageScope`
|
|
41
|
+
* recognizes them, which is the whole surface a caller needs.
|
|
42
|
+
*/
|
|
43
|
+
const CONTROL_SCOPE_PREFIX = 'control-';
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The storage scope key for a party's control database.
|
|
47
|
+
*
|
|
48
|
+
* Returns `control-` followed by the base64url encoding of `partyId`'s UTF-8 bytes,
|
|
49
|
+
* so the whole key stays within `[A-Za-z0-9._-]` and is safe to use directly as a
|
|
50
|
+
* file name, directory name or database name. See the module comment for why the
|
|
51
|
+
* encoding is load-bearing rather than decorative.
|
|
52
|
+
*
|
|
53
|
+
* To read a party id back off a device — from, say, a LevelDB file named
|
|
54
|
+
* `sereus-control-MTExMTExMTEtMjIyMi00MzMzLTg0NDQtNTU1NTU1NTU1NTU1` — in a browser or
|
|
55
|
+
* Node console, with no dependency on this package:
|
|
56
|
+
* ```js
|
|
57
|
+
* const b64 = key.slice('control-'.length).replace(/-/g, '+').replace(/_/g, '/');
|
|
58
|
+
* new TextDecoder().decode(Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)))
|
|
59
|
+
* ```
|
|
60
|
+
* The `TextDecoder` step is not optional: `atob` alone yields one character per BYTE,
|
|
61
|
+
* so a party id with any non-ASCII character in it decodes to mojibake.
|
|
62
|
+
*/
|
|
63
|
+
export function controlStorageScope(partyId: string): string {
|
|
64
|
+
return CONTROL_SCOPE_PREFIX + uint8ArrayToString(uint8ArrayFromString(partyId, 'utf8'), 'base64url');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Whether a scope key names a control database rather than a strand.
|
|
69
|
+
*
|
|
70
|
+
* A prefix test, and a sound one: a strand scope key can never begin `control-`,
|
|
71
|
+
* because {@link isValidStrandScopeKey} rejects that prefix and
|
|
72
|
+
* {@link assertStrandScopeKey} runs on every strand launch.
|
|
73
|
+
*/
|
|
74
|
+
export function isControlStorageScope(scope: string): boolean {
|
|
75
|
+
return scope.startsWith(CONTROL_SCOPE_PREFIX);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The longest strand scope key accepted.
|
|
80
|
+
*
|
|
81
|
+
* The longest id cadre-core mints is 39 characters (`strand-` plus the 32 hex
|
|
82
|
+
* characters of `randomBytes(128, 'hex')` — 128 BITS, so 16 bytes), so this leaves
|
|
83
|
+
* ample room while keeping a key clear of the 255-BYTE limit every mainstream
|
|
84
|
+
* filesystem puts on one path component. The charset is ASCII, so characters and
|
|
85
|
+
* bytes are the same count here.
|
|
86
|
+
*/
|
|
87
|
+
const MAX_STRAND_SCOPE_KEY_LENGTH = 128;
|
|
88
|
+
|
|
89
|
+
/** The charset every scope key stays within — see the module comment. */
|
|
90
|
+
const SCOPE_KEY_CHARSET = /^[A-Za-z0-9._-]+$/;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Whether a strand id is usable as a storage scope key and as a libp2p network name.
|
|
94
|
+
*
|
|
95
|
+
* A strand id is not minted locally in the general case — a strand row replicates in
|
|
96
|
+
* from another node in the party carrying whatever id THAT node wrote — and both
|
|
97
|
+
* places it lands turn it straight into a name: the embedder's storage provider
|
|
98
|
+
* (a directory under cadre-cli's storage path, a LevelDB filename, an IndexedDB
|
|
99
|
+
* database name) and the strand node's protocol prefix `/optimystic/strand-<id>`.
|
|
100
|
+
*
|
|
101
|
+
* Valid means all of:
|
|
102
|
+
* - non-empty, and at most {@link MAX_STRAND_SCOPE_KEY_LENGTH} characters;
|
|
103
|
+
* - within `[A-Za-z0-9._-]`, so no separator, drive letter, NUL or non-ASCII text;
|
|
104
|
+
* - not `.` or `..`, which the charset admits but every filesystem reads as a
|
|
105
|
+
* directory rather than a name;
|
|
106
|
+
* - not `control-`-prefixed, which would let a strand's store masquerade as a
|
|
107
|
+
* party's control database (see {@link isControlStorageScope}).
|
|
108
|
+
*/
|
|
109
|
+
export function isValidStrandScopeKey(strandId: string): boolean {
|
|
110
|
+
return strandId.length > 0
|
|
111
|
+
&& strandId.length <= MAX_STRAND_SCOPE_KEY_LENGTH
|
|
112
|
+
&& SCOPE_KEY_CHARSET.test(strandId)
|
|
113
|
+
&& strandId !== '.'
|
|
114
|
+
&& strandId !== '..'
|
|
115
|
+
&& !isControlStorageScope(strandId);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Thrown when a strand id cannot be used as a storage scope key or network name.
|
|
120
|
+
*
|
|
121
|
+
* NOT retryable: the id is a property of the strand row, so every later attempt on
|
|
122
|
+
* the same row fails identically. Callers that poll — `CadreNode.handleStrandAdded`
|
|
123
|
+
* via `StrandWatcher` — suppress the strand rather than scheduling a retry.
|
|
124
|
+
*/
|
|
125
|
+
export class InvalidStrandIdError extends Error {
|
|
126
|
+
readonly strandId: string;
|
|
127
|
+
|
|
128
|
+
constructor(strandId: string) {
|
|
129
|
+
super(
|
|
130
|
+
`Strand id ${JSON.stringify(strandId)} cannot be used as a storage scope key: ` +
|
|
131
|
+
`a strand id must be 1-${MAX_STRAND_SCOPE_KEY_LENGTH} characters within [A-Za-z0-9._-], ` +
|
|
132
|
+
"must not be '.' or '..', and must not begin 'control-'. " +
|
|
133
|
+
'This strand was created by a node that does not mint conforming ids; it cannot be ' +
|
|
134
|
+
'started here, because the id becomes a file, directory or database name.'
|
|
135
|
+
);
|
|
136
|
+
this.name = 'InvalidStrandIdError';
|
|
137
|
+
this.strandId = strandId;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** {@link isValidStrandScopeKey} as an assertion, throwing {@link InvalidStrandIdError}. */
|
|
142
|
+
export function assertStrandScopeKey(strandId: string): void {
|
|
143
|
+
if (!isValidStrandScopeKey(strandId)) {
|
|
144
|
+
throw new InvalidStrandIdError(strandId);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The charset half of the rule on its own, for the CONTROL key.
|
|
150
|
+
*
|
|
151
|
+
* {@link assertStrandScopeKey} cannot serve here: it rejects the `control-` prefix by
|
|
152
|
+
* design. Asserted at the seam that calls the provider (`CadreNode.resolveControlStorage`)
|
|
153
|
+
* rather than inside {@link controlStorageScope}, so the guarantee belongs to the call
|
|
154
|
+
* that hands a key out and not to one particular way of minting one.
|
|
155
|
+
*/
|
|
156
|
+
export function assertScopeKeyCharset(scope: string): void {
|
|
157
|
+
if (!SCOPE_KEY_CHARSET.test(scope)) {
|
|
158
|
+
throw new Error(
|
|
159
|
+
`Storage scope key ${JSON.stringify(scope)} leaves the [A-Za-z0-9._-] charset ` +
|
|
160
|
+
'embedders rely on to use it directly as a file, directory or database name.'
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
@@ -64,12 +64,42 @@ export const DEFAULT_STRAND_FIRST_SYNC_POLL_MS = 500;
|
|
|
64
64
|
|
|
65
65
|
/**
|
|
66
66
|
* How long `CadreNode.addStrand` waits for a joining machine's first sync before rejecting
|
|
67
|
-
* with {@link StrandAwaitingFirstSyncError}.
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
67
|
+
* with {@link StrandAwaitingFirstSyncError}.
|
|
68
|
+
*
|
|
69
|
+
* Measured over a DIRECT connection (2026-09-16): the joiner reads the founder's rows about
|
|
70
|
+
* 1.3 s after `addStrand` would previously have resolved. That is still the fast case, and it
|
|
71
|
+
* is not what this budget has to cover.
|
|
72
|
+
*
|
|
73
|
+
* Measured over a RELAYED SLOW LINK (2026-09-26): one Windows developer machine, two
|
|
74
|
+
* relay-only `CadreNode`s (`listenAddrs: []`) on a shared loopback dedicated relay, with a
|
|
75
|
+
* 900 ms one-way per-frame outbound delay applied to every websocket by
|
|
76
|
+
* `integration-tests/src/harness/ws-latency.ts` in `pipelined` mode — frames stay overlapped
|
|
77
|
+
* in flight, so the figure is latency and bandwidth stays unlimited; the harness's other mode
|
|
78
|
+
* (`serial`) is a per-socket frame-rate cap and its delays are NOT comparable
|
|
79
|
+
* (`docs/testing.md` → "Link latency"). A round trip therefore costs about
|
|
80
|
+
* 1.8 s. Time from `addStrand` to writable, for a machine holding nothing of the strand yet:
|
|
81
|
+
* 23, 27, 31 and 41 s over four runs at optimystic's 1000 ms cohort read deadline, and 35, 42
|
|
82
|
+
* and 46 s over three runs at a 5000 ms one. **The second band is the one in force**: sereus
|
|
83
|
+
* declares 5000 ms (`COHORT_READ_DEADLINE_MS`, `quereus-plugin-sereus/src/cluster-size.ts`),
|
|
84
|
+
* because 1000 ms is shorter than one round trip on that link and leaves every cohort read with
|
|
85
|
+
* no answer to corroborate against. Widening that deadline makes a consult against a peer that
|
|
86
|
+
* cannot answer cost longer, and this phase runs several of those — which is why the two bands
|
|
87
|
+
* are both recorded here, and why a change to either number has to be weighed against the
|
|
88
|
+
* other. 120 s clears the worst sample of the band in force by about 2.6x. The previous 30 s
|
|
89
|
+
* default sat INSIDE the first band, so roughly half of those joins were rejected as "not
|
|
90
|
+
* writable yet" while the sync was progressing normally and went on to complete.
|
|
91
|
+
*
|
|
92
|
+
* NOTE: accepted tradeoff — what 120 s costs. This wait is what an app's `addStrand` sits in
|
|
93
|
+
* before it is told "not yet", so a strand none of whose members is reachable at all takes two
|
|
94
|
+
* minutes to report instead of thirty seconds. The slow report was weighed against refusing
|
|
95
|
+
* joins that were working, and the refusal is the worse failure. The cost is bounded — the
|
|
96
|
+
* rejection is retryable, the strand stays launched and keeps probing, and `strand:writable`
|
|
97
|
+
* fires the moment the sync lands, so an app that listens for the event rather than awaiting
|
|
98
|
+
* the call is unaffected either way. Revisit if the gate ever learns whether ANY other member
|
|
99
|
+
* is connected: "no peer at all" could then be reported at once and this budget would only
|
|
100
|
+
* ever be spent on a sync that is actually in progress.
|
|
71
101
|
*/
|
|
72
|
-
export const DEFAULT_STRAND_FIRST_SYNC_TIMEOUT_MS =
|
|
102
|
+
export const DEFAULT_STRAND_FIRST_SYNC_TIMEOUT_MS = 120_000;
|
|
73
103
|
|
|
74
104
|
/** Embedder-facing tuning for the gate, threaded from `CadreNodeConfig.strandFirstSync`. */
|
|
75
105
|
export interface StrandFirstSyncConfig {
|
|
@@ -242,8 +272,7 @@ export class StrandFirstSyncGate {
|
|
|
242
272
|
private async probe(): Promise<void> {
|
|
243
273
|
if (this.stopped || this.opened || this.probing) return;
|
|
244
274
|
this.probing = true;
|
|
245
|
-
const held = await
|
|
246
|
-
.finally(() => { this.probing = false; });
|
|
275
|
+
const held = await this.probeHeld();
|
|
247
276
|
// Re-check after the await: a release or a force-open may have landed mid-read.
|
|
248
277
|
if (this.stopped || this.opened) return;
|
|
249
278
|
if (!held) {
|
|
@@ -255,4 +284,23 @@ export class StrandFirstSyncGate {
|
|
|
255
284
|
log('[%s] first-sync gate opened: Strand.Header and every App table read from a peer', this.deps.label);
|
|
256
285
|
this.deps.onHeaderHeld();
|
|
257
286
|
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* One probe's answer, with a throw from OUTSIDE {@link strandFirstSyncComplete}'s own read
|
|
290
|
+
* guards — the `App` schema lookup, or `getDatabase()` itself — reported as "not yet" like
|
|
291
|
+
* any failing read. The loop is scheduled from `void this.probe()`, so an escaping rejection
|
|
292
|
+
* would be unhandled AND leave nothing scheduled: the strand would stay gated for the whole
|
|
293
|
+
* `timeoutMs` and never recover, which is the one failure this gate must not have.
|
|
294
|
+
*/
|
|
295
|
+
private async probeHeld(): Promise<boolean> {
|
|
296
|
+
try {
|
|
297
|
+
return await strandFirstSyncComplete(this.deps.database.getDatabase(), this.deps.label);
|
|
298
|
+
} catch (error) {
|
|
299
|
+
log('[%s] first-sync probe threw (treated as not yet synced): %s', this.deps.label,
|
|
300
|
+
error instanceof Error ? error.message : String(error));
|
|
301
|
+
return false;
|
|
302
|
+
} finally {
|
|
303
|
+
this.probing = false;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
258
306
|
}
|