data-ark 0.1.2 → 0.1.3
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 +5 -2
- package/bin/data-ark.js +9 -15
- package/package.json +1 -1
- package/src/cli.js +22 -0
- package/src/commands/restore.js +29 -4
- package/src/commands/upload.js +44 -4
- package/src/downloader.js +42 -6
- package/src/state.js +49 -0
package/README.md
CHANGED
|
@@ -26,7 +26,7 @@ data-ark signs in with your own Telegram account (MTProto), not a bot. That is a
|
|
|
26
26
|
| Flag | Default | Meaning |
|
|
27
27
|
|---|---|---|
|
|
28
28
|
| `--to <chat>` | the remembered destination | `@username`, `-100123…`, or `me`. `upload` and `status` remember it; `list` and `restore` only look there. A negative channel id works either way: `--to -100123…` or `--to=-100123…` |
|
|
29
|
-
| `--chunk-size <n>` | `1800MB` | e.g. `1.8GB`, `500MB`. Hard ceiling 1950MB. |
|
|
29
|
+
| `--chunk-size <n>` | `1800MB` | e.g. `1.8GB`, `500MB`. Hard ceiling 1950MB. An unfinished backup keeps the size it started with. |
|
|
30
30
|
| `--concurrency <n>` | `8` | 512KB parts sent in parallel. An integer from 1 to 64. |
|
|
31
31
|
| `--out <path>` | the basename from the manifest | Where to write the restored file; relative paths resolve against the current directory |
|
|
32
32
|
| `--limit <n>` | `20` | How many backups `list` shows, newest first |
|
|
@@ -73,7 +73,10 @@ Every run mints a `backupId`. The file is read directly by offset — no tempora
|
|
|
73
73
|
If the connection drops during an **upload**, just run the same command again — progress lives in `~/.data-ark/state/` and finished chunks are skipped, keeping the same `backupId`. Two things to know about rerunning:
|
|
74
74
|
|
|
75
75
|
- Running again with a `--to` that differs from the destination stored in the unfinished progress makes data-ark **refuse to run** rather than silently redirect — one backup cannot be split across two destinations. The error points the way out: drop `--to` to keep sending to the original destination, or delete the state file to start a new backup.
|
|
76
|
-
- Running again
|
|
76
|
+
- Running again **without** `--chunk-size` resumes at the size the backup started with, whatever the default is today.
|
|
77
|
+
- Running again **with** a `--chunk-size` that differs from that size makes data-ark **refuse to run**, for the same reason as `--to`: the chunks already in the chat were cut that way and cannot be re-cut. Drop the flag to carry on, or delete the state file to start a new backup — which leaves the chunks already sent in the chat with nothing pointing at them.
|
|
78
|
+
|
|
79
|
+
`Ctrl-C` during an upload names the backup it was working on, so `data-ark status` and a later `restore` have something to go on. data-ark keeps the **20 most recent** unfinished backups in `~/.data-ark/state/`; starting a new one past that drops the oldest record and says which id it dropped. Only the local record goes — the chunks that backup sent stay in the chat, searchable by that id, but it can no longer be resumed.
|
|
77
80
|
|
|
78
81
|
**Restore keeps no state to resume from.** Pressing `Ctrl-C` mid-restore saves nothing — running again starts over.
|
|
79
82
|
|
package/bin/data-ark.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { route, HELP } from '../src/cli.js'
|
|
2
|
+
import { route, HELP, interruptMessage } from '../src/cli.js'
|
|
3
3
|
import { runLogin } from '../src/commands/login.js'
|
|
4
4
|
import { runList } from '../src/commands/list.js'
|
|
5
5
|
import { runLogout } from '../src/commands/logout.js'
|
|
@@ -12,22 +12,12 @@ const SIGINT_EXIT_CODE = 130
|
|
|
12
12
|
|
|
13
13
|
// Which command is running when Ctrl-C arrives — each one tells a different truth
|
|
14
14
|
// about whether progress was saved, so we need to know which to pick the right line.
|
|
15
|
+
// The backup id arrives a moment later, once upload knows which backup this run is.
|
|
15
16
|
let currentCommand = null
|
|
16
|
-
|
|
17
|
-
function sigintMessage(command) {
|
|
18
|
-
if (command === 'upload') {
|
|
19
|
-
return '\nStopped. Progress is saved, run the same command again to continue.\n'
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
if (command === 'restore') {
|
|
23
|
-
return '\nStopped. Download progress is not saved, running again starts over.\n'
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
return '\nStopped.\n'
|
|
27
|
-
}
|
|
17
|
+
let currentBackupId = null
|
|
28
18
|
|
|
29
19
|
process.on('SIGINT', () => {
|
|
30
|
-
process.stderr.write(
|
|
20
|
+
process.stderr.write(interruptMessage(currentCommand, { backupId: currentBackupId }))
|
|
31
21
|
process.exit(SIGINT_EXIT_CODE)
|
|
32
22
|
})
|
|
33
23
|
|
|
@@ -70,7 +60,11 @@ async function main() {
|
|
|
70
60
|
return
|
|
71
61
|
|
|
72
62
|
case 'upload':
|
|
73
|
-
await runUpload(parsed.args[0], parsed.options
|
|
63
|
+
await runUpload(parsed.args[0], parsed.options, {
|
|
64
|
+
onBackupId: (id) => {
|
|
65
|
+
currentBackupId = id
|
|
66
|
+
},
|
|
67
|
+
})
|
|
74
68
|
return
|
|
75
69
|
|
|
76
70
|
case 'restore':
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -27,6 +27,7 @@ Options:
|
|
|
27
27
|
--to <chat> Destination: @username, -100123..., or me. upload and status
|
|
28
28
|
remember it; list and restore only look there.
|
|
29
29
|
--chunk-size <n> Size of each chunk, default 1800MB. Examples: 1.8GB, 500MB.
|
|
30
|
+
An unfinished backup keeps the size it started with.
|
|
30
31
|
--concurrency <n> 512KB parts sent in parallel, default 8, max 64.
|
|
31
32
|
--out <path> Where to write the restored file. Defaults to the basename in the manifest.
|
|
32
33
|
--limit <n> How many backups list shows, default 20.
|
|
@@ -34,6 +35,27 @@ Options:
|
|
|
34
35
|
-h, --help Show this help.
|
|
35
36
|
`
|
|
36
37
|
|
|
38
|
+
// What Ctrl-C means depends on the command that was running: upload has written every
|
|
39
|
+
// finished chunk to a state file, restore has not. Naming the backup matters because the
|
|
40
|
+
// id is what `status` lists and what a later `restore` needs — the chunks are already in
|
|
41
|
+
// the chat under that id, whether or not this run ever finishes.
|
|
42
|
+
export function interruptMessage(command, { backupId } = {}) {
|
|
43
|
+
if (command === 'upload') {
|
|
44
|
+
const backup = backupId ? `Backup ${backupId} is saved` : 'Progress is saved'
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
`\n${backup} — run the same command again to continue, ` +
|
|
48
|
+
'or "npx data-ark status" to see what is left.\n'
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (command === 'restore') {
|
|
53
|
+
return '\nStopped. Download progress is not saved, running again starts over.\n'
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return '\nStopped.\n'
|
|
57
|
+
}
|
|
58
|
+
|
|
37
59
|
// A channel id is negative, and typing it separated by a space is the natural reflex —
|
|
38
60
|
// but parseArgs rejects any value starting with a dash as ambiguous. Join the pair itself
|
|
39
61
|
// so `--to -100123` works like `--to=-100123`. Only a bare negative integer qualifies, so
|
package/src/commands/restore.js
CHANGED
|
@@ -7,7 +7,11 @@ import { closeQuietly, connect as realConnect, requireChat, searchDocuments } fr
|
|
|
7
7
|
import { defaultConfigDir, loadConfig } from '../config.js'
|
|
8
8
|
import { downloadToFile } from '../downloader.js'
|
|
9
9
|
import { manifestFileName, parseManifest } from '../manifest.js'
|
|
10
|
-
import { createProgress, formatBytes } from '../progress.js'
|
|
10
|
+
import { createProgress, formatBytes, formatDuration } from '../progress.js'
|
|
11
|
+
|
|
12
|
+
// Anything past a minute of waiting needs saying out loud; below that the pause is shorter
|
|
13
|
+
// than the time a user would spend wondering about it.
|
|
14
|
+
const LONG_WAIT_MS = 60_000
|
|
11
15
|
|
|
12
16
|
async function realSearchManifest(client, peer, backupId) {
|
|
13
17
|
const wanted = manifestFileName(backupId)
|
|
@@ -25,8 +29,8 @@ async function realGetMessage(client, peer, msgId) {
|
|
|
25
29
|
return message ?? null
|
|
26
30
|
}
|
|
27
31
|
|
|
28
|
-
export async function realDownloadChunk(client, message, handle, offset, onProgress) {
|
|
29
|
-
return await downloadToFile(client, message, handle.fd, { offset, onProgress })
|
|
32
|
+
export async function realDownloadChunk(client, message, handle, offset, onProgress, retryOptions) {
|
|
33
|
+
return await downloadToFile(client, message, handle.fd, { offset, onProgress, retryOptions })
|
|
30
34
|
}
|
|
31
35
|
|
|
32
36
|
// manifest.name comes from data downloaded off Telegram — don't trust it when picking
|
|
@@ -63,15 +67,35 @@ export async function runRestore(backupId, options = {}, deps = {}) {
|
|
|
63
67
|
getMessage = realGetMessage,
|
|
64
68
|
downloadChunk = realDownloadChunk,
|
|
65
69
|
confirm = askConfirm,
|
|
70
|
+
retryOptions = {},
|
|
66
71
|
writeErr = (line) => process.stderr.write(line),
|
|
72
|
+
log: writeLog = (line) => console.log(line),
|
|
67
73
|
silent = false,
|
|
68
74
|
} = deps
|
|
69
75
|
|
|
70
76
|
const config = await loadConfig(configDir)
|
|
71
77
|
const chat = requireChat(options, config)
|
|
72
|
-
const log = silent ? () => {} :
|
|
78
|
+
const log = silent ? () => {} : writeLog
|
|
73
79
|
const warn = silent ? () => {} : writeErr
|
|
74
80
|
|
|
81
|
+
// A restore keeps no progress file, so a part that comes back -503 is retried rather than
|
|
82
|
+
// thrown away — and a retry nobody is told about is indistinguishable from a hung transfer,
|
|
83
|
+
// because the progress bar simply stops moving while the wait runs.
|
|
84
|
+
function onRetry(err, attempt, delayMs) {
|
|
85
|
+
if (delayMs > LONG_WAIT_MS) {
|
|
86
|
+
warn(
|
|
87
|
+
`\nTelegram wants ${formatDuration(delayMs / 1000)} of waiting before the next part ` +
|
|
88
|
+
`(${err.message}). data-ark is waiting and will carry on by itself, leave it running.\n`,
|
|
89
|
+
)
|
|
90
|
+
return
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
warn(
|
|
94
|
+
`\nTemporary error (${err.message}), retry ${attempt} in ` +
|
|
95
|
+
`${formatDuration(delayMs / 1000)}.\n`,
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
|
|
75
99
|
const client = await connect(config, { verbose: options.verbose })
|
|
76
100
|
|
|
77
101
|
try {
|
|
@@ -134,6 +158,7 @@ export async function runRestore(backupId, options = {}, deps = {}) {
|
|
|
134
158
|
handle,
|
|
135
159
|
chunk.i * manifest.chunkSize,
|
|
136
160
|
progress.advance,
|
|
161
|
+
{ ...retryOptions, onRetry },
|
|
137
162
|
)
|
|
138
163
|
|
|
139
164
|
progress.finish()
|
package/src/commands/upload.js
CHANGED
|
@@ -23,7 +23,16 @@ import {
|
|
|
23
23
|
serializeManifest,
|
|
24
24
|
} from '../manifest.js'
|
|
25
25
|
import { createProgress, formatBytes, formatDuration } from '../progress.js'
|
|
26
|
-
import {
|
|
26
|
+
import {
|
|
27
|
+
MAX_STATES,
|
|
28
|
+
clearState,
|
|
29
|
+
loadState,
|
|
30
|
+
markChunkDone,
|
|
31
|
+
pruneStates,
|
|
32
|
+
saveState,
|
|
33
|
+
stateFile,
|
|
34
|
+
stateKey,
|
|
35
|
+
} from '../state.js'
|
|
27
36
|
import { uploadRange } from '../uploader.js'
|
|
28
37
|
|
|
29
38
|
// Above this threshold the wait must be spelled out, per spec §8.
|
|
@@ -63,6 +72,7 @@ export async function runUpload(filePath, options = {}, deps = {}) {
|
|
|
63
72
|
retryOptions = {},
|
|
64
73
|
writeErr = (line) => process.stderr.write(line),
|
|
65
74
|
silent = false,
|
|
75
|
+
onBackupId = () => {},
|
|
66
76
|
} = deps
|
|
67
77
|
|
|
68
78
|
const absPath = path.resolve(filePath)
|
|
@@ -81,7 +91,7 @@ export async function runUpload(filePath, options = {}, deps = {}) {
|
|
|
81
91
|
|
|
82
92
|
const config = await loadConfig(configDir)
|
|
83
93
|
const chat = requireChat(options, config)
|
|
84
|
-
const
|
|
94
|
+
const requestedChunkSize = options['chunk-size'] ? parseSize(options['chunk-size']) : null
|
|
85
95
|
const concurrency = options.concurrency ? Number(options.concurrency) : DEFAULT_CONCURRENCY
|
|
86
96
|
|
|
87
97
|
if (!Number.isInteger(concurrency) || concurrency < 1 || concurrency > MAX_CONCURRENCY) {
|
|
@@ -92,11 +102,28 @@ export async function runUpload(filePath, options = {}, deps = {}) {
|
|
|
92
102
|
)
|
|
93
103
|
}
|
|
94
104
|
|
|
95
|
-
const chunks = planChunks(stat.size, chunkSize)
|
|
96
105
|
const key = stateKey(absPath, stat.size, stat.mtimeMs)
|
|
97
106
|
|
|
98
107
|
let state = await loadState(key, configDir)
|
|
99
|
-
|
|
108
|
+
|
|
109
|
+
// The chunks already in the chat were cut at the size this backup started with, and
|
|
110
|
+
// nothing can re-cut them. Carrying on at a different size would abandon every one of
|
|
111
|
+
// them in the chat, where data-ark can no longer find them — so an unfinished backup
|
|
112
|
+
// keeps its own chunk size, and a flag that disagrees is refused rather than obeyed.
|
|
113
|
+
if (state && requestedChunkSize !== null && requestedChunkSize !== state.chunkSize) {
|
|
114
|
+
const file = stateFile(key, configDir)
|
|
115
|
+
throw new Error(
|
|
116
|
+
`This unfinished backup is cut into ${formatBytes(state.chunkSize)} chunks, but ` +
|
|
117
|
+
`--chunk-size asks for ${formatBytes(requestedChunkSize)} — the chunks already in ` +
|
|
118
|
+
`${state.chat} cannot be re-cut. Run again without --chunk-size to carry on, or delete ` +
|
|
119
|
+
`${file} and run again to start a new backup, which leaves the chunks already sent ` +
|
|
120
|
+
'sitting in the chat with nothing to point at them.',
|
|
121
|
+
)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const chunkSize = state ? state.chunkSize : (requestedChunkSize ?? DEFAULT_CHUNK_SIZE)
|
|
125
|
+
const chunks = planChunks(stat.size, chunkSize)
|
|
126
|
+
const resuming = Boolean(state)
|
|
100
127
|
|
|
101
128
|
if (resuming && state.chat !== String(chat)) {
|
|
102
129
|
const file = stateFile(key, configDir)
|
|
@@ -122,8 +149,21 @@ export async function runUpload(filePath, options = {}, deps = {}) {
|
|
|
122
149
|
done: {},
|
|
123
150
|
}
|
|
124
151
|
await saveState(key, state, configDir)
|
|
152
|
+
|
|
153
|
+
// Only a new backup adds to the directory, so this is the one place it can grow.
|
|
154
|
+
// The report goes out even when the caller asked for silence: this is not narration
|
|
155
|
+
// about a transfer, it is data-ark dropping the only record of someone else's chunks.
|
|
156
|
+
for (const gone of await pruneStates(configDir)) {
|
|
157
|
+
writeErr(
|
|
158
|
+
`\nDropped the record of unfinished backup ${gone.id}: data-ark keeps the ` +
|
|
159
|
+
`${MAX_STATES} most recent. The chunks it sent are still in ${gone.chat}, ` +
|
|
160
|
+
'searchable by that id, but that backup can no longer be resumed.\n',
|
|
161
|
+
)
|
|
162
|
+
}
|
|
125
163
|
}
|
|
126
164
|
|
|
165
|
+
onBackupId(state.id)
|
|
166
|
+
|
|
127
167
|
const log = silent ? () => {} : (line) => console.log(line)
|
|
128
168
|
const warn = silent ? () => {} : writeErr
|
|
129
169
|
|
package/src/downloader.js
CHANGED
|
@@ -2,7 +2,10 @@ import { createHash } from 'node:crypto'
|
|
|
2
2
|
import { write as writeCallback } from 'node:fs'
|
|
3
3
|
import { promisify } from 'node:util'
|
|
4
4
|
|
|
5
|
+
import { returnBigInt } from 'telegram/Helpers.js'
|
|
6
|
+
|
|
5
7
|
import { PART_SIZE } from './chunking.js'
|
|
8
|
+
import { withRetry } from './retry.js'
|
|
6
9
|
|
|
7
10
|
const write = promisify(writeCallback)
|
|
8
11
|
|
|
@@ -15,7 +18,7 @@ async function writeExactly(fd, buffer, position) {
|
|
|
15
18
|
}
|
|
16
19
|
}
|
|
17
20
|
|
|
18
|
-
export async function downloadToFile(client, message, fd, { offset, onProgress } = {}) {
|
|
21
|
+
export async function downloadToFile(client, message, fd, { offset, onProgress, retryOptions } = {}) {
|
|
19
22
|
const document = message?.media?.document
|
|
20
23
|
|
|
21
24
|
if (!document) {
|
|
@@ -29,11 +32,44 @@ export async function downloadToFile(client, message, fd, { offset, onProgress }
|
|
|
29
32
|
const hash = createHash('sha256')
|
|
30
33
|
let written = 0
|
|
31
34
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
35
|
+
// A chunk is thousands of separate part requests and any one of them can come back
|
|
36
|
+
// -503, so a stream that breaks has to be picked up rather than abandoned — there is no
|
|
37
|
+
// resume file for a download, and giving up throws away every byte fetched so far.
|
|
38
|
+
// Restarting the iterator at `offset + written` is what makes that safe: bytes are
|
|
39
|
+
// hashed in the order they are written and `written` only moves once a buffer has been
|
|
40
|
+
// both written and hashed, so the resumed stream continues the same digest.
|
|
41
|
+
//
|
|
42
|
+
// The two offsets are not the same number and must not be confused: `written` is a
|
|
43
|
+
// position inside the document, which is where the stream resumes, while `offset +
|
|
44
|
+
// written` is a position inside the file being assembled, which is where the bytes land.
|
|
45
|
+
// Every chunk after the first has a non-zero `offset`, so mixing them up reads the wrong
|
|
46
|
+
// part of the document — caught by sha256, but only after re-downloading the whole chunk.
|
|
47
|
+
async function streamFromWhereWeStopped() {
|
|
48
|
+
for await (const buffer of client.iterDownload({
|
|
49
|
+
file: message.media,
|
|
50
|
+
offset: returnBigInt(written),
|
|
51
|
+
requestSize: PART_SIZE,
|
|
52
|
+
})) {
|
|
53
|
+
await writeExactly(fd, buffer, offset + written)
|
|
54
|
+
hash.update(buffer)
|
|
55
|
+
written += buffer.length
|
|
56
|
+
onProgress?.(buffer.length)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// One attempt budget for the whole chunk would be the wrong unit: 1800MB is 3600 requests,
|
|
61
|
+
// and five failures spread across them is a healthy download, not a broken one. So a budget
|
|
62
|
+
// only ends the restore when it is spent without gaining a single byte. Every outer pass
|
|
63
|
+
// must gain at least one byte to earn another, which is what bounds the loop.
|
|
64
|
+
for (;;) {
|
|
65
|
+
const before = written
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
await withRetry(streamFromWhereWeStopped, retryOptions)
|
|
69
|
+
break
|
|
70
|
+
} catch (err) {
|
|
71
|
+
if (written === before) throw err
|
|
72
|
+
}
|
|
37
73
|
}
|
|
38
74
|
|
|
39
75
|
return { sha256: hash.digest('hex'), size: written }
|
package/src/state.js
CHANGED
|
@@ -44,6 +44,55 @@ export async function clearState(key, configDir = defaultConfigDir()) {
|
|
|
44
44
|
}
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
// A state file is only useful while its backup can still be resumed, and nothing ever
|
|
48
|
+
// removes one whose file was edited since: the key includes mtime, so that state can never
|
|
49
|
+
// match again. Left alone the directory only grows, and with it the report `status` prints.
|
|
50
|
+
export const MAX_STATES = 20
|
|
51
|
+
|
|
52
|
+
// The newest states are the ones worth keeping, and a state file is rewritten every time a
|
|
53
|
+
// chunk lands, so its mtime is when this backup last made progress. Returns the states that
|
|
54
|
+
// were dropped: the caller says their ids out loud, because after this the id is the only
|
|
55
|
+
// way left to find those chunks in the chat.
|
|
56
|
+
export async function pruneStates(configDir = defaultConfigDir(), keep = MAX_STATES) {
|
|
57
|
+
let names
|
|
58
|
+
try {
|
|
59
|
+
names = await fs.readdir(stateDir(configDir))
|
|
60
|
+
} catch (err) {
|
|
61
|
+
if (err.code === 'ENOENT') return []
|
|
62
|
+
throw err
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const files = []
|
|
66
|
+
|
|
67
|
+
for (const name of names) {
|
|
68
|
+
if (!name.endsWith('.json')) continue
|
|
69
|
+
|
|
70
|
+
const file = path.join(stateDir(configDir), name)
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
const stat = await fs.stat(file)
|
|
74
|
+
files.push({ key: name.slice(0, -'.json'.length), file, mtimeMs: stat.mtimeMs })
|
|
75
|
+
} catch (err) {
|
|
76
|
+
// Gone between readdir and stat: nothing left to prune.
|
|
77
|
+
if (err.code !== 'ENOENT') throw err
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
files.sort((a, b) => b.mtimeMs - a.mtimeMs)
|
|
82
|
+
|
|
83
|
+
const dropped = []
|
|
84
|
+
|
|
85
|
+
for (const { key, file } of files.slice(keep)) {
|
|
86
|
+
// Read before unlink: a file that cannot be read back, or that carries no id, is
|
|
87
|
+
// still pruned — it just cannot be named, and a report naming nothing helps no one.
|
|
88
|
+
const state = await loadState(key, configDir)
|
|
89
|
+
await fs.unlink(file)
|
|
90
|
+
if (state?.id) dropped.push(state)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return dropped
|
|
94
|
+
}
|
|
95
|
+
|
|
47
96
|
// status needs every unfinished backup at once. A state file that cannot be read is skipped
|
|
48
97
|
// rather than fatal, for the same reason loadState returns null: one corrupt file must not
|
|
49
98
|
// hide the other backups still waiting to be finished.
|