@freqhole/reliquary 0.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.
- package/dist/automerge/acl-filtering.d.ts +87 -0
- package/dist/automerge/acl-filtering.d.ts.map +1 -0
- package/dist/automerge/acl-filtering.js +138 -0
- package/dist/automerge/acl-filtering.test.d.ts +2 -0
- package/dist/automerge/acl-filtering.test.d.ts.map +1 -0
- package/dist/automerge/acl-filtering.test.js +302 -0
- package/dist/automerge/index.d.ts +5 -0
- package/dist/automerge/index.d.ts.map +1 -0
- package/dist/automerge/index.js +7 -0
- package/dist/automerge/iroh-network-adapter.d.ts +178 -0
- package/dist/automerge/iroh-network-adapter.d.ts.map +1 -0
- package/dist/automerge/iroh-network-adapter.js +691 -0
- package/dist/automerge/iroh-network-adapter.test.d.ts +2 -0
- package/dist/automerge/iroh-network-adapter.test.d.ts.map +1 -0
- package/dist/automerge/iroh-network-adapter.test.js +818 -0
- package/dist/automerge/types.d.ts +66 -0
- package/dist/automerge/types.d.ts.map +1 -0
- package/dist/automerge/types.js +10 -0
- package/dist/blobs/bytes-backend.d.ts +41 -0
- package/dist/blobs/bytes-backend.d.ts.map +1 -0
- package/dist/blobs/bytes-backend.js +252 -0
- package/dist/blobs/bytes-backend.test.d.ts +2 -0
- package/dist/blobs/bytes-backend.test.d.ts.map +1 -0
- package/dist/blobs/bytes-backend.test.js +241 -0
- package/dist/blobs/db.d.ts +8 -0
- package/dist/blobs/db.d.ts.map +1 -0
- package/dist/blobs/db.js +141 -0
- package/dist/blobs/db.test.d.ts +2 -0
- package/dist/blobs/db.test.d.ts.map +1 -0
- package/dist/blobs/db.test.js +120 -0
- package/dist/blobs/index.d.ts +5 -0
- package/dist/blobs/index.d.ts.map +1 -0
- package/dist/blobs/index.js +2 -0
- package/dist/blobs/store.d.ts +47 -0
- package/dist/blobs/store.d.ts.map +1 -0
- package/dist/blobs/store.js +263 -0
- package/dist/blobs/store.test.d.ts +2 -0
- package/dist/blobs/store.test.d.ts.map +1 -0
- package/dist/blobs/store.test.js +377 -0
- package/dist/blobs/types.d.ts +55 -0
- package/dist/blobs/types.d.ts.map +1 -0
- package/dist/blobs/types.js +8 -0
- package/dist/ensure/client.d.ts +19 -0
- package/dist/ensure/client.d.ts.map +1 -0
- package/dist/ensure/client.js +74 -0
- package/dist/ensure/client.test.d.ts +2 -0
- package/dist/ensure/client.test.d.ts.map +1 -0
- package/dist/ensure/client.test.js +133 -0
- package/dist/ensure/index.d.ts +4 -0
- package/dist/ensure/index.d.ts.map +1 -0
- package/dist/ensure/index.js +4 -0
- package/dist/ensure/responder.d.ts +20 -0
- package/dist/ensure/responder.d.ts.map +1 -0
- package/dist/ensure/responder.js +100 -0
- package/dist/ensure/responder.test.d.ts +2 -0
- package/dist/ensure/responder.test.d.ts.map +1 -0
- package/dist/ensure/responder.test.js +191 -0
- package/dist/ensure/types.d.ts +19 -0
- package/dist/ensure/types.d.ts.map +1 -0
- package/dist/ensure/types.js +4 -0
- package/dist/ensure/types.test.d.ts +2 -0
- package/dist/ensure/types.test.d.ts.map +1 -0
- package/dist/ensure/types.test.js +81 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.test.d.ts +2 -0
- package/dist/index.test.d.ts.map +1 -0
- package/dist/index.test.js +7 -0
- package/dist/solid/blob-url.d.ts +10 -0
- package/dist/solid/blob-url.d.ts.map +1 -0
- package/dist/solid/blob-url.js +34 -0
- package/dist/solid/blob-url.test.d.ts +2 -0
- package/dist/solid/blob-url.test.d.ts.map +1 -0
- package/dist/solid/blob-url.test.js +62 -0
- package/dist/solid/connection-summary.d.ts +25 -0
- package/dist/solid/connection-summary.d.ts.map +1 -0
- package/dist/solid/connection-summary.js +29 -0
- package/dist/solid/connection-summary.test.d.ts +2 -0
- package/dist/solid/connection-summary.test.d.ts.map +1 -0
- package/dist/solid/connection-summary.test.js +57 -0
- package/dist/solid/doc-store.d.ts +29 -0
- package/dist/solid/doc-store.d.ts.map +1 -0
- package/dist/solid/doc-store.js +79 -0
- package/dist/solid/doc-store.test.d.ts +2 -0
- package/dist/solid/doc-store.test.d.ts.map +1 -0
- package/dist/solid/doc-store.test.js +234 -0
- package/dist/solid/index.d.ts +4 -0
- package/dist/solid/index.d.ts.map +1 -0
- package/dist/solid/index.js +9 -0
- package/dist/solid/transfer-progress.d.ts +21 -0
- package/dist/solid/transfer-progress.d.ts.map +1 -0
- package/dist/solid/transfer-progress.js +32 -0
- package/dist/solid/transfer-progress.test.d.ts +2 -0
- package/dist/solid/transfer-progress.test.d.ts.map +1 -0
- package/dist/solid/transfer-progress.test.js +76 -0
- package/dist/testing/blob-fixtures.d.ts +19 -0
- package/dist/testing/blob-fixtures.d.ts.map +1 -0
- package/dist/testing/blob-fixtures.js +46 -0
- package/dist/testing/blob-fixtures.test.d.ts +2 -0
- package/dist/testing/blob-fixtures.test.d.ts.map +1 -0
- package/dist/testing/blob-fixtures.test.js +46 -0
- package/dist/testing/idb-harness.d.ts +10 -0
- package/dist/testing/idb-harness.d.ts.map +1 -0
- package/dist/testing/idb-harness.js +17 -0
- package/dist/testing/idb-harness.test.d.ts +2 -0
- package/dist/testing/idb-harness.test.d.ts.map +1 -0
- package/dist/testing/idb-harness.test.js +48 -0
- package/dist/testing/index.d.ts +7 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +11 -0
- package/dist/testing/media.d.ts +15 -0
- package/dist/testing/media.d.ts.map +1 -0
- package/dist/testing/media.js +37 -0
- package/dist/testing/media.test.d.ts +2 -0
- package/dist/testing/media.test.d.ts.map +1 -0
- package/dist/testing/media.test.js +47 -0
- package/dist/testing/mock-blob-fetch.d.ts +33 -0
- package/dist/testing/mock-blob-fetch.d.ts.map +1 -0
- package/dist/testing/mock-blob-fetch.js +39 -0
- package/dist/testing/mock-blob-fetch.test.d.ts +2 -0
- package/dist/testing/mock-blob-fetch.test.d.ts.map +1 -0
- package/dist/testing/mock-blob-fetch.test.js +65 -0
- package/dist/testing/mock-node.d.ts +36 -0
- package/dist/testing/mock-node.d.ts.map +1 -0
- package/dist/testing/mock-node.js +97 -0
- package/dist/testing/mock-node.test.d.ts +2 -0
- package/dist/testing/mock-node.test.d.ts.map +1 -0
- package/dist/testing/mock-node.test.js +66 -0
- package/dist/testing/streams.d.ts +68 -0
- package/dist/testing/streams.d.ts.map +1 -0
- package/dist/testing/streams.js +99 -0
- package/dist/testing/streams.test.d.ts +2 -0
- package/dist/testing/streams.test.d.ts.map +1 -0
- package/dist/testing/streams.test.js +82 -0
- package/dist/transfer/cancellation.d.ts +8 -0
- package/dist/transfer/cancellation.d.ts.map +1 -0
- package/dist/transfer/cancellation.js +19 -0
- package/dist/transfer/index.d.ts +8 -0
- package/dist/transfer/index.d.ts.map +1 -0
- package/dist/transfer/index.js +4 -0
- package/dist/transfer/prefetch.d.ts +38 -0
- package/dist/transfer/prefetch.d.ts.map +1 -0
- package/dist/transfer/prefetch.js +61 -0
- package/dist/transfer/prefetch.test.d.ts +2 -0
- package/dist/transfer/prefetch.test.d.ts.map +1 -0
- package/dist/transfer/prefetch.test.js +136 -0
- package/dist/transfer/serve.d.ts +66 -0
- package/dist/transfer/serve.d.ts.map +1 -0
- package/dist/transfer/serve.js +126 -0
- package/dist/transfer/serve.test.d.ts +2 -0
- package/dist/transfer/serve.test.d.ts.map +1 -0
- package/dist/transfer/serve.test.js +101 -0
- package/dist/transfer/snatch.d.ts +39 -0
- package/dist/transfer/snatch.d.ts.map +1 -0
- package/dist/transfer/snatch.js +289 -0
- package/dist/transfer/snatch.test.d.ts +2 -0
- package/dist/transfer/snatch.test.d.ts.map +1 -0
- package/dist/transfer/snatch.test.js +337 -0
- package/dist/transfer/types.d.ts +85 -0
- package/dist/transfer/types.d.ts.map +1 -0
- package/dist/transfer/types.js +15 -0
- package/dist/utils/hash.d.ts +13 -0
- package/dist/utils/hash.d.ts.map +1 -0
- package/dist/utils/hash.js +31 -0
- package/dist/utils/hash.test.d.ts +2 -0
- package/dist/utils/hash.test.d.ts.map +1 -0
- package/dist/utils/hash.test.js +56 -0
- package/dist/utils/image-utils.d.ts +28 -0
- package/dist/utils/image-utils.d.ts.map +1 -0
- package/dist/utils/image-utils.js +113 -0
- package/dist/utils/image-utils.test.d.ts +2 -0
- package/dist/utils/image-utils.test.d.ts.map +1 -0
- package/dist/utils/image-utils.test.js +181 -0
- package/dist/utils/index.d.ts +4 -0
- package/dist/utils/index.d.ts.map +1 -0
- package/dist/utils/index.js +3 -0
- package/dist/utils/log.d.ts +22 -0
- package/dist/utils/log.d.ts.map +1 -0
- package/dist/utils/log.js +100 -0
- package/dist/utils/log.test.d.ts +2 -0
- package/dist/utils/log.test.d.ts.map +1 -0
- package/dist/utils/log.test.js +113 -0
- package/dist/worker/blob-worker-client.d.ts +118 -0
- package/dist/worker/blob-worker-client.d.ts.map +1 -0
- package/dist/worker/blob-worker-client.js +361 -0
- package/dist/worker/blob-worker-client.test.d.ts +2 -0
- package/dist/worker/blob-worker-client.test.d.ts.map +1 -0
- package/dist/worker/blob-worker-client.test.js +137 -0
- package/dist/worker/blob-worker-logic.d.ts +120 -0
- package/dist/worker/blob-worker-logic.d.ts.map +1 -0
- package/dist/worker/blob-worker-logic.js +368 -0
- package/dist/worker/blob-worker-logic.test.d.ts +2 -0
- package/dist/worker/blob-worker-logic.test.d.ts.map +1 -0
- package/dist/worker/blob-worker-logic.test.js +191 -0
- package/dist/worker/blob-worker.d.ts +21 -0
- package/dist/worker/blob-worker.d.ts.map +1 -0
- package/dist/worker/blob-worker.js +44 -0
- package/dist/worker/index.d.ts +12 -0
- package/dist/worker/index.d.ts.map +1 -0
- package/dist/worker/index.js +6 -0
- package/dist/worker/midden-blake3.d.ts +23 -0
- package/dist/worker/midden-blake3.d.ts.map +1 -0
- package/dist/worker/midden-blake3.js +47 -0
- package/dist/worker/midden-blake3.test.d.ts +2 -0
- package/dist/worker/midden-blake3.test.d.ts.map +1 -0
- package/dist/worker/midden-blake3.test.js +25 -0
- package/dist/worker/midden-not-bundled.stub.d.ts +1 -0
- package/dist/worker/midden-not-bundled.stub.d.ts.map +1 -0
- package/dist/worker/midden-not-bundled.stub.js +10 -0
- package/dist/worker/midden-stub.d.ts +10 -0
- package/dist/worker/midden-stub.d.ts.map +1 -0
- package/dist/worker/midden-stub.js +31 -0
- package/dist/worker/midden-stub.test.d.ts +2 -0
- package/dist/worker/midden-stub.test.d.ts.map +1 -0
- package/dist/worker/midden-stub.test.js +25 -0
- package/dist/worker/midden-worker-client.d.ts +103 -0
- package/dist/worker/midden-worker-client.d.ts.map +1 -0
- package/dist/worker/midden-worker-client.js +246 -0
- package/dist/worker/midden-worker-client.test.d.ts +2 -0
- package/dist/worker/midden-worker-client.test.d.ts.map +1 -0
- package/dist/worker/midden-worker-client.test.js +151 -0
- package/dist/worker/midden-worker-contract.d.ts +87 -0
- package/dist/worker/midden-worker-contract.d.ts.map +1 -0
- package/dist/worker/midden-worker-contract.js +30 -0
- package/package.json +90 -0
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
// p2p blob download: snatch a blob from a peer, either into memory or
|
|
2
|
+
// straight to a user-chosen disk location.
|
|
3
|
+
//
|
|
4
|
+
// per-peer retry loop tries three strategies in order against each peer:
|
|
5
|
+
// bulk verified download with progress, chunk-streamed verified download,
|
|
6
|
+
// and an unverified base64 proxy fallback. a strategy is skipped when its
|
|
7
|
+
// node method is missing, and falls through to the next when it FAILS
|
|
8
|
+
// against that peer - a peer whose backend only accepts an app-level rpc
|
|
9
|
+
// alpn makes the verified strategies fail even though the local node has
|
|
10
|
+
// the methods, and only the proxy fallback can reach it. a cancelled
|
|
11
|
+
// error (see `cancellation.ts`) always rethrows immediately - it is never
|
|
12
|
+
// treated as "this strategy/peer failed, try the next one".
|
|
13
|
+
import { log } from "../utils/log.js";
|
|
14
|
+
import { hashBlake3 } from "../worker/index.js";
|
|
15
|
+
import { isCancelledError } from "./cancellation.js";
|
|
16
|
+
const TAG = "transfer.snatch";
|
|
17
|
+
const DEFAULT_TIMEOUT_MS = 10 * 60_000;
|
|
18
|
+
/** how long to wait for trailing chunk-callback messages to arrive after
|
|
19
|
+
* the download rpc's own promise has already resolved (see
|
|
20
|
+
* `waitForBytesReceived` below). */
|
|
21
|
+
const TAIL_CHUNK_WAIT_DEADLINE_MS = 30_000;
|
|
22
|
+
const TAIL_CHUNK_POLL_INTERVAL_MS = 25;
|
|
23
|
+
function withTimeout(promise, ms) {
|
|
24
|
+
let timer;
|
|
25
|
+
const timeout = new Promise((_resolve, reject) => {
|
|
26
|
+
timer = setTimeout(() => reject(new Error("peer download timed out")), ms);
|
|
27
|
+
});
|
|
28
|
+
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
|
|
29
|
+
}
|
|
30
|
+
function throwIfAborted(signal) {
|
|
31
|
+
if (signal?.aborted)
|
|
32
|
+
throw new DOMException("snatch cancelled", "AbortError");
|
|
33
|
+
}
|
|
34
|
+
function base64ToBytes(b64) {
|
|
35
|
+
const binary = atob(b64);
|
|
36
|
+
const bytes = new Uint8Array(binary.length);
|
|
37
|
+
for (let i = 0; i < binary.length; i++)
|
|
38
|
+
bytes[i] = binary.charCodeAt(i);
|
|
39
|
+
return bytes;
|
|
40
|
+
}
|
|
41
|
+
/** a worker-hosted node's chunk callback fires as its own fire-and-forget
|
|
42
|
+
* message, on a separate channel from the download rpc's return value -
|
|
43
|
+
* the rpc promise can resolve while the last few chunk messages are
|
|
44
|
+
* still in flight. wait until every byte has actually landed before
|
|
45
|
+
* treating the transfer as complete (this exact race lost the tail of a
|
|
46
|
+
* large transfer once; the wait is load-bearing, not defensive fluff). */
|
|
47
|
+
async function waitForBytesReceived(getReceived, total) {
|
|
48
|
+
const deadline = Date.now() + TAIL_CHUNK_WAIT_DEADLINE_MS;
|
|
49
|
+
while (getReceived() < total && Date.now() < deadline) {
|
|
50
|
+
await new Promise((resolve) => setTimeout(resolve, TAIL_CHUNK_POLL_INTERVAL_MS));
|
|
51
|
+
}
|
|
52
|
+
if (getReceived() < total) {
|
|
53
|
+
throw new Error(`chunk stream incomplete: received ${getReceived()} of ${total} bytes`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function parseFlatProxyResponse(body) {
|
|
57
|
+
const parsed = JSON.parse(body);
|
|
58
|
+
if (typeof parsed.data !== "string")
|
|
59
|
+
return null;
|
|
60
|
+
return { data: parsed.data, mime: parsed.mime };
|
|
61
|
+
}
|
|
62
|
+
async function base64ProxyFallback(node, peerAddr, info, options, timeoutMs) {
|
|
63
|
+
if (!node.proxy_request || !options.proxyPath)
|
|
64
|
+
return null;
|
|
65
|
+
const path = options.proxyPath(info.id ?? info.blake3);
|
|
66
|
+
const resp = await withTimeout(node.proxy_request(peerAddr, "GET", path, null), timeoutMs);
|
|
67
|
+
if (resp.status !== 200)
|
|
68
|
+
return null;
|
|
69
|
+
const parse = options.parseProxyResponse ?? parseFlatProxyResponse;
|
|
70
|
+
const parsed = parse(resp.body);
|
|
71
|
+
if (!parsed)
|
|
72
|
+
return null;
|
|
73
|
+
const bytes = base64ToBytes(parsed.data);
|
|
74
|
+
options.onProgress?.(1);
|
|
75
|
+
// strategies 1/2 get cryptographic verification for free - the
|
|
76
|
+
// transport checks every chunk against the requested hash's bao tree
|
|
77
|
+
// during the download itself. this fallback is a plain base64 json
|
|
78
|
+
// response with zero transfer-level integrity checking, so verify
|
|
79
|
+
// explicitly: a corrupted or malicious response must be rejected, not
|
|
80
|
+
// silently accepted and persisted under the wrong hash.
|
|
81
|
+
const actualHash = await hashBlake3(bytes);
|
|
82
|
+
if (actualHash !== info.blake3) {
|
|
83
|
+
throw new Error(`snatch hash mismatch: expected blake3 ${info.blake3.slice(0, 16)}... but downloaded bytes hash to ${actualHash.slice(0, 16)}... (proxy fallback is not cryptographically verified in transit)`);
|
|
84
|
+
}
|
|
85
|
+
return { bytes, blake3: info.blake3, mime: parsed.mime ?? info.mime };
|
|
86
|
+
}
|
|
87
|
+
async function snatchFromPeer(node, peerAddr, info, options, timeoutMs) {
|
|
88
|
+
let lastError;
|
|
89
|
+
// strategy 1: bulk verified download.
|
|
90
|
+
if (node.download_verified_with_ensure_progress) {
|
|
91
|
+
try {
|
|
92
|
+
const bytes = await withTimeout(node.download_verified_with_ensure_progress(peerAddr, info.blake3, info.size, options.onProgress ?? (() => { }), options.downloadId), timeoutMs);
|
|
93
|
+
return { bytes, blake3: info.blake3, mime: info.mime };
|
|
94
|
+
}
|
|
95
|
+
catch (err) {
|
|
96
|
+
// a deliberate pause must not fall through to the other strategies
|
|
97
|
+
if (isCancelledError(err))
|
|
98
|
+
throw err;
|
|
99
|
+
lastError = err;
|
|
100
|
+
log.debug(TAG, `strategy 1 (bulk verified) failed against ${peerAddr.slice(0, 16)}...:`, err);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
// strategy 2: chunk-streamed verified download, accumulated in memory.
|
|
104
|
+
if (node.download_verified_streaming_with_ensure) {
|
|
105
|
+
try {
|
|
106
|
+
const chunks = [];
|
|
107
|
+
let bytesReceived = 0;
|
|
108
|
+
const onChunk = (chunk, offset) => {
|
|
109
|
+
bytesReceived += chunk.length;
|
|
110
|
+
chunks.push({ offset, data: chunk });
|
|
111
|
+
};
|
|
112
|
+
const total = await withTimeout(node.download_verified_streaming_with_ensure(peerAddr, info.blake3, info.size, onChunk, options.onProgress ?? (() => { }), options.downloadId), timeoutMs);
|
|
113
|
+
await waitForBytesReceived(() => bytesReceived, total);
|
|
114
|
+
const buffer = new Uint8Array(total);
|
|
115
|
+
for (const { offset, data } of chunks)
|
|
116
|
+
buffer.set(data, offset);
|
|
117
|
+
return { bytes: buffer, blake3: info.blake3, mime: info.mime };
|
|
118
|
+
}
|
|
119
|
+
catch (err) {
|
|
120
|
+
if (isCancelledError(err))
|
|
121
|
+
throw err;
|
|
122
|
+
lastError = err;
|
|
123
|
+
log.debug(TAG, `strategy 2 (streamed verified) failed against ${peerAddr.slice(0, 16)}...:`, err);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
// strategy 3: unverified base64 proxy fallback. its own failures
|
|
127
|
+
// (non-200, bad envelope, hash mismatch) count as this peer failing.
|
|
128
|
+
const proxyAttempted = !!(node.proxy_request && options.proxyPath);
|
|
129
|
+
const fallback = await base64ProxyFallback(node, peerAddr, info, options, timeoutMs);
|
|
130
|
+
if (fallback)
|
|
131
|
+
return fallback;
|
|
132
|
+
if (proxyAttempted && !lastError) {
|
|
133
|
+
lastError = new Error("proxy fallback returned no blob");
|
|
134
|
+
}
|
|
135
|
+
throw lastError ?? new Error("peer has no supported download strategy");
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* download a blob's bytes from the first peer (in `peerNodeIds` order)
|
|
139
|
+
* that can serve it. a peer failure (offline, transport error, hash
|
|
140
|
+
* mismatch) tries the next peer; a cancelled error (see `cancellation.ts`)
|
|
141
|
+
* rethrows immediately with no next-peer retry.
|
|
142
|
+
*/
|
|
143
|
+
export async function snatchBlob(node, peerNodeIds, info, options = {}) {
|
|
144
|
+
if (peerNodeIds.length === 0) {
|
|
145
|
+
throw new Error("no peers available for snatch");
|
|
146
|
+
}
|
|
147
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
148
|
+
let lastError;
|
|
149
|
+
for (const peerAddr of peerNodeIds) {
|
|
150
|
+
throwIfAborted(options.signal);
|
|
151
|
+
try {
|
|
152
|
+
const result = await snatchFromPeer(node, peerAddr, info, options, timeoutMs);
|
|
153
|
+
if (result.bytes.length === 0) {
|
|
154
|
+
throw new Error("snatch returned 0 bytes - refusing empty payload");
|
|
155
|
+
}
|
|
156
|
+
return result;
|
|
157
|
+
}
|
|
158
|
+
catch (err) {
|
|
159
|
+
if (isCancelledError(err))
|
|
160
|
+
throw err;
|
|
161
|
+
lastError = err;
|
|
162
|
+
log.warn(TAG, `snatch from peer ${peerAddr.slice(0, 16)}... failed:`, err);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
throw lastError ?? new Error("snatch failed: all peers exhausted");
|
|
166
|
+
}
|
|
167
|
+
async function streamToWritable(node, peerAddr, info, writable, options, timeoutMs) {
|
|
168
|
+
// the File System Access API requires ordered, awaited writes; the
|
|
169
|
+
// transport's chunk callback fires synchronously and must not await, so
|
|
170
|
+
// writes are chained here and drained once the transfer settles.
|
|
171
|
+
let writeChain = Promise.resolve();
|
|
172
|
+
let writeError = null;
|
|
173
|
+
let bytesReceived = 0;
|
|
174
|
+
const onChunk = (chunk, offset) => {
|
|
175
|
+
bytesReceived += chunk.length;
|
|
176
|
+
if (writeError)
|
|
177
|
+
return; // stop queueing after the first failure
|
|
178
|
+
writeChain = writeChain.then(async () => {
|
|
179
|
+
if (writeError)
|
|
180
|
+
return;
|
|
181
|
+
try {
|
|
182
|
+
await writable.write({ type: "write", position: offset, data: chunk });
|
|
183
|
+
}
|
|
184
|
+
catch (err) {
|
|
185
|
+
writeError = err;
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
};
|
|
189
|
+
const total = await withTimeout(node.download_verified_streaming_with_ensure(peerAddr, info.blake3, info.size, onChunk, options.onProgress ?? (() => { }), options.downloadId), timeoutMs);
|
|
190
|
+
await waitForBytesReceived(() => bytesReceived, total);
|
|
191
|
+
await writeChain;
|
|
192
|
+
if (writeError)
|
|
193
|
+
throw writeError;
|
|
194
|
+
return total;
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* download a blob straight to a user-chosen disk location, writing each
|
|
198
|
+
* verified chunk to `writable` at its explicit offset as it streams in
|
|
199
|
+
* (no full payload ever held in memory). falls back to a fully-buffered
|
|
200
|
+
* `snatchBlob` + single write when the node has no streaming strategy.
|
|
201
|
+
*
|
|
202
|
+
* on a mid-stream peer failure, `writable` is truncated back to zero
|
|
203
|
+
* before the next peer is tried, so a retry never appends onto partial
|
|
204
|
+
* data. on a cancelled error, `writable` is left exactly as it is (no
|
|
205
|
+
* truncate, no close) so a resumed snatch can rewrite the same offsets;
|
|
206
|
+
* the caller owns pausing/closing/discarding it in that case.
|
|
207
|
+
*/
|
|
208
|
+
export async function snatchBlobToDisk(node, peerNodeIds, info, writable, options = {}) {
|
|
209
|
+
if (peerNodeIds.length === 0) {
|
|
210
|
+
throw new Error("no peers available for snatch");
|
|
211
|
+
}
|
|
212
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
213
|
+
const canStream = typeof node.download_verified_streaming_with_ensure === "function";
|
|
214
|
+
let lastError;
|
|
215
|
+
for (const peerAddr of peerNodeIds) {
|
|
216
|
+
throwIfAborted(options.signal);
|
|
217
|
+
if (canStream) {
|
|
218
|
+
try {
|
|
219
|
+
const size = await streamToWritable(node, peerAddr, info, writable, options, timeoutMs);
|
|
220
|
+
if (size === 0) {
|
|
221
|
+
throw new Error("snatch returned 0 bytes - refusing empty payload");
|
|
222
|
+
}
|
|
223
|
+
await writable.close();
|
|
224
|
+
return { size, blake3: info.blake3, mime: info.mime };
|
|
225
|
+
}
|
|
226
|
+
catch (err) {
|
|
227
|
+
if (isCancelledError(err))
|
|
228
|
+
throw err;
|
|
229
|
+
lastError = err;
|
|
230
|
+
log.warn(TAG, `streamed snatch-to-disk from peer ${peerAddr.slice(0, 16)}... failed:`, err);
|
|
231
|
+
await writable.truncate(0);
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
let result;
|
|
236
|
+
try {
|
|
237
|
+
result = await snatchFromPeer(node, peerAddr, info, options, timeoutMs);
|
|
238
|
+
if (result.bytes.length === 0) {
|
|
239
|
+
throw new Error("snatch returned 0 bytes - refusing empty payload");
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
catch (err) {
|
|
243
|
+
if (isCancelledError(err))
|
|
244
|
+
throw err;
|
|
245
|
+
lastError = err;
|
|
246
|
+
log.warn(TAG, `buffered snatch-to-disk from peer ${peerAddr.slice(0, 16)}... failed:`, err);
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
throwIfAborted(options.signal);
|
|
250
|
+
// write phase: a failure here is a real disk-write error, not a peer
|
|
251
|
+
// problem, so it is surfaced directly - never retried against another
|
|
252
|
+
// peer. `.slice()` copies into a fresh, exactly-sized buffer (not the
|
|
253
|
+
// transport's possibly-oversized/shared backing buffer).
|
|
254
|
+
await writable.write(result.bytes.slice());
|
|
255
|
+
await writable.close();
|
|
256
|
+
return { size: result.bytes.length, blake3: result.blake3, mime: result.mime };
|
|
257
|
+
}
|
|
258
|
+
throw lastError ?? new Error("snatch failed: all peers exhausted");
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* pause an in-flight snatch registered under `downloadId`. the transfer
|
|
262
|
+
* stops at the next chunk boundary and its promise rejects with a
|
|
263
|
+
* cancelled error (see `isCancelledError`). the partial stays pinned in
|
|
264
|
+
* the transport's store - resume by calling `snatchBlob`/
|
|
265
|
+
* `snatchBlobToDisk` again with the same `downloadId`/blake3 (only
|
|
266
|
+
* missing ranges transfer). returns false when `node` has no
|
|
267
|
+
* `download_cancel` or the download already settled.
|
|
268
|
+
*/
|
|
269
|
+
export async function pauseSnatchDownload(node, downloadId) {
|
|
270
|
+
if (!node.download_cancel)
|
|
271
|
+
return false;
|
|
272
|
+
return (await node.download_cancel(downloadId)) === true;
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* discard a paused partial: releases the gc pin a paused download left
|
|
276
|
+
* behind so the transport can reclaim it. call when the user cancels for
|
|
277
|
+
* good rather than pausing to resume later. best-effort - failures are
|
|
278
|
+
* logged, not thrown.
|
|
279
|
+
*/
|
|
280
|
+
export async function discardPausedDownload(node, blake3Hash) {
|
|
281
|
+
if (!node.unprotect_blob)
|
|
282
|
+
return;
|
|
283
|
+
try {
|
|
284
|
+
await node.unprotect_blob(blake3Hash);
|
|
285
|
+
}
|
|
286
|
+
catch (err) {
|
|
287
|
+
log.debug(TAG, "discardPausedDownload failed (non-fatal):", err);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"snatch.test.d.ts","sourceRoot":"","sources":["../../src/transfer/snatch.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
vi.mock("../worker/index.js", () => ({
|
|
3
|
+
hashBlake3: vi.fn(async (bytes) => `hash-of-${bytes.length}-bytes`),
|
|
4
|
+
}));
|
|
5
|
+
import { hashBlake3 } from "../worker/index.js";
|
|
6
|
+
import { discardPausedDownload, pauseSnatchDownload, snatchBlob, snatchBlobToDisk, } from "./snatch.js";
|
|
7
|
+
afterEach(() => {
|
|
8
|
+
vi.clearAllMocks();
|
|
9
|
+
});
|
|
10
|
+
const BLAKE3 = "abc123";
|
|
11
|
+
const SIZE = 12;
|
|
12
|
+
function bytes(n) {
|
|
13
|
+
const arr = new Uint8Array(n);
|
|
14
|
+
for (let i = 0; i < n; i++)
|
|
15
|
+
arr[i] = i % 256;
|
|
16
|
+
return arr;
|
|
17
|
+
}
|
|
18
|
+
describe("snatchBlob", () => {
|
|
19
|
+
it("returns bytes from strategy 1 (bulk verified download)", async () => {
|
|
20
|
+
const node = {
|
|
21
|
+
node_id: () => "me",
|
|
22
|
+
download_verified_with_ensure_progress: vi.fn(async (_peer, _hash, _size, onProgress) => {
|
|
23
|
+
onProgress(1);
|
|
24
|
+
return bytes(SIZE);
|
|
25
|
+
}),
|
|
26
|
+
};
|
|
27
|
+
const result = await snatchBlob(node, ["peer-a"], { blake3: BLAKE3, size: SIZE });
|
|
28
|
+
expect(result.bytes.length).toBe(SIZE);
|
|
29
|
+
expect(result.blake3).toBe(BLAKE3);
|
|
30
|
+
expect(node.download_verified_with_ensure_progress).toHaveBeenCalledWith("peer-a", BLAKE3, SIZE, expect.any(Function), undefined);
|
|
31
|
+
});
|
|
32
|
+
it("falls through to the next peer when one fails", async () => {
|
|
33
|
+
let calls = 0;
|
|
34
|
+
const node = {
|
|
35
|
+
node_id: () => "me",
|
|
36
|
+
download_verified_with_ensure_progress: vi.fn(async (peer) => {
|
|
37
|
+
calls++;
|
|
38
|
+
if (peer === "peer-a")
|
|
39
|
+
throw new Error("offline");
|
|
40
|
+
return bytes(SIZE);
|
|
41
|
+
}),
|
|
42
|
+
};
|
|
43
|
+
const result = await snatchBlob(node, ["peer-a", "peer-b"], { blake3: BLAKE3, size: SIZE });
|
|
44
|
+
expect(calls).toBe(2);
|
|
45
|
+
expect(result.bytes.length).toBe(SIZE);
|
|
46
|
+
});
|
|
47
|
+
it("rethrows a cancelled error immediately without trying the next peer", async () => {
|
|
48
|
+
let calls = 0;
|
|
49
|
+
const node = {
|
|
50
|
+
node_id: () => "me",
|
|
51
|
+
download_verified_with_ensure_progress: vi.fn(async () => {
|
|
52
|
+
calls++;
|
|
53
|
+
throw new DOMException("aborted", "AbortError");
|
|
54
|
+
}),
|
|
55
|
+
};
|
|
56
|
+
await expect(snatchBlob(node, ["peer-a", "peer-b"], { blake3: BLAKE3, size: SIZE })).rejects.toMatchObject({ name: "AbortError" });
|
|
57
|
+
expect(calls).toBe(1);
|
|
58
|
+
});
|
|
59
|
+
it("throws immediately when the signal is already aborted", async () => {
|
|
60
|
+
const node = {
|
|
61
|
+
node_id: () => "me",
|
|
62
|
+
download_verified_with_ensure_progress: vi.fn(),
|
|
63
|
+
};
|
|
64
|
+
const controller = new AbortController();
|
|
65
|
+
controller.abort();
|
|
66
|
+
await expect(snatchBlob(node, ["peer-a"], { blake3: BLAKE3, size: SIZE }, { signal: controller.signal })).rejects.toMatchObject({ name: "AbortError" });
|
|
67
|
+
expect(node.download_verified_with_ensure_progress).not.toHaveBeenCalled();
|
|
68
|
+
});
|
|
69
|
+
it("assembles out-of-order chunks via strategy 2 (streaming) at their explicit offsets", async () => {
|
|
70
|
+
const node = {
|
|
71
|
+
node_id: () => "me",
|
|
72
|
+
download_verified_streaming_with_ensure: vi.fn(async (_peer, _hash, _size, onChunk, onProgress) => {
|
|
73
|
+
// deliver out of order to prove offsets (not push order) win.
|
|
74
|
+
onChunk(new Uint8Array([40, 41, 42, 43]), 8);
|
|
75
|
+
onChunk(new Uint8Array([0, 1, 2, 3]), 0);
|
|
76
|
+
onChunk(new Uint8Array([10, 11, 12, 13]), 4);
|
|
77
|
+
onProgress(1);
|
|
78
|
+
return 12;
|
|
79
|
+
}),
|
|
80
|
+
};
|
|
81
|
+
const result = await snatchBlob(node, ["peer-a"], { blake3: BLAKE3, size: 12 });
|
|
82
|
+
expect(Array.from(result.bytes)).toEqual([0, 1, 2, 3, 10, 11, 12, 13, 40, 41, 42, 43]);
|
|
83
|
+
});
|
|
84
|
+
it("rejects when the streamed byte count never reaches the declared total", async () => {
|
|
85
|
+
vi.useFakeTimers();
|
|
86
|
+
try {
|
|
87
|
+
const node = {
|
|
88
|
+
node_id: () => "me",
|
|
89
|
+
download_verified_streaming_with_ensure: vi.fn(async (_peer, _hash, _size, onChunk) => {
|
|
90
|
+
onChunk(new Uint8Array([1, 2, 3]), 0);
|
|
91
|
+
return 12; // declares 12 total but only 3 bytes ever arrive
|
|
92
|
+
}),
|
|
93
|
+
};
|
|
94
|
+
const promise = snatchBlob(node, ["peer-a"], { blake3: BLAKE3, size: 12 });
|
|
95
|
+
const assertion = expect(promise).rejects.toThrow(/chunk stream incomplete/);
|
|
96
|
+
await vi.advanceTimersByTimeAsync(30_000);
|
|
97
|
+
await assertion;
|
|
98
|
+
}
|
|
99
|
+
finally {
|
|
100
|
+
vi.useRealTimers();
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
it("rejects an empty payload", async () => {
|
|
104
|
+
const node = {
|
|
105
|
+
node_id: () => "me",
|
|
106
|
+
download_verified_with_ensure_progress: vi.fn(async () => new Uint8Array(0)),
|
|
107
|
+
};
|
|
108
|
+
await expect(snatchBlob(node, ["peer-a"], { blake3: BLAKE3, size: 0 })).rejects.toThrow(/0 bytes/);
|
|
109
|
+
});
|
|
110
|
+
it("uses the base64 proxy fallback when no verified strategy is available, verifying the hash", async () => {
|
|
111
|
+
const payload = bytes(4);
|
|
112
|
+
const b64 = Buffer.from(payload).toString("base64");
|
|
113
|
+
const node = {
|
|
114
|
+
node_id: () => "me",
|
|
115
|
+
proxy_request: vi.fn(async () => ({
|
|
116
|
+
status: 200,
|
|
117
|
+
body: JSON.stringify({ data: b64, mime: "image/png" }),
|
|
118
|
+
})),
|
|
119
|
+
};
|
|
120
|
+
const expectedHash = `hash-of-${payload.length}-bytes`;
|
|
121
|
+
const result = await snatchBlob(node, ["peer-a"], {
|
|
122
|
+
blake3: expectedHash,
|
|
123
|
+
size: payload.length,
|
|
124
|
+
id: "some-app-id",
|
|
125
|
+
}, {
|
|
126
|
+
proxyPath: (id) => `/api/blobs/${id}/data`,
|
|
127
|
+
});
|
|
128
|
+
expect(node.proxy_request).toHaveBeenCalledWith("peer-a", "GET", "/api/blobs/some-app-id/data", null);
|
|
129
|
+
expect(Array.from(result.bytes)).toEqual(Array.from(payload));
|
|
130
|
+
expect(result.mime).toBe("image/png");
|
|
131
|
+
expect(hashBlake3).toHaveBeenCalled();
|
|
132
|
+
});
|
|
133
|
+
it("rejects a proxy-fallback response whose bytes don't match the expected blake3", async () => {
|
|
134
|
+
const payload = bytes(4);
|
|
135
|
+
const b64 = Buffer.from(payload).toString("base64");
|
|
136
|
+
const node = {
|
|
137
|
+
node_id: () => "me",
|
|
138
|
+
proxy_request: vi.fn(async () => ({
|
|
139
|
+
status: 200,
|
|
140
|
+
body: JSON.stringify({ data: b64 }),
|
|
141
|
+
})),
|
|
142
|
+
};
|
|
143
|
+
await expect(snatchBlob(node, ["peer-a"], { blake3: "not-the-real-hash", size: payload.length }, { proxyPath: (id) => `/api/blobs/${id}/data` })).rejects.toThrow(/hash mismatch/);
|
|
144
|
+
});
|
|
145
|
+
it("falls through to the next strategy on the same peer when a verified strategy fails", async () => {
|
|
146
|
+
// a peer whose backend only accepts an app-level rpc alpn makes the
|
|
147
|
+
// verified strategies fail even though the local node has the methods -
|
|
148
|
+
// the proxy fallback must still be attempted against that same peer.
|
|
149
|
+
const payload = bytes(4);
|
|
150
|
+
const b64 = Buffer.from(payload).toString("base64");
|
|
151
|
+
const node = {
|
|
152
|
+
node_id: () => "me",
|
|
153
|
+
download_verified_with_ensure_progress: vi.fn(async () => {
|
|
154
|
+
throw new Error("peer rejected iroh-blobs alpn");
|
|
155
|
+
}),
|
|
156
|
+
download_verified_streaming_with_ensure: vi.fn(async () => {
|
|
157
|
+
throw new Error("peer rejected iroh-blobs alpn");
|
|
158
|
+
}),
|
|
159
|
+
proxy_request: vi.fn(async () => ({
|
|
160
|
+
status: 200,
|
|
161
|
+
body: JSON.stringify({ data: b64, mime: "image/png" }),
|
|
162
|
+
})),
|
|
163
|
+
};
|
|
164
|
+
const result = await snatchBlob(node, ["peer-a"], { blake3: `hash-of-${payload.length}-bytes`, size: payload.length }, { proxyPath: (id) => `/api/blobs/${id}/data` });
|
|
165
|
+
expect(node.download_verified_with_ensure_progress).toHaveBeenCalledTimes(1);
|
|
166
|
+
expect(node.download_verified_streaming_with_ensure).toHaveBeenCalledTimes(1);
|
|
167
|
+
expect(node.proxy_request).toHaveBeenCalledTimes(1);
|
|
168
|
+
expect(Array.from(result.bytes)).toEqual(Array.from(payload));
|
|
169
|
+
});
|
|
170
|
+
it("does not fall through to another strategy on a cancelled error", async () => {
|
|
171
|
+
const node = {
|
|
172
|
+
node_id: () => "me",
|
|
173
|
+
download_verified_with_ensure_progress: vi.fn(async () => {
|
|
174
|
+
throw new Error("download cancelled");
|
|
175
|
+
}),
|
|
176
|
+
download_verified_streaming_with_ensure: vi.fn(),
|
|
177
|
+
proxy_request: vi.fn(),
|
|
178
|
+
};
|
|
179
|
+
await expect(snatchBlob(node, ["peer-a", "peer-b"], { blake3: BLAKE3, size: SIZE }, { proxyPath: (id) => `/api/blobs/${id}/data` })).rejects.toThrow(/download cancelled/);
|
|
180
|
+
expect(node.download_verified_streaming_with_ensure).not.toHaveBeenCalled();
|
|
181
|
+
expect(node.proxy_request).not.toHaveBeenCalled();
|
|
182
|
+
});
|
|
183
|
+
it("parses a nested proxy envelope via parseProxyResponse", async () => {
|
|
184
|
+
const payload = bytes(4);
|
|
185
|
+
const b64 = Buffer.from(payload).toString("base64");
|
|
186
|
+
const node = {
|
|
187
|
+
node_id: () => "me",
|
|
188
|
+
proxy_request: vi.fn(async () => ({
|
|
189
|
+
status: 200,
|
|
190
|
+
body: JSON.stringify({ success: true, data: { data: b64, mime: "audio/wav" } }),
|
|
191
|
+
})),
|
|
192
|
+
};
|
|
193
|
+
const result = await snatchBlob(node, ["peer-a"], { blake3: `hash-of-${payload.length}-bytes`, size: payload.length }, {
|
|
194
|
+
proxyPath: (id) => `/api/blobs/${id}/data`,
|
|
195
|
+
parseProxyResponse: (body) => {
|
|
196
|
+
const parsed = JSON.parse(body);
|
|
197
|
+
if (!parsed.success || typeof parsed.data?.data !== "string")
|
|
198
|
+
return null;
|
|
199
|
+
return { data: parsed.data.data, mime: parsed.data.mime };
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
expect(Array.from(result.bytes)).toEqual(Array.from(payload));
|
|
203
|
+
expect(result.mime).toBe("audio/wav");
|
|
204
|
+
});
|
|
205
|
+
it("throws when there are no peers to try", async () => {
|
|
206
|
+
const node = { node_id: () => "me" };
|
|
207
|
+
await expect(snatchBlob(node, [], { blake3: BLAKE3, size: SIZE })).rejects.toThrow(/no peers/);
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
class FakeWritable {
|
|
211
|
+
chunks = new Map();
|
|
212
|
+
closed = false;
|
|
213
|
+
truncated = [];
|
|
214
|
+
async write(input) {
|
|
215
|
+
if (input instanceof Uint8Array) {
|
|
216
|
+
this.chunks.set(0, input);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
this.chunks.set(input.position, input.data);
|
|
220
|
+
}
|
|
221
|
+
async truncate(size) {
|
|
222
|
+
this.truncated.push(size);
|
|
223
|
+
this.chunks.clear();
|
|
224
|
+
}
|
|
225
|
+
async close() {
|
|
226
|
+
this.closed = true;
|
|
227
|
+
}
|
|
228
|
+
assembled(total) {
|
|
229
|
+
const buf = new Uint8Array(total);
|
|
230
|
+
for (const [offset, data] of this.chunks)
|
|
231
|
+
buf.set(data, offset);
|
|
232
|
+
return buf;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
describe("snatchBlobToDisk", () => {
|
|
236
|
+
it("streams verified chunks straight to the writable and closes it", async () => {
|
|
237
|
+
const node = {
|
|
238
|
+
node_id: () => "me",
|
|
239
|
+
download_verified_streaming_with_ensure: vi.fn(async (_peer, _hash, _size, onChunk, onProgress) => {
|
|
240
|
+
onChunk(new Uint8Array([1, 2, 3, 4]), 0);
|
|
241
|
+
onChunk(new Uint8Array([5, 6, 7, 8]), 4);
|
|
242
|
+
onProgress(1);
|
|
243
|
+
return 8;
|
|
244
|
+
}),
|
|
245
|
+
};
|
|
246
|
+
const writable = new FakeWritable();
|
|
247
|
+
const result = await snatchBlobToDisk(node, ["peer-a"], { blake3: BLAKE3, size: 8 }, writable);
|
|
248
|
+
expect(result.size).toBe(8);
|
|
249
|
+
expect(Array.from(writable.assembled(8))).toEqual([1, 2, 3, 4, 5, 6, 7, 8]);
|
|
250
|
+
expect(writable.closed).toBe(true);
|
|
251
|
+
expect(writable.truncated).toEqual([]);
|
|
252
|
+
});
|
|
253
|
+
it("truncates and retries the next peer on a mid-stream failure", async () => {
|
|
254
|
+
let attempt = 0;
|
|
255
|
+
const node = {
|
|
256
|
+
node_id: () => "me",
|
|
257
|
+
download_verified_streaming_with_ensure: vi.fn(async (peer, _h, _s, onChunk) => {
|
|
258
|
+
attempt++;
|
|
259
|
+
if (peer === "peer-a") {
|
|
260
|
+
onChunk(new Uint8Array([9, 9]), 0);
|
|
261
|
+
throw new Error("connection reset");
|
|
262
|
+
}
|
|
263
|
+
onChunk(new Uint8Array([1, 2, 3, 4]), 0);
|
|
264
|
+
return 4;
|
|
265
|
+
}),
|
|
266
|
+
};
|
|
267
|
+
const writable = new FakeWritable();
|
|
268
|
+
const result = await snatchBlobToDisk(node, ["peer-a", "peer-b"], { blake3: BLAKE3, size: 4 }, writable);
|
|
269
|
+
expect(attempt).toBe(2);
|
|
270
|
+
expect(writable.truncated).toEqual([0]);
|
|
271
|
+
expect(Array.from(writable.assembled(4))).toEqual([1, 2, 3, 4]);
|
|
272
|
+
expect(writable.closed).toBe(true);
|
|
273
|
+
});
|
|
274
|
+
it("leaves the writable open (no truncate, no close) on a cancelled download", async () => {
|
|
275
|
+
const node = {
|
|
276
|
+
node_id: () => "me",
|
|
277
|
+
download_verified_streaming_with_ensure: vi.fn(async () => {
|
|
278
|
+
throw new Error("download cancelled");
|
|
279
|
+
}),
|
|
280
|
+
};
|
|
281
|
+
const writable = new FakeWritable();
|
|
282
|
+
await expect(snatchBlobToDisk(node, ["peer-a"], { blake3: BLAKE3, size: 4 }, writable)).rejects.toThrow(/download cancelled/);
|
|
283
|
+
expect(writable.truncated).toEqual([]);
|
|
284
|
+
expect(writable.closed).toBe(false);
|
|
285
|
+
});
|
|
286
|
+
it("falls back to a buffered download + single write when streaming is unavailable", async () => {
|
|
287
|
+
const node = {
|
|
288
|
+
node_id: () => "me",
|
|
289
|
+
download_verified_with_ensure_progress: vi.fn(async () => bytes(6)),
|
|
290
|
+
};
|
|
291
|
+
const writable = new FakeWritable();
|
|
292
|
+
const result = await snatchBlobToDisk(node, ["peer-a"], { blake3: BLAKE3, size: 6 }, writable);
|
|
293
|
+
expect(result.size).toBe(6);
|
|
294
|
+
expect(writable.closed).toBe(true);
|
|
295
|
+
});
|
|
296
|
+
it("surfaces a buffered-path disk-write failure directly instead of retrying another peer", async () => {
|
|
297
|
+
const download = vi.fn(async () => bytes(6));
|
|
298
|
+
const node = {
|
|
299
|
+
node_id: () => "me",
|
|
300
|
+
download_verified_with_ensure_progress: download,
|
|
301
|
+
};
|
|
302
|
+
const writable = new FakeWritable();
|
|
303
|
+
writable.write = async () => {
|
|
304
|
+
throw new Error("disk full");
|
|
305
|
+
};
|
|
306
|
+
await expect(snatchBlobToDisk(node, ["peer-a", "peer-b"], { blake3: BLAKE3, size: 6 }, writable)).rejects.toThrow(/disk full/);
|
|
307
|
+
expect(download).toHaveBeenCalledTimes(1);
|
|
308
|
+
});
|
|
309
|
+
});
|
|
310
|
+
describe("pauseSnatchDownload / discardPausedDownload", () => {
|
|
311
|
+
it("pauses via download_cancel and reports whether a download was actually flagged", async () => {
|
|
312
|
+
const node = {
|
|
313
|
+
node_id: () => "me",
|
|
314
|
+
download_cancel: vi.fn(async () => true),
|
|
315
|
+
};
|
|
316
|
+
await expect(pauseSnatchDownload(node, "dl-1")).resolves.toBe(true);
|
|
317
|
+
expect(node.download_cancel).toHaveBeenCalledWith("dl-1");
|
|
318
|
+
});
|
|
319
|
+
it("returns false when the node has no download_cancel", async () => {
|
|
320
|
+
const node = { node_id: () => "me" };
|
|
321
|
+
await expect(pauseSnatchDownload(node, "dl-1")).resolves.toBe(false);
|
|
322
|
+
});
|
|
323
|
+
it("discards a paused download via unprotect_blob, swallowing errors", async () => {
|
|
324
|
+
const node = {
|
|
325
|
+
node_id: () => "me",
|
|
326
|
+
unprotect_blob: vi.fn(async () => {
|
|
327
|
+
throw new Error("boom");
|
|
328
|
+
}),
|
|
329
|
+
};
|
|
330
|
+
await expect(discardPausedDownload(node, BLAKE3)).resolves.toBeUndefined();
|
|
331
|
+
expect(node.unprotect_blob).toHaveBeenCalledWith(BLAKE3);
|
|
332
|
+
});
|
|
333
|
+
it("is a no-op when the node has no unprotect_blob", async () => {
|
|
334
|
+
const node = { node_id: () => "me" };
|
|
335
|
+
await expect(discardPausedDownload(node, BLAKE3)).resolves.toBeUndefined();
|
|
336
|
+
});
|
|
337
|
+
});
|