Desktop: parallel WSL + Windows backends with mode picker#2751
Open
Jgratton24 wants to merge 78 commits into
Open
Desktop: parallel WSL + Windows backends with mode picker#2751Jgratton24 wants to merge 78 commits into
Jgratton24 wants to merge 78 commits into
Conversation
Stdin pipes inherited across the wsl.exe boundary fail to re-open via /proc/self/fd/0, so add EACCES to the codes that drop back to reading the fd directly. Without this the WSL desktop backend fails to load its bootstrap envelope with "Failed to duplicate bootstrap fd" and ends up in a scheduled-restart loop.
Lets the desktop app launch the local backend inside a WSL distro instead of natively on Windows. Adds: - Backend plumbing (apps/desktop/src/wsl): pure path parsing utilities, a DesktopWslEnvironment Effect service wrapping wsl.exe operations (listDistros, preWarm, windowsToWslPath, ensureNodePty, isAvailable), and an explicit preflight that checks for missing node / build tools before spawning so the failure message names the actual problem. - Spawn path: DesktopBackendConfiguration branches on the new wslMode setting and assembles "wsl.exe -d <distro> -- node <linux-entry> --bootstrap-fd 0" with the bootstrap envelope on stdin (wsl.exe drops additional file descriptors). Sensitive env vars forward via WSLENV; --dev-url is passed as a CLI flag so the WSL dev backend lands in dev/ instead of userdata/ deterministi- cally. The Windows-side T3CODE_HOME is scrubbed and extendEnv is disabled for WSL so the WSL backend cannot accidentally share a baseDir with the local backend via /mnt/c/... - Settings: wslMode + wslDistro on DesktopAppSettings, with validation that drops distro names containing control or shell meta characters. Contracts get DesktopWslMode / DesktopWslDistro / DesktopWslState schemas. - IPC: getWslState and setWslBackend on the desktop bridge. The setter pre-warms the WSL VM, persists settings, then drives an in-process backend stop + start with a 2-minute readiness wait and a rollback path that reverts to the previous mode if the new backend never reports ready. pickFolder defaults to the WSL home UNC path when wslMode is "wsl". - Web UI: backend-runtime selector in Connection Settings with a three-stage swap modal (restarting / re-establishing session / syncing) that suppresses the WS reconnect toast for the duration of the swap, waits for the new backend's welcome event before closing, and clears the previous env's store state so the side- bar does not render stale threads. New suppressReconnect helper on the connection-status atom plus exports for the descriptor refresh and reauth used by the swap flow.
- drain stdout/stderr concurrently in runWslShell so node-gyp output on both pipes can't deadlock the child - short-circuit waitForReady when desiredRunning flips off, so an external stop() during swap doesn't waste the full timeout - guard runSwap continuations after the 180s flow timeout so an orphaned IPC resolution can't overwrite rolled-back UI state - drop unused refreshPrimaryEnvironmentDescriptor — descriptor URL is stable across the swap and consumers re-fetch lazily
…tate removal - move clearTimeout(flowTimeoutHandle) into the finally block so it fires on success, error, and timeout — previously the error path left a live 180s timer that would reject an unreferenced promise (unhandled rejection) - remove the second removeEnvironmentState call after welcome; the first call right after the IPC swap already wipes the old environment state and nothing recreates state under the old env id during reauth/welcome
… mapping, surface failed rollback - map null distro to the actual default distro name in the backend select so the dropdown highlights a real option instead of an orphan "__default__" with no matching item when distros are listed - add getUserHome to DesktopWslEnvironment (cached per distro) and pass the resolved /home/<user> into the picker helper so ~/path expands correctly instead of producing /home/<rest> - surface a clearer error when the rollback backend also fails to start, so the user knows the app is degraded rather than seeing the misleading "Rolled back to the previous mode" message
…undant WSLENV entry - swap "__local__" / "__default__" select values for "backend:local" / "backend:default-wsl" — the colon is rejected by DISTRO_NAME_PATTERN so the sentinels can never collide with an actual WSL distro name - remove VITE_DEV_SERVER_URL from WSL_FORWARDED_ENV_NAMES; the value is delivered exclusively via the --dev-url CLI flag because WSLENV translation of URL-shaped values is unreliable, and keeping it in both paths contradicted the comment at the CLI-flag site
The dropdown maps state.distro: null to the actual default distro's name so the Select highlights a real option, but the no-op check still compared target.distro (e.g. "Ubuntu") against state.distro (null). Re-picking the visually-active row opened the confirmation dialog and triggered a full backend restart for what was clearly a no-op. Resolve both sides through the same null->default mapping before comparing.
The renderer's 180s ceiling was shorter than the IPC's worst-case duration: setWslBackend can take up to ~2min for the initial readiness wait plus another ~2min for the rollback readiness wait before throwing WslBackendSwapError, so the client was firing "Backend swap took too long" while the main process was still actively rolling back. Bump the ceiling to 6 minutes (4min IPC worst case + ~60s reauth retry budget + 45s welcome race) so a real hang still surfaces but a legitimate rollback completes.
…n through error recovery - remove the unused `enabled` field from WslConfig and the unreferenced DEFAULT_WSL_CONFIG export; the toggle moved to DesktopAppSettings.wslMode during the migration and the field was carried along by every caller as noise that didn't influence behavior - wrap the entire backend-swap flow (success + catch) in suppressReconnect so the catch-block reauth doesn't fire reconnect/offline toasts on top of the error toast the user is reading. The previous structure only suppressed during the happy path; recovery work landed outside the window
…e-fire false resolve onWelcome subscribes with `immediate: true`, so the listener fires synchronously with whatever welcome payload is already in the atom. The previous code compared against `previousPrimaryEnvId` (descriptor-derived); if the descriptor hadn't loaded yet, that was null and any non-null current welcome would resolve the promise instantly, completing the "syncing" stage before the new backend's welcome actually arrived. Capture the current welcome's env-id from the atom as the baseline instead so the immediate fire never matches the "new welcome arrived" predicate.
Phase-1 foundation for running Windows and WSL backends side by side. Introduces: * BackendInstanceId brand + PRIMARY_INSTANCE_ID constant * DesktopBackendInstance interface mirroring the legacy backend manager surface so consumers can migrate one call site at a time * DesktopBackendPool service with get/list/primary operations * Phase-1 layer that wraps the existing singleton DesktopBackendManager and exposes it under PRIMARY_INSTANCE_ID, no behavior change * layerTest helper for unit tests The pool is wired into the desktop application layer alongside the existing manager; current consumers (window/wsl IPC, lifecycle hooks, telemetry) still depend on DesktopBackendManager directly. The header docblock on DesktopBackendPool.ts captures the full migration sequence for follow-up commits: reshape the manager into an instance factory, move per-instance state off DesktopState/DesktopBackendOutputLog, wire WSL as a second pooled instance, widen the bootstrap IPC, and retire the swap-mode dialog.
Replace the singleton DesktopBackendManager Context.Service with a factory function makeBackendInstance(spec) that returns one DesktopBackendInstance per call. Each instance owns its own state Ref, mutex, restart fiber, and active child process so no state is shared across pool members. The pool layer calls the factory once for the Windows primary at startup, wiring the spec's configResolve to DesktopBackendConfiguration and the onReady/onShutdown callbacks to the legacy global side effects (DesktopState.backendReady, DesktopWindow.handleBackendReady). Those last couplings move per-instance in steps 2 and 3. All five consumers (DesktopApp bootstrap + shutdown, wsl.ts swap IPC, window.ts bootstrap IPC, DesktopUpdates installer) now read the primary instance via pool.primary instead of the deleted manager service. Log session boundaries are prefixed with the instance id so per-backend output stays distinguishable until step 3 splits the output log. DesktopBackendManager.test.ts rewritten to use the factory directly under Effect.scoped. DesktopUpdates.test.ts swaps its backend stub from a Layer.succeed(DesktopBackendManager, ...) to DesktopBackendPool.layerTest([stub]).
…ndow DesktopState.backendReady was the last global coupling tying backend lifecycle to app-wide state. With the pool owning per-instance readiness (instance.snapshot.ready), the only remaining consumer of the global latch is the window's auto-create-on-ready path. Move ownership of the latch into DesktopWindow's own internals so DesktopState only carries truly app-wide state (the quitting flag). DesktopWindow gains handleBackendNotReady, called by the primary instance's onShutdown callback so the latch clears on clean stop, restart, or crash. Without it the macOS dock-click activation path could produce a window pointing at a backend that is no longer up. The pool spec wires both callbacks against the window service instead of the state Ref. Test stubs for DesktopWindowShape pick up the new handleBackendNotReady field; DesktopWindow.test.ts drops its DesktopState dependency.
…factory Backend child output and session boundaries used to land in one shared server-child.log via the DesktopBackendOutputLog singleton. With a second backend instance on the way, intermixing two processes' stdout streams into one file makes triage harder than it needs to be. Replace the singleton with DesktopBackendOutputLogFactory.forInstance(id) that vends a rotating writer per backend id. The primary keeps the historical server-child.log path so existing ops tooling, packaged-build log inspection, and habit don't break; non-primary instances land in server-child-<sanitized-id>.log. A SynchronizedRef-backed cache keyed by id ensures repeated forInstance lookups on the same id reuse the writer (important under restart loops that re-resolve the factory). Each emitted record now carries an instanceId annotation so cross-file greps can still associate records belonging to the same backend even if log paths drift later. The redundant "instance=<id>" prefix on session boundary details is dropped — that info now lives in the structured annotation and in the file path. DesktopBackendManager pulls its writer from the factory at instance construction time so each instance carries a fixed writer for its lifetime. DesktopBackendManager.test.ts stubs the factory directly; DesktopObservability.test.ts asserts the new instanceId annotation.
Lays the groundwork for the WSL second-instance orchestrator. Pool now exposes: - register(spec): builds a DesktopBackendInstance via the factory under a fresh child Scope owned by the pool, adds it to the registry, and returns the instance unstarted so the caller decides when to start. - unregister(id): atomically removes the entry from the registry and closes the child scope, which runs the instance's auto-stop finalizer. Each registered instance lives under its own Scope so a single unregister stops just that instance without disturbing the rest of the pool. The primary instance keeps its place in the pool's own layer scope and is guarded by a DesktopBackendPoolCannotUnregisterPrimaryError; that case is treated as a wiring bug rather than something callers handle. The instances Ref upgrades to a SynchronizedRef so register and unregister can run as serialized modify-effects without racing each other on the underlying Map. Duplicate registration on the same id fails with a typed DesktopBackendPoolInstanceAlreadyRegisteredError. No caller registers a second instance yet — that arrives in the next commit when the WSL orchestrator goes in.
The "local" vs "wsl" swap mode is going away. Windows and WSL backends will run in parallel as two pool instances, so the setting that drives WSL only needs to answer "should there be a WSL backend at all". Rename the persisted field to wslBackendEnabled and replace setWslMode with two narrower setters (setWslBackendEnabled, setWslDistro) so the upcoming orchestrator IPC can toggle each independently. Existing on-disk settings that still carry the legacy wslMode key get migrated on load: wslMode=="wsl" becomes wslBackendEnabled=true. The schema still accepts wslMode for one release so users coming off the swap-mode build keep their selection. The new wslBackendEnabled wins when both keys are present, and the next persist drops wslMode. Consumers that read settings.wslMode get pointed at wslBackendEnabled: DesktopBackendConfiguration (the resolver still produces a single WSL config in this commit; the split lands next), the pickFolder IPC, and the wsl.ts IPC's readWslState/setWslBackend handlers. The wire shape for the renderer stays the same in this commit so the web app keeps compiling; the renderer-facing IPC gets reworked alongside the orchestrator.
…esolvers
DesktopBackendConfiguration.resolve was a single effect that picked
between local and WSL config based on the persisted wslMode. Now that
the two backends run in parallel, each pool instance needs its own
resolver. Split into:
- resolvePrimary: Effect<DesktopBackendStartConfig>
Always Windows-native. Reads port/host/exposure from
DesktopServerExposure.backendConfig like before.
- resolveWsl({ port, distro }): Effect<DesktopBackendStartConfig>
Builds a WSL-via-wsl.exe config for the given distro on the
given port. Doesn't touch DesktopServerExposure since the WSL
backend is loopback-only by design; the primary owns LAN
exposure when the user enables network-accessible mode.
Shared bits (bootstrap token via tokenRef, persisted observability
endpoints, env patching, mergeWslEnv) stay private to this module.
Both resolvers reuse the same bootstrapToken so the renderer can
authenticate against either backend with one token.
The WSL config now hardcodes 127.0.0.1 + tailscaleServeEnabled=false
in the bootstrap envelope. The old code copied the primary's host
(could be 0.0.0.0) and tailscale flags into the WSL bootstrap, which
made sense when WSL was a replacement but is wrong when both run
side by side: a tailscale-serve forwarder bound on Windows can't also
bind from inside WSL on the same port. Loopback-only WSL plus the
primary handling LAN exposure is the cleaner v1 contract.
DesktopBackendPool's primary spec now wires configuration.resolvePrimary;
the WSL spec call site lands in the orchestrator commit. Tests updated
to drive the two resolvers explicitly and to assert the shared-token
guarantee.
This is the cut-over to parallel backends. The old "swap the primary
into WSL and bounce it" flow goes away; the WSL backend is now a
second instance registered with the pool, running alongside the
Windows primary. Toggling the WSL backend on/off doesn't touch the
primary at all.
New service DesktopWslBackend (apps/desktop/src/wsl/DesktopWslBackend.ts)
owns the orchestration. Its one entry point, reconcile, reads the
persisted wslBackendEnabled + wslDistro settings, looks at what's
currently registered with the pool, and brings the two in line:
- If WSL should be running and isn't, allocate a loopback port
starting one above the primary's, register a fresh instance via
pool.register({ id, label, configResolve: resolveWsl(...) }),
and kick off instance.start.
- If WSL is running with a stale distro selection, unregister the
old instance (which closes its scope and stops the child process)
before registering the new one.
- If WSL should not be running, unregister whatever wsl: instance
is registered.
reconcile never fails. Port-allocation failures, "WSL not available",
and pool-already-registered errors are logged and the call returns
having left the pool in a consistent state. The primary backend is
never affected.
Instance ids encode the user's distro selection: wsl:default when
wslDistro is null (track the WSL default) and wsl:<distro> otherwise.
These ids are what the env-id work in step 6/7 will key off, so they
stay stable across underlying-default-distro changes — picking
"track default" doesn't reshuffle env ids if the user later sets a
different WSL distro as the default.
Bootstrap call site (DesktopApp.ts) forks reconcile after the primary
start request. The WSL backend can take a moment to come up first
time (wsl.exe cold spawn, node-pty build); the fork keeps that off
the primary's critical path.
IPC surface change:
- Drop setWslBackend({mode, distro}) — the swap call with rollback
semantics — and the SWAP_READINESS_TIMEOUT / waitForReady /
in-process primary stop+start dance in apps/desktop/src/ipc/methods/wsl.ts.
- Add setWslBackendEnabled(boolean) + setWslDistro(string | null).
Each persists the setting via DesktopAppSettings and then calls
wslBackend.reconcile to bring the pool in line. No rollback path:
with both backends running, "WSL didn't come up" is transient
state on one instance, not a degraded app.
- Drop DesktopWslMode / DesktopWslModeSchema from contracts. The
DesktopWslState wire shape changes mode: "local" | "wsl" to a
plain enabled: boolean.
- New IPC channels SET_WSL_BACKEND_ENABLED_CHANNEL +
SET_WSL_DISTRO_CHANNEL.
DesktopBackendConfiguration's resolveWsl bootstrap now hardcodes
tailscaleServePort: 443 when tailscaleServeEnabled is false, because
PortSchema rejects 0. The backend only reads the port when serve is
on, so the value is inert.
Web UI (ConnectionsSettings.tsx) is wired against the new IPC. The
swap ceremony (reauth, welcome-race, suppressReconnect, 6-minute
flow timeout) goes away — toggling is fast and non-destructive now.
The dialog is still the same select-with-confirm shape; step 8 will
rework it into a proper "WSL backend: enabled + distro picker"
control. SettingsPanels.browser.tsx and localApi.test.ts pick up
the new mock shape.
The pool's design-notes block was still describing the step-4 cut-off
("WSL instance not yet registered, IPC still uses swap mode"). Step 5
shipped, so update the "current state" section and convert the
forward-looking migration list into a history block + a short "what's
left" callout for steps 6+. No code changes here, just the header
docblock.
…stances getLocalEnvironmentBootstrap used to hand back a single bootstrap for the primary backend. With the WSL backend running as a second pool instance, the renderer needs to learn about both so step 7 can register them as separate local environments. Rename the IPC to getLocalEnvironmentBootstraps (plural) and walk pool.list, emitting one entry per instance that already has a config. Instances that are registered but haven't produced a config yet (WSL backend mid-registration before its first start cycle) are skipped and will appear on the next call. The bootstrap entry gains an id field that mirrors the backend instance id (e.g. "primary" or "wsl:ubuntu"). The renderer uses that to find the primary entry today (auth.ts, target.ts); step 7 keys local environments off the same id. PRIMARY_LOCAL_ENVIRONMENT_ID is exported from contracts so web code can reference the primary by name without importing brand machinery from the desktop package. The desktop side wraps the same constant in BackendInstanceId so the two stay locked together. Test bridge mocks updated. The DesktopBridge casts in authBootstrap.test.ts went through `as DesktopBridge` previously; the array-returning plural is structurally different enough that TS flagged it, so they go through `as unknown as DesktopBridge` now.
PickFolderOptions gains an optional targetEnvironmentId so callers that know which local backend they're targeting (a project opened in WSL, for example) can ask for that backend's filesystem picker. The default behavior is unchanged: when targetEnvironmentId is undefined, the dialog opens against the Windows-native primary, which is what every existing caller gets. This is deliberate — most users never enable the WSL backend and shouldn't see a different picker showing up. Only callers that explicitly opt in route to WSL. When targetEnvironmentId starts with "wsl:", the handler uses the WSL helpers in wslPathParsing.ts. The id encodes the distro selection (e.g. "wsl:ubuntu") and falls back to the persisted wslDistro setting when the id is the "wsl:default" sentinel, matching how DesktopWslBackend.reconcile resolves the same input. The legacy "if wslBackendEnabled then always use WSL picker" branch is gone — that was the swap-mode mental model.
The WSL section in ConnectionsSettings was still shaped as a "switch
backend" decision: pick local-or-wsl from one dropdown, confirm in a
modal, watch a "restarting backend" spinner. That mental model is wrong
for parallel backends. Toggling the WSL backend on/off doesn't bounce
the Windows one, and switching distros only restarts the WSL instance.
Replace with two plain rows:
- "WSL backend" — a switch that enables/disables the second
backend. Off by default for users who never opted in, so the
normal flow looks the same as before.
- "WSL distro" — a select that lists the installed distros, shown
only when the toggle is on. Changing the selection writes the
new wslDistro setting and lets the orchestrator restart just the
WSL instance.
Both controls fire the relevant new IPCs (setWslBackendEnabled,
setWslDistro) without an AlertDialog confirmation. The reconcile is
non-destructive: the orchestrator unregisters and re-registers the
WSL pool instance, the primary stays up.
Drop the confirm-then-apply state machine
(pendingDesktopWslSelection, the dialog markup, the per-stage spinner
copy). The error toast and disabled-while-updating spinner stay so
the user gets feedback if the orchestrator's reconcile fails.
BACKEND_VALUE_LOCAL is gone — there's no longer a "switch to local"
option to express. BACKEND_VALUE_DEFAULT_WSL stays as the sentinel
for the "track the WSL default" choice in the distro picker.
Rewrite the "current state" section so it matches what's actually in the tree (plural bootstraps IPC, pickFolder routing, toggle-style settings UX). Note the renderer-side gap: the web env runtime still treats the primary as the only local environment, and lifting that requires a per-environment auth bootstrap pass that we deliberately left for a follow-up. The desktop side is ready when the renderer takes it up.
Bring up the second local backend in the renderer so its env id
appears in the saved-environment registry alongside any remote saved
envs the user paired. The sidebar, env switcher, CommandPalette, and
project-routing UI all consume that registry, so they pick up the
WSL backend without per-surface plumbing.
How the data flows:
- On boot, runtime/service.ts calls
reconcileLocalSecondaryEnvironments() (and again after a 5s delay
to catch a slow WSL cold boot).
- The reconciler reads getLocalEnvironmentBootstraps() from the
desktop bridge. Primary stays owned by the primary/ runtime;
everything else with a desktopLocal instance id is routed here.
- For each new instance, the reconciler POSTs the shared bootstrap
token to /api/auth/bootstrap/bearer on the WSL backend's URL,
fetches the descriptor, builds a SavedEnvironmentRecord carrying
a desktopLocal marker, upserts it into the registry, writes the
bearer to the secret store, and triggers
ensureSavedEnvironmentConnection.
- Records carrying desktopLocal are filtered out of the saved-env
persistence path, so toggling WSL off or switching distros
doesn't leave stale entries on disk.
After-toggle wiring: ConnectionsSettings.applyWslSettingChange fires
reconcile after each setWslBackendEnabled/setWslDistro call, then
again after 1.5s for the same slow-boot reason.
Why bearer-token auth instead of cookies:
- The WSL backend runs on its own loopback port. Cookies are
per-origin, and the renderer's origin (the primary's URL in
packaged builds, the vite dev server URL in dev) doesn't match
that port, so a cookie set on the WSL origin wouldn't ride along.
- Bearer auth uses the Authorization header which the backend's
CORS layer already permits, and WS connections use the
?wsToken=... pattern that saved environments rely on. No CORS
surgery on the backend side.
- Auth state is per-env (a separate bearer per backend); the global
primary auth gate in primary/auth.ts stays untouched, so the
normal single-backend flow for non-WSL users is unaffected.
The reconciler is idempotent, dedupes concurrent calls per instance
id, and never throws — errors get logged and the caller can retry by
calling again. If the WSL backend restarts and its old bearer goes
stale, the user toggling settings re-bootstraps a fresh one.
Plumbing changes:
- ensureSavedEnvironmentConnection exported from runtime/service
so the reconciler can reuse the saved-env connection lifecycle
without duplicating it.
- New removeSavedEnvironmentByInstance variant: same teardown as
removeSavedEnvironment but skips the secret-store delete, since
desktopLocal entries may not own a persisted secret to remove.
The command-palette's add-project flow gated the "Open project from File Manager" affordance on browseEnvironmentId === primary. That held for the swap-mode world where the desktop only managed one local backend. With the WSL backend now registered as a saved-env with a desktopLocal marker, the file-manager picker should be available there too — and the desktop side already knows how to route a pickFolder call into the right WSL distro's filesystem when the renderer passes targetEnvironmentId. Open the gate to "primary OR desktopLocal" and forward browseEnvironmentId as targetEnvironmentId on the pickFolder call. Remote saved environments stay browse-only because the desktop side has no way to spawn an OS file dialog over there.
The saved-env registry subscriber kicks off a sync as soon as upsert lands, and that path reads the bearer back via readSavedEnvironmentBearerToken. Writing the bearer first means whichever path connects first finds the credential.
After step 7a landed, the renderer registers each non-primary
bootstrap as a desktop-local SavedEnvironmentRecord via the
reconcileLocalSecondaryEnvironments path. The desktopLocal marker
keeps these entries out of saved-env persistence so they don't end
up in the user's settings file, and the saved-env runtime takes care
of the connection lifecycle, sidebar listing, env-switcher, and
project-id routing for free.
Browser validation done with a real dev:desktop run with
wslBackendEnabled=true and wslDistro="Ubuntu":
- Distinct ports (13773 primary, 13774 wsl) listening side by
side, both serving distinct env descriptors (windows vs linux
platform).
- Per-instance log files in dev/logs/ (server-child.log +
server-child-wsl_Ubuntu.log).
- Renderer completes the bearer-token bootstrap against the WSL
backend (200), obtains a ws-token (200), holds an ESTABLISHED
WebSocket connection to each port (netstat).
Header docblock now lists this state explicitly + the per-commit
migration history.
The WSL backend was bound to 127.0.0.1 inside the distro and the renderer reached it through wslhost (Windows' built-in localhost forwarder). That forwarding is flaky on at least my Win11 install: the readiness probe and saved-env descriptor fetch both saw "Failed to fetch" against a backend that was otherwise healthy. Bind to 0.0.0.0 inside WSL and advertise the distro's eth0 IP as the renderer-visible httpBaseUrl. DesktopWslEnvironment.getDistroIp uses `hostname -I` inside the distro (cached per distro) and falls back to 127.0.0.1 + wslhost when the probe fails, so a busted setup degrades to the prior behavior instead of regressing. The network this exposes on is the WSL-vEthernet network, not the LAN; primary owns LAN exposure when the user opts in, so this doesn't widen the attack surface for non-WSL users.
The desktop-bootstrap credential used the same single-use + 5-minute TTL semantics as a user-facing pairing link. That fit the original mental model (one renderer, one bootstrap exchange) but breaks parallel backends where a slow WSL cold boot lands outside the renderer's first reconcile pass, and breaks page reloads where the renderer no longer has the bearer it traded the bootstrap for and needs to re-exchange. Switch the seed for `desktopBootstrapToken` to `remainingUses: "unbounded"` with a 24h TTL. The seed is delivered over trusted IPC (fd3 / stdin) at backend launch and lives in the renderer/desktop processes the user already trusts, so single-use buys us nothing operationally and costs us recoverable error paths. Tests: - BootstrapCredentialService.test.ts now asserts repeat consumption succeeds and that expiry kicks in past 24h, not 5 minutes. - server.test.ts: the "rejects reusing" test is rewritten as "allows reusing" against the same credential.
Companion to the backend fall-back-to-Windows fix. The Connections "WSL backend" row was hidden whenever desktopWslState.available was false. If the user still had the WSL backend persisted (enabled, possibly wsl-only) but WSL went away (uninstalled, distro removed), the desktop falls back to the Windows backend yet the UI offered no way to clear the WSL preference - stranding the user until WSL worked again or settings were hand-edited. renderWslRow now shows a recovery row in that state (WSL unavailable but still persisted) with a "Switch to Windows" action that routes through the existing disable flow (clears wslBackendEnabled and, if set, wslOnly, then relaunches onto Windows). When WSL is unavailable and unused there's nothing to recover, so the section stays hidden as before.
Bugbot (low): getOrCreateBootstrapToken did a non-atomic read-then-write on a plain Ref — Ref.get, check, crypto.randomBytes (a yield point), Ref.set. Now that resolvePrimary and resolveWsl share this closure, two concurrent resolutions before the token is cached could both observe None, generate distinct tokens, and have one overwrite the other, leaving the backends with mismatched tokens that break the shared-token invariant the renderer relies on. Switch tokenRef to a SynchronizedRef and generate via modifyEffect, which serializes the whole get-or-create so the first caller wins and the rest reuse its token. Added a concurrent-resolution regression test.
…ttings Bugbot (medium): the pool computed primaryLabel once at init purely from persisted settings (wslOnly && wslBackendEnabled -> "WSL"), but resolvePrimary now falls back to the Windows config when WSL is unavailable. So the pool's instance.label could read "WSL" while the backend actually ran Windows-native, and getLocalEnvironmentBootstraps surfaced that stale label to the sidebar/env switcher. Centralize the decision: DesktopBackendConfiguration gains a resolvePrimaryLabel derived from the same describePrimary helper that resolvePrimary now uses (wsl-only + WSL actually available, else Windows), so the label and the start-config can't disagree. The pool consumes configuration.resolvePrimaryLabel and no longer reads settings directly. Added label regression tests (available -> WSL distro, unavailable -> Windows).
Bugbot (medium): the lazy primary label (resolved via instance.label -> resolvePrimaryLabel -> describePrimary -> wslEnvironment.isAvailable) is read inside getLocalEnvironmentBootstraps, a makeSyncIpcMethod handler evaluated with Effect.runSync. isAvailable's real layer probes the filesystem (fileSystem.exists on wsl.exe), so leaving it as a live async effect would make runSync throw and break the renderer's synchronous bootstrap path. Probe wsl.exe once at DesktopWslEnvironment layer init and expose isAvailable as the cached value (Effect.succeed). WSL availability is process-static — the Windows feature isn't added/removed mid-session and backend mode changes already require a restart — so the cached boolean stays accurate while keeping the whole label chain synchronously resolvable (settings.get is already a SynchronizedRef read). Added a regression test that builds the real WSL layer and resolves the primary label with a top-level runSync, exactly as the handler does.
# Conflicts: # apps/server/src/auth/PairingGrantStore.test.ts # apps/server/src/server.test.ts # apps/web/src/environments/primary/auth.ts
…overy button Two WSL-mode edge cases from review: Medium (cursor) — WSL-only distro change ignored: in wsl-only mode the pool's primary IS the WSL backend and its distro is captured when that backend starts. setWslDistro only persisted + reconciled, but reconcile deliberately skips the secondary in wsl-only mode, so nothing restarted the primary — the old distro kept serving while labels/IPC showed the new one. setWslDistro now relaunches (like the wsl-only toggle) when wslOnly && the distro actually changed; dual-backend mode still swaps the secondary via reconcile. The renderer routes wsl-only distro changes through the existing "Switch WSL distro?" confirm so the relaunch isn't a surprise. High (macroscope) — recovery button non-functional: the "Switch to Windows" recovery row in renderWslRow is shown when `enabled || wslOnly`, but handleSelectWslMode's WSL-off path returned early on `!enabled` alone, so when WSL went unavailable with wsl-only persisted (enabled false, wslOnly true) the button did nothing. The guard is now `!enabled && !wslOnly`, matching the row's visibility; the disable flow then clears wslOnly and relaunches onto Windows.
Resolve conflicts from the upstream bun->pnpm+Vite migration (pingdotgg#2899), relay tunnels (pingdotgg#2837), and proof-key bootstrap auth: - apps/web/src/environments/primary/auth.ts: upstream switched the contracts import to `import type` and added ServerAuthSessionMethod; keep that and re-add PRIMARY_LOCAL_ENVIRONMENT_ID as a value import (still used at runtime to find the primary bootstrap entry). - apps/web/src/environments/runtime/catalog.ts: keep both new optional fields on SavedEnvironmentRecord (our desktopLocal marker and upstream's relayManaged). - apps/server/src/auth/PairingGrantStore.test.ts: keep upstream's new proof-key thumbprint test and our reusable-bootstrap-grant test (the desktop bootstrap credential is reusable: remainingUses unbounded, 24h TTL). - apps/web/src/components/settings/ConnectionsSettings.tsx: render both the WSL row and upstream's CloudLinkRow in the connected section.
The merge combined our handleBackendReady(httpBaseUrl) signature change with an upstream-added test that still yielded it as a bare Effect. Pass the resolved URL like the other call site so the merged test typechecks against the function form.
…ode detection isLocalHostIpv4 matched interface family against the string IPv4 only. Some Node builds report family as the numeric 4 (Electron 41 / Node 22 reports the string, but the representation has varied across versions), which would make mirrored-mode detection silently miss and leave the renderer pointed at the distro IP instead of loopback. Normalize via String() and accept either form.
Contributor
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 56c0512. Configure here.
mergeWslEnv split the user's WSLENV on every colon and re-serialized it, collapsing empty :: segments, trimming, and de-duplicating entries. That diverged the forwarded value from what the user configured on Windows. Keep the existing WSLENV byte-for-byte and only append the secret names we forward that aren't already declared. Empty segments are WSL no-ops, so nothing stopped forwarding, but verbatim preservation matches intent (and the test name) and removes the surprise.
Upstream pingdotgg#2964 migrated the workspace test imports from "vitest" to "vite-plus/test" and dropped vitest as a resolvable module. Our WSL test files predate that migration and still imported from "vitest", which broke typecheck (TS2307) after merging main. Repoint them.
Align the four list/path/IP/home probe fallbacks with the convention upstream adopted in pingdotgg#2968: replace Effect.catch(() => Effect.succeed(x)) with the more direct Effect.orElseSucceed(() => x). Behavior is identical (recover from the error channel with a default); silences the new catchToOrElseSucceed lint.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Stacked on top of #2353. That PR let users pick one backend at a time (Windows or WSL, swap to switch). This PR makes them run side by side so projects on both sides are always reachable.
What Changed
Why
#2353 treated the backends as mutually exclusive: switching from Windows to WSL stopped one and started the other. That works if you only work on one side, but in a mixed workflow you constantly have projects on both. Swapping interrupted whatever was running on the other backend and forced you to wait for the new one to come up before you could open anything.
Running them in parallel removes the swap entirely. The Windows backend stays primary for Windows projects, a WSL backend runs alongside it for projects that live on the Linux side, and the renderer routes per-project. The "Run WSL only" mode is the escape hatch for users who don't want two processes at all.
UI Changes
Enable-mode picker (shown when the user picks a distro from the Off state):
Connections settings panel with the consolidated WSL backend picker and "Run WSL only" row:
Sidebar indicator while the WSL backend is cold-booting:
Checklist
Note
High Risk
Touches core Electron startup/shutdown, child process spawning (including wsl.exe), shared auth bootstrap tokens, and breaking bridge/API renames (
getLocalEnvironmentBootstraps, config resolve split); misconfiguration could strand windows or leave orphan backends.Overview
Replaces the singleton desktop backend manager with a
DesktopBackendPooland per-instancemakeBackendInstancelifecycle so Windows and WSL server processes can run in parallel, with startup/shutdown and update install stopping every registered instance (not only the primary).Configuration & WSL spawning splits into
resolvePrimary/resolveWsl/resolvePrimaryLabel, including WSL preflight (wsl.exe, path conversion, node-pty), stdin vs fd3 bootstrap delivery,WSLENVsecret forwarding, separatet3Homefor Linux, andwslOnlymode that can make the primary a WSL backend (with fallback to Windows when WSL is unavailable). Primary and WSL share one bootstrap token via an atomicSynchronizedRef.IPC & bridge:
getLocalEnvironmentBootstrap→getLocalEnvironmentBootstraps(array, skips preflight-failed instances); new WSL settings handlers;pickFoldercan targetwsl:env ids and return Linux paths.Observability:
DesktopBackendOutputLogFactory.forInstance(id)with per-instance log files andinstanceIdannotations.DesktopState.backendReadyremoved; the main window tracks readiness from poolonReady/onShutdownand loads the URL reported by the primary (important for WSL-only).Settings persist
wslBackendEnabled,wslDistro,wslOnly(migrates legacywslMode);wslOnly/ distro changes in wsl-only mode can relaunch the app.Reviewed by Cursor Bugbot for commit bd82399. Bugbot is set up for automated code reviews on this repo. Configure here.
Note
Add parallel WSL + Windows backend support with a mode picker to the Desktop app
DesktopBackendPoolto manage multiple concurrent backend instances (Windows-native and WSL), replacing the singleDesktopBackendManagerwith per-instance lifecycle.DesktopWslBackendorchestrator andDesktopWslEnvironmentservice to manage WSL distro enumeration, path translation, preflight checks, and port allocation for WSL-hosted backends.DesktopAppSettingswithwslBackendEnabled,wslDistro, andwslOnlyfields; migrates legacywslModeon load; exposes setter IPC handlers via the preload bridge.LocalSecondaryStatuscomponent that shows connection progress and allows retry.DesktopBackendConfiguration.resolveis removed; all callers must migrate toresolvePrimary.DesktopBackendBootstrap.t3Homeis now optional, requiring consumers to handle its absence. Desktop-local saved-environment records are no longer persisted to disk.Macroscope summarized bd82399.