Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
ca96de3
Cache Runtime locally on WSL Filesystem
ikifar2012 Aug 9, 2026
b3671ad
improve runtime ready check
ikifar2012 Aug 9, 2026
dbf3965
Merge branch 'main' into fix-wsl-local-runtime
ikifar2012 Aug 9, 2026
a7afd00
fix: Race deletes completed runtime cache
ikifar2012 Aug 9, 2026
ac89dce
fix: Windows tar gets absolute archive path
ikifar2012 Aug 9, 2026
7195ecd
Merge branch 'fix-wsl-local-runtime' of https://github.com/ikifar2012…
ikifar2012 Aug 9, 2026
1e73634
improve install lock
ikifar2012 Aug 9, 2026
8c0f370
fix: Wait-loop trap swallows termination
ikifar2012 Aug 9, 2026
f31d575
Merge branch 'main' into fix-wsl-local-runtime
ikifar2012 Aug 9, 2026
053a280
fall back when WSL runtime staging fails
ikifar2012 Aug 10, 2026
4ee11bd
use flock for WSL runtime installs
ikifar2012 Aug 10, 2026
0a6c16e
Implemented automatic WSL runtime pruning.
ikifar2012 Aug 10, 2026
5232e36
invalidate WSL runtime cache when archive changes
ikifar2012 Aug 10, 2026
a77f11c
Merge upstream/main into fix-wsl-local-runtime
ikifar2012 Aug 14, 2026
0f5ca48
Merge branch 'main' into fix-wsl-local-runtime
ikifar2012 Aug 15, 2026
1553b33
fix(desktop): validate packaged WSL runtime dependencies
ikifar2012 Aug 15, 2026
b086ca9
Merge upstream/main into fix-wsl-local-runtime
ikifar2012 Aug 18, 2026
ec800b8
fix(build): keep the WSL runtime tar target relative to tar's cwd
ikifar2012 Aug 18, 2026
6576d39
docs: keep the Arch nightly instructions with the stable block
ikifar2012 Aug 18, 2026
3742dc5
fix(desktop): prune orphaned WSL runtime install scratch directories
ikifar2012 Aug 19, 2026
f2ce5ce
Merge branch 'main' into fix-wsl-local-runtime
ikifar2012 Aug 19, 2026
39370bd
Address Merge Blockers
ikifar2012 Aug 21, 2026
223ebd2
Merge branch 'main' into fix-wsl-local-runtime
ikifar2012 Aug 21, 2026
ef29b5a
fix(desktop): recover from a WSL runtime cache that cannot load node-pty
ikifar2012 Aug 22, 2026
751a6f7
fix(desktop): recover from a WSL runtime cache that cannot load node-pty
ikifar2012 Aug 22, 2026
f9cf782
Merge branch 'main' into fix-wsl-local-runtime
ikifar2012 Aug 22, 2026
0b5e357
fix(build): stop shipping a WSL runtime archive with no Linux node-pty
ikifar2012 Aug 22, 2026
3db01b2
Merge branch 'fix-wsl-local-runtime' of https://github.com/ikifar2012…
ikifar2012 Aug 22, 2026
b6f7942
Merge remote-tracking branch 'upstream/main' into fix-wsl-local-runtime
ikifar2012 Aug 24, 2026
7fb4131
fix(desktop): reuse the WSL runtime cache across releases with the sa…
ikifar2012 Aug 24, 2026
4ee6fdb
fix(build): keep the WSL content-id sidecar out of app.asar
ikifar2012 Aug 24, 2026
ae14a3a
Merge branch 'main' into fix-wsl-local-runtime
ikifar2012 Aug 24, 2026
b835a97
fix(desktop): stop reinstalls deleting a WSL runtime still in use
ikifar2012 Aug 24, 2026
cb5d9dd
Merge branch 'fix-wsl-local-runtime' of https://github.com/ikifar2012…
ikifar2012 Aug 24, 2026
36ab20a
fix(desktop): reinstall a WSL runtime whose server entry went bad
ikifar2012 Aug 25, 2026
c73b467
fix(build): keep executable bits and symlinks in the WSL runtime cont…
ikifar2012 Aug 25, 2026
55dc373
Merge branch 'main' into fix-wsl-local-runtime
ikifar2012 Aug 25, 2026
75b0fc8
fix(build): keep permissions and symlinks in the WSL runtime content id
ikifar2012 Aug 25, 2026
d45ad4a
Merge branch 'fix-wsl-local-runtime' of https://github.com/ikifar2012…
ikifar2012 Aug 25, 2026
1814e23
test(desktop): run the WSL install lock and its refusals through a re…
ikifar2012 Aug 25, 2026
1ea9c29
Merge branch 'main' into fix-wsl-local-runtime
ikifar2012 Aug 26, 2026
0e85a77
fix(desktop): harden WSL runtime cache lifecycle
t3-code[bot] Aug 28, 2026
1a1f360
fix(desktop): preserve active stale WSL runtimes
t3-code[bot] Aug 28, 2026
72383b1
fix(desktop): lease WSL caches through backend spawn
t3-code[bot] Aug 28, 2026
f540555
fix(desktop): validate packaged WSL runtime
t3-code[bot] Aug 28, 2026
9f10957
test(desktop): share packaged WSL harness
t3-code[bot] Aug 28, 2026
2a59fbb
fix(build): parse Windows tar listings
t3-code[bot] Aug 28, 2026
cee8a4a
Merge branch 'main' into fix-wsl-local-runtime
ikifar2012 Aug 28, 2026
dc98770
fix(desktop): invalidate partial legacy trees
t3-code[bot] Aug 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 124 additions & 3 deletions apps/desktop/src/backend/DesktopBackendConfiguration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ describe("DesktopBackendConfiguration", () => {
],
windowsToWslPath: (distro) => {
observedDistros.push(distro);
return Option.some("/repo/apps/server/dist/bin.mjs");
return Option.some("/repo");
},
ensureNodePty: (distro) => {
observedDistros.push(distro);
Expand Down Expand Up @@ -225,6 +225,126 @@ describe("DesktopBackendConfiguration", () => {
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);

it.effect("resolveWsl launches a packaged backend from the WSL-local runtime cache", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const baseDir = yield* fileSystem.makeTempDirectoryScoped({
prefix: "t3-desktop-backend-config-test-",
});
const archivePath = path.join(baseDir, "wsl-runtime.tar.gz");
const entryPath = path.join(baseDir, "app.asar.unpacked/apps/server/dist/bin.mjs");
yield* fileSystem.makeDirectory(path.dirname(entryPath), { recursive: true });
yield* fileSystem.writeFileString(entryPath, "");
yield* fileSystem.writeFileString(archivePath, "archive");

const observedArchives: Array<{ windowsArchivePath: string; runtimeId: string }> = [];
const observedNodePtyRoots: string[] = [];
const linuxAppRoot = "/home/test/.t3/runtime/1.2.3-x64";
const config = yield* Effect.gen(function* () {
const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration;
return yield* configuration.resolveWsl({ port: 5000, distro: "Ubuntu" });
}).pipe(
Effect.provide(
DesktopBackendConfiguration.layer.pipe(
Layer.provideMerge(serverExposureLayer),
Layer.provideMerge(DesktopAppSettings.layerTest()),
Layer.provideMerge(
DesktopWslEnvironment.layerTest({
isAvailable: true,
distros: [{ name: "Ubuntu", isDefault: true, version: 2 }],
windowsToWslPath: () => Option.some("/mnt/c/app.asar.unpacked"),
prepareRuntime: (_distro, windowsArchivePath, runtimeId) => {
observedArchives.push({ windowsArchivePath, runtimeId });
return { ok: true, linuxAppRoot };
},
ensureNodePty: (_distro, root) => {
observedNodePtyRoots.push(root);
return { ok: true, nodePath: "/usr/bin/node", resolvedPath: "/usr/bin:/bin" };
},
getDistroIp: () => Option.some("172.27.0.99"),
}),
),
Layer.provideMerge(
makeEnvironmentLayer(baseDir, {
appPath: baseDir,
platform: "win32",
resourcesPath: baseDir,
}),
),
),
),
);

assert.deepEqual(observedArchives, [
{
windowsArchivePath: archivePath,
runtimeId: "1.2.3-x64",
},
]);
assert.deepEqual(observedNodePtyRoots, [linuxAppRoot]);
assert.equal(config.entryPath, entryPath);
assert.include(config.args, `${linuxAppRoot}/apps/server/dist/bin.mjs`);
assert.equal(config.wslRuntimeId, "1.2.3-x64");
assert.isTrue(Option.isNone(config.preflightFailure));
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);

it.effect("resolveWsl falls back to the mounted runtime when archive staging fails", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const baseDir = yield* fileSystem.makeTempDirectoryScoped({
prefix: "t3-desktop-backend-config-test-",
});
const archivePath = path.join(baseDir, "wsl-runtime.tar.gz");
const entryPath = path.join(baseDir, "app.asar.unpacked/apps/server/dist/bin.mjs");
yield* fileSystem.makeDirectory(path.dirname(entryPath), { recursive: true });
yield* fileSystem.writeFileString(entryPath, "");
yield* fileSystem.writeFileString(archivePath, "corrupt archive");

const mountedAppRoot = "/mnt/c/app.asar.unpacked";
const observedNodePtyRoots: string[] = [];
const config = yield* Effect.gen(function* () {
const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration;
return yield* configuration.resolveWsl({ port: 5000, distro: "Ubuntu" });
}).pipe(
Effect.provide(
DesktopBackendConfiguration.layer.pipe(
Layer.provideMerge(serverExposureLayer),
Layer.provideMerge(DesktopAppSettings.layerTest()),
Layer.provideMerge(
DesktopWslEnvironment.layerTest({
isAvailable: true,
distros: [{ name: "Ubuntu", isDefault: true, version: 2 }],
windowsToWslPath: () => Option.some(mountedAppRoot),
prepareRuntime: () => ({ ok: false, reason: "archive is corrupt" }),
ensureNodePty: (_distro, root) => {
observedNodePtyRoots.push(root);
return { ok: true, nodePath: "/usr/bin/node", resolvedPath: "/usr/bin:/bin" };
},
getDistroIp: () => Option.some("172.27.0.99"),
}),
),
Layer.provideMerge(
makeEnvironmentLayer(baseDir, {
appPath: baseDir,
platform: "win32",
resourcesPath: baseDir,
}),
),
),
),
);

assert.deepEqual(observedNodePtyRoots, [mountedAppRoot]);
assert.equal(config.entryPath, entryPath);
assert.include(config.args, `${mountedAppRoot}/apps/server/dist/bin.mjs`);
assert.isUndefined(config.wslRuntimeId);
assert.isTrue(Option.isNone(config.preflightFailure));
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);

it.effect(
"resolveWsl preserves inherited PATH with quote-sensitive values as separate args",
() =>
Expand All @@ -239,7 +359,8 @@ describe("DesktopBackendConfiguration", () => {
yield* fileSystem.writeFileString(entryPath, "");

const nodePath = "/home/test user's/.nvm/versions/node/v22.0.0/bin/node";
const linuxEntryPath = "/tmp/t3 code's launch/entry file.mjs";
const linuxAppRoot = "/tmp/t3 code's launch";
const linuxEntryPath = `${linuxAppRoot}/apps/server/dist/bin.mjs`;
const resolvedPath = "/home/test user/bin:/opt/test's tools/bin:/usr/bin:/bin";
const devServerUrl = "http://127.0.0.1:5733/dev%20assets/?label=hello%20world";
const config = yield* Effect.gen(function* () {
Expand All @@ -254,7 +375,7 @@ describe("DesktopBackendConfiguration", () => {
DesktopWslEnvironment.layerTest({
isAvailable: true,
distros: [{ name: "Ubuntu", isDefault: true, version: 2 }],
windowsToWslPath: () => Option.some(linuxEntryPath),
windowsToWslPath: () => Option.some(linuxAppRoot),
ensureNodePty: () => ({ ok: true, nodePath, resolvedPath }),
getDistroIp: () => Option.some("172.27.0.99"),
}),
Expand Down
65 changes: 50 additions & 15 deletions apps/desktop/src/backend/DesktopBackendConfiguration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ const DESKTOP_BACKEND_ENV_NAMES = [
const WSL_FORWARDED_ENV_NAMES = ["OPENAI_API_KEY", "ANTHROPIC_API_KEY"] as const;

const WSL_SERVER_SYSTEM_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
const WSL_RUNTIME_ARCHIVE_NAME = "wsl-runtime.tar.gz";

const backendChildEnvPatch = (): Record<string, string | undefined> =>
Object.fromEntries(DESKTOP_BACKEND_ENV_NAMES.map((name) => [name, undefined]));
Expand Down Expand Up @@ -221,6 +222,7 @@ interface WslPreflightSuccess {
// PATH captured from the same login shell after the shared resolver loaded
// version managers. The launch forwards this value directly without a shell.
readonly resolvedPath: string;
readonly runtimeId?: string;
}

interface WslPreflightFailure {
Expand All @@ -239,7 +241,9 @@ const WSL_TRANSIENT_PREFLIGHT_RETRY_LIMIT = 12;
const runWslPreflight = Effect.fn("desktop.backendConfiguration.wslPreflight")(function* (input: {
readonly distro: string | null;
readonly windowsEntryPath: string;
readonly windowsRepoRoot: string;
readonly windowsAppRoot: string;
readonly windowsRuntimeArchivePath: string | null;
readonly runtimeId: string;
readonly allowBuild: boolean;
}): Effect.fn.Return<
WslPreflightSuccess | WslPreflightFailure,
Expand Down Expand Up @@ -299,16 +303,36 @@ const runWslPreflight = Effect.fn("desktop.backendConfiguration.wslPreflight")(f
} as const;
}

const linuxEntry = yield* wslEnv.windowsToWslPath(runningDistro, input.windowsEntryPath);
if (Option.isNone(linuxEntry)) {
const mountedAppRoot = yield* wslEnv.windowsToWslPath(runningDistro, input.windowsAppRoot);
if (Option.isNone(mountedAppRoot)) {
return {
_tag: "Failed",
reason: `wslpath conversion failed for ${input.windowsEntryPath}`,
reason: `wslpath conversion failed for ${input.windowsAppRoot}`,
fatal: false,
} as const;
}

const nodePtyResult = yield* wslEnv.ensureNodePty(runningDistro, input.windowsRepoRoot, {
let linuxAppRoot = mountedAppRoot.value;
let runtimeId: string | undefined;
if (input.windowsRuntimeArchivePath !== null) {
const runtime = yield* wslEnv.prepareRuntime(
runningDistro,
input.windowsRuntimeArchivePath,
input.runtimeId,
);
if (runtime.ok) {
linuxAppRoot = runtime.linuxAppRoot;
runtimeId = input.runtimeId;
} else {
yield* Effect.logWarning(
"Could not stage the WSL runtime; launching from the mounted application instead.",
{ reason: runtime.reason },
);
}
}
const linuxEntryPath = `${linuxAppRoot}/apps/server/dist/bin.mjs`;

const nodePtyResult = yield* wslEnv.ensureNodePty(runningDistro, linuxAppRoot, {
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
allowBuild: input.allowBuild,
nodeEngineRange: serverPackageJson.engines.node,
});
Expand All @@ -324,9 +348,10 @@ const runWslPreflight = Effect.fn("desktop.backendConfiguration.wslPreflight")(f
return {
_tag: "Ready",
runningDistro,
linuxEntryPath: linuxEntry.value,
linuxEntryPath,
nodePath: nodePtyResult.nodePath,
resolvedPath: nodePtyResult.resolvedPath,
...(runtimeId === undefined ? {} : { runtimeId }),
} as const;
});

Expand Down Expand Up @@ -428,6 +453,7 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl
> {
const environment = yield* DesktopEnvironment.DesktopEnvironment;
const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment;
const fileSystem = yield* FileSystem.FileSystem;

// Bind to 0.0.0.0 inside WSL so the backend is reachable both via
// WSL2's automatic localhost forwarding (wslhost: Windows 127.0.0.1
Expand Down Expand Up @@ -464,22 +490,28 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl
...buildObservabilityFragment(input.observabilitySettings),
};

// In packaged builds environment.appRoot is .../resources/app.asar — an
// archive FILE. The Windows primary reads its entry through
// ELECTRON_RUN_AS_NODE (asar-aware), but the WSL backend launches plain
// `wsl.exe -- node`, which can't read inside an asar. electron-builder unpacks
// the server bundle + node-pty (see asarUnpack in build-desktop-artifact.ts)
// to the app.asar.unpacked sibling, so point WSL there. In dev appRoot is
// already a real directory, so this is a no-op.
// Packaged Windows builds carry a compressed Linux runtime beside app.asar.
// Install it once into the distro's ext4 filesystem so Node doesn't resolve
// the server's module graph through slow /mnt/c DrvFS reads on every launch.
// Older/local packages without the archive retain the mounted-directory path
// as a compatibility fallback; dev appRoot is already a real directory.
const wslAppRoot = environment.isPackaged
? environment.path.join(environment.resourcesPath, "app.asar.unpacked")
: environment.appRoot;
const wslEntryPath = environment.path.join(wslAppRoot, "apps/server/dist/bin.mjs");

const wslRuntimeArchivePath = environment.path.join(
environment.resourcesPath,
WSL_RUNTIME_ARCHIVE_NAME,
);
const hasWslRuntimeArchive = environment.isPackaged
? yield* fileSystem.exists(wslRuntimeArchivePath).pipe(Effect.orElseSucceed(() => false))
: false;
const preflight = yield* runWslPreflight({
distro: input.distro,
windowsEntryPath: wslEntryPath,
windowsRepoRoot: wslAppRoot,
windowsAppRoot: wslAppRoot,
windowsRuntimeArchivePath: hasWslRuntimeArchive ? wslRuntimeArchivePath : null,
runtimeId: `${environment.appVersion}-${environment.processArch}`,
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Outdated
// Packaged builds ship a prebuilt Linux node-pty (built on Linux in CI and
// attached to the Windows artifact — see build-desktop-artifact.ts), so the
// WSL backend never needs a compiler, node-gyp, or network on first launch.
Expand Down Expand Up @@ -550,6 +582,9 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl
httpBaseUrl,
captureOutput: true,
...(runningDistro !== null ? { runningDistro } : {}),
...(preflight._tag === "Ready" && preflight.runtimeId !== undefined
? { wslRuntimeId: preflight.runtimeId }
: {}),
};

// Forward the dev-server URL as an explicit CLI flag so the WSL backend's
Expand Down
30 changes: 27 additions & 3 deletions apps/desktop/src/backend/DesktopBackendManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
import * as DesktopBackendManager from "./DesktopBackendManager.ts";
import * as DesktopObservability from "../app/DesktopObservability.ts";
import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts";
import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts";

const decodeDesktopBackendBootstrap = Schema.decodeEffect(
Schema.fromJsonString(DesktopBackendBootstrap),
Expand Down Expand Up @@ -132,6 +133,7 @@ interface MakeInstanceInput {
readonly desktopTelemetryPublisher?: Partial<
DesktopTelemetryPublisher.DesktopTelemetryPublisher["Service"]
>;
readonly pruneRuntimes?: (distro: string | null, runtimeId: string) => Effect.Effect<void>;
}

// Helper that constructs a primary backend instance using the factory
Expand Down Expand Up @@ -167,6 +169,9 @@ function makeTestInstance(input: MakeInstanceInput) {
removeControlSource: () => Effect.void,
...input.desktopTelemetryPublisher,
}),
DesktopWslEnvironment.layerTest(
input.pruneRuntimes === undefined ? {} : { pruneRuntimes: input.pruneRuntimes },
),
);

const instance = DesktopBackendManager.makeBackendInstance({
Expand Down Expand Up @@ -647,25 +652,39 @@ describe("DesktopBackendManager", () => {
Effect.scoped(
Effect.gen(function* () {
const requestUrls: Array<string> = [];
const prunedRuntimes: Array<[string | null, string]> = [];
const statuses = [503, 200];
let readyCount = 0;
const firstRequest = yield* Deferred.make<void>();
const ready = yield* Deferred.make<void>();
const backendReady = yield* Deferred.make<void>();
const processExit = yield* Deferred.make<void>();
const pruneComplete = yield* Deferred.make<void>();
const exited = yield* Queue.unbounded<void>();

const spawnerLayer = Layer.succeed(
ChildProcessSpawner.ChildProcessSpawner,
ChildProcessSpawner.make(() =>
Effect.succeed(
makeProcess({
exitCode: Deferred.await(ready).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))),
exitCode: Deferred.await(processExit).pipe(
Effect.as(ChildProcessSpawner.ExitCode(0)),
),
}),
),
),
);

const instance = yield* makeTestInstance({
spawnerLayer,
config: {
...baseConfig,
runningDistro: "Ubuntu",
wslRuntimeId: "1.2.3-x64",
},
pruneRuntimes: (distro, runtimeId) =>
Effect.sync(() => {
prunedRuntimes.push([distro, runtimeId]);
}).pipe(Effect.andThen(Deferred.succeed(pruneComplete, void 0)), Effect.asVoid),
httpClientLayer: httpClientLayer((request) =>
Effect.gen(function* () {
const status = statuses.shift();
Expand All @@ -677,7 +696,7 @@ describe("DesktopBackendManager", () => {
),
onReady: Effect.sync(() => {
readyCount += 1;
}).pipe(Effect.andThen(Deferred.succeed(ready, void 0)), Effect.asVoid),
}).pipe(Effect.andThen(Deferred.succeed(backendReady, void 0)), Effect.asVoid),
backendOutputLog: {
persistFailure: () => Queue.offer(exited, void 0).pipe(Effect.asVoid),
},
Expand All @@ -687,12 +706,17 @@ describe("DesktopBackendManager", () => {
yield* Deferred.await(firstRequest);

assert.equal(readyCount, 0);
assert.deepEqual(prunedRuntimes, []);
assert.deepEqual(requestUrls, ["http://127.0.0.1:3773/.well-known/t3/environment"]);

yield* TestClock.adjust(Duration.millis(100));
yield* Deferred.await(backendReady);
yield* Deferred.await(pruneComplete);
yield* Deferred.succeed(processExit, void 0);
yield* Queue.take(exited);

assert.equal(readyCount, 1);
assert.deepEqual(prunedRuntimes, [["Ubuntu", "1.2.3-x64"]]);
assert.deepEqual(requestUrls, [
"http://127.0.0.1:3773/.well-known/t3/environment",
"http://127.0.0.1:3773/.well-known/t3/environment",
Expand Down
Loading
Loading