@namzu/sandbox 6.1.0 → 7.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.
- package/CHANGELOG.md +62 -0
- package/README.md +29 -1
- package/dist/backends/aci-standby-pool/index.d.ts.map +1 -1
- package/dist/backends/aci-standby-pool/index.js +163 -111
- package/dist/backends/aci-standby-pool/index.js.map +1 -1
- package/dist/backends/docker/index.d.ts.map +1 -1
- package/dist/backends/docker/index.js +212 -149
- package/dist/backends/docker/index.js.map +1 -1
- package/dist/backends/firecracker/index.d.ts.map +1 -1
- package/dist/backends/firecracker/index.js +154 -71
- package/dist/backends/firecracker/index.js.map +1 -1
- package/dist/backends/firecracker/protocol.d.ts +19 -27
- package/dist/backends/firecracker/protocol.d.ts.map +1 -1
- package/dist/backends/firecracker/protocol.js +57 -32
- package/dist/backends/firecracker/protocol.js.map +1 -1
- package/dist/backends/firecracker/transport.d.ts +26 -7
- package/dist/backends/firecracker/transport.d.ts.map +1 -1
- package/dist/backends/firecracker/transport.js +235 -72
- package/dist/backends/firecracker/transport.js.map +1 -1
- package/dist/backends/http-worker-client.d.ts +17 -0
- package/dist/backends/http-worker-client.d.ts.map +1 -0
- package/dist/backends/http-worker-client.js +173 -0
- package/dist/backends/http-worker-client.js.map +1 -0
- package/dist/backends/readiness.d.ts +43 -0
- package/dist/backends/readiness.d.ts.map +1 -0
- package/dist/backends/readiness.js +150 -0
- package/dist/backends/readiness.js.map +1 -0
- package/dist/backends/remote-execution-controller.d.ts +76 -0
- package/dist/backends/remote-execution-controller.d.ts.map +1 -0
- package/dist/backends/remote-execution-controller.js +294 -0
- package/dist/backends/remote-execution-controller.js.map +1 -0
- package/dist/egress/proxy.d.ts.map +1 -1
- package/dist/egress/proxy.js +20 -3
- package/dist/egress/proxy.js.map +1 -1
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/backends/aci-standby-pool/index.ts +199 -117
- package/src/backends/docker/index.ts +235 -175
- package/src/backends/firecracker/index.ts +163 -63
- package/src/backends/firecracker/protocol.ts +67 -33
- package/src/backends/firecracker/transport.ts +301 -77
- package/src/backends/http-worker-client.ts +230 -0
- package/src/backends/readiness.ts +173 -0
- package/src/backends/remote-execution-controller.ts +462 -0
- package/src/egress/proxy.ts +18 -3
- package/src/index.ts +27 -0
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { type SandboxExecOptions, type SandboxExecResult, withHint } from '@namzu/sdk'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
RemoteCancellationUnsupportedError,
|
|
5
|
+
RemoteCommandError,
|
|
6
|
+
type RemoteExecutionAdapter,
|
|
7
|
+
RemoteExecutionController,
|
|
8
|
+
RemoteProtocolError,
|
|
9
|
+
} from './remote-execution-controller.js'
|
|
10
|
+
|
|
11
|
+
type WorkerEvent =
|
|
12
|
+
| { readonly type: 'stdout_delta'; readonly data: string }
|
|
13
|
+
| { readonly type: 'stderr_delta'; readonly data: string }
|
|
14
|
+
| {
|
|
15
|
+
readonly type: 'result'
|
|
16
|
+
readonly exitCode: number
|
|
17
|
+
readonly timedOut: boolean
|
|
18
|
+
readonly durationMs: number
|
|
19
|
+
readonly signal?: string
|
|
20
|
+
readonly stdoutTruncated?: boolean
|
|
21
|
+
readonly stderrTruncated?: boolean
|
|
22
|
+
}
|
|
23
|
+
| { readonly type: 'error'; readonly error: string }
|
|
24
|
+
|
|
25
|
+
function parseWorkerEvent(line: string): WorkerEvent {
|
|
26
|
+
let parsed: unknown
|
|
27
|
+
try {
|
|
28
|
+
parsed = JSON.parse(line)
|
|
29
|
+
} catch (error) {
|
|
30
|
+
throw new RemoteProtocolError(
|
|
31
|
+
`worker emitted malformed NDJSON: ${error instanceof Error ? error.message : String(error)}`,
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
35
|
+
throw new RemoteProtocolError('worker emitted an event without an object body')
|
|
36
|
+
}
|
|
37
|
+
const event = parsed as Record<string, unknown>
|
|
38
|
+
if (
|
|
39
|
+
(event.type === 'stdout_delta' || event.type === 'stderr_delta') &&
|
|
40
|
+
typeof event.data === 'string'
|
|
41
|
+
) {
|
|
42
|
+
return event as WorkerEvent
|
|
43
|
+
}
|
|
44
|
+
if (event.type === 'error' && typeof event.error === 'string') return event as WorkerEvent
|
|
45
|
+
if (
|
|
46
|
+
event.type === 'result' &&
|
|
47
|
+
Number.isFinite(event.exitCode) &&
|
|
48
|
+
typeof event.timedOut === 'boolean' &&
|
|
49
|
+
Number.isFinite(event.durationMs) &&
|
|
50
|
+
(event.signal === undefined || typeof event.signal === 'string') &&
|
|
51
|
+
(event.stdoutTruncated === undefined || typeof event.stdoutTruncated === 'boolean') &&
|
|
52
|
+
(event.stderrTruncated === undefined || typeof event.stderrTruncated === 'boolean')
|
|
53
|
+
) {
|
|
54
|
+
return event as WorkerEvent
|
|
55
|
+
}
|
|
56
|
+
throw new RemoteProtocolError(`worker emitted an invalid ${String(event.type)} event`)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function readExecution(
|
|
60
|
+
baseUrl: string,
|
|
61
|
+
executionId: string | undefined,
|
|
62
|
+
command: string,
|
|
63
|
+
argv: string[] | undefined,
|
|
64
|
+
opts: SandboxExecOptions | undefined,
|
|
65
|
+
transportSignal: AbortSignal,
|
|
66
|
+
): Promise<SandboxExecResult> {
|
|
67
|
+
let response: Response
|
|
68
|
+
try {
|
|
69
|
+
response = await fetch(`${baseUrl}/execute`, {
|
|
70
|
+
method: 'POST',
|
|
71
|
+
headers: { 'content-type': 'application/json' },
|
|
72
|
+
signal: transportSignal,
|
|
73
|
+
body: JSON.stringify({
|
|
74
|
+
...(executionId ? { executionId } : {}),
|
|
75
|
+
command,
|
|
76
|
+
args: argv ?? [],
|
|
77
|
+
cwd: opts?.cwd,
|
|
78
|
+
env: opts?.env,
|
|
79
|
+
timeoutMs: opts?.timeout,
|
|
80
|
+
}),
|
|
81
|
+
})
|
|
82
|
+
} catch (error) {
|
|
83
|
+
const cause = error instanceof Error ? error.cause : undefined
|
|
84
|
+
const causeMessage =
|
|
85
|
+
cause instanceof Error
|
|
86
|
+
? `${cause.message}${(cause as Error & { code?: string }).code ? ` (${(cause as Error & { code?: string }).code})` : ''}`
|
|
87
|
+
: cause
|
|
88
|
+
? String(cause)
|
|
89
|
+
: 'unknown'
|
|
90
|
+
throw withHint(
|
|
91
|
+
new Error(
|
|
92
|
+
`namzu-sandbox /execute fetch failed (baseUrl=${baseUrl}): ${error instanceof Error ? error.message : String(error)} — cause: ${causeMessage}`,
|
|
93
|
+
{ cause: error },
|
|
94
|
+
),
|
|
95
|
+
'The worker was reachable when the sandbox started, so it has most likely exited, been killed, or become unreachable since. Check the container logs and runtime exit state.',
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
if (!response.ok || !response.body) {
|
|
99
|
+
throw new Error(`execute failed: HTTP ${response.status} ${await response.text()}`)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const decoder = new TextDecoder()
|
|
103
|
+
const reader = response.body.getReader()
|
|
104
|
+
let buffered = ''
|
|
105
|
+
let stdout = ''
|
|
106
|
+
let stderr = ''
|
|
107
|
+
let terminal: Extract<WorkerEvent, { type: 'result' }> | undefined
|
|
108
|
+
let terminalCount = 0
|
|
109
|
+
|
|
110
|
+
const consume = (rawLine: string): void => {
|
|
111
|
+
if (!rawLine.trim()) return
|
|
112
|
+
const event = parseWorkerEvent(rawLine)
|
|
113
|
+
if (terminalCount > 0) {
|
|
114
|
+
throw new RemoteProtocolError('worker emitted data after its terminal event')
|
|
115
|
+
}
|
|
116
|
+
if (event.type === 'stdout_delta') {
|
|
117
|
+
stdout += event.data
|
|
118
|
+
opts?.onOutput?.({ stream: 'stdout', data: event.data })
|
|
119
|
+
return
|
|
120
|
+
}
|
|
121
|
+
if (event.type === 'stderr_delta') {
|
|
122
|
+
stderr += event.data
|
|
123
|
+
opts?.onOutput?.({ stream: 'stderr', data: event.data })
|
|
124
|
+
return
|
|
125
|
+
}
|
|
126
|
+
terminalCount += 1
|
|
127
|
+
if (event.type === 'error') throw new RemoteCommandError(event.error)
|
|
128
|
+
terminal = event
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
for (;;) {
|
|
132
|
+
const { value, done } = await reader.read()
|
|
133
|
+
if (done) break
|
|
134
|
+
buffered += decoder.decode(value, { stream: true })
|
|
135
|
+
let newline = buffered.indexOf('\n')
|
|
136
|
+
while (newline !== -1) {
|
|
137
|
+
consume(buffered.slice(0, newline))
|
|
138
|
+
buffered = buffered.slice(newline + 1)
|
|
139
|
+
newline = buffered.indexOf('\n')
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
buffered += decoder.decode()
|
|
143
|
+
if (buffered.trim()) consume(buffered)
|
|
144
|
+
if (terminalCount !== 1 || !terminal) {
|
|
145
|
+
throw new RemoteProtocolError('worker response ended without exactly one result event')
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
exitCode: terminal.exitCode,
|
|
150
|
+
stdout,
|
|
151
|
+
stderr,
|
|
152
|
+
...(terminal.signal ? { signal: terminal.signal } : {}),
|
|
153
|
+
timedOut: terminal.timedOut,
|
|
154
|
+
durationMs: terminal.durationMs,
|
|
155
|
+
...(terminal.stdoutTruncated !== undefined
|
|
156
|
+
? { stdoutTruncated: terminal.stdoutTruncated }
|
|
157
|
+
: {}),
|
|
158
|
+
...(terminal.stderrTruncated !== undefined
|
|
159
|
+
? { stderrTruncated: terminal.stderrTruncated }
|
|
160
|
+
: {}),
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* A per-sandbox HTTP worker client. The controller caches protocol support for
|
|
166
|
+
* that worker and gives every v2 command an identity before it can be admitted.
|
|
167
|
+
*/
|
|
168
|
+
export class HttpWorkerClient {
|
|
169
|
+
private readonly controller: RemoteExecutionController
|
|
170
|
+
|
|
171
|
+
constructor(baseUrl: string) {
|
|
172
|
+
const adapter: RemoteExecutionAdapter = {
|
|
173
|
+
label: 'HTTP worker',
|
|
174
|
+
reserve: async (signal) => {
|
|
175
|
+
const response = await fetch(`${baseUrl}/executions/reserve`, {
|
|
176
|
+
method: 'POST',
|
|
177
|
+
signal,
|
|
178
|
+
})
|
|
179
|
+
if (response.status === 404) {
|
|
180
|
+
throw new RemoteCancellationUnsupportedError(
|
|
181
|
+
'This sandbox worker does not support the execution-cancellation lease protocol. Rebuild the worker image or standby-pool profile before passing SandboxExecOptions.signal; refusing rather than pretending cancellation is active.',
|
|
182
|
+
)
|
|
183
|
+
}
|
|
184
|
+
if (!response.ok) {
|
|
185
|
+
throw new Error(
|
|
186
|
+
`execution reservation failed: HTTP ${response.status} ${await response.text()}`,
|
|
187
|
+
)
|
|
188
|
+
}
|
|
189
|
+
return await response.json()
|
|
190
|
+
},
|
|
191
|
+
cancel: async (executionId, signal) => {
|
|
192
|
+
const response = await fetch(`${baseUrl}/cancel`, {
|
|
193
|
+
method: 'POST',
|
|
194
|
+
headers: { 'content-type': 'application/json' },
|
|
195
|
+
body: JSON.stringify({ executionId }),
|
|
196
|
+
signal,
|
|
197
|
+
})
|
|
198
|
+
if (!response.ok) {
|
|
199
|
+
throw new Error(`cancel failed: HTTP ${response.status} ${await response.text()}`)
|
|
200
|
+
}
|
|
201
|
+
return await response.json()
|
|
202
|
+
},
|
|
203
|
+
execute: async (executionId, command, argv, opts, signal) =>
|
|
204
|
+
await readExecution(baseUrl, executionId, command, argv, opts, signal),
|
|
205
|
+
}
|
|
206
|
+
this.controller = new RemoteExecutionController(adapter)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async exec(
|
|
210
|
+
command: string,
|
|
211
|
+
argv: string[] | undefined,
|
|
212
|
+
opts: SandboxExecOptions | undefined,
|
|
213
|
+
): Promise<SandboxExecResult> {
|
|
214
|
+
return await this.controller.exec(command, argv, opts)
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Compatibility entry point for focused consumers. Sandbox backends keep one
|
|
220
|
+
* {@link HttpWorkerClient} per remote sandbox so capability state is not shared
|
|
221
|
+
* across peers and is not re-probed for every command.
|
|
222
|
+
*/
|
|
223
|
+
export async function execViaHttpWorker(
|
|
224
|
+
baseUrl: string,
|
|
225
|
+
command: string,
|
|
226
|
+
argv: string[] | undefined,
|
|
227
|
+
opts: SandboxExecOptions | undefined,
|
|
228
|
+
): Promise<SandboxExecResult> {
|
|
229
|
+
return await new HttpWorkerClient(baseUrl).exec(command, argv, opts)
|
|
230
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One owner for backend readiness clocks.
|
|
3
|
+
*
|
|
4
|
+
* A timestamp checked between retries is not a deadline when the thing inside
|
|
5
|
+
* a retry can remain pending. This owner races every foreign promise against
|
|
6
|
+
* the remaining operation budget and supplies a private signal so cooperative
|
|
7
|
+
* transports can release their sockets too.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const MAX_NODE_TIMER_MS = 2_147_483_647
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Readiness failure teardown is deliberately a second, short budget. The
|
|
14
|
+
* health deadline has already expired, so pretending cleanup fits inside it
|
|
15
|
+
* either skips cleanup entirely or keeps `create()` pending without a bound.
|
|
16
|
+
*/
|
|
17
|
+
export const FAILURE_CLEANUP_GRACE_MS = 1_000
|
|
18
|
+
|
|
19
|
+
export interface ReadinessOptions {
|
|
20
|
+
readonly timeoutMs: number
|
|
21
|
+
readonly pollIntervalMs: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class OperationDeadlineExpired extends Error {
|
|
25
|
+
constructor(readonly label: string) {
|
|
26
|
+
super(`${label} deadline expired`)
|
|
27
|
+
this.name = 'OperationDeadlineExpired'
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function assertTimerValue(field: string, value: number): void {
|
|
32
|
+
if (!Number.isSafeInteger(value) || value <= 0 || value > MAX_NODE_TIMER_MS) {
|
|
33
|
+
throw new RangeError(
|
|
34
|
+
`${field} must be a positive safe integer no greater than ${MAX_NODE_TIMER_MS}; received ${String(value)}`,
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function resolveReadinessOptions(
|
|
40
|
+
owner: string,
|
|
41
|
+
timeoutMs: number | undefined,
|
|
42
|
+
pollIntervalMs: number | undefined,
|
|
43
|
+
defaults: ReadinessOptions,
|
|
44
|
+
): ReadinessOptions {
|
|
45
|
+
const resolved = {
|
|
46
|
+
timeoutMs: timeoutMs ?? defaults.timeoutMs,
|
|
47
|
+
pollIntervalMs: pollIntervalMs ?? defaults.pollIntervalMs,
|
|
48
|
+
}
|
|
49
|
+
assertTimerValue(`${owner}.readyTimeoutMs`, resolved.timeoutMs)
|
|
50
|
+
assertTimerValue(`${owner}.readyPollIntervalMs`, resolved.pollIntervalMs)
|
|
51
|
+
return resolved
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export class OperationDeadline {
|
|
55
|
+
private readonly expiresAt: number
|
|
56
|
+
|
|
57
|
+
constructor(
|
|
58
|
+
timeoutMs: number,
|
|
59
|
+
readonly label: string,
|
|
60
|
+
private readonly callerSignal?: AbortSignal,
|
|
61
|
+
) {
|
|
62
|
+
assertTimerValue(`${label} timeout`, timeoutMs)
|
|
63
|
+
this.expiresAt = performance.now() + timeoutMs
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
remainingMs(): number {
|
|
67
|
+
return Math.max(0, this.expiresAt - performance.now())
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async run<T>(operation: (signal: AbortSignal) => Promise<T>): Promise<T> {
|
|
71
|
+
this.callerSignal?.throwIfAborted()
|
|
72
|
+
const remaining = this.remainingMs()
|
|
73
|
+
if (remaining <= 0) throw new OperationDeadlineExpired(this.label)
|
|
74
|
+
|
|
75
|
+
const controller = new AbortController()
|
|
76
|
+
const expired = new OperationDeadlineExpired(this.label)
|
|
77
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
78
|
+
let onCallerAbort: (() => void) | undefined
|
|
79
|
+
const cancelled = this.callerSignal
|
|
80
|
+
? new Promise<never>((_resolve, reject) => {
|
|
81
|
+
onCallerAbort = () => {
|
|
82
|
+
const reason = this.callerSignal?.reason
|
|
83
|
+
controller.abort(reason)
|
|
84
|
+
reject(reason)
|
|
85
|
+
}
|
|
86
|
+
this.callerSignal?.addEventListener('abort', onCallerAbort, {
|
|
87
|
+
once: true,
|
|
88
|
+
})
|
|
89
|
+
})
|
|
90
|
+
: undefined
|
|
91
|
+
const expiry = new Promise<never>((_resolve, reject) => {
|
|
92
|
+
timer = setTimeout(() => {
|
|
93
|
+
controller.abort(expired)
|
|
94
|
+
reject(expired)
|
|
95
|
+
}, remaining)
|
|
96
|
+
})
|
|
97
|
+
const pending = Promise.resolve().then(() => operation(controller.signal))
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
const value = await Promise.race(cancelled ? [pending, expiry, cancelled] : [pending, expiry])
|
|
101
|
+
// Publication fence: a foreign promise that settles at the boundary
|
|
102
|
+
// does not get to turn an already-expired attempt into readiness.
|
|
103
|
+
if (this.callerSignal?.aborted) throw this.callerSignal.reason
|
|
104
|
+
if (controller.signal.aborted || this.remainingMs() <= 0) {
|
|
105
|
+
controller.abort(expired)
|
|
106
|
+
throw expired
|
|
107
|
+
}
|
|
108
|
+
return value
|
|
109
|
+
} finally {
|
|
110
|
+
if (timer !== undefined) clearTimeout(timer)
|
|
111
|
+
if (onCallerAbort) this.callerSignal?.removeEventListener('abort', onCallerAbort)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async delay(maximumMs: number): Promise<void> {
|
|
116
|
+
await this.run(
|
|
117
|
+
(signal) =>
|
|
118
|
+
new Promise<void>((resolve, reject) => {
|
|
119
|
+
if (signal.aborted) {
|
|
120
|
+
reject(signal.reason)
|
|
121
|
+
return
|
|
122
|
+
}
|
|
123
|
+
const finish = (err?: unknown) => {
|
|
124
|
+
clearTimeout(timer)
|
|
125
|
+
signal.removeEventListener('abort', abort)
|
|
126
|
+
if (err === undefined) resolve()
|
|
127
|
+
else reject(err)
|
|
128
|
+
}
|
|
129
|
+
const abort = () => {
|
|
130
|
+
finish(signal.reason)
|
|
131
|
+
}
|
|
132
|
+
const timer = setTimeout(() => finish(), maximumMs)
|
|
133
|
+
signal.addEventListener('abort', abort, { once: true })
|
|
134
|
+
}),
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export async function probeHttpHealth(
|
|
140
|
+
url: string,
|
|
141
|
+
signal: AbortSignal,
|
|
142
|
+
): Promise<{ readonly ok: boolean; readonly status: number }> {
|
|
143
|
+
signal.throwIfAborted()
|
|
144
|
+
const response = await fetch(url, { signal })
|
|
145
|
+
const result = { ok: response.ok, status: response.status }
|
|
146
|
+
try {
|
|
147
|
+
await response.body?.cancel()
|
|
148
|
+
} catch {
|
|
149
|
+
// The status is already known. A body cancellation failure must not
|
|
150
|
+
// hide it; the owning deadline still aborts the transport if needed.
|
|
151
|
+
}
|
|
152
|
+
signal.throwIfAborted()
|
|
153
|
+
return result
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Failure cleanup must never replace or indefinitely delay the primary
|
|
158
|
+
* readiness error. Cooperative cleanup observes the signal; an implementation
|
|
159
|
+
* that ignores it is still detached from the caller by the independent race.
|
|
160
|
+
*/
|
|
161
|
+
export async function runFailureCleanup(
|
|
162
|
+
cleanup: (signal: AbortSignal) => Promise<void>,
|
|
163
|
+
graceMs = FAILURE_CLEANUP_GRACE_MS,
|
|
164
|
+
): Promise<void> {
|
|
165
|
+
const deadline = new OperationDeadline(graceMs, 'sandbox failure cleanup')
|
|
166
|
+
try {
|
|
167
|
+
await deadline.run(cleanup)
|
|
168
|
+
} catch {
|
|
169
|
+
// The readiness failure remains primary. Promise.race installed a
|
|
170
|
+
// rejection observer on the losing cleanup promise, so a late failure
|
|
171
|
+
// cannot become an unhandled rejection.
|
|
172
|
+
}
|
|
173
|
+
}
|