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(...@@ -58,14 +58,16 @@ function makeEnvironmentLayer(
58 readonly devServerUrl?: string;58 readonly devServerUrl?: string;
59 readonly platform?: NodeJS.Platform;59 readonly platform?: NodeJS.Platform;
60 readonly resourcesPath?: string;60 readonly resourcesPath?: string;
61 readonly appVersion?: string;
62 readonly processArch?: NodeJS.Architecture;
61 },63 },
62) {64) {
63 return DesktopEnvironment.layer({65 return DesktopEnvironment.layer({
64 dirname: options?.dirname ?? "/repo/apps/desktop/src",66 dirname: options?.dirname ?? "/repo/apps/desktop/src",
65 homeDirectory: baseDir,67 homeDirectory: baseDir,
66 platform: options?.platform ?? "darwin",68 platform: options?.platform ?? "darwin",
67 processArch: "x64",69 processArch: options?.processArch ?? "x64",
68 appVersion: "1.2.3",70 appVersion: options?.appVersion ?? "1.2.3",
69 appPath: options?.appPath ?? "/repo",71 appPath: options?.appPath ?? "/repo",
70 isPackaged: options?.isPackaged ?? true,72 isPackaged: options?.isPackaged ?? true,
71 resourcesPath: options?.resourcesPath ?? "/missing/resources",73 resourcesPath: options?.resourcesPath ?? "/missing/resources",
...@@ -123,7 +125,107 @@ const withHarness = <A, E, R>(...@@ -123,7 +125,107 @@ const withHarness = <A, E, R>(
123 );125 );
124 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer));126 }).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
126describe("DesktopBackendConfiguration", () => {220describe("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
127 it.effect("resolvePrimary produces a stable scoped bootstrap token", () =>229 it.effect("resolvePrimary produces a stable scoped bootstrap token", () =>
128 withHarness(230 withHarness(
129 Effect.gen(function* () {231 Effect.gen(function* () {
...@@ -158,10 +260,11 @@ describe("DesktopBackendConfiguration", () => {...@@ -158,10 +260,11 @@ describe("DesktopBackendConfiguration", () => {
158 it.effect("resolvePrimary starts from server.asar without materializing the WSL tree", () =>260 it.effect("resolvePrimary starts from server.asar without materializing the WSL tree", () =>
159 Effect.gen(function* () {261 Effect.gen(function* () {
160 const fileSystem = yield* FileSystem.FileSystem;262 const fileSystem = yield* FileSystem.FileSystem;
263 const path = yield* Path.Path;
161 const baseDir = yield* fileSystem.makeTempDirectoryScoped({264 const baseDir = yield* fileSystem.makeTempDirectoryScoped({
162 prefix: "t3-desktop-backend-config-test-",265 prefix: "t3-desktop-backend-config-test-",
163 });266 });
164 const resourcesPath = `${baseDir}/resources`;267 const resourcesPath = path.join(baseDir, "resources");
165268
166 const config = yield* Effect.gen(function* () {269 const config = yield* Effect.gen(function* () {
167 const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration;270 const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration;
...@@ -177,6 +280,7 @@ describe("DesktopBackendConfiguration", () => {...@@ -177,6 +280,7 @@ describe("DesktopBackendConfiguration", () => {
177 DesktopWslServerTree.DesktopWslServerTree,280 DesktopWslServerTree.DesktopWslServerTree,
178 DesktopWslServerTree.DesktopWslServerTree.of({281 DesktopWslServerTree.DesktopWslServerTree.of({
179 ensure: Effect.die("Windows primary must not extract the WSL server tree"),282 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"),
180 }),284 }),
181 ),285 ),
182 ),286 ),
...@@ -191,7 +295,10 @@ describe("DesktopBackendConfiguration", () => {...@@ -191,7 +295,10 @@ describe("DesktopBackendConfiguration", () => {
191 ),295 ),
192 );296 );
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 );
195 assert.equal(config.env.ELECTRON_RUN_AS_NODE, "1");302 assert.equal(config.env.ELECTRON_RUN_AS_NODE, "1");
196 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),303 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
197 );304 );
...@@ -239,7 +346,7 @@ describe("DesktopBackendConfiguration", () => {...@@ -239,7 +346,7 @@ describe("DesktopBackendConfiguration", () => {
239 ],346 ],
240 windowsToWslPath: (distro) => {347 windowsToWslPath: (distro) => {
241 observedDistros.push(distro);348 observedDistros.push(distro);
242 return Option.some("/repo/apps/server/dist/bin.mjs");349 return Option.some("/repo");
243 },350 },
244 ensureNodePty: (distro) => {351 ensureNodePty: (distro) => {
245 observedDistros.push(distro);352 observedDistros.push(distro);
...@@ -269,6 +376,275 @@ describe("DesktopBackendConfiguration", () => {...@@ -269,6 +376,275 @@ describe("DesktopBackendConfiguration", () => {
269 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),376 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
270 );377 );
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
272 it.effect(648 it.effect(
273 "resolveWsl preserves inherited PATH with quote-sensitive values as separate args",649 "resolveWsl preserves inherited PATH with quote-sensitive values as separate args",
274 () =>650 () =>
...@@ -283,7 +659,8 @@ describe("DesktopBackendConfiguration", () => {...@@ -283,7 +659,8 @@ describe("DesktopBackendConfiguration", () => {
283 yield* fileSystem.writeFileString(entryPath, "");659 yield* fileSystem.writeFileString(entryPath, "");
284660
285 const nodePath = "/home/test user's/.nvm/versions/node/v22.0.0/bin/node";661 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`;
287 const resolvedPath = "/home/test user/bin:/opt/test's tools/bin:/usr/bin:/bin";664 const resolvedPath = "/home/test user/bin:/opt/test's tools/bin:/usr/bin:/bin";
288 const devServerUrl = "http://127.0.0.1:5733/dev%20assets/?label=hello%20world";665 const devServerUrl = "http://127.0.0.1:5733/dev%20assets/?label=hello%20world";
289 const config = yield* Effect.gen(function* () {666 const config = yield* Effect.gen(function* () {
...@@ -299,7 +676,7 @@ describe("DesktopBackendConfiguration", () => {...@@ -299,7 +676,7 @@ describe("DesktopBackendConfiguration", () => {
299 DesktopWslEnvironment.layerTest({676 DesktopWslEnvironment.layerTest({
300 isAvailable: true,677 isAvailable: true,
301 distros: [{ name: "Ubuntu", isDefault: true, version: 2 }],678 distros: [{ name: "Ubuntu", isDefault: true, version: 2 }],
302 windowsToWslPath: () => Option.some(linuxEntryPath),679 windowsToWslPath: () => Option.some(linuxAppRoot),
303 ensureNodePty: () => ({ ok: true, nodePath, resolvedPath }),680 ensureNodePty: () => ({ ok: true, nodePath, resolvedPath }),
304 getDistroIp: () => Option.some("172.27.0.99"),681 getDistroIp: () => Option.some("172.27.0.99"),
305 }),682 }),
...@@ -815,13 +1192,14 @@ describe("DesktopBackendConfiguration", () => {...@@ -815,13 +1192,14 @@ describe("DesktopBackendConfiguration", () => {
815 it.effect("prefers the external packaged resource monitor over the copy inside the asar", () =>1192 it.effect("prefers the external packaged resource monitor over the copy inside the asar", () =>
816 Effect.gen(function* () {1193 Effect.gen(function* () {
817 const fileSystem = yield* FileSystem.FileSystem;1194 const fileSystem = yield* FileSystem.FileSystem;
1195 const path = yield* Path.Path;
818 const baseDir = yield* fileSystem.makeTempDirectoryScoped({1196 const baseDir = yield* fileSystem.makeTempDirectoryScoped({
819 prefix: "t3-desktop-backend-config-test-",1197 prefix: "t3-desktop-backend-config-test-",
820 });1198 });
821 const resourcesPath = `${baseDir}/resources`;1199 const resourcesPath = path.join(baseDir, "resources");
822 const dirname = `${resourcesPath}/app.asar/apps/desktop/dist-electron`;1200 const dirname = `${resourcesPath}/app.asar/apps/desktop/dist-electron`;
823 const embeddedMonitorPath = `${resourcesPath}/app.asar/apps/desktop/prod-resources/resource-monitor/t3-resource-monitor`;1201 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");
825 yield* fileSystem.makeDirectory(1203 yield* fileSystem.makeDirectory(
826 `${resourcesPath}/app.asar/apps/desktop/prod-resources/resource-monitor`,1204 `${resourcesPath}/app.asar/apps/desktop/prod-resources/resource-monitor`,
827 { recursive: true },1205 { recursive: true },
apps/desktop/src/backend/DesktopBackendConfiguration.ts+185-56
...@@ -213,6 +213,7 @@ interface SharedBootstrapInput {...@@ -213,6 +213,7 @@ interface SharedBootstrapInput {
213interface WslPreflightSuccess {213interface WslPreflightSuccess {
214 readonly _tag: "Ready";214 readonly _tag: "Ready";
215 readonly runningDistro: string;215 readonly runningDistro: string;
216 readonly windowsEntryPath: string;
216 readonly linuxEntryPath: string;217 readonly linuxEntryPath: string;
217 // Absolute path to the node binary the preflight validated after the shared218 // Absolute path to the node binary the preflight validated after the shared
218 // remote resolver repaired PATH. The launch must use this exact path so it219 // remote resolver repaired PATH. The launch must use this exact path so it
...@@ -222,6 +223,8 @@ interface WslPreflightSuccess {...@@ -222,6 +223,8 @@ interface WslPreflightSuccess {
222 // PATH captured from the same login shell after the shared resolver loaded223 // PATH captured from the same login shell after the shared resolver loaded
223 // version managers. The launch forwards this value directly without a shell.224 // version managers. The launch forwards this value directly without a shell.
224 readonly resolvedPath: string;225 readonly resolvedPath: string;
226 // Identifies the distro-local runtime cache selected from the packaged archive.
227 readonly runtimeId?: string;
225}228}
226229
227interface WslPreflightFailure {230interface WslPreflightFailure {
...@@ -236,18 +239,35 @@ interface WslPreflightFailure {...@@ -236,18 +239,35 @@ interface WslPreflightFailure {
236}239}
237240
238const WSL_TRANSIENT_PREFLIGHT_RETRY_LIMIT = 12;241const 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
240const runWslPreflight = Effect.fn("desktop.backendConfiguration.wslPreflight")(function* (input: {256const runWslPreflight = Effect.fn("desktop.backendConfiguration.wslPreflight")(function* (input: {
241 readonly distro: string | null;257 readonly distro: string | null;
242 readonly windowsEntryPath: string;258 readonly runtimeArchive: DesktopWslEnvironment.WslRuntimeArchive | null;
243 readonly windowsRepoRoot: string;
244 readonly allowBuild: boolean;259 readonly allowBuild: boolean;
245}): Effect.fn.Return<260}): Effect.fn.Return<
246 WslPreflightSuccess | WslPreflightFailure,261 WslPreflightSuccess | WslPreflightFailure,
247 never,262 never,
248 DesktopWslEnvironment.DesktopWslEnvironment | FileSystem.FileSystem263 | DesktopEnvironment.DesktopEnvironment
264 | DesktopWslEnvironment.DesktopWslEnvironment
265 | DesktopWslServerTree.DesktopWslServerTree
266 | FileSystem.FileSystem
249> {267> {
268 const environment = yield* DesktopEnvironment.DesktopEnvironment;
250 const wslEnv = yield* DesktopWslEnvironment.DesktopWslEnvironment;269 const wslEnv = yield* DesktopWslEnvironment.DesktopWslEnvironment;
270 const wslServerTree = yield* DesktopWslServerTree.DesktopWslServerTree;
251 const fileSystem = yield* FileSystem.FileSystem;271 const fileSystem = yield* FileSystem.FileSystem;
252272
253 const wslAvailable = yield* wslEnv.isAvailable;273 const wslAvailable = yield* wslEnv.isAvailable;
...@@ -289,43 +309,127 @@ const runWslPreflight = Effect.fn("desktop.backendConfiguration.wslPreflight")(f...@@ -289,43 +309,127 @@ const runWslPreflight = Effect.fn("desktop.backendConfiguration.wslPreflight")(f
289 } as const;309 } as const;
290 }310 }
291311
292 const entryExists = yield* fileSystem312 const nodePtyOptions = {
293 .exists(input.windowsEntryPath)313 allowBuild: input.allowBuild,
294 .pipe(Effect.orElseSucceed(() => false));314 nodeEngineRange: serverPackageJson.engines.node,
295 if (!entryExists) {315 };
296 return {316 const failedNodePty = (result: FailedNodePtyResult) =>
317 ({
297 _tag: "Failed",318 _tag: "Failed",
298 reason: `missing server entry at ${input.windowsEntryPath}`,319 reason: `WSL node-pty unavailable: ${result.reason}`,
299 fatal: true,320 fatal: result.fatal,
300 } as const;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 }
301 }397 }
302398
303 const linuxEntry = yield* wslEnv.windowsToWslPath(runningDistro, input.windowsEntryPath);399 const mounted = yield* resolveMountedAppRoot;
304 if (Option.isNone(linuxEntry)) {400 if (!mounted.ok) {
305 return {401 return stagedFailure && mounted.fatal
306 _tag: "Failed",402 ? failedNodePty(stagedFailure.nodePty)
307 reason: `wslpath conversion failed for ${input.windowsEntryPath}`,403 : ({ _tag: "Failed", reason: mounted.reason, fatal: mounted.fatal } as const);
308 fatal: false,
309 } as const;
310 }404 }
311405
312 const nodePtyResult = yield* wslEnv.ensureNodePty(runningDistro, input.windowsRepoRoot, {406 const nodePtyResult = yield* wslEnv.ensureNodePty(
313 allowBuild: input.allowBuild,407 runningDistro,
314 nodeEngineRange: serverPackageJson.engines.node,408 mounted.linuxAppRoot,
315 });409 nodePtyOptions,
410 );
316 if (!nodePtyResult.ok) {411 if (!nodePtyResult.ok) {
317 return {412 // Substituting the staged verdict for a transient mounted failure would
318 _tag: "Failed",413 // turn a retryable failure into a fatal one, ending the WSL attempt (and,
319 reason: `WSL node-pty unavailable: ${nodePtyResult.reason}`,414 // in wsl-only mode, persisting Windows) before the slow /mnt path had a
320 fatal: nodePtyResult.fatal,415 // chance to answer and clear the bad cache.
321 ...(nodePtyResult.retryLimit === undefined ? {} : { retryLimit: nodePtyResult.retryLimit }),416 return failedNodePty(
322 } as const;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);
323 }426 }
324427
325 return {428 return {
326 _tag: "Ready",429 _tag: "Ready",
327 runningDistro,430 runningDistro,
328 linuxEntryPath: linuxEntry.value,431 windowsEntryPath: mounted.windowsEntryPath,
432 linuxEntryPath: `${mounted.linuxAppRoot}/apps/server/dist/bin.mjs`,
329 nodePath: nodePtyResult.nodePath,433 nodePath: nodePtyResult.nodePath,
330 resolvedPath: nodePtyResult.resolvedPath,434 resolvedPath: nodePtyResult.resolvedPath,
331 } as const;435 } as const;
...@@ -430,7 +534,7 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl...@@ -430,7 +534,7 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl
430> {534> {
431 const environment = yield* DesktopEnvironment.DesktopEnvironment;535 const environment = yield* DesktopEnvironment.DesktopEnvironment;
432 const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment;536 const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment;
433 const wslServerTree = yield* DesktopWslServerTree.DesktopWslServerTree;537 const fileSystem = yield* FileSystem.FileSystem;
434538
435 // Bind to 0.0.0.0 inside WSL so the backend is reachable both via539 // Bind to 0.0.0.0 inside WSL so the backend is reachable both via
436 // WSL2's automatic localhost forwarding (wslhost: Windows 127.0.0.1540 // WSL2's automatic localhost forwarding (wslhost: Windows 127.0.0.1
...@@ -467,31 +571,54 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl...@@ -467,31 +571,54 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl
467 ...buildObservabilityFragment(input.observabilitySettings),571 ...buildObservabilityFragment(input.observabilitySettings),
468 };572 };
469573
470 // In packaged builds the server tree ships inside resources/server.asar —574 // The archive is the primary packaged WSL path: it installs directly into
471 // an archive FILE the Windows primary reads through ELECTRON_RUN_AS_NODE575 // the distro's ext4 filesystem. The server.asar extraction service is only
472 // (asar-aware). The WSL backend launches plain `wsl.exe -- node`, which576 // consulted lazily if the archive is unavailable or cannot be staged.
473 // can't read an asar, so materialize (or reuse) the extracted copy of the577 const archivePath = environment.path.join(environment.resourcesPath, WSL_RUNTIME_ARCHIVE_NAME);
474 // sidecar before preflighting. In dev the server tree is the real checkout578 const archiveHashPath = environment.path.join(
475 // directory and ensure returns it unchanged.579 environment.resourcesPath,
476 const serverTree = yield* wslServerTree.ensure;580 WSL_RUNTIME_ARCHIVE_HASH_NAME,
477 const wslAppRoot = serverTree.ok ? serverTree.root : environment.serverRoot;581 );
478 const wslEntryPath = environment.path.join(wslAppRoot, "apps/server/dist/bin.mjs");582
479583 const hasArchive = environment.isPackaged
480 const preflight = serverTree.ok584 ? yield* fileSystem.exists(archivePath).pipe(Effect.orElseSucceed(() => false))
481 ? yield* runWslPreflight({585 : false;
482 distro: input.distro,586 const archiveHash = hasArchive
483 windowsEntryPath: wslEntryPath,587 ? yield* fileSystem.readFileString(archiveHashPath).pipe(
484 windowsRepoRoot: wslAppRoot,588 Effect.map(parseWslRuntimeArchiveHash),
485 // Packaged builds ship a prebuilt Linux node-pty (built on Linux in CI and589 Effect.orElseSucceed(() => null),
486 // attached to the Windows artifact — see build-desktop-artifact.ts), so the590 )
487 // WSL backend never needs a compiler, node-gyp, or network on first launch.591 : null;
488 // Compiling from source is a dev-only convenience: a checkout has no shipped592 if (hasArchive && archiveHash === null) {
489 // prebuilt, and developers have the toolchain. In packaged builds we instead593 yield* Effect.logWarning(
490 // surface a clear diagnostic if the prebuilt can't load (unsupported594 "Ignoring the WSL runtime archive because its SHA-256 identity is missing or invalid; launching from the mounted server tree instead.",
491 // arch/distro), rather than silently dropping into a fragile runtime build.595 { hashPath: archiveHashPath },
492 allowBuild: !environment.isPackaged,596 );
493 })597 }
494 : ({ _tag: "Failed", reason: serverTree.reason, fatal: serverTree.fatal } as const);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
496 // Every operation after preflight uses the same concrete distro. In623 // Every operation after preflight uses the same concrete distro. In
497 // default-tracking mode this closes the race where the system default624 // default-tracking mode this closes the race where the system default
...@@ -537,7 +664,8 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl...@@ -537,7 +664,8 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl
537664
538 const baseConfig = {665 const baseConfig = {
539 executablePath: "wsl.exe",666 executablePath: "wsl.exe",
540 entryPath: wslEntryPath,667 entryPath:
668 preflight._tag === "Ready" ? preflight.windowsEntryPath : environment.backendEntryPath,
541 cwd: environment.backendCwd,669 cwd: environment.backendCwd,
542 env: {670 env: {
543 ...parentEnvWithoutT3Home,671 ...parentEnvWithoutT3Home,
...@@ -605,6 +733,7 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl...@@ -605,6 +733,7 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl
605 ...devUrlArgs,733 ...devUrlArgs,
606 ],734 ],
607 preflightFailure: Option.none(),735 preflightFailure: Option.none(),
736 ...(preflight.runtimeId === undefined ? {} : { wslRuntimeId: preflight.runtimeId }),
608 } satisfies DesktopBackendManager.DesktopBackendStartConfig;737 } satisfies DesktopBackendManager.DesktopBackendStartConfig;
609});738});
610739
apps/desktop/src/backend/DesktopBackendManager.test.ts+27-3
...@@ -25,6 +25,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";...@@ -25,6 +25,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
25import * as DesktopBackendManager from "./DesktopBackendManager.ts";25import * as DesktopBackendManager from "./DesktopBackendManager.ts";
26import * as DesktopObservability from "../app/DesktopObservability.ts";26import * as DesktopObservability from "../app/DesktopObservability.ts";
27import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts";27import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts";
28import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts";
2829
29const decodeDesktopBackendBootstrap = Schema.decodeEffect(30const decodeDesktopBackendBootstrap = Schema.decodeEffect(
30 Schema.fromJsonString(DesktopBackendBootstrap),31 Schema.fromJsonString(DesktopBackendBootstrap),
...@@ -132,6 +133,7 @@ interface MakeInstanceInput {...@@ -132,6 +133,7 @@ interface MakeInstanceInput {
132 readonly desktopTelemetryPublisher?: Partial<133 readonly desktopTelemetryPublisher?: Partial<
133 DesktopTelemetryPublisher.DesktopTelemetryPublisher["Service"]134 DesktopTelemetryPublisher.DesktopTelemetryPublisher["Service"]
134 >;135 >;
136 readonly pruneRuntimes?: (distro: string | null, runtimeId: string) => Effect.Effect<void>;
135}137}
136138
137// Helper that constructs a primary backend instance using the factory139// Helper that constructs a primary backend instance using the factory
...@@ -167,6 +169,9 @@ function makeTestInstance(input: MakeInstanceInput) {...@@ -167,6 +169,9 @@ function makeTestInstance(input: MakeInstanceInput) {
167 removeControlSource: () => Effect.void,169 removeControlSource: () => Effect.void,
168 ...input.desktopTelemetryPublisher,170 ...input.desktopTelemetryPublisher,
169 }),171 }),
172 DesktopWslEnvironment.layerTest(
173 input.pruneRuntimes === undefined ? {} : { pruneRuntimes: input.pruneRuntimes },
174 ),
170 );175 );
171176
172 const instance = DesktopBackendManager.makeBackendInstance({177 const instance = DesktopBackendManager.makeBackendInstance({
...@@ -647,10 +652,13 @@ describe("DesktopBackendManager", () => {...@@ -647,10 +652,13 @@ describe("DesktopBackendManager", () => {
647 Effect.scoped(652 Effect.scoped(
648 Effect.gen(function* () {653 Effect.gen(function* () {
649 const requestUrls: Array<string> = [];654 const requestUrls: Array<string> = [];
655 const prunedRuntimes: Array<[string | null, string]> = [];
650 const statuses = [503, 200];656 const statuses = [503, 200];
651 let readyCount = 0;657 let readyCount = 0;
652 const firstRequest = yield* Deferred.make<void>();658 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>();
654 const exited = yield* Queue.unbounded<void>();662 const exited = yield* Queue.unbounded<void>();
655663
656 const spawnerLayer = Layer.succeed(664 const spawnerLayer = Layer.succeed(
...@@ -658,7 +666,9 @@ describe("DesktopBackendManager", () => {...@@ -658,7 +666,9 @@ describe("DesktopBackendManager", () => {
658 ChildProcessSpawner.make(() =>666 ChildProcessSpawner.make(() =>
659 Effect.succeed(667 Effect.succeed(
660 makeProcess({668 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 ),
662 }),672 }),
663 ),673 ),
664 ),674 ),
...@@ -666,6 +676,15 @@ describe("DesktopBackendManager", () => {...@@ -666,6 +676,15 @@ describe("DesktopBackendManager", () => {
666676
667 const instance = yield* makeTestInstance({677 const instance = yield* makeTestInstance({
668 spawnerLayer,678 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),
669 httpClientLayer: httpClientLayer((request) =>688 httpClientLayer: httpClientLayer((request) =>
670 Effect.gen(function* () {689 Effect.gen(function* () {
671 const status = statuses.shift();690 const status = statuses.shift();
...@@ -677,7 +696,7 @@ describe("DesktopBackendManager", () => {...@@ -677,7 +696,7 @@ describe("DesktopBackendManager", () => {
677 ),696 ),
678 onReady: Effect.sync(() => {697 onReady: Effect.sync(() => {
679 readyCount += 1;698 readyCount += 1;
680 }).pipe(Effect.andThen(Deferred.succeed(ready, void 0)), Effect.asVoid),699 }).pipe(Effect.andThen(Deferred.succeed(backendReady, void 0)), Effect.asVoid),
681 backendOutputLog: {700 backendOutputLog: {
682 persistFailure: () => Queue.offer(exited, void 0).pipe(Effect.asVoid),701 persistFailure: () => Queue.offer(exited, void 0).pipe(Effect.asVoid),
683 },702 },
...@@ -687,12 +706,17 @@ describe("DesktopBackendManager", () => {...@@ -687,12 +706,17 @@ describe("DesktopBackendManager", () => {
687 yield* Deferred.await(firstRequest);706 yield* Deferred.await(firstRequest);
688707
689 assert.equal(readyCount, 0);708 assert.equal(readyCount, 0);
709 assert.deepEqual(prunedRuntimes, []);
690 assert.deepEqual(requestUrls, ["http://127.0.0.1:3773/.well-known/t3/environment"]);710 assert.deepEqual(requestUrls, ["http://127.0.0.1:3773/.well-known/t3/environment"]);
691711
692 yield* TestClock.adjust(Duration.millis(100));712 yield* TestClock.adjust(Duration.millis(100));
713 yield* Deferred.await(backendReady);
714 yield* Deferred.await(pruneComplete);
715 yield* Deferred.succeed(processExit, void 0);
693 yield* Queue.take(exited);716 yield* Queue.take(exited);
694717
695 assert.equal(readyCount, 1);718 assert.equal(readyCount, 1);
719 assert.deepEqual(prunedRuntimes, [["Ubuntu", "1.2.3-x64"]]);
696 assert.deepEqual(requestUrls, [720 assert.deepEqual(requestUrls, [
697 "http://127.0.0.1:3773/.well-known/t3/environment",721 "http://127.0.0.1:3773/.well-known/t3/environment",
698 "http://127.0.0.1:3773/.well-known/t3/environment",722 "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...@@ -52,6 +52,7 @@ import { waitForHttpReady as waitForHttpReadyShared } from "@t3tools/shared/http
5252
53import * as DesktopObservability from "../app/DesktopObservability.ts";53import * as DesktopObservability from "../app/DesktopObservability.ts";
54import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts";54import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts";
55import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts";
5556
56const INITIAL_RESTART_DELAY = Duration.millis(500);57const INITIAL_RESTART_DELAY = Duration.millis(500);
57const MAX_RESTART_DELAY = Duration.seconds(10);58const MAX_RESTART_DELAY = Duration.seconds(10);
...@@ -99,6 +100,10 @@ export interface DesktopBackendStartConfig extends BackendProcessContext {...@@ -99,6 +100,10 @@ export interface DesktopBackendStartConfig extends BackendProcessContext {
99 // Present for a WSL run after the configured/default distro has been100 // Present for a WSL run after the configured/default distro has been
100 // resolved to the concrete distro passed to wsl.exe.101 // resolved to the concrete distro passed to wsl.exe.
101 readonly runningDistro?: string;102 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;
102}107}
103108
104// A preflight failure records whether it is fatal. Transient failures (WSL109// A preflight failure records whether it is fatal. Transient failures (WSL
...@@ -637,6 +642,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* (...@@ -637,6 +642,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* (
637 | HttpClient.HttpClient642 | HttpClient.HttpClient
638 | DesktopObservability.DesktopBackendOutputLogFactory643 | DesktopObservability.DesktopBackendOutputLogFactory
639 | DesktopTelemetryPublisher.DesktopTelemetryPublisher644 | DesktopTelemetryPublisher.DesktopTelemetryPublisher
645 | DesktopWslEnvironment.DesktopWslEnvironment
640 | Scope.Scope646 | Scope.Scope
641> {647> {
642 const parentScope = yield* Scope.Scope;648 const parentScope = yield* Scope.Scope;
...@@ -644,6 +650,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* (...@@ -644,6 +650,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* (
644 const backendOutputLogFactory = yield* DesktopObservability.DesktopBackendOutputLogFactory;650 const backendOutputLogFactory = yield* DesktopObservability.DesktopBackendOutputLogFactory;
645 const backendOutputLog = yield* backendOutputLogFactory.forInstance(spec.id);651 const backendOutputLog = yield* backendOutputLogFactory.forInstance(spec.id);
646 const desktopTelemetryPublisher = yield* DesktopTelemetryPublisher.DesktopTelemetryPublisher;652 const desktopTelemetryPublisher = yield* DesktopTelemetryPublisher.DesktopTelemetryPublisher;
653 const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment;
647 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;654 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
648 const httpClient = yield* HttpClient.HttpClient;655 const httpClient = yield* HttpClient.HttpClient;
649 const state = yield* Ref.make(initialState);656 const state = yield* Ref.make(initialState);
...@@ -939,6 +946,15 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* (...@@ -939,6 +946,15 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* (
939 }946 }
940947
941 yield* spec.onReady?.(config.value.httpBaseUrl) ?? Effect.void;948 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 }
942 }),958 }),
943 onReadinessFailure: Effect.fn("desktop.backendInstance.onReadinessFailure")(959 onReadinessFailure: Effect.fn("desktop.backendInstance.onReadinessFailure")(
944 function* (error) {960 function* (error) {
apps/desktop/src/backend/DesktopBackendPool.test.ts+2
...@@ -14,6 +14,7 @@ import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";...@@ -14,6 +14,7 @@ import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
14import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts";14import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts";
15import * as ElectronDialog from "../electron/ElectronDialog.ts";15import * as ElectronDialog from "../electron/ElectronDialog.ts";
16import * as DesktopWindow from "../window/DesktopWindow.ts";16import * as DesktopWindow from "../window/DesktopWindow.ts";
17import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts";
17import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts";18import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts";
18import * as DesktopBackendPool from "./DesktopBackendPool.ts";19import * as DesktopBackendPool from "./DesktopBackendPool.ts";
19import type { DesktopBackendSnapshot, DesktopBackendStartConfig } from "./DesktopBackendManager.ts";20import type { DesktopBackendSnapshot, DesktopBackendStartConfig } from "./DesktopBackendManager.ts";
...@@ -79,6 +80,7 @@ function makePoolLayer(...@@ -79,6 +80,7 @@ function makePoolLayer(
79 resolveWsl: () => Effect.die("unexpected WSL config resolve"),80 resolveWsl: () => Effect.die("unexpected WSL config resolve"),
80 } satisfies DesktopBackendConfiguration.DesktopBackendConfiguration["Service"]),81 } satisfies DesktopBackendConfiguration.DesktopBackendConfiguration["Service"]),
81 DesktopAppSettings.layerTest(),82 DesktopAppSettings.layerTest(),
83 DesktopWslEnvironment.layerTest(),
82 ElectronDialog.layer,84 ElectronDialog.layer,
83 Layer.succeed(DesktopWindow.DesktopWindow, {85 Layer.succeed(DesktopWindow.DesktopWindow, {
84 createMain: Effect.die("unexpected window create"),86 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";...@@ -99,6 +99,7 @@ import * as DesktopObservability from "../app/DesktopObservability.ts";
99import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";99import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
100import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts";100import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts";
101import * as DesktopWindow from "../window/DesktopWindow.ts";101import * as DesktopWindow from "../window/DesktopWindow.ts";
102import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts";
102import * as ElectronDialog from "../electron/ElectronDialog.ts";103import * as ElectronDialog from "../electron/ElectronDialog.ts";
103104
104const { logWarning: logBackendPoolWarning } =105const { logWarning: logBackendPoolWarning } =
...@@ -178,7 +179,8 @@ export type BackendInstanceFactoryRequirements =...@@ -178,7 +179,8 @@ export type BackendInstanceFactoryRequirements =
178 | ChildProcessSpawner.ChildProcessSpawner179 | ChildProcessSpawner.ChildProcessSpawner
179 | HttpClient.HttpClient180 | HttpClient.HttpClient
180 | DesktopObservability.DesktopBackendOutputLogFactory181 | DesktopObservability.DesktopBackendOutputLogFactory
181 | DesktopTelemetryPublisher.DesktopTelemetryPublisher;182 | DesktopTelemetryPublisher.DesktopTelemetryPublisher
183 | DesktopWslEnvironment.DesktopWslEnvironment;
182184
183interface ActiveRegisteredInstance {185interface ActiveRegisteredInstance {
184 readonly _tag: "Active";186 readonly _tag: "Active";
apps/desktop/src/wsl/DesktopWslEnvironment.test.ts+623-1
...@@ -1,5 +1,7 @@...@@ -1,5 +1,7 @@
1// @effect-diagnostics nodeBuiltinImport:off - the executed suite runs the generated install script through a real POSIX shell.
1import { describe, it } from "@effect/vitest";2import { 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";
3import * as Duration from "effect/Duration";5import * as Duration from "effect/Duration";
4import * as Effect from "effect/Effect";6import * as Effect from "effect/Effect";
5import * as Fiber from "effect/Fiber";7import * as Fiber from "effect/Fiber";
...@@ -11,6 +13,9 @@ import { ChildProcessSpawner } from "effect/unstable/process";...@@ -11,6 +13,9 @@ import { ChildProcessSpawner } from "effect/unstable/process";
1113
12import {14import {
13 buildWslNodeEnvPreamble,15 buildWslNodeEnvPreamble,
16 buildWslRuntimeInstallScript,
17 buildWslRuntimeInvalidateScript,
18 buildWslRuntimePruneScript,
14 DesktopWslDistroListError,19 DesktopWslDistroListError,
15 formatMissingToolsReason,20 formatMissingToolsReason,
16 formatNodePtyProbeFailureReason,21 formatNodePtyProbeFailureReason,
...@@ -19,11 +24,62 @@ import {...@@ -19,11 +24,62 @@ import {
19 parseNodeVersion,24 parseNodeVersion,
20 parseResolvedPath,25 parseResolvedPath,
21 parseToolchainReport,26 parseToolchainReport,
27 parseWslRuntimeRoot,
22 probeWslDistros,28 probeWslDistros,
29 sanitizeWslRuntimeId,
23} from "./DesktopWslEnvironment.ts";30} from "./DesktopWslEnvironment.ts";
2431
25const encoder = new TextEncoder();32const 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
27const makeDistroListSpawner = (result: { readonly stdout?: string; readonly exitCode?: number }) =>83const makeDistroListSpawner = (result: { readonly stdout?: string; readonly exitCode?: number }) =>
28 ChildProcessSpawner.make(() =>84 ChildProcessSpawner.make(() =>
29 Effect.succeed(85 Effect.succeed(
...@@ -125,6 +181,572 @@ describe("buildWslNodeEnvPreamble", () => {...@@ -125,6 +181,572 @@ describe("buildWslNodeEnvPreamble", () => {
125 });181 });
126});182});
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
128describe("parseToolchainReport", () => {750describe("parseToolchainReport", () => {
129 it("returns no missing tools and no node version on empty output", () => {751 it("returns no missing tools and no node version on empty output", () => {
130 expect(parseToolchainReport("")).toEqual({ missingTools: [], nodeVersion: null });752 expect(parseToolchainReport("")).toEqual({ missingTools: [], nodeVersion: null });
apps/desktop/src/wsl/DesktopWslEnvironment.ts+404-26
...@@ -22,6 +22,9 @@ const WSLPATH_TIMEOUT = Duration.seconds(10);...@@ -22,6 +22,9 @@ const WSLPATH_TIMEOUT = Duration.seconds(10);
22const PROBE_TIMEOUT = Duration.seconds(10);22const PROBE_TIMEOUT = Duration.seconds(10);
23const TOOLCHAIN_TIMEOUT = Duration.seconds(10);23const TOOLCHAIN_TIMEOUT = Duration.seconds(10);
24const BUILD_TIMEOUT = Duration.minutes(5);24const 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);
25const USER_HOME_TIMEOUT = Duration.seconds(5);28const USER_HOME_TIMEOUT = Duration.seconds(5);
26const TOOLCHAIN_TRANSPORT_RETRY_LIMIT = 12;29const TOOLCHAIN_TRANSPORT_RETRY_LIMIT = 12;
27const BUILD_TRANSPORT_RETRY_LIMIT = 2;30const BUILD_TRANSPORT_RETRY_LIMIT = 2;
...@@ -31,6 +34,25 @@ export interface EnsureWslNodePtyOptions {...@@ -31,6 +34,25 @@ export interface EnsureWslNodePtyOptions {
31 readonly nodeEngineRange?: string | null;34 readonly nodeEngineRange?: string | null;
32}35}
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
34export type EnsureWslNodePtyResult =56export type EnsureWslNodePtyResult =
35 | {57 | {
36 readonly ok: true;58 readonly ok: true;
...@@ -79,9 +101,16 @@ export class DesktopWslEnvironment extends Context.Service<...@@ -79,9 +101,16 @@ export class DesktopWslEnvironment extends Context.Service<
79 // (the backend can be listening for 30+ seconds before wslhost starts101 // (the backend can be listening for 30+ seconds before wslhost starts
80 // forwarding 127.0.0.1:port to WSL-side localhost).102 // forwarding 127.0.0.1:port to WSL-side localhost).
81 readonly getDistroIp: (distro: string | null) => Effect.Effect<Option.Option<string>>;103 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>;
82 readonly ensureNodePty: (111 readonly ensureNodePty: (
83 distro: string | null,112 distro: string | null,
84 windowsRepoRoot: string,113 linuxAppRoot: string,
85 options?: EnsureWslNodePtyOptions,114 options?: EnsureWslNodePtyOptions,
86 ) => Effect.Effect<EnsureWslNodePtyResult>;115 ) => Effect.Effect<EnsureWslNodePtyResult>;
87 }116 }
...@@ -149,18 +178,28 @@ const runWslShell = (...@@ -149,18 +178,28 @@ const runWslShell = (
149 distro: string | null,178 distro: string | null,
150 bashScript: string,179 bashScript: string,
151 timeout: Duration.Duration,180 timeout: Duration.Duration,
152 options: EnsureWslNodePtyOptions = {},181 options: {
182 readonly nodeEngineRange?: string | null;
183 readonly resolveNode?: boolean;
184 } = {},
153): Effect.Effect<ShellResult, never, ChildProcessSpawner.ChildProcessSpawner> => {185): Effect.Effect<ShellResult, never, ChildProcessSpawner.ChildProcessSpawner> => {
154 const spawner = ChildProcessSpawner.ChildProcessSpawner;186 const spawner = ChildProcessSpawner.ChildProcessSpawner;
155 // -l picks up profile-managed PATH; the shared resolver covers supported187 // Node probes use a login bash so profile-managed PATH entries and supported
156 // version managers that non-interactive login shells can miss. -s so bash188 // version managers are available. Runtime installation needs only POSIX tools,
157 // reads the script from stdin.189 // so it skips profile loading and runs sh directly.
190 const resolveNode = options.resolveNode !== false;
158 const command = ChildProcess.make(191 const command = ChildProcess.make(
159 "wsl.exe",192 "wsl.exe",
160 [...buildDistroArgs(distro), "--", "bash", "-l", "-s"],193 resolveNode
194 ? [...buildDistroArgs(distro), "--", "bash", "-l", "-s"]
195 : [...buildDistroArgs(distro), "--exec", "sh", "-s"],
161 {196 {
162 stdin: Stream.encodeText(197 stdin: Stream.encodeText(
163 Stream.make(`${buildWslNodeEnvPreamble(options.nodeEngineRange)}${bashScript}`),198 Stream.make(
199 resolveNode
200 ? `${buildWslNodeEnvPreamble(options.nodeEngineRange)}${bashScript}`
201 : bashScript,
202 ),
164 ),203 ),
165 stdout: "pipe",204 stdout: "pipe",
166 stderr: "pipe",205 stderr: "pipe",
...@@ -216,6 +255,240 @@ const runWslShell = (...@@ -216,6 +255,240 @@ const runWslShell = (
216255
217const shellQuote = (value: string): string => `'${value.replaceAll("'", "'\\''")}'`;256const 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
219const NODE_PTY_PREBUILD_MISSING_EXIT_CODE = 4;492const NODE_PTY_PREBUILD_MISSING_EXIT_CODE = 4;
220493
221export const formatNodePtyProbeFailureReason = (exitCode: number): string | null =>494export const formatNodePtyProbeFailureReason = (exitCode: number): string | null =>
...@@ -390,23 +663,10 @@ export const formatMissingToolsReason = (...@@ -390,23 +663,10 @@ export const formatMissingToolsReason = (
390663
391const ensureNodePtyImpl = (664const ensureNodePtyImpl = (
392 distro: string | null,665 distro: string | null,
393 windowsRepoRoot: string,666 linuxRepoRoot: string,
394 windowsToWslPath: (
395 distro: string | null,
396 windowsPath: string,
397 ) => Effect.Effect<Option.Option<string>>,
398 options: EnsureWslNodePtyOptions = {},667 options: EnsureWslNodePtyOptions = {},
399): Effect.Effect<EnsureWslNodePtyResult, never, ChildProcessSpawner.ChildProcessSpawner> =>668): Effect.Effect<EnsureWslNodePtyResult, never, ChildProcessSpawner.ChildProcessSpawner> =>
400 Effect.gen(function* () {669 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;
410 // node-pty lives in the apps/server workspace's node_modules; resolve from670 // node-pty lives in the apps/server workspace's node_modules; resolve from
411 // there rather than the monorepo root, where Bun's hoist layout omits it.671 // there rather than the monorepo root, where Bun's hoist layout omits it.
412 const linuxServerDir = `${linuxRepoRoot}/apps/server`;672 const linuxServerDir = `${linuxRepoRoot}/apps/server`;
...@@ -584,6 +844,96 @@ const ensureNodePtyImpl = (...@@ -584,6 +844,96 @@ const ensureNodePtyImpl = (
584 } as const;844 } as const;
585 });845 });
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
587export const probeWslDistros: Effect.Effect<937export const probeWslDistros: Effect.Effect<
588 readonly WslDistro[],938 readonly WslDistro[],
589 DesktopWslDistroListError,939 DesktopWslDistroListError,
...@@ -778,9 +1128,15 @@ export interface DesktopWslEnvironmentTestStub {...@@ -778,9 +1128,15 @@ export interface DesktopWslEnvironmentTestStub {
778 readonly windowsToWslPath?: (distro: string | null, windowsPath: string) => Option.Option<string>;1128 readonly windowsToWslPath?: (distro: string | null, windowsPath: string) => Option.Option<string>;
779 readonly getUserHome?: (distro: string | null) => Option.Option<string>;1129 readonly getUserHome?: (distro: string | null) => Option.Option<string>;
780 readonly getDistroIp?: (distro: string | null) => Option.Option<string>;1130 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>;
781 readonly ensureNodePty?: (1137 readonly ensureNodePty?: (
782 distro: string | null,1138 distro: string | null,
783 windowsRepoRoot: string,1139 linuxAppRoot: string,
784 options?: EnsureWslNodePtyOptions,1140 options?: EnsureWslNodePtyOptions,
785 ) => EnsureWslNodePtyResult;1141 ) => EnsureWslNodePtyResult;
786}1142}
...@@ -800,9 +1156,19 @@ export const layerTest = (stub: DesktopWslEnvironmentTestStub = {}) => {...@@ -800,9 +1156,19 @@ export const layerTest = (stub: DesktopWslEnvironmentTestStub = {}) => {
800 Effect.succeed(stub.windowsToWslPath?.(distro, windowsPath) ?? Option.none()),1156 Effect.succeed(stub.windowsToWslPath?.(distro, windowsPath) ?? Option.none()),
801 getUserHome: (distro) => Effect.succeed(stub.getUserHome?.(distro) ?? Option.none<string>()),1157 getUserHome: (distro) => Effect.succeed(stub.getUserHome?.(distro) ?? Option.none<string>()),
802 getDistroIp: (distro) => Effect.succeed(stub.getDistroIp?.(distro) ?? Option.none<string>()),1158 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) =>
804 Effect.succeed(1170 Effect.succeed(
805 stub.ensureNodePty?.(distro, windowsRepoRoot, options) ?? {1171 stub.ensureNodePty?.(distro, linuxAppRoot, options) ?? {
806 ok: false,1172 ok: false,
807 reason: "ensureNodePty stub not configured",1173 reason: "ensureNodePty stub not configured",
808 fatal: true,1174 fatal: true,
...@@ -882,8 +1248,20 @@ export const layer = Layer.effect(...@@ -882,8 +1248,20 @@ export const layer = Layer.effect(
882 windowsToWslPath,1248 windowsToWslPath,
883 getUserHome,1249 getUserHome,
884 getDistroIp,1250 getDistroIp,
885 ensureNodePty: (distro, windowsRepoRoot, options) =>1251 prepareRuntime: (distro, archive) =>
886 provideSpawner(ensureNodePtyImpl(distro, windowsRepoRoot, windowsToWslPath, options)).pipe(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(
887 Effect.withSpan("desktop.wsl.ensureNodePty"),1265 Effect.withSpan("desktop.wsl.ensureNodePty"),
888 ),1266 ),
889 });1267 });
apps/desktop/src/wsl/DesktopWslServerTree.test.ts+87
...@@ -297,6 +297,93 @@ describe("DesktopWslServerTree", () => {...@@ -297,6 +297,93 @@ describe("DesktopWslServerTree", () => {
297 ).pipe(Effect.provide(NodeServices.layer)),297 ).pipe(Effect.provide(NodeServices.layer)),
298 );298 );
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
300 it.effect("reports a retryable failure when the archive cannot be read", () =>387 it.effect("reports a retryable failure when the archive cannot be read", () =>
301 withTempDir((tempDir) =>388 withTempDir((tempDir) =>
302 Effect.gen(function* () {389 Effect.gen(function* () {
apps/desktop/src/wsl/DesktopWslServerTree.ts+32-5
...@@ -11,10 +11,9 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";...@@ -11,10 +11,9 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
11// Packaged Windows builds ship the server tree inside resources/server.asar11// Packaged Windows builds ship the server tree inside resources/server.asar
12// (see scripts/build-desktop-artifact.ts). The Windows primary reads it in12// (see scripts/build-desktop-artifact.ts). The Windows primary reads it in
13// place through the asar-aware ELECTRON_RUN_AS_NODE runtime, but the WSL13// place through the asar-aware ELECTRON_RUN_AS_NODE runtime, but the WSL
14// backend launches plain `wsl.exe -- node`, which cannot read an asar14// backend launches plain `wsl.exe -- node`, which cannot read an asar archive.
15// archive. This service materializes the archive into a real, version-keyed15// This fallback service materializes the archive into a real, version-keyed
16// directory the first time the WSL backend starts, and reuses it afterwards —16// directory only when the distro-local runtime cannot be prepared.
17// so only users who enable WSL ever pay for a loose copy of the server tree.
18//17//
19// Reading through Electron's patched fs also transparently returns the18// Reading through Electron's patched fs also transparently returns the
20// contents of files that electron-builder/asar left in the server.asar.unpacked19// contents of files that electron-builder/asar left in the server.asar.unpacked
...@@ -52,6 +51,10 @@ export class DesktopWslServerTree extends Context.Service<...@@ -52,6 +51,10 @@ export class DesktopWslServerTree extends Context.Service<
52 // the checkout already is that directory; packaged Windows builds extract51 // the checkout already is that directory; packaged Windows builds extract
53 // server.asar on first use.52 // server.asar on first use.
54 readonly ensure: Effect.Effect<WslServerTreeResult>;53 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>;
55 }58 }
56>()("@t3tools/desktop/wsl/DesktopWslServerTree") {}59>()("@t3tools/desktop/wsl/DesktopWslServerTree") {}
5760
...@@ -173,6 +176,28 @@ export const make = Effect.gen(function* () {...@@ -173,6 +176,28 @@ export const make = Effect.gen(function* () {
173 // first caller extracts, later callers see the marker and reuse the tree.176 // first caller extracts, later callers see the marker and reuse the tree.
174 const gate = yield* Semaphore.make(1);177 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
176 const ensure: Effect.Effect<WslServerTreeResult> = gate201 const ensure: Effect.Effect<WslServerTreeResult> = gate
177 .withPermits(1)(202 .withPermits(1)(
178 Effect.gen(function* () {203 Effect.gen(function* () {
...@@ -205,13 +230,14 @@ export const make = Effect.gen(function* () {...@@ -205,13 +230,14 @@ export const make = Effect.gen(function* () {
205 )230 )
206 .pipe(Effect.withSpan("desktop.wslServerTree.ensure"));231 .pipe(Effect.withSpan("desktop.wslServerTree.ensure"));
207232
208 return DesktopWslServerTree.of({ ensure });233 return DesktopWslServerTree.of({ ensure, cleanupLegacy });
209});234});
210235
211export const layer = Layer.effect(DesktopWslServerTree, make);236export const layer = Layer.effect(DesktopWslServerTree, make);
212237
213export interface DesktopWslServerTreeTestStub {238export interface DesktopWslServerTreeTestStub {
214 readonly result?: WslServerTreeResult;239 readonly result?: WslServerTreeResult;
240 readonly cleanupLegacy?: Effect.Effect<void>;
215}241}
216242
217export const layerTest = (stub: DesktopWslServerTreeTestStub = {}) =>243export const layerTest = (stub: DesktopWslServerTreeTestStub = {}) =>
...@@ -221,6 +247,7 @@ export const layerTest = (stub: DesktopWslServerTreeTestStub = {}) =>...@@ -221,6 +247,7 @@ export const layerTest = (stub: DesktopWslServerTreeTestStub = {}) =>
221 const environment = yield* DesktopEnvironment.DesktopEnvironment;247 const environment = yield* DesktopEnvironment.DesktopEnvironment;
222 return DesktopWslServerTree.of({248 return DesktopWslServerTree.of({
223 ensure: Effect.succeed(stub.result ?? { ok: true, root: environment.appRoot }),249 ensure: Effect.succeed(stub.result ?? { ok: true, root: environment.appRoot }),
250 cleanupLegacy: stub.cleanupLegacy ?? Effect.void,
224 });251 });
225 }),252 }),
226 );253 );
docs/operations/release.md+11-4
...@@ -222,10 +222,12 @@ Windows packages the bundled server and only its runtime-external/native...@@ -222,10 +222,12 @@ Windows packages the bundled server and only its runtime-external/native
222dependency closure in `resources/server.asar`. Native modules and helper222dependency closure in `resources/server.asar`. Native modules and helper
223executables declared as unpacked by that archive must be present at the matching223executables declared as unpacked by that archive must be present at the matching
224paths below `resources/server.asar.unpacked`. The Windows-native backend reads224paths below `resources/server.asar.unpacked`. The Windows-native backend reads
225the archive in place through Electron. WSL cannot read ASAR files, so enabling225the archive in place through Electron. Packaged Windows builds also ship a
226the WSL backend extracts the server tree once into the desktop state directory226Linux-only `resources/wsl-runtime.tar.gz` plus its SHA-256 sidecar. WSL verifies
227under `wsl-server-tree/<version>` and reuses the completed version until the app227and extracts that archive into `~/.t3/wsl-runtime/sha256-<archive-digest>` inside
228is updated.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
230The artifact builder rejects a Windows package when any of these invariants232The artifact builder rejects a Windows package when any of these invariants
231break:233break:
...@@ -236,6 +238,11 @@ break:...@@ -236,6 +238,11 @@ break:
236- On same-architecture Windows builds, the packaged primary cannot load the fff238- On same-architecture Windows builds, the packaged primary cannot load the fff
237 native library from inside `server.asar` through its `.unpacked` sibling.239 native library from inside `server.asar` through its `.unpacked` sibling.
238- The isolated, extracted sidecar cannot load the server entry with plain Node.240- 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.
239- The external Windows resource monitor is absent.246- The external Windows resource monitor is absent.
240- The unpacked Windows application contains more than 80 files.247- The unpacked Windows application contains more than 80 files.
241248
docs/user/install.md+10
...@@ -49,6 +49,16 @@ Nightly:...@@ -49,6 +49,16 @@ Nightly:
49yay -S t3code-nightly-bin49yay -S t3code-nightly-bin
50```50```
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
52## Providers62## Providers
5363
54T3 Code drives provider CLIs; it does not ship them. Install the CLI for each provider you want64T3 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,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
1import * as NodeServices from "@effect/platform-node/NodeServices";4import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, it } from "@effect/vitest";5import { assert, it } from "@effect/vitest";
3import * as ConfigProvider from "effect/ConfigProvider";6import * as ConfigProvider from "effect/ConfigProvider";
...@@ -8,11 +11,13 @@ import * as Option from "effect/Option";...@@ -8,11 +11,13 @@ import * as Option from "effect/Option";
8import * as Path from "effect/Path";11import * as Path from "effect/Path";
9import * as Sink from "effect/Sink";12import * as Sink from "effect/Sink";
10import * as Stream from "effect/Stream";13import * as Stream from "effect/Stream";
11import { ChildProcessSpawner } from "effect/unstable/process";14import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
1215
13import {16import {
14 BundleNotSelfContainedError,17 BundleNotSelfContainedError,
15 BuildCommandFailedError,18 BuildCommandFailedError,
19 buildWslRuntimeArchiveArgs,
20 parseWslRuntimeArchiveMembers,
16 DesktopDmgBackgroundSourceMissingError,21 DesktopDmgBackgroundSourceMissingError,
17 createStageWorkspaceConfig,22 createStageWorkspaceConfig,
18 createStagePatchedDependencies,23 createStagePatchedDependencies,
...@@ -51,6 +56,8 @@ import {...@@ -51,6 +56,8 @@ import {
51 stageLinuxIconSize,56 stageLinuxIconSize,
52 stageDesktopDmgBackground,57 stageDesktopDmgBackground,
53 stageResourceMonitor,58 stageResourceMonitor,
59 stageWslRuntimeArchive,
60 bundlesWslRuntime,
54 STAGE_INSTALL_ARGS,61 STAGE_INSTALL_ARGS,
55 ancestorNodeModulesPaths,62 ancestorNodeModulesPaths,
56 copyDirectoryPreservingSymlinks,63 copyDirectoryPreservingSymlinks,
...@@ -63,10 +70,38 @@ import {...@@ -63,10 +70,38 @@ import {
63 WINDOWS_SERVER_ASAR_RESOURCE,70 WINDOWS_SERVER_ASAR_RESOURCE,
64 WINDOWS_SERVER_ASAR_UNPACK_GLOB,71 WINDOWS_SERVER_ASAR_UNPACK_GLOB,
65 WINDOWS_SERVER_RESOURCE_SOURCE_DIR,72 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,
66} from "./build-desktop-artifact.ts";79} from "./build-desktop-artifact.ts";
67import { BRAND_ASSET_PATHS } from "./lib/brand-assets.ts";80import { BRAND_ASSET_PATHS } from "./lib/brand-assets.ts";
68import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess";81import { 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
70function mockProcess(exitCode: number) {105function mockProcess(exitCode: number) {
71 return ChildProcessSpawner.makeHandle({106 return ChildProcessSpawner.makeHandle({
72 pid: ChildProcessSpawner.ProcessId(1),107 pid: ChildProcessSpawner.ProcessId(1),
...@@ -107,6 +142,7 @@ function iconResizeSpawnerLayer(...@@ -107,6 +142,7 @@ function iconResizeSpawnerLayer(
107const makeWindowsPayloadFixture = Effect.fn("test.makeWindowsPayloadFixture")(function* (input: {142const makeWindowsPayloadFixture = Effect.fn("test.makeWindowsPayloadFixture")(function* (input: {
108 readonly copyUnpackedNatives: boolean;143 readonly copyUnpackedNatives: boolean;
109 readonly serverEntrySource?: string;144 readonly serverEntrySource?: string;
145 readonly wslRuntime?: "valid" | "forbidden" | "bad-digest";
110}) {146}) {
111 const fs = yield* FileSystem.FileSystem;147 const fs = yield* FileSystem.FileSystem;
112 const path = yield* Path.Path;148 const path = yield* Path.Path;
...@@ -143,6 +179,61 @@ const makeWindowsPayloadFixture = Effect.fn("test.makeWindowsPayloadFixture")(fu...@@ -143,6 +179,61 @@ const makeWindowsPayloadFixture = Effect.fn("test.makeWindowsPayloadFixture")(fu
143 yield* fs.writeFileString(path.join(packagedAppDir, appExecutableName), "electron");179 yield* fs.writeFileString(path.join(packagedAppDir, appExecutableName), "electron");
144 yield* fs.writeFileString(path.join(packagedAppDir, "chrome_crashpad_handler.exe"), "crashpad");180 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
146 return {237 return {
147 stageDistDir,238 stageDistDir,
148 packagedAppDir,239 packagedAppDir,
...@@ -434,12 +525,27 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {...@@ -434,12 +525,27 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
434 );525 );
435 });526 });
436527
437 it("limits Electron locales and excludes the unused Claude SDK executable", () => {528 it("limits Electron locales and excludes separately packaged resources", () => {
438 assert.deepStrictEqual(DESKTOP_ELECTRON_LANGUAGES, ["en-US"]);529 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
439 assert.deepStrictEqual(DESKTOP_FILE_EXCLUSIONS, [543 assert.deepStrictEqual(DESKTOP_FILE_EXCLUSIONS, [
440 "!**/node_modules/@anthropic-ai/claude-agent-sdk-*/**/*",544 "!**/node_modules/@anthropic-ai/claude-agent-sdk-*/**/*",
441 "!apps/desktop/prod-resources/windows-server",545 "!apps/desktop/prod-resources/windows-server",
442 "!apps/desktop/prod-resources/windows-server/**/*",546 "!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",
443 ]);549 ]);
444 assert.equal(WINDOWS_SERVER_RESOURCE_SOURCE_DIR, "apps/desktop/prod-resources/windows-server");550 assert.equal(WINDOWS_SERVER_RESOURCE_SOURCE_DIR, "apps/desktop/prod-resources/windows-server");
445 assert.deepStrictEqual(WINDOWS_SERVER_EXTRA_RESOURCES, [551 assert.deepStrictEqual(WINDOWS_SERVER_EXTRA_RESOURCES, [
...@@ -479,6 +585,17 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {...@@ -479,6 +585,17 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
479 false,585 false,
480 undefined,586 undefined,
481 undefined,587 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,
482 );599 );
483600
484 // All platforms keep app.asar fully packed; Windows ships the server601 // All platforms keep app.asar fully packed; Windows ships the server
...@@ -493,6 +610,16 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {...@@ -493,6 +610,16 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
493 to: "resource-monitor",610 to: "resource-monitor",
494 },611 },
495 ...WINDOWS_SERVER_EXTRA_RESOURCES,612 ...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,
496 ]);623 ]);
497 assert.deepStrictEqual(win.nsis, { differentialPackage: true });624 assert.deepStrictEqual(win.nsis, { differentialPackage: true });
498 // Native binaries and helper executables cannot load from inside an625 // Native binaries and helper executables cannot load from inside an
...@@ -716,6 +843,82 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {...@@ -716,6 +843,82 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
716 ),843 ),
717 );844 );
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
719 it.effect("probes fff through the packaged Windows primary instead of helper executables", () => {922 it.effect("probes fff through the packaged Windows primary instead of helper executables", () => {
720 const commands: Array<{923 const commands: Array<{
721 readonly command: string;924 readonly command: string;
...@@ -1257,6 +1460,206 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {...@@ -1257,6 +1460,206 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
1257 assert.equal(resourceMonitorExecutableName("mac"), "t3-resource-monitor");1460 assert.equal(resourceMonitorExecutableName("mac"), "t3-resource-monitor");
1258 assert.equal(resourceMonitorExecutableName("win"), "t3-resource-monitor.exe");1461 assert.equal(resourceMonitorExecutableName("win"), "t3-resource-monitor.exe");
1259 });1462 });
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
1260 it("promotes target fff binaries to direct staged dependencies", () => {1663 it("promotes target fff binaries to direct staged dependencies", () => {
1261 assert.deepStrictEqual(resolveFffNativeDependencies("mac", "arm64", "0.9.4"), {1664 assert.deepStrictEqual(resolveFffNativeDependencies("mac", "arm64", "0.9.4"), {
1262 "@ff-labs/fff-bin-darwin-arm64": "0.9.4",1665 "@ff-labs/fff-bin-darwin-arm64": "0.9.4",
scripts/build-desktop-artifact.ts+233-4
...@@ -2,6 +2,7 @@...@@ -2,6 +2,7 @@
2// @effect-diagnostics nodeBuiltinImport:off - Node's typed junction API avoids Windows symlink privileges while keeping the probe isolated.2// @effect-diagnostics nodeBuiltinImport:off - Node's typed junction API avoids Windows symlink privileges while keeping the probe isolated.
33
4import * as NodeFSP from "node:fs/promises";4import * as NodeFSP from "node:fs/promises";
5import * as NodeCrypto from "node:crypto";
5import * as NodeModule from "node:module";6import * as NodeModule from "node:module";
67
7import {8import {
...@@ -560,6 +561,8 @@ const WindowsPackagedPayloadValidationReason = Schema.Literals([...@@ -560,6 +561,8 @@ const WindowsPackagedPayloadValidationReason = Schema.Literals([
560 "sidecar-invalid",561 "sidecar-invalid",
561 "unpacked-native-missing",562 "unpacked-native-missing",
562 "resource-monitor-missing",563 "resource-monitor-missing",
564 "wsl-runtime-missing",
565 "wsl-runtime-invalid",
563 "file-limit-exceeded",566 "file-limit-exceeded",
564]);567]);
565568
...@@ -584,6 +587,12 @@ export class WindowsPackagedPayloadValidationError extends Schema.TaggedErrorCla...@@ -584,6 +587,12 @@ export class WindowsPackagedPayloadValidationError extends Schema.TaggedErrorCla
584 if (this.reason === "resource-monitor-missing") {587 if (this.reason === "resource-monitor-missing") {
585 return "Windows packaged payload is missing the resource monitor executable.";588 return "Windows packaged payload is missing the resource monitor executable.";
586 }589 }
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 }
587 if (this.reason === "sidecar-invalid") {596 if (this.reason === "sidecar-invalid") {
588 return "Windows packaged payload contains an invalid server.asar sidecar.";597 return "Windows packaged payload contains an invalid server.asar sidecar.";
589 }598 }
...@@ -795,6 +804,8 @@ export const DESKTOP_FILE_EXCLUSIONS = [...@@ -795,6 +804,8 @@ export const DESKTOP_FILE_EXCLUSIONS = [
795 // staging inputs out of app.asar; they are emitted once at resources/.804 // staging inputs out of app.asar; they are emitted once at resources/.
796 "!apps/desktop/prod-resources/windows-server",805 "!apps/desktop/prod-resources/windows-server",
797 "!apps/desktop/prod-resources/windows-server/**/*",806 "!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",
798] as const;809] as const;
799// Windows terminal helpers cannot run on macOS and slow signing and notarization.810// Windows terminal helpers cannot run on macOS and slow signing and notarization.
800export const MAC_FILE_EXCLUSIONS = [811export const MAC_FILE_EXCLUSIONS = [
...@@ -806,8 +817,8 @@ export const MAC_FILE_EXCLUSIONS = [...@@ -806,8 +817,8 @@ export const MAC_FILE_EXCLUSIONS = [
806// then extracts a handful of large archives instead of thousands of small817// then extracts a handful of large archives instead of thousands of small
807// files, which dominates install (and update) time. The Windows primary runs818// files, which dominates install (and update) time. The Windows primary runs
808// the server from inside server.asar via the asar-aware ELECTRON_RUN_AS_NODE819// 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 lazily820// runtime. WSL normally uses the dedicated compressed Linux runtime below;
810// extracts the sidecar to a version-keyed directory (see DesktopWslServerTree).821// DesktopWslServerTree can still materialize this sidecar as a fallback.
811export const WINDOWS_SERVER_ASAR_RESOURCE = "server.asar";822export const WINDOWS_SERVER_ASAR_RESOURCE = "server.asar";
812// dlopen/spawn need real files, so native modules, shared libraries, and823// dlopen/spawn need real files, so native modules, shared libraries, and
813// helper executables live in the server.asar.unpacked sibling (the standard824// helper executables live in the server.asar.unpacked sibling (the standard
...@@ -852,6 +863,53 @@ export const WINDOWS_SERVER_EXTRA_RESOURCES = [...@@ -852,6 +863,53 @@ export const WINDOWS_SERVER_EXTRA_RESOURCES = [
852 filter: [WINDOWS_SERVER_ASAR_RESOURCE, `${WINDOWS_SERVER_ASAR_RESOURCE}.unpacked/**/*`],863 filter: [WINDOWS_SERVER_ASAR_RESOURCE, `${WINDOWS_SERVER_ASAR_RESOURCE}.unpacked/**/*`],
853 },864 },
854] as const;865] 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;
855export const DESKTOP_EXTRA_RESOURCES = [913export const DESKTOP_EXTRA_RESOURCES = [
856 {914 {
857 from: "apps/desktop/prod-resources/resource-monitor",915 from: "apps/desktop/prod-resources/resource-monitor",
...@@ -2077,6 +2135,10 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* (...@@ -2077,6 +2135,10 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* (
2077 readonly provisioningProfilePath: string;2135 readonly provisioningProfilePath: string;
2078 }2136 }
2079 | undefined,2137 | 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,
2080) {2142) {
2081 const buildConfig: Record<string, unknown> = {2143 const buildConfig: Record<string, unknown> = {
2082 appId: DESKTOP_APP_ID,2144 appId: DESKTOP_APP_ID,
...@@ -2094,6 +2156,7 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* (...@@ -2094,6 +2156,7 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* (
2094 extraResources: [2156 extraResources: [
2095 ...DESKTOP_EXTRA_RESOURCES,2157 ...DESKTOP_EXTRA_RESOURCES,
2096 ...(platform === "win" ? WINDOWS_SERVER_EXTRA_RESOURCES : []),2158 ...(platform === "win" ? WINDOWS_SERVER_EXTRA_RESOURCES : []),
2159 ...(platform === "win" && wslRuntimeBundled ? WSL_RUNTIME_EXTRA_RESOURCES : []),
2097 ],2160 ],
2098 };2161 };
2099 const updateChannel = resolveDesktopUpdateChannel(version);2162 const updateChannel = resolveDesktopUpdateChannel(version);
...@@ -2247,8 +2310,7 @@ const stageWslNodePtyPrebuild = Effect.fn("stageWslNodePtyPrebuild")(function* (...@@ -2247,8 +2310,7 @@ const stageWslNodePtyPrebuild = Effect.fn("stageWslNodePtyPrebuild")(function* (
2247 return;2310 return;
2248 }2311 }
22492312
2250 // WSL runs the same CPU arch as the Windows host; universal is mac-only.2313 const linuxArch = resolveWslPrebuildArch(input.arch);
2251 const linuxArch = input.arch === "x64" ? "x64" : input.arch === "arm64" ? "arm64" : undefined;
2252 if (linuxArch === undefined) {2314 if (linuxArch === undefined) {
2253 yield* Effect.logWarning(2315 yield* Effect.logWarning(
2254 `[desktop-artifact] No WSL node-pty prebuild mapping for arch "${input.arch}"; skipping WSL backend bundling.`,2316 `[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* (...@@ -2294,6 +2356,56 @@ const stageWslNodePtyPrebuild = Effect.fn("stageWslNodePtyPrebuild")(function* (
2294 );2356 );
2295});2357});
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
2297// Stage and pack the Windows server sidecar: the bundled server plus a hoisted2409// Stage and pack the Windows server sidecar: the bundled server plus a hoisted
2298// install of only its runtime-external/native dependency closure for win32 and2410// install of only its runtime-external/native dependency closure for win32 and
2299// WSL Linux. The Windows primary runs from the archive through the asar-aware2411// WSL Linux. The Windows primary runs from the archive through the asar-aware
...@@ -2337,6 +2449,8 @@ export const stageWindowsServerSidecar = Effect.fn("stageWindowsServerSidecar")(...@@ -2337,6 +2449,8 @@ export const stageWindowsServerSidecar = Effect.fn("stageWindowsServerSidecar")(
2337 readonly overrides: Record<string, string>;2449 readonly overrides: Record<string, string>;
2338 readonly wslPrebuildPath: string | undefined;2450 readonly wslPrebuildPath: string | undefined;
2339 readonly asarPath: string;2451 readonly asarPath: string;
2452 readonly wslRuntimeArchivePath: string;
2453 readonly wslRuntimeArchiveHashPath: string;
2340 readonly verbose: boolean;2454 readonly verbose: boolean;
2341}) {2455}) {
2342 const fs = yield* FileSystem.FileSystem;2456 const fs = yield* FileSystem.FileSystem;
...@@ -2402,6 +2516,16 @@ export const stageWindowsServerSidecar = Effect.fn("stageWindowsServerSidecar")(...@@ -2402,6 +2516,16 @@ export const stageWindowsServerSidecar = Effect.fn("stageWindowsServerSidecar")(
2402 arch: input.arch,2516 arch: input.arch,
2403 prebuildPath: input.wslPrebuildPath,2517 prebuildPath: input.wslPrebuildPath,
2404 });2518 });
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
2406 yield* Effect.log("[desktop-artifact] Packing server.asar...");2530 yield* Effect.log("[desktop-artifact] Packing server.asar...");
2407 yield* fs.makeDirectory(path.dirname(input.asarPath), { recursive: true });2531 yield* fs.makeDirectory(path.dirname(input.asarPath), { recursive: true });
...@@ -2546,6 +2670,7 @@ export const validateWindowsPackagedPayload = Effect.fn(...@@ -2546,6 +2670,7 @@ export const validateWindowsPackagedPayload = Effect.fn(
2546 readonly stageDistDir: string;2670 readonly stageDistDir: string;
2547 readonly appExecutableName: string;2671 readonly appExecutableName: string;
2548 readonly targetArch: typeof BuildArch.Type;2672 readonly targetArch: typeof BuildArch.Type;
2673 readonly expectWslRuntime?: boolean;
2549 readonly fileLimit?: number;2674 readonly fileLimit?: number;
2550 readonly verbose?: boolean;2675 readonly verbose?: boolean;
2551}) {2676}) {
...@@ -2645,6 +2770,99 @@ export const validateWindowsPackagedPayload = Effect.fn(...@@ -2645,6 +2770,99 @@ export const validateWindowsPackagedPayload = Effect.fn(
2645 });2770 });
2646 }2771 }
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
2648 const fileCount = yield* countPayloadFiles(packagedAppDir);2866 const fileCount = yield* countPayloadFiles(packagedAppDir);
2649 if (fileCount > fileLimit) {2867 if (fileCount > fileLimit) {
2650 return yield* new WindowsPackagedPayloadValidationError({2868 return yield* new WindowsPackagedPayloadValidationError({
...@@ -2979,6 +3197,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (...@@ -2979,6 +3197,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (
2979 provisioningProfilePath: macPasskeySigning.provisioningProfilePath,3197 provisioningProfilePath: macPasskeySigning.provisioningProfilePath,
2980 }3198 }
2981 : undefined,3199 : undefined,
3200 bundlesWslRuntime({ arch: options.arch, prebuildPath: options.wslPrebuild }),
2982 ),3201 ),
2983 dependencies: stageDependencies,3202 dependencies: stageDependencies,
2984 devDependencies: {3203 devDependencies: {
...@@ -3033,6 +3252,12 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (...@@ -3033,6 +3252,12 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (
3033 overrides: resolvedOverrides,3252 overrides: resolvedOverrides,
3034 wslPrebuildPath: options.wslPrebuild,3253 wslPrebuildPath: options.wslPrebuild,
3035 asarPath: windowsServerAsarPath,3254 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
3036 verbose: options.verbose,3261 verbose: options.verbose,
3037 });3262 });
3038 }3263 }
...@@ -3131,6 +3356,10 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (...@@ -3131,6 +3356,10 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (
3131 stageDistDir,3356 stageDistDir,
3132 appExecutableName: `${resolveDesktopProductName(appVersion)}.exe`,3357 appExecutableName: `${resolveDesktopProductName(appVersion)}.exe`,
3133 targetArch: options.arch,3358 targetArch: options.arch,
3359 expectWslRuntime: bundlesWslRuntime({
3360 arch: options.arch,
3361 prebuildPath: options.wslPrebuild,
3362 }),
3134 verbose: options.verbose,3363 verbose: options.verbose,
3135 });3364 });
3136 }3365 }