Skip to content

Commit 403a229

Browse files
committed
feat(desktop): surface dual-mode WSL backend preflight failures in settings
A fatal preflight failure on the dual-mode WSL secondary used to stop silently after the cap, leaving the user with WSL enabled but no backend and no reason why. Record the reason on DesktopWslBackend, expose it via the getWslState IPC, and show it inline in the Connections WSL row (no modal dialog; Windows keeps working). Cleared on any reconcile so it reflects the current attempt; stays null in wsl-only mode, which surfaces via a dialog + fallback instead.
1 parent 420bfbb commit 403a229

5 files changed

Lines changed: 49 additions & 2 deletions

File tree

apps/desktop/src/ipc/methods/wsl.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { DesktopWslStateSchema, type DesktopWslState } from "@t3tools/contracts";
22
import * as Effect from "effect/Effect";
3+
import * as Option from "effect/Option";
34
import * as Schema from "effect/Schema";
45

56
import * as DesktopLifecycle from "../../app/DesktopLifecycle.ts";
@@ -12,21 +13,28 @@ import { makeIpcMethod } from "../DesktopIpc.ts";
1213
const readWslState: Effect.Effect<
1314
DesktopWslState,
1415
never,
15-
DesktopAppSettings.DesktopAppSettings | DesktopWslEnvironment.DesktopWslEnvironment
16+
| DesktopAppSettings.DesktopAppSettings
17+
| DesktopWslEnvironment.DesktopWslEnvironment
18+
| DesktopWslBackend.DesktopWslBackend
1619
> = Effect.gen(function* () {
1720
const appSettings = yield* DesktopAppSettings.DesktopAppSettings;
1821
const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment;
22+
const wslBackend = yield* DesktopWslBackend.DesktopWslBackend;
1923
const settings = yield* appSettings.get;
2024
const available = yield* wslEnvironment.isAvailable;
2125
// Only enumerate distros when WSL is actually available — listDistros on a
2226
// non-WSL host would spawn wsl.exe and hit the timeout for nothing.
2327
const distros = available ? yield* wslEnvironment.listDistros : [];
28+
const preflightError = yield* wslBackend.lastPreflightError;
2429
return {
2530
enabled: settings.wslBackendEnabled,
2631
distro: settings.wslDistro,
2732
available,
2833
wslOnly: settings.wslOnly,
2934
distros,
35+
// Only the dual-mode secondary records this; a wsl-only failure surfaces via
36+
// a dialog + Windows fallback, so it stays null there.
37+
preflightError: settings.wslOnly ? null : Option.getOrNull(preflightError),
3038
};
3139
});
3240

apps/desktop/src/wsl/DesktopWslBackend.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import * as Context from "effect/Context";
2525
import * as Effect from "effect/Effect";
2626
import * as Layer from "effect/Layer";
2727
import * as Option from "effect/Option";
28+
import * as Ref from "effect/Ref";
2829
import * as Semaphore from "effect/Semaphore";
2930

3031
import * as NetService from "@t3tools/shared/Net";
@@ -49,6 +50,11 @@ export interface DesktopWslBackendShape {
4950
// Idempotent. Never fails (errors are logged); callers can chain it
5051
// after persisting settings without an error-handling dance.
5152
readonly reconcile: Effect.Effect<void>;
53+
// Reason the dual-mode WSL secondary last failed preflight (no node, wrong
54+
// version, missing build tools), or None. Read by the getWslState IPC so
55+
// Connections settings can show it inline. None in wsl-only mode (that path
56+
// surfaces via a dialog + Windows fallback).
57+
readonly lastPreflightError: Effect.Effect<Option.Option<string>>;
5258
}
5359

