authorgravatar for ikifar2012@users.noreply.github.comMatheson Steplock <ikifar2012@users.noreply.github.com> 2026-08-28 14:19:21-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-28 23:49:21+05:30
logf94a0d646ed78a4788e4af6417f74202a628a5e9
treeaca9d44cb1be4d37eb65978d4884f893da280674
parent018d7f2775daabd2ef07898af29586915a0b7f67
signature Signed by PGP key B5690EEEBB952194

fix(desktop): Cache Runtime locally on WSL Filesystem, dramatically improving launch times (#5769)

Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: t3-code[bot] <269035359+t3-code[bot]@users.noreply.github.com> Co-authored-by: Utkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com>

14 files changed, 2425 insertions(+), 111 deletions(-)

apps/desktop/src/backend/DesktopBackendConfiguration.test.ts+387-9
......@@ -58,14 +58,16 @@ function makeEnvironmentLayer(
5858 readonly devServerUrl?: string;
5959 readonly platform?: NodeJS.Platform;
6060 readonly resourcesPath?: string;
61 readonly appVersion?: string;
62 readonly processArch?: NodeJS.Architecture;
6163 },
6264) {
6365 return DesktopEnvironment.layer({
6466 dirname: options?.dirname ?? "/repo/apps/desktop/src",
6567 homeDirectory: baseDir,
6668 platform: options?.platform ?? "darwin",
67 processArch: "x64",
68 appVersion: "1.2.3",
69 processArch: options?.processArch ?? "x64",
70 appVersion: options?.appVersion ?? "1.2.3",
6971 appPath: options?.appPath ?? "/repo",
7072 isPackaged: options?.isPackaged ?? true,
7173 resourcesPath: options?.resourcesPath ?? "/missing/resources",
......@@ -123,7 +125,107 @@ const withHarness = <A, E, R>(
123125 );
124126 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer));
125127
128interface PackagedWslHarnessContext {
129 readonly baseDir: string;
130 readonly archivePath: string;
131 readonly hashPath: string;
132 readonly archiveHash: string;
133 readonly mountedAppRoot: string;
134 readonly mountedEntryPath: string;
135}
136
137const withPackagedWslHarness = <A, E, R>(
138 input: {
139 readonly archiveHash: string;
140 readonly wsl: (
141 context: PackagedWslHarnessContext,
142 ) => DesktopWslEnvironment.DesktopWslEnvironmentTestStub;
143 readonly forbidFallback?: string;
144 readonly cleanupLegacy?: Effect.Effect<void>;
145 readonly forbidCleanup?: string;
146 },
147 effect: (
148 context: PackagedWslHarnessContext,
149 ) => Effect.Effect<
150 A,
151 E,
152 R | FileSystem.FileSystem | Path.Path | DesktopBackendConfiguration.DesktopBackendConfiguration
153 >,
154) =>
155 Effect.gen(function* () {
156 const fileSystem = yield* FileSystem.FileSystem;
157 const path = yield* Path.Path;
158 const baseDir = yield* fileSystem.makeTempDirectoryScoped({
159 prefix: "t3-desktop-backend-config-test-",
160 });
161 const archivePath = path.join(baseDir, "wsl-runtime.tar.gz");
162 const hashPath = `${archivePath}.sha256`;
163 const mountedAppRoot = "/mnt/c/app.asar.unpacked";
164 const mountedEntryPath = path.join(baseDir, "app.asar.unpacked/apps/server/dist/bin.mjs");
165 yield* fileSystem.makeDirectory(path.dirname(mountedEntryPath), { recursive: true });
166 yield* fileSystem.writeFileString(mountedEntryPath, "");
167 yield* fileSystem.writeFileString(archivePath, "archive");
168 yield* fileSystem.writeFileString(hashPath, `${input.archiveHash}\n`);
169
170 const context = {
171 baseDir,
172 archivePath,
173 hashPath,
174 archiveHash: input.archiveHash,
175 mountedAppRoot,
176 mountedEntryPath,
177 } satisfies PackagedWslHarnessContext;
178 const serverTreeLayer = input.forbidFallback
179 ? Layer.succeed(
180 DesktopWslServerTree.DesktopWslServerTree,
181 DesktopWslServerTree.DesktopWslServerTree.of({
182 ensure: Effect.die(input.forbidFallback),
183 cleanupLegacy: input.forbidCleanup
184 ? Effect.die(input.forbidCleanup)
185 : (input.cleanupLegacy ?? Effect.void),
186 }),
187 )
188 : DesktopWslServerTree.layerTest({
189 result: { ok: true, root: path.join(baseDir, "app.asar.unpacked") },
190 cleanupLegacy: input.cleanupLegacy ?? Effect.void,
191 });
192
193 return yield* effect(context).pipe(
194 Effect.provide(
195 DesktopBackendConfiguration.layer.pipe(
196 Layer.provideMerge(serverExposureLayer),
197 Layer.provideMerge(DesktopAppSettings.layerTest()),
198 Layer.provideMerge(serverTreeLayer),
199 Layer.provideMerge(
200 DesktopWslEnvironment.layerTest({
201 isAvailable: true,
202 distros: [{ name: "Ubuntu", isDefault: true, version: 2 }],
203 windowsToWslPath: () => Option.some(mountedAppRoot),
204 getDistroIp: () => Option.some("172.27.0.99"),
205 ...input.wsl(context),
206 }),
207 ),
208 Layer.provideMerge(
209 makeEnvironmentLayer(baseDir, {
210 appPath: baseDir,
211 platform: "win32",
212 resourcesPath: baseDir,
213 }),
214 ),
215 ),
216 ),
217 );
218 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer));
219
126220describe("DesktopBackendConfiguration", () => {
221 it("accepts only normalized SHA-256 archive identities", () => {
222 assert.equal(
223 DesktopBackendConfiguration.parseWslRuntimeArchiveHash(` ${"A".repeat(64)}\n`),
224 "a".repeat(64),
225 );
226 assert.isNull(DesktopBackendConfiguration.parseWslRuntimeArchiveHash("abc123"));
227 });
228
127229 it.effect("resolvePrimary produces a stable scoped bootstrap token", () =>
128230 withHarness(
129231 Effect.gen(function* () {
......@@ -158,10 +260,11 @@ describe("DesktopBackendConfiguration", () => {
158260 it.effect("resolvePrimary starts from server.asar without materializing the WSL tree", () =>
159261 Effect.gen(function* () {
160262 const fileSystem = yield* FileSystem.FileSystem;
263 const path = yield* Path.Path;
161264 const baseDir = yield* fileSystem.makeTempDirectoryScoped({
162265 prefix: "t3-desktop-backend-config-test-",
163266 });
164 const resourcesPath = `${baseDir}/resources`;
267 const resourcesPath = path.join(baseDir, "resources");
165268
166269 const config = yield* Effect.gen(function* () {
167270 const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration;
......@@ -177,6 +280,7 @@ describe("DesktopBackendConfiguration", () => {
177280 DesktopWslServerTree.DesktopWslServerTree,
178281 DesktopWslServerTree.DesktopWslServerTree.of({
179282 ensure: Effect.die("Windows primary must not extract the WSL server tree"),
283 cleanupLegacy: Effect.die("Windows primary must not clean the WSL server tree"),
180284 }),
181285 ),
182286 ),
......@@ -191,7 +295,10 @@ describe("DesktopBackendConfiguration", () => {
191295 ),
192296 );
193297
194 assert.equal(config.entryPath, `${resourcesPath}/server.asar/apps/server/dist/bin.mjs`);
298 assert.equal(
299 config.entryPath,
300 path.join(resourcesPath, "server.asar/apps/server/dist/bin.mjs"),
301 );
195302 assert.equal(config.env.ELECTRON_RUN_AS_NODE, "1");
196303 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
197304 );
......@@ -239,7 +346,7 @@ describe("DesktopBackendConfiguration", () => {
239346 ],
240347 windowsToWslPath: (distro) => {
241348 observedDistros.push(distro);
242 return Option.some("/repo/apps/server/dist/bin.mjs");
349 return Option.some("/repo");
243350 },
244351 ensureNodePty: (distro) => {
245352 observedDistros.push(distro);
......@@ -269,6 +376,275 @@ describe("DesktopBackendConfiguration", () => {
269376 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
270377 );
271378
379 it.effect("resolveWsl launches a packaged backend from the WSL-local runtime cache", () => {
380 const observedArchives: Array<{
381 windowsArchivePath: string;
382 runtimeId: string;
383 sha256: string;
384 }> = [];
385 const observedNodePtyRoots: string[] = [];
386 let legacyCleanupCount = 0;
387 const linuxAppRoot = "/home/test/.t3/wsl-runtime/1.2.3-x64";
388
389 return withPackagedWslHarness(
390 {
391 archiveHash: "a".repeat(64),
392 forbidFallback: "A valid WSL archive must not extract the Windows fallback",
393 cleanupLegacy: Effect.sync(() => {
394 legacyCleanupCount += 1;
395 }),
396 wsl: () => ({
397 prepareRuntime: (_distro, archive) => {
398 observedArchives.push({
399 windowsArchivePath: archive.windowsPath,
400 runtimeId: archive.runtimeId,
401 sha256: archive.sha256,
402 });
403 return { ok: true, linuxAppRoot };
404 },
405 ensureNodePty: (_distro, root) => {
406 observedNodePtyRoots.push(root);
407 return { ok: true, nodePath: "/usr/bin/node", resolvedPath: "/usr/bin:/bin" };
408 },
409 }),
410 },
411 ({ archiveHash, archivePath, baseDir }) =>
412 Effect.gen(function* () {
413 const path = yield* Path.Path;
414 const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration;
415 const config = yield* configuration.resolveWsl({ port: 5000, distro: "Ubuntu" });
416
417 assert.deepEqual(observedArchives, [
418 {
419 windowsArchivePath: archivePath,
420 runtimeId: `sha256-${archiveHash}`,
421 sha256: archiveHash,
422 },
423 ]);
424 assert.deepEqual(observedNodePtyRoots, [linuxAppRoot]);
425 assert.equal(
426 config.entryPath,
427 path.join(baseDir, "server.asar/apps/server/dist/bin.mjs"),
428 );
429 assert.include(config.args, `${linuxAppRoot}/apps/server/dist/bin.mjs`);
430 assert.equal(config.wslRuntimeId, `sha256-${archiveHash}`);
431 assert.equal(legacyCleanupCount, 1);
432 assert.isTrue(Option.isNone(config.preflightFailure));
433 }),
434 );
435 });
436
437 it.effect("resolveWsl changes the cache id when the packaged archive changes", () => {
438 const firstHash = "a".repeat(64);
439 const secondHash = "b".repeat(64);
440 const observedRuntimeIds: string[] = [];
441 return withPackagedWslHarness(
442 {
443 archiveHash: firstHash,
444 wsl: () => ({
445 prepareRuntime: (_distro, archive) => {
446 observedRuntimeIds.push(archive.runtimeId);
447 return { ok: true, linuxAppRoot: `/runtime/${archive.runtimeId}` };
448 },
449 ensureNodePty: () => ({
450 ok: true,
451 nodePath: "/usr/bin/node",
452 resolvedPath: "/usr/bin:/bin",
453 }),
454 }),
455 },
456 ({ hashPath, mountedAppRoot }) =>
457 Effect.gen(function* () {
458 const fileSystem = yield* FileSystem.FileSystem;
459 const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration;
460 const first = yield* configuration.resolveWsl({ port: 5000, distro: "Ubuntu" });
461 yield* fileSystem.writeFileString(hashPath, secondHash);
462 const second = yield* configuration.resolveWsl({ port: 5000, distro: "Ubuntu" });
463 yield* fileSystem.writeFileString(hashPath, "not-a-sha256");
464 const invalidIdentity = yield* configuration.resolveWsl({ port: 5000, distro: "Ubuntu" });
465
466 assert.deepEqual(observedRuntimeIds, [`sha256-${firstHash}`, `sha256-${secondHash}`]);
467 assert.equal(first.wslRuntimeId, observedRuntimeIds[0]);
468 assert.equal(second.wslRuntimeId, observedRuntimeIds[1]);
469 assert.isUndefined(invalidIdentity.wslRuntimeId);
470 assert.include(invalidIdentity.args, `${mountedAppRoot}/apps/server/dist/bin.mjs`);
471 }),
472 );
473 });
474
475 it.effect("resolveWsl falls back to the mounted runtime when archive staging fails", () => {
476 const observedNodePtyRoots: string[] = [];
477 return withPackagedWslHarness(
478 {
479 archiveHash: "b".repeat(64),
480 wsl: () => ({
481 prepareRuntime: () => ({ ok: false, reason: "archive is corrupt" }),
482 ensureNodePty: (_distro, root) => {
483 observedNodePtyRoots.push(root);
484 return { ok: true, nodePath: "/usr/bin/node", resolvedPath: "/usr/bin:/bin" };
485 },
486 }),
487 },
488 ({ mountedAppRoot, mountedEntryPath }) =>
489 Effect.gen(function* () {
490 const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration;
491 const config = yield* configuration.resolveWsl({ port: 5000, distro: "Ubuntu" });
492
493 assert.deepEqual(observedNodePtyRoots, [mountedAppRoot]);
494 assert.equal(config.entryPath, mountedEntryPath);
495 assert.include(config.args, `${mountedAppRoot}/apps/server/dist/bin.mjs`);
496 assert.isUndefined(config.wslRuntimeId);
497 assert.isTrue(Option.isNone(config.preflightFailure));
498 }),
499 );
500 });
501
502 it.effect("resolveWsl retires a staged runtime that cannot load node-pty", () => {
503 const archiveHash = "c".repeat(64);
504 const stagedAppRoot = `/home/test/.t3/wsl-runtime/sha256-${archiveHash}`;
505 const observedNodePtyRoots: string[] = [];
506 const invalidatedRuntimeIds: string[] = [];
507 return withPackagedWslHarness(
508 {
509 archiveHash,
510 wsl: () => ({
511 prepareRuntime: () => ({ ok: true, linuxAppRoot: stagedAppRoot }),
512 invalidateRuntime: (_distro, runtimeId) =>
513 Effect.sync(() => {
514 invalidatedRuntimeIds.push(runtimeId);
515 }),
516 ensureNodePty: (_distro, root) => {
517 observedNodePtyRoots.push(root);
518 return root === stagedAppRoot
519 ? { ok: false, reason: "pty.node could not be loaded", fatal: true }
520 : { ok: true, nodePath: "/usr/bin/node", resolvedPath: "/usr/bin:/bin" };
521 },
522 }),
523 },
524 ({ mountedAppRoot, mountedEntryPath }) =>
525 Effect.gen(function* () {
526 const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration;
527 const config = yield* configuration.resolveWsl({ port: 5000, distro: "Ubuntu" });
528
529 assert.deepEqual(observedNodePtyRoots, [stagedAppRoot, mountedAppRoot]);
530 assert.include(config.args, `${mountedAppRoot}/apps/server/dist/bin.mjs`);
531 assert.equal(config.entryPath, mountedEntryPath);
532 assert.isUndefined(config.wslRuntimeId);
533 assert.isTrue(Option.isNone(config.preflightFailure));
534 assert.deepEqual(invalidatedRuntimeIds, [`sha256-${archiveHash}`]);
535 }),
536 );
537 });
538
539 it.effect("resolveWsl keeps the staged runtime when the mounted tree fails too", () => {
540 const stagedAppRoot = "/home/test/.t3/wsl-runtime/cache";
541 const invalidatedRuntimeIds: string[] = [];
542 return withPackagedWslHarness(
543 {
544 archiveHash: "d".repeat(64),
545 wsl: () => ({
546 prepareRuntime: () => ({ ok: true, linuxAppRoot: stagedAppRoot }),
547 invalidateRuntime: (_distro, runtimeId) =>
548 Effect.sync(() => {
549 invalidatedRuntimeIds.push(runtimeId);
550 }),
551 ensureNodePty: (_distro, root) => ({
552 ok: false,
553 reason:
554 root === stagedAppRoot
555 ? "unsupported CPU architecture or incompatible system libraries"
556 : "mounted tree is broken in some other way",
557 fatal: true,
558 }),
559 }),
560 },
561 () =>
562 Effect.gen(function* () {
563 const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration;
564 const config = yield* configuration.resolveWsl({ port: 5000, distro: "Ubuntu" });
565 const failure = Option.getOrThrow(config.preflightFailure);
566
567 assert.isTrue(failure.fatal);
568 assert.include(failure.reason, "unsupported CPU architecture");
569 assert.deepEqual(invalidatedRuntimeIds, []);
570 }),
571 );
572 });
573
574 it.effect("resolveWsl keeps WSL retryable when the mounted fallback fails transiently", () => {
575 const stagedAppRoot = "/home/test/.t3/wsl-runtime/cache";
576 const invalidatedRuntimeIds: string[] = [];
577 return withPackagedWslHarness(
578 {
579 archiveHash: "f".repeat(64),
580 wsl: () => ({
581 prepareRuntime: () => ({ ok: true, linuxAppRoot: stagedAppRoot }),
582 invalidateRuntime: (_distro, runtimeId) =>
583 Effect.sync(() => {
584 invalidatedRuntimeIds.push(runtimeId);
585 }),
586 ensureNodePty: (_distro, root) =>
587 root === stagedAppRoot
588 ? { ok: false, reason: "pty.node could not be loaded", fatal: true }
589 : {
590 ok: false,
591 reason: "WSL backend preflight timed out while probing for Node.js.",
592 fatal: false,
593 },
594 }),
595 },
596 () =>
597 Effect.gen(function* () {
598 const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration;
599 const config = yield* configuration.resolveWsl({ port: 5000, distro: "Ubuntu" });
600 const failure = Option.getOrThrow(config.preflightFailure);
601
602 assert.isFalse(failure.fatal);
603 assert.equal(failure.retryLimit, 12);
604 assert.include(failure.reason, "timed out");
605 assert.deepEqual(invalidatedRuntimeIds, []);
606 }),
607 );
608 });
609
610 it.effect("resolveWsl retries the staged runtime after a transient probe failure", () => {
611 const invalidatedRuntimeIds: string[] = [];
612 return withPackagedWslHarness(
613 {
614 archiveHash: "e".repeat(64),
615 forbidFallback: "A transient probe failure must not extract the fallback",
616 forbidCleanup: "A transient probe failure must not clean the fallback tree",
617 wsl: () => ({
618 prepareRuntime: () => ({
619 ok: true,
620 linuxAppRoot: "/home/test/.t3/wsl-runtime/cache",
621 }),
622 invalidateRuntime: (_distro, runtimeId) =>
623 Effect.sync(() => {
624 invalidatedRuntimeIds.push(runtimeId);
625 }),
626 ensureNodePty: () => ({
627 ok: false,
628 reason: "WSL backend preflight timed out while probing for Node.js.",
629 fatal: false,
630 retryLimit: 12,
631 }),
632 }),
633 },
634 () =>
635 Effect.gen(function* () {
636 const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration;
637 const config = yield* configuration.resolveWsl({ port: 5000, distro: "Ubuntu" });
638 const failure = Option.getOrThrow(config.preflightFailure);
639
640 assert.isFalse(failure.fatal);
641 assert.equal(failure.retryLimit, 12);
642 assert.include(failure.reason, "timed out");
643 assert.deepEqual(invalidatedRuntimeIds, []);
644 }),
645 );
646 });
647
272648 it.effect(
273649 "resolveWsl preserves inherited PATH with quote-sensitive values as separate args",
274650 () =>
......@@ -283,7 +659,8 @@ describe("DesktopBackendConfiguration", () => {
283659 yield* fileSystem.writeFileString(entryPath, "");
284660
285661 const nodePath = "/home/test user's/.nvm/versions/node/v22.0.0/bin/node";
286 const linuxEntryPath = "/tmp/t3 code's launch/entry file.mjs";
662 const linuxAppRoot = "/tmp/t3 code's launch";
663 const linuxEntryPath = `${linuxAppRoot}/apps/server/dist/bin.mjs`;
287664 const resolvedPath = "/home/test user/bin:/opt/test's tools/bin:/usr/bin:/bin";
288665 const devServerUrl = "http://127.0.0.1:5733/dev%20assets/?label=hello%20world";
289666 const config = yield* Effect.gen(function* () {
......@@ -299,7 +676,7 @@ describe("DesktopBackendConfiguration", () => {
299676 DesktopWslEnvironment.layerTest({
300677 isAvailable: true,
301678 distros: [{ name: "Ubuntu", isDefault: true, version: 2 }],
302 windowsToWslPath: () => Option.some(linuxEntryPath),
679 windowsToWslPath: () => Option.some(linuxAppRoot),
303680 ensureNodePty: () => ({ ok: true, nodePath, resolvedPath }),
304681 getDistroIp: () => Option.some("172.27.0.99"),
305682 }),
......@@ -815,13 +1192,14 @@ describe("DesktopBackendConfiguration", () => {
8151192 it.effect("prefers the external packaged resource monitor over the copy inside the asar", () =>
8161193 Effect.gen(function* () {
8171194 const fileSystem = yield* FileSystem.FileSystem;
1195 const path = yield* Path.Path;
8181196 const baseDir = yield* fileSystem.makeTempDirectoryScoped({
8191197 prefix: "t3-desktop-backend-config-test-",
8201198 });
821 const resourcesPath = `${baseDir}/resources`;
1199 const resourcesPath = path.join(baseDir, "resources");
8221200 const dirname = `${resourcesPath}/app.asar/apps/desktop/dist-electron`;
8231201 const embeddedMonitorPath = `${resourcesPath}/app.asar/apps/desktop/prod-resources/resource-monitor/t3-resource-monitor`;
824 const monitorPath = `${resourcesPath}/resource-monitor/t3-resource-monitor`;
1202 const monitorPath = path.join(resourcesPath, "resource-monitor/t3-resource-monitor");
8251203 yield* fileSystem.makeDirectory(
8261204 `${resourcesPath}/app.asar/apps/desktop/prod-resources/resource-monitor`,
8271205 { recursive: true },
apps/desktop/src/backend/DesktopBackendConfiguration.ts+185-56
......@@ -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;
242const WSL_RUNTIME_ARCHIVE_NAME = "wsl-runtime.tar.gz";
243const WSL_RUNTIME_ARCHIVE_HASH_NAME = `${WSL_RUNTIME_ARCHIVE_NAME}.sha256`;
244const SHA256_HEX_PATTERN = /^[0-9a-f]{64}$/i;
245
246export const parseWslRuntimeArchiveHash = (value: string): string | null => {
247 const trimmed = value.trim();
248 return SHA256_HEX_PATTERN.test(trimmed) ? trimmed.toLowerCase() : null;
249};
250
251type 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
apps/desktop/src/backend/DesktopBackendManager.test.ts+27-3
......@@ -25,6 +25,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
2525import * as DesktopBackendManager from "./DesktopBackendManager.ts";
2626import * as DesktopObservability from "../app/DesktopObservability.ts";
2727import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts";
28import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts";
2829
2930const decodeDesktopBackendBootstrap = Schema.decodeEffect(
3031 Schema.fromJsonString(DesktopBackendBootstrap),
......@@ -132,6 +133,7 @@ interface MakeInstanceInput {
132133 readonly desktopTelemetryPublisher?: Partial<
133134 DesktopTelemetryPublisher.DesktopTelemetryPublisher["Service"]
134135 >;
136 readonly pruneRuntimes?: (distro: string | null, runtimeId: string) => Effect.Effect<void>;
135137}
136138
137139// Helper that constructs a primary backend instance using the factory
......@@ -167,6 +169,9 @@ function makeTestInstance(input: MakeInstanceInput) {
167169 removeControlSource: () => Effect.void,
168170 ...input.desktopTelemetryPublisher,
169171 }),
172 DesktopWslEnvironment.layerTest(
173 input.pruneRuntimes === undefined ? {} : { pruneRuntimes: input.pruneRuntimes },
174 ),
170175 );
171176
172177 const instance = DesktopBackendManager.makeBackendInstance({
......@@ -647,10 +652,13 @@ describe("DesktopBackendManager", () => {
647652 Effect.scoped(
648653 Effect.gen(function* () {
649654 const requestUrls: Array<string> = [];
655 const prunedRuntimes: Array<[string | null, string]> = [];
650656 const statuses = [503, 200];
651657 let readyCount = 0;
652658 const firstRequest = yield* Deferred.make<void>();
653 const ready = yield* Deferred.make<void>();
659 const backendReady = yield* Deferred.make<void>();
660 const processExit = yield* Deferred.make<void>();
661 const pruneComplete = yield* Deferred.make<void>();
654662 const exited = yield* Queue.unbounded<void>();
655663
656664 const spawnerLayer = Layer.succeed(
......@@ -658,7 +666,9 @@ describe("DesktopBackendManager", () => {
658666 ChildProcessSpawner.make(() =>
659667 Effect.succeed(
660668 makeProcess({
661 exitCode: Deferred.await(ready).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))),
669 exitCode: Deferred.await(processExit).pipe(
670 Effect.as(ChildProcessSpawner.ExitCode(0)),
671 ),
662672 }),
663673 ),
664674 ),
......@@ -666,6 +676,15 @@ describe("DesktopBackendManager", () => {
666676
667677 const instance = yield* makeTestInstance({
668678 spawnerLayer,
679 config: {
680 ...baseConfig,
681 runningDistro: "Ubuntu",
682 wslRuntimeId: "1.2.3-x64",
683 },
684 pruneRuntimes: (distro, runtimeId) =>
685 Effect.sync(() => {
686 prunedRuntimes.push([distro, runtimeId]);
687 }).pipe(Effect.andThen(Deferred.succeed(pruneComplete, void 0)), Effect.asVoid),
669688 httpClientLayer: httpClientLayer((request) =>
670689 Effect.gen(function* () {
671690 const status = statuses.shift();
......@@ -677,7 +696,7 @@ describe("DesktopBackendManager", () => {
677696 ),
678697 onReady: Effect.sync(() => {
679698 readyCount += 1;
680 }).pipe(Effect.andThen(Deferred.succeed(ready, void 0)), Effect.asVoid),
699 }).pipe(Effect.andThen(Deferred.succeed(backendReady, void 0)), Effect.asVoid),
681700 backendOutputLog: {
682701 persistFailure: () => Queue.offer(exited, void 0).pipe(Effect.asVoid),
683702 },
......@@ -687,12 +706,17 @@ describe("DesktopBackendManager", () => {
687706 yield* Deferred.await(firstRequest);
688707
689708 assert.equal(readyCount, 0);
709 assert.deepEqual(prunedRuntimes, []);
690710 assert.deepEqual(requestUrls, ["http://127.0.0.1:3773/.well-known/t3/environment"]);
691711
692712 yield* TestClock.adjust(Duration.millis(100));
713 yield* Deferred.await(backendReady);
714 yield* Deferred.await(pruneComplete);
715 yield* Deferred.succeed(processExit, void 0);
693716 yield* Queue.take(exited);
694717
695718 assert.equal(readyCount, 1);
719 assert.deepEqual(prunedRuntimes, [["Ubuntu", "1.2.3-x64"]]);
696720 assert.deepEqual(requestUrls, [
697721 "http://127.0.0.1:3773/.well-known/t3/environment",
698722 "http://127.0.0.1:3773/.well-known/t3/environment",
apps/desktop/src/backend/DesktopBackendManager.ts+16
......@@ -52,6 +52,7 @@ import { waitForHttpReady as waitForHttpReadyShared } from "@t3tools/shared/http
5252
5353import * as DesktopObservability from "../app/DesktopObservability.ts";
5454import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts";
55import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts";
5556
5657const INITIAL_RESTART_DELAY = Duration.millis(500);
5758const MAX_RESTART_DELAY = Duration.seconds(10);
......@@ -99,6 +100,10 @@ export interface DesktopBackendStartConfig extends BackendProcessContext {
99100 // Present for a WSL run after the configured/default distro has been
100101 // resolved to the concrete distro passed to wsl.exe.
101102 readonly runningDistro?: string;
103 // Present only when this run launched from a staged WSL-local runtime.
104 // Once HTTP readiness succeeds, the manager uses it to retain this cache
105 // plus the newest previous cache and prune older versions.
106 readonly wslRuntimeId?: string;
102107}
103108
104109// A preflight failure records whether it is fatal. Transient failures (WSL
......@@ -637,6 +642,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* (
637642 | HttpClient.HttpClient
638643 | DesktopObservability.DesktopBackendOutputLogFactory
639644 | DesktopTelemetryPublisher.DesktopTelemetryPublisher
645 | DesktopWslEnvironment.DesktopWslEnvironment
640646 | Scope.Scope
641647> {
642648 const parentScope = yield* Scope.Scope;
......@@ -644,6 +650,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* (
644650 const backendOutputLogFactory = yield* DesktopObservability.DesktopBackendOutputLogFactory;
645651 const backendOutputLog = yield* backendOutputLogFactory.forInstance(spec.id);
646652 const desktopTelemetryPublisher = yield* DesktopTelemetryPublisher.DesktopTelemetryPublisher;
653 const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment;
647654 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
648655 const httpClient = yield* HttpClient.HttpClient;
649656 const state = yield* Ref.make(initialState);
......@@ -939,6 +946,15 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* (
939946 }
940947
941948 yield* spec.onReady?.(config.value.httpBaseUrl) ?? Effect.void;
949 if (
950 config.value.runningDistro !== undefined &&
951 config.value.wslRuntimeId !== undefined
952 ) {
953 yield* wslEnvironment.pruneRuntimes(
954 config.value.runningDistro,
955 config.value.wslRuntimeId,
956 );
957 }
942958 }),
943959 onReadinessFailure: Effect.fn("desktop.backendInstance.onReadinessFailure")(
944960 function* (error) {
apps/desktop/src/backend/DesktopBackendPool.test.ts+2
......@@ -14,6 +14,7 @@ import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
1414import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts";
1515import * as ElectronDialog from "../electron/ElectronDialog.ts";
1616import * as DesktopWindow from "../window/DesktopWindow.ts";
17import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts";
1718import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts";
1819import * as DesktopBackendPool from "./DesktopBackendPool.ts";
1920import type { DesktopBackendSnapshot, DesktopBackendStartConfig } from "./DesktopBackendManager.ts";
......@@ -79,6 +80,7 @@ function makePoolLayer(
7980 resolveWsl: () => Effect.die("unexpected WSL config resolve"),
8081 } satisfies DesktopBackendConfiguration.DesktopBackendConfiguration["Service"]),
8182 DesktopAppSettings.layerTest(),
83 DesktopWslEnvironment.layerTest(),
8284 ElectronDialog.layer,
8385 Layer.succeed(DesktopWindow.DesktopWindow, {
8486 createMain: Effect.die("unexpected window create"),
apps/desktop/src/backend/DesktopBackendPool.ts+3-1
......@@ -99,6 +99,7 @@ import * as DesktopObservability from "../app/DesktopObservability.ts";
9999import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
100100import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts";
101101import * as DesktopWindow from "../window/DesktopWindow.ts";
102import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts";
102103import * as ElectronDialog from "../electron/ElectronDialog.ts";
103104
104105const { logWarning: logBackendPoolWarning } =
......@@ -178,7 +179,8 @@ export type BackendInstanceFactoryRequirements =
178179 | ChildProcessSpawner.ChildProcessSpawner
179180 | HttpClient.HttpClient
180181 | DesktopObservability.DesktopBackendOutputLogFactory
181 | DesktopTelemetryPublisher.DesktopTelemetryPublisher;
182 | DesktopTelemetryPublisher.DesktopTelemetryPublisher
183 | DesktopWslEnvironment.DesktopWslEnvironment;
182184
183185interface ActiveRegisteredInstance {
184186 readonly _tag: "Active";
apps/desktop/src/wsl/DesktopWslEnvironment.test.ts+623-1
......@@ -1,5 +1,7 @@
1// @effect-diagnostics nodeBuiltinImport:off - the executed suite runs the generated install script through a real POSIX shell.
12import { describe, it } from "@effect/vitest";
2import { expect } from "vite-plus/test";
3import { afterAll, expect } from "vite-plus/test";
4import * as NodeChildProcess from "node:child_process";
35import * as Duration from "effect/Duration";
46import * as Effect from "effect/Effect";
57import * as Fiber from "effect/Fiber";
......@@ -11,6 +13,9 @@ import { ChildProcessSpawner } from "effect/unstable/process";
1113
1214import {
1315 buildWslNodeEnvPreamble,
16 buildWslRuntimeInstallScript,
17 buildWslRuntimeInvalidateScript,
18 buildWslRuntimePruneScript,
1419 DesktopWslDistroListError,
1520 formatMissingToolsReason,
1621 formatNodePtyProbeFailureReason,
......@@ -19,11 +24,62 @@ import {
1924 parseNodeVersion,
2025 parseResolvedPath,
2126 parseToolchainReport,
27 parseWslRuntimeRoot,
2228 probeWslDistros,
29 sanitizeWslRuntimeId,
2330} from "./DesktopWslEnvironment.ts";
2431
2532const encoder = new TextEncoder();
2633
34// The install script only fails the way this file cares about when a real shell
35// runs it, so find one that has the tools it needs: bash directly on Linux, and
36// the WSL distro on a Windows dev box, where Git Bash ships no flock. Anywhere
37// else the executed suite skips and the generated-text assertions stand alone.
38const REQUIRED_SHELL_TOOLS = ["flock", "sha256sum", "tar", "mktemp"] as const;
39
40const posixShellRunner = (() => {
41 // Candidates rather than a platform switch: wsl.exe simply fails to spawn
42 // where it does not exist, which is the same answer as a shell missing flock.
43 const candidates = [
44 { file: "bash", args: [] as ReadonlyArray<string> },
45 { file: "wsl.exe", args: ["-e", "bash"] as ReadonlyArray<string> },
46 ];
47 const probe = [
48 "[ -d /proc/1 ] || exit 1",
49 ...REQUIRED_SHELL_TOOLS.map((tool) => `command -v ${tool} >/dev/null || exit 1`),
50 ].join("\n");
51 return (
52 candidates.find((candidate) => {
53 const result = NodeChildProcess.spawnSync(candidate.file, [...candidate.args, "-c", probe], {
54 encoding: "utf8",
55 });
56 return result.status === 0;
57 }) ?? null
58 );
59})();
60
61const runShell = (script: string) => {
62 if (posixShellRunner === null) throw new Error("no POSIX shell runner available");
63 // The install script arrives on stdin in production too, which is what lets
64 // its own /proc scan not match itself.
65 const result = NodeChildProcess.spawnSync(
66 posixShellRunner.file,
67 [...posixShellRunner.args, "-s"],
68 { input: script, encoding: "utf8" },
69 );
70 return { status: result.status, stdout: result.stdout ?? "", stderr: result.stderr ?? "" };
71};
72
73const sh = (value: string) => `'${value.replaceAll("'", "'\\''")}'`;
74
75const readField = (stdout: string, field: string) => {
76 const line = stdout.split("\n").find((candidate) => candidate.startsWith(`${field}:`));
77 if (line === undefined) throw new Error(`missing ${field} in fixture output: ${stdout}`);
78 return line.slice(field.length + 1).trim();
79};
80
81const SERVER_ENTRY_SOURCE = 'console.log("t3code wsl runtime test server");';
82
2783const makeDistroListSpawner = (result: { readonly stdout?: string; readonly exitCode?: number }) =>
2884 ChildProcessSpawner.make(() =>
2985 Effect.succeed(
......@@ -125,6 +181,572 @@ describe("buildWslNodeEnvPreamble", () => {
125181 });
126182});
127183
184describe("WSL runtime cache", () => {
185 it("sanitizes cache ids before interpolating them into Linux paths", () => {
186 expect(sanitizeWslRuntimeId("1.2.3/x64; touch /tmp/nope")).toBe("1.2.3_x64__touch__tmp_nope");
187 });
188
189 it("installs through a temporary directory and only reuses valid completed caches", () => {
190 const script = buildWslRuntimeInstallScript(
191 "/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz",
192 "1.2.3-x64",
193 "b".repeat(64),
194 );
195
196 expect(script).toContain('runtime_parent="$HOME/.t3/wsl-runtime"');
197 expect(script).toContain(' [ -f "$ready_marker" ] &&');
198 expect(script).toContain(' [ -f "$runtime_root/apps/server/dist/bin.mjs" ] &&');
199 expect(script).toContain(' [ -f "$runtime_root/node_modules/node-pty/package.json" ] &&');
200 expect(script).toContain(' node_pty_payload_present "$runtime_root"');
201 expect(script).not.toContain("node_modules/effect/package.json");
202 expect(script).toContain("if runtime_is_ready; then");
203 expect(script).toContain("trap 'exit 1' HUP INT TERM");
204 expect(script).toContain('exec 9> "$runtime_lock"');
205 expect(script).toContain("flock -x 9");
206 expect(script).not.toContain("runtime_lock_pid");
207 expect(script).not.toContain("sleep 0.1");
208 expect(script).not.toContain('rm -rf "$runtime_lock"');
209 expect(script).toContain('mv -T "$runtime_root" "$runtime_stale"');
210 expect(script).toContain('mktemp -d "$runtime_parent/.1.2.3-x64.tmp.XXXXXX"');
211 expect(script).toContain(
212 "tar -xzf '/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz' -C \"$runtime_tmp\"",
213 );
214 expect(script).toContain('test -f "$runtime_tmp/apps/server/dist/bin.mjs"');
215 expect(script).toContain('test -f "$runtime_tmp/node_modules/node-pty/package.json"');
216 expect(script).toContain('mv -T "$runtime_tmp" "$runtime_root"');
217 expect(script).not.toContain('rm -rf "$runtime_root"');
218
219 const lockAcquired = script.indexOf("flock -x 9");
220 const readinessAfterLock = script.indexOf("if runtime_is_ready; then", lockAcquired + 1);
221 const existingRuntimeMoved = script.indexOf('mv -T "$runtime_root" "$runtime_stale"');
222 expect(lockAcquired).toBeGreaterThan(-1);
223 expect(readinessAfterLock).toBeGreaterThan(lockAcquired);
224 expect(existingRuntimeMoved).toBeGreaterThan(readinessAfterLock);
225 });
226
227 it("verifies the archive digest before extracting, and only on a cache miss", () => {
228 const script = buildWslRuntimeInstallScript(
229 "/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz",
230 "1.2.3-x64",
231 "b".repeat(64),
232 );
233
234 const expected = "b".repeat(64);
235 expect(script).toContain(
236 "archive_sha=$(sha256sum '/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz' | cut -d ' ' -f 1)",
237 );
238 expect(script).toContain(`if [ "$archive_sha" != '${expected}' ]; then`);
239
240 // A warm cache exits before the hash, so reuse never pays for it, and the
241 // mismatch check runs before anything mutates the cache.
242 const readyShortCircuit = script.indexOf("if runtime_is_ready; then");
243 const digestChecked = script.indexOf("archive_sha=$(sha256sum");
244 const existingRuntimeMoved = script.indexOf('mv -T "$runtime_root" "$runtime_stale"');
245 const extracted = script.indexOf("tar -xzf");
246 expect(digestChecked).toBeGreaterThan(readyShortCircuit);
247 expect(existingRuntimeMoved).toBeGreaterThan(digestChecked);
248 expect(extracted).toBeGreaterThan(digestChecked);
249 });
250
251 // Invalidation revokes the ready marker without stopping the backend that
252 // failed the probe, so the next install can find an unready tree that a live
253 // process is still running out of. Deleting it there unlinks node_modules
254 // under that process; the pruner already refuses to touch in-use caches, and
255 // the install path has to refuse too.
256 it("moves an in-use runtime aside instead of deleting it under a live backend", () => {
257 const script = buildWslRuntimeInstallScript(
258 "/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz",
259 "sha256-" + "c".repeat(64),
260 "b".repeat(64),
261 );
262
263 expect(script).toContain('grep -qF -- "$1/" /proc/[0-9]*/cmdline 2>/dev/null');
264 // No /proc means no way to tell, and guessing wrong costs a backend its
265 // runtime, so an unknowable answer has to count as in use.
266 expect(script).toContain(" [ -d /proc/1 ] || return 0");
267 expect(script).toContain(' if runtime_in_use "$runtime_root"; then');
268
269 // A process's cmdline keeps the pre-rename path, so the question is only
270 // answerable before the move.
271 const inUseChecked = script.indexOf('if runtime_in_use "$runtime_root"; then');
272 const moved = script.indexOf('mv -T "$runtime_root" "$runtime_stale"');
273 expect(inUseChecked).toBeGreaterThan(-1);
274 expect(inUseChecked).toBeLessThan(moved);
275
276 // In use: keep the tree and restart the sweep's clock, because renaming
277 // preserves the directory's mtime and a long-installed tree would otherwise
278 // already be past the age gate. Idle: delete it now, as before.
279 const kept = script.indexOf('touch "$runtime_stale"');
280 const deleted = script.indexOf('rm -rf "$runtime_stale"');
281 expect(kept).toBeGreaterThan(moved);
282 expect(deleted).toBeGreaterThan(kept);
283 });
284
285 it("treats a runtime whose native payload went missing as a cache miss", () => {
286 const script = buildWslRuntimeInstallScript(
287 "/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz",
288 "1.2.3-x64",
289 "b".repeat(64),
290 );
291
292 // A glob, not a mapped `uname -m`: this is a presence check, and the later
293 // native probe is what judges arch and loadability.
294 expect(script).toContain(
295 ' for candidate in "$1"/node_modules/node-pty/prebuilds/linux-*/pty.node; do',
296 );
297 // The marker the probe reads must sit beside the binary, or the runtime is
298 // just as unusable as one missing pty.node outright.
299 expect(script).toContain(' [ -f "${candidate%/*}/t3code-wsl-node-pty.json" ] || continue');
300
301 // Readiness gates the short-circuit, so a cache missing the payload
302 // reinstalls from the archive instead of being reused forever.
303 const payloadCheckDefined = script.indexOf("node_pty_payload_present() {");
304 const readinessDefined = script.indexOf("runtime_is_ready() {");
305 const readyShortCircuit = script.indexOf("if runtime_is_ready; then");
306 expect(payloadCheckDefined).toBeGreaterThan(-1);
307 expect(payloadCheckDefined).toBeLessThan(readinessDefined);
308 expect(readinessDefined).toBeLessThan(readyShortCircuit);
309 });
310
311 // A truncated or half-written bin.mjs passes every presence check the cache
312 // had: the file exists, node-pty still loads, and launch then picks a server
313 // that exits before it becomes ready — forever, because nothing ever
314 // reinstalls. The digest the install records is what turns that into a miss.
315 it("re-hashes the server entry against the digest the install recorded", () => {
316 const script = buildWslRuntimeInstallScript(
317 "/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz",
318 "1.2.3-x64",
319 "b".repeat(64),
320 );
321
322 expect(script).toContain(
323 ` sha256sum "$1/apps/server/dist/bin.mjs" 2>/dev/null | cut -d ' ' -f 1`,
324 );
325 expect(script).toContain(
326 ' [ "$recorded_entry_digest" = "$(runtime_server_entry_digest "$runtime_root")" ]',
327 );
328 // A runtime installed before the marker carried a digest reads as empty,
329 // which has to be a miss rather than a pass.
330 expect(script).toContain(' [ -n "$recorded_entry_digest" ] &&');
331 expect(script).toContain(
332 `printf '%s\\n' "$installed_entry_digest" > "$runtime_tmp/.t3code-wsl-runtime-ready"`,
333 );
334
335 // The digest is recorded after extraction and before promotion.
336 const extracted = script.indexOf("tar -xzf");
337 const digestRecorded = script.indexOf(
338 'installed_entry_digest=$(runtime_server_entry_digest "$runtime_tmp")',
339 );
340 const markerWritten = script.indexOf('> "$runtime_tmp/.t3code-wsl-runtime-ready"');
341 const promoted = script.indexOf('mv -T "$runtime_tmp" "$runtime_root"');
342 expect(digestRecorded).toBeGreaterThan(extracted);
343 expect(markerWritten).toBeGreaterThan(digestRecorded);
344 expect(promoted).toBeGreaterThan(markerWritten);
345 });
346
347 it("refuses to mark an archive without a native payload as ready", () => {
348 const script = buildWslRuntimeInstallScript(
349 "/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz",
350 "1.2.3-x64",
351 "b".repeat(64),
352 );
353
354 expect(script).toContain('if ! node_pty_payload_present "$runtime_tmp"; then');
355
356 // The extracted tree is rejected before the ready marker is written, so a
357 // defective archive falls back to the mounted tree instead of caching.
358 const payloadValidated = script.indexOf('node_pty_payload_present "$runtime_tmp"');
359 const markerWritten = script.indexOf('> "$runtime_tmp/.t3code-wsl-runtime-ready"');
360 const promoted = script.indexOf('mv -T "$runtime_tmp" "$runtime_root"');
361 expect(payloadValidated).toBeGreaterThan(-1);
362 expect(markerWritten).toBeGreaterThan(payloadValidated);
363 expect(promoted).toBeGreaterThan(payloadValidated);
364 });
365
366 it("parses only absolute Linux runtime paths", () => {
367 expect(parseWslRuntimeRoot("runtimeRoot:/home/josh/.t3/wsl-runtime/1.2.3-x64\n")).toBe(
368 "/home/josh/.t3/wsl-runtime/1.2.3-x64",
369 );
370 expect(parseWslRuntimeRoot("runtimeRoot:relative/path\n")).toBeNull();
371 expect(parseWslRuntimeRoot("noise\n")).toBeNull();
372 });
373
374 it("prunes completed runtimes except the current and newest previous cache", () => {
375 const script = buildWslRuntimePruneScript("1.2.3/x64");
376
377 expect(script).toContain('current_runtime="$runtime_parent/1.2.3_x64"');
378 expect(script).toContain('[ "$candidate" -nt "$previous_runtime" ]');
379 expect(script).toContain('[ "$candidate" != "$current_runtime" ] || continue');
380 expect(script).toContain('[ "$candidate" != "$previous_runtime" ] || continue');
381 expect(script).toContain('[ -f "$candidate/.t3code-wsl-runtime-ready" ] || continue');
382 expect(script).toContain('rm -rf -- "$candidate"');
383 });
384
385 it("never deletes a runtime another backend is running from", () => {
386 const script = buildWslRuntimePruneScript("1.2.3/x64");
387
388 // The running backend's argv holds `<runtime>/apps/server/dist/bin.mjs`, so
389 // the process itself is the lease and exiting releases it. Nothing has to be
390 // registered up front, which is what makes this cover backends already
391 // running from an older version that knows nothing about pruning.
392 expect(script).toContain(' grep -qF -- "$1/" /proc/[0-9]*/cmdline 2>/dev/null');
393 expect(script).toContain(' ! runtime_in_use "$candidate" || continue');
394
395 // Without visible processes the retention rules cannot tell a live cache
396 // from an abandoned one, so the sweep is skipped rather than guessed at.
397 expect(script).toContain("[ -d /proc/1 ] || exit 0");
398
399 // The guard has to gate the delete, not just exist.
400 const inUseChecked = script.indexOf('! runtime_in_use "$candidate"');
401 const removed = script.indexOf('rm -rf -- "$candidate"');
402 expect(inUseChecked).toBeGreaterThan(-1);
403 expect(removed).toBeGreaterThan(inUseChecked);
404 });
405
406 it("sweeps orphaned install scratch directories the ready-marker loops cannot see", () => {
407 const script = buildWslRuntimePruneScript("1.2.3/x64");
408
409 // Dot-prefixed, so `"$runtime_parent"/*` never matches them, and they carry
410 // no ready marker either; without this pass a killed install leaks forever.
411 expect(script).toContain(
412 'for scratch in "$runtime_parent"/.*.tmp.* "$runtime_parent"/.*.stale.*; do',
413 );
414 // Age guard: a scratch directory younger than this belongs to a live install.
415 expect(script).toContain('find "$scratch" -maxdepth 0 -mmin +120');
416 });
417
418 it("invalidates a cache by dropping its ready marker, not the tree", () => {
419 const script = buildWslRuntimeInvalidateScript("1.2.3/x64");
420
421 // Readiness is a presence check, so a tree whose pty.node is present but
422 // unloadable stays ready forever unless the probe can revoke the marker.
423 expect(script).toContain('rm -f "$HOME/.t3/wsl-runtime/1.2.3_x64/.t3code-wsl-runtime-ready"');
424 // Deleting the tree here would pull it out from under any backend still
425 // running from it; the next install moves an unready root aside instead.
426 expect(script).not.toContain("rm -rf");
427 });
428});
429
430// Reading the generated script proves what it says, not what it does. A cache
431// whose bin.mjs was truncated satisfied every assertion above and still got
432// reused, so these run the real script against a real archive in a throwaway
433// HOME and check the outcome.
434describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed)", () => {
435 const fixtures: Array<string> = [];
436
437 afterAll(() => {
438 for (const work of fixtures) runShell(`set -eu\nrm -rf ${sh(work)}`);
439 fixtures.length = 0;
440 });
441
442 const createFixture = () => {
443 const result = runShell(
444 [
445 "set -eu",
446 "work=$(mktemp -d)",
447 'stage="$work/stage"',
448 'mkdir -p "$stage/apps/server/dist" "$stage/node_modules/node-pty/prebuilds/linux-x64" "$work/home"',
449 `printf '%s' ${sh(SERVER_ENTRY_SOURCE)} > "$stage/apps/server/dist/bin.mjs"`,
450 `printf '%s' '{"name":"node-pty","version":"0.0.0-test"}' > "$stage/node_modules/node-pty/package.json"`,
451 `printf '%s' 'pty-native-payload' > "$stage/node_modules/node-pty/prebuilds/linux-x64/pty.node"`,
452 `printf '%s' '{"arch":"x64"}' > "$stage/node_modules/node-pty/prebuilds/linux-x64/t3code-wsl-node-pty.json"`,
453 `tar -czf "$work/wsl-runtime.tar.gz" -C "$stage" apps/server/dist node_modules`,
454 `printf 'work:%s\\n' "$work"`,
455 `printf 'archiveSha:%s\\n' "$(sha256sum "$work/wsl-runtime.tar.gz" | cut -d ' ' -f 1)"`,
456 ].join("\n"),
457 );
458 expect(result.status, result.stderr).toBe(0);
459
460 const work = readField(result.stdout, "work");
461 fixtures.push(work);
462 const archivePath = `${work}/wsl-runtime.tar.gz`;
463 const archiveSha = readField(result.stdout, "archiveSha");
464 const runtimeId = `sha256-${archiveSha}`;
465 // The script reads $HOME, and WSL does not inherit the parent process's
466 // environment, so the home override rides in the script itself.
467 const installScript = (archive = archivePath, sha = archiveSha) =>
468 [
469 `HOME=${sh(`${work}/home`)}`,
470 "export HOME",
471 buildWslRuntimeInstallScript(archive, runtimeId, sha),
472 ].join("\n");
473 return {
474 work,
475 archivePath,
476 archiveSha,
477 runtimeId,
478 runtimeParent: `${work}/home/.t3/wsl-runtime`,
479 runtimeRoot: `${work}/home/.t3/wsl-runtime/${runtimeId}`,
480 serverEntry: `${work}/home/.t3/wsl-runtime/${runtimeId}/apps/server/dist/bin.mjs`,
481 installScript,
482 install: (archive?: string, sha?: string) => runShell(installScript(archive, sha)),
483 };
484 };
485
486 it("reuses a warm cache without touching the archive", () => {
487 const fixture = createFixture();
488 expect(fixture.install().status).toBe(0);
489 // Deleting the archive is how the test tells reuse apart from a silent
490 // reinstall: only the warm path can succeed without it.
491 expect(runShell(`set -eu\nrm ${sh(fixture.archivePath)}`).status).toBe(0);
492
493 const warm = fixture.install();
494
495 expect(warm.status, warm.stderr).toBe(0);
496 expect(parseWslRuntimeRoot(warm.stdout)).toBe(fixture.runtimeRoot);
497 });
498
499 it("reinstalls a cache whose server entry was truncated", () => {
500 const fixture = createFixture();
501 expect(fixture.install().status).toBe(0);
502 expect(runShell(`set -eu\n: > ${sh(fixture.serverEntry)}`).status).toBe(0);
503
504 const repaired = fixture.install();
505
506 expect(repaired.status, repaired.stderr).toBe(0);
507 expect(parseWslRuntimeRoot(repaired.stdout)).toBe(fixture.runtimeRoot);
508 const restored = runShell(`set -eu\ncat ${sh(fixture.serverEntry)}`);
509 expect(restored.stdout).toBe(SERVER_ENTRY_SOURCE);
510 });
511
512 it("falls back instead of launching a corrupted cache it cannot reinstall", () => {
513 const fixture = createFixture();
514 expect(fixture.install().status).toBe(0);
515 expect(runShell(`set -eu\n: > ${sh(fixture.serverEntry)}`).status).toBe(0);
516 expect(runShell(`set -eu\nrm ${sh(fixture.archivePath)}`).status).toBe(0);
517
518 const broken = fixture.install();
519
520 // Non-zero with no runtimeRoot is what sends the backend to the mounted
521 // server tree. Exiting 0 here is the bug: launch would pick the zero-byte
522 // server, fail to become ready, and do it again on every restart.
523 expect(broken.status).not.toBe(0);
524 expect(parseWslRuntimeRoot(broken.stdout)).toBeNull();
525 });
526
527 it("extracts once when two installs race for the same cache", () => {
528 const fixture = createFixture();
529 // A tar shim counts extractions and holds the critical section open long
530 // enough that the second install is certain to arrive while the first is
531 // still inside it. One extraction is the answer either way the runs
532 // interleave: whoever waits for the lock re-checks readiness before
533 // spending an extract, so a broken lock shows up as two.
534 const raced = runShell(
535 [
536 "set -eu",
537 `work=${sh(fixture.work)}`,
538 'mkdir -p "$work/bin"',
539 "real_tar=$(command -v tar)",
540 `printf '#!/bin/sh\\nprintf x >> "%s/tar-calls"\\nsleep 1\\nexec %s "$@"\\n' "$work" "$real_tar" > "$work/bin/tar"`,
541 'chmod +x "$work/bin/tar"',
542 ': > "$work/tar-calls"',
543 'PATH="$work/bin:$PATH"',
544 "export PATH",
545 `cat > "$work/install.sh" <<'T3CODE_INSTALL_SCRIPT'`,
546 fixture.installScript(),
547 "T3CODE_INSTALL_SCRIPT",
548 // Both racers run the same file, and neither file path contains the
549 // runtime root, so the script's own /proc scan cannot see them.
550 'sh "$work/install.sh" > "$work/first.out" 2>&1 &',
551 "first=$!",
552 'sh "$work/install.sh" > "$work/second.out" 2>&1 &',
553 "second=$!",
554 "if wait $first; then first_status=0; else first_status=$?; fi",
555 "if wait $second; then second_status=0; else second_status=$?; fi",
556 `printf 'firstStatus:%s\\n' "$first_status"`,
557 `printf 'secondStatus:%s\\n' "$second_status"`,
558 `printf 'extractions:%s\\n' "$(wc -c < "$work/tar-calls" | tr -d ' ')"`,
559 `printf 'firstRoot:%s\\n' "$(sed -n 's/^runtimeRoot://p' "$work/first.out")"`,
560 `printf 'secondRoot:%s\\n' "$(sed -n 's/^runtimeRoot://p' "$work/second.out")"`,
561 ].join("\n"),
562 );
563
564 expect(raced.status, raced.stderr).toBe(0);
565 expect(readField(raced.stdout, "firstStatus")).toBe("0");
566 expect(readField(raced.stdout, "secondStatus")).toBe("0");
567 expect(readField(raced.stdout, "extractions")).toBe("1");
568 expect(readField(raced.stdout, "firstRoot")).toBe(fixture.runtimeRoot);
569 expect(readField(raced.stdout, "secondRoot")).toBe(fixture.runtimeRoot);
570 });
571
572 it("leaves no half-built cache when extraction fails", () => {
573 const fixture = createFixture();
574 // Truncating the archive and re-recording its digest gets the install past
575 // the digest gate and into a tar that dies mid-stream, which is what a full
576 // disk or an interrupted write looks like from inside the distro.
577 const truncated = runShell(
578 [
579 "set -eu",
580 `work=${sh(fixture.work)}`,
581 'size=$(wc -c < "$work/wsl-runtime.tar.gz")',
582 'head -c $((size / 2)) "$work/wsl-runtime.tar.gz" > "$work/truncated.tar.gz"',
583 `printf 'sha:%s\\n' "$(sha256sum "$work/truncated.tar.gz" | cut -d ' ' -f 1)"`,
584 ].join("\n"),
585 );
586 expect(truncated.status, truncated.stderr).toBe(0);
587
588 const failed = fixture.install(
589 `${fixture.work}/truncated.tar.gz`,
590 readField(truncated.stdout, "sha"),
591 );
592
593 expect(failed.status).not.toBe(0);
594 expect(parseWslRuntimeRoot(failed.stdout)).toBeNull();
595 // A partial extract that survived under the cache name would be promoted by
596 // the next launch's readiness check; scratch that survived would sit there
597 // until the pruner's age sweep. Neither is left behind. Only directories
598 // are counted: the empty flock file stays on purpose, which is what keeps
599 // the lock from carrying stale state across a killed install.
600 const leftovers = runShell(
601 `set -eu\nfind ${sh(fixture.runtimeParent)} -mindepth 1 -maxdepth 1 -type d`,
602 );
603 expect(leftovers.stdout.trim()).toBe("");
604 });
605
606 // The archive and the identity recorded beside it can diverge — a partial
607 // download, or a rebuilt archive dropped next to an older sidecar. Either
608 // gate firing means the bytes never reach the cache under a name that claims
609 // to describe something else.
610 it("refuses an archive whose bytes do not match the digest recorded for it", () => {
611 const fixture = createFixture();
612
613 const refused = fixture.install(fixture.archivePath, "e".repeat(64));
614
615 expect(refused.status).not.toBe(0);
616 expect(refused.stderr).toContain("does not match its recorded SHA-256");
617 expect(parseWslRuntimeRoot(refused.stdout)).toBeNull();
618 const leftovers = runShell(
619 `set -eu\nfind ${sh(fixture.runtimeParent)} -mindepth 1 -maxdepth 1 -type d`,
620 );
621 expect(leftovers.stdout.trim()).toBe("");
622 });
623
624 it("leases a warm cache across the prepare-to-spawn handoff", () => {
625 const fixture = createFixture();
626 expect(fixture.install().status).toBe(0);
627 const selected = runShell(
628 [
629 "set -eu",
630 `runtime_parent=${sh(fixture.runtimeParent)}`,
631 'mkdir -p "$runtime_parent/sha256-current" "$runtime_parent/sha256-previous"',
632 'printf ready > "$runtime_parent/sha256-current/.t3code-wsl-runtime-ready"',
633 'printf ready > "$runtime_parent/sha256-previous/.t3code-wsl-runtime-ready"',
634 `touch -d "10 minutes ago" ${sh(fixture.runtimeRoot)}`,
635 'touch -d "1 minute ago" "$runtime_parent/sha256-previous"',
636 `cat > ${sh(`${fixture.work}/select.sh`)} <<'T3CODE_SELECT_SCRIPT'`,
637 fixture.installScript(),
638 "T3CODE_SELECT_SCRIPT",
639 `sh ${sh(`${fixture.work}/select.sh`)}`,
640 `HOME=${sh(`${fixture.work}/home`)}`,
641 "export HOME",
642 buildWslRuntimePruneScript("sha256-current"),
643 `test -d ${sh(fixture.runtimeRoot)}`,
644 ].join("\n"),
645 );
646
647 expect(selected.status, `${selected.stdout}\n${selected.stderr}`).toBe(0);
648 });
649
650 it("prunes a selected cache after its prepare-to-spawn grace period expires", () => {
651 const fixture = createFixture();
652 expect(fixture.install().status).toBe(0);
653 const result = runShell(
654 [
655 "set -eu",
656 `runtime_parent=${sh(fixture.runtimeParent)}`,
657 'mkdir -p "$runtime_parent/sha256-current" "$runtime_parent/sha256-previous"',
658 'printf ready > "$runtime_parent/sha256-current/.t3code-wsl-runtime-ready"',
659 'printf ready > "$runtime_parent/sha256-previous/.t3code-wsl-runtime-ready"',
660 `touch -d "10 minutes ago" ${sh(fixture.runtimeRoot)}`,
661 `touch -d "10 minutes ago" ${sh(`${fixture.runtimeRoot}/.t3code-wsl-runtime-selected`)}`,
662 'touch -d "1 minute ago" "$runtime_parent/sha256-previous"',
663 `HOME=${sh(`${fixture.work}/home`)}`,
664 "export HOME",
665 buildWslRuntimePruneScript("sha256-current"),
666 `test ! -e ${sh(fixture.runtimeRoot)}`,
667 ].join("\n"),
668 );
669
670 expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0);
671 });
672
673 it("removes an aged stale tree after replacing an active unready cache", () => {
674 const fixture = createFixture();
675 expect(fixture.install().status).toBe(0);
676 const result = runShell(
677 [
678 "set -eu",
679 `runtime_root=${sh(fixture.runtimeRoot)}`,
680 `runtime_parent=${sh(fixture.runtimeParent)}`,
681 'rm "$runtime_root/.t3code-wsl-runtime-ready"',
682 'sh -c "sleep 30" "$runtime_root/apps/server/dist/bin.mjs" >/dev/null 2>&1 &',
683 "active_pid=$!",
684 "sleep 0.1",
685 fixture.installScript(),
686 'stale=$(find "$runtime_parent" -maxdepth 1 -type d -name ".sha256-*.stale.*" -print -quit)',
687 'test -n "$stale"',
688 'touch -d "180 minutes ago" "$stale"',
689 `HOME=${sh(`${fixture.work}/home`)}`,
690 "export HOME",
691 buildWslRuntimePruneScript(fixture.runtimeId),
692 'test ! -e "$stale"',
693 "kill $active_pid",
694 "wait $active_pid 2>/dev/null || true",
695 ].join("\n"),
696 );
697
698 expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0);
699 });
700
701 it("prunes old and markerless caches without touching retained, active, locked, or unrelated roots", () => {
702 const result = runShell(
703 [
704 "set -eu",
705 "work=$(mktemp -d)",
706 'home="$work/home"',
707 'runtime_parent="$home/.t3/wsl-runtime"',
708 'mkdir -p "$runtime_parent"',
709 'make_ready() { mkdir -p "$runtime_parent/$1/apps/server/dist"; printf ready > "$runtime_parent/$1/.t3code-wsl-runtime-ready"; }',
710 "make_ready sha256-current",
711 "make_ready sha256-previous",
712 "make_ready sha256-active",
713 "make_ready sha256-old",
714 "make_ready sha256-locked",
715 'mkdir -p "$runtime_parent/sha256-markerless" "$runtime_parent/versions"',
716 'touch -d "1 minute ago" "$runtime_parent/sha256-previous"',
717 'touch -d "4 minutes ago" "$runtime_parent/sha256-active"',
718 'touch -d "3 minutes ago" "$runtime_parent/sha256-old"',
719 'touch -d "2 minutes ago" "$runtime_parent/sha256-locked"',
720 'sh -c "sleep 30" "$runtime_parent/sha256-active/apps/server/dist/bin.mjs" >/dev/null 2>&1 &',
721 "active_pid=$!",
722 "(",
723 ' exec 9> "$runtime_parent/.sha256-locked.install.lock"',
724 " flock -x 9",
725 " sleep 30",
726 ") >/dev/null 2>&1 &",
727 "lock_pid=$!",
728 "sleep 0.1",
729 `HOME="$home"`,
730 "export HOME",
731 buildWslRuntimePruneScript("sha256-current"),
732 'test -d "$runtime_parent/sha256-current"',
733 'test -d "$runtime_parent/sha256-previous"',
734 'test -d "$runtime_parent/sha256-active"',
735 'test -d "$runtime_parent/sha256-locked"',
736 'test -d "$runtime_parent/versions"',
737 'test ! -e "$runtime_parent/sha256-old"',
738 'test ! -e "$runtime_parent/sha256-markerless"',
739 "kill $active_pid $lock_pid",
740 "wait $active_pid 2>/dev/null || true",
741 "wait $lock_pid 2>/dev/null || true",
742 'rm -rf "$work"',
743 ].join("\n"),
744 );
745
746 expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0);
747 });
748});
749
128750describe("parseToolchainReport", () => {
129751 it("returns no missing tools and no node version on empty output", () => {
130752 expect(parseToolchainReport("")).toEqual({ missingTools: [], nodeVersion: null });
apps/desktop/src/wsl/DesktopWslEnvironment.ts+404-26
......@@ -22,6 +22,9 @@ const WSLPATH_TIMEOUT = Duration.seconds(10);
2222const PROBE_TIMEOUT = Duration.seconds(10);
2323const TOOLCHAIN_TIMEOUT = Duration.seconds(10);
2424const BUILD_TIMEOUT = Duration.minutes(5);
25const RUNTIME_INSTALL_TIMEOUT = Duration.minutes(2);
26const RUNTIME_PRUNE_TIMEOUT = Duration.seconds(30);
27const RUNTIME_INVALIDATE_TIMEOUT = Duration.seconds(15);
2528const USER_HOME_TIMEOUT = Duration.seconds(5);
2629const TOOLCHAIN_TRANSPORT_RETRY_LIMIT = 12;
2730const BUILD_TRANSPORT_RETRY_LIMIT = 2;
......@@ -31,6 +34,25 @@ export interface EnsureWslNodePtyOptions {
3134 readonly nodeEngineRange?: string | null;
3235}
3336
37// The packaged WSL runtime archive plus the SHA-256 identity the build recorded
38// for it. The cache key derives from the same digest, and installation verifies
39// the bytes before promoting the extracted tree.
40export interface WslRuntimeArchive {
41 readonly windowsPath: string;
42 readonly runtimeId: string;
43 readonly sha256: string;
44}
45
46export type PrepareWslRuntimeResult =
47 | {
48 readonly ok: true;
49 readonly linuxAppRoot: string;
50 }
51 | {
52 readonly ok: false;
53 readonly reason: string;
54 };
55
3456export type EnsureWslNodePtyResult =
3557 | {
3658 readonly ok: true;
......@@ -79,9 +101,16 @@ export class DesktopWslEnvironment extends Context.Service<
79101 // (the backend can be listening for 30+ seconds before wslhost starts
80102 // forwarding 127.0.0.1:port to WSL-side localhost).
81103 readonly getDistroIp: (distro: string | null) => Effect.Effect<Option.Option<string>>;
104 readonly prepareRuntime: (
105 distro: string | null,
106 archive: WslRuntimeArchive,
107 ) => Effect.Effect<PrepareWslRuntimeResult>;
108 readonly pruneRuntimes: (distro: string | null, runtimeId: string) => Effect.Effect<void>;
109 // Marks a staged runtime as unusable so the next launch reinstalls it.
110 readonly invalidateRuntime: (distro: string | null, runtimeId: string) => Effect.Effect<void>;
82111 readonly ensureNodePty: (
83112 distro: string | null,
84 windowsRepoRoot: string,
113 linuxAppRoot: string,
85114 options?: EnsureWslNodePtyOptions,
86115 ) => Effect.Effect<EnsureWslNodePtyResult>;
87116 }
......@@ -149,18 +178,28 @@ const runWslShell = (
149178 distro: string | null,
150179 bashScript: string,
151180 timeout: Duration.Duration,
152 options: EnsureWslNodePtyOptions = {},
181 options: {
182 readonly nodeEngineRange?: string | null;
183 readonly resolveNode?: boolean;
184 } = {},
153185): Effect.Effect<ShellResult, never, ChildProcessSpawner.ChildProcessSpawner> => {
154186 const spawner = ChildProcessSpawner.ChildProcessSpawner;
155 // -l picks up profile-managed PATH; the shared resolver covers supported
156 // version managers that non-interactive login shells can miss. -s so bash
157 // reads the script from stdin.
187 // Node probes use a login bash so profile-managed PATH entries and supported
188 // version managers are available. Runtime installation needs only POSIX tools,
189 // so it skips profile loading and runs sh directly.
190 const resolveNode = options.resolveNode !== false;
158191 const command = ChildProcess.make(
159192 "wsl.exe",
160 [...buildDistroArgs(distro), "--", "bash", "-l", "-s"],
193 resolveNode
194 ? [...buildDistroArgs(distro), "--", "bash", "-l", "-s"]
195 : [...buildDistroArgs(distro), "--exec", "sh", "-s"],
161196 {
162197 stdin: Stream.encodeText(
163 Stream.make(`${buildWslNodeEnvPreamble(options.nodeEngineRange)}${bashScript}`),
198 Stream.make(
199 resolveNode
200 ? `${buildWslNodeEnvPreamble(options.nodeEngineRange)}${bashScript}`
201 : bashScript,
202 ),
164203 ),
165204 stdout: "pipe",
166205 stderr: "pipe",
......@@ -216,6 +255,240 @@ const runWslShell = (
216255
217256const shellQuote = (value: string): string => `'${value.replaceAll("'", "'\\''")}'`;
218257
258// Holds the sha256 of the runtime's server entry, written when the install
259// promotes a verified tree. Presence alone only says an install once finished
260// here; the digest is what lets a later launch prove the entry still is what
261// that install wrote.
262const WSL_RUNTIME_READY_MARKER = ".t3code-wsl-runtime-ready";
263const WSL_RUNTIME_SELECTED_MARKER = ".t3code-wsl-runtime-selected";
264const WSL_RUNTIME_SELECTION_GRACE_MINUTES = 5;
265
266export const sanitizeWslRuntimeId = (value: string): string =>
267 value.replace(/[^A-Za-z0-9._-]/g, "_");
268
269// `archiveSha256` is the digest the build recorded alongside the archive. The
270// install verifies the bytes before extracting, so an archive can never be
271// promoted under an identity that does not describe it.
272export const buildWslRuntimeInstallScript = (
273 linuxArchivePath: string,
274 runtimeId: string,
275 archiveSha256: string,
276): string => {
277 const safeRuntimeId = sanitizeWslRuntimeId(runtimeId);
278 return [
279 "set -eu",
280 'runtime_parent="$HOME/.t3/wsl-runtime"',
281 `runtime_root="$runtime_parent/${safeRuntimeId}"`,
282 `ready_marker="$runtime_root/${WSL_RUNTIME_READY_MARKER}"`,
283 // The native payload is the part of the tree the WSL backend actually
284 // dlopens, and the only part a user can plausibly break by hand. Checking
285 // node-pty's package.json alone let a runtime whose pty.node had gone
286 // missing stay cache-ready forever: every launch reused it and then failed
287 // the native probe, with no reinstall and no fallback. Match on the glob
288 // rather than a mapped `uname -m` so this stays a presence check; the probe
289 // is what decides whether the binary is the right arch and loadable.
290 "node_pty_payload_present() {",
291 ' for candidate in "$1"/node_modules/node-pty/prebuilds/linux-*/pty.node; do',
292 ' [ -f "$candidate" ] || continue',
293 ' [ -f "${candidate%/*}/t3code-wsl-node-pty.json" ] || continue',
294 " return 0",
295 " done",
296 " return 1",
297 "}",
298 // Hashing the server entry is the only check that can tell a working cache
299 // from one whose bin.mjs was truncated or half-written: the file is still
300 // there, the native probe still passes, and launch then picks a server that
301 // exits before it can become ready, on every restart. Hashing the ~7MB
302 // entry measures in single-digit milliseconds inside the distro, once per
303 // launch, against a cold reinstall of a few hundred megabytes.
304 "runtime_server_entry_digest() {",
305 ` sha256sum "$1/apps/server/dist/bin.mjs" 2>/dev/null | cut -d ' ' -f 1`,
306 "}",
307 "runtime_is_ready() {",
308 ' [ -f "$ready_marker" ] &&',
309 ' [ -f "$runtime_root/apps/server/dist/bin.mjs" ] &&',
310 ' [ -f "$runtime_root/node_modules/node-pty/package.json" ] &&',
311 ' node_pty_payload_present "$runtime_root" &&',
312 // An empty or unreadable marker is a miss, not a pass: that is what a
313 // runtime installed before the marker carried a digest looks like, and one
314 // reinstall is the cheapest way to make it verifiable from then on.
315 ` recorded_entry_digest=$(tr -d '[:space:]' < "$ready_marker" 2>/dev/null) &&`,
316 ' [ -n "$recorded_entry_digest" ] &&',
317 ' [ "$recorded_entry_digest" = "$(runtime_server_entry_digest "$runtime_root")" ]',
318 "}",
319 'mkdir -p "$runtime_parent"',
320 `runtime_lock="$runtime_parent/.${safeRuntimeId}.install.lock"`,
321 "trap 'exit 1' HUP INT TERM",
322 'exec 9> "$runtime_lock"',
323 "flock -x 9",
324 "if runtime_is_ready; then",
325 ` touch "$runtime_root/${WSL_RUNTIME_SELECTED_MARKER}"`,
326 ` printf 'runtimeRoot:%s\\n' "$runtime_root"`,
327 " exit 0",
328 "fi",
329 // Hash only on a cache miss: a warm launch already exited above, and a cold
330 // install is about to read the whole archive through tar anyway. `set -eu`
331 // turns a distro without sha256sum into an install failure, which falls back
332 // to the mounted server tree rather than trusting unverified bytes.
333 `archive_sha=$(sha256sum ${shellQuote(linuxArchivePath)} | cut -d ' ' -f 1)`,
334 `if [ "$archive_sha" != ${shellQuote(archiveSha256)} ]; then`,
335 ` printf 'WSL runtime archive does not match its recorded SHA-256 (expected %s, got %s)\\n' ${shellQuote(archiveSha256)} "$archive_sha" >&2`,
336 " exit 1",
337 "fi",
338 // A backend can still be running out of an unready tree: the probe revokes
339 // the ready marker without stopping the process it just failed for, and
340 // invalidation deliberately leaves the tree in place for exactly that
341 // reason. Deleting it here unlinks node_modules from under a live backend,
342 // which then breaks the moment it lazily loads anything it had not already
343 // read. Move it aside either way, but only delete it now when nothing is
344 // running from it; otherwise hand it to the pruner's scratch sweep, which
345 // is what that delay is for. A process's cmdline keeps the pre-rename path,
346 // so this has to be asked before the move, not after. This script arrives
347 // on stdin, so it cannot match itself.
348 "runtime_in_use() {",
349 // No /proc means no way to tell, and guessing wrong costs a live backend
350 // its runtime. Keeping the tree only costs disk until the sweep runs.
351 " [ -d /proc/1 ] || return 0",
352 ' grep -qF -- "$1/" /proc/[0-9]*/cmdline 2>/dev/null',
353 "}",
354 'if [ -e "$runtime_root" ]; then',
355 ' if runtime_in_use "$runtime_root"; then',
356 " runtime_root_in_use=1",
357 " else",
358 " runtime_root_in_use=0",
359 " fi",
360 ` runtime_stale=$(mktemp -d "$runtime_parent/.${safeRuntimeId}.stale.XXXXXX")`,
361 ' rmdir "$runtime_stale"',
362 ' if mv -T "$runtime_root" "$runtime_stale" 2>/dev/null; then',
363 ' if [ "$runtime_root_in_use" = 1 ]; then',
364 // Renaming keeps the directory's old mtime, so restart the cleanup clock.
365 ' touch "$runtime_stale"',
366 " else",
367 ' rm -rf "$runtime_stale"',
368 " fi",
369 " fi",
370 "fi",
371 `runtime_tmp=$(mktemp -d "$runtime_parent/.${safeRuntimeId}.tmp.XXXXXX")`,
372 'cleanup_runtime_install() { rm -rf "$runtime_tmp"; }',
373 "trap cleanup_runtime_install EXIT",
374 `tar -xzf ${shellQuote(linuxArchivePath)} -C "$runtime_tmp"`,
375 'test -f "$runtime_tmp/apps/server/dist/bin.mjs"',
376 'test -f "$runtime_tmp/node_modules/node-pty/package.json"',
377
378 // Never write the ready marker over a tree that is missing the native
379 // payload. Failing here drops out to the mounted-tree fallback, which is
380 // recoverable; promoting it would mark the defect ready and cache it.
381 'if ! node_pty_payload_present "$runtime_tmp"; then',
382 " printf 'WSL runtime archive is missing its Linux node-pty binary\\n' >&2",
383 " exit 1",
384 "fi",
385 // The archive's bytes were verified against archiveSha256 above, so the
386 // digest recorded here describes content this install proved. Every later
387 // warm reuse checks the entry against it.
388 'installed_entry_digest=$(runtime_server_entry_digest "$runtime_tmp")',
389 'if [ -z "$installed_entry_digest" ]; then',
390 " printf 'Could not hash the WSL runtime server entry\\n' >&2",
391 " exit 1",
392 "fi",
393 `printf '%s\\n' "$installed_entry_digest" > "$runtime_tmp/${WSL_RUNTIME_READY_MARKER}"`,
394 'if mv -T "$runtime_tmp" "$runtime_root" 2>/dev/null; then',
395 " :",
396 "elif runtime_is_ready; then",
397 ' rm -rf "$runtime_tmp"',
398 "else",
399 ` printf 'Could not promote WSL runtime cache at %s\\n' "$runtime_root" >&2`,
400 " exit 1",
401 "fi",
402 `touch "$runtime_root/${WSL_RUNTIME_SELECTED_MARKER}"`,
403 `printf 'runtimeRoot:%s\\n' "$runtime_root"`,
404 ].join("\n");
405};
406
407// An interrupted install leaves a dot-prefixed scratch directory behind. A cold
408// install extracts a few hundred MB inside the distro, so two hours is far past
409// any live install while still bounding how long an orphan survives.
410const ORPHANED_RUNTIME_SCRATCH_MAX_AGE_MINUTES = 120;
411
412export const buildWslRuntimePruneScript = (runtimeId: string): string => {
413 const safeRuntimeId = sanitizeWslRuntimeId(runtimeId);
414 return [
415 "set -eu",
416 'runtime_parent="$HOME/.t3/wsl-runtime"',
417 `current_runtime="$runtime_parent/${safeRuntimeId}"`,
418 '[ -d "$runtime_parent" ] || exit 0',
419 // Serialize the whole retention decision so two backends cannot select
420 // different "previous" caches and delete around one another.
421 'prune_lock="$runtime_parent/.prune.lock"',
422 'exec 8> "$prune_lock"',
423 "flock -x 8",
424 // Without a way to see the distro's processes we cannot tell which caches
425 // are load-bearing, and the retention rules below are not safe on their own.
426 "[ -d /proc/1 ] || exit 0",
427 "runtime_in_use() {",
428 ' grep -qF -- "$1/" /proc/[0-9]*/cmdline 2>/dev/null',
429 "}",
430 'previous_runtime=""',
431 'for candidate in "$runtime_parent"/sha256-*; do',
432 ' [ -d "$candidate" ] || continue',
433 ' [ "$candidate" != "$current_runtime" ] || continue',
434 ` [ -f "$candidate/${WSL_RUNTIME_READY_MARKER}" ] || continue`,
435 ' if [ -z "$previous_runtime" ] || [ "$candidate" -nt "$previous_runtime" ]; then',
436 ' previous_runtime="$candidate"',
437 " fi",
438 "done",
439 // Only this desktop-owned prefix is eligible. Markerless roots are broken
440 // caches left by invalidation and must not become permanent disk leaks.
441 'for candidate in "$runtime_parent"/sha256-*; do',
442 ' [ -d "$candidate" ] || continue',
443 ' [ "$candidate" != "$current_runtime" ] || continue',
444 ' [ "$candidate" != "$previous_runtime" ] || continue',
445 ' ! runtime_in_use "$candidate" || continue',
446 " candidate_name=${candidate##*/}",
447 ' candidate_lock="$runtime_parent/.${candidate_name}.install.lock"',
448 ' exec 9> "$candidate_lock"',
449 // A held lock means another launch is installing or repairing this cache.
450 // Skip instead of waiting or deleting underneath it.
451 " flock -n 9 || continue",
452 ` selected_marker="$candidate/${WSL_RUNTIME_SELECTED_MARKER}"`,
453 ` if [ -f "$selected_marker" ] && find "$selected_marker" -maxdepth 0 -mmin -${String(WSL_RUNTIME_SELECTION_GRACE_MINUTES)} -print -quit | grep -q .; then`,
454 " flock -u 9",
455 " continue",
456 " fi",
457 ' rm -rf -- "$candidate"',
458 " flock -u 9",
459 "done",
460 // Interrupted installs use dot-prefixed names under this dedicated parent.
461 'for scratch in "$runtime_parent"/.*.tmp.* "$runtime_parent"/.*.stale.*; do',
462 ' [ -d "$scratch" ] || continue',
463 ` find "$scratch" -maxdepth 0 -mmin +${String(ORPHANED_RUNTIME_SCRATCH_MAX_AGE_MINUTES)} -print -quit | grep -q . || continue`,
464 ' rm -rf -- "$scratch"',
465 "done",
466 ].join("\n");
467};
468
469// Drops the ready marker so the next launch reinstalls the runtime from the
470// archive. Readiness is a presence check by design, so a cached tree whose
471// native payload is present but unloadable (truncated pty.node, a distro whose
472// glibc the binary needs and the tree was copied from another machine) stays
473// ready forever and fails the probe on every launch. Only the probe can see
474// that, so the probe is what revokes the marker. The tree itself is left in
475// place: the install script moves an unready root aside before extracting.
476export const buildWslRuntimeInvalidateScript = (runtimeId: string): string => {
477 const safeRuntimeId = sanitizeWslRuntimeId(runtimeId);
478 return [
479 "set -eu",
480 `rm -f "$HOME/.t3/wsl-runtime/${safeRuntimeId}/${WSL_RUNTIME_READY_MARKER}"`,
481 ].join("\n");
482};
483
484export const parseWslRuntimeRoot = (stdout: string): string | null => {
485 const prefix = "runtimeRoot:";
486 const line = stdout.split("\n").find((candidate) => candidate.startsWith(prefix));
487 if (line === undefined) return null;
488 const runtimeRoot = line.slice(prefix.length).replace(/\r$/, "");
489 return runtimeRoot.startsWith("/") ? runtimeRoot : null;
490};
491
219492const NODE_PTY_PREBUILD_MISSING_EXIT_CODE = 4;
220493
221494export const formatNodePtyProbeFailureReason = (exitCode: number): string | null =>
......@@ -390,23 +663,10 @@ export const formatMissingToolsReason = (
390663
391664const ensureNodePtyImpl = (
392665 distro: string | null,
393 windowsRepoRoot: string,
394 windowsToWslPath: (
395 distro: string | null,
396 windowsPath: string,
397 ) => Effect.Effect<Option.Option<string>>,
666 linuxRepoRoot: string,
398667 options: EnsureWslNodePtyOptions = {},
399668): Effect.Effect<EnsureWslNodePtyResult, never, ChildProcessSpawner.ChildProcessSpawner> =>
400669 Effect.gen(function* () {
401 const linuxRepoRootOption = yield* windowsToWslPath(distro, windowsRepoRoot);
402 if (Option.isNone(linuxRepoRootOption)) {
403 return {
404 ok: false,
405 reason: `wslpath conversion failed for ${windowsRepoRoot}`,
406 fatal: false,
407 } as const;
408 }
409 const linuxRepoRoot = linuxRepoRootOption.value;
410670 // node-pty lives in the apps/server workspace's node_modules; resolve from
411671 // there rather than the monorepo root, where Bun's hoist layout omits it.
412672 const linuxServerDir = `${linuxRepoRoot}/apps/server`;
......@@ -584,6 +844,96 @@ const ensureNodePtyImpl = (
584844 } as const;
585845 });
586846
847const prepareWslRuntimeImpl = Effect.fn("desktop.wsl.prepareRuntimeImpl")(function* (
848 distro: string | null,
849 archive: WslRuntimeArchive,
850 windowsToWslPath: (
851 distro: string | null,
852 windowsPath: string,
853 ) => Effect.Effect<Option.Option<string>>,
854): Effect.fn.Return<PrepareWslRuntimeResult, never, ChildProcessSpawner.ChildProcessSpawner> {
855 const linuxArchivePath = yield* windowsToWslPath(distro, archive.windowsPath);
856 if (Option.isNone(linuxArchivePath)) {
857 return {
858 ok: false,
859 reason: `wslpath conversion failed for ${archive.windowsPath}`,
860 } as const;
861 }
862
863 const install = yield* runWslShell(
864 distro,
865 buildWslRuntimeInstallScript(linuxArchivePath.value, archive.runtimeId, archive.sha256),
866 RUNTIME_INSTALL_TIMEOUT,
867 { resolveNode: false },
868 );
869 if (install.transportFailure !== null) {
870 return {
871 ok: false,
872 reason:
873 install.transportFailure === "timeout"
874 ? "WSL runtime installation timed out. Check that the distro has free disk space, then retry."
875 : "WSL runtime installation lost communication with wsl.exe. Retry, or check that the distro is healthy.",
876 } as const;
877 }
878 if (install.exitCode !== 0) {
879 const trimmedTail = `${install.stdout}${install.stderr}`.trim().slice(-500);
880 return {
881 ok: false,
882 reason: `WSL runtime installation failed (exit ${install.exitCode}): ${trimmedTail || "no stderr captured"}`,
883 } as const;
884 }
885
886 const linuxAppRoot = parseWslRuntimeRoot(install.stdout);
887 return linuxAppRoot === null
888 ? {
889 ok: false,
890 reason: "WSL runtime installation completed without reporting its cache path.",
891 }
892 : { ok: true, linuxAppRoot };
893});
894
895const pruneWslRuntimesImpl = Effect.fn("desktop.wsl.pruneRuntimesImpl")(function* (
896 distro: string | null,
897 runtimeId: string,
898): Effect.fn.Return<void, never, ChildProcessSpawner.ChildProcessSpawner> {
899 const result = yield* runWslShell(
900 distro,
901 buildWslRuntimePruneScript(runtimeId),
902 RUNTIME_PRUNE_TIMEOUT,
903 { resolveNode: false },
904 );
905 if (result.transportFailure === null && result.exitCode === 0) return;
906
907 const detail = `${result.stdout}${result.stderr}`.trim().slice(-500);
908 yield* Effect.logWarning("Could not prune old WSL runtime caches.", {
909 distro,
910 runtimeId,
911 detail: detail || `exit ${result.exitCode}`,
912 });
913});
914
915const invalidateWslRuntimeImpl = Effect.fn("desktop.wsl.invalidateRuntimeImpl")(function* (
916 distro: string | null,
917 runtimeId: string,
918): Effect.fn.Return<void, never, ChildProcessSpawner.ChildProcessSpawner> {
919 const result = yield* runWslShell(
920 distro,
921 buildWslRuntimeInvalidateScript(runtimeId),
922 RUNTIME_INVALIDATE_TIMEOUT,
923 { resolveNode: false },
924 );
925 if (result.transportFailure === null && result.exitCode === 0) return;
926
927 const detail = `${result.stdout}${result.stderr}`.trim().slice(-500);
928 // Best effort: the caller has already fallen back to the mounted tree, so a
929 // failure here only costs the reinstall that would have repaired the cache.
930 yield* Effect.logWarning("Could not invalidate the staged WSL runtime cache.", {
931 distro,
932 runtimeId,
933 detail: detail || `exit ${result.exitCode}`,
934 });
935});
936
587937export const probeWslDistros: Effect.Effect<
588938 readonly WslDistro[],
589939 DesktopWslDistroListError,
......@@ -778,9 +1128,15 @@ export interface DesktopWslEnvironmentTestStub {
7781128 readonly windowsToWslPath?: (distro: string | null, windowsPath: string) => Option.Option<string>;
7791129 readonly getUserHome?: (distro: string | null) => Option.Option<string>;
7801130 readonly getDistroIp?: (distro: string | null) => Option.Option<string>;
1131 readonly prepareRuntime?: (
1132 distro: string | null,
1133 archive: WslRuntimeArchive,
1134 ) => PrepareWslRuntimeResult;
1135 readonly pruneRuntimes?: (distro: string | null, runtimeId: string) => Effect.Effect<void>;
1136 readonly invalidateRuntime?: (distro: string | null, runtimeId: string) => Effect.Effect<void>;
7811137 readonly ensureNodePty?: (
7821138 distro: string | null,
783 windowsRepoRoot: string,
1139 linuxAppRoot: string,
7841140 options?: EnsureWslNodePtyOptions,
7851141 ) => EnsureWslNodePtyResult;
7861142}
......@@ -800,9 +1156,19 @@ export const layerTest = (stub: DesktopWslEnvironmentTestStub = {}) => {
8001156 Effect.succeed(stub.windowsToWslPath?.(distro, windowsPath) ?? Option.none()),
8011157 getUserHome: (distro) => Effect.succeed(stub.getUserHome?.(distro) ?? Option.none<string>()),
8021158 getDistroIp: (distro) => Effect.succeed(stub.getDistroIp?.(distro) ?? Option.none<string>()),
803 ensureNodePty: (distro, windowsRepoRoot, options) =>
1159 prepareRuntime: (distro, archive) =>
1160 Effect.succeed(
1161 stub.prepareRuntime?.(distro, archive) ?? {
1162 ok: false,
1163 reason: "prepareRuntime stub not configured",
1164 },
1165 ),
1166 pruneRuntimes: (distro, runtimeId) => stub.pruneRuntimes?.(distro, runtimeId) ?? Effect.void,
1167 invalidateRuntime: (distro, runtimeId) =>
1168 stub.invalidateRuntime?.(distro, runtimeId) ?? Effect.void,
1169 ensureNodePty: (distro, linuxAppRoot, options) =>
8041170 Effect.succeed(
805 stub.ensureNodePty?.(distro, windowsRepoRoot, options) ?? {
1171 stub.ensureNodePty?.(distro, linuxAppRoot, options) ?? {
8061172 ok: false,
8071173 reason: "ensureNodePty stub not configured",
8081174 fatal: true,
......@@ -882,8 +1248,20 @@ export const layer = Layer.effect(
8821248 windowsToWslPath,
8831249 getUserHome,
8841250 getDistroIp,
885 ensureNodePty: (distro, windowsRepoRoot, options) =>
886 provideSpawner(ensureNodePtyImpl(distro, windowsRepoRoot, windowsToWslPath, options)).pipe(
1251 prepareRuntime: (distro, archive) =>
1252 provideSpawner(prepareWslRuntimeImpl(distro, archive, windowsToWslPath)).pipe(
1253 Effect.withSpan("desktop.wsl.prepareRuntime"),
1254 ),
1255 pruneRuntimes: (distro, runtimeId) =>
1256 provideSpawner(pruneWslRuntimesImpl(distro, runtimeId)).pipe(
1257 Effect.withSpan("desktop.wsl.pruneRuntimes"),
1258 ),
1259 invalidateRuntime: (distro, runtimeId) =>
1260 provideSpawner(invalidateWslRuntimeImpl(distro, runtimeId)).pipe(
1261 Effect.withSpan("desktop.wsl.invalidateRuntime"),
1262 ),
1263 ensureNodePty: (distro, linuxAppRoot, options) =>
1264 provideSpawner(ensureNodePtyImpl(distro, linuxAppRoot, options)).pipe(
8871265 Effect.withSpan("desktop.wsl.ensureNodePty"),
8881266 ),
8891267 });
apps/desktop/src/wsl/DesktopWslServerTree.test.ts+87
......@@ -297,6 +297,93 @@ describe("DesktopWslServerTree", () => {
297297 ).pipe(Effect.provide(NodeServices.layer)),
298298 );
299299
300 it.effect("removes the legacy Windows extraction tree without preparing a fallback", () =>
301 withTempDir((tempDir) =>
302 Effect.gen(function* () {
303 const fileSystem = yield* FileSystem.FileSystem;
304 const path = yield* Path.Path;
305 const resourcesPath = path.join(tempDir, "resources");
306 const treeRoot = path.join(tempDir, "userdata", "wsl-server-tree");
307 yield* fileSystem.makeDirectory(path.join(treeRoot, "1.2.3"), { recursive: true });
308 yield* fileSystem.writeFileString(path.join(treeRoot, "1.2.3", "legacy"), "old");
309
310 yield* Effect.gen(function* () {
311 const tree = yield* DesktopWslServerTree.DesktopWslServerTree;
312 yield* tree.cleanupLegacy;
313 }).pipe(
314 Effect.provide(
315 DesktopWslServerTree.layer.pipe(
316 Layer.provideMerge(environmentLayer({ baseDir: tempDir, resourcesPath })),
317 ),
318 ),
319 );
320
321 assert.isFalse(yield* fileSystem.exists(treeRoot));
322 }),
323 ).pipe(Effect.provide(NodeServices.layer)),
324 );
325
326 it.effect("re-extracts after legacy cleanup partially deletes the completed tree", () =>
327 withTempDir((tempDir) =>
328 Effect.gen(function* () {
329 const fileSystem = yield* FileSystem.FileSystem;
330 const path = yield* Path.Path;
331 const resourcesPath = path.join(tempDir, "resources");
332 const serverRoot = path.join(resourcesPath, "server.asar");
333 const sourceEntryPath = path.join(serverRoot, "apps/server/dist/bin.mjs");
334 yield* fileSystem.makeDirectory(path.dirname(sourceEntryPath), { recursive: true });
335 yield* fileSystem.writeFileString(sourceEntryPath, "fresh-server-entry");
336
337 const initial = yield* ensureWith({ baseDir: tempDir, resourcesPath });
338 assert.isTrue(initial.ok);
339 const versionDir = initial.ok ? initial.root : "";
340 const treeRoot = path.dirname(versionDir);
341 const extractedEntryPath = path.join(versionDir, "apps/server/dist/bin.mjs");
342 let cleanupFailed = false;
343 const partialCleanupFileSystem = Layer.effect(
344 FileSystem.FileSystem,
345 Effect.gen(function* () {
346 const realFileSystem = yield* FileSystem.FileSystem;
347 return {
348 ...realFileSystem,
349 remove: (target, options) =>
350 String(target) === treeRoot && options?.recursive === true && !cleanupFailed
351 ? Effect.gen(function* () {
352 cleanupFailed = true;
353 yield* realFileSystem.remove(extractedEntryPath);
354 return yield* PlatformError.systemError({
355 _tag: "PermissionDenied",
356 module: "FileSystem",
357 method: "remove",
358 pathOrDescriptor: treeRoot,
359 description: "simulated partial legacy cleanup",
360 });
361 })
362 : realFileSystem.remove(target, options),
363 } satisfies FileSystem.FileSystem;
364 }),
365 ).pipe(Layer.provide(NodeServices.layer));
366
367 const result = yield* Effect.gen(function* () {
368 const tree = yield* DesktopWslServerTree.DesktopWslServerTree;
369 yield* tree.cleanupLegacy;
370 return yield* tree.ensure;
371 }).pipe(
372 Effect.provide(
373 DesktopWslServerTree.layer.pipe(
374 Layer.provideMerge(environmentLayer({ baseDir: tempDir, resourcesPath })),
375 Layer.provideMerge(partialCleanupFileSystem),
376 ),
377 ),
378 );
379
380 assert.isTrue(cleanupFailed);
381 assert.isTrue(result.ok);
382 assert.equal(yield* fileSystem.readFileString(extractedEntryPath), "fresh-server-entry");
383 }),
384 ).pipe(Effect.provide(NodeServices.layer)),
385 );
386
300387 it.effect("reports a retryable failure when the archive cannot be read", () =>
301388 withTempDir((tempDir) =>
302389 Effect.gen(function* () {
apps/desktop/src/wsl/DesktopWslServerTree.ts+32-5
......@@ -11,10 +11,9 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
1111// Packaged Windows builds ship the server tree inside resources/server.asar
1212// (see scripts/build-desktop-artifact.ts). The Windows primary reads it in
1313// place through the asar-aware ELECTRON_RUN_AS_NODE runtime, but the WSL
14// backend launches plain `wsl.exe -- node`, which cannot read an asar
15// archive. This service materializes the archive into a real, version-keyed
16// directory the first time the WSL backend starts, and reuses it afterwards —
17// so only users who enable WSL ever pay for a loose copy of the server tree.
14// backend launches plain `wsl.exe -- node`, which cannot read an asar archive.
15// This fallback service materializes the archive into a real, version-keyed
16// directory only when the distro-local runtime cannot be prepared.
1817//
1918// Reading through Electron's patched fs also transparently returns the
2019// contents of files that electron-builder/asar left in the server.asar.unpacked
......@@ -52,6 +51,10 @@ export class DesktopWslServerTree extends Context.Service<
5251 // the checkout already is that directory; packaged Windows builds extract
5352 // server.asar on first use.
5453 readonly ensure: Effect.Effect<WslServerTreeResult>;
54 // Removes the Windows-side extraction cache after a distro-local runtime
55 // has proven healthy. Serialized with ensure so cleanup cannot race an
56 // extraction that the mounted fallback is preparing.
57 readonly cleanupLegacy: Effect.Effect<void>;
5558 }
5659>()("@t3tools/desktop/wsl/DesktopWslServerTree") {}
5760
......@@ -173,6 +176,28 @@ export const make = Effect.gen(function* () {
173176 // first caller extracts, later callers see the marker and reuse the tree.
174177 const gate = yield* Semaphore.make(1);
175178
179 const cleanupLegacy = gate
180 .withPermits(1)(
181 needsExtraction
182 ? Effect.gen(function* () {
183 // Invalidate completeness before recursive deletion. Windows can
184 // remove part of a tree and then fail on a locked file; without
185 // this ordering, a surviving marker makes ensure reuse that
186 // half-deleted fallback instead of extracting it again.
187 yield* fs.remove(join(versionDir, MARKER_FILE_NAME), { force: true });
188 yield* fs.remove(treeRoot, { recursive: true, force: true });
189 }).pipe(
190 Effect.catch((cause) =>
191 Effect.logWarning("[wsl-server-tree] Could not remove the legacy extraction cache.", {
192 treeRoot,
193 cause,
194 }),
195 ),
196 )
197 : Effect.void,
198 )
199 .pipe(Effect.withSpan("desktop.wslServerTree.cleanupLegacy"));
200
176201 const ensure: Effect.Effect<WslServerTreeResult> = gate
177202 .withPermits(1)(
178203 Effect.gen(function* () {
......@@ -205,13 +230,14 @@ export const make = Effect.gen(function* () {
205230 )
206231 .pipe(Effect.withSpan("desktop.wslServerTree.ensure"));
207232
208 return DesktopWslServerTree.of({ ensure });
233 return DesktopWslServerTree.of({ ensure, cleanupLegacy });
209234});
210235
211236export const layer = Layer.effect(DesktopWslServerTree, make);
212237
213238export interface DesktopWslServerTreeTestStub {
214239 readonly result?: WslServerTreeResult;
240 readonly cleanupLegacy?: Effect.Effect<void>;
215241}
216242
217243export const layerTest = (stub: DesktopWslServerTreeTestStub = {}) =>
......@@ -221,6 +247,7 @@ export const layerTest = (stub: DesktopWslServerTreeTestStub = {}) =>
221247 const environment = yield* DesktopEnvironment.DesktopEnvironment;
222248 return DesktopWslServerTree.of({
223249 ensure: Effect.succeed(stub.result ?? { ok: true, root: environment.appRoot }),
250 cleanupLegacy: stub.cleanupLegacy ?? Effect.void,
224251 });
225252 }),
226253 );
docs/operations/release.md+11-4
......@@ -222,10 +222,12 @@ Windows packages the bundled server and only its runtime-external/native
222222dependency closure in `resources/server.asar`. Native modules and helper
223223executables declared as unpacked by that archive must be present at the matching
224224paths below `resources/server.asar.unpacked`. The Windows-native backend reads
225the archive in place through Electron. WSL cannot read ASAR files, so enabling
226the WSL backend extracts the server tree once into the desktop state directory
227under `wsl-server-tree/<version>` and reuses the completed version until the app
228is updated.
225the archive in place through Electron. Packaged Windows builds also ship a
226Linux-only `resources/wsl-runtime.tar.gz` plus its SHA-256 sidecar. WSL verifies
227and extracts that archive into `~/.t3/wsl-runtime/sha256-<archive-digest>` inside
228the selected distro, then reuses it for later launches of the same update. The
229Windows-side `wsl-server-tree/<version>` extraction remains a fallback and is
230removed after the distro-local runtime passes preflight.
229231
230232The artifact builder rejects a Windows package when any of these invariants
231233break:
......@@ -236,6 +238,11 @@ break:
236238- On same-architecture Windows builds, the packaged primary cannot load the fff
237239 native library from inside `server.asar` through its `.unpacked` sibling.
238240- The isolated, extracted sidecar cannot load the server entry with plain Node.
241- A Windows build with a WSL node-pty prebuild omits the WSL archive or SHA-256
242 sidecar, the sidecar digest does not match the emitted archive, or required
243 Linux runtime members are absent.
244- The emitted WSL archive contains Windows/Darwin node-pty payloads, ConPTY,
245 pnpm install metadata, or Windows-only FFF, ffi-rs, or msgpackr bindings.
239246- The external Windows resource monitor is absent.
240247- The unpacked Windows application contains more than 80 files.
241248
docs/user/install.md+10
......@@ -49,6 +49,16 @@ Nightly:
4949yay -S t3code-nightly-bin
5050```
5151
52### Windows Subsystem for Linux
53
54When the desktop app runs a WSL backend, it installs the matching server runtime into
55`~/.t3/wsl-runtime` inside the selected distro. The first launch after installing or updating T3
56Code may take a little longer while that release's runtime is extracted. Later launches reuse the
57Linux-local copy so startup does not depend on reading application files through `/mnt/c`. After a
58successful launch, T3 Code keeps the current runtime and one previous runtime for rollback and
59removes older caches automatically. If a cached runtime stops working, T3 Code launches from the
60application files under `/mnt/c` instead and reinstalls the runtime on the next launch.
61
5262## Providers
5363
5464T3 Code drives provider CLIs; it does not ship them. Install the CLI for each provider you want
scripts/build-desktop-artifact.test.ts+405-2
......@@ -1,3 +1,6 @@
1// @effect-diagnostics nodeBuiltinImport:off - packaged-archive fixtures compute the sidecar digest with the same Node primitive as the builder.
2import * as NodeCrypto from "node:crypto";
3
14import * as NodeServices from "@effect/platform-node/NodeServices";
25import { assert, it } from "@effect/vitest";
36import * as ConfigProvider from "effect/ConfigProvider";
......@@ -8,11 +11,13 @@ import * as Option from "effect/Option";
811import * as Path from "effect/Path";
912import * as Sink from "effect/Sink";
1013import * as Stream from "effect/Stream";
11import { ChildProcessSpawner } from "effect/unstable/process";
14import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
1215
1316import {
1417 BundleNotSelfContainedError,
1518 BuildCommandFailedError,
19 buildWslRuntimeArchiveArgs,
20 parseWslRuntimeArchiveMembers,
1621 DesktopDmgBackgroundSourceMissingError,
1722 createStageWorkspaceConfig,
1823 createStagePatchedDependencies,
......@@ -51,6 +56,8 @@ import {
5156 stageLinuxIconSize,
5257 stageDesktopDmgBackground,
5358 stageResourceMonitor,
59 stageWslRuntimeArchive,
60 bundlesWslRuntime,
5461 STAGE_INSTALL_ARGS,
5562 ancestorNodeModulesPaths,
5663 copyDirectoryPreservingSymlinks,
......@@ -63,10 +70,38 @@ import {
6370 WINDOWS_SERVER_ASAR_RESOURCE,
6471 WINDOWS_SERVER_ASAR_UNPACK_GLOB,
6572 WINDOWS_SERVER_RESOURCE_SOURCE_DIR,
73 WSL_RUNTIME_ARCHIVE_EXTRA_RESOURCE,
74 WSL_RUNTIME_ARCHIVE_HASH_EXTRA_RESOURCE,
75 WSL_RUNTIME_ARCHIVE_HASH_NAME,
76 WSL_RUNTIME_ARCHIVE_NAME,
77 WSL_RUNTIME_EXTRA_RESOURCES,
78 wslRuntimeArchiveTarTarget,
6679} from "./build-desktop-artifact.ts";
6780import { BRAND_ASSET_PATHS } from "./lib/brand-assets.ts";
6881import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess";
6982
83// A minimal stand-in for the staged sidecar roots packed into the WSL archive.
84const stageWslRuntimeTreeFixture = Effect.fn("stageWslRuntimeTreeFixture")(function* (
85 root: string,
86 serverSource: string,
87) {
88 const fs = yield* FileSystem.FileSystem;
89 const path = yield* Path.Path;
90 yield* fs.makeDirectory(path.join(root, "apps/server/dist"), { recursive: true });
91 yield* fs.writeFileString(path.join(root, "apps/server/dist/bin.mjs"), serverSource);
92 yield* fs.makeDirectory(path.join(root, "node_modules/node-pty/prebuilds/linux-x64"), {
93 recursive: true,
94 });
95 yield* fs.writeFileString(
96 path.join(root, "node_modules/node-pty/package.json"),
97 '{"name":"node-pty"}\n',
98 );
99 yield* fs.writeFileString(
100 path.join(root, "node_modules/node-pty/prebuilds/linux-x64/pty.node"),
101 "pty",
102 );
103});
104
70105function mockProcess(exitCode: number) {
71106 return ChildProcessSpawner.makeHandle({
72107 pid: ChildProcessSpawner.ProcessId(1),
......@@ -107,6 +142,7 @@ function iconResizeSpawnerLayer(
107142const makeWindowsPayloadFixture = Effect.fn("test.makeWindowsPayloadFixture")(function* (input: {
108143 readonly copyUnpackedNatives: boolean;
109144 readonly serverEntrySource?: string;
145 readonly wslRuntime?: "valid" | "forbidden" | "bad-digest";
110146}) {
111147 const fs = yield* FileSystem.FileSystem;
112148 const path = yield* Path.Path;
......@@ -143,6 +179,61 @@ const makeWindowsPayloadFixture = Effect.fn("test.makeWindowsPayloadFixture")(fu
143179 yield* fs.writeFileString(path.join(packagedAppDir, appExecutableName), "electron");
144180 yield* fs.writeFileString(path.join(packagedAppDir, "chrome_crashpad_handler.exe"), "crashpad");
145181
182 if (input.wslRuntime !== undefined) {
183 const wslSourceDir = path.join(tempDir, "wsl-source");
184 const linuxPrebuildDir = path.join(wslSourceDir, "node_modules/node-pty/prebuilds/linux-x64");
185 yield* fs.makeDirectory(path.join(wslSourceDir, "apps/server/dist"), { recursive: true });
186 yield* fs.makeDirectory(linuxPrebuildDir, { recursive: true });
187 yield* fs.writeFileString(
188 path.join(wslSourceDir, "apps/server/dist/bin.mjs"),
189 "console.log('wsl server');\n",
190 );
191 yield* fs.writeFileString(
192 path.join(wslSourceDir, "node_modules/node-pty/package.json"),
193 '{"name":"node-pty"}',
194 );
195 yield* fs.writeFileString(path.join(linuxPrebuildDir, "pty.node"), "linux-pty");
196 yield* fs.writeFileString(
197 path.join(linuxPrebuildDir, "t3code-wsl-node-pty.json"),
198 '{"arch":"x64"}',
199 );
200 if (input.wslRuntime === "forbidden") {
201 const windowsPrebuildDir = path.join(
202 wslSourceDir,
203 "node_modules/node-pty/prebuilds/win32-x64",
204 );
205 yield* fs.makeDirectory(windowsPrebuildDir, { recursive: true });
206 yield* fs.writeFileString(path.join(windowsPrebuildDir, "pty.node"), "windows-pty");
207 }
208
209 const archivePath = path.join(resourcesDir, WSL_RUNTIME_ARCHIVE_NAME);
210 const hashPath = path.join(resourcesDir, WSL_RUNTIME_ARCHIVE_HASH_NAME);
211 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
212 const tar = yield* spawner.spawn(
213 ChildProcess.make(
214 "tar",
215 [
216 "-czf",
217 wslRuntimeArchiveTarTarget(path.relative(wslSourceDir, archivePath)),
218 "apps/server/dist",
219 "node_modules",
220 ],
221 { cwd: wslSourceDir, stdin: "ignore", stdout: "ignore", stderr: "pipe" },
222 ),
223 );
224 assert.equal(Number(yield* tar.exitCode), 0);
225 const archiveDigest = NodeCrypto.createHash("sha256");
226 yield* fs
227 .stream(archivePath)
228 .pipe(Stream.runForEach((chunk) => Effect.sync(() => archiveDigest.update(chunk))));
229 yield* fs.writeFileString(
230 hashPath,
231 input.wslRuntime === "bad-digest"
232 ? `${"0".repeat(64)}\n`
233 : `${archiveDigest.digest("hex")}\n`,
234 );
235 }
236
146237 return {
147238 stageDistDir,
148239 packagedAppDir,
......@@ -434,12 +525,27 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
434525 );
435526 });
436527
437 it("limits Electron locales and excludes the unused Claude SDK executable", () => {
528 it("limits Electron locales and excludes separately packaged resources", () => {
438529 assert.deepStrictEqual(DESKTOP_ELECTRON_LANGUAGES, ["en-US"]);
530 // Every WSL staging input is emitted once at resources/, so adding one
531 // without its exclusion silently packs a second copy into app.asar. The
532 // snapshot below cannot catch that on its own: adding a resource and
533 // forgetting the exclusion leaves the exclusion list untouched, so it still
534 // matches. Assert the invariant first, where the failure names the culprit.
535 for (const resource of WSL_RUNTIME_EXTRA_RESOURCES) {
536 assert.include(
537 DESKTOP_FILE_EXCLUSIONS,
538 `!${resource.from}`,
539 `${resource.from} ships via extraResources and must be excluded from app.asar`,
540 );
541 }
542
439543 assert.deepStrictEqual(DESKTOP_FILE_EXCLUSIONS, [
440544 "!**/node_modules/@anthropic-ai/claude-agent-sdk-*/**/*",
441545 "!apps/desktop/prod-resources/windows-server",
442546 "!apps/desktop/prod-resources/windows-server/**/*",
547 "!apps/desktop/prod-resources/wsl-runtime.tar.gz",
548 "!apps/desktop/prod-resources/wsl-runtime.tar.gz.sha256",
443549 ]);
444550 assert.equal(WINDOWS_SERVER_RESOURCE_SOURCE_DIR, "apps/desktop/prod-resources/windows-server");
445551 assert.deepStrictEqual(WINDOWS_SERVER_EXTRA_RESOURCES, [
......@@ -479,6 +585,17 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
479585 false,
480586 undefined,
481587 undefined,
588 true,
589 );
590 const winWithoutWslPrebuild = yield* createBuildConfig(
591 "win",
592 "nsis",
593 "1.2.3",
594 false,
595 false,
596 undefined,
597 undefined,
598 false,
482599 );
483600
484601 // All platforms keep app.asar fully packed; Windows ships the server
......@@ -493,6 +610,16 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
493610 to: "resource-monitor",
494611 },
495612 ...WINDOWS_SERVER_EXTRA_RESOURCES,
613 ...WSL_RUNTIME_EXTRA_RESOURCES,
614 ]);
615 // No Linux prebuild means the sidecar staging never writes the archive,
616 // so listing it here would fail the build on a missing source file.
617 assert.deepStrictEqual(winWithoutWslPrebuild.extraResources, [
618 {
619 from: "apps/desktop/prod-resources/resource-monitor",
620 to: "resource-monitor",
621 },
622 ...WINDOWS_SERVER_EXTRA_RESOURCES,
496623 ]);
497624 assert.deepStrictEqual(win.nsis, { differentialPackage: true });
498625 // Native binaries and helper executables cannot load from inside an
......@@ -716,6 +843,82 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
716843 ),
717844 );
718845
846 it.effect("validates the emitted WSL archive and its SHA-256 sidecar", () =>
847 Effect.scoped(
848 Effect.gen(function* () {
849 const fixture = yield* makeWindowsPayloadFixture({
850 copyUnpackedNatives: true,
851 wslRuntime: "valid",
852 });
853 const result = yield* validateWindowsPackagedPayload({
854 stageDistDir: fixture.stageDistDir,
855 appExecutableName: fixture.appExecutableName,
856 targetArch: "x64",
857 expectWslRuntime: true,
858 });
859
860 assert.equal(result.packagedAppDir, fixture.packagedAppDir);
861 }),
862 ),
863 );
864
865 it.effect("rejects a Windows package missing its expected WSL runtime", () =>
866 Effect.scoped(
867 Effect.gen(function* () {
868 const fixture = yield* makeWindowsPayloadFixture({ copyUnpackedNatives: true });
869 const error = yield* validateWindowsPackagedPayload({
870 stageDistDir: fixture.stageDistDir,
871 appExecutableName: fixture.appExecutableName,
872 targetArch: "x64",
873 expectWslRuntime: true,
874 }).pipe(Effect.flip);
875
876 assert.instanceOf(error, WindowsPackagedPayloadValidationError);
877 assert.equal(error.reason, "wsl-runtime-missing");
878 }),
879 ),
880 );
881
882 it.effect("rejects forbidden native members in the emitted WSL archive", () =>
883 Effect.scoped(
884 Effect.gen(function* () {
885 const fixture = yield* makeWindowsPayloadFixture({
886 copyUnpackedNatives: true,
887 wslRuntime: "forbidden",
888 });
889 const error = yield* validateWindowsPackagedPayload({
890 stageDistDir: fixture.stageDistDir,
891 appExecutableName: fixture.appExecutableName,
892 targetArch: "x64",
893 expectWslRuntime: true,
894 }).pipe(Effect.flip);
895
896 assert.instanceOf(error, WindowsPackagedPayloadValidationError);
897 assert.equal(error.reason, "wsl-runtime-invalid");
898 }),
899 ),
900 );
901
902 it.effect("rejects an emitted WSL archive whose sidecar digest does not match", () =>
903 Effect.scoped(
904 Effect.gen(function* () {
905 const fixture = yield* makeWindowsPayloadFixture({
906 copyUnpackedNatives: true,
907 wslRuntime: "bad-digest",
908 });
909 const error = yield* validateWindowsPackagedPayload({
910 stageDistDir: fixture.stageDistDir,
911 appExecutableName: fixture.appExecutableName,
912 targetArch: "x64",
913 expectWslRuntime: true,
914 }).pipe(Effect.flip);
915
916 assert.instanceOf(error, WindowsPackagedPayloadValidationError);
917 assert.equal(error.reason, "wsl-runtime-invalid");
918 }),
919 ),
920 );
921
719922 it.effect("probes fff through the packaged Windows primary instead of helper executables", () => {
720923 const commands: Array<{
721924 readonly command: string;
......@@ -1257,6 +1460,206 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
12571460 assert.equal(resourceMonitorExecutableName("mac"), "t3-resource-monitor");
12581461 assert.equal(resourceMonitorExecutableName("win"), "t3-resource-monitor.exe");
12591462 });
1463
1464 it("packages the WSL server and production dependencies as one compressed runtime", () => {
1465 assert.equal(WSL_RUNTIME_ARCHIVE_NAME, "wsl-runtime.tar.gz");
1466 assert.equal(WSL_RUNTIME_ARCHIVE_HASH_NAME, "wsl-runtime.tar.gz.sha256");
1467 assert.deepStrictEqual(WSL_RUNTIME_ARCHIVE_EXTRA_RESOURCE, {
1468 from: "apps/desktop/prod-resources/wsl-runtime.tar.gz",
1469 to: "wsl-runtime.tar.gz",
1470 });
1471 assert.deepStrictEqual(WSL_RUNTIME_ARCHIVE_HASH_EXTRA_RESOURCE, {
1472 from: "apps/desktop/prod-resources/wsl-runtime.tar.gz.sha256",
1473 to: "wsl-runtime.tar.gz.sha256",
1474 });
1475 // The archive is only usable alongside a Linux pty.node, so both the
1476 // staging and the packaging config hang off this one decision.
1477 assert.isTrue(bundlesWslRuntime({ arch: "x64", prebuildPath: "/tmp/pty.node" }));
1478 assert.isTrue(bundlesWslRuntime({ arch: "arm64", prebuildPath: "/tmp/pty.node" }));
1479 assert.isFalse(bundlesWslRuntime({ arch: "x64", prebuildPath: undefined }));
1480 assert.isFalse(bundlesWslRuntime({ arch: "universal", prebuildPath: "/tmp/pty.node" }));
1481
1482 assert.deepStrictEqual(buildWslRuntimeArchiveArgs(), [
1483 "-czf",
1484 "apps/desktop/prod-resources/wsl-runtime.tar.gz",
1485 "--exclude=node_modules/@anthropic-ai/claude-agent-sdk-*",
1486 "--exclude=node_modules/.bin*",
1487 "--exclude=node_modules/.pnpm*",
1488 "--exclude=node_modules/.modules.yaml*",
1489 "--exclude=node_modules/.pnpm-workspace-state-v1.json*",
1490 "--exclude=node_modules/node-pty/prebuilds/darwin-*",
1491 "--exclude=node_modules/node-pty/prebuilds/win32-*",
1492 "--exclude=node_modules/node-pty/build*",
1493 "--exclude=node_modules/node-pty/third_party/conpty*",
1494 "--exclude=node_modules/@ff-labs/fff-bin-win32-*",
1495 "--exclude=node_modules/@yuuang/ffi-rs-win32-*",
1496 "--exclude=node_modules/@msgpackr-extract/msgpackr-extract-win32-*",
1497 "apps/server/dist",
1498 "node_modules",
1499 ]);
1500 });
1501
1502 it("parses Windows bsdtar member listings with CRLF line endings", () => {
1503 assert.deepStrictEqual(
1504 parseWslRuntimeArchiveMembers(
1505 "./apps/server/dist/bin.mjs\r\nnode_modules/node-pty/package.json\r\n",
1506 ),
1507 ["apps/server/dist/bin.mjs", "node_modules/node-pty/package.json"],
1508 );
1509 });
1510
1511 it("keeps Windows tar targets colon-free so GNU tar does not read them as remote hosts", () => {
1512 assert.equal(
1513 wslRuntimeArchiveTarTarget("..\\app\\apps\\desktop\\prod-resources\\wsl-runtime.tar.gz"),
1514 "../app/apps/desktop/prod-resources/wsl-runtime.tar.gz",
1515 );
1516 assert.equal(
1517 wslRuntimeArchiveTarTarget("../app/apps/desktop/prod-resources/wsl-runtime.tar.gz"),
1518 "../app/apps/desktop/prod-resources/wsl-runtime.tar.gz",
1519 );
1520 });
1521
1522 // The staged source tree and the archive live in sibling stage directories,
1523 // so this covers the real call: on Windows the archive path is an absolute
1524 // C:\... path, and handing that to tar is what made Git's GNU tar try to
1525 // reach a host named "C".
1526 it.effect("spawns tar with an archive target relative to the staged source tree", () => {
1527 const commands: Array<{
1528 readonly command: string;
1529 readonly args: ReadonlyArray<string>;
1530 readonly options: { readonly cwd?: string };
1531 }> = [];
1532
1533 return Effect.scoped(
1534 Effect.gen(function* () {
1535 const fs = yield* FileSystem.FileSystem;
1536 const path = yield* Path.Path;
1537 const stageRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-wsl-runtime-archive-" });
1538 const sourceDir = path.join(stageRoot, "server");
1539 const stageAppDir = path.join(stageRoot, "app");
1540 const archivePath = path.join(stageAppDir, WSL_RUNTIME_ARCHIVE_EXTRA_RESOURCE.from);
1541 const hashPath = path.join(stageAppDir, WSL_RUNTIME_ARCHIVE_HASH_EXTRA_RESOURCE.from);
1542 yield* stageWslRuntimeTreeFixture(sourceDir, "export const serve = 1;\n");
1543
1544 const spawnerLayer = Layer.succeed(
1545 ChildProcessSpawner.ChildProcessSpawner,
1546 ChildProcessSpawner.make((command) => {
1547 const childProcess = command as unknown as (typeof commands)[number];
1548 commands.push(childProcess);
1549 // Stand in for tar: write the archive by resolving the -f target
1550 // against the cwd tar was spawned in, exactly as tar would.
1551 const target = path.resolve(childProcess.options.cwd ?? "", childProcess.args[1] ?? "");
1552 return Effect.as(fs.writeFileString(target, "wsl-runtime-archive"), mockProcess(0));
1553 }),
1554 );
1555
1556 yield* stageWslRuntimeArchive({ sourceDir, archivePath, hashPath }).pipe(
1557 Effect.provide(spawnerLayer),
1558 );
1559
1560 const tarCommand = commands.find((command) => command.command === "tar");
1561 if (tarCommand === undefined) return assert.fail("tar was not spawned");
1562
1563 const target = tarCommand.args[1] ?? "";
1564 assert.equal(tarCommand.options.cwd, sourceDir);
1565 assert.notInclude(target, ":");
1566 assert.isFalse(path.isAbsolute(target));
1567 // Relative or not, tar has to land the archive where the build expects it.
1568 assert.equal(path.resolve(sourceDir, target), archivePath);
1569 assert.isTrue(yield* fs.exists(archivePath));
1570
1571 // The archive digest both gates installation and names the cache.
1572 const hash = yield* fs.readFileString(hashPath);
1573 assert.match(hash.trim(), /^[0-9a-f]{64}$/);
1574 }),
1575 );
1576 });
1577
1578 it.effect("ships only Linux runtime members in the WSL archive", () =>
1579 Effect.scoped(
1580 Effect.gen(function* () {
1581 const fs = yield* FileSystem.FileSystem;
1582 const path = yield* Path.Path;
1583 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
1584 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-wsl-runtime-members-" });
1585 const sourceDir = path.join(root, "server");
1586 const archivePath = path.join(root, "wsl-runtime.tar.gz");
1587 const hashPath = `${archivePath}.sha256`;
1588 yield* stageWslRuntimeTreeFixture(sourceDir, "export const serve = 1;\n");
1589
1590 const members = [
1591 "node_modules/node-pty/prebuilds/darwin-x64/pty.node",
1592 "node_modules/node-pty/prebuilds/win32-x64/pty.node",
1593 "node_modules/node-pty/build/Release/pty.node",
1594 "node_modules/node-pty/third_party/conpty/win10-x64/conpty.dll",
1595 "node_modules/@ff-labs/fff-bin-win32-x64/fff.dll",
1596 "node_modules/@ff-labs/fff-bin-linux-x64-gnu/libfff.so",
1597 "node_modules/@yuuang/ffi-rs-win32-x64-msvc/ffi.dll",
1598 "node_modules/@yuuang/ffi-rs-linux-x64-gnu/libffi.so",
1599 "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64/addon.node",
1600 "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64/addon.node",
1601 "node_modules/@anthropic-ai/claude-agent-sdk-win32-x64/index.js",
1602 "node_modules/.bin/tool",
1603 "node_modules/.pnpm/lock.yaml",
1604 "node_modules/.modules.yaml",
1605 "node_modules/.pnpm-workspace-state-v1.json",
1606 ] as const;
1607 yield* Effect.forEach(
1608 members,
1609 (member) =>
1610 Effect.gen(function* () {
1611 const memberPath = path.join(sourceDir, member);
1612 yield* fs.makeDirectory(path.dirname(memberPath), { recursive: true });
1613 yield* fs.writeFileString(memberPath, member);
1614 }),
1615 { discard: true },
1616 );
1617
1618 yield* stageWslRuntimeArchive({ sourceDir, archivePath, hashPath });
1619 const process = yield* spawner.spawn(
1620 ChildProcess.make("tar", ["-tzf", archivePath], {
1621 stdin: "ignore",
1622 stdout: "pipe",
1623 stderr: "pipe",
1624 }),
1625 );
1626 const listing = yield* process.stdout.pipe(
1627 Stream.decodeText(),
1628 Stream.runFold(
1629 () => "",
1630 (output, chunk) => output + chunk,
1631 ),
1632 );
1633 assert.equal(Number(yield* process.exitCode), 0);
1634
1635 assert.include(listing, "apps/server/dist/bin.mjs");
1636 assert.include(listing, "node_modules/node-pty/prebuilds/linux-x64/pty.node");
1637 assert.include(listing, "node_modules/@ff-labs/fff-bin-linux-x64-gnu/libfff.so");
1638 assert.include(listing, "node_modules/@yuuang/ffi-rs-linux-x64-gnu/libffi.so");
1639 assert.include(
1640 listing,
1641 "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64/addon.node",
1642 );
1643 for (const excluded of [
1644 "prebuilds/darwin-",
1645 "prebuilds/win32-",
1646 "node-pty/build",
1647 "third_party/conpty",
1648 "fff-bin-win32-",
1649 "ffi-rs-win32-",
1650 "msgpackr-extract-win32-",
1651 "claude-agent-sdk-",
1652 "node_modules/.bin",
1653 "node_modules/.pnpm",
1654 "node_modules/.modules.yaml",
1655 "node_modules/.pnpm-workspace-state-v1.json",
1656 ]) {
1657 assert.notInclude(listing, excluded);
1658 }
1659 }),
1660 ),
1661 );
1662
12601663 it("promotes target fff binaries to direct staged dependencies", () => {
12611664 assert.deepStrictEqual(resolveFffNativeDependencies("mac", "arm64", "0.9.4"), {
12621665 "@ff-labs/fff-bin-darwin-arm64": "0.9.4",
scripts/build-desktop-artifact.ts+233-4
......@@ -2,6 +2,7 @@
22// @effect-diagnostics nodeBuiltinImport:off - Node's typed junction API avoids Windows symlink privileges while keeping the probe isolated.
33
44import * as NodeFSP from "node:fs/promises";
5import * as NodeCrypto from "node:crypto";
56import * as NodeModule from "node:module";
67
78import {
......@@ -560,6 +561,8 @@ const WindowsPackagedPayloadValidationReason = Schema.Literals([
560561 "sidecar-invalid",
561562 "unpacked-native-missing",
562563 "resource-monitor-missing",
564 "wsl-runtime-missing",
565 "wsl-runtime-invalid",
563566 "file-limit-exceeded",
564567]);
565568
......@@ -584,6 +587,12 @@ export class WindowsPackagedPayloadValidationError extends Schema.TaggedErrorCla
584587 if (this.reason === "resource-monitor-missing") {
585588 return "Windows packaged payload is missing the resource monitor executable.";
586589 }
590 if (this.reason === "wsl-runtime-missing") {
591 return "Windows packaged payload is missing the WSL runtime archive or SHA-256 sidecar.";
592 }
593 if (this.reason === "wsl-runtime-invalid") {
594 return "Windows packaged payload contains an invalid WSL runtime archive.";
595 }
587596 if (this.reason === "sidecar-invalid") {
588597 return "Windows packaged payload contains an invalid server.asar sidecar.";
589598 }
......@@ -795,6 +804,8 @@ export const DESKTOP_FILE_EXCLUSIONS = [
795804 // staging inputs out of app.asar; they are emitted once at resources/.
796805 "!apps/desktop/prod-resources/windows-server",
797806 "!apps/desktop/prod-resources/windows-server/**/*",
807 "!apps/desktop/prod-resources/wsl-runtime.tar.gz",
808 "!apps/desktop/prod-resources/wsl-runtime.tar.gz.sha256",
798809] as const;
799810// Windows terminal helpers cannot run on macOS and slow signing and notarization.
800811export const MAC_FILE_EXCLUSIONS = [
......@@ -806,8 +817,8 @@ export const MAC_FILE_EXCLUSIONS = [
806817// then extracts a handful of large archives instead of thousands of small
807818// files, which dominates install (and update) time. The Windows primary runs
808819// the server from inside server.asar via the asar-aware ELECTRON_RUN_AS_NODE
809// runtime; the WSL backend cannot read asar archives, so enabling WSL lazily
810// extracts the sidecar to a version-keyed directory (see DesktopWslServerTree).
820// runtime. WSL normally uses the dedicated compressed Linux runtime below;
821// DesktopWslServerTree can still materialize this sidecar as a fallback.
811822export const WINDOWS_SERVER_ASAR_RESOURCE = "server.asar";
812823// dlopen/spawn need real files, so native modules, shared libraries, and
813824// helper executables live in the server.asar.unpacked sibling (the standard
......@@ -852,6 +863,53 @@ export const WINDOWS_SERVER_EXTRA_RESOURCES = [
852863 filter: [WINDOWS_SERVER_ASAR_RESOURCE, `${WINDOWS_SERVER_ASAR_RESOURCE}.unpacked/**/*`],
853864 },
854865] as const;
866export const WSL_RUNTIME_ARCHIVE_NAME = "wsl-runtime.tar.gz";
867export const WSL_RUNTIME_ARCHIVE_HASH_NAME = `${WSL_RUNTIME_ARCHIVE_NAME}.sha256`;
868export const WSL_RUNTIME_ARCHIVE_EXTRA_RESOURCE = {
869 from: `apps/desktop/prod-resources/${WSL_RUNTIME_ARCHIVE_NAME}`,
870 to: WSL_RUNTIME_ARCHIVE_NAME,
871} as const;
872export const WSL_RUNTIME_ARCHIVE_HASH_EXTRA_RESOURCE = {
873 from: `apps/desktop/prod-resources/${WSL_RUNTIME_ARCHIVE_HASH_NAME}`,
874 to: WSL_RUNTIME_ARCHIVE_HASH_NAME,
875} as const;
876export const WSL_RUNTIME_ARCHIVE_CONTENT_ROOTS = ["apps/server/dist", "node_modules"] as const;
877
878// The WSL runtime uses only the Linux half of the shared Windows/WSL sidecar.
879// Keep build/install metadata and target-native packages that cannot run in
880// WSL out of the compressed archive.
881export const WSL_RUNTIME_ARCHIVE_EXCLUDED_PREFIXES = [
882 "node_modules/@anthropic-ai/claude-agent-sdk-",
883 "node_modules/.bin",
884 "node_modules/.pnpm",
885 "node_modules/.modules.yaml",
886 "node_modules/.pnpm-workspace-state-v1.json",
887 "node_modules/node-pty/prebuilds/darwin-",
888 "node_modules/node-pty/prebuilds/win32-",
889 "node_modules/node-pty/build",
890 "node_modules/node-pty/third_party/conpty",
891 "node_modules/@ff-labs/fff-bin-win32-",
892 "node_modules/@yuuang/ffi-rs-win32-",
893 "node_modules/@msgpackr-extract/msgpackr-extract-win32-",
894] as const;
895// WSL runs the same CPU arch as the Windows host; universal is mac-only.
896export const resolveWslPrebuildArch = (arch: typeof BuildArch.Type): "x64" | "arm64" | undefined =>
897 arch === "x64" ? "x64" : arch === "arm64" ? "arm64" : undefined;
898
899// A packaged WSL runtime is only usable when a Linux pty.node is bundled with
900// it, so this one predicate decides both whether the archive is built and
901// whether the packaging config ships it. Without it the build would produce an
902// archive that can never pass the install script's payload check, and every
903// launch would extract a few hundred MB from /mnt/c only to throw it away.
904export const bundlesWslRuntime = (input: {
905 readonly arch: typeof BuildArch.Type;
906 readonly prebuildPath: string | undefined;
907}): boolean => input.prebuildPath !== undefined && resolveWslPrebuildArch(input.arch) !== undefined;
908
909export const WSL_RUNTIME_EXTRA_RESOURCES = [
910 WSL_RUNTIME_ARCHIVE_EXTRA_RESOURCE,
911 WSL_RUNTIME_ARCHIVE_HASH_EXTRA_RESOURCE,
912] as const;
855913export const DESKTOP_EXTRA_RESOURCES = [
856914 {
857915 from: "apps/desktop/prod-resources/resource-monitor",
......@@ -2077,6 +2135,10 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* (
20772135 readonly provisioningProfilePath: string;
20782136 }
20792137 | undefined,
2138 // Windows only, and false when no Linux node-pty prebuild was bundled: the
2139 // sidecar staging skips the archive in that case, and listing a resource
2140 // whose source file was never written fails the electron-builder step.
2141 wslRuntimeBundled = false,
20802142) {
20812143 const buildConfig: Record<string, unknown> = {
20822144 appId: DESKTOP_APP_ID,
......@@ -2094,6 +2156,7 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* (
20942156 extraResources: [
20952157 ...DESKTOP_EXTRA_RESOURCES,
20962158 ...(platform === "win" ? WINDOWS_SERVER_EXTRA_RESOURCES : []),
2159 ...(platform === "win" && wslRuntimeBundled ? WSL_RUNTIME_EXTRA_RESOURCES : []),
20972160 ],
20982161 };
20992162 const updateChannel = resolveDesktopUpdateChannel(version);
......@@ -2247,8 +2310,7 @@ const stageWslNodePtyPrebuild = Effect.fn("stageWslNodePtyPrebuild")(function* (
22472310 return;
22482311 }
22492312
2250 // WSL runs the same CPU arch as the Windows host; universal is mac-only.
2251 const linuxArch = input.arch === "x64" ? "x64" : input.arch === "arm64" ? "arm64" : undefined;
2313 const linuxArch = resolveWslPrebuildArch(input.arch);
22522314 if (linuxArch === undefined) {
22532315 yield* Effect.logWarning(
22542316 `[desktop-artifact] No WSL node-pty prebuild mapping for arch "${input.arch}"; skipping WSL backend bundling.`,
......@@ -2294,6 +2356,56 @@ const stageWslNodePtyPrebuild = Effect.fn("stageWslNodePtyPrebuild")(function* (
22942356 );
22952357});
22962358
2359// tar reads an `-f` target containing a colon as `host:path` and tries to reach
2360// it over rsh, so handing it a Windows drive path (C:\...\wsl-runtime.tar.gz)
2361// makes Git for Windows' GNU tar fail with "Cannot connect to C: resolve
2362// failed". The staged source tree and the archive both live under the build's
2363// stage root, so the target is always expressible relative to tar's cwd.
2364export const wslRuntimeArchiveTarTarget = (relativeArchivePath: string): string =>
2365 relativeArchivePath.replaceAll("\\", "/");
2366
2367// `archivePath` is relative to the cwd tar runs in; see wslRuntimeArchiveTarTarget.
2368export const buildWslRuntimeArchiveArgs = (
2369 archivePath: string = WSL_RUNTIME_ARCHIVE_EXTRA_RESOURCE.from,
2370): ReadonlyArray<string> => [
2371 "-czf",
2372 archivePath,
2373 ...WSL_RUNTIME_ARCHIVE_EXCLUDED_PREFIXES.map((prefix) => `--exclude=${prefix}*`),
2374 ...WSL_RUNTIME_ARCHIVE_CONTENT_ROOTS,
2375];
2376
2377export const parseWslRuntimeArchiveMembers = (listing: string): ReadonlyArray<string> =>
2378 listing
2379 .split(/\r?\n/)
2380 .map((member) => member.replace(/^\.\//, "").replace(/\/$/, ""))
2381 .filter((member) => member.length > 0);
2382
2383export const stageWslRuntimeArchive = Effect.fn("stageWslRuntimeArchive")(function* (input: {
2384 readonly sourceDir: string;
2385 readonly archivePath: string;
2386 readonly hashPath: string;
2387}) {
2388 const fs = yield* FileSystem.FileSystem;
2389 const path = yield* Path.Path;
2390 yield* fs.makeDirectory(path.dirname(input.archivePath), { recursive: true });
2391 const tarTarget = wslRuntimeArchiveTarTarget(path.relative(input.sourceDir, input.archivePath));
2392 yield* runCommand(
2393 ChildProcess.make("tar", buildWslRuntimeArchiveArgs(tarTarget), {
2394 cwd: input.sourceDir,
2395 }),
2396 { label: "tar WSL runtime", verbose: false },
2397 );
2398 const hash = NodeCrypto.createHash("sha256");
2399 yield* fs
2400 .stream(input.archivePath)
2401 .pipe(Stream.runForEach((chunk) => Effect.sync(() => hash.update(chunk))));
2402 const digest = hash.digest("hex");
2403 yield* fs.writeFileString(input.hashPath, `${digest}\n`);
2404 yield* Effect.log(
2405 `[desktop-artifact] Staged compressed WSL runtime at ${input.archivePath} (${digest}).`,
2406 );
2407});
2408
22972409// Stage and pack the Windows server sidecar: the bundled server plus a hoisted
22982410// install of only its runtime-external/native dependency closure for win32 and
22992411// WSL Linux. The Windows primary runs from the archive through the asar-aware
......@@ -2337,6 +2449,8 @@ export const stageWindowsServerSidecar = Effect.fn("stageWindowsServerSidecar")(
23372449 readonly overrides: Record<string, string>;
23382450 readonly wslPrebuildPath: string | undefined;
23392451 readonly asarPath: string;
2452 readonly wslRuntimeArchivePath: string;
2453 readonly wslRuntimeArchiveHashPath: string;
23402454 readonly verbose: boolean;
23412455}) {
23422456 const fs = yield* FileSystem.FileSystem;
......@@ -2402,6 +2516,16 @@ export const stageWindowsServerSidecar = Effect.fn("stageWindowsServerSidecar")(
24022516 arch: input.arch,
24032517 prebuildPath: input.wslPrebuildPath,
24042518 });
2519 // Skip the archive entirely rather than shipping one the install script must
2520 // extract and reject on every launch. The desktop app treats a missing
2521 // archive as "no WSL-local runtime" and goes straight to the mounted tree.
2522 if (bundlesWslRuntime({ arch: input.arch, prebuildPath: input.wslPrebuildPath })) {
2523 yield* stageWslRuntimeArchive({
2524 sourceDir: serverStageDir,
2525 archivePath: input.wslRuntimeArchivePath,
2526 hashPath: input.wslRuntimeArchiveHashPath,
2527 });
2528 }
24052529
24062530 yield* Effect.log("[desktop-artifact] Packing server.asar...");
24072531 yield* fs.makeDirectory(path.dirname(input.asarPath), { recursive: true });
......@@ -2546,6 +2670,7 @@ export const validateWindowsPackagedPayload = Effect.fn(
25462670 readonly stageDistDir: string;
25472671 readonly appExecutableName: string;
25482672 readonly targetArch: typeof BuildArch.Type;
2673 readonly expectWslRuntime?: boolean;
25492674 readonly fileLimit?: number;
25502675 readonly verbose?: boolean;
25512676}) {
......@@ -2645,6 +2770,99 @@ export const validateWindowsPackagedPayload = Effect.fn(
26452770 });
26462771 }
26472772
2773 const wslArchivePath = path.join(resourcesDir, WSL_RUNTIME_ARCHIVE_NAME);
2774 const wslArchiveHashPath = path.join(resourcesDir, WSL_RUNTIME_ARCHIVE_HASH_NAME);
2775 const [hasWslArchive, hasWslArchiveHash] = yield* Effect.all([
2776 isFile(wslArchivePath),
2777 isFile(wslArchiveHashPath),
2778 ]);
2779 if (input.expectWslRuntime === true && (!hasWslArchive || !hasWslArchiveHash)) {
2780 return yield* new WindowsPackagedPayloadValidationError({
2781 reason: "wsl-runtime-missing",
2782 packagedAppDir,
2783 missingFiles: [
2784 ...(hasWslArchive ? [] : [WSL_RUNTIME_ARCHIVE_NAME]),
2785 ...(hasWslArchiveHash ? [] : [WSL_RUNTIME_ARCHIVE_HASH_NAME]),
2786 ],
2787 });
2788 }
2789 if (hasWslArchive !== hasWslArchiveHash) {
2790 return yield* new WindowsPackagedPayloadValidationError({
2791 reason: "wsl-runtime-missing",
2792 packagedAppDir,
2793 missingFiles: [hasWslArchive ? WSL_RUNTIME_ARCHIVE_HASH_NAME : WSL_RUNTIME_ARCHIVE_NAME],
2794 });
2795 }
2796 if (hasWslArchive && hasWslArchiveHash) {
2797 const invalidWslRuntime = (cause: unknown) =>
2798 new WindowsPackagedPayloadValidationError({
2799 reason: "wsl-runtime-invalid",
2800 packagedAppDir,
2801 cause,
2802 });
2803 const recordedHash = yield* fs
2804 .readFileString(wslArchiveHashPath)
2805 .pipe(Effect.mapError(invalidWslRuntime));
2806 const expectedHash = recordedHash.trim().toLowerCase();
2807 if (!/^[0-9a-f]{64}$/.test(expectedHash)) {
2808 return yield* invalidWslRuntime(new Error("invalid WSL runtime SHA-256 sidecar"));
2809 }
2810 const archiveHash = NodeCrypto.createHash("sha256");
2811 yield* fs.stream(wslArchivePath).pipe(
2812 Stream.runForEach((chunk) => Effect.sync(() => archiveHash.update(chunk))),
2813 Effect.mapError(invalidWslRuntime),
2814 );
2815 const actualHash = archiveHash.digest("hex");
2816 if (actualHash !== expectedHash) {
2817 return yield* invalidWslRuntime(
2818 new Error(`WSL runtime SHA-256 mismatch: expected ${expectedHash}, got ${actualHash}`),
2819 );
2820 }
2821
2822 const listing = yield* spawnAndCollectOutput(
2823 ChildProcess.make("tar", ["-tzf", WSL_RUNTIME_ARCHIVE_NAME], {
2824 cwd: resourcesDir,
2825 stdin: "ignore",
2826 stdout: "pipe",
2827 stderr: "pipe",
2828 }),
2829 ).pipe(Effect.mapError(invalidWslRuntime));
2830 if (listing.exitCode !== 0) {
2831 return yield* invalidWslRuntime(
2832 new Error(`tar could not list WSL runtime archive: ${listing.stderr.trim()}`),
2833 );
2834 }
2835 const members = parseWslRuntimeArchiveMembers(listing.stdout);
2836 const forbiddenMember = members.find((member) =>
2837 WSL_RUNTIME_ARCHIVE_EXCLUDED_PREFIXES.some((prefix) => member.startsWith(prefix)),
2838 );
2839 if (forbiddenMember !== undefined) {
2840 return yield* invalidWslRuntime(
2841 new Error(`WSL runtime archive contains forbidden member ${forbiddenMember}`),
2842 );
2843 }
2844 const wslArch = resolveWslPrebuildArch(input.targetArch);
2845 const requiredMembers = [
2846 "apps/server/dist/bin.mjs",
2847 "node_modules/node-pty/package.json",
2848 ...(wslArch === undefined
2849 ? []
2850 : [
2851 `node_modules/node-pty/prebuilds/linux-${wslArch}/pty.node`,
2852 `node_modules/node-pty/prebuilds/linux-${wslArch}/t3code-wsl-node-pty.json`,
2853 ]),
2854 ];
2855 const missingMembers = requiredMembers.filter((member) => !members.includes(member));
2856 if (missingMembers.length > 0) {
2857 return yield* new WindowsPackagedPayloadValidationError({
2858 reason: "wsl-runtime-invalid",
2859 packagedAppDir,
2860 missingFiles: missingMembers,
2861 cause: new Error("WSL runtime archive is incomplete"),
2862 });
2863 }
2864 }
2865
26482866 const fileCount = yield* countPayloadFiles(packagedAppDir);
26492867 if (fileCount > fileLimit) {
26502868 return yield* new WindowsPackagedPayloadValidationError({
......@@ -2979,6 +3197,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (
29793197 provisioningProfilePath: macPasskeySigning.provisioningProfilePath,
29803198 }
29813199 : undefined,
3200 bundlesWslRuntime({ arch: options.arch, prebuildPath: options.wslPrebuild }),
29823201 ),
29833202 dependencies: stageDependencies,
29843203 devDependencies: {
......@@ -3033,6 +3252,12 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (
30333252 overrides: resolvedOverrides,
30343253 wslPrebuildPath: options.wslPrebuild,
30353254 asarPath: windowsServerAsarPath,
3255 wslRuntimeArchivePath: path.join(stageAppDir, WSL_RUNTIME_ARCHIVE_EXTRA_RESOURCE.from),
3256 wslRuntimeArchiveHashPath: path.join(
3257 stageAppDir,
3258 WSL_RUNTIME_ARCHIVE_HASH_EXTRA_RESOURCE.from,
3259 ),
3260
30363261 verbose: options.verbose,
30373262 });
30383263 }
......@@ -3131,6 +3356,10 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (
31313356 stageDistDir,
31323357 appExecutableName: `${resolveDesktopProductName(appVersion)}.exe`,
31333358 targetArch: options.arch,
3359 expectWslRuntime: bundlesWslRuntime({
3360 arch: options.arch,
3361 prebuildPath: options.wslPrebuild,
3362 }),
31343363 verbose: options.verbose,
31353364 });
31363365 }