telstore 0.1.8 → 0.1.10

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/src/stream.js ADDED
@@ -0,0 +1,295 @@
1
+ import { promises as fs } from 'node:fs'
2
+
3
+ import { formatBytes } from './progress.js'
4
+
5
+ // `handle.write(buffer)` is not guaranteed to write the whole buffer in one call. `bytes`
6
+ // becomes the chunk's recorded length, so trusting an unchecked write would let telstore
7
+ // claim more reached disk than actually did — silently wrong data, the one thing this
8
+ // project refuses to produce. Loops until every byte of `buffer` has landed, or throws if
9
+ // a write reports zero bytes written (which would otherwise spin forever rather than fail).
10
+ async function writeFully(handle, buffer) {
11
+ let written = 0
12
+
13
+ while (written < buffer.length) {
14
+ const { bytesWritten } = await handle.write(buffer.subarray(written))
15
+
16
+ if (bytesWritten === 0) {
17
+ throw new Error('write wrote 0 bytes; refusing to spin retrying it')
18
+ }
19
+
20
+ written += bytesWritten
21
+ }
22
+ }
23
+
24
+ // One reader over the life of an upload: it holds the iterator, so the bytes a chunk did
25
+ // not want are the first bytes of the next chunk rather than something dropped between
26
+ // two reads. Pulling through an async iterator is also what gives backpressure for free —
27
+ // while a chunk uploads, nothing calls next(), so the child blocks on its own write and
28
+ // the backlog stays in the pipe instead of in this process.
29
+ export class ChunkReader {
30
+ constructor(readable) {
31
+ this.readable = readable
32
+ this.iterator = readable[Symbol.asyncIterator]()
33
+ this.pending = null
34
+ this.ended = false
35
+ this.error = null
36
+ }
37
+
38
+ // Writes at most `limit` bytes into `handle`. `eof` means the stream is finished and
39
+ // there will never be more — reported on the fill that meets the end, and on every one
40
+ // after it. An error from the source propagates out of the awaited `next()` call as a
41
+ // rejection of this method; it is never mistaken for `done`.
42
+ //
43
+ // Once the source has thrown, every later call rejects with that same error rather than
44
+ // asking the iterator again. An async generator that has already thrown reports `done:
45
+ // true` on the next `next()` call rather than throwing again, which — left unguarded —
46
+ // would turn one real failure into a clean end of stream on the very next fill.
47
+ async fill(handle, limit) {
48
+ if (this.error !== null) throw this.error
49
+
50
+ let bytes = 0
51
+
52
+ while (bytes < limit) {
53
+ if (this.pending === null) {
54
+ if (this.ended) break
55
+
56
+ let next
57
+ try {
58
+ next = await this.iterator.next()
59
+ } catch (err) {
60
+ this.error = err
61
+ throw err
62
+ }
63
+
64
+ const { value, done } = next
65
+
66
+ if (done) {
67
+ this.ended = true
68
+ break
69
+ }
70
+
71
+ this.pending = value
72
+ }
73
+
74
+ const room = limit - bytes
75
+ const take = this.pending.length <= room ? this.pending : this.pending.subarray(0, room)
76
+
77
+ await writeFully(handle, take)
78
+ bytes += take.length
79
+
80
+ this.pending =
81
+ take.length === this.pending.length ? null : this.pending.subarray(take.length)
82
+ }
83
+
84
+ return { bytes, eof: this.ended && this.pending === null }
85
+ }
86
+
87
+ // What a failed upload calls on the way out. The iterator is abandoned mid-stream then, and
88
+ // it is the iterator — not the caller — that holds the source open: returning it is what
89
+ // releases the stream, so a producer blocked writing into a pipe nobody is reading stops
90
+ // being blocked. Nothing it has to say can matter by then, because an error is already on
91
+ // its way out of the caller, so a refusal to close is swallowed rather than thrown over it.
92
+ //
93
+ // The source is destroyed as well, and not for symmetry. `return()` on an async generator
94
+ // that has never been started does not run the body, so it never reaches the `finally` a
95
+ // Node stream's iterator destroys the stream in — measured on node 22, not assumed. A
96
+ // reader closed before its first fill() would otherwise leave the child blocked on a pipe
97
+ // with nothing to unblock it.
98
+ async close() {
99
+ // An abort is not an end. Arming the same sticky error a producer's own failure sets is
100
+ // what stops a later fill() answering `eof: true` for a read that stopped with bytes
101
+ // still unread — mistaking one for the other is the thing this class exists to refuse.
102
+ // `??=` because the reason the source stopped is worth more than the fact that telstore
103
+ // then closed it, and close() runs on the path where that reason is already on its way
104
+ // out to the caller.
105
+ this.error ??= new Error(
106
+ 'The stream was closed before it ended, so nothing more can be read from it.',
107
+ )
108
+ this.pending = null
109
+
110
+ try {
111
+ await this.iterator.return?.()
112
+ } catch {
113
+ // Nothing here can change what already went wrong.
114
+ }
115
+
116
+ try {
117
+ this.readable.destroy?.()
118
+ } catch {
119
+ // As above.
120
+ }
121
+ }
122
+ }
123
+
124
+ // What a destination that has gone away is reported as, and a class rather than a message so
125
+ // the caller can tell it from this function's own refusal below without matching on words. The
126
+ // restore direction has to say different things about a command that stopped reading and a
127
+ // chunk file that came up short, and a check on the spelling of an error is a check that goes
128
+ // quietly wrong the first time a node release rewords one.
129
+ export class DestinationGoneError extends Error {
130
+ constructor(cause) {
131
+ super(
132
+ cause === null
133
+ ? 'The destination closed before the chunk was through.'
134
+ : `The destination stopped accepting bytes: ${cause.message}`,
135
+ { cause: cause ?? undefined },
136
+ )
137
+ this.name = 'DestinationGoneError'
138
+ }
139
+ }
140
+
141
+ // The other direction from ChunkReader, and hand-rolled for a reason of the same kind.
142
+ // `pipeline` was the obvious choice here and the wrong one: `{ end: false }` is what lets a
143
+ // command see one stream rather than one per chunk, and a destination that is never ended is a
144
+ // destination `pipeline` never cleans up after — its error, close, finish and end handlers stay
145
+ // on it for the life of the run. Measured on node 22: one handler per chunk on a PassThrough,
146
+ // four on a child's stdin, MaxListenersExceededWarnings torn through the progress bar by the
147
+ // eleventh chunk, and at MAX_CHUNKS around 40,000 closures on one emitter each retaining a
148
+ // finished pipeline's graph. So the loop is explicit, and every listener it adds it takes off
149
+ // again on the way out.
150
+ //
151
+ // What the loop has to keep, because the destination is a command's stdin and not a file:
152
+ // backpressure (a chunk can be 1800MB and must never sit whole in this process), a failure
153
+ // that rejects rather than waits forever, the handle left usable for the next chunk
154
+ // (`autoClose: false`, measured on node 22 rather than assumed), and the destination left open.
155
+ export async function writeChunkTo(writable, handle, length, { onProgress = () => {} } = {}) {
156
+ // `end: length - 1` is inclusive, so zero has to be turned away before it asks for byte -1.
157
+ if (length === 0) return
158
+
159
+ let seen = 0
160
+ let failure = null
161
+ let wake = null
162
+
163
+ // Latched for the whole pump rather than only while a drain is being waited for: a
164
+ // destination can fail in the middle of a write this loop is not waiting on, and a loop that
165
+ // noticed only at its next stall would go on reading into a pipe that has gone. `close`
166
+ // counts for as much as `error` — a command that leaves without a word closes its end and
167
+ // emits nothing else, and a drain that can never come is the hang this project refuses
168
+ // everywhere — so both of them wake the wait below as well as arming it.
169
+ const fail = (err) => {
170
+ failure ??= new DestinationGoneError(err ?? null)
171
+ if (wake) wake()
172
+ }
173
+
174
+ const onError = (err) => fail(err)
175
+ const onClose = () => fail(null)
176
+ const onDrain = () => {
177
+ if (wake) wake()
178
+ }
179
+
180
+ writable.on('error', onError)
181
+ writable.on('close', onClose)
182
+ writable.on('drain', onDrain)
183
+
184
+ // The failure to raise now: the one the listeners caught, or a destination that had already
185
+ // gone before this function was ever called. That second one cannot be listened for, because
186
+ // there is nothing left to hear — measured on node 22, `write()` into a destroyed or an
187
+ // already-ended stream returns false and emits nothing at all, node's `errorOrDestroy` bailing
188
+ // out on a stream that is already destroyed — so 'error', 'close' and 'drain' are all events
189
+ // that can no longer arrive and the wait below would be forever. The `pipeline` this replaced
190
+ // rejected on it; a command that takes one chunk and closes its end at the chunk boundary is
191
+ // how a restore gets here, and the `failure === null` guard on the wait does not help, because
192
+ // that covers a destination that died during a write rather than before one.
193
+ const failureNow = () => {
194
+ if (failure === null && (writable.destroyed || writable.writableEnded)) fail(null)
195
+
196
+ return failure
197
+ }
198
+ try {
199
+ // Inside the try, because it throws: `createReadStream` on a handle that is already closed
200
+ // fails synchronously (measured: ERR_OUT_OF_RANGE on fd -1), and opened above this line that
201
+ // would leave the three listeners on somebody's stdin — the exact leak this loop exists to
202
+ // have fixed. Not a 'data' listener on the stream either: attaching one switches it to
203
+ // flowing mode and the backpressure this function exists to honour goes with it. `for await`
204
+ // pulls instead, so nothing is read while a write is unacknowledged.
205
+ //
206
+ // Nothing destroys it by hand, which is measured rather than tidy. On node 22 a `destroy()`
207
+ // on a read stream opened this way closes the FileHandle under it whatever `autoClose` says
208
+ // — EBADF on the next read — and the next chunk would then ask a closed handle for a stream.
209
+ // What the two exits really do, on the same node: leaving the loop by a throw destroys the
210
+ // stream through the iterator's `return()`, while reaching the end of it does not destroy
211
+ // the stream at all — it is finished, holds no fd of its own, and the caller's handle is
212
+ // untouched either way.
213
+ const source = handle.createReadStream({ start: 0, end: length - 1, autoClose: false })
214
+
215
+ for await (const bytes of source) {
216
+ // At the top, because this is where a failure is raised: one that landed while the loop was
217
+ // waiting — for a read, or for the drain below — and one that was already there before the
218
+ // first read. Before the write and before the count, so a destination that has gone is
219
+ // handed nothing more: the bytes reported here are what the caller's running total and its
220
+ // failure message are built from, and one buffer more would be up to 64KB the message
221
+ // claims and the destination never took.
222
+ if (failureNow() !== null) throw failure
223
+
224
+ seen += bytes.length
225
+ onProgress(bytes.length)
226
+
227
+ // Handed over, not accepted: what this counts is what telstore wrote into the
228
+ // destination, which is as much as anything on this side can know. Whether the command
229
+ // on the far end ever read it is what its exit code answers.
230
+ //
231
+ // `failureNow()` is what stops the wait outliving the thing it is waiting for: a drain
232
+ // that has been overtaken by an error, a close, or a destination that was already gone is
233
+ // a drain that will never come, and waiting for it is the hang this project refuses
234
+ // everywhere. A failure that lands during the wait wakes it instead, and the check at the
235
+ // top of the next turn — or the one after the loop, on the last buffer — raises it.
236
+ if (!writable.write(bytes) && failureNow() === null) {
237
+ await new Promise((resolve) => {
238
+ wake = resolve
239
+ })
240
+ wake = null
241
+ }
242
+ }
243
+ } finally {
244
+ writable.off('error', onError)
245
+ writable.off('close', onClose)
246
+ writable.off('drain', onDrain)
247
+ }
248
+
249
+ // A failure that landed on the last write, after the loop had no more bytes to check it
250
+ // against. Accepted is not delivered: `write()` returning true means the destination took the
251
+ // bytes into its own buffer, and the EPIPE from a far end that has gone can arrive after that.
252
+ // Those bytes never reached the command, so this is not a chunk that went over.
253
+ if (failureNow() !== null) throw failure
254
+
255
+ // `createReadStream` stops at the file's real end of data without complaining when `end`
256
+ // reaches past it, so a chunk file shorter than `length` makes the loop above finish having
257
+ // moved too few bytes. That must not be read as success: the caller's running total is built
258
+ // from the size it was told to expect, so a short chunk here would become a truncated stream
259
+ // handed to somebody's tar and reported as a finished restore — the one thing this project
260
+ // refuses to do. This is the only failure this function raises on its own account; everything
261
+ // else that comes out of it belongs to the destination or to the file being read.
262
+ if (seen !== length) {
263
+ throw new Error(
264
+ `The chunk file held ${seen} bytes, but ${length} were asked for — the chunk is ` +
265
+ 'shorter than the manifest says it should be. Refusing to hand the destination a ' +
266
+ 'truncated stream and call it done.',
267
+ )
268
+ }
269
+ }
270
+
271
+ // The borrowing ends whether the chunk went out or the run fell over on it. close() failing
272
+ // must not be what stops the unlink — the file would sit there holding a whole chunk that
273
+ // nothing will ever remove — and a removal that fails must not replace the error already on
274
+ // its way out of the loop, so it is said on stderr rather than thrown.
275
+ //
276
+ // On writeErr rather than warn, like the prune report and for the same reason: a leaked file
277
+ // holding up to 1.8GB is not narration about a transfer that --silent asked to be spared. It
278
+ // is telstore leaving something on this machine that only the user can now clear up, and a
279
+ // caller silencing the progress bar has not asked to be kept in the dark about that.
280
+ export async function discardChunkFile(handle, file, { writeErr, chunkSize }) {
281
+ try {
282
+ await handle.close()
283
+ } catch {
284
+ // The file is about to be unlinked; whatever close had to say about it changes nothing.
285
+ }
286
+
287
+ try {
288
+ await fs.rm(file, { force: true })
289
+ } catch (err) {
290
+ writeErr(
291
+ `\nCould not remove the temporary chunk file ${file}: ${err.message}. It holds up to ` +
292
+ `${formatBytes(chunkSize)} and telstore will not try again — remove it by hand.\n`,
293
+ )
294
+ }
295
+ }
package/src/tar.js ADDED
@@ -0,0 +1,23 @@
1
+ // What the tar shortcuts know about names, and all they know. Pure strings: the parser needs
2
+ // it before anything is open and the restore command needs it before anything is downloaded,
3
+ // so it belongs to neither of them.
4
+ //
5
+ // `tarc` always compresses. A backup called a.tar holding gzip bytes would be a name that
6
+ // lies to whoever restores it, and the name is what `list` shows and `--search` matches, so
7
+ // it is the one part of this that cannot be left to the person typing.
8
+ const GZIP_SUFFIXES = ['.tar.gz', '.tgz']
9
+
10
+ export function isGzipName(name) {
11
+ const lower = String(name).toLowerCase()
12
+
13
+ return GZIP_SUFFIXES.some((suffix) => lower.endsWith(suffix))
14
+ }
15
+
16
+ // Appended in lower case, matched without case: A.TAR becomes A.TAR.gz rather than
17
+ // A.TAR.tar.gz, because the capitals are how someone typed it and not a second intention.
18
+ export function archiveName(name) {
19
+ if (isGzipName(name)) return name
20
+ if (String(name).toLowerCase().endsWith('.tar')) return `${name}.gz`
21
+
22
+ return `${name}.tar.gz`
23
+ }