Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
42 changes: 42 additions & 0 deletions apps/desktop/src/backend/DesktopBackendConfiguration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -855,6 +855,48 @@ describe("DesktopBackendConfiguration", () => {
}).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))),
);

it.effect("gives only a WSL-only primary the browser channel socket secret", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const baseDir = yield* fileSystem.makeTempDirectoryScoped({
prefix: "t3-desktop-backend-config-test-",
});

yield* Effect.gen(function* () {
const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration;
const primary = yield* configuration.resolvePrimary;
const besideWindows = yield* configuration.resolveWsl({ port: 5000, distro: "Ubuntu" });

assert.equal(primary.executablePath, "wsl.exe");
assert.match(primary.bootstrap.desktopBrowserSocketSecret ?? "", /^[0-9a-f]{64}$/);
// Only the primary's tabs render natively.
assert.isUndefined(besideWindows.bootstrap.desktopBrowserSocketSecret);
}).pipe(
Effect.provide(
layerBackendConfiguration().pipe(
Layer.provideMerge(layerServerExposure),
Layer.provideMerge(
DesktopAppSettings.layerTest({
...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS,
wslBackendEnabled: true,
wslOnly: true,
wslDistro: "Ubuntu",
}),
),
Layer.provideMerge(DesktopWslServerTree.layerTest()),
Layer.provideMerge(
DesktopWslEnvironment.layerTest({
isAvailable: true,
distros: [{ name: "Ubuntu", isDefault: true, version: 2 }],
}),
),
Layer.provideMerge(layerEnvironment(baseDir, { platform: "win32" })),
),
),
);
}).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))),
);

it.effect(
"resolvePrimary marks a removed persisted WSL distro as a fatal preflight failure",
() =>
Expand Down
7 changes: 7 additions & 0 deletions apps/desktop/src/backend/DesktopBackendConfiguration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -586,6 +586,7 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl
input: SharedBootstrapInput & {
readonly port: number;
readonly distro: string | null;
readonly desktopBrowserSocketSecret?: string;
},
): Effect.fn.Return<
DesktopBackendManager.DesktopBackendStartConfig,
Expand Down Expand Up @@ -614,6 +615,9 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl
// PortSchema rejects 0 even when Tailscale Serve is disabled.
tailscaleServeEnabled: false,
tailscaleServePort: 443,
...(input.desktopBrowserSocketSecret === undefined
? {}
: { desktopBrowserSocketSecret: input.desktopBrowserSocketSecret }),
...buildObservabilityFragment(input.observabilitySettings),
};

