Skip to content

Commit 864f7fb

Browse files
authored
Merge pull request #141 from pylon-code/upstream/2026-08-28-wsl-runtime-cache
fix(desktop): cache the WSL runtime inside the distro
2 parents 9dd3c8e + 0cd0838 commit 864f7fb

14 files changed

Lines changed: 2482 additions & 111 deletions

‎apps/desktop/src/backend/DesktopBackendConfiguration.test.ts‎

Lines changed: 387 additions & 9 deletions
Large diffs are not rendered by default.

‎apps/desktop/src/backend/DesktopBackendConfiguration.ts‎

Lines changed: 185 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,7 @@ interface SharedBootstrapInput {
213213
interface WslPreflightSuccess {
214214
readonly _tag: "Ready";
215215
readonly runningDistro: string;
216+
readonly windowsEntryPath: string;
216217
readonly linuxEntryPath: string;
217218
// Absolute path to the node binary the preflight validated after the shared
218219
// remote resolver repaired PATH. The launch must use this exact path so it
@@ -222,6 +223,8 @@ interface WslPreflightSuccess {
222223
// PATH captured from the same login shell after the shared resolver loaded
223224
// version managers. The launch forwards this value directly without a shell.
224225
readonly resolvedPath: string;
226+
// Identifies the distro-local runtime cache selected from the packaged archive.
227+
readonly runtimeId?: string;
225228
}
226229

227230
interface WslPreflightFailure {
@@ -236,18 +239,35 @@ interface WslPreflightFailure {
236239
}
237240

238241
const WSL_TRANSIENT_PREFLIGHT_RETRY_LIMIT = 12;
242+
const WSL_RUNTIME_ARCHIVE_NAME = "wsl-runtime.tar.gz";
243+
const WSL_RUNTIME_ARCHIVE_HASH_NAME = `${WSL_RUNTIME_ARCHIVE_NAME}.sha256`;
244+
const SHA256_HEX_PATTERN = /^[0-9a-f]{64}$/i;
245+
246+
export const parseWslRuntimeArchiveHash = (value: string): string | null => {
247+
const trimmed = value.trim();
248+
return SHA256_HEX_PATTERN.test(trimmed) ? trimmed.toLowerCase() : null;
249+
};
250+
251+
type FailedNodePtyResult = Extract<
252+
DesktopWslEnvironment.EnsureWslNodePtyResult,
253+
{ readonly ok: false }
254+
>;
239255

240256
const runWslPreflight = Effect.fn("desktop.backendConfiguration.wslPreflight")(function* (input: {
241257
readonly distro: string | null;
242-
readonly windowsEntryPath: string;
243-
readonly windowsRepoRoot: string;
258+
readonly runtimeArchive: DesktopWslEnvironment.WslRuntimeArchive | null;
244259
readonly allowBuild: boolean;
245260
}): Effect.fn.Return<
246261
WslPreflightSuccess | WslPreflightFailure,
247262
never,
248-
DesktopWslEnvironment.DesktopWslEnvironment | FileSystem.FileSystem
263+
| DesktopEnvironment.DesktopEnvironment
264+
| DesktopWslEnvironment.DesktopWslEnvironment
265+
| DesktopWslServerTree.DesktopWslServerTree
266+
| FileSystem.FileSystem
249267
> {
268+
const environment = yield* DesktopEnvironment.DesktopEnvironment;
250269
const wslEnv = yield* DesktopWslEnvironment.DesktopWslEnvironment;
270+
const wslServerTree = yield* DesktopWslServerTree.DesktopWslServerTree;
251271
const fileSystem = yield* FileSystem.FileSystem;
252272

253273
const wslAvailable = yield* wslEnv.isAvailable;
@@ -289,43 +309,127 @@ const runWslPreflight = Effect.fn("desktop.backendConfiguration.wslPreflight")(f
289309
} as const;
290310
}
291311

292-
const entryExists = yield* fileSystem
293-
.exists(input.windowsEntryPath)
294-
.pipe(Effect.orElseSucceed(() => false));
295-
if (!entryExists) {
296-
return {
312+
const nodePtyOptions = {
313+
allowBuild: input.allowBuild,
314+
nodeEngineRange: serverPackageJson.engines.node,
315+
};
316+
const failedNodePty = (result: FailedNodePtyResult) =>
317+
({
297318
_tag: "Failed",
298-
reason: `missing server entry at ${input.windowsEntryPath}`,
299-
fatal: true,
300-
} as const;
319+
reason: `WSL node-pty unavailable: ${result.reason}`,
320+
fatal: result.fatal,
321+
...(result.retryLimit === undefined ? {} : { retryLimit: result.retryLimit }),
322+
}) as const;
323+
324+
// The mounted server tree is the fallback runtime: the Windows-side copy the
325+
// distro reads over /mnt. Slower to launch from, but always installed.
326+
const resolveMountedAppRoot = Effect.gen(function* () {
327+
const serverTree = yield* wslServerTree.ensure;
328+
if (!serverTree.ok) {
329+
return { ok: false, reason: serverTree.reason, fatal: serverTree.fatal } as const;
330+
}
331+
const windowsEntryPath = environment.path.join(serverTree.root, "apps/server/dist/bin.mjs");
332+
const entryExists = yield* fileSystem
333+
.exists(windowsEntryPath)
334+
.pipe(Effect.orElseSucceed(() => false));
335+
if (!entryExists) {
336+
return {
337+
ok: false,
338+
reason: `missing server entry at ${windowsEntryPath}`,
339+
fatal: true,
340+
} as const;
341+
}
342+
const mountedAppRoot = yield* wslEnv.windowsToWslPath(runningDistro, serverTree.root);
343+
return Option.isNone(mountedAppRoot)
344+
? ({
345+
ok: false,
346+
reason: `wslpath conversion failed for ${serverTree.root}`,
347+
fatal: false,
348+
} as const)
349+
: ({ ok: true, windowsEntryPath, linuxAppRoot: mountedAppRoot.value } as const);
350+
});
351+
352+
// Set once a staged runtime has been ruled out by the probe, and carried
353+
// through the mounted attempt: if the mounted tree works the cache is the
354+
// broken part and gets invalidated, and if the mounted tree returns its own
355+
// fatal verdict the cached reason is the more actionable one to report.
356+
// A transient mounted failure is neither — it rules nothing out, so it stays
357+
// retryable and the staged verdict waits for an attempt that can answer.
358+
let stagedFailure:
359+
| { readonly runtimeId: string; readonly nodePty: FailedNodePtyResult }
360+
| undefined;
361+
362+
if (input.runtimeArchive !== null) {
363+
const runtime = yield* wslEnv.prepareRuntime(runningDistro, input.runtimeArchive);
364+
if (runtime.ok) {
365+
const stagedNodePty = yield* wslEnv.ensureNodePty(
366+
runningDistro,
367+
runtime.linuxAppRoot,
368+
nodePtyOptions,
369+
);
370+
if (stagedNodePty.ok) {
371+
yield* wslServerTree.cleanupLegacy;
372+
return {
373+
_tag: "Ready",
374+
runningDistro,
375+
windowsEntryPath: environment.backendEntryPath,
376+
linuxEntryPath: `${runtime.linuxAppRoot}/apps/server/dist/bin.mjs`,
377+
nodePath: stagedNodePty.nodePath,
378+
resolvedPath: stagedNodePty.resolvedPath,
379+
runtimeId: input.runtimeArchive.runtimeId,
380+
} as const;
381+
}
382+
// A transport failure says nothing about the staged tree, so it is
383+
// retried against the same cache rather than spending a second probe on
384+
// the mounted tree and risking a needless reinstall.
385+
if (!stagedNodePty.fatal) return failedNodePty(stagedNodePty);
386+
yield* Effect.logWarning(
387+
"The staged WSL runtime could not load node-pty; retrying from the mounted server tree.",
388+
{ reason: stagedNodePty.reason },
389+
);
390+
stagedFailure = { runtimeId: input.runtimeArchive.runtimeId, nodePty: stagedNodePty };
391+
} else {
392+
yield* Effect.logWarning(
393+
"Could not stage the WSL runtime; launching from the mounted server tree instead.",
394+
{ reason: runtime.reason },
395+
);
396+
}
301397
}
302398

303-
const linuxEntry = yield* wslEnv.windowsToWslPath(runningDistro, input.windowsEntryPath);
304-
if (Option.isNone(linuxEntry)) {
305-
return {
306-
_tag: "Failed",
307-
reason: `wslpath conversion failed for ${input.windowsEntryPath}`,
308-
fatal: false,
309-
} as const;
399+
const mounted = yield* resolveMountedAppRoot;
400+
if (!mounted.ok) {
401+
return stagedFailure && mounted.fatal
402+
? failedNodePty(stagedFailure.nodePty)
403+
: ({ _tag: "Failed", reason: mounted.reason, fatal: mounted.fatal } as const);
310404
}
311405

312-
const nodePtyResult = yield* wslEnv.ensureNodePty(runningDistro, input.windowsRepoRoot, {
313-
allowBuild: input.allowBuild,
314-
nodeEngineRange: serverPackageJson.engines.node,
315-
});
406+
const nodePtyResult = yield* wslEnv.ensureNodePty(
407+
runningDistro,
408+
mounted.linuxAppRoot,
409+
nodePtyOptions,
410+
);
316411
if (!nodePtyResult.ok) {
317-
return {
318-
_tag: "Failed",
319-
reason: `WSL node-pty unavailable: ${nodePtyResult.reason}`,
320-
fatal: nodePtyResult.fatal,
321-
...(nodePtyResult.retryLimit === undefined ? {} : { retryLimit: nodePtyResult.retryLimit }),
322-
} as const;
412+
// Substituting the staged verdict for a transient mounted failure would
413+
// turn a retryable failure into a fatal one, ending the WSL attempt (and,
414+
// in wsl-only mode, persisting Windows) before the slow /mnt path had a
415+
// chance to answer and clear the bad cache.
416+
return failedNodePty(
417+
stagedFailure && nodePtyResult.fatal ? stagedFailure.nodePty : nodePtyResult,
418+
);
419+
}
420+
421+
// The mounted tree runs what the cache could not, so the cache is the broken
422+
// copy: revoke its ready marker so the next launch reinstalls it instead of
423+
// reusing a tree that has already been proven unloadable.
424+
if (stagedFailure) {
425+
yield* wslEnv.invalidateRuntime(runningDistro, stagedFailure.runtimeId);
323426
}
324427

325428
return {
326429
_tag: "Ready",
327430
runningDistro,
328-
linuxEntryPath: linuxEntry.value,
431+
windowsEntryPath: mounted.windowsEntryPath,
432+
linuxEntryPath: `${mounted.linuxAppRoot}/apps/server/dist/bin.mjs`,
329433
nodePath: nodePtyResult.nodePath,
330434
resolvedPath: nodePtyResult.resolvedPath,
331435
} as const;
@@ -430,7 +534,7 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl
430534
> {
431535
const environment = yield* DesktopEnvironment.DesktopEnvironment;
432536
const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment;
433-
const wslServerTree = yield* DesktopWslServerTree.DesktopWslServerTree;
537+
const fileSystem = yield* FileSystem.FileSystem;
434538

435539
// Bind to 0.0.0.0 inside WSL so the backend is reachable both via
436540
// WSL2's automatic localhost forwarding (wslhost: Windows 127.0.0.1
@@ -467,31 +571,54 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl
467571
...buildObservabilityFragment(input.observabilitySettings),
468572
};
469573

470-
// In packaged builds the server tree ships inside resources/server.asar —
471-
// an archive FILE the Windows primary reads through ELECTRON_RUN_AS_NODE
472-
// (asar-aware). The WSL backend launches plain `wsl.exe -- node`, which
473-
// can't read an asar, so materialize (or reuse) the extracted copy of the
474-
// sidecar before preflighting. In dev the server tree is the real checkout
475-
// directory and ensure returns it unchanged.
476-
const serverTree = yield* wslServerTree.ensure;
477-
const wslAppRoot = serverTree.ok ? serverTree.root : environment.serverRoot;
478-
const wslEntryPath = environment.path.join(wslAppRoot, "apps/server/dist/bin.mjs");
479-
480-
const preflight = serverTree.ok
481-
? yield* runWslPreflight({
482-
distro: input.distro,
483-
windowsEntryPath: wslEntryPath,
484-
windowsRepoRoot: wslAppRoot,
485-
// Packaged builds ship a prebuilt Linux node-pty (built on Linux in CI and
486-
// attached to the Windows artifact — see build-desktop-artifact.ts), so the
487-
// WSL backend never needs a compiler, node-gyp, or network on first launch.
488-
// Compiling from source is a dev-only convenience: a checkout has no shipped
489-
// prebuilt, and developers have the toolchain. In packaged builds we instead
490-
// surface a clear diagnostic if the prebuilt can't load (unsupported
491-
// arch/distro), rather than silently dropping into a fragile runtime build.
492-
allowBuild: !environment.isPackaged,
493-
})
494-
: ({ _tag: "Failed", reason: serverTree.reason, fatal: serverTree.fatal } as const);
574+
// The archive is the primary packaged WSL path: it installs directly into
575+
// the distro's ext4 filesystem. The server.asar extraction service is only
576+
// consulted lazily if the archive is unavailable or cannot be staged.
577+
const archivePath = environment.path.join(environment.resourcesPath, WSL_RUNTIME_ARCHIVE_NAME);
578+
const archiveHashPath = environment.path.join(
579+
environment.resourcesPath,
580+
WSL_RUNTIME_ARCHIVE_HASH_NAME,
581+
);
582+
583+
const hasArchive = environment.isPackaged
584+
? yield* fileSystem.exists(archivePath).pipe(Effect.orElseSucceed(() => false))
585+
: false;
586+
const archiveHash = hasArchive
587+
? yield* fileSystem.readFileString(archiveHashPath).pipe(
588+
Effect.map(parseWslRuntimeArchiveHash),
589+
Effect.orElseSucceed(() => null),
590+
)
591+
: null;
592+
if (hasArchive && archiveHash === null) {
593+
yield* Effect.logWarning(
594+
"Ignoring the WSL runtime archive because its SHA-256 identity is missing or invalid; launching from the mounted server tree instead.",
595+
{ hashPath: archiveHashPath },
596+
);
597+
}
598+
599+
const preflight = yield* runWslPreflight({
600+
distro: input.distro,
601+
runtimeArchive:
602+
archiveHash === null
603+
? null
604+
: {
605+
windowsPath: archivePath,
606+
// The verified archive bytes are the cache identity. Release builds
607+
// embed the release version and pnpm install metadata, so the
608+
// archive changes on every update even when application logic does
609+
// not. Later launches of that update still reuse this directory.
610+
runtimeId: `sha256-${archiveHash}`,
611+
sha256: archiveHash,
612+
},
613+
// Packaged builds ship a prebuilt Linux node-pty (built on Linux in CI and
614+
// attached to the Windows artifact — see build-desktop-artifact.ts), so the
615+
// WSL backend never needs a compiler, node-gyp, or network on first launch.
616+
// Compiling from source is a dev-only convenience: a checkout has no shipped
617+
// prebuilt, and developers have the toolchain. In packaged builds we instead
618+
// surface a clear diagnostic if the prebuilt can't load (unsupported
619+
// arch/distro), rather than silently dropping into a fragile runtime build.
620+
allowBuild: !environment.isPackaged,
621+
});
495622

496623
// Every operation after preflight uses the same concrete distro. In
497624
// default-tracking mode this closes the race where the system default
@@ -537,7 +664,8 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl
537664

538665
const baseConfig = {
539666
executablePath: "wsl.exe",
540-
entryPath: wslEntryPath,
667+
entryPath:
668+
preflight._tag === "Ready" ? preflight.windowsEntryPath : environment.backendEntryPath,
541669
cwd: environment.backendCwd,
542670
env: {
543671
...parentEnvWithoutT3Home,
@@ -605,6 +733,7 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl
605733
...devUrlArgs,
606734
],
607735
preflightFailure: Option.none(),
736+
...(preflight.runtimeId === undefined ? {} : { wslRuntimeId: preflight.runtimeId }),
608737
} satisfies DesktopBackendManager.DesktopBackendStartConfig;
609738
});
610739

0 commit comments

Comments
 (0)