Skip to content
Merged
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,4 @@ squashfs-root/
.gstack/
dist-electron/
.electron-runtime/
.claude/
70 changes: 10 additions & 60 deletions apps/desktop/src/app/DesktopApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import * as Effect from "effect/Effect";
import * as Option from "effect/Option";
import * as Ref from "effect/Ref";

import * as NetService from "@threadlines/shared/Net";
import * as ElectronApp from "../electron/ElectronApp.ts";
import * as ElectronDialog from "../electron/ElectronDialog.ts";
import * as ElectronProtocol from "../electron/ElectronProtocol.ts";
Expand All @@ -22,28 +21,16 @@ import * as DesktopShellEnvironment from "../shell/DesktopShellEnvironment.ts";
import * as DesktopState from "./DesktopState.ts";
import * as DesktopUpdates from "../updates/DesktopUpdates.ts";
import * as DesktopStatusIndicator from "../window/DesktopStatusIndicator.ts";

const DEFAULT_DESKTOP_BACKEND_PORT = 3773;
const MAX_TCP_PORT = 65_535;
const DESKTOP_LOCAL_ONLY_PORT_PROBE_HOSTS = ["127.0.0.1"] as const;
const DESKTOP_NETWORK_ACCESSIBLE_PORT_PROBE_HOSTS = ["0.0.0.0"] as const;
import {
DEFAULT_DESKTOP_BACKEND_PORT,
desktopBackendPortProbeHosts,
resolveDesktopBackendPort,
} from "../backend/backendPort.ts";

const makeDesktopRunId = randomUUIDv4.pipe(
Effect.map((value) => value.replaceAll("-", "").slice(0, 12)),
);

class DesktopBackendPortUnavailableError extends Data.TaggedError(
"DesktopBackendPortUnavailableError",
)<{
readonly startPort: number;
readonly maxPort: number;
readonly hosts: readonly string[];
}> {
override get message() {
return `No desktop backend port is available on hosts ${this.hosts.join(", ")} between ${this.startPort} and ${this.maxPort}.`;
}
}

