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/README.md +110 -5
- package/bin/telstore.js +272 -4
- package/package.json +1 -1
- package/src/caption.js +6 -1
- package/src/cli.js +279 -12
- package/src/client.js +168 -9
- package/src/commands/delete.js +335 -43
- package/src/commands/down.js +311 -0
- package/src/commands/list.js +170 -16
- package/src/commands/restore-stream.js +407 -0
- package/src/commands/restore.js +27 -20
- package/src/commands/status.js +190 -19
- package/src/commands/upload-stream.js +459 -0
- package/src/commands/upload.js +37 -25
- package/src/commands/verify.js +294 -0
- package/src/manifest.js +59 -2
- package/src/progress.js +86 -0
- package/src/shell.js +33 -0
- package/src/spawn.js +38 -0
- package/src/state.js +78 -0
- package/src/stream.js +295 -0
- package/src/tar.js +23 -0
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
|
+
}
|