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,163 @@
1
+ import { randomBytes } from 'node:crypto'
2
+
3
+ import { countChunks } from './chunking.js'
4
+
5
+ export const MANIFEST_VERSION = 1
6
+
7
+ export function newBackupId(now = new Date(), randomHex = () => randomBytes(3).toString('hex')) {
8
+ const yyyy = now.getUTCFullYear()
9
+ const mm = String(now.getUTCMonth() + 1).padStart(2, '0')
10
+ const dd = String(now.getUTCDate()).padStart(2, '0')
11
+ return `telark-${yyyy}${mm}${dd}-${randomHex()}`
12
+ }
13
+
14
+ export function chunkFileName(id, i) {
15
+ return `${id}.part${String(i + 1).padStart(4, '0')}`
16
+ }
17
+
18
+ export function manifestFileName(id) {
19
+ return `${id}.manifest.json`
20
+ }
21
+
22
+ export function buildManifest({ id, name, size, chunkSize, chunks, createdAt = new Date().toISOString() }) {
23
+ return {
24
+ v: MANIFEST_VERSION,
25
+ id,
26
+ name,
27
+ size,
28
+ chunkSize,
29
+ createdAt,
30
+ chunks: [...chunks]
31
+ .sort((a, b) => a.i - b.i)
32
+ .map(({ i, msgId, size: chunkBytes, sha256 }) => ({ i, msgId, size: chunkBytes, sha256 })),
33
+ }
34
+ }
35
+
36
+ export function serializeManifest(manifest) {
37
+ return Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`, 'utf8')
38
+ }
39
+
40
+ // parseManifest's own front door, on its own so delete can read a manifest body without
41
+ // the layout checks behind it.
42
+ export function parseManifestJson(input) {
43
+ const text = Buffer.isBuffer(input) ? input.toString('utf8') : String(input)
44
+
45
+ try {
46
+ return JSON.parse(text)
47
+ } catch {
48
+ throw new Error('Cannot read manifest: content is not valid JSON.')
49
+ }
50
+ }
51
+
52
+ // Delete needs one thing from a manifest that restore does not, and none of the things
53
+ // restore needs. parseManifest is the wrong gate for it: it validates the chunk *layout*,
54
+ // because restore writes bytes at offsets computed from it — and a manifest that fails
55
+ // those checks is exactly the broken backup somebody is trying to delete, so refusing to
56
+ // read it here would leave the only way out through the Telegram app. It also never looks
57
+ // at msgId, which is the only field delete actually uses.
58
+ //
59
+ // Every id is checked before a single message is removed. A msgId is handed to Telegram as
60
+ // the name of something to destroy for good, and that is the one number nobody may guess
61
+ // at — so a manifest that cannot say it exactly is refused whole, rather than half-deleted
62
+ // and then left without the list that names the rest.
63
+ export function manifestMessageIds(manifest) {
64
+ if (!Array.isArray(manifest?.chunks) || manifest.chunks.length === 0) {
65
+ throw new Error('Manifest has no chunk list, so it cannot say which messages to remove.')
66
+ }
67
+
68
+ return manifest.chunks.map((chunk, index) => {
69
+ const msgId = chunk?.msgId
70
+
71
+ if (!Number.isSafeInteger(msgId) || msgId < 1) {
72
+ throw new Error(
73
+ `Manifest gives ${JSON.stringify(msgId)} as the message id of chunk ${index + 1}, ` +
74
+ 'which is not a message id. Deleting from this manifest could remove the wrong ' +
75
+ 'messages, so telark is not deleting anything.',
76
+ )
77
+ }
78
+
79
+ return msgId
80
+ })
81
+ }
82
+
83
+ export function parseManifest(input) {
84
+ const manifest = parseManifestJson(input)
85
+
86
+ if (manifest.v !== MANIFEST_VERSION) {
87
+ throw new Error(
88
+ `Manifest uses version ${manifest.v}, this build of telark only understands version ${MANIFEST_VERSION}.`,
89
+ )
90
+ }
91
+
92
+ if (!Array.isArray(manifest.chunks) || manifest.chunks.length === 0) {
93
+ throw new Error('Manifest has no chunk list.')
94
+ }
95
+
96
+ // The manifest comes off a chat, so nothing in it is trusted. The checks below are
97
+ // arithmetic on these numbers, and arithmetic on a string or a null does not fail — it
98
+ // produces a comparison that rejects the manifest for the wrong reason. A string size used
99
+ // to be reported as "add up to 100, but the manifest records a file size of 100", which
100
+ // sends the reader hunting for a difference that is not there.
101
+ if (!Number.isSafeInteger(manifest.size) || manifest.size < 0) {
102
+ throw new Error(
103
+ `Manifest records a file size of ${JSON.stringify(manifest.size)}, ` +
104
+ 'which is not a whole number of bytes.',
105
+ )
106
+ }
107
+
108
+ if (!Number.isSafeInteger(manifest.chunkSize) || manifest.chunkSize < 1) {
109
+ throw new Error(
110
+ `Manifest records a chunk size of ${JSON.stringify(manifest.chunkSize)}, ` +
111
+ 'which is not a whole number of bytes above zero.',
112
+ )
113
+ }
114
+
115
+ manifest.chunks.forEach((chunk, index) => {
116
+ if (typeof chunk !== 'object' || chunk === null) {
117
+ throw new Error(
118
+ `Manifest entry for chunk ${index + 1} is not an object: ${JSON.stringify(chunk)}.`,
119
+ )
120
+ }
121
+
122
+ if (!Number.isSafeInteger(chunk.size) || chunk.size < 0) {
123
+ throw new Error(
124
+ `Manifest records ${JSON.stringify(chunk.size)} bytes for chunk ${index + 1}, ` +
125
+ 'which is not a whole number of bytes.',
126
+ )
127
+ }
128
+
129
+ if (chunk.i !== index) {
130
+ throw new Error(`Manifest is missing chunk ${index}: the chunk list is not contiguous.`)
131
+ }
132
+ })
133
+
134
+ const expectedChunks = countChunks(manifest.size, manifest.chunkSize)
135
+ if (manifest.chunks.length !== expectedChunks) {
136
+ throw new Error(`Manifest is missing ${expectedChunks - manifest.chunks.length} chunk(s).`)
137
+ }
138
+
139
+ const total = manifest.chunks.reduce((sum, chunk) => sum + chunk.size, 0)
140
+ if (total !== manifest.size) {
141
+ throw new Error(
142
+ `Chunk sizes add up to ${total}, but the manifest records a file size of ${manifest.size}.`,
143
+ )
144
+ }
145
+
146
+ // Restore writes chunk i at exactly offset i * chunkSize, so the layout must be
147
+ // uniform: every chunk is chunkSize, except the last one which is the remainder.
148
+ // A correct total with individually wrong sizes yields a file with a hole or
149
+ // extra length while every per-chunk sha256 still matches — silently wrong data,
150
+ // precisely what telark must never produce.
151
+ manifest.chunks.forEach((chunk, index) => {
152
+ const expected = Math.min(manifest.chunkSize, manifest.size - index * manifest.chunkSize)
153
+ if (chunk.size !== expected) {
154
+ throw new Error(
155
+ `Manifest records ${chunk.size} bytes for chunk ${index + 1}, but a layout of ` +
156
+ `${manifest.chunkSize} bytes per chunk requires ${expected} bytes. ` +
157
+ 'This manifest describes the wrong chunk positions; restoring it would produce a corrupt file.',
158
+ )
159
+ }
160
+ })
161
+
162
+ return manifest
163
+ }
@@ -0,0 +1,100 @@
1
+ const UNITS = ['B', 'KB', 'MB', 'GB', 'TB']
2
+
3
+ export function formatBytes(n) {
4
+ if (n < 1024) return `${n} B`
5
+
6
+ let value = n
7
+ let unit = 0
8
+
9
+ while (value >= 1024 && unit < UNITS.length - 1) {
10
+ value /= 1024
11
+ unit += 1
12
+ }
13
+
14
+ return `${value.toFixed(1)} ${UNITS[unit]}`
15
+ }
16
+
17
+ export function formatDuration(seconds) {
18
+ if (!Number.isFinite(seconds)) return '--'
19
+
20
+ const total = Math.round(seconds)
21
+ const h = Math.floor(total / 3600)
22
+ const m = Math.floor((total % 3600) / 60)
23
+ const s = total % 60
24
+
25
+ if (h > 0) return `${h}h${m}m`
26
+ if (m > 0) return `${m}m${s}s`
27
+ return `${s}s`
28
+ }
29
+
30
+ export function renderProgress({ done, total, elapsedMs, label, width = 24, transferred = done }) {
31
+ const ratio = total === 0 ? 1 : Math.min(done / total, 1)
32
+ const filled = Math.round(ratio * width)
33
+ const bar = `${'█'.repeat(filled)}${'░'.repeat(width - filled)}`
34
+
35
+ // Speed measures what this run moved, not what the file already has. A resumed upload
36
+ // starts with gigabytes behind it, and dividing those by a two-second-old run reports a
37
+ // fictional 3 GB/s and an ETA of almost nothing. The bar and the byte counts still speak
38
+ // for the whole file, because that is the question being asked.
39
+ const bytesPerSecond = elapsedMs > 0 ? transferred / (elapsedMs / 1000) : 0
40
+ // Clamp to 0: done can overshoot total (one extra tick) and a negative ETA is meaningless.
41
+ const remaining = bytesPerSecond > 0 ? Math.max(0, (total - done) / bytesPerSecond) : Infinity
42
+
43
+ const percent = String(Math.floor(ratio * 100)).padStart(3)
44
+ const speed = `${formatBytes(Math.round(bytesPerSecond))}/s`
45
+
46
+ return `${label} ${bar} ${percent}% ${formatBytes(done)}/${formatBytes(total)} ${speed} ETA ${formatDuration(remaining)}`
47
+ }
48
+
49
+ export function createProgress({
50
+ total,
51
+ label,
52
+ // Bytes a previous run already sent. They belong on the bar — the question is how much of
53
+ // the file is done, not how much of today's session — but not in the speed.
54
+ done: startedWith = 0,
55
+ write = (line) => process.stderr.write(line),
56
+ now = () => Date.now(),
57
+ minIntervalMs = 200,
58
+ }) {
59
+ const startedAt = now()
60
+ let done = startedWith
61
+ let currentLabel = label
62
+ let lastDrawnAt = startedAt
63
+ let widestLine = 0
64
+
65
+ // \r only moves the cursor home, it does not erase. A redraw that is shorter than the one
66
+ // before it (a shrinking ETA, a speed that changes unit) would leave the previous tail on
67
+ // screen, so pad every line out to the widest one drawn so far.
68
+ function draw(suffix) {
69
+ const line = renderProgress({
70
+ done,
71
+ total,
72
+ elapsedMs: now() - startedAt,
73
+ label: currentLabel,
74
+ transferred: done - startedWith,
75
+ })
76
+ widestLine = Math.max(widestLine, line.length)
77
+ write(`\r${line.padEnd(widestLine)}${suffix}`)
78
+ }
79
+
80
+ return {
81
+ advance(bytes) {
82
+ done += bytes
83
+ if (now() - lastDrawnAt < minIntervalMs) return
84
+ lastDrawnAt = now()
85
+ draw('')
86
+ },
87
+ // One bar spans the whole transfer while the label names the chunk in flight, so the
88
+ // label changes mid-line and has to be drawn at once: throttled, it would keep showing
89
+ // the previous chunk's number for the first 200ms of the new one. It counts as a draw,
90
+ // because the redraw a moment later would say the same thing.
91
+ setLabel(next) {
92
+ currentLabel = next
93
+ lastDrawnAt = now()
94
+ draw('')
95
+ },
96
+ finish() {
97
+ draw('\n')
98
+ },
99
+ }
100
+ }
package/src/retry.js ADDED
@@ -0,0 +1,57 @@
1
+ const DEFAULT_ATTEMPTS = 8
2
+ const DEFAULT_BASE_DELAY_MS = 1000
3
+
4
+ // Past half a minute the doubling stops buying anything: the wait is already long enough
5
+ // that the far side has either recovered or is not coming back on this attempt. Left
6
+ // uncapped, eight attempts would end in a two-minute stare at a frozen bar.
7
+ const MAX_BACKOFF_MS = 30_000
8
+
9
+ function defaultSleep(ms) {
10
+ return new Promise((resolve) => setTimeout(resolve, ms))
11
+ }
12
+
13
+ function floodWaitSeconds(err) {
14
+ if (typeof err?.seconds === 'number' && String(err?.errorMessage ?? '').includes('FLOOD_WAIT')) {
15
+ return err.seconds
16
+ }
17
+ return null
18
+ }
19
+
20
+ export async function withRetry(fn, options = {}) {
21
+ const {
22
+ attempts = DEFAULT_ATTEMPTS,
23
+ baseDelayMs = DEFAULT_BASE_DELAY_MS,
24
+ sleep = defaultSleep,
25
+ now = Date.now,
26
+ onRetry,
27
+ } = options
28
+
29
+ let lastError
30
+
31
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
32
+ const startedAt = now()
33
+
34
+ try {
35
+ return await fn()
36
+ } catch (err) {
37
+ lastError = err
38
+
39
+ if (attempt === attempts) break
40
+
41
+ const flood = floodWaitSeconds(err)
42
+ const delayMs =
43
+ flood === null
44
+ ? Math.min(baseDelayMs * 2 ** (attempt - 1), MAX_BACKOFF_MS)
45
+ : flood * 1000
46
+
47
+ // How long the failed attempt itself took. A request that errors instantly costs the
48
+ // user nothing but a line of output; one that took a minute to give up left the
49
+ // progress bar frozen for that minute. Only the caller can weigh the two, so it is
50
+ // told which kind this was.
51
+ onRetry?.(err, attempt, delayMs, now() - startedAt)
52
+ await sleep(delayMs)
53
+ }
54
+ }
55
+
56
+ throw lastError
57
+ }
@@ -0,0 +1,190 @@
1
+ import { normalizeChatTarget } from './chat.js'
2
+ import {
3
+ DEFAULT_CHUNK_SIZE,
4
+ DEFAULT_CONCURRENCY,
5
+ MAX_CONCURRENCY,
6
+ parseSize,
7
+ } from './chunking.js'
8
+ import { formatBytes } from './progress.js'
9
+
10
+ export const DEFAULT_LIMIT = 20
11
+
12
+ // The three keys telark writes for itself. Naming them separately is what lets the
13
+ // unknown-key error say "managed by login" instead of listing a session as something the
14
+ // user forgot to spell correctly.
15
+ const MANAGED_BY_LOGIN = new Set(['session', 'apiId', 'apiHash'])
16
+
17
+ function wholeNumber(raw, where, minimum, maximum, explanation) {
18
+ if (typeof raw !== 'string' && typeof raw !== 'number') {
19
+ throw new Error(`Invalid ${where}: ${JSON.stringify(raw)}. ${explanation}`)
20
+ }
21
+
22
+ const value = Number(String(raw).trim())
23
+
24
+ if (!Number.isInteger(value) || value < minimum || (maximum !== null && value > maximum)) {
25
+ throw new Error(`Invalid ${where}: "${raw}". ${explanation}`)
26
+ }
27
+
28
+ return value
29
+ }
30
+
31
+ // One entry per setting: the flag that overrides it for a run, the built-in default, the
32
+ // parser that both the flag and the stored value go through, and how the value is written
33
+ // back out. `format` must round-trip — whatever it prints has to parse to the same value,
34
+ // which is why chunkSize prints bytes and leaves "1.8 GB" to `describe`.
35
+ export const SETTINGS = {
36
+ chat: {
37
+ flag: 'to',
38
+ default: null,
39
+ // A number here is a chat id, and a chat id is a whole number. 42.5 would otherwise
40
+ // slip through as the string "42.5" and only fail much later, at Telegram, as a chat
41
+ // that does not exist.
42
+ parse(raw, where) {
43
+ const usable = typeof raw === 'string' || (typeof raw === 'number' && Number.isInteger(raw))
44
+
45
+ if (!usable) {
46
+ throw new Error(
47
+ `Invalid ${where}: ${JSON.stringify(raw)}. A destination is @username, -100123..., or me.`,
48
+ )
49
+ }
50
+
51
+ return normalizeChatTarget(raw)
52
+ },
53
+ format: (value) => String(value),
54
+ },
55
+ chunkSize: {
56
+ flag: 'chunk-size',
57
+ default: DEFAULT_CHUNK_SIZE,
58
+ parse(raw, where) {
59
+ if (typeof raw !== 'string' && typeof raw !== 'number') {
60
+ throw new Error(
61
+ `Invalid ${where}: ${JSON.stringify(raw)}. Valid examples: 1800MB, 1.8GB, 524288.`,
62
+ )
63
+ }
64
+
65
+ try {
66
+ return parseSize(raw)
67
+ } catch (err) {
68
+ throw new Error(`Invalid ${where}: "${raw}". ${err.message}`)
69
+ }
70
+ },
71
+ // Bytes, not "1.8 GB": formatBytes rounds to one decimal, so the pretty form parses
72
+ // back to a different size and `config chunkSize` would print a value that, typed in
73
+ // again, cuts the file differently.
74
+ format: (value) => String(value),
75
+ describe: (value) => formatBytes(value),
76
+ },
77
+ concurrency: {
78
+ flag: 'concurrency',
79
+ default: DEFAULT_CONCURRENCY,
80
+ parse: (raw, where) =>
81
+ wholeNumber(
82
+ raw,
83
+ where,
84
+ 1,
85
+ MAX_CONCURRENCY,
86
+ `Must be an integer from 1 to ${MAX_CONCURRENCY} — each slot holds a 512KB part in RAM ` +
87
+ 'and Telegram answers with FLOOD_WAIT if too many requests go out at once.',
88
+ ),
89
+ format: (value) => String(value),
90
+ },
91
+ limit: {
92
+ flag: 'limit',
93
+ default: DEFAULT_LIMIT,
94
+ parse: (raw, where) =>
95
+ wholeNumber(raw, where, 1, null, 'Must be a whole number of backups, 1 or more.'),
96
+ format: (value) => String(value),
97
+ },
98
+ verbose: {
99
+ flag: 'verbose',
100
+ default: false,
101
+ parse(raw, where) {
102
+ if (typeof raw === 'boolean') return raw
103
+
104
+ const text = String(raw).trim().toLowerCase()
105
+
106
+ if (text === 'true') return true
107
+ if (text === 'false') return false
108
+
109
+ throw new Error(`Invalid ${where}: ${JSON.stringify(raw)}. Must be true or false.`)
110
+ },
111
+ format: (value) => String(value),
112
+ },
113
+ }
114
+
115
+ export const SETTING_KEYS = Object.keys(SETTINGS)
116
+
117
+ // Someone who has been typing `--to` and `--chunk-size` for a week will type them at the
118
+ // config command too. Accept the flag spelling as a way in, and canonicalise on the way to
119
+ // disk so the file only ever holds one name per setting.
120
+ const ALIASES = new Map()
121
+
122
+ for (const [key, spec] of Object.entries(SETTINGS)) {
123
+ ALIASES.set(key.toLowerCase(), key)
124
+ ALIASES.set(spec.flag.toLowerCase(), key)
125
+ }
126
+
127
+ export function canonicalKey(input) {
128
+ return ALIASES.get(String(input).trim().toLowerCase()) ?? null
129
+ }
130
+
131
+ export function isManagedByLogin(input) {
132
+ return MANAGED_BY_LOGIN.has(String(input).trim())
133
+ }
134
+
135
+ // Where a value came from, spelled the way the user would recognise it. A stored value that
136
+ // fails to parse must not be reported as a bad flag: nobody typed a flag, and telling them
137
+ // to fix one sends them off after the wrong thing — the same mistake the old
138
+ // "run again without --to" advice made.
139
+ function origin(key, from, file) {
140
+ return from === 'flag' ? `--${SETTINGS[key].flag}` : `${key} in ${file}`
141
+ }
142
+
143
+ // Precedence is flag, then the stored setting, then the built-in default. `source` is not
144
+ // decoration: an unfinished upload has to tell "you asked for this size" from "this is
145
+ // merely your default", and only the source separates them.
146
+ export function resolveSettings(options = {}, config = {}, { file = 'the config file' } = {}) {
147
+ const values = {}
148
+ const sources = {}
149
+ const stored = config.settings ?? {}
150
+
151
+ for (const [key, spec] of Object.entries(SETTINGS)) {
152
+ const flagValue = options[spec.flag]
153
+
154
+ if (flagValue !== undefined) {
155
+ values[key] = spec.parse(flagValue, origin(key, 'flag', file))
156
+ sources[key] = 'flag'
157
+ } else if (stored[key] !== undefined) {
158
+ values[key] = spec.parse(stored[key], origin(key, 'settings', file))
159
+ sources[key] = 'settings'
160
+ } else {
161
+ values[key] = spec.default
162
+ sources[key] = 'default'
163
+ }
164
+ }
165
+
166
+ return {
167
+ values,
168
+ // Throws rather than returning undefined for a typo: a misspelt key at the chunk-size
169
+ // call site would turn "refuse to resume at a different size" into "resume at a
170
+ // different size", silently, which is the one thing this project must never do.
171
+ source(key) {
172
+ if (!(key in sources)) {
173
+ throw new Error(`Unknown setting: ${key}. Known settings: ${SETTING_KEYS.join(', ')}.`)
174
+ }
175
+
176
+ return sources[key]
177
+ },
178
+ }
179
+ }
180
+
181
+ export function requireChat(values) {
182
+ if (values.chat === null || values.chat === undefined) {
183
+ throw new Error(
184
+ 'No destination set — run "npx telark config chat @my_backups" to set one ' +
185
+ '("config chat me" for Saved Messages), or pass --to to choose one for this run.',
186
+ )
187
+ }
188
+
189
+ return values.chat
190
+ }
package/src/stall.js ADDED
@@ -0,0 +1,63 @@
1
+ // Sixty seconds of silence on one 512KB part is a dead connection, not a slow one: a link
2
+ // that cannot deliver 8KB/s could not finish a multi-gigabyte restore anyway.
3
+ export const DEFAULT_STALL_MS = 60_000
4
+
5
+ // setTimeout stores its delay in a 32-bit signed integer. Node's answer to a larger one is
6
+ // to warn and fire after a single tick, so a deadline meant to be more patient becomes the
7
+ // least patient one there is.
8
+ const MAX_TIMER_MS = 2 ** 31 - 1
9
+
10
+ // A request that never comes back is not the same as one that fails, and only one of the
11
+ // two is something withRetry can do anything about.
12
+ //
13
+ // GramJS can leave a request queued on a sender it has quietly given up on. Its own abort
14
+ // path is unreachable: MTProtoSender rejects pending states only when
15
+ // `_currentRetries > _reconnectRetries`, and `reconnectRetries` has no default to compare
16
+ // against, so the test is never true (network/MTProtoSender.js:376). Nor does the reconnect
17
+ // itself report failure — `connect()` exhausts its attempts and returns false rather than
18
+ // throwing, so `_reconnect()` finishes as if it had worked and puts the request back on a
19
+ // queue with no send loop left to drain it (network/MTProtoSender.js:148,795).
20
+ //
21
+ // The promise then simply sits there. Nothing throws, nothing prints, and once no handle is
22
+ // left the process ends mid-transfer without a word — the one outcome this project forbids.
23
+ export async function withStallTimeout(promise, ms, describe) {
24
+ if (!(ms > 0)) return await promise
25
+
26
+ if (ms > MAX_TIMER_MS) {
27
+ throw new Error(
28
+ `A stall deadline of ${ms}ms is out of range: a timer holds at most ${MAX_TIMER_MS}ms, ` +
29
+ 'and a larger one fires after a single tick rather than waiting.',
30
+ )
31
+ }
32
+
33
+ let timer
34
+
35
+ try {
36
+ return await Promise.race([
37
+ promise,
38
+ new Promise((_, reject) => {
39
+ // Deliberately not unref'd. This timer is the only thing keeping the event loop
40
+ // alive while a request is outstanding, so a stall ends in this rejection instead
41
+ // of in Node running out of work and exiting silently.
42
+ timer = setTimeout(() => {
43
+ // A throw inside a timer callback is an uncaught exception, which ends the process
44
+ // rather than the request. Building the message must never be able to do that.
45
+ let message
46
+
47
+ try {
48
+ message = describe()
49
+ } catch (err) {
50
+ message = `A network wait went past ${ms}ms, and describing it failed: ${err.message}`
51
+ }
52
+
53
+ reject(new Error(message))
54
+ }, ms)
55
+ }),
56
+ ])
57
+ } finally {
58
+ // Promise.race has already attached a handler to `promise`, so a rejection arriving
59
+ // after we have stopped waiting is still handled and cannot surface as an
60
+ // unhandledRejection that hides the stall.
61
+ clearTimeout(timer)
62
+ }
63
+ }