5460
export class DesktopWslBackend extends Context.Service<DesktopWslBackend, DesktopWslBackendShape>()(
@@ -102,6 +108,13 @@ export const layer = Layer.effect(
102108
// with different distros, leaving the loser stranded.
103109
const reconcileMutex = yield* Semaphore.make(1);
104110

111+
// Last fatal preflight failure from the dual-mode WSL *secondary*, surfaced
112+
// inline in Connections settings. The primary's failure is handled by the
113+
// pool (dialog + Windows fallback) instead; here the app stays usable on
114+
// Windows, so we record the reason rather than interrupting. Cleared on any
115+
// reconcile state change so it reflects the current attempt.
116+
const preflightErrorRef = yield* Ref.make(Option.none<string>());
117+
105118
const findExistingWslInstance = pool.list.pipe(
106119
Effect.map((instances) => instances.find((instance) => isWslInstanceId(instance.id))),
107120
Effect.map(Option.fromNullishOr),
@@ -150,6 +163,10 @@ export const layer = Layer.effect(
150163
id: targetId,
151164
label: Effect.succeed(buildLabel(input.distro)),
152165
configResolve: configuration.resolveWsl({ port: allocatedPort, distro: input.distro }),
166+
// Dual-mode secondary: record a fatal preflight failure so Connections
167+
// settings can show why the WSL backend never appeared. No dialog or
168+
// fallback — Windows is the primary and keeps working.
169+
onPreflightFailed: (reason) => Ref.set(preflightErrorRef, Option.some(reason)),
153170
})
154171
.pipe(
155172
Effect.map((registered) => Option.some(registered)),
@@ -195,6 +212,11 @@ export const layer = Layer.effect(
195212
return;
196213
}
197214

215+
// A real state change is happening (start, stop, or distro swap). Clear
216+
// any stale secondary preflight error so it reflects this fresh attempt;
217+
// onPreflightFailed re-sets it only if the new secondary exhausts retries.
218+
yield* Ref.set(preflightErrorRef, Option.none());
219+
198220
if (Option.isSome(existingId)) {
199221
yield* logWslBackendInfo("tearing down WSL backend", { id: existingId.value });
200222
yield* stopExisting(existingId.value);
@@ -227,6 +249,9 @@ export const layer = Layer.effect(
227249
Effect.withSpan("desktop.wslBackend.reconcile"),
228250
);
229251

230-
return DesktopWslBackend.of({ reconcile });
252+
return DesktopWslBackend.of({
253+
reconcile,
254+
lastPreflightError: Ref.get(preflightErrorRef),
255+
});
231256
}),
232257
);

apps/web/src/components/settings/ConnectionsSettings.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3031,6 +3031,10 @@ export function ConnectionsSettings() {
30313031
status={
30323032
desktopWslError ? (
30333033
<span className="block text-destructive">{desktopWslError}</span>
3034+
) : desktopWslState.preflightError ? (
3035+
<span className="block text-destructive">
3036+
WSL backend couldn't start: {desktopWslState.preflightError}
3037+
</span>
30343038
) : null
30353039
}
30363040
control={

apps/web/src/localApi.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,27 +235,31 @@ function makeDesktopBridge(overrides: Partial<DesktopBridge> = {}): DesktopBridg
235235
available: false,
236236
wslOnly: false,
237237
distros: [],
238+
preflightError: null,
238239
}),
239240
setWslBackendEnabled: async () => ({
240241
enabled: false,
241242
distro: null,
242243
available: false,
243244
wslOnly: false,
244245
distros: [],
246+
preflightError: null,
245247
}),
246248
setWslDistro: async () => ({
247249
enabled: false,
248250
distro: null,
249251
available: false,
250252
wslOnly: false,
251253
distros: [],
254+
preflightError: null,
252255
}),
253256
setWslOnly: async () => ({
254257
enabled: false,
255258
distro: null,
256259
available: false,
257260
wslOnly: false,
258261
distros: [],
262+
preflightError: null,
259263
}),
260264
pickFolder: async () => null,
261265
confirm: async () => true,

packages/contracts/src/ipc.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -421,6 +421,11 @@ export interface DesktopWslState {
421421
// primary backend's spec is captured once at layer init.
422422
wslOnly: boolean;
423423
distros: readonly DesktopWslDistro[];
424+
// Reason the dual-mode WSL backend last failed preflight (no node, wrong
425+
// version, missing build tools), or null. Surfaced inline in Connections
426+
// settings. Always null in wsl-only mode — that path shows a dialog and
427+
// falls back to Windows instead.
428+
preflightError: string | null;
424429
}
425430

426431
export const DesktopWslStateSchema = Schema.Struct({
@@ -429,6 +434,7 @@ export const DesktopWslStateSchema = Schema.Struct({
429434
available: Schema.Boolean,
430435
wslOnly: Schema.Boolean,
431436
distros: Schema.Array(DesktopWslDistroSchema),
437+
preflightError: Schema.NullOr(Schema.String),
432438
});
433439

434440
export const DesktopCloudAuthFetchInputSchema = Schema.Struct({

0 commit comments

Comments
 (0)