telstore 0.1.9 → 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 +74 -2
- package/bin/telstore.js +257 -4
- package/package.json +1 -1
- package/src/caption.js +6 -1
- package/src/cli.js +265 -10
- package/src/client.js +7 -1
- package/src/commands/delete.js +333 -41
- package/src/commands/down.js +311 -0
- package/src/commands/list.js +1 -31
- 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 +1 -5
- package/src/manifest.js +53 -1
- 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/progress.js
CHANGED
|
@@ -47,6 +47,57 @@ export function renderProgress({ done, total, elapsedMs, label, width = 24, tran
|
|
|
47
47
|
return `${label} ${bar} ${percent}% ${formatBytes(done)}/${formatBytes(total)} ${speed} ETA ${formatDuration(remaining)}`
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
// A percentage of an unknown total is an invented number, and an ETA from one is worse: it
|
|
51
|
+
// would count down to a finish nobody can predict.
|
|
52
|
+
export function renderStreamProgress({ done, elapsedMs, label }) {
|
|
53
|
+
const bytesPerSecond = elapsedMs > 0 ? done / (elapsedMs / 1000) : 0
|
|
54
|
+
|
|
55
|
+
return `${label} ${formatBytes(done)} sent ${formatBytes(Math.round(bytesPerSecond))}/s`
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function createStreamProgress({
|
|
59
|
+
label,
|
|
60
|
+
write = (line) => process.stderr.write(line),
|
|
61
|
+
now = () => Date.now(),
|
|
62
|
+
minIntervalMs = 200,
|
|
63
|
+
}) {
|
|
64
|
+
const startedAt = now()
|
|
65
|
+
let done = 0
|
|
66
|
+
let currentLabel = label
|
|
67
|
+
let lastDrawnAt = startedAt
|
|
68
|
+
let widestLine = 0
|
|
69
|
+
|
|
70
|
+
// Same \r discipline as createProgress: a redraw shorter than the one before it would
|
|
71
|
+
// leave the previous line's tail on screen, so pad every line out to the widest drawn so far.
|
|
72
|
+
function draw(suffix) {
|
|
73
|
+
const line = renderStreamProgress({ done, elapsedMs: now() - startedAt, label: currentLabel })
|
|
74
|
+
widestLine = Math.max(widestLine, line.length)
|
|
75
|
+
write(`\r${line.padEnd(widestLine)}${suffix}`)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
advance(bytes) {
|
|
80
|
+
done += bytes
|
|
81
|
+
if (now() - lastDrawnAt < minIntervalMs) return
|
|
82
|
+
lastDrawnAt = now()
|
|
83
|
+
draw('')
|
|
84
|
+
},
|
|
85
|
+
setLabel(next) {
|
|
86
|
+
currentLabel = next
|
|
87
|
+
lastDrawnAt = now()
|
|
88
|
+
draw('')
|
|
89
|
+
},
|
|
90
|
+
finish() {
|
|
91
|
+
draw('\n')
|
|
92
|
+
},
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// A number turned into words a person reads: "1 chunk" for one, "3 chunks" for the rest.
|
|
97
|
+
export function plural(n, word) {
|
|
98
|
+
return `${n} ${word}${n === 1 ? '' : 's'}`
|
|
99
|
+
}
|
|
100
|
+
|
|
50
101
|
export function createProgress({
|
|
51
102
|
total,
|
|
52
103
|
label,
|
|
@@ -99,3 +150,38 @@ export function createProgress({
|
|
|
99
150
|
},
|
|
100
151
|
}
|
|
101
152
|
}
|
|
153
|
+
|
|
154
|
+
// A walk of one page is over in about the time it takes to notice — 165ms against a real
|
|
155
|
+
// chat — and that is the usual case, so nothing is drawn for the first stretch: a line that
|
|
156
|
+
// appears and is wiped in the same breath is a flicker, not information. Past that the read
|
|
157
|
+
// is long enough that silence reads as the hang this project refuses everywhere else.
|
|
158
|
+
//
|
|
159
|
+
// \r only moves the cursor home, so every line is padded to the widest one drawn and the last
|
|
160
|
+
// write wipes the row: whatever the command prints next must never land on half a notice.
|
|
161
|
+
//
|
|
162
|
+
// Here rather than beside either caller: `list` walks a chat to find backups and `delete`
|
|
163
|
+
// walks it to find chunks nothing on this machine names, and two copies of "when is a read
|
|
164
|
+
// long enough to say something about" is how they start disagreeing about it.
|
|
165
|
+
const NOTICE_QUIET_MS = 400
|
|
166
|
+
const NOTICE_INTERVAL_MS = 200
|
|
167
|
+
|
|
168
|
+
export function createWalkNotice({ write, now, quietMs = NOTICE_QUIET_MS, intervalMs = NOTICE_INTERVAL_MS }) {
|
|
169
|
+
const startedAt = now()
|
|
170
|
+
let lastDrawnAt = 0
|
|
171
|
+
let widest = 0
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
tick(text) {
|
|
175
|
+
if (now() - startedAt < quietMs) return
|
|
176
|
+
if (lastDrawnAt !== 0 && now() - lastDrawnAt < intervalMs) return
|
|
177
|
+
|
|
178
|
+
lastDrawnAt = now()
|
|
179
|
+
widest = Math.max(widest, text.length)
|
|
180
|
+
write(`\r${text.padEnd(widest)}`)
|
|
181
|
+
},
|
|
182
|
+
clear() {
|
|
183
|
+
if (widest === 0) return
|
|
184
|
+
write(`\r${' '.repeat(widest)}\r`)
|
|
185
|
+
},
|
|
186
|
+
}
|
|
187
|
+
}
|
package/src/shell.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// A command meant to be pasted has to survive the shell that receives it: anything a shell
|
|
2
|
+
// would take apart comes back quoted, and a path with a space in it is the ordinary case,
|
|
3
|
+
// not an exotic one. `status` and `down` both print resume commands, and two copies of this
|
|
4
|
+
// rule is how they start disagreeing about which paths are safe to print bare.
|
|
5
|
+
const BARE_ARG = /^[A-Za-z0-9_@%+:,./-]+$/
|
|
6
|
+
|
|
7
|
+
export function shellArg(text) {
|
|
8
|
+
const value = String(text)
|
|
9
|
+
|
|
10
|
+
return BARE_ARG.test(value) ? value : `'${value.replaceAll("'", `'\\''`)}'`
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// The one line in telstore that destroys data when it is wrong. `delete` resolves its own
|
|
14
|
+
// destination from config and then fires the recorded message ids at whatever peer that turns
|
|
15
|
+
// out to be, so a command pasted next week — or one built here under a `--chat` this run was
|
|
16
|
+
// given — would remove whatever happens to carry those ids in the chat it resolves. Naming a
|
|
17
|
+
// chat that turns out to be the default costs a few characters; leaving it out when it is not
|
|
18
|
+
// costs somebody else's messages, and nothing undoes that. Four commands print this string
|
|
19
|
+
// (`upload-stream`'s rollback, `cli`'s second Ctrl-C, `status`, `down`) and they had four
|
|
20
|
+
// copies of the rule, which is how three of them stayed right and one drifted.
|
|
21
|
+
//
|
|
22
|
+
// The chatless branch is not a convenience: it is for the single caller that genuinely does
|
|
23
|
+
// not know where the chunks went. `bin/telstore.js` holds the chat the run handed it, and a
|
|
24
|
+
// Ctrl-C arriving before the run ever reported one leaves the id — the only part of the
|
|
25
|
+
// message with any value — rather than printing `--chat undefined`, which `delete` would read
|
|
26
|
+
// as no destination at all while looking like one. A caller that would rather print nothing
|
|
27
|
+
// than a command missing its chat decides that for itself before calling: `down` does, and
|
|
28
|
+
// says why beside its own check.
|
|
29
|
+
export function deleteCommand(id, chat) {
|
|
30
|
+
const where = chat === null || chat === undefined ? '' : String(chat).trim()
|
|
31
|
+
|
|
32
|
+
return `npx telstore delete ${shellArg(id)}${where === '' ? '' : ` --chat ${shellArg(where)}`}`
|
|
33
|
+
}
|
package/src/spawn.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
|
|
3
|
+
// stderr is inherited, never captured: when a producer command fails it explains itself in
|
|
4
|
+
// its own words, on the stream the user is already watching. telstore adds the exit code
|
|
5
|
+
// and what it did about it, and does not paraphrase.
|
|
6
|
+
//
|
|
7
|
+
// `exited` can be built before the caller has any chance to await it — the caller reads
|
|
8
|
+
// `stdout` first, and only awaits `exited` once the stream ends. A rejected promise with no
|
|
9
|
+
// handler attached yet makes Node report an unhandledRejection, so a no-op `.catch` is
|
|
10
|
+
// attached here immediately. That does not consume the rejection: the `exited` this function
|
|
11
|
+
// returns is the same promise, and `await`ing it later still resolves or rejects exactly as
|
|
12
|
+
// it would have.
|
|
13
|
+
export function spawnProducer(argv, { stdio = ['ignore', 'pipe', 'inherit'] } = {}) {
|
|
14
|
+
const [command, ...args] = argv
|
|
15
|
+
const child = spawn(command, args, { stdio })
|
|
16
|
+
|
|
17
|
+
const exited = new Promise((resolve, reject) => {
|
|
18
|
+
child.on('error', (err) => {
|
|
19
|
+
reject(
|
|
20
|
+
new Error(
|
|
21
|
+
err.code === 'ENOENT'
|
|
22
|
+
? `Cannot run ${command}: no such command on this machine.`
|
|
23
|
+
: `Cannot run ${command}: ${err.message}`,
|
|
24
|
+
),
|
|
25
|
+
)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
child.on('close', (code, signal) => resolve({ code, signal }))
|
|
29
|
+
})
|
|
30
|
+
exited.catch(() => {})
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
stdout: child.stdout,
|
|
34
|
+
stdin: child.stdin,
|
|
35
|
+
exited,
|
|
36
|
+
kill: (signal = 'SIGTERM') => child.kill(signal),
|
|
37
|
+
}
|
|
38
|
+
}
|
package/src/state.js
CHANGED
|
@@ -8,6 +8,69 @@ export function stateDir(configDir = defaultConfigDir()) {
|
|
|
8
8
|
return path.join(configDir, 'state')
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
// The other thing telstore keeps on this machine, and the only one that is not a record: a
|
|
12
|
+
// stream upload borrows one chunk of disk at a time here while it sends it. Under
|
|
13
|
+
// ~/.telstore rather than os.tmpdir() because /tmp is tmpfs on many Linux distributions, and
|
|
14
|
+
// "borrow one chunk of disk" would silently mean "borrow 1800MB of RAM" — a memory limit
|
|
15
|
+
// dressed up as a chunk size.
|
|
16
|
+
//
|
|
17
|
+
// It lives beside stateDir because it answers the same question — what has this machine got
|
|
18
|
+
// of telstore's on it — and because `status` has to be able to ask without importing the
|
|
19
|
+
// upload command, which would drag a second upload loop and teleproto in with it.
|
|
20
|
+
export function tempDirFor(configDir = defaultConfigDir()) {
|
|
21
|
+
return path.join(configDir, 'tmp')
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// What is in there now. A run removes its own chunk file on every ending it gets to run code
|
|
25
|
+
// for, so a file here is either a run happening at this moment or a run that was stopped
|
|
26
|
+
// where it stood — a SIGKILL, a crash, a machine losing power. Nothing here removes them:
|
|
27
|
+
// from outside the run that owns one, those two cases look exactly the same, and deleting
|
|
28
|
+
// the chunk a live upload is filling is the confident wrong thing this project refuses
|
|
29
|
+
// everywhere else. Naming them is the whole job.
|
|
30
|
+
//
|
|
31
|
+
// A stat that fails yields an unknown size rather than a dropped row, the same care
|
|
32
|
+
// listStates takes with mtimes: the file really can vanish between the readdir and the stat —
|
|
33
|
+
// that is what a run finishing normally does — and status is the command someone runs
|
|
34
|
+
// *because* something is wrong.
|
|
35
|
+
export async function listTempChunks(configDir = defaultConfigDir()) {
|
|
36
|
+
const dir = tempDirFor(configDir)
|
|
37
|
+
let names
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
names = await fs.readdir(dir)
|
|
41
|
+
} catch (err) {
|
|
42
|
+
// A machine that has never made a backup from a command has no such directory, and that
|
|
43
|
+
// is not a fault to report. Anything else is: a directory telstore cannot read may be
|
|
44
|
+
// holding a whole chunk, and answering "nothing there" would be the silent wrong answer
|
|
45
|
+
// this listing exists to prevent. The caller decides what to do with it.
|
|
46
|
+
if (err.code === 'ENOENT') return []
|
|
47
|
+
|
|
48
|
+
throw err
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const found = []
|
|
52
|
+
|
|
53
|
+
for (const name of names.sort()) {
|
|
54
|
+
const file = path.join(dir, name)
|
|
55
|
+
let size = null
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
const stat = await fs.stat(file)
|
|
59
|
+
|
|
60
|
+
if (!stat.isFile()) continue
|
|
61
|
+
|
|
62
|
+
size = stat.size
|
|
63
|
+
} catch {
|
|
64
|
+
// Gone or unreadable between the readdir and here. Still a name worth printing: the
|
|
65
|
+
// point of the listing is that nothing telstore left behind goes unmentioned.
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
found.push({ name, file, size })
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return found
|
|
72
|
+
}
|
|
73
|
+
|
|
11
74
|
export function stateKey(absPath, size, mtimeMs) {
|
|
12
75
|
return createHash('sha1').update(`${absPath}:${size}:${mtimeMs}`).digest('hex')
|
|
13
76
|
}
|
|
@@ -16,6 +79,14 @@ export function stateFile(key, configDir = defaultConfigDir()) {
|
|
|
16
79
|
return path.join(stateDir(configDir), `${key}.json`)
|
|
17
80
|
}
|
|
18
81
|
|
|
82
|
+
// stateKey hashes path:size:mtime, and a stream has none of the three. What holds still is
|
|
83
|
+
// the backup id, and hashing it keeps the file name in the same 40-hex shape the directory
|
|
84
|
+
// already sorts, prunes and filters on — a stream record is an upload record, not a third
|
|
85
|
+
// kind, so it shares that namespace rather than getting a prefix of its own.
|
|
86
|
+
export function streamKey(backupId) {
|
|
87
|
+
return createHash('sha1').update(`stream:${backupId}`).digest('hex')
|
|
88
|
+
}
|
|
89
|
+
|
|
19
90
|
// A restore's record is filed beside the uploads and must never compete with them for a
|
|
20
91
|
// prune slot. Losing an upload record strands chunks in a chat where only the id can still
|
|
21
92
|
// find them, which is why pruneStates reads each file back to name what it drops; losing a
|
|
@@ -218,6 +289,13 @@ export async function findStates(backupId, configDir = defaultConfigDir()) {
|
|
|
218
289
|
// Never throws. status calls this for every record it prints, and one damaged path must not
|
|
219
290
|
// take the rest of the report down with it.
|
|
220
291
|
export async function canResume(key, state) {
|
|
292
|
+
// A stream cannot be resumed by anyone, so this is not a question about a file. Answering
|
|
293
|
+
// it by stat-ing state.path would report "missing" for a record that never had a path,
|
|
294
|
+
// and status would then offer a resume command that starts a brand new backup. This has
|
|
295
|
+
// to run before the stat below, not after it fails: a stream record's key could still
|
|
296
|
+
// happen to match a real file on disk, and that file is not what makes it unresumable.
|
|
297
|
+
if (state.kind === 'stream') return { ok: false, reason: 'stream' }
|
|
298
|
+
|
|
221
299
|
let stat
|
|
222
300
|
|
|
223
301
|
try {
|
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
|
+
}
|