mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-13 07:12:00 +01:00
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - Remote workspaces save their files as encrypted snapshots in object
storage. Snapshots over 16 MiB use S3 multipart uploads.
> - A server restart can cut off a snapshot upload in the middle of an
`UploadPart` request.
> - The server aborts a multipart upload only in-process, on an error or
an abort signal. A SIGKILL can never do that. On SIGTERM, the drain step
timed out while the store held the part, and the process exited without
`AbortMultipartUpload`.
> - The incomplete upload then stays in the bucket. Nothing records its
upload id and nothing finds it again, so incomplete uploads pile up and
the operator pays for their parts.
> - This pull request aborts the incomplete uploads of each save attempt
when that attempt can no longer win, with no polling and no schema
change.
> - The benefit is that no incomplete upload outlives its save attempt.
## Linked Issues or Issue Description
**What happened?**
A restart-chaos wave (Daytona, R1 workload, storage fault ST4,
`--storage-trigger snapshot-part`, faults K1 and K2, master
`fea4530a30`) passed all 3 tasks. All 3 committed snapshots read back
intact. But the storage audit found 3 incomplete multipart uploads. Each
one was the exact upload that a restart cut off in the middle of
`UploadPart`. The retried save then published a different attempt of the
same snapshot.
**Expected behavior**
When a save attempt can no longer become the snapshot's object, its
incomplete multipart uploads are aborted. The storage audit shows 0
incomplete uploads after the wave.
**Steps to reproduce**
1. Run a snapshot save larger than 16 MiB against S3.
2. Kill the server (SIGKILL), or send SIGTERM while the store holds an
`UploadPart` request.
3. Let the save retry and publish.
4. List the bucket's multipart uploads. The cut-off attempt's upload is
still there.
**Paperclip version or commit**
master `fea4530a30` and later.
**Relevant logs or output**
`shutdown step timed out; continuing shutdown without it
{"step":"delivery_work_stop","durationMs":20001}`, then `Paperclip did
not stop within 30000ms; sending SIGKILL`.
## What Changed
- The S3 storage provider and the storage service get
`abortIncompleteUploads(objectKey)`. It lists the multipart uploads
under that exact key (`ListMultipartUploads` with the key as the
prefix), skips other keys that only share the prefix, and aborts each
one. `NoSuchUpload` counts as already done. The local-disk provider has
no multipart uploads and does nothing.
- Save publication: the save journals every attempt's object key in
`metadata.uploads` before any remote I/O. When one attempt moves the
snapshot from `pending` to `uploaded`, no other attempt can win any
more. The winning process now aborts the incomplete uploads of every
other journaled attempt key. Each abort is best effort and bounded to 5
s. A snapshot with one attempt sends no extra request.
- A losing uploader that is still live: if its upload fails after
another attempt has published the snapshot, it now returns the published
snapshot (after it verifies it) instead of the error. So an abort from
the winner never fails a save that is still running in another process.
- Snapshot retention: before it deletes a retired snapshot's objects
(this includes saves that #20502 retires as abandoned), retention aborts
the incomplete uploads of each attempt key. A store that refuses the
listing (for example a missing `s3:ListBucketMultipartUploads`
permission) logs a warning and does not block the deletion.
- Abandoned saves: when the ownership reconciler retires an abandoned
save (`remote_snapshot_save` and `remote_snapshot_ledger` settlement,
from #20502), it now aborts the incomplete uploads of every journaled
attempt key after its transaction commits. The owner run is terminal and
the save work is settled, so no live uploader can own those uploads.
This is the durable path for SIGKILL: the reconciler pass after boot
settles the dead save and cleans its uploads.
- SIGTERM drain: the multipart upload code keeps an in-process set of
running uploads. Shutdown aborts them right away when
`delivery_work_stop` times out. It also aborts them after the heartbeat
run drain, before the finalizer and workspace-recovery waits, after a 5
s grace for them to finish. It waits at most 10 s for the
`AbortMultipartUpload` calls. Shutdown does nothing extra when no upload
runs.
- No polling and no periodic sweep. All triggers are events that already
exist: publication, abandonment settlement, retention of a retired
snapshot, and shutdown.
Previous-release story (`rules/upgrade-safe-run-state.mdc`): this adds
no persisted state and changes no saved shape. A snapshot that the
previous release saved with cut-off attempts in `metadata.uploads`
carries forward. Its incomplete uploads are aborted when the new release
publishes a retry of that snapshot, or when retention retires the
snapshot.
## Verification
- Red before, green after (all fail on master, pass with this change):
- `server/src/services/remote-workspace-snapshot-storage.test.ts`:
"aborts the incomplete upload of an attempt a restart cut off once a
retried save publishes" and "adopts the published save when another
uploader aborts this attempt's upload".
- `server/src/services/remote-workspace-snapshot-retention.test.ts`:
"aborts each attempt's incomplete multipart upload before deleting a
retired snapshot's objects". A second test proves that a refused listing
does not block retention.
- `server/src/__tests__/storage-s3-multipart-shutdown.test.ts`: file and
streamed uploads whose part the store holds are aborted at shutdown, and
the provider aborts only the exact key across listing pages.
- `server/src/__tests__/remote-snapshot-ledger-settlement.test.ts`:
"aborts the incomplete multipart uploads of every attempt when the
reconciler retires an abandoned save" (both settlement kinds; a key
outside the snapshot's prefix is skipped).
- `tests/restart-chaos/storage-orphan-uploads.test.ts` (MinIO): the real
provider aborts both cut-off uploads of one attempt key, and keeps a
sibling attempt and a longer key that shares the prefix.
- `cd server && npx vitest run src/__tests__/storage-s3 src/storage
src/__tests__/remote-snapshot-ledger-settlement.test.ts
src/services/remote-workspace-snapshot
src/services/remote-workspace-transport.test.ts
src/__tests__/remote-workspace-file-resources.test.ts`: 19 files, 517
tests pass; with `src/services/recovery`, `src/__tests__/ownership*` and
`heartbeat-workspace-busy.test.ts`, 32 files and 580 tests pass.
- `cd server && npx tsc --noEmit` and `node
scripts/typecheck-test-projects.mjs tests/restart-chaos` pass.
- Live proof, Daytona R1, ST4, `--storage-trigger snapshot-part`, K1/K2,
seed 1033, the evidence wave's flags, image
`paperclip-daytona-runner@sha256:05a4a092…` (controllerMatch: match):
- Before (master `fea4530a30`): `w-20261110T130541-s1033`: 3/3 pass, **3
incomplete multipart uploads**.
- `w-20261110T134623-s1033` on an earlier revision of this PR (publish +
retention hooks only): 2 incomplete uploads remained. Those saves were
abandoned, not retried, so neither hook ran. This led to the settlement
hook.
- After (head `8bc8fc66d0`): `w-20261110T141144-s1033`: 3/3 tasks pass,
exit 0, every fired restart proved it cut the held `UploadPart`, the
reconciler settled 3 abandoned saves, shutdown logged one in-flight
abort, and the storage audit shows **0 incomplete multipart uploads**
and 3 committed snapshots that read back intact.
## Risks
- A live uploader in another process can lose its incomplete upload when
a different attempt publishes first. That uploader now adopts the
published snapshot, so its save still succeeds.
- Narrow gap: an uploader that recorded its attempt but had not yet sent
`CreateMultipartUpload` when the winner published, and that then crashes
during its upload, leaves an incomplete upload until retention retires
the snapshot. An S3 lifecycle rule for incomplete uploads stays a good
backstop.
- Stores without `ListMultipartUploads` permission log a warning and
keep the previous behavior.
- Shutdown can take up to 10 s longer, and only when an upload is still
running after the drain.
## Model Used
Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, with tool use and code
execution in Claude Code.
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
375 lines
13 KiB
TypeScript
375 lines
13 KiB
TypeScript
type HotRestartShutdownPreparation = {
|
|
skipDrain: boolean;
|
|
};
|
|
|
|
type ShutdownLogger = {
|
|
info(obj: object, msg: string): void;
|
|
error(obj: object, msg: string): void;
|
|
};
|
|
|
|
export const PRE_DRAIN_SHUTDOWN_BUDGET_MS = 20_000;
|
|
|
|
export type ShutdownStepResult<T> =
|
|
| { outcome: "completed"; value: T; durationMs: number }
|
|
| { outcome: "timed_out"; durationMs: number }
|
|
| { outcome: "failed"; error: unknown; durationMs: number };
|
|
|
|
export async function afterStalledShutdownStep<T>(result: ShutdownStepResult<T>, onStalled: () => Promise<void>) {
|
|
if (result.outcome !== "completed") await onStalled();
|
|
return result;
|
|
}
|
|
|
|
export async function runBoundedShutdownStep<T>(input: {
|
|
step: string;
|
|
signal: "SIGINT" | "SIGTERM";
|
|
timeoutMs: number;
|
|
run: () => Promise<T>;
|
|
log: ShutdownLogger;
|
|
now?: () => number;
|
|
}): Promise<ShutdownStepResult<T>> {
|
|
const now = input.now ?? Date.now;
|
|
const startedAt = now();
|
|
const timeoutMs = Math.max(0, input.timeoutMs);
|
|
let timer: NodeJS.Timeout | null = null;
|
|
try {
|
|
const result = await Promise.race([
|
|
Promise.resolve()
|
|
.then(input.run)
|
|
.then(
|
|
(value) => ({ outcome: "completed" as const, value }),
|
|
(error: unknown) => ({ outcome: "failed" as const, error }),
|
|
),
|
|
new Promise<{ outcome: "timed_out" }>((resolve) => {
|
|
timer = setTimeout(() => resolve({ outcome: "timed_out" }), timeoutMs);
|
|
timer.unref?.();
|
|
}),
|
|
]);
|
|
const durationMs = now() - startedAt;
|
|
const fields = { signal: input.signal, step: input.step, durationMs, timeoutMs };
|
|
if (result.outcome === "completed") {
|
|
input.log.info(fields, "shutdown step completed");
|
|
return { outcome: "completed", value: result.value, durationMs };
|
|
}
|
|
if (result.outcome === "failed") {
|
|
input.log.error({ ...fields, err: result.error }, "shutdown step failed; continuing shutdown");
|
|
return { outcome: "failed", error: result.error, durationMs };
|
|
}
|
|
input.log.info(fields, "shutdown step timed out; continuing shutdown without it");
|
|
return { outcome: "timed_out", durationMs };
|
|
} finally {
|
|
if (timer) clearTimeout(timer);
|
|
}
|
|
}
|
|
|
|
export function createPreDrainShutdownBudget(input: {
|
|
signal: "SIGINT" | "SIGTERM";
|
|
budgetMs?: number;
|
|
log: ShutdownLogger;
|
|
now?: () => number;
|
|
}) {
|
|
const now = input.now ?? Date.now;
|
|
const deadline = now() + (input.budgetMs ?? PRE_DRAIN_SHUTDOWN_BUDGET_MS);
|
|
return {
|
|
run<T>(step: string, run: () => Promise<T>) {
|
|
return runBoundedShutdownStep({
|
|
step,
|
|
signal: input.signal,
|
|
timeoutMs: deadline - now(),
|
|
run,
|
|
log: input.log,
|
|
now,
|
|
});
|
|
},
|
|
};
|
|
}
|
|
|
|
export async function drainRunExecutionFinalizersForShutdown(input: {
|
|
signal: "SIGINT" | "SIGTERM";
|
|
drain: (() => Promise<void>) | null;
|
|
timeoutMs?: number;
|
|
log: ShutdownLogger;
|
|
}): Promise<"drained" | "timed_out" | "unavailable"> {
|
|
if (!input.drain) return "unavailable";
|
|
const timeoutMs = input.timeoutMs ?? 5_000;
|
|
let timer: NodeJS.Timeout | null = null;
|
|
try {
|
|
const result = await Promise.race([
|
|
input.drain().then(() => "drained" as const),
|
|
new Promise<"timed_out">((resolve) => {
|
|
timer = setTimeout(() => resolve("timed_out"), timeoutMs);
|
|
timer.unref?.();
|
|
}),
|
|
]);
|
|
if (result === "timed_out") {
|
|
input.log.info(
|
|
{ signal: input.signal, timeoutMs },
|
|
"bounded heartbeat execution finalizer drain timed out",
|
|
);
|
|
}
|
|
return result;
|
|
} finally {
|
|
if (timer) clearTimeout(timer);
|
|
}
|
|
}
|
|
|
|
type ShutdownHttpListener = {
|
|
listening: boolean;
|
|
close(callback?: (err?: Error) => void): unknown;
|
|
closeIdleConnections?: () => void;
|
|
closeAllConnections?: () => void;
|
|
};
|
|
|
|
/**
|
|
* Stops the HTTP listener from accepting new requests and waits, for at most
|
|
* `timeoutMs`, for the open connections to finish. Idle keep-alive sockets
|
|
* close at once; whatever is still open when the grace period ends is closed
|
|
* forcibly, so the teardown never hangs on a long-lived client. Call this
|
|
* before the database pool ends, so no request can reach a route after
|
|
* `sql.end()` and fail with a connection-ended error.
|
|
*/
|
|
export async function closeHttpListenerForShutdown(input: {
|
|
server: ShutdownHttpListener;
|
|
signal: "SIGINT" | "SIGTERM";
|
|
timeoutMs?: number;
|
|
drainRequests?: (signal: AbortSignal) => Promise<void>;
|
|
closeRunAdmission?: () => void;
|
|
log: ShutdownLogger;
|
|
}): Promise<"closed" | "timed_out" | "not_listening"> {
|
|
input.closeRunAdmission?.();
|
|
if (!input.server.listening) return "not_listening";
|
|
const timeoutMs = input.timeoutMs ?? 5_000;
|
|
let timer: NodeJS.Timeout | null = null;
|
|
const controller = new AbortController();
|
|
try {
|
|
const closed = new Promise<"closed">((resolve) => {
|
|
input.server.close((err) => {
|
|
if (err && (err as NodeJS.ErrnoException).code !== "ERR_SERVER_NOT_RUNNING") {
|
|
input.log.error({ err, signal: input.signal }, "HTTP listener close failed");
|
|
}
|
|
resolve("closed");
|
|
});
|
|
input.server.closeIdleConnections?.();
|
|
});
|
|
return await Promise.race([
|
|
input.drainRequests ? input.drainRequests(controller.signal).then(() => {
|
|
input.server.closeIdleConnections?.();
|
|
return "closed" as const;
|
|
}) : closed,
|
|
new Promise<"timed_out">((resolve) => {
|
|
timer = setTimeout(() => {
|
|
input.log.info(
|
|
{ signal: input.signal, timeoutMs },
|
|
"HTTP listener drain timed out; closing the remaining connections",
|
|
);
|
|
input.server.closeAllConnections?.();
|
|
resolve("timed_out");
|
|
}, timeoutMs);
|
|
timer.unref?.();
|
|
}),
|
|
]);
|
|
} finally {
|
|
if (timer) clearTimeout(timer);
|
|
controller.abort();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Runs the final, ordered teardown of the server. It awaits the application
|
|
* service cleanup first, so a live setup-token login session stops and releases
|
|
* its sandbox lease before the database and the provider stop. The caller runs
|
|
* `process.exit(0)` only after this helper resolves, so an orderly shutdown
|
|
* never leaves a sandbox lease or confidential login state alive past the
|
|
* process exit.
|
|
*
|
|
* A step that rejects does not stop the teardown. The helper logs the error and
|
|
* continues to the next step. A failed setup-token lease release stays a
|
|
* durable record for the startup reaper; the helper surfaces it in the log
|
|
* instead of blocking the exit path.
|
|
*/
|
|
export async function finalizeServerShutdown(input: {
|
|
signal: "SIGINT" | "SIGTERM";
|
|
shutdownAppServices: (() => Promise<void>) | undefined;
|
|
/**
|
|
* Stops the HTTP listener and drains its connections (see
|
|
* `closeHttpListenerForShutdown`). Runs first, while every application
|
|
* service is still available to the requests being drained, so no request
|
|
* runs against a half-dismantled service or an ended pool.
|
|
*/
|
|
closeHttpListener?: (() => Promise<unknown>) | null;
|
|
/**
|
|
* Waits for every run-failure Sentry report still in flight. Runs after the
|
|
* application services and before the database pool ends, so a report that
|
|
* started just before shutdown still gets its database read and reaches
|
|
* Sentry before `shutdownSentry` flushes and closes the client.
|
|
*/
|
|
drainPendingRunFailureReports?: (() => Promise<void>) | null;
|
|
/**
|
|
* Ends the server's PostgreSQL client pools. Runs after the application
|
|
* services (which still need the database) and before the embedded
|
|
* provider stops, so the backends close in order and none outlive the
|
|
* process.
|
|
*/
|
|
closeDatabase?: (() => Promise<void>) | null;
|
|
stopEmbeddedPostgres: (() => Promise<void>) | null;
|
|
shutdownInstrumentation: () => Promise<void>;
|
|
shutdownSentry: () => Promise<void>;
|
|
log: ShutdownLogger;
|
|
}): Promise<void> {
|
|
const { signal } = input;
|
|
|
|
// Stop accepting requests and drain the open ones before any service goes
|
|
// away, so a request that is still in flight sees a fully working server.
|
|
if (input.closeHttpListener) {
|
|
try {
|
|
await input.closeHttpListener();
|
|
} catch (err) {
|
|
input.log.error({ err, signal }, "HTTP listener shutdown failed");
|
|
}
|
|
}
|
|
|
|
// Await the application service cleanup, so a live setup-token login session
|
|
// releases its sandbox lease before the database and the provider stop. A
|
|
// rejected cleanup stays durable for the reaper; it does not block the exit.
|
|
try {
|
|
await input.shutdownAppServices?.();
|
|
} catch (err) {
|
|
input.log.error({ err, signal }, "Application service shutdown failed");
|
|
}
|
|
|
|
// Wait for every in-flight run-failure Sentry report before the database
|
|
// pool ends. `reportRunFailure` is fire-and-forget: without this wait, a
|
|
// report that started just before shutdown can lose its database read to
|
|
// the pool end below, or lose its Sentry call to the flush further down.
|
|
if (input.drainPendingRunFailureReports) {
|
|
try {
|
|
await input.drainPendingRunFailureReports();
|
|
} catch (err) {
|
|
input.log.error({ err, signal }, "run-failure report drain failed");
|
|
}
|
|
}
|
|
|
|
// End the client pools once nothing needs them any more. Without this the
|
|
// process exit leaves the pooled backends to PostgreSQL's own TCP keepalive
|
|
// reaping, and a restart loop can pile up enough of them to hit
|
|
// `max_connections` before the next boot gets a connection.
|
|
if (input.closeDatabase) {
|
|
try {
|
|
await input.closeDatabase();
|
|
} catch (err) {
|
|
input.log.error({ err, signal }, "Database client shutdown failed");
|
|
}
|
|
}
|
|
|
|
if (input.stopEmbeddedPostgres) {
|
|
input.log.info({ signal }, "Stopping embedded PostgreSQL");
|
|
try {
|
|
await input.stopEmbeddedPostgres();
|
|
} catch (err) {
|
|
input.log.error({ err }, "Failed to stop embedded PostgreSQL cleanly");
|
|
}
|
|
}
|
|
|
|
// Flush buffered OTel spans before the process goes away; without this await
|
|
// the exporter's final batch is dropped on exit.
|
|
await input.shutdownInstrumentation();
|
|
|
|
// Flush buffered Sentry events before the process goes away; without this
|
|
// await the last events are dropped on exit.
|
|
await input.shutdownSentry();
|
|
}
|
|
|
|
const COORDINATED_SHUTDOWN_SIGNALS = ["SIGINT", "SIGTERM"] as const;
|
|
|
|
type ShutdownSignalTarget = {
|
|
rawListeners(eventName: string): Function[];
|
|
removeListener(eventName: string, listener: (...args: any[]) => void): unknown;
|
|
};
|
|
|
|
/**
|
|
* Some dependencies eagerly install process signal handlers as an import side
|
|
* effect. Paperclip must remain the sole owner of SIGINT/SIGTERM ordering: its
|
|
* handler first snapshots live heartbeat runs and only then stops embedded
|
|
* infrastructure. Remove only listeners added by the supplied import, while
|
|
* preserving every listener that was already registered.
|
|
*/
|
|
export async function loadWithoutCoordinatedShutdownSignalHooks<T>(
|
|
load: () => Promise<T>,
|
|
signalTarget: ShutdownSignalTarget = process,
|
|
) {
|
|
const listenersBeforeLoad = new Map(
|
|
COORDINATED_SHUTDOWN_SIGNALS.map((signal) => [
|
|
signal,
|
|
signalTarget.rawListeners(signal),
|
|
]),
|
|
);
|
|
|
|
let loaded: T;
|
|
try {
|
|
loaded = await load();
|
|
} finally {
|
|
for (const signal of COORDINATED_SHUTDOWN_SIGNALS) {
|
|
const remainingBeforeLoad = [...(listenersBeforeLoad.get(signal) ?? [])];
|
|
for (const listener of signalTarget.rawListeners(signal)) {
|
|
const existingIndex = remainingBeforeLoad.indexOf(listener);
|
|
if (existingIndex >= 0) {
|
|
remainingBeforeLoad.splice(existingIndex, 1);
|
|
continue;
|
|
}
|
|
signalTarget.removeListener(signal, listener as (...args: any[]) => void);
|
|
}
|
|
}
|
|
}
|
|
|
|
return loaded;
|
|
}
|
|
|
|
export async function coordinateHeartbeatSchedulerShutdown<
|
|
TPreparation extends HotRestartShutdownPreparation,
|
|
>(input: {
|
|
signal: "SIGINT" | "SIGTERM";
|
|
prepareHotRestartShutdown: ((signal: "SIGINT" | "SIGTERM") => Promise<TPreparation>) | null;
|
|
waitForHeartbeatSchedulerIdle: () => Promise<void>;
|
|
}): Promise<{
|
|
hotRestart: TPreparation | null;
|
|
preparationError: unknown;
|
|
waitedForSchedulerIdle: boolean;
|
|
}> {
|
|
let hotRestart: TPreparation | null = null;
|
|
let preparationError: unknown = null;
|
|
|
|
// The signal handler stops the scheduler before entering this coordinator.
|
|
// Quiesce any callback that was already in flight before querying running
|
|
// rows for the shutdown snapshot, otherwise a late queue claim can create a
|
|
// run that is absent from both the snapshot and the selective drain set.
|
|
await input.waitForHeartbeatSchedulerIdle();
|
|
|
|
if (input.prepareHotRestartShutdown) {
|
|
try {
|
|
hotRestart = await input.prepareHotRestartShutdown(input.signal);
|
|
} catch (err) {
|
|
preparationError = err;
|
|
}
|
|
}
|
|
|
|
return {
|
|
hotRestart,
|
|
preparationError,
|
|
waitedForSchedulerIdle: true,
|
|
};
|
|
}
|
|
|
|
export async function finishServiceShutdown(
|
|
stop: () => Promise<void>,
|
|
cleanup: () => Promise<void>,
|
|
): Promise<void> {
|
|
const failures: unknown[] = [];
|
|
let retryStop = false;
|
|
try { await stop(); } catch { retryStop = true; }
|
|
try { await cleanup(); } catch (error) { failures.push(error); }
|
|
if (retryStop) {
|
|
try { await stop(); } catch (error) { failures.unshift(error); }
|
|
}
|
|
if (failures.length === 1) throw failures[0];
|
|
if (failures.length > 1) throw new AggregateError(failures, "Service shutdown failed");
|
|
}
|