From 7ca71abe7eb328f77fd7c3e72a3c840f63863cea Mon Sep 17 00:00:00 2001 From: Andrew Date: Wed, 17 Jun 2026 12:47:49 -0400 Subject: [PATCH] fix(desktop): stage ffi-rs native bindings in desktop builds The packaged backend crash-loops on startup with "Cannot find module '@yuuang/ffi-rs-win32-x64-msvc'". ffi-rs (used by @ff-labs/fff-node) loads its prebuilt native addon from a platform-specific @yuuang/ffi-rs-* package, which was never staged into the desktop build. Because the desktop window is gated on backend HTTP readiness, the backend never becomes ready and no window ever opens. Mirror the existing @ff-labs/fff-bin-* staging: promote the target-triple @yuuang/ffi-rs-* binding to a direct staged dependency (version resolved from pnpm-lock.yaml, where ffi-rs is pinned) and add it to asarUnpack so the .node loads outside the asar archive. Refs #3125 --- scripts/build-desktop-artifact.test.ts | 53 ++++++++++++- scripts/build-desktop-artifact.ts | 105 +++++++++++++++++++++++-- 2 files changed, 150 insertions(+), 8 deletions(-) diff --git a/scripts/build-desktop-artifact.test.ts b/scripts/build-desktop-artifact.test.ts index 8135f7e259d..67640a7e3d7 100644 --- a/scripts/build-desktop-artifact.test.ts +++ b/scripts/build-desktop-artifact.test.ts @@ -9,7 +9,9 @@ import { createStageWorkspaceConfig, createStagePnpmConfig, DESKTOP_ASAR_UNPACK, + parseFfiRsLockfileVersion, resolveDesktopRuntimeDependencies, + resolveFfiRsNativeDependencies, resolveFffNativeDependencies, resolveBuildOptions, resolveDesktopBuildIconAssets, @@ -171,8 +173,55 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { }); }); - it("unpacks the fff shared library for filesystem and FFI access", () => { - assert.deepStrictEqual(DESKTOP_ASAR_UNPACK, ["node_modules/@ff-labs/fff-bin-*/**/*"]); + it("unpacks the fff shared library and ffi-rs native bindings for FFI access", () => { + assert.deepStrictEqual(DESKTOP_ASAR_UNPACK, [ + "node_modules/@ff-labs/fff-bin-*/**/*", + "node_modules/@yuuang/ffi-rs-*/**/*", + ]); + }); + + it("promotes the target ffi-rs native binding to a direct staged dependency", () => { + assert.deepStrictEqual(resolveFfiRsNativeDependencies("mac", "arm64", "1.3.2"), { + "@yuuang/ffi-rs-darwin-arm64": "1.3.2", + }); + assert.deepStrictEqual(resolveFfiRsNativeDependencies("mac", "universal", "1.3.2"), { + "@yuuang/ffi-rs-darwin-arm64": "1.3.2", + "@yuuang/ffi-rs-darwin-x64": "1.3.2", + }); + assert.deepStrictEqual(resolveFfiRsNativeDependencies("win", "x64", "1.3.2"), { + "@yuuang/ffi-rs-win32-x64-msvc": "1.3.2", + }); + assert.deepStrictEqual(resolveFfiRsNativeDependencies("win", "arm64", "1.3.2"), { + "@yuuang/ffi-rs-win32-arm64-msvc": "1.3.2", + }); + assert.deepStrictEqual(resolveFfiRsNativeDependencies("linux", "arm64", "1.3.2"), { + "@yuuang/ffi-rs-linux-arm64-gnu": "1.3.2", + "@yuuang/ffi-rs-linux-arm64-musl": "1.3.2", + }); + }); + + it("reads the single resolved ffi-rs version from the lockfile", () => { + const lockfile = [ + "packages:", + " fetch-nodeshim@0.4.10: {}", + " ffi-rs@1.3.2:", + " resolution: {integrity: sha512-deadbeef==}", + " fill-range@7.1.1:", + "snapshots:", + " ffi-rs@1.3.2:", + " optionalDependencies:", + " '@yuuang/ffi-rs-win32-x64-msvc': 1.3.2", + "", + ].join("\n"); + assert.equal(parseFfiRsLockfileVersion(lockfile), "1.3.2"); + }); + + it("fails when ffi-rs is absent or resolves to multiple versions", () => { + assert.throws(() => parseFfiRsLockfileVersion("packages:\n effect@4.0.0: {}\n"), /Could not find ffi-rs/); + assert.throws( + () => parseFfiRsLockfileVersion("packages:\n ffi-rs@1.3.2:\n ffi-rs@1.4.0:\n"), + /single ffi-rs version/, + ); }); it("promotes target fff binaries to direct staged dependencies", () => { diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index 6b519b1d4e3..44e126c152a 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -291,7 +291,13 @@ interface StagePackageJson { } export const STAGE_INSTALL_ARGS = ["install", "--prod"] as const; -export const DESKTOP_ASAR_UNPACK = ["node_modules/@ff-labs/fff-bin-*/**/*"] as const; +export const DESKTOP_ASAR_UNPACK = [ + "node_modules/@ff-labs/fff-bin-*/**/*", + // ffi-rs (used by @ff-labs/fff-node) loads its prebuilt native addon from a + // platform-specific @yuuang/ffi-rs-* package. The `.node` must live outside + // the asar archive so Electron can dlopen it at runtime. + "node_modules/@yuuang/ffi-rs-*/**/*", +] as const; export function resolveFffNativeDependencies( platform: typeof BuildPlatform.Type, @@ -319,6 +325,88 @@ export function resolveFffNativeDependencies( ); } +export function resolveFfiRsNativeDependencies( + platform: typeof BuildPlatform.Type, + arch: typeof BuildArch.Type, + version: string, +): Record { + const architectures = arch === "universal" ? (["arm64", "x64"] as const) : [arch]; + + if (platform === "mac") { + return Object.fromEntries( + architectures.map((architecture) => [`@yuuang/ffi-rs-darwin-${architecture}`, version]), + ); + } + + if (platform === "win") { + return Object.fromEntries( + architectures.map((architecture) => [`@yuuang/ffi-rs-win32-${architecture}-msvc`, version]), + ); + } + + return Object.fromEntries( + architectures.flatMap((architecture) => + ["gnu", "musl"].map((libc) => [`@yuuang/ffi-rs-linux-${architecture}-${libc}`, version]), + ), + ); +} + +// ffi-rs is a transitive dependency (via @ff-labs/fff-node), so its version is +// not declared in any package.json. Its prebuilt @yuuang/ffi-rs-* bindings are +// published in lockstep with ffi-rs, so we read the resolved version from the +// lockfile to pin the binding we promote into the staged install. +export function parseFfiRsLockfileVersion(lockfileContents: string): string { + const versions = new Set(); + const pattern = /(?:^|\n) {2}ffi-rs@([^:\s(]+):/g; + for (const match of lockfileContents.matchAll(pattern)) { + const version = match[1]; + if (version !== undefined) { + versions.add(version); + } + } + + const uniqueVersions = [...versions]; + if (uniqueVersions.length > 1) { + throw new Error( + `Expected a single ffi-rs version in pnpm-lock.yaml but found: ${uniqueVersions.sort().join(", ")}.`, + ); + } + + const [version] = uniqueVersions; + if (version === undefined) { + throw new Error("Could not find ffi-rs in pnpm-lock.yaml."); + } + + return version; +} + +const resolveFfiRsBindingVersion = Effect.fn("resolveFfiRsBindingVersion")(function* ( + repoRoot: string, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const lockfileContents = yield* fs + .readFileString(path.join(repoRoot, "pnpm-lock.yaml")) + .pipe( + Effect.mapError( + (cause) => + new BuildScriptError({ + message: "Could not read pnpm-lock.yaml to resolve ffi-rs native bindings.", + cause, + }), + ), + ); + + return yield* Effect.try({ + try: () => parseFfiRsLockfileVersion(lockfileContents), + catch: (cause) => + new BuildScriptError({ + message: "Could not resolve the ffi-rs native binding version from pnpm-lock.yaml.", + cause, + }), + }); +}); + export function createStageWorkspaceConfig( platform: typeof BuildPlatform.Type, arch: typeof BuildArch.Type, @@ -956,14 +1044,19 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( // electron-builder is filtering out stageResourcesDir directory in the AppImage for production yield* fs.copy(stageResourcesDir, path.join(stageAppDir, "apps/desktop/prod-resources")); + const fffNodeVersion = serverPackageJson.dependencies["@ff-labs/fff-node"]; + const ffiRsNativeDependencies = fffNodeVersion + ? resolveFfiRsNativeDependencies( + options.platform, + options.arch, + yield* resolveFfiRsBindingVersion(repoRoot), + ) + : {}; const stageDependencies = { ...resolvedServerDependencies, ...resolvedDesktopRuntimeDependencies, - ...resolveFffNativeDependencies( - options.platform, - options.arch, - serverPackageJson.dependencies["@ff-labs/fff-node"], - ), + ...resolveFffNativeDependencies(options.platform, options.arch, fffNodeVersion), + ...ffiRsNativeDependencies, }; const stagePnpmConfig = createStagePnpmConfig(workspacePatchedDependencies, stageDependencies); const stagePackageJson: StagePackageJson = {