mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
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:
@@ -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)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user