From 804a789793eda2b63e02908182e1c7c35afafe48 Mon Sep 17 00:00:00 2001 From: Yaacov Date: Tue, 21 Jul 2026 00:43:29 +0300 Subject: [PATCH 01/13] Secure Scient state initialization --- .github/workflows/ci.yml | 5 + .../src/desktopScientDataDirectories.test.ts | 78 ++++++++ .../src/desktopScientDataDirectories.ts | 31 ++++ apps/desktop/src/main.ts | 9 +- apps/server/src/config.ts | 16 +- apps/server/src/main.test.ts | 73 ++++++++ apps/server/src/main.ts | 9 + apps/server/src/privatePathPermissions.ts | 71 ++------ apps/server/src/serverLogger.ts | 8 +- .../src/serverPrivateDirectories.test.ts | 168 ++++++++++++++++++ packages/shared/package.json | 8 + packages/shared/src/privatePathPermissions.ts | 59 ++++++ packages/shared/src/scientDataDirectories.ts | 46 +++++ 13 files changed, 519 insertions(+), 62 deletions(-) create mode 100644 apps/desktop/src/desktopScientDataDirectories.test.ts create mode 100644 apps/desktop/src/desktopScientDataDirectories.ts create mode 100644 apps/server/src/serverPrivateDirectories.test.ts create mode 100644 packages/shared/src/privatePathPermissions.ts create mode 100644 packages/shared/src/scientDataDirectories.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c56fbf319..f86f6704e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -135,6 +135,11 @@ jobs: - name: Test Effect Windows process spawn run: bun run --cwd apps/server test src/windowsProcessEffect.test.ts + - name: Test Windows private state initialization + run: | + bun run --cwd apps/server test src/serverPrivateDirectories.test.ts + bun run --cwd apps/desktop test src/desktopScientDataDirectories.test.ts + - name: Exercise Windows release staging run: bun run release:smoke diff --git a/apps/desktop/src/desktopScientDataDirectories.test.ts b/apps/desktop/src/desktopScientDataDirectories.test.ts new file mode 100644 index 000000000..9ba8f4673 --- /dev/null +++ b/apps/desktop/src/desktopScientDataDirectories.test.ts @@ -0,0 +1,78 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { PRIVATE_DIRECTORY_MODE } from "@synara/shared/privatePathPermissions"; +import { afterEach, describe, expect, it } from "vitest"; + +import { ensurePrivateDesktopScientDataDirectoriesSync } from "./desktopScientDataDirectories"; +import { seedScientHomeFromPapiLab } from "./legacyPapiLabHomeMigration"; + +const temporaryRoots: string[] = []; + +function makeRoot(): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "scient-desktop-private-dirs-")); + temporaryRoots.push(root); + return root; +} + +afterEach(() => { + for (const root of temporaryRoots.splice(0)) { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +describe("ensurePrivateDesktopScientDataDirectoriesSync", () => { + it.runIf(process.platform !== "win32")( + "repairs migrated state before desktop logging can use it", + () => { + const container = makeRoot(); + const legacyHome = path.join(container, ".papilab"); + const scientHome = path.join(container, ".scient"); + const legacyLogsDir = path.join(legacyHome, "userdata", "logs"); + fs.mkdirSync(legacyLogsDir, { recursive: true }); + fs.writeFileSync(path.join(legacyLogsDir, "server.log"), "legacy"); + fs.chmodSync(path.join(legacyHome, "userdata"), 0o775); + fs.chmodSync(legacyLogsDir, 0o775); + + expect( + seedScientHomeFromPapiLab({ sourcePath: legacyHome, targetPath: scientHome }).status, + ).toBe("seeded"); + const paths = ensurePrivateDesktopScientDataDirectoriesSync(scientHome); + + expect(fs.readFileSync(path.join(paths.logsDir, "server.log"), "utf8")).toBe("legacy"); + for (const directoryPath of Object.values(paths)) { + expect(fs.statSync(directoryPath).mode & 0o777, directoryPath).toBe(PRIVATE_DIRECTORY_MODE); + } + }, + ); + + it.runIf(process.platform !== "win32")( + "creates a fresh desktop tree as owner-only under umask 002", + () => { + const scientHome = path.join(makeRoot(), ".scient"); + const previousUmask = process.umask(0o002); + let paths: ReturnType; + try { + paths = ensurePrivateDesktopScientDataDirectoriesSync(scientHome); + } finally { + process.umask(previousUmask); + } + + for (const directoryPath of Object.values(paths)) { + expect(fs.statSync(directoryPath).mode & 0o777, directoryPath).toBe(PRIVATE_DIRECTORY_MODE); + } + }, + ); + + it("creates every managed directory with Windows permission semantics", () => { + const paths = ensurePrivateDesktopScientDataDirectoriesSync( + path.join(makeRoot(), ".scient"), + "win32", + ); + + for (const directoryPath of Object.values(paths)) { + expect(fs.statSync(directoryPath).isDirectory(), directoryPath).toBe(true); + } + }); +}); diff --git a/apps/desktop/src/desktopScientDataDirectories.ts b/apps/desktop/src/desktopScientDataDirectories.ts new file mode 100644 index 000000000..e0cd389bc --- /dev/null +++ b/apps/desktop/src/desktopScientDataDirectories.ts @@ -0,0 +1,31 @@ +import path from "node:path"; + +import { + ensurePrivateScientDirectoriesSync, + type ScientDataDirectoryPaths, +} from "@synara/shared/scientDataDirectories"; + +export function deriveDesktopScientDataDirectories(baseDir: string): ScientDataDirectoryPaths { + const stateDir = path.join(baseDir, "userdata"); + const logsDir = path.join(stateDir, "logs"); + return { + baseDir, + stateDir, + secretsDir: path.join(stateDir, "secrets"), + worktreesDir: path.join(baseDir, "worktrees"), + attachmentsDir: path.join(stateDir, "attachments"), + logsDir, + providerLogsDir: path.join(logsDir, "provider"), + terminalLogsDir: path.join(logsDir, "terminals"), + }; +} + +/** Secures desktop-owned state before logging or the backend child can touch it. */ +export function ensurePrivateDesktopScientDataDirectoriesSync( + baseDir: string, + platform: NodeJS.Platform = process.platform, +): ScientDataDirectoryPaths { + const paths = deriveDesktopScientDataDirectories(baseDir); + ensurePrivateScientDirectoriesSync(paths, platform); + return paths; +} diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 774a99d76..b30b47d96 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -155,6 +155,7 @@ import { repairBrowserProfileFromBridgeManifest, seedDesktopUserDataProfileFromPapiLab, } from "./desktopUserDataProfile"; +import { ensurePrivateDesktopScientDataDirectoriesSync } from "./desktopScientDataDirectories"; import { seedScientHomeFromPapiLab } from "./legacyPapiLabHomeMigration"; import { isBrokenPipeError } from "./desktopProcessErrors"; import { @@ -229,7 +230,11 @@ if (legacyPapiLabHome) { } } const BASE_DIR = resolvedScientHome; -const STATE_DIR = Path.join(BASE_DIR, "userdata"); +// Migration must run before this call because it atomically renames a staged +// legacy home into a non-existent target. From this point onward every desktop +// writer and the backend child see the same secured Scient-owned boundaries. +const SCIENT_DATA_DIRECTORIES = ensurePrivateDesktopScientDataDirectoriesSync(BASE_DIR); +const STATE_DIR = SCIENT_DATA_DIRECTORIES.stateDir; const DESKTOP_WINDOW_STATE_PATH = Path.join(STATE_DIR, "desktop-window-state.json"); const DESKTOP_SCHEME = SCIENT_DESKTOP_SCHEME; const LEGACY_PAPILAB_DESKTOP_SCHEME = "papilab"; @@ -240,7 +245,7 @@ const APP_DISPLAY_NAME = isDevelopment ? `${SCIENT_APP_NAME} (Dev)` : SCIENT_APP const APP_USER_MODEL_ID = scientBundleId(isDevelopment); const COMMIT_HASH_PATTERN = /^[0-9a-f]{7,40}$/i; const COMMIT_HASH_DISPLAY_LENGTH = 12; -const LOG_DIR = Path.join(STATE_DIR, "logs"); +const LOG_DIR = SCIENT_DATA_DIRECTORIES.logsDir; const LOG_FILE_MAX_BYTES = 10 * 1024 * 1024; const LOG_FILE_MAX_FILES = 10; const APP_RUN_ID = Crypto.randomBytes(6).toString("hex"); diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index e950d87f5..48d380225 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -10,6 +10,10 @@ import { Effect, FileSystem, Layer, Path, ServiceMap } from "effect"; import OS from "node:os"; import pathPosix from "node:path/posix"; import pathWin32 from "node:path/win32"; +import { + ensurePrivateScientDirectoriesSync, + ensurePrivateScientStateDirectoriesSync, +} from "@synara/shared/scientDataDirectories"; import { realpathNearestExisting } from "./realpathNearestExisting"; @@ -160,9 +164,15 @@ export class ServerConfig extends ServiceMap.Service { + if (typeof baseDirOrPrefix === "string") { + // Explicit test fixtures commonly use shared roots such as /tmp. + // Secure Scient's children without changing or rejecting that root. + ensurePrivateScientStateDirectoriesSync(derivedPaths); + } else { + ensurePrivateScientDirectoriesSync({ baseDir, ...derivedPaths }); + } + }); const { homeDir, chatWorkspaceRoot, studioWorkspaceRoot } = yield* resolveCanonicalWorkspaceRoots({ homeDir: OS.homedir() }); diff --git a/apps/server/src/main.test.ts b/apps/server/src/main.test.ts index 47c10f3e3..24548a698 100644 --- a/apps/server/src/main.test.ts +++ b/apps/server/src/main.test.ts @@ -16,6 +16,7 @@ import { NetService } from "@synara/shared/Net"; import { ServerConfig, type ServerConfigShape } from "./config"; import { Open, type OpenShape } from "./open"; import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery"; +import { PRIVATE_DIRECTORY_MODE } from "./privatePathPermissions"; import { ServerSettingsService } from "./serverSettings"; import { AnalyticsService } from "./telemetry/Services/AnalyticsService"; import { Server, type ServerShape } from "./effectServer"; @@ -143,6 +144,20 @@ it.layer(testLayer)("server CLI command", (it) => { assert.equal(resolvedConfig?.autoBootstrapProjectFromCwd, false); assert.equal(resolvedConfig?.logProviderEvents, false); assert.equal(resolvedConfig?.logWebSocketEvents, false); + if (process.platform !== "win32" && resolvedConfig) { + for (const directoryPath of [ + resolvedConfig.baseDir, + resolvedConfig.stateDir, + resolvedConfig.secretsDir, + resolvedConfig.worktreesDir, + resolvedConfig.attachmentsDir, + resolvedConfig.logsDir, + resolvedConfig.providerLogsDir, + resolvedConfig.terminalLogsDir, + ]) { + assert.equal(fs.statSync(directoryPath).mode & 0o777, PRIVATE_DIRECTORY_MODE); + } + } assert.equal(stop.mock.calls.length, 1); }), ); @@ -240,6 +255,64 @@ it.layer(testLayer)("server CLI command", (it) => { }), ); + it.effect("secures every Scient-owned state directory before server startup", () => + Effect.gen(function* () { + const preexistingLogsDir = path.join(defaultScientHome, "userdata", "logs"); + fs.mkdirSync(preexistingLogsDir, { recursive: true }); + fs.chmodSync(defaultScientHome, 0o775); + fs.chmodSync(path.join(defaultScientHome, "userdata"), 0o775); + fs.chmodSync(preexistingLogsDir, 0o775); + + const previousUmask = process.umask(0o002); + try { + yield* runCli([], { + SYNARA_MODE: "desktop", + SYNARA_NO_BROWSER: "true", + }); + } finally { + process.umask(previousUmask); + } + + assert.equal(start.mock.calls.length, 1); + const config = resolvedConfig; + if (!config) throw new Error("Expected server config to resolve before startup"); + const privateDirectories = [ + config.baseDir, + config.stateDir, + config.secretsDir, + config.worktreesDir, + config.attachmentsDir, + config.logsDir, + config.providerLogsDir, + config.terminalLogsDir, + ]; + for (const directoryPath of privateDirectories) { + assert.equal(fs.statSync(directoryPath).mode & 0o777, PRIVATE_DIRECTORY_MODE); + } + }), + ); + + it.effect("does not start when SCIENT_HOME is a symlink", () => + Effect.gen(function* () { + if (process.platform === "win32") return; + const container = makeTempHome("scient-main-symlink-"); + const target = path.join(container, "target"); + const symlink = path.join(container, "scient-home"); + fs.mkdirSync(target); + fs.chmodSync(target, 0o775); + fs.symlinkSync(target, symlink, "dir"); + + yield* runCli([], { + SCIENT_HOME: symlink, + SYNARA_MODE: "desktop", + SYNARA_NO_BROWSER: "true", + }).pipe(Effect.catch(() => Effect.void)); + + assert.equal(start.mock.calls.length, 0); + assert.equal(fs.statSync(target).mode & 0o777, 0o775); + }), + ); + it.effect("allows overriding desktop host with --host", () => Effect.gen(function* () { yield* runCli(["--host", "0.0.0.0"], { diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 5cc9d95ff..0f494f2ea 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -10,6 +10,7 @@ import OS from "node:os"; import { Config, Data, Effect, FileSystem, Layer, Option, Path, Schema, ServiceMap } from "effect"; import { Command, Flag } from "effect/unstable/cli"; import { NetService } from "@synara/shared/Net"; +import { ensurePrivateScientDirectoriesSync } from "@synara/shared/scientDataDirectories"; import { DEFAULT_PORT, deriveServerPaths, @@ -174,6 +175,14 @@ const ServerConfigLive = (input: CliInput) => const baseDir = yield* resolveBaseDir(configuredHome); const userHomeDir = OS.homedir(); const derivedPaths = yield* deriveServerPaths(baseDir, devUrl); + yield* Effect.try({ + try: () => ensurePrivateScientDirectoriesSync({ baseDir, ...derivedPaths }), + catch: (cause) => + new StartupError({ + message: `Failed to secure Scient application data at ${baseDir}`, + cause, + }), + }); const noBrowser = resolveBooleanFlag(input.noBrowser, env.noBrowser ?? mode === "desktop"); const authToken = Option.getOrUndefined(input.authToken) ?? env.authToken; const autoBootstrapProjectFromCwd = resolveBooleanFlag( diff --git a/apps/server/src/privatePathPermissions.ts b/apps/server/src/privatePathPermissions.ts index dc45e4782..9f52045ea 100644 --- a/apps/server/src/privatePathPermissions.ts +++ b/apps/server/src/privatePathPermissions.ts @@ -1,35 +1,27 @@ import fs from "node:fs"; import path from "node:path"; -export const PRIVATE_DIRECTORY_MODE = 0o700; -export const PRIVATE_FILE_MODE = 0o600; -export const PRIVATE_EXECUTABLE_FILE_MODE = 0o700; -const UNSUPPORTED_DIRECTORY_SYNC_CODES = new Set(["EINVAL", "ENOTSUP", "EBADF"]); - -export class PrivatePathPermissionError extends Error { - readonly path: string; - readonly operation: string; +import { + PRIVATE_DIRECTORY_MODE, + PRIVATE_EXECUTABLE_FILE_MODE, + PRIVATE_FILE_MODE, + PrivatePathPermissionError, + supportsPosixPermissions, + withPrivatePathContext, +} from "@synara/shared/privatePathPermissions"; + +export { + ensurePrivateDirectorySync, + PRIVATE_DIRECTORY_MODE, + PRIVATE_EXECUTABLE_FILE_MODE, + PRIVATE_FILE_MODE, + PrivatePathPermissionError, + supportsPosixPermissions, +} from "@synara/shared/privatePathPermissions"; - constructor(operation: string, targetPath: string, cause: unknown) { - super(`Failed to ${operation} private path ${targetPath}`, { cause }); - this.name = "PrivatePathPermissionError"; - this.path = targetPath; - this.operation = operation; - } -} - -function withPathContext(operation: string, targetPath: string, action: () => T): T { - try { - return action(); - } catch (cause) { - if (cause instanceof PrivatePathPermissionError) throw cause; - throw new PrivatePathPermissionError(operation, targetPath, cause); - } -} +const UNSUPPORTED_DIRECTORY_SYNC_CODES = new Set(["EINVAL", "ENOTSUP", "EBADF"]); -export function supportsPosixPermissions(platform: NodeJS.Platform = process.platform): boolean { - return platform !== "win32"; -} +const withPathContext = withPrivatePathContext; /** Flushes directory-entry changes where the platform exposes durable directory fsync. */ export async function syncDirectoryEntry( @@ -52,31 +44,6 @@ export async function syncDirectoryEntry( } } -export function ensurePrivateDirectorySync( - directoryPath: string, - platform: NodeJS.Platform = process.platform, -): void { - withPathContext("create", directoryPath, () => { - fs.mkdirSync(directoryPath, { recursive: true, mode: PRIVATE_DIRECTORY_MODE }); - }); - if (!supportsPosixPermissions(platform)) return; - - const directoryFlags = fs.constants.O_RDONLY | fs.constants.O_DIRECTORY | fs.constants.O_NOFOLLOW; - const descriptor = withPathContext("open without following symlinks", directoryPath, () => - fs.openSync(directoryPath, directoryFlags), - ); - try { - withPathContext("set mode on", directoryPath, () => { - if (!fs.fstatSync(descriptor).isDirectory()) { - throw new Error("Path is not a directory"); - } - fs.fchmodSync(descriptor, PRIVATE_DIRECTORY_MODE); - }); - } finally { - fs.closeSync(descriptor); - } -} - export function repairPrivateFileSync( filePath: string, options: { diff --git a/apps/server/src/serverLogger.ts b/apps/server/src/serverLogger.ts index 1b90babaa..3c3ab24f4 100644 --- a/apps/server/src/serverLogger.ts +++ b/apps/server/src/serverLogger.ts @@ -1,16 +1,14 @@ -import fs from "node:fs"; - import { Effect, Logger } from "effect"; import * as Layer from "effect/Layer"; import { ServerConfig } from "./config"; +import { ensurePrivateDirectorySync } from "./privatePathPermissions"; export const ServerLoggerLive = Effect.gen(function* () { const { logsDir, serverLogPath } = yield* ServerConfig; - yield* Effect.sync(() => { - fs.mkdirSync(logsDir, { recursive: true }); - }); + // Keep the logger safe in isolation as well as behind normal config startup. + yield* Effect.sync(() => ensurePrivateDirectorySync(logsDir)); const fileLogger = Logger.formatSimple.pipe(Logger.toFile(serverLogPath)); diff --git a/apps/server/src/serverPrivateDirectories.test.ts b/apps/server/src/serverPrivateDirectories.test.ts new file mode 100644 index 000000000..428b8694d --- /dev/null +++ b/apps/server/src/serverPrivateDirectories.test.ts @@ -0,0 +1,168 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { PRIVATE_DIRECTORY_MODE, PrivatePathPermissionError } from "./privatePathPermissions"; +import { + ensurePrivateScientDirectoriesSync, + type ScientDataDirectoryPaths, +} from "@synara/shared/scientDataDirectories"; + +const temporaryRoots: string[] = []; + +function makeRoot(): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "scient-private-dirs-")); + temporaryRoots.push(root); + return root; +} + +function makePaths(baseDir: string): ScientDataDirectoryPaths { + const stateDir = path.join(baseDir, "userdata"); + const logsDir = path.join(stateDir, "logs"); + return { + baseDir, + stateDir, + secretsDir: path.join(stateDir, "secrets"), + worktreesDir: path.join(baseDir, "worktrees"), + attachmentsDir: path.join(stateDir, "attachments"), + logsDir, + providerLogsDir: path.join(logsDir, "provider"), + terminalLogsDir: path.join(logsDir, "terminals"), + }; +} + +function permissionMode(targetPath: string): number { + return fs.statSync(targetPath).mode & 0o777; +} + +function orderedDirectoryPaths(paths: ScientDataDirectoryPaths): readonly string[] { + return [ + paths.baseDir, + paths.stateDir, + paths.secretsDir, + paths.attachmentsDir, + paths.logsDir, + paths.providerLogsDir, + paths.terminalLogsDir, + paths.worktreesDir, + ]; +} + +afterEach(() => { + for (const root of temporaryRoots.splice(0)) { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +describe("ensurePrivateScientDirectoriesSync", () => { + it.runIf(process.platform !== "win32")( + "creates every Scient-owned directory as owner-only under common umasks", + () => { + for (const umask of [0o000, 0o002, 0o022]) { + const container = makeRoot(); + const paths = makePaths(path.join(container, `scient-home-${umask.toString(8)}`)); + const previousUmask = process.umask(umask); + try { + ensurePrivateScientDirectoriesSync(paths); + } finally { + process.umask(previousUmask); + } + + for (const directoryPath of Object.values(paths)) { + expect(permissionMode(directoryPath), directoryPath).toBe(PRIVATE_DIRECTORY_MODE); + } + } + }, + ); + + it.runIf(process.platform !== "win32")( + "repairs an existing group-writable application-data tree and is idempotent", + () => { + for (const insecureMode of [0o755, 0o775, 0o777]) { + const baseDir = path.join(makeRoot(), `scient-home-${insecureMode.toString(8)}`); + const paths = makePaths(baseDir); + for (const directoryPath of orderedDirectoryPaths(paths)) { + fs.mkdirSync(directoryPath, { recursive: true }); + fs.chmodSync(directoryPath, insecureMode); + } + + ensurePrivateScientDirectoriesSync(paths); + ensurePrivateScientDirectoriesSync(paths); + + for (const directoryPath of Object.values(paths)) { + expect(permissionMode(directoryPath), directoryPath).toBe(PRIVATE_DIRECTORY_MODE); + } + } + }, + ); + + it.runIf(process.platform !== "win32")( + "refuses symlinks at every managed boundary without repairing their targets", + () => { + for (let targetIndex = 0; targetIndex < 8; targetIndex += 1) { + const container = makeRoot(); + const paths = makePaths(path.join(container, "scient-home")); + const orderedPaths = orderedDirectoryPaths(paths); + for (const precedingPath of orderedPaths.slice(0, targetIndex)) { + fs.mkdirSync(precedingPath, { recursive: true }); + fs.chmodSync(precedingPath, PRIVATE_DIRECTORY_MODE); + } + const externalTarget = path.join(container, `target-${targetIndex}`); + fs.mkdirSync(externalTarget); + fs.chmodSync(externalTarget, 0o775); + fs.symlinkSync(externalTarget, orderedPaths[targetIndex]!, "dir"); + + expect(() => ensurePrivateScientDirectoriesSync(paths)).toThrow(PrivatePathPermissionError); + expect(permissionMode(externalTarget)).toBe(0o775); + } + }, + ); + + it.runIf(process.platform !== "win32")( + "reports a regular file at every expected directory boundary", + () => { + for (let targetIndex = 0; targetIndex < 8; targetIndex += 1) { + const container = makeRoot(); + const paths = makePaths(path.join(container, "scient-home")); + const orderedPaths = orderedDirectoryPaths(paths); + for (const precedingPath of orderedPaths.slice(0, targetIndex)) { + fs.mkdirSync(precedingPath, { recursive: true }); + fs.chmodSync(precedingPath, PRIVATE_DIRECTORY_MODE); + } + fs.writeFileSync(orderedPaths[targetIndex]!, "not-a-directory"); + + expect(() => ensurePrivateScientDirectoriesSync(paths)).toThrow(PrivatePathPermissionError); + } + }, + ); + + it.runIf(process.platform !== "win32")( + "never changes a user project outside Scient application data", + () => { + const container = makeRoot(); + const projectDir = path.join(container, "project"); + fs.mkdirSync(projectDir, { mode: 0o775 }); + fs.chmodSync(projectDir, 0o775); + + ensurePrivateScientDirectoriesSync(makePaths(path.join(container, "scient-home"))); + + expect(permissionMode(projectDir)).toBe(0o775); + }, + ); + + it("creates directories without applying POSIX chmod semantics on Windows", () => { + const baseDir = path.join(makeRoot(), "scient-home"); + const paths = makePaths(baseDir); + fs.mkdirSync(baseDir, { mode: 0o755 }); + const originalMode = permissionMode(baseDir); + + ensurePrivateScientDirectoriesSync(paths, "win32"); + + for (const directoryPath of Object.values(paths)) { + expect(fs.statSync(directoryPath).isDirectory()).toBe(true); + } + if (process.platform !== "win32") expect(permissionMode(baseDir)).toBe(originalMode); + }); +}); diff --git a/packages/shared/package.json b/packages/shared/package.json index 8c59343b8..4758a1849 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -24,6 +24,14 @@ "types": "./src/logging.ts", "import": "./src/logging.ts" }, + "./privatePathPermissions": { + "types": "./src/privatePathPermissions.ts", + "import": "./src/privatePathPermissions.ts" + }, + "./scientDataDirectories": { + "types": "./src/scientDataDirectories.ts", + "import": "./src/scientDataDirectories.ts" + }, "./errorMessages": { "types": "./src/errorMessages.ts", "import": "./src/errorMessages.ts" diff --git a/packages/shared/src/privatePathPermissions.ts b/packages/shared/src/privatePathPermissions.ts new file mode 100644 index 000000000..2c8f5184f --- /dev/null +++ b/packages/shared/src/privatePathPermissions.ts @@ -0,0 +1,59 @@ +import fs from "node:fs"; + +export const PRIVATE_DIRECTORY_MODE = 0o700; +export const PRIVATE_FILE_MODE = 0o600; +export const PRIVATE_EXECUTABLE_FILE_MODE = 0o700; + +export class PrivatePathPermissionError extends Error { + readonly path: string; + readonly operation: string; + + constructor(operation: string, targetPath: string, cause: unknown) { + super(`Failed to ${operation} private path ${targetPath}`, { cause }); + this.name = "PrivatePathPermissionError"; + this.path = targetPath; + this.operation = operation; + } +} + +export function withPrivatePathContext( + operation: string, + targetPath: string, + action: () => T, +): T { + try { + return action(); + } catch (cause) { + if (cause instanceof PrivatePathPermissionError) throw cause; + throw new PrivatePathPermissionError(operation, targetPath, cause); + } +} + +export function supportsPosixPermissions(platform: NodeJS.Platform = process.platform): boolean { + return platform !== "win32"; +} + +export function ensurePrivateDirectorySync( + directoryPath: string, + platform: NodeJS.Platform = process.platform, +): void { + withPrivatePathContext("create", directoryPath, () => { + fs.mkdirSync(directoryPath, { recursive: true, mode: PRIVATE_DIRECTORY_MODE }); + }); + if (!supportsPosixPermissions(platform)) return; + + const directoryFlags = fs.constants.O_RDONLY | fs.constants.O_DIRECTORY | fs.constants.O_NOFOLLOW; + const descriptor = withPrivatePathContext("open without following symlinks", directoryPath, () => + fs.openSync(directoryPath, directoryFlags), + ); + try { + withPrivatePathContext("set mode on", directoryPath, () => { + if (!fs.fstatSync(descriptor).isDirectory()) { + throw new Error("Path is not a directory"); + } + fs.fchmodSync(descriptor, PRIVATE_DIRECTORY_MODE); + }); + } finally { + fs.closeSync(descriptor); + } +} diff --git a/packages/shared/src/scientDataDirectories.ts b/packages/shared/src/scientDataDirectories.ts new file mode 100644 index 000000000..6009b6441 --- /dev/null +++ b/packages/shared/src/scientDataDirectories.ts @@ -0,0 +1,46 @@ +import { ensurePrivateDirectorySync } from "./privatePathPermissions"; + +export interface ScientDataDirectoryPaths { + readonly baseDir: string; + readonly stateDir: string; + readonly secretsDir: string; + readonly worktreesDir: string; + readonly attachmentsDir: string; + readonly logsDir: string; + readonly providerLogsDir: string; + readonly terminalLogsDir: string; +} + +type ScientStateDirectoryPaths = Omit; + +/** Secures children of an application-data home without modifying the home itself. */ +export function ensurePrivateScientStateDirectoriesSync( + paths: ScientStateDirectoryPaths, + platform: NodeJS.Platform = process.platform, +): void { + const privateDirectories = [ + paths.stateDir, + paths.secretsDir, + paths.attachmentsDir, + paths.logsDir, + paths.providerLogsDir, + paths.terminalLogsDir, + ]; + + for (const directoryPath of new Set(privateDirectories)) { + ensurePrivateDirectorySync(directoryPath, platform); + } +} + +/** + * Creates or repairs every security-boundary directory derived from + * SCIENT_HOME. User-selected project/workspace paths are deliberately absent. + */ +export function ensurePrivateScientDirectoriesSync( + paths: ScientDataDirectoryPaths, + platform: NodeJS.Platform = process.platform, +): void { + ensurePrivateDirectorySync(paths.baseDir, platform); + ensurePrivateScientStateDirectoriesSync(paths, platform); + ensurePrivateDirectorySync(paths.worktreesDir, platform); +} From 3433cfbcc3d78ddbc0aa26c265f0718433d7cf0a Mon Sep 17 00:00:00 2001 From: Yaacov Date: Tue, 21 Jul 2026 02:30:34 +0300 Subject: [PATCH 02/13] Allow legacy migration state test --- scripts/check-brand-identity.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/check-brand-identity.ts b/scripts/check-brand-identity.ts index 282bccf8d..bbefc9603 100644 --- a/scripts/check-brand-identity.ts +++ b/scripts/check-brand-identity.ts @@ -300,6 +300,7 @@ function containsForbiddenIdentity(value: string): boolean { const legacyPapiLabCompatibilityPaths = new Set([ "apps/desktop/src/desktopStorageMigration.ts", "apps/desktop/src/desktopStorageMigration.test.ts", + "apps/desktop/src/desktopScientDataDirectories.test.ts", "apps/desktop/src/desktopUserDataProfile.ts", "apps/desktop/src/desktopUserDataProfile.test.ts", "apps/desktop/src/legacyPapiLabHomeMigration.ts", From 5e93c43099fcf3b4c24f9a5e01c31ec8b0fe1d8b Mon Sep 17 00:00:00 2001 From: Yaacov Date: Tue, 21 Jul 2026 01:00:14 +0300 Subject: [PATCH 03/13] Supervise the desktop backend lifecycle --- .github/workflows/ci.yml | 5 + apps/desktop/src/backendProcessTree.test.ts | 76 +++++ apps/desktop/src/backendProcessTree.ts | 73 ++++ .../src/desktopBackendSupervisor.test.ts | 217 ++++++++++++ apps/desktop/src/desktopBackendSupervisor.ts | 258 ++++++++++++++ apps/desktop/src/main.ts | 323 +++++++++--------- apps/server/src/desktopParentShutdown.test.ts | 32 ++ apps/server/src/desktopParentShutdown.ts | 23 ++ apps/server/src/main.ts | 5 +- packages/shared/package.json | 4 + packages/shared/src/backendControl.ts | 26 ++ 11 files changed, 874 insertions(+), 168 deletions(-) create mode 100644 apps/desktop/src/backendProcessTree.test.ts create mode 100644 apps/desktop/src/backendProcessTree.ts create mode 100644 apps/desktop/src/desktopBackendSupervisor.test.ts create mode 100644 apps/desktop/src/desktopBackendSupervisor.ts create mode 100644 apps/server/src/desktopParentShutdown.test.ts create mode 100644 apps/server/src/desktopParentShutdown.ts create mode 100644 packages/shared/src/backendControl.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f86f6704e..d79dd31d2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -140,6 +140,11 @@ jobs: bun run --cwd apps/server test src/serverPrivateDirectories.test.ts bun run --cwd apps/desktop test src/desktopScientDataDirectories.test.ts + - name: Test Windows backend lifecycle + run: | + bun run --cwd apps/desktop test src/desktopBackendSupervisor.test.ts src/backendProcessTree.test.ts + bun run --cwd apps/server test src/desktopParentShutdown.test.ts + - name: Exercise Windows release staging run: bun run release:smoke diff --git a/apps/desktop/src/backendProcessTree.test.ts b/apps/desktop/src/backendProcessTree.test.ts new file mode 100644 index 000000000..d177f08c9 --- /dev/null +++ b/apps/desktop/src/backendProcessTree.test.ts @@ -0,0 +1,76 @@ +import { EventEmitter } from "node:events"; + +import { describe, expect, it, vi } from "vitest"; + +import { + backendProcessContainmentOptions, + forceTerminateBackendProcessTree, +} from "./backendProcessTree"; + +describe("forceTerminateBackendProcessTree", () => { + it("always reserves an IPC channel and isolates POSIX process groups", () => { + expect(backendProcessContainmentOptions(true, "linux")).toEqual({ + detached: true, + stdio: ["ignore", "pipe", "pipe", "ipc"], + }); + expect(backendProcessContainmentOptions(false, "darwin")).toEqual({ + detached: true, + stdio: ["ignore", "inherit", "inherit", "ipc"], + }); + expect(backendProcessContainmentOptions(false, "win32")).toEqual({ + detached: false, + stdio: ["ignore", "inherit", "inherit", "ipc"], + }); + }); + + it("kills the detached POSIX process group", async () => { + const killProcessGroup = vi.fn(); + + await forceTerminateBackendProcessTree({ pid: 4321 }, { platform: "linux", killProcessGroup }); + + expect(killProcessGroup).toHaveBeenCalledWith(-4321, "SIGKILL"); + }); + + it("ignores a POSIX process group that already exited", async () => { + await expect( + forceTerminateBackendProcessTree( + { pid: 4321 }, + { + platform: "darwin", + killProcessGroup: () => { + const error = new Error("missing") as NodeJS.ErrnoException; + error.code = "ESRCH"; + throw error; + }, + }, + ), + ).resolves.toBeUndefined(); + }); + + it("uses the Windows taskkill executable without a shell", async () => { + const process = new EventEmitter(); + const spawnProcess = vi.fn(() => process); + + const terminating = forceTerminateBackendProcessTree( + { pid: 4321 }, + { + platform: "win32", + env: { SystemRoot: "D:\\Windows" }, + spawnProcess: spawnProcess as never, + }, + ); + process.emit("exit", 0, null); + await terminating; + + expect(spawnProcess).toHaveBeenCalledWith( + "D:\\Windows\\System32\\taskkill.exe", + ["/PID", "4321", "/T", "/F"], + { + env: { SystemRoot: "D:\\Windows" }, + shell: false, + stdio: "ignore", + windowsHide: true, + }, + ); + }); +}); diff --git a/apps/desktop/src/backendProcessTree.ts b/apps/desktop/src/backendProcessTree.ts new file mode 100644 index 000000000..98f17691a --- /dev/null +++ b/apps/desktop/src/backendProcessTree.ts @@ -0,0 +1,73 @@ +import { spawn } from "node:child_process"; +import type { ChildProcess, SpawnOptions } from "node:child_process"; +import path from "node:path"; + +import { resolveWindowsSystemRoot } from "@synara/shared/windowsProcess"; + +export interface ForceTerminateBackendProcessTreeOptions { + readonly platform?: NodeJS.Platform; + readonly env?: NodeJS.ProcessEnv; + readonly killProcessGroup?: (pid: number, signal: NodeJS.Signals) => void; + readonly spawnProcess?: typeof spawn; +} + +export function backendProcessContainmentOptions( + captureLogs: boolean, + platform: NodeJS.Platform = process.platform, +): Pick { + return { + detached: platform !== "win32", + stdio: captureLogs + ? ["ignore", "pipe", "pipe", "ipc"] + : ["ignore", "inherit", "inherit", "ipc"], + }; +} + +function ignoreMissingProcess(error: unknown): void { + if ((error as NodeJS.ErrnoException)?.code !== "ESRCH") throw error; +} + +async function forceTerminateWindowsTree( + pid: number, + options: ForceTerminateBackendProcessTreeOptions, +): Promise { + const env = options.env ?? process.env; + const taskkill = path.win32.join(resolveWindowsSystemRoot(env), "System32", "taskkill.exe"); + const child = (options.spawnProcess ?? spawn)(taskkill, ["/PID", String(pid), "/T", "/F"], { + env, + shell: false, + stdio: "ignore", + windowsHide: true, + }); + + await new Promise((resolve, reject) => { + child.once("error", reject); + child.once("exit", (code) => { + if (code === 0 || code === 128) { + resolve(); + return; + } + reject(new Error(`taskkill exited with status ${code ?? "null"}`)); + }); + }); +} + +/** Force-kills the backend and all descendants after graceful IPC shutdown timed out. */ +export async function forceTerminateBackendProcessTree( + child: Pick, + options: ForceTerminateBackendProcessTreeOptions = {}, +): Promise { + const pid = child.pid; + if (!pid || pid <= 0) return; + + if ((options.platform ?? process.platform) === "win32") { + await forceTerminateWindowsTree(pid, options); + return; + } + + try { + (options.killProcessGroup ?? process.kill)(-pid, "SIGKILL"); + } catch (error) { + ignoreMissingProcess(error); + } +} diff --git a/apps/desktop/src/desktopBackendSupervisor.test.ts b/apps/desktop/src/desktopBackendSupervisor.test.ts new file mode 100644 index 000000000..68ff24e23 --- /dev/null +++ b/apps/desktop/src/desktopBackendSupervisor.test.ts @@ -0,0 +1,217 @@ +import { EventEmitter } from "node:events"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + DesktopBackendSupervisor, + type DesktopBackendChild, + type DesktopBackendSupervisorOptions, +} from "./desktopBackendSupervisor"; + +class FakeBackendChild extends EventEmitter implements DesktopBackendChild { + readonly pid: number; + connected = true; + exitCode: number | null = null; + signalCode: NodeJS.Signals | null = null; + readonly sent: unknown[] = []; + + constructor(pid: number) { + super(); + this.pid = pid; + } + + send(message: unknown): boolean { + this.sent.push(message); + return this.connected; + } + + spawn(): void { + this.emit("spawn"); + } + + fail(error: Error): void { + this.emit("error", error); + } + + exit(code: number | null = 0, signal: NodeJS.Signals | null = null): void { + this.exitCode = code; + this.signalCode = signal; + this.emit("exit", code, signal); + } +} + +function makeHarness(overrides: Partial = {}) { + const children: FakeBackendChild[] = []; + const prepared: number[] = []; + const exits: Array<{ generation: number; reason: string; expected: boolean }> = []; + const restarts: Array<{ attempt: number; delayMs: number; reason: string }> = []; + const forceTerminateTree = vi.fn(async (child: DesktopBackendChild) => { + (child as FakeBackendChild).exit(null, "SIGKILL"); + }); + const supervisor = new DesktopBackendSupervisor({ + prepareStart: async (generation) => { + prepared.push(generation); + }, + spawn: (generation) => { + const child = new FakeBackendChild(1_000 + generation); + children.push(child); + return child; + }, + requestGracefulShutdown: (child, reason) => + child.send?.({ type: "scient.backend.shutdown", reason }) ?? false, + forceTerminateTree, + onGenerationExited: (event) => exits.push(event), + onRestartScheduled: (event) => restarts.push(event), + ...overrides, + }); + return { children, exits, forceTerminateTree, prepared, restarts, supervisor }; +} + +beforeEach(() => { + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("DesktopBackendSupervisor", () => { + it("serializes duplicate starts into one backend generation", async () => { + const harness = makeHarness(); + + await Promise.all([harness.supervisor.start(), harness.supervisor.start()]); + + expect(harness.prepared).toEqual([1]); + expect(harness.children).toHaveLength(1); + expect(harness.supervisor.currentGeneration?.number).toBe(1); + }); + + it("handles an error followed by exit exactly once", async () => { + const harness = makeHarness(); + await harness.supervisor.start(); + const child = harness.children[0]!; + + child.fail(new Error("spawn failed")); + child.exit(1); + + expect(harness.exits).toEqual([ + { generation: 1, pid: 1001, reason: "error=spawn failed", expected: false }, + ]); + expect(harness.restarts).toEqual([{ attempt: 0, delayMs: 500, reason: "error=spawn failed" }]); + await vi.advanceTimersByTimeAsync(500); + expect(harness.children).toHaveLength(2); + }); + + it("backs off across unstable generations and resets only after readiness", async () => { + const harness = makeHarness(); + await harness.supervisor.start(); + + harness.children[0]!.exit(1); + await vi.advanceTimersByTimeAsync(500); + harness.children[1]!.exit(1); + await vi.advanceTimersByTimeAsync(1_000); + harness.supervisor.markReady(3); + harness.children[2]!.exit(1); + + expect(harness.restarts.map(({ attempt, delayMs }) => ({ attempt, delayMs }))).toEqual([ + { attempt: 0, delayMs: 500 }, + { attempt: 1, delayMs: 1_000 }, + { attempt: 0, delayMs: 500 }, + ]); + }); + + it("ignores late events from a closed generation", async () => { + const harness = makeHarness(); + await harness.supervisor.start(); + const first = harness.children[0]!; + first.fail(new Error("first failure")); + await vi.advanceTimersByTimeAsync(500); + + first.exit(1); + + expect(harness.supervisor.currentGeneration?.number).toBe(2); + expect(harness.exits).toHaveLength(1); + expect(harness.restarts).toHaveLength(1); + }); + + it("uses graceful IPC and does not force-kill a backend that exits", async () => { + const harness = makeHarness(); + await harness.supervisor.start(); + const child = harness.children[0]!; + + const stopping = harness.supervisor.stop("app quit"); + await Promise.resolve(); + child.exit(0); + await stopping; + + expect(child.sent).toEqual([{ type: "scient.backend.shutdown", reason: "app quit" }]); + expect(harness.forceTerminateTree).not.toHaveBeenCalled(); + expect(harness.exits[0]?.expected).toBe(true); + expect(harness.restarts).toHaveLength(0); + }); + + it("coalesces repeated stops and force-terminates the tree after timeout", async () => { + const harness = makeHarness({ + gracefulShutdownTimeoutMs: 100, + forcedExitTimeoutMs: 50, + }); + await harness.supervisor.start(); + const child = harness.children[0]!; + + const firstStop = harness.supervisor.stop("first quit"); + const secondStop = harness.supervisor.stop("second quit"); + await vi.advanceTimersByTimeAsync(100); + await Promise.all([firstStop, secondStop]); + + expect(child.sent).toEqual([{ type: "scient.backend.shutdown", reason: "first quit" }]); + expect(harness.forceTerminateTree).toHaveBeenCalledOnce(); + expect(harness.restarts).toHaveLength(0); + }); + + it("force-terminates immediately when the IPC channel is unavailable", async () => { + const harness = makeHarness({ + requestGracefulShutdown: () => false, + gracefulShutdownTimeoutMs: 10_000, + }); + await harness.supervisor.start(); + + await harness.supervisor.stop("lost IPC"); + + expect(harness.forceTerminateTree).toHaveBeenCalledOnce(); + expect(vi.getTimerCount()).toBe(0); + }); + + it("does not restart a start failure classified as fatal", async () => { + const fatalError = new Error("backend bundle missing"); + const onFatalStartFailure = vi.fn(); + const harness = makeHarness({ + prepareStart: async () => { + throw fatalError; + }, + classifyStartFailure: () => "fatal", + onFatalStartFailure, + }); + + await harness.supervisor.start(); + + expect(harness.supervisor.desiredRunning).toBe(false); + expect(harness.restarts).toHaveLength(0); + expect(onFatalStartFailure).toHaveBeenCalledWith(fatalError); + }); + + it("honors a start queued while graceful shutdown is still finishing", async () => { + const harness = makeHarness(); + await harness.supervisor.start(); + const first = harness.children[0]!; + + const stopping = harness.supervisor.stop("updater handoff"); + const restarting = harness.supervisor.start(); + await Promise.resolve(); + first.exit(0); + await Promise.all([stopping, restarting]); + + expect(harness.children).toHaveLength(2); + expect(harness.supervisor.currentGeneration?.number).toBe(2); + expect(harness.restarts).toHaveLength(0); + }); +}); diff --git a/apps/desktop/src/desktopBackendSupervisor.ts b/apps/desktop/src/desktopBackendSupervisor.ts new file mode 100644 index 000000000..80476aefe --- /dev/null +++ b/apps/desktop/src/desktopBackendSupervisor.ts @@ -0,0 +1,258 @@ +import type { ScientBackendShutdownMessage } from "@synara/shared/backendControl"; + +export interface DesktopBackendChild { + readonly pid?: number | undefined; + readonly connected?: boolean | undefined; + readonly exitCode: number | null; + readonly signalCode: NodeJS.Signals | null; + once(event: "exit", listener: (code: number | null, signal: NodeJS.Signals | null) => void): this; + on(event: "error", listener: (error: Error) => void): this; + off(event: "exit", listener: (code: number | null, signal: NodeJS.Signals | null) => void): this; + send?(message: ScientBackendShutdownMessage): boolean; +} + +export interface DesktopBackendGeneration { + readonly child: DesktopBackendChild; + readonly number: number; +} + +export interface DesktopBackendExit { + readonly generation: number; + readonly pid: number | null; + readonly reason: string; + readonly expected: boolean; +} + +export interface DesktopBackendSupervisorOptions { + readonly prepareStart: (generation: number) => Promise; + readonly spawn: (generation: number) => DesktopBackendChild; + readonly requestGracefulShutdown: (child: DesktopBackendChild, reason: string) => boolean; + readonly forceTerminateTree: (child: DesktopBackendChild) => Promise | void; + readonly onGenerationStarted?: (generation: DesktopBackendGeneration) => void; + readonly onGenerationExited?: (exit: DesktopBackendExit) => void; + readonly onRestartScheduled?: (input: { + readonly attempt: number; + readonly delayMs: number; + readonly reason: string; + }) => void; + readonly classifyStartFailure?: (error: unknown) => "fatal" | "retry"; + readonly onFatalStartFailure?: (error: unknown) => void; + readonly onError?: (error: unknown, context: string) => void; + readonly setTimer?: typeof setTimeout; + readonly clearTimer?: typeof clearTimeout; + readonly restartBaseDelayMs?: number; + readonly restartMaxDelayMs?: number; + readonly gracefulShutdownTimeoutMs?: number; + readonly forcedExitTimeoutMs?: number; +} + +interface ActiveGeneration extends DesktopBackendGeneration { + closed: boolean; +} + +const DEFAULT_RESTART_BASE_DELAY_MS = 500; +const DEFAULT_RESTART_MAX_DELAY_MS = 10_000; +const DEFAULT_GRACEFUL_SHUTDOWN_TIMEOUT_MS = 8_000; +const DEFAULT_FORCED_EXIT_TIMEOUT_MS = 2_000; + +function childHasExited(child: DesktopBackendChild): boolean { + return child.exitCode !== null || child.signalCode !== null; +} + +function exitReason(code: number | null, signal: NodeJS.Signals | null): string { + return `code=${code ?? "null"} signal=${signal ?? "null"}`; +} + +/** + * Owns exactly one desired desktop backend process. Every mutation is serialized, + * and generation checks prevent late events from an old child changing current state. + */ +export class DesktopBackendSupervisor { + readonly #options: DesktopBackendSupervisorOptions; + readonly #setTimer: typeof setTimeout; + readonly #clearTimer: typeof clearTimeout; + + #desiredRunning = false; + #active: ActiveGeneration | null = null; + #generation = 0; + #restartAttempt = 0; + #restartTimer: ReturnType | null = null; + #transition: Promise = Promise.resolve(); + readonly #stoppingGenerations = new Set(); + + constructor(options: DesktopBackendSupervisorOptions) { + this.#options = options; + this.#setTimer = options.setTimer ?? setTimeout; + this.#clearTimer = options.clearTimer ?? clearTimeout; + } + + get desiredRunning(): boolean { + return this.#desiredRunning; + } + + get currentGeneration(): DesktopBackendGeneration | null { + return this.#active ? { child: this.#active.child, number: this.#active.number } : null; + } + + start(): Promise { + this.#desiredRunning = true; + return this.#enqueue(() => this.#ensureStarted()); + } + + stop(reason: string): Promise { + this.#desiredRunning = false; + if (this.#active) this.#stoppingGenerations.add(this.#active.number); + this.#clearRestartTimer(); + return this.#enqueue(() => this.#stopActive(reason)); + } + + markReady(generation: number): void { + if ( + !this.#active || + this.#active.number !== generation || + this.#active.closed || + !this.#desiredRunning + ) { + return; + } + this.#restartAttempt = 0; + } + + #enqueue(action: () => Promise): Promise { + const next = this.#transition.then(action, action); + this.#transition = next.catch((error: unknown) => { + this.#options.onError?.(error, "backend lifecycle transition"); + }); + return next; + } + + async #ensureStarted(): Promise { + if (!this.#desiredRunning || this.#active) return; + + const generation = ++this.#generation; + try { + await this.#options.prepareStart(generation); + if (!this.#desiredRunning || this.#active) return; + + const child = this.#options.spawn(generation); + const active: ActiveGeneration = { child, number: generation, closed: false }; + this.#active = active; + this.#bindChild(active); + this.#options.onGenerationStarted?.(active); + } catch (error) { + if (!this.#desiredRunning) return; + if (this.#options.classifyStartFailure?.(error) === "fatal") { + this.#desiredRunning = false; + this.#options.onFatalStartFailure?.(error); + return; + } + this.#scheduleRestart(error instanceof Error ? error.message : String(error)); + } + } + + #bindChild(active: ActiveGeneration): void { + active.child.on("error", (error) => { + this.#handleGenerationClosed(active, `error=${error.message}`); + }); + active.child.once("exit", (code, signal) => { + this.#handleGenerationClosed(active, exitReason(code, signal)); + }); + } + + #handleGenerationClosed(active: ActiveGeneration, reason: string): void { + if (active.closed) return; + active.closed = true; + const wasCurrent = this.#active === active; + if (wasCurrent) this.#active = null; + const expected = this.#stoppingGenerations.delete(active.number) || !this.#desiredRunning; + this.#options.onGenerationExited?.({ + generation: active.number, + pid: active.child.pid ?? null, + reason, + expected, + }); + if (wasCurrent && !expected) this.#scheduleRestart(reason); + } + + #scheduleRestart(reason: string): void { + if (!this.#desiredRunning || this.#restartTimer) return; + const baseDelay = this.#options.restartBaseDelayMs ?? DEFAULT_RESTART_BASE_DELAY_MS; + const maxDelay = this.#options.restartMaxDelayMs ?? DEFAULT_RESTART_MAX_DELAY_MS; + const attempt = this.#restartAttempt; + const delayMs = Math.min(baseDelay * 2 ** attempt, maxDelay); + this.#restartAttempt += 1; + this.#options.onRestartScheduled?.({ attempt, delayMs, reason }); + this.#restartTimer = this.#setTimer(() => { + this.#restartTimer = null; + void this.#enqueue(() => this.#ensureStarted()); + }, delayMs); + this.#restartTimer.unref?.(); + } + + #clearRestartTimer(): void { + if (!this.#restartTimer) return; + this.#clearTimer(this.#restartTimer); + this.#restartTimer = null; + } + + async #stopActive(reason: string): Promise { + const active = this.#active; + if (!active) return; + this.#stoppingGenerations.add(active.number); + if (childHasExited(active.child)) { + this.#handleGenerationClosed(active, "already exited"); + return; + } + + const gracefulTimeoutMs = + this.#options.gracefulShutdownTimeoutMs ?? DEFAULT_GRACEFUL_SHUTDOWN_TIMEOUT_MS; + const forcedExitTimeoutMs = this.#options.forcedExitTimeoutMs ?? DEFAULT_FORCED_EXIT_TIMEOUT_MS; + + const exitedGracefully = await this.#waitForExit(active, gracefulTimeoutMs, () => { + const sent = this.#options.requestGracefulShutdown(active.child, reason); + if (!sent) { + this.#options.onError?.( + new Error("Backend IPC shutdown request was unavailable."), + `generation ${active.number} graceful shutdown`, + ); + } + return sent; + }); + if (exitedGracefully) return; + + try { + await this.#options.forceTerminateTree(active.child); + } catch (error) { + this.#options.onError?.(error, `generation ${active.number} force termination`); + } + const exitedAfterForce = await this.#waitForExit(active, forcedExitTimeoutMs); + if (!exitedAfterForce) { + this.#handleGenerationClosed(active, "force termination timed out"); + } + } + + async #waitForExit( + active: ActiveGeneration, + timeoutMs: number, + begin?: () => boolean | void, + ): Promise { + if (active.closed || childHasExited(active.child)) return true; + + return await new Promise((resolve) => { + let settled = false; + const settle = (exited: boolean) => { + if (settled) return; + settled = true; + active.child.off("exit", onExit); + this.#clearTimer(timeout); + resolve(exited); + }; + const onExit = () => settle(true); + active.child.once("exit", onExit); + const timeout = this.#setTimer(() => settle(false), Math.max(0, timeoutMs)); + timeout.unref?.(); + if (begin?.() === false) settle(false); + if (active.closed || childHasExited(active.child)) settle(true); + }); + } +} diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index b30b47d96..abc005e0a 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -43,6 +43,7 @@ import type { import { autoUpdater, BaseUpdater, CancellationToken } from "electron-updater"; import type { ContextMenuItem } from "@synara/contracts"; +import { makeScientBackendShutdownMessage } from "@synara/shared/backendControl"; import { getMacTrafficLightPosition } from "@synara/shared/desktopChrome"; import { SCIENT_APP_NAME, @@ -57,6 +58,15 @@ import { RotatingFileSink } from "@synara/shared/logging"; import { ensureStaticSnapshot, findAsarArchivePath } from "@synara/shared/staticSnapshot"; import { isBackendReadinessAborted, waitForHttpReady } from "./backendReadiness"; import { resolveBackendNodeArgs } from "./backendNodeOptions"; +import { + backendProcessContainmentOptions, + forceTerminateBackendProcessTree, +} from "./backendProcessTree"; +import { + DesktopBackendSupervisor, + type DesktopBackendExit, + type DesktopBackendGeneration, +} from "./desktopBackendSupervisor"; import { bundleSignatureFromStats, isBundleStable, @@ -280,7 +290,7 @@ const browserPerfLoggingEnabled = process.env.SYNARA_BROWSER_PERF === "1"; type DesktopUpdateErrorContext = DesktopUpdateState["errorContext"]; let mainWindow: BrowserWindow | null = null; -let backendProcess: ChildProcess.ChildProcess | null = null; +let backendSupervisor: DesktopBackendSupervisor | null = null; let backendPort = 0; let backendAuthToken = ""; let backendHttpUrl = ""; @@ -288,8 +298,6 @@ let backendWsUrl = ""; let backendReadinessAbortController: AbortController | null = null; let backendInitialWindowOpenInFlight: Promise | null = null; let backendListeningDetector: ServerListeningDetector | null = null; -let restartAttempt = 0; -let restartTimer: ReturnType | null = null; let isQuitting = false; let isUpdaterInstallPreparing = false; let isUpdaterQuitAndInstallInFlight = false; @@ -529,7 +537,8 @@ async function reserveBackendEndpoint(reason: string): Promise { } async function waitForBackendWindowReady(baseUrl: string): Promise<"listening" | "http"> { - return await waitForBackendStartupReady({ + const generation = backendSupervisor?.currentGeneration?.number ?? null; + const source = await waitForBackendStartupReady({ listeningPromise: backendListeningDetector?.promise ?? null, waitForHttpReady: () => waitForBackendHttpReady(baseUrl, { @@ -551,6 +560,8 @@ async function waitForBackendWindowReady(baseUrl: string): Promise<"listening" | }), cancelHttpWait: cancelBackendReadinessWait, }); + if (generation !== null) backendSupervisor?.markReady(generation); + return source; } function ensureInitialBackendWindowOpen(baseUrl: string): void { @@ -1142,7 +1153,7 @@ function handleFatalStartupError(stage: string, error: unknown): void { `Stage: ${stage}\n${message}${detail}`, ); } - stopBackend(); + stopBackend(`fatal startup: ${stage}`); restoreStdIoCapture?.(); app.quit(); } @@ -2500,7 +2511,7 @@ async function installDownloadedUpdate(): Promise<{ isQuitting = true; isUpdaterInstallPreparing = true; clearUpdatePollTimer(); - await stopBackendAndWaitForExit(); + await stopBackendAndWaitForExit("updater install handoff"); await logMacUpdateDiagnostics("before install handoff"); isUpdaterQuitAndInstallInFlight = true; autoUpdater.quitAndInstall(); @@ -2739,45 +2750,24 @@ function backendEnv(): NodeJS.ProcessEnv { }; } -function scheduleBackendRestart(reason: string): void { - if (isQuitting || restartTimer) return; - - const delayMs = Math.min(500 * 2 ** restartAttempt, 10_000); - restartAttempt += 1; - safeConsoleError(`[desktop] backend exited unexpectedly (${reason}); restarting in ${delayMs}ms`); - - restartTimer = setTimeout(() => { - restartTimer = null; - void restartBackendAfterCrash(reason); - }, delayMs); +interface BackendGenerationRuntime { + readonly listeningDetector: ServerListeningDetector; + readonly closeSession: (details: string) => void; } -async function restartBackendAfterCrash(reason: string): Promise { - if (isQuitting || backendProcess) { - return; +class MissingBackendEntryError extends Error { + constructor(readonly entryPath: string) { + super(`Missing packaged server entry at ${entryPath}`); + this.name = "MissingBackendEntryError"; } - - cancelBackendReadinessWait(); - try { - await reserveBackendEndpoint("backend restart"); - } catch (error) { - scheduleBackendRestart( - `failed to reserve restart port after ${reason}: ${formatErrorMessage(error)}`, - ); - return; - } - - startBackend(); - ensureInitialBackendWindowOpen(backendHttpUrl); } -function startBackend(): void { - if (isQuitting || backendProcess) return; +const backendGenerationRuntimes = new Map(); +function spawnBackendGeneration(generation: number): ChildProcess.ChildProcess { const backendEntry = resolveBackendEntry(); if (!FS.existsSync(backendEntry)) { - scheduleBackendRestart(`missing server entry at ${backendEntry}`); - return; + throw new MissingBackendEntryError(backendEntry); } const captureBackendLogs = app.isPackaged && backendLogSink !== null; @@ -2789,11 +2779,12 @@ function startBackend(): void { ...backendEnv(), ELECTRON_RUN_AS_NODE: "1", }, - stdio: captureBackendLogs ? ["ignore", "pipe", "pipe"] : "inherit", + // POSIX force termination targets this dedicated process group. Windows + // uses taskkill /T after the same graceful IPC deadline. + ...backendProcessContainmentOptions(captureBackendLogs), }); const listeningDetector = new ServerListeningDetector(); backendListeningDetector = listeningDetector; - backendProcess = child; let backendSessionClosed = false; const closeBackendSession = (details: string) => { if (backendSessionClosed) return; @@ -2805,120 +2796,133 @@ function startBackend(): void { `pid=${child.pid ?? "unknown"} port=${backendPort} cwd=${resolveBackendCwd()}`, ); captureBackendOutput(child); - - child.once("spawn", () => { - restartAttempt = 0; - }); - - child.on("error", (error) => { - if (backendListeningDetector === listeningDetector) { - listeningDetector.fail(error); - backendListeningDetector = null; - } - if (backendProcess === child) { - backendProcess = null; - } - closeBackendSession(`pid=${child.pid ?? "unknown"} error=${error.message}`); - scheduleBackendRestart(error.message); - }); - - child.on("exit", (code, signal) => { - if (backendListeningDetector === listeningDetector) { - listeningDetector.fail( - new Error( - `backend exited before logging readiness (code=${code ?? "null"} signal=${signal ?? "null"})`, - ), - ); - backendListeningDetector = null; - } - if (backendProcess === child) { - backendProcess = null; - } - closeBackendSession( - `pid=${child.pid ?? "unknown"} code=${code ?? "null"} signal=${signal ?? "null"}`, - ); - if (isQuitting) return; - const reason = `code=${code ?? "null"} signal=${signal ?? "null"}`; - scheduleBackendRestart(reason); + backendGenerationRuntimes.set(generation, { + listeningDetector, + closeSession: closeBackendSession, }); + return child; } -function stopBackend(): void { - cancelBackendReadinessWait(); - backendListeningDetector = null; - if (restartTimer) { - clearTimeout(restartTimer); - restartTimer = null; +function handleBackendGenerationStarted(generation: DesktopBackendGeneration): void { + if (isDevelopment) { + void waitForBackendWindowReady(backendHttpUrl) + .then((source) => { + writeDesktopLogHeader(`backend generation=${generation.number} ready source=${source}`); + if (!mainWindow) { + mainWindow = createWindow(); + writeDesktopLogHeader("bootstrap main window created"); + } + }) + .catch((error) => { + if (isBackendReadinessAborted(error)) return; + writeDesktopLogHeader( + `backend generation=${generation.number} readiness warning message=${formatErrorMessage(error)}`, + ); + console.warn("[desktop] backend readiness check timed out", error); + if (!mainWindow) { + mainWindow = createWindow(); + writeDesktopLogHeader("bootstrap main window created after readiness warning"); + } + }); + return; } - const child = backendProcess; - backendProcess = null; - if (!child) return; - - if (child.exitCode === null && child.signalCode === null) { - child.kill("SIGTERM"); - setTimeout(() => { - if (child.exitCode === null && child.signalCode === null) { - child.kill("SIGKILL"); - } - }, BACKEND_FORCE_KILL_DELAY_MS).unref(); + const hadWindow = (mainWindow ?? BrowserWindow.getAllWindows()[0] ?? null) !== null; + ensureInitialBackendWindowOpen(backendHttpUrl); + if (hadWindow && backendInitialWindowOpenInFlight === null) { + void waitForBackendWindowReady(backendHttpUrl) + .then((source) => { + writeDesktopLogHeader(`backend generation=${generation.number} ready source=${source}`); + }) + .catch((error) => { + if (isBackendReadinessAborted(error)) return; + console.warn("[desktop] restarted backend readiness check timed out", error); + }); } } -async function stopBackendAndWaitForExit(timeoutMs = BACKEND_SHUTDOWN_TIMEOUT_MS): Promise { +function handleBackendGenerationExited(exit: DesktopBackendExit): void { cancelBackendReadinessWait(); - backendListeningDetector = null; - if (restartTimer) { - clearTimeout(restartTimer); - restartTimer = null; - } - - const child = backendProcess; - backendProcess = null; - if (!child) return; - const backendChild = child; - if (backendChild.exitCode !== null || backendChild.signalCode !== null) return; - - await new Promise((resolve) => { - let settled = false; - let forceKillTimer: ReturnType | null = null; - let exitTimeoutTimer: ReturnType | null = null; - - function settle(): void { - if (settled) return; - settled = true; - backendChild.off("exit", onExit); - if (forceKillTimer) { - clearTimeout(forceKillTimer); - } - if (exitTimeoutTimer) { - clearTimeout(exitTimeoutTimer); - } - resolve(); - } - - function onExit(): void { - settle(); + const runtime = backendGenerationRuntimes.get(exit.generation); + backendGenerationRuntimes.delete(exit.generation); + if (runtime) { + if (backendListeningDetector === runtime.listeningDetector) { + runtime.listeningDetector.fail( + new Error(`backend generation ${exit.generation} closed (${exit.reason})`), + ); + backendListeningDetector = null; } + runtime.closeSession( + `pid=${exit.pid ?? "unknown"} generation=${exit.generation} ${exit.reason}`, + ); + } +} - backendChild.once("exit", onExit); - backendChild.kill("SIGTERM"); - - const forceKillDelayMs = Math.min(BACKEND_FORCE_KILL_DELAY_MS, Math.max(1, timeoutMs - 500)); - forceKillTimer = setTimeout(() => { - if (backendChild.exitCode === null && backendChild.signalCode === null) { - backendChild.kill("SIGKILL"); +function getBackendSupervisor(): DesktopBackendSupervisor { + if (backendSupervisor) return backendSupervisor; + backendSupervisor = new DesktopBackendSupervisor({ + prepareStart: async (generation) => { + cancelBackendReadinessWait(); + await reserveBackendEndpoint( + generation === 1 ? "bootstrap" : `backend generation ${generation}`, + ); + }, + spawn: spawnBackendGeneration, + requestGracefulShutdown: (child, reason) => { + if (!child.send || child.connected === false) return false; + try { + return child.send(makeScientBackendShutdownMessage(reason)); + } catch { + return false; } - }, forceKillDelayMs); - forceKillTimer.unref(); + }, + forceTerminateTree: (child) => forceTerminateBackendProcessTree(child), + onGenerationStarted: handleBackendGenerationStarted, + onGenerationExited: handleBackendGenerationExited, + onRestartScheduled: ({ delayMs, reason }) => { + safeConsoleError( + `[desktop] backend exited unexpectedly (${reason}); restarting in ${delayMs}ms`, + ); + }, + onError: (error, context) => { + safeConsoleError(`[desktop] ${context}: ${formatErrorMessage(error)}`); + }, + classifyStartFailure: (error) => + error instanceof MissingBackendEntryError ? "fatal" : "retry", + onFatalStartFailure: (error) => handleFatalStartupError("backend", error), + gracefulShutdownTimeoutMs: BACKEND_FORCE_KILL_DELAY_MS, + forcedExitTimeoutMs: BACKEND_SHUTDOWN_TIMEOUT_MS - BACKEND_FORCE_KILL_DELAY_MS, + }); + return backendSupervisor; +} + +function startBackend(): void { + if (isQuitting) return; + void getBackendSupervisor() + .start() + .catch((error: unknown) => { + safeConsoleError(`[desktop] backend start failed: ${formatErrorMessage(error)}`); + }); +} - exitTimeoutTimer = setTimeout(() => { - settle(); - }, timeoutMs); - exitTimeoutTimer.unref(); +function stopBackend(reason = "desktop stop"): void { + cancelBackendReadinessWait(); + if (!backendSupervisor) return; + void backendSupervisor.stop(reason).catch((error: unknown) => { + safeConsoleError(`[desktop] backend stop failed: ${formatErrorMessage(error)}`); }); } +async function stopBackendAndWaitForExit(reason = "desktop shutdown"): Promise { + cancelBackendReadinessWait(); + if (!backendSupervisor) return; + try { + await backendSupervisor.stop(reason); + } catch (error) { + safeConsoleError(`[desktop] backend stop failed: ${formatErrorMessage(error)}`); + } +} + async function disposeBrowserUsePipeServerForShutdown(reason: string): Promise { const pipeServer = browserUsePipeServer; browserUsePipeServer = null; @@ -2950,7 +2954,7 @@ async function shutdownDesktopRuntime(reason: string): Promise { appSnapManager?.dispose(); appSnapManager = null; await disposeBrowserUsePipeServerForShutdown(reason); - await stopBackendAndWaitForExit(); + await stopBackendAndWaitForExit(reason); browserManager.dispose(); restoreStdIoCapture?.(); writeDesktopLogHeader(`${reason} shutdown complete`); @@ -3453,6 +3457,19 @@ function createWindow(): BrowserWindow { window.on("unmaximize", () => emitDesktopWindowState(window)); window.on("enter-full-screen", () => emitDesktopWindowState(window)); window.on("leave-full-screen", () => emitDesktopWindowState(window)); + if (process.platform === "win32") { + window.on("query-session-end", (event) => { + if (desktopShutdownComplete) return; + event.preventDefault(); + writeDesktopLogHeader("Windows query-session-end received"); + requestGracefulAppQuit("Windows session end"); + }); + window.on("session-end", () => { + if (desktopShutdownPromise) return; + writeDesktopLogHeader("Windows session-end received"); + void shutdownDesktopRuntime("Windows session end"); + }); + } window.on("close", () => { try { writeDesktopWindowState(DESKTOP_WINDOW_STATE_PATH, { @@ -3544,39 +3561,11 @@ if (!hasSingleInstanceLock) { async function bootstrap(): Promise { writeDesktopLogHeader("bootstrap start"); backendAuthToken = Crypto.randomBytes(24).toString("hex"); - await reserveBackendEndpoint("bootstrap"); registerIpcHandlers(); writeDesktopLogHeader("bootstrap ipc handlers registered"); - startBackend(); + await getBackendSupervisor().start(); writeDesktopLogHeader("bootstrap backend start requested"); - - if (isDevelopment) { - void waitForBackendWindowReady(backendHttpUrl) - .then((source) => { - writeDesktopLogHeader(`bootstrap backend ready source=${source}`); - if (!mainWindow) { - mainWindow = createWindow(); - writeDesktopLogHeader("bootstrap main window created"); - } - }) - .catch((error) => { - if (isBackendReadinessAborted(error)) { - return; - } - writeDesktopLogHeader( - `bootstrap backend readiness warning message=${formatErrorMessage(error)}`, - ); - console.warn("[desktop] backend readiness check timed out during dev bootstrap", error); - if (!mainWindow) { - mainWindow = createWindow(); - writeDesktopLogHeader("bootstrap main window created after readiness warning"); - } - }); - return; - } - - ensureInitialBackendWindowOpen(backendHttpUrl); } app.on("before-quit", (event) => { diff --git a/apps/server/src/desktopParentShutdown.test.ts b/apps/server/src/desktopParentShutdown.test.ts new file mode 100644 index 000000000..cc4f470a9 --- /dev/null +++ b/apps/server/src/desktopParentShutdown.test.ts @@ -0,0 +1,32 @@ +import { EventEmitter } from "node:events"; + +import { Effect, Fiber } from "effect"; +import { describe, expect, it } from "vitest"; + +import { waitForDesktopParentShutdown } from "./desktopParentShutdown"; + +describe("waitForDesktopParentShutdown", () => { + it("ignores unrelated messages and completes for the shutdown protocol", async () => { + const source = new EventEmitter(); + const fiber = Effect.runFork(waitForDesktopParentShutdown(source)); + await new Promise((resolve) => setImmediate(resolve)); + + source.emit("message", { type: "other" }); + expect(fiber.pollUnsafe()).toBeUndefined(); + source.emit("message", { type: "scient.backend.shutdown", reason: "app quit" }); + + await Effect.runPromise(Fiber.join(fiber)); + expect(fiber.pollUnsafe()).toBeDefined(); + expect(source.listenerCount("message")).toBe(0); + }); + + it("removes its listener when the server scope is interrupted", async () => { + const source = new EventEmitter(); + const fiber = Effect.runFork(waitForDesktopParentShutdown(source)); + await new Promise((resolve) => setImmediate(resolve)); + + await Effect.runPromise(Fiber.interrupt(fiber)); + + expect(source.listenerCount("message")).toBe(0); + }); +}); diff --git a/apps/server/src/desktopParentShutdown.ts b/apps/server/src/desktopParentShutdown.ts new file mode 100644 index 000000000..277229ad1 --- /dev/null +++ b/apps/server/src/desktopParentShutdown.ts @@ -0,0 +1,23 @@ +import { Effect } from "effect"; + +import { isScientBackendShutdownMessage } from "@synara/shared/backendControl"; + +export interface DesktopParentMessageSource { + on(event: "message", listener: (message: unknown) => void): unknown; + off(event: "message", listener: (message: unknown) => void): unknown; +} + +/** Completes when the Electron parent asks the scoped server runtime to shut down. */ +export function waitForDesktopParentShutdown( + source: DesktopParentMessageSource = process, +): Effect.Effect { + return Effect.callback((resume) => { + const onMessage = (message: unknown) => { + if (!isScientBackendShutdownMessage(message)) return; + source.off("message", onMessage); + resume(Effect.void); + }; + source.on("message", onMessage); + return Effect.sync(() => source.off("message", onMessage)); + }); +} diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 0f494f2ea..30a09b8bf 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -11,6 +11,7 @@ import { Config, Data, Effect, FileSystem, Layer, Option, Path, Schema, ServiceM import { Command, Flag } from "effect/unstable/cli"; import { NetService } from "@synara/shared/Net"; import { ensurePrivateScientDirectoriesSync } from "@synara/shared/scientDataDirectories"; +import { waitForDesktopParentShutdown } from "./desktopParentShutdown"; import { DEFAULT_PORT, deriveServerPaths, @@ -371,7 +372,9 @@ const makeServerProgram = (input: CliInput) => ); } - return yield* stopSignal; + return yield* config.mode === "desktop" + ? Effect.raceFirst(stopSignal, waitForDesktopParentShutdown()) + : stopSignal; }).pipe(Effect.provide(LayerLive(input))); /** diff --git a/packages/shared/package.json b/packages/shared/package.json index 4758a1849..400001c61 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -84,6 +84,10 @@ "types": "./src/browserSession.ts", "import": "./src/browserSession.ts" }, + "./backendControl": { + "types": "./src/backendControl.ts", + "import": "./src/backendControl.ts" + }, "./conversationEdit": { "types": "./src/conversationEdit.ts", "import": "./src/conversationEdit.ts" diff --git a/packages/shared/src/backendControl.ts b/packages/shared/src/backendControl.ts new file mode 100644 index 000000000..fd10ae5e6 --- /dev/null +++ b/packages/shared/src/backendControl.ts @@ -0,0 +1,26 @@ +export const SCIENT_BACKEND_SHUTDOWN_MESSAGE_TYPE = "scient.backend.shutdown"; + +export interface ScientBackendShutdownMessage { + readonly type: typeof SCIENT_BACKEND_SHUTDOWN_MESSAGE_TYPE; + readonly reason: string; +} + +export function makeScientBackendShutdownMessage(reason: string): ScientBackendShutdownMessage { + return { + type: SCIENT_BACKEND_SHUTDOWN_MESSAGE_TYPE, + reason, + }; +} + +export function isScientBackendShutdownMessage( + message: unknown, +): message is ScientBackendShutdownMessage { + return ( + typeof message === "object" && + message !== null && + "type" in message && + message.type === SCIENT_BACKEND_SHUTDOWN_MESSAGE_TYPE && + "reason" in message && + typeof message.reason === "string" + ); +} From a1d6b63b1d950183a775d25be71f87a5b2530596 Mon Sep 17 00:00:00 2001 From: Yaacov Date: Tue, 21 Jul 2026 02:02:38 +0300 Subject: [PATCH 04/13] Supervise desktop connection recovery --- .../src/desktopBackendSupervisor.test.ts | 71 ++++ apps/desktop/src/desktopBackendSupervisor.ts | 53 ++- apps/desktop/src/desktopConnectionWake.ts | 6 + .../src/initialBackendWindowOpen.test.ts | 17 + apps/desktop/src/initialBackendWindowOpen.ts | 2 + apps/desktop/src/main.ts | 39 +- apps/desktop/src/preload.ts | 11 + .../src/terminal/Layers/Manager.test.ts | 29 ++ apps/server/src/terminal/Layers/Manager.ts | 11 + apps/server/src/terminal/Services/Manager.ts | 4 + apps/server/src/wsRpc.ts | 12 +- apps/web/src/components/ChatView.browser.tsx | 13 +- .../src/components/EventRouter.browser.tsx | 122 ++++++ .../components/KeybindingsToast.browser.tsx | 11 +- .../components/terminal/terminalRuntime.ts | 185 +++++---- .../terminal/terminalRuntimeTypes.test.ts | 24 +- .../terminal/terminalRuntimeTypes.ts | 44 ++- apps/web/src/connectionSupervisor.test.ts | 222 +++++++++++ apps/web/src/connectionSupervisor.ts | 338 +++++++++++++++++ apps/web/src/projectTerminalRunner.test.ts | 2 + apps/web/src/routes/__root.tsx | 3 + apps/web/src/terminalActivity.test.ts | 4 + apps/web/src/test/effectRpcWebSocketMock.ts | 17 +- apps/web/src/wsNativeApi.test.ts | 2 + apps/web/src/wsTransport.ts | 351 +++++++++++------- apps/web/src/wsTransportEvents.ts | 2 +- packages/contracts/src/ipc.ts | 4 + packages/contracts/src/terminal.test.ts | 6 + packages/contracts/src/terminal.ts | 8 + 29 files changed, 1379 insertions(+), 234 deletions(-) create mode 100644 apps/desktop/src/desktopConnectionWake.ts create mode 100644 apps/web/src/connectionSupervisor.test.ts create mode 100644 apps/web/src/connectionSupervisor.ts diff --git a/apps/desktop/src/desktopBackendSupervisor.test.ts b/apps/desktop/src/desktopBackendSupervisor.test.ts index 68ff24e23..b05358b26 100644 --- a/apps/desktop/src/desktopBackendSupervisor.test.ts +++ b/apps/desktop/src/desktopBackendSupervisor.test.ts @@ -134,6 +134,39 @@ describe("DesktopBackendSupervisor", () => { expect(harness.restarts).toHaveLength(1); }); + it("restarts an alive generation that never becomes ready", async () => { + const harness = makeHarness({ + gracefulShutdownTimeoutMs: 100, + forcedExitTimeoutMs: 50, + }); + await harness.supervisor.start(); + const first = harness.children[0]!; + + const restarting = harness.supervisor.restartGeneration(1, "readiness timed out"); + await Promise.resolve(); + first.exit(0); + await restarting; + + expect(first.sent).toEqual([ + { type: "scient.backend.shutdown", reason: "readiness timed out" }, + ]); + expect(harness.restarts).toEqual([{ attempt: 0, delayMs: 500, reason: "readiness timed out" }]); + await vi.advanceTimersByTimeAsync(500); + expect(harness.supervisor.currentGeneration?.number).toBe(2); + }); + + it("ignores a readiness timeout reported by a stale generation", async () => { + const harness = makeHarness(); + await harness.supervisor.start(); + harness.children[0]!.exit(1); + await vi.advanceTimersByTimeAsync(500); + + await harness.supervisor.restartGeneration(1, "late readiness timeout"); + + expect(harness.supervisor.currentGeneration?.number).toBe(2); + expect(harness.restarts).toHaveLength(1); + }); + it("uses graceful IPC and does not force-kill a backend that exits", async () => { const harness = makeHarness(); await harness.supervisor.start(); @@ -181,6 +214,44 @@ describe("DesktopBackendSupervisor", () => { expect(vi.getTimerCount()).toBe(0); }); + it("does not overlap a replacement with a backend that survived force termination", async () => { + const onError = vi.fn(); + const onUnrecoverableGeneration = vi.fn(); + const harness = makeHarness({ + gracefulShutdownTimeoutMs: 10, + forcedExitTimeoutMs: 10, + forceTerminateTree: vi.fn(async () => undefined), + onError, + onUnrecoverableGeneration, + }); + await harness.supervisor.start(); + const first = harness.children[0]!; + + const restarting = harness.supervisor.restartGeneration(1, "readiness timed out"); + await vi.advanceTimersByTimeAsync(20); + await restarting; + + expect(harness.supervisor.currentGeneration?.number).toBe(1); + expect(harness.children).toHaveLength(1); + expect(harness.restarts).toHaveLength(0); + expect(harness.supervisor.desiredRunning).toBe(false); + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ message: "Backend remained alive after force termination." }), + "generation 1 restart blocked", + ); + expect(onUnrecoverableGeneration).toHaveBeenCalledWith({ + error: expect.objectContaining({ + message: "Backend remained alive after force termination.", + }), + generation: { child: first, number: 1 }, + reason: "readiness timed out", + }); + + first.exit(1); + expect(harness.restarts).toHaveLength(0); + expect(harness.supervisor.currentGeneration).toBeNull(); + }); + it("does not restart a start failure classified as fatal", async () => { const fatalError = new Error("backend bundle missing"); const onFatalStartFailure = vi.fn(); diff --git a/apps/desktop/src/desktopBackendSupervisor.ts b/apps/desktop/src/desktopBackendSupervisor.ts index 80476aefe..38258466b 100644 --- a/apps/desktop/src/desktopBackendSupervisor.ts +++ b/apps/desktop/src/desktopBackendSupervisor.ts @@ -37,6 +37,11 @@ export interface DesktopBackendSupervisorOptions { }) => void; readonly classifyStartFailure?: (error: unknown) => "fatal" | "retry"; readonly onFatalStartFailure?: (error: unknown) => void; + readonly onUnrecoverableGeneration?: (input: { + readonly error: Error; + readonly generation: DesktopBackendGeneration; + readonly reason: string; + }) => void; readonly onError?: (error: unknown, context: string) => void; readonly setTimer?: typeof setTimeout; readonly clearTimer?: typeof clearTimeout; @@ -103,7 +108,9 @@ export class DesktopBackendSupervisor { this.#desiredRunning = false; if (this.#active) this.#stoppingGenerations.add(this.#active.number); this.#clearRestartTimer(); - return this.#enqueue(() => this.#stopActive(reason)); + return this.#enqueue(async () => { + await this.#stopActive(reason); + }); } markReady(generation: number): void { @@ -118,6 +125,37 @@ export class DesktopBackendSupervisor { this.#restartAttempt = 0; } + restartGeneration(generation: number, reason: string): Promise { + return this.#enqueue(async () => { + if ( + !this.#desiredRunning || + !this.#active || + this.#active.number !== generation || + this.#active.closed + ) { + return; + } + const target = { child: this.#active.child, number: generation }; + const exited = await this.#stopActive(reason); + if (exited && this.#desiredRunning) { + this.#scheduleRestart(reason); + } else if (!exited) { + // A replacement must never overlap an old process that ignored force + // termination. Stop automatic recovery and surface a fatal lifecycle + // failure instead of leaving the app in an unreported half-alive state. + const error = new Error("Backend remained alive after force termination."); + this.#desiredRunning = false; + this.#clearRestartTimer(); + this.#options.onError?.(error, `generation ${generation} restart blocked`); + this.#options.onUnrecoverableGeneration?.({ + error, + generation: target, + reason, + }); + } + }); + } + #enqueue(action: () => Promise): Promise { const next = this.#transition.then(action, action); this.#transition = next.catch((error: unknown) => { @@ -195,13 +233,13 @@ export class DesktopBackendSupervisor { this.#restartTimer = null; } - async #stopActive(reason: string): Promise { + async #stopActive(reason: string): Promise { const active = this.#active; - if (!active) return; + if (!active) return true; this.#stoppingGenerations.add(active.number); if (childHasExited(active.child)) { this.#handleGenerationClosed(active, "already exited"); - return; + return true; } const gracefulTimeoutMs = @@ -218,7 +256,7 @@ export class DesktopBackendSupervisor { } return sent; }); - if (exitedGracefully) return; + if (exitedGracefully) return true; try { await this.#options.forceTerminateTree(active.child); @@ -226,9 +264,8 @@ export class DesktopBackendSupervisor { this.#options.onError?.(error, `generation ${active.number} force termination`); } const exitedAfterForce = await this.#waitForExit(active, forcedExitTimeoutMs); - if (!exitedAfterForce) { - this.#handleGenerationClosed(active, "force termination timed out"); - } + if (!exitedAfterForce) return false; + return true; } async #waitForExit( diff --git a/apps/desktop/src/desktopConnectionWake.ts b/apps/desktop/src/desktopConnectionWake.ts new file mode 100644 index 000000000..65d253354 --- /dev/null +++ b/apps/desktop/src/desktopConnectionWake.ts @@ -0,0 +1,6 @@ +// FILE: desktopConnectionWake.ts +// Purpose: Defines the main-to-renderer signal used to verify connections after native wake events. +// Layer: Desktop IPC +// Exports: DESKTOP_CONNECTION_WAKE_CHANNEL. + +export const DESKTOP_CONNECTION_WAKE_CHANNEL = "desktop:connection-wake"; diff --git a/apps/desktop/src/initialBackendWindowOpen.test.ts b/apps/desktop/src/initialBackendWindowOpen.test.ts index c5c99c864..bb3c222d8 100644 --- a/apps/desktop/src/initialBackendWindowOpen.test.ts +++ b/apps/desktop/src/initialBackendWindowOpen.test.ts @@ -91,4 +91,21 @@ describe("openInitialBackendWindow", () => { expect(options.createWindow).not.toHaveBeenCalled(); expect(options.waitForBackendWindowReady).not.toHaveBeenCalled(); }); + + it("reports a non-abort readiness failure to the lifecycle owner", async () => { + const error = new Error("backend stayed unready"); + const onReadinessFailure = vi.fn(); + const options = createOptions({ + waitForBackendWindowReady: vi.fn(async () => { + throw error; + }), + onReadinessFailure, + }); + + openInitialBackendWindow(options); + const watchedPromise = vi.mocked(options.setReadinessInFlight).mock.calls[0]?.[0]; + await expect(watchedPromise).resolves.toBeUndefined(); + + expect(onReadinessFailure).toHaveBeenCalledWith(error); + }); }); diff --git a/apps/desktop/src/initialBackendWindowOpen.ts b/apps/desktop/src/initialBackendWindowOpen.ts index 6864f537d..0677bdd40 100644 --- a/apps/desktop/src/initialBackendWindowOpen.ts +++ b/apps/desktop/src/initialBackendWindowOpen.ts @@ -17,6 +17,7 @@ export interface InitialBackendWindowOpenOptions { readonly isReadinessAborted: (error: unknown) => boolean; readonly formatErrorMessage: (error: unknown) => string; readonly warn: (message: string, error: unknown) => void; + readonly onReadinessFailure?: (error: unknown) => void; } export function openInitialBackendWindow(options: InitialBackendWindowOpenOptions): void { @@ -46,6 +47,7 @@ export function openInitialBackendWindow(options: InitialBackendWindowOpenOption `bootstrap backend readiness warning message=${options.formatErrorMessage(error)}`, ); options.warn("[desktop] backend readiness check timed out during packaged bootstrap", error); + options.onReadinessFailure?.(error); }) .finally(() => { if (options.getReadinessInFlight() === nextOpen) { diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index abc005e0a..b9eb36e0f 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -22,6 +22,7 @@ import { Notification, nativeImage, nativeTheme, + powerMonitor, protocol, screen, session, @@ -36,6 +37,7 @@ import type { } from "electron"; import * as Effect from "effect/Effect"; import type { + DesktopConnectionWakeReason, DesktopTheme, DesktopUpdateActionResult, DesktopUpdateState, @@ -76,6 +78,7 @@ import { } from "./bundleSwapDetection"; import { waitForBackendStartupReady } from "./backendStartupReadiness"; import { showDesktopConfirmDialog } from "./confirmDialog"; +import { DESKTOP_CONNECTION_WAKE_CHANNEL } from "./desktopConnectionWake"; import { LSREGISTER_PATH, parseLastLaunchVersion, @@ -467,6 +470,13 @@ function emitDesktopWindowState(window: BrowserWindow | null = mainWindow): void window.webContents.send(WINDOW_STATE_CHANNEL, getDesktopWindowState(window)); } +function emitDesktopConnectionWake(reason: DesktopConnectionWakeReason): void { + for (const window of BrowserWindow.getAllWindows()) { + if (window.isDestroyed() || window.webContents.isDestroyed()) continue; + window.webContents.send(DESKTOP_CONNECTION_WAKE_CHANNEL, reason); + } +} + function isSaveFileInput(input: unknown): input is { defaultFilename: string; contents: string; @@ -564,7 +574,14 @@ async function waitForBackendWindowReady(baseUrl: string): Promise<"listening" | return source; } -function ensureInitialBackendWindowOpen(baseUrl: string): void { +function restartBackendAfterReadinessFailure(generation: number): void { + writeDesktopLogHeader( + `backend generation=${generation} readiness failed; scheduling replacement`, + ); + void backendSupervisor?.restartGeneration(generation, "readiness check failed"); +} + +function ensureInitialBackendWindowOpen(baseUrl: string, generation?: number): void { openInitialBackendWindow({ isDevelopment, baseUrl, @@ -583,6 +600,11 @@ function ensureInitialBackendWindowOpen(baseUrl: string): void { warn: (message, error) => { console.warn(message, error); }, + ...(generation === undefined + ? {} + : { + onReadinessFailure: () => restartBackendAfterReadinessFailure(generation), + }), }); } @@ -2819,6 +2841,7 @@ function handleBackendGenerationStarted(generation: DesktopBackendGeneration): v `backend generation=${generation.number} readiness warning message=${formatErrorMessage(error)}`, ); console.warn("[desktop] backend readiness check timed out", error); + restartBackendAfterReadinessFailure(generation.number); if (!mainWindow) { mainWindow = createWindow(); writeDesktopLogHeader("bootstrap main window created after readiness warning"); @@ -2828,7 +2851,7 @@ function handleBackendGenerationStarted(generation: DesktopBackendGeneration): v } const hadWindow = (mainWindow ?? BrowserWindow.getAllWindows()[0] ?? null) !== null; - ensureInitialBackendWindowOpen(backendHttpUrl); + ensureInitialBackendWindowOpen(backendHttpUrl, generation.number); if (hadWindow && backendInitialWindowOpenInFlight === null) { void waitForBackendWindowReady(backendHttpUrl) .then((source) => { @@ -2837,6 +2860,7 @@ function handleBackendGenerationStarted(generation: DesktopBackendGeneration): v .catch((error) => { if (isBackendReadinessAborted(error)) return; console.warn("[desktop] restarted backend readiness check timed out", error); + restartBackendAfterReadinessFailure(generation.number); }); } } @@ -2890,6 +2914,11 @@ function getBackendSupervisor(): DesktopBackendSupervisor { classifyStartFailure: (error) => error instanceof MissingBackendEntryError ? "fatal" : "retry", onFatalStartFailure: (error) => handleFatalStartupError("backend", error), + onUnrecoverableGeneration: ({ error, generation, reason }) => + handleFatalStartupError( + `backend generation ${generation.number} recovery (${reason})`, + error, + ), gracefulShutdownTimeoutMs: BACKEND_FORCE_KILL_DELAY_MS, forcedExitTimeoutMs: BACKEND_SHUTDOWN_TIMEOUT_MS - BACKEND_FORCE_KILL_DELAY_MS, }); @@ -3631,10 +3660,16 @@ if (hasSingleInstanceLock) { app.on("browser-window-focus", () => { handleDesktopAppForegrounded(); + emitDesktopConnectionWake("window-focus"); + }); + + powerMonitor.on("resume", () => { + emitDesktopConnectionWake("system-resume"); }); app.on("activate", () => { handleDesktopAppForegrounded(); + emitDesktopConnectionWake("app-activate"); if (BrowserWindow.getAllWindows().length === 0) { if (!isDevelopment) { ensureInitialBackendWindowOpen(backendHttpUrl); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 0a7600a0c..eb9a5e55d 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -9,6 +9,7 @@ import { import { SERVER_TRANSCRIBE_VOICE_CHANNEL } from "./voiceTranscription"; import { STORAGE_MIGRATION_IPC_CHANNELS } from "./desktopStorageMigration"; import { APPSNAP_IPC_CHANNELS } from "./appSnapIpc"; +import { DESKTOP_CONNECTION_WAKE_CHANNEL } from "./desktopConnectionWake"; const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; const SAVE_FILE_CHANNEL = "desktop:save-file"; @@ -45,6 +46,16 @@ function getDesktopWsUrl(): string | null { contextBridge.exposeInMainWorld("desktopBridge", { getWsUrl: getDesktopWsUrl, + onConnectionWake: (listener) => { + const wrappedListener = (_event: Electron.IpcRendererEvent, reason: unknown) => { + if (reason !== "app-activate" && reason !== "window-focus" && reason !== "system-resume") { + return; + } + listener(reason); + }; + ipcRenderer.on(DESKTOP_CONNECTION_WAKE_CHANNEL, wrappedListener); + return () => ipcRenderer.removeListener(DESKTOP_CONNECTION_WAKE_CHANNEL, wrappedListener); + }, // Absolute path for OS-dropped File objects (folders with spaces/parens, etc.). getPathForFile: (file: File) => { try { diff --git a/apps/server/src/terminal/Layers/Manager.test.ts b/apps/server/src/terminal/Layers/Manager.test.ts index 89c33a760..0091c5dad 100644 --- a/apps/server/src/terminal/Layers/Manager.test.ts +++ b/apps/server/src/terminal/Layers/Manager.test.ts @@ -509,6 +509,35 @@ describe("TerminalManager", () => { manager.dispose(); }); + it("captures an exact monotonic output barrier in reconnect snapshots", async () => { + const { manager, ptyAdapter } = makeManager(); + const outputEvents: Array> = []; + manager.on("event", (event) => { + if (event.type === "output") outputEvents.push(event); + }); + const initial = await manager.open(openInput()); + const process = ptyAdapter.processes[0]; + expect(process).toBeDefined(); + if (!process) return; + + expect(initial.outputSequence).toBe(0); + expect(initial.outputEpoch).not.toBe(""); + process.emitData("before snapshot\n"); + await waitFor(() => outputEvents.length === 1); + + const snapshot = await manager.open(openInput()); + expect(snapshot.history).toContain("before snapshot"); + expect(snapshot.outputSequence).toBe(1); + expect(snapshot.outputEpoch).toBe(initial.outputEpoch); + + process.emitData("after snapshot\n"); + await waitFor(() => outputEvents.length === 2); + expect(outputEvents.map((event) => event.outputSequence)).toEqual([1, 2]); + expect(outputEvents.every((event) => event.outputEpoch === initial.outputEpoch)).toBe(true); + + manager.dispose(); + }); + it("includes live terminal mode replay preamble in reattach snapshots", async () => { const { manager, ptyAdapter } = makeManager(); await manager.open(openInput()); diff --git a/apps/server/src/terminal/Layers/Manager.ts b/apps/server/src/terminal/Layers/Manager.ts index a6cf16dd5..1a525a565 100644 --- a/apps/server/src/terminal/Layers/Manager.ts +++ b/apps/server/src/terminal/Layers/Manager.ts @@ -2,6 +2,7 @@ // Purpose: Implements server-side terminal sessions, cleanup orchestration, history persistence, and PTY output flow control. // Layer: Terminal infrastructure // Depends on: PTY adapters, process-tree cleanup helpers, shared terminal contracts, and server config. +import { randomUUID } from "node:crypto"; import { EventEmitter } from "node:events"; import fs from "node:fs"; import path from "node:path"; @@ -1001,6 +1002,7 @@ interface KillEscalationHandle { } export class TerminalManagerRuntime extends EventEmitter { + private readonly outputEpoch = randomUUID(); private readonly sessions = new Map(); private readonly logsDir: string; private managedWrapperBinDir: string | null; @@ -1124,6 +1126,8 @@ export class TerminalManagerRuntime extends EventEmitter modeReplayTracker: null, pendingOutputChunks: [], pendingOutputLength: 0, + outputSequence: 0, + outputEpoch: this.outputEpoch, outputFlushTimer: null, streamOutput: input.streamOutput ?? true, outputPaused: false, @@ -1333,6 +1337,8 @@ export class TerminalManagerRuntime extends EventEmitter modeReplayTracker: null, pendingOutputChunks: [], pendingOutputLength: 0, + outputSequence: 0, + outputEpoch: this.outputEpoch, outputFlushTimer: null, // Restart has no headless mode of its own; fresh sessions stream normally // and existing sessions (below) keep whatever mode they were opened with. @@ -1685,6 +1691,7 @@ export class TerminalManagerRuntime extends EventEmitter // history above, but skip the live broadcast so unviewed background output // never reaches the WebSocket fanout. if (session.streamOutput) { + session.outputSequence += 1; this.emitEvent({ type: "output", threadId: session.threadId, @@ -1692,6 +1699,8 @@ export class TerminalManagerRuntime extends EventEmitter createdAt: new Date().toISOString(), data, byteLength, + outputEpoch: session.outputEpoch, + outputSequence: session.outputSequence, }); } if (session.outputAckObserved) { @@ -2476,6 +2485,8 @@ export class TerminalManagerRuntime extends EventEmitter status: session.status, pid: session.pid, history: session.history.toString(), + outputEpoch: session.outputEpoch, + outputSequence: session.outputSequence, ...(replayPreamble.length > 0 ? { replayPreamble } : {}), exitCode: session.exitCode, exitSignal: session.exitSignal, diff --git a/apps/server/src/terminal/Services/Manager.ts b/apps/server/src/terminal/Services/Manager.ts index 4d8615c41..556f6e250 100644 --- a/apps/server/src/terminal/Services/Manager.ts +++ b/apps/server/src/terminal/Services/Manager.ts @@ -63,6 +63,10 @@ export interface TerminalSessionState { pendingOutputChunks: string[]; /** Total UTF-8 byte length of buffered output chunks. */ pendingOutputLength: number; + /** Monotonic barrier for emitted output batches and reconnect snapshots. */ + outputSequence: number; + /** Identifies the server process that owns the output-sequence namespace. */ + outputEpoch: string; /** Timer handle for the next scheduled output flush. */ outputFlushTimer: ReturnType | null; /** diff --git a/apps/server/src/wsRpc.ts b/apps/server/src/wsRpc.ts index 75778171e..dcd6430da 100644 --- a/apps/server/src/wsRpc.ts +++ b/apps/server/src/wsRpc.ts @@ -768,7 +768,7 @@ export const makeWsRpcLayer = () => [WS_METHODS.projectsListDevServers]: () => rpcEffect(devServerManager.list, "Failed to list dev servers"), [WS_METHODS.subscribeProjectDevServerEvents]: () => - Stream.concat( + bufferLiveWhileInitialStreamLoads( Stream.fromEffect( devServerManager.list.pipe( Effect.map( @@ -1259,7 +1259,7 @@ export const makeWsRpcLayer = () => "Failed to update keybinding", ), [WS_METHODS.subscribeServerLifecycle]: () => - Stream.concat( + bufferLiveWhileInitialStreamLoads( Stream.fromEffect( lifecycleEvents.snapshot.pipe( Effect.map((snapshot) => @@ -1284,7 +1284,7 @@ export const makeWsRpcLayer = () => ), ), [WS_METHODS.subscribeServerConfig]: () => - Stream.concat( + bufferLiveWhileInitialStreamLoads( Stream.fromEffect( loadServerConfig.pipe( Effect.map( @@ -1325,7 +1325,7 @@ export const makeWsRpcLayer = () => ), ).pipe(Stream.mapError((cause) => toWsRpcError(cause, "Server config stream failed"))), [WS_METHODS.subscribeServerProviderStatuses]: () => - Stream.concat( + bufferLiveWhileInitialStreamLoads( Stream.fromEffect( providerHealth.getStatuses.pipe( Effect.flatMap(enrichProviderStatuses), @@ -1346,7 +1346,7 @@ export const makeWsRpcLayer = () => ), ), [WS_METHODS.subscribeServerSettings]: () => - Stream.concat( + bufferLiveWhileInitialStreamLoads( Stream.fromEffect( serverSettings.getSettings.pipe(Effect.map((settings) => ({ settings }))), ), @@ -1414,7 +1414,7 @@ export const makeWsRpcLayer = () => [WS_METHODS.automationArchiveRun]: (input) => rpcEffect(automationService.archiveRun(input), "Failed to update automation run"), [WS_METHODS.subscribeAutomationEvents]: () => - Stream.merge( + bufferLiveWhileInitialStreamLoads( Stream.fromEffect( automationService.list({}).pipe( Effect.map(({ definitions, runs }) => ({ diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index e563edf54..10f598ac2 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -44,6 +44,7 @@ import { useSplitViewStore } from "../splitViewStore"; import { useStore } from "../store"; import { createShellSnapshotFromReadModel, + createTestEnvironmentDescriptor, flattenEffectRpcRequestPayload, readEffectRpcClientMessage, sendEffectRpcChunk, @@ -1021,6 +1022,9 @@ function resolveWsRpc(body: WsRequestEnvelope["body"]): unknown { if (tag === WS_METHODS.serverGetConfig) { return fixture.serverConfig; } + if (tag === WS_METHODS.serverGetEnvironment) { + return createTestEnvironmentDescriptor(); + } if (tag === WS_METHODS.gitListBranches) { const cwd = typeof body.cwd === "string" ? body.cwd : null; const branchName = cwd ? (fixture.gitBranchByCwd[cwd] ?? "main") : "main"; @@ -1082,6 +1086,8 @@ function resolveWsRpc(body: WsRequestEnvelope["body"]): unknown { status: "running", pid: 123, history: "", + outputEpoch: "epoch-1", + outputSequence: 0, exitCode: null, exitSignal: null, updatedAt: NOW_ISO, @@ -1250,8 +1256,13 @@ const worker = setupWorker( method === WS_METHODS.subscribeServerProviderStatuses || method === WS_METHODS.subscribeServerSettings || method === WS_METHODS.subscribeTerminalEvents || - method === WS_METHODS.subscribeOrchestrationDomainEvents + method === WS_METHODS.subscribeOrchestrationDomainEvents || + method === WS_METHODS.subscribeProjectDevServerEvents || + method === WS_METHODS.subscribeAutomationEvents ) { + // Keep unasserted streaming subscriptions open. Completing them with a + // unary `{}` response is a protocol error and correctly triggers the + // connection supervisor's recovery path. return; } sendEffectRpcExit(client, parsed.request.id, resolveWsRpc(requestBody)); diff --git a/apps/web/src/components/EventRouter.browser.tsx b/apps/web/src/components/EventRouter.browser.tsx index 61b24c771..14af5e33c 100644 --- a/apps/web/src/components/EventRouter.browser.tsx +++ b/apps/web/src/components/EventRouter.browser.tsx @@ -26,6 +26,7 @@ import { getRouter } from "../router"; import { useStore } from "../store"; import { createShellSnapshotFromReadModel, + createTestEnvironmentDescriptor, flattenEffectRpcRequestPayload, readEffectRpcClientMessage, sendEffectRpcChunk, @@ -54,6 +55,10 @@ interface EffectRpcStreamHandle { acknowledgedChunkCount: number; } +interface ClosableEffectRpcWebSocketClient extends EffectRpcWebSocketClient { + readonly close: (code?: number, reason?: string) => void; +} + let fixture: TestFixture; let serverLifecycleStream: EffectRpcStreamHandle | null = null; let shellStream: EffectRpcStreamHandle | null = null; @@ -68,6 +73,7 @@ const subscribeThreadRequestCountById = new Map(); let subscribeThreadRequests: ThreadId[] = []; let replayEvents: OrchestrationEvent[] = []; let replayRequestCursors: number[] = []; +let activeWsClient: ClosableEffectRpcWebSocketClient | null = null; const mountedAppCleanups = new Set<() => Promise>(); const wsLink = ws.link(/ws(s)?:\/\/.*/); @@ -233,6 +239,9 @@ function resolveWsRpc(tag: string, body?: unknown): unknown { if (tag === WS_METHODS.serverGetConfig) { return fixture.serverConfig; } + if (tag === WS_METHODS.serverGetEnvironment) { + return createTestEnvironmentDescriptor(); + } if (tag === WS_METHODS.gitListBranches) { return { isRepo: true, @@ -275,6 +284,7 @@ function resolveWsRpc(tag: string, body?: unknown): unknown { const worker = setupWorker( wsLink.addEventListener("connection", ({ client }) => { + activeWsClient = client; client.addEventListener("message", (event) => { if (typeof event.data !== "string") { return; @@ -495,6 +505,7 @@ describe("EventRouter scoped orchestration sync", () => { document.body.innerHTML = ""; serverLifecycleStream = null; shellStream = null; + activeWsClient = null; threadStreamByThreadId.clear(); delayNextThreadSnapshot = false; localStorage.clear(); @@ -1254,4 +1265,115 @@ describe("EventRouter scoped orchestration sync", () => { await mounted.cleanup(); } }); + + it("reconnects once and rebuilds scoped subscriptions from fresh server snapshots", async () => { + const mounted = await mountApp(); + + try { + await vi.waitFor( + () => { + expect(subscribeShellRequestCount).toBe(1); + expect(subscribeThreadRequestCountById.get(THREAD_ID)).toBe(1); + expect(activeWsClient).not.toBeNull(); + }, + { timeout: 4_000, interval: 16 }, + ); + + fixture = { + ...fixture, + snapshot: { + ...fixture.snapshot, + snapshotSequence: fixture.snapshot.snapshotSequence + 1, + threads: fixture.snapshot.threads.map((thread) => + thread.id === THREAD_ID ? { ...thread, title: "Recovered after reconnect" } : thread, + ), + }, + }; + activeWsClient?.close(1012, "test server restart"); + + await vi.waitFor( + () => { + expect(subscribeShellRequestCount).toBe(2); + expect(subscribeThreadRequestCountById.get(THREAD_ID)).toBe(2); + expect(getThreadFromState(useStore.getState(), THREAD_ID)?.title).toBe( + "Recovered after reconnect", + ); + }, + { timeout: 5_000, interval: 16 }, + ); + } finally { + fixture = buildFixture(); + await mounted.cleanup(); + } + }); + + it("does not let an older reconnect snapshot overwrite already-applied events", async () => { + const mounted = await mountApp(); + const latestMessageId = MessageId.makeUnsafe("msg-before-reconnect-boundary"); + + try { + await sendShellEventPush({ + kind: "thread-upserted", + sequence: 3, + thread: { + ...createShellSnapshotFromReadModel(fixture.snapshot).threads[0]!, + title: "Newest local title", + }, + }); + await sendThreadEventPush({ + sequence: 3, + eventId: EventId.makeUnsafe("event-before-reconnect-boundary"), + aggregateKind: "thread", + aggregateId: THREAD_ID, + occurredAt: "2026-03-04T12:00:05.000Z", + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + type: "thread.message-sent", + payload: { + threadId: THREAD_ID, + messageId: latestMessageId, + role: "assistant", + text: "Already applied before reconnect", + turnId: TurnId.makeUnsafe("turn-before-reconnect-boundary"), + source: "native", + streaming: false, + createdAt: "2026-03-04T12:00:05.000Z", + updatedAt: "2026-03-04T12:00:05.000Z", + }, + }); + await vi.waitFor(() => { + const thread = getThreadFromState(useStore.getState(), THREAD_ID); + expect(thread?.title).toBe("Newest local title"); + expect(thread?.messages.some((message) => message.id === latestMessageId)).toBe(true); + }); + + fixture = { + ...fixture, + snapshot: { + ...fixture.snapshot, + snapshotSequence: 2, + threads: fixture.snapshot.threads.map((thread) => + thread.id === THREAD_ID ? { ...thread, title: "Older reconnect title" } : thread, + ), + }, + }; + activeWsClient?.close(1012, "test stale reconnect snapshot"); + + await vi.waitFor( + () => { + expect(subscribeShellRequestCount).toBe(2); + expect(subscribeThreadRequestCountById.get(THREAD_ID)).toBe(2); + const thread = getThreadFromState(useStore.getState(), THREAD_ID); + expect(thread?.title).toBe("Newest local title"); + expect(thread?.messages.some((message) => message.id === latestMessageId)).toBe(true); + }, + { timeout: 5_000, interval: 16 }, + ); + } finally { + fixture = buildFixture(); + await mounted.cleanup(); + } + }); }); diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx index 7917f35ef..482b5b602 100644 --- a/apps/web/src/components/KeybindingsToast.browser.tsx +++ b/apps/web/src/components/KeybindingsToast.browser.tsx @@ -21,6 +21,7 @@ import { getRouter } from "../router"; import { useStore } from "../store"; import { createShellSnapshotFromReadModel, + createTestEnvironmentDescriptor, flattenEffectRpcRequestPayload, readEffectRpcClientMessage, sendEffectRpcChunk, @@ -166,6 +167,9 @@ function resolveWsRpc(tag: string): unknown { if (tag === WS_METHODS.serverGetConfig) { return fixture.serverConfig; } + if (tag === WS_METHODS.serverGetEnvironment) { + return createTestEnvironmentDescriptor(); + } if (tag === WS_METHODS.gitListBranches) { return { isRepo: true, @@ -242,8 +246,13 @@ const worker = setupWorker( method === WS_METHODS.subscribeServerProviderStatuses || method === WS_METHODS.subscribeServerSettings || method === WS_METHODS.subscribeTerminalEvents || - method === WS_METHODS.subscribeOrchestrationDomainEvents + method === WS_METHODS.subscribeOrchestrationDomainEvents || + method === WS_METHODS.subscribeProjectDevServerEvents || + method === WS_METHODS.subscribeAutomationEvents ) { + // Keep unasserted streaming subscriptions open. Completing them with a + // unary `{}` response is a protocol error and correctly triggers the + // connection supervisor's recovery path. return; } sendEffectRpcExit(client, parsed.request.id, resolveWsRpc(method)); diff --git a/apps/web/src/components/terminal/terminalRuntime.ts b/apps/web/src/components/terminal/terminalRuntime.ts index 87de059d0..64757b63f 100644 --- a/apps/web/src/components/terminal/terminalRuntime.ts +++ b/apps/web/src/components/terminal/terminalRuntime.ts @@ -46,10 +46,13 @@ import { writeSystemMessage, } from "./terminalRuntimeAppearance"; import { terminalEventDispatcher } from "./terminalEventDispatcher"; -import type { - TerminalRuntimeConfig, - TerminalRuntimeEntry, - TerminalRuntimeViewState, +import { + acceptTerminalOutputSequence, + acceptTerminalSnapshotBarrier, + type TerminalOutputEvent, + type TerminalRuntimeConfig, + type TerminalRuntimeEntry, + type TerminalRuntimeViewState, } from "./terminalRuntimeTypes"; import { waitForTerminalFontReady } from "./terminalFontSettle"; import { observeTerminalWriteParsed } from "./terminalPerformance"; @@ -682,46 +685,88 @@ async function sendTerminalInput( } } +function beginTerminalSnapshotCapture(entry: TerminalRuntimeEntry): number { + entry.snapshotReconcileActive = true; + entry.snapshotBufferedOutputEvents.length = 0; + return ++entry.snapshotReconcileRequestId; +} + +function deliverTerminalOutputEvent(entry: TerminalRuntimeEntry, event: TerminalOutputEvent): void { + if (!acceptTerminalOutputSequence(entry, event.outputEpoch, event.outputSequence)) { + acknowledgeParsedOutput(entry, event.byteLength ?? terminalByteLength(event.data)); + return; + } + setRuntimeStatus(entry, "ready"); + scheduleWrite(entry, event.data, event.byteLength ?? terminalByteLength(event.data)); +} + +function finishTerminalSnapshotCapture(entry: TerminalRuntimeEntry, requestId: number): void { + if (entry.disposed || entry.snapshotReconcileRequestId !== requestId) return; + entry.snapshotReconcileActive = false; + const buffered = entry.snapshotBufferedOutputEvents + .splice(0) + .toSorted((left, right) => left.outputSequence - right.outputSequence); + for (const event of buffered) deliverTerminalOutputEvent(entry, event); +} + +function completeTerminalSnapshotCapture( + entry: TerminalRuntimeEntry, + requestId: number, + snapshot: TerminalSessionSnapshot, + onComplete?: () => void, +): void { + if (entry.disposed || entry.snapshotReconcileRequestId !== requestId) { + return; + } + if (!entry.opened || entry.hasHandledExit) { + finishTerminalSnapshotCapture(entry, requestId); + return; + } + + const finish = () => { + if (entry.disposed || entry.snapshotReconcileRequestId !== requestId) return; + finishTerminalSnapshotCapture(entry, requestId); + setRuntimeStatus(entry, "ready"); + onComplete?.(); + }; + + // The sequence is the server-side barrier for this exact history. Live + // output is buffered while the snapshot RPC is in flight and only events + // newer than the barrier are appended after the authoritative replay. + if (acceptTerminalSnapshotBarrier(entry, snapshot.outputEpoch, snapshot.outputSequence)) { + if (snapshotHasReplayPayload(snapshot)) { + replaySnapshot(entry, snapshot, finish); + return; + } + } + finish(); +} + function reconcileTerminalSnapshot(entry: TerminalRuntimeEntry): void { - if (entry.disposed || !entry.opened || entry.hasHandledExit) return; + if (entry.disposed || !entry.opened || entry.hasHandledExit || entry.snapshotReconcileActive) { + return; + } const api = readNativeApi(); if (!api) return; - const outputEventVersionAtRequest = entry.outputEventVersion; - const requestId = ++entry.snapshotReconcileRequestId; + if (entry.snapshotReconcileTimer !== null) { + window.clearTimeout(entry.snapshotReconcileTimer); + entry.snapshotReconcileTimer = null; + } + + const requestId = beginTerminalSnapshotCapture(entry); setRuntimeStatus(entry, "connecting"); void api.terminal .open(buildOpenInput(entry)) .then((snapshot) => { - if ( - entry.disposed || - !entry.opened || - entry.hasHandledExit || - entry.snapshotReconcileRequestId !== requestId - ) { - return; - } - - if (entry.outputEventVersion !== outputEventVersionAtRequest) { - return; - } - - if (snapshotHasReplayPayload(snapshot)) { - replaySnapshot(entry, snapshot, () => { - if (!entry.disposed && entry.snapshotReconcileRequestId === requestId) { - setRuntimeStatus(entry, "ready"); - } - }); - return; - } - - setRuntimeStatus(entry, "ready"); + completeTerminalSnapshotCapture(entry, requestId, snapshot); }) .catch((error) => { if (entry.disposed || !entry.opened || entry.snapshotReconcileRequestId !== requestId) { return; } + finishTerminalSnapshotCapture(entry, requestId); setRuntimeStatus(entry, "error"); writeSystemMessage( entry.terminal, @@ -822,8 +867,12 @@ export function createRuntimeEntry(config: TerminalRuntimeConfig): TerminalRunti pendingWriteLength: 0, pendingWriteBytes: 0, linkMatchCache: new Map(), - outputEventVersion: 0, + lastOutputEpoch: null, + lastOutputSequence: 0, + snapshotReconcileActive: false, + snapshotBufferedOutputEvents: [], snapshotReconcileRequestId: 0, + snapshotReconcileTimer: null, webglLoadFrame: null, themeRefreshFrame: 0, themeObserver: null, @@ -870,7 +919,7 @@ export function createRuntimeEntry(config: TerminalRuntimeConfig): TerminalRunti reconcileTerminalSnapshot(entry); return; } - if (state === "connecting" || state === "closed") { + if (state === "connecting" || state === "reconnecting") { setRuntimeStatus(entry, "connecting"); } }); @@ -1006,14 +1055,19 @@ export function createRuntimeEntry(config: TerminalRuntimeConfig): TerminalRunti entry.terminalId, (event) => { if (event.type === "output") { - setRuntimeStatus(entry, "ready"); - entry.outputEventVersion += 1; - scheduleWrite(entry, event.data, event.byteLength ?? terminalByteLength(event.data)); + if (entry.snapshotReconcileActive) { + entry.snapshotBufferedOutputEvents.push(event); + } else { + deliverTerminalOutputEvent(entry, event); + } return; } if (event.type === "started" || event.type === "restarted") { entry.hasHandledExit = false; + if (entry.snapshotReconcileActive) return; + entry.lastOutputEpoch = event.snapshot.outputEpoch; + entry.lastOutputSequence = event.snapshot.outputSequence; const shouldReplaySnapshot = event.type === "restarted" || snapshotHasReplayPayload(event.snapshot); if (shouldReplaySnapshot) { @@ -1091,53 +1145,30 @@ function openTerminal(entry: TerminalRuntimeEntry): void { entry.lastSentResize = null; entry.opened = true; setRuntimeStatus(entry, "connecting"); - const outputEventVersionAtOpen = entry.outputEventVersion; const openInput = buildOpenInput(entry); + const requestId = beginTerminalSnapshotCapture(entry); void api.terminal .open(openInput) .then((snapshot) => { - if (entry.disposed) return; - if ( - snapshotHasReplayPayload(snapshot) && - entry.outputEventVersion === outputEventVersionAtOpen - ) { - replaySnapshot(entry, snapshot, () => setRuntimeStatus(entry, "ready")); - } else if (entry.outputEventVersion === outputEventVersionAtOpen) { - setRuntimeStatus(entry, "ready"); - window.setTimeout(() => { - if ( - entry.disposed || - !entry.opened || - entry.outputEventVersion !== outputEventVersionAtOpen - ) { - return; - } - void api.terminal - .open(openInput) - .then((nextSnapshot) => { - if ( - entry.disposed || - entry.outputEventVersion !== outputEventVersionAtOpen || - !snapshotHasReplayPayload(nextSnapshot) - ) { - return; - } - replaySnapshot(entry, nextSnapshot, () => setRuntimeStatus(entry, "ready")); - }) - .catch(() => { - // Best-effort recovery only; the original open already succeeded. - }); - }, OPEN_SNAPSHOT_RECONCILE_DELAY_MS); - } - if (entry.viewState.autoFocus) { - window.requestAnimationFrame(() => { - entry.terminal.focus(); - }); - } + const shouldReconcileEmptySnapshot = !snapshotHasReplayPayload(snapshot); + completeTerminalSnapshotCapture(entry, requestId, snapshot, () => { + if (shouldReconcileEmptySnapshot && entry.lastOutputSequence === snapshot.outputSequence) { + entry.snapshotReconcileTimer = window.setTimeout(() => { + entry.snapshotReconcileTimer = null; + reconcileTerminalSnapshot(entry); + }, OPEN_SNAPSHOT_RECONCILE_DELAY_MS); + } + if (entry.viewState.autoFocus) { + window.requestAnimationFrame(() => { + entry.terminal.focus(); + }); + } + }); }) .catch((error) => { if (entry.disposed) return; + finishTerminalSnapshotCapture(entry, requestId); entry.opened = false; setRuntimeStatus(entry, "error"); writeSystemMessage(entry.terminal, describeErrorMessage(error, "Failed to open terminal")); @@ -1209,6 +1240,12 @@ export function disposeRuntimeEntry(entry: TerminalRuntimeEntry): void { // Closing a terminal should not synchronously paint queued output into a buffer // that is about to be destroyed; acknowledge and drop it to keep close latency low. clearPendingWrites(entry); + if (entry.snapshotReconcileTimer !== null) { + window.clearTimeout(entry.snapshotReconcileTimer); + entry.snapshotReconcileTimer = null; + } + entry.snapshotReconcileActive = false; + entry.snapshotBufferedOutputEvents.length = 0; entry.unsubscribeTerminalEvents?.(); entry.unsubscribeTerminalEvents = null; entry.querySuppressionDispose?.(); diff --git a/apps/web/src/components/terminal/terminalRuntimeTypes.test.ts b/apps/web/src/components/terminal/terminalRuntimeTypes.test.ts index 4d6183ce2..747c057d1 100644 --- a/apps/web/src/components/terminal/terminalRuntimeTypes.test.ts +++ b/apps/web/src/components/terminal/terminalRuntimeTypes.test.ts @@ -4,10 +4,32 @@ import { describe, expect, it } from "vitest"; -import { buildTerminalRuntimeKey } from "./terminalRuntimeTypes"; +import { + acceptTerminalOutputSequence, + acceptTerminalSnapshotBarrier, + buildTerminalRuntimeKey, +} from "./terminalRuntimeTypes"; describe("buildTerminalRuntimeKey", () => { it("builds a thread-scoped runtime key for terminal persistence", () => { expect(buildTerminalRuntimeKey("thread-123", "terminal-abc")).toBe("thread-123::terminal-abc"); }); }); + +describe("terminal output barriers", () => { + it("resets the live sequence namespace when the server epoch changes", () => { + const barrier = { lastOutputEpoch: "server-old", lastOutputSequence: 42 }; + + expect(acceptTerminalOutputSequence(barrier, "server-new", 1)).toBe(true); + expect(barrier).toEqual({ lastOutputEpoch: "server-new", lastOutputSequence: 1 }); + expect(acceptTerminalOutputSequence(barrier, "server-new", 1)).toBe(false); + }); + + it("rejects older snapshots only within the same server epoch", () => { + const barrier = { lastOutputEpoch: "server-old", lastOutputSequence: 42 }; + + expect(acceptTerminalSnapshotBarrier(barrier, "server-old", 41)).toBe(false); + expect(acceptTerminalSnapshotBarrier(barrier, "server-new", 0)).toBe(true); + expect(barrier).toEqual({ lastOutputEpoch: "server-new", lastOutputSequence: 0 }); + }); +}); diff --git a/apps/web/src/components/terminal/terminalRuntimeTypes.ts b/apps/web/src/components/terminal/terminalRuntimeTypes.ts index 7c60df77d..75645d2b2 100644 --- a/apps/web/src/components/terminal/terminalRuntimeTypes.ts +++ b/apps/web/src/components/terminal/terminalRuntimeTypes.ts @@ -5,6 +5,7 @@ import { FitAddon } from "@xterm/addon-fit"; import { SearchAddon } from "@xterm/addon-search"; import { WebglAddon } from "@xterm/addon-webgl"; +import type { TerminalEvent } from "@synara/contracts"; import { type TerminalActivityState, type TerminalCliKind } from "@synara/shared/terminalThreads"; import { Terminal, type IDisposable } from "@xterm/xterm"; import type { TerminalLinkMatch } from "../../terminal-links"; @@ -48,8 +49,45 @@ export interface TerminalPendingWrite { queuedAt: number; } +export type TerminalOutputEvent = Extract; + export type TerminalRuntimeStatus = "connecting" | "replaying" | "ready" | "error"; +export interface TerminalOutputBarrier { + lastOutputEpoch: string | null; + lastOutputSequence: number; +} + +/** Advances a live-output barrier, resetting its sequence namespace after a server restart. */ +export function acceptTerminalOutputSequence( + barrier: TerminalOutputBarrier, + outputEpoch: string, + outputSequence: number, +): boolean { + if (outputEpoch !== barrier.lastOutputEpoch) { + barrier.lastOutputEpoch = outputEpoch; + barrier.lastOutputSequence = 0; + } + if (outputSequence <= barrier.lastOutputSequence) return false; + barrier.lastOutputSequence = outputSequence; + return true; +} + +/** Applies an authoritative snapshot unless a newer event in the same epoch already arrived. */ +export function acceptTerminalSnapshotBarrier( + barrier: TerminalOutputBarrier, + outputEpoch: string, + outputSequence: number, +): boolean { + if (outputEpoch !== barrier.lastOutputEpoch) { + barrier.lastOutputEpoch = outputEpoch; + barrier.lastOutputSequence = 0; + } + if (outputSequence < barrier.lastOutputSequence) return false; + barrier.lastOutputSequence = outputSequence; + return true; +} + export interface TerminalRuntimeEntry { runtimeKey: string; threadId: string; @@ -83,8 +121,12 @@ export interface TerminalRuntimeEntry { pendingWriteLength: number; pendingWriteBytes: number; linkMatchCache: Map; - outputEventVersion: number; + lastOutputEpoch: string | null; + lastOutputSequence: number; + snapshotReconcileActive: boolean; + snapshotBufferedOutputEvents: TerminalOutputEvent[]; snapshotReconcileRequestId: number; + snapshotReconcileTimer: number | null; webglLoadFrame: number | null; themeRefreshFrame: number; themeObserver: MutationObserver | null; diff --git a/apps/web/src/connectionSupervisor.test.ts b/apps/web/src/connectionSupervisor.test.ts new file mode 100644 index 000000000..29c0a008e --- /dev/null +++ b/apps/web/src/connectionSupervisor.test.ts @@ -0,0 +1,222 @@ +// FILE: connectionSupervisor.test.ts +// Purpose: Locks single-owner connection retry, generation, and wake-probe behavior. +// Layer: Web transport lifecycle tests +// Depends on: ConnectionSupervisor and deterministic timers. + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { ConnectionSupervisor, type ConnectionSupervisorSession } from "./connectionSupervisor"; + +interface TestSession { + readonly id: number; +} + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, reject, resolve }; +} + +function makeHarness( + connect: (generation: number) => Promise = async (generation) => ({ + id: generation, + }), +) { + const closed: Array> = []; + const ready: Array> = []; + const retries: Array<{ attempt: number; delayMs: number; reason: string }> = []; + const probe = vi.fn(async () => undefined); + const supervisor = new ConnectionSupervisor({ + connect, + close: (session) => { + closed.push(session); + }, + probe, + random: () => 0.5, + onReady: (session) => ready.push(session), + onRetryScheduled: (retry) => retries.push(retry), + }); + return { closed, probe, ready, retries, supervisor }; +} + +beforeEach(() => { + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("ConnectionSupervisor", () => { + it("shares one validated generation across concurrent waiters", async () => { + const first = deferred(); + const connect = vi.fn(() => first.promise); + const harness = makeHarness(connect); + + const left = harness.supervisor.waitForSession(); + const right = harness.supervisor.waitForSession(); + expect(connect).toHaveBeenCalledOnce(); + + first.resolve({ id: 10 }); + + await expect(left).resolves.toEqual({ generation: 1, value: { id: 10 } }); + await expect(right).resolves.toEqual({ generation: 1, value: { id: 10 } }); + expect(harness.ready).toHaveLength(1); + expect(harness.supervisor.snapshot.phase).toBe("ready"); + }); + + it("backs off 1, 2, 4, 8, and 16 seconds with one retry owner", async () => { + const connect = vi.fn(async () => { + throw new Error("offline"); + }); + const harness = makeHarness(connect); + + harness.supervisor.start(); + await vi.advanceTimersByTimeAsync(0); + for (const delay of [1_000, 2_000, 4_000, 8_000]) { + await vi.advanceTimersByTimeAsync(delay); + } + + expect(harness.retries.map(({ delayMs }) => delayMs)).toEqual([ + 1_000, 2_000, 4_000, 8_000, 16_000, + ]); + expect(connect).toHaveBeenCalledTimes(5); + expect(vi.getTimerCount()).toBe(1); + harness.supervisor.dispose(); + }); + + it("settles a caller waiting on an unavailable connection without stopping recovery", async () => { + const neverConnects = deferred(); + const harness = makeHarness(() => neverConnects.promise); + const waiting = harness.supervisor.waitForSession({ timeoutMs: 250 }); + const rejection = expect(waiting).rejects.toThrow("Connection unavailable after 250ms"); + + await vi.advanceTimersByTimeAsync(250); + + await rejection; + expect(harness.supervisor.snapshot.phase).toBe("connecting"); + harness.supervisor.dispose(); + neverConnects.resolve({ id: 1 }); + }); + + it("ignores stale failures after a replacement generation becomes ready", async () => { + const harness = makeHarness(); + const first = await harness.supervisor.waitForSession(); + + expect(harness.supervisor.invalidate(first.generation, "socket closed")).toBe(true); + await vi.advanceTimersByTimeAsync(1_000); + const second = await harness.supervisor.waitForSession(); + + expect(second.generation).toBe(2); + expect(harness.supervisor.invalidate(first.generation, "late stream exit")).toBe(false); + expect(harness.supervisor.currentSession).toEqual(second); + expect(harness.retries).toHaveLength(1); + }); + + it("waits for the old session to close before opening its replacement", async () => { + const closeFinished = deferred(); + const connect = vi.fn(async (generation: number) => ({ id: generation })); + const supervisor = new ConnectionSupervisor({ + connect, + close: () => closeFinished.promise, + probe: async () => undefined, + random: () => 0.5, + }); + const first = await supervisor.waitForSession(); + + supervisor.invalidate(first.generation, "socket closed"); + await vi.advanceTimersByTimeAsync(1_000); + expect(connect).toHaveBeenCalledOnce(); + + closeFinished.resolve(); + await vi.advanceTimersByTimeAsync(0); + const second = await supervisor.waitForSession(); + expect(second.generation).toBe(2); + expect(connect).toHaveBeenCalledTimes(2); + supervisor.dispose(); + }); + + it("recovers after bounded teardown when an old session never disposes", async () => { + const neverCloses = deferred(); + const onError = vi.fn(); + const connect = vi.fn(async (generation: number) => ({ id: generation })); + const supervisor = new ConnectionSupervisor({ + connect, + close: () => neverCloses.promise, + closeTimeoutMs: 250, + probe: async () => undefined, + random: () => 0.5, + onError, + }); + const first = await supervisor.waitForSession(); + + supervisor.invalidate(first.generation, "socket closed"); + await vi.advanceTimersByTimeAsync(249); + expect(connect).toHaveBeenCalledOnce(); + + await vi.advanceTimersByTimeAsync(751); + const second = await supervisor.waitForSession(); + expect(second.generation).toBe(2); + expect(connect).toHaveBeenCalledTimes(2); + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringContaining("disposal timed out") }), + "generation 1 invalidation", + ); + + supervisor.dispose(); + neverCloses.resolve(); + }); + + it("probes a ready session and reconnects when the probe fails", async () => { + const harness = makeHarness(); + const first = await harness.supervisor.waitForSession(); + harness.probe.mockRejectedValueOnce(new Error("stale socket")); + + await harness.supervisor.probe("resume"); + + expect(harness.closed).toEqual([first]); + expect(harness.supervisor.snapshot).toMatchObject({ + phase: "reconnecting", + retryDelayMs: 1_000, + }); + await harness.supervisor.probe("window focus"); + const second = await harness.supervisor.waitForSession(); + expect(second.generation).toBe(2); + }); + + it("does not let an old generation's probe suppress probing its replacement", async () => { + const harness = makeHarness(); + const first = await harness.supervisor.waitForSession(); + const oldProbe = deferred(); + harness.probe.mockImplementationOnce(() => oldProbe.promise).mockResolvedValueOnce(undefined); + + const firstProbe = harness.supervisor.probe("first focus"); + harness.supervisor.invalidate(first.generation, "stream closed"); + await vi.advanceTimersByTimeAsync(1_000); + const second = await harness.supervisor.waitForSession(); + await harness.supervisor.probe("second focus"); + + expect(second.generation).toBe(2); + expect(harness.probe).toHaveBeenCalledTimes(2); + oldProbe.resolve(undefined); + await firstProbe; + }); + + it("closes a connect result that arrives after disposal", async () => { + const pending = deferred(); + const harness = makeHarness(() => pending.promise); + const waiting = harness.supervisor.waitForSession(); + + harness.supervisor.dispose(); + pending.resolve({ id: 1 }); + + await expect(waiting).rejects.toThrow("disposed"); + await vi.advanceTimersByTimeAsync(0); + expect(harness.closed).toEqual([{ generation: 1, value: { id: 1 } }]); + expect(harness.supervisor.snapshot.phase).toBe("disposed"); + }); +}); diff --git a/apps/web/src/connectionSupervisor.ts b/apps/web/src/connectionSupervisor.ts new file mode 100644 index 000000000..444657fcf --- /dev/null +++ b/apps/web/src/connectionSupervisor.ts @@ -0,0 +1,338 @@ +// FILE: connectionSupervisor.ts +// Purpose: Owns one desired browser-to-server connection across retries and wake probes. +// Layer: Web transport lifecycle +// Exports: ConnectionSupervisor and its observable lifecycle snapshot. + +export type ConnectionSupervisorPhase = "connecting" | "ready" | "reconnecting" | "disposed"; + +export interface ConnectionSupervisorSession { + readonly generation: number; + readonly value: T; +} + +export interface ConnectionSupervisorSnapshot { + readonly phase: ConnectionSupervisorPhase; + readonly generation: number | null; + readonly retryAttempt: number; + readonly retryDelayMs: number | null; +} + +export interface ConnectionSupervisorOptions { + readonly connect: (generation: number) => Promise; + readonly close: (session: ConnectionSupervisorSession) => Promise | void; + readonly probe: (session: ConnectionSupervisorSession) => Promise; + readonly onReady?: (session: ConnectionSupervisorSession) => void; + readonly onInvalidated?: (session: ConnectionSupervisorSession, reason: string) => void; + readonly onSnapshot?: (snapshot: ConnectionSupervisorSnapshot) => void; + readonly onError?: (error: unknown, context: string) => void; + readonly onRetryScheduled?: (input: { + readonly attempt: number; + readonly delayMs: number; + readonly reason: string; + }) => void; + readonly setTimer?: typeof setTimeout; + readonly clearTimer?: typeof clearTimeout; + readonly random?: () => number; + readonly retryBaseDelayMs?: number; + readonly retryMaxDelayMs?: number; + readonly retryJitterRatio?: number; + /** + * Maximum time replacement creation waits for an old session to dispose. + * A timed-out session remains stale by generation and may finish disposing in + * the background, but it cannot indefinitely block recovery. + */ + readonly closeTimeoutMs?: number; +} + +interface SessionWaiter { + readonly resolve: (session: ConnectionSupervisorSession) => void; + readonly reject: (error: Error) => void; +} + +const DEFAULT_RETRY_BASE_DELAY_MS = 1_000; +const DEFAULT_RETRY_MAX_DELAY_MS = 16_000; +const DEFAULT_RETRY_JITTER_RATIO = 0.2; +const DEFAULT_CLOSE_TIMEOUT_MS = 5_000; + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +/** + * Serializes connection creation, invalidation, retry, and wake probing. Callers + * may report the same broken generation more than once; only the current generation + * can change state, so late stream exits cannot replace a healthy session. + */ +export class ConnectionSupervisor { + readonly #options: ConnectionSupervisorOptions; + readonly #setTimer: typeof setTimeout; + readonly #clearTimer: typeof clearTimeout; + readonly #random: () => number; + + #desiredRunning = false; + #disposed = false; + #generation = 0; + #active: ConnectionSupervisorSession | null = null; + #connectInFlight: Promise | null = null; + #closeInFlight: Promise | null = null; + #probeInFlight: { readonly generation: number; readonly promise: Promise } | null = null; + #retryTimer: ReturnType | null = null; + #retryAttempt = 0; + #hasBeenReady = false; + #snapshot: ConnectionSupervisorSnapshot = { + phase: "connecting", + generation: null, + retryAttempt: 0, + retryDelayMs: null, + }; + readonly #waiters = new Set>(); + + constructor(options: ConnectionSupervisorOptions) { + this.#options = options; + this.#setTimer = options.setTimer ?? globalThis.setTimeout.bind(globalThis); + this.#clearTimer = options.clearTimer ?? globalThis.clearTimeout.bind(globalThis); + this.#random = options.random ?? Math.random; + } + + get snapshot(): ConnectionSupervisorSnapshot { + return this.#snapshot; + } + + get currentSession(): ConnectionSupervisorSession | null { + return this.#active; + } + + start(): void { + if (this.#disposed) return; + this.#desiredRunning = true; + if (!this.#active && !this.#connectInFlight && !this.#retryTimer) { + this.#beginConnect(); + } + } + + waitForSession(options?: { + readonly timeoutMs?: number; + }): Promise> { + if (this.#disposed) { + return Promise.reject(new Error("Connection supervisor disposed")); + } + if (this.#active) return Promise.resolve(this.#active); + this.start(); + return new Promise((resolve, reject) => { + let timeout: ReturnType | null = null; + const waiter: SessionWaiter = { + resolve: (session) => { + if (timeout !== null) this.#clearTimer(timeout); + this.#waiters.delete(waiter); + resolve(session); + }, + reject: (error) => { + if (timeout !== null) this.#clearTimer(timeout); + this.#waiters.delete(waiter); + reject(error); + }, + }; + this.#waiters.add(waiter); + const timeoutMs = options?.timeoutMs; + if (timeoutMs !== undefined) { + timeout = this.#setTimer( + () => { + waiter.reject(new Error(`Connection unavailable after ${Math.max(0, timeoutMs)}ms`)); + }, + Math.max(0, timeoutMs), + ); + } + }); + } + + invalidate(generation: number, reason: string): boolean { + const active = this.#active; + if (this.#disposed || !active || active.generation !== generation) return false; + + this.#active = null; + this.#options.onInvalidated?.(active, reason); + this.#close(active, `generation ${generation} invalidation`); + this.#scheduleRetry(reason); + return true; + } + + probe(reason: string): Promise { + if (this.#disposed) return Promise.resolve(); + this.start(); + if (!this.#active) { + this.#retryNow(); + return this.#connectInFlight ?? Promise.resolve(); + } + if (this.#probeInFlight?.generation === this.#active.generation) { + return this.#probeInFlight.promise; + } + + const session = this.#active; + const probe = this.#options + .probe(session) + .catch((error: unknown) => { + if (this.#active?.generation !== session.generation) return; + this.#options.onError?.(error, `generation ${session.generation} wake probe`); + this.invalidate(session.generation, `${reason}: ${errorMessage(error)}`); + }) + .finally(() => { + if (this.#probeInFlight?.promise === probe) this.#probeInFlight = null; + }); + this.#probeInFlight = { generation: session.generation, promise: probe }; + return probe; + } + + dispose(): void { + if (this.#disposed) return; + this.#disposed = true; + this.#desiredRunning = false; + this.#clearRetryTimer(); + this.#generation += 1; + + const active = this.#active; + this.#active = null; + if (active) { + this.#options.onInvalidated?.(active, "disposed"); + this.#close(active, `generation ${active.generation} disposal`); + } + const error = new Error("Connection supervisor disposed"); + for (const waiter of this.#waiters) waiter.reject(error); + this.#waiters.clear(); + this.#publish({ + phase: "disposed", + generation: null, + retryAttempt: this.#retryAttempt, + retryDelayMs: null, + }); + } + + #beginConnect(): void { + if (this.#disposed || !this.#desiredRunning || this.#active || this.#connectInFlight) { + return; + } + this.#clearRetryTimer(); + const generation = ++this.#generation; + this.#publish({ + phase: this.#hasBeenReady ? "reconnecting" : "connecting", + generation, + retryAttempt: this.#retryAttempt, + retryDelayMs: null, + }); + + const connectResult = this.#closeInFlight + ? this.#closeInFlight.then(() => this.#options.connect(generation)) + : this.#options.connect(generation); + const connecting = connectResult + .then((value) => { + const session = { generation, value } satisfies ConnectionSupervisorSession; + if (this.#disposed || !this.#desiredRunning || generation !== this.#generation) { + this.#close(session, `stale generation ${generation}`); + return; + } + this.#active = session; + this.#hasBeenReady = true; + this.#retryAttempt = 0; + this.#publish({ + phase: "ready", + generation, + retryAttempt: 0, + retryDelayMs: null, + }); + for (const waiter of this.#waiters) waiter.resolve(session); + this.#waiters.clear(); + this.#options.onReady?.(session); + }) + .catch((error: unknown) => { + if (this.#disposed || !this.#desiredRunning || generation !== this.#generation) return; + this.#options.onError?.(error, `generation ${generation} connect`); + this.#scheduleRetry(errorMessage(error)); + }) + .finally(() => { + if (this.#connectInFlight === connecting) { + this.#connectInFlight = null; + if (!this.#disposed && this.#desiredRunning && !this.#active && !this.#retryTimer) { + this.#beginConnect(); + } + } + }); + this.#connectInFlight = connecting; + } + + #scheduleRetry(reason: string): void { + if (this.#disposed || !this.#desiredRunning || this.#retryTimer) return; + const attempt = this.#retryAttempt; + const baseDelay = this.#options.retryBaseDelayMs ?? DEFAULT_RETRY_BASE_DELAY_MS; + const maxDelay = this.#options.retryMaxDelayMs ?? DEFAULT_RETRY_MAX_DELAY_MS; + const jitterRatio = Math.max( + 0, + Math.min(this.#options.retryJitterRatio ?? DEFAULT_RETRY_JITTER_RATIO, 1), + ); + const exponentialDelay = Math.min(baseDelay * 2 ** attempt, maxDelay); + const jitterMultiplier = 1 + (this.#random() * 2 - 1) * jitterRatio; + const delayMs = Math.max(0, Math.round(exponentialDelay * jitterMultiplier)); + this.#retryAttempt += 1; + this.#publish({ + phase: this.#hasBeenReady ? "reconnecting" : "connecting", + generation: null, + retryAttempt: this.#retryAttempt, + retryDelayMs: delayMs, + }); + this.#options.onRetryScheduled?.({ attempt, delayMs, reason }); + this.#retryTimer = this.#setTimer(() => { + this.#retryTimer = null; + this.#beginConnect(); + }, delayMs); + } + + #retryNow(): void { + if (this.#disposed || !this.#desiredRunning || this.#active) return; + if (this.#retryTimer) { + this.#clearRetryTimer(); + } + this.#beginConnect(); + } + + #clearRetryTimer(): void { + if (!this.#retryTimer) return; + this.#clearTimer(this.#retryTimer); + this.#retryTimer = null; + } + + #close(session: ConnectionSupervisorSession, context: string): void { + const previousClose = this.#closeInFlight ?? Promise.resolve(); + const closing = previousClose + .then(() => this.#closeWithTimeout(session)) + .catch((error: unknown) => { + this.#options.onError?.(error, context); + }) + .finally(() => { + if (this.#closeInFlight === closing) this.#closeInFlight = null; + }); + this.#closeInFlight = closing; + } + + async #closeWithTimeout(session: ConnectionSupervisorSession): Promise { + const timeoutMs = Math.max(0, this.#options.closeTimeoutMs ?? DEFAULT_CLOSE_TIMEOUT_MS); + let timeout: ReturnType | null = null; + const close = Promise.resolve().then(() => this.#options.close(session)); + const timedOut = new Promise((_, reject) => { + timeout = this.#setTimer(() => { + reject( + new Error( + `Connection generation ${session.generation} disposal timed out after ${timeoutMs}ms`, + ), + ); + }, timeoutMs); + }); + try { + await Promise.race([close, timedOut]); + } finally { + if (timeout !== null) this.#clearTimer(timeout); + } + } + + #publish(snapshot: ConnectionSupervisorSnapshot): void { + this.#snapshot = snapshot; + this.#options.onSnapshot?.(snapshot); + } +} diff --git a/apps/web/src/projectTerminalRunner.test.ts b/apps/web/src/projectTerminalRunner.test.ts index 5f11f2cf3..f8084d7fd 100644 --- a/apps/web/src/projectTerminalRunner.test.ts +++ b/apps/web/src/projectTerminalRunner.test.ts @@ -11,6 +11,8 @@ describe("runProjectCommandInTerminal", () => { status: "running", pid: 1234, history: "", + outputEpoch: "epoch-1", + outputSequence: 0, exitCode: null, exitSignal: null, updatedAt: "2026-01-01T00:00:00.000Z", diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index de0abebd2..06a892f36 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -1244,6 +1244,9 @@ function EventRouter() { const unsubShellEvent = api.orchestration.onShellEvent((item) => { if (item.kind === "snapshot") { + if (item.snapshot.snapshotSequence < shellSnapshotSequence) { + return; + } shellSnapshotSequence = item.snapshot.snapshotSequence; syncServerShellSnapshot(item.snapshot); reconcilePromotedDraftsFromShellThreads(item.snapshot.threads); diff --git a/apps/web/src/terminalActivity.test.ts b/apps/web/src/terminalActivity.test.ts index eefcf9a3e..a51795959 100644 --- a/apps/web/src/terminalActivity.test.ts +++ b/apps/web/src/terminalActivity.test.ts @@ -10,6 +10,8 @@ const snapshot: TerminalSessionSnapshot = { status: "running", pid: 1234, history: "", + outputEpoch: "epoch-1", + outputSequence: 0, exitCode: null, exitSignal: null, updatedAt: "2026-01-01T00:00:00.000Z", @@ -71,6 +73,8 @@ describe("terminalActivityFromEvent", () => { ...eventBase(), type: "output", data: "hello", + outputEpoch: "epoch-1", + outputSequence: 1, }), ).toBeNull(); expect( diff --git a/apps/web/src/test/effectRpcWebSocketMock.ts b/apps/web/src/test/effectRpcWebSocketMock.ts index 7c9376df5..360a22156 100644 --- a/apps/web/src/test/effectRpcWebSocketMock.ts +++ b/apps/web/src/test/effectRpcWebSocketMock.ts @@ -3,7 +3,12 @@ // Layer: Web test utility // Exports: helpers for request parsing plus Exit/Chunk/Pong responses. -import type { OrchestrationReadModel, OrchestrationShellSnapshot } from "@synara/contracts"; +import { + EnvironmentId, + type ExecutionEnvironmentDescriptor, + type OrchestrationReadModel, + type OrchestrationShellSnapshot, +} from "@synara/contracts"; export interface EffectRpcWebSocketClient { readonly send: (data: string) => void; @@ -157,3 +162,13 @@ export function createShellSnapshotFromReadModel( updatedAt: snapshot.updatedAt, }; } + +export function createTestEnvironmentDescriptor(): ExecutionEnvironmentDescriptor { + return { + environmentId: EnvironmentId.makeUnsafe("test-environment"), + label: "Browser test", + platform: { os: "linux", arch: "x64" }, + serverVersion: "0.0.0-test", + capabilities: { repositoryIdentity: true }, + }; +} diff --git a/apps/web/src/wsNativeApi.test.ts b/apps/web/src/wsNativeApi.test.ts index ae5a57105..40ace20f6 100644 --- a/apps/web/src/wsNativeApi.test.ts +++ b/apps/web/src/wsNativeApi.test.ts @@ -348,6 +348,8 @@ describe("wsNativeApi", () => { createdAt: "2026-02-24T00:00:00.000Z", type: "output", data: "hello", + outputEpoch: "epoch-1", + outputSequence: 1, } as const; emitPush(WS_CHANNELS.terminalEvent, terminalEvent); diff --git a/apps/web/src/wsTransport.ts b/apps/web/src/wsTransport.ts index 01ed74550..aaf89ccf8 100644 --- a/apps/web/src/wsTransport.ts +++ b/apps/web/src/wsTransport.ts @@ -29,6 +29,7 @@ import { Cause, Data, Effect, Exit, Layer, ManagedRuntime, Scope, Stream } from import { RpcClient, RpcSerialization } from "effect/unstable/rpc"; import * as Socket from "effect/unstable/socket/Socket"; +import { ConnectionSupervisor, type ConnectionSupervisorSession } from "./connectionSupervisor"; import type { WsTransportState } from "./wsTransportEvents"; type PushListener = (message: WsPushMessage) => void; @@ -43,8 +44,16 @@ type RpcClientInstance = type SessionHandle = { readonly client: RpcClientInstance; readonly runtime: ManagedRuntime.ManagedRuntime; + readonly clientScope: Scope.Closeable; }; +type ActiveSession = ConnectionSupervisorSession; + +interface StreamCleanup { + readonly generation: number; + readonly cancel: () => void; +} + class WsTransportRpcError extends Data.TaggedError("WsTransportRpcError")<{ readonly message: string; readonly cause?: unknown; @@ -60,6 +69,8 @@ const makeRpcClient = RpcClient.make(WsRpcGroup); // opts out for known long-running calls (git actions, compaction, provider // updates) whose duration is bounded elsewhere. const REQUEST_TIMEOUT_MS = 60_000; +const SESSION_VALIDATION_TIMEOUT_MS = 15_000; +const WAKE_PROBE_TIMEOUT_MS = 5_000; function resolveRpcUrl(rawUrl: string): string { const url = new URL(rawUrl); @@ -97,6 +108,19 @@ function causeToError(cause: Cause.Cause): Error { return error instanceof Error ? error : new Error(String(error)); } +function connectionErrorSummary(error: unknown): string { + const message = error instanceof Error ? error.message : String(error); + const socketCloseCode = message.match(/SocketCloseError:\s*(\d{4})/i)?.[1]; + if (socketCloseCode) return `socket closed (${socketCloseCode})`; + if (/timed?\s*out|timeout/i.test(message)) return "connection timed out"; + if (/schema|decode|encode|protocol/i.test(message)) return "protocol validation failed"; + if (/ECONNREFUSED|connection refused|server unavailable/i.test(message)) { + return "server unavailable"; + } + if (/disposed|interrupt/i.test(message)) return "connection closed"; + return "connection operation failed"; +} + function omitNullUserInputAnswers(input: unknown): unknown { if (!input || typeof input !== "object") { return input; @@ -135,24 +159,37 @@ export class WsTransport { private readonly stateListeners = new Set<(state: WsTransportState) => void>(); private readonly latestPushByChannel = new Map(); private sequence = 0; - private sessionVersion = 0; private state: WsTransportState = "connecting"; private disposed = false; - private runtime: ManagedRuntime.ManagedRuntime; - private clientScope: Scope.Closeable; - private clientPromise: Promise; - private reconnectPromise: Promise | null = null; - private reconnectFailures = 0; - private readonly streamCleanups = new Map void>(); + private readonly supervisor: ConnectionSupervisor; + private readonly streamCleanups = new Map(); + private readonly wakeCleanups: Array<() => void> = []; private shellSubscribed = false; private readonly threadSubscriptions = new Map(); constructor(url?: string) { this.explicitUrl = url ?? null; - const session = this.createSession(); - this.runtime = session.runtime; - this.clientScope = session.clientScope; - this.clientPromise = session.clientPromise; + this.supervisor = new ConnectionSupervisor({ + connect: () => this.createValidatedSession(), + close: ({ value }) => this.closeSession(value), + probe: ({ value }) => + this.validateSession(value, WAKE_PROBE_TIMEOUT_MS, WS_METHODS.serverGetEnvironment), + onReady: (session) => this.restoreStreams(session), + onInvalidated: (session) => this.stopGenerationStreams(session.generation), + onSnapshot: (snapshot) => { + if (snapshot.phase === "ready") this.setState("open"); + else if (snapshot.phase === "reconnecting") this.setState("reconnecting"); + else if (snapshot.phase === "connecting") this.setState("connecting"); + else this.setState("disposed"); + }, + onError: (error, context) => { + if (!this.disposed) { + console.warn(`[scient-connection] ${context}: ${connectionErrorSummary(error)}`); + } + }, + }); + this.installWakeRecovery(); + this.supervisor.start(); } async request( @@ -161,14 +198,12 @@ export class WsTransport { options?: { readonly timeoutMs?: number | null }, ): Promise { if (this.disposed) throw new Error("Transport disposed"); - const session = await this.getSession(); - - if (method === WS_METHODS.gitRunStackedAction) { - return (await this.runGitActionStream(session, params)) as T; - } + const timeoutMs = options?.timeoutMs === undefined ? REQUEST_TIMEOUT_MS : options.timeoutMs; if (method === ORCHESTRATION_WS_METHODS.subscribeShell) { this.shellSubscribed = true; + const session = await this.getSession(REQUEST_TIMEOUT_MS); + if (!this.shellSubscribed) return undefined as T; this.startShellStream(session); return undefined as T; } @@ -180,6 +215,8 @@ export class WsTransport { if (method === ORCHESTRATION_WS_METHODS.subscribeThread) { const threadId = (params as { threadId: string }).threadId; this.threadSubscriptions.set(threadId, params); + const session = await this.getSession(REQUEST_TIMEOUT_MS); + if (this.threadSubscriptions.get(threadId) !== params) return undefined as T; this.startThreadStream(session, threadId, params as never); return undefined as T; } @@ -190,19 +227,24 @@ export class WsTransport { return undefined as T; } + const session = await this.getSession(REQUEST_TIMEOUT_MS); + + if (method === WS_METHODS.gitRunStackedAction) { + return (await this.runGitActionStream(session, params)) as T; + } + const rpcInput = method === ORCHESTRATION_WS_METHODS.dispatchCommand ? (params as { command: unknown }).command : (params ?? {}); const normalizedRpcInput = omitNullUserInputAnswers(rpcInput); const call = ( - session.client as unknown as Record< + session.value.client as unknown as Record< string, (input: unknown) => Effect.Effect > )[method]; if (!call) throw new WsTransportRpcError({ message: `Unknown RPC method: ${method}` }); - const timeoutMs = options?.timeoutMs === undefined ? REQUEST_TIMEOUT_MS : options.timeoutMs; const rpcEffect = timeoutMs === null ? call(normalizedRpcInput) @@ -215,7 +257,7 @@ export class WsTransport { }), ), }); - return (await session.runtime.runPromise(rpcEffect)) as T; + return (await session.value.runtime.runPromise(rpcEffect)) as T; } subscribe( @@ -273,79 +315,76 @@ export class WsTransport { dispose() { if (this.disposed) return; this.disposed = true; - this.setState("disposed"); - for (const cleanup of this.streamCleanups.values()) cleanup(); + for (const cleanup of this.wakeCleanups.splice(0)) cleanup(); + for (const cleanup of this.streamCleanups.values()) cleanup.cancel(); this.streamCleanups.clear(); - // Dispose can race with initial connection or reconnect promises. Mark them - // handled before closing the runtime so test/browser teardown stays quiet. - void this.clientPromise.catch(() => undefined); - void this.reconnectPromise?.catch(() => undefined); - const runtime = this.runtime; - const clientScope = this.clientScope; - void runtime - .runPromise(Scope.close(clientScope, Exit.void)) - .catch(() => undefined) - .finally(() => { - void runtime.dispose().catch(() => undefined); - }); + this.supervisor.dispose(); } - private createSession() { - const sessionVersion = ++this.sessionVersion; + private async createValidatedSession(): Promise { const runtime = ManagedRuntime.make(makeProtocolLayer(makeSocketUrl(this.explicitUrl))); const clientScope = runtime.runSync(Scope.make()); - const clientPromise = runtime - .runPromise(Scope.provide(clientScope)(makeRpcClient)) - .then((client): SessionHandle => { - if (!this.disposed && this.sessionVersion === sessionVersion) { - this.setState("open"); - } - return { client, runtime }; - }) - .catch((error) => { - if (!this.disposed && this.sessionVersion === sessionVersion) { - this.setState("closed"); - } - throw error; - }); - return { runtime, clientScope, clientPromise }; - } - - private async getSession(): Promise { try { - const session = await this.clientPromise; - if (this.disposed) { - throw new Error("Transport disposed"); - } + const client = await runtime.runPromise(Scope.provide(clientScope)(makeRpcClient)); + const session = { client, clientScope, runtime } satisfies SessionHandle; + await this.validateSession( + session, + SESSION_VALIDATION_TIMEOUT_MS, + WS_METHODS.serverGetConfig, + ); return session; - } catch { - if (this.disposed) throw new Error("Transport disposed"); - return this.reconnect(); + } catch (error) { + await this.closeRuntime(runtime, clientScope); + throw error; } } - private reconnect(): Promise { - if (this.reconnectPromise) return this.reconnectPromise; - - const oldRuntime = this.runtime; - const oldClientScope = this.clientScope; - const staleStreamCleanups = [...this.streamCleanups.values()]; - this.streamCleanups.clear(); - for (const cleanup of staleStreamCleanups) cleanup(); + private validateSession( + session: SessionHandle, + timeoutMs: number, + method: string, + ): Promise { + const validate = ( + session.client as unknown as Record< + string, + (input: unknown) => Effect.Effect + > + )[method]; + if (!validate) + return Promise.reject(new Error(`Connection validation RPC unavailable: ${method}`)); + const validation = validate({}).pipe( + Effect.timeoutOrElse({ + duration: timeoutMs, + onTimeout: () => + Effect.fail( + new WsTransportRpcError({ + message: `Connection validation timed out after ${timeoutMs}ms: ${method}`, + }), + ), + }), + Effect.asVoid, + ); + return session.runtime.runPromise(validation); + } - this.setState("connecting"); + private closeSession(session: SessionHandle): Promise { + return this.closeRuntime(session.runtime, session.clientScope); + } - void oldRuntime - .runPromise(Scope.close(oldClientScope, Exit.void)) - .catch(() => undefined) - .finally(() => { - void oldRuntime.dispose().catch(() => undefined); - }); + private async closeRuntime( + runtime: ManagedRuntime.ManagedRuntime, + clientScope: Scope.Closeable, + ): Promise { + try { + await runtime.runPromise(Scope.close(clientScope, Exit.void)).catch(() => undefined); + } finally { + await runtime.dispose().catch(() => undefined); + } + } - this.reconnectPromise = this.openReconnectSession().finally(() => { - this.reconnectPromise = null; - }); - return this.reconnectPromise; + private getSession(timeoutMs: number): Promise { + if (this.disposed) return Promise.reject(new Error("Transport disposed")); + return this.supervisor.waitForSession({ timeoutMs }); } private setState(state: WsTransportState): void { @@ -360,31 +399,41 @@ export class WsTransport { } } - private async openReconnectSession(): Promise { - const delayMs = Math.min(500 * 2 ** this.reconnectFailures, 5_000); - this.reconnectFailures += 1; - await new Promise((resolve) => window.setTimeout(resolve, delayMs)); - - const session = this.createSession(); - this.runtime = session.runtime; - this.clientScope = session.clientScope; - this.clientPromise = session.clientPromise; - - const handle = await session.clientPromise; - if (this.disposed) { - throw new Error("Transport disposed"); - } - this.reconnectFailures = 0; + private restoreStreams(session: ActiveSession): void { + if (this.disposed || this.supervisor.currentSession?.generation !== session.generation) return; for (const channel of this.listeners.keys()) { this.startChannelStream(channel as WsPushChannel); } if (this.shellSubscribed) { - this.startShellStream(handle); + this.startShellStream(session); } for (const [threadId, input] of this.threadSubscriptions) { - this.startThreadStream(handle, threadId, input); + this.startThreadStream(session, threadId, input); + } + } + + private installWakeRecovery(): void { + const probe = (reason: string) => { + void this.supervisor.probe(reason); + }; + if (typeof window.addEventListener === "function") { + const handleFocus = () => probe("window focus"); + window.addEventListener("focus", handleFocus); + this.wakeCleanups.push(() => window.removeEventListener("focus", handleFocus)); + } + if (typeof document !== "undefined" && typeof document.addEventListener === "function") { + const handleVisibilityChange = () => { + if (document.visibilityState === "visible") probe("document visible"); + }; + document.addEventListener("visibilitychange", handleVisibilityChange); + this.wakeCleanups.push(() => + document.removeEventListener("visibilitychange", handleVisibilityChange), + ); + } + const onConnectionWake = window.desktopBridge?.onConnectionWake; + if (onConnectionWake) { + this.wakeCleanups.push(onConnectionWake((reason) => probe(`desktop ${reason}`))); } - return handle; } private emit(channel: C, data: WsPushMessage["data"]): void { @@ -407,9 +456,17 @@ export class WsTransport { } private startChannelStream(channel: WsPushChannel): void { - void this.getSession() + void this.supervisor + .waitForSession() .then((session) => { - const { client } = session; + if ( + this.disposed || + !this.listeners.has(channel) || + this.supervisor.currentSession?.generation !== session.generation + ) { + return; + } + const { client } = session.value; if (isServerLifecyclePushChannel(channel)) { this.startLifecycleStream(session); @@ -477,8 +534,9 @@ export class WsTransport { }) .catch((error) => { if (!this.disposed && this.listeners.has(channel)) { - console.warn("WebSocket RPC channel failed to start", error); - window.setTimeout(() => this.startChannelStream(channel), 500); + console.warn( + `[scient-connection] channel ${channel} failed to start: ${connectionErrorSummary(error)}`, + ); } }); } @@ -501,11 +559,11 @@ export class WsTransport { return shouldKeepServerLifecycleStream(new Set(this.listeners.keys())); } - private startLifecycleStream(session: SessionHandle): void { + private startLifecycleStream(session: ActiveSession): void { this.startStream( session, "server.lifecycle", - session.client[WS_METHODS.subscribeServerLifecycle]({}), + session.value.client[WS_METHODS.subscribeServerLifecycle]({}), (event: ServerLifecycleStreamEvent) => { if (event.type === "welcome") { this.emit(WS_CHANNELS.serverWelcome, event.payload); @@ -516,94 +574,111 @@ export class WsTransport { ); } - private startShellStream(session: SessionHandle): void { + private startShellStream(session: ActiveSession): void { this.startStream( session, "orchestration.shell", - session.client[ORCHESTRATION_WS_METHODS.subscribeShell]({}), + session.value.client[ORCHESTRATION_WS_METHODS.subscribeShell]({}), (event: OrchestrationShellStreamItem) => this.emit(ORCHESTRATION_WS_CHANNELS.shellEvent, event), ); } - private startThreadStream(session: SessionHandle, threadId: string, input: unknown): void { + private startThreadStream(session: ActiveSession, threadId: string, input: unknown): void { + if (this.supervisor.currentSession?.generation !== session.generation) return; const key = `orchestration.thread:${threadId}`; this.stopStream(key); this.startStream( session, key, - session.client[ORCHESTRATION_WS_METHODS.subscribeThread](input as never), + session.value.client[ORCHESTRATION_WS_METHODS.subscribeThread](input as never), (event: OrchestrationThreadStreamItem) => this.emit(ORCHESTRATION_WS_CHANNELS.threadEvent, event), ); } private startStream( - session: SessionHandle, + session: ActiveSession, key: string, stream: unknown, listener: (event: T) => void, ): void { - if (this.streamCleanups.has(key)) return; + if (this.supervisor.currentSession?.generation !== session.generation) return; + const existing = this.streamCleanups.get(key); + if (existing?.generation === session.generation) return; + if (existing) { + this.streamCleanups.delete(key); + existing.cancel(); + } const runnableStream = stream as Stream.Stream; - const cancel = session.runtime.runCallback( - Stream.runForEach(runnableStream, (event) => Effect.sync(() => listener(event))), + const cancel = session.value.runtime.runCallback( + Stream.runForEach(runnableStream, (event) => + Effect.sync(() => { + if (this.supervisor.currentSession?.generation !== session.generation) return; + listener(event); + }), + ), { onExit: (exit) => { // A replacement or intentional stop removes this exact cleanup from // the map before cancellation. Ignore that stale stream's later exit; // otherwise it can reconnect over the replacement and lose events. - if (this.streamCleanups.get(key) !== cancel || this.disposed) { + if (this.streamCleanups.get(key)?.cancel !== cancel || this.disposed) { return; } this.streamCleanups.delete(key); if (Exit.isFailure(exit)) { const interrupted = Cause.hasInterruptsOnly(exit.cause); if (!interrupted) { - console.warn("WebSocket RPC stream failed", causeToError(exit.cause)); + console.warn( + `[scient-connection] stream ${key} failed: ${connectionErrorSummary(causeToError(exit.cause))}`, + ); } - window.setTimeout( - () => { - if (!this.disposed && !this.streamCleanups.has(key)) { - // Reconnecting restores every active channel, shell, and thread - // subscription. Restarting this stream again would replace the - // restored subscription and could drop its first event. - void this.reconnect().catch((error) => { - if (!this.disposed) { - console.warn("WebSocket RPC stream reconnect failed", error); - } - }); - } - }, - interrupted ? 0 : 500, + this.supervisor.invalidate( + session.generation, + interrupted ? `stream ${key} interrupted` : `stream ${key} failed`, ); + } else { + this.supervisor.invalidate(session.generation, `stream ${key} completed`); } }, }, ); - this.streamCleanups.set(key, cancel); + this.streamCleanups.set(key, { generation: session.generation, cancel }); } private stopStream(key: string): void { const cleanup = this.streamCleanups.get(key); if (!cleanup) return; this.streamCleanups.delete(key); - cleanup(); + cleanup.cancel(); + } + + private stopGenerationStreams(generation: number): void { + for (const [key, cleanup] of this.streamCleanups) { + if (cleanup.generation !== generation) continue; + this.streamCleanups.delete(key); + cleanup.cancel(); + } } private async runGitActionStream( - session: SessionHandle, + session: ActiveSession, params: unknown, ): Promise { let result: GitRunStackedActionResult | null = null; - await session.runtime.runPromise( - Stream.runForEach(session.client[WS_METHODS.gitRunStackedAction](params as never), (event) => - Effect.sync(() => { - this.emit(WS_CHANNELS.gitActionProgress, event as GitActionProgressEvent); - if ((event as GitActionProgressEvent).kind === "action_finished") { - result = (event as Extract).result; - } - }), + await session.value.runtime.runPromise( + Stream.runForEach( + session.value.client[WS_METHODS.gitRunStackedAction](params as never), + (event) => + Effect.sync(() => { + if (this.supervisor.currentSession?.generation !== session.generation) return; + this.emit(WS_CHANNELS.gitActionProgress, event as GitActionProgressEvent); + if ((event as GitActionProgressEvent).kind === "action_finished") { + result = (event as Extract) + .result; + } + }), ), ); if (!result) throw new Error("Git action stream completed without a final result."); diff --git a/apps/web/src/wsTransportEvents.ts b/apps/web/src/wsTransportEvents.ts index 34ea297e8..657f355fa 100644 --- a/apps/web/src/wsTransportEvents.ts +++ b/apps/web/src/wsTransportEvents.ts @@ -3,7 +3,7 @@ // Layer: Web transport utility // Exports: event helpers used by wsNativeApi and terminal runtime recovery. -export type WsTransportState = "connecting" | "open" | "closed" | "disposed"; +export type WsTransportState = "connecting" | "reconnecting" | "open" | "disposed"; export const SYNARA_WS_TRANSPORT_STATE_EVENT = "scient:ws-transport-state"; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 3f18dc657..1016c7145 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -406,6 +406,8 @@ export interface DesktopWindowState { isFullscreen: boolean; } +export type DesktopConnectionWakeReason = "app-activate" | "window-focus" | "system-resume"; + export interface ScientStorageSnapshot { readonly version: 1; readonly exportedAt: string; @@ -414,6 +416,8 @@ export interface ScientStorageSnapshot { export interface DesktopBridge { getWsUrl: () => string | null; + /** Native wake signals that should verify an apparently-open server connection. */ + onConnectionWake?: (listener: (reason: DesktopConnectionWakeReason) => void) => () => void; /** * Absolute filesystem path for a File from drag/drop or file inputs. * Electron only (`webUtils.getPathForFile`). Returns null when unavailable. diff --git a/packages/contracts/src/terminal.test.ts b/packages/contracts/src/terminal.test.ts index f3e14b7f9..a01444e6b 100644 --- a/packages/contracts/src/terminal.test.ts +++ b/packages/contracts/src/terminal.test.ts @@ -205,6 +205,8 @@ describe("TerminalSessionSnapshot", () => { status: "running", pid: 1234, history: "hello\n", + outputEpoch: "epoch-1", + outputSequence: 4, replayPreamble: "\u001b[?2004h\u001b[=7;1u", exitCode: null, exitSignal: null, @@ -223,6 +225,8 @@ describe("TerminalEvent", () => { terminalId: DEFAULT_TERMINAL_ID, createdAt: new Date().toISOString(), data: "line\n", + outputEpoch: "epoch-1", + outputSequence: 1, }), ).toBe(true); }); @@ -235,6 +239,8 @@ describe("TerminalEvent", () => { terminalId: DEFAULT_TERMINAL_ID, createdAt: new Date().toISOString(), data: "line\n", + outputEpoch: "epoch-1", + outputSequence: 2, byteLength: 5, }), ).toBe(true); diff --git a/packages/contracts/src/terminal.ts b/packages/contracts/src/terminal.ts index 78cbb66cf..9aa62dc01 100644 --- a/packages/contracts/src/terminal.ts +++ b/packages/contracts/src/terminal.ts @@ -100,6 +100,10 @@ export const TerminalSessionSnapshot = Schema.Struct({ status: TerminalSessionStatus, pid: Schema.NullOr(Schema.Int.check(Schema.isGreaterThan(0))), history: Schema.String, + /** Identifies the server process that owns this sequence namespace. */ + outputEpoch: Schema.String.check(Schema.isNonEmpty()), + /** Monotonic output-event barrier captured with this authoritative history. */ + outputSequence: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), replayPreamble: Schema.optional(Schema.String.check(Schema.isMaxLength(4_096))), exitCode: Schema.NullOr(Schema.Int), exitSignal: Schema.NullOr(Schema.Int), @@ -123,6 +127,10 @@ const TerminalOutputEvent = Schema.Struct({ ...TerminalEventBaseSchema.fields, type: Schema.Literal("output"), data: Schema.String, + /** Identifies the server process that owns this sequence namespace. */ + outputEpoch: Schema.String.check(Schema.isNonEmpty()), + /** Monotonic sequence within this server-side terminal session. */ + outputSequence: Schema.Int.check(Schema.isGreaterThan(0)), byteLength: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))), }); From a37c4764f858ce14a76c475d06cb44d258ec8216 Mon Sep 17 00:00:00 2001 From: Yaacov Date: Tue, 21 Jul 2026 02:24:57 +0300 Subject: [PATCH 05/13] Define safe RPC recovery policies --- apps/web/src/rpcRecoveryPolicy.test.ts | 176 +++++++++++++++++++++++++ apps/web/src/rpcRecoveryPolicy.ts | 104 +++++++++++++++ apps/web/src/wsTransport.ts | 73 +++++++--- 3 files changed, 333 insertions(+), 20 deletions(-) create mode 100644 apps/web/src/rpcRecoveryPolicy.test.ts create mode 100644 apps/web/src/rpcRecoveryPolicy.ts diff --git a/apps/web/src/rpcRecoveryPolicy.test.ts b/apps/web/src/rpcRecoveryPolicy.test.ts new file mode 100644 index 000000000..4a7f61311 --- /dev/null +++ b/apps/web/src/rpcRecoveryPolicy.test.ts @@ -0,0 +1,176 @@ +// FILE: rpcRecoveryPolicy.test.ts +// Purpose: Locks conservative RPC replay boundaries and one-retry behavior. +// Layer: Web transport recovery policy tests + +import { ORCHESTRATION_WS_METHODS, WS_METHODS } from "@synara/contracts"; +import { RpcClientError } from "effect/unstable/rpc/RpcClientError"; +import { SocketCloseError, SocketError, SocketOpenError } from "effect/unstable/socket/Socket"; +import { describe, expect, it, vi } from "vitest"; + +import { + isRpcTransportFailure, + rpcRecoveryPolicyFor, + runRpcWithRecovery, +} from "./rpcRecoveryPolicy"; + +describe("rpcRecoveryPolicyFor", () => { + it("allows reviewed read-only methods to replay on a new generation", () => { + expect(rpcRecoveryPolicyFor(WS_METHODS.filesystemBrowse)).toBe("retry-on-new-generation"); + expect(rpcRecoveryPolicyFor(WS_METHODS.gitStatus)).toBe("retry-on-new-generation"); + expect(rpcRecoveryPolicyFor(ORCHESTRATION_WS_METHODS.getSnapshot)).toBe( + "retry-on-new-generation", + ); + }); + + it.each([ + WS_METHODS.projectsAdd, + WS_METHODS.projectsWriteFile, + WS_METHODS.gitPull, + WS_METHODS.gitRunStackedAction, + WS_METHODS.terminalOpen, + WS_METHODS.terminalWrite, + WS_METHODS.serverInstallProvider, + WS_METHODS.serverGenerateThreadRecap, + WS_METHODS.scientProjectInitializationPreview, + WS_METHODS.studioListThreadOutputs, + WS_METHODS.pullRequestsList, + WS_METHODS.pullRequestsDetail, + WS_METHODS.pullRequestsDiff, + WS_METHODS.serverListProviderUsage, + WS_METHODS.providerGetComposerCapabilities, + WS_METHODS.providerListSkills, + WS_METHODS.providerListModels, + WS_METHODS.pullRequestsComment, + WS_METHODS.automationRunNow, + ORCHESTRATION_WS_METHODS.dispatchCommand, + ORCHESTRATION_WS_METHODS.importThread, + ])("never replays a mutating or outcome-ambiguous method: %s", (method) => { + expect(rpcRecoveryPolicyFor(method)).toBe("never-replay"); + }); + + it("defaults unknown future methods to never replay", () => { + expect(rpcRecoveryPolicyFor("future.method")).toBe("never-replay"); + }); +}); + +describe("isRpcTransportFailure", () => { + it("recognizes structured RPC socket failures", () => { + expect( + isRpcTransportFailure(new RpcClientError({ reason: new SocketCloseError({ code: 1006 }) })), + ).toBe(true); + expect( + isRpcTransportFailure( + new RpcClientError({ + reason: new SocketOpenError({ kind: "Timeout", cause: "open timed out" }), + }), + ), + ).toBe(true); + expect( + isRpcTransportFailure(new SocketError({ reason: new SocketCloseError({ code: 1006 }) })), + ).toBe(true); + }); + + it.each([ + new Error("Git command timed out after 60000ms"), + new Error("provider connection is not authenticated"), + new Error("socket validation failed"), + { _tag: "GitCommandError", message: "connection timed out" }, + { _tag: "RpcClientError", reason: { _tag: "HttpClientError" } }, + ])("does not infer transport failure from semantic message text: %o", (error) => { + expect(isRpcTransportFailure(error)).toBe(false); + }); +}); + +describe("runRpcWithRecovery", () => { + it("replays a reviewed read exactly once on a replacement generation", async () => { + const run = vi + .fn<(session: { generation: number }) => Promise>() + .mockRejectedValueOnce(new Error("socket closed")) + .mockResolvedValueOnce("recovered"); + const recover = vi.fn(async () => ({ generation: 2 })); + + await expect( + runRpcWithRecovery({ + method: WS_METHODS.filesystemBrowse, + session: { generation: 1 }, + run, + shouldRecover: () => true, + recover, + }), + ).resolves.toBe("recovered"); + expect(run.mock.calls.map(([session]) => session.generation)).toEqual([1, 2]); + expect(recover).toHaveBeenCalledOnce(); + }); + + it("does not replay a mutation even when recovery could replace the session", async () => { + const failure = new Error("outcome unknown"); + const run = vi.fn(async () => Promise.reject(failure)); + const recover = vi.fn(async () => ({ generation: 2 })); + + await expect( + runRpcWithRecovery({ + method: WS_METHODS.projectsWriteFile, + session: { generation: 1 }, + run, + shouldRecover: () => true, + recover, + }), + ).rejects.toBe(failure); + expect(run).toHaveBeenCalledOnce(); + expect(recover).not.toHaveBeenCalled(); + }); + + it("does not replay a read without a genuinely new generation", async () => { + const failure = new Error("validation failed"); + const run = vi.fn(async () => Promise.reject(failure)); + + await expect( + runRpcWithRecovery({ + method: WS_METHODS.gitStatus, + session: { generation: 3 }, + run, + shouldRecover: () => true, + recover: async () => ({ generation: 3 }), + }), + ).rejects.toBe(failure); + expect(run).toHaveBeenCalledOnce(); + }); + + it("never attempts a third execution when the replay fails", async () => { + const firstFailure = new Error("first socket closed"); + const replayFailure = new Error("replacement failed"); + const run = vi + .fn<(session: { generation: number }) => Promise>() + .mockRejectedValueOnce(firstFailure) + .mockRejectedValueOnce(replayFailure); + + await expect( + runRpcWithRecovery({ + method: WS_METHODS.serverGetConfig, + session: { generation: 1 }, + run, + shouldRecover: () => true, + recover: async () => ({ generation: 2 }), + }), + ).rejects.toBe(replayFailure); + expect(run).toHaveBeenCalledTimes(2); + }); + + it("preserves semantic errors even if another path already replaced the generation", async () => { + const failure = new Error("Git command timed out after 60000ms"); + const run = vi.fn(async () => Promise.reject(failure)); + const recover = vi.fn(async () => ({ generation: 2 })); + + await expect( + runRpcWithRecovery({ + method: WS_METHODS.filesystemBrowse, + session: { generation: 1 }, + run, + shouldRecover: isRpcTransportFailure, + recover, + }), + ).rejects.toBe(failure); + expect(run).toHaveBeenCalledOnce(); + expect(recover).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/rpcRecoveryPolicy.ts b/apps/web/src/rpcRecoveryPolicy.ts new file mode 100644 index 000000000..4f1971be4 --- /dev/null +++ b/apps/web/src/rpcRecoveryPolicy.ts @@ -0,0 +1,104 @@ +// FILE: rpcRecoveryPolicy.ts +// Purpose: Defines and enforces the narrow set of RPCs that may replay after reconnection. +// Layer: Web transport recovery policy + +import { ORCHESTRATION_WS_METHODS, WS_METHODS } from "@synara/contracts"; + +export type RpcRecoveryPolicy = "retry-on-new-generation" | "never-replay"; + +type KnownRpcMethod = + | (typeof WS_METHODS)[keyof typeof WS_METHODS] + | (typeof ORCHESTRATION_WS_METHODS)[keyof typeof ORCHESTRATION_WS_METHODS]; + +// This allowlist is intentionally conservative. A method belongs here only when +// running it twice cannot create, update, delete, launch user-visible work, +// acknowledge, or spend on the user's behalf. New methods inherit +// `never-replay` until reviewed. +const RETRYABLE_READ_METHODS: ReadonlySet = new Set([ + WS_METHODS.projectsList, + WS_METHODS.projectsDiscoverScripts, + WS_METHODS.projectsListDirectories, + WS_METHODS.projectsSearchEntries, + WS_METHODS.projectsSearchLocalEntries, + WS_METHODS.projectsReadFile, + WS_METHODS.projectsListDevServers, + WS_METHODS.filesystemBrowse, + WS_METHODS.gitGithubRepository, + WS_METHODS.gitStatus, + WS_METHODS.gitReadWorkingTreeDiff, + WS_METHODS.gitListBranches, + WS_METHODS.gitStashInfo, + WS_METHODS.serverGetConfig, + WS_METHODS.serverGetEnvironment, + WS_METHODS.serverGetSettings, + WS_METHODS.serverListWorktrees, + WS_METHODS.serverListLocalServers, + WS_METHODS.serverGetProviderUsageSnapshot, + WS_METHODS.statsGetProfileStats, + WS_METHODS.statsGetProfileTokenStats, + WS_METHODS.serverGetDiagnostics, + WS_METHODS.automationList, + ORCHESTRATION_WS_METHODS.getSnapshot, + ORCHESTRATION_WS_METHODS.getShellSnapshot, + ORCHESTRATION_WS_METHODS.getTurnDiff, + ORCHESTRATION_WS_METHODS.getFullThreadDiff, + ORCHESTRATION_WS_METHODS.replayEvents, +]); + +export function rpcRecoveryPolicyFor(method: string): RpcRecoveryPolicy { + return RETRYABLE_READ_METHODS.has(method as KnownRpcMethod) + ? "retry-on-new-generation" + : "never-replay"; +} + +export interface RpcRecoverySession { + readonly generation: number; +} + +const SOCKET_ERROR_REASONS = new Set([ + "SocketReadError", + "SocketWriteError", + "SocketOpenError", + "SocketCloseError", +]); + +function taggedReason(value: unknown): string | null { + if (!value || typeof value !== "object" || !("reason" in value)) return null; + const reason = value.reason; + if (!reason || typeof reason !== "object" || !("_tag" in reason)) return null; + return typeof reason._tag === "string" ? reason._tag : null; +} + +/** + * Recognizes Effect's structured socket failures without guessing from message + * text. In particular, application and Git timeouts are not transport failures. + */ +export function isRpcTransportFailure(error: unknown): boolean { + if (!error || typeof error !== "object" || !("_tag" in error)) return false; + if (error._tag !== "RpcClientError" && error._tag !== "SocketError") return false; + const reason = taggedReason(error); + return reason !== null && SOCKET_ERROR_REASONS.has(reason); +} + +/** + * Runs one RPC attempt and, for reviewed reads only, permits exactly one replay + * when recovery produces a different connection generation. + */ +export async function runRpcWithRecovery(input: { + readonly method: string; + readonly session: TSession; + readonly run: (session: TSession) => Promise; + readonly shouldRecover: (error: unknown) => boolean; + readonly recover: (session: TSession, error: unknown) => Promise; +}): Promise { + try { + return await input.run(input.session); + } catch (error) { + if (rpcRecoveryPolicyFor(input.method) === "never-replay" || !input.shouldRecover(error)) { + throw error; + } + const replacement = await input.recover(input.session, error); + if (!replacement || replacement.generation === input.session.generation) throw error; + return input.run(replacement); + } +} diff --git a/apps/web/src/wsTransport.ts b/apps/web/src/wsTransport.ts index aaf89ccf8..df13cec13 100644 --- a/apps/web/src/wsTransport.ts +++ b/apps/web/src/wsTransport.ts @@ -30,6 +30,7 @@ import { RpcClient, RpcSerialization } from "effect/unstable/rpc"; import * as Socket from "effect/unstable/socket/Socket"; import { ConnectionSupervisor, type ConnectionSupervisorSession } from "./connectionSupervisor"; +import { isRpcTransportFailure, runRpcWithRecovery } from "./rpcRecoveryPolicy"; import type { WsTransportState } from "./wsTransportEvents"; type PushListener = (message: WsPushMessage) => void; @@ -238,26 +239,14 @@ export class WsTransport { ? (params as { command: unknown }).command : (params ?? {}); const normalizedRpcInput = omitNullUserInputAnswers(rpcInput); - const call = ( - session.value.client as unknown as Record< - string, - (input: unknown) => Effect.Effect - > - )[method]; - if (!call) throw new WsTransportRpcError({ message: `Unknown RPC method: ${method}` }); - const rpcEffect = - timeoutMs === null - ? call(normalizedRpcInput) - : Effect.timeoutOrElse(call(normalizedRpcInput), { - duration: timeoutMs, - onTimeout: () => - Effect.fail( - new WsTransportRpcError({ - message: `RPC request timed out after ${timeoutMs}ms: ${method}`, - }), - ), - }); - return (await session.value.runtime.runPromise(rpcEffect)) as T; + return runRpcWithRecovery({ + method, + session, + run: (attemptSession) => + this.runRpcCall(attemptSession, method, normalizedRpcInput, timeoutMs), + shouldRecover: isRpcTransportFailure, + recover: (failedSession) => this.recoverReadSession(failedSession, method), + }); } subscribe( @@ -387,6 +376,50 @@ export class WsTransport { return this.supervisor.waitForSession({ timeoutMs }); } + private runRpcCall( + session: ActiveSession, + method: string, + input: unknown, + timeoutMs: number | null, + ): Promise { + const call = ( + session.value.client as unknown as Record< + string, + (input: unknown) => Effect.Effect + > + )[method]; + if (!call) { + return Promise.reject(new WsTransportRpcError({ message: `Unknown RPC method: ${method}` })); + } + const rpcEffect = + timeoutMs === null + ? call(input) + : Effect.timeoutOrElse(call(input), { + duration: timeoutMs, + onTimeout: () => + Effect.fail( + new WsTransportRpcError({ + message: `RPC request timed out after ${timeoutMs}ms: ${method}`, + }), + ), + }); + return session.value.runtime.runPromise(rpcEffect) as Promise; + } + + private async recoverReadSession( + failedSession: ActiveSession, + method: string, + ): Promise { + if (this.disposed) return null; + if (this.supervisor.currentSession?.generation === failedSession.generation) { + await this.supervisor.probe(`RPC ${method} failed`); + } + if (this.disposed || this.supervisor.currentSession?.generation === failedSession.generation) { + return null; + } + return this.getSession(REQUEST_TIMEOUT_MS); + } + private setState(state: WsTransportState): void { if (this.state === state) return; this.state = state; From b4b25583ef0b5a1a88582cfc3dc1c87b8a3c9e20 Mon Sep 17 00:00:00 2001 From: Yaacov Date: Tue, 21 Jul 2026 02:59:06 +0300 Subject: [PATCH 06/13] Surface connection recovery diagnostics --- apps/desktop/src/desktopDiagnostics.test.ts | 21 +++ apps/desktop/src/desktopDiagnostics.ts | 17 +++ apps/desktop/src/main.ts | 6 + apps/desktop/src/preload.ts | 4 + ...onnectionRecoveryNotifications.browser.tsx | 94 ++++++++++++ .../ConnectionRecoveryNotifications.tsx | 125 ++++++++++++++++ apps/web/src/components/ui/toast.tsx | 22 ++- apps/web/src/connectionRecoveryNotice.test.ts | 122 +++++++++++++++ apps/web/src/connectionRecoveryNotice.ts | 139 ++++++++++++++++++ apps/web/src/routes/__root.tsx | 2 + apps/web/src/wsNativeApi.test.ts | 16 +- apps/web/src/wsNativeApi.ts | 2 +- apps/web/src/wsTransportEvents.test.ts | 78 ++++++++++ apps/web/src/wsTransportEvents.ts | 9 ++ packages/contracts/src/ipc.ts | 4 + 15 files changed, 653 insertions(+), 8 deletions(-) create mode 100644 apps/desktop/src/desktopDiagnostics.test.ts create mode 100644 apps/desktop/src/desktopDiagnostics.ts create mode 100644 apps/web/src/components/ConnectionRecoveryNotifications.browser.tsx create mode 100644 apps/web/src/components/ConnectionRecoveryNotifications.tsx create mode 100644 apps/web/src/connectionRecoveryNotice.test.ts create mode 100644 apps/web/src/connectionRecoveryNotice.ts create mode 100644 apps/web/src/wsTransportEvents.test.ts diff --git a/apps/desktop/src/desktopDiagnostics.test.ts b/apps/desktop/src/desktopDiagnostics.test.ts new file mode 100644 index 000000000..3939b3b1b --- /dev/null +++ b/apps/desktop/src/desktopDiagnostics.test.ts @@ -0,0 +1,21 @@ +// FILE: desktopDiagnostics.test.ts +// Purpose: Verifies native log-folder opening and actionable OS errors. +// Layer: Desktop diagnostics tests + +import { describe, expect, it, vi } from "vitest"; + +import { openDesktopLogsDirectory } from "./desktopDiagnostics"; + +describe("openDesktopLogsDirectory", () => { + it("opens the exact Scient logs directory", async () => { + const openPath = vi.fn(async () => ""); + await expect(openDesktopLogsDirectory("/tmp/scient/logs", openPath)).resolves.toBeUndefined(); + expect(openPath).toHaveBeenCalledWith("/tmp/scient/logs"); + }); + + it("surfaces the native shell error", async () => { + await expect( + openDesktopLogsDirectory("/tmp/scient/logs", async () => "No application is registered"), + ).rejects.toThrow("No application is registered"); + }); +}); diff --git a/apps/desktop/src/desktopDiagnostics.ts b/apps/desktop/src/desktopDiagnostics.ts new file mode 100644 index 000000000..f3f89cfe9 --- /dev/null +++ b/apps/desktop/src/desktopDiagnostics.ts @@ -0,0 +1,17 @@ +// FILE: desktopDiagnostics.ts +// Purpose: Exposes narrow native diagnostics actions that remain available when the backend is down. +// Layer: Desktop main-process support + +export const DESKTOP_DIAGNOSTICS_IPC_CHANNELS = { + openLogsDirectory: "desktop:diagnostics-open-logs-directory", +} as const; + +export async function openDesktopLogsDirectory( + logsDirectory: string, + openPath: (path: string) => Promise, +): Promise { + const errorMessage = await openPath(logsDirectory); + if (errorMessage.trim().length > 0) { + throw new Error(errorMessage); + } +} diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index b9eb36e0f..05edb5c21 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -79,6 +79,7 @@ import { import { waitForBackendStartupReady } from "./backendStartupReadiness"; import { showDesktopConfirmDialog } from "./confirmDialog"; import { DESKTOP_CONNECTION_WAKE_CHANNEL } from "./desktopConnectionWake"; +import { DESKTOP_DIAGNOSTICS_IPC_CHANNELS, openDesktopLogsDirectory } from "./desktopDiagnostics"; import { LSREGISTER_PATH, parseLastLaunchVersion, @@ -3225,6 +3226,11 @@ function registerIpcHandlers(): void { shell.showItemInFolder(resolvedPath); }); + ipcMain.removeHandler(DESKTOP_DIAGNOSTICS_IPC_CHANNELS.openLogsDirectory); + ipcMain.handle(DESKTOP_DIAGNOSTICS_IPC_CHANNELS.openLogsDirectory, async () => { + await openDesktopLogsDirectory(LOG_DIR, (path) => shell.openPath(path)); + }); + ipcMain.removeHandler(WINDOW_MINIMIZE_CHANNEL); ipcMain.handle(WINDOW_MINIMIZE_CHANNEL, async (event) => { const window = BrowserWindow.fromWebContents(event.sender) ?? mainWindow; diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index eb9a5e55d..dab31a3c9 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -10,6 +10,7 @@ import { SERVER_TRANSCRIBE_VOICE_CHANNEL } from "./voiceTranscription"; import { STORAGE_MIGRATION_IPC_CHANNELS } from "./desktopStorageMigration"; import { APPSNAP_IPC_CHANNELS } from "./appSnapIpc"; import { DESKTOP_CONNECTION_WAKE_CHANNEL } from "./desktopConnectionWake"; +import { DESKTOP_DIAGNOSTICS_IPC_CHANNELS } from "./desktopDiagnostics"; const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; const SAVE_FILE_CHANNEL = "desktop:save-file"; @@ -75,6 +76,9 @@ contextBridge.exposeInMainWorld("desktopBridge", { shell: { showInFolder: (path: string) => ipcRenderer.invoke(SHOW_IN_FOLDER_CHANNEL, path), }, + diagnostics: { + openLogsDirectory: () => ipcRenderer.invoke(DESKTOP_DIAGNOSTICS_IPC_CHANNELS.openLogsDirectory), + }, clipboard: { writeImagePngDataUrl: (dataUrl: string) => ipcRenderer.invoke(CLIPBOARD_WRITE_IMAGE_CHANNEL, dataUrl), diff --git a/apps/web/src/components/ConnectionRecoveryNotifications.browser.tsx b/apps/web/src/components/ConnectionRecoveryNotifications.browser.tsx new file mode 100644 index 000000000..6ff79e1b9 --- /dev/null +++ b/apps/web/src/components/ConnectionRecoveryNotifications.browser.tsx @@ -0,0 +1,94 @@ +// FILE: ConnectionRecoveryNotifications.browser.tsx +// Purpose: Browser regressions for recovery-toast dismissal, visible timing, and copy errors. +// Layer: Browser UI test + +import "../index.css"; + +import { + RouterProvider, + createMemoryHistory, + createRootRoute, + createRouter, +} from "@tanstack/react-router"; +import { page } from "vitest/browser"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { render } from "vitest-browser-react"; + +import { ToastProvider, toastManager } from "./ui/toast"; + +function ToastHarness() { + return ; +} + +async function mountToastHarness() { + const host = document.createElement("div"); + document.body.append(host); + const rootRoute = createRootRoute({ component: ToastHarness }); + const router = createRouter({ + history: createMemoryHistory({ initialEntries: ["/"] }), + routeTree: rootRoute, + }); + const screen = await render(, { container: host }); + + return async () => { + toastManager.close(); + await screen.unmount(); + host.remove(); + }; +} + +describe("connection recovery toast integration", () => { + afterEach(() => { + vi.restoreAllMocks(); + document.body.innerHTML = ""; + }); + + it("runs the manager close callback used by swipe and Escape dismissal", async () => { + const cleanup = await mountToastHarness(); + const onClose = vi.fn(); + const toastId = toastManager.add({ onClose, title: "Reconnecting", timeout: 0 }); + + toastManager.close(toastId); + + expect(onClose).toHaveBeenCalledOnce(); + await cleanup(); + }); + + it("counts auto-dismiss time only while the window is visible and focused", async () => { + const cleanup = await mountToastHarness(); + const hasFocus = vi.spyOn(document, "hasFocus").mockReturnValue(false); + toastManager.add({ + title: "Reconnected", + data: { allowCrossThreadVisibility: true, dismissAfterVisibleMs: 100 }, + timeout: 0, + }); + + await new Promise((resolve) => window.setTimeout(resolve, 200)); + expect(document.body.textContent).toContain("Reconnected"); + + hasFocus.mockReturnValue(true); + window.dispatchEvent(new FocusEvent("focus")); + await expect.poll(() => document.body.textContent).not.toContain("Reconnected"); + await cleanup(); + }); + + it("shows a visible error when diagnostic copy fails", async () => { + const cleanup = await mountToastHarness(); + vi.spyOn(navigator.clipboard, "writeText").mockRejectedValue(new Error("Clipboard denied")); + vi.spyOn(document, "execCommand").mockReturnValue(false); + toastManager.add({ + title: "Scient is still reconnecting", + data: { + allowCrossThreadVisibility: true, + copyLabel: "diagnostics", + copyText: "Scient connection diagnostics", + }, + timeout: 0, + }); + + await page.getByRole("button", { name: "Copy diagnostics" }).click(); + + await expect.poll(() => document.body.textContent).toContain("Could not copy diagnostics"); + await cleanup(); + }); +}); diff --git a/apps/web/src/components/ConnectionRecoveryNotifications.tsx b/apps/web/src/components/ConnectionRecoveryNotifications.tsx new file mode 100644 index 000000000..d2d16b9d7 --- /dev/null +++ b/apps/web/src/components/ConnectionRecoveryNotifications.tsx @@ -0,0 +1,125 @@ +// FILE: ConnectionRecoveryNotifications.tsx +// Purpose: Surfaces calm, actionable local-service connection recovery status. +// Layer: Global web application notifications + +import { useEffect, useRef } from "react"; + +import { APP_VERSION } from "../branding"; +import { + ConnectionRecoveryNoticeController, + formatConnectionRecoveryDiagnostics, +} from "../connectionRecoveryNotice"; +import { addWsTransportStateListener } from "../wsTransportEvents"; +import { toastManager } from "./ui/toast"; + +type RecoveryToastId = ReturnType; + +export function ConnectionRecoveryNotifications() { + const toastIdRef = useRef(null); + + useEffect(() => { + const clearToast = () => { + if (toastIdRef.current !== null) toastManager.close(toastIdRef.current); + toastIdRef.current = null; + }; + + let controller: ConnectionRecoveryNoticeController; + controller = new ConnectionRecoveryNoticeController({ + onClear: clearToast, + onRecovered: () => { + const toastId = toastIdRef.current; + if (toastId === null) return; + toastManager.update(toastId, { + type: "success", + title: "Reconnected", + description: "Scient is connected to its local service again.", + actionProps: undefined, + onClose: undefined, + data: { + allowCrossThreadVisibility: true, + dismissAfterVisibleMs: 3_000, + }, + timeout: 0, + }); + }, + onShow: () => { + toastIdRef.current = toastManager.add({ + type: "loading", + title: "Reconnecting…", + description: + "Scient is restoring its local connection. Open chats remain on this computer.", + onClose: () => { + controller.dismissCurrentOutage(); + toastIdRef.current = null; + }, + data: { allowCrossThreadVisibility: true }, + timeout: 0, + }); + }, + onShowDetails: (stateStartedAt) => { + const diagnostics = formatConnectionRecoveryDiagnostics({ + appVersion: APP_VERSION, + desktopApp: Boolean(window.desktopBridge), + generatedAt: new Date(), + navigatorOnline: typeof navigator.onLine === "boolean" ? navigator.onLine : null, + platform: navigator.platform, + state: "reconnecting", + stateStartedAt, + visibility: document.visibilityState, + }); + const openLogs = window.desktopBridge?.diagnostics?.openLogsDirectory; + const nextToast = { + type: "warning" as const, + title: "Scient is still reconnecting", + description: + "Scient keeps trying automatically. Copy the connection summary or open the logs for details.", + actionProps: undefined, + onClose: () => { + controller.dismissCurrentOutage(); + toastIdRef.current = null; + }, + data: { + allowCrossThreadVisibility: true, + copyLabel: "diagnostics", + copyText: diagnostics, + ...(openLogs + ? { + secondaryActionProps: { + children: "Open logs", + onClick: () => { + void openLogs().catch((error: unknown) => { + toastManager.add({ + type: "error", + title: "Could not open logs", + description: + error instanceof Error + ? error.message + : "The logs folder could not be opened.", + }); + }); + }, + }, + } + : {}), + }, + timeout: 0, + }; + if (toastIdRef.current === null) { + toastIdRef.current = toastManager.add(nextToast); + } else { + toastManager.update(toastIdRef.current, nextToast); + } + }, + }); + + const unsubscribe = addWsTransportStateListener((state) => controller.handleState(state), { + replayLatest: true, + }); + return () => { + unsubscribe(); + controller.dispose(); + }; + }, []); + + return null; +} diff --git a/apps/web/src/components/ui/toast.tsx b/apps/web/src/components/ui/toast.tsx index 8ae0e9c73..16b5a6486 100644 --- a/apps/web/src/components/ui/toast.tsx +++ b/apps/web/src/components/ui/toast.tsx @@ -34,6 +34,7 @@ import { type ThreadToastData = { allowCrossThreadVisibility?: boolean; + copyLabel?: string; copyText?: string; onClose?: () => void; secondaryActionProps?: React.ComponentProps; @@ -209,32 +210,44 @@ function ThreadToastVisibleAutoDismiss({ function ToastActions({ actionProps, + copyLabel, copyText, secondaryActionProps, }: { actionProps: ToastObject["actionProps"]; + copyLabel: string | undefined; copyText: string | undefined; secondaryActionProps: ThreadToastData["secondaryActionProps"]; }) { - const { copyToClipboard, isCopied } = useCopyToClipboard(); + const { copyToClipboard, isCopied } = useCopyToClipboard({ + onError: (error) => { + toastManager.add({ + type: "error", + title: copyLabel ? `Could not copy ${copyLabel}` : "Could not copy text", + description: error.message, + }); + }, + }); if (!actionProps && !copyText && !secondaryActionProps) return null; + const copyActionLabel = copyLabel ? `Copy ${copyLabel}` : "Copy error message"; + return (
{copyText && ( )} {actionProps && ( @@ -436,6 +449,7 @@ function ToastSurface({ {!compact ? ( diff --git a/apps/web/src/connectionRecoveryNotice.test.ts b/apps/web/src/connectionRecoveryNotice.test.ts new file mode 100644 index 000000000..1b7e5c0e0 --- /dev/null +++ b/apps/web/src/connectionRecoveryNotice.test.ts @@ -0,0 +1,122 @@ +// FILE: connectionRecoveryNotice.test.ts +// Purpose: Verifies recovery-notice timing and privacy-safe diagnostic copy. +// Layer: Web connection recovery presentation tests + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + CONNECTION_DETAILS_DELAY_MS, + CONNECTION_NOTICE_DELAY_MS, + ConnectionRecoveryNoticeController, + formatConnectionRecoveryDiagnostics, +} from "./connectionRecoveryNotice"; + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("connection recovery notice", () => { + it("waits before notifying and reserves detailed help for sustained outages", () => { + expect(CONNECTION_NOTICE_DELAY_MS).toBeGreaterThanOrEqual(1_000); + expect(CONNECTION_DETAILS_DELAY_MS).toBeGreaterThan(CONNECTION_NOTICE_DELAY_MS); + }); + + it("stays silent for initial connection and brief reconnects", async () => { + vi.useFakeTimers(); + const callbacks = { + onClear: vi.fn(), + onRecovered: vi.fn(), + onShow: vi.fn(), + onShowDetails: vi.fn(), + }; + const controller = new ConnectionRecoveryNoticeController(callbacks); + + controller.handleState("connecting"); + controller.handleState("open"); + controller.handleState("reconnecting"); + await vi.advanceTimersByTimeAsync(CONNECTION_NOTICE_DELAY_MS - 1); + controller.handleState("open"); + + expect(callbacks.onShow).not.toHaveBeenCalled(); + expect(callbacks.onShowDetails).not.toHaveBeenCalled(); + expect(callbacks.onRecovered).not.toHaveBeenCalled(); + }); + + it("uses one notice through delayed details and recovery", async () => { + vi.useFakeTimers(); + const callbacks = { + onClear: vi.fn(), + onRecovered: vi.fn(), + onShow: vi.fn(), + onShowDetails: vi.fn(), + }; + const controller = new ConnectionRecoveryNoticeController(callbacks); + + controller.handleState("reconnecting"); + await vi.advanceTimersByTimeAsync(CONNECTION_NOTICE_DELAY_MS); + expect(callbacks.onShow).toHaveBeenCalledOnce(); + await vi.advanceTimersByTimeAsync(CONNECTION_DETAILS_DELAY_MS - CONNECTION_NOTICE_DELAY_MS); + expect(callbacks.onShowDetails).toHaveBeenCalledOnce(); + + controller.handleState("open"); + expect(callbacks.onRecovered).toHaveBeenCalledOnce(); + }); + + it("cancels stale timers and respects manual dismissal across repeated cycles", async () => { + vi.useFakeTimers(); + const callbacks = { + onClear: vi.fn(), + onRecovered: vi.fn(), + onShow: vi.fn(), + onShowDetails: vi.fn(), + }; + const controller = new ConnectionRecoveryNoticeController(callbacks); + + controller.handleState("reconnecting"); + await vi.advanceTimersByTimeAsync(CONNECTION_NOTICE_DELAY_MS); + controller.dismissCurrentOutage(); + await vi.advanceTimersByTimeAsync(CONNECTION_DETAILS_DELAY_MS); + controller.handleState("open"); + expect(callbacks.onShowDetails).not.toHaveBeenCalled(); + expect(callbacks.onRecovered).not.toHaveBeenCalled(); + + controller.handleState("reconnecting"); + controller.handleState("open"); + await vi.advanceTimersByTimeAsync(CONNECTION_DETAILS_DELAY_MS); + expect(callbacks.onShow).toHaveBeenCalledOnce(); + }); + + it("formats bounded local diagnostics without project, URL, command, or content fields", () => { + const diagnostics = formatConnectionRecoveryDiagnostics({ + appVersion: "0.5.7", + desktopApp: true, + generatedAt: new Date("2026-07-21T00:00:12.000Z"), + navigatorOnline: true, + platform: "Linux x86_64", + state: "reconnecting", + stateStartedAt: new Date("2026-07-21T00:00:00.000Z"), + visibility: "visible", + }); + + expect(diagnostics).toContain("Transport state: reconnecting"); + expect(diagnostics).toContain("Elapsed: 12s"); + expect(diagnostics).toContain("Platform: Linux x86_64"); + expect(diagnostics).not.toMatch(/project|conversation|command line|websocket url|token/i); + }); + + it("never reports a negative elapsed duration when clocks move backwards", () => { + const diagnostics = formatConnectionRecoveryDiagnostics({ + appVersion: "0.5.7", + desktopApp: false, + generatedAt: new Date("2026-07-21T00:00:00.000Z"), + navigatorOnline: null, + platform: "", + state: "connecting", + stateStartedAt: new Date("2026-07-21T00:00:03.000Z"), + visibility: "", + }); + + expect(diagnostics).toContain("Elapsed: 0s"); + expect(diagnostics).toContain("Browser online: unknown"); + }); +}); diff --git a/apps/web/src/connectionRecoveryNotice.ts b/apps/web/src/connectionRecoveryNotice.ts new file mode 100644 index 000000000..3d1da28e9 --- /dev/null +++ b/apps/web/src/connectionRecoveryNotice.ts @@ -0,0 +1,139 @@ +// FILE: connectionRecoveryNotice.ts +// Purpose: Owns privacy-safe copy and timing policy for local-service recovery notices. +// Layer: Web connection recovery presentation logic + +import type { WsTransportState } from "./wsTransportEvents"; + +export const CONNECTION_NOTICE_DELAY_MS = 1_500; +export const CONNECTION_DETAILS_DELAY_MS = 10_000; + +export interface ConnectionRecoveryNoticeCallbacks { + readonly onClear: () => void; + readonly onRecovered: () => void; + readonly onShow: (stateStartedAt: Date) => void; + readonly onShowDetails: (stateStartedAt: Date) => void; +} + +export interface ConnectionRecoveryNoticeClock { + readonly clearTimeout: (timer: ReturnType) => void; + readonly now: () => Date; + readonly setTimeout: (callback: () => void, delayMs: number) => ReturnType; +} + +const systemClock: ConnectionRecoveryNoticeClock = { + clearTimeout: (timer) => globalThis.clearTimeout(timer), + now: () => new Date(), + setTimeout: (callback, delayMs) => globalThis.setTimeout(callback, delayMs), +}; + +/** + * Owns one post-ready reconnect notice. Initial connection remains on the + * existing startup surface, and every transition cancels stale timers. + */ +export class ConnectionRecoveryNoticeController { + readonly #callbacks: ConnectionRecoveryNoticeCallbacks; + readonly #clock: ConnectionRecoveryNoticeClock; + #detailsTimer: ReturnType | null = null; + #dismissed = false; + #noticeTimer: ReturnType | null = null; + #reconnecting = false; + #visible = false; + + constructor( + callbacks: ConnectionRecoveryNoticeCallbacks, + clock: ConnectionRecoveryNoticeClock = systemClock, + ) { + this.#callbacks = callbacks; + this.#clock = clock; + } + + handleState(state: WsTransportState): void { + if (state === "reconnecting") { + if (this.#reconnecting) return; + this.#reset(); + this.#callbacks.onClear(); + this.#reconnecting = true; + const stateStartedAt = this.#clock.now(); + this.#noticeTimer = this.#clock.setTimeout(() => { + this.#noticeTimer = null; + if (!this.#reconnecting || this.#dismissed) return; + this.#visible = true; + this.#callbacks.onShow(stateStartedAt); + }, CONNECTION_NOTICE_DELAY_MS); + this.#detailsTimer = this.#clock.setTimeout(() => { + this.#detailsTimer = null; + if (!this.#reconnecting || this.#dismissed || !this.#visible) return; + this.#callbacks.onShowDetails(stateStartedAt); + }, CONNECTION_DETAILS_DELAY_MS); + return; + } + + const shouldAnnounceRecovery = state === "open" && this.#visible && !this.#dismissed; + this.#reset(); + if (shouldAnnounceRecovery) this.#callbacks.onRecovered(); + else this.#callbacks.onClear(); + } + + dismissCurrentOutage(): void { + if (!this.#reconnecting) return; + this.#dismissed = true; + this.#visible = false; + this.#cancelTimer("details"); + } + + dispose(): void { + this.#reset(); + this.#callbacks.onClear(); + } + + #cancelTimer(kind: "details" | "notice"): void { + const timer = kind === "details" ? this.#detailsTimer : this.#noticeTimer; + if (timer !== null) this.#clock.clearTimeout(timer); + if (kind === "details") this.#detailsTimer = null; + else this.#noticeTimer = null; + } + + #reset(): void { + this.#cancelTimer("notice"); + this.#cancelTimer("details"); + this.#dismissed = false; + this.#reconnecting = false; + this.#visible = false; + } +} + +export interface ConnectionRecoveryDiagnosticsInput { + readonly appVersion: string; + readonly desktopApp: boolean; + readonly generatedAt: Date; + readonly navigatorOnline: boolean | null; + readonly platform: string; + readonly state: WsTransportState; + readonly stateStartedAt: Date; + readonly visibility: string; +} + +/** + * Produces a bounded local summary that intentionally excludes URLs, paths, + * project names, conversation content, process command lines, and credentials. + */ +export function formatConnectionRecoveryDiagnostics( + input: ConnectionRecoveryDiagnosticsInput, +): string { + const elapsedSeconds = Math.max( + 0, + Math.round((input.generatedAt.getTime() - input.stateStartedAt.getTime()) / 1_000), + ); + return [ + "Scient connection diagnostics", + `Generated: ${input.generatedAt.toISOString()}`, + `App version: ${input.appVersion}`, + `Transport state: ${input.state}`, + `State started: ${input.stateStartedAt.toISOString()}`, + `Elapsed: ${elapsedSeconds}s`, + `Platform: ${input.platform || "unknown"}`, + `Desktop app: ${input.desktopApp ? "yes" : "no"}`, + `Browser online: ${input.navigatorOnline === null ? "unknown" : input.navigatorOnline ? "yes" : "no"}`, + `Window visibility: ${input.visibility || "unknown"}`, + ].join("\n"); +} diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 06a892f36..c91d643ce 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -26,6 +26,7 @@ import { AppSnapCoordinator } from "../components/AppSnapCoordinator"; import { AppSnapWelcomeDialog } from "../components/AppSnapWelcomeDialog"; import { FeedbackDialog } from "../components/FeedbackDialog"; import { ProviderConnectionDialog } from "../components/ProviderConnectionDialog"; +import { ConnectionRecoveryNotifications } from "../components/ConnectionRecoveryNotifications"; import { SETTINGS_TARGETS } from "../settingsNavigation"; import ShortcutsDialog from "../components/ShortcutsDialog"; import WhatsNewDialog from "../components/WhatsNewDialog"; @@ -202,6 +203,7 @@ function RootRouteView() { + diff --git a/apps/web/src/wsNativeApi.test.ts b/apps/web/src/wsNativeApi.test.ts index 40ace20f6..219ccc829 100644 --- a/apps/web/src/wsNativeApi.test.ts +++ b/apps/web/src/wsNativeApi.test.ts @@ -26,6 +26,7 @@ import { import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const requestMock = vi.fn<(...args: Array) => Promise>(); +const onStateChangeMock = vi.fn(() => () => undefined); const showContextMenuFallbackMock = vi.fn< ( @@ -62,9 +63,7 @@ vi.mock("./wsTransport", () => { WsTransport: class MockWsTransport { request = requestMock; subscribe = subscribeMock; - onStateChange() { - return () => undefined; - } + onStateChange = onStateChangeMock; getLatestPush(channel: string) { return latestPushByChannel.get(channel) ?? null; } @@ -116,6 +115,7 @@ const defaultProviders: ReadonlyArray = [ beforeEach(() => { vi.resetModules(); requestMock.mockReset(); + onStateChangeMock.mockClear(); showContextMenuFallbackMock.mockReset(); subscribeMock.mockClear(); channelListeners.clear(); @@ -130,6 +130,16 @@ afterEach(() => { }); describe("wsNativeApi", () => { + it("seeds renderer transport state from the new transport immediately", async () => { + const { createWsNativeApi } = await import("./wsNativeApi"); + + createWsNativeApi(); + + expect(onStateChangeMock).toHaveBeenCalledWith(expect.any(Function), { + replayCurrent: true, + }); + }); + it("delivers and caches valid server.welcome payloads", async () => { const { createWsNativeApi, onServerWelcome } = await import("./wsNativeApi"); diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts index 69583a6ad..2c25ee8c7 100644 --- a/apps/web/src/wsNativeApi.ts +++ b/apps/web/src/wsNativeApi.ts @@ -322,7 +322,7 @@ export function createWsNativeApi(): NativeApi { } const transport = new WsTransport(); - transport.onStateChange((state) => emitWsTransportState(state)); + transport.onStateChange((state) => emitWsTransportState(state), { replayCurrent: true }); transport.subscribe(WS_CHANNELS.serverWelcome, (message) => { const payload = message.data; diff --git a/apps/web/src/wsTransportEvents.test.ts b/apps/web/src/wsTransportEvents.test.ts new file mode 100644 index 000000000..3edc096dc --- /dev/null +++ b/apps/web/src/wsTransportEvents.test.ts @@ -0,0 +1,78 @@ +// FILE: wsTransportEvents.test.ts +// Purpose: Verifies transport-state retention and late-subscriber replay. +// Layer: Web transport event tests + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + addWsTransportStateListener, + emitWsTransportState, + getLatestWsTransportState, +} from "./wsTransportEvents"; + +beforeEach(() => { + emitWsTransportState("connecting"); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("wsTransportEvents", () => { + it("retains the latest state even when no browser event target exists", () => { + emitWsTransportState("reconnecting"); + expect(getLatestWsTransportState()).toBe("reconnecting"); + }); + + it("replays the latest state to a late subscriber when requested", () => { + emitWsTransportState("open"); + const listener = vi.fn(); + + const unsubscribe = addWsTransportStateListener(listener, { replayLatest: true }); + + expect(listener).toHaveBeenCalledOnce(); + expect(listener).toHaveBeenCalledWith("open"); + expect(() => unsubscribe()).not.toThrow(); + }); + + it("does not replay unless the caller opts in", () => { + const listener = vi.fn(); + addWsTransportStateListener(listener); + expect(listener).not.toHaveBeenCalled(); + }); + + it("delivers browser events until unsubscribe and ignores events without detail", () => { + const eventListeners = new Set<(event: Event) => void>(); + class TestCustomEvent { + readonly detail: T; + readonly type: string; + + constructor(type: string, init: { detail: T }) { + this.type = type; + this.detail = init.detail; + } + } + vi.stubGlobal("CustomEvent", TestCustomEvent); + vi.stubGlobal("window", { + addEventListener: (_type: string, listener: (event: Event) => void) => + eventListeners.add(listener), + dispatchEvent: (event: Event) => { + for (const listener of eventListeners) listener(event); + return true; + }, + removeEventListener: (_type: string, listener: (event: Event) => void) => + eventListeners.delete(listener), + }); + const listener = vi.fn(); + const unsubscribe = addWsTransportStateListener(listener); + + emitWsTransportState("open"); + for (const eventListener of eventListeners) eventListener({} as Event); + expect(listener).toHaveBeenCalledOnce(); + expect(listener).toHaveBeenCalledWith("open"); + + unsubscribe(); + emitWsTransportState("reconnecting"); + expect(listener).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/web/src/wsTransportEvents.ts b/apps/web/src/wsTransportEvents.ts index 657f355fa..9ebd02f52 100644 --- a/apps/web/src/wsTransportEvents.ts +++ b/apps/web/src/wsTransportEvents.ts @@ -11,8 +11,15 @@ export interface WsTransportStateEventDetail { state: WsTransportState; } +let latestWsTransportState: WsTransportState = "connecting"; + +export function getLatestWsTransportState(): WsTransportState { + return latestWsTransportState; +} + // Emits a browser-local event without leaking transport internals into UI code. export function emitWsTransportState(state: WsTransportState): void { + latestWsTransportState = state; if ( typeof window === "undefined" || typeof window.dispatchEvent !== "function" || @@ -31,7 +38,9 @@ export function emitWsTransportState(state: WsTransportState): void { // Subscribes to the shared transport state event. Returns an idempotent cleanup. export function addWsTransportStateListener( listener: (state: WsTransportState) => void, + options?: { readonly replayLatest?: boolean }, ): () => void { + if (options?.replayLatest) listener(latestWsTransportState); if (typeof window === "undefined" || typeof window.addEventListener !== "function") { return () => undefined; } diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 1016c7145..d17920c13 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -440,6 +440,10 @@ export interface DesktopBridge { shell?: { showInFolder: (path: string) => Promise; }; + diagnostics?: { + /** Opens Scient's local logs without depending on the backend connection. */ + openLogsDirectory: () => Promise; + }; clipboard?: { writeImagePngDataUrl: (dataUrl: string) => Promise; }; From 0cbae5abe8c4dd4aeb4975dc3a435b61c9040e5a Mon Sep 17 00:00:00 2001 From: Yaacov Date: Tue, 21 Jul 2026 03:11:02 +0300 Subject: [PATCH 07/13] Support Linux desktop development launch --- apps/desktop/scripts/dev-electron.mjs | 24 ++++---- apps/desktop/scripts/electron-launcher.mjs | 39 +++++++++++++ .../scripts/electron-launcher.test.mjs | 55 +++++++++++++++++++ apps/desktop/scripts/smoke-test.mjs | 6 +- apps/desktop/scripts/start-electron.mjs | 5 +- 5 files changed, 113 insertions(+), 16 deletions(-) create mode 100644 apps/desktop/scripts/electron-launcher.test.mjs diff --git a/apps/desktop/scripts/dev-electron.mjs b/apps/desktop/scripts/dev-electron.mjs index 861301371..0676b2544 100644 --- a/apps/desktop/scripts/dev-electron.mjs +++ b/apps/desktop/scripts/dev-electron.mjs @@ -4,7 +4,7 @@ import { join } from "node:path"; import waitOn from "wait-on"; import { buildAppSnapHelper } from "./build-appsnap-helper.mjs"; -import { desktopDir, resolveElectronPath } from "./electron-launcher.mjs"; +import { desktopDir, resolveElectronLaunchCommand } from "./electron-launcher.mjs"; const port = Number(process.env.ELECTRON_RENDERER_PORT ?? 5733); const devServerUrl = `http://localhost:${port}`; @@ -140,18 +140,18 @@ function startApp() { return; } - const app = spawn( - resolveElectronPath(), - [`--synara-dev-root=${desktopDir}`, "dist-electron/main.js"], - { - cwd: desktopDir, - env: { - ...childEnv, - VITE_DEV_SERVER_URL: devServerUrl, - }, - stdio: "inherit", + const electronCommand = resolveElectronLaunchCommand([ + `--synara-dev-root=${desktopDir}`, + "dist-electron/main.js", + ]); + const app = spawn(electronCommand.electronPath, electronCommand.args, { + cwd: desktopDir, + env: { + ...childEnv, + VITE_DEV_SERVER_URL: devServerUrl, }, - ); + stdio: "inherit", + }); currentApp = app; diff --git a/apps/desktop/scripts/electron-launcher.mjs b/apps/desktop/scripts/electron-launcher.mjs index e06f504a2..696c1fb00 100644 --- a/apps/desktop/scripts/electron-launcher.mjs +++ b/apps/desktop/scripts/electron-launcher.mjs @@ -146,3 +146,42 @@ export function resolveElectronPath() { return buildMacLauncher(electronBinaryPath); } + +export function isLinuxSetuidSandboxConfigured( + electronBinaryPath, + { platform = process.platform, stat = statSync } = {}, +) { + if (platform !== "linux") { + return true; + } + + const sandboxPath = join(dirname(electronBinaryPath), "chrome-sandbox"); + try { + const sandboxStat = stat(sandboxPath); + return sandboxStat.uid === 0 && (sandboxStat.mode & 0o4777) === 0o4755; + } catch { + return false; + } +} + +export function resolveLinuxSandboxArgs( + electronBinaryPath, + { platform = process.platform, stat = statSync, warn = console.warn } = {}, +) { + if (isLinuxSetuidSandboxConfigured(electronBinaryPath, { platform, stat })) { + return []; + } + + warn( + "[desktop-launcher] Electron chrome-sandbox is not root-owned with mode 4755; launching local Electron with --no-sandbox.", + ); + return ["--no-sandbox"]; +} + +export function resolveElectronLaunchCommand(args = []) { + const electronPath = resolveElectronPath(); + return { + electronPath, + args: [...resolveLinuxSandboxArgs(electronPath), ...args], + }; +} diff --git a/apps/desktop/scripts/electron-launcher.test.mjs b/apps/desktop/scripts/electron-launcher.test.mjs new file mode 100644 index 000000000..48dc82387 --- /dev/null +++ b/apps/desktop/scripts/electron-launcher.test.mjs @@ -0,0 +1,55 @@ +import { describe, expect, it, vi } from "vitest"; + +import { isLinuxSetuidSandboxConfigured, resolveLinuxSandboxArgs } from "./electron-launcher.mjs"; + +const ELECTRON_PATH = "/repo/node_modules/electron/dist/electron"; +const SANDBOX_PATH = "/repo/node_modules/electron/dist/chrome-sandbox"; + +describe("Linux Electron sandbox launch policy", () => { + it("leaves non-Linux launches unchanged without inspecting chrome-sandbox", () => { + const stat = vi.fn(); + + expect(resolveLinuxSandboxArgs(ELECTRON_PATH, { platform: "darwin", stat })).toEqual([]); + expect(stat).not.toHaveBeenCalled(); + }); + + it("keeps Chromium's sandbox when chrome-sandbox is root-owned setuid 4755", () => { + const stat = vi.fn(() => ({ mode: 0o104755, uid: 0 })); + + expect(isLinuxSetuidSandboxConfigured(ELECTRON_PATH, { platform: "linux", stat })).toBe(true); + expect(resolveLinuxSandboxArgs(ELECTRON_PATH, { platform: "linux", stat })).toEqual([]); + expect(stat).toHaveBeenCalledWith(SANDBOX_PATH); + }); + + it.each([ + ["is not root-owned", { mode: 0o104755, uid: 1000 }], + ["is not setuid", { mode: 0o100755, uid: 0 }], + ["is group-writable", { mode: 0o104775, uid: 0 }], + ])("uses the local-development fallback when chrome-sandbox %s", (_reason, metadata) => { + const warn = vi.fn(); + + expect( + resolveLinuxSandboxArgs(ELECTRON_PATH, { + platform: "linux", + stat: () => metadata, + warn, + }), + ).toEqual(["--no-sandbox"]); + expect(warn).toHaveBeenCalledOnce(); + }); + + it("uses the local-development fallback when chrome-sandbox is missing", () => { + const warn = vi.fn(); + + expect( + resolveLinuxSandboxArgs(ELECTRON_PATH, { + platform: "linux", + stat: () => { + throw new Error("ENOENT"); + }, + warn, + }), + ).toEqual(["--no-sandbox"]); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("launching local Electron")); + }); +}); diff --git a/apps/desktop/scripts/smoke-test.mjs b/apps/desktop/scripts/smoke-test.mjs index 677e33f2f..7fa88373f 100644 --- a/apps/desktop/scripts/smoke-test.mjs +++ b/apps/desktop/scripts/smoke-test.mjs @@ -2,14 +2,16 @@ import { spawn, spawnSync } from "node:child_process"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { resolveElectronLaunchCommand } from "./electron-launcher.mjs"; + const __dirname = dirname(fileURLToPath(import.meta.url)); const desktopDir = resolve(__dirname, ".."); -const electronBin = resolve(desktopDir, "node_modules/.bin/electron"); const mainJs = resolve(desktopDir, "dist-electron/main.js"); console.log("\nLaunching Electron smoke test..."); -const child = spawn(electronBin, [mainJs], { +const electronCommand = resolveElectronLaunchCommand([mainJs]); +const child = spawn(electronCommand.electronPath, electronCommand.args, { stdio: ["pipe", "pipe", "pipe"], detached: process.platform !== "win32", env: { diff --git a/apps/desktop/scripts/start-electron.mjs b/apps/desktop/scripts/start-electron.mjs index 345c3843a..093f7239e 100644 --- a/apps/desktop/scripts/start-electron.mjs +++ b/apps/desktop/scripts/start-electron.mjs @@ -1,7 +1,7 @@ import { spawn } from "node:child_process"; import { buildAppSnapHelper } from "./build-appsnap-helper.mjs"; -import { desktopDir, resolveElectronPath } from "./electron-launcher.mjs"; +import { desktopDir, resolveElectronLaunchCommand } from "./electron-launcher.mjs"; if (process.platform === "darwin") { buildAppSnapHelper({ arch: process.arch }); @@ -10,7 +10,8 @@ if (process.platform === "darwin") { const childEnv = { ...process.env }; delete childEnv.ELECTRON_RUN_AS_NODE; -const child = spawn(resolveElectronPath(), ["dist-electron/main.js"], { +const electronCommand = resolveElectronLaunchCommand(["dist-electron/main.js"]); +const child = spawn(electronCommand.electronPath, electronCommand.args, { stdio: "inherit", cwd: desktopDir, env: childEnv, From 958e08871f0aeb80c089a02fab545a8a545dccdd Mon Sep 17 00:00:00 2001 From: Yaacov Date: Tue, 21 Jul 2026 04:07:41 +0300 Subject: [PATCH 08/13] Isolate provider dialog browser fixtures --- .../ProviderConnectionDialog.browser.tsx | 80 +++++++++++-------- 1 file changed, 48 insertions(+), 32 deletions(-) diff --git a/apps/web/src/components/ProviderConnectionDialog.browser.tsx b/apps/web/src/components/ProviderConnectionDialog.browser.tsx index 5e6635ba3..fadff29ce 100644 --- a/apps/web/src/components/ProviderConnectionDialog.browser.tsx +++ b/apps/web/src/components/ProviderConnectionDialog.browser.tsx @@ -103,6 +103,14 @@ describe("ProviderConnectionDialog", () => { }); it("starts official browser sign-in and shows background progress", async () => { + const initialProvider = { + provider: "codex", + status: "warning", + available: true, + authStatus: "unauthenticated", + checkedAt, + runtime: systemRuntime, + } satisfies ServerProviderStatus; const waitingProvider = { provider: "codex", status: "warning", @@ -120,15 +128,12 @@ describe("ProviderConnectionDialog", () => { }, } satisfies ServerProviderStatus; const startProviderConnection = vi.fn().mockResolvedValue({ providers: [waitingProvider] }); - const restoreNativeApi = installNativeApi({ startProviderConnection }); - const queryClient = createQueryClient({ - provider: "codex", - status: "warning", - available: true, - authStatus: "unauthenticated", - checkedAt, - runtime: systemRuntime, + const refreshProviders = vi.fn().mockResolvedValue({ providers: [initialProvider] }); + const restoreNativeApi = installNativeApi({ + refreshProviders, + startProviderConnection, }); + const queryClient = createQueryClient(initialProvider); useProviderConnectionDialogStore.getState().openDialog("codex", "settings"); const screen = await render( @@ -192,6 +197,14 @@ describe("ProviderConnectionDialog", () => { }>)( "starts the guided $provider connection flow", async ({ provider, method, title, primaryLabel }) => { + const initialProvider = { + provider, + status: "warning", + available: true, + authStatus: "unauthenticated", + checkedAt, + runtime: systemRuntime, + } satisfies ServerProviderStatus; const waitingProvider = { provider, status: "warning", @@ -209,15 +222,12 @@ describe("ProviderConnectionDialog", () => { }, } satisfies ServerProviderStatus; const startProviderConnection = vi.fn().mockResolvedValue({ providers: [waitingProvider] }); - const restoreNativeApi = installNativeApi({ startProviderConnection }); - const queryClient = createQueryClient({ - provider, - status: "warning", - available: true, - authStatus: "unauthenticated", - checkedAt, - runtime: systemRuntime, + const refreshProviders = vi.fn().mockResolvedValue({ providers: [initialProvider] }); + const restoreNativeApi = installNativeApi({ + refreshProviders, + startProviderConnection, }); + const queryClient = createQueryClient(initialProvider); useProviderConnectionDialogStore.getState().openDialog(provider, "settings"); const screen = await render( @@ -540,6 +550,22 @@ describe("ProviderConnectionDialog", () => { }); it("requires reviewed consent before starting a managed installation", async () => { + const initialProvider = { + provider: "antigravity", + status: "error", + available: false, + authStatus: "unknown", + checkedAt, + runtime: { + source: "missing", + managedVersion: null, + canInstall: true, + canRepair: false, + canRollback: false, + canRemove: false, + message: "No usable provider runtime was found.", + }, + } satisfies ServerProviderStatus; const installingProvider = { provider: "antigravity", status: "error", @@ -577,23 +603,13 @@ describe("ProviderConnectionDialog", () => { expiresAt: "2026-07-19T12:10:00.000Z", }); const installProvider = vi.fn().mockResolvedValue({ providers: [installingProvider] }); - const restoreNativeApi = installNativeApi({ prepareProviderInstall, installProvider }); - const queryClient = createQueryClient({ - provider: "antigravity", - status: "error", - available: false, - authStatus: "unknown", - checkedAt, - runtime: { - source: "missing", - managedVersion: null, - canInstall: true, - canRepair: false, - canRollback: false, - canRemove: false, - message: "No usable provider runtime was found.", - }, + const refreshProviders = vi.fn().mockResolvedValue({ providers: [initialProvider] }); + const restoreNativeApi = installNativeApi({ + refreshProviders, + prepareProviderInstall, + installProvider, }); + const queryClient = createQueryClient(initialProvider); useProviderConnectionDialogStore.getState().openDialog("antigravity", "settings"); const screen = await render( From aabb53f4553024651e9b802b2e7f7e57ba39c89e Mon Sep 17 00:00:00 2001 From: Yaacov Date: Tue, 21 Jul 2026 08:29:36 +0300 Subject: [PATCH 09/13] fix(desktop): fail closed on unsafe Linux sandbox --- apps/desktop/scripts/dev-electron.mjs | 2 +- apps/desktop/scripts/electron-launcher.mjs | 50 ++++++++++--- .../scripts/electron-launcher.test.mjs | 70 +++++++++++++------ apps/desktop/scripts/smoke-test.mjs | 2 +- apps/desktop/scripts/start-electron.mjs | 4 +- 5 files changed, 91 insertions(+), 37 deletions(-) diff --git a/apps/desktop/scripts/dev-electron.mjs b/apps/desktop/scripts/dev-electron.mjs index 0676b2544..c34c2229e 100644 --- a/apps/desktop/scripts/dev-electron.mjs +++ b/apps/desktop/scripts/dev-electron.mjs @@ -143,7 +143,7 @@ function startApp() { const electronCommand = resolveElectronLaunchCommand([ `--synara-dev-root=${desktopDir}`, "dist-electron/main.js", - ]); + ], { development: true }); const app = spawn(electronCommand.electronPath, electronCommand.args, { cwd: desktopDir, env: { diff --git a/apps/desktop/scripts/electron-launcher.mjs b/apps/desktop/scripts/electron-launcher.mjs index 696c1fb00..236f2a631 100644 --- a/apps/desktop/scripts/electron-launcher.mjs +++ b/apps/desktop/scripts/electron-launcher.mjs @@ -5,6 +5,7 @@ import { copyFileSync, cpSync, existsSync, + lstatSync, mkdirSync, readFileSync, readdirSync, @@ -149,7 +150,7 @@ export function resolveElectronPath() { export function isLinuxSetuidSandboxConfigured( electronBinaryPath, - { platform = process.platform, stat = statSync } = {}, + { platform = process.platform, lstat = lstatSync } = {}, ) { if (platform !== "linux") { return true; @@ -157,31 +158,58 @@ export function isLinuxSetuidSandboxConfigured( const sandboxPath = join(dirname(electronBinaryPath), "chrome-sandbox"); try { - const sandboxStat = stat(sandboxPath); - return sandboxStat.uid === 0 && (sandboxStat.mode & 0o4777) === 0o4755; + const sandboxStat = lstat(sandboxPath); + return ( + sandboxStat.isFile() && + sandboxStat.uid === 0 && + (sandboxStat.mode & 0o7777) === 0o4755 + ); } catch { return false; } } +export class LinuxSandboxConfigurationError extends Error { + constructor(sandboxPath) { + super( + `Electron sandbox helper ${sandboxPath} must be a regular file owned by root with exact mode 4755. ` + + "Repair the helper before launching Scient. For an isolated local development session only, " + + "set SCIENT_DEV_ALLOW_NO_SANDBOX=1 to accept the unsafe fallback explicitly.", + ); + this.name = "LinuxSandboxConfigurationError"; + this.sandboxPath = sandboxPath; + } +} + export function resolveLinuxSandboxArgs( electronBinaryPath, - { platform = process.platform, stat = statSync, warn = console.warn } = {}, + { + platform = process.platform, + lstat = lstatSync, + development = isDevelopment, + env = process.env, + warn = console.warn, + } = {}, ) { - if (isLinuxSetuidSandboxConfigured(electronBinaryPath, { platform, stat })) { + if (isLinuxSetuidSandboxConfigured(electronBinaryPath, { platform, lstat })) { return []; } - warn( - "[desktop-launcher] Electron chrome-sandbox is not root-owned with mode 4755; launching local Electron with --no-sandbox.", - ); - return ["--no-sandbox"]; + const sandboxPath = join(dirname(electronBinaryPath), "chrome-sandbox"); + if (development && env.SCIENT_DEV_ALLOW_NO_SANDBOX === "1") { + warn( + "[desktop-launcher] Unsafe development override accepted: launching local Electron with --no-sandbox.", + ); + return ["--no-sandbox"]; + } + + throw new LinuxSandboxConfigurationError(sandboxPath); } -export function resolveElectronLaunchCommand(args = []) { +export function resolveElectronLaunchCommand(args = [], options = {}) { const electronPath = resolveElectronPath(); return { electronPath, - args: [...resolveLinuxSandboxArgs(electronPath), ...args], + args: [...resolveLinuxSandboxArgs(electronPath, options), ...args], }; } diff --git a/apps/desktop/scripts/electron-launcher.test.mjs b/apps/desktop/scripts/electron-launcher.test.mjs index 48dc82387..28c7f95bd 100644 --- a/apps/desktop/scripts/electron-launcher.test.mjs +++ b/apps/desktop/scripts/electron-launcher.test.mjs @@ -1,55 +1,79 @@ import { describe, expect, it, vi } from "vitest"; -import { isLinuxSetuidSandboxConfigured, resolveLinuxSandboxArgs } from "./electron-launcher.mjs"; +import { + isLinuxSetuidSandboxConfigured, + LinuxSandboxConfigurationError, + resolveLinuxSandboxArgs, +} from "./electron-launcher.mjs"; const ELECTRON_PATH = "/repo/node_modules/electron/dist/electron"; const SANDBOX_PATH = "/repo/node_modules/electron/dist/chrome-sandbox"; describe("Linux Electron sandbox launch policy", () => { it("leaves non-Linux launches unchanged without inspecting chrome-sandbox", () => { - const stat = vi.fn(); + const lstat = vi.fn(); - expect(resolveLinuxSandboxArgs(ELECTRON_PATH, { platform: "darwin", stat })).toEqual([]); - expect(stat).not.toHaveBeenCalled(); + expect(resolveLinuxSandboxArgs(ELECTRON_PATH, { platform: "darwin", lstat })).toEqual([]); + expect(lstat).not.toHaveBeenCalled(); }); it("keeps Chromium's sandbox when chrome-sandbox is root-owned setuid 4755", () => { - const stat = vi.fn(() => ({ mode: 0o104755, uid: 0 })); + const lstat = vi.fn(() => ({ isFile: () => true, mode: 0o104755, uid: 0 })); - expect(isLinuxSetuidSandboxConfigured(ELECTRON_PATH, { platform: "linux", stat })).toBe(true); - expect(resolveLinuxSandboxArgs(ELECTRON_PATH, { platform: "linux", stat })).toEqual([]); - expect(stat).toHaveBeenCalledWith(SANDBOX_PATH); + expect(isLinuxSetuidSandboxConfigured(ELECTRON_PATH, { platform: "linux", lstat })).toBe(true); + expect(resolveLinuxSandboxArgs(ELECTRON_PATH, { platform: "linux", lstat })).toEqual([]); + expect(lstat).toHaveBeenCalledWith(SANDBOX_PATH); }); it.each([ - ["is not root-owned", { mode: 0o104755, uid: 1000 }], - ["is not setuid", { mode: 0o100755, uid: 0 }], - ["is group-writable", { mode: 0o104775, uid: 0 }], - ])("uses the local-development fallback when chrome-sandbox %s", (_reason, metadata) => { - const warn = vi.fn(); + ["is not root-owned", { isFile: () => true, mode: 0o104755, uid: 1000 }], + ["is not setuid", { isFile: () => true, mode: 0o100755, uid: 0 }], + ["is group-writable", { isFile: () => true, mode: 0o104775, uid: 0 }], + ["has extra special bits", { isFile: () => true, mode: 0o106755, uid: 0 }], + ["is not a regular file", { isFile: () => false, mode: 0o104755, uid: 0 }], + ])("fails closed when chrome-sandbox %s", (_reason, metadata) => { + expect(() => + resolveLinuxSandboxArgs(ELECTRON_PATH, { + platform: "linux", + lstat: () => metadata, + }), + ).toThrow(LinuxSandboxConfigurationError); + }); - expect( + it("fails closed when chrome-sandbox is missing", () => { + expect(() => resolveLinuxSandboxArgs(ELECTRON_PATH, { platform: "linux", - stat: () => metadata, - warn, + lstat: () => { + throw new Error("ENOENT"); + }, }), - ).toEqual(["--no-sandbox"]); - expect(warn).toHaveBeenCalledOnce(); + ).toThrow(expect.objectContaining({ sandboxPath: SANDBOX_PATH })); }); - it("uses the local-development fallback when chrome-sandbox is missing", () => { + it("allows --no-sandbox only through an explicit local-development override", () => { const warn = vi.fn(); expect( resolveLinuxSandboxArgs(ELECTRON_PATH, { platform: "linux", - stat: () => { - throw new Error("ENOENT"); - }, + lstat: () => ({ isFile: () => false, mode: 0, uid: 1000 }), + development: true, + env: { SCIENT_DEV_ALLOW_NO_SANDBOX: "1" }, warn, }), ).toEqual(["--no-sandbox"]); - expect(warn).toHaveBeenCalledWith(expect.stringContaining("launching local Electron")); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("Unsafe development override")); + }); + + it("refuses the unsafe override outside development", () => { + expect(() => + resolveLinuxSandboxArgs(ELECTRON_PATH, { + platform: "linux", + lstat: () => ({ isFile: () => false, mode: 0, uid: 1000 }), + development: false, + env: { SCIENT_DEV_ALLOW_NO_SANDBOX: "1" }, + }), + ).toThrow(LinuxSandboxConfigurationError); }); }); diff --git a/apps/desktop/scripts/smoke-test.mjs b/apps/desktop/scripts/smoke-test.mjs index 7fa88373f..b864df9c5 100644 --- a/apps/desktop/scripts/smoke-test.mjs +++ b/apps/desktop/scripts/smoke-test.mjs @@ -10,7 +10,7 @@ const mainJs = resolve(desktopDir, "dist-electron/main.js"); console.log("\nLaunching Electron smoke test..."); -const electronCommand = resolveElectronLaunchCommand([mainJs]); +const electronCommand = resolveElectronLaunchCommand([mainJs], { development: false }); const child = spawn(electronCommand.electronPath, electronCommand.args, { stdio: ["pipe", "pipe", "pipe"], detached: process.platform !== "win32", diff --git a/apps/desktop/scripts/start-electron.mjs b/apps/desktop/scripts/start-electron.mjs index 093f7239e..2822a18c3 100644 --- a/apps/desktop/scripts/start-electron.mjs +++ b/apps/desktop/scripts/start-electron.mjs @@ -10,7 +10,9 @@ if (process.platform === "darwin") { const childEnv = { ...process.env }; delete childEnv.ELECTRON_RUN_AS_NODE; -const electronCommand = resolveElectronLaunchCommand(["dist-electron/main.js"]); +const electronCommand = resolveElectronLaunchCommand(["dist-electron/main.js"], { + development: true, +}); const child = spawn(electronCommand.electronPath, electronCommand.args, { stdio: "inherit", cwd: desktopDir, From 57439dd4df6a42412d21f5a24223c3dd99bb3214 Mon Sep 17 00:00:00 2001 From: Yaacov Date: Tue, 21 Jul 2026 08:45:17 +0300 Subject: [PATCH 10/13] style(desktop): format Linux launcher hardening --- apps/desktop/scripts/dev-electron.mjs | 8 ++++---- apps/desktop/scripts/electron-launcher.mjs | 6 +----- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/apps/desktop/scripts/dev-electron.mjs b/apps/desktop/scripts/dev-electron.mjs index c34c2229e..acdeaf566 100644 --- a/apps/desktop/scripts/dev-electron.mjs +++ b/apps/desktop/scripts/dev-electron.mjs @@ -140,10 +140,10 @@ function startApp() { return; } - const electronCommand = resolveElectronLaunchCommand([ - `--synara-dev-root=${desktopDir}`, - "dist-electron/main.js", - ], { development: true }); + const electronCommand = resolveElectronLaunchCommand( + [`--synara-dev-root=${desktopDir}`, "dist-electron/main.js"], + { development: true }, + ); const app = spawn(electronCommand.electronPath, electronCommand.args, { cwd: desktopDir, env: { diff --git a/apps/desktop/scripts/electron-launcher.mjs b/apps/desktop/scripts/electron-launcher.mjs index 236f2a631..f0e729ff0 100644 --- a/apps/desktop/scripts/electron-launcher.mjs +++ b/apps/desktop/scripts/electron-launcher.mjs @@ -159,11 +159,7 @@ export function isLinuxSetuidSandboxConfigured( const sandboxPath = join(dirname(electronBinaryPath), "chrome-sandbox"); try { const sandboxStat = lstat(sandboxPath); - return ( - sandboxStat.isFile() && - sandboxStat.uid === 0 && - (sandboxStat.mode & 0o7777) === 0o4755 - ); + return sandboxStat.isFile() && sandboxStat.uid === 0 && (sandboxStat.mode & 0o7777) === 0o4755; } catch { return false; } From 09e114726458b36507fe09edf57f9ddfab350b24 Mon Sep 17 00:00:00 2001 From: Yaacov Date: Tue, 21 Jul 2026 09:36:08 +0300 Subject: [PATCH 11/13] fix(release): preserve AppImage sandbox --- scripts/build-desktop-artifact-mac-config.test.ts | 3 +++ scripts/lib/desktop-platform-build-config.ts | 6 ++++++ scripts/release-smoke.ts | 10 +++++++++- 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/scripts/build-desktop-artifact-mac-config.test.ts b/scripts/build-desktop-artifact-mac-config.test.ts index 42b978772..938d21739 100644 --- a/scripts/build-desktop-artifact-mac-config.test.ts +++ b/scripts/build-desktop-artifact-mac-config.test.ts @@ -85,6 +85,9 @@ describe("createDesktopPlatformBuildConfig", () => { assert.equal(linux.mac, undefined); assert.equal(linux.extraFiles, undefined); assert.deepStrictEqual(linux.asarUnpack, ["node_modules/node-pty/**"]); + assert.deepStrictEqual(linux.appImage, { + executableArgs: [], + }); assert.deepStrictEqual(linux.linux, { target: ["AppImage"], executableName: "scient", diff --git a/scripts/lib/desktop-platform-build-config.ts b/scripts/lib/desktop-platform-build-config.ts index 711af1905..490dffc09 100644 --- a/scripts/lib/desktop-platform-build-config.ts +++ b/scripts/lib/desktop-platform-build-config.ts @@ -19,6 +19,7 @@ export const NODE_PTY_ASAR_UNPACK_GLOBS = ["node_modules/node-pty/**"] as const; export interface DesktopPlatformBuildConfig { readonly afterPack?: string; + readonly appImage?: Record; readonly asarUnpack?: ReadonlyArray; readonly extraFiles?: ReadonlyArray>; readonly files?: ReadonlyArray; @@ -102,6 +103,11 @@ export function createDesktopPlatformBuildConfig( if (input.platform === "linux") { return { ...nativePackaging, + // electron-builder's legacy AppImage target otherwise injects --no-sandbox + // into AppRun. An explicit empty list preserves Chromium's real sandbox. + appImage: { + executableArgs: [], + }, linux: { target: [input.target], executableName: "scient", diff --git a/scripts/release-smoke.ts b/scripts/release-smoke.ts index 11c0cc2ec..975c6f23e 100644 --- a/scripts/release-smoke.ts +++ b/scripts/release-smoke.ts @@ -121,7 +121,11 @@ function verifyCanonicalIdentity(): void { throw new Error("Expected packaged Scient clients to use the approved update channel."); } - const linux = createDesktopPlatformBuildConfig({ platform: "linux", target: "AppImage" }).linux; + const linuxBuildConfig = createDesktopPlatformBuildConfig({ + platform: "linux", + target: "AppImage", + }); + const linux = linuxBuildConfig.linux; if (!linux || linux.executableName !== "scient") { throw new Error("Expected Linux desktop releases to install the scient executable."); } @@ -133,6 +137,10 @@ function verifyCanonicalIdentity(): void { if (linux.syncDesktopName !== true) { throw new Error("Expected Linux desktop releases to synchronize the desktop entry name."); } + const appImageExecutableArgs = linuxBuildConfig.appImage?.executableArgs; + if (!Array.isArray(appImageExecutableArgs) || appImageExecutableArgs.length !== 0) { + throw new Error("Expected AppImage packaging to preserve Chromium's sandboxed command line."); + } const releasePolicy = readReleaseUpdatePolicyConfig(repoRoot); const resolvedPolicy = resolveReleaseUpdatePolicy("9.9.9", releasePolicy); From ba7b8274fbd5752a2d2073f54f0431e989cbd4c6 Mon Sep 17 00:00:00 2001 From: Yaacov Date: Tue, 21 Jul 2026 09:41:47 +0300 Subject: [PATCH 12/13] revert(release): keep AppImage migration out of launcher hardening --- scripts/build-desktop-artifact-mac-config.test.ts | 3 --- scripts/lib/desktop-platform-build-config.ts | 6 ------ scripts/release-smoke.ts | 10 +--------- 3 files changed, 1 insertion(+), 18 deletions(-) diff --git a/scripts/build-desktop-artifact-mac-config.test.ts b/scripts/build-desktop-artifact-mac-config.test.ts index 938d21739..42b978772 100644 --- a/scripts/build-desktop-artifact-mac-config.test.ts +++ b/scripts/build-desktop-artifact-mac-config.test.ts @@ -85,9 +85,6 @@ describe("createDesktopPlatformBuildConfig", () => { assert.equal(linux.mac, undefined); assert.equal(linux.extraFiles, undefined); assert.deepStrictEqual(linux.asarUnpack, ["node_modules/node-pty/**"]); - assert.deepStrictEqual(linux.appImage, { - executableArgs: [], - }); assert.deepStrictEqual(linux.linux, { target: ["AppImage"], executableName: "scient", diff --git a/scripts/lib/desktop-platform-build-config.ts b/scripts/lib/desktop-platform-build-config.ts index 490dffc09..711af1905 100644 --- a/scripts/lib/desktop-platform-build-config.ts +++ b/scripts/lib/desktop-platform-build-config.ts @@ -19,7 +19,6 @@ export const NODE_PTY_ASAR_UNPACK_GLOBS = ["node_modules/node-pty/**"] as const; export interface DesktopPlatformBuildConfig { readonly afterPack?: string; - readonly appImage?: Record; readonly asarUnpack?: ReadonlyArray; readonly extraFiles?: ReadonlyArray>; readonly files?: ReadonlyArray; @@ -103,11 +102,6 @@ export function createDesktopPlatformBuildConfig( if (input.platform === "linux") { return { ...nativePackaging, - // electron-builder's legacy AppImage target otherwise injects --no-sandbox - // into AppRun. An explicit empty list preserves Chromium's real sandbox. - appImage: { - executableArgs: [], - }, linux: { target: [input.target], executableName: "scient", diff --git a/scripts/release-smoke.ts b/scripts/release-smoke.ts index 975c6f23e..11c0cc2ec 100644 --- a/scripts/release-smoke.ts +++ b/scripts/release-smoke.ts @@ -121,11 +121,7 @@ function verifyCanonicalIdentity(): void { throw new Error("Expected packaged Scient clients to use the approved update channel."); } - const linuxBuildConfig = createDesktopPlatformBuildConfig({ - platform: "linux", - target: "AppImage", - }); - const linux = linuxBuildConfig.linux; + const linux = createDesktopPlatformBuildConfig({ platform: "linux", target: "AppImage" }).linux; if (!linux || linux.executableName !== "scient") { throw new Error("Expected Linux desktop releases to install the scient executable."); } @@ -137,10 +133,6 @@ function verifyCanonicalIdentity(): void { if (linux.syncDesktopName !== true) { throw new Error("Expected Linux desktop releases to synchronize the desktop entry name."); } - const appImageExecutableArgs = linuxBuildConfig.appImage?.executableArgs; - if (!Array.isArray(appImageExecutableArgs) || appImageExecutableArgs.length !== 0) { - throw new Error("Expected AppImage packaging to preserve Chromium's sandboxed command line."); - } const releasePolicy = readReleaseUpdatePolicyConfig(repoRoot); const resolvedPolicy = resolveReleaseUpdatePolicy("9.9.9", releasePolicy); From ee3a38c6b316c90174c9920461e4562e9d6332f4 Mon Sep 17 00:00:00 2001 From: Yaacov Date: Tue, 21 Jul 2026 09:54:25 +0300 Subject: [PATCH 13/13] fix(desktop): honor Linux user namespace sandbox --- apps/desktop/scripts/electron-launcher.mjs | 28 +++++++++++-- .../scripts/electron-launcher.test.mjs | 40 +++++++++++++++++-- 2 files changed, 62 insertions(+), 6 deletions(-) diff --git a/apps/desktop/scripts/electron-launcher.mjs b/apps/desktop/scripts/electron-launcher.mjs index f0e729ff0..3c3075ba2 100644 --- a/apps/desktop/scripts/electron-launcher.mjs +++ b/apps/desktop/scripts/electron-launcher.mjs @@ -165,11 +165,29 @@ export function isLinuxSetuidSandboxConfigured( } } +export function isLinuxUserNamespaceSandboxAvailable({ + platform = process.platform, + runUnshare = spawnSync, +} = {}) { + if (platform !== "linux") { + return true; + } + + const result = runUnshare("unshare", ["-Ur", "true"], { + shell: false, + stdio: "ignore", + timeout: 5_000, + windowsHide: true, + }); + return !result.error && result.status === 0; +} + export class LinuxSandboxConfigurationError extends Error { constructor(sandboxPath) { super( - `Electron sandbox helper ${sandboxPath} must be a regular file owned by root with exact mode 4755. ` + - "Repair the helper before launching Scient. For an isolated local development session only, " + + `Electron needs either unprivileged user namespaces or a sandbox helper at ${sandboxPath} ` + + "that is a regular file owned by root with exact mode 4755. Enable one of those sandbox paths " + + "before launching Scient. For an isolated local development session only, " + "set SCIENT_DEV_ALLOW_NO_SANDBOX=1 to accept the unsafe fallback explicitly.", ); this.name = "LinuxSandboxConfigurationError"; @@ -182,12 +200,16 @@ export function resolveLinuxSandboxArgs( { platform = process.platform, lstat = lstatSync, + runUnshare = spawnSync, development = isDevelopment, env = process.env, warn = console.warn, } = {}, ) { - if (isLinuxSetuidSandboxConfigured(electronBinaryPath, { platform, lstat })) { + if ( + isLinuxSetuidSandboxConfigured(electronBinaryPath, { platform, lstat }) || + isLinuxUserNamespaceSandboxAvailable({ platform, runUnshare }) + ) { return []; } diff --git a/apps/desktop/scripts/electron-launcher.test.mjs b/apps/desktop/scripts/electron-launcher.test.mjs index 28c7f95bd..b5d9e6b7c 100644 --- a/apps/desktop/scripts/electron-launcher.test.mjs +++ b/apps/desktop/scripts/electron-launcher.test.mjs @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import { isLinuxSetuidSandboxConfigured, + isLinuxUserNamespaceSandboxAvailable, LinuxSandboxConfigurationError, resolveLinuxSandboxArgs, } from "./electron-launcher.mjs"; @@ -12,17 +13,46 @@ const SANDBOX_PATH = "/repo/node_modules/electron/dist/chrome-sandbox"; describe("Linux Electron sandbox launch policy", () => { it("leaves non-Linux launches unchanged without inspecting chrome-sandbox", () => { const lstat = vi.fn(); + const runUnshare = vi.fn(); - expect(resolveLinuxSandboxArgs(ELECTRON_PATH, { platform: "darwin", lstat })).toEqual([]); + expect( + resolveLinuxSandboxArgs(ELECTRON_PATH, { platform: "darwin", lstat, runUnshare }), + ).toEqual([]); expect(lstat).not.toHaveBeenCalled(); + expect(runUnshare).not.toHaveBeenCalled(); }); it("keeps Chromium's sandbox when chrome-sandbox is root-owned setuid 4755", () => { const lstat = vi.fn(() => ({ isFile: () => true, mode: 0o104755, uid: 0 })); + const runUnshare = vi.fn(); expect(isLinuxSetuidSandboxConfigured(ELECTRON_PATH, { platform: "linux", lstat })).toBe(true); - expect(resolveLinuxSandboxArgs(ELECTRON_PATH, { platform: "linux", lstat })).toEqual([]); + expect( + resolveLinuxSandboxArgs(ELECTRON_PATH, { platform: "linux", lstat, runUnshare }), + ).toEqual([]); expect(lstat).toHaveBeenCalledWith(SANDBOX_PATH); + expect(runUnshare).not.toHaveBeenCalled(); + }); + + it("keeps Chromium's sandbox when unprivileged user namespaces work without a helper", () => { + const runUnshare = vi.fn(() => ({ status: 0 })); + + expect( + resolveLinuxSandboxArgs(ELECTRON_PATH, { + platform: "linux", + lstat: () => { + throw new Error("ENOENT"); + }, + runUnshare, + }), + ).toEqual([]); + expect(runUnshare).toHaveBeenCalledWith("unshare", ["-Ur", "true"], { + shell: false, + stdio: "ignore", + timeout: 5_000, + windowsHide: true, + }); + expect(isLinuxUserNamespaceSandboxAvailable({ platform: "linux", runUnshare })).toBe(true); }); it.each([ @@ -36,17 +66,19 @@ describe("Linux Electron sandbox launch policy", () => { resolveLinuxSandboxArgs(ELECTRON_PATH, { platform: "linux", lstat: () => metadata, + runUnshare: () => ({ status: 1 }), }), ).toThrow(LinuxSandboxConfigurationError); }); - it("fails closed when chrome-sandbox is missing", () => { + it("fails closed when both chrome-sandbox and unprivileged user namespaces are unavailable", () => { expect(() => resolveLinuxSandboxArgs(ELECTRON_PATH, { platform: "linux", lstat: () => { throw new Error("ENOENT"); }, + runUnshare: () => ({ status: 1 }), }), ).toThrow(expect.objectContaining({ sandboxPath: SANDBOX_PATH })); }); @@ -58,6 +90,7 @@ describe("Linux Electron sandbox launch policy", () => { resolveLinuxSandboxArgs(ELECTRON_PATH, { platform: "linux", lstat: () => ({ isFile: () => false, mode: 0, uid: 1000 }), + runUnshare: () => ({ status: 1 }), development: true, env: { SCIENT_DEV_ALLOW_NO_SANDBOX: "1" }, warn, @@ -71,6 +104,7 @@ describe("Linux Electron sandbox launch policy", () => { resolveLinuxSandboxArgs(ELECTRON_PATH, { platform: "linux", lstat: () => ({ isFile: () => false, mode: 0, uid: 1000 }), + runUnshare: () => ({ status: 1 }), development: false, env: { SCIENT_DEV_ALLOW_NO_SANDBOX: "1" }, }),