Skip to content
Source
Sign inSign up

DesktopWslBackend.ts

221 lines8.8 KB

1import * as Cause from "effect/Cause";
2import * as Context from "effect/Context";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Option from "effect/Option";
6import * as Ref from "effect/Ref";
7import * as Semaphore from "effect/Semaphore";
8​
9import * as NetService from "@t3tools/shared/Net";
10​
11import * as DesktopObservability from "../app/DesktopObservability.ts";
12import * as DesktopBackendConfiguration from "../backend/DesktopBackendConfiguration.ts";
13import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts";
14import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts";
15import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
16import * as DesktopWslEnvironment from "./DesktopWslEnvironment.ts";
17​
18export const WSL_INSTANCE_ID_PREFIX = "wsl:";
19const WSL_DEFAULT_DISTRO_ID = `${WSL_INSTANCE_ID_PREFIX}default`;
20const MAX_TCP_PORT = 65_535;
21​
22export class DesktopWslBackend extends Context.Service<
23 DesktopWslBackend,
24 {
25 readonly reconcile: Effect.Effect<void>;
26 readonly lastPreflightError: Effect.Effect<Option.Option<string>>;
27 }
28>()("@t3tools/desktop/wsl/DesktopWslBackend") {}
29​
30const { logInfo: logWslBackendInfo, logWarning: logWslBackendWarning } =
31 DesktopObservability.makeComponentLogger("desktop-wsl-backend");
32​
33const resolveTargetInstanceId = (distro: string | null): DesktopBackendPool.BackendInstanceId =>
34 DesktopBackendPool.BackendInstanceId(
35 distro === null ? WSL_DEFAULT_DISTRO_ID : `${WSL_INSTANCE_ID_PREFIX}${distro}`,
36 );
37​
38const isWslInstanceId = (id: DesktopBackendPool.BackendInstanceId): boolean =>
39 id.startsWith(WSL_INSTANCE_ID_PREFIX);
40​
41const buildLabel = (distro: string | null): string =>
42 distro === null ? "WSL (default distro)" : `WSL (${distro})`;
43​
44// WSL auto-forwards loopback ports, so only IPv4 loopback availability matters.
45const scanForWslPort = Effect.fn("desktop.wslBackend.scanForWslPort")(function* (
46 startPort: number,
47): Effect.fn.Return<number, NetService.NetError, NetService.NetService> {
48 const net = yield* NetService.NetService;
49 for (let port = startPort; port <= MAX_TCP_PORT; port += 1) {
50 if (yield* net.canListenOnHost(port, "127.0.0.1")) {
51 return port;
52 }
53 }
54 return yield* new NetService.NetError({
55 message: `No loopback port available for WSL backend between ${startPort} and ${MAX_TCP_PORT}.`,
56 });
57});
58​
59export const layer = Layer.effect(
60 DesktopWslBackend,
61 Effect.gen(function* () {
62 const pool = yield* DesktopBackendPool.DesktopBackendPool;
63 const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration;
64 const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;
65 const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment;
66 const appSettings = yield* DesktopAppSettings.DesktopAppSettings;
67 const net = yield* NetService.NetService;
68 // Serialize bootstrap and IPC reconciles so concurrent reads cannot register competing WSL instances.
69 const reconcileMutex = yield* Semaphore.make(1);
70​
71 const preflightErrorRef = yield* Ref.make(Option.none<string>());
72 const ownershipRefusedRef = yield* Ref.make(
73 Option.none<DesktopBackendPool.BackendInstanceId>(),
74 );
75​
76 const findExistingWslInstance = pool.list.pipe(
77 Effect.map((instances) => instances.find((instance) => isWslInstanceId(instance.id))),
78 Effect.map(Option.fromNullishOr),
79 );
80​
81 const stopExisting = (id: DesktopBackendPool.BackendInstanceId) =>
82 pool.unregister(id).pipe(
83 Effect.catchTags({
84 DesktopBackendPoolCannotUnregisterPrimaryError: (cause) =>
85 logWslBackendWarning("refusing to unregister primary as wsl instance", {
86 id,
87 error: cause.message,
88 }),
89 }),
90 );
91​
92 const startNew = Effect.fn("desktop.wslBackend.startNew")(function* (input: {
93 readonly distro: string | null;
94 }) {
95 const primaryConfig = yield* serverExposure.backendConfig;
96 const port = yield* scanForWslPort(primaryConfig.port + 1).pipe(
97 Effect.provideService(NetService.NetService, net),
98 Effect.asSome,
99 Effect.catch((error) =>
100 logWslBackendWarning("could not allocate port for WSL backend", {
101 error: error.message,
102 }).pipe(Effect.as(Option.none<number>())),
103 ),
104 );
105​
106 if (Option.isNone(port)) {
107 return;
108 }
109 const allocatedPort = port.value;
110​
111 const targetId = resolveTargetInstanceId(input.distro);
112 yield* logWslBackendInfo("registering WSL backend with pool", {
113 id: targetId,
114 port: allocatedPort,
115 distro: input.distro ?? null,
116 });
117​
118 const instance = yield* pool
119 .register({
120 id: targetId,
121 label: Effect.succeed(buildLabel(input.distro)),
122 configResolve: configuration.resolveWsl({ port: allocatedPort, distro: input.distro }),
123 onPreflightFailed: (failure) =>
124 Ref.set(preflightErrorRef, Option.some(failure.reason)).pipe(Effect.as(false)),
125 onStateDirOwned: () =>
126 Ref.set(ownershipRefusedRef, Option.some(targetId)).pipe(
127 Effect.andThen(
128 Ref.set(
129 preflightErrorRef,
130 Option.some(
131 "Another T3 Code server is using this distro's T3 home, or an older background service is set up for it. Stop that server or pair with it, or try `t3 update` in the distro. If the older launcher cannot update, run `t3 service uninstall` and optionally reinstall with `t3 service install` from a current CLI in the distro. Use `npx t3@latest` if `t3` is missing or outdated, and pass `--base-dir` with the service's T3 home to each command.",
132 ),
133 ),
134 ),
135 ),
136 onReady: () =>
137 Ref.set(ownershipRefusedRef, Option.none()).pipe(
138 Effect.andThen(Ref.set(preflightErrorRef, Option.none())),
139 ),
140 })
141 .pipe(
142 Effect.asSome,
143 Effect.catch((error) =>
144 logWslBackendWarning("WSL backend already registered, skipping start", {
145 id: targetId,
146 error: error.message,
147 }).pipe(Effect.as(Option.none<DesktopBackendPool.DesktopBackendInstance>())),
148 ),
149 );
150​
151 yield* Option.match(instance, {
152 onNone: () => Effect.void,
153 onSome: (registered) => registered.start,
154 });
155 });
156​
157 const reconcileBody = Effect.gen(function* () {
158 const settings = yield* appSettings.get;
159 if (!settings.localEnvironmentEnabled) return;
160 const available = yield* wslEnvironment.isAvailable;
161 const existing = yield* findExistingWslInstance;
162 const existingId = Option.map(existing, (instance) => instance.id);
163​
164 // In wsl-only mode WSL is already primary; do not start a second backend on the same distro.
165 const shouldRun = settings.wslBackendEnabled && available && !settings.wslOnly;
166 const targetId = shouldRun
167 ? Option.some(resolveTargetInstanceId(settings.wslDistro))
168 : Option.none<DesktopBackendPool.BackendInstanceId>();
169​
170 if (Option.isNone(targetId) && Option.isNone(existingId)) {
171 return;
172 }
173 if (
174 Option.isSome(targetId) &&
175 Option.isSome(existing) &&
176 targetId.value === existing.value.id
177 ) {
178 const existingInstance = existing.value;
179 const snapshot = yield* existingInstance.snapshot;
180 const isIdle =
181 !snapshot.ready && Option.isNone(snapshot.activePid) && !snapshot.restartScheduled;
182 if (isIdle) {
183 const refused = yield* Ref.get(ownershipRefusedRef);
184 if (Option.isSome(refused) && refused.value === existingInstance.id) return;
185 yield* logWslBackendInfo("retrying idle WSL backend", { id: existingInstance.id });
186 yield* Ref.set(preflightErrorRef, Option.none());
187 yield* existingInstance.start;
188 }
189 return;
190 }
191​
192 yield* Ref.set(preflightErrorRef, Option.none());
193 yield* Ref.set(ownershipRefusedRef, Option.none());
194​
195 if (Option.isSome(existingId)) {
196 yield* logWslBackendInfo("tearing down WSL backend", { id: existingId.value });
197 yield* stopExisting(existingId.value);
198 }
199​
200 if (Option.isSome(targetId)) {
201 // Pre-warm WSL so readiness cannot race its cold start.
202 yield* wslEnvironment.preWarm(settings.wslDistro);
203 yield* startNew({ distro: settings.wslDistro });
204 }
205 });
206​
207 const reconcile = reconcileMutex
208 .withPermits(1)(reconcileBody)
209 .pipe(
210 Effect.catchCause((cause) =>
211 logWslBackendWarning("reconcile failed", { cause: Cause.pretty(cause) }),
212 ),
213 Effect.withSpan("desktop.wslBackend.reconcile"),
214 );
215​
216 return DesktopWslBackend.of({
217 reconcile,
218 lastPreflightError: Ref.get(preflightErrorRef),
219 });
220 }),
221);