From 0de6844d8d0e1feabd0a6b9feb6f9636a9506228 Mon Sep 17 00:00:00 2001 From: Vitalii Mikhailov Date: Mon, 6 Jul 2026 15:31:42 +0300 Subject: [PATCH 1/2] Better diagnostics --- src/main/src/downloading/downloader.ts | 21 ++++++++--- .../views/DownloadView.tsx | 6 ++-- src/shared/src/download-errors.test.ts | 35 +++++++++++++++++++ src/shared/src/types/errors.ts | 2 +- 4 files changed, 56 insertions(+), 8 deletions(-) create mode 100644 src/shared/src/download-errors.test.ts diff --git a/src/main/src/downloading/downloader.ts b/src/main/src/downloading/downloader.ts index 0e1c5c70cd..d8720ace78 100644 --- a/src/main/src/downloading/downloader.ts +++ b/src/main/src/downloading/downloader.ts @@ -142,16 +142,24 @@ export async function download( const fd = await open(dest, flag); handle = { fd, path: dest }; } catch (err) { - throw new DownloadError({ code: "fs-error", path: dest }, `Failed to open ${dest}`, err); + const errno = getErrorCode(err) ?? undefined; + throw new DownloadError( + { code: "fs-error", path: dest, errno }, + errno ? `Failed to open ${dest} (${errno})` : `Failed to open ${dest}`, + err, + ); } if (checkpoint && probe.size) { try { await handle.fd.truncate(probe.size); } catch (err) { + const errno = getErrorCode(err) ?? undefined; throw new DownloadError( - { code: "fs-error", path: handle.path }, - `Failed to truncate ${handle.path}`, + { code: "fs-error", path: handle.path, errno }, + errno + ? `Failed to truncate ${handle.path} (${errno})` + : `Failed to truncate ${handle.path}`, err, ); } @@ -432,9 +440,12 @@ async function downloadStream( if (progress) progress.bytesWritten += result.bytesWritten; writePosition += result.bytesWritten; } catch (err) { + const errno = getErrorCode(err) ?? undefined; throw new DownloadError( - { code: "fs-error", path: handle.path }, - `Failed to write to ${handle.path}`, + { code: "fs-error", path: handle.path, errno }, + errno + ? `Failed to write to ${handle.path} (${errno})` + : `Failed to write to ${handle.path}`, err, ); } diff --git a/src/renderer/src/extensions/download_management/views/DownloadView.tsx b/src/renderer/src/extensions/download_management/views/DownloadView.tsx index e1c3f7649a..f5607126c3 100644 --- a/src/renderer/src/extensions/download_management/views/DownloadView.tsx +++ b/src/renderer/src/extensions/download_management/views/DownloadView.tsx @@ -422,6 +422,8 @@ class DownloadView extends ComponentEx { } const urlInvalid = ["moved permanently", "forbidden", "gone"]; const title = resume ? "Failed to resume download" : "Failed to start download"; + // Fall back to `err.code` for raw Node errors that reach here directly. + const errno: string | undefined = err.payload?.errno ?? err.code; if (err instanceof ProcessCanceled) { this.props.onShowError(title, err, undefined, false); } else if (err instanceof UserCanceled) { @@ -526,9 +528,9 @@ class DownloadView extends ComponentEx { undefined, false, ); - } else if (err.code === "ENOSPC") { + } else if (errno === "ENOSPC") { this.props.onShowError(title, "The disk is full", undefined, false); - } else if (err.code === "EBADF") { + } else if (errno === "EBADF") { this.props.onShowError( title, "Failed to write to disk. If you use a removable media or " + diff --git a/src/shared/src/download-errors.test.ts b/src/shared/src/download-errors.test.ts new file mode 100644 index 0000000000..99b6b42a49 --- /dev/null +++ b/src/shared/src/download-errors.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; + +import { downloadErrorToWire, wireToDownloadError } from "./download-errors"; +import { DownloadError } from "./types/errors"; + +const fsErrorErrno = (err: unknown): string | undefined => + err instanceof DownloadError && err.payload.code === "fs-error" ? err.payload.errno : undefined; + +describe("download-errors wire codec", () => { + it("preserves the fs-error errno across the wire", () => { + const wire = downloadErrorToWire( + new DownloadError( + { code: "fs-error", path: "C:/tmp/__vortex_tmp_0001", errno: "ENOSPC" }, + "Failed to write to C:/tmp/__vortex_tmp_0001 (ENOSPC)", + ), + ); + const wireErrno = wire.payload.code === "fs-error" ? wire.payload.errno : undefined; + expect(wireErrno).toBe("ENOSPC"); + + const rebuilt = wireToDownloadError(wire); + expect(rebuilt).toBeInstanceOf(DownloadError); + expect(fsErrorErrno(rebuilt)).toBe("ENOSPC"); + expect(rebuilt.message).toContain("ENOSPC"); + }); + + it("tolerates an fs-error with no errno", () => { + const rebuilt = wireToDownloadError( + downloadErrorToWire( + new DownloadError({ code: "fs-error", path: "C:/tmp/x" }, "Failed to write to C:/tmp/x"), + ), + ); + expect(rebuilt).toBeInstanceOf(DownloadError); + expect(fsErrorErrno(rebuilt)).toBeUndefined(); + }); +}); diff --git a/src/shared/src/types/errors.ts b/src/shared/src/types/errors.ts index 91e59576cf..198b66029c 100644 --- a/src/shared/src/types/errors.ts +++ b/src/shared/src/types/errors.ts @@ -6,7 +6,7 @@ export type DownloadErrorPayload = | { code: "precondition-failed"; url: URL } | { code: "protocol-violation"; url: URL } | { code: "is-html"; url: URL } - | { code: "fs-error"; path: string } + | { code: "fs-error"; path: string; errno?: string } | { code: "resolver-error" }; export class DownloadError extends Error { From d630dc1f35345a44e58be48b5cabd031731cb268 Mon Sep 17 00:00:00 2001 From: Vitalii Mikhailov Date: Tue, 7 Jul 2026 16:05:57 +0300 Subject: [PATCH 2/2] Created a shared package for contracts we'll need between shared and other packages Moved FS errors into the contract package FS errors now use our semantic error handling --- packages/adaptor-api/package.json | 3 + packages/adaptor-api/src/fs/filesystem.ts | 13 +--- packages/contracts/eslint.config.mjs | 5 ++ packages/contracts/package.json | 33 +++++++++ packages/contracts/src/fs.ts | 18 +++++ packages/contracts/src/lib.ts | 1 + packages/contracts/tsconfig.json | 18 +++++ packages/contracts/tsdown.config.ts | 14 ++++ pnpm-lock.yaml | 9 +++ src/main/src/downloading/downloader.ts | 30 ++++---- src/main/src/filesystem/backend.ts | 63 +---------------- src/main/src/filesystem/parse-node-error.ts | 70 +++++++++++++++++++ .../views/DownloadView.tsx | 14 ++-- src/shared/package.json | 1 + src/shared/src/download-errors.test.ts | 39 ++++++++--- src/shared/src/types/errors.ts | 4 +- src/shared/tsconfig.json | 1 + tsconfig.json | 3 + 18 files changed, 235 insertions(+), 104 deletions(-) create mode 100644 packages/contracts/eslint.config.mjs create mode 100644 packages/contracts/package.json create mode 100644 packages/contracts/src/fs.ts create mode 100644 packages/contracts/src/lib.ts create mode 100644 packages/contracts/tsconfig.json create mode 100644 packages/contracts/tsdown.config.ts create mode 100644 src/main/src/filesystem/parse-node-error.ts diff --git a/packages/adaptor-api/package.json b/packages/adaptor-api/package.json index 2651157ca0..c4c4110623 100644 --- a/packages/adaptor-api/package.json +++ b/packages/adaptor-api/package.json @@ -11,6 +11,9 @@ "lint": "pnpm eslint --quiet --concurrency auto .", "lint:verbose": "pnpm eslint --concurrency auto ." }, + "dependencies": { + "@nexusmods/contracts": "workspace:*" + }, "peerDependencies": { "rolldown": "catalog:" }, diff --git a/packages/adaptor-api/src/fs/filesystem.ts b/packages/adaptor-api/src/fs/filesystem.ts index fd20347411..a3719cc0cf 100644 --- a/packages/adaptor-api/src/fs/filesystem.ts +++ b/packages/adaptor-api/src/fs/filesystem.ts @@ -1,5 +1,7 @@ /// +import type { FileSystemErrorCode } from "@nexusmods/contracts"; + import type { Pattern } from "./matcher"; import type { QualifiedPath, ResolvedPath } from "./paths"; @@ -301,16 +303,7 @@ export type DirectoryStatus = StatusTime & { readonly hardlinkCount: number; }; -/** @public */ -export type FileSystemErrorCode = - | "already exists" - | "directory not empty" - | "no permissions" - | "no space" - | "not a directory" - | "not a file" - | "not found" - | "generic"; +export type { FileSystemErrorCode }; /** @public */ export class FileSystemError extends Error { diff --git a/packages/contracts/eslint.config.mjs b/packages/contracts/eslint.config.mjs new file mode 100644 index 0000000000..07bdcf4032 --- /dev/null +++ b/packages/contracts/eslint.config.mjs @@ -0,0 +1,5 @@ +import { defineConfig } from "eslint/config"; + +import { baseConfig } from "../../eslint.config.base.mjs"; + +export default defineConfig([...baseConfig(import.meta.dirname)]); diff --git a/packages/contracts/package.json b/packages/contracts/package.json new file mode 100644 index 0000000000..ca3d7be965 --- /dev/null +++ b/packages/contracts/package.json @@ -0,0 +1,33 @@ +{ + "$schema": "https://www.schemastore.org/package.json", + "name": "@nexusmods/contracts", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "pnpm tsdown", + "typecheck": "pnpm tsc -p ./tsconfig.json", + "lint": "pnpm eslint --quiet --concurrency auto .", + "lint:verbose": "pnpm eslint --concurrency auto ." + }, + "exports": { + ".": { + "development": "./src/lib.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + }, + "./package.json": "./package.json" + }, + "publishConfig": { + "exports": { + ".": { + "import": "./dist/index.js", + "require": "./dist/index.cjs" + }, + "./package.json": "./package.json" + } + }, + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.cts" +} diff --git a/packages/contracts/src/fs.ts b/packages/contracts/src/fs.ts new file mode 100644 index 0000000000..5a33baf322 --- /dev/null +++ b/packages/contracts/src/fs.ts @@ -0,0 +1,18 @@ +/** + * Semantic filesystem error code. + * + * Single source of truth shared by the filesystem contract in + * `@nexusmods/adaptor-api/fs` (which normalizes raw Node errors into these + * codes). + * + * @public + */ +export type FileSystemErrorCode = + | "already exists" + | "directory not empty" + | "no permissions" + | "no space" + | "not a directory" + | "not a file" + | "not found" + | "generic"; diff --git a/packages/contracts/src/lib.ts b/packages/contracts/src/lib.ts new file mode 100644 index 0000000000..84bac613dc --- /dev/null +++ b/packages/contracts/src/lib.ts @@ -0,0 +1 @@ +export type { FileSystemErrorCode } from "./fs"; diff --git a/packages/contracts/tsconfig.json b/packages/contracts/tsconfig.json new file mode 100644 index 0000000000..41f1f0c91d --- /dev/null +++ b/packages/contracts/tsconfig.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://www.schemastore.org/tsconfig.json", + "extends": "../../tsconfig.strict.json", + "include": ["./src/**/*.ts"], + "compilerOptions": { + "composite": true, + "noEmit": true, + "target": "esnext", + "module": "esnext", + "moduleResolution": "bundler", + "customConditions": ["development"], + "lib": ["ESNext"], + "types": [], + "allowJs": false, + "isolatedDeclarations": true, + "noUncheckedSideEffectImports": true + } +} diff --git a/packages/contracts/tsdown.config.ts b/packages/contracts/tsdown.config.ts new file mode 100644 index 0000000000..1cbb386056 --- /dev/null +++ b/packages/contracts/tsdown.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + entry: { + index: "./src/lib.ts", + }, + format: ["esm", "cjs"], + platform: "neutral", + tsconfig: "./tsconfig.json", + dts: { sourcemap: true }, + exports: { + devExports: "development", + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 06588157f4..b2b830e32c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4033,6 +4033,10 @@ importers: version: 6.0.3 packages/adaptor-api: + dependencies: + '@nexusmods/contracts': + specifier: workspace:* + version: link:../contracts devDependencies: '@types/node': specifier: 'catalog:' @@ -4062,6 +4066,8 @@ importers: specifier: workspace:* version: link:../../adaptor-api + packages/contracts: {} + packages/e2e: devDependencies: '@playwright/test': @@ -5258,6 +5264,9 @@ importers: src/shared: dependencies: + '@nexusmods/contracts': + specifier: workspace:* + version: link:../../packages/contracts '@opentelemetry/api': specifier: 'catalog:' version: 1.9.1 diff --git a/src/main/src/downloading/downloader.ts b/src/main/src/downloading/downloader.ts index d8720ace78..cb4295b735 100644 --- a/src/main/src/downloading/downloader.ts +++ b/src/main/src/downloading/downloader.ts @@ -1,6 +1,7 @@ import { type FileHandle as NodeFileHandle, access, open } from "node:fs/promises"; import type { IncomingHttpHeaders } from "node:http"; +import type { FileSystemErrorCode } from "@nexusmods/adaptor-api/fs"; import { getErrorCode, unknownToError } from "@vortex/shared"; import type { ByteRange, @@ -18,6 +19,7 @@ import type { RateLimiter } from "limiter"; import PQueue from "p-queue"; import type { CookieJar } from "tough-cookie"; +import { parseNodeError } from "../filesystem/parse-node-error"; import { isCancellation, toNetworkError } from "./errors"; import type { ProgressReporter } from "./progress"; import type { NormalizedResource } from "./resolver"; @@ -44,6 +46,12 @@ export type TimeoutOptions = { stall: number; }; +function fsErrorMessage(verb: string, filePath: string, reason: FileSystemErrorCode): string { + return reason === "generic" + ? `Failed to ${verb} ${filePath}` + : `Failed to ${verb} ${filePath}: ${reason}`; +} + /** @internal */ export async function download( resource: T, @@ -142,10 +150,10 @@ export async function download( const fd = await open(dest, flag); handle = { fd, path: dest }; } catch (err) { - const errno = getErrorCode(err) ?? undefined; + const { code: reason, isTransient } = parseNodeError(err); throw new DownloadError( - { code: "fs-error", path: dest, errno }, - errno ? `Failed to open ${dest} (${errno})` : `Failed to open ${dest}`, + { code: "fs-error", path: dest, reason, isTransient }, + fsErrorMessage("open", dest, reason), err, ); } @@ -154,12 +162,10 @@ export async function download( try { await handle.fd.truncate(probe.size); } catch (err) { - const errno = getErrorCode(err) ?? undefined; + const { code: reason, isTransient } = parseNodeError(err); throw new DownloadError( - { code: "fs-error", path: handle.path, errno }, - errno - ? `Failed to truncate ${handle.path} (${errno})` - : `Failed to truncate ${handle.path}`, + { code: "fs-error", path: handle.path, reason, isTransient }, + fsErrorMessage("truncate", handle.path, reason), err, ); } @@ -440,12 +446,10 @@ async function downloadStream( if (progress) progress.bytesWritten += result.bytesWritten; writePosition += result.bytesWritten; } catch (err) { - const errno = getErrorCode(err) ?? undefined; + const { code: reason, isTransient } = parseNodeError(err); throw new DownloadError( - { code: "fs-error", path: handle.path, errno }, - errno - ? `Failed to write to ${handle.path} (${errno})` - : `Failed to write to ${handle.path}`, + { code: "fs-error", path: handle.path, reason, isTransient }, + fsErrorMessage("write to", handle.path, reason), err, ); } diff --git a/src/main/src/filesystem/backend.ts b/src/main/src/filesystem/backend.ts index e24cfe0b72..11a7e7441c 100644 --- a/src/main/src/filesystem/backend.ts +++ b/src/main/src/filesystem/backend.ts @@ -19,7 +19,6 @@ import { Readable, Writable } from "node:stream"; import type { DirectoryStatus, FileStatus, - FileSystemErrorCode, StatResult, Status, StatusTime, @@ -28,67 +27,7 @@ import type { Pattern, ResolvedPath } from "@nexusmods/adaptor-api/fs"; import type { FileSystemBackend as NodeFileSystemBackend } from "@nexusmods/adaptor-api/fs"; import { FileSystemError, matches } from "@nexusmods/adaptor-api/fs"; -interface ParsedNodeError { - code: FileSystemErrorCode; - isTransient: boolean; - originalCode: string; -} - -function parseNodeError(err: unknown): ParsedNodeError { - if (!(err instanceof Error)) { - return { code: "generic", isTransient: false, originalCode: "" }; - } - - // https://nodejs.org/api/errors.html - // NOTE(erri120): Node.js is inconsistent when it comes to data on Errors. - // The error code that we care about is on err.info.code or err.code if err.info doesn't exist - - if (!("code" in err) || typeof err.code !== "string") { - return { code: "generic", isTransient: false, originalCode: "" }; - } - - const originalCode = - "info" in err && - typeof err.info === "object" && - err.info !== null && - "code" in err.info && - typeof err.info.code === "string" - ? err.info.code - : err.code; - - // NOTE(erri120): Node.js uses POSIX error names as codes - // https://www.man7.org/linux/man-pages/man3/errno.3.html - // https://nodejs.org/api/errors.html#common-system-errors - if (originalCode === "EACCES" || originalCode === "EPERM") { - // EACCES: Permission denied (POSIX.1-2001). - // EPERM: Operation not permitted (POSIX.1-2001). - return { code: "no permissions", isTransient: false, originalCode }; - } else if (originalCode === "ENOENT") { - // ENOENT: No such file or directory (POSIX.1-2001). - return { code: "not found", isTransient: false, originalCode }; - } else if (originalCode === "EEXIST") { - // EEXIST: File exists (POSIX.1-2001). - return { code: "already exists", isTransient: false, originalCode }; - } else if (originalCode === "ENOSPC") { - // ENOSPC: No space left on device (POSIX.1-2001) - return { code: "no space", isTransient: false, originalCode }; - } else if (originalCode === "ENOTDIR") { - // ENOTDIR: Not a directory (POSIX.1-2001). - return { code: "not a directory", isTransient: false, originalCode }; - } else if (originalCode === "EISDIR") { - // EISDIR: Is a directory (POSIX.1-2001). - return { code: "not a file", isTransient: false, originalCode }; - } else if (originalCode === "ENOTEMPTY") { - // ENOTEMPTY: Directory not empty (POSIX.1-2001). - return { code: "directory not empty", isTransient: false, originalCode }; - } else if (originalCode === "EMFILE" || originalCode === "EBUSY") { - // EMFILE: Too many open files (POSIX.1-2001). - // EBUSY: Device or resource busy (POSIX.1-2001) - return { code: "generic", isTransient: true, originalCode }; - } else { - return { code: "generic", isTransient: false, originalCode }; - } -} +import { parseNodeError } from "./parse-node-error"; /** * Node-backed implementation of {@link NodeFileSystemBackend}. Operates on diff --git a/src/main/src/filesystem/parse-node-error.ts b/src/main/src/filesystem/parse-node-error.ts new file mode 100644 index 0000000000..a2b67d4ec1 --- /dev/null +++ b/src/main/src/filesystem/parse-node-error.ts @@ -0,0 +1,70 @@ +import type { FileSystemErrorCode } from "@nexusmods/adaptor-api/fs"; + +export interface ParsedNodeError { + code: FileSystemErrorCode; + isTransient: boolean; + originalCode: string; +} + +/** + * Normalizes a raw Node.js error into a semantic {@link FileSystemErrorCode}. + * Node is inconsistent about where it puts the error code, so this mirrors the + * mapping used by the filesystem backend and is the single place download I/O + * (which uses `node:fs` directly rather than the filesystem service) reuses to + * avoid leaking raw errno strings into the rest of the app. + */ +export function parseNodeError(err: unknown): ParsedNodeError { + if (!(err instanceof Error)) { + return { code: "generic", isTransient: false, originalCode: "" }; + } + + // https://nodejs.org/api/errors.html + // NOTE(erri120): Node.js is inconsistent when it comes to data on Errors. + // The error code that we care about is on err.info.code or err.code if err.info doesn't exist + + if (!("code" in err) || typeof err.code !== "string") { + return { code: "generic", isTransient: false, originalCode: "" }; + } + + const originalCode = + "info" in err && + typeof err.info === "object" && + err.info !== null && + "code" in err.info && + typeof err.info.code === "string" + ? err.info.code + : err.code; + + // NOTE(erri120): Node.js uses POSIX error names as codes + // https://www.man7.org/linux/man-pages/man3/errno.3.html + // https://nodejs.org/api/errors.html#common-system-errors + if (originalCode === "EACCES" || originalCode === "EPERM") { + // EACCES: Permission denied (POSIX.1-2001). + // EPERM: Operation not permitted (POSIX.1-2001). + return { code: "no permissions", isTransient: false, originalCode }; + } else if (originalCode === "ENOENT") { + // ENOENT: No such file or directory (POSIX.1-2001). + return { code: "not found", isTransient: false, originalCode }; + } else if (originalCode === "EEXIST") { + // EEXIST: File exists (POSIX.1-2001). + return { code: "already exists", isTransient: false, originalCode }; + } else if (originalCode === "ENOSPC") { + // ENOSPC: No space left on device (POSIX.1-2001) + return { code: "no space", isTransient: false, originalCode }; + } else if (originalCode === "ENOTDIR") { + // ENOTDIR: Not a directory (POSIX.1-2001). + return { code: "not a directory", isTransient: false, originalCode }; + } else if (originalCode === "EISDIR") { + // EISDIR: Is a directory (POSIX.1-2001). + return { code: "not a file", isTransient: false, originalCode }; + } else if (originalCode === "ENOTEMPTY") { + // ENOTEMPTY: Directory not empty (POSIX.1-2001). + return { code: "directory not empty", isTransient: false, originalCode }; + } else if (originalCode === "EMFILE" || originalCode === "EBUSY") { + // EMFILE: Too many open files (POSIX.1-2001). + // EBUSY: Device or resource busy (POSIX.1-2001) + return { code: "generic", isTransient: true, originalCode }; + } else { + return { code: "generic", isTransient: false, originalCode }; + } +} diff --git a/src/renderer/src/extensions/download_management/views/DownloadView.tsx b/src/renderer/src/extensions/download_management/views/DownloadView.tsx index f5607126c3..306037af6a 100644 --- a/src/renderer/src/extensions/download_management/views/DownloadView.tsx +++ b/src/renderer/src/extensions/download_management/views/DownloadView.tsx @@ -422,8 +422,8 @@ class DownloadView extends ComponentEx { } const urlInvalid = ["moved permanently", "forbidden", "gone"]; const title = resume ? "Failed to resume download" : "Failed to start download"; - // Fall back to `err.code` for raw Node errors that reach here directly. - const errno: string | undefined = err.payload?.errno ?? err.code; + // Semantic filesystem error, normalized in the main process from the raw Node error. + const fsReason: string | undefined = err.payload?.reason; if (err instanceof ProcessCanceled) { this.props.onShowError(title, err, undefined, false); } else if (err instanceof UserCanceled) { @@ -528,14 +528,14 @@ class DownloadView extends ComponentEx { undefined, false, ); - } else if (errno === "ENOSPC") { + } else if (fsReason === "no space") { this.props.onShowError(title, "The disk is full", undefined, false); - } else if (errno === "EBADF") { + } else if (fsReason === "no permissions") { this.props.onShowError( title, - "Failed to write to disk. If you use a removable media or " + - "a network drive, the connection may be unstable. " + - "Please try resuming once you checked.", + "Vortex doesn't have permission to write the download to disk. " + + "Check that the download folder isn't read-only and that another " + + "program (such as antivirus) isn't locking the file, then try again.", undefined, false, ); diff --git a/src/shared/package.json b/src/shared/package.json index 30206ab977..d3243873db 100644 --- a/src/shared/package.json +++ b/src/shared/package.json @@ -63,6 +63,7 @@ "./package.json": "./package.json" }, "dependencies": { + "@nexusmods/contracts": "workspace:*", "@opentelemetry/api": "catalog:", "@opentelemetry/resources": "catalog:", "@opentelemetry/sdk-trace-base": "catalog:", diff --git a/src/shared/src/download-errors.test.ts b/src/shared/src/download-errors.test.ts index 99b6b42a49..12b6c96cbd 100644 --- a/src/shared/src/download-errors.test.ts +++ b/src/shared/src/download-errors.test.ts @@ -1,35 +1,52 @@ +import type { FileSystemErrorCode } from "@nexusmods/contracts"; import { describe, expect, it } from "vitest"; import { downloadErrorToWire, wireToDownloadError } from "./download-errors"; import { DownloadError } from "./types/errors"; -const fsErrorErrno = (err: unknown): string | undefined => - err instanceof DownloadError && err.payload.code === "fs-error" ? err.payload.errno : undefined; +const fsErrorReason = (err: unknown): FileSystemErrorCode | undefined => + err instanceof DownloadError && err.payload.code === "fs-error" ? err.payload.reason : undefined; describe("download-errors wire codec", () => { - it("preserves the fs-error errno across the wire", () => { + it("preserves the fs-error reason across the wire", () => { const wire = downloadErrorToWire( new DownloadError( - { code: "fs-error", path: "C:/tmp/__vortex_tmp_0001", errno: "ENOSPC" }, - "Failed to write to C:/tmp/__vortex_tmp_0001 (ENOSPC)", + { + code: "fs-error", + path: "C:/tmp/__vortex_tmp_0001", + reason: "no space", + isTransient: false, + }, + "Failed to write to C:/tmp/__vortex_tmp_0001: no space", ), ); - const wireErrno = wire.payload.code === "fs-error" ? wire.payload.errno : undefined; - expect(wireErrno).toBe("ENOSPC"); + const wireReason = wire.payload.code === "fs-error" ? wire.payload.reason : undefined; + expect(wireReason).toBe("no space"); const rebuilt = wireToDownloadError(wire); expect(rebuilt).toBeInstanceOf(DownloadError); - expect(fsErrorErrno(rebuilt)).toBe("ENOSPC"); - expect(rebuilt.message).toContain("ENOSPC"); + expect(fsErrorReason(rebuilt)).toBe("no space"); + expect(rebuilt.message).toContain("no space"); }); - it("tolerates an fs-error with no errno", () => { + it("preserves the transient flag across the wire", () => { + const wire = downloadErrorToWire( + new DownloadError( + { code: "fs-error", path: "C:/tmp/x", reason: "generic", isTransient: true }, + "Failed to write to C:/tmp/x", + ), + ); + const isTransient = wire.payload.code === "fs-error" ? wire.payload.isTransient : undefined; + expect(isTransient).toBe(true); + }); + + it("tolerates an fs-error with no reason", () => { const rebuilt = wireToDownloadError( downloadErrorToWire( new DownloadError({ code: "fs-error", path: "C:/tmp/x" }, "Failed to write to C:/tmp/x"), ), ); expect(rebuilt).toBeInstanceOf(DownloadError); - expect(fsErrorErrno(rebuilt)).toBeUndefined(); + expect(fsErrorReason(rebuilt)).toBeUndefined(); }); }); diff --git a/src/shared/src/types/errors.ts b/src/shared/src/types/errors.ts index 198b66029c..56b4fb7691 100644 --- a/src/shared/src/types/errors.ts +++ b/src/shared/src/types/errors.ts @@ -1,3 +1,5 @@ +import type { FileSystemErrorCode } from "@nexusmods/contracts"; + export type DownloadErrorPayload = | { code: "cancellation" } | { code: "network-error"; url: URL } @@ -6,7 +8,7 @@ export type DownloadErrorPayload = | { code: "precondition-failed"; url: URL } | { code: "protocol-violation"; url: URL } | { code: "is-html"; url: URL } - | { code: "fs-error"; path: string; errno?: string } + | { code: "fs-error"; path: string; reason?: FileSystemErrorCode; isTransient?: boolean } | { code: "resolver-error" }; export class DownloadError extends Error { diff --git a/src/shared/tsconfig.json b/src/shared/tsconfig.json index 380a456541..32175ed311 100644 --- a/src/shared/tsconfig.json +++ b/src/shared/tsconfig.json @@ -12,6 +12,7 @@ "target": "esnext", "module": "esnext", "moduleResolution": "bundler", + "customConditions": ["development"], "lib": ["ESNext"], "types": [], "allowJs": false, diff --git a/tsconfig.json b/tsconfig.json index b09f71b91a..9b3c669d77 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,6 +2,9 @@ "$schema": "https://www.schemastore.org/tsconfig.json", "files": [], "references": [ + { + "path": "./packages/contracts/tsconfig.json" + }, { "path": "./packages/adaptor-api/tsconfig.json" },