| author | |
| committer | |
| log | 07549200dcdc70dce57285276879004024beeaa7 |
| tree | f3a5b3faa1460263bcb2be6cd72e2d14eeca8635 |
| parent | af6c138a0de297f94ddef3133e2f47d017b46e94 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>8 files changed, 725 insertions(+), 844 deletions(-)
.github/workflows/release.yml+103-40| ... | @@ -354,21 +354,28 @@ jobs: | ... | @@ -354,21 +354,28 @@ jobs: |
| 354 | # job — the Windows artifact then ships a ready WSL backend binary with no | 354 | # job — the Windows artifact then ships a ready WSL backend binary with no |
| 355 | # cross-compiling and no first-launch compiler/node-gyp/network on the user's | 355 | # cross-compiling and no first-launch compiler/node-gyp/network on the user's |
| 356 | # machine. node-pty is N-API, so one binary works across all WSL Node versions. | 356 | # machine. node-pty is N-API, so one binary works across all WSL Node versions. |
| 357 | build_wsl_node_pty: | 357 | # The Linux CLI archive is built ahead of the desktop matrix because two |
| 358 | name: Build WSL node-pty (linux-x64) | 358 | # consumers need it: the Linux desktop entry attaches it to the release, and |
| 359 | # the Windows desktop entry embeds it as the WSL runtime. Building it once | ||
| 360 | # here means the WSL backend runs the exact bytes a Linux user downloads. | ||
| 361 | build_linux_cli: | ||
| 362 | name: Build CLI archive (linux-x64) | ||
| 359 | # Same gating as relay_public_config: only the release commit is needed, so | 363 | # Same gating as relay_public_config: only the release commit is needed, so |
| 360 | # this runs alongside preflight. See the condition comment there. | 364 | # this runs alongside preflight. See the condition comment there. |
| 361 | needs: [resolve_commit] | 365 | needs: [resolve_commit, preflight, relay_public_config] |
| 362 | if: | | 366 | if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' }} |
| 363 | needs.resolve_commit.result == 'success' && | 367 | runs-on: blacksmith-32vcpu-ubuntu-2404 |
| 364 | (github.event_name != 'schedule' || needs.resolve_commit.outputs.has_changes == 'true') | 368 | timeout-minutes: 20 |
| 365 | runs-on: blacksmith-8vcpu-ubuntu-2404 | 369 | env: |
| 366 | timeout-minutes: 15 | 370 | T3CODE_CLERK_PUBLISHABLE_KEY: ${{ needs.relay_public_config.outputs.clerk_publishable_key }} |
| 371 | T3CODE_CLERK_JWT_TEMPLATE: ${{ needs.relay_public_config.outputs.clerk_jwt_template }} | ||
| 372 | T3CODE_CLERK_CLI_OAUTH_CLIENT_ID: ${{ needs.relay_public_config.outputs.clerk_cli_oauth_client_id }} | ||
| 373 | T3CODE_RELAY_URL: ${{ needs.relay_public_config.outputs.relay_url }} | ||
| 367 | steps: | 374 | steps: |
| 368 | - name: Checkout | 375 | - name: Checkout |
| 369 | uses: actions/checkout@v6 | 376 | uses: actions/checkout@v6 |
| 370 | with: | 377 | with: |
| 371 | ref: ${{ needs.resolve_commit.outputs.ref }} | 378 | ref: ${{ needs.preflight.outputs.ref }} |
| 372 | sparse-checkout: | | 379 | sparse-checkout: | |
| 373 | /* | 380 | /* |
| 374 | !/.repos/ | 381 | !/.repos/ |
| ... | @@ -382,38 +389,92 @@ jobs: | ... | @@ -382,38 +389,92 @@ jobs: |
| 382 | run-install: | | 389 | run-install: | |
| 383 | args: | 390 | args: |
| 384 | - --filter=t3... | 391 | - --filter=t3... |
| 392 | - --filter=@t3tools/web... | ||
| 393 | - --filter=@t3tools/scripts... | ||
| 394 | |||
| 395 | - name: Cache resource monitor | ||
| 396 | id: resource_monitor_cache | ||
| 397 | uses: actions/cache@v6 | ||
| 398 | with: | ||
| 399 | path: native/resource-monitor/target/x86_64-unknown-linux-gnu/release/t3-resource-monitor | ||
| 400 | key: resource-monitor-x86_64-unknown-linux-gnu-${{ hashFiles('native/resource-monitor/Cargo.lock', 'native/resource-monitor/Cargo.toml', 'native/resource-monitor/src/**') }} | ||
| 401 | |||
| 402 | - name: Setup Rust | ||
| 403 | if: steps.resource_monitor_cache.outputs.cache-hit != 'true' | ||
| 404 | uses: dtolnay/rust-toolchain@stable | ||
| 405 | with: | ||
| 406 | targets: x86_64-unknown-linux-gnu | ||
| 407 | |||
| 408 | - name: Build resource monitor | ||
| 409 | if: steps.resource_monitor_cache.outputs.cache-hit != 'true' | ||
| 410 | run: cargo build --locked --release --manifest-path native/resource-monitor/Cargo.toml --target x86_64-unknown-linux-gnu | ||
| 411 | |||
| 412 | - name: Download relay client tracing config | ||
| 413 | uses: actions/download-artifact@v8 | ||
| 414 | with: | ||
| 415 | name: relay-client-tracing-config | ||
| 416 | path: ${{ runner.temp }}/relay-client-tracing | ||
| 385 | 417 | ||
| 386 | - name: Build node-pty linux-x64 prebuild | 418 | - name: Load relay client tracing config |
| 387 | shell: bash | 419 | shell: bash |
| 420 | run: | | ||
| 421 | config_path="$RUNNER_TEMP/relay-client-tracing/relay-client-tracing.env" | ||
| 422 | tracing_token="$(sed -n 's/^T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN=//p' "$config_path")" | ||
| 423 | echo "::add-mask::$tracing_token" | ||
| 424 | cat "$config_path" >> "$GITHUB_ENV" | ||
| 425 | |||
| 426 | - name: Align package versions to release version | ||
| 427 | run: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}" | ||
| 428 | |||
| 429 | # The t3 build task depends on @t3tools/web#build, so the web client is | ||
| 430 | # built as part of this step. | ||
| 431 | - name: Build CLI package | ||
| 432 | run: vp run --filter t3 build | ||
| 433 | |||
| 434 | - name: Build CLI single-executable | ||
| 435 | env: | ||
| 436 | # The exact version, not a major: vp downloads it from nodejs.org/dist on | ||
| 437 | # the runner, and only exact versions have a dist directory. Keep in | ||
| 438 | # step with SEA_NODE_VERSION in apps/server/vite.config.ts. | ||
| 439 | VP_NODE_VERSION: "26.8.2" | ||
| 440 | run: node apps/server/scripts/cli.ts build-exe --verbose | ||
| 441 | |||
| 442 | - name: Stage resource monitor for the CLI archive | ||
| 388 | run: | | 443 | run: | |
| 389 | set -euo pipefail | 444 | set -euo pipefail |
| 390 | # Resolve node-pty from apps/server (where it's a dependency) and build | 445 | target_dir="$RUNNER_TEMP/cli-resource-monitor/linux-x64" |
| 391 | # its native binary from source for Linux. node-addon-api resolves from | 446 | mkdir -p "$target_dir" |
| 392 | # node-pty's own dependency tree, so node-gyp has everything it needs. | 447 | cp native/resource-monitor/target/x86_64-unknown-linux-gnu/release/t3-resource-monitor "$target_dir/" |
| 393 | pty_pkg="$(node -e "console.log(require.resolve('node-pty/package.json', { paths: ['$GITHUB_WORKSPACE/apps/server'] }))")" | 448 | |
| 394 | pty_dir="$(dirname "$pty_pkg")" | 449 | - name: Build CLI archive |
| 395 | ( cd "$pty_dir" && npx --yes node-gyp rebuild ) | 450 | run: | |
| 396 | mkdir -p wsl-prebuild | 451 | node scripts/build-cli-archive.ts \ |
| 397 | cp "$pty_dir/build/Release/pty.node" wsl-prebuild/pty.node | 452 | --platform linux \ |
| 398 | file wsl-prebuild/pty.node | 453 | --arch x64 \ |
| 399 | 454 | --version "${{ needs.preflight.outputs.version }}" \ | |
| 400 | - name: Upload node-pty linux-x64 prebuild | 455 | --resource-monitor-dir "$RUNNER_TEMP/cli-resource-monitor" \ |
| 456 | --output-dir release-cli | ||
| 457 | |||
| 458 | - name: Smoke-test CLI archive | ||
| 459 | run: node scripts/smoke-cli-archive.ts --archive release-cli/* --expect-version "${{ needs.preflight.outputs.version }}" | ||
| 460 | |||
| 461 | - name: Upload CLI archive | ||
| 401 | uses: actions/upload-artifact@v7 | 462 | uses: actions/upload-artifact@v7 |
| 402 | with: | 463 | with: |
| 403 | name: wsl-node-pty-x64 | 464 | name: cli-linux-x64 |
| 404 | path: wsl-prebuild/pty.node | 465 | path: release-cli/* |
| 405 | if-no-files-found: error | 466 | if-no-files-found: error |
| 406 | 467 | ||
| 407 | build: | 468 | build: |
| 408 | name: Build ${{ matrix.label }} | 469 | name: Build ${{ matrix.label }} |
| 409 | # build_wsl_node_pty stays in `needs` so it runs first and its artifact is | 470 | # build_linux_cli stays in `needs` so it runs first and its artifact is |
| 410 | # available to download, but only the Windows matrix entry consumes it. We | 471 | # available to download, but only the Windows matrix entry consumes it (as |
| 411 | # therefore gate the job on preflight + relay (must succeed) WITHOUT requiring | 472 | # the WSL runtime). The job is gated on preflight + relay WITHOUT requiring |
| 412 | # build_wsl_node_pty, so a failed Linux prebuild doesn't skip the macOS/Linux | 473 | # build_linux_cli, so a failed Linux archive doesn't skip the macOS builds. |
| 413 | # builds. `!cancelled()` (not `!failure()`) lets the job run even when | 474 | # `!cancelled()` (not `!failure()`) lets the job run even when |
| 414 | # build_wsl_node_pty failed; the Windows-only download step below then fails | 475 | # build_linux_cli failed; the Windows-only download step below then fails |
| 415 | # that single platform if the prebuild is missing. | 476 | # that single platform if the archive is missing. |
| 416 | needs: [preflight, relay_public_config, build_wsl_node_pty] | 477 | needs: [preflight, relay_public_config, build_linux_cli] |
| 417 | if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' }} | 478 | if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' }} |
| 418 | runs-on: ${{ matrix.runner }} | 479 | runs-on: ${{ matrix.runner }} |
| 419 | timeout-minutes: 30 | 480 | timeout-minutes: 30 |
| ... | @@ -448,6 +509,7 @@ jobs: | ... | @@ -448,6 +509,7 @@ jobs: |
| 448 | rust_target: x86_64-apple-darwin | 509 | rust_target: x86_64-apple-darwin |
| 449 | resource_key: darwin-x64 | 510 | resource_key: darwin-x64 |
| 450 | cli_archive: false | 511 | cli_archive: false |
| 512 | # The Linux CLI archive is produced by build_linux_cli, not here. | ||
| 451 | - label: Linux x64 | 513 | - label: Linux x64 |
| 452 | runner: blacksmith-32vcpu-ubuntu-2404 | 514 | runner: blacksmith-32vcpu-ubuntu-2404 |
| 453 | platform: linux | 515 | platform: linux |
| ... | @@ -455,7 +517,7 @@ jobs: | ... | @@ -455,7 +517,7 @@ jobs: |
| 455 | arch: x64 | 517 | arch: x64 |
| 456 | rust_target: x86_64-unknown-linux-gnu | 518 | rust_target: x86_64-unknown-linux-gnu |
| 457 | resource_key: linux-x64 | 519 | resource_key: linux-x64 |
| 458 | cli_archive: true | 520 | cli_archive: false |
| 459 | - label: Windows x64 | 521 | - label: Windows x64 |
| 460 | runner: blacksmith-32vcpu-windows-2025 | 522 | runner: blacksmith-32vcpu-windows-2025 |
| 461 | platform: win | 523 | platform: win |
| ... | @@ -553,12 +615,14 @@ jobs: | ... | @@ -553,12 +615,14 @@ jobs: |
| 553 | - name: Align package versions to release version | 615 | - name: Align package versions to release version |
| 554 | run: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}" | 616 | run: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}" |
| 555 | 617 | ||
| 556 | - name: Download WSL node-pty prebuild | 618 | # The WSL backend runs the Linux CLI archive inside the distro, so the |
| 619 | # Windows desktop embeds the same archive the release attaches. | ||
| 620 | - name: Download Linux CLI archive for WSL | ||
| 557 | if: matrix.platform == 'win' | 621 | if: matrix.platform == 'win' |
| 558 | uses: actions/download-artifact@v7 | 622 | uses: actions/download-artifact@v8 |
| 559 | with: | 623 | with: |
| 560 | name: wsl-node-pty-x64 | 624 | name: cli-linux-x64 |
| 561 | path: wsl-prebuild | 625 | path: wsl-runtime |
| 562 | 626 | ||
| 563 | - name: Install Spectre-mitigated MSVC libs | 627 | - name: Install Spectre-mitigated MSVC libs |
| 564 | if: matrix.platform == 'win' | 628 | if: matrix.platform == 'win' |
| ... | @@ -720,10 +784,9 @@ jobs: | ... | @@ -720,10 +784,9 @@ jobs: |
| 720 | echo "macOS signing disabled (missing one or more Apple signing secrets)." | 784 | echo "macOS signing disabled (missing one or more Apple signing secrets)." |
| 721 | fi | 785 | fi |
| 722 | elif [[ "${{ matrix.platform }}" == "win" ]]; then | 786 | elif [[ "${{ matrix.platform }}" == "win" ]]; then |
| 723 | # Bundle the Linux node-pty binary built by the build_wsl_node_pty job | 787 | # Embed the Linux CLI archive built by build_linux_cli as the WSL |
| 724 | # so the packaged WSL backend ships a ready binary (no first-launch | 788 | # runtime. Required for a working WSL backend on Windows. |
| 725 | # compile). Required for a working WSL backend on Windows. | 789 | args+=(--wsl-runtime "$GITHUB_WORKSPACE"/wsl-runtime/t3-*-linux-x64.tar.gz) |
| 726 | args+=(--wsl-prebuild "$GITHUB_WORKSPACE/wsl-prebuild/pty.node") | ||
| 727 | if has_all \ | 790 | if has_all \ |
| 728 | "$AZURE_TENANT_ID" \ | 791 | "$AZURE_TENANT_ID" \ |
| 729 | "$AZURE_CLIENT_ID" \ | 792 | "$AZURE_CLIENT_ID" \ |
apps/desktop/src/backend/DesktopBackendConfiguration.test.ts+44-57| ... | @@ -374,9 +374,10 @@ describe("DesktopBackendConfiguration", () => { | ... | @@ -374,9 +374,10 @@ describe("DesktopBackendConfiguration", () => { |
| 374 | runtimeId: string; | 374 | runtimeId: string; |
| 375 | sha256: string; | 375 | sha256: string; |
| 376 | }> = []; | 376 | }> = []; |
| 377 | const observedNodePtyRoots: string[] = []; | 377 | const observedProbeRoots: string[] = []; |
| 378 | let legacyCleanupCount = 0; | 378 | let legacyCleanupCount = 0; |
| 379 | const linuxAppRoot = "/home/test/.t3/wsl-runtime/1.2.3-x64"; | 379 | const linuxAppRoot = "/home/test/.t3/wsl-runtime/1.2.3-x64"; |
| 380 | const resolvedPath = "/home/test/.local/bin:/usr/bin:/bin"; | ||
| 380 | 381 | ||
| 381 | return withPackagedWslHarness( | 382 | return withPackagedWslHarness( |
| 382 | { | 383 | { |
| ... | @@ -394,9 +395,14 @@ describe("DesktopBackendConfiguration", () => { | ... | @@ -394,9 +395,14 @@ describe("DesktopBackendConfiguration", () => { |
| 394 | }); | 395 | }); |
| 395 | return { ok: true, linuxAppRoot }; | 396 | return { ok: true, linuxAppRoot }; |
| 396 | }, | 397 | }, |
| 397 | ensureNodePty: (_distro, root) => { | 398 | probeRuntime: (_distro, root) => { |
| 398 | observedNodePtyRoots.push(root); | 399 | observedProbeRoots.push(root); |
| 399 | return { ok: true, nodePath: "/usr/bin/node", resolvedPath: "/usr/bin:/bin" }; | 400 | return { ok: true, resolvedPath }; |
| 401 | }, | ||
| 402 | // The staged runtime carries its own Node, so the preflight must not | ||
| 403 | // go looking for one in the distro. | ||
| 404 | ensureNodePty: () => { | ||
| 405 | throw new Error("the staged runtime must not probe for Node"); | ||
| 400 | }, | 406 | }, |
| 401 | }), | 407 | }), |
| 402 | }, | 408 | }, |
| ... | @@ -413,12 +419,22 @@ describe("DesktopBackendConfiguration", () => { | ... | @@ -413,12 +419,22 @@ describe("DesktopBackendConfiguration", () => { |
| 413 | sha256: archiveHash, | 419 | sha256: archiveHash, |
| 414 | }, | 420 | }, |
| 415 | ]); | 421 | ]); |
| 416 | assert.deepEqual(observedNodePtyRoots, [linuxAppRoot]); | 422 | assert.deepEqual(observedProbeRoots, [linuxAppRoot]); |
| 417 | assert.equal( | 423 | assert.equal( |
| 418 | config.entryPath, | 424 | config.entryPath, |
| 419 | path.join(baseDir, "server.asar/apps/server/dist/bin.mjs"), | 425 | path.join(baseDir, "server.asar/apps/server/dist/bin.mjs"), |
| 420 | ); | 426 | ); |
| 421 | assert.include(config.args, `${linuxAppRoot}/apps/server/dist/bin.mjs`); | 427 | assert.deepEqual(config.args, [ |
| 428 | "-d", | ||
| 429 | "Ubuntu", | ||
| 430 | "--exec", | ||
| 431 | "env", | ||
| 432 | `PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${resolvedPath}`, | ||
| 433 | `${linuxAppRoot}/t3`, | ||
| 434 | "--bootstrap-fd", | ||
| 435 | "0", | ||
| 436 | ]); | ||
| 437 | assert.notInclude(config.args, "/usr/bin/node"); | ||
| 422 | assert.equal(config.wslRuntimeId, `sha256-${archiveHash}`); | 438 | assert.equal(config.wslRuntimeId, `sha256-${archiveHash}`); |
| 423 | assert.equal(legacyCleanupCount, 1); | 439 | assert.equal(legacyCleanupCount, 1); |
| 424 | assert.isTrue(Option.isNone(config.preflightFailure)); | 440 | assert.isTrue(Option.isNone(config.preflightFailure)); |
| ... | @@ -457,8 +473,11 @@ describe("DesktopBackendConfiguration", () => { | ... | @@ -457,8 +473,11 @@ describe("DesktopBackendConfiguration", () => { |
| 457 | 473 | ||
| 458 | assert.deepEqual(observedRuntimeIds, [`sha256-${firstHash}`, `sha256-${secondHash}`]); | 474 | assert.deepEqual(observedRuntimeIds, [`sha256-${firstHash}`, `sha256-${secondHash}`]); |
| 459 | assert.equal(first.wslRuntimeId, observedRuntimeIds[0]); | 475 | assert.equal(first.wslRuntimeId, observedRuntimeIds[0]); |
| 476 | assert.include(first.args, `/runtime/sha256-${firstHash}/t3`); | ||
| 460 | assert.equal(second.wslRuntimeId, observedRuntimeIds[1]); | 477 | assert.equal(second.wslRuntimeId, observedRuntimeIds[1]); |
| 478 | assert.include(second.args, `/runtime/sha256-${secondHash}/t3`); | ||
| 461 | assert.isUndefined(invalidIdentity.wslRuntimeId); | 479 | assert.isUndefined(invalidIdentity.wslRuntimeId); |
| 480 | assert.include(invalidIdentity.args, "/usr/bin/node"); | ||
| 462 | assert.include(invalidIdentity.args, `${mountedAppRoot}/apps/server/dist/bin.mjs`); | 481 | assert.include(invalidIdentity.args, `${mountedAppRoot}/apps/server/dist/bin.mjs`); |
| 463 | }), | 482 | }), |
| 464 | ); | 483 | ); |
| ... | @@ -484,6 +503,7 @@ describe("DesktopBackendConfiguration", () => { | ... | @@ -484,6 +503,7 @@ describe("DesktopBackendConfiguration", () => { |
| 484 | 503 | ||
| 485 | assert.deepEqual(observedNodePtyRoots, [mountedAppRoot]); | 504 | assert.deepEqual(observedNodePtyRoots, [mountedAppRoot]); |
| 486 | assert.equal(config.entryPath, mountedEntryPath); | 505 | assert.equal(config.entryPath, mountedEntryPath); |
| 506 | assert.include(config.args, "/usr/bin/node"); | ||
| 487 | assert.include(config.args, `${mountedAppRoot}/apps/server/dist/bin.mjs`); | 507 | assert.include(config.args, `${mountedAppRoot}/apps/server/dist/bin.mjs`); |
| 488 | assert.isUndefined(config.wslRuntimeId); | 508 | assert.isUndefined(config.wslRuntimeId); |
| 489 | assert.isTrue(Option.isNone(config.preflightFailure)); | 509 | assert.isTrue(Option.isNone(config.preflightFailure)); |
| ... | @@ -491,9 +511,10 @@ describe("DesktopBackendConfiguration", () => { | ... | @@ -491,9 +511,10 @@ describe("DesktopBackendConfiguration", () => { |
| 491 | ); | 511 | ); |
| 492 | }); | 512 | }); |
| 493 | 513 | ||
| 494 | it.effect("resolveWsl retires a staged runtime that cannot load node-pty", () => { | 514 | it.effect("resolveWsl retires a staged runtime whose executable does not start", () => { |
| 495 | const archiveHash = "c".repeat(64); | 515 | const archiveHash = "c".repeat(64); |
| 496 | const stagedAppRoot = `/home/test/.t3/wsl-runtime/sha256-${archiveHash}`; | 516 | const stagedAppRoot = `/home/test/.t3/wsl-runtime/sha256-${archiveHash}`; |
| 517 | const observedProbeRoots: string[] = []; | ||
| 497 | const observedNodePtyRoots: string[] = []; | 518 | const observedNodePtyRoots: string[] = []; |
| 498 | const invalidatedRuntimeIds: string[] = []; | 519 | const invalidatedRuntimeIds: string[] = []; |
| 499 | return withPackagedWslHarness( | 520 | return withPackagedWslHarness( |
| ... | @@ -505,11 +526,13 @@ describe("DesktopBackendConfiguration", () => { | ... | @@ -505,11 +526,13 @@ describe("DesktopBackendConfiguration", () => { |
| 505 | Effect.sync(() => { | 526 | Effect.sync(() => { |
| 506 | invalidatedRuntimeIds.push(runtimeId); | 527 | invalidatedRuntimeIds.push(runtimeId); |
| 507 | }), | 528 | }), |
| 529 | probeRuntime: (_distro, root) => { | ||
| 530 | observedProbeRoots.push(root); | ||
| 531 | return { ok: false, reason: `${root}/t3 --version failed (exit 127)` }; | ||
| 532 | }, | ||
| 508 | ensureNodePty: (_distro, root) => { | 533 | ensureNodePty: (_distro, root) => { |
| 509 | observedNodePtyRoots.push(root); | 534 | observedNodePtyRoots.push(root); |
| 510 | return root === stagedAppRoot | 535 | return { ok: true, nodePath: "/usr/bin/node", resolvedPath: "/usr/bin:/bin" }; |
| 511 | ? { ok: false, reason: "pty.node could not be loaded", fatal: true } | ||
| 512 | : { ok: true, nodePath: "/usr/bin/node", resolvedPath: "/usr/bin:/bin" }; | ||
| 513 | }, | 536 | }, |
| 514 | }), | 537 | }), |
| 515 | }, | 538 | }, |
| ... | @@ -518,8 +541,11 @@ describe("DesktopBackendConfiguration", () => { | ... | @@ -518,8 +541,11 @@ describe("DesktopBackendConfiguration", () => { |
| 518 | const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; | 541 | const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; |
| 519 | const config = yield* configuration.resolveWsl({ port: 5000, distro: "Ubuntu" }); | 542 | const config = yield* configuration.resolveWsl({ port: 5000, distro: "Ubuntu" }); |
| 520 | 543 | ||
| 521 | assert.deepEqual(observedNodePtyRoots, [stagedAppRoot, mountedAppRoot]); | 544 | assert.deepEqual(observedProbeRoots, [stagedAppRoot]); |
| 545 | assert.deepEqual(observedNodePtyRoots, [mountedAppRoot]); | ||
| 546 | assert.include(config.args, "/usr/bin/node"); | ||
| 522 | assert.include(config.args, `${mountedAppRoot}/apps/server/dist/bin.mjs`); | 547 | assert.include(config.args, `${mountedAppRoot}/apps/server/dist/bin.mjs`); |
| 548 | assert.notInclude(config.args, `${stagedAppRoot}/t3`); | ||
| 523 | assert.equal(config.entryPath, mountedEntryPath); | 549 | assert.equal(config.entryPath, mountedEntryPath); |
| 524 | assert.isUndefined(config.wslRuntimeId); | 550 | assert.isUndefined(config.wslRuntimeId); |
| 525 | assert.isTrue(Option.isNone(config.preflightFailure)); | 551 | assert.isTrue(Option.isNone(config.preflightFailure)); |
| ... | @@ -540,12 +566,13 @@ describe("DesktopBackendConfiguration", () => { | ... | @@ -540,12 +566,13 @@ describe("DesktopBackendConfiguration", () => { |
| 540 | Effect.sync(() => { | 566 | Effect.sync(() => { |
| 541 | invalidatedRuntimeIds.push(runtimeId); | 567 | invalidatedRuntimeIds.push(runtimeId); |
| 542 | }), | 568 | }), |
| 543 | ensureNodePty: (_distro, root) => ({ | 569 | probeRuntime: () => ({ |
| 544 | ok: false, | 570 | ok: false, |
| 545 | reason: | 571 | reason: "unsupported CPU architecture or incompatible system libraries", |
| 546 | root === stagedAppRoot | 572 | }), |
| 547 | ? "unsupported CPU architecture or incompatible system libraries" | 573 | ensureNodePty: () => ({ |
| 548 | : "mounted tree is broken in some other way", | 574 | ok: false, |
| 575 | reason: "mounted tree is broken in some other way", | ||
| 549 | fatal: true, | 576 | fatal: true, |
| 550 | }), | 577 | }), |
| 551 | }), | 578 | }), |
| ... | @@ -575,51 +602,11 @@ describe("DesktopBackendConfiguration", () => { | ... | @@ -575,51 +602,11 @@ describe("DesktopBackendConfiguration", () => { |
| 575 | Effect.sync(() => { | 602 | Effect.sync(() => { |
| 576 | invalidatedRuntimeIds.push(runtimeId); | 603 | invalidatedRuntimeIds.push(runtimeId); |
| 577 | }), | 604 | }), |
| 578 | ensureNodePty: (_distro, root) => | 605 | probeRuntime: () => ({ ok: false, reason: "t3 --version failed (exit 1)" }), |
| 579 | root === stagedAppRoot | ||
| 580 | ? { ok: false, reason: "pty.node could not be loaded", fatal: true } | ||
| 581 | : { | ||
| 582 | ok: false, | ||
| 583 | reason: "WSL backend preflight timed out while probing for Node.js.", | ||
| 584 | fatal: false, | ||
| 585 | }, | ||
| 586 | }), | ||
| 587 | }, | ||
| 588 | () => | ||
| 589 | Effect.gen(function* () { | ||
| 590 | const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; | ||
| 591 | const config = yield* configuration.resolveWsl({ port: 5000, distro: "Ubuntu" }); | ||
| 592 | const failure = Option.getOrThrow(config.preflightFailure); | ||
| 593 | |||
| 594 | assert.isFalse(failure.fatal); | ||
| 595 | assert.equal(failure.retryLimit, 12); | ||
| 596 | assert.include(failure.reason, "timed out"); | ||
| 597 | assert.deepEqual(invalidatedRuntimeIds, []); | ||
| 598 | }), | ||
| 599 | ); | ||
| 600 | }); | ||
| 601 | |||
| 602 | it.effect("resolveWsl retries the staged runtime after a transient probe failure", () => { | ||
| 603 | const invalidatedRuntimeIds: string[] = []; | ||
| 604 | return withPackagedWslHarness( | ||
| 605 | { | ||
| 606 | archiveHash: "e".repeat(64), | ||
| 607 | forbidFallback: "A transient probe failure must not extract the fallback", | ||
| 608 | forbidCleanup: "A transient probe failure must not clean the fallback tree", | ||
| 609 | wsl: () => ({ | ||
| 610 | prepareRuntime: () => ({ | ||
| 611 | ok: true, | ||
| 612 | linuxAppRoot: "/home/test/.t3/wsl-runtime/cache", | ||
| 613 | }), | ||
| 614 | invalidateRuntime: (_distro, runtimeId) => | ||
| 615 | Effect.sync(() => { | ||
| 616 | invalidatedRuntimeIds.push(runtimeId); | ||
| 617 | }), | ||
| 618 | ensureNodePty: () => ({ | 606 | ensureNodePty: () => ({ |
| 619 | ok: false, | 607 | ok: false, |
| 620 | reason: "WSL backend preflight timed out while probing for Node.js.", | 608 | reason: "WSL backend preflight timed out while probing for Node.js.", |
| 621 | fatal: false, | 609 | fatal: false, |
| 622 | retryLimit: 12, | ||
| 623 | }), | 610 | }), |
| 624 | }), | 611 | }), |
| 625 | }, | 612 | }, |
apps/desktop/src/backend/DesktopBackendConfiguration.ts+75-50| ... | @@ -95,6 +95,11 @@ const WSL_FORWARDED_ENV_NAMES = ["OPENAI_API_KEY", "ANTHROPIC_API_KEY"] as const | ... | @@ -95,6 +95,11 @@ const WSL_FORWARDED_ENV_NAMES = ["OPENAI_API_KEY", "ANTHROPIC_API_KEY"] as const |
| 95 | 95 | ||
| 96 | const WSL_SERVER_SYSTEM_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; | 96 | const WSL_SERVER_SYSTEM_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; |
| 97 | 97 | ||
| 98 | const nodeBinDirOf = (nodePath: string): string => { | ||
| 99 | const lastSlash = nodePath.lastIndexOf("/"); | ||
| 100 | return lastSlash > 0 ? nodePath.slice(0, lastSlash) : "/usr/bin"; | ||
| 101 | }; | ||
| 102 | |||
| 98 | const backendChildEnvPatch = (): Record<string, string | undefined> => | 103 | const backendChildEnvPatch = (): Record<string, string | undefined> => |
| 99 | Object.fromEntries(DESKTOP_BACKEND_ENV_NAMES.map((name) => [name, undefined])); | 104 | Object.fromEntries(DESKTOP_BACKEND_ENV_NAMES.map((name) => [name, undefined])); |
| 100 | 105 | ||
| ... | @@ -210,18 +215,31 @@ interface SharedBootstrapInput { | ... | @@ -210,18 +215,31 @@ interface SharedBootstrapInput { |
| 210 | readonly observabilitySettings: BackendObservabilitySettings; | 215 | readonly observabilitySettings: BackendObservabilitySettings; |
| 211 | } | 216 | } |
| 212 | 217 | ||
| 218 | // What the launch runs inside the distro. The staged runtime is the release's | ||
| 219 | // self-contained `t3` executable (Node inside); the mounted server tree is a | ||
| 220 | // script that needs the distro's own Node. | ||
| 221 | type WslPreflightRuntime = | ||
| 222 | | { | ||
| 223 | readonly kind: "executable"; | ||
| 224 | readonly entryPath: string; | ||
| 225 | } | ||
| 226 | | { | ||
| 227 | readonly kind: "node-script"; | ||
| 228 | // Absolute path to the node binary the preflight validated after the | ||
| 229 | // shared remote resolver repaired PATH. The launch must use this exact | ||
| 230 | // path so it doesn't fall through to a different/old node than the one | ||
| 231 | // node-pty was probed with. | ||
| 232 | readonly nodePath: string; | ||
| 233 | readonly linuxEntryPath: string; | ||
| 234 | }; | ||
| 235 | |||
| 213 | interface WslPreflightSuccess { | 236 | interface WslPreflightSuccess { |
| 214 | readonly _tag: "Ready"; | 237 | readonly _tag: "Ready"; |
| 215 | readonly runningDistro: string; | 238 | readonly runningDistro: string; |
| 216 | readonly windowsEntryPath: string; | 239 | readonly windowsEntryPath: string; |
| 217 | readonly linuxEntryPath: string; | 240 | readonly runtime: WslPreflightRuntime; |
| 218 | // Absolute path to the node binary the preflight validated after the shared | 241 | // PATH captured from the user's login shell. The launch forwards this value |
| 219 | // remote resolver repaired PATH. The launch must use this exact path so it | 242 | // directly without a shell so the server can spawn provider CLIs by name. |
| 220 | // doesn't fall through to a different/old node than the one node-pty was | ||
| 221 | // built against. | ||
| 222 | readonly nodePath: string; | ||
| 223 | // PATH captured from the same login shell after the shared resolver loaded | ||
| 224 | // version managers. The launch forwards this value directly without a shell. | ||
| 225 | readonly resolvedPath: string; | 243 | readonly resolvedPath: string; |
| 226 | // Identifies the distro-local runtime cache selected from the packaged archive. | 244 | // Identifies the distro-local runtime cache selected from the packaged archive. |
| 227 | readonly runtimeId?: string; | 245 | readonly runtimeId?: string; |
| ... | @@ -355,39 +373,36 @@ const runWslPreflight = Effect.fn("desktop.backendConfiguration.wslPreflight")(f | ... | @@ -355,39 +373,36 @@ const runWslPreflight = Effect.fn("desktop.backendConfiguration.wslPreflight")(f |
| 355 | // fatal verdict the cached reason is the more actionable one to report. | 373 | // 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 | 374 | // 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. | 375 | // retryable and the staged verdict waits for an attempt that can answer. |
| 358 | let stagedFailure: | 376 | let stagedFailure: { readonly runtimeId: string; readonly reason: string } | undefined; |
| 359 | | { readonly runtimeId: string; readonly nodePty: FailedNodePtyResult } | 377 | const failedStaged = (failure: { readonly reason: string }) => |
| 360 | | undefined; | 378 | ({ |
| 379 | _tag: "Failed", | ||
| 380 | reason: `WSL runtime unavailable: ${failure.reason}`, | ||
| 381 | fatal: true, | ||
| 382 | }) as const; | ||
| 361 | 383 | ||
| 362 | if (input.runtimeArchive !== null) { | 384 | if (input.runtimeArchive !== null) { |
| 363 | const runtime = yield* wslEnv.prepareRuntime(runningDistro, input.runtimeArchive); | 385 | const runtime = yield* wslEnv.prepareRuntime(runningDistro, input.runtimeArchive); |
| 364 | if (runtime.ok) { | 386 | if (runtime.ok) { |
| 365 | const stagedNodePty = yield* wslEnv.ensureNodePty( | 387 | // The staged runtime is self-contained, so the only question is whether |
| 366 | runningDistro, | 388 | // it runs here; there is no Node to find or node-pty to load. |
| 367 | runtime.linuxAppRoot, | 389 | const stagedProbe = yield* wslEnv.probeRuntime(runningDistro, runtime.linuxAppRoot); |
| 368 | nodePtyOptions, | 390 | if (stagedProbe.ok) { |
| 369 | ); | ||
| 370 | if (stagedNodePty.ok) { | ||
| 371 | yield* wslServerTree.cleanupLegacy; | 391 | yield* wslServerTree.cleanupLegacy; |
| 372 | return { | 392 | return { |
| 373 | _tag: "Ready", | 393 | _tag: "Ready", |
| 374 | runningDistro, | 394 | runningDistro, |
| 375 | windowsEntryPath: environment.backendEntryPath, | 395 | windowsEntryPath: environment.backendEntryPath, |
| 376 | linuxEntryPath: `${runtime.linuxAppRoot}/apps/server/dist/bin.mjs`, | 396 | runtime: { kind: "executable", entryPath: `${runtime.linuxAppRoot}/t3` }, |
| 377 | nodePath: stagedNodePty.nodePath, | 397 | resolvedPath: stagedProbe.resolvedPath, |
| 378 | resolvedPath: stagedNodePty.resolvedPath, | ||
| 379 | runtimeId: input.runtimeArchive.runtimeId, | 398 | runtimeId: input.runtimeArchive.runtimeId, |
| 380 | } as const; | 399 | } as const; |
| 381 | } | 400 | } |
| 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( | 401 | yield* Effect.logWarning( |
| 387 | "The staged WSL runtime could not load node-pty; retrying from the mounted server tree.", | 402 | "The staged WSL runtime did not start; retrying from the mounted server tree.", |
| 388 | { reason: stagedNodePty.reason }, | 403 | { reason: stagedProbe.reason }, |
| 389 | ); | 404 | ); |
| 390 | stagedFailure = { runtimeId: input.runtimeArchive.runtimeId, nodePty: stagedNodePty }; | 405 | stagedFailure = { runtimeId: input.runtimeArchive.runtimeId, reason: stagedProbe.reason }; |
| 391 | } else { | 406 | } else { |
| 392 | yield* Effect.logWarning( | 407 | yield* Effect.logWarning( |
| 393 | "Could not stage the WSL runtime; launching from the mounted server tree instead.", | 408 | "Could not stage the WSL runtime; launching from the mounted server tree instead.", |
| ... | @@ -399,7 +414,7 @@ const runWslPreflight = Effect.fn("desktop.backendConfiguration.wslPreflight")(f | ... | @@ -399,7 +414,7 @@ const runWslPreflight = Effect.fn("desktop.backendConfiguration.wslPreflight")(f |
| 399 | const mounted = yield* resolveMountedAppRoot; | 414 | const mounted = yield* resolveMountedAppRoot; |
| 400 | if (!mounted.ok) { | 415 | if (!mounted.ok) { |
| 401 | return stagedFailure && mounted.fatal | 416 | return stagedFailure && mounted.fatal |
| 402 | ? failedNodePty(stagedFailure.nodePty) | 417 | ? failedStaged(stagedFailure) |
| 403 | : ({ _tag: "Failed", reason: mounted.reason, fatal: mounted.fatal } as const); | 418 | : ({ _tag: "Failed", reason: mounted.reason, fatal: mounted.fatal } as const); |
| 404 | } | 419 | } |
| 405 | 420 | ||
| ... | @@ -413,9 +428,9 @@ const runWslPreflight = Effect.fn("desktop.backendConfiguration.wslPreflight")(f | ... | @@ -413,9 +428,9 @@ const runWslPreflight = Effect.fn("desktop.backendConfiguration.wslPreflight")(f |
| 413 | // turn a retryable failure into a fatal one, ending the WSL attempt (and, | 428 | // turn a retryable failure into a fatal one, ending the WSL attempt (and, |
| 414 | // in wsl-only mode, persisting Windows) before the slow /mnt path had a | 429 | // in wsl-only mode, persisting Windows) before the slow /mnt path had a |
| 415 | // chance to answer and clear the bad cache. | 430 | // chance to answer and clear the bad cache. |
| 416 | return failedNodePty( | 431 | return stagedFailure && nodePtyResult.fatal |
| 417 | stagedFailure && nodePtyResult.fatal ? stagedFailure.nodePty : nodePtyResult, | 432 | ? failedStaged(stagedFailure) |
| 418 | ); | 433 | : failedNodePty(nodePtyResult); |
| 419 | } | 434 | } |
| 420 | 435 | ||
| 421 | // The mounted tree runs what the cache could not, so the cache is the broken | 436 | // The mounted tree runs what the cache could not, so the cache is the broken |
| ... | @@ -429,8 +444,11 @@ const runWslPreflight = Effect.fn("desktop.backendConfiguration.wslPreflight")(f | ... | @@ -429,8 +444,11 @@ const runWslPreflight = Effect.fn("desktop.backendConfiguration.wslPreflight")(f |
| 429 | _tag: "Ready", | 444 | _tag: "Ready", |
| 430 | runningDistro, | 445 | runningDistro, |
| 431 | windowsEntryPath: mounted.windowsEntryPath, | 446 | windowsEntryPath: mounted.windowsEntryPath, |
| 432 | linuxEntryPath: `${mounted.linuxAppRoot}/apps/server/dist/bin.mjs`, | 447 | runtime: { |
| 433 | nodePath: nodePtyResult.nodePath, | 448 | kind: "node-script", |
| 449 | nodePath: nodePtyResult.nodePath, | ||
| 450 | linuxEntryPath: `${mounted.linuxAppRoot}/apps/server/dist/bin.mjs`, | ||
| 451 | }, | ||
| 434 | resolvedPath: nodePtyResult.resolvedPath, | 452 | resolvedPath: nodePtyResult.resolvedPath, |
| 435 | } as const; | 453 | } as const; |
| 436 | }); | 454 | }); |
| ... | @@ -610,13 +628,13 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl | ... | @@ -610,13 +628,13 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl |
| 610 | runtimeId: `sha256-${archiveHash}`, | 628 | runtimeId: `sha256-${archiveHash}`, |
| 611 | sha256: archiveHash, | 629 | sha256: archiveHash, |
| 612 | }, | 630 | }, |
| 613 | // Packaged builds ship a prebuilt Linux node-pty (built on Linux in CI and | 631 | // Packaged builds run the self-contained Linux runtime and, on fallback, |
| 614 | // attached to the Windows artifact — see build-desktop-artifact.ts), so the | 632 | // whatever Linux node-pty the mounted tree carries, so the WSL backend never |
| 615 | // WSL backend never needs a compiler, node-gyp, or network on first launch. | 633 | // needs a compiler, node-gyp, or network on first launch. Compiling from |
| 616 | // Compiling from source is a dev-only convenience: a checkout has no shipped | 634 | // source is a dev-only convenience: a checkout has no Linux binary, and |
| 617 | // prebuilt, and developers have the toolchain. In packaged builds we instead | 635 | // developers have the toolchain. In packaged builds we instead surface a |
| 618 | // surface a clear diagnostic if the prebuilt can't load (unsupported | 636 | // clear diagnostic if the binary can't load (unsupported arch/distro), |
| 619 | // arch/distro), rather than silently dropping into a fragile runtime build. | 637 | // rather than silently dropping into a fragile runtime build. |
| 620 | allowBuild: !environment.isPackaged, | 638 | allowBuild: !environment.isPackaged, |
| 621 | }); | 639 | }); |
| 622 | 640 | ||
| ... | @@ -709,15 +727,23 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl | ... | @@ -709,15 +727,23 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl |
| 709 | 727 | ||
| 710 | // The WSL server spawns commands its providers reference by name — `npm`/`npx` | 728 | // The WSL server spawns commands its providers reference by name — `npm`/`npx` |
| 711 | // for provider updates, and the installed CLIs themselves (e.g. `codex`). Those | 729 | // for provider updates, and the installed CLIs themselves (e.g. `codex`). Those |
| 712 | // live in the resolved Node's bin dir, which `wsl.exe -- node` does NOT put on | 730 | // live on the user's login-shell PATH, which `wsl.exe --exec` does NOT put on |
| 713 | // the process PATH, so `npm install -g ...` fails with NotFound. Pass the | 731 | // the process PATH, so `npm install -g ...` fails with NotFound. Pass the |
| 714 | // user PATH entries captured by the login-shell preflight. Every dynamic | 732 | // user PATH entries captured by the preflight. Every dynamic value is a |
| 715 | // value is a separate argv entry under `wsl.exe --exec`; no shell command is | 733 | // separate argv entry under `wsl.exe --exec`; no shell command is involved, |
| 716 | // involved, so Windows cannot mangle nested quotes and stdin remains reserved | 734 | // so Windows cannot mangle nested quotes and stdin remains reserved for the |
| 717 | // for the bootstrap envelope. | 735 | // bootstrap envelope. A node-script runtime additionally leads with the |
| 718 | const lastSlash = preflight.nodePath.lastIndexOf("/"); | 736 | // probed Node's bin dir so the server cannot pick up a different node than |
| 719 | const nodeBinDir = lastSlash > 0 ? preflight.nodePath.slice(0, lastSlash) : "/usr/bin"; | 737 | // the one node-pty was probed with. |
| 720 | const launchPath = `${nodeBinDir}:${WSL_SERVER_SYSTEM_PATH}:${preflight.resolvedPath}`; | 738 | const runtime = preflight.runtime; |
| 739 | const launchPath = | ||
| 740 | runtime.kind === "executable" | ||
| 741 | ? `${WSL_SERVER_SYSTEM_PATH}:${preflight.resolvedPath}` | ||
| 742 | : `${nodeBinDirOf(runtime.nodePath)}:${WSL_SERVER_SYSTEM_PATH}:${preflight.resolvedPath}`; | ||
| 743 | const command = | ||
| 744 | runtime.kind === "executable" | ||
| 745 | ? [runtime.entryPath] | ||
| 746 | : [runtime.nodePath, runtime.linuxEntryPath]; | ||
| 721 | 747 | ||
| 722 | return { | 748 | return { |
| 723 | ...baseConfig, | 749 | ...baseConfig, |
| ... | @@ -726,8 +752,7 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl | ... | @@ -726,8 +752,7 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl |
| 726 | "--exec", | 752 | "--exec", |
| 727 | "env", | 753 | "env", |
| 728 | `PATH=${launchPath}`, | 754 | `PATH=${launchPath}`, |
| 729 | preflight.nodePath, | 755 | ...command, |
| 730 | preflight.linuxEntryPath, | ||
| 731 | "--bootstrap-fd", | 756 | "--bootstrap-fd", |
| 732 | "0", | 757 | "0", |
| 733 | ...devUrlArgs, | 758 | ...devUrlArgs, |
apps/desktop/src/wsl/DesktopWslEnvironment.test.ts+42-45| ... | @@ -74,7 +74,9 @@ const readField = (stdout: string, field: string) => { | ... | @@ -74,7 +74,9 @@ const readField = (stdout: string, field: string) => { |
| 74 | return line.slice(field.length + 1).trim(); | 74 | return line.slice(field.length + 1).trim(); |
| 75 | }; | 75 | }; |
| 76 | 76 | ||
| 77 | const SERVER_ENTRY_SOURCE = 'console.log("t3code wsl runtime test server");'; | 77 | // Stands in for the release's self-contained `t3` executable: the install |
| 78 | // script only asks it for `--version`. | ||
| 79 | const SERVER_ENTRY_SOURCE = '#!/bin/sh\necho "t3code wsl runtime test server 0.0.0"\n'; | ||
| 78 | 80 | ||
| 79 | const makeDistroListSpawner = (result: { readonly stdout?: string; readonly exitCode?: number }) => | 81 | const makeDistroListSpawner = (result: { readonly stdout?: string; readonly exitCode?: number }) => |
| 80 | ChildProcessSpawner.make(() => | 82 | ChildProcessSpawner.make(() => |
| ... | @@ -164,21 +166,22 @@ describe("WSL runtime cache", () => { | ... | @@ -164,21 +166,22 @@ describe("WSL runtime cache", () => { |
| 164 | 166 | ||
| 165 | expect(script).toContain('runtime_parent="$HOME/.t3/wsl-runtime"'); | 167 | expect(script).toContain('runtime_parent="$HOME/.t3/wsl-runtime"'); |
| 166 | expect(script).toContain(' [ -f "$ready_marker" ] &&'); | 168 | expect(script).toContain(' [ -f "$ready_marker" ] &&'); |
| 167 | expect(script).toContain(' [ -f "$runtime_root/apps/server/dist/bin.mjs" ] &&'); | 169 | expect(script).toContain(' runtime_entry_runs "$runtime_root" &&'); |
| 168 | expect(script).toContain(' [ -f "$runtime_root/node_modules/node-pty/package.json" ] &&'); | ||
| 169 | expect(script).toContain(' node_pty_payload_present "$runtime_root"'); | ||
| 170 | expect(script).toContain("if runtime_is_ready; then"); | 170 | expect(script).toContain("if runtime_is_ready; then"); |
| 171 | expect(script).not.toContain("bin.mjs"); | ||
| 172 | expect(script).not.toContain("node-pty"); | ||
| 171 | expect(script).toContain("trap 'exit 1' HUP INT TERM"); | 173 | expect(script).toContain("trap 'exit 1' HUP INT TERM"); |
| 172 | expect(script).toContain('exec 9> "$runtime_lock"'); | 174 | expect(script).toContain('exec 9> "$runtime_lock"'); |
| 173 | expect(script).toContain("flock -x 9"); | 175 | expect(script).toContain("flock -x 9"); |
| 174 | expect(script).not.toContain('rm -rf "$runtime_lock"'); | 176 | expect(script).not.toContain('rm -rf "$runtime_lock"'); |
| 175 | expect(script).toContain('mv -T "$runtime_root" "$runtime_stale"'); | 177 | expect(script).toContain('mv -T "$runtime_root" "$runtime_stale"'); |
| 176 | expect(script).toContain('mktemp -d "$runtime_parent/.1.2.3-x64.tmp.XXXXXX"'); | 178 | expect(script).toContain('mktemp -d "$runtime_parent/.1.2.3-x64.tmp.XXXXXX"'); |
| 179 | // The release archive wraps everything in one `t3-<version>-linux-x64/` | ||
| 180 | // directory; stripping it puts the executable at `$runtime_root/t3`. | ||
| 177 | expect(script).toContain( | 181 | expect(script).toContain( |
| 178 | "tar -xzf '/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz' -C \"$runtime_tmp\"", | 182 | "tar -xzf '/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz' -C \"$runtime_tmp\" --strip-components=1", |
| 179 | ); | 183 | ); |
| 180 | expect(script).toContain('test -f "$runtime_tmp/apps/server/dist/bin.mjs"'); | 184 | expect(script).toContain('if ! runtime_entry_runs "$runtime_tmp"; then'); |
| 181 | expect(script).toContain('test -f "$runtime_tmp/node_modules/node-pty/package.json"'); | ||
| 182 | expect(script).toContain('mv -T "$runtime_tmp" "$runtime_root"'); | 185 | expect(script).toContain('mv -T "$runtime_tmp" "$runtime_root"'); |
| 183 | expect(script).not.toContain('rm -rf "$runtime_root"'); | 186 | expect(script).not.toContain('rm -rf "$runtime_root"'); |
| 184 | 187 | ||
| ... | @@ -248,46 +251,39 @@ describe("WSL runtime cache", () => { | ... | @@ -248,46 +251,39 @@ describe("WSL runtime cache", () => { |
| 248 | expect(deleted).toBeGreaterThan(kept); | 251 | expect(deleted).toBeGreaterThan(kept); |
| 249 | }); | 252 | }); |
| 250 | 253 | ||
| 251 | it("treats a runtime whose native payload went missing as a cache miss", () => { | 254 | it("treats a runtime whose executable no longer runs as a cache miss", () => { |
| 252 | const script = buildWslRuntimeInstallScript( | 255 | const script = buildWslRuntimeInstallScript( |
| 253 | "/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz", | 256 | "/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz", |
| 254 | "1.2.3-x64", | 257 | "1.2.3-x64", |
| 255 | "b".repeat(64), | 258 | "b".repeat(64), |
| 256 | ); | 259 | ); |
| 257 | 260 | ||
| 258 | // A glob, not a mapped `uname -m`: this is a presence check, and the later | 261 | // The same proof the SSH runner and CLI installers use: executable, and |
| 259 | // native probe is what judges arch and loadability. | 262 | // `--version` exits 0. That is what decides arch and loadability, so no |
| 260 | expect(script).toContain( | 263 | // separate native probe is needed. |
| 261 | ' for candidate in "$1"/node_modules/node-pty/prebuilds/linux-*/pty.node; do', | 264 | expect(script).toContain(' [ -x "$1/t3" ] && "$1/t3" --version >/dev/null 2>&1'); |
| 262 | ); | ||
| 263 | // The marker the probe reads must sit beside the binary, or the runtime is | ||
| 264 | // just as unusable as one missing pty.node outright. | ||
| 265 | expect(script).toContain(' [ -f "${candidate%/*}/t3code-wsl-node-pty.json" ] || continue'); | ||
| 266 | 265 | ||
| 267 | // Readiness gates the short-circuit, so a cache missing the payload | 266 | // Readiness gates the short-circuit, so a cache whose executable broke |
| 268 | // reinstalls from the archive instead of being reused forever. | 267 | // reinstalls from the archive instead of being reused forever. |
| 269 | const payloadCheckDefined = script.indexOf("node_pty_payload_present() {"); | 268 | const entryCheckDefined = script.indexOf("runtime_entry_runs() {"); |
| 270 | const readinessDefined = script.indexOf("runtime_is_ready() {"); | 269 | const readinessDefined = script.indexOf("runtime_is_ready() {"); |
| 271 | const readyShortCircuit = script.indexOf("if runtime_is_ready; then"); | 270 | const readyShortCircuit = script.indexOf("if runtime_is_ready; then"); |
| 272 | expect(payloadCheckDefined).toBeGreaterThan(-1); | 271 | expect(entryCheckDefined).toBeGreaterThan(-1); |
| 273 | expect(payloadCheckDefined).toBeLessThan(readinessDefined); | 272 | expect(entryCheckDefined).toBeLessThan(readinessDefined); |
| 274 | expect(readinessDefined).toBeLessThan(readyShortCircuit); | 273 | expect(readinessDefined).toBeLessThan(readyShortCircuit); |
| 275 | }); | 274 | }); |
| 276 | 275 | ||
| 277 | // A truncated or half-written bin.mjs passes every presence check the cache | 276 | // A swapped or half-written `t3` can still exist and even still answer |
| 278 | // had: the file exists, node-pty still loads, and launch then picks a server | 277 | // `--version`, and launch then runs something this install never verified. |
| 279 | // that exits before it becomes ready — forever, because nothing ever | 278 | // The digest the install records is what turns that into a miss. |
| 280 | // reinstalls. The digest the install records is what turns that into a miss. | 279 | it("re-hashes the executable against the digest the install recorded", () => { |
| 281 | it("re-hashes the server entry against the digest the install recorded", () => { | ||
| 282 | const script = buildWslRuntimeInstallScript( | 280 | const script = buildWslRuntimeInstallScript( |
| 283 | "/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz", | 281 | "/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz", |
| 284 | "1.2.3-x64", | 282 | "1.2.3-x64", |
| 285 | "b".repeat(64), | 283 | "b".repeat(64), |
| 286 | ); | 284 | ); |
| 287 | 285 | ||
| 288 | expect(script).toContain( | 286 | expect(script).toContain(` sha256sum "$1/t3" 2>/dev/null | cut -d ' ' -f 1`); |
| 289 | ` sha256sum "$1/apps/server/dist/bin.mjs" 2>/dev/null | cut -d ' ' -f 1`, | ||
| 290 | ); | ||
| 291 | expect(script).toContain( | 287 | expect(script).toContain( |
| 292 | ' [ "$recorded_entry_digest" = "$(runtime_server_entry_digest "$runtime_root")" ]', | 288 | ' [ "$recorded_entry_digest" = "$(runtime_server_entry_digest "$runtime_root")" ]', |
| 293 | ); | 289 | ); |
| ... | @@ -310,18 +306,18 @@ describe("WSL runtime cache", () => { | ... | @@ -310,18 +306,18 @@ describe("WSL runtime cache", () => { |
| 310 | expect(promoted).toBeGreaterThan(markerWritten); | 306 | expect(promoted).toBeGreaterThan(markerWritten); |
| 311 | }); | 307 | }); |
| 312 | 308 | ||
| 313 | it("refuses to mark an archive without a native payload as ready", () => { | 309 | it("refuses to mark an archive whose executable does not run as ready", () => { |
| 314 | const script = buildWslRuntimeInstallScript( | 310 | const script = buildWslRuntimeInstallScript( |
| 315 | "/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz", | 311 | "/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz", |
| 316 | "1.2.3-x64", | 312 | "1.2.3-x64", |
| 317 | "b".repeat(64), | 313 | "b".repeat(64), |
| 318 | ); | 314 | ); |
| 319 | 315 | ||
| 320 | expect(script).toContain('if ! node_pty_payload_present "$runtime_tmp"; then'); | 316 | expect(script).toContain('if ! runtime_entry_runs "$runtime_tmp"; then'); |
| 321 | 317 | ||
| 322 | // The extracted tree is rejected before the ready marker is written, so a | 318 | // The extracted tree is rejected before the ready marker is written, so a |
| 323 | // defective archive falls back to the mounted tree instead of caching. | 319 | // defective archive falls back to the mounted tree instead of caching. |
| 324 | const payloadValidated = script.indexOf('node_pty_payload_present "$runtime_tmp"'); | 320 | const payloadValidated = script.indexOf('runtime_entry_runs "$runtime_tmp"'); |
| 325 | const markerWritten = script.indexOf('> "$runtime_tmp/.t3code-wsl-runtime-ready"'); | 321 | const markerWritten = script.indexOf('> "$runtime_tmp/.t3code-wsl-runtime-ready"'); |
| 326 | const promoted = script.indexOf('mv -T "$runtime_tmp" "$runtime_root"'); | 322 | const promoted = script.indexOf('mv -T "$runtime_tmp" "$runtime_root"'); |
| 327 | expect(payloadValidated).toBeGreaterThan(-1); | 323 | expect(payloadValidated).toBeGreaterThan(-1); |
| ... | @@ -351,7 +347,7 @@ describe("WSL runtime cache", () => { | ... | @@ -351,7 +347,7 @@ describe("WSL runtime cache", () => { |
| 351 | it("never deletes a runtime another backend is running from", () => { | 347 | it("never deletes a runtime another backend is running from", () => { |
| 352 | const script = buildWslRuntimePruneScript("1.2.3/x64"); | 348 | const script = buildWslRuntimePruneScript("1.2.3/x64"); |
| 353 | 349 | ||
| 354 | // The running backend's argv holds `<runtime>/apps/server/dist/bin.mjs`, so | 350 | // The running backend's argv holds `<runtime>/t3`, so |
| 355 | // the process itself is the lease and exiting releases it. Nothing has to be | 351 | // the process itself is the lease and exiting releases it. Nothing has to be |
| 356 | // registered up front, which is what makes this cover backends already | 352 | // registered up front, which is what makes this cover backends already |
| 357 | // running from an older version that knows nothing about pruning. | 353 | // running from an older version that knows nothing about pruning. |
| ... | @@ -394,7 +390,7 @@ describe("WSL runtime cache", () => { | ... | @@ -394,7 +390,7 @@ describe("WSL runtime cache", () => { |
| 394 | }); | 390 | }); |
| 395 | 391 | ||
| 396 | // Reading the generated script proves what it says, not what it does. A cache | 392 | // Reading the generated script proves what it says, not what it does. A cache |
| 397 | // whose bin.mjs was truncated satisfied every assertion above and still got | 393 | // whose entry was truncated satisfied every assertion above and still got |
| 398 | // reused, so these run the real script against a real archive in a throwaway | 394 | // reused, so these run the real script against a real archive in a throwaway |
| 399 | // HOME and check the outcome. | 395 | // HOME and check the outcome. |
| 400 | describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed)", () => { | 396 | describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed)", () => { |
| ... | @@ -410,13 +406,14 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed | ... | @@ -410,13 +406,14 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed |
| 410 | [ | 406 | [ |
| 411 | "set -eu", | 407 | "set -eu", |
| 412 | "work=$(mktemp -d)", | 408 | "work=$(mktemp -d)", |
| 413 | 'stage="$work/stage"', | 409 | // Mirrors the release archive: one top-level versioned directory that |
| 414 | 'mkdir -p "$stage/apps/server/dist" "$stage/node_modules/node-pty/prebuilds/linux-x64" "$work/home"', | 410 | // holds the executable and its native addons. |
| 415 | `printf '%s' ${sh(SERVER_ENTRY_SOURCE)} > "$stage/apps/server/dist/bin.mjs"`, | 411 | 'stage="$work/stage/t3-0.0.0-linux-x64"', |
| 416 | `printf '%s' '{"name":"node-pty","version":"0.0.0-test"}' > "$stage/node_modules/node-pty/package.json"`, | 412 | 'mkdir -p "$stage/node_modules/node-pty/build/Release" "$work/home"', |
| 417 | `printf '%s' 'pty-native-payload' > "$stage/node_modules/node-pty/prebuilds/linux-x64/pty.node"`, | 413 | `printf '%s' ${sh(SERVER_ENTRY_SOURCE)} > "$stage/t3"`, |
| 418 | `printf '%s' '{"arch":"x64"}' > "$stage/node_modules/node-pty/prebuilds/linux-x64/t3code-wsl-node-pty.json"`, | 414 | 'chmod +x "$stage/t3"', |
| 419 | `tar -czf "$work/wsl-runtime.tar.gz" -C "$stage" apps/server/dist node_modules`, | 415 | `printf '%s' 'pty-native-payload' > "$stage/node_modules/node-pty/build/Release/pty.node"`, |
| 416 | `tar -czf "$work/wsl-runtime.tar.gz" -C "$work/stage" t3-0.0.0-linux-x64`, | ||
| 420 | `printf 'work:%s\\n' "$work"`, | 417 | `printf 'work:%s\\n' "$work"`, |
| 421 | `printf 'archiveSha:%s\\n' "$(sha256sum "$work/wsl-runtime.tar.gz" | cut -d ' ' -f 1)"`, | 418 | `printf 'archiveSha:%s\\n' "$(sha256sum "$work/wsl-runtime.tar.gz" | cut -d ' ' -f 1)"`, |
| 422 | ].join("\n"), | 419 | ].join("\n"), |
| ... | @@ -443,7 +440,7 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed | ... | @@ -443,7 +440,7 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed |
| 443 | runtimeId, | 440 | runtimeId, |
| 444 | runtimeParent: `${work}/home/.t3/wsl-runtime`, | 441 | runtimeParent: `${work}/home/.t3/wsl-runtime`, |
| 445 | runtimeRoot: `${work}/home/.t3/wsl-runtime/${runtimeId}`, | 442 | runtimeRoot: `${work}/home/.t3/wsl-runtime/${runtimeId}`, |
| 446 | serverEntry: `${work}/home/.t3/wsl-runtime/${runtimeId}/apps/server/dist/bin.mjs`, | 443 | serverEntry: `${work}/home/.t3/wsl-runtime/${runtimeId}/t3`, |
| 447 | installScript, | 444 | installScript, |
| 448 | install: (archive?: string, sha?: string) => runShell(installScript(archive, sha)), | 445 | install: (archive?: string, sha?: string) => runShell(installScript(archive, sha)), |
| 449 | }; | 446 | }; |
| ... | @@ -462,7 +459,7 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed | ... | @@ -462,7 +459,7 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed |
| 462 | expect(parseWslRuntimeRoot(warm.stdout)).toBe(fixture.runtimeRoot); | 459 | expect(parseWslRuntimeRoot(warm.stdout)).toBe(fixture.runtimeRoot); |
| 463 | }); | 460 | }); |
| 464 | 461 | ||
| 465 | it("reinstalls a cache whose server entry was truncated", () => { | 462 | it("reinstalls a cache whose executable was truncated", () => { |
| 466 | const fixture = createFixture(); | 463 | const fixture = createFixture(); |
| 467 | expect(fixture.install().status).toBe(0); | 464 | expect(fixture.install().status).toBe(0); |
| 468 | expect(runShell(`set -eu\n: > ${sh(fixture.serverEntry)}`).status).toBe(0); | 465 | expect(runShell(`set -eu\n: > ${sh(fixture.serverEntry)}`).status).toBe(0); |
| ... | @@ -645,7 +642,7 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed | ... | @@ -645,7 +642,7 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed |
| 645 | `runtime_root=${sh(fixture.runtimeRoot)}`, | 642 | `runtime_root=${sh(fixture.runtimeRoot)}`, |
| 646 | `runtime_parent=${sh(fixture.runtimeParent)}`, | 643 | `runtime_parent=${sh(fixture.runtimeParent)}`, |
| 647 | 'rm "$runtime_root/.t3code-wsl-runtime-ready"', | 644 | 'rm "$runtime_root/.t3code-wsl-runtime-ready"', |
| 648 | 'sh -c "sleep 30" "$runtime_root/apps/server/dist/bin.mjs" >/dev/null 2>&1 &', | 645 | 'sh -c "sleep 30" "$runtime_root/t3" >/dev/null 2>&1 &', |
| 649 | "active_pid=$!", | 646 | "active_pid=$!", |
| 650 | "sleep 0.1", | 647 | "sleep 0.1", |
| 651 | fixture.installScript(), | 648 | fixture.installScript(), |
| ... | @@ -672,7 +669,7 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed | ... | @@ -672,7 +669,7 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed |
| 672 | 'home="$work/home"', | 669 | 'home="$work/home"', |
| 673 | 'runtime_parent="$home/.t3/wsl-runtime"', | 670 | 'runtime_parent="$home/.t3/wsl-runtime"', |
| 674 | 'mkdir -p "$runtime_parent"', | 671 | 'mkdir -p "$runtime_parent"', |
| 675 | 'make_ready() { mkdir -p "$runtime_parent/$1/apps/server/dist"; printf ready > "$runtime_parent/$1/.t3code-wsl-runtime-ready"; }', | 672 | 'make_ready() { mkdir -p "$runtime_parent/$1"; printf ready > "$runtime_parent/$1/.t3code-wsl-runtime-ready"; }', |
| 676 | "make_ready sha256-current", | 673 | "make_ready sha256-current", |
| 677 | "make_ready sha256-previous", | 674 | "make_ready sha256-previous", |
| 678 | "make_ready sha256-active", | 675 | "make_ready sha256-active", |
| ... | @@ -683,7 +680,7 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed | ... | @@ -683,7 +680,7 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed |
| 683 | 'touch -d "4 minutes ago" "$runtime_parent/sha256-active"', | 680 | 'touch -d "4 minutes ago" "$runtime_parent/sha256-active"', |
| 684 | 'touch -d "3 minutes ago" "$runtime_parent/sha256-old"', | 681 | 'touch -d "3 minutes ago" "$runtime_parent/sha256-old"', |
| 685 | 'touch -d "2 minutes ago" "$runtime_parent/sha256-locked"', | 682 | 'touch -d "2 minutes ago" "$runtime_parent/sha256-locked"', |
| 686 | 'sh -c "sleep 30" "$runtime_parent/sha256-active/apps/server/dist/bin.mjs" >/dev/null 2>&1 &', | 683 | 'sh -c "sleep 30" "$runtime_parent/sha256-active/t3" >/dev/null 2>&1 &', |
| 687 | "active_pid=$!", | 684 | "active_pid=$!", |
| 688 | "(", | 685 | "(", |
| 689 | ' exec 9> "$runtime_parent/.sha256-locked.install.lock"', | 686 | ' exec 9> "$runtime_parent/.sha256-locked.install.lock"', |
apps/desktop/src/wsl/DesktopWslEnvironment.ts+125-71| ... | @@ -66,6 +66,19 @@ export type EnsureWslNodePtyResult = | ... | @@ -66,6 +66,19 @@ export type EnsureWslNodePtyResult = |
| 66 | readonly retryLimit?: number; | 66 | readonly retryLimit?: number; |
| 67 | }; | 67 | }; |
| 68 | 68 | ||
| 69 | // Outcome of asking the staged self-contained runtime to prove itself. Any | ||
| 70 | // failure sends the launch to the mounted server tree; the caller decides what | ||
| 71 | // to do with the cache. | ||
| 72 | export type ProbeWslRuntimeResult = | ||
| 73 | | { | ||
| 74 | readonly ok: true; | ||
| 75 | readonly resolvedPath: string; | ||
| 76 | } | ||
| 77 | | { | ||
| 78 | readonly ok: false; | ||
| 79 | readonly reason: string; | ||
| 80 | }; | ||
| 81 | |||
| 69 | export class DesktopWslDistroListError extends Schema.TaggedError<DesktopWslDistroListError>()( | 82 | export class DesktopWslDistroListError extends Schema.TaggedError<DesktopWslDistroListError>()( |
| 70 | "DesktopWslDistroListError", | 83 | "DesktopWslDistroListError", |
| 71 | { reason: Schema.String }, | 84 | { reason: Schema.String }, |
| ... | @@ -108,6 +121,13 @@ export class DesktopWslEnvironment extends Context.Service< | ... | @@ -108,6 +121,13 @@ export class DesktopWslEnvironment extends Context.Service< |
| 108 | readonly pruneRuntimes: (distro: string | null, runtimeId: string) => Effect.Effect<void>; | 121 | readonly pruneRuntimes: (distro: string | null, runtimeId: string) => Effect.Effect<void>; |
| 109 | // Marks a staged runtime as unusable so the next launch reinstalls it. | 122 | // Marks a staged runtime as unusable so the next launch reinstalls it. |
| 110 | readonly invalidateRuntime: (distro: string | null, runtimeId: string) => Effect.Effect<void>; | 123 | readonly invalidateRuntime: (distro: string | null, runtimeId: string) => Effect.Effect<void>; |
| 124 | // Proves a staged self-contained runtime can run (`<root>/t3 --version`) | ||
| 125 | // and captures the user's login-shell PATH for the launch. Needs no Node | ||
| 126 | // in the distro; the mounted server tree still goes through ensureNodePty. | ||
| 127 | readonly probeRuntime: ( | ||
| 128 | distro: string | null, | ||
| 129 | linuxAppRoot: string, | ||
| 130 | ) => Effect.Effect<ProbeWslRuntimeResult>; | ||
| 111 | readonly ensureNodePty: ( | 131 | readonly ensureNodePty: ( |
| 112 | distro: string | null, | 132 | distro: string | null, |
| 113 | linuxAppRoot: string, | 133 | linuxAppRoot: string, |
| ... | @@ -149,14 +169,15 @@ const TIMEOUT_RESULT: ShellResult = { | ... | @@ -149,14 +169,15 @@ const TIMEOUT_RESULT: ShellResult = { |
| 149 | 169 | ||
| 150 | const formatWslShellTransportFailureReason = ( | 170 | const formatWslShellTransportFailureReason = ( |
| 151 | failure: ShellResult["transportFailure"], | 171 | failure: ShellResult["transportFailure"], |
| 172 | subject = "Node.js", | ||
| 152 | ): string | null => { | 173 | ): string | null => { |
| 153 | switch (failure) { | 174 | switch (failure) { |
| 154 | case "timeout": | 175 | case "timeout": |
| 155 | return "WSL backend preflight timed out while probing for Node.js. WSL may be slow to start; retry, or check that the distro is healthy."; | 176 | return `WSL backend preflight timed out while probing for ${subject}. WSL may be slow to start; retry, or check that the distro is healthy.`; |
| 156 | case "spawn": | 177 | case "spawn": |
| 157 | return "WSL backend preflight could not start wsl.exe to probe for Node.js. Check that WSL is installed and the distro is accessible."; | 178 | return `WSL backend preflight could not start wsl.exe to probe for ${subject}. Check that WSL is installed and the distro is accessible.`; |
| 158 | case "process": | 179 | case "process": |
| 159 | return "WSL backend preflight lost communication with wsl.exe while probing for Node.js. Retry, or check that the distro is healthy."; | 180 | return `WSL backend preflight lost communication with wsl.exe while probing for ${subject}. Retry, or check that the distro is healthy.`; |
| 160 | case null: | 181 | case null: |
| 161 | return null; | 182 | return null; |
| 162 | } | 183 | } |
| ... | @@ -255,7 +276,7 @@ const runWslShell = ( | ... | @@ -255,7 +276,7 @@ const runWslShell = ( |
| 255 | 276 | ||
| 256 | const shellQuote = (value: string): string => `'${value.replaceAll("'", "'\\''")}'`; | 277 | const shellQuote = (value: string): string => `'${value.replaceAll("'", "'\\''")}'`; |
| 257 | 278 | ||
| 258 | // Holds the sha256 of the runtime's server entry, written when the install | 279 | // Holds the sha256 of the runtime's `t3` executable, written when the install |
| 259 | // promotes a verified tree. Presence alone only says an install once finished | 280 | // 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 | 281 | // here; the digest is what lets a later launch prove the entry still is what |
| 261 | // that install wrote. | 282 | // that install wrote. |
| ... | @@ -279,35 +300,25 @@ export const buildWslRuntimeInstallScript = ( | ... | @@ -279,35 +300,25 @@ export const buildWslRuntimeInstallScript = ( |
| 279 | 'runtime_parent="$HOME/.t3/wsl-runtime"', | 300 | 'runtime_parent="$HOME/.t3/wsl-runtime"', |
| 280 | `runtime_root="$runtime_parent/${safeRuntimeId}"`, | 301 | `runtime_root="$runtime_parent/${safeRuntimeId}"`, |
| 281 | `ready_marker="$runtime_root/${WSL_RUNTIME_READY_MARKER}"`, | 302 | `ready_marker="$runtime_root/${WSL_RUNTIME_READY_MARKER}"`, |
| 282 | // The native payload is the part of the tree the WSL backend actually | 303 | // The runtime is a self-contained `t3` executable with Node inside, so the |
| 283 | // dlopens, and the only part a user can plausibly break by hand. Checking | 304 | // readiness proof is the same one the SSH runner and the CLI installers |
| 284 | // node-pty's package.json alone let a runtime whose pty.node had gone | 305 | // use: the file is executable and `t3 --version` exits 0. That covers the |
| 285 | // missing stay cache-ready forever: every launch reused it and then failed | 306 | // truncated-binary and wrong-arch cases without a separate native probe. |
| 286 | // the native probe, with no reinstall and no fallback. Match on the glob | 307 | "runtime_entry_runs() {", |
| 287 | // rather than a mapped `uname -m` so this stays a presence check; the probe | 308 | ' [ -x "$1/t3" ] && "$1/t3" --version >/dev/null 2>&1', |
| 288 | // is what decides whether the binary is the right arch and loadable. | ||
| 289 | "node_pty_payload_present() {", | ||
| 290 | ' for candidate in "$1"/node_modules/node-pty/prebuilds/linux-*/pty.node; do', | ||
| 291 | ' [ -f "$candidate" ] || continue', | ||
| 292 | ' [ -f "${candidate%/*}/t3code-wsl-node-pty.json" ] || continue', | ||
| 293 | " return 0", | ||
| 294 | " done", | ||
| 295 | " return 1", | ||
| 296 | "}", | 309 | "}", |
| 297 | // Hashing the server entry is the only check that can tell a working cache | 310 | // Hashing the entry is what tells a working cache from one whose `t3` was |
| 298 | // from one whose bin.mjs was truncated or half-written: the file is still | 311 | // swapped or half-written after install: the file is still there and may |
| 299 | // there, the native probe still passes, and launch then picks a server that | 312 | // even still run, and launch then picks an executable that is not what |
| 300 | // exits before it can become ready, on every restart. Hashing the ~7MB | 313 | // this install verified. Hashing the executable measures in tens of |
| 301 | // entry measures in single-digit milliseconds inside the distro, once per | 314 | // milliseconds inside the distro, once per launch, against a cold |
| 302 | // launch, against a cold reinstall of a few hundred megabytes. | 315 | // reinstall of a few hundred megabytes. |
| 303 | "runtime_server_entry_digest() {", | 316 | "runtime_server_entry_digest() {", |
| 304 | ` sha256sum "$1/apps/server/dist/bin.mjs" 2>/dev/null | cut -d ' ' -f 1`, | 317 | ` sha256sum "$1/t3" 2>/dev/null | cut -d ' ' -f 1`, |
| 305 | "}", | 318 | "}", |
| 306 | "runtime_is_ready() {", | 319 | "runtime_is_ready() {", |
| 307 | ' [ -f "$ready_marker" ] &&', | 320 | ' [ -f "$ready_marker" ] &&', |
| 308 | ' [ -f "$runtime_root/apps/server/dist/bin.mjs" ] &&', | 321 | ' runtime_entry_runs "$runtime_root" &&', |
| 309 | ' [ -f "$runtime_root/node_modules/node-pty/package.json" ] &&', | ||
| 310 | ' node_pty_payload_present "$runtime_root" &&', | ||
| 311 | // An empty or unreadable marker is a miss, not a pass: that is what a | 322 | // An empty or unreadable marker is a miss, not a pass: that is what a |
| 312 | // runtime installed before the marker carried a digest looks like, and one | 323 | // runtime installed before the marker carried a digest looks like, and one |
| 313 | // reinstall is the cheapest way to make it verifiable from then on. | 324 | // reinstall is the cheapest way to make it verifiable from then on. |
| ... | @@ -370,15 +381,14 @@ export const buildWslRuntimeInstallScript = ( | ... | @@ -370,15 +381,14 @@ export const buildWslRuntimeInstallScript = ( |
| 370 | `runtime_tmp=$(mktemp -d "$runtime_parent/.${safeRuntimeId}.tmp.XXXXXX")`, | 381 | `runtime_tmp=$(mktemp -d "$runtime_parent/.${safeRuntimeId}.tmp.XXXXXX")`, |
| 371 | 'cleanup_runtime_install() { rm -rf "$runtime_tmp"; }', | 382 | 'cleanup_runtime_install() { rm -rf "$runtime_tmp"; }', |
| 372 | "trap cleanup_runtime_install EXIT", | 383 | "trap cleanup_runtime_install EXIT", |
| 373 | `tar -xzf ${shellQuote(linuxArchivePath)} -C "$runtime_tmp"`, | 384 | // The release archive has one top-level `t3-<version>-linux-<arch>/` |
| 374 | 'test -f "$runtime_tmp/apps/server/dist/bin.mjs"', | 385 | // directory; strip it so the executable lands at `$runtime_root/t3`. |
| 375 | 'test -f "$runtime_tmp/node_modules/node-pty/package.json"', | 386 | `tar -xzf ${shellQuote(linuxArchivePath)} -C "$runtime_tmp" --strip-components=1`, |
| 376 | 387 | // Never write the ready marker over a tree whose executable does not run. | |
| 377 | // Never write the ready marker over a tree that is missing the native | 388 | // Failing here drops out to the mounted-tree fallback, which is |
| 378 | // payload. Failing here drops out to the mounted-tree fallback, which is | ||
| 379 | // recoverable; promoting it would mark the defect ready and cache it. | 389 | // recoverable; promoting it would mark the defect ready and cache it. |
| 380 | 'if ! node_pty_payload_present "$runtime_tmp"; then', | 390 | 'if ! runtime_entry_runs "$runtime_tmp"; then', |
| 381 | " printf 'WSL runtime archive is missing its Linux node-pty binary\\n' >&2", | 391 | " printf 'WSL runtime archive does not contain a working t3 executable\\n' >&2", |
| 382 | " exit 1", | 392 | " exit 1", |
| 383 | "fi", | 393 | "fi", |
| 384 | // The archive's bytes were verified against archiveSha256 above, so the | 394 | // The archive's bytes were verified against archiveSha256 above, so the |
| ... | @@ -466,12 +476,12 @@ export const buildWslRuntimePruneScript = (runtimeId: string): string => { | ... | @@ -466,12 +476,12 @@ export const buildWslRuntimePruneScript = (runtimeId: string): string => { |
| 466 | }; | 476 | }; |
| 467 | 477 | ||
| 468 | // Drops the ready marker so the next launch reinstalls the runtime from the | 478 | // Drops the ready marker so the next launch reinstalls the runtime from the |
| 469 | // archive. Readiness is a presence check by design, so a cached tree whose | 479 | // archive. Readiness is decided inside the install script, so a cached tree |
| 470 | // native payload is present but unloadable (truncated pty.node, a distro whose | 480 | // that passes there but fails the launch-time probe (a distro whose glibc the |
| 471 | // glibc the binary needs and the tree was copied from another machine) stays | 481 | // executable needs, a tree copied from another machine) would stay ready |
| 472 | // ready forever and fails the probe on every launch. Only the probe can see | 482 | // forever and fail on every launch. Only the probe can see that, so the probe |
| 473 | // that, so the probe is what revokes the marker. The tree itself is left in | 483 | // is what revokes the marker. The tree itself is left in place: the install |
| 474 | // place: the install script moves an unready root aside before extracting. | 484 | // script moves an unready root aside before extracting. |
| 475 | export const buildWslRuntimeInvalidateScript = (runtimeId: string): string => { | 485 | export const buildWslRuntimeInvalidateScript = (runtimeId: string): string => { |
| 476 | const safeRuntimeId = sanitizeWslRuntimeId(runtimeId); | 486 | const safeRuntimeId = sanitizeWslRuntimeId(runtimeId); |
| 477 | return [ | 487 | return [ |
| ... | @@ -488,22 +498,29 @@ export const parseWslRuntimeRoot = (stdout: string): string | null => { | ... | @@ -488,22 +498,29 @@ export const parseWslRuntimeRoot = (stdout: string): string | null => { |
| 488 | return runtimeRoot.startsWith("/") ? runtimeRoot : null; | 498 | return runtimeRoot.startsWith("/") ? runtimeRoot : null; |
| 489 | }; | 499 | }; |
| 490 | 500 | ||
| 491 | const NODE_PTY_PREBUILD_MISSING_EXIT_CODE = 4; | 501 | // The mounted server tree carries no Linux pty.node unless the build put one |
| 502 | // there. Distinct from a binary that is present but will not load, which is a | ||
| 503 | // distro problem rather than a build problem. | ||
| 504 | const NODE_PTY_BINARY_MISSING_EXIT_CODE = 4; | ||
| 492 | 505 | ||
| 493 | const formatNodePtyProbeFailureReason = (exitCode: number): string | null => | 506 | const formatNodePtyProbeFailureReason = (exitCode: number): string | null => |
| 494 | exitCode === NODE_PTY_PREBUILD_MISSING_EXIT_CODE | 507 | exitCode === NODE_PTY_BINARY_MISSING_EXIT_CODE |
| 495 | ? "WSL support is missing from this T3 Code build: the packaged Linux node-pty binary was not included. Rebuild the Windows artifact with `--wsl-prebuild <path-to-linux-pty.node>` or install a build that includes WSL support." | 508 | ? "WSL support is missing from this T3 Code build: the packaged Linux node-pty binary was not included. Install a build that includes WSL support." |
| 496 | : null; | 509 | : null; |
| 497 | 510 | ||
| 511 | // Captures the login-shell PATH as `resolvedPath:` so the launch can forward the | ||
| 512 | // user's PATH; the server spawns provider CLIs (`codex`, `claude`) by name. | ||
| 513 | const RESOLVED_PATH_LINE = `printf 'resolvedPath:%s\\n' "$PATH"`; | ||
| 514 | |||
| 498 | const NODE_PTY_PROBE_SCRIPT = ( | 515 | const NODE_PTY_PROBE_SCRIPT = ( |
| 499 | linuxServerDir: string, | 516 | linuxServerDir: string, |
| 500 | ) => `printf 'nodePath:%s\\n' "$(command -v node 2>/dev/null)" | 517 | ) => `printf 'nodePath:%s\\n' "$(command -v node 2>/dev/null)" |
| 501 | printf 'nodeVersion:%s\\n' "$(node -p 'process.versions.node' 2>/dev/null)" | 518 | printf 'nodeVersion:%s\\n' "$(node -p 'process.versions.node' 2>/dev/null)" |
| 502 | printf 'resolvedPath:%s\\n' "$PATH" | 519 | ${RESOLVED_PATH_LINE} |
| 503 | cd ${shellQuote(linuxServerDir)} && node <<'NODE' >/dev/null 2>&1 | 520 | cd ${shellQuote(linuxServerDir)} && node <<'NODE' >/dev/null 2>&1 |
| 504 | // The WSL Node can't read inside app.asar, so confirm what the server needs is | 521 | // The WSL Node can't read inside app.asar, so confirm what the server needs is |
| 505 | // unpacked on the real filesystem before reporting the backend healthy. Exit 3 | 522 | // unpacked on the real filesystem before reporting the backend healthy. Exit 3 |
| 506 | // marks this distinct from a node-pty prebuild problem so the caller can report | 523 | // marks this distinct from a node-pty binary problem so the caller can report |
| 507 | // it accurately instead of letting the server crash on ERR_MODULE_NOT_FOUND at | 524 | // it accurately instead of letting the server crash on ERR_MODULE_NOT_FOUND at |
| 508 | // launch (which, in wsl-only mode, would just fail to launch with no fallback). | 525 | // launch (which, in wsl-only mode, would just fail to launch with no fallback). |
| 509 | // | 526 | // |
| ... | @@ -517,26 +534,27 @@ const fs = require("node:fs"); | ... | @@ -517,26 +534,27 @@ const fs = require("node:fs"); |
| 517 | const path = require("node:path"); | 534 | const path = require("node:path"); |
| 518 | const pkgDir = path.dirname(require.resolve("node-pty/package.json")); | 535 | const pkgDir = path.dirname(require.resolve("node-pty/package.json")); |
| 519 | // node-pty 1.x is N-API based, so a single Linux pty.node is ABI-stable across | 536 | // node-pty 1.x is N-API based, so a single Linux pty.node is ABI-stable across |
| 520 | // Node versions — require() succeeding IS the real compatibility test. Compare | 537 | // Node versions — require() succeeding IS the real compatibility test. Look in |
| 521 | // only arch and node-pty version (a stale binary from a different node-pty), | 538 | // the same places node-pty's own loader does. |
| 522 | // NOT process.versions.modules: that would reject a perfectly loadable prebuilt | 539 | const candidates = [ |
| 523 | // whenever the user's WSL Node ABI differs from the build's, defeating the | 540 | path.join(pkgDir, "build", "Release", "pty.node"), |
| 524 | // whole point of shipping one prebuilt for all Node versions. | 541 | path.join(pkgDir, "prebuilds", "linux-" + process.arch, "pty.node"), |
| 525 | const expected = { | 542 | ]; |
| 526 | arch: process.arch, | 543 | if (!candidates.some((candidate) => fs.existsSync(candidate))) process.exit(${NODE_PTY_BINARY_MISSING_EXIT_CODE}); |
| 527 | nodePtyVersion: require("node-pty/package.json").version, | ||
| 528 | }; | ||
| 529 | const prebuildDir = path.join(pkgDir, "prebuilds", "linux-" + process.arch); | ||
| 530 | const marker = path.join(prebuildDir, "t3code-wsl-node-pty.json"); | ||
| 531 | const binary = path.join(prebuildDir, "pty.node"); | ||
| 532 | if (!fs.existsSync(marker) || !fs.existsSync(binary)) process.exit(${NODE_PTY_PREBUILD_MISSING_EXIT_CODE}); | ||
| 533 | require("node-pty"); | 544 | require("node-pty"); |
| 534 | const actual = JSON.parse(fs.readFileSync(marker, "utf8")); | ||
| 535 | for (const key of Object.keys(expected)) { | ||
| 536 | if (actual[key] !== expected[key]) process.exit(2); | ||
| 537 | } | ||
| 538 | NODE`; | 545 | NODE`; |
| 539 | 546 | ||
| 547 | // Readiness proof for a staged self-contained runtime: the executable runs and | ||
| 548 | // reports its version, and the login shell's PATH is captured for the launch. | ||
| 549 | // This runs under plain `sh` (no Node resolver preamble, since the runtime | ||
| 550 | // needs no Node), so the login shell is entered explicitly for the PATH | ||
| 551 | // capture; a distro without bash falls back to the PATH sh was started with. | ||
| 552 | const RUNTIME_PROBE_SCRIPT = (linuxAppRoot: string) => | ||
| 553 | [ | ||
| 554 | `bash -lc ${shellQuote(RESOLVED_PATH_LINE)} 2>/dev/null || ${RESOLVED_PATH_LINE}`, | ||
| 555 | `${shellQuote(`${linuxAppRoot}/t3`)} --version >/dev/null 2>&1`, | ||
| 556 | ].join("\n"); | ||
| 557 | |||
| 540 | const TOOLCHAIN_CHECK_SCRIPT = [ | 558 | const TOOLCHAIN_CHECK_SCRIPT = [ |
| 541 | "for tool in node make g++ python3; do", | 559 | "for tool in node make g++ python3; do", |
| 542 | ' command -v "$tool" >/dev/null 2>&1 || echo "missing:$tool"', | 560 | ' command -v "$tool" >/dev/null 2>&1 || echo "missing:$tool"', |
| ... | @@ -552,15 +570,8 @@ const NODE_PTY_BUILD_SCRIPT = (linuxServerDir: string) => | ... | @@ -552,15 +570,8 @@ const NODE_PTY_BUILD_SCRIPT = (linuxServerDir: string) => |
| 552 | "set -e", | 570 | "set -e", |
| 553 | `cd ${shellQuote(linuxServerDir)}`, | 571 | `cd ${shellQuote(linuxServerDir)}`, |
| 554 | `pkg_dir=$(node -p "require('node:path').dirname(require.resolve('node-pty/package.json'))")`, | 572 | `pkg_dir=$(node -p "require('node:path').dirname(require.resolve('node-pty/package.json'))")`, |
| 555 | `arch=$(node -p "process.arch")`, | ||
| 556 | `modules=$(node -p "process.versions.modules")`, | ||
| 557 | `node_pty_version=$(node -p "require('node-pty/package.json').version")`, | ||
| 558 | `cd "$pkg_dir"`, | 573 | `cd "$pkg_dir"`, |
| 559 | "npx --yes node-gyp rebuild", | 574 | "npx --yes node-gyp rebuild", |
| 560 | `prebuild_dir="prebuilds/linux-$arch"`, | ||
| 561 | `mkdir -p "$prebuild_dir"`, | ||
| 562 | `cp build/Release/pty.node "$prebuild_dir/pty.node"`, | ||
| 563 | `printf '{"arch":"%s","modules":"%s","nodePtyVersion":"%s"}\\n' "$arch" "$modules" "$node_pty_version" > "$prebuild_dir/t3code-wsl-node-pty.json"`, | ||
| 564 | `node -e 'require("node-pty")'`, | 575 | `node -e 'require("node-pty")'`, |
| 565 | ].join("\n"); | 576 | ].join("\n"); |
| 566 | 577 | ||
| ... | @@ -660,6 +671,38 @@ export const formatMissingToolsReason = ( | ... | @@ -660,6 +671,38 @@ export const formatMissingToolsReason = ( |
| 660 | return `WSL distro is missing required tools: ${issues.join(", ")}. Install ${remediations.join(" and ")}, then retry.`; | 671 | return `WSL distro is missing required tools: ${issues.join(", ")}. Install ${remediations.join(" and ")}, then retry.`; |
| 661 | }; | 672 | }; |
| 662 | 673 | ||
| 674 | const probeWslRuntimeImpl = ( | ||
| 675 | distro: string | null, | ||
| 676 | linuxAppRoot: string, | ||
| 677 | ): Effect.Effect<ProbeWslRuntimeResult, never, ChildProcessSpawner.ChildProcessSpawner> => | ||
| 678 | Effect.gen(function* () { | ||
| 679 | const probe = yield* runWslShell(distro, RUNTIME_PROBE_SCRIPT(linuxAppRoot), PROBE_TIMEOUT, { | ||
| 680 | resolveNode: false, | ||
| 681 | }); | ||
| 682 | const transportFailureReason = formatWslShellTransportFailureReason( | ||
| 683 | probe.transportFailure, | ||
| 684 | "the staged runtime", | ||
| 685 | ); | ||
| 686 | if (transportFailureReason !== null) { | ||
| 687 | return { ok: false, reason: transportFailureReason } as const; | ||
| 688 | } | ||
| 689 | if (probe.exitCode !== 0) { | ||
| 690 | const trimmedTail = probe.stderr.trim().slice(-500); | ||
| 691 | return { | ||
| 692 | ok: false, | ||
| 693 | reason: `${linuxAppRoot}/t3 --version failed (exit ${probe.exitCode})${trimmedTail ? `: ${trimmedTail}` : ""}`, | ||
| 694 | } as const; | ||
| 695 | } | ||
| 696 | const resolvedPath = parseResolvedPath(probe.stdout); | ||
| 697 | if (resolvedPath === null) { | ||
| 698 | return { | ||
| 699 | ok: false, | ||
| 700 | reason: "WSL login-shell PATH could not be resolved during backend preflight.", | ||
| 701 | } as const; | ||
| 702 | } | ||
| 703 | return { ok: true, resolvedPath } as const; | ||
| 704 | }); | ||
| 705 | |||
| 663 | const ensureNodePtyImpl = ( | 706 | const ensureNodePtyImpl = ( |
| 664 | distro: string | null, | 707 | distro: string | null, |
| 665 | linuxRepoRoot: string, | 708 | linuxRepoRoot: string, |
| ... | @@ -1133,6 +1176,9 @@ export interface DesktopWslEnvironmentTestStub { | ... | @@ -1133,6 +1176,9 @@ export interface DesktopWslEnvironmentTestStub { |
| 1133 | ) => PrepareWslRuntimeResult; | 1176 | ) => PrepareWslRuntimeResult; |
| 1134 | readonly pruneRuntimes?: (distro: string | null, runtimeId: string) => Effect.Effect<void>; | 1177 | readonly pruneRuntimes?: (distro: string | null, runtimeId: string) => Effect.Effect<void>; |
| 1135 | readonly invalidateRuntime?: (distro: string | null, runtimeId: string) => Effect.Effect<void>; | 1178 | readonly invalidateRuntime?: (distro: string | null, runtimeId: string) => Effect.Effect<void>; |
| 1179 | // Defaults to success with a plain PATH: a staged runtime that was prepared | ||
| 1180 | // is assumed to run unless the test says otherwise. | ||
| 1181 | readonly probeRuntime?: (distro: string | null, linuxAppRoot: string) => ProbeWslRuntimeResult; | ||
| 1136 | readonly ensureNodePty?: ( | 1182 | readonly ensureNodePty?: ( |
| 1137 | distro: string | null, | 1183 | distro: string | null, |
| 1138 | linuxAppRoot: string, | 1184 | linuxAppRoot: string, |
| ... | @@ -1165,6 +1211,10 @@ export const layerTest = (stub: DesktopWslEnvironmentTestStub = {}) => { | ... | @@ -1165,6 +1211,10 @@ export const layerTest = (stub: DesktopWslEnvironmentTestStub = {}) => { |
| 1165 | pruneRuntimes: (distro, runtimeId) => stub.pruneRuntimes?.(distro, runtimeId) ?? Effect.void, | 1211 | pruneRuntimes: (distro, runtimeId) => stub.pruneRuntimes?.(distro, runtimeId) ?? Effect.void, |
| 1166 | invalidateRuntime: (distro, runtimeId) => | 1212 | invalidateRuntime: (distro, runtimeId) => |
| 1167 | stub.invalidateRuntime?.(distro, runtimeId) ?? Effect.void, | 1213 | stub.invalidateRuntime?.(distro, runtimeId) ?? Effect.void, |
| 1214 | probeRuntime: (distro, linuxAppRoot) => | ||
| 1215 | Effect.succeed( | ||
| 1216 | stub.probeRuntime?.(distro, linuxAppRoot) ?? { ok: true, resolvedPath: "/usr/bin:/bin" }, | ||
| 1217 | ), | ||
| 1168 | ensureNodePty: (distro, linuxAppRoot, options) => | 1218 | ensureNodePty: (distro, linuxAppRoot, options) => |
| 1169 | Effect.succeed( | 1219 | Effect.succeed( |
| 1170 | stub.ensureNodePty?.(distro, linuxAppRoot, options) ?? { | 1220 | stub.ensureNodePty?.(distro, linuxAppRoot, options) ?? { |
| ... | @@ -1259,6 +1309,10 @@ export const layer = Layer.effect( | ... | @@ -1259,6 +1309,10 @@ export const layer = Layer.effect( |
| 1259 | provideSpawner(invalidateWslRuntimeImpl(distro, runtimeId)).pipe( | 1309 | provideSpawner(invalidateWslRuntimeImpl(distro, runtimeId)).pipe( |
| 1260 | Effect.withSpan("desktop.wsl.invalidateRuntime"), | 1310 | Effect.withSpan("desktop.wsl.invalidateRuntime"), |
| 1261 | ), | 1311 | ), |
| 1312 | probeRuntime: (distro, linuxAppRoot) => | ||
| 1313 | provideSpawner(probeWslRuntimeImpl(distro, linuxAppRoot)).pipe( | ||
| 1314 | Effect.withSpan("desktop.wsl.probeRuntime"), | ||
| 1315 | ), | ||
| 1262 | ensureNodePty: (distro, linuxAppRoot, options) => | 1316 | ensureNodePty: (distro, linuxAppRoot, options) => |
| 1263 | provideSpawner(ensureNodePtyImpl(distro, linuxAppRoot, options)).pipe( | 1317 | provideSpawner(ensureNodePtyImpl(distro, linuxAppRoot, options)).pipe( |
| 1264 | Effect.withSpan("desktop.wsl.ensureNodePty"), | 1318 | Effect.withSpan("desktop.wsl.ensureNodePty"), |
docs/operations/development.md+3-2| ... | @@ -191,8 +191,9 @@ rustup target add x86_64-pc-windows-msvc | ... | @@ -191,8 +191,9 @@ rustup target add x86_64-pc-windows-msvc |
| 191 | rustup target add aarch64-pc-windows-msvc | 191 | rustup target add aarch64-pc-windows-msvc |
| 192 | ``` | 192 | ``` |
| 193 | 193 | ||
| 194 | NSIS is downloaded by electron-builder. WSL support additionally needs a Linux node-pty prebuild; | 194 | NSIS is downloaded by electron-builder. WSL support additionally needs the Linux CLI archive |
| 195 | see the [release runbook](./release.md#windows-payload-topology-and-update-validation). | 195 | passed as `--wsl-runtime`; see the |
| 196 | [release runbook](./release.md#windows-payload-topology-and-update-validation). | ||
| 196 | 197 | ||
| 197 | ### Signing and passkeys | 198 | ### Signing and passkeys |
| 198 | 199 |
scripts/build-desktop-artifact.test.ts+216-324| ... | @@ -17,7 +17,6 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | ... | @@ -17,7 +17,6 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 17 | import { | 17 | import { |
| 18 | BundleNotSelfContainedError, | 18 | BundleNotSelfContainedError, |
| 19 | BuildCommandFailedError, | 19 | BuildCommandFailedError, |
| 20 | buildWslRuntimeArchiveArgs, | ||
| 21 | parseWslRuntimeArchiveMembers, | 20 | parseWslRuntimeArchiveMembers, |
| 22 | DesktopDmgBackgroundSourceMissingError, | 21 | DesktopDmgBackgroundSourceMissingError, |
| 23 | createStageWorkspaceConfig, | 22 | createStageWorkspaceConfig, |
| ... | @@ -88,32 +87,48 @@ import { | ... | @@ -88,32 +87,48 @@ import { |
| 88 | WSL_RUNTIME_ARCHIVE_HASH_NAME, | 87 | WSL_RUNTIME_ARCHIVE_HASH_NAME, |
| 89 | WSL_RUNTIME_ARCHIVE_NAME, | 88 | WSL_RUNTIME_ARCHIVE_NAME, |
| 90 | WSL_RUNTIME_EXTRA_RESOURCES, | 89 | WSL_RUNTIME_EXTRA_RESOURCES, |
| 91 | wslRuntimeArchiveTarTarget, | 90 | WslRuntimeArchiveMissingError, |
| 91 | wslRuntimeArchiveStem, | ||
| 92 | } from "./build-desktop-artifact.ts"; | 92 | } from "./build-desktop-artifact.ts"; |
| 93 | import { BRAND_ASSET_PATHS } from "./lib/brand-assets.ts"; | 93 | import { BRAND_ASSET_PATHS } from "./lib/brand-assets.ts"; |
| 94 | import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; | 94 | import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; |
| 95 | import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; | 95 | import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; |
| 96 | 96 | ||
| 97 | // A minimal stand-in for the staged sidecar roots packed into the WSL archive. | 97 | // A minimal stand-in for the Linux CLI release archive: one top-level |
| 98 | const stageWslRuntimeTreeFixture = Effect.fn("stageWslRuntimeTreeFixture")(function* ( | 98 | // directory named after the archive stem holding the executable, the web |
| 99 | root: string, | 99 | // client, and the runtime externals with node-pty built from source. |
| 100 | serverSource: string, | 100 | const makeLinuxCliArchiveFixture = Effect.fn("test.makeLinuxCliArchiveFixture")(function* (input: { |
| 101 | ) { | 101 | readonly root: string; |
| 102 | readonly stem: string; | ||
| 103 | readonly extraMembers?: ReadonlyArray<string>; | ||
| 104 | readonly omitMembers?: ReadonlyArray<string>; | ||
| 105 | }) { | ||
| 102 | const fs = yield* FileSystem.FileSystem; | 106 | const fs = yield* FileSystem.FileSystem; |
| 103 | const path = yield* Path.Path; | 107 | const path = yield* Path.Path; |
| 104 | yield* fs.makeDirectory(path.join(root, "apps/server/dist"), { recursive: true }); | 108 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 105 | yield* fs.writeFileString(path.join(root, "apps/server/dist/bin.mjs"), serverSource); | 109 | const contentRoot = path.join(input.root, "content"); |
| 106 | yield* fs.makeDirectory(path.join(root, "node_modules/node-pty/prebuilds/linux-x64"), { | 110 | const members = [ |
| 107 | recursive: true, | 111 | `${input.stem}/t3`, |
| 108 | }); | 112 | `${input.stem}/client/index.html`, |
| 109 | yield* fs.writeFileString( | 113 | `${input.stem}/node_modules/node-pty/package.json`, |
| 110 | path.join(root, "node_modules/node-pty/package.json"), | 114 | `${input.stem}/node_modules/node-pty/build/Release/pty.node`, |
| 111 | '{"name":"node-pty"}\n', | 115 | ...(input.extraMembers ?? []), |
| 112 | ); | 116 | ].filter((member) => !(input.omitMembers ?? []).includes(member)); |
| 113 | yield* fs.writeFileString( | 117 | for (const member of members) { |
| 114 | path.join(root, "node_modules/node-pty/prebuilds/linux-x64/pty.node"), | 118 | const memberPath = path.join(contentRoot, member); |
| 115 | "pty", | 119 | yield* fs.makeDirectory(path.dirname(memberPath), { recursive: true }); |
| 120 | yield* fs.writeFileString(memberPath, member); | ||
| 121 | } | ||
| 122 | const archivePath = path.join(input.root, `${input.stem}.tar.gz`); | ||
| 123 | const tar = yield* spawner.spawn( | ||
| 124 | ChildProcess.make("tar", ["-czf", archivePath, "-C", contentRoot, "."], { | ||
| 125 | stdin: "ignore", | ||
| 126 | stdout: "ignore", | ||
| 127 | stderr: "pipe", | ||
| 128 | }), | ||
| 116 | ); | 129 | ); |
| 130 | assert.equal(Number(yield* tar.exitCode), 0); | ||
| 131 | return archivePath; | ||
| 117 | }); | 132 | }); |
| 118 | 133 | ||
| 119 | function mockProcess(exitCode: number, stdout = "") { | 134 | function mockProcess(exitCode: number, stdout = "") { |
| ... | @@ -154,10 +169,12 @@ function iconResizeSpawnerLayer( | ... | @@ -154,10 +169,12 @@ function iconResizeSpawnerLayer( |
| 154 | ); | 169 | ); |
| 155 | } | 170 | } |
| 156 | 171 | ||
| 172 | const WINDOWS_PAYLOAD_FIXTURE_VERSION = "1.2.3"; | ||
| 173 | |||
| 157 | const makeWindowsPayloadFixture = Effect.fn("test.makeWindowsPayloadFixture")(function* (input: { | 174 | const makeWindowsPayloadFixture = Effect.fn("test.makeWindowsPayloadFixture")(function* (input: { |
| 158 | readonly copyUnpackedNatives: boolean; | 175 | readonly copyUnpackedNatives: boolean; |
| 159 | readonly serverEntrySource?: string; | 176 | readonly serverEntrySource?: string; |
| 160 | readonly wslRuntime?: "valid" | "forbidden" | "bad-digest"; | 177 | readonly wslRuntime?: "valid" | "loose-server-tree" | "missing-pty" | "bad-digest"; |
| 161 | }) { | 178 | }) { |
| 162 | const fs = yield* FileSystem.FileSystem; | 179 | const fs = yield* FileSystem.FileSystem; |
| 163 | const path = yield* Path.Path; | 180 | const path = yield* Path.Path; |
| ... | @@ -195,58 +212,30 @@ const makeWindowsPayloadFixture = Effect.fn("test.makeWindowsPayloadFixture")(fu | ... | @@ -195,58 +212,30 @@ const makeWindowsPayloadFixture = Effect.fn("test.makeWindowsPayloadFixture")(fu |
| 195 | yield* fs.writeFileString(path.join(packagedAppDir, "chrome_crashpad_handler.exe"), "crashpad"); | 212 | yield* fs.writeFileString(path.join(packagedAppDir, "chrome_crashpad_handler.exe"), "crashpad"); |
| 196 | 213 | ||
| 197 | if (input.wslRuntime !== undefined) { | 214 | if (input.wslRuntime !== undefined) { |
| 198 | const wslSourceDir = path.join(tempDir, "wsl-source"); | 215 | const stem = wslRuntimeArchiveStem(WINDOWS_PAYLOAD_FIXTURE_VERSION, "x64"); |
| 199 | const linuxPrebuildDir = path.join(wslSourceDir, "node_modules/node-pty/prebuilds/linux-x64"); | 216 | const sourceArchivePath = |
| 200 | yield* fs.makeDirectory(path.join(wslSourceDir, "apps/server/dist"), { recursive: true }); | 217 | input.wslRuntime === "loose-server-tree" |
| 201 | yield* fs.makeDirectory(linuxPrebuildDir, { recursive: true }); | 218 | ? // The old hand-rolled runtime: apps/server/dist + node_modules at the |
| 202 | yield* fs.writeFileString( | 219 | // archive root, no single stem directory, no `t3` executable. |
| 203 | path.join(wslSourceDir, "apps/server/dist/bin.mjs"), | 220 | yield* makeLinuxCliArchiveFixture({ |
| 204 | "console.log('wsl server');\n", | 221 | root: path.join(tempDir, "wsl-runtime"), |
| 205 | ); | 222 | stem: "apps", |
| 206 | yield* fs.writeFileString( | 223 | omitMembers: ["apps/t3", "apps/client/index.html"], |
| 207 | path.join(wslSourceDir, "node_modules/node-pty/package.json"), | 224 | extraMembers: ["apps/server/dist/bin.mjs", "node_modules/node-pty/package.json"], |
| 208 | '{"name":"node-pty"}', | 225 | }) |
| 209 | ); | 226 | : yield* makeLinuxCliArchiveFixture({ |
| 210 | yield* fs.writeFileString(path.join(linuxPrebuildDir, "pty.node"), "linux-pty"); | 227 | root: path.join(tempDir, "wsl-runtime"), |
| 211 | yield* fs.writeFileString( | 228 | stem, |
| 212 | path.join(linuxPrebuildDir, "t3code-wsl-node-pty.json"), | 229 | ...(input.wslRuntime === "missing-pty" |
| 213 | '{"arch":"x64"}', | 230 | ? { omitMembers: [`${stem}/node_modules/node-pty/build/Release/pty.node`] } |
| 214 | ); | 231 | : {}), |
| 215 | if (input.wslRuntime === "forbidden") { | 232 | }); |
| 216 | const windowsPrebuildDir = path.join( | ||
| 217 | wslSourceDir, | ||
| 218 | "node_modules/node-pty/prebuilds/win32-x64", | ||
| 219 | ); | ||
| 220 | yield* fs.makeDirectory(windowsPrebuildDir, { recursive: true }); | ||
| 221 | yield* fs.writeFileString(path.join(windowsPrebuildDir, "pty.node"), "windows-pty"); | ||
| 222 | } | ||
| 223 | |||
| 224 | const archivePath = path.join(resourcesDir, WSL_RUNTIME_ARCHIVE_NAME); | 233 | const archivePath = path.join(resourcesDir, WSL_RUNTIME_ARCHIVE_NAME); |
| 225 | const hashPath = path.join(resourcesDir, WSL_RUNTIME_ARCHIVE_HASH_NAME); | 234 | const hashPath = path.join(resourcesDir, WSL_RUNTIME_ARCHIVE_HASH_NAME); |
| 226 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | 235 | yield* stageWslRuntimeArchive({ sourceArchivePath, archivePath, hashPath }); |
| 227 | const tar = yield* spawner.spawn( | 236 | if (input.wslRuntime === "bad-digest") { |
| 228 | ChildProcess.make( | 237 | yield* fs.writeFileString(hashPath, `${"0".repeat(64)}\n`); |
| 229 | "tar", | 238 | } |
| 230 | [ | ||
| 231 | "-czf", | ||
| 232 | wslRuntimeArchiveTarTarget(path.relative(wslSourceDir, archivePath)), | ||
| 233 | "apps/server/dist", | ||
| 234 | "node_modules", | ||
| 235 | ], | ||
| 236 | { cwd: wslSourceDir, stdin: "ignore", stdout: "ignore", stderr: "pipe" }, | ||
| 237 | ), | ||
| 238 | ); | ||
| 239 | assert.equal(Number(yield* tar.exitCode), 0); | ||
| 240 | const archiveDigest = NodeCrypto.createHash("sha256"); | ||
| 241 | yield* fs | ||
| 242 | .stream(archivePath) | ||
| 243 | .pipe(Stream.runForEach((chunk) => Effect.sync(() => archiveDigest.update(chunk)))); | ||
| 244 | yield* fs.writeFileString( | ||
| 245 | hashPath, | ||
| 246 | input.wslRuntime === "bad-digest" | ||
| 247 | ? `${"0".repeat(64)}\n` | ||
| 248 | : `${archiveDigest.digest("hex")}\n`, | ||
| 249 | ); | ||
| 250 | } | 239 | } |
| 251 | 240 | ||
| 252 | return { | 241 | return { |
| ... | @@ -463,40 +452,20 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { | ... | @@ -463,40 +452,20 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { |
| 463 | libc: ["glibc"], | 452 | libc: ["glibc"], |
| 464 | }, | 453 | }, |
| 465 | }); | 454 | }); |
| 466 | // The Windows app stage only serves the desktop main process; the server | 455 | // Windows stages only win32 natives; WSL runs the separately built Linux |
| 467 | // sidecar stage is the one that needs Linux natives (below). | 456 | // CLI archive rather than anything installed here. |
| 468 | assert.deepStrictEqual(createStageWorkspaceConfig({ platform: "win", arch: "x64" }), { | 457 | assert.deepStrictEqual(createStageWorkspaceConfig({ platform: "win", arch: "x64" }), { |
| 469 | supportedArchitectures: { | 458 | supportedArchitectures: { |
| 470 | os: ["win32"], | 459 | os: ["win32"], |
| 471 | cpu: ["x64"], | 460 | cpu: ["x64"], |
| 472 | }, | 461 | }, |
| 473 | }); | 462 | }); |
| 474 | // The server sidecar stage bundles the same-architecture WSL (Linux, | 463 | assert.deepStrictEqual(createStageWorkspaceConfig({ platform: "win", arch: "arm64" }), { |
| 475 | // glibc) backend, so its install must fetch Linux native optional deps | 464 | supportedArchitectures: { |
| 476 | // (e.g. ffi-rs) too — and must be hoisted so the tree survives asar | 465 | os: ["win32"], |
| 477 | // packing and runtime extraction without symlinks. | 466 | cpu: ["arm64"], |
| 478 | assert.deepStrictEqual( | ||
| 479 | createStageWorkspaceConfig({ platform: "win", arch: "x64", linuxServerBackend: true }), | ||
| 480 | { | ||
| 481 | supportedArchitectures: { | ||
| 482 | os: ["win32", "linux"], | ||
| 483 | cpu: ["x64"], | ||
| 484 | libc: ["glibc"], | ||
| 485 | }, | ||
| 486 | nodeLinker: "hoisted", | ||
| 487 | }, | ||
| 488 | ); | ||
| 489 | assert.deepStrictEqual( | ||
| 490 | createStageWorkspaceConfig({ platform: "win", arch: "arm64", linuxServerBackend: true }), | ||
| 491 | { | ||
| 492 | supportedArchitectures: { | ||
| 493 | os: ["win32", "linux"], | ||
| 494 | cpu: ["arm64"], | ||
| 495 | libc: ["glibc"], | ||
| 496 | }, | ||
| 497 | nodeLinker: "hoisted", | ||
| 498 | }, | 467 | }, |
| 499 | ); | 468 | }); |
| 500 | assert.deepStrictEqual(createStageWorkspaceConfig({ platform: "mac", arch: "universal" }), { | 469 | assert.deepStrictEqual(createStageWorkspaceConfig({ platform: "mac", arch: "universal" }), { |
| 501 | supportedArchitectures: { | 470 | supportedArchitectures: { |
| 502 | os: ["darwin"], | 471 | os: ["darwin"], |
| ... | @@ -635,7 +604,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { | ... | @@ -635,7 +604,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { |
| 635 | undefined, | 604 | undefined, |
| 636 | true, | 605 | true, |
| 637 | ); | 606 | ); |
| 638 | const winWithoutWslPrebuild = yield* createBuildConfig( | 607 | const winWithoutWslRuntime = yield* createBuildConfig( |
| 639 | "win", | 608 | "win", |
| 640 | "nsis", | 609 | "nsis", |
| 641 | "1.2.3", | 610 | "1.2.3", |
| ... | @@ -654,8 +623,8 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { | ... | @@ -654,8 +623,8 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { |
| 654 | assert.notProperty(linux, "asarUnpack"); | 623 | assert.notProperty(linux, "asarUnpack"); |
| 655 | assert.deepStrictEqual(win.asar, { smartUnpack: false }); | 624 | assert.deepStrictEqual(win.asar, { smartUnpack: false }); |
| 656 | assert.deepStrictEqual(win.asarUnpack, [WINDOWS_NATIVE_ASAR_UNPACK_GLOB]); | 625 | assert.deepStrictEqual(win.asarUnpack, [WINDOWS_NATIVE_ASAR_UNPACK_GLOB]); |
| 657 | assert.deepStrictEqual(winWithoutWslPrebuild.asar, win.asar); | 626 | assert.deepStrictEqual(winWithoutWslRuntime.asar, win.asar); |
| 658 | assert.deepStrictEqual(winWithoutWslPrebuild.asarUnpack, win.asarUnpack); | 627 | assert.deepStrictEqual(winWithoutWslRuntime.asarUnpack, win.asarUnpack); |
| 659 | assert.deepStrictEqual(mac.extraResources, DESKTOP_EXTRA_RESOURCES); | 628 | assert.deepStrictEqual(mac.extraResources, DESKTOP_EXTRA_RESOURCES); |
| 660 | assert.deepStrictEqual(linux.extraResources, [ | 629 | assert.deepStrictEqual(linux.extraResources, [ |
| 661 | ...DESKTOP_EXTRA_RESOURCES, | 630 | ...DESKTOP_EXTRA_RESOURCES, |
| ... | @@ -670,9 +639,9 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { | ... | @@ -670,9 +639,9 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { |
| 670 | ...WINDOWS_SERVER_EXTRA_RESOURCES, | 639 | ...WINDOWS_SERVER_EXTRA_RESOURCES, |
| 671 | ...WSL_RUNTIME_EXTRA_RESOURCES, | 640 | ...WSL_RUNTIME_EXTRA_RESOURCES, |
| 672 | ]); | 641 | ]); |
| 673 | // No Linux prebuild means the sidecar staging never writes the archive, | 642 | // No Linux CLI archive means staging never writes the runtime, so |
| 674 | // so listing it here would fail the build on a missing source file. | 643 | // listing it here would fail the build on a missing source file. |
| 675 | assert.deepStrictEqual(winWithoutWslPrebuild.extraResources, [ | 644 | assert.deepStrictEqual(winWithoutWslRuntime.extraResources, [ |
| 676 | { | 645 | { |
| 677 | from: "apps/desktop/prod-resources/resource-monitor", | 646 | from: "apps/desktop/prod-resources/resource-monitor", |
| 678 | to: "resource-monitor", | 647 | to: "resource-monitor", |
| ... | @@ -707,7 +676,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { | ... | @@ -707,7 +676,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { |
| 707 | assert.deepStrictEqual(mac.files, [...DESKTOP_FILE_EXCLUSIONS, ...MAC_FILE_EXCLUSIONS]); | 676 | assert.deepStrictEqual(mac.files, [...DESKTOP_FILE_EXCLUSIONS, ...MAC_FILE_EXCLUSIONS]); |
| 708 | assert.deepStrictEqual(linux.files, [...DESKTOP_FILE_EXCLUSIONS, ...LINUX_FILE_EXCLUSIONS]); | 677 | assert.deepStrictEqual(linux.files, [...DESKTOP_FILE_EXCLUSIONS, ...LINUX_FILE_EXCLUSIONS]); |
| 709 | assert.deepStrictEqual(win.files, DESKTOP_FILE_EXCLUSIONS); | 678 | assert.deepStrictEqual(win.files, DESKTOP_FILE_EXCLUSIONS); |
| 710 | assert.deepStrictEqual(winWithoutWslPrebuild.files, win.files); | 679 | assert.deepStrictEqual(winWithoutWslRuntime.files, win.files); |
| 711 | assert.notProperty(mac.mac as Record<string, unknown>, "sign"); | 680 | assert.notProperty(mac.mac as Record<string, unknown>, "sign"); |
| 712 | for (const config of [linux, win]) { | 681 | for (const config of [linux, win]) { |
| 713 | assert.deepStrictEqual(config.electronLanguages, DESKTOP_ELECTRON_LANGUAGES); | 682 | assert.deepStrictEqual(config.electronLanguages, DESKTOP_ELECTRON_LANGUAGES); |
| ... | @@ -819,60 +788,50 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { | ... | @@ -819,60 +788,50 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { |
| 819 | ]); | 788 | ]); |
| 820 | }); | 789 | }); |
| 821 | 790 | ||
| 822 | it.effect( | 791 | it.effect("keeps target native files while excluding the other Windows architecture", () => |
| 823 | "keeps target and WSL native files while excluding the other Windows architecture", | 792 | Effect.scoped( |
| 824 | () => | 793 | Effect.gen(function* () { |
| 825 | Effect.scoped( | 794 | const fs = yield* FileSystem.FileSystem; |
| 826 | Effect.gen(function* () { | 795 | const path = yield* Path.Path; |
| 827 | const fs = yield* FileSystem.FileSystem; | 796 | const tempDir = yield* fs.makeTempDirectoryScoped({ |
| 828 | const path = yield* Path.Path; | 797 | prefix: "t3-windows-architecture-test-", |
| 829 | const tempDir = yield* fs.makeTempDirectoryScoped({ | 798 | }); |
| 830 | prefix: "t3-windows-architecture-test-", | 799 | const sourceDir = path.join(tempDir, "server"); |
| 831 | }); | 800 | const nativeFiles = [ |
| 832 | const sourceDir = path.join(tempDir, "server"); | 801 | "node_modules/node-pty/prebuilds/win32-x64/conpty/OpenConsole.exe", |
| 833 | const nativeFiles = [ | 802 | "node_modules/node-pty/prebuilds/win32-arm64/conpty/OpenConsole.exe", |
| 834 | "node_modules/node-pty/prebuilds/win32-x64/conpty/OpenConsole.exe", | 803 | "node_modules/node-pty/third_party/conpty/1.0.0/win10-x64/OpenConsole.exe", |
| 835 | "node_modules/node-pty/prebuilds/win32-arm64/conpty/OpenConsole.exe", | 804 | "node_modules/node-pty/third_party/conpty/1.0.0/win10-arm64/OpenConsole.exe", |
| 836 | "node_modules/node-pty/prebuilds/linux-x64/pty.node", | 805 | ]; |
| 837 | "node_modules/node-pty/third_party/conpty/1.0.0/win10-x64/OpenConsole.exe", | 806 | |
| 838 | "node_modules/node-pty/third_party/conpty/1.0.0/win10-arm64/OpenConsole.exe", | 807 | for (const nativeFile of nativeFiles) { |
| 839 | ]; | 808 | const nativePath = path.join(sourceDir, nativeFile); |
| 840 | 809 | yield* fs.makeDirectory(path.dirname(nativePath), { recursive: true }); | |
| 841 | for (const nativeFile of nativeFiles) { | 810 | yield* fs.writeFileString(nativePath, "native"); |
| 842 | const nativePath = path.join(sourceDir, nativeFile); | 811 | } |
| 843 | yield* fs.makeDirectory(path.dirname(nativePath), { recursive: true }); | ||
| 844 | yield* fs.writeFileString(nativePath, "native"); | ||
| 845 | } | ||
| 846 | 812 | ||
| 847 | const asarPath = path.join(tempDir, "server.asar"); | 813 | const asarPath = path.join(tempDir, "server.asar"); |
| 848 | yield* packWindowsServerAsar({ sourceDir, asarPath, arch: "x64" }); | 814 | yield* packWindowsServerAsar({ sourceDir, asarPath, arch: "x64" }); |
| 849 | const unpackedRoot = `${asarPath}.unpacked`; | 815 | const unpackedRoot = `${asarPath}.unpacked`; |
| 850 | 816 | ||
| 851 | assert.isTrue( | 817 | assert.isTrue( |
| 852 | yield* fs.exists( | 818 | yield* fs.exists( |
| 853 | path.join( | 819 | path.join( |
| 854 | unpackedRoot, | 820 | unpackedRoot, |
| 855 | "node_modules/node-pty/prebuilds/win32-x64/conpty/OpenConsole.exe", | 821 | "node_modules/node-pty/prebuilds/win32-x64/conpty/OpenConsole.exe", |
| 856 | ), | ||
| 857 | ), | ||
| 858 | ); | ||
| 859 | assert.isTrue( | ||
| 860 | yield* fs.exists( | ||
| 861 | path.join(unpackedRoot, "node_modules/node-pty/prebuilds/linux-x64/pty.node"), | ||
| 862 | ), | ||
| 863 | ); | ||
| 864 | assert.isFalse( | ||
| 865 | yield* fs.exists( | ||
| 866 | path.join(unpackedRoot, "node_modules/node-pty/prebuilds/win32-arm64"), | ||
| 867 | ), | ||
| 868 | ); | ||
| 869 | assert.isFalse( | ||
| 870 | yield* fs.exists( | ||
| 871 | path.join(unpackedRoot, "node_modules/node-pty/third_party/conpty/1.0.0/win10-arm64"), | ||
| 872 | ), | 822 | ), |
| 873 | ); | 823 | ), |
| 874 | }), | 824 | ); |
| 875 | ), | 825 | assert.isFalse( |
| 826 | yield* fs.exists(path.join(unpackedRoot, "node_modules/node-pty/prebuilds/win32-arm64")), | ||
| 827 | ); | ||
| 828 | assert.isFalse( | ||
| 829 | yield* fs.exists( | ||
| 830 | path.join(unpackedRoot, "node_modules/node-pty/third_party/conpty/1.0.0/win10-arm64"), | ||
| 831 | ), | ||
| 832 | ); | ||
| 833 | }), | ||
| 834 | ), | ||
| 876 | ); | 835 | ); |
| 877 | 836 | ||
| 878 | it.effect("stages a cached resource monitor without invoking Cargo", () => | 837 | it.effect("stages a cached resource monitor without invoking Cargo", () => |
| ... | @@ -1205,6 +1164,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { | ... | @@ -1205,6 +1164,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { |
| 1205 | stageDistDir: fixture.stageDistDir, | 1164 | stageDistDir: fixture.stageDistDir, |
| 1206 | appExecutableName: fixture.appExecutableName, | 1165 | appExecutableName: fixture.appExecutableName, |
| 1207 | targetArch: "x64", | 1166 | targetArch: "x64", |
| 1167 | appVersion: WINDOWS_PAYLOAD_FIXTURE_VERSION, | ||
| 1208 | }); | 1168 | }); |
| 1209 | 1169 | ||
| 1210 | const secondAsarPath = path.join(path.dirname(fixture.generatedAsarPath), "second.asar"); | 1170 | const secondAsarPath = path.join(path.dirname(fixture.generatedAsarPath), "second.asar"); |
| ... | @@ -1226,7 +1186,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { | ... | @@ -1226,7 +1186,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { |
| 1226 | ).pipe(Effect.provideService(HostProcessPlatform, "linux")), | 1186 | ).pipe(Effect.provideService(HostProcessPlatform, "linux")), |
| 1227 | ); | 1187 | ); |
| 1228 | 1188 | ||
| 1229 | it.effect("validates the emitted WSL archive and its SHA-256 sidecar", () => | 1189 | it.effect("accepts an embedded Linux CLI release archive with a matching digest", () => |
| 1230 | Effect.scoped( | 1190 | Effect.scoped( |
| 1231 | Effect.gen(function* () { | 1191 | Effect.gen(function* () { |
| 1232 | const fixture = yield* makeWindowsPayloadFixture({ | 1192 | const fixture = yield* makeWindowsPayloadFixture({ |
| ... | @@ -1237,6 +1197,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { | ... | @@ -1237,6 +1197,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { |
| 1237 | stageDistDir: fixture.stageDistDir, | 1197 | stageDistDir: fixture.stageDistDir, |
| 1238 | appExecutableName: fixture.appExecutableName, | 1198 | appExecutableName: fixture.appExecutableName, |
| 1239 | targetArch: "x64", | 1199 | targetArch: "x64", |
| 1200 | appVersion: WINDOWS_PAYLOAD_FIXTURE_VERSION, | ||
| 1240 | expectWslRuntime: true, | 1201 | expectWslRuntime: true, |
| 1241 | }); | 1202 | }); |
| 1242 | 1203 | ||
| ... | @@ -1245,6 +1206,27 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { | ... | @@ -1245,6 +1206,27 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { |
| 1245 | ).pipe(Effect.provideService(HostProcessPlatform, "linux")), | 1206 | ).pipe(Effect.provideService(HostProcessPlatform, "linux")), |
| 1246 | ); | 1207 | ); |
| 1247 | 1208 | ||
| 1209 | it.effect("rejects an embedded archive built for a different release version", () => | ||
| 1210 | Effect.scoped( | ||
| 1211 | Effect.gen(function* () { | ||
| 1212 | const fixture = yield* makeWindowsPayloadFixture({ | ||
| 1213 | copyUnpackedNatives: true, | ||
| 1214 | wslRuntime: "valid", | ||
| 1215 | }); | ||
| 1216 | const error = yield* validateWindowsPackagedPayload({ | ||
| 1217 | stageDistDir: fixture.stageDistDir, | ||
| 1218 | appExecutableName: fixture.appExecutableName, | ||
| 1219 | targetArch: "x64", | ||
| 1220 | appVersion: "9.9.9", | ||
| 1221 | expectWslRuntime: true, | ||
| 1222 | }).pipe(Effect.flip); | ||
| 1223 | |||
| 1224 | assert.instanceOf(error, WindowsPackagedPayloadValidationError); | ||
| 1225 | assert.equal(error.reason, "wsl-runtime-invalid"); | ||
| 1226 | }), | ||
| 1227 | ), | ||
| 1228 | ); | ||
| 1229 | |||
| 1248 | it.effect("rejects a Windows package missing its expected WSL runtime", () => | 1230 | it.effect("rejects a Windows package missing its expected WSL runtime", () => |
| 1249 | Effect.scoped( | 1231 | Effect.scoped( |
| 1250 | Effect.gen(function* () { | 1232 | Effect.gen(function* () { |
| ... | @@ -1253,6 +1235,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { | ... | @@ -1253,6 +1235,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { |
| 1253 | stageDistDir: fixture.stageDistDir, | 1235 | stageDistDir: fixture.stageDistDir, |
| 1254 | appExecutableName: fixture.appExecutableName, | 1236 | appExecutableName: fixture.appExecutableName, |
| 1255 | targetArch: "x64", | 1237 | targetArch: "x64", |
| 1238 | appVersion: WINDOWS_PAYLOAD_FIXTURE_VERSION, | ||
| 1256 | expectWslRuntime: true, | 1239 | expectWslRuntime: true, |
| 1257 | }).pipe(Effect.flip); | 1240 | }).pipe(Effect.flip); |
| 1258 | 1241 | ||
| ... | @@ -1262,22 +1245,47 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { | ... | @@ -1262,22 +1245,47 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { |
| 1262 | ), | 1245 | ), |
| 1263 | ); | 1246 | ); |
| 1264 | 1247 | ||
| 1265 | it.effect("rejects forbidden native members in the emitted WSL archive", () => | 1248 | it.effect("rejects a loose server tree that is not a Linux CLI release archive", () => |
| 1249 | Effect.scoped( | ||
| 1250 | Effect.gen(function* () { | ||
| 1251 | const fixture = yield* makeWindowsPayloadFixture({ | ||
| 1252 | copyUnpackedNatives: true, | ||
| 1253 | wslRuntime: "loose-server-tree", | ||
| 1254 | }); | ||
| 1255 | const error = yield* validateWindowsPackagedPayload({ | ||
| 1256 | stageDistDir: fixture.stageDistDir, | ||
| 1257 | appExecutableName: fixture.appExecutableName, | ||
| 1258 | targetArch: "x64", | ||
| 1259 | appVersion: WINDOWS_PAYLOAD_FIXTURE_VERSION, | ||
| 1260 | expectWslRuntime: true, | ||
| 1261 | }).pipe(Effect.flip); | ||
| 1262 | |||
| 1263 | assert.instanceOf(error, WindowsPackagedPayloadValidationError); | ||
| 1264 | assert.equal(error.reason, "wsl-runtime-invalid"); | ||
| 1265 | }), | ||
| 1266 | ), | ||
| 1267 | ); | ||
| 1268 | |||
| 1269 | it.effect("rejects an embedded archive without the Linux node-pty binary", () => | ||
| 1266 | Effect.scoped( | 1270 | Effect.scoped( |
| 1267 | Effect.gen(function* () { | 1271 | Effect.gen(function* () { |
| 1268 | const fixture = yield* makeWindowsPayloadFixture({ | 1272 | const fixture = yield* makeWindowsPayloadFixture({ |
| 1269 | copyUnpackedNatives: true, | 1273 | copyUnpackedNatives: true, |
| 1270 | wslRuntime: "forbidden", | 1274 | wslRuntime: "missing-pty", |
| 1271 | }); | 1275 | }); |
| 1272 | const error = yield* validateWindowsPackagedPayload({ | 1276 | const error = yield* validateWindowsPackagedPayload({ |
| 1273 | stageDistDir: fixture.stageDistDir, | 1277 | stageDistDir: fixture.stageDistDir, |
| 1274 | appExecutableName: fixture.appExecutableName, | 1278 | appExecutableName: fixture.appExecutableName, |
| 1275 | targetArch: "x64", | 1279 | targetArch: "x64", |
| 1280 | appVersion: WINDOWS_PAYLOAD_FIXTURE_VERSION, | ||
| 1276 | expectWslRuntime: true, | 1281 | expectWslRuntime: true, |
| 1277 | }).pipe(Effect.flip); | 1282 | }).pipe(Effect.flip); |
| 1278 | 1283 | ||
| 1279 | assert.instanceOf(error, WindowsPackagedPayloadValidationError); | 1284 | assert.instanceOf(error, WindowsPackagedPayloadValidationError); |
| 1280 | assert.equal(error.reason, "wsl-runtime-invalid"); | 1285 | assert.equal(error.reason, "wsl-runtime-invalid"); |
| 1286 | assert.deepStrictEqual(error.missingFiles, [ | ||
| 1287 | `${wslRuntimeArchiveStem(WINDOWS_PAYLOAD_FIXTURE_VERSION, "x64")}/node_modules/node-pty/build/Release/pty.node`, | ||
| 1288 | ]); | ||
| 1281 | }), | 1289 | }), |
| 1282 | ), | 1290 | ), |
| 1283 | ); | 1291 | ); |
| ... | @@ -1293,6 +1301,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { | ... | @@ -1293,6 +1301,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { |
| 1293 | stageDistDir: fixture.stageDistDir, | 1301 | stageDistDir: fixture.stageDistDir, |
| 1294 | appExecutableName: fixture.appExecutableName, | 1302 | appExecutableName: fixture.appExecutableName, |
| 1295 | targetArch: "x64", | 1303 | targetArch: "x64", |
| 1304 | appVersion: WINDOWS_PAYLOAD_FIXTURE_VERSION, | ||
| 1296 | expectWslRuntime: true, | 1305 | expectWslRuntime: true, |
| 1297 | }).pipe(Effect.flip); | 1306 | }).pipe(Effect.flip); |
| 1298 | 1307 | ||
| ... | @@ -1327,6 +1336,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { | ... | @@ -1327,6 +1336,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { |
| 1327 | stageDistDir: fixture.stageDistDir, | 1336 | stageDistDir: fixture.stageDistDir, |
| 1328 | appExecutableName: fixture.appExecutableName, | 1337 | appExecutableName: fixture.appExecutableName, |
| 1329 | targetArch: "x64", | 1338 | targetArch: "x64", |
| 1339 | appVersion: WINDOWS_PAYLOAD_FIXTURE_VERSION, | ||
| 1330 | }); | 1340 | }); |
| 1331 | 1341 | ||
| 1332 | const primaryProbe = commands.find( | 1342 | const primaryProbe = commands.find( |
| ... | @@ -1466,6 +1476,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { | ... | @@ -1466,6 +1476,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { |
| 1466 | stageDistDir: fixture.stageDistDir, | 1476 | stageDistDir: fixture.stageDistDir, |
| 1467 | appExecutableName: fixture.appExecutableName, | 1477 | appExecutableName: fixture.appExecutableName, |
| 1468 | targetArch: "arm64", | 1478 | targetArch: "arm64", |
| 1479 | appVersion: WINDOWS_PAYLOAD_FIXTURE_VERSION, | ||
| 1469 | }); | 1480 | }); |
| 1470 | 1481 | ||
| 1471 | assert.isFalse( | 1482 | assert.isFalse( |
| ... | @@ -1502,6 +1513,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { | ... | @@ -1502,6 +1513,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { |
| 1502 | stageDistDir: fixture.stageDistDir, | 1513 | stageDistDir: fixture.stageDistDir, |
| 1503 | appExecutableName: fixture.appExecutableName, | 1514 | appExecutableName: fixture.appExecutableName, |
| 1504 | targetArch: "arm64", | 1515 | targetArch: "arm64", |
| 1516 | appVersion: WINDOWS_PAYLOAD_FIXTURE_VERSION, | ||
| 1505 | }).pipe(Effect.flip); | 1517 | }).pipe(Effect.flip); |
| 1506 | 1518 | ||
| 1507 | assert.instanceOf(error, WindowsPrimaryNativeProbeError); | 1519 | assert.instanceOf(error, WindowsPrimaryNativeProbeError); |
| ... | @@ -1525,6 +1537,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { | ... | @@ -1525,6 +1537,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { |
| 1525 | stageDistDir: fixture.stageDistDir, | 1537 | stageDistDir: fixture.stageDistDir, |
| 1526 | appExecutableName: fixture.appExecutableName, | 1538 | appExecutableName: fixture.appExecutableName, |
| 1527 | targetArch: "x64", | 1539 | targetArch: "x64", |
| 1540 | appVersion: WINDOWS_PAYLOAD_FIXTURE_VERSION, | ||
| 1528 | }).pipe(Effect.flip); | 1541 | }).pipe(Effect.flip); |
| 1529 | 1542 | ||
| 1530 | assert.instanceOf(error, WindowsPackagedPayloadValidationError); | 1543 | assert.instanceOf(error, WindowsPackagedPayloadValidationError); |
| ... | @@ -1553,6 +1566,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { | ... | @@ -1553,6 +1566,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { |
| 1553 | stageDistDir: fixture.stageDistDir, | 1566 | stageDistDir: fixture.stageDistDir, |
| 1554 | appExecutableName: fixture.appExecutableName, | 1567 | appExecutableName: fixture.appExecutableName, |
| 1555 | targetArch: "x64", | 1568 | targetArch: "x64", |
| 1569 | appVersion: WINDOWS_PAYLOAD_FIXTURE_VERSION, | ||
| 1556 | }).pipe(Effect.flip); | 1570 | }).pipe(Effect.flip); |
| 1557 | assert.instanceOf(nativeError, WindowsPackagedPayloadValidationError); | 1571 | assert.instanceOf(nativeError, WindowsPackagedPayloadValidationError); |
| 1558 | assert.equal(nativeError.reason, "unpacked-native-missing"); | 1572 | assert.equal(nativeError.reason, "unpacked-native-missing"); |
| ... | @@ -1573,6 +1587,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { | ... | @@ -1573,6 +1587,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { |
| 1573 | stageDistDir: fixture.stageDistDir, | 1587 | stageDistDir: fixture.stageDistDir, |
| 1574 | appExecutableName: fixture.appExecutableName, | 1588 | appExecutableName: fixture.appExecutableName, |
| 1575 | targetArch: "x64", | 1589 | targetArch: "x64", |
| 1590 | appVersion: WINDOWS_PAYLOAD_FIXTURE_VERSION, | ||
| 1576 | }).pipe(Effect.flip); | 1591 | }).pipe(Effect.flip); |
| 1577 | assert.instanceOf(resourceMonitorError, WindowsPackagedPayloadValidationError); | 1592 | assert.instanceOf(resourceMonitorError, WindowsPackagedPayloadValidationError); |
| 1578 | assert.equal(resourceMonitorError.reason, "resource-monitor-missing"); | 1593 | assert.equal(resourceMonitorError.reason, "resource-monitor-missing"); |
| ... | @@ -1591,6 +1606,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { | ... | @@ -1591,6 +1606,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { |
| 1591 | stageDistDir: fixture.stageDistDir, | 1606 | stageDistDir: fixture.stageDistDir, |
| 1592 | appExecutableName: fixture.appExecutableName, | 1607 | appExecutableName: fixture.appExecutableName, |
| 1593 | targetArch: "x64", | 1608 | targetArch: "x64", |
| 1609 | appVersion: WINDOWS_PAYLOAD_FIXTURE_VERSION, | ||
| 1594 | fileLimit: 2, | 1610 | fileLimit: 2, |
| 1595 | }).pipe(Effect.flip); | 1611 | }).pipe(Effect.flip); |
| 1596 | 1612 | ||
| ... | @@ -1612,6 +1628,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { | ... | @@ -1612,6 +1628,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { |
| 1612 | stageDistDir: fixture.stageDistDir, | 1628 | stageDistDir: fixture.stageDistDir, |
| 1613 | appExecutableName: fixture.appExecutableName, | 1629 | appExecutableName: fixture.appExecutableName, |
| 1614 | targetArch: "x64", | 1630 | targetArch: "x64", |
| 1631 | appVersion: WINDOWS_PAYLOAD_FIXTURE_VERSION, | ||
| 1615 | }).pipe(Effect.flip); | 1632 | }).pipe(Effect.flip); |
| 1616 | 1633 | ||
| 1617 | assert.instanceOf(error, BundleNotSelfContainedError); | 1634 | assert.instanceOf(error, BundleNotSelfContainedError); |
| ... | @@ -1923,7 +1940,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { | ... | @@ -1923,7 +1940,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { |
| 1923 | assert.equal(resourceMonitorExecutableName("win"), "t3-resource-monitor.exe"); | 1940 | assert.equal(resourceMonitorExecutableName("win"), "t3-resource-monitor.exe"); |
| 1924 | }); | 1941 | }); |
| 1925 | 1942 | ||
| 1926 | it("packages the WSL server and production dependencies as one compressed runtime", () => { | 1943 | it("ships the Linux CLI release archive as the WSL runtime", () => { |
| 1927 | assert.equal(WSL_RUNTIME_ARCHIVE_NAME, "wsl-runtime.tar.gz"); | 1944 | assert.equal(WSL_RUNTIME_ARCHIVE_NAME, "wsl-runtime.tar.gz"); |
| 1928 | assert.equal(WSL_RUNTIME_ARCHIVE_HASH_NAME, "wsl-runtime.tar.gz.sha256"); | 1945 | assert.equal(WSL_RUNTIME_ARCHIVE_HASH_NAME, "wsl-runtime.tar.gz.sha256"); |
| 1929 | assert.deepStrictEqual(WSL_RUNTIME_ARCHIVE_EXTRA_RESOURCE, { | 1946 | assert.deepStrictEqual(WSL_RUNTIME_ARCHIVE_EXTRA_RESOURCE, { |
| ... | @@ -1934,190 +1951,65 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { | ... | @@ -1934,190 +1951,65 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { |
| 1934 | from: "apps/desktop/prod-resources/wsl-runtime.tar.gz.sha256", | 1951 | from: "apps/desktop/prod-resources/wsl-runtime.tar.gz.sha256", |
| 1935 | to: "wsl-runtime.tar.gz.sha256", | 1952 | to: "wsl-runtime.tar.gz.sha256", |
| 1936 | }); | 1953 | }); |
| 1937 | // The archive is only usable alongside a Linux pty.node, so both the | 1954 | // Both the staging and the packaging config hang off this one decision: |
| 1938 | // staging and the packaging config hang off this one decision. | 1955 | // Windows only, and only when CI handed the build a Linux CLI archive. |
| 1939 | assert.isTrue(bundlesWslRuntime({ arch: "x64", prebuildPath: "/tmp/pty.node" })); | 1956 | const runtimeArchivePath = "/tmp/t3-1.2.3-linux-x64.tar.gz"; |
| 1940 | assert.isTrue(bundlesWslRuntime({ arch: "arm64", prebuildPath: "/tmp/pty.node" })); | 1957 | assert.isTrue(bundlesWslRuntime({ platform: "win", runtimeArchivePath })); |
| 1941 | assert.isFalse(bundlesWslRuntime({ arch: "x64", prebuildPath: undefined })); | 1958 | assert.isFalse(bundlesWslRuntime({ platform: "win", runtimeArchivePath: undefined })); |
| 1942 | assert.isFalse(bundlesWslRuntime({ arch: "universal", prebuildPath: "/tmp/pty.node" })); | 1959 | assert.isFalse(bundlesWslRuntime({ platform: "linux", runtimeArchivePath })); |
| 1943 | 1960 | assert.isFalse(bundlesWslRuntime({ platform: "mac", runtimeArchivePath })); | |
| 1944 | assert.deepStrictEqual(buildWslRuntimeArchiveArgs(), [ | 1961 | assert.equal(wslRuntimeArchiveStem("1.2.3", "x64"), "t3-1.2.3-linux-x64"); |
| 1945 | "-czf", | ||
| 1946 | "apps/desktop/prod-resources/wsl-runtime.tar.gz", | ||
| 1947 | "--exclude=node_modules/@anthropic-ai/claude-agent-sdk-*", | ||
| 1948 | "--exclude=node_modules/.bin*", | ||
| 1949 | "--exclude=node_modules/.pnpm*", | ||
| 1950 | "--exclude=node_modules/.modules.yaml*", | ||
| 1951 | "--exclude=node_modules/.pnpm-workspace-state-v1.json*", | ||
| 1952 | "--exclude=node_modules/node-pty/prebuilds/darwin-*", | ||
| 1953 | "--exclude=node_modules/node-pty/prebuilds/win32-*", | ||
| 1954 | "--exclude=node_modules/node-pty/build*", | ||
| 1955 | "--exclude=node_modules/node-pty/third_party/conpty*", | ||
| 1956 | "--exclude=node_modules/@ff-labs/fff-bin-win32-*", | ||
| 1957 | "--exclude=node_modules/@yuuang/ffi-rs-win32-*", | ||
| 1958 | "--exclude=node_modules/@msgpackr-extract/msgpackr-extract-win32-*", | ||
| 1959 | "apps/server/dist", | ||
| 1960 | "node_modules", | ||
| 1961 | ]); | ||
| 1962 | }); | 1962 | }); |
| 1963 | 1963 | ||
| 1964 | it("parses Windows bsdtar member listings with CRLF line endings", () => { | 1964 | it("parses Windows bsdtar member listings with CRLF line endings", () => { |
| 1965 | assert.deepStrictEqual( | 1965 | assert.deepStrictEqual( |
| 1966 | parseWslRuntimeArchiveMembers( | 1966 | parseWslRuntimeArchiveMembers("./t3-1.2.3-linux-x64/t3\r\nt3-1.2.3-linux-x64/client/\r\n"), |
| 1967 | "./apps/server/dist/bin.mjs\r\nnode_modules/node-pty/package.json\r\n", | 1967 | ["t3-1.2.3-linux-x64/t3", "t3-1.2.3-linux-x64/client"], |
| 1968 | ), | ||
| 1969 | ["apps/server/dist/bin.mjs", "node_modules/node-pty/package.json"], | ||
| 1970 | ); | 1968 | ); |
| 1971 | }); | 1969 | }); |
| 1972 | 1970 | ||
| 1973 | it("keeps Windows tar targets colon-free so GNU tar does not read them as remote hosts", () => { | 1971 | it.effect("stages the Linux CLI archive verbatim with its SHA-256 sidecar", () => |
| 1974 | assert.equal( | 1972 | Effect.scoped( |
| 1975 | wslRuntimeArchiveTarTarget("..\\app\\apps\\desktop\\prod-resources\\wsl-runtime.tar.gz"), | ||
| 1976 | "../app/apps/desktop/prod-resources/wsl-runtime.tar.gz", | ||
| 1977 | ); | ||
| 1978 | assert.equal( | ||
| 1979 | wslRuntimeArchiveTarTarget("../app/apps/desktop/prod-resources/wsl-runtime.tar.gz"), | ||
| 1980 | "../app/apps/desktop/prod-resources/wsl-runtime.tar.gz", | ||
| 1981 | ); | ||
| 1982 | }); | ||
| 1983 | |||
| 1984 | // The staged source tree and the archive live in sibling stage directories, | ||
| 1985 | // so this covers the real call: on Windows the archive path is an absolute | ||
| 1986 | // C:\... path, and handing that to tar is what made Git's GNU tar try to | ||
| 1987 | // reach a host named "C". | ||
| 1988 | it.effect("spawns tar with an archive target relative to the staged source tree", () => { | ||
| 1989 | const commands: Array<{ | ||
| 1990 | readonly command: string; | ||
| 1991 | readonly args: ReadonlyArray<string>; | ||
| 1992 | readonly options: { readonly cwd?: string }; | ||
| 1993 | }> = []; | ||
| 1994 | |||
| 1995 | return Effect.scoped( | ||
| 1996 | Effect.gen(function* () { | 1973 | Effect.gen(function* () { |
| 1997 | const fs = yield* FileSystem.FileSystem; | 1974 | const fs = yield* FileSystem.FileSystem; |
| 1998 | const path = yield* Path.Path; | 1975 | const path = yield* Path.Path; |
| 1999 | const stageRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-wsl-runtime-archive-" }); | 1976 | const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-wsl-runtime-stage-" }); |
| 2000 | const sourceDir = path.join(stageRoot, "server"); | 1977 | const sourceArchivePath = yield* makeLinuxCliArchiveFixture({ |
| 2001 | const stageAppDir = path.join(stageRoot, "app"); | 1978 | root, |
| 1979 | stem: "t3-1.2.3-linux-x64", | ||
| 1980 | }); | ||
| 1981 | const stageAppDir = path.join(root, "app"); | ||
| 2002 | const archivePath = path.join(stageAppDir, WSL_RUNTIME_ARCHIVE_EXTRA_RESOURCE.from); | 1982 | const archivePath = path.join(stageAppDir, WSL_RUNTIME_ARCHIVE_EXTRA_RESOURCE.from); |
| 2003 | const hashPath = path.join(stageAppDir, WSL_RUNTIME_ARCHIVE_HASH_EXTRA_RESOURCE.from); | 1983 | const hashPath = path.join(stageAppDir, WSL_RUNTIME_ARCHIVE_HASH_EXTRA_RESOURCE.from); |
| 2004 | yield* stageWslRuntimeTreeFixture(sourceDir, "export const serve = 1;\n"); | ||
| 2005 | |||
| 2006 | const spawnerLayer = Layer.succeed( | ||
| 2007 | ChildProcessSpawner.ChildProcessSpawner, | ||
| 2008 | ChildProcessSpawner.make((command) => { | ||
| 2009 | const childProcess = command as unknown as (typeof commands)[number]; | ||
| 2010 | commands.push(childProcess); | ||
| 2011 | // Stand in for tar: write the archive by resolving the -f target | ||
| 2012 | // against the cwd tar was spawned in, exactly as tar would. | ||
| 2013 | const target = path.resolve(childProcess.options.cwd ?? "", childProcess.args[1] ?? ""); | ||
| 2014 | return Effect.as(fs.writeFileString(target, "wsl-runtime-archive"), mockProcess(0)); | ||
| 2015 | }), | ||
| 2016 | ); | ||
| 2017 | |||
| 2018 | yield* stageWslRuntimeArchive({ sourceDir, archivePath, hashPath }).pipe( | ||
| 2019 | Effect.provide(spawnerLayer), | ||
| 2020 | ); | ||
| 2021 | 1984 | ||
| 2022 | const tarCommand = commands.find((command) => command.command === "tar"); | 1985 | yield* stageWslRuntimeArchive({ sourceArchivePath, archivePath, hashPath }); |
| 2023 | if (tarCommand === undefined) return assert.fail("tar was not spawned"); | ||
| 2024 | 1986 | ||
| 2025 | const target = tarCommand.args[1] ?? ""; | 1987 | const [source, staged] = yield* Effect.all([ |
| 2026 | assert.equal(tarCommand.options.cwd, sourceDir); | 1988 | fs.readFile(sourceArchivePath), |
| 2027 | assert.notInclude(target, ":"); | 1989 | fs.readFile(archivePath), |
| 2028 | assert.isFalse(path.isAbsolute(target)); | 1990 | ]); |
| 2029 | // Relative or not, tar has to land the archive where the build expects it. | 1991 | assert.deepStrictEqual(staged, source); |
| 2030 | assert.equal(path.resolve(sourceDir, target), archivePath); | 1992 | // The digest both gates installation inside the distro and names the |
| 2031 | assert.isTrue(yield* fs.exists(archivePath)); | 1993 | // extracted runtime's cache directory. |
| 2032 | |||
| 2033 | // The archive digest both gates installation and names the cache. | ||
| 2034 | const hash = yield* fs.readFileString(hashPath); | 1994 | const hash = yield* fs.readFileString(hashPath); |
| 2035 | assert.match(hash.trim(), /^[0-9a-f]{64}$/); | 1995 | assert.equal(hash, `${NodeCrypto.createHash("sha256").update(source).digest("hex")}\n`); |
| 2036 | }), | 1996 | }), |
| 2037 | ); | 1997 | ), |
| 2038 | }); | 1998 | ); |
| 2039 | 1999 | ||
| 2040 | it.effect("ships only Linux runtime members in the WSL archive", () => | 2000 | it.effect("fails when the Linux CLI archive handed to the build does not exist", () => |
| 2041 | Effect.scoped( | 2001 | Effect.scoped( |
| 2042 | Effect.gen(function* () { | 2002 | Effect.gen(function* () { |
| 2043 | const fs = yield* FileSystem.FileSystem; | 2003 | const fs = yield* FileSystem.FileSystem; |
| 2044 | const path = yield* Path.Path; | 2004 | const path = yield* Path.Path; |
| 2045 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | 2005 | const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-wsl-runtime-missing-" }); |
| 2046 | const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-wsl-runtime-members-" }); | 2006 | const error = yield* stageWslRuntimeArchive({ |
| 2047 | const sourceDir = path.join(root, "server"); | 2007 | sourceArchivePath: path.join(root, "t3-1.2.3-linux-x64.tar.gz"), |
| 2048 | const archivePath = path.join(root, "wsl-runtime.tar.gz"); | 2008 | archivePath: path.join(root, WSL_RUNTIME_ARCHIVE_NAME), |
| 2049 | const hashPath = `${archivePath}.sha256`; | 2009 | hashPath: path.join(root, WSL_RUNTIME_ARCHIVE_HASH_NAME), |
| 2050 | yield* stageWslRuntimeTreeFixture(sourceDir, "export const serve = 1;\n"); | 2010 | }).pipe(Effect.flip); |
| 2051 | |||
| 2052 | const members = [ | ||
| 2053 | "node_modules/node-pty/prebuilds/darwin-x64/pty.node", | ||
| 2054 | "node_modules/node-pty/prebuilds/win32-x64/pty.node", | ||
| 2055 | "node_modules/node-pty/build/Release/pty.node", | ||
| 2056 | "node_modules/node-pty/third_party/conpty/win10-x64/conpty.dll", | ||
| 2057 | "node_modules/@ff-labs/fff-bin-win32-x64/fff.dll", | ||
| 2058 | "node_modules/@ff-labs/fff-bin-linux-x64-gnu/libfff.so", | ||
| 2059 | "node_modules/@yuuang/ffi-rs-win32-x64-msvc/ffi.dll", | ||
| 2060 | "node_modules/@yuuang/ffi-rs-linux-x64-gnu/libffi.so", | ||
| 2061 | "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64/addon.node", | ||
| 2062 | "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64/addon.node", | ||
| 2063 | "node_modules/@anthropic-ai/claude-agent-sdk-win32-x64/index.js", | ||
| 2064 | "node_modules/.bin/tool", | ||
| 2065 | "node_modules/.pnpm/lock.yaml", | ||
| 2066 | "node_modules/.modules.yaml", | ||
| 2067 | "node_modules/.pnpm-workspace-state-v1.json", | ||
| 2068 | ] as const; | ||
| 2069 | yield* Effect.forEach( | ||
| 2070 | members, | ||
| 2071 | (member) => | ||
| 2072 | Effect.gen(function* () { | ||
| 2073 | const memberPath = path.join(sourceDir, member); | ||
| 2074 | yield* fs.makeDirectory(path.dirname(memberPath), { recursive: true }); | ||
| 2075 | yield* fs.writeFileString(memberPath, member); | ||
| 2076 | }), | ||
| 2077 | { discard: true }, | ||
| 2078 | ); | ||
| 2079 | |||
| 2080 | yield* stageWslRuntimeArchive({ sourceDir, archivePath, hashPath }); | ||
| 2081 | const process = yield* spawner.spawn( | ||
| 2082 | ChildProcess.make("tar", ["-tzf", archivePath], { | ||
| 2083 | stdin: "ignore", | ||
| 2084 | stdout: "pipe", | ||
| 2085 | stderr: "pipe", | ||
| 2086 | }), | ||
| 2087 | ); | ||
| 2088 | const listing = yield* process.stdout.pipe( | ||
| 2089 | Stream.decodeText(), | ||
| 2090 | Stream.runFold( | ||
| 2091 | () => "", | ||
| 2092 | (output, chunk) => output + chunk, | ||
| 2093 | ), | ||
| 2094 | ); | ||
| 2095 | assert.equal(Number(yield* process.exitCode), 0); | ||
| 2096 | 2011 | ||
| 2097 | assert.include(listing, "apps/server/dist/bin.mjs"); | 2012 | assert.instanceOf(error, WslRuntimeArchiveMissingError); |
| 2098 | assert.include(listing, "node_modules/node-pty/prebuilds/linux-x64/pty.node"); | ||
| 2099 | assert.include(listing, "node_modules/@ff-labs/fff-bin-linux-x64-gnu/libfff.so"); | ||
| 2100 | assert.include(listing, "node_modules/@yuuang/ffi-rs-linux-x64-gnu/libffi.so"); | ||
| 2101 | assert.include( | ||
| 2102 | listing, | ||
| 2103 | "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64/addon.node", | ||
| 2104 | ); | ||
| 2105 | for (const excluded of [ | ||
| 2106 | "prebuilds/darwin-", | ||
| 2107 | "prebuilds/win32-", | ||
| 2108 | "node-pty/build", | ||
| 2109 | "third_party/conpty", | ||
| 2110 | "fff-bin-win32-", | ||
| 2111 | "ffi-rs-win32-", | ||
| 2112 | "msgpackr-extract-win32-", | ||
| 2113 | "claude-agent-sdk-", | ||
| 2114 | "node_modules/.bin", | ||
| 2115 | "node_modules/.pnpm", | ||
| 2116 | "node_modules/.modules.yaml", | ||
| 2117 | "node_modules/.pnpm-workspace-state-v1.json", | ||
| 2118 | ]) { | ||
| 2119 | assert.notInclude(listing, excluded); | ||
| 2120 | } | ||
| 2121 | }), | 2013 | }), |
| 2122 | ), | 2014 | ), |
| 2123 | ); | 2015 | ); |
| ... | @@ -2228,7 +2120,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { | ... | @@ -2228,7 +2120,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { |
| 2228 | verbose: Option.none(), | 2120 | verbose: Option.none(), |
| 2229 | mockUpdates: Option.none(), | 2121 | mockUpdates: Option.none(), |
| 2230 | mockUpdateServerPort: Option.none(), | 2122 | mockUpdateServerPort: Option.none(), |
| 2231 | wslPrebuild: Option.none(), | 2123 | wslRuntime: Option.none(), |
| 2232 | }).pipe( | 2124 | }).pipe( |
| 2233 | Effect.provide( | 2125 | Effect.provide( |
| 2234 | Layer.mergeAll( | 2126 | Layer.mergeAll( |
| ... | @@ -2268,7 +2160,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { | ... | @@ -2268,7 +2160,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { |
| 2268 | verbose: Option.none(), | 2160 | verbose: Option.none(), |
| 2269 | mockUpdates: Option.none(), | 2161 | mockUpdates: Option.none(), |
| 2270 | mockUpdateServerPort: Option.none(), | 2162 | mockUpdateServerPort: Option.none(), |
| 2271 | wslPrebuild: Option.none(), | 2163 | wslRuntime: Option.none(), |
| 2272 | }), | 2164 | }), |
| 2273 | ); | 2165 | ); |
| 2274 | 2166 | ||
| ... | @@ -2292,7 +2184,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { | ... | @@ -2292,7 +2184,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { |
| 2292 | verbose: Option.some(false), | 2184 | verbose: Option.some(false), |
| 2293 | mockUpdates: Option.some(false), | 2185 | mockUpdates: Option.some(false), |
| 2294 | mockUpdateServerPort: Option.none(), | 2186 | mockUpdateServerPort: Option.none(), |
| 2295 | wslPrebuild: Option.none(), | 2187 | wslRuntime: Option.none(), |
| 2296 | }).pipe( | 2188 | }).pipe( |
| 2297 | Effect.provide( | 2189 | Effect.provide( |
| 2298 | ConfigProvider.layer( | 2190 | ConfigProvider.layer( |
scripts/build-desktop-artifact.ts+117-255| ... | @@ -89,9 +89,6 @@ const RepoRoot = Effect.service(Path.Path).pipe( | ... | @@ -89,9 +89,6 @@ const RepoRoot = Effect.service(Path.Path).pipe( |
| 89 | ); | 89 | ); |
| 90 | const encodeJsonString = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); | 90 | const encodeJsonString = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); |
| 91 | const decodeWorkspaceConfig = Schema.decodeEffect(fromYaml(WorkspaceConfig)); | 91 | const decodeWorkspaceConfig = Schema.decodeEffect(fromYaml(WorkspaceConfig)); |
| 92 | const decodeNodePtyManifest = Schema.decodeUnknownEffect( | ||
| 93 | Schema.fromJsonString(Schema.Struct({ version: Schema.String })), | ||
| 94 | ); | ||
| 95 | const encodeStageWorkspaceConfig = Schema.encodeEffect(fromYaml(StageWorkspaceConfig)); | 92 | const encodeStageWorkspaceConfig = Schema.encodeEffect(fromYaml(StageWorkspaceConfig)); |
| 96 | 93 | ||
| 97 | const readWorkspaceConfig = Effect.fn("readWorkspaceConfig")(function* () { | 94 | const readWorkspaceConfig = Effect.fn("readWorkspaceConfig")(function* () { |
| ... | @@ -164,7 +161,7 @@ interface BuildCliInput { | ... | @@ -164,7 +161,7 @@ interface BuildCliInput { |
| 164 | readonly verbose: Option.Option<boolean>; | 161 | readonly verbose: Option.Option<boolean>; |
| 165 | readonly mockUpdates: Option.Option<boolean>; | 162 | readonly mockUpdates: Option.Option<boolean>; |
| 166 | readonly mockUpdateServerPort: Option.Option<number>; | 163 | readonly mockUpdateServerPort: Option.Option<number>; |
| 167 | readonly wslPrebuild: Option.Option<string>; | 164 | readonly wslRuntime: Option.Option<string>; |
| 168 | } | 165 | } |
| 169 | 166 | ||
| 170 | function detectHostBuildPlatform(hostPlatform: string): typeof BuildPlatform.Type | undefined { | 167 | function detectHostBuildPlatform(hostPlatform: string): typeof BuildPlatform.Type | undefined { |
| ... | @@ -399,7 +396,7 @@ const WINDOWS_DESKTOP_BUILD_PREREQUISITES = [ | ... | @@ -399,7 +396,7 @@ const WINDOWS_DESKTOP_BUILD_PREREQUISITES = [ |
| 399 | id: "msvc", | 396 | id: "msvc", |
| 400 | description: "Visual Studio Build Tools with C++, Windows SDK, and Spectre libraries", | 397 | description: "Visual Studio Build Tools with C++, Windows SDK, and Spectre libraries", |
| 401 | }, | 398 | }, |
| 402 | { id: "tar", description: "tar for the bundled WSL runtime" }, | 399 | { id: "tar", description: "tar to inspect the bundled WSL runtime archive" }, |
| 403 | ] as const; | 400 | ] as const; |
| 404 | 401 | ||
| 405 | export class WindowsDesktopBuildPrerequisitesMissingError extends Schema.TaggedError<WindowsDesktopBuildPrerequisitesMissingError>()( | 402 | export class WindowsDesktopBuildPrerequisitesMissingError extends Schema.TaggedError<WindowsDesktopBuildPrerequisitesMissingError>()( |
| ... | @@ -670,14 +667,14 @@ export class DesktopBuildNoArtifactsProducedError extends Schema.TaggedError<Des | ... | @@ -670,14 +667,14 @@ export class DesktopBuildNoArtifactsProducedError extends Schema.TaggedError<Des |
| 670 | } | 667 | } |
| 671 | } | 668 | } |
| 672 | 669 | ||
| 673 | export class WslNodePtyPrebuildMissingError extends Schema.TaggedError<WslNodePtyPrebuildMissingError>()( | 670 | export class WslRuntimeArchiveMissingError extends Schema.TaggedError<WslRuntimeArchiveMissingError>()( |
| 674 | "WslNodePtyPrebuildMissingError", | 671 | "WslRuntimeArchiveMissingError", |
| 675 | { | 672 | { |
| 676 | prebuildPath: Schema.String, | 673 | archivePath: Schema.String, |
| 677 | }, | 674 | }, |
| 678 | ) { | 675 | ) { |
| 679 | override get message(): string { | 676 | override get message(): string { |
| 680 | return `WSL node-pty prebuild not found at ${this.prebuildPath}.`; | 677 | return `WSL runtime archive not found at ${this.archivePath}.`; |
| 681 | } | 678 | } |
| 682 | } | 679 | } |
| 683 | 680 | ||
| ... | @@ -754,18 +751,6 @@ export class WindowsPackagedPayloadValidationError extends Schema.TaggedError<Wi | ... | @@ -754,18 +751,6 @@ export class WindowsPackagedPayloadValidationError extends Schema.TaggedError<Wi |
| 754 | } | 751 | } |
| 755 | } | 752 | } |
| 756 | 753 | ||
| 757 | export class WslNodePtyManifestReadError extends Schema.TaggedError<WslNodePtyManifestReadError>()( | ||
| 758 | "WslNodePtyManifestReadError", | ||
| 759 | { | ||
| 760 | manifestPath: Schema.String, | ||
| 761 | cause: Schema.Defect(), | ||
| 762 | }, | ||
| 763 | ) { | ||
| 764 | override get message(): string { | ||
| 765 | return `Could not read node-pty version from ${this.manifestPath}.`; | ||
| 766 | } | ||
| 767 | } | ||
| 768 | |||
| 769 | export class LinuxIconResizeError extends Schema.TaggedError<LinuxIconResizeError>()( | 754 | export class LinuxIconResizeError extends Schema.TaggedError<LinuxIconResizeError>()( |
| 770 | "LinuxIconResizeError", | 755 | "LinuxIconResizeError", |
| 771 | { | 756 | { |
| ... | @@ -933,7 +918,7 @@ interface ResolvedBuildOptions { | ... | @@ -933,7 +918,7 @@ interface ResolvedBuildOptions { |
| 933 | readonly verbose: boolean; | 918 | readonly verbose: boolean; |
| 934 | readonly mockUpdates: boolean; | 919 | readonly mockUpdates: boolean; |
| 935 | readonly mockUpdateServerPort: number | undefined; | 920 | readonly mockUpdateServerPort: number | undefined; |
| 936 | readonly wslPrebuild: string | undefined; | 921 | readonly wslRuntime: string | undefined; |
| 937 | } | 922 | } |
| 938 | 923 | ||
| 939 | interface StagePackageJson { | 924 | interface StagePackageJson { |
| ... | @@ -1007,8 +992,8 @@ export function resolveMacFileExclusions(arch?: typeof BuildArch.Type) { | ... | @@ -1007,8 +992,8 @@ export function resolveMacFileExclusions(arch?: typeof BuildArch.Type) { |
| 1007 | // then extracts a handful of large archives instead of thousands of small | 992 | // then extracts a handful of large archives instead of thousands of small |
| 1008 | // files, which dominates install (and update) time. The Windows primary runs | 993 | // files, which dominates install (and update) time. The Windows primary runs |
| 1009 | // the server from inside server.asar via the asar-aware ELECTRON_RUN_AS_NODE | 994 | // the server from inside server.asar via the asar-aware ELECTRON_RUN_AS_NODE |
| 1010 | // runtime. WSL normally uses the dedicated compressed Linux runtime below; | 995 | // runtime. WSL does not use this sidecar: it runs the Linux CLI archive |
| 1011 | // DesktopWslServerTree can still materialize this sidecar as a fallback. | 996 | // embedded as resources/wsl-runtime.tar.gz (see WSL_RUNTIME_ARCHIVE_NAME). |
| 1012 | export const WINDOWS_SERVER_ASAR_RESOURCE = "server.asar"; | 997 | export const WINDOWS_SERVER_ASAR_RESOURCE = "server.asar"; |
| 1013 | // dlopen/spawn need real files, so native modules, shared libraries, and | 998 | // dlopen/spawn need real files, so native modules, shared libraries, and |
| 1014 | // helper executables live in each archive's .unpacked sibling (the standard | 999 | // helper executables live in each archive's .unpacked sibling (the standard |
| ... | @@ -1064,38 +1049,16 @@ export const WSL_RUNTIME_ARCHIVE_HASH_EXTRA_RESOURCE = { | ... | @@ -1064,38 +1049,16 @@ export const WSL_RUNTIME_ARCHIVE_HASH_EXTRA_RESOURCE = { |
| 1064 | from: `apps/desktop/prod-resources/${WSL_RUNTIME_ARCHIVE_HASH_NAME}`, | 1049 | from: `apps/desktop/prod-resources/${WSL_RUNTIME_ARCHIVE_HASH_NAME}`, |
| 1065 | to: WSL_RUNTIME_ARCHIVE_HASH_NAME, | 1050 | to: WSL_RUNTIME_ARCHIVE_HASH_NAME, |
| 1066 | } as const; | 1051 | } as const; |
| 1067 | export const WSL_RUNTIME_ARCHIVE_CONTENT_ROOTS = ["apps/server/dist", "node_modules"] as const; | 1052 | |
| 1068 | 1053 | // The WSL runtime is the Linux CLI release archive (t3-<version>-linux-<arch> | |
| 1069 | // The WSL runtime uses only the Linux half of the shared Windows/WSL sidecar. | 1054 | // .tar.gz, built by scripts/build-cli-archive.ts) copied in verbatim, so WSL |
| 1070 | // Keep build/install metadata and target-native packages that cannot run in | 1055 | // runs the exact bytes a Linux user downloads. This one predicate decides both |
| 1071 | // WSL out of the compressed archive. | 1056 | // whether the archive is staged and whether the packaging config ships it: |
| 1072 | export const WSL_RUNTIME_ARCHIVE_EXCLUDED_PREFIXES = [ | 1057 | // listing an extraResource whose source was never written fails electron-builder. |
| 1073 | "node_modules/@anthropic-ai/claude-agent-sdk-", | ||
| 1074 | "node_modules/.bin", | ||
| 1075 | "node_modules/.pnpm", | ||
| 1076 | "node_modules/.modules.yaml", | ||
| 1077 | "node_modules/.pnpm-workspace-state-v1.json", | ||
| 1078 | "node_modules/node-pty/prebuilds/darwin-", | ||
| 1079 | "node_modules/node-pty/prebuilds/win32-", | ||
| 1080 | "node_modules/node-pty/build", | ||
| 1081 | "node_modules/node-pty/third_party/conpty", | ||
| 1082 | "node_modules/@ff-labs/fff-bin-win32-", | ||
| 1083 | "node_modules/@yuuang/ffi-rs-win32-", | ||
| 1084 | "node_modules/@msgpackr-extract/msgpackr-extract-win32-", | ||
| 1085 | ] as const; | ||
| 1086 | // WSL runs the same CPU arch as the Windows host; universal is mac-only. | ||
| 1087 | export const resolveWslPrebuildArch = (arch: typeof BuildArch.Type): "x64" | "arm64" | undefined => | ||
| 1088 | arch === "x64" ? "x64" : arch === "arm64" ? "arm64" : undefined; | ||
| 1089 | |||
| 1090 | // A packaged WSL runtime is only usable when a Linux pty.node is bundled with | ||
| 1091 | // it, so this one predicate decides both whether the archive is built and | ||
| 1092 | // whether the packaging config ships it. Without it the build would produce an | ||
| 1093 | // archive that can never pass the install script's payload check, and every | ||
| 1094 | // launch would extract a few hundred MB from /mnt/c only to throw it away. | ||
| 1095 | export const bundlesWslRuntime = (input: { | 1058 | export const bundlesWslRuntime = (input: { |
| 1096 | readonly arch: typeof BuildArch.Type; | 1059 | readonly platform: typeof BuildPlatform.Type; |
| 1097 | readonly prebuildPath: string | undefined; | 1060 | readonly runtimeArchivePath: string | undefined; |
| 1098 | }): boolean => input.prebuildPath !== undefined && resolveWslPrebuildArch(input.arch) !== undefined; | 1061 | }): boolean => input.platform === "win" && input.runtimeArchivePath !== undefined; |
| 1099 | 1062 | ||
| 1100 | export const WSL_RUNTIME_EXTRA_RESOURCES = [ | 1063 | export const WSL_RUNTIME_EXTRA_RESOURCES = [ |
| 1101 | WSL_RUNTIME_ARCHIVE_EXTRA_RESOURCE, | 1064 | WSL_RUNTIME_ARCHIVE_EXTRA_RESOURCE, |
| ... | @@ -1515,15 +1478,8 @@ export function createStageWorkspaceConfig(input: { | ... | @@ -1515,15 +1478,8 @@ export function createStageWorkspaceConfig(input: { |
| 1515 | readonly allowBuilds?: Record<string, boolean>; | 1478 | readonly allowBuilds?: Record<string, boolean>; |
| 1516 | readonly patchedDependencies?: Record<string, string>; | 1479 | readonly patchedDependencies?: Record<string, string>; |
| 1517 | readonly overrides?: Record<string, string>; | 1480 | readonly overrides?: Record<string, string>; |
| 1518 | // The Windows server sidecar stage runs both the Windows primary and the | ||
| 1519 | // WSL Linux backend from one dependency tree, so it needs win32 + linux | ||
| 1520 | // natives (e.g. @yuuang/ffi-rs-linux-x64-gnu) — and a hoisted (physical, | ||
| 1521 | // symlink-free) node_modules: the tree gets packed into server.asar and | ||
| 1522 | // later extracted for WSL, and neither step can rely on pnpm's | ||
| 1523 | // symlink/junction layout surviving the trip. | ||
| 1524 | readonly linuxServerBackend?: boolean; | ||
| 1525 | }): StageWorkspaceConfig { | 1481 | }): StageWorkspaceConfig { |
| 1526 | const { platform, arch, allowBuilds, patchedDependencies, overrides, linuxServerBackend } = input; | 1482 | const { platform, arch, allowBuilds, patchedDependencies, overrides } = input; |
| 1527 | const hostOs = platform === "mac" ? "darwin" : platform === "win" ? "win32" : "linux"; | 1483 | const hostOs = platform === "mac" ? "darwin" : platform === "win" ? "win32" : "linux"; |
| 1528 | const hostCpu = arch === "universal" ? ["arm64", "x64"] : [arch]; | 1484 | const hostCpu = arch === "universal" ? ["arm64", "x64"] : [arch]; |
| 1529 | // Linux AppImages execute a Linux/glibc Node process that loads | 1485 | // Linux AppImages execute a Linux/glibc Node process that loads |
| ... | @@ -1536,16 +1492,10 @@ export function createStageWorkspaceConfig(input: { | ... | @@ -1536,16 +1492,10 @@ export function createStageWorkspaceConfig(input: { |
| 1536 | cpu: hostCpu, | 1492 | cpu: hostCpu, |
| 1537 | libc: ["glibc"], | 1493 | libc: ["glibc"], |
| 1538 | } | 1494 | } |
| 1539 | : linuxServerBackend | 1495 | : { |
| 1540 | ? { | 1496 | os: [hostOs], |
| 1541 | os: Array.from(new Set([hostOs, "linux"])), | 1497 | cpu: hostCpu, |
| 1542 | cpu: hostCpu, | 1498 | }; |
| 1543 | libc: ["glibc"], | ||
| 1544 | } | ||
| 1545 | : { | ||
| 1546 | os: [hostOs], | ||
| 1547 | cpu: hostCpu, | ||
| 1548 | }; | ||
| 1549 | 1499 | ||
| 1550 | return { | 1500 | return { |
| 1551 | supportedArchitectures, | 1501 | supportedArchitectures, |
| ... | @@ -1554,7 +1504,6 @@ export function createStageWorkspaceConfig(input: { | ... | @@ -1554,7 +1504,6 @@ export function createStageWorkspaceConfig(input: { |
| 1554 | ? { patchedDependencies } | 1504 | ? { patchedDependencies } |
| 1555 | : {}), | 1505 | : {}), |
| 1556 | ...(overrides && Object.keys(overrides).length > 0 ? { overrides } : {}), | 1506 | ...(overrides && Object.keys(overrides).length > 0 ? { overrides } : {}), |
| 1557 | ...(linuxServerBackend ? { nodeLinker: "hoisted" as const } : {}), | ||
| 1558 | }; | 1507 | }; |
| 1559 | } | 1508 | } |
| 1560 | 1509 | ||
| ... | @@ -1600,11 +1549,10 @@ const BuildEnvConfig = Config.all({ | ... | @@ -1600,11 +1549,10 @@ const BuildEnvConfig = Config.all({ |
| 1600 | verbose: Config.boolean("T3CODE_DESKTOP_VERBOSE").pipe(Config.withDefault(false)), | 1549 | verbose: Config.boolean("T3CODE_DESKTOP_VERBOSE").pipe(Config.withDefault(false)), |
| 1601 | mockUpdates: Config.boolean("T3CODE_DESKTOP_MOCK_UPDATES").pipe(Config.withDefault(false)), | 1550 | mockUpdates: Config.boolean("T3CODE_DESKTOP_MOCK_UPDATES").pipe(Config.withDefault(false)), |
| 1602 | mockUpdateServerPort: Config.string("T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT").pipe(Config.option), | 1551 | mockUpdateServerPort: Config.string("T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT").pipe(Config.option), |
| 1603 | // Path to a prebuilt Linux node-pty binary (pty.node) for the target arch, | 1552 | // Path to the Linux CLI release archive (t3-<version>-linux-x64.tar.gz) built |
| 1604 | // produced by the Linux CI job and handed to the Windows packaging job. Placed | 1553 | // by the build_linux_cli CI job. The Windows build embeds it verbatim as the |
| 1605 | // into the staged node-pty so the WSL backend ships a ready binary and never | 1554 | // WSL runtime. |
| 1606 | // compiles on the user's machine. | 1555 | wslRuntime: Config.string("T3CODE_DESKTOP_WSL_RUNTIME").pipe(Config.option), |
| 1607 | wslPrebuild: Config.string("T3CODE_DESKTOP_WSL_PREBUILD").pipe(Config.option), | ||
| 1608 | }); | 1556 | }); |
| 1609 | 1557 | ||
| 1610 | const MockUpdateServerPortSchema = Schema.NumberFromString.check( | 1558 | const MockUpdateServerPortSchema = Schema.NumberFromString.check( |
| ... | @@ -1696,8 +1644,8 @@ export const resolveBuildOptions = Effect.fn("resolveBuildOptions")(function* ( | ... | @@ -1696,8 +1644,8 @@ export const resolveBuildOptions = Effect.fn("resolveBuildOptions")(function* ( |
| 1696 | ), | 1644 | ), |
| 1697 | )); | 1645 | )); |
| 1698 | 1646 | ||
| 1699 | const wslPrebuild = | 1647 | const wslRuntime = |
| 1700 | Option.getOrUndefined(input.wslPrebuild) ?? Option.getOrUndefined(env.wslPrebuild); | 1648 | Option.getOrUndefined(input.wslRuntime) ?? Option.getOrUndefined(env.wslRuntime); |
| 1701 | 1649 | ||
| 1702 | return { | 1650 | return { |
| 1703 | platform, | 1651 | platform, |
| ... | @@ -1711,7 +1659,7 @@ export const resolveBuildOptions = Effect.fn("resolveBuildOptions")(function* ( | ... | @@ -1711,7 +1659,7 @@ export const resolveBuildOptions = Effect.fn("resolveBuildOptions")(function* ( |
| 1711 | verbose, | 1659 | verbose, |
| 1712 | mockUpdates, | 1660 | mockUpdates, |
| 1713 | mockUpdateServerPort, | 1661 | mockUpdateServerPort, |
| 1714 | wslPrebuild, | 1662 | wslRuntime, |
| 1715 | } satisfies ResolvedBuildOptions; | 1663 | } satisfies ResolvedBuildOptions; |
| 1716 | }); | 1664 | }); |
| 1717 | 1665 | ||
| ... | @@ -2683,9 +2631,9 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( | ... | @@ -2683,9 +2631,9 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( |
| 2683 | readonly provisioningProfilePath: string; | 2631 | readonly provisioningProfilePath: string; |
| 2684 | } | 2632 | } |
| 2685 | | undefined, | 2633 | | undefined, |
| 2686 | // Windows only, and false when no Linux node-pty prebuild was bundled: the | 2634 | // Windows only, and false when no Linux CLI archive was handed to the build: |
| 2687 | // sidecar staging skips the archive in that case, and listing a resource | 2635 | // staging skips the archive in that case, and listing a resource whose |
| 2688 | // whose source file was never written fails the electron-builder step. | 2636 | // source file was never written fails the electron-builder step. |
| 2689 | wslRuntimeBundled = false, | 2637 | wslRuntimeBundled = false, |
| 2690 | arch?: typeof BuildArch.Type, | 2638 | arch?: typeof BuildArch.Type, |
| 2691 | ) { | 2639 | ) { |
| ... | @@ -2850,114 +2798,25 @@ const assertPlatformBuildResources = Effect.fn("assertPlatformBuildResources")(f | ... | @@ -2850,114 +2798,25 @@ const assertPlatformBuildResources = Effect.fn("assertPlatformBuildResources")(f |
| 2850 | } | 2798 | } |
| 2851 | }); | 2799 | }); |
| 2852 | 2800 | ||
| 2853 | // Stage the prebuilt Linux node-pty binary into the packaged app so the WSL | 2801 | // Copy the Linux CLI release archive into the stage verbatim and record its |
| 2854 | // backend never compiles on the user's machine. node-pty publishes no Linux | 2802 | // SHA-256 beside it. The desktop app verifies the digest inside the distro |
| 2855 | // prebuilt and the WSL Linux Node can't load the Windows/Electron binary, so the | 2803 | // before extracting, and the digest also names the extracted runtime's cache |
| 2856 | // Linux CI job builds pty.node and hands it here. We drop it into the staged | 2804 | // directory, so it must be computed from the exact bytes that ship. |
| 2857 | // node-pty's prebuilds/linux-<arch>/ with a t3code marker the WSL preflight | ||
| 2858 | // checks (arch + node-pty version; the binary is N-API, hence ABI-stable across | ||
| 2859 | // Node versions). A missing prebuild is a warning, not an error, so local and | ||
| 2860 | // non-Windows builds still succeed — they just won't ship a working WSL backend. | ||
| 2861 | const stageWslNodePtyPrebuild = Effect.fn("stageWslNodePtyPrebuild")(function* (input: { | ||
| 2862 | readonly stageAppDir: string; | ||
| 2863 | readonly arch: typeof BuildArch.Type; | ||
| 2864 | readonly prebuildPath: string | undefined; | ||
| 2865 | }) { | ||
| 2866 | const fs = yield* FileSystem.FileSystem; | ||
| 2867 | const path = yield* Path.Path; | ||
| 2868 | |||
| 2869 | if (input.prebuildPath === undefined) { | ||
| 2870 | yield* Effect.logWarning( | ||
| 2871 | "[desktop-artifact] No WSL node-pty prebuild provided (--wsl-prebuild / T3CODE_DESKTOP_WSL_PREBUILD); the packaged WSL backend will not start until a Linux pty.node is bundled.", | ||
| 2872 | ); | ||
| 2873 | return; | ||
| 2874 | } | ||
| 2875 | |||
| 2876 | const linuxArch = resolveWslPrebuildArch(input.arch); | ||
| 2877 | if (linuxArch === undefined) { | ||
| 2878 | yield* Effect.logWarning( | ||
| 2879 | `[desktop-artifact] No WSL node-pty prebuild mapping for arch "${input.arch}"; skipping WSL backend bundling.`, | ||
| 2880 | ); | ||
| 2881 | return; | ||
| 2882 | } | ||
| 2883 | |||
| 2884 | const prebuildExists = yield* fs | ||
| 2885 | .exists(input.prebuildPath) | ||
| 2886 | .pipe(Effect.orElseSucceed(() => false)); | ||
| 2887 | if (!prebuildExists) { | ||
| 2888 | return yield* new WslNodePtyPrebuildMissingError({ | ||
| 2889 | prebuildPath: input.prebuildPath, | ||
| 2890 | }); | ||
| 2891 | } | ||
| 2892 | |||
| 2893 | // Resolve through the (pnpm) symlink so we write into the stage's own node-pty | ||
| 2894 | // copy, never a shared content-addressable store. | ||
| 2895 | const nodePtyLink = path.join(input.stageAppDir, "node_modules", "node-pty"); | ||
| 2896 | const nodePtyDir = yield* fs.realPath(nodePtyLink).pipe(Effect.orElseSucceed(() => nodePtyLink)); | ||
| 2897 | |||
| 2898 | const manifestPath = path.join(nodePtyDir, "package.json"); | ||
| 2899 | const pkgRaw = yield* fs.readFileString(manifestPath); | ||
| 2900 | const manifest = yield* decodeNodePtyManifest(pkgRaw).pipe( | ||
| 2901 | Effect.mapError( | ||
| 2902 | (cause) => | ||
| 2903 | new WslNodePtyManifestReadError({ | ||
| 2904 | manifestPath, | ||
| 2905 | cause, | ||
| 2906 | }), | ||
| 2907 | ), | ||
| 2908 | ); | ||
| 2909 | const nodePtyVersion = manifest.version; | ||
| 2910 | |||
| 2911 | const prebuildDir = path.join(nodePtyDir, "prebuilds", `linux-${linuxArch}`); | ||
| 2912 | yield* fs.makeDirectory(prebuildDir, { recursive: true }); | ||
| 2913 | yield* fs.copyFile(input.prebuildPath, path.join(prebuildDir, "pty.node")); | ||
| 2914 | const markerJson = yield* encodeJsonString({ arch: linuxArch, nodePtyVersion }); | ||
| 2915 | yield* fs.writeFileString(path.join(prebuildDir, "t3code-wsl-node-pty.json"), `${markerJson}\n`); | ||
| 2916 | |||
| 2917 | yield* Effect.log( | ||
| 2918 | `[desktop-artifact] Staged WSL node-pty prebuild (linux-${linuxArch}, node-pty ${nodePtyVersion}).`, | ||
| 2919 | ); | ||
| 2920 | }); | ||
| 2921 | |||
| 2922 | // tar reads an `-f` target containing a colon as `host:path` and tries to reach | ||
| 2923 | // it over rsh, so handing it a Windows drive path (C:\...\wsl-runtime.tar.gz) | ||
| 2924 | // makes Git for Windows' GNU tar fail with "Cannot connect to C: resolve | ||
| 2925 | // failed". The staged source tree and the archive both live under the build's | ||
| 2926 | // stage root, so the target is always expressible relative to tar's cwd. | ||
| 2927 | export const wslRuntimeArchiveTarTarget = (relativeArchivePath: string): string => | ||
| 2928 | relativeArchivePath.replaceAll("\\", "/"); | ||
| 2929 | |||
| 2930 | // `archivePath` is relative to the cwd tar runs in; see wslRuntimeArchiveTarTarget. | ||
| 2931 | export const buildWslRuntimeArchiveArgs = ( | ||
| 2932 | archivePath: string = WSL_RUNTIME_ARCHIVE_EXTRA_RESOURCE.from, | ||
| 2933 | ): ReadonlyArray<string> => [ | ||
| 2934 | "-czf", | ||
| 2935 | archivePath, | ||
| 2936 | ...WSL_RUNTIME_ARCHIVE_EXCLUDED_PREFIXES.map((prefix) => `--exclude=${prefix}*`), | ||
| 2937 | ...WSL_RUNTIME_ARCHIVE_CONTENT_ROOTS, | ||
| 2938 | ]; | ||
| 2939 | |||
| 2940 | export const parseWslRuntimeArchiveMembers = (listing: string): ReadonlyArray<string> => | ||
| 2941 | listing | ||
| 2942 | .split(/\r?\n/) | ||
| 2943 | .map((member) => member.replace(/^\.\//, "").replace(/\/$/, "")) | ||
| 2944 | .filter((member) => member.length > 0); | ||
| 2945 | |||
| 2946 | export const stageWslRuntimeArchive = Effect.fn("stageWslRuntimeArchive")(function* (input: { | 2805 | export const stageWslRuntimeArchive = Effect.fn("stageWslRuntimeArchive")(function* (input: { |
| 2947 | readonly sourceDir: string; | 2806 | readonly sourceArchivePath: string; |
| 2948 | readonly archivePath: string; | 2807 | readonly archivePath: string; |
| 2949 | readonly hashPath: string; | 2808 | readonly hashPath: string; |
| 2950 | }) { | 2809 | }) { |
| 2951 | const fs = yield* FileSystem.FileSystem; | 2810 | const fs = yield* FileSystem.FileSystem; |
| 2952 | const path = yield* Path.Path; | 2811 | const path = yield* Path.Path; |
| 2812 | const sourceExists = yield* fs | ||
| 2813 | .exists(input.sourceArchivePath) | ||
| 2814 | .pipe(Effect.orElseSucceed(() => false)); | ||
| 2815 | if (!sourceExists) { | ||
| 2816 | return yield* new WslRuntimeArchiveMissingError({ archivePath: input.sourceArchivePath }); | ||
| 2817 | } | ||
| 2953 | yield* fs.makeDirectory(path.dirname(input.archivePath), { recursive: true }); | 2818 | yield* fs.makeDirectory(path.dirname(input.archivePath), { recursive: true }); |
| 2954 | const tarTarget = wslRuntimeArchiveTarTarget(path.relative(input.sourceDir, input.archivePath)); | 2819 | yield* fs.copyFile(input.sourceArchivePath, input.archivePath); |
| 2955 | yield* runCommand( | ||
| 2956 | ChildProcess.make("tar", buildWslRuntimeArchiveArgs(tarTarget), { | ||
| 2957 | cwd: input.sourceDir, | ||
| 2958 | }), | ||
| 2959 | { label: "tar WSL runtime", verbose: false }, | ||
| 2960 | ); | ||
| 2961 | const hash = NodeCrypto.createHash("sha256"); | 2820 | const hash = NodeCrypto.createHash("sha256"); |
| 2962 | yield* fs | 2821 | yield* fs |
| 2963 | .stream(input.archivePath) | 2822 | .stream(input.archivePath) |
| ... | @@ -2965,16 +2824,27 @@ export const stageWslRuntimeArchive = Effect.fn("stageWslRuntimeArchive")(functi | ... | @@ -2965,16 +2824,27 @@ export const stageWslRuntimeArchive = Effect.fn("stageWslRuntimeArchive")(functi |
| 2965 | const digest = hash.digest("hex"); | 2824 | const digest = hash.digest("hex"); |
| 2966 | yield* fs.writeFileString(input.hashPath, `${digest}\n`); | 2825 | yield* fs.writeFileString(input.hashPath, `${digest}\n`); |
| 2967 | yield* Effect.log( | 2826 | yield* Effect.log( |
| 2968 | `[desktop-artifact] Staged compressed WSL runtime at ${input.archivePath} (${digest}).`, | 2827 | `[desktop-artifact] Staged WSL runtime archive at ${input.archivePath} (${digest}).`, |
| 2969 | ); | 2828 | ); |
| 2970 | }); | 2829 | }); |
| 2971 | 2830 | ||
| 2831 | // Mirrors cliArchiveStem in scripts/build-cli-archive.ts (which imports from | ||
| 2832 | // this module, so it cannot be imported here). WSL runs the same CPU arch as | ||
| 2833 | // the Windows host. | ||
| 2834 | export const wslRuntimeArchiveStem = (version: string, arch: typeof BuildArch.Type): string => | ||
| 2835 | `t3-${version}-linux-${arch}`; | ||
| 2836 | |||
| 2837 | export const parseWslRuntimeArchiveMembers = (listing: string): ReadonlyArray<string> => | ||
| 2838 | listing | ||
| 2839 | .split(/\r?\n/) | ||
| 2840 | .map((member) => member.replace(/^\.\//, "").replace(/\/$/, "")) | ||
| 2841 | .filter((member) => member.length > 0); | ||
| 2842 | |||
| 2972 | // Stage and pack the Windows server sidecar: the bundled server plus a hoisted | 2843 | // Stage and pack the Windows server sidecar: the bundled server plus a hoisted |
| 2973 | // install of only its runtime-external/native dependency closure for win32 and | 2844 | // install of only its runtime-external/native dependency closure for win32. |
| 2974 | // WSL Linux. The Windows primary runs from the archive through the asar-aware | 2845 | // The Windows primary runs from the archive through the asar-aware |
| 2975 | // ELECTRON_RUN_AS_NODE runtime; enabling WSL extracts it to a real directory. | 2846 | // ELECTRON_RUN_AS_NODE runtime. Shipping one packed archive instead of |
| 2976 | // Shipping one packed archive instead of thousands of loose files is what | 2847 | // thousands of loose files is what makes the NSIS install/update fast. |
| 2977 | // makes the NSIS install/update fast. | ||
| 2978 | export const packWindowsServerAsar = Effect.fn("packWindowsServerAsar")(function* (input: { | 2848 | export const packWindowsServerAsar = Effect.fn("packWindowsServerAsar")(function* (input: { |
| 2979 | readonly sourceDir: string; | 2849 | readonly sourceDir: string; |
| 2980 | readonly asarPath: string; | 2850 | readonly asarPath: string; |
| ... | @@ -3025,10 +2895,7 @@ export const stageWindowsServerSidecar = Effect.fn("stageWindowsServerSidecar")( | ... | @@ -3025,10 +2895,7 @@ export const stageWindowsServerSidecar = Effect.fn("stageWindowsServerSidecar")( |
| 3025 | readonly allowBuilds: Record<string, boolean>; | 2895 | readonly allowBuilds: Record<string, boolean>; |
| 3026 | readonly patchedDependencies: Record<string, string>; | 2896 | readonly patchedDependencies: Record<string, string>; |
| 3027 | readonly overrides: Record<string, string>; | 2897 | readonly overrides: Record<string, string>; |
| 3028 | readonly wslPrebuildPath: string | undefined; | ||
| 3029 | readonly asarPath: string; | 2898 | readonly asarPath: string; |
| 3030 | readonly wslRuntimeArchivePath: string; | ||
| 3031 | readonly wslRuntimeArchiveHashPath: string; | ||
| 3032 | readonly verbose: boolean; | 2899 | readonly verbose: boolean; |
| 3033 | }) { | 2900 | }) { |
| 3034 | const fs = yield* FileSystem.FileSystem; | 2901 | const fs = yield* FileSystem.FileSystem; |
| ... | @@ -3040,11 +2907,7 @@ export const stageWindowsServerSidecar = Effect.fn("stageWindowsServerSidecar")( | ... | @@ -3040,11 +2907,7 @@ export const stageWindowsServerSidecar = Effect.fn("stageWindowsServerSidecar")( |
| 3040 | 2907 | ||
| 3041 | const sidecarDependencies = { | 2908 | const sidecarDependencies = { |
| 3042 | ...input.runtimeExternalDependencies, | 2909 | ...input.runtimeExternalDependencies, |
| 3043 | // The sidecar serves two processes: the Windows primary loads win32 | ||
| 3044 | // natives, and the WSL backend loads the matching Linux natives (fff via | ||
| 3045 | // ffi-rs) from the extracted copy of this same tree. | ||
| 3046 | ...resolveFffNativeDependencies("win", input.arch, input.fffNodeVersion), | 2910 | ...resolveFffNativeDependencies("win", input.arch, input.fffNodeVersion), |
| 3047 | ...resolveFffNativeDependencies("linux", input.arch, input.fffNodeVersion), | ||
| 3048 | }; | 2911 | }; |
| 3049 | const sidecarPatchedDependencies = createStagePatchedDependencies( | 2912 | const sidecarPatchedDependencies = createStagePatchedDependencies( |
| 3050 | input.patchedDependencies, | 2913 | input.patchedDependencies, |
| ... | @@ -3062,14 +2925,18 @@ export const stageWindowsServerSidecar = Effect.fn("stageWindowsServerSidecar")( | ... | @@ -3062,14 +2925,18 @@ export const stageWindowsServerSidecar = Effect.fn("stageWindowsServerSidecar")( |
| 3062 | path.join(serverStageDir, "package.json"), | 2925 | path.join(serverStageDir, "package.json"), |
| 3063 | `${sidecarPackageJsonString}\n`, | 2926 | `${sidecarPackageJsonString}\n`, |
| 3064 | ); | 2927 | ); |
| 3065 | const sidecarWorkspaceConfig = createStageWorkspaceConfig({ | 2928 | const sidecarWorkspaceConfig = { |
| 3066 | platform: "win", | 2929 | ...createStageWorkspaceConfig({ |
| 3067 | arch: input.arch, | 2930 | platform: "win", |
| 3068 | allowBuilds: input.allowBuilds, | 2931 | arch: input.arch, |
| 3069 | patchedDependencies: sidecarPatchedDependencies, | 2932 | allowBuilds: input.allowBuilds, |
| 3070 | overrides: input.overrides, | 2933 | patchedDependencies: sidecarPatchedDependencies, |
| 3071 | linuxServerBackend: true, | 2934 | overrides: input.overrides, |
| 3072 | }); | 2935 | }), |
| 2936 | // The tree gets packed into server.asar, which cannot carry pnpm's | ||
| 2937 | // symlink/junction layout, so install a physical, hoisted node_modules. | ||
| 2938 | nodeLinker: "hoisted" as const, | ||
| 2939 | }; | ||
| 3073 | const sidecarWorkspaceConfigString = yield* encodeStageWorkspaceConfig(sidecarWorkspaceConfig); | 2940 | const sidecarWorkspaceConfigString = yield* encodeStageWorkspaceConfig(sidecarWorkspaceConfig); |
| 3074 | yield* fs.writeFileString( | 2941 | yield* fs.writeFileString( |
| 3075 | path.join(serverStageDir, "pnpm-workspace.yaml"), | 2942 | path.join(serverStageDir, "pnpm-workspace.yaml"), |
| ... | @@ -3089,22 +2956,6 @@ export const stageWindowsServerSidecar = Effect.fn("stageWindowsServerSidecar")( | ... | @@ -3089,22 +2956,6 @@ export const stageWindowsServerSidecar = Effect.fn("stageWindowsServerSidecar")( |
| 3089 | { label: "vp install --prod (server sidecar)", verbose: input.verbose }, | 2956 | { label: "vp install --prod (server sidecar)", verbose: input.verbose }, |
| 3090 | ); | 2957 | ); |
| 3091 | 2958 | ||
| 3092 | yield* stageWslNodePtyPrebuild({ | ||
| 3093 | stageAppDir: serverStageDir, | ||
| 3094 | arch: input.arch, | ||
| 3095 | prebuildPath: input.wslPrebuildPath, | ||
| 3096 | }); | ||
| 3097 | // Skip the archive entirely rather than shipping one the install script must | ||
| 3098 | // extract and reject on every launch. The desktop app treats a missing | ||
| 3099 | // archive as "no WSL-local runtime" and goes straight to the mounted tree. | ||
| 3100 | if (bundlesWslRuntime({ arch: input.arch, prebuildPath: input.wslPrebuildPath })) { | ||
| 3101 | yield* stageWslRuntimeArchive({ | ||
| 3102 | sourceDir: serverStageDir, | ||
| 3103 | archivePath: input.wslRuntimeArchivePath, | ||
| 3104 | hashPath: input.wslRuntimeArchiveHashPath, | ||
| 3105 | }); | ||
| 3106 | } | ||
| 3107 | |||
| 3108 | yield* Effect.log("[desktop-artifact] Packing server.asar..."); | 2959 | yield* Effect.log("[desktop-artifact] Packing server.asar..."); |
| 3109 | yield* fs.makeDirectory(path.dirname(input.asarPath), { recursive: true }); | 2960 | yield* fs.makeDirectory(path.dirname(input.asarPath), { recursive: true }); |
| 3110 | yield* packWindowsServerAsar({ | 2961 | yield* packWindowsServerAsar({ |
| ... | @@ -3248,6 +3099,9 @@ export const validateWindowsPackagedPayload = Effect.fn( | ... | @@ -3248,6 +3099,9 @@ export const validateWindowsPackagedPayload = Effect.fn( |
| 3248 | readonly stageDistDir: string; | 3099 | readonly stageDistDir: string; |
| 3249 | readonly appExecutableName: string; | 3100 | readonly appExecutableName: string; |
| 3250 | readonly targetArch: typeof BuildArch.Type; | 3101 | readonly targetArch: typeof BuildArch.Type; |
| 3102 | // The version the embedded Linux CLI archive must carry; its top-level | ||
| 3103 | // directory is named t3-<version>-linux-<arch>. | ||
| 3104 | readonly appVersion: string; | ||
| 3251 | readonly expectWslRuntime?: boolean; | 3105 | readonly expectWslRuntime?: boolean; |
| 3252 | readonly fileLimit?: number; | 3106 | readonly fileLimit?: number; |
| 3253 | readonly verbose?: boolean; | 3107 | readonly verbose?: boolean; |
| ... | @@ -3411,24 +3265,22 @@ export const validateWindowsPackagedPayload = Effect.fn( | ... | @@ -3411,24 +3265,22 @@ export const validateWindowsPackagedPayload = Effect.fn( |
| 3411 | ); | 3265 | ); |
| 3412 | } | 3266 | } |
| 3413 | const members = parseWslRuntimeArchiveMembers(listing.stdout); | 3267 | const members = parseWslRuntimeArchiveMembers(listing.stdout); |
| 3414 | const forbiddenMember = members.find((member) => | 3268 | // A release archive unpacks to one directory named after its stem; the |
| 3415 | WSL_RUNTIME_ARCHIVE_EXCLUDED_PREFIXES.some((prefix) => member.startsWith(prefix)), | 3269 | // desktop app's WSL install script relies on that layout to find `t3`. |
| 3416 | ); | 3270 | const stem = wslRuntimeArchiveStem(input.appVersion, input.targetArch); |
| 3417 | if (forbiddenMember !== undefined) { | 3271 | const topLevel = new Set(members.map((member) => member.split("/")[0])); |
| 3272 | if (topLevel.size !== 1 || !topLevel.has(stem)) { | ||
| 3418 | return yield* invalidWslRuntime( | 3273 | return yield* invalidWslRuntime( |
| 3419 | new Error(`WSL runtime archive contains forbidden member ${forbiddenMember}`), | 3274 | new Error( |
| 3275 | `WSL runtime archive must contain a single top-level directory ${stem}, found ${[...topLevel].join(", ") || "nothing"}`, | ||
| 3276 | ), | ||
| 3420 | ); | 3277 | ); |
| 3421 | } | 3278 | } |
| 3422 | const wslArch = resolveWslPrebuildArch(input.targetArch); | ||
| 3423 | const requiredMembers = [ | 3279 | const requiredMembers = [ |
| 3424 | "apps/server/dist/bin.mjs", | 3280 | `${stem}/t3`, |
| 3425 | "node_modules/node-pty/package.json", | 3281 | `${stem}/client`, |
| 3426 | ...(wslArch === undefined | 3282 | `${stem}/node_modules`, |
| 3427 | ? [] | 3283 | `${stem}/node_modules/node-pty/build/Release/pty.node`, |
| 3428 | : [ | ||
| 3429 | `node_modules/node-pty/prebuilds/linux-${wslArch}/pty.node`, | ||
| 3430 | `node_modules/node-pty/prebuilds/linux-${wslArch}/t3code-wsl-node-pty.json`, | ||
| 3431 | ]), | ||
| 3432 | ]; | 3284 | ]; |
| 3433 | const missingMembers = requiredMembers.filter((member) => !members.includes(member)); | 3285 | const missingMembers = requiredMembers.filter((member) => !members.includes(member)); |
| 3434 | if (missingMembers.length > 0) { | 3286 | if (missingMembers.length > 0) { |
| ... | @@ -3436,9 +3288,16 @@ export const validateWindowsPackagedPayload = Effect.fn( | ... | @@ -3436,9 +3288,16 @@ export const validateWindowsPackagedPayload = Effect.fn( |
| 3436 | reason: "wsl-runtime-invalid", | 3288 | reason: "wsl-runtime-invalid", |
| 3437 | packagedAppDir, | 3289 | packagedAppDir, |
| 3438 | missingFiles: missingMembers, | 3290 | missingFiles: missingMembers, |
| 3439 | cause: new Error("WSL runtime archive is incomplete"), | 3291 | cause: new Error("WSL runtime archive is not a Linux CLI release archive"), |
| 3440 | }); | 3292 | }); |
| 3441 | } | 3293 | } |
| 3294 | // The CLI archive runs the single-executable, never a loose server bundle. | ||
| 3295 | const bundleEntry = members.find((member) => member.endsWith("/bin.mjs")); | ||
| 3296 | if (bundleEntry !== undefined) { | ||
| 3297 | return yield* invalidWslRuntime( | ||
| 3298 | new Error(`WSL runtime archive contains a server bundle entry ${bundleEntry}`), | ||
| 3299 | ); | ||
| 3300 | } | ||
| 3442 | } | 3301 | } |
| 3443 | 3302 | ||
| 3444 | const fileCount = yield* countPayloadFiles(packagedAppDir); | 3303 | const fileCount = yield* countPayloadFiles(packagedAppDir); |
| ... | @@ -3487,8 +3346,8 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( | ... | @@ -3487,8 +3346,8 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( |
| 3487 | yield* preflightWindowsDesktopBuild({ | 3346 | yield* preflightWindowsDesktopBuild({ |
| 3488 | arch: options.arch, | 3347 | arch: options.arch, |
| 3489 | bundlesWslRuntime: bundlesWslRuntime({ | 3348 | bundlesWslRuntime: bundlesWslRuntime({ |
| 3490 | arch: options.arch, | 3349 | platform: options.platform, |
| 3491 | prebuildPath: options.wslPrebuild, | 3350 | runtimeArchivePath: options.wslRuntime, |
| 3492 | }), | 3351 | }), |
| 3493 | }); | 3352 | }); |
| 3494 | } | 3353 | } |
| ... | @@ -3807,7 +3666,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( | ... | @@ -3807,7 +3666,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( |
| 3807 | provisioningProfilePath: macPasskeySigning.provisioningProfilePath, | 3666 | provisioningProfilePath: macPasskeySigning.provisioningProfilePath, |
| 3808 | } | 3667 | } |
| 3809 | : undefined, | 3668 | : undefined, |
| 3810 | bundlesWslRuntime({ arch: options.arch, prebuildPath: options.wslPrebuild }), | 3669 | bundlesWslRuntime({ platform: options.platform, runtimeArchivePath: options.wslRuntime }), |
| 3811 | options.arch, | 3670 | options.arch, |
| 3812 | ), | 3671 | ), |
| 3813 | dependencies: stageDependencies, | 3672 | dependencies: stageDependencies, |
| ... | @@ -3847,9 +3706,8 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( | ... | @@ -3847,9 +3706,8 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( |
| 3847 | yield* stageClerkPasskeyNativeBinaries(stageAppDir, options.platform, options.arch); | 3706 | yield* stageClerkPasskeyNativeBinaries(stageAppDir, options.platform, options.arch); |
| 3848 | yield* stageKeyringNativeBinaries(stageAppDir, options.platform, options.arch); | 3707 | yield* stageKeyringNativeBinaries(stageAppDir, options.platform, options.arch); |
| 3849 | 3708 | ||
| 3850 | // WSL is Windows-only, so only the Windows artifact carries the server | 3709 | // Only the Windows artifact carries the server sidecar and the WSL runtime; |
| 3851 | // sidecar (which embeds the Linux node-pty prebuild); other platforms | 3710 | // other platforms ignore the --wsl-runtime input. |
| 3852 | // ignore the prebuild input. | ||
| 3853 | if (options.platform === "win" && windowsServerAsarPath) { | 3711 | if (options.platform === "win" && windowsServerAsarPath) { |
| 3854 | yield* stageWindowsServerSidecar({ | 3712 | yield* stageWindowsServerSidecar({ |
| 3855 | stageRoot, | 3713 | stageRoot, |
| ... | @@ -3862,17 +3720,20 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( | ... | @@ -3862,17 +3720,20 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( |
| 3862 | allowBuilds: workspaceAllowBuilds, | 3720 | allowBuilds: workspaceAllowBuilds, |
| 3863 | patchedDependencies: workspacePatchedDependencies, | 3721 | patchedDependencies: workspacePatchedDependencies, |
| 3864 | overrides: resolvedOverrides, | 3722 | overrides: resolvedOverrides, |
| 3865 | wslPrebuildPath: options.wslPrebuild, | ||
| 3866 | asarPath: windowsServerAsarPath, | 3723 | asarPath: windowsServerAsarPath, |
| 3867 | wslRuntimeArchivePath: path.join(stageAppDir, WSL_RUNTIME_ARCHIVE_EXTRA_RESOURCE.from), | ||
| 3868 | wslRuntimeArchiveHashPath: path.join( | ||
| 3869 | stageAppDir, | ||
| 3870 | WSL_RUNTIME_ARCHIVE_HASH_EXTRA_RESOURCE.from, | ||
| 3871 | ), | ||
| 3872 | |||
| 3873 | verbose: options.verbose, | 3724 | verbose: options.verbose, |
| 3874 | }); | 3725 | }); |
| 3875 | } | 3726 | } |
| 3727 | if ( | ||
| 3728 | options.wslRuntime !== undefined && | ||
| 3729 | bundlesWslRuntime({ platform: options.platform, runtimeArchivePath: options.wslRuntime }) | ||
| 3730 | ) { | ||
| 3731 | yield* stageWslRuntimeArchive({ | ||
| 3732 | sourceArchivePath: options.wslRuntime, | ||
| 3733 | archivePath: path.join(stageAppDir, WSL_RUNTIME_ARCHIVE_EXTRA_RESOURCE.from), | ||
| 3734 | hashPath: path.join(stageAppDir, WSL_RUNTIME_ARCHIVE_HASH_EXTRA_RESOURCE.from), | ||
| 3735 | }); | ||
| 3736 | } | ||
| 3876 | 3737 | ||
| 3877 | // electron-builder treats several set-but-empty variables (e.g. CSC_LINK="") | 3738 | // electron-builder treats several set-but-empty variables (e.g. CSC_LINK="") |
| 3878 | // as enabled, so copy the host env and scrub empty values instead of relying | 3739 | // as enabled, so copy the host env and scrub empty values instead of relying |
| ... | @@ -3968,9 +3829,10 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( | ... | @@ -3968,9 +3829,10 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( |
| 3968 | stageDistDir, | 3829 | stageDistDir, |
| 3969 | appExecutableName: `${resolveDesktopProductName(appVersion)}.exe`, | 3830 | appExecutableName: `${resolveDesktopProductName(appVersion)}.exe`, |
| 3970 | targetArch: options.arch, | 3831 | targetArch: options.arch, |
| 3832 | appVersion, | ||
| 3971 | expectWslRuntime: bundlesWslRuntime({ | 3833 | expectWslRuntime: bundlesWslRuntime({ |
| 3972 | arch: options.arch, | 3834 | platform: options.platform, |
| 3973 | prebuildPath: options.wslPrebuild, | 3835 | runtimeArchivePath: options.wslRuntime, |
| 3974 | }), | 3836 | }), |
| 3975 | verbose: options.verbose, | 3837 | verbose: options.verbose, |
| 3976 | }); | 3838 | }); |
| ... | @@ -4055,9 +3917,9 @@ const buildDesktopArtifactCli = Command.make("build-desktop-artifact", { | ... | @@ -4055,9 +3917,9 @@ const buildDesktopArtifactCli = Command.make("build-desktop-artifact", { |
| 4055 | Flag.withDescription("Mock update server port (env: T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT)."), | 3917 | Flag.withDescription("Mock update server port (env: T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT)."), |
| 4056 | Flag.optional, | 3918 | Flag.optional, |
| 4057 | ), | 3919 | ), |
| 4058 | wslPrebuild: Flag.string("wsl-prebuild").pipe( | 3920 | wslRuntime: Flag.string("wsl-runtime").pipe( |
| 4059 | Flag.withDescription( | 3921 | Flag.withDescription( |
| 4060 | "Path to a prebuilt Linux node-pty (pty.node) for the target arch, staged for the WSL backend (env: T3CODE_DESKTOP_WSL_PREBUILD).", | 3922 | "Path to the Linux CLI release archive (t3-<version>-linux-x64.tar.gz) to embed as the WSL runtime of a Windows build (env: T3CODE_DESKTOP_WSL_RUNTIME).", |
| 4061 | ), | 3923 | ), |
| 4062 | Flag.optional, | 3924 | Flag.optional, |
| 4063 | ), | 3925 | ), |