fix: surface API 500 causes, guide update EACCES, non-zero daemon signal exit
Addresses three user-reported bugs: - API 500 diagnosability: rethrowAsApiError now preserves the original error as Error `cause` and the /api boundary logs stack + cause for 5xx, so the opaque "task write API returns 500 for every task" failures are traceable (client body stays generic in production). - In-app "Update now": detect EACCES/EPERM install failures and return actionable remediation (sudo fn update / reinstall without sudo / brew upgrade) instead of raw npm stderr; do not retry --force for this class. - Daemon restart: `fn daemon` and `fn serve` exit 128+signal (SIGTERM=143, SIGINT=130) on signal-initiated shutdown so Restart=on-failure restarts a memory-pressure kill. Interactive `fn dashboard` TUI intentionally unchanged. Adds regression tests (update-check EACCES/EPERM, daemon exit codes) and three @runfusion/fusion patch changesets. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
7
.changeset/daemon-signal-nonzero-exit.md
Normal file
7
.changeset/daemon-signal-nonzero-exit.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Daemon exits non-zero on signal termination so Restart=on-failure restarts it after a memory-pressure kill.
|
||||||
|
category: fix
|
||||||
|
dev: `fn daemon` and `fn serve` (packages/cli/src/commands/daemon.ts, serve.ts) now exit with the POSIX 128+signal code (SIGTERM=143, SIGINT=130) on signal-initiated graceful shutdown instead of 0. Previously a memory-pressure SIGTERM produced exit 0, which `Restart=on-failure` treated as a clean stop, leaving the daemon dead. A deliberate `systemctl stop` still won't restart (systemd honors the requested inactive state regardless of exit code); a non-signal shutdown still exits 0. The interactive TUI launcher (`fn dashboard`) is intentionally unchanged — it has its own signal-name-keyed restart supervisor.
|
||||||
7
.changeset/task-api-500-diagnostics.md
Normal file
7
.changeset/task-api-500-diagnostics.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Server now logs the underlying stack behind an API 500 so opaque task-endpoint failures are diagnosable.
|
||||||
|
category: fix
|
||||||
|
dev: `rethrowAsApiError` (packages/dashboard/src/api-error.ts) now preserves the original error as Error `cause` instead of discarding it, and `sendErrorResponse`/the `/api` error boundary (packages/dashboard/src/server.ts) log the stack + cause chain for 5xx (not just the message). The client-facing body stays generic in production. Unblocks root-causing the reported "task write API returns 500 for every task" (GET/DELETE/PATCH/retry/archive/reset on /api/tasks/:id) whose cause was previously never recorded server-side.
|
||||||
7
.changeset/update-now-eacces-guidance.md
Normal file
7
.changeset/update-now-eacces-guidance.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: "Update now" now explains permission (EACCES) failures and how to fix them instead of showing raw npm errors.
|
||||||
|
category: fix
|
||||||
|
dev: `performUpdateInstall` (packages/dashboard/src/update-check.ts) detects EACCES/EPERM install failures (by error code or stderr text) and returns actionable remediation — run `sudo fn update`, reinstall without sudo, or `brew upgrade fusion` for Homebrew installs — rather than the raw `npm error EACCES … rename '/usr/lib/node_modules/@runfusion/fusion'`. Occurs when Fusion was installed via `sudo npm i -g` (root-owned global dir); `--force` is not retried for this class since it cannot grant write permission.
|
||||||
@@ -823,6 +823,22 @@ describe("runDaemon", () => {
|
|||||||
await triggerSignal("SIGINT");
|
await triggerSignal("SIGINT");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// FNXC:DaemonSignalExit 2026-07-10-14:00: a memory-pressure SIGTERM must exit
|
||||||
|
// non-zero (128+signal) so a `Restart=on-failure` supervisor restarts the
|
||||||
|
// daemon instead of treating the kill as a clean stop. Regression for the
|
||||||
|
// "daemon exits clean under memory pressure and isn't restarted" report.
|
||||||
|
it("exits 143 on SIGTERM-initiated shutdown", async () => {
|
||||||
|
await runDaemon({});
|
||||||
|
await triggerSignal("SIGTERM");
|
||||||
|
expect(process.exit).toHaveBeenCalledWith(143);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exits 130 on SIGINT-initiated shutdown", async () => {
|
||||||
|
await runDaemon({});
|
||||||
|
await triggerSignal("SIGINT");
|
||||||
|
expect(process.exit).toHaveBeenCalledWith(130);
|
||||||
|
});
|
||||||
|
|
||||||
it("auto-loads installed plugins during startup", async () => {
|
it("auto-loads installed plugins during startup", async () => {
|
||||||
const { PluginLoader } = await import("@fusion/core");
|
const { PluginLoader } = await import("@fusion/core");
|
||||||
|
|
||||||
|
|||||||
@@ -962,7 +962,20 @@ export async function runDaemon(opts: DaemonOptions = {}) {
|
|||||||
|
|
||||||
let shuttingDown = false;
|
let shuttingDown = false;
|
||||||
|
|
||||||
const shutdown = async () => {
|
/*
|
||||||
|
FNXC:DaemonSignalExit 2026-07-10-14:00:
|
||||||
|
When the host terminates the daemon under memory pressure it sends SIGTERM, which
|
||||||
|
this handler turns into a graceful shutdown. Exiting 0 on a signal made a
|
||||||
|
memory-pressure kill indistinguishable from a clean operator stop, so a
|
||||||
|
`Restart=on-failure` systemd unit treated it as success and left the daemon dead.
|
||||||
|
Exit with the POSIX 128+signal convention (SIGINT=130, SIGTERM=143) so
|
||||||
|
`Restart=on-failure` restarts an externally-killed daemon; a deliberate
|
||||||
|
`systemctl stop` still won't restart (systemd honors the requested inactive
|
||||||
|
state regardless of exit code). A non-signal shutdown() caller still exits 0.
|
||||||
|
*/
|
||||||
|
const SIGNAL_EXIT_CODES: Record<string, number> = { SIGINT: 130, SIGTERM: 143 };
|
||||||
|
|
||||||
|
const shutdown = async (signal?: NodeJS.Signals) => {
|
||||||
if (shuttingDown) return;
|
if (shuttingDown) return;
|
||||||
shuttingDown = true;
|
shuttingDown = true;
|
||||||
|
|
||||||
@@ -1006,14 +1019,14 @@ export async function runDaemon(opts: DaemonOptions = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
store.close();
|
store.close();
|
||||||
process.exit(0);
|
process.exit(signal ? (SIGNAL_EXIT_CODES[signal] ?? 128) : 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
process.on("SIGINT", () => {
|
process.on("SIGINT", () => {
|
||||||
void shutdown();
|
void shutdown("SIGINT");
|
||||||
});
|
});
|
||||||
process.on("SIGTERM", () => {
|
process.on("SIGTERM", () => {
|
||||||
void shutdown();
|
void shutdown("SIGTERM");
|
||||||
});
|
});
|
||||||
|
|
||||||
// Ignore SIGHUP so the daemon survives SSH session disconnects
|
// Ignore SIGHUP so the daemon survives SSH session disconnects
|
||||||
|
|||||||
@@ -1080,7 +1080,17 @@ export async function runServe(
|
|||||||
|
|
||||||
let shuttingDown = false;
|
let shuttingDown = false;
|
||||||
|
|
||||||
const shutdown = async () => {
|
/*
|
||||||
|
FNXC:DaemonSignalExit 2026-07-10-14:00:
|
||||||
|
Same invariant as `fn daemon`: a memory-pressure SIGTERM must exit non-zero so a
|
||||||
|
`Restart=on-failure` supervisor restarts the server rather than treating the kill
|
||||||
|
as a clean stop. Exit 128+signal (SIGINT=130, SIGTERM=143); a non-signal caller
|
||||||
|
still exits 0. A deliberate `systemctl stop` won't restart regardless (systemd
|
||||||
|
honors the requested inactive state).
|
||||||
|
*/
|
||||||
|
const SIGNAL_EXIT_CODES: Record<string, number> = { SIGINT: 130, SIGTERM: 143 };
|
||||||
|
|
||||||
|
const shutdown = async (signal?: NodeJS.Signals) => {
|
||||||
if (shuttingDown) return;
|
if (shuttingDown) return;
|
||||||
shuttingDown = true;
|
shuttingDown = true;
|
||||||
|
|
||||||
@@ -1151,14 +1161,14 @@ export async function runServe(
|
|||||||
|
|
||||||
stopDiagnosticInterval();
|
stopDiagnosticInterval();
|
||||||
store.close();
|
store.close();
|
||||||
process.exit(0);
|
process.exit(signal ? (SIGNAL_EXIT_CODES[signal] ?? 128) : 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
process.on("SIGINT", () => {
|
process.on("SIGINT", () => {
|
||||||
void shutdown();
|
void shutdown("SIGINT");
|
||||||
});
|
});
|
||||||
process.on("SIGTERM", () => {
|
process.on("SIGTERM", () => {
|
||||||
void shutdown();
|
void shutdown("SIGTERM");
|
||||||
});
|
});
|
||||||
|
|
||||||
// Ignore SIGHUP so the server survives SSH session disconnects.
|
// Ignore SIGHUP so the server survives SSH session disconnects.
|
||||||
|
|||||||
@@ -227,6 +227,39 @@ describe("update-check", () => {
|
|||||||
expect(execFake).toHaveBeenCalledTimes(1);
|
expect(execFake).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// FNXC:UpdateInstallPermissions 2026-07-10-14:00: a root-owned global dir
|
||||||
|
// (from `sudo npm i -g`) makes the non-root in-app updater fail with EACCES/
|
||||||
|
// EPERM. It must surface actionable remediation, not raw npm stderr, and must
|
||||||
|
// NOT retry with --force (which cannot grant write permission).
|
||||||
|
it("performUpdateInstall returns actionable guidance on an EACCES permission failure", async () => {
|
||||||
|
const execFake = vi.fn().mockRejectedValue(
|
||||||
|
Object.assign(new Error("EACCES"), {
|
||||||
|
code: "EACCES",
|
||||||
|
stderr: "npm error EACCES: permission denied, rename '/usr/lib/node_modules/@runfusion/fusion'",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await performUpdateInstall("1.0.0", "2.0.0", { exec: execFake, fusionDir });
|
||||||
|
expect(result.updated).toBe(false);
|
||||||
|
expect(result.error).toMatch(/EACCES\/EPERM|not writable|sudo/i);
|
||||||
|
// Must not fall through to raw npm stderr, and must not retry with --force.
|
||||||
|
expect(result.error).not.toContain("rename '/usr/lib/node_modules");
|
||||||
|
expect(execFake).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("performUpdateInstall detects EPERM from stderr text without an error code", async () => {
|
||||||
|
const execFake = vi.fn().mockRejectedValue(
|
||||||
|
Object.assign(new Error("install failed"), {
|
||||||
|
stderr: "npm error code EPERM\nnpm error operation not permitted",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await performUpdateInstall("1.0.0", "2.0.0", { exec: execFake, fusionDir });
|
||||||
|
expect(result.updated).toBe(false);
|
||||||
|
expect(result.error).toMatch(/not writable|sudo/i);
|
||||||
|
expect(execFake).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
describe("frequency", () => {
|
describe("frequency", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
__resetStartupRefreshFlag();
|
__resetStartupRefreshFlag();
|
||||||
|
|||||||
@@ -9,6 +9,15 @@ export interface ApiErrorResponse {
|
|||||||
export interface SendErrorOptions {
|
export interface SendErrorOptions {
|
||||||
details?: Record<string, unknown>;
|
details?: Record<string, unknown>;
|
||||||
logger?: RuntimeLogger;
|
logger?: RuntimeLogger;
|
||||||
|
/*
|
||||||
|
FNXC:ApiErrorDiagnostics 2026-07-10-14:00:
|
||||||
|
The original thrown error behind a 5xx. When present, its stack (and any `cause`
|
||||||
|
chain) is logged so server-side 500s are root-causable. Previously only the error
|
||||||
|
*message* was logged and `rethrowAsApiError` discarded the stack, leaving the
|
||||||
|
full-TaskDetail 500s on /api/tasks/:id (GET/DELETE/PATCH/retry/archive/reset)
|
||||||
|
untraceable across releases.
|
||||||
|
*/
|
||||||
|
error?: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
@@ -16,12 +25,19 @@ export class ApiError extends Error {
|
|||||||
public readonly details?: Record<string, unknown>;
|
public readonly details?: Record<string, unknown>;
|
||||||
public readonly isOperational: boolean;
|
public readonly isOperational: boolean;
|
||||||
|
|
||||||
constructor(statusCode: number, message: string, details?: Record<string, unknown>) {
|
constructor(statusCode: number, message: string, details?: Record<string, unknown>, cause?: unknown) {
|
||||||
super(message);
|
super(message);
|
||||||
this.name = "ApiError";
|
this.name = "ApiError";
|
||||||
this.statusCode = statusCode;
|
this.statusCode = statusCode;
|
||||||
this.details = details;
|
this.details = details;
|
||||||
this.isOperational = true;
|
this.isOperational = true;
|
||||||
|
// FNXC:ApiErrorDiagnostics 2026-07-10-14:00: preserve the wrapped error's
|
||||||
|
// stack/chain via Error `cause` so the boundary can log where the 500 came
|
||||||
|
// from (assigned directly rather than via super(message,{cause}) to stay
|
||||||
|
// independent of the compiled lib target).
|
||||||
|
if (cause !== undefined) {
|
||||||
|
(this as { cause?: unknown }).cause = cause;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,11 +50,17 @@ export function sendErrorResponse(
|
|||||||
if (statusCode >= 500) {
|
if (statusCode >= 500) {
|
||||||
const request = res.req;
|
const request = res.req;
|
||||||
const logger = options?.logger ?? createRuntimeLogger("api:error");
|
const logger = options?.logger ?? createRuntimeLogger("api:error");
|
||||||
|
// FNXC:ApiErrorDiagnostics 2026-07-10-14:00: log the underlying stack and
|
||||||
|
// cause (not just the message) so a 500 can be traced to its origin.
|
||||||
|
const originalError = options?.error;
|
||||||
|
const cause = originalError instanceof Error ? (originalError as { cause?: unknown }).cause : undefined;
|
||||||
logger.error("Request failed", {
|
logger.error("Request failed", {
|
||||||
method: request?.method,
|
method: request?.method,
|
||||||
path: request?.originalUrl ?? request?.path,
|
path: request?.originalUrl ?? request?.path,
|
||||||
statusCode,
|
statusCode,
|
||||||
message,
|
message,
|
||||||
|
stack: originalError instanceof Error ? originalError.stack : undefined,
|
||||||
|
cause: cause instanceof Error ? (cause.stack ?? cause.message) : cause !== undefined ? String(cause) : undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,16 +85,16 @@ export function catchHandler(fn: AsyncHandler): RequestHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (error instanceof ApiError) {
|
if (error instanceof ApiError) {
|
||||||
sendErrorResponse(res, error.statusCode, error.message, { details: error.details });
|
sendErrorResponse(res, error.statusCode, error.message, { details: error.details, error });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
sendErrorResponse(res, 500, error.message);
|
sendErrorResponse(res, 500, error.message, { error });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
sendErrorResponse(res, 500, "Internal server error");
|
sendErrorResponse(res, 500, "Internal server error", { error });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1930,13 +1930,22 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
|
|||||||
// API Error Handling Middleware - MUST be after API routes but before SPA fallback
|
// API Error Handling Middleware - MUST be after API routes but before SPA fallback
|
||||||
// This ensures API errors return JSON instead of falling through to the SPA fallback (which returns HTML)
|
// This ensures API errors return JSON instead of falling through to the SPA fallback (which returns HTML)
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:ApiErrorDiagnostics 2026-07-10-14:00:
|
||||||
|
The /api error boundary is the chokepoint for every unhandled per-request error.
|
||||||
|
It must LOG the underlying error (stack + cause), not just echo a message, so a
|
||||||
|
500 is root-causable server-side — the reported "task write API returns 500 for
|
||||||
|
every task" was undiagnosable because the wrapped error's origin was never
|
||||||
|
recorded. The client-facing body stays generic in production (avoid leaking
|
||||||
|
internals); pass `error: err` so sendErrorResponse logs the stack/cause.
|
||||||
|
*/
|
||||||
app.use("/api", (err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
app.use("/api", (err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
||||||
if (res.headersSent) {
|
if (res.headersSent) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (err instanceof ApiError) {
|
if (err instanceof ApiError) {
|
||||||
sendErrorResponse(res, err.statusCode, err.message, { details: err.details });
|
sendErrorResponse(res, err.statusCode, err.message, { details: err.details, error: err });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1948,7 +1957,7 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
|
|||||||
? err.message
|
? err.message
|
||||||
: fallbackMessage;
|
: fallbackMessage;
|
||||||
|
|
||||||
sendErrorResponse(res, 500, message);
|
sendErrorResponse(res, 500, message, { error: err });
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!isHeadless) {
|
if (!isHeadless) {
|
||||||
|
|||||||
@@ -105,6 +105,57 @@ function getInstallErrorMessage(error: unknown): string {
|
|||||||
return error instanceof Error ? error.message : String(error);
|
return error instanceof Error ? error.message : String(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:UpdateInstallPermissions 2026-07-10-14:00:
|
||||||
|
The in-app "Update now" button runs `npm install -g @runfusion/fusion@latest` as the
|
||||||
|
(typically non-root) dashboard process. When Fusion was installed via `sudo npm i -g`,
|
||||||
|
the global package dir is root-owned, so npm's rename() fails with EACCES/EPERM and the
|
||||||
|
button ALWAYS fails. Previously the raw npm stderr was surfaced with no explanation.
|
||||||
|
Detect the permission class and return an actionable remediation instead of a cryptic
|
||||||
|
EACCES, mirroring the CLI's Homebrew-path awareness. (`--force` cannot grant write
|
||||||
|
permission, so we do not retry it for this class — unlike bin-collision errors.)
|
||||||
|
*/
|
||||||
|
function isPermissionInstallError(error: unknown): boolean {
|
||||||
|
const installError = error as InstallError & { code?: string };
|
||||||
|
if (installError?.code === "EACCES" || installError?.code === "EPERM") return true;
|
||||||
|
const message = [installError?.message, installError?.stderr, installError?.stdout]
|
||||||
|
.filter((part): part is string => typeof part === "string" && part.length > 0)
|
||||||
|
.join("\n");
|
||||||
|
return /\bEACCES\b|\bEPERM\b|permission denied|operation not permitted/i.test(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Best-effort path of the running Fusion binary, used to tailor remediation. */
|
||||||
|
function detectRunningBinaryPath(): string | null {
|
||||||
|
const argvPath = process.argv[1];
|
||||||
|
if (typeof argvPath === "string" && argvPath.length > 0) return argvPath;
|
||||||
|
return typeof process.execPath === "string" ? process.execPath : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isHomebrewInstall(binaryPath: string | null): boolean {
|
||||||
|
if (!binaryPath) return false;
|
||||||
|
return (
|
||||||
|
binaryPath.startsWith("/opt/homebrew/") ||
|
||||||
|
binaryPath.startsWith("/usr/local/Homebrew/") ||
|
||||||
|
binaryPath.startsWith("/home/linuxbrew/")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPermissionRemediationMessage(binaryPath: string | null): string {
|
||||||
|
if (isHomebrewInstall(binaryPath)) {
|
||||||
|
return (
|
||||||
|
"Update failed: this Fusion install is managed by Homebrew and cannot be updated with npm. " +
|
||||||
|
"Update it from a terminal with: brew upgrade fusion"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
"Update failed: the global npm directory is not writable by the Fusion process (EACCES/EPERM). " +
|
||||||
|
"This happens when Fusion was installed with `sudo npm i -g`, leaving a root-owned package directory " +
|
||||||
|
"that the dashboard (running as a normal user) cannot replace. Update from a terminal instead:\n" +
|
||||||
|
" • sudo fn update (or: sudo npm i -g @runfusion/fusion@latest)\n" +
|
||||||
|
" • or reinstall without sudo so the global directory is user-owned"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function getInstallOptions(): { timeout: number; maxBuffer: number } {
|
function getInstallOptions(): { timeout: number; maxBuffer: number } {
|
||||||
return {
|
return {
|
||||||
timeout: INSTALL_TIMEOUT_MS,
|
timeout: INSTALL_TIMEOUT_MS,
|
||||||
@@ -169,6 +220,18 @@ export async function performUpdateInstall(
|
|||||||
updated: true,
|
updated: true,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// FNXC:UpdateInstallPermissions 2026-07-10-14:00: a root-owned global dir
|
||||||
|
// (from `sudo npm i -g`) yields EACCES/EPERM the non-root updater cannot
|
||||||
|
// recover from — return actionable guidance rather than raw npm stderr.
|
||||||
|
if (isPermissionInstallError(error)) {
|
||||||
|
return {
|
||||||
|
currentVersion,
|
||||||
|
latestVersion,
|
||||||
|
updated: false,
|
||||||
|
error: getPermissionRemediationMessage(detectRunningBinaryPath()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (!isBinCollisionInstallError(error)) {
|
if (!isBinCollisionInstallError(error)) {
|
||||||
return {
|
return {
|
||||||
currentVersion,
|
currentVersion,
|
||||||
|
|||||||
Reference in New Issue
Block a user