@cogcoin/client 0.5.4 → 0.5.6
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 +1 -1
- package/dist/app-paths.d.ts +2 -0
- package/dist/app-paths.js +4 -0
- package/dist/art/wallet.txt +9 -9
- package/dist/bitcoind/bootstrap/chainstate.d.ts +2 -1
- package/dist/bitcoind/bootstrap/chainstate.js +4 -1
- package/dist/bitcoind/bootstrap/chunk-manifest.d.ts +14 -0
- package/dist/bitcoind/bootstrap/chunk-manifest.js +85 -0
- package/dist/bitcoind/bootstrap/chunk-recovery.d.ts +4 -0
- package/dist/bitcoind/bootstrap/chunk-recovery.js +122 -0
- package/dist/bitcoind/bootstrap/constants.d.ts +3 -1
- package/dist/bitcoind/bootstrap/constants.js +3 -1
- package/dist/bitcoind/bootstrap/controller.d.ts +10 -2
- package/dist/bitcoind/bootstrap/controller.js +56 -12
- package/dist/bitcoind/bootstrap/default-snapshot-chunk-manifest.d.ts +2 -0
- package/dist/bitcoind/bootstrap/default-snapshot-chunk-manifest.js +2309 -0
- package/dist/bitcoind/bootstrap/download.js +177 -83
- package/dist/bitcoind/bootstrap/headers.d.ts +16 -2
- package/dist/bitcoind/bootstrap/headers.js +124 -14
- package/dist/bitcoind/bootstrap/state.d.ts +11 -1
- package/dist/bitcoind/bootstrap/state.js +50 -23
- package/dist/bitcoind/bootstrap/types.d.ts +12 -1
- package/dist/bitcoind/client/factory.js +11 -2
- package/dist/bitcoind/client/internal-types.d.ts +1 -0
- package/dist/bitcoind/client/managed-client.d.ts +1 -1
- package/dist/bitcoind/client/managed-client.js +29 -15
- package/dist/bitcoind/client/sync-engine.js +88 -16
- package/dist/bitcoind/errors.js +9 -0
- package/dist/bitcoind/indexer-daemon.d.ts +7 -0
- package/dist/bitcoind/indexer-daemon.js +31 -22
- package/dist/bitcoind/processing-start-height.d.ts +7 -0
- package/dist/bitcoind/processing-start-height.js +9 -0
- package/dist/bitcoind/progress/controller.js +1 -0
- package/dist/bitcoind/progress/formatting.js +4 -1
- package/dist/bitcoind/retryable-rpc.d.ts +11 -0
- package/dist/bitcoind/retryable-rpc.js +30 -0
- package/dist/bitcoind/service.d.ts +16 -1
- package/dist/bitcoind/service.js +228 -115
- package/dist/bitcoind/testing.d.ts +1 -1
- package/dist/bitcoind/testing.js +1 -1
- package/dist/bitcoind/types.d.ts +10 -0
- package/dist/cli/commands/follow.js +9 -0
- package/dist/cli/commands/service-runtime.js +150 -134
- package/dist/cli/commands/sync.js +9 -0
- package/dist/cli/commands/wallet-admin.js +77 -21
- package/dist/cli/context.js +4 -2
- package/dist/cli/mutation-json.js +2 -0
- package/dist/cli/output.js +3 -1
- package/dist/cli/parse.d.ts +1 -1
- package/dist/cli/parse.js +6 -0
- package/dist/cli/preview-json.js +2 -0
- package/dist/cli/runner.js +1 -0
- package/dist/cli/types.d.ts +6 -3
- package/dist/cli/types.js +1 -1
- package/dist/cli/wallet-format.js +134 -14
- package/dist/wallet/lifecycle.d.ts +6 -0
- package/dist/wallet/lifecycle.js +168 -37
- package/dist/wallet/read/context.js +10 -4
- package/dist/wallet/reset.d.ts +61 -2
- package/dist/wallet/reset.js +208 -63
- package/dist/wallet/root-resolution.d.ts +20 -0
- package/dist/wallet/root-resolution.js +37 -0
- package/dist/wallet/runtime.d.ts +3 -0
- package/dist/wallet/runtime.js +3 -0
- package/dist/wallet/state/crypto.d.ts +3 -0
- package/dist/wallet/state/crypto.js +3 -0
- package/dist/wallet/state/pending-init.d.ts +24 -0
- package/dist/wallet/state/pending-init.js +59 -0
- package/dist/wallet/state/provider.d.ts +1 -0
- package/dist/wallet/state/provider.js +7 -1
- package/dist/wallet/state/storage.d.ts +7 -1
- package/dist/wallet/state/storage.js +39 -0
- package/dist/wallet/types.d.ts +9 -0
- package/package.json +4 -2
|
@@ -1,7 +1,10 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import { mkdir, open, rename, rm } from "node:fs/promises";
|
|
2
3
|
import { formatManagedSyncErrorMessage } from "../errors.js";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
4
|
+
import { DOWNLOAD_RETRY_BASE_MS, DOWNLOAD_RETRY_MAX_MS, } from "./constants.js";
|
|
5
|
+
import { applyVerifiedFrontierState, reconcileSnapshotDownloadArtifacts, } from "./chunk-recovery.js";
|
|
6
|
+
import { resolveBundledSnapshotChunkManifest, resolveSnapshotChunkCount, resolveSnapshotChunkSize, resolveVerifiedChunkBytes, } from "./chunk-manifest.js";
|
|
7
|
+
import { statOrNull, validateSnapshotFileForTesting } from "./snapshot-file.js";
|
|
5
8
|
import { saveBootstrapState } from "./state.js";
|
|
6
9
|
function describeSnapshotDownloadError(error, url) {
|
|
7
10
|
if (!(error instanceof Error)) {
|
|
@@ -27,14 +30,48 @@ function describeSnapshotDownloadError(error, url) {
|
|
|
27
30
|
? `snapshot download failed from ${source}: ${causeMessage}`
|
|
28
31
|
: `snapshot download failed from ${source}: fetch failed`;
|
|
29
32
|
}
|
|
30
|
-
function
|
|
31
|
-
|
|
32
|
-
|
|
33
|
+
function createAbortError(signal) {
|
|
34
|
+
const reason = signal?.reason;
|
|
35
|
+
if (reason instanceof Error) {
|
|
36
|
+
return reason;
|
|
37
|
+
}
|
|
38
|
+
const error = new Error("managed_sync_aborted");
|
|
39
|
+
error.name = "AbortError";
|
|
40
|
+
return error;
|
|
41
|
+
}
|
|
42
|
+
function isAbortError(error, signal) {
|
|
43
|
+
if (signal?.aborted) {
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
return error instanceof Error
|
|
47
|
+
&& (error.name === "AbortError" || error.message === "managed_sync_aborted");
|
|
48
|
+
}
|
|
49
|
+
function throwIfAborted(signal) {
|
|
50
|
+
if (signal?.aborted) {
|
|
51
|
+
throw createAbortError(signal);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function sleep(ms, signal) {
|
|
55
|
+
if (ms <= 0) {
|
|
56
|
+
throwIfAborted(signal);
|
|
57
|
+
return Promise.resolve();
|
|
58
|
+
}
|
|
59
|
+
return new Promise((resolve, reject) => {
|
|
60
|
+
const timer = setTimeout(() => {
|
|
61
|
+
signal?.removeEventListener("abort", onAbort);
|
|
62
|
+
resolve();
|
|
63
|
+
}, ms);
|
|
64
|
+
const onAbort = () => {
|
|
65
|
+
clearTimeout(timer);
|
|
66
|
+
signal?.removeEventListener("abort", onAbort);
|
|
67
|
+
reject(createAbortError(signal));
|
|
68
|
+
};
|
|
69
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
33
70
|
});
|
|
34
71
|
}
|
|
35
|
-
function updateDownloadProgress(progress, state, downloadedBytes, resumed, startedAt) {
|
|
72
|
+
function updateDownloadProgress(progress, state, attemptStartBytes, downloadedBytes, resumed, startedAt) {
|
|
36
73
|
const elapsedSeconds = Math.max(0.001, (Date.now() - startedAt) / 1000);
|
|
37
|
-
const bytesPerSecond = Math.max(0, (downloadedBytes -
|
|
74
|
+
const bytesPerSecond = Math.max(0, (downloadedBytes - attemptStartBytes) / elapsedSeconds);
|
|
38
75
|
const remaining = Math.max(0, state.snapshot.sizeBytes - downloadedBytes);
|
|
39
76
|
return progress.setPhase("snapshot_download", {
|
|
40
77
|
message: "Downloading UTXO snapshot.",
|
|
@@ -48,76 +85,112 @@ function updateDownloadProgress(progress, state, downloadedBytes, resumed, start
|
|
|
48
85
|
lastError: state.lastError,
|
|
49
86
|
});
|
|
50
87
|
}
|
|
88
|
+
async function persistVerifiedChunk(state, manifest, verifiedChunkCount, options) {
|
|
89
|
+
applyVerifiedFrontierState(state, manifest, verifiedChunkCount);
|
|
90
|
+
state.validated = false;
|
|
91
|
+
state.lastError = null;
|
|
92
|
+
await saveBootstrapState(options.paths, state);
|
|
93
|
+
}
|
|
94
|
+
async function finalizeSnapshotDownload(metadata, manifest, state, options, resumed) {
|
|
95
|
+
await validateSnapshotFileForTesting(options.paths.partialSnapshotPath, metadata);
|
|
96
|
+
await rename(options.paths.partialSnapshotPath, options.paths.snapshotPath);
|
|
97
|
+
applyVerifiedFrontierState(state, manifest, resolveSnapshotChunkCount(manifest));
|
|
98
|
+
state.validated = true;
|
|
99
|
+
state.lastError = null;
|
|
100
|
+
await saveBootstrapState(options.paths, state);
|
|
101
|
+
await options.progress.setPhase("snapshot_download", {
|
|
102
|
+
resumed,
|
|
103
|
+
downloadedBytes: metadata.sizeBytes,
|
|
104
|
+
totalBytes: metadata.sizeBytes,
|
|
105
|
+
percent: 100,
|
|
106
|
+
bytesPerSecond: null,
|
|
107
|
+
etaSeconds: 0,
|
|
108
|
+
lastError: null,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
async function finalizeExistingSnapshot(metadata, manifest, state, options) {
|
|
112
|
+
applyVerifiedFrontierState(state, manifest, resolveSnapshotChunkCount(manifest));
|
|
113
|
+
state.validated = true;
|
|
114
|
+
state.lastError = null;
|
|
115
|
+
await saveBootstrapState(options.paths, state);
|
|
116
|
+
await rm(options.paths.partialSnapshotPath, { force: true });
|
|
117
|
+
await options.progress.setPhase("snapshot_download", {
|
|
118
|
+
resumed: false,
|
|
119
|
+
downloadedBytes: metadata.sizeBytes,
|
|
120
|
+
totalBytes: metadata.sizeBytes,
|
|
121
|
+
percent: 100,
|
|
122
|
+
bytesPerSecond: null,
|
|
123
|
+
etaSeconds: 0,
|
|
124
|
+
lastError: null,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
async function truncateToVerifiedFrontier(path, verifiedBytes) {
|
|
128
|
+
const file = await open(path, "a+");
|
|
129
|
+
try {
|
|
130
|
+
await file.truncate(verifiedBytes);
|
|
131
|
+
await file.sync();
|
|
132
|
+
}
|
|
133
|
+
finally {
|
|
134
|
+
await file.close();
|
|
135
|
+
}
|
|
136
|
+
}
|
|
51
137
|
export async function downloadSnapshotFileForTesting(options) {
|
|
52
|
-
const { metadata, paths, progress, fetchImpl = fetch } = options;
|
|
138
|
+
const { metadata, paths, progress, fetchImpl = fetch, signal, snapshotIdentity = "current", } = options;
|
|
139
|
+
const manifest = options.manifest ?? resolveBundledSnapshotChunkManifest(metadata);
|
|
53
140
|
const state = options.state;
|
|
54
141
|
await mkdir(paths.directory, { recursive: true });
|
|
55
|
-
const existingPart = await statOrNull(paths.partialSnapshotPath);
|
|
56
142
|
const existingFull = await statOrNull(paths.snapshotPath);
|
|
57
143
|
if (state.validated && existingFull?.size === metadata.sizeBytes) {
|
|
58
|
-
await
|
|
59
|
-
resumed: false,
|
|
60
|
-
downloadedBytes: metadata.sizeBytes,
|
|
61
|
-
totalBytes: metadata.sizeBytes,
|
|
62
|
-
percent: 100,
|
|
63
|
-
bytesPerSecond: null,
|
|
64
|
-
etaSeconds: 0,
|
|
65
|
-
lastError: null,
|
|
66
|
-
});
|
|
144
|
+
await finalizeExistingSnapshot(metadata, manifest, state, { paths, progress });
|
|
67
145
|
return;
|
|
68
146
|
}
|
|
69
|
-
if (
|
|
147
|
+
if (existingFull?.size === metadata.sizeBytes) {
|
|
70
148
|
try {
|
|
71
149
|
await validateSnapshotFileForTesting(paths.snapshotPath, metadata);
|
|
72
|
-
state
|
|
73
|
-
state.downloadedBytes = metadata.sizeBytes;
|
|
74
|
-
state.lastError = null;
|
|
75
|
-
await saveBootstrapState(paths, state);
|
|
76
|
-
await progress.setPhase("snapshot_download", {
|
|
77
|
-
resumed: false,
|
|
78
|
-
downloadedBytes: metadata.sizeBytes,
|
|
79
|
-
totalBytes: metadata.sizeBytes,
|
|
80
|
-
percent: 100,
|
|
81
|
-
bytesPerSecond: null,
|
|
82
|
-
etaSeconds: 0,
|
|
83
|
-
lastError: null,
|
|
84
|
-
});
|
|
150
|
+
await finalizeExistingSnapshot(metadata, manifest, state, { paths, progress });
|
|
85
151
|
return;
|
|
86
152
|
}
|
|
87
153
|
catch {
|
|
88
|
-
await resetSnapshotFiles(paths);
|
|
89
|
-
state.downloadedBytes = 0;
|
|
90
154
|
state.validated = false;
|
|
91
|
-
await saveBootstrapState(paths, state);
|
|
92
155
|
}
|
|
93
156
|
}
|
|
157
|
+
else if (state.validated) {
|
|
158
|
+
state.validated = false;
|
|
159
|
+
}
|
|
160
|
+
await reconcileSnapshotDownloadArtifacts(paths, state, manifest, snapshotIdentity);
|
|
161
|
+
await saveBootstrapState(paths, state);
|
|
162
|
+
throwIfAborted(signal);
|
|
163
|
+
if (state.downloadedBytes >= metadata.sizeBytes) {
|
|
164
|
+
await finalizeSnapshotDownload(metadata, manifest, state, { paths, progress }, state.downloadedBytes > 0);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
94
167
|
let retryDelayMs = DOWNLOAD_RETRY_BASE_MS;
|
|
95
168
|
while (true) {
|
|
96
|
-
|
|
97
|
-
if (startOffset > metadata.sizeBytes) {
|
|
98
|
-
await rm(paths.partialSnapshotPath, { force: true });
|
|
99
|
-
startOffset = 0;
|
|
100
|
-
}
|
|
169
|
+
const startOffset = state.downloadedBytes;
|
|
101
170
|
const resumed = startOffset > 0;
|
|
102
171
|
try {
|
|
172
|
+
throwIfAborted(signal);
|
|
103
173
|
const headers = resumed ? { Range: `bytes=${startOffset}-` } : undefined;
|
|
104
|
-
const response = await fetchImpl(metadata.url, { headers });
|
|
105
|
-
if (
|
|
174
|
+
const response = await fetchImpl(metadata.url, { headers, signal });
|
|
175
|
+
if (resumed && response.status !== 206) {
|
|
176
|
+
throw new Error(response.status === 200
|
|
177
|
+
? "snapshot_resume_requires_partial_content"
|
|
178
|
+
: `snapshot_http_${response.status}`);
|
|
179
|
+
}
|
|
180
|
+
if (!resumed && response.status !== 200) {
|
|
106
181
|
throw new Error(`snapshot_http_${response.status}`);
|
|
107
182
|
}
|
|
108
183
|
if (response.body === null) {
|
|
109
184
|
throw new Error("snapshot_response_body_missing");
|
|
110
185
|
}
|
|
111
|
-
|
|
112
|
-
if (resumed && response.status === 200) {
|
|
113
|
-
await rm(paths.partialSnapshotPath, { force: true });
|
|
114
|
-
writeFrom = 0;
|
|
115
|
-
}
|
|
116
|
-
const file = await open(paths.partialSnapshotPath, writeFrom === 0 ? "w" : "a");
|
|
186
|
+
const file = await open(paths.partialSnapshotPath, resumed ? "a" : "w");
|
|
117
187
|
const reader = response.body.getReader();
|
|
118
188
|
const startedAt = Date.now();
|
|
119
|
-
|
|
120
|
-
let
|
|
189
|
+
const attemptStartBytes = startOffset;
|
|
190
|
+
let downloadedBytes = startOffset;
|
|
191
|
+
let currentChunkIndex = state.verifiedChunkCount;
|
|
192
|
+
let currentChunkBytes = 0;
|
|
193
|
+
let currentChunkHash = createHash("sha256");
|
|
121
194
|
await progress.setPhase("snapshot_download", {
|
|
122
195
|
resumed,
|
|
123
196
|
downloadedBytes,
|
|
@@ -129,56 +202,77 @@ export async function downloadSnapshotFileForTesting(options) {
|
|
|
129
202
|
});
|
|
130
203
|
try {
|
|
131
204
|
while (true) {
|
|
205
|
+
throwIfAborted(signal);
|
|
132
206
|
const { done, value } = await reader.read();
|
|
133
207
|
if (done) {
|
|
134
208
|
break;
|
|
135
209
|
}
|
|
136
|
-
if (value === undefined) {
|
|
210
|
+
if (value === undefined || value.byteLength === 0) {
|
|
137
211
|
continue;
|
|
138
212
|
}
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
213
|
+
let offset = 0;
|
|
214
|
+
while (offset < value.byteLength) {
|
|
215
|
+
if (currentChunkIndex >= manifest.chunkSha256s.length) {
|
|
216
|
+
throw new Error(`snapshot_size_mismatch_${downloadedBytes + (value.byteLength - offset)}`);
|
|
217
|
+
}
|
|
218
|
+
const chunkSizeBytes = resolveSnapshotChunkSize(manifest, currentChunkIndex);
|
|
219
|
+
const remainingChunkBytes = chunkSizeBytes - currentChunkBytes;
|
|
220
|
+
const writeLength = Math.min(remainingChunkBytes, value.byteLength - offset);
|
|
221
|
+
const segment = value.subarray(offset, offset + writeLength);
|
|
222
|
+
await file.write(segment);
|
|
223
|
+
currentChunkHash.update(segment);
|
|
224
|
+
currentChunkBytes += segment.byteLength;
|
|
225
|
+
downloadedBytes += segment.byteLength;
|
|
226
|
+
offset += writeLength;
|
|
227
|
+
if (currentChunkBytes === chunkSizeBytes) {
|
|
228
|
+
const actualSha256 = currentChunkHash.digest("hex");
|
|
229
|
+
const expectedSha256 = manifest.chunkSha256s[currentChunkIndex];
|
|
230
|
+
if (actualSha256 !== expectedSha256) {
|
|
231
|
+
const verifiedBytes = resolveVerifiedChunkBytes(manifest, currentChunkIndex);
|
|
232
|
+
await file.truncate(verifiedBytes);
|
|
233
|
+
await file.sync();
|
|
234
|
+
throw new Error(`snapshot_chunk_sha256_mismatch_${currentChunkIndex}`);
|
|
235
|
+
}
|
|
236
|
+
currentChunkIndex += 1;
|
|
237
|
+
currentChunkBytes = 0;
|
|
238
|
+
currentChunkHash = createHash("sha256");
|
|
239
|
+
await file.sync();
|
|
240
|
+
await persistVerifiedChunk(state, manifest, currentChunkIndex, { paths });
|
|
241
|
+
}
|
|
148
242
|
}
|
|
243
|
+
await updateDownloadProgress(progress, state, attemptStartBytes, downloadedBytes, resumed, startedAt);
|
|
244
|
+
}
|
|
245
|
+
if (downloadedBytes !== metadata.sizeBytes) {
|
|
246
|
+
throw new Error(`snapshot_download_incomplete_${downloadedBytes}`);
|
|
149
247
|
}
|
|
150
248
|
}
|
|
249
|
+
catch (error) {
|
|
250
|
+
if (!isAbortError(error, signal)) {
|
|
251
|
+
await file.truncate(state.downloadedBytes);
|
|
252
|
+
await file.sync();
|
|
253
|
+
}
|
|
254
|
+
throw error;
|
|
255
|
+
}
|
|
151
256
|
finally {
|
|
152
257
|
await file.close();
|
|
153
258
|
}
|
|
154
|
-
state
|
|
155
|
-
await saveBootstrapState(paths, state);
|
|
156
|
-
await validateSnapshotFileForTesting(paths.partialSnapshotPath, metadata);
|
|
157
|
-
await rename(paths.partialSnapshotPath, paths.snapshotPath);
|
|
158
|
-
state.validated = true;
|
|
159
|
-
state.downloadedBytes = metadata.sizeBytes;
|
|
160
|
-
state.lastError = null;
|
|
161
|
-
await saveBootstrapState(paths, state);
|
|
162
|
-
await progress.setPhase("snapshot_download", {
|
|
163
|
-
resumed,
|
|
164
|
-
downloadedBytes: metadata.sizeBytes,
|
|
165
|
-
totalBytes: metadata.sizeBytes,
|
|
166
|
-
percent: 100,
|
|
167
|
-
bytesPerSecond: null,
|
|
168
|
-
etaSeconds: 0,
|
|
169
|
-
lastError: null,
|
|
170
|
-
});
|
|
259
|
+
await finalizeSnapshotDownload(metadata, manifest, state, { paths, progress }, resumed);
|
|
171
260
|
return;
|
|
172
261
|
}
|
|
173
262
|
catch (error) {
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
263
|
+
if (isAbortError(error, signal)) {
|
|
264
|
+
const partialInfo = await statOrNull(paths.partialSnapshotPath);
|
|
265
|
+
if (partialInfo !== null) {
|
|
266
|
+
await truncateToVerifiedFrontier(paths.partialSnapshotPath, state.downloadedBytes);
|
|
267
|
+
}
|
|
268
|
+
state.lastError = null;
|
|
179
269
|
state.validated = false;
|
|
270
|
+
await saveBootstrapState(paths, state);
|
|
271
|
+
throw createAbortError(signal);
|
|
180
272
|
}
|
|
273
|
+
const message = formatManagedSyncErrorMessage(describeSnapshotDownloadError(error, metadata.url));
|
|
181
274
|
state.lastError = message;
|
|
275
|
+
state.validated = false;
|
|
182
276
|
await saveBootstrapState(paths, state);
|
|
183
277
|
await progress.setPhase("snapshot_download", {
|
|
184
278
|
resumed,
|
|
@@ -189,7 +283,7 @@ export async function downloadSnapshotFileForTesting(options) {
|
|
|
189
283
|
etaSeconds: null,
|
|
190
284
|
lastError: message,
|
|
191
285
|
});
|
|
192
|
-
await sleep(retryDelayMs);
|
|
286
|
+
await sleep(retryDelayMs, signal);
|
|
193
287
|
retryDelayMs = Math.min(retryDelayMs * 2, DOWNLOAD_RETRY_MAX_MS);
|
|
194
288
|
}
|
|
195
289
|
}
|
|
@@ -1,13 +1,27 @@
|
|
|
1
|
+
import { type ManagedRpcRetryState } from "../retryable-rpc.js";
|
|
1
2
|
import type { BitcoinRpcClient } from "../rpc.js";
|
|
2
3
|
import type { ManagedProgressController } from "../progress.js";
|
|
3
4
|
import type { SnapshotMetadata } from "../types.js";
|
|
4
5
|
export declare function waitForHeaders(rpc: Pick<BitcoinRpcClient, "getBlockchainInfo" | "getNetworkInfo">, snapshot: SnapshotMetadata, progress: Pick<ManagedProgressController, "setPhase">, options?: {
|
|
5
6
|
now?: () => number;
|
|
6
|
-
sleep?: (ms: number) => Promise<void>;
|
|
7
|
+
sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
|
|
7
8
|
noPeerTimeoutMs?: number;
|
|
9
|
+
signal?: AbortSignal;
|
|
10
|
+
retryState?: ManagedRpcRetryState;
|
|
11
|
+
debugLogPath?: string;
|
|
12
|
+
readDebugLogProgress?: (debugLogPath: string) => Promise<{
|
|
13
|
+
height: number;
|
|
14
|
+
message: string;
|
|
15
|
+
} | null>;
|
|
8
16
|
}): Promise<void>;
|
|
9
17
|
export declare function waitForHeadersForTesting(rpc: Pick<BitcoinRpcClient, "getBlockchainInfo" | "getNetworkInfo">, snapshot: SnapshotMetadata | undefined, progress: Pick<ManagedProgressController, "setPhase">, options?: {
|
|
10
18
|
now?: () => number;
|
|
11
|
-
sleep?: (ms: number) => Promise<void>;
|
|
19
|
+
sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
|
|
12
20
|
noPeerTimeoutMs?: number;
|
|
21
|
+
signal?: AbortSignal;
|
|
22
|
+
debugLogPath?: string;
|
|
23
|
+
readDebugLogProgress?: (debugLogPath: string) => Promise<{
|
|
24
|
+
height: number;
|
|
25
|
+
message: string;
|
|
26
|
+
} | null>;
|
|
13
27
|
}): Promise<void>;
|
|
@@ -1,8 +1,35 @@
|
|
|
1
|
+
import { open } from "node:fs/promises";
|
|
1
2
|
import { formatManagedSyncErrorMessage } from "../errors.js";
|
|
3
|
+
import { MANAGED_RPC_RETRY_MESSAGE, consumeManagedRpcRetryDelayMs, createManagedRpcRetryState, describeManagedRpcRetryError, resetManagedRpcRetryState, isRetryableManagedRpcError, } from "../retryable-rpc.js";
|
|
2
4
|
import { DEFAULT_SNAPSHOT_METADATA, HEADER_NO_PEER_TIMEOUT_MS, HEADER_POLL_MS, } from "./constants.js";
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
5
|
+
const DEBUG_LOG_TAIL_BYTES = 64 * 1024;
|
|
6
|
+
const HEADER_SYNC_DEBUG_LINE_PATTERN = /Pre-synchronizing blockheaders,\s*height:\s*([\d,]+)\s*\(~(\d+(?:\.\d+)?)%\)/u;
|
|
7
|
+
function createAbortError(signal) {
|
|
8
|
+
const reason = signal?.reason;
|
|
9
|
+
if (reason instanceof Error) {
|
|
10
|
+
return reason;
|
|
11
|
+
}
|
|
12
|
+
const error = new Error("managed_sync_aborted");
|
|
13
|
+
error.name = "AbortError";
|
|
14
|
+
return error;
|
|
15
|
+
}
|
|
16
|
+
function throwIfAborted(signal) {
|
|
17
|
+
if (signal?.aborted) {
|
|
18
|
+
throw createAbortError(signal);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function sleep(ms, signal) {
|
|
22
|
+
return new Promise((resolve, reject) => {
|
|
23
|
+
const timer = setTimeout(() => {
|
|
24
|
+
signal?.removeEventListener("abort", onAbort);
|
|
25
|
+
resolve();
|
|
26
|
+
}, ms);
|
|
27
|
+
const onAbort = () => {
|
|
28
|
+
clearTimeout(timer);
|
|
29
|
+
signal?.removeEventListener("abort", onAbort);
|
|
30
|
+
reject(createAbortError(signal));
|
|
31
|
+
};
|
|
32
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
6
33
|
});
|
|
7
34
|
}
|
|
8
35
|
function resolvePeerCount(networkInfo) {
|
|
@@ -10,7 +37,52 @@ function resolvePeerCount(networkInfo) {
|
|
|
10
37
|
? networkInfo.connections
|
|
11
38
|
: (networkInfo.connections_in ?? 0) + (networkInfo.connections_out ?? 0);
|
|
12
39
|
}
|
|
13
|
-
function
|
|
40
|
+
async function readDebugLogTail(filePath, maxBytes = DEBUG_LOG_TAIL_BYTES) {
|
|
41
|
+
let handle = null;
|
|
42
|
+
try {
|
|
43
|
+
handle = await open(filePath, "r");
|
|
44
|
+
const stats = await handle.stat();
|
|
45
|
+
const bytesToRead = Math.min(maxBytes, Math.max(0, stats.size));
|
|
46
|
+
if (bytesToRead === 0) {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
const buffer = Buffer.alloc(bytesToRead);
|
|
50
|
+
await handle.read(buffer, 0, bytesToRead, stats.size - bytesToRead);
|
|
51
|
+
return buffer.toString("utf8");
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
finally {
|
|
60
|
+
await handle?.close().catch(() => { });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
async function readHeaderSyncProgressFromDebugLog(debugLogPath) {
|
|
64
|
+
const tail = await readDebugLogTail(debugLogPath);
|
|
65
|
+
if (tail === null) {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
const lines = tail.split(/\r?\n/u).reverse();
|
|
69
|
+
for (const line of lines) {
|
|
70
|
+
const match = HEADER_SYNC_DEBUG_LINE_PATTERN.exec(line);
|
|
71
|
+
if (match === null) {
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
const height = Number(match[1].replaceAll(",", ""));
|
|
75
|
+
if (!Number.isFinite(height) || height < 0) {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
height,
|
|
80
|
+
message: `Pre-synchronizing blockheaders, height: ${height.toLocaleString()} (~${match[2]}%)`,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
function resolveHeaderWaitMessage(headers, peerCount, networkActive, rpcHeaders, headerSyncMessage) {
|
|
14
86
|
if (!networkActive) {
|
|
15
87
|
return "Bitcoin networking is inactive for the managed node.";
|
|
16
88
|
}
|
|
@@ -20,31 +92,69 @@ function resolveHeaderWaitMessage(headers, peerCount, networkActive) {
|
|
|
20
92
|
if (peerCount === 0) {
|
|
21
93
|
return `Waiting for peers to continue header sync (${headers.toLocaleString()} headers, 0 peers).`;
|
|
22
94
|
}
|
|
23
|
-
|
|
95
|
+
if (rpcHeaders > 0) {
|
|
96
|
+
return "Waiting for Bitcoin headers to reach the snapshot height.";
|
|
97
|
+
}
|
|
98
|
+
return headerSyncMessage ?? "Pre-synchronizing blockheaders.";
|
|
24
99
|
}
|
|
25
100
|
export async function waitForHeaders(rpc, snapshot, progress, options = {}) {
|
|
26
101
|
const now = options.now ?? Date.now;
|
|
27
102
|
const sleepImpl = options.sleep ?? sleep;
|
|
28
103
|
const noPeerTimeoutMs = options.noPeerTimeoutMs ?? HEADER_NO_PEER_TIMEOUT_MS;
|
|
104
|
+
const { signal } = options;
|
|
105
|
+
const retryState = options.retryState ?? createManagedRpcRetryState();
|
|
106
|
+
const readDebugLogProgress = options.readDebugLogProgress ?? readHeaderSyncProgressFromDebugLog;
|
|
29
107
|
let noPeerSince = null;
|
|
108
|
+
let lastBlocks = 0;
|
|
109
|
+
let lastHeaders = 0;
|
|
30
110
|
while (true) {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
111
|
+
throwIfAborted(signal);
|
|
112
|
+
let info;
|
|
113
|
+
let networkInfo;
|
|
114
|
+
try {
|
|
115
|
+
[info, networkInfo] = await Promise.all([
|
|
116
|
+
rpc.getBlockchainInfo(),
|
|
117
|
+
rpc.getNetworkInfo(),
|
|
118
|
+
]);
|
|
119
|
+
resetManagedRpcRetryState(retryState);
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
if (!isRetryableManagedRpcError(error)) {
|
|
123
|
+
throw error;
|
|
124
|
+
}
|
|
125
|
+
await progress.setPhase("wait_headers_for_snapshot", {
|
|
126
|
+
headers: lastHeaders,
|
|
127
|
+
targetHeight: snapshot.height,
|
|
128
|
+
blocks: lastBlocks,
|
|
129
|
+
percent: (Math.min(lastHeaders, snapshot.height) / snapshot.height) * 100,
|
|
130
|
+
lastError: describeManagedRpcRetryError(error),
|
|
131
|
+
message: MANAGED_RPC_RETRY_MESSAGE,
|
|
132
|
+
});
|
|
133
|
+
await sleepImpl(consumeManagedRpcRetryDelayMs(retryState), signal);
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
lastBlocks = info.blocks;
|
|
137
|
+
const debugLogProgress = info.headers === 0 && options.debugLogPath !== undefined
|
|
138
|
+
? await readDebugLogProgress(options.debugLogPath)
|
|
139
|
+
: null;
|
|
140
|
+
const observedHeaders = info.headers > 0
|
|
141
|
+
? info.headers
|
|
142
|
+
: Math.max(info.headers, debugLogProgress?.height ?? 0);
|
|
143
|
+
lastHeaders = observedHeaders;
|
|
35
144
|
const peerCount = resolvePeerCount(networkInfo);
|
|
36
|
-
const message = resolveHeaderWaitMessage(
|
|
145
|
+
const message = resolveHeaderWaitMessage(observedHeaders, peerCount, networkInfo.networkactive, info.headers, debugLogProgress?.message ?? null);
|
|
37
146
|
await progress.setPhase("wait_headers_for_snapshot", {
|
|
38
|
-
headers:
|
|
147
|
+
headers: observedHeaders,
|
|
39
148
|
targetHeight: snapshot.height,
|
|
40
149
|
blocks: info.blocks,
|
|
41
|
-
percent: (Math.min(
|
|
150
|
+
percent: (Math.min(observedHeaders, snapshot.height) / snapshot.height) * 100,
|
|
151
|
+
lastError: null,
|
|
42
152
|
message,
|
|
43
153
|
});
|
|
44
154
|
if (info.headers >= snapshot.height) {
|
|
45
155
|
return;
|
|
46
156
|
}
|
|
47
|
-
if (
|
|
157
|
+
if (observedHeaders === 0 && peerCount === 0) {
|
|
48
158
|
noPeerSince ??= now();
|
|
49
159
|
if (now() - noPeerSince >= noPeerTimeoutMs) {
|
|
50
160
|
throw new Error(formatManagedSyncErrorMessage("bitcoind_no_peers_for_header_sync_check_internet_or_firewall"));
|
|
@@ -53,7 +163,7 @@ export async function waitForHeaders(rpc, snapshot, progress, options = {}) {
|
|
|
53
163
|
else {
|
|
54
164
|
noPeerSince = null;
|
|
55
165
|
}
|
|
56
|
-
await sleepImpl(HEADER_POLL_MS);
|
|
166
|
+
await sleepImpl(HEADER_POLL_MS, signal);
|
|
57
167
|
}
|
|
58
168
|
}
|
|
59
169
|
export async function waitForHeadersForTesting(rpc, snapshot = DEFAULT_SNAPSHOT_METADATA, progress, options) {
|
|
@@ -1,11 +1,15 @@
|
|
|
1
|
-
import type { BootstrapPaths, BootstrapPersistentState } from "./types.js";
|
|
1
|
+
import type { BootstrapPaths, BootstrapPersistentState, LoadedBootstrapState } from "./types.js";
|
|
2
2
|
import type { BootstrapPhase, SnapshotMetadata } from "../types.js";
|
|
3
|
+
export declare function loadBootstrapStateRecord(paths: BootstrapPaths, snapshot: SnapshotMetadata): Promise<LoadedBootstrapState>;
|
|
3
4
|
export declare function loadBootstrapState(paths: BootstrapPaths, snapshot: SnapshotMetadata): Promise<BootstrapPersistentState>;
|
|
4
5
|
export declare function saveBootstrapState(paths: BootstrapPaths, state: BootstrapPersistentState): Promise<void>;
|
|
5
6
|
export declare function createBootstrapStateForTesting(snapshot?: SnapshotMetadata): {
|
|
6
7
|
metadataVersion: number;
|
|
7
8
|
snapshot: SnapshotMetadata;
|
|
8
9
|
phase: BootstrapPhase;
|
|
10
|
+
integrityVersion: number;
|
|
11
|
+
chunkSizeBytes: number;
|
|
12
|
+
verifiedChunkCount: number;
|
|
9
13
|
downloadedBytes: number;
|
|
10
14
|
validated: boolean;
|
|
11
15
|
loadTxOutSetComplete: boolean;
|
|
@@ -18,6 +22,9 @@ export declare function loadBootstrapStateForTesting(paths: BootstrapPaths, snap
|
|
|
18
22
|
metadataVersion: number;
|
|
19
23
|
snapshot: SnapshotMetadata;
|
|
20
24
|
phase: BootstrapPhase;
|
|
25
|
+
integrityVersion: number;
|
|
26
|
+
chunkSizeBytes: number;
|
|
27
|
+
verifiedChunkCount: number;
|
|
21
28
|
downloadedBytes: number;
|
|
22
29
|
validated: boolean;
|
|
23
30
|
loadTxOutSetComplete: boolean;
|
|
@@ -30,6 +37,9 @@ export declare function saveBootstrapStateForTesting(paths: BootstrapPaths, stat
|
|
|
30
37
|
metadataVersion: number;
|
|
31
38
|
snapshot: SnapshotMetadata;
|
|
32
39
|
phase: BootstrapPhase;
|
|
40
|
+
integrityVersion: number;
|
|
41
|
+
chunkSizeBytes: number;
|
|
42
|
+
verifiedChunkCount: number;
|
|
33
43
|
downloadedBytes: number;
|
|
34
44
|
validated: boolean;
|
|
35
45
|
loadTxOutSetComplete: boolean;
|