Expand Down Expand Up @@ -812,10 +816,13 @@ export const make = Effect.gen(function* () {
const persistedSettings = yield* settings.get;
const shared = yield* sharedInputs;
yield* wslEnvironment.preWarm(persistedSettings.wslDistro);
// Only the primary's tabs render natively, so a WSL backend beside Windows gets no channel.
const desktopBrowserSocketSecret = Hex.encode(yield* random.randomBytes(32));
return yield* resolveWslStartConfig({
...shared,
port: backendExposure.port,
distro: persistedSettings.wslDistro,
desktopBrowserSocketSecret,
}).pipe(
Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment),
Effect.provideService(DesktopWslEnvironment.DesktopWslEnvironment, wslEnvironment),
Expand Down
82 changes: 82 additions & 0 deletions apps/desktop/src/backend/DesktopBackendManager.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import {
SERVER_EXIT_CODE_MANAGED_POLICY,
SERVER_EXIT_CODE_STATE_DIR_OWNED,
DESKTOP_BROWSER_SOCKET_PATH,
DESKTOP_BROWSER_SOCKET_SECRET_HEADER,
DesktopBackendBootstrap,
type DesktopBackendBootstrap as DesktopBackendBootstrapValue,
DesktopTelemetryControlMessage,
Expand All @@ -11,6 +13,7 @@ import {
DATABASE_MIGRATIONS_STARTED_MESSAGE,
} from "@t3tools/shared/databaseMigrationLog";
import { assert, describe, it } from "@effect/vitest";
import * as NodeWS from "@effect/platform-node/NodeWS";
import * as Deferred from "effect/Deferred";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
Expand Down Expand Up @@ -320,6 +323,85 @@ describe("DesktopBackendManager", () => {
}),
);

it.live(
"authenticates the browser socket and carries events and commands without inherited fds",
() =>
Effect.scoped(
Effect.gen(function* () {
const commandHandled = yield* Deferred.make<string>();
const eventReceived = yield* Deferred.make<{
readonly path: string | undefined;
readonly secret: string | string[] | undefined;
readonly event: string;
}>();
const childExit = yield* Deferred.make<ChildProcessSpawner.ExitCode>();
const server = yield* Effect.acquireRelease(
Effect.sync(() => new NodeWS.WebSocketServer({ host: "127.0.0.1", port: 0 })),
(server) =>
Effect.promise(
() =>
new Promise<void>((resolve) => {
for (const client of server.clients) client.terminate();
server.close(() => resolve());
}),
),
);
if (!server.address()) {
yield* Effect.promise(
() => new Promise<void>((resolve) => server.once("listening", resolve)),
);
}
const address = server.address();
if (typeof address !== "object" || address === null)
throw new Error("Missing socket port");
const secret = "browser-bootstrap-secret";
const event = '{"type":"attached","threadId":"thread","tabId":"tab"}\n';
const command = '{"type":"release","threadId":"thread","tabId":"tab"}';
server.on("connection", (socket, request) => {
socket.once("message", (data) => {
Deferred.doneUnsafe(
eventReceived,
Effect.succeed({
path: request.url,
secret: request.headers[DESKTOP_BROWSER_SOCKET_SECRET_HEADER],
event: data.toString(),
}),
);
socket.send(`${command}\n`);
});
});
const layerSpawner = Layer.succeed(
ChildProcessSpawner.ChildProcessSpawner,
ChildProcessSpawner.make(() =>
Effect.succeed(makeProcess({ exitCode: Deferred.await(childExit) })),
),
);
const running = yield* DesktopBackendManager.runBackendProcess({
...baseConfig,
bootstrapDelivery: "stdin",
bootstrap: { ...baseConfig.bootstrap, desktopBrowserSocketSecret: secret },
httpBaseUrl: new URL(`http://127.0.0.1:${address.port}`),
desktopTelemetryStream: Stream.empty,
desktopBrowserStream: Stream.make(new TextEncoder().encode(event)),
onDesktopBrowserCommand: (line) =>
Deferred.succeed(commandHandled, line).pipe(Effect.asVoid),
}).pipe(
Effect.provide(Layer.merge(layerSpawner, layerHealthyHttpClient)),
Effect.scoped,
Effect.forkScoped,
);
assert.deepEqual(yield* Deferred.await(eventReceived), {
path: DESKTOP_BROWSER_SOCKET_PATH,
secret,
event,
});
assert.equal(yield* Deferred.await(commandHandled), command);
yield* Deferred.succeed(childExit, ChildProcessSpawner.ExitCode(0));
assert.equal((yield* Fiber.join(running)).code.pipe(Option.getOrUndefined), 0);
}),
),
);

it.effect("reports a SIGKILL exit without an exit code", () =>
Effect.gen(function* () {
const exitCause = PlatformError.systemError({
Expand Down
106 changes: 104 additions & 2 deletions apps/desktop/src/backend/DesktopBackendManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,22 @@ import * as FileSystem from "effect/FileSystem";
import * as Option from "effect/Option";
import * as PlatformError from "effect/PlatformError";
import * as Ref from "effect/Ref";
import * as Schedule from "effect/Schedule";
import * as SubscriptionRef from "effect/SubscriptionRef";
import * as Schema from "effect/Schema";
import * as Semaphore from "effect/Semaphore";
import * as Scope from "effect/Scope";
import * as Stream from "effect/Stream";
import { HttpClient } from "effect/http";
import { ChildProcess, ChildProcessSpawner } from "effect/process";
import * as Socket from "effect/socket/Socket";
import * as NodeWS from "@effect/platform-node/NodeWS";

import {
SERVER_EXIT_CODE_MANAGED_POLICY,
SERVER_EXIT_CODE_STATE_DIR_OWNED,
DESKTOP_BROWSER_SOCKET_PATH,
DESKTOP_BROWSER_SOCKET_SECRET_HEADER,
DesktopBackendBootstrap,
type DesktopBackendBootstrap as DesktopBackendBootstrapValue,
PRIMARY_LOCAL_ENVIRONMENT_ID,
Expand Down Expand Up @@ -54,6 +59,10 @@ const BACKEND_READINESS_PATH = "/.well-known/t3/environment";
// Three restarts without readiness is about 2 s of backoff: past a blip, early enough to help.
const STARTUP_FAILURE_RESTART_ATTEMPTS = 3;
const STARTUP_FAILURE_OUTPUT_CHARS = 4_000;
const DESKTOP_BROWSER_SOCKET_RETRY = Duration.seconds(1);
const DESKTOP_BROWSER_SOCKET_MAX_RETRY = Duration.seconds(30);
// Each conversion starts wsl.exe, so multi-file uploads must share a process budget.
const MAX_CONCURRENT_WSL_PATH_CONVERSIONS = 4;
const { logWarning: logBackendProcessWarning } =
DesktopObservability.makeComponentLogger("desktop-backend-process");

Expand Down Expand Up @@ -198,7 +207,10 @@ export type BackendProcessError = typeof BackendProcessError.Type;
interface RunBackendProcessOptions extends DesktopBackendStartConfig {
readonly desktopTelemetryStream: Stream.Stream<Uint8Array>;
readonly desktopBrowserStream?: Stream.Stream<Uint8Array>;
readonly onDesktopBrowserCommand?: (line: string) => Effect.Effect<void>;
readonly onDesktopBrowserCommand?: (
line: string,
serverPath?: (path: string) => string | Promise<string>,
) => Effect.Effect<void>;
readonly onDesktopTelemetryControl?: (
message: DesktopTelemetryControlMessageValue,
) => Effect.Effect<void>;
Expand Down Expand Up @@ -406,6 +418,61 @@ function drainBackendOutput(
);
}

const socketTextDecoder = new TextDecoder();

// wsl.exe cannot inherit the browser fds; reconnect until the backend scope ends.
const bridgeDesktopBrowserSocket = Effect.fn("desktop.backendProcess.bridgeDesktopBrowserSocket")(
function* (input: {
readonly httpBaseUrl: URL;
readonly secret: string;
readonly events: Stream.Stream<Uint8Array>;
readonly onCommand: (line: string) => Effect.Effect<void>;
}) {
const url = new URL(DESKTOP_BROWSER_SOCKET_PATH, input.httpBaseUrl);
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
const connect = Effect.gen(function* () {
const socket = yield* Socket.makeWebSocket(url.href, {
headers: { [DESKTOP_BROWSER_SOCKET_SECRET_HEADER]: input.secret },
});
const reader = yield* socket.reader;
const writer = yield* socket.writer;
yield* input.events.pipe(
Stream.runForEach((line) => writer.write(line)),
Effect.forkScoped,
);
return yield* reader.pull.pipe(
Effect.flatMap((frames) =>
Effect.forEach(
frames.flatMap((frame) =>
(typeof frame === "string" ? frame : socketTextDecoder.decode(frame)).split("\n"),
),
(line) => (line.length === 0 ? Effect.void : input.onCommand(line)),
{ discard: true },
),
),
Effect.forever,
);
}).pipe(Effect.scoped);
return yield* connect.pipe(
Effect.catchCause((cause) =>
logBackendProcessWarning("desktop browser socket stopped", {
url: url.href,
cause: Cause.pretty(cause),
}),
),
Effect.repeat(
Schedule.exponential(DESKTOP_BROWSER_SOCKET_RETRY).pipe(
Schedule.modifyDelay(({ duration }) =>
Effect.succeed(Duration.min(duration, DESKTOP_BROWSER_SOCKET_MAX_RETRY)),
),
),
),
);
},
// Authentication headers must not depend on Electron's global WebSocket implementation.
Effect.provide(NodeWS.layerWebSocketConstructor),
);

const encodeBootstrapJson = Schema.encodeEffect(Schema.fromJsonString(DesktopBackendBootstrap));
const decodeDesktopTelemetryControlLine = Schema.decodeUnknownEffect(
Schema.fromJsonString(DesktopTelemetryControlMessage),
Expand Down Expand Up @@ -594,7 +661,42 @@ export const runBackendProcess = Effect.fn("runBackendProcess")(function* (
),
);

yield* probeReadiness().pipe(Effect.repeat({ while: (ready) => !ready }), Effect.forkScoped);
const processScope = yield* Scope.Scope;
const pathConversions = yield* Semaphore.make(MAX_CONCURRENT_WSL_PATH_CONVERSIONS);
const runPromise = Effect.runPromiseWith(yield* Effect.context<never>());
const desktopBrowserSocketSecret = options.bootstrap.desktopBrowserSocketSecret;
const onDesktopBrowserCommand = options.onDesktopBrowserCommand;
const runningDistro = options.runningDistro;
// DrvFS mounts cannot be re-exported through WSL UNC paths; let the distro resolve its mounts.
const serverPath =
runningDistro === undefined
? undefined
: (path: string) =>
runPromise(
DesktopWslEnvironment.wslToWindowsPath(runningDistro, path).pipe(
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
pathConversions.withPermit,
Effect.forkIn(processScope),
Effect.flatMap(Fiber.join),
),
);
const bridgeDesktopBrowser =
desktopBrowserSocketSecret !== undefined &&
options.desktopBrowserStream !== undefined &&
onDesktopBrowserCommand !== undefined
? bridgeDesktopBrowserSocket({
httpBaseUrl: options.httpBaseUrl,
secret: desktopBrowserSocketSecret,
events: options.desktopBrowserStream,
onCommand: (line) => onDesktopBrowserCommand(line, serverPath),
})
: Effect.void;

yield* probeReadiness().pipe(
Effect.repeat({ while: (ready) => !ready }),
Effect.andThen(bridgeDesktopBrowser),
Effect.forkScoped,
);

const exit = yield* handle.exitCode.pipe(
Effect.withSpan("desktop.backendProcess.awaitExit"),
Expand Down
Loading
Loading