telstore 0.1.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.
@@ -0,0 +1,327 @@
1
+ import { promises as fs } from 'node:fs'
2
+ import path from 'node:path'
3
+
4
+ import { Api } from 'telegram'
5
+ import { CustomFile } from 'telegram/client/uploads.js'
6
+
7
+ import { PART_SIZE, planChunks } from '../chunking.js'
8
+ import { chunkCaption, manifestCaption } from '../caption.js'
9
+ import { describeChat } from '../chat.js'
10
+ import { closeQuietly, connect as realConnect } from '../client.js'
11
+ import { configFile, defaultConfigDir, loadConfig } from '../config.js'
12
+ import { requireChat, resolveSettings } from '../settings.js'
13
+ import {
14
+ buildManifest,
15
+ chunkFileName,
16
+ manifestFileName,
17
+ newBackupId,
18
+ serializeManifest,
19
+ } from '../manifest.js'
20
+ import { createProgress, formatBytes, formatDuration } from '../progress.js'
21
+ import {
22
+ MAX_STATES,
23
+ clearState,
24
+ loadState,
25
+ markChunkDone,
26
+ pruneStates,
27
+ saveState,
28
+ stateFile,
29
+ stateKey,
30
+ } from '../state.js'
31
+ import { uploadRange } from '../uploader.js'
32
+
33
+ // Above this threshold the wait must be spelled out, per spec §8.
34
+ const LONG_WAIT_MS = 60_000
35
+
36
+ // A transient error that resolves itself on the next try is not news, and one line per
37
+ // occurrence buries the progress bar in a wall of text. Stay quiet until the third retry:
38
+ // by then the trouble has outlived two backoffs and is worth saying out loud.
39
+ const ANNOUNCE_AFTER_ATTEMPT = 3
40
+
41
+ // Chunks and manifests differ only in where the bytes come from. Everything Telegram is
42
+ // told about them — document, not preview; this exact file name — is decided once.
43
+ async function sendDocument(client, peer, { file, fileName, caption }) {
44
+ return await client.sendFile(peer, {
45
+ file,
46
+ caption,
47
+ forceDocument: true,
48
+ attributes: [new Api.DocumentAttributeFilename({ fileName })],
49
+ })
50
+ }
51
+
52
+ async function realSendChunk(client, peer, { inputFile, fileName, caption }) {
53
+ return await sendDocument(client, peer, { file: inputFile, fileName, caption })
54
+ }
55
+
56
+ async function realSendManifest(client, peer, { bytes, fileName, caption }) {
57
+ return await sendDocument(client, peer, {
58
+ file: new CustomFile(fileName, bytes.length, '', bytes),
59
+ fileName,
60
+ caption,
61
+ })
62
+ }
63
+
64
+ export async function runUpload(filePath, options = {}, deps = {}) {
65
+ const {
66
+ connect = realConnect,
67
+ sendChunk = realSendChunk,
68
+ sendManifest = realSendManifest,
69
+ disconnect = (client) => client.destroy(),
70
+ configDir = defaultConfigDir(),
71
+ partSize = PART_SIZE,
72
+ retryOptions = {},
73
+ writeErr = (line) => process.stderr.write(line),
74
+ log: writeLog = (line) => console.log(line),
75
+ silent = false,
76
+ onBackupId = () => {},
77
+ } = deps
78
+
79
+ const absPath = path.resolve(filePath)
80
+
81
+ let stat
82
+ try {
83
+ stat = await fs.stat(absPath)
84
+ } catch (err) {
85
+ if (err.code === 'ENOENT') throw new Error(`File does not exist: ${absPath}`)
86
+ throw err
87
+ }
88
+
89
+ if (!stat.isFile()) {
90
+ throw new Error(`${absPath} is not a file.`)
91
+ }
92
+
93
+ const config = await loadConfig(configDir)
94
+ const { values: settings, source } = resolveSettings(options, config, {
95
+ file: configFile(configDir),
96
+ })
97
+ const chat = requireChat(settings)
98
+ const concurrency = settings.concurrency
99
+
100
+ const key = stateKey(absPath, stat.size, stat.mtimeMs)
101
+
102
+ let state = await loadState(key, configDir)
103
+
104
+ // The chunks already in the chat were cut at the size this backup started with, and
105
+ // nothing can re-cut them. Carrying on at a different size would abandon every one of
106
+ // them in the chat, where telark can no longer find them — so an unfinished backup
107
+ // keeps its own chunk size, and a flag that disagrees is refused rather than obeyed.
108
+ //
109
+ // Only a flag is a disagreement. A configured chunkSize says what to use when nobody asks
110
+ // for anything, and this run asked for nothing — so the backup quietly keeps its own size
111
+ // rather than being refused over a preference set weeks ago for other files.
112
+ if (state && source('chunkSize') === 'flag' && settings.chunkSize !== state.chunkSize) {
113
+ const file = stateFile(key, configDir)
114
+ throw new Error(
115
+ `This unfinished backup is cut into ${formatBytes(state.chunkSize)} chunks, but ` +
116
+ `--chunk-size asks for ${formatBytes(settings.chunkSize)} — the chunks already in ` +
117
+ `${state.chat} cannot be re-cut. Run again without --chunk-size to carry on, or delete ` +
118
+ `${file} and run again to start a new backup, which leaves the chunks already sent ` +
119
+ 'sitting in the chat with nothing to point at them.',
120
+ )
121
+ }
122
+
123
+ const chunkSize = state ? state.chunkSize : settings.chunkSize
124
+
125
+ // A resumed upload takes its chunk size off disk, and nothing validated that file on the
126
+ // way in. planChunks will refuse an unusable one, but its message is about chunk sizes and
127
+ // would send the reader looking for a --chunk-size flag they never passed.
128
+ if (state && (!Number.isSafeInteger(chunkSize) || chunkSize < 1)) {
129
+ throw new Error(
130
+ `The record of this unfinished backup gives a chunk size of ${JSON.stringify(chunkSize)}, ` +
131
+ `which cannot be used. ${stateFile(key, configDir)} is damaged — delete it and run ` +
132
+ 'again to start a new backup, which leaves the chunks already sent sitting in the ' +
133
+ 'chat with nothing to point at them.',
134
+ )
135
+ }
136
+
137
+ const chunks = planChunks(stat.size, chunkSize)
138
+ const resuming = Boolean(state)
139
+
140
+ // Naming the way back rather than a flag to drop: the destination may have come from the
141
+ // command line or from the stored setting, and "run again without --to" is no help to
142
+ // someone who never typed one. Pointing at the chat itself is right either way.
143
+ if (resuming && state.chat !== String(chat)) {
144
+ const file = stateFile(key, configDir)
145
+ throw new Error(
146
+ `This unfinished backup is going to ${state.chat}, but the current command targets ${chat} — ` +
147
+ `a single backup cannot be split across two destinations. Run again with ` +
148
+ `--to ${state.chat} to carry on sending there, or delete ${file} and run again to ` +
149
+ `start a new backup in ${chat}.`,
150
+ )
151
+ }
152
+
153
+ if (!resuming) {
154
+ state = {
155
+ id: newBackupId(),
156
+ chat: String(chat),
157
+ path: absPath,
158
+ size: stat.size,
159
+ mtimeMs: stat.mtimeMs,
160
+ chunkSize,
161
+ done: {},
162
+ }
163
+ await saveState(key, state, configDir)
164
+
165
+ // Only a new backup adds to the directory, so this is the one place it can grow.
166
+ // The report goes out even when the caller asked for silence: this is not narration
167
+ // about a transfer, it is telark dropping the only record of someone else's chunks.
168
+ for (const gone of await pruneStates(configDir)) {
169
+ writeErr(
170
+ `\nDropped the record of unfinished backup ${gone.id}: telark keeps the ` +
171
+ `${MAX_STATES} most recent. The chunks it sent are still in ${gone.chat}, ` +
172
+ 'searchable by that id, but that backup can no longer be resumed.\n',
173
+ )
174
+ }
175
+ }
176
+
177
+ onBackupId(state.id)
178
+
179
+ const log = silent ? () => {} : writeLog
180
+ const warn = silent ? () => {} : writeErr
181
+
182
+ // Retries and FLOOD_WAIT must be announced: a silent FLOOD_WAIT_3600 leaves the user
183
+ // staring at a frozen progress bar for an hour, assuming the process has hung.
184
+ function onRetry(err, attempt, delayMs, elapsedMs = 0) {
185
+ if (delayMs > LONG_WAIT_MS) {
186
+ warn(
187
+ `\nTelegram wants ${formatDuration(delayMs / 1000)} of waiting before the next send ` +
188
+ `(${err.message}). telark is waiting and will carry on by itself, leave it running.\n`,
189
+ )
190
+ return
191
+ }
192
+
193
+ // The exception to staying quiet: an attempt that took a minute to fail spent that
194
+ // minute with the bar frozen, which is exactly what a hang looks like. Those are worth
195
+ // a line the first time, whatever the attempt number.
196
+ if (attempt < ANNOUNCE_AFTER_ATTEMPT && elapsedMs < LONG_WAIT_MS) return
197
+
198
+ warn(
199
+ `\nTemporary error (${err.message}), retry ${attempt} in ` +
200
+ `${formatDuration(delayMs / 1000)}.\n`,
201
+ )
202
+ }
203
+
204
+ log(`Backup ${state.id}`)
205
+ log(`File ${absPath} (${formatBytes(stat.size)}, ${chunks.length} chunks)`)
206
+ log(`To ${describeChat(chat)}\n`)
207
+
208
+ const client = await connect(config, { verbose: settings.verbose })
209
+
210
+ try {
211
+ // Everything already in the chat is reported before the bar exists. These lines go to
212
+ // stdout while the bar is rewritten on stderr with \r, so printed from inside the loop
213
+ // they would land straight on the line the bar keeps returning to.
214
+ const pending = []
215
+
216
+ for (const chunk of chunks) {
217
+ if (state.done[String(chunk.i)]) {
218
+ log(`Chunk ${chunk.i + 1}/${chunks.length} already uploaded, skipping.`)
219
+ continue
220
+ }
221
+
222
+ pending.push(chunk)
223
+ }
224
+
225
+ // A run that only failed to send the manifest has every chunk done and nothing left to
226
+ // transfer: no bar at all, rather than one that springs into existence at 100%.
227
+ if (pending.length > 0) {
228
+ const remaining = pending.reduce((sum, chunk) => sum + chunk.length, 0)
229
+
230
+ // One bar for the whole upload: the label names the chunk in flight, everything else
231
+ // describes the file. Chunks a previous run sent count towards the bar but not towards
232
+ // the speed, so an hour-old chunk cannot inflate the ETA of the ones still to go.
233
+ // warn is already the no-op when silent, and createProgress draws through nothing else.
234
+ const progress = createProgress({
235
+ total: stat.size,
236
+ done: stat.size - remaining,
237
+ label: `Chunk ${pending[0].i + 1}/${chunks.length}`,
238
+ write: warn,
239
+ })
240
+
241
+ try {
242
+ for (const chunk of pending) {
243
+ progress.setLabel(`Chunk ${chunk.i + 1}/${chunks.length}`)
244
+
245
+ const fileName = chunkFileName(state.id, chunk.i)
246
+ const handle = await fs.open(absPath, 'r')
247
+
248
+ try {
249
+ const { inputFile, sha256 } = await uploadRange(client, handle.fd, {
250
+ offset: chunk.offset,
251
+ length: chunk.length,
252
+ fileName,
253
+ concurrency,
254
+ partSize,
255
+ onProgress: (bytes) => progress.advance(bytes),
256
+ retryOptions: { ...retryOptions, onRetry },
257
+ })
258
+
259
+ const message = await sendChunk(client, chat, {
260
+ inputFile,
261
+ fileName,
262
+ caption: chunkCaption({ id: state.id, number: chunk.i + 1, total: chunks.length }),
263
+ })
264
+
265
+ state = await markChunkDone(
266
+ key,
267
+ state,
268
+ chunk.i,
269
+ { msgId: message.id, size: chunk.length, sha256 },
270
+ configDir,
271
+ )
272
+ } finally {
273
+ await handle.close()
274
+ }
275
+ }
276
+ } finally {
277
+ // Same reason as restore: a send that fails must not leave "Error: ..." printed over
278
+ // the bar's own line.
279
+ progress.finish()
280
+ }
281
+ }
282
+
283
+ // The file can be overwritten mid-upload — with a 50GB file an hour passes between
284
+ // the first and last chunk. The manifest would then describe a hybrid file that never
285
+ // existed: restore still matches every sha256, but the data is garbage.
286
+ const after = await fs.stat(absPath)
287
+
288
+ if (after.size !== stat.size || after.mtimeMs !== stat.mtimeMs) {
289
+ throw new Error(
290
+ `${absPath} changed during the upload ` +
291
+ `(size ${stat.size} → ${after.size}, mtime ${stat.mtimeMs} → ${after.mtimeMs}). ` +
292
+ 'This backup mixes old and new data and cannot be trusted — telark is not sending the manifest. ' +
293
+ 'Wait until the file settles, then run again to create a new backup.',
294
+ )
295
+ }
296
+
297
+ const manifest = buildManifest({
298
+ id: state.id,
299
+ name: path.basename(absPath),
300
+ size: stat.size,
301
+ chunkSize,
302
+ chunks: chunks.map((chunk) => ({ i: chunk.i, ...state.done[String(chunk.i)] })),
303
+ })
304
+
305
+ await sendManifest(client, chat, {
306
+ bytes: serializeManifest(manifest),
307
+ fileName: manifestFileName(state.id),
308
+ caption: manifestCaption({
309
+ id: manifest.id,
310
+ name: manifest.name,
311
+ size: manifest.size,
312
+ chunks: manifest.chunks.length,
313
+ createdAt: manifest.createdAt,
314
+ }),
315
+ })
316
+
317
+ await clearState(key, configDir)
318
+
319
+ log(`\nDone. Restore with:\n npx telark restore ${state.id}`)
320
+
321
+ return { id: state.id, chunks: chunks.length }
322
+ } finally {
323
+ await closeQuietly(client, disconnect, (err) =>
324
+ warn(`\nWarning: could not close the Telegram connection: ${err.message}\n`),
325
+ )
326
+ }
327
+ }
package/src/config.js ADDED
@@ -0,0 +1,86 @@
1
+ import { promises as fs } from 'node:fs'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+
5
+ const FILE_NAME = 'config.json'
6
+
7
+ export function defaultConfigDir() {
8
+ return path.join(os.homedir(), '.telark')
9
+ }
10
+
11
+ export function configFile(dir = defaultConfigDir()) {
12
+ return path.join(dir, FILE_NAME)
13
+ }
14
+
15
+ function isPlainObject(value) {
16
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
17
+ }
18
+
19
+ // `config.json` is hand-editable now that the `config` command invites people into it,
20
+ // which puts it in the same category as a manifest or a state file: believed only after it
21
+ // has been checked. A `settings` that is not an object would make every lookup below it
22
+ // return undefined, and telark would then run happily on built-in defaults while the
23
+ // user's own choices sat there ignored — so it is named and refused instead.
24
+ export function checkConfigShape(raw, file) {
25
+ if (!isPlainObject(raw)) {
26
+ throw new Error(
27
+ `Corrupt config file: ${file} holds ${Array.isArray(raw) ? 'a list' : typeof raw}, ` +
28
+ 'not a group of settings.',
29
+ )
30
+ }
31
+
32
+ if (raw.settings !== undefined && !isPlainObject(raw.settings)) {
33
+ throw new Error(
34
+ `"settings" in ${file} holds ${Array.isArray(raw.settings) ? 'a list' : typeof raw.settings}, ` +
35
+ 'not a group of settings. telark will not guess what was meant — fix that entry, ' +
36
+ 'or remove it to fall back to the defaults.',
37
+ )
38
+ }
39
+
40
+ return raw
41
+ }
42
+
43
+ export async function loadConfig(dir = defaultConfigDir()) {
44
+ const file = configFile(dir)
45
+
46
+ let raw
47
+ try {
48
+ raw = await fs.readFile(file, 'utf8')
49
+ } catch (err) {
50
+ if (err.code === 'ENOENT') return {}
51
+ throw err
52
+ }
53
+
54
+ let parsed
55
+ try {
56
+ parsed = JSON.parse(raw)
57
+ } catch (err) {
58
+ throw new Error(
59
+ `Corrupt config file: ${file} is not valid JSON (${err.message}). ` +
60
+ 'Fix the syntax to keep your session, or delete the file and run "telark login" again.',
61
+ )
62
+ }
63
+
64
+ return checkConfigShape(parsed, file)
65
+ }
66
+
67
+ // Config and state are both small JSON files holding something that must survive a crash
68
+ // mid-write: write beside the target, then rename, which is atomic on the same filesystem.
69
+ export async function writeJsonAtomic(file, value) {
70
+ await fs.mkdir(path.dirname(file), { recursive: true, mode: 0o700 })
71
+
72
+ const tmp = `${file}.tmp`
73
+
74
+ await fs.writeFile(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 })
75
+ await fs.rename(tmp, file)
76
+ }
77
+
78
+ export async function saveConfig(config, dir = defaultConfigDir()) {
79
+ await writeJsonAtomic(configFile(dir), config)
80
+ }
81
+
82
+ export async function clearSession(dir = defaultConfigDir()) {
83
+ const config = await loadConfig(dir)
84
+ delete config.session
85
+ await saveConfig(config, dir)
86
+ }
package/src/confirm.js ADDED
@@ -0,0 +1,11 @@
1
+ import readline from 'node:readline/promises'
2
+ import { stdin, stdout } from 'node:process'
3
+
4
+ // Anything but a yes is a no. Both commands that ask are about to do something that cannot
5
+ // be taken back, so a stray keystroke or an empty line has to mean stop.
6
+ export async function askConfirm(question) {
7
+ const rl = readline.createInterface({ input: stdin, output: stdout })
8
+ const answer = await rl.question(question)
9
+ rl.close()
10
+ return /^y/i.test(answer.trim())
11
+ }
@@ -0,0 +1,190 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { read as readCallback, write as writeCallback } from 'node:fs'
3
+ import { promisify } from 'node:util'
4
+
5
+ import { returnBigInt } from 'telegram/Helpers.js'
6
+
7
+ import { DEFAULT_CONCURRENCY, PART_SIZE, SLICE_SIZE } from './chunking.js'
8
+ import { withRetry } from './retry.js'
9
+ import { DEFAULT_STALL_MS, withStallTimeout } from './stall.js'
10
+
11
+ const write = promisify(writeCallback)
12
+ const read = promisify(readCallback)
13
+
14
+ // Read back in far bigger bites than the 512KB the network hands us: this loop is pure disk.
15
+ const HASH_READ_SIZE = 4 * 1024 * 1024
16
+
17
+ async function writeExactly(fd, buffer, position) {
18
+ let written = 0
19
+
20
+ while (written < buffer.length) {
21
+ const { bytesWritten } = await write(fd, buffer, written, buffer.length - written, position + written)
22
+ written += bytesWritten
23
+ }
24
+ }
25
+
26
+ async function readExactly(fd, length, position) {
27
+ const buffer = Buffer.allocUnsafe(length)
28
+ let filled = 0
29
+
30
+ while (filled < length) {
31
+ const { bytesRead } = await read(fd, buffer, filled, length - filled, position + filled)
32
+ if (bytesRead === 0) {
33
+ throw new Error(`Short read: needed ${length} bytes at offset ${position} but the file ended.`)
34
+ }
35
+ filled += bytesRead
36
+ }
37
+
38
+ return buffer
39
+ }
40
+
41
+ // The digest is taken from the assembled range on disk rather than from the buffers as they
42
+ // arrive. Once slices land out of order that is the only order left to hash in, and it is
43
+ // the better check anyway: a slice written at the wrong offset, two slices overlapping, or
44
+ // one silently skipped all show up here. It does not prove the bytes reached the platter —
45
+ // this read may well be served from the page cache — it proves the assembly.
46
+ async function hashRange(fd, offset, length) {
47
+ const hash = createHash('sha256')
48
+
49
+ for (let at = 0; at < length; at += HASH_READ_SIZE) {
50
+ hash.update(await readExactly(fd, Math.min(HASH_READ_SIZE, length - at), offset + at))
51
+ }
52
+
53
+ return hash.digest('hex')
54
+ }
55
+
56
+ export async function downloadToFile(
57
+ client,
58
+ message,
59
+ fd,
60
+ { offset, onProgress, retryOptions, concurrency = DEFAULT_CONCURRENCY, stallMs = DEFAULT_STALL_MS } = {},
61
+ ) {
62
+ const document = message?.media?.document
63
+
64
+ if (!document) {
65
+ throw new Error(`Message ${message?.id} has no file attached.`)
66
+ }
67
+
68
+ if (!Number.isFinite(offset)) {
69
+ throw new Error(`offset must be a finite number, got: ${offset}`)
70
+ }
71
+
72
+ // A pool of fewer than one worker does no work. In the download path that means
73
+ // Promise.all resolves at once and a chunk nobody fetched is reported as complete; in the
74
+ // upload path the batch loop never advances and the process spins in microtasks, which not
75
+ // even a test timeout can interrupt. Neither is something a caller should be able to ask
76
+ // for by accident.
77
+ if (!Number.isInteger(concurrency) || concurrency < 1) {
78
+ throw new Error(
79
+ `Worker count must be a whole number of at least one, got: ${JSON.stringify(concurrency)}.`,
80
+ )
81
+ }
82
+
83
+ // Telegram's own record of how long the document is. Taking the length from the caller
84
+ // instead would make runRestore's size check compare the manifest against itself.
85
+ const size = returnBigInt(document.size ?? 0).toJSNumber()
86
+
87
+ if (!Number.isFinite(size) || size <= 0) {
88
+ throw new Error(`Message ${message?.id} has a document with no usable size.`)
89
+ }
90
+
91
+ const sliceCount = Math.ceil(size / SLICE_SIZE)
92
+
93
+ // One slice is one short stream. `done` lives outside withRetry, so a retried slice picks
94
+ // up at its own watermark instead of fetching again what it already has.
95
+ //
96
+ // The two offsets are not the same number: `start + done` is a position inside the
97
+ // document, which is where the stream resumes, while `offset + start + done` is a position
98
+ // inside the file being assembled, which is where the bytes land.
99
+ async function downloadSlice(index) {
100
+ const start = index * SLICE_SIZE
101
+ const length = Math.min(SLICE_SIZE, size - start)
102
+ let done = 0
103
+
104
+ await withRetry(async () => {
105
+ // Iterated by hand rather than with `for await` so each part can be given a deadline:
106
+ // a stalled stream yields nothing and raises nothing, and only a race against a timer
107
+ // turns that silence into an error withRetry can act on. Nothing is lost by stepping
108
+ // outside `for await` — GramJS's download iterator exposes `next` alone, so breaking
109
+ // out of the loop never closed anything either.
110
+ const stream = client.iterDownload({
111
+ file: message.media,
112
+ offset: returnBigInt(start + done),
113
+ requestSize: PART_SIZE,
114
+ })
115
+ const parts = stream[Symbol.asyncIterator]()
116
+
117
+ for (;;) {
118
+ const { value: buffer, done: ended } = await withStallTimeout(
119
+ parts.next(),
120
+ stallMs,
121
+ () =>
122
+ `Telegram stopped sending slice ${index + 1}/${sliceCount} of message ` +
123
+ `${message?.id}: nothing arrived for ${Math.round(stallMs / 1000)}s after ` +
124
+ `${done} of ${length} bytes.`,
125
+ )
126
+
127
+ if (ended) break
128
+
129
+ // The stream runs to the end of the document; this slice stops at its own boundary.
130
+ const take = Math.min(buffer.length, length - done)
131
+
132
+ await writeExactly(fd, buffer.subarray(0, take), offset + start + done)
133
+ done += take
134
+ onProgress?.(take)
135
+
136
+ if (done >= length) break
137
+ }
138
+
139
+ // A stream that ends before the slice's own boundary is a short delivery, not a
140
+ // completed slice — without this, a hole here would surface only as a digest
141
+ // mismatch, telling the user their data is corrupt when it was simply cut short.
142
+ // Checked inside the retried callback so a transient short stream is retried, and a
143
+ // stream that yields nothing at all still counts as a failure worth retrying.
144
+ if (done < length) {
145
+ throw new Error(
146
+ `Slice ${index + 1}/${sliceCount} of message ${message?.id} ended after ${done} of ${length} bytes.`,
147
+ )
148
+ }
149
+ }, retryOptions)
150
+ }
151
+
152
+ let next = 0
153
+ let failed = false
154
+ let failure = null
155
+
156
+ const worker = async () => {
157
+ for (;;) {
158
+ // Stop handing out work the moment anything has failed: the chunk is lost either way,
159
+ // and every further request is bandwidth spent on a file about to be thrown away.
160
+ if (failed) return
161
+
162
+ const index = next
163
+ next += 1
164
+ if (index >= sliceCount) return
165
+
166
+ try {
167
+ await downloadSlice(index)
168
+ } catch (err) {
169
+ // Not `failure ??= err`: a falsy rejection reason would leave `failure` falsy and the
170
+ // throw below would read as success, turning a broken chunk into a silent one. The
171
+ // first error is the one kept — later ones are usually consequences of the shutdown
172
+ // rather than the cause.
173
+ if (!failed) {
174
+ failed = true
175
+ failure = err
176
+ }
177
+ return
178
+ }
179
+ }
180
+ }
181
+
182
+ // Every worker absorbs its own error above, so none of these promises rejects and none can
183
+ // become an unhandledRejection that hides the real one. This await is also what guarantees
184
+ // no write is still in flight when the function returns.
185
+ await Promise.all(Array.from({ length: Math.min(concurrency, sliceCount) }, worker))
186
+
187
+ if (failed) throw failure
188
+
189
+ return { sha256: await hashRange(fd, offset, size), size }
190
+ }