@mcsherrylabs/pi-meerkat 0.10.1 → 0.11.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/dist/installer/committee-init.sh +17 -33
- package/dist/installer/committee.conf +12 -34
- package/dist/installer/{node.devnet.conf → node-follower.conf} +3 -9
- package/dist/installer/node-join.sh +11 -4
- package/dist/installer/node.conf +4 -234
- package/dist/installer/node.local.conf +8 -0
- package/dist/installer/runtime.json +1 -1
- package/dist/installer-runtime/dist/scripts/recover-owner.js +7 -9
- package/dist/installer-runtime/dist/scripts/subscriptions-init.js +3 -4
- package/dist/installer-runtime/dist/scripts/versions.mjs +5 -10
- package/dist/installer-runtime/dist/src/announcements.js +1 -289
- package/dist/installer-runtime/dist/src/app.js +12 -548
- package/dist/installer-runtime/dist/src/chainfetch.js +0 -64
- package/dist/installer-runtime/dist/src/env.js +35 -32
- package/dist/installer-runtime/dist/src/service.js +151 -0
- package/dist/installer.d.ts +8 -18
- package/dist/installer.d.ts.map +1 -1
- package/dist/installer.js +8 -9
- package/dist/installer.js.map +1 -1
- package/dist/mcp-upstream.d.ts +1 -1
- package/dist/mcp-upstream.js +1 -1
- package/installer/Node.Dockerfile +4 -3
- package/installer/Registry.Dockerfile +34 -9
- package/installer/check-single-sdk.mjs +151 -0
- package/package.json +4 -4
- package/dist/installer-runtime/dist/src/runtime.js +0 -192
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
// stores and floods the body it will later serve. This is why the group-chat deployment
|
|
31
31
|
// has a "DA group = the app nodes" step, and why an app cannot simply write to a channel
|
|
32
32
|
// it claimed with defaults.
|
|
33
|
-
import {
|
|
33
|
+
import { fString, fVarint, fMessage, concat } from "@mcsherrylabs/evolver-client";
|
|
34
34
|
/**
|
|
35
35
|
* The channel this app owns — ONE variable, read by the app AND by the node's
|
|
36
36
|
* `subscribed-channels` declaration in docker-compose.devnet.yml, because the two must agree
|
|
@@ -47,23 +47,6 @@ import { buildSignedTx, claimBody, channelBody, createGroupBody, addMemberBody,
|
|
|
47
47
|
* rather than a literal.
|
|
48
48
|
*/
|
|
49
49
|
export const CHANNEL = process.env.EVOLVER_CHANNEL?.trim() || "announcements1";
|
|
50
|
-
/**
|
|
51
|
-
* The DA threshold. ZERO — best-effort — because this app's DA group has ONE member.
|
|
52
|
-
*
|
|
53
|
-
* The scheduler's own validation states the rule and I had it wrong: at one DA node a
|
|
54
|
-
* threshold of 1 can never be satisfied, because a node is never its own flood target and
|
|
55
|
-
* does not self-attest, so a write needing one attestation waits for one that will not come
|
|
56
|
-
* and ends in `503 da_unavailable`.
|
|
57
|
-
*
|
|
58
|
-
* ⚠ And best-effort means what it says: one member is ONE VOLUME. Lose it and every message
|
|
59
|
-
* that member alone held is unrecoverable — and any future processing node declaring this
|
|
60
|
-
* channel WEDGES on the first such message, permanently, because apply is forward-only. That
|
|
61
|
-
* is not hypothetical: it is what happened to `announcements`, and it is why this app is on
|
|
62
|
-
* `announcements1` (evolver#83). Two members on separate volumes is the answer when the
|
|
63
|
-
* messages matter.
|
|
64
|
-
*/
|
|
65
|
-
export const DA_THRESHOLD_M = Number(process.env.EVOLVER_DA_THRESHOLD_M?.trim() || 0);
|
|
66
|
-
const utf8 = (s) => new TextEncoder().encode(s);
|
|
67
50
|
/** The channel's terms as a NAMED node sees them — pass the devnet url to ask the chain, or
|
|
68
51
|
* this app's node to ask its own cursor. The difference is the point (fact 2). */
|
|
69
52
|
export async function channelTerms(baseUrl, ledgerId = CHANNEL) {
|
|
@@ -111,27 +94,6 @@ export async function groupMembers(baseUrl, group) {
|
|
|
111
94
|
return { known: false };
|
|
112
95
|
}
|
|
113
96
|
}
|
|
114
|
-
/**
|
|
115
|
-
* This node's OWN on-chain identity, which the app needs because the DA group's member is the
|
|
116
|
-
* NODE, not the app.
|
|
117
|
-
*
|
|
118
|
-
* Read from `/status`, which is an HTML page — the honest state of things: a node reports its
|
|
119
|
-
* own identity to a human and has no machine-readable equivalent. A JSON status route would
|
|
120
|
-
* make this a question instead of a parse (see evolver#82, which asks for the same thing about
|
|
121
|
-
* readiness).
|
|
122
|
-
*/
|
|
123
|
-
export async function nodeSelfId(baseUrl) {
|
|
124
|
-
try {
|
|
125
|
-
const res = await fetch(`${baseUrl}/status`, { signal: AbortSignal.timeout(10_000) });
|
|
126
|
-
if (!res.ok)
|
|
127
|
-
return null;
|
|
128
|
-
const m = /<h1>\s*Node:\s*([A-Za-z0-9]+)\s*<\/h1>/.exec(await res.text());
|
|
129
|
-
return m ? m[1] : null;
|
|
130
|
-
}
|
|
131
|
-
catch {
|
|
132
|
-
return null;
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
97
|
/**
|
|
136
98
|
* Submit a write and then CONFIRM IT BY READING — the only reliable shape on a node whose
|
|
137
99
|
* apply this app paces (fact 3).
|
|
@@ -176,61 +138,6 @@ export async function submitAndConfirm(node, stx, label, confirm, say, opts) {
|
|
|
176
138
|
}
|
|
177
139
|
throw new Error(`${label}: not visible on chain within the deadline — outcome UNKNOWN, nothing resent`);
|
|
178
140
|
}
|
|
179
|
-
/**
|
|
180
|
-
* Create the DA group and put this app's NODE in it.
|
|
181
|
-
*
|
|
182
|
-
* Both steps are best-effort and SAY what happened, because neither is the gate: from run two
|
|
183
|
-
* onward "already exists" is the normal answer, and on a paced node a submit routinely returns
|
|
184
|
-
* `timeout` and commits anyway. The real gate is the CLAIM below — it cannot succeed unless the
|
|
185
|
-
* group exists with this node in it, so a silent failure here surfaces there rather than
|
|
186
|
-
* passing unnoticed.
|
|
187
|
-
*/
|
|
188
|
-
export async function ensureDaGroup(node, baseUrl, owner, signer, group, memberNodeId, say, pace) {
|
|
189
|
-
say(` DA group '${group}', member '${memberNodeId}' — the NODE's identity, not the app's`);
|
|
190
|
-
// READ FIRST, AND SUBMIT ONLY WHAT IS MISSING.
|
|
191
|
-
//
|
|
192
|
-
// This used to submit CreateGroup and AddMember unconditionally on every run. A CreateGroup
|
|
193
|
-
// for a name that already exists is not simply refused: the node ACCEPTS it, then rejects it
|
|
194
|
-
// PRE-COMMIT every round ("name 'x' taken by group") and RETAINS it, re-proposing until its
|
|
195
|
-
// TTL. On the live devnet that did two things — three rejection warnings per round in the
|
|
196
|
-
// leader's log, and, far worse, it kept the tx queue permanently non-empty, which SUPPRESSES
|
|
197
|
-
// wake-on-work for the whole chain (evolver#85: the wake fires only on the queue's empty →
|
|
198
|
-
// non-empty edge). Every submitter, ours and everyone else's, then waited up to the
|
|
199
|
-
// five-minute idle heartbeat — which is also what burned an identity name through
|
|
200
|
-
// evolver#86. A pointless resubmission is not free, and on a shared chain it is not local.
|
|
201
|
-
const before = await groupMembers(baseUrl, group);
|
|
202
|
-
if (before.known && "members" in before && before.members.includes(memberNodeId)) {
|
|
203
|
-
say(` group '${group}' exists with '${memberNodeId}' as a member — nothing to submit`);
|
|
204
|
-
return;
|
|
205
|
-
}
|
|
206
|
-
if (!before.known) {
|
|
207
|
-
throw new Error(`could not read group '${group}' from ${baseUrl} — refusing to guess: ` +
|
|
208
|
-
`submitting a CreateGroup for a name that already exists poisons the ` +
|
|
209
|
-
`chain's tx queue (evolver#85), so this needs an answer, not an attempt.`);
|
|
210
|
-
}
|
|
211
|
-
const steps = [];
|
|
212
|
-
if ("absent" in before) {
|
|
213
|
-
steps.push([`create group '${group}'`, createGroupBody(group),
|
|
214
|
-
async () => { const g = await groupMembers(baseUrl, group); return g.known && !("absent" in g); }]);
|
|
215
|
-
}
|
|
216
|
-
else {
|
|
217
|
-
say(` group '${group}' already exists — adding the member only`);
|
|
218
|
-
}
|
|
219
|
-
steps.push([`add '${memberNodeId}' to '${group}'`, addMemberBody(group, memberNodeId),
|
|
220
|
-
async () => {
|
|
221
|
-
const g = await groupMembers(baseUrl, group);
|
|
222
|
-
return g.known && "members" in g && g.members.includes(memberNodeId);
|
|
223
|
-
}]);
|
|
224
|
-
for (const [label, body, confirm] of steps) {
|
|
225
|
-
const stx = buildSignedTx({
|
|
226
|
-
ledgerId: LEDGER.groups, senderNodeId: owner.nodeId, body,
|
|
227
|
-
ttlRound: BigInt((await node.currentRound()) + 50), signers: [signer],
|
|
228
|
-
});
|
|
229
|
-
// Confirmed by READING, like every other write here: a submit that times out has an
|
|
230
|
-
// unknown outcome, and on a quiescent chain the block is minutes away.
|
|
231
|
-
await submitAndConfirm(node, stx, label, confirm, say, { pace });
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
141
|
/**
|
|
235
142
|
* `UpdateChannelDaTxBody` (097) — OwnersTxBody variant 4.
|
|
236
143
|
*
|
|
@@ -251,198 +158,3 @@ export function updateChannelDaBody(args) {
|
|
|
251
158
|
parts.push(...fVarint(3, args.daThresholdM));
|
|
252
159
|
return concat(fMessage(4, concat(parts)));
|
|
253
160
|
}
|
|
254
|
-
/**
|
|
255
|
-
* Make the channel exist, owned by us, WITH a DA group — the only shape that accepts a
|
|
256
|
-
* body-form write (fact 4).
|
|
257
|
-
*
|
|
258
|
-
* Idempotent, and the three cases are genuinely different: nothing claimed (claim it, carrying
|
|
259
|
-
* the DA policy), claimed by us with the WRONG DA policy (converge it in place with
|
|
260
|
-
* `UpdateChannelDa`), claimed by somebody else (stop: that is a real conflict on a shared
|
|
261
|
-
* chain).
|
|
262
|
-
*/
|
|
263
|
-
export async function ensureChannelWithDa(node, chainUrl, owner, signer, group, say, pace) {
|
|
264
|
-
const ask = () => channelTerms(chainUrl);
|
|
265
|
-
const read = await ask();
|
|
266
|
-
if (!read.ok)
|
|
267
|
-
throw new Error(`could not read the terms of '${CHANNEL}' from ${chainUrl}: ${read.why}\n` +
|
|
268
|
-
` Refusing to guess: an unreadable answer used to mean "unclaimed", and acting on ` +
|
|
269
|
-
`that submits a Claim for a channel someone else may own — accepted, then rejected ` +
|
|
270
|
-
`every round until its TTL (evolver#85).`);
|
|
271
|
-
const terms = read.terms;
|
|
272
|
-
const ownedByUs = terms.owners.some((o) => o.name === owner.nodeId);
|
|
273
|
-
const someoneElse = terms.owners.length > 0 && !ownedByUs;
|
|
274
|
-
// A CHANNEL NAME IS GLOBAL ON A SHARED CHAIN, and whoever claims it owns it. A second
|
|
275
|
-
// deployment of this app — new identities, same channel name — is therefore a READER, not a
|
|
276
|
-
// broken installation: it can declare the channel, subscribe, and fold the ordered stream,
|
|
277
|
-
// which is most of what an announcements channel is for. Only the owner may write (the
|
|
278
|
-
// default write policy is owner-or-group; 156 lets an owner open it to any writer).
|
|
279
|
-
//
|
|
280
|
-
// Dying here instead would be wrong in the ordinary case and would make the name a
|
|
281
|
-
// first-come landmine.
|
|
282
|
-
if (someoneElse) {
|
|
283
|
-
say(` '${CHANNEL}' is owned by ${terms.owners.map((o) => o.name).join(", ")} — this app will READ it, not write`);
|
|
284
|
-
return "reader";
|
|
285
|
-
}
|
|
286
|
-
// THE POLICY MUST MATCH, not merely exist — and "wrong" includes a threshold this app now
|
|
287
|
-
// knows is unsatisfiable. A mismatch is converged with `UpdateChannelDa`, in place; a second
|
|
288
|
-
// Claim would not do it (on an already-claimed ledger a Claim is a committed NO-OP).
|
|
289
|
-
//
|
|
290
|
-
// Measured on the deployment: `announcements1` was claimed at m=1 with a single-member
|
|
291
|
-
// group, this function said "already claimed by us with a DA group — reusing", and the very
|
|
292
|
-
// next write died with `fetch failed` — the attestation that can never arrive, waited out
|
|
293
|
-
// until the connection dropped. Checking only for PRESENCE is what let that through twice.
|
|
294
|
-
const daOk = !!terms.da && terms.da.group === group && terms.da.thresholdM === DA_THRESHOLD_M;
|
|
295
|
-
if (ownedByUs && daOk) {
|
|
296
|
-
say(` '${CHANNEL}' already claimed by us with DA group '${group}' (m=${DA_THRESHOLD_M}) — reusing`);
|
|
297
|
-
return "owner";
|
|
298
|
-
}
|
|
299
|
-
if (ownedByUs && !daOk) {
|
|
300
|
-
const had = terms.da ? `DA group '${terms.da.group}' (m=${terms.da.thresholdM})` : "NO DA group";
|
|
301
|
-
say(` '${CHANNEL}' is claimed by us with ${had}, but this app needs '${group}' (m=${DA_THRESHOLD_M})`);
|
|
302
|
-
// ⚠️ A TARGETED UPDATE, NOT UNCLAIM-THEN-RECLAIM.
|
|
303
|
-
//
|
|
304
|
-
// This used to drop the claim and re-claim, on the belief that a DA policy is set at claim
|
|
305
|
-
// time and cannot be changed. That belief is wrong — `UpdateChannelDa` (097) exists and is
|
|
306
|
-
// owner-gated (OwnersLedgerOrchestrator validates it and sets the policy in place) — and
|
|
307
|
-
// the belief made the app do something irreversible to fix something reversible:
|
|
308
|
-
// `clearChannelPolicies` drops all three policy rows, and BETWEEN the unclaim and the
|
|
309
|
-
// re-claim the channel name is UNOWNED, so on a shared chain anyone can claim it and the
|
|
310
|
-
// name is lost for good. A second Claim would not even have converged it: on an
|
|
311
|
-
// already-claimed ledger a Claim is a committed NO-OP, which is why the unclaim was there
|
|
312
|
-
// at all. evolver-scheduler's provisioner had this right first.
|
|
313
|
-
say(` converging in place with UpdateChannelDa — an unclaim would expose the name to being taken`);
|
|
314
|
-
const upd = buildSignedTx({
|
|
315
|
-
ledgerId: LEDGER.owners, senderNodeId: owner.nodeId,
|
|
316
|
-
body: updateChannelDaBody({ ledgerId: CHANNEL, daGroup: group, daThresholdM: DA_THRESHOLD_M }),
|
|
317
|
-
ttlRound: BigInt((await node.currentRound()) + 50), signers: [signer],
|
|
318
|
-
});
|
|
319
|
-
await submitAndConfirm(node, upd, `update '${CHANNEL}' DA policy to '${group}' (m=${DA_THRESHOLD_M})`, async () => {
|
|
320
|
-
const r = await ask();
|
|
321
|
-
return r.ok && !!r.terms.da && r.terms.da.group === group &&
|
|
322
|
-
r.terms.da.thresholdM === DA_THRESHOLD_M;
|
|
323
|
-
}, say, { pace });
|
|
324
|
-
return "owner";
|
|
325
|
-
}
|
|
326
|
-
// Nothing owns it: claim it, carrying the DA policy in the claim itself.
|
|
327
|
-
const claim = buildSignedTx({
|
|
328
|
-
ledgerId: LEDGER.owners, senderNodeId: owner.nodeId,
|
|
329
|
-
body: claimBody({ ledgerId: CHANNEL, daGroup: group, daThresholdM: DA_THRESHOLD_M }),
|
|
330
|
-
ttlRound: BigInt((await node.currentRound()) + 50), signers: [signer],
|
|
331
|
-
});
|
|
332
|
-
await submitAndConfirm(node, claim, `claim '${CHANNEL}' with DA group '${group}' (m=${DA_THRESHOLD_M})`, async () => {
|
|
333
|
-
const r = await ask();
|
|
334
|
-
return r.ok && !!r.terms.da && r.terms.da.group === group &&
|
|
335
|
-
r.terms.da.thresholdM === DA_THRESHOLD_M &&
|
|
336
|
-
r.terms.owners.some((o) => o.name === owner.nodeId);
|
|
337
|
-
}, say, { pace });
|
|
338
|
-
return "owner";
|
|
339
|
-
}
|
|
340
|
-
/** Write one announcement. Plain UTF-8: this channel is public by intent. */
|
|
341
|
-
export async function announce(node, from, signer, text, say) {
|
|
342
|
-
const stx = buildSignedTx({
|
|
343
|
-
ledgerId: CHANNEL, senderNodeId: from.nodeId,
|
|
344
|
-
body: channelBody(utf8(text)), ttlRound: BigInt((await node.currentRound()) + 50), signers: [signer],
|
|
345
|
-
});
|
|
346
|
-
// submitTxWithDa: the node collects the channel's DA attestations over the overlay and
|
|
347
|
-
// assembles the transaction. A client cannot do that for itself — it has no overlay.
|
|
348
|
-
const r = await node.submitTxWithDa(stx);
|
|
349
|
-
if (r.kind === "committed") {
|
|
350
|
-
say(` announced: "${text}"`);
|
|
351
|
-
return;
|
|
352
|
-
}
|
|
353
|
-
if (r.kind === "timeout") {
|
|
354
|
-
say(` announce: submit timed out — the stream below is the confirmation`);
|
|
355
|
-
return;
|
|
356
|
-
}
|
|
357
|
-
// A duplicate is not a failure of this app: the txId is a function of the body, the sender
|
|
358
|
-
// and the TTL, so an identical announcement that already committed answers `duplicate` —
|
|
359
|
-
// which means the message IS on the channel, which is exactly what the stream below checks.
|
|
360
|
-
if (r.kind === "failed" && r.reason === "duplicate") {
|
|
361
|
-
say(` announce: this exact message is already on the chain — the stream below is the confirmation`);
|
|
362
|
-
return;
|
|
363
|
-
}
|
|
364
|
-
throw new Error(`the announcement was refused: ${JSON.stringify(r)}\n` +
|
|
365
|
-
` insufficient_da means the DA group could not be mustered — is this app's node a member?\n` +
|
|
366
|
-
` out-of-band means the channel has no DA group at all (see ensureChannelWithDa).`);
|
|
367
|
-
}
|
|
368
|
-
// The demo reads the shared collector's durable inbox. There is no local handler map,
|
|
369
|
-
// volatile receive buffer, cursor probe or second consumer of the channel route.
|
|
370
|
-
export async function retainedMessages(store, channel) {
|
|
371
|
-
const messages = [];
|
|
372
|
-
let after;
|
|
373
|
-
do {
|
|
374
|
-
const page = await store.inboxPage(channel, { limit: 100, after });
|
|
375
|
-
for (const entry of page.entries)
|
|
376
|
-
if (entry.body !== null)
|
|
377
|
-
messages.push(new TextDecoder().decode(entry.body));
|
|
378
|
-
if (!page.more)
|
|
379
|
-
return messages;
|
|
380
|
-
if (!page.next || (after && (BigInt(page.next.round) < BigInt(after.round) ||
|
|
381
|
-
(page.next.round === after.round && BigInt(page.next.index) <= BigInt(after.index)))))
|
|
382
|
-
throw new Error("inbox readback did not advance its continuation");
|
|
383
|
-
after = page.next;
|
|
384
|
-
} while (true);
|
|
385
|
-
}
|
|
386
|
-
/** Pace the processor forward until this node stops making progress — which is how a processing
|
|
387
|
-
* node applies blocks at all (fact 2). Anything of ours it passes on the way is KEPT. */
|
|
388
|
-
export async function paceApply(pace, say) {
|
|
389
|
-
const MAX = 200;
|
|
390
|
-
// A walk stops at its budget whether or not the cursor reached the tip, so the report
|
|
391
|
-
// distinguishes the two. Claiming "to its tip" after a truncated walk is a statement the code
|
|
392
|
-
// cannot make — a backlog longer than MAX leaves apply behind, and the reads that follow would
|
|
393
|
-
// silently answer as of an earlier round.
|
|
394
|
-
let walked = 0;
|
|
395
|
-
let caughtUp = false;
|
|
396
|
-
for (let attempts = 0; attempts < 10 && walked < MAX; attempts++) {
|
|
397
|
-
const r = await pace();
|
|
398
|
-
walked += r.inboxed + r.missed + r.duplicates;
|
|
399
|
-
if (r.stoppedBecause === "body-pending")
|
|
400
|
-
throw new Error("node is waiting for a channel body; nothing skipped");
|
|
401
|
-
if (r.stoppedBecause === "caught-up") {
|
|
402
|
-
caughtUp = true;
|
|
403
|
-
break;
|
|
404
|
-
}
|
|
405
|
-
}
|
|
406
|
-
if (!caughtUp)
|
|
407
|
-
say(` paced this node's apply, stopping at ${walked} delivery(ies) — the backlog is longer, so apply may still be behind`);
|
|
408
|
-
else
|
|
409
|
-
say(` paced this node's apply to its tip; original deliveries are retained in the inbox`);
|
|
410
|
-
}
|
|
411
|
-
/** Pacing with no narration, for use inside a wait loop. Keeps what it walks past, as above. */
|
|
412
|
-
export async function paceQuietly(pace) {
|
|
413
|
-
await pace();
|
|
414
|
-
}
|
|
415
|
-
/**
|
|
416
|
-
* Follow the stream until our own message arrives.
|
|
417
|
-
*
|
|
418
|
-
* The delivery is the app's write coming back THROUGH CONSENSUS, which is the only evidence
|
|
419
|
-
* that matters: a submit says the transaction was ordered; a delivery says the app can read
|
|
420
|
-
* what was ordered, in order, from its own node.
|
|
421
|
-
*
|
|
422
|
-
* A submit that timed out has an UNKNOWN outcome and the block carrying it can be minutes away
|
|
423
|
-
* on a quiescent chain, so this waits to a DEADLINE rather than for a fixed number of idle polls
|
|
424
|
-
* (20s of idle was the old bound, and it expired before the block existed). Nothing is ever
|
|
425
|
-
* resent — the transaction id is fixed, so waiting is the only correct move.
|
|
426
|
-
*/
|
|
427
|
-
export async function readBack(pace, want, say, opts) {
|
|
428
|
-
// ⚠️ A CALLER THAT CANNOT MATCH MUST PASS ITS OWN DEADLINE. This waits for a specific
|
|
429
|
-
// message, so with a sentinel that never matches it waits out the whole budget — five
|
|
430
|
-
// minutes of "following the stream" for the reader-mode caller, which only wants to watch
|
|
431
|
-
// briefly. That regression arrived with the deadline itself, from changing this function's
|
|
432
|
-
// contract without revisiting its second call site.
|
|
433
|
-
const deadline = Date.now() + (opts.deadlineMs ?? 300_000);
|
|
434
|
-
let received = [...await opts.messages()];
|
|
435
|
-
while (!received.some((t) => t.includes(want)) && Date.now() < deadline) {
|
|
436
|
-
// Pending/quiet is an outcome; storage, module and transport failures are errors. Never
|
|
437
|
-
// disguise one as an empty read or a successful wait. The app's finally releases its owner.
|
|
438
|
-
const r = await pace();
|
|
439
|
-
received = [...await opts.messages()];
|
|
440
|
-
if ((r.stoppedBecause === "caught-up" || r.stoppedBecause === "body-pending") && !received.some(t => t.includes(want)))
|
|
441
|
-
await new Promise(res => setTimeout(res, 500));
|
|
442
|
-
}
|
|
443
|
-
const found = received.some((t) => t.includes(want));
|
|
444
|
-
say(` retained ${received.length} message(s) on '${CHANNEL}'`);
|
|
445
|
-
return { seen: [...received], found };
|
|
446
|
-
}
|
|
447
|
-
// Only the node's body-unavailable outcome permits advancement. No application timer
|
|
448
|
-
// converts pending/unreadable outcomes into absence; the shared collector owns that rule.
|