Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 111 additions & 0 deletions apps/desktop/scripts/main-process-bundle.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -171,3 +171,114 @@ it("loads the emitted packaged boot entry and backend cache preload", async () =
await NodeFSP.rm(directory, { recursive: true, force: true });
}
});

it("under AppImage, reuses the shipped compile cache from a different mount path", async () => {
const directory = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-desktop-appimage-"));
try {
const entries = ["src/boot.ts", "src/compileCache.ts"];
const source = NodePath.join(directory, "source");
await NodeFSP.mkdir(NodePath.join(source, "src"), { recursive: true });
await Promise.all(
entries.map((entry) =>
NodeFSP.copyFile(new URL(`../${entry}`, import.meta.url), NodePath.join(source, entry)),
),
);
assert.ok(Array.isArray(desktopConfig.pack));
for (const packConfig of desktopConfig.pack) {
if (!Array.isArray(packConfig.entry)) continue;
if (!packConfig.entry.some((entry) => entries.includes(entry))) continue;
await build({
...packConfig,
config: false,
cwd: source,
tsconfig: false,
sourcemap: false,
onSuccess: undefined,
logLevel: "silent",
});
}
// Two launches of one AppImage see the same files under different mount
// paths. The shipped cache sits beside app.asar, outside it.
const mounts = ["mount-first", "mount-second"].map((name) => NodePath.join(directory, name));
const cacheDirs = [];
for (const mount of mounts) {
const distElectron = NodePath.join(mount, "resources/app.asar/apps/desktop/dist-electron");
await NodeFSP.mkdir(distElectron, { recursive: true });
await NodeFSP.cp(NodePath.join(source, "dist-electron"), distElectron, { recursive: true });
await NodeFSP.writeFile(
NodePath.join(distElectron, "main.cjs"),
"console.log(require('node:module').getCompileCacheDir() ?? 'uncached');",
);
cacheDirs.push(NodePath.join(mount, "resources/compile-cache"));
}
const launch = (mount) =>
NodeChildProcess.spawnSync(
process.execPath,
[NodePath.join(mount, "resources/app.asar/apps/desktop/dist-electron/boot.cjs")],
{
encoding: "utf8",
env: {
...process.env,
APPIMAGE: NodePath.join(mount, "t3code"),
NODE_COMPILE_CACHE: undefined,
NODE_DISABLE_COMPILE_CACHE: undefined,
// A miss on a read-only cache looks like a hit from the outside;
// Node's own log is what tells them apart.
NODE_DEBUG_NATIVE: "COMPILE_CACHE",
XDG_CACHE_HOME: directory,
},
},
);
const listEntries = async (cacheDir) =>
(await NodeFSP.readdir(cacheDir, { recursive: true, withFileTypes: true }))
.filter((entry) => entry.isFile())
.map((entry) => NodePath.relative(cacheDir, NodePath.join(entry.parentPath, entry.name)))
.sort();

// The build warms the cache at one path; a launch anywhere else must hit it.
await NodeFSP.mkdir(cacheDirs[0], { recursive: true });
const warm = launch(mounts[0]);
assert.equal(warm.status, 0, warm.stderr);
assert.ok(warm.stdout.trim().startsWith(cacheDirs[0]), warm.stdout);
const warmed = await listEntries(cacheDirs[0]);
assert.ok(warmed.length > 0);

// The image is read-only, so the second launch must hit without writing.
await NodeFSP.cp(cacheDirs[0], cacheDirs[1], { recursive: true });
const statsBefore = await Promise.all(
warmed.map((entry) => NodeFSP.stat(NodePath.join(cacheDirs[1], entry))),
);
const readOnly = process.getuid?.() !== 0;
if (readOnly) await chmodTree(cacheDirs[1], 0o555, 0o444);
try {
const reuse = launch(mounts[1]);
assert.equal(reuse.status, 0, reuse.stderr);
assert.ok(reuse.stdout.trim().startsWith(cacheDirs[1]), reuse.stdout);
assert.match(reuse.stderr, /main\.cjs was accepted/, reuse.stderr);
} finally {
if (readOnly) await chmodTree(cacheDirs[1], 0o755, 0o644);
}
assert.deepEqual(await listEntries(cacheDirs[1]), warmed);
const statsAfter = await Promise.all(
warmed.map((entry) => NodeFSP.stat(NodePath.join(cacheDirs[1], entry))),
);
assert.deepEqual(
statsAfter.map((stat) => stat.mtimeMs),
statsBefore.map((stat) => stat.mtimeMs),
"a hit rewrites nothing",
);
} finally {
await NodeFSP.rm(directory, { recursive: true, force: true });
}
});

