fix(packaged): prewarm linux AppImage sidecar payload + widen linux status budget (#5836)

Every AppImage launch mounts a fresh FUSE squashfs whose VFS page cache
is cold, so the daemon sidecar demand-pages its 124 MB bundled node
binary (plus dist and the ~70 MB bundled plugins registry) through FUSE
on EVERY launch while Electron faults through the same mount. The
daemon could not bind its IPC socket within the 35s DAEMON_STATUS_TIMEOUT_MS
and every launch failed with a sidecar status timeout (white screen /
recovery error).

- Add apps/packaged/src/prewarm.ts: best-effort sequential read of the
  sidecar cold-start set into the page cache before spawning, gated to
  linux + FUSE-backed payloads (plain-file installs skip). Sequential
  reads measure ~45 MB/s through squashfuse vs ~5 MB/s demand-paged
  under launch contention; daemon ready drops from ~25-60s+ to ~3-19s.
- Widen the linux status-wait baseline to 90s as a safety net for slow
  devices, mirroring the win32 rationale (#5515): same "slow, not
  dead" failure class.

Fixes #5835
This commit is contained in:
ArseniyGl
2026-07-21 03:49:28 +00:00
committed by GitHub
parent 4fb217c957
commit 06ce4eeba2
4 changed files with 646 additions and 10 deletions
+295
View File
@@ -0,0 +1,295 @@
import { createRequire } from "node:module";
import { open, readFile, readdir } from "node:fs/promises";
import { dirname, join } from "node:path";
const require = createRequire(import.meta.url);
/**
* Linux AppImage cold-start prewarm (issue #5835).
*
* Every AppImage launch mounts a FRESH FUSE squashfs, and the VFS page cache
* for FUSE files is per-mount — so the underlying AppImage file can be fully
* cached while every launch still cold-reads the daemon/web payload through
* the FUSE server. The daemon sidecar demand-pages its 124 MB bundled node
* binary (plus its JS dist) one fault at a time while the Electron main
* process faults through the same single mount, and the 35s status budget is
* blown before the daemon ever binds its IPC socket.
*
* The same payload read sequentially is dramatically faster (~45 MB/s through
* squashfuse vs ~5 MB/s demand-paged under contention: 124 MB in ~3s instead
* of ~25-60s). Reading it into the page cache BEFORE spawning a sidecar
* moves that cost out of the timed status window and turns every launch into
* the "later launches are warm" case the win32 budget already assumes.
*
* Everything here is best-effort: a prewarm miss must never break startup,
* it just falls back to the old demand-paged behavior.
*/
export type PrewarmTarget = {
kind: "file" | "dir";
path: string;
};
export type PrewarmReport = {
skipped: boolean;
reason?: string;
files?: number;
bytes?: number;
durationMs?: number;
};
/** /proc/mounts escapes space, tab, newline and backslash as octal \040 etc. */
export function unescapeProcMountsField(field: string): string {
return field.replace(/\\([0-7]{3})/g, (_, octal: string) =>
String.fromCharCode(Number.parseInt(octal, 8)),
);
}
/**
* Decides whether `targetPath` lives on a FUSE mount, parsing an injected
* /proc/mounts table. Returns null when no mountpoint matches at all (a
* malformed table), so the caller can choose its own fail-open/closed policy.
*/
export function detectFuseBackedPath(
targetPath: string,
mountsContent: string,
): boolean | null {
let bestMatch: { mountpoint: string; fstype: string } | null = null;
for (const line of mountsContent.split("\n")) {
const fields = line.trim().split(/\s+/);
if (fields.length < 3) continue;
const mountpoint = unescapeProcMountsField(fields[1]);
const fstype = fields[2];
const matches =
mountpoint === "/"
? targetPath.startsWith("/")
: targetPath === mountpoint || targetPath.startsWith(`${mountpoint}/`);
if (!matches) continue;
if (bestMatch == null || mountpoint.length > bestMatch.mountpoint.length) {
bestMatch = { mountpoint, fstype };
}
}
if (bestMatch == null) return null;
return /^fuse(\..*)?$/.test(bestMatch.fstype);
}
/**
* Daemon cold-start payload: the bundled node binary (the dominant 124 MB
* demand-paged artifact) plus the daemon's compiled dist. When the sidecar
* would run under Electron-as-node (no bundled node configured) the binary
* target is omitted — the Electron executable is already paged in, since the
* launcher itself is running from it.
*/
export function resolveDaemonPrewarmTargets(options: {
nodeCommand: string | null;
daemonSidecarEntry: string;
resourceRoot: string | null;
}): PrewarmTarget[] {
const targets: PrewarmTarget[] = [];
if (options.nodeCommand != null && options.nodeCommand.length > 0) {
targets.push({ kind: "file", path: options.nodeCommand });
}
targets.push({ kind: "dir", path: dirname(dirname(options.daemonSidecarEntry)) });
// The daemon registers ~460 bundled plugins at startup by reading them from
// the resource root (~70 MB here) — measured as the dominant cold FUSE read
// left in the daemon's timed status window after the binary and dist are
// warm (6.4s -> 2.7s ready time once plugins are prewarmed too).
if (options.resourceRoot != null && options.resourceRoot.length > 0) {
targets.push({ kind: "dir", path: join(options.resourceRoot, "plugins") });
}
return targets;
}
/**
* Web cold-start payload. In "server" output mode (always the case on Linux)
* the web sidecar boots a Next server from the shipped web package, so the
* cold set is the sidecar dist, the compiled `.next/server` route chunks, and
* the Next framework's own server code. `next/dist/compiled` is deliberately
* NOT prewarmed: at ~107 MB it dominates the read set while only a small
* fraction is required during the status window — the rest is demand-loaded
* on first paint, which has no hard timeout. In "standalone" mode the whole
* self-contained bundle root is the payload. Missing pieces are skipped at
* collection time, so stale targets never fail the prewarm.
*/
export function resolveWebPrewarmTargets(options: {
webSidecarEntry: string | null;
webStandaloneRoot: string | null;
resolveNextPackageRoot?: (webPackageRoot: string) => string | null;
}): PrewarmTarget[] {
if (options.webStandaloneRoot != null && options.webStandaloneRoot.length > 0) {
return [{ kind: "dir", path: options.webStandaloneRoot }];
}
if (options.webSidecarEntry == null || options.webSidecarEntry.length === 0) {
return [];
}
// <webPkg>/dist/sidecar/index.js -> sidecarDir = dist/sidecar, webPkg = ../..
const sidecarDir = dirname(options.webSidecarEntry);
const webPackageRoot = dirname(dirname(sidecarDir));
const targets: PrewarmTarget[] = [
{ kind: "dir", path: sidecarDir },
{ kind: "dir", path: join(webPackageRoot, ".next", "server") },
];
const resolveNext =
options.resolveNextPackageRoot ?? defaultResolveNextPackageRoot;
const nextRoot = resolveNext(webPackageRoot);
if (nextRoot != null) {
targets.push({ kind: "dir", path: join(nextRoot, "dist", "server") });
}
return targets;
}
function defaultResolveNextPackageRoot(webPackageRoot: string): string | null {
try {
const packageJsonPath = require.resolve("next/package.json", {
paths: [webPackageRoot],
});
return dirname(packageJsonPath);
} catch {
return null;
}
}
/**
* Expands targets into a concrete file list. Directories are walked
* recursively; symlinks are not followed (a FUSE mount full of symlinks must
* not pull the prewarm outside its payload); missing/unreadable paths are
* dropped silently. The result is sorted and de-duplicated so reports and
* tests are deterministic.
*/
export async function collectPrewarmFiles(
targets: PrewarmTarget[],
): Promise<string[]> {
const files = new Set<string>();
const walk = async (dir: string): Promise<void> => {
let entries;
try {
entries = await readdir(dir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
const fullPath = join(dir, entry.name);
if (entry.isDirectory()) {
await walk(fullPath);
} else if (entry.isFile() && !entry.isSymbolicLink()) {
files.add(fullPath);
}
}
};
for (const target of targets) {
if (target.kind === "dir") {
await walk(target.path);
} else {
try {
await open(target.path, "r").then((handle) => handle.close());
files.add(target.path);
} catch {
// Missing file target: skip.
}
}
}
return [...files].sort();
}
/**
* Reads each file sequentially into a small reused buffer and discards the
* contents — the point is populating the VFS page cache, not consuming the
* data. A concurrency of two keeps a FUSE server saturated without making
* the reads random. Per-file failures are swallowed: whatever could not be
* prewarmed will simply be demand-paged later, as before.
*/
export async function prewarmFiles(
files: string[],
options: { concurrency?: number; chunkBytes?: number } = {},
): Promise<{ files: number; bytes: number }> {
const concurrency = Math.max(1, options.concurrency ?? 2);
const chunkBytes = Math.max(64 * 1024, options.chunkBytes ?? 4 * 1024 * 1024);
let done = 0;
let totalBytes = 0;
let nextIndex = 0;
const worker = async (): Promise<void> => {
const buffer = Buffer.alloc(chunkBytes);
while (nextIndex < files.length) {
const file = files[nextIndex++];
try {
const handle = await open(file, "r");
try {
for (;;) {
const { bytesRead } = await handle.read(buffer, 0, chunkBytes, null);
if (bytesRead <= 0) break;
totalBytes += bytesRead;
}
} finally {
await handle.close();
}
done += 1;
} catch {
// Unreadable file: leave it to demand paging.
}
}
};
await Promise.all(
Array.from({ length: Math.min(concurrency, files.length) }, () => worker()),
);
return { files: done, bytes: totalBytes };
}
/**
* Gated, best-effort prewarm entry point used by the packaged launcher.
*
* Gates: linux only (win32 already has its own AV-scan budget, macOS has no
* per-launch FUSE remount), and only when the payload actually lives on a
* FUSE mount — plain-file installs (unpacked dir, extracted AppImage) have a
* persistent page cache and prewarming them would be pure overhead. When the
* mounts table cannot be read at all the function fails OPEN toward
* prewarming: a few hundred MB of redundant cached reads costs ~0.1s on a
* plain filesystem, while a wrongly skipped prewarm reintroduces #5835.
*/
export async function prewarmPackagedFiles(
targets: PrewarmTarget[],
options: {
platform?: NodeJS.Platform;
mountsContent?: string | null;
log?: (message: string) => void;
} = {},
): Promise<PrewarmReport> {
const platform = options.platform ?? process.platform;
if (platform !== "linux") {
return { skipped: true, reason: "not-linux" };
}
if (targets.length === 0) {
return { skipped: true, reason: "no-targets" };
}
let mountsContent = options.mountsContent;
if (mountsContent === undefined) {
mountsContent = await readFile("/proc/mounts", "utf8").catch(() => null);
}
if (mountsContent != null) {
const fuseBacked = detectFuseBackedPath(targets[0].path, mountsContent);
if (fuseBacked === false) {
return { skipped: true, reason: "not-fuse" };
}
}
const startedAt = Date.now();
try {
const files = await collectPrewarmFiles(targets);
const { files: done, bytes } = await prewarmFiles(files);
const durationMs = Date.now() - startedAt;
options.log?.(
`[open-design packaged] prewarm complete files=${done}/${files.length} bytes=${bytes} durationMs=${durationMs}`,
);
return { skipped: false, files: done, bytes, durationMs };
} catch (error) {
return {
skipped: true,
reason: `error: ${error instanceof Error ? error.message : String(error)}`,
};
}
}
+62 -6
View File
@@ -33,6 +33,11 @@ import {
import type { PackagedWebOutputMode } from "./config.js";
import type { PackagedNamespacePaths } from "./paths.js";
import {
prewarmPackagedFiles,
resolveDaemonPrewarmTargets,
resolveWebPrewarmTargets,
} from "./prewarm.js";
const require = createRequire(import.meta.url);
const PACKAGED_CHILD_ENV_ALLOWLIST = [
@@ -158,6 +163,15 @@ const DAEMON_STATUS_TIMEOUT_MS = 35_000;
// lets that first launch succeed instead of failing to a recovery screen and
// forcing a manual relaunch.
const WIN32_STATUS_TIMEOUT_MS = 90_000;
// Linux AppImage launches remount a fresh FUSE squashfs every time, so the
// VFS page cache for the packaged payload is cold on EVERY launch — the
// daemon demand-pages its 124 MB bundled node binary through FUSE while the
// Electron main process faults through the same mount, and the 35s POSIX
// baseline is unreachable on mid-range hardware (issue #5835). The prewarm
// pass in startPackagedSidecars cuts the usual case to a few seconds; this
// widened budget is the safety net for slow devices, mirroring the win32
// rationale above (same "slow, not dead" failure class as #5515).
const LINUX_STATUS_TIMEOUT_MS = 90_000;
const DAEMON_MIGRATION_STATUS_TIMEOUT_MS = 30 * 60 * 1000;
// Poll cadence for waitForStatus: start tight so a fast daemon is detected
@@ -168,15 +182,19 @@ const STATUS_POLL_INITIAL_MS = 150;
const STATUS_POLL_MAX_MS = 1500;
// Baseline status wait budget by platform, before the daemon-only legacy
// migration override. win32 gets the wider AV-scan headroom; every other OS
// keeps the 35s baseline.
// migration override. win32 gets the wider AV-scan headroom, linux gets the
// same headroom for AppImage FUSE cold starts; every other OS keeps the 35s
// baseline.
function baseStatusTimeoutMs(platform: NodeJS.Platform = process.platform): number {
return platform === "win32" ? WIN32_STATUS_TIMEOUT_MS : DAEMON_STATUS_TIMEOUT_MS;
if (platform === "win32") return WIN32_STATUS_TIMEOUT_MS;
if (platform === "linux") return LINUX_STATUS_TIMEOUT_MS;
return DAEMON_STATUS_TIMEOUT_MS;
}
/**
* Daemon status wait budget. The platform baseline (35s, or 90s on win32 for
* AV-scan headroom) is fine for normal cold boots, but the OD_LEGACY_DATA_DIR
* AV-scan headroom and on linux for AppImage FUSE cold starts) is fine for
* normal cold boots, but the OD_LEGACY_DATA_DIR
* one-shot recovery flow can synch-copy a multi-GB legacy `.od/` payload before
* SQLite even opens, and killing the child mid-migration can leave dataDir
* half-promoted. When the env var is set, use a 30-minute budget so the parent
@@ -576,11 +594,34 @@ export async function startPackagedSidecars(
const children: ManagedSidecarChild[] = [];
const daemonSidecarEntry =
options.daemonSidecarEntry ?? resolveSidecarEntry("@open-design/daemon", "sidecar");
const webSidecarEntry =
options.webSidecarEntry ?? resolveSidecarEntry("@open-design/web", "sidecar");
const prewarmLog = (message: string): void => {
void appendSidecarLifecycleLog(join(paths.logsRoot, "launcher", "latest.log"), message);
};
try {
// Issue #5835: on Linux AppImage the payload lives on a fresh FUSE mount
// with a cold page cache. Read the daemon's cold-start set (bundled node
// binary + daemon dist) into the page cache BEFORE spawning, so the
// timed status window covers real daemon work instead of demand-paging
// ~145 MB through FUSE page fault by page fault. Best-effort and gated
// to linux+FUSE inside prewarmPackagedFiles; plain-file installs skip.
await prewarmPackagedFiles(
resolveDaemonPrewarmTargets({
nodeCommand: options.nodeCommand,
daemonSidecarEntry,
resourceRoot: paths.resourceRoot,
}),
{ log: prewarmLog },
);
options.onPhase?.("daemon-spawning");
const daemon = await spawnSidecarChild({
app: APP_KEYS.DAEMON,
entryPath: options.daemonSidecarEntry ?? resolveSidecarEntry("@open-design/daemon", "sidecar"),
entryPath: daemonSidecarEntry,
env: buildPackagedDaemonSpawnEnv(paths, {
appVersion: options.appVersion,
amrProfile: options.amrProfile,
@@ -598,6 +639,17 @@ export async function startPackagedSidecars(
runtime,
});
children.push(daemon);
// The web prewarm overlaps the daemon's boot wait: its read set (web
// sidecar dist, .next/server chunks, Next framework code) is disjoint
// from the daemon's already-warm working set, so the FUSE server stays
// busy on sequential reads while the daemon does CPU/SQLite work.
const webPrewarm = prewarmPackagedFiles(
resolveWebPrewarmTargets({
webSidecarEntry,
webStandaloneRoot: options.webStandaloneRoot,
}),
{ log: prewarmLog },
);
const daemonStatus = await waitForStatus<DaemonStatusSnapshot>(
daemon.ipcPath,
(status) => status.url != null,
@@ -612,10 +664,14 @@ export async function startPackagedSidecars(
if (daemonStatus.url == null) throw new Error("daemon did not report a URL");
options.onPhase?.("daemon-ready");
// The web payload must be in the page cache before the web sidecar
// enters its own timed status window.
await webPrewarm;
options.onPhase?.("web-spawning");
const web = await spawnSidecarChild({
app: APP_KEYS.WEB,
entryPath: options.webSidecarEntry ?? resolveSidecarEntry("@open-design/web", "sidecar"),
entryPath: webSidecarEntry,
env: {
[SIDECAR_ENV.DAEMON_PORT]: extractPort(daemonStatus.url),
[SIDECAR_ENV.WEB_PORT]: "0",
+275
View File
@@ -0,0 +1,275 @@
/**
* Regression coverage for the Linux AppImage cold-start prewarm in
* apps/packaged/src/prewarm.ts.
*
* Background: every AppImage launch mounts a fresh FUSE squashfs whose VFS
* page cache is cold, so the daemon sidecar demand-pages its 124 MB bundled
* node binary (plus JS payload) through FUSE on EVERY launch while Electron
* faults through the same mount. Demand-paged cold starts measured ~25-60s on
* a mid-range Linux box and routinely blew past the 35s status budget, while
* the same payload read sequentially takes ~3s. The prewarm pass reads the
* sidecar payload into the page cache before spawning so the timed window
* only covers real daemon work.
*
* @see apps/packaged/src/prewarm.ts
* @see https://github.com/nexu-io/open-design/issues/5835
*/
import { mkdtempSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import {
collectPrewarmFiles,
detectFuseBackedPath,
prewarmFiles,
prewarmPackagedFiles,
resolveDaemonPrewarmTargets,
resolveWebPrewarmTargets,
unescapeProcMountsField,
} from '../src/prewarm.js';
const MOUNTS_FIXTURE = [
'proc /proc proc rw,nosuid,nodev,noexec,relatime 0 0',
'/dev/sda1 / ext4 rw,relatime 0 0',
'tmpfs /tmp tmpfs rw,nosuid,nodev 0 0',
'Open\\040Design-default.AppImage /tmp/.mount_Open\\040DePl0rQ fuse.Open\\040Design-default.AppImage ro,nosuid,nodev,relatime,user_id=1000,group_id=1000 0 0',
].join('\n');
const tempRoots: string[] = [];
function makeTempRoot(): string {
const root = mkdtempSync(join(tmpdir(), 'od-prewarm-test-'));
tempRoots.push(root);
return root;
}
afterEach(() => {
while (tempRoots.length > 0) {
const root = tempRoots.pop();
if (root != null) rmSync(root, { recursive: true, force: true });
}
});
describe('unescapeProcMountsField', () => {
it('decodes octal escapes used for space, tab, newline and backslash', () => {
expect(unescapeProcMountsField('/tmp/.mount_Open\\040DePl0rQ')).toBe('/tmp/.mount_Open DePl0rQ');
expect(unescapeProcMountsField('a\\011b\\012c\\134d')).toBe('a\tb\nc\\d');
expect(unescapeProcMountsField('/plain/path')).toBe('/plain/path');
});
});
describe('detectFuseBackedPath', () => {
it('detects a path under an AppImage FUSE mount with an escaped space', () => {
expect(
detectFuseBackedPath('/tmp/.mount_Open DePl0rQ/resources/open-design/bin/node', MOUNTS_FIXTURE),
).toBe(true);
});
it('matches the mountpoint itself', () => {
expect(detectFuseBackedPath('/tmp/.mount_Open DePl0rQ', MOUNTS_FIXTURE)).toBe(true);
});
it('returns false for a path on a regular filesystem', () => {
expect(detectFuseBackedPath('/usr/bin/node', MOUNTS_FIXTURE)).toBe(false);
});
it('prefers the longest matching mountpoint over shorter prefixes', () => {
const mounts = [
'/dev/sda1 / ext4 rw,relatime 0 0',
'squashfuse /opt/app fuse.squashfuse ro,relatime 0 0',
].join('\n');
expect(detectFuseBackedPath('/opt/app/resources/node', mounts)).toBe(true);
expect(detectFuseBackedPath('/etc/hostname', mounts)).toBe(false);
});
it('does not match a sibling directory that merely shares a path prefix', () => {
const mounts = [
'/dev/sda1 / ext4 rw,relatime 0 0',
'squashfuse /opt/app fuse.squashfuse ro,relatime 0 0',
].join('\n');
expect(detectFuseBackedPath('/opt/app-other/node', mounts)).toBe(false);
});
it('returns null when no mountpoint matches', () => {
expect(detectFuseBackedPath('/somewhere/else', 'proc /proc proc rw 0 0')).toBe(null);
});
});
describe('resolveDaemonPrewarmTargets', () => {
it('includes the bundled node binary, the daemon dist dir and the bundled plugins', () => {
const targets = resolveDaemonPrewarmTargets({
nodeCommand: '/res/open-design/bin/node',
daemonSidecarEntry: '/res/app/node_modules/@open-design/daemon/dist/sidecar/index.js',
resourceRoot: '/res/open-design',
});
expect(targets).toEqual([
{ kind: 'file', path: '/res/open-design/bin/node' },
{ kind: 'dir', path: '/res/app/node_modules/@open-design/daemon/dist' },
{ kind: 'dir', path: '/res/open-design/plugins' },
]);
});
it('omits the node binary when the sidecar would run under Electron-as-node', () => {
const targets = resolveDaemonPrewarmTargets({
nodeCommand: null,
daemonSidecarEntry: '/res/app/node_modules/@open-design/daemon/dist/sidecar/index.js',
resourceRoot: '/res/open-design',
});
expect(targets).toEqual([
{ kind: 'dir', path: '/res/app/node_modules/@open-design/daemon/dist' },
{ kind: 'dir', path: '/res/open-design/plugins' },
]);
});
it('omits the plugins dir when the resource root is unknown', () => {
const targets = resolveDaemonPrewarmTargets({
nodeCommand: '/res/open-design/bin/node',
daemonSidecarEntry: '/res/app/node_modules/@open-design/daemon/dist/sidecar/index.js',
resourceRoot: null,
});
expect(targets).toEqual([
{ kind: 'file', path: '/res/open-design/bin/node' },
{ kind: 'dir', path: '/res/app/node_modules/@open-design/daemon/dist' },
]);
});
});
describe('resolveWebPrewarmTargets', () => {
const entry = '/res/app/node_modules/@open-design/web/dist/sidecar/index.js';
it('covers the web sidecar, Next server chunks and framework server code in server mode', () => {
// next/dist/compiled (~107 MB) is intentionally excluded: only a small
// fraction of it is required during the status window, and the rest is
// demand-loaded on first paint, which has no hard timeout.
const targets = resolveWebPrewarmTargets({
webSidecarEntry: entry,
webStandaloneRoot: null,
resolveNextPackageRoot: () => '/res/app/node_modules/next',
});
expect(targets).toEqual([
{ kind: 'dir', path: '/res/app/node_modules/@open-design/web/dist/sidecar' },
{ kind: 'dir', path: '/res/app/node_modules/@open-design/web/.next/server' },
{ kind: 'dir', path: '/res/app/node_modules/next/dist/server' },
]);
});
it('falls back to the standalone bundle root in standalone mode', () => {
const targets = resolveWebPrewarmTargets({
webSidecarEntry: entry,
webStandaloneRoot: '/res/open-design-web-standalone',
resolveNextPackageRoot: () => '/res/app/node_modules/next',
});
expect(targets).toEqual([{ kind: 'dir', path: '/res/open-design-web-standalone' }]);
});
it('skips the Next framework dirs when next cannot be resolved', () => {
const targets = resolveWebPrewarmTargets({
webSidecarEntry: entry,
webStandaloneRoot: null,
resolveNextPackageRoot: () => null,
});
expect(targets).toEqual([
{ kind: 'dir', path: '/res/app/node_modules/@open-design/web/dist/sidecar' },
{ kind: 'dir', path: '/res/app/node_modules/@open-design/web/.next/server' },
]);
});
it('returns no targets when the web sidecar entry is unknown', () => {
expect(
resolveWebPrewarmTargets({ webSidecarEntry: null, webStandaloneRoot: null }),
).toEqual([]);
});
});
describe('collectPrewarmFiles', () => {
it('walks directories recursively, keeps plain files, and skips missing paths and symlinks', async () => {
const root = makeTempRoot();
const dir = join(root, 'dist');
mkdirSync(join(dir, 'nested'), { recursive: true });
writeFileSync(join(dir, 'a.js'), 'aaa');
writeFileSync(join(dir, 'nested', 'b.js'), 'bbbbb');
writeFileSync(join(root, 'node.bin'), 'nn');
symlinkSync(join(dir, 'a.js'), join(dir, 'link.js'));
const resolved = await collectPrewarmFiles([
{ kind: 'dir', path: dir },
{ kind: 'file', path: join(root, 'node.bin') },
{ kind: 'file', path: join(root, 'missing.bin') },
]);
expect(resolved).toEqual([
join(dir, 'a.js'),
join(dir, 'nested', 'b.js'),
join(root, 'node.bin'),
]);
});
});
describe('prewarmFiles', () => {
it('reads every file fully and reports totals, skipping unreadable entries', async () => {
const root = makeTempRoot();
writeFileSync(join(root, 'one.bin'), Buffer.alloc(10_000, 1));
writeFileSync(join(root, 'two.bin'), Buffer.alloc(20_000, 2));
const report = await prewarmFiles([
join(root, 'one.bin'),
join(root, 'two.bin'),
join(root, 'gone.bin'),
]);
expect(report.files).toBe(2);
expect(report.bytes).toBe(30_000);
});
});
describe('prewarmPackagedFiles', () => {
it('skips on non-linux platforms', async () => {
const report = await prewarmPackagedFiles([], { platform: 'darwin' });
expect(report.skipped).toBe(true);
expect(report.reason).toBe('not-linux');
});
it('skips when there is nothing to prewarm', async () => {
const report = await prewarmPackagedFiles([], { platform: 'linux' });
expect(report.skipped).toBe(true);
expect(report.reason).toBe('no-targets');
});
it('skips when the payload is not backed by a FUSE mount', async () => {
const root = makeTempRoot();
writeFileSync(join(root, 'node.bin'), 'nn');
const report = await prewarmPackagedFiles([{ kind: 'file', path: join(root, 'node.bin') }], {
platform: 'linux',
mountsContent: MOUNTS_FIXTURE,
});
expect(report.skipped).toBe(true);
expect(report.reason).toBe('not-fuse');
});
it('prewarms when the payload lives under a FUSE mount', async () => {
const root = makeTempRoot();
const fuseRoot = join(root, '.mount_Open DePl0rQ');
mkdirSync(fuseRoot, { recursive: true });
writeFileSync(join(fuseRoot, 'node.bin'), Buffer.alloc(4096, 3));
const mounts = MOUNTS_FIXTURE.replaceAll('/tmp/.mount_Open\\040DePl0rQ', fuseRoot.replaceAll(' ', '\\040'));
const report = await prewarmPackagedFiles([{ kind: 'file', path: join(fuseRoot, 'node.bin') }], {
platform: 'linux',
mountsContent: mounts,
});
expect(report.skipped).toBe(false);
expect(report.files).toBe(1);
expect(report.bytes).toBe(4096);
expect(report.durationMs).toBeGreaterThanOrEqual(0);
});
it('fails open toward prewarming when the mounts table is unavailable', async () => {
const root = makeTempRoot();
writeFileSync(join(root, 'node.bin'), Buffer.alloc(2048, 4));
const report = await prewarmPackagedFiles([{ kind: 'file', path: join(root, 'node.bin') }], {
platform: 'linux',
mountsContent: null,
});
expect(report.skipped).toBe(false);
expect(report.bytes).toBe(2048);
});
});
+14 -4
View File
@@ -39,11 +39,20 @@ function slashPath(value: string): string {
}
describe('resolveDaemonStatusTimeoutMs', () => {
it('uses the 35-second baseline budget on non-win32 for normal cold boots', () => {
expect(resolveDaemonStatusTimeoutMs({}, 'linux')).toBe(35_000);
it('uses the 35-second baseline budget on platforms without a known slow-cold-start class', () => {
expect(resolveDaemonStatusTimeoutMs({}, 'darwin')).toBe(35_000);
});
it('widens the baseline to 90 seconds on linux for AppImage FUSE cold starts', () => {
// Every AppImage launch mounts a fresh FUSE squashfs with a cold VFS page
// cache, so the daemon demand-pages its bundled node binary through FUSE on
// EVERY launch and can blow past the 35s baseline. The prewarm pass cuts the
// usual case to a few seconds; the wider budget is the safety net for slow
// devices, mirroring the win32 rationale.
// https://github.com/nexu-io/open-design/issues/5835
expect(resolveDaemonStatusTimeoutMs({}, 'linux')).toBe(90_000);
});
it('widens the baseline to 90 seconds on win32 for AV-scan-slow first launches', () => {
// Windows Defender scanning freshly-written packaged binaries inflates the
// daemon cold start (native better-sqlite3 load + first SQLite open + pipe
@@ -53,7 +62,7 @@ describe('resolveDaemonStatusTimeoutMs', () => {
});
it('treats an empty OD_LEGACY_DATA_DIR as unset', () => {
expect(resolveDaemonStatusTimeoutMs({ OD_LEGACY_DATA_DIR: '' }, 'linux')).toBe(35_000);
expect(resolveDaemonStatusTimeoutMs({ OD_LEGACY_DATA_DIR: '' }, 'darwin')).toBe(35_000);
});
it('extends the budget to 30 minutes when OD_LEGACY_DATA_DIR is set', () => {
@@ -75,7 +84,8 @@ describe('resolveDaemonStatusTimeoutMs', () => {
const original = process.env.OD_LEGACY_DATA_DIR;
try {
delete process.env.OD_LEGACY_DATA_DIR;
expect(resolveDaemonStatusTimeoutMs(undefined, 'linux')).toBe(35_000);
expect(resolveDaemonStatusTimeoutMs(undefined, 'linux')).toBe(90_000);
expect(resolveDaemonStatusTimeoutMs(undefined, 'darwin')).toBe(35_000);
process.env.OD_LEGACY_DATA_DIR = '/some/legacy/path';
expect(resolveDaemonStatusTimeoutMs(undefined, 'linux')).toBe(30 * 60 * 1000);
} finally {