| author | |
| committer | |
| log | f94a0d646ed78a4788e4af6417f74202a628a5e9 |
| tree | aca9d44cb1be4d37eb65978d4884f893da280674 |
| parent | 018d7f2775daabd2ef07898af29586915a0b7f67 |
| signature | Signed by PGP key B5690EEEBB952194 |
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)); |
| 125 | 127 | ||
| 128 | interface 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 | |||
| 137 | const 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 | |||
| 126 | describe("DesktopBackendConfiguration", () => { | 220 | describe("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"); |
| 165 | 268 | ||
| 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 | ); |
| 193 | 297 | ||
| 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 | ); |
| 271 | 378 | ||
| 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, ""); |
| 284 | 660 | ||
| 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 { |
| 213 | interface WslPreflightSuccess { | 213 | interface 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 shared | 218 | // 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 it | 219 | // 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 loaded | 223 | // 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 | } |
| 226 | 229 | ||
| 227 | interface WslPreflightFailure { | 230 | interface WslPreflightFailure { |
| ... | @@ -236,18 +239,35 @@ interface WslPreflightFailure { | ... | @@ -236,18 +239,35 @@ interface WslPreflightFailure { |
| 236 | } | 239 | } |
| 237 | 240 | ||
| 238 | const WSL_TRANSIENT_PREFLIGHT_RETRY_LIMIT = 12; | 241 | const WSL_TRANSIENT_PREFLIGHT_RETRY_LIMIT = 12; |
| 242 | const WSL_RUNTIME_ARCHIVE_NAME = "wsl-runtime.tar.gz"; | ||
| 243 | const WSL_RUNTIME_ARCHIVE_HASH_NAME = `${WSL_RUNTIME_ARCHIVE_NAME}.sha256`; | ||
| 244 | const SHA256_HEX_PATTERN = /^[0-9a-f]{64}$/i; | ||
| 245 | |||
| 246 | export const parseWslRuntimeArchiveHash = (value: string): string | null => { | ||
| 247 | const trimmed = value.trim(); | ||
| 248 | return SHA256_HEX_PATTERN.test(trimmed) ? trimmed.toLowerCase() : null; | ||
| 249 | }; | ||
| 250 | |||
| 251 | type FailedNodePtyResult = Extract< | ||
| 252 | DesktopWslEnvironment.EnsureWslNodePtyResult, | ||
| 253 | { readonly ok: false } | ||
| 254 | >; | ||
| 239 | 255 | ||
| 240 | const runWslPreflight = Effect.fn("desktop.backendConfiguration.wslPreflight")(function* (input: { | 256 | const 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.FileSystem | 263 | | 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; |
| 252 | 272 | ||
| 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 | } |
| 291 | 311 | ||
| 292 | const entryExists = yield* fileSystem | 312 | 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 | } |
| 302 | 398 | ||
| 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 | } |
| 311 | 405 | ||
| 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 | } |
| 324 | 427 | ||
| 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; |
| 434 | 538 | ||
| 435 | // Bind to 0.0.0.0 inside WSL so the backend is reachable both via | 539 | // 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.1 | 540 | // 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 | }; |
| 469 | 573 | ||
| 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_NODE | 575 | // the distro's ext4 filesystem. The server.asar extraction service is only |
| 472 | // (asar-aware). The WSL backend launches plain `wsl.exe -- node`, which | 576 | // 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 the | 577 | const archivePath = environment.path.join(environment.resourcesPath, WSL_RUNTIME_ARCHIVE_NAME); |
| 474 | // sidecar before preflighting. In dev the server tree is the real checkout | 578 | 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 | |
| 479 | 583 | const hasArchive = environment.isPackaged | |
| 480 | const preflight = serverTree.ok | 584 | ? 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 and | 589 | Effect.orElseSucceed(() => null), |
| 486 | // attached to the Windows artifact — see build-desktop-artifact.ts), so the | 590 | ) |
| 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 shipped | 592 | if (hasArchive && archiveHash === null) { |
| 489 | // prebuilt, and developers have the toolchain. In packaged builds we instead | 593 | yield* Effect.logWarning( |
| 490 | // surface a clear diagnostic if the prebuilt can't load (unsupported | 594 | "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 | }); | ||
| 495 | 622 | ||
| 496 | // Every operation after preflight uses the same concrete distro. In | 623 | // Every operation after preflight uses the same concrete distro. In |
| 497 | // default-tracking mode this closes the race where the system default | 624 | // 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 |
| 537 | 664 | ||
| 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 | }); |
| 610 | 739 |
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"; |
| 25 | import * as DesktopBackendManager from "./DesktopBackendManager.ts"; | 25 | import * as DesktopBackendManager from "./DesktopBackendManager.ts"; |
| 26 | import * as DesktopObservability from "../app/DesktopObservability.ts"; | 26 | import * as DesktopObservability from "../app/DesktopObservability.ts"; |
| 27 | import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts"; | 27 | import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts"; |
| 28 | import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts"; | ||
| 28 | 29 | ||
| 29 | const decodeDesktopBackendBootstrap = Schema.decodeEffect( | 30 | const 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 | } |
| 136 | 138 | ||
| 137 | // Helper that constructs a primary backend instance using the factory | 139 | // 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 | ); |
| 171 | 176 | ||
| 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>(); |
| 655 | 663 | ||
| 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", () => { |
| 666 | 676 | ||
| 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); |
| 688 | 707 | ||
| 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"]); |
| 691 | 711 | ||
| 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); |
| 694 | 717 | ||
| 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 |
| 52 | 52 | ||
| 53 | import * as DesktopObservability from "../app/DesktopObservability.ts"; | 53 | import * as DesktopObservability from "../app/DesktopObservability.ts"; |
| 54 | import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts"; | 54 | import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts"; |
| 55 | import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts"; | ||
| 55 | 56 | ||
| 56 | const INITIAL_RESTART_DELAY = Duration.millis(500); | 57 | const INITIAL_RESTART_DELAY = Duration.millis(500); |
| 57 | const MAX_RESTART_DELAY = Duration.seconds(10); | 58 | const 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 been | 100 | // 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 | } |
| 103 | 108 | ||
| 104 | // A preflight failure records whether it is fatal. Transient failures (WSL | 109 | // 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.HttpClient | 642 | | HttpClient.HttpClient |
| 638 | | DesktopObservability.DesktopBackendOutputLogFactory | 643 | | DesktopObservability.DesktopBackendOutputLogFactory |
| 639 | | DesktopTelemetryPublisher.DesktopTelemetryPublisher | 644 | | DesktopTelemetryPublisher.DesktopTelemetryPublisher |
| 645 | | DesktopWslEnvironment.DesktopWslEnvironment | ||
| 640 | | Scope.Scope | 646 | | 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 | } |
| 940 | 947 | ||
| 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"; |
| 14 | import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts"; | 14 | import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts"; |
| 15 | import * as ElectronDialog from "../electron/ElectronDialog.ts"; | 15 | import * as ElectronDialog from "../electron/ElectronDialog.ts"; |
| 16 | import * as DesktopWindow from "../window/DesktopWindow.ts"; | 16 | import * as DesktopWindow from "../window/DesktopWindow.ts"; |
| 17 | import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts"; | ||
| 17 | import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts"; | 18 | import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts"; |
| 18 | import * as DesktopBackendPool from "./DesktopBackendPool.ts"; | 19 | import * as DesktopBackendPool from "./DesktopBackendPool.ts"; |
| 19 | import type { DesktopBackendSnapshot, DesktopBackendStartConfig } from "./DesktopBackendManager.ts"; | 20 | import 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"; |
| 99 | import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; | 99 | import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; |
| 100 | import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts"; | 100 | import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts"; |
| 101 | import * as DesktopWindow from "../window/DesktopWindow.ts"; | 101 | import * as DesktopWindow from "../window/DesktopWindow.ts"; |
| 102 | import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts"; | ||
| 102 | import * as ElectronDialog from "../electron/ElectronDialog.ts"; | 103 | import * as ElectronDialog from "../electron/ElectronDialog.ts"; |
| 103 | 104 | ||
| 104 | const { logWarning: logBackendPoolWarning } = | 105 | const { logWarning: logBackendPoolWarning } = |
| ... | @@ -178,7 +179,8 @@ export type BackendInstanceFactoryRequirements = | ... | @@ -178,7 +179,8 @@ export type BackendInstanceFactoryRequirements = |
| 178 | | ChildProcessSpawner.ChildProcessSpawner | 179 | | ChildProcessSpawner.ChildProcessSpawner |
| 179 | | HttpClient.HttpClient | 180 | | HttpClient.HttpClient |
| 180 | | DesktopObservability.DesktopBackendOutputLogFactory | 181 | | DesktopObservability.DesktopBackendOutputLogFactory |
| 181 | | DesktopTelemetryPublisher.DesktopTelemetryPublisher; | 182 | | DesktopTelemetryPublisher.DesktopTelemetryPublisher |
| 183 | | DesktopWslEnvironment.DesktopWslEnvironment; | ||
| 182 | 184 | ||
| 183 | interface ActiveRegisteredInstance { | 185 | interface 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. | ||
| 1 | import { describe, it } from "@effect/vitest"; | 2 | import { describe, it } from "@effect/vitest"; |
| 2 | import { expect } from "vite-plus/test"; | 3 | import { afterAll, expect } from "vite-plus/test"; |
| 4 | import * as NodeChildProcess from "node:child_process"; | ||
| 3 | import * as Duration from "effect/Duration"; | 5 | import * as Duration from "effect/Duration"; |
| 4 | import * as Effect from "effect/Effect"; | 6 | import * as Effect from "effect/Effect"; |
| 5 | import * as Fiber from "effect/Fiber"; | 7 | import * 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"; |
| 11 | 13 | ||
| 12 | import { | 14 | import { |
| 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"; |
| 24 | 31 | ||
| 25 | const encoder = new TextEncoder(); | 32 | const encoder = new TextEncoder(); |
| 26 | 33 | ||
| 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. | ||
| 38 | const REQUIRED_SHELL_TOOLS = ["flock", "sha256sum", "tar", "mktemp"] as const; | ||
| 39 | |||
| 40 | const 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 | |||
| 61 | const 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 | |||
| 73 | const sh = (value: string) => `'${value.replaceAll("'", "'\\''")}'`; | ||
| 74 | |||
| 75 | const 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 | |||
| 81 | const SERVER_ENTRY_SOURCE = 'console.log("t3code wsl runtime test server");'; | ||
| 82 | |||
| 27 | const makeDistroListSpawner = (result: { readonly stdout?: string; readonly exitCode?: number }) => | 83 | const 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 | }); |
| 127 | 183 | ||
| 184 | describe("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. | ||
| 434 | describe.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 | |||
| 128 | describe("parseToolchainReport", () => { | 750 | describe("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); |
| 22 | const PROBE_TIMEOUT = Duration.seconds(10); | 22 | const PROBE_TIMEOUT = Duration.seconds(10); |
| 23 | const TOOLCHAIN_TIMEOUT = Duration.seconds(10); | 23 | const TOOLCHAIN_TIMEOUT = Duration.seconds(10); |
| 24 | const BUILD_TIMEOUT = Duration.minutes(5); | 24 | const BUILD_TIMEOUT = Duration.minutes(5); |
| 25 | const RUNTIME_INSTALL_TIMEOUT = Duration.minutes(2); | ||
| 26 | const RUNTIME_PRUNE_TIMEOUT = Duration.seconds(30); | ||
| 27 | const RUNTIME_INVALIDATE_TIMEOUT = Duration.seconds(15); | ||
| 25 | const USER_HOME_TIMEOUT = Duration.seconds(5); | 28 | const USER_HOME_TIMEOUT = Duration.seconds(5); |
| 26 | const TOOLCHAIN_TRANSPORT_RETRY_LIMIT = 12; | 29 | const TOOLCHAIN_TRANSPORT_RETRY_LIMIT = 12; |
| 27 | const BUILD_TRANSPORT_RETRY_LIMIT = 2; | 30 | const 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 | } |
| 33 | 36 | ||
| 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. | ||
| 40 | export interface WslRuntimeArchive { | ||
| 41 | readonly windowsPath: string; | ||
| 42 | readonly runtimeId: string; | ||
| 43 | readonly sha256: string; | ||
| 44 | } | ||
| 45 | |||
| 46 | export type PrepareWslRuntimeResult = | ||
| 47 | | { | ||
| 48 | readonly ok: true; | ||
| 49 | readonly linuxAppRoot: string; | ||
| 50 | } | ||
| 51 | | { | ||
| 52 | readonly ok: false; | ||
| 53 | readonly reason: string; | ||
| 54 | }; | ||
| 55 | |||
| 34 | export type EnsureWslNodePtyResult = | 56 | export 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 starts | 101 | // (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 supported | 187 | // 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 bash | 188 | // 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 = ( |
| 216 | 255 | ||
| 217 | const shellQuote = (value: string): string => `'${value.replaceAll("'", "'\\''")}'`; | 256 | const shellQuote = (value: string): string => `'${value.replaceAll("'", "'\\''")}'`; |
| 218 | 257 | ||
| 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. | ||
| 262 | const WSL_RUNTIME_READY_MARKER = ".t3code-wsl-runtime-ready"; | ||
| 263 | const WSL_RUNTIME_SELECTED_MARKER = ".t3code-wsl-runtime-selected"; | ||
| 264 | const WSL_RUNTIME_SELECTION_GRACE_MINUTES = 5; | ||
| 265 | |||
| 266 | export 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. | ||
| 272 | export 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. | ||
| 410 | const ORPHANED_RUNTIME_SCRATCH_MAX_AGE_MINUTES = 120; | ||
| 411 | |||
| 412 | export 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. | ||
| 476 | export 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 | |||
| 484 | export 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 | |||
| 219 | const NODE_PTY_PREBUILD_MISSING_EXIT_CODE = 4; | 492 | const NODE_PTY_PREBUILD_MISSING_EXIT_CODE = 4; |
| 220 | 493 | ||
| 221 | export const formatNodePtyProbeFailureReason = (exitCode: number): string | null => | 494 | export const formatNodePtyProbeFailureReason = (exitCode: number): string | null => |
| ... | @@ -390,23 +663,10 @@ export const formatMissingToolsReason = ( | ... | @@ -390,23 +663,10 @@ export const formatMissingToolsReason = ( |
| 390 | 663 | ||
| 391 | const ensureNodePtyImpl = ( | 664 | const 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 from | 670 | // 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 | }); |
| 586 | 846 | ||
| 847 | const 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 | |||
| 895 | const 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 | |||
| 915 | const 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 | |||
| 587 | export const probeWslDistros: Effect.Effect< | 937 | export 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 | ); |
| 299 | 299 | ||
| 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.asar | 11 | // Packaged Windows builds ship the server tree inside resources/server.asar |
| 12 | // (see scripts/build-desktop-artifact.ts). The Windows primary reads it in | 12 | // (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 WSL | 13 | // place through the asar-aware ELECTRON_RUN_AS_NODE runtime, but the WSL |
| 14 | // backend launches plain `wsl.exe -- node`, which cannot read an asar | 14 | // backend launches plain `wsl.exe -- node`, which cannot read an asar archive. |
| 15 | // archive. This service materializes the archive into a real, version-keyed | 15 | // 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 the | 18 | // Reading through Electron's patched fs also transparently returns the |
| 20 | // contents of files that electron-builder/asar left in the server.asar.unpacked | 19 | // 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 extract | 51 | // 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") {} |
| 57 | 60 | ||
| ... | @@ -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); |
| 175 | 178 | ||
| 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> = gate | 201 | 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")); |
| 207 | 232 | ||
| 208 | return DesktopWslServerTree.of({ ensure }); | 233 | return DesktopWslServerTree.of({ ensure, cleanupLegacy }); |
| 209 | }); | 234 | }); |
| 210 | 235 | ||
| 211 | export const layer = Layer.effect(DesktopWslServerTree, make); | 236 | export const layer = Layer.effect(DesktopWslServerTree, make); |
| 212 | 237 | ||
| 213 | export interface DesktopWslServerTreeTestStub { | 238 | export interface DesktopWslServerTreeTestStub { |
| 214 | readonly result?: WslServerTreeResult; | 239 | readonly result?: WslServerTreeResult; |
| 240 | readonly cleanupLegacy?: Effect.Effect<void>; | ||
| 215 | } | 241 | } |
| 216 | 242 | ||
| 217 | export const layerTest = (stub: DesktopWslServerTreeTestStub = {}) => | 243 | export 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 |
| 222 | dependency closure in `resources/server.asar`. Native modules and helper | 222 | dependency closure in `resources/server.asar`. Native modules and helper |
| 223 | executables declared as unpacked by that archive must be present at the matching | 223 | executables declared as unpacked by that archive must be present at the matching |
| 224 | paths below `resources/server.asar.unpacked`. The Windows-native backend reads | 224 | paths below `resources/server.asar.unpacked`. The Windows-native backend reads |
| 225 | the archive in place through Electron. WSL cannot read ASAR files, so enabling | 225 | the archive in place through Electron. Packaged Windows builds also ship a |
| 226 | the WSL backend extracts the server tree once into the desktop state directory | 226 | Linux-only `resources/wsl-runtime.tar.gz` plus its SHA-256 sidecar. WSL verifies |
| 227 | under `wsl-server-tree/<version>` and reuses the completed version until the app | 227 | and extracts that archive into `~/.t3/wsl-runtime/sha256-<archive-digest>` inside |
| 228 | is updated. | 228 | the selected distro, then reuses it for later launches of the same update. The |
| 229 | Windows-side `wsl-server-tree/<version>` extraction remains a fallback and is | ||
| 230 | removed after the distro-local runtime passes preflight. | ||
| 229 | 231 | ||
| 230 | The artifact builder rejects a Windows package when any of these invariants | 232 | The artifact builder rejects a Windows package when any of these invariants |
| 231 | break: | 233 | break: |
| ... | @@ -236,6 +238,11 @@ break: | ... | @@ -236,6 +238,11 @@ break: |
| 236 | - On same-architecture Windows builds, the packaged primary cannot load the fff | 238 | - 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. |
| 241 | 248 |
docs/user/install.md+10| ... | @@ -49,6 +49,16 @@ Nightly: | ... | @@ -49,6 +49,16 @@ Nightly: |
| 49 | yay -S t3code-nightly-bin | 49 | yay -S t3code-nightly-bin |
| 50 | ``` | 50 | ``` |
| 51 | 51 | ||
| 52 | ### Windows Subsystem for Linux | ||
| 53 | |||
| 54 | When 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 | ||
| 56 | Code may take a little longer while that release's runtime is extracted. Later launches reuse the | ||
| 57 | Linux-local copy so startup does not depend on reading application files through `/mnt/c`. After a | ||
| 58 | successful launch, T3 Code keeps the current runtime and one previous runtime for rollback and | ||
| 59 | removes older caches automatically. If a cached runtime stops working, T3 Code launches from the | ||
| 60 | application files under `/mnt/c` instead and reinstalls the runtime on the next launch. | ||
| 61 | |||
| 52 | ## Providers | 62 | ## Providers |
| 53 | 63 | ||
| 54 | T3 Code drives provider CLIs; it does not ship them. Install the CLI for each provider you want | 64 | T3 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. | ||
| 2 | import * as NodeCrypto from "node:crypto"; | ||
| 3 | |||
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 4 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | import { assert, it } from "@effect/vitest"; | 5 | import { assert, it } from "@effect/vitest"; |
| 3 | import * as ConfigProvider from "effect/ConfigProvider"; | 6 | import * 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"; |
| 8 | import * as Path from "effect/Path"; | 11 | import * as Path from "effect/Path"; |
| 9 | import * as Sink from "effect/Sink"; | 12 | import * as Sink from "effect/Sink"; |
| 10 | import * as Stream from "effect/Stream"; | 13 | import * as Stream from "effect/Stream"; |
| 11 | import { ChildProcessSpawner } from "effect/unstable/process"; | 14 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 12 | 15 | ||
| 13 | import { | 16 | import { |
| 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"; |
| 67 | import { BRAND_ASSET_PATHS } from "./lib/brand-assets.ts"; | 80 | import { BRAND_ASSET_PATHS } from "./lib/brand-assets.ts"; |
| 68 | import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; | 81 | import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; |
| 69 | 82 | ||
| 83 | // A minimal stand-in for the staged sidecar roots packed into the WSL archive. | ||
| 84 | const 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 | |||
| 70 | function mockProcess(exitCode: number) { | 105 | function 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( |
| 107 | const makeWindowsPayloadFixture = Effect.fn("test.makeWindowsPayloadFixture")(function* (input: { | 142 | const 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"); |
| 145 | 181 | ||
| 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 | }); |
| 436 | 527 | ||
| 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 | ); |
| 483 | 600 | ||
| 484 | // All platforms keep app.asar fully packed; Windows ships the server | 601 | // 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 an | 625 | // 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 | ); |
| 718 | 845 | ||
| 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. |
| 3 | 3 | ||
| 4 | import * as NodeFSP from "node:fs/promises"; | 4 | import * as NodeFSP from "node:fs/promises"; |
| 5 | import * as NodeCrypto from "node:crypto"; | ||
| 5 | import * as NodeModule from "node:module"; | 6 | import * as NodeModule from "node:module"; |
| 6 | 7 | ||
| 7 | import { | 8 | import { |
| ... | @@ -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 | ]); |
| 565 | 568 | ||
| ... | @@ -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. |
| 800 | export const MAC_FILE_EXCLUSIONS = [ | 811 | export 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 small | 817 | // then extracts a handful of large archives instead of thousands of small |
| 807 | // files, which dominates install (and update) time. The Windows primary runs | 818 | // 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_NODE | 819 | // the server from inside server.asar via the asar-aware ELECTRON_RUN_AS_NODE |
| 809 | // runtime; the WSL backend cannot read asar archives, so enabling WSL lazily | 820 | // 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. |
| 811 | export const WINDOWS_SERVER_ASAR_RESOURCE = "server.asar"; | 822 | export const WINDOWS_SERVER_ASAR_RESOURCE = "server.asar"; |
| 812 | // dlopen/spawn need real files, so native modules, shared libraries, and | 823 | // dlopen/spawn need real files, so native modules, shared libraries, and |
| 813 | // helper executables live in the server.asar.unpacked sibling (the standard | 824 | // 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; |
| 866 | export const WSL_RUNTIME_ARCHIVE_NAME = "wsl-runtime.tar.gz"; | ||
| 867 | export const WSL_RUNTIME_ARCHIVE_HASH_NAME = `${WSL_RUNTIME_ARCHIVE_NAME}.sha256`; | ||
| 868 | export 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; | ||
| 872 | export 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; | ||
| 876 | export 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. | ||
| 881 | export 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. | ||
| 896 | export 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. | ||
| 904 | export const bundlesWslRuntime = (input: { | ||
| 905 | readonly arch: typeof BuildArch.Type; | ||
| 906 | readonly prebuildPath: string | undefined; | ||
| 907 | }): boolean => input.prebuildPath !== undefined && resolveWslPrebuildArch(input.arch) !== undefined; | ||
| 908 | |||
| 909 | export const WSL_RUNTIME_EXTRA_RESOURCES = [ | ||
| 910 | WSL_RUNTIME_ARCHIVE_EXTRA_RESOURCE, | ||
| 911 | WSL_RUNTIME_ARCHIVE_HASH_EXTRA_RESOURCE, | ||
| 912 | ] as const; | ||
| 855 | export const DESKTOP_EXTRA_RESOURCES = [ | 913 | export 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 | } |
| 2249 | 2312 | ||
| 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 | }); |
| 2296 | 2358 | ||
| 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. | ||
| 2364 | export const wslRuntimeArchiveTarTarget = (relativeArchivePath: string): string => | ||
| 2365 | relativeArchivePath.replaceAll("\\", "/"); | ||
| 2366 | |||
| 2367 | // `archivePath` is relative to the cwd tar runs in; see wslRuntimeArchiveTarTarget. | ||
| 2368 | export 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 | |||
| 2377 | export 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 | |||
| 2383 | export 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 hoisted | 2409 | // 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 and | 2410 | // 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-aware | 2411 | // 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 | } | ||
| 2405 | 2529 | ||
| 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 | } |
| 2647 | 2772 | ||
| 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 | } |