async function chmodTree(root, directoryMode, fileMode) {
const entries = await NodeFSP.readdir(root, { recursive: true, withFileTypes: true });
for (const entry of entries) {
await NodeFSP.chmod(
NodePath.join(entry.parentPath, entry.name),
entry.isDirectory() ? directoryMode : fileMode,
);
}
await NodeFSP.chmod(root, directoryMode);
}
20 changes: 16 additions & 4 deletions apps/desktop/src/compileCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,23 @@ import * as NodePath from "node:path";
// Linux uses the user's cache dir because /tmp is shared between users; the
// macOS and Windows temp dirs are already per user.
//
// Skipped under AppImage: it mounts the app at a new /tmp/.mount_* path each
// launch, and Node keys entries by path, so every launch would miss and leave
// another copy behind. The backend inherits APPIMAGE, so this covers it too.
// AppImage mounts the app at a new /tmp/.mount_* path each launch, and Node
// keys entries by absolute path, so a per-user cache would miss every launch
// and leave another copy behind. AppImage instead reads the portable cache the
// Linux build ships next to app.asar (scripts/desktop-compile-cache-hook.ts).
// Its keys are relative to the cache directory, so every mount hits; the image
// is read-only, so misses are compiled and never written. Node 24 looks the
// cache up by uid and the build ships uid 1000, so other uids run without a
// cache until Node drops the uid from portable caches. The backend inherits
// APPIMAGE, so this covers it too.
try {
if (!process.env.APPIMAGE) {
if (process.env.APPIMAGE) {
NodeModule.enableCompileCache({
// This file is resources/app.asar/apps/desktop/dist-electron/compileCache.cjs.
directory: NodePath.resolve(__dirname, "../../../..", "compile-cache"),
portable: true,
});
} else {
const cacheRoot =
// oxlint-disable-next-line t3code/no-global-process-runtime -- Loads before any Effect runtime.
process.platform === "linux"
Expand Down
3 changes: 3 additions & 0 deletions scripts/build-desktop-artifact.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -673,6 +673,9 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
assert.deepStrictEqual(win.asarUnpack, [WINDOWS_NATIVE_ASAR_UNPACK_GLOB]);
assert.deepStrictEqual(winWithoutWslRuntime.asar, win.asar);
assert.deepStrictEqual(winWithoutWslRuntime.asarUnpack, win.asarUnpack);
assert.match(String(linux.afterPack), /[\\/]scripts[\\/]desktop-compile-cache-hook\.ts$/);
assert.notProperty(mac, "afterPack");
assert.notProperty(win, "afterPack");
assert.deepStrictEqual(mac.extraResources, DESKTOP_EXTRA_RESOURCES);
assert.deepStrictEqual(linux.extraResources, [
...DESKTOP_EXTRA_RESOURCES,
Expand Down
6 changes: 6 additions & 0 deletions scripts/build-desktop-artifact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2890,6 +2890,12 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* (
buildConfig.toolsets = { appimage: "1.0.3" };
const path = yield* Path.Path;
const repoRoot = yield* RepoRoot;
// AppImage mounts move between launches, so the runtime cannot keep a
// compile cache of its own. Warm one into the unpacked app before the
// AppImage is assembled. The .deb is built from the same directory and
// ships the entries too, unused: its install path is stable, so it keeps
// the per-user cache.
buildConfig.afterPack = path.join(repoRoot, "scripts/desktop-compile-cache-hook.ts");
buildConfig.linux = {
// The .deb is built from the same unpacked app after the AppImage.
// electron-builder lists both in latest-linux.yml and writes
Expand Down
Loading
Loading