class DesktopDevelopmentBackendPortRequiredError extends Data.TaggedError(
"DesktopDevelopmentBackendPortRequiredError",
)<{}> {
Expand All @@ -58,43 +45,6 @@ const { logInfo: logBootstrapInfo, logWarning: logBootstrapWarning } =
const { logInfo: logStartupInfo, logError: logStartupError } =
DesktopObservability.makeComponentLogger("desktop-startup");

const resolveDesktopBackendPort = Effect.fn("resolveDesktopBackendPort")(function* (input: {
readonly configuredPort: Option.Option<number>;
readonly probeHosts: readonly string[];
}) {
if (Option.isSome(input.configuredPort)) {
return {
port: input.configuredPort.value,
selectedByScan: false,
} as const;
}

const net = yield* NetService.NetService;
for (let port = DEFAULT_DESKTOP_BACKEND_PORT; port <= MAX_TCP_PORT; port += 1) {
let availableOnEveryHost = true;

for (const host of input.probeHosts) {
if (!(yield* net.canListenOnHost(port, host))) {
availableOnEveryHost = false;
break;
}
}

if (availableOnEveryHost) {
return {
port,
selectedByScan: true,
} as const;
}
}

return yield* new DesktopBackendPortUnavailableError({
startPort: DEFAULT_DESKTOP_BACKEND_PORT,
maxPort: MAX_TCP_PORT,
hosts: input.probeHosts,
});
});

const handleFatalStartupError = Effect.fn("desktop.startup.handleFatalStartupError")(function* (
stage: string,
error: unknown,
Expand Down Expand Up @@ -145,10 +95,7 @@ const bootstrap = Effect.gen(function* () {
}

const settings = yield* desktopSettings.get;
const probeHosts =
settings.serverExposureMode === "network-accessible"
? DESKTOP_NETWORK_ACCESSIBLE_PORT_PROBE_HOSTS
: DESKTOP_LOCAL_ONLY_PORT_PROBE_HOSTS;
const probeHosts = desktopBackendPortProbeHosts(settings.serverExposureMode);
const backendPortSelection = yield* resolveDesktopBackendPort({
configuredPort: environment.configuredBackendPort,
probeHosts,
Expand All @@ -169,7 +116,10 @@ const bootstrap = Effect.gen(function* () {
mode: settings.serverExposureMode,
});
}
const serverExposureState = yield* serverExposure.configureFromSettings({ port: backendPort });
const serverExposureState = yield* serverExposure.configureFromSettings({
port: backendPort,
selectedByScan: backendPortSelection.selectedByScan,
});
const backendConfig = yield* serverExposure.backendConfig;
yield* logBootstrapInfo("bootstrap resolved backend endpoint", {
baseUrl: backendConfig.httpBaseUrl.href,
Expand Down
85 changes: 85 additions & 0 deletions apps/desktop/src/app/DesktopLifecycle.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { assert, describe, it } from "@effect/vitest";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as Fiber from "effect/Fiber";
import * as Layer from "effect/Layer";
import * as TestClock from "effect/testing/TestClock";

import * as ElectronApp from "../electron/ElectronApp.ts";
import {
DESKTOP_SHUTDOWN_FAILSAFE_DURATION,
DesktopShutdown,
layerShutdown,
requestDesktopShutdownAndWaitWithFailsafe,
} from "./DesktopLifecycle.ts";

const makeElectronAppLayer = (exitCodes: Array<number>) =>
Layer.succeed(ElectronApp.ElectronApp, {
metadata: Effect.die("unexpected metadata read"),
name: Effect.succeed("Threadlines"),
whenReady: Effect.void,
quit: Effect.void,
exit: (code) =>
Effect.sync(() => {
exitCodes.push(code);
}),
relaunch: () => Effect.void,
setPath: () => Effect.void,
setName: () => Effect.void,
setAboutPanelOptions: () => Effect.void,
setAppUserModelId: () => Effect.void,
setDesktopName: () => Effect.void,
setDockIcon: () => Effect.void,
setDockBadge: () => Effect.void,
bounceDock: () => Effect.die("unexpected dock bounce"),
cancelDockBounce: () => Effect.void,
appendCommandLineSwitch: () => Effect.void,
on: () => Effect.void,
} satisfies ElectronApp.ElectronAppShape);

const runHarness = (
body: (
exitCodes: ReadonlyArray<number>,
) => Effect.Effect<void, never, DesktopShutdown | ElectronApp.ElectronApp>,
): Effect.Effect<void> => {
const exitCodes: Array<number> = [];
return body(exitCodes).pipe(
Effect.provide(
Layer.mergeAll(layerShutdown, makeElectronAppLayer(exitCodes), TestClock.layer()),
),
);
};

describe("requestDesktopShutdownAndWaitWithFailsafe", () => {
it.effect("forces the process down when shutdown never completes", () =>
runHarness((exitCodes) =>
Effect.gen(function* () {
const fiber = yield* Effect.forkChild(requestDesktopShutdownAndWaitWithFailsafe());
yield* Effect.yieldNow;

yield* TestClock.adjust(Duration.seconds(14));
assert.deepEqual([...exitCodes], []);

yield* TestClock.adjust(Duration.seconds(1));
yield* Fiber.join(fiber);
assert.deepEqual([...exitCodes], [1]);
}),
),
);

it.effect("stays out of the way when shutdown completes in time", () =>
runHarness((exitCodes) =>
Effect.gen(function* () {
const shutdown = yield* DesktopShutdown;
const fiber = yield* Effect.forkChild(requestDesktopShutdownAndWaitWithFailsafe());
yield* Effect.yieldNow;

yield* shutdown.markComplete;
yield* Fiber.join(fiber);

yield* TestClock.adjust(DESKTOP_SHUTDOWN_FAILSAFE_DURATION);
assert.deepEqual([...exitCodes], []);
}),
),
);
});
44 changes: 41 additions & 3 deletions apps/desktop/src/app/DesktopLifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import * as Cause from "effect/Cause";
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
import * as Deferred from "effect/Deferred";
import * as Duration from "effect/Duration";
import * as Layer from "effect/Layer";
import * as Ref from "effect/Ref";
import * as Scope from "effect/Scope";
Expand Down Expand Up @@ -89,6 +90,14 @@ function addScopedListener<Args extends ReadonlyArray<unknown>>(
).pipe(Effect.asVoid);
}

/**
* How long a quit may wait for the backend to stop before we stop asking
* nicely. A shutdown that never completes leaves a windowless process holding
* the single-instance lock, which makes every later launch look like a dead
* app, so the failsafe has to win eventually.
*/
export const DESKTOP_SHUTDOWN_FAILSAFE_DURATION = Duration.seconds(15);

const requestDesktopShutdownAndWait = Effect.fn("desktop.lifecycle.requestShutdownAndWait")(
function* (): Effect.fn.Return<void, never, DesktopShutdown> {
const shutdown = yield* DesktopShutdown;
Expand All @@ -97,6 +106,26 @@ const requestDesktopShutdownAndWait = Effect.fn("desktop.lifecycle.requestShutdo
},
);

/**
* Requests shutdown and waits for it, but never forever: on timeout the process
* is forced down so it cannot linger holding the single-instance lock. A
* shutdown that completes in time is untouched.
*/
export const requestDesktopShutdownAndWaitWithFailsafe = Effect.fn(
"desktop.lifecycle.requestShutdownAndWaitWithFailsafe",
)(function* (): Effect.fn.Return<void, never, DesktopShutdown | ElectronApp.ElectronApp> {
const electronApp = yield* ElectronApp.ElectronApp;
yield* requestDesktopShutdownAndWait().pipe(
Effect.timeoutOrElse({
duration: DESKTOP_SHUTDOWN_FAILSAFE_DURATION,
orElse: () =>
logLifecycleError("shutdown did not complete before the failsafe; forcing exit", {
failsafeMs: Duration.toMillis(DESKTOP_SHUTDOWN_FAILSAFE_DURATION),
}).pipe(Effect.andThen(electronApp.exit(1))),
}),
);
});

function handleBeforeQuit(
event: Electron.Event,
runEffect: <A, E>(effect: Effect.Effect<A, E, DesktopLifecycleRuntimeServices>) => Promise<A>,
Expand All @@ -120,7 +149,7 @@ function handleBeforeQuit(
const state = yield* DesktopState.DesktopState;
yield* Ref.set(state.quitting, true);
yield* logLifecycleInfo("before-quit received");
yield* requestDesktopShutdownAndWait();
yield* requestDesktopShutdownAndWaitWithFailsafe();
}).pipe(Effect.withSpan("desktop.lifecycle.beforeQuit")),
).finally(() => {
markQuitAllowed();
Expand All @@ -145,7 +174,7 @@ function quitFromSignal(
const wasQuitting = yield* Ref.getAndSet(state.quitting, true);
if (wasQuitting) return;
yield* logLifecycleInfo("process signal received", { signal });
yield* requestDesktopShutdownAndWait();
yield* requestDesktopShutdownAndWaitWithFailsafe();
yield* electronApp.quit;
}).pipe(Effect.withSpan("desktop.lifecycle.processSignal")),
);
Expand All @@ -162,7 +191,7 @@ export const layer = Layer.succeed(
yield* Effect.gen(function* () {
yield* Effect.yieldNow;
yield* Ref.set(state.quitting, true);
yield* requestDesktopShutdownAndWait();
yield* requestDesktopShutdownAndWaitWithFailsafe();
if (environment.isDevelopment) {
yield* electronApp.exit(75);
return;
Expand Down Expand Up @@ -208,6 +237,15 @@ export const layer = Layer.succeed(
yield* electronApp.on("activate", () => {
void runEffect(desktopWindow.activate.pipe(Effect.withSpan("desktop.lifecycle.activate")));
});
// Only ever fires in the process holding the single-instance lock: a
// second launch hands its argv over and exits, and we surface the window
// it was asking for. `activate` is backend-ready-aware, so a launch
// during startup no-ops and the window arrives at readiness.
yield* electronApp.on("second-instance", () => {
void runEffect(
desktopWindow.activate.pipe(Effect.withSpan("desktop.lifecycle.secondInstance")),
);
});
yield* electronApp.on("window-all-closed", () => {
void runEffect(
Effect.gen(function* () {
Expand Down
27 changes: 21 additions & 6 deletions apps/desktop/src/app/desktopUserData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,13 @@ const trimNonEmpty = (value: string | undefined): string | undefined => {
return trimmed !== undefined && trimmed.length > 0 ? trimmed : undefined;
};

const firstEnvAlias = (
/**
* Returns the first trimmed non-empty value among `names`, mirroring
* `DesktopConfig`'s `THREADLINES_` → `BADCODE_` → `T3CODE_` alias order. Shared
* with the other module-load-time desktop gates so alias handling stays in one
* place.
*/
export const readDesktopEnvAlias = (
env: Readonly<Record<string, string | undefined>>,
names: ReadonlyArray<string>,
): string | undefined => {
Expand All @@ -58,7 +64,17 @@ const firstEnvAlias = (
return undefined;
};

const isParsableUrl = (value: string): boolean => URL.canParse(value);
/**
* Development is defined by Vite handing the main process a usable dev-server
* URL. Shared so every module-load-time gate splits the dev and release lanes
* on exactly the same signal.
*/
export const isDesktopDevelopmentEnv = (
env: Readonly<Record<string, string | undefined>>,
): boolean => {
const devServerUrl = trimNonEmpty(env["VITE_DEV_SERVER_URL"]);
return devServerUrl !== undefined && URL.canParse(devServerUrl);
};

/**
* Reads the userData-relevant configuration straight from an environment
Expand All @@ -69,17 +85,16 @@ const isParsableUrl = (value: string): boolean => URL.canParse(value);
export const readDesktopUserDataConfigFromEnv = (
env: Readonly<Record<string, string | undefined>>,
): DesktopUserDataConfig => {
const devServerUrl = trimNonEmpty(env["VITE_DEV_SERVER_URL"]);
return {
isDevelopment: devServerUrl !== undefined && isParsableUrl(devServerUrl),
isDevelopment: isDesktopDevelopmentEnv(env),
windowsAppDataDirectory: trimNonEmpty(env["APPDATA"]),
xdgConfigHome: trimNonEmpty(env["XDG_CONFIG_HOME"]),
appDataDirectoryOverride: firstEnvAlias(env, [
appDataDirectoryOverride: readDesktopEnvAlias(env, [
"THREADLINES_DESKTOP_APP_DATA_DIR",
"BADCODE_DESKTOP_APP_DATA_DIR",
"T3CODE_DESKTOP_APP_DATA_DIR",
]),
userDataDirNameOverride: firstEnvAlias(env, [
userDataDirNameOverride: readDesktopEnvAlias(env, [
"THREADLINES_DESKTOP_USER_DATA_DIR_NAME",
"BADCODE_DESKTOP_USER_DATA_DIR_NAME",
"T3CODE_DESKTOP_USER_DATA_DIR_NAME",
Expand Down
Loading
Loading