@plane31/blind-relay 0.3.0 → 0.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 +29 -1
- package/dist/adapters/index.d.ts +4 -0
- package/dist/adapters/index.d.ts.map +1 -1
- package/dist/adapters/index.js +5 -0
- package/dist/adapters/index.js.map +1 -1
- package/dist/client.d.ts +33 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +76 -13
- package/dist/client.js.map +1 -1
- package/dist/crypto/sodium.d.ts.map +1 -1
- package/dist/crypto/sodium.js +3 -33
- package/dist/crypto/sodium.js.map +1 -1
- package/dist/crypto/sodiumLike.d.ts +39 -0
- package/dist/crypto/sodiumLike.d.ts.map +1 -0
- package/dist/crypto/sodiumLike.js +142 -0
- package/dist/crypto/sodiumLike.js.map +1 -0
- package/dist/errors.d.ts +6 -2
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +5 -1
- package/dist/errors.js.map +1 -1
- package/dist/inbox.d.ts +4 -0
- package/dist/inbox.d.ts.map +1 -1
- package/dist/inbox.js +22 -14
- package/dist/inbox.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/outbox.d.ts +5 -0
- package/dist/outbox.d.ts.map +1 -1
- package/dist/outbox.js +6 -3
- package/dist/outbox.js.map +1 -1
- package/dist/testing/faults.d.ts +5 -0
- package/dist/testing/faults.d.ts.map +1 -1
- package/dist/testing/faults.js +13 -0
- package/dist/testing/faults.js.map +1 -1
- package/package.json +1 -1
- package/src/adapters/index.ts +6 -0
- package/src/client.ts +107 -13
- package/src/crypto/sodium.ts +4 -33
- package/src/crypto/sodiumLike.ts +169 -0
- package/src/errors.ts +6 -1
- package/src/inbox.ts +24 -13
- package/src/index.ts +2 -1
- package/src/outbox.ts +9 -3
- package/src/testing/faults.ts +17 -0
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A libsodium backend over any object with libsodium's JavaScript API: `libsodium-wrappers-sumo`
|
|
3
|
+
* on Node (wasm), or a native binding on a phone (`react-native-libsodium` over JSI), where it
|
|
4
|
+
* replaces the portable backend's pure-JavaScript Ed25519 and X25519 with the C implementation.
|
|
5
|
+
*
|
|
6
|
+
* Bindings differ from libsodium's reference API in small ways (react-native-libsodium 1.7 has
|
|
7
|
+
* no `crypto_scalarmult`, no `memzero`, no `crypto_core_ed25519_is_valid_point`, and its AEAD
|
|
8
|
+
* takes only string associated data, while the protocol's is binary). So the backend is built per
|
|
9
|
+
* function: each one the binding provides is checked once against the portable implementation
|
|
10
|
+
* on known inputs, and a function the binding lacks or gets wrong on a type it does not support
|
|
11
|
+
* falls back to the portable one, while a function that answers differently on the same input is
|
|
12
|
+
* refused outright, since silently wrong bytes would be worse than slow ones. The caller awaits
|
|
13
|
+
* `sodium.ready` before building. Both backends pass the same vectors.
|
|
14
|
+
*/
|
|
15
|
+
import { KavachError } from "../errors.js";
|
|
16
|
+
import type { BoxKeypair, CryptoBackend, SignKeypair } from "./backend.js";
|
|
17
|
+
import { nobleBackend } from "./noble.js";
|
|
18
|
+
|
|
19
|
+
/** The subset of libsodium's API the backend can use; everything but signing, boxing and hashing may be absent. */
|
|
20
|
+
export interface SodiumLike {
|
|
21
|
+
crypto_generichash(outLen: number, data: Uint8Array, key?: Uint8Array | null): Uint8Array;
|
|
22
|
+
randombytes_buf(length: number): Uint8Array;
|
|
23
|
+
crypto_sign_seed_keypair(seed: Uint8Array): { publicKey: Uint8Array; privateKey: Uint8Array };
|
|
24
|
+
crypto_sign_detached(message: Uint8Array, secretKey: Uint8Array): Uint8Array;
|
|
25
|
+
crypto_sign_verify_detached(signature: Uint8Array, message: Uint8Array, publicKey: Uint8Array): boolean;
|
|
26
|
+
crypto_core_ed25519_is_valid_point?(point: Uint8Array): boolean;
|
|
27
|
+
crypto_box_seed_keypair(seed: Uint8Array): { publicKey: Uint8Array; privateKey: Uint8Array };
|
|
28
|
+
crypto_box_keypair(): { publicKey: Uint8Array; privateKey: Uint8Array };
|
|
29
|
+
crypto_scalarmult?(secretKey: Uint8Array, publicKey: Uint8Array): Uint8Array;
|
|
30
|
+
crypto_box_easy(message: Uint8Array, nonce: Uint8Array, publicKey: Uint8Array, secretKey: Uint8Array): Uint8Array;
|
|
31
|
+
crypto_box_open_easy(box: Uint8Array, nonce: Uint8Array, publicKey: Uint8Array, secretKey: Uint8Array): Uint8Array;
|
|
32
|
+
crypto_aead_xchacha20poly1305_ietf_encrypt?(message: Uint8Array, aad: Uint8Array | null, secretNonce: Uint8Array | null, nonce: Uint8Array, key: Uint8Array): Uint8Array;
|
|
33
|
+
crypto_aead_xchacha20poly1305_ietf_decrypt?(secretNonce: Uint8Array | null, ciphertext: Uint8Array, aad: Uint8Array | null, nonce: Uint8Array, key: Uint8Array): Uint8Array;
|
|
34
|
+
memzero?(bytes: Uint8Array): void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Which of the backend's operations run on the binding and which on the portable implementation, after the build-time check. */
|
|
38
|
+
export interface SodiumBackendReport {
|
|
39
|
+
readonly native: readonly string[];
|
|
40
|
+
readonly portable: readonly string[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const same = (a: Uint8Array, b: Uint8Array): boolean => a.length === b.length && a.every((x, i) => x === b[i]);
|
|
44
|
+
/** The X25519 base point, for deriving a public key from a secret scalar on the portable implementation. */
|
|
45
|
+
const X25519_BASE = Uint8Array.from([9, ...new Array<number>(31).fill(0)]);
|
|
46
|
+
|
|
47
|
+
/** Build the backend; see the module comment for how each function is checked. */
|
|
48
|
+
export function sodiumBackend(sodium: SodiumLike): CryptoBackend {
|
|
49
|
+
return buildSodiumBackend(sodium).backend;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** One build per binding object: the checks run once in a JavaScript context, however often the same binding is installed. */
|
|
53
|
+
const built = new WeakMap<SodiumLike, { backend: CryptoBackend; report: SodiumBackendReport }>();
|
|
54
|
+
|
|
55
|
+
export function buildSodiumBackend(sodium: SodiumLike): { backend: CryptoBackend; report: SodiumBackendReport } {
|
|
56
|
+
const cached = built.get(sodium);
|
|
57
|
+
if (cached) return cached;
|
|
58
|
+
const result = checkAndBuild(sodium);
|
|
59
|
+
built.set(sodium, result);
|
|
60
|
+
return result;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function checkAndBuild(sodium: SodiumLike): { backend: CryptoBackend; report: SodiumBackendReport } {
|
|
64
|
+
const stage = "sodiumBackend";
|
|
65
|
+
const native: string[] = [];
|
|
66
|
+
const portable: string[] = [];
|
|
67
|
+
const n = nobleBackend;
|
|
68
|
+
// Known inputs: deterministic, so the check is the same on every device.
|
|
69
|
+
const seed = Uint8Array.from({ length: 32 }, (_, i) => (i * 37 + 11) & 0xff);
|
|
70
|
+
const message = Uint8Array.from({ length: 70 }, (_, i) => (i * 29 + 3) & 0xff);
|
|
71
|
+
const nonce24 = Uint8Array.from({ length: 24 }, (_, i) => (i * 13 + 5) & 0xff);
|
|
72
|
+
const key32 = Uint8Array.from({ length: 32 }, (_, i) => (255 - i * 7) & 0xff);
|
|
73
|
+
const aad = Uint8Array.from([0, 1, 2, 250, 251, 252]);
|
|
74
|
+
|
|
75
|
+
/** A required function: present and agreeing with the portable implementation, or the binding is refused. */
|
|
76
|
+
const required = (name: string, check: () => boolean): void => {
|
|
77
|
+
let ok = false;
|
|
78
|
+
try {
|
|
79
|
+
ok = check();
|
|
80
|
+
} catch {
|
|
81
|
+
ok = false;
|
|
82
|
+
}
|
|
83
|
+
if (!ok) throw new KavachError("INVALID_KEY", stage, `${name} is missing or disagrees with the portable implementation; the binding cannot be used`);
|
|
84
|
+
native.push(name);
|
|
85
|
+
};
|
|
86
|
+
/** An optional function: used when present and agreeing on the protocol's input types, portable otherwise. */
|
|
87
|
+
const optional = (name: string, present: boolean, check: () => boolean): boolean => {
|
|
88
|
+
let ok = false;
|
|
89
|
+
if (present) {
|
|
90
|
+
try {
|
|
91
|
+
ok = check();
|
|
92
|
+
} catch {
|
|
93
|
+
ok = false;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
(ok ? native : portable).push(name);
|
|
97
|
+
return ok;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const nSign = n.signKeypairFromSeed(seed);
|
|
101
|
+
required("signKeypairFromSeed", () => {
|
|
102
|
+
const kp = sodium.crypto_sign_seed_keypair(seed);
|
|
103
|
+
return same(kp.publicKey, nSign.publicKey) && same(kp.privateKey, nSign.secretKey);
|
|
104
|
+
});
|
|
105
|
+
required("signDetached", () => same(sodium.crypto_sign_detached(message, nSign.secretKey), n.signDetached(message, nSign.secretKey)));
|
|
106
|
+
required("verifyDetached", () => sodium.crypto_sign_verify_detached(n.signDetached(message, nSign.secretKey), message, nSign.publicKey) === true && sodium.crypto_sign_verify_detached(n.signDetached(message, nSign.secretKey), nonce24, nSign.publicKey) === false);
|
|
107
|
+
required("blake2b", () => same(sodium.crypto_generichash(32, message, null), n.blake2b(message, 32)) && same(sodium.crypto_generichash(64, message, key32), n.blake2b(message, 64, key32)));
|
|
108
|
+
const nBox = n.boxKeypairFromSeed(seed);
|
|
109
|
+
required("boxKeypairFromSeed", () => {
|
|
110
|
+
const kp = sodium.crypto_box_seed_keypair(seed);
|
|
111
|
+
return same(kp.publicKey, nBox.publicKey) && same(kp.privateKey, nBox.secretKey);
|
|
112
|
+
});
|
|
113
|
+
required("boxKeypair", () => {
|
|
114
|
+
// A fresh keypair cannot be compared with a known one; its public key must be the one its secret derives.
|
|
115
|
+
const kp = sodium.crypto_box_keypair();
|
|
116
|
+
return kp.publicKey.length === 32 && kp.privateKey.length === 32 && same(kp.publicKey, n.scalarMult(kp.privateKey, X25519_BASE));
|
|
117
|
+
});
|
|
118
|
+
const other = n.boxKeypairFromSeed(key32);
|
|
119
|
+
required("boxEasy", () => same(sodium.crypto_box_easy(message, nonce24, other.publicKey, nBox.secretKey), n.boxEasy(message, nonce24, other.publicKey, nBox.secretKey)));
|
|
120
|
+
required("boxOpenEasy", () => same(sodium.crypto_box_open_easy(n.boxEasy(message, nonce24, other.publicKey, nBox.secretKey), nonce24, nBox.publicKey, other.secretKey), message));
|
|
121
|
+
required("randomBytes", () => sodium.randombytes_buf(16).length === 16);
|
|
122
|
+
const scalarMultNative = optional("scalarMult", typeof sodium.crypto_scalarmult === "function", () => same(sodium.crypto_scalarmult?.(nBox.secretKey, other.publicKey) ?? new Uint8Array(), n.scalarMult(nBox.secretKey, other.publicKey)));
|
|
123
|
+
const aeadNative = optional("aead", typeof sodium.crypto_aead_xchacha20poly1305_ietf_encrypt === "function" && typeof sodium.crypto_aead_xchacha20poly1305_ietf_decrypt === "function", () => {
|
|
124
|
+
// The protocol's associated data is binary; a binding that takes only strings throws here and falls back.
|
|
125
|
+
const sealed = sodium.crypto_aead_xchacha20poly1305_ietf_encrypt?.(message, aad, null, nonce24, key32) ?? new Uint8Array();
|
|
126
|
+
const opened = sodium.crypto_aead_xchacha20poly1305_ietf_decrypt?.(null, n.aeadEncrypt(message, aad, nonce24, key32), aad, nonce24, key32) ?? new Uint8Array();
|
|
127
|
+
return same(sealed, n.aeadEncrypt(message, aad, nonce24, key32)) && same(opened, message);
|
|
128
|
+
});
|
|
129
|
+
const validPointNative = optional("isValidSignPoint", typeof sodium.crypto_core_ed25519_is_valid_point === "function", () => sodium.crypto_core_ed25519_is_valid_point?.(nSign.publicKey) === true && sodium.crypto_core_ed25519_is_valid_point?.(new Uint8Array(32)) === false);
|
|
130
|
+
const memzeroNative = optional("memzero", typeof sodium.memzero === "function", () => {
|
|
131
|
+
const probe = Uint8Array.from([1, 2, 3]);
|
|
132
|
+
sodium.memzero?.(probe);
|
|
133
|
+
return probe.every((b) => b === 0);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const backend: CryptoBackend = {
|
|
137
|
+
name: "sodium",
|
|
138
|
+
blake2b: (data, outLen, key) => sodium.crypto_generichash(outLen, data, key ?? null),
|
|
139
|
+
randomBytes: (length) => sodium.randombytes_buf(length),
|
|
140
|
+
signKeypairFromSeed(s): SignKeypair {
|
|
141
|
+
const kp = sodium.crypto_sign_seed_keypair(s);
|
|
142
|
+
return { publicKey: kp.publicKey, secretKey: kp.privateKey };
|
|
143
|
+
},
|
|
144
|
+
signDetached: (m, secretKey) => sodium.crypto_sign_detached(m, secretKey),
|
|
145
|
+
verifyDetached(signature, m, publicKey) {
|
|
146
|
+
try {
|
|
147
|
+
return sodium.crypto_sign_verify_detached(signature, m, publicKey);
|
|
148
|
+
} catch {
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
},
|
|
152
|
+
isValidSignPoint: validPointNative ? (publicKey) => publicKey.length === 32 && sodium.crypto_core_ed25519_is_valid_point?.(publicKey) === true : (publicKey) => n.isValidSignPoint(publicKey),
|
|
153
|
+
boxKeypairFromSeed(s): BoxKeypair {
|
|
154
|
+
const kp = sodium.crypto_box_seed_keypair(s);
|
|
155
|
+
return { publicKey: kp.publicKey, secretKey: kp.privateKey };
|
|
156
|
+
},
|
|
157
|
+
boxKeypair(): BoxKeypair {
|
|
158
|
+
const kp = sodium.crypto_box_keypair();
|
|
159
|
+
return { publicKey: kp.publicKey, secretKey: kp.privateKey };
|
|
160
|
+
},
|
|
161
|
+
scalarMult: scalarMultNative ? (secretKey, publicKey) => sodium.crypto_scalarmult?.(secretKey, publicKey) ?? n.scalarMult(secretKey, publicKey) : (secretKey, publicKey) => n.scalarMult(secretKey, publicKey),
|
|
162
|
+
boxEasy: (m, nonce, publicKey, secretKey) => sodium.crypto_box_easy(m, nonce, publicKey, secretKey),
|
|
163
|
+
boxOpenEasy: (box, nonce, publicKey, secretKey) => sodium.crypto_box_open_easy(box, nonce, publicKey, secretKey),
|
|
164
|
+
aeadEncrypt: aeadNative ? (plaintext, a, nonce, key) => sodium.crypto_aead_xchacha20poly1305_ietf_encrypt?.(plaintext, a, null, nonce, key) ?? n.aeadEncrypt(plaintext, a, nonce, key) : (plaintext, a, nonce, key) => n.aeadEncrypt(plaintext, a, nonce, key),
|
|
165
|
+
aeadDecrypt: aeadNative ? (c, a, nonce, key) => sodium.crypto_aead_xchacha20poly1305_ietf_decrypt?.(null, c, a, nonce, key) ?? n.aeadDecrypt(c, a, nonce, key) : (c, a, nonce, key) => n.aeadDecrypt(c, a, nonce, key),
|
|
166
|
+
memzero: memzeroNative ? (bytes) => sodium.memzero?.(bytes) : (bytes) => bytes.fill(0)
|
|
167
|
+
};
|
|
168
|
+
return { backend, report: { native, portable } };
|
|
169
|
+
}
|
package/src/errors.ts
CHANGED
|
@@ -52,6 +52,7 @@ export type ErrorCode =
|
|
|
52
52
|
| "CLOSED"
|
|
53
53
|
| "RECOVERY_REQUIRED"
|
|
54
54
|
| "STORAGE_FAILURE"
|
|
55
|
+
| "RATE_LIMITED"
|
|
55
56
|
/** The host could not be reached or did not answer in time; the same call may succeed later. A transport raises it, never a plain error. */
|
|
56
57
|
| "NETWORK";
|
|
57
58
|
|
|
@@ -63,6 +64,7 @@ const RETRYABLE: ReadonlySet<ErrorCode> = new Set<ErrorCode>([
|
|
|
63
64
|
"LOCKED",
|
|
64
65
|
"STORAGE_FAILURE",
|
|
65
66
|
"NETWORK",
|
|
67
|
+
"RATE_LIMITED",
|
|
66
68
|
"SUCCESSION_PENDING"
|
|
67
69
|
]);
|
|
68
70
|
|
|
@@ -75,13 +77,16 @@ export class KavachError extends Error {
|
|
|
75
77
|
readonly code: ErrorCode;
|
|
76
78
|
readonly stage: string;
|
|
77
79
|
readonly retryable: boolean;
|
|
80
|
+
/** On `RATE_LIMITED`: how long the host asked the client to wait, when it said. */
|
|
81
|
+
readonly retryAfterMs: number | undefined;
|
|
78
82
|
|
|
79
|
-
constructor(code: ErrorCode, stage = "unknown", message?: string) {
|
|
83
|
+
constructor(code: ErrorCode, stage = "unknown", message?: string, options: { retryAfterMs?: number } = {}) {
|
|
80
84
|
super(message ?? `${code} at ${stage}`);
|
|
81
85
|
this.name = "KavachError";
|
|
82
86
|
this.code = code;
|
|
83
87
|
this.stage = stage;
|
|
84
88
|
this.retryable = RETRYABLE.has(code);
|
|
89
|
+
this.retryAfterMs = options.retryAfterMs;
|
|
85
90
|
}
|
|
86
91
|
}
|
|
87
92
|
|
package/src/inbox.ts
CHANGED
|
@@ -191,6 +191,10 @@ export interface StreamSyncSummary {
|
|
|
191
191
|
acked: bigint | undefined;
|
|
192
192
|
/** The relay holds more records past the last page read; the budget stopped the catch-up. */
|
|
193
193
|
hasMore: boolean;
|
|
194
|
+
/** Record bytes read from the relay. */
|
|
195
|
+
bytes: number;
|
|
196
|
+
/** Set when a read failed after the pages counted above: the caller reports it, with what landed. */
|
|
197
|
+
failure?: unknown;
|
|
194
198
|
}
|
|
195
199
|
|
|
196
200
|
function subscription(tx: Pick<Tx, "get">, streamId: Uint8Array, authorIdentityId: Uint8Array): SubscriptionRow {
|
|
@@ -236,23 +240,29 @@ export interface ReadBudget {
|
|
|
236
240
|
* records past the last page when the budget stopped the loop.
|
|
237
241
|
*/
|
|
238
242
|
export async function syncStream(deps: InboxDeps, streamId: Uint8Array, authorIdentityId: Uint8Array, budget: ReadBudget = { remainingRecords: READ_BATCH, deadline: deps.now() }): Promise<StreamSyncSummary> {
|
|
239
|
-
const summary: StreamSyncSummary = { read: 0, stored: 0, duplicate: 0, keyPending: 0, invalid: 0, expired: 0, acked: undefined, hasMore: false };
|
|
243
|
+
const summary: StreamSyncSummary = { read: 0, stored: 0, duplicate: 0, keyPending: 0, invalid: 0, expired: 0, acked: undefined, hasMore: false, bytes: 0 };
|
|
240
244
|
const sub = await deps.store.read((tx) => subscription(tx, streamId, authorIdentityId));
|
|
241
245
|
const unresolved = await deps.store.read((tx) => tx.list("unresolved", toHex(streamId) + ":"));
|
|
242
246
|
let from = unresolved.length > 0 ? unbig(sub.ackPrefix) : unbig(sub.scanPos);
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
247
|
+
try {
|
|
248
|
+
for (;;) {
|
|
249
|
+
// A failed read is the caller's to report: the sync names the stream in its report and goes
|
|
250
|
+
// on to the next one, rather than counting a stream it never read as synced.
|
|
251
|
+
const page = await deps.relay.readRecords(streamId, from, READ_BATCH);
|
|
252
|
+
await processPage(deps, streamId, authorIdentityId, page, summary);
|
|
253
|
+
budget.remainingRecords -= page.records.length;
|
|
254
|
+
const caughtUp = page.nextSeq >= page.tail || page.nextSeq <= from;
|
|
255
|
+
if (caughtUp) break;
|
|
256
|
+
if (budget.remainingRecords <= 0 || deps.now() >= budget.deadline) {
|
|
257
|
+
summary.hasMore = true;
|
|
258
|
+
break;
|
|
259
|
+
}
|
|
260
|
+
from = page.nextSeq;
|
|
254
261
|
}
|
|
255
|
-
|
|
262
|
+
} catch (e) {
|
|
263
|
+
// The pages that landed before the failure are real work: the summary keeps them and carries
|
|
264
|
+
// the failure beside them, untouched, for the caller to report.
|
|
265
|
+
summary.failure = e;
|
|
256
266
|
}
|
|
257
267
|
return summary;
|
|
258
268
|
}
|
|
@@ -269,6 +279,7 @@ async function processPage(deps: InboxDeps, streamId: Uint8Array, authorIdentity
|
|
|
269
279
|
}
|
|
270
280
|
for (const item of page.records) {
|
|
271
281
|
summary.read++;
|
|
282
|
+
summary.bytes += item.wire.length;
|
|
272
283
|
if (item.deliverySeq <= page.expiredThrough) {
|
|
273
284
|
summary.expired++;
|
|
274
285
|
continue;
|
package/src/index.ts
CHANGED
|
@@ -36,7 +36,8 @@ export {
|
|
|
36
36
|
type ReplacementProposal,
|
|
37
37
|
type SyncError,
|
|
38
38
|
type SyncOptions,
|
|
39
|
-
type SyncReport
|
|
39
|
+
type SyncReport,
|
|
40
|
+
type SyncTimings
|
|
40
41
|
} from "./client.js";
|
|
41
42
|
|
|
42
43
|
// Errors -------------------------------------------------------------------------------------------
|
package/src/outbox.ts
CHANGED
|
@@ -199,6 +199,10 @@ export interface DrainSummary {
|
|
|
199
199
|
cancelled: number;
|
|
200
200
|
unknown: number;
|
|
201
201
|
retry: number;
|
|
202
|
+
/** Record bytes handed to the relay by this drain. */
|
|
203
|
+
bytes: number;
|
|
204
|
+
/** The error that ended the drain early, when a send failed before or without an answer (NETWORK, RATE_LIMITED); its record stays queued. */
|
|
205
|
+
blocked?: KavachError;
|
|
202
206
|
}
|
|
203
207
|
|
|
204
208
|
/** "none": nothing to send, the drain ends. "skipped": a row was cancelled, the next one is tried. */
|
|
@@ -211,17 +215,19 @@ type HandoffStep = { kind: "none" } | { kind: "skipped" } | { kind: "sent"; key:
|
|
|
211
215
|
* revalidates first.
|
|
212
216
|
*/
|
|
213
217
|
export async function drain(deps: OutboxDeps, maxRecords = 1000): Promise<DrainSummary> {
|
|
214
|
-
const summary: DrainSummary = { sent: 0, accepted: 0, cancelled: 0, unknown: 0, retry: 0 };
|
|
218
|
+
const summary: DrainSummary = { sent: 0, accepted: 0, cancelled: 0, unknown: 0, retry: 0, bytes: 0 };
|
|
215
219
|
for (let i = 0; i < maxRecords; i++) {
|
|
216
220
|
const step = await handoffNext(deps, summary);
|
|
217
221
|
if (step.kind === "none") break;
|
|
218
222
|
if (step.kind === "skipped") continue; // a cancelled row counts against the limit, never ends the drain
|
|
219
223
|
summary.sent++;
|
|
224
|
+
summary.bytes += step.row.wire.length / 2;
|
|
220
225
|
let outcome: AppendOutcome | undefined;
|
|
221
226
|
try {
|
|
222
227
|
outcome = await step.outcome;
|
|
223
|
-
} catch {
|
|
224
|
-
outcome = undefined; //
|
|
228
|
+
} catch (e) {
|
|
229
|
+
outcome = undefined; // no answer: the identical bytes are retried, the relay answers from its checkpoint
|
|
230
|
+
if (e instanceof KavachError) summary.blocked ??= e; // and the reason reaches the report
|
|
225
231
|
}
|
|
226
232
|
const final = await recordOutcome(deps, step.key, outcome);
|
|
227
233
|
if (final === "accepted") summary.accepted++;
|
package/src/testing/faults.ts
CHANGED
|
@@ -113,6 +113,10 @@ export interface MaliciousKnobs {
|
|
|
113
113
|
readonly hold: Map<MutationName, Promise<void>>;
|
|
114
114
|
/** Called when a held mutation has reached its gate (the host has answered; the response is being held), so a test can act at exactly that point. */
|
|
115
115
|
onHeld?: (name: MutationName) => void;
|
|
116
|
+
/** Mutations whose next call fails with exactly this error before reaching the host (consumed once). */
|
|
117
|
+
readonly failWith: Map<MutationName, KavachError>;
|
|
118
|
+
/** Record reads fail once with NETWORK after this many pages have been served. */
|
|
119
|
+
failReadsAfter?: number | undefined;
|
|
116
120
|
/** Reads that answer "nothing" while present. */
|
|
117
121
|
readonly withhold: Set<"snapshots" | "epochs" | "records" | "requests" | "currentEpoch" | "keyList" | "certificates" | "successions" | "circleList" | "subscriptions">;
|
|
118
122
|
/** Older documents served instead of the current ones. */
|
|
@@ -142,6 +146,7 @@ export function emptyKnobs(): MaliciousKnobs {
|
|
|
142
146
|
loseResponseOf: new Set(),
|
|
143
147
|
refuse: new Set(),
|
|
144
148
|
hold: new Map(),
|
|
149
|
+
failWith: new Map(),
|
|
145
150
|
withhold: new Set(),
|
|
146
151
|
replay: { snapshots: new Map() },
|
|
147
152
|
fork: { snapshots: new Map(), manifests: new Map(), records: new Map(), certificates: new Map() },
|
|
@@ -298,6 +303,11 @@ export class MaliciousTransport implements Transport {
|
|
|
298
303
|
appendRecord: (recordId, wire) => {
|
|
299
304
|
// Must stay synchronous up to the inner call (T6h).
|
|
300
305
|
this.calls.push("appendRecord");
|
|
306
|
+
const fail = k.failWith.get("appendRecord");
|
|
307
|
+
if (fail) {
|
|
308
|
+
k.failWith.delete("appendRecord");
|
|
309
|
+
return Promise.reject(fail);
|
|
310
|
+
}
|
|
301
311
|
const p = r().appendRecord(recordId, wire);
|
|
302
312
|
if (!k.loseResponseOf.has("appendRecord")) return p;
|
|
303
313
|
k.loseResponseOf.delete("appendRecord");
|
|
@@ -308,6 +318,13 @@ export class MaliciousTransport implements Transport {
|
|
|
308
318
|
},
|
|
309
319
|
readRecords: async (streamId, afterSeq, limit): Promise<ReadRecordsResult> => {
|
|
310
320
|
this.calls.push("readRecords");
|
|
321
|
+
if (k.failReadsAfter !== undefined) {
|
|
322
|
+
if (k.failReadsAfter <= 0) {
|
|
323
|
+
k.failReadsAfter = undefined;
|
|
324
|
+
throw Object.freeze(new KavachError("NETWORK", "transport", "network unreachable")); // frozen, as a transport may hand it over
|
|
325
|
+
}
|
|
326
|
+
k.failReadsAfter--;
|
|
327
|
+
}
|
|
311
328
|
if (k.withhold.has("records")) {
|
|
312
329
|
const probe = await r().readRecords(streamId, afterSeq, limit);
|
|
313
330
|
return { ...probe, records: [], nextSeq: afterSeq };
|