@@ -213,6 +213,7 @@ interface SharedBootstrapInput {
213213interface 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
227230interface WslPreflightFailure {
@@ -236,18 +239,35 @@ interface WslPreflightFailure {
236239}
237240
238241const 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 - 9 a - 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
240256const 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