From 0bab9e3438ab9f62ee8a70939e868ec5f166185a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Fri, 17 Jul 2026 23:17:15 +0200 Subject: [PATCH] fix(restore): simplify persistence and harden cross-platform cleanup (#602) ## Summary - Follow up #578 by consolidating desktop persistence, restore reconciliation, lifecycle coordination, and regression coverage. - Preserve active drafts and attachments, request-scoped workspace ownership, deletion tombstones, renderer authority, and bounded shutdown behavior. - Fix the reported macOS cleanup failure with targeted BSD process queries and random-token-guarded process-group cleanup, without an unverified PID fallback. ## Platform hardening - Ignore development renderer origins in packaged Electron builds. - Preserve staged Tauri navigation authority and handle confirmed Windows session-end shutdown on the UI thread. - Bound workspace launch preflight, runtime startup, and health readiness. - Retain cleanup ownership after unexpected leaders exit and verify portable POSIX descendants by immutable identity or inherited launch token. - Add real Darwin-only process-group integration tests for macOS CI. ## Scope - 96 files changed. - 6,295 additions and 12,167 deletions, a net reduction of 5,872 lines from the merged implementation. - Consolidated duplicated tests while retaining focused race, durability, cleanup, and platform contracts. ## Validation - pm run typecheck - pm run typecheck --workspace @neuralnomads/codenomad - Electron native suite: 60 passed - Tauri suite: 49 passed - Focused server lifecycle/identity suite: 31 passed, 2 Darwin-only skipped on Windows - Focused UI restore/codec/reconciliation suite: 36 passed - Broader server suite: 59 passed, 3 platform skips - Broader UI suite: 97 passed, 1 skip; 2 Node 25 solid-toast loader failures reproduced on the merged baseline - git diff --check - Final limited gatekeeper: PASS for server/macOS, UI restore, and Electron/Tauri --- .github/workflows/comment-pr-artifacts.yml | 5 + .github/workflows/pr-build.yml | 118 +- .../main/client-state-cross-host-child.ts | 47 + .../main/client-state-cross-host.test.ts | 226 +++ .../electron/main/client-state-cross-host.ts | 344 ++++ .../main/client-state-election-child.ts | 4 +- .../main/client-state-ipc-handlers.test.ts | 76 - .../main/client-state-ipc-handlers.ts | 52 - .../electron/main/client-state-ipc.test.ts | 64 + .../electron/main/client-state-ipc.ts | 90 +- .../main/client-state-lifecycle.test.ts | 212 ++- .../electron/main/client-state-lifecycle.ts | 242 +-- .../main/client-state-navigation.test.ts | 198 +-- .../electron/main/client-state-navigation.ts | 32 +- .../main/client-state-process-identity.ts | 73 +- .../main/client-state-process.test.ts | 629 ++----- .../electron/main/client-state-process.ts | 260 ++- .../electron/main/client-state.test.ts | 481 +++--- .../electron/main/client-state.ts | 279 +++- packages/electron-app/electron/main/ipc.ts | 3 +- .../electron/main/main-window-close.test.ts | 83 - .../electron/main/main-window-close.ts | 54 - packages/electron-app/electron/main/main.ts | 118 +- .../electron-app/electron/main/permissions.ts | 16 +- .../electron/main/process-manager.ts | 463 ++---- .../electron/main/process-stop.test.ts | 542 ++++++ .../electron/main/process-stop.ts | 361 ++++ .../main/renderer-client-state-flush.test.ts | 43 +- .../electron/main/renderer-origin.test.ts | 25 + .../electron/main/renderer-origin.ts | 24 + .../main/serialized-lifecycle.test.ts | 38 + .../electron/main/serialized-lifecycle.ts | 22 + .../electron/main/window-state.test.ts | 120 +- .../electron/main/window-state.ts | 72 +- .../electron/resources/cli-supervisor.cjs | 131 -- packages/electron-app/package.json | 2 +- packages/server/src/api-types.ts | 2 +- packages/server/src/index.test.ts | 57 +- packages/server/src/index.ts | 38 +- .../src/server/__tests__/remote-proxy.test.ts | 143 +- packages/server/src/server/remote-proxy.ts | 179 +- .../src/server/routes/workspaces.test.ts | 40 + packages/server/src/settings/binaries.test.ts | 11 +- packages/server/src/shutdown.test.ts | 226 +-- packages/server/src/shutdown.ts | 164 +- .../src/workspaces/__tests__/spawn.test.ts | 4 +- .../__tests__/workspace-identity.test.ts | 449 ++--- .../src/workspaces/launch-cleanup.test.ts | 60 +- .../server/src/workspaces/launch-cleanup.ts | 165 -- .../server/src/workspaces/manager.test.ts | 511 +++--- packages/server/src/workspaces/manager.ts | 756 ++++----- .../process-identity.darwin.test.ts | 97 ++ .../src/workspaces/process-identity.test.ts | 270 ++- .../server/src/workspaces/process-identity.ts | 442 ++--- .../server/src/workspaces/runtime.test.ts | 833 +++------- packages/server/src/workspaces/runtime.ts | 762 ++++----- packages/server/tsconfig.json | 2 +- packages/tauri-app/Cargo.lock | 1 + packages/tauri-app/src-tauri/Cargo.toml | 3 +- .../tauri-app/src-tauri/src/cert_manager.rs | 44 +- .../tauri-app/src-tauri/src/cli_manager.rs | 1085 ++++++++---- .../tauri-app/src-tauri/src/client_state.rs | 512 ++++-- .../src-tauri/src/client_state/access.rs | 95 +- .../src-tauri/src/client_state/commands.rs | 33 +- .../src-tauri/src/client_state/cross_host.rs | 1467 +++++++++++++++++ .../src-tauri/src/client_state/navigation.rs | 279 +--- .../src-tauri/src/client_state/process.rs | 130 +- .../src-tauri/src/client_state/tests.rs | 1191 +++++++------ .../src-tauri/src/client_state/window.rs | 55 +- packages/tauri-app/src-tauri/src/linux_tls.rs | 20 +- packages/tauri-app/src-tauri/src/main.rs | 18 +- packages/tauri-app/src-tauri/src/shutdown.rs | 811 +++++---- packages/ui/src/App.tsx | 18 +- .../instance/shell/useSessionCache.ts | 4 +- .../ui/src/components/message-section.tsx | 1 + packages/ui/src/lib/attachment-mentions.ts | 71 +- .../ui/src/lib/attachment-placeholders.ts | 18 +- .../lib/hooks/use-app-session-capture.test.ts | 37 + .../src/lib/hooks/use-app-session-capture.ts | 463 +++--- .../src/lib/hooks/use-app-session-restore.ts | 542 ++---- .../ui/src/lib/i18n/messages/de/settings.ts | 2 +- .../ui/src/lib/i18n/messages/en/settings.ts | 2 +- .../ui/src/lib/i18n/messages/es/settings.ts | 2 +- .../ui/src/lib/i18n/messages/fr/settings.ts | 2 +- .../ui/src/lib/i18n/messages/he/settings.ts | 2 +- .../ui/src/lib/i18n/messages/ja/settings.ts | 2 +- .../ui/src/lib/i18n/messages/ne/settings.ts | 2 +- .../ui/src/lib/i18n/messages/ru/settings.ts | 2 +- .../src/lib/i18n/messages/zh-Hans/settings.ts | 2 +- packages/ui/src/lib/native/client-state.ts | 151 +- packages/ui/src/lib/retry-utils.ts | 4 +- packages/ui/src/lib/trailing-resync.test.ts | 77 +- .../abort-created-workspace-cleanup.test.ts | 430 +++-- .../stores/abort-created-workspace-cleanup.ts | 179 +- .../stores/abortable-restore-creation.test.ts | 110 -- .../src/stores/abortable-restore-creation.ts | 39 - .../app-session-prompt-hydration.test.ts | 71 +- .../stores/app-session-reconciliation.test.ts | 176 +- .../src/stores/app-session-reconciliation.ts | 219 +-- .../stores/app-session-restore-gate.test.ts | 19 + .../ui/src/stores/app-session-restore-gate.ts | 11 +- .../stores/app-session-restore-queue.test.ts | 39 + .../src/stores/app-session-restore-queue.ts | 15 + .../app-session-restore-timeout.test.ts | 88 +- .../src/stores/app-session-restore-timeout.ts | 62 +- .../stores/app-session-snapshot-merge.test.ts | 1285 ++++++--------- .../src/stores/app-session-snapshot-merge.ts | 548 +++--- .../stores/app-session-workspace-hydration.ts | 42 + packages/ui/src/stores/attachments.ts | 151 +- .../stores/client-state-attachments-codec.ts | 295 ++-- .../ui/src/stores/client-state-claim.test.ts | 28 - .../stores/client-state-clear-failure.test.ts | 72 - .../stores/client-state-clear-retry.test.ts | 63 - .../ui/src/stores/client-state-codec.test.ts | 817 ++++----- packages/ui/src/stores/client-state-codec.ts | 500 +++--- .../client-state-disable-failure.test.ts | 65 - .../client-state-disable-rejected.test.ts | 41 - .../stores/client-state-disable-retry.test.ts | 67 - .../ui/src/stores/client-state-flush.test.ts | 69 - .../ui/src/stores/client-state-future.test.ts | 44 - .../ui/src/stores/client-state-race.test.ts | 62 - .../src/stores/client-state-secondary.test.ts | 57 - .../ui/src/stores/client-state-web.test.ts | 46 - packages/ui/src/stores/client-state.test.ts | 323 +++- packages/ui/src/stores/client-state.ts | 483 ++---- packages/ui/src/stores/commands.ts | 2 - .../stores/instance-lifecycle-authority.ts | 2 +- .../stores/instance-runtime-authority.test.ts | 553 ++----- .../instances-restore-cancellation.test.ts | 20 + .../instances-restore-ownership.test.ts | 47 + packages/ui/src/stores/instances.ts | 548 +++--- packages/ui/src/stores/message-v2/bridge.ts | 18 +- packages/ui/src/stores/message-v2/bus.test.ts | 34 + packages/ui/src/stores/message-v2/bus.ts | 10 +- .../src/stores/message-v2/instance-store.ts | 40 +- .../message-hydration-authority.test.ts | 9 + .../message-v2/message-hydration-authority.ts | 2 + .../stores/restore-creation-cancellation.ts | 11 + .../restore-workspace-commit-gates.test.ts | 81 + .../stores/restore-workspace-commit-gates.ts | 111 ++ packages/ui/src/stores/session-api.ts | 414 +++-- packages/ui/src/stores/session-events.ts | 148 +- .../session-generation-admission.test.ts | 110 +- .../session-generation-recovery.test.ts | 224 +-- .../src/stores/session-generation-recovery.ts | 46 +- .../ui/src/stores/session-list-options.ts | 14 + .../stores/session-metadata-completeness.ts | 7 + .../ui/src/stores/session-metadata.test.ts | 18 + packages/ui/src/stores/session-metadata.ts | 6 +- .../ui/src/stores/session-pagination.test.ts | 22 +- .../stores/session-request-authority.test.ts | 179 ++ packages/ui/src/stores/session-state.ts | 353 ++-- packages/ui/src/stores/sessions.ts | 2 + packages/ui/src/stores/sidecars.ts | 8 +- ...orkspace-list-reconciliation-fence.test.ts | 49 + .../workspace-list-reconciliation-fence.ts | 36 + 156 files changed, 14195 insertions(+), 13353 deletions(-) create mode 100644 packages/electron-app/electron/main/client-state-cross-host-child.ts create mode 100644 packages/electron-app/electron/main/client-state-cross-host.test.ts create mode 100644 packages/electron-app/electron/main/client-state-cross-host.ts delete mode 100644 packages/electron-app/electron/main/client-state-ipc-handlers.test.ts delete mode 100644 packages/electron-app/electron/main/client-state-ipc-handlers.ts create mode 100644 packages/electron-app/electron/main/client-state-ipc.test.ts delete mode 100644 packages/electron-app/electron/main/main-window-close.test.ts delete mode 100644 packages/electron-app/electron/main/main-window-close.ts create mode 100644 packages/electron-app/electron/main/process-stop.test.ts create mode 100644 packages/electron-app/electron/main/process-stop.ts create mode 100644 packages/electron-app/electron/main/renderer-origin.test.ts create mode 100644 packages/electron-app/electron/main/renderer-origin.ts create mode 100644 packages/electron-app/electron/main/serialized-lifecycle.test.ts create mode 100644 packages/electron-app/electron/main/serialized-lifecycle.ts delete mode 100644 packages/electron-app/electron/resources/cli-supervisor.cjs delete mode 100644 packages/server/src/workspaces/launch-cleanup.ts create mode 100644 packages/server/src/workspaces/process-identity.darwin.test.ts create mode 100644 packages/tauri-app/src-tauri/src/client_state/cross_host.rs create mode 100644 packages/ui/src/lib/hooks/use-app-session-capture.test.ts delete mode 100644 packages/ui/src/stores/abortable-restore-creation.test.ts delete mode 100644 packages/ui/src/stores/abortable-restore-creation.ts create mode 100644 packages/ui/src/stores/app-session-restore-gate.test.ts create mode 100644 packages/ui/src/stores/app-session-restore-queue.test.ts create mode 100644 packages/ui/src/stores/app-session-restore-queue.ts create mode 100644 packages/ui/src/stores/app-session-workspace-hydration.ts delete mode 100644 packages/ui/src/stores/client-state-claim.test.ts delete mode 100644 packages/ui/src/stores/client-state-clear-failure.test.ts delete mode 100644 packages/ui/src/stores/client-state-clear-retry.test.ts delete mode 100644 packages/ui/src/stores/client-state-disable-failure.test.ts delete mode 100644 packages/ui/src/stores/client-state-disable-rejected.test.ts delete mode 100644 packages/ui/src/stores/client-state-disable-retry.test.ts delete mode 100644 packages/ui/src/stores/client-state-flush.test.ts delete mode 100644 packages/ui/src/stores/client-state-future.test.ts delete mode 100644 packages/ui/src/stores/client-state-race.test.ts delete mode 100644 packages/ui/src/stores/client-state-secondary.test.ts delete mode 100644 packages/ui/src/stores/client-state-web.test.ts create mode 100644 packages/ui/src/stores/instances-restore-cancellation.test.ts create mode 100644 packages/ui/src/stores/instances-restore-ownership.test.ts create mode 100644 packages/ui/src/stores/message-v2/message-hydration-authority.test.ts create mode 100644 packages/ui/src/stores/message-v2/message-hydration-authority.ts create mode 100644 packages/ui/src/stores/restore-creation-cancellation.ts create mode 100644 packages/ui/src/stores/restore-workspace-commit-gates.test.ts create mode 100644 packages/ui/src/stores/restore-workspace-commit-gates.ts create mode 100644 packages/ui/src/stores/session-metadata-completeness.ts create mode 100644 packages/ui/src/stores/session-metadata.test.ts create mode 100644 packages/ui/src/stores/session-request-authority.test.ts create mode 100644 packages/ui/src/stores/workspace-list-reconciliation-fence.test.ts create mode 100644 packages/ui/src/stores/workspace-list-reconciliation-fence.ts diff --git a/.github/workflows/comment-pr-artifacts.yml b/.github/workflows/comment-pr-artifacts.yml index 1b308d62..e6383bcf 100644 --- a/.github/workflows/comment-pr-artifacts.yml +++ b/.github/workflows/comment-pr-artifacts.yml @@ -93,6 +93,11 @@ jobs: return; } + if (matchedRun.conclusion !== 'success') { + core.setFailed(`PR Build Validation run ${matchedRun.id} concluded ${matchedRun.conclusion}.`); + return; + } + const artifacts = await github.paginate( github.rest.actions.listWorkflowRunArtifacts, { owner, repo, run_id: matchedRun.id, per_page: 100 } diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml index cf8a11a2..a50c3f5e 100644 --- a/.github/workflows/pr-build.yml +++ b/.github/workflows/pr-build.yml @@ -46,7 +46,10 @@ jobs: fi build: - needs: authorize + needs: + - authorize + - tests + - tests-tauri-windows if: ${{ needs.authorize.outputs.allowed == 'true' && !github.event.pull_request.draft }} uses: ./.github/workflows/build-and-upload.yml with: @@ -56,3 +59,116 @@ jobs: actions_artifacts_retention_days: 7 actions_artifacts_name_prefix: pr-${{ github.event.pull_request.number }}-${{ github.event.pull_request.head.sha }}- set_versions: false + + tests: + needs: authorize + if: ${{ needs.authorize.outputs.allowed == 'true' && !github.event.pull_request.draft }} + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Install Linux test dependencies (Tauri) + run: | + sudo apt-get update + sudo apt-get install -y \ + build-essential \ + pkg-config \ + libgtk-3-dev \ + libglib2.0-dev \ + libwebkit2gtk-4.1-dev \ + libsoup-3.0-dev \ + libayatana-appindicator3-dev \ + librsvg2-dev + + - name: Install dependencies + run: npm ci + + - name: Typecheck desktop clients + run: npm run typecheck + + - name: Test Electron client state + run: npm run test:native --workspace @neuralnomads/codenomad-electron-app + + - name: Test changed runnable UI behavior + run: >- + node --import tsx --test + packages/ui/src/lib/hooks/use-app-session-capture.test.ts + packages/ui/src/lib/trailing-resync.test.ts + packages/ui/src/stores/abort-created-workspace-cleanup.test.ts + packages/ui/src/stores/app-session-reconciliation.test.ts + packages/ui/src/stores/app-session-restore-gate.test.ts + packages/ui/src/stores/app-session-restore-queue.test.ts + packages/ui/src/stores/app-session-restore-timeout.test.ts + packages/ui/src/stores/app-session-snapshot-merge.test.ts + packages/ui/src/stores/restore-workspace-commit-gates.test.ts + packages/ui/src/stores/client-state-codec.test.ts + packages/ui/src/stores/client-state.test.ts + packages/ui/src/stores/instances-restore-cancellation.test.ts + packages/ui/src/stores/message-v2/message-hydration-authority.test.ts + packages/ui/src/stores/session-generation-recovery.test.ts + packages/ui/src/stores/session-metadata.test.ts + packages/ui/src/stores/session-pagination.test.ts + packages/ui/src/stores/workspace-list-reconciliation-fence.test.ts + + - name: Test restore ownership integration + run: >- + node --conditions=browser --import tsx --test --test-force-exit + packages/ui/src/stores/instances-restore-ownership.test.ts + + - name: Test server + run: node --import tsx --test "packages/server/src/**/*.test.ts" + + - name: Prepare Tauri test resources + run: >- + npm run dev:prep --workspace @codenomad/tauri-app && + node -e "require('fs').mkdirSync('packages/tauri-app/src-tauri/resources/server',{recursive:true})" + + - name: Test Tauri crate + working-directory: packages/tauri-app/src-tauri + run: cargo test --locked + + tests-tauri-windows: + needs: authorize + if: ${{ needs.authorize.outputs.allowed == 'true' && !github.event.pull_request.draft }} + runs-on: windows-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Install dependencies + run: npm ci + + - name: Test Windows server spawn behavior + run: node --import tsx --test packages/server/src/workspaces/__tests__/spawn.test.ts + + - name: Prepare Tauri test resources + run: >- + npm run dev:prep --workspace @codenomad/tauri-app && + node -e "require('fs').mkdirSync('packages/tauri-app/src-tauri/resources/server',{recursive:true})" + + - name: Test Tauri crate on Windows + working-directory: packages/tauri-app/src-tauri + run: cargo test --locked diff --git a/packages/electron-app/electron/main/client-state-cross-host-child.ts b/packages/electron-app/electron/main/client-state-cross-host-child.ts new file mode 100644 index 00000000..079032bb --- /dev/null +++ b/packages/electron-app/electron/main/client-state-cross-host-child.ts @@ -0,0 +1,47 @@ +import { existsSync, writeFileSync } from "node:fs" +import { CrossHostRegistration, createCrossHostOwner } from "./client-state-cross-host" +import { getProcessStartIdentity } from "./client-state-process-identity" +import { isPidAlive } from "./client-state-process" +import { ClientStateManager } from "./client-state" + +const [directory, startPath, readyPath, mode, userDataPath, participantReadyPath, participantContinuePath, legacyTauriDataPath, operation, payload] = process.argv.slice(2) +if (!directory || !startPath) throw new Error("Expected election directory and start path") +if (readyPath) writeFileSync(readyPath, "") +while (!existsSync(startPath)) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5) + +const manager = mode === "full" && userDataPath + ? new ClientStateManager(userDataPath, undefined, { + crossHostElectionDirectory: directory, + legacyTauriDataPath: legacyTauriDataPath || null, + crossHostDependencies: { + pidAlive: isPidAlive, + processStartIdentity: getProcessStartIdentity, + onParticipantPublished: participantReadyPath && participantContinuePath + ? () => { + writeFileSync(participantReadyPath, "") + while (!existsSync(participantContinuePath)) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5) + } + : undefined, + }, + }) + : undefined +const owner = manager ? undefined : createCrossHostOwner() +const registration = owner && CrossHostRegistration.register(directory, owner, true, { + pidAlive: mode === "retire-crash" ? () => false : isPidAlive, + processStartIdentity: getProcessStartIdentity, + onOwnerPrepared: mode === "owner-crash" ? () => process.exit(91) : undefined, + onOwnerRetired: mode === "retire-crash" ? () => process.exit(91) : undefined, +}) +if (manager?.isPrimary && operation === "save") { + await manager.setRestoreEnabled(true) + await manager.saveClientState(JSON.parse(payload)) +} +process.stdout.write(`${JSON.stringify({ + acquired: manager?.isPrimary ?? Boolean(registration?.isPrimary), + state: operation === "load" ? manager?.loadClientState() : undefined, +})}\n`) +process.stdin.resume() +process.stdin.once("end", () => { + if (manager) void manager.drainAndReleasePrimary().finally(() => process.exit()) + else registration?.release() +}) diff --git a/packages/electron-app/electron/main/client-state-cross-host.test.ts b/packages/electron-app/electron/main/client-state-cross-host.test.ts new file mode 100644 index 00000000..1d7e0b38 --- /dev/null +++ b/packages/electron-app/electron/main/client-state-cross-host.test.ts @@ -0,0 +1,226 @@ +import assert from "node:assert/strict" +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process" +import { once } from "node:events" +import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join, posix, win32 } from "node:path" +import { fileURLToPath } from "node:url" +import test from "node:test" +import { + CrossHostRegistration, + CROSS_HOST_OWNER_DIRECTORY, + resolveCrossHostElectionDirectory, + resolveCrossHostStatePath, + type CrossHostLeaseDependencies, +} from "./client-state-cross-host" +import type { ProcessOwner } from "./client-state-process" + +function temp(t: test.TestContext): string { + const path = mkdtempSync(join(tmpdir(), "codenomad-cross-host-")) + t.after(() => rmSync(path, { recursive: true, force: true })) + return path +} + +function owner(pid: number, token: string, identity = `${token}-start`): ProcessOwner { + return { pid, runToken: token, processStartIdentity: identity } +} + +function dependencies(alive: boolean, identity?: string): CrossHostLeaseDependencies { + return { pidAlive: () => alive, processStartIdentity: () => identity } +} + +function ownerFile(directory: string): string { + return join(directory, CROSS_HOST_OWNER_DIRECTORY, "owner.json") +} + +interface Child { + process: ChildProcessWithoutNullStreams + result: Promise +} + +function child(directory: string, start: string, mode = ""): Child { + const process = spawn(globalThis.process.execPath, [ + "--import", "tsx", fileURLToPath(new URL("./client-state-cross-host-child.ts", import.meta.url)), directory, start, "", mode, + ]) as ChildProcessWithoutNullStreams + process.stdout.setEncoding("utf8"); process.stderr.setEncoding("utf8") + const result = new Promise((resolve, reject) => { + let output = "", errors = "" + process.stdout.on("data", (chunk: string) => { + output += chunk + if (output.includes("\n")) resolve(JSON.parse(output).acquired) + }) + process.stderr.on("data", (chunk: string) => { errors += chunk }) + process.once("error", reject) + process.once("exit", (code) => { if (!output && code !== 91) reject(new Error(`child ${code}: ${errors}`)) }) + }) + return { process, result } +} + +async function stop(...children: Child[]): Promise { + const running = children.filter(({ process }) => process.exitCode === null && process.signalCode === null) + const exits = running.map(({ process }) => once(process, "exit")) + running.forEach(({ process }) => process.stdin.end()) + await Promise.all(exits) +} + +async function waitForExit(child: Child): Promise { + if (child.process.exitCode === null && child.process.signalCode === null) await once(child.process, "exit") +} + +test("simultaneous acquisition across processes yields one owner", async (t) => { + const directory = temp(t), start = join(directory, "start") + const children = Array.from({ length: 4 }, () => child(directory, start)) + try { + writeFileSync(start, "") + assert.equal((await Promise.all(children.map(({ result }) => result))).filter(Boolean).length, 1) + } finally { await stop(...children) } +}) + +test("owner publication crash leaves no visible owner", async (t) => { + const directory = temp(t), start = join(directory, "start") + const crashed = child(directory, start, "owner-crash") + writeFileSync(start, "") + await waitForExit(crashed) + assert.equal(existsSync(join(directory, CROSS_HOST_OWNER_DIRECTORY)), false) + const winner = CrossHostRegistration.register(directory, owner(101, "winner"), true, dependencies(true, "winner-start"))! + assert.equal(winner.isPrimary, true) +}) + +test("stale retirement crash cannot retire a successor", async (t) => { + const directory = temp(t), staleDirectory = join(directory, CROSS_HOST_OWNER_DIRECTORY) + mkdirSync(staleDirectory) + writeFileSync(join(staleDirectory, "owner.json"), JSON.stringify(owner(4_000_000_000, "stale"))) + const start = join(directory, "start"), crashed = child(directory, start, "retire-crash") + writeFileSync(start, "") + await waitForExit(crashed) + const winner = CrossHostRegistration.register(directory, owner(102, "successor"), true, dependencies(true, "successor-start"))! + assert.equal(winner.isPrimary, true) + assert.equal(JSON.parse(readFileSync(ownerFile(directory), "utf8")).runToken, "successor") +}) + +test("stale recovery is identity guarded and blocked by non-claiming live participants", (t) => { + for (const value of [ + { alive: false, identity: undefined, recover: true }, + { alive: true, identity: "reused", recover: true }, + { alive: true, identity: "old-start", recover: false }, + { alive: true, identity: undefined, recover: false }, + ]) { + const directory = temp(t), staleDirectory = join(directory, CROSS_HOST_OWNER_DIRECTORY) + mkdirSync(staleDirectory); writeFileSync(join(staleDirectory, "owner.json"), JSON.stringify(owner(201, "old", "old-start"))) + const registration = CrossHostRegistration.register(directory, owner(202, "new"), true, dependencies(value.alive, value.identity))! + assert.equal(registration.isPrimary, value.recover) + } + + const directory = temp(t), staleDirectory = join(directory, CROSS_HOST_OWNER_DIRECTORY) + mkdirSync(staleDirectory); writeFileSync(join(staleDirectory, "owner.json"), JSON.stringify(owner(301, "dead"))) + writeFileSync(join(directory, "participant.302.secondary.json"), JSON.stringify(owner(302, "secondary"))) + const identities = new Map([[302, "secondary-start"]]) + const blocked = CrossHostRegistration.register(directory, owner(303, "next"), true, { + pidAlive: (pid) => pid === 302, + processStartIdentity: (pid) => identities.get(pid), + })! + assert.equal(blocked.isPrimary, false) +}) + +test("simultaneous claimants deterministically recover a stale owner", (t) => { + const directory = temp(t), staleDirectory = join(directory, CROSS_HOST_OWNER_DIRECTORY) + const stale = owner(601, "stale"), first = owner(602, "a"), second = owner(603, "b") + mkdirSync(staleDirectory) + const observed = JSON.stringify(stale) + writeFileSync(join(staleDirectory, "owner.json"), observed) + writeFileSync(join(directory, "participant.603.b.json"), JSON.stringify(second)) + writeFileSync(join(directory, "recovery.603.b.claim"), observed) + const identities = new Map([[602, first.processStartIdentity], [603, second.processStartIdentity]]) + const deps = { + pidAlive: (pid: number) => pid !== stale.pid, + processStartIdentity: (pid: number) => identities.get(pid), + } + const winner = CrossHostRegistration.register(directory, first, true, deps)! + const loser = CrossHostRegistration.register(directory, second, true, deps)! + assert.equal(winner.isPrimary, true) + assert.equal(loser.isPrimary, false) +}) + +test("graceful primary release allows a successor while a secondary remains", (t) => { + const directory = temp(t) + const primary = CrossHostRegistration.register(directory, owner(401, "primary"), true, dependencies(true, "primary-start"))! + const secondary = CrossHostRegistration.register(directory, owner(402, "secondary"), true, dependencies(true, "primary-start"))! + assert.equal(secondary.isPrimary, false) + + assert.equal(primary.release(), true) + const successor = CrossHostRegistration.register(directory, owner(403, "successor"), true, dependencies(true, "successor-start"))! + assert.equal(successor.isPrimary, true) +}) + +test("graceful handoff retires the old cohort so a crashed successor can recover", (t) => { + const directory = temp(t), secondaryOwner = owner(422, "secondary"), successorOwner = owner(423, "successor"), lateOwner = owner(425, "late") + const malformed = join(directory, "participant.malformed.json") + const primary = CrossHostRegistration.register(directory, owner(421, "primary"), true, { + pidAlive: () => true, + processStartIdentity: () => "primary-start", + onGracefulOwnerChecked: () => { + writeFileSync(join(directory, "participant.423.successor.json"), JSON.stringify(successorOwner)) + writeFileSync(join(directory, "participant.425.late.json"), JSON.stringify(lateOwner)) + writeFileSync(malformed, "malformed") + }, + onOwnerRetired: () => { + mkdirSync(join(directory, CROSS_HOST_OWNER_DIRECTORY)) + writeFileSync(ownerFile(directory), JSON.stringify(successorOwner)) + }, + })! + CrossHostRegistration.register(directory, secondaryOwner, true, dependencies(true, "primary-start"))! + + primary.release() + assert.equal(readdirSync(directory).some((name) => name.startsWith("retired.")), false) + assert.equal(JSON.parse(readFileSync(ownerFile(directory), "utf8")).runToken, "successor") + assert.equal(existsSync(join(directory, "participant.423.successor.json")), false) + assert.equal(existsSync(join(directory, "participant.425.late.json")), false) + assert.equal(existsSync(malformed), false) + + const claimantOwner = owner(424, "claimant"), identities = new Map([ + [secondaryOwner.pid, secondaryOwner.processStartIdentity], + [lateOwner.pid, lateOwner.processStartIdentity], + [claimantOwner.pid, claimantOwner.processStartIdentity], + ]) + const claimant = CrossHostRegistration.register(directory, claimantOwner, true, { + pidAlive: (pid) => identities.has(pid), + processStartIdentity: (pid) => identities.get(pid), + })! + assert.equal(claimant.isPrimary, true) +}) + +test("non-owner release does not remove a live owner's record", (t) => { + const directory = temp(t) + const primary = CrossHostRegistration.register(directory, owner(411, "primary"), true, dependencies(true, "primary-start"))! + const secondary = CrossHostRegistration.register(directory, owner(412, "secondary"), true, dependencies(true, "primary-start"))! + + assert.equal(secondary.release(), true) + assert.equal(primary.isPrimary, true) + assert.equal(JSON.parse(readFileSync(ownerFile(directory), "utf8")).runToken, "primary") +}) + +test("primary crash remains fenced by its non-claiming secondary cohort", async (t) => { + const directory = temp(t), firstStart = join(directory, "first-start") + const primary = child(directory, firstStart), secondary = child(directory, firstStart) + writeFileSync(firstStart, "") + const roles = await Promise.all([primary.result, secondary.result]) + const winner = roles[0] ? primary : secondary, survivor = roles[0] ? secondary : primary + winner.process.kill() + await waitForExit(winner) + const blocked = CrossHostRegistration.register(directory, owner(501, "blocked"), true)! + assert.equal(blocked.isPrimary, false) + blocked.release() + survivor.process.kill() + await waitForExit(survivor) + const successor = CrossHostRegistration.register(directory, owner(502, "successor"), true)! + assert.equal(successor.isPrimary, true) +}) + +test("platform paths match the Rust contract", () => { + assert.equal(resolveCrossHostElectionDirectory({ HOME: "/Users/dev" }, "darwin", "/fallback"), posix.join("/Users/dev", ".codenomad", "client-state", "election")) + assert.equal(resolveCrossHostElectionDirectory({ HOME: "/home/dev" }, "linux", "/fallback"), posix.join("/home/dev", ".codenomad", "client-state", "election")) + assert.equal(resolveCrossHostElectionDirectory({ USERPROFILE: "", HOME: "D:\\Home" }, "win32", "C:\\Fallback"), win32.join("D:\\Home", ".codenomad", "client-state", "election")) + assert.equal(resolveCrossHostStatePath({ HOME: "/Users/dev" }, "darwin", "/fallback"), posix.join("/Users/dev", ".codenomad", "client-state", "client-state.json")) + assert.equal(resolveCrossHostStatePath({ HOME: "/home/dev" }, "linux", "/fallback"), posix.join("/home/dev", ".codenomad", "client-state", "client-state.json")) + assert.equal(resolveCrossHostStatePath({ USERPROFILE: "", HOME: "D:\\Home" }, "win32", "C:\\Fallback"), win32.join("D:\\Home", ".codenomad", "client-state", "client-state.json")) +}) diff --git a/packages/electron-app/electron/main/client-state-cross-host.ts b/packages/electron-app/electron/main/client-state-cross-host.ts new file mode 100644 index 00000000..33cbbd82 --- /dev/null +++ b/packages/electron-app/electron/main/client-state-cross-host.ts @@ -0,0 +1,344 @@ +import { randomUUID } from "node:crypto" +import { closeSync, existsSync, fsyncSync, linkSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, rmSync, unlinkSync, writeFileSync } from "node:fs" +import { homedir } from "node:os" +import { basename, dirname, join, posix, win32 } from "node:path" +import { getProcessStartIdentity, type ProcessStartIdentityLookup } from "./client-state-process-identity" +import { hasErrorCode, isPidAlive, type ProcessOwner } from "./client-state-process" + +export const CROSS_HOST_OWNER_DIRECTORY = "primary.owner.json" +const OWNER_FILENAME = "owner.json" +const PARTICIPANT_PREFIX = "participant." +const PARTICIPANT_SUFFIX = ".json" +const RECOVERY_PREFIX = "recovery." +const RECOVERY_SUFFIX = ".claim" +const RETIRED_PREFIX = "retired." +const ACQUIRE_ATTEMPTS = 10 + +export interface CrossHostLeaseDependencies { + pidAlive(pid: number): boolean + processStartIdentity: ProcessStartIdentityLookup + onParticipantPublished?(): void + onOwnerPrepared?(): void + onOwnerRetired?(): void + onGracefulOwnerChecked?(): void +} + +const defaultDependencies: CrossHostLeaseDependencies = { + pidAlive: isPidAlive, + processStartIdentity: getProcessStartIdentity, +} + +function validHome(value: string | undefined, platform: NodeJS.Platform): string | undefined { + if (!value) return undefined + if (platform !== "win32") return posix.isAbsolute(value) ? value : undefined + return /^(?:[A-Za-z]:[\\/]|\\\\)/.test(value) ? value : undefined +} + +export function resolveCrossHostElectionDirectory( + environment: NodeJS.ProcessEnv = process.env, + platform: NodeJS.Platform = process.platform, + fallbackHome = homedir(), +): string { + const pathApi = platform === "win32" ? win32 : posix + const configured = platform === "win32" + ? validHome(environment.USERPROFILE, platform) ?? validHome(environment.HOME, platform) + : validHome(environment.HOME, platform) + return pathApi.join(configured ?? fallbackHome, ".codenomad", "client-state", "election") +} + +export function resolveCrossHostStatePath( + environment: NodeJS.ProcessEnv = process.env, + platform: NodeJS.Platform = process.platform, + fallbackHome = homedir(), +): string { + const pathApi = platform === "win32" ? win32 : posix + const configured = platform === "win32" + ? validHome(environment.USERPROFILE, platform) ?? validHome(environment.HOME, platform) + : validHome(environment.HOME, platform) + return pathApi.join(configured ?? fallbackHome, ".codenomad", "client-state", "client-state.json") +} + +export function resolveLegacyTauriDataDirectory( + environment: NodeJS.ProcessEnv = process.env, + platform: NodeJS.Platform = process.platform, + fallbackHome = homedir(), +): string { + const pathApi = platform === "win32" ? win32 : posix + const home = platform === "win32" + ? validHome(environment.USERPROFILE, platform) ?? validHome(environment.HOME, platform) ?? fallbackHome + : validHome(environment.HOME, platform) ?? fallbackHome + const root = platform === "win32" + ? validHome(environment.APPDATA, platform) ?? pathApi.join(home, "AppData", "Roaming") + : platform === "darwin" + ? pathApi.join(home, "Library", "Application Support") + : validHome(environment.XDG_DATA_HOME, platform) ?? pathApi.join(home, ".local", "share") + return pathApi.join(root, "ai.neuralnomads.codenomad.client") +} + +export function createCrossHostOwner(): ProcessOwner | undefined { + const processStartIdentity = getProcessStartIdentity(process.pid) + return processStartIdentity ? { pid: process.pid, runToken: randomUUID(), processStartIdentity } : undefined +} + +function serializeOwner(owner: ProcessOwner): string { + return JSON.stringify({ pid: owner.pid, runToken: owner.runToken, processStartIdentity: owner.processStartIdentity }) +} + +function parseOwner(value: string): ProcessOwner | undefined { + try { + const owner = JSON.parse(value) as Partial + if (Number.isInteger(owner.pid) && Number(owner.pid) > 0 && Number(owner.pid) <= 0xffff_ffff && + typeof owner.runToken === "string" && /^[A-Za-z0-9_-]+$/.test(owner.runToken) && + typeof owner.processStartIdentity === "string" && owner.processStartIdentity) { + return { pid: Number(owner.pid), runToken: owner.runToken, processStartIdentity: owner.processStartIdentity } + } + } catch {} + return undefined +} + +function sameOwner(left: ProcessOwner, right: ProcessOwner): boolean { + return left.pid === right.pid && left.runToken === right.runToken && left.processStartIdentity === right.processStartIdentity +} + +function readIfExists(path: string): string | undefined { + try { return readFileSync(path, "utf8") } catch (error) { + if (hasErrorCode(error, "ENOENT")) return undefined + throw error + } +} + +function sync(descriptor: number): void { + try { fsyncSync(descriptor) } catch (error) { + if (!["EINVAL", "ENOTSUP", "ENOSYS"].some((code) => hasErrorCode(error, code))) throw error + } +} + +function publishFile(path: string, value: string): void { + const temporary = join(dirname(path), `.publish.${randomUUID()}.tmp`) + let descriptor: number | undefined + try { + descriptor = openSync(temporary, "wx", 0o600) + writeFileSync(descriptor, value, "utf8") + sync(descriptor) + closeSync(descriptor) + descriptor = undefined + linkSync(temporary, path) + } finally { + if (descriptor !== undefined) try { closeSync(descriptor) } catch {} + try { unlinkSync(temporary) } catch {} + } +} + +function participantPath(directory: string, owner: ProcessOwner): string { + return join(directory, `${PARTICIPANT_PREFIX}${owner.pid}.${owner.runToken}${PARTICIPANT_SUFFIX}`) +} + +function recoveryPath(directory: string, owner: ProcessOwner): string { + return join(directory, `${RECOVERY_PREFIX}${owner.pid}.${owner.runToken}${RECOVERY_SUFFIX}`) +} + +function publishParticipant(path: string, owner: ProcessOwner): void { + const value = serializeOwner(owner) + try { publishFile(path, value) } catch (error) { + if (!hasErrorCode(error, "EEXIST") || readIfExists(path) !== value) throw error + } +} + +function ownerPath(directory: string): string { + return join(directory, CROSS_HOST_OWNER_DIRECTORY, OWNER_FILENAME) +} + +function ownerIsStale(owner: ProcessOwner, dependencies: CrossHostLeaseDependencies): boolean | undefined { + if (!dependencies.pidAlive(owner.pid)) return true + const identity = dependencies.processStartIdentity(owner.pid) + return identity ? identity !== owner.processStartIdentity : undefined +} + +function removeParticipantIfOwned(path: string, owner: ProcessOwner): void { + const observed = readIfExists(path) + const current = observed === undefined ? undefined : parseOwner(observed) + if (!current || !sameOwner(current, owner)) return + try { unlinkSync(path) } catch (error) { + if (!hasErrorCode(error, "ENOENT")) throw error + } +} + +function retireOwnerIfOwned(directory: string, owner: ProcessOwner, dependencies: CrossHostLeaseDependencies): void { + const observed = readIfExists(ownerPath(directory)) + const current = parseOwner(observed ?? "") + if (!current || !sameOwner(current, owner)) return + dependencies.onGracefulOwnerChecked?.() + if (readIfExists(ownerPath(directory)) !== observed) return + const retired = join(directory, `${RETIRED_PREFIX}${owner.pid}.${owner.runToken}`) + try { renameSync(join(directory, CROSS_HOST_OWNER_DIRECTORY), retired) } catch (error) { + if (["ENOENT", "EEXIST", "ENOTEMPTY"].some((code) => hasErrorCode(error, code)) || existsSync(retired)) return + throw error + } + try { + dependencies.onOwnerRetired?.() + for (const name of readdirSync(directory)) { + if (!name.startsWith(PARTICIPANT_PREFIX) || !name.endsWith(PARTICIPANT_SUFFIX)) continue + const path = join(directory, name), observedParticipant = readIfExists(path) + if (observedParticipant === undefined) continue + const participant = parseOwner(observedParticipant) + if (participant) { + removeParticipantIfOwned(path, participant) + try { unlinkSync(recoveryPath(directory, participant)) } catch {} + } else if (readIfExists(path) === observedParticipant) { + try { unlinkSync(path) } catch (error) { + if (!hasErrorCode(error, "ENOENT")) throw error + } + } + } + } finally { + try { rmSync(retired, { recursive: true, force: true }) } catch {} + } +} + +function recoveryClaimants( + directory: string, + current: ProcessOwner, + observedOwner: string, + dependencies: CrossHostLeaseDependencies, +): ProcessOwner[] | undefined { + const claimants = [current] + for (const name of readdirSync(directory)) { + if (!name.startsWith(PARTICIPANT_PREFIX) || !name.endsWith(PARTICIPANT_SUFFIX)) continue + const path = join(directory, name) + const participant = parseOwner(readIfExists(path) ?? "") + if (!participant) return undefined + if (sameOwner(participant, current)) continue + const stale = ownerIsStale(participant, dependencies) + if (stale === true) { + removeParticipantIfOwned(path, participant) + try { unlinkSync(recoveryPath(directory, participant)) } catch {} + continue + } + const claimPath = recoveryPath(directory, participant) + let claim = readIfExists(claimPath) + for (let attempt = 0; claim !== observedOwner && attempt < 20; attempt += 1) { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5) + claim = readIfExists(claimPath) + } + if (claim !== observedOwner) return undefined + claimants.push(participant) + } + return claimants +} + +function retireOwner(directory: string, observed: string, owner: ProcessOwner, claimant: ProcessOwner, dependencies: CrossHostLeaseDependencies): boolean { + if (ownerIsStale(owner, dependencies) !== true) return false + const claimants = recoveryClaimants(directory, claimant, observed, dependencies) + if (!claimants) return false + claimants.sort((left, right) => serializeOwner(left) < serializeOwner(right) ? -1 : 1) + if (!sameOwner(claimants[0]!, claimant)) return false + if (readIfExists(ownerPath(directory)) !== observed) return false + const retired = join(directory, `${RETIRED_PREFIX}${owner.pid}.${owner.runToken}`) + try { + renameSync(join(directory, CROSS_HOST_OWNER_DIRECTORY), retired) + dependencies.onOwnerRetired?.() + return true + } catch (error) { + if (["ENOENT", "EEXIST", "ENOTEMPTY"].some((code) => hasErrorCode(error, code)) || existsSync(retired)) return false + throw error + } +} + +function publishOwner(directory: string, owner: ProcessOwner, dependencies: CrossHostLeaseDependencies): boolean { + const temporary = join(directory, `.owner.${randomUUID()}.tmp`) + try { + mkdirSync(temporary, { mode: 0o700 }) + const descriptor = openSync(join(temporary, OWNER_FILENAME), "wx", 0o600) + try { writeFileSync(descriptor, serializeOwner(owner), "utf8"); sync(descriptor) } finally { closeSync(descriptor) } + dependencies.onOwnerPrepared?.() + renameSync(temporary, join(directory, CROSS_HOST_OWNER_DIRECTORY)) + return true + } catch (error) { + if (hasErrorCode(error, "EEXIST") || hasErrorCode(error, "ENOTEMPTY") || existsSync(join(directory, CROSS_HOST_OWNER_DIRECTORY))) return false + throw error + } finally { + try { rmSync(temporary, { recursive: true, force: true }) } catch {} + } +} + +export function crossHostParticipants(directory: string): ProcessOwner[] { + try { + return readdirSync(directory) + .filter((name) => name.startsWith(PARTICIPANT_PREFIX) && name.endsWith(PARTICIPANT_SUFFIX)) + .map((name) => parseOwner(readIfExists(join(directory, name)) ?? "")) + .filter((owner): owner is ProcessOwner => Boolean(owner)) + } catch (error) { + if (hasErrorCode(error, "ENOENT")) return [] + throw error + } +} + +export class CrossHostRegistration { + private released = false + + private constructor( + private readonly directory: string, + readonly owner: ProcessOwner, + private readonly participant: string, + private readonly recoveryClaim: string | undefined, + private primary: boolean, + private readonly dependencies: CrossHostLeaseDependencies, + ) {} + + get path(): string { return this.directory } + + static register( + directory: string, + owner: ProcessOwner, + primaryCandidate: boolean | (() => boolean), + dependencies: CrossHostLeaseDependencies = defaultDependencies, + ): CrossHostRegistration | undefined { + if (!owner.processStartIdentity || !/^[A-Za-z0-9_-]+$/.test(owner.runToken)) return undefined + mkdirSync(directory, { recursive: true, mode: 0o700 }) + const participant = participantPath(directory, owner) + publishParticipant(participant, owner) + dependencies.onParticipantPublished?.() + let primary = false + let recoveryClaim: string | undefined + try { + if (typeof primaryCandidate === "function" ? primaryCandidate() : primaryCandidate) { + for (let attempt = 0; attempt < ACQUIRE_ATTEMPTS; attempt += 1) { + if (publishOwner(directory, owner, dependencies)) { primary = true; break } + const observed = readIfExists(ownerPath(directory)) + if (observed === undefined) continue + const existing = parseOwner(observed) + if (!existing) break + if (sameOwner(existing, owner)) { primary = true; break } + if (ownerIsStale(existing, dependencies) === true) { + recoveryClaim ??= recoveryPath(directory, owner) + try { publishFile(recoveryClaim, observed) } catch (error) { + if (!hasErrorCode(error, "EEXIST") || readIfExists(recoveryClaim) !== observed) throw error + } + } + if (!retireOwner(directory, observed, existing, owner, dependencies)) break + } + } + return new CrossHostRegistration(directory, owner, participant, recoveryClaim, primary, dependencies) + } catch (error) { + removeParticipantIfOwned(participant, owner) + if (recoveryClaim) try { unlinkSync(recoveryClaim) } catch {} + throw error + } + } + + get isPrimary(): boolean { + if (this.released || !this.primary) return false + const current = parseOwner(readIfExists(ownerPath(this.directory)) ?? "") + return Boolean(current && sameOwner(current, this.owner)) + } + + release(): boolean { + if (this.released) return false + retireOwnerIfOwned(this.directory, this.owner, this.dependencies) + removeParticipantIfOwned(this.participant, this.owner) + if (this.recoveryClaim) try { unlinkSync(this.recoveryClaim) } catch {} + this.primary = false + this.released = true + return true + } +} diff --git a/packages/electron-app/electron/main/client-state-election-child.ts b/packages/electron-app/electron/main/client-state-election-child.ts index 7be1585a..4b4db1bf 100644 --- a/packages/electron-app/electron/main/client-state-election-child.ts +++ b/packages/electron-app/electron/main/client-state-election-child.ts @@ -54,9 +54,9 @@ const election = electClientStateProcess( }, ) -process.stdout.write(`${JSON.stringify({ isPrimary: election.isPrimary, owner, warnings })}\n`) +process.stdout.write(`${JSON.stringify({ isPrimary: election, owner, warnings })}\n`) process.stdin.resume() process.stdin.once("end", () => { - removeRunningMarkerIfOwned(election.runningMarkerPath, owner) + removeRunningMarkerIfOwned(join(directory, `client-state.running.${owner.pid}.${owner.runToken}.json`), owner) removeProcessOwnerLockIfOwned(primaryLockPath, owner) }) diff --git a/packages/electron-app/electron/main/client-state-ipc-handlers.test.ts b/packages/electron-app/electron/main/client-state-ipc-handlers.test.ts deleted file mode 100644 index 0762d805..00000000 --- a/packages/electron-app/electron/main/client-state-ipc-handlers.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -import assert from "node:assert/strict" -import { mkdtempSync, rmSync } from "node:fs" -import { tmpdir } from "node:os" -import { join } from "node:path" -import test from "node:test" -import { ClientStateManager } from "./client-state" -import { - createClientStateIPCHandlers, - shouldResetRendererAccessTokenForNavigation, -} from "./client-state-ipc-handlers" - -test("only trusted full main-frame navigation resets renderer access", () => { - const trusted = (url: string) => new URL(url).origin === "http://127.0.0.1:3000" - - assert.equal( - shouldResetRendererAccessTokenForNavigation("http://127.0.0.1:3000/reload", false, true, trusted), - true, - ) - assert.equal( - shouldResetRendererAccessTokenForNavigation("http://127.0.0.1:3000/frame", false, false, trusted), - false, - ) - assert.equal( - shouldResetRendererAccessTokenForNavigation("http://127.0.0.1:3000/#route", true, true, trusted), - false, - ) - assert.equal( - shouldResetRendererAccessTokenForNavigation("https://untrusted.example/reload", false, true, trusted), - false, - ) -}) - -test("client-state handlers require the claimed nonempty renderer token", async (testContext) => { - const directory = mkdtempSync(join(tmpdir(), "codenomad-client-state-ipc-")) - const manager = new ClientStateManager(directory) - const handlers = createClientStateIPCHandlers(manager) - testContext.after(async () => { - await manager.drainAndReleasePrimary().catch(() => {}) - rmSync(directory, { recursive: true, force: true }) - }) - - assert.throws(() => handlers.claimAccess(""), /nonempty string/) - assert.throws(() => handlers.load("not-claimed"), /has not been claimed/) - assert.equal(handlers.claimAccess("trusted-renderer-token"), true) - assert.equal(handlers.claimAccess("trusted-renderer-token"), true) - assert.throws(() => handlers.claimAccess("child-frame-token"), /does not match/) - - for (const invoke of [ - () => handlers.load("child-frame-token"), - () => handlers.save("child-frame-token", { denied: true }), - () => handlers.setRestoreEnabled("child-frame-token", false), - () => handlers.clear("child-frame-token"), - ]) { - assert.throws(invoke, /has not been claimed/) - } - - assert.equal(await handlers.save("trusted-renderer-token", { shutdownFlush: true }), true) - assert.deepEqual(handlers.load("trusted-renderer-token").snapshot, { shutdownFlush: true }) -}) - -test("trusted renderer navigation reset invalidates the old token before a new claim", async (testContext) => { - const directory = mkdtempSync(join(tmpdir(), "codenomad-client-state-token-reset-")) - const manager = new ClientStateManager(directory) - const handlers = createClientStateIPCHandlers(manager) - testContext.after(async () => { - await manager.drainAndReleasePrimary().catch(() => {}) - rmSync(directory, { recursive: true, force: true }) - }) - - handlers.claimAccess("first-document") - manager.resetRendererAccessToken() - - assert.throws(() => handlers.load("first-document"), /has not been claimed/) - assert.equal(handlers.claimAccess("reloaded-document"), true) - assert.equal(handlers.load("reloaded-document").isPrimary, true) -}) diff --git a/packages/electron-app/electron/main/client-state-ipc-handlers.ts b/packages/electron-app/electron/main/client-state-ipc-handlers.ts deleted file mode 100644 index 3cda55b8..00000000 --- a/packages/electron-app/electron/main/client-state-ipc-handlers.ts +++ /dev/null @@ -1,52 +0,0 @@ -import type { ClientStateManager } from "./client-state" - -export function shouldResetRendererAccessTokenForNavigation( - url: string, - isInPlace: boolean, - isMainFrame: boolean, - isTrustedOrigin: (url: string) => boolean, -): boolean { - return isMainFrame && !isInPlace && isTrustedOrigin(url) -} - -export function createRendererAccessNavigationCommitHandler( - clientState: Pick, - isTrustedOrigin: (url: string) => boolean, -) { - return (url: string, isInPlace: boolean, isMainFrame: boolean): void => { - if (shouldResetRendererAccessTokenForNavigation(url, isInPlace, isMainFrame, isTrustedOrigin)) { - clientState.resetRendererAccessToken() - } - } -} - -export function createClientStateIPCHandlers(clientState: ClientStateManager) { - const requireAccess = (token: unknown) => clientState.assertRendererAccessToken(token) - - return { - claimAccess(token: unknown) { - return clientState.claimClientStateAccess(token) - }, - load(token: unknown) { - requireAccess(token) - return clientState.loadClientState() - }, - save(token: unknown, snapshot: unknown) { - requireAccess(token) - return clientState.saveClientState(snapshot) - }, - setRestoreEnabled(token: unknown, enabled: unknown) { - requireAccess(token) - if (typeof enabled !== "boolean") { - throw new TypeError("Restore enabled must be a boolean") - } - return clientState.setRestoreEnabled(enabled) - }, - clear(token: unknown) { - requireAccess(token) - return clientState.clearClientState() - }, - } -} - -export type ClientStateIPCHandlers = ReturnType diff --git a/packages/electron-app/electron/main/client-state-ipc.test.ts b/packages/electron-app/electron/main/client-state-ipc.test.ts new file mode 100644 index 00000000..15f1698e --- /dev/null +++ b/packages/electron-app/electron/main/client-state-ipc.test.ts @@ -0,0 +1,64 @@ +import assert from "node:assert/strict" +import test from "node:test" +import type { IpcMainInvokeEvent } from "electron" +import { setupClientStateIPC } from "./client-state-ipc" + +function harness() { + const handlers = new Map unknown>() + const listeners = new Map void>() + const frame = { url: "http://127.0.0.1:3000/app" } + const webContents = { + mainFrame: frame, + getURL: () => "http://127.0.0.1:3000/app", + on: (event: string, listener: (...args: unknown[]) => void) => listeners.set(event, listener), + } + const window = { isDestroyed: () => false, webContents } + let current: typeof window | null = window + const calls: string[] = [] + const state = { + claimClientStateAccess: (token: unknown) => { calls.push(`claim:${token}`); return true }, + assertRendererAccessToken: (token: unknown) => calls.push(`assert:${token}`), + loadClientState: () => ({ isPrimary: true }), + saveClientState: () => true, + setRestoreEnabled: () => true, + clearClientState: () => true, + resetRendererAccessToken: () => calls.push("reset"), + } + const bind = setupClientStateIPC( + { handle: (channel, listener) => handlers.set(channel, listener) }, + state as never, + () => current as never, + () => ["http://127.0.0.1:3000"], + ) + bind(window as never) + return { calls, frame, handlers, listeners, setCurrent: (value: typeof window | null) => { current = value }, webContents, window } +} + +test("IPC channels enforce the current main sender, frame, origin, and token", async () => { + const h = harness() + assert.deepEqual([...h.handlers.keys()], [ + "client-state:claimAccess", "client-state:load", "client-state:save", + "client-state:setRestoreEnabled", "client-state:clear", + ]) + const event = { sender: h.webContents, senderFrame: h.frame } + await h.handlers.get("client-state:claimAccess")!(event as never, "token") + await h.handlers.get("client-state:load")!(event as never, "token") + assert.deepEqual(h.calls, ["claim:token", "assert:token"]) + + for (const invalid of [ + { sender: {}, senderFrame: h.frame }, + { sender: h.webContents, senderFrame: { url: h.frame.url } }, + { sender: h.webContents, senderFrame: { ...h.frame, url: "https://example.com" } }, + ]) await assert.rejects(h.handlers.get("client-state:load")!(invalid as never, "token") as Promise) +}) + +test("only the registered current window can reset renderer authority", () => { + const h = harness() + h.listeners.get("did-navigate")!({}, "http://127.0.0.1:3000/next") + h.listeners.get("render-process-gone")!() + assert.deepEqual(h.calls, ["reset", "reset"]) + h.setCurrent(null) + h.listeners.get("did-navigate")!({}, "http://127.0.0.1:3000/late") + h.listeners.get("destroyed")!() + assert.deepEqual(h.calls, ["reset", "reset"]) +}) diff --git a/packages/electron-app/electron/main/client-state-ipc.ts b/packages/electron-app/electron/main/client-state-ipc.ts index 34231f9e..51b0b0c5 100644 --- a/packages/electron-app/electron/main/client-state-ipc.ts +++ b/packages/electron-app/electron/main/client-state-ipc.ts @@ -1,13 +1,15 @@ -import { ipcMain, type BrowserWindow, type IpcMainInvokeEvent } from "electron" +import type { BrowserWindow, IpcMainInvokeEvent } from "electron" import type { ClientStateManager } from "./client-state" -import { - createClientStateIPCHandlers, - createRendererAccessNavigationCommitHandler, -} from "./client-state-ipc-handlers" -import { isAllowedRendererOrigin } from "./permissions" +import { shouldResetRendererAccessTokenForNavigation } from "./client-state-navigation" +import { isAllowedRendererOrigin } from "./renderer-origin" -function validateSender(event: IpcMainInvokeEvent, mainWindow: BrowserWindow, getAllowedOrigins: () => string[]) { +interface IPCRegistrar { + handle(channel: string, listener: (event: IpcMainInvokeEvent, ...args: unknown[]) => unknown): void +} + +function validateSender(event: IpcMainInvokeEvent, mainWindow: BrowserWindow | null, allowedOrigins: string[]) { if ( + !mainWindow || mainWindow.isDestroyed() || event.sender !== mainWindow.webContents || event.senderFrame !== mainWindow.webContents.mainFrame @@ -15,7 +17,6 @@ function validateSender(event: IpcMainInvokeEvent, mainWindow: BrowserWindow, ge throw new Error("Client state IPC is only available to the local main window") } - const allowedOrigins = getAllowedOrigins() const currentUrl = mainWindow.webContents.getURL() if ( !isAllowedRendererOrigin(currentUrl, allowedOrigins) || @@ -27,42 +28,51 @@ function validateSender(event: IpcMainInvokeEvent, mainWindow: BrowserWindow, ge } export function setupClientStateIPC( - mainWindow: BrowserWindow, + ipcMain: IPCRegistrar, clientState: ClientStateManager, - getAllowedOrigins: () => string[], + getMainWindow: () => BrowserWindow | null, + getAllowedOrigins: (window: BrowserWindow | null) => string[], ) { - const handlers = createClientStateIPCHandlers(clientState) - const handleNavigationCommit = createRendererAccessNavigationCommitHandler( - clientState, - (url) => isAllowedRendererOrigin(url, getAllowedOrigins()), - ) + const validate = (event: IpcMainInvokeEvent) => { + const window = getMainWindow() + validateSender(event, window, getAllowedOrigins(window)) + } + const handle = ( + channel: string, + operation: (argument: unknown, token: unknown) => unknown, + ) => ipcMain.handle(channel, async (event, token: unknown, argument: unknown) => { + validate(event) + clientState.assertRendererAccessToken(token) + return operation(argument, token) + }) ipcMain.handle("client-state:claimAccess", async (event, token: unknown) => { - validateSender(event, mainWindow, getAllowedOrigins) - return handlers.claimAccess(token) + validate(event) + return clientState.claimClientStateAccess(token) }) + handle("client-state:load", () => clientState.loadClientState()) + handle("client-state:save", (snapshot, token) => clientState.saveClientState(snapshot, token)) + handle("client-state:setRestoreEnabled", (enabled, token) => { + if (typeof enabled !== "boolean") throw new Error("Restore enabled must be a boolean") + return clientState.setRestoreEnabled(enabled, token) + }) + handle("client-state:clear", (_argument, token) => clientState.clearClientState(token)) - ipcMain.handle("client-state:load", async (event, token: unknown) => { - validateSender(event, mainWindow, getAllowedOrigins) - return handlers.load(token) - }) - - ipcMain.handle("client-state:save", async (event, token: unknown, snapshot: unknown) => { - validateSender(event, mainWindow, getAllowedOrigins) - return handlers.save(token, snapshot) - }) - - ipcMain.handle("client-state:setRestoreEnabled", async (event, token: unknown, enabled: unknown) => { - validateSender(event, mainWindow, getAllowedOrigins) - return handlers.setRestoreEnabled(token, enabled) - }) - - ipcMain.handle("client-state:clear", async (event, token: unknown) => { - validateSender(event, mainWindow, getAllowedOrigins) - return handlers.clear(token) - }) - - mainWindow.webContents.on("did-navigate", (_event, url) => { - handleNavigationCommit(url, false, true) - }) + return (window: BrowserWindow): void => { + window.webContents.on("did-navigate", (_event, url) => { + if (getMainWindow() === window && shouldResetRendererAccessTokenForNavigation( + url, + false, + true, + (target) => isAllowedRendererOrigin(target, getAllowedOrigins(window)), + )) { + clientState.resetRendererAccessToken() + } + }) + const resetDestroyedRenderer = () => { + if (getMainWindow() === window) clientState.resetRendererAccessToken() + } + window.webContents.on("render-process-gone", resetDestroyedRenderer) + window.webContents.on("destroyed", resetDestroyedRenderer) + } } diff --git a/packages/electron-app/electron/main/client-state-lifecycle.test.ts b/packages/electron-app/electron/main/client-state-lifecycle.test.ts index 8adf1c37..9f65b211 100644 --- a/packages/electron-app/electron/main/client-state-lifecycle.test.ts +++ b/packages/electron-app/electron/main/client-state-lifecycle.test.ts @@ -1,4 +1,5 @@ import assert from "node:assert/strict" +import { setTimeout as delay } from "node:timers/promises" import test from "node:test" import type { App, BrowserWindow } from "electron" import { ClientStateLifecycle } from "./client-state-lifecycle" @@ -6,88 +7,153 @@ import type { ClientStateManager } from "./client-state" import type { CliProcessManager } from "./process-manager" import type { WindowStateTracker } from "./window-state" -function createHarness(options: { rendererFlush?: () => Promise; timeoutMs?: number } = {}) { - const handlers = new Map void>() - let nativeFlushes = 0 - let rendererFlushes = 0 - let primaryReleases = 0 - let cliStops = 0 +const tick = () => new Promise((resolve) => setImmediate(resolve)) +function harness(options: { + flush?: () => Promise + stop?: () => Promise + nativeFlush?: () => Promise + otherWindow?: boolean + sessionEndCleanupTimeoutMs?: number + sessionEndReleaseTimeoutMs?: number + release?: () => Promise +} = {}) { + const windows = new Map void>() + const appEvents = new Map void>() + const calls: string[] = [] let exits = 0 const window = { - on: (event: string, handler: () => void) => { - handlers.set(event, handler) - }, + on: (name: string, handler: (event?: { preventDefault(): void }) => void) => windows.set(name, handler), isDestroyed: () => false, - close: () => {}, - webContents: { - isDestroyed: () => false, - getURL: () => "http://127.0.0.1:43123/workspace", - executeJavaScript: () => { - rendererFlushes += 1 - return options.rendererFlush?.() ?? Promise.resolve() - }, - }, + close: () => { calls.push("close"); windows.get("close")?.({ preventDefault: () => assert.fail("approved close prevented") }) }, + hide: () => { calls.push("hide") }, + show: () => { calls.push("show") }, + webContents: { isDestroyed: () => false, getURL: () => "http://127.0.0.1:43123/workspace", executeJavaScript: () => { calls.push("renderer"); return options.flush?.() ?? Promise.resolve() } }, } as unknown as BrowserWindow - const app = { on: () => {}, quit: () => {}, exit: () => { exits += 1 } } as unknown as App - const clientStateManager = { - isPrimary: true, - drainAndReleasePrimary: async () => { primaryReleases += 1 }, - } as ClientStateManager - const cliManager = { stop: async () => { cliStops += 1 } } as unknown as CliProcessManager - const lifecycle = new ClientStateLifecycle({ - app, - clientStateManager, - cliManager, - getMainWindow: () => window, - getAllWindows: () => [window], - getAllowedRendererOrigins: () => ["http://127.0.0.1:43123"], - isTrustedRendererOrigin: () => true, - windowsSessionEndFlushTimeoutMs: options.timeoutMs, - rendererFlushTimeoutMs: options.timeoutMs === undefined ? undefined : options.timeoutMs * 2, - isWindows: true, - }) - const tracker = { flush: async () => { nativeFlushes += 1 } } as unknown as WindowStateTracker - lifecycle.attachMainWindow(window, tracker) - return { - handlers, - lifecycle, - getNativeFlushes: () => nativeFlushes, - getRendererFlushes: () => rendererFlushes, - getPrimaryReleases: () => primaryReleases, - getCliStops: () => cliStops, - getExits: () => exits, - } + const other = { isDestroyed: () => false, hide: () => { calls.push("hide-other") } } as unknown as BrowserWindow + const app = { on: (name: string, handler: never) => appEvents.set(name, handler), quit: () => calls.push("quit"), exit: () => { exits++ } } as unknown as App + const manager = { isPrimary: true, flush: async () => {}, drainAndReleasePrimary: async () => { calls.push("release"); await options.release?.() } } as ClientStateManager + const cli = { shutdown: async () => { calls.push("stop"); await options.stop?.() } } as unknown as CliProcessManager + const lifecycle = new ClientStateLifecycle({ app, clientStateManager: manager, cliManager: cli, getMainWindow: () => window, getAllWindows: () => options.otherWindow ? [window, other] : [window], getAllowedRendererOrigins: () => ["http://127.0.0.1:43123"], isTrustedRendererOrigin: () => true, isWindows: true, sessionEndCleanupTimeoutMs: options.sessionEndCleanupTimeoutMs, sessionEndReleaseTimeoutMs: options.sessionEndReleaseTimeoutMs }) + lifecycle.attachMainWindow(window, { flush: async () => { calls.push("native"); await options.nativeFlush?.() } } as unknown as WindowStateTracker) + lifecycle.registerAppEvents() + const close = () => { let prevented = false; windows.get("close")?.({ preventDefault: () => { prevented = true } }); return prevented } + return { appEvents, calls, close, exits: () => exits, lifecycle, window, windows } } -test("Windows session termination flushes renderer and native client state once", async () => { - const harness = createHarness() +test("close flushes renderer/native once before approval, even when repeated or renderer fails", async (t) => { + await t.test("ordinary", async () => { + const h = harness({ otherWindow: true }) + assert.equal(h.close(), true) + await tick() + assert.deepEqual(h.calls, ["renderer", "native", "close"]) + }) + await t.test("coalesced", async () => { + let release!: () => void + const h = harness({ otherWindow: true, flush: () => new Promise((resolve) => { release = resolve }) }) + assert.equal(h.close(), true); assert.equal(h.close(), true) + assert.deepEqual(h.calls, ["renderer"]) + release(); await tick() + assert.deepEqual(h.calls, ["renderer", "native", "close"]) + }) + await t.test("renderer failure", async () => { + const h = harness({ otherWindow: true, flush: async () => { throw new Error("failed") } }) + assert.equal(h.close(), true); await tick() + assert.deepEqual(h.calls, ["renderer", "native", "close"]) + }) +}) +test("late old-window detach preserves replacement tracker during shutdown", async () => { + const h = harness() + const replacement = { on: () => {} } as unknown as BrowserWindow + h.lifecycle.attachMainWindow(replacement, { flush: async () => { h.calls.push("replacement-native") } } as unknown as WindowStateTracker) + h.lifecycle.detachMainWindow(h.window) + h.appEvents.get("before-quit")?.({ preventDefault: () => {} }) + await (h.lifecycle as any).shutdown + assert.deepEqual(h.calls, ["hide", "renderer", "replacement-native", "stop", "release"]) +}) + +test("Windows session end vetoes termination until cleanup exits explicitly", async () => { + const h = harness() let prevented = false - harness.handlers.get("query-session-end")?.({ preventDefault: () => { prevented = true } }) - harness.handlers.get("session-end")?.() - await (harness.lifecycle as any).windowsSessionEndFlush - await new Promise((resolve) => setImmediate(resolve)) - + h.windows.get("query-session-end")?.({ preventDefault: () => { prevented = true } }) + h.windows.get("session-end")?.() + await (h.lifecycle as any).sessionEnd; await tick() assert.equal(prevented, true) - assert.equal(harness.getRendererFlushes(), 1) - assert.equal(harness.getNativeFlushes(), 1) - assert.equal(harness.getPrimaryReleases(), 1) - assert.equal(harness.getCliStops(), 1) - assert.equal(harness.getExits(), 1) + assert.deepEqual(h.calls, ["renderer", "native", "stop", "release"]) + assert.equal(h.exits(), 1) }) -test("Windows session termination flush is globally bounded", async () => { - const harness = createHarness({ rendererFlush: () => new Promise(() => {}), timeoutMs: 20 }) - const startedAt = Date.now() - - harness.handlers.get("query-session-end")?.({ preventDefault: () => {} }) - await (harness.lifecycle as any).windowsSessionEndFlush - await new Promise((resolve) => setImmediate(resolve)) - - assert.ok(Date.now() - startedAt < 500) - assert.equal(harness.getRendererFlushes(), 1) - assert.equal(harness.getNativeFlushes(), 0) - assert.equal(harness.getPrimaryReleases(), 0) - assert.equal(harness.getCliStops(), 0) - assert.equal(harness.getExits(), 1) +test("session end force-exits after the bounded window when an ordinary shutdown is hung", async () => { + const h = harness({ flush: () => new Promise(() => {}), sessionEndCleanupTimeoutMs: 10 }) + let prevented = false + h.appEvents.get("before-quit")?.({ preventDefault: () => {} }) + h.windows.get("query-session-end")?.({ preventDefault: () => { prevented = true } }) + await delay(25) + assert.equal(prevented, true) + assert.deepEqual(h.calls, ["hide", "renderer", "release"]) + assert.equal(h.exits(), 1) +}) + +test("ordinary quit hides promptly and waits for CLI stop confirmation", async () => { + let confirmStop!: () => void + const h = harness({ stop: () => new Promise((resolve) => { confirmStop = resolve }) }) + h.appEvents.get("before-quit")?.({ preventDefault: () => {} }) + await tick() + assert.deepEqual(h.calls, ["hide", "renderer", "native", "stop"]) + assert.equal(h.exits(), 0) + confirmStop() + await (h.lifecycle as any).shutdown; await tick() + assert.deepEqual(h.calls, ["hide", "renderer", "native", "stop", "release"]) + assert.equal(h.exits(), 1) +}) + +test("ordinary quit does not exit when CLI cleanup is unconfirmed", async () => { + const h = harness({ stop: async () => { throw new Error("unconfirmed") } }) + h.appEvents.get("before-quit")?.({ preventDefault: () => {} }) + await assert.rejects((h.lifecycle as any).shutdown, /unconfirmed/) + await tick() + assert.equal(h.exits(), 0) + assert.deepEqual(h.calls, ["hide", "renderer", "native", "stop", "show"]) +}) + +test("Windows session-end rejection fails open at the bounded deadline", async () => { + const h = harness({ stop: async () => { throw new Error("unconfirmed") }, sessionEndCleanupTimeoutMs: 10 }) + h.appEvents.get("before-quit")?.({ preventDefault: () => {} }) + h.windows.get("query-session-end")?.({ preventDefault: () => {} }) + await delay(25) + assert.equal(h.exits(), 1) + assert.deepEqual(h.calls, ["hide", "renderer", "native", "stop", "release"]) +}) + +test("Windows fail-open bounds a hanging primary release before app.exit", async () => { + const h = harness({ + flush: () => new Promise(() => {}), + release: () => new Promise(() => {}), + sessionEndCleanupTimeoutMs: 30, + sessionEndReleaseTimeoutMs: 10, + }) + h.windows.get("query-session-end")?.({ preventDefault: () => {} }) + + await delay(25) + assert.deepEqual(h.calls, ["renderer", "release"]) + assert.equal(h.exits(), 0) + await delay(15) + assert.equal(h.exits(), 1) +}) + +test("CLI termination waits for the native snapshot flush", async () => { + let release!: () => void + const h = harness({ nativeFlush: () => new Promise((resolve) => { release = resolve }) }) + h.appEvents.get("before-quit")?.({ preventDefault: () => {} }) + await tick() + assert.deepEqual(h.calls, ["hide", "renderer", "native"]) + assert.equal(h.exits(), 0) + release(); await (h.lifecycle as any).shutdown + assert.deepEqual(h.calls, ["hide", "renderer", "native", "stop", "release"]) +}) + +test("closing the final window hides it before requesting quit", () => { + const h = harness() + assert.equal(h.close(), true) + assert.deepEqual(h.calls, ["hide", "quit"]) }) diff --git a/packages/electron-app/electron/main/client-state-lifecycle.ts b/packages/electron-app/electron/main/client-state-lifecycle.ts index a52d29c8..c0e7405c 100644 --- a/packages/electron-app/electron/main/client-state-lifecycle.ts +++ b/packages/electron-app/electron/main/client-state-lifecycle.ts @@ -1,12 +1,9 @@ -import type { App, BrowserWindow, Event } from "electron" +import type { App, BrowserWindow } from "electron" import type { ClientStateManager } from "./client-state" -import { MainWindowCloseController } from "./main-window-close" import type { CliProcessManager } from "./process-manager" import { flushRendererClientStateBeforeShutdown } from "./renderer-client-state-flush" import type { WindowStateTracker } from "./window-state" -const WINDOWS_SESSION_END_FLUSH_TIMEOUT_MS = 1_500 - interface ClientStateLifecycleDependencies { app: App clientStateManager: ClientStateManager @@ -15,63 +12,62 @@ interface ClientStateLifecycleDependencies { getAllWindows(): BrowserWindow[] getAllowedRendererOrigins(window?: BrowserWindow | null): string[] isTrustedRendererOrigin(url: string, allowedOrigins: string[]): boolean - windowsSessionEndFlushTimeoutMs?: number rendererFlushTimeoutMs?: number + sessionEndCleanupTimeoutMs?: number + sessionEndReleaseTimeoutMs?: number isWindows?: boolean } export class ClientStateLifecycle { - private shutdownStarted = false - private shutdownExitAllowed = false + private shutdown: Promise | null = null + private sessionEnd: Promise | null = null + private exitAllowed = false private trackedMainWindow: BrowserWindow | null = null private windowStateTracker: WindowStateTracker | null = null - private windowsSessionEndFlush: Promise | null = null - private windowsSessionEndExitAllowed = false + private windowsHiddenForShutdown = false + private primaryRelease: Promise | null = null constructor(private readonly dependencies: ClientStateLifecycleDependencies) {} - attachMainWindow(window: BrowserWindow, windowStateTracker: WindowStateTracker | null): void { + attachMainWindow(window: BrowserWindow, tracker: WindowStateTracker | null): void { this.trackedMainWindow = window - this.windowStateTracker = windowStateTracker - - const closeController = new MainWindowCloseController({ - flushRenderer: () => this.flushRenderer(window, "main-window close"), - flushNative: () => this.flushNative(), - closeWindow: () => window.close(), - reportError: (stage, error) => { - console.warn(`[client-state] ${stage} main-window close flush failed; continuing close`, error) - }, - }) + this.windowStateTracker = tracker + let closeApproved = false + let closeInProgress = false window.on("close", (event) => { - if (this.shutdownExitAllowed) return - if (this.shutdownStarted) { - event.preventDefault() - return - } + if (this.exitAllowed || closeApproved) return + event.preventDefault() + if (this.shutdown) return const hasOtherWindow = this.dependencies .getAllWindows() .some((candidate) => candidate !== window && !candidate.isDestroyed()) - if (hasOtherWindow) { - const decision = closeController.handleClose() - if (!decision.allow) { - event.preventDefault() - } - return + if (!hasOtherWindow) { + window.hide() + this.dependencies.app.quit() + } else if (!closeInProgress) { + closeInProgress = true + void this.flushForClose(window).finally(() => { + closeApproved = true + try { + window.close() + } catch (error) { + closeApproved = false + closeInProgress = false + console.warn("[client-state] main-window close failed", error) + } + }) } - - event.preventDefault() - this.dependencies.app.quit() }) if (this.dependencies.isWindows ?? process.platform === "win32") { - window.on("query-session-end", (event: Event) => { - if (this.windowsSessionEndExitAllowed) return + window.on("query-session-end", (event) => { + if (this.exitAllowed) return event.preventDefault() - this.startWindowsSessionEnd(window) + this.promoteToSessionEnd(window) }) - window.on("session-end", () => this.startWindowsSessionEnd(window)) + window.on("session-end", () => this.promoteToSessionEnd(window)) } } @@ -83,102 +79,114 @@ export class ClientStateLifecycle { registerAppEvents(): void { const { app } = this.dependencies - app.on("before-quit", (event) => this.handleBeforeQuit(event)) - app.on("window-all-closed", () => { - // Closing the final remaining window should quit the app on all platforms. - app.quit() + app.on("before-quit", (event) => { + if (this.exitAllowed) return + event.preventDefault() + this.hideWindows() + void this.startShutdown(this.dependencies.getMainWindow()).then(() => this.exit(), (error) => { + if (!this.sessionEnd) this.restoreWindowAfterRejectedShutdown(this.dependencies.getMainWindow()) + console.warn("[client-state] desktop shutdown remains pending because cleanup was not contained", error) + }) }) + app.on("window-all-closed", () => app.quit()) } - private async handleBeforeQuit(event: { preventDefault(): void }): Promise { - event.preventDefault() - if (this.shutdownStarted) return - this.shutdownStarted = true - - try { - await this.flushRenderer(this.dependencies.getMainWindow(), "shutdown") - } catch (error) { - console.warn("[client-state] renderer shutdown flush failed; continuing shutdown", error) - } - - try { - await this.flushNative() - } catch (error) { - console.warn("[client-state] failed to flush state during shutdown", error) - } - - try { - await this.dependencies.clientStateManager.drainAndReleasePrimary() - } catch (error) { - console.warn("[client-state] failed to drain state before releasing primary ownership", error) - } - - await this.dependencies.cliManager.stop().catch(() => {}) - this.shutdownExitAllowed = true - this.dependencies.app.exit(0) + private async flushForClose(window: BrowserWindow): Promise { + await this.runStage("renderer main-window close flush", () => this.flushRenderer(window)) + await this.runStage("native main-window close flush", () => this.flushNative()) } - private async flushRenderer(window: BrowserWindow | null, context: "main-window close" | "shutdown"): Promise { + private startShutdown(window: BrowserWindow | null): Promise { + if (this.shutdown) return this.shutdown + const stages = (async () => { + await this.runStage("renderer shutdown flush", () => this.flushRenderer(window)) + await this.runStage("native shutdown flush", () => this.flushNative()) + await this.dependencies.cliManager.shutdown() + await this.releasePrimary() + })() + this.shutdown = stages.catch((error) => { + this.shutdown = null + throw error + }) + return this.shutdown + } + + private hideWindows(): void { + for (const window of this.dependencies.getAllWindows()) { + if (!window.isDestroyed()) { + window.hide() + this.windowsHiddenForShutdown = true + } + } + } + + private restoreWindowAfterRejectedShutdown(preferred: BrowserWindow | null): void { + if (!this.windowsHiddenForShutdown) return + this.windowsHiddenForShutdown = false + const window = preferred && !preferred.isDestroyed() + ? preferred + : this.dependencies.getAllWindows().find((candidate) => !candidate.isDestroyed()) + if (window) window.show() + } + + private promoteToSessionEnd(window: BrowserWindow): void { + if (this.exitAllowed || this.sessionEnd) return + const cleanup = this.startShutdown(window) + this.sessionEnd = new Promise((resolve) => { + const timeoutMs = this.dependencies.sessionEndCleanupTimeoutMs ?? 5_000 + const releaseTimeoutMs = Math.min(timeoutMs, this.dependencies.sessionEndReleaseTimeoutMs ?? 250) + const releaseTimer = setTimeout(() => { + void this.releasePrimary() + }, Math.max(0, timeoutMs - releaseTimeoutMs)) + const exitTimer = setTimeout(() => { + console.warn(`[client-state] OS session-end cleanup exceeded ${timeoutMs}ms; exiting without containment`) + resolve() + }, timeoutMs) + void cleanup.then(() => { + clearTimeout(releaseTimer) + clearTimeout(exitTimer) + resolve() + }, (error) => { + console.warn("[client-state] OS session-end cleanup was not contained; waiting for forced exit", error) + }) + }).then(() => this.exit()) + } + + private releasePrimary(): Promise { + if (!this.primaryRelease) { + this.primaryRelease = this.runStage("primary release", () => this.dependencies.clientStateManager.drainAndReleasePrimary()) + } + return this.primaryRelease + } + + private async flushRenderer(window: BrowserWindow | null): Promise { const result = await flushRendererClientStateBeforeShutdown( window, this.dependencies.clientStateManager.isPrimary, - (url) => - this.dependencies.isTrustedRendererOrigin(url, this.dependencies.getAllowedRendererOrigins(window)), + (url) => this.dependencies.isTrustedRendererOrigin(url, this.dependencies.getAllowedRendererOrigins(window)), this.dependencies.rendererFlushTimeoutMs, ) if (result === "untrusted-origin") { - console.warn(`[client-state] skipped renderer ${context} flush for an untrusted origin`) + console.warn("[client-state] skipped renderer flush for an untrusted origin") } } private async flushNative(): Promise { - if (this.windowStateTracker) { - await this.windowStateTracker.flush() - } else { - await this.dependencies.clientStateManager.flush() + if (this.windowStateTracker) await this.windowStateTracker.flush() + else await this.dependencies.clientStateManager.flush() + } + + private async runStage(name: string, operation: () => Promise): Promise { + try { + await operation() + } catch (error) { + console.warn(`[client-state] ${name} failed; continuing`, error) } } - private flushForWindowsSessionEnd(window: BrowserWindow): Promise { - if (this.windowsSessionEndFlush) return this.windowsSessionEndFlush - - const flush = async () => { - try { - await this.flushRenderer(window, "shutdown") - } catch (error) { - console.warn("[client-state] Windows session-end renderer flush failed", error) - } - try { - await this.flushNative() - } catch (error) { - console.warn("[client-state] Windows session-end native flush failed", error) - } - try { - await this.dependencies.clientStateManager.drainAndReleasePrimary() - } catch (error) { - console.warn("[client-state] Windows session-end primary release failed", error) - } - try { - await this.dependencies.cliManager.stop() - } catch (error) { - console.warn("[client-state] Windows session-end CLI stop failed", error) - } - } - this.windowsSessionEndFlush = Promise.race([ - flush(), - new Promise((resolve) => setTimeout( - resolve, - this.dependencies.windowsSessionEndFlushTimeoutMs ?? WINDOWS_SESSION_END_FLUSH_TIMEOUT_MS, - )), - ]) - return this.windowsSessionEndFlush - } - - private startWindowsSessionEnd(window: BrowserWindow): void { - if (this.windowsSessionEndFlush) return - void this.flushForWindowsSessionEnd(window).finally(() => { - this.windowsSessionEndExitAllowed = true - this.dependencies.app.exit(0) - }) + private exit(): void { + if (this.exitAllowed) return + this.exitAllowed = true + this.dependencies.app.exit(0) } } diff --git a/packages/electron-app/electron/main/client-state-navigation.test.ts b/packages/electron-app/electron/main/client-state-navigation.test.ts index 29068a7f..8c40b0a9 100644 --- a/packages/electron-app/electron/main/client-state-navigation.test.ts +++ b/packages/electron-app/electron/main/client-state-navigation.test.ts @@ -3,147 +3,87 @@ import { mkdtempSync, rmSync } from "node:fs" import { tmpdir } from "node:os" import { join } from "node:path" import test from "node:test" -import { - ClientStateIPCHandlers, - createClientStateIPCHandlers, - createRendererAccessNavigationCommitHandler, -} from "./client-state-ipc-handlers" -import { ClientStateNavigationController } from "./client-state-navigation" import { ClientStateManager } from "./client-state" +import { ClientStateNavigationController, shouldResetRendererAccessTokenForNavigation } from "./client-state-navigation" -test("edit then immediate reload persists the latest snapshot and rotates renderer access", async (testContext) => { - const directory = mkdtempSync(join(tmpdir(), "codenomad-client-state-navigation-")) - const manager = new ClientStateManager(directory) - const handlers: ClientStateIPCHandlers = createClientStateIPCHandlers(manager) - testContext.after(async () => { - await manager.drainAndReleasePrimary().catch(() => {}) - rmSync(directory, { recursive: true, force: true }) - }) +const tick = () => new Promise((resolve) => setImmediate(resolve)) +function window(executeJavaScript: () => Promise = async () => {}) { + return { isDestroyed: () => false, webContents: { isDestroyed: () => false, getURL: () => "http://127.0.0.1:3000/app", executeJavaScript } } +} +function controller(win: ReturnType, manager: { isPrimary: boolean }, report: (error: unknown) => void = (error) => assert.fail(String(error))) { + return new ClientStateNavigationController(win as never, { clientStateManager: manager, isTrustedOrigin: () => true, reportFlushError: report }) +} +function managerHarness(t: test.TestContext) { + const directory = mkdtempSync(join(tmpdir(), "codenomad-navigation-")) + const manager = new ClientStateManager(directory, undefined, { crossHostElectionDirectory: join(directory, "election") }) + t.after(async () => { await manager.drainAndReleasePrimary().catch(() => {}); rmSync(directory, { recursive: true, force: true }) }) + return manager +} - handlers.claimAccess("outgoing-document") +test("renderer access resets only for trusted full main-frame navigation", () => { + const trusted = (url: string) => new URL(url).origin === "http://127.0.0.1:3000" + for (const [url, inPlace, mainFrame, expected] of [ + ["http://127.0.0.1:3000/reload", false, true, true], + ["http://127.0.0.1:3000/frame", false, false, false], + ["http://127.0.0.1:3000/#route", true, true, false], + ["https://untrusted.example/reload", false, true, false], + ] as const) assert.equal(shouldResetRendererAccessTokenForNavigation(url, inPlace, mainFrame, trusted), expected) +}) + +test("immediate reload flushes latest state before rotating document access", async (t) => { + const manager = managerHarness(t) + await manager.setRestoreEnabled(true) + manager.claimClientStateAccess("outgoing") + const load = (token: string) => { manager.assertRendererAccessToken(token); return manager.loadClientState() } + const save = (token: string, state: unknown) => { manager.assertRendererAccessToken(token); return manager.saveClientState(state) } + for (const denied of [() => load("other"), () => save("other", {})]) assert.throws(denied, /has not been claimed/) let navigated = false - const window = { - isDestroyed: () => false, - webContents: { - isDestroyed: () => false, - getURL: () => "http://127.0.0.1:3000/app", - executeJavaScript: async () => { - await handlers.save("outgoing-document", { revision: 7, editor: "latest" }) - }, - }, - } - const controller = new ClientStateNavigationController({ - clientStateManager: manager, - getWindow: () => window as never, - isTrustedOrigin: () => true, - reportFlushError: (error) => assert.fail(String(error)), - }) - const commitNavigation = createRendererAccessNavigationCommitHandler(manager, () => true) - - await controller.navigate(async () => { + await controller(window(() => save("outgoing", { revision: 7, editor: "latest" })), manager).navigate(() => { navigated = true - assert.deepEqual(handlers.load("outgoing-document").snapshot, { revision: 7, editor: "latest" }) - commitNavigation("http://127.0.0.1:3000/app", false, true) + assert.deepEqual(load("outgoing").snapshot, { revision: 7, editor: "latest" }) + manager.resetRendererAccessToken() }) - assert.equal(navigated, true) - assert.throws(() => handlers.load("outgoing-document"), /has not been claimed/) - handlers.claimAccess("new-document") - assert.deepEqual(handlers.load("new-document").snapshot, { revision: 7, editor: "latest" }) + assert.throws(() => load("outgoing"), /has not been claimed/) + manager.claimClientStateAccess("incoming") + assert.deepEqual(load("incoming").snapshot, { revision: 7, editor: "latest" }) }) -test("failed loadURL retains access for the current document", async (testContext) => { - const directory = mkdtempSync(join(tmpdir(), "codenomad-client-state-navigation-failure-")) - const manager = new ClientStateManager(directory) - const handlers = createClientStateIPCHandlers(manager) - testContext.after(async () => { - await manager.drainAndReleasePrimary().catch(() => {}) - rmSync(directory, { recursive: true, force: true }) +test("failed navigation retains current document access", async (t) => { + for (const [name, operation] of [ + ["loadURL", () => Promise.reject(new Error("loadURL failed"))], + ["reload", () => { throw new Error("reload failed") }], + ] as const) await t.test(name, async (st) => { + const manager = managerHarness(st) + manager.claimClientStateAccess("current") + await assert.rejects(controller(window(), manager).navigate(operation), new RegExp(`${name} failed`)) + manager.assertRendererAccessToken("current") + assert.equal(await manager.saveClientState({ retained: name }), true) }) - handlers.claimAccess("current-document") - - const controller = new ClientStateNavigationController({ - clientStateManager: manager, - getWindow: () => ({ - isDestroyed: () => false, - webContents: { - isDestroyed: () => false, - getURL: () => "http://127.0.0.1:3000/app", - executeJavaScript: async () => {}, - }, - }) as never, - isTrustedOrigin: () => true, - reportFlushError: (error) => assert.fail(String(error)), - }) - - await assert.rejects(controller.navigate(() => Promise.reject(new Error("loadURL failed"))), /loadURL failed/) - assert.equal(await handlers.save("current-document", { retained: "after-loadURL" }), true) - assert.deepEqual(handlers.load("current-document").snapshot, { retained: "after-loadURL" }) }) -test("failed reload retains access for the current document", async (testContext) => { - const directory = mkdtempSync(join(tmpdir(), "codenomad-client-state-reload-failure-")) - const manager = new ClientStateManager(directory) - const handlers = createClientStateIPCHandlers(manager) - testContext.after(async () => { - await manager.drainAndReleasePrimary().catch(() => {}) - rmSync(directory, { recursive: true, force: true }) - }) - handlers.claimAccess("current-document") - - const controller = new ClientStateNavigationController({ - clientStateManager: manager, - getWindow: () => ({ - isDestroyed: () => false, - webContents: { - isDestroyed: () => false, - getURL: () => "http://127.0.0.1:3000/app", - executeJavaScript: async () => {}, - }, - }) as never, - isTrustedOrigin: () => true, - reportFlushError: (error) => assert.fail(String(error)), - }) - - await assert.rejects(controller.navigate(() => { throw new Error("reload failed") }), /reload failed/) - assert.equal(await handlers.save("current-document", { retained: "after-reload" }), true) -}) - -test("hung renderer flush is bounded and does not deadlock reload", async () => { - const manager = { - isPrimary: true, - resetRendererAccessTokenCalls: 0, - resetRendererAccessToken() { - this.resetRendererAccessTokenCalls += 1 - }, - } - let navigated = false - let reported = false - const controller = new ClientStateNavigationController({ - clientStateManager: manager, - getWindow: () => ({ - isDestroyed: () => false, - webContents: { - isDestroyed: () => false, - getURL: () => "http://127.0.0.1:3000/app", - executeJavaScript: () => new Promise(() => {}), - }, - }) as never, - isTrustedOrigin: () => true, - reportFlushError: () => { - reported = true - }, - }) - - const startedAt = Date.now() - await controller.navigate(() => { - navigated = true - }) - const elapsedMs = Date.now() - startedAt - +test("hung renderer flush is bounded without rotating access or blocking navigation", async () => { + const manager = { isPrimary: true, resets: 0, resetRendererAccessToken() { this.resets++ } } + let navigated = false, reported = false + const started = Date.now() + await controller(window(() => new Promise(() => {})), manager, () => { reported = true }).navigate(() => { navigated = true }) + const elapsed = Date.now() - started assert.equal(reported, true) - assert.equal(manager.resetRendererAccessTokenCalls, 0) + assert.equal(manager.resets, 0) assert.equal(navigated, true) - assert.ok(elapsedMs >= 900, `flush timeout ended too early after ${elapsedMs}ms`) - assert.ok(elapsedMs < 2_000, `flush timeout was not bounded: ${elapsedMs}ms`) + assert.ok(elapsed >= 900 && elapsed < 2_000, `unexpected timeout: ${elapsed}ms`) +}) + +test("queued navigation preserves order and distinct generations", async () => { + const calls: string[] = [] + let release!: () => void + const gate = new Promise((resolve) => { release = resolve }) + const navigation = controller(window(), { isPrimary: true }) + const first = navigation.navigate(async (_window, generation) => { calls.push(`start-${generation}`); await gate; calls.push(`end-${generation}`) }) + const second = navigation.navigate((_window, generation) => { calls.push(`run-${generation}`) }) + await tick() + assert.deepEqual(calls, ["start-1"]) + release() + await Promise.all([first, second]) + assert.deepEqual(calls, ["start-1", "end-1", "run-2"]) }) diff --git a/packages/electron-app/electron/main/client-state-navigation.ts b/packages/electron-app/electron/main/client-state-navigation.ts index 8e27331e..feed061d 100644 --- a/packages/electron-app/electron/main/client-state-navigation.ts +++ b/packages/electron-app/electron/main/client-state-navigation.ts @@ -4,25 +4,32 @@ import { flushRendererClientStateBeforeShutdown } from "./renderer-client-state- interface ClientStateNavigationDependencies { clientStateManager: Pick - getWindow(): BrowserWindow | null isTrustedOrigin(url: string): boolean reportFlushError(error: unknown): void } export class ClientStateNavigationController { private queue: Promise = Promise.resolve() + private generation = 0 - constructor(private readonly dependencies: ClientStateNavigationDependencies) {} + constructor( + private readonly window: BrowserWindow, + private readonly dependencies: ClientStateNavigationDependencies, + ) {} - navigate(operation: (window: BrowserWindow) => void | Promise): Promise { - const request = this.queue.catch(() => {}).then(() => this.performNavigation(operation)) + navigate(operation: (window: BrowserWindow, generation: number) => void | Promise): Promise { + const generation = ++this.generation + const request = this.queue.catch(() => {}).then(() => this.performNavigation(operation, generation)) this.queue = request return request } - private async performNavigation(operation: (window: BrowserWindow) => void | Promise): Promise { - const window = this.dependencies.getWindow() - if (!window || window.isDestroyed() || window.webContents.isDestroyed()) return + private async performNavigation( + operation: (window: BrowserWindow, generation: number) => void | Promise, + generation: number, + ): Promise { + const { window } = this + if (window.isDestroyed() || window.webContents.isDestroyed()) return try { await flushRendererClientStateBeforeShutdown( @@ -35,6 +42,15 @@ export class ClientStateNavigationController { } if (window.isDestroyed() || window.webContents.isDestroyed()) return - await operation(window) + await operation(window, generation) } } + +export function shouldResetRendererAccessTokenForNavigation( + url: string, + isInPlace: boolean, + isMainFrame: boolean, + isTrustedOrigin: (url: string) => boolean, +): boolean { + return isMainFrame && !isInPlace && isTrustedOrigin(url) +} diff --git a/packages/electron-app/electron/main/client-state-process-identity.ts b/packages/electron-app/electron/main/client-state-process-identity.ts index 7c189c7f..749bfc57 100644 --- a/packages/electron-app/electron/main/client-state-process-identity.ts +++ b/packages/electron-app/electron/main/client-state-process-identity.ts @@ -1,7 +1,11 @@ -import { spawnSync } from "node:child_process" -import { readFileSync } from "node:fs" +import { execFile, spawnSync } from "node:child_process" +import { readFile as readFileAsync } from "node:fs/promises" +import { readFileSync, readlinkSync } from "node:fs" +import { basename, resolve } from "node:path" export type ProcessStartIdentityLookup = (pid: number) => string | undefined +export type AsyncProcessStartIdentityLookup = (pid: number, timeoutMs: number) => Promise | string | undefined +export type ExpectedProcessLookup = (pid: number) => boolean | undefined function readLinuxProcessStartIdentity(pid: number): string | undefined { const stat = readFileSync(`/proc/${pid}/stat`, "utf8") @@ -32,6 +36,16 @@ function readCommandIdentity(command: string, args: string[], prefix: string): s return undefined } +function readCommandIdentityAsync(command: string, args: string[], prefix: string, timeoutMs: number): Promise { + if (timeoutMs <= 0) return Promise.resolve(undefined) + return new Promise((resolve) => { + execFile(command, args, { encoding: "utf8", windowsHide: true, timeout: timeoutMs }, (error, stdout) => { + const value = error ? "" : stdout.trim() + resolve(value ? `${prefix}:${value}` : undefined) + }) + }) +} + export function getProcessStartIdentity(pid: number): string | undefined { if (!Number.isInteger(pid) || pid <= 0) return undefined @@ -49,7 +63,7 @@ export function getProcessStartIdentity(pid: number): string | undefined { "-NoProfile", "-NonInteractive", "-Command", - `(Get-Process -Id ${pid} -ErrorAction Stop).StartTime.ToUniversalTime().Ticks`, + `(Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}" -ErrorAction Stop).CreationDate.ToUniversalTime().Ticks`, ], "win32", ) @@ -60,3 +74,56 @@ export function getProcessStartIdentity(pid: number): string | undefined { return undefined } + +export async function getProcessStartIdentityAsync( + pid: number, + timeoutMs: number, + platform: NodeJS.Platform = process.platform, +): Promise { + if (!Number.isInteger(pid) || pid <= 0 || timeoutMs <= 0) return undefined + try { + if (platform === "linux") { + const signal = AbortSignal.timeout(timeoutMs) + const stat = await readFileAsync(`/proc/${pid}/stat`, { encoding: "utf8", signal }) + const commandEnd = stat.lastIndexOf(")") + const startTicks = commandEnd < 0 ? undefined : stat.slice(commandEnd + 1).trim().split(/\s+/)[19] + if (!startTicks) return undefined + const bootId = (await readFileAsync("/proc/sys/kernel/random/boot_id", { encoding: "utf8", signal })).trim() + return bootId ? `linux:${bootId}:${startTicks}` : undefined + } + if (platform === "darwin") { + return readCommandIdentityAsync("ps", ["-p", String(pid), "-o", "lstart="], "darwin", timeoutMs) + } + if (platform === "win32") { + return readCommandIdentityAsync("powershell.exe", [ + "-NoProfile", + "-NonInteractive", + "-Command", + `(Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}" -ErrorAction Stop).CreationDate.ToUniversalTime().Ticks`, + ], "win32", timeoutMs) + } + } catch { + // Identity lookup is best-effort; callers refuse destructive actions when it is unavailable. + } + return undefined +} + +export function isExpectedTauriProcess(pid: number): boolean | undefined { + try { + const executable = process.platform === "linux" + ? readlinkSync(`/proc/${pid}/exe`) + : readCommandIdentity( + process.platform === "win32" ? "powershell.exe" : "ps", + process.platform === "win32" + ? ["-NoProfile", "-NonInteractive", "-Command", `(Get-Process -Id ${pid} -ErrorAction Stop).Path`] + : ["-p", String(pid), "-o", "comm="], + "path", + )?.slice(5) + if (!executable) return undefined + if (resolve(executable).toLowerCase() === resolve(process.execPath).toLowerCase()) return false + return ["codenomad", "codenomad.exe", "codenomad-tauri", "codenomad-tauri.exe"] + .includes(basename(executable).toLowerCase()) + } catch { + return undefined + } +} diff --git a/packages/electron-app/electron/main/client-state-process.test.ts b/packages/electron-app/electron/main/client-state-process.test.ts index 796e63c6..fe9e0bde 100644 --- a/packages/electron-app/electron/main/client-state-process.test.ts +++ b/packages/electron-app/electron/main/client-state-process.test.ts @@ -2,482 +2,189 @@ import assert from "node:assert/strict" import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process" import { randomUUID } from "node:crypto" import { once } from "node:events" -import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs" +import fs, { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs" +import { syncBuiltinESMExports } from "node:module" import { tmpdir } from "node:os" import { join } from "node:path" import { fileURLToPath } from "node:url" import test from "node:test" -import { - cleanStaleRunningMarkers, - createRunningMarker, - electClientStateProcess, - getRunningMarkerPath, - REGISTRATION_LOCK_WAIT_MS, - removeProcessOwnerLockIfOwned, - removeRunningMarkerIfOwned, - type ProcessOwner, -} from "./client-state-process" -import { getProcessStartIdentity } from "./client-state-process-identity" +import { cleanStaleRunningMarkers, createRunningMarker, electClientStateProcess, getRunningMarkerPath, hasLiveTauriClient, REGISTRATION_LOCK_WAIT_MS, removeProcessOwnerLockIfOwned, removeRunningMarkerIfOwned, type ProcessOwner } from "./client-state-process" +import { getProcessStartIdentity, getProcessStartIdentityAsync } from "./client-state-process-identity" -function withTempDirectory(testContext: test.TestContext): string { - const directory = mkdtempSync(join(tmpdir(), "codenomad-client-state-")) - testContext.after(() => rmSync(directory, { recursive: true, force: true })) - return directory -} +function temp(t: test.TestContext) { const directory = mkdtempSync(join(tmpdir(), "codenomad-election-")); t.after(() => rmSync(directory, { recursive: true, force: true })); return directory } -interface ElectionChild { - process: ChildProcessWithoutNullStreams - result: Promise -} +test("legacy Tauri markers block only while their PID may be live", (t) => { + const directory = temp(t) + writeFileSync(join(directory, "client-state.running.123.1.lock"), "") + writeFileSync(join(directory, "client-state.running.456.2.lock"), "") + assert.equal(hasLiveTauriClient(directory, (pid) => pid === 456), true) + assert.equal(hasLiveTauriClient(directory, () => false), false) + assert.equal(hasLiveTauriClient(directory, (pid) => pid === 456, () => "reused", () => false), false) + assert.equal(hasLiveTauriClient(directory, (pid) => pid === 456, () => undefined, () => undefined), true) + assert.equal(hasLiveTauriClient(directory, (pid) => pid === 456, () => "tauri-start", () => true), true) + assert.equal(hasLiveTauriClient(directory, (pid) => pid === 456, () => "tauri-start", () => true, [ + { pid: 456, runToken: "upgraded", processStartIdentity: "tauri-start" }, + ]), false) +}) -interface ElectionChildResult { - isPrimary: boolean - owner: ProcessOwner - warnings: string[] -} - -interface ElectionChildOptions { - registrationLockWaitMs?: number - primaryPausedPath?: string - primaryReleasePath?: string -} - -function startElectionChild( - directory: string, - startPath: string, - options: ElectionChildOptions = {}, -): ElectionChild { - const childPath = fileURLToPath(new URL("./client-state-election-child.ts", import.meta.url)) - const child = spawn(process.execPath, [ - "--import", - "tsx", - childPath, - directory, - randomUUID(), - startPath, - options.registrationLockWaitMs?.toString() ?? "", - options.primaryPausedPath ?? "", - options.primaryReleasePath ?? "", - ]) - child.stdout.setEncoding("utf8") - child.stderr.setEncoding("utf8") - - const result = new Promise((resolve, reject) => { - let stdout = "" - let stderr = "" - let settled = false - child.stdout.on("data", (chunk: string) => { - stdout += chunk - const newline = stdout.indexOf("\n") - if (newline < 0 || settled) { - return - } - settled = true - resolve(JSON.parse(stdout.slice(0, newline)) as ElectionChildResult) +interface Child { process: ChildProcessWithoutNullStreams; result: Promise<{ isPrimary: boolean }> } +function child(directory: string, start: string, wait = "", paused = "", release = ""): Child { + const process = spawn(globalThis.process.execPath, ["--import", "tsx", fileURLToPath(new URL("./client-state-election-child.ts", import.meta.url)), directory, randomUUID(), start, wait, paused, release]) + process.stdout.setEncoding("utf8"); process.stderr.setEncoding("utf8") + const result = new Promise<{ isPrimary: boolean }>((resolve, reject) => { + let output = "", errors = "", settled = false + process.stdout.on("data", (chunk: string) => { + output += chunk + const end = output.indexOf("\n") + if (end >= 0 && !settled) { settled = true; resolve(JSON.parse(output.slice(0, end))) } }) - child.stderr.on("data", (chunk: string) => { - stderr += chunk - }) - child.once("error", (error) => { - if (!settled) { - settled = true - reject(error) - } - }) - child.once("exit", (code) => { - if (!settled) { - settled = true - reject(new Error(`Election child exited with code ${code}: ${stderr}`)) - } + process.stderr.on("data", (chunk: string) => { errors += chunk }); process.once("error", reject) + process.once("exit", (code) => { if (!settled) reject(new Error(`child ${code}: ${errors}`)) }) + }) + return { process, result } +} +async function stop(children: Child[]) { const exits = children.map(({ process }) => once(process, "exit")); children.forEach(({ process }) => process.stdin.end()); await Promise.all(exits) } +async function contenders(directory: string, configure?: () => void, count = 2) { + const start = join(directory, "start") + configure?.() + const children = Array.from({ length: count }, () => child(directory, start, "40")) + try { + writeFileSync(start, "") + const roles = await Promise.all(children.map(({ result }) => result)) + assert.equal(roles.filter(({ isPrimary }) => isPrimary).length, 1, JSON.stringify(roles)) + } finally { await stop(children) } +} + +test("current process start identity is stable", () => { + const identity = getProcessStartIdentity(process.pid) + assert.ok(identity, `identity unavailable on ${process.platform}`) + assert.equal(getProcessStartIdentity(process.pid), identity) +}) + +test("async process identity matches the spawn-time identity", async () => { + const identity = getProcessStartIdentity(process.pid) + assert.ok(identity) + assert.equal(await getProcessStartIdentityAsync(process.pid, 1_500), identity) +}) + +test("marker cleanup preserves only election-relevant live cohorts", async (t) => { + const cases: Array<{ name: string; marker: ProcessOwner; current: ProcessOwner; alive: boolean; identity?: string; primary?: ProcessOwner; blocking: boolean; remains: boolean }> = [ + { name: "live secondary", marker: { pid: 2, runToken: "live" }, current: { pid: 3, runToken: "new" }, alive: true, blocking: true, remains: true }, + { name: "same PID old run", marker: { pid: 4, runToken: "old" }, current: { pid: 4, runToken: "new" }, alive: true, blocking: false, remains: false }, + { name: "reused PID", marker: { pid: 5, runToken: "old", processStartIdentity: "old" }, current: { pid: 6, runToken: "new" }, alive: true, identity: "reused", blocking: false, remains: false }, + { name: "unknown identity", marker: { pid: 7, runToken: "unknown", processStartIdentity: "old" }, current: { pid: 8, runToken: "new" }, alive: true, blocking: true, remains: true }, + { name: "acknowledged primary", marker: { pid: 9, runToken: "secondary" }, current: { pid: 10, runToken: "primary" }, alive: true, primary: { pid: 10, runToken: "primary" }, blocking: false, remains: true }, + ] + for (const value of cases) await t.test(value.name, (st) => { + const directory = temp(st) + const path = createRunningMarker(directory, value.marker, value.primary) + const identity = () => value.identity + assert.equal(cleanStaleRunningMarkers(directory, value.current, () => value.alive, identity), value.blocking) + assert.equal(existsSync(path), value.remains) + }) +}) + +test("marker removal and mismatched contents never discard a possible live owner", (t) => { + const directory = temp(t) + const filenameOwner = { pid: 11, runToken: "filename" } + const storedOwner = { pid: 12, runToken: "stored" } + const current = { pid: 13, runToken: "current" } + const path = createRunningMarker(directory, filenameOwner) + writeFileSync(path, JSON.stringify(storedOwner)) + assert.equal(removeRunningMarkerIfOwned(path, filenameOwner), false) + assert.deepEqual(JSON.parse(readFileSync(path, "utf8")), storedOwner) + assert.equal(cleanStaleRunningMarkers(directory, current, () => false), false) + assert.equal(existsSync(path), false) + createRunningMarker(directory, filenameOwner) + writeFileSync(path, JSON.stringify(storedOwner)) + assert.equal(cleanStaleRunningMarkers(directory, current, (pid) => pid === filenameOwner.pid), true) + assert.equal(existsSync(path), true) +}) + +test("process files tolerate only unsupported fsync and never clobber an owner", (t) => { + const directory = temp(t) + const first = { pid: 14, runToken: "first" } + const path = createRunningMarker(directory, first) + assert.throws(() => createRunningMarker(directory, { pid: 14, runToken: "first" }), { code: "EEXIST" }) + assert.deepEqual(JSON.parse(readFileSync(path, "utf8")), first) + const original = fs.fsyncSync + t.after(() => { fs.fsyncSync = original; syncBuiltinESMExports() }) + for (const [code, retained] of [["EINVAL", true], ["ENOTSUP", true], ["ENOSYS", true], ["EIO", false]] as const) { + fs.fsyncSync = () => { throw Object.assign(new Error(code), { code }) }; syncBuiltinESMExports() + const owner = { pid: 15, runToken: code }, next = getRunningMarkerPath(directory, owner) + if (retained) createRunningMarker(directory, owner) + else assert.throws(() => createRunningMarker(directory, owner), { code }) + assert.equal(existsSync(next), retained) + } +}) + +test("simultaneous registration and stale lock recovery elect exactly one primary", async (t) => { + await t.test("clean", (st) => contenders(temp(st))) + await t.test("unrelated live registration PID", (st) => { + const directory = temp(st) + return contenders(directory, () => writeFileSync(join(directory, "client-state.registration.lock"), JSON.stringify({ pid: process.pid, runToken: "old" }))) + }) + for (let round = 0; round < 10; round++) await t.test(`reused primary PID ${round}`, (st) => { + const directory = temp(st) + return contenders(directory, () => { + const owner = { pid: process.pid, runToken: `old-${round}`, processStartIdentity: `old-start-${round}` } + writeFileSync(join(directory, "client-state.primary.lock"), JSON.stringify(owner)) + createRunningMarker(directory, owner) }) }) - - return { process: child, result } -} - -async function stopElectionChildren(children: ElectionChild[]) { - const exits = children.map((child) => once(child.process, "exit")) - for (const child of children) { - child.process.stdin.end() - } - await Promise.all(exits) -} - -async function waitForFile(path: string, timeoutMs: number): Promise { - const deadline = Date.now() + timeoutMs - while (!existsSync(path)) { - if (Date.now() >= deadline) { - throw new Error(`Timed out waiting for ${path}`) - } - await new Promise((resolve) => setTimeout(resolve, 5)) - } -} - -test("the current process exposes a stable OS start identity", () => { - const first = getProcessStartIdentity(process.pid) - const second = getProcessStartIdentity(process.pid) - - assert.ok(first, `process start identity is unavailable on ${process.platform}`) - assert.equal(second, first) }) -test("a live secondary marker prevents a successor from becoming primary", (testContext) => { - const directory = withTempDirectory(testContext) - const exitedPrimary: ProcessOwner = { pid: 101, runToken: "exited-primary" } - const liveSecondary: ProcessOwner = { pid: 202, runToken: "live-secondary" } - const successor: ProcessOwner = { pid: 303, runToken: "successor" } - - createRunningMarker(directory, exitedPrimary) - createRunningMarker(directory, liveSecondary) - createRunningMarker(directory, successor) - - const hasOtherLiveProcess = cleanStaleRunningMarkers(directory, successor, (pid) => pid === liveSecondary.pid) - - assert.equal(hasOtherLiveProcess, true) - assert.equal(existsSync(getRunningMarkerPath(directory, exitedPrimary)), false) - assert.equal(existsSync(getRunningMarkerPath(directory, liveSecondary)), true) - assert.equal(existsSync(getRunningMarkerPath(directory, successor)), true) -}) - -test("a reused PID marker with a different run token is stale", (testContext) => { - const directory = withTempDirectory(testContext) - const oldRun: ProcessOwner = { pid: 404, runToken: "old-run" } - const currentRun: ProcessOwner = { pid: 404, runToken: "current-run" } - - createRunningMarker(directory, oldRun) - createRunningMarker(directory, currentRun) - - assert.equal(cleanStaleRunningMarkers(directory, currentRun, () => true), false) - assert.equal(existsSync(getRunningMarkerPath(directory, oldRun)), false) - assert.equal(existsSync(getRunningMarkerPath(directory, currentRun)), true) -}) - -test("a marker from a crashed process is stale when its PID has been reused", (testContext) => { - const directory = withTempDirectory(testContext) - const crashedOwner: ProcessOwner = { - pid: 404, - runToken: "crashed-run", - processStartIdentity: "start:old", - } - const currentOwner: ProcessOwner = { - pid: 505, - runToken: "current-run", - processStartIdentity: "start:current", - } - createRunningMarker(directory, crashedOwner) - - const hasOtherLiveProcess = cleanStaleRunningMarkers( - directory, - currentOwner, - () => true, - (pid) => pid === crashedOwner.pid ? "start:reused" : currentOwner.processStartIdentity, - ) - - assert.equal(hasOtherLiveProcess, false) - assert.equal(existsSync(getRunningMarkerPath(directory, crashedOwner)), false) -}) - -test("an identity lookup failure remains conservative for election safety", (testContext) => { - const directory = withTempDirectory(testContext) - const unverifiedOwner: ProcessOwner = { - pid: 606, - runToken: "unverified-run", - processStartIdentity: "start:missing", - } - const currentOwner: ProcessOwner = { pid: 707, runToken: "current-run" } - createRunningMarker(directory, unverifiedOwner) - - assert.equal(cleanStaleRunningMarkers(directory, currentOwner, () => true, () => undefined), true) - assert.equal(existsSync(getRunningMarkerPath(directory, unverifiedOwner)), true) -}) - -test("a secondary that observed the current primary does not make it relinquish ownership", (testContext) => { - const directory = withTempDirectory(testContext) - const currentPrimary: ProcessOwner = { pid: 303, runToken: "current-primary" } - const overlappingSecondary: ProcessOwner = { pid: 404, runToken: "overlapping-secondary" } - createRunningMarker(directory, overlappingSecondary, currentPrimary) - - const hasBlockingProcess = cleanStaleRunningMarkers( - directory, - currentPrimary, - (pid) => pid === overlappingSecondary.pid, - ) - - assert.equal(hasBlockingProcess, false) - assert.equal(existsSync(getRunningMarkerPath(directory, overlappingSecondary)), true) -}) - -test("shutdown does not remove a marker whose ownership changed", (testContext) => { - const directory = withTempDirectory(testContext) - const owner: ProcessOwner = { pid: 505, runToken: "owned-run" } - const replacement: ProcessOwner = { pid: 606, runToken: "replacement-run" } - const markerPath = createRunningMarker(directory, owner) - writeFileSync(markerPath, JSON.stringify(replacement), "utf8") - - assert.equal(removeRunningMarkerIfOwned(markerPath, owner), false) - assert.deepEqual(JSON.parse(readFileSync(markerPath, "utf8")), replacement) -}) - -test("simultaneous process registration elects exactly one primary", async (testContext) => { - const directory = withTempDirectory(testContext) - const startPath = join(directory, "start") - const children = [startElectionChild(directory, startPath), startElectionChild(directory, startPath)] - writeFileSync(startPath, "", "utf8") - - const roles = await Promise.all(children.map((child) => child.result)) - await stopElectionChildren(children) - - assert.equal(roles.filter((role) => role.isPrimary).length, 1) -}) - -test("an unrelated live PID in a stale registration file still elects exactly one primary", async (testContext) => { - const directory = withTempDirectory(testContext) - const registrationLockPath = join(directory, "client-state.registration.lock") - writeFileSync( - registrationLockPath, - JSON.stringify({ pid: process.pid, runToken: "unrelated-live-process" }), - "utf8", - ) - const startPath = join(directory, "start") - const children = [startElectionChild(directory, startPath), startElectionChild(directory, startPath)] - writeFileSync(startPath, "", "utf8") - - const roles = await Promise.all(children.map((child) => child.result)) - await stopElectionChildren(children) - - assert.equal(roles.filter((role) => role.isPrimary).length, 1) -}) - -async function assertStaleLivePidPrimaryRecovery(testContext: test.TestContext, round: number) { - const directory = withTempDirectory(testContext) - const primaryLockPath = join(directory, "client-state.primary.lock") - const crashedOwner: ProcessOwner = { - pid: process.pid, - runToken: `reused-live-primary-${round}`, - processStartIdentity: `old-process-start-${round}`, - } - writeFileSync( - primaryLockPath, - JSON.stringify(crashedOwner), - "utf8", - ) - createRunningMarker(directory, crashedOwner) - const startPath = join(directory, "start") - const options: ElectionChildOptions = { registrationLockWaitMs: 40 } - const children = [startElectionChild(directory, startPath, options), startElectionChild(directory, startPath, options)] - let stopped = false +test("overlapping stale-registration recovery cannot leave all contenders secondary", async (t) => { + const directory = temp(t), start = join(directory, "start"), paused = join(directory, "paused"), release = join(directory, "release") + writeFileSync(join(directory, "client-state.registration.lock"), JSON.stringify({ pid: process.pid, runToken: "old" })) + const children = [child(directory, start, "40", paused, release), child(directory, start, "40", paused, release)] try { - writeFileSync(startPath, "", "utf8") - const roles = await Promise.all(children.map((child) => child.result)) - assert.equal( - roles.filter((role) => role.isPrimary).length, - 1, - JSON.stringify({ round, roles }), - ) - await stopElectionChildren(children) - stopped = true - } finally { - if (!stopped) await stopElectionChildren(children) + writeFileSync(start, "") + const deadline = Date.now() + 2_000 + while (!existsSync(paused) && Date.now() < deadline) await new Promise((resolve) => setTimeout(resolve, 5)) + assert.equal(existsSync(paused), true) + await Promise.race(children.map(({ result }) => result)) + writeFileSync(release, "") + const roles = await Promise.all(children.map(({ result }) => result)) + assert.equal(roles.filter(({ isPrimary }) => isPrimary).length, 1, JSON.stringify(roles)) + } finally { if (!existsSync(release)) writeFileSync(release, ""); await stop(children) } +}) + +test("a surviving older secondary keeps later processes secondary", async (t) => { + const directory = temp(t) + const launch = async (name: string) => { + const start = join(directory, name), next = child(directory, start) + writeFileSync(start, "") + return { next, role: await next.result } } -} - -test("a crash-left primary marker whose PID was reused still elects exactly one primary", async (testContext) => { - await assertStaleLivePidPrimaryRecovery(testContext, 0) + const first = await launch("first"); assert.equal(first.role.isPrimary, true) + const second = await launch("second"); assert.equal(second.role.isPrimary, false) + await stop([first.next]) + const third = await launch("third") + await stop([second.next, third.next]) + assert.equal(third.role.isPrimary, false) }) -test("simultaneous reused-PID recovery remains single-primary under stress", async (testContext) => { - for (let round = 1; round <= 20; round += 1) { - await assertStaleLivePidPrimaryRecovery(testContext, round) - } -}) - -test("overlapping stale-registration recovery cannot leave every contender secondary", async (testContext) => { - const directory = withTempDirectory(testContext) - const registrationLockPath = join(directory, "client-state.registration.lock") - const startPath = join(directory, "start") - const primaryPausedPath = join(directory, "primary-paused") - const primaryReleasePath = join(directory, "primary-release") - writeFileSync( - registrationLockPath, - JSON.stringify({ pid: process.pid, runToken: "unrelated-live-process" }), - "utf8", - ) - const options: ElectionChildOptions = { - registrationLockWaitMs: 40, - primaryPausedPath, - primaryReleasePath, - } - const children = [startElectionChild(directory, startPath, options), startElectionChild(directory, startPath, options)] - let stopped = false - try { - writeFileSync(startPath, "", "utf8") - - await waitForFile(primaryPausedPath, 2_000) - await Promise.race(children.map((child) => child.result)) - writeFileSync(primaryReleasePath, "", "utf8") - const roles = await Promise.all(children.map((child) => child.result)) - const electionFiles = Object.fromEntries( - readdirSync(directory) - .filter((filename) => filename.startsWith("client-state.")) - .map((filename) => [filename, readFileSync(join(directory, filename), "utf8")]), - ) - assert.equal( - roles.filter((role) => role.isPrimary).length, - 1, - JSON.stringify({ roles, electionFiles }), - ) - await stopElectionChildren(children) - stopped = true - } finally { - if (!existsSync(primaryReleasePath)) { - writeFileSync(primaryReleasePath, "", "utf8") - } - if (!stopped) { - await stopElectionChildren(children) - } - } -}) - -test("a new process remains secondary after primary exits while an older secondary lives", async (testContext) => { - const directory = withTempDirectory(testContext) - const firstStart = join(directory, "start-first") - const first = startElectionChild(directory, firstStart) - writeFileSync(firstStart, "", "utf8") - assert.equal((await first.result).isPrimary, true) - - const secondStart = join(directory, "start-second") - const second = startElectionChild(directory, secondStart) - writeFileSync(secondStart, "", "utf8") - assert.equal((await second.result).isPrimary, false) - await stopElectionChildren([first]) - - const thirdStart = join(directory, "start-third") - const third = startElectionChild(directory, thirdStart) - writeFileSync(thirdStart, "", "utf8") - const thirdRole = await third.result - await stopElectionChildren([second, third]) - - assert.equal(thirdRole.isPrimary, false) -}) - -test("a primary lock with the current PID and an old token is reclaimed", (testContext) => { - const directory = withTempDirectory(testContext) - const primaryLockPath = join(directory, "client-state.primary.lock") - const registrationLockPath = join(directory, "client-state.registration.lock") - const owner: ProcessOwner = { pid: process.pid, runToken: "current-run" } - writeFileSync(primaryLockPath, JSON.stringify({ pid: process.pid, runToken: "old-run" }), "utf8") - - const election = electClientStateProcess(directory, owner, { primaryLockPath, registrationLockPath }) - - assert.equal(election.isPrimary, true) - removeRunningMarkerIfOwned(election.runningMarkerPath, owner) - removeProcessOwnerLockIfOwned(primaryLockPath, owner) -}) - -test("a malformed marker-backed lock cannot keep the outer recovery loop alive", (testContext) => { - const directory = withTempDirectory(testContext) - const primaryLockPath = join(directory, "client-state.primary.lock") - const registrationLockPath = join(directory, "client-state.registration.lock") - const owner: ProcessOwner = { pid: process.pid, runToken: "bounded-recovery" } - writeFileSync(registrationLockPath, "malformed", "utf8") - - const startedAt = Date.now() - const election = electClientStateProcess( - directory, - owner, - { primaryLockPath, registrationLockPath }, - () => {}, - () => true, - 0, - ) - - assert.ok(Date.now() - startedAt < 500) - assert.equal(election.isPrimary, false) - removeRunningMarkerIfOwned(election.runningMarkerPath, owner) -}) - -test("a live registration-lock owner with a matching marker remains conservative", (testContext) => { - const directory = withTempDirectory(testContext) - const primaryLockPath = join(directory, "client-state.primary.lock") - const registrationLockPath = join(directory, "client-state.registration.lock") - const stuckOwner: ProcessOwner = { pid: 8181, runToken: "stuck-registration" } - const currentOwner: ProcessOwner = { pid: 9191, runToken: "current-registration" } - const boundedTestWaitMs = 30 - writeFileSync(registrationLockPath, JSON.stringify(stuckOwner), "utf8") - createRunningMarker(directory, stuckOwner) - - const startedAt = Date.now() - const election = electClientStateProcess( - directory, - currentOwner, - { primaryLockPath, registrationLockPath }, - () => {}, - (pid) => pid === stuckOwner.pid, - boundedTestWaitMs, - ) - const elapsedMs = Date.now() - startedAt - - assert.equal(election.isPrimary, false) - assert.ok(elapsedMs >= boundedTestWaitMs - 5, `registration wait ended too early after ${elapsedMs}ms`) - assert.ok(elapsedMs < 500, `registration wait was not bounded: ${elapsedMs}ms`) - assert.equal(existsSync(election.runningMarkerPath), true) - assert.deepEqual(JSON.parse(readFileSync(registrationLockPath, "utf8")), stuckOwner) +test("lock recovery handles PID reuse, malformed files, and verified live owners", async (t) => { + const cases = [ + { name: "same PID old token", owner: { pid: process.pid, runToken: "new" }, file: { pid: process.pid, runToken: "old" }, lock: "primary", alive: () => true, expected: true }, + { name: "malformed registration", owner: { pid: 21, runToken: "new" }, file: "malformed", lock: "registration", alive: () => true, expected: false }, + { name: "live registration marker", owner: { pid: 22, runToken: "new" }, file: { pid: 23, runToken: "live" }, lock: "registration", alive: (pid: number) => pid === 23, marker: true, expected: false }, + { name: "identity-verified registration", owner: { pid: 24, runToken: "new" }, file: { pid: 25, runToken: "live", processStartIdentity: "start" }, lock: "registration", alive: (pid: number) => pid === 25, identity: () => "start", expected: false }, + { name: "live primary marker", owner: { pid: 26, runToken: "new" }, file: { pid: 27, runToken: "live" }, lock: "primary", alive: (pid: number) => pid === 27, marker: true, expected: false }, + ] as const + for (const value of cases) await t.test(value.name, (st) => { + const directory = temp(st), primary = join(directory, "client-state.primary.lock"), registration = join(directory, "client-state.registration.lock") + const path = value.lock === "primary" ? primary : registration + writeFileSync(path, typeof value.file === "string" ? value.file : JSON.stringify(value.file)) + if ("marker" in value && value.marker && typeof value.file !== "string") createRunningMarker(directory, value.file) + const started = Date.now() + const elected = electClientStateProcess(directory, value.owner, { primaryLockPath: primary, registrationLockPath: registration }, () => {}, value.alive, 30, () => {}, "identity" in value ? value.identity : undefined) + assert.equal(elected, value.expected) + assert.ok(Date.now() - started < 500) + if (!value.expected) assert.deepEqual(readFileSync(path, "utf8"), typeof value.file === "string" ? value.file : JSON.stringify(value.file)) + removeRunningMarkerIfOwned(getRunningMarkerPath(directory, value.owner), value.owner) + removeProcessOwnerLockIfOwned(primary, value.owner) + }) assert.equal(REGISTRATION_LOCK_WAIT_MS, 1_000) - removeRunningMarkerIfOwned(election.runningMarkerPath, currentOwner) -}) - -test("a verified live registration owner is not stolen before publishing its marker", (testContext) => { - const directory = withTempDirectory(testContext) - const primaryLockPath = join(directory, "client-state.primary.lock") - const registrationLockPath = join(directory, "client-state.registration.lock") - const registeringOwner: ProcessOwner = { - pid: 8282, - runToken: "registering-process", - processStartIdentity: "start:registering", - } - const currentOwner: ProcessOwner = { pid: 9292, runToken: "current-process" } - writeFileSync(registrationLockPath, JSON.stringify(registeringOwner), "utf8") - - const election = electClientStateProcess( - directory, - currentOwner, - { primaryLockPath, registrationLockPath }, - () => {}, - (pid) => pid === registeringOwner.pid, - 30, - () => {}, - (pid) => pid === registeringOwner.pid ? registeringOwner.processStartIdentity : undefined, - ) - - assert.equal(election.isPrimary, false) - assert.deepEqual(JSON.parse(readFileSync(registrationLockPath, "utf8")), registeringOwner) - removeRunningMarkerIfOwned(election.runningMarkerPath, currentOwner) -}) - -test("a live primary-lock owner with a matching marker remains conservative", (testContext) => { - const directory = withTempDirectory(testContext) - const primaryLockPath = join(directory, "client-state.primary.lock") - const registrationLockPath = join(directory, "client-state.registration.lock") - const primaryOwner: ProcessOwner = { pid: 7171, runToken: "live-primary" } - const currentOwner: ProcessOwner = { pid: 8181, runToken: "current-process" } - const boundedTestWaitMs = 30 - writeFileSync(primaryLockPath, JSON.stringify(primaryOwner), "utf8") - createRunningMarker(directory, primaryOwner) - - const startedAt = Date.now() - const election = electClientStateProcess( - directory, - currentOwner, - { primaryLockPath, registrationLockPath }, - () => {}, - (pid) => pid === primaryOwner.pid, - boundedTestWaitMs, - ) - const elapsedMs = Date.now() - startedAt - - assert.equal(election.isPrimary, false) - assert.ok(elapsedMs >= boundedTestWaitMs - 5, `primary wait ended too early after ${elapsedMs}ms`) - assert.ok(elapsedMs < 500, `primary wait was not bounded: ${elapsedMs}ms`) - assert.deepEqual(JSON.parse(readFileSync(primaryLockPath, "utf8")), primaryOwner) - removeRunningMarkerIfOwned(election.runningMarkerPath, currentOwner) }) diff --git a/packages/electron-app/electron/main/client-state-process.ts b/packages/electron-app/electron/main/client-state-process.ts index 55003374..6874dde0 100644 --- a/packages/electron-app/electron/main/client-state-process.ts +++ b/packages/electron-app/electron/main/client-state-process.ts @@ -1,7 +1,9 @@ -import { closeSync, openSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from "node:fs" +import { closeSync, fsyncSync, openSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from "node:fs" import { basename, join } from "node:path" import { + type ExpectedProcessLookup, getProcessStartIdentity, + isExpectedTauriProcess, type ProcessStartIdentityLookup, } from "./client-state-process-identity" @@ -24,11 +26,6 @@ export interface ClientStateElectionPaths { registrationLockPath: string } -export interface ClientStateElectionResult { - isPrimary: boolean - runningMarkerPath: string -} - interface ProcessOwnerLockAcquisition { acquired: boolean liveOwner?: { @@ -96,6 +93,31 @@ export function isPidAlive(pid: number): boolean { } } +export function hasLiveTauriClient( + tauriDataPath: string, + pidAlive: (pid: number) => boolean = isPidAlive, + processStartIdentity: ProcessStartIdentityLookup = getProcessStartIdentity, + expectedProcess: ExpectedProcessLookup = isExpectedTauriProcess, + upgradedParticipants: readonly ProcessOwner[] = [], +): boolean { + let entries: string[] + try { + entries = readdirSync(tauriDataPath) + } catch (error) { + if (hasErrorCode(error, "ENOENT")) return false + throw error + } + return entries.some((name) => { + const match = /^client-state\.running\.(\d+)\..+\.lock$/.exec(name) + if (!match) return false + const pid = Number(match[1]) + if (!Number.isInteger(pid) || pid <= 0 || !pidAlive(pid)) return false + const liveIdentity = processStartIdentity(pid) + if (liveIdentity && upgradedParticipants.some((owner) => owner.pid === pid && owner.processStartIdentity === liveIdentity)) return false + return expectedProcess(pid) !== false + }) +} + export function classifyRunningMarker( markerOwner: ProcessOwner, currentOwner: ProcessOwner, @@ -145,28 +167,26 @@ export function createRunningMarker( primaryOwner?: ProcessOwner, ): string { const markerPath = getRunningMarkerPath(userDataPath, owner) + publishProcessFile(markerPath, JSON.stringify(primaryOwner ? { ...owner, primaryOwner } : owner)) + return markerPath +} + +function publishProcessFile(path: string, contents: string): void { let descriptor: number | undefined - let created = false try { - descriptor = openSync(markerPath, "wx", 0o600) - created = true - writeFileSync(descriptor, JSON.stringify(primaryOwner ? { ...owner, primaryOwner } : owner), "utf8") + descriptor = openSync(path, "wx", 0o600) + writeFileSync(descriptor, contents, "utf8") + try { + fsyncSync(descriptor) + } catch (error) { + if (!["EINVAL", "ENOTSUP", "ENOSYS"].some((code) => hasErrorCode(error, code))) throw error + } closeSync(descriptor) - return markerPath + descriptor = undefined } catch (error) { if (descriptor !== undefined) { - try { - closeSync(descriptor) - } catch { - // Ignore cleanup errors after a failed marker write. - } - } - if (created) { - try { - unlinkSync(markerPath) - } catch { - // The unique run-token path cannot be owned by another process. - } + try { closeSync(descriptor) } catch {} + try { unlinkSync(path) } catch {} } throw error } @@ -187,10 +207,28 @@ function removeFileIfUnchanged(path: string, observed: string): boolean { } } +function readFileIfExists(path: string): string | undefined { + try { + return readFileSync(path, "utf8") + } catch (error) { + if (hasErrorCode(error, "ENOENT")) return undefined + throw error + } +} + function waitForLockRetry(delayMs = LOCK_RETRY_DELAY_MS) { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, Math.max(0, delayMs)) } +function removeContendedFile(path: string, observed: string): void { + try { + removeFileIfUnchanged(path, observed) + } catch (error) { + if (!isTransientFileContentionError(error)) throw error + waitForLockRetry() + } +} + function acquireProcessOwnerLockWithStatus( path: string, owner: ProcessOwner, @@ -211,43 +249,17 @@ function acquireProcessOwnerLockWithStatus( return { acquired: false, liveOwner } } - let descriptor: number | undefined - let created = false try { - descriptor = openSync(path, "wx", 0o600) - created = true - writeFileSync(descriptor, serializedOwner, "utf8") - closeSync(descriptor) + publishProcessFile(path, serializedOwner) return { acquired: true } } catch (error) { - if (descriptor !== undefined) { - try { - closeSync(descriptor) - } catch { - // Ignore cleanup errors after a failed exclusive create. - } - } - if (created) { - try { - unlinkSync(path) - } catch { - // The lock remains conservative if cleanup fails. - } - } if (!hasErrorCode(error, "EEXIST")) { throw error } } - let observed: string - try { - observed = readFileSync(path, "utf8") - } catch (error) { - if (hasErrorCode(error, "ENOENT")) { - continue - } - throw error - } + const observed = readFileIfExists(path) + if (observed === undefined) continue const existingOwner = parseProcessOwner(observed) if (existingOwner) { @@ -270,60 +282,35 @@ function acquireProcessOwnerLockWithStatus( continue } - try { - removeFileIfUnchanged(path, observed) - } catch (error) { - if (!isTransientFileContentionError(error)) { - throw error - } - waitForLockRetry() - } + removeContendedFile(path, observed) } return { acquired: false, liveOwner } } -export function acquireProcessOwnerLock( - path: string, - owner: ProcessOwner, - waitForLiveOwner: boolean, - pidAlive: (pid: number) => boolean = isPidAlive, - liveOwnerWaitMs = REGISTRATION_LOCK_WAIT_MS, - processStartIdentity: ProcessStartIdentityLookup = getProcessStartIdentity, -): boolean { - return acquireProcessOwnerLockWithStatus( - path, - owner, - waitForLiveOwner, - pidAlive, - liveOwnerWaitMs, - processStartIdentity, - ).acquired +export function removeProcessOwnerLockIfOwned(path: string, owner: ProcessOwner): boolean { + const observed = readFileIfExists(path) + const current = observed === undefined ? undefined : parseProcessOwner(observed) + return Boolean(current && isSameProcessOwner(current, owner) && removeFileIfUnchanged(path, observed!)) } -export function removeProcessOwnerLockIfOwned(path: string, owner: ProcessOwner): boolean { +function releaseProcessOwnerLock( + path: string, + owner: ProcessOwner, + onWarning: (message: string, error: unknown) => void, + warning: string, +): void { try { - const observed = readFileSync(path, "utf8") - const current = parseProcessOwner(observed) - return Boolean(current && isSameProcessOwner(current, owner) && removeFileIfUnchanged(path, observed)) + removeProcessOwnerLockIfOwned(path, owner) } catch (error) { - if (hasErrorCode(error, "ENOENT")) { - return false - } - throw error + onWarning(warning, error) } } export function isProcessOwnerLockOwned(path: string, owner: ProcessOwner): boolean { - try { - const current = parseProcessOwner(readFileSync(path, "utf8")) - return Boolean(current && isSameProcessOwner(current, owner)) - } catch (error) { - if (hasErrorCode(error, "ENOENT")) { - return false - } - throw error - } + const value = readFileIfExists(path) + const current = value === undefined ? undefined : parseProcessOwner(value) + return Boolean(current && isSameProcessOwner(current, owner)) } export function removeRunningMarkerIfOwned(markerPath: string, owner: ProcessOwner): boolean { @@ -332,16 +319,9 @@ export function removeRunningMarkerIfOwned(markerPath: string, owner: ProcessOwn return false } - try { - const observed = readFileSync(markerPath, "utf8") - const storedOwner = parseProcessOwner(observed) - return Boolean(storedOwner && isSameProcessOwner(storedOwner, owner) && removeFileIfUnchanged(markerPath, observed)) - } catch (error) { - if (hasErrorCode(error, "ENOENT")) { - return false - } - throw error - } + const observed = readFileIfExists(markerPath) + const storedOwner = observed === undefined ? undefined : parseProcessOwner(observed) + return Boolean(storedOwner && isSameProcessOwner(storedOwner, owner) && removeFileIfUnchanged(markerPath, observed!)) } export function cleanStaleRunningMarkers( @@ -359,20 +339,17 @@ export function cleanStaleRunningMarkers( } const markerPath = join(userDataPath, filename) - let observed: string - try { - observed = readFileSync(markerPath, "utf8") - } catch (error) { - if (hasErrorCode(error, "ENOENT")) { - continue - } - throw error - } + const observed = readFileIfExists(markerPath) + if (observed === undefined) continue const storedOwner = parseProcessOwner(observed) if (storedOwner && !isSameProcessOwner(storedOwner, filenameOwner)) { - if (classifyRunningMarker(storedOwner, currentOwner, pidAlive, processStartIdentity) === "other-live") { + const storedStatus = classifyRunningMarker(storedOwner, currentOwner, pidAlive, processStartIdentity) + const filenameStatus = classifyRunningMarker(filenameOwner, currentOwner, pidAlive, processStartIdentity) + if (storedStatus === "other-live" || filenameStatus === "other-live") { hasOtherLiveProcess = true + } else { + removeFileIfUnchanged(markerPath, observed) } continue } @@ -402,20 +379,11 @@ function hasMatchingLiveRunningMarker( return false } - try { - const markerOwner = parseProcessOwner(readFileSync(getRunningMarkerPath(userDataPath, owner), "utf8")) - if (!markerOwner || !isSameProcessOwner(markerOwner, owner)) return false - if (markerOwner.processStartIdentity) { - const liveIdentity = processStartIdentity(markerOwner.pid) - if (liveIdentity && liveIdentity !== markerOwner.processStartIdentity) return false - } - return true - } catch (error) { - if (hasErrorCode(error, "ENOENT")) { - return false - } - throw error - } + const value = readFileIfExists(getRunningMarkerPath(userDataPath, owner)) + const markerOwner = value === undefined ? undefined : parseProcessOwner(value) + if (!markerOwner || !isSameProcessOwner(markerOwner, owner)) return false + const liveIdentity = markerOwner.processStartIdentity && processStartIdentity(markerOwner.pid) + return !liveIdentity || liveIdentity === markerOwner.processStartIdentity } function acquireMarkerBackedProcessOwnerLock( @@ -454,14 +422,7 @@ function acquireMarkerBackedProcessOwnerLock( )) { return acquisition } - try { - removeFileIfUnchanged(path, acquisition.liveOwner.observed) - } catch (error) { - if (!isTransientFileContentionError(error)) { - throw error - } - waitForLockRetry() - } + removeContendedFile(path, acquisition.liveOwner.observed) } return lastAcquisition } @@ -475,8 +436,7 @@ export function electClientStateProcess( registrationLockWaitMs = REGISTRATION_LOCK_WAIT_MS, onPrimaryLockAcquired: () => void = () => {}, processStartIdentity: ProcessStartIdentityLookup = getProcessStartIdentity, -): ClientStateElectionResult { - const runningMarkerPath = getRunningMarkerPath(userDataPath, owner) +): boolean { let registrationAcquired = false let registeringOwner: ProcessOwner | undefined @@ -501,7 +461,7 @@ export function electClientStateProcess( } catch (error) { onWarning("failed to create running marker", error) } - return { isPrimary: false, runningMarkerPath } + return false } try { @@ -531,11 +491,7 @@ export function electClientStateProcess( } } catch (error) { onWarning("failed to inspect running markers", error) - try { - removeProcessOwnerLockIfOwned(paths.primaryLockPath, owner) - } catch (releaseError) { - onWarning("failed to release primary lock", releaseError) - } + releaseProcessOwnerLock(paths.primaryLockPath, owner, onWarning, "failed to release primary lock") isPrimary = false } } @@ -544,22 +500,12 @@ export function electClientStateProcess( createRunningMarker(userDataPath, owner, acknowledgedPrimary) } catch (error) { onWarning("failed to create running marker", error) - try { - if (isPrimary) { - removeProcessOwnerLockIfOwned(paths.primaryLockPath, owner) - } - } catch (releaseError) { - onWarning("failed to release primary lock", releaseError) - } - return { isPrimary: false, runningMarkerPath } + if (isPrimary) releaseProcessOwnerLock(paths.primaryLockPath, owner, onWarning, "failed to release primary lock") + return false } - return { isPrimary, runningMarkerPath } + return isPrimary } finally { - try { - removeProcessOwnerLockIfOwned(paths.registrationLockPath, owner) - } catch (error) { - onWarning("failed to release registration lock", error) - } + releaseProcessOwnerLock(paths.registrationLockPath, owner, onWarning, "failed to release registration lock") } } diff --git a/packages/electron-app/electron/main/client-state.test.ts b/packages/electron-app/electron/main/client-state.test.ts index 9523cf6e..18ee8d3a 100644 --- a/packages/electron-app/electron/main/client-state.test.ts +++ b/packages/electron-app/electron/main/client-state.test.ts @@ -1,289 +1,282 @@ import assert from "node:assert/strict" -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs" +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs" import { writeFile } from "node:fs/promises" import { tmpdir } from "node:os" import { join } from "node:path" import test from "node:test" import { ClientStateManager, type ClientStateWriter } from "./client-state" -function createManager(testContext: test.TestContext) { - const directory = mkdtempSync(join(tmpdir(), "codenomad-client-state-manager-")) - let failWrites = false - let writeCount = 0 - const writer: ClientStateWriter = async (temporaryPath, serializedState) => { - writeCount += 1 - if (failWrites) { - throw new Error("injected write failure") - } - await writeFile(temporaryPath, serializedState, "utf8") +function harness(t: test.TestContext, initial?: object) { + const directory = mkdtempSync(join(tmpdir(), "codenomad-state-")) + const statePath = join(directory, "client-state.json") + if (initial) writeFileSync(statePath, JSON.stringify(initial)) + let failing = false + let writes = 0 + const managers: ClientStateManager[] = [] + const create = (writer: ClientStateWriter = async (path, value) => { + writes++ + if (failing) throw new Error("injected write failure") + await writeFile(path, value, "utf8") + }, processOwner?: { pid: number; runToken: string; processStartIdentity: string }) => { + const manager = new ClientStateManager(directory, writer, { crossHostElectionDirectory: join(directory, "election"), processOwner }) + managers.push(manager) + return manager } - const manager = new ClientStateManager(directory, writer) - testContext.after(async () => { - await manager.drainAndReleasePrimary().catch(() => {}) + t.after(async () => { + await Promise.all(managers.map((manager) => manager.drainAndReleasePrimary().catch(() => {}))) rmSync(directory, { recursive: true, force: true }) }) - return { - directory, - manager, - failWrites: (fail: boolean) => { - failWrites = fail - }, - writeCount: () => writeCount, - } + return { create, directory, statePath, fail: (value: boolean) => { failing = value }, writes: () => writes } } -test("failed restore setting write rolls in-memory state back", async (testContext) => { - const harness = createManager(testContext) - await harness.manager.saveClientState({ kept: true }) - harness.failWrites(true) +test("renderer access is exclusive per document and resettable", async (t) => { + const manager = harness(t, { version: 1, restoreEnabled: true }).create() + assert.throws(() => manager.claimClientStateAccess(""), /nonempty string/) + assert.throws(() => manager.assertRendererAccessToken("unclaimed"), /has not been claimed/) + assert.equal(manager.claimClientStateAccess("document-1"), true) + assert.equal(manager.claimClientStateAccess("document-1"), true) + assert.throws(() => manager.claimClientStateAccess("document-2"), /does not match/) + manager.assertRendererAccessToken("document-1") + assert.equal(await manager.saveClientState({ saved: true }), true) + manager.resetRendererAccessToken() + assert.throws(() => manager.assertRendererAccessToken("document-1"), /has not been claimed/) + assert.equal(manager.claimClientStateAccess("document-2"), true) +}) - await assert.rejects(harness.manager.setRestoreEnabled(false), /injected write failure/) +test("restore defaults on unless explicitly disabled", (t) => { + assert.equal(harness(t).create().loadClientState().restoreEnabled, true) + assert.equal(harness(t, { version: 1 }).create().loadClientState().restoreEnabled, true) + assert.equal(harness(t, { version: 1, restoreEnabled: false }).create().loadClientState().restoreEnabled, false) +}) - assert.deepEqual(harness.manager.loadClientState(), { - isPrimary: true, - restoreEnabled: true, - snapshot: { kept: true }, +test("cross-host ownership is required in addition to each host-local election", async (t) => { + const root = mkdtempSync(join(tmpdir(), "codenomad-cross-host-state-")) + const electronDirectory = join(root, "electron"), tauriDirectory = join(root, "tauri"), election = join(root, "election") + t.after(() => rmSync(root, { recursive: true, force: true })) + const identities = new Map([[8101, "tauri-start"], [8102, "electron-start"], [8103, "successor-start"]]) + const crossHostDependencies = { pidAlive: (pid: number) => identities.has(pid), processStartIdentity: (pid: number) => identities.get(pid) } + const primary = new ClientStateManager(tauriDirectory, undefined, { + crossHostElectionDirectory: election, + crossHostDependencies, + processOwner: { pid: 8101, runToken: "tauri", processStartIdentity: "tauri-start" }, }) - const persisted = JSON.parse(readFileSync(join(harness.directory, "client-state.json"), "utf8")) - assert.equal(persisted.restoreEnabled, true) -}) - -test("failed clear restores snapshot and suppression state", async (testContext) => { - const harness = createManager(testContext) - await harness.manager.saveClientState({ kept: true }) - harness.failWrites(true) - - await assert.rejects(harness.manager.clearClientState(), /injected write failure/) - - harness.failWrites(false) - await harness.manager.setRestoreEnabled(true) - assert.deepEqual(harness.manager.loadClientState().snapshot, { kept: true }) - - const writesBeforeSave = harness.writeCount() - await harness.manager.saveClientState({ replacement: true }) - assert.equal(harness.writeCount(), writesBeforeSave + 1) - assert.deepEqual(harness.manager.loadClientState().snapshot, { replacement: true }) -}) - -test("successful clear keeps later snapshot saves as suppressed no-ops", async (testContext) => { - const harness = createManager(testContext) - await harness.manager.saveClientState({ kept: true }) - await harness.manager.clearClientState() - const writesAfterClear = harness.writeCount() - - assert.equal(await harness.manager.saveClientState({ ignored: true }), true) - assert.equal(harness.writeCount(), writesAfterClear) - assert.equal(harness.manager.loadClientState().snapshot, null) -}) - -test("failed restore re-enable keeps successful clear suppression active", async (testContext) => { - const harness = createManager(testContext) - await harness.manager.saveClientState({ cleared: true }) - await harness.manager.clearClientState() - harness.failWrites(true) - - await assert.rejects(harness.manager.setRestoreEnabled(true), /injected write failure/) - const writesAfterFailure = harness.writeCount() - - assert.equal(await harness.manager.saveClientState({ ignored: true }), true) - assert.equal(harness.writeCount(), writesAfterFailure) - assert.equal(harness.manager.loadClientState().snapshot, null) -}) - -test("disabling restore atomically removes snapshot and window state", async (testContext) => { - const harness = createManager(testContext) - await harness.manager.saveClientState({ kept: true }) - await harness.manager.saveWindowState({ - bounds: { x: 10, y: 20, width: 1200, height: 800 }, - maximized: true, - fullscreen: false, - zoomFactor: 1.25, - }) - const writesBeforeDisable = harness.writeCount() - - assert.equal(await harness.manager.setRestoreEnabled(false), true) - - assert.equal(harness.writeCount(), writesBeforeDisable + 1) - assert.deepEqual(harness.manager.loadClientState(), { - isPrimary: true, - restoreEnabled: false, - snapshot: null, - }) - assert.equal(harness.manager.getWindowState(), undefined) - assert.deepEqual(JSON.parse(readFileSync(join(harness.directory, "client-state.json"), "utf8")), { + mkdirSync(electronDirectory) + writeFileSync(join(electronDirectory, "client-state.json"), JSON.stringify({ version: 1, - restoreEnabled: false, + restoreEnabled: true, + snapshot: { tabs: ["must-not-restore"] }, + })) + const secondary = new ClientStateManager(electronDirectory, undefined, { + crossHostElectionDirectory: election, + crossHostDependencies, + processOwner: { pid: 8102, runToken: "electron", processStartIdentity: "electron-start" }, }) + assert.deepEqual(secondary.loadClientState(), { isPrimary: false, restoreEnabled: false, snapshot: null }) + await secondary.drainAndReleasePrimary() + + await primary.drainAndReleasePrimary() + identities.delete(8101); identities.delete(8102) + const successor = new ClientStateManager(electronDirectory, undefined, { + crossHostElectionDirectory: election, + crossHostDependencies, + processOwner: { pid: 8103, runToken: "successor", processStartIdentity: "successor-start" }, + }) + assert.equal(successor.isPrimary, true) + await successor.drainAndReleasePrimary() }) -test("restore-disabled state remains write-suppressed after restart", async (testContext) => { - const directory = mkdtempSync(join(tmpdir(), "codenomad-client-state-disabled-restart-")) - const statePath = join(directory, "client-state.json") - const disabledState = JSON.stringify({ version: 1, restoreEnabled: false }) - writeFileSync(statePath, disabledState, "utf8") - const manager = new ClientStateManager(directory) - testContext.after(async () => { - await manager.drainAndReleasePrimary().catch(() => {}) - rmSync(directory, { recursive: true, force: true }) - }) - - assert.equal(await manager.saveWindowState({ - bounds: { x: 10, y: 20, width: 1200, height: 800 }, - maximized: false, - fullscreen: false, - zoomFactor: 1, - }), true) - assert.equal(readFileSync(statePath, "utf8"), disabledState) +test("first shared primary deterministically migrates legacy host envelopes", async (t) => { + const root = mkdtempSync(join(tmpdir(), "codenomad-migration-")) + const electron = join(root, "electron"), tauri = join(root, "tauri"), election = join(root, "shared", "election") + mkdirSync(electron, { recursive: true }); mkdirSync(tauri, { recursive: true }) + t.after(() => rmSync(root, { recursive: true, force: true })) + writeFileSync(join(electron, "client-state.json"), JSON.stringify({ version: 1, restoreEnabled: true, snapshot: { revision: 999, savedAt: 10, host: "electron" } })) + writeFileSync(join(tauri, "client-state.json"), JSON.stringify({ + version: 1, + restoreEnabled: true, + snapshot: { savedAt: 20, host: "tauri" }, + window: { bounds: { x: 10, y: 10, width: 1000, height: 700 }, maximized: false, fullscreen: false, zoomFactor: 1 }, + })) + const manager = new ClientStateManager(electron, undefined, { crossHostElectionDirectory: election, legacyTauriDataPath: tauri }) + assert.deepEqual(manager.loadClientState().snapshot, { savedAt: 20, host: "tauri" }) + assert.equal(manager.getWindowState(), undefined) + assert.equal(existsSync(join(electron, "client-state.json")), false) + assert.equal(existsSync(join(tauri, "client-state.json")), false) + await manager.drainAndReleasePrimary() }) -test("drain freezes new mutations and waits for every admitted write before release", async (testContext) => { - const directory = mkdtempSync(join(tmpdir(), "codenomad-client-state-drain-")) - let resolveWriterStarted!: () => void - let continueWriter!: () => void - const writerStarted = new Promise((resolve) => { - resolveWriterStarted = resolve - }) - const writerGate = new Promise((resolve) => { - continueWriter = resolve - }) - const writer: ClientStateWriter = async (temporaryPath, serializedState) => { - await writeFile(temporaryPath, serializedState, "utf8") - resolveWriterStarted() - await writerGate - } - const manager = new ClientStateManager(directory, writer) - testContext.after(async () => { - continueWriter() - await manager.drainAndReleasePrimary().catch(() => {}) - rmSync(directory, { recursive: true, force: true }) - }) +test("legacy migration prefers disabled and ignores malformed candidates", async (t) => { + const root = mkdtempSync(join(tmpdir(), "codenomad-migration-")) + const electron = join(root, "electron"), tauri = join(root, "tauri"), election = join(root, "shared", "election") + mkdirSync(electron, { recursive: true }); mkdirSync(tauri, { recursive: true }) + t.after(() => rmSync(root, { recursive: true, force: true })) + writeFileSync(join(electron, "client-state.json"), "malformed") + writeFileSync(join(tauri, "client-state.json"), JSON.stringify({ version: 1, restoreEnabled: false, snapshot: { savedAt: 1 } })) + const manager = new ClientStateManager(electron, undefined, { crossHostElectionDirectory: election, legacyTauriDataPath: tauri }) + assert.deepEqual(manager.loadClientState(), { isPrimary: true, restoreEnabled: false, snapshot: null }) + assert.equal(JSON.parse(readFileSync(join(root, "shared", "client-state.json"), "utf8")).restoreEnabled, false) + await manager.drainAndReleasePrimary() +}) - const admittedWrite = manager.saveClientState({ admitted: true }) - await writerStarted - let drainSettled = false - const drain = manager.drainAndReleasePrimary().finally(() => { - drainSettled = true +test("legacy migration does not resurrect a snapshot after clear", async (t) => { + const root = mkdtempSync(join(tmpdir(), "codenomad-migration-")) + const electron = join(root, "electron"), tauri = join(root, "tauri"), election = join(root, "shared", "election") + mkdirSync(electron, { recursive: true }); mkdirSync(tauri, { recursive: true }) + t.after(() => rmSync(root, { recursive: true, force: true })) + writeFileSync(join(electron, "client-state.json"), JSON.stringify({ version: 1, restoreEnabled: true })) + writeFileSync(join(tauri, "client-state.json"), JSON.stringify({ version: 1, restoreEnabled: true, snapshot: { savedAt: 20 } })) + const manager = new ClientStateManager(electron, undefined, { crossHostElectionDirectory: election, legacyTauriDataPath: tauri }) + assert.equal(manager.loadClientState().snapshot, null) + await manager.drainAndReleasePrimary() +}) + +test("legacy cleanup failure cannot abort startup after shared state replacement", async (t) => { + const root = mkdtempSync(join(tmpdir(), "codenomad-migration-")) + const electron = join(root, "electron"), shared = join(root, "shared"), election = join(shared, "election") + mkdirSync(electron, { recursive: true }) + t.after(() => rmSync(root, { recursive: true, force: true })) + writeFileSync(join(electron, "client-state.json"), JSON.stringify({ version: 1, restoreEnabled: true, snapshot: { savedAt: 10 } })) + + const manager = new ClientStateManager(electron, undefined, { + crossHostElectionDirectory: election, + removeLegacyState: () => { throw new Error("injected cleanup failure") }, }) + assert.deepEqual(manager.loadClientState().snapshot, { savedAt: 10 }) + assert.equal(existsSync(join(shared, "client-state.json")), true) + await manager.drainAndReleasePrimary() +}) - await assert.rejects(manager.saveClientState({ tooLate: true }), /frozen for shutdown/) - await new Promise((resolve) => setImmediate(resolve)) - assert.equal(drainSettled, false) - assert.equal(manager.isPrimary, true) - - continueWriter() - await admittedWrite - await drain - +test("ownership loss immediately disables restore reads and mutations", async (t) => { + const h = harness(t, { + version: 1, + restoreEnabled: true, + snapshot: { tabs: ["must-stop"] }, + window: { width: 900, height: 700 }, + }) + const manager = h.create() + writeFileSync(join(h.directory, "election", "primary.owner.json", "owner.json"), "malformed") assert.equal(manager.isPrimary, false) - assert.deepEqual(JSON.parse(readFileSync(join(directory, "client-state.json"), "utf8")).snapshot, { - admitted: true, - }) + assert.deepEqual(manager.loadClientState(), { isPrimary: false, restoreEnabled: false, snapshot: null }) + assert.equal(manager.getWindowState(), undefined) + assert.equal(await manager.saveClientState({ ignored: true }), false) }) -test("a delayed old writer cannot replace state after a successor takes ownership", async (testContext) => { - const directory = mkdtempSync(join(tmpdir(), "codenomad-client-state-owner-race-")) - let resolveWriterStarted!: () => void - let continueWriter!: () => void - const writerStarted = new Promise((resolve) => { - resolveWriterStarted = resolve - }) - const writerGate = new Promise((resolve) => { - continueWriter = resolve - }) - const oldManager = new ClientStateManager(directory, async (temporaryPath, serializedState) => { - await writeFile(temporaryPath, serializedState, "utf8") - resolveWriterStarted() - await writerGate - }) - let successor: ClientStateManager | undefined - testContext.after(async () => { - continueWriter() - await oldManager.drainAndReleasePrimary().catch(() => {}) - await successor?.drainAndReleasePrimary().catch(() => {}) - rmSync(directory, { recursive: true, force: true }) - }) +test("failed preference and clear writes roll memory and suppression back", async (t) => { + const h = harness(t, { version: 1, restoreEnabled: true }) + const manager = h.create() + await manager.saveClientState({ kept: true }) + h.fail(true) + await assert.rejects(manager.setRestoreEnabled(false), /injected write failure/) + assert.deepEqual(manager.loadClientState(), { isPrimary: true, restoreEnabled: true, snapshot: { kept: true } }) + assert.equal(JSON.parse(readFileSync(h.statePath, "utf8")).restoreEnabled, true) + await assert.rejects(manager.clearClientState(), /injected write failure/) + h.fail(false) + await manager.setRestoreEnabled(true) + const before = h.writes() + await manager.saveClientState({ replacement: true }) + assert.equal(h.writes(), before + 1) + assert.deepEqual(manager.loadClientState().snapshot, { replacement: true }) +}) - const staleWrite = oldManager.saveClientState({ stale: true }) - await writerStarted - const oldDrain = oldManager.drainAndReleasePrimary() +test("successful clear suppresses saves, including after failed re-enable", async (t) => { + const h = harness(t, { version: 1, restoreEnabled: true }) + const manager = h.create() + await manager.saveClientState({ kept: true }) + await manager.clearClientState() + h.fail(true) + await assert.rejects(manager.setRestoreEnabled(true), /injected write failure/) + const before = h.writes() + assert.equal(await manager.saveClientState({ ignored: true }), true) + assert.equal(h.writes(), before) + assert.equal(manager.loadClientState().snapshot, null) +}) - // A different run token with the same PID deterministically models PID reuse by a successor. - successor = new ClientStateManager(directory) +test("disabling restore atomically removes snapshot/window and survives restart", async (t) => { + const h = harness(t, { version: 1, restoreEnabled: true }) + const manager = h.create(undefined, { pid: process.pid, runToken: "before-restart", processStartIdentity: "old-start" }) + await manager.saveClientState({ kept: true }) + await manager.saveWindowState({ bounds: { x: 10, y: 20, width: 1200, height: 800 }, maximized: true, fullscreen: false, zoomFactor: 1.25 }) + const before = h.writes() + assert.equal(await manager.setRestoreEnabled(false), true) + assert.equal(h.writes(), before + 1) + assert.deepEqual(manager.loadClientState(), { isPrimary: true, restoreEnabled: false, snapshot: null }) + assert.equal(manager.getWindowState(), undefined) + const disabled = JSON.stringify({ version: 1, restoreEnabled: false }) + assert.equal(readFileSync(h.statePath, "utf8"), disabled) + await manager.drainAndReleasePrimary() + const restarted = h.create() + assert.equal(await restarted.saveWindowState({ bounds: { x: 0, y: 0, width: 800, height: 600 }, maximized: false, fullscreen: false, zoomFactor: 1 }), true) + assert.equal(readFileSync(h.statePath, "utf8"), disabled) +}) + +test("drain freezes mutations and waits for admitted writes", async (t) => { + const h = harness(t, { version: 1, restoreEnabled: true }) + let started!: () => void + let release!: () => void + const began = new Promise((resolve) => { started = resolve }) + const gate = new Promise((resolve) => { release = resolve }) + const manager = h.create(async (path, value) => { await writeFile(path, value); started(); await gate }) + const admitted = manager.saveClientState({ admitted: true }) + await began + let settled = false + const drain = manager.drainAndReleasePrimary().finally(() => { settled = true }) + await assert.rejects(manager.saveClientState({ late: true }), /frozen for shutdown/) + await new Promise((resolve) => setImmediate(resolve)) + assert.equal(settled, false) + assert.equal(manager.isPrimary, true) + release() + await Promise.all([admitted, drain]) + assert.equal(manager.isPrimary, false) + assert.deepEqual(JSON.parse(readFileSync(h.statePath, "utf8")).snapshot, { admitted: true }) +}) + +test("an old writer cannot replace a successor after PID reuse", async (t) => { + const h = harness(t, { version: 1, restoreEnabled: true }) + let started!: () => void + let release!: () => void + const began = new Promise((resolve) => { started = resolve }) + const gate = new Promise((resolve) => { release = resolve }) + const old = h.create( + async (path, value) => { await writeFile(path, value); started(); await gate }, + { pid: process.pid, runToken: "old-run", processStartIdentity: "old-start" }, + ) + const staleWrite = old.saveClientState({ stale: true }) + await began + const oldDrain = old.drainAndReleasePrimary() + const successor = h.create() assert.equal(successor.isPrimary, true) await successor.saveClientState({ successor: true }) - - continueWriter() + release() await assert.rejects(staleWrite, /ownership changed before atomic replacement/) await assert.rejects(oldDrain, /ownership changed before atomic replacement/) - assert.deepEqual(JSON.parse(readFileSync(join(directory, "client-state.json"), "utf8")).snapshot, { - successor: true, - }) + assert.deepEqual(JSON.parse(readFileSync(h.statePath, "utf8")).snapshot, { successor: true }) }) -test("future outer envelopes survive normal persistence and clear explicitly unblocks v1", async (testContext) => { - const directory = mkdtempSync(join(tmpdir(), "codenomad-client-state-future-")) - const statePath = join(directory, "client-state.json") - const futureEnvelope = { - version: 2, - restoreEnabled: false, - snapshot: { future: true }, - window: { futureShape: true }, - futurePreference: "preserve-me", - } - writeFileSync(statePath, JSON.stringify(futureEnvelope), "utf8") - const managers: ClientStateManager[] = [] - const manager = new ClientStateManager(directory) - managers.push(manager) - testContext.after(async () => { - await Promise.all(managers.map((activeManager) => activeManager.drainAndReleasePrimary().catch(() => {}))) - rmSync(directory, { recursive: true, force: true }) - }) - - assert.deepEqual(manager.loadClientState(), { isPrimary: true, restoreEnabled: true, snapshot: null }) +test("future envelopes are preserved until a successful explicit clear", async (t) => { + const future = { version: 7, restoreEnabled: false, snapshot: { future: true }, futurePreference: "keep" } + const h = harness(t, future) + const manager = h.create(undefined, { pid: process.pid, runToken: "future-before-restart", processStartIdentity: "old-start" }) + assert.deepEqual(manager.loadClientState(), { isPrimary: true, restoreEnabled: false, snapshot: null }) assert.equal(await manager.saveClientState({ ignored: true }), true) assert.equal(await manager.setRestoreEnabled(false), false) - assert.equal(await manager.setRestoreEnabled(true), false) - assert.equal( - await manager.saveWindowState({ - bounds: { x: 0, y: 0, width: 1000, height: 700 }, - maximized: false, - fullscreen: false, - zoomFactor: 1, - }), - true, - ) - assert.deepEqual(JSON.parse(readFileSync(statePath, "utf8")), futureEnvelope) - + assert.deepEqual(JSON.parse(readFileSync(h.statePath, "utf8")), future) await manager.drainAndReleasePrimary() - const restartedManager = new ClientStateManager(directory) - managers.push(restartedManager) - assert.deepEqual(restartedManager.loadClientState(), { isPrimary: true, restoreEnabled: true, snapshot: null }) - assert.deepEqual(JSON.parse(readFileSync(statePath, "utf8")), futureEnvelope) - - assert.equal(await restartedManager.clearClientState(), true) - assert.deepEqual(JSON.parse(readFileSync(statePath, "utf8")), { version: 1, restoreEnabled: true }) - assert.equal(await restartedManager.saveClientState({ nowSupported: true }), true) - assert.deepEqual(JSON.parse(readFileSync(statePath, "utf8")).snapshot, { nowSupported: true }) + const restarted = h.create() + assert.deepEqual(JSON.parse(readFileSync(h.statePath, "utf8")), future) + assert.equal(await restarted.clearClientState(), true) + assert.deepEqual(JSON.parse(readFileSync(h.statePath, "utf8")), { version: 1, restoreEnabled: false }) + assert.equal(await restarted.saveClientState({ supported: true }), true) + assert.deepEqual(JSON.parse(readFileSync(h.statePath, "utf8")).snapshot, { supported: true }) }) -test("failed future-envelope clear keeps normal persistence blocked", async (testContext) => { - const directory = mkdtempSync(join(tmpdir(), "codenomad-client-state-future-failure-")) - const statePath = join(directory, "client-state.json") - const serializedFutureEnvelope = JSON.stringify({ version: 7, future: true }) - writeFileSync(statePath, serializedFutureEnvelope, "utf8") +test("failed future-envelope clear leaves persistence blocked", async (t) => { + const future = { version: 7, future: true } + const h = harness(t, future) let writes = 0 - const manager = new ClientStateManager(directory, async () => { - writes += 1 - throw new Error("injected future clear failure") - }) - testContext.after(async () => { - await manager.drainAndReleasePrimary().catch(() => {}) - rmSync(directory, { recursive: true, force: true }) - }) - - await assert.rejects(manager.clearClientState(), /injected future clear failure/) + const manager = h.create(async () => { writes++; throw new Error("clear failed") }) + await assert.rejects(manager.clearClientState(), /clear failed/) assert.equal(await manager.setRestoreEnabled(false), false) assert.equal(await manager.saveClientState({ ignored: true }), true) assert.equal(writes, 1) - assert.equal(readFileSync(statePath, "utf8"), serializedFutureEnvelope) + assert.deepEqual(JSON.parse(readFileSync(h.statePath, "utf8")), future) }) diff --git a/packages/electron-app/electron/main/client-state.ts b/packages/electron-app/electron/main/client-state.ts index c710101d..98b09320 100644 --- a/packages/electron-app/electron/main/client-state.ts +++ b/packages/electron-app/electron/main/client-state.ts @@ -1,10 +1,11 @@ import { randomUUID } from "node:crypto" -import { mkdirSync, readFileSync } from "node:fs" -import { rename, rm, writeFile } from "node:fs/promises" -import { join } from "node:path" +import { closeSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs" +import { open, rename, rm } from "node:fs/promises" +import { dirname, join } from "node:path" import { electClientStateProcess, getRunningMarkerPath, + hasLiveTauriClient, hasErrorCode, isProcessOwnerLockOwned, removeProcessOwnerLockIfOwned, @@ -12,6 +13,14 @@ import { removeRunningMarkerIfOwned, } from "./client-state-process" import { getProcessStartIdentity } from "./client-state-process-identity" +import { + CrossHostRegistration, + crossHostParticipants, + resolveCrossHostElectionDirectory, + resolveCrossHostStatePath, + resolveLegacyTauriDataDirectory, + type CrossHostLeaseDependencies, +} from "./client-state-cross-host" import { normalizeNativeWindowState } from "./window-state" const CLIENT_STATE_VERSION = 1 @@ -53,8 +62,22 @@ export type ClientStateWriter = ( serializedState: string, ) => Promise +interface ClientStateManagerOptions { + crossHostElectionDirectory?: string + crossHostDependencies?: CrossHostLeaseDependencies + legacyTauriDataPath?: string | null + processOwner?: ProcessOwner + removeLegacyState?(path: string): void +} + async function writeClientStateTemporary(temporaryPath: string, serializedState: string): Promise { - await writeFile(temporaryPath, serializedState, { encoding: "utf8", mode: 0o600 }) + const file = await open(temporaryPath, "w", 0o600) + try { + await file.writeFile(serializedState, "utf8") + await file.sync() + } finally { + await file.close() + } } interface ParsedClientState { @@ -67,7 +90,7 @@ function parseClientState(value: string): ParsedClientState { try { const candidate = JSON.parse(value) as Record if (candidate && typeof candidate.version === "number" && candidate.version > CLIENT_STATE_VERSION) { - return { state: defaults, unsupportedFutureEnvelope: true } + return { state: { ...defaults, restoreEnabled: false }, unsupportedFutureEnvelope: true } } if (!candidate || candidate.version !== CLIENT_STATE_VERSION) { return { state: defaults, unsupportedFutureEnvelope: false } @@ -91,57 +114,134 @@ function parseClientState(value: string): ParsedClientState { } } +function legacyCandidate(path: string, host: "electron" | "tauri"): { host: string; state: PersistedClientState; savedAt: number; hasSnapshot: boolean } | undefined { + try { + const candidate = JSON.parse(readFileSync(path, "utf8")) as Record + if (!candidate || candidate.version !== CLIENT_STATE_VERSION) return undefined + const parsed = parseClientState(JSON.stringify(candidate)).state + delete parsed.window + const snapshot = candidate.snapshot as Record | undefined + const savedAt = typeof snapshot?.savedAt === "number" && Number.isFinite(snapshot.savedAt) ? snapshot.savedAt : -1 + return { host, state: parsed, savedAt, hasSnapshot: snapshot !== undefined } + } catch { + return undefined + } +} + +function isFutureLegacyCandidate(path: string): boolean { + try { + const candidate = JSON.parse(readFileSync(path, "utf8")) as Record + return typeof candidate?.version === "number" && candidate.version > CLIENT_STATE_VERSION + } catch { + return false + } +} + export class ClientStateManager { private readonly userDataPath: string private readonly statePath: string private readonly lockPath: string - private readonly registrationLockPath: string - private readonly owner: ProcessOwner = { - pid: process.pid, - runToken: randomUUID(), - processStartIdentity: getProcessStartIdentity(process.pid), - } - private readonly runningMarkerPath: string + private readonly legacyTauriDataPath: string | null + private readonly owner: ProcessOwner private state: PersistedClientState = { version: CLIENT_STATE_VERSION, restoreEnabled: true } private writeQueue: Promise = Promise.resolve() private drainAndReleasePromise: Promise | undefined + private crossHostRegistration: CrossHostRegistration | undefined private primary = false private persistenceSuppressed = false private unsupportedFutureEnvelope = false - private ownershipGeneration = 1 - private frozenGeneration: number | undefined + private frozen = false private rendererAccessToken: string | undefined - constructor(userDataPath: string, private readonly writeState: ClientStateWriter = writeClientStateTemporary) { + constructor( + userDataPath: string, + private readonly writeState: ClientStateWriter = writeClientStateTemporary, + options?: ClientStateManagerOptions, + ) { + this.owner = options?.processOwner ?? { + pid: process.pid, + runToken: randomUUID(), + processStartIdentity: getProcessStartIdentity(process.pid), + } mkdirSync(userDataPath, { recursive: true }) this.userDataPath = userDataPath - this.statePath = join(userDataPath, CLIENT_STATE_FILENAME) + const crossHostElectionDirectory = options?.crossHostElectionDirectory ?? resolveCrossHostElectionDirectory() + this.statePath = options?.crossHostElectionDirectory + ? join(dirname(crossHostElectionDirectory), CLIENT_STATE_FILENAME) + : resolveCrossHostStatePath() + mkdirSync(dirname(this.statePath), { recursive: true }) this.lockPath = join(userDataPath, PRIMARY_LOCK_FILENAME) - this.registrationLockPath = join(userDataPath, REGISTRATION_LOCK_FILENAME) - this.runningMarkerPath = getRunningMarkerPath(userDataPath, this.owner) + const registrationLockPath = join(userDataPath, REGISTRATION_LOCK_FILENAME) const election = electClientStateProcess( userDataPath, this.owner, - { primaryLockPath: this.lockPath, registrationLockPath: this.registrationLockPath }, + { primaryLockPath: this.lockPath, registrationLockPath }, (message, error) => console.warn(`[client-state] ${message}`, error), ) - this.primary = election.isPrimary - if (this.primary) { + const legacyTauriDataPath = options?.legacyTauriDataPath === undefined + ? (options?.crossHostElectionDirectory ? null : resolveLegacyTauriDataDirectory()) + : options.legacyTauriDataPath + this.legacyTauriDataPath = legacyTauriDataPath + this.primary = election + try { + this.crossHostRegistration = CrossHostRegistration.register( + crossHostElectionDirectory, + this.owner, + () => { + if (!this.primary || !legacyTauriDataPath) return this.primary + try { + return !hasLiveTauriClient( + legacyTauriDataPath, + options?.crossHostDependencies?.pidAlive, + options?.crossHostDependencies?.processStartIdentity, + undefined, + crossHostParticipants(crossHostElectionDirectory), + ) + } catch (error) { + console.warn("[client-state] failed to inspect legacy Tauri process markers; continuing as secondary", error) + return false + } + }, + options?.crossHostDependencies, + ) + } catch (error) { + console.warn("[client-state] failed to register cross-host ownership", error) + } + if (!this.crossHostRegistration?.isPrimary) { + if (election) removeProcessOwnerLockIfOwned(this.lockPath, this.owner) + this.primary = false + } + if (this.isPrimary) { + const legacyPaths = [ + ["electron", join(userDataPath, CLIENT_STATE_FILENAME)], + ...(legacyTauriDataPath ? [["tauri", join(legacyTauriDataPath, CLIENT_STATE_FILENAME)] as const] : []), + ] as ReadonlyArray + this.migrateLegacyStateIfNeeded(legacyPaths, options?.removeLegacyState) + const futureLegacyBlocked = this.unsupportedFutureEnvelope const persisted = this.readState() - this.state = persisted.state + this.state = futureLegacyBlocked + ? { version: CLIENT_STATE_VERSION, restoreEnabled: false } + : persisted.state this.persistenceSuppressed = !this.state.restoreEnabled - this.unsupportedFutureEnvelope = persisted.unsupportedFutureEnvelope + this.unsupportedFutureEnvelope = futureLegacyBlocked || persisted.unsupportedFutureEnvelope } } get isPrimary(): boolean { - return this.primary + if (!this.primary || !this.crossHostRegistration?.isPrimary) return false + if (!this.legacyTauriDataPath) return true + try { + return !hasLiveTauriClient(this.legacyTauriDataPath, undefined, undefined, undefined, crossHostParticipants(this.crossHostRegistration.path)) + } catch (error) { + console.warn("[client-state] failed to recheck legacy Tauri process markers; ownership disabled", error) + return false + } } loadClientState(): ClientStateLoadResult { - if (!this.primary) { - return { isPrimary: false, restoreEnabled: true, snapshot: null } + if (!this.isPrimary) { + return { isPrimary: false, restoreEnabled: false, snapshot: null } } return { isPrimary: true, @@ -151,7 +251,7 @@ export class ClientStateManager { } getWindowState(): NativeWindowState | undefined { - return this.primary && !this.unsupportedFutureEnvelope && this.state.restoreEnabled ? this.state.window : undefined + return this.isPrimary && !this.unsupportedFutureEnvelope && this.state.restoreEnabled ? this.state.window : undefined } claimClientStateAccess(token: unknown): true { @@ -177,7 +277,7 @@ export class ClientStateManager { this.rendererAccessToken = undefined } - saveClientState(snapshot: unknown): Promise { + saveClientState(snapshot: unknown, rendererToken?: unknown): Promise { const disposition = this.getMutationDisposition() if (disposition) return disposition @@ -192,10 +292,10 @@ export class ClientStateManager { const normalizedSnapshot = JSON.parse(serialized) as unknown return this.mutateAndPersist((state) => { state.snapshot = normalizedSnapshot - }, true) + }, true, rendererToken) } - setRestoreEnabled(enabled: boolean): Promise { + setRestoreEnabled(enabled: boolean, rendererToken?: unknown): Promise { const disposition = this.getMutationDisposition(false) if (disposition) return disposition @@ -212,14 +312,14 @@ export class ClientStateManager { delete state.window this.persistenceSuppressed = true } - }) + }, false, rendererToken) } - clearClientState(): Promise { - if (!this.primary) { + clearClientState(rendererToken?: unknown): Promise { + if (!this.isPrimary) { return Promise.resolve(false) } - if (this.frozenGeneration !== undefined) { + if (this.frozen) { return Promise.reject(new Error("Client state persistence is frozen for shutdown")) } @@ -230,7 +330,7 @@ export class ClientStateManager { delete state.window this.unsupportedFutureEnvelope = false this.persistenceSuppressed = !clearingFutureEnvelope - }) + }, false, rendererToken) } saveWindowState(windowState: NativeWindowState): Promise { @@ -255,16 +355,11 @@ export class ClientStateManager { return this.drainAndReleasePromise } - this.frozenGeneration = this.ownershipGeneration - this.drainAndReleasePromise = (async () => { - try { - await this.writeQueue - } finally { - this.primary = false - this.ownershipGeneration += 1 - this.releaseOwnedProcessFiles() - } - })() + this.frozen = true + this.drainAndReleasePromise = this.writeQueue.finally(() => { + this.primary = false + this.releaseOwnedProcessFiles() + }) return this.drainAndReleasePromise } @@ -282,11 +377,59 @@ export class ClientStateManager { } } + private migrateLegacyStateIfNeeded( + paths: ReadonlyArray, + removeLegacyState = (path: string) => rmSync(path, { force: true }), + ): void { + try { + readFileSync(this.statePath) + return + } catch (error) { + if (!hasErrorCode(error, "ENOENT")) return + } + if (paths.some(([, path]) => isFutureLegacyCandidate(path))) { + this.unsupportedFutureEnvelope = true + return + } + const winner = paths + .map(([host, path]) => legacyCandidate(path, host)) + .filter((candidate): candidate is NonNullable => Boolean(candidate)) + .sort((left, right) => + Number(left.state.restoreEnabled) - Number(right.state.restoreEnabled) || + Number(left.hasSnapshot) - Number(right.hasSnapshot) || + right.savedAt - left.savedAt || + right.host.localeCompare(left.host), + )[0] + if (!winner) return + + const temporaryPath = join(dirname(this.statePath), `.${CLIENT_STATE_FILENAME}.${this.owner.pid}.${this.owner.runToken}.migration.tmp`) + let descriptor: number | undefined + try { + descriptor = openSync(temporaryPath, "wx", 0o600) + writeFileSync(descriptor, JSON.stringify(winner.state), "utf8") + fsyncSync(descriptor) + closeSync(descriptor) + descriptor = undefined + this.assertReplacementAllowed() + renameSync(temporaryPath, this.statePath) + for (const [, path] of paths) { + try { + removeLegacyState(path) + } catch (error) { + console.warn(`[client-state] failed to remove migrated legacy state at ${path}`, error) + } + } + } finally { + if (descriptor !== undefined) closeSync(descriptor) + rm(temporaryPath, { force: true }).catch(() => {}) + } + } + private getMutationDisposition(futureEnvelopeResult = true): Promise | undefined { - if (!this.primary) { + if (!this.isPrimary) { return Promise.resolve(false) } - if (this.frozenGeneration !== undefined) { + if (this.frozen) { return Promise.reject(new Error("Client state persistence is frozen for shutdown")) } if (this.unsupportedFutureEnvelope) { @@ -298,9 +441,10 @@ export class ClientStateManager { private mutateAndPersist( mutate: (state: PersistedClientState) => void, skipWhenSuppressed = false, + rendererToken?: unknown, ): Promise { - const admittedGeneration = this.ownershipGeneration const operation = this.writeQueue.catch(() => {}).then(async () => { + if (rendererToken !== undefined) this.assertRendererAccessToken(rendererToken) if (skipWhenSuppressed && this.persistenceSuppressed) { return } @@ -310,7 +454,7 @@ export class ClientStateManager { const previousUnsupportedFutureEnvelope = this.unsupportedFutureEnvelope try { mutate(this.state) - await this.writeAtomically(JSON.stringify(this.state), admittedGeneration) + await this.writeAtomically(JSON.stringify(this.state), rendererToken) } catch (error) { this.state = previousState this.persistenceSuppressed = previousPersistenceSuppressed @@ -322,14 +466,14 @@ export class ClientStateManager { return operation.then(() => true) } - private async writeAtomically(serializedState: string, admittedGeneration: number): Promise { + private async writeAtomically(serializedState: string, rendererToken?: unknown): Promise { const temporaryPath = join( - this.userDataPath, + dirname(this.statePath), `.${CLIENT_STATE_FILENAME}.${this.owner.pid}.${this.owner.runToken}.tmp`, ) try { await this.writeState(temporaryPath, serializedState) - this.assertReplacementAllowed(admittedGeneration) + this.assertReplacementAllowed(rendererToken) await rename(temporaryPath, this.statePath) } catch (error) { await rm(temporaryPath, { force: true }).catch(() => {}) @@ -337,28 +481,25 @@ export class ClientStateManager { } } - private assertReplacementAllowed(admittedGeneration: number): void { - if ( - !this.primary || - admittedGeneration !== this.ownershipGeneration || - (this.frozenGeneration !== undefined && admittedGeneration !== this.frozenGeneration) || - !isProcessOwnerLockOwned(this.lockPath, this.owner) - ) { + private assertReplacementAllowed(rendererToken?: unknown): void { + if (rendererToken !== undefined) this.assertRendererAccessToken(rendererToken) + if (!this.isPrimary || !isProcessOwnerLockOwned(this.lockPath, this.owner)) { throw new Error("Client state ownership changed before atomic replacement") } } private releaseOwnedProcessFiles(): void { - try { - removeRunningMarkerIfOwned(this.runningMarkerPath, this.owner) - } catch (error) { - console.warn("[client-state] failed to remove running marker", error) - } - - try { - removeProcessOwnerLockIfOwned(this.lockPath, this.owner) - } catch (error) { - console.warn("[client-state] failed to release primary lock", error) + const releases: Array<[string, () => void]> = [ + ["remove running marker", () => { removeRunningMarkerIfOwned(getRunningMarkerPath(this.userDataPath, this.owner), this.owner) }], + ["release primary lock", () => { removeProcessOwnerLockIfOwned(this.lockPath, this.owner) }], + ["release cross-host registration", () => { this.crossHostRegistration?.release(); this.crossHostRegistration = undefined }], + ] + for (const [action, release] of releases) { + try { + release() + } catch (error) { + console.warn(`[client-state] failed to ${action}`, error) + } } } diff --git a/packages/electron-app/electron/main/ipc.ts b/packages/electron-app/electron/main/ipc.ts index 076ec8ca..6e664d90 100644 --- a/packages/electron-app/electron/main/ipc.ts +++ b/packages/electron-app/electron/main/ipc.ts @@ -41,8 +41,7 @@ export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessMan ipcMain.handle("cli:restart", async () => { const devMode = process.env.NODE_ENV === "development" - await cliManager.stop() - return cliManager.start({ dev: devMode }) + return cliManager.restart({ dev: devMode }) }) ipcMain.handle("dialog:open", async (_, request: DialogOpenRequest): Promise => { diff --git a/packages/electron-app/electron/main/main-window-close.test.ts b/packages/electron-app/electron/main/main-window-close.test.ts deleted file mode 100644 index c1bcf440..00000000 --- a/packages/electron-app/electron/main/main-window-close.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -import assert from "node:assert/strict" -import test from "node:test" -import { MainWindowCloseController } from "./main-window-close" - -test("a close-only request flushes renderer and native state before approving close", async () => { - const calls: string[] = [] - let controller: MainWindowCloseController - controller = new MainWindowCloseController({ - flushRenderer: async () => { - calls.push("renderer") - }, - flushNative: async () => { - calls.push("native") - }, - closeWindow: () => { - calls.push("close") - assert.equal(controller.handleClose().allow, true) - }, - reportError: () => assert.fail("close flow should not report an error"), - }) - - const decision = controller.handleClose() - assert.equal(decision.allow, false) - await decision.completion - assert.deepEqual(calls, ["renderer", "native", "close"]) -}) - -test("repeated close requests share one in-progress flush", async () => { - let releaseRenderer: (() => void) | undefined - let rendererFlushes = 0 - let nativeFlushes = 0 - let closes = 0 - const controller = new MainWindowCloseController({ - flushRenderer: () => { - rendererFlushes += 1 - return new Promise((resolve) => { - releaseRenderer = resolve - }) - }, - flushNative: async () => { - nativeFlushes += 1 - }, - closeWindow: () => { - closes += 1 - }, - reportError: () => assert.fail("close flow should not report an error"), - }) - - const first = controller.handleClose() - const second = controller.handleClose() - assert.equal(first.allow, false) - assert.equal(second.allow, false) - assert.equal(second.completion, first.completion) - assert.equal(rendererFlushes, 1) - - releaseRenderer?.() - await first.completion - assert.equal(nativeFlushes, 1) - assert.equal(closes, 1) -}) - -test("renderer flush failure still flushes native state and closes", async () => { - const errors: string[] = [] - let nativeFlushed = false - let closed = false - const controller = new MainWindowCloseController({ - flushRenderer: async () => { - throw new Error("timed out") - }, - flushNative: async () => { - nativeFlushed = true - }, - closeWindow: () => { - closed = true - }, - reportError: (stage) => errors.push(stage), - }) - - await controller.handleClose().completion - assert.equal(nativeFlushed, true) - assert.equal(closed, true) - assert.deepEqual(errors, ["renderer"]) -}) diff --git a/packages/electron-app/electron/main/main-window-close.ts b/packages/electron-app/electron/main/main-window-close.ts deleted file mode 100644 index acaf1a4a..00000000 --- a/packages/electron-app/electron/main/main-window-close.ts +++ /dev/null @@ -1,54 +0,0 @@ -export interface MainWindowCloseHooks { - flushRenderer(): Promise - flushNative(): Promise - closeWindow(): void - reportError(stage: "renderer" | "native" | "close", error: unknown): void -} - -export interface MainWindowCloseDecision { - allow: boolean - completion?: Promise -} - -export class MainWindowCloseController { - private closeApproved = false - private closeInProgress = false - private completion: Promise | undefined - - constructor(private readonly hooks: MainWindowCloseHooks) {} - - handleClose(): MainWindowCloseDecision { - if (this.closeApproved) { - return { allow: true } - } - if (!this.closeInProgress) { - this.closeInProgress = true - this.completion = this.flushAndClose() - } - return { allow: false, completion: this.completion } - } - - private async flushAndClose(): Promise { - try { - await this.hooks.flushRenderer() - } catch (error) { - this.hooks.reportError("renderer", error) - } - - try { - await this.hooks.flushNative() - } catch (error) { - this.hooks.reportError("native", error) - } - - this.closeApproved = true - try { - this.hooks.closeWindow() - } catch (error) { - this.closeApproved = false - this.closeInProgress = false - this.completion = undefined - this.hooks.reportError("close", error) - } - } -} diff --git a/packages/electron-app/electron/main/main.ts b/packages/electron-app/electron/main/main.ts index c599d55c..f90fb361 100644 --- a/packages/electron-app/electron/main/main.ts +++ b/packages/electron-app/electron/main/main.ts @@ -1,4 +1,4 @@ -import { app, BrowserView, BrowserWindow, nativeImage, screen, session, shell } from "electron" +import { app, BrowserView, BrowserWindow, ipcMain, nativeImage, screen, session, shell } from "electron" import http from "node:http" import https from "node:https" import { existsSync, mkdirSync, rmSync } from "fs" @@ -11,11 +11,13 @@ import { ClientStateLifecycle } from "./client-state-lifecycle" import { ClientStateNavigationController } from "./client-state-navigation" import { setupCliIPC } from "./ipc" import { configureMediaPermissionHandlers, isAllowedRendererOrigin } from "./permissions" +import { resolveConfiguredRendererOrigins } from "./renderer-origin" import { CliProcessManager } from "./process-manager" import { clampWindowBounds, DEFAULT_WINDOW_HEIGHT, DEFAULT_WINDOW_WIDTH, + installWindowZoomInput, restoreWindowState, WindowStateTracker, } from "./window-state" @@ -113,6 +115,12 @@ const clientStateLifecycle = new ClientStateLifecycle({ getAllowedRendererOrigins, isTrustedRendererOrigin: isAllowedRendererOrigin, }) +const bindClientStateWindow = setupClientStateIPC( + ipcMain, + clientStateManager, + () => mainWindow, + getAllowedRendererOrigins, +) if (isMac) { app.commandLine.appendSwitch("disable-spell-checking") @@ -173,19 +181,18 @@ function resolveLoadingFilePath() { return join(app.getAppPath(), "dist/renderer/loading.html") } -function loadLoadingScreen(window: BrowserWindow) { +async function loadLoadingScreen(window: BrowserWindow): Promise { const target = resolveLoadingTarget() - const loader = - target.type === "url" - ? window.loadURL(target.source) - : window.loadFile(target.source) - - return loader.catch((error) => { + try { + await (target.type === "url" ? window.loadURL(target.source) : window.loadFile(target.source)) + return true + } catch (error) { if (isIgnorableNavigationError(error)) { - return + return false } console.error("[cli] failed to load loading screen:", error) - }) + return false + } } function isIgnorableNavigationError(error: unknown): boolean { @@ -205,16 +212,11 @@ function getAllowedRendererOrigins(window?: BrowserWindow | null): string[] { origins.add(origin) } } - const rendererCandidates = [currentCliUrl, process.env.VITE_DEV_SERVER_URL, process.env.ELECTRON_RENDERER_URL] - for (const candidate of rendererCandidates) { - if (!candidate) { - continue - } - try { - origins.add(new URL(candidate).origin) - } catch (error) { - console.warn("[cli] failed to parse origin for", candidate, error) - } + for (const origin of resolveConfiguredRendererOrigins(currentCliUrl, app.isPackaged, [ + process.env.VITE_DEV_SERVER_URL, + process.env.ELECTRON_RENDERER_URL, + ])) { + origins.add(origin) } return Array.from(origins) } @@ -276,6 +278,21 @@ function setWindowAllowedOrigin(window: BrowserWindow, url: string) { } } +function stageWindowAllowedOrigin(window: BrowserWindow, url: string): () => void { + const previous = remoteWindowOrigins.get(window.id) + try { + const origins = new Set(previous) + origins.add(new URL(url).origin) + remoteWindowOrigins.set(window.id, origins) + } catch (error) { + console.warn("[cli] failed to stage allowed origin", url, error) + } + return () => { + if (previous) remoteWindowOrigins.set(window.id, previous) + else remoteWindowOrigins.delete(window.id) + } +} + function clearWindowAllowedOrigin(window: BrowserWindow) { remoteWindowOrigins.delete(window.id) } @@ -367,6 +384,7 @@ function createWindow() { mainWindow = new BrowserWindow({ width: restoredBounds?.width ?? DEFAULT_WINDOW_WIDTH, height: restoredBounds?.height ?? DEFAULT_WINDOW_HEIGHT, + useContentSize: true, ...(restoredBounds ? { x: restoredBounds.x, y: restoredBounds.y } : {}), minWidth: 800, minHeight: 600, @@ -376,20 +394,23 @@ function createWindow() { preload: getPreloadPath(), contextIsolation: true, nodeIntegration: false, + ...(savedWindowState ? { zoomFactor: savedWindowState.zoomFactor } : {}), spellcheck: !isMac, additionalArguments: ["--codenomad-window-context=local"], }, }) const window = mainWindow - const navigationController = new ClientStateNavigationController({ - clientStateManager, - getWindow: () => mainWindow, - isTrustedOrigin: (url) => isAllowedRendererOrigin(url, getAllowedRendererOrigins(window)), - reportFlushError: (error) => { - console.warn("[client-state] renderer pre-navigation flush failed; continuing navigation", error) + const navigationController = new ClientStateNavigationController( + window, + { + clientStateManager, + isTrustedOrigin: (url) => isAllowedRendererOrigin(url, getAllowedRendererOrigins(window)), + reportFlushError: (error) => { + console.warn("[client-state] renderer pre-navigation flush failed; continuing navigation", error) + }, }, - }) + ) mainNavigationController = navigationController let windowStateTracker: WindowStateTracker | null = null @@ -397,6 +418,10 @@ function createWindow() { restoreWindowState(window, savedWindowState, restoredBounds) windowStateTracker = new WindowStateTracker(window, clientStateManager, savedWindowState) } + installWindowZoomInput(window, (level) => { + if (windowStateTracker) windowStateTracker.setZoomLevel(level) + else window.webContents.setZoomLevel(level) + }) setupNavigationGuards(window, navigationController) @@ -422,7 +447,7 @@ function createWindow() { }, }) setupCliIPC(window, cliManager) - setupClientStateIPC(window, clientStateManager, () => getAllowedRendererOrigins(window)) + bindClientStateWindow(window) clientStateLifecycle.attachMainWindow(window, windowStateTracker) window.on("closed", () => { @@ -454,19 +479,18 @@ function showLoadingScreen(force = false) { } const window = mainWindow + const wasShowingLoadingScreen = showingLoadingScreen showingLoadingScreen = true destroyPreloadingView() - currentCliUrl = null pendingCliUrl = null - const navigate = (target: BrowserWindow) => { + void mainNavigationController?.navigate(async (target) => { + if (!(await loadLoadingScreen(target))) { + showingLoadingScreen = wasShowingLoadingScreen + return + } + currentCliUrl = null clearWindowAllowedOrigin(target) - return loadLoadingScreen(target) - } - if (mainNavigationController) { - void mainNavigationController.navigate(navigate) - } else { - navigate(window) - } + }) } function isBootstrapTokenUrl(url: string): boolean { @@ -539,21 +563,23 @@ function finalizeCliSwap(url: string) { return } - const window = mainWindow const navigate = async (target: BrowserWindow) => { + const rollbackOrigin = stageWindowAllowedOrigin(target, url) + try { + await target.loadURL(url) + } catch (error) { + rollbackOrigin() + throw error + } showingLoadingScreen = false currentCliUrl = url setWindowAllowedOrigin(target, url) pendingCliUrl = null - await target.loadURL(url) } - const navigation = mainNavigationController - ? mainNavigationController.navigate(navigate) - : navigate(window) - void navigation.catch((error) => { - if (!isIgnorableNavigationError(error)) { - console.error("[cli] failed to load CLI view:", error) - } + void mainNavigationController?.navigate(navigate).then(() => { + if (cliManager.getStatus().state !== "ready") showLoadingScreen() + }).catch((error) => { + if (!isIgnorableNavigationError(error)) console.error("[cli] failed to load CLI view:", error) }) } diff --git a/packages/electron-app/electron/main/permissions.ts b/packages/electron-app/electron/main/permissions.ts index 28652321..5e1bf4f8 100644 --- a/packages/electron-app/electron/main/permissions.ts +++ b/packages/electron-app/electron/main/permissions.ts @@ -1,20 +1,10 @@ import { session, systemPreferences } from "electron" +import { isAllowedRendererOrigin } from "./renderer-origin" + +export { isAllowedRendererOrigin } from "./renderer-origin" const isMac = process.platform === "darwin" -export function isAllowedRendererOrigin(origin: string | undefined | null, allowedOrigins: string[]): boolean { - if (!origin) { - return false - } - - try { - const normalized = new URL(origin).origin - return allowedOrigins.includes(normalized) - } catch { - return false - } -} - export function configureMediaPermissionHandlers(getAllowedOrigins: () => string[]) { const isAudioMediaRequest = (permission: string, details?: unknown) => { if (permission !== "media") { diff --git a/packages/electron-app/electron/main/process-manager.ts b/packages/electron-app/electron/main/process-manager.ts index a1cd35e0..090de0d1 100644 --- a/packages/electron-app/electron/main/process-manager.ts +++ b/packages/electron-app/electron/main/process-manager.ts @@ -1,5 +1,5 @@ -import { spawn, spawnSync, type ChildProcess } from "child_process" -import { app, utilityProcess, type UtilityProcess } from "electron" +import { spawn, type ChildProcess } from "child_process" +import { app } from "electron" import { createRequire } from "module" import { EventEmitter } from "events" import { existsSync, readFileSync } from "fs" @@ -8,6 +8,16 @@ import path from "path" import { fileURLToPath } from "url" import { parse as parseYaml } from "yaml" import { ensureManagedNodeBinary } from "./managed-node" +import { getProcessStartIdentityAsync } from "./client-state-process-identity" +import { + CLI_STOP_DEADLINE_MS, + captureInitialProcessTree, + captureProcessTree, + forceCapturedProcessTree, + mergeCapturedProcessTrees, + stopManagedChild, +} from "./process-stop" +import { SerializedLifecycle } from "./serialized-lifecycle" import { buildUserShellCommand, getUserShellEnv, supportsUserShell } from "./user-shell" const nodeRequire = createRequire(import.meta.url) @@ -15,8 +25,9 @@ const mainFilename = fileURLToPath(import.meta.url) const mainDirname = path.dirname(mainFilename) const BOOTSTRAP_TOKEN_PREFIX = "CODENOMAD_BOOTSTRAP_TOKEN:" +const SERVER_SHUTDOWN_COMPLETE = "CODENOMAD_SHUTDOWN_STATUS:complete" +const SERVER_SHUTDOWN_INCOMPLETE = "CODENOMAD_SHUTDOWN_STATUS:incomplete" const SESSION_COOKIE_NAME_PREFIX = "codenomad_session" - type CliState = "starting" | "ready" | "error" | "stopped" type ListeningMode = "local" | "all" @@ -45,9 +56,6 @@ interface CliEntryResolution { nodeArgs?: string[] } -type ManagedChild = ChildProcess | UtilityProcess -type ChildLaunchMode = "spawn" | "utility" - const DEFAULT_CONFIG_PATH = "~/.config/codenomad/config.json" function isYamlPath(filePath: string): boolean { @@ -127,18 +135,42 @@ export declare interface CliProcessManager { } export class CliProcessManager extends EventEmitter { - private child?: ManagedChild - private childLaunchMode: ChildLaunchMode = "spawn" + private child?: ChildProcess + private childStartIdentity?: Promise private status: CliStatus = { state: "stopped" } private stdoutBuffer = "" private stderrBuffer = "" private bootstrapToken: string | null = null private authCookieName = `${SESSION_COOKIE_NAME_PREFIX}_${process.pid}_${Date.now()}` private requestedStop = false + private shutdownStatus: "complete" | "incomplete" | null = null + private lifecycle = new SerializedLifecycle() - async start(options: StartOptions): Promise { + start(options: StartOptions): Promise { + return this.lifecycle.enqueue(() => this.startNow(options)) + } + + restart(options: StartOptions): Promise { + return this.lifecycle.enqueue(async () => { + await this.stopNow() + if (this.lifecycle.stopped) throw new Error("CLI process manager is shutting down") + return this.startNow(options) + }) + } + + stop(): Promise { + return this.lifecycle.enqueue(() => this.stopNow()) + } + + shutdown(): Promise { + return this.lifecycle.stop(() => this.stopNow()) + } + + private async startNow(options: StartOptions): Promise { + if (this.lifecycle.stopped) throw new Error("CLI process manager is shutting down") if (this.child) { - await this.stop() + await this.stopNow() + if (this.child) throw new Error("CLI process did not exit before restart") } this.stdoutBuffer = "" @@ -146,6 +178,8 @@ export class CliProcessManager extends EventEmitter { this.bootstrapToken = null this.authCookieName = `${SESSION_COOKIE_NAME_PREFIX}_${process.pid}_${Date.now()}` this.requestedStop = false + this.shutdownStatus = null + this.childStartIdentity = undefined this.updateStatus({ state: "starting", port: undefined, pid: undefined, url: undefined, error: undefined }) const listeningMode = this.resolveListeningMode() @@ -153,63 +187,32 @@ export class CliProcessManager extends EventEmitter { const args = this.buildCliArgs(options, host) const cliEntry = await this.resolveCliEntry(options) - let child: ManagedChild + console.info( + `[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) using ${cliEntry.runner} at ${cliEntry.entry} (host=${host})`, + ) - if (this.shouldUsePackagedShellSupervisor(options)) { - const runtimePath = this.resolveShellNodeCommand() - const entryPath = this.resolveBundledProdEntry() - const supervisorPath = this.resolveCliSupervisorPath() - const shellEnv = supportsUserShell() ? getUserShellEnv() : { ...process.env } - const shellTarget = this.buildCommand(cliEntry, args) - const shellCommand = buildUserShellCommand(`exec ${shellTarget}`) - const supervisorPayload = JSON.stringify({ - command: shellCommand.command, - args: shellCommand.args, - cwd: process.cwd(), - }) + const env = supportsUserShell() ? getUserShellEnv() : { ...process.env } + env.ELECTRON_RUN_AS_NODE = "1" - console.info( - `[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) via utility supervisor using node at ${runtimePath} (host=${host})`, - ) - console.info(`[cli] utility supervisor: ${supervisorPath}`) - console.info(`[cli] shell command: ${shellCommand.command} ${shellCommand.args.join(" ")}`) + const spawnDetails = supportsUserShell() + ? buildUserShellCommand(`ELECTRON_RUN_AS_NODE=1 exec ${this.buildCommand(cliEntry, args)}`) + : this.buildDirectSpawn(cliEntry, args) - child = utilityProcess.fork(supervisorPath, [supervisorPayload], { - env: { ...shellEnv, ELECTRON_RUN_AS_NODE: "1" }, - stdio: "pipe", - serviceName: "CodeNomad CLI Supervisor", - }) - this.childLaunchMode = "utility" - } else { - console.info( - `[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) using ${cliEntry.runner} at ${cliEntry.entry} (host=${host})`, - ) + const child = spawn(spawnDetails.command, spawnDetails.args, { + cwd: process.cwd(), + stdio: ["pipe", "pipe", "pipe"], + env, + shell: false, + detached: process.platform !== "win32", + }) - const env = supportsUserShell() ? getUserShellEnv() : { ...process.env } - env.ELECTRON_RUN_AS_NODE = "1" - - const spawnDetails = supportsUserShell() - ? buildUserShellCommand(`ELECTRON_RUN_AS_NODE=1 exec ${this.buildCommand(cliEntry, args)}`) - : this.buildDirectSpawn(cliEntry, args) - - const detached = process.platform !== "win32" - child = spawn(spawnDetails.command, spawnDetails.args, { - cwd: process.cwd(), - stdio: ["ignore", "pipe", "pipe"], - env, - shell: false, - detached, - }) - - console.info(`[cli] spawn command: ${spawnDetails.command} ${spawnDetails.args.join(" ")}`) - this.childLaunchMode = "spawn" - } - - if (this.childLaunchMode === "spawn" && !child.pid) { + console.info(`[cli] spawn command: ${spawnDetails.command} ${spawnDetails.args.join(" ")}`) + if (!child.pid) { console.error("[cli] spawn failed: no pid") } this.child = child + this.childStartIdentity = child.pid ? getProcessStartIdentityAsync(child.pid, 1_500) : Promise.resolve(undefined) this.updateStatus({ pid: child.pid ?? undefined }) const stdout = child.stdout as NodeJS.ReadableStream | undefined @@ -223,48 +226,25 @@ export class CliProcessManager extends EventEmitter { this.handleStream(data.toString(), "stderr") }) - if (this.childLaunchMode === "utility") { - const utilityChild = child as UtilityProcess + child.on("error", (error) => { + console.error("[cli] failed to start CLI:", error) + this.updateStatus({ state: "error", error: error.message }) + this.emit("error", error) + }) - utilityChild.on("error", (error) => { - const message = this.describeUtilityProcessError(error) - console.error("[cli] utility supervisor failed:", error) - this.updateStatus({ state: "error", error: message }) - this.emit("error", new Error(message)) - }) - - utilityChild.on("exit", (code) => { - const failed = this.status.state !== "ready" - const error = failed ? this.status.error ?? `CLI exited with code ${code ?? 0}` : undefined - console.info(`[cli] exit (code=${code ?? ""})${error ? ` error=${error}` : ""}`) - this.updateStatus({ state: failed ? "error" : "stopped", error }) - if (failed && error) { - this.emit("error", new Error(error)) - } - this.emit("exit", this.status) - this.child = undefined - }) - } else { - const spawnedChild = child as ChildProcess - - spawnedChild.on("error", (error) => { - console.error("[cli] failed to start CLI:", error) - this.updateStatus({ state: "error", error: error.message }) - this.emit("error", error) - }) - - spawnedChild.on("exit", (code, signal) => { - const failed = this.status.state !== "ready" - const error = failed ? this.status.error ?? `CLI exited with code ${code ?? 0}${signal ? ` (${signal})` : ""}` : undefined - console.info(`[cli] exit (code=${code}, signal=${signal || ""})${error ? ` error=${error}` : ""}`) - this.updateStatus({ state: failed ? "error" : "stopped", error }) - if (failed && error) { - this.emit("error", new Error(error)) - } - this.emit("exit", this.status) - this.child = undefined - }) - } + child.on("exit", (code, signal) => { + if (this.child !== child) return + const failed = this.status.state !== "ready" + const error = failed ? this.status.error ?? `CLI exited with code ${code ?? 0}${signal ? ` (${signal})` : ""}` : undefined + console.info(`[cli] exit (code=${code}, signal=${signal || ""})${error ? ` error=${error}` : ""}`) + this.updateStatus({ state: failed ? "error" : "stopped", error }) + if (failed && error) { + this.emit("error", new Error(error)) + } + this.emit("exit", this.status) + this.child = undefined + this.childStartIdentity = undefined + }) return new Promise((resolve, reject) => { const timeout = setTimeout(() => { @@ -284,18 +264,14 @@ export class CliProcessManager extends EventEmitter { }) } - async stop(): Promise { + private async stopNow(): Promise { const child = this.child if (!child) { this.updateStatus({ state: "stopped" }) return } - if (this.childLaunchMode === "utility") { - return this.stopUtilityChild(child as UtilityProcess) - } - - const spawnedChild = child as ChildProcess + const spawnedChild = child this.requestedStop = true @@ -308,138 +284,64 @@ export class CliProcessManager extends EventEmitter { const isAlreadyExited = () => spawnedChild.exitCode !== null || spawnedChild.signalCode !== null - const tryKillPosixGroup = (signal: NodeJS.Signals) => { - try { - // Negative PID targets the process group (POSIX). - process.kill(-pid, signal) - return true - } catch (error) { - const err = error as NodeJS.ErrnoException - if (err?.code === "ESRCH") { - return true - } - return false - } + const deadlineAt = Date.now() + CLI_STOP_DEADLINE_MS + const spawnedIdentity = this.childStartIdentity ?? Promise.resolve(undefined) + const { tree: initialCapture, rootStartIdentity } = await captureInitialProcessTree( + pid, + process.platform, + undefined, + () => spawnedIdentity, + deadlineAt, + ) + let processTree = rootStartIdentity + ? mergeCapturedProcessTrees(undefined, initialCapture, pid, rootStartIdentity) + : initialCapture + let enforcement: Promise | null = null + const forceProcessTree = (enforcementDeadline = deadlineAt) => { + if (enforcement) return enforcement + enforcement = (async () => { + const latest = await captureProcessTree(pid, process.platform, undefined, Math.min(1_500, enforcementDeadline - Date.now())) + processTree = mergeCapturedProcessTrees(processTree, latest, pid, rootStartIdentity) + return processTree ? forceCapturedProcessTree(processTree, undefined, undefined, process.kill, { deadlineAt: enforcementDeadline }) : false + })().finally(() => { enforcement = null }) + return enforcement } - const tryKillSinglePid = (signal: NodeJS.Signals) => { - try { - process.kill(pid, signal) - return true - } catch (error) { - const err = error as NodeJS.ErrnoException - if (err?.code === "ESRCH") { - return true - } - return false - } - } - - const tryTaskkill = (force: boolean) => { - const args = ["/PID", String(pid), "/T"] - if (force) { - args.push("/F") - } - - try { - const result = spawnSync("taskkill", args, { encoding: "utf8" }) - const exitCode = result.status - if (exitCode === 0) { - return true - } - - // If the PID is already gone, treat it as success. - const stderr = (result.stderr ?? "").toString().toLowerCase() - const stdout = (result.stdout ?? "").toString().toLowerCase() - const combined = `${stdout}\n${stderr}` - if (combined.includes("not found") || combined.includes("no running instance")) { - return true - } - return false - } catch { - return false - } - } - - const sendStopSignal = (signal: NodeJS.Signals) => { - if (process.platform === "win32") { - tryTaskkill(signal === "SIGKILL") - return - } - - // Prefer process-group signaling so wrapper launchers (shell/tsx) don't outlive Electron. - const groupOk = tryKillPosixGroup(signal) - if (!groupOk) { - tryKillSinglePid(signal) - } - } - - return new Promise((resolve) => { - const killTimeout = setTimeout(() => { - console.warn( - `[cli] stop timed out after 30000ms; sending SIGKILL (pid=${child.pid ?? "unknown"})`, - ) - sendStopSignal("SIGKILL") - }, 30000) - - spawnedChild.on("exit", () => { - clearTimeout(killTimeout) - this.child = undefined - console.info("[cli] CLI process exited") - this.updateStatus({ state: "stopped" }) - resolve() + let forceConfirmed = false + const enforceIncompleteCleanup = () => { + void forceProcessTree().then((confirmed) => { + forceConfirmed = confirmed + if (!confirmed) console.warn(`[cli] immediate enforcement after incomplete cleanup was not confirmed (pid=${pid})`) + }, (error) => { + console.warn(`[cli] immediate enforcement after incomplete cleanup failed (pid=${pid})`, error) + forceConfirmed = false }) + } + this.once("shutdownIncomplete", enforceIncompleteCleanup) - if (isAlreadyExited()) { - clearTimeout(killTimeout) - this.child = undefined - this.updateStatus({ state: "stopped" }) - resolve() - return - } - - sendStopSignal("SIGTERM") - }) - } - - private stopUtilityChild(child: UtilityProcess): Promise { - this.requestedStop = true - - const pid = child.pid - if (!pid) { + try { + await stopManagedChild({ + child: spawnedChild, + isExited: isAlreadyExited, + force: async (forceDeadline) => forceConfirmed || await forceProcessTree(forceDeadline), + isCleanupComplete: () => this.shutdownStatus === "complete", + deadlineMs: CLI_STOP_DEADLINE_MS, + deadlineAt, + forceReserveMs: 5_000, + warn: (message, error) => console.warn(`[cli] ${message} (pid=${pid})`, error ?? ""), + }) + } finally { + this.off("shutdownIncomplete", enforceIncompleteCleanup) + } + if (this.shutdownStatus !== "complete") { + console.warn(`[cli] CLI exited without a complete graceful-shutdown handshake (status=${this.shutdownStatus ?? "missing"})`) + } + console.info("[cli] CLI process exited") + if (this.child === spawnedChild) { this.child = undefined + this.childStartIdentity = undefined this.updateStatus({ state: "stopped" }) - return Promise.resolve() } - - return new Promise((resolve) => { - const killTimeout = setTimeout(() => { - console.warn(`[cli] stop timed out after 30000ms; sending SIGKILL (pid=${pid})`) - try { - process.kill(pid, "SIGKILL") - } catch { - // no-op - } - }, 30000) - - child.once("exit", () => { - clearTimeout(killTimeout) - this.child = undefined - console.info("[cli] CLI process exited") - this.updateStatus({ state: "stopped" }) - resolve() - }) - - if (child.pid === undefined) { - clearTimeout(killTimeout) - this.child = undefined - this.updateStatus({ state: "stopped" }) - resolve() - return - } - - child.kill() - }) } getStatus(): CliStatus { @@ -455,26 +357,23 @@ export class CliProcessManager extends EventEmitter { } private handleTimeout() { - if (this.child) { - const pid = this.child.pid - if (this.childLaunchMode === "utility") { - if (pid) { - try { - process.kill(pid, "SIGKILL") - } catch { - // no-op + const timedOutChild = this.child + if (timedOutChild) { + const pid = timedOutChild.pid + if (pid) { + const deadlineAt = Date.now() + 5_000 + const spawnedIdentity = this.childStartIdentity ?? Promise.resolve(undefined) + void captureInitialProcessTree(pid, process.platform, undefined, () => spawnedIdentity, deadlineAt).then(async ({ tree, rootStartIdentity }) => { + const latest = await captureProcessTree(pid, process.platform, undefined, Math.min(1_500, deadlineAt - Date.now())) + const processTree = mergeCapturedProcessTrees(tree, latest, pid, rootStartIdentity) + const forced = processTree ? await forceCapturedProcessTree(processTree, undefined, undefined, process.kill, { deadlineAt }) : false + if (!forced) console.warn(`[cli] startup-timeout process tree cleanup was not confirmed (pid=${pid})`) + else if (this.child === timedOutChild) { + this.child = undefined + this.childStartIdentity = undefined } - } - } else if (pid && process.platform !== "win32") { - try { - process.kill(-pid, "SIGKILL") - } catch { - ;(this.child as ChildProcess).kill("SIGKILL") - } - } else { - ;(this.child as ChildProcess).kill("SIGKILL") + }).catch((error) => console.warn(`[cli] startup-timeout process tree cleanup failed (pid=${pid})`, error)) } - this.child = undefined } this.updateStatus({ state: "error", error: "CLI did not start in time" }) this.emit("error", new Error("CLI did not start in time")) @@ -505,6 +404,19 @@ export class CliProcessManager extends EventEmitter { const trimmed = line.trim() if (!trimmed) continue + if (trimmed === SERVER_SHUTDOWN_COMPLETE) { + if (this.shutdownStatus === "incomplete") continue + this.shutdownStatus = "complete" + console.info("[cli] server confirmed graceful shutdown") + continue + } + if (trimmed === SERVER_SHUTDOWN_INCOMPLETE) { + if (this.shutdownStatus === "incomplete") continue + this.shutdownStatus = "incomplete" + console.warn("[cli] server reported incomplete cleanup; requesting final process-tree enforcement") + this.emit("shutdownIncomplete") + continue + } if (trimmed.startsWith(BOOTSTRAP_TOKEN_PREFIX)) { const token = trimmed.slice(BOOTSTRAP_TOKEN_PREFIX.length).trim() if (token && !this.bootstrapToken) { @@ -661,57 +573,4 @@ export class CliProcessManager extends EventEmitter { throw new Error("Unable to locate the packaged CodeNomad server entrypoint (dist/bin.js). Rebuild the desktop bundle.") } - private shouldUsePackagedShellSupervisor(options: StartOptions): boolean { - return false - } - - private resolveCliSupervisorPath(): string { - const candidates = [ - path.join(process.resourcesPath, "cli-supervisor.cjs"), - path.join(mainDirname, "../resources/cli-supervisor.cjs"), - ] - - for (const candidate of candidates) { - if (existsSync(candidate)) { - return candidate - } - } - - throw new Error("Unable to locate CodeNomad CLI supervisor script.") - } - - private resolveShellNodeCommand(): string { - const configured = process.env.NODE_BINARY?.trim() - return configured && configured.length > 0 ? configured : "node" - } - - private resolveBundledProdEntry(): string { - const candidates = [ - path.join(process.resourcesPath, "server", "dist", "bin.js"), - path.join(mainDirname, "../resources/server/dist/bin.js"), - ] - - for (const candidate of candidates) { - if (existsSync(candidate)) { - return candidate - } - } - - throw new Error("Unable to locate bundled CodeNomad CLI build in app resources.") - } - - private describeUtilityProcessError(error: unknown): string { - if (error instanceof Error && error.message) { - return error.message - } - - if (error && typeof error === "object") { - const typed = error as { type?: unknown; location?: unknown } - if (typeof typed.type === "string") { - return typeof typed.location === "string" ? `${typed.type} at ${typed.location}` : typed.type - } - } - - return String(error) - } } diff --git a/packages/electron-app/electron/main/process-stop.test.ts b/packages/electron-app/electron/main/process-stop.test.ts new file mode 100644 index 00000000..898a79c3 --- /dev/null +++ b/packages/electron-app/electron/main/process-stop.test.ts @@ -0,0 +1,542 @@ +import assert from "node:assert/strict" +import { spawn } from "node:child_process" +import { EventEmitter, once } from "node:events" +import { registerHooks } from "node:module" +import { setTimeout as delay } from "node:timers/promises" +import test from "node:test" +import { + CLI_SHUTDOWN_COMMAND, + CLI_STOP_DEADLINE_MS, + captureProcessTree, + forceCapturedProcessTree, + mergeCapturedProcessTrees, + stopManagedChild, +} from "./process-stop" + +class FakeChild extends EventEmitter { + writes: string[] = [] + exited = false + stdin = { + writable: true, + destroyed: false, + end: (chunk: string, callback: (error?: Error | null) => void) => { + this.writes.push(chunk) + callback() + }, + } +} + +test("force success terminates stop without requiring an exit event", async () => { + const child = new FakeChild() + let forces = 0 + let resolved = false + const stopped = stopManagedChild({ + child, + isExited: () => child.exited, + deadlineMs: 100, + forceReserveMs: 80, + force: () => { forces++; return true }, + }).then(() => { resolved = true }) + + assert.equal(CLI_STOP_DEADLINE_MS, 30_000) + assert.deepEqual(child.writes, [CLI_SHUTDOWN_COMMAND]) + await delay(50) + assert.equal(forces, 1) + await stopped + assert.equal(resolved, true) +}) + +test("an absolute stop deadline includes work completed before stopManagedChild starts", async () => { + const child = new FakeChild() + const deadlineAt = Date.now() + 500 + await delay(100) + const started = Date.now() + + await stopManagedChild({ + child, + isExited: () => false, + deadlineMs: 1_000, + deadlineAt, + forceReserveMs: 200, + force: () => true, + }) + + assert.ok(Date.now() - started < 800) +}) + +test("the hard stop deadline rejects even when force never settles", { timeout: 500 }, async () => { + const child = new FakeChild() + const started = Date.now() + + await assert.rejects(stopManagedChild({ + child, + isExited: () => false, + deadlineMs: 30, + forceReserveMs: 20, + force: () => new Promise(() => {}), + }), /overall deadline/) + assert.ok(Date.now() - started < 100) +}) + +test("the hard deadline also bounds enforcement for an already-exited child", { timeout: 500 }, async () => { + const child = new FakeChild() + child.exited = true + + await assert.rejects(stopManagedChild({ + child, + isExited: () => true, + isCleanupComplete: () => false, + deadlineMs: 20, + force: () => new Promise(() => {}), + }), /overall deadline/) +}) + +test("confirmed exit cancels the delayed force command", async () => { + const child = new FakeChild() + let forces = 0 + const stopped = stopManagedChild({ + child, + isExited: () => child.exited, + deadlineMs: 15, + force: () => { forces++; return true }, + }) + + child.exited = true + child.emit("exit") + await stopped + await delay(30) + assert.equal(forces, 0) +}) + +test("unconfirmed final enforcement retries until the process exits", async () => { + const child = new FakeChild() + let forces = 0 + const stopped = stopManagedChild({ + child, + isExited: () => child.exited, + deadlineMs: 100, + forceReserveMs: 90, + forceRetryMs: 5, + force: () => { + forces++ + if (forces < 2) return false + child.exited = true + child.emit("exit") + return true + }, + }) + + await stopped + assert.equal(forces, 2) +}) + +test("exit without a complete shutdown handshake enforces the captured tree", async () => { + const child = new FakeChild() + let forces = 0 + const stopped = stopManagedChild({ + child, + isExited: () => child.exited, + isCleanupComplete: () => false, + force: () => { forces++; return true }, + }) + + child.exited = true + child.emit("exit") + await stopped + assert.equal(forces, 1) +}) + +test("ending CLI stdin permits a real child to exit naturally", { timeout: 5_000 }, async () => { + const child = spawn(process.execPath, ["-e", ` + let buffer = "" + process.stdin.on("data", (chunk) => { + buffer += chunk + if (!buffer.includes(${JSON.stringify(CLI_SHUTDOWN_COMMAND.trim())})) return + process.stdin.removeAllListeners("data") + process.stdin.pause() + setTimeout(() => { process.exitCode = 0 }, 10) + }) + `], { stdio: ["pipe", "ignore", "inherit"] }) + let forces = 0 + + await stopManagedChild({ + child, + isExited: () => child.exitCode !== null || child.signalCode !== null, + deadlineMs: 1_000, + force: () => { forces++; child.kill("SIGKILL"); return true }, + }) + + assert.equal(child.exitCode, 0) + assert.equal(forces, 0) +}) + +test("tree capture records immutable root and nested descendant identities", async () => { + const list = (() => ({ status: 0, stdout: "100|1|linux:boot:10\n200|100|linux:boot:20\n201|200|linux:boot:21\n999|1|linux:boot:99\n", stderr: "", pid: 1, + signal: null, output: [] })) + const tree = await captureProcessTree(100, "linux", list) + assert.deepEqual(tree, { platform: "linux", members: [ + { pid: 100, startIdentity: "linux:boot:10" }, + { pid: 200, startIdentity: "linux:boot:20" }, + { pid: 201, startIdentity: "linux:boot:21" }, + ] }) +}) + +test("Windows tree capture ignores the system idle PID without rejecting the process table", async () => { + const list = (() => ({ + status: 0, + error: undefined, + stdout: "0|0|win32:system\n100|0|win32:100\n101|100|win32:101\n", + stderr: "", + })) + assert.deepEqual((await captureProcessTree(100, "win32", list))?.members, [ + { pid: 100, startIdentity: "win32:100" }, + { pid: 101, startIdentity: "win32:101" }, + ]) +}) + +test("a malformed descendant row invalidates the entire process snapshot", async () => { + const list = (() => ({ + status: 0, + stdout: "100|0|win32:100\n101|100|win32:\n", + stderr: "", + })) + + assert.equal(await captureProcessTree(100, "win32", list), undefined) +}) + +test("Windows verifies creation time and terminates through one native process handle", async () => { + const commands: Array<{ command: string; args: readonly string[] }> = [] + let terminated = false + const tree = { platform: "win32" as const, members: [{ pid: 42, startIdentity: "win32:638800000000000000" }] } + const runner = async (command: string, args: readonly string[]) => { + commands.push({ command, args }) + terminated = true + return { status: 0, stdout: "terminated\n", stderr: "" } + } + const lookup = async () => { + assert.equal(terminated, true, "identity was queried separately before the handle-bound termination") + return undefined + } + const kill = (() => { const error = new Error("gone") as NodeJS.ErrnoException; error.code = "ESRCH"; throw error }) as typeof process.kill + + assert.equal(await forceCapturedProcessTree(tree, lookup, runner, kill), true) + assert.equal(commands.length, 1) + assert.equal(commands[0]!.command, "powershell.exe") + const script = commands[0]!.args.join(" ") + assert.match(script, /OpenProcess/) + assert.match(script, /GetProcessTimes/) + assert.match(script, /TerminateProcess/) + assert.match(script, /CloseHandle/) + assert.match(script, /638800000000000000/) + assert.doesNotMatch(script, /taskkill/i) +}) + +test("Windows native-handle termination refuses a live process with a mismatched creation time", { + skip: process.platform !== "win32", + timeout: 5_000, +}, async () => { + const tree = { platform: "win32" as const, members: [{ pid: process.pid, startIdentity: "win32:1" }] } + + assert.equal(await forceCapturedProcessTree(tree), true) + assert.doesNotThrow(() => process.kill(process.pid, 0)) +}) + +test("Windows native-handle termination accepts CIM precision for an owned process", { + skip: process.platform !== "win32", + timeout: 10_000, +}, async (t) => { + const child = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { stdio: "ignore" }) + t.after(() => { if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL") }) + assert.ok(child.pid) + const exited = once(child, "exit") + const tree = await captureProcessTree(child.pid, "win32") + assert.ok(tree) + + assert.equal(await forceCapturedProcessTree(tree), true) + await exited +}) + +test("Windows native-handle termination refuses a sub-microsecond identity mismatch", { + skip: process.platform !== "win32", + timeout: 10_000, +}, async (t) => { + const child = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { stdio: "ignore" }) + t.after(() => { if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL") }) + assert.ok(child.pid) + const tree = await captureProcessTree(child.pid, "win32") + assert.ok(tree) + tree.members[0]!.startIdentity = `win32:${BigInt(tree.members[0]!.startIdentity.slice(6)) + 1n}` + + assert.equal(await forceCapturedProcessTree(tree), true) + assert.doesNotThrow(() => process.kill(child.pid!, 0)) +}) + +test("PID reuse is identity-guarded on Windows and POSIX", async () => { + for (const platform of ["win32", "linux"] as const) { + const commands: string[][] = [] + const signals: number[] = [] + const tree = { platform, members: [{ pid: 42, startIdentity: platform === "win32" ? "win32:1" : "old" }] } + const runTaskkill = ((_command: string, args: readonly string[]) => { + commands.push([...args]) + return { status: 0, stdout: "mismatch\n", stderr: "", pid: 1, signal: null, output: [] } + }) + const kill = ((pid: number) => { signals.push(pid); return true }) as typeof process.kill + + assert.equal(await forceCapturedProcessTree(tree, () => "reused", runTaskkill, kill), true) + assert.equal(commands.length, platform === "win32" ? 1 : 0) + assert.deepEqual(signals, []) + } +}) + +test("a stale captured identity cannot authorize termination after handle-bound PID reuse", async () => { + let commands = 0 + const tree = { platform: "win32" as const, members: [{ pid: 42, startIdentity: "win32:1" }] } + const runTaskkill = (() => { + commands++ + return { status: 0, stdout: "mismatch\n", stderr: "", pid: 1, signal: null, output: [] } + }) + + assert.equal(await forceCapturedProcessTree(tree, undefined, runTaskkill, process.kill, { + revalidateIdentity: async () => "reused", + }), true) + assert.equal(commands, 1) +}) + +test("a successful snapshot omission still requires a liveness check", async () => { + let lookups = 0 + let livenessChecks = 0 + const tree = { platform: "win32" as const, members: [{ pid: 42, startIdentity: "win32:1" }] } + const runTaskkill = (() => ({ status: 0, stdout: "terminated\n", stderr: "", pid: 1, signal: null, output: [] })) + const kill = ((_pid: number, signal?: NodeJS.Signals | number) => { + if (signal === 0) livenessChecks++ + return true + }) as typeof process.kill + + assert.equal(await forceCapturedProcessTree( + tree, + () => { lookups++; return undefined }, + runTaskkill, + kill, + ), false) + assert.equal(lookups, 1) + assert.equal(livenessChecks, 1) +}) + +test("enforcement awaits asynchronous commands without blocking timers", async () => { + let settled = false + let timerFired = false + const tree = { platform: "win32" as const, members: [{ pid: 42, startIdentity: "win32:1" }] } + const enforcement = Promise.resolve(forceCapturedProcessTree( + tree, + async () => undefined, + (async () => { + await delay(30) + return { status: 0, stdout: "terminated\n", stderr: "", pid: 1, signal: null, output: [] } + }), + (() => { const error = new Error("gone") as NodeJS.ErrnoException; error.code = "ESRCH"; throw error }) as typeof process.kill, + )).then((value) => { settled = true; return value }) + setTimeout(() => { timerFired = true }, 1) + + await delay(5) + assert.equal(timerFired, true) + assert.equal(settled, false) + assert.equal(await enforcement, true) +}) + +test("failed initial capture retains a bounded spawn-time root identity", async () => { + const module = await import("./process-stop") as typeof import("./process-stop") & { + captureInitialProcessTree(...args: unknown[]): Promise<{ tree?: unknown; rootStartIdentity?: string }> + } + const timeouts: number[] = [] + let lookupStarted = false + const result = await module.captureInitialProcessTree( + 100, + "win32", + async () => { + assert.equal(lookupStarted, true) + return { status: 1, stdout: "", stderr: "" } + }, + async (_pid: number, timeoutMs: number) => { lookupStarted = true; timeouts.push(timeoutMs); return "win32:100" }, + Date.now() + 500, + ) + + assert.equal(result.rootStartIdentity, "win32:100") + assert.ok(timeouts[0]! > 0 && timeouts[0]! <= 500) + assert.deepEqual(mergeCapturedProcessTrees(undefined, { + platform: "win32", + members: [{ pid: 100, startIdentity: "win32:100" }], + }, 100, result.rootStartIdentity), { + platform: "win32", + members: [{ pid: 100, startIdentity: "win32:100" }], + }) +}) + +test("tree enforcement stops launching commands when its deadline is spent", async () => { + const tree = { platform: "win32" as const, members: [ + { pid: 101, startIdentity: "win32:1" }, + { pid: 102, startIdentity: "win32:2" }, + { pid: 103, startIdentity: "win32:3" }, + ] } + const timeouts: number[] = [] + let now = 0 + const runTaskkill = ((_command: string, _args: readonly string[], options: { timeout?: number }) => { + timeouts.push(options.timeout ?? 0) + now += options.timeout ?? 0 + return { status: 0, stdout: "terminated\n", stderr: "", pid: 1, signal: null, output: [] } + }) + + assert.equal(await forceCapturedProcessTree(tree, undefined, runTaskkill, process.kill, { + deadlineAt: 2_500, + now: () => now, + }), false) + assert.deepEqual(timeouts, [1_500, 1_000]) +}) + +test("later captures preserve root ownership and add every descendant identity", async () => { + const captured = { platform: "linux" as const, members: [ + { pid: 100, startIdentity: "root" }, + { pid: 200, startIdentity: "old-child" }, + ] } + const latest = { platform: "linux" as const, members: [ + { pid: 100, startIdentity: "root" }, + { pid: 200, startIdentity: "reused-child" }, + { pid: 300, startIdentity: "new-child" }, + ] } + + const merged = mergeCapturedProcessTrees(captured, latest, 100)! + assert.deepEqual(merged.members, [ + { pid: 100, startIdentity: "root" }, + { pid: 200, startIdentity: "old-child" }, + { pid: 200, startIdentity: "reused-child" }, + { pid: 300, startIdentity: "new-child" }, + ]) + const identities = new Map([[100, "root"], [200, "reused-child"], [300, "new-child"]]) + const signals: number[] = [] + const kill = ((pid: number, signal?: NodeJS.Signals | number) => { + if (signal === 0) { + if (identities.has(pid)) return true + const error = new Error("gone") as NodeJS.ErrnoException + error.code = "ESRCH" + throw error + } + signals.push(pid) + identities.delete(pid) + return true + }) as typeof process.kill + assert.equal(await forceCapturedProcessTree(merged, (pid) => identities.get(pid), undefined, kill), true) + assert.deepEqual(signals, [300, 200, 100]) + + const survivingIdentities = new Map([[100, "root"], [200, "reused-child"], [300, "new-child"]]) + assert.equal(await forceCapturedProcessTree( + merged, + (pid) => survivingIdentities.get(pid), + undefined, + (() => true) as typeof process.kill, + ), false) + + const reusedRoot = mergeCapturedProcessTrees(captured, { + platform: "linux", + members: [{ pid: 100, startIdentity: "reused-root" }, { pid: 400, startIdentity: "foreign-child" }], + }, 100) + assert.deepEqual(reusedRoot, captured) + const rootSignals: number[] = [] + assert.equal(await forceCapturedProcessTree( + reusedRoot!, + (pid) => pid === 100 ? "reused-root" : undefined, + undefined, + ((pid: number, signal?: NodeJS.Signals | number) => { + if (signal === 0) { + const error = new Error("gone") as NodeJS.ErrnoException + error.code = "ESRCH" + throw error + } + rootSignals.push(pid) + return true + }) as typeof process.kill, + ), true) + assert.deepEqual(rootSignals, []) + assert.equal(mergeCapturedProcessTrees(undefined, latest, 100), undefined) +}) + +test("a matching late capture becomes the baseline after the initial capture fails", () => { + const latest = { platform: "linux" as const, members: [ + { pid: 100, startIdentity: "original-root" }, + { pid: 200, startIdentity: "child" }, + ] } + + assert.deepEqual(mergeCapturedProcessTrees(undefined, latest, 100, "original-root"), latest) + assert.equal(mergeCapturedProcessTrees(undefined, latest, 100, "reused-root"), undefined) +}) + +test("an exited root without a trustworthy capture cannot confirm containment", async () => { + const child = new FakeChild() + child.exited = true + let tree: ReturnType + + await assert.rejects(stopManagedChild({ + child, + isExited: () => child.exited, + isCleanupComplete: () => false, + forceAttempts: 1, + force: () => { + tree = mergeCapturedProcessTrees(tree, undefined, 100, "original-root") + return tree ? forceCapturedProcessTree(tree) : false + }, + }), /termination was not confirmed/) +}) + +test("captured descendants are forced individually in child-first order", async () => { + const signals: number[] = [] + const tree = { platform: "linux" as const, members: [ + { pid: 100, startIdentity: "a" }, { pid: 200, startIdentity: "b" }, { pid: 201, startIdentity: "c" }, + ] } + const identities = new Map([[100, "a"], [200, "b"], [201, "c"]]) + const kill = ((pid: number, signal?: NodeJS.Signals | number) => { + if (signal === 0) { + if (identities.has(pid)) return true + const error = new Error("gone") as NodeJS.ErrnoException + error.code = "ESRCH" + throw error + } + signals.push(pid) + identities.delete(pid) + return true + }) as typeof process.kill + + assert.equal(await forceCapturedProcessTree(tree, (pid) => identities.get(pid), undefined, kill), true) + assert.deepEqual(signals, [201, 200, 100]) +}) + +test("signal dispatch is not confirmation while the captured identity remains", async () => { + const tree = { platform: "linux" as const, members: [{ pid: 42, startIdentity: "owned" }] } + const kill = (() => true) as typeof process.kill + + assert.equal(await forceCapturedProcessTree(tree, () => "owned", undefined, kill), false) +}) + +test("incomplete shutdown status remains terminal", async () => { + const hooks = registerHooks({ + resolve(specifier, context, nextResolve) { + if (specifier === "electron") { + return { shortCircuit: true, url: "data:text/javascript,export const app={isPackaged:false,getAppPath(){return ''}}" } + } + return nextResolve(specifier, context) + }, + }) + try { + const { CliProcessManager } = await import("./process-manager") + const manager = new CliProcessManager() + let enforcements = 0 + ;(manager as EventEmitter).on("shutdownIncomplete", () => { enforcements++ }) + + ;(manager as any).handleStream( + "CODENOMAD_SHUTDOWN_STATUS:incomplete\nCODENOMAD_SHUTDOWN_STATUS:complete\n", + "stdout", + ) + + assert.equal((manager as any).shutdownStatus, "incomplete") + assert.equal(enforcements, 1) + } finally { + hooks.deregister() + } +}) diff --git a/packages/electron-app/electron/main/process-stop.ts b/packages/electron-app/electron/main/process-stop.ts new file mode 100644 index 00000000..658bd7e8 --- /dev/null +++ b/packages/electron-app/electron/main/process-stop.ts @@ -0,0 +1,361 @@ +import { execFile } from "node:child_process" +import { getProcessStartIdentityAsync, type AsyncProcessStartIdentityLookup } from "./client-state-process-identity" + +export const CLI_SHUTDOWN_COMMAND = "codenomad:shutdown\n" +export const CLI_STOP_DEADLINE_MS = 30_000 + +interface ExitTrackedChild { + stdin?: { + destroyed?: boolean + writable?: boolean + end(chunk: string, callback: (error?: Error | null) => void): unknown + } | null + once(event: "exit", listener: () => void): unknown + off?(event: "exit", listener: () => void): unknown +} + +interface StopManagedChildOptions { + child: ExitTrackedChild + isExited(): boolean + force(deadlineAt?: number): Promise | boolean + isCleanupComplete?(): boolean + deadlineMs?: number + deadlineAt?: number + forceReserveMs?: number + forceRetryMs?: number + forceAttempts?: number + warn?(message: string, error?: unknown): void +} + +interface ProcessRow { + pid: number + parentPid: number + startIdentity: string +} + +export interface CapturedProcessTree { + platform: NodeJS.Platform + members: Array<{ pid: number; startIdentity: string }> +} + +interface AsyncCommandResult { + status: number | null + stdout: string + stderr: string + error?: Error +} + +type AsyncCommandRunner = ( + command: string, + args: readonly string[], + options: { encoding: "utf8"; timeout: number; windowsHide?: boolean; env?: NodeJS.ProcessEnv }, +) => Promise | AsyncCommandResult + +interface ForceCapturedProcessTreeOptions { + deadlineAt?: number + now?: () => number + revalidateIdentity?: AsyncProcessStartIdentityLookup +} + +export function mergeCapturedProcessTrees( + captured: CapturedProcessTree | undefined, + latest: CapturedProcessTree | undefined, + rootPid: number, + expectedRootIdentity?: string, +): CapturedProcessTree | undefined { + if (!latest || (captured && latest.platform !== captured.platform)) return captured + const capturedRoot = captured?.members.find((member) => member.pid === rootPid) + const latestRoot = latest.members.find((member) => member.pid === rootPid) + const rootIdentity = capturedRoot?.startIdentity ?? expectedRootIdentity + if (!rootIdentity || !latestRoot || rootIdentity !== latestRoot.startIdentity) return captured + if (!captured) return latest + + const identityKey = (member: { pid: number; startIdentity: string }) => `${member.pid}\0${member.startIdentity}` + const members = new Map(captured.members.map((member) => [identityKey(member), member])) + for (const member of latest.members) { + members.set(identityKey(member), member) + } + return { platform: captured.platform, members: [...members.values()] } +} + +function runCommand( + command: string, + args: readonly string[], + options: { encoding: "utf8"; timeout: number; windowsHide?: boolean; env?: NodeJS.ProcessEnv }, +): Promise { + return new Promise((resolve) => { + execFile(command, args, options, (error, stdout, stderr) => { + resolve({ status: error ? null : 0, stdout, stderr, error: error ?? undefined }) + }) + }) +} + +async function captureProcessRows( + platform: NodeJS.Platform, + runList: AsyncCommandRunner, + timeoutMs: number, +): Promise { + if (timeoutMs <= 0) return undefined + const result = platform === "win32" + ? await runList("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", + "Get-CimInstance Win32_Process | ForEach-Object { '{0}|{1}|win32:{2}' -f $_.ProcessId, $_.ParentProcessId, ([datetime]$_.CreationDate).ToUniversalTime().Ticks }"], + { encoding: "utf8", timeout: timeoutMs, windowsHide: true }) + : platform === "linux" + ? await runList("sh", ["-c", `boot=$(cat /proc/sys/kernel/random/boot_id) || exit 1 +for stat in /proc/[0-9]*/stat; do + line=$(cat "$stat" 2>/dev/null) || continue + pid=$(printf '%s\n' "$line" | cut -d' ' -f1); rest=$(printf '%s\n' "$line" | sed 's/^.*) //'); set -- $rest + ppid=$2; shift 19; printf '%s|%s|linux:%s:%s\n' "$pid" "$ppid" "$boot" "$1" +done`], { encoding: "utf8", timeout: timeoutMs }) + : await runList("ps", ["-A", "-o", "pid=,ppid=,lstart="], { encoding: "utf8", timeout: timeoutMs, + env: { ...process.env, LC_ALL: "C", LANG: "C" } }) + if (result.status !== 0 || result.error) return undefined + + const rows: ProcessRow[] = [] + for (const line of String(result.stdout ?? "").split(/\r?\n/)) { + if (!line.trim()) continue + if (platform === "darwin" ? /^0(?:\s|$)/.test(line.trim()) : /^0(?:\||$)/.test(line.trim())) continue + const fields = platform === "darwin" + ? line.trim().match(/^(\d+)\s+(\d+)\s+(.+)$/)?.slice(1) + : line.trim().split("|") + if (!fields || fields.length !== 3) return undefined + const [pidText, parentPidText, rawIdentity] = fields + const pid = Number(pidText), parentPid = Number(parentPidText) + const startIdentity = platform === "darwin" ? `darwin:${rawIdentity}` : rawIdentity + const validIdentity = platform === "win32" + ? /^win32:\d+$/.test(startIdentity) + : platform === "linux" + ? /^linux:[^:]+:\d+$/.test(startIdentity) + : Boolean(rawIdentity.trim()) + if (!Number.isInteger(pid) || pid <= 0 || !Number.isInteger(parentPid) || !validIdentity) return undefined + rows.push({ pid, parentPid, startIdentity }) + } + return rows +} + +function processTreeFromRows(rootPid: number, platform: NodeJS.Platform, rows: ProcessRow[]): CapturedProcessTree | undefined { + const descendants = new Set([rootPid]) + let changed = true + while (changed) { + changed = false + for (const row of rows) { + if (!descendants.has(row.parentPid) || descendants.has(row.pid)) continue + descendants.add(row.pid) + changed = true + } + } + const members = rows.filter((row) => descendants.has(row.pid)) + .map(({ pid, startIdentity }) => ({ pid, startIdentity })) + return members.some((member) => member.pid === rootPid) ? { platform, members } : undefined +} + +export async function captureProcessTree( + rootPid: number, + platform: NodeJS.Platform = process.platform, + runList: AsyncCommandRunner = runCommand, + timeoutMs = 1_500, +): Promise { + const rows = await captureProcessRows(platform, runList, timeoutMs) + return rows ? processTreeFromRows(rootPid, platform, rows) : undefined +} + +export async function captureInitialProcessTree( + rootPid: number, + platform: NodeJS.Platform = process.platform, + runList: AsyncCommandRunner = runCommand, + lookup: AsyncProcessStartIdentityLookup = (pid, timeoutMs) => getProcessStartIdentityAsync(pid, timeoutMs, platform), + deadlineAt = Date.now() + 3_000, +): Promise<{ tree?: CapturedProcessTree; rootStartIdentity?: string }> { + const fallbackIdentity = Promise.resolve(lookup(rootPid, Math.min(1_500, deadlineAt - Date.now()))) + const captured = await captureProcessTree(rootPid, platform, runList, Math.min(1_500, deadlineAt - Date.now())) + const rootStartIdentity = await fallbackIdentity + const tree = rootStartIdentity + ? mergeCapturedProcessTrees(undefined, captured, rootPid, rootStartIdentity) + : undefined + return { tree, rootStartIdentity } +} + +export async function forceCapturedProcessTree( + tree: CapturedProcessTree, + lookup?: AsyncProcessStartIdentityLookup, + runTerminate: AsyncCommandRunner = runCommand, + kill: typeof process.kill = process.kill, + options: ForceCapturedProcessTreeOptions = {}, +): Promise { + const now = options.now ?? Date.now + const remainingMs = () => options.deadlineAt === undefined ? 1_500 : options.deadlineAt - now() + const currentIdentity = options.revalidateIdentity ?? lookup + ?? ((pid, timeoutMs) => getProcessStartIdentityAsync(pid, timeoutMs, tree.platform)) + let confirmed = true + const isGone = (pid: number) => { + try { + kill(pid, 0) + return false + } catch (error) { + return (error as NodeJS.ErrnoException).code === "ESRCH" + } + } + for (const member of [...tree.members].reverse()) { + if (remainingMs() <= 0) return false + if (tree.platform === "win32") { + const expectedTicks = member.startIdentity.match(/^win32:(\d+)$/)?.[1] + if (!expectedTicks) { + confirmed = false + continue + } + const timeout = Math.min(1_500, remainingMs()) + if (timeout <= 0) return false + const script = `$source = @' +using System; +using System.Runtime.InteropServices; +public static class CodeNomadProcessHandle { + [StructLayout(LayoutKind.Sequential)] public struct FileTime { public uint Low; public uint High; } + [DllImport("kernel32.dll", SetLastError=true)] public static extern IntPtr OpenProcess(uint access, bool inherit, uint processId); + [DllImport("kernel32.dll", SetLastError=true)] public static extern bool GetProcessTimes(IntPtr process, out FileTime creation, out FileTime exit, out FileTime kernel, out FileTime user); + [DllImport("kernel32.dll", SetLastError=true)] public static extern bool TerminateProcess(IntPtr process, uint exitCode); + [DllImport("kernel32.dll")] public static extern bool CloseHandle(IntPtr handle); +} +'@ +Add-Type -TypeDefinition $source +$handle = [CodeNomadProcessHandle]::OpenProcess(0x1001, $false, ${member.pid}) +if ($handle -eq [IntPtr]::Zero) { exit 3 } +try { + $creation = [CodeNomadProcessHandle+FileTime]::new() + $exit = [CodeNomadProcessHandle+FileTime]::new() + $kernel = [CodeNomadProcessHandle+FileTime]::new() + $user = [CodeNomadProcessHandle+FileTime]::new() + if (-not [CodeNomadProcessHandle]::GetProcessTimes($handle, [ref]$creation, [ref]$exit, [ref]$kernel, [ref]$user)) { exit 4 } + $fileTime = ([long]$creation.High -shl 32) -bor $creation.Low + $nativeTicks = [DateTime]::FromFileTimeUtc($fileTime).Ticks + $expectedTicks = [long]::Parse('${expectedTicks}') + $nativeTicks -= $nativeTicks % 10 + if ($nativeTicks -ne $expectedTicks) { 'mismatch'; exit 0 } + if (-not [CodeNomadProcessHandle]::TerminateProcess($handle, 1)) { exit 5 } + 'terminated' +} finally { + [void][CodeNomadProcessHandle]::CloseHandle($handle) +}` + const result = await runTerminate("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { + encoding: "utf8", + timeout, + windowsHide: true, + }) + const outcome = result.status === 0 ? result.stdout.trim() : "" + if (outcome === "mismatch") continue + if (outcome !== "terminated" && !isGone(member.pid)) confirmed = false + continue + } + const identity = await currentIdentity(member.pid, Math.min(1_500, remainingMs())) + if (!identity) { + if (!isGone(member.pid)) confirmed = false + continue + } + if (identity !== member.startIdentity) continue + try { + kill(member.pid, "SIGKILL") + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ESRCH") confirmed = false + } + } + if (remainingMs() <= 0) return false + for (const member of tree.members) { + if (remainingMs() <= 0) return false + const remainingIdentity = await currentIdentity(member.pid, Math.min(1_500, remainingMs())) + if (remainingIdentity === member.startIdentity) confirmed = false + else if (!remainingIdentity && !isGone(member.pid)) confirmed = false + } + return confirmed +} + +export function stopManagedChild(options: StopManagedChildOptions): Promise { + return new Promise((resolve, reject) => { + let settled = false + let timer: ReturnType | undefined + let hardTimer: ReturnType | undefined + let attempts = 0 + const deadlineMs = options.deadlineMs ?? CLI_STOP_DEADLINE_MS + const deadlineAt = options.deadlineAt ?? Date.now() + deadlineMs + const cleanupComplete = options.isCleanupComplete ?? (() => true) + const removeListener = () => options.child.off?.("exit", onExit) + const finish = (error?: Error) => { + if (settled) return + settled = true + if (timer) clearTimeout(timer) + if (hardTimer) clearTimeout(hardTimer) + removeListener() + if (error) reject(error) + else resolve() + } + let forcing = false + const force = () => { + if (timer) clearTimeout(timer) + timer = undefined + if (settled) return + if (forcing) return + if (Date.now() >= deadlineAt) { + finish(new Error("CLI process tree termination exceeded its overall deadline")) + return + } + attempts += 1 + forcing = true + void Promise.resolve().then(() => options.force(deadlineAt)).then((confirmed) => { + forcing = false + if (settled) return + if (Date.now() > deadlineAt) { + finish(new Error("CLI process tree termination exceeded its overall deadline")) + return + } + if (confirmed) { + finish() + return + } + retry() + }, (error) => { + forcing = false + options.warn?.("Failed to force CLI process tree termination", error) + retry() + }) + } + const retry = () => { + const maxAttempts = options.forceAttempts ?? 3 + if (attempts >= maxAttempts) { + finish(new Error(`CLI process tree termination was not confirmed after ${attempts} attempts`)) + return + } + options.warn?.("CLI process tree termination was not confirmed; retrying") + const retryMs = options.forceRetryMs ?? 1_000 + timer = setTimeout(force, Math.min(retryMs, Math.max(0, deadlineAt - Date.now()))) + } + function onExit() { + if (cleanupComplete()) finish() + else force() + } + + options.child.once("exit", onExit) + hardTimer = setTimeout(() => { + finish(new Error("CLI process tree termination exceeded its overall deadline")) + }, Math.max(0, deadlineAt - Date.now())) + if (options.isExited()) { + onExit() + return + } + + const forceAt = deadlineAt - (options.forceReserveMs ?? Math.min(1_500, deadlineMs / 2)) + timer = setTimeout(() => { + options.warn?.("CLI cleanup reached its final enforcement window; forcing process tree termination") + force() + }, Math.max(0, forceAt - Date.now())) + const stdin = options.child.stdin + if (!stdin || stdin.destroyed || stdin.writable === false) { + options.warn?.("CLI stdin is not writable; waiting until the force deadline") + return + } + try { + stdin.end(CLI_SHUTDOWN_COMMAND, (error) => { + if (error) options.warn?.("Failed to send the CLI graceful shutdown command; waiting until the force deadline", error) + }) + } catch (error) { + options.warn?.("Failed to send the CLI graceful shutdown command; waiting until the force deadline", error) + } + }) +} diff --git a/packages/electron-app/electron/main/renderer-client-state-flush.test.ts b/packages/electron-app/electron/main/renderer-client-state-flush.test.ts index bfbf45bf..3617e4d8 100644 --- a/packages/electron-app/electron/main/renderer-client-state-flush.test.ts +++ b/packages/electron-app/electron/main/renderer-client-state-flush.test.ts @@ -2,43 +2,24 @@ import assert from "node:assert/strict" import test from "node:test" import { flushRendererClientStateBeforeShutdown, type RendererFlushWindow } from "./renderer-client-state-flush" -function createWindow(executeJavaScript: (source: string) => Promise): RendererFlushWindow { - return { - isDestroyed: () => false, - webContents: { - isDestroyed: () => false, - getURL: () => "http://127.0.0.1:3000/app", - executeJavaScript, - }, - } -} - -test("renderer flush only executes for a primary trusted window", async () => { - let calls = 0 - const window = createWindow(async () => { - calls += 1 - }) - - assert.equal(await flushRendererClientStateBeforeShutdown(window, false, () => true), "not-primary") - assert.equal(await flushRendererClientStateBeforeShutdown(window, true, () => false), "untrusted-origin") - assert.equal(calls, 0) +const window = (executeJavaScript: (source: string) => Promise): RendererFlushWindow => ({ + isDestroyed: () => false, + webContents: { isDestroyed: () => false, getURL: () => "http://127.0.0.1:3000/app", executeJavaScript }, }) -test("renderer flush awaits the registered shutdown callback", async () => { +test("renderer flush enforces primary/trusted access and invokes the registered callback", async () => { + let calls = 0 let source = "" - const window = createWindow(async (value) => { - source = value - }) - - assert.equal(await flushRendererClientStateBeforeShutdown(window, true, () => true), "flushed") + const target = window(async (value) => { calls++; source = value }) + assert.equal(await flushRendererClientStateBeforeShutdown(target, false, () => true), "not-primary") + assert.equal(await flushRendererClientStateBeforeShutdown(target, true, () => false), "untrusted-origin") + assert.equal(calls, 0) + assert.equal(await flushRendererClientStateBeforeShutdown(target, true, () => true), "flushed") + assert.equal(calls, 1) assert.match(source, /__CODENOMAD_FLUSH_CLIENT_STATE_BEFORE_NATIVE_SHUTDOWN__/) assert.match(source, /http:\/\/127\.0\.0\.1:3000/) }) test("renderer flush rejects after its bounded timeout", async () => { - const window = createWindow(() => new Promise(() => {})) - await assert.rejects( - flushRendererClientStateBeforeShutdown(window, true, () => true, 10), - /timed out after 10ms/, - ) + await assert.rejects(flushRendererClientStateBeforeShutdown(window(() => new Promise(() => {})), true, () => true, 10), /timed out after 10ms/) }) diff --git a/packages/electron-app/electron/main/renderer-origin.test.ts b/packages/electron-app/electron/main/renderer-origin.test.ts new file mode 100644 index 00000000..9080e7cc --- /dev/null +++ b/packages/electron-app/electron/main/renderer-origin.test.ts @@ -0,0 +1,25 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { resolveConfiguredRendererOrigins } from "./renderer-origin" + +test("packaged renderer origins exclude development server environment URLs", () => { + assert.deepEqual( + resolveConfiguredRendererOrigins( + "https://127.0.0.1:43123/workspace", + true, + ["http://localhost:3000/app", "http://127.0.0.1:5173/loading.html"], + ), + ["https://127.0.0.1:43123"], + ) +}) + +test("development renderer origins include configured development servers", () => { + assert.deepEqual( + resolveConfiguredRendererOrigins( + "http://127.0.0.1:43123/workspace", + false, + ["http://localhost:3000/app", "http://localhost:3000/loading.html"], + ), + ["http://127.0.0.1:43123", "http://localhost:3000"], + ) +}) diff --git a/packages/electron-app/electron/main/renderer-origin.ts b/packages/electron-app/electron/main/renderer-origin.ts new file mode 100644 index 00000000..40f501b1 --- /dev/null +++ b/packages/electron-app/electron/main/renderer-origin.ts @@ -0,0 +1,24 @@ +export function isAllowedRendererOrigin(origin: string | undefined | null, allowedOrigins: string[]): boolean { + if (!origin) return false + try { + return allowedOrigins.includes(new URL(origin).origin) + } catch { + return false + } +} + +export function resolveConfiguredRendererOrigins( + currentCliUrl: string | null, + isPackaged: boolean, + devCandidates: Array, +): string[] { + const candidates = isPackaged ? [currentCliUrl] : [currentCliUrl, ...devCandidates] + const origins = new Set() + for (const candidate of candidates) { + if (!candidate) continue + try { + origins.add(new URL(candidate).origin) + } catch {} + } + return [...origins] +} diff --git a/packages/electron-app/electron/main/serialized-lifecycle.test.ts b/packages/electron-app/electron/main/serialized-lifecycle.test.ts new file mode 100644 index 00000000..24e7422f --- /dev/null +++ b/packages/electron-app/electron/main/serialized-lifecycle.test.ts @@ -0,0 +1,38 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { SerializedLifecycle } from "./serialized-lifecycle" + +test("serializes operations and exposes shutdown before queued work resumes", async () => { + const lifecycle = new SerializedLifecycle() + let release!: () => void + const gate = new Promise((resolve) => { release = resolve }) + let active = 0, maximum = 0 + const first = lifecycle.enqueue(async () => { + active += 1; maximum = Math.max(maximum, active) + await gate + active -= 1 + if (lifecycle.stopped) throw new Error("stopped") + }) + const second = lifecycle.enqueue(async () => { + active += 1; maximum = Math.max(maximum, active); active -= 1 + }) + const shutdown = lifecycle.stop(async () => {}) + release() + await assert.rejects(first, /stopped/) + await second + await shutdown + assert.equal(maximum, 1) +}) + +test("failed shutdown reopens the lifecycle before queued retries run", async () => { + const lifecycle = new SerializedLifecycle() + const shutdown = lifecycle.stop(async () => { throw new Error("cleanup unconfirmed") }) + const retry = lifecycle.enqueue(async () => { + assert.equal(lifecycle.stopped, false) + return "restarted" + }) + + await assert.rejects(shutdown, /cleanup unconfirmed/) + assert.equal(await retry, "restarted") + assert.equal(lifecycle.stopped, false) +}) diff --git a/packages/electron-app/electron/main/serialized-lifecycle.ts b/packages/electron-app/electron/main/serialized-lifecycle.ts new file mode 100644 index 00000000..1f72f5a7 --- /dev/null +++ b/packages/electron-app/electron/main/serialized-lifecycle.ts @@ -0,0 +1,22 @@ +export class SerializedLifecycle { + private tail: Promise = Promise.resolve() + stopped = false + + enqueue(operation: () => Promise): Promise { + const queued = this.tail.catch(() => {}).then(operation) + this.tail = queued.then(() => {}, () => {}) + return queued + } + + stop(operation: () => Promise): Promise { + this.stopped = true + return this.enqueue(async () => { + try { + return await operation() + } catch (error) { + this.stopped = false + throw error + } + }) + } +} diff --git a/packages/electron-app/electron/main/window-state.test.ts b/packages/electron-app/electron/main/window-state.test.ts index bf3338d3..fae89974 100644 --- a/packages/electron-app/electron/main/window-state.test.ts +++ b/packages/electron-app/electron/main/window-state.test.ts @@ -1,34 +1,16 @@ import assert from "node:assert/strict" import test from "node:test" -import { clampWindowBounds, normalizeNativeWindowState, normalizeZoomFactor } from "./window-state" +import { clampWindowBounds, installWindowZoomInput, normalizeNativeWindowState, normalizeZoomFactor, restoreWindowState, WindowStateTracker } from "./window-state" +import type { BrowserWindow } from "electron" +import type { ClientStateManager } from "./client-state" const primaryDisplay = { x: 0, y: 0, width: 1920, height: 1080 } -test("rejects invalid persisted bounds", () => { - assert.equal( - normalizeNativeWindowState({ - bounds: { x: 0, y: 0, width: Number.NaN, height: 900 }, - maximized: false, - fullscreen: false, - zoomFactor: 1, - }), - undefined, - ) -}) - -test("moves off-screen bounds onto the nearest display", () => { +test("normalizes persisted window state", () => { + assert.equal(normalizeNativeWindowState({ bounds: { x: 0, y: 0, width: Number.NaN, height: 900 }, maximized: false, fullscreen: false, zoomFactor: 1 }), undefined) + assert.deepEqual(clampWindowBounds({ x: 4000, y: 2000, width: 1400, height: 900 }, [primaryDisplay]), { x: 520, y: 180, width: 1400, height: 900 }) assert.deepEqual( - clampWindowBounds({ x: 4000, y: 2000, width: 1400, height: 900 }, [primaryDisplay]), - { x: 520, y: 180, width: 1400, height: 900 }, - ) -}) - -test("clamps dimensions to the selected work area", () => { - assert.deepEqual( - clampWindowBounds({ x: -2000, y: 100, width: 3000, height: 300 }, [ - { x: -1280, y: 0, width: 1280, height: 1024 }, - primaryDisplay, - ]), + clampWindowBounds({ x: -2000, y: 100, width: 3000, height: 300 }, [{ x: -1280, y: 0, width: 1280, height: 1024 }, primaryDisplay]), { x: -1280, y: 100, width: 1280, height: 600 }, ) }) @@ -38,3 +20,91 @@ test("normalizes unsafe zoom factors", () => { assert.equal(normalizeZoomFactor(0.01), 0.25) assert.equal(normalizeZoomFactor(9), 5) }) + +test("restores shared outer position and content size", () => { + const calls: unknown[] = [] + const window = { + setPosition: (x: number, y: number) => calls.push(["position", x, y]), + setContentSize: (width: number, height: number) => calls.push(["content", width, height]), + maximize: () => undefined, + setFullScreen: () => undefined, + webContents: { setZoomFactor: () => undefined }, + } as unknown as BrowserWindow + const bounds = { x: 10, y: 20, width: 1200, height: 800 } + restoreWindowState(window, { bounds, maximized: false, fullscreen: false, zoomFactor: 1 }, bounds) + assert.deepEqual(calls, [["position", 10, 20], ["content", 1200, 800]]) +}) + +test("flush captures the current native zoom", async () => { + let zoomLevel = -0.5 + const window = { + isDestroyed: () => false, + on: () => undefined, + getPosition: () => [0, 0], + getContentSize: () => [1200, 800], + isMaximized: () => false, + isFullScreen: () => false, + webContents: { + isDestroyed: () => false, + on: () => undefined, + setZoomLevel: (level: number) => { zoomLevel = level }, + getZoomLevel: () => zoomLevel, + setZoomFactor: (factor: number) => { zoomLevel = Math.log(factor) / Math.log(1.2) }, + getZoomFactor: () => 1.2 ** zoomLevel, + }, + } as unknown as BrowserWindow + const savedZoomFactors: number[] = [] + const manager = { + saveWindowState: async (state: { zoomFactor: number }) => { savedZoomFactors.push(state.zoomFactor); return true }, + flush: async () => undefined, + } as unknown as ClientStateManager + const tracker = new WindowStateTracker(window, manager, { bounds: { x: 0, y: 0, width: 1200, height: 800 }, maximized: false, fullscreen: false, zoomFactor: 1 }) + + await tracker.flush() + assert.ok(Math.abs(savedZoomFactors.at(-1)! - (1.2 ** -0.5)) < 0.000001) +}) + +test("Electron keyboard and wheel zoom input is applied explicitly", () => { + const events = new Map void>() + let zoomLevel = 0 + const prevented: string[] = [] + const window = { + webContents: { + on: (name: string, handler: (...args: any[]) => void) => events.set(name, handler), + getZoomLevel: () => zoomLevel, + }, + } as unknown as BrowserWindow + installWindowZoomInput(window, (level) => { zoomLevel = level }) + + events.get("before-input-event")?.({ preventDefault: () => prevented.push("keyboard") }, { + type: "keyDown", control: true, meta: false, alt: false, key: "=", + }) + assert.equal(zoomLevel, 0.5) + events.get("zoom-changed")?.({ preventDefault: () => prevented.push("wheel") }, "out") + assert.equal(zoomLevel, 0) + assert.deepEqual(prevented, ["keyboard", "wheel"]) +}) + +test("native menu zoom survives cross-origin navigation", () => { + const events = new Map void>() + let zoomLevel = -0.5 + const window = { + isDestroyed: () => false, + on: () => undefined, + webContents: { + isDestroyed: () => false, + on: (name: string, handler: (...args: any[]) => void) => events.set(name, handler), + getZoomFactor: () => 1.2 ** zoomLevel, + setZoomFactor: (factor: number) => { zoomLevel = Math.log(factor) / Math.log(1.2) }, + }, + } as unknown as BrowserWindow + const manager = { flush: async () => undefined } as unknown as ClientStateManager + new WindowStateTracker(window, manager, { + bounds: { x: 0, y: 0, width: 1200, height: 800 }, maximized: false, fullscreen: false, zoomFactor: 1, + }) + + events.get("did-start-navigation")?.({}, "http://next.test", false, true) + zoomLevel = 0 + events.get("did-finish-load")?.() + assert.ok(Math.abs(zoomLevel - (-0.5)) < 0.000001) +}) diff --git a/packages/electron-app/electron/main/window-state.ts b/packages/electron-app/electron/main/window-state.ts index ec5b1eec..b595b495 100644 --- a/packages/electron-app/electron/main/window-state.ts +++ b/packages/electron-app/electron/main/window-state.ts @@ -118,7 +118,8 @@ export function restoreWindowState(window: BrowserWindow, state: NativeWindowSta } if (bounds) { - window.setBounds(bounds) + window.setPosition(bounds.x, bounds.y) + window.setContentSize(bounds.width, bounds.height) } window.webContents.setZoomFactor(normalizeZoomFactor(state.zoomFactor)) if (state.maximized) { @@ -129,9 +130,31 @@ export function restoreWindowState(window: BrowserWindow, state: NativeWindowSta } } +export function installWindowZoomInput(window: BrowserWindow, setZoomLevel: (level: number) => void): void { + const changeZoom = (delta: number) => setZoomLevel(window.webContents.getZoomLevel() + delta) + window.webContents.on("before-input-event", (event, input) => { + if (input.type !== "keyDown" || (!input.control && !input.meta) || input.alt) return + if (input.key === "+" || input.key === "=") { + event.preventDefault() + changeZoom(0.5) + } else if (input.key === "-") { + event.preventDefault() + changeZoom(-0.5) + } else if (input.key === "0") { + event.preventDefault() + setZoomLevel(0) + } + }) + window.webContents.on("zoom-changed", (event, direction) => { + event.preventDefault() + changeZoom(direction === "in" ? 0.5 : -0.5) + }) +} + export class WindowStateTracker { private saveTimer: ReturnType | undefined private desiredZoomFactor: number + private normalBounds: WindowBounds constructor( private readonly window: BrowserWindow, @@ -139,23 +162,32 @@ export class WindowStateTracker { initialState?: NativeWindowState, ) { this.desiredZoomFactor = normalizeZoomFactor(initialState?.zoomFactor) + const [x, y] = typeof window.getPosition === "function" ? window.getPosition() : [0, 0] + const [width, height] = typeof window.getContentSize === "function" + ? window.getContentSize() + : [DEFAULT_WINDOW_WIDTH, DEFAULT_WINDOW_HEIGHT] + this.normalBounds = initialState?.bounds ?? { x, y, width, height } - const scheduleSave = () => this.scheduleSave() - window.on("move", scheduleSave) - window.on("resize", scheduleSave) - window.on("maximize", scheduleSave) - window.on("unmaximize", scheduleSave) - window.on("enter-full-screen", scheduleSave) - window.on("leave-full-screen", scheduleSave) - window.webContents.on("zoom-changed", scheduleSave) + for (const event of ["move", "resize"]) { + window.on(event as "move", () => { + if (!window.isMaximized() && !window.isFullScreen()) this.captureNormalBounds() + this.scheduleSave() + }) + } + for (const event of ["maximize", "unmaximize", "enter-full-screen", "leave-full-screen"]) { + window.on(event as "maximize", () => this.scheduleSave()) + } + window.webContents.on("zoom-changed", () => this.scheduleSave()) + window.webContents.on("did-start-navigation", (_event, _url, _isInPlace, isMainFrame) => { + if (isMainFrame && !window.webContents.isDestroyed()) { + this.desiredZoomFactor = normalizeZoomFactor(window.webContents.getZoomFactor()) + } + }) window.webContents.on("did-finish-load", () => { if (!window.webContents.isDestroyed()) { window.webContents.setZoomFactor(this.desiredZoomFactor) } }) - window.on("close", () => { - void this.saveNow() - }) window.on("closed", () => this.clearTimer()) } @@ -167,6 +199,13 @@ export class WindowStateTracker { await this.clientState.flush() } + setZoomLevel(level: number): void { + if (this.window.isDestroyed() || this.window.webContents.isDestroyed()) return + this.window.webContents.setZoomLevel(level) + this.desiredZoomFactor = normalizeZoomFactor(this.window.webContents.getZoomFactor()) + this.scheduleSave() + } + private scheduleSave() { this.clearTimer() this.saveTimer = setTimeout(() => { @@ -196,11 +235,18 @@ export class WindowStateTracker { } this.desiredZoomFactor = normalizeZoomFactor(this.window.webContents.getZoomFactor()) + if (!this.window.isMaximized() && !this.window.isFullScreen()) this.captureNormalBounds() return this.clientState.saveWindowState({ - bounds: this.window.getNormalBounds(), + bounds: this.normalBounds, maximized: this.window.isMaximized(), fullscreen: this.window.isFullScreen(), zoomFactor: this.desiredZoomFactor, }) } + + private captureNormalBounds(): void { + const [x, y] = this.window.getPosition() + const [width, height] = this.window.getContentSize() + this.normalBounds = { x, y, width, height } + } } diff --git a/packages/electron-app/electron/resources/cli-supervisor.cjs b/packages/electron-app/electron/resources/cli-supervisor.cjs deleted file mode 100644 index 3ac319e3..00000000 --- a/packages/electron-app/electron/resources/cli-supervisor.cjs +++ /dev/null @@ -1,131 +0,0 @@ -#!/usr/bin/env node - -const { spawn } = require("child_process") - -const SHUTDOWN_GRACE_MS = 30_000 - -let child = null -let shutdownTimer = null - -function log(message, error) { - if (error) { - console.error(`[cli-supervisor] ${message}`, error) - return - } - console.log(`[cli-supervisor] ${message}`) -} - -function clearShutdownTimer() { - if (shutdownTimer) { - clearTimeout(shutdownTimer) - shutdownTimer = null - } -} - -function forwardStream(stream, target) { - if (!stream) return - stream.on("data", (chunk) => { - target.write(chunk) - }) -} - -function terminateChild(force) { - if (!child || child.exitCode !== null || child.signalCode !== null) { - return - } - - try { - child.kill(force ? "SIGKILL" : "SIGTERM") - } catch { - // no-op - } -} - -function requestShutdown(force = false) { - if (!child) { - process.exit(force ? 1 : 0) - return - } - - terminateChild(force) - if (force) { - process.exit(1) - return - } - - clearShutdownTimer() - shutdownTimer = setTimeout(() => { - log(`shutdown timed out after ${SHUTDOWN_GRACE_MS}ms; forcing child termination`) - terminateChild(true) - }, SHUTDOWN_GRACE_MS) - shutdownTimer.unref() -} - -function installShutdownHandlers() { - process.on("SIGTERM", () => requestShutdown(false)) - process.on("SIGINT", () => requestShutdown(false)) - process.on("disconnect", () => requestShutdown(false)) - process.on("uncaughtException", (error) => { - log("uncaught exception", error) - requestShutdown(true) - }) - process.on("unhandledRejection", (error) => { - log("unhandled rejection", error) - requestShutdown(true) - }) -} - -function parsePayload() { - const raw = process.argv[2] - if (!raw) { - throw new Error("Supervisor payload is required") - } - - const parsed = JSON.parse(raw) - if (!parsed || typeof parsed !== "object") { - throw new Error("Supervisor payload must be an object") - } - if (typeof parsed.command !== "string" || parsed.command.trim().length === 0) { - throw new Error("Supervisor payload command is required") - } - if (!Array.isArray(parsed.args) || !parsed.args.every((value) => typeof value === "string")) { - throw new Error("Supervisor payload args must be a string array") - } - - return { - command: parsed.command, - args: parsed.args, - cwd: typeof parsed.cwd === "string" && parsed.cwd.trim().length > 0 ? parsed.cwd : process.cwd(), - } -} - -function main() { - installShutdownHandlers() - - const payload = parsePayload() - log(`launching shell command: ${payload.command} ${payload.args.join(" ")}`) - - child = spawn(payload.command, payload.args, { - cwd: payload.cwd, - env: process.env, - shell: false, - stdio: ["ignore", "pipe", "pipe"], - }) - - forwardStream(child.stdout, process.stdout) - forwardStream(child.stderr, process.stderr) - - child.on("error", (error) => { - log("failed to spawn shell command", error) - process.exit(1) - }) - - child.on("exit", (code, signal) => { - clearShutdownTimer() - log(`child exited code=${code ?? ""} signal=${signal ?? ""}`) - process.exitCode = typeof code === "number" ? code : signal ? 1 : 0 - process.exit() - }) -} - -main() diff --git a/packages/electron-app/package.json b/packages/electron-app/package.json index 59c2599b..339f56e8 100644 --- a/packages/electron-app/package.json +++ b/packages/electron-app/package.json @@ -24,7 +24,7 @@ "prebuild": "npm run prepare:resources", "build": "electron-vite build", "typecheck": "tsc --noEmit -p tsconfig.json", - "test:native": "node --import tsx --test electron/main/client-state-process.test.ts electron/main/client-state.test.ts electron/main/client-state-ipc-handlers.test.ts electron/main/client-state-navigation.test.ts electron/main/client-state-lifecycle.test.ts electron/main/main-window-close.test.ts electron/main/renderer-client-state-flush.test.ts electron/main/window-state.test.ts", + "test:native": "node --import tsx --test electron/main/client-state-cross-host.test.ts electron/main/client-state-process.test.ts electron/main/client-state.test.ts electron/main/client-state-ipc.test.ts electron/main/client-state-navigation.test.ts electron/main/client-state-lifecycle.test.ts electron/main/process-stop.test.ts electron/main/renderer-client-state-flush.test.ts electron/main/renderer-origin.test.ts electron/main/serialized-lifecycle.test.ts electron/main/window-state.test.ts", "preview": "electron-vite preview", "build:binaries": "node scripts/build.js", "build:mac": "node scripts/build.js mac", diff --git a/packages/server/src/api-types.ts b/packages/server/src/api-types.ts index ef3d14c9..a69c0155 100644 --- a/packages/server/src/api-types.ts +++ b/packages/server/src/api-types.ts @@ -471,7 +471,7 @@ export type WorkspaceEventPayload = | { type: "workspace.created"; workspace: WorkspaceDescriptor } | { type: "workspace.started"; workspace: WorkspaceDescriptor } | { type: "workspace.error"; workspace: WorkspaceDescriptor } - | { type: "workspace.stopped"; workspaceId: string } + | { type: "workspace.stopped"; workspaceId: string; reason?: "deleted" | "stopped" } | { type: "workspace.log"; entry: WorkspaceLogEntry } | { type: "sidecar.updated"; sidecar: SideCar } | { type: "sidecar.removed"; sidecarId: string } diff --git a/packages/server/src/index.test.ts b/packages/server/src/index.test.ts index e5875951..2e8f4cf8 100644 --- a/packages/server/src/index.test.ts +++ b/packages/server/src/index.test.ts @@ -1,7 +1,9 @@ import assert from "node:assert/strict" +import { spawn } from "node:child_process" +import { once } from "node:events" import { describe, it } from "node:test" -import { installShutdownSignalHandlers } from "./index" +import { installShutdownSignalHandlers, installShutdownStdinHandler, STDIN_SHUTDOWN_COMMAND } from "./index" import { createServerShutdownHandler } from "./shutdown" describe("CLI shutdown signal registration", () => { @@ -12,22 +14,17 @@ describe("CLI shutdown signal registration", () => { finishCleanup = resolve }) const exits: number[] = [] + let handled: Promise | undefined const shutdown = createServerShutdownHandler({ shutdown: () => cleanup, - logger: { - info: () => undefined, - warn: () => undefined, - error: () => undefined, - }, + logger: { info() {}, warn() {}, error() {} }, forceExit: (code) => exits.push(code), setExitCode: () => undefined, + reportStatus: () => undefined, }) - installShutdownSignalHandlers( - { - on: (signal, listener) => listeners.set(signal, listener), - }, - shutdown, + { on: (signal, listener) => listeners.set(signal, listener) }, + (signal) => (handled = shutdown(signal)), ) listeners.get("SIGINT")?.() @@ -35,6 +32,42 @@ describe("CLI shutdown signal registration", () => { assert.deepEqual(exits, [1]) finishCleanup() - await cleanup + await handled + }) + + it("coalesces chunked and repeated stdin shutdown commands through the same handler", async () => { + const listeners = new Map void>() + const triggers: string[] = [] + let destroys = 0 + const source = { + on: (event: "data", listener: (chunk: Buffer | string) => void) => listeners.set(event, listener), + off: (event: "data", listener: (chunk: Buffer | string) => void) => { + if (listeners.get(event) === listener) listeners.delete(event) + }, + destroy: () => { destroys++ }, + } + installShutdownStdinHandler(source, async (trigger) => { triggers.push(trigger) }) + + const listener = listeners.get("data")! + listener(STDIN_SHUTDOWN_COMMAND.slice(0, 8)) + listener(`${STDIN_SHUTDOWN_COMMAND.slice(8)}\n${STDIN_SHUTDOWN_COMMAND}\n`) + listener(`${STDIN_SHUTDOWN_COMMAND}\n`) + await Promise.resolve() + + assert.deepEqual(triggers, ["stdin"]) + assert.equal(destroys, 1) + assert.equal(listeners.has("data"), false) + }) + + it("allows a real piped process to exit naturally after the shutdown command", { timeout: 5_000 }, async () => { + const moduleUrl = new URL("./index.ts", import.meta.url).href + const child = spawn(process.execPath, ["--import", "tsx", "--input-type=module", "-e", ` + import { installShutdownStdinHandler } from ${JSON.stringify(moduleUrl)} + installShutdownStdinHandler(process.stdin, async () => { process.exitCode = 0 }) + `], { stdio: ["pipe", "ignore", "inherit"] }) + + child.stdin.end(`${STDIN_SHUTDOWN_COMMAND}\n`) + const [code] = await once(child, "exit") + assert.equal(code, 0) }) }) diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 1205fde7..5cee9f0e 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -32,7 +32,7 @@ import { ClientConnectionManager } from "./clients/connection-manager" import { PluginChannelManager } from "./plugins/channel" import { VoiceModeManager } from "./plugins/voice-mode" import { runCliUpgrade } from "./cli-upgrade" -import { createServerShutdownHandler, orchestrateServerShutdown } from "./shutdown" +import { createServerShutdownHandler, orchestrateServerShutdown, type ServerShutdownTrigger } from "./shutdown" const require = createRequire(import.meta.url) @@ -74,6 +74,7 @@ const DEFAULT_HOST = "127.0.0.1" const DEFAULT_CONFIG_PATH = "~/.config/codenomad/config.json" const DEFAULT_HTTPS_PORT = 9898 const DEFAULT_HTTP_PORT = 9899 +export const STDIN_SHUTDOWN_COMMAND = "codenomad:shutdown" interface ShutdownSignalSource { on: (signal: "SIGINT" | "SIGTERM", listener: () => void) => unknown @@ -81,12 +82,39 @@ interface ShutdownSignalSource { export function installShutdownSignalHandlers( source: ShutdownSignalSource, - shutdown: (signal: NodeJS.Signals) => Promise, + shutdown: (signal: ServerShutdownTrigger) => Promise, ): void { source.on("SIGINT", () => void shutdown("SIGINT")) source.on("SIGTERM", () => void shutdown("SIGTERM")) } +interface ShutdownStdinSource { + on(event: "data", listener: (chunk: Buffer | string) => void): unknown + off?(event: "data", listener: (chunk: Buffer | string) => void): unknown + destroy?(): unknown +} + +export function installShutdownStdinHandler( + source: ShutdownStdinSource, + shutdown: (signal: ServerShutdownTrigger) => Promise, +): void { + let buffer = "" + let requested = false + const onData = (chunk: Buffer | string) => { + if (requested) return + buffer += chunk.toString() + const lines = buffer.split(/\r?\n/) + buffer = lines.pop() ?? "" + if (!lines.some((line) => line.trim() === STDIN_SHUTDOWN_COMMAND)) return + + requested = true + source.off?.("data", onData) + source.destroy?.() + void shutdown("stdin") + } + source.on("data", onData) +} + function parseCliOptions(argv: string[]): CliOptions { const program = new Command() .name("codenomad") @@ -570,6 +598,11 @@ async function main() { const shutdown = createServerShutdownHandler({ logger, + holdAfterFailure: () => new Promise(() => { setInterval(() => undefined, 60_000) }), + setExitCode: (code) => { + process.stdin.destroy() + process.exitCode = code + }, shutdown: () => orchestrateServerShutdown( { @@ -595,6 +628,7 @@ async function main() { }) installShutdownSignalHandlers(process, shutdown) + installShutdownStdinHandler(process.stdin, shutdown) } if (path.resolve(process.argv[1] ?? "") === __filename) { diff --git a/packages/server/src/server/__tests__/remote-proxy.test.ts b/packages/server/src/server/__tests__/remote-proxy.test.ts index e4ac882e..f4e5053d 100644 --- a/packages/server/src/server/__tests__/remote-proxy.test.ts +++ b/packages/server/src/server/__tests__/remote-proxy.test.ts @@ -13,32 +13,20 @@ import { RemoteProxySessionManager } from "../remote-proxy" import { resolveHttpsOptions } from "../tls" const sharedTempDir = fs.mkdtempSync(path.join(os.tmpdir(), "codenomad-remote-proxy-test-")) -const sharedTls = resolveHttpsOptions({ - enabled: true, - configDir: sharedTempDir, - host: "127.0.0.1", - logger: createStubLogger(), -}) - -if (!sharedTls) { - throw new Error("Failed to generate HTTPS options for remote proxy tests") -} - +const sharedTls = resolveHttpsOptions({ enabled: true, configDir: sharedTempDir, host: "127.0.0.1", logger: createStubLogger() }) +if (!sharedTls) throw new Error("Failed to generate HTTPS options for remote proxy tests") const sharedHttpsOptions = sharedTls.httpsOptions - const httpsDispatcher = new Agent({ connect: { rejectUnauthorized: false } }) const managers = new Set() afterEach(async () => { - for (const manager of managers) { - await disposeManager(manager) - } + for (const manager of managers) await manager.shutdown().catch(() => undefined) managers.clear() }) -after(() => { +after(async () => { fs.rmSync(sharedTempDir, { recursive: true, force: true }) - httpsDispatcher.close().catch(() => {}) + await httpsDispatcher.destroy().catch(() => {}) }) describe("RemoteProxySessionManager", () => { @@ -47,10 +35,8 @@ describe("RemoteProxySessionManager", () => { const manager = createSessionManager() const session1 = await createSession(manager, `${upstreamBaseUrl}/base`) const session2 = await createSession(manager, `${upstreamBaseUrl}/base`) - const blocked = await proxyFetch(`${session1.proxyOrigin}/status`) assert.equal(blocked.status, 403) - const wrongTokenResponse = await proxyFetch(`${session1.proxyOrigin}/__codenomad/api/auth/token`, { method: "POST", headers: { "content-type": "application/json" }, @@ -70,9 +56,7 @@ describe("RemoteProxySessionManager", () => { await withUpstreamServer(async (upstreamBaseUrl) => { const manager = createSessionManager() const session = await createSession(manager, `${upstreamBaseUrl}/base`) - await activateSession(session) - const apiResponse = await proxyFetch(`${session.proxyOrigin}/api/auth/status?foo=bar`) assert.equal(apiResponse.status, 200) assert.equal(await apiResponse.text(), "/base/api/auth/status?foo=bar") @@ -84,10 +68,8 @@ describe("RemoteProxySessionManager", () => { const requestUrl = req.url ?? "" if (requestUrl === "/base/redirect") { res.writeHead(302, { location: "/base/after?ok=1" }) - res.end() - return + return res.end() } - res.writeHead(200, { "content-type": "text/plain" }) res.end(requestUrl) }) @@ -97,21 +79,17 @@ describe("RemoteProxySessionManager", () => { await withUpstreamServer(async (upstreamBaseUrl) => { const manager = createSessionManager() const session = await createSession(manager, `${upstreamBaseUrl}/base`) - await activateSession(session) - const loginResponse = await proxyFetch(`${session.proxyOrigin}/login`) assert.equal(loginResponse.status, 200) const setCookie = getSetCookie(loginResponse)[0] assert.match(setCookie, /^cnrp_[0-9a-f]+_session=abc123/i) assert.doesNotMatch(setCookie, /domain=/i) - const cookieHeader = setCookie.split(";", 1)[0] const whoamiResponse = await proxyFetch(`${session.proxyOrigin}/whoami`, { headers: { cookie: cookieHeader }, }) - assert.equal(await whoamiResponse.text(), "session=abc123") }, (req, res) => { const requestUrl = req.url ?? "" @@ -120,16 +98,12 @@ describe("RemoteProxySessionManager", () => { "content-type": "text/plain", "set-cookie": "session=abc123; Path=/; Secure; HttpOnly; Domain=127.0.0.1", }) - res.end("ok") - return + return res.end("ok") } - if (requestUrl === "/base/whoami") { res.writeHead(200, { "content-type": "text/plain" }) - res.end(req.headers.cookie ?? "") - return + return res.end(req.headers.cookie ?? "") } - res.writeHead(404, { "content-type": "text/plain" }) res.end(requestUrl) }) @@ -139,17 +113,13 @@ describe("RemoteProxySessionManager", () => { await withUpstreamServer(async (upstreamBaseUrl) => { const manager = createSessionManager() const session = await createSession(manager, `${upstreamBaseUrl}/base`) - assert.equal(await manager.deleteSession(session.sessionId), true) assert.equal(await manager.deleteSession(session.sessionId), false) - const session3 = await createSession(manager, `${upstreamBaseUrl}/base`) const internalSessions = (manager as any).sessions as Map const internalCleanup = (manager as any).cleanupExpiredSessions as () => Promise - internalSessions.get(session3.sessionId)!.lastAccessAt = Date.now() - 31 * 60_000 await internalCleanup.call(manager) - assert.equal(internalSessions.has(session3.sessionId), false) assert.equal(await manager.deleteSession(session3.sessionId), false) }, (_req, res) => { @@ -163,9 +133,7 @@ describe("RemoteProxySessionManager", () => { const manager = createSessionManager() const first = await createSession(manager, `${upstreamBaseUrl}/base`) await createSession(manager, `${upstreamBaseUrl}/other`) - await manager.shutdown() - assert.equal((manager as any).sessions.size, 0) await assert.rejects(proxyFetch(`${first.proxyOrigin}/status`)) }, (_req, res) => { @@ -184,21 +152,18 @@ describe("RemoteProxySessionManager", () => { await closeGate.promise return originalClose() } - internalSession.lastAccessAt = Date.now() - 31 * 60_000 const cleanup = (manager as any).cleanupExpiredSessions() as Promise - assert.equal((manager as any).pendingDisposals.size, 1) let shutdownSettled = false const shutdown = manager.shutdown().then(() => { shutdownSettled = true }) await new Promise((resolve) => setImmediate(resolve)) assert.equal(shutdownSettled, false) - closeGate.resolve() await cleanup await shutdown - assert.equal((manager as any).pendingDisposals.size, 0) + assert.equal((manager as any).disposals.size, 0) }, (_req, res) => { res.writeHead(200).end("ok") }) @@ -209,21 +174,18 @@ describe("RemoteProxySessionManager", () => { const manager = createSessionManager({ disposalTimeoutMs: 100 }) const session = await createSession(manager, `${upstreamBaseUrl}/base`) await activateSession(session) - const response = await proxyFetch(`${session.proxyOrigin}/events`) assert.equal(response.status, 200) await Promise.race([ manager.shutdown(), new Promise((_resolve, reject) => setTimeout(() => reject(new Error("shutdown stalled")), 500)), ]) - assert.equal((manager as any).sessions.size, 0) - assert.equal((manager as any).pendingDisposals.size, 0) + assert.equal((manager as any).disposals.size, 0) }, (req, res) => { if (req.url === "/base/events") { res.writeHead(200, { "content-type": "text/event-stream" }) - res.write("data: connected\n\n") - return + return void res.write("data: connected\n\n") } res.writeHead(200).end("ok") }) @@ -235,13 +197,15 @@ describe("RemoteProxySessionManager", () => { const session = await createSession(manager, `${upstreamBaseUrl}/base`) const internalSession = (manager as any).sessions.get(session.sessionId) const originalClose = internalSession.app.close.bind(internalSession.app) + let failClose = true internalSession.app.close = async () => { await originalClose() - throw new Error("close failed") + if (failClose) { failClose = false; throw new Error("close failed") } } - await assert.rejects(manager.deleteSession(session.sessionId), /Remote proxy disposal failed/) - await assert.rejects(manager.shutdown(), /Remote proxy shutdown failed/) + // A completed deletion failure predating shutdown must not poison it. + await manager.shutdown() + assert.equal((manager as any).sessions.size, 0) }, (_req, res) => { res.writeHead(200).end("ok") }) @@ -252,27 +216,53 @@ describe("RemoteProxySessionManager", () => { const manager = createSessionManager() const creation = manager.createSession(`${upstreamBaseUrl}/base`, false) const shutdown = manager.shutdown() - await assert.rejects(creation, /shutting down/) await shutdown - - assert.equal((manager as any).pendingCreations.size, 0) + assert.equal((manager as any).creations.size, 0) assert.equal((manager as any).sessions.size, 0) await assert.rejects(manager.createSession(`${upstreamBaseUrl}/base`, false), /shutting down/) }, (_req, res) => { res.writeHead(200).end("ok") }) }) + + it("coalesces shutdown, retains current disposal failures, and gives every session its own agent", async () => { + await withUpstreamServer(async (upstreamBaseUrl) => { + const manager = createSessionManager() + const verified = await manager.createSession(`${upstreamBaseUrl}/verified`, false) + const insecure = await manager.createSession(`${upstreamBaseUrl}/insecure`, true) + const sessions = (manager as any).sessions as Map + assert.ok(sessions.get(verified.sessionId).dispatcher instanceof Agent) + assert.ok(sessions.get(insecure.sessionId).dispatcher instanceof Agent) + assert.notStrictEqual(sessions.get(verified.sessionId).dispatcher, sessions.get(insecure.sessionId).dispatcher) + + const closeGate = deferred() + const originalClose = sessions.get(verified.sessionId).app.close.bind(sessions.get(verified.sessionId).app) + let failClose = true + sessions.get(verified.sessionId).app.close = async () => { + await closeGate.promise + await originalClose() + if (failClose) { failClose = false; throw new Error("current close failed") } + } + const disposal = manager.deleteSession(verified.sessionId); const first = manager.shutdown() + const concurrent = manager.shutdown() + assert.strictEqual(first, concurrent) + closeGate.resolve() + await assert.rejects(disposal, /Remote proxy disposal failed/) + await assert.rejects(first, (error: unknown) => error instanceof AggregateError && error.errors.some((cause) => + cause instanceof AggregateError && cause.errors.some((nested) => /current close failed/.test(String(nested))))) + await manager.shutdown() + assert.equal(sessions.size, 0) + }, (_req, res) => { + res.writeHead(200).end("ok") + }) + }) }) function createSessionManager(options: { disposalTimeoutMs?: number } = {}) { const manager = new RemoteProxySessionManager({ - authManager: { - isLoopbackRequest: () => true, - } as unknown as AuthManager, - logger: createStubLogger(), - httpsOptions: sharedHttpsOptions, - ...options, + authManager: { isLoopbackRequest: () => true } as unknown as AuthManager, + logger: createStubLogger(), httpsOptions: sharedHttpsOptions, ...options, }) managers.add(manager) return manager @@ -280,9 +270,7 @@ function createSessionManager(options: { disposalTimeoutMs?: number } = {}) { function deferred() { let resolve!: (value: T) => void - const promise = new Promise((resolvePromise) => { - resolve = resolvePromise - }) + const promise = new Promise((resolvePromise) => { resolve = resolvePromise }) return { promise, resolve } } @@ -303,18 +291,14 @@ async function activateSession(session: { proxyOrigin: string; token: string }) headers: { "content-type": "application/json" }, body: JSON.stringify({ token: session.token }), }) - if (!response.ok) { - return false - } + if (!response.ok) return false const body = (await response.json()) as { ok?: boolean } return body.ok === true } function getSetCookie(response: Awaited>): string[] { const values = (response.headers as any).getSetCookie?.() as string[] | undefined - if (Array.isArray(values) && values.length > 0) { - return values - } + if (Array.isArray(values) && values.length > 0) return values const fallback = response.headers.get("set-cookie") return fallback ? [fallback] : [] } @@ -323,22 +307,15 @@ async function proxyFetch(url: string, init?: Parameters[1]) { return fetch(url, { dispatcher: httpsDispatcher, ...init }) } -async function disposeManager(manager: RemoteProxySessionManager) { - await manager.shutdown().catch(() => undefined) -} - async function withUpstreamServer( callback: (baseUrl: string) => Promise, handler: (req: IncomingMessage, res: ServerResponse) => void, ) { const server = http.createServer(handler) await new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve())) - try { const address = server.address() - if (!address || typeof address === "string") { - throw new Error("Failed to resolve upstream server address") - } + if (!address || typeof address === "string") throw new Error("Failed to resolve upstream server address") await callback(`http://127.0.0.1:${address.port}`) } finally { await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))) @@ -346,14 +323,6 @@ async function withUpstreamServer( } function createStubLogger(): Logger { - const logger = { - info() {}, - warn() {}, - error() {}, - child() { - return logger - }, - } - + const logger = { info() {}, warn() {}, error() {}, child() { return logger } } return logger as unknown as Logger } diff --git a/packages/server/src/server/remote-proxy.ts b/packages/server/src/server/remote-proxy.ts index f0e9c09b..a4c47aac 100644 --- a/packages/server/src/server/remote-proxy.ts +++ b/packages/server/src/server/remote-proxy.ts @@ -16,16 +16,12 @@ interface RemoteProxySession { id: string bootstrapToken: string targetBaseUrl: URL - skipTlsVerify: boolean localBaseUrl: URL - entryUrl: URL - bootstrapUrl: string activated: boolean cookiePrefix: string app: FastifyInstance - dispatcher: Agent + dispatcher?: Agent abortController: AbortController - createdAt: number lastAccessAt: number } @@ -43,35 +39,23 @@ export interface RemoteProxySessionCreateResult { export class RemoteProxySessionManager { private readonly sessions = new Map() - private readonly pendingCreations = new Set>() - private readonly pendingDisposals = new Set>() - private readonly disposalFailures: unknown[] = [] + private readonly creations = new Set>() + private readonly disposals = new Set>() + private readonly sessionDisposals = new Map>() private readonly cleanupTimer: NodeJS.Timeout - private readonly disposalTimeoutMs: number private shuttingDown = false + private shutdownPromise?: Promise constructor(private readonly options: RemoteProxySessionManagerOptions) { - this.disposalTimeoutMs = Math.max(1, options.disposalTimeoutMs ?? SESSION_DISPOSAL_TIMEOUT_MS) - this.cleanupTimer = setInterval(() => { - void this.cleanupExpiredSessions().catch((error) => { - this.options.logger.error({ err: error }, "Failed to dispose expired remote proxy session") - }) - }, 60_000) + this.cleanupTimer = setInterval(() => void this.cleanupExpiredSessions().catch((error) => + this.options.logger.error({ err: error }, "Failed to dispose expired remote proxy session")), 60_000) this.cleanupTimer.unref() } async createSession(baseUrl: string, skipTlsVerify: boolean): Promise { - if (this.shuttingDown) { - throw new Error("Remote proxy session manager is shutting down") - } + if (this.shuttingDown) throw new Error("Remote proxy session manager is shutting down") - const creation = this.createSessionInternal(baseUrl, skipTlsVerify) - this.pendingCreations.add(creation) - try { - return await creation - } finally { - this.pendingCreations.delete(creation) - } + return this.track(this.creations, this.createSessionInternal(baseUrl, skipTlsVerify)) } private async createSessionInternal(baseUrl: string, skipTlsVerify: boolean): Promise { @@ -125,7 +109,7 @@ export class RemoteProxySessionManager { reply.send({ ok: true }) }) - app.all("/*", async (request, reply) => { + const handleProxyRequest = async (request: FastifyRequest, reply: FastifyReply) => { if (!session) { reply.code(503).send({ error: "Remote proxy session is unavailable" }) return @@ -138,86 +122,70 @@ export class RemoteProxySessionManager { session.lastAccessAt = Date.now() await proxyRequest({ request, reply, session, logger: this.options.logger }) - }) - - app.setNotFoundHandler(async (request, reply) => { - if (!session) { - reply.code(503).send({ error: "Remote proxy session is unavailable" }) - return - } - - if (!session.activated) { - reply.code(403).send({ error: "Remote proxy session is not activated" }) - return - } - - session.lastAccessAt = Date.now() - await proxyRequest({ request, reply, session, logger: this.options.logger }) - }) + } + app.all("/*", handleProxyRequest) + app.setNotFoundHandler(handleProxyRequest) const addressInfo = await app.listen({ host: LOOPBACK_HOST, port: 0 }) - if (this.shuttingDown) { - abortController.abort() - try { - await this.disposeResources(app, dispatcher) - } catch (error) { - this.disposalFailures.push(error) - throw error - } - throw new Error("Remote proxy session manager is shutting down") - } const address = new URL(addressInfo) const localBaseUrl = new URL(`https://${LOOPBACK_HOST}:${address.port}`) const entryUrl = new URL(targetBaseUrl.pathname || "/", localBaseUrl) const returnTo = buildReturnToTarget(entryUrl) + const bootstrapUrl = `${localBaseUrl.origin}${BOOTSTRAP_PAGE_PATH}?returnTo=${encodeURIComponent(returnTo)}#${encodeURIComponent(bootstrapToken)}` session = { id: sessionId, bootstrapToken, targetBaseUrl, - skipTlsVerify, localBaseUrl, - entryUrl, - bootstrapUrl: `${localBaseUrl.origin}${BOOTSTRAP_PAGE_PATH}?returnTo=${encodeURIComponent(returnTo)}#${encodeURIComponent(bootstrapToken)}`, activated: false, cookiePrefix: `cnrp_${randomBytes(6).toString("hex")}_`, app, dispatcher, abortController, - createdAt: Date.now(), lastAccessAt: Date.now(), } this.sessions.set(sessionId, session) + if (this.shuttingDown) { + await this.disposeSession(sessionId) + throw new Error("Remote proxy session manager is shutting down") + } this.options.logger.info( { sessionId, targetBaseUrl: targetBaseUrl.toString(), localBaseUrl: localBaseUrl.toString() }, "Created remote proxy session", ) - return { sessionId, windowUrl: session.bootstrapUrl } + return { sessionId, windowUrl: bootstrapUrl } } async deleteSession(sessionId: string): Promise { return this.disposeSession(sessionId) } - async shutdown(): Promise { + shutdown(): Promise { + if (this.shutdownPromise) return this.shutdownPromise this.shuttingDown = true clearInterval(this.cleanupTimer) - const disposals = new Set(this.pendingDisposals) - await Promise.allSettled(Array.from(this.pendingCreations)) - for (const sessionId of this.sessions.keys()) disposals.add(this.disposeSession(sessionId)) - while (this.pendingDisposals.size > 0) { - const pending = Array.from(this.pendingDisposals) - for (const disposal of pending) disposals.add(disposal) - await Promise.allSettled(pending) + const shutdown = this.drainShutdown() + this.shutdownPromise = shutdown + void shutdown.finally(() => { + if (this.shutdownPromise === shutdown) this.shutdownPromise = undefined + }).catch(() => undefined) + return shutdown + } + + private async drainShutdown(): Promise { + const disposals = new Set(this.disposals) + while (this.creations.size > 0) { + await Promise.allSettled([...this.creations]) + for (const disposal of this.disposals) disposals.add(disposal) } - const results = await Promise.allSettled(disposals) - const failures = [ - ...this.disposalFailures, - ...results.flatMap((result) => result.status === "rejected" ? [result.reason] : []), - ] - if (failures.length > 0) throw createDisposalError("Remote proxy shutdown failed", failures) + const pendingResults = await Promise.allSettled(disposals) + const results = await Promise.allSettled(Array.from(this.sessions.keys(), (id) => this.disposeSession(id))) + const failures = [...pendingResults, ...results] + .flatMap((result) => result.status === "rejected" ? [result.reason] : []) + if (failures.length) throw new AggregateError(failures, "Remote proxy shutdown failed") } private async cleanupExpiredSessions() { @@ -231,52 +199,47 @@ export class RemoteProxySessionManager { } private disposeSession(sessionId: string): Promise { + const pending = this.sessionDisposals.get(sessionId) + if (pending) return pending const session = this.sessions.get(sessionId) - if (!session) { - return Promise.resolve(false) - } + if (!session) return Promise.resolve(false) - this.sessions.delete(sessionId) session.abortController.abort() - const disposal = this.disposeResources(session.app, session.dispatcher) - .then(() => { - this.options.logger.info({ sessionId }, "Disposed remote proxy session") - return true - }) - .catch((error) => { - this.disposalFailures.push(error) - throw error - }) - this.pendingDisposals.add(disposal) - void disposal.then( - () => this.pendingDisposals.delete(disposal), - () => this.pendingDisposals.delete(disposal), - ) + const disposal = this.trackDisposal(this.disposeResources(session.app, session.dispatcher).then(() => { + if (this.sessions.get(sessionId) === session) this.sessions.delete(sessionId) + this.options.logger.info({ sessionId }, "Disposed remote proxy session") + return true + })) + this.sessionDisposals.set(sessionId, disposal) + void disposal.finally(() => { + if (this.sessionDisposals.get(sessionId) === disposal) this.sessionDisposals.delete(sessionId) + }).catch(() => undefined) return disposal } - private async disposeResources(app: FastifyInstance, dispatcher: Agent): Promise { + private async disposeResources(app: FastifyInstance, dispatcher?: Agent): Promise { app.server.closeAllConnections?.() - const disposal = Promise.allSettled([app.close(), dispatcher.destroy()]) - let timeout: NodeJS.Timeout | undefined - const deadline = new Promise((_resolve, reject) => { - timeout = setTimeout(() => reject(new Error( - `Remote proxy disposal timed out after ${this.disposalTimeoutMs}ms`, - )), this.disposalTimeoutMs) - timeout.unref() - }) - try { - const results = await Promise.race([disposal, deadline]) - const failures = results.flatMap((result) => result.status === "rejected" ? [result.reason] : []) - if (failures.length > 0) throw createDisposalError("Remote proxy disposal failed", failures) - } finally { - if (timeout) clearTimeout(timeout) - } + const results = await Promise.race([ + Promise.allSettled([app.close(), dispatcher?.destroy()]), + new Promise((_resolve, reject) => AbortSignal.timeout( + Math.max(1, this.options.disposalTimeoutMs ?? SESSION_DISPOSAL_TIMEOUT_MS), + ).addEventListener( + "abort", () => reject(new Error("Remote proxy disposal timed out")), + )), + ]) + const failures = results.flatMap((result) => result.status === "rejected" ? [result.reason] : []) + if (failures.length) throw new AggregateError(failures, "Remote proxy disposal failed") } -} -function createDisposalError(message: string, failures: unknown[]): Error & { failures: unknown[] } { - return Object.assign(new Error(message), { failures }) + private track(operations: Set>, operation: Promise): Promise { + operations.add(operation) + void operation.finally(() => operations.delete(operation)).catch(() => undefined) + return operation + } + + private trackDisposal(operation: Promise): Promise { + return this.track(this.disposals, operation) + } } function normalizeBaseUrl(input: string): URL { diff --git a/packages/server/src/server/routes/workspaces.test.ts b/packages/server/src/server/routes/workspaces.test.ts index 063a02d7..e115c5b7 100644 --- a/packages/server/src/server/routes/workspaces.test.ts +++ b/packages/server/src/server/routes/workspaces.test.ts @@ -83,4 +83,44 @@ describe("workspace routes", () => { assert.equal(calls.length, 2) await app.close() }) + + it("rejects release after cancellation wins while deletion is still pending", async () => { + const app = Fastify({ logger: false }) + let state: "active" | "cancelled" | "released" = "active" + let cancellationStarted!: () => void + let finishDeletion!: () => void + const started = new Promise((resolve) => { cancellationStarted = resolve }) + const deletion = new Promise((resolve) => { finishDeletion = resolve }) + const workspaceManager = { + cancelCreationRequest: async () => { + state = "cancelled" + cancellationStarted() + await deletion + }, + releaseCreationRequest: () => { + if (state === "cancelled") return false + state = "released" + return true + }, + } as unknown as WorkspaceManager + registerWorkspaceRoutes(app, { workspaceManager }) + + const cancellation = app.inject({ + method: "POST", + url: "/api/workspaces/creation/cancel", + payload: { requestId: "restore-request" }, + }) + await started + const release = await app.inject({ + method: "POST", + url: "/api/workspaces/workspace/creation/release", + payload: { requestId: "restore-request" }, + }) + + assert.equal(release.statusCode, 404) + assert.equal(release.body, "Workspace creation request not found") + finishDeletion() + assert.equal((await cancellation).statusCode, 204) + await app.close() + }) }) diff --git a/packages/server/src/settings/binaries.test.ts b/packages/server/src/settings/binaries.test.ts index 1c4523f2..5c3fc86d 100644 --- a/packages/server/src/settings/binaries.test.ts +++ b/packages/server/src/settings/binaries.test.ts @@ -9,19 +9,12 @@ describe("BinaryResolver", () => { const settings = { getOwner(scope: string, owner: string) { if (scope === "config" && owner === "server") return { opencodeBinary: "default-opencode" } - if (scope === "state" && owner === "ui") { - return { opencodeBinaries: [{ path: "saved-opencode", label: "Saved", version: "1.2.3" }] } - } + if (scope === "state" && owner === "ui") return { opencodeBinaries: [{ path: "saved-opencode", label: "Saved", version: "1.2.3" }] } return {} }, } as unknown as SettingsService const resolver = new BinaryResolver(settings) - - assert.deepEqual(resolver.resolve("saved-opencode"), { - path: "saved-opencode", - label: "Saved", - version: "1.2.3", - }) + assert.deepEqual(resolver.resolve("saved-opencode"), { path: "saved-opencode", label: "Saved", version: "1.2.3" }) assert.equal(resolver.resolveDefault().path, "default-opencode") }) }) diff --git a/packages/server/src/shutdown.test.ts b/packages/server/src/shutdown.test.ts index 4ae5e9f0..446e7791 100644 --- a/packages/server/src/shutdown.test.ts +++ b/packages/server/src/shutdown.test.ts @@ -1,140 +1,162 @@ import assert from "node:assert/strict" import { describe, it } from "node:test" - import { createServerShutdownHandler, orchestrateServerShutdown, - ServerShutdownError, + SERVER_SHUTDOWN_COMPLETE, + SERVER_SHUTDOWN_INCOMPLETE, type ServerShutdownOperations, } from "./shutdown" -const logger = { - info: () => undefined, - warn: () => undefined, - error: () => undefined, -} - -function operations(overrides: Partial = {}): ServerShutdownOperations { - return { - stopInstanceEventBridge: () => undefined, - stopSidecars: () => undefined, - stopClientConnections: () => undefined, - stopRemoteProxySessions: () => undefined, - stopWorkspaces: () => undefined, - stopHttpServers: () => undefined, - stopReleaseMonitor: () => undefined, - ...overrides, - } -} +const logger = { info() {}, warn() {}, error() {} } +const operations = (overrides: Partial = {}): ServerShutdownOperations => ({ + stopInstanceEventBridge() {}, stopSidecars() {}, stopClientConnections() {}, + stopRemoteProxySessions() {}, stopWorkspaces() {}, stopHttpServers() {}, stopReleaseMonitor() {}, + ...overrides, +}) describe("server shutdown orchestration", () => { - it("retries workspace cleanup before reporting success", async () => { + it("retries workspace cleanup and preserves shutdown order", async () => { + const calls: string[] = [] let attempts = 0 - await orchestrateServerShutdown( - operations({ - stopWorkspaces: () => { - attempts += 1 - if (attempts === 1) throw new Error("detached process still alive") - }, - }), - logger, - ) - - assert.equal(attempts, 2) + await orchestrateServerShutdown(operations({ + stopRemoteProxySessions: () => { calls.push("remote-proxy") }, + stopWorkspaces: () => { calls.push(`workspaces-${++attempts}`); if (attempts === 1) throw new Error("still alive") }, + stopHttpServers: () => { calls.push("http") }, + }), logger) + assert.deepEqual(calls, ["workspaces-1", "remote-proxy", "workspaces-2", "http"]) }) - it("stops remote proxy sessions before workspace and HTTP shutdown", async () => { + it("closes remaining resources and aggregates the concrete current error", async () => { + const failure = new Error("workspace abc POSIX process group is still alive") const closed: string[] = [] - await orchestrateServerShutdown(operations({ - stopRemoteProxySessions: () => { closed.push("remote-proxy") }, - stopWorkspaces: () => { closed.push("workspaces") }, + let attempts = 0 + await assert.rejects(orchestrateServerShutdown(operations({ + stopWorkspaces: () => { attempts++; throw failure }, stopHttpServers: () => { closed.push("http") }, + stopReleaseMonitor: () => { closed.push("release-monitor") }, + }), logger), (error: unknown) => { + assert.ok(error instanceof AggregateError) + assert.equal(error.errors.length, 1) + assert.match(error.errors[0].message, /^stopWorkspaces failed:/) + assert.strictEqual(error.errors[0].cause, failure) + return true + }) + assert.deepEqual([attempts, closed], [2, ["http", "release-monitor"]]) + }) + + it("starts workspace cleanup without waiting for preliminary shutdown", async () => { + let releasePreliminary!: () => void + const preliminary = new Promise((resolve) => { releasePreliminary = resolve }) + let workspaceStarted = false + const shutdown = orchestrateServerShutdown(operations({ + stopRemoteProxySessions: () => preliminary, + stopWorkspaces: () => { workspaceStarted = true }, }), logger) - assert.deepEqual(closed, ["remote-proxy", "workspaces", "http"]) - }) - - it("closes other resources and rejects with the concrete workspace failure", async () => { - const closed: string[] = [] - const cleanupFailure = new Error("workspace abc POSIX process group is still alive") - let attempts = 0 - - await assert.rejects( - orchestrateServerShutdown( - operations({ - stopWorkspaces: () => { - attempts += 1 - throw cleanupFailure - }, - stopHttpServers: () => { - closed.push("http") - }, - stopReleaseMonitor: () => { - closed.push("release-monitor") - }, - }), - logger, - ), - (error: unknown) => { - assert.ok(error instanceof ServerShutdownError) - assert.equal(error.failures[0]?.resource, "Workspace manager") - assert.strictEqual(error.failures[0]?.error, cleanupFailure) - return true - }, - ) - - assert.equal(attempts, 2) - assert.deepEqual(closed, ["http", "release-monitor"]) + await new Promise((resolve) => setImmediate(resolve)) + assert.equal(workspaceStarted, true) + releasePreliminary() + await shutdown }) }) describe("server shutdown signal boundary", () => { - it("forces a nonzero exit after failed bounded cleanup returns", async () => { + it("reports incomplete cleanup and holds for final tree enforcement", async () => { const calls: string[] = [] const exits: number[] = [] + const statuses: string[] = [] + let releaseHold!: () => void + const hold = new Promise((resolve) => { releaseHold = resolve }) const handler = createServerShutdownHandler({ - shutdown: async () => { - calls.push("cleanup") - throw new Error("retained child survived") - }, - logger: { - info: () => calls.push("info"), - warn: () => undefined, - error: () => calls.push("error"), - }, - forceExit: (code) => { - calls.push("force-exit") - exits.push(code) - }, + shutdown: async () => { calls.push("cleanup"); throw new Error("retained child survived") }, + logger: { info: () => calls.push("info"), warn() {}, error: () => calls.push("error") }, + forceExit: (code) => { calls.push("force-exit"); exits.push(code) }, + setExitCode: () => undefined, + reportStatus: (status) => statuses.push(status), + holdAfterFailure: () => hold, + retryAttempts: 0, }) - - await handler("SIGTERM") - + const pending = handler("SIGTERM") + await new Promise((resolve) => setImmediate(resolve)) + assert.deepEqual([exits, statuses, calls], [[], [SERVER_SHUTDOWN_INCOMPLETE], ["info", "cleanup", "error", "error"]]) + handler("SIGTERM") assert.deepEqual(exits, [1]) - assert.deepEqual(calls, ["info", "cleanup", "error", "force-exit"]) + releaseHold() + await pending }) - it("escalates a second signal while shutdown is still pending", async () => { - let finishCleanup!: () => void - const cleanup = new Promise((resolve) => { - finishCleanup = resolve - }) - const exits: number[] = [] - const exitCodes: number[] = [] + it("reports complete cleanup before allowing natural exit", async () => { + const statuses: string[] = [], exitCodes: number[] = [] const handler = createServerShutdownHandler({ - shutdown: () => cleanup, + shutdown: async () => undefined, logger, - forceExit: (code) => exits.push(code), + reportStatus: (status) => statuses.push(status), setExitCode: (code) => exitCodes.push(code), }) + await handler("stdin") + assert.deepEqual(statuses, [SERVER_SHUTDOWN_COMPLETE]) + assert.deepEqual(exitCodes, [0]) + }) - const first = handler("SIGINT") - const second = handler("SIGTERM") + it("keeps retrying identity-aware cleanup during the final enforcement budget", async () => { + let attempts = 0 + const statuses: string[] = [], exitCodes: number[] = [] + const handler = createServerShutdownHandler({ + shutdown: async () => { + attempts++ + if (attempts === 1) throw new Error("detached group still alive") + }, + logger, + reportStatus: (status) => statuses.push(status), + setExitCode: (code) => exitCodes.push(code), + retryDelayMs: 0, + }) + + await handler("stdin") + assert.equal(attempts, 2) + assert.deepEqual(statuses, [SERVER_SHUTDOWN_COMPLETE]) + assert.deepEqual(exitCodes, [0]) + }) + + it("preserves containment after the standalone cleanup retry budget", async () => { + let attempts = 0 + const statuses: string[] = [], exitCodes: number[] = [], forcedExits: number[] = [] + let releaseHold!: () => void + const hold = new Promise((resolve) => { releaseHold = resolve }) + const handler = createServerShutdownHandler({ + shutdown: async () => { attempts++; throw new Error("process tree remains alive") }, + logger, + reportStatus: (status) => statuses.push(status), + setExitCode: (code) => exitCodes.push(code), + forceExit: (code) => forcedExits.push(code), + holdAfterFailure: () => hold, + retryDelayMs: 0, + retryAttempts: 2, + }) + + const pending = handler("stdin") + while (attempts < 3) await new Promise((resolve) => setTimeout(resolve, 0)) + assert.equal(attempts, 3) + assert.deepEqual(statuses, [SERVER_SHUTDOWN_INCOMPLETE]) + assert.deepEqual(exitCodes, [1]) + assert.deepEqual(forcedExits, []) + handler("SIGTERM") + assert.deepEqual(forcedExits, [1]) + releaseHold() + await pending + }) + + it("escalates a second signal while sharing first-signal cleanup", async () => { + let finish!: () => void + const cleanup = new Promise((resolve) => { finish = resolve }) + const exits: number[] = [], exitCodes: number[] = [] + const handler = createServerShutdownHandler({ shutdown: () => cleanup, logger, + forceExit: (code) => exits.push(code), setExitCode: (code) => exitCodes.push(code), reportStatus: () => undefined }) + const first = handler("SIGINT"), second = handler("SIGTERM") assert.strictEqual(first, second) assert.deepEqual(exits, [1]) - - finishCleanup() - await first + finish(); await first assert.deepEqual(exitCodes, [0]) }) }) diff --git a/packages/server/src/shutdown.ts b/packages/server/src/shutdown.ts index 717df67f..3324aa25 100644 --- a/packages/server/src/shutdown.ts +++ b/packages/server/src/shutdown.ts @@ -1,62 +1,56 @@ -interface ShutdownLogger { - info: (data: unknown, message?: string) => void - warn: (data: unknown, message?: string) => void - error: (data: unknown, message?: string) => void -} +type ShutdownLogger = Pick +type ShutdownOperation = () => void | Promise -export interface ServerShutdownOperations { - stopInstanceEventBridge: () => void | Promise - stopSidecars: () => void | Promise - stopClientConnections: () => void | Promise - stopRemoteProxySessions: () => void | Promise - stopWorkspaces: () => void | Promise - stopHttpServers: () => void | Promise - stopReleaseMonitor: () => void | Promise -} +export type ServerShutdownTrigger = NodeJS.Signals | "stdin" +export const SERVER_SHUTDOWN_COMPLETE = "CODENOMAD_SHUTDOWN_STATUS:complete" +export const SERVER_SHUTDOWN_INCOMPLETE = "CODENOMAD_SHUTDOWN_STATUS:incomplete" -export class ServerShutdownError extends Error { - readonly failures: Array<{ resource: string; error: unknown }> +export type ServerShutdownOperations = Record< + "stopInstanceEventBridge" | "stopSidecars" | "stopClientConnections" | "stopRemoteProxySessions" | "stopWorkspaces" | + "stopHttpServers" | "stopReleaseMonitor", + ShutdownOperation +> - constructor(failures: Array<{ resource: string; error: unknown }>) { - super(`Server shutdown failed while stopping: ${failures.map((failure) => failure.resource).join(", ")}`) - this.name = "ServerShutdownError" - this.failures = failures - } -} - -export interface ServerShutdownHandlerOptions { - shutdown: () => Promise - logger: ShutdownLogger - forceExit?: (code: number) => void - setExitCode?: (code: number) => void -} - -export function createServerShutdownHandler(options: ServerShutdownHandlerOptions) { - const forceExit = options.forceExit ?? ((code: number) => process.exit(code)) - const setExitCode = options.setExitCode ?? ((code: number) => { - process.exitCode = code - }) - let shutdownPromise: Promise | undefined - - return (signal: NodeJS.Signals): Promise => { - if (shutdownPromise) { +export function createServerShutdownHandler(options: { shutdown: () => Promise; logger: ShutdownLogger; + forceExit?: (code: number) => void; setExitCode?: (code: number) => void; + reportStatus?: (status: string) => void; holdAfterFailure?: () => Promise; + retryDelayMs?: number; retryAttempts?: number }) { + const forceExit = options.forceExit ?? process.exit + const setExitCode = options.setExitCode ?? ((code: number) => { process.exitCode = code }) + const reportStatus = options.reportStatus ?? ((status: string) => console.log(status)) + let pending: Promise | undefined + return (signal: ServerShutdownTrigger): Promise => { + if (pending) { options.logger.error({ signal }, "Additional shutdown signal received; forcing nonzero exit") forceExit(1) - return shutdownPromise + return pending } - options.logger.info({ signal }, "Received shutdown signal, stopping workspaces and server") - shutdownPromise = (async () => { - try { - await options.shutdown() - options.logger.info({}, "Shutdown complete") - setExitCode(0) - } catch (error) { - options.logger.error({ err: error }, "Server shutdown incomplete; forcing nonzero exit") - forceExit(1) + pending = Promise.resolve().then(options.shutdown).then(() => { + options.logger.info({}, "Shutdown complete") + reportStatus(SERVER_SHUTDOWN_COMPLETE) + setExitCode(0) + }, async (error) => { + options.logger.error({ err: error }, "Server shutdown incomplete; retrying cleanup") + const retryAttempts = Math.max(0, Math.floor(options.retryAttempts ?? 3)) + for (let attempt = 1; attempt <= retryAttempts; attempt += 1) { + await new Promise((resolve) => setTimeout(resolve, options.retryDelayMs ?? 250)) + try { + await options.shutdown() + options.logger.info({}, "Shutdown cleanup retry completed") + reportStatus(SERVER_SHUTDOWN_COMPLETE) + setExitCode(0) + return + } catch (retryError) { + options.logger.warn({ err: retryError, attempt, attempts: retryAttempts }, "Shutdown cleanup retry remains incomplete") + } } - })() - return shutdownPromise + options.logger.error({ attempts: retryAttempts }, "Shutdown cleanup retries exhausted; preserving process-tree containment") + reportStatus(SERVER_SHUTDOWN_INCOMPLETE) + setExitCode(1) + if (options.holdAfterFailure) await options.holdAfterFailure() + }) + return pending } } @@ -65,45 +59,43 @@ export async function orchestrateServerShutdown( logger: ShutdownLogger, workspaceAttempts = 2, ): Promise { - const failures: Array<{ resource: string; error: unknown }> = [] - const run = async (resource: string, operation: () => void | Promise) => { - try { - await operation() - } catch (error) { - failures.push({ resource, error }) - logger.error({ err: error, resource }, `${resource} shutdown failed`) + const errors: unknown[] = [] + const namedError = (name: keyof ServerShutdownOperations, cause: unknown) => Object.assign( + new Error(`${name} failed: ${cause instanceof Error ? cause.message : String(cause)}`), + { cause }, + ) + const settle = async (pending: Array<[keyof ServerShutdownOperations, ShutdownOperation]>) => { + const results = await Promise.allSettled(pending.map(([, run]) => Promise.resolve().then(run))) + for (let index = 0; index < results.length; index += 1) { + const result = results[index]! + if (result.status !== "rejected") continue + const error = namedError(pending[index]![0], result.reason) + errors.push(error) + logger.error({ err: error }, "Server resource shutdown failed") } } - await run("Instance event bridge", operations.stopInstanceEventBridge) - await run("SideCar manager", operations.stopSidecars) - await run("Client connection manager", operations.stopClientConnections) - await run("Remote proxy session manager", operations.stopRemoteProxySessions) - - const attempts = Math.max(1, Math.floor(workspaceAttempts)) - let workspaceFailure: unknown - for (let attempt = 1; attempt <= attempts; attempt += 1) { - try { - await operations.stopWorkspaces() - workspaceFailure = undefined - logger.info({ attempt }, "Workspace manager shutdown complete") - break - } catch (error) { - workspaceFailure = error + const workspaceShutdown = (async () => { + const attempts = Math.max(1, Math.floor(workspaceAttempts)) + for (let attempt = 1; attempt <= attempts; attempt += 1) { + const [result] = await Promise.allSettled([Promise.resolve().then(operations.stopWorkspaces)]) + if (result.status === "fulfilled") break if (attempt < attempts) { - logger.warn({ err: error, attempt, attempts }, "Workspace manager shutdown failed; retrying cleanup") + logger.warn({ err: result.reason, attempt, attempts }, "Workspace manager shutdown failed; retrying cleanup") + continue } + const error = namedError("stopWorkspaces", result.reason) + errors.push(error) + logger.error({ err: error, attempts }, "Workspace manager shutdown failed") } - } - if (workspaceFailure !== undefined) { - failures.push({ resource: "Workspace manager", error: workspaceFailure }) - logger.error({ err: workspaceFailure, attempts }, "Workspace manager shutdown failed") - } - - await run("HTTP servers", operations.stopHttpServers) - await run("Release monitor", operations.stopReleaseMonitor) - - if (failures.length > 0) { - throw new ServerShutdownError(failures) - } + })() + await Promise.all([ + settle([ + ["stopInstanceEventBridge", operations.stopInstanceEventBridge], ["stopSidecars", operations.stopSidecars], + ["stopClientConnections", operations.stopClientConnections], ["stopRemoteProxySessions", operations.stopRemoteProxySessions], + ]), + workspaceShutdown, + ]) + await settle([["stopHttpServers", operations.stopHttpServers], ["stopReleaseMonitor", operations.stopReleaseMonitor]]) + if (errors.length) throw new AggregateError(errors, "Server shutdown failed") } diff --git a/packages/server/src/workspaces/__tests__/spawn.test.ts b/packages/server/src/workspaces/__tests__/spawn.test.ts index d2e9822e..d11d8a66 100644 --- a/packages/server/src/workspaces/__tests__/spawn.test.ts +++ b/packages/server/src/workspaces/__tests__/spawn.test.ts @@ -60,7 +60,7 @@ describe("buildWindowsSpawnSpec", () => { assert.equal(buildWindowsSpawnSpec("opencode", []).processKind, "windows-wrapper") }) - it("resolves a bare cmd shim from a quoted PATH entry and wraps its absolute path", () => { + it("resolves a bare cmd shim from a quoted PATH entry and wraps its absolute path", { skip: process.platform !== "win32" }, () => { const root = mkdtempSync(path.join(tmpdir(), "codenomad-spawn-")) const cwd = path.join(root, "workspace") const bin = path.join(root, "bin with spaces") @@ -84,7 +84,7 @@ describe("buildWindowsSpawnSpec", () => { } }) - it("honors PATHEXT precedence when both native and shim files exist", () => { + it("honors PATHEXT precedence when both native and shim files exist", { skip: process.platform !== "win32" }, () => { const root = mkdtempSync(path.join(tmpdir(), "codenomad-spawn-")) writeFileSync(path.join(root, "opencode.cmd"), "@echo off\r\n") writeFileSync(path.join(root, "opencode.exe"), "") diff --git a/packages/server/src/workspaces/__tests__/workspace-identity.test.ts b/packages/server/src/workspaces/__tests__/workspace-identity.test.ts index 5854adae..ccf8e6ca 100644 --- a/packages/server/src/workspaces/__tests__/workspace-identity.test.ts +++ b/packages/server/src/workspaces/__tests__/workspace-identity.test.ts @@ -10,22 +10,24 @@ import { WorkspaceManager } from "../manager" import { normalizeWorkspaceIdentityPath, resolveWorkspaceIdentity } from "../workspace-identity" const temporaryDirectories: string[] = [] +const runtimeResult = (pid = 123) => ({ + pid, + port: 4321, + exitPromise: new Promise(() => undefined), + getLastOutput: () => "", +}) function deferred() { let resolve!: (value: T) => void - let reject!: (reason?: unknown) => void - const promise = new Promise((resolvePromise, rejectPromise) => { - resolve = resolvePromise - reject = rejectPromise - }) - return { promise, resolve, reject } + const promise = new Promise((resolvePromise) => { resolve = resolvePromise }) + return { promise, resolve } } afterEach(async () => { await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { force: true, recursive: true }))) }) -async function createLinkedWorkspace(): Promise<{ root: string; target: string; link: string }> { +async function createLinkedWorkspace() { const root = await mkdtemp(path.join(os.tmpdir(), "codenomad-workspace-identity-")) temporaryDirectories.push(root) const target = path.join(root, "target") @@ -35,195 +37,118 @@ async function createLinkedWorkspace(): Promise<{ root: string; target: string; return { root, target, link } } -function createManager(rootDir: string): WorkspaceManager { +function createManager(rootDir: string) { const logger = pino({ level: "silent" }) const manager = new WorkspaceManager({ rootDir, settings: { getOwner: () => ({ environmentVariables: {} }) }, - binaryResolver: { - resolve: () => ({ path: process.execPath, label: "Node.js", version: process.version }), - }, + binaryResolver: { resolve: () => ({ path: process.execPath, label: "Node.js", version: process.version }) }, eventBus: new EventBus(logger), logger, getServerBaseUrl: () => "http://127.0.0.1:3000", } as unknown as ConstructorParameters[0]) - - const internal = manager as any - internal.runtime.launch = async () => ({ - pid: 123, - port: 4321, - exitPromise: new Promise(() => {}), - cancellationPromise: new Promise(() => {}), - getLastOutput: () => "", - }) - internal.waitForWorkspaceReadiness = async () => undefined + ;(manager as any).runtime.launch = async () => runtimeResult() + ;(manager as any).runtime.stop = async () => undefined + ;(manager as any).waitForWorkspaceReadiness = async () => undefined return manager } +async function waitForOwners(manager: WorkspaceManager, count: number) { + while ([...(manager as any).pendingWorkspaceCreations.values()][0]?.ownership.size !== count) { + await new Promise((resolve) => setImmediate(resolve)) + } +} + +async function createSharedLaunch() { + const { root, target, link } = await createLinkedWorkspace() + const manager = createManager(root) + const launchGate = deferred() + let launches = 0 + ;(manager as any).runtime.launch = async () => { + launches += 1 + await launchGate.promise + return runtimeResult() + } + const leader = manager.create(target, undefined, { requestId: "leader" }) + const follower = manager.create(link, undefined, { requestId: "follower" }) + await waitForOwners(manager, 2) + return { manager, launchGate, leader, follower, launches: () => launches } +} + describe("workspace identity", () => { - it("normalizes Windows drive and UNC paths without affecting POSIX case", () => { + it("normalizes Windows paths without affecting POSIX case", () => { assert.equal(normalizeWorkspaceIdentityPath("C:\\Projects\\CodeNomad\\", "win32"), "c:\\projects\\codenomad\\") assert.equal(normalizeWorkspaceIdentityPath(String.raw`\\Server\Share\Repo`, "win32"), String.raw`\\server\share\repo`) assert.equal(normalizeWorkspaceIdentityPath("/Projects/CodeNomad/", "linux"), "/Projects/CodeNomad/") }) - it("resolves a symlink and its target to the same canonical launch path", async () => { + it("canonicalizes aliases and falls back to an absolute identity for missing paths", async () => { const { root, target, link } = await createLinkedWorkspace() - const targetResult = await resolveWorkspaceIdentity(target, root) - const linkResult = await resolveWorkspaceIdentity(link, root) + const [targetResult, linkResult, missing] = await Promise.all([ + resolveWorkspaceIdentity(target, root), + resolveWorkspaceIdentity(link, root), + resolveWorkspaceIdentity("missing", root), + ]) + const expectedMissing = path.resolve(root, "missing") assert.equal(linkResult.identityKey, targetResult.identityKey) assert.equal(linkResult.workspacePath, targetResult.workspacePath) - assert.notEqual(linkResult.workspacePath, path.normalize(link)) + assert.equal(missing.workspacePath, expectedMissing) + assert.equal(missing.identityKey, normalizeWorkspaceIdentityPath(expectedMissing)) }) - it("falls back to a normalized absolute identity when realpath fails", async () => { - const root = await mkdtemp(path.join(os.tmpdir(), "codenomad-workspace-missing-")) - temporaryDirectories.push(root) - const result = await resolveWorkspaceIdentity("missing", root) - const expected = path.resolve(root, "missing") - - assert.equal(result.workspacePath, expected) - assert.equal(result.identityKey, normalizeWorkspaceIdentityPath(expected)) - }) - - it("atomically reuses an active workspace reached through a symlink", async () => { + it("deduplicates active canonical aliases", async () => { const { root, target, link } = await createLinkedWorkspace() const manager = createManager(root) - const [targetResult, linkResult] = await Promise.all([manager.create(target), manager.create(link)]) - - assert.equal(Number(targetResult.created) + Number(linkResult.created), 1) - assert.equal(targetResult.workspace.id, linkResult.workspace.id) - assert.equal(manager.list().length, 1) - }) - - it("shares an in-flight startup between canonical aliases", async () => { - const { root, target, link } = await createLinkedWorkspace() - const manager = createManager(root) - const launchGate = deferred() - let launches = 0 - ;(manager as any).runtime.launch = async () => { - launches += 1 - await launchGate.promise - return { - pid: 123, - port: 4321, - exitPromise: new Promise(() => {}), - cancellationPromise: new Promise(() => {}), - getLastOutput: () => "", - } - } - - const firstPromise = manager.create(target, undefined, { requestId: "restore-request" }) - const secondPromise = manager.create(link) - while (![...(manager as any).pendingWorkspaceCreations.values()][0]?.followerCount) { - await new Promise((resolve) => setImmediate(resolve)) - } - launchGate.resolve() - const [first, second] = await Promise.all([firstPromise, secondPromise]) - - assert.equal(launches, 1) - assert.equal(first.created, false) - assert.equal(first.workspace.requestId, undefined) - assert.equal(first.workspace.id, second.workspace.id) - assert.equal(first.workspace.status, "ready") - assert.equal(second.workspace.status, "ready") - }) - - it("keeps both cleanup owners when two restore requests share a canonical launch", async () => { - const { root, target, link } = await createLinkedWorkspace() - const manager = createManager(root) - const [first, second] = await Promise.all([ - manager.create(target, undefined, { requestId: "restore-first" }), - manager.create(link, undefined, { requestId: "restore-second" }), - ]) + const [first, second] = await Promise.all([manager.create(target), manager.create(link)]) assert.equal(Number(first.created) + Number(second.created), 1) assert.equal(first.workspace.id, second.workspace.id) - assert.equal(first.workspace.requestId, "restore-first") - assert.equal(second.workspace.requestId, "restore-second") - assert.equal(manager.releaseCreationRequest(first.workspace.id, "restore-first"), true) - assert.equal(manager.releaseCreationRequest(first.workspace.id, "restore-second"), true) - }) - - it("detaches a cancelled restore follower without stopping the leader launch", async () => { - const { root, target, link } = await createLinkedWorkspace() - const manager = createManager(root) - const launchGate = deferred() - let launches = 0 - ;(manager as any).runtime.launch = async () => { - launches += 1 - await launchGate.promise - return { - pid: 123, - port: 4321, - exitPromise: new Promise(() => {}), - cancellationPromise: new Promise(() => {}), - getLastOutput: () => "", - } - } - - const leader = manager.create(target, undefined, { requestId: "restore-leader" }) - const follower = manager.create(link, undefined, { requestId: "restore-follower" }) - while (![...(manager as any).pendingWorkspaceCreations.values()][0]?.followerCount) { - await new Promise((resolve) => setImmediate(resolve)) - } - await manager.cancelCreationRequest("restore-follower") - launchGate.resolve() - - const leaderResult = await leader - await assert.rejects(follower, /creation request restore-follower was cancelled/) - assert.equal(launches, 1) - assert.equal(leaderResult.workspace.requestId, "restore-leader") assert.equal(manager.list().length, 1) }) - it("detaches a cancelled restore leader without stopping a follower launch", async () => { - const { root, target, link } = await createLinkedWorkspace() - const manager = createManager(root) - const launchGate = deferred() - ;(manager as any).runtime.launch = async () => { - await launchGate.promise - return { - pid: 123, - port: 4321, - exitPromise: new Promise(() => {}), - cancellationPromise: new Promise(() => {}), - getLastOutput: () => "", - } - } + it("shares one in-flight launch between canonical aliases", async () => { + const shared = await createSharedLaunch() + shared.launchGate.resolve() + const [leader, follower] = await Promise.all([shared.leader, shared.follower]) - const leader = manager.create(target, undefined, { requestId: "restore-leader" }) - const follower = manager.create(link, undefined, { requestId: "restore-follower" }) - while (![...(manager as any).pendingWorkspaceCreations.values()][0]?.followerCount) { - await new Promise((resolve) => setImmediate(resolve)) - } - await manager.cancelCreationRequest("restore-leader") - launchGate.resolve() - - await assert.rejects(leader, /creation request restore-leader was cancelled/) - const followerResult = await follower - assert.equal(followerResult.workspace.requestId, "restore-follower") - assert.equal(manager.list().length, 1) + assert.equal(shared.launches(), 1) + assert.equal(leader.workspace.id, follower.workspace.id) + assert.equal(Number(leader.created) + Number(follower.created), 1) + assert.equal(leader.workspace.status, "ready") }) - it("retains a shared workspace after one restore owner releases and another cancels", async () => { - const { root, target, link } = await createLinkedWorkspace() - const manager = createManager(root) - const [first, second] = await Promise.all([ - manager.create(target, undefined, { requestId: "restore-first" }), - manager.create(link, undefined, { requestId: "restore-second" }), - ]) + for (const cancelledRole of ["leader", "follower"] as const) { + it(`detaches a cancelled ${cancelledRole} without stopping its shared owner`, async () => { + const shared = await createSharedLaunch() + await shared.manager.cancelCreationRequest(cancelledRole) + shared.launchGate.resolve() + const cancelled = shared[cancelledRole] + const survivor = shared[cancelledRole === "leader" ? "follower" : "leader"] - assert.equal(manager.releaseCreationRequest(first.workspace.id, "restore-first"), true) - await manager.cancelCreationRequest("restore-second") + await assert.rejects(cancelled, new RegExp(`creation request ${cancelledRole} was cancelled`)) + const result = await survivor + assert.equal(shared.launches(), 1) + assert.equal(result.workspace.requestId, cancelledRole === "leader" ? "follower" : "leader") + assert.equal(shared.manager.list().length, 1) + }) + } - assert.equal(manager.list().length, 1) - assert.equal(manager.get(second.workspace.id)?.requestId, undefined) - assert.equal(manager.releaseCreationRequest(second.workspace.id, "restore-second"), true) - }) + for (const releasedRole of ["leader", "follower"] as const) { + it(`retains shared ownership when the ${releasedRole} releases and the other owner cancels`, async () => { + const shared = await createSharedLaunch() + shared.launchGate.resolve() + const [leader, follower] = await Promise.all([shared.leader, shared.follower]) + assert.equal(leader.workspace.id, follower.workspace.id) - it("releases a failed identity reservation so creation can be retried", async () => { + assert.equal(shared.manager.releaseCreationRequest(leader.workspace.id, releasedRole), true) + await shared.manager.cancelCreationRequest(releasedRole === "leader" ? "follower" : "leader") + assert.equal(shared.manager.list().length, 1) + assert.equal(shared.manager.get(leader.workspace.id)?.requestId, undefined) + }) + } + + it("releases a failed canonical reservation for retry", async () => { const { root, target, link } = await createLinkedWorkspace() const manager = createManager(root) const launchGate = deferred() @@ -233,207 +158,29 @@ describe("workspace identity", () => { await launchGate.promise throw new Error("launch failed") } - - const firstFailure = manager.create(target) - const secondFailure = manager.create(link) - while (![...(manager as any).pendingWorkspaceCreations.values()][0]?.followerCount) { - await new Promise((resolve) => setImmediate(resolve)) - } + const failures = [ + manager.create(target, undefined, { requestId: "first" }), + manager.create(link, undefined, { requestId: "second" }), + ] + await waitForOwners(manager, 2) launchGate.resolve() - const failed = await Promise.allSettled([firstFailure, secondFailure]) - assert.deepEqual(failed.map((result) => result.status), ["rejected", "rejected"]) + assert.deepEqual((await Promise.allSettled(failures)).map((result) => result.status), ["rejected", "rejected"]) assert.equal(launches, 1) - ;(manager as any).runtime.launch = async () => ({ - pid: 456, - port: 5432, - exitPromise: new Promise(() => {}), - cancellationPromise: new Promise(() => {}), - getLastOutput: () => "", - }) - const retry = await manager.create(target) - - assert.equal(retry.created, true) - assert.equal(retry.workspace.status, "ready") + ;(manager as any).runtime.launch = async () => runtimeResult(456) + assert.equal((await manager.create(target)).created, true) }) - it("allows an explicit second workspace for the same canonical path", async () => { + it("allows forced canonical duplicates without replacing the reusable workspace", async () => { const { root, target, link } = await createLinkedWorkspace() const manager = createManager(root) - const [first, second] = await Promise.all([ - manager.create(target, undefined, { forceNew: true }), - manager.create(link, undefined, { forceNew: true }), - ]) + const normal = await manager.create(target) + const forced = await manager.create(link, undefined, { forceNew: true }) + assert.notEqual(normal.workspace.id, forced.workspace.id) - assert.equal(first.created, true) - assert.equal(second.created, true) - assert.notEqual(first.workspace.id, second.workspace.id) - assert.equal(manager.list().length, 2) - }) - - it("does not reuse restore-owned workspaces until hydration releases ownership", async () => { - const { root, target, link } = await createLinkedWorkspace() - const manager = createManager(root) - const restore = await manager.create(target, undefined, { - forceNew: true, - requestId: "restore-request", - }) - - const concurrent = await manager.create(link) - assert.equal(concurrent.created, true) - assert.notEqual(concurrent.workspace.id, restore.workspace.id) - - assert.equal(manager.releaseCreationRequest(restore.workspace.id, "wrong-request"), false) - assert.equal(manager.releaseCreationRequest(restore.workspace.id, "restore-request"), true) - assert.equal(manager.releaseCreationRequest(restore.workspace.id, "restore-request"), true) - await manager.delete(concurrent.workspace.id) + await manager.delete(forced.workspace.id) const reused = await manager.create(link) assert.equal(reused.created, false) - assert.equal(reused.workspace.id, restore.workspace.id) - }) - - it("keeps the canonical reservation when a forced duplicate is deleted", async () => { - const { root, target, link } = await createLinkedWorkspace() - const manager = createManager(root) - const normalLaunch = deferred<{ - pid: number - port: number - exitPromise: Promise - cancellationPromise: Promise - getLastOutput: () => string - }>() - const forcedLaunch = deferred<{ - pid: number - port: number - exitPromise: Promise - cancellationPromise: Promise - getLastOutput: () => string - }>() - let launches = 0 - const launchedWorkspaceIds: string[] = [] - ;(manager as any).runtime.launch = (options: { workspaceId: string }) => { - launches += 1 - launchedWorkspaceIds.push(options.workspaceId) - return launches === 1 ? normalLaunch.promise : forcedLaunch.promise - } - - const first = manager.create(target) - while (launchedWorkspaceIds.length < 1) { - await new Promise((resolve) => setImmediate(resolve)) - } - const forced = manager.create(link, undefined, { forceNew: true }) - const forcedRejected = assert.rejects(forced, /launch was cancelled/) - while (launchedWorkspaceIds.length < 2) { - await new Promise((resolve) => setImmediate(resolve)) - } - const forcedDeletion = manager.delete(launchedWorkspaceIds[1]!) - - const reused = manager.create(link) - normalLaunch.resolve({ pid: 123, port: 4321, exitPromise: new Promise(() => {}), cancellationPromise: new Promise(() => {}), getLastOutput: () => "" }) - forcedLaunch.resolve({ pid: 456, port: 5432, exitPromise: new Promise(() => {}), cancellationPromise: new Promise(() => {}), getLastOutput: () => "" }) - await forcedDeletion - const [firstResult, reusedResult] = await Promise.all([first, reused]) - - await forcedRejected - assert.equal(launches, 2) - assert.equal(firstResult.workspace.id, reusedResult.workspace.id) - assert.equal(reusedResult.created, false) - }) - - it("cancels an in-flight startup when its workspace is deleted", async () => { - const { root, target } = await createLinkedWorkspace() - const manager = createManager(root) - const launch = deferred<{ - pid: number - port: number - exitPromise: Promise - cancellationPromise: Promise - getLastOutput: () => string - }>() - let workspaceId = "" - ;(manager as any).runtime.launch = (options: { workspaceId: string }) => { - workspaceId = options.workspaceId - return launch.promise - } - const events: any[] = [] - ;(manager as any).options.eventBus.onEvent((event: any) => events.push(event)) - - const creation = manager.create(target) - const creationRejected = assert.rejects(creation, /launch was cancelled/) - while (!workspaceId) { - await new Promise((resolve) => setImmediate(resolve)) - } - const deletion = manager.delete(workspaceId) - launch.resolve({ pid: 123, port: 4321, exitPromise: new Promise(() => {}), cancellationPromise: new Promise(() => {}), getLastOutput: () => "" }) - - await Promise.all([creationRejected, deletion]) - assert.equal(manager.get(workspaceId), undefined) - assert.equal(events.filter((event) => event.type === "workspace.stopped" && event.workspaceId === workspaceId).length, 1) - }) - - it("cancels an in-flight restore startup by request id", async () => { - const { root, target } = await createLinkedWorkspace() - const manager = createManager(root) - const launch = deferred<{ - pid: number - port: number - exitPromise: Promise - cancellationPromise: Promise - getLastOutput: () => string - }>() - let workspaceId = "" - ;(manager as any).runtime.launch = (options: { workspaceId: string }) => { - workspaceId = options.workspaceId - return launch.promise - } - - const creation = manager.create(target, undefined, { requestId: "cancel-restore" }) - const creationRejected = assert.rejects(creation, /launch was cancelled/) - while (!workspaceId) await new Promise((resolve) => setImmediate(resolve)) - const cancellation = manager.cancelCreationRequest("cancel-restore") - launch.resolve({ pid: 123, port: 4321, exitPromise: new Promise(() => {}), cancellationPromise: new Promise(() => {}), getLastOutput: () => "" }) - - await Promise.all([creationRejected, cancellation]) - assert.equal(manager.get(workspaceId), undefined) - }) - - it("remembers cancellation that arrives before restore reservation", async () => { - const { root, target } = await createLinkedWorkspace() - const manager = createManager(root) - - await manager.cancelCreationRequest("early-cancel") - await assert.rejects( - manager.create(target, undefined, { requestId: "early-cancel" }), - /creation request early-cancel was cancelled/, - ) - assert.equal(manager.list().length, 0) - }) - - it("waits for and cancels forced creations during shutdown", async () => { - const { root, target } = await createLinkedWorkspace() - const manager = createManager(root) - const launch = deferred<{ - pid: number - port: number - exitPromise: Promise - cancellationPromise: Promise - getLastOutput: () => string - }>() - let workspaceId = "" - ;(manager as any).runtime.launch = (options: { workspaceId: string }) => { - workspaceId = options.workspaceId - return launch.promise - } - - const creation = manager.create(target, undefined, { forceNew: true }) - const creationRejected = assert.rejects(creation, /launch was cancelled/) - while (!workspaceId) { - await new Promise((resolve) => setImmediate(resolve)) - } - const shutdown = manager.shutdown() - launch.resolve({ pid: 123, port: 4321, exitPromise: new Promise(() => {}), cancellationPromise: new Promise(() => {}), getLastOutput: () => "" }) - - await Promise.all([creationRejected, shutdown]) - assert.equal(manager.list().length, 0) + assert.equal(reused.workspace.id, normal.workspace.id) }) }) diff --git a/packages/server/src/workspaces/launch-cleanup.test.ts b/packages/server/src/workspaces/launch-cleanup.test.ts index 92f72b35..2a2f34f8 100644 --- a/packages/server/src/workspaces/launch-cleanup.test.ts +++ b/packages/server/src/workspaces/launch-cleanup.test.ts @@ -1,50 +1,34 @@ import assert from "node:assert/strict" -import type { SpawnSyncReturns } from "node:child_process" +import { spawnSync, type SpawnSyncReturns } from "node:child_process" import { describe, it } from "node:test" +import { LAUNCH_CLEANUP_TOKEN_ENV, probeLaunchCleanupToken, signalLaunchCleanupToken } from "./process-identity" -import { - LAUNCH_CLEANUP_TOKEN_ENV, - probeLaunchCleanupToken, - signalLaunchCleanupToken, -} from "./launch-cleanup" - -type SpawnCommand = typeof import("node:child_process").spawnSync - -function result(stdout = "", status = 0, stderr = ""): SpawnSyncReturns { - return { pid: 1, output: [null, stdout, stderr], stdout, stderr, status, signal: null } -} +type Spawn = typeof import("node:child_process").spawnSync +const result = (stdout: string): SpawnSyncReturns => ({ pid: 1, output: [null, stdout, ""], stdout, stderr: "", status: 0, signal: null }) describe("launch cleanup token adapter", () => { - it("passes the exact token to a bounded Linux environ probe", () => { - const token = "a".repeat(64) - let invocation: { command: string; args: readonly string[]; timeout?: number } | undefined - const probe = probeLaunchCleanupToken(((command: string, args: readonly string[], options: { timeout?: number }) => { - invocation = { command, args, timeout: options.timeout } - return result("5000|1|4242|150|boot-a|150\n") - }) as unknown as SpawnCommand, token, 25) - + it("passes the exact token to the bounded Linux environ probe", () => { + const token = "a".repeat(64), calls: any[] = [] + const run = ((command: string, args: string[], options: object) => { calls.push(command, args, options); return result("CODENOMAD_PROCESS|5000|1|4242|150|boot-a|150\n") }) as unknown as Spawn + const probe = probeLaunchCleanupToken(run, token, 25) assert.equal(probe.ok && probe.processes.get(5000)?.startOrder, "150") - assert.equal(invocation?.command, "sh") - assert.equal(invocation?.timeout, 25) - assert.ok(invocation?.args.includes(LAUNCH_CLEANUP_TOKEN_ENV)) - assert.ok(invocation?.args.includes(token)) - assert.match(invocation?.args[1] ?? "", /\/proc\/\$1\/environ/) + assert.equal(calls[0], "sh") + assert.deepEqual([calls[2].timeout, calls[1].includes(LAUNCH_CLEANUP_TOKEN_ENV), calls[1].includes(token)], [25, true, true]) + assert.match(calls[1][1], /\/proc\/\$1\/environ/) + assert.doesNotMatch(calls[1][1], /\bseq\b/) }) - it("signals every exact-token target returned by the bounded adapter", () => { - const cleanup = signalLaunchCleanupToken((() => result( - "CODENOMAD_TARGET|4242|1|4242|100|boot-a|100\n" + - "CODENOMAD_TARGET|5000|1|4242|150|boot-a|150\n" + - "CODENOMAD_RESULT|1\n", - )) as unknown as SpawnCommand, "b".repeat(64), "SIGKILL", 25) - - assert.equal(cleanup.ok, true) - assert.deepEqual(cleanup.targets.map((target) => target.pid), [4242, 5000]) + it("executes a successful empty Linux token probe", { skip: process.platform !== "linux" }, () => { + const probe = probeLaunchCleanupToken(spawnSync, "f".repeat(64), 1_000) + assert.deepEqual(probe, { ok: true, processes: new Map() }) }) - it("rejects malformed cleanup records conservatively", () => { - const probe = probeLaunchCleanupToken((() => result("5000|1|4242|150|boot-a|150|truncated\n")) as unknown as SpawnCommand, "c".repeat(64), 25) - - assert.equal(probe.ok, false) + it("signals every exact-token target and rejects malformed records", () => { + const rows = "CODENOMAD_TARGET|4242|1|4242|100|boot-a|100\nCODENOMAD_TARGET|5000|1|4242|150|boot-a|150\nCODENOMAD_RESULT|1\n" + const run = ((() => result(rows)) as unknown) as Spawn + const cleanup = signalLaunchCleanupToken(run, "b".repeat(64), "SIGKILL", 25) + assert.deepEqual([cleanup.ok, cleanup.targets.map(({ pid }) => pid)], [true, [4242, 5000]]) + const malformed = ((() => result("5000|1|4242|150|boot-a|150|truncated\n")) as unknown) as Spawn + assert.equal(probeLaunchCleanupToken(malformed, "c".repeat(64), 25).ok, false) }) }) diff --git a/packages/server/src/workspaces/launch-cleanup.ts b/packages/server/src/workspaces/launch-cleanup.ts deleted file mode 100644 index b5ab3507..00000000 --- a/packages/server/src/workspaces/launch-cleanup.ts +++ /dev/null @@ -1,165 +0,0 @@ -import type { SpawnSyncReturns, spawnSync } from "node:child_process" - -import type { ProcessIdentity, ProcessSnapshot } from "./process-identity" - -export const LAUNCH_CLEANUP_TOKEN_ENV = "CODENOMAD_LAUNCH_CLEANUP_TOKEN" - -type SpawnCommand = typeof spawnSync - -export interface TokenSignalResult { - ok: boolean - signalSent: boolean - targets: ProcessIdentity[] - error?: string -} - -const LINUX_TOKEN_PROBE_SCRIPT = String.raw` -boot=$(cat /proc/sys/kernel/random/boot_id 2>/dev/null) || exit 20 -key=$1; expected=$2 -matches_token() { - test -r "/proc/$1/environ" || return 1 - tr '\0' '\n' < "/proc/$1/environ" 2>/dev/null | grep -Fqx -- "$key=$expected" -} -read_stat() { - line=$(cat "/proc/$1/stat" 2>/dev/null) || return 1 - stat_pid=$(printf '%s\n' "$line" | cut -d' ' -f1); rest=$(printf '%s\n' "$line" | sed 's/^.*) //'); set -- $rest - stat_ppid=$2; stat_group=$3; stat_start=$20 -} -for environ in /proc/[0-9]*/environ; do - pid=$(basename "$(dirname "$environ")") - if matches_token "$pid" && read_stat "$pid"; then - printf '%s|%s|%s|%s|%s|%s\n' "$stat_pid" "$stat_ppid" "$stat_group" "$stat_start" "$boot" "$stat_start" - fi -done -` - -const LINUX_TOKEN_SIGNAL_SCRIPT = String.raw` -boot=$(cat /proc/sys/kernel/random/boot_id 2>/dev/null) || exit 20 -key=$1; expected=$2; requested_signal=$3 -matches_token() { - test -r "/proc/$1/environ" || return 1 - tr '\0' '\n' < "/proc/$1/environ" 2>/dev/null | grep -Fqx -- "$key=$expected" -} -read_stat() { - line=$(cat "/proc/$1/stat" 2>/dev/null) || return 1 - stat_pid=$(printf '%s\n' "$line" | cut -d' ' -f1); rest=$(printf '%s\n' "$line" | sed 's/^.*) //'); set -- $rest - stat_ppid=$2; stat_group=$3; stat_start=$20 -} -signal_sent=0 -# Repeat inside the one bounded adapter so children forked by a signaled parent are selected too. -for pass in 1 2 3; do - for environ in /proc/[0-9]*/environ; do - pid=$(basename "$(dirname "$environ")") - if matches_token "$pid" && read_stat "$pid"; then - printf 'CODENOMAD_TARGET|%s|%s|%s|%s|%s|%s\n' "$stat_pid" "$stat_ppid" "$stat_group" "$stat_start" "$boot" "$stat_start" - if matches_token "$pid" && read_stat "$pid" && kill "-$requested_signal" "$pid" 2>/dev/null; then signal_sent=1; fi - fi - done -done -printf 'CODENOMAD_RESULT|%s\n' "$signal_sent" -` - -function failure(result: SpawnSyncReturns, token?: string): string { - const message = result.error?.message || `launch cleanup command failed with exit code ${result.status}` - return token ? redactToken(message, token) : message -} - -function redactToken(value: string, token: string): string { - return value.split(token).join("[REDACTED]") -} - -function parseIdentity(line: string): ProcessIdentity | null { - const fields = line.split("|") - if (fields.length !== 6) return null - const [pidText = "", parentPidText = "", groupIdText = "", startTime = "", bootId = "", startOrder = ""] = fields - if (!/^\d+$/.test(pidText) || !/^\d+$/.test(parentPidText) || !/^\d+$/.test(groupIdText) || - !startTime || !bootId || !/^\d+$/.test(startOrder)) return null - const pid = Number.parseInt(pidText, 10) - const parentPid = Number.parseInt(parentPidText, 10) - const groupId = Number.parseInt(groupIdText, 10) - if (pid <= 0 || parentPid < 0 || groupId <= 0) return null - return { pid, parentPid, groupId, startTime, bootId, startOrder } -} - -function runLinux( - spawnCommand: SpawnCommand, - script: string, - args: string[], - timeoutMs: number, - distro?: string, -): SpawnSyncReturns { - return distro - ? spawnCommand("wsl.exe", ["--distribution", distro, "--exec", "sh", "-c", script, "codenomad-token-cleanup", ...args], { - encoding: "utf8", - timeout: timeoutMs, - }) - : spawnCommand("sh", ["-c", script, "codenomad-token-cleanup", ...args], { encoding: "utf8", timeout: timeoutMs }) -} - -export function probeLaunchCleanupToken( - spawnCommand: SpawnCommand, - token: string, - timeoutMs: number, - distro?: string, -): ProcessSnapshot { - try { - const result = runLinux(spawnCommand, LINUX_TOKEN_PROBE_SCRIPT, [LAUNCH_CLEANUP_TOKEN_ENV, token], timeoutMs, distro) - if (result.status !== 0) return { ok: false, error: failure(result, token) } - const processes = new Map() - for (const line of String(result.stdout ?? "").split(/\r?\n/)) { - if (!line) continue - const identity = parseIdentity(line) - if (!identity) return { ok: false, error: "launch cleanup probe returned a malformed identity record" } - processes.set(identity.pid, identity) - } - return { ok: true, processes } - } catch (error) { - return { ok: false, error: redactToken(error instanceof Error ? error.message : String(error), token) } - } -} - -export function signalLaunchCleanupToken( - spawnCommand: SpawnCommand, - token: string, - signal: NodeJS.Signals, - timeoutMs: number, - distro?: string, -): TokenSignalResult { - try { - const signalName = signal === "SIGKILL" ? "KILL" : "TERM" - const result = runLinux( - spawnCommand, - LINUX_TOKEN_SIGNAL_SCRIPT, - [LAUNCH_CLEANUP_TOKEN_ENV, token, signalName], - timeoutMs, - distro, - ) - if (result.status !== 0) return { ok: false, signalSent: false, targets: [], error: failure(result, token) } - const targets = new Map() - let signalSent: boolean | undefined - for (const line of String(result.stdout ?? "").split(/\r?\n/)) { - if (!line) continue - if (line.startsWith("CODENOMAD_TARGET|")) { - const identity = parseIdentity(line.slice("CODENOMAD_TARGET|".length)) - if (!identity) return { ok: false, signalSent: false, targets: [], error: "launch cleanup signal returned a malformed target record" } - targets.set(identity.pid, identity) - continue - } - if (line === "CODENOMAD_RESULT|0" || line === "CODENOMAD_RESULT|1") { - signalSent = line.endsWith("1") - continue - } - return { ok: false, signalSent: false, targets: [], error: "launch cleanup signal returned unexpected output" } - } - return signalSent === undefined - ? { ok: false, signalSent: false, targets: [], error: "launch cleanup signal returned no structured result" } - : { ok: true, signalSent, targets: Array.from(targets.values()) } - } catch (error) { - return { - ok: false, - signalSent: false, - targets: [], - error: redactToken(error instanceof Error ? error.message : String(error), token), - } - } -} diff --git a/packages/server/src/workspaces/manager.test.ts b/packages/server/src/workspaces/manager.test.ts index 1d7b6cad..7b9a66e4 100644 --- a/packages/server/src/workspaces/manager.test.ts +++ b/packages/server/src/workspaces/manager.test.ts @@ -4,18 +4,16 @@ import pino from "pino" import { EventBus } from "../events/bus" import { - WorkspaceRuntimeIdentityCaptureError, - WorkspaceRuntimeLaunchCancelledError, WorkspaceWindowsTreeCleanupIncompleteError, type ProcessExitInfo, type WorkspaceRuntime, } from "./runtime" import { + WorkspaceCleanupTimeoutError, WorkspaceLaunchCancelledError, - WorkspaceLaunchSettlementTimeoutError, + WorkspaceLaunchTimeoutError, WorkspaceManager, WorkspaceShutdownError, - WorkspaceShutdownTimeoutError, } from "./manager" function deferred() { @@ -31,43 +29,38 @@ function deferred() { class ControlledRuntime { readonly launchResult = deferred>>() readonly launchCalled = deferred() - readonly cancellation = deferred() readonly active = new Set() - readonly stopCalls: string[] = [] - launchCalls = 0 + stopCalls = 0 failStops = 0 + onExit?: (info: ProcessExitInfo) => void launch: WorkspaceRuntime["launch"] = (options) => { - this.launchCalls += 1 this.active.add(options.workspaceId) + this.onExit = options.onExit this.launchCalled.resolve(options.workspaceId) + options.signal?.addEventListener("abort", () => this.launchResult.reject(options.signal?.reason), { once: true }) return this.launchResult.promise } stop: WorkspaceRuntime["stop"] = async (workspaceId) => { - this.stopCalls.push(workspaceId) - this.cancellation.resolve(new WorkspaceRuntimeLaunchCancelledError(workspaceId)) - if (this.failStops > 0) { - this.failStops -= 1 - throw new Error("controlled stop failure") - } + this.stopCalls += 1 + if (this.failStops-- > 0) throw new Error("controlled stop failure") this.active.delete(workspaceId) } - resolveLaunch(workspaceId: string): void { + resolveLaunch(): void { this.launchResult.resolve({ pid: 1234, port: 4321, exitPromise: new Promise(() => undefined), - cancellationPromise: this.cancellation.promise, getLastOutput: () => "", }) } } -function createHarness(managerOptions: { +function createHarness(options: { shutdownTimeoutMs?: number - launchSettlementTimeoutMs?: number + launchTimeoutMs?: number setTimeout?: (callback: () => void, delayMs: number) => ReturnType clearTimeout?: (timer: ReturnType) => void } = {}) { @@ -75,178 +68,72 @@ function createHarness(managerOptions: { const runtime = new ControlledRuntime() const readiness = deferred() const started: string[] = [] - let createdId = "" - eventBus.on("workspace.created", (event) => { - createdId = event.workspace.id - }) + const stopped: string[] = [] eventBus.on("workspace.started", (event) => started.push(event.workspace.id)) - + eventBus.on("workspace.stopped", (event) => stopped.push(event.workspaceId)) const manager = new WorkspaceManager({ rootDir: process.cwd(), settings: { getOwner: () => ({}) } as never, - binaryResolver: { - resolve: () => ({ path: "test-opencode", label: "test-opencode" }), - } as never, + binaryResolver: { resolve: () => ({ path: "test-opencode", label: "test-opencode" }) } as never, eventBus, logger: pino({ level: "silent" }), getServerBaseUrl: () => "http://127.0.0.1:4000", runtime, - ...managerOptions, + ...options, }) - ;(manager as unknown as { - waitForWorkspaceReadiness: () => Promise - }).waitForWorkspaceReadiness = () => readiness.promise - - return { manager, runtime, readiness, started, eventBus, getCreatedId: () => createdId } + ;(manager as any).waitForWorkspaceReadiness = ({ signal }: { signal?: AbortSignal }) => Promise.race([ + readiness.promise, + new Promise((_resolve, reject) => { + const cancel = () => reject(signal?.reason) + signal?.addEventListener("abort", cancel, { once: true }) + if (signal?.aborted) cancel() + }), + ]) + return { manager, runtime, readiness, started, stopped } } -describe("workspace manager launch cancellation", () => { - it("keeps a launched workspace unpublished until runtime identity launch succeeds", async () => { - const harness = createHarness() - let deletion: Promise | undefined - harness.eventBus.on("workspace.created", (event) => { - deletion = harness.manager.delete(event.workspace.id) +async function createReady(harness: ReturnType) { + const creation = harness.manager.create(process.cwd()) + const workspaceId = await harness.runtime.launchCalled.promise + harness.runtime.resolveLaunch() + harness.readiness.resolve(undefined) + await creation + return workspaceId +} + +describe("workspace manager lifecycle", () => { + for (const boundary of ["launch", "readiness", "shutdown"] as const) { + it(`cancels and cleans a workspace during ${boundary}`, async () => { + const harness = createHarness() + const creation = harness.manager.create(process.cwd()) + const workspaceId = await harness.runtime.launchCalled.promise + let cleanup: Promise + if (boundary === "readiness") { + harness.runtime.resolveLaunch() + await new Promise((resolve) => setImmediate(resolve)) + cleanup = harness.manager.delete(workspaceId) + } else { + cleanup = boundary === "shutdown" ? harness.manager.shutdown() : harness.manager.delete(workspaceId) + harness.runtime.resolveLaunch() + } + + await assert.rejects(creation, WorkspaceLaunchCancelledError) + await cleanup + assert.deepEqual([harness.runtime.active.size, harness.started, harness.manager.list(), harness.stopped], + [0, [], [], boundary === "readiness" ? [workspaceId] : []]) }) + } - const creation = harness.manager.create(process.cwd()) - const workspaceId = await harness.runtime.launchCalled.promise - assert.equal(harness.getCreatedId(), "") - assert.deepEqual(harness.manager.list(), []) - assert.equal(harness.manager.getInstancePort(workspaceId), undefined) - assert.equal(harness.manager.getInstanceAuthorizationHeader(workspaceId), undefined) - - harness.runtime.resolveLaunch(workspaceId) - await assert.rejects(creation, WorkspaceLaunchCancelledError) - await deletion - - assert.equal(harness.runtime.launchCalls, 1) - assert.equal(harness.runtime.active.size, 0) - assert.deepEqual(harness.started, []) - assert.deepEqual(harness.manager.list(), []) - }) - - it("stops a late launch and shares cleanup across concurrent deletes", async () => { + it("shares failed cleanup and allows a later delete retry", async () => { const harness = createHarness() - const creation = harness.manager.create(process.cwd()) - const workspaceId = await harness.runtime.launchCalled.promise + const workspaceId = await createReady(harness) + harness.runtime.failStops = 2 - const firstDelete = harness.manager.delete(workspaceId) - const secondDelete = harness.manager.delete(workspaceId) - assert.strictEqual(firstDelete, secondDelete) - assert.equal(harness.manager.get(workspaceId), undefined) - - harness.runtime.resolveLaunch(workspaceId) - - await assert.rejects(creation, WorkspaceLaunchCancelledError) - await Promise.all([firstDelete, secondDelete]) - - assert.equal(harness.runtime.active.has(workspaceId), false) - assert.deepEqual(harness.started, []) - assert.equal(harness.manager.get(workspaceId), undefined) - assert.deepEqual(harness.manager.list(), []) - }) - - it("does not publish or retain a workspace when mandatory identity launch fails", async () => { - const events: string[] = [] - const eventBus = new EventBus() - eventBus.on("workspace.created", () => events.push("created")) - eventBus.on("workspace.started", () => events.push("started")) - eventBus.on("workspace.error", () => events.push("error")) - const runtime = { - launch: ((options: { workspaceId: string }) => Promise.reject( - new WorkspaceRuntimeIdentityCaptureError(options.workspaceId, "probe unavailable"), - )) as WorkspaceRuntime["launch"], - stop: (() => Promise.resolve()) as WorkspaceRuntime["stop"], - } - const manager = new WorkspaceManager({ - rootDir: process.cwd(), - settings: { getOwner: () => ({}) } as never, - binaryResolver: { resolve: () => ({ path: "test-opencode", label: "test-opencode" }) } as never, - eventBus, - logger: pino({ level: "silent" }), - getServerBaseUrl: () => "http://127.0.0.1:4000", - runtime, - }) - - await assert.rejects(manager.create(process.cwd()), WorkspaceRuntimeIdentityCaptureError) - assert.deepEqual(events, []) - assert.deepEqual(manager.list(), []) - }) - - it("retains an unpublished cleanup record when identity-failure cleanup cannot be proven", async () => { - let workspaceId = "" - let stopAttempts = 0 - const runtime = { - launch: ((options: { workspaceId: string }) => { - workspaceId = options.workspaceId - return Promise.reject(new WorkspaceRuntimeIdentityCaptureError(options.workspaceId, "probe unavailable")) - }) as WorkspaceRuntime["launch"], - stop: (async () => { - stopAttempts += 1 - if (stopAttempts === 1) throw new Error("cleanup proof unavailable") - }) as WorkspaceRuntime["stop"], - } - const manager = new WorkspaceManager({ - rootDir: process.cwd(), - settings: { getOwner: () => ({}) } as never, - binaryResolver: { resolve: () => ({ path: "test-opencode", label: "test-opencode" }) } as never, - eventBus: new EventBus(), - logger: pino({ level: "silent" }), - getServerBaseUrl: () => "http://127.0.0.1:4000", - runtime, - }) - - await assert.rejects(manager.create(process.cwd()), /cleanup proof unavailable/) - assert.deepEqual(manager.list(), []) - assert.equal(manager.get(workspaceId), undefined) - - await manager.delete(workspaceId) - assert.equal(stopAttempts, 3) - }) - - it("actively cancels pending readiness without publishing workspace.started", async () => { - const harness = createHarness() - const creation = harness.manager.create(process.cwd()) - const workspaceId = await harness.runtime.launchCalled.promise - harness.runtime.resolveLaunch(workspaceId) - await new Promise((resolve) => setImmediate(resolve)) - - const deletion = harness.manager.delete(workspaceId) - - await assert.rejects(creation, WorkspaceLaunchCancelledError) - await deletion - assert.equal(harness.runtime.active.has(workspaceId), false) - assert.deepEqual(harness.started, []) - assert.equal(harness.manager.get(workspaceId), undefined) - }) - - it("cancels and cleans a starting child during shutdown", async () => { - const harness = createHarness() - const creation = harness.manager.create(process.cwd()) - const workspaceId = await harness.runtime.launchCalled.promise - - const shutdown = harness.manager.shutdown() - harness.runtime.resolveLaunch(workspaceId) - - await assert.rejects(creation, WorkspaceLaunchCancelledError) - await shutdown - assert.equal(harness.runtime.active.size, 0) - assert.deepEqual(harness.started, []) - assert.deepEqual(harness.manager.list(), []) - }) - - it("still starts and deletes an ordinary ready workspace", async () => { - const harness = createHarness() - const creation = harness.manager.create(process.cwd(), "ordinary") - const workspaceId = await harness.runtime.launchCalled.promise - harness.runtime.resolveLaunch(workspaceId) - harness.readiness.resolve("2.0.0") - - const { workspace } = await creation - assert.equal(workspace.id, harness.getCreatedId()) - assert.equal(workspace.status, "ready") - assert.equal(workspace.binaryVersion, "2.0.0") - assert.deepEqual(harness.started, [workspaceId]) + const first = harness.manager.delete(workspaceId) + const concurrent = harness.manager.delete(workspaceId) + assert.strictEqual(first, concurrent) + const failures = await Promise.allSettled([first, concurrent]) + assert.deepEqual(failures.map((result) => result.status), ["rejected", "rejected"]) assert.equal(harness.runtime.active.has(workspaceId), true) await harness.manager.delete(workspaceId) @@ -254,133 +141,201 @@ describe("workspace manager launch cancellation", () => { assert.equal(harness.manager.get(workspaceId), undefined) }) - it("publishes stopped and removes a workspace after readiness fails", async () => { + it("retries cancellation deletion for an already-cancelled request", async () => { const harness = createHarness() - const stopped: string[] = [] - harness.eventBus.on("workspace.stopped", (event) => stopped.push(event.workspaceId)) - const creation = harness.manager.create(process.cwd()) + const creation = harness.manager.create(process.cwd(), undefined, { requestId: "retry-cancel" }) const workspaceId = await harness.runtime.launchCalled.promise - harness.runtime.resolveLaunch(workspaceId) - await new Promise((resolve) => setImmediate(resolve)) - - harness.readiness.reject(new Error("readiness failed")) - await assert.rejects(creation, /readiness failed/) - - assert.deepEqual(stopped, [workspaceId]) - assert.equal(harness.manager.get(workspaceId), undefined) - assert.deepEqual(harness.manager.list(), []) - }) - - it("retains a cancelled record after failed cleanup and allows delete retry", async () => { - const harness = createHarness() - const creation = harness.manager.create(process.cwd()) - const workspaceId = await harness.runtime.launchCalled.promise - harness.runtime.resolveLaunch(workspaceId) + harness.runtime.resolveLaunch() harness.readiness.resolve(undefined) await creation + harness.runtime.failStops = 2 - harness.runtime.failStops = 1 - const firstDelete = harness.manager.delete(workspaceId) - const concurrentDelete = harness.manager.delete(workspaceId) - assert.strictEqual(firstDelete, concurrentDelete) - const failedDeletes = await Promise.allSettled([firstDelete, concurrentDelete]) - assert.equal(failedDeletes[0].status, "rejected") - assert.equal(failedDeletes[1].status, "rejected") - if (failedDeletes[0].status === "rejected" && failedDeletes[1].status === "rejected") { - assert.strictEqual(failedDeletes[0].reason, failedDeletes[1].reason) - assert.match(String(failedDeletes[0].reason), /controlled stop failure/) - } - assert.equal(harness.manager.get(workspaceId)?.status, "ready") + await assert.rejects(harness.manager.cancelCreationRequest("retry-cancel"), /controlled stop failure/) + assert.equal(harness.manager.get(workspaceId)?.id, workspaceId) assert.equal(harness.runtime.active.has(workspaceId), true) - const retryDelete = harness.manager.delete(workspaceId) - assert.notStrictEqual(retryDelete, firstDelete) - await retryDelete - assert.equal(harness.runtime.active.has(workspaceId), false) + await harness.manager.cancelCreationRequest("retry-cancel") assert.equal(harness.manager.get(workspaceId), undefined) + assert.equal(harness.runtime.active.has(workspaceId), false) + assert.deepEqual(harness.stopped, [workspaceId]) }) - it("rejects shutdown at its deadline instead of waiting forever", async () => { - let fireDeadline: (() => void) | undefined - const timer = {} as ReturnType + it("gives release and cancellation one terminal winner", async () => { + const cancelled = createHarness() + const cancelledCreation = cancelled.manager.create(process.cwd(), undefined, { requestId: "cancel-wins" }) + const cancelledId = await cancelled.runtime.launchCalled.promise + cancelled.runtime.resolveLaunch() + cancelled.readiness.resolve(undefined) + await cancelledCreation + const stopStarted = deferred() + const finishStop = deferred() + const originalStop = cancelled.runtime.stop + cancelled.runtime.stop = async (workspaceId) => { + stopStarted.resolve() + await finishStop.promise + await originalStop(workspaceId) + } + + const cancellation = cancelled.manager.cancelCreationRequest("cancel-wins") + await stopStarted.promise + assert.equal(cancelled.manager.releaseCreationRequest(cancelledId, "cancel-wins"), false) + assert.equal(cancelled.manager.get(cancelledId)?.id, cancelledId) + finishStop.resolve() + await cancellation + assert.equal(cancelled.manager.get(cancelledId), undefined) + + const released = createHarness() + const releasedCreation = released.manager.create(process.cwd(), undefined, { requestId: "release-wins" }) + const releasedId = await released.runtime.launchCalled.promise + released.runtime.resolveLaunch() + released.readiness.resolve(undefined) + await releasedCreation + + assert.equal(released.manager.releaseCreationRequest(releasedId, "release-wins"), true) + await released.manager.cancelCreationRequest("release-wins") + assert.equal(released.manager.releaseCreationRequest(releasedId, "release-wins"), true) + assert.equal(released.manager.get(releasedId)?.id, releasedId) + assert.equal(released.runtime.active.has(releasedId), true) + }) + + it("retains unresolved pre-creation cancellation until its delayed create", async () => { + const harness = createHarness() + const requestIds = Array.from({ length: 1_025 }, (_, index) => `pending-cancel-${index}`) + await Promise.all(requestIds.map((requestId) => harness.manager.cancelCreationRequest(requestId))) + + assert.equal((harness.manager as any).cancelledCreationRequests.size, requestIds.length) + await assert.rejects( + harness.manager.create(process.cwd(), undefined, { requestId: requestIds[0] }), + /was cancelled/, + ) + assert.equal((harness.manager as any).cancelledCreationRequests.has(requestIds[0]), false) + assert.equal((harness.manager as any).cancelledCreationRequests.size, requestIds.length - 1) + }) + + it("returns scoped correlation while an ordinary shared launch remains retained", async () => { + const harness = createHarness() + const ordinary = harness.manager.create(process.cwd()) + const workspaceId = await harness.runtime.launchCalled.promise + const scoped = harness.manager.create(process.cwd(), undefined, { requestId: "restore-shared" }) + harness.runtime.resolveLaunch() + harness.readiness.resolve(undefined) + + const [ordinaryResult, scopedResult] = await Promise.all([ordinary, scoped]) + assert.equal(ordinaryResult.created, true) + assert.equal(ordinaryResult.workspace.requestId, undefined) + assert.equal(scopedResult.created, false) + assert.equal(scopedResult.workspace.id, workspaceId) + assert.equal(scopedResult.workspace.requestId, "restore-shared") + + assert.equal(harness.manager.releaseCreationRequest(workspaceId, "restore-shared"), true) + assert.equal(harness.manager.get(workspaceId)?.id, workspaceId) + assert.equal(harness.runtime.active.has(workspaceId), true) + + const reused = await harness.manager.create(process.cwd(), undefined, { requestId: "restore-reused" }) + assert.equal(reused.workspace.requestId, "restore-reused") + await harness.manager.cancelCreationRequest("restore-reused") + assert.equal(harness.manager.get(workspaceId)?.id, workspaceId) + assert.equal(harness.runtime.active.has(workspaceId), true) + await assert.rejects( + harness.manager.create(process.cwd(), undefined, { requestId: "restore-reused" }), + /was cancelled/, + ) + assert.equal(harness.manager.releaseCreationRequest(workspaceId, "restore-reused"), false) + }) + + for (const boundary of ["runtime launch", "health readiness"] as const) { + it(`applies one shared end-to-end deadline during ${boundary} and cleans up`, async () => { + const deadlines: Array<() => void> = [] + const harness = createHarness({ + launchTimeoutMs: 25, + setTimeout: ((callback: () => void) => { + const timer = { active: true } + deadlines.push(() => { if (timer.active) callback() }) + return timer as unknown as ReturnType + }) as typeof setTimeout, + clearTimeout: ((timer: { active: boolean }) => { timer.active = false }) as unknown as typeof clearTimeout, + }) + const first = harness.manager.create(process.cwd(), undefined, { requestId: "deadline-one" }) + const workspaceId = await harness.runtime.launchCalled.promise + const shared = harness.manager.create(process.cwd(), undefined, { requestId: "deadline-two" }) + while ([...(harness.manager as any).pendingWorkspaceCreations.values()][0]?.ownership.size !== 2) { + await new Promise((resolve) => setImmediate(resolve)) + } + if (boundary === "health readiness") { + harness.runtime.resolveLaunch() + await new Promise((resolve) => setImmediate(resolve)) + } + + for (const fire of deadlines) fire() + const outcomes = await Promise.allSettled([first, shared]) + assert.deepEqual(outcomes.map((outcome) => outcome.status), ["rejected", "rejected"]) + assert.ok(outcomes.every((outcome) => outcome.status === "rejected" && outcome.reason instanceof WorkspaceLaunchTimeoutError)) + assert.strictEqual((outcomes[0] as PromiseRejectedResult).reason, (outcomes[1] as PromiseRejectedResult).reason) + assert.equal(harness.runtime.active.has(workspaceId), false) + assert.equal(harness.runtime.stopCalls >= 1, true) + assert.deepEqual(harness.manager.list(), []) + }) + } + + it("bounds shutdown instead of waiting forever", async () => { + let fireDeadline!: () => void + let cleared = 0 const harness = createHarness({ shutdownTimeoutMs: 25, - setTimeout: (callback: () => void) => { + setTimeout: ((callback: () => void) => { fireDeadline = callback - return timer - }, - clearTimeout: () => undefined, - }) - const creation = harness.manager.create(process.cwd()) - const workspaceId = await harness.runtime.launchCalled.promise - harness.runtime.resolveLaunch(workspaceId) - harness.readiness.resolve(undefined) - await creation + return {} as ReturnType + }) as typeof setTimeout, + clearTimeout: () => { cleared += 1 }, + } as never) + const workspaceId = await createReady(harness) + cleared = 0 harness.runtime.stop = () => new Promise(() => undefined) const shutdown = harness.manager.shutdown() - assert.ok(fireDeadline) fireDeadline() - - await assert.rejects(shutdown, WorkspaceShutdownTimeoutError) + await assert.rejects(shutdown, WorkspaceCleanupTimeoutError) assert.equal(harness.manager.get(workspaceId)?.status, "ready") + assert.equal(cleared, 1) }) - it("rejects failed shutdown cleanup and allows a later delete retry", async () => { - const harness = createHarness() - const creation = harness.manager.create(process.cwd()) - const workspaceId = await harness.runtime.launchCalled.promise - harness.runtime.resolveLaunch(workspaceId) - harness.readiness.resolve(undefined) - await creation - harness.runtime.failStops = 1 + it("publishes stopped exactly once for normal exit, readiness failure, and manager cleanup", async () => { + const normal = createHarness() + const normalId = await createReady(normal) + normal.runtime.onExit?.({ workspaceId: normalId, code: 0, signal: null, requested: false }) + await normal.manager.delete(normalId) + assert.deepEqual(normal.stopped, [normalId]) - await assert.rejects(harness.manager.shutdown(), WorkspaceShutdownError) - assert.equal(harness.manager.get(workspaceId)?.status, "ready") - assert.equal(harness.runtime.active.has(workspaceId), true) + const failed = createHarness() + const failedCreation = failed.manager.create(process.cwd()) + const failedId = await failed.runtime.launchCalled.promise + failed.runtime.resolveLaunch() + failed.readiness.reject(new Error("not ready")) + await assert.rejects(failedCreation, /not ready/) + assert.deepEqual(failed.stopped, [failedId]) - await harness.manager.delete(workspaceId) - assert.equal(harness.manager.get(workspaceId), undefined) - assert.equal(harness.runtime.active.has(workspaceId), false) + const cleaned = createHarness() + const cleanedId = await createReady(cleaned) + await cleaned.manager.shutdown() + assert.deepEqual(cleaned.stopped, [cleanedId]) }) - it("reports incomplete Windows tree cleanup during shutdown", async () => { - const harness = createHarness() - const creation = harness.manager.create(process.cwd()) - const workspaceId = await harness.runtime.launchCalled.promise - harness.runtime.resolveLaunch(workspaceId) - harness.readiness.resolve(undefined) - await creation - harness.runtime.stop = async () => { - throw new WorkspaceWindowsTreeCleanupIncompleteError(workspaceId, 4242, ["taskkill /T failed: unavailable"]) - } + for (const [name, failure] of [ + ["cleanup failures", new Error("stop failed")], + ["incomplete Windows tree cleanup", new WorkspaceWindowsTreeCleanupIncompleteError("workspace", 4242, ["taskkill failed"])], + ] as const) { + it(`aggregates ${name} during shutdown`, async () => { + const harness = createHarness() + const workspaceId = await createReady(harness) + harness.runtime.stop = async () => { throw failure } - await assert.rejects(harness.manager.shutdown(), (error: unknown) => { - assert.ok(error instanceof WorkspaceShutdownError) - assert.ok(error.errors[0] instanceof WorkspaceWindowsTreeCleanupIncompleteError) - return true + await assert.rejects(harness.manager.shutdown(), (error: unknown) => { + assert.ok(error instanceof WorkspaceShutdownError) + assert.strictEqual(error.errors[0], failure) + return true + }) + assert.equal(harness.manager.get(workspaceId)?.status, "ready") + assert.equal(harness.runtime.active.has(workspaceId), true) }) - assert.equal(harness.manager.get(workspaceId)?.status, "ready") - assert.equal(harness.runtime.active.has(workspaceId), true) - }) - - it("bounds cleanup when a runtime does not settle its cancelled launch", async () => { - let fireDeadline: (() => void) | undefined - const timer = {} as ReturnType - const harness = createHarness({ - launchSettlementTimeoutMs: 25, - setTimeout: (callback: () => void) => { - fireDeadline = callback - return timer - }, - clearTimeout: () => undefined, - }) - const deletion = (harness.manager as unknown as { - withLaunchSettlementTimeout: (workspaceId: string, completion: Promise) => Promise - }).withLaunchSettlementTimeout("workspace-1", new Promise(() => undefined)) - assert.ok(fireDeadline) - fireDeadline() - - await assert.rejects(deletion, WorkspaceLaunchSettlementTimeoutError) - }) + } }) diff --git a/packages/server/src/workspaces/manager.ts b/packages/server/src/workspaces/manager.ts index 368d2db2..547bccd8 100644 --- a/packages/server/src/workspaces/manager.ts +++ b/packages/server/src/workspaces/manager.ts @@ -2,6 +2,7 @@ import path from "path" import { spawnSync } from "child_process" import { randomUUID } from "node:crypto" import { connect } from "net" +import { setTimeout as delay } from "node:timers/promises" import { EventBus } from "../events/bus" import type { SettingsService } from "../settings/service" import type { BinaryResolver } from "../settings/binaries" @@ -27,6 +28,9 @@ import { resolveWorkspaceIdentity } from "./workspace-identity" import { parseWslUncPath } from "./spawn" const STARTUP_STABILITY_DELAY_MS = 1500 +const DEFAULT_LAUNCH_TIMEOUT_MS = 30_000 +const ORDINARY_CREATION_OWNER = "" +const WORKSPACE_STATE = Symbol("workspaceState") type ManagerTimeout = ReturnType interface WorkspaceRuntimeController { @@ -57,134 +61,106 @@ interface WorkspaceManagerOptions { getServerBaseUrl: () => string /** Optional CA bundle path to trust CodeNomad HTTPS certs. */ nodeExtraCaCertsPath?: string - runtime?: WorkspaceRuntimeController + runtime?: Pick shutdownTimeoutMs?: number launchSettlementTimeoutMs?: number + launchTimeoutMs?: number setTimeout?: (callback: () => void, delayMs: number) => ManagerTimeout clearTimeout?: (timer: ManagerTimeout) => void } -interface WorkspaceLaunchLifecycle { - cancelled: boolean - settled: boolean - completion: Promise - complete: () => void +interface WorkspaceRecord extends WorkspaceDescriptor { + identityKey: string + ownership: WorkspaceCreationOwnership + [WORKSPACE_STATE]: WorkspaceState +} + +interface WorkspaceState { + abortController: AbortController + creation?: Promise + settlement?: Promise deletePromise?: Promise - stoppedEventPublished: boolean -} - -interface WorkspaceRecord { - descriptor: WorkspaceDescriptor - lifecycle: WorkspaceLaunchLifecycle published: boolean - creationOwnership: WorkspaceCreationOwnership - releasedCreationRequestIds: Set + stoppedPublished: boolean } - export class WorkspaceLaunchCancelledError extends Error { constructor(workspaceId: string) { super(`Workspace ${workspaceId} launch was cancelled`) this.name = "WorkspaceLaunchCancelledError" } } - -export class WorkspaceShutdownTimeoutError extends Error { - readonly code = "WORKSPACE_SHUTDOWN_TIMEOUT" +export class WorkspaceLaunchTimeoutError extends Error { + readonly code = "WORKSPACE_LAUNCH_TIMEOUT" readonly retryable = true - - constructor(timeoutMs: number) { - super(`Workspace shutdown did not finish within ${timeoutMs}ms; remaining workspace cleanup can be retried`) - this.name = "WorkspaceShutdownTimeoutError" + constructor(workspaceId: string | undefined, timeoutMs: number) { + super(`${workspaceId ? `Workspace ${workspaceId}` : "Workspace"} did not finish launching within ${timeoutMs}ms`) + this.name = "WorkspaceLaunchTimeoutError" } } - -export class WorkspaceLaunchSettlementTimeoutError extends Error { - readonly code = "WORKSPACE_LAUNCH_SETTLEMENT_TIMEOUT" +export class WorkspaceCleanupTimeoutError extends Error { + readonly code = "WORKSPACE_CLEANUP_TIMEOUT" readonly retryable = true - - constructor(workspaceId: string, timeoutMs: number) { - super(`Workspace ${workspaceId} launch cancellation did not settle within ${timeoutMs}ms; cleanup can be retried`) - this.name = "WorkspaceLaunchSettlementTimeoutError" + constructor(operation: string, timeoutMs: number) { + super(`Workspace ${operation} did not finish within ${timeoutMs}ms; cleanup can be retried`) + this.name = "WorkspaceCleanupTimeoutError" } } - -export class WorkspaceShutdownError extends Error { +export class WorkspaceShutdownError extends AggregateError { readonly code = "WORKSPACE_SHUTDOWN_FAILED" readonly retryable = true - readonly errors: unknown[] - constructor(errors: unknown[]) { - super(`Failed to stop ${errors.length} workspace${errors.length === 1 ? "" : "s"} during shutdown; cleanup can be retried`) + super(errors, `Failed to stop ${errors.length} workspace${errors.length === 1 ? "" : "s"} during shutdown; cleanup can be retried`) this.name = "WorkspaceShutdownError" - this.errors = errors } } - export interface WorkspaceCreateResult { workspace: WorkspaceDescriptor created: boolean } - export interface WorkspaceCreateOptions { binaryPath?: string requestId?: string forceNew?: boolean } - -interface PendingWorkspaceCreation { - promise: Promise - ownership: WorkspaceCreationOwnership - followerCount: number +type CreationRequestState = "active" | "cancelled" | "released" +type WorkspaceCreationOwnership = Map +interface WorkspaceReadiness { + workspaceId: string + port: number + exitPromise: Promise + getLastOutput: () => string + signal?: AbortSignal } - -interface WorkspaceCreationOwnership { - requestIds: Set - cancelledRequestIds: Set - sharedByNonRestoreCaller: boolean -} - export class WorkspaceManager { private readonly workspaces = new Map() - private readonly workspaceIdentities = new Map() - private readonly pendingWorkspaceCreations = new Map() - private readonly pendingWorkspaceOwners = new Map() + private readonly pendingWorkspaceCreations = new Map() private readonly cancelledCreationRequests = new Set() private shuttingDown = false - private readonly runtime: WorkspaceRuntimeController + private readonly runtime: Pick private readonly codeNomadPluginUrl: string private readonly opencodeAuth = new Map() - private readonly shutdownTimeoutMs: number - private readonly launchSettlementTimeoutMs: number - private readonly scheduleTimeout: (callback: () => void, delayMs: number) => ManagerTimeout - private readonly cancelTimeout: (timer: ManagerTimeout) => void constructor(private readonly options: WorkspaceManagerOptions) { this.runtime = options.runtime ?? new WorkspaceRuntime(this.options.eventBus, this.options.logger) this.codeNomadPluginUrl = getCodeNomadPluginUrl() - this.shutdownTimeoutMs = Math.max(1, options.shutdownTimeoutMs ?? 10000) - this.launchSettlementTimeoutMs = Math.max(1, options.launchSettlementTimeoutMs ?? 5000) - this.scheduleTimeout = options.setTimeout ?? setTimeout - this.cancelTimeout = options.clearTimeout ?? clearTimeout } - list(): WorkspaceDescriptor[] { return Array.from(this.workspaces.values()) - .filter((record) => record.published) - .map((record) => record.descriptor) + .filter((record) => record[WORKSPACE_STATE].published) } get(id: string): WorkspaceDescriptor | undefined { const record = this.workspaces.get(id) - return record?.published ? record.descriptor : undefined + return record?.[WORKSPACE_STATE].published ? record : undefined } getInstancePort(id: string): number | undefined { const record = this.workspaces.get(id) - return record?.published ? record.descriptor.port : undefined + return record?.[WORKSPACE_STATE].published ? record.port : undefined } getInstanceAuthorizationHeader(id: string): string | undefined { - return this.workspaces.get(id)?.published ? this.opencodeAuth.get(id)?.authorization : undefined + return this.workspaces.get(id)?.[WORKSPACE_STATE].published ? this.opencodeAuth.get(id)?.authorization : undefined } findReadyInstanceIdByBinary(binaryPath: string): string | undefined { @@ -198,15 +174,16 @@ export class WorkspaceManager { identityKey: string, includeRestoreOwned: boolean, ): WorkspaceDescriptor | undefined { - for (const [workspaceId, record] of this.workspaces) { + for (const record of this.workspaces.values()) { + const state = record[WORKSPACE_STATE] if ( - record.published - && !record.lifecycle.cancelled - && (includeRestoreOwned || !record.descriptor.requestId) - && record.descriptor.status === "ready" - && this.workspaceIdentities.get(workspaceId) === identityKey + state.published + && !state.abortController.signal.aborted + && (includeRestoreOwned || !record.requestId) + && record.status === "ready" + && record.identityKey === identityKey ) { - return record.descriptor + return record } } return undefined @@ -266,89 +243,73 @@ export class WorkspaceManager { name?: string, options: WorkspaceCreateOptions = {}, ): Promise { - const { workspacePath, identityKey } = await resolveWorkspaceIdentity(folder, this.options.rootDir) - if (options.requestId && this.cancelledCreationRequests.delete(options.requestId)) { - throw new Error(`Workspace creation request ${options.requestId} was cancelled`) - } - if (this.shuttingDown) { - throw new Error("Workspace manager is shutting down") - } - if (options.forceNew) { - const ownership = this.createOwnership(options.requestId) - const result = await this.createResolvedWorkspace(workspacePath, identityKey, name, options, ownership) - return this.resultForCreationOwner(result, options.requestId, ownership) - } - - const existing = this.findReadyWorkspaceByIdentity(identityKey, Boolean(options.requestId)) - if (existing) { - this.options.logger.info({ workspaceId: existing.id, folder: workspacePath }, "Reusing existing workspace") - const record = this.workspaces.get(existing.id) - if (options.requestId && record && record.creationOwnership.requestIds.size > 0) { - record.creationOwnership.requestIds.add(options.requestId) - this.syncCreationOwnership(record) - return this.resultForCreationOwner({ workspace: existing, created: false }, options.requestId, record.creationOwnership) + const launchTimeoutMs = Math.max(1, this.options.launchTimeoutMs ?? DEFAULT_LAUNCH_TIMEOUT_MS) + const launchDeadlineAt = Date.now() + launchTimeoutMs + try { + const { workspacePath, identityKey } = await this.withLaunchDeadline( + resolveWorkspaceIdentity(folder, this.options.rootDir), + undefined, + launchDeadlineAt, + launchTimeoutMs, + ) + if (options.requestId && this.cancelledCreationRequests.has(options.requestId)) { + throw new Error(`Workspace creation request ${options.requestId} was cancelled`) } - return { workspace: existing, created: false } - } - - const pending = this.pendingWorkspaceCreations.get(identityKey) - if (pending) { - pending.followerCount += 1 - if (options.requestId) { - pending.ownership.requestIds.add(options.requestId) - } else { - const workspaceId = this.pendingWorkspaceOwners.get(identityKey) - if (workspaceId) { - this.retainCreationOwnership(this.workspaces.get(workspaceId)) - } else { - pending.ownership.sharedByNonRestoreCaller = true + if (this.shuttingDown) { + throw new Error("Workspace manager is shutting down") + } + if (options.forceNew) { + const ownership = this.createOwnership(options.requestId) + const record = this.reserveWorkspace(workspacePath, identityKey, name, options, ownership, launchDeadlineAt) + const result = await this.startCreation(record, options, launchDeadlineAt, launchTimeoutMs) + return this.finishCreation(result, options.requestId, ownership) + } + const existing = this.findReadyWorkspaceByIdentity(identityKey, Boolean(options.requestId)) + if (existing) { + this.options.logger.info({ workspaceId: existing.id, folder: workspacePath }, "Reusing existing workspace") + const record = this.workspaces.get(existing.id) + if (options.requestId && record) { + if (!record.ownership.has(options.requestId)) record.ownership.set(options.requestId, "active") + this.syncOwnership(record) + return this.finishCreation({ workspace: existing, created: false }, options.requestId, record.ownership) + } + return { workspace: existing, created: false } + } + const pending = this.pendingWorkspaceCreations.get(identityKey) + if (pending) { + const state = pending[WORKSPACE_STATE] + const owner = options.requestId ?? ORDINARY_CREATION_OWNER + if (!pending.ownership.has(owner)) pending.ownership.set(owner, "active") + this.syncOwnership(pending) + const result = await state.creation! + return this.finishCreation({ workspace: result.workspace, created: false }, options.requestId, pending.ownership) + } + const ownership = this.createOwnership(options.requestId) + const record = this.reserveWorkspace(workspacePath, identityKey, name, options, ownership, launchDeadlineAt) + const creation = this.startCreation(record, options, launchDeadlineAt, launchTimeoutMs) + this.pendingWorkspaceCreations.set(identityKey, record) + try { + return this.finishCreation(await creation, options.requestId, ownership) + } finally { + if (this.pendingWorkspaceCreations.get(identityKey) === record) { + this.pendingWorkspaceCreations.delete(identityKey) } } - const result = await pending.promise - return this.resultForCreationOwner({ workspace: result.workspace, created: false }, options.requestId, pending.ownership) - } - - const ownership = this.createOwnership(options.requestId) - const creation = this.createResolvedWorkspace( - workspacePath, - identityKey, - name, - options, - ownership, - (workspaceId) => { - this.pendingWorkspaceOwners.set(identityKey, workspaceId) - }, - ) - const pendingCreation = { promise: creation, ownership, followerCount: 0 } - this.pendingWorkspaceCreations.set(identityKey, pendingCreation) - try { - const result = await creation - return this.resultForCreationOwner( - options.requestId && pendingCreation.ownership.sharedByNonRestoreCaller - ? { workspace: result.workspace, created: false } - : result, - options.requestId, - pendingCreation.ownership, - ) } finally { - if (this.pendingWorkspaceCreations.get(identityKey) === pendingCreation) { - this.pendingWorkspaceCreations.delete(identityKey) - this.pendingWorkspaceOwners.delete(identityKey) - } + if (options.requestId) this.cancelledCreationRequests.delete(options.requestId) } } - - private async createResolvedWorkspace( + private reserveWorkspace( workspacePath: string, identityKey: string, name: string | undefined, options: WorkspaceCreateOptions, - creationOwnership: WorkspaceCreationOwnership, - onReserved?: (workspaceId: string) => void, - ): Promise { + ownership: WorkspaceCreationOwnership, + launchDeadlineAt: number, + ): WorkspaceRecord { const id = randomUUID() const binary = this.options.binaryResolver.resolve(options.binaryPath) - const resolvedBinaryPath = this.resolveBinaryPath(binary.path) + const resolvedBinaryPath = this.resolveBinaryPath(binary.path, Math.max(1, launchDeadlineAt - Date.now())) clearWorkspaceSearchCache(workspacePath) this.options.logger.info({ workspaceId: id, folder: workspacePath, binary: resolvedBinaryPath }, "Creating workspace") @@ -356,7 +317,7 @@ export class WorkspaceManager { const proxyPath = `/workspaces/${id}/instance` - const descriptor: WorkspaceDescriptor = { + const record = { id, requestId: options.requestId, path: workspacePath, @@ -368,33 +329,65 @@ export class WorkspaceManager { binaryVersion: binary.version, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), - } - - let completeLaunch!: () => void - const completion = new Promise((resolve) => { - completeLaunch = resolve + } as WorkspaceRecord + Object.defineProperties(record, { + identityKey: { value: identityKey }, + ownership: { value: ownership }, + [WORKSPACE_STATE]: { value: { abortController: new AbortController(), published: false, stoppedPublished: false } }, }) - const record: WorkspaceRecord = { - descriptor, - lifecycle: { - cancelled: false, - settled: false, - completion, - complete: completeLaunch, - stoppedEventPublished: false, - }, - published: false, - creationOwnership, - releasedCreationRequestIds: new Set(), - } this.workspaces.set(id, record) - this.workspaceIdentities.set(id, identityKey) - onReserved?.(id) - if (options.requestId && this.cancelledCreationRequests.delete(options.requestId)) { - record.lifecycle.cancelled = true + if (options.requestId && this.cancelledCreationRequests.has(options.requestId)) { + record[WORKSPACE_STATE].abortController.abort(new WorkspaceLaunchCancelledError(id)) } + return record + } + private startCreation(record: WorkspaceRecord, options: WorkspaceCreateOptions, + launchDeadlineAt: number, launchTimeoutMs: number): Promise { + const creation = this.createWithDeadline(record, options, launchDeadlineAt, launchTimeoutMs) + record[WORKSPACE_STATE].creation = creation + record[WORKSPACE_STATE].settlement = creation.then(() => undefined, () => undefined) + return creation + } + private async createWithDeadline(record: WorkspaceRecord, options: WorkspaceCreateOptions, + launchDeadlineAt: number, launchTimeoutMs: number): Promise { + const timeoutMs = Math.max(1, launchDeadlineAt - Date.now()) + const state = record[WORKSPACE_STATE] + let timeout: ManagerTimeout | null = (this.options.setTimeout ?? setTimeout)(() => { + timeout = null + if (!state.abortController.signal.aborted) { + state.abortController.abort(new WorkspaceLaunchTimeoutError(record.id, launchTimeoutMs)) + } + }, timeoutMs) + try { + return await this.createResolvedWorkspace(record, options) + } finally { + if (timeout) (this.options.clearTimeout ?? clearTimeout)(timeout) + } + } + private async withLaunchDeadline(operation: Promise, workspaceId: string | undefined, + deadlineAt: number, launchTimeoutMs: number): Promise { + const timeoutMs = Math.max(1, deadlineAt - Date.now()) + let timeout: ManagerTimeout | null = null + const deadline = new Promise((_resolve, reject) => { + timeout = (this.options.setTimeout ?? setTimeout)(() => { + timeout = null + reject(new WorkspaceLaunchTimeoutError(workspaceId, launchTimeoutMs)) + }, timeoutMs) + }) + try { + return await Promise.race([operation, deadline]) + } finally { + if (timeout) (this.options.clearTimeout ?? clearTimeout)(timeout) + } + } + private async createResolvedWorkspace( + record: WorkspaceRecord, + options: WorkspaceCreateOptions, + ): Promise { + const state = record[WORKSPACE_STATE] + const { id, path: workspacePath, binaryId: resolvedBinaryPath, proxyPath } = record try { this.throwIfCancelled(record) @@ -431,308 +424,233 @@ export class WorkspaceManager { } const logLevel = (serverConfig as any)?.logLevel - const { pid, port, exitPromise, cancellationPromise, getLastOutput } = await this.runtime.launch({ + const { pid, port, exitPromise, getLastOutput } = await this.runtime.launch({ workspaceId: id, folder: workspacePath, binaryPath: resolvedBinaryPath, environment, logLevel, + signal: state.abortController.signal, onExit: (info) => this.handleProcessExit(info.workspaceId, info), }) - descriptor.pid = pid - descriptor.port = port + record.pid = pid + record.port = port this.throwIfCancelled(record) - record.published = true - this.options.eventBus.publish({ type: "workspace.created", workspace: descriptor }) + state.published = true + this.options.eventBus.publish({ type: "workspace.created", workspace: record }) this.throwIfCancelled(record) - const readinessAbort = new AbortController() - const runtimeVersion = await Promise.race([ - this.waitForWorkspaceReadiness({ workspaceId: id, port, exitPromise, getLastOutput, signal: readinessAbort.signal }), - cancellationPromise.then((error) => { - readinessAbort.abort(error) - throw error - }), - ]) + const runtimeVersion = await this.waitForWorkspaceReadiness({ + workspaceId: id, + port, + exitPromise, + getLastOutput, + signal: state.abortController.signal, + }) this.throwIfCancelled(record) if (runtimeVersion) { - descriptor.binaryVersion = runtimeVersion + record.binaryVersion = runtimeVersion } - descriptor.status = "ready" - descriptor.updatedAt = new Date().toISOString() - this.options.eventBus.publish({ type: "workspace.started", workspace: descriptor }) + record.status = "ready" + record.updatedAt = new Date().toISOString() + this.options.eventBus.publish({ type: "workspace.started", workspace: record }) this.options.logger.info({ workspaceId: id, port }, "Workspace ready") - return { workspace: descriptor, created: true } + return { workspace: record, created: true } } catch (error) { - if (record.lifecycle.cancelled) { - await this.stopRuntime(id) - throw error instanceof WorkspaceLaunchCancelledError ? error : new WorkspaceLaunchCancelledError(id) - } - if (!record.published) { - try { - await this.stopRuntime(id) - } catch (cleanupError) { - this.options.logger.error({ workspaceId: id, err: cleanupError }, "Unpublished workspace cleanup remains pending") - throw cleanupError - } - if (this.workspaces.get(id) === record) { - this.workspaces.delete(id) - this.workspaceIdentities.delete(id) - } - this.opencodeAuth.delete(id) - this.options.logger.error({ workspaceId: id, err: error }, "Workspace failed before identity publication") - throw error - } - + const launchFailure = state.abortController.signal.aborted ? state.abortController.signal.reason : error let stopFailure: unknown - await this.stopRuntime(id).catch((stopError) => { + await this.runtime.stop(id).catch((stopError) => { stopFailure = stopError - this.options.logger.warn({ workspaceId: id, err: stopError }, "Failed to stop workspace after startup error") }) if (!stopFailure) { - this.publishStopped(record) - if (this.workspaces.get(id) === record) { - this.workspaces.delete(id) - this.workspaceIdentities.delete(id) - } - this.opencodeAuth.delete(id) - throw error + this.removeRecord(id, record, state.published) + throw launchFailure } - descriptor.status = "error" - descriptor.error = stopFailure instanceof Error + if (!state.published) { + throw stopFailure + } + record.status = "error" + record.error = stopFailure instanceof Error ? `Workspace startup failed and its process could not be stopped: ${stopFailure.message}` - : error instanceof Error ? error.message : String(error) - descriptor.updatedAt = new Date().toISOString() - if (this.workspaces.get(id) === record && record.published) { - this.options.eventBus.publish({ type: "workspace.error", workspace: descriptor }) + : launchFailure instanceof Error ? launchFailure.message : String(launchFailure) + record.updatedAt = new Date().toISOString() + if (this.workspaces.get(id) === record && state.published) { + this.options.eventBus.publish({ type: "workspace.error", workspace: record }) } - this.options.logger.error({ workspaceId: id, err: error }, "Workspace failed to start") - throw error - } finally { - record.lifecycle.settled = true - record.lifecycle.complete() + this.options.logger.error({ workspaceId: id, err: launchFailure }, "Workspace failed to start") + throw launchFailure } } delete(id: string): Promise { const record = this.workspaces.get(id) if (!record) return Promise.resolve(undefined) - - record.lifecycle.cancelled = true - const identityKey = this.workspaceIdentities.get(id) - if (identityKey && this.pendingWorkspaceOwners.get(identityKey) === id) { - this.pendingWorkspaceCreations.delete(identityKey) - this.pendingWorkspaceOwners.delete(identityKey) + const state = record[WORKSPACE_STATE] + if (!state.abortController.signal.aborted) { + state.abortController.abort(new WorkspaceLaunchCancelledError(id)) } - if (!record.lifecycle.deletePromise) { + const pending = this.pendingWorkspaceCreations.get(record.identityKey) + if (pending === record) { + this.pendingWorkspaceCreations.delete(record.identityKey) + } + if (!state.deletePromise) { let deletePromise!: Promise deletePromise = this.cleanupDeletedWorkspace(id, record).catch((error) => { - if (record.lifecycle.deletePromise === deletePromise) { - record.lifecycle.deletePromise = undefined - } + if (state.deletePromise === deletePromise) state.deletePromise = undefined throw error }) - record.lifecycle.deletePromise = deletePromise + state.deletePromise = deletePromise } - return record.lifecycle.deletePromise + return state.deletePromise } releaseCreationRequest(id: string, requestId: string): boolean { const record = this.workspaces.get(id) - if (!record?.published) return false - if (record.releasedCreationRequestIds.has(requestId)) return true - if (!record.creationOwnership.requestIds.has(requestId)) return false - this.retainCreationOwnership(record) + if (!record?.[WORKSPACE_STATE].published) return false + const ownership = record.ownership + const state = ownership.get(requestId) + if (state === "released") return true + if (state === "cancelled") return false + if (state !== "active") return false + ownership.set(requestId, "released") + this.syncOwnership(record) return true } async cancelCreationRequest(requestId: string): Promise { + let matched = false for (const [workspaceId, record] of this.workspaces) { - if (record.releasedCreationRequestIds.has(requestId)) return - if (!record.creationOwnership.requestIds.delete(requestId)) continue - record.creationOwnership.cancelledRequestIds.add(requestId) - record.releasedCreationRequestIds.add(requestId) - this.syncCreationOwnership(record) - if (record.creationOwnership.requestIds.size > 0 || record.creationOwnership.sharedByNonRestoreCaller) { + const ownership = record.ownership + const state = ownership.get(requestId) + if (state === "released") { matched = true; continue } + if (state === "cancelled") { + matched = true + if (this.hasActiveRequest(ownership) || this.isRetained(ownership)) continue + await this.delete(workspaceId) return } + if (state !== "active") continue + matched = true + ownership.set(requestId, "cancelled") + this.syncOwnership(record) + if (this.hasActiveRequest(ownership) || this.isRetained(ownership)) return await this.delete(workspaceId) return } - if (this.cancelledCreationRequests.size >= 1_024) { - const oldestRequestId = this.cancelledCreationRequests.values().next().value - if (oldestRequestId) this.cancelledCreationRequests.delete(oldestRequestId) - } - this.cancelledCreationRequests.add(requestId) + if (!matched) this.cancelledCreationRequests.add(requestId) } private createOwnership(requestId?: string): WorkspaceCreationOwnership { - return { - requestIds: new Set(requestId ? [requestId] : []), - cancelledRequestIds: new Set(), - sharedByNonRestoreCaller: !requestId, - } + return new Map([[requestId ?? ORDINARY_CREATION_OWNER, "active"]]) } - private resultForCreationOwner( + private finishCreation( result: WorkspaceCreateResult, requestId: string | undefined, ownership: WorkspaceCreationOwnership, ): WorkspaceCreateResult { - if (requestId && ownership.cancelledRequestIds.has(requestId)) { + if (requestId && ownership.get(requestId) === "cancelled") { throw new Error(`Workspace creation request ${requestId} was cancelled`) } + const retained = this.isRetained(ownership) return { - workspace: requestId && !ownership.sharedByNonRestoreCaller - ? { ...result.workspace, requestId } - : result.workspace, - created: result.created, + workspace: requestId ? { ...result.workspace, requestId } : result.workspace, + created: result.created && !(requestId && retained), } } - private syncCreationOwnership(record: WorkspaceRecord | undefined): void { + private syncOwnership(record: WorkspaceRecord | undefined): void { if (!record) return - record.descriptor.requestId = record.creationOwnership.sharedByNonRestoreCaller + const ownership = record.ownership + record.requestId = this.isRetained(ownership) ? undefined - : record.creationOwnership.requestIds.values().next().value + : Array.from(ownership).find(([, state]) => state === "active")?.[0] } - private retainCreationOwnership(record: WorkspaceRecord | undefined): void { - if (!record) return - for (const requestId of record.creationOwnership.requestIds) { - record.releasedCreationRequestIds.add(requestId) - } - record.creationOwnership.requestIds.clear() - record.creationOwnership.sharedByNonRestoreCaller = true - this.syncCreationOwnership(record) + private isRetained(ownership: WorkspaceCreationOwnership): boolean { + return ownership.has(ORDINARY_CREATION_OWNER) || Array.from(ownership.values()).includes("released") + } + + private hasActiveRequest(ownership: WorkspaceCreationOwnership): boolean { + return Array.from(ownership).some(([requestId, state]) => Boolean(requestId) && state === "active") } async shutdown() { this.shuttingDown = true this.options.logger.info("Shutting down all workspaces") - const stopTasks = Array.from(this.workspaces.keys(), (id) => this.delete(id)) - let stopFailures: unknown[] = [] - - if (stopTasks.length > 0) { - const results = await this.withShutdownTimeout(Promise.allSettled(stopTasks)) - stopFailures = results.flatMap((result) => (result.status === "rejected" ? [result.reason] : [])) - } - + const results = stopTasks.length + ? await this.withTimeout(Promise.allSettled(stopTasks), this.options.shutdownTimeoutMs ?? 10000, "shutdown") + : [] + const stopFailures = results.flatMap((result) => result.status === "rejected" ? [result.reason] : []) if (this.workspaces.size === 0) { - this.workspaceIdentities.clear() this.pendingWorkspaceCreations.clear() - this.pendingWorkspaceOwners.clear() this.cancelledCreationRequests.clear() - this.options.logger.info("All workspaces cleared") - } else { - this.options.logger.warn( - { workspaceIds: Array.from(this.workspaces.keys()) }, - "Some workspace records remain after failed shutdown cleanup", - ) - if (stopFailures.length === 0) { - stopFailures.push(new Error(`Workspace cleanup remains incomplete for: ${Array.from(this.workspaces.keys()).join(", ")}`)) - } - } - - if (stopFailures.length > 0) { - throw new WorkspaceShutdownError(stopFailures) - } + } else if (!stopFailures.length) stopFailures.push( + new Error(`Workspace cleanup remains incomplete for: ${Array.from(this.workspaces.keys()).join(", ")}`), + ) + if (stopFailures.length) throw new WorkspaceShutdownError(stopFailures) } - private async withShutdownTimeout(operation: Promise): Promise { + private async withTimeout(operation: Promise, timeoutMs: number, label: string): Promise { let timeout: ManagerTimeout | null = null const deadline = new Promise((_resolve, reject) => { - timeout = this.scheduleTimeout(() => { + timeout = (this.options.setTimeout ?? setTimeout)(() => { timeout = null - reject(new WorkspaceShutdownTimeoutError(this.shutdownTimeoutMs)) - }, this.shutdownTimeoutMs) + reject(new WorkspaceCleanupTimeoutError(label, timeoutMs)) + }, timeoutMs) }) try { return await Promise.race([operation, deadline]) } finally { - if (timeout) { - this.cancelTimeout(timeout) - } + if (timeout) (this.options.clearTimeout ?? clearTimeout)(timeout) } } private requireWorkspace(id: string): WorkspaceDescriptor { const record = this.workspaces.get(id) - if (!record?.published) { - throw new Error("Workspace not found") - } - return record.descriptor + if (!record?.[WORKSPACE_STATE].published) throw new Error("Workspace not found") + return record } private throwIfCancelled(record: WorkspaceRecord): void { - if (record.lifecycle.cancelled) { - throw new WorkspaceLaunchCancelledError(record.descriptor.id) - } - } - - private async stopRuntime(workspaceId: string): Promise { - try { - await this.runtime.stop(workspaceId) - } catch (error) { - this.options.logger.warn({ workspaceId, err: error }, "Failed to stop workspace process cleanly") - throw error - } + record[WORKSPACE_STATE].abortController.signal.throwIfAborted() } private async cleanupDeletedWorkspace(id: string, record: WorkspaceRecord): Promise { - this.options.logger.info({ workspaceId: id }, "Stopping workspace") - // Stop once immediately, then again after launch settlement to cover a child // that became available while cancellation was propagating. - await this.stopRuntime(id) - if (!record.lifecycle.settled) { - await this.withLaunchSettlementTimeout(id, record.lifecycle.completion) - } - try { - await this.runtime.stop(id) - } catch (error) { - this.options.logger.warn({ workspaceId: id, err: error }, "Failed final workspace process cleanup") - throw error - } - - if (this.workspaces.get(id) === record) { - this.workspaces.delete(id) - this.workspaceIdentities.delete(id) - this.opencodeAuth.delete(id) - clearWorkspaceSearchCache(record.descriptor.path) - this.publishStopped(record) - } - return record.descriptor - } - - private async withLaunchSettlementTimeout(workspaceId: string, completion: Promise): Promise { - let timeout: ManagerTimeout | null = null - const deadline = new Promise((_resolve, reject) => { - timeout = this.scheduleTimeout(() => { - timeout = null - reject(new WorkspaceLaunchSettlementTimeoutError(workspaceId, this.launchSettlementTimeoutMs)) - }, this.launchSettlementTimeoutMs) + const immediateStop = this.runtime.stop(id).catch((error) => { + this.options.logger.warn({ workspaceId: id, err: error }, "Initial workspace process cleanup failed; retrying after launch settles") }) + await this.withTimeout(record[WORKSPACE_STATE].settlement!, this.options.launchSettlementTimeoutMs ?? 5000, `${id} launch cancellation`) + await immediateStop + await this.runtime.stop(id) - try { - await Promise.race([completion, deadline]) - } finally { - if (timeout) { - this.cancelTimeout(timeout) - } - } + this.removeRecord(id, record, true) + return record } - private publishStopped(record: WorkspaceRecord): void { - if (record.lifecycle.stoppedEventPublished) return - record.lifecycle.stoppedEventPublished = true - this.options.eventBus.publish({ type: "workspace.stopped", workspaceId: record.descriptor.id }) + private removeRecord(id: string, record: WorkspaceRecord, publishStopped: boolean): void { + if (this.workspaces.get(id) !== record) return + this.workspaces.delete(id) + this.opencodeAuth.delete(id) + clearWorkspaceSearchCache(record.path) + if (publishStopped) this.publishStopped(record, "deleted") } - resolveBinaryPath(identifier: string): string { + private publishStopped(record: WorkspaceRecord, reason: "deleted" | "stopped" = "stopped"): void { + const state = record[WORKSPACE_STATE] + if (!state.published || state.stoppedPublished) return + state.stoppedPublished = true + record.status = "stopped" + record.error = undefined + this.options.eventBus.publish({ type: "workspace.stopped", workspaceId: record.id, reason }) + } + + resolveBinaryPath(identifier: string, timeoutMs = DEFAULT_LAUNCH_TIMEOUT_MS): string { if (!identifier) { return identifier } @@ -745,7 +663,7 @@ export class WorkspaceManager { const locator = process.platform === "win32" ? "where" : "which" try { - const result = spawnSync(locator, [identifier], { encoding: "utf8" }) + const result = spawnSync(locator, [identifier], { encoding: "utf8", timeout: Math.max(1, timeoutMs) }) if (result.status === 0 && result.stdout) { const candidates = result.stdout .split(/\r?\n/) @@ -785,60 +703,27 @@ export class WorkspaceManager { return candidates[0] ?? "" } - private async waitForWorkspaceReadiness(params: { - workspaceId: string - port: number - exitPromise: Promise - getLastOutput: () => string - signal?: AbortSignal - }): Promise { + private async waitForWorkspaceReadiness(params: WorkspaceReadiness): Promise { await Promise.race([ this.waitForPortAvailability(params.port, 5000, params.signal), - params.exitPromise.then((info) => { - throw this.buildStartupError( - params.workspaceId, - "exited before becoming ready", - info, - params.getLastOutput(), - ) - }), + this.exitDuringStartup(params, "exited before becoming ready"), ]) const version = await this.waitForInstanceHealth(params) await Promise.race([ - this.delay(STARTUP_STABILITY_DELAY_MS, params.signal), - params.exitPromise.then((info) => { - throw this.buildStartupError( - params.workspaceId, - "exited shortly after start", - info, - params.getLastOutput(), - ) - }), + delay(STARTUP_STABILITY_DELAY_MS, undefined, { signal: params.signal }), + this.exitDuringStartup(params, "exited shortly after start"), ]) return version } - private async waitForInstanceHealth(params: { - workspaceId: string - port: number - exitPromise: Promise - getLastOutput: () => string - signal?: AbortSignal - }): Promise { + private async waitForInstanceHealth(params: WorkspaceReadiness): Promise { const probeResult = await Promise.race([ this.probeInstance(params.workspaceId, params.port, params.signal), - params.exitPromise.then((info) => { - throw this.buildStartupError( - params.workspaceId, - "exited during health checks", - info, - params.getLastOutput(), - ) - }), + this.exitDuringStartup(params, "exited during health checks"), ]) if (probeResult.ok) { @@ -853,6 +738,12 @@ export class WorkspaceManager { throw new Error(`Workspace ${params.workspaceId} failed health check: ${reason}.`) } + private exitDuringStartup(params: WorkspaceReadiness, phase: string): Promise { + return params.exitPromise.then((info) => { + throw this.buildStartupError(params.workspaceId, phase, info, params.getLastOutput()) + }) + } + private async probeInstance( workspaceId: string, port: number, @@ -909,7 +800,6 @@ export class WorkspaceManager { const deadline = Date.now() + timeoutMs let settled = false let retryTimer: NodeJS.Timeout | null = null - let socket: ReturnType | null = null const cleanup = () => { settled = true @@ -917,27 +807,21 @@ export class WorkspaceManager { clearTimeout(retryTimer) retryTimer = null } - signal?.removeEventListener("abort", onAbort) - } - const onAbort = () => { - if (settled) return - cleanup() - socket?.destroy() - reject(signal?.reason instanceof Error ? signal.reason : new Error("Workspace readiness was cancelled")) } const tryConnect = () => { - if (settled) { - return - } - socket = connect({ port, host: "127.0.0.1" }, () => { + if (settled) return + const socket = connect({ port, host: "127.0.0.1", signal }, () => { cleanup() - socket?.end() + socket.end() resolve() }) socket.once("error", () => { - socket?.destroy() - if (settled) { + socket.destroy() + if (settled) return + if (signal?.aborted) { + cleanup() + reject(signal.reason) return } if (Date.now() >= deadline) { @@ -952,37 +836,11 @@ export class WorkspaceManager { }) } - if (signal?.aborted) { - onAbort() - return - } - signal?.addEventListener("abort", onAbort, { once: true }) + if (signal?.aborted) return reject(signal.reason) tryConnect() }) } - private delay(durationMs: number, signal?: AbortSignal): Promise { - if (durationMs <= 0) { - return Promise.resolve() - } - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - signal?.removeEventListener("abort", onAbort) - resolve() - }, durationMs) - const onAbort = () => { - clearTimeout(timer) - signal?.removeEventListener("abort", onAbort) - reject(signal?.reason instanceof Error ? signal.reason : new Error("Workspace readiness was cancelled")) - } - if (signal?.aborted) { - onAbort() - return - } - signal?.addEventListener("abort", onAbort, { once: true }) - }) - } - private describeExit(info: ProcessExitInfo): string { if (info.signal) { return `signal ${info.signal}` @@ -996,7 +854,7 @@ export class WorkspaceManager { private handleProcessExit(workspaceId: string, info: { code: number | null; requested: boolean }) { const record = this.workspaces.get(workspaceId) if (!record) return - const workspace = record.descriptor + const workspace = record this.opencodeAuth.delete(workspaceId) @@ -1006,9 +864,7 @@ export class WorkspaceManager { workspace.port = undefined workspace.updatedAt = new Date().toISOString() - if (record.lifecycle.cancelled || info.requested || info.code === 0) { - workspace.status = "stopped" - workspace.error = undefined + if (record[WORKSPACE_STATE].abortController.signal.aborted || info.requested || info.code === 0) { this.publishStopped(record) } else { workspace.status = "error" diff --git a/packages/server/src/workspaces/process-identity.darwin.test.ts b/packages/server/src/workspaces/process-identity.darwin.test.ts new file mode 100644 index 00000000..f657474f --- /dev/null +++ b/packages/server/src/workspaces/process-identity.darwin.test.ts @@ -0,0 +1,97 @@ +import assert from "node:assert/strict" +import { spawn, spawnSync } from "node:child_process" +import { once } from "node:events" +import { setTimeout as delay } from "node:timers/promises" +import { it } from "node:test" + +import { + LAUNCH_CLEANUP_TOKEN_ENV, + probePosixProcesses, + signalOwnedPosixProcessGroup, + signalPosixProcesses, +} from "./process-identity" + +const darwinOnly = { skip: process.platform !== "darwin", timeout: 10_000 } + +async function spawnDetachedGroup(cleanupToken?: string) { + const leader = spawn(process.execPath, ["-e", ` + const { spawn } = require("node:child_process") + spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { stdio: "ignore" }) + process.stdout.write("ready\\n") + setInterval(() => {}, 1000) + `], { + detached: true, + stdio: ["ignore", "pipe", "ignore"], + env: { ...process.env, ...(cleanupToken ? { [LAUNCH_CLEANUP_TOKEN_ENV]: cleanupToken } : {}) }, + }) + assert.ok(leader.pid) + await once(leader.stdout!, "data") + return leader as typeof leader & { pid: number } +} + +async function assertGroupGone(groupId: number): Promise { + for (let attempt = 0; attempt < 20; attempt += 1) { + const remaining = probePosixProcesses(spawnSync, 1_000, "darwin", { groupId }) + if (remaining.ok && remaining.processes.size === 0) return + await delay(50) + } + assert.fail("owned Darwin process group remained alive after signaling") +} + +it("uses real Darwin ps identities to stop an owned detached process group", darwinOnly, async () => { + const leader = await spawnDetachedGroup() + + try { + const snapshot = probePosixProcesses(spawnSync, 1_000, "darwin", { + pids: [leader.pid], + groupId: leader.pid, + }) + assert.equal(snapshot.ok, true) + assert.equal(snapshot.ok && snapshot.processes.get(leader.pid)?.groupId, leader.pid) + assert.equal(snapshot.ok && snapshot.processes.size >= 2, true) + + const signaled = signalOwnedPosixProcessGroup(spawnSync, leader.pid, "SIGTERM", 1_000) + assert.equal(signaled.ok && signaled.matched, true) + assert.equal(signaled.ok && signaled.signalSent, true) + await assertGroupGone(leader.pid) + } finally { + try { + process.kill(-leader.pid, "SIGKILL") + } catch { + // The successful path has already removed the process group. + } + } +}) + +it("uses a retained real Darwin identity anchor after the group leader exits", darwinOnly, async () => { + const cleanupToken = "darwin-integration-cleanup-token" + const leader = await spawnDetachedGroup(cleanupToken) + + try { + const snapshot = probePosixProcesses(spawnSync, 1_000, "darwin", { groupId: leader.pid }) + assert.equal(snapshot.ok, true) + const leaderIdentity = snapshot.ok ? snapshot.processes.get(leader.pid) : undefined + assert.ok(leaderIdentity) + assert.equal(snapshot.ok && snapshot.processes.size >= 2, true) + + leader.kill("SIGTERM") + if (leader.exitCode === null) await once(leader, "exit") + const signaled = signalPosixProcesses(spawnSync, { + leader: leaderIdentity, + groupId: leader.pid, + members: [], + signal: "SIGTERM", + allowLeaderlessGroup: true, + cleanupToken, + }, 1_000, "darwin") + assert.equal(signaled.ok && signaled.matched, true) + assert.equal(signaled.ok && signaled.signalSent, true) + await assertGroupGone(leader.pid) + } finally { + try { + process.kill(-leader.pid, "SIGKILL") + } catch { + // The successful path has already removed the process group. + } + } +}) diff --git a/packages/server/src/workspaces/process-identity.test.ts b/packages/server/src/workspaces/process-identity.test.ts index 753e1df7..bd73104c 100644 --- a/packages/server/src/workspaces/process-identity.test.ts +++ b/packages/server/src/workspaces/process-identity.test.ts @@ -3,215 +3,131 @@ import type { SpawnSyncReturns } from "node:child_process" import { describe, it } from "node:test" import { - probePosixProcesses, - probeWindowsProcesses, - probeWslProcesses, - sameProcess, - signalOwnedPosixProcessGroup, - signalPosixProcesses, - signalWindowsProcesses, - startedNoLaterThan, - type ProcessIdentity, + probePosixProcesses, probeWindowsProcesses, probeWslProcesses, sameProcess, + signalOwnedPosixProcessGroup, signalPosixProcesses, signalWindowsProcesses, + startedNoLaterThan, type ProcessIdentity, } from "./process-identity" -type SpawnCommand = typeof import("node:child_process").spawnSync - -function result(stdout = "", status = 0, stderr = ""): SpawnSyncReturns { - return { pid: 1, output: [null, stdout, stderr], stdout, stderr, status, signal: null } -} - -function b64(value: string): string { - return Buffer.from(value).toString("base64") -} +type Spawn = typeof import("node:child_process").spawnSync +type Call = { command: string; args: readonly string[]; script: string } +const output = (stdout = "", status = 0, stderr = ""): SpawnSyncReturns => + ({ pid: 1, output: [null, stdout, stderr], stdout, stderr, status, signal: null }) +const spawn = (stdout: string, call?: Call, status = 0, stderr = "") => ((command: string, args: readonly string[]) => { + if (call) Object.assign(call, { command, args, script: command === "powershell.exe" ? args.at(-1) ?? "" : args[args.indexOf("-c") + 1] ?? "" }) + return output(stdout, status, stderr) +}) as unknown as Spawn +const b64 = (value: string) => Buffer.from(value).toString("base64") +const identity = (startTime = "123456"): ProcessIdentity => + ({ pid: 42, parentPid: 1, groupId: 42, startTime, bootId: "boot-a", startOrder: startTime }) describe("process identity probes", () => { - it("parses Linux PID, PGID, and kernel process start ticks", () => { - let invocation: { command: string; args: readonly string[] } | undefined - const probe = probePosixProcesses(((command: string, args: readonly string[]) => { - invocation = { command, args } - return result("42|1|42|123456|boot-a|123456\n") - }) as unknown as SpawnCommand, 25, "linux") - - assert.equal(invocation?.command, "sh") - assert.ok(invocation?.args.includes("codenomad-posix-identity")) - assert.equal(probe.ok, true) - if (probe.ok) { - assert.deepEqual(probe.processes.get(42), { - pid: 42, - parentPid: 1, - groupId: 42, - startTime: "123456", - bootId: "boot-a", - startOrder: "123456", - }) - } + it("parses immutable Linux identities", () => { + const call = {} as Call + const probe = probePosixProcesses(spawn("42|1|42|123456|boot-a|123456\n", call), 25, "linux") + assert.deepEqual([call.command, call.args.includes("codenomad-posix-identity"), call.script.trimEnd().endsWith("exit 0")], ["sh", true, true]) + assert.deepEqual(probe.ok && probe.processes.get(42), identity()) }) - it("uses one process-table query on non-Linux POSIX platforms", () => { - let invocation: { command: string; args: readonly string[] } | undefined - const probe = probePosixProcesses(((command: string, args: readonly string[]) => { - invocation = { command, args } - return result(" 42 1 42 Fri Jul 10 12:34:56 2026 /usr/bin/opencode serve\n") - }) as unknown as SpawnCommand, 25, "darwin") - - assert.equal(invocation?.command, "ps") - assert.deepEqual(invocation?.args, ["-axo", "pid=,ppid=,pgid=,lstart=,command="]) - assert.equal(probe.ok && probe.processes.get(42)?.startTime, "Fri Jul 10 12:34:56 2026\t/usr/bin/opencode serve") - }) - - it("round trips delimiter-heavy non-Linux POSIX command identities", () => { + it("uses one delimiter-safe process-table query on portable POSIX", () => { + const call = {} as Call const command = "/opt/opencode 'pipe|value'\t\"quoted\" café" const start = "Fri Jul 10 12:34:56 2026" - const probe = probePosixProcesses((() => result( - `42 1 42 ${start} ${command}\n`, - )) as unknown as SpawnCommand, 25, "darwin") - + const probe = probePosixProcesses(spawn(`42 1 42 ${start} ${command}\n`, call), 25, "darwin") + assert.deepEqual([call.command, call.args], ["ps", ["-axo", "pid=,ppid=,pgid=,lstart=,comm="]]) assert.equal(probe.ok && probe.processes.get(42)?.startTime, `${start}\t${command}`) }) - it("uses delimiter-safe identities during non-Linux POSIX escalation", () => { + it("ignores malformed unrelated portable rows but fails for a malformed requested identity", () => { + const start = "Fri Jul 10 12:34:56 2026" + const unrelated = `77 1 77 malformed identity\n42 1 42 ${start} opencode\n` + const filtered = probePosixProcesses(spawn(unrelated), 25, "darwin", { pids: [42], groupId: 42 }) + assert.equal(filtered.ok && filtered.processes.get(42)?.startTime, `${start}\topencode`) + assert.equal(probePosixProcesses(spawn(unrelated), 25, "darwin", { pids: [77] }).ok, false) + }) + + it("preserves delimiter-heavy identities through POSIX escalation and rescan", () => { const command = "/opt/opencode pipe|value\nnext\t'quoted'" - const startTime = `Fri Jul 10 12:34:56 2026\t${command}` - const identity: ProcessIdentity = { pid: 42, parentPid: 1, groupId: 42, startTime } - const guarded = signalPosixProcesses((() => result( - `CODENOMAD_TARGET_B64|42|1|42|${b64("Fri Jul 10 12:34:56 2026")}|${b64(command)}\nCODENOMAD_RESULT|1||1\n`, - )) as unknown as SpawnCommand, { leader: identity, groupId: 42, members: [identity], signal: "SIGKILL" }, 25, "darwin") + const row = `CODENOMAD_TARGET_B64|42|1|42|${b64("Fri Jul 10 12:34:56 2026")}|${b64(command)}\nCODENOMAD_RESULT|1||1\n` + const expected = `Fri Jul 10 12:34:56 2026\t${command}` + const guardedCall = {} as Call + const guarded = signalPosixProcesses(spawn(row, guardedCall), { leader: identity(expected), groupId: 42, members: [identity(expected)], signal: "SIGKILL" }, 25, "darwin") + const call = {} as Call + const owned = signalOwnedPosixProcessGroup(spawn(row, call), 42, "SIGTERM", 25) + assert.deepEqual([guarded.ok, guarded.ok && guarded.signaled[0]?.startTime], [true, expected]) + assert.deepEqual([owned.ok && owned.matched, owned.ok && owned.signaled[0]?.startTime], [true, expected]) + assert.ok(call.script.indexOf('kill "-$requested_signal"') < call.script.lastIndexOf("for current_pid")) + assert.match(call.script, /group_pids\(\).*pid=,pgid=/) + assert.doesNotMatch(call.script, /ps -eo pid=/) + assert.match(guardedCall.script, /test "\$current_group" = "\$expected_group"/) + }) + it("marks a retained portable group request for leaderless guarded cleanup", () => { + const call = {} as Call + const guarded = signalPosixProcesses(spawn("CODENOMAD_RESULT|1||1\n", call), { + leader: identity("gone"), groupId: 42, members: [identity("member")], signal: "SIGTERM", + allowLeaderlessGroup: true, cleanupToken: "secret-token", + }, 25, "darwin") assert.equal(guarded.ok, true) - assert.equal(guarded.ok && guarded.signaled[0]?.startTime, startTime) + assert.equal(call.args[7], "1") + assert.equal(call.args[8], "secret-token") + assert.match(call.script, /anchor=0/) + assert.match(call.script, /has_cleanup_token/) }) - it("signals and rescans a still-owned portable POSIX process group", () => { - const command = "/opt/opencode pipe|value\nchild" - let script = "" - const cleanup = signalOwnedPosixProcessGroup(((_command: string, args: readonly string[]) => { - script = args[1] ?? "" - return result( - `CODENOMAD_TARGET_B64|42|1|42|${b64("Fri Jul 10 12:34:56 2026")}|${b64(command)}\n` + - "CODENOMAD_RESULT|1||1\n", - ) - }) as unknown as SpawnCommand, 42, "SIGTERM", 25) - - assert.equal(cleanup.ok && cleanup.matched, true) - assert.equal(cleanup.ok && cleanup.signaled[0]?.startTime, `Fri Jul 10 12:34:56 2026\t${command}`) - assert.ok(script.indexOf('kill "-$requested_signal"') < script.lastIndexOf("for current_pid")) - }) - - it("rejects malformed portable process rows", () => { - const probe = probePosixProcesses((() => result("42 malformed process row\n")) as unknown as SpawnCommand, 25, "darwin") - - assert.deepEqual(probe, { ok: false, error: "process identity query returned no parseable processes" }) - }) - - it("queries WSL identities inside the selected distro", () => { - let invocation: { command: string; args: readonly string[] } | undefined - const probe = probeWslProcesses(((command: string, args: readonly string[]) => { - invocation = { command, args } - return result("99|1|99|123456|boot-a|123456\n101|99|99|123460|boot-a|123460\n") - }) as unknown as SpawnCommand, "Ubuntu Test", 25) - - assert.equal(invocation?.command, "wsl.exe") - assert.deepEqual(invocation?.args.slice(0, 4), ["--distribution", "Ubuntu Test", "--exec", "sh"]) - assert.ok(invocation?.args.includes("codenomad-wsl-identity")) + it("queries WSL identities in the selected distro", () => { + const call = {} as Call + const probe = probeWslProcesses(spawn("99|1|99|123456|boot-a|123456\n101|99|99|123460|boot-a|123460\n", call), "Ubuntu Test", 25) + assert.deepEqual([call.command, call.args.slice(0, 4), call.args.includes("codenomad-wsl-identity"), call.script.trimEnd().endsWith("exit 0")], + ["wsl.exe", ["--distribution", "Ubuntu Test", "--exec", "sh"], true, true]) assert.equal(probe.ok && probe.processes.get(101)?.startTime, "123460") }) - it("parses Windows CIM CreationDate as the immutable identity", () => { - let script = "" - const probe = probeWindowsProcesses(((_command: string, args: readonly string[]) => { - script = args.at(-1) ?? "" - return result("4242|100|0|20260710123456.123456+000||20260710123456\n") - }) as unknown as SpawnCommand, 25) - - assert.match(script, /Get-CimInstance Win32_Process/) - assert.match(script, /ProcessId -gt 0/) + it("uses Windows CIM CreationDate as the immutable identity", () => { + const call = {} as Call + const probe = probeWindowsProcesses(spawn("4242|100|0|20260710123456.123456+000||20260710123456\n", call), 25) + assert.match(call.script, /Get-CimInstance Win32_Process/) + assert.match(call.script, /ProcessId -gt 0/) assert.equal(probe.ok && probe.processes.get(4242)?.startTime, "20260710123456.123456+000") }) - it("matches only the same numeric PID and start identity", () => { - const original: ProcessIdentity = { pid: 42, parentPid: 1, groupId: 42, startTime: "start-a" } - assert.equal(sameProcess(original, { ...original }), true) - assert.equal(sameProcess(original, { ...original, startTime: "start-b" }), false) - assert.equal(sameProcess(original, { ...original, pid: 43 }), false) + it("rejects PID reuse and invalid start ordering", () => { + const original = identity("9") + for (const [candidate, expected] of [[{ ...original }, true], [{ ...original, startTime: "10" }, false], [{ ...original, pid: 43 }, false]] as const) + assert.equal(sameProcess(original, candidate), expected) + assert.equal(startedNoLaterThan(original, "10"), true) + assert.equal(startedNoLaterThan({ ...original, startOrder: "11" }, "10"), false) + assert.equal(startedNoLaterThan({ ...original, startOrder: "Fri Jul 10" }, "10"), false) }) - it("compares Linux start ticks numerically and rejects non-numeric fallbacks", () => { - const identity: ProcessIdentity = { pid: 42, parentPid: 1, groupId: 42, startTime: "9", startOrder: "9" } - assert.equal(startedNoLaterThan(identity, "10"), true) - assert.equal(startedNoLaterThan({ ...identity, startOrder: "11" }, "10"), false) - assert.equal(startedNoLaterThan({ ...identity, startOrder: "Fri Jul 10" }, "10"), false) - }) - - it("returns mismatch without scheduling a second POSIX signal command", () => { - const invocations: Array<{ command: string; args: readonly string[] }> = [] - const identity: ProcessIdentity = { - pid: 42, - parentPid: 1, - groupId: 42, - startTime: "123456", - bootId: "boot-a", - startOrder: "123456", - } - const guarded = signalPosixProcesses(((command: string, args: readonly string[]) => { - invocations.push({ command, args }) - return result("CODENOMAD_RESULT|0||0\n") - }) as unknown as SpawnCommand, { leader: identity, groupId: 42, members: [identity], signal: "SIGTERM" }, 25, "linux") - + it("returns a POSIX mismatch without a second signal command", () => { + const call = {} as Call + const guarded = signalPosixProcesses(spawn("CODENOMAD_RESULT|0||0\n", call), { leader: identity(), groupId: 42, members: [identity()], signal: "SIGTERM" }, 25, "linux") assert.deepEqual(guarded, { ok: true, matched: false, signalSent: false, signaled: [] }) - assert.equal(invocations.length, 1) - assert.equal(invocations[0]?.command, "sh") - assert.match(invocations[0]?.args[2] ?? "", /codenomad-guarded-signal/) - assert.ok(invocations[0]?.args.includes("123456")) - const script = invocations[0]?.args[1] ?? "" - assert.ok(script.indexOf('kill "-$requested_signal"') < script.indexOf("uptime=$(cut")) + assert.deepEqual([call.command, call.args[2], call.args.includes("123456")], ["sh", "codenomad-guarded-signal", true]) + assert.ok(call.script.indexOf('kill "-$requested_signal"') < call.script.indexOf("uptime=$(cut")) }) - it("uses one guarded Windows CIM selection and termination invocation", () => { - const invocations: Array<{ command: string; args: readonly string[] }> = [] - const identity: ProcessIdentity = { pid: 4242, parentPid: 1, groupId: 4242, startTime: "created" } - const guarded = signalWindowsProcesses(((command: string, args: readonly string[]) => { - invocations.push({ command, args }) - return result("CODENOMAD_TARGET|4242|1|0|created||99\nCODENOMAD_RESULT|1||1\n") - }) as unknown as SpawnCommand, { leader: identity, groupId: 4242, members: [identity], signal: "SIGKILL" }, 25) - + it("selects and terminates Windows identities in one guarded CIM invocation", () => { + const call = {} as Call + const guarded = signalWindowsProcesses(spawn("CODENOMAD_TARGET|4242|1|0|created||99\nCODENOMAD_RESULT|1||1\n", call), { leader: identity("created"), groupId: 42, members: [identity("created")], signal: "SIGKILL" }, 25) assert.equal(guarded.ok && guarded.matched, true) - assert.equal(invocations.length, 1) - assert.equal(invocations[0]?.command, "powershell.exe") - const script = invocations[0]?.args.at(-1) ?? "" - assert.match(script, /CreationDate/) - assert.match(script, /Invoke-CimMethod -InputObject/) - assert.equal(script.match(/foreach \(\$process in \$selected\)/g)?.length, 2) - assert.ok(script.indexOf("CODENOMAD_TARGET|") < script.indexOf("Invoke-CimMethod")) - assert.doesNotMatch(script, /taskkill/i) + assert.equal(call.command, "powershell.exe") + assert.match(call.script, /CreationDate.*Invoke-CimMethod -InputObject/s) + assert.equal(call.script.match(/foreach \(\$process in \$selected\)/g)?.length, 2) + assert.ok(call.script.indexOf("CODENOMAD_TARGET|") < call.script.indexOf("Invoke-CimMethod")) + assert.doesNotMatch(call.script, /taskkill/i) }) - it("preserves observed Windows targets when guarded termination fails partway", () => { - const identity: ProcessIdentity = { pid: 4242, parentPid: 1, groupId: 4242, startTime: "created" } - const guarded = signalWindowsProcesses((() => result( - [ - "CODENOMAD_TARGET|4242|1|0|created||99", - "CODENOMAD_TARGET|4243|4242|0|descendant||100", - ].join("\n"), - 1, - "termination failed", - )) as unknown as SpawnCommand, { - leader: identity, - groupId: 4242, - members: [identity], - signal: "SIGTERM", - }, 25) - + it("retains observed Windows identities after partial termination failure", () => { + const rows = "CODENOMAD_TARGET|4242|1|0|created||99\nCODENOMAD_TARGET|4243|4242|0|descendant||100" + const guarded = signalWindowsProcesses(spawn(rows, undefined, 1, "termination failed"), { leader: identity("created"), groupId: 42, members: [identity("created")], signal: "SIGTERM" }, 25) assert.equal(guarded.ok, false) - assert.deepEqual(!guarded.ok && guarded.observed?.map((target) => target.pid), [4242, 4243]) + assert.deepEqual(!guarded.ok && guarded.observed?.map(({ pid }) => pid), [4242, 4243]) }) - it("reports command failures without fabricating identities", () => { - const probe = probeWindowsProcesses((() => result("", 1, "CIM unavailable")) as unknown as SpawnCommand, 25) - assert.deepEqual(probe, { ok: false, error: "CIM unavailable" }) - }) - - it("rejects successful commands with unparseable or empty output", () => { - const probe = probeWslProcesses((() => result("not an identity")) as unknown as SpawnCommand, "Ubuntu", 25) - assert.deepEqual(probe, { ok: false, error: "process identity query returned no parseable processes" }) + it("fails conservatively for command, malformed, and empty probe output", () => { + assert.deepEqual(probeWindowsProcesses(spawn("", undefined, 1, "CIM unavailable"), 25), { ok: false, error: "CIM unavailable" }) + assert.deepEqual(probePosixProcesses(spawn("", undefined, 20, "proc unavailable"), 25, "linux"), { ok: false, error: "proc unavailable" }) + for (const probe of [probePosixProcesses(spawn("42 malformed process row\n"), 25, "darwin"), probeWslProcesses(spawn("not an identity"), "Ubuntu", 25)]) assert.equal(probe.ok, false) }) }) diff --git a/packages/server/src/workspaces/process-identity.ts b/packages/server/src/workspaces/process-identity.ts index e7c86ae1..cde0556e 100644 --- a/packages/server/src/workspaces/process-identity.ts +++ b/packages/server/src/workspaces/process-identity.ts @@ -18,44 +18,49 @@ export interface GuardedSignalRequest { groupId?: number members: ProcessIdentity[] signal: NodeJS.Signals + allowLeaderlessGroup?: boolean + cleanupToken?: string +} + +export interface PosixProcessFilter { + pids?: readonly number[] + groupId?: number } export type GuardedSignalResult = | { ok: true; matched: boolean; signalSent: boolean; signaled: ProcessIdentity[]; cutoff?: string } | { ok: false; error: string; observed?: ProcessIdentity[] } +export type TokenSignalResult = { ok: boolean; signalSent: boolean; targets: ProcessIdentity[]; error?: string } + +export const LAUNCH_CLEANUP_TOKEN_ENV = "CODENOMAD_LAUNCH_CLEANUP_TOKEN" + type SpawnCommand = typeof spawnSync -const LINUX_SNAPSHOT_SCRIPT = String.raw` -boot=$(cat /proc/sys/kernel/random/boot_id 2>/dev/null) || exit 1 -test -n "$boot" || exit 1 -for stat in /proc/[0-9]*/stat; do - line=$(cat "$stat" 2>/dev/null) || continue - pid=$(printf '%s\n' "$line" | cut -d' ' -f1) - rest=$(printf '%s\n' "$line" | sed 's/^.*) //') - set -- $rest - printf '%s|%s|%s|%s|%s|%s\n' "$pid" "$2" "$3" "$20" "$boot" "$20" -done -` - -const LINUX_GUARDED_SIGNAL_SCRIPT = String.raw` +const LINUX_IDENTITY_FUNCTIONS = String.raw` boot=$(cat /proc/sys/kernel/random/boot_id 2>/dev/null) || exit 20 read_stat() { line=$(cat "/proc/$1/stat" 2>/dev/null) || return 1 stat_pid=$(printf '%s\n' "$line" | cut -d' ' -f1); rest=$(printf '%s\n' "$line" | sed 's/^.*) //'); set -- $rest stat_ppid=$2; stat_group=$3; stat_start=$20 } -emit_target() { - printf 'CODENOMAD_TARGET|%s|%s|%s|%s|%s|%s\n' "$stat_pid" "$stat_ppid" "$stat_group" "$stat_start" "$boot" "$stat_start" -} +emit_linux() { printf '%s|%s|%s|%s|%s|%s|%s\n' "$1" "$stat_pid" "$stat_ppid" "$stat_group" "$stat_start" "$boot" "$stat_start"; } +` + +const LINUX_SNAPSHOT_SCRIPT = String.raw`${LINUX_IDENTITY_FUNCTIONS} +for stat in /proc/[0-9]*/stat; do + pid=$(basename "$(dirname "$stat")"); read_stat "$pid" && emit_linux "" | cut -c2- +done +exit 0 +` + +const LINUX_GUARDED_SIGNAL_SCRIPT = String.raw`${LINUX_IDENTITY_FUNCTIONS} leader_pid=$1; leader_start=$2; leader_boot=$3; expected_group=$4; requested_signal=$5 -shift 5 -matched=0; cutoff=; signal_sent=0 +shift 5; matched=0; cutoff=; signal_sent=0 if read_stat "$leader_pid" && test "$boot" = "$leader_boot" && test "$stat_start" = "$leader_start" && test "$stat_group" = "$expected_group"; then matched=1 for stat in /proc/[0-9]*/stat; do - candidate=$(basename "$(dirname "$stat")") - if read_stat "$candidate" && test "$stat_group" = "$expected_group"; then emit_target; fi + candidate=$(basename "$(dirname "$stat")"); read_stat "$candidate" && test "$stat_group" = "$expected_group" && emit_linux CODENOMAD_TARGET done if kill "-$requested_signal" -- "-$expected_group" 2>/dev/null; then signal_sent=1 @@ -67,7 +72,7 @@ else while test "$#" -ge 3; do expected_pid=$1; expected_start=$2; expected_boot=$3; shift 3 if read_stat "$expected_pid" && test "$boot" = "$expected_boot" && test "$stat_start" = "$expected_start"; then - emit_target + emit_linux CODENOMAD_TARGET if kill "-$requested_signal" "$expected_pid" 2>/dev/null; then signal_sent=1; fi fi done @@ -75,78 +80,104 @@ fi printf 'CODENOMAD_RESULT|%s|%s|%s\n' "$matched" "$cutoff" "$signal_sent" ` -const POSIX_GUARDED_SIGNAL_SCRIPT = String.raw` +const POSIX_IDENTITY_FUNCTIONS = String.raw` +LC_ALL=C; export LC_ALL; set -f encode() { printf '%s' "$1" | base64 | tr -d '\r\n'; } read_identity() { - current_meta=$(ps -p "$1" -o ppid= -o pgid= -o lstart= 2>/dev/null) || return 1 - current_command=$(ps -p "$1" -o command= 2>/dev/null) || return 1 - current_verify=$(ps -p "$1" -o ppid= -o pgid= -o lstart= 2>/dev/null) || return 1 + current_meta=$(ps -p "$1" -o ppid= -o pgid= -o lstart= -o comm= 2>/dev/null) || return 1 + current_verify=$(ps -p "$1" -o ppid= -o pgid= -o lstart= -o comm= 2>/dev/null) || return 1 test "$current_meta" = "$current_verify" || return 1 - set -- $current_meta - test "$#" -ge 7 || return 1 - current_ppid=$1; current_group=$2; shift 2 - current_start="$1 $2 $3 $4 $5" + set -- $current_meta; test "$#" -ge 7 || return 1 + current_ppid=$1; current_group=$2; shift 2; current_start="$1 $2 $3 $4 $5" + shift 5; current_command="$*"; test -n "$current_command" || return 1 current_identity=$(printf '%s\t%s' "$current_start" "$current_command") } emit_target() { printf 'CODENOMAD_TARGET_B64|%s|%s|%s|' "$current_pid" "$current_ppid" "$current_group" encode "$current_start"; printf '|'; encode "$current_command"; printf '\n' } -leader_pid=$1; leader_start=$2; expected_group=$3; requested_signal=$4; shift 4 +group_pids() { ps -axo pid=,pgid= 2>/dev/null | awk -v group="$1" '$2 == group { print $1 }'; } +has_cleanup_token() { + test -n "$cleanup_token" || return 1 + ps eww -p "$1" -o command= 2>/dev/null | tr ' ' '\n' | grep -Fqx -- "${LAUNCH_CLEANUP_TOKEN_ENV}=$cleanup_token" +} +` + +const LINUX_TOKEN_SCRIPT = String.raw`${LINUX_IDENTITY_FUNCTIONS} +key=$1; expected=$2; requested_signal=$3 +matches_token() { test -r "/proc/$1/environ" && tr '\0' '\n' < "/proc/$1/environ" 2>/dev/null | grep -Fqx -- "$key=$expected"; } +signal_sent=0; passes=1; test -n "$requested_signal" && passes=3 +pass=0 +while test "$pass" -lt "$passes"; do + pass=$((pass + 1)) + for environ in /proc/[0-9]*/environ; do + pid=$(basename "$(dirname "$environ")") + if matches_token "$pid" && read_stat "$pid"; then + test -n "$requested_signal" && prefix=CODENOMAD_TARGET || prefix=CODENOMAD_PROCESS + emit_linux "$prefix" + if test -n "$requested_signal" && matches_token "$pid" && read_stat "$pid" && kill "-$requested_signal" "$pid" 2>/dev/null; then signal_sent=1; fi + fi + done +done +if test -n "$requested_signal"; then printf 'CODENOMAD_RESULT|%s\n' "$signal_sent"; fi +exit 0 +` + +const POSIX_GUARDED_SIGNAL_SCRIPT = String.raw`${POSIX_IDENTITY_FUNCTIONS} +leader_pid=$1; leader_start=$2; expected_group=$3; requested_signal=$4; allow_leaderless=$5; cleanup_token=$6; shift 6 matched=0; signal_sent=0 if read_identity "$leader_pid" && test "$current_group" = "$expected_group" && test "$current_identity" = "$leader_start"; then matched=1 - for current_pid in $(ps -eo pid= 2>/dev/null); do - if read_identity "$current_pid" && test "$current_group" = "$expected_group"; then emit_target; fi + for current_pid in $(group_pids "$expected_group"); do + read_identity "$current_pid" && test "$current_group" = "$expected_group" && emit_target done if kill "-$requested_signal" -- "-$expected_group" 2>/dev/null; then signal_sent=1; fi +elif test "$allow_leaderless" = 1 && ! read_identity "$expected_group"; then + anchor=0 + while test "$#" -ge 2; do + expected_pid=$1; expected_start=$2; shift 2; current_pid=$expected_pid + if read_identity "$expected_pid" && test "$current_group" = "$expected_group" && test "$current_identity" = "$expected_start"; then anchor=1; fi + done + if test "$anchor" = 0; then + for current_pid in $(group_pids "$expected_group"); do + if has_cleanup_token "$current_pid" && read_identity "$current_pid" && test "$current_group" = "$expected_group"; then anchor=1; break; fi + done + fi + if test "$anchor" = 1; then + matched=1 + for current_pid in $(group_pids "$expected_group"); do + read_identity "$current_pid" && test "$current_group" = "$expected_group" && emit_target + done + if kill "-$requested_signal" -- "-$expected_group" 2>/dev/null; then signal_sent=1; fi + fi else while test "$#" -ge 2; do - expected_pid=$1; expected_start=$2; shift 2 - current_pid=$expected_pid - if read_identity "$expected_pid" && test "$current_identity" = "$expected_start"; then - emit_target - if kill "-$requested_signal" "$expected_pid" 2>/dev/null; then signal_sent=1; fi + expected_pid=$1; expected_start=$2; shift 2; current_pid=$expected_pid + if read_identity "$expected_pid" && test "$current_group" = "$expected_group" && test "$current_identity" = "$expected_start"; then + emit_target; if kill "-$requested_signal" "$expected_pid" 2>/dev/null; then signal_sent=1; fi fi done fi printf 'CODENOMAD_RESULT|%s||%s\n' "$matched" "$signal_sent" ` -const POSIX_OWNED_GROUP_SIGNAL_SCRIPT = String.raw` -encode() { printf '%s' "$1" | base64 | tr -d '\r\n'; } -read_identity() { - current_meta=$(ps -p "$1" -o ppid= -o pgid= -o lstart= 2>/dev/null) || return 1 - current_command=$(ps -p "$1" -o command= 2>/dev/null) || return 1 - current_verify=$(ps -p "$1" -o ppid= -o pgid= -o lstart= 2>/dev/null) || return 1 - test "$current_meta" = "$current_verify" || return 1 - set -- $current_meta - test "$#" -ge 7 || return 1 - current_ppid=$1; current_group=$2; shift 2 - current_start="$1 $2 $3 $4 $5" - current_identity=$(printf '%s\t%s' "$current_start" "$current_command") -} -emit_target() { - printf 'CODENOMAD_TARGET_B64|%s|%s|%s|' "$current_pid" "$current_ppid" "$current_group" - encode "$current_start"; printf '|'; encode "$current_command"; printf '\n' -} +const POSIX_OWNED_GROUP_SIGNAL_SCRIPT = String.raw`${POSIX_IDENTITY_FUNCTIONS} root_pid=$1; requested_signal=$2; matched=0; signal_sent=0 if read_identity "$root_pid" && test "$current_group" = "$root_pid"; then matched=1 - for current_pid in $(ps -eo pid= 2>/dev/null); do - if read_identity "$current_pid" && test "$current_group" = "$root_pid"; then emit_target; fi + for current_pid in $(group_pids "$root_pid"); do + read_identity "$current_pid" && test "$current_group" = "$root_pid" && emit_target done if kill "-$requested_signal" -- "-$root_pid" 2>/dev/null; then signal_sent=1; fi - for current_pid in $(ps -eo pid= 2>/dev/null); do - if read_identity "$current_pid" && test "$current_group" = "$root_pid"; then emit_target; fi + for current_pid in $(group_pids "$root_pid"); do + read_identity "$current_pid" && test "$current_group" = "$root_pid" && emit_target done fi printf 'CODENOMAD_RESULT|%s||%s\n' "$matched" "$signal_sent" ` -function commandError(result: SpawnSyncReturns): string { - return result.error?.message || String(result.stderr ?? result.stdout ?? "").trim() || `exit code ${result.status}` -} +const commandError = (result: SpawnSyncReturns): string => + result.error?.message || String(result.stderr ?? result.stdout ?? "").trim() || `exit code ${result.status}` function parseDelimitedSnapshot(output: string, requireBootId = false): Map | null { const processes = new Map() @@ -160,14 +191,8 @@ function parseDelimitedSnapshot(output: string, requireBootId = false): Map 0 ? groupId : pid, - startTime, - ...(bootId ? { bootId } : {}), - ...(startOrder ? { startOrder } : {}), - }) + processes.set(pid, { pid, parentPid, groupId: Number.isInteger(groupId) && groupId > 0 ? groupId : pid, startTime, + ...(bootId ? { bootId } : {}), ...(startOrder ? { startOrder } : {}) }) } return processes } @@ -205,47 +230,67 @@ function parseBase64Snapshot(output: string, prefix = "CODENOMAD_B64|"): Map | null { +function parsePortablePosixSnapshot(output: string, filter?: PosixProcessFilter): Map | null { const processes = new Map() + const requestedPids = filter?.pids ? new Set(filter.pids) : undefined for (const line of output.split(/\r?\n/)) { if (!line.trim()) continue const match = line.match(/^\s*(\d+)\s+(\d+)\s+(\d+)\s+(\S+\s+\S+\s+\d+\s+\d{2}:\d{2}:\d{2}\s+\d{4})\s+(.+)$/) - if (!match) return null + if (!match) { + const numeric = line.match(/^\s*(\d+)\s+(\d+)\s+(\d+)\s+/) + if (!filter || !numeric || requestedPids?.has(Number(numeric[1])) || Number(numeric[3]) === filter.groupId) return null + continue + } const [, pidText = "", parentPidText = "", groupIdText = "", start = "", command = ""] = match const pid = Number.parseInt(pidText, 10) const parentPid = Number.parseInt(parentPidText, 10) const groupId = Number.parseInt(groupIdText, 10) if (pid <= 0 || parentPid < 0 || groupId <= 0) return null + if (filter && !requestedPids?.has(pid) && groupId !== filter.groupId) continue processes.set(pid, { pid, parentPid, groupId, startTime: `${start}\t${command}` }) } return processes } -function snapshotOrFailure(processes: Map | null): ProcessSnapshot { - return processes && processes.size > 0 - ? { ok: true, processes } - : { ok: false, error: "process identity query returned no parseable processes" } +function querySnapshot( + run: () => SpawnSyncReturns, + parse: (output: string) => Map | null, + options: { allowEmpty?: boolean; malformedError?: string; redact?: (error: string) => string } = {}, +): ProcessSnapshot { + const sanitize = options.redact ?? ((error: string) => error) + try { + const result = run() + if (result.status !== 0) return { ok: false, error: sanitize(commandError(result)) } + const processes = parse(String(result.stdout ?? "")) + if (processes && (options.allowEmpty || processes.size > 0)) return { ok: true, processes } + return { ok: false, error: options.malformedError ?? "process identity query returned no parseable processes" } + } catch (error) { + return { ok: false, error: sanitize(error instanceof Error ? error.message : String(error)) } + } +} + +function parsePrefixedSnapshot(output: string, prefix: string): Map | null { + const records: string[] = [] + for (const line of output.split(/\r?\n/)) { + if (!line) continue + if (!line.startsWith(prefix)) return null + records.push(line.slice(prefix.length)) + } + return parseDelimitedSnapshot(records.join("\n"), true) } function parseGuardedResult(result: SpawnSyncReturns): GuardedSignalResult { const signaled = new Map() - const failure = (error: string): GuardedSignalResult => ({ - ok: false, - error, - ...(signaled.size > 0 ? { observed: Array.from(signaled.values()) } : {}), - }) + const failure = (error: string): GuardedSignalResult => ({ ok: false, error, + ...(signaled.size > 0 ? { observed: Array.from(signaled.values()) } : {}) }) let matched: boolean | undefined let signalSent = false let cutoff: string | undefined for (const line of String(result.stdout ?? "").split(/\r?\n/)) { - if (line.startsWith("CODENOMAD_TARGET|")) { - const parsed = parseDelimitedSnapshot(line.slice("CODENOMAD_TARGET|".length)) - if (!parsed) return failure("guarded signal command returned a malformed target record") - for (const identity of parsed.values()) signaled.set(identity.pid, identity) - continue - } - if (line.startsWith("CODENOMAD_TARGET_B64|")) { - const parsed = parseBase64Snapshot(line, "CODENOMAD_TARGET_B64|") + if (line.startsWith("CODENOMAD_TARGET|") || line.startsWith("CODENOMAD_TARGET_B64|")) { + const parsed = line.startsWith("CODENOMAD_TARGET_B64|") + ? parseBase64Snapshot(line, "CODENOMAD_TARGET_B64|") + : parseDelimitedSnapshot(line.slice("CODENOMAD_TARGET|".length)) if (!parsed) return failure("guarded signal command returned a malformed target record") for (const identity of parsed.values()) signaled.set(identity.pid, identity) continue @@ -269,28 +314,31 @@ function parseGuardedResult(result: SpawnSyncReturns): GuardedSignalResu : { ok: true, matched, signalSent, signaled: Array.from(signaled.values()), ...(cutoff ? { cutoff } : {}) } } -function runGuardedCommand( - spawnCommand: SpawnCommand, - command: string, - args: string[], - timeoutMs: number, -): GuardedSignalResult { +function runGuardedCommand(run: () => SpawnSyncReturns): GuardedSignalResult { try { - return parseGuardedResult(spawnCommand(command, args, { encoding: "utf8", timeout: timeoutMs })) + return parseGuardedResult(run()) } catch (error) { return { ok: false, error: error instanceof Error ? error.message : String(error) } } } -function signalName(signal: NodeJS.Signals): "TERM" | "KILL" { - return signal === "SIGKILL" ? "KILL" : "TERM" +const signalName = (signal: NodeJS.Signals): "TERM" | "KILL" => signal === "SIGKILL" ? "KILL" : "TERM" + +function runLinuxScript(spawnCommand: SpawnCommand, script: string, args: string[], timeoutMs: number, + label: string, distro?: string): SpawnSyncReturns { + return distro + ? spawnCommand("wsl.exe", ["--distribution", distro, "--exec", "sh", "-c", script, label, ...args], { encoding: "utf8", timeout: timeoutMs }) + : spawnCommand("sh", ["-c", script, label, ...args], { encoding: "utf8", timeout: timeoutMs }) } +const redactToken = (value: string, token: string): string => value.split(token).join("[REDACTED]") + function shellGuardArgs(request: GuardedSignalRequest, linux: boolean): string[] { const leader = request.leader const args = linux ? [String(leader?.pid ?? 0), leader?.startTime ?? "", leader?.bootId ?? "", String(request.groupId ?? 0), signalName(request.signal)] - : [String(leader?.pid ?? 0), leader?.startTime ?? "", String(request.groupId ?? 0), signalName(request.signal)] + : [String(leader?.pid ?? 0), leader?.startTime ?? "", String(request.groupId ?? 0), signalName(request.signal), + request.allowLeaderlessGroup ? "1" : "0", request.cleanupToken ?? ""] for (const member of request.members) { args.push(String(member.pid), member.startTime) if (linux) args.push(member.bootId ?? "") @@ -298,16 +346,14 @@ function shellGuardArgs(request: GuardedSignalRequest, linux: boolean): string[] return args } -function quotePowerShell(value: string): string { - return `'${value.replace(/'/g, "''")}'` -} +const quotePowerShell = (value: string): string => `'${value.replace(/'/g, "''")}'` function buildWindowsGuardedScript(request: GuardedSignalRequest): string { const leaderPid = request.leader?.pid ?? 0 const leaderStart = quotePowerShell(request.leader?.startTime ?? "") - const expected = request.members - .map((identity) => `@{ Pid = ${identity.pid}; Start = ${quotePowerShell(identity.startTime)} }`) - .join(", ") + const expected = request.members.map( + (identity) => `@{ Pid = ${identity.pid}; Start = ${quotePowerShell(identity.startTime)} }`, + ).join(", ") return [ "$ErrorActionPreference = 'Stop'", `$leaderPid = ${leaderPid}`, @@ -338,10 +384,8 @@ function buildWindowsGuardedScript(request: GuardedSignalRequest): string { } export function sameProcess(left: ProcessIdentity | undefined, right: ProcessIdentity | undefined): boolean { - return Boolean( - left && right && left.pid === right.pid && left.startTime === right.startTime && - (!left.bootId || !right.bootId || left.bootId === right.bootId), - ) + return Boolean(left && right && left.pid === right.pid && left.startTime === right.startTime && + (!left.bootId || !right.bootId || left.bootId === right.bootId)) } export function startedNoLaterThan(identity: ProcessIdentity, cutoff: string): boolean { @@ -370,31 +414,20 @@ export function descendantsOf(processes: Map, rootPid: return descendants } -export function probePosixProcesses( - spawnCommand: SpawnCommand, - timeoutMs: number, - platform: NodeJS.Platform = process.platform, -): ProcessSnapshot { - try { - if (platform === "linux") { - const result = spawnCommand("sh", ["-c", LINUX_SNAPSHOT_SCRIPT, "codenomad-posix-identity"], { - encoding: "utf8", - timeout: timeoutMs, - }) - if (result.status !== 0) return { ok: false, error: commandError(result) } - return snapshotOrFailure(parseDelimitedSnapshot(String(result.stdout ?? ""), true)) - } - - // POSIX has no portable pidfd/start ticks; collect one coherent table instead of probing every PID. - const result = spawnCommand("ps", ["-axo", "pid=,ppid=,pgid=,lstart=,command="], { - encoding: "utf8", - timeout: timeoutMs, - }) - if (result.status !== 0) return { ok: false, error: commandError(result) } - return snapshotOrFailure(parsePortablePosixSnapshot(String(result.stdout ?? ""))) - } catch (error) { - return { ok: false, error: error instanceof Error ? error.message : String(error) } - } +export function probePosixProcesses(spawnCommand: SpawnCommand, timeoutMs: number, + platform: NodeJS.Platform = process.platform, filter?: PosixProcessFilter): ProcessSnapshot { + if (platform === "linux") return querySnapshot( + () => runLinuxScript(spawnCommand, LINUX_SNAPSHOT_SCRIPT, [], timeoutMs, "codenomad-posix-identity"), + (output) => parseDelimitedSnapshot(output, true), + ) + // POSIX has no portable pidfd/start ticks; collect one coherent table instead of probing every PID. + return querySnapshot( + () => spawnCommand("ps", ["-axo", "pid=,ppid=,pgid=,lstart=,comm="], { + encoding: "utf8", timeout: timeoutMs, env: { ...process.env, LC_ALL: "C", LANG: "C" }, + }), + (output) => parsePortablePosixSnapshot(output, filter), + { allowEmpty: Boolean(filter) }, + ) } export function probeWindowsProcesses(spawnCommand: SpawnCommand, timeoutMs: number): ProcessSnapshot { @@ -402,84 +435,105 @@ export function probeWindowsProcesses(spawnCommand: SpawnCommand, timeoutMs: num "$all = @(Get-CimInstance Win32_Process -ErrorAction Stop)", "$all | Where-Object { [int]$_.ProcessId -gt 0 } | ForEach-Object { $start = ([datetime]$_.CreationDate).ToUniversalTime().Ticks.ToString(); '{0}|{1}|0|{2}||{2}' -f [int]$_.ProcessId, [int]$_.ParentProcessId, $start }", ].join("; ") - try { - const result = spawnCommand("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { - encoding: "utf8", - timeout: timeoutMs, - }) - if (result.status !== 0) return { ok: false, error: commandError(result) } - return snapshotOrFailure(parseDelimitedSnapshot(String(result.stdout ?? ""))) - } catch (error) { - return { ok: false, error: error instanceof Error ? error.message : String(error) } - } + return querySnapshot( + () => spawnCommand("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { encoding: "utf8", timeout: timeoutMs }), + parseDelimitedSnapshot, + ) } export function probeWslProcesses(spawnCommand: SpawnCommand, distro: string, timeoutMs: number): ProcessSnapshot { - try { - const result = spawnCommand( - "wsl.exe", - ["--distribution", distro, "--exec", "sh", "-c", LINUX_SNAPSHOT_SCRIPT, "codenomad-wsl-identity"], - { encoding: "utf8", timeout: timeoutMs }, - ) - if (result.status !== 0) return { ok: false, error: commandError(result) } - return snapshotOrFailure(parseDelimitedSnapshot(String(result.stdout ?? ""), true)) - } catch (error) { - return { ok: false, error: error instanceof Error ? error.message : String(error) } - } + return querySnapshot( + () => runLinuxScript(spawnCommand, LINUX_SNAPSHOT_SCRIPT, [], timeoutMs, "codenomad-wsl-identity", distro), + (output) => parseDelimitedSnapshot(output, true), + ) } -export function signalPosixProcesses( - spawnCommand: SpawnCommand, - request: GuardedSignalRequest, - timeoutMs: number, - platform: NodeJS.Platform, -): GuardedSignalResult { +export function signalPosixProcesses(spawnCommand: SpawnCommand, request: GuardedSignalRequest, + timeoutMs: number, platform: NodeJS.Platform): GuardedSignalResult { const linux = platform === "linux" - return runGuardedCommand( - spawnCommand, + return runGuardedCommand(() => spawnCommand( "sh", ["-c", linux ? LINUX_GUARDED_SIGNAL_SCRIPT : POSIX_GUARDED_SIGNAL_SCRIPT, "codenomad-guarded-signal", ...shellGuardArgs(request, linux)], - timeoutMs, - ) + { encoding: "utf8", timeout: timeoutMs }, + )) } -export function signalOwnedPosixProcessGroup( - spawnCommand: SpawnCommand, - rootPid: number, - signal: NodeJS.Signals, - timeoutMs: number, -): GuardedSignalResult { - return runGuardedCommand( - spawnCommand, +export function signalOwnedPosixProcessGroup(spawnCommand: SpawnCommand, rootPid: number, + signal: NodeJS.Signals, timeoutMs: number): GuardedSignalResult { + return runGuardedCommand(() => spawnCommand( "sh", ["-c", POSIX_OWNED_GROUP_SIGNAL_SCRIPT, "codenomad-owned-group-cleanup", String(rootPid), signalName(signal)], - timeoutMs, - ) + { encoding: "utf8", timeout: timeoutMs }, + )) } -export function signalWslProcesses( - spawnCommand: SpawnCommand, - distro: string, - request: GuardedSignalRequest, - timeoutMs: number, -): GuardedSignalResult { - return runGuardedCommand( - spawnCommand, - "wsl.exe", - ["--distribution", distro, "--exec", "sh", "-c", LINUX_GUARDED_SIGNAL_SCRIPT, "codenomad-wsl-guarded-signal", ...shellGuardArgs(request, true)], - timeoutMs, - ) +export function signalWslProcesses(spawnCommand: SpawnCommand, distro: string, + request: GuardedSignalRequest, timeoutMs: number): GuardedSignalResult { + return runGuardedCommand(() => runLinuxScript( + spawnCommand, + LINUX_GUARDED_SIGNAL_SCRIPT, + shellGuardArgs(request, true), + timeoutMs, + "codenomad-wsl-guarded-signal", + distro, + )) } -export function signalWindowsProcesses( - spawnCommand: SpawnCommand, - request: GuardedSignalRequest, - timeoutMs: number, -): GuardedSignalResult { - return runGuardedCommand( - spawnCommand, +export function signalWindowsProcesses(spawnCommand: SpawnCommand, request: GuardedSignalRequest, + timeoutMs: number): GuardedSignalResult { + return runGuardedCommand(() => spawnCommand( "powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", buildWindowsGuardedScript(request)], - timeoutMs, + { encoding: "utf8", timeout: timeoutMs }, + )) +} + +export function probeLaunchCleanupToken(spawnCommand: SpawnCommand, token: string, + timeoutMs: number, distro?: string): ProcessSnapshot { + return querySnapshot( + () => runLinuxScript( + spawnCommand, + LINUX_TOKEN_SCRIPT, + [LAUNCH_CLEANUP_TOKEN_ENV, token, ""], + timeoutMs, + "codenomad-token-cleanup", + distro, + ), + (output) => parsePrefixedSnapshot(output, "CODENOMAD_PROCESS|"), + { + allowEmpty: true, + malformedError: "launch cleanup probe returned malformed or unexpected output", + redact: (error) => redactToken(error, token), + }, ) } + +export function signalLaunchCleanupToken(spawnCommand: SpawnCommand, token: string, + signal: NodeJS.Signals, timeoutMs: number, distro?: string): TokenSignalResult { + const failed = (error: string): TokenSignalResult => ({ ok: false, signalSent: false, targets: [], error }) + try { + const result = runLinuxScript( + spawnCommand, + LINUX_TOKEN_SCRIPT, + [LAUNCH_CLEANUP_TOKEN_ENV, token, signalName(signal)], + timeoutMs, + "codenomad-token-cleanup", + distro, + ) + if (result.status !== 0) return failed(redactToken(commandError(result), token)) + const lines = String(result.stdout ?? "").split(/\r?\n/).filter(Boolean) + const resultLines = lines.filter((line) => line.startsWith("CODENOMAD_RESULT|")) + if (resultLines.length !== 1 || !/^CODENOMAD_RESULT\|[01]$/.test(resultLines[0] ?? "")) { + return failed("launch cleanup signal returned no valid structured result") + } + const targets = parsePrefixedSnapshot( + lines.filter((line) => !line.startsWith("CODENOMAD_RESULT|")).join("\n"), + "CODENOMAD_TARGET|", + ) + return targets + ? { ok: true, signalSent: resultLines[0]!.endsWith("1"), targets: Array.from(targets.values()) } + : failed("launch cleanup signal returned malformed or unexpected output") + } catch (error) { + return failed(redactToken(error instanceof Error ? error.message : String(error), token)) + } +} diff --git a/packages/server/src/workspaces/runtime.test.ts b/packages/server/src/workspaces/runtime.test.ts index cbc5a7c4..7258bd8d 100644 --- a/packages/server/src/workspaces/runtime.test.ts +++ b/packages/server/src/workspaces/runtime.test.ts @@ -6,674 +6,251 @@ import { describe, it } from "node:test" import pino from "pino" import { EventBus } from "../events/bus" -import { - WorkspaceRuntime, - WorkspaceRuntimeIdentityCaptureError, - WorkspaceRuntimeLaunchCancelledError, - WorkspaceStopTimeoutError, - WorkspaceWindowsTreeCleanupIncompleteError, - type WorkspaceRuntimeOptions, -} from "./runtime" - -type TimerHandle = ReturnType -type SpawnCommand = typeof import("node:child_process").spawnSync - +import { WorkspaceRuntime, WorkspaceRuntimeIdentityCaptureError, WorkspaceStopTimeoutError, + WorkspaceWindowsTreeCleanupIncompleteError, type WorkspaceRuntimeOptions } from "./runtime" +type Timer = ReturnType +type Command = typeof import("node:child_process").spawnSync +type Call = { command: string; args: readonly string[] } class ManualTimers { - private nextId = 1 - private readonly pending = new Map void; delayMs: number }>() - - readonly setTimeout = (callback: () => void, delayMs: number): TimerHandle => { - const id = this.nextId++ - this.pending.set(id, { callback, delayMs }) - return id as unknown as TimerHandle - } - - readonly clearTimeout = (timer: TimerHandle): void => { - this.pending.delete(timer as unknown as number) - } - - runNext(): void { - const next = Array.from(this.pending.entries()).sort((left, right) => left[1].delayMs - right[1].delayMs || left[0] - right[0])[0] + private id = 0 + private pending = new Map void; delay: number }>() + set = (callback: () => void, delay: number) => { const id = ++this.id; this.pending.set(id, { callback, delay }); return id as unknown as Timer } + clear = (timer: Timer) => this.pending.delete(timer as unknown as number) + run(): void { + const next = [...this.pending].sort((a, b) => a[1].delay - b[1].delay || a[0] - b[0])[0] assert.ok(next, "expected a pending timer") this.pending.delete(next[0]) next[1].callback() } - - get size(): number { - return this.pending.size - } } - class FakeChild extends EventEmitter { - readonly stdout = new PassThrough() - readonly stderr = new PassThrough() - readonly liveSignals: NodeJS.Signals[] = [] + stdout = new PassThrough() + stderr = new PassThrough() exitCode: number | null = null signalCode: NodeJS.Signals | null = null - - constructor(readonly pid: number | undefined = 4242) { - super() - } - - kill(signal: NodeJS.Signals = "SIGTERM"): boolean { - this.liveSignals.push(signal) - return true - } - - exit(code: number | null, signal: NodeJS.Signals | null): void { - this.exitCode = code - this.signalCode = signal - this.emit("exit", code, signal) - } + signals: NodeJS.Signals[] = [] + constructor(readonly pid: number | undefined = 4242) { super() } + kill(signal: NodeJS.Signals = "SIGTERM") { this.signals.push(signal); return true } + exit(code: number | null = 0, signal: NodeJS.Signals | null = null) { this.exitCode = code; this.signalCode = signal; this.emit("exit", code, signal) } } - -function result(stdout = "", status = 0, stderr = ""): SpawnSyncReturns { - return { pid: 1, output: [null, stdout, stderr], stdout, stderr, status, signal: null } -} - -function linuxRows(rows: Array<[number, number, number, string]>, bootId = "boot-a"): string { - return rows.map(([pid, parentPid, groupId, start]) => `${pid}|${parentPid}|${groupId}|${start}|${bootId}|${start}`).join("\n") -} - -function windowsRows(rows: Array<[number, number, string]>): string { - return rows.map(([pid, parentPid, start], index) => `${pid}|${parentPid}|0|${start}||${100 + index}`).join("\n") -} - -function guardedRows(matched: boolean, rows: Array<[number, number, number, string]>, cutoff?: string, bootId = "boot-a"): string { - const targets = rows.map(([pid, parentPid, groupId, start]) => - `CODENOMAD_TARGET|${pid}|${parentPid}|${groupId}|${start}|${bootId}|${start}`, - ) - return [...targets, `CODENOMAD_RESULT|${matched ? "1" : "0"}|${cutoff ?? ""}|${targets.length > 0 ? "1" : "0"}`].join("\n") -} - -function tokenSignalRows(rows: Array<[number, number, number, string]>, bootId = "boot-a"): string { - return [ - ...rows.map(([pid, parentPid, groupId, start]) => - `CODENOMAD_TARGET|${pid}|${parentPid}|${groupId}|${start}|${bootId}|${start}`, - ), - `CODENOMAD_RESULT|${rows.length > 0 ? "1" : "0"}`, - ].join("\n") -} - -function isTokenCleanup(args: readonly string[]): boolean { - return args.includes("codenomad-token-cleanup") -} - -function isTokenSignal(args: readonly string[]): boolean { - return isTokenCleanup(args) && args.some((arg) => arg.includes("for pass in 1 2 3")) -} - -function isGuarded(args: readonly string[]): boolean { - return !isTokenCleanup(args) && args.some((arg) => arg.includes("guarded-signal") || arg.includes("CODENOMAD_RESULT")) -} - -async function createRuntime( - options: Omit = {}, - reportPort = true, - binaryPath = "opencode", -) { +const result = (stdout = "", status = 0, stderr = ""): SpawnSyncReturns => + ({ pid: 1, output: [null, stdout, stderr], stdout, stderr, status, signal: null }) +const posix = (rows: Array<[number, number, number, string]>, boot = "boot-a") => + rows.map(([pid, ppid, pgid, start]) => `${pid}|${ppid}|${pgid}|${start}|${boot}|${start}`).join("\n") +const portable = (rows: Array<[number, number, number, string, string]>) => + rows.map(([pid, ppid, pgid, start, command]) => `${pid} ${ppid} ${pgid} ${start} ${command}`).join("\n") +const windows = (rows: Array<[number, number, string]>) => + rows.map(([pid, ppid, start], i) => `${pid}|${ppid}|0|${start}||${100 + i}`).join("\n") +const guarded = (matched: boolean, rows: Array<[number, number, number, string]>, boot = "boot-a") => [ + ...rows.map(([pid, ppid, pgid, start]) => `CODENOMAD_TARGET|${pid}|${ppid}|${pgid}|${start}|${boot}|${start}`), + `CODENOMAD_RESULT|${matched ? "1" : "0"}|200|${rows.length ? "1" : "0"}`, +].join("\n") +const token = (rows: Array<[number, number, number, string]>, signal: boolean, boot = "boot-a") => [ + ...rows.map(([pid, ppid, pgid, start]) => `${signal ? "CODENOMAD_TARGET" : "CODENOMAD_PROCESS"}|${pid}|${ppid}|${pgid}|${start}|${boot}|${start}`), + ...(signal ? [`CODENOMAD_RESULT|${rows.length ? "1" : "0"}`] : []), +].join("\n") +const isToken = (args: readonly string[]) => args.includes("codenomad-token-cleanup") +const isSignal = (args: readonly string[]) => isToken(args) && (args.includes("TERM") || args.includes("KILL")) +const isGuarded = (args: readonly string[]) => !isToken(args) && args.some((arg) => arg.includes("guarded-signal") || arg.includes("CODENOMAD_RESULT")) +async function harness(options: WorkspaceRuntimeOptions & { binary?: string; output?: string; report?: boolean } = {}) { const child = new FakeChild() const timers = new ManualTimers() + const calls: Call[] = [] const platform = options.platform ?? "linux" - const defaultCommand = ((command: string, args: readonly string[]) => { - if (isTokenCleanup(args)) { - return isTokenSignal(args) - ? result(tokenSignalRows([[4242, 1, 4242, "100"]])) - : result(linuxRows([[4242, 1, 4242, "100"]])) - } - if (isGuarded(args)) { - return platform === "win32" - ? result("CODENOMAD_TARGET|4242|1|0|win-start||100\nCODENOMAD_RESULT|1||1") - : result(guardedRows(true, [[4242, 1, 4242, "100"]], "200")) - } - return platform === "win32" - ? result(windowsRows([[4242, 1, "win-start"]])) - : result(linuxRows([[4242, 1, 4242, "100"]])) - }) as unknown as SpawnCommand + const command = options.spawnSync ?? ((command: string, args: readonly string[]) => { + calls.push({ command, args: [...args] }) + const alive = child.exitCode === null && child.signalCode === null + if (isToken(args)) return result(token(alive ? [[4242, 1, 4242, "100"]] : [], isSignal(args))) + if (isGuarded(args)) return result(platform === "win32" + ? "CODENOMAD_TARGET|4242|1|0|win-start||100\nCODENOMAD_RESULT|1||1" + : guarded(true, [[4242, 1, 4242, "100"]])) + return result(platform === "win32" + ? windows(alive ? [[4242, 1, "win-start"]] : []) + : posix(alive ? [[4242, 1, 4242, "100"]] : [[1, 0, 1, "10"]])) + }) as Command const runtime = new WorkspaceRuntime(new EventBus(), pino({ level: "silent" }), { - gracefulStopTimeoutMs: 10, - forcedStopTimeoutMs: 10, - spawnSync: defaultCommand, - ...options, - setTimeout: timers.setTimeout, - clearTimeout: timers.clearTimeout, - spawn: (() => { - if (reportPort) queueMicrotask(() => child.stdout.write("opencode server listening on http://127.0.0.1:4321\n")) - return child as unknown as ChildProcess - }) as typeof import("node:child_process").spawn, + platform, gracefulStopTimeoutMs: 10, forcedStopTimeoutMs: 10, ...options, + spawnSync: command, setTimeout: timers.set, clearTimeout: timers.clear, + spawn: (() => child as unknown as ChildProcess) as typeof import("node:child_process").spawn, }) - const launch = runtime.launch({ workspaceId: "workspace-1", folder: process.cwd(), binaryPath }) - if (reportPort) await launch - return { runtime, child, timers, launch } -} - -function setWslIdentity(runtime: WorkspaceRuntime): void { - const managed = (runtime as unknown as { - processes: Map }> - }).processes.get("workspace-1") - assert.ok(managed) - managed.processKind = "wsl" - managed.wsl = { - distro: "Ubuntu", - linuxPid: 99, - linuxPgid: 99, - leaderStartTime: "50", - bootId: "wsl-boot", - members: new Map([[99, { pid: 99, parentPid: 1, groupId: 99, startTime: "50", bootId: "wsl-boot", startOrder: "50" }]]), + const abort = new AbortController() + const folder = platform === "win32" && process.platform !== "win32" ? `/${process.cwd()}` : process.cwd() + const launch = runtime.launch({ workspaceId: "w", folder, binaryPath: options.binary ?? "opencode", signal: abort.signal }) + if (options.report !== false) { + queueMicrotask(() => child.stdout.write(options.output ?? "opencode server listening on http://127.0.0.1:4321\n")) + await launch } + return { runtime, child, timers, calls, launch, abort } } - -describe("workspace runtime verified stop", () => { - it("keeps an error listener on children rejected before launch handlers are installed", async () => { - const child = new FakeChild(undefined) +describe("workspace runtime lifecycle contracts", () => { + it("cancels before spawn and while waiting for a port without losing retryable cleanup", async () => { + let spawned = false const runtime = new WorkspaceRuntime(new EventBus(), pino({ level: "silent" }), { - platform: "linux", - spawn: (() => child as unknown as ChildProcess) as typeof import("node:child_process").spawn, - spawnSync: (() => result()) as unknown as SpawnCommand, + spawn: (() => { spawned = true; return new FakeChild() as unknown as ChildProcess }) as typeof import("node:child_process").spawn, }) + const pre = new AbortController(); pre.abort(new Error("pre-cancelled")) + await assert.rejects(runtime.launch({ workspaceId: "pre", folder: process.cwd(), binaryPath: "opencode", signal: pre.signal }), /pre-cancelled/) + assert.equal(spawned, false) - await assert.rejects( - runtime.launch({ workspaceId: "missing-pid", folder: process.cwd(), binaryPath: "missing-opencode" }), - WorkspaceRuntimeIdentityCaptureError, - ) - assert.equal(child.listenerCount("error"), 1) - assert.doesNotThrow(() => child.emit("error", new Error("ENOENT"))) - }) - - it("rejects a direct Windows launch without immutable process identity", async () => { - let commandCalls = 0 - const harness = await createRuntime({ - platform: "win32", - spawnSync: (() => { - commandCalls += 1 - return result("", 1, "CIM unavailable") - }) as unknown as SpawnCommand, - }, false, "opencode.exe") - - await assert.rejects(harness.launch, WorkspaceRuntimeIdentityCaptureError) - assert.ok(commandCalls >= 1) - assert.deepEqual(harness.child.liveSignals, ["SIGTERM"]) - harness.child.exit(null, "SIGTERM") - }) - - it("stops an identity-matched direct Windows process tree", async () => { let alive = true - const commands: Array<{ command: string; args: readonly string[] }> = [] - const harness = await createRuntime({ - platform: "win32", - spawnSync: ((command: string, args: readonly string[]) => { - commands.push({ command, args: [...args] }) - if (isGuarded(args)) { - alive = false - return result([ - "CODENOMAD_TARGET|4243|4242|0|descendant-start||101", - "CODENOMAD_TARGET|4242|1|0|win-start||100", - "CODENOMAD_RESULT|1||1", - ].join("\n")) - } - return result(alive - ? windowsRows([[4242, 1, "win-start"], [4243, 4242, "descendant-start"]]) - : windowsRows([[7, 1, "other-start"]])) - }) as unknown as SpawnCommand, - }, true, "opencode.exe") + const h = await harness({ report: false, spawnSync: ((_command: string, args: readonly string[]) => { + if (isToken(args)) return result(token(alive ? [[4242, 1, 4242, "100"]] : [], isSignal(args))) + if (isGuarded(args)) return result(guarded(true, [[4242, 1, 4242, "100"]])) + return result(posix(alive ? [[4242, 1, 4242, "100"]] : [[1, 0, 1, "10"]])) + }) as unknown as Command }) + h.abort.abort(new Error("port-cancelled")) + await assert.rejects(h.launch, /port-cancelled/) + const first = h.runtime.stop("w"); h.timers.run(); h.timers.run() + await assert.rejects(first, WorkspaceStopTimeoutError) + alive = false + const retry = h.runtime.stop("w"); h.child.exit(); await retry - await harness.runtime.stop("workspace-1") - assert.equal(commands.some(({ command }) => command === "taskkill.exe"), false) - assert.equal(commands.filter(({ args }) => isGuarded(args)).length, 1) - assert.deepEqual(harness.child.liveSignals, []) + const direct = await harness({ report: false }) + const stopped = direct.runtime.stop("w") + await assert.rejects(direct.launch, /runtime launch was cancelled/) + direct.child.exit() + await stopped }) - - it("keeps direct Windows stop bounded when the child ignores termination", async () => { - const commands: Array<{ command: string; args: readonly string[] }> = [] - const harness = await createRuntime({ - platform: "win32", - spawnSync: ((command: string, args: readonly string[]) => { - commands.push({ command, args: [...args] }) - return isGuarded(args) - ? result("CODENOMAD_TARGET|4242|1|0|win-start||100\nCODENOMAD_RESULT|1||1") - : result(windowsRows([[4242, 1, "win-start"]])) - }) as unknown as SpawnCommand, - }, true, "opencode.exe") - - const stop = harness.runtime.stop("workspace-1") - harness.timers.runNext() - harness.timers.runNext() - await assert.rejects(stop, WorkspaceStopTimeoutError) - assert.equal(commands.filter(({ args }) => isGuarded(args)).length, 2) - assert.equal(commands.some(({ command }) => command === "taskkill.exe"), false) - assert.deepEqual(harness.child.liveSignals, []) + it("rejects launches whose immutable identity cannot be captured and safely cleans up", async () => { + for (const scenario of [{ platform: "linux" as const, binary: "opencode" }, { platform: "win32" as const, binary: "opencode.exe" }]) { + const child = new FakeChild(scenario.platform === "linux" ? undefined : 4242) + const runtime = new WorkspaceRuntime(new EventBus(), pino({ level: "silent" }), { + platform: scenario.platform, + spawn: (() => child as unknown as ChildProcess) as typeof import("node:child_process").spawn, + spawnSync: (() => result("", 1, "identity unavailable")) as unknown as Command, + }) + await assert.rejects(runtime.launch({ workspaceId: scenario.platform, folder: process.cwd(), binaryPath: scenario.binary }), WorkspaceRuntimeIdentityCaptureError) + assert.deepEqual(child.signals, ["SIGTERM"]) + assert.doesNotThrow(() => child.emit("error", new Error("late spawn error"))) + } }) - - it("keeps tracking a descendant after partial guarded Windows termination", async () => { - let stage: "tree" | "descendant" | "gone" = "tree" - let guardedCalls = 0 - const harness = await createRuntime({ - platform: "win32", - spawnSync: ((_command: string, args: readonly string[]) => { - if (isGuarded(args)) { - guardedCalls += 1 - if (guardedCalls === 1) { - stage = "descendant" - return result([ - "CODENOMAD_TARGET|4242|1|0|win-start||100", - "CODENOMAD_TARGET|4243|4242|0|descendant-start||101", - ].join("\n"), 1, "descendant termination failed") - } - stage = "gone" - return result("CODENOMAD_TARGET|4243|1|0|descendant-start||101\nCODENOMAD_RESULT|0||1") - } - if (stage === "tree") { - return result(windowsRows([[4242, 1, "win-start"], [4243, 4242, "descendant-start"]])) - } - if (stage === "descendant") { - return result(windowsRows([[4243, 1, "descendant-start"]])) - } - return result(windowsRows([[7, 1, "other-start"]])) - }) as unknown as SpawnCommand, - }, true, "opencode.exe") - - let settled = false - const stop = harness.runtime.stop("workspace-1").finally(() => { settled = true }) - await Promise.resolve() - assert.equal(settled, false) - harness.timers.runNext() - harness.timers.runNext() - await stop - assert.equal(guardedCalls, 2) + it("signals identity-matched POSIX, Windows, and WSL processes", async () => { + const scenarios = [ + { name: "POSIX", platform: "linux" as const, binary: "opencode", marker: "codenomad-guarded-signal" }, + { name: "Windows", platform: "win32" as const, binary: "opencode.exe", marker: "CODENOMAD_RESULT" }, + { name: "WSL", platform: "win32" as const, binary: "\\\\wsl$\\Ubuntu\\usr\\bin\\opencode", marker: "codenomad-wsl-guarded-signal", + output: "__CODENOMAD_WSL_PID__:99:99:50:wsl-boot\nopencode server listening on http://127.0.0.1:4321\n" }, + ] + for (const scenario of scenarios) { + let alive = true + const calls: Call[] = [] + const h = await harness({ platform: scenario.platform, binary: scenario.binary, output: scenario.output, spawnSync: ((command: string, args: readonly string[]) => { + calls.push({ command, args: [...args] }) + const wsl = scenario.name === "WSL" + if (wsl && command === "powershell.exe") return result(windows([[4242, 1, "host-start"]])) + if (isToken(args)) { const rows: Array<[number, number, number, string]> = alive ? [[wsl ? 99 : 4242, 1, wsl ? 99 : 4242, wsl ? "50" : "100"]] : []; if (isSignal(args)) alive = false; return result(token(rows, isSignal(args), wsl ? "wsl-boot" : "boot-a")) } + if (isGuarded(args)) { alive = false; return result(wsl ? guarded(true, [[99, 1, 99, "50"]], "wsl-boot") : scenario.platform === "win32" ? "CODENOMAD_TARGET|4242|1|0|win-start||100\nCODENOMAD_RESULT|1||1" : guarded(true, [[4242, 1, 4242, "100"]])) } + return result(wsl + ? posix(alive ? [[99, 1, 99, "50"]] : [[1, 0, 1, "10"]], "wsl-boot") + : scenario.platform === "win32" + ? windows(alive ? [[4242, 1, "win-start"]] : [[1, 0, "system-start"]]) + : posix(alive ? [[4242, 1, 4242, "100"]] : [[1, 0, 1, "10"]])) + }) as unknown as Command }) + const stop = h.runtime.stop("w") + h.child.exit() + await stop + assert.ok(calls.some(({ args }) => args.some((arg) => arg.includes(scenario.marker))), `${scenario.name} signal`) + assert.equal(calls.some(({ command }) => command === "taskkill.exe"), false) + } }) - - it("cleans a token-matching descendant that survives its unidentified wrapper", async () => { - const child = new FakeChild() - const timers = new ManualTimers() - let tokenAlive = true - let spawnedToken = "" - let logs = "" - const tokenSignals: number[][] = [] - const runtime = new WorkspaceRuntime(new EventBus(), pino({ level: "trace" }, { - write: (chunk: string) => { logs += chunk }, - }), { - platform: "linux", - spawnSync: ((_command: string, args: readonly string[]) => { - if (isTokenCleanup(args)) { - if (isTokenSignal(args)) { - const rows: Array<[number, number, number, string]> = tokenAlive - ? [[5000, 1, 4242, "150"]] - : [] - tokenSignals.push(rows.map(([pid]) => pid)) - tokenAlive = false - return result(tokenSignalRows(rows)) - } - return result(tokenAlive ? linuxRows([[5000, 1, 4242, "150"]]) : "") - } - return result("", 1, "proc unavailable") - }) as unknown as SpawnCommand, - spawn: ((_command, _args, options) => { - spawnedToken = String(options?.env?.CODENOMAD_LAUNCH_CLEANUP_TOKEN ?? "") - return child as unknown as ChildProcess - }) as typeof import("node:child_process").spawn, - setTimeout: timers.setTimeout, - clearTimeout: timers.clearTimeout, - }) - - await assert.rejects( - runtime.launch({ workspaceId: "workspace-1", folder: process.cwd(), binaryPath: "opencode" }), - WorkspaceRuntimeIdentityCaptureError, - ) - assert.equal((runtime as unknown as { processes: Map }).processes.size, 0) - assert.deepEqual(child.liveSignals, ["SIGTERM"]) - assert.match(spawnedToken, /^[a-f0-9]{64}$/) - assert.equal(logs.includes(spawnedToken), false) - assert.match(logs, /\[REDACTED\]/) - assert.deepEqual(tokenSignals, [[5000]]) + it("does not signal a reused PID or process group", async () => { + let launched = false + const calls: string[][] = [] + const h = await harness({ spawnSync: ((_command: string, args: readonly string[]) => { + calls.push([...args]) + if (isToken(args)) return result(token([], isSignal(args))) + if (isGuarded(args)) return result(guarded(true, [[4242, 1, 4242, "100"]])) + if (!launched) { launched = true; return result(posix([[4242, 1, 4242, "100"]])) } + return result(posix([[4242, 1, 4242, "300"], [6000, 4242, 4242, "150"]])) + }) as unknown as Command }) + await h.runtime.stop("w") + assert.ok(calls.every((args) => !args.includes("6000") && !args.includes("300"))) }) - - it("uses one guarded command per signal", async () => { - const guardedInvocations: readonly string[][] = [] - const mutableInvocations = guardedInvocations as string[][] - const spawnCommand = ((_command: string, args: readonly string[]) => { - if (isTokenCleanup(args)) { - return isTokenSignal(args) - ? result(tokenSignalRows([[4242, 1, 4242, "100"]])) - : result(linuxRows([[4242, 1, 4242, "100"]])) - } + it("retains and cleans a portable process group after its leader exits", async () => { + const start = "Fri Jul 10 12:34:56 2026" + let alive = true + let leaderExited = false + const guardedCalls: readonly string[][] = [] + const h = await harness({ platform: "darwin", spawnSync: ((_command: string, args: readonly string[]) => { if (isGuarded(args)) { - mutableInvocations.push([...args]) - return result(guardedRows(true, [[4242, 1, 4242, "100"]], "200")) + (guardedCalls as string[][]).push([...args]) + alive = false + return result(`CODENOMAD_TARGET_B64|5000|1|4242|${Buffer.from(start).toString("base64")}|${Buffer.from("opencode-child").toString("base64")}\nCODENOMAD_RESULT|1||1`) } - return result(linuxRows([[4242, 1, 4242, "100"]])) - }) as unknown as SpawnCommand - const harness = await createRuntime({ - platform: "linux", - spawnSync: spawnCommand, - }) + const rows: Array<[number, number, number, string, string]> = !alive + ? [] + : leaderExited + ? [[5000, 4242, 4242, start, "opencode-child"]] + : [[4242, 1, 4242, start, "opencode"]] + return result(portable(rows)) + }) as unknown as Command }) - const stop = harness.runtime.stop("workspace-1") - harness.timers.runNext() - harness.timers.runNext() - await assert.rejects(stop, WorkspaceStopTimeoutError) - assert.equal(guardedInvocations.length, 2) - assert.ok(guardedInvocations.every((args) => args.includes("codenomad-guarded-signal"))) + leaderExited = true + h.child.exit(1) + await new Promise((resolve) => setImmediate(resolve)) + assert.equal(guardedCalls.length, 1) + assert.equal(guardedCalls[0]?.[7], "1") + assert.equal((h.runtime as unknown as { processes: Map }).processes.size, 0) }) - - it("sends no second command when the guarded operation reports identity mismatch", async () => { - let launchProbe = true - let guardedCommands = 0 - const spawnCommand = ((_command: string, args: readonly string[]) => { - if (isTokenCleanup(args)) return result(isTokenSignal(args) ? tokenSignalRows([]) : "") + it("refuses a leaderless portable group when no retained identity anchor remains", async () => { + const start = "Fri Jul 10 12:34:56 2026" + let leaderExited = false + const guardedCalls: readonly string[][] = [] + const h = await harness({ platform: "darwin", spawnSync: ((_command: string, args: readonly string[]) => { if (isGuarded(args)) { - guardedCommands += 1 + (guardedCalls as string[][]).push([...args]) return result("CODENOMAD_RESULT|0||0") } - if (launchProbe) { - launchProbe = false - return result(linuxRows([[4242, 1, 4242, "100"]])) - } - return result(linuxRows([[4242, 1, 4242, "300"]])) - }) as unknown as SpawnCommand - const harness = await createRuntime({ platform: "linux", spawnSync: spawnCommand }) + return result(portable(leaderExited + ? [[6000, 1, 4242, "Fri Jul 10 99:99:99 2026", "unverified-process"]] + : [[4242, 1, 4242, start, "opencode"]])) + }) as unknown as Command }) - const stop = harness.runtime.stop("workspace-1") - await stop - assert.equal(guardedCommands, 1) + leaderExited = true + h.child.exit(1) + const cleanup = h.runtime.stop("w") + h.timers.run(); h.timers.run() + await assert.rejects(cleanup, (error: unknown) => + error instanceof WorkspaceStopTimeoutError && /no longer has a verified identity anchor/.test(error.message)) + assert.equal(guardedCalls.length, 2) + assert.ok(guardedCalls.every((args) => args[7] === "1" && !args.includes("6000"))) + assert.equal((h.runtime as unknown as { processes: Map }).processes.size, 1) }) - - it("tracks a descendant forked between the precheck and SIGTERM dispatch", async () => { - let phase: "launch" | "after-term" | "after-kill" = "launch" - const guardedTargets: string[][] = [] - const spawnCommand = ((_command: string, args: readonly string[]) => { - if (isTokenCleanup(args)) { - const rows: Array<[number, number, number, string]> = phase === "after-term" ? [[5000, 4242, 4242, "120"]] : [] - return result(isTokenSignal(args) ? tokenSignalRows(rows) : linuxRows(rows)) - } - if (isGuarded(args)) { - guardedTargets.push([...args]) - if (phase === "launch") { - phase = "after-term" - return result(guardedRows(true, [[4242, 1, 4242, "100"], [5000, 4242, 4242, "120"]], "200")) - } - phase = "after-kill" - return result(guardedRows(false, [[5000, 4242, 4242, "120"]])) - } - if (phase === "launch") return result(linuxRows([[4242, 1, 4242, "100"]])) - if (phase === "after-term") return result(linuxRows([[5000, 1, 4242, "120"]])) - return result(linuxRows([[7, 1, 7, "10"]])) - }) as unknown as SpawnCommand - const harness = await createRuntime({ platform: "linux", spawnSync: spawnCommand }) - - const stop = harness.runtime.stop("workspace-1") - harness.timers.runNext() - harness.timers.runNext() - await stop - - assert.ok(guardedTargets[1]?.includes("5000")) - assert.ok(guardedTargets[1]?.includes("120")) + it("bounds direct Windows cleanup without falling back to taskkill", async () => { + const calls: Call[] = [] + const h = await harness({ platform: "win32", binary: "opencode.exe", spawnSync: ((command: string, args: readonly string[]) => { + calls.push({ command, args: [...args] }) + return isGuarded(args) ? result("CODENOMAD_TARGET|4242|1|0|win-start||100\nCODENOMAD_RESULT|1||1") : result(windows([[4242, 1, "win-start"]])) + }) as unknown as Command }) + const stop = h.runtime.stop("w"); h.timers.run(); h.timers.run() + await assert.rejects(stop, WorkspaceStopTimeoutError) + assert.equal(calls.some(({ command }) => command === "taskkill.exe"), false) + assert.equal(calls.filter(({ args }) => isGuarded(args)).length, 2) }) - - it("adopts a descendant forked after SIGTERM and before the returned cutoff", async () => { - let phase: "launch" | "after-term" | "after-kill" = "launch" - const guardedTargets: string[][] = [] - const spawnCommand = ((_command: string, args: readonly string[]) => { - if (isTokenCleanup(args)) { - const rows: Array<[number, number, number, string]> = phase === "after-term" ? [[5000, 1, 4242, "150"]] : [] - return result(isTokenSignal(args) ? tokenSignalRows(rows) : linuxRows(rows)) - } - if (isGuarded(args)) { - guardedTargets.push([...args]) - if (phase === "launch") { - phase = "after-term" - return result(guardedRows(true, [[4242, 1, 4242, "100"]], "200")) - } - phase = "after-kill" - return result(guardedRows(false, [[5000, 4242, 4242, "150"]])) - } - if (phase === "launch") return result(linuxRows([[4242, 1, 4242, "100"]])) - if (phase === "after-term") { - return result(linuxRows([[5000, 1, 4242, "150"]])) - } - return result(linuxRows([[7, 1, 7, "10"]])) - }) as unknown as SpawnCommand - const harness = await createRuntime({ platform: "linux", spawnSync: spawnCommand }) - - const stop = harness.runtime.stop("workspace-1") - harness.child.exit(0, null) - harness.timers.runNext() - harness.timers.runNext() - await stop - - assert.equal(guardedTargets.length, 2) - assert.ok(guardedTargets[1]?.includes("5000"), "SIGKILL must include the newly tracked immutable descendant") - assert.ok(guardedTargets[1]?.includes("150")) - }) - - it("does not adopt a newly reused process group after original ownership is lost", async () => { - let launched = false - let termSent = false - let guardedCommands = 0 - const spawnCommand = ((_command: string, args: readonly string[]) => { - if (isTokenCleanup(args)) return result(isTokenSignal(args) ? tokenSignalRows([]) : "") - if (isGuarded(args)) { - guardedCommands += 1 - termSent = true - return result(guardedRows(true, [[4242, 1, 4242, "100"]], "200")) - } - if (!launched) { - launched = true - return result(linuxRows([[4242, 1, 4242, "100"]])) - } - return termSent - ? result(linuxRows([[4242, 1, 4242, "300"], [6000, 4242, 4242, "150"]])) - : result(linuxRows([[4242, 1, 4242, "100"]])) - }) as unknown as SpawnCommand - const harness = await createRuntime({ platform: "linux", spawnSync: spawnCommand }) - - const stop = harness.runtime.stop("workspace-1") - await stop - assert.equal(guardedCommands, 1) - }) - - it("uses a guarded WSL command and retains immutable Linux identities", async () => { - let alive = true - const guardedCommands: string[][] = [] - const spawnCommand = ((command: string, args: readonly string[]) => { - if (command === "powershell.exe") return result(windowsRows([[4242, 1, "host-start"]])) - if (isTokenCleanup(args)) { - const rows: Array<[number, number, number, string]> = alive ? [[99, 1, 99, "50"]] : [] - if (isTokenSignal(args)) alive = false - return result(isTokenSignal(args) ? tokenSignalRows(rows, "wsl-boot") : linuxRows(rows, "wsl-boot")) - } - if (args.includes("codenomad-wsl-guarded-signal")) { - guardedCommands.push([...args]) - alive = false - return result(guardedRows(true, [[99, 1, 99, "50"]], "80", "wsl-boot")) - } - if (args.includes("codenomad-wsl-identity")) { - return result(alive ? linuxRows([[99, 1, 99, "50"]], "wsl-boot") : linuxRows([[7, 1, 7, "10"]], "wsl-boot")) - } - return result() - }) as unknown as SpawnCommand - const harness = await createRuntime({ platform: "win32", spawnSync: spawnCommand }, true, "opencode.cmd") - setWslIdentity(harness.runtime) - - const stop = harness.runtime.stop("workspace-1") - await stop - assert.equal(guardedCommands.length, 1) - assert.ok(guardedCommands[0]?.includes("wsl-boot")) - }) - - it("launches and stops a bare opencode cmd shim without PowerShell", async () => { - const commands: Array<{ command: string; args: readonly string[] }> = [] - const spawnCommand = ((command: string, args: readonly string[]) => { - commands.push({ command, args: [...args] }) - return command === "powershell.exe" ? result("", 1, "ETIMEDOUT") : result() - }) as unknown as SpawnCommand - const harness = await createRuntime({ platform: "win32", spawnSync: spawnCommand }, true, "opencode") - - const stop = harness.runtime.stop("workspace-1") - assert.deepEqual(commands, [{ command: "taskkill.exe", args: ["/PID", "4242", "/T"] }]) - harness.child.exit(0, null) - await stop - assert.equal(commands.some(({ command }) => command === "powershell.exe"), false) - }) - - it("adds force only when Windows wrapper cleanup escalates", async () => { - const invocations: readonly string[][] = [] - const harness = await createRuntime({ - platform: "win32", - spawnSync: ((_command: string, args: readonly string[]) => { - (invocations as string[][]).push([...args]) - return result() - }) as unknown as SpawnCommand, - }, true, "opencode.cmd") - - const stop = harness.runtime.stop("workspace-1") - harness.timers.runNext() - assert.deepEqual(invocations, [ - ["/PID", "4242", "/T"], - ["/PID", "4242", "/T", "/F"], - ]) - harness.child.exit(0, null) - await stop - }) - - it("keeps failed Windows wrapper cleanup bounded and retryable", async () => { + it("escalates wrapper cleanup, reports incomplete exited trees, and permits retry", async () => { let available = false - const harness = await createRuntime({ - platform: "win32", - spawnSync: (() => available ? result() : result("", 1, "taskkill unavailable")) as unknown as SpawnCommand, - }, true, "opencode.cmd") - - const first = harness.runtime.stop("workspace-1") - harness.timers.runNext() - harness.timers.runNext() - await assert.rejects(first, (error: unknown) => { - assert.ok(error instanceof WorkspaceStopTimeoutError) - assert.match(error.message, /taskkill \/T failed: taskkill unavailable/) - assert.match(error.message, /taskkill \/T \/F failed: taskkill unavailable/) - return true - }) - + const calls: Call[] = [] + const h = await harness({ platform: "win32", binary: "opencode.cmd", spawnSync: ((command: string, args: readonly string[]) => { + calls.push({ command, args: [...args] }) + return available ? result() : result("", 1, "taskkill unavailable") + }) as unknown as Command }) + const first = h.runtime.stop("w"); h.timers.run(); h.timers.run() + await assert.rejects(first, (error: unknown) => error instanceof WorkspaceStopTimeoutError && /\/T \/F failed/.test(error.message)) + assert.deepEqual(calls.map(({ args }) => args), [["/PID", "4242", "/T"], ["/PID", "4242", "/T", "/F"]]) available = true - const retry = harness.runtime.stop("workspace-1") - harness.child.exit(0, null) - await retry + const retry = h.runtime.stop("w"); h.child.exit(); await retry + + const exited = await harness({ platform: "win32", binary: "opencode.cmd", spawnSync: (() => result("", 1, "taskkill unavailable")) as unknown as Command }) + const incomplete = exited.runtime.stop("w"); exited.child.exit(1) + await assert.rejects(incomplete, WorkspaceWindowsTreeCleanupIncompleteError) + await assert.rejects(exited.runtime.stop("w"), WorkspaceWindowsTreeCleanupIncompleteError) }) - - it("never signals an exited wrapper PID after tree cleanup was not confirmed", async () => { - const invocations: readonly string[][] = [] - const harness = await createRuntime({ - platform: "win32", - spawnSync: ((_command: string, args: readonly string[]) => { - (invocations as string[][]).push([...args]) - return result("", 1, "taskkill unavailable") - }) as unknown as SpawnCommand, - }, true, "opencode") - - const first = harness.runtime.stop("workspace-1") - harness.child.exit(1, null) - await assert.rejects(first, WorkspaceWindowsTreeCleanupIncompleteError) - assert.equal((harness.runtime as unknown as { processes: Map }).processes.size, 1) - assert.deepEqual(invocations, [["/PID", "4242", "/T"]]) - - const second = harness.runtime.stop("workspace-1") - await assert.rejects(second, (error: unknown) => { - assert.ok(error instanceof WorkspaceWindowsTreeCleanupIncompleteError) - assert.match(error.message, /taskkill unavailable/) - return true - }) - assert.equal((harness.runtime as unknown as { processes: Map }).processes.size, 1) - assert.deepEqual(invocations, [["/PID", "4242", "/T"]]) - }) - - it("persists confirmed wrapper tree cleanup across a later exit and retry", async () => { - const invocations: readonly string[][] = [] - const harness = await createRuntime({ - platform: "win32", - spawnSync: ((_command: string, args: readonly string[]) => { - (invocations as string[][]).push([...args]) - return result() - }) as unknown as SpawnCommand, - }, true, "opencode.cmd") - - const first = harness.runtime.stop("workspace-1") - harness.timers.runNext() - harness.timers.runNext() - await assert.rejects(first, WorkspaceStopTimeoutError) - harness.child.exit(0, null) - - await harness.runtime.stop("workspace-1") - assert.deepEqual(invocations, [ - ["/PID", "4242", "/T"], - ["/PID", "4242", "/T", "/F"], - ]) - assert.equal((harness.runtime as unknown as { processes: Map }).processes.size, 0) - }) - - it("retains launch identity so a transient stop failure can recover on retry", async () => { - let available = true - let alive = true - let guardedCommands = 0 - const spawnCommand = ((_command: string, args: readonly string[]) => { - if (isTokenCleanup(args)) { - if (!available) return result("", 1, "identity service unavailable") - const rows: Array<[number, number, number, string]> = alive ? [[4242, 1, 4242, "100"]] : [] - if (isTokenSignal(args)) alive = false - return result(isTokenSignal(args) ? tokenSignalRows(rows) : linuxRows(rows)) - } - if (isGuarded(args)) { - guardedCommands += 1 - if (!available) return result("", 1, "identity service unavailable") - alive = false - return result(guardedRows(true, [[4242, 1, 4242, "100"]], "200")) - } - if (!available) return result("", 1, "identity service unavailable") - return result(alive ? linuxRows([[4242, 1, 4242, "100"]]) : linuxRows([[7, 1, 7, "10"]])) - }) as unknown as SpawnCommand - const harness = await createRuntime({ platform: "linux", spawnSync: spawnCommand }) - - available = false - const first = harness.runtime.stop("workspace-1") - harness.timers.runNext() - harness.timers.runNext() - await assert.rejects(first, /cleanup could not be confirmed/) - - available = true - const retry = harness.runtime.stop("workspace-1") - await retry - assert.equal(guardedCommands, 3) - }) - it("shares one bounded stop operation across concurrent callers", async () => { - const harness = await createRuntime({ platform: "linux" }) - const first = harness.runtime.stop("workspace-1") - const second = harness.runtime.stop("workspace-1") + const h = await harness() + const first = h.runtime.stop("w"); const second = h.runtime.stop("w") assert.strictEqual(first, second) - harness.timers.runNext() - harness.timers.runNext() - const results = await Promise.allSettled([first, second]) - assert.equal(results[0].status, "rejected") - assert.equal(results[1].status, "rejected") - }) - - it("cancels a no-port launch while retaining retryable stop state", async () => { - let alive = true - const spawnCommand = ((_command: string, args: readonly string[]) => { - if (isTokenCleanup(args)) { - const rows: Array<[number, number, number, string]> = alive ? [[4242, 1, 4242, "100"]] : [] - return result(isTokenSignal(args) ? tokenSignalRows(rows) : linuxRows(rows)) - } - if (isGuarded(args)) return result(guardedRows(true, [[4242, 1, 4242, "100"]], "200")) - return result(alive ? linuxRows([[4242, 1, 4242, "100"]]) : linuxRows([[7, 1, 7, "10"]])) - }) as unknown as SpawnCommand - const harness = await createRuntime({ platform: "linux", spawnSync: spawnCommand }, false) - - const firstStop = harness.runtime.stop("workspace-1") - await assert.rejects(harness.launch, WorkspaceRuntimeLaunchCancelledError) - harness.timers.runNext() - harness.timers.runNext() - await assert.rejects(firstStop, WorkspaceStopTimeoutError) - - alive = false - const retry = harness.runtime.stop("workspace-1") - await retry + h.timers.run(); h.timers.run() + const outcomes = await Promise.allSettled([first, second]) + assert.deepEqual(outcomes.map(({ status }) => status), ["rejected", "rejected"]) }) }) diff --git a/packages/server/src/workspaces/runtime.ts b/packages/server/src/workspaces/runtime.ts index 68c5e2a9..78ffc70a 100644 --- a/packages/server/src/workspaces/runtime.ts +++ b/packages/server/src/workspaces/runtime.ts @@ -8,12 +8,15 @@ import { Logger } from "../logger" import { buildSpawnSpec, type SpawnProcessKind } from "./spawn" import { descendantsOf, + LAUNCH_CLEANUP_TOKEN_ENV, + probeLaunchCleanupToken, probePosixProcesses, probeWindowsProcesses, probeWslProcesses, sameProcess, signalPosixProcesses, signalOwnedPosixProcessGroup, + signalLaunchCleanupToken, signalWindowsProcesses, signalWslProcesses, startedNoLaterThan, @@ -21,11 +24,6 @@ import { type ProcessIdentity, type ProcessSnapshot, } from "./process-identity" -import { - LAUNCH_CLEANUP_TOKEN_ENV, - probeLaunchCleanupToken, - signalLaunchCleanupToken, -} from "./launch-cleanup" const SENSITIVE_ENV_KEY = /(PASSWORD|TOKEN|SECRET)/i const WSL_PID_MARKER = "__CODENOMAD_WSL_PID__:" @@ -49,6 +47,7 @@ interface LaunchOptions { environment?: Record logLevel?: string onExit?: (info: ProcessExitInfo) => void + signal?: AbortSignal } export interface ProcessExitInfo { @@ -58,6 +57,16 @@ export interface ProcessExitInfo { requested: boolean } +interface TrackedProcesses { + leader?: ProcessIdentity + groupId?: number + dispatchCutoff?: string + groupOwnershipRetained?: boolean + groupGoneConfirmed?: boolean + groupOwnershipUncertain?: boolean + members: Map +} + interface ManagedProcess { child: ChildProcess cleanupToken: string @@ -67,22 +76,15 @@ interface ManagedProcess { identityCaptureFailed?: boolean requestedStop: boolean stopPromise?: Promise - cancelWaits?: () => void + cancelLaunch?: () => void finalizeExit?: (code: number | null, signal: NodeJS.Signals | null) => void - targets?: { - leader?: ProcessIdentity - groupId?: number - dispatchCutoff?: string - members: Map - } - wsl?: { + targets?: TrackedProcesses + wsl?: TrackedProcesses & { distro: string linuxPid: number | null linuxPgid: number | null leaderStartTime: string | null bootId: string | null - dispatchCutoff?: string - members: Map } } @@ -127,15 +129,6 @@ export class WorkspaceWindowsTreeCleanupIncompleteError extends Error { } } -export class WorkspaceRuntimeLaunchCancelledError extends Error { - readonly code = "WORKSPACE_RUNTIME_LAUNCH_CANCELLED" - - constructor(workspaceId: string) { - super(`Workspace ${workspaceId} runtime launch was cancelled`) - this.name = "WorkspaceRuntimeLaunchCancelledError" - } -} - export class WorkspaceRuntimeIdentityCaptureError extends Error { readonly code = "WORKSPACE_RUNTIME_IDENTITY_CAPTURE_FAILED" @@ -175,9 +168,9 @@ export class WorkspaceRuntime { pid: number port: number exitPromise: Promise - cancellationPromise: Promise getLastOutput: () => string }> { + options.signal?.throwIfAborted() this.validateFolder(options.folder) const logLevel = typeof options.logLevel === "string" ? options.logLevel.toUpperCase() : "DEBUG" @@ -189,11 +182,6 @@ export class WorkspaceRuntime { const exitPromise = new Promise((resolveExit) => { exitResolve = resolveExit }) - let cancellationResolve: ((error: WorkspaceRuntimeLaunchCancelledError) => void) | null = null - const cancellationPromise = new Promise((resolveCancellation) => { - cancellationResolve = resolveCancellation - }) - // Store recent output for debugging - keep last 50 lines from each stream const MAX_OUTPUT_LINES = 50 const recentStdout: string[] = [] @@ -284,7 +272,12 @@ export class WorkspaceRuntime { const launchSnapshot = child.pid ? this.platform === "win32" ? probeWindowsProcesses(this.spawnCommand, this.stopCommandTimeoutMs) - : probePosixProcesses(this.spawnCommand, this.stopCommandTimeoutMs, this.platform) + : probePosixProcesses( + this.spawnCommand, + this.stopCommandTimeoutMs, + this.platform, + this.platform === "linux" ? undefined : { pids: [child.pid], groupId: child.pid }, + ) : { ok: false as const, error: "spawned child did not expose a PID" } const launchLeader = launchSnapshot.ok && child.pid ? launchSnapshot.processes.get(child.pid) : undefined if (!launchLeader) { @@ -297,7 +290,11 @@ export class WorkspaceRuntime { } managed.targets!.leader = launchLeader managed.targets!.groupId = launchLeader.groupId - managed.targets!.members.set(launchLeader.pid, launchLeader) + managed.targets!.groupOwnershipRetained = this.platform !== "linux" && this.platform !== "win32" && + launchLeader.groupId === launchLeader.pid + for (const identity of launchSnapshot.ok ? launchSnapshot.processes.values() : [launchLeader]) { + if (identity.groupId === launchLeader.groupId) managed.targets!.members.set(identity.pid, identity) + } } let stdoutBuffer = "" @@ -305,6 +302,13 @@ export class WorkspaceRuntime { let portFound = false let pendingPort: number | null = null let launchSettled = false + const cancelLaunch = () => { + if (launchSettled) return + launchSettled = true + stopWarningTimer() + reject(options.signal?.reason ?? new Error(`Workspace ${options.workspaceId} runtime launch was cancelled`)) + } + managed.cancelLaunch = cancelLaunch let warningTimer: NodeJS.Timeout | null = null @@ -323,18 +327,8 @@ export class WorkspaceRuntime { startWarningTimer() - managed.cancelWaits = () => { - const error = new WorkspaceRuntimeLaunchCancelledError(options.workspaceId) - if (!launchSettled) { - launchSettled = true - stopWarningTimer() - reject(error) - } - if (cancellationResolve) { - cancellationResolve(error) - cancellationResolve = null - } - } + options.signal?.addEventListener("abort", cancelLaunch, { once: true }) + if (options.signal?.aborted) cancelLaunch() const cleanupStreams = () => { stopWarningTimer() @@ -346,11 +340,11 @@ export class WorkspaceRuntime { const handleExit = (code: number | null, signal: NodeJS.Signals | null) => { if (finalized) return finalized = true + const cleanupRequired = !managed.requestedStop this.logger.info({ workspaceId: options.workspaceId, code, signal }, "OpenCode process exited") - if (!managed.requestedStop && this.processes.get(options.workspaceId) === managed) { - this.processes.delete(options.workspaceId) - } cleanupStreams() + options.signal?.removeEventListener("abort", cancelLaunch) + managed.cancelLaunch = undefined child.removeListener("error", handleError) child.removeListener("exit", handleExit) const exitInfo: ProcessExitInfo = { @@ -373,15 +367,20 @@ export class WorkspaceRuntime { } else { options.onExit?.(exitInfo) } + if (cleanupRequired && this.processes.get(options.workspaceId) === managed) { + void this.stop(options.workspaceId).catch((error) => { + this.logger.warn({ workspaceId: options.workspaceId, err: error }, "Unexpected workspace exit cleanup remains pending") + }) + } } managed.finalizeExit = handleExit const handleError = (error: Error) => { + const cleanupRequired = !managed.requestedStop cleanupStreams() + options.signal?.removeEventListener("abort", cancelLaunch) + managed.cancelLaunch = undefined child.removeListener("exit", handleExit) - if (!managed.requestedStop && this.processes.get(options.workspaceId) === managed) { - this.processes.delete(options.workspaceId) - } this.logger.error({ workspaceId: options.workspaceId, err: error }, "Workspace runtime error") if (exitResolve) { exitResolve({ workspaceId: options.workspaceId, code: null, signal: null, requested: managed.requestedStop }) @@ -391,6 +390,11 @@ export class WorkspaceRuntime { launchSettled = true reject(error) } + if (cleanupRequired && this.processes.get(options.workspaceId) === managed) { + void this.stop(options.workspaceId).catch((stopError) => { + this.logger.warn({ workspaceId: options.workspaceId, err: stopError }, "Workspace error cleanup remains pending") + }) + } } child.removeListener("error", handleEarlyError) @@ -405,9 +409,11 @@ export class WorkspaceRuntime { portFound = true launchSettled = true stopWarningTimer() + options.signal?.removeEventListener("abort", cancelLaunch) + managed.cancelLaunch = undefined child.removeListener("error", handleError) this.logger.info({ workspaceId: options.workspaceId, port: pendingPort }, "Workspace runtime allocated port") - resolve({ pid: child.pid!, port: pendingPort, exitPromise, cancellationPromise, getLastOutput }) + resolve({ pid: child.pid!, port: pendingPort, exitPromise, getLastOutput }) } const failWslIdentityCapture = (detail: string) => { @@ -505,15 +511,16 @@ export class WorkspaceRuntime { } private beginFailedLaunchCleanup(workspaceId: string, managed: ManagedProcess): void { - const child = managed.child managed.identityCaptureFailed = true void this.stop(workspaceId).catch((error) => { this.logger.warn({ workspaceId, err: error }, "Unpublished workspace cleanup remains pending") }) - try { - child.kill("SIGTERM") - } catch (error) { - this.logger.debug({ workspaceId, err: error }, "Failed initial live-child cleanup signal") + if (managed.child.exitCode === null && managed.child.signalCode === null) { + try { + managed.child.kill("SIGTERM") + } catch (error) { + this.logger.debug({ workspaceId, err: error }, "Failed initial live-child cleanup signal") + } } } @@ -527,472 +534,299 @@ export class WorkspaceRuntime { const stopPromise = this.stopManagedProcess(workspaceId, managed) managed.stopPromise = stopPromise - void stopPromise.then( - () => { - if (managed.stopPromise === stopPromise) managed.stopPromise = undefined - }, - () => { - if (managed.stopPromise === stopPromise) managed.stopPromise = undefined - }, - ) + void stopPromise.finally(() => { + if (managed.stopPromise === stopPromise) managed.stopPromise = undefined + }).catch(() => undefined) return stopPromise } private stopManagedProcess(workspaceId: string, managed: ManagedProcess): Promise { managed.requestedStop = true - managed.cancelWaits?.() - managed.cancelWaits = undefined - const child = managed.child + managed.cancelLaunch?.() + managed.cancelLaunch = undefined this.logger.info({ workspaceId }, "Stopping OpenCode process") + if (managed.processKind === "windows-wrapper") return this.stopOwnedWindowsProcess(workspaceId, managed) - if (managed.processKind === "windows-wrapper") { - return this.stopOwnedWindowsProcess(workspaceId, managed) - } - + const { child } = managed const pid = child.pid const failures: string[] = [] - - type TargetLiveness = { state: "alive" | "gone" | "unknown"; detail: string } - type RefreshedTargets = { - snapshot: ProcessSnapshot - leaderMatches: boolean - aliveMembers: ProcessIdentity[] - } - const wrapperExited = () => child.exitCode !== null || child.signalCode !== null - const rememberGroupMembers = ( - snapshot: ProcessSnapshot, - targets: NonNullable, - ): RefreshedTargets => { - if (!snapshot.ok || !pid) return { snapshot, leaderMatches: false, aliveMembers: [] } - const currentLeader = snapshot.processes.get(pid) - const leaderMatches = sameProcess(targets.leader, currentLeader) - const currentGroupLeader = targets.groupId ? snapshot.processes.get(targets.groupId) : undefined - const groupWasReused = Boolean(currentGroupLeader && !sameProcess(targets.leader, currentGroupLeader)) - if (targets.groupId) { - for (const process of snapshot.processes.values()) { - const launchBootMatches = !targets.leader?.bootId || process.bootId === targets.leader.bootId - const withinProvenLinuxDispatch = this.platform === "linux" && Boolean( - targets.dispatchCutoff && launchBootMatches && startedNoLaterThan(process, targets.dispatchCutoff), - ) - if (process.groupId === targets.groupId && (leaderMatches || (!groupWasReused && withinProvenLinuxDispatch))) { - targets.members.set(process.pid, process) - } - } - } - return { - snapshot, - leaderMatches, - aliveMembers: Array.from(targets.members.values()).filter((identity) => - sameProcess(identity, snapshot.processes.get(identity.pid)), - ), - } - } - - const refreshHostTargets = (): RefreshedTargets => { - const targets = managed.targets ?? { members: new Map() } - managed.targets = targets - const snapshot = this.platform === "win32" - ? probeWindowsProcesses(this.spawnCommand, this.stopCommandTimeoutMs) - : probePosixProcesses(this.spawnCommand, this.stopCommandTimeoutMs, this.platform) - if (!snapshot.ok) { - failures.push(`${this.platform === "win32" ? "Windows" : "POSIX"} identity discovery failed: ${snapshot.error}`) - return { snapshot, leaderMatches: false, aliveMembers: [] } - } - - const refreshed = rememberGroupMembers(snapshot, targets) - if (this.platform === "win32" && refreshed.leaderMatches && targets.leader) { - for (const descendant of descendantsOf(snapshot.processes, targets.leader.pid)) { - targets.members.set(descendant.pid, descendant) - } - refreshed.aliveMembers = Array.from(targets.members.values()).filter((identity) => - sameProcess(identity, snapshot.processes.get(identity.pid)), - ) - } - return refreshed - } - - const refreshWslTargets = (): RefreshedTargets => { - const wsl = managed.wsl! - const snapshot = probeWslProcesses(this.spawnCommand, wsl.distro, this.stopCommandTimeoutMs) - if (!snapshot.ok) { - failures.push(`WSL identity discovery failed: ${snapshot.error}`) - return { snapshot, leaderMatches: false, aliveMembers: [] } - } - if (!wsl.linuxPid || !wsl.leaderStartTime) { - return { snapshot, leaderMatches: false, aliveMembers: [] } - } - const leaderIdentity: ProcessIdentity = { - pid: wsl.linuxPid, - parentPid: 0, - groupId: wsl.linuxPgid ?? wsl.linuxPid, - startTime: wsl.leaderStartTime, - ...(wsl.bootId ? { bootId: wsl.bootId } : {}), - startOrder: wsl.leaderStartTime, - } - const currentLeader = snapshot.processes.get(wsl.linuxPid) - const leaderMatches = sameProcess(leaderIdentity, currentLeader) - const currentGroupLeader = wsl.linuxPgid ? snapshot.processes.get(wsl.linuxPgid) : undefined - const groupWasReused = Boolean(currentGroupLeader && !sameProcess(leaderIdentity, currentGroupLeader)) - if (wsl.linuxPgid) { - for (const process of snapshot.processes.values()) { - const withinProvenDispatch = Boolean( - wsl.dispatchCutoff && process.bootId === wsl.bootId && startedNoLaterThan(process, wsl.dispatchCutoff), - ) - if (process.groupId === wsl.linuxPgid && (leaderMatches || (!groupWasReused && withinProvenDispatch))) { - wsl.members.set(process.pid, process) - } - } - } - return { - snapshot, - leaderMatches, - aliveMembers: Array.from(wsl.members.values()).filter((identity) => - sameProcess(identity, snapshot.processes.get(identity.pid)), - ), - } - } - const hasWslIdentity = () => Boolean( managed.wsl?.linuxPid && managed.wsl.linuxPgid && managed.wsl.leaderStartTime && managed.wsl.bootId, ) - - const canUseTokenCleanup = () => this.platform === "linux" || Boolean(managed.wsl) - const tokenTarget = () => managed.wsl ?? managed.targets! - const refreshTokenTargets = (): ProcessSnapshot | undefined => { - if (!canUseTokenCleanup()) return undefined - const snapshot = probeLaunchCleanupToken( - this.spawnCommand, - managed.cleanupToken, - this.stopCommandTimeoutMs, - managed.wsl?.distro, - ) - if (!snapshot.ok) { - failures.push(`${managed.wsl ? "WSL" : "Linux"} launch-token discovery failed: ${snapshot.error}`) - return snapshot + const trackedTarget = () => managed.wsl && hasWslIdentity() ? managed.wsl : managed.targets! + const trackedLeader = (): ProcessIdentity | undefined => { + if (!managed.wsl || !hasWslIdentity()) return managed.targets?.leader + return { + pid: managed.wsl.linuxPid!, + parentPid: 0, + groupId: managed.wsl.linuxPgid!, + startTime: managed.wsl.leaderStartTime!, + bootId: managed.wsl.bootId!, + startOrder: managed.wsl.leaderStartTime!, } - const target = tokenTarget() - for (const identity of snapshot.processes.values()) target.members.set(identity.pid, identity) - return snapshot } - const recordSignalResult = ( - result: GuardedSignalResult, - target: NonNullable | NonNullable, - platformName: string, - signal: NodeJS.Signals, - ) => { - if (!result.ok) { - for (const identity of result.observed ?? []) target.members.set(identity.pid, identity) - failures.push(`${platformName} guarded ${signal} failed: ${result.error}`) - return + const refreshTargets = () => { + const target = trackedTarget() + const leader = trackedLeader() + const groupId = managed.wsl && hasWslIdentity() ? managed.wsl.linuxPgid! : target.groupId + const portableGroupId = this.platform !== "linux" && this.platform !== "win32" && + target.groupOwnershipRetained && !target.groupGoneConfirmed ? groupId : undefined + const snapshot = managed.wsl && hasWslIdentity() + ? probeWslProcesses(this.spawnCommand, managed.wsl.distro, this.stopCommandTimeoutMs) + : this.platform === "win32" + ? probeWindowsProcesses(this.spawnCommand, this.stopCommandTimeoutMs) + : probePosixProcesses(this.spawnCommand, this.stopCommandTimeoutMs, this.platform, this.platform === "linux" + ? undefined + : { pids: [leader?.pid, ...target.members.keys()].filter((value): value is number => Boolean(value)), groupId: portableGroupId }) + if (!snapshot.ok) { + const platformName = managed.wsl && hasWslIdentity() ? "WSL" : this.platform === "win32" ? "Windows" : "POSIX" + failures.push(`${platformName} identity discovery failed: ${snapshot.error}`) + return { snapshot, aliveMembers: [] as ProcessIdentity[] } + } + + const leaderMatches = sameProcess(leader, leader ? snapshot.processes.get(leader.pid) : undefined) + const groupLeader = groupId ? snapshot.processes.get(groupId) : undefined + const groupWasReused = Boolean(groupLeader && !sameProcess(leader, groupLeader)) + const retainedAnchorMatches = Boolean(portableGroupId && Array.from(target.members.values()).some((identity) => + sameProcess(identity, snapshot.processes.get(identity.pid)), + )) + if (portableGroupId && groupWasReused) { + target.groupGoneConfirmed = true + target.groupOwnershipUncertain = false + } + for (const process of snapshot.processes.values()) { + const sameBoot = !leader?.bootId || process.bootId === leader.bootId + const withinDispatch = (this.platform === "linux" || Boolean(managed.wsl)) && Boolean( + target.dispatchCutoff && sameBoot && startedNoLaterThan(process, target.dispatchCutoff), + ) + const withinRetainedPortableGroup = Boolean(portableGroupId && !groupWasReused && retainedAnchorMatches) + if (groupId && process.groupId === groupId && (leaderMatches || withinRetainedPortableGroup || (!groupWasReused && withinDispatch))) { + target.members.set(process.pid, process) + } + } + if (portableGroupId && !groupWasReused) { + const groupPresent = Array.from(snapshot.processes.values()).some((process) => process.groupId === portableGroupId) + if (!groupPresent) { + target.groupGoneConfirmed = true + target.groupOwnershipUncertain = false + } else if (!leaderMatches && !retainedAnchorMatches) { + target.groupOwnershipUncertain = true + } + } + if (this.platform === "win32" && !managed.wsl && leaderMatches && leader) { + for (const descendant of descendantsOf(snapshot.processes, leader.pid)) target.members.set(descendant.pid, descendant) + } + const aliveMembers = Array.from(target.members.values()).filter((identity) => + sameProcess(identity, snapshot.processes.get(identity.pid)), + ) + return { snapshot, aliveMembers } + } + + const usesTokenCleanup = () => this.platform === "linux" || Boolean(managed.wsl) + const refreshTokenTargets = (): ProcessSnapshot | undefined => { + if (!usesTokenCleanup()) return + const snapshot = probeLaunchCleanupToken( + this.spawnCommand, managed.cleanupToken, this.stopCommandTimeoutMs, managed.wsl?.distro, + ) + if (!snapshot.ok) failures.push(`${managed.wsl ? "WSL" : "Linux"} launch-token discovery failed: ${snapshot.error}`) + else for (const identity of snapshot.processes.values()) trackedTarget().members.set(identity.pid, identity) + return snapshot + } + const recordSignalResult = (result: GuardedSignalResult, target: TrackedProcesses, name: string, signal: NodeJS.Signals) => { + const identities = result.ok ? result.signaled : (result.observed ?? []) + for (const identity of identities) target.members.set(identity.pid, identity) + if (!result.ok) failures.push(`${name} guarded ${signal} failed: ${result.error}`) + else { + if (result.matched && !result.signalSent) failures.push(`${name} guarded ${signal} matched but sent no signal`) + if (result.cutoff) target.dispatchCutoff = result.cutoff } - if (result.matched && !result.signalSent) failures.push(`${platformName} guarded ${signal} matched but sent no signal`) - for (const identity of result.signaled) target.members.set(identity.pid, identity) - if (result.cutoff) target.dispatchCutoff = result.cutoff } const sendStopSignal = (signal: NodeJS.Signals) => { - if (!pid) { - failures.push(`${signal} was not sent because the process PID is unavailable`) - } - - let ownedPosixHandled = false + if (!pid) failures.push(`${signal} was not sent because the process PID is unavailable`) + if (pid && wrapperExited() && this.platform !== "linux" && this.platform !== "win32") refreshTargets() + let signaledOwnedGroup = false if (pid && managed.identityCaptureFailed && this.platform !== "linux" && this.platform !== "win32" && !wrapperExited()) { - const cleanup = signalOwnedPosixProcessGroup(this.spawnCommand, pid, signal, this.stopCommandTimeoutMs) - recordSignalResult(cleanup, managed.targets!, "owned POSIX group", signal) - if (cleanup.ok && cleanup.matched) { - const leader = cleanup.signaled.find((identity) => identity.pid === pid) - if (leader) { - managed.targets!.leader = leader - managed.targets!.groupId = pid - } - } - refreshHostTargets() - ownedPosixHandled = true + const result = signalOwnedPosixProcessGroup(this.spawnCommand, pid, signal, this.stopCommandTimeoutMs) + recordSignalResult(result, managed.targets!, "owned POSIX group", signal) + const leader = result.ok && result.matched ? result.signaled.find((identity) => identity.pid === pid) : undefined + if (leader) Object.assign(managed.targets!, { leader, groupId: pid }) + refreshTargets() + signaledOwnedGroup = true } - - if (pid && managed.wsl && hasWslIdentity()) { - const wsl = managed.wsl - const leader: ProcessIdentity = { - pid: wsl.linuxPid!, - parentPid: 0, - groupId: wsl.linuxPgid!, - startTime: wsl.leaderStartTime!, - bootId: wsl.bootId!, - startOrder: wsl.leaderStartTime!, - } - const result = signalWslProcesses(this.spawnCommand, wsl.distro, { - leader, - groupId: wsl.linuxPgid!, - members: Array.from(wsl.members.values()), - signal, - }, this.stopCommandTimeoutMs) - recordSignalResult(result, wsl, "WSL", signal) - refreshWslTargets() - } else if (pid && !ownedPosixHandled) { - const targets = managed.targets! + if (pid && !signaledOwnedGroup) { + const target = trackedTarget() + const groupId = managed.wsl && hasWslIdentity() ? managed.wsl.linuxPgid! : target.groupId const request = { - leader: targets.leader, - groupId: targets.groupId, - members: Array.from(targets.members.values()), - signal, + leader: trackedLeader(), groupId, members: [...target.members.values()], signal, + allowLeaderlessGroup: this.platform !== "linux" && this.platform !== "win32" && + Boolean(target.groupOwnershipRetained && !target.groupGoneConfirmed && wrapperExited()), + cleanupToken: this.platform !== "linux" && this.platform !== "win32" ? managed.cleanupToken : undefined, } - if (this.platform === "win32") { - recordSignalResult( - signalWindowsProcesses(this.spawnCommand, request, this.stopCommandTimeoutMs), - targets, - "Windows", - signal, - ) - } else { - recordSignalResult( - signalPosixProcesses(this.spawnCommand, request, this.stopCommandTimeoutMs, this.platform), - targets, - "POSIX", - signal, - ) - } - refreshHostTargets() + const result = managed.wsl && hasWslIdentity() + ? signalWslProcesses(this.spawnCommand, managed.wsl.distro, request, this.stopCommandTimeoutMs) + : this.platform === "win32" + ? signalWindowsProcesses(this.spawnCommand, request, this.stopCommandTimeoutMs) + : signalPosixProcesses(this.spawnCommand, request, this.stopCommandTimeoutMs, this.platform) + recordSignalResult(result, target, managed.wsl && hasWslIdentity() ? "WSL" : this.platform === "win32" ? "Windows" : "POSIX", signal) + refreshTargets() } - - if (canUseTokenCleanup()) { - const tokenResult = signalLaunchCleanupToken( - this.spawnCommand, - managed.cleanupToken, - signal, - this.stopCommandTimeoutMs, - managed.wsl?.distro, + if (usesTokenCleanup()) { + const result = signalLaunchCleanupToken( + this.spawnCommand, managed.cleanupToken, signal, this.stopCommandTimeoutMs, managed.wsl?.distro, ) - if (!tokenResult.ok) { - failures.push(`${managed.wsl ? "WSL" : "Linux"} launch-token ${signal} failed: ${tokenResult.error}`) - } else { - if (tokenResult.targets.length > 0 && !tokenResult.signalSent) { - failures.push(`${managed.wsl ? "WSL" : "Linux"} launch-token ${signal} matched but sent no signal`) - } - const target = tokenTarget() - for (const identity of tokenResult.targets) target.members.set(identity.pid, identity) + const name = managed.wsl ? "WSL" : "Linux" + if (!result.ok) failures.push(`${name} launch-token ${signal} failed: ${result.error}`) + else { + if (result.targets.length > 0 && !result.signalSent) failures.push(`${name} launch-token ${signal} matched but sent no signal`) + for (const identity of result.targets) trackedTarget().members.set(identity.pid, identity) } refreshTokenTargets() } } - const probeTargetLiveness = (): TargetLiveness => { - const refreshed = pid - ? managed.wsl && hasWslIdentity() ? refreshWslTargets() : refreshHostTargets() - : undefined + const probeLiveness = () => { + const refreshed = pid ? refreshTargets() : undefined const tokenSnapshot = refreshTokenTargets() if (tokenSnapshot && !tokenSnapshot.ok) { - return { state: "unknown", detail: `${managed.wsl ? "WSL Linux" : "Linux"} launch-token cleanup could not be confirmed` } + return { state: "unknown", detail: `${managed.wsl ? "WSL Linux" : "Linux"} launch-token cleanup could not be confirmed` } as const } if (refreshed && !refreshed.snapshot.ok && (managed.targets?.leader || !tokenSnapshot?.ok)) { - const platformName = managed.wsl ? "WSL Linux" : this.platform === "win32" ? "Windows" : "POSIX" - return { state: "unknown", detail: `${platformName} target identity could not be confirmed` } + const name = managed.wsl ? "WSL Linux" : this.platform === "win32" ? "Windows" : "POSIX" + return { state: "unknown", detail: `${name} target identity could not be confirmed` } as const } - const trackedCount = managed.wsl ? managed.wsl.members.size : (managed.targets?.members.size ?? 0) if (managed.identityCaptureFailed && this.platform === "win32" && !managed.wsl) { - return { state: "unknown", detail: "Windows cleanup cannot prove exact launch ownership without a Job Object" } + return { state: "unknown", detail: "Windows cleanup cannot prove exact launch ownership without a Job Object" } as const } if (managed.identityCaptureFailed && managed.wsl && !managed.targets?.leader && !wrapperExited()) { - return { state: "unknown", detail: "the unidentified Windows WSL wrapper is still alive" } + return { state: "unknown", detail: "the unidentified Windows WSL wrapper is still alive" } as const } - if (trackedCount === 0) { - if (tokenSnapshot?.ok && tokenSnapshot.processes.size === 0) { - return { state: "gone", detail: "no process carries the unpublished launch token" } - } - return { state: "unknown", detail: pid ? "the original process identity was not captured" : "the target PID is unavailable" } + if (trackedTarget().groupOwnershipUncertain) { + return { state: "unknown", detail: "the retained POSIX process group no longer has a verified identity anchor" } as const + } + if (trackedTarget().members.size === 0) { + if (tokenSnapshot?.ok && tokenSnapshot.processes.size === 0) return { state: "gone", detail: "no process carries the unpublished launch token" } as const + return { state: "unknown", detail: pid ? "the original process identity was not captured" : "the target PID is unavailable" } as const } if ((refreshed?.aliveMembers.length ?? 0) === 0 && (!tokenSnapshot?.ok || tokenSnapshot.processes.size === 0)) { - return { state: "gone", detail: "all tracked original process identities are gone" } + return { state: "gone", detail: "all tracked original process identities are gone" } as const } - const targetName = managed.wsl && hasWslIdentity() ? "WSL Linux process group" : this.platform === "win32" ? "Windows process tree" : "POSIX process group" - return { state: "alive", detail: `the tracked original ${targetName} is still alive` } + const name = managed.wsl && hasWslIdentity() ? "WSL Linux process group" : this.platform === "win32" ? "Windows process tree" : "POSIX process group" + return { state: "alive", detail: `the tracked original ${name} is still alive` } as const } - - return new Promise((resolve, reject) => { - let escalationTimer: RuntimeTimeout | null = null - let deadlineTimer: RuntimeTimeout | null = null - let settled = false - - const cleanup = () => { - child.removeListener("exit", onExit) - child.removeListener("error", onError) - if (escalationTimer) { - this.cancelTimeout(escalationTimer) - escalationTimer = null - } - if (deadlineTimer) { - this.cancelTimeout(deadlineTimer) - deadlineTimer = null - } - } - - const confirmTargetGone = () => { - if (settled) return - settled = true - cleanup() - if (this.processes.get(workspaceId) === managed) { - this.processes.delete(workspaceId) - } - managed.finalizeExit?.(child.exitCode, child.signalCode) - resolve() - } - const checkForConfirmedStop = () => { - const liveness = probeTargetLiveness() - if (liveness.state === "gone") { - confirmTargetGone() - } - return liveness - } - const onExit = () => { - checkForConfirmedStop() - } - const onError = (error: Error) => { - failures.push(`child process error while stopping: ${error.message}`) - } - - child.once("exit", onExit) - child.on("error", onError) - - escalationTimer = this.scheduleTimeout(() => { - escalationTimer = null - if (settled) return - const liveness = checkForConfirmedStop() - if (settled) return + const stopped = () => probeLiveness().state === "gone" ? true : undefined + const totalTimeoutMs = this.gracefulStopTimeoutMs + this.forcedStopTimeoutMs + return this.runBoundedStop(workspaceId, managed, { + start: () => { + this.logger.debug({ workspaceId, pid, detached: this.platform !== "win32" }, "Sending SIGTERM to workspace process (tree/group)") + sendStopSignal("SIGTERM") + return stopped() + }, + exit: stopped, + error: (error) => { failures.push(`child process error while stopping: ${error.message}`) }, + escalate: () => { + const liveness = probeLiveness() + if (liveness.state === "gone") return true this.logger.warn({ workspaceId, pid }, "Process did not stop after SIGTERM, escalating") sendStopSignal("SIGKILL") - if (liveness.state === "unknown") { - this.logger.debug({ workspaceId, pid }, "Escalating because target liveness could not be confirmed") - } - }, this.gracefulStopTimeoutMs) - - const totalTimeoutMs = this.gracefulStopTimeoutMs + this.forcedStopTimeoutMs - deadlineTimer = this.scheduleTimeout(() => { - deadlineTimer = null - if (settled) return - const liveness = checkForConfirmedStop() - if (settled) return - - settled = true - cleanup() - const wrapperDetail = wrapperExited() ? "the wrapper exited but " : "" - reject(new WorkspaceStopTimeoutError(workspaceId, pid, totalTimeoutMs, `${wrapperDetail}${liveness.detail}`, failures)) - }, totalTimeoutMs) - - this.logger.debug( - { workspaceId, pid, detached: this.platform !== "win32" }, - "Sending SIGTERM to workspace process (tree/group)", - ) - sendStopSignal("SIGTERM") - checkForConfirmedStop() + }, + deadline: () => { + const liveness = probeLiveness() + if (liveness.state === "gone") return true + const prefix = wrapperExited() ? "the wrapper exited but " : "" + return new WorkspaceStopTimeoutError(workspaceId, pid, totalTimeoutMs, `${prefix}${liveness.detail}`, failures) + }, }) } private stopOwnedWindowsProcess(workspaceId: string, managed: ManagedProcess): Promise { - const child = managed.child + const { child } = managed const pid = child.pid const failures = (managed.windowsTreeCleanupFailures ??= []) - - return new Promise((resolve, reject) => { - let escalationTimer: RuntimeTimeout | null = null - let deadlineTimer: RuntimeTimeout | null = null - let settled = false - - const cleanup = () => { - child.removeListener("exit", onExit) - child.removeListener("error", onError) - if (escalationTimer) this.cancelTimeout(escalationTimer) - if (deadlineTimer) this.cancelTimeout(deadlineTimer) - } - const finish = () => { - if (settled) return - settled = true - cleanup() - if (this.processes.get(workspaceId) === managed) this.processes.delete(workspaceId) - managed.finalizeExit?.(child.exitCode, child.signalCode) - resolve() - } - const rejectIncompleteCleanup = () => { - if (settled) return - settled = true - cleanup() - reject(new WorkspaceWindowsTreeCleanupIncompleteError(workspaceId, pid, failures)) - } - const onExit = () => { - if (managed.windowsTreeCleanupConfirmed) { - finish() - } else { - rejectIncompleteCleanup() - } - } - const onError = (error: Error) => { - if (settled) return - settled = true - cleanup() - failures.push(`child process error while stopping: ${error.message}`) - reject(error) - } - const stopChild = (force: boolean) => { - if (child.exitCode !== null || child.signalCode !== null) return - if (!pid) { - failures.push(`${force ? "forced" : "graceful"} stop was not sent because the process PID is unavailable`) - return - } - - const args = ["/PID", String(pid), "/T", ...(force ? ["/F"] : [])] - try { - const result = this.spawnCommand("taskkill.exe", args, { - encoding: "utf8", - timeout: this.stopCommandTimeoutMs, - }) - if (result.status === 0) { - managed.windowsTreeCleanupConfirmed = true - } else { - const detail = result.error?.message || String(result.stderr ?? result.stdout ?? "").trim() || `exit code ${result.status}` - failures.push(`taskkill ${force ? "/T /F" : "/T"} failed: ${detail}`) - } - } catch (error) { - failures.push(`taskkill ${force ? "/T /F" : "/T"} failed: ${error instanceof Error ? error.message : String(error)}`) - } - } - - child.once("exit", onExit) - child.once("error", onError) - if (child.exitCode !== null || child.signalCode !== null) { - if (managed.windowsTreeCleanupConfirmed) { - finish() - } else { - rejectIncompleteCleanup() - } + const outcome = () => managed.windowsTreeCleanupConfirmed + ? true + : new WorkspaceWindowsTreeCleanupIncompleteError(workspaceId, pid, failures) + const stopChild = (force: boolean) => { + if (child.exitCode !== null || child.signalCode !== null) return + if (!pid) { + failures.push(`${force ? "forced" : "graceful"} stop was not sent because the process PID is unavailable`) return } - - escalationTimer = this.scheduleTimeout(() => { - escalationTimer = null - if (settled) return + const args = ["/PID", String(pid), "/T", ...(force ? ["/F"] : [])] + try { + const result = this.spawnCommand("taskkill.exe", args, { encoding: "utf8", timeout: this.stopCommandTimeoutMs }) + if (result.status === 0) managed.windowsTreeCleanupConfirmed = true + else { + const detail = result.error?.message || String(result.stderr ?? result.stdout ?? "").trim() || `exit code ${result.status}` + failures.push(`taskkill ${force ? "/T /F" : "/T"} failed: ${detail}`) + } + } catch (error) { + failures.push(`taskkill ${force ? "/T /F" : "/T"} failed: ${error instanceof Error ? error.message : String(error)}`) + } + } + const totalTimeoutMs = this.gracefulStopTimeoutMs + this.forcedStopTimeoutMs + return this.runBoundedStop(workspaceId, managed, { + start: () => { + if (child.exitCode !== null || child.signalCode !== null) return outcome() + this.logger.debug({ workspaceId, pid }, "Stopping owned Windows workspace wrapper tree") + stopChild(false) + }, + exit: outcome, + error: (error) => { + failures.push(`child process error while stopping: ${error.message}`) + return error + }, + escalate: () => { this.logger.warn({ workspaceId, pid }, "Owned Windows process did not stop after the graceful attempt, escalating") stopChild(true) - }, this.gracefulStopTimeoutMs) + }, + deadline: () => new WorkspaceStopTimeoutError( + workspaceId, pid, totalTimeoutMs, + child.exitCode !== null || child.signalCode !== null + ? "taskkill did not confirm tree cleanup before the owned Windows wrapper exited" + : "the owned Windows wrapper did not emit exit or error after tree termination", + failures, + ), + }) + } - const totalTimeoutMs = this.gracefulStopTimeoutMs + this.forcedStopTimeoutMs - deadlineTimer = this.scheduleTimeout(() => { - deadlineTimer = null - if (settled) return + private runBoundedStop( + workspaceId: string, + managed: ManagedProcess, + actions: { + start: () => true | Error | void + exit: () => true | Error | void + error: (error: Error) => true | Error | void + escalate: () => true | Error | void + deadline: () => true | Error + }, + ): Promise { + const { child } = managed + return new Promise((resolve, reject) => { + let settled = false + const timers: RuntimeTimeout[] = [] + const finish = (outcome: true | Error | void) => { + if (settled || !outcome) return settled = true - cleanup() - reject(new WorkspaceStopTimeoutError( - workspaceId, - pid, - totalTimeoutMs, - child.exitCode !== null || child.signalCode !== null - ? "taskkill did not confirm tree cleanup before the owned Windows wrapper exited" - : "the owned Windows wrapper did not emit exit or error after tree termination", - failures, - )) - }, totalTimeoutMs) - - this.logger.debug({ workspaceId, pid }, "Stopping owned Windows workspace wrapper tree") - stopChild(false) + child.removeListener("exit", onExit) + child.removeListener("error", onError) + for (const timer of timers) this.cancelTimeout(timer) + if (outcome instanceof Error) reject(outcome) + else { + if (this.processes.get(workspaceId) === managed) this.processes.delete(workspaceId) + managed.finalizeExit?.(child.exitCode, child.signalCode) + resolve() + } + } + const onExit = () => finish(actions.exit()) + const onError = (error: Error) => finish(actions.error(error)) + child.once("exit", onExit) + child.on("error", onError) + timers.push(this.scheduleTimeout(() => finish(actions.escalate()), this.gracefulStopTimeoutMs)) + timers.push(this.scheduleTimeout(() => finish(actions.deadline()), this.gracefulStopTimeoutMs + this.forcedStopTimeoutMs)) + finish(actions.start()) }) } diff --git a/packages/server/tsconfig.json b/packages/server/tsconfig.json index e6f63b22..8e9b5ce4 100644 --- a/packages/server/tsconfig.json +++ b/packages/server/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "target": "ES2020", + "target": "ES2021", "module": "ESNext", "moduleResolution": "Bundler", "strict": true, diff --git a/packages/tauri-app/Cargo.lock b/packages/tauri-app/Cargo.lock index 37a59fc0..9e381964 100644 --- a/packages/tauri-app/Cargo.lock +++ b/packages/tauri-app/Cargo.lock @@ -520,6 +520,7 @@ dependencies = [ "tauri-plugin-opener", "tempfile", "url", + "uuid", "webkit2gtk", "webview2-com", "which", diff --git a/packages/tauri-app/src-tauri/Cargo.toml b/packages/tauri-app/src-tauri/Cargo.toml index b897d8cb..d99219a8 100644 --- a/packages/tauri-app/src-tauri/Cargo.toml +++ b/packages/tauri-app/src-tauri/Cargo.toml @@ -28,11 +28,12 @@ dirs = "5" tauri-plugin-opener = "2" tauri-plugin-global-shortcut = "2" url = "2" +uuid = { version = "1", features = ["v4"] } tauri-plugin-notification = "2" [target.'cfg(windows)'.dependencies] webview2-com = "=0.38.2" -windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_Security_Cryptography", "Win32_UI_Shell", "Win32_Security", "Win32_System_JobObjects"] } +windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_Security_Cryptography", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging", "Win32_Security", "Win32_System_JobObjects", "Win32_System_Threading"] } [target.'cfg(target_os = "linux")'.dependencies] webkit2gtk = "2.0.2" diff --git a/packages/tauri-app/src-tauri/src/cert_manager.rs b/packages/tauri-app/src-tauri/src/cert_manager.rs index aea7ec55..c844e641 100644 --- a/packages/tauri-app/src-tauri/src/cert_manager.rs +++ b/packages/tauri-app/src-tauri/src/cert_manager.rs @@ -84,8 +84,7 @@ fn resolve_tls_asset_paths() -> Result { }) } (Some(_), None) | (None, Some(_)) => Err( - "CLI_TLS_KEY and CLI_TLS_CERT must both be set when using custom TLS files" - .to_string(), + "CLI_TLS_KEY and CLI_TLS_CERT must both be set when using custom TLS files".to_string(), ), (None, None) => { let tls_dir = server_tls_dir()?; @@ -108,10 +107,12 @@ fn resolve_server_config_base_dir() -> Result { let lower = raw.trim().to_lowercase(); if lower.ends_with(".yaml") || lower.ends_with(".yml") || lower.ends_with(".json") { - return expanded - .parent() - .map(Path::to_path_buf) - .ok_or_else(|| format!("Failed to determine config base dir from {}", expanded.display())); + return expanded.parent().map(Path::to_path_buf).ok_or_else(|| { + format!( + "Failed to determine config base dir from {}", + expanded.display() + ) + }); } Ok(expanded) @@ -239,8 +240,12 @@ pub fn trust_cert_in_store(cert_der: &[u8]) -> Result<(), String> { "codenomad-server-ca-{}.cer", trusted_marker_file_suffix(cert_der) )); - fs::write(&temp_path, cert_der) - .map_err(|e| format!("Failed to write temporary certificate {}: {e}", temp_path.display()))?; + fs::write(&temp_path, cert_der).map_err(|e| { + format!( + "Failed to write temporary certificate {}: {e}", + temp_path.display() + ) + })?; let keychain_path = resolve_macos_user_keychain()?; @@ -249,9 +254,7 @@ pub fn trust_cert_in_store(cert_der: &[u8]) -> Result<(), String> { command.arg(&keychain_path); let output = command.arg(&temp_path).output().map_err(|e| { - format!( - "Failed to launch macOS security tool to trust the local CA certificate: {e}" - ) + format!("Failed to launch macOS security tool to trust the local CA certificate: {e}") })?; let _ = fs::remove_file(&temp_path); @@ -328,7 +331,8 @@ fn resolve_macos_user_keychain() -> Result { } let home = dirs::home_dir().or_else(|| env::var("HOME").ok().map(PathBuf::from)); - let home = home.ok_or_else(|| "Cannot determine home directory for macOS keychain lookup".to_string())?; + let home = home + .ok_or_else(|| "Cannot determine home directory for macOS keychain lookup".to_string())?; Ok(home.join("Library/Keychains/login.keychain-db")) } @@ -340,8 +344,12 @@ fn macos_cert_is_trusted(cert_der: &[u8]) -> Result { "codenomad-server-ca-verify-{}.cer", trusted_marker_file_suffix(cert_der) )); - fs::write(&temp_path, cert_der) - .map_err(|e| format!("Failed to write temporary certificate {}: {e}", temp_path.display()))?; + fs::write(&temp_path, cert_der).map_err(|e| { + format!( + "Failed to write temporary certificate {}: {e}", + temp_path.display() + ) + })?; let keychain_path = resolve_macos_user_keychain()?; let fingerprint = macos_cert_sha256(&temp_path)?; @@ -353,7 +361,9 @@ fn macos_cert_is_trusted(cert_der: &[u8]) -> Result { if !find_output.status.success() { let _ = fs::remove_file(&temp_path); - let stderr = String::from_utf8_lossy(&find_output.stderr).trim().to_string(); + let stderr = String::from_utf8_lossy(&find_output.stderr) + .trim() + .to_string(); let detail = if stderr.is_empty() { format!("security exited with status {}", find_output.status) } else { @@ -376,7 +386,9 @@ fn macos_cert_is_trusted(cert_der: &[u8]) -> Result { .args(["-k"]) .arg(&keychain_path) .output() - .map_err(|e| format!("Failed to verify macOS trust for the local CodeNomad CA certificate: {e}"))?; + .map_err(|e| { + format!("Failed to verify macOS trust for the local CodeNomad CA certificate: {e}") + })?; let _ = fs::remove_file(&temp_path); Ok(verify_output.status.success()) diff --git a/packages/tauri-app/src-tauri/src/cli_manager.rs b/packages/tauri-app/src-tauri/src/cli_manager.rs index 4523c60c..78c42dd1 100644 --- a/packages/tauri-app/src-tauri/src/cli_manager.rs +++ b/packages/tauri-app/src-tauri/src/cli_manager.rs @@ -19,7 +19,7 @@ use std::net::TcpStream; use std::os::unix::process::CommandExt; use std::path::PathBuf; use std::process::{Child, Command, Stdio}; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::Arc; use std::thread; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; @@ -33,14 +33,17 @@ use std::os::windows::process::CommandExt; use windows_sys::Win32::Foundation::{CloseHandle, HANDLE}; #[cfg(windows)] use windows_sys::Win32::System::JobObjects::{ - AssignProcessToJobObject, CreateJobObjectW, JobObjectExtendedLimitInformation, - SetInformationJobObject, JOBOBJECT_EXTENDED_LIMIT_INFORMATION, - JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, + AssignProcessToJobObject, CreateJobObjectW, JobObjectBasicAccountingInformation, + JobObjectExtendedLimitInformation, QueryInformationJobObject, SetInformationJobObject, + TerminateJobObject, JOBOBJECT_BASIC_ACCOUNTING_INFORMATION, + JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, }; #[cfg(windows)] const CREATE_NO_WINDOW: u32 = 0x08000000; const MISSING_NODE_PREFIX: &str = "CODENOMAD_MISSING_NODE:"; +#[cfg(windows)] +const CLI_SHUTDOWN_COMMAND: &[u8] = b"codenomad:shutdown\n"; #[cfg(windows)] #[derive(Debug)] @@ -96,6 +99,36 @@ impl WindowsJobObject { Ok(()) } + + fn active_processes(&self) -> anyhow::Result { + let mut info: JOBOBJECT_BASIC_ACCOUNTING_INFORMATION = unsafe { zeroed() }; + let ok = unsafe { + QueryInformationJobObject( + self.handle, + JobObjectBasicAccountingInformation, + &mut info as *mut _ as *mut c_void, + size_of::() as u32, + std::ptr::null_mut(), + ) + }; + if ok == 0 { + return Err(anyhow::anyhow!( + "QueryInformationJobObject failed: {}", + std::io::Error::last_os_error() + )); + } + Ok(info.ActiveProcesses) + } + + fn terminate(&self) -> anyhow::Result<()> { + if unsafe { TerminateJobObject(self.handle, 1) } == 0 { + return Err(anyhow::anyhow!( + "TerminateJobObject failed: {}", + std::io::Error::last_os_error() + )); + } + Ok(()) + } } #[cfg(windows)] @@ -140,9 +173,11 @@ fn workspace_root() -> Option { const SESSION_COOKIE_NAME_PREFIX: &str = "codenomad_session"; +#[cfg(not(windows))] const CLI_STOP_GRACE_SECS: u64 = 30; #[cfg(windows)] const CLI_WINDOWS_FORCE_GRACE_MS: u64 = 2_000; +const CLI_FORCE_CONFIRM_GRACE_SECS: u64 = 2; #[cfg(unix)] fn configure_posix_process_group(command: &mut Command) { @@ -159,32 +194,218 @@ fn configure_posix_process_group(command: &mut Command) { } #[cfg(windows)] -fn kill_process_tree_windows(pid: u32, force: bool) -> bool { - let mut args = vec!["/PID".to_string(), pid.to_string(), "/T".to_string()]; - if force { - args.push("/F".to_string()); +const WINDOWS_CLI_LAUNCHER_ARG: &str = "--codenomad-internal-cli-launcher"; + +#[cfg(windows)] +fn wait_for_windows_cli_launch_gate(mut reader: impl Read) -> bool { + let mut gate = [0_u8; 1]; + reader.read_exact(&mut gate).is_ok() && gate[0] == 1 +} + +#[cfg(windows)] +fn relay_windows_cli_control( + mut reader: impl Read, + mut writer: impl Write, +) -> std::io::Result { + std::io::copy(&mut reader, &mut writer) +} + +#[cfg(windows)] +fn request_windows_cli_shutdown(writer: &mut impl Write) -> std::io::Result<()> { + writer.write_all(CLI_SHUTDOWN_COMMAND)?; + writer.flush() +} + +#[cfg(windows)] +pub(crate) fn run_windows_cli_launcher_if_requested() -> Option { + let mut args = std::env::args_os(); + args.next(); + if args.next()?.to_str()? != WINDOWS_CLI_LAUNCHER_ARG { + return None; + } + let Some(program) = args.next() else { + return Some(1); + }; + if !wait_for_windows_cli_launch_gate(std::io::stdin().lock()) { + return Some(1); } - let mut command = Command::new("taskkill"); - command.args(&args); + let mut command = Command::new(program); + command.args(args); + command.stdin(Stdio::piped()); configure_spawn(&mut command); + let Ok(mut child) = command.spawn() else { + return Some(1); + }; + if let Some(mut node_stdin) = child.stdin.take() { + thread::spawn(move || { + let stdin = std::io::stdin(); + let _ = relay_windows_cli_control(stdin.lock(), &mut node_stdin); + }); + } + Some( + child + .wait() + .ok() + .and_then(|status| status.code()) + .unwrap_or(1), + ) +} - match command.output() { - Ok(output) => { - if output.status.success() { - return true; - } +#[cfg(windows)] +fn windows_containment_confirmed(child_exited: bool, job_active_processes: Option) -> bool { + child_exited && job_active_processes == Some(0) +} - // If the PID is already gone, treat it as success. - let stdout = String::from_utf8_lossy(&output.stdout).to_lowercase(); - let stderr = String::from_utf8_lossy(&output.stderr).to_lowercase(); - let combined = format!("{stdout}\n{stderr}"); - combined.contains("not found") || combined.contains("no running instance") +fn wait_for_termination( + timeout: Duration, + poll_interval: Duration, + mut is_terminated: impl FnMut() -> anyhow::Result, +) -> anyhow::Result<()> { + let deadline = Instant::now() + timeout; + loop { + if is_terminated()? { + return Ok(()); } - Err(_) => false, + if Instant::now() >= deadline { + return Err(TerminationTimeout.into()); + } + thread::sleep(poll_interval.min(deadline.saturating_duration_since(Instant::now()))); } } -fn navigate_main(app: &AppHandle, url: &str) { + +#[derive(Debug)] +struct TerminationTimeout; + +impl std::fmt::Display for TerminationTimeout { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("process termination was not confirmed") + } +} + +impl std::error::Error for TerminationTimeout {} + +fn containment_is_complete(root_exited: bool, descendants_gone: bool) -> bool { + root_exited && descendants_gone +} + +#[cfg(unix)] +fn process_group_is_gone(pid: u32) -> anyhow::Result { + if unsafe { libc::kill(-(pid as i32), 0) } == 0 { + return Ok(false); + } + match std::io::Error::last_os_error().raw_os_error() { + Some(libc::ESRCH) => Ok(true), + Some(libc::EPERM) => Ok(false), + _ => Err(anyhow::anyhow!( + "failed to inspect CLI process group {pid}: {}", + std::io::Error::last_os_error() + )), + } +} + +fn stop_child( + child: &mut Child, + #[cfg(windows)] job: Option<&WindowsJobObject>, +) -> anyhow::Result<()> { + let pid = child.id(); + #[cfg(windows)] + if job.is_none() { + if child.try_wait()?.is_none() { + let _ = child.kill(); + let _ = wait_for_termination( + Duration::from_secs(CLI_FORCE_CONFIRM_GRACE_SECS), + Duration::from_millis(25), + || Ok(child.try_wait()?.is_some()), + ); + } + return Err(anyhow::anyhow!( + "CLI pid={pid} descendant cleanup cannot be confirmed without a Windows job" + )); + } + + #[cfg(unix)] + unsafe { + if libc::kill(-(pid as i32), libc::SIGTERM) != 0 { + let _ = libc::kill(pid as i32, libc::SIGTERM); + } + } + #[cfg(windows)] + let graceful_timeout = Duration::from_millis(CLI_WINDOWS_FORCE_GRACE_MS); + #[cfg(not(windows))] + let graceful_timeout = Duration::from_secs(CLI_STOP_GRACE_SECS); + #[cfg(windows)] + if child.try_wait()?.is_none() { + match child.stdin.take() { + Some(mut control) => { + if let Err(err) = request_windows_cli_shutdown(&mut control) { + log_line(&format!( + "failed to request graceful CLI shutdown pid={pid}: {err}" + )); + } + } + None => log_line(&format!("CLI control channel is unavailable pid={pid}")), + } + } + let graceful = wait_for_termination(graceful_timeout, Duration::from_millis(50), || { + let child_exited = child.try_wait()?.is_some(); + #[cfg(unix)] + return Ok(child_exited && process_group_is_gone(pid)?); + #[cfg(windows)] + return Ok(windows_containment_confirmed( + child_exited, + job.map(WindowsJobObject::active_processes).transpose()?, + )); + #[cfg(not(any(unix, windows)))] + Ok(child_exited) + }); + match graceful { + Ok(()) => return Ok(()), + Err(err) if !err.is::() => { + return Err(anyhow::anyhow!( + "failed to inspect CLI pid={pid} termination: {err}" + )) + } + Err(_) => {} + } + + log_line(&format!("CLI shutdown timed out; escalating pid={pid}")); + #[cfg(unix)] + unsafe { + if libc::kill(-(pid as i32), libc::SIGKILL) != 0 { + let _ = libc::kill(pid as i32, libc::SIGKILL); + } + } + #[cfg(windows)] + { + job.expect("Windows job checked above").terminate()?; + } + #[cfg(not(any(unix, windows)))] + child.kill()?; + + wait_for_termination( + Duration::from_secs(CLI_FORCE_CONFIRM_GRACE_SECS), + Duration::from_millis(25), + || { + let child_exited = child.try_wait()?.is_some(); + #[cfg(unix)] + return Ok(child_exited && process_group_is_gone(pid)?); + #[cfg(windows)] + return Ok(windows_containment_confirmed( + child_exited, + job.map(WindowsJobObject::active_processes).transpose()?, + )); + #[cfg(not(any(unix, windows)))] + Ok(child_exited) + }, + ) + .map_err(|err| anyhow::anyhow!("CLI pid={pid} termination was not confirmed: {err}")) +} + +fn navigate_main(manager: &CliProcessManager, generation: u64, app: &AppHandle, url: &str) { + if !manager.is_current_generation(generation) { + return; + } if app.webview_windows().contains_key("main") { let final_url = augment_launch_url(url); let mut display = final_url.clone(); @@ -193,17 +414,24 @@ fn navigate_main(app: &AppHandle, url: &str) { } log_line(&format!("navigating main to {display}")); if let Ok(parsed) = Url::parse(&final_url) { - crate::client_state::before_main_window_navigation( + let current = manager.clone(); + let navigate = manager.clone(); + crate::client_state::before_main_window_navigation_if( app, crate::client_state::NavigationKind::Cli, Some(parsed.clone()), + move || current.is_current_generation(generation), move |app| { - let window = app - .get_webview_window("main") - .ok_or_else(|| "main window not found for CLI navigation".to_string())?; - window - .navigate(parsed) - .map_err(|err| format!("failed to navigate main window to CLI URL: {err}")) + navigate + .with_current_generation(generation, || { + let window = app.get_webview_window("main").ok_or_else(|| { + "main window not found for CLI navigation".to_string() + })?; + window.navigate(parsed).map_err(|err| { + format!("failed to navigate main window to CLI URL: {err}") + }) + }) + .unwrap_or_else(|| Err("discarded stale CLI navigation".to_string())) }, ); } else { @@ -502,10 +730,11 @@ pub struct CliProcessManager { child: Arc>>, #[cfg(windows)] job: Arc>>, - ready: Arc, bootstrap_token: Arc>>, session_cookie: Arc>>, auth_cookie_name: Arc>>, + lifecycle: Arc>, + generation: Arc, } impl CliProcessManager { @@ -515,17 +744,19 @@ impl CliProcessManager { child: Arc::new(Mutex::new(None)), #[cfg(windows)] job: Arc::new(Mutex::new(None)), - ready: Arc::new(AtomicBool::new(false)), bootstrap_token: Arc::new(Mutex::new(None)), session_cookie: Arc::new(Mutex::new(None)), auth_cookie_name: Arc::new(Mutex::new(None)), + lifecycle: Arc::new(Mutex::new(())), + generation: Arc::new(AtomicU64::new(0)), } } pub fn start(&self, app: AppHandle, dev: bool) -> anyhow::Result<()> { + let _lifecycle = self.lifecycle.lock(); + let generation = self.advance_generation(); log_line(&format!("start requested (dev={dev})")); - self.stop()?; - self.ready.store(false, Ordering::SeqCst); + self.stop_tracked_child()?; *self.bootstrap_token.lock() = None; *self.session_cookie.lock() = None; *self.auth_cookie_name.lock() = None; @@ -539,35 +770,11 @@ impl CliProcessManager { } Self::emit_status(&app, &self.status.lock()); - let status_arc = self.status.clone(); - let child_arc = self.child.clone(); - #[cfg(windows)] - let job_arc = self.job.clone(); - let ready_flag = self.ready.clone(); - let token_arc = self.bootstrap_token.clone(); - let session_cookie_arc = self.session_cookie.clone(); - let auth_cookie_name_arc = self.auth_cookie_name.clone(); + let manager = self.clone(); thread::spawn(move || { - if let Err(err) = Self::spawn_cli( - app.clone(), - status_arc.clone(), - child_arc, - #[cfg(windows)] - job_arc, - ready_flag, - token_arc, - session_cookie_arc, - auth_cookie_name_arc, - dev, - ) { + if let Err(err) = Self::spawn_cli(manager.clone(), app.clone(), generation, dev) { log_line(&format!("cli spawn failed: {err}")); - let mut locked = status_arc.lock(); - locked.state = CliState::Error; - locked.error = Some(err.to_string()); - let snapshot = locked.clone(); - drop(locked); - let _ = app.emit("cli:error", json!({"message": err.to_string()})); - let _ = app.emit("cli:status", snapshot); + manager.publish_error(&app, generation, err.to_string()); } }); @@ -575,77 +782,73 @@ impl CliProcessManager { } pub fn stop(&self) -> anyhow::Result<()> { - #[cfg(windows)] - let _job = self.job.lock().take(); + let _lifecycle = self.lifecycle.lock(); + self.advance_generation(); + self.stop_tracked_child()?; + self.reset_stopped_status(); + Ok(()) + } - let mut child_opt = self.child.lock(); - if let Some(mut child) = child_opt.take() { - log_line(&format!("stopping CLI pid={}", child.id())); - #[cfg(unix)] - unsafe { - let pid = child.id() as i32; - // Prefer signaling the process group to avoid orphaning children - // when the CLI was launched via a wrapper shell. - let group_res = libc::kill(-pid, libc::SIGTERM); - if group_res != 0 { - let _ = libc::kill(pid, libc::SIGTERM); + fn advance_generation(&self) -> u64 { + self.generation.fetch_add(1, Ordering::SeqCst) + 1 + } + + fn is_current_generation(&self, generation: u64) -> bool { + self.generation.load(Ordering::SeqCst) == generation + } + + fn with_current_generation( + &self, + generation: u64, + operation: impl FnOnce() -> T, + ) -> Option { + let _lifecycle = self.lock_current_generation(generation)?; + Some(operation()) + } + + fn lock_current_generation(&self, generation: u64) -> Option> { + let lifecycle = self.lifecycle.lock(); + self.is_current_generation(generation).then_some(lifecycle) + } + + fn stop_tracked_child(&self) -> anyhow::Result<()> { + let Some(mut child) = self.child.lock().take() else { + #[cfg(windows)] + if let Some(job) = self.job.lock().take() { + let result = job.terminate().and_then(|()| { + wait_for_termination( + Duration::from_secs(CLI_FORCE_CONFIRM_GRACE_SECS), + Duration::from_millis(25), + || Ok(job.active_processes()? == 0), + ) + }); + if let Err(err) = result { + *self.job.lock() = Some(job); + return Err(err); } } + return Ok(()); + }; + #[cfg(windows)] + let job = self.job.lock().take(); + log_line(&format!("stopping CLI pid={}", child.id())); + let result = stop_child( + &mut child, + #[cfg(windows)] + job.as_ref(), + ); + if let Err(err) = result { + *self.child.lock() = Some(child); #[cfg(windows)] { - let _ = kill_process_tree_windows(child.id(), false); + *self.job.lock() = job; } - - let start = Instant::now(); - loop { - match child.try_wait() { - Ok(Some(_)) => break, - Ok(None) => { - #[cfg(windows)] - if start.elapsed() > Duration::from_millis(CLI_WINDOWS_FORCE_GRACE_MS) { - log_line(&format!( - "regular Windows shutdown still running after {}ms; escalating pid={}", - CLI_WINDOWS_FORCE_GRACE_MS, - child.id() - )); - if !kill_process_tree_windows(child.id(), true) { - let _ = child.kill(); - } - break; - } - - if start.elapsed() > Duration::from_secs(CLI_STOP_GRACE_SECS) { - log_line(&format!( - "stop timed out after {}s; sending SIGKILL pid={}", - CLI_STOP_GRACE_SECS, - child.id() - )); - #[cfg(unix)] - unsafe { - let pid = child.id() as i32; - let group_res = libc::kill(-pid, libc::SIGKILL); - if group_res != 0 { - let _ = libc::kill(pid, libc::SIGKILL); - } - } - #[cfg(windows)] - { - if !kill_process_tree_windows(child.id(), true) { - let _ = child.kill(); - } - } - break; - } - thread::sleep(Duration::from_millis(50)); - } - Err(_) => break, - } - } - } else { - #[cfg(windows)] - log_line("tracked CLI process already exited; dropping Windows job object to reap descendants"); + return Err(err); } + Ok(()) + } + fn reset_stopped_status(&self) { let mut status = self.status.lock(); status.state = CliState::Stopped; status.pid = None; @@ -653,8 +856,18 @@ impl CliProcessManager { status.url = None; status.error = None; *self.session_cookie.lock() = None; + } - Ok(()) + fn publish_error(&self, app: &AppHandle, generation: u64, message: String) { + self.with_current_generation(generation, || { + let mut status = self.status.lock(); + status.state = CliState::Error; + status.error = Some(message.clone()); + let snapshot = status.clone(); + drop(status); + let _ = app.emit("cli:error", json!({"message": message})); + let _ = app.emit("cli:status", snapshot); + }); } pub fn status(&self) -> CliStatus { @@ -682,16 +895,14 @@ impl CliProcessManager { } fn spawn_cli( + manager: CliProcessManager, app: AppHandle, - status: Arc>, - child_holder: Arc>>, - #[cfg(windows)] job_holder: Arc>>, - ready: Arc, - bootstrap_token: Arc>>, - session_cookie: Arc>>, - auth_cookie_name_holder: Arc>>, + generation: u64, dev: bool, ) -> anyhow::Result<()> { + let Some(lifecycle) = manager.lock_current_generation(generation) else { + return Ok(()); + }; log_line("resolving CLI entry"); let resolution = CliEntry::resolve(&app, dev)?; let host = resolve_listening_host(); @@ -700,7 +911,6 @@ impl CliProcessManager { resolution.runner, resolution.entry, host )); let auth_cookie_name = Arc::new(generate_auth_cookie_name()); - *auth_cookie_name_holder.lock() = Some(auth_cookie_name.as_str().to_string()); let args = resolution.build_args(dev, &host, auth_cookie_name.as_str()); log_line(&format!("CLI args: {:?}", args)); if dev { @@ -736,7 +946,7 @@ impl CliProcessManager { }) }; - let child = match &command_info { + let mut child = match &command_info { ShellCommandType::UserShell(cmd) => { log_line(&format!("spawn command: {} {:?}", cmd.shell, cmd.args)); let mut c = Command::new(&cmd.shell); @@ -756,9 +966,23 @@ impl CliProcessManager { } ShellCommandType::Direct(cmd) => { log_line(&format!("spawn command: {} {:?}", cmd.program, cmd.args)); + #[cfg(windows)] + let mut c = { + // The launcher cannot create Node until its stdin gate opens. Assigning + // the blocked launcher first makes every later descendant inherit the job. + let mut launcher = Command::new(std::env::current_exe()?); + launcher + .arg(WINDOWS_CLI_LAUNCHER_ARG) + .arg(&cmd.program) + .args(&cmd.args) + .stdin(Stdio::piped()); + launcher + }; + #[cfg(not(windows))] let mut c = Command::new(&cmd.program); - c.args(&cmd.args) - .env("ELECTRON_RUN_AS_NODE", "1") + #[cfg(not(windows))] + c.args(&cmd.args); + c.env("ELECTRON_RUN_AS_NODE", "1") .stdout(Stdio::piped()) .stderr(Stdio::piped()); configure_spawn(&mut c); @@ -774,201 +998,193 @@ impl CliProcessManager { let pid = child.id(); log_line(&format!("spawned pid={pid}")); #[cfg(windows)] - match WindowsJobObject::create().and_then(|job| { + let job = match WindowsJobObject::create().and_then(|job| { job.assign_child(&child)?; Ok(job) }) { Ok(job) => { log_line(&format!("attached pid={pid} to Windows job object")); - *job_holder.lock() = Some(job); + job } Err(err) => { - log_line(&format!( - "failed to attach pid={pid} to Windows job object; falling back to taskkill-only cleanup: {err}" + let _ = child.kill(); + let _ = child.wait(); + return Err(anyhow::anyhow!( + "failed to contain blocked CLI launcher pid={pid}; launch cancelled: {err}" )); } - } - - { - let mut locked = status.lock(); - locked.pid = Some(pid); - } - Self::emit_status(&app, &status.lock()); - - { - let mut holder = child_holder.lock(); - *holder = Some(child); - } - - let child_clone = child_holder.clone(); - let status_clone = status.clone(); - let app_clone = app.clone(); - let ready_clone = ready.clone(); - let token_clone = bootstrap_token.clone(); - let session_cookie_clone = session_cookie.clone(); - let auth_cookie_name_clone = auth_cookie_name.clone(); - - thread::spawn(move || { - let stdout = child_clone - .lock() - .as_mut() - .and_then(|c| c.stdout.take()) - .map(BufReader::new); - let stderr = child_clone - .lock() - .as_mut() - .and_then(|c| c.stderr.take()) - .map(BufReader::new); - - if let Some(reader) = stdout { - let app = app_clone.clone(); - let status = status_clone.clone(); - let ready = ready_clone.clone(); - let token = token_clone.clone(); - let session_cookie = session_cookie_clone.clone(); - let auth_cookie_name = auth_cookie_name_clone.clone(); - thread::spawn(move || { - Self::process_stream( - reader, - "stdout", - &app, - &status, - &ready, - &token, - &session_cookie, - auth_cookie_name.as_str(), - ); - }); - } - - if let Some(reader) = stderr { - let app = app_clone.clone(); - let status = status_clone.clone(); - let ready = ready_clone.clone(); - let token = token_clone.clone(); - let session_cookie = session_cookie_clone.clone(); - let auth_cookie_name = auth_cookie_name_clone.clone(); - thread::spawn(move || { - Self::process_stream( - reader, - "stderr", - &app, - &status, - &ready, - &token, - &session_cookie, - auth_cookie_name.as_str(), - ); - }); - } - }); - - let app_clone = app.clone(); - let status_clone = status.clone(); - let ready_clone = ready.clone(); - let child_holder_clone = child_holder.clone(); + }; #[cfg(windows)] - let job_holder_clone = job_holder.clone(); - thread::spawn(move || { - let timeout = Duration::from_secs(60); - thread::sleep(timeout); - if ready_clone.load(Ordering::SeqCst) { - return; - } - let mut locked = status_clone.lock(); - locked.state = CliState::Error; - locked.error = Some("CLI did not start in time".to_string()); - log_line("timeout waiting for CLI readiness"); - if let Some(child) = child_holder_clone.lock().as_mut() { - #[cfg(unix)] - unsafe { - let pid = child.id() as i32; - let group_res = libc::kill(-pid, libc::SIGKILL); - if group_res != 0 { - let _ = libc::kill(pid, libc::SIGKILL); - } - } - #[cfg(windows)] - { - if !kill_process_tree_windows(child.id(), true) { - let _ = child.kill(); - } - } - #[cfg(not(any(unix, windows)))] - { - let _ = child.kill(); - } - } - let _ = app_clone.emit("cli:error", json!({"message": "CLI did not start in time"})); - Self::emit_status(&app_clone, &locked); - }); - - let status_clone = status.clone(); - let app_clone = app.clone(); - thread::spawn(move || { - // Do not hold the child mutex while waiting for process exit. - // Holding the lock across `wait()` deadlocks `stop()`, which needs the - // same lock to send SIGTERM/SIGKILL when the user quits the app. - let code = loop { - let maybe_exited = { - let mut guard = child_holder.lock(); - if guard.is_none() { - return; - } - match guard - .as_mut() - .and_then(|child| child.try_wait().ok().flatten()) - { - Some(status) => { - // Drop the handle after the process exits so other callers - // don't attempt to stop/kill a finished process. - *guard = None; - #[cfg(windows)] - { - let _ = job_holder_clone.lock().take(); - } - Some(status) - } - None => None, - } - }; - - if let Some(status) = maybe_exited { - break Some(status); - } - thread::sleep(Duration::from_millis(100)); - }; - - let mut locked = status_clone.lock(); - let failed = locked.state != CliState::Ready; - let err_msg = if failed { - Some(match code { - Some(status) => format!("CLI exited early: {status}"), - None => "CLI exited early".to_string(), - }) - } else { - None - }; - - if failed { - locked.state = CliState::Error; - if locked.error.is_none() { - locked.error = err_msg.clone(); - } - log_line(&format!( - "cli process exited before ready: {:?}", - locked.error + { + let gate_result = child + .stdin + .as_mut() + .ok_or_else(|| anyhow::anyhow!("blocked CLI launcher stdin is unavailable")) + .and_then(|gate| { + gate.write_all(&[1])?; + gate.flush().map_err(anyhow::Error::from) + }); + if let Err(err) = gate_result { + let _ = job.terminate(); + let _ = child.kill(); + let _ = child.wait(); + return Err(anyhow::anyhow!( + "failed to release contained CLI launcher pid={pid}: {err}" )); - let _ = app_clone.emit( - "cli:error", - json!({"message": locked.error.clone().unwrap_or_default()}), - ); - } else { - locked.state = CliState::Stopped; - log_line("cli process stopped cleanly"); } + } - Self::emit_status(&app_clone, &locked); + let stdout = child.stdout.take().map(BufReader::new); + let stderr = child.stderr.take().map(BufReader::new); + debug_assert!(manager.child.lock().is_none()); + *manager.auth_cookie_name.lock() = Some(auth_cookie_name.as_str().to_string()); + manager.status.lock().pid = Some(pid); + *manager.child.lock() = Some(child); + #[cfg(windows)] + { + *manager.job.lock() = Some(job); + } + Self::emit_status(&app, &manager.status.lock()); + drop(lifecycle); + + let ready = Arc::new(AtomicBool::new(false)); + if let Some(reader) = stdout { + let app = app.clone(); + let manager = manager.clone(); + let ready = ready.clone(); + let auth_cookie_name = auth_cookie_name.clone(); + thread::spawn(move || { + Self::process_stream( + reader, + "stdout", + &manager, + generation, + &app, + &ready, + auth_cookie_name.as_str(), + ); + }); + } + if let Some(reader) = stderr { + let app = app.clone(); + let manager = manager.clone(); + let ready = ready.clone(); + let auth_cookie_name = auth_cookie_name.clone(); + thread::spawn(move || { + Self::process_stream( + reader, + "stderr", + &manager, + generation, + &app, + &ready, + auth_cookie_name.as_str(), + ); + }); + } + + { + let manager = manager.clone(); + let app = app.clone(); + let ready = ready.clone(); + thread::spawn(move || { + thread::sleep(Duration::from_secs(60)); + let _lifecycle = manager.lifecycle.lock(); + if !manager.is_current_generation(generation) || ready.load(Ordering::SeqCst) { + return; + } + manager.advance_generation(); + log_line("timeout waiting for CLI readiness"); + let stop_error = manager.stop_tracked_child().err(); + let message = stop_error.map_or_else( + || "CLI did not start in time".to_string(), + |err| format!("CLI did not start in time; cleanup failed: {err}"), + ); + let mut status = manager.status.lock(); + status.state = CliState::Error; + status.error = Some(message.clone()); + let snapshot = status.clone(); + drop(status); + let _ = app.emit("cli:error", json!({"message": message})); + let _ = app.emit("cli:status", snapshot); + }); + } + + thread::spawn(move || loop { + enum Poll { + Running, + Exited(std::process::ExitStatus), + Failed(String), + } + let poll = { + let _lifecycle = manager.lifecycle.lock(); + if !manager.is_current_generation(generation) { + return; + } + let mut child = manager.child.lock(); + let Some(tracked) = child.as_mut() else { + return; + }; + match tracked.try_wait() { + Ok(Some(status)) => { + #[cfg(unix)] + let group_is_gone = process_group_is_gone(pid).map_err(|err| { + format!("failed to inspect exited CLI pid={pid}: {err}") + }); + #[cfg(windows)] + let group_is_gone = match manager.job.lock().as_ref() { + Some(job) => job.active_processes().map(|active| active == 0), + None => Ok(false), + }; + #[cfg(not(any(unix, windows)))] + let group_is_gone: anyhow::Result = Ok(true); + match group_is_gone { + Ok(gone) if containment_is_complete(true, gone) => { + *child = None; + #[cfg(windows)] + { + manager.job.lock().take(); + } + Poll::Exited(status) + } + // The root may be only a launcher/wrapper. Keep ownership and + // monitoring until the process group/job is actually empty. + Ok(_) => Poll::Running, + Err(err) => Poll::Failed(format!( + "failed to inspect exited CLI pid={pid}: {err}" + )), + } + } + Ok(None) => Poll::Running, + Err(err) => Poll::Failed(format!("failed to inspect CLI pid={pid}: {err}")), + } + }; + match poll { + Poll::Running => thread::sleep(Duration::from_millis(100)), + Poll::Failed(message) => { + manager.publish_error(&app, generation, message); + return; + } + Poll::Exited(code) => { + manager.with_current_generation(generation, || { + let mut status = manager.status.lock(); + if status.state != CliState::Ready { + status.state = CliState::Error; + if status.error.is_none() { + status.error = Some(format!("CLI exited early: {code}")); + } + let _ = app.emit( + "cli:error", + json!({"message": status.error.clone().unwrap_or_default()}), + ); + } else { + status.state = CliState::Stopped; + } + Self::emit_status(&app, &status); + }); + return; + } + } }); Ok(()) @@ -977,11 +1193,10 @@ impl CliProcessManager { fn process_stream( mut reader: R, stream: &str, + manager: &CliProcessManager, + generation: u64, app: &AppHandle, - status: &Arc>, ready: &Arc, - bootstrap_token: &Arc>>, - session_cookie: &Arc>>, auth_cookie_name: &str, ) { let mut buffer = String::new(); @@ -994,15 +1209,20 @@ impl CliProcessManager { match reader.read_line(&mut buffer) { Ok(0) => break, Ok(_) => { + if !manager.is_current_generation(generation) { + break; + } let line = buffer.trim_end(); if !line.is_empty() { if line.starts_with(token_prefix) { let token = line.trim_start_matches(token_prefix).trim(); if !token.is_empty() { - let mut guard = bootstrap_token.lock(); - if guard.is_none() { - *guard = Some(token.to_string()); - } + manager.with_current_generation(generation, || { + let mut guard = manager.bootstrap_token.lock(); + if guard.is_none() { + *guard = Some(token.to_string()); + } + }); } continue; } @@ -1014,13 +1234,15 @@ impl CliProcessManager { } if let Some(node_binary) = line.strip_prefix(MISSING_NODE_PREFIX) { - let mut locked = status.lock(); - if locked.error.is_none() { - locked.error = Some(format!( - "Node binary '{}' not found in the desktop shell environment. CodeNomad desktop currently requires Node.js installed on the system, or set NODE_BINARY to a valid runtime path.", - node_binary.trim() - )); - } + manager.with_current_generation(generation, || { + let mut locked = manager.status.lock(); + if locked.error.is_none() { + locked.error = Some(format!( + "Node binary '{}' not found in the desktop shell environment. CodeNomad desktop currently requires Node.js installed on the system, or set NODE_BINARY to a valid runtime path.", + node_binary.trim() + )); + } + }); continue; } @@ -1030,11 +1252,10 @@ impl CliProcessManager { .map(|m| m.as_str().to_string()) { Self::mark_ready( + manager, + generation, app, - status, ready, - bootstrap_token, - session_cookie, auth_cookie_name, url, ); @@ -1048,62 +1269,78 @@ impl CliProcessManager { } fn mark_ready( + manager: &CliProcessManager, + generation: u64, app: &AppHandle, - status: &Arc>, ready: &Arc, - bootstrap_token: &Arc>>, - session_cookie: &Arc>>, auth_cookie_name: &str, base_url: String, ) { - ready.store(true, Ordering::SeqCst); + if ready.swap(true, Ordering::SeqCst) { + return; + } let port = Url::parse(&base_url) .ok() .and_then(|u| u.port_or_known_default()) .map(|p| p as u16); - let mut locked = status.lock(); - locked.port = port; - locked.url = Some(base_url.clone()); - locked.state = CliState::Ready; - locked.error = None; + let token = manager + .with_current_generation(generation, || { + let mut locked = manager.status.lock(); + locked.port = port; + locked.url = Some(base_url.clone()); + locked.state = CliState::Ready; + locked.error = None; + manager.bootstrap_token.lock().take() + }) + .flatten(); + if !manager.is_current_generation(generation) { + return; + } log_line(&format!("cli ready on {base_url}")); - let token = bootstrap_token.lock().take(); - if let Some(token) = token { // Token exchange is only implemented for loopback HTTP. If localUrl is HTTPS, // skip the exchange and let the user authenticate normally. let scheme = Url::parse(&base_url).ok().map(|u| u.scheme().to_string()); if scheme.as_deref() != Some("http") { - navigate_main(app, &base_url); + navigate_main(manager, generation, app, &base_url); } else { match exchange_bootstrap_token(&base_url, &token, &auth_cookie_name) { Ok(Some(session_id)) => { - if let Err(err) = + let cookie_result = manager.with_current_generation(generation, || { set_session_cookie(app, &base_url, &auth_cookie_name, &session_id) - { + }); + if cookie_result.is_none() { + return; + } + if let Err(err) = cookie_result.unwrap() { log_line(&format!("failed to set session cookie: {err}")); - navigate_main(app, &format!("{base_url}/login")); + navigate_main(manager, generation, app, &format!("{base_url}/login")); } else { - *session_cookie.lock() = Some(session_id.clone()); - navigate_main(app, &base_url); + manager.with_current_generation(generation, || { + *manager.session_cookie.lock() = Some(session_id.clone()); + }); + navigate_main(manager, generation, app, &base_url); } } Ok(None) => { log_line("bootstrap token exchange failed (invalid token)"); - navigate_main(app, &format!("{base_url}/login")); + navigate_main(manager, generation, app, &format!("{base_url}/login")); } Err(err) => { log_line(&format!("bootstrap token exchange failed: {err}")); - navigate_main(app, &format!("{base_url}/login")); + navigate_main(manager, generation, app, &format!("{base_url}/login")); } } } } else { - navigate_main(app, &base_url); + navigate_main(manager, generation, app, &base_url); } - let _ = app.emit("cli:ready", locked.clone()); - Self::emit_status(app, &locked); + manager.with_current_generation(generation, || { + let status = manager.status.lock().clone(); + let _ = app.emit("cli:ready", status.clone()); + Self::emit_status(app, &status); + }); } fn emit_status(app: &AppHandle, status: &CliStatus) { @@ -1501,4 +1738,140 @@ mod tests { std::env::remove_var("CODENOMAD_UI_LAUNCH_QUERY"); assert_eq!(augmented, "http://127.0.0.1:3000?existing=true&debug=true"); } + + #[test] + fn stale_generation_cannot_publish_status() { + let manager = CliProcessManager::new(); + let first = manager.advance_generation(); + manager.with_current_generation(first, || { + manager.status.lock().state = CliState::Starting; + }); + let second = manager.advance_generation(); + + assert!(manager + .with_current_generation(first, || { + manager.status.lock().state = CliState::Ready; + }) + .is_none()); + assert_eq!(manager.status().state, CliState::Starting); + assert!(manager + .with_current_generation(second, || { + manager.status.lock().state = CliState::Ready; + }) + .is_some()); + assert_eq!(manager.status().state, CliState::Ready); + } + + #[test] + fn stop_waits_for_an_authorized_spawn_section() { + let manager = CliProcessManager::new(); + let generation = manager.advance_generation(); + let worker_manager = manager.clone(); + let (authorized_tx, authorized_rx) = std::sync::mpsc::channel(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + let worker = std::thread::spawn(move || { + let _lifecycle = worker_manager.lock_current_generation(generation).unwrap(); + authorized_tx.send(()).unwrap(); + release_rx.recv().unwrap(); + }); + authorized_rx.recv().unwrap(); + + let stopping_manager = manager.clone(); + let (stopped_tx, stopped_rx) = std::sync::mpsc::channel(); + let stopping = std::thread::spawn(move || { + stopping_manager.stop().unwrap(); + stopped_tx.send(()).unwrap(); + }); + assert!(stopped_rx.recv_timeout(Duration::from_millis(20)).is_err()); + + release_tx.send(()).unwrap(); + worker.join().unwrap(); + stopped_rx.recv_timeout(Duration::from_secs(1)).unwrap(); + stopping.join().unwrap(); + assert!(!manager.is_current_generation(generation)); + } + + #[cfg(windows)] + #[test] + fn windows_launch_gate_requires_parent_release() { + assert!(!wait_for_windows_cli_launch_gate(std::io::Cursor::new([]))); + assert!(!wait_for_windows_cli_launch_gate(std::io::Cursor::new([0]))); + assert!(wait_for_windows_cli_launch_gate(std::io::Cursor::new([1]))); + } + + #[cfg(windows)] + #[test] + fn windows_launcher_preserves_the_shutdown_control_channel() { + let mut control = std::io::Cursor::new(b"\x01codenomad:shutdown\n"); + let mut node_stdin = Vec::new(); + + assert!(wait_for_windows_cli_launch_gate(&mut control)); + relay_windows_cli_control(&mut control, &mut node_stdin).unwrap(); + + assert_eq!(node_stdin, b"codenomad:shutdown\n"); + } + + #[cfg(windows)] + #[test] + fn windows_stop_writes_the_exact_shutdown_command() { + let mut control = Vec::new(); + request_windows_cli_shutdown(&mut control).unwrap(); + assert_eq!(control, b"codenomad:shutdown\n"); + } + + #[cfg(windows)] + #[test] + fn windows_requires_an_empty_job_to_confirm_containment() { + assert!(windows_containment_confirmed(true, Some(0))); + assert!(!windows_containment_confirmed(true, Some(1))); + assert!(!windows_containment_confirmed(true, None)); + assert!(!windows_containment_confirmed(false, Some(0))); + } + + #[cfg(windows)] + #[test] + fn windows_exited_child_without_job_remains_unconfirmed() { + let mut command = Command::new("cmd.exe"); + command.args(["/C", "exit", "0"]); + configure_spawn(&mut command); + let mut child = command.spawn().unwrap(); + child.wait().unwrap(); + + let error = stop_child(&mut child, None).unwrap_err().to_string(); + assert!(error.contains("cannot be confirmed without a Windows job")); + } + + #[test] + fn exited_root_is_not_complete_while_descendants_remain() { + assert!(!containment_is_complete(true, false)); + assert!(containment_is_complete(true, true)); + } + + #[test] + fn termination_wait_propagates_probe_errors() { + let result = wait_for_termination(Duration::ZERO, Duration::ZERO, || { + Err(anyhow::anyhow!("try_wait failed")) + }); + assert_eq!(result.unwrap_err().to_string(), "try_wait failed"); + } + + #[test] + fn termination_wait_rejects_unconfirmed_exit() { + let result = wait_for_termination(Duration::ZERO, Duration::ZERO, || Ok(false)); + assert_eq!( + result.unwrap_err().to_string(), + "process termination was not confirmed" + ); + } + + #[test] + fn termination_wait_accepts_confirmed_exit() { + let mut probes = 0; + wait_for_termination(Duration::from_secs(1), Duration::ZERO, || { + probes += 1; + Ok(probes == 2) + }) + .unwrap(); + assert_eq!(probes, 2); + } } diff --git a/packages/tauri-app/src-tauri/src/client_state.rs b/packages/tauri-app/src-tauri/src/client_state.rs index 66aaacb9..d2b37b18 100644 --- a/packages/tauri-app/src-tauri/src/client_state.rs +++ b/packages/tauri-app/src-tauri/src/client_state.rs @@ -1,5 +1,6 @@ mod access; mod commands; +mod cross_host; mod navigation; mod process; mod window; @@ -15,7 +16,9 @@ pub use commands::{ client_state_navigation_flushed, client_state_renderer_flushed, client_state_save, client_state_set_restore_enabled, }; -pub(crate) use navigation::{before_main_window_navigation, NavigationKind}; +pub(crate) use navigation::{ + before_main_window_navigation, before_main_window_navigation_if, NavigationKind, +}; pub use window::{ capture_and_flush_main_window, main_window_zoom, set_main_window_zoom, setup_main_window, DEFAULT_ZOOM_LEVEL, @@ -26,15 +29,30 @@ use serde_json::Value; use std::fs; use std::io::Write; use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Mutex; -use tauri::{AppHandle, Manager}; +use std::time::{Duration, Instant}; +use tauri::{AppHandle, Emitter, Manager}; use url::Url; use window::NativeWindowState; const CLIENT_STATE_VERSION: u64 = 1; const CLIENT_STATE_FILENAME: &str = "client-state.json"; const MAX_CLIENT_SNAPSHOT_BYTES: usize = 1024 * 1024; +const RENDERER_FLUSH_TIMEOUT: Duration = Duration::from_secs(1); + +#[derive(Clone, Copy, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct RendererFlushRequest { + pub(crate) generation: u64, +} + +#[derive(Default)] +struct RendererFlush { + request_lock: Mutex<()>, + next_generation: AtomicU64, + acknowledged_generation: AtomicU64, +} #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] @@ -47,6 +65,8 @@ struct PersistedClientState { window: Option, #[serde(skip)] unsupported_future_envelope: bool, + #[serde(skip)] + writes_enabled: bool, } impl Default for PersistedClientState { @@ -57,6 +77,7 @@ impl Default for PersistedClientState { snapshot: None, window: None, unsupported_future_envelope: false, + writes_enabled: true, } } } @@ -76,17 +97,34 @@ pub struct ClientState { zoom_level: Mutex, write_lock: Mutex<()>, save_generation: AtomicU64, - persistence_suppressed: AtomicBool, renderer_access: access::RendererAccess, + renderer_flush: RendererFlush, write_state: StateWriter, } -type StateWriter = std::sync::Arc Result<(), String> + Send + Sync>; +type StateWriter = + std::sync::Arc bool) -> Result<(), String> + Send + Sync>; impl ClientState { pub fn initialize(app: &AppHandle) -> Self { match app.path().app_data_dir() { - Ok(app_data_dir) => Self::initialize_managed_at(&app_data_dir), + Ok(app_data_dir) => { + match (cross_host::election_directory(), cross_host::state_path()) { + (Ok(election_dir), Ok(state_path)) => { + let legacy_electron = cross_host::legacy_electron_data_directory(); + Self::initialize_managed_at_with_election( + &app_data_dir, + &election_dir, + &state_path, + legacy_electron.as_deref(), + ) + } + (Err(err), _) | (_, Err(err)) => { + eprintln!("[client-state] initialization failed; restore disabled: {err}"); + Self::disabled(app_data_dir.join(CLIENT_STATE_FILENAME)) + } + } + } Err(err) => { eprintln!("[client-state] initialization failed; restore disabled: {err}"); Self::disabled(PathBuf::new()) @@ -94,6 +132,7 @@ impl ClientState { } } + #[cfg(test)] fn initialize_managed_at(app_data_dir: &Path) -> Self { Self::initialize_at(app_data_dir).unwrap_or_else(|err| { eprintln!("[client-state] initialization failed; restore disabled: {err}"); @@ -101,31 +140,88 @@ impl ClientState { }) } + fn initialize_managed_at_with_election( + app_data_dir: &Path, + election_dir: &Path, + state_path: &Path, + legacy_electron_data_dir: Option<&Path>, + ) -> Self { + Self::initialize_at_with_writer_and_election( + app_data_dir, + election_dir, + state_path, + legacy_electron_data_dir, + std::sync::Arc::new(write_atomically), + ) + .unwrap_or_else(|err| { + eprintln!("[client-state] initialization failed; restore disabled: {err}"); + Self::disabled(app_data_dir.join(CLIENT_STATE_FILENAME)) + }) + } + fn disabled(state_path: PathBuf) -> Self { let state = PersistedClientState { restore_enabled: false, + writes_enabled: false, ..PersistedClientState::default() }; + Self::new( + state_path, + process::ProcessState::disabled(), + state, + std::sync::Arc::new(write_atomically), + ) + } + + fn new( + state_path: PathBuf, + process: process::ProcessState, + state: PersistedClientState, + write_state: StateWriter, + ) -> Self { + let zoom_level = state + .restore_enabled + .then(|| state.window.as_ref().map(|window| window.zoom_factor)) + .flatten() + .unwrap_or(DEFAULT_ZOOM_LEVEL); Self { state_path, - process: process::ProcessState::disabled(), + process, state: Mutex::new(state), - zoom_level: Mutex::new(DEFAULT_ZOOM_LEVEL), + zoom_level: Mutex::new(zoom_level), write_lock: Mutex::new(()), save_generation: AtomicU64::new(0), - persistence_suppressed: AtomicBool::new(true), renderer_access: access::RendererAccess::default(), - write_state: std::sync::Arc::new(write_atomically), + renderer_flush: RendererFlush::default(), + write_state, } } + #[cfg(test)] fn initialize_at(app_data_dir: &Path) -> Result { Self::initialize_at_with_writer(app_data_dir, std::sync::Arc::new(write_atomically)) } + #[cfg(test)] fn initialize_at_with_writer( app_data_dir: &Path, write_state: StateWriter, + ) -> Result { + Self::initialize_at_with_writer_and_election( + app_data_dir, + &app_data_dir.join(".cross-host-election"), + &app_data_dir.join(CLIENT_STATE_FILENAME), + None, + write_state, + ) + } + + fn initialize_at_with_writer_and_election( + app_data_dir: &Path, + election_dir: &Path, + state_path: &Path, + legacy_electron_data_dir: Option<&Path>, + write_state: StateWriter, ) -> Result { fs::create_dir_all(app_data_dir).map_err(|err| { format!( @@ -134,61 +230,61 @@ impl ClientState { ) })?; - let state_path = app_data_dir.join(CLIENT_STATE_FILENAME); - let registration = process::Registration::initialize(app_data_dir)?; + let registration = process::Registration::initialize( + app_data_dir, + election_dir, + legacy_electron_data_dir, + )?; + let future_legacy = + !state_path.exists() && has_future_legacy_state(app_data_dir, legacy_electron_data_dir); + if registration.is_primary() && !state_path.exists() && !future_legacy { + migrate_legacy_state(state_path, app_data_dir, legacy_electron_data_dir, &|| { + registration.is_primary() + })?; + } let state = if registration.is_primary() { - read_client_state(&state_path) + if future_legacy { + PersistedClientState { + restore_enabled: false, + unsupported_future_envelope: true, + writes_enabled: false, + ..PersistedClientState::default() + } + } else { + read_client_state(state_path) + } } else { PersistedClientState::default() }; - let zoom_level = if state.restore_enabled { - state - .window - .as_ref() - .map(|window| window.zoom_factor) - .unwrap_or(DEFAULT_ZOOM_LEVEL) - } else { - DEFAULT_ZOOM_LEVEL - }; - let persistence_suppressed = !state.restore_enabled; let process = registration.finish(); - - Ok(Self { - state_path, + Ok(Self::new( + state_path.to_path_buf(), process, - state: Mutex::new(state), - zoom_level: Mutex::new(zoom_level), - write_lock: Mutex::new(()), - save_generation: AtomicU64::new(0), - persistence_suppressed: AtomicBool::new(persistence_suppressed), - renderer_access: access::RendererAccess::default(), + state, write_state, - }) + )) } fn is_primary(&self) -> bool { self.process.is_primary() } - fn load(&self) -> Result { - if !self.is_primary() { - let restore_enabled = self - .state - .lock() - .map_err(|err| err.to_string())? - .restore_enabled; - return Ok(ClientStateLoadResult { - is_primary: false, - restore_enabled, - snapshot: Value::Null, - }); - } + fn claim_renderer_access(&self, access_token: &str, renderer_url: &Url) -> Result<(), String> { + let _write = self.write_lock.lock().map_err(|err| err.to_string())?; + self.renderer_access.claim(access_token, renderer_url) + } + fn load(&self) -> Result { let state = self.state.lock().map_err(|err| err.to_string())?; + let is_primary = self.is_primary(); Ok(ClientStateLoadResult { - is_primary: true, - restore_enabled: state.restore_enabled, - snapshot: if state.restore_enabled { + is_primary, + restore_enabled: if is_primary || !self.process.is_registered() { + state.restore_enabled + } else { + true + }, + snapshot: if is_primary && state.restore_enabled { state.snapshot.clone().unwrap_or(Value::Null) } else { Value::Null @@ -196,8 +292,20 @@ impl ClientState { }) } + #[cfg(test)] fn save_snapshot(&self, snapshot: Value) -> Result { + self.save_snapshot_guarded(snapshot, || true) + } + + fn save_snapshot_guarded( + &self, + snapshot: Value, + access_valid: impl Fn() -> bool, + ) -> Result { let _write = self.write_lock.lock().map_err(|err| err.to_string())?; + if !access_valid() { + return Err("Client state renderer authority changed before mutation".to_string()); + } if !self.is_primary() { return Ok(false); } @@ -208,44 +316,72 @@ impl ClientState { return Err("Client snapshot exceeds the 1 MiB limit".to_string()); } - self.state.lock().map_err(|err| err.to_string())?.snapshot = Some(snapshot); - self.write_current_state() + self.mutate_and_write(|state| state.snapshot = Some(snapshot), &access_valid) } + #[cfg(test)] fn set_restore_enabled(&self, enabled: bool) -> Result { - let _write = self.write_lock.lock().map_err(|err| err.to_string())?; - if !self.is_primary() { - return Ok(false); - } - if self.has_unsupported_future_envelope()? { - return Ok(false); - } - self.mutate_and_write(|state| { - state.restore_enabled = enabled; - if !enabled { - state.snapshot = None; - state.window = None; - } - self.persistence_suppressed - .store(!enabled, Ordering::SeqCst); - }) + self.set_restore_enabled_guarded(enabled, || true) } - fn clear(&self) -> Result { + fn set_restore_enabled_guarded( + &self, + enabled: bool, + access_valid: impl Fn() -> bool, + ) -> Result { let _write = self.write_lock.lock().map_err(|err| err.to_string())?; + if !access_valid() { + return Err("Client state renderer authority changed before mutation".to_string()); + } if !self.is_primary() { return Ok(false); } - self.mutate_and_write(|state| { - if state.unsupported_future_envelope { - *state = PersistedClientState::default(); - self.persistence_suppressed.store(false, Ordering::SeqCst); - } else { - state.snapshot = None; - state.window = None; - self.persistence_suppressed.store(true, Ordering::SeqCst); - } - }) + if self + .state + .lock() + .map_err(|err| err.to_string())? + .unsupported_future_envelope + { + return Ok(false); + } + self.mutate_and_write( + |state| { + state.restore_enabled = enabled; + if !enabled { + state.snapshot = None; + state.window = None; + } + state.writes_enabled = enabled; + }, + &access_valid, + ) + } + + #[cfg(test)] + fn clear(&self) -> Result { + self.clear_guarded(|| true) + } + + fn clear_guarded(&self, access_valid: impl Fn() -> bool) -> Result { + let _write = self.write_lock.lock().map_err(|err| err.to_string())?; + if !access_valid() { + return Err("Client state renderer authority changed before mutation".to_string()); + } + if !self.is_primary() { + return Ok(false); + } + self.mutate_and_write( + |state| { + if state.unsupported_future_envelope { + *state = PersistedClientState::default(); + } else { + state.snapshot = None; + state.window = None; + state.writes_enabled = false; + } + }, + &access_valid, + ) } fn flush(&self) -> Result<(), String> { @@ -256,60 +392,36 @@ impl ClientState { Ok(()) } - fn has_unsupported_future_envelope(&self) -> Result { + fn normal_writes_suppressed(&self) -> Result { self.state .lock() - .map(|state| state.unsupported_future_envelope) + .map(|state| !state.writes_enabled || state.unsupported_future_envelope) .map_err(|err| err.to_string()) } - fn normal_writes_suppressed(&self) -> Result { - Ok(self.persistence_suppressed.load(Ordering::SeqCst) - || self.has_unsupported_future_envelope()?) + fn write_current_state(&self) -> Result<(), String> { + self.write_current_state_guarded(&|| true) } - fn claim_renderer_access(&self, access_token: &str, renderer_url: &Url) -> Result<(), String> { - self.renderer_access.claim(access_token, renderer_url) - } - - fn validate_renderer_access( + fn write_current_state_guarded( &self, - access_token: &str, - renderer_url: &Url, + replacement_valid: &dyn Fn() -> bool, ) -> Result<(), String> { - self.renderer_access.validate(access_token, renderer_url) - } - - fn renderer_origin_can_claim(&self, renderer_url: &Url) -> bool { - self.renderer_access.allows_claim_origin(renderer_url) - } - - fn begin_renderer_navigation(&self, target_url: Option<&Url>) -> Result<(), String> { - self.renderer_access.begin_navigation(target_url) - } - - fn cancel_renderer_navigation(&self) { - self.renderer_access.cancel_navigation(); - } - - fn renderer_access_is_claimed(&self) -> bool { - self.renderer_access.is_claimed() - } - - fn write_current_state(&self) -> Result { let bytes = { let state = self.state.lock().map_err(|err| err.to_string())?; serde_json::to_vec(&*state).map_err(|err| err.to_string())? }; - (self.write_state)(&self.state_path, &bytes)?; - Ok(true) + (self.write_state)(&self.state_path, &bytes, &|| { + self.is_primary() && replacement_valid() + })?; + Ok(()) } fn mutate_and_write( &self, mutate: impl FnOnce(&mut PersistedClientState), + replacement_valid: &dyn Fn() -> bool, ) -> Result { - let previous_persistence_suppressed = self.persistence_suppressed.load(Ordering::SeqCst); let previous_state = { let mut state = self.state.lock().map_err(|err| err.to_string())?; let previous = state.clone(); @@ -317,12 +429,10 @@ impl ClientState { previous }; - match self.write_current_state() { - Ok(written) => Ok(written), + match self.write_current_state_guarded(replacement_valid) { + Ok(()) => Ok(true), Err(err) => { *self.state.lock().map_err(|lock_err| lock_err.to_string())? = previous_state; - self.persistence_suppressed - .store(previous_persistence_suppressed, Ordering::SeqCst); Err(err) } } @@ -335,6 +445,52 @@ impl ClientState { .unwrap_or_else(|err| err.into_inner()); self.process.release_locks(); } + + pub(crate) fn wait_for_renderer_flush(&self, app: &AppHandle, require_claim: bool) { + let _request = self + .renderer_flush + .request_lock + .lock() + .unwrap_or_else(|err| err.into_inner()); + if require_claim && !self.renderer_access.is_claimed() { + return; + } + let Some(window) = app.get_webview_window("main") else { + return; + }; + let generation = self + .renderer_flush + .next_generation + .fetch_add(1, Ordering::SeqCst) + + 1; + if let Err(err) = window.emit( + "client-state:navigation-flush-requested", + RendererFlushRequest { generation }, + ) { + eprintln!("[client-state] failed to request renderer flush: {err}"); + return; + } + + let deadline = Instant::now() + RENDERER_FLUSH_TIMEOUT; + while self.renderer_flush.next_generation.load(Ordering::SeqCst) == generation + && self + .renderer_flush + .acknowledged_generation + .load(Ordering::SeqCst) + != generation + && Instant::now() < deadline + { + std::thread::sleep(Duration::from_millis(10)); + } + } + + fn acknowledge_renderer_flush(&self, generation: u64) { + if self.renderer_flush.next_generation.load(Ordering::SeqCst) == generation { + self.renderer_flush + .acknowledged_generation + .store(generation, Ordering::SeqCst); + } + } } impl Drop for ClientState { @@ -361,7 +517,9 @@ fn parse_client_state(bytes: &[u8]) -> PersistedClientState { let version = value.get("version").and_then(Value::as_u64); if version.is_some_and(|version| version > CLIENT_STATE_VERSION) { return PersistedClientState { + restore_enabled: false, unsupported_future_envelope: true, + writes_enabled: false, ..PersistedClientState::default() }; } @@ -374,25 +532,120 @@ fn parse_client_state(bytes: &[u8]) -> PersistedClientState { .map(|size| size <= MAX_CLIENT_SNAPSHOT_BYTES) .unwrap_or(false) }); + let restore_enabled = value + .get("restoreEnabled") + .and_then(Value::as_bool) + .unwrap_or(true); PersistedClientState { version: CLIENT_STATE_VERSION, - restore_enabled: value - .get("restoreEnabled") - .and_then(Value::as_bool) - .unwrap_or(true), + restore_enabled, snapshot, window: value.get("window").and_then(window::normalize_window_state), unsupported_future_envelope: false, + writes_enabled: restore_enabled, } } +fn legacy_candidate( + path: &Path, + host: &'static str, +) -> Option<(PersistedClientState, bool, i64, &'static str)> { + let bytes = fs::read(path).ok()?; + let Value::Object(value) = serde_json::from_slice::(&bytes).ok()? else { + return None; + }; + if value.get("version").and_then(Value::as_u64) != Some(CLIENT_STATE_VERSION) { + return None; + } + let saved_at = value + .get("snapshot") + .and_then(Value::as_object) + .and_then(|snapshot| snapshot.get("savedAt")) + .and_then(Value::as_i64) + .unwrap_or(-1); + let mut parsed = parse_client_state(&bytes); + parsed.window = None; + Some((parsed, value.contains_key("snapshot"), saved_at, host)) +} + +fn has_future_legacy_state(tauri_data_dir: &Path, electron_data_dir: Option<&Path>) -> bool { + [ + electron_data_dir.map(|path| path.join(CLIENT_STATE_FILENAME)), + Some(tauri_data_dir.join(CLIENT_STATE_FILENAME)), + ] + .into_iter() + .flatten() + .any(|path| { + fs::read(path) + .ok() + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) + .and_then(|value| value.get("version").and_then(Value::as_u64)) + .is_some_and(|version| version > CLIENT_STATE_VERSION) + }) +} + +fn migrate_legacy_state( + state_path: &Path, + tauri_data_dir: &Path, + electron_data_dir: Option<&Path>, + ownership_valid: &dyn Fn() -> bool, +) -> Result<(), String> { + let mut candidates = [ + electron_data_dir + .and_then(|path| legacy_candidate(&path.join(CLIENT_STATE_FILENAME), "electron")), + legacy_candidate(&tauri_data_dir.join(CLIENT_STATE_FILENAME), "tauri"), + ] + .into_iter() + .flatten() + .collect::>(); + candidates.sort_by(|left, right| { + left.0 + .restore_enabled + .cmp(&right.0.restore_enabled) + .then_with(|| left.1.cmp(&right.1)) + .then_with(|| right.2.cmp(&left.2)) + .then_with(|| right.3.cmp(left.3)) + }); + let Some((state, _, _, _)) = candidates.first() else { + return Ok(()); + }; + let bytes = serde_json::to_vec(state).map_err(|err| err.to_string())?; + if let Some(parent) = state_path.parent() { + fs::create_dir_all(parent) + .map_err(|err| format!("failed to create shared client-state directory: {err}"))?; + } + write_atomically(state_path, &bytes, ownership_valid)?; + for path in [ + electron_data_dir.map(|path| path.join(CLIENT_STATE_FILENAME)), + Some(tauri_data_dir.join(CLIENT_STATE_FILENAME)), + ] + .into_iter() + .flatten() + { + match fs::remove_file(path) { + Ok(()) => {} + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(err) => { + return Err(format!( + "failed to remove migrated legacy client state: {err}" + )) + } + } + } + Ok(()) +} + fn serialized_value_size(value: &Value) -> Result { serde_json::to_vec(value) .map(|bytes| bytes.len()) .map_err(|err| err.to_string()) } -fn write_atomically(path: &Path, bytes: &[u8]) -> Result<(), String> { +fn write_atomically( + path: &Path, + bytes: &[u8], + ownership_valid: &dyn Fn() -> bool, +) -> Result<(), String> { let parent = path .parent() .ok_or_else(|| format!("state path has no parent: {}", path.display()))?; @@ -402,18 +655,29 @@ fn write_atomically(path: &Path, bytes: &[u8]) -> Result<(), String> { .write_all(bytes) .and_then(|_| temporary.as_file().sync_all()) .map_err(|err| format!("failed to write temporary state file: {err}"))?; + if !ownership_valid() { + return Err("Client state ownership changed before atomic replacement".to_string()); + } temporary .persist(path) .map_err(|err| format!("failed to replace state file: {}", err.error))?; Ok(()) } -pub fn flush_and_release(app: &AppHandle) { - window::capture_and_flush_main_window(app); +pub fn release(app: &AppHandle) { if let Some(state) = app.try_state::() { state.release_locks(); } } +pub fn flush_and_release_without_window_capture(app: &AppHandle) { + if let Some(state) = app.try_state::() { + if let Err(err) = state.flush() { + eprintln!("[client-state] failed to flush state: {err}"); + } + state.release_locks(); + } +} + #[cfg(test)] mod tests; diff --git a/packages/tauri-app/src-tauri/src/client_state/access.rs b/packages/tauri-app/src-tauri/src/client_state/access.rs index 97d71350..31181c62 100644 --- a/packages/tauri-app/src-tauri/src/client_state/access.rs +++ b/packages/tauri-app/src-tauri/src/client_state/access.rs @@ -11,6 +11,12 @@ struct RendererAccessState { token: Option, committed_origin: Option, pending_origin: Option, + generation: u64, +} + +pub(super) struct PendingNavigation { + previous_origin: Option, + staged_origin: Option, } fn origin_key(url: &Url) -> Result { @@ -29,48 +35,38 @@ impl RendererAccess { let renderer_origin = origin_key(renderer_url)?; let mut state = self.state.lock().map_err(|err| err.to_string())?; - match state.token.as_deref() { - Some(current) - if current == access_token - && state.committed_origin.as_deref() == Some(renderer_origin.as_str()) => - { - Ok(()) - } - Some(_) if state.pending_origin.as_deref() == Some(renderer_origin.as_str()) => { - state.token = Some(access_token.to_string()); - state.committed_origin = Some(renderer_origin); - state.pending_origin = None; - Ok(()) - } - Some(current) if current != access_token => { - Err("Client state access token does not match this renderer".to_string()) - } - Some(_) => { - Err("Client state renderer origin changed without access rotation".to_string()) - } - None => { - state.token = Some(access_token.to_string()); - state.committed_origin = Some(renderer_origin); - state.pending_origin = None; - Ok(()) - } + if state.token.is_none() + || state.pending_origin.as_deref() == Some(renderer_origin.as_str()) + { + state.token = Some(access_token.to_string()); + state.committed_origin = Some(renderer_origin); + state.pending_origin = None; + state.generation = state.generation.wrapping_add(1); + return Ok(()); + } + if state.token.as_deref() != Some(access_token) { + return Err("Client state access token does not match this renderer".to_string()); + } + if state.committed_origin.as_deref() == Some(renderer_origin.as_str()) { + Ok(()) + } else { + Err("Client state renderer origin changed without access rotation".to_string()) } } - pub(super) fn validate(&self, access_token: &str, renderer_url: &Url) -> Result<(), String> { + pub(super) fn validate(&self, access_token: &str, renderer_url: &Url) -> Result { if access_token.is_empty() { return Err("Client state access token must not be empty".to_string()); } let renderer_origin = origin_key(renderer_url)?; let state = self.state.lock().map_err(|err| err.to_string())?; + if state.token.as_deref() == Some(access_token) + && state.committed_origin.as_deref() == Some(renderer_origin.as_str()) + { + return Ok(state.generation); + } match state.token.as_deref() { - Some(current) - if current == access_token - && state.committed_origin.as_deref() == Some(renderer_origin.as_str()) => - { - Ok(()) - } Some(current) if current == access_token => { Err("Client state renderer origin does not match this renderer".to_string()) } @@ -79,6 +75,13 @@ impl RendererAccess { } } + pub(super) fn is_generation_current(&self, generation: u64) -> bool { + self.state + .lock() + .map(|state| state.generation == generation) + .unwrap_or(false) + } + pub(super) fn allows_claim_origin(&self, renderer_url: &Url) -> bool { let Ok(renderer_origin) = origin_key(renderer_url) else { return false; @@ -92,20 +95,30 @@ impl RendererAccess { .unwrap_or(false) } - pub(super) fn begin_navigation(&self, target_url: Option<&Url>) -> Result<(), String> { + pub(super) fn begin_navigation( + &self, + target_url: Option<&Url>, + ) -> Result { let mut state = self.state.lock().map_err(|err| err.to_string())?; - state.pending_origin = match target_url { + let previous_origin = state.pending_origin.clone(); + let staged_origin = match target_url { Some(url) => Some(origin_key(url)?), - None => state.committed_origin.clone(), + None => previous_origin + .clone() + .or_else(|| state.committed_origin.clone()), }; - Ok(()) + state.pending_origin = staged_origin.clone(); + Ok(PendingNavigation { + previous_origin, + staged_origin, + }) } - pub(super) fn cancel_navigation(&self) { - self.state - .lock() - .unwrap_or_else(|err| err.into_inner()) - .pending_origin = None; + pub(super) fn cancel_navigation(&self, navigation: PendingNavigation) { + let mut state = self.state.lock().unwrap_or_else(|err| err.into_inner()); + if state.pending_origin == navigation.staged_origin { + state.pending_origin = navigation.previous_origin; + } } pub(super) fn is_claimed(&self) -> bool { diff --git a/packages/tauri-app/src-tauri/src/client_state/commands.rs b/packages/tauri-app/src-tauri/src/client_state/commands.rs index 31efa283..f22b6423 100644 --- a/packages/tauri-app/src-tauri/src/client_state/commands.rs +++ b/packages/tauri-app/src-tauri/src/client_state/commands.rs @@ -51,7 +51,7 @@ fn validate_claim_origin( state: &ClientState, ) -> Result<(), String> { let status = app_state.manager.status(); - if state.renderer_origin_can_claim(current_url) + if state.renderer_access.allows_claim_origin(current_url) || is_allowed_client_state_origin(current_url, status.url.as_deref()) { Ok(()) @@ -64,9 +64,9 @@ fn validate_access( window: &WebviewWindow, state: &ClientState, access_token: &str, -) -> Result<(), String> { +) -> Result { let current_url = main_window_url(window)?; - state.validate_renderer_access(access_token, ¤t_url) + state.renderer_access.validate(access_token, ¤t_url) } #[tauri::command] @@ -84,7 +84,6 @@ pub fn client_state_claim_access( #[tauri::command] pub fn client_state_load( window: WebviewWindow, - _app_state: State<'_, AppState>, state: State<'_, ClientState>, access_token: String, ) -> Result { @@ -95,60 +94,60 @@ pub fn client_state_load( #[tauri::command] pub fn client_state_save( window: WebviewWindow, - _app_state: State<'_, AppState>, state: State<'_, ClientState>, access_token: String, snapshot: Value, ) -> Result { - validate_access(&window, &state, &access_token)?; - state.save_snapshot(snapshot) + let generation = validate_access(&window, &state, &access_token)?; + state.save_snapshot_guarded(snapshot, || { + state.renderer_access.is_generation_current(generation) + }) } #[tauri::command] pub fn client_state_set_restore_enabled( window: WebviewWindow, - _app_state: State<'_, AppState>, state: State<'_, ClientState>, access_token: String, enabled: bool, ) -> Result { - validate_access(&window, &state, &access_token)?; - state.set_restore_enabled(enabled) + let generation = validate_access(&window, &state, &access_token)?; + state.set_restore_enabled_guarded(enabled, || { + state.renderer_access.is_generation_current(generation) + }) } #[tauri::command] pub fn client_state_clear( window: WebviewWindow, - _app_state: State<'_, AppState>, state: State<'_, ClientState>, access_token: String, ) -> Result { - validate_access(&window, &state, &access_token)?; - state.clear() + let generation = validate_access(&window, &state, &access_token)?; + state.clear_guarded(|| state.renderer_access.is_generation_current(generation)) } #[tauri::command] pub fn client_state_renderer_flushed( app: AppHandle, window: WebviewWindow, - _app_state: State<'_, AppState>, state: State<'_, ClientState>, access_token: String, + generation: u64, ) -> Result<(), String> { validate_access(&window, &state, &access_token)?; - crate::shutdown::renderer_flushed(app); + crate::shutdown::renderer_flushed(app, generation); Ok(()) } #[tauri::command] pub fn client_state_navigation_flushed( window: WebviewWindow, - _app_state: State<'_, AppState>, state: State<'_, ClientState>, access_token: String, generation: u64, ) -> Result<(), String> { validate_access(&window, &state, &access_token)?; - super::navigation::renderer_flushed(generation); + state.acknowledge_renderer_flush(generation); Ok(()) } diff --git a/packages/tauri-app/src-tauri/src/client_state/cross_host.rs b/packages/tauri-app/src-tauri/src/client_state/cross_host.rs new file mode 100644 index 00000000..afeaeedc --- /dev/null +++ b/packages/tauri-app/src-tauri/src/client_state/cross_host.rs @@ -0,0 +1,1467 @@ +use serde::{Deserialize, Serialize}; +use std::ffi::OsString; +use std::fs::{self, OpenOptions}; +use std::io::Write; +use std::path::{Path, PathBuf}; +#[cfg(any(target_os = "macos", windows))] +use std::process::{Command, Stdio}; +#[cfg(any(target_os = "macos", windows))] +use std::time::{Duration, Instant}; + +const OWNER_DIRECTORY: &str = "primary.owner.json"; +const OWNER_FILENAME: &str = "owner.json"; +const PARTICIPANT_PREFIX: &str = "participant."; +const PARTICIPANT_SUFFIX: &str = ".json"; +const RECOVERY_PREFIX: &str = "recovery."; +const RECOVERY_SUFFIX: &str = ".claim"; +const RETIRED_PREFIX: &str = "retired."; +const ACQUIRE_ATTEMPTS: usize = 10; + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +struct Owner { + pid: u32, + run_token: String, + process_start_identity: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct LegacyOwner { + pid: u32, + run_token: String, + process_start_identity: Option, +} + +pub(super) struct Registration { + election_directory: PathBuf, + participant_path: PathBuf, + recovery_claim: Option, + owner: Owner, + legacy_electron_data: Option, + primary: bool, + released: bool, +} + +pub(super) fn election_directory() -> Result { + resolve_election_directory_for( + std::env::consts::OS, + |name| std::env::var_os(name), + dirs::home_dir().as_deref(), + ) + .map(PathBuf::from) + .ok_or_else(|| "user home directory is unavailable".to_string()) +} + +pub(super) fn state_path() -> Result { + resolve_state_path_for( + std::env::consts::OS, + |name| std::env::var_os(name), + dirs::home_dir().as_deref(), + ) + .map(PathBuf::from) + .ok_or_else(|| "user home directory is unavailable".to_string()) +} + +pub(super) fn legacy_electron_data_directory() -> Option { + resolve_legacy_electron_data_directory_for( + std::env::consts::OS, + |name| std::env::var_os(name), + dirs::home_dir().as_deref(), + ) + .map(PathBuf::from) +} + +fn valid_home(value: OsString, platform: &str) -> Option { + let value = value.into_string().ok()?; + let valid = if platform == "windows" { + let bytes = value.as_bytes(); + (bytes.len() >= 3 + && bytes[0].is_ascii_alphabetic() + && bytes[1] == b':' + && (bytes[2] == b'\\' || bytes[2] == b'/')) + || value.starts_with("\\\\") + } else { + value.starts_with('/') + }; + valid.then_some(value) +} + +fn configured_home( + platform: &str, + environment: &impl Fn(&str) -> Option, + fallback_home: Option<&Path>, +) -> Option { + let configured = if platform == "windows" { + environment("USERPROFILE") + .and_then(|value| valid_home(value, platform)) + .or_else(|| environment("HOME").and_then(|value| valid_home(value, platform))) + } else { + environment("HOME").and_then(|value| valid_home(value, platform)) + }; + configured.or_else(|| fallback_home.map(|path| path.to_string_lossy().into_owned())) +} + +fn resolve_election_directory_for( + platform: &str, + environment: impl Fn(&str) -> Option, + fallback_home: Option<&Path>, +) -> Option { + let home = configured_home(platform, &environment, fallback_home)?; + Some(if platform == "windows" { + format!( + "{}\\.codenomad\\client-state\\election", + home.trim_end_matches(['\\', '/']) + ) + } else { + format!( + "{}/.codenomad/client-state/election", + home.trim_end_matches('/') + ) + }) +} + +fn resolve_state_path_for( + platform: &str, + environment: impl Fn(&str) -> Option, + fallback_home: Option<&Path>, +) -> Option { + let home = configured_home(platform, &environment, fallback_home)?; + Some(if platform == "windows" { + format!( + "{}\\.codenomad\\client-state\\client-state.json", + home.trim_end_matches(['\\', '/']) + ) + } else { + format!( + "{}/.codenomad/client-state/client-state.json", + home.trim_end_matches('/') + ) + }) +} + +fn resolve_legacy_electron_data_directory_for( + platform: &str, + environment: impl Fn(&str) -> Option, + fallback_home: Option<&Path>, +) -> Option { + let home = configured_home(platform, &environment, fallback_home)?; + if platform == "windows" { + let root = environment("APPDATA") + .and_then(|value| valid_home(value, platform)) + .unwrap_or_else(|| format!("{}\\AppData\\Roaming", home.trim_end_matches(['\\', '/']))); + Some(format!("{}\\CodeNomad", root.trim_end_matches(['\\', '/']))) + } else if platform == "macos" { + Some(format!( + "{}/Library/Application Support/CodeNomad", + home.trim_end_matches('/') + )) + } else { + let root = environment("XDG_CONFIG_HOME") + .and_then(|value| valid_home(value, platform)) + .unwrap_or_else(|| format!("{}/.config", home.trim_end_matches('/'))); + Some(format!("{}/CodeNomad", root.trim_end_matches('/'))) + } +} + +impl Registration { + pub(super) fn register( + election_directory: &Path, + primary_candidate: bool, + legacy_electron_data: Option<&Path>, + ) -> Result, String> { + let Some(current_identity) = process_start_identity(std::process::id()) else { + return Ok(None); + }; + Self::register_with_legacy( + election_directory, + Owner { + pid: std::process::id(), + run_token: uuid::Uuid::new_v4().to_string(), + process_start_identity: current_identity, + }, + primary_candidate, + legacy_electron_data, + pid_is_alive, + process_start_identity, + expected_electron_process, + ) + } + + #[cfg(test)] + fn register_with( + election_directory: &Path, + owner: Owner, + primary_candidate: bool, + pid_alive: impl Fn(u32) -> bool + Copy, + identity: impl Fn(u32) -> Option + Copy, + ) -> Result, String> { + Self::register_with_legacy( + election_directory, + owner, + primary_candidate, + None, + pid_alive, + identity, + |_| Some(false), + ) + } + + fn register_with_legacy( + election_directory: &Path, + owner: Owner, + primary_candidate: bool, + legacy_electron_data: Option<&Path>, + pid_alive: impl Fn(u32) -> bool + Copy, + identity: impl Fn(u32) -> Option + Copy, + expected_electron: impl Fn(u32) -> Option + Copy, + ) -> Result, String> { + if !valid_token(&owner.run_token) || owner.process_start_identity.is_empty() { + return Ok(None); + } + fs::create_dir_all(election_directory) + .map_err(|err| format!("failed to create cross-host election directory: {err}"))?; + let participant_path = participant_path(election_directory, &owner); + publish_participant(&participant_path, &owner)?; + let mut recovery_claim = None; + + let result = (|| { + let legacy_blocked = legacy_electron_data + .filter(|_| primary_candidate) + .map(|path| { + has_live_legacy_electron_with( + path, + election_directory, + pid_alive, + identity, + expected_electron, + ) + }) + .transpose()? + .unwrap_or(false); + let mut primary = false; + if primary_candidate && !legacy_blocked { + for _ in 0..ACQUIRE_ATTEMPTS { + if publish_owner(election_directory, &owner)? { + primary = true; + break; + } + let Some(observed) = read_if_exists(&owner_path(election_directory))? else { + continue; + }; + let Some(existing) = parse_owner(&observed) else { + break; + }; + if existing == owner { + primary = true; + break; + } + if owner_is_stale(&existing, pid_alive, identity) == Some(true) { + let claim = recovery_path(election_directory, &owner); + publish_file(&claim, &observed, "recovery claim")?; + recovery_claim = Some(claim); + } + if !retire_owner( + election_directory, + &observed, + &existing, + &owner, + pid_alive, + identity, + )? { + break; + } + } + } + Ok(Some(Self { + election_directory: election_directory.to_path_buf(), + participant_path: participant_path.clone(), + recovery_claim: recovery_claim.clone(), + owner: owner.clone(), + legacy_electron_data: legacy_electron_data.map(Path::to_path_buf), + primary, + released: false, + })) + })(); + if result.is_err() { + let _ = remove_participant_if_owned(&participant_path, &owner); + if let Some(claim) = recovery_claim { + let _ = fs::remove_file(claim); + } + } + result + } + + pub(super) fn is_primary(&self) -> bool { + if self.released || !self.primary { + return false; + } + let shared = read_if_exists(&owner_path(&self.election_directory)) + .ok() + .flatten() + .and_then(|value| parse_owner(&value)) + .is_some_and(|owner| owner == self.owner); + let legacy_clear = self + .legacy_electron_data + .as_deref() + .map(|path| { + has_live_legacy_electron_with( + path, + &self.election_directory, + pid_is_alive, + process_start_identity, + expected_electron_process, + ) + }) + .transpose() + .map(|blocked| !blocked.unwrap_or(false)) + .unwrap_or(false); + shared && legacy_clear + } + + pub(super) fn release(&mut self) -> Result { + if self.released { + return Ok(false); + } + retire_owner_if_owned(&self.election_directory, &self.owner)?; + remove_participant_if_owned(&self.participant_path, &self.owner)?; + if let Some(claim) = &self.recovery_claim { + match fs::remove_file(claim) { + Ok(()) => {} + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(err) => return Err(format!("failed to remove recovery claim: {err}")), + } + } + self.primary = false; + self.released = true; + Ok(true) + } +} + +impl Drop for Registration { + fn drop(&mut self) { + if let Err(err) = self.release() { + eprintln!("[client-state] failed to release cross-host registration: {err}"); + } + } +} + +fn valid_token(token: &str) -> bool { + !token.is_empty() + && token + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-') +} + +fn serialize_owner(owner: &Owner) -> Result { + serde_json::to_string(owner).map_err(|err| err.to_string()) +} + +fn parse_owner(value: &str) -> Option { + let owner = serde_json::from_str::(value).ok()?; + (owner.pid > 0 && valid_token(&owner.run_token) && !owner.process_start_identity.is_empty()) + .then_some(owner) +} + +fn owner_path(directory: &Path) -> PathBuf { + directory.join(OWNER_DIRECTORY).join(OWNER_FILENAME) +} + +fn participant_path(directory: &Path, owner: &Owner) -> PathBuf { + directory.join(format!( + "{PARTICIPANT_PREFIX}{}.{}{PARTICIPANT_SUFFIX}", + owner.pid, owner.run_token + )) +} + +fn recovery_path(directory: &Path, owner: &Owner) -> PathBuf { + directory.join(format!( + "{RECOVERY_PREFIX}{}.{}{RECOVERY_SUFFIX}", + owner.pid, owner.run_token + )) +} + +fn read_if_exists(path: &Path) -> Result, String> { + match fs::read_to_string(path) { + Ok(value) => Ok(Some(value)), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(err) => Err(format!( + "failed to read cross-host path {}: {err}", + path.display() + )), + } +} + +fn sync(file: &fs::File) -> std::io::Result<()> { + match file.sync_all() { + Ok(()) => Ok(()), + Err(err) if is_unsupported_sync_error(&err) => Ok(()), + Err(err) => Err(err), + } +} + +fn publish_file(path: &Path, value: &str, label: &str) -> Result<(), String> { + let parent = path + .parent() + .ok_or_else(|| format!("{label} path has no parent"))?; + let mut temporary = tempfile::NamedTempFile::new_in(parent) + .map_err(|err| format!("failed to create cross-host {label}: {err}"))?; + temporary + .write_all(value.as_bytes()) + .and_then(|_| sync(temporary.as_file())) + .map_err(|err| format!("failed to write cross-host {label}: {err}"))?; + match temporary.persist_noclobber(path) { + Ok(_) => Ok(()), + Err(err) if err.error.kind() == std::io::ErrorKind::AlreadyExists => { + if read_if_exists(path)?.as_deref() == Some(value) { + Ok(()) + } else { + Err(format!( + "cross-host {label} path belongs to another process" + )) + } + } + Err(err) => Err(format!( + "failed to publish cross-host {label}: {}", + err.error + )), + } +} + +fn publish_participant(path: &Path, owner: &Owner) -> Result<(), String> { + publish_file(path, &serialize_owner(owner)?, "participant") +} + +fn publish_owner(directory: &Path, owner: &Owner) -> Result { + let temporary = tempfile::Builder::new() + .prefix(".owner.") + .tempdir_in(directory) + .map_err(|err| format!("failed to prepare cross-host owner: {err}"))?; + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .open(temporary.path().join(OWNER_FILENAME)) + .map_err(|err| format!("failed to prepare cross-host owner: {err}"))?; + file.write_all(serialize_owner(owner)?.as_bytes()) + .and_then(|_| sync(&file)) + .map_err(|err| format!("failed to prepare cross-host owner: {err}"))?; + drop(file); + match fs::rename(temporary.path(), directory.join(OWNER_DIRECTORY)) { + Ok(()) => { + let _ = temporary.keep(); + Ok(true) + } + Err(err) + if err.kind() == std::io::ErrorKind::AlreadyExists + || directory.join(OWNER_DIRECTORY).exists() => + { + Ok(false) + } + Err(err) => Err(format!("failed to publish cross-host owner: {err}")), + } +} + +fn owner_is_stale( + owner: &Owner, + pid_alive: impl Fn(u32) -> bool, + identity: impl Fn(u32) -> Option, +) -> Option { + if !pid_alive(owner.pid) { + return Some(true); + } + identity(owner.pid).map(|live| live != owner.process_start_identity) +} + +fn participants(directory: &Path) -> Result, String> { + let mut participants = Vec::new(); + for entry in + fs::read_dir(directory).map_err(|err| format!("failed to read participants: {err}"))? + { + let entry = entry.map_err(|err| format!("failed to read participant: {err}"))?; + let name = entry.file_name(); + let name = name.to_string_lossy(); + if !name.starts_with(PARTICIPANT_PREFIX) || !name.ends_with(PARTICIPANT_SUFFIX) { + continue; + } + let Some(value) = read_if_exists(&entry.path())? else { + continue; + }; + let owner = parse_owner(&value) + .ok_or_else(|| "cross-host participant is incomplete".to_string())?; + participants.push((entry.path(), owner)); + } + Ok(participants) +} + +fn recovery_claimants( + directory: &Path, + current: &Owner, + observed_owner: &str, + pid_alive: impl Fn(u32) -> bool + Copy, + identity: impl Fn(u32) -> Option + Copy, +) -> Result>, String> { + let mut claimants = vec![current.clone()]; + for (path, participant) in participants(directory)? { + if participant == *current { + continue; + } + if owner_is_stale(&participant, pid_alive, identity) == Some(true) { + remove_participant_if_owned(&path, &participant)?; + match fs::remove_file(recovery_path(directory, &participant)) { + Ok(()) => {} + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(err) => return Err(format!("failed to remove stale recovery claim: {err}")), + } + continue; + } + let claim_path = recovery_path(directory, &participant); + let mut claim = read_if_exists(&claim_path)?; + for _ in 0..20 { + if claim.as_deref() == Some(observed_owner) { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + claim = read_if_exists(&claim_path)?; + } + if claim.as_deref() != Some(observed_owner) { + return Ok(None); + } + claimants.push(participant); + } + Ok(Some(claimants)) +} + +fn retire_owner( + directory: &Path, + observed: &str, + owner: &Owner, + claimant: &Owner, + pid_alive: impl Fn(u32) -> bool + Copy, + identity: impl Fn(u32) -> Option + Copy, +) -> Result { + if owner_is_stale(owner, pid_alive, identity) != Some(true) { + return Ok(false); + } + let Some(mut claimants) = + recovery_claimants(directory, claimant, observed, pid_alive, identity)? + else { + return Ok(false); + }; + claimants.sort_by_key(|candidate| serialize_owner(candidate).unwrap_or_default()); + if claimants.first() != Some(claimant) + || read_if_exists(&owner_path(directory))?.as_deref() != Some(observed) + { + return Ok(false); + } + let retired = directory.join(format!("{RETIRED_PREFIX}{}.{}", owner.pid, owner.run_token)); + match fs::rename(directory.join(OWNER_DIRECTORY), &retired) { + Ok(()) => Ok(true), + Err(err) + if err.kind() == std::io::ErrorKind::NotFound + || err.kind() == std::io::ErrorKind::AlreadyExists + || retired.exists() => + { + Ok(false) + } + Err(err) => Err(format!("failed to retire stale cross-host owner: {err}")), + } +} + +fn remove_participant_if_owned(path: &Path, owner: &Owner) -> Result<(), String> { + if read_if_exists(path)? + .as_deref() + .and_then(parse_owner) + .as_ref() + != Some(owner) + { + return Ok(()); + } + match fs::remove_file(path) { + Ok(()) => Ok(()), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(err) => Err(format!("failed to remove cross-host participant: {err}")), + } +} + +fn retire_owner_if_owned(directory: &Path, owner: &Owner) -> Result<(), String> { + retire_owner_if_owned_with(directory, owner, || {}, || {}) +} + +fn retire_owner_if_owned_with( + directory: &Path, + owner: &Owner, + on_checked: impl FnOnce(), + on_retired: impl FnOnce(), +) -> Result<(), String> { + let Some(observed) = read_if_exists(&owner_path(directory))? else { + return Ok(()); + }; + if parse_owner(&observed).as_ref() != Some(owner) { + return Ok(()); + } + on_checked(); + if read_if_exists(&owner_path(directory))?.as_deref() != Some(&observed) { + return Ok(()); + } + let retired = directory.join(format!("{RETIRED_PREFIX}{}.{}", owner.pid, owner.run_token)); + match fs::rename(directory.join(OWNER_DIRECTORY), &retired) { + Ok(()) => {} + Err(err) + if err.kind() == std::io::ErrorKind::NotFound + || err.kind() == std::io::ErrorKind::AlreadyExists + || retired.exists() => + { + return Ok(()); + } + Err(err) => return Err(format!("failed to retire owned cross-host owner: {err}")), + } + on_retired(); + let result: Result<(), String> = (|| { + for entry in + fs::read_dir(directory).map_err(|err| format!("failed to read participants: {err}"))? + { + let entry = entry.map_err(|err| format!("failed to read participant: {err}"))?; + let name = entry.file_name(); + let name = name.to_string_lossy(); + if !name.starts_with(PARTICIPANT_PREFIX) || !name.ends_with(PARTICIPANT_SUFFIX) { + continue; + } + let path = entry.path(); + let Some(observed_participant) = read_if_exists(&path)? else { + continue; + }; + if let Some(participant) = parse_owner(&observed_participant) { + remove_participant_if_owned(&path, &participant)?; + let _ = fs::remove_file(recovery_path(directory, &participant)); + } else if read_if_exists(&path)?.as_deref() == Some(&observed_participant) { + match fs::remove_file(&path) { + Ok(()) => {} + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(err) => { + return Err(format!( + "failed to remove malformed cross-host participant: {err}" + )) + } + } + } + } + Ok(()) + })(); + let _ = fs::remove_dir_all(retired); + result +} + +fn has_live_legacy_electron_with( + directory: &Path, + election_directory: &Path, + pid_alive: impl Fn(u32) -> bool, + identity: impl Fn(u32) -> Option, + expected_electron: impl Fn(u32) -> Option, +) -> Result { + let entries = match fs::read_dir(directory) { + Ok(entries) => entries, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(err) => return Err(format!("failed to inspect legacy Electron markers: {err}")), + }; + let upgraded: Vec = participants(election_directory)? + .into_iter() + .map(|(_, owner)| owner) + .collect(); + for entry in entries { + let entry = + entry.map_err(|err| format!("failed to inspect legacy Electron marker: {err}"))?; + let name = entry.file_name(); + let name = name.to_string_lossy(); + let Some(value) = name + .strip_prefix("client-state.running.") + .and_then(|value| value.strip_suffix(".json")) + else { + continue; + }; + let Some((pid, run_token)) = value.split_once('.') else { + return Ok(true); + }; + let Ok(pid) = pid.parse::() else { + return Ok(true); + }; + if !pid_alive(pid) { + continue; + } + let marker = read_if_exists(&entry.path())?; + let Some(marker) = marker + .as_deref() + .and_then(|value| serde_json::from_str::(value).ok()) + else { + return Ok(true); + }; + if marker.pid != pid || marker.run_token != run_token { + return Ok(true); + } + let live_identity = identity(pid); + if let Some(marker_identity) = marker.process_start_identity.as_deref() { + let Some(live_identity) = live_identity.as_deref() else { + return Ok(true); + }; + if live_identity != marker_identity { + continue; + } + if upgraded.iter().any(|owner| { + owner.pid == pid + && owner.run_token == run_token + && owner.process_start_identity == live_identity + }) { + continue; + } + return Ok(true); + } + match expected_electron(pid) { + Some(false) => continue, + Some(true) | None => return Ok(true), + } + } + Ok(false) +} + +fn expected_electron_process(pid: u32) -> Option { + let executable = process_executable(pid)?; + let current = std::env::current_exe().ok()?; + if paths_equal(&executable, ¤t) { + return Some(false); + } + let name = executable + .file_name()? + .to_string_lossy() + .to_ascii_lowercase(); + Some(matches!( + name.as_str(), + "codenomad" | "codenomad.exe" | "electron" | "electron.exe" + )) +} + +#[cfg(target_os = "linux")] +fn process_executable(pid: u32) -> Option { + fs::read_link(format!("/proc/{pid}/exe")).ok() +} + +#[cfg(target_os = "macos")] +fn process_executable(pid: u32) -> Option { + command_value("ps", &["-p", &pid.to_string(), "-o", "comm="]).map(PathBuf::from) +} + +#[cfg(windows)] +fn process_executable(pid: u32) -> Option { + command_value( + "powershell.exe", + &[ + "-NoProfile", + "-NonInteractive", + "-Command", + &format!("(Get-Process -Id {pid} -ErrorAction Stop).Path"), + ], + ) + .map(PathBuf::from) +} + +fn paths_equal(left: &Path, right: &Path) -> bool { + if cfg!(windows) { + left.to_string_lossy() + .eq_ignore_ascii_case(&right.to_string_lossy()) + } else { + left == right + } +} + +fn is_unsupported_sync_error(error: &std::io::Error) -> bool { + if error.kind() == std::io::ErrorKind::Unsupported { + return true; + } + #[cfg(unix)] + { + matches!( + error.raw_os_error(), + Some(libc::EINVAL) | Some(libc::ENOSYS) | Some(libc::ENOTSUP) + ) + } + #[cfg(not(unix))] + false +} + +#[cfg(unix)] +fn pid_is_alive(pid: u32) -> bool { + let result = unsafe { libc::kill(pid as libc::pid_t, 0) }; + result == 0 || std::io::Error::last_os_error().raw_os_error() != Some(libc::ESRCH) +} + +#[cfg(windows)] +fn pid_is_alive(pid: u32) -> bool { + use windows_sys::Win32::Foundation::{CloseHandle, ERROR_INVALID_PARAMETER, STILL_ACTIVE}; + use windows_sys::Win32::System::Threading::{ + GetExitCodeProcess, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, + }; + unsafe { + let process = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid); + if process.is_null() { + return windows_sys::Win32::Foundation::GetLastError() != ERROR_INVALID_PARAMETER; + } + let mut exit_code = 0; + let alive = + GetExitCodeProcess(process, &mut exit_code) == 0 || exit_code == STILL_ACTIVE as u32; + CloseHandle(process); + alive + } +} + +#[cfg(target_os = "linux")] +fn process_start_identity(pid: u32) -> Option { + let stat = fs::read_to_string(format!("/proc/{pid}/stat")).ok()?; + let command_end = stat.rfind(')')?; + let start_ticks = stat[command_end + 1..].split_whitespace().nth(19)?; + let boot_id = fs::read_to_string("/proc/sys/kernel/random/boot_id").ok()?; + let boot_id = boot_id.trim(); + (!boot_id.is_empty()).then(|| format!("linux:{boot_id}:{start_ticks}")) +} + +#[cfg(any(target_os = "macos", windows))] +fn command_value(command: &str, args: &[&str]) -> Option { + for _ in 0..2 { + let mut command = Command::new(command); + command + .args(args) + .stdout(Stdio::piped()) + .stderr(Stdio::null()); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + command.creation_flags(0x08000000); + } + let Ok(mut child) = command.spawn() else { + continue; + }; + let deadline = Instant::now() + Duration::from_secs(5); + let status = loop { + match child.try_wait() { + Ok(Some(status)) => break Some(status), + Ok(None) if Instant::now() < deadline => { + std::thread::sleep(Duration::from_millis(10)) + } + _ => { + let _ = child.kill(); + let _ = child.wait(); + break None; + } + } + }; + if status.is_some_and(|status| status.success()) { + let mut value = String::new(); + use std::io::Read; + if child.stdout.take()?.read_to_string(&mut value).is_ok() && !value.trim().is_empty() { + return Some(value.trim().to_string()); + } + } + } + None +} + +#[cfg(target_os = "macos")] +fn process_start_identity(pid: u32) -> Option { + command_value("ps", &["-p", &pid.to_string(), "-o", "lstart="]) + .map(|value| format!("darwin:{value}")) +} + +#[cfg(windows)] +fn process_start_identity(pid: u32) -> Option { + command_value( + "powershell.exe", + &[ + "-NoProfile", + "-NonInteractive", + "-Command", + &format!("(Get-CimInstance Win32_Process -Filter \"ProcessId = {pid}\" -ErrorAction Stop).CreationDate.ToUniversalTime().Ticks"), + ], + ) + .map(|value| format!("win32:{value}")) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + use std::io::{BufRead, BufReader}; + use std::process::{Command, Stdio}; + use std::sync::{Arc, Barrier}; + use std::thread; + + fn owner(pid: u32, token: &str, identity: &str) -> Owner { + Owner { + pid, + run_token: token.to_string(), + process_start_identity: identity.to_string(), + } + } + + fn node_host( + election: &Path, + start: &Path, + mode: &str, + user_data: Option<&Path>, + legacy_tauri_data: Option<&Path>, + ) -> std::process::Child { + let manifest = Path::new(env!("CARGO_MANIFEST_DIR")); + let script = + manifest.join("../../electron-app/electron/main/client-state-cross-host-child.ts"); + let mut command = Command::new("node"); + command + .current_dir(manifest.join("../../..")) + .args(["--import", "tsx"]) + .arg(script) + .arg(election) + .arg(start) + .args(["", mode]); + if let Some(user_data) = user_data { + command.arg(user_data).args(["", ""]); + if let Some(legacy) = legacy_tauri_data { + command.arg(legacy); + } + } + command + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("Node and tsx are required for cross-language election tests") + } + + fn node_primary(child: &mut std::process::Child) -> bool { + let mut line = String::new(); + BufReader::new(child.stdout.as_mut().unwrap()) + .read_line(&mut line) + .unwrap(); + assert!(!line.is_empty()); + serde_json::from_str::(&line).unwrap()["acquired"] + .as_bool() + .unwrap() + } + + fn stop_node(mut child: std::process::Child) { + drop(child.stdin.take()); + let output = child.wait_with_output().unwrap(); + assert!( + output.status.success(), + "Node host failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + #[test] + fn simultaneous_startup_elects_one_primary() { + let directory = tempfile::tempdir().unwrap(); + let start = Arc::new(Barrier::new(5)); + let finish = Arc::new(Barrier::new(5)); + let handles: Vec<_> = (0..4) + .map(|index| { + let directory = directory.path().to_path_buf(); + let start = Arc::clone(&start); + let finish = Arc::clone(&finish); + thread::spawn(move || { + start.wait(); + let registration = Registration::register_with( + &directory, + owner( + 100 + index, + &format!("run-{index}"), + &format!("start-{index}"), + ), + true, + |_| true, + |pid| Some(format!("start-{}", pid - 100)), + ) + .unwrap() + .unwrap(); + let primary = registration.is_primary(); + finish.wait(); + primary + }) + }) + .collect(); + start.wait(); + finish.wait(); + assert_eq!( + handles + .into_iter() + .map(|handle| handle.join().unwrap()) + .filter(|primary| *primary) + .count(), + 1 + ); + } + + #[test] + fn electron_and_tauri_simultaneous_startup_with_legacy_detection_elects_one() { + use crate::client_state::process; + + let root = tempfile::tempdir().unwrap(); + let election = root.path().join("election"); + let node_data = root.path().join("electron"); + let rust_data = root.path().join("tauri"); + fs::create_dir_all(&rust_data).unwrap(); + let start = root.path().join("start"); + let mut node = node_host( + &election, + &start, + "full", + Some(&node_data), + Some(&rust_data), + ); + fs::write(&start, b"").unwrap(); + let rust = process::Registration::initialize(&rust_data, &election, Some(&node_data)) + .unwrap() + .finish(); + let node_primary = node_primary(&mut node); + assert_ne!(node_primary, rust.is_primary()); + rust.release_locks(); + stop_node(node); + } + + #[test] + fn node_crash_points_leave_rust_a_safe_path_to_ownership() { + let owner_crash = tempfile::tempdir().unwrap(); + let start = owner_crash.path().join("start"); + let mut node = node_host(owner_crash.path(), &start, "owner-crash", None, None); + fs::write(&start, b"").unwrap(); + assert_eq!(node.wait().unwrap().code(), Some(91)); + let rust = Registration::register(owner_crash.path(), true, None) + .unwrap() + .unwrap(); + assert!(rust.is_primary()); + + let retirement_crash = tempfile::tempdir().unwrap(); + publish_owner( + retirement_crash.path(), + &owner(4_000_000_000, "stale", "stale-start"), + ) + .unwrap(); + let start = retirement_crash.path().join("start"); + let mut node = node_host(retirement_crash.path(), &start, "retire-crash", None, None); + fs::write(&start, b"").unwrap(); + assert_eq!(node.wait().unwrap().code(), Some(91)); + let rust = Registration::register(retirement_crash.path(), true, None) + .unwrap() + .unwrap(); + assert!(rust.is_primary()); + } + + #[test] + fn legacy_tauri_pid_reuse_does_not_veto_electron() { + let root = tempfile::tempdir().unwrap(); + let election = root.path().join("election"); + let electron_data = root.path().join("electron"); + let legacy_tauri = root.path().join("tauri"); + fs::create_dir_all(&legacy_tauri).unwrap(); + fs::write( + legacy_tauri.join(format!( + "client-state.running.{}.legacy.lock", + std::process::id() + )), + b"", + ) + .unwrap(); + let start = root.path().join("start"); + let mut node = node_host( + &election, + &start, + "full", + Some(&electron_data), + Some(&legacy_tauri), + ); + fs::write(&start, b"").unwrap(); + assert!(node_primary(&mut node)); + stop_node(node); + } + + #[test] + fn crashed_node_primary_remains_fenced_by_rust_secondary() { + let root = tempfile::tempdir().unwrap(); + let start = root.path().join("start"); + let mut node = node_host(root.path(), &start, "", None, None); + fs::write(&start, b"").unwrap(); + assert!(node_primary(&mut node)); + let mut secondary = Registration::register(root.path(), true, None) + .unwrap() + .unwrap(); + assert!(!secondary.is_primary()); + node.kill().unwrap(); + node.wait().unwrap(); + + let mut blocked = Registration::register(root.path(), true, None) + .unwrap() + .unwrap(); + assert!(!blocked.is_primary()); + secondary.release().unwrap(); + blocked.release().unwrap(); + let successor = Registration::register(root.path(), true, None) + .unwrap() + .unwrap(); + assert!(successor.is_primary()); + } + + #[test] + fn owner_publication_crash_is_invisible() { + let directory = tempfile::tempdir().unwrap(); + let pending = directory.path().join(".owner.crashed.tmp"); + fs::create_dir(&pending).unwrap(); + fs::write(pending.join(OWNER_FILENAME), b"partial").unwrap(); + let registration = Registration::register_with( + directory.path(), + owner(201, "winner", "winner-start"), + true, + |_| true, + |_| Some("winner-start".to_string()), + ) + .unwrap() + .unwrap(); + assert!(registration.is_primary()); + } + + #[test] + fn stale_retirement_crash_leaves_successor_safe() { + let directory = tempfile::tempdir().unwrap(); + let stale = owner(301, "stale", "stale-start"); + assert!(publish_owner(directory.path(), &stale).unwrap()); + let observed = fs::read_to_string(owner_path(directory.path())).unwrap(); + assert!(retire_owner( + directory.path(), + &observed, + &stale, + &owner(302, "claimant", "claimant-start"), + |_| false, + |_| None + ) + .unwrap()); + let successor = Registration::register_with( + directory.path(), + owner(302, "successor", "successor-start"), + true, + |_| true, + |_| Some("successor-start".to_string()), + ) + .unwrap() + .unwrap(); + assert!(successor.is_primary()); + assert!(directory.path().join("retired.301.stale").exists()); + } + + #[test] + fn stale_owner_retirement_is_identity_and_cohort_guarded() { + for (alive, identity, recovered) in [ + (false, None, true), + (true, Some("reused"), true), + (true, Some("old-start"), false), + (true, None, false), + ] { + let directory = tempfile::tempdir().unwrap(); + publish_owner(directory.path(), &owner(401, "old", "old-start")).unwrap(); + let registration = Registration::register_with( + directory.path(), + owner(402, "new", "new-start"), + true, + |_| alive, + |_| identity.map(str::to_string), + ) + .unwrap() + .unwrap(); + assert_eq!(registration.is_primary(), recovered); + } + + let directory = tempfile::tempdir().unwrap(); + publish_owner(directory.path(), &owner(501, "dead-primary", "old")).unwrap(); + publish_participant( + &participant_path( + directory.path(), + &owner(502, "secondary", "secondary-start"), + ), + &owner(502, "secondary", "secondary-start"), + ) + .unwrap(); + let identities = HashMap::from([(502, "secondary-start")]); + let blocked = Registration::register_with( + directory.path(), + owner(503, "next", "next-start"), + true, + |pid| pid == 502, + |pid| identities.get(&pid).map(|value| value.to_string()), + ) + .unwrap() + .unwrap(); + assert!(!blocked.is_primary()); + } + + #[test] + fn simultaneous_claimants_deterministically_recover_a_stale_owner() { + let directory = tempfile::tempdir().unwrap(); + let stale = owner(601, "stale", "stale-start"); + let first = owner(602, "a", "a-start"); + let second = owner(603, "b", "b-start"); + publish_owner(directory.path(), &stale).unwrap(); + let observed = fs::read_to_string(owner_path(directory.path())).unwrap(); + publish_participant(&participant_path(directory.path(), &second), &second).unwrap(); + publish_file( + &recovery_path(directory.path(), &second), + &observed, + "recovery claim", + ) + .unwrap(); + let identities = HashMap::from([(602, "a-start"), (603, "b-start")]); + let winner = Registration::register_with( + directory.path(), + first, + true, + |pid| pid != stale.pid, + |pid| identities.get(&pid).map(|value| value.to_string()), + ) + .unwrap() + .unwrap(); + let loser = Registration::register_with( + directory.path(), + second, + true, + |pid| pid != stale.pid, + |pid| identities.get(&pid).map(|value| value.to_string()), + ) + .unwrap() + .unwrap(); + assert!(winner.is_primary()); + assert!(!loser.is_primary()); + } + + #[test] + fn graceful_primary_release_allows_a_successor_while_a_secondary_remains() { + let directory = tempfile::tempdir().unwrap(); + let mut primary = Registration::register_with( + directory.path(), + owner(601, "primary", "primary-start"), + true, + |_| true, + |_| Some("primary-start".to_string()), + ) + .unwrap() + .unwrap(); + let secondary = Registration::register_with( + directory.path(), + owner(602, "secondary", "secondary-start"), + true, + |_| true, + |_| Some("primary-start".to_string()), + ) + .unwrap() + .unwrap(); + assert!(!secondary.is_primary()); + + assert!(primary.release().unwrap()); + let successor = Registration::register_with( + directory.path(), + owner(603, "successor", "successor-start"), + true, + |_| true, + |_| Some("successor-start".to_string()), + ) + .unwrap() + .unwrap(); + assert!(successor.is_primary()); + } + + #[test] + fn graceful_handoff_retires_the_old_cohort_so_a_crashed_successor_can_recover() { + let directory = tempfile::tempdir().unwrap(); + let secondary_owner = owner(622, "secondary", "secondary-start"); + let successor_owner = owner(623, "successor", "successor-start"); + let late_owner = owner(625, "late", "late-start"); + let primary = Registration::register_with( + directory.path(), + owner(621, "primary", "primary-start"), + true, + |_| true, + |_| Some("primary-start".to_string()), + ) + .unwrap() + .unwrap(); + let _secondary = Registration::register_with( + directory.path(), + secondary_owner.clone(), + true, + |_| true, + |_| Some("primary-start".to_string()), + ) + .unwrap() + .unwrap(); + + let malformed = directory.path().join("participant.malformed.json"); + retire_owner_if_owned_with( + directory.path(), + &primary.owner, + || { + publish_participant( + &participant_path(directory.path(), &successor_owner), + &successor_owner, + ) + .unwrap(); + publish_participant( + &participant_path(directory.path(), &late_owner), + &late_owner, + ) + .unwrap(); + fs::write(&malformed, b"malformed").unwrap(); + }, + || assert!(publish_owner(directory.path(), &successor_owner).unwrap()), + ) + .unwrap(); + assert!(!directory.path().join("retired.621.primary").exists()); + assert_eq!( + parse_owner(&fs::read_to_string(owner_path(directory.path())).unwrap()), + Some(successor_owner) + ); + assert!(!participant_path( + directory.path(), + &owner(623, "successor", "successor-start") + ) + .exists()); + assert!(!participant_path(directory.path(), &late_owner).exists()); + assert!(!malformed.exists()); + + let claimant_owner = owner(624, "claimant", "claimant-start"); + let identities = HashMap::from([ + (secondary_owner.pid, secondary_owner.process_start_identity), + (late_owner.pid, late_owner.process_start_identity), + ( + claimant_owner.pid, + claimant_owner.process_start_identity.clone(), + ), + ]); + let claimant = Registration::register_with( + directory.path(), + claimant_owner, + true, + |pid| identities.contains_key(&pid), + |pid| identities.get(&pid).cloned(), + ) + .unwrap() + .unwrap(); + assert!(claimant.is_primary()); + } + + #[test] + fn non_owner_release_does_not_remove_a_live_owners_record() { + let directory = tempfile::tempdir().unwrap(); + let primary_owner = owner(611, "primary", "primary-start"); + let primary = Registration::register_with( + directory.path(), + primary_owner.clone(), + true, + |_| true, + |_| Some("primary-start".to_string()), + ) + .unwrap() + .unwrap(); + let mut secondary = Registration::register_with( + directory.path(), + owner(612, "secondary", "secondary-start"), + true, + |_| true, + |_| Some("primary-start".to_string()), + ) + .unwrap() + .unwrap(); + + assert!(secondary.release().unwrap()); + assert!(primary.is_primary()); + assert_eq!( + parse_owner(&fs::read_to_string(owner_path(directory.path())).unwrap()), + Some(primary_owner) + ); + } + + #[test] + fn upgraded_markers_do_not_veto_but_unmatched_legacy_does() { + let election = tempfile::tempdir().unwrap(); + let legacy = tempfile::tempdir().unwrap(); + let upgraded = owner(701, "electron", "electron-start"); + publish_participant(&participant_path(election.path(), &upgraded), &upgraded).unwrap(); + fs::write( + legacy.path().join("client-state.running.701.electron.json"), + serialize_owner(&upgraded).unwrap(), + ) + .unwrap(); + assert!(!has_live_legacy_electron_with( + legacy.path(), + election.path(), + |_| true, + |_| Some("electron-start".to_string()), + |_| Some(true), + ) + .unwrap()); + fs::write( + legacy.path().join("client-state.running.702.legacy.json"), + r#"{"pid":702,"runToken":"legacy"}"#, + ) + .unwrap(); + assert!(has_live_legacy_electron_with( + legacy.path(), + election.path(), + |_| true, + |_| None, + |_| None, + ) + .unwrap()); + } + + #[test] + fn platform_paths_match_electron() { + let resolve = |platform: &str, values: HashMap<&str, &str>, fallback: &str| { + resolve_election_directory_for( + platform, + |name| values.get(name).map(OsString::from), + Some(Path::new(fallback)), + ) + .unwrap() + }; + assert_eq!( + resolve("linux", HashMap::from([("HOME", "/home/dev")]), "/fallback"), + "/home/dev/.codenomad/client-state/election" + ); + assert_eq!( + resolve( + "windows", + HashMap::from([("USERPROFILE", ""), ("HOME", "D:\\Home")]), + "C:\\Fallback" + ), + "D:\\Home\\.codenomad\\client-state\\election" + ); + let resolve_state = |platform: &str, values: HashMap<&str, &str>, fallback: &str| { + resolve_state_path_for( + platform, + |name| values.get(name).map(OsString::from), + Some(Path::new(fallback)), + ) + .unwrap() + }; + assert_eq!( + resolve_state( + "macos", + HashMap::from([("HOME", "/Users/dev")]), + "/fallback" + ), + "/Users/dev/.codenomad/client-state/client-state.json" + ); + assert_eq!( + resolve_state("linux", HashMap::from([("HOME", "/home/dev")]), "/fallback"), + "/home/dev/.codenomad/client-state/client-state.json" + ); + assert_eq!( + resolve_state( + "windows", + HashMap::from([("USERPROFILE", ""), ("HOME", "D:\\Home")]), + "C:\\Fallback" + ), + "D:\\Home\\.codenomad\\client-state\\client-state.json" + ); + } +} diff --git a/packages/tauri-app/src-tauri/src/client_state/navigation.rs b/packages/tauri-app/src-tauri/src/client_state/navigation.rs index 997d2e0e..ef49dbc0 100644 --- a/packages/tauri-app/src-tauri/src/client_state/navigation.rs +++ b/packages/tauri-app/src-tauri/src/client_state/navigation.rs @@ -1,21 +1,14 @@ use super::ClientState; -use serde::Serialize; use std::collections::VecDeque; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::{mpsc, LazyLock, Mutex}; -use std::time::Duration; -use tauri::{AppHandle, Emitter, Manager}; +use std::sync::{LazyLock, Mutex}; +use tauri::{AppHandle, Manager}; use url::Url; -const RENDERER_FLUSH_TIMEOUT: Duration = Duration::from_secs(1); -const RELOAD_COALESCED: &str = "reload coalesced with an existing reload request"; -const RELOAD_SUPERSEDED: &str = "reload superseded by a queued force reload"; -static NEXT_GENERATION: AtomicU64 = AtomicU64::new(0); -static PENDING_FLUSH: LazyLock = LazyLock::new(PendingFlush::default); static NAVIGATIONS: LazyLock>> = LazyLock::new(|| Mutex::new(NavigationQueue::default())); type Operation = Box Result<(), String> + Send + 'static>; +type NavigationGuard = Box bool + Send + 'static>; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum NavigationKind { @@ -24,55 +17,21 @@ pub(crate) enum NavigationKind { ForceReload, } -impl NavigationKind { - fn description(self) -> &'static str { - match self { - Self::Cli => "CLI navigation", - Self::Reload => "reload", - Self::ForceReload => "force reload", - } - } -} - -#[derive(Clone, Copy, Serialize)] -#[serde(rename_all = "camelCase")] -struct NavigationFlushRequest { - generation: u64, -} - struct NavigationOperation { app: AppHandle, target_url: Option, + is_current: NavigationGuard, navigate: Operation, } struct QueuedNavigation { kind: NavigationKind, - description: String, value: T, - completion: mpsc::Sender>, } impl QueuedNavigation { - fn new( - kind: NavigationKind, - description: impl Into, - value: T, - ) -> (Self, mpsc::Receiver>) { - let (completion, receiver) = mpsc::channel(); - ( - Self { - kind, - description: description.into(), - value, - completion, - }, - receiver, - ) - } - - fn finish(self, result: Result<(), String>) { - finish_request(&self.description, self.completion, result); + fn new(kind: NavigationKind, value: T) -> Self { + Self { kind, value } } } @@ -97,7 +56,6 @@ impl NavigationQueue { if request.kind != NavigationKind::Cli { if self.active == Some(request.kind) || self.active == Some(NavigationKind::ForceReload) { - request.finish(Err(RELOAD_COALESCED.to_string())); return false; } @@ -109,10 +67,7 @@ impl NavigationQueue { if request.kind == NavigationKind::ForceReload && self.pending[index].kind == NavigationKind::Reload { - let replaced = std::mem::replace(&mut self.pending[index], request); - replaced.finish(Err(RELOAD_SUPERSEDED.to_string())); - } else { - request.finish(Err(RELOAD_COALESCED.to_string())); + self.pending[index] = request; } return false; } @@ -153,54 +108,28 @@ impl NavigationQueue { } } -#[derive(Default)] -struct PendingFlush { - sender: Mutex)>>, -} - -impl PendingFlush { - fn begin(&self, generation: u64) -> mpsc::Receiver<()> { - let (sender, receiver) = mpsc::sync_channel(1); - *self.sender.lock().unwrap_or_else(|err| err.into_inner()) = Some((generation, sender)); - receiver - } - - fn acknowledge(&self, generation: u64) { - let sender = self - .sender - .lock() - .unwrap_or_else(|err| err.into_inner()) - .as_ref() - .filter(|(current, _)| *current == generation) - .map(|(_, sender)| sender.clone()); - if let Some(sender) = sender { - let _ = sender.try_send(()); - } - } - - fn complete(&self, generation: u64) { - let mut pending = self.sender.lock().unwrap_or_else(|err| err.into_inner()); - if pending - .as_ref() - .is_some_and(|(current, _)| *current == generation) - { - pending.take(); - } - } -} - pub(crate) fn before_main_window_navigation( app: &AppHandle, kind: NavigationKind, target_url: Option, navigate: impl FnOnce(AppHandle) -> Result<(), String> + Send + 'static, ) { - let (request, _completion) = QueuedNavigation::new( + before_main_window_navigation_if(app, kind, target_url, || true, navigate); +} + +pub(crate) fn before_main_window_navigation_if( + app: &AppHandle, + kind: NavigationKind, + target_url: Option, + is_current: impl Fn() -> bool + Send + 'static, + navigate: impl FnOnce(AppHandle) -> Result<(), String> + Send + 'static, +) { + let request = QueuedNavigation::new( kind, - kind.description(), NavigationOperation { app: app.clone(), target_url, + is_current: Box::new(is_current), navigate: Box::new(navigate), }, ); @@ -223,23 +152,38 @@ fn run_navigation_queue() { return; }; - let QueuedNavigation { - description, - completion, - value: - NavigationOperation { - app, - target_url, - navigate, - }, - .. - } = request; - wait_for_renderer_flush(&app); - let state = app.try_state::(); - let result = execute_navigation(state.as_deref(), target_url.as_ref(), || { - navigate(app.clone()) + let NavigationOperation { + app, + target_url, + is_current, + navigate, + } = request.value; + if !is_current() { + NAVIGATIONS + .lock() + .unwrap_or_else(|err| err.into_inner()) + .complete_active(); + continue; + } + if let Some(state) = app.try_state::() { + state.wait_for_renderer_flush(&app, true); + } + if !is_current() { + NAVIGATIONS + .lock() + .unwrap_or_else(|err| err.into_inner()) + .complete_active(); + continue; + } + let result = crate::shutdown::with_navigation_authority(&app, || { + let state = app.try_state::(); + execute_navigation(state.as_deref(), target_url.as_ref(), || { + navigate(app.clone()) + }) }); - finish_request(&description, completion, result); + if let Some(Err(err)) = result { + eprintln!("[client-state] navigation failed: {err}"); + } NAVIGATIONS .lock() @@ -248,117 +192,64 @@ fn run_navigation_queue() { } } -fn wait_for_renderer_flush(app: &AppHandle) { - let renderer_claimed = app - .try_state::() - .is_some_and(|state| state.renderer_access_is_claimed()); - if !renderer_claimed { - return; - } - let Some(window) = app.get_webview_window("main") else { - return; - }; - - let generation = NEXT_GENERATION.fetch_add(1, Ordering::SeqCst) + 1; - let receiver = PENDING_FLUSH.begin(generation); - if let Err(err) = window.emit( - "client-state:navigation-flush-requested", - NavigationFlushRequest { generation }, - ) { - eprintln!("[client-state] failed to request renderer navigation flush: {err}"); - } - let _ = receiver.recv_timeout(RENDERER_FLUSH_TIMEOUT); - PENDING_FLUSH.complete(generation); -} - fn execute_navigation( state: Option<&ClientState>, target_url: Option<&Url>, navigate: impl FnOnce() -> Result<(), String>, ) -> Result<(), String> { - if let Some(state) = state { - state.begin_renderer_navigation(target_url)?; - } + let pending = state + .map(|state| state.renderer_access.begin_navigation(target_url)) + .transpose()?; let result = navigate(); if result.is_err() { - if let Some(state) = state { - state.cancel_renderer_navigation(); + if let (Some(state), Some(pending)) = (state, pending) { + state.renderer_access.cancel_navigation(pending); } } result } -fn finish_request( - description: &str, - completion: mpsc::Sender>, - result: Result<(), String>, -) { - if let Err(err) = &result { - eprintln!("[client-state] {description} failed: {err}"); - } - let _ = completion.send(result); -} - -pub(crate) fn renderer_flushed(generation: u64) { - PENDING_FLUSH.acknowledge(generation); -} - #[cfg(test)] mod tests { use super::*; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; - fn queued( - kind: NavigationKind, - name: &'static str, - ) -> ( - QueuedNavigation<&'static str>, - mpsc::Receiver>, - ) { - QueuedNavigation::new(kind, name, name) + fn queued(kind: NavigationKind, name: &'static str) -> QueuedNavigation<&'static str> { + QueuedNavigation::new(kind, name) } #[test] fn cli_navigation_survives_overlapping_reload_and_force_reload() { let mut queue = NavigationQueue::default(); - let (cli, cli_result) = queued(NavigationKind::Cli, "cli"); + let cli = queued(NavigationKind::Cli, "cli"); assert!(queue.enqueue(cli)); let cli = queue.next().unwrap(); assert_eq!(cli.value, "cli"); - let (reload, reload_result) = queued(NavigationKind::Reload, "reload"); + let reload = queued(NavigationKind::Reload, "reload"); assert!(!queue.enqueue(reload)); - let (force_reload, force_reload_result) = - queued(NavigationKind::ForceReload, "force reload"); + let force_reload = queued(NavigationKind::ForceReload, "force reload"); assert!(!queue.enqueue(force_reload)); - assert_eq!( - reload_result.recv().unwrap(), - Err(RELOAD_SUPERSEDED.to_string()) - ); - cli.finish(Ok(())); queue.complete_active(); - assert_eq!(cli_result.recv().unwrap(), Ok(())); let force_reload = queue.next().unwrap(); assert_eq!(force_reload.value, "force reload"); - force_reload.finish(Ok(())); queue.complete_active(); - assert_eq!(force_reload_result.recv().unwrap(), Ok(())); assert!(queue.next().is_none()); } #[test] fn cli_requests_jump_queued_reload_without_reordering_each_other() { let mut queue = NavigationQueue::default(); - let (active_cli, _) = queued(NavigationKind::Cli, "active cli"); + let active_cli = queued(NavigationKind::Cli, "active cli"); queue.enqueue(active_cli); queue.next().unwrap(); - let (reload, _) = queued(NavigationKind::Reload, "reload"); - let (first_cli, _) = queued(NavigationKind::Cli, "first cli"); - let (second_cli, _) = queued(NavigationKind::Cli, "second cli"); + let reload = queued(NavigationKind::Reload, "reload"); + let first_cli = queued(NavigationKind::Cli, "first cli"); + let second_cli = queued(NavigationKind::Cli, "second cli"); queue.enqueue(reload); queue.enqueue(first_cli); queue.enqueue(second_cli); @@ -371,25 +262,6 @@ mod tests { assert_eq!(queue.next().unwrap().value, "reload"); } - #[test] - fn stale_flush_acknowledgement_cannot_release_the_next_request() { - let pending = PendingFlush::default(); - let first = pending.begin(1); - pending.acknowledge(1); - first.recv_timeout(Duration::from_millis(10)).unwrap(); - pending.complete(1); - - let second = pending.begin(2); - pending.acknowledge(1); - assert_eq!( - second.recv_timeout(Duration::from_millis(10)), - Err(mpsc::RecvTimeoutError::Timeout) - ); - pending.acknowledge(2); - second.recv_timeout(Duration::from_millis(10)).unwrap(); - pending.complete(2); - } - #[test] fn failed_navigation_preserves_renderer_access_and_runs_once() { let directory = tempfile::tempdir().unwrap(); @@ -397,7 +269,8 @@ mod tests { let renderer_url = url::Url::parse("http://127.0.0.1:43123/workspace").unwrap(); let calls = Arc::new(AtomicUsize::new(0)); state - .claim_renderer_access("current-renderer", &renderer_url) + .renderer_access + .claim("current-renderer", &renderer_url) .unwrap(); let calls_for_navigation = Arc::clone(&calls); @@ -409,7 +282,8 @@ mod tests { assert_eq!(result, Err("synchronous navigation failure".to_string())); assert_eq!(calls.load(Ordering::SeqCst), 1); state - .validate_renderer_access("current-renderer", &renderer_url) + .renderer_access + .validate("current-renderer", &renderer_url) .unwrap(); } @@ -420,23 +294,28 @@ mod tests { let outgoing_url = url::Url::parse("http://127.0.0.1:43123/workspace").unwrap(); let incoming_url = url::Url::parse("http://127.0.0.1:43124/workspace").unwrap(); state - .claim_renderer_access("outgoing-renderer", &outgoing_url) + .renderer_access + .claim("outgoing-renderer", &outgoing_url) .unwrap(); execute_navigation(Some(&state), Some(&incoming_url), || Ok(())).unwrap(); state - .validate_renderer_access("outgoing-renderer", &outgoing_url) + .renderer_access + .validate("outgoing-renderer", &outgoing_url) .unwrap(); - assert!(state.renderer_origin_can_claim(&outgoing_url)); + assert!(state.renderer_access.allows_claim_origin(&outgoing_url)); state - .claim_renderer_access("incoming-renderer", &incoming_url) + .renderer_access + .claim("incoming-renderer", &incoming_url) .unwrap(); assert!(state - .validate_renderer_access("outgoing-renderer", &outgoing_url) + .renderer_access + .validate("outgoing-renderer", &outgoing_url) .is_err()); state - .validate_renderer_access("incoming-renderer", &incoming_url) + .renderer_access + .validate("incoming-renderer", &incoming_url) .unwrap(); } } diff --git a/packages/tauri-app/src-tauri/src/client_state/process.rs b/packages/tauri-app/src-tauri/src/client_state/process.rs index 061f33cc..2107c089 100644 --- a/packages/tauri-app/src-tauri/src/client_state/process.rs +++ b/packages/tauri-app/src-tauri/src/client_state/process.rs @@ -1,11 +1,12 @@ use fs2::FileExt; use std::fs::{self, File, OpenOptions}; -use std::io::{Seek, SeekFrom, Write}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Mutex; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; +use super::cross_host; + pub(super) const PRIMARY_LOCK_FILENAME: &str = "client-state.primary.lock"; const REGISTRATION_LOCK_FILENAME: &str = "client-state.registration.lock"; const REGISTRATION_OWNER_FILENAME: &str = "client-state.registration.owner"; @@ -18,17 +19,25 @@ static NEXT_RUNNING_MARKER_ID: AtomicU64 = AtomicU64::new(0); pub(super) struct ProcessState { primary_lock: Mutex>, + cross_host_registration: Mutex>, running_marker: Mutex>, registration_file: Option, } pub(super) struct Registration { - process: ProcessState, - registration_file: Option, + primary_lock: Option, + cross_host_registration: Option, + running_marker: RunningMarker, + registration_file: File, + holds_registration_lock: bool, } impl Registration { - pub(super) fn initialize(app_data_dir: &Path) -> Result { + pub(super) fn initialize( + app_data_dir: &Path, + cross_host_election_dir: &Path, + legacy_electron_data_dir: Option<&Path>, + ) -> Result { let registration_path = app_data_dir.join(REGISTRATION_LOCK_FILENAME); let registration_file = OpenOptions::new() .read(true) @@ -60,14 +69,14 @@ impl Registration { let running_marker = create_running_marker(app_data_dir, acknowledged_registration.as_deref())?; let lock_path = app_data_dir.join(PRIMARY_LOCK_FILENAME); - let mut lock_file = OpenOptions::new() + let lock_file = OpenOptions::new() .read(true) .write(true) .create(true) .open(&lock_path) .map_err(|err| format!("failed to open primary lock {}: {err}", lock_path.display()))?; - let primary_lock = if !has_registration_lock { + let mut primary_lock = if !has_registration_lock { None } else { match FileExt::try_lock_exclusive(&lock_file) { @@ -77,19 +86,7 @@ impl Registration { &running_marker.path, ®istration_id, ) { - Ok(false) => { - lock_file - .set_len(0) - .and_then(|_| lock_file.seek(SeekFrom::Start(0)).map(|_| ())) - .and_then(|_| { - write!(lock_file, "{{\"pid\":{}}}\n", std::process::id()) - }) - .and_then(|_| lock_file.sync_data()) - .map_err(|err| { - format!("failed to record primary lock owner: {err}") - })?; - Some(lock_file) - } + Ok(false) => Some(lock_file), Ok(true) => { release_primary_file(&lock_file); None @@ -110,32 +107,56 @@ impl Registration { } }; + let cross_host_registration = match cross_host::Registration::register( + cross_host_election_dir, + primary_lock.is_some(), + legacy_electron_data_dir, + ) { + Ok(Some(registration)) => Some(registration), + Ok(None) => None, + Err(err) => { + eprintln!("[client-state] failed to register cross-host ownership: {err}"); + None + } + }; + if !cross_host_registration + .as_ref() + .is_some_and(cross_host::Registration::is_primary) + { + if let Some(file) = primary_lock.take() { + release_primary_file(&file); + } + } + Ok(Self { - process: ProcessState { - primary_lock: Mutex::new(primary_lock), - running_marker: Mutex::new(Some(running_marker)), - registration_file: Some( - registration_file.try_clone().map_err(|err| { - format!("failed to retain registration lock handle: {err}") - })?, - ), - }, - registration_file: has_registration_lock.then_some(registration_file), + primary_lock, + cross_host_registration, + running_marker, + registration_file, + holds_registration_lock: has_registration_lock, }) } pub(super) fn is_primary(&self) -> bool { - self.process.is_primary() + self.primary_lock.is_some() + && self + .cross_host_registration + .as_ref() + .is_some_and(cross_host::Registration::is_primary) } pub(super) fn finish(self) -> ProcessState { - if let Some(registration_file) = self.registration_file { - if let Err(err) = FileExt::unlock(®istration_file) { + if self.holds_registration_lock { + if let Err(err) = FileExt::unlock(&self.registration_file) { eprintln!("[client-state] failed to release registration lock: {err}"); } - drop(registration_file); } - self.process + ProcessState { + primary_lock: Mutex::new(self.primary_lock), + cross_host_registration: Mutex::new(self.cross_host_registration), + running_marker: Mutex::new(Some(self.running_marker)), + registration_file: Some(self.registration_file), + } } } @@ -172,16 +193,32 @@ impl ProcessState { pub(super) fn disabled() -> Self { Self { primary_lock: Mutex::new(None), + cross_host_registration: Mutex::new(None), running_marker: Mutex::new(None), registration_file: None, } } pub(super) fn is_primary(&self) -> bool { - self.primary_lock + let has_local_lock = self + .primary_lock .lock() .map(|lock| lock.is_some()) - .unwrap_or(false) + .unwrap_or(false); + has_local_lock + && self + .cross_host_registration + .lock() + .map(|registration| { + registration + .as_ref() + .is_some_and(cross_host::Registration::is_primary) + }) + .unwrap_or(false) + } + + pub(super) fn is_registered(&self) -> bool { + self.registration_file.is_some() } pub(super) fn release_locks(&self) { @@ -216,6 +253,12 @@ impl ProcessState { if let Some(file) = primary_lock { release_primary_file(&file); } + let cross_host_registration = self + .cross_host_registration + .lock() + .unwrap_or_else(|err| err.into_inner()) + .take(); + drop(cross_host_registration); if let Err(err) = FileExt::unlock(registration_file) { eprintln!("[client-state] failed to release registration lock: {err}"); } @@ -409,19 +452,18 @@ mod tests { .unwrap(); let started_at = Instant::now(); - let registration = Registration::initialize(directory.path()).unwrap(); + let registration = + Registration::initialize(directory.path(), &directory.path().join("cross-host"), None) + .unwrap(); assert!(!registration.is_primary()); assert!(started_at.elapsed() < Duration::from_secs(2)); - let marker = registration.process.running_marker.lock().unwrap(); - let marker_name = marker - .as_ref() - .unwrap() + let marker_name = registration + .running_marker .path .file_name() .unwrap() .to_string_lossy(); assert!(marker_name.contains(&format!(".acknowledges.{registration_id}"))); - drop(marker); FileExt::unlock(&owner).unwrap(); registration.finish().release_locks(); @@ -432,7 +474,9 @@ mod tests { use std::sync::{mpsc, Arc}; let directory = tempfile::tempdir().unwrap(); - let registration = Registration::initialize(directory.path()).unwrap(); + let registration = + Registration::initialize(directory.path(), &directory.path().join("cross-host"), None) + .unwrap(); assert!(registration.is_primary()); let process = Arc::new(registration.finish()); let registration_file = OpenOptions::new() diff --git a/packages/tauri-app/src-tauri/src/client_state/tests.rs b/packages/tauri-app/src-tauri/src/client_state/tests.rs index 2680cdbf..a2a7d618 100644 --- a/packages/tauri-app/src-tauri/src/client_state/tests.rs +++ b/packages/tauri-app/src-tauri/src/client_state/tests.rs @@ -10,460 +10,460 @@ use super::{ }; use serde_json::{json, Value}; use std::fs; +use std::io::{BufRead, BufReader}; +use std::process::{Command, Stdio}; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::{mpsc, Arc, Barrier}; use std::thread; use std::time::Duration; +use tempfile::TempDir; use url::Url; +fn load(is_primary: bool, restore_enabled: bool, snapshot: Value) -> ClientStateLoadResult { + ClientStateLoadResult { + is_primary, + restore_enabled, + snapshot, + } +} + +fn assert_access_rejected(state: &ClientState, token: &str, url: &Url) { + assert!(state.renderer_access.validate(token, url).is_err()); +} + +fn acknowledged_generation(state: &ClientState) -> u64 { + state + .renderer_flush + .acknowledged_generation + .load(Ordering::SeqCst) +} + +fn assert_receive_timeout(result: Result) { + assert!(matches!(result, Err(mpsc::RecvTimeoutError::Timeout))); +} + +fn enable_restore(state: &ClientState) { + assert!(state.set_restore_enabled(true).unwrap()); +} + +fn enable_restore_in_memory(state: &ClientState) { + let mut persisted = state.state.lock().unwrap(); + persisted.restore_enabled = true; + persisted.writes_enabled = true; +} + +fn failing_state(initially_failing: bool) -> (TempDir, ClientState, Arc) { + let directory = tempfile::tempdir().unwrap(); + let fail = Arc::new(AtomicBool::new(initially_failing)); + let writer_flag = Arc::clone(&fail); + let state = ClientState::initialize_at_with_writer( + directory.path(), + Arc::new(move |path, bytes, ownership_valid| { + if writer_flag.load(Ordering::SeqCst) { + Err("injected write failure".to_string()) + } else { + super::write_atomically(path, bytes, ownership_valid) + } + }), + ) + .unwrap(); + (directory, state, fail) +} +fn window() -> NativeWindowState { + NativeWindowState { + bounds: bounds(20, 30, 1400, 900), + maximized: true, + fullscreen: false, + zoom_factor: 2.0, + } +} +fn bounds(x: i32, y: i32, width: i32, height: i32) -> WindowBounds { + WindowBounds { + x, + y, + width, + height, + } +} +fn display(x: i32, y: i32, width: u32, height: u32) -> DisplayArea { + DisplayArea { + x, + y, + width, + height, + } +} +fn concurrent_roles(path: &std::path::Path, count: usize) -> Vec { + let start = Arc::new(Barrier::new(count + 1)); + let release = Arc::new(Barrier::new(count + 1)); + let (sender, receiver) = mpsc::channel(); + let handles: Vec<_> = (0..count) + .map(|_| { + let path = path.to_path_buf(); + let start = Arc::clone(&start); + let release = Arc::clone(&release); + let sender = sender.clone(); + thread::spawn(move || { + start.wait(); + let state = ClientState::initialize_at(&path).unwrap(); + sender.send(state.is_primary()).unwrap(); + release.wait(); + }) + }) + .collect(); + drop(sender); + start.wait(); + let roles = (0..count).map(|_| receiver.recv().unwrap()).collect(); + release.wait(); + for handle in handles { + handle.join().unwrap(); + } + roles +} + +fn run_node_state_host( + election: &std::path::Path, + electron_data: &std::path::Path, + tauri_data: &std::path::Path, + operation: &str, + payload: Option<&Value>, +) -> Value { + let root = election.parent().unwrap(); + fs::create_dir_all(root).unwrap(); + let start = root.join(format!("node-start-{operation}")); + let script = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../electron-app/electron/main/client-state-cross-host-child.ts"); + let mut child = Command::new("node") + .current_dir(std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../..")) + .args(["--import", "tsx"]) + .arg(script) + .arg(election) + .arg(&start) + .args(["", "full"]) + .arg(electron_data) + .args(["", ""]) + .arg(tauri_data) + .arg(operation) + .arg(payload.map(Value::to_string).unwrap_or_default()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("Node and tsx are required for cross-language state tests"); + fs::write(start, b"").unwrap(); + let mut line = String::new(); + BufReader::new(child.stdout.as_mut().unwrap()) + .read_line(&mut line) + .unwrap(); + drop(child.stdin.take()); + let output = child.wait_with_output().unwrap(); + assert!( + output.status.success(), + "Node host failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + serde_json::from_str(&line).unwrap() +} #[test] -fn invalid_or_old_state_uses_safe_defaults() { - for value in [ +fn restore_defaults_on_unless_explicitly_disabled() { + let directory = tempfile::tempdir().unwrap(); + let state = ClientState::initialize_at(directory.path()).unwrap(); + assert_eq!(state.load().unwrap(), load(true, true, Value::Null)); + assert!(state.save_snapshot(json!({ "saved": true })).unwrap()); + + let disabled_directory = tempfile::tempdir().unwrap(); + fs::write( + disabled_directory.path().join(CLIENT_STATE_FILENAME), + br#"{"version":1,"restoreEnabled":false}"#, + ) + .unwrap(); + let disabled = ClientState::initialize_at(disabled_directory.path()).unwrap(); + assert_eq!(disabled.load().unwrap(), load(true, false, Value::Null)); +} + +#[test] +fn parses_envelopes_and_normalizes_zoom() { + for bytes in [ br#"not json"#.as_slice(), - br#"{"version":0,"restoreEnabled":false,"snapshot":{"old":true}}"#.as_slice(), + br#"{"version":0,"restoreEnabled":false}"#.as_slice(), br#"{"version":1,"restoreEnabled":"no"}"#.as_slice(), ] { - let state = parse_client_state(value); + let state = parse_client_state(bytes); assert!(state.restore_enabled); assert_eq!(state.snapshot, None); assert!(!state.unsupported_future_envelope); } -} - -#[test] -fn parses_and_normalizes_versioned_state() { let state = parse_client_state( br#"{"version":1,"restoreEnabled":false,"snapshot":{"folder":"work"},"window":{"bounds":{"x":20,"y":30,"width":1400,"height":900},"maximized":true,"fullscreen":false,"zoomFactor":20}}"#, ); - assert!(!state.restore_enabled); assert_eq!(state.snapshot, Some(json!({ "folder": "work" }))); assert_eq!(state.window.unwrap().zoom_factor, MAX_ZOOM_LEVEL); -} - -#[test] -fn normalizes_valid_native_zoom_levels() { - assert_eq!(normalize_native_zoom_level(1.25), Some(1.25)); - assert_eq!(normalize_native_zoom_level(0.01), Some(0.25)); - assert_eq!(normalize_native_zoom_level(20.0), Some(MAX_ZOOM_LEVEL)); -} - -#[test] -fn rejects_invalid_native_zoom_levels() { - for value in [0.0, -1.0, f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { - assert_eq!(normalize_native_zoom_level(value), None); + for (input, expected) in [ + (1.25, Some(1.25)), + (0.01, Some(0.25)), + (20.0, Some(MAX_ZOOM_LEVEL)), + (0.0, None), + (-1.0, None), + (f64::NAN, None), + (f64::INFINITY, None), + ] { + assert_eq!(normalize_native_zoom_level(input), expected); } } #[test] -fn disabled_restore_initializes_default_zoom() { - let directory = tempfile::tempdir().unwrap(); +fn migrates_dual_legacy_files_with_disabled_dominance_and_malformed_fallback() { + for malformed_electron in [false, true] { + let root = tempfile::tempdir().unwrap(); + let electron = root.path().join("electron"); + let tauri = root.path().join("tauri"); + let election = root.path().join("shared/election"); + let shared = root.path().join("shared/client-state.json"); + fs::create_dir_all(&electron).unwrap(); + fs::create_dir_all(&tauri).unwrap(); + fs::write( + electron.join(CLIENT_STATE_FILENAME), + if malformed_electron { + b"malformed".to_vec() + } else { + serde_json::to_vec(&json!({ + "version": 1, + "restoreEnabled": true, + "snapshot": { "revision": 999, "savedAt": 20, "host": "electron" } + })) + .unwrap() + }, + ) + .unwrap(); + fs::write( + tauri.join(CLIENT_STATE_FILENAME), + serde_json::to_vec(&json!({ + "version": 1, + "restoreEnabled": false, + "snapshot": { "savedAt": 1, "host": "tauri" } + })) + .unwrap(), + ) + .unwrap(); + let state = ClientState::initialize_at_with_writer_and_election( + &tauri, + &election, + &shared, + Some(&electron), + Arc::new(super::write_atomically), + ) + .unwrap(); + assert_eq!(state.load().unwrap(), load(true, false, Value::Null)); + assert!(!parse_client_state(&fs::read(&shared).unwrap()).restore_enabled); + assert!(!electron.join(CLIENT_STATE_FILENAME).exists()); + assert!(!tauri.join(CLIENT_STATE_FILENAME).exists()); + } + + let root = tempfile::tempdir().unwrap(); + let electron = root.path().join("electron"); + let tauri = root.path().join("tauri"); + let election = root.path().join("shared/election"); + let shared = root.path().join("shared/client-state.json"); + fs::create_dir_all(&electron).unwrap(); + fs::create_dir_all(&tauri).unwrap(); fs::write( - directory.path().join(CLIENT_STATE_FILENAME), - br#"{"version":1,"restoreEnabled":false,"window":{"bounds":{"x":20,"y":30,"width":1400,"height":900},"maximized":false,"fullscreen":false,"zoomFactor":2}}"#, + electron.join(CLIENT_STATE_FILENAME), + serde_json::to_vec(&json!({ "version": 1, "restoreEnabled": true })).unwrap(), ) .unwrap(); - - let state = ClientState::initialize_at(directory.path()).unwrap(); - assert_eq!(*state.zoom_level.lock().unwrap(), DEFAULT_ZOOM_LEVEL); - assert!(state.persistence_suppressed.load(Ordering::SeqCst)); - let disabled_bytes = fs::read(directory.path().join(CLIENT_STATE_FILENAME)).unwrap(); - assert!(state.save_snapshot(json!({ "ignored": true })).unwrap()); - assert_eq!( - fs::read(directory.path().join(CLIENT_STATE_FILENAME)).unwrap(), - disabled_bytes - ); -} - -#[test] -fn disabling_restore_atomically_clears_persisted_snapshot_and_window() { - let directory = tempfile::tempdir().unwrap(); - let state_path = directory.path().join(CLIENT_STATE_FILENAME); fs::write( - &state_path, - br#"{"version":1,"restoreEnabled":true,"snapshot":{"kept":true},"window":{"bounds":{"x":20,"y":30,"width":1400,"height":900},"maximized":true,"fullscreen":false,"zoomFactor":2}}"#, + tauri.join(CLIENT_STATE_FILENAME), + serde_json::to_vec(&json!({ + "version": 1, + "restoreEnabled": true, + "snapshot": { "savedAt": 20 } + })) + .unwrap(), ) .unwrap(); - let write_count = Arc::new(AtomicUsize::new(0)); - let write_count_for_writer = Arc::clone(&write_count); - let state = ClientState::initialize_at_with_writer( - directory.path(), - Arc::new(move |path, bytes| { - write_count_for_writer.fetch_add(1, Ordering::SeqCst); - super::write_atomically(path, bytes) - }), + let state = ClientState::initialize_at_with_writer_and_election( + &tauri, + &election, + &shared, + Some(&electron), + Arc::new(super::write_atomically), ) .unwrap(); - - assert!(state.set_restore_enabled(false).unwrap()); - assert_eq!(write_count.load(Ordering::SeqCst), 1); - - let persisted = parse_client_state(&fs::read(&state_path).unwrap()); - assert!(!persisted.restore_enabled); - assert_eq!(persisted.snapshot, None); - assert_eq!(persisted.window, None); - let disabled_bytes = fs::read(&state_path).unwrap(); - assert!(state.save_snapshot(json!({ "ignored": true })).unwrap()); - assert_eq!(fs::read(state_path).unwrap(), disabled_bytes); - assert_eq!(write_count.load(Ordering::SeqCst), 1); + assert_eq!(state.load().unwrap().snapshot, Value::Null); } #[test] -fn moves_offscreen_bounds_to_nearest_monitor_work_area() { - let bounds = WindowBounds { - x: 4000, - y: 2000, - width: 1400, - height: 900, - }; - let displays = [DisplayArea { - x: 0, - y: 0, - width: 1920, - height: 1080, - }]; +fn electron_and_tauri_share_the_complete_envelope_across_handoffs() { + let root = tempfile::tempdir().unwrap(); + let electron = root.path().join("electron"); + let tauri = root.path().join("tauri"); + let election = root.path().join("shared/election"); + let shared = root.path().join("shared/client-state.json"); + fs::create_dir_all(&electron).unwrap(); + fs::create_dir_all(&tauri).unwrap(); - assert_eq!( - clamp_window_bounds(&bounds, &displays), - Some(WindowBounds { - x: 520, - y: 180, - width: 1400, - height: 900, - }) + let electron_snapshot = json!({ "version": 0, "savedAt": 10, "from": "electron" }); + let node = run_node_state_host( + &election, + &electron, + &tauri, + "save", + Some(&electron_snapshot), ); -} + assert_eq!(node["acquired"], true); + let rust = ClientState::initialize_at_with_writer_and_election( + &tauri, + &election, + &shared, + Some(&electron), + Arc::new(super::write_atomically), + ) + .unwrap(); + assert_eq!(rust.load().unwrap().snapshot, electron_snapshot); + let tauri_snapshot = json!({ "savedAt": 20, "from": "tauri" }); + assert!(rust.save_snapshot(tauri_snapshot.clone()).unwrap()); + rust.release_locks(); + assert!(!election.join("primary.owner.json").exists()); + let node = run_node_state_host(&election, &electron, &tauri, "load", None); + assert_eq!(node["acquired"], true); + assert_eq!(node["state"]["snapshot"], tauri_snapshot); +} #[test] -fn clamps_window_size_to_selected_monitor() { - let bounds = WindowBounds { - x: -2000, - y: 100, - width: 3000, - height: 300, - }; - let displays = [ - DisplayArea { - x: -1280, - y: 0, - width: 1280, - height: 1024, - }, - DisplayArea { - x: 0, - y: 0, - width: 1920, - height: 1080, - }, +fn normalizes_window_bounds_against_displays() { + let cases = [ + ( + bounds(4000, 2000, 1400, 900), + vec![display(0, 0, 1920, 1080)], + bounds(520, 180, 1400, 900), + ), + ( + bounds(-2000, 100, 3000, 300), + vec![display(-1280, 0, 1280, 1024), display(0, 0, 1920, 1080)], + bounds(-1280, 100, 1280, 600), + ), ]; - - assert_eq!( - clamp_window_bounds(&bounds, &displays), - Some(WindowBounds { - x: -1280, - y: 100, - width: 1280, - height: 600, - }) - ); -} - -#[test] -fn existing_unlocked_lock_and_marker_files_are_recovered() { - let directory = tempfile::tempdir().unwrap(); - fs::write( - directory.path().join(PRIMARY_LOCK_FILENAME), - br#"{"pid":999999}"#, - ) - .unwrap(); - let stale_marker = directory.path().join(format!( - "{RUNNING_MARKER_PREFIX}stale{RUNNING_MARKER_SUFFIX}" - )); - fs::write(&stale_marker, b"").unwrap(); - - let state = ClientState::initialize_at(directory.path()).unwrap(); - assert!(state.is_primary()); - assert!(!stale_marker.exists()); -} - -#[test] -fn simultaneous_clients_elect_exactly_one_primary() { - let directory = tempfile::tempdir().unwrap(); - let start = Arc::new(Barrier::new(3)); - let release = Arc::new(Barrier::new(3)); - let (sender, receiver) = mpsc::channel(); - let mut handles = Vec::new(); - - for _ in 0..2 { - let path = directory.path().to_path_buf(); - let start = Arc::clone(&start); - let release = Arc::clone(&release); - let sender = sender.clone(); - handles.push(thread::spawn(move || { - start.wait(); - let state = ClientState::initialize_at(&path).unwrap(); - sender.send(state.is_primary()).unwrap(); - release.wait(); - drop(state); - })); + for (bounds, displays, expected) in cases { + assert_eq!(clamp_window_bounds(&bounds, &displays), Some(expected)); } - drop(sender); - - start.wait(); - let roles = [receiver.recv().unwrap(), receiver.recv().unwrap()]; - release.wait(); - for handle in handles { - handle.join().unwrap(); +} +#[test] +fn stale_files_recover_without_trusting_pid_identity() { + for lock_contents in [ + b"{\"pid\":999999}".as_slice(), + b"{\"pid\":0}", + b"inconclusive", + ] { + let directory = tempfile::tempdir().unwrap(); + fs::write(directory.path().join(PRIMARY_LOCK_FILENAME), lock_contents).unwrap(); + let marker = directory.path().join(format!( + "{RUNNING_MARKER_PREFIX}stale{RUNNING_MARKER_SUFFIX}" + )); + fs::write(&marker, b"").unwrap(); + let state = ClientState::initialize_at(directory.path()).unwrap(); + assert!(state.is_primary()); + assert!(!marker.exists()); } - +} +#[test] +fn election_preserves_cohorts_until_every_participant_exits() { + let directory = tempfile::tempdir().unwrap(); assert_eq!( - roles.into_iter().filter(|is_primary| *is_primary).count(), + concurrent_roles(directory.path(), 2) + .iter() + .filter(|role| **role) + .count(), 1 ); -} - -#[test] -fn primary_role_recovers_after_all_participants_drop() { - let directory = tempfile::tempdir().unwrap(); + fs::remove_dir_all(directory.path().join(".cross-host-election")).unwrap(); let primary = ClientState::initialize_at(directory.path()).unwrap(); let secondary = ClientState::initialize_at(directory.path()).unwrap(); assert!(primary.is_primary()); assert!(!secondary.is_primary()); - drop(primary); + assert_eq!(concurrent_roles(directory.path(), 2), [false, false]); let waiting = ClientState::initialize_at(directory.path()).unwrap(); assert!(!waiting.is_primary()); - drop(secondary); drop(waiting); - let recovered = ClientState::initialize_at(directory.path()).unwrap(); - assert!(recovered.is_primary()); + // Thread-backed clients share this test process identity; real exited hosts do not. + fs::remove_dir_all(directory.path().join(".cross-host-election")).unwrap(); + assert!(ClientState::initialize_at(directory.path()) + .unwrap() + .is_primary()); } - #[test] -fn third_client_remains_secondary_after_primary_drops_while_secondary_lives() { - let directory = tempfile::tempdir().unwrap(); - let primary = ClientState::initialize_at(directory.path()).unwrap(); - assert!(primary.save_snapshot(json!({ "restore": true })).unwrap()); - let secondary = ClientState::initialize_at(directory.path()).unwrap(); - - drop(primary); - let third = ClientState::initialize_at(directory.path()).unwrap(); - - assert!(!secondary.is_primary()); - assert_eq!( - third.load().unwrap(), - ClientStateLoadResult { - is_primary: false, - restore_enabled: true, - snapshot: Value::Null, - } - ); -} - -#[test] -fn concurrent_successors_remain_secondary_while_an_older_secondary_lives() { - let directory = tempfile::tempdir().unwrap(); - let primary = ClientState::initialize_at(directory.path()).unwrap(); - let secondary = ClientState::initialize_at(directory.path()).unwrap(); - drop(primary); - - let start = Arc::new(Barrier::new(3)); - let release = Arc::new(Barrier::new(3)); - let (sender, receiver) = mpsc::channel(); - let mut handles = Vec::new(); - for _ in 0..2 { - let path = directory.path().to_path_buf(); - let start = Arc::clone(&start); - let release = Arc::clone(&release); - let sender = sender.clone(); - handles.push(thread::spawn(move || { - start.wait(); - let state = ClientState::initialize_at(&path).unwrap(); - sender.send(state.is_primary()).unwrap(); - release.wait(); - drop(state); - })); - } - drop(sender); - - start.wait(); - let roles = [receiver.recv().unwrap(), receiver.recv().unwrap()]; - release.wait(); - for handle in handles { - handle.join().unwrap(); - } - drop(secondary); - - assert_eq!(roles, [false, false]); -} - -#[test] -fn secondary_never_reads_or_writes_primary_state() { +fn secondary_and_failed_initialization_are_isolated() { let directory = tempfile::tempdir().unwrap(); let primary = ClientState::initialize_at(directory.path()).unwrap(); + enable_restore(&primary); assert!(primary.save_snapshot(json!({ "kept": true })).unwrap()); let state_path = directory.path().join(CLIENT_STATE_FILENAME); let original = fs::read(&state_path).unwrap(); - let secondary = ClientState::initialize_at(directory.path()).unwrap(); - assert_eq!( - secondary.load().unwrap(), - ClientStateLoadResult { - is_primary: false, - restore_enabled: true, - snapshot: Value::Null, - } - ); + assert_eq!(secondary.load().unwrap(), load(false, true, Value::Null)); assert!(!secondary.save_snapshot(json!({ "replace": true })).unwrap()); assert!(!secondary.set_restore_enabled(false).unwrap()); assert!(!secondary.clear().unwrap()); assert_eq!(fs::read(state_path).unwrap(), original); + let invalid = directory.path().join("not-a-directory"); + fs::write(&invalid, b"occupied").unwrap(); + let disabled = ClientState::initialize_managed_at(&invalid); + assert_eq!(disabled.load().unwrap(), load(false, false, Value::Null)); + assert!(!disabled.save_snapshot(json!({ "ignored": true })).unwrap()); } - #[test] -fn initialization_failure_is_managed_as_non_primary_with_restore_disabled() { - let directory = tempfile::tempdir().unwrap(); - let invalid_app_data_dir = directory.path().join("not-a-directory"); - fs::write(&invalid_app_data_dir, b"occupied").unwrap(); - - let state = ClientState::initialize_managed_at(&invalid_app_data_dir); - - assert_eq!( - state.load().unwrap(), - ClientStateLoadResult { - is_primary: false, - restore_enabled: false, - snapshot: Value::Null, +fn disable_and_clear_suppress_later_writes() { + for clear in [false, true] { + let directory = tempfile::tempdir().unwrap(); + let state = ClientState::initialize_at(directory.path()).unwrap(); + enable_restore(&state); + assert!(state.save_snapshot(json!({ "removed": true })).unwrap()); + state.state.lock().unwrap().window = Some(window()); + if clear { + assert!(state.clear().unwrap()); + assert_eq!(state.load().unwrap(), load(true, true, Value::Null)); + } else { + assert!(state.set_restore_enabled(false).unwrap()); + assert_eq!(state.load().unwrap(), load(true, false, Value::Null)); + assert_eq!(*state.zoom_level.lock().unwrap(), DEFAULT_ZOOM_LEVEL); } - ); - assert!(!state.save_snapshot(json!({ "ignored": true })).unwrap()); + let path = directory.path().join(CLIENT_STATE_FILENAME); + let persisted = parse_client_state(&fs::read(&path).unwrap()); + assert_eq!(persisted.snapshot, None); + assert_eq!(persisted.window, None); + let bytes = fs::read(&path).unwrap(); + assert!(state.save_snapshot(json!({ "ignored": true })).unwrap()); + assert_eq!(fs::read(path).unwrap(), bytes); + } } - #[test] -fn disabled_primary_loads_role_and_setting_while_writes_are_noops() { - let directory = tempfile::tempdir().unwrap(); - let state = ClientState::initialize_at(directory.path()).unwrap(); - assert!(state.save_snapshot(json!({ "removed": true })).unwrap()); - assert!(state.set_restore_enabled(false).unwrap()); - let state_path = directory.path().join(CLIENT_STATE_FILENAME); - let disabled = fs::read(&state_path).unwrap(); - - assert_eq!( - state.load().unwrap(), - ClientStateLoadResult { - is_primary: true, - restore_enabled: false, - snapshot: Value::Null, - } - ); - assert!(state.save_snapshot(json!({ "ignored": true })).unwrap()); - assert_eq!(fs::read(state_path).unwrap(), disabled); -} - -#[test] -fn failed_restore_setting_write_rolls_memory_back() { - let directory = tempfile::tempdir().unwrap(); - let fail_writes = Arc::new(AtomicBool::new(false)); - let fail_writes_for_writer = Arc::clone(&fail_writes); - let state = ClientState::initialize_at_with_writer( - directory.path(), - Arc::new(move |path, bytes| { - if fail_writes_for_writer.load(Ordering::SeqCst) { - Err("injected write failure".to_string()) - } else { - super::write_atomically(path, bytes) - } - }), - ) - .unwrap(); - assert!(state.save_snapshot(json!({ "kept": true })).unwrap()); - state.state.lock().unwrap().window = Some(NativeWindowState { - bounds: WindowBounds { - x: 20, - y: 30, - width: 1400, - height: 900, - }, - maximized: true, - fullscreen: false, - zoom_factor: 2.0, - }); - fail_writes.store(true, Ordering::SeqCst); - - assert_eq!( - state.set_restore_enabled(false).unwrap_err(), - "injected write failure" - ); - assert_eq!( - state.load().unwrap(), - ClientStateLoadResult { - is_primary: true, - restore_enabled: true, - snapshot: json!({ "kept": true }), - } - ); - assert!(state.state.lock().unwrap().window.is_some()); - assert!(!state.persistence_suppressed.load(Ordering::SeqCst)); - assert!( - parse_client_state(&fs::read(directory.path().join(CLIENT_STATE_FILENAME)).unwrap()) - .restore_enabled - ); - fail_writes.store(false, Ordering::SeqCst); - assert!(state.save_snapshot(json!({ "replacement": true })).unwrap()); -} - -#[test] -fn failed_clear_restores_snapshot_and_suppression_flag() { - let directory = tempfile::tempdir().unwrap(); - let fail_writes = Arc::new(AtomicBool::new(false)); - let fail_writes_for_writer = Arc::clone(&fail_writes); - let state = ClientState::initialize_at_with_writer( - directory.path(), - Arc::new(move |path, bytes| { - if fail_writes_for_writer.load(Ordering::SeqCst) { - Err("injected write failure".to_string()) - } else { - super::write_atomically(path, bytes) - } - }), - ) - .unwrap(); - assert!(state.save_snapshot(json!({ "kept": true })).unwrap()); - fail_writes.store(true, Ordering::SeqCst); - - assert_eq!(state.clear().unwrap_err(), "injected write failure"); - - fail_writes.store(false, Ordering::SeqCst); - assert_eq!(state.load().unwrap().snapshot, json!({ "kept": true })); - assert!(!state.persistence_suppressed.load(Ordering::SeqCst)); - assert!(state.save_snapshot(json!({ "replacement": true })).unwrap()); - assert_eq!( - state.load().unwrap().snapshot, - json!({ "replacement": true }) - ); -} - -#[test] -fn failed_restore_reenable_keeps_successful_clear_suppression_active() { - let directory = tempfile::tempdir().unwrap(); - let fail_writes = Arc::new(AtomicBool::new(false)); - let fail_writes_for_writer = Arc::clone(&fail_writes); - let state = ClientState::initialize_at_with_writer( - directory.path(), - Arc::new(move |path, bytes| { - if fail_writes_for_writer.load(Ordering::SeqCst) { - Err("injected write failure".to_string()) - } else { - super::write_atomically(path, bytes) - } - }), - ) - .unwrap(); - assert!(state.save_snapshot(json!({ "cleared": true })).unwrap()); - assert!(state.clear().unwrap()); - fail_writes.store(true, Ordering::SeqCst); - +fn failed_writes_restore_memory_and_suppression_state() { + for operation in ["snapshot", "clear", "disable"] { + let (_directory, state, fail) = failing_state(false); + enable_restore(&state); + assert!(state.save_snapshot(json!({ "kept": true })).unwrap()); + state.state.lock().unwrap().window = Some(window()); + fail.store(true, Ordering::SeqCst); + let error = match operation { + "snapshot" => state.save_snapshot(json!({ "lost": true })).unwrap_err(), + "clear" => state.clear().unwrap_err(), + _ => state.set_restore_enabled(false).unwrap_err(), + }; + assert_eq!(error, "injected write failure"); + assert_eq!( + state.load().unwrap(), + load(true, true, json!({ "kept": true })) + ); + assert!(state.state.lock().unwrap().window.is_some()); + assert!(state.state.lock().unwrap().writes_enabled); + fail.store(false, Ordering::SeqCst); + assert!(state.save_snapshot(json!({ "replacement": true })).unwrap()); + } + let (_directory, state, fail) = failing_state(false); + state.clear().unwrap(); + fail.store(true, Ordering::SeqCst); assert_eq!( state.set_restore_enabled(true).unwrap_err(), "injected write failure" @@ -471,291 +471,272 @@ fn failed_restore_reenable_keeps_successful_clear_suppression_active() { assert!(state.save_snapshot(json!({ "ignored": true })).unwrap()); assert_eq!(state.load().unwrap().snapshot, Value::Null); } - #[test] -fn future_envelope_is_preserved_until_clear_replaces_and_unblocks_it() { +fn future_envelope_is_preserved_until_successful_clear() { let directory = tempfile::tempdir().unwrap(); - let state_path = directory.path().join(CLIENT_STATE_FILENAME); - let future = br#"{"version":2,"restoreEnabled":false,"snapshot":{"future":true},"window":{"future":true},"extension":{"keep":"exactly"}}"#; - fs::write(&state_path, future).unwrap(); - let state = ClientState::initialize_at(directory.path()).unwrap(); - - assert!(state.state.lock().unwrap().unsupported_future_envelope); - assert_eq!( - state.load().unwrap(), - ClientStateLoadResult { - is_primary: true, - restore_enabled: true, - snapshot: Value::Null, - } - ); - assert!(!state.set_restore_enabled(false).unwrap()); - assert!(!state.set_restore_enabled(true).unwrap()); - assert!(state - .save_snapshot(Value::String("x".repeat(MAX_CLIENT_SNAPSHOT_BYTES))) - .unwrap()); - state.state.lock().unwrap().window = Some(NativeWindowState { - bounds: WindowBounds { - x: 20, - y: 30, - width: 1400, - height: 900, - }, - maximized: true, - fullscreen: false, - zoom_factor: 2.0, - }); - state.flush().unwrap(); - assert_eq!(fs::read(&state_path).unwrap(), future); - - drop(state); - let state = ClientState::initialize_at(directory.path()).unwrap(); - assert_eq!( - state.load().unwrap(), - ClientStateLoadResult { - is_primary: true, - restore_enabled: true, - snapshot: Value::Null, - } - ); - assert_eq!(fs::read(&state_path).unwrap(), future); - - assert!(state.clear().unwrap()); - assert_eq!( - serde_json::from_slice::(&fs::read(&state_path).unwrap()).unwrap(), - json!({ "version": 1, "restoreEnabled": true }) - ); - assert!(!state.state.lock().unwrap().unsupported_future_envelope); - assert!(!state.persistence_suppressed.load(Ordering::SeqCst)); - assert!(state.save_snapshot(json!({ "afterClear": true })).unwrap()); - assert_eq!( - state.load().unwrap().snapshot, - json!({ "afterClear": true }) - ); -} - -#[test] -fn failed_future_envelope_clear_preserves_file_and_write_suppression() { - let directory = tempfile::tempdir().unwrap(); - let state_path = directory.path().join(CLIENT_STATE_FILENAME); - let future = br#"{"version":9,"futureField":{"must":"survive"}}"#; - fs::write(&state_path, future).unwrap(); - let fail_writes = Arc::new(AtomicBool::new(true)); - let write_count = Arc::new(AtomicUsize::new(0)); - let fail_writes_for_writer = Arc::clone(&fail_writes); - let write_count_for_writer = Arc::clone(&write_count); + let path = directory.path().join(CLIENT_STATE_FILENAME); + let future = br#"{"version":9,"snapshot":{"future":true},"extension":{"keep":"exactly"}}"#; + fs::write(&path, future).unwrap(); + let fail = Arc::new(AtomicBool::new(true)); + let count = Arc::new(AtomicUsize::new(0)); + let writer_fail = Arc::clone(&fail); + let writer_count = Arc::clone(&count); let state = ClientState::initialize_at_with_writer( directory.path(), - Arc::new(move |path, bytes| { - write_count_for_writer.fetch_add(1, Ordering::SeqCst); - if fail_writes_for_writer.load(Ordering::SeqCst) { + Arc::new(move |path, bytes, ownership_valid| { + writer_count.fetch_add(1, Ordering::SeqCst); + if writer_fail.load(Ordering::SeqCst) { Err("injected write failure".to_string()) } else { - super::write_atomically(path, bytes) + super::write_atomically(path, bytes, ownership_valid) } }), ) .unwrap(); - - assert_eq!(state.clear().unwrap_err(), "injected write failure"); - assert_eq!(fs::read(&state_path).unwrap(), future); - assert!(state.state.lock().unwrap().unsupported_future_envelope); + assert_eq!(state.load().unwrap(), load(true, false, Value::Null)); assert!(!state.set_restore_enabled(false).unwrap()); assert!(state.save_snapshot(json!({ "ignored": true })).unwrap()); state.flush().unwrap(); - assert_eq!(write_count.load(Ordering::SeqCst), 1); - - fail_writes.store(false, Ordering::SeqCst); + assert_eq!(count.load(Ordering::SeqCst), 0); + assert_eq!(state.clear().unwrap_err(), "injected write failure"); + assert_eq!(fs::read(&path).unwrap(), future); + assert!(state.state.lock().unwrap().unsupported_future_envelope); + fail.store(false, Ordering::SeqCst); assert!(state.clear().unwrap()); + assert!(!state.state.lock().unwrap().unsupported_future_envelope); + enable_restore(&state); assert!(state.save_snapshot(json!({ "accepted": true })).unwrap()); - assert_eq!(write_count.load(Ordering::SeqCst), 3); + assert_eq!(state.load().unwrap().snapshot, json!({ "accepted": true })); +} +#[test] +fn renderer_tokens_and_origins_are_isolated_across_navigation() { + let directory = tempfile::tempdir().unwrap(); + let state = ClientState::initialize_at(directory.path()).unwrap(); + let outgoing = Url::parse("http://127.0.0.1:43123/workspace").unwrap(); + let incoming = Url::parse("http://127.0.0.1:43124/workspace").unwrap(); + assert!(state.renderer_access.claim("", &outgoing).is_err()); + assert_access_rejected(&state, "missing", &outgoing); + state.renderer_access.claim("outgoing", &outgoing).unwrap(); + assert!(state.renderer_access.claim("other", &outgoing).is_err()); + assert_access_rejected(&state, "outgoing", &incoming); + state + .renderer_access + .begin_navigation(Some(&incoming)) + .unwrap(); + state + .renderer_access + .validate("outgoing", &outgoing) + .unwrap(); + state.renderer_access.claim("incoming", &incoming).unwrap(); + assert_access_rejected(&state, "outgoing", &outgoing); + state + .renderer_access + .validate("incoming", &incoming) + .unwrap(); + for (url, managed, allowed) in [ + (&outgoing, Some("http://127.0.0.1:43123"), true), + (&incoming, Some("http://127.0.0.1:43123"), false), + ( + &Url::parse("http://localhost:9000/workspace").unwrap(), + None, + false, + ), + ( + &Url::parse("https://tauri.localhost/loading.html").unwrap(), + None, + true, + ), + ] { + assert_eq!(is_allowed_client_state_origin(url, managed), allowed); + } + for url in ["file:///tmp/loading.html", "about:blank"] { + assert!(state + .renderer_access + .claim("opaque", &Url::parse(url).unwrap()) + .is_err()); + } } #[test] -fn renderer_access_requires_a_claimed_matching_nonempty_token() { +fn reload_preserves_pending_cross_origin_authority_until_incoming_claim() { let directory = tempfile::tempdir().unwrap(); let state = ClientState::initialize_at(directory.path()).unwrap(); - let renderer_a = Url::parse("http://127.0.0.1:43123/workspace").unwrap(); - let renderer_b = Url::parse("http://127.0.0.1:43124/workspace").unwrap(); + let outgoing = Url::parse("http://127.0.0.1:43123/workspace").unwrap(); + let incoming = Url::parse("http://127.0.0.1:43124/workspace").unwrap(); + state.renderer_access.claim("outgoing", &outgoing).unwrap(); - assert!(state.claim_renderer_access("", &renderer_a).is_err()); - assert!(state.validate_renderer_access("", &renderer_a).is_err()); - assert!(state - .validate_renderer_access("renderer-a", &renderer_a) - .is_err()); state - .claim_renderer_access("renderer-a", &renderer_a) + .renderer_access + .begin_navigation(Some(&incoming)) .unwrap(); - state - .validate_renderer_access("renderer-a", &renderer_a) - .unwrap(); - state - .claim_renderer_access("renderer-a", &renderer_a) - .unwrap(); - assert!(state - .claim_renderer_access("renderer-b", &renderer_a) - .is_err()); - assert!(state - .validate_renderer_access("renderer-b", &renderer_a) - .is_err()); - assert!(state - .validate_renderer_access("renderer-a", &renderer_b) - .is_err()); + state.renderer_access.begin_navigation(None).unwrap(); - state.begin_renderer_navigation(Some(&renderer_b)).unwrap(); + state.renderer_access.claim("incoming", &incoming).unwrap(); state - .validate_renderer_access("renderer-a", &renderer_a) - .unwrap(); - state - .claim_renderer_access("renderer-b", &renderer_b) - .unwrap(); - state - .validate_renderer_access("renderer-b", &renderer_b) + .renderer_access + .validate("incoming", &incoming) .unwrap(); } #[test] -fn navigation_token_rotation_preserves_the_outgoing_renderers_latest_snapshot() { +fn failed_follow_up_navigation_restores_previous_pending_authority() { let directory = tempfile::tempdir().unwrap(); let state = ClientState::initialize_at(directory.path()).unwrap(); - let outgoing_url = Url::parse("http://127.0.0.1:43123/workspace").unwrap(); - let incoming_url = Url::parse("http://127.0.0.1:43124/workspace").unwrap(); + let outgoing = Url::parse("http://127.0.0.1:43123/workspace").unwrap(); + let incoming = Url::parse("http://127.0.0.1:43124/workspace").unwrap(); + let failed = Url::parse("http://127.0.0.1:43125/workspace").unwrap(); + state.renderer_access.claim("outgoing", &outgoing).unwrap(); + state + .renderer_access + .begin_navigation(Some(&incoming)) + .unwrap(); - state - .claim_renderer_access("outgoing-document", &outgoing_url) + let failed_navigation = state + .renderer_access + .begin_navigation(Some(&failed)) .unwrap(); - state - .validate_renderer_access("outgoing-document", &outgoing_url) - .unwrap(); - assert!(!is_allowed_client_state_origin( - &outgoing_url, - Some(incoming_url.as_str()), - )); - state - .validate_renderer_access("outgoing-document", &outgoing_url) - .unwrap(); - assert!(state.renderer_origin_can_claim(&outgoing_url)); - assert!(state - .save_snapshot(json!({ "revision": 7, "editor": "latest" })) - .unwrap()); + state.renderer_access.cancel_navigation(failed_navigation); + state.renderer_access.claim("incoming", &incoming).unwrap(); state - .begin_renderer_navigation(Some(&incoming_url)) + .renderer_access + .validate("incoming", &incoming) .unwrap(); - state - .validate_renderer_access("outgoing-document", &outgoing_url) - .unwrap(); - assert!(state - .save_snapshot(json!({ "revision": 8, "editor": "flushed" })) - .unwrap()); - state - .claim_renderer_access("new-document", &incoming_url) - .unwrap(); - state - .validate_renderer_access("new-document", &incoming_url) - .unwrap(); - assert!(state - .validate_renderer_access("outgoing-document", &outgoing_url) - .is_err()); - assert_eq!( - state.load().unwrap().snapshot, - json!({ "revision": 8, "editor": "flushed" }) - ); } - #[test] -fn opaque_renderer_urls_cannot_share_client_state_authority() { +fn flush_generation_and_request_order_are_strict() { let directory = tempfile::tempdir().unwrap(); - let state = ClientState::initialize_at(directory.path()).unwrap(); - let app_url = Url::parse("tauri://localhost/loading.html").unwrap(); - let asset_url = Url::parse("asset://localhost/loading.html").unwrap(); - let file_url = Url::parse("file:///tmp/loading.html").unwrap(); - let about_url = Url::parse("about:blank").unwrap(); - - state.claim_renderer_access("app", &app_url).unwrap(); - state.validate_renderer_access("app", &app_url).unwrap(); - assert!(state.validate_renderer_access("app", &asset_url).is_err()); - assert!(state.claim_renderer_access("file", &file_url).is_err()); - assert!(state.claim_renderer_access("about", &about_url).is_err()); + let state = Arc::new(ClientState::initialize_at(directory.path()).unwrap()); + state + .renderer_flush + .next_generation + .store(2, Ordering::SeqCst); + state.acknowledge_renderer_flush(1); + assert_eq!(acknowledged_generation(&state), 0); + state.acknowledge_renderer_flush(2); + assert_eq!(acknowledged_generation(&state), 2); + let first = state.renderer_flush.request_lock.lock().unwrap(); + let waiting = Arc::clone(&state); + let (sender, receiver) = mpsc::channel(); + let waiter = thread::spawn(move || { + let _request = waiting.renderer_flush.request_lock.lock().unwrap(); + sender.send(()).unwrap(); + }); + assert_receive_timeout(receiver.recv_timeout(Duration::from_millis(20))); + drop(first); + receiver.recv_timeout(Duration::from_secs(1)).unwrap(); + waiter.join().unwrap(); } - #[test] fn ownership_release_drains_active_write_and_blocks_later_writes() { let directory = tempfile::tempdir().unwrap(); - let write_count = Arc::new(AtomicUsize::new(0)); - let write_count_for_writer = Arc::clone(&write_count); - let (write_started_tx, write_started_rx) = mpsc::sync_channel(0); - let (allow_write_tx, allow_write_rx) = mpsc::sync_channel(0); - let allow_write_rx = std::sync::Mutex::new(allow_write_rx); + let (started_tx, started_rx) = mpsc::sync_channel(0); + let (allow_tx, allow_rx) = mpsc::sync_channel(0); + let allow_rx = std::sync::Mutex::new(allow_rx); let state = Arc::new( ClientState::initialize_at_with_writer( directory.path(), - Arc::new(move |path, bytes| { - write_count_for_writer.fetch_add(1, Ordering::SeqCst); - write_started_tx.send(()).unwrap(); - allow_write_rx.lock().unwrap().recv().unwrap(); - super::write_atomically(path, bytes) + Arc::new(move |path, bytes, ownership_valid| { + started_tx.send(()).unwrap(); + allow_rx.lock().unwrap().recv().unwrap(); + super::write_atomically(path, bytes, ownership_valid) }), ) .unwrap(), ); - - let writing_state = Arc::clone(&state); - let writer = thread::spawn(move || writing_state.save_snapshot(json!({ "first": true }))); - write_started_rx.recv().unwrap(); - - let releasing_state = Arc::clone(&state); - let (release_started_tx, release_started_rx) = mpsc::sync_channel(0); - let (release_finished_tx, release_finished_rx) = mpsc::sync_channel(0); + enable_restore_in_memory(&state); + let writing = Arc::clone(&state); + let writer = thread::spawn(move || writing.save_snapshot(json!({ "first": true }))); + started_rx.recv().unwrap(); + let releasing = Arc::clone(&state); + let (released_tx, released_rx) = mpsc::channel(); let releaser = thread::spawn(move || { - release_started_tx.send(()).unwrap(); - releasing_state.release_locks(); - release_finished_tx.send(()).unwrap(); + releasing.release_locks(); + released_tx.send(()).unwrap(); }); - release_started_rx.recv().unwrap(); - assert_eq!( - release_finished_rx.recv_timeout(Duration::from_millis(50)), - Err(mpsc::RecvTimeoutError::Timeout) - ); - - allow_write_tx.send(()).unwrap(); + assert_receive_timeout(released_rx.recv_timeout(Duration::from_millis(50))); + allow_tx.send(()).unwrap(); assert!(writer.join().unwrap().unwrap()); - release_finished_rx.recv().unwrap(); + released_rx.recv().unwrap(); releaser.join().unwrap(); assert!(!state.is_primary()); - assert_eq!(write_count.load(Ordering::SeqCst), 1); assert!(!state.save_snapshot(json!({ "tooLate": true })).unwrap()); - assert_eq!(write_count.load(Ordering::SeqCst), 1); } #[test] -fn client_state_origin_requires_managed_cli_or_narrow_app_origin() { - let managed = "http://127.0.0.1:43123"; - assert!(is_allowed_client_state_origin( - &Url::parse("http://127.0.0.1:43123/workspace").unwrap(), - Some(managed), - )); - assert!(!is_allowed_client_state_origin( - &Url::parse("http://127.0.0.1:43124/workspace").unwrap(), - Some(managed), - )); - assert!(!is_allowed_client_state_origin( - &Url::parse("http://localhost:9000/workspace").unwrap(), - None, - )); - assert!(is_allowed_client_state_origin( - &Url::parse("https://tauri.localhost/loading.html").unwrap(), - None, - )); +fn ownership_loss_blocks_the_final_atomic_replacement() { + let directory = tempfile::tempdir().unwrap(); + let owner_path = directory + .path() + .join(".cross-host-election") + .join("primary.owner.json") + .join("owner.json"); + let state = ClientState::initialize_at_with_writer( + directory.path(), + Arc::new(move |path, bytes, ownership_valid| { + fs::write(&owner_path, b"malformed").unwrap(); + super::write_atomically(path, bytes, ownership_valid) + }), + ) + .unwrap(); + enable_restore_in_memory(&state); + assert_eq!( + state.save_snapshot(json!({ "blocked": true })).unwrap_err(), + "Client state ownership changed before atomic replacement" + ); + assert!(!state.is_primary()); + assert_eq!(state.load().unwrap(), load(false, true, Value::Null)); + assert!(!directory.path().join(CLIENT_STATE_FILENAME).exists()); } #[test] -fn rejects_snapshots_over_one_mib_without_replacing_state() { +fn renderer_rotation_blocks_an_in_flight_old_renderer_replacement() { + let directory = tempfile::tempdir().unwrap(); + let (started_tx, started_rx) = mpsc::sync_channel(0); + let (allow_tx, allow_rx) = mpsc::sync_channel(0); + let allow_rx = std::sync::Mutex::new(allow_rx); + let state = Arc::new( + ClientState::initialize_at_with_writer( + directory.path(), + Arc::new(move |path, bytes, replacement_valid| { + started_tx.send(()).unwrap(); + allow_rx.lock().unwrap().recv().unwrap(); + super::write_atomically(path, bytes, replacement_valid) + }), + ) + .unwrap(), + ); + enable_restore_in_memory(&state); + let outgoing = Url::parse("http://127.0.0.1:43123/workspace").unwrap(); + let incoming = Url::parse("http://127.0.0.1:43124/workspace").unwrap(); + state.renderer_access.claim("old", &outgoing).unwrap(); + let generation = state.renderer_access.validate("old", &outgoing).unwrap(); + let writing = Arc::clone(&state); + let authority = Arc::clone(&state); + let writer = thread::spawn(move || { + writing.save_snapshot_guarded(json!({ "stale": true }), || { + authority.renderer_access.is_generation_current(generation) + }) + }); + started_rx.recv().unwrap(); + state + .renderer_access + .begin_navigation(Some(&incoming)) + .unwrap(); + state.renderer_access.claim("new", &incoming).unwrap(); + allow_tx.send(()).unwrap(); + assert!(writer.join().unwrap().is_err()); + assert_eq!(state.load().unwrap().snapshot, Value::Null); + assert!(!directory.path().join(CLIENT_STATE_FILENAME).exists()); +} +#[test] +fn oversized_snapshot_does_not_replace_state() { let directory = tempfile::tempdir().unwrap(); let state = ClientState::initialize_at(directory.path()).unwrap(); - assert!(state.save_snapshot(json!({ "small": true })).unwrap()); - - let oversized = Value::String("x".repeat(MAX_CLIENT_SNAPSHOT_BYTES)); - assert!(state.save_snapshot(oversized).is_err()); + enable_restore(&state); + state.save_snapshot(json!({ "small": true })).unwrap(); + assert!(state + .save_snapshot(Value::String("x".repeat(MAX_CLIENT_SNAPSHOT_BYTES))) + .is_err()); assert_eq!(state.load().unwrap().snapshot, json!({ "small": true })); } diff --git a/packages/tauri-app/src-tauri/src/client_state/window.rs b/packages/tauri-app/src-tauri/src/client_state/window.rs index c54cce77..fbcad4f8 100644 --- a/packages/tauri-app/src-tauri/src/client_state/window.rs +++ b/packages/tauri-app/src-tauri/src/client_state/window.rs @@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use std::sync::atomic::Ordering; use std::time::Duration; -use tauri::{AppHandle, Manager, PhysicalPosition, PhysicalSize, WindowEvent}; +use tauri::{AppHandle, LogicalPosition, LogicalSize, Manager, WindowEvent}; const MIN_WINDOW_WIDTH: i32 = 800; const MIN_WINDOW_HEIGHT: i32 = 600; @@ -71,27 +71,23 @@ pub(super) fn clamp_window_bounds( bounds: &WindowBounds, displays: &[DisplayArea], ) -> Option { - let mut selected: Option<(DisplayArea, i64, i128)> = None; - for display in displays + let display = displays .iter() .copied() .filter(|display| display.width > 0 && display.height > 0) - { - let intersection = intersection_area(bounds, display); - let distance = center_distance_squared(bounds, display); - if selected - .as_ref() - .map(|(_, best_intersection, best_distance)| { - intersection > *best_intersection - || (intersection == *best_intersection && distance < *best_distance) - }) - .unwrap_or(true) - { - selected = Some((display, intersection, distance)); - } - } - - let (display, _, _) = selected?; + .reduce(|best, candidate| { + let best_intersection = intersection_area(bounds, best); + let candidate_intersection = intersection_area(bounds, candidate); + if candidate_intersection > best_intersection + || (candidate_intersection == best_intersection + && center_distance_squared(bounds, candidate) + < center_distance_squared(bounds, best)) + { + candidate + } else { + best + } + })?; let maximum_width = display.width.min(i32::MAX as u32) as i32; let maximum_height = display.height.min(i32::MAX as u32) as i32; let width = bounds @@ -151,12 +147,15 @@ fn capture_main_window_in_memory(app: &AppHandle) { let maximized = window.is_maximized().unwrap_or(false); let fullscreen = window.is_fullscreen().unwrap_or(false); let minimized = window.is_minimized().unwrap_or(false); + let scale_factor = window.scale_factor().unwrap_or(1.0); let current_bounds = if !maximized && !fullscreen && !minimized { window .outer_position() .ok() .zip(window.inner_size().ok()) .and_then(|(position, size)| { + let position = position.to_logical::(scale_factor); + let size = size.to_logical::(scale_factor); let bounds = WindowBounds { x: position.x, y: position.y, @@ -280,26 +279,32 @@ pub fn setup_main_window(app: &AppHandle) -> Result<(), String> { .flatten() }; if let Some(mut saved_window) = saved_window { + let startup_scale = window.scale_factor().unwrap_or(1.0); let displays = window .available_monitors() .unwrap_or_default() .into_iter() .map(|monitor| { let work_area = monitor.work_area(); + let position = work_area.position.to_logical::(startup_scale); + let size = work_area.size.to_logical::(startup_scale); DisplayArea { - x: work_area.position.x, - y: work_area.position.y, - width: work_area.size.width, - height: work_area.size.height, + x: position.x, + y: position.y, + width: size.width, + height: size.height, } }) .collect::>(); if let Some(bounds) = clamp_window_bounds(&saved_window.bounds, &displays) { - let _ = window.set_size(PhysicalSize::new(bounds.width as u32, bounds.height as u32)); - let _ = window.set_position(PhysicalPosition::new(bounds.x, bounds.y)); + let _ = window.set_size(LogicalSize::new(bounds.width as u32, bounds.height as u32)); + let _ = window.set_position(LogicalPosition::new(bounds.x, bounds.y)); saved_window.bounds = bounds; } else if let Ok(position) = window.outer_position() { if let Ok(size) = window.inner_size() { + let scale_factor = window.scale_factor().unwrap_or(1.0); + let position = position.to_logical::(scale_factor); + let size = size.to_logical::(scale_factor); saved_window.bounds = WindowBounds { x: position.x, y: position.y, diff --git a/packages/tauri-app/src-tauri/src/linux_tls.rs b/packages/tauri-app/src-tauri/src/linux_tls.rs index 1a4865d2..6228d079 100644 --- a/packages/tauri-app/src-tauri/src/linux_tls.rs +++ b/packages/tauri-app/src-tauri/src/linux_tls.rs @@ -30,15 +30,17 @@ pub fn ensure_remote_window_tls_handler( let webview = platform_webview.inner(); let app_handle = app_handle.clone(); let window_label = window_label.clone(); - webview.connect_load_failed_with_tls_errors(move |view, failing_uri, certificate, _| { - allow_remote_tls_certificate( - &app_handle, - &window_label, - view, - failing_uri, - certificate, - ) - }); + webview.connect_load_failed_with_tls_errors( + move |view, failing_uri, certificate, _| { + allow_remote_tls_certificate( + &app_handle, + &window_label, + view, + failing_uri, + certificate, + ) + }, + ); }) .map_err(|err| err.to_string()) } diff --git a/packages/tauri-app/src-tauri/src/main.rs b/packages/tauri-app/src-tauri/src/main.rs index 123701a8..1b3161bc 100644 --- a/packages/tauri-app/src-tauri/src/main.rs +++ b/packages/tauri-app/src-tauri/src/main.rs @@ -349,6 +349,9 @@ async fn open_remote_window_impl( .build() .map_err(|err| err.to_string())?; + #[cfg(windows)] + shutdown::schedule_windows_session_end_handler(&window)?; + #[cfg(target_os = "linux")] { linux_tls::ensure_remote_window_tls_handler(&window, &app, &label)?; @@ -561,6 +564,11 @@ fn set_windows_app_user_model_id() { fn set_windows_app_user_model_id() {} fn main() { + #[cfg(windows)] + if let Some(code) = cli_manager::run_windows_cli_launcher_if_requested() { + std::process::exit(code); + } + let _ = rustls::crypto::ring::default_provider().install_default(); let navigation_guard: TauriPlugin = PluginBuilder::new("external-link-guard") @@ -607,10 +615,14 @@ fn main() { set_windows_app_user_model_id(); let client_state = client_state::ClientState::initialize(&app.handle()); app.manage(client_state); + app.manage(shutdown::ShutdownCoordinator::default()); build_menu(&app.handle())?; client_state::setup_main_window(&app.handle()) .map_err(|err| std::io::Error::new(std::io::ErrorKind::Other, err))?; if let Some(window) = app.get_webview_window("main") { + #[cfg(windows)] + shutdown::install_windows_session_end_handler(&window) + .map_err(|err| std::io::Error::new(std::io::ErrorKind::Other, err))?; let _ = window.eval(LOCAL_WINDOW_CONTEXT_SCRIPT); } if let Some(shortcut) = fullscreen_shortcut() { @@ -752,7 +764,7 @@ fn main() { .expect("error while building tauri application") .run(|app_handle, event| match event { tauri::RunEvent::ExitRequested { api, .. } => { - if shutdown::exit_allowed() { + if shutdown::exit_allowed(&app_handle) { return; } api.prevent_exit(); @@ -785,11 +797,11 @@ fn main() { .. } => { if label == "main" { - if shutdown::main_window_close_allowed() { + if shutdown::main_window_close_allowed(&app_handle) { return; } let final_window = app_handle.webview_windows().len() == 1; - if shutdown::exit_allowed() { + if shutdown::exit_allowed(&app_handle) { return; } api.prevent_close(); diff --git a/packages/tauri-app/src-tauri/src/shutdown.rs b/packages/tauri-app/src-tauri/src/shutdown.rs index 756de526..8fab8fae 100644 --- a/packages/tauri-app/src-tauri/src/shutdown.rs +++ b/packages/tauri-app/src-tauri/src/shutdown.rs @@ -1,55 +1,121 @@ use crate::{client_state, AppState}; -use std::sync::atomic::{AtomicU64, AtomicU8, Ordering}; +#[cfg(windows)] +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Mutex; use std::time::Duration; +#[cfg(windows)] +use tauri::WebviewWindow; use tauri::{AppHandle, Emitter, Manager}; -static SHUTDOWN_STATE: AtomicU8 = AtomicU8::new(ShutdownPhase::Idle as u8); -static RENDERER_FLUSH_GENERATION: AtomicU64 = AtomicU64::new(0); const RENDERER_FLUSH_TIMEOUT: Duration = Duration::from_secs(1); +const SHUTDOWN_STOP_ATTEMPTS: usize = 2; +#[cfg(windows)] +const WINDOWS_SESSION_END_TIMEOUT: Duration = Duration::from_secs(5); -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -#[repr(u8)] +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub(crate) enum ShutdownPhase { - Idle = 0, - WaitingForShutdownRenderer = 1, - CleanupInProgress = 2, - ExitAllowed = 3, - WaitingForMainWindowRenderer = 4, - FlushingMainWindow = 5, - FlushingMainWindowForShutdown = 6, - MainWindowCloseAllowed = 7, + #[default] + Idle, + WaitingForShutdownRenderer, + CleanupInProgress, + CleanupBlocked, + ExitAllowed, + WaitingForMainWindowRenderer, + FlushingMainWindow, + FlushingMainWindowForShutdown, + MainWindowCloseAllowed, } -impl ShutdownPhase { - pub(crate) fn from_raw(value: u8) -> Self { - match value { - 1 => Self::WaitingForShutdownRenderer, - 2 => Self::CleanupInProgress, - 3 => Self::ExitAllowed, - 4 => Self::WaitingForMainWindowRenderer, - 5 => Self::FlushingMainWindow, - 6 => Self::FlushingMainWindowForShutdown, - 7 => Self::MainWindowCloseAllowed, - _ => Self::Idle, +#[derive(Default)] +pub(crate) struct ShutdownCoordinator { + state: Mutex, + #[cfg(windows)] + windows_session_end_started: AtomicBool, +} + +#[derive(Default)] +struct ShutdownState { + phase: ShutdownPhase, + flush_generation: u64, + active_flush_generation: Option, +} + +impl ShutdownCoordinator { + fn apply(&self, event: ShutdownEvent) -> (ShutdownAction, Option) { + let mut state = self.state.lock().unwrap_or_else(|err| err.into_inner()); + if let Some(generation) = event.renderer_generation() { + if state.active_flush_generation != Some(generation) { + return (ShutdownAction::None, None); + } + } + let (next, action) = transition(state.phase, event); + state.phase = next; + if action == ShutdownAction::RequestRendererFlush { + state.flush_generation += 1; + state.active_flush_generation = Some(state.flush_generation); + } else if !matches!( + next, + ShutdownPhase::WaitingForShutdownRenderer | ShutdownPhase::WaitingForMainWindowRenderer + ) { + state.active_flush_generation = None; + } + (action, state.active_flush_generation) + } + + fn phase(&self) -> ShutdownPhase { + self.state + .lock() + .unwrap_or_else(|err| err.into_inner()) + .phase + } + + fn with_navigation(&self, operation: impl FnOnce() -> T) -> Option { + let state = self.state.lock().unwrap_or_else(|err| err.into_inner()); + (state.phase == ShutdownPhase::Idle).then(operation) + } + + #[cfg(windows)] + fn begin_windows_session_end(&self) -> bool { + !self + .windows_session_end_started + .swap(true, Ordering::SeqCst) + } + + #[cfg(windows)] + fn complete_windows_session_end(&self) { + let mut state = self.state.lock().unwrap_or_else(|err| err.into_inner()); + state.phase = ShutdownPhase::ExitAllowed; + state.active_flush_generation = None; + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ShutdownEvent { + BeginShutdown, + BeginMainWindowClose, + RendererFlushed(u64), + RendererUnavailable, + RendererTimeout(u64), + MainWindowFlushed, + MainWindowCloseFailed, + MainWindowDestroyed, + CleanupFinished, + CleanupFailed, +} + +impl ShutdownEvent { + fn renderer_generation(self) -> Option { + match self { + Self::RendererFlushed(generation) | Self::RendererTimeout(generation) => { + Some(generation) + } + _ => None, } } } #[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(crate) enum ShutdownEvent { - BeginShutdown { renderer_available: bool }, - BeginMainWindowClose, - RendererFlushed, - RendererTimeout, - RendererUnavailable, - MainWindowFlushed, - MainWindowCloseFailed, - MainWindowDestroyed, - CleanupFinished, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(crate) enum ShutdownAction { +enum ShutdownAction { None, RequestRendererFlush, FlushMainWindow, @@ -57,114 +123,64 @@ pub(crate) enum ShutdownAction { StartCleanup, } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(crate) struct ShutdownTransition { - pub(crate) next: ShutdownPhase, - pub(crate) action: ShutdownAction, -} - -pub(crate) fn transition(phase: ShutdownPhase, event: ShutdownEvent) -> ShutdownTransition { - use ShutdownAction::{ - CloseMainWindow, FlushMainWindow, None, RequestRendererFlush, StartCleanup, - }; - use ShutdownEvent::{ - BeginMainWindowClose, BeginShutdown, CleanupFinished, MainWindowCloseFailed, - MainWindowDestroyed, MainWindowFlushed, RendererFlushed, RendererTimeout, - RendererUnavailable, - }; - use ShutdownPhase::{ - CleanupInProgress, ExitAllowed, FlushingMainWindow, FlushingMainWindowForShutdown, Idle, - MainWindowCloseAllowed, WaitingForMainWindowRenderer, WaitingForShutdownRenderer, - }; +fn transition(phase: ShutdownPhase, event: ShutdownEvent) -> (ShutdownPhase, ShutdownAction) { + use ShutdownAction::*; + use ShutdownEvent::*; + use ShutdownPhase::*; match (phase, event) { + (Idle, BeginShutdown) => (WaitingForShutdownRenderer, RequestRendererFlush), ( - Idle, - BeginShutdown { - renderer_available: true, - }, - ) => ShutdownTransition { - next: WaitingForShutdownRenderer, - action: RequestRendererFlush, - }, - ( - Idle, - BeginShutdown { - renderer_available: false, - }, - ) - | (WaitingForShutdownRenderer, RendererUnavailable) - | (WaitingForShutdownRenderer, RendererFlushed | RendererTimeout) - | (WaitingForShutdownRenderer, MainWindowDestroyed) => ShutdownTransition { - next: CleanupInProgress, - action: StartCleanup, - }, - (Idle, BeginMainWindowClose) => ShutdownTransition { - next: WaitingForMainWindowRenderer, - action: RequestRendererFlush, - }, - (WaitingForMainWindowRenderer, RendererFlushed | RendererTimeout) => ShutdownTransition { - next: FlushingMainWindow, - action: FlushMainWindow, - }, - (WaitingForMainWindowRenderer, RendererUnavailable | MainWindowDestroyed) => { - ShutdownTransition { - next: Idle, - action: None, - } + WaitingForShutdownRenderer, + RendererFlushed(_) | RendererUnavailable | RendererTimeout(_) | MainWindowDestroyed, + ) => (CleanupInProgress, StartCleanup), + (Idle, BeginMainWindowClose) => (WaitingForMainWindowRenderer, RequestRendererFlush), + (WaitingForMainWindowRenderer, RendererFlushed(_) | RendererTimeout(_)) => { + (FlushingMainWindow, FlushMainWindow) } - (WaitingForMainWindowRenderer, BeginShutdown { .. }) => ShutdownTransition { - next: WaitingForShutdownRenderer, - action: None, - }, - (FlushingMainWindow, BeginShutdown { .. }) => ShutdownTransition { - next: FlushingMainWindowForShutdown, - action: None, - }, - (FlushingMainWindow, MainWindowFlushed) => ShutdownTransition { - next: MainWindowCloseAllowed, - action: CloseMainWindow, - }, - (FlushingMainWindow, MainWindowDestroyed) => ShutdownTransition { - next: Idle, - action: None, - }, + (WaitingForMainWindowRenderer, RendererUnavailable | MainWindowDestroyed) => (Idle, None), + (WaitingForMainWindowRenderer, BeginShutdown) => (WaitingForShutdownRenderer, None), + (FlushingMainWindow, BeginShutdown) => (FlushingMainWindowForShutdown, None), + (FlushingMainWindow, MainWindowFlushed) => (MainWindowCloseAllowed, CloseMainWindow), + (FlushingMainWindow, MainWindowDestroyed) => (Idle, None), (FlushingMainWindowForShutdown, MainWindowFlushed | MainWindowDestroyed) => { - ShutdownTransition { - next: CleanupInProgress, - action: StartCleanup, - } + (CleanupInProgress, StartCleanup) } - (MainWindowCloseAllowed, MainWindowDestroyed | MainWindowCloseFailed) => { - ShutdownTransition { - next: Idle, - action: None, - } - } - (MainWindowCloseAllowed, BeginShutdown { .. }) => ShutdownTransition { - next: CleanupInProgress, - action: StartCleanup, - }, - (CleanupInProgress, CleanupFinished) => ShutdownTransition { - next: ExitAllowed, - action: None, - }, - _ => ShutdownTransition { - next: phase, - action: None, - }, + (MainWindowCloseAllowed, MainWindowDestroyed | MainWindowCloseFailed) => (Idle, None), + (MainWindowCloseAllowed, BeginShutdown) => (CleanupInProgress, StartCleanup), + (CleanupInProgress, CleanupFinished) => (ExitAllowed, None), + (CleanupInProgress, CleanupFailed) => (CleanupBlocked, None), + (CleanupBlocked, BeginShutdown) => (CleanupInProgress, StartCleanup), + _ => (phase, None), } } -fn start_cleanup(app: AppHandle) { +fn apply_event(app: AppHandle, event: ShutdownEvent) { + let (action, generation) = app.state::().apply(event); + + match action { + ShutdownAction::RequestRendererFlush => request_renderer_flush(app, generation.unwrap()), + ShutdownAction::FlushMainWindow => flush_main_window(app), + ShutdownAction::CloseMainWindow => close_main_window(app), + ShutdownAction::StartCleanup => start_cleanup(app), + ShutdownAction::None => {} + } +} + +fn request_renderer_flush(app: AppHandle, generation: u64) { + let Some(window) = app.get_webview_window("main") else { + apply_event(app, ShutdownEvent::RendererUnavailable); + return; + }; + if let Err(err) = window.emit( + "client-state:flush-requested", + client_state::RendererFlushRequest { generation }, + ) { + eprintln!("[client-state] failed to request renderer shutdown flush: {err}"); + } std::thread::spawn(move || { - if let Some(state) = app.try_state::() { - state.desktop_events.stop(); - let _ = state.manager.stop(); - } - client_state::flush_and_release(&app); - apply_event(app.clone(), ShutdownEvent::CleanupFinished); - app.exit(0); + std::thread::sleep(RENDERER_FLUSH_TIMEOUT); + apply_event(app, ShutdownEvent::RendererTimeout(generation)); }); } @@ -186,227 +202,410 @@ fn close_main_window(app: AppHandle) { } } -fn request_renderer_flush(app: AppHandle) { - let generation = RENDERER_FLUSH_GENERATION.fetch_add(1, Ordering::SeqCst) + 1; - let Some(window) = app.get_webview_window("main") else { - apply_event(app, ShutdownEvent::RendererUnavailable); - return; - }; - - if let Err(err) = window.emit("client-state:flush-requested", ()) { - eprintln!("[client-state] failed to request renderer shutdown flush: {err}"); - } - +fn start_cleanup(app: AppHandle) { std::thread::spawn(move || { - std::thread::sleep(RENDERER_FLUSH_TIMEOUT); - if RENDERER_FLUSH_GENERATION.load(Ordering::SeqCst) == generation { - apply_event(app, ShutdownEvent::RendererTimeout); + let result = cleanup(&app, true); + match result { + Ok(()) => { + apply_event(app.clone(), ShutdownEvent::CleanupFinished); + app.exit(0); + } + Err(err) => { + eprintln!( + "[tauri] shutdown cleanup remains unconfirmed after {SHUTDOWN_STOP_ATTEMPTS} attempts: {err}; keeping the app alive for a later quit retry" + ); + apply_event(app, ShutdownEvent::CleanupFailed); + } } }); } -fn apply_event(app: AppHandle, event: ShutdownEvent) { - loop { - let current_raw = SHUTDOWN_STATE.load(Ordering::SeqCst); - let current = ShutdownPhase::from_raw(current_raw); - let next = transition(current, event); - if next.next == current { - return; +fn retry_bounded( + attempts: usize, + mut operation: impl FnMut() -> Result<(), E>, +) -> Result<(), E> { + assert!(attempts > 0); + for attempt in 1..=attempts { + match operation() { + Ok(()) => return Ok(()), + Err(err) if attempt == attempts => return Err(err), + Err(_) => {} } - if SHUTDOWN_STATE - .compare_exchange( - current_raw, - next.next as u8, - Ordering::SeqCst, - Ordering::SeqCst, - ) - .is_err() - { - continue; - } - - match next.action { - ShutdownAction::RequestRendererFlush => request_renderer_flush(app), - ShutdownAction::FlushMainWindow => flush_main_window(app), - ShutdownAction::CloseMainWindow => close_main_window(app), - ShutdownAction::StartCleanup => start_cleanup(app), - ShutdownAction::None => {} - } - return; } + unreachable!() +} + +fn cleanup(app: &AppHandle, capture_window: bool) -> Result<(), String> { + if capture_window { + client_state::capture_and_flush_main_window(app); + } else { + client_state::flush_and_release_without_window_capture(app); + } + if let Some(state) = app.try_state::() { + state.desktop_events.stop(); + retry_bounded(SHUTDOWN_STOP_ATTEMPTS, || { + state.manager.stop().map_err(|err| err.to_string()) + })?; + } + if capture_window { + client_state::release(app); + } + Ok(()) } pub(crate) fn request(app: AppHandle) { - let renderer_available = app.get_webview_window("main").is_some(); - apply_event(app, ShutdownEvent::BeginShutdown { renderer_available }); + apply_event(app, ShutdownEvent::BeginShutdown); +} + +#[cfg(windows)] +pub(crate) fn request_windows_session_end(app: AppHandle) { + if !app + .state::() + .begin_windows_session_end() + { + return; + } + + let (finished_tx, finished_rx) = std::sync::mpsc::sync_channel(1); + let cleanup_app = app.clone(); + std::thread::spawn(move || { + // WM_ENDSESSION runs on the window thread. Do not request renderer flushes or + // native window state here: either can marshal back to the blocked thread. The + // bounded fallback therefore persists only state already captured in memory. + let result = cleanup(&cleanup_app, false); + if let Err(err) = &result { + eprintln!("[tauri] Windows session-end cleanup failed: {err}"); + } + cleanup_app + .state::() + .complete_windows_session_end(); + let _ = finished_tx.send(result); + }); + match finished_rx.recv_timeout(WINDOWS_SESSION_END_TIMEOUT) { + Ok(Ok(())) => {} + Ok(Err(_)) => {} + Err(_) => eprintln!( + "[tauri] Windows session-end cleanup exceeded {:?}; returning control to Windows", + WINDOWS_SESSION_END_TIMEOUT + ), + } } pub(crate) fn request_main_window_close(app: AppHandle) { apply_event(app, ShutdownEvent::BeginMainWindowClose); } -pub(crate) fn renderer_flushed(app: AppHandle) { - apply_event(app, ShutdownEvent::RendererFlushed); +pub(crate) fn renderer_flushed(app: AppHandle, generation: u64) { + apply_event(app, ShutdownEvent::RendererFlushed(generation)); } pub(crate) fn main_window_destroyed(app: AppHandle) { apply_event(app, ShutdownEvent::MainWindowDestroyed); } -pub(crate) fn main_window_close_allowed() -> bool { - ShutdownPhase::from_raw(SHUTDOWN_STATE.load(Ordering::SeqCst)) - == ShutdownPhase::MainWindowCloseAllowed +fn phase(app: &AppHandle) -> ShutdownPhase { + app.state::().phase() } -pub(crate) fn exit_allowed() -> bool { - ShutdownPhase::from_raw(SHUTDOWN_STATE.load(Ordering::SeqCst)) == ShutdownPhase::ExitAllowed +pub(crate) fn with_navigation_authority( + app: &AppHandle, + operation: impl FnOnce() -> T, +) -> Option { + app.state::() + .with_navigation(operation) +} + +pub(crate) fn main_window_close_allowed(app: &AppHandle) -> bool { + phase(app) == ShutdownPhase::MainWindowCloseAllowed +} + +pub(crate) fn exit_allowed(app: &AppHandle) -> bool { + phase(app) == ShutdownPhase::ExitAllowed +} + +#[cfg(windows)] +fn is_confirmed_windows_session_end(message: u32, wparam: usize) -> bool { + use windows_sys::Win32::UI::WindowsAndMessaging::WM_ENDSESSION; + + message == WM_ENDSESSION && wparam != 0 +} + +#[cfg(windows)] +struct WindowsSessionEndContext { + app: AppHandle, +} + +#[cfg(windows)] +unsafe extern "system" fn windows_session_end_proc( + hwnd: windows_sys::Win32::Foundation::HWND, + message: u32, + wparam: windows_sys::Win32::Foundation::WPARAM, + lparam: windows_sys::Win32::Foundation::LPARAM, + subclass_id: usize, + reference_data: usize, +) -> windows_sys::Win32::Foundation::LRESULT { + use windows_sys::Win32::UI::Shell::{DefSubclassProc, RemoveWindowSubclass}; + use windows_sys::Win32::UI::WindowsAndMessaging::{WM_NCDESTROY, WM_QUERYENDSESSION}; + + if message == WM_NCDESTROY { + RemoveWindowSubclass(hwnd, Some(windows_session_end_proc), subclass_id); + let result = DefSubclassProc(hwnd, message, wparam, lparam); + drop(Box::from_raw( + reference_data as *mut WindowsSessionEndContext, + )); + return result; + } + + if message == WM_QUERYENDSESSION { + return 1; + } + + if is_confirmed_windows_session_end(message, wparam) { + let context = &*(reference_data as *const WindowsSessionEndContext); + request_windows_session_end(context.app.clone()); + return 0; + } + + DefSubclassProc(hwnd, message, wparam, lparam) +} + +#[cfg(windows)] +pub(crate) fn install_windows_session_end_handler(window: &WebviewWindow) -> Result<(), String> { + use windows_sys::Win32::UI::Shell::SetWindowSubclass; + + const SUBCLASS_ID: usize = 0x434E_5345; + let hwnd = window.hwnd().map_err(|err| err.to_string())?; + let context = Box::into_raw(Box::new(WindowsSessionEndContext { + app: window.app_handle().clone(), + })); + let installed = unsafe { + SetWindowSubclass( + hwnd.0, + Some(windows_session_end_proc), + SUBCLASS_ID, + context as usize, + ) + }; + if installed == 0 { + unsafe { drop(Box::from_raw(context)) }; + return Err("failed to install Windows session-end handler".to_string()); + } + Ok(()) +} + +#[cfg(windows)] +pub(crate) fn schedule_windows_session_end_handler(window: &WebviewWindow) -> Result<(), String> { + let window = window.clone(); + let app = window.app_handle().clone(); + app.run_on_main_thread(move || { + if let Err(err) = install_windows_session_end_handler(&window) { + eprintln!("[client-state] failed to install Windows session-end handler: {err}"); + } + }) + .map_err(|err| err.to_string()) } #[cfg(test)] mod tests { use super::*; + use std::sync::{mpsc, Arc}; #[test] - fn first_shutdown_waits_for_an_available_renderer() { - assert_eq!( - transition( - ShutdownPhase::Idle, - ShutdownEvent::BeginShutdown { - renderer_available: true, - }, - ), - ShutdownTransition { - next: ShutdownPhase::WaitingForShutdownRenderer, - action: ShutdownAction::RequestRendererFlush, - } - ); - } - - #[test] - fn shutdown_without_a_renderer_starts_cleanup_directly() { - assert_eq!( - transition( - ShutdownPhase::Idle, - ShutdownEvent::BeginShutdown { - renderer_available: false, - }, - ), - ShutdownTransition { - next: ShutdownPhase::CleanupInProgress, - action: ShutdownAction::StartCleanup, - } - ); - } - - #[test] - fn close_only_acknowledgement_flushes_and_closes_main_without_cleanup() { + fn close_only_flushes_and_closes_without_cleanup() { let waiting = transition(ShutdownPhase::Idle, ShutdownEvent::BeginMainWindowClose); - assert_eq!(waiting.next, ShutdownPhase::WaitingForMainWindowRenderer); - assert_eq!(waiting.action, ShutdownAction::RequestRendererFlush); - - for event in [ - ShutdownEvent::RendererFlushed, - ShutdownEvent::RendererTimeout, - ] { - assert_eq!( - transition(waiting.next, event), - ShutdownTransition { - next: ShutdownPhase::FlushingMainWindow, - action: ShutdownAction::FlushMainWindow, - } - ); - } - assert_eq!( - transition( - ShutdownPhase::FlushingMainWindow, - ShutdownEvent::MainWindowFlushed, - ), - ShutdownTransition { - next: ShutdownPhase::MainWindowCloseAllowed, - action: ShutdownAction::CloseMainWindow, - } - ); - } - - #[test] - fn destroyed_main_resets_close_only_state_for_later_remote_shutdown() { - let reset = transition( - ShutdownPhase::MainWindowCloseAllowed, - ShutdownEvent::MainWindowDestroyed, - ); - assert_eq!(reset.next, ShutdownPhase::Idle); - assert_eq!(reset.action, ShutdownAction::None); - assert_eq!( - transition( - reset.next, - ShutdownEvent::BeginShutdown { - renderer_available: false, - }, - ), - ShutdownTransition { - next: ShutdownPhase::CleanupInProgress, - action: ShutdownAction::StartCleanup, - } - ); - } - - #[test] - fn full_shutdown_upgrades_an_in_progress_close_only_flush() { - assert_eq!( - transition( + waiting, + ( ShutdownPhase::WaitingForMainWindowRenderer, - ShutdownEvent::BeginShutdown { - renderer_available: true, - }, - ), - ShutdownTransition { - next: ShutdownPhase::WaitingForShutdownRenderer, - action: ShutdownAction::None, - } + ShutdownAction::RequestRendererFlush + ) ); + let flushing = transition(waiting.0, ShutdownEvent::RendererFlushed(1)); assert_eq!( - transition( + flushing, + ( ShutdownPhase::FlushingMainWindow, - ShutdownEvent::BeginShutdown { - renderer_available: true, - }, - ), - ShutdownTransition { - next: ShutdownPhase::FlushingMainWindowForShutdown, - action: ShutdownAction::None, - } + ShutdownAction::FlushMainWindow + ) ); assert_eq!( - transition( + transition(flushing.0, ShutdownEvent::MainWindowFlushed), + ( + ShutdownPhase::MainWindowCloseAllowed, + ShutdownAction::CloseMainWindow + ) + ); + } + + #[test] + fn shutdown_promotes_each_close_only_phase() { + for (phase, event, expected) in [ + ( + ShutdownPhase::WaitingForMainWindowRenderer, + ShutdownEvent::BeginShutdown, + ( + ShutdownPhase::WaitingForShutdownRenderer, + ShutdownAction::None, + ), + ), + ( + ShutdownPhase::FlushingMainWindow, + ShutdownEvent::BeginShutdown, + ( + ShutdownPhase::FlushingMainWindowForShutdown, + ShutdownAction::None, + ), + ), + ( ShutdownPhase::FlushingMainWindowForShutdown, ShutdownEvent::MainWindowFlushed, + ( + ShutdownPhase::CleanupInProgress, + ShutdownAction::StartCleanup, + ), ), - ShutdownTransition { - next: ShutdownPhase::CleanupInProgress, - action: ShutdownAction::StartCleanup, - } + ( + ShutdownPhase::MainWindowCloseAllowed, + ShutdownEvent::BeginShutdown, + ( + ShutdownPhase::CleanupInProgress, + ShutdownAction::StartCleanup, + ), + ), + ] { + assert_eq!(transition(phase, event), expected); + } + } + + #[test] + fn stale_renderer_completion_cannot_advance_a_later_close() { + let coordinator = ShutdownCoordinator::default(); + let (_, first) = coordinator.apply(ShutdownEvent::BeginMainWindowClose); + assert_eq!(first, Some(1)); + coordinator.apply(ShutdownEvent::RendererFlushed(1)); + coordinator.apply(ShutdownEvent::MainWindowFlushed); + coordinator.apply(ShutdownEvent::MainWindowDestroyed); + let (_, second) = coordinator.apply(ShutdownEvent::BeginMainWindowClose); + assert_eq!(second, Some(2)); + for stale in [ + ShutdownEvent::RendererFlushed(1), + ShutdownEvent::RendererTimeout(1), + ] { + assert_eq!(coordinator.apply(stale), (ShutdownAction::None, None)); + assert_eq!( + coordinator.phase(), + ShutdownPhase::WaitingForMainWindowRenderer + ); + } + assert_eq!( + coordinator.apply(ShutdownEvent::RendererTimeout(2)).0, + ShutdownAction::FlushMainWindow ); } #[test] - fn cleanup_completion_allows_exit_and_cannot_restart_shutdown() { + fn cleanup_completion_permanently_allows_exit() { let completed = transition( ShutdownPhase::CleanupInProgress, ShutdownEvent::CleanupFinished, ); - assert_eq!(completed.next, ShutdownPhase::ExitAllowed); assert_eq!( - transition( - completed.next, - ShutdownEvent::BeginShutdown { - renderer_available: true, - }, - ), - ShutdownTransition { - next: ShutdownPhase::ExitAllowed, - action: ShutdownAction::None, - } + completed, + (ShutdownPhase::ExitAllowed, ShutdownAction::None) + ); + assert_eq!( + transition(completed.0, ShutdownEvent::BeginShutdown), + (ShutdownPhase::ExitAllowed, ShutdownAction::None) ); } + + #[test] + fn failed_cleanup_stays_alive_and_accepts_a_later_retry() { + assert_eq!( + transition( + ShutdownPhase::CleanupInProgress, + ShutdownEvent::CleanupFailed + ), + (ShutdownPhase::CleanupBlocked, ShutdownAction::None) + ); + assert_eq!( + transition(ShutdownPhase::CleanupBlocked, ShutdownEvent::BeginShutdown), + ( + ShutdownPhase::CleanupInProgress, + ShutdownAction::StartCleanup + ) + ); + } + + #[test] + fn shutdown_stop_retries_are_bounded() { + let mut attempts = 0; + retry_bounded(SHUTDOWN_STOP_ATTEMPTS, || { + attempts += 1; + (attempts == SHUTDOWN_STOP_ATTEMPTS).then_some(()).ok_or(()) + }) + .unwrap(); + assert_eq!(attempts, SHUTDOWN_STOP_ATTEMPTS); + + let mut failures = 0; + assert!(retry_bounded(SHUTDOWN_STOP_ATTEMPTS, || { + failures += 1; + Err::<(), _>("unconfirmed") + }) + .is_err()); + assert_eq!(failures, SHUTDOWN_STOP_ATTEMPTS); + } + + #[test] + fn shutdown_waits_for_the_final_navigation_invocation() { + let coordinator = Arc::new(ShutdownCoordinator::default()); + let navigating = Arc::clone(&coordinator); + let (started_tx, started_rx) = mpsc::channel(); + let (release_tx, release_rx) = mpsc::channel(); + let navigation = std::thread::spawn(move || { + navigating.with_navigation(|| { + started_tx.send(()).unwrap(); + release_rx.recv().unwrap(); + }) + }); + started_rx.recv().unwrap(); + let shutting_down = Arc::clone(&coordinator); + let (finished_tx, finished_rx) = mpsc::channel(); + let shutdown = std::thread::spawn(move || { + shutting_down.apply(ShutdownEvent::BeginShutdown); + finished_tx.send(()).unwrap(); + }); + assert!(finished_rx.recv_timeout(Duration::from_millis(20)).is_err()); + release_tx.send(()).unwrap(); + navigation.join().unwrap(); + finished_rx.recv_timeout(Duration::from_secs(1)).unwrap(); + shutdown.join().unwrap(); + assert_eq!( + coordinator.phase(), + ShutdownPhase::WaitingForShutdownRenderer + ); + assert!(coordinator.with_navigation(|| ()).is_none()); + } + + #[cfg(windows)] + #[test] + fn windows_session_end_is_bounded_and_starts_once() { + let coordinator = ShutdownCoordinator::default(); + assert!(coordinator.begin_windows_session_end()); + assert!(!coordinator.begin_windows_session_end()); + assert!(WINDOWS_SESSION_END_TIMEOUT <= Duration::from_secs(5)); + } + + #[cfg(windows)] + #[test] + fn only_confirmed_end_session_starts_shutdown() { + use windows_sys::Win32::UI::WindowsAndMessaging::{ + WM_CLOSE, WM_ENDSESSION, WM_QUERYENDSESSION, + }; + + assert!(!is_confirmed_windows_session_end(WM_QUERYENDSESSION, 0)); + assert!(is_confirmed_windows_session_end(WM_ENDSESSION, 1)); + assert!(!is_confirmed_windows_session_end(WM_ENDSESSION, 0)); + assert!(!is_confirmed_windows_session_end(WM_CLOSE, 0)); + } } diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index e5b611aa..189957c7 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -19,6 +19,8 @@ import { initGithubStars } from "./stores/github-stars" import { useCommands } from "./lib/hooks/use-commands" import { useAppLifecycle } from "./lib/hooks/use-app-lifecycle" import { useAppSessionRestore } from "./lib/hooks/use-app-session-restore" +import { loadedRestorableSession } from "./stores/client-state" +import { shouldShowAppHomeOverlay, shouldShowEmptyAppHome } from "./stores/app-session-restore-gate" import { getLogger } from "./lib/logger" import { launchError, showLaunchError, clearLaunchError } from "./stores/launch-errors" import { formatLaunchErrorMessage, isMissingBinaryMessage } from "./lib/launch-errors" @@ -628,15 +630,17 @@ const App: Component = () => { } > - + + + - +
{ - return typeof value === "object" && value !== null && !Array.isArray(value) -} - -function escapeRegExp(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") -} - -function addCandidate(candidates: string[], value: unknown): void { - if (typeof value !== "string" || value.length === 0 || value.length > MAX_MENTION_CANDIDATE_LENGTH) return - if (!candidates.includes(value)) candidates.push(value) -} +const MAX_MENTION_LENGTH = 4096 +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value) +const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") export function getAttachmentPromptMentionCandidates(value: unknown): string[] { if (!isRecord(value)) return [] - - const candidates: string[] = [] - if (typeof value.display === "string" && value.display.startsWith("@")) { - addCandidate(candidates, value.display.slice(1)) - } - - const source = value.source - if (!isRecord(source)) return candidates + const source = isRecord(value.source) ? value.source : {} + const candidates: unknown[] = [ + typeof value.display === "string" && value.display.startsWith("@") ? value.display.slice(1) : undefined, + ] if (source.type === "file") { - addCandidate(candidates, source.path) - addCandidate(candidates, value.filename) - + candidates.push(source.path, value.filename) if (source.mime === "inode/directory" && typeof source.path === "string") { - const trimmed = source.path.replace(/\/+$/, "") - if (trimmed === "" || trimmed === ".") { - addCandidate(candidates, "./") - } else { - addCandidate(candidates, `${trimmed}/`) - addCandidate(candidates, `${trimmed.replace(/^\.\//, "")}/`) - } + const path = source.path.replace(/\/+$/, "") + candidates.push(path === "" || path === "." ? "./" : `${path}/`) + if (path !== "" && path !== ".") candidates.push(`${path.replace(/^\.\//, "")}/`) } } else if (source.type === "agent") { - addCandidate(candidates, source.name) - addCandidate(candidates, value.filename) + candidates.push(source.name, value.filename) } else if ( source.type === "text" && typeof source.value === "string" && value.display === `path: ${source.value}` ) { - addCandidate(candidates, source.value) + candidates.push(source.value) } - return candidates + return [...new Set(candidates.filter( + (candidate): candidate is string => + typeof candidate === "string" && candidate.length > 0 && candidate.length <= MAX_MENTION_LENGTH, + ))] } -export function createPromptMentionRegex( - candidate: string, - options: { global?: boolean } = {}, -): RegExp { +export function createPromptMentionRegex(candidate: string, options: { global?: boolean } = {}): RegExp { // Picker paths are inserted literally, including spaces; escaping is only for regex matching. return new RegExp(`@${escapeRegExp(candidate)}(?=\\s|$)`, options.global ? "gi" : "i") } + +export function removeAttachmentPromptTokens(prompt: string, attachment: unknown): string { + const display = isRecord(attachment) ? attachment.display : undefined + const placeholder = getAttachmentPlaceholder(display) + if (placeholder) { + return prompt.replace(createAttachmentPlaceholderRegex(placeholder.kind, placeholder.counter), "") + } + for (const candidate of getAttachmentPromptMentionCandidates(attachment)) { + prompt = prompt.replace(createPromptMentionRegex(candidate, { global: true }), "") + } + return prompt +} diff --git a/packages/ui/src/lib/attachment-placeholders.ts b/packages/ui/src/lib/attachment-placeholders.ts index 657a3ae6..500943c0 100644 --- a/packages/ui/src/lib/attachment-placeholders.ts +++ b/packages/ui/src/lib/attachment-placeholders.ts @@ -1,15 +1,23 @@ export type AttachmentPlaceholderKind = "image" | "pasted" -function escapeRegExp(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") +const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + +export function getAttachmentPlaceholder(display: unknown) { + if (typeof display !== "string" || display.length > 1024) return + const match = display.match(/(pasted|image)\s*#\s*(\d+)/i) + if (!match) return + return { + kind: match[1]!.toLowerCase() === "image" ? "image" as const : "pasted" as const, + counter: match[2]!, + } } export function createAttachmentPlaceholderRegex( kind: AttachmentPlaceholderKind, - counter?: string | number, + counter: string | number | undefined = undefined, options: { global?: boolean } = {}, ): RegExp { const label = kind === "image" ? "Image" : "pasted" - const counterPattern = counter === undefined ? "(\\d+)" : escapeRegExp(String(counter)) - return new RegExp(`\\[\\s*${label}\\s*#\\s*${counterPattern}\\s*\\]`, options.global === false ? "i" : "gi") + const count = counter === undefined ? "(\\d+)" : escapeRegExp(String(counter)) + return new RegExp(`\\[\\s*${label}\\s*#\\s*${count}\\s*\\]`, options.global === false ? "i" : "gi") } diff --git a/packages/ui/src/lib/hooks/use-app-session-capture.test.ts b/packages/ui/src/lib/hooks/use-app-session-capture.test.ts new file mode 100644 index 00000000..b644b0fd --- /dev/null +++ b/packages/ui/src/lib/hooks/use-app-session-capture.test.ts @@ -0,0 +1,37 @@ +import assert from "node:assert/strict" +import { readFileSync } from "node:fs" +import { describe, it } from "node:test" + +const source = (file: string) => readFileSync(new URL(file, import.meta.url), "utf8") + +describe("app session capture listener readiness", () => { + it("waits for both Tauri flush listeners before restore starts capture", () => { + const capture = source("./use-app-session-capture.ts") + const restore = source("./use-app-session-restore.ts") + const ready = capture.slice(capture.indexOf("const ready ="), capture.indexOf("const markScrollAuthority")) + assert.match(ready, /Promise\.all/) + assert.match(ready, /client-state:flush-requested/) + assert.match(ready, /client-state:navigation-flush-requested/) + assert.ok(restore.indexOf("await capture.ready") < restore.indexOf("capture.start(")) + }) + + it("uses the serialized commit queue without serializing create requests", () => { + const restore = source("./use-app-session-restore.ts") + assert.match(restore, /runWithSerializedCommits/) + assert.match(restore, /waitForCreateCommit/) + assert.doesNotMatch(restore, /for \(const match of missing\) await restoreWorkspace/) + }) + + it("does not track prompt hydration writes in the capture effect", () => { + const capture = source("./use-app-session-capture.ts") + assert.match(capture, /untrack\(\(\) => hydratePreservedPrompts/) + }) + + it("reapplies full preserved state after transient reopen hydration", () => { + const capture = source("./use-app-session-capture.ts") + assert.match(capture, /waitForInstanceInitialSessionHydration/) + assert.match(capture, /hydrateRestoredWorkspaceState/) + assert.match(capture, /settlePreservedTab/) + }) + +}) diff --git a/packages/ui/src/lib/hooks/use-app-session-capture.ts b/packages/ui/src/lib/hooks/use-app-session-capture.ts index ab2fd4ad..446a37de 100644 --- a/packages/ui/src/lib/hooks/use-app-session-capture.ts +++ b/packages/ui/src/lib/hooks/use-app-session-capture.ts @@ -1,321 +1,308 @@ -import { createEffect, createSignal, onCleanup, onMount } from "solid-js" +import { createEffect, createSignal, onCleanup, onMount, untrack } from "solid-js" import { listen } from "@tauri-apps/api/event" import { getLogger } from "../logger" -import { - acknowledgeNativeClientStateNavigationFlush, - acknowledgeNativeClientStateRendererFlush, -} from "../native/client-state" +import { acknowledgeNativeClientStateNavigationFlush, acknowledgeNativeClientStateRendererFlush } from "../native/client-state" import { isElectronHost, isLocalWindow, isTauriHost } from "../runtime-env" import { - clientStateIsPrimary, - flushClientState, - restorePreviousStateEnabled, - updateRestorableSession, - type RestorableSessionState, - type RestorableTabState, - type RestorableWorkspaceTabState, + clientStateIsPrimary, flushClientState, restorePreviousStateEnabled, updateRestorableSession, + type RestorableSessionState, type RestorableTabState, type RestorableWorkspaceTabState, } from "../../stores/client-state" -import { normalizeWorkspacePath, shouldEnableSessionCapture } from "../../stores/app-session-reconciliation" +import { normalizeWorkspacePath } from "../../stores/app-session-reconciliation" import { - createRestorableSessionPreservation, - mapRestoredWorkspaces, - markPreservedWorkspaceRemoved, - markPreservedWorkspaceReopened, - markRestoredTab, - mergeRestorableSessionState, - unmapRestoredWorkspace, - type RestorableSessionPreservation, + createRestorableSessionPreservation, createRestoredTabCommitGuard, markPreservedWorkspaceRemoved, + markPreservedWorkspaceReopened, markPreservedWorkspaceUnavailable, + getPreservedWorkspaceReopenTarget, getPreservedWorkspaceState, + hasRestoredTabBinding, mergeRestorableSessionState, recordRestoredTab, + settleRestoredTab as settlePreservedTab, type RestorableSessionPreservation, type RestorableWorkspaceRuntimeAuthority, - type RestoredWorkspaceMapping, } from "../../stores/app-session-snapshot-merge" import { activeAppTabId, appTabs, getInstanceAppTabId } from "../../stores/app-tabs" -import { instances } from "../../stores/instances" +import { showFolderSelection } from "../../stores/ui" +import { instances, waitForInstanceInitialSessionHydration } from "../../stores/instances" import { - activeParentSessionId, - activeSessionId, - getAuthoritativeDraftSessionIdsForInstance, - getAuthoritativelyDeletedSessionIdsForInstance, - getSessionDraftPromptsForInstance, - getSessions, + activeParentSessionId, activeSessionId, getAuthoritativeDraftSessionIdsForInstance, + getAuthoritativelyDeletedSessionIdsForInstance, getSessionDraftPromptsForInstance, getSessions, hasAuthoritativeSessionSelection, } from "../../stores/sessions" import { messageStoreBus } from "../../stores/message-v2/bus" import type { ScrollSnapshot } from "../../stores/message-v2/types" -import { - getAuthoritativeAttachmentSessionIdsForInstance, - getSessionAttachmentsForInstance, -} from "../../stores/attachments" +import { getAuthoritativeAttachmentSessionIdsForInstance, getSessionAttachmentsForInstance } from "../../stores/attachments" import { serializeDraftAttachments } from "../../stores/client-state-attachments-codec" import { onInstanceLifecycleAuthority } from "../../stores/instance-lifecycle-authority" import { getPersistedGenerationRecovery, type PersistedGenerationRecovery } from "../../stores/session-generation-recovery" - +import { hydrateWorkspacePromptState } from "../../stores/app-session-prompt-hydration" +import { + hydrateRestoredWorkspaceState, NO_SESSION_DRAFT_SESSION_ID, +} from "../../stores/app-session-workspace-hydration" const log = getLogger("actions") const MESSAGE_SCROLL_SCOPE = "message-stream" const MAX_CAPTURED_SCROLL_SNAPSHOTS = 96 const CAPTURE_DEBOUNCE_MS = 100 - -interface AppSessionCaptureController { - beginRestore(snapshot: RestorableSessionState): void - markRestoredTab( - tabIndex: number, - unavailableSessionIds?: ReadonlySet, - restoredTabId?: string | null, - ): void - mapWorkspaces(mappings: readonly RestoredWorkspaceMapping[]): void - unmapWorkspace(runtimeTabId: string): void - prepareCapture(snapshotExisted: boolean, restoreCompleted: boolean): void - startCapture(): void -} - function captureScrollSnapshots(instanceId: string): Record { const store = messageStoreBus.getInstance(instanceId) if (!store) return {} - const suffix = `:${MESSAGE_SCROLL_SCOPE}` - const snapshots = Object.entries(store.state.scrollState) + return Object.fromEntries(Object.entries(store.state.scrollState) .filter(([key]) => key.endsWith(suffix)) .map(([key, snapshot]) => ({ sessionId: key.slice(0, -suffix.length), snapshot })) - .filter((entry) => Boolean(entry.sessionId)) - .sort((left, right) => right.snapshot.updatedAt - left.snapshot.updatedAt) + .filter(({ sessionId }) => Boolean(sessionId)) + .sort((a, b) => b.snapshot.updatedAt - a.snapshot.updatedAt) .slice(0, MAX_CAPTURED_SCROLL_SNAPSHOTS) - - const result: Record = {} - for (const { sessionId, snapshot } of snapshots) result[sessionId] = { ...snapshot } - return result + .map(({ sessionId, snapshot }) => [sessionId, { ...snapshot }])) } - -function captureUnseenIdleMarkers(instanceId: string): Record { - return Object.fromEntries( - getSessions(instanceId) - .filter((session) => session.status === "idle" && typeof session.idleSince === "number") - .map((session) => [session.id, session.idleSince as number]), - ) -} - -function captureGenerationRecovery(instanceId: string): Record { - const result: Record = {} +function captureRuntimeState(instanceId: string): Pick { + const unseenIdleSince: Record = {} + const generationRecovery: Record = {} for (const session of getSessions(instanceId)) { + if (session.status === "idle" && typeof session.idleSince === "number") unseenIdleSince[session.id] = session.idleSince const recovery = getPersistedGenerationRecovery(session.status, session.generationRecovery) - if (recovery) result[session.id] = recovery + if (recovery) generationRecovery[session.id] = recovery } - return result + return { unseenIdleSince, generationRecovery } } - -function captureRestorableSessionState( - authoritativeScrollSessionIdsByInstance: ReadonlyMap>, -): { - state: RestorableSessionState - tabIds: string[] - authorities: Array -} { +function captureState(scrollAuthority: ReadonlyMap>) { const tabs = appTabs() - const nextOccurrenceByPath = new Map() - const occurrenceByInstanceId = new Map() + const nextOccurrence = new Map() + const occurrenceByInstance = new Map() for (const instance of instances().values()) { - const pathKey = normalizeWorkspacePath(instance.folder) - const occurrence = nextOccurrenceByPath.get(pathKey) ?? 0 - nextOccurrenceByPath.set(pathKey, occurrence + 1) - occurrenceByInstanceId.set(instance.id, occurrence) + const path = normalizeWorkspacePath(instance.folder) + const occurrence = nextOccurrence.get(path) ?? 0 + nextOccurrence.set(path, occurrence + 1) + occurrenceByInstance.set(instance.id, occurrence) } const restorableTabs: RestorableTabState[] = tabs.map((tab) => { if (tab.kind === "sidecar") return { kind: "sidecar", sidecarId: tab.sidecarTab.sidecarId } - - const draftState = serializeDraftAttachments( - getSessionDraftPromptsForInstance(tab.instance.id), - getSessionAttachmentsForInstance(tab.instance.id), - ) + const id = tab.instance.id + const parentId = activeParentSessionId().get(id) + const sessionId = activeSessionId().get(id) + const prioritySessionIds = [sessionId, "__no_session_draft__"].filter((value): value is string => Boolean(value)) const result: RestorableWorkspaceTabState = { - kind: "workspace", - folder: tab.instance.folder, - occurrence: occurrenceByInstanceId.get(tab.instance.id) ?? 0, - drafts: draftState.drafts, - attachments: draftState.attachments, - scrollSnapshots: captureScrollSnapshots(tab.instance.id), - unseenIdleSince: captureUnseenIdleMarkers(tab.instance.id), - generationRecovery: captureGenerationRecovery(tab.instance.id), + kind: "workspace", folder: tab.instance.folder, occurrence: occurrenceByInstance.get(id) ?? 0, + ...serializeDraftAttachments( + getSessionDraftPromptsForInstance(id), getSessionAttachmentsForInstance(id), prioritySessionIds, + ), + ...captureRuntimeState(id), scrollSnapshots: captureScrollSnapshots(id), } if (tab.instance.projectName) result.projectName = tab.instance.projectName if (tab.instance.binaryPath) result.binaryPath = tab.instance.binaryPath - - const parentSessionId = activeParentSessionId().get(tab.instance.id) - const sessionId = activeSessionId().get(tab.instance.id) - if (parentSessionId) result.activeParentSessionId = parentSessionId + if (parentId) result.activeParentSessionId = parentId if (sessionId) result.activeSessionId = sessionId return result }) - + const authorities: Array = tabs.map((tab) => { + if (tab.kind !== "instance") return undefined + const id = tab.instance.id + const sessionIds = new Set(getSessions(id).map(({ id }) => id)) + return { + drafts: getAuthoritativeDraftSessionIdsForInstance(id), + attachments: getAuthoritativeAttachmentSessionIdsForInstance(id), + scrollSnapshots: scrollAuthority.get(id), idleMarkers: sessionIds, generationRecovery: sessionIds, + deletedSessions: getAuthoritativelyDeletedSessionIdsForInstance(id), + sessionSelection: hasAuthoritativeSessionSelection(id), + } + }) return { state: { tabs: restorableTabs, - activeTabIndex: tabs.findIndex((tab) => tab.id === activeAppTabId()), + activeTabIndex: tabs.findIndex(({ id }) => id === activeAppTabId()), + ...(showFolderSelection() ? { homeActive: true } : {}), }, - tabIds: tabs.map((tab) => tab.id), - authorities: tabs.map((tab) => tab.kind === "instance" ? { - drafts: getAuthoritativeDraftSessionIdsForInstance(tab.instance.id), - attachments: getAuthoritativeAttachmentSessionIdsForInstance(tab.instance.id), - scrollSnapshots: authoritativeScrollSessionIdsByInstance.get(tab.instance.id), - idleMarkers: new Set(getSessions(tab.instance.id).map((session) => session.id)), - generationRecovery: new Set(getSessions(tab.instance.id).map((session) => session.id)), - deletedSessions: getAuthoritativelyDeletedSessionIdsForInstance(tab.instance.id), - sessionSelection: hasAuthoritativeSessionSelection(tab.instance.id), - } : undefined), + tabIds: tabs.map(({ id }) => id), authorities, } } - -export function useAppSessionCapture(): AppSessionCaptureController { - const [startupFinished, setStartupFinished] = createSignal(false) - const [captureAllowed, setCaptureAllowed] = createSignal(false) - const captureEnabled = () => captureAllowed() && startupFinished() && clientStateIsPrimary() && restorePreviousStateEnabled() +export function useAppSessionCapture() { + const [started, setStarted] = createSignal(false) + const enabled = () => started() && clientStateIsPrimary() && restorePreviousStateEnabled() + const scrollAuthority = new Map>() + const instanceLifecycleTokens = new Map() + let nextInstanceLifecycleToken = 0 let disposed = false - let captureTimer: ReturnType | null = null - let capturePreservation: RestorableSessionPreservation | null = null - const authoritativeScrollSessionIdsByInstance = new Map>() - - const markScrollSessionAuthoritative = (instanceId: string, sessionId: string) => { - const sessionIds = authoritativeScrollSessionIdsByInstance.get(instanceId) ?? new Set() - sessionIds.add(sessionId) - authoritativeScrollSessionIdsByInstance.set(instanceId, sessionIds) + const hydrationController = new AbortController() + let timer: ReturnType | null = null + let preservation: RestorableSessionPreservation | null = null + const hydratePreservedPrompts = (instanceId: string) => { + if (!preservation) return + const instance = instances().get(instanceId) + if (!instance) return + const snapshot = getPreservedWorkspaceState(preservation, { + runtimeTabId: getInstanceAppTabId(instanceId), folder: instance.folder, occurrence: 0, + }) + if (!snapshot) return + hydrateWorkspacePromptState( + instanceId, + snapshot, + new Set(getSessions(instanceId).map(({ id }) => id)), + NO_SESSION_DRAFT_SESSION_ID, + ) } - const captureMergedState = () => { - const captured = captureRestorableSessionState(authoritativeScrollSessionIdsByInstance) - return mergeRestorableSessionState(captured.state, capturePreservation, { - currentTabIds: captured.tabIds, - currentTabAuthorities: captured.authorities, + const mergedState = () => { + const captured = captureState(scrollAuthority) + return mergeRestorableSessionState(captured.state, preservation, { + currentTabIds: captured.tabIds, currentTabAuthorities: captured.authorities, }) } - const persistCurrentState = () => { - if (!captureEnabled() || disposed) return - updateRestorableSession(captureMergedState()) + const capture = () => { + timer = null + if (enabled() && !disposed) updateRestorableSession(mergedState()) } - const flushCurrentState = async () => { - if (captureTimer !== null) { - clearTimeout(captureTimer) - captureTimer = null - } - if (captureEnabled()) updateRestorableSession(captureMergedState()) + const schedule = () => { + if (!enabled() || disposed) return + if (timer) clearTimeout(timer) + timer = setTimeout(capture, CAPTURE_DEBOUNCE_MS) + } + const flush = async () => { + if (timer) clearTimeout(timer) + timer = null + if (enabled()) updateRestorableSession(mergedState()) await flushClientState() } - const scheduleCapture = () => { - if (!captureEnabled() || disposed) return - if (captureTimer !== null) clearTimeout(captureTimer) - captureTimer = setTimeout(() => { - captureTimer = null - persistCurrentState() - }, CAPTURE_DEBOUNCE_MS) + const nativeUnlisteners: Array<() => void> = [] + let nativeDisposed = false + const register = (event: string, acknowledge: (payload: T) => void | Promise) => listen(event, ({ payload }) => { + void flush().then(() => acknowledge(payload)).catch((error) => log.error(`Failed to handle ${event}`, error)) + }).then((unlisten) => { + if (nativeDisposed) unlisten() + else nativeUnlisteners.push(unlisten) + }).catch((error) => log.error(`Failed to listen for ${event}`, error)) + const ready = isTauriHost() && isLocalWindow() + ? Promise.all([ + register<{ generation: number }>("client-state:flush-requested", + ({ generation }) => acknowledgeNativeClientStateRendererFlush(generation)), + register<{ generation: number }>("client-state:navigation-flush-requested", + ({ generation }) => acknowledgeNativeClientStateNavigationFlush(generation)), + ]).then(() => undefined) + : Promise.resolve() + const markScrollAuthority = (instanceId: string, sessionId: string) => { + const sessionIds = scrollAuthority.get(instanceId) ?? new Set() + sessionIds.add(sessionId) + scrollAuthority.set(instanceId, sessionIds) + schedule() } - - const stopListeningForScrollChanges = messageStoreBus.onScrollSnapshotChanged((instanceId, sessionId, scope) => { - if (scope !== MESSAGE_SCROLL_SCOPE) return - markScrollSessionAuthoritative(instanceId, sessionId) - scheduleCapture() - }) - const stopListeningForClearedSessions = messageStoreBus.onSessionCleared((instanceId, sessionId) => { - markScrollSessionAuthoritative(instanceId, sessionId) - scheduleCapture() - }) - const stopListeningForDestroyedInstances = messageStoreBus.onInstanceDestroyed((instanceId) => { - authoritativeScrollSessionIdsByInstance.delete(instanceId) - }) - const stopListeningForInstanceLifecycleAuthority = onInstanceLifecycleAuthority((event) => { - if (!capturePreservation) return - const workspace = { - runtimeTabId: getInstanceAppTabId(event.instanceId), - folder: event.folder, - occurrence: event.occurrence, - } - capturePreservation = event.type === "removed" - ? markPreservedWorkspaceRemoved(capturePreservation, workspace) - : markPreservedWorkspaceReopened(capturePreservation, workspace) - scheduleCapture() - }) - + const cleanups = [ + messageStoreBus.onScrollSnapshotChanged((instanceId, sessionId, scope) => { + if (scope === MESSAGE_SCROLL_SCOPE) markScrollAuthority(instanceId, sessionId) + }), + messageStoreBus.onSessionCleared(markScrollAuthority), + messageStoreBus.onInstanceDestroyed((instanceId) => { + scrollAuthority.delete(instanceId) + instanceLifecycleTokens.delete(instanceId) + }), + onInstanceLifecycleAuthority((event) => { + const lifecycleToken = ++nextInstanceLifecycleToken + instanceLifecycleTokens.set(event.instanceId, lifecycleToken) + if (!preservation) return + const workspace = { runtimeTabId: getInstanceAppTabId(event.instanceId), folder: event.folder, occurrence: event.occurrence } + if (event.type === "unavailable") { + const captured = captureState(scrollAuthority) + const index = captured.tabIds.indexOf(workspace.runtimeTabId) + const tab = index < 0 ? undefined : captured.state.tabs[index] + markPreservedWorkspaceUnavailable( + preservation, + workspace, + tab?.kind === "workspace" ? tab : undefined, + index < 0 ? undefined : captured.authorities[index], + ) + schedule() + return + } + if (event.type === "removed") { + markPreservedWorkspaceRemoved(preservation, workspace) + } else { + const target = getPreservedWorkspaceReopenTarget(preservation, workspace) + markPreservedWorkspaceReopened(preservation, workspace) + const snapshot = target?.snapshot + const sourceIndex = target?.sourceIndex ?? -1 + const isCurrentBinding = () => Boolean( + preservation + && instanceLifecycleTokens.get(event.instanceId) === lifecycleToken + && instances().has(event.instanceId) + && hasRestoredTabBinding(preservation, sourceIndex, workspace.runtimeTabId), + ) + if (snapshot && instances().has(event.instanceId)) void waitForInstanceInitialSessionHydration(event.instanceId).then(() => { + if (!isCurrentBinding()) return null + return hydrateRestoredWorkspaceState(event.instanceId, snapshot, hydrationController.signal, isCurrentBinding) + }).then((unavailable) => { + if (!unavailable || !preservation || !isCurrentBinding()) return + settlePreservedTab(preservation, sourceIndex, workspace.runtimeTabId, workspace.runtimeTabId, unavailable) + schedule() + }).catch((error) => { + if (!hydrationController.signal.aborted && isCurrentBinding()) { + log.warn("Failed to restore preserved state for reopened workspace", { instanceId: event.instanceId, error }) + } + }) + } + schedule() + }), + ] createEffect(() => { - if (!captureEnabled()) return + if (!enabled()) return const tabs = appTabs() - activeAppTabId() - activeParentSessionId() - activeSessionId() - for (const tab of tabs) { - if (tab.kind !== "instance") continue + activeAppTabId(); activeParentSessionId(); activeSessionId(); showFolderSelection() + for (const tab of tabs) if (tab.kind === "instance") { getSessions(tab.instance.id) getSessionDraftPromptsForInstance(tab.instance.id) getSessionAttachmentsForInstance(tab.instance.id) } - scheduleCapture() + schedule() }) - - onMount(() => { - const handlePageHide = () => void flushCurrentState() - const handleBeforeUnload = () => void flushCurrentState() - window.addEventListener("pagehide", handlePageHide) - window.addEventListener("beforeunload", handleBeforeUnload) - - let stopTauriFlushListener: (() => void) | null = null - let stopTauriNavigationFlushListener: (() => void) | null = null - let nativeHooksDisposed = false - if (isElectronHost() && isLocalWindow()) { - window.__CODENOMAD_FLUSH_CLIENT_STATE_BEFORE_NATIVE_SHUTDOWN__ = flushCurrentState - } else if (isTauriHost() && isLocalWindow()) { - void listen("client-state:flush-requested", () => { - void flushCurrentState() - .then(() => acknowledgeNativeClientStateRendererFlush()) - .catch((error) => log.error("Failed to flush client state for native shutdown", error)) - }).then((unlisten) => { - if (nativeHooksDisposed) unlisten() - else stopTauriFlushListener = unlisten - }).catch((error) => log.error("Failed to listen for native client-state flush requests", error)) - void listen<{ generation: number }>("client-state:navigation-flush-requested", (event) => { - void flushCurrentState() - .then(() => acknowledgeNativeClientStateNavigationFlush(event.payload.generation)) - .catch((error) => log.error("Failed to flush client state for native navigation", error)) - }).then((unlisten) => { - if (nativeHooksDisposed) unlisten() - else stopTauriNavigationFlushListener = unlisten - }).catch((error) => log.error("Failed to listen for native client-state navigation requests", error)) + createEffect(() => { + if (!enabled()) return + const tabs = appTabs() + for (const tab of tabs) if (tab.kind === "instance") { + getSessions(tab.instance.id) + untrack(() => hydratePreservedPrompts(tab.instance.id)) } - + }) + onMount(() => { + const flushNow = () => void flush() + window.addEventListener("pagehide", flushNow) + window.addEventListener("beforeunload", flushNow) + if (isElectronHost() && isLocalWindow()) window.__CODENOMAD_FLUSH_CLIENT_STATE_BEFORE_NATIVE_SHUTDOWN__ = flush onCleanup(() => { - nativeHooksDisposed = true - window.removeEventListener("pagehide", handlePageHide) - window.removeEventListener("beforeunload", handleBeforeUnload) - stopTauriFlushListener?.() - stopTauriNavigationFlushListener?.() - if (window.__CODENOMAD_FLUSH_CLIENT_STATE_BEFORE_NATIVE_SHUTDOWN__ === flushCurrentState) { + window.removeEventListener("pagehide", flushNow) + window.removeEventListener("beforeunload", flushNow) + if (window.__CODENOMAD_FLUSH_CLIENT_STATE_BEFORE_NATIVE_SHUTDOWN__ === flush) { delete window.__CODENOMAD_FLUSH_CLIENT_STATE_BEFORE_NATIVE_SHUTDOWN__ } }) }) - onCleanup(() => { - void flushCurrentState() + hydrationController.abort(new Error("App session capture disposed")) + nativeDisposed = true + nativeUnlisteners.forEach((unlisten) => unlisten()) + void flush() disposed = true - stopListeningForScrollChanges() - stopListeningForClearedSessions() - stopListeningForDestroyedInstances() - stopListeningForInstanceLifecycleAuthority() + cleanups.forEach((cleanup) => cleanup()) }) - return { - beginRestore(snapshot) { - capturePreservation = createRestorableSessionPreservation(snapshot) + ready, + start(snapshot?: RestorableSessionState) { + if (snapshot) preservation = createRestorableSessionPreservation(snapshot) + setStarted(true) }, - markRestoredTab(tabIndex, unavailableSessionIds, restoredTabId) { - if (!capturePreservation) return - capturePreservation = markRestoredTab(capturePreservation, tabIndex, unavailableSessionIds, restoredTabId) + recordRestoredTab(index: number, tabId: string | null, unavailable?: ReadonlySet) { + if (!preservation) return + recordRestoredTab(preservation, index, tabId, unavailable) + schedule() }, - mapWorkspaces(mappings) { - if (!capturePreservation) return - capturePreservation = mapRestoredWorkspaces(capturePreservation, mappings) + createRestoredTabCommitGuard(index: number) { + return preservation ? createRestoredTabCommitGuard(preservation, index) : () => false }, - unmapWorkspace(runtimeTabId) { - if (!capturePreservation) return - capturePreservation = unmapRestoredWorkspace(capturePreservation, runtimeTabId) + hasRestoredTabBinding(index: number, expectedRuntimeTabId: string) { + return preservation ? hasRestoredTabBinding(preservation, index, expectedRuntimeTabId) : false }, - prepareCapture(snapshotExisted, restoreCompleted) { - setCaptureAllowed(shouldEnableSessionCapture(snapshotExisted, restoreCompleted)) - }, - startCapture() { - setStartupFinished(true) + settleRestoredTab( + index: number, + expectedRuntimeTabId: string, + tabId: string | null, + unavailable?: ReadonlySet, + ) { + if (!preservation || !settlePreservedTab(preservation, index, expectedRuntimeTabId, tabId, unavailable)) return false + schedule() + return true }, + restoredTabIds: () => preservation?.results.map((result) => + "runtimeTabId" in result ? result.runtimeTabId ?? null : null) ?? [], } } +export type AppSessionCaptureController = ReturnType diff --git a/packages/ui/src/lib/hooks/use-app-session-restore.ts b/packages/ui/src/lib/hooks/use-app-session-restore.ts index 984531eb..2c748776 100644 --- a/packages/ui/src/lib/hooks/use-app-session-restore.ts +++ b/packages/ui/src/lib/hooks/use-app-session-restore.ts @@ -1,412 +1,228 @@ import { onCleanup, onMount } from "solid-js" import { getLogger } from "../logger" import { isWebHost } from "../runtime-env" -import { useAppSessionCapture } from "./use-app-session-capture" +import { useAppSessionCapture, type AppSessionCaptureController } from "./use-app-session-capture" import { - clientStateIsPrimary, - loadedClientSnapshotExists, - loadedRestorableSession, - restorePreviousStateEnabled, + clientStateIsPrimary, loadedRestorableSession, restorePreviousStateEnabled, type RestorableSessionState, - type RestorableWorkspaceTabState, } from "../../stores/client-state" import { releaseAppSessionRestoreGate } from "../../stores/app-session-restore-gate" +import { setShowFolderSelection } from "../../stores/ui" import { - getUnavailableRestoredSessionIds, - normalizeWorkspacePath, - reconcileWorkspaceTabs, - resolveRestoredActiveTabId, - resolveRestoredSessionSelection, - shouldRestoreSessionState, + reconcileWorkspaceTabs, resolveRestoredActiveTabId, shouldRestoreSessionState, } from "../../stores/app-session-reconciliation" +import { getAbortReason, runAbortable } from "../../stores/app-session-restore-timeout" import { - getAbortReason, - runWithRestoreDeadline, - withRestoreTimeout, - type RestoreActivity, -} from "../../stores/app-session-restore-timeout" -import { completeAbortableRestoreHydration } from "../../stores/abortable-restore-creation" -import type { RestoredWorkspaceMapping } from "../../stores/app-session-snapshot-merge" -import { - activeAppTabId, - appTabOrderRevision, - appTabSelectionRevision, - getInstanceAppTabId, - getSidecarAppTabId, - selectAppTab, - setAppTabOrder, + activeAppTabId, appTabOrderRevision, appTabSelectionRevision, getInstanceAppTabId, + getSidecarAppTabId, selectAppTab, setAppTabOrder, } from "../../stores/app-tabs" import { - createInstance, - disposeRestoreCreatedInstance, - releaseRestoreCreatedInstance, - instances, - waitForInitialWorkspaceLoad, - waitForInstanceInitialSessionHydration, + cancelRestoreCreationRequest, createInstance, disposeRestoreCreatedInstance, releaseRestoreCreatedInstance, instances, + waitForInitialWorkspaceLoad, waitForInstanceInitialSessionHydration, } from "../../stores/instances" import { openSidecarTab, SidecarNotFoundError } from "../../stores/sidecars" import { - getSessions, - hasAuthoritativeSessionSelection, - hydrateActiveSessionSelection, - hydrateSessionIdleMarkers, - hydrateSessionGenerationRecovery, -} from "../../stores/sessions" -import { messageStoreBus, type MessageScrollSnapshotSeed } from "../../stores/message-v2/bus" -import { hydrateWorkspacePromptState } from "../../stores/app-session-prompt-hydration" - + hydrateRestoredWorkspaceState, +} from "../../stores/app-session-workspace-hydration" +import { runWithSerializedCommits } from "../../stores/app-session-restore-queue" +import { waitForSettledPrerequisite } from "../trailing-resync" const log = getLogger("actions") -const MESSAGE_SCROLL_SCOPE = "message-stream" -const NO_SESSION_DRAFT_SESSION_ID = "__no_session_draft__" -// Independent tab operations share one concurrent startup window. -const INITIAL_WORKSPACE_LOAD_TIMEOUT_MS = 15_000 -const RESTORE_OPERATION_TIMEOUT_MS = 30_000 -const RESTORE_CREATE_WITH_ALIAS_RETRY_TIMEOUT_MS = RESTORE_OPERATION_TIMEOUT_MS * 2 -const MINIMUM_STARTUP_RESTORE_TIMEOUT_MS = 60_000 -const STARTUP_RESTORE_GRACE_MS = 5_000 - -function getStartupRestoreTimeoutMs(snapshot: RestorableSessionState): number { - const workspaceGroupSizes = new Map() - let largestSequentialGroup = 1 - for (const tab of snapshot.tabs) { - if (tab.kind !== "workspace") continue - const path = normalizeWorkspacePath(tab.folder) - const size = (workspaceGroupSizes.get(path) ?? 0) + 1 - workspaceGroupSizes.set(path, size) - largestSequentialGroup = Math.max(largestSequentialGroup, size) - } - const concurrentOperationBudget = INITIAL_WORKSPACE_LOAD_TIMEOUT_MS - + largestSequentialGroup * RESTORE_CREATE_WITH_ALIAS_RETRY_TIMEOUT_MS - + STARTUP_RESTORE_GRACE_MS - return Math.max(MINIMUM_STARTUP_RESTORE_TIMEOUT_MS, concurrentOperationBudget) +const INITIAL_LOAD_TIMEOUT_MS = 15_000 +const OPERATION_TIMEOUT_MS = 30_000 +const CREATE_TIMEOUT_MS = OPERATION_TIMEOUT_MS * 2 +const CLEANUP_TIMEOUT_MS = 5_000 +const MINIMUM_STARTUP_TIMEOUT_MS = 60_000 +function startupTimeout(snapshot: RestorableSessionState): number { + const workspaceCount = snapshot.tabs.filter((tab) => tab.kind === "workspace").length + return Math.max(MINIMUM_STARTUP_TIMEOUT_MS, + INITIAL_LOAD_TIMEOUT_MS + Math.max(1, workspaceCount) * (CREATE_TIMEOUT_MS + CLEANUP_TIMEOUT_MS) + 5_000) } - -function restoreWorkspaceState(instanceId: string, snapshot: RestorableWorkspaceTabState): Set { - const availableSessions = getSessions(instanceId) - const validSessionIds = new Set(availableSessions.map((session) => session.id)) - const unavailableSessionIds = getUnavailableRestoredSessionIds(availableSessions, { - activeParentSessionId: snapshot.activeParentSessionId, - activeSessionId: snapshot.activeSessionId, - draftSessionIds: Object.keys(snapshot.drafts), - attachmentSessionIds: Object.keys(snapshot.attachments), - scrollSessionIds: Object.keys(snapshot.scrollSnapshots), - idleMarkerSessionIds: Object.keys(snapshot.unseenIdleSince), - generationRecoverySessionIds: Object.keys(snapshot.generationRecovery), - }, [NO_SESSION_DRAFT_SESSION_ID]) - - hydrateWorkspacePromptState(instanceId, snapshot, validSessionIds, NO_SESSION_DRAFT_SESSION_ID) - hydrateSessionIdleMarkers(instanceId, snapshot.unseenIdleSince) - hydrateSessionGenerationRecovery(instanceId, snapshot.generationRecovery) - - const scrollSeeds: MessageScrollSnapshotSeed[] = [] - for (const [sessionId, scrollSnapshot] of Object.entries(snapshot.scrollSnapshots)) { - if (!validSessionIds.has(sessionId)) continue - scrollSeeds.push({ sessionId, scope: MESSAGE_SCROLL_SCOPE, snapshot: scrollSnapshot }) +async function disposeFailedRestoreWorkspace(instanceId: string): Promise { + const cleanup = disposeRestoreCreatedInstance(instanceId) + try { + await runAbortable(() => cleanup, { + timeoutMs: CLEANUP_TIMEOUT_MS, + message: `Timed out cleaning up restored workspace ${instanceId}`, + }) + } catch (error) { + log.warn("Restore workspace cleanup continues in the background", { instanceId, error }) } - messageStoreBus.seedScrollSnapshots(instanceId, scrollSeeds) - - const selection = resolveRestoredSessionSelection( - availableSessions, - snapshot.activeParentSessionId, - snapshot.activeSessionId, - ) - if (hasAuthoritativeSessionSelection(instanceId)) { - return unavailableSessionIds - } - if (!selection) { - hydrateActiveSessionSelection(instanceId, null, null) - return unavailableSessionIds - } - hydrateActiveSessionSelection(instanceId, selection.parentSessionId, selection.activeSessionId) - return unavailableSessionIds } - -async function restoreWorkspaceTabs( - snapshot: RestorableSessionState, - restoredTabIds: (string | null)[], - isRestoreActive: RestoreActivity, - restoreSignal: AbortSignal, - updatePreservation: ( - tabIndex: number, - unavailableSessionIds?: ReadonlySet, - restoredTabId?: string | null, - ) => void, - mapWorkspaces: (mappings: readonly RestoredWorkspaceMapping[]) => void, - unmapWorkspace: (runtimeTabId: string) => void, - selectRestoredActive: (tabId: string, requested: boolean) => void, - applyRestoredOrder: (tabIds: string[]) => void, -): Promise { - const workspaceMatches = reconcileWorkspaceTabs( - snapshot.tabs.map((tab) => - tab.kind === "workspace" - ? { kind: tab.kind, folderPath: tab.folder, occurrence: tab.occurrence } - : { kind: tab.kind }, - ), - Array.from(instances().values()).map((instance) => ({ id: instance.id, folderPath: instance.folder })), - ) - - const existingMatches = workspaceMatches.filter((match) => match.existingWorkspaceId) - const missingMatches = workspaceMatches.filter((match) => !match.existingWorkspaceId) - mapWorkspaces(existingMatches.map((match) => ({ - sourceIndex: match.tabIndex, - runtimeTabId: getInstanceAppTabId(match.existingWorkspaceId!), - }))) - for (const match of existingMatches) { - restoredTabIds[match.tabIndex] = getInstanceAppTabId(match.existingWorkspaceId!) +function createRestoreContext(snapshot: RestorableSessionState, signal: AbortSignal, capture: AppSessionCaptureController) { + const orderRevision = appTabOrderRevision() + const selectionRevision = appTabSelectionRevision() + let ownedActiveTabId: string | null = null + return { + snapshot, signal, capture, + selectActive(tabId: string | null, requested: boolean) { + if (appTabSelectionRevision() !== selectionRevision) return + const current = activeAppTabId() + if ((current && current !== ownedActiveTabId) || (!requested && ownedActiveTabId)) return + selectAppTab(tabId, { source: "restore" }) + ownedActiveTabId = tabId + }, + applyOrder() { + if (appTabOrderRevision() === orderRevision) { + setAppTabOrder(capture.restoredTabIds().filter((id): id is string => Boolean(id))) + } + }, } - const claimedWorkspaceIds = new Set(existingMatches.map((match) => match.existingWorkspaceId!)) - applyRestoredOrder(restoredTabIds.filter((tabId): tabId is string => Boolean(tabId))) - const provisionalActiveTabId = resolveRestoredActiveTabId(restoredTabIds, snapshot.activeTabIndex) - if (provisionalActiveTabId) { - selectRestoredActive(provisionalActiveTabId, provisionalActiveTabId === restoredTabIds[snapshot.activeTabIndex]) +} +type RestoreContext = ReturnType +const waitForWorkspaceMountAdoption = () => new Promise((resolve) => setTimeout(resolve, 0)) +async function restoreTabs(context: RestoreContext): Promise { + const { snapshot, signal, capture } = context + const sidecars = snapshot.tabs.map((tab, index) => tab.kind === "sidecar" ? restoreSidecar(tab, index) : undefined) + try { + await runAbortable(async (operationSignal) => { + await waitForInitialWorkspaceLoad() + if (operationSignal.aborted) throw getAbortReason(operationSignal) + }, { timeoutMs: INITIAL_LOAD_TIMEOUT_MS, message: "Timed out loading initial workspaces", signal }) + } catch (error) { + log.error("Failed to load workspaces before restoring app session", error) + return Promise.all(sidecars).then(() => undefined) } - - const missingMatchesByPath = new Map() - for (const match of missingMatches) { - const path = normalizeWorkspacePath(match.descriptor.folderPath) - const group = missingMatchesByPath.get(path) ?? [] - group.push(match) - missingMatchesByPath.set(path, group) - } - for (const group of missingMatchesByPath.values()) { - group.sort((left, right) => left.descriptor.occurrence - right.descriptor.occurrence || left.tabIndex - right.tabIndex) - } - - const restoreMatch = async (match: (typeof workspaceMatches)[number]): Promise => { - if (!isRestoreActive()) return + if (signal.aborted) return Promise.all(sidecars).then(() => undefined) + const matches = reconcileWorkspaceTabs(snapshot.tabs.map((tab) => tab.kind === "workspace" + ? { kind: tab.kind, folderPath: tab.folder, occurrence: tab.occurrence } + : { kind: tab.kind }), Array.from(instances().values()) + .map(({ id, folder, status }) => ({ id, folderPath: folder, status }))) + const existing = matches.filter(({ existingWorkspaceId }) => existingWorkspaceId) + const missing = matches.filter(({ existingWorkspaceId }) => !existingWorkspaceId) + existing.forEach(({ tabIndex, existingWorkspaceId }) => + capture.recordRestoredTab(tabIndex, getInstanceAppTabId(existingWorkspaceId!))) + const claimedIds = new Set(existing.map(({ existingWorkspaceId }) => existingWorkspaceId!)) + context.applyOrder() + const restoredIds = capture.restoredTabIds() + const provisionalId = resolveRestoredActiveTabId(restoredIds, snapshot.activeTabIndex) + if (provisionalId) context.selectActive(provisionalId, provisionalId === restoredIds[snapshot.activeTabIndex]) + const restoreWorkspace = async ( + match: (typeof matches)[number], + waitForCreateCommit?: Promise, + finishCreateCommit?: () => void, + ) => { + if (signal.aborted) return const tab = snapshot.tabs[match.tabIndex] if (!tab || tab.kind !== "workspace") return - - let restoreCreatedId: string | null = null + let createdId: string | null = null + const canCommitCreation = capture.createRestoredTabCommitGuard(match.tabIndex) try { - const instanceId = await withRestoreTimeout(async (operationSignal) => { + const instanceId = await runAbortable(async (operationSignal) => { const existingId = match.existingWorkspaceId - const createMissingInstance = (forceNew: boolean) => createInstance(tab.folder, tab.binaryPath, tab.projectName, { - activate: false, - signal: operationSignal, - forceNew, - onCreateCommit: (createdId) => mapWorkspaces([{ - sourceIndex: match.tabIndex, - runtimeTabId: getInstanceAppTabId(createdId), - }]), + const create = (forceNew: boolean) => createInstance(tab.folder, tab.binaryPath, tab.projectName, { + activate: false, signal: operationSignal, forceNew, + waitForCreateCommit: waitForCreateCommit ? () => waitForCreateCommit : undefined, + shouldCreateCommit: canCommitCreation, + onCreateCommit: (id) => capture.recordRestoredTab(match.tabIndex, getInstanceAppTabId(id)), }) - let creationResult = existingId || isWebHost() - ? null - : await createMissingInstance(match.descriptor.occurrence > 0) - if (creationResult && claimedWorkspaceIds.has(creationResult.instanceId)) { - if (!creationResult.reused && creationResult.requestId) { - await releaseRestoreCreatedInstance(creationResult.instanceId, creationResult.requestId) - } - creationResult = await createMissingInstance(true) - } - const id = existingId ?? creationResult?.instanceId ?? null - if (!id) return null - claimedWorkspaceIds.add(id) - const createdByRestore = creationResult?.reused === false - if (createdByRestore) restoreCreatedId = id - - await completeAbortableRestoreHydration(id, { - signal: operationSignal, - hydrate: waitForInstanceInitialSessionHydration, - commit: async (hydratedId) => { - const restoredTabId = getInstanceAppTabId(hydratedId) - if (createdByRestore && creationResult?.requestId) { - await releaseRestoreCreatedInstance(hydratedId, creationResult.requestId) - } - if (operationSignal.aborted) throw getAbortReason(operationSignal) - restoredTabIds[match.tabIndex] = restoredTabId - updatePreservation(match.tabIndex, restoreWorkspaceState(hydratedId, tab), restoredTabId) - if (match.tabIndex === snapshot.activeTabIndex) selectRestoredActive(restoredTabId, true) - }, - discard: existingId ? undefined : async (discardedId) => { - unmapWorkspace(getInstanceAppTabId(discardedId)) - if (createdByRestore) await disposeRestoreCreatedInstance(discardedId) - }, - }) - return id - }, match.existingWorkspaceId ? RESTORE_OPERATION_TIMEOUT_MS : RESTORE_CREATE_WITH_ALIAS_RETRY_TIMEOUT_MS, `Timed out restoring workspace ${tab.folder}`, restoreSignal) - if (!isRestoreActive()) return - if (!instanceId) { - log.info("Skipped automatic remote workspace launch while restoring browser state", { folder: tab.folder }) - return - } - } catch (error) { - if (restoreCreatedId) { - unmapWorkspace(getInstanceAppTabId(restoreCreatedId)) - await disposeRestoreCreatedInstance(restoreCreatedId) - } - if (!isRestoreActive()) return - log.warn("Skipped workspace while restoring app session", { folder: tab.folder, error }) - } - } - - await Promise.all([ - ...existingMatches.map(restoreMatch), - ...Array.from(missingMatchesByPath.values(), async (group) => { - for (const match of group) await restoreMatch(match) - }), - ]) -} - -async function restoreSidecarTabs( - snapshot: RestorableSessionState, - restoredTabIds: (string | null)[], - isRestoreActive: RestoreActivity, - restoreSignal: AbortSignal, - updatePreservation: ( - tabIndex: number, - unavailableSessionIds?: ReadonlySet, - restoredTabId?: string | null, - ) => void, - selectRestoredActive: (tabId: string, requested: boolean) => void, -): Promise { - await Promise.all(snapshot.tabs.map(async (tab, index) => { - if (!isRestoreActive()) return - if (!tab || tab.kind !== "sidecar") return - try { - const opened = await withRestoreTimeout( - (operationSignal) => openSidecarTab(tab.sidecarId, { - activate: false, - propagateLoadErrors: true, - signal: operationSignal, - }), - RESTORE_OPERATION_TIMEOUT_MS, - `Timed out restoring SideCar ${tab.sidecarId}`, - restoreSignal, - ) - if (!isRestoreActive()) return - restoredTabIds[index] = getSidecarAppTabId(opened.token) - updatePreservation(index, undefined, restoredTabIds[index]) - if (index === snapshot.activeTabIndex) selectRestoredActive(restoredTabIds[index]!, true) - } catch (error) { - if (error instanceof SidecarNotFoundError) updatePreservation(index) - if (!isRestoreActive()) return - log.warn("Skipped SideCar while restoring app session", { sidecarId: tab.sidecarId, error }) - } - })) -} - -async function restoreAppSession( - snapshot: RestorableSessionState, - isRestoreActive: RestoreActivity, - restoreSignal: AbortSignal, - updatePreservation: ( - tabIndex: number, - unavailableSessionIds?: ReadonlySet, - restoredTabId?: string | null, - ) => void, - mapWorkspaces: (mappings: readonly RestoredWorkspaceMapping[]) => void, - unmapWorkspace: (runtimeTabId: string) => void, -): Promise { - const restoredTabIds = Array(snapshot.tabs.length).fill(null) - const initialOrderRevision = appTabOrderRevision() - const initialSelectionRevision = appTabSelectionRevision() - let restoreOwnedActiveTabId: string | null = null - const selectRestoredActive = (tabId: string, requested: boolean) => { - if (appTabSelectionRevision() !== initialSelectionRevision) return - const current = activeAppTabId() - if (current && current !== restoreOwnedActiveTabId) return - if (!requested && restoreOwnedActiveTabId) return - selectAppTab(tabId, { source: "restore" }) - restoreOwnedActiveTabId = tabId - } - const applyRestoredOrder = (tabIds: string[]) => { - if (appTabOrderRevision() !== initialOrderRevision) return - setAppTabOrder(tabIds) - } - const workspaceRestoration = (async () => { - try { - await withRestoreTimeout( - async (operationSignal) => { - await waitForInitialWorkspaceLoad() + let creation = existingId || isWebHost() ? null : await create(match.descriptor.occurrence > 0) + if (creation && claimedIds.has(creation.instanceId)) { if (operationSignal.aborted) throw getAbortReason(operationSignal) - }, - INITIAL_WORKSPACE_LOAD_TIMEOUT_MS, - "Timed out loading initial workspaces", - restoreSignal, - ) + if (creation.requestId) await cancelRestoreCreationRequest(creation.instanceId, creation.requestId) + creation = await create(true) + } + if (creation && finishCreateCommit) await waitForWorkspaceMountAdoption() + finishCreateCommit?.() + const id = existingId ?? creation?.instanceId ?? null + if (!id) return null + claimedIds.add(id) + const created = creation?.reused === false + if (created) createdId = id + try { + // A reconnect can recover the list; saved IDs still restore directly after an initial failure. + await runAbortable( + () => waitForSettledPrerequisite(waitForInstanceInitialSessionHydration(id)), + { signal: operationSignal }, + ) + const tabId = getInstanceAppTabId(id) + const isCurrentBinding = () => capture.hasRestoredTabBinding(match.tabIndex, tabId) + if (!isCurrentBinding()) return id + const unavailable = await hydrateRestoredWorkspaceState(id, tab, operationSignal, isCurrentBinding) + if (operationSignal.aborted) throw getAbortReason(operationSignal) + if (!unavailable || !isCurrentBinding()) return id + if (creation?.requestId) await releaseRestoreCreatedInstance(id, creation.requestId) + if (operationSignal.aborted) throw getAbortReason(operationSignal) + if (capture.settleRestoredTab(match.tabIndex, tabId, tabId, unavailable) + && match.tabIndex === snapshot.activeTabIndex) context.selectActive(tabId, true) + } catch (error) { + if (!existingId && creation?.requestId) { + capture.settleRestoredTab(match.tabIndex, getInstanceAppTabId(id), null) + if (created) createdId = null + await disposeFailedRestoreWorkspace(id) + } + throw error + } + return id + }, { + timeoutMs: match.existingWorkspaceId ? OPERATION_TIMEOUT_MS : CREATE_TIMEOUT_MS, + message: `Timed out restoring workspace ${tab.folder}`, signal, + }) + if (!signal.aborted && !instanceId) { + log.info("Skipped automatic remote workspace launch while restoring browser state", { folder: tab.folder }) + } } catch (error) { - log.error("Failed to load workspaces before restoring app session", error) - return + if (createdId) { + capture.settleRestoredTab(match.tabIndex, getInstanceAppTabId(createdId), null) + await disposeFailedRestoreWorkspace(createdId) + } + if (!signal.aborted) log.warn("Skipped workspace while restoring app session", { folder: tab.folder, error }) } - if (!isRestoreActive()) return - await restoreWorkspaceTabs( - snapshot, - restoredTabIds, - isRestoreActive, - restoreSignal, - updatePreservation, - mapWorkspaces, - unmapWorkspace, - selectRestoredActive, - applyRestoredOrder, - ) - })() - const sidecarRestoration = restoreSidecarTabs( - snapshot, - restoredTabIds, - isRestoreActive, - restoreSignal, - updatePreservation, - selectRestoredActive, - ) - await Promise.all([workspaceRestoration, sidecarRestoration]) - if (!isRestoreActive()) return - - const restoredOrder = restoredTabIds.filter((tabId): tabId is string => Boolean(tabId)) - applyRestoredOrder(restoredOrder) - if (!activeAppTabId() && appTabSelectionRevision() === initialSelectionRevision) { - selectAppTab(resolveRestoredActiveTabId(restoredTabIds, snapshot.activeTabIndex), { source: "restore" }) } + async function restoreSidecar(tab: Extract, + index: number) { + if (signal.aborted) return + try { + const opened = await runAbortable((operationSignal) => openSidecarTab(tab.sidecarId, { + activate: false, propagateLoadErrors: true, signal: operationSignal, + }), { timeoutMs: OPERATION_TIMEOUT_MS, message: `Timed out restoring SideCar ${tab.sidecarId}`, signal }) + if (signal.aborted) return + const tabId = getSidecarAppTabId(opened.token) + capture.recordRestoredTab(index, tabId, new Set()) + if (index === snapshot.activeTabIndex) context.selectActive(tabId, true) + } catch (error) { + if (error instanceof SidecarNotFoundError) capture.recordRestoredTab(index, null, new Set()) + if (!signal.aborted) log.warn("Skipped SideCar while restoring app session", { sidecarId: tab.sidecarId, error }) + } + } + const restoreMissing = () => runWithSerializedCommits( + [...missing].sort((a, b) => a.tabIndex - b.tabIndex), + (match, waitForCommit, finishCommit) => restoreWorkspace(match, waitForCommit, finishCommit), + ) + await Promise.all([...existing.map((match) => restoreWorkspace(match)), restoreMissing(), ...sidecars]) } - export function useAppSessionRestore(): void { const capture = useAppSessionCapture() - const restoreController = new AbortController() + const controller = new AbortController() let disposed = false - onMount(() => { - const primary = clientStateIsPrimary() - const restoreEnabled = restorePreviousStateEnabled() - const snapshotExisted = loadedClientSnapshotExists() const snapshot = loadedRestorableSession() - + setShowFolderSelection(snapshot?.homeActive === true) void (async () => { - let restoreCompleted = false try { - if (shouldRestoreSessionState(primary, restoreEnabled, snapshot)) { - capture.beginRestore(snapshot!) - capture.prepareCapture(snapshotExisted, false) - capture.startCapture() - await runWithRestoreDeadline( - (isRestoreActive, restoreSignal) => restoreAppSession( - snapshot!, - isRestoreActive, - restoreSignal, - capture.markRestoredTab, - capture.mapWorkspaces, - capture.unmapWorkspace, - ), - getStartupRestoreTimeoutMs(snapshot!), - "Timed out restoring the saved app session", - restoreController.signal, - ) - restoreCompleted = true - } + await capture.ready + if (!shouldRestoreSessionState(clientStateIsPrimary(), restorePreviousStateEnabled(), snapshot)) return capture.start() + capture.start(snapshot!) + await runAbortable(async (signal) => { + const context = createRestoreContext(snapshot!, signal, capture) + await restoreTabs(context) + if (signal.aborted) return + context.applyOrder() + if (!activeAppTabId()) { + context.selectActive(resolveRestoredActiveTabId(capture.restoredTabIds(), snapshot!.activeTabIndex), true) + } + }, { + timeoutMs: startupTimeout(snapshot!), message: "Timed out restoring the saved app session", signal: controller.signal, + }) } catch (error) { log.error("Failed to restore app session", error) } finally { - if (disposed) return - capture.prepareCapture(snapshotExisted, restoreCompleted) - releaseAppSessionRestoreGate() - capture.startCapture() + if (!disposed) releaseAppSessionRestoreGate() } })() }) - onCleanup(() => { disposed = true - restoreController.abort(new Error("App session restore disposed")) + controller.abort(new Error("App session restore disposed")) releaseAppSessionRestoreGate() }) } diff --git a/packages/ui/src/lib/i18n/messages/de/settings.ts b/packages/ui/src/lib/i18n/messages/de/settings.ts index d08121c0..06f888d6 100644 --- a/packages/ui/src/lib/i18n/messages/de/settings.ts +++ b/packages/ui/src/lib/i18n/messages/de/settings.ts @@ -119,7 +119,7 @@ export const settingsMessages = { "settings.appearance.startup.title": "Startverhalten", "settings.appearance.startup.subtitle": "Lege fest, was dieses Gerät beim Start von CodeNomad wiederherstellt.", "settings.appearance.startup.restore.title": "Vorherigen Zustand wiederherstellen", - "settings.appearance.startup.restore.subtitle": "Arbeitsbereichs- und Sidecar-Tabs, aktive Sitzungen, Entwürfe, Scrollpositionen und das Panel-Layout erneut öffnen.", + "settings.appearance.startup.restore.subtitle": "Arbeitsbereichs- und Sidecar-Tabs, aktive Sitzungen und nicht gesendete Nachrichten erneut öffnen sowie Scrollpositionen, Panel-Layout, Fensterposition und Zoom wiederherstellen.", "settings.appearance.startup.clear.title": "Gespeicherter Startzustand", "settings.appearance.startup.clear.subtitle": "Gespeicherte Tabs, Entwürfe, Scrollpositionen und das Panel-Layout entfernen. CodeNomad- und OpenCode-Daten werden nicht gelöscht.", "settings.appearance.startup.clear.action": "Gespeicherten Zustand löschen", diff --git a/packages/ui/src/lib/i18n/messages/en/settings.ts b/packages/ui/src/lib/i18n/messages/en/settings.ts index 9fc9641c..52f7459a 100644 --- a/packages/ui/src/lib/i18n/messages/en/settings.ts +++ b/packages/ui/src/lib/i18n/messages/en/settings.ts @@ -119,7 +119,7 @@ export const settingsMessages = { "settings.appearance.startup.title": "Startup", "settings.appearance.startup.subtitle": "Choose what this device restores when CodeNomad starts.", "settings.appearance.startup.restore.title": "Restore previous state", - "settings.appearance.startup.restore.subtitle": "Reopen workspace and sidecar tabs, active sessions, drafts, scroll positions, and panel layout.", + "settings.appearance.startup.restore.subtitle": "Reopen workspace and sidecar tabs, active sessions, and unsent messages, and restore scroll positions, panel layout, window position, and zoom.", "settings.appearance.startup.clear.title": "Saved startup state", "settings.appearance.startup.clear.subtitle": "Remove saved tabs, drafts, scroll positions, and panel layout. CodeNomad and OpenCode data are not deleted.", "settings.appearance.startup.clear.action": "Clear saved state", diff --git a/packages/ui/src/lib/i18n/messages/es/settings.ts b/packages/ui/src/lib/i18n/messages/es/settings.ts index d7db4cc6..3dd6fb9c 100644 --- a/packages/ui/src/lib/i18n/messages/es/settings.ts +++ b/packages/ui/src/lib/i18n/messages/es/settings.ts @@ -119,7 +119,7 @@ export const settingsMessages = { "settings.appearance.startup.title": "Inicio", "settings.appearance.startup.subtitle": "Elige qué restaura este dispositivo al iniciar CodeNomad.", "settings.appearance.startup.restore.title": "Restaurar el estado anterior", - "settings.appearance.startup.restore.subtitle": "Volver a abrir las pestañas de espacios de trabajo y sidecars, las sesiones activas, los borradores, las posiciones de desplazamiento y el diseño de paneles.", + "settings.appearance.startup.restore.subtitle": "Volver a abrir las pestañas de espacios de trabajo y sidecars, las sesiones activas y los mensajes no enviados, y restaurar las posiciones de desplazamiento, el diseño de paneles, la posición de la ventana y el zoom.", "settings.appearance.startup.clear.title": "Estado de inicio guardado", "settings.appearance.startup.clear.subtitle": "Elimina pestañas, borradores, posiciones de desplazamiento y el diseño de paneles guardados. No se eliminan datos de CodeNomad ni de OpenCode.", "settings.appearance.startup.clear.action": "Borrar estado guardado", diff --git a/packages/ui/src/lib/i18n/messages/fr/settings.ts b/packages/ui/src/lib/i18n/messages/fr/settings.ts index 7db8a04f..7a19dc7d 100644 --- a/packages/ui/src/lib/i18n/messages/fr/settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr/settings.ts @@ -119,7 +119,7 @@ export const settingsMessages = { "settings.appearance.startup.title": "Démarrage", "settings.appearance.startup.subtitle": "Choisissez ce que cet appareil restaure au démarrage de CodeNomad.", "settings.appearance.startup.restore.title": "Restaurer l’état précédent", - "settings.appearance.startup.restore.subtitle": "Rouvrir les onglets d’espaces de travail et de sidecars, les sessions actives, les brouillons, les positions de défilement et la disposition des panneaux.", + "settings.appearance.startup.restore.subtitle": "Rouvrir les onglets d’espaces de travail et de sidecars, les sessions actives et les messages non envoyés, et restaurer les positions de défilement, la disposition des panneaux, la position de la fenêtre et le zoom.", "settings.appearance.startup.clear.title": "État de démarrage enregistré", "settings.appearance.startup.clear.subtitle": "Supprime les onglets, brouillons, positions de défilement et la disposition des panneaux enregistrés. Les données CodeNomad et OpenCode ne sont pas supprimées.", "settings.appearance.startup.clear.action": "Effacer l’état enregistré", diff --git a/packages/ui/src/lib/i18n/messages/he/settings.ts b/packages/ui/src/lib/i18n/messages/he/settings.ts index ddcd4541..d64e7860 100644 --- a/packages/ui/src/lib/i18n/messages/he/settings.ts +++ b/packages/ui/src/lib/i18n/messages/he/settings.ts @@ -119,7 +119,7 @@ export const settingsMessages = { "settings.appearance.startup.title": "הפעלה", "settings.appearance.startup.subtitle": "בחר מה ישוחזר במכשיר זה בעת הפעלת CodeNomad.", "settings.appearance.startup.restore.title": "שחזור המצב הקודם", - "settings.appearance.startup.restore.subtitle": "פתיחה מחדש של כרטיסיות סביבות עבודה ו-sidecar, הפעלות פעילות, טיוטות, מיקומי גלילה ופריסת חלוניות.", + "settings.appearance.startup.restore.subtitle": "פתיחה מחדש של כרטיסיות סביבות עבודה ו-sidecar, הפעלות פעילות והודעות שלא נשלחו, ושחזור מיקומי גלילה, פריסת חלוניות, מיקום החלון ורמת התקריב.", "settings.appearance.startup.clear.title": "מצב הפעלה שמור", "settings.appearance.startup.clear.subtitle": "הסרת כרטיסיות, טיוטות, מיקומי גלילה ופריסת חלוניות שנשמרו. נתוני CodeNomad ו-OpenCode לא יימחקו.", "settings.appearance.startup.clear.action": "נקה מצב שמור", diff --git a/packages/ui/src/lib/i18n/messages/ja/settings.ts b/packages/ui/src/lib/i18n/messages/ja/settings.ts index 836c458e..34ecfa0b 100644 --- a/packages/ui/src/lib/i18n/messages/ja/settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja/settings.ts @@ -119,7 +119,7 @@ export const settingsMessages = { "settings.appearance.startup.title": "起動", "settings.appearance.startup.subtitle": "CodeNomad の起動時にこのデバイスで復元する内容を選択します。", "settings.appearance.startup.restore.title": "前回の状態を復元", - "settings.appearance.startup.restore.subtitle": "ワークスペースとサイドカーのタブ、アクティブなセッション、下書き、スクロール位置、パネル配置を再度開きます。", + "settings.appearance.startup.restore.subtitle": "ワークスペースとサイドカーのタブ、アクティブなセッション、未送信メッセージを再度開き、スクロール位置、パネル配置、ウィンドウ位置、ズームを復元します。", "settings.appearance.startup.clear.title": "保存された起動状態", "settings.appearance.startup.clear.subtitle": "保存されたタブ、下書き、スクロール位置、パネル配置を削除します。CodeNomad と OpenCode のデータは削除されません。", "settings.appearance.startup.clear.action": "保存状態を消去", diff --git a/packages/ui/src/lib/i18n/messages/ne/settings.ts b/packages/ui/src/lib/i18n/messages/ne/settings.ts index d21cd521..24dfdfe1 100644 --- a/packages/ui/src/lib/i18n/messages/ne/settings.ts +++ b/packages/ui/src/lib/i18n/messages/ne/settings.ts @@ -119,7 +119,7 @@ export const settingsMessages = { "settings.appearance.startup.title": "सुरुआत", "settings.appearance.startup.subtitle": "CodeNomad सुरु हुँदा यस यन्त्रमा के पुनर्स्थापना गर्ने छान्नुहोस्।", "settings.appearance.startup.restore.title": "अघिल्लो अवस्था पुनर्स्थापना गर्नुहोस्", - "settings.appearance.startup.restore.subtitle": "कार्यस्थान र साइडकार ट्याब, सक्रिय सत्र, मस्यौदा, स्क्रोल स्थिति र प्यानल लेआउट फेरि खोल्नुहोस्।", + "settings.appearance.startup.restore.subtitle": "कार्यस्थान र साइडकार ट्याब, सक्रिय सत्र र नपठाइएका सन्देशहरू फेरि खोल्नुहोस्, र स्क्रोल स्थिति, प्यानल लेआउट, विन्डोको स्थान र जुम पुनर्स्थापना गर्नुहोस्।", "settings.appearance.startup.clear.title": "सुरक्षित सुरुआत अवस्था", "settings.appearance.startup.clear.subtitle": "सुरक्षित ट्याब, मस्यौदा, स्क्रोल स्थिति र प्यानल लेआउट हटाउनुहोस्। CodeNomad र OpenCode का डेटा मेटिँदैनन्।", "settings.appearance.startup.clear.action": "सुरक्षित अवस्था खाली गर्नुहोस्", diff --git a/packages/ui/src/lib/i18n/messages/ru/settings.ts b/packages/ui/src/lib/i18n/messages/ru/settings.ts index 5be2cb07..dfff560c 100644 --- a/packages/ui/src/lib/i18n/messages/ru/settings.ts +++ b/packages/ui/src/lib/i18n/messages/ru/settings.ts @@ -119,7 +119,7 @@ export const settingsMessages = { "settings.appearance.startup.title": "Запуск", "settings.appearance.startup.subtitle": "Выберите, что восстанавливать на этом устройстве при запуске CodeNomad.", "settings.appearance.startup.restore.title": "Восстанавливать предыдущее состояние", - "settings.appearance.startup.restore.subtitle": "Повторно открывать вкладки рабочих пространств и sidecar, активные сессии, черновики, позиции прокрутки и расположение панелей.", + "settings.appearance.startup.restore.subtitle": "Повторно открывать вкладки рабочих пространств и sidecar, активные сессии и неотправленные сообщения, а также восстанавливать позиции прокрутки, расположение панелей, положение окна и масштаб.", "settings.appearance.startup.clear.title": "Сохранённое состояние запуска", "settings.appearance.startup.clear.subtitle": "Удалить сохранённые вкладки, черновики, позиции прокрутки и расположение панелей. Данные CodeNomad и OpenCode не удаляются.", "settings.appearance.startup.clear.action": "Очистить сохранённое состояние", diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts index a61539d9..e5256b0b 100644 --- a/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts @@ -119,7 +119,7 @@ export const settingsMessages = { "settings.appearance.startup.title": "启动", "settings.appearance.startup.subtitle": "选择 CodeNomad 启动时在此设备上恢复的内容。", "settings.appearance.startup.restore.title": "恢复上次状态", - "settings.appearance.startup.restore.subtitle": "重新打开工作区和边栏标签页、活动会话、草稿、滚动位置和面板布局。", + "settings.appearance.startup.restore.subtitle": "重新打开工作区和边栏标签页、活动会话和未发送的消息,并恢复滚动位置、面板布局、窗口位置和缩放级别。", "settings.appearance.startup.clear.title": "已保存的启动状态", "settings.appearance.startup.clear.subtitle": "移除已保存的标签页、草稿、滚动位置和面板布局。不会删除 CodeNomad 或 OpenCode 数据。", "settings.appearance.startup.clear.action": "清除已保存状态", diff --git a/packages/ui/src/lib/native/client-state.ts b/packages/ui/src/lib/native/client-state.ts index 8d48aa62..7b6fc1e3 100644 --- a/packages/ui/src/lib/native/client-state.ts +++ b/packages/ui/src/lib/native/client-state.ts @@ -1,137 +1,58 @@ import { invoke } from "@tauri-apps/api/core" import { isElectronHost, isLocalWindow, isTauriHost } from "../runtime-env" - -const LEGACY_WEB_SNAPSHOT_STORAGE_KEY = "codenomad-client-snapshot-v1" -const LEGACY_WEB_RESTORE_ENABLED_STORAGE_KEY = "codenomad-client-restore-enabled-v1" -const accessToken = createAccessToken() - -let nativeAccessClaimed = false - -export interface NativeClientStateLoadResult { +const LEGACY_WEB_KEYS = ["codenomad-client-snapshot-v1", "codenomad-client-restore-enabled-v1"] +export type NativeClientStateLoadResult = { isPrimary: boolean restoreEnabled: boolean snapshot: unknown | null } - -function getWebStorage(): Storage | null { - if (typeof window === "undefined") return null - try { - return window.localStorage - } catch { - return null - } -} - -function createAccessToken(): string { +const SECONDARY_CLIENT_STATE: NativeClientStateLoadResult = { isPrimary: false, restoreEnabled: false, snapshot: null } +const accessToken = (() => { const bytes = new Uint8Array(32) globalThis.crypto.getRandomValues(bytes) return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("") +})() +let nativeAccessClaimed = false +const electronApi = () => (window as Window & { electronAPI?: ElectronAPI }).electronAPI +function dispatchNative(electronOperation: (api: ElectronAPI | undefined) => Promise | undefined, command: string, args: Record = {}): Promise | undefined { + if (isElectronHost()) return electronOperation(electronApi()) + if (isTauriHost()) return invoke(command, { accessToken, ...args }) } - -function initializeWebClientState(): NativeClientStateLoadResult { - const storage = getWebStorage() - try { - storage?.removeItem(LEGACY_WEB_SNAPSHOT_STORAGE_KEY) - storage?.removeItem(LEGACY_WEB_RESTORE_ENABLED_STORAGE_KEY) - } catch { - // Web client snapshots are retired; inaccessible storage is already non-persistent. - } - return { isPrimary: false, restoreEnabled: true, snapshot: null } -} - -function electronApi(): ElectronAPI | undefined { - if (typeof window === "undefined") return undefined - return (window as Window & { electronAPI?: ElectronAPI }).electronAPI -} - async function claimNativeClientStateAccess(): Promise { - if (nativeAccessClaimed) return true if (!isLocalWindow()) return false - try { - if (isElectronHost()) { - const claim = electronApi()?.claimClientStateAccess - nativeAccessClaimed = typeof claim === "function" && await claim(accessToken) - return nativeAccessClaimed - } - - if (isTauriHost()) { - await invoke("client_state_claim_access", { accessToken }) - nativeAccessClaimed = true - return true - } + const result = await dispatchNative((api) => api?.claimClientStateAccess?.(accessToken), "client_state_claim_access") + nativeAccessClaimed = isTauriHost() || result === true } catch { nativeAccessClaimed = false } - return false + return nativeAccessClaimed } - export async function loadNativeClientState(): Promise { - if (isElectronHost()) { - const load = electronApi()?.loadClientState - if (typeof load !== "function" || !await claimNativeClientStateAccess()) { - return { isPrimary: false, restoreEnabled: true, snapshot: null } - } - return load(accessToken) + if (isElectronHost() || isTauriHost()) { + if (!await claimNativeClientStateAccess()) return SECONDARY_CLIENT_STATE + return await dispatchNative((api) => api?.loadClientState?.(accessToken), "client_state_load") ?? SECONDARY_CLIENT_STATE } - - if (isTauriHost()) { - if (!await claimNativeClientStateAccess()) { - return { isPrimary: false, restoreEnabled: true, snapshot: null } - } - return invoke("client_state_load", { accessToken }) - } - - return initializeWebClientState() + try { + for (const key of LEGACY_WEB_KEYS) window.localStorage.removeItem(key) + } catch {} + return SECONDARY_CLIENT_STATE } - -export async function saveNativeClientState(snapshot: unknown): Promise { - if (isElectronHost()) { - const save = electronApi()?.saveClientState - return nativeAccessClaimed && typeof save === "function" ? save(accessToken, snapshot) : false - } - - if (isTauriHost()) { - return nativeAccessClaimed ? invoke("client_state_save", { accessToken, snapshot }) : false - } - - return false +async function mutateNativeClientState(electronOperation: (api: ElectronAPI) => Promise | undefined, command: string, args: Record = {}): Promise { + if (!nativeAccessClaimed) return false + return await dispatchNative((api) => api && electronOperation(api), command, args) ?? false } - -export async function setNativeRestoreEnabled(enabled: boolean): Promise { - if (isElectronHost()) { - const setEnabled = electronApi()?.setClientStateRestoreEnabled - return nativeAccessClaimed && typeof setEnabled === "function" ? setEnabled(accessToken, enabled) : false - } - - if (isTauriHost()) { - return nativeAccessClaimed - ? invoke("client_state_set_restore_enabled", { accessToken, enabled }) - : false - } - - return false -} - -export async function clearNativeClientState(): Promise { - if (isElectronHost()) { - const clear = electronApi()?.clearClientState - return nativeAccessClaimed && typeof clear === "function" ? clear(accessToken) : false - } - - if (isTauriHost()) { - return nativeAccessClaimed ? invoke("client_state_clear", { accessToken }) : false - } - - return false -} - -export async function acknowledgeNativeClientStateRendererFlush(): Promise { - if (!isTauriHost() || !nativeAccessClaimed) return - await invoke("client_state_renderer_flushed", { accessToken }) -} - -export async function acknowledgeNativeClientStateNavigationFlush(generation: number): Promise { - if (!isTauriHost() || !nativeAccessClaimed) return - await invoke("client_state_navigation_flushed", { accessToken, generation }) +export const saveNativeClientState = (snapshot: unknown): Promise => + mutateNativeClientState((api) => api.saveClientState?.(accessToken, snapshot), "client_state_save", { snapshot }) +export const setNativeRestoreEnabled = (enabled: boolean): Promise => + mutateNativeClientState((api) => api.setClientStateRestoreEnabled?.(accessToken, enabled), "client_state_set_restore_enabled", { enabled }) +export const clearNativeClientState = (): Promise => + mutateNativeClientState((api) => api.clearClientState?.(accessToken), "client_state_clear") +function acknowledge(command: string, args: Record = {}): Promise { + if (!isTauriHost() || !nativeAccessClaimed) return Promise.resolve() + return invoke(command, { accessToken, ...args }) } +export const acknowledgeNativeClientStateNavigationFlush = (generation: number) => + acknowledge("client_state_navigation_flushed", { generation }) +export const acknowledgeNativeClientStateRendererFlush = (generation: number) => + acknowledge("client_state_renderer_flushed", { generation }) diff --git a/packages/ui/src/lib/retry-utils.ts b/packages/ui/src/lib/retry-utils.ts index d644cbe9..54350105 100644 --- a/packages/ui/src/lib/retry-utils.ts +++ b/packages/ui/src/lib/retry-utils.ts @@ -5,6 +5,7 @@ interface RetryOptions { backoffMultiplier?: number timeoutMs?: number shouldRetry?: (error: Error, attempt: number) => boolean + wait?: (delayMs: number) => Promise } export async function retryWithBackoff( @@ -18,6 +19,7 @@ export async function retryWithBackoff( backoffMultiplier = 2, timeoutMs, shouldRetry = () => true, + wait = (delayMs: number) => new Promise((resolve) => setTimeout(resolve, delayMs)), } = options let lastError: Error | null = null @@ -44,7 +46,7 @@ export async function retryWithBackoff( lastError = err if (attempt < maxAttempts && shouldRetry(err, attempt)) { - await new Promise((resolve) => setTimeout(resolve, delayMs)) + await wait(delayMs) delayMs = Math.min(delayMs * backoffMultiplier, maxDelayMs) } else { throw err diff --git a/packages/ui/src/lib/trailing-resync.test.ts b/packages/ui/src/lib/trailing-resync.test.ts index aea917b0..7e4a5d16 100644 --- a/packages/ui/src/lib/trailing-resync.test.ts +++ b/packages/ui/src/lib/trailing-resync.test.ts @@ -1,79 +1,54 @@ import assert from "node:assert/strict" import { it } from "node:test" - import { TrailingResyncCoordinator, waitForSettledPrerequisite } from "./trailing-resync.ts" - function deferred() { - let resolve!: () => void - let reject!: (error: unknown) => void + let resolve!: () => void, reject!: (error: unknown) => void const promise = new Promise((resolvePromise, rejectPromise) => { - resolve = resolvePromise - reject = rejectPromise + resolve = resolvePromise; reject = rejectPromise }) return { promise, resolve, reject } } - +const turn = () => new Promise((resolve) => setImmediate(resolve)) +const coordinatorFor = (passes: ReturnType[], errors: unknown[] = []) => { + let calls = 0 + return { + coordinator: new TrailingResyncCoordinator(async () => passes[calls++]!.promise, (_key, error) => errors.push(error)), + calls: () => calls, + } +} it("runs a trailing resync when reconnect occurs during an active pass", async () => { const passes = [deferred(), deferred()] - let calls = 0 - const coordinator = new TrailingResyncCoordinator( - async () => passes[calls++]!.promise, - () => undefined, - ) - + const { coordinator, calls } = coordinatorFor(passes) const settled = coordinator.request("workspace-1") - coordinator.request("workspace-1") - await Promise.resolve() - assert.equal(calls, 1) - - passes[0]!.resolve() - await new Promise((resolve) => setImmediate(resolve)) - assert.equal(calls, 2) - passes[1]!.resolve() - await settled + coordinator.request("workspace-1"); await Promise.resolve() + assert.equal(calls(), 1) + passes[0]!.resolve(); await turn() + assert.equal(calls(), 2) + passes[1]!.resolve(); await settled }) - it("retries a queued resync after the active pass fails", async () => { const passes = [deferred(), deferred()] const errors: unknown[] = [] - let calls = 0 - const coordinator = new TrailingResyncCoordinator( - async () => passes[calls++]!.promise, - (_key, error) => errors.push(error), - ) - + const { coordinator, calls } = coordinatorFor(passes, errors) const settled = coordinator.request("workspace-1") - coordinator.request("workspace-1") - await Promise.resolve() - passes[0]!.reject(new Error("transport reset")) - await new Promise((resolve) => setImmediate(resolve)) - assert.equal(calls, 2) - passes[1]!.resolve() - await settled - + coordinator.request("workspace-1"); await Promise.resolve() + passes[0]!.reject(new Error("transport reset")); await turn() + assert.equal(calls(), 2) + passes[1]!.resolve(); await settled assert.equal(errors.length, 1) }) - it("does not lose a request queued as the previous pass settles", async () => { const firstPass = deferred() let calls = 0 - const coordinator = new TrailingResyncCoordinator( - async () => { - calls += 1 - if (calls === 1) await firstPass.promise - }, - () => undefined, - ) - + const coordinator = new TrailingResyncCoordinator(async () => { + calls += 1 + if (calls === 1) await firstPass.promise + }, () => undefined) const first = coordinator.request("workspace-1") const boundaryRequest = firstPass.promise.then(() => coordinator.request("workspace-1")) - firstPass.resolve() - await first - await boundaryRequest - + firstPass.resolve(); await first; await boundaryRequest assert.equal(calls, 2) }) - it("continues recovery after a prerequisite rejects", async () => { await assert.doesNotReject(waitForSettledPrerequisite(Promise.reject(new Error("initial hydration failed")))) }) diff --git a/packages/ui/src/stores/abort-created-workspace-cleanup.test.ts b/packages/ui/src/stores/abort-created-workspace-cleanup.test.ts index e5028edc..b6f7a4d9 100644 --- a/packages/ui/src/stores/abort-created-workspace-cleanup.test.ts +++ b/packages/ui/src/stores/abort-created-workspace-cleanup.test.ts @@ -3,254 +3,248 @@ import { describe, it } from "node:test" import { AbortCreatedWorkspaceCleanup } from "./abort-created-workspace-cleanup.ts" -interface TestWorkspace { - id: string - status: "starting" | "ready" - requestId?: string -} - -function createManualWait() { +interface TestWorkspace { id: string; status: "starting" | "ready"; requestId?: string; reused?: boolean } +const workspace = (id: string, requestId?: string): TestWorkspace => ({ id, status: "ready", requestId }) +async function flushPromises() { await Promise.resolve(); await Promise.resolve() } +function createHarness(options: { failures?: number; pending?: boolean; retryDelay?: number } = {}) { + let discardCalls = 0 + let finishDiscard: (() => void) | undefined + const discarded: TestWorkspace[] = [] const waits: Array<{ delayMs: number; resolve: () => void }> = [] + const restored: TestWorkspace[] = [] + const cleanup = new AbortCreatedWorkspaceCleanup({ + discardWorkspace: async (item) => { + discardCalls += 1 + discarded.push(item) + if (options.pending) await new Promise((resolve) => { finishDiscard = resolve }) + if (discardCalls <= (options.failures ?? 0)) throw new Error("server unavailable") + }, + restoreWorkspace: (value) => restored.push(value), + retryDelaysMs: options.retryDelay === undefined ? [] : [options.retryDelay], + wait: (delayMs) => new Promise((resolve) => waits.push({ delayMs, resolve })), + }) return { - waits, - wait: (delayMs: number) => new Promise((resolve) => waits.push({ delayMs, resolve })), + cleanup, discarded, restored, waits, + get discardCalls() { return discardCalls }, + finishDiscard: () => finishDiscard?.(), } } -async function flushPromises(): Promise { - await Promise.resolve() - await Promise.resolve() -} - describe("abort-created workspace cleanup", () => { - it("retries when the first delete rejects and releases quarantine after a later success", async () => { - const workspace: TestWorkspace = { id: "created", status: "ready" } - const manualWait = createManualWait() - let deleteCalls = 0 - const restored: TestWorkspace[] = [] - const cleanup = new AbortCreatedWorkspaceCleanup({ - deleteWorkspace: async () => { - deleteCalls += 1 - if (deleteCalls === 1) throw new Error("temporary network failure") - }, - restoreWorkspace: (value) => restored.push(value), - retryDelaysMs: [25], - wait: manualWait.wait, - }) + it("late creation cancellation cannot leak a restore-created workspace", async () => { + const item = workspace("late creation", "restore-request") + const harness = createHarness() + harness.cleanup.beginRequest(item.requestId!) + assert.equal(harness.cleanup.trackPendingRequest(item), true) + await harness.cleanup.discardTracked(item.id, { retainTombstone: true }) + harness.cleanup.finishRequest(item.requestId!) + assert.equal(harness.discardCalls, 1) + assert.equal(harness.discarded[0]?.requestId, "restore-request") + assert.deepEqual(harness.restored, []) + assert.equal(harness.cleanup.shouldIgnoreEvent(item.id), true) + }) - cleanup.track(workspace) - const completion = cleanup.discardTracked(workspace.id) + it("does not tombstone a reused workspace after request cancellation", async () => { + const item = { ...workspace("shared workspace", "restore-request"), reused: true }, harness = createHarness() + harness.cleanup.beginRequest(item.requestId!) + harness.cleanup.quarantineRequest(item.requestId!) + assert.equal(harness.cleanup.trackPendingRequest(item), true) await flushPromises() - - assert.equal(deleteCalls, 1) - assert.equal(cleanup.shouldIgnoreEvent(workspace.id), true) - assert.deepEqual(manualWait.waits.map((entry) => entry.delayMs), [25]) - - manualWait.waits[0]?.resolve() - await completion - - assert.equal(deleteCalls, 2) - assert.equal(cleanup.shouldIgnoreEvent(workspace.id), false) - assert.equal(cleanup.owns(workspace.id), false) - assert.deepEqual(restored, []) + assert.equal(harness.discardCalls, 1) + assert.equal(harness.cleanup.shouldIgnoreEvent(item.id), false) }) - it("ignores matching events only while delete cleanup is pending", async () => { - const workspace: TestWorkspace = { id: "created", status: "ready" } - const appliedEvents: string[] = [] - let finishDelete!: () => void - const cleanup = new AbortCreatedWorkspaceCleanup({ - deleteWorkspace: () => new Promise((resolve) => { - finishDelete = resolve - }), - restoreWorkspace: () => undefined, - }) - - const completion = cleanup.discardCreated(workspace) - if (!cleanup.shouldIgnoreEvent(workspace.id)) appliedEvents.push("workspace.started while pending") - assert.equal(cleanup.shouldIgnoreEvent(workspace.id), true) - assert.equal(cleanup.shouldIgnoreEvent("pre-existing"), false) - assert.equal(appliedEvents.length, 0) - - finishDelete() - await completion - - if (!cleanup.shouldIgnoreEvent(workspace.id)) appliedEvents.push("workspace.started after cleanup") - assert.equal(cleanup.shouldIgnoreEvent(workspace.id), false) - assert.deepEqual(appliedEvents, ["workspace.started after cleanup"]) + it("forgets only the matching request for a shared workspace", () => { + const leader = workspace("shared", "leader"), harness = createHarness() + harness.cleanup.track(leader) + harness.cleanup.forgetRequest(leader.id, "follower") + assert.equal(harness.cleanup.get(leader.id)?.requestId, "leader") + harness.cleanup.forgetRequest(leader.id, "leader") + assert.equal(harness.cleanup.get(leader.id), undefined) }) - it("retains cancellation quarantine after delete and ignores delayed events and resolution", async () => { - const workspace: TestWorkspace = { id: "cancelled-restore", status: "ready" } - const appliedEvents: string[] = [] - const cleanup = new AbortCreatedWorkspaceCleanup({ - deleteWorkspace: async () => undefined, - restoreWorkspace: () => undefined, - }) - - cleanup.track(workspace) - await cleanup.discardTracked(workspace.id, { retainTombstone: true }) - - if (!cleanup.shouldIgnoreEvent(workspace.id)) appliedEvents.push("delayed workspace.created") - if (!cleanup.shouldIgnoreEvent(workspace.id)) appliedEvents.push("delayed workspace.started") - cleanup.track(workspace) - if (!cleanup.shouldIgnoreEvent(workspace.id)) appliedEvents.push("late create resolution") - - assert.deepEqual(appliedEvents, []) - assert.equal(cleanup.shouldIgnoreEvent(workspace.id), true) - assert.equal(cleanup.owns(workspace.id), true) + it("keeps a workspace user-owned when release succeeds during cancellation", async () => { + const item = workspace("released-during-cancel", "restore-request"), harness = createHarness() + harness.cleanup.track(item) + let finishRelease!: () => void + const release = harness.cleanup.releaseAfter(item.id, () => new Promise((resolve) => { finishRelease = resolve })) + await harness.cleanup.discardTracked(item.id, { retainTombstone: true }) + finishRelease() + const released = await release + harness.cleanup.track(item) + await harness.cleanup.discardTracked(item.id, { retainTombstone: true }) + assert.equal(released?.id, item.id) + assert.equal(harness.cleanup.owns(item.id), false) + assert.equal(harness.discardCalls, 0) }) - it("clears a durable tombstone only for an explicit user-owned create correlation", async () => { - const workspace: TestWorkspace = { id: "reused-id", status: "ready" } - const cleanup = new AbortCreatedWorkspaceCleanup({ - deleteWorkspace: async () => undefined, - restoreWorkspace: () => undefined, - }) - - await cleanup.discardCreated(workspace, { retainTombstone: true }) - cleanup.track(workspace) - assert.equal(cleanup.shouldIgnoreEvent(workspace.id), true) - - cleanup.release(workspace.id) - assert.equal(cleanup.shouldIgnoreEvent(workspace.id), true) - - cleanup.releaseTombstoneForUserCreate(workspace.id) - assert.equal(cleanup.shouldIgnoreEvent(workspace.id), false) - assert.equal(cleanup.owns(workspace.id), false) - - cleanup.releaseTombstoneForUserCreate("ordinary-user-workspace") - assert.equal(cleanup.shouldIgnoreEvent("ordinary-user-workspace"), false) - assert.equal(cleanup.owns("ordinary-user-workspace"), false) + it("restores cleanup ownership when server release fails", async () => { + const item = workspace("failed-release", "restore-request"), harness = createHarness() + harness.cleanup.track(item) + await assert.rejects(harness.cleanup.releaseAfter(item.id, () => Promise.reject(new Error("release failed")))) + assert.equal(harness.cleanup.owns(item.id), true) + await harness.cleanup.discardTracked(item.id) + assert.equal(harness.discardCalls, 1) }) - it("restores a running workspace and releases quarantine after bounded delete failures", async () => { - const workspace: TestWorkspace = { id: "still-running", status: "ready" } - const manualWait = createManualWait() - const restored: TestWorkspace[] = [] - let deleteCalls = 0 - const cleanup = new AbortCreatedWorkspaceCleanup({ - deleteWorkspace: async () => { - deleteCalls += 1 - throw new Error("server unavailable") - }, - restoreWorkspace: (value) => restored.push(value), - retryDelaysMs: [50], - wait: manualWait.wait, - }) - - const completion = cleanup.discardCreated(workspace) + it("retries rejected cancellation and releases quarantine after success", async () => { + const item = workspace("created"), harness = createHarness({ failures: 1, retryDelay: 25 }) + harness.cleanup.track(item) + const completion = harness.cleanup.discardTracked(item.id) await flushPromises() - assert.equal(cleanup.shouldIgnoreEvent(workspace.id), true) - - manualWait.waits[0]?.resolve() - await completion - - assert.equal(deleteCalls, 2) - assert.deepEqual(restored, [workspace]) - assert.equal(cleanup.shouldIgnoreEvent(workspace.id), false) - assert.equal(cleanup.owns(workspace.id), false) + assert.equal(harness.discardCalls, 1) + assert.equal(harness.cleanup.shouldIgnoreEvent(item.id), true) + assert.deepEqual(harness.waits.map(({ delayMs }) => delayMs), [25]) + harness.waits[0]?.resolve(); await completion + assert.equal(harness.discardCalls, 2) + assert.equal(harness.cleanup.shouldIgnoreEvent(item.id), false) + assert.equal(harness.cleanup.owns(item.id), false) + assert.deepEqual(harness.restored, []) }) - it("never deletes a workspace that was not tracked as restore-created", async () => { - let deleteCalls = 0 - const cleanup = new AbortCreatedWorkspaceCleanup({ - deleteWorkspace: async () => { - deleteCalls += 1 - }, - restoreWorkspace: () => undefined, - }) - - await cleanup.discardTracked("pre-existing") - cleanup.track({ id: "completed-restore", status: "ready" }) - cleanup.release("completed-restore") - await cleanup.discardTracked("completed-restore") - - assert.equal(deleteCalls, 0) + it("ignores matching events only while cancellation is pending", async () => { + const item = workspace("created"), harness = createHarness({ pending: true }) + const completion = harness.cleanup.discardCreated(item) + assert.equal(harness.cleanup.shouldIgnoreEvent(item.id), true) + assert.equal(harness.cleanup.shouldIgnoreEvent("pre-existing"), false) + harness.finishDiscard(); await completion + assert.equal(harness.cleanup.shouldIgnoreEvent(item.id), false) }) - it("transfers a tracked workspace to user ownership before restore cleanup", async () => { - const workspace: TestWorkspace = { id: "selected-during-restore", status: "ready", requestId: "restore-request" } - let deleteCalls = 0 - const cleanup = new AbortCreatedWorkspaceCleanup({ - deleteWorkspace: async () => { - deleteCalls += 1 - }, - restoreWorkspace: () => undefined, - }) - - cleanup.track(workspace) - assert.equal(cleanup.release(workspace.id), workspace) - await cleanup.discardTracked(workspace.id, { retainTombstone: true }) - - assert.equal(cleanup.owns(workspace.id), false) - assert.equal(deleteCalls, 0) + it("retains cancellation quarantine across delayed events and create resolution", async () => { + const item = workspace("cancelled-restore"), harness = createHarness() + harness.cleanup.track(item) + await harness.cleanup.discardTracked(item.id, { retainTombstone: true }) + assert.equal(harness.cleanup.shouldIgnoreEvent(item.id), true, "delayed created/started events stay quarantined") + harness.cleanup.track(item) + assert.equal(harness.cleanup.shouldIgnoreEvent(item.id), true, "late create resolution stays quarantined") + assert.equal(harness.cleanup.owns(item.id), true) }) - it("correlates a created event before create resolves and quarantines explicit close", async () => { - const workspace: TestWorkspace = { - id: "slow-restore", - status: "starting", - requestId: "restore-request", - } - let finishDelete!: () => void - let deleteCalls = 0 - const applied: string[] = [] - const cleanup = new AbortCreatedWorkspaceCleanup({ - deleteWorkspace: () => { - deleteCalls += 1 - return new Promise((resolve) => { - finishDelete = resolve - }) - }, - restoreWorkspace: () => undefined, - }) + it("keeps failed cancellation correlation quarantined until late creation is reconciled", async () => { + const item = workspace("late-after-failed-cancel", "failed-request"), harness = createHarness({ pending: true }) + harness.cleanup.beginRequest("failed-request"); harness.cleanup.quarantineRequest("failed-request") + assert.equal(harness.cleanup.trackPendingRequest(item), true) + assert.equal(harness.cleanup.shouldIgnoreEvent(item.id), true, "late workspace.created is never admitted") + harness.finishDiscard(); await flushPromises() + assert.equal(harness.cleanup.shouldIgnoreEvent(item.id), true, "reconciled creation retains its tombstone") + }) - cleanup.beginRequest("restore-request") - assert.equal(cleanup.trackPendingRequest(workspace), true) - if (!cleanup.shouldIgnoreEvent(workspace.id)) applied.push("workspace.created") + it("adopts an event-before-abort workspace into one bounded cleanup", async () => { + const item = workspace("event-before-abort", "restore-request") + const harness = createHarness({ failures: 1, retryDelay: 25 }) + harness.cleanup.beginRequest(item.requestId!) + assert.equal(harness.cleanup.trackPendingRequest(item), true) - const deletion = cleanup.discardTracked(workspace.id, { retainTombstone: true }) - if (!cleanup.shouldIgnoreEvent(workspace.id)) applied.push("workspace.started") - if (!cleanup.shouldIgnoreEvent(workspace.id)) applied.push("create resolution") - assert.equal(deleteCalls, 1) - assert.deepEqual(applied, ["workspace.created"]) + const cleanup = harness.cleanup.quarantineRequest(item.requestId!) + const duplicate = harness.cleanup.quarantineRequest(item.requestId!) + await flushPromises() + assert.equal(harness.discardCalls, 1, "quarantine does not start a parallel cancellation") + assert.equal(harness.cleanup.shouldIgnoreEvent(item.id), true) + assert.deepEqual(harness.waits.map(({ delayMs }) => delayMs), [25]) - finishDelete() + harness.waits[0]?.resolve() + await Promise.all([cleanup, duplicate]) + assert.equal(harness.discardCalls, 2, "the failed delete is retried once") + assert.equal(harness.cleanup.shouldIgnoreEvent(item.id), true) + }) + + it("clears a durable tombstone only for explicit user-owned create correlation", async () => { + const item = workspace("reused-id"), harness = createHarness() + await harness.cleanup.discardCreated(item, { retainTombstone: true }) + harness.cleanup.track(item); harness.cleanup.release(item.id) + assert.equal(harness.cleanup.shouldIgnoreEvent(item.id), true, "ordinary track/release cannot clear tombstone") + harness.cleanup.releaseTombstoneForUserCreate(item.id) + assert.equal(harness.cleanup.shouldIgnoreEvent(item.id), false) + assert.equal(harness.cleanup.owns(item.id), false) + harness.cleanup.releaseTombstoneForUserCreate("ordinary-user-workspace") + assert.equal(harness.cleanup.owns("ordinary-user-workspace"), false) + }) + + it("restores a running workspace after bounded cancellation failures", async () => { + const item = workspace("still-running"), harness = createHarness({ failures: 2, retryDelay: 50 }) + const completion = harness.cleanup.discardCreated(item) + await flushPromises() + assert.equal(harness.cleanup.shouldIgnoreEvent(item.id), true) + harness.waits[0]?.resolve(); await completion + assert.equal(harness.discardCalls, 2) + assert.deepEqual(harness.restored, [item]) + assert.equal(harness.cleanup.shouldIgnoreEvent(item.id), false) + assert.equal(harness.cleanup.owns(item.id), false) + }) + + it("restores the newest correlated descriptor after cancellation failures", async () => { + const starting = { ...workspace("progressed", "request"), status: "starting" as const } + const ready = { ...starting, status: "ready" as const } + const harness = createHarness({ failures: 2, retryDelay: 50 }) + harness.cleanup.track(starting) + const completion = harness.cleanup.discardTracked(starting.id) + await flushPromises() + harness.cleanup.track(ready) + harness.waits[0]?.resolve(); await completion + assert.equal(harness.discarded[1]?.status, "ready") + assert.deepEqual(harness.restored, [ready]) + }) + + it("never discards untracked or released workspaces", async () => { + const harness = createHarness() + await harness.cleanup.discardTracked("pre-existing") + harness.cleanup.track(workspace("completed-restore")); harness.cleanup.release("completed-restore") + harness.cleanup.track(workspace("completed-restore")) + await harness.cleanup.discardTracked("completed-restore") + assert.equal(harness.discardCalls, 0) + }) + + it("transfers a tracked workspace to user ownership before cleanup", async () => { + const item = workspace("selected-during-restore", "restore-request"), harness = createHarness() + harness.cleanup.track(item) + assert.equal(harness.cleanup.release(item.id), item) + await harness.cleanup.discardTracked(item.id, { retainTombstone: true }) + assert.equal(harness.cleanup.owns(item.id), false) + assert.equal(harness.discardCalls, 0) + }) + + it("does not start a lazy release after explicit close owns cancellation", async () => { + const item = workspace("closed-before-release", "restore-request") + const harness = createHarness({ pending: true }) + harness.cleanup.track(item) + const deletion = harness.cleanup.discardTracked(item.id, { retainTombstone: true }) + let releaseCalls = 0 + + const released = await harness.cleanup.releaseAfter(item.id, async () => { releaseCalls += 1 }) + + assert.equal(released, undefined) + assert.equal(releaseCalls, 0) + assert.equal(harness.cleanup.shouldIgnoreEvent(item.id), true) + harness.finishDiscard() await deletion - cleanup.finishRequest("restore-request") - - if (!cleanup.shouldIgnoreEvent(workspace.id)) applied.push("late workspace.created") - if (!cleanup.shouldIgnoreEvent(workspace.id)) applied.push("late workspace.started") - cleanup.track(workspace) - if (!cleanup.shouldIgnoreEvent(workspace.id)) applied.push("late create resolution") - assert.deepEqual(applied, ["workspace.created"]) - assert.equal(cleanup.owns(workspace.id), true) }) - it("releases explicit-close quarantine only after bounded deletion failure reconciliation", async () => { - const workspace: TestWorkspace = { id: "reconcile", status: "ready", requestId: "request" } - const manualWait = createManualWait() - const restored: TestWorkspace[] = [] - const cleanup = new AbortCreatedWorkspaceCleanup({ - deleteWorkspace: async () => { - throw new Error("server unavailable") - }, - restoreWorkspace: (value) => restored.push(value), - retryDelaysMs: [10], - wait: manualWait.wait, - }) + it("correlates created before resolution and quarantines explicit-close races", async () => { + const item = { ...workspace("slow-restore", "restore-request"), status: "starting" as const } + const harness = createHarness({ pending: true }) + harness.cleanup.beginRequest("restore-request") + assert.equal(harness.cleanup.trackPendingRequest(item), true) + assert.equal(harness.cleanup.shouldIgnoreEvent(item.id), false, "initial correlated created event is accepted") + const deletion = harness.cleanup.discardTracked(item.id, { retainTombstone: true }) + assert.equal(harness.cleanup.shouldIgnoreEvent(item.id), true, "started event and create resolution are quarantined") + assert.equal(harness.discardCalls, 1) + harness.finishDiscard(); await deletion; harness.cleanup.finishRequest("restore-request") + assert.equal(harness.cleanup.shouldIgnoreEvent(item.id), true, "late created/started events stay quarantined") + harness.cleanup.track(item) + assert.equal(harness.cleanup.shouldIgnoreEvent(item.id), true, "late create resolution stays quarantined") + assert.equal(harness.cleanup.owns(item.id), true) + }) - cleanup.beginRequest("request") - cleanup.trackPendingRequest(workspace) - const completion = cleanup.discardTracked(workspace.id, { retainTombstone: true }) + it("releases explicit-close quarantine after failed deletion reconciliation", async () => { + const item = workspace("reconcile", "request"), harness = createHarness({ failures: 2, retryDelay: 10 }) + harness.cleanup.beginRequest("request"); assert.equal(harness.cleanup.trackPendingRequest(item), true) + const completion = harness.cleanup.discardTracked(item.id, { retainTombstone: true }) await flushPromises() - assert.equal(cleanup.shouldIgnoreEvent(workspace.id), true) - - manualWait.waits[0]?.resolve() - await completion - assert.deepEqual(restored, [workspace]) - assert.equal(cleanup.shouldIgnoreEvent(workspace.id), false) - assert.equal(cleanup.owns(workspace.id), false) + assert.equal(harness.cleanup.shouldIgnoreEvent(item.id), true) + harness.waits[0]?.resolve(); await completion + assert.deepEqual(harness.restored, [item]) + assert.equal(harness.cleanup.shouldIgnoreEvent(item.id), false) + assert.equal(harness.cleanup.owns(item.id), false) }) }) diff --git a/packages/ui/src/stores/abort-created-workspace-cleanup.ts b/packages/ui/src/stores/abort-created-workspace-cleanup.ts index f161b469..0e678f4a 100644 --- a/packages/ui/src/stores/abort-created-workspace-cleanup.ts +++ b/packages/ui/src/stores/abort-created-workspace-cleanup.ts @@ -1,17 +1,22 @@ +import { retryWithBackoff } from "../lib/retry-utils" + interface CreatedWorkspace { id: string requestId?: string + reused?: boolean } -interface CleanupEntry { +interface PendingCleanup { workspace: T completion: Promise resolve: () => void retainTombstone: boolean } +type OwnedWorkspace = { workspace: T } | PendingCleanup | { tombstone: true } | { released: true } + interface AbortCreatedWorkspaceCleanupOptions { - deleteWorkspace: (workspaceId: string) => Promise + discardWorkspace: (workspace: T) => Promise restoreWorkspace: (workspace: T) => void retryDelaysMs?: readonly number[] wait?: (delayMs: number) => Promise @@ -20,59 +25,94 @@ interface AbortCreatedWorkspaceCleanupOptions { const DEFAULT_RETRY_DELAYS_MS = [250, 1_000, 2_000] as const -function waitForDelay(delayMs: number): Promise { - return new Promise((resolve) => setTimeout(resolve, delayMs)) -} - export class AbortCreatedWorkspaceCleanup { - private readonly tracked = new Map() - private readonly pending = new Map>() - private readonly pendingRequestIds = new Set() - private readonly tombstones = new Set() - private readonly retryDelaysMs: readonly number[] - private readonly wait: (delayMs: number) => Promise + private readonly owned = new Map>() + private readonly pendingRequestIds = new Map() - constructor(private readonly options: AbortCreatedWorkspaceCleanupOptions) { - this.retryDelaysMs = options.retryDelaysMs ?? DEFAULT_RETRY_DELAYS_MS - this.wait = options.wait ?? waitForDelay - } + constructor(private readonly options: AbortCreatedWorkspaceCleanupOptions) {} track(workspace: T): void { - if (!this.pending.has(workspace.id) && !this.tombstones.has(workspace.id)) { - this.tracked.set(workspace.id, workspace) - } + const entry = this.owned.get(workspace.id) + if (!entry) this.owned.set(workspace.id, { workspace }) + else if ("workspace" in entry && !("released" in entry)) entry.workspace = workspace } beginRequest(requestId: string): void { - this.pendingRequestIds.add(requestId) + this.pendingRequestIds.set(requestId, false) } finishRequest(requestId: string): void { this.pendingRequestIds.delete(requestId) } + forgetRequest(workspaceId: string, requestId: string): void { + this.pendingRequestIds.delete(requestId) + const entry = this.owned.get(workspaceId) + if (entry && "workspace" in entry && !("completion" in entry) && entry.workspace.requestId === requestId) { + this.owned.delete(workspaceId) + } + } + + quarantineRequest(requestId: string): Promise | undefined { + if (!this.pendingRequestIds.has(requestId)) return undefined + this.pendingRequestIds.set(requestId, true) + for (const [workspaceId, entry] of this.owned) { + if (!("workspace" in entry) || entry.workspace.requestId !== requestId) continue + const cleanup = this.discardTracked(workspaceId, { retainTombstone: entry.workspace.reused !== true }) + void cleanup.finally(() => this.finishRequest(requestId)) + return cleanup + } + return undefined + } + trackPendingRequest(workspace: T): boolean { - if (!workspace.requestId || !this.pendingRequestIds.has(workspace.requestId)) return false + if (!workspace.requestId) return false + const quarantined = this.pendingRequestIds.get(workspace.requestId) + if (quarantined === undefined) return false this.track(workspace) + if (quarantined) void this.discardTracked(workspace.id, { retainTombstone: workspace.reused !== true }) + .finally(() => this.finishRequest(workspace.requestId!)) return true } release(workspaceId: string): T | undefined { - const workspace = this.tracked.get(workspaceId) - this.tracked.delete(workspaceId) - return workspace + const entry = this.owned.get(workspaceId) + if (!entry || "tombstone" in entry || "released" in entry || "completion" in entry) return undefined + this.owned.set(workspaceId, { released: true }) + return entry.workspace + } + + get(workspaceId: string): T | undefined { + const entry = this.owned.get(workspaceId) + return entry && "workspace" in entry && !("completion" in entry) ? entry.workspace : undefined + } + + async releaseAfter(workspaceId: string, operation: (workspace: T) => Promise): Promise { + const workspace = this.release(workspaceId) + if (!workspace) return undefined + try { + await operation(workspace) + return workspace + } catch (error) { + const entry = this.owned.get(workspaceId) + if (workspace && entry && "released" in entry) this.owned.set(workspaceId, { workspace }) + throw error + } } releaseTombstoneForUserCreate(workspaceId: string): void { - this.tombstones.delete(workspaceId) + const entry = this.owned.get(workspaceId) + if (entry && ("tombstone" in entry || "released" in entry)) this.owned.delete(workspaceId) } owns(workspaceId: string): boolean { - return this.tracked.has(workspaceId) || this.pending.has(workspaceId) || this.tombstones.has(workspaceId) + const entry = this.owned.get(workspaceId) + return Boolean(entry && !("released" in entry)) } shouldIgnoreEvent(workspaceId: string): boolean { - return this.pending.has(workspaceId) || this.tombstones.has(workspaceId) + const entry = this.owned.get(workspaceId) + return !!entry && ("completion" in entry || "tombstone" in entry) } discardCreated(workspace: T, options?: { retainTombstone?: boolean }): Promise { @@ -80,77 +120,60 @@ export class AbortCreatedWorkspaceCleanup { } discardTracked(workspaceId: string, options?: { retainTombstone?: boolean }): Promise { - const pending = this.pending.get(workspaceId) - if (pending) return pending.completion - if (this.tombstones.has(workspaceId)) return Promise.resolve() - - const workspace = this.tracked.get(workspaceId) - if (!workspace) return Promise.resolve() - return this.start(workspace, options) + const entry = this.owned.get(workspaceId) + if (!entry || "tombstone" in entry || "released" in entry) return Promise.resolve() + if ("completion" in entry) return entry.completion + return this.start(entry.workspace, options) } private start(workspace: T, options?: { retainTombstone?: boolean }): Promise { - const pending = this.pending.get(workspace.id) - if (pending) return pending.completion - if (this.tombstones.has(workspace.id)) return Promise.resolve() + const current = this.owned.get(workspace.id) + if (current && "completion" in current) return current.completion + if (current && ("tombstone" in current || "released" in current)) return Promise.resolve() let resolve!: () => void - const completion = new Promise((done) => { - resolve = done - }) + const completion = new Promise((done) => { resolve = done }) const entry = { workspace, completion, resolve, retainTombstone: options?.retainTombstone === true } - this.tracked.delete(workspace.id) - this.pending.set(workspace.id, entry) + this.owned.set(workspace.id, entry) void this.run(entry) return completion } - private async run(entry: CleanupEntry): Promise { + private async run(entry: PendingCleanup): Promise { let lastError: unknown + let deleted = false + const retryDelays = this.options.retryDelaysMs ?? DEFAULT_RETRY_DELAYS_MS + let retryIndex = 0 - for (let attempt = 0; ; attempt += 1) { - try { - await this.options.deleteWorkspace(entry.workspace.id) - this.finish(entry, true) - return - } catch (error) { - lastError = error - } - - const delayMs = this.retryDelaysMs[attempt] - if (delayMs === undefined) break - - try { - await this.wait(delayMs) - } catch (error) { - lastError = error - break - } - if (this.pending.get(entry.workspace.id) !== entry) return - } - - if (this.pending.get(entry.workspace.id) !== entry) return try { - this.options.restoreWorkspace(entry.workspace) + await retryWithBackoff(() => this.options.discardWorkspace(entry.workspace), { + maxAttempts: retryDelays.length + 1, + wait: () => (this.options.wait ?? ((delay) => new Promise((resolve) => setTimeout(resolve, delay))))( + retryDelays[retryIndex++] ?? 0, + ), + }) + deleted = true } catch (error) { lastError = error } - this.finish(entry, false) + + if (this.owned.get(entry.workspace.id) !== entry) return + if (!deleted) { + try { + this.options.restoreWorkspace(entry.workspace) + } catch (error) { + lastError = error + } + } + if (deleted && entry.retainTombstone) this.owned.set(entry.workspace.id, { tombstone: true }) + else this.owned.delete(entry.workspace.id) + entry.resolve() + + if (deleted) return try { this.options.onPermanentFailure?.(entry.workspace, lastError) } catch { // Cleanup has already reached its safe terminal state. } } - - private finish(entry: CleanupEntry, deleted: boolean): void { - if (this.pending.get(entry.workspace.id) !== entry) return - this.pending.delete(entry.workspace.id) - if (deleted && entry.retainTombstone) { - this.tombstones.add(entry.workspace.id) - } else { - this.tombstones.delete(entry.workspace.id) - } - entry.resolve() - } } diff --git a/packages/ui/src/stores/abortable-restore-creation.test.ts b/packages/ui/src/stores/abortable-restore-creation.test.ts deleted file mode 100644 index 917dd73e..00000000 --- a/packages/ui/src/stores/abortable-restore-creation.test.ts +++ /dev/null @@ -1,110 +0,0 @@ -import assert from "node:assert/strict" -import { describe, it } from "node:test" - -import { - completeAbortableRestoreCreation, - completeAbortableRestoreHydration, -} from "./abortable-restore-creation.ts" -import { - createRestorableSessionPreservation, - mapRestoredWorkspace, - mergeRestorableSessionState, - unmapRestoredWorkspace, -} from "./app-session-snapshot-merge.ts" - -describe("abortable restore creation", () => { - it("discards a workspace that completes after cancellation without committing it", async () => { - let finish: ((value: { id: string }) => void) | undefined - const creation = new Promise<{ id: string }>((resolve) => { - finish = resolve - }) - const controller = new AbortController() - let committed = false - const discarded: string[] = [] - const result = completeAbortableRestoreCreation(creation, { - signal: controller.signal, - commit: () => { - committed = true - }, - discard: async (workspace) => { - discarded.push(workspace.id) - }, - }) - - controller.abort(new Error("restore timed out")) - finish?.({ id: "late-workspace" }) - await assert.rejects(result, /restore timed out/) - assert.equal(committed, false) - assert.deepEqual(discarded, ["late-workspace"]) - }) - - it("disposes a committed restore workspace when hydration is aborted before state application", async () => { - const controller = new AbortController() - const disposed: string[] = [] - let creationCommitted = false - let preservation = createRestorableSessionPreservation({ - tabs: [{ - kind: "workspace", - folder: "/restore", - occurrence: 0, - drafts: { missing: "preserve through cancellation" }, - attachments: {}, - scrollSnapshots: {}, - unseenIdleSince: {}, - generationRecovery: {}, - }], - activeTabIndex: 0, - }) - const created = await completeAbortableRestoreCreation(Promise.resolve({ id: "restore-workspace" }), { - signal: controller.signal, - commit: (workspace) => { - creationCommitted = true - preservation = mapRestoredWorkspace(preservation, 0, `instance:${workspace.id}`) - }, - discard: async (workspace) => { - preservation = unmapRestoredWorkspace(preservation, `instance:${workspace.id}`) - disposed.push(workspace.id) - }, - }) - assert.equal(creationCommitted, true) - assert.equal(preservation.restoredWorkspaceSourceIndexes.get("instance:restore-workspace"), 0) - - let finishHydration: (() => void) | undefined - const hydration = new Promise((resolve) => { - finishHydration = resolve - }) - let restoredTabId: string | null = null - let restoredStateApplied = false - - const completion = completeAbortableRestoreHydration(created, { - signal: controller.signal, - hydrate: () => hydration, - commit: (workspace) => { - restoredTabId = `instance:${workspace.id}` - restoredStateApplied = true - }, - discard: async (workspace) => { - preservation = unmapRestoredWorkspace(preservation, `instance:${workspace.id}`) - disposed.push(workspace.id) - }, - }) - - controller.abort(new Error("restore timed out during hydration")) - await assert.rejects(completion, /restore timed out during hydration/) - assert.deepEqual(disposed, ["restore-workspace"]) - assert.equal(restoredTabId, null) - assert.equal(restoredStateApplied, false) - assert.equal(preservation.restoredWorkspaceSourceIndexes.size, 0) - const cancelled = mergeRestorableSessionState({ tabs: [], activeTabIndex: -1 }, preservation) - assert.equal(cancelled.tabs.length, 1) - assert.equal( - cancelled.tabs[0]?.kind === "workspace" ? cancelled.tabs[0].drafts.missing : undefined, - "preserve through cancellation", - ) - - finishHydration?.() - await new Promise((resolve) => setTimeout(resolve, 0)) - assert.equal(restoredTabId, null) - assert.equal(restoredStateApplied, false) - }) -}) diff --git a/packages/ui/src/stores/abortable-restore-creation.ts b/packages/ui/src/stores/abortable-restore-creation.ts deleted file mode 100644 index 31d988a0..00000000 --- a/packages/ui/src/stores/abortable-restore-creation.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { awaitRestoreStep, getAbortReason } from "./app-session-restore-timeout" - -export async function completeAbortableRestoreCreation( - creation: Promise, - options: { - signal?: AbortSignal - commit: (value: T) => void | Promise - discard: (value: T) => Promise - }, -): Promise { - const value = await creation - if (options.signal?.aborted) { - await options.discard(value) - throw getAbortReason(options.signal) - } - options.commit(value) - return value -} - -export async function completeAbortableRestoreHydration( - value: T, - options: { - signal: AbortSignal - hydrate: (value: T) => Promise - commit: (value: T) => void - discard?: (value: T) => Promise - }, -): Promise { - try { - if (options.signal.aborted) throw getAbortReason(options.signal) - await awaitRestoreStep(options.hydrate(value), options.signal) - if (options.signal.aborted) throw getAbortReason(options.signal) - await options.commit(value) - return value - } catch (error) { - if (options.signal.aborted) await options.discard?.(value) - throw error - } -} diff --git a/packages/ui/src/stores/app-session-prompt-hydration.test.ts b/packages/ui/src/stores/app-session-prompt-hydration.test.ts index 7b66be8c..eb8cfbd3 100644 --- a/packages/ui/src/stores/app-session-prompt-hydration.test.ts +++ b/packages/ui/src/stores/app-session-prompt-hydration.test.ts @@ -2,23 +2,10 @@ import assert from "node:assert/strict" import { describe, it } from "node:test" import { createRoot } from "solid-js" import { isServer } from "solid-js/web" - const NO_SESSION_DRAFT_SESSION_ID = "__no_session_draft__" - describe("app session prompt hydration", () => { it("keeps pasted and image attachments when hydrating a mounted no-session prompt", { skip: isServer }, async () => { - const [ - { usePromptAttachments }, - { usePromptState }, - { clearInstanceAttachments, getAttachments }, - { hydrateWorkspacePromptState }, - { - clearInstanceDraftPrompts, - clearSessionDraftPrompt, - getAuthoritativeDraftSessionIdsForInstance, - getSessionDraftPrompt, - }, - ] = await Promise.all([ + const [attachmentsHook, promptHook, attachments, hydration, sessions] = await Promise.all([ import("../components/prompt-input/usePromptAttachments.ts"), import("../components/prompt-input/usePromptState.ts"), import("./attachments.ts"), @@ -26,18 +13,15 @@ describe("app session prompt hydration", () => { import("./sessions.ts"), ]) const instanceId = "mounted-no-session-instance" - let dispose = () => {} - let prompt = () => "" - + let dispose = () => {}, prompt = () => "" createRoot((rootDispose) => { dispose = rootDispose - const promptState = usePromptState({ + const promptState = promptHook.usePromptState({ instanceId: () => instanceId, sessionId: () => NO_SESSION_DRAFT_SESSION_ID, instanceFolder: () => "", }) - prompt = promptState.prompt - usePromptAttachments({ + prompt = promptState.prompt; attachmentsHook.usePromptAttachments({ instanceId: () => instanceId, sessionId: () => NO_SESSION_DRAFT_SESSION_ID, instanceFolder: () => "/work", @@ -46,49 +30,26 @@ describe("app session prompt hydration", () => { getTextarea: () => null, }) }) - try { - hydrateWorkspacePromptState(instanceId, { + hydration.hydrateWorkspacePromptState(instanceId, { drafts: { [NO_SESSION_DRAFT_SESSION_ID]: "before [pasted #1] and [Image #1] after", "ordinary-session": "ordinary draft", }, - attachments: { - [NO_SESSION_DRAFT_SESSION_ID]: [{ - id: "paste-1", - type: "text", - display: "pasted #1 (4 lines)", - url: "", - filename: "paste-1.txt", - mediaType: "text/plain", - source: { type: "text", value: "restored pasted text" }, - }, { - id: "image-1", - type: "file", - display: "[Image #1]", - url: "", - filename: "image-1.png", - mediaType: "image/png", - source: { type: "file", path: "/work/image-1.png", mime: "image/png" }, - }], - }, + attachments: { [NO_SESSION_DRAFT_SESSION_ID]: [ + { id: "paste-1", type: "text", display: "pasted #1 (4 lines)", url: "", filename: "paste-1.txt", mediaType: "text/plain", source: { type: "text", value: "restored pasted text" } }, + { id: "image-1", type: "file", display: "[Image #1]", url: "", filename: "image-1.png", mediaType: "image/png", source: { type: "file", path: "/work/image-1.png", mime: "image/png" } }, + ] }, }, new Set(["ordinary-session"]), NO_SESSION_DRAFT_SESSION_ID) - assert.equal(prompt(), "before [pasted #1] and [Image #1] after") - assert.deepEqual( - getAttachments(instanceId, NO_SESSION_DRAFT_SESSION_ID).map((attachment) => attachment.id), - ["paste-1", "image-1"], - ) - assert.equal(getSessionDraftPrompt(instanceId, "ordinary-session"), "ordinary draft") - assert.equal(getAuthoritativeDraftSessionIdsForInstance(instanceId).has("ordinary-session"), false) - - clearSessionDraftPrompt(instanceId, "ordinary-session") - assert.equal(getSessionDraftPrompt(instanceId, "ordinary-session"), "") - assert.equal(getAuthoritativeDraftSessionIdsForInstance(instanceId).has("ordinary-session"), true) + assert.deepEqual(attachments.getAttachments(instanceId, NO_SESSION_DRAFT_SESSION_ID).map(({ id }) => id), ["paste-1", "image-1"]) + assert.equal(sessions.getSessionDraftPrompt(instanceId, "ordinary-session"), "ordinary draft") + assert.equal(sessions.getAuthoritativeDraftSessionIdsForInstance(instanceId).has("ordinary-session"), false) + sessions.clearSessionDraftPrompt(instanceId, "ordinary-session") + assert.equal(sessions.getSessionDraftPrompt(instanceId, "ordinary-session"), "") + assert.equal(sessions.getAuthoritativeDraftSessionIdsForInstance(instanceId).has("ordinary-session"), true) } finally { - dispose() - clearInstanceAttachments(instanceId) - clearInstanceDraftPrompts(instanceId) + dispose(); attachments.clearInstanceAttachments(instanceId); sessions.clearInstanceDraftPrompts(instanceId) } }) }) diff --git a/packages/ui/src/stores/app-session-reconciliation.test.ts b/packages/ui/src/stores/app-session-reconciliation.test.ts index 52471193..374630a3 100644 --- a/packages/ui/src/stores/app-session-reconciliation.test.ts +++ b/packages/ui/src/stores/app-session-reconciliation.test.ts @@ -1,151 +1,71 @@ import assert from "node:assert/strict" import { describe, it } from "node:test" - -import { - areRestoredSessionReferencesAvailable, - getUnavailableRestoredSessionIds, - reconcileWorkspaceTabs, - resolveRestoredActiveTabId, - resolveRestoredSessionSelection, - shouldEnableSessionCapture, - shouldRestoreSessionState, -} from "./app-session-reconciliation.ts" - +import { getUnavailableRestoredSessionIds, getUnavailableWorkspaceIds, reconcileWorkspaceTabs, resolveRestoredActiveTabId, resolveRestoredSessionSelection, shouldRestoreSessionState } from "./app-session-reconciliation.ts" describe("app session reconciliation", () => { - it("matches duplicate workspace folders by normalized path occurrence", () => { - const matches = reconcileWorkspaceTabs( - [ - { kind: "workspace", folderPath: String.raw`C:\Code\Nomad`, occurrence: 1 }, - { kind: "workspace", folderPath: "c:/code/nomad/", occurrence: 0 }, - ], - [ - { id: "first", folderPath: "C:/CODE/NOMAD" }, - { id: "second", folderPath: "c:\\code\\nomad\\" }, - ], - ) + const workspaceCases = [ + ["matches duplicate workspace folders by normalized path occurrence", [ + { kind: "workspace" as const, folderPath: String.raw`C:\Code\Nomad`, occurrence: 1 }, { kind: "workspace" as const, folderPath: "c:/code/nomad/", occurrence: 0 }, + ], [{ id: "first", folderPath: "C:/CODE/NOMAD" }, { id: "second", folderPath: "c:\\code\\nomad\\" }], ["second", "first"]], + ["derives occurrences for snapshots written before occurrence was explicit", [ + { kind: "workspace" as const, folderPath: "/code/nomad" }, { kind: "workspace" as const, folderPath: "/code/nomad/" }, + ], [{ id: "first", folderPath: "/code/nomad" }, { id: "second", folderPath: "/code/nomad" }], ["first", "second"]], + ["does not match one live workspace to duplicate descriptors", [ + { kind: "workspace" as const, folderPath: "/code/nomad", occurrence: 0 }, { kind: "workspace" as const, folderPath: "/code/nomad", occurrence: 0 }, + ], [{ id: "only", folderPath: "/code/nomad" }], ["only", null]], + ] as const + for (const [label, saved, live, expected] of workspaceCases) it(label, () => assert.deepEqual( + reconcileWorkspaceTabs([...saved], [...live]).map(({ existingWorkspaceId }) => existingWorkspaceId), expected, + )) + it("keeps saved order while a restored workspace is still starting", () => { + const saved = [ + { kind: "workspace", folderPath: "D:/DreamX-World" }, + { kind: "workspace", folderPath: "D:/CodeNomad" }, + { kind: "workspace", folderPath: "D:/stale" }, + ] + const live = [ + { id: "codenomad", folderPath: "D:/CodeNomad", status: "ready" }, + { id: "dreamx", folderPath: "D:/DreamX-World", status: "starting" }, + { id: "stale", folderPath: "D:/stale", status: "stopped" }, + ] as const - assert.deepEqual(matches.map((match) => match.existingWorkspaceId), ["second", "first"]) - }) - - it("derives occurrences for snapshots written before occurrence was explicit", () => { - const matches = reconcileWorkspaceTabs( - [ - { kind: "workspace", folderPath: "/code/nomad" }, - { kind: "workspace", folderPath: "/code/nomad/" }, - ], - [ - { id: "first", folderPath: "/code/nomad" }, - { id: "second", folderPath: "/code/nomad" }, - ], - ) - - assert.deepEqual(matches.map((match) => match.existingWorkspaceId), ["first", "second"]) - }) - - it("does not match one live workspace to duplicate descriptors", () => { - const matches = reconcileWorkspaceTabs( - [ - { kind: "workspace", folderPath: "/code/nomad", occurrence: 0 }, - { kind: "workspace", folderPath: "/code/nomad", occurrence: 0 }, - ], - [{ id: "only", folderPath: "/code/nomad" }], - ) - - assert.deepEqual(matches.map((match) => match.existingWorkspaceId), ["only", null]) - }) - - it("falls back to a valid parent when the active session is stale", () => { assert.deepEqual( - resolveRestoredSessionSelection( - [ - { id: "parent", parentId: null }, - { id: "child", parentId: "parent" }, - ], - "parent", - "deleted-child", - ), - { parentSessionId: "parent", activeSessionId: "parent" }, + reconcileWorkspaceTabs(saved, live).map(({ existingWorkspaceId }) => existingWorkspaceId), + ["dreamx", "codenomad", null], ) }) - - it("restores a grandchild under its root session", () => { + it("reconciles only workspaces missing from a refresh and not owned by restore cleanup", () => { assert.deepEqual( - resolveRestoredSessionSelection( - [ - { id: "root", parentId: null }, - { id: "child", parentId: "root" }, - { id: "grandchild", parentId: "child" }, - ], - "root", - "grandchild", - ), - { parentSessionId: "root", activeSessionId: "grandchild" }, + getUnavailableWorkspaceIds(["present", "stopped", "cancelling"], new Set(["present"]), (id) => id === "cancelling"), + ["stopped"], ) }) - + const selectionCases = [ + ["falls back to a valid parent when the active session is stale", [{ id: "parent", parentId: null }, { id: "child", parentId: "parent" }], "parent", "deleted-child", { parentSessionId: "parent", activeSessionId: "parent" }], + ["restores a grandchild under its root session", [{ id: "root", parentId: null }, { id: "child", parentId: "root" }, { id: "grandchild", parentId: "child" }], "root", "grandchild", { parentSessionId: "root", activeSessionId: "grandchild" }], + ["keeps the special info selection without requiring a session", [], null, "info", { parentSessionId: null, activeSessionId: "info" }], + ] as const + for (const [label, sessions, parent, active, expected] of selectionCases) it(label, () => assert.deepEqual( + resolveRestoredSessionSelection([...sessions], parent, active), expected, + )) it("rejects ancestry with a cycle or missing parent", () => { - assert.equal( - resolveRestoredSessionSelection([ - { id: "first", parentId: "second" }, - { id: "second", parentId: "first" }, - ], null, "first"), - null, - ) - assert.equal( - resolveRestoredSessionSelection([{ id: "orphan", parentId: "missing" }], null, "orphan"), - null, - ) + assert.equal(resolveRestoredSessionSelection([{ id: "first", parentId: "second" }, { id: "second", parentId: "first" }], null, "first"), null) + assert.equal(resolveRestoredSessionSelection([{ id: "orphan", parentId: "missing" }], null, "orphan"), null) }) - it("treats missing saved session references as unsafe", () => { const sessions = [{ id: "loaded", parentId: null }] - assert.equal(areRestoredSessionReferencesAvailable(sessions, { - activeParentSessionId: "loaded", - activeSessionId: "info", - draftSessionIds: ["loaded", "__no_session_draft__"], - attachmentSessionIds: ["loaded"], - scrollSessionIds: ["loaded"], - }, ["__no_session_draft__"]), true) - assert.equal(areRestoredSessionReferencesAvailable(sessions, { - activeParentSessionId: "missing-parent", - activeSessionId: "missing-active", - draftSessionIds: ["missing-draft"], - attachmentSessionIds: ["missing-attachment"], - scrollSessionIds: ["missing-scroll"], - }), false) - assert.deepEqual( - [...getUnavailableRestoredSessionIds(sessions, { - activeSessionId: "missing-active", - draftSessionIds: ["missing-draft"], - attachmentSessionIds: ["missing-attachment"], - scrollSessionIds: ["loaded"], - })], - ["missing-active", "missing-draft", "missing-attachment"], - ) - }) - - it("keeps the special info selection without requiring a session", () => { - assert.deepEqual(resolveRestoredSessionSelection([], null, "info"), { - parentSessionId: null, - activeSessionId: "info", - }) + const unavailable = (state: Parameters[1], allowed: string[] = []) => [...getUnavailableRestoredSessionIds(sessions, state, allowed)] + assert.deepEqual(unavailable({ activeParentSessionId: "loaded", activeSessionId: "info", draftSessionIds: ["loaded", "__no_session_draft__"], attachmentSessionIds: ["loaded"], scrollSessionIds: ["loaded"] }, ["__no_session_draft__"]), []) + assert.notEqual(unavailable({ activeParentSessionId: "missing-parent", activeSessionId: "missing-active", draftSessionIds: ["missing-draft"], attachmentSessionIds: ["missing-attachment"], scrollSessionIds: ["missing-scroll"] }).length, 0) + assert.deepEqual(unavailable({ activeSessionId: "missing-active", draftSessionIds: ["missing-draft"], attachmentSessionIds: ["missing-attachment"], scrollSessionIds: ["loaded"] }), ["missing-active", "missing-draft", "missing-attachment"]) }) it("falls back to the first restored tab when the active SideCar failed", () => { assert.equal(resolveRestoredActiveTabId(["instance:workspace", null, "sidecar:other"], 1), "instance:workspace") }) - it("does not restore for secondary or disabled clients", () => { const snapshot = { tabs: [] } - assert.equal(shouldRestoreSessionState(false, true, snapshot), false) - assert.equal(shouldRestoreSessionState(true, false, snapshot), false) - assert.equal(shouldRestoreSessionState(true, true, null), false) - assert.equal(shouldRestoreSessionState(true, true, snapshot), true) - }) - - it("keeps capture enabled after partial or timed-out restoration", () => { - assert.equal(shouldEnableSessionCapture(true, false), true) - assert.equal(shouldEnableSessionCapture(true, true), true) - assert.equal(shouldEnableSessionCapture(false, false), true) + for (const [primary, enabled, state, expected] of [[false, true, snapshot, false], [true, false, snapshot, false], [true, true, null, false], [true, true, snapshot, true]] as const) { + assert.equal(shouldRestoreSessionState(primary, enabled, state), expected) + } }) }) diff --git a/packages/ui/src/stores/app-session-reconciliation.ts b/packages/ui/src/stores/app-session-reconciliation.ts index 5871d678..51d641cc 100644 --- a/packages/ui/src/stores/app-session-reconciliation.ts +++ b/packages/ui/src/stores/app-session-reconciliation.ts @@ -1,36 +1,6 @@ -export interface WorkspaceTabDescriptor { - kind: "workspace" - folderPath: string - occurrence: number -} - -export interface LiveWorkspaceDescriptor { - id: string - folderPath: string -} - -export interface WorkspaceTabMatch { - tabIndex: number - descriptor: WorkspaceTabDescriptor - existingWorkspaceId: string | null -} - -export interface ReconcileTabDescriptor { - kind: string - folderPath?: string - occurrence?: number -} - -export interface SessionDescriptor { - id: string - parentId?: string | null -} - -export interface RestoredSessionSelection { - parentSessionId: string | null - activeSessionId: string -} - +export type ReconcileTabDescriptor = { kind: string; folderPath?: string; occurrence?: number } +type LiveWorkspaceDescriptor = { id: string; folderPath: string; status?: string } +export type SessionDescriptor = { id: string; parentId?: string | null } export interface RestoredSessionReferences { activeParentSessionId?: string activeSessionId?: string @@ -40,121 +10,79 @@ export interface RestoredSessionReferences { idleMarkerSessionIds?: readonly string[] generationRecoverySessionIds?: readonly string[] } - -function normalizeWorkspacePath(folderPath: string): string { +export function normalizeWorkspacePath(folderPath: string): string { const windowsLike = /^(?:[A-Za-z]:[/\\]|[/\\]{2})/.test(folderPath) - const slashNormalized = windowsLike ? folderPath.replace(/\\/g, "/").toLowerCase() : folderPath - if (slashNormalized === "/" || /^[a-z]:\/$/.test(slashNormalized)) return slashNormalized - return slashNormalized.replace(/\/+$/, "") + const normalized = windowsLike ? folderPath.replace(/\\/g, "/").toLowerCase() : folderPath + return normalized === "/" || /^[a-z]:\/$/.test(normalized) ? normalized : normalized.replace(/\/+$/, "") } - -function reconcileWorkspaceTabs( +export function getUnavailableWorkspaceIds( + localIds: Iterable, + remoteIds: ReadonlySet, + isProtected: (id: string) => boolean, +): string[] { + return [...localIds].filter((id) => !remoteIds.has(id) && !isProtected(id)) +} +export function reconcileWorkspaceTabs( tabs: readonly ReconcileTabDescriptor[], liveWorkspaces: readonly LiveWorkspaceDescriptor[], -): WorkspaceTabMatch[] { +) { const liveByPath = new Map() for (const workspace of liveWorkspaces) { - const key = normalizeWorkspacePath(workspace.folderPath) - const matches = liveByPath.get(key) - if (matches) { - matches.push(workspace) - } else { - liveByPath.set(key, [workspace]) - } + if (workspace.status === "stopped" || workspace.status === "error") continue + const path = normalizeWorkspacePath(workspace.folderPath) + liveByPath.set(path, [...(liveByPath.get(path) ?? []), workspace]) } - - const result: WorkspaceTabMatch[] = [] - const inferredOccurrences = new Map() - const claimedWorkspaceIds = new Set() - tabs.forEach((tab, tabIndex) => { - if (tab.kind !== "workspace" || typeof tab.folderPath !== "string") return - const normalizedPath = normalizeWorkspacePath(tab.folderPath) - const inferredOccurrence = inferredOccurrences.get(normalizedPath) ?? 0 - const occurrence = Number.isInteger(tab.occurrence) && Number(tab.occurrence) >= 0 - ? Number(tab.occurrence) - : inferredOccurrence - inferredOccurrences.set(normalizedPath, Math.max(inferredOccurrence, occurrence) + 1) - const descriptor: WorkspaceTabDescriptor = { - kind: "workspace", - folderPath: tab.folderPath, - occurrence, - } - const matches = liveByPath.get(normalizedPath) ?? [] - const existingWorkspace = matches[occurrence] - const existingWorkspaceId = existingWorkspace && !claimedWorkspaceIds.has(existingWorkspace.id) - ? existingWorkspace.id - : null - if (existingWorkspaceId) claimedWorkspaceIds.add(existingWorkspaceId) - result.push({ + const nextOccurrences = new Map() + const claimed = new Set() + return tabs.flatMap((tab, tabIndex) => { + if (tab.kind !== "workspace" || typeof tab.folderPath !== "string") return [] + const path = normalizeWorkspacePath(tab.folderPath) + const inferred = nextOccurrences.get(path) ?? 0 + const occurrence = Number.isInteger(tab.occurrence) && Number(tab.occurrence) >= 0 ? Number(tab.occurrence) : inferred + nextOccurrences.set(path, Math.max(inferred, occurrence) + 1) + const workspace = liveByPath.get(path)?.[occurrence] + const existingWorkspaceId = workspace && !claimed.has(workspace.id) ? workspace.id : null + if (existingWorkspaceId) claimed.add(existingWorkspaceId) + return [{ tabIndex, - descriptor, + descriptor: { kind: "workspace" as const, folderPath: tab.folderPath, occurrence }, existingWorkspaceId, - }) + }] }) - return result } - -function resolveRestoredSessionSelection( +export function resolveRestoredSessionSelection( availableSessions: readonly SessionDescriptor[], requestedParentSessionId: string | null | undefined, requestedActiveSessionId: string | null | undefined, -): RestoredSessionSelection | null { - const sessionsById = new Map(availableSessions.map((session) => [session.id, session])) - - const resolveRootId = (sessionId: string | null | undefined): string | null => { - if (!sessionId) return null - let current = sessionsById.get(sessionId) - if (!current) return null - +) { + const sessions = new Map(availableSessions.map((session) => [session.id, session])) + const rootId = (sessionId: string | null | undefined): string | null => { + let current = sessionId ? sessions.get(sessionId) : undefined const seen = new Set() - while (current.parentId) { + while (current?.parentId) { if (seen.has(current.id)) return null seen.add(current.id) - const parent = sessionsById.get(current.parentId) - if (!parent) return null - current = parent + current = sessions.get(current.parentId) } - return current.id + return current?.id ?? null } - - const validRequestedParent = resolveRootId(requestedParentSessionId) - - if (requestedActiveSessionId === "info") { - return { parentSessionId: validRequestedParent, activeSessionId: "info" } + const parentSessionId = rootId(requestedParentSessionId) + if (requestedActiveSessionId === "info") return { parentSessionId, activeSessionId: "info" } + const active = requestedActiveSessionId ? sessions.get(requestedActiveSessionId) : undefined + const activeParentId = rootId(active?.id) + if (active && activeParentId && (!parentSessionId || parentSessionId === activeParentId)) { + return { parentSessionId: activeParentId, activeSessionId: active.id } } - - const requestedActive = requestedActiveSessionId ? sessionsById.get(requestedActiveSessionId) : undefined - if (requestedActive) { - const validActiveParentId = resolveRootId(requestedActive.id) - if (validActiveParentId) { - if (!validRequestedParent || validRequestedParent === validActiveParentId) { - return { parentSessionId: validActiveParentId, activeSessionId: requestedActive.id } - } - } - } - - if (validRequestedParent) { - return { parentSessionId: validRequestedParent, activeSessionId: validRequestedParent } - } - return null + return parentSessionId ? { parentSessionId, activeSessionId: parentSessionId } : null } - -function areRestoredSessionReferencesAvailable( - availableSessions: readonly SessionDescriptor[], - references: RestoredSessionReferences, - allowedNonSessionIds: readonly string[] = [], -): boolean { - return getUnavailableRestoredSessionIds(availableSessions, references, allowedNonSessionIds).size === 0 -} - -function getUnavailableRestoredSessionIds( +export function getUnavailableRestoredSessionIds( availableSessions: readonly SessionDescriptor[], references: RestoredSessionReferences, allowedNonSessionIds: readonly string[] = [], ): Set { - const availableIds = new Set(availableSessions.map((session) => session.id)) - const allowedIds = new Set(allowedNonSessionIds) - const requiredIds = [ + const available = new Set(availableSessions.map(({ id }) => id)) + const allowed = new Set(allowedNonSessionIds) + const referenced = [ references.activeParentSessionId, references.activeSessionId === "info" ? undefined : references.activeSessionId, ...references.draftSessionIds, @@ -163,46 +91,17 @@ function getUnavailableRestoredSessionIds( ...(references.idleMarkerSessionIds ?? []), ...(references.generationRecoverySessionIds ?? []), ] - return new Set( - requiredIds.filter((sessionId): sessionId is string => - Boolean(sessionId) && !availableIds.has(sessionId!) && !allowedIds.has(sessionId!), - ), - ) + return new Set(referenced.filter((id): id is string => Boolean(id) && !available.has(id!) && !allowed.has(id!))) } - -function resolveRestoredActiveTabId( +export function resolveRestoredActiveTabId( restoredTabIds: readonly (string | null | undefined)[], requestedActiveTabIndex: number, ): string | null { - if (Number.isInteger(requestedActiveTabIndex) && requestedActiveTabIndex >= 0) { - const requested = restoredTabIds[requestedActiveTabIndex] - if (requested) return requested - } - return restoredTabIds.find((tabId): tabId is string => Boolean(tabId)) ?? null + const requested = Number.isInteger(requestedActiveTabIndex) && requestedActiveTabIndex >= 0 + ? restoredTabIds[requestedActiveTabIndex] + : null + return requested || restoredTabIds.find((id): id is string => Boolean(id)) || null } - -function shouldRestoreSessionState( - isPrimary: boolean, - restoreEnabled: boolean, - snapshot: unknown, -): boolean { - return isPrimary && restoreEnabled && Boolean(snapshot) -} - -function shouldEnableSessionCapture( - _snapshotExisted: boolean, - _restoreCompleted: boolean, -): boolean { - return true -} - -export { - areRestoredSessionReferencesAvailable, - getUnavailableRestoredSessionIds, - normalizeWorkspacePath, - reconcileWorkspaceTabs, - resolveRestoredActiveTabId, - resolveRestoredSessionSelection, - shouldEnableSessionCapture, - shouldRestoreSessionState, +export function shouldRestoreSessionState(isPrimary: boolean, restoreEnabled: boolean, snapshot: unknown): boolean { + return Boolean(isPrimary && restoreEnabled && snapshot) } diff --git a/packages/ui/src/stores/app-session-restore-gate.test.ts b/packages/ui/src/stores/app-session-restore-gate.test.ts new file mode 100644 index 00000000..66279b33 --- /dev/null +++ b/packages/ui/src/stores/app-session-restore-gate.test.ts @@ -0,0 +1,19 @@ +import assert from "node:assert/strict" +import { it } from "node:test" + +import { shouldShowAppHomeOverlay, shouldShowEmptyAppHome } from "./app-session-restore-gate.ts" + +const tab = { kind: "sidecar" as const, sidecarId: "preview" } + +it("hides the empty-app home while saved tabs are restoring", () => { + assert.equal(shouldShowEmptyAppHome({ tabs: [tab], activeTabIndex: 0 }, true), false) + assert.equal(shouldShowEmptyAppHome({ tabs: [tab], activeTabIndex: 0, homeActive: true }, true), true) + assert.equal(shouldShowEmptyAppHome({ tabs: [], activeTabIndex: -1 }, true), true) + assert.equal(shouldShowEmptyAppHome({ tabs: [tab], activeTabIndex: 0 }, false), true) +}) + +it("mounts the requested home overlay only when tabs exist", () => { + assert.equal(shouldShowAppHomeOverlay(true, 0), false) + assert.equal(shouldShowAppHomeOverlay(true, 1), true) + assert.equal(shouldShowAppHomeOverlay(false, 1), false) +}) diff --git a/packages/ui/src/stores/app-session-restore-gate.ts b/packages/ui/src/stores/app-session-restore-gate.ts index 9b6260cd..8b0d16e4 100644 --- a/packages/ui/src/stores/app-session-restore-gate.ts +++ b/packages/ui/src/stores/app-session-restore-gate.ts @@ -1,4 +1,5 @@ import { createSignal } from "solid-js" +import type { RestorableSessionState } from "./client-state-codec" const [appSessionRestoreGateActive, setAppSessionRestoreGateActive] = createSignal(true) @@ -6,4 +7,12 @@ function releaseAppSessionRestoreGate(): void { setAppSessionRestoreGateActive(false) } -export { appSessionRestoreGateActive, releaseAppSessionRestoreGate } +function shouldShowEmptyAppHome(snapshot: RestorableSessionState | null, restoreActive = appSessionRestoreGateActive()): boolean { + return !restoreActive || !snapshot?.tabs.length || snapshot.homeActive === true +} + +function shouldShowAppHomeOverlay(requested: boolean, tabCount: number): boolean { + return requested && tabCount > 0 +} + +export { appSessionRestoreGateActive, releaseAppSessionRestoreGate, shouldShowAppHomeOverlay, shouldShowEmptyAppHome } diff --git a/packages/ui/src/stores/app-session-restore-queue.test.ts b/packages/ui/src/stores/app-session-restore-queue.test.ts new file mode 100644 index 00000000..29119fb0 --- /dev/null +++ b/packages/ui/src/stores/app-session-restore-queue.test.ts @@ -0,0 +1,39 @@ +import assert from "node:assert/strict" +import { it } from "node:test" +import { runWithSerializedCommits } from "./app-session-restore-queue.ts" + +function deferred() { + let resolve!: () => void + const promise = new Promise((done) => { resolve = done }) + return { promise, resolve } +} + +it("starts create requests concurrently but commits mounts in saved order", async () => { + const requests = [deferred(), deferred(), deferred()] + const hydrations = [deferred(), deferred(), deferred()] + const started: number[] = [] + const commits: number[] = [] + let mounted = 0 + const restoration = runWithSerializedCommits([0, 1, 2], async (index, waitForCommit, finishCommit) => { + started.push(index) + await requests[index]!.promise + await waitForCommit + mounted += 1 + assert.equal(mounted, 1, "workspace mounts never overlap") + commits.push(index) + await Promise.resolve() + mounted -= 1 + finishCommit() + await hydrations[index]!.promise + }) + + assert.deepEqual(started, [0, 1, 2], "all network requests start without waiting for a prior mount") + requests[2]!.resolve(); requests[1]!.resolve() + await Promise.resolve() + assert.deepEqual(commits, [], "later responses wait for their saved-order commit turn") + requests[0]!.resolve() + await new Promise((resolve) => setImmediate(resolve)) + assert.deepEqual(commits, [0, 1, 2], "post-mount hydration does not hold the commit queue") + hydrations.forEach(({ resolve }) => resolve()) + await restoration +}) diff --git a/packages/ui/src/stores/app-session-restore-queue.ts b/packages/ui/src/stores/app-session-restore-queue.ts new file mode 100644 index 00000000..095f8442 --- /dev/null +++ b/packages/ui/src/stores/app-session-restore-queue.ts @@ -0,0 +1,15 @@ +export async function runWithSerializedCommits( + items: readonly T[], + run: (item: T, waitForCommit: Promise, finishCommit: () => void) => Promise, +): Promise { + let previous = Promise.resolve() + const tasks = items.map((item) => { + const waitForCommit = previous + let finishCommit!: () => void + const commit = new Promise((resolve) => { finishCommit = resolve }) + const task = run(item, waitForCommit, finishCommit).finally(finishCommit) + previous = Promise.allSettled([waitForCommit, commit]).then(() => undefined) + return task + }) + await Promise.all(tasks) +} diff --git a/packages/ui/src/stores/app-session-restore-timeout.test.ts b/packages/ui/src/stores/app-session-restore-timeout.test.ts index 536f7340..fc97aa36 100644 --- a/packages/ui/src/stores/app-session-restore-timeout.test.ts +++ b/packages/ui/src/stores/app-session-restore-timeout.test.ts @@ -1,89 +1,53 @@ import assert from "node:assert/strict" import { describe, it } from "node:test" - -import { - awaitRestoreStep, - RestoreTimeoutError, - runWithRestoreDeadline, - withRestoreTimeout, -} from "./app-session-restore-timeout.ts" - +import { RestoreTimeoutError, runAbortable } from "./app-session-restore-timeout.ts" +const deferred = () => { + let resolve!: () => void + const promise = new Promise((done) => { resolve = done }) + return { promise, resolve } +} +const tick = () => new Promise((resolve) => setTimeout(resolve, 0)) describe("app session restore timeouts", () => { it("rejects an operation that does not settle within its bound", async () => { let operationSignal: AbortSignal | undefined - await assert.rejects( - withRestoreTimeout((signal) => { - operationSignal = signal - return new Promise(() => {}) - }, 5, "restore stalled"), - (error) => error instanceof RestoreTimeoutError && error.message === "restore stalled", - ) + await assert.rejects(runAbortable((signal) => { + operationSignal = signal; return new Promise(() => {}) + }, { timeoutMs: 5, message: "restore stalled" }), (error) => error instanceof RestoreTimeoutError && error.message === "restore stalled") assert.equal(operationSignal?.aborted, true) }) - it("deactivates a timed-out restore before its late completion", async () => { - let finish: (() => void) | undefined + const pending = deferred() let lateWrite = false - const pending = new Promise((resolve) => { - finish = resolve - }) - - await assert.rejects( - runWithRestoreDeadline(async (isActive) => { - await pending - if (isActive()) lateWrite = true - }, 5, "startup restore stalled"), - RestoreTimeoutError, - ) - - finish?.() - await new Promise((resolve) => setTimeout(resolve, 0)) + await assert.rejects(runAbortable(async (signal) => { + await pending.promise + if (!signal.aborted) lateWrite = true + }, { timeoutMs: 5, message: "startup restore stalled" }), RestoreTimeoutError) + pending.resolve() + await tick() assert.equal(lateWrite, false) }) - it("propagates a deadline abort signal into a nested operation", async () => { let nestedSignal: AbortSignal | undefined - await assert.rejects( - runWithRestoreDeadline( - (_isActive, deadlineSignal) => withRestoreTimeout((signal) => { - nestedSignal = signal - return new Promise(() => {}) - }, 1000, "nested stalled", deadlineSignal), - 5, - "deadline stalled", - ), - RestoreTimeoutError, - ) + await assert.rejects(runAbortable((deadlineSignal) => runAbortable((signal) => { + nestedSignal = signal; return new Promise(() => {}) + }, { timeoutMs: 1000, message: "nested stalled", signal: deadlineSignal }), { timeoutMs: 5, message: "deadline stalled" }), RestoreTimeoutError) assert.equal(nestedSignal?.aborted, true) }) - it("cancels the restore deadline when its owner is disposed", async () => { const controller = new AbortController() let restoreSignal: AbortSignal | undefined - const completion = runWithRestoreDeadline( - async (_isActive, signal) => { - restoreSignal = signal - await new Promise(() => undefined) - }, - 1_000, - "deadline stalled", - controller.signal, - ) - + const completion = runAbortable(async (signal) => { + restoreSignal = signal; await new Promise(() => undefined) + }, { timeoutMs: 1_000, message: "deadline stalled", signal: controller.signal }) controller.abort(new Error("restore disposed")) await assert.rejects(completion, /restore disposed/) assert.equal(restoreSignal?.aborted, true) }) - it("rejects a SideCar load that completes after its restore signal aborts", async () => { - let finish: (() => void) | undefined - const load = new Promise((resolve) => { - finish = resolve - }) + const load = deferred() const controller = new AbortController() - const completion = awaitRestoreStep(load, controller.signal) - controller.abort(new Error("SideCar restore timed out")) - finish?.() + const completion = runAbortable(() => load.promise, { signal: controller.signal }) + controller.abort(new Error("SideCar restore timed out")); load.resolve() await assert.rejects(completion, /SideCar restore timed out/) }) }) diff --git a/packages/ui/src/stores/app-session-restore-timeout.ts b/packages/ui/src/stores/app-session-restore-timeout.ts index 43279e02..ea6e7008 100644 --- a/packages/ui/src/stores/app-session-restore-timeout.ts +++ b/packages/ui/src/stores/app-session-restore-timeout.ts @@ -1,6 +1,3 @@ -export type RestoreActivity = () => boolean -export type RestoreOperation = (signal: AbortSignal) => Promise - export class RestoreTimeoutError extends Error { constructor(message: string) { super(message) @@ -15,32 +12,9 @@ export function getAbortReason(signal: AbortSignal): Error { return error } -export async function awaitRestoreStep(operation: Promise, signal?: AbortSignal): Promise { - if (!signal) return operation - if (signal.aborted) throw getAbortReason(signal) - - return new Promise((resolve, reject) => { - let settled = false - const finish = (callback: () => void) => { - if (settled) return - settled = true - signal.removeEventListener("abort", handleAbort) - callback() - } - const handleAbort = () => finish(() => reject(getAbortReason(signal))) - signal.addEventListener("abort", handleAbort, { once: true }) - operation.then( - (value) => finish(() => resolve(value)), - (error) => finish(() => reject(error)), - ) - }) -} - -export function withRestoreTimeout( - operation: RestoreOperation, - timeoutMs: number, - message: string, - parentSignal?: AbortSignal, +export function runAbortable( + operation: (signal: AbortSignal) => Promise, + options: { signal?: AbortSignal; timeoutMs?: number; message?: string } = {}, ): Promise { return new Promise((resolve, reject) => { const controller = new AbortController() @@ -48,26 +22,26 @@ export function withRestoreTimeout( const finish = (callback: () => void) => { if (settled) return settled = true - clearTimeout(timer) - parentSignal?.removeEventListener("abort", handleParentAbort) + if (timer) clearTimeout(timer) + options.signal?.removeEventListener("abort", handleParentAbort) callback() } const handleParentAbort = () => { - const reason = getAbortReason(parentSignal!) + const reason = getAbortReason(options.signal!) controller.abort(reason) finish(() => reject(reason)) } - const timer = setTimeout(() => { - const error = new RestoreTimeoutError(message) + const timer = options.timeoutMs === undefined ? undefined : setTimeout(() => { + const error = new RestoreTimeoutError(options.message ?? "Operation timed out") controller.abort(error) finish(() => reject(error)) - }, timeoutMs) + }, options.timeoutMs) - if (parentSignal?.aborted) { + if (options.signal?.aborted) { handleParentAbort() return } - parentSignal?.addEventListener("abort", handleParentAbort, { once: true }) + options.signal?.addEventListener("abort", handleParentAbort, { once: true }) Promise.resolve().then(() => operation(controller.signal)).then( (value) => { @@ -79,17 +53,3 @@ export function withRestoreTimeout( ) }) } - -export async function runWithRestoreDeadline( - operation: (isActive: RestoreActivity, signal: AbortSignal) => Promise, - timeoutMs: number, - message: string, - parentSignal?: AbortSignal, -): Promise { - return withRestoreTimeout( - (signal) => operation(() => !signal.aborted, signal), - timeoutMs, - message, - parentSignal, - ) -} diff --git a/packages/ui/src/stores/app-session-snapshot-merge.test.ts b/packages/ui/src/stores/app-session-snapshot-merge.test.ts index b493b8b9..e2987823 100644 --- a/packages/ui/src/stores/app-session-snapshot-merge.test.ts +++ b/packages/ui/src/stores/app-session-snapshot-merge.test.ts @@ -1,868 +1,537 @@ import assert from "node:assert/strict" import { describe, it } from "node:test" +import type { RestorableAttachment } from "./client-state-attachments-codec.ts" import type { RestorableSessionState, RestorableWorkspaceTabState } from "./client-state-codec.ts" import { createRestorableSessionPreservation, - mapRestoredWorkspace, - mapRestoredWorkspaces, + createRestoredTabCommitGuard, + getPreservedWorkspaceState, + getPreservedWorkspaceReopenTarget, + hasRestoredTabBinding, markPreservedWorkspaceRemoved, markPreservedWorkspaceReopened, - markRestoredTab, + markPreservedWorkspaceUnavailable, mergeRestorableSessionState, + recordRestoredTab, + settleRestoredTab, + type RestorableSessionPreservation, + type RestorableWorkspaceRuntimeAuthority, } from "./app-session-snapshot-merge.ts" import { reconcileWorkspaceTabs } from "./app-session-reconciliation.ts" -function workspace( +const empty = (): RestorableSessionState => ({ tabs: [], activeTabIndex: -1 }) +const session = ( + tabs: RestorableSessionState["tabs"], + activeTabIndex = tabs.length ? 0 : -1, +): RestorableSessionState => ({ tabs, activeTabIndex }) +const workspace = ( folder: string, - occurrence: number, + occurrence = 0, state: Partial = {}, -): RestorableWorkspaceTabState { - return { - kind: "workspace", - folder, - occurrence, - drafts: {}, - attachments: {}, - scrollSnapshots: {}, - unseenIdleSince: {}, - generationRecovery: {}, - ...state, +): RestorableWorkspaceTabState => ({ + kind: "workspace", folder, occurrence, drafts: {}, attachments: {}, scrollSnapshots: {}, + unseenIdleSince: {}, generationRecovery: {}, ...state, +}) +const sidecar = (sidecarId: string) => ({ kind: "sidecar" as const, sidecarId }) +const attachment = (id = "paste", path?: string): RestorableAttachment => ({ + id, type: path ? "file" : "text", display: id, url: "", filename: `${id}.txt`, mediaType: "text/plain", + source: path ? { type: "file", path, mime: "text/plain" } : { type: "text", value: `${id} content` }, +}) +const scroll = (scrollTop: number, updatedAt = 1) => ({ scrollTop, atBottom: false, updatedAt }) + +function restored( + saved: RestorableSessionState, + mappings: readonly { source: number; runtime?: string | null; unavailable?: readonly string[]; pending?: boolean }[] = [], +): RestorableSessionPreservation { + const preservation = createRestorableSessionPreservation(saved) + for (const mapping of mappings) { + recordRestoredTab( + preservation, + mapping.source, + mapping.runtime ?? null, + mapping.pending ? undefined : new Set(mapping.unavailable), + ) } + return preservation } +function workspaceAt(state: RestorableSessionState, index = 0): RestorableWorkspaceTabState { + const tab = state.tabs[index] + assert.equal(tab?.kind, "workspace", `tab ${index} should be a workspace`) + if (tab?.kind !== "workspace") throw new Error(`tab ${index} is not a workspace`) + return tab +} + +const labels = (state: RestorableSessionState) => state.tabs.map((tab) => + tab.kind === "workspace" ? `${tab.folder}:${tab.occurrence}` : tab.sidecarId) +const mergeOne = ( + savedTab: RestorableWorkspaceTabState, + currentTab = workspace(savedTab.folder, savedTab.occurrence), + authority?: RestorableWorkspaceRuntimeAuthority, +) => workspaceAt(mergeRestorableSessionState( + session([currentTab]), + createRestorableSessionPreservation(session([savedTab])), + { currentTabIds: ["instance:work"], currentTabAuthorities: [authority] }, +)) + describe("app session snapshot merge", () => { - it("retains transient tabs and missing session state while persisting current and new tabs", () => { - const savedAttachment = { - id: "paste", - type: "text" as const, - display: "pasted #1 (4 lines)", - url: "", - filename: "paste.txt", - mediaType: "text/plain", - source: { type: "text" as const, value: "saved paste" }, - } - const saved: RestorableSessionState = { - activeTabIndex: 1, - tabs: [ - workspace("/work/a", 0, { - activeParentSessionId: "missing-session", - activeSessionId: "missing-session", - drafts: { "missing-session": "saved draft" }, - attachments: { "missing-session": [savedAttachment] }, - scrollSnapshots: { - "missing-session": { scrollTop: 42, atBottom: false, updatedAt: 1 }, - }, - }), - { kind: "sidecar", sidecarId: "transient" }, - { kind: "sidecar", sidecarId: "deleted" }, - workspace("/work/b", 0), - ], - } - let preservation = createRestorableSessionPreservation(saved) - preservation = markRestoredTab(preservation, 0, new Set(["missing-session"])) - preservation = markRestoredTab(preservation, 2) - preservation = markRestoredTab(preservation, 3) - - const current: RestorableSessionState = { - activeTabIndex: 2, - tabs: [ - workspace("/work/a", 0, { drafts: { visible: "current draft" } }), - workspace("/work/b", 0), - { kind: "sidecar", sidecarId: "new-runtime-tab" }, - ], - } - const merged = mergeRestorableSessionState(current, preservation) - - assert.deepEqual(merged.tabs.map((tab) => tab.kind === "sidecar" ? tab.sidecarId : tab.folder), [ - "/work/a", - "transient", - "/work/b", - "new-runtime-tab", - ]) - assert.equal(merged.activeTabIndex, 3) - const first = merged.tabs[0] - assert.equal(first?.kind, "workspace") - if (first?.kind !== "workspace") return - assert.deepEqual(first.drafts, { "missing-session": "saved draft", visible: "current draft" }) - assert.deepEqual(first.attachments["missing-session"], [savedAttachment]) - assert.equal(first.scrollSnapshots["missing-session"]?.scrollTop, 42) - assert.equal(first.activeParentSessionId, "missing-session") - assert.equal(first.activeSessionId, "missing-session") - }) - - it("uses a recovered runtime tab instead of duplicating a retained whole tab", () => { - const saved: RestorableSessionState = { - activeTabIndex: 0, - tabs: [{ kind: "sidecar", sidecarId: "preview" }], - } - const preservation = createRestorableSessionPreservation(saved) - const current: RestorableSessionState = { - activeTabIndex: 0, - tabs: [ - { kind: "sidecar", sidecarId: "preview" }, - { kind: "sidecar", sidecarId: "new" }, - ], - } - - assert.deepEqual(mergeRestorableSessionState(current, preservation), current) - }) - - it("backfills failed hydration state into a blank matching runtime workspace during capture", () => { - const savedAttachment = { - id: "paste", - type: "text" as const, - display: "pasted #1 (4 lines)", - url: "", - filename: "paste.txt", - mediaType: "text/plain", - source: { type: "text" as const, value: "unsaved paste" }, - } - const saved: RestorableSessionState = { - activeTabIndex: 0, - tabs: [ - workspace("/failed", 0, { - projectName: "saved metadata", - activeParentSessionId: "unsaved-session", - activeSessionId: "unsaved-session", - drafts: { "unsaved-session": "[pasted #1]", live: "stale draft" }, - attachments: { "unsaved-session": [savedAttachment] }, - scrollSnapshots: { - "unsaved-session": { scrollTop: 37, atBottom: false, updatedAt: 1 }, - }, - }), - workspace("/restored", 0), - ], - } - let preservation = createRestorableSessionPreservation(saved) - preservation = markRestoredTab(preservation, 1, new Set(), "instance:restored") - - const current: RestorableSessionState = { - activeTabIndex: 0, - tabs: [ - workspace("/restored", 0), - workspace("/failed", 0, { - projectName: "current metadata", - drafts: { live: "current draft" }, - }), - ], - } - const merged = mergeRestorableSessionState(current, preservation, { - currentTabIds: ["instance:restored", "instance:failed"], - }) - - assert.equal(merged.activeTabIndex, 0) - assert.deepEqual(merged.tabs.map((tab) => tab.kind === "workspace" ? tab.folder : tab.kind), [ - "/restored", - "/failed", - ]) - const failed = merged.tabs[1] - assert.equal(failed?.kind, "workspace") - if (failed?.kind !== "workspace") return - assert.equal(failed.occurrence, 0) - assert.equal(failed.projectName, "current metadata") - assert.deepEqual(failed.drafts, { - "unsaved-session": "[pasted #1]", - live: "current draft", - }) - assert.deepEqual(failed.attachments["unsaved-session"], [savedAttachment]) - assert.equal(failed.scrollSnapshots["unsaved-session"]?.scrollTop, 37) - assert.equal(failed.activeParentSessionId, "unsaved-session") - assert.equal(failed.activeSessionId, "unsaved-session") - }) - - it("keeps untouched failed-hydration records as capture fallbacks", () => { - const saved = { - tabs: [workspace("/failed", 0, { - drafts: { missing: "saved draft" }, - attachments: { missing: [{ - id: "paste", - type: "text" as const, - display: "pasted #1 (4 lines)", - url: "", - filename: "paste.txt", - mediaType: "text/plain", - source: { type: "text" as const, value: "saved paste" }, - }] }, - })], - activeTabIndex: 0, - } - const merged = mergeRestorableSessionState( - { tabs: [workspace("/failed", 0)], activeTabIndex: 0 }, - createRestorableSessionPreservation(saved), - { currentTabIds: ["instance:failed"], currentTabAuthorities: [{}] }, + it("retains the latest restored tab state after a non-authoritative stop", () => { + const saved = session([workspace("/work", 0, { drafts: { missing: "saved", current: "old" } })]) + const preservation = restored(saved, [{ source: 0, runtime: "instance:work" }]) + markPreservedWorkspaceUnavailable( + preservation, + { runtimeTabId: "instance:work", folder: "/work", occurrence: 0 }, + workspace("/work", 0, { drafts: { current: "latest unsent draft" } }), ) - const tab = merged.tabs[0] - assert.equal(tab?.kind, "workspace") - if (tab?.kind !== "workspace") return - assert.equal(tab.drafts.missing, "saved draft") - assert.equal(tab.attachments.missing?.[0]?.id, "paste") + const merged = mergeRestorableSessionState(empty(), preservation, { currentTabIds: [] }) + assert.deepEqual(workspaceAt(merged).drafts, { missing: "saved", current: "latest unsent draft" }) + assert.equal(getPreservedWorkspaceState( + preservation, + { runtimeTabId: "instance:other", folder: "/work", occurrence: 0 }, + ), null, "a different runtime cannot claim prompts by folder occurrence alone") }) - it("excludes an explicitly closed failed-hydration workspace while retaining transient absence", () => { - const saved = { - tabs: [workspace("/failed", 0, { drafts: { missing: "retry me" } })], - activeTabIndex: 0, - } - const unresolved = createRestorableSessionPreservation(saved) - const empty = { tabs: [], activeTabIndex: -1 } - - assert.equal(mergeRestorableSessionState(empty, unresolved).tabs.length, 1) - - const removed = markPreservedWorkspaceRemoved(unresolved, { - runtimeTabId: "instance:failed", - folder: "/failed", - occurrence: 0, - }) - assert.deepEqual(mergeRestorableSessionState(empty, removed), empty) - - const reopened = markPreservedWorkspaceReopened(removed, { - runtimeTabId: "instance:reopened", - folder: "/failed", - occurrence: 0, - }) - assert.equal(mergeRestorableSessionState(empty, reopened).tabs.length, 1) - }) - - it("clears a restored workspace binding when its runtime ID is reopened", () => { - let preservation = markRestoredTab( - createRestorableSessionPreservation({ - tabs: [workspace("/work", 0)], - activeTabIndex: 0, + it("retains missing sessions, transient tabs, and new runtime tabs", () => { + const savedFile = attachment("path-file", "/work/a/notes.txt") + const saved = session([ + workspace("/work/a", 0, { + activeParentSessionId: "missing", activeSessionId: "missing", + drafts: { missing: "saved draft" }, attachments: { missing: [savedFile] }, + scrollSnapshots: { missing: scroll(42) }, }), - 0, - new Set(), - "instance:reused", - ) - assert.equal(preservation.restoredWorkspaceSourceIndexes.get("instance:reused"), 0) + sidecar("transient"), sidecar("deleted"), workspace("/work/b"), + ], 1) + const preservation = restored(saved, [ + { source: 0, unavailable: ["missing"] }, { source: 2 }, { source: 3 }, + ]) + const merged = mergeRestorableSessionState(session([ + workspace("/work/a", 0, { drafts: { visible: "current draft" } }), + workspace("/work/b"), sidecar("new-runtime-tab"), + ], 2), preservation) + assert.deepEqual(labels(merged), ["/work/a:0", "transient", "/work/b:0", "new-runtime-tab"]) + assert.equal(merged.activeTabIndex, 3) + const tab = workspaceAt(merged) + assert.deepEqual(tab.drafts, { missing: "saved draft", visible: "current draft" }) + assert.deepEqual(tab.attachments.missing, [savedFile], "path-backed payload survives") + assert.deepEqual(tab.scrollSnapshots.missing, scroll(42), "missing-session scroll is seeded") + assert.deepEqual([tab.activeParentSessionId, tab.activeSessionId], ["missing", "missing"]) + }) + + it("backfills failed hydration without replacing current metadata or user mutations", () => { + const savedFile = attachment("paste") + const saved = session([ + workspace("/failed", 0, { + projectName: "saved metadata", activeParentSessionId: "unsaved", activeSessionId: "unsaved", + drafts: { unsaved: "[paste]", live: "stale" }, attachments: { unsaved: [savedFile] }, + scrollSnapshots: { unsaved: scroll(37) }, + }), + workspace("/restored"), + ]) + const merged = mergeRestorableSessionState( + session([workspace("/restored"), workspace("/failed", 0, { + projectName: "current metadata", drafts: { live: "current draft" }, + })]), + restored(saved, [{ source: 1, runtime: "instance:restored" }]), + { currentTabIds: ["instance:restored", "instance:failed"] }, + ) + + assert.deepEqual(labels(merged), ["/restored:0", "/failed:0"]) + const tab = workspaceAt(merged, 1) + assert.equal(tab.projectName, "current metadata") + assert.deepEqual(tab.drafts, { unsaved: "[paste]", live: "current draft" }) + assert.deepEqual(tab.attachments.unsaved, [savedFile]) + assert.equal(tab.scrollSnapshots.unsaved?.scrollTop, 37) + assert.deepEqual([tab.activeParentSessionId, tab.activeSessionId], ["unsaved", "unsaved"]) + }) + + it("retains payload records at persistence budget boundaries", () => { + const drafts = Object.fromEntries(Array.from({ length: 24 }, (_, index) => [`draft-${index}`, `value-${index}`])) + const scrollSnapshots = Object.fromEntries(Array.from({ length: 96 }, (_, index) => [`scroll-${index}`, scroll(index)])) + const tab = mergeOne(workspace("/budget", 0, { + drafts, scrollSnapshots, attachments: { path: [attachment("large-path", "/budget/large.bin")] }, + })) + assert.equal(Object.keys(tab.drafts).length, 24, "draft limit remains intact") + assert.equal(Object.keys(tab.scrollSnapshots).length, 96, "per-tab scroll limit remains intact") + assert.equal(tab.attachments.path?.[0]?.source.type, "file", "path payload remains intact") + }) + + for (const testCase of [ + { label: "draft cleared by user", field: "drafts" as const, saved: { missing: "saved" }, owned: "missing" }, + { label: "last attachment removed", field: "attachments" as const, saved: { missing: [attachment()] }, owned: "missing" }, + { label: "idle marker seen", field: "unseenIdleSince" as const, saved: { seen: 1_000 }, authority: "idleMarkers" as const, owned: "seen" }, + { label: "generation recovery cleared", field: "generationRecovery" as const, saved: { resumed: "working" as const }, owned: "resumed" }, + ]) { + it(`does not resurrect preserved state after authoritative ${testCase.label}`, () => { + const authorityField = testCase.authority ?? testCase.field + const tab = mergeOne( + workspace("/work", 0, { [testCase.field]: testCase.saved }), + workspace("/work"), + { [authorityField]: new Set([testCase.owned]) }, + ) + assert.deepEqual(tab[testCase.field], {}, `${testCase.field} should remain cleared`) + }) + } + + it("removes every record and selection for a remotely deleted session", () => { + const tab = mergeOne(workspace("/work", 0, { + activeParentSessionId: "deleted", activeSessionId: "deleted", drafts: { deleted: "draft" }, + attachments: { deleted: [attachment()] }, scrollSnapshots: { deleted: scroll(42) }, + unseenIdleSince: { deleted: 1_000 }, generationRecovery: { deleted: "working" }, + }), workspace("/work"), { deletedSessions: new Set(["deleted"]) }) + assert.deepEqual({ + drafts: tab.drafts, attachments: tab.attachments, scrolls: tab.scrollSnapshots, + idle: tab.unseenIdleSince, recovery: tab.generationRecovery, + }, { drafts: {}, attachments: {}, scrolls: {}, idle: {}, recovery: {} }) + assert.deepEqual([tab.activeParentSessionId, tab.activeSessionId], [undefined, undefined]) + }) + + it("preserves only unavailable idle and recovery records after partial restore", () => { + const saved = session([workspace("/work", 0, { + unseenIdleSince: { missing: 1_000, loaded: 2_000 }, + generationRecovery: { missing: "working", loaded: "interrupted" }, + })]) + const merged = mergeRestorableSessionState( + session([workspace("/work")]), + restored(saved, [{ source: 0, runtime: "instance:work", unavailable: ["missing"] }]), + { currentTabIds: ["instance:work"], currentTabAuthorities: [{ + idleMarkers: new Set(["loaded"]), generationRecovery: new Set(["loaded"]), + }] }, + ) + const tab = workspaceAt(merged) + assert.deepEqual(tab.unseenIdleSince, { missing: 1_000 }) + assert.deepEqual(tab.generationRecovery, { missing: "working" }) + }) + + it("keeps all authoritative current nested values over preserved values", () => { + const tab = mergeOne( + workspace("/work", 0, { + drafts: { id: "saved" }, attachments: { id: [attachment("saved")] }, + scrollSnapshots: { id: scroll(10) }, generationRecovery: { id: "working" }, + }), + workspace("/work", 0, { + drafts: { id: "current" }, attachments: { id: [attachment("current")] }, + scrollSnapshots: { id: scroll(90, 2) }, generationRecovery: { id: "interrupted" }, + }), + { drafts: new Set(["id"]), attachments: new Set(["id"]), scrollSnapshots: new Set(["id"]), + generationRecovery: new Set(["id"]) }, + ) + assert.equal(tab.drafts.id, "current") + assert.equal(tab.attachments.id?.[0]?.id, "current") + assert.equal(tab.scrollSnapshots.id?.scrollTop, 90) + assert.equal(tab.generationRecovery.id, "interrupted") + }) + + for (const testCase of [ + { label: "later parent/child selection", current: { activeParentSessionId: "current-parent", activeSessionId: "current-child" }, + expected: ["current-parent", "current-child"] }, + { label: "current info selection", current: { activeSessionId: "info" }, expected: [undefined, "info"] }, + { label: "untouched runtime selection", current: {}, expected: ["missing-parent", "missing-child"] }, + ]) { + it(`handles user selection during restore: ${testCase.label}`, () => { + const saved = session([workspace("/work", 0, { + activeParentSessionId: "missing-parent", activeSessionId: "missing-child", + })]) + const merged = mergeRestorableSessionState( + session([workspace("/work", 0, testCase.current)]), + restored(saved, [{ source: 0, runtime: "instance:work", unavailable: ["missing-parent", "missing-child"] }]), + { currentTabIds: ["instance:work"] }, + ) + const tab = workspaceAt(merged) + assert.deepEqual([tab.activeParentSessionId, tab.activeSessionId], testCase.expected) + }) + } + + it("keeps unresolved and partial payloads until a reopened workspace is actually restored", () => { + const saved = session([workspace("/failed", 0, { drafts: { missing: "retry", loaded: "discard" } })]) + let preservation = createRestorableSessionPreservation(saved) + assert.equal(mergeRestorableSessionState(empty(), preservation).tabs.length, 1, "transient absence retained") + preservation = markPreservedWorkspaceRemoved(preservation, { + runtimeTabId: "instance:failed", folder: "/failed", occurrence: 0, + }) + assert.deepEqual(mergeRestorableSessionState(empty(), preservation), empty(), "explicit close tombstones") preservation = markPreservedWorkspaceReopened(preservation, { - runtimeTabId: "instance:reused", - folder: "/work", - occurrence: 0, + runtimeTabId: "instance:reopened", folder: "/failed", occurrence: 0, }) - assert.equal(preservation.restoredWorkspaceSourceIndexes.size, 0) - assert.equal(preservation.restoredTabIds[0], null) + assert.equal(mergeRestorableSessionState(empty(), preservation).tabs.length, 1, "reopen clears tombstone") + assert.equal(preservation.results[0]?.runtimeTabId, "instance:reopened", "reopen binds the new runtime") + recordRestoredTab(preservation, 0, "instance:partial", new Set(["missing"])) + markPreservedWorkspaceReopened(preservation, { + runtimeTabId: "instance:partial", folder: "/failed", occurrence: 0, + }) + const partial = workspaceAt(mergeRestorableSessionState(session([workspace("/failed")]), preservation, { + currentTabIds: ["instance:reopened"], + })) + assert.deepEqual(partial.drafts, { missing: "retry" }, "reopen retains unavailable payload") }) - it("does not restore any state for an authoritatively deleted session", () => { - const preservation = createRestorableSessionPreservation({ - tabs: [workspace("/work", 0, { - activeParentSessionId: "deleted", - activeSessionId: "deleted", - drafts: { deleted: "saved draft" }, - attachments: { deleted: [{ - id: "paste", - type: "text", - display: "pasted #1 (4 lines)", - url: "", - filename: "paste.txt", - mediaType: "text/plain", - source: { type: "text", value: "saved paste" }, - }] }, - scrollSnapshots: { deleted: { scrollTop: 42, atBottom: false, updatedAt: 1 } }, - })], - activeTabIndex: 0, + it("rebinds preserved state when a workspace runtime is reopened", () => { + const preservation = restored(session([workspace("/work")]), [{ source: 0, runtime: "instance:reused" }]) + assert.equal(preservation.results[0]?.runtimeTabId, "instance:reused") + markPreservedWorkspaceReopened(preservation, { + runtimeTabId: "instance:reused", folder: "/work", occurrence: 0, }) - const merged = mergeRestorableSessionState( - { tabs: [workspace("/work", 0)], activeTabIndex: 0 }, - preservation, - { - currentTabIds: ["instance:work"], - currentTabAuthorities: [{ deletedSessions: new Set(["deleted"]) }], - }, - ) - const tab = merged.tabs[0] - assert.equal(tab?.kind, "workspace") - if (tab?.kind !== "workspace") return - assert.deepEqual(tab.drafts, {}) - assert.deepEqual(tab.attachments, {}) - assert.deepEqual(tab.scrollSnapshots, {}) - assert.equal(tab.activeParentSessionId, undefined) - assert.equal(tab.activeSessionId, undefined) + assert.equal(preservation.results[0]?.runtimeTabId, "instance:reused") }) - it("does not resurrect a preserved draft after an explicit runtime clear", () => { - const preservation = createRestorableSessionPreservation({ - tabs: [workspace("/failed", 0, { drafts: { missing: "saved draft" } })], - activeTabIndex: 0, - }) - const merged = mergeRestorableSessionState( - { tabs: [workspace("/failed", 0)], activeTabIndex: 0 }, - preservation, - { - currentTabIds: ["instance:failed"], - currentTabAuthorities: [{ drafts: new Set(["missing"]) }], - }, - ) - - assert.deepEqual(merged.tabs[0]?.kind === "workspace" ? merged.tabs[0].drafts : undefined, {}) + it("hydrates only a genuine unavailable or removed workspace reopen", () => { + const preservation = createRestorableSessionPreservation(session([workspace("/work")])) + const opened = { runtimeTabId: "instance:new", folder: "/work", occurrence: 0 } + assert.equal(getPreservedWorkspaceReopenTarget(preservation, opened), null, "initial restore create owns hydration") + recordRestoredTab(preservation, 0, "instance:old") + assert.equal(getPreservedWorkspaceReopenTarget(preservation, opened)?.sourceIndex, 0) }) - it("does not resurrect preserved attachments after the last runtime attachment is removed", () => { - const preservation = createRestorableSessionPreservation({ - tabs: [workspace("/failed", 0, { - attachments: { missing: [{ - id: "paste", - type: "text", - display: "pasted #1 (4 lines)", - url: "", - filename: "paste.txt", - mediaType: "text/plain", - source: { type: "text", value: "saved paste" }, - }] }, - })], - activeTabIndex: 0, - }) - const merged = mergeRestorableSessionState( - { tabs: [workspace("/failed", 0)], activeTabIndex: 0 }, - preservation, - { - currentTabIds: ["instance:failed"], - currentTabAuthorities: [{ attachments: new Set(["missing"]) }], - }, - ) + it("does not seed or settle after an explicit close during hydration", async () => { + const preservation = createRestorableSessionPreservation(session([workspace("/work")])) + recordRestoredTab(preservation, 0, "instance:hydrating") + let resume!: () => void + const hydration = new Promise((resolve) => { resume = resolve }) + const effects: string[] = [] + const completion = (async () => { + await hydration + if (!hasRestoredTabBinding(preservation, 0, "instance:hydrating")) return + effects.push("seed", "release", "select") + settleRestoredTab(preservation, 0, "instance:hydrating", "instance:hydrating", new Set()) + })() - assert.deepEqual(merged.tabs[0]?.kind === "workspace" ? merged.tabs[0].attachments : undefined, {}) + markPreservedWorkspaceRemoved(preservation, { + runtimeTabId: "instance:hydrating", folder: "/work", occurrence: 0, + }) + markPreservedWorkspaceReopened(preservation, { + runtimeTabId: "instance:reopened", folder: "/work", occurrence: 0, + }) + resume() + await completion + + assert.deepEqual(effects, []) + assert.deepEqual(preservation.results[0], { status: "pending", runtimeTabId: "instance:reopened" }) }) - it("does not resurrect a preserved idle marker after it is seen at runtime", () => { - const preservation = createRestorableSessionPreservation({ - tabs: [workspace("/work", 0, { unseenIdleSince: { seen: 1_000 } })], - activeTabIndex: 0, - }) - const merged = mergeRestorableSessionState( - { tabs: [workspace("/work", 0)], activeTabIndex: 0 }, - preservation, - { - currentTabIds: ["instance:work"], - currentTabAuthorities: [{ idleMarkers: new Set(["seen"]) }], - }, - ) + it("invalidates a pending create commit after close even when the workspace reopens", () => { + const preservation = createRestorableSessionPreservation(session([workspace("/work")])) + const canCommit = createRestoredTabCommitGuard(preservation, 0) - assert.deepEqual(merged.tabs[0]?.kind === "workspace" ? merged.tabs[0].unseenIdleSince : undefined, {}) + markPreservedWorkspaceReopened(preservation, { + runtimeTabId: "instance:restore-event", folder: "/work", occurrence: 0, + }) + assert.equal(canCommit(), true, "restore creation events do not invalidate their own response") + + markPreservedWorkspaceRemoved(preservation, { + runtimeTabId: "instance:restore-event", folder: "/work", occurrence: 0, + }) + markPreservedWorkspaceReopened(preservation, { + runtimeTabId: "instance:user-reopened", folder: "/work", occurrence: 0, + }) + assert.equal(canCommit(), false, "a late response cannot overwrite the close authority") }) - it("preserves idle markers for sessions unavailable during partial restore", () => { - let preservation = createRestorableSessionPreservation({ - tabs: [workspace("/work", 0, { - unseenIdleSince: { missing: 1_000, loaded: 2_000 }, - })], - activeTabIndex: 0, + it("compare-and-set settlement cannot overwrite removed or rebound authority", () => { + const preservation = createRestorableSessionPreservation(session([workspace("/work")])) + recordRestoredTab(preservation, 0, "instance:old") + markPreservedWorkspaceRemoved(preservation, { + runtimeTabId: "instance:old", folder: "/work", occurrence: 0, }) - preservation = markRestoredTab(preservation, 0, new Set(["missing"]), "instance:work") + assert.equal(settleRestoredTab(preservation, 0, "instance:old", "instance:old", new Set()), false) + assert.equal(settleRestoredTab(preservation, 0, "instance:old", null), false) + assert.deepEqual(preservation.results[0], { status: "removed" }) - const merged = mergeRestorableSessionState( - { tabs: [workspace("/work", 0)], activeTabIndex: 0 }, - preservation, - { - currentTabIds: ["instance:work"], - currentTabAuthorities: [{ idleMarkers: new Set(["loaded"]) }], - }, - ) + markPreservedWorkspaceReopened(preservation, { + runtimeTabId: "instance:new", folder: "/work", occurrence: 0, + }) + recordRestoredTab(preservation, 0, "instance:new") + assert.equal(settleRestoredTab(preservation, 0, "instance:old", null), false) + assert.deepEqual(preservation.results[0], { status: "pending", runtimeTabId: "instance:new" }) + assert.equal(settleRestoredTab(preservation, 0, "instance:new", "instance:new", new Set()), true) + assert.equal(preservation.results[0]?.status, "restored") + }) + it("does not duplicate recovered or authoritatively deleted sidecars", () => { + const recovered = session([sidecar("preview"), sidecar("new")]) assert.deepEqual( - merged.tabs[0]?.kind === "workspace" ? merged.tabs[0].unseenIdleSince : undefined, - { missing: 1_000 }, + mergeRestorableSessionState(recovered, createRestorableSessionPreservation(session([sidecar("preview")]))), + recovered, ) + const deleted = restored(session([sidecar("deleted")]), [{ source: 0 }]) + assert.deepEqual(mergeRestorableSessionState(empty(), deleted), empty()) }) - it("does not resurrect cleared generation recovery for an authoritative runtime session", () => { - const preservation = createRestorableSessionPreservation({ - tabs: [workspace("/work", 0, { generationRecovery: { resumed: "working" } })], - activeTabIndex: 0, + for (const testCase of [ + { label: "restored tabs reordered", current: [workspace("/b"), workspace("/a")], ids: ["instance:b", "instance:a"], active: 0 }, + { label: "new tab interleaved", current: [workspace("/a"), sidecar("new"), workspace("/b")], + ids: ["instance:a", "sidecar:new", "instance:b"], active: 1 }, + ]) { + it(`keeps current layout: ${testCase.label}`, () => { + const saved = session([workspace("/a"), workspace("/b")]) + const preservation = restored(saved, [ + { source: 0, runtime: "instance:a" }, { source: 1, runtime: "instance:b" }, + ]) + const current = session(testCase.current, testCase.active) + assert.deepEqual(mergeRestorableSessionState(current, preservation, { currentTabIds: testCase.ids }), current) }) - const merged = mergeRestorableSessionState( - { tabs: [workspace("/work", 0)], activeTabIndex: 0 }, - preservation, - { - currentTabIds: ["instance:work"], - currentTabAuthorities: [{ generationRecovery: new Set(["resumed"]) }], - }, - ) + } - assert.deepEqual(merged.tabs[0]?.kind === "workspace" ? merged.tabs[0].generationRecovery : undefined, {}) - }) - - it("preserves generation recovery for a session unavailable during partial restore", () => { - let preservation = createRestorableSessionPreservation({ - tabs: [workspace("/work", 0, { - generationRecovery: { missing: "working", loaded: "interrupted" }, - })], - activeTabIndex: 0, - }) - preservation = markRestoredTab(preservation, 0, new Set(["missing"]), "instance:work") - - const merged = mergeRestorableSessionState( - { tabs: [workspace("/work", 0)], activeTabIndex: 0 }, - preservation, - { - currentTabIds: ["instance:work"], - currentTabAuthorities: [{ generationRecovery: new Set(["loaded"]) }], - }, - ) - - assert.deepEqual( - merged.tabs[0]?.kind === "workspace" ? merged.tabs[0].generationRecovery : undefined, - { missing: "working" }, - ) - }) - - it("keeps current interrupted recovery over a preserved working marker", () => { - const preservation = createRestorableSessionPreservation({ - tabs: [workspace("/work", 0, { generationRecovery: { session: "working" } })], - activeTabIndex: 0, - }) - const merged = mergeRestorableSessionState( - { tabs: [workspace("/work", 0, { generationRecovery: { session: "interrupted" } })], activeTabIndex: 0 }, - preservation, - { - currentTabIds: ["instance:work"], - currentTabAuthorities: [{ generationRecovery: new Set(["session"]) }], - }, - ) - - assert.deepEqual( - merged.tabs[0]?.kind === "workspace" ? merged.tabs[0].generationRecovery : undefined, - { session: "interrupted" }, - ) - }) - - it("keeps new authoritative runtime values over preserved values", () => { - const savedAttachment = { - id: "saved", - type: "text" as const, - display: "saved attachment", - url: "", - filename: "saved.txt", - mediaType: "text/plain", - source: { type: "text" as const, value: "saved attachment" }, - } - const currentAttachment = { - ...savedAttachment, - id: "current", - display: "current attachment", - filename: "current.txt", - source: { type: "text" as const, value: "current attachment" }, - } - const preservation = createRestorableSessionPreservation({ - tabs: [workspace("/failed", 0, { - drafts: { missing: "saved draft" }, - attachments: { missing: [savedAttachment] }, - scrollSnapshots: { missing: { scrollTop: 10, atBottom: false, updatedAt: 1 } }, - })], - activeTabIndex: 0, - }) - const current = workspace("/failed", 0, { - drafts: { missing: "current draft" }, - attachments: { missing: [currentAttachment] }, - scrollSnapshots: { missing: { scrollTop: 90, atBottom: true, updatedAt: 2 } }, - }) - const merged = mergeRestorableSessionState( - { tabs: [current], activeTabIndex: 0 }, - preservation, - { - currentTabIds: ["instance:failed"], - currentTabAuthorities: [{ - drafts: new Set(["missing"]), - attachments: new Set(["missing"]), - scrollSnapshots: new Set(["missing"]), - }], - }, - ) - const tab = merged.tabs[0] - assert.equal(tab?.kind, "workspace") - if (tab?.kind !== "workspace") return - assert.equal(tab.drafts.missing, "current draft") - assert.equal(tab.attachments.missing?.[0]?.id, "current") - assert.equal(tab.scrollSnapshots.missing?.scrollTop, 90) - }) - - it("keeps a later current session selection over a missing preserved selection", () => { - const saved = { - tabs: [workspace("/work", 0, { - activeParentSessionId: "missing-parent", - activeSessionId: "missing-child", - })], - activeTabIndex: 0, - } - const preservation = markRestoredTab( - createRestorableSessionPreservation(saved), - 0, - new Set(["missing-parent", "missing-child"]), - "instance:work", - ) - const current = workspace("/work", 0, { - activeParentSessionId: "current-parent", - activeSessionId: "current-child", - }) - const merged = mergeRestorableSessionState( - { tabs: [current], activeTabIndex: 0 }, - preservation, - { currentTabIds: ["instance:work"] }, - ) - const tab = merged.tabs[0] - assert.equal(tab?.kind === "workspace" ? tab.activeParentSessionId : undefined, "current-parent") - assert.equal(tab?.kind === "workspace" ? tab.activeSessionId : undefined, "current-child") - }) - - it("keeps a current info selection over a missing preserved selection", () => { - const saved = { - tabs: [workspace("/work", 0, { - activeParentSessionId: "missing-parent", - activeSessionId: "missing-child", - })], - activeTabIndex: 0, - } - const preservation = markRestoredTab( - createRestorableSessionPreservation(saved), - 0, - new Set(["missing-parent", "missing-child"]), - "instance:work", - ) - const current = workspace("/work", 0, { activeSessionId: "info" }) - const merged = mergeRestorableSessionState( - { tabs: [current], activeTabIndex: 0 }, - preservation, - { currentTabIds: ["instance:work"] }, - ) - const tab = merged.tabs[0] - assert.equal(tab?.kind === "workspace" ? tab.activeParentSessionId : undefined, undefined) - assert.equal(tab?.kind === "workspace" ? tab.activeSessionId : undefined, "info") - }) - - it("preserves a missing source selection while runtime selection is untouched", () => { - const saved = { - tabs: [workspace("/work", 0, { - activeParentSessionId: "missing-parent", - activeSessionId: "missing-child", - })], - activeTabIndex: 0, - } - const preservation = markRestoredTab( - createRestorableSessionPreservation(saved), - 0, - new Set(["missing-parent", "missing-child"]), - "instance:work", - ) - const merged = mergeRestorableSessionState( - { tabs: [workspace("/work", 0)], activeTabIndex: 0 }, - preservation, - { currentTabIds: ["instance:work"] }, - ) - const tab = merged.tabs[0] - assert.equal(tab?.kind === "workspace" ? tab.activeParentSessionId : undefined, "missing-parent") - assert.equal(tab?.kind === "workspace" ? tab.activeSessionId : undefined, "missing-child") - }) - - it("does not reinsert an authoritatively deleted SideCar", () => { - const saved: RestorableSessionState = { - activeTabIndex: 0, - tabs: [{ kind: "sidecar", sidecarId: "deleted" }], - } - const preservation = markRestoredTab(createRestorableSessionPreservation(saved), 0) - assert.deepEqual(mergeRestorableSessionState({ tabs: [], activeTabIndex: -1 }, preservation), { - tabs: [], - activeTabIndex: -1, - }) - }) - - it("does not relaunch a removed first occurrence from a restored duplicate pair", () => { - const saved: RestorableSessionState = { - activeTabIndex: 1, - tabs: [ - workspace("/same", 0), - workspace("/same", 1, { drafts: { missing: "preserve me" } }), - ], - } - let preservation = createRestorableSessionPreservation(saved) - preservation = markRestoredTab(preservation, 0, new Set(), "instance:first") - preservation = markRestoredTab(preservation, 1, new Set(["missing"]), "instance:second") - const current: RestorableSessionState = { - activeTabIndex: 0, - tabs: [workspace("/same", 0, { drafts: { current: "captured" } })], - } - - const merged = mergeRestorableSessionState(current, preservation, { - currentTabIds: ["instance:second"], - }) - - assert.equal(merged.tabs.length, 1) - assert.equal(merged.tabs[0]?.kind === "workspace" ? merged.tabs[0].occurrence : undefined, 0) - assert.deepEqual(merged.tabs[0]?.kind === "workspace" ? merged.tabs[0].drafts : undefined, { - missing: "preserve me", - current: "captured", - }) - const nextLaunch = reconcileWorkspaceTabs( - merged.tabs.map((tab) => tab.kind === "workspace" - ? { kind: tab.kind, folderPath: tab.folder, occurrence: tab.occurrence } - : { kind: tab.kind }), - [{ id: "second", folderPath: "/same" }], - ) - assert.deepEqual(nextLaunch.map((match) => match.existingWorkspaceId), ["second"]) - }) - - it("tombstones both same-folder whole preservations when closed first then remaining", () => { - const saved: RestorableSessionState = { - activeTabIndex: 0, - tabs: [ - workspace("/same", 0, { drafts: { first: "preserve first" } }), - workspace("/same", 1, { drafts: { second: "preserve second" } }), - ], - } - let preservation = createRestorableSessionPreservation(saved) - preservation = mapRestoredWorkspace(preservation, 0, "instance:first") - preservation = mapRestoredWorkspace(preservation, 1, "instance:second") - - preservation = markPreservedWorkspaceRemoved(preservation, { - runtimeTabId: "instance:first", - folder: "/same", - occurrence: 0, - }) - const afterFirstClose = mergeRestorableSessionState( - { tabs: [workspace("/same", 0)], activeTabIndex: 0 }, - preservation, - { currentTabIds: ["instance:second"] }, - ) - assert.equal(afterFirstClose.tabs.length, 1) - assert.equal(afterFirstClose.tabs[0]?.kind === "workspace" ? afterFirstClose.tabs[0].drafts.second : undefined, "preserve second") - - preservation = markPreservedWorkspaceRemoved(preservation, { - runtimeTabId: "instance:second", - folder: "/same", - occurrence: 0, - }) - assert.deepEqual( - mergeRestorableSessionState({ tabs: [], activeTabIndex: -1 }, preservation), - { tabs: [], activeTabIndex: -1 }, - ) - }) - - it("keeps duplicate source mappings through reorder and reverse close order", () => { - const saved: RestorableSessionState = { - activeTabIndex: 0, - tabs: [ - workspace("/same", 0, { drafts: { first: "source first" } }), - workspace("/same", 1, { drafts: { second: "source second" } }), - ], - } - let preservation = createRestorableSessionPreservation(saved) - preservation = mapRestoredWorkspace(preservation, 0, "instance:first") - preservation = mapRestoredWorkspace(preservation, 1, "instance:second") - - const reordered = mergeRestorableSessionState({ - tabs: [workspace("/same", 1), workspace("/same", 0)], - activeTabIndex: 0, - }, preservation, { - currentTabIds: ["instance:second", "instance:first"], - }) - assert.equal(reordered.tabs[0]?.kind === "workspace" ? reordered.tabs[0].drafts.second : undefined, "source second") - assert.equal(reordered.tabs[1]?.kind === "workspace" ? reordered.tabs[1].drafts.first : undefined, "source first") - - preservation = markPreservedWorkspaceRemoved(preservation, { - runtimeTabId: "instance:second", - folder: "/same", - occurrence: 1, - }) - preservation = markPreservedWorkspaceRemoved(preservation, { - runtimeTabId: "instance:first", - folder: "/same", - occurrence: 0, - }) - assert.equal(mergeRestorableSessionState({ tabs: [], activeTabIndex: -1 }, preservation).tabs.length, 0) - }) - - it("preserves transient absence for mapped duplicate workspaces", () => { - const saved: RestorableSessionState = { - activeTabIndex: 0, - tabs: [workspace("/same", 0), workspace("/same", 1)], - } - let preservation = createRestorableSessionPreservation(saved) - preservation = mapRestoredWorkspace(preservation, 0, "instance:first") - preservation = mapRestoredWorkspace(preservation, 1, "instance:second") - - const merged = mergeRestorableSessionState({ tabs: [], activeTabIndex: -1 }, preservation) - assert.equal(merged.tabs.length, 2) - }) - - it("preserves an uncertain duplicate instead of advancing to the first remaining source", () => { - const saved: RestorableSessionState = { - activeTabIndex: 0, - tabs: [ - workspace("/same", 0, { drafts: { first: "first source" } }), - workspace("/same", 1, { drafts: { second: "uncertain source" } }), - ], - } - let preservation = createRestorableSessionPreservation(saved) - preservation = markPreservedWorkspaceRemoved(preservation, { - runtimeTabId: "instance:unknown-first", - folder: "/same", - occurrence: 0, - }) - preservation = markPreservedWorkspaceRemoved(preservation, { - runtimeTabId: "instance:unknown-second", - folder: "/same", - occurrence: 0, - }) - - const merged = mergeRestorableSessionState({ tabs: [], activeTabIndex: -1 }, preservation) - assert.equal(merged.tabs.length, 1) - assert.equal(merged.tabs[0]?.kind === "workspace" ? merged.tabs[0].drafts.second : undefined, "uncertain source") - }) - - it("atomically binds every matched duplicate before close events can renumber occurrences", () => { - const saved: RestorableSessionState = { - activeTabIndex: 0, - tabs: [workspace("/same", 0), workspace("/same", 1), workspace("/same", 2)], - } - const preservation = mapRestoredWorkspaces(createRestorableSessionPreservation(saved), [ - { sourceIndex: 0, runtimeTabId: "instance:first" }, - { sourceIndex: 1, runtimeTabId: "instance:middle" }, - { sourceIndex: 2, runtimeTabId: "instance:last" }, + it("keeps current active tabs while shifting around unresolved source tabs", () => { + const currentTabs = [workspace("/a"), workspace("/b")] + const mapped = restored(session(currentTabs), [ + { source: 0, runtime: "instance:a", unavailable: ["missing"] }, + { source: 1, runtime: "instance:b" }, ]) - - assert.deepEqual([...preservation.restoredWorkspaceSourceIndexes], [ - ["instance:first", 0], - ["instance:middle", 1], - ["instance:last", 2], - ]) - }) - - it("keeps the current order after restored tabs are reordered", () => { - const saved: RestorableSessionState = { - activeTabIndex: 0, - tabs: [workspace("/a", 0), workspace("/b", 0)], - } - let preservation = createRestorableSessionPreservation(saved) - preservation = markRestoredTab(preservation, 0, new Set(), "instance:a") - preservation = markRestoredTab(preservation, 1, new Set(), "instance:b") - const current: RestorableSessionState = { - activeTabIndex: 0, - tabs: [workspace("/b", 0), workspace("/a", 0)], - } - - assert.deepEqual(mergeRestorableSessionState(current, preservation, { - currentTabIds: ["instance:b", "instance:a"], - }), current) - }) - - it("keeps a new tab interleaved between restored tabs", () => { - const saved: RestorableSessionState = { - activeTabIndex: 0, - tabs: [workspace("/a", 0), workspace("/b", 0)], - } - let preservation = createRestorableSessionPreservation(saved) - preservation = markRestoredTab(preservation, 0, new Set(), "instance:a") - preservation = markRestoredTab(preservation, 1, new Set(), "instance:b") - const current: RestorableSessionState = { - activeTabIndex: 1, - tabs: [ - workspace("/a", 0), - { kind: "sidecar", sidecarId: "new" }, - workspace("/b", 0), - ], - } - - assert.deepEqual(mergeRestorableSessionState(current, preservation, { - currentTabIds: ["instance:a", "sidecar:new", "instance:b"], - }), current) - }) - - it("keeps the current active workspace after partial state preservation", () => { - const saved: RestorableSessionState = { - activeTabIndex: 0, - tabs: [ - workspace("/a", 0, { drafts: { missing: "preserve me" } }), - workspace("/b", 0), - ], - } - let preservation = createRestorableSessionPreservation(saved) - preservation = markRestoredTab(preservation, 0, new Set(["missing"]), "instance:a") - preservation = markRestoredTab(preservation, 1, new Set(), "instance:b") - const currentTabs = [workspace("/a", 0), workspace("/b", 0)] const options = { currentTabIds: ["instance:a", "instance:b"] } + assert.equal(mergeRestorableSessionState(session(currentTabs, 0), mapped, options).activeTabIndex, 0) + assert.equal(mergeRestorableSessionState(session(currentTabs, 1), mapped, options).activeTabIndex, 1) - assert.equal(mergeRestorableSessionState({ tabs: currentTabs, activeTabIndex: 0 }, preservation, options).activeTabIndex, 0) - assert.equal(mergeRestorableSessionState({ tabs: currentTabs, activeTabIndex: 1 }, preservation, options).activeTabIndex, 1) - }) - - it("does not pin future captures to an unresolved source active tab", () => { - const saved: RestorableSessionState = { - activeTabIndex: 0, - tabs: [ - { kind: "sidecar", sidecarId: "unresolved" }, - workspace("/a", 0), - workspace("/b", 0), - ], - } - let preservation = createRestorableSessionPreservation(saved) - preservation = markRestoredTab(preservation, 1, new Set(), "instance:a") - preservation = markRestoredTab(preservation, 2, new Set(), "instance:b") - const currentTabs = [workspace("/a", 0), workspace("/b", 0)] - const options = { currentTabIds: ["instance:a", "instance:b"] } - - assert.equal(mergeRestorableSessionState({ tabs: currentTabs, activeTabIndex: 0 }, preservation, options).activeTabIndex, 1) - assert.equal(mergeRestorableSessionState({ tabs: currentTabs, activeTabIndex: 1 }, preservation, options).activeTabIndex, 2) - }) - - it("retains an unresolved tab beside its nearest restored source neighbor", () => { - const saved: RestorableSessionState = { - activeTabIndex: 1, - tabs: [ - workspace("/a", 0), - { kind: "sidecar", sidecarId: "unresolved" }, - workspace("/b", 0), - ], - } - let preservation = createRestorableSessionPreservation(saved) - preservation = markRestoredTab(preservation, 0, new Set(), "instance:a") - preservation = markRestoredTab(preservation, 2, new Set(), "instance:b") - const current: RestorableSessionState = { - activeTabIndex: 1, - tabs: [workspace("/a", 0), workspace("/b", 0)], - } - - const merged = mergeRestorableSessionState(current, preservation, { - currentTabIds: ["instance:a", "instance:b"], - }) - assert.deepEqual(merged.tabs.map((tab) => tab.kind === "sidecar" ? tab.sidecarId : tab.folder), [ - "/a", - "unresolved", - "/b", + const unresolved = restored(session([sidecar("unresolved"), ...currentTabs]), [ + { source: 1, runtime: "instance:a" }, { source: 2, runtime: "instance:b" }, ]) + assert.equal(mergeRestorableSessionState(session(currentTabs, 0), unresolved, options).activeTabIndex, 1) + assert.equal(mergeRestorableSessionState(session(currentTabs, 1), unresolved, options).activeTabIndex, 2) + }) + + it("maps the saved active source tab while startup capture has no active tab", () => { + const saved = session([workspace("/a"), workspace("/b"), workspace("/c")], 2) + const preservation = restored(saved, [ + { source: 0, runtime: "instance:a", pending: true }, + { source: 1, runtime: "instance:b", pending: true }, + { source: 2, runtime: "instance:c", pending: true }, + ]) + const merged = mergeRestorableSessionState(session(saved.tabs, -1), preservation, { + currentTabIds: ["instance:a", "instance:b", "instance:c"], + }) assert.equal(merged.activeTabIndex, 2) }) - it("gives unresolved old occurrence zero a distinct occurrence from renumbered current old occurrence one", () => { - const saved: RestorableSessionState = { - activeTabIndex: 1, - tabs: [ - workspace("/same", 0, { drafts: { failed: "retry me" } }), - workspace("/same", 1, { drafts: { restored: "saved" } }), - ], - } - let preservation = createRestorableSessionPreservation(saved) - preservation = markRestoredTab(preservation, 1, new Set(), "instance:second") - const current: RestorableSessionState = { - activeTabIndex: 0, - tabs: [workspace("/same", 0, { drafts: { current: "captured" } })], - } - - const merged = mergeRestorableSessionState(current, preservation, { - currentTabIds: ["instance:second"], + it("retains saved session IDs during a sub-debounce startup flush unless selection is authoritative", () => { + const saved = session([workspace("/work", 0, { + activeParentSessionId: "saved-parent", activeSessionId: "saved-child", + })]) + const preservation = restored(saved, [{ source: 0, runtime: "instance:work", pending: true }]) + const startup = session([workspace("/work")]) + const beforeDebounce = mergeRestorableSessionState(startup, preservation, { + currentTabIds: ["instance:work"], currentTabAuthorities: [{ sessionSelection: false }], }) - assert.equal(merged.tabs.length, 2) - assert.equal(merged.tabs[0]?.kind === "workspace" ? merged.tabs[0].drafts.failed : undefined, "retry me") - assert.equal(merged.tabs[0]?.kind === "workspace" ? merged.tabs[0].occurrence : undefined, 1) - assert.equal(merged.tabs[1]?.kind === "workspace" ? merged.tabs[1].occurrence : undefined, 0) - assert.equal(merged.tabs[1]?.kind === "workspace" ? merged.tabs[1].drafts.current : undefined, "captured") - const nextLaunch = reconcileWorkspaceTabs( + assert.deepEqual( + [workspaceAt(beforeDebounce).activeParentSessionId, workspaceAt(beforeDebounce).activeSessionId], + ["saved-parent", "saved-child"], + ) + const cleared = mergeRestorableSessionState(startup, preservation, { + currentTabIds: ["instance:work"], currentTabAuthorities: [{ sessionSelection: true }], + }) + assert.deepEqual([workspaceAt(cleared).activeParentSessionId, workspaceAt(cleared).activeSessionId], [undefined, undefined]) + }) + + it("inserts an unresolved tab beside its nearest restored source neighbor", () => { + const saved = session([workspace("/a"), sidecar("unresolved"), workspace("/b")], 1) + const preservation = restored(saved, [ + { source: 0, runtime: "instance:a" }, { source: 2, runtime: "instance:b" }, + ]) + const merged = mergeRestorableSessionState(session([workspace("/a"), workspace("/b")], 1), preservation, { + currentTabIds: ["instance:a", "instance:b"], + }) + assert.deepEqual(labels(merged), ["/a:0", "unresolved", "/b:0"]) + assert.equal(merged.activeTabIndex, 2) + }) + + it("maps duplicate folders atomically and follows runtime reorder", () => { + const saved = session([ + workspace("/same", 0, { drafts: { first: "source first" } }), + workspace("/same", 1, { drafts: { second: "source second" } }), + workspace("/same", 2), + ]) + const preservation = restored(saved, [ + { source: 0, runtime: "instance:first", pending: true }, + { source: 1, runtime: "instance:second", pending: true }, + { source: 2, runtime: "instance:last", pending: true }, + ]) + assert.deepEqual(preservation.results.map((result) => result.runtimeTabId), [ + "instance:first", "instance:second", "instance:last", + ], "all duplicate bindings are recorded before close events") + const reordered = mergeRestorableSessionState( + session([workspace("/same", 1), workspace("/same", 0), workspace("/same", 2)]), + preservation, + { currentTabIds: ["instance:second", "instance:first", "instance:last"] }, + ) + assert.equal(workspaceAt(reordered, 0).drafts.second, "source second") + assert.equal(workspaceAt(reordered, 1).drafts.first, "source first") + }) + + it("tombstones duplicate folders independently in either close order", () => { + for (const order of [[0, 1], [1, 0]]) { + const saved = session([ + workspace("/same", 0, { drafts: { first: "first" } }), + workspace("/same", 1, { drafts: { second: "second" } }), + ]) + const preservation = restored(saved, [ + { source: 0, runtime: "instance:first", pending: true }, + { source: 1, runtime: "instance:second", pending: true }, + ]) + for (const source of order) markPreservedWorkspaceRemoved(preservation, { + runtimeTabId: `instance:${source ? "second" : "first"}`, folder: "/same", occurrence: source, + }) + assert.equal(mergeRestorableSessionState(empty(), preservation).tabs.length, 0, `close order ${order.join(",")}`) + } + }) + + it("retains mapped duplicates during transient absence and only the uncertain remaining source", () => { + const saved = session([ + workspace("/same", 0, { drafts: { first: "first source" } }), + workspace("/same", 1, { drafts: { second: "uncertain source" } }), + ]) + const absent = restored(saved, [ + { source: 0, runtime: "instance:first", pending: true }, + { source: 1, runtime: "instance:second", pending: true }, + ]) + assert.equal(mergeRestorableSessionState(empty(), absent).tabs.length, 2) + + const uncertain = createRestorableSessionPreservation(saved) + markPreservedWorkspaceRemoved(uncertain, { runtimeTabId: "unknown:first", folder: "/same", occurrence: 0 }) + markPreservedWorkspaceRemoved(uncertain, { runtimeTabId: "unknown:second", folder: "/same", occurrence: 0 }) + const merged = mergeRestorableSessionState(empty(), uncertain) + assert.equal(merged.tabs.length, 1) + assert.equal(workspaceAt(merged).drafts.second, "uncertain source") + }) + + it("does not relaunch a closed duplicate after occurrences renumber", () => { + const saved = session([ + workspace("/same", 0), workspace("/same", 1, { drafts: { missing: "preserve" } }), + ], 1) + const preservation = restored(saved, [ + { source: 0, runtime: "instance:first" }, + { source: 1, runtime: "instance:second", unavailable: ["missing"] }, + ]) + const merged = mergeRestorableSessionState( + session([workspace("/same", 0, { drafts: { current: "captured" } })]), + preservation, + { currentTabIds: ["instance:second"] }, + ) + assert.equal(merged.tabs.length, 1) + assert.deepEqual(workspaceAt(merged).drafts, { missing: "preserve", current: "captured" }) + assert.deepEqual(reconcileWorkspaceTabs( + [{ kind: "workspace", folderPath: "/same", occurrence: 0 }], + [{ id: "second", folderPath: "/same" }], + ).map((match) => match.existingWorkspaceId), ["second"]) + }) + + it("assigns unresolved duplicate a distinct occurrence and reconciliation slot", () => { + const saved = session([ + workspace("/same", 0, { drafts: { failed: "retry" } }), + workspace("/same", 1, { drafts: { restored: "saved" } }), + ], 1) + const merged = mergeRestorableSessionState( + session([workspace("/same", 0, { drafts: { current: "captured" } })]), + restored(saved, [{ source: 1, runtime: "instance:second" }]), + { currentTabIds: ["instance:second"] }, + ) + assert.deepEqual(labels(merged), ["/same:1", "/same:0"]) + assert.equal(workspaceAt(merged, 0).drafts.failed, "retry") + const matches = reconcileWorkspaceTabs( merged.tabs.map((tab) => tab.kind === "workspace" - ? { kind: tab.kind, folderPath: tab.folder, occurrence: tab.occurrence } - : { kind: tab.kind }), + ? { kind: "workspace", folderPath: tab.folder, occurrence: tab.occurrence } + : { kind: "sidecar" }), [{ id: "second", folderPath: "/same" }], ) - assert.deepEqual(nextLaunch.map((match) => match.existingWorkspaceId), [null, "second"]) - assert.equal(nextLaunch.filter((match) => !match.existingWorkspaceId).length, 1) + assert.deepEqual(matches.map((match) => match.existingWorkspaceId), [null, "second"]) + assert.equal(matches.filter((match) => !match.existingWorkspaceId).length, 1) }) }) diff --git a/packages/ui/src/stores/app-session-snapshot-merge.ts b/packages/ui/src/stores/app-session-snapshot-merge.ts index e2e6ae33..6df31891 100644 --- a/packages/ui/src/stores/app-session-snapshot-merge.ts +++ b/packages/ui/src/stores/app-session-snapshot-merge.ts @@ -4,19 +4,17 @@ import type { RestorableWorkspaceTabState, } from "./client-state-codec" import { normalizeWorkspacePath } from "./app-session-reconciliation" - -export type PreservedTabState = - | { mode: "whole"; tab: RestorableTabState } - | { mode: "workspace-state"; tab: RestorableWorkspaceTabState } - -export interface RestorableSessionPreservation { - sourceTabs: readonly RestorableTabState[] - preservedTabs: readonly (PreservedTabState | null)[] - restoredTabIds: readonly (string | null)[] - restoredWorkspaceSourceIndexes: ReadonlyMap - removedWholeTabIndexes: ReadonlySet +export interface RestoreTabResult { + status: "pending" | "restored" | "removed" + runtimeTabId?: string | null + unavailableSessionIds?: ReadonlySet +} +export interface RestorableSessionPreservation { + sourceTabs: RestorableTabState[] + activeTabIndex: number + results: RestoreTabResult[] + removalRevisions: number[] } - export interface RestorableWorkspaceRuntimeAuthority { drafts?: ReadonlySet attachments?: ReadonlySet @@ -26,211 +24,154 @@ export interface RestorableWorkspaceRuntimeAuthority { deletedSessions?: ReadonlySet sessionSelection?: boolean } - -export interface RestoredWorkspaceMapping { - sourceIndex: number - runtimeTabId: string +interface TabIdentity { + key: string + occurrence: number + value: string +} +function mapTabIdentities(tabs: readonly RestorableTabState[]): TabIdentity[] { + const nextOccurrences = new Map() + return tabs.map((tab) => { + const key = tab.kind === "workspace" + ? `workspace:${normalizeWorkspacePath(tab.folder)}` + : `sidecar:${tab.sidecarId}` + const inferred = nextOccurrences.get(key) ?? 0 + const occurrence = tab.kind === "workspace" ? tab.occurrence ?? inferred : inferred + nextOccurrences.set(key, Math.max(inferred, occurrence) + 1) + return { key, occurrence, value: `${key}:${occurrence}` } + }) } - export function createRestorableSessionPreservation( snapshot: RestorableSessionState, ): RestorableSessionPreservation { return { - sourceTabs: snapshot.tabs, - preservedTabs: snapshot.tabs.map((tab) => ({ mode: "whole", tab })), - restoredTabIds: snapshot.tabs.map(() => null), - restoredWorkspaceSourceIndexes: new Map(), - removedWholeTabIndexes: new Set(), + sourceTabs: [...snapshot.tabs], + activeTabIndex: snapshot.activeTabIndex, + results: snapshot.tabs.map(() => ({ status: "pending" })), + removalRevisions: snapshot.tabs.map(() => 0), } } - -export function mapRestoredWorkspace( +export function createRestoredTabCommitGuard( preservation: RestorableSessionPreservation, sourceIndex: number, - runtimeTabId: string, -): RestorableSessionPreservation { - return mapRestoredWorkspaces(preservation, [{ sourceIndex, runtimeTabId }]) +): () => boolean { + const removalRevision = preservation.removalRevisions[sourceIndex] + return () => preservation.removalRevisions[sourceIndex] === removalRevision } - -export function mapRestoredWorkspaces( +export function recordRestoredTab( preservation: RestorableSessionPreservation, - mappings: readonly RestoredWorkspaceMapping[], -): RestorableSessionPreservation { - const restoredWorkspaceSourceIndexes = new Map(preservation.restoredWorkspaceSourceIndexes) - let changed = false - for (const { sourceIndex, runtimeTabId } of mappings) { - if (!runtimeTabId || preservation.sourceTabs[sourceIndex]?.kind !== "workspace") continue - for (const [mappedRuntimeTabId, mappedSourceIndex] of restoredWorkspaceSourceIndexes) { - if (mappedRuntimeTabId === runtimeTabId || mappedSourceIndex === sourceIndex) { - restoredWorkspaceSourceIndexes.delete(mappedRuntimeTabId) - } - } - restoredWorkspaceSourceIndexes.set(runtimeTabId, sourceIndex) - changed = true - } - return changed ? { ...preservation, restoredWorkspaceSourceIndexes } : preservation + sourceIndex: number, + runtimeTabId: string | null, + unavailableSessionIds?: ReadonlySet, +): void { + if (!preservation.sourceTabs[sourceIndex]) return + preservation.results[sourceIndex] = unavailableSessionIds + ? { status: "restored", runtimeTabId, unavailableSessionIds } + : { status: "pending", ...(runtimeTabId ? { runtimeTabId } : {}) } } - -export function unmapRestoredWorkspace( +export function hasRestoredTabBinding( preservation: RestorableSessionPreservation, - runtimeTabId: string, -): RestorableSessionPreservation { - const restoredTabIndex = preservation.restoredTabIds.findIndex((tabId) => tabId === runtimeTabId) - if (!preservation.restoredWorkspaceSourceIndexes.has(runtimeTabId) && restoredTabIndex < 0) return preservation - const restoredWorkspaceSourceIndexes = new Map(preservation.restoredWorkspaceSourceIndexes) - restoredWorkspaceSourceIndexes.delete(runtimeTabId) - const restoredTabIds = [...preservation.restoredTabIds] - if (restoredTabIndex >= 0) restoredTabIds[restoredTabIndex] = null - return { ...preservation, restoredTabIds, restoredWorkspaceSourceIndexes } + sourceIndex: number, + expectedRuntimeTabId: string, +): boolean { + const result = preservation.results[sourceIndex] + return Boolean(result?.status === "pending" && result.runtimeTabId === expectedRuntimeTabId) +} +export function settleRestoredTab( + preservation: RestorableSessionPreservation, + sourceIndex: number, + expectedRuntimeTabId: string, + runtimeTabId: string | null, + unavailableSessionIds?: ReadonlySet, +): boolean { + if (!hasRestoredTabBinding(preservation, sourceIndex, expectedRuntimeTabId)) return false + recordRestoredTab(preservation, sourceIndex, runtimeTabId, unavailableSessionIds) + return true } - function findWorkspaceSourceIndex( preservation: RestorableSessionPreservation, workspace: { runtimeTabId: string; folder: string; occurrence: number }, ): number | undefined { - const mappedSourceIndex = preservation.restoredWorkspaceSourceIndexes.get(workspace.runtimeTabId) - if (mappedSourceIndex !== undefined) return mappedSourceIndex - - const runtimeIndex = preservation.restoredTabIds.findIndex((tabId) => tabId === workspace.runtimeTabId) + const runtimeIndex = preservation.results.findIndex((result) => result.runtimeTabId === workspace.runtimeTabId) if (runtimeIndex >= 0) return runtimeIndex - - const sourceOccurrences = resolveWorkspaceOccurrences(preservation.sourceTabs) - const folder = normalizeWorkspacePath(workspace.folder) - const identityIndex = preservation.sourceTabs.findIndex((tab, index) => - tab.kind === "workspace" - && normalizeWorkspacePath(tab.folder) === folder - && sourceOccurrences[index] === workspace.occurrence, - ) - return identityIndex >= 0 ? identityIndex : undefined + const identity = `workspace:${normalizeWorkspacePath(workspace.folder)}:${workspace.occurrence}` + const index = mapTabIdentities(preservation.sourceTabs).findIndex((candidate) => candidate.value === identity) + return index >= 0 ? index : undefined +} +export function getPreservedWorkspaceState( + preservation: RestorableSessionPreservation, + workspace: { runtimeTabId: string; folder: string; occurrence: number }, +): RestorableWorkspaceTabState | null { + const index = preservation.results.findIndex((result) => result.runtimeTabId === workspace.runtimeTabId) + const source = preservation.sourceTabs[index] + return source?.kind === "workspace" ? source : null +} +export function getPreservedWorkspaceReopenTarget( + preservation: RestorableSessionPreservation, + workspace: { runtimeTabId: string; folder: string; occurrence: number }, +): { sourceIndex: number; snapshot: RestorableWorkspaceTabState } | null { + const sourceIndex = findWorkspaceSourceIndex(preservation, workspace) + if (sourceIndex === undefined) return null + const result = preservation.results[sourceIndex] + const source = preservation.sourceTabs[sourceIndex] + if ((!result?.runtimeTabId && result?.status !== "removed") || source?.kind !== "workspace") return null + return { sourceIndex, snapshot: source } } - export function markPreservedWorkspaceRemoved( preservation: RestorableSessionPreservation, workspace: { runtimeTabId: string; folder: string; occurrence: number }, ): RestorableSessionPreservation { - const sourceIndex = findWorkspaceSourceIndex(preservation, workspace) - const nextPreservation = unmapRestoredWorkspace(preservation, workspace.runtimeTabId) - if (sourceIndex === undefined || nextPreservation.preservedTabs[sourceIndex]?.mode !== "whole") { - return nextPreservation + const index = findWorkspaceSourceIndex(preservation, workspace) + if (index !== undefined && preservation.results[index]?.status === "pending") { + preservation.results[index] = { status: "removed" } + preservation.removalRevisions[index] = (preservation.removalRevisions[index] ?? 0) + 1 } - const removedWholeTabIndexes = new Set(nextPreservation.removedWholeTabIndexes) - removedWholeTabIndexes.add(sourceIndex) - return { ...nextPreservation, removedWholeTabIndexes } + return preservation } - export function markPreservedWorkspaceReopened( preservation: RestorableSessionPreservation, workspace: { runtimeTabId: string; folder: string; occurrence: number }, ): RestorableSessionPreservation { - const sourceIndex = findWorkspaceSourceIndex(preservation, workspace) - const nextPreservation = unmapRestoredWorkspace(preservation, workspace.runtimeTabId) - if (sourceIndex === undefined || !nextPreservation.removedWholeTabIndexes.has(sourceIndex)) { - return nextPreservation + const index = findWorkspaceSourceIndex(preservation, workspace) + if (index === undefined) return preservation + const result = preservation.results[index] + preservation.results[index] = result?.status === "removed" + ? { status: "pending", runtimeTabId: workspace.runtimeTabId } + : { ...result, status: "pending", runtimeTabId: workspace.runtimeTabId } + return preservation +} +function getPreservedTab(source: RestorableTabState, result: RestoreTabResult): RestorableTabState | null { + const unavailable = result.unavailableSessionIds + if (result.status === "pending" && !unavailable) return source + if ((result.status !== "restored" && result.status !== "pending") || source.kind !== "workspace" || !unavailable?.size) return null + const keep = (record: Record) => Object.fromEntries( + Object.entries(record).filter(([id]) => unavailable.has(id)), + ) + const tab: RestorableWorkspaceTabState = { + kind: "workspace", + folder: source.folder, + drafts: keep(source.drafts), + attachments: keep(source.attachments), + scrollSnapshots: keep(source.scrollSnapshots), + unseenIdleSince: keep(source.unseenIdleSince), + generationRecovery: keep(source.generationRecovery), } - const removedWholeTabIndexes = new Set(nextPreservation.removedWholeTabIndexes) - removedWholeTabIndexes.delete(sourceIndex) - return { ...nextPreservation, removedWholeTabIndexes } -} - -export function markRestoredTab( - preservation: RestorableSessionPreservation, - tabIndex: number, - unavailableSessionIds: ReadonlySet = new Set(), - restoredTabId: string | null = null, -): RestorableSessionPreservation { - const source = preservation.sourceTabs[tabIndex] - if (!source) return preservation - - let nextEntry: PreservedTabState | null = null - if (source.kind === "workspace" && unavailableSessionIds.size > 0) { - const drafts = Object.fromEntries( - Object.entries(source.drafts).filter(([sessionId]) => unavailableSessionIds.has(sessionId)), - ) - const attachments = Object.fromEntries( - Object.entries(source.attachments).filter(([sessionId]) => unavailableSessionIds.has(sessionId)), - ) - const scrollSnapshots = Object.fromEntries( - Object.entries(source.scrollSnapshots).filter(([sessionId]) => unavailableSessionIds.has(sessionId)), - ) - const unseenIdleSince = Object.fromEntries( - Object.entries(source.unseenIdleSince).filter(([sessionId]) => unavailableSessionIds.has(sessionId)), - ) - const generationRecovery = Object.fromEntries( - Object.entries(source.generationRecovery).filter(([sessionId]) => unavailableSessionIds.has(sessionId)), - ) - const tab: RestorableWorkspaceTabState = { - kind: "workspace", - folder: source.folder, - drafts, - attachments, - scrollSnapshots, - unseenIdleSince, - generationRecovery, - } - if (source.occurrence !== undefined) tab.occurrence = source.occurrence - if (unavailableSessionIds.has(source.activeParentSessionId ?? "")) { - tab.activeParentSessionId = source.activeParentSessionId - } - if (unavailableSessionIds.has(source.activeSessionId ?? "")) { - tab.activeSessionId = source.activeSessionId - } - nextEntry = { mode: "workspace-state", tab } + if (source.occurrence !== undefined) tab.occurrence = source.occurrence + if (source.activeParentSessionId && unavailable.has(source.activeParentSessionId)) { + tab.activeParentSessionId = source.activeParentSessionId } - - const preservedTabs = [...preservation.preservedTabs] - const restoredTabIds = [...preservation.restoredTabIds] - preservedTabs[tabIndex] = nextEntry - restoredTabIds[tabIndex] = restoredTabId - const nextPreservation = { ...preservation, preservedTabs, restoredTabIds } - return source.kind === "workspace" && restoredTabId - ? mapRestoredWorkspace(nextPreservation, tabIndex, restoredTabId) - : nextPreservation + if (source.activeSessionId && unavailable.has(source.activeSessionId)) tab.activeSessionId = source.activeSessionId + return tab } - -function resolveWorkspaceOccurrences(tabs: readonly RestorableTabState[]): Array { - const workspaceOccurrences = new Map() - return tabs.map((tab) => { - if (tab.kind !== "workspace") return undefined - - const path = normalizeWorkspacePath(tab.folder) - const inferredOccurrence = workspaceOccurrences.get(path) ?? 0 - const occurrence = tab.occurrence ?? inferredOccurrence - workspaceOccurrences.set(path, Math.max(inferredOccurrence, occurrence) + 1) - return occurrence - }) -} - -function buildTabIdentities( - tabs: readonly RestorableTabState[], - workspaceOccurrences: readonly (number | undefined)[], -): string[] { - const sidecarOccurrences = new Map() - return tabs.map((tab, index) => { - if (tab.kind === "sidecar") { - const occurrence = sidecarOccurrences.get(tab.sidecarId) ?? 0 - sidecarOccurrences.set(tab.sidecarId, occurrence + 1) - return `sidecar:${tab.sidecarId}:${occurrence}` - } - - const path = normalizeWorkspacePath(tab.folder) - return `workspace:${path}:${workspaceOccurrences[index]}` - }) -} - function mergeWorkspaceState( current: RestorableWorkspaceTabState, preserved: RestorableWorkspaceTabState, authority: RestorableWorkspaceRuntimeAuthority = {}, ): RestorableWorkspaceTabState { - const mergeRecords = ( - currentRecord: Record, - preservedRecord: Record, - authoritativeIds: ReadonlySet | undefined, - ): Record => { - const fallback = { ...preservedRecord } - for (const sessionId of authoritativeIds ?? []) delete fallback[sessionId] - for (const sessionId of authority.deletedSessions ?? []) delete fallback[sessionId] - return { ...fallback, ...currentRecord } + const mergeRecords = (currentRecord: Record, fallback: Record, owned?: ReadonlySet) => { + const preservedRecord = { ...fallback } + for (const id of [...(owned ?? []), ...(authority.deletedSessions ?? [])]) delete preservedRecord[id] + return { ...preservedRecord, ...currentRecord } } const result: RestorableWorkspaceTabState = { ...current, @@ -238,23 +179,26 @@ function mergeWorkspaceState( attachments: mergeRecords(current.attachments, preserved.attachments, authority.attachments), scrollSnapshots: mergeRecords(current.scrollSnapshots, preserved.scrollSnapshots, authority.scrollSnapshots), unseenIdleSince: mergeRecords(current.unseenIdleSince, preserved.unseenIdleSince, authority.idleMarkers), - generationRecovery: mergeRecords( - current.generationRecovery, - preserved.generationRecovery, - authority.generationRecovery, - ), + generationRecovery: mergeRecords(current.generationRecovery, preserved.generationRecovery, authority.generationRecovery), } - if (!authority.sessionSelection && !current.activeParentSessionId && !current.activeSessionId) { - if (preserved.activeParentSessionId && !authority.deletedSessions?.has(preserved.activeParentSessionId)) { - result.activeParentSessionId = preserved.activeParentSessionId - } - if (preserved.activeSessionId && !authority.deletedSessions?.has(preserved.activeSessionId)) { - result.activeSessionId = preserved.activeSessionId - } + const restoreSelection = !authority.sessionSelection && !current.activeParentSessionId && !current.activeSessionId + if (restoreSelection && preserved.activeParentSessionId && !authority.deletedSessions?.has(preserved.activeParentSessionId)) { + result.activeParentSessionId = preserved.activeParentSessionId + } + if (restoreSelection && preserved.activeSessionId && !authority.deletedSessions?.has(preserved.activeSessionId)) { + result.activeSessionId = preserved.activeSessionId } return result } - +function nearestInsertionSlot(sourceIndex: number, matches: readonly (number | undefined)[], fallback: number): number { + for (let distance = 1; distance < matches.length; distance += 1) { + const before = sourceIndex - distance + const after = sourceIndex + distance + if (before >= 0 && matches[before] !== undefined) return matches[before]! + 1 + if (after < matches.length && matches[after] !== undefined) return matches[after]! + } + return fallback +} export function mergeRestorableSessionState( current: RestorableSessionState, preservation: RestorableSessionPreservation | null, @@ -264,139 +208,93 @@ export function mergeRestorableSessionState( } = {}, ): RestorableSessionState { if (!preservation) return current - - const currentWorkspaceOccurrences = resolveWorkspaceOccurrences(current.tabs) - const sourceWorkspaceOccurrences = resolveWorkspaceOccurrences(preservation.sourceTabs) - const currentIdentities = buildTabIdentities(current.tabs, currentWorkspaceOccurrences) - const sourceIdentities = buildTabIdentities(preservation.sourceTabs, sourceWorkspaceOccurrences) - const currentIndexesByIdentity = new Map() - currentIdentities.forEach((identity, index) => { - const indexes = currentIndexesByIdentity.get(identity) ?? [] - indexes.push(index) - currentIndexesByIdentity.set(identity, indexes) + const currentIdentities = mapTabIdentities(current.tabs) + const sourceIdentities = mapTabIdentities(preservation.sourceTabs) + const indexesByIdentity = new Map() + currentIdentities.forEach(({ value }, index) => { + indexesByIdentity.set(value, [...(indexesByIdentity.get(value) ?? []), index]) }) - - const currentIndexByRuntimeId = new Map( - (options.currentTabIds ?? []).map((tabId, index) => [tabId, index]), - ) - const sourceCurrentIndexes: Array = preservation.sourceTabs.map(() => undefined) - const claimedCurrentIndexes = new Set() - preservation.restoredTabIds.forEach((tabId, sourceIndex) => { - const currentIndex = tabId ? currentIndexByRuntimeId.get(tabId) : undefined - if (currentIndex === undefined || !current.tabs[currentIndex] || claimedCurrentIndexes.has(currentIndex)) return - sourceCurrentIndexes[sourceIndex] = currentIndex - claimedCurrentIndexes.add(currentIndex) - }) - preservation.restoredWorkspaceSourceIndexes.forEach((sourceIndex, runtimeTabId) => { - const currentIndex = currentIndexByRuntimeId.get(runtimeTabId) - if (currentIndex === undefined || !current.tabs[currentIndex] || claimedCurrentIndexes.has(currentIndex)) return - sourceCurrentIndexes[sourceIndex] = currentIndex - claimedCurrentIndexes.add(currentIndex) - }) - - preservation.sourceTabs.forEach((_sourceTab, sourceIndex) => { - if (sourceCurrentIndexes[sourceIndex] !== undefined) return - const preserved = preservation.removedWholeTabIndexes.has(sourceIndex) - ? null - : preservation.preservedTabs[sourceIndex] - const canMatchByIdentity = preserved?.mode === "whole" || options.currentTabIds === undefined - if (!canMatchByIdentity) return - - const identityMatches = currentIndexesByIdentity.get(sourceIdentities[sourceIndex] ?? "") - while (identityMatches?.length) { - const candidate = identityMatches.shift()! - if (claimedCurrentIndexes.has(candidate)) continue - sourceCurrentIndexes[sourceIndex] = candidate - claimedCurrentIndexes.add(candidate) - break - } - }) - - const currentTabs = [...current.tabs] - preservation.sourceTabs.forEach((_sourceTab, sourceIndex) => { - const currentIndex = sourceCurrentIndexes[sourceIndex] - const preserved = preservation.removedWholeTabIndexes.has(sourceIndex) - ? null - : preservation.preservedTabs[sourceIndex] - if (currentIndex === undefined || !preserved) return - - const currentTab = currentTabs[currentIndex] - if (currentTab?.kind !== "workspace") return - const authority = options.currentTabAuthorities?.[currentIndex] - if (preserved.mode === "workspace-state") { - currentTabs[currentIndex] = mergeWorkspaceState(currentTab, preserved.tab, authority) - } else if (preserved.tab.kind === "workspace") { - currentTabs[currentIndex] = mergeWorkspaceState(currentTab, preserved.tab, authority) - } - }) - - const insertionsByCurrentSlot = new Map() - const usedWorkspaceOccurrences = new Map>() - currentTabs.forEach((tab, index) => { - if (tab.kind !== "workspace") return - const path = normalizeWorkspacePath(tab.folder) - const used = usedWorkspaceOccurrences.get(path) ?? new Set() - used.add(currentWorkspaceOccurrences[index]!) - usedWorkspaceOccurrences.set(path, used) - }) - preservation.sourceTabs.forEach((sourceTab, sourceIndex) => { - const preserved = preservation.removedWholeTabIndexes.has(sourceIndex) - ? null - : preservation.preservedTabs[sourceIndex] - if (sourceCurrentIndexes[sourceIndex] !== undefined || preserved?.mode !== "whole") return - - let nearestMappedSourceIndex: number | undefined - for (let distance = 1; distance < preservation.sourceTabs.length; distance += 1) { - const previousSourceIndex = sourceIndex - distance - if (previousSourceIndex >= 0 && sourceCurrentIndexes[previousSourceIndex] !== undefined) { - nearestMappedSourceIndex = previousSourceIndex - break - } - const nextSourceIndex = sourceIndex + distance - if (nextSourceIndex < preservation.sourceTabs.length && sourceCurrentIndexes[nextSourceIndex] !== undefined) { - nearestMappedSourceIndex = nextSourceIndex - break - } - } - - const nearestCurrentIndex = nearestMappedSourceIndex === undefined - ? undefined - : sourceCurrentIndexes[nearestMappedSourceIndex] - const slot = nearestCurrentIndex === undefined - ? currentTabs.length - : nearestCurrentIndex + (nearestMappedSourceIndex! < sourceIndex ? 1 : 0) - const insertions = insertionsByCurrentSlot.get(slot) ?? [] - let tab = sourceTab - if (sourceTab.kind === "workspace") { - const path = normalizeWorkspacePath(sourceTab.folder) - const used = usedWorkspaceOccurrences.get(path) ?? new Set() - let occurrence = sourceWorkspaceOccurrences[sourceIndex] ?? 0 - if (used.has(occurrence)) { - occurrence = 0 - while (used.has(occurrence)) occurrence += 1 - } - used.add(occurrence) - usedWorkspaceOccurrences.set(path, used) - tab = { ...sourceTab, occurrence } - } - insertions.push(tab) - insertionsByCurrentSlot.set(slot, insertions) - }) - - const currentOutputIndexes = new Map() - const tabs: RestorableTabState[] = [] - - for (let slot = 0; slot <= currentTabs.length; slot += 1) { - for (const insertion of insertionsByCurrentSlot.get(slot) ?? []) { - tabs.push(insertion) - } - const currentTab = currentTabs[slot] - if (!currentTab) continue - currentOutputIndexes.set(slot, tabs.length) - tabs.push(currentTab) + const indexesByRuntimeId = new Map((options.currentTabIds ?? []).map((id, index) => [id, index])) + const matches: Array = preservation.sourceTabs.map(() => undefined) + const claimed = new Set() + const claim = (sourceIndex: number, currentIndex: number | undefined) => { + if (currentIndex === undefined || !current.tabs[currentIndex] || claimed.has(currentIndex)) return + matches[sourceIndex] = currentIndex + claimed.add(currentIndex) + } + preservation.results.forEach((result, index) => { + if (result.runtimeTabId) claim(index, indexesByRuntimeId.get(result.runtimeTabId)) + }) + preservation.results.forEach((result, index) => { + if (matches[index] !== undefined || (result.status !== "pending" && options.currentTabIds)) return + claim(index, indexesByIdentity.get(sourceIdentities[index]!.value)?.find((candidate) => !claimed.has(candidate))) + }) + const currentTabs = [...current.tabs] + preservation.sourceTabs.forEach((source, index) => { + const currentIndex = matches[index] + const target = currentIndex === undefined ? undefined : currentTabs[currentIndex] + const fallback = getPreservedTab(source, preservation.results[index]!) + if (target?.kind === "workspace" && fallback?.kind === "workspace") { + currentTabs[currentIndex!] = mergeWorkspaceState(target, fallback, options.currentTabAuthorities?.[currentIndex!]) + } + }) + const insertions = new Map>() + const usedOccurrences = new Map>() + currentIdentities.forEach(({ key, occurrence }) => { + const used = usedOccurrences.get(key) ?? new Set() + used.add(occurrence) + usedOccurrences.set(key, used) + }) + preservation.sourceTabs.forEach((source, index) => { + if (matches[index] !== undefined || preservation.results[index]?.status !== "pending") return + const slot = nearestInsertionSlot(index, matches, currentTabs.length) + let tab = source + if (source.kind === "workspace") { + const { key, occurrence: sourceOccurrence } = sourceIdentities[index]! + const used = usedOccurrences.get(key) ?? new Set() + let occurrence = sourceOccurrence + while (used.has(occurrence)) occurrence += 1 + used.add(occurrence) + usedOccurrences.set(key, used) + tab = { ...source, occurrence } + } + insertions.set(slot, [...(insertions.get(slot) ?? []), { sourceIndex: index, tab }]) + }) + const outputIndexes = new Map() + const sourceOutputIndexes = new Map() + const tabs: RestorableTabState[] = [] + for (let slot = 0; slot <= currentTabs.length; slot += 1) { + for (const insertion of insertions.get(slot) ?? []) { + sourceOutputIndexes.set(insertion.sourceIndex, tabs.length) + tabs.push(insertion.tab) + } + if (!currentTabs[slot]) continue + outputIndexes.set(slot, tabs.length) + const sourceIndex = matches.findIndex((currentIndex) => currentIndex === slot) + if (sourceIndex >= 0) sourceOutputIndexes.set(sourceIndex, tabs.length) + tabs.push(currentTabs[slot]!) + } + const activeTabIndex = outputIndexes.get(current.activeTabIndex) + ?? sourceOutputIndexes.get(preservation.activeTabIndex) + ?? (tabs.length ? 0 : -1) + return { + tabs, + activeTabIndex, + ...(current.homeActive === true ? { homeActive: true } : {}), } - - const currentActiveIndex = currentOutputIndexes.get(current.activeTabIndex) - const activeTabIndex = currentActiveIndex ?? (tabs.length > 0 ? 0 : -1) - return { tabs, activeTabIndex } +} +export function markPreservedWorkspaceUnavailable( + preservation: RestorableSessionPreservation, + workspace: { runtimeTabId: string; folder: string; occurrence: number }, + current?: RestorableWorkspaceTabState, + authority?: RestorableWorkspaceRuntimeAuthority, +): RestorableSessionPreservation { + const index = findWorkspaceSourceIndex(preservation, workspace) + if (index === undefined) return preservation + const source = preservation.sourceTabs[index] + if (current) preservation.sourceTabs[index] = source?.kind === "workspace" + ? mergeWorkspaceState(current, source, authority) + : current + preservation.results[index] = { status: "pending", runtimeTabId: workspace.runtimeTabId } + return preservation } diff --git a/packages/ui/src/stores/app-session-workspace-hydration.ts b/packages/ui/src/stores/app-session-workspace-hydration.ts new file mode 100644 index 00000000..b5a6be37 --- /dev/null +++ b/packages/ui/src/stores/app-session-workspace-hydration.ts @@ -0,0 +1,42 @@ +import type { RestorableWorkspaceTabState } from "./client-state-codec" +import { getUnavailableRestoredSessionIds, resolveRestoredSessionSelection } from "./app-session-reconciliation" +import { getAbortReason } from "./app-session-restore-timeout" +import { hydrateWorkspacePromptState } from "./app-session-prompt-hydration" +import { messageStoreBus, type MessageScrollSnapshotSeed } from "./message-v2/bus" +import { + getSessions, hasAuthoritativeSessionSelection, hydrateActiveSessionSelection, + hydrateRestoredSessionChain, hydrateSessionGenerationRecovery, hydrateSessionIdleMarkers, +} from "./sessions" + +const MESSAGE_SCROLL_SCOPE = "message-stream" +export const NO_SESSION_DRAFT_SESSION_ID = "__no_session_draft__" + +export async function hydrateRestoredWorkspaceState( + instanceId: string, + snapshot: RestorableWorkspaceTabState, + signal: AbortSignal, + isCurrentBinding: () => boolean, +): Promise | null> { + await hydrateRestoredSessionChain(instanceId, [snapshot.activeParentSessionId, snapshot.activeSessionId], signal) + if (signal.aborted) throw getAbortReason(signal) + if (!isCurrentBinding()) return null + const sessions = getSessions(instanceId) + const validIds = new Set(sessions.map(({ id }) => id)) + const unavailable = getUnavailableRestoredSessionIds(sessions, { + activeParentSessionId: snapshot.activeParentSessionId, activeSessionId: snapshot.activeSessionId, + draftSessionIds: Object.keys(snapshot.drafts), attachmentSessionIds: Object.keys(snapshot.attachments), + scrollSessionIds: Object.keys(snapshot.scrollSnapshots), idleMarkerSessionIds: Object.keys(snapshot.unseenIdleSince), + generationRecoverySessionIds: Object.keys(snapshot.generationRecovery), + }, [NO_SESSION_DRAFT_SESSION_ID]) + hydrateWorkspacePromptState(instanceId, snapshot, validIds, NO_SESSION_DRAFT_SESSION_ID) + hydrateSessionIdleMarkers(instanceId, snapshot.unseenIdleSince) + hydrateSessionGenerationRecovery(instanceId, snapshot.generationRecovery) + const scrollSeeds: MessageScrollSnapshotSeed[] = Object.entries(snapshot.scrollSnapshots) + .map(([sessionId, scrollSnapshot]) => ({ sessionId, scope: MESSAGE_SCROLL_SCOPE, snapshot: scrollSnapshot })) + messageStoreBus.seedScrollSnapshots(instanceId, scrollSeeds) + if (!hasAuthoritativeSessionSelection(instanceId)) { + const selection = resolveRestoredSessionSelection(sessions, snapshot.activeParentSessionId, snapshot.activeSessionId) + hydrateActiveSessionSelection(instanceId, selection?.parentSessionId ?? null, selection?.activeSessionId ?? null) + } + return unavailable +} diff --git a/packages/ui/src/stores/attachments.ts b/packages/ui/src/stores/attachments.ts index 63d9e262..41d695d2 100644 --- a/packages/ui/src/stores/attachments.ts +++ b/packages/ui/src/stores/attachments.ts @@ -1,139 +1,98 @@ import { createSignal } from "solid-js" import type { Attachment } from "../types/attachment" -const [attachments, setAttachments] = createSignal>(new Map()) -const [authoritativeAttachmentKeys, setAuthoritativeAttachmentKeys] = createSignal>(new Set()) +interface SessionAttachmentState { + values: Attachment[] + authoritative: boolean +} -function getSessionKey(instanceId: string, sessionId: string): string { - return `${instanceId}:${sessionId}` +const [attachments, setAttachments] = createSignal>(new Map()) + +const getSessionKey = (instanceId: string, sessionId: string) => `${instanceId}:${sessionId}` + +function setSession(instanceId: string, sessionId: string, state?: SessionAttachmentState) { + const key = getSessionKey(instanceId, sessionId) + setAttachments((previous) => { + const next = new Map(previous) + if (state) next.set(key, state) + else next.delete(key) + return next + }) } function getAttachments(instanceId: string, sessionId: string): Attachment[] { - const key = getSessionKey(instanceId, sessionId) - return attachments().get(key) || [] + return attachments().get(getSessionKey(instanceId, sessionId))?.values ?? [] +} + +function getInstanceEntries(instanceId: string) { + const prefix = `${instanceId}:` + return [...attachments()].filter(([key]) => key.startsWith(prefix)) + .map(([key, state]) => [key.slice(prefix.length), state] as const) } function getSessionAttachmentsForInstance(instanceId: string): Record { if (!instanceId) return {} - const prefix = `${instanceId}:` - const result: Record = {} - for (const [key, value] of attachments()) { - if (!key.startsWith(prefix) || value.length === 0) continue - result[key.slice(prefix.length)] = [...value] - } - return result + return Object.fromEntries( + getInstanceEntries(instanceId) + .filter(([, state]) => state.values.length > 0) + .map(([sessionId, state]) => [sessionId, [...state.values]]), + ) } function getAuthoritativeAttachmentSessionIdsForInstance(instanceId: string): ReadonlySet { if (!instanceId) return new Set() - const prefix = `${instanceId}:` return new Set( - [...authoritativeAttachmentKeys()] - .filter((key) => key.startsWith(prefix)) - .map((key) => key.slice(prefix.length)), + getInstanceEntries(instanceId) + .filter(([, state]) => state.authoritative) + .map(([sessionId]) => sessionId), ) } -function markAttachmentsAuthoritative(key: string) { - setAuthoritativeAttachmentKeys((prev) => { - if (prev.has(key)) return prev - const next = new Set(prev) - next.add(key) - return next - }) -} - function addAttachment(instanceId: string, sessionId: string, attachment: Attachment) { - const key = getSessionKey(instanceId, sessionId) - markAttachmentsAuthoritative(key) - setAttachments((prev) => { - const next = new Map(prev) - const existing = next.get(key) || [] - next.set(key, [...existing, attachment]) - return next + setSession(instanceId, sessionId, { + values: [...getAttachments(instanceId, sessionId), attachment], + authoritative: true, }) } function removeAttachment(instanceId: string, sessionId: string, attachmentId: string) { - const key = getSessionKey(instanceId, sessionId) - markAttachmentsAuthoritative(key) - setAttachments((prev) => { - const next = new Map(prev) - const existing = next.get(key) || [] - next.set( - key, - existing.filter((a) => a.id !== attachmentId), - ) - return next + setSession(instanceId, sessionId, { + values: getAttachments(instanceId, sessionId).filter((attachment) => attachment.id !== attachmentId), + authoritative: true, }) } function clearAttachments(instanceId: string, sessionId: string) { - const key = getSessionKey(instanceId, sessionId) - markAttachmentsAuthoritative(key) - setAttachments((prev) => { - const next = new Map(prev) - next.delete(key) - return next - }) + setSession(instanceId, sessionId, { values: [], authoritative: true }) } function deleteSessionAttachments(instanceId: string, sessionId: string) { - const key = getSessionKey(instanceId, sessionId) - setAttachments((prev) => { - if (!prev.has(key)) return prev - const next = new Map(prev) - next.delete(key) - return next - }) - setAuthoritativeAttachmentKeys((prev) => { - if (!prev.has(key)) return prev - const next = new Set(prev) - next.delete(key) - return next + setSession(instanceId, sessionId) +} + +function hydrateSessionAttachments(instanceId: string, sessionId: string, values: Attachment[]) { + setSession(instanceId, sessionId, { + values: [...values], + authoritative: attachments().get(getSessionKey(instanceId, sessionId))?.authoritative ?? false, }) } -function hydrateSessionAttachments(instanceId: string, sessionId: string, value: Attachment[]) { - const key = getSessionKey(instanceId, sessionId) - setAttachments((prev) => { - const next = new Map(prev) - if (value.length === 0) { - next.delete(key) - } else { - next.set(key, [...value]) +function clearInstanceAttachments(instanceId: string, valuesOnly = false) { + if (!instanceId) return + const prefix = `${instanceId}:` + setAttachments((previous) => { + const next = new Map(previous) + for (const [key, state] of next) { + if (!key.startsWith(prefix)) continue + if (valuesOnly && state.authoritative) next.set(key, { ...state, values: [] }) + else next.delete(key) } return next }) } function clearInstanceAttachmentValues(instanceId: string) { - if (!instanceId) return - const prefix = `${instanceId}:` - setAttachments((prev) => { - const next = new Map(prev) - let changed = false - for (const key of next.keys()) { - if (!key.startsWith(prefix)) continue - next.delete(key) - changed = true - } - return changed ? next : prev - }) -} - -function clearInstanceAttachmentAuthority(instanceId: string) { - if (!instanceId) return - const prefix = `${instanceId}:` - setAuthoritativeAttachmentKeys((prev) => { - const next = new Set([...prev].filter((key) => !key.startsWith(prefix))) - return next.size === prev.size ? prev : next - }) -} - -function clearInstanceAttachments(instanceId: string) { - clearInstanceAttachmentValues(instanceId) - clearInstanceAttachmentAuthority(instanceId) + clearInstanceAttachments(instanceId, true) } export { diff --git a/packages/ui/src/stores/client-state-attachments-codec.ts b/packages/ui/src/stores/client-state-attachments-codec.ts index bcf8d473..1253ddda 100644 --- a/packages/ui/src/stores/client-state-attachments-codec.ts +++ b/packages/ui/src/stores/client-state-attachments-codec.ts @@ -1,21 +1,16 @@ import type { Attachment, AttachmentSource } from "../types/attachment" -import { - createAttachmentPlaceholderRegex, - type AttachmentPlaceholderKind, -} from "../lib/attachment-placeholders" -import { createPromptMentionRegex, getAttachmentPromptMentionCandidates } from "../lib/attachment-mentions" +import { removeAttachmentPromptTokens } from "../lib/attachment-mentions" export type RestorableAttachmentSource = | { type: "file"; path: string; mime: string; data?: string } | { type: "text"; value: string } - | { - type: "symbol" - path: string - name: string - kind: number - range: { start: { line: number; char: number }; end: { line: number; char: number } } - } | { type: "agent"; name: string } + | { type: "symbol"; path: string; name: string; kind: number; range: { start: Position; end: Position } } + +interface Position { + line: number + char: number +} export interface RestorableAttachment { id: string @@ -26,80 +21,51 @@ export interface RestorableAttachment { mediaType: string source: RestorableAttachmentSource } - export interface AttachmentCodecBudget { attachmentsRemaining: number metadataCharactersRemaining: number fileDataCharactersRemaining: number } -const MAX_ATTACHMENT_SESSIONS_PER_TAB = 24 -const MAX_ATTACHMENTS_PER_SESSION = 8 -const MAX_ATTACHMENTS_TOTAL = 64 -const MAX_ATTACHMENT_METADATA_CHARACTERS = 24 * 1024 -const MAX_FILE_DATA_BYTES = 64 * 1024 -const MAX_FILE_DATA_CHARACTERS = 96 * 1024 -const MAX_ID_LENGTH = 512 -const MAX_DISPLAY_LENGTH = 1024 -const MAX_PATH_LENGTH = 4096 -const MAX_MIME_LENGTH = 256 -const MAX_TEXT_LENGTH = 24 * 1024 +const MAX_SESSIONS = 24 +const MAX_PER_SESSION = 8 +const MAX_ATTACHMENTS = 64 +const MAX_METADATA = 24 * 1024 +const MAX_FILE_BYTES = 64 * 1024 +const MAX_FILE_CHARACTERS = 96 * 1024 +const MAX_ID = 512 +const MAX_DISPLAY = 1024 +const MAX_PATH = 4096 +const MAX_MIME = 256 +const MAX_TEXT = 24 * 1024 -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value) -} +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value) +const isSafeKey = (value: string) => + value !== "__proto__" && value !== "constructor" && value !== "prototype" -function isSafeKey(value: string): boolean { - return value !== "__proto__" && value !== "constructor" && value !== "prototype" -} - -function takeString( - value: unknown, - maxLength: number, - budget: AttachmentCodecBudget, - allowEmpty = false, -): string | undefined { - if (typeof value !== "string" || value.length > maxLength || value.length > budget.metadataCharactersRemaining) { - return undefined - } - if (!allowEmpty && value.trim().length === 0) return undefined +function takeString(value: unknown, max: number, budget: AttachmentCodecBudget, allowEmpty = false) { + if ( + typeof value !== "string" || + value.length > max || + value.length > budget.metadataCharactersRemaining + ) return + if (!allowEmpty && value.trim().length === 0) return budget.metadataCharactersRemaining -= value.length return value } function bytesToBase64(bytes: Uint8Array): string { let binary = "" - const chunkSize = 0x8000 - for (let index = 0; index < bytes.length; index += chunkSize) { - binary += String.fromCharCode(...bytes.subarray(index, Math.min(index + chunkSize, bytes.length))) + for (let index = 0; index < bytes.length; index += 0x8000) { + binary += String.fromCharCode(...bytes.subarray(index, index + 0x8000)) } return btoa(binary) } -function base64ToBytes(value: string): Uint8Array { - const binary = atob(value) - const result = new Uint8Array(binary.length) - for (let index = 0; index < binary.length; index += 1) result[index] = binary.charCodeAt(index) - return result -} +const base64ToBytes = (value: string) => Uint8Array.from(atob(value), (character) => character.charCodeAt(0)) -function normalizeBase64(value: unknown): string | undefined { - if (value instanceof Uint8Array) { - if (value.byteLength > MAX_FILE_DATA_BYTES) return undefined - return bytesToBase64(value) - } - if (typeof value !== "string" || value.length > MAX_FILE_DATA_CHARACTERS) return undefined - if (value.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(value)) return undefined - try { - if (base64ToBytes(value).byteLength > MAX_FILE_DATA_BYTES) return undefined - } catch { - return undefined - } - return value -} - -function isValidFileData(value: unknown): boolean { - if (value instanceof Uint8Array) return true +function validBase64(value: unknown): value is string { if (typeof value !== "string" || value.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(value)) return false try { base64ToBytes(value) @@ -109,117 +75,91 @@ function isValidFileData(value: unknown): boolean { } } -function dataUrlPayload(value: unknown): string | undefined { - if (typeof value !== "string") return undefined - const match = value.match(/^data:[^;,]+;base64,([A-Za-z0-9+/]*={0,2})$/) - return match?.[1] -} +const dataUrlPayload = (value: unknown): string | undefined => typeof value === "string" + ? value.match(/^data:[^;,]+;base64,([A-Za-z0-9+/]*={0,2})$/)?.[1] + : undefined function takeFileData(value: unknown, url: unknown, budget: AttachmentCodecBudget): string | undefined { - const rawData = dataUrlPayload(url) ?? value - if (rawData === undefined) return undefined - const data = normalizeBase64(rawData) - if (data === undefined || data.length > budget.fileDataCharactersRemaining) return undefined + const raw = dataUrlPayload(url) ?? value + if (raw instanceof Uint8Array && raw.byteLength > MAX_FILE_BYTES) return + const data = raw instanceof Uint8Array ? bytesToBase64(raw) : raw + if ( + !validBase64(data) || + base64ToBytes(data).byteLength > MAX_FILE_BYTES || + data.length > MAX_FILE_CHARACTERS || + data.length > budget.fileDataCharactersRemaining + ) return budget.fileDataCharactersRemaining -= data.length return data } -function takePosition(value: unknown): { line: number; char: number } | undefined { - if (!isRecord(value)) return undefined - if (!Number.isSafeInteger(value.line) || Number(value.line) < 0) return undefined - if (!Number.isSafeInteger(value.char) || Number(value.char) < 0) return undefined +function takePosition(value: unknown): Position | undefined { + if (!isRecord(value) || !Number.isSafeInteger(value.line) || Number(value.line) < 0) return + if (!Number.isSafeInteger(value.char) || Number(value.char) < 0) return return { line: Number(value.line), char: Number(value.char) } } function normalizeSource( value: unknown, - rawUrl: unknown, + url: unknown, budget: AttachmentCodecBudget, ): RestorableAttachmentSource | undefined { - if (!isRecord(value)) return undefined - if (value.type === "file") { - const path = takeString(value.path, MAX_PATH_LENGTH, budget) - const mime = takeString(value.mime, MAX_MIME_LENGTH, budget) - if (path === undefined || mime === undefined) return undefined - const hasData = value.data !== undefined || dataUrlPayload(rawUrl) !== undefined - const rawData = dataUrlPayload(rawUrl) ?? value.data - const data = takeFileData(value.data, rawUrl, budget) - const hasRestorablePath = typeof rawUrl === "string" && rawUrl.length > 0 && !rawUrl.startsWith("data:") - if (hasData && data === undefined && (!hasRestorablePath || !isValidFileData(rawData))) return undefined - return data === undefined ? { type: "file", path, mime } : { type: "file", path, mime, data } - } + if (!isRecord(value)) return if (value.type === "text") { - const text = takeString(value.value, MAX_TEXT_LENGTH, budget, true) + const text = takeString(value.value, MAX_TEXT, budget, true) return text === undefined ? undefined : { type: "text", value: text } } if (value.type === "agent") { - const name = takeString(value.name, MAX_DISPLAY_LENGTH, budget) + const name = takeString(value.name, MAX_DISPLAY, budget) return name === undefined ? undefined : { type: "agent", name } } - if (value.type !== "symbol" || !isRecord(value.range)) return undefined - const path = takeString(value.path, MAX_PATH_LENGTH, budget) - const name = takeString(value.name, MAX_DISPLAY_LENGTH, budget) + const path = takeString(value.path, MAX_PATH, budget) + if (value.type === "file") { + const mime = takeString(value.mime, MAX_MIME, budget) + if (path === undefined || mime === undefined) return + const rawData = dataUrlPayload(url) ?? value.data + const data = takeFileData(value.data, url, budget) + const pathBacked = typeof url === "string" && url.length > 0 && !url.startsWith("data:") + const validPathData = rawData instanceof Uint8Array || validBase64(rawData) + if (rawData !== undefined && data === undefined && (!pathBacked || !validPathData)) return + return data === undefined ? { type: "file", path, mime } : { type: "file", path, mime, data } + } + if (value.type !== "symbol" || !isRecord(value.range)) return + const name = takeString(value.name, MAX_DISPLAY, budget) const start = takePosition(value.range.start) const end = takePosition(value.range.end) - if (path === undefined || name === undefined || !Number.isSafeInteger(value.kind) || !start || !end) return undefined + if (path === undefined || name === undefined || !Number.isSafeInteger(value.kind) || !start || !end) return return { type: "symbol", path, name, kind: Number(value.kind), range: { start, end } } } function normalizeAttachment(value: unknown, budget: AttachmentCodecBudget): RestorableAttachment | undefined { - if (!isRecord(value) || budget.attachmentsRemaining <= 0) return undefined - if (value.type !== "file" && value.type !== "text" && value.type !== "symbol" && value.type !== "agent") { - return undefined - } - - const nextBudget = { ...budget } - const id = takeString(value.id, MAX_ID_LENGTH, nextBudget) - const display = takeString(value.display, MAX_DISPLAY_LENGTH, nextBudget) - const filename = takeString(value.filename, MAX_PATH_LENGTH, nextBudget) - const mediaType = takeString(value.mediaType, MAX_MIME_LENGTH, nextBudget) - const source = normalizeSource(value.source, value.url, nextBudget) - if (id === undefined || display === undefined || filename === undefined || mediaType === undefined || !source) { - return undefined - } - if (source.type !== value.type) return undefined + if (!isRecord(value) || budget.attachmentsRemaining <= 0) return + if (!["file", "text", "symbol", "agent"].includes(String(value.type))) return + const next = { ...budget } + const id = takeString(value.id, MAX_ID, next) + const display = takeString(value.display, MAX_DISPLAY, next) + const filename = takeString(value.filename, MAX_PATH, next) + const mediaType = takeString(value.mediaType, MAX_MIME, next) + const source = normalizeSource(value.source, value.url, next) const rawUrl = typeof value.url === "string" && !value.url.startsWith("data:") ? value.url : "" - const url = takeString(rawUrl, MAX_PATH_LENGTH, nextBudget, true) - if (url === undefined) return undefined - nextBudget.attachmentsRemaining -= 1 - Object.assign(budget, nextBudget) - return { id, type: value.type, display, url, filename, mediaType, source } -} + const url = takeString(rawUrl, MAX_PATH, next, true) + if ( + !id || !display || !filename || !mediaType || !source || + source.type !== value.type || url === undefined + ) return -function getDraftPlaceholder( - value: unknown, -): { kind: AttachmentPlaceholderKind; counter: string } | undefined { - if (!isRecord(value) || typeof value.display !== "string" || value.display.length > MAX_DISPLAY_LENGTH) return undefined - const match = value.display.match(/(pasted|image)\s*#\s*(\d+)/i) - if (match?.[1] && match[2]) { - return { kind: match[1].toLowerCase() === "image" ? "image" : "pasted", counter: match[2] } - } - return undefined -} - -function removeAttachmentPromptTokens(draft: string, attachment: unknown): string { - const placeholder = getDraftPlaceholder(attachment) - if (placeholder) { - return draft.replace(createAttachmentPlaceholderRegex(placeholder.kind, placeholder.counter), "") - } - - let nextDraft = draft - for (const candidate of getAttachmentPromptMentionCandidates(attachment)) { - nextDraft = nextDraft.replace(createPromptMentionRegex(candidate, { global: true }), "") - } - return nextDraft + next.attachmentsRemaining -= 1 + Object.assign(budget, next) + return { id, type: value.type as Attachment["type"], display, url, filename, mediaType, source } } export function createAttachmentCodecBudget(): AttachmentCodecBudget { return { - attachmentsRemaining: MAX_ATTACHMENTS_TOTAL, - metadataCharactersRemaining: MAX_ATTACHMENT_METADATA_CHARACTERS, - fileDataCharactersRemaining: MAX_FILE_DATA_CHARACTERS, + attachmentsRemaining: MAX_ATTACHMENTS, + metadataCharactersRemaining: MAX_METADATA, + fileDataCharactersRemaining: MAX_FILE_CHARACTERS, } } @@ -227,30 +167,33 @@ export function normalizeRestorableAttachmentRecord( value: unknown, drafts: Record, budget: AttachmentCodecBudget, + prioritySessionIds: readonly string[] = [], ): { attachments: Record; drafts: Record } | null { if (!isRecord(value)) return null const attachments: Record = Object.create(null) const nextDrafts = { ...drafts } - let sessionCount = 0 - - for (const [sessionId, rawAttachments] of Object.entries(value)) { - if (!isSafeKey(sessionId) || sessionId.length === 0 || sessionId.length > MAX_ID_LENGTH || !Array.isArray(rawAttachments)) { - continue - } - const canPersistSession = sessionCount < MAX_ATTACHMENT_SESSIONS_PER_TAB - if (canPersistSession) sessionCount += 1 + let sessions = 0 + const priority = [...new Set(prioritySessionIds)] + const prioritySet = new Set(priority) + const entries = [ + ...priority.flatMap((sessionId) => Object.prototype.hasOwnProperty.call(value, sessionId) + ? [[sessionId, value[sessionId]] as const] + : []), + ...Object.entries(value).filter(([sessionId]) => !prioritySet.has(sessionId)), + ] + for (const [sessionId, rawAttachments] of entries) { + if ( + !isSafeKey(sessionId) || !sessionId || + sessionId.length > MAX_ID || !Array.isArray(rawAttachments) + ) continue + const persist = sessions++ < MAX_SESSIONS const normalized: RestorableAttachment[] = [] - for (const rawAttachment of rawAttachments) { - const attachment = canPersistSession && normalized.length < MAX_ATTACHMENTS_PER_SESSION - ? normalizeAttachment(rawAttachment, budget) - : undefined - if (attachment) { - normalized.push(attachment) - } else if (nextDrafts[sessionId]) { - nextDrafts[sessionId] = removeAttachmentPromptTokens(nextDrafts[sessionId], rawAttachment) - } + for (const raw of rawAttachments) { + const attachment = persist && normalized.length < MAX_PER_SESSION ? normalizeAttachment(raw, budget) : undefined + if (attachment) normalized.push(attachment) + else if (nextDrafts[sessionId]) nextDrafts[sessionId] = removeAttachmentPromptTokens(nextDrafts[sessionId], raw) } - if (normalized.length > 0) attachments[sessionId] = normalized + if (normalized.length) attachments[sessionId] = normalized } return { attachments, drafts: nextDrafts } } @@ -258,8 +201,9 @@ export function normalizeRestorableAttachmentRecord( export function serializeDraftAttachments( drafts: Record, attachments: Record, -): { drafts: Record; attachments: Record } { - return normalizeRestorableAttachmentRecord(attachments, drafts, createAttachmentCodecBudget()) + prioritySessionIds: readonly string[] = [], +) { + return normalizeRestorableAttachmentRecord(attachments, drafts, createAttachmentCodecBudget(), prioritySessionIds) ?? { drafts: { ...drafts }, attachments: {} } } @@ -267,21 +211,18 @@ export function hydrateRestorableAttachment(value: RestorableAttachment): Attach let source: AttachmentSource let url = value.url if (value.source.type === "file") { - let data: Uint8Array | undefined - if (value.source.data !== undefined) { - try { - data = base64ToBytes(value.source.data) - } catch { - return null - } - if (!url) url = `data:${value.source.mime};base64,${value.source.data}` + try { + const data = value.source.data === undefined ? undefined : base64ToBytes(value.source.data) + source = { type: "file", path: value.source.path, mime: value.source.mime, data } + if (!url && value.source.data) url = `data:${value.source.mime};base64,${value.source.data}` + } catch { + return null } - source = { type: "file", path: value.source.path, mime: value.source.mime, data } - } else if (value.source.type === "text") { - source = value.source - if (!url) url = `data:text/plain;base64,${bytesToBase64(new TextEncoder().encode(value.source.value))}` } else { source = value.source + if (value.source.type === "text" && !url) { + url = `data:text/plain;base64,${bytesToBase64(new TextEncoder().encode(value.source.value))}` + } } return { ...value, url, source } } diff --git a/packages/ui/src/stores/client-state-claim.test.ts b/packages/ui/src/stores/client-state-claim.test.ts deleted file mode 100644 index 83327605..00000000 --- a/packages/ui/src/stores/client-state-claim.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import assert from "node:assert/strict" -import { it } from "node:test" - -it("treats a rejected renderer access claim as non-primary without loading state", async () => { - let loadCount = 0 - Object.defineProperty(globalThis, "window", { - configurable: true, - value: { - __CODENOMAD_RUNTIME_HOST__: "electron", - __CODENOMAD_WINDOW_CONTEXT__: "local", - electronAPI: { - claimClientStateAccess: async () => { - throw new Error("claim rejected") - }, - loadClientState: async () => { - loadCount += 1 - return { isPrimary: true, restoreEnabled: true, snapshot: null } - }, - }, - }, - }) - - const clientState = await import("./client-state.ts") - await clientState.initializeClientState() - - assert.equal(clientState.clientStateIsPrimary(), false) - assert.equal(loadCount, 0) -}) diff --git a/packages/ui/src/stores/client-state-clear-failure.test.ts b/packages/ui/src/stores/client-state-clear-failure.test.ts deleted file mode 100644 index 17fb8bc3..00000000 --- a/packages/ui/src/stores/client-state-clear-failure.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import assert from "node:assert/strict" -import { it } from "node:test" - -it("applies and persists mutations buffered during a delayed failed clear", async () => { - let rejectClear: ((error: Error) => void) | undefined - let markClearStarted: (() => void) | undefined - const clearStarted = new Promise((resolve) => { - markClearStarted = resolve - }) - const savedSnapshots: any[] = [] - Object.defineProperty(globalThis, "window", { - configurable: true, - value: { - __CODENOMAD_RUNTIME_HOST__: "electron", - __CODENOMAD_WINDOW_CONTEXT__: "local", - localStorage: createMemoryStorage(), - electronAPI: { - claimClientStateAccess: async () => true, - loadClientState: async () => ({ - isPrimary: true, - restoreEnabled: true, - snapshot: { - version: 1, - revision: 1, - savedAt: 1, - layout: {}, - session: { tabs: [{ kind: "sidecar", sidecarId: "saved" }], activeTabIndex: 0 }, - }, - }), - saveClientState: async (_token: string, snapshot: unknown) => { - savedSnapshots.push(snapshot) - return true - }, - clearClientState: () => { - markClearStarted?.() - return new Promise((_resolve, reject) => { - rejectClear = reject - }) - }, - }, - }, - }) - - const clientState = await import("./client-state.ts") - await clientState.initializeClientState() - - const clearing = clientState.clearRestoredClientState() - await clearStarted - clientState.updateRestorableSession({ tabs: [{ kind: "sidecar", sidecarId: "buffered" }], activeTabIndex: 0 }) - clientState.writeClientLayoutValue("opencode-session-sidebar-width-v8", "410") - assert.ok(rejectClear) - rejectClear(new Error("native clear failed")) - - await assert.rejects(clearing, /native clear failed/) - await clientState.flushClientState() - - assert.equal(savedSnapshots.length, 1) - assert.equal(savedSnapshots[0].session.tabs[0].sidecarId, "buffered") - assert.equal(savedSnapshots[0].layout["opencode-session-sidebar-width-v8"], "410") -}) - -function createMemoryStorage(): Storage { - const values = new Map() - return { - get length() { return values.size }, - clear: () => values.clear(), - getItem: (key) => values.get(key) ?? null, - key: (index) => [...values.keys()][index] ?? null, - removeItem: (key) => { values.delete(key) }, - setItem: (key, value) => { values.set(key, String(value)) }, - } -} diff --git a/packages/ui/src/stores/client-state-clear-retry.test.ts b/packages/ui/src/stores/client-state-clear-retry.test.ts deleted file mode 100644 index 7206832f..00000000 --- a/packages/ui/src/stores/client-state-clear-retry.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import assert from "node:assert/strict" -import { it } from "node:test" - -it("preserves retry dirt from an in-flight failed save when native clear throws", async () => { - let rejectFirstSave: ((error: Error) => void) | undefined - let markFirstSaveStarted: (() => void) | undefined - const firstSaveStarted = new Promise((resolve) => { - markFirstSaveStarted = resolve - }) - const savedSnapshots: unknown[] = [] - Object.defineProperty(globalThis, "window", { - configurable: true, - value: { - __CODENOMAD_RUNTIME_HOST__: "electron", - __CODENOMAD_WINDOW_CONTEXT__: "local", - localStorage: createMemoryStorage(), - electronAPI: { - claimClientStateAccess: async () => true, - loadClientState: async () => ({ isPrimary: true, restoreEnabled: true, snapshot: null }), - saveClientState: (_token: string, snapshot: unknown) => { - savedSnapshots.push(snapshot) - if (savedSnapshots.length > 1) return Promise.resolve(true) - markFirstSaveStarted?.() - return new Promise((_resolve, reject) => { - rejectFirstSave = reject - }) - }, - clearClientState: async () => { - throw new Error("native clear failed") - }, - }, - }, - }) - - const clientState = await import("./client-state.ts") - await clientState.initializeClientState() - clientState.updateRestorableSession({ tabs: [{ kind: "sidecar", sidecarId: "retry" }], activeTabIndex: 0 }) - - const firstFlush = clientState.flushClientState() - await firstSaveStarted - const clearing = clientState.clearRestoredClientState() - assert.ok(rejectFirstSave) - rejectFirstSave(new Error("first save failed")) - - await assert.rejects(firstFlush, /first save failed/) - await assert.rejects(clearing, /native clear failed/) - await clientState.flushClientState() - - assert.equal(savedSnapshots.length, 2) - assert.equal((savedSnapshots[1] as any).session.tabs[0].sidecarId, "retry") -}) - -function createMemoryStorage(): Storage { - const values = new Map() - return { - get length() { return values.size }, - clear: () => values.clear(), - getItem: (key) => values.get(key) ?? null, - key: (index) => [...values.keys()][index] ?? null, - removeItem: (key) => { values.delete(key) }, - setItem: (key, value) => { values.set(key, String(value)) }, - } -} diff --git a/packages/ui/src/stores/client-state-codec.test.ts b/packages/ui/src/stores/client-state-codec.test.ts index e3180a2a..5b8da11e 100644 --- a/packages/ui/src/stores/client-state-codec.test.ts +++ b/packages/ui/src/stores/client-state-codec.test.ts @@ -1,550 +1,359 @@ import assert from "node:assert/strict" import { describe, it } from "node:test" -import { hydrateRestorableAttachment } from "./client-state-attachments-codec.ts" -import { - decodeClientSnapshot, - isFutureClientSnapshot, - normalizeRestorableSession, - type RestorableTabState, -} from "./client-state-codec.ts" +import { hydrateRestorableAttachment, serializeDraftAttachments } from "./client-state-attachments-codec.ts" +import { decodeClientSnapshot, isFutureClientSnapshot, normalizeRestorableSession } from "./client-state-codec.ts" + +type UnknownRecord = Record + +const workspace = (overrides: UnknownRecord = {}): UnknownRecord => ({ + kind: "workspace", folder: "/work", drafts: {}, attachments: {}, scrollSnapshots: {}, ...overrides, +}) +const session = (...tabs: unknown[]) => normalizeRestorableSession({ activeTabIndex: 0, tabs }) +const snapshot = (overrides: UnknownRecord = {}) => ({ + version: 1, revision: 1, savedAt: 1, layout: {}, session: null, ...overrides, +}) +const attachment = (source: UnknownRecord, overrides: UnknownRecord = {}) => ({ + id: "item", type: source.type, display: "@item", + url: "", + filename: "item", + mediaType: "text/plain", + source, + ...overrides, +}) +const file = (path: string, overrides: UnknownRecord = {}) => attachment( + { type: "file", path, mime: "text/plain" }, + { filename: path.split(/[\\/]/).pop() || path, ...overrides }, +) +const firstWorkspace = (value: ReturnType) => { + const tab = value?.tabs[0] + assert.ok(tab?.kind === "workspace") + return tab +} + +it("preserves only an explicit active home view", () => { + assert.equal(normalizeRestorableSession({ tabs: [], activeTabIndex: -1, homeActive: true })?.homeActive, true) + assert.equal(normalizeRestorableSession({ tabs: [], activeTabIndex: -1, homeActive: "true" })?.homeActive, undefined) +}) +const normalizeWorkspace = (overrides: UnknownRecord) => firstWorkspace(session(workspace(overrides))) describe("client state codec", () => { - it("normalizes a v1 workspace and sidecar session", () => { - const decoded = decodeClientSnapshot({ - version: 1, + it("normalizes v1 workspace state, scroll state, and a sidecar", () => { + const decoded = decodeClientSnapshot(snapshot({ revision: 7, - savedAt: 1234, layout: { drawer: "320" }, session: { activeTabIndex: 8, - tabs: [ - { - type: "instance", - folder: "C:/work/project", - occurrence: 1, - projectName: "Project", - drafts: { session1: "unfinished prompt" }, - scrollSnapshots: { - session1: { scrollTop: 120, scrollRatio: 0.5, atBottom: false, updatedAt: 1200 }, - }, - unseenIdleSince: { session1: 1100, malformed: -1 }, - generationRecovery: { session1: "working", session2: "interrupted", malformed: "idle" }, - }, - { kind: "sidecar", sidecarId: "docs" }, - ], + tabs: [workspace({ + type: "instance", kind: undefined, folder: "C:/work/project", occurrence: 1, projectName: "Project", + drafts: { session1: "unfinished prompt" }, + scrollSnapshots: { session1: { scrollTop: 120, scrollRatio: 0.5, atBottom: false, updatedAt: 1200 } }, + unseenIdleSince: { session1: 1100, malformed: -1 }, + generationRecovery: { session1: "working", session2: "interrupted", malformed: "idle" }, + }), { kind: "sidecar", sidecarId: "docs" }], }, - }) + })) - assert.ok(decoded) - assert.equal(decoded.revision, 7) - assert.equal(decoded.session?.activeTabIndex, 1) - assert.equal(decoded.session?.tabs[0]?.kind, "workspace") - assert.equal(decoded.session?.tabs[0]?.kind === "workspace" ? decoded.session.tabs[0].occurrence : undefined, 1) - assert.deepEqual( - decoded.session?.tabs[0]?.kind === "workspace" ? { ...decoded.session.tabs[0].unseenIdleSince } : undefined, - { session1: 1100 }, - ) - assert.deepEqual( - decoded.session?.tabs[0]?.kind === "workspace" ? { ...decoded.session.tabs[0].generationRecovery } : undefined, - { session1: "working", session2: "interrupted" }, - ) - assert.deepEqual(decoded.session?.tabs[1], { kind: "sidecar", sidecarId: "docs" }) + assert.equal(decoded?.revision, 7) + assert.equal(decoded?.session?.activeTabIndex, 1) + const tab = decoded?.session?.tabs[0] + assert.equal(tab?.kind, "workspace") + if (tab?.kind !== "workspace") return + assert.equal(tab.occurrence, 1) + assert.deepEqual({ ...tab.drafts }, { session1: "unfinished prompt" }) + assert.deepEqual({ ...tab.unseenIdleSince }, { session1: 1100 }) + assert.deepEqual({ ...tab.generationRecovery }, { session1: "working", session2: "interrupted" }) + assert.deepEqual(tab.scrollSnapshots.session1, { scrollTop: 120, scrollRatio: 0.5, atBottom: false, updatedAt: 1200 }) + assert.deepEqual(decoded?.session?.tabs[1], { kind: "sidecar", sidecarId: "docs" }) }) - it("ignores malformed and future snapshots", () => { - assert.equal(decodeClientSnapshot({ version: 2, revision: 1, savedAt: 1, layout: {}, session: null }), null) - assert.equal(isFutureClientSnapshot({ version: 2 }), true) - assert.equal(decodeClientSnapshot({ version: 1, revision: -1, savedAt: 1, layout: {}, session: null }), null) - assert.equal(decodeClientSnapshot({ version: 1, revision: 1, savedAt: 1, layout: [], session: null }), null) + it("rejects malformed snapshots/sessions and recognizes future envelopes", () => { + const cases: [string, unknown, unknown][] = [ + ["future version", snapshot({ version: 2 }), null], + ["negative revision", snapshot({ revision: -1 }), null], + ["non-record layout", snapshot({ layout: [] }), null], + ] + for (const [label, value, expected] of cases) assert.equal(decodeClientSnapshot(value), expected, label) + assert.equal(isFutureClientSnapshot({ version: 2 }), true, "future envelope") + assert.equal(isFutureClientSnapshot({ version: 1 }), false, "current envelope") assert.equal(normalizeRestorableSession({ tabs: [{ kind: "workspace" }], activeTabIndex: 0 }), null) }) - it("caps record counts and drops unsafe or malformed entries", () => { + it("caps records and drops unsafe or malformed entries", () => { const drafts = Object.fromEntries(Array.from({ length: 40 }, (_, index) => [`session-${index}`, `draft-${index}`])) Object.defineProperty(drafts, "__proto__", { value: "unsafe", enumerable: true }) - const scrollSnapshots = { - valid: { scrollTop: 10, atBottom: true, updatedAt: 100 }, - malformed: { scrollTop: Number.NaN, atBottom: false, updatedAt: 100 }, - } - - const session = normalizeRestorableSession({ - activeTabIndex: 0, - tabs: [{ kind: "workspace", folder: "C:/work", drafts, scrollSnapshots }], - }) - - assert.ok(session) - const workspace = session.tabs[0] - assert.equal(workspace?.kind, "workspace") - if (workspace?.kind !== "workspace") return - assert.equal(Object.keys(workspace.drafts).length, 24) - assert.equal(Object.prototype.hasOwnProperty.call(workspace.drafts, "__proto__"), false) - assert.deepEqual(workspace.scrollSnapshots.valid, { scrollTop: 10, atBottom: true, updatedAt: 100 }) - assert.equal(workspace.scrollSnapshots.malformed, undefined) - }) - - it("remaps the active tab by its original identity after malformed tabs are filtered", () => { - const session = normalizeRestorableSession({ - activeTabIndex: 2, - tabs: [ - { kind: "sidecar" }, - { kind: "sidecar", sidecarId: "first" }, - { kind: "sidecar", sidecarId: "active" }, - { kind: "sidecar", sidecarId: "last" }, - ], - }) - assert.equal(session?.activeTabIndex, 1) - assert.deepEqual(session?.tabs[1], { kind: "sidecar", sidecarId: "active" }) - - const filteredActive = normalizeRestorableSession({ - activeTabIndex: 0, - tabs: [{ kind: "sidecar" }, { kind: "sidecar", sidecarId: "fallback" }], - }) - assert.equal(filteredActive?.activeTabIndex, 0) - }) - - it("round trips pasted text and bounded file data", () => { - const decoded = decodeClientSnapshot({ - version: 1, - revision: 1, - savedAt: 1, - layout: {}, - session: { - activeTabIndex: 0, - tabs: [{ - kind: "workspace", - folder: "/work", - drafts: { session1: "Review [pasted #1] and [Image #1]" }, - attachments: { - session1: [ - { - id: "paste-1", - type: "text", - display: "pasted #1 (4 lines)", - url: "data:text/plain;base64,ignored", - filename: "paste-1.txt", - mediaType: "text/plain", - source: { type: "text", value: "alpha\nbeta\ngamma\ndelta" }, - }, - { - id: "image-1", - type: "file", - display: "[Image #1]", - url: "data:image/png;base64,iVBORw0KGgo=", - filename: "image-1.png", - mediaType: "image/png", - source: { type: "file", path: "image-1.png", mime: "image/png", data: new Uint8Array([1, 2]) }, - }, - { - id: "symbol-1", - type: "symbol", - display: "@run", - url: "file:///work/large.png", - filename: "main.ts", - mediaType: "text/plain", - source: { - type: "symbol", - path: "src/main.ts", - name: "run", - kind: 12, - range: { start: { line: 1, char: 2 }, end: { line: 3, char: 4 } }, - }, - }, - { - id: "agent-1", - type: "agent", - display: "@reviewer", - url: "", - filename: "reviewer", - mediaType: "text/plain", - source: { type: "agent", name: "reviewer" }, - }, - ], - }, - scrollSnapshots: {}, - }], + const tab = normalizeWorkspace({ + drafts, + scrollSnapshots: { + valid: { scrollTop: 10, atBottom: true, updatedAt: 100 }, + malformed: { scrollTop: Number.NaN, atBottom: false, updatedAt: 100 }, }, }) + assert.equal(Object.keys(tab.drafts).length, 24) + assert.equal(Object.prototype.hasOwnProperty.call(tab.drafts, "__proto__"), false) + assert.deepEqual(tab.scrollSnapshots.valid, { scrollTop: 10, atBottom: true, updatedAt: 100 }) + assert.equal(tab.scrollSnapshots.malformed, undefined) + }) + it("remaps active tabs after filtering, including a filtered active tab", () => { + const cases = [ + ["surviving active identity", 2, [{ kind: "sidecar" }, + { kind: "sidecar", sidecarId: "first" }, { kind: "sidecar", sidecarId: "active" }], 1], + ["filtered active fallback", 0, [{ kind: "sidecar" }, { kind: "sidecar", sidecarId: "fallback" }], 0], + ] as const + for (const [label, activeTabIndex, tabs, expected] of cases) { + const normalized = normalizeRestorableSession({ activeTabIndex, tabs }) + assert.equal(normalized?.activeTabIndex, expected, label) + } + }) + + it("round trips text, file, symbol, and agent attachments", () => { + const attachments = [ + attachment({ type: "text", value: "alpha\nbeta\ngamma\ndelta" }, { id: "paste", type: "text", display: "pasted #1 (4 lines)" }), + attachment( + { type: "file", path: "image.png", mime: "image/png", data: new Uint8Array([1, 2]) }, + { + id: "image", type: "file", display: "[Image #1]", url: "data:image/png;base64,iVBORw0KGgo=", + filename: "image.png", mediaType: "image/png", + }, + ), + attachment( + { + type: "symbol", path: "src/main.ts", name: "run", kind: 12, + range: { start: { line: 1, char: 2 }, end: { line: 3, char: 4 } }, + }, + { id: "symbol", type: "symbol" }, + ), + attachment({ type: "agent", name: "reviewer" }, { id: "agent", type: "agent" }), + ] + const decoded = decodeClientSnapshot(snapshot({ session: { activeTabIndex: 0, tabs: [workspace({ + drafts: { session1: "Review [pasted #1] and [Image #1]" }, attachments: { session1: attachments }, + })] } })) const roundTripped = decodeClientSnapshot(JSON.parse(JSON.stringify(decoded))) - const workspace = roundTripped?.session?.tabs[0] - assert.equal(workspace?.kind, "workspace") - if (workspace?.kind !== "workspace") return - assert.equal(workspace.attachments.session1?.length, 4) - assert.equal(workspace.attachments.session1?.[1]?.source.type, "file") - assert.equal(workspace.attachments.session1?.[2]?.source.type, "symbol") - assert.equal(workspace.attachments.session1?.[3]?.source.type, "agent") - const restored = hydrateRestorableAttachment(workspace.attachments.session1![1]!) - assert.deepEqual( - restored?.source.type === "file" ? [...(restored.source.data ?? [])] : null, - [...new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10])], - ) + const tab = roundTripped?.session?.tabs[0] + assert.equal(tab?.kind, "workspace") + if (tab?.kind !== "workspace") return + assert.deepEqual(tab.attachments.session1?.map((item) => item.source.type), ["text", "file", "symbol", "agent"]) + const restored = hydrateRestorableAttachment(tab.attachments.session1![1]!) + assert.deepEqual(restored?.source.type === "file" ? [...(restored.source.data ?? [])] : null, + [137, 80, 78, 71, 13, 10, 26, 10]) assert.ok(Buffer.byteLength(JSON.stringify(roundTripped), "utf8") < 1024 * 1024) }) - it("drops unsupported attachments while retaining an oversized path-backed file", () => { - const session = normalizeRestorableSession({ - activeTabIndex: 0, - tabs: [{ - kind: "workspace", - folder: "/work", - drafts: { session1: "keep @other, remove [Image #9] and @unsupported" }, - attachments: { - session1: [ - { - id: "large", - type: "file", - display: "[Image #9]", - url: "file:///work/large.png", - filename: "large.png", - mediaType: "image/png", - source: { - type: "file", - path: "large.png", - mime: "image/png", - data: new Uint8Array(65 * 1024), - }, - }, - { - id: "unsupported", - type: "archive", - display: "@unsupported", - url: "", - filename: "archive.zip", - mediaType: "application/zip", - source: { type: "archive" }, - }, - ], - }, - scrollSnapshots: {}, - }], - }) - const workspace = session?.tabs[0] - assert.equal(workspace?.kind, "workspace") - if (workspace?.kind !== "workspace") return - assert.equal(workspace.attachments.session1?.length, 1) - assert.deepEqual(workspace.attachments.session1?.[0]?.source, { - type: "file", - path: "large.png", - mime: "image/png", - }) - assert.equal(workspace.drafts.session1, "keep @other, remove [Image #9] and ") - }) - - it("keeps a path-backed file and its exact mention when inline data exceeds 64 KiB", () => { + it("drops unsupported attachments but retains oversized path-backed files and exact mentions", () => { const mention = "@./reports/exact report.txt" - const session = normalizeRestorableSession({ - activeTabIndex: 0, - tabs: [{ - kind: "workspace", - folder: "/work", - drafts: { session1: `Review ${mention} without changing it` }, - attachments: { - session1: [{ - id: "large-path-file", - type: "file", - display: "@exact report.txt", - url: "file:///work/reports/exact%20report.txt", - filename: "exact report.txt", - mediaType: "text/plain", - source: { - type: "file", - path: "./reports/exact report.txt", - mime: "text/plain", - data: new Uint8Array(64 * 1024 + 1), - }, - }], - }, - }], + const oversizedData = new Uint8Array(64 * 1024 + 1) + oversizedData.subarray = () => { throw new Error("oversized data must not be Base64 encoded") } + const large = file("./reports/exact report.txt", { + id: "large", display: "@exact report.txt", url: "file:///work/reports/exact%20report.txt", + filename: "exact report.txt", source: { type: "file", path: "./reports/exact report.txt", mime: "text/plain", data: oversizedData }, }) - - const workspace = session?.tabs[0] - assert.equal(workspace?.kind, "workspace") - if (workspace?.kind !== "workspace") return - assert.equal(workspace.drafts.session1, `Review ${mention} without changing it`) - assert.deepEqual(workspace.attachments.session1?.[0]?.source, { - type: "file", - path: "./reports/exact report.txt", - mime: "text/plain", + const unsupported = attachment({ type: "archive" }, { id: "unsupported", type: "archive", display: "@unsupported" }) + const tab = normalizeWorkspace({ + drafts: { session1: `Review ${mention}; remove @unsupported` }, attachments: { session1: [large, unsupported] }, }) + assert.equal(tab.attachments.session1?.length, 1) + assert.deepEqual(tab.attachments.session1?.[0]?.source, + { type: "file", path: "./reports/exact report.txt", mime: "text/plain" }) + assert.equal(tab.drafts.session1, `Review ${mention}; remove `) }) - it("reserves every tab identity before optional draft content", () => { - const session = normalizeRestorableSession({ - activeTabIndex: 1, - tabs: [ - { - kind: "workspace", - folder: "/large-drafts", - drafts: { - first: "a".repeat(32 * 1024), - second: "b".repeat(32 * 1024), - third: "c".repeat(32 * 1024), - }, - }, - { kind: "sidecar", sidecarId: "later-sidecar" }, - ], - }) - - assert.equal(session?.tabs.length, 2) - assert.deepEqual(session?.tabs[1], { kind: "sidecar", sidecarId: "later-sidecar" }) - assert.equal(session?.activeTabIndex, 1) - }) - - it("prioritizes tab identities over optional top-level layout strings", () => { - const decoded = decodeClientSnapshot({ - version: 1, - revision: 1, - savedAt: 1, - layout: Object.fromEntries(Array.from({ length: 24 }, (_, index) => [ - `panel-${index}`, - "x".repeat(4096), - ])), - session: { - activeTabIndex: 0, - tabs: [{ kind: "sidecar", sidecarId: "structural-tab" }], - }, + it("reserves structural and active-session identity before optional strings", () => { + const drafts = { first: "a".repeat(32 * 1024), second: "b".repeat(32 * 1024), third: "c".repeat(32 * 1024) } + const normalized = normalizeRestorableSession({ activeTabIndex: 1, tabs: [ + workspace({ folder: "/large", drafts }), { kind: "sidecar", sidecarId: "later-sidecar" }, + ] }) + assert.deepEqual(normalized?.tabs[1], { kind: "sidecar", sidecarId: "later-sidecar" }) + assert.equal(normalized?.activeTabIndex, 1) + + const tab = normalizeWorkspace({ + activeParentSessionId: "parent-session", activeSessionId: "child-session", drafts, }) + assert.equal(tab.activeParentSessionId, "parent-session") + assert.equal(tab.activeSessionId, "child-session") + const decoded = decodeClientSnapshot(snapshot({ + layout: Object.fromEntries(Array.from({ length: 24 }, (_, index) => [`panel-${index}`, "x".repeat(4096)])), + session: { activeTabIndex: 0, tabs: [{ kind: "sidecar", sidecarId: "structural-tab" }] }, + })) assert.deepEqual(decoded?.session?.tabs, [{ kind: "sidecar", sidecarId: "structural-tab" }]) }) + it("normalizes a valid unversioned legacy snapshot to version 1", () => { + const legacy: UnknownRecord = snapshot({ session: { activeTabIndex: 0, tabs: [workspace()] } }) + delete legacy.version + const decoded = decodeClientSnapshot(legacy) + assert.equal(decoded?.version, 1) + assert.equal(decoded?.session?.tabs[0]?.kind, "workspace") + assert.equal(decodeClientSnapshot({ ...legacy, version: 2 }), null) + }) + + for (const [label, activeSessionId] of [ + ["active session", "active-session"], + ["active no-session prompt", "__no_session_draft__"], + ] as const) { + it(`retains the ${label} draft beyond the per-tab entry budget`, () => { + const drafts = Object.fromEntries([ + ...Array.from({ length: 24 }, (_, index) => [`older-${index}`, `draft-${index}`]), + [activeSessionId, "unsent active draft"], + ]) + const tab = normalizeRestorableSession({ activeTabIndex: 0, tabs: [workspace({ + activeSessionId: activeSessionId === "__no_session_draft__" ? undefined : activeSessionId, + drafts, + })] })?.tabs[0] + + assert.equal(tab?.kind === "workspace" ? tab.drafts[activeSessionId] : undefined, "unsent active draft") + assert.equal(tab?.kind === "workspace" ? Object.keys(tab.drafts).length : 0, 24) + }) + } + + it("retains the active tab draft before earlier tabs consume the string budget", () => { + const normalized = normalizeRestorableSession({ activeTabIndex: 1, tabs: [ + workspace({ drafts: { + first: "a".repeat(32 * 1024), second: "b".repeat(32 * 1024), third: "c".repeat(32 * 1024), + } }), + workspace({ folder: "/active", activeSessionId: "active", drafts: { active: "keep me" } }), + ] }) + const active = normalized?.tabs[1] + + assert.equal(active?.kind === "workspace" ? active.drafts.active : undefined, "keep me") + assert.equal(normalized?.activeTabIndex, 1) + }) + + it("retains the active tab draft before inactive tab identities consume the string budget", () => { + const normalized = normalizeRestorableSession({ activeTabIndex: 24, tabs: [ + ...Array.from({ length: 24 }, (_, index) => workspace({ folder: `${index}${"x".repeat(4095)}` })), + workspace({ folder: "/active", activeSessionId: "active", drafts: { active: "keep me" } }), + ] }) + const active = normalized?.tabs.find((tab) => tab.kind === "workspace" && tab.folder === "/active") + + assert.equal(active?.kind === "workspace" ? active.drafts.active : undefined, "keep me") + assert.equal(normalized?.tabs[normalized.activeTabIndex], active) + }) + + it("reserves inactive tab identities and selected drafts before active payloads", () => { + const normalized = normalizeRestorableSession({ activeTabIndex: 0, tabs: [ + workspace({ drafts: { + first: "a".repeat(32 * 1024), second: "b".repeat(32 * 1024), third: "c".repeat(32 * 1024), + } }), + workspace({ folder: "/inactive", activeParentSessionId: "parent", activeSessionId: "selected", + drafts: Object.fromEntries([ + ...Array.from({ length: 24 }, (_, index) => [`older-${index}`, `draft-${index}`]), + ["selected", "keep inactive"], + ]) }), + ] }) + const inactive = normalized?.tabs.find((tab) => tab.kind === "workspace" && tab.folder === "/inactive") + + assert.equal(inactive?.kind === "workspace" ? inactive.activeParentSessionId : undefined, "parent") + assert.equal(inactive?.kind === "workspace" ? inactive.activeSessionId : undefined, "selected") + assert.equal(inactive?.kind === "workspace" ? inactive.drafts.selected : undefined, "keep inactive") + }) + + it("retains active-session attachments beyond the per-tab session budget", () => { + const activeAttachment = file("active.png", { id: "active-image", display: "[Image #1]", mediaType: "image/png" }) + const attachments = Object.fromEntries([ + ...Array.from({ length: 24 }, (_, index) => [`older-${index}`, [file(`older-${index}.txt`)]]), + ["active", [activeAttachment]], + ]) + const tab = normalizeRestorableSession({ activeTabIndex: 0, tabs: [workspace({ + activeSessionId: "active", + drafts: { active: "Review [Image #1]" }, + attachments, + })] })?.tabs[0] + + assert.equal(tab?.kind === "workspace" ? tab.attachments.active?.[0]?.id : undefined, "active-image") + assert.equal(tab?.kind === "workspace" ? tab.drafts.active : undefined, "Review [Image #1]") + }) + + it("reserves inactive selected attachments before active optional attachments", () => { + const activeAttachments = Object.fromEntries(Array.from({ length: 8 }, (_, sessionIndex) => [ + `active-${sessionIndex}`, + Array.from({ length: 8 }, (_, attachmentIndex) => file(`active-${sessionIndex}-${attachmentIndex}.txt`)), + ])) + const selected = file("inactive.txt", { id: "inactive-selected", display: "@inactive.txt" }) + const normalized = normalizeRestorableSession({ activeTabIndex: 0, tabs: [ + workspace({ activeSessionId: "active-0", drafts: { "active-0": "active" }, attachments: activeAttachments }), + workspace({ folder: "/inactive", activeSessionId: "selected", drafts: { selected: "Review @inactive.txt" }, + attachments: { selected: [selected] } }), + ] }) + const inactive = normalized?.tabs.find((tab) => tab.kind === "workspace" && tab.folder === "/inactive") + + assert.equal(inactive?.kind === "workspace" ? inactive.attachments.selected?.[0]?.id : undefined, "inactive-selected") + }) + + it("prioritizes active attachments during capture serialization", () => { + const activeAttachment = file("active.png", { id: "active-image", display: "[Image #1]", mediaType: "image/png" }) + const attachments = Object.fromEntries([ + ...Array.from({ length: 24 }, (_, index) => [`older-${index}`, [file(`older-${index}.txt`)]]), + ["active", [activeAttachment]], + ]) as never + const captured = serializeDraftAttachments({ active: "Review [Image #1]" }, attachments, ["active"]) + + assert.equal(captured.attachments.active?.[0]?.id, "active-image") + assert.equal(captured.drafts.active, "Review [Image #1]") + }) + it("does not let malformed tabs consume the identity budget", () => { - const session = normalizeRestorableSession({ - activeTabIndex: 24, - tabs: [ - ...Array.from({ length: 24 }, () => ({ - kind: "workspace", - folder: "x".repeat(4096), - drafts: [], - })), - { kind: "sidecar", sidecarId: "valid-late-tab" }, - ], - }) - - assert.deepEqual(session?.tabs, [{ kind: "sidecar", sidecarId: "valid-late-tab" }]) - assert.equal(session?.activeTabIndex, 0) + const normalized = normalizeRestorableSession({ activeTabIndex: 24, tabs: [ + ...Array.from({ length: 24 }, () => workspace({ folder: "x".repeat(4096), drafts: [] })), + { kind: "sidecar", sidecarId: "valid-late-tab" }, + ] }) + assert.deepEqual(normalized, { tabs: [{ kind: "sidecar", sidecarId: "valid-late-tab" }], activeTabIndex: 0 }) }) - it("reserves active session selection before optional drafts", () => { - const session = normalizeRestorableSession({ - activeTabIndex: 0, - tabs: [{ - kind: "workspace", - folder: "/work", - activeParentSessionId: "parent-session", - activeSessionId: "child-session", - drafts: { - first: "a".repeat(32 * 1024), - second: "b".repeat(32 * 1024), - third: "c".repeat(32 * 1024), - }, - }], - }) - - const workspace = session?.tabs[0] - assert.equal(workspace?.kind, "workspace") - if (workspace?.kind !== "workspace") return - assert.equal(workspace.activeParentSessionId, "parent-session") - assert.equal(workspace.activeSessionId, "child-session") - }) - - it("removes every picker mention for the ninth file attachment without touching unrelated mentions", () => { + it("removes every picker mention for attachments beyond the per-session limit", () => { const cases = [ - { - name: "relative file", - path: "./dir/f8", - filename: "f8", - display: "@f8", - tokens: ["@./dir/f8", "@f8"], - collisions: ["@./dir/f80", "@f80"], - mime: "text/plain", - }, - { - name: "absolute file", - path: "/var/work/f8.ts", - filename: "f8.ts", - display: "@f8.ts", - tokens: ["@/var/work/f8.ts", "@f8.ts"], - collisions: ["@/var/work/f8.ts.bak", "@f8.tsx"], - mime: "text/plain", - }, - { - name: "Windows file with regex characters", - path: "C:\\work\\[draft](8).ts", - filename: "[draft](8).ts", - display: "@[draft](8).ts", - tokens: ["@C:\\work\\[draft](8).ts", "@[draft](8).ts"], - collisions: ["@C:\\work\\[draft](8).tsx", "@[draft](8).tsx"], - mime: "text/plain", - }, - { - name: "raw spaced file path", - path: "./dir/my file.ts", - filename: "my file.ts", - display: "@my file.ts", - tokens: ["@./dir/my file.ts", "@my file.ts"], - collisions: ["@./dir/my file.tsx", "@my file.tsx"], - mime: "text/plain", - }, - { - name: "relative directory", - path: "./dir/nested", - filename: "nested/", - display: "@nested/", - tokens: ["@dir/nested/", "@nested/"], - collisions: ["@dir/nested/other", "@nested/other"], - mime: "inode/directory", - }, - ] - - const session = normalizeRestorableSession({ - activeTabIndex: 0, - tabs: cases.map((testCase, caseIndex) => ({ - kind: "workspace", - folder: `/work/${caseIndex}`, - drafts: { - session: `remove ${testCase.tokens.join(" then ")} keep ${testCase.collisions.join(" and ")} and @other`, - }, - attachments: { - session: [ - ...Array.from({ length: 8 }, (_, index) => ({ - id: `keep-${caseIndex}-${index}`, - type: "file", - display: `@keep-${index}`, - url: "", - filename: `keep-${index}`, - mediaType: "text/plain", - source: { type: "file", path: `./keep/${caseIndex}/${index}`, mime: "text/plain" }, - })), - { - id: `drop-${caseIndex}`, - type: "file", - display: testCase.display, - url: "", - filename: testCase.filename, - mediaType: testCase.mime, - source: { type: "file", path: testCase.path, mime: testCase.mime }, - }, - ], - }, - scrollSnapshots: {}, - })), - }) - - assert.ok(session) - for (const [index, testCase] of cases.entries()) { - const workspace: RestorableTabState | undefined = session.tabs[index] - assert.equal(workspace?.kind, "workspace", testCase.name) - if (workspace?.kind !== "workspace") continue - assert.equal(workspace.attachments.session?.length, 8, testCase.name) - assert.equal( - workspace.drafts.session, - `remove then keep ${testCase.collisions.join(" and ")} and @other`, - testCase.name, - ) + ["relative file", "./dir/f8", "f8", "@f8", + ["@./dir/f8", "@f8"], ["@./dir/f80", "@f80"], "text/plain"], + ["absolute file", "/var/work/f8.ts", "f8.ts", "@f8.ts", + ["@/var/work/f8.ts", "@f8.ts"], ["@/var/work/f8.ts.bak", "@f8.tsx"], "text/plain"], + ["Windows regex characters", "C:\\work\\[draft](8).ts", "[draft](8).ts", "@[draft](8).ts", + ["@C:\\work\\[draft](8).ts", "@[draft](8).ts"], + ["@C:\\work\\[draft](8).tsx", "@[draft](8).tsx"], "text/plain"], + ["raw spaced path", "./dir/my file.ts", "my file.ts", "@my file.ts", + ["@./dir/my file.ts", "@my file.ts"], ["@./dir/my file.tsx", "@my file.tsx"], "text/plain"], + ["relative directory", "./dir/nested", "nested/", "@nested/", + ["@dir/nested/", "@nested/"], ["@dir/nested/other", "@nested/other"], "inode/directory"], + ] as const + for (const [label, path, filename, display, tokens, collisions, mime] of cases) { + const keep = Array.from({ length: 8 }, (_, index) => file(`./keep/${index}`, { id: `keep-${index}` })) + const tab = normalizeWorkspace({ + drafts: { session: `remove ${tokens.join(" then ")} keep ${collisions.join(" and ")} and @other` }, + attachments: { session: [...keep, file(path, { id: "drop", filename, display, + mediaType: mime, source: { type: "file", path, mime } })] }, + }) + assert.equal(tab.attachments.session?.length, 8, label) + assert.equal(tab.drafts.session, `remove then keep ${collisions.join(" and ")} and @other`, label) } }) - it("removes loose dropped-attachment placeholders without removing ordinary bracket text", () => { - const session = normalizeRestorableSession({ - activeTabIndex: 0, - tabs: [{ - kind: "workspace", - folder: "/work", - drafts: { - session1: [ - "[Image #9]", - "[ Image # 9 ]", - "[iMaGe # 9]", - "Image #9", - "[Image #90]", - "[Image #9 notes]", - "[pasted #4]", - "[ pasted # 4 ]", - "[PaStEd # 4]", - "pasted #4", - "[pasted #40]", - "[pasted notes]", - "[ordinary bracket text]", - ].join("|"), - }, - attachments: { - session1: [ - { - id: "large-image", - type: "file", - display: "[Image #9]", - url: "", - filename: "large.png", - mediaType: "image/png", - source: { - type: "file", - path: "large.png", - mime: "image/png", - data: new Uint8Array(65 * 1024), - }, - }, - { - id: "large-paste", - type: "text", - display: "pasted #4 (4 lines)", - url: "", - filename: "paste-4.txt", - mediaType: "text/plain", - source: { type: "text", value: "x".repeat(24 * 1024 + 1) }, - }, - { - id: "ordinary-display", - type: "archive", - display: "[ordinary bracket text]", - url: "", - filename: "archive.zip", - mediaType: "application/zip", - source: { type: "archive" }, - }, - ], - }, - scrollSnapshots: {}, - }], - }) - const workspace = session?.tabs[0] - assert.equal(workspace?.kind, "workspace") - if (workspace?.kind !== "workspace") return - assert.equal(Object.keys(workspace.attachments).length, 0) - assert.equal( - workspace.drafts.session1, - "|||Image #9|[Image #90]|[Image #9 notes]||||pasted #4|[pasted #40]|[pasted notes]|[ordinary bracket text]", - ) + it("removes loose dropped placeholders without touching ordinary bracket text", () => { + const input = [ + "[Image #9]", "[ Image # 9 ]", "[iMaGe # 9]", "Image #9", + "[Image #90]", "[Image #9 notes]", + "[pasted #4]", "[ pasted # 4 ]", "[PaStEd # 4]", "pasted #4", + "[pasted #40]", "[pasted notes]", "[ordinary bracket text]", + ].join("|") + const dropped = [ + file("large.png", { id: "image", display: "[Image #9]", mediaType: "image/png", + source: { type: "file", path: "large.png", mime: "image/png", data: new Uint8Array(65 * 1024) } }), + attachment({ type: "text", value: "x".repeat(24 * 1024 + 1) }, { id: "paste", type: "text", display: "pasted #4 (4 lines)" }), + attachment({ type: "archive" }, { id: "ordinary", type: "archive", display: "[ordinary bracket text]" }), + ] + const tab = normalizeWorkspace({ drafts: { session1: input }, attachments: { session1: dropped } }) + assert.deepEqual({ ...tab.attachments }, {}) + assert.equal(tab.drafts.session1, + "|||Image #9|[Image #90]|[Image #9 notes]||||pasted #4|[pasted #40]|[pasted notes]|[ordinary bracket text]") }) - it("keeps the normalized attachment snapshot below the native 1 MiB limit", () => { - const session = normalizeRestorableSession({ - activeTabIndex: 0, - tabs: Array.from({ length: 32 }, (_, index) => ({ - kind: "workspace", - folder: `/work/${index}`, - drafts: { session: `[Image #${index + 1}]` }, - attachments: { - session: [{ - id: `file-${index}`, - type: "file", - display: `[Image #${index + 1}]`, - url: "", - filename: `file-${index}.bin`, - mediaType: "application/octet-stream", - source: { - type: "file", - path: `file-${index}.bin`, - mime: "application/octet-stream", - data: new Uint8Array(64 * 1024), - }, - }], - }, - scrollSnapshots: {}, - })), - }) - assert.ok(session) - assert.ok(Buffer.byteLength(JSON.stringify(session), "utf8") < 1024 * 1024) + it("keeps a maximal normalized attachment snapshot below the native 1 MiB limit", () => { + const normalized = session(...Array.from({ length: 32 }, (_, index) => workspace({ + folder: `/work/${index}`, drafts: { session: `[Image #${index + 1}]` }, + attachments: { session: [file(`file-${index}.bin`, { id: `file-${index}`, display: `[Image #${index + 1}]`, + mediaType: "application/octet-stream", source: { type: "file", path: `file-${index}.bin`, + mime: "application/octet-stream", data: new Uint8Array(64 * 1024) } })] }, + }))) + assert.ok(normalized) + assert.ok(Buffer.byteLength(JSON.stringify(normalized), "utf8") < 1024 * 1024) }) }) diff --git a/packages/ui/src/stores/client-state-codec.ts b/packages/ui/src/stores/client-state-codec.ts index 63ef31fa..e8d53e26 100644 --- a/packages/ui/src/stores/client-state-codec.ts +++ b/packages/ui/src/stores/client-state-codec.ts @@ -1,359 +1,281 @@ import type { ScrollSnapshot } from "./message-v2/types" -import { - createAttachmentCodecBudget, - normalizeRestorableAttachmentRecord, - type AttachmentCodecBudget, - type RestorableAttachment, -} from "./client-state-attachments-codec" +import { createAttachmentCodecBudget, normalizeRestorableAttachmentRecord, + type AttachmentCodecBudget, type RestorableAttachment } from "./client-state-attachments-codec" import type { PersistedGenerationRecovery } from "./session-generation-recovery" export interface RestorableWorkspaceTabState { - kind: "workspace" - folder: string - occurrence?: number - projectName?: string - binaryPath?: string - activeParentSessionId?: string - activeSessionId?: string - drafts: Record - attachments: Record - scrollSnapshots: Record - unseenIdleSince: Record + kind: "workspace"; folder: string; occurrence?: number; projectName?: string; binaryPath?: string + activeParentSessionId?: string; activeSessionId?: string + drafts: Record; attachments: Record + scrollSnapshots: Record; unseenIdleSince: Record generationRecovery: Record } -export interface RestorableSidecarTabState { - kind: "sidecar" - sidecarId: string -} - +export interface RestorableSidecarTabState { kind: "sidecar"; sidecarId: string } export type RestorableTabState = RestorableWorkspaceTabState | RestorableSidecarTabState - -export interface RestorableSessionState { - tabs: RestorableTabState[] - activeTabIndex: number -} - +export interface RestorableSessionState { tabs: RestorableTabState[]; activeTabIndex: number; homeActive?: boolean } export interface ClientSnapshotV1 { - version: 1 - revision: number - savedAt: number - layout: Record - session: RestorableSessionState | null + version: 1; revision: number; savedAt: number + layout: Record; session: RestorableSessionState | null } -const MAX_TABS = 32 -const MAX_LAYOUT_ENTRIES = 64 -const MAX_DRAFTS_PER_TAB = 24 -const MAX_SCROLL_SNAPSHOTS_PER_TAB = 96 -const MAX_IDLE_MARKERS_PER_TAB = 256 -const MAX_GENERATION_RECOVERY_PER_TAB = 256 -const MAX_KEY_LENGTH = 256 -const MAX_PATH_LENGTH = 4096 -const MAX_ID_LENGTH = 512 -const MAX_LAYOUT_VALUE_LENGTH = 4096 -const MAX_DRAFT_LENGTH = 32 * 1024 -const MAX_ANCHOR_KEY_LENGTH = 1024 -const MAX_TOTAL_STRING_LENGTH = 96 * 1024 -const MAX_TOTAL_SCROLL_SNAPSHOTS = 256 +const MAX_TABS = 32, MAX_LAYOUT_ENTRIES = 64, MAX_DRAFTS = 24, MAX_SCROLLS_PER_TAB = 96 +const MAX_IDLE_MARKERS = 256, MAX_RECOVERY = 256, MAX_KEY = 256, MAX_PATH = 4096, MAX_ID = 512 +const MAX_LAYOUT_VALUE = 4096, MAX_DRAFT = 32 * 1024, MAX_ANCHOR_KEY = 1024 +const MAX_STRINGS = 96 * 1024, MAX_SCROLLS = 256 +const NO_SESSION_DRAFT_SESSION_ID = "__no_session_draft__" -interface StringBudget { - remaining: number - scrollSnapshotsRemaining: number - attachments: AttachmentCodecBudget +interface StringBudget { remaining: number; scrollSnapshotsRemaining: number; attachments: AttachmentCodecBudget } + +function createBudget(): StringBudget { + return { remaining: MAX_STRINGS, scrollSnapshotsRemaining: MAX_SCROLLS, attachments: createAttachmentCodecBudget() } } -function createStringBudget(): StringBudget { - return { - remaining: MAX_TOTAL_STRING_LENGTH, - scrollSnapshotsRemaining: MAX_TOTAL_SCROLL_SNAPSHOTS, - attachments: createAttachmentCodecBudget(), - } -} +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value) +const safeKey = (key: string) => key !== "__proto__" && key !== "constructor" && key !== "prototype" -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value) -} - -function isSafeRecordKey(key: string): boolean { - return key !== "__proto__" && key !== "constructor" && key !== "prototype" -} - -function takeString( - value: unknown, - maxLength: number, - budget: StringBudget, - options: { allowEmpty?: boolean } = {}, -): string | undefined { - if (typeof value !== "string" || value.length > maxLength || value.length > budget.remaining) return undefined - if (!options.allowEmpty && value.trim().length === 0) return undefined +function takeString(value: unknown, max: number, budget: StringBudget, allowEmpty = false): string | undefined { + if (typeof value !== "string" || value.length > max || value.length > budget.remaining) return + if (!allowEmpty && value.trim().length === 0) return budget.remaining -= value.length return value } -function takeOptionalString(value: unknown, maxLength: number, budget: StringBudget): string | undefined { - return value === undefined ? undefined : takeString(value, maxLength, budget) -} +const takeNumber = (value: unknown, min: number, max: number): number | undefined => + typeof value === "number" && Number.isFinite(value) && value >= min && value <= max ? value : undefined -function takeFiniteNumber(value: unknown, min: number, max: number): number | undefined { - if (typeof value !== "number" || !Number.isFinite(value) || value < min || value > max) return undefined - return value +function normalizeRecord( + value: unknown, max: number, budget: StringBudget, + normalize: (value: unknown) => T | undefined, + accept: (value: unknown) => boolean = () => true, + accepted: () => void = () => {}, + priorityKeys: readonly string[] = [], +): Record | null { + if (!isRecord(value)) return null + const result: Record = Object.create(null) + let count = 0 + const priorities = [...new Set(priorityKeys)] + const prioritySet = new Set(priorities) + const entries = [ + ...priorities.flatMap((key) => Object.prototype.hasOwnProperty.call(value, key) ? [[key, value[key]] as const] : []), + ...Object.entries(value).filter(([key]) => !prioritySet.has(key)), + ] + for (const [rawKey, rawValue] of entries) { + if (count >= max) break + if (!safeKey(rawKey) || !accept(rawValue)) continue + const key = takeString(rawKey, MAX_KEY, budget) + const entry = normalize(rawValue) + if (key === undefined || entry === undefined) continue + result[key] = entry + count += 1 + accepted() + } + return result } function normalizeStringRecord( value: unknown, - maxEntries: number, - maxValueLength: number, + max: number, + valueMax: number, budget: StringBudget, -): Record | null { - if (!isRecord(value)) return null - - const result: Record = Object.create(null) - let count = 0 - for (const [rawKey, rawValue] of Object.entries(value)) { - if (count >= maxEntries) break - if (!isSafeRecordKey(rawKey)) continue - - const key = takeString(rawKey, MAX_KEY_LENGTH, budget) - const entry = takeString(rawValue, maxValueLength, budget, { allowEmpty: true }) - if (key === undefined || entry === undefined) continue - result[key] = entry - count += 1 - } - return result + priorityKeys: readonly string[] = [], +) { + return normalizeRecord(value, max, budget, (entry) => takeString(entry, valueMax, budget, true), + undefined, undefined, priorityKeys) } -function normalizeScrollSnapshot(value: unknown, budget: StringBudget): ScrollSnapshot | null { - if (!isRecord(value)) return null - - const scrollTop = takeFiniteNumber(value.scrollTop, 0, 1_000_000_000) - const updatedAt = takeFiniteNumber(value.updatedAt, 0, Number.MAX_SAFE_INTEGER) - if (scrollTop === undefined || updatedAt === undefined || typeof value.atBottom !== "boolean") return null - +function normalizeScrollSnapshot(value: unknown, budget: StringBudget): ScrollSnapshot | undefined { + if (!isRecord(value)) return + const scrollTop = takeNumber(value.scrollTop, 0, 1_000_000_000) + const updatedAt = takeNumber(value.updatedAt, 0, Number.MAX_SAFE_INTEGER) + if (scrollTop === undefined || updatedAt === undefined || typeof value.atBottom !== "boolean") return const result: ScrollSnapshot = { scrollTop, atBottom: value.atBottom, updatedAt } - - if (value.scrollRatio !== undefined) { - const scrollRatio = takeFiniteNumber(value.scrollRatio, 0, 1) - if (scrollRatio !== undefined) result.scrollRatio = scrollRatio - } - if (value.maxScrollTop !== undefined) { - const maxScrollTop = takeFiniteNumber(value.maxScrollTop, 0, 1_000_000_000) - if (maxScrollTop !== undefined) result.maxScrollTop = maxScrollTop - } - if (value.anchorKey !== undefined) { - const anchorKey = takeString(value.anchorKey, MAX_ANCHOR_KEY_LENGTH, budget) - if (anchorKey !== undefined) result.anchorKey = anchorKey - } - if (value.anchorOffset !== undefined) { - const anchorOffset = takeFiniteNumber(value.anchorOffset, -1_000_000, 1_000_000) - if (anchorOffset !== undefined) result.anchorOffset = anchorOffset - } - if (value.followModeType === "following" || value.followModeType === "escaped") { - result.followModeType = value.followModeType - } - - return result -} - -function normalizeScrollSnapshotRecord(value: unknown, budget: StringBudget): Record | null { - if (!isRecord(value)) return null - - const result: Record = Object.create(null) - let count = 0 - for (const [rawKey, rawValue] of Object.entries(value)) { - if (count >= MAX_SCROLL_SNAPSHOTS_PER_TAB || budget.scrollSnapshotsRemaining <= 0) break - if (!isSafeRecordKey(rawKey)) continue - - const key = takeString(rawKey, MAX_KEY_LENGTH, budget) - const snapshot = normalizeScrollSnapshot(rawValue, budget) - if (key === undefined || snapshot === null) continue - result[key] = snapshot - count += 1 - budget.scrollSnapshotsRemaining -= 1 - } - return result -} - -function normalizeIdleMarkerRecord(value: unknown, budget: StringBudget): Record | null { - if (!isRecord(value)) return null - - const result: Record = Object.create(null) - let count = 0 - for (const [rawKey, rawValue] of Object.entries(value)) { - if (count >= MAX_IDLE_MARKERS_PER_TAB) break - if (!isSafeRecordKey(rawKey)) continue - - const key = takeString(rawKey, MAX_KEY_LENGTH, budget) - const idleSince = takeFiniteNumber(rawValue, 0, Number.MAX_SAFE_INTEGER) - if (key === undefined || idleSince === undefined) continue - result[key] = idleSince - count += 1 - } - return result -} - -function normalizeGenerationRecoveryRecord( - value: unknown, - budget: StringBudget, -): Record | null { - if (!isRecord(value)) return null - - const result: Record = Object.create(null) - let count = 0 - for (const [rawKey, rawValue] of Object.entries(value)) { - if (count >= MAX_GENERATION_RECOVERY_PER_TAB) break - if (!isSafeRecordKey(rawKey) || (rawValue !== "working" && rawValue !== "interrupted")) continue - - const key = takeString(rawKey, MAX_KEY_LENGTH, budget) - if (key === undefined) continue - result[key] = rawValue - count += 1 - } + const scrollRatio = takeNumber(value.scrollRatio, 0, 1) + const maxScrollTop = takeNumber(value.maxScrollTop, 0, 1_000_000_000) + const anchorOffset = takeNumber(value.anchorOffset, -1_000_000, 1_000_000) + const anchorKey = value.anchorKey === undefined ? undefined : takeString(value.anchorKey, MAX_ANCHOR_KEY, budget) + if (scrollRatio !== undefined) result.scrollRatio = scrollRatio + if (maxScrollTop !== undefined) result.maxScrollTop = maxScrollTop + if (anchorKey !== undefined) result.anchorKey = anchorKey + if (anchorOffset !== undefined) result.anchorOffset = anchorOffset + if (value.followModeType === "following" || value.followModeType === "escaped") result.followModeType = value.followModeType return result } function normalizeWorkspaceTab( value: Record, - folder: string, - activeParentSessionId: string | undefined, - activeSessionId: string | undefined, + identity: WorkspaceIdentity, budget: StringBudget, + _prioritizeDrafts: boolean, ): RestorableWorkspaceTabState | null { - const normalizedDrafts = normalizeStringRecord(value.drafts ?? {}, MAX_DRAFTS_PER_TAB, MAX_DRAFT_LENGTH, budget) - const scrollSnapshots = normalizeScrollSnapshotRecord(value.scrollSnapshots ?? {}, budget) - const unseenIdleSince = normalizeIdleMarkerRecord(value.unseenIdleSince ?? {}, budget) - const generationRecovery = normalizeGenerationRecoveryRecord(value.generationRecovery ?? {}, budget) - if ( - normalizedDrafts === null - || scrollSnapshots === null - || unseenIdleSince === null - || generationRecovery === null - ) return null - const attachmentResult = normalizeRestorableAttachmentRecord(value.attachments ?? {}, normalizedDrafts, budget.attachments) - if (attachmentResult === null) return null + const reservedDrafts = identity.priorityDrafts + const remainingDrafts = Object.fromEntries(Object.entries(value.drafts ?? {}) + .filter(([id]) => !Object.prototype.hasOwnProperty.call(reservedDrafts, id))) + const additionalDrafts = normalizeStringRecord( + remainingDrafts, + Math.max(0, MAX_DRAFTS - Object.keys(reservedDrafts).length), + MAX_DRAFT, + budget, + ) + const drafts = additionalDrafts ? { ...reservedDrafts, ...additionalDrafts } : null + const scrollLimit = Math.min(MAX_SCROLLS_PER_TAB, budget.scrollSnapshotsRemaining) + const scrollSnapshots = normalizeRecord(value.scrollSnapshots ?? {}, scrollLimit, budget, + (entry) => normalizeScrollSnapshot(entry, budget), undefined, + () => { budget.scrollSnapshotsRemaining -= 1 }) + const unseenIdleSince = normalizeRecord(value.unseenIdleSince ?? {}, MAX_IDLE_MARKERS, budget, + (entry) => takeNumber(entry, 0, Number.MAX_SAFE_INTEGER)) + const generationRecovery = normalizeRecord( + value.generationRecovery ?? {}, MAX_RECOVERY, budget, + (entry) => entry as PersistedGenerationRecovery, + (entry) => entry === "working" || entry === "interrupted") + if (!drafts || !scrollSnapshots || !unseenIdleSince || !generationRecovery) return null + const remainingAttachments = Object.fromEntries(Object.entries(value.attachments ?? {}) + .filter(([id]) => !identity.prioritySessionIds.includes(id))) + const attachmentResult = normalizeRestorableAttachmentRecord( + remainingAttachments, drafts, budget.attachments, + ) + if (!attachmentResult) return null const result: RestorableWorkspaceTabState = { - kind: "workspace", - folder, + kind: "workspace", folder: identity.folder, drafts: attachmentResult.drafts, - attachments: attachmentResult.attachments, - scrollSnapshots, - unseenIdleSince, - generationRecovery, + attachments: { ...identity.priorityAttachments, ...attachmentResult.attachments }, + scrollSnapshots, unseenIdleSince, generationRecovery, } if (Number.isInteger(value.occurrence) && Number(value.occurrence) >= 0 && Number(value.occurrence) < MAX_TABS) { result.occurrence = Number(value.occurrence) } - const projectName = takeOptionalString(value.projectName, MAX_PATH_LENGTH, budget) - const binaryPath = takeOptionalString(value.binaryPath, MAX_PATH_LENGTH, budget) + const projectName = value.projectName === undefined ? undefined : takeString(value.projectName, MAX_PATH, budget) + const binaryPath = value.binaryPath === undefined ? undefined : takeString(value.binaryPath, MAX_PATH, budget) if (projectName !== undefined) result.projectName = projectName if (binaryPath !== undefined) result.binaryPath = binaryPath - if (activeParentSessionId !== undefined) result.activeParentSessionId = activeParentSessionId - if (activeSessionId !== undefined) result.activeSessionId = activeSessionId + if (identity.activeParentSessionId !== undefined) result.activeParentSessionId = identity.activeParentSessionId + if (identity.activeSessionId !== undefined) result.activeSessionId = identity.activeSessionId return result } -type NormalizedTabIdentity = - | { - kind: "workspace" - value: Record - folder: string - activeParentSessionId: string | undefined - activeSessionId: string | undefined - } - | { kind: "sidecar"; sidecarId: string } +interface WorkspaceIdentity { + kind: "workspace"; value: Record; folder: string + activeParentSessionId?: string; activeSessionId?: string + prioritySessionIds: string[]; priorityDrafts: Record + priorityAttachments: Record +} +type TabIdentity = WorkspaceIdentity | RestorableSidecarTabState -function normalizeTabIdentity(value: unknown, budget: StringBudget): NormalizedTabIdentity | null { +function normalizeIdentity(value: unknown, budget: StringBudget): TabIdentity | null { if (!isRecord(value)) return null - - // `type` is accepted as an early-schema migration alias, but v1 is always - // written with `kind` so subsequent loads have one canonical shape. const kind = value.kind ?? value.type - if (kind === "workspace" || kind === "instance") { - if ( - !isRecord(value.drafts ?? {}) - || !isRecord(value.attachments ?? {}) - || !isRecord(value.scrollSnapshots ?? {}) - || !isRecord(value.unseenIdleSince ?? {}) - || !isRecord(value.generationRecovery ?? {}) - ) return null - const folder = takeString(value.folder, MAX_PATH_LENGTH, budget) - if (folder === undefined) return null - const activeParentSessionId = takeOptionalString(value.activeParentSessionId, MAX_ID_LENGTH, budget) - const activeSessionId = takeOptionalString(value.activeSessionId, MAX_ID_LENGTH, budget) - return { kind: "workspace", value, folder, activeParentSessionId, activeSessionId } + if (kind === "sidecar") { + const sidecarId = takeString(value.sidecarId, MAX_ID, budget) + return sidecarId === undefined ? null : { kind, sidecarId } } - if (kind !== "sidecar") return null + if (kind !== "workspace" && kind !== "instance") return null + if (!["drafts", "attachments", "scrollSnapshots", "unseenIdleSince", "generationRecovery"] + .every((key) => isRecord(value[key] ?? {}))) return null + const folder = takeString(value.folder, MAX_PATH, budget) + if (folder === undefined) return null + const activeParentSessionId = value.activeParentSessionId === undefined ? undefined + : takeString(value.activeParentSessionId, MAX_ID, budget) + const activeSessionId = value.activeSessionId === undefined ? undefined : takeString(value.activeSessionId, MAX_ID, budget) + const prioritySessionIds = [activeSessionId, NO_SESSION_DRAFT_SESSION_ID] + .filter((id): id is string => Boolean(id)) + return { + kind: "workspace", value, folder, activeParentSessionId, activeSessionId, + prioritySessionIds, priorityDrafts: Object.create(null), priorityAttachments: Object.create(null), + } +} - const sidecarId = takeString(value.sidecarId, MAX_ID_LENGTH, budget) - return sidecarId === undefined ? null : { kind: "sidecar", sidecarId } +function reservePriorityDrafts(identity: WorkspaceIdentity, budget: StringBudget): void { + const drafts = identity.value.drafts ?? {} + if (!isRecord(drafts)) return + for (const rawKey of identity.prioritySessionIds) { + if (!safeKey(rawKey) || !Object.prototype.hasOwnProperty.call(drafts, rawKey)) continue + const remaining = budget.remaining + const key = takeString(rawKey, MAX_KEY, budget) + const draft = takeString(drafts[rawKey], MAX_DRAFT, budget, true) + if (key === undefined || draft === undefined) { + budget.remaining = remaining + continue + } + identity.priorityDrafts[key] = draft + } + const attachments = identity.value.attachments ?? {} + if (!isRecord(attachments)) return + const priorityAttachments = Object.fromEntries(identity.prioritySessionIds + .filter((id) => Object.prototype.hasOwnProperty.call(attachments, id)) + .map((id) => [id, attachments[id]])) + const result = normalizeRestorableAttachmentRecord( + priorityAttachments, + identity.priorityDrafts, + budget.attachments, + identity.prioritySessionIds, + ) + if (!result) return + identity.priorityDrafts = result.drafts + identity.priorityAttachments = result.attachments } export function normalizeRestorableSession(value: unknown): RestorableSessionState | null { - return normalizeRestorableSessionWithBudget(value, createStringBudget()) + return normalizeSession(value, createBudget()) } -function normalizeRestorableSessionWithBudget(value: unknown, budget: StringBudget): RestorableSessionState | null { +function normalizeSession(value: unknown, budget: StringBudget): RestorableSessionState | null { if (!isRecord(value) || !Array.isArray(value.tabs) || !Number.isInteger(value.activeTabIndex)) return null - - const normalizedIdentities: Array<{ originalIndex: number; identity: NormalizedTabIdentity }> = [] - for (const [originalIndex, rawTab] of value.tabs.slice(0, MAX_TABS).entries()) { - const identity = normalizeTabIdentity(rawTab, budget) - if (identity) normalizedIdentities.push({ originalIndex, identity }) + const requested = Number(value.activeTabIndex) + const rawTabs = value.tabs.slice(0, MAX_TABS) + const identityByIndex = new Map() + const normalizedByIndex = new Map() + const normalizeIdentityAt = (originalIndex: number) => { + const identity = normalizeIdentity(rawTabs[originalIndex], budget) + if (!identity) return + identityByIndex.set(originalIndex, identity) } - - const normalizedTabs: Array<{ originalIndex: number; tab: RestorableTabState }> = [] - for (const { originalIndex, identity } of normalizedIdentities) { + const normalizeTabAt = (originalIndex: number, prioritizeDrafts: boolean) => { + const identity = identityByIndex.get(originalIndex) + if (!identity) return const tab = identity.kind === "sidecar" ? identity - : normalizeWorkspaceTab( - identity.value, - identity.folder, - identity.activeParentSessionId, - identity.activeSessionId, - budget, - ) - if (tab) normalizedTabs.push({ originalIndex, tab }) + : normalizeWorkspaceTab(identity.value, identity, budget, prioritizeDrafts) + if (tab) normalizedByIndex.set(originalIndex, tab) } + rawTabs.forEach((_tab, index) => normalizeIdentityAt(index)) + const priorityOrder = [requested, ...rawTabs.map((_tab, index) => index).filter((index) => index !== requested)] + for (const index of priorityOrder) { + const identity = identityByIndex.get(index) + if (identity?.kind === "workspace") reservePriorityDrafts(identity, budget) + } + if (rawTabs[requested] !== undefined) normalizeTabAt(requested, true) + rawTabs.forEach((_tab, index) => { + if (index !== requested) normalizeTabAt(index, true) + }) + const identities = rawTabs.flatMap((_tab, originalIndex) => { + const identity = identityByIndex.get(originalIndex) + return identity ? [{ originalIndex, identity }] : [] + }) + const normalized = identities.flatMap(({ originalIndex }) => { + const tab = normalizedByIndex.get(originalIndex) + return tab ? [{ originalIndex, tab }] : [] + }) + if (value.tabs.length && !normalized.length) return null - const tabs = normalizedTabs.map((entry) => entry.tab) - if (value.tabs.length > 0 && tabs.length === 0) return null - const requestedActiveTabIndex = Number(value.activeTabIndex) - const survivingActiveIndex = normalizedTabs.findIndex((entry) => entry.originalIndex === requestedActiveTabIndex) - const fallbackActiveIndex = normalizedTabs.findIndex((entry) => entry.originalIndex > requestedActiveTabIndex) - const activeTabIndex = tabs.length === 0 - ? -1 - : survivingActiveIndex >= 0 - ? survivingActiveIndex - : fallbackActiveIndex >= 0 - ? fallbackActiveIndex - : tabs.length - 1 - return { tabs, activeTabIndex } + const tabs = normalized.map(({ tab }) => tab) + const surviving = normalized.findIndex(({ originalIndex }) => originalIndex === requested) + const next = normalized.findIndex(({ originalIndex }) => originalIndex > requested) + const activeTabIndex = !tabs.length ? -1 : surviving >= 0 ? surviving : next >= 0 ? next : tabs.length - 1 + return { tabs, activeTabIndex, ...(value.homeActive === true ? { homeActive: true } : {}) } } export function decodeClientSnapshot(value: unknown): ClientSnapshotV1 | null { - if (!isRecord(value) || value.version !== 1) return null - if (!Number.isSafeInteger(value.revision) || Number(value.revision) < 0) return null - - const savedAt = takeFiniteNumber(value.savedAt, 0, Number.MAX_SAFE_INTEGER) + if (!isRecord(value) || (value.version !== undefined && value.version !== 1) + || !Number.isSafeInteger(value.revision) || Number(value.revision) < 0) return null + const savedAt = takeNumber(value.savedAt, 0, Number.MAX_SAFE_INTEGER) if (savedAt === undefined) return null - - const budget = createStringBudget() - let session: RestorableSessionState | null = null - if (value.session !== null) { - session = normalizeRestorableSessionWithBudget(value.session, budget) - if (session === null) return null - } - const layout = normalizeStringRecord(value.layout, MAX_LAYOUT_ENTRIES, MAX_LAYOUT_VALUE_LENGTH, budget) - if (layout === null) return null - - return { - version: 1, - revision: Number(value.revision), - savedAt, - layout, - session, - } + const budget = createBudget() + const session = value.session === null ? null : normalizeSession(value.session, budget) + if (value.session !== null && !session) return null + const layout = normalizeStringRecord(value.layout, MAX_LAYOUT_ENTRIES, MAX_LAYOUT_VALUE, budget) + return layout ? { version: 1, revision: Number(value.revision), savedAt, layout, session } : null } export function isFutureClientSnapshot(value: unknown): boolean { diff --git a/packages/ui/src/stores/client-state-disable-failure.test.ts b/packages/ui/src/stores/client-state-disable-failure.test.ts deleted file mode 100644 index 83db5e21..00000000 --- a/packages/ui/src/stores/client-state-disable-failure.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import assert from "node:assert/strict" -import { it } from "node:test" - -it("rolls back and persists mutations buffered during a delayed failed disable", async () => { - let rejectDisable: ((error: Error) => void) | undefined - let markDisableStarted: (() => void) | undefined - const disableStarted = new Promise((resolve) => { - markDisableStarted = resolve - }) - const preferenceUpdates: boolean[] = [] - const savedSnapshots: unknown[] = [] - Object.defineProperty(globalThis, "window", { - configurable: true, - value: { - __CODENOMAD_RUNTIME_HOST__: "electron", - __CODENOMAD_WINDOW_CONTEXT__: "local", - localStorage: createMemoryStorage(), - electronAPI: { - claimClientStateAccess: async () => true, - loadClientState: async () => ({ isPrimary: true, restoreEnabled: true, snapshot: null }), - saveClientState: async (_token: string, snapshot: unknown) => { - savedSnapshots.push(snapshot) - return true - }, - setClientStateRestoreEnabled: (_token: string, enabled: boolean) => { - preferenceUpdates.push(enabled) - markDisableStarted?.() - return new Promise((_resolve, reject) => { - rejectDisable = reject - }) - }, - }, - }, - }) - - const clientState = await import("./client-state.ts") - await clientState.initializeClientState() - - const disabling = clientState.setRestorePreviousStateEnabled(false) - await disableStarted - clientState.updateRestorableSession({ tabs: [{ kind: "sidecar", sidecarId: "buffered" }], activeTabIndex: 0 }) - clientState.writeClientLayoutValue("opencode-session-sidebar-width-v8", "430") - assert.ok(rejectDisable) - rejectDisable(new Error("native disable failed")) - await assert.rejects(disabling, /native disable failed/) - - assert.equal(clientState.restorePreviousStateEnabled(), true) - assert.deepEqual(preferenceUpdates, [false]) - await clientState.flushClientState() - assert.equal(savedSnapshots.length, 1) - assert.equal((savedSnapshots[0] as any).session.tabs[0].sidecarId, "buffered") - assert.equal((savedSnapshots[0] as any).layout["opencode-session-sidebar-width-v8"], "430") -}) - -function createMemoryStorage(): Storage { - const values = new Map() - return { - get length() { return values.size }, - clear: () => values.clear(), - getItem: (key) => values.get(key) ?? null, - key: (index) => [...values.keys()][index] ?? null, - removeItem: (key) => { values.delete(key) }, - setItem: (key, value) => { values.set(key, String(value)) }, - } -} diff --git a/packages/ui/src/stores/client-state-disable-rejected.test.ts b/packages/ui/src/stores/client-state-disable-rejected.test.ts deleted file mode 100644 index 58b0eff7..00000000 --- a/packages/ui/src/stores/client-state-disable-rejected.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import assert from "node:assert/strict" -import { it } from "node:test" - -it("keeps primary ownership so a future envelope can be cleared after disable is rejected", async () => { - let disableCount = 0 - let clearCount = 0 - Object.defineProperty(globalThis, "window", { - configurable: true, - value: { - __CODENOMAD_RUNTIME_HOST__: "electron", - __CODENOMAD_WINDOW_CONTEXT__: "local", - electronAPI: { - claimClientStateAccess: async () => true, - loadClientState: async () => ({ - isPrimary: true, - restoreEnabled: true, - snapshot: { version: 2, future: true }, - }), - setClientStateRestoreEnabled: async () => { - disableCount += 1 - return false - }, - clearClientState: async () => { - clearCount += 1 - return true - }, - }, - }, - }) - - const clientState = await import("./client-state.ts") - await clientState.initializeClientState() - await assert.rejects(clientState.setRestorePreviousStateEnabled(false), /update was rejected/) - - assert.equal(clientState.restorePreviousStateEnabled(), true) - assert.equal(clientState.clientStateIsPrimary(), true) - assert.equal(disableCount, 1) - await clientState.clearRestoredClientState() - assert.equal(clientState.clientStateIsPrimary(), true) - assert.equal(clearCount, 1) -}) diff --git a/packages/ui/src/stores/client-state-disable-retry.test.ts b/packages/ui/src/stores/client-state-disable-retry.test.ts deleted file mode 100644 index e7d6374e..00000000 --- a/packages/ui/src/stores/client-state-disable-retry.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import assert from "node:assert/strict" -import { it } from "node:test" - -it("retries an in-flight failed save after disable clear is rejected and rolled back", async () => { - let rejectFirstSave: ((error: Error) => void) | undefined - let markFirstSaveStarted: (() => void) | undefined - const firstSaveStarted = new Promise((resolve) => { - markFirstSaveStarted = resolve - }) - const savedSnapshots: unknown[] = [] - const preferenceUpdates: boolean[] = [] - Object.defineProperty(globalThis, "window", { - configurable: true, - value: { - __CODENOMAD_RUNTIME_HOST__: "electron", - __CODENOMAD_WINDOW_CONTEXT__: "local", - localStorage: createMemoryStorage(), - electronAPI: { - claimClientStateAccess: async () => true, - loadClientState: async () => ({ isPrimary: true, restoreEnabled: true, snapshot: null }), - saveClientState: (_token: string, snapshot: unknown) => { - savedSnapshots.push(snapshot) - if (savedSnapshots.length > 1) return Promise.resolve(true) - markFirstSaveStarted?.() - return new Promise((_resolve, reject) => { - rejectFirstSave = reject - }) - }, - setClientStateRestoreEnabled: async (_token: string, enabled: boolean) => { - preferenceUpdates.push(enabled) - throw new Error("native disable failed") - }, - }, - }, - }) - - const clientState = await import("./client-state.ts") - await clientState.initializeClientState() - clientState.updateRestorableSession({ tabs: [{ kind: "sidecar", sidecarId: "retry" }], activeTabIndex: 0 }) - - const firstFlush = clientState.flushClientState() - await firstSaveStarted - const disabling = clientState.setRestorePreviousStateEnabled(false) - assert.ok(rejectFirstSave) - rejectFirstSave(new Error("first save failed")) - - await assert.rejects(firstFlush, /first save failed/) - await assert.rejects(disabling, /native disable failed/) - await clientState.flushClientState() - - assert.equal(clientState.restorePreviousStateEnabled(), true) - assert.deepEqual(preferenceUpdates, [false]) - assert.equal(savedSnapshots.length, 2) - assert.equal((savedSnapshots[1] as any).session.tabs[0].sidecarId, "retry") -}) - -function createMemoryStorage(): Storage { - const values = new Map() - return { - get length() { return values.size }, - clear: () => values.clear(), - getItem: (key) => values.get(key) ?? null, - key: (index) => [...values.keys()][index] ?? null, - removeItem: (key) => { values.delete(key) }, - setItem: (key, value) => { values.set(key, String(value)) }, - } -} diff --git a/packages/ui/src/stores/client-state-flush.test.ts b/packages/ui/src/stores/client-state-flush.test.ts deleted file mode 100644 index baccc8dc..00000000 --- a/packages/ui/src/stores/client-state-flush.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import assert from "node:assert/strict" -import { it } from "node:test" - -it("waits for replacement writes, retries once, and bounds persistent flush failures", async () => { - let phase: "race" | "one-time" | "persistent" = "race" - let phaseAttempts = 0 - let releaseFirstRaceSave!: () => void - let releaseSecondRaceSave!: () => void - let markFirstRaceSaveStarted!: () => void - let markSecondRaceSaveStarted!: () => void - const firstRaceSaveStarted = new Promise((resolve) => { markFirstRaceSaveStarted = resolve }) - const secondRaceSaveStarted = new Promise((resolve) => { markSecondRaceSaveStarted = resolve }) - const firstRaceSave = new Promise((resolve) => { releaseFirstRaceSave = resolve }) - const secondRaceSave = new Promise((resolve) => { releaseSecondRaceSave = resolve }) - Object.defineProperty(globalThis, "window", { - configurable: true, - value: { - __CODENOMAD_RUNTIME_HOST__: "electron", - __CODENOMAD_WINDOW_CONTEXT__: "local", - electronAPI: { - claimClientStateAccess: async () => true, - loadClientState: async () => ({ isPrimary: true, restoreEnabled: true, snapshot: null }), - saveClientState: async () => { - phaseAttempts += 1 - if (phase === "race") { - if (phaseAttempts === 1) { - markFirstRaceSaveStarted() - await firstRaceSave - } else { - markSecondRaceSaveStarted() - await secondRaceSave - } - return true - } - if (phase === "persistent" || phaseAttempts === 1) throw new Error(`${phase} save failure`) - return true - }, - }, - }, - }) - - const clientState = await import("./client-state.ts") - await clientState.initializeClientState() - - clientState.updateRestorableSession({ tabs: [{ kind: "sidecar", sidecarId: "first" }], activeTabIndex: 0 }) - let racingFlushSettled = false - const racingFlush = clientState.flushClientState().finally(() => { racingFlushSettled = true }) - await firstRaceSaveStarted - clientState.updateRestorableSession({ tabs: [{ kind: "sidecar", sidecarId: "second" }], activeTabIndex: 0 }) - await new Promise((resolve) => setTimeout(resolve, 300)) - releaseFirstRaceSave() - await secondRaceSaveStarted - await Promise.resolve() - assert.equal(racingFlushSettled, false) - releaseSecondRaceSave() - await racingFlush - - phase = "one-time" - phaseAttempts = 0 - clientState.updateRestorableSession({ tabs: [{ kind: "sidecar", sidecarId: "retry-once" }], activeTabIndex: 0 }) - await clientState.flushClientState() - assert.equal(phaseAttempts, 2) - - phase = "persistent" - phaseAttempts = 0 - clientState.updateRestorableSession({ tabs: [{ kind: "sidecar", sidecarId: "never-saved" }], activeTabIndex: 0 }) - await assert.rejects(clientState.flushClientState(), /persistent save failure/) - assert.equal(phaseAttempts, 3) -}) diff --git a/packages/ui/src/stores/client-state-future.test.ts b/packages/ui/src/stores/client-state-future.test.ts deleted file mode 100644 index 2e0a6b96..00000000 --- a/packages/ui/src/stores/client-state-future.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import assert from "node:assert/strict" -import { it } from "node:test" - -it("clears a native future envelope while suppressing recapture for the rest of the run", async () => { - let clearCount = 0 - let saveCount = 0 - Object.defineProperty(globalThis, "window", { - configurable: true, - value: { - __CODENOMAD_RUNTIME_HOST__: "electron", - __CODENOMAD_WINDOW_CONTEXT__: "local", - electronAPI: { - claimClientStateAccess: async () => true, - loadClientState: async () => ({ - isPrimary: true, - restoreEnabled: true, - snapshot: { version: 2, future: true }, - }), - saveClientState: async () => { - saveCount += 1 - return true - }, - clearClientState: async () => { - clearCount += 1 - return true - }, - }, - }, - }) - - const clientState = await import("./client-state.ts") - await clientState.initializeClientState() - clientState.writeClientLayoutValue("opencode-session-sidebar-width-v8", "350") - clientState.updateRestorableSession({ tabs: [{ kind: "sidecar", sidecarId: "docs" }], activeTabIndex: 0 }) - await clientState.flushClientState() - assert.equal(saveCount, 0) - - await clientState.clearRestoredClientState() - clientState.writeClientLayoutValue("opencode-session-sidebar-width-v8", "360") - clientState.updateRestorableSession({ tabs: [{ kind: "sidecar", sidecarId: "new" }], activeTabIndex: 0 }) - await clientState.flushClientState() - assert.equal(clearCount, 1) - assert.equal(saveCount, 0) -}) diff --git a/packages/ui/src/stores/client-state-race.test.ts b/packages/ui/src/stores/client-state-race.test.ts deleted file mode 100644 index 25fa71ff..00000000 --- a/packages/ui/src/stores/client-state-race.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import assert from "node:assert/strict" -import { it } from "node:test" - -it("blocks captures before an in-flight clear reaches the native host", async () => { - let finishClear: ((cleared: boolean) => void) | undefined - let nativeSaveCount = 0 - Object.defineProperty(globalThis, "window", { - configurable: true, - value: { - __CODENOMAD_RUNTIME_HOST__: "electron", - __CODENOMAD_WINDOW_CONTEXT__: "local", - localStorage: createMemoryStorage(), - electronAPI: { - claimClientStateAccess: async () => true, - loadClientState: async () => ({ - isPrimary: true, - restoreEnabled: true, - snapshot: { - version: 1, - revision: 1, - savedAt: 1, - layout: {}, - session: { tabs: [{ kind: "sidecar", sidecarId: "saved" }], activeTabIndex: 0 }, - }, - }), - saveClientState: async () => { - nativeSaveCount += 1 - return true - }, - clearClientState: () => new Promise((resolve) => { - finishClear = resolve - }), - }, - }, - }) - - const clientState = await import("./client-state.ts") - await clientState.initializeClientState() - - const clearing = clientState.clearRestoredClientState() - clientState.updateRestorableSession({ tabs: [{ kind: "sidecar", sidecarId: "transient" }], activeTabIndex: 0 }) - await Promise.resolve() - assert.ok(finishClear) - finishClear(true) - await clearing - await clientState.flushClientState() - - assert.equal(clientState.loadedRestorableSession(), null) - assert.equal(nativeSaveCount, 0) -}) - -function createMemoryStorage(): Storage { - const values = new Map() - return { - get length() { return values.size }, - clear: () => values.clear(), - getItem: (key) => values.get(key) ?? null, - key: (index) => [...values.keys()][index] ?? null, - removeItem: (key) => { values.delete(key) }, - setItem: (key, value) => { values.set(key, String(value)) }, - } -} diff --git a/packages/ui/src/stores/client-state-secondary.test.ts b/packages/ui/src/stores/client-state-secondary.test.ts deleted file mode 100644 index 742a9510..00000000 --- a/packages/ui/src/stores/client-state-secondary.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import assert from "node:assert/strict" -import { it } from "node:test" - -class MemoryStorage { - private readonly values = new Map() - - get length() { return this.values.size } - getItem(key: string) { return this.values.get(key) ?? null } - key(index: number) { return [...this.values.keys()][index] ?? null } - removeItem(key: string) { this.values.delete(key) } - setItem(key: string, value: string) { this.values.set(key, String(value)) } -} - -it("keeps secondary-process layout local without hydrating or writing native snapshots", async () => { - const storage = new MemoryStorage() - let nativeSaveCount = 0 - Object.defineProperty(globalThis, "window", { - configurable: true, - value: { - __CODENOMAD_RUNTIME_HOST__: "electron", - __CODENOMAD_WINDOW_CONTEXT__: "local", - localStorage: storage, - electronAPI: { - claimClientStateAccess: async () => true, - loadClientState: async () => ({ - isPrimary: false, - restoreEnabled: true, - snapshot: { - version: 1, - revision: 4, - savedAt: 100, - layout: { "opencode-session-sidebar-width-v8": "380" }, - session: { tabs: [{ kind: "sidecar", sidecarId: "docs" }], activeTabIndex: 0 }, - }, - }), - saveClientState: async () => { - nativeSaveCount += 1 - return false - }, - }, - }, - }) - - const clientState = await import("./client-state.ts") - await clientState.initializeClientState() - - assert.equal(clientState.clientStateIsPrimary(), false) - assert.equal(clientState.loadedRestorableSession(), null) - assert.equal(clientState.readClientLayoutValue("opencode-session-sidebar-width-v8"), null) - - clientState.writeClientLayoutValue("opencode-session-sidebar-width-v8", "340") - clientState.updateRestorableSession({ tabs: [{ kind: "sidecar", sidecarId: "other" }], activeTabIndex: 0 }) - await clientState.flushClientState() - - assert.equal(storage.getItem("opencode-session-sidebar-width-v8"), "340") - assert.equal(nativeSaveCount, 0) -}) diff --git a/packages/ui/src/stores/client-state-web.test.ts b/packages/ui/src/stores/client-state-web.test.ts deleted file mode 100644 index 5284a666..00000000 --- a/packages/ui/src/stores/client-state-web.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import assert from "node:assert/strict" -import { it } from "node:test" - -class MemoryStorage { - private readonly values = new Map() - - get length() { return this.values.size } - getItem(key: string) { return this.values.get(key) ?? null } - key(index: number) { return [...this.values.keys()][index] ?? null } - removeItem(key: string) { this.values.delete(key) } - setItem(key: string, value: string) { this.values.set(key, String(value)) } -} - -it("keeps plain web hosts secondary and retires web snapshots without touching legacy layout", async () => { - const storage = new MemoryStorage() - storage.setItem("codenomad-client-snapshot-v1", JSON.stringify({ - version: 1, - revision: 2, - savedAt: 10, - layout: {}, - session: { tabs: [{ kind: "sidecar", sidecarId: "private" }], activeTabIndex: 0 }, - })) - storage.setItem("codenomad-client-restore-enabled-v1", "false") - storage.setItem("opencode-session-sidebar-width-v8", "360") - Object.defineProperty(globalThis, "window", { - configurable: true, - value: { localStorage: storage }, - }) - - const clientState = await import("./client-state.ts") - await clientState.initializeClientState() - - assert.equal(clientState.clientStateIsPrimary(), false) - assert.equal(clientState.loadedRestorableSession(), null) - assert.equal(storage.getItem("codenomad-client-snapshot-v1"), null) - assert.equal(storage.getItem("codenomad-client-restore-enabled-v1"), null) - assert.equal(clientState.readClientLayoutValue("opencode-session-sidebar-width-v8"), "360") - - clientState.writeClientLayoutValue("opencode-session-sidebar-width-v8", "420") - clientState.updateRestorableSession({ tabs: [{ kind: "sidecar", sidecarId: "other" }], activeTabIndex: 0 }) - await clientState.flushClientState() - - assert.equal(storage.getItem("opencode-session-sidebar-width-v8"), "420") - assert.equal(storage.getItem("codenomad-client-snapshot-v1"), null) - assert.equal(clientState.loadedRestorableSession(), null) -}) diff --git a/packages/ui/src/stores/client-state.test.ts b/packages/ui/src/stores/client-state.test.ts index 3e859028..882a9234 100644 --- a/packages/ui/src/stores/client-state.test.ts +++ b/packages/ui/src/stores/client-state.test.ts @@ -1,95 +1,250 @@ import assert from "node:assert/strict" -import { it } from "node:test" - +import { describe, it } from "node:test" +type ClientState = typeof import("./client-state.ts") +type NativeApi = Record any>; type TransactionKind = "clear" | "disable" +const layoutKey = "opencode-session-sidebar-width-v8"; let moduleId = 0 class MemoryStorage { private readonly values = new Map() - - get length() { return this.values.size } - clear() { this.values.clear() } + get length() { return this.values.size }; clear() { this.values.clear() } getItem(key: string) { return this.values.get(key) ?? null } key(index: number) { return [...this.values.keys()][index] ?? null } removeItem(key: string) { this.values.delete(key) } setItem(key: string, value: string) { this.values.set(key, String(value)) } } - -it("claims access before loading and uses one renderer token for every native operation", async () => { - const storage = new MemoryStorage() - storage.setItem("opencode-session-sidebar-width-v8", "360") - const calls: Array<{ operation: string; token: string }> = [] - const savedSnapshots: any[] = [] - let clearCount = 0 - Object.defineProperty(globalThis, "window", { - configurable: true, - value: { - __CODENOMAD_RUNTIME_HOST__: "electron", - __CODENOMAD_WINDOW_CONTEXT__: "local", - localStorage: storage, - electronAPI: { - claimClientStateAccess: async (token: string) => { - calls.push({ operation: "claim", token }) - return true - }, - loadClientState: async (token: string) => { - calls.push({ operation: "load", token }) - return { isPrimary: true, restoreEnabled: true, snapshot: null } - }, - saveClientState: async (token: string, snapshot: unknown) => { - calls.push({ operation: "save", token }) - savedSnapshots.push(snapshot) - return true - }, - setClientStateRestoreEnabled: async (token: string, enabled: boolean) => { - calls.push({ operation: `set:${enabled}`, token }) - return true - }, - clearClientState: async () => { - clearCount += 1 - return true - }, - }, - }, +const session = (sidecarId: string) => ({ tabs: [{ kind: "sidecar" as const, sidecarId }], activeTabIndex: 0 }) +const snapshot = (sidecarId: string, layout: Record = {}) => ({ version: 1, revision: 1, savedAt: 1, layout, session: session(sidecarId) }) +const loadResult = (saved: unknown = null, isPrimary = true) => ({ isPrimary, restoreEnabled: true, snapshot: saved }) +const deferred = () => { + let resolve!: (value: T) => void, reject!: (error: Error) => void + const promise = new Promise((yes, no) => { resolve = yes; reject = no }) + return { promise, reject, resolve } +} +const installWindow = (api?: NativeApi, storage = new MemoryStorage()) => { + Object.defineProperty(globalThis, "window", { configurable: true, value: api + ? { __CODENOMAD_RUNTIME_HOST__: "electron", __CODENOMAD_WINDOW_CONTEXT__: "local", localStorage: storage, electronAPI: api } + : { localStorage: storage } }) + return storage +} +const boot = async (api?: NativeApi, storage?: MemoryStorage) => { + installWindow(api ? { claimClientStateAccess: async () => true, ...api } : undefined, storage) + const state = await import(`./client-state.ts?test=${moduleId++}`) as ClientState + await state.initializeClientState() + return state +} +const transact = (state: ClientState, kind: TransactionKind) => kind === "clear" + ? state.clearRestoredClientState() : state.setRestorePreviousStateEnabled(false) +describe("client state ownership and persistence", () => { + it("treats a rejected access claim as secondary without loading", async () => { + let loads = 0 + const state = await boot({ + claimClientStateAccess: async () => { throw new Error("claim rejected") }, + loadClientState: async () => { loads += 1; return loadResult() }, + }) + assert.equal(state.clientStateIsPrimary(), false) + assert.equal(loads, 0) }) - - const clientState = await import("./client-state.ts") - await clientState.initializeClientState() - - assert.equal(clientState.clientStateIsPrimary(), true) - assert.equal(clientState.readClientLayoutValue("opencode-session-sidebar-width-v8"), "360") - - clientState.writeClientLayoutValue("opencode-session-right-drawer-width-v1", "480") - clientState.updateRestorableSession({ - activeTabIndex: 0, - tabs: [{ - kind: "workspace", - folder: "C:/work/project", - drafts: { session1: "draft" }, - attachments: {}, - scrollSnapshots: {}, - unseenIdleSince: {}, - generationRecovery: {}, - }], + it("claims before loading and reuses one renderer token for native operations", async () => { + const storage = new MemoryStorage() + storage.setItem(layoutKey, "360") + const calls: Array<{ operation: string; token: string }> = [], saved: any[] = [] + const state = await boot({ + claimClientStateAccess: async (token) => { calls.push({ operation: "claim", token }); return true }, + loadClientState: async (token) => { calls.push({ operation: "load", token }); return loadResult() }, + saveClientState: async (token, value) => { calls.push({ operation: "save", token }); saved.push(value); return true }, + setClientStateRestoreEnabled: async (token, enabled) => { calls.push({ operation: `set:${enabled}`, token }); return true }, + }, storage) + assert.equal(state.readClientLayoutValue(layoutKey), "360") + state.updateRestorableSession(session("project")); await state.flushClientState() + state.writeClientLayoutValue(layoutKey, "500"); await state.flushClientState() + assert.deepEqual(saved.map(({ revision }) => revision), [1, 2]) + assert.equal(saved[0].version, 1) + assert.equal(saved[0].layout[layoutKey], "360") + assert.equal(saved[0].session.tabs[0].sidecarId, "project") + await state.setRestorePreviousStateEnabled(false) + assert.equal(state.restorePreviousStateEnabled(), false) + assert.equal(state.loadedRestorableSession(), null) + assert.equal(storage.getItem(layoutKey), null) + assert.deepEqual(calls.map(({ operation }) => operation), ["claim", "load", "save", "save", "set:false"]) + assert.match(calls[0]!.token, /^[0-9a-f]{64}$/) + assert.ok(calls.every(({ token }) => token === calls[0]!.token)) + assert.equal(storage.getItem(calls[0]!.token), null) }) - await clientState.flushClientState() - - const first = savedSnapshots[0] - assert.equal(first.version, 1) - assert.equal(first.revision, 1) - assert.equal(first.layout["opencode-session-sidebar-width-v8"], "360") - assert.equal(first.session.tabs[0].folder, "C:/work/project") - - clientState.writeClientLayoutValue("opencode-session-right-drawer-width-v1", "500") - await clientState.flushClientState() - const second = savedSnapshots[1] - assert.equal(second.revision, 2) - - await clientState.setRestorePreviousStateEnabled(false) - assert.equal(clientState.restorePreviousStateEnabled(), false) - assert.equal(clientState.loadedRestorableSession(), null) - assert.equal(storage.getItem("opencode-session-sidebar-width-v8"), null) - assert.equal(clientState.readClientLayoutValue("opencode-session-sidebar-width-v8"), null) - assert.equal(clearCount, 0) - assert.deepEqual(calls.map((call) => call.operation), ["claim", "load", "save", "save", "set:false"]) - assert.match(calls[0]!.token, /^[0-9a-f]{64}$/) - assert.ok(calls.every((call) => call.token === calls[0]!.token)) - assert.equal(storage.getItem(calls[0]!.token), null) +}) +const destructiveContract = async (kind: TransactionKind, inFlightSave: boolean) => { + const operation = deferred(), started = deferred() + const saved: any[] = [], preferences: boolean[] = [] + const api: NativeApi = { + loadClientState: async () => loadResult(!inFlightSave && kind === "clear" ? snapshot("saved") : null), + saveClientState: (_token, value) => { + saved.push(value) + if (!inFlightSave || saved.length > 1) return Promise.resolve(true) + started.resolve(); return operation.promise + }, + } + api[kind === "clear" ? "clearClientState" : "setClientStateRestoreEnabled"] = (...args: any[]) => { + if (kind === "disable") preferences.push(args[1]) + if (inFlightSave) throw new Error(`native ${kind} failed`) + started.resolve(); return operation.promise + } + const state = await boot(api) + if (inFlightSave) { + state.updateRestorableSession(session("retry")) + const firstFlush = state.flushClientState() + await started.promise + const transaction = transact(state, kind) + operation.reject(new Error("first save failed")) + await assert.rejects(firstFlush, /first save failed/) + await assert.rejects(transaction, new RegExp(`native ${kind} failed`)) + } else { + const transaction = transact(state, kind) + await started.promise + state.updateRestorableSession(session("buffered")) + state.writeClientLayoutValue(layoutKey, "430") + operation.reject(new Error(`native ${kind} failed`)) + await assert.rejects(transaction, new RegExp(`native ${kind} failed`)) + } + assert.equal(state.restorePreviousStateEnabled(), true) + if (kind === "disable") assert.deepEqual(preferences, [false]) + await state.flushClientState() + assert.equal(saved.length, inFlightSave ? 2 : 1) + assert.equal(saved.at(-1).session.tabs[0].sidecarId, inFlightSave ? "retry" : "buffered") + if (!inFlightSave) assert.equal(saved[0].layout[layoutKey], "430") +} +describe("failed destructive transactions", () => { + for (const kind of ["clear", "disable"] as const) { + it(`${kind}: rolls back and persists mutations buffered during delayed failure`, () => destructiveContract(kind, false)) + it(`${kind}: preserves retry dirt from an in-flight failed save`, () => destructiveContract(kind, true)) + } +}) +describe("future envelopes and clear races", () => { + it("serializes overlapping clear and disable transactions without stranding writes", async () => { + const clear = deferred(), disable = deferred() + const clearStarted = deferred(), disableStarted = deferred() + const operations: string[] = [], saved: any[] = [] + const state = await boot({ + loadClientState: async () => loadResult(snapshot("saved")), + clearClientState: async () => { operations.push("clear"); clearStarted.resolve(); return clear.promise }, + setClientStateRestoreEnabled: async (_token, enabled) => { + operations.push(`restore:${enabled}`) + if (!enabled) disableStarted.resolve() + return enabled ? true : disable.promise + }, + saveClientState: async (_token, value) => { saved.push(value); return true }, + }) + const clearing = state.clearRestoredClientState() + let sameValueSettled = false + const sameValueEnable = state.setRestorePreviousStateEnabled(true).then(() => { sameValueSettled = true }) + const disabling = state.setRestorePreviousStateEnabled(false) + const secondClear = state.clearRestoredClientState() + const enabling = state.setRestorePreviousStateEnabled(true) + await clearStarted.promise + assert.deepEqual(operations, ["clear"], "disable waits for clear ownership") + assert.equal(sameValueSettled, false, "same-value enable waits for prior transition") + clear.resolve(true); await clearing; await sameValueEnable; await disableStarted.promise + assert.deepEqual(operations, ["clear", "restore:true", "restore:false"]) + disable.resolve(true); await disabling; await secondClear; await enabling + assert.deepEqual(operations, ["clear", "restore:true", "restore:false", "clear", "restore:true"]) + state.updateRestorableSession(session("after-overlap")); await state.flushClientState() + assert.equal(saved.at(-1).session.tabs[0].sidecarId, "after-overlap") + }) + + it("clears a future envelope while suppressing recapture for the run", async () => { + let clears = 0, saves = 0 + const state = await boot({ + loadClientState: async () => loadResult({ version: 2, future: true }), + saveClientState: async () => { saves += 1; return true }, + clearClientState: async () => { clears += 1; return true }, + }) + state.writeClientLayoutValue(layoutKey, "350"); state.updateRestorableSession(session("docs")) + await state.flushClientState(); await state.clearRestoredClientState() + state.writeClientLayoutValue(layoutKey, "360"); state.updateRestorableSession(session("new")) + await state.flushClientState() + assert.equal(clears, 1) + assert.equal(saves, 0) + }) + it("keeps ownership of a future envelope after disable is rejected", async () => { + let clears = 0 + const state = await boot({ + loadClientState: async () => loadResult({ version: 2, future: true }), + setClientStateRestoreEnabled: async () => false, + clearClientState: async () => { clears += 1; return true }, + }) + await assert.rejects(state.setRestorePreviousStateEnabled(false), /update was rejected/) + assert.equal(state.restorePreviousStateEnabled(), true) + assert.equal(state.clientStateIsPrimary(), true) + await state.clearRestoredClientState() + assert.equal(state.clientStateIsPrimary(), true) + assert.equal(clears, 1) + }) + it("blocks captures before an in-flight clear reaches the native host", async () => { + const clear = deferred(); let saves = 0 + const state = await boot({ + loadClientState: async () => loadResult(snapshot("saved")), + saveClientState: async () => { saves += 1; return true }, + clearClientState: () => clear.promise, + }) + const clearing = state.clearRestoredClientState() + state.updateRestorableSession(session("transient")) + await Promise.resolve(); clear.resolve(true); await clearing; await state.flushClientState() + assert.equal(state.loadedRestorableSession(), null) + assert.equal(saves, 0) + }) +}) +describe("flush behavior", () => { + it("waits for replacement writes, retries once, and bounds persistent failures", async () => { + let phase: "race" | "one-time" | "persistent" = "race", attempts = 0 + const starts = [deferred(), deferred()], saves = [deferred(), deferred()] + const state = await boot({ + loadClientState: async () => loadResult(), + saveClientState: async () => { + attempts += 1 + if (phase === "race") { starts[attempts - 1]!.resolve(); await saves[attempts - 1]!.promise; return true } + if (phase === "persistent" || attempts === 1) throw new Error(`${phase} save failure`) + return true + }, + }) + state.updateRestorableSession(session("first")) + let settled = false + const flush = state.flushClientState().finally(() => { settled = true }) + await starts[0]!.promise; state.updateRestorableSession(session("second")) + await new Promise((resolve) => setTimeout(resolve, 300)); saves[0]!.resolve(); await starts[1]!.promise + await Promise.resolve(); assert.equal(settled, false, "replacement save remains part of flush") + saves[1]!.resolve(); await flush + for (const test of [ + { phase: "one-time" as const, attempts: 2, error: null }, + { phase: "persistent" as const, attempts: 3, error: /persistent save failure/ }, + ]) { + phase = test.phase; attempts = 0; state.updateRestorableSession(session(test.phase)) + if (test.error) await assert.rejects(state.flushClientState(), test.error) + else await state.flushClientState() + assert.equal(attempts, test.attempts, test.phase) + } + }) +}) +describe("secondary hosts", () => { + for (const host of ["electron secondary", "plain web"] as const) { + it(`${host}: keeps layout local without restoring or writing snapshots`, async () => { + const storage = new MemoryStorage(); storage.setItem(layoutKey, "360") + let saves = 0 + if (host === "plain web") { + storage.setItem("codenomad-client-snapshot-v1", JSON.stringify(snapshot("private"))) + storage.setItem("codenomad-client-restore-enabled-v1", "false") + } else storage.removeItem(layoutKey) + const state = await boot(host === "plain web" ? undefined : { + loadClientState: async () => loadResult(snapshot("docs", { [layoutKey]: "380" }), false), + saveClientState: async () => { saves += 1; return false }, + }, storage) + assert.equal(state.clientStateIsPrimary(), false) + assert.equal(state.loadedRestorableSession(), null) + assert.equal(state.readClientLayoutValue(layoutKey), host === "plain web" ? "360" : null) + state.writeClientLayoutValue(layoutKey, "420"); state.updateRestorableSession(session("other")) + await state.flushClientState() + assert.equal(storage.getItem(layoutKey), "420") + assert.equal(saves, 0) + if (host === "plain web") { + assert.equal(storage.getItem("codenomad-client-snapshot-v1"), null) + assert.equal(storage.getItem("codenomad-client-restore-enabled-v1"), null) + } + }) + } }) diff --git a/packages/ui/src/stores/client-state.ts b/packages/ui/src/stores/client-state.ts index a90e54ac..b13376e9 100644 --- a/packages/ui/src/stores/client-state.ts +++ b/packages/ui/src/stores/client-state.ts @@ -1,194 +1,108 @@ import { createSignal } from "solid-js" -import { - clearNativeClientState, - loadNativeClientState, - saveNativeClientState, - setNativeRestoreEnabled, -} from "../lib/native/client-state" -import { - decodeClientSnapshot, - isFutureClientSnapshot, - normalizeRestorableSession, - type ClientSnapshotV1, - type RestorableSessionState, - type RestorableSidecarTabState, - type RestorableTabState, - type RestorableWorkspaceTabState, -} from "./client-state-codec" - -export type { - ClientSnapshotV1, - RestorableSessionState, - RestorableSidecarTabState, - RestorableTabState, - RestorableWorkspaceTabState, -} - +import { clearNativeClientState, loadNativeClientState, saveNativeClientState, setNativeRestoreEnabled } from "../lib/native/client-state" +import { decodeClientSnapshot, isFutureClientSnapshot, normalizeRestorableSession } from "./client-state-codec" +import type { ClientSnapshotV1, RestorableSessionState, RestorableSidecarTabState, RestorableTabState, RestorableWorkspaceTabState } from "./client-state-codec" +export type { ClientSnapshotV1, RestorableSessionState, RestorableSidecarTabState, RestorableTabState, RestorableWorkspaceTabState } const SAVE_DEBOUNCE_MS = 250 const FLUSH_MAX_ATTEMPTS = 3 const MAX_LAYOUT_ENTRIES = 64 const MAX_LAYOUT_KEY_LENGTH = 256 const MAX_LAYOUT_VALUE_LENGTH = 4096 const LEGACY_LAYOUT_KEY_PREFIX = "opencode-session-" - +const UNSAFE_LAYOUT_KEYS = new Set(["__proto__", "constructor", "prototype"]) const [clientStateIsPrimary, setClientStateIsPrimary] = createSignal(true) -const [restorePreviousStateEnabled, setRestorePreviousStateEnabledSignal] = createSignal(true) +const [restorePreviousStateEnabled, setRestorePreviousStateEnabledSignal] = createSignal(false) const [loadedClientSnapshotExists, setLoadedClientSnapshotExists] = createSignal(false) const [loadedRestorableSession, setLoadedRestorableSession] = createSignal(null) - let initialized = false let initialization: Promise | null = null let layout: Record = Object.create(null) let revision = 0 let dirty = false -let snapshotWriteBlocked = false -let transactionWriteBlocked = false +let writeBlock: false | "snapshot" | "transaction" = false let saveTimer: ReturnType | null = null let writeQueue: Promise = Promise.resolve() +let destructiveQueue: Promise = Promise.resolve() let lastSaveError: unknown -const knownLegacyLayoutKeys = new Set() -const NO_PENDING_SESSION = Symbol("no-pending-session") -let pendingSessionWrite: RestorableSessionState | null | typeof NO_PENDING_SESSION = NO_PENDING_SESSION -const pendingLayoutWrites = new Map() - -function getLocalStorage(): Storage | null { - if (typeof window === "undefined") return null +const transactionLayoutWrites = new Set() +function useLocalStorage(fallback: T, operation: (storage: Storage) => T): T { try { - return window.localStorage + return operation(window.localStorage) } catch { - return null + return fallback } } function isValidLayoutKey(key: string): boolean { - return key.length > 0 - && key.length <= MAX_LAYOUT_KEY_LENGTH - && key !== "__proto__" - && key !== "constructor" - && key !== "prototype" + return key.length > 0 && key.length <= MAX_LAYOUT_KEY_LENGTH && !UNSAFE_LAYOUT_KEYS.has(key) } - -function isValidLayoutValue(value: string): boolean { - return value.length <= MAX_LAYOUT_VALUE_LENGTH -} - -function rememberLegacyLayoutKey(key: string) { - if (isValidLayoutKey(key)) knownLegacyLayoutKeys.add(key) -} - -function readLegacyLayoutValue(key: string): string | null { - const storage = getLocalStorage() - if (!storage) return null - try { +const readLegacyLayoutValue = (key: string) => + useLocalStorage(null, (storage) => { const value = storage.getItem(key) - if (value === null || !isValidLayoutValue(value)) return null - rememberLegacyLayoutKey(key) - return value - } catch { - return null - } -} - -function writeLegacyLayoutValue(key: string, value: string) { - const storage = getLocalStorage() - if (!storage) return - try { - storage.setItem(key, value) - rememberLegacyLayoutKey(key) - } catch { - // The native snapshot remains the source of truth when web storage is unavailable. - } -} - -function collectLegacyLayoutKeys(): string[] { - const keys = new Set(knownLegacyLayoutKeys) - const storage = getLocalStorage() - if (!storage) return [...keys] - - try { - for (let index = 0; index < storage.length; index += 1) { - const key = storage.key(index) - if (key?.startsWith(LEGACY_LAYOUT_KEY_PREFIX)) keys.add(key) - } - } catch { - // Keep keys already observed by the layout facade. + return value !== null && value.length <= MAX_LAYOUT_VALUE_LENGTH ? value : null + }) +const writeLegacyLayoutValue = (key: string, value: string) => + useLocalStorage(undefined, (storage) => storage.setItem(key, value)) +function legacyLayoutKeys(storage: Storage): string[] { + const keys = new Set(Object.keys(layout)) + for (let index = 0; index < storage.length; index += 1) { + const key = storage.key(index) + if (key?.startsWith(LEGACY_LAYOUT_KEY_PREFIX)) keys.add(key) } return [...keys] } - -function clearLegacyLayoutValues() { - const storage = getLocalStorage() - if (!storage) return - try { - for (const key of collectLegacyLayoutKeys()) storage.removeItem(key) - } catch { - // Native clearing still prevents these values from being restored by the primary process. - } - knownLegacyLayoutKeys.clear() -} +const hasLayoutKey = (key: string) => Object.prototype.hasOwnProperty.call(layout, key) +const layoutIsFull = () => Object.keys(layout).length >= MAX_LAYOUT_ENTRIES function migrateLegacyLayoutValues(): boolean { - const storage = getLocalStorage() - if (!storage) return false - - let changed = false - for (const key of collectLegacyLayoutKeys()) { - if (Object.prototype.hasOwnProperty.call(layout, key)) continue - if (Object.keys(layout).length >= MAX_LAYOUT_ENTRIES) break - const value = readLegacyLayoutValue(key) - if (value === null) continue - layout[key] = value - changed = true - } - return changed + return useLocalStorage(false, (storage) => { + const previousSize = Object.keys(layout).length + for (const key of legacyLayoutKeys(storage)) { + if (hasLayoutKey(key)) continue + if (layoutIsFull()) break + const value = storage.getItem(key) + if (value === null || value.length > MAX_LAYOUT_VALUE_LENGTH) continue + layout[key] = value + } + return Object.keys(layout).length !== previousSize + }) } function canWriteSnapshot(): boolean { - return initialized - && clientStateIsPrimary() - && restorePreviousStateEnabled() - && !snapshotWriteBlocked - && !transactionWriteBlocked + return initialized && clientStateIsPrimary() && restorePreviousStateEnabled() && !writeBlock } - -function cancelSaveTimer() { - if (saveTimer === null) return - clearTimeout(saveTimer) +const cancelSaveTimer = () => { + if (saveTimer !== null) clearTimeout(saveTimer) saveTimer = null } -function currentSnapshot(): ClientSnapshotV1 { - revision += 1 - return { - version: 1, - revision, - savedAt: Date.now(), - layout: { ...layout }, - session: loadedRestorableSession(), - } -} - function enqueuePendingSave(): Promise { cancelSaveTimer() if (!dirty || !canWriteSnapshot()) return writeQueue - + const snapshot: ClientSnapshotV1 = { + version: 1, + revision: ++revision, + savedAt: Date.now(), + layout: { ...layout }, + session: loadedRestorableSession(), + } + const normalizedSnapshot = decodeClientSnapshot(snapshot) + if (!normalizedSnapshot) return Promise.reject(new Error("Client snapshot normalization failed")) + layout = { ...normalizedSnapshot.layout } dirty = false - const snapshot = currentSnapshot() - const saveAttempt = writeQueue - .then(async () => { - try { - const saved = await saveNativeClientState(snapshot) - if (!saved) { - setClientStateIsPrimary(false) - throw new Error("Native client state save was rejected") - } - lastSaveError = undefined - } catch (error) { - dirty = true - lastSaveError = error - throw error + const saveAttempt = writeQueue.then(async () => { + try { + if (!await saveNativeClientState(normalizedSnapshot)) { + setClientStateIsPrimary(false) + throw new Error("Native client state save was rejected") } - }) + lastSaveError = undefined + } catch (error) { + dirty = true + lastSaveError = error + throw error + } + }) writeQueue = saveAttempt.catch(() => undefined) return saveAttempt } @@ -197,95 +111,90 @@ function scheduleSave() { dirty = true if (!canWriteSnapshot()) return cancelSaveTimer() - saveTimer = setTimeout(() => { - saveTimer = null - void enqueuePendingSave().catch((error) => { - console.warn("[client-state] failed to save client snapshot", error) + saveTimer = setTimeout(() => void enqueuePendingSave() + .catch((error) => console.warn("[client-state] failed to save client snapshot", error)), SAVE_DEBOUNCE_MS) +} +function resetLoadedState(snapshot: ClientSnapshotV1 | null = null, resetRevision = false) { + layout = snapshot ? { ...snapshot.layout } : Object.create(null) + if (resetRevision) revision = snapshot?.revision ?? 0 + setLoadedClientSnapshotExists(snapshot !== null) + setLoadedRestorableSession(snapshot?.session ?? null) +} +async function executeDestructiveTransaction(operation: () => Promise, rejectedMessage: string, loseOwnershipOnRejection = false): Promise { + const previousWriteBlock = writeBlock + let retryDirty = dirty + writeBlock = "transaction" + transactionLayoutWrites.clear() + cancelSaveTimer() + dirty = false + try { + await writeQueue + retryDirty ||= dirty + dirty = false + if (!await operation()) { + if (loseOwnershipOnRejection) setClientStateIsPrimary(false) + throw new Error(rejectedMessage) + } + useLocalStorage(undefined, (storage) => { + for (const key of legacyLayoutKeys(storage)) storage.removeItem(key) }) - }, SAVE_DEBOUNCE_MS) -} - -function resetPendingWrites() { - pendingSessionWrite = NO_PENDING_SESSION - pendingLayoutWrites.clear() -} - -function applyPendingWrites(): boolean { - let changed = false - if (pendingSessionWrite !== NO_PENDING_SESSION) { - setLoadedRestorableSession(pendingSessionWrite) - changed = true + resetLoadedState() + transactionLayoutWrites.clear() + writeBlock = "snapshot" + } catch (error) { + retryDirty ||= dirty + for (const key of transactionLayoutWrites) writeLegacyLayoutValue(key, layout[key]!) + transactionLayoutWrites.clear() + writeBlock = previousWriteBlock + dirty = retryDirty + if (dirty) scheduleSave() + throw error } - for (const [key, value] of pendingLayoutWrites) { - writeLegacyLayoutValue(key, value) - if (layout[key] === value) continue - layout[key] = value - changed = true - } - resetPendingWrites() - return changed } - -function rollbackWriteTransaction(wasWriteBlocked: boolean, retryDirty: boolean) { - transactionWriteBlocked = false - snapshotWriteBlocked = wasWriteBlocked - dirty = retryDirty - if (applyPendingWrites()) dirty = true - if (dirty) scheduleSave() +function runDestructiveTransition(operation: () => Promise): Promise { + const transition = destructiveQueue.then(operation) + destructiveQueue = transition.catch(() => undefined) + return transition } - export function updateRestorableSession(state: RestorableSessionState | null): void { const normalized = state === null ? null : normalizeRestorableSession(state) if (state !== null && normalized === null) return - if (transactionWriteBlocked) { - pendingSessionWrite = normalized + if (writeBlock === "transaction") { + setLoadedRestorableSession(normalized) + dirty = true return } - if (snapshotWriteBlocked || !clientStateIsPrimary() || !restorePreviousStateEnabled()) return - + if (writeBlock || !clientStateIsPrimary() || !restorePreviousStateEnabled()) return setLoadedRestorableSession(normalized) scheduleSave() } export function readClientLayoutValue(key: string): string | null { if (!isValidLayoutKey(key)) return null - if (!clientStateIsPrimary()) return readLegacyLayoutValue(key) - if (!restorePreviousStateEnabled()) return null - if (snapshotWriteBlocked || transactionWriteBlocked) return null - - if (Object.prototype.hasOwnProperty.call(layout, key)) return layout[key] ?? null + if (!restorePreviousStateEnabled() || writeBlock) return null + if (hasLayoutKey(key)) return layout[key] ?? null const legacyValue = readLegacyLayoutValue(key) - if (legacyValue === null || Object.keys(layout).length >= MAX_LAYOUT_ENTRIES) return legacyValue - + if (legacyValue === null || layoutIsFull()) return legacyValue layout[key] = legacyValue scheduleSave() return legacyValue } export function writeClientLayoutValue(key: string, value: string): void { - if (!isValidLayoutKey(key) || !isValidLayoutValue(value)) return - - if (!clientStateIsPrimary()) { - writeLegacyLayoutValue(key, value) + if (!isValidLayoutKey(key) || value.length > MAX_LAYOUT_VALUE_LENGTH) return + if (!clientStateIsPrimary()) return writeLegacyLayoutValue(key, value) + if (writeBlock === "transaction") { + if (!hasLayoutKey(key) && layoutIsFull()) return + dirty ||= layout[key] !== value + layout[key] = value + transactionLayoutWrites.add(key) return } - if (transactionWriteBlocked) { - const isNewKey = !Object.prototype.hasOwnProperty.call(layout, key) && !pendingLayoutWrites.has(key) - const pendingNewKeyCount = [...pendingLayoutWrites.keys()] - .filter((pendingKey) => !Object.prototype.hasOwnProperty.call(layout, pendingKey)) - .length - if (isNewKey && Object.keys(layout).length + pendingNewKeyCount >= MAX_LAYOUT_ENTRIES) return - pendingLayoutWrites.set(key, value) - return - } - if (!restorePreviousStateEnabled()) return - if (snapshotWriteBlocked) return - - if (!Object.prototype.hasOwnProperty.call(layout, key) && Object.keys(layout).length >= MAX_LAYOUT_ENTRIES) return + if (!restorePreviousStateEnabled() || writeBlock) return + if (!hasLayoutKey(key) && layoutIsFull()) return writeLegacyLayoutValue(key, value) if (layout[key] === value) return - layout[key] = value scheduleSave() } @@ -293,12 +202,12 @@ export function writeClientLayoutValue(key: string, value: string): void { export async function flushClientState(): Promise { cancelSaveTimer() let lastError = lastSaveError - let attempts = 0 - - while (attempts < FLUSH_MAX_ATTEMPTS) { - const pendingQueue = writeQueue - await pendingQueue - if (pendingQueue !== writeQueue) continue + for (let attempts = 0; attempts < FLUSH_MAX_ATTEMPTS; attempts += 1) { + while (true) { + const pending = writeQueue + await pending + if (pending === writeQueue) break + } if (!dirty) { if (lastError !== undefined) throw lastError return @@ -308,7 +217,6 @@ export async function flushClientState(): Promise { if (writeError !== undefined) throw writeError return } - attempts += 1 try { await enqueuePendingSave() lastError = undefined @@ -316,144 +224,63 @@ export async function flushClientState(): Promise { lastError = error } } - - while (true) { - const pendingQueue = writeQueue - await pendingQueue - if (pendingQueue !== writeQueue) continue - if (!dirty) return - break - } - if (lastError !== undefined) throw lastError - throw new Error("Client state remained dirty after the final flush attempt") + await writeQueue + if (!dirty) return + throw lastError ?? new Error("Client state remained dirty after the final flush attempt") } export async function clearRestoredClientState(): Promise { - if (!clientStateIsPrimary()) throw new Error("Client state is not owned by this window") - - const wasWriteBlocked = snapshotWriteBlocked - const wasDirty = dirty - let retryDirty = wasDirty - transactionWriteBlocked = true - resetPendingWrites() - cancelSaveTimer() - dirty = false - try { - await writeQueue - retryDirty ||= dirty - dirty = false - - const cleared = await clearNativeClientState() - if (!cleared) { - setClientStateIsPrimary(false) - throw new Error("Native client state clear was rejected") - } - - layout = Object.create(null) - setLoadedClientSnapshotExists(false) - setLoadedRestorableSession(null) - clearLegacyLayoutValues() - resetPendingWrites() - transactionWriteBlocked = false - snapshotWriteBlocked = true - } catch (error) { - retryDirty ||= dirty - rollbackWriteTransaction(wasWriteBlocked, retryDirty) - throw error - } + await runDestructiveTransition(async () => { + if (!clientStateIsPrimary()) throw new Error("Client state is not owned by this window") + await executeDestructiveTransaction(clearNativeClientState, "Native client state clear was rejected", true) + }) } export async function setRestorePreviousStateEnabled(enabled: boolean): Promise { - if (enabled === restorePreviousStateEnabled()) return - if (!clientStateIsPrimary()) throw new Error("Client state is not owned by this window") - - if (!enabled) { - const wasWriteBlocked = snapshotWriteBlocked - const wasDirty = dirty - let retryDirty = wasDirty - transactionWriteBlocked = true - resetPendingWrites() - setRestorePreviousStateEnabledSignal(false) - cancelSaveTimer() - dirty = false - await writeQueue - retryDirty ||= dirty - dirty = false - - try { - const preferenceSaved = await setNativeRestoreEnabled(false) - if (!preferenceSaved) { - throw new Error("Native restore preference update was rejected") - } - - layout = Object.create(null) - setLoadedClientSnapshotExists(false) - setLoadedRestorableSession(null) - clearLegacyLayoutValues() - resetPendingWrites() - transactionWriteBlocked = false - snapshotWriteBlocked = true - return - } catch (error) { - retryDirty ||= dirty + await runDestructiveTransition(async () => { + if (enabled === restorePreviousStateEnabled() && (!enabled || writeBlock === false)) return + if (!clientStateIsPrimary()) throw new Error("Client state is not owned by this window") + if (enabled) { + if (!await setNativeRestoreEnabled(true)) throw new Error("Native restore preference update was rejected") + writeBlock = false + setRestorePreviousStateEnabledSignal(true) + return + } + setRestorePreviousStateEnabledSignal(false) + try { + await executeDestructiveTransaction(() => setNativeRestoreEnabled(false), "Native restore preference update was rejected") + } catch (error) { setRestorePreviousStateEnabledSignal(true) - rollbackWriteTransaction(wasWriteBlocked, retryDirty) throw error } - } - - const preferenceSaved = await setNativeRestoreEnabled(true) - if (!preferenceSaved) { - throw new Error("Native restore preference update was rejected") - } - snapshotWriteBlocked = false - setRestorePreviousStateEnabledSignal(true) + }) } export function initializeClientState(): Promise { if (initialization) return initialization - initialization = (async () => { try { const loaded = await loadNativeClientState() setClientStateIsPrimary(loaded.isPrimary) setRestorePreviousStateEnabledSignal(loaded.restoreEnabled) - setLoadedClientSnapshotExists(false) - setLoadedRestorableSession(null) - layout = Object.create(null) - revision = 0 + resetLoadedState(null, true) dirty = false lastSaveError = undefined - snapshotWriteBlocked = false - transactionWriteBlocked = false - resetPendingWrites() - - if (!loaded.isPrimary || !loaded.restoreEnabled) { - initialized = true - return - } - - snapshotWriteBlocked = isFutureClientSnapshot(loaded.snapshot) - const snapshot = decodeClientSnapshot(loaded.snapshot) - if (snapshot) { - setLoadedClientSnapshotExists(true) - revision = snapshot.revision - layout = { ...snapshot.layout } - setLoadedRestorableSession(snapshot.session) - for (const key of Object.keys(snapshot.layout)) rememberLegacyLayoutKey(key) - } - + writeBlock = false + transactionLayoutWrites.clear() initialized = true - if (!snapshotWriteBlocked && migrateLegacyLayoutValues()) scheduleSave() + if (!loaded.isPrimary || !loaded.restoreEnabled) return + writeBlock = isFutureClientSnapshot(loaded.snapshot) ? "snapshot" : false + const snapshot = decodeClientSnapshot(loaded.snapshot) + resetLoadedState(snapshot, true) + if (!writeBlock && migrateLegacyLayoutValues()) scheduleSave() } catch (error) { initialized = true setClientStateIsPrimary(false) - setLoadedClientSnapshotExists(false) - setLoadedRestorableSession(null) + resetLoadedState() console.warn("[client-state] failed to initialize client state", error) } })() - return initialization } diff --git a/packages/ui/src/stores/commands.ts b/packages/ui/src/stores/commands.ts index ac60efca..a9f0ed3c 100644 --- a/packages/ui/src/stores/commands.ts +++ b/packages/ui/src/stores/commands.ts @@ -26,5 +26,3 @@ export function clearCommands(instanceId: string): void { return next }) } - -export { commandMap as commands } diff --git a/packages/ui/src/stores/instance-lifecycle-authority.ts b/packages/ui/src/stores/instance-lifecycle-authority.ts index 63659adf..c939ec08 100644 --- a/packages/ui/src/stores/instance-lifecycle-authority.ts +++ b/packages/ui/src/stores/instance-lifecycle-authority.ts @@ -1,5 +1,5 @@ export interface InstanceLifecycleAuthorityEvent { - type: "removed" | "opened" + type: "removed" | "opened" | "unavailable" instanceId: string folder: string occurrence: number diff --git a/packages/ui/src/stores/instance-runtime-authority.test.ts b/packages/ui/src/stores/instance-runtime-authority.test.ts index 0872c452..98a330c9 100644 --- a/packages/ui/src/stores/instance-runtime-authority.test.ts +++ b/packages/ui/src/stores/instance-runtime-authority.test.ts @@ -3,457 +3,220 @@ import { describe, it } from "node:test" import { createFileAttachment, createTextAttachment } from "../types/attachment.ts" import { - addAttachment, - clearInstanceAttachments, + addAttachment, clearInstanceAttachments, getAttachments, hydrateSessionAttachments, removeAttachment, getAuthoritativeAttachmentSessionIdsForInstance, - getAttachments, - hydrateSessionAttachments, - removeAttachment, } from "./attachments.ts" import { addInstance, clearReloadableInstanceState, removeInstance } from "./instances.ts" import { - activeParentSessionId, - activeSessionId, - clearActiveParentSession, - clearInstanceDraftPromptValues, - clearInstanceDraftPrompts, - clearInstanceDeletedSessionAuthority, - clearInstanceSessionSelection, - clearSessionDraftPrompt, - getAuthoritativeDraftSessionIdsForInstance, - getAuthoritativelyDeletedSessionIdsForInstance, - getSessionDraftPromptsForInstance, - hasAuthoritativeSessionSelection, - hydrateActiveSessionSelection, - hydrateSessionDraftPrompt, - setActiveParentSession, - setActiveSession, - setSessions, - setSessionDraftPrompt, + activeParentSessionId, activeSessionId, clearActiveParentSession, clearInstanceDraftPromptValues, + clearInstanceDraftPrompts, clearInstanceDeletedSessionAuthority, clearInstanceSessionSelection, + clearSessionDraftPrompt, getAuthoritativeDraftSessionIdsForInstance, + getAuthoritativelyDeletedSessionIdsForInstance, getSessionDraftPromptsForInstance, + hasAuthoritativeSessionSelection, hydrateActiveSessionSelection, hydrateSessionDraftPrompt, + setActiveParentSession, setActiveSession, setSessions, setSessionDraftPrompt, } from "./session-state.ts" import { removeSessionRuntimeState } from "./session-api.ts" import { handleSessionDeleted } from "./session-events.ts" import { - createRestorableSessionPreservation, - mapRestoredWorkspace, - mapRestoredWorkspaces, - markPreservedWorkspaceRemoved, - markPreservedWorkspaceReopened, - markRestoredTab, - mergeRestorableSessionState, + createRestorableSessionPreservation, markPreservedWorkspaceRemoved, markPreservedWorkspaceReopened, + mergeRestorableSessionState, recordRestoredTab, } from "./app-session-snapshot-merge.ts" import type { RestorableWorkspaceTabState } from "./client-state-codec.ts" import { onInstanceLifecycleAuthority } from "./instance-lifecycle-authority.ts" +const absent = { tabs: [], activeTabIndex: -1 } function workspace(state: Partial = {}): RestorableWorkspaceTabState { + return { kind: "workspace", folder: "/work", occurrence: 0, drafts: {}, attachments: {}, + scrollSnapshots: {}, unseenIdleSince: {}, generationRecovery: {}, ...state } +} +function instance(id: string, folder = "/work", status: "ready" | "error" = "ready") { + return { id, folder, port: 0, pid: 0, proxyPath: "", status, client: null } +} +function clearSessionState(instanceId: string) { + clearInstanceAttachments(instanceId) + clearInstanceDraftPrompts(instanceId) + clearInstanceDeletedSessionAuthority(instanceId) + clearInstanceSessionSelection(instanceId) + setSessions((previous) => { const next = new Map(previous); next.delete(instanceId); return next }) +} +function selectParentAndChild(instanceId: string) { + const parent = { id: "parent", instanceId, parentId: null, title: "Parent", status: "idle" } + const child = { id: "child", instanceId, parentId: "parent", title: "Child", status: "idle" } + setSessions((previous) => new Map(previous).set(instanceId, new Map([["parent", parent], ["child", child]]))) + setActiveParentSession(instanceId, "parent") + setActiveSession(instanceId, "child") +} +function preservationHarness(tabs: RestorableWorkspaceTabState[]) { + let value = createRestorableSessionPreservation({ tabs, activeTabIndex: 0 }) + const stop = onInstanceLifecycleAuthority((event) => { + const descriptor = { runtimeTabId: `instance:${event.instanceId}`, folder: event.folder, occurrence: event.occurrence } + value = event.type === "removed" + ? markPreservedWorkspaceRemoved(value, descriptor) + : markPreservedWorkspaceReopened(value, descriptor) + }) return { - kind: "workspace", - folder: "/work", - occurrence: 0, - drafts: {}, - attachments: {}, - scrollSnapshots: {}, - unseenIdleSince: {}, - generationRecovery: {}, - ...state, + get value() { return value }, + map(sourceIndex: number, instanceId: string, unavailable?: ReadonlySet) { + recordRestoredTab(value, sourceIndex, `instance:${instanceId}`, unavailable) + }, + close() { stop() }, } } describe("instance runtime authority", () => { it("preserves pasted and file attachments with prompt content during rehydration cleanup", () => { - const instanceId = "rehydrate-unsent-attachments" - const sessionId = "draft-session" + const id = "rehydrate-unsent-attachments", sessionId = "draft-session" const pasted = createTextAttachment("pasted body", "pasted #1 (4 lines)", "paste-1.txt") const file = createFileAttachment("/work/notes.txt", "notes.txt", "text/plain", new TextEncoder().encode("notes")) - const prompt = "Review [pasted #1] and @notes.txt" - - addAttachment(instanceId, sessionId, pasted) - addAttachment(instanceId, sessionId, file) - setSessionDraftPrompt(instanceId, sessionId, prompt) - - clearReloadableInstanceState(instanceId) - - assert.equal(getSessionDraftPromptsForInstance(instanceId)[sessionId], prompt) - assert.deepEqual(getAttachments(instanceId, sessionId), [pasted, file]) - - clearInstanceAttachments(instanceId) - clearInstanceDraftPrompts(instanceId) + addAttachment(id, sessionId, pasted); addAttachment(id, sessionId, file) + setSessionDraftPrompt(id, sessionId, "Review [pasted #1] and @notes.txt") + clearReloadableInstanceState(id) + assert.equal(getSessionDraftPromptsForInstance(id)[sessionId], "Review [pasted #1] and @notes.txt") + assert.deepEqual(getAttachments(id, sessionId), [pasted, file]) + clearSessionState(id) }) - it("removes attachment payloads and attachment authority on definitive session removal", () => { - const instanceId = "definitive-session-removal" - const sessionId = "deleted-session" - addAttachment(instanceId, sessionId, createTextAttachment("pasted", "pasted #1 (4 lines)", "paste.txt")) - addAttachment( - instanceId, - sessionId, - createFileAttachment("/work/file.txt", "file.txt", "text/plain", new TextEncoder().encode("file")), - ) - setSessionDraftPrompt(instanceId, sessionId, "[pasted #1] @file.txt") - - removeSessionRuntimeState(instanceId, sessionId) - - assert.deepEqual(getAttachments(instanceId, sessionId), []) - assert.equal(getAuthoritativeAttachmentSessionIdsForInstance(instanceId).has(sessionId), false) - assert.equal(getSessionDraftPromptsForInstance(instanceId)[sessionId], undefined) - assert.equal(getAuthoritativelyDeletedSessionIdsForInstance(instanceId).has(sessionId), true) - - clearInstanceAttachments(instanceId) - clearInstanceDraftPrompts(instanceId) - clearInstanceDeletedSessionAuthority(instanceId) + for (const test of [ + { label: "direct definitive session removal", remove: removeSessionRuntimeState }, + { label: "session.deleted event", remove: (id: string, sessionId: string) => handleSessionDeleted(id, + { type: "session.deleted", properties: { info: { id: sessionId } } }) }, + ]) it(`removes attachment authority on ${test.label}`, () => { + const id = `authority-${test.label}`, sessionId = "deleted-session" + addAttachment(id, sessionId, createTextAttachment("pasted", "pasted #1", "paste.txt")) + setSessionDraftPrompt(id, sessionId, "[pasted #1]") + test.remove(id, sessionId) + assert.deepEqual(getAttachments(id, sessionId), []) + assert.equal(getAuthoritativeAttachmentSessionIdsForInstance(id).has(sessionId), false) + assert.equal(getSessionDraftPromptsForInstance(id)[sessionId], undefined) + assert.equal(getAuthoritativelyDeletedSessionIdsForInstance(id).has(sessionId), true) + clearSessionState(id) }) - it("applies definitive session cleanup for session.deleted events", () => { - const instanceId = "session-deleted-event" - const sessionId = "event-session" - addAttachment(instanceId, sessionId, createTextAttachment("event paste", "pasted #1 (4 lines)", "paste.txt")) - - handleSessionDeleted(instanceId, { - type: "session.deleted", - properties: { info: { id: sessionId } }, - }) - - assert.deepEqual(getAttachments(instanceId, sessionId), []) - assert.equal(getAuthoritativeAttachmentSessionIdsForInstance(instanceId).has(sessionId), false) - assert.equal(getAuthoritativelyDeletedSessionIdsForInstance(instanceId).has(sessionId), true) - - clearInstanceAttachments(instanceId) - clearInstanceDraftPrompts(instanceId) - clearInstanceDeletedSessionAuthority(instanceId) + for (const test of [ + { label: "retained parent when active child is deleted", deleted: "child", parent: "parent", active: "parent" }, + { label: "no selection when selected parent is deleted", deleted: "parent", parent: undefined, active: undefined }, + ] as const) it(`selects ${test.label}`, () => { + const id = `selection-${test.deleted}` + selectParentAndChild(id) + removeSessionRuntimeState(id, test.deleted) + assert.equal(activeParentSessionId().get(id), test.parent) + assert.equal(activeSessionId().get(id), test.active) + clearSessionState(id) }) - it("selects the retained parent when the active child is deleted remotely", () => { - const instanceId = "deleted-active-child" - const parentId = "parent" - const childId = "child" - const parent = { id: parentId, instanceId, parentId: null, title: "Parent", status: "idle" } - const child = { id: childId, instanceId, parentId, title: "Child", status: "idle" } - setSessions((prev) => { - const next = new Map(prev) - next.set(instanceId, new Map([[parentId, parent], [childId, child]])) - return next - }) - setActiveParentSession(instanceId, parentId) - setActiveSession(instanceId, childId) - - handleSessionDeleted(instanceId, { - type: "session.deleted", - properties: { info: { id: childId } }, - }) - - assert.equal(activeParentSessionId().get(instanceId), parentId) - assert.equal(activeSessionId().get(instanceId), parentId) - - clearInstanceSessionSelection(instanceId) - clearInstanceDeletedSessionAuthority(instanceId) - setSessions((prev) => { - const next = new Map(prev) - next.delete(instanceId) - return next - }) - }) - - it("clears an active child selection when its selected parent is deleted", () => { - const instanceId = "deleted-selected-parent" - const parentId = "parent" - const childId = "child" - const parent = { id: parentId, instanceId, parentId: null, title: "Parent", status: "idle" } - const child = { id: childId, instanceId, parentId, title: "Child", status: "idle" } - setSessions((prev) => { - const next = new Map(prev) - next.set(instanceId, new Map([[parentId, parent], [childId, child]])) - return next - }) - setActiveParentSession(instanceId, parentId) - setActiveSession(instanceId, childId) - - removeSessionRuntimeState(instanceId, parentId) - - assert.equal(activeParentSessionId().has(instanceId), false) - assert.equal(activeSessionId().has(instanceId), false) - - clearInstanceSessionSelection(instanceId) - clearInstanceDeletedSessionAuthority(instanceId) - setSessions((prev) => { - const next = new Map(prev) - next.delete(instanceId) - return next - }) - }) - - it("tombstones failed hydration preservation only after explicit instance removal", () => { - const instanceId = "failed-hydration-removal" - let preservation = createRestorableSessionPreservation({ - tabs: [workspace({ folder: "/failed", drafts: { missing: "retry me" } })], - activeTabIndex: 0, - }) - const stop = onInstanceLifecycleAuthority((event) => { - const descriptor = { - runtimeTabId: `instance:${event.instanceId}`, - folder: event.folder, - occurrence: event.occurrence, - } - preservation = event.type === "removed" - ? markPreservedWorkspaceRemoved(preservation, descriptor) - : markPreservedWorkspaceReopened(preservation, descriptor) - }) - - addInstance({ - id: instanceId, - folder: "/failed", - port: 0, - pid: 0, - proxyPath: "", - status: "error", - client: null, - }) - preservation = mapRestoredWorkspace(preservation, 0, `instance:${instanceId}`) - + it("tombstones failed hydration only after removal and reopens it as pending", () => { + const id = "failed-hydration-removal" + const harness = preservationHarness([workspace({ folder: "/failed", drafts: { missing: "retry me" } })]) + addInstance(instance(id, "/failed", "error")); harness.map(0, id) try { - const absent = { tabs: [], activeTabIndex: -1 } - assert.equal(mergeRestorableSessionState(absent, preservation).tabs.length, 1) - - removeInstance(instanceId) - assert.equal(mergeRestorableSessionState(absent, preservation).tabs.length, 0) - assert.equal(preservation.restoredWorkspaceSourceIndexes.size, 0) - - addInstance({ - id: `${instanceId}-reopened`, - folder: "/failed", - port: 0, - pid: 0, - proxyPath: "", - status: "ready", - client: null, - }) - assert.equal(mergeRestorableSessionState(absent, preservation).tabs.length, 1) - assert.equal(preservation.restoredWorkspaceSourceIndexes.size, 0) + assert.equal(mergeRestorableSessionState(absent, harness.value).tabs.length, 1) + removeInstance(id) + assert.equal(mergeRestorableSessionState(absent, harness.value).tabs.length, 0) + assert.equal(harness.value.results[0]?.status, "removed") + addInstance(instance(`${id}-reopened`, "/failed")) + assert.equal(mergeRestorableSessionState(absent, harness.value).tabs.length, 1) + assert.equal(harness.value.results[0]?.status, "pending") } finally { - stop() - removeInstance(instanceId, { authoritative: false }) - removeInstance(`${instanceId}-reopened`, { authoritative: false }) + harness.close(); removeInstance(id, { authoritative: false }); removeInstance(`${id}-reopened`, { authoritative: false }) } }) - it("tombstones each mapped failed-hydration duplicate after sequential instance closes", () => { - const firstId = "failed-hydration-duplicate-first" - const secondId = "failed-hydration-duplicate-second" - let preservation = createRestorableSessionPreservation({ - tabs: [ - workspace({ folder: "/duplicate", occurrence: 0, drafts: { first: "retry first" } }), - workspace({ folder: "/duplicate", occurrence: 1, drafts: { second: "retry second" } }), - ], - activeTabIndex: 0, - }) - const stop = onInstanceLifecycleAuthority((event) => { - const descriptor = { - runtimeTabId: `instance:${event.instanceId}`, - folder: event.folder, - occurrence: event.occurrence, - } - preservation = event.type === "removed" - ? markPreservedWorkspaceRemoved(preservation, descriptor) - : markPreservedWorkspaceReopened(preservation, descriptor) - }) - - addInstance({ - id: firstId, - folder: "/duplicate", - port: 0, - pid: 0, - proxyPath: "", - status: "error", - client: null, - }) - addInstance({ - id: secondId, - folder: "/duplicate", - port: 0, - pid: 0, - proxyPath: "", - status: "error", - client: null, - }) - preservation = mapRestoredWorkspace(preservation, 0, `instance:${firstId}`) - preservation = mapRestoredWorkspace(preservation, 1, `instance:${secondId}`) - + it("keeps a pending source tab after non-authoritative startup removal", () => { + const id = "automatic-startup-removal" + const harness = preservationHarness([workspace({ folder: "/automatic", drafts: { missing: "retry me" } })]) + addInstance(instance(id, "/automatic", "error")); harness.map(0, id) try { - removeInstance(firstId) - removeInstance(secondId) - - assert.deepEqual( - mergeRestorableSessionState({ tabs: [], activeTabIndex: -1 }, preservation), - { tabs: [], activeTabIndex: -1 }, - ) - assert.equal(preservation.restoredWorkspaceSourceIndexes.size, 0) + removeInstance(id, { authoritative: false }) + assert.equal(harness.value.results[0]?.status, "pending") + assert.equal(mergeRestorableSessionState(absent, harness.value).tabs.length, 1) } finally { - stop() - removeInstance(firstId, { authoritative: false }) - removeInstance(secondId, { authoritative: false }) + harness.close(); removeInstance(id, { authoritative: false }) } }) - it("keeps source zero when failed-hydration sources one and two close around occurrence renumbering", () => { - for (const closeOrder of [["middle", "last"], ["last", "middle"]] as const) { - const suffix = closeOrder.join("-") - const ids = { - first: `failed-hydration-three-first-${suffix}`, - middle: `failed-hydration-three-middle-${suffix}`, - last: `failed-hydration-three-last-${suffix}`, - } - let preservation = createRestorableSessionPreservation({ - tabs: [ - workspace({ folder: "/three", occurrence: 0, drafts: { first: "keep open" } }), - workspace({ folder: "/three", occurrence: 1, drafts: { middle: "close middle" } }), - workspace({ folder: "/three", occurrence: 2, drafts: { last: "close last" } }), - ], - activeTabIndex: 0, - }) - const stop = onInstanceLifecycleAuthority((event) => { - const descriptor = { - runtimeTabId: `instance:${event.instanceId}`, - folder: event.folder, - occurrence: event.occurrence, - } - preservation = event.type === "removed" - ? markPreservedWorkspaceRemoved(preservation, descriptor) - : markPreservedWorkspaceReopened(preservation, descriptor) - }) + it("tombstones each mapped failed-hydration duplicate after sequential closes", () => { + const ids = ["duplicate-first", "duplicate-second"] + const harness = preservationHarness(ids.map((id, occurrence) => + workspace({ folder: "/duplicate", occurrence, drafts: { [id]: `retry ${id}` } }))) + ids.forEach((id, index) => { addInstance(instance(id, "/duplicate", "error")); harness.map(index, id) }) + try { + ids.forEach((id) => removeInstance(id)) + assert.deepEqual(mergeRestorableSessionState(absent, harness.value), absent) + assert.deepEqual(harness.value.results.map(({ status }) => status), ["removed", "removed"]) + } finally { harness.close(); ids.forEach((id) => removeInstance(id, { authoritative: false })) } + }) - for (const id of [ids.first, ids.middle, ids.last]) { - addInstance({ - id, - folder: "/three", - port: 0, - pid: 0, - proxyPath: "", - status: "error", - client: null, - }) - } - preservation = mapRestoredWorkspaces(preservation, [ - { sourceIndex: 0, runtimeTabId: `instance:${ids.first}` }, - { sourceIndex: 1, runtimeTabId: `instance:${ids.middle}` }, - { sourceIndex: 2, runtimeTabId: `instance:${ids.last}` }, + for (const closeOrder of [["middle", "last"], ["last", "middle"]] as const) { + it(`keeps source zero across occurrence renumbering when closing ${closeOrder.join(" then ")}`, () => { + const ids = { first: `three-first-${closeOrder[0]}`, middle: `three-middle-${closeOrder[0]}`, last: `three-last-${closeOrder[0]}` } + const harness = preservationHarness([ + workspace({ folder: "/three", occurrence: 0, drafts: { first: "keep open" } }), + workspace({ folder: "/three", occurrence: 1, drafts: { middle: "close middle" } }), + workspace({ folder: "/three", occurrence: 2, drafts: { last: "close last" } }), ]) - + Object.values(ids).forEach((id, index) => { addInstance(instance(id, "/three", "error")); harness.map(index, id) }) try { - for (const position of closeOrder) removeInstance(ids[position]) - - assert.deepEqual([...preservation.removedWholeTabIndexes].sort(), [1, 2]) + closeOrder.forEach((position) => removeInstance(ids[position])) + assert.deepEqual(harness.value.results.flatMap((result, index) => result.status === "removed" ? [index] : []), [1, 2]) const merged = mergeRestorableSessionState( - { tabs: [workspace({ folder: "/three", occurrence: 0 })], activeTabIndex: 0 }, - preservation, + { tabs: [workspace({ folder: "/three" })], activeTabIndex: 0 }, harness.value, { currentTabIds: [`instance:${ids.first}`] }, ) - assert.equal(merged.tabs.length, 1) - assert.equal(merged.tabs[0]?.kind === "workspace" ? merged.tabs[0].drafts.first : undefined, "keep open") - assert.equal(merged.tabs[0]?.kind === "workspace" ? merged.tabs[0].drafts.middle : undefined, undefined) - assert.equal(merged.tabs[0]?.kind === "workspace" ? merged.tabs[0].drafts.last : undefined, undefined) - } finally { - stop() - removeInstance(ids.first, { authoritative: false }) - removeInstance(ids.middle, { authoritative: false }) - removeInstance(ids.last, { authoritative: false }) - } - } - }) + const drafts = merged.tabs[0]?.kind === "workspace" ? merged.tabs[0].drafts : {} + assert.deepEqual(drafts, { first: "keep open" }) + } finally { harness.close(); Object.values(ids).forEach((id) => removeInstance(id, { authoritative: false })) } + }) + } it("retains an explicit draft-clear tombstone through rehydrate value cleanup", () => { - const instanceId = "draft-rehydrate-authority" - const sessionId = "missing-session" - const preservation = createRestorableSessionPreservation({ - tabs: [workspace({ drafts: { [sessionId]: "preserved draft" } })], - activeTabIndex: 0, + const id = "draft-rehydrate-authority", sessionId = "missing-session" + const preserved = createRestorableSessionPreservation({ + tabs: [workspace({ drafts: { [sessionId]: "preserved draft" } })], activeTabIndex: 0, }) - - hydrateSessionDraftPrompt(instanceId, sessionId, "restored draft") - clearSessionDraftPrompt(instanceId, sessionId) - clearInstanceDraftPromptValues(instanceId) - - const authority = getAuthoritativeDraftSessionIdsForInstance(instanceId) + hydrateSessionDraftPrompt(id, sessionId, "restored draft"); clearSessionDraftPrompt(id, sessionId); clearInstanceDraftPromptValues(id) + const authority = getAuthoritativeDraftSessionIdsForInstance(id) assert.equal(authority.has(sessionId), true) - assert.deepEqual(getSessionDraftPromptsForInstance(instanceId), {}) - - const merged = mergeRestorableSessionState( - { tabs: [workspace()], activeTabIndex: 0 }, - preservation, - { - currentTabIds: [`instance:${instanceId}`], - currentTabAuthorities: [{ drafts: authority }], - }, - ) + assert.deepEqual(getSessionDraftPromptsForInstance(id), {}) + const merged = mergeRestorableSessionState({ tabs: [workspace()], activeTabIndex: 0 }, preserved, + { currentTabIds: [`instance:${id}`], currentTabAuthorities: [{ drafts: authority }] }) assert.deepEqual(merged.tabs[0]?.kind === "workspace" ? merged.tabs[0].drafts : undefined, {}) - - clearInstanceDraftPrompts(instanceId) - assert.equal(getAuthoritativeDraftSessionIdsForInstance(instanceId).has(sessionId), false) + clearInstanceDraftPrompts(id) + assert.equal(getAuthoritativeDraftSessionIdsForInstance(id).has(sessionId), false) }) it("keeps an explicitly closed selection at none and clears authority on final removal", () => { - const instanceId = "selection-close-authority" - addInstance({ - id: instanceId, - folder: "/work", - port: 0, - pid: 0, - proxyPath: "", - status: "ready", - client: null, - }) - const preservation = markRestoredTab( - createRestorableSessionPreservation({ - tabs: [workspace({ - activeParentSessionId: "missing-parent", - activeSessionId: "missing-child", - })], - activeTabIndex: 0, - }), - 0, - new Set(["missing-parent", "missing-child"]), - `instance:${instanceId}`, - ) - - hydrateActiveSessionSelection(instanceId, null, null) - assert.equal(hasAuthoritativeSessionSelection(instanceId), false) - - setActiveParentSession(instanceId, "current-session") - clearActiveParentSession(instanceId) - hydrateActiveSessionSelection(instanceId, "missing-parent", "missing-child") - assert.equal(activeParentSessionId().has(instanceId), false) - assert.equal(activeSessionId().has(instanceId), false) - assert.equal(hasAuthoritativeSessionSelection(instanceId), true) - - const merged = mergeRestorableSessionState( - { tabs: [workspace()], activeTabIndex: 0 }, - preservation, - { - currentTabIds: [`instance:${instanceId}`], - currentTabAuthorities: [{ sessionSelection: hasAuthoritativeSessionSelection(instanceId) }], - }, - ) + const id = "selection-close-authority" + addInstance(instance(id)) + const preserved = createRestorableSessionPreservation({ tabs: [workspace({ + activeParentSessionId: "missing-parent", activeSessionId: "missing-child", + })], activeTabIndex: 0 }) + recordRestoredTab(preserved, 0, `instance:${id}`, new Set(["missing-parent", "missing-child"])) + hydrateActiveSessionSelection(id, null, null) + assert.equal(hasAuthoritativeSessionSelection(id), false) + setActiveParentSession(id, "current-session"); clearActiveParentSession(id) + hydrateActiveSessionSelection(id, "missing-parent", "missing-child") + assert.equal(activeParentSessionId().has(id), false); assert.equal(activeSessionId().has(id), false) + assert.equal(hasAuthoritativeSessionSelection(id), true) + const merged = mergeRestorableSessionState({ tabs: [workspace()], activeTabIndex: 0 }, preserved, + { currentTabIds: [`instance:${id}`], currentTabAuthorities: [{ sessionSelection: true }] }) const tab = merged.tabs[0] assert.equal(tab?.kind === "workspace" ? tab.activeParentSessionId : undefined, undefined) assert.equal(tab?.kind === "workspace" ? tab.activeSessionId : undefined, undefined) - - clearSessionDraftPrompt(instanceId, "removed-session") - const attachment = createTextAttachment("removed", "pasted #1 (1 line)", "removed.txt") - hydrateSessionAttachments(instanceId, "removed-session", [attachment]) - removeAttachment(instanceId, "removed-session", attachment.id) - - removeInstance(instanceId) - assert.equal(hasAuthoritativeSessionSelection(instanceId), false) - assert.equal(getAuthoritativeDraftSessionIdsForInstance(instanceId).size, 0) - assert.equal(getAuthoritativeAttachmentSessionIdsForInstance(instanceId).size, 0) + clearSessionDraftPrompt(id, "removed-session") + const attachment = createTextAttachment("removed", "pasted #1", "removed.txt") + hydrateSessionAttachments(id, "removed-session", [attachment]); removeAttachment(id, "removed-session", attachment.id) + removeInstance(id) + assert.equal(hasAuthoritativeSessionSelection(id), false) + assert.equal(getAuthoritativeDraftSessionIdsForInstance(id).size, 0) + assert.equal(getAuthoritativeAttachmentSessionIdsForInstance(id).size, 0) }) it("marks info selection as authoritative without restore hydration doing so", () => { - const instanceId = "info-selection-authority" - hydrateActiveSessionSelection(instanceId, null, "info") - assert.equal(hasAuthoritativeSessionSelection(instanceId), false) - - setActiveSession(instanceId, "info") - assert.equal(hasAuthoritativeSessionSelection(instanceId), true) - - clearInstanceSessionSelection(instanceId) + const id = "info-selection-authority" + hydrateActiveSessionSelection(id, null, "info") + assert.equal(hasAuthoritativeSessionSelection(id), false) + setActiveSession(id, "info") + assert.equal(hasAuthoritativeSessionSelection(id), true) + clearInstanceSessionSelection(id) }) }) diff --git a/packages/ui/src/stores/instances-restore-cancellation.test.ts b/packages/ui/src/stores/instances-restore-cancellation.test.ts new file mode 100644 index 00000000..149c8503 --- /dev/null +++ b/packages/ui/src/stores/instances-restore-cancellation.test.ts @@ -0,0 +1,20 @@ +import assert from "node:assert/strict" +import { it } from "node:test" + +import { serverApi } from "../lib/api-client.ts" +import { cancelRestoreCreation } from "./restore-creation-cancellation.ts" + +it("retries pre-response restore cancellation without SSE correlation", async () => { + const originalCancel = serverApi.cancelWorkspaceCreation + let calls = 0 + serverApi.cancelWorkspaceCreation = async () => { + if (++calls === 1) throw new Error("temporary cancellation failure") + } + + try { + await cancelRestoreCreation("pre-response-request") + assert.equal(calls, 2) + } finally { + serverApi.cancelWorkspaceCreation = originalCancel + } +}) diff --git a/packages/ui/src/stores/instances-restore-ownership.test.ts b/packages/ui/src/stores/instances-restore-ownership.test.ts new file mode 100644 index 00000000..e6a151cc --- /dev/null +++ b/packages/ui/src/stores/instances-restore-ownership.test.ts @@ -0,0 +1,47 @@ +import assert from "node:assert/strict" +import { it } from "node:test" + +import { serverApi } from "../lib/api-client.ts" +import { AbortCreatedWorkspaceCleanup } from "./abort-created-workspace-cleanup.ts" +import { + createInstance, releaseRestoreCreatedInstance, removeInstance, +} from "./instances.ts" + +it("only restore-scoped creations are owned by abort cleanup", async () => { + const originalCreate = serverApi.createWorkspace + const originalRelease = serverApi.releaseWorkspaceCreation + const originalTrack = AbortCreatedWorkspaceCleanup.prototype.track + const tracked: Array<{ id: string; requestId?: string }> = [] + const released: Array<[string, string]> = [] + + serverApi.createWorkspace = async (payload) => ({ + id: payload.path.slice(1), requestId: payload.requestId, path: payload.path, status: "starting", + proxyPath: "", binaryId: "test", binaryLabel: "Test", createdAt: "", updatedAt: "", + }) + serverApi.releaseWorkspaceCreation = async (id, requestId) => { released.push([id, requestId]) } + AbortCreatedWorkspaceCleanup.prototype.track = function (workspace) { + tracked.push(workspace) + originalTrack.call(this, workspace) + } + + try { + await createInstance("/ordinary", undefined, undefined, { activate: false }) + assert.equal(tracked.some(({ id }) => id === "ordinary"), false) + + const restored = await createInstance("/restore", undefined, undefined, { + activate: false, + signal: new AbortController().signal, + }) + assert.ok(restored.requestId) + assert.equal(tracked.some(({ id, requestId }) => id === "restore" && requestId === restored.requestId), true) + await releaseRestoreCreatedInstance("restore", restored.requestId) + await releaseRestoreCreatedInstance("restore", restored.requestId) + assert.deepEqual(released, [["restore", restored.requestId]]) + } finally { + AbortCreatedWorkspaceCleanup.prototype.track = originalTrack + serverApi.createWorkspace = originalCreate + serverApi.releaseWorkspaceCreation = originalRelease + removeInstance("ordinary", { authoritative: false }) + removeInstance("restore", { authoritative: false }) + } +}) diff --git a/packages/ui/src/stores/instances.ts b/packages/ui/src/stores/instances.ts index 67c8caf1..ba708fce 100644 --- a/packages/ui/src/stores/instances.ts +++ b/packages/ui/src/stores/instances.ts @@ -67,10 +67,16 @@ import { tGlobal } from "../lib/i18n" import { appSessionRestoreGateActive } from "./app-session-restore-gate" import { clearInstanceAttachments } from "./attachments" import { publishInstanceLifecycleAuthority } from "./instance-lifecycle-authority" -import { completeAbortableRestoreCreation } from "./abortable-restore-creation" +import { getUnavailableWorkspaceIds } from "./app-session-reconciliation" import { getAbortReason } from "./app-session-restore-timeout" import { AbortCreatedWorkspaceCleanup } from "./abort-created-workspace-cleanup" import { TrailingResyncCoordinator, waitForSettledPrerequisite } from "../lib/trailing-resync" +import { retryWithBackoff } from "../lib/retry-utils" +import { cancelRestoreCreation } from "./restore-creation-cancellation" +import { + RestoreWorkspaceCommitGates, type RestoreWorkspaceCommitGate, type RestoreWorkspaceTerminal, +} from "./restore-workspace-commit-gates" +import { WorkspaceListReconciliationFence } from "./workspace-list-reconciliation-fence" const log = getLogger("api") @@ -114,75 +120,74 @@ const [logStreamingState, setLogStreamingState] = createSignal>(new Map()) const [activePermissionId, setActivePermissionId] = createSignal>(new Map()) -const permissionSessionCounts = new Map>() -const permissionEnqueuedAt = new Map() -const permissionSourceByInstance = new Map>() const [questionQueues, setQuestionQueues] = createSignal>(new Map()) const [activeQuestionId, setActiveQuestionId] = createSignal>(new Map()) -const questionSessionCounts = new Map>() -const questionEnqueuedAt = new Map() -const questionSourceByInstance = new Map>() +class InterruptionRegistry { + private readonly enqueuedAt = new Map() + private readonly sources = new Map>() + private readonly sessionCounts = new Map>() -function ensurePermissionEnqueuedAt(permission: PermissionRequest): number { - const existing = permissionEnqueuedAt.get(permission.id) - if (existing) return existing - const now = Date.now() - permissionEnqueuedAt.set(permission.id, now) - return now -} + constructor(private readonly defaultSource: S) {} -function setPermissionSource(instanceId: string, requestId: string, source: PermissionSource): void { - let sources = permissionSourceByInstance.get(instanceId) - if (!sources) { - sources = new Map() - permissionSourceByInstance.set(instanceId, sources) + ensureEnqueuedAt(request: T): number { + const existing = this.enqueuedAt.get(request.id) + if (existing) return existing + const now = Date.now() + this.enqueuedAt.set(request.id, now) + return now } - sources.set(requestId, source) -} -function getPermissionSource(instanceId: string, requestId: string): PermissionSource { - return permissionSourceByInstance.get(instanceId)?.get(requestId) ?? "v2" -} + enqueuedAtFor(requestId: string): number { + return this.enqueuedAt.get(requestId) ?? Date.now() + } -function deletePermissionSource(instanceId: string, requestId: string): void { - const sources = permissionSourceByInstance.get(instanceId) - if (!sources) return - sources.delete(requestId) - if (sources.size === 0) { - permissionSourceByInstance.delete(instanceId) + setSource(instanceId: string, requestId: string, source: S): void { + const sources = this.sources.get(instanceId) ?? new Map() + sources.set(requestId, source) + this.sources.set(instanceId, sources) + } + + getSource(instanceId: string, requestId: string): S { + return this.sources.get(instanceId)?.get(requestId) ?? this.defaultSource + } + + remove(instanceId: string, requestId: string): void { + this.enqueuedAt.delete(requestId) + const sources = this.sources.get(instanceId) + sources?.delete(requestId) + if (sources?.size === 0) this.sources.delete(instanceId) + } + + increment(instanceId: string, sessionId: string): void { + const counts = this.sessionCounts.get(instanceId) ?? new Map() + counts.set(sessionId, (counts.get(sessionId) ?? 0) + 1) + this.sessionCounts.set(instanceId, counts) + } + + decrement(instanceId: string, sessionId: string): number { + const counts = this.sessionCounts.get(instanceId) + const next = Math.max(0, (counts?.get(sessionId) ?? 0) - 1) + if (next) counts?.set(sessionId, next) + else counts?.delete(sessionId) + if (counts?.size === 0) this.sessionCounts.delete(instanceId) + return next + } + + sessionIds(instanceId: string): IterableIterator { + return this.sessionCounts.get(instanceId)?.keys() ?? new Map().keys() + } + + clear(instanceId: string, requests: readonly T[], clearPending: (sessionId: string) => void): void { + requests.forEach(({ id }) => this.enqueuedAt.delete(id)) + this.sources.delete(instanceId) + for (const sessionId of this.sessionCounts.get(instanceId)?.keys() ?? []) clearPending(sessionId) + this.sessionCounts.delete(instanceId) } } -function ensureQuestionEnqueuedAt(request: QuestionRequest): number { - const existing = questionEnqueuedAt.get(request.id) - if (existing) return existing - const now = Date.now() - questionEnqueuedAt.set(request.id, now) - return now -} - -function setQuestionSource(instanceId: string, requestId: string, source: QuestionSource): void { - let sources = questionSourceByInstance.get(instanceId) - if (!sources) { - sources = new Map() - questionSourceByInstance.set(instanceId, sources) - } - sources.set(requestId, source) -} - -function getQuestionSource(instanceId: string, requestId: string): QuestionSource { - return questionSourceByInstance.get(instanceId)?.get(requestId) ?? "v2" -} - -function deleteQuestionSource(instanceId: string, requestId: string): void { - const sources = questionSourceByInstance.get(instanceId) - if (!sources) return - sources.delete(requestId) - if (sources.size === 0) { - questionSourceByInstance.delete(instanceId) - } -} +const permissionRegistry = new InterruptionRegistry("v2") +const questionRegistry = new InterruptionRegistry("v2") type InterruptionKind = "permission" | "question" @@ -224,17 +229,27 @@ const pendingRehydrations = new Map>() const initialHydrations = new Map>() const initialSessionHydrations = new Map>() const initialWorkspaceMetadataHydrations = new Map>() -const restoreCreatedWorkspaceCleanup = new AbortCreatedWorkspaceCleanup({ - deleteWorkspace: (workspaceId) => serverApi.deleteWorkspace(workspaceId), - restoreWorkspace: (workspace) => upsertWorkspace(workspace), +type RestoreWorkspaceDescriptor = WorkspaceDescriptor & { reused?: boolean } +const workspaceListReconciliationFence = new WorkspaceListReconciliationFence() + +const restoreCreatedWorkspaceCleanup = new AbortCreatedWorkspaceCleanup({ + discardWorkspace: (workspace) => { + if (!workspace.requestId) return Promise.reject(new Error(`Restore workspace ${workspace.id} has no creation request`)) + return serverApi.cancelWorkspaceCreation(workspace.requestId) + }, + restoreWorkspace: (workspace) => { + workspaceListReconciliationFence.markMutation(workspace.id) + upsertWorkspace(workspace) + }, onPermanentFailure: (workspace, error) => { - log.error("Failed to dispose workspace created by cancelled restore; restored it to the UI", { + log.error("Failed to cancel restore workspace ownership; restored it to the UI", { workspaceId: workspace.id, error, }) }, }) let restoreCreationRequestSequence = 0 +const restoreCreationCommitGates = new RestoreWorkspaceCommitGates() const connectionResyncs = new TrailingResyncCoordinator( async (instanceId) => { @@ -291,8 +306,8 @@ function settleInstanceReadyWaiters(instanceId: string, error?: Error): void { function reconcilePendingSessionIndicators(instanceId: string): void { reconcileSessionPendingState( instanceId, - new Set(permissionSessionCounts.get(instanceId)?.keys() ?? []), - new Set(questionSessionCounts.get(instanceId)?.keys() ?? []), + new Set(permissionRegistry.sessionIds(instanceId)), + new Set(questionRegistry.sessionIds(instanceId)), ) } @@ -465,7 +480,7 @@ async function syncPendingPermissions(instanceId: string): Promise { return [] }) for (const permission of legacyRemote) { - setPermissionSource(instanceId, permission.id, "legacy") + permissionRegistry.setSource(instanceId, permission.id, "legacy") remote.push({ request: permission, source: "legacy" }) } @@ -476,7 +491,7 @@ async function syncPendingPermissions(instanceId: string): Promise { ) log.info("v2.permission.request.list", { instanceId, location, resolvedLocation: response.location }) for (const permission of response.data) { - setPermissionSource(instanceId, permission.id, "v2") + permissionRegistry.setSource(instanceId, permission.id, "v2") remote.push({ request: permission, source: "v2" }) } } @@ -521,7 +536,7 @@ async function syncPendingQuestions(instanceId: string): Promise { return [] }) for (const request of legacyRemote) { - setQuestionSource(instanceId, request.id, "legacy") + questionRegistry.setSource(instanceId, request.id, "legacy") remote.push({ request, source: "legacy" }) } @@ -532,7 +547,7 @@ async function syncPendingQuestions(instanceId: string): Promise { ) log.info("v2.question.request.list", { instanceId, location, resolvedLocation: response.location }) for (const request of response.data) { - setQuestionSource(instanceId, request.id, "v2") + questionRegistry.setSource(instanceId, request.id, "v2") remote.push({ request, source: "v2" }) } } @@ -550,7 +565,7 @@ async function syncPendingQuestions(instanceId: string): Promise { // Upsert all server-side pending questions. for (const { request, source } of remote) { - ensureQuestionEnqueuedAt(request) + questionRegistry.ensureEnqueuedAt(request) addQuestionToQueue(instanceId, request, source) upsertQuestionV2(instanceId, request) } @@ -570,7 +585,7 @@ function startInstanceSessionHydration(instanceId: string, force = false): { const worktreeHydration = force ? reloadWorktrees(instanceId) : ensureWorktreesLoaded(instanceId) - const sessions = worktreeHydration.then(async () => { + const sessions = Promise.all([worktreeHydration, worktreeMapHydration]).then(async () => { resetSessionPagination(instanceId) await fetchSessions(instanceId).catch((error) => { log.error("Failed to hydrate sessions", { instanceId, error }) @@ -700,31 +715,72 @@ async function disposeInstance(instanceId: string): Promise { return promise } -const initialWorkspaceLoad = (async function initializeWorkspaces(): Promise<{ error?: unknown }> { +async function refreshWorkspaceList(): Promise { + const requestFence = workspaceListReconciliationFence.begin() + const removalCandidates = new Set(instances().keys()) try { const workspaces = await serverApi.fetchWorkspaces() - workspaces.forEach((workspace) => upsertWorkspace(workspace)) - // After a UI refresh, we may have instances but no active selection. + if (!workspaceListReconciliationFence.isCurrent(requestFence)) return + const remoteIds = new Set(workspaces.map(({ id }) => id)) + for (const workspace of workspaces) { + if (!workspaceListReconciliationFence.allows(requestFence, workspace.id)) continue + restoreCreatedWorkspaceCleanup.trackPendingRequest(workspace) + if (restoreCreationCommitGates.deferRefreshWorkspace(workspace)) continue + if (restoreCreatedWorkspaceCleanup.owns(workspace.id)) { + restoreCreatedWorkspaceCleanup.track(workspace) + continue + } + upsertWorkspace(workspace) + } + const unchangedCandidates = [...removalCandidates] + .filter((id) => workspaceListReconciliationFence.allows(requestFence, id)) + for (const instanceId of getUnavailableWorkspaceIds( + unchangedCandidates, remoteIds, (id) => restoreCreatedWorkspaceCleanup.owns(id), + )) { + releaseInstanceResources(instanceId) + removeInstance(instanceId, { authoritative: false }) + } ensureActiveInstanceSelected() + } finally { + workspaceListReconciliationFence.complete(requestFence) + } +} + +const initialWorkspaceLoad = (async function initializeWorkspaces(): Promise<{ error?: unknown }> { + try { + await refreshWorkspaceList() return {} } catch (error) { log.error("Failed to load workspaces", error) return { error } } })() +let latestWorkspaceLoad = initialWorkspaceLoad + +serverEvents.onOpen(() => { + latestWorkspaceLoad = refreshWorkspaceList().then( + () => ({}), + (error) => { + log.warn("Failed to reconcile workspaces after event reconnect", error) + return { error } + }, + ) +}) async function waitForInitialWorkspaceLoad(): Promise { - const result = await initialWorkspaceLoad - if (result.error !== undefined) throw result.error + let load = initialWorkspaceLoad + while (true) { + const result = await load + if (result.error !== undefined) throw result.error + if (load === latestWorkspaceLoad) return + load = latestWorkspaceLoad + } } serverEvents.on("*", (event) => handleWorkspaceEvent(event)) function handleWorkspaceEvent(event: WorkspaceEventPayload) { - if ("workspace" in event) { - restoreCreatedWorkspaceCleanup.trackPendingRequest(event.workspace) - } const workspaceId = event.type === "workspace.log" ? event.entry.workspaceId : "workspace" in event @@ -732,6 +788,17 @@ function handleWorkspaceEvent(event: WorkspaceEventPayload) { : "workspaceId" in event && typeof event.workspaceId === "string" ? event.workspaceId : null + if (workspaceId && event.type !== "workspace.log") { + workspaceListReconciliationFence.markMutation(workspaceId) + } + if ("workspace" in event) { + restoreCreatedWorkspaceCleanup.trackPendingRequest(event.workspace) + if (restoreCreationCommitGates.deferWorkspace(event.workspace)) return + } + if (event.type === "workspace.stopped" + && restoreCreationCommitGates.deferStopped(event.workspaceId, event.reason)) { + return + } if (workspaceId && restoreCreatedWorkspaceCleanup.shouldIgnoreEvent(workspaceId)) { return } @@ -752,7 +819,7 @@ function handleWorkspaceEvent(event: WorkspaceEventPayload) { case "workspace.stopped": restoreCreatedWorkspaceCleanup.release(event.workspaceId) releaseInstanceResources(event.workspaceId) - removeInstance(event.workspaceId) + removeInstance(event.workspaceId, { authoritative: event.reason === "deleted" }) break case "workspace.log": handleWorkspaceLog(event.entry) @@ -871,6 +938,14 @@ function removeInstance(id: string, options: { authoritative?: boolean } = {}) { .filter((instance) => normalizeInstanceFolderPath(instance.folder) === normalizeInstanceFolderPath(removedInstance.folder)) .findIndex((instance) => instance.id === id) : -1 + if (removedInstance && removedOccurrence >= 0 && options.authoritative === false) { + publishInstanceLifecycleAuthority({ + type: "unavailable", + instanceId: id, + folder: removedInstance.folder, + occurrence: removedOccurrence, + }) + } let nextActiveId: string | null = null setInstances((prev) => { @@ -933,47 +1008,60 @@ function removeInstance(id: string, options: { authoritative?: boolean } = {}) { } function removeRestoreCreatedInstanceFromUi(instanceId: string): void { + workspaceListReconciliationFence.markMutation(instanceId) if (instances().has(instanceId)) { releaseInstanceResources(instanceId) removeInstance(instanceId, { authoritative: false }) } } -function disposeNewRestoreCreatedWorkspace(workspace: WorkspaceDescriptor): Promise { - removeRestoreCreatedInstanceFromUi(workspace.id) - return restoreCreatedWorkspaceCleanup.discardCreated(workspace, { retainTombstone: true }) +function disposeRestoreWorkspaceResponse(workspace: RestoreWorkspaceDescriptor): Promise { + if (workspace.reused !== true) removeRestoreCreatedInstanceFromUi(workspace.id) + return restoreCreatedWorkspaceCleanup.discardCreated(workspace, { retainTombstone: workspace.reused !== true }) } function disposeRestoreCreatedInstance(instanceId: string): Promise { - if (!restoreCreatedWorkspaceCleanup.owns(instanceId)) return Promise.resolve() - removeRestoreCreatedInstanceFromUi(instanceId) - return restoreCreatedWorkspaceCleanup.discardTracked(instanceId, { retainTombstone: true }) + const workspace = restoreCreatedWorkspaceCleanup.get(instanceId) + if (!workspace) return Promise.resolve() + if (workspace.reused !== true) removeRestoreCreatedInstanceFromUi(instanceId) + return restoreCreatedWorkspaceCleanup.discardTracked(instanceId, { retainTombstone: workspace.reused !== true }) } async function releaseRestoreCreatedInstance(instanceId: string, requestId: string): Promise { - let lastError: unknown - for (const delayMs of [0, 250, 1_000, 2_000]) { - if (delayMs > 0) await new Promise((resolve) => setTimeout(resolve, delayMs)) - try { - await serverApi.releaseWorkspaceCreation(instanceId, requestId) - restoreCreatedWorkspaceCleanup.release(instanceId) - return - } catch (error) { - lastError = error - } - } - log.warn("Failed to release restore workspace creation ownership", { instanceId, error: lastError }) - throw lastError + await restoreCreatedWorkspaceCleanup.releaseAfter(instanceId, () => + retryWithBackoff(() => serverApi.releaseWorkspaceCreation(instanceId, requestId), { + maxAttempts: 4, + initialDelayMs: 250, + maxDelayMs: 2_000, + backoffMultiplier: 4, + })) +} + +async function cancelRestoreCreationRequest(instanceId: string | undefined, requestId: string): Promise { + await cancelRestoreCreation(requestId) + if (instanceId) restoreCreatedWorkspaceCleanup.forgetRequest(instanceId, requestId) } function claimRestoreCreatedInstanceForUser(instanceId: string): void { - const workspace = restoreCreatedWorkspaceCleanup.release(instanceId) + const workspace = restoreCreatedWorkspaceCleanup.get(instanceId) if (!workspace?.requestId) return void releaseRestoreCreatedInstance(instanceId, workspace.requestId).catch((error) => { log.warn("Failed to transfer restore workspace creation ownership to the user", { instanceId, error }) }) } +async function settleRestoreWorkspaceTerminal( + workspace: RestoreWorkspaceDescriptor, + terminal: RestoreWorkspaceTerminal, +): Promise { + if (terminal.status === "stopped") { + restoreCreatedWorkspaceCleanup.release(workspace.id) + removeRestoreCreatedInstanceFromUi(workspace.id) + return + } + await disposeRestoreWorkspaceResponse(workspace) +} + async function createInstance( folder: string, binaryPath?: string, @@ -981,67 +1069,113 @@ async function createInstance( options?: { activate?: boolean signal?: AbortSignal + shouldCreateCommit?: () => boolean onCreateCommit?: (instanceId: string) => void + waitForCreateCommit?: () => Promise forceNew?: boolean }, ): Promise<{ instanceId: string; reused: boolean; requestId?: string }> { const restoreRequestId = options?.signal ? createRestoreCreationRequestId() : undefined if (restoreRequestId) restoreCreatedWorkspaceCleanup.beginRequest(restoreRequestId) - let cancellationRequest: Promise | null = null + const commitGate: RestoreWorkspaceCommitGate | undefined = restoreRequestId && options?.waitForCreateCommit + ? restoreCreationCommitGates.begin(restoreRequestId, options.waitForCreateCommit(), folder) + : undefined + let cancellationRequest: Promise | null = null + let requestResolved = false + let terminalHandled = false const cancelPendingCreation = () => { if (!restoreRequestId) return - cancellationRequest ??= serverApi.cancelWorkspaceCreation(restoreRequestId).catch((error) => { - log.warn("Failed to cancel restore workspace creation", { requestId: restoreRequestId, error }) - }) + const trackedCleanup = restoreCreatedWorkspaceCleanup.quarantineRequest(restoreRequestId) + cancellationRequest ??= trackedCleanup + ? trackedCleanup.then(() => true) + : cancelRestoreCreationRequest(undefined, restoreRequestId) + .then(() => true, (error) => { + log.warn("Failed to cancel restore workspace creation", { requestId: restoreRequestId, error }) + return false + }) } options?.signal?.addEventListener("abort", cancelPendingCreation, { once: true }) try { if (options?.signal?.aborted) throw getAbortReason(options.signal) - const workspace = await completeAbortableRestoreCreation( - serverApi.createWorkspace({ - path: folder, - name: projectName, - binaryPath, - requestId: restoreRequestId, - forceNew: options?.forceNew, - }, { signal: options?.signal }), - { - signal: options?.signal, - commit: (created) => { - const reused = created.reused === true - if (options?.signal && !reused) { - restoreCreatedWorkspaceCleanup.track(created) - } else if (!options?.signal) { - restoreCreatedWorkspaceCleanup.releaseTombstoneForUserCreate(created.id) - } - if (restoreCreatedWorkspaceCleanup.shouldIgnoreEvent(created.id)) return - upsertWorkspace(created, reused ? undefined : projectName) - options?.onCreateCommit?.(created.id) - if (!reused && (options?.activate ?? true)) setActiveInstanceId(created.id) - }, - discard: (created) => created.reused === true - ? Promise.resolve() - : disposeNewRestoreCreatedWorkspace(created), - }, - ) + const workspace = await serverApi.createWorkspace({ + path: folder, + name: projectName, + binaryPath, + requestId: restoreRequestId, + forceNew: options?.forceNew, + }, { signal: options?.signal }) + requestResolved = true const reused = workspace.reused === true - if (restoreCreatedWorkspaceCleanup.shouldIgnoreEvent(workspace.id)) { - if (!reused) await restoreCreatedWorkspaceCleanup.discardCreated(workspace, { retainTombstone: true }) + if (restoreRequestId) restoreCreationCommitGates.bindResponse(restoreRequestId, workspace.id) + if (options?.signal?.aborted) { + if (workspace.requestId) await disposeRestoreWorkspaceResponse(workspace) + throw getAbortReason(options.signal) + } + if (options?.signal && workspace.requestId) { + const observed = restoreRequestId + ? restoreCreationCommitGates.resolve(restoreRequestId, workspace).workspace + : workspace + restoreCreatedWorkspaceCleanup.track({ + ...observed, + requestId: observed.requestId ?? workspace.requestId, + ...(reused ? { reused: true as const } : {}), + }) + } + else if (!options?.signal) restoreCreatedWorkspaceCleanup.releaseTombstoneForUserCreate(workspace.id) + if (commitGate) await commitGate.wait + if (options?.signal?.aborted) { + if (workspace.requestId) await disposeRestoreWorkspaceResponse(workspace) + throw getAbortReason(options.signal) + } + const resolution = restoreRequestId + ? restoreCreationCommitGates.resolve(restoreRequestId, workspace) + : { workspace } + const committedWorkspace: RestoreWorkspaceDescriptor = { + ...resolution.workspace, + requestId: resolution.workspace.requestId ?? workspace.requestId, + ...(reused ? { reused: true } : {}), + } + if (restoreRequestId) restoreCreatedWorkspaceCleanup.track(committedWorkspace) + const terminal = resolution.terminal ?? (committedWorkspace.status === "error" || committedWorkspace.status === "stopped" + ? { status: committedWorkspace.status, message: committedWorkspace.error } + : undefined) + if (terminal) { + terminalHandled = true + await settleRestoreWorkspaceTerminal(committedWorkspace, terminal) + throw new Error(terminal.message || `Restore-created workspace ${workspace.id} ${terminal.status}`) + } + const discarded = restoreCreatedWorkspaceCleanup.shouldIgnoreEvent(workspace.id) + || options?.shouldCreateCommit?.() === false + if (!discarded) { + workspaceListReconciliationFence.markMutation(workspace.id) + upsertWorkspace(committedWorkspace, reused ? undefined : projectName) + options?.onCreateCommit?.(workspace.id) + if (!reused && (options?.activate ?? true)) setActiveInstanceId(workspace.id) + } + if (discarded) { + if (workspace.requestId) await disposeRestoreWorkspaceResponse(workspace) throw new Error(`Restore-created workspace ${workspace.id} was closed before startup completed`) } - return { instanceId: workspace.id, reused, requestId: restoreRequestId } + return { instanceId: workspace.id, reused, requestId: workspace.requestId } } catch (error) { + if (!terminalHandled && commitGate?.terminal && commitGate.workspace) { + terminalHandled = true + await settleRestoreWorkspaceTerminal(commitGate.workspace, commitGate.terminal) + } if (!options?.signal?.aborted) log.error("Failed to create workspace", error) throw error } finally { options?.signal?.removeEventListener("abort", cancelPendingCreation) if (restoreRequestId) { - const pendingCancellation = cancellationRequest as Promise | null - if (pendingCancellation) { - void pendingCancellation.finally(() => restoreCreatedWorkspaceCleanup.finishRequest(restoreRequestId)) - } else { + restoreCreationCommitGates.end(restoreRequestId) + const pendingCancellation = cancellationRequest as Promise | null + if (requestResolved || !pendingCancellation) { restoreCreatedWorkspaceCleanup.finishRequest(restoreRequestId) + } else { + void pendingCancellation.then((cancelled) => { + if (cancelled) restoreCreatedWorkspaceCleanup.finishRequest(restoreRequestId) + }) } } } @@ -1087,11 +1221,14 @@ function stopInstance(id: string) { const instance = instances().get(id) if (!instance) return + workspaceListReconciliationFence.markMutation(id) releaseInstanceResources(id) removeInstance(id) if (restoreCreatedWorkspaceCleanup.owns(id)) { void restoreCreatedWorkspaceCleanup.discardTracked(id, { retainTombstone: true }) + .then(() => serverApi.deleteWorkspace(id)) + .catch((error) => log.error("Failed to stop restore-tracked workspace", error)) return } @@ -1187,18 +1324,18 @@ function getQuestionEnqueuedAtForInstance(instanceId: string, requestId: string) const queue = getQuestionQueue(instanceId) const match = queue.find((q) => q.id === requestId) if (match) { - return ensureQuestionEnqueuedAt(match) + return questionRegistry.ensureEnqueuedAt(match) } - return questionEnqueuedAt.get(requestId) ?? Date.now() + return questionRegistry.enqueuedAtFor(requestId) } function getPermissionEnqueuedAtForInstance(instanceId: string, permissionId: string): number { const queue = getPermissionQueue(instanceId) const match = queue.find((permission) => permission.id === permissionId) if (match) { - return ensurePermissionEnqueuedAt(match) + return permissionRegistry.ensureEnqueuedAt(match) } - return permissionEnqueuedAt.get(permissionId) ?? Date.now() + return permissionRegistry.enqueuedAtFor(permissionId) } function computeActiveInterruption(instanceId: string): ActiveInterruption { @@ -1210,8 +1347,8 @@ function computeActiveInterruption(instanceId: string): ActiveInterruption { if (firstPermission && !firstQuestion) return { kind: "permission", id: firstPermission.id } if (firstQuestion && !firstPermission) return { kind: "question", id: firstQuestion.id } - const permTime = firstPermission ? ensurePermissionEnqueuedAt(firstPermission) : Number.MAX_SAFE_INTEGER - const quesTime = firstQuestion ? ensureQuestionEnqueuedAt(firstQuestion) : Number.MAX_SAFE_INTEGER + const permTime = firstPermission ? permissionRegistry.ensureEnqueuedAt(firstPermission) : Number.MAX_SAFE_INTEGER + const quesTime = firstQuestion ? questionRegistry.ensureEnqueuedAt(firstQuestion) : Number.MAX_SAFE_INTEGER if (permTime <= quesTime) return { kind: "permission", id: firstPermission.id } return { kind: "question", id: firstQuestion!.id } } @@ -1252,82 +1389,12 @@ function recomputeActiveInterruption(instanceId: string): void { setActiveInterruptionForInstance(instanceId, computeActiveInterruption(instanceId)) } -function incrementSessionPendingCount(instanceId: string, sessionId: string): void { - let sessionCounts = permissionSessionCounts.get(instanceId) - if (!sessionCounts) { - sessionCounts = new Map() - permissionSessionCounts.set(instanceId, sessionCounts) - } - const current = sessionCounts.get(sessionId) ?? 0 - sessionCounts.set(sessionId, current + 1) -} - -function decrementSessionPendingCount(instanceId: string, sessionId: string): number { - const sessionCounts = permissionSessionCounts.get(instanceId) - if (!sessionCounts) return 0 - const current = sessionCounts.get(sessionId) ?? 0 - if (current <= 1) { - sessionCounts.delete(sessionId) - if (sessionCounts.size === 0) { - permissionSessionCounts.delete(instanceId) - } - return 0 - } - const nextValue = current - 1 - sessionCounts.set(sessionId, nextValue) - return nextValue -} - -function clearSessionPendingCounts(instanceId: string): void { - const sessionCounts = permissionSessionCounts.get(instanceId) - if (!sessionCounts) return - for (const sessionId of sessionCounts.keys()) { - setSessionPendingPermission(instanceId, sessionId, false) - } - permissionSessionCounts.delete(instanceId) -} - -function incrementQuestionSessionPendingCount(instanceId: string, sessionId: string): void { - let sessionCounts = questionSessionCounts.get(instanceId) - if (!sessionCounts) { - sessionCounts = new Map() - questionSessionCounts.set(instanceId, sessionCounts) - } - const current = sessionCounts.get(sessionId) ?? 0 - sessionCounts.set(sessionId, current + 1) -} - -function decrementQuestionSessionPendingCount(instanceId: string, sessionId: string): number { - const sessionCounts = questionSessionCounts.get(instanceId) - if (!sessionCounts) return 0 - const current = sessionCounts.get(sessionId) ?? 0 - if (current <= 1) { - sessionCounts.delete(sessionId) - if (sessionCounts.size === 0) { - questionSessionCounts.delete(instanceId) - } - return 0 - } - const nextValue = current - 1 - sessionCounts.set(sessionId, nextValue) - return nextValue -} - -function clearQuestionSessionPendingCounts(instanceId: string): void { - const sessionCounts = questionSessionCounts.get(instanceId) - if (!sessionCounts) return - for (const sessionId of sessionCounts.keys()) { - setSessionPendingQuestion(instanceId, sessionId, false) - } - questionSessionCounts.delete(instanceId) -} - function addPermissionToQueue(instanceId: string, permission: PermissionRequest, source: PermissionSource = "v2"): PermissionRequest | undefined { let inserted = false let updated = false let previousPermission: PermissionRequest | undefined let queuedPermission = permission - setPermissionSource(instanceId, permission.id, source) + permissionRegistry.setSource(instanceId, permission.id, source) setPermissionQueues((prev) => { const next = new Map(prev) @@ -1339,13 +1406,13 @@ function addPermissionToQueue(instanceId: string, permission: PermissionRequest, queuedPermission = mergePermissionRequest(previousPermission, permission) const updatedQueue = queue.slice() updatedQueue[existingIndex] = queuedPermission - next.set(instanceId, updatedQueue.sort((a, b) => ensurePermissionEnqueuedAt(a) - ensurePermissionEnqueuedAt(b))) + next.set(instanceId, updatedQueue.sort((a, b) => permissionRegistry.ensureEnqueuedAt(a) - permissionRegistry.ensureEnqueuedAt(b))) updated = true return next } - ensurePermissionEnqueuedAt(queuedPermission) - const updatedQueue = [...queue, queuedPermission].sort((a, b) => ensurePermissionEnqueuedAt(a) - ensurePermissionEnqueuedAt(b)) + permissionRegistry.ensureEnqueuedAt(queuedPermission) + const updatedQueue = [...queue, queuedPermission].sort((a, b) => permissionRegistry.ensureEnqueuedAt(a) - permissionRegistry.ensureEnqueuedAt(b)) next.set(instanceId, updatedQueue) inserted = true return next @@ -1360,13 +1427,13 @@ function addPermissionToQueue(instanceId: string, permission: PermissionRequest, const previousSessionId = previousPermission ? getPermissionSessionId(previousPermission) : undefined const sessionId = getPermissionSessionId(queuedPermission) if (previousSessionId && previousSessionId !== sessionId) { - const remaining = decrementSessionPendingCount(instanceId, previousSessionId) + const remaining = permissionRegistry.decrement(instanceId, previousSessionId) setSessionPendingPermission(instanceId, previousSessionId, remaining > 0) } if (sessionId) { if (inserted || previousSessionId !== sessionId) { - incrementSessionPendingCount(instanceId, sessionId) + permissionRegistry.increment(instanceId, sessionId) } setSessionPendingPermission(instanceId, sessionId, true) @@ -1400,14 +1467,13 @@ function removePermissionFromQueue(instanceId: string, permissionId: string): vo }) recomputeActiveInterruption(instanceId) - permissionEnqueuedAt.delete(permissionId) - deletePermissionSource(instanceId, permissionId) + permissionRegistry.remove(instanceId, permissionId) const removed = removedPermission if (removed) { const removedSessionId = getPermissionSessionId(removed) if (removedSessionId) { - const remaining = decrementSessionPendingCount(instanceId, removedSessionId) + const remaining = permissionRegistry.decrement(instanceId, removedSessionId) setSessionPendingPermission(instanceId, removedSessionId, remaining > 0) } } @@ -1469,10 +1535,9 @@ function clearSyncedYoloSessionsForInstance(instanceId: string): void { } function clearPermissionQueue(instanceId: string): void { - for (const permission of getPermissionQueue(instanceId)) { - permissionEnqueuedAt.delete(permission.id) - } - permissionSourceByInstance.delete(instanceId) + permissionRegistry.clear(instanceId, getPermissionQueue(instanceId), (sessionId) => { + setSessionPendingPermission(instanceId, sessionId, false) + }) setPermissionQueues((prev) => { const next = new Map(prev) next.delete(instanceId) @@ -1483,13 +1548,12 @@ function clearPermissionQueue(instanceId: string): void { next.delete(instanceId) return next }) - clearSessionPendingCounts(instanceId) recomputeActiveInterruption(instanceId) } function addQuestionToQueue(instanceId: string, request: QuestionRequest, source: QuestionSource = "v2"): void { let inserted = false - setQuestionSource(instanceId, request.id, source) + questionRegistry.setSource(instanceId, request.id, source) setQuestionQueues((prev) => { const next = new Map(prev) @@ -1499,9 +1563,9 @@ function addQuestionToQueue(instanceId: string, request: QuestionRequest, source return next } - ensureQuestionEnqueuedAt(request) + questionRegistry.ensureEnqueuedAt(request) const updatedQueue = [...queue, request].sort((a, b) => { - return ensureQuestionEnqueuedAt(a) - ensureQuestionEnqueuedAt(b) + return questionRegistry.ensureEnqueuedAt(a) - questionRegistry.ensureEnqueuedAt(b) }) next.set(instanceId, updatedQueue) inserted = true @@ -1516,7 +1580,7 @@ function addQuestionToQueue(instanceId: string, request: QuestionRequest, source const sessionId = getQuestionSessionId(request) if (sessionId) { - incrementQuestionSessionPendingCount(instanceId, sessionId) + questionRegistry.increment(instanceId, sessionId) setSessionPendingQuestion(instanceId, sessionId, true) } @@ -1538,21 +1602,19 @@ function removeQuestionFromQueue(instanceId: string, requestId: string): void { return next }) - questionEnqueuedAt.delete(requestId) - deleteQuestionSource(instanceId, requestId) + questionRegistry.remove(instanceId, requestId) recomputeActiveInterruption(instanceId) if (removedSessionId) { - const remaining = decrementQuestionSessionPendingCount(instanceId, removedSessionId) + const remaining = questionRegistry.decrement(instanceId, removedSessionId) setSessionPendingQuestion(instanceId, removedSessionId, remaining > 0) } } function clearQuestionQueue(instanceId: string): void { - for (const request of getQuestionQueue(instanceId)) { - questionEnqueuedAt.delete(request.id) - } - questionSourceByInstance.delete(instanceId) + questionRegistry.clear(instanceId, getQuestionQueue(instanceId), (sessionId) => { + setSessionPendingQuestion(instanceId, sessionId, false) + }) setQuestionQueues((prev) => { const next = new Map(prev) next.delete(instanceId) @@ -1563,7 +1625,6 @@ function clearQuestionQueue(instanceId: string): void { next.delete(instanceId) return next }) - clearQuestionSessionPendingCounts(instanceId) recomputeActiveInterruption(instanceId) } @@ -1588,7 +1649,7 @@ async function sendQuestionReply( try { const client = getRootClient(instanceId) - const source = getQuestionSource(instanceId, requestId) + const source = questionRegistry.getSource(instanceId, requestId) if (source === "legacy") { const workspace = sessionId ? await getOpenCodeWorkspaceIdForSession(instanceId, sessionId) : null @@ -1626,7 +1687,7 @@ async function sendQuestionReject(instanceId: string, sessionId: string, request try { const client = getRootClient(instanceId) - const source = getQuestionSource(instanceId, requestId) + const source = questionRegistry.getSource(instanceId, requestId) if (source === "legacy") { const workspace = sessionId ? await getOpenCodeWorkspaceIdForSession(instanceId, sessionId) : null @@ -1668,7 +1729,7 @@ async function sendPermissionResponse( try { const client = getRootClient(instanceId) - const source = getPermissionSource(instanceId, requestId) + const source = permissionRegistry.getSource(instanceId, requestId) if (source === "legacy") { const workspace = sessionId ? await getOpenCodeWorkspaceIdForSession(instanceId, sessionId) : null @@ -1777,6 +1838,7 @@ export { updateInstance, removeInstance, createInstance, + cancelRestoreCreationRequest, disposeRestoreCreatedInstance, releaseRestoreCreatedInstance, claimRestoreCreatedInstanceForUser, diff --git a/packages/ui/src/stores/message-v2/bridge.ts b/packages/ui/src/stores/message-v2/bridge.ts index 6ef773b7..44c3872a 100644 --- a/packages/ui/src/stores/message-v2/bridge.ts +++ b/packages/ui/src/stores/message-v2/bridge.ts @@ -5,6 +5,7 @@ import { getQuestionCallId, getQuestionMessageId } from "../../types/question" import type { Message, MessageInfo, ClientPart } from "../../types/message" import type { Session } from "../../types/session" import { messageStoreBus } from "./bus" +import { canHydrateMessages } from "./message-hydration-authority" import type { MessageStatus, ReplaceMessageIdOptions, SessionRevertState } from "./types" interface SessionMetadata { @@ -40,9 +41,11 @@ export function seedSessionMessagesV2( session: Session | SessionMetadata, messages: Message[], messageInfos?: Map, -): void { - if (!session || !Array.isArray(messages)) return + expectedRevision?: number, +): boolean { + if (!session || !Array.isArray(messages)) return false const store = messageStoreBus.getOrCreate(instanceId) + if (expectedRevision !== undefined && !canHydrateMessages(expectedRevision, store.getSessionRevision(session.id))) return false const metadata: SessionMetadata = "id" in session ? { id: session.id, title: session.title, parentId: session.parentId ?? null } : session store.addOrUpdateSession({ @@ -65,6 +68,7 @@ export function seedSessionMessagesV2( })) store.hydrateMessages(metadata.id, normalizedMessages, messageInfos?.values()) + return true } interface MessageInfoOptions { @@ -117,7 +121,7 @@ export function applyPartDeltaV2( partId: input.partId, field: input.field, delta: input.delta, - bumpSessionRevision: false, + bumpSessionRevision: true, }) } @@ -281,16 +285,16 @@ export function removePermissionV2(instanceId: string, permissionId: string): vo store.removePermission(permissionId) } -export function removeMessageV2(instanceId: string, messageId: string): void { +export function removeMessageV2(instanceId: string, messageId: string, sessionId?: string): void { if (!messageId) return const store = messageStoreBus.getOrCreate(instanceId) - store.removeMessage(messageId) + store.removeMessage(messageId, sessionId) } -export function removeMessagePartV2(instanceId: string, messageId: string, partId: string): void { +export function removeMessagePartV2(instanceId: string, messageId: string, partId: string, sessionId?: string): void { if (!messageId || !partId) return const store = messageStoreBus.getOrCreate(instanceId) - store.removeMessagePart(messageId, partId) + store.removeMessagePart(messageId, partId, sessionId) } export function ensureSessionMetadataV2(instanceId: string, session: Session | null | undefined): void { diff --git a/packages/ui/src/stores/message-v2/bus.test.ts b/packages/ui/src/stores/message-v2/bus.test.ts index 44320d14..127b0d44 100644 --- a/packages/ui/src/stores/message-v2/bus.test.ts +++ b/packages/ui/src/stores/message-v2/bus.test.ts @@ -2,6 +2,7 @@ import assert from "node:assert/strict" import { describe, it } from "node:test" import { messageStoreBus } from "./bus.ts" +import { invalidateSessionMessageLoad, messagesLoaded, setMessagesLoaded } from "../session-state.ts" describe("message store scroll snapshots", () => { it("seeds an unregistered instance without claiming runtime authority", () => { @@ -58,4 +59,37 @@ describe("message store scroll snapshots", () => { if (messageStoreBus.getInstance(instanceId)) messageStoreBus.unregisterInstance(instanceId) } }) + + it("preserves scroll and invalidates message hydration during render-cache eviction", () => { + const instanceId = "scroll-render-cache-eviction" + const store = messageStoreBus.getOrCreate(instanceId) + const snapshot = { scrollTop: 240, atBottom: false, updatedAt: 2400 } + setMessagesLoaded((prev) => new Map(prev).set(instanceId, new Set(["session-1"]))) + try { + store.restoreScrollSnapshot("session-1", "message-stream", snapshot) + invalidateSessionMessageLoad(instanceId, "session-1") + store.clearSession("session-1", { preserveScroll: true, notify: false }) + assert.deepEqual(store.getScrollSnapshot("session-1", "message-stream"), snapshot) + assert.equal(messagesLoaded().get(instanceId)?.has("session-1") ?? false, false) + } finally { + messageStoreBus.unregisterInstance(instanceId) + } + }) + + it("does not replace newer runtime scroll with a late native seed", () => { + const instanceId = "scroll-late-seed" + const store = messageStoreBus.getOrCreate(instanceId) + const current = { scrollTop: 300, atBottom: false, updatedAt: 3000 } + try { + store.restoreScrollSnapshot("session-1", "message-stream", current) + messageStoreBus.seedScrollSnapshots(instanceId, [{ + sessionId: "session-1", + scope: "message-stream", + snapshot: { scrollTop: 100, atBottom: false, updatedAt: 1000 }, + }]) + assert.deepEqual(store.getScrollSnapshot("session-1", "message-stream"), current) + } finally { + messageStoreBus.unregisterInstance(instanceId) + } + }) }) diff --git a/packages/ui/src/stores/message-v2/bus.ts b/packages/ui/src/stores/message-v2/bus.ts index 6169bdcb..86900cd9 100644 --- a/packages/ui/src/stores/message-v2/bus.ts +++ b/packages/ui/src/stores/message-v2/bus.ts @@ -38,7 +38,7 @@ class MessageStoreBus { if (seeds) { this.scrollSnapshotSeeds.delete(instanceId) for (const seed of seeds) { - resolved.restoreScrollSnapshot(seed.sessionId, seed.scope, seed.snapshot) + this.applyScrollSeed(resolved, seed) } } return resolved @@ -75,13 +75,19 @@ class MessageStoreBus { const store = this.stores.get(instanceId) if (store) { for (const seed of seeds) { - store.restoreScrollSnapshot(seed.sessionId, seed.scope, seed.snapshot) + this.applyScrollSeed(store, seed) } return } this.scrollSnapshotSeeds.set(instanceId, seeds) } + private applyScrollSeed(store: InstanceMessageStore, seed: MessageScrollSnapshotSeed): void { + const current = store.getScrollSnapshot(seed.sessionId, seed.scope) + if (current && current.updatedAt >= seed.snapshot.updatedAt) return + store.restoreScrollSnapshot(seed.sessionId, seed.scope, seed.snapshot) + } + private notifyScrollSnapshotChanged( instanceId: string, sessionId: string, diff --git a/packages/ui/src/stores/message-v2/instance-store.ts b/packages/ui/src/stores/message-v2/instance-store.ts index 9ecc00a4..437bea3d 100644 --- a/packages/ui/src/stores/message-v2/instance-store.ts +++ b/packages/ui/src/stores/message-v2/instance-store.ts @@ -226,8 +226,8 @@ export interface InstanceMessageStore { bumpRevision?: boolean bumpSessionRevision: boolean }) => void - removeMessage: (messageId: string) => void - removeMessagePart: (messageId: string, partId: string) => void + removeMessage: (messageId: string, fallbackSessionId?: string) => void + removeMessagePart: (messageId: string, partId: string, fallbackSessionId?: string) => void bufferPendingPart: (entry: PendingPartEntry) => void flushPendingParts: (messageId: string) => void replaceMessageId: (options: ReplaceMessageIdOptions) => void @@ -254,7 +254,7 @@ export interface InstanceMessageStore { getLastCompactionMessageIndex: (sessionId: string) => number getMessage: (messageId: string) => MessageRecord | undefined getLatestTodoSnapshot: (sessionId: string) => LatestTodoSnapshot | undefined - clearSession: (sessionId: string) => void + clearSession: (sessionId: string, options?: { preserveScroll?: boolean; notify?: boolean }) => void clearScrollSnapshots: () => void clearInstance: () => void } @@ -732,7 +732,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt } } - function removeMessage(messageId: string) { + function removeMessage(messageId: string, fallbackSessionId?: string) { if (!messageId) return const record = state.messages[messageId] @@ -751,6 +751,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt } }) } + if (!sessionIds.size && fallbackSessionId) sessionIds.add(fallbackSessionId) clearRecordDisplayCacheForMessages(instanceId, [messageId]) @@ -800,10 +801,13 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt }) } - function removeMessagePart(messageId: string, partId: string) { + function removeMessagePart(messageId: string, partId: string, fallbackSessionId?: string) { if (!messageId || !partId) return const message = state.messages[messageId] - if (!message) return + if (!message) { + if (fallbackSessionId) bumpSessionRevision(fallbackSessionId) + return + } clearRecordDisplayCacheForMessages(instanceId, [messageId]) @@ -1221,7 +1225,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt return state.scrollState[key] } - function clearSession(sessionId: string) { + function clearSession(sessionId: string, options?: { preserveScroll?: boolean; notify?: boolean }) { if (!sessionId) return clearPromptDisplayOverridesForSession(instanceId, sessionId) @@ -1290,16 +1294,18 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt return next }) - setState("scrollState", (prev) => { - const next = { ...prev } - const prefix = `${sessionId}:` - Object.keys(next).forEach((key) => { - if (key.startsWith(prefix)) { - delete next[key] - } + if (!options?.preserveScroll) { + setState("scrollState", (prev) => { + const next = { ...prev } + const prefix = `${sessionId}:` + Object.keys(next).forEach((key) => { + if (key.startsWith(prefix)) { + delete next[key] + } + }) + return next }) - return next - }) + } setState("sessions", sessionId, (current) => { if (!current) return current @@ -1317,7 +1323,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt clearLatestTodoSnapshot(sessionId) - hooks?.onSessionCleared?.(instanceId, sessionId) + if (options?.notify !== false) hooks?.onSessionCleared?.(instanceId, sessionId) } diff --git a/packages/ui/src/stores/message-v2/message-hydration-authority.test.ts b/packages/ui/src/stores/message-v2/message-hydration-authority.test.ts new file mode 100644 index 00000000..d5ff9842 --- /dev/null +++ b/packages/ui/src/stores/message-v2/message-hydration-authority.test.ts @@ -0,0 +1,9 @@ +import assert from "node:assert/strict" +import test from "node:test" + +import { canHydrateMessages } from "./message-hydration-authority.ts" + +test("rejects HTTP hydration after a newer message revision", () => { + assert.equal(canHydrateMessages(4, 5), false) + assert.equal(canHydrateMessages(5, 5), true) +}) diff --git a/packages/ui/src/stores/message-v2/message-hydration-authority.ts b/packages/ui/src/stores/message-v2/message-hydration-authority.ts new file mode 100644 index 00000000..c4493423 --- /dev/null +++ b/packages/ui/src/stores/message-v2/message-hydration-authority.ts @@ -0,0 +1,2 @@ +export const canHydrateMessages = (expectedRevision: number, currentRevision: number): boolean => + expectedRevision === currentRevision diff --git a/packages/ui/src/stores/restore-creation-cancellation.ts b/packages/ui/src/stores/restore-creation-cancellation.ts new file mode 100644 index 00000000..dccf8ca4 --- /dev/null +++ b/packages/ui/src/stores/restore-creation-cancellation.ts @@ -0,0 +1,11 @@ +import { serverApi } from "../lib/api-client" +import { retryWithBackoff } from "../lib/retry-utils" + +export function cancelRestoreCreation(requestId: string): Promise { + return retryWithBackoff(() => serverApi.cancelWorkspaceCreation(requestId), { + maxAttempts: 4, + initialDelayMs: 250, + maxDelayMs: 2_000, + backoffMultiplier: 4, + }) +} diff --git a/packages/ui/src/stores/restore-workspace-commit-gates.test.ts b/packages/ui/src/stores/restore-workspace-commit-gates.test.ts new file mode 100644 index 00000000..0ee5b872 --- /dev/null +++ b/packages/ui/src/stores/restore-workspace-commit-gates.test.ts @@ -0,0 +1,81 @@ +import assert from "node:assert/strict" +import { readFileSync } from "node:fs" +import { describe, it } from "node:test" +import type { WorkspaceDescriptor } from "../../../server/src/api-types.ts" +import { RestoreWorkspaceCommitGates } from "./restore-workspace-commit-gates.ts" + +const workspace = ( + status: WorkspaceDescriptor["status"], + updatedAt: string, + values: Partial = {}, +): WorkspaceDescriptor => ({ + id: "workspace-1", requestId: "request-1", path: "/work", name: "work", status, + proxyPath: "", binaryId: "opencode", binaryLabel: "OpenCode", createdAt: "2026-01-01T00:00:00Z", + updatedAt, ...values, +}) + +describe("restore workspace commit gates", () => { + it("integrates refresh and terminal events without bypassing the gate", () => { + const source = readFileSync(new URL("./instances.ts", import.meta.url), "utf8") + const refresh = source.slice(source.indexOf("async function refreshWorkspaceList"), source.indexOf("const initialWorkspaceLoad")) + assert.ok(refresh.indexOf("restoreCreationCommitGates.deferRefreshWorkspace(workspace)") < refresh.indexOf("upsertWorkspace(workspace)")) + assert.match(source, /restoreCreationCommitGates\.deferStopped\(event\.workspaceId, event\.reason\)/) + assert.match(source, /settleRestoreWorkspaceTerminal\(committedWorkspace, terminal\)/) + }) + + it("defers refresh/SSE descriptors and prefers a ready HTTP response over stale created state", () => { + const gates = new RestoreWorkspaceCommitGates() + gates.begin("request-1", Promise.resolve()) + assert.equal(gates.deferWorkspace(workspace("starting", "2026-01-01T00:00:01Z")), true) + const response = workspace("ready", "2026-01-01T00:00:02Z", { port: 3000 }) + assert.equal(gates.resolve("request-1", response).workspace, response) + }) + + it("defers a refresh descriptor without request correlation when its path is gated", () => { + const gates = new RestoreWorkspaceCommitGates() + gates.begin("request-1", Promise.resolve(), String.raw`C:\Work`) + const refresh = workspace("starting", "2026-01-01T00:00:01Z", { + requestId: undefined, path: "c:/work/", + }) + assert.equal(gates.deferRefreshWorkspace(refresh), true) + assert.equal(gates.resolve("request-1", workspace("ready", "2026-01-01T00:00:02Z")).workspace.status, "ready") + }) + + it("uses a newer equally-advanced SSE descriptor", () => { + const gates = new RestoreWorkspaceCommitGates() + gates.begin("request-1", Promise.resolve()) + const response = workspace("ready", "2026-01-01T00:00:02Z", { port: 3000 }) + const event = workspace("ready", "2026-01-01T00:00:03Z", { port: 4000 }) + gates.deferWorkspace(event) + assert.equal(gates.resolve("request-1", response).workspace, event) + }) + + it("retains error and stopped terminals until create handling resolves them", () => { + const gates = new RestoreWorkspaceCommitGates() + gates.begin("request-1", Promise.resolve()) + gates.deferWorkspace(workspace("starting", "2026-01-01T00:00:01Z")) + assert.equal(gates.deferStopped("workspace-1", "server stopped"), true) + assert.deepEqual(gates.resolve("request-1", workspace("ready", "2026-01-01T00:00:02Z")).terminal, + { status: "stopped", message: "server stopped" }) + gates.end("request-1") + assert.equal(gates.deferStopped("workspace-1"), false, "terminal events are handled normally after commit") + }) + + it("correlates a stopped event that arrives before the HTTP response binds its workspace ID", () => { + const gates = new RestoreWorkspaceCommitGates() + gates.begin("request-1", Promise.resolve()) + assert.equal(gates.deferStopped("workspace-1", "stopped before response"), false) + gates.bindResponse("request-1", "workspace-1") + assert.deepEqual(gates.resolve("request-1", workspace("ready", "2026-01-01T00:00:02Z")).terminal, + { status: "stopped", message: "stopped before response" }) + }) + + it("retains a correlated workspace error over a ready response", () => { + const gates = new RestoreWorkspaceCommitGates() + gates.begin("request-1", Promise.resolve()) + gates.deferWorkspace(workspace("error", "2026-01-01T00:00:03Z", { error: "launch failed" })) + const resolved = gates.resolve("request-1", workspace("ready", "2026-01-01T00:00:02Z")) + assert.equal(resolved.workspace.status, "error") + assert.deepEqual(resolved.terminal, { status: "error", message: "launch failed" }) + }) +}) diff --git a/packages/ui/src/stores/restore-workspace-commit-gates.ts b/packages/ui/src/stores/restore-workspace-commit-gates.ts new file mode 100644 index 00000000..245e1f09 --- /dev/null +++ b/packages/ui/src/stores/restore-workspace-commit-gates.ts @@ -0,0 +1,111 @@ +import type { WorkspaceDescriptor } from "../../../server/src/api-types" +import { normalizeWorkspacePath } from "./app-session-reconciliation" + +export interface RestoreWorkspaceTerminal { + status: "error" | "stopped" + message?: string +} + +export interface RestoreWorkspaceCommitGate { + requestId: string + wait: Promise + workspace?: T + terminal?: RestoreWorkspaceTerminal + workspaceIds: Set + expectedPath?: string +} + +const statusRank = (status: WorkspaceDescriptor["status"]) => + status === "error" || status === "stopped" ? 3 : status === "ready" ? 2 : 1 + +export function preferAdvancedWorkspaceDescriptor(current: T, candidate: T): T { + const rankDifference = statusRank(candidate.status) - statusRank(current.status) + if (rankDifference !== 0) return rankDifference > 0 ? candidate : current + const currentUpdatedAt = Date.parse(current.updatedAt) + const candidateUpdatedAt = Date.parse(candidate.updatedAt) + if (Number.isFinite(currentUpdatedAt) && Number.isFinite(candidateUpdatedAt) && currentUpdatedAt !== candidateUpdatedAt) { + return candidateUpdatedAt > currentUpdatedAt ? candidate : current + } + return candidate +} + +export class RestoreWorkspaceCommitGates { + private readonly byRequestId = new Map>() + private readonly byWorkspaceId = new Map>() + private readonly stoppedByWorkspaceId = new Map() + + begin(requestId: string, wait: Promise, expectedPath?: string): RestoreWorkspaceCommitGate { + const gate = { + requestId, wait, workspaceIds: new Set(), + ...(expectedPath ? { expectedPath: normalizeWorkspacePath(expectedPath) } : {}), + } + this.byRequestId.set(requestId, gate) + return gate + } + + bindResponse(requestId: string, workspaceId: string): void { + const gate = this.byRequestId.get(requestId) + if (!gate) return + gate.workspaceIds.add(workspaceId) + this.byWorkspaceId.set(workspaceId, gate) + const stopped = this.stoppedByWorkspaceId.get(workspaceId) + if (stopped) { + gate.terminal = stopped + this.stoppedByWorkspaceId.delete(workspaceId) + } + } + + deferWorkspace(workspace: T): boolean { + const gate = workspace.requestId + ? this.byRequestId.get(workspace.requestId) ?? this.byWorkspaceId.get(workspace.id) + : this.byWorkspaceId.get(workspace.id) + if (!gate) return false + gate.workspace = gate.workspace + ? preferAdvancedWorkspaceDescriptor(gate.workspace, workspace) + : workspace + gate.workspaceIds.add(workspace.id) + this.byWorkspaceId.set(workspace.id, gate) + if (workspace.status === "error" || workspace.status === "stopped") { + gate.terminal = { status: workspace.status, message: workspace.error } + } + return true + } + + deferRefreshWorkspace(workspace: T): boolean { + if (this.deferWorkspace(workspace)) return true + const path = normalizeWorkspacePath(workspace.path) + return [...this.byRequestId.values()].some((gate) => gate.expectedPath === path) + } + + deferStopped(workspaceId: string, message?: string): boolean { + const gate = this.byWorkspaceId.get(workspaceId) + const terminal = { status: "stopped" as const, message } + if (!gate) { + if (this.byRequestId.size > 0) this.stoppedByWorkspaceId.set(workspaceId, terminal) + return false + } + gate.terminal = terminal + return true + } + + resolve(requestId: string, response: T): { workspace: T; terminal?: RestoreWorkspaceTerminal } { + const gate = this.byRequestId.get(requestId) + return { + workspace: gate?.workspace + ? preferAdvancedWorkspaceDescriptor(response, gate.workspace) + : response, + ...(gate?.terminal ? { terminal: gate.terminal } : {}), + } + } + + end(requestId: string): void { + const gate = this.byRequestId.get(requestId) + if (!gate) return + this.byRequestId.delete(requestId) + for (const workspaceId of gate.workspaceIds) { + if (this.byWorkspaceId.get(workspaceId) === gate) this.byWorkspaceId.delete(workspaceId) + this.stoppedByWorkspaceId.delete(workspaceId) + } + if (this.byRequestId.size === 0) this.stoppedByWorkspaceId.clear() + } +} diff --git a/packages/ui/src/stores/session-api.ts b/packages/ui/src/stores/session-api.ts index 578acebb..692afa9c 100644 --- a/packages/ui/src/stores/session-api.ts +++ b/packages/ui/src/stores/session-api.ts @@ -29,6 +29,8 @@ import { providers, setAgents, setMessagesLoaded, + advanceMessageLoadEpoch, + isCurrentMessageLoad, setSessionMessagesLoadError, setProviders, setSessionInfoByInstance, @@ -63,20 +65,31 @@ import { getRootClient } from "./opencode-client" import { tGlobal } from "../lib/i18n" import { getWorktreeSlugForSession, + getWorktreeSlugForDirectory, getWorktrees, migrateLegacyWorktreeMapToSessionMetadata, pruneStaleLegacyWorktreeMapEntries, removeLegacyParentSessionMapping, setWorktreeSlugForParentSession, } from "./worktrees" -import { getOpenCodeWorkspaceIdForSession } from "./opencode-workspaces" +import { getOpenCodeWorkspaceIdForSession, getOpenCodeWorkspaceIdForWorktree } from "./opencode-workspaces" import { hydrateSessionMetadataWithClient } from "./session-metadata" -import { PROJECT_SESSION_LIST_LIMIT, buildProjectSessionListOptions, filterProjectScopedSessions } from "./session-list-options" +import { preferSessionMetadata, shouldReplaceSessionMetadata } from "./session-metadata-completeness" +import { + PROJECT_SESSION_LIST_LIMIT, + buildProjectSessionListOptions, + filterProjectScopedSessions, + getAuthoritativelyMissingSessionIds, + isProjectSessionListComplete, +} from "./session-list-options" import { mergeFetchedSessionRuntimeState, resolveAuthoritativeGenerationRecovery } from "./session-generation-recovery" const log = getLogger("api") const sessionListRequestIds = new Map() let nextSessionListRequestId = 0 +const pendingMetadataHydrations = new Map>() +const sessionWorkspaceHints = new Map>() +messageStoreBus.onInstanceDestroyed((instanceId) => sessionWorkspaceHints.delete(instanceId)) function beginSessionListRequest(instanceId: string): number { const requestId = ++nextSessionListRequestId @@ -100,10 +113,61 @@ function clearSessionListRequestState(instanceId: string): void { } async function getSessionWorkspacePayload(instanceId: string, sessionId: string): Promise<{ workspace?: string }> { + const hinted = sessionWorkspaceHints.get(instanceId)?.get(sessionId) + if (hinted) return { workspace: hinted } const workspace = await getOpenCodeWorkspaceIdForSession(instanceId, sessionId) return workspace ? { workspace } : {} } +async function getSessionWorkspaceCandidates( + instanceId: string, + sessionId: string, + fallback: { workspace?: string } = {}, +): Promise> { + const candidates: Array<{ workspace?: string }> = [] + const seen = new Set() + const add = (candidate: { workspace?: string }) => { + const key = candidate.workspace ?? "root" + if (seen.has(key)) return + seen.add(key) + candidates.push(candidate) + } + add(await getSessionWorkspacePayload(instanceId, sessionId)) + add(fallback) + add({}) + for (const worktree of getWorktrees(instanceId)) { + if (!worktree.slug || worktree.slug === "root") continue + const workspace = await getOpenCodeWorkspaceIdForWorktree(instanceId, worktree.slug) + if (workspace) add({ workspace }) + } + return candidates +} + +function rememberSessionWorkspace(instanceId: string, sessionId: string, workspace: string | undefined): void { + if (!workspace) return + const hints = new Map(sessionWorkspaceHints.get(instanceId) ?? new Map()) + hints.set(sessionId, workspace) + sessionWorkspaceHints.set(instanceId, hints) +} + +async function recordSessionWorkspaceHints(instanceId: string, apiSessions: SDKSession[]): Promise { + const hints = new Map(sessionWorkspaceHints.get(instanceId) ?? new Map()) + const workspaceBySlug = new Map>() + await Promise.all(apiSessions.map(async (session) => { + const directory = (session as SDKSession & { directory?: string }).directory + const slug = getWorktreeSlugForDirectory(instanceId, directory) + if (!slug || slug === "root") return + let workspace = workspaceBySlug.get(slug) + if (!workspace) { + workspace = getOpenCodeWorkspaceIdForWorktree(instanceId, slug) + workspaceBySlug.set(slug, workspace) + } + const workspaceId = await workspace + if (workspaceId) hints.set(session.id, workspaceId) + })) + sessionWorkspaceHints.set(instanceId, hints) +} + interface SessionForkResponse { id: string title?: string @@ -133,6 +197,8 @@ type V2SessionListOptions = { type ProjectSessionListResponse = { data: SDKSession[] + listedIds: Set + complete: boolean } function getKnownParentId(session: SDKSession | Session): string | null | undefined { @@ -164,6 +230,8 @@ async function fetchV2Sessions(instanceId: string, options: V2SessionListOptions return { data: filterProjectScopedSessions(data, allowedDirectories), + listedIds: new Set(data.map((session) => session.id)), + complete: isProjectSessionListComplete(data.length), } } @@ -176,15 +244,115 @@ async function hydrateMissingSessionMetadata(instanceId: string, sessionIds: str if (uniqueIds.length === 0) return const client = getRootClient(instanceId) - for (const sessionId of uniqueIds) { - const session = sessions().get(instanceId)?.get(sessionId) - if (!session || session.metadata !== undefined) continue - try { - await hydrateSessionMetadataWithClient(client, instanceId, sessionId) - } catch (error) { - log.warn("Failed to hydrate session metadata", { instanceId, sessionId, error }) + let nextIndex = 0 + const worker = async () => { + while (nextIndex < uniqueIds.length) { + const sessionId = uniqueIds[nextIndex++]! + const session = sessions().get(instanceId)?.get(sessionId) + if (!session || !shouldReplaceSessionMetadata(session.metadata)) continue + try { + await hydrateSessionMetadata(instanceId, sessionId, client) + } catch (error) { + log.warn("Failed to hydrate session metadata", { instanceId, sessionId, error }) + } } } + await Promise.all(Array.from({ length: Math.min(8, uniqueIds.length) }, worker)) +} + +function hydrateSessionMetadata(instanceId: string, sessionId: string, client = getRootClient(instanceId)): Promise { + const key = `${instanceId}:${sessionId}` + const current = pendingMetadataHydrations.get(key) + if (current) return current + const hydration = (async () => { + const candidates = await getSessionWorkspaceCandidates(instanceId, sessionId) + let lastError: unknown + for (const delayMs of [0, 100, 400]) { + if (delayMs > 0) await new Promise((resolve) => setTimeout(resolve, delayMs)) + for (const candidate of candidates) { + try { + await hydrateSessionMetadataWithClient(client, instanceId, sessionId, candidate) + rememberSessionWorkspace(instanceId, sessionId, candidate.workspace) + return + } catch (error) { + lastError = error + } + } + } + throw lastError + })().finally(() => pendingMetadataHydrations.delete(key)) + pendingMetadataHydrations.set(key, hydration) + return hydration +} + +async function hydrateRestoredSessionChain( + instanceId: string, + requestedIds: Array, + signal?: AbortSignal, +): Promise { + const client = getRootClient(instanceId) + const pending = requestedIds.filter((id): id is string => Boolean(id) && id !== "info") + const visited = new Set() + let chainWorkspacePayload: { workspace?: string } = {} + while (pending.length > 0) { + signal?.throwIfAborted() + const sessionId = pending.shift()! + if (visited.has(sessionId)) continue + visited.add(sessionId) + if (getAuthoritativelyDeletedSessionIdsForInstance(instanceId).has(sessionId)) continue + + let session = sessions().get(instanceId)?.get(sessionId) + if (!session) { + try { + const workspaceCandidates = await getSessionWorkspaceCandidates(instanceId, sessionId, chainWorkspacePayload) + signal?.throwIfAborted() + let apiSession: SDKSession | undefined + let hydratedWorkspace: string | undefined + let lastError: unknown + for (const workspacePayload of workspaceCandidates) { + try { + apiSession = await requestData( + client.session.get({ sessionID: sessionId, ...workspacePayload }), + "session.get", + ) + hydratedWorkspace = workspacePayload.workspace + break + } catch (error) { + lastError = error + } + } + if (!apiSession) throw lastError + signal?.throwIfAborted() + rememberSessionWorkspace(instanceId, sessionId, hydratedWorkspace) + setSessions((prev) => { + if (getAuthoritativelyDeletedSessionIdsForInstance(instanceId).has(sessionId) || signal?.aborted) return prev + const next = new Map(prev) + const instanceSessions = new Map(next.get(instanceId) ?? new Map()) + instanceSessions.set(sessionId, toClientSessionV2(instanceId, apiSession, instanceSessions.get(sessionId))) + next.set(instanceId, instanceSessions) + return next + }) + session = sessions().get(instanceId)?.get(sessionId) + if (session?.parentId === null) prependSessionListId(instanceId, sessionId) + } catch (error) { + if (signal?.aborted) throw error + log.warn("Failed to hydrate restored session", { instanceId, sessionId, error }) + continue + } + } else if (shouldReplaceSessionMetadata(session.metadata)) { + try { + await hydrateSessionMetadata(instanceId, sessionId, client) + } catch (error) { + if (signal?.aborted) throw error + log.warn("Failed to hydrate restored session metadata", { instanceId, sessionId, error }) + } + } + if (session?.parentId === null) { + const rootWorkspacePayload = await getSessionWorkspacePayload(instanceId, session.id) + if (rootWorkspacePayload.workspace) chainWorkspacePayload = rootWorkspacePayload + } + if (session?.parentId) pending.push(session.parentId) + } } async function ensureV2ParentChainsLoaded(instanceId: string, apiSessions: SDKSession[], directory?: string): Promise { @@ -201,8 +369,10 @@ async function ensureV2ParentChainsLoaded(instanceId: string, apiSessions: SDKSe setSessions((prev) => { const next = new Map(prev) const instanceSessions = new Map(next.get(instanceId) ?? new Map()) + const deletedSessionIds = getAuthoritativelyDeletedSessionIdsForInstance(instanceId) for (const apiSession of items) { + if (deletedSessionIds.has(apiSession.id)) continue const existingSession = instanceSessions.get(apiSession.id) instanceSessions.set(apiSession.id, toClientSessionV2(instanceId, apiSession, existingSession)) loaded.set(apiSession.id, apiSession) @@ -236,6 +406,7 @@ async function fetchSessions(instanceId: string, options?: { reset?: boolean }): log.info("session.list", { instanceId, limit: PROJECT_SESSION_LIST_LIMIT, directory: sessionListOptions.directory, scope: "project" }) const response = await fetchV2Sessions(instanceId, sessionListOptions) if (!isLatestSessionListRequest(instanceId, requestId)) return + await recordSessionWorkspaceHints(instanceId, getV2SessionItems(response)) let statusById: Record = {} let statusResponseKnown = false @@ -280,6 +451,13 @@ async function fetchSessions(instanceId: string, options?: { reset?: boolean }): }) } + const remotelyDeletedSessionIds = getAuthoritativelyMissingSessionIds( + existingSessions.keys(), + response.listedIds, + response.complete, + ) + for (const sessionId of remotelyDeletedSessionIds) removeSessionRuntimeState(instanceId, sessionId) + setSessions((prev) => { const next = new Map(prev) const instanceSessions = new Map(next.get(instanceId) ?? new Map()) @@ -398,8 +576,10 @@ async function searchSessions(instanceId: string, query: string): Promise setSessions((prev) => { const next = new Map(prev) const instanceSessions = new Map(next.get(instanceId) ?? new Map()) + const deletedSessionIds = getAuthoritativelyDeletedSessionIdsForInstance(instanceId) for (const apiSession of searchResults) { + if (deletedSessionIds.has(apiSession.id)) continue const existingSession = instanceSessions.get(apiSession.id) instanceSessions.set(apiSession.id, toClientSessionV2(instanceId, apiSession, existingSession)) } @@ -407,13 +587,16 @@ async function searchSessions(instanceId: string, query: string): Promise next.set(instanceId, instanceSessions) return next }) + void hydrateMissingSessionMetadata(instanceId, searchResults.map((session) => session.id)) await ensureV2ParentChainsLoaded(instanceId, searchResults, instance.folder) if (!isLatestSessionSearch(instanceId, trimmedQuery, requestId)) return const hydratedSessions = sessions().get(instanceId) - const hasUnrenderableChildResult = searchResults.some((session) => { + const deletedSessionIds = getAuthoritativelyDeletedSessionIdsForInstance(instanceId) + const currentSearchResults = searchResults.filter((session) => !deletedSessionIds.has(session.id)) + const hasUnrenderableChildResult = currentSearchResults.some((session) => { const parentId = session.parentID return Boolean(parentId && !hydratedSessions?.has(parentId)) }) @@ -424,7 +607,7 @@ async function searchSessions(instanceId: string, query: string): Promise } syncInstanceSessionIndicator(instanceId) - setSessionSearchResults(instanceId, trimmedQuery, searchResults.map((session) => session.id), requestId) + setSessionSearchResults(instanceId, trimmedQuery, currentSearchResults.map((session) => session.id), requestId) } catch (error) { log.error("Failed to search sessions:", error) if (isLatestSessionSearch(instanceId, trimmedQuery, requestId)) { @@ -435,6 +618,7 @@ async function searchSessions(instanceId: string, query: string): Promise } function toClientSessionV2(instanceId: string, apiSession: SDKSession, existingSession?: Session): Session { + const incomingMetadata = (apiSession as SDKSession & { metadata?: Session["metadata"] }).metadata return { id: apiSession.id, instanceId, @@ -457,7 +641,7 @@ function toClientSessionV2(instanceId: string, apiSession: SDKSession, existingS time: { ...apiSession.time, }, - metadata: existingSession?.metadata, + metadata: preferSessionMetadata(incomingMetadata, existingSession?.metadata), revert: existingSession?.revert, pendingPermission: existingSession?.pendingPermission, pendingQuestion: existingSession?.pendingQuestion, @@ -720,6 +904,7 @@ async function deleteSession(instanceId: string, sessionId: string): Promise { const next = { ...prev } const loadingSet = next.loadingMessages.get(instanceId) || new Set() @@ -906,132 +1095,134 @@ async function loadMessages( "session.messages", ) + if (!isCurrentMessageLoad(instanceId, sessionId, loadEpoch) || !sessions().get(instanceId)?.has(sessionId)) return + if (!Array.isArray(apiMessages)) { return } setSessionMessagesLoadError(instanceId, sessionId, null) - // Treat empty sessions as loaded to avoid re-fetch loops. - setMessagesLoaded((prev) => { - const next = new Map(prev) - const loadedSet = next.get(instanceId) || new Set() - loadedSet.add(sessionId) - next.set(instanceId, loadedSet) - return next - }) - if (apiMessages.length === 0) { - return - } + if (messageStoreBus.getOrCreate(instanceId).getSessionRevision(sessionId) !== messageRevision) { + retryAfterRevisionConflict = true + } else { + setMessagesLoaded((prev) => { + const next = new Map(prev) + const loadedSet = next.get(instanceId) || new Set() + loadedSet.add(sessionId) + next.set(instanceId, loadedSet) + return next + }) + } + } else { + const messagesInfo = new Map() + const messages: Message[] = apiMessages.map((apiMessage: any) => { + const info = apiMessage.info || apiMessage + const role = info.role || "assistant" + const messageId = info.id || String(Date.now()) - const messagesInfo = new Map() - const messages: Message[] = apiMessages.map((apiMessage: any) => { - const info = apiMessage.info || apiMessage - const role = info.role || "assistant" - const messageId = info.id || String(Date.now()) + messagesInfo.set(messageId, info) - messagesInfo.set(messageId, info) + const parts: any[] = (apiMessage.parts || []).map((part: any) => normalizeMessagePart(part)) - const parts: any[] = (apiMessage.parts || []).map((part: any) => normalizeMessagePart(part)) + const message: Message = { + id: messageId, + sessionId, + type: role === "user" ? "user" : "assistant", + parts, + timestamp: info.time?.created || Date.now(), + status: "complete" as const, + version: 0, + } - const message: Message = { - id: messageId, - sessionId, - type: role === "user" ? "user" : "assistant", - parts, - timestamp: info.time?.created || Date.now(), - status: "complete" as const, - version: 0, + return message + }) + + let agentName = "" + let providerID = "" + let modelID = "" + + for (let i = apiMessages.length - 1; i >= 0; i--) { + const apiMessage = apiMessages[i] + const info = apiMessage.info || apiMessage + + if (info.role === "assistant") { + agentName = (info as any).mode || (info as any).agent || "" + providerID = (info as any).providerID || "" + modelID = (info as any).modelID || "" + if (agentName && providerID && modelID) break + } } - return message - }) - - let agentName = "" - let providerID = "" - let modelID = "" - - for (let i = apiMessages.length - 1; i >= 0; i--) { - const apiMessage = apiMessages[i] - const info = apiMessage.info || apiMessage - - if (info.role === "assistant") { - agentName = (info as any).mode || (info as any).agent || "" - providerID = (info as any).providerID || "" - modelID = (info as any).modelID || "" - if (agentName && providerID && modelID) break + if (!agentName && !providerID && !modelID) { + const defaultModel = await getDefaultModel(instanceId, session.agent) + if (!isCurrentMessageLoad(instanceId, sessionId, loadEpoch) || !sessions().get(instanceId)?.has(sessionId)) return + agentName = session.agent + providerID = defaultModel.providerId + modelID = defaultModel.modelId } - } - if (!agentName && !providerID && !modelID) { - const defaultModel = await getDefaultModel(instanceId, session.agent) - agentName = session.agent - providerID = defaultModel.providerId - modelID = defaultModel.modelId - } - - setSessions((prev) => { - const next = new Map(prev) - const nextInstanceSessions = next.get(instanceId) - if (!nextInstanceSessions) { + setSessions((prev) => { + if (!isCurrentMessageLoad(instanceId, sessionId, loadEpoch)) return prev + const next = new Map(prev) + const nextInstanceSessions = next.get(instanceId) + if (!nextInstanceSessions) return next + const existingSession = nextInstanceSessions.get(sessionId) + if (!existingSession) return next + nextInstanceSessions.set(sessionId, { + ...existingSession, + agent: agentName || existingSession.agent, + model: providerID && modelID ? { providerId: providerID, modelId: modelID } : existingSession.model, + }) + next.set(instanceId, nextInstanceSessions) return next + }) + + const sessionForV2 = sessions().get(instanceId)?.get(sessionId) ?? { + id: sessionId, title: session?.title, parentId: session?.parentId ?? null, revert: session?.revert, } - - const existingSession = nextInstanceSessions.get(sessionId) - if (!existingSession) { - return next + if (!isCurrentMessageLoad(instanceId, sessionId, loadEpoch)) return + if (!seedSessionMessagesV2(instanceId, sessionForV2, messages, messagesInfo, messageRevision)) { + retryAfterRevisionConflict = true + } else { + setMessagesLoaded((prev) => { + const next = new Map(prev) + const loadedSet = next.get(instanceId) || new Set() + loadedSet.add(sessionId) + next.set(instanceId, loadedSet) + return next + }) + reconcilePendingPermissionsV2(instanceId, sessionId) + reconcilePendingQuestionsV2(instanceId, sessionId) } - - const updatedSession = { - ...existingSession, - agent: agentName || existingSession.agent, - model: providerID && modelID ? { providerId: providerID, modelId: modelID } : existingSession.model, - } - - nextInstanceSessions.set(sessionId, updatedSession) - next.set(instanceId, nextInstanceSessions) - return next - }) - - setMessagesLoaded((prev) => { - const next = new Map(prev) - const loadedSet = next.get(instanceId) || new Set() - loadedSet.add(sessionId) - next.set(instanceId, loadedSet) - return next - }) - - const sessionForV2 = sessions().get(instanceId)?.get(sessionId) ?? { - id: sessionId, - title: session?.title, - parentId: session?.parentId ?? null, - revert: session?.revert, } - seedSessionMessagesV2(instanceId, sessionForV2, messages, messagesInfo) - - // Permissions can be hydrated before messages/tool parts exist in the store. - // After message hydration, try to attach any pending permissions to tool-call part ids. - reconcilePendingPermissionsV2(instanceId, sessionId) - reconcilePendingQuestionsV2(instanceId, sessionId) } catch (error) { log.error("Failed to load messages:", error) - setSessionMessagesLoadError(instanceId, sessionId, getOpencodeErrorMessage(error, tGlobal("messageSection.loadError.detail"))) + if (isCurrentMessageLoad(instanceId, sessionId, loadEpoch)) { + setSessionMessagesLoadError(instanceId, sessionId, getOpencodeErrorMessage(error, tGlobal("messageSection.loadError.detail"))) + } throw error } finally { - setLoading((prev) => { - const next = { ...prev } - const loadingSet = next.loadingMessages.get(instanceId) - if (loadingSet) { - loadingSet.delete(sessionId) - } - return next - }) + if (isCurrentMessageLoad(instanceId, sessionId, loadEpoch)) { + setLoading((prev) => { + const next = { ...prev } + const loadingSet = next.loadingMessages.get(instanceId) + if (loadingSet) loadingSet.delete(sessionId) + return next + }) + } } + if (retryAfterRevisionConflict && sessions().get(instanceId)?.has(sessionId)) { + await new Promise((resolve) => setTimeout(resolve, 50)) + return loadMessages(instanceId, sessionId, { force: true, skipChildren }) + } + + if (!isCurrentMessageLoad(instanceId, sessionId, loadEpoch) || !sessions().get(instanceId)?.has(sessionId)) return updateSessionInfo(instanceId, sessionId) if (!skipChildren && session.parentId === null) { @@ -1056,6 +1247,7 @@ export { fetchProviders, fetchSessions, + hydrateRestoredSessionChain, loadMoreSessions, searchSessions, forkSession, diff --git a/packages/ui/src/stores/session-events.ts b/packages/ui/src/stores/session-events.ts index a208ac57..a5f7d1c5 100644 --- a/packages/ui/src/stores/session-events.ts +++ b/packages/ui/src/stores/session-events.ts @@ -62,8 +62,8 @@ import { type SessionRetryState, type SessionStatus, } from "../types/session" -import { cancelSessionGenerationAdmissions, ensureSessionAncestorsExpanded, prependSessionListId, sessions, setSessions, syncInstanceSessionIndicator, withSession } from "./session-state" -import { resolveAuthoritativeGenerationRecovery } from "./session-generation-recovery" +import { ensureSessionAncestorsExpanded, getAuthoritativelyDeletedSessionIdsForInstance, prependSessionListId, sessions, setSessionStatus, setSessions, syncInstanceSessionIndicator, withSession } from "./session-state" +import { mergeFetchedSessionRuntimeState } from "./session-generation-recovery" import { normalizeMessagePart } from "./message-v2/normalizers" import { updateSessionInfo } from "./message-v2/session-info" import { tGlobal } from "../lib/i18n" @@ -95,14 +95,6 @@ const log = getLogger("sse") const pendingSessionFetches = new Map>() let activeRetryToast: ToastHandle | null = null -function isSameRetryState(left: SessionRetryState | null | undefined, right: SessionRetryState | null | undefined): boolean { - const a = left ?? null - const b = right ?? null - if (a === b) return true - if (!a || !b) return false - return a.attempt === b.attempt && a.message === b.message && a.next === b.next -} - function shouldSendOsNotification(kind: "needsInput" | "idle"): boolean { if (typeof document === "undefined") return false const pref = preferences() @@ -166,47 +158,6 @@ interface TuiToastEvent { const ALLOWED_TOAST_VARIANTS = new Set(["info", "success", "warning", "error"]) -function applySessionStatus(instanceId: string, sessionId: string, status: SessionStatus, retry?: SessionRetryState | null) { - let expandAncestors = false - - withSession(instanceId, sessionId, (session) => { - const current = session.status ?? "idle" - const nextRetry = retry ?? null - const admissionPending = session.generationAdmissionToken !== undefined && status === "idle" - const generationRecovery = admissionPending - ? "pending" - : resolveAuthoritativeGenerationRecovery(session.generationRecovery, status) - if ( - current === status - && isSameRetryState(session.retry, nextRetry) - && session.runtimeStatusKnown === !admissionPending - && (session.generationRecovery ?? null) === generationRecovery - ) return false - - if (current === "compacting" && status !== "compacting") { - return false - } - - session.status = status - session.runtimeStatusKnown = !admissionPending - session.generationRecovery = generationRecovery - if (!admissionPending) { - cancelSessionGenerationAdmissions(instanceId, sessionId) - session.generationAdmissionToken = undefined - } - session.retry = status === "working" ? nextRetry : null - session.idleSince = getIdleSinceForStatusTransition(current, status, session.idleSince) - - // Auto-expand the parent thread when a child session starts working. - // Users can still collapse it; we only expand on the transition. - if (session.parentId && status === "working" && current !== "working") { - expandAncestors = true - } - }) - - if (expandAncestors) ensureSessionAncestorsExpanded(instanceId, sessionId) -} - async function fetchSessionInfo(instanceId: string, sessionId: string, directory?: string): Promise { const instance = instances().get(instanceId) if (!instance?.client) return null @@ -222,36 +173,19 @@ async function fetchSessionInfo(instanceId: string, sessionId: string, directory "session.get", ) - let fetchedStatus: SessionStatus = "idle" - let fetchedRetry: SessionRetryState | null = null + let rawStatus = (info as any)?.status let fetchedStatusKnown = false try { - let statuses: Record = {} - try { - statuses = await requestData>(client.session.status(), "session.status") - } catch { - statuses = await requestData>(client.session.status(), "session.status") - } - // Session status is global-ish; prefer the root context when available. - // (OpenCode may scope status by directory in older builds.) - // If root fails, fall back to the worktree-scoped client. - // - // Note: requestData throws on error, so we catch below. - const rawStatus = (info as any)?.status ?? statuses?.[sessionId] - const hasType = rawStatus && typeof rawStatus === "object" && typeof rawStatus.type === "string" - fetchedStatus = hasType ? mapSdkSessionStatus(rawStatus) : "idle" - fetchedRetry = hasType ? mapSdkSessionRetry(rawStatus) : null + const statuses = await requestData>(client.session.status(), "session.status") + rawStatus ??= statuses?.[sessionId] fetchedStatusKnown = true } catch (error) { log.error("Failed to fetch session status", error) - const rawStatus = (info as any)?.status - const hasType = rawStatus && typeof rawStatus === "object" && typeof rawStatus.type === "string" - if (hasType) { - fetchedStatus = mapSdkSessionStatus(rawStatus) - fetchedRetry = mapSdkSessionRetry(rawStatus) - fetchedStatusKnown = true - } } + const hasStatus = rawStatus && typeof rawStatus === "object" && typeof rawStatus.type === "string" + fetchedStatusKnown ||= Boolean(hasStatus) + const fetchedStatus: SessionStatus = hasStatus ? mapSdkSessionStatus(rawStatus) : "idle" + const fetchedRetry: SessionRetryState | null = hasStatus ? mapSdkSessionRetry(rawStatus) : null const fetched = createClientSession(info, instanceId, "", { providerId: "", modelId: "" }, fetchedStatus) fetched.retry = fetchedRetry @@ -264,32 +198,25 @@ async function fetchSessionInfo(instanceId: string, sessionId: string, directory const next = new Map(prev) const instanceSessions = next.get(instanceId) ?? new Map() const existing = instanceSessions.get(sessionId) - const admissionPending = existing?.generationAdmissionToken !== undefined - const status = admissionPending || existing?.status === "compacting" ? existing!.status : fetched.status - const runtimeStatusKnown = admissionPending - ? existing?.runtimeStatusKnown ?? false - : Boolean(existing?.status === "compacting" || fetchedStatusKnown || existing?.runtimeStatusKnown) - const merged: Session = { + const compacting = existing?.status === "compacting" + const candidate: Session = { ...fetched, agent: existing?.agent ?? fetched.agent, model: existing?.model ?? fetched.model, - status, - retry: existing?.status === "compacting" ? null : fetched.retry, - idleSince: getIdleSinceForStatusTransition( - existing?.status, - existing?.status === "compacting" ? "compacting" : fetched.status, - existing?.idleSince, - ), + status: compacting ? "compacting" : fetched.status, + retry: compacting ? null : fetched.retry, + idleSince: getIdleSinceForStatusTransition(existing?.status, compacting ? "compacting" : fetched.status, existing?.idleSince), pendingPermission: existing?.pendingPermission ?? fetched.pendingPermission, pendingQuestion: existing?.pendingQuestion ?? false, - runtimeStatusKnown, - generationRecovery: admissionPending - ? existing?.generationRecovery ?? "pending" - : runtimeStatusKnown - ? resolveAuthoritativeGenerationRecovery(existing?.generationRecovery, status) - : existing?.generationRecovery ?? null, - generationAdmissionToken: admissionPending ? existing?.generationAdmissionToken : undefined, + runtimeStatusKnown: compacting || fetched.runtimeStatusKnown, } + const merged = mergeFetchedSessionRuntimeState( + candidate, + existing, + existing, + getAuthoritativelyDeletedSessionIdsForInstance(instanceId).has(sessionId), + ) + if (!merged) return prev instanceSessions.set(sessionId, merged) next.set(instanceId, instanceSessions) updatedInstanceSessions = instanceSessions @@ -318,22 +245,19 @@ function ensureSessionStatus( directory?: string, retry?: SessionRetryState | null, ) { - const instanceSessions = sessions().get(instanceId) - const existing = instanceSessions?.get(sessionId) + const existing = sessions().get(instanceId)?.get(sessionId) if (existing) { - applySessionStatus(instanceId, sessionId, status, retry) + setSessionStatus(instanceId, sessionId, status, { retry }) return } const key = `${instanceId}:${sessionId}` - if (pendingSessionFetches.has(key)) { - return - } + if (pendingSessionFetches.has(key)) return const pending = (async () => { const fetched = await fetchSessionInfo(instanceId, sessionId, directory) if (!fetched) return - applySessionStatus(instanceId, sessionId, status, retry) + setSessionStatus(instanceId, sessionId, status, { retry }) })() pendingSessionFetches.set(key, pending) @@ -517,6 +441,7 @@ function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): vo const info = event.properties?.info if (!info) return + if (getAuthoritativelyDeletedSessionIdsForInstance(instanceId).has(info.id)) return const instanceSessions = sessions().get(instanceId) ?? new Map() @@ -672,19 +597,8 @@ function handleSessionCompacted(instanceId: string, event: EventSessionCompacted log.info(`[SSE] Session compacted: ${sessionID}`) const existing = sessions().get(instanceId)?.get(sessionID) - if (existing) { - cancelSessionGenerationAdmissions(instanceId, sessionID) - withSession(instanceId, sessionID, (session) => { - session.status = "working" - session.runtimeStatusKnown = true - session.generationRecovery = null - session.generationAdmissionToken = undefined - session.retry = null - session.idleSince = null - }) - } else { - ensureSessionStatus(instanceId, sessionID, "working", (event as any)?.directory) - } + if (existing) setSessionStatus(instanceId, sessionID, "working", { force: true }) + else ensureSessionStatus(instanceId, sessionID, "working", (event as any)?.directory) loadMessages(instanceId, sessionID, { force: true }).catch((error) => log.error("Failed to reload session after compaction", error)) @@ -728,7 +642,7 @@ function handleMessageRemoved(instanceId: string, event: MessageRemovedEvent): v if (!sessionID || !messageID) return log.info(`[SSE] Message removed from session ${sessionID}`, { messageID }) - removeMessageV2(instanceId, messageID) + removeMessageV2(instanceId, messageID, sessionID) updateSessionInfo(instanceId, sessionID) } @@ -737,7 +651,7 @@ function handleMessagePartRemoved(instanceId: string, event: MessagePartRemovedE if (!sessionID || !messageID || !partID) return log.info(`[SSE] Message part removed from session ${sessionID}`, { messageID, partID }) - removeMessagePartV2(instanceId, messageID, partID) + removeMessagePartV2(instanceId, messageID, partID, sessionID) updateSessionInfo(instanceId, sessionID) } diff --git a/packages/ui/src/stores/session-generation-admission.test.ts b/packages/ui/src/stores/session-generation-admission.test.ts index 614d8f17..521ad740 100644 --- a/packages/ui/src/stores/session-generation-admission.test.ts +++ b/packages/ui/src/stores/session-generation-admission.test.ts @@ -1,90 +1,38 @@ import assert from "node:assert/strict" import { afterEach, describe, it } from "node:test" - import type { Session } from "../types/session.ts" -import { - beginSessionGenerationAdmission, - getSessions, - setSessions, - setSessionStatus, -} from "./session-state.ts" - +import { beginSessionGenerationAdmission, getSessions, setSessions, setSessionStatus } from "./session-state.ts" const instanceId = "generation-admission-instance" const sessionId = "generation-admission-session" - -function seedSession(state: Partial = {}): void { - const session = { - id: sessionId, - instanceId, - parentId: null, - title: "Session", - agent: "build", - model: { providerId: "provider", modelId: "model" }, - version: "1", - time: { created: 1, updated: 1 }, - status: "idle", - idleSince: 10, - runtimeStatusKnown: true, - generationRecovery: "interrupted", - ...state, - } as Session - setSessions(new Map([[instanceId, new Map([[sessionId, session]])]])) +const seedSession = () => setSessions(new Map([[instanceId, new Map([[sessionId, { + id: sessionId, instanceId, parentId: null, title: "Session", agent: "build", + model: { providerId: "provider", modelId: "model" }, version: "1", time: { created: 1, updated: 1 }, + status: "idle", idleSince: 10, runtimeStatusKnown: true, generationRecovery: "interrupted", +} as Session]])]])) +const result = () => { + const { status, runtimeStatusKnown, generationRecovery, generationAdmissionToken: token } = getSessions(instanceId)[0] + return { status, runtimeStatusKnown, generationRecovery, token } } - afterEach(() => setSessions(new Map())) - describe("session generation admission", () => { - it("does not overwrite a newer authoritative SSE status when admission completes", () => { - seedSession() - const admission = beginSessionGenerationAdmission(instanceId, sessionId) - - setSessionStatus(instanceId, sessionId, "working") - admission.complete() - - const session = getSessions(instanceId)[0] - assert.equal(session.status, "working") - assert.equal(session.runtimeStatusKnown, true) - assert.equal(session.generationRecovery, null) - assert.equal(session.generationAdmissionToken, undefined) - }) - - it("ignores idle authority until an in-flight admission is acknowledged", () => { - seedSession() - const admission = beginSessionGenerationAdmission(instanceId, sessionId) - - setSessionStatus(instanceId, sessionId, "idle") - const pending = getSessions(instanceId)[0] - assert.equal(pending.runtimeStatusKnown, false) - assert.equal(pending.generationRecovery, "pending") - assert.equal(typeof pending.generationAdmissionToken, "number") - - admission.complete() - assert.equal(getSessions(instanceId)[0].generationAdmissionToken, undefined) - assert.equal(getSessions(instanceId)[0].generationRecovery, "pending") - }) - - it("rolls back only when no authoritative event superseded the failed admission", () => { - seedSession() - const admission = beginSessionGenerationAdmission(instanceId, sessionId) - admission.rollback() - - const session = getSessions(instanceId)[0] - assert.equal(session.runtimeStatusKnown, true) - assert.equal(session.generationRecovery, "interrupted") - assert.equal(session.idleSince, 10) - }) - - it("retains pending recovery when one of two overlapping admissions succeeds", () => { - seedSession() - const first = beginSessionGenerationAdmission(instanceId, sessionId) - const second = beginSessionGenerationAdmission(instanceId, sessionId) - - first.complete() - second.rollback() - - const session = getSessions(instanceId)[0] - assert.equal(session.runtimeStatusKnown, false) - assert.equal(session.generationRecovery, "pending") - assert.equal(session.generationAdmissionToken, undefined) - }) + const admit = () => beginSessionGenerationAdmission(instanceId, sessionId) + const cases = [ + ["newer authoritative working status supersedes completion", () => { const admission = admit(); setSessionStatus(instanceId, sessionId, "working"); admission.complete() }, { status: "working", runtimeStatusKnown: true, generationRecovery: null, token: undefined }], + ["idle authority waits for admission acknowledgement", () => { + const admission = admit(); setSessionStatus(instanceId, sessionId, "idle") + assert.deepEqual(result(), { status: "idle", runtimeStatusKnown: false, generationRecovery: "pending", token: result().token }) + assert.equal(typeof result().token, "number") + admission.complete() + }, { status: "idle", runtimeStatusKnown: false, generationRecovery: "pending", token: undefined }], + ["rollback restores interrupted state without newer authority", () => admit().rollback(), { status: "idle", runtimeStatusKnown: true, generationRecovery: "interrupted", token: undefined }], + ["one successful overlapping admission retains pending recovery", () => { const first = admit(); const second = admit(); first.complete(); second.rollback() }, { status: "idle", runtimeStatusKnown: false, generationRecovery: "pending", token: undefined }], + ] as const + for (const [label, run, expected] of cases) { + it(label, () => { + seedSession() + run() + assert.deepEqual(result(), expected) + if (expected.generationRecovery === "interrupted") assert.equal(getSessions(instanceId)[0].idleSince, 10) + }) + } }) diff --git a/packages/ui/src/stores/session-generation-recovery.test.ts b/packages/ui/src/stores/session-generation-recovery.test.ts index 50a72312..ec5e3988 100644 --- a/packages/ui/src/stores/session-generation-recovery.test.ts +++ b/packages/ui/src/stores/session-generation-recovery.test.ts @@ -1,167 +1,77 @@ import assert from "node:assert/strict" import { describe, it } from "node:test" - -import { - getPersistedGenerationRecovery, - mergeFetchedSessionRuntimeState, - resolveAuthoritativeGenerationRecovery, - resolveHydratedGenerationRecovery, -} from "./session-generation-recovery.ts" import type { Session } from "../types/session.ts" - -function session(state: Partial): Session { - return { - id: "session", - instanceId: "instance", - parentId: null, - title: "Session", - agent: "build", - model: { providerId: "provider", modelId: "model" }, - version: "1", - time: { created: 1, updated: 1 }, - status: "idle", - ...state, - } as Session -} - +import { getPersistedGenerationRecovery, mergeFetchedSessionRuntimeState, resolveAuthoritativeGenerationRecovery, resolveHydratedGenerationRecovery } from "./session-generation-recovery.ts" +const session = (state: Partial = {}): Session => ({ + id: "session", instanceId: "instance", parentId: null, title: "Session", agent: "build", + model: { providerId: "provider", modelId: "model" }, version: "1", + time: { created: 1, updated: 1 }, status: "idle", ...state, +} as Session) +const runtime = (value: Session) => ({ + title: value.title, status: value.status, runtimeStatusKnown: value.runtimeStatusKnown, + generationRecovery: value.generationRecovery, token: value.generationAdmissionToken, source: value.metadata?.source, updated: value.time.updated, +}) describe("session generation recovery", () => { - it("passively reconnects when the runtime is still working", () => { - assert.equal(resolveHydratedGenerationRecovery("working", "working", true), null) - assert.equal(resolveHydratedGenerationRecovery("working", "compacting", true), null) + it("resolves hydrated, authoritative, and persisted recovery states", () => { + const cases: Array<[string, () => unknown, unknown]> = [ + ["working reconnect", () => resolveHydratedGenerationRecovery("working", "working", true), null], + ["compacting reconnect", () => resolveHydratedGenerationRecovery("working", "compacting", true), null], + ["idle before authority", () => resolveHydratedGenerationRecovery("working", "idle", false), "pending"], + ["authoritative idle hydration", () => resolveHydratedGenerationRecovery("working", "idle", true), "interrupted"], + ["authoritative idle event", () => resolveAuthoritativeGenerationRecovery("pending", "idle"), "interrupted"], + ["interruption survives hydration", () => resolveHydratedGenerationRecovery("interrupted", "idle", false), "interrupted"], + ["interruption persists", () => getPersistedGenerationRecovery("idle", "interrupted"), "interrupted"], + ["working clears pending", () => resolveAuthoritativeGenerationRecovery("pending", "working"), null], + ["working clears interruption", () => resolveAuthoritativeGenerationRecovery("interrupted", "working"), null], + ["working persists", () => getPersistedGenerationRecovery("working", null), "working"], + ["compacting persists", () => getPersistedGenerationRecovery("compacting", null), "working"], + ["pending persists as work", () => getPersistedGenerationRecovery("idle", "pending"), "working"], + ["ordinary idle omitted", () => getPersistedGenerationRecovery("idle", null), null], + ] + for (const [label, actual, expected] of cases) assert.equal(actual(), expected, label) }) - - it("marks prior work interrupted only after authoritative idle", () => { - assert.equal(resolveHydratedGenerationRecovery("working", "idle", false), "pending") - assert.equal(resolveHydratedGenerationRecovery("working", "idle", true), "interrupted") - assert.equal(resolveAuthoritativeGenerationRecovery("pending", "idle"), "interrupted") - }) - - it("keeps an interruption across restarts until new work is admitted", () => { - assert.equal(resolveHydratedGenerationRecovery("interrupted", "idle", false), "interrupted") - assert.equal(getPersistedGenerationRecovery("idle", "interrupted"), "interrupted") - }) - - it("clears recovery when authoritative work resumes", () => { - assert.equal(resolveAuthoritativeGenerationRecovery("pending", "working"), null) - assert.equal(resolveAuthoritativeGenerationRecovery("interrupted", "working"), null) - }) - - it("persists active and unresolved work without persisting ordinary idle sessions", () => { - assert.equal(getPersistedGenerationRecovery("working", null), "working") - assert.equal(getPersistedGenerationRecovery("compacting", null), "working") - assert.equal(getPersistedGenerationRecovery("idle", "pending"), "working") - assert.equal(getPersistedGenerationRecovery("idle", null), null) - }) - - it("preserves a newer SSE state over a stale session fetch", () => { - const captured = session({ title: "Captured", status: "idle", runtimeStatusKnown: false }) - const fetched = session({ - title: "Stale fetch", - metadata: { source: "fetch" }, - time: { created: 1, updated: 2 }, - status: "idle", - runtimeStatusKnown: true, - generationRecovery: "interrupted", + const mergeCases = [ + ["newer SSE state supersedes a stale fetch", { + captured: session({ title: "Captured", runtimeStatusKnown: false }), + fetched: session({ title: "Stale fetch", metadata: { source: "fetch" }, time: { created: 1, updated: 2 }, runtimeStatusKnown: true, generationRecovery: "interrupted" }), + latest: session({ title: "New SSE title", metadata: { source: "sse" }, time: { created: 1, updated: 3 }, status: "working", runtimeStatusKnown: true, generationRecovery: null }), + expected: { title: "New SSE title", status: "working", runtimeStatusKnown: true, generationRecovery: null, token: undefined, source: "sse", updated: 3 }, + }], + ["in-flight admission survives a fetch snapshot", { + captured: session({ runtimeStatusKnown: false, generationRecovery: "pending", generationAdmissionToken: 1 }), + fetched: session({ runtimeStatusKnown: true, generationRecovery: "interrupted" }), + latest: null, + expected: { title: "Session", status: "idle", runtimeStatusKnown: false, generationRecovery: "pending", token: 1, source: undefined, updated: 1 }, + }], + ["active fetch wins after a captured admission completes", { + captured: session({ title: "Captured", runtimeStatusKnown: true, generationRecovery: "interrupted" }), + fetched: session({ title: "Fetched", status: "working", runtimeStatusKnown: true, generationRecovery: null }), + latest: session({ title: "New SSE title", metadata: { source: "sse" }, time: { created: 1, updated: 3 }, runtimeStatusKnown: false, generationRecovery: "pending" }), + expected: { title: "New SSE title", status: "working", runtimeStatusKnown: true, generationRecovery: null, token: undefined, source: "sse", updated: 3 }, + }], + ["active authority clears a captured admission token", { + captured: session({ runtimeStatusKnown: false, generationRecovery: "pending", generationAdmissionToken: 1 }), + fetched: session({ status: "working", runtimeStatusKnown: true, generationRecovery: null }), + latest: session({ runtimeStatusKnown: false, generationRecovery: "pending", generationAdmissionToken: undefined }), + expected: { title: "Session", status: "working", runtimeStatusKnown: true, generationRecovery: null, token: undefined, source: undefined, updated: 1 }, + }], + ["newer local state preserves optional field deletion", { + captured: session({ retry: { attempt: 1, message: "retrying", next: 10 } }), + fetched: session({ retry: { attempt: 2, message: "stale", next: 20 } }), + latest: session(), + expected: { title: "Session", status: "idle", runtimeStatusKnown: undefined, generationRecovery: undefined, token: undefined, source: undefined, updated: 1 }, + }], + ] as const + for (const [label, test] of mergeCases) { + it(label, () => { + const merged = mergeFetchedSessionRuntimeState(test.fetched, test.captured, test.latest ?? test.captured) + assert.ok(merged) + assert.deepEqual(runtime(merged), test.expected) }) - const latest = session({ - title: "New SSE title", - metadata: { source: "sse" }, - time: { created: 1, updated: 3 }, - status: "working", - runtimeStatusKnown: true, - generationRecovery: null, - }) - - const merged = mergeFetchedSessionRuntimeState(fetched, captured, latest) - assert.ok(merged) - assert.equal(merged.status, "working") - assert.equal(merged.generationRecovery, null) - assert.equal(merged.title, "New SSE title") - assert.deepEqual(merged.metadata, { source: "sse" }) - assert.equal(merged.time.updated, 3) - }) - - it("preserves an in-flight admission even when it predates the fetch snapshot", () => { - const admission = session({ - status: "idle", - runtimeStatusKnown: false, - generationRecovery: "pending", - generationAdmissionToken: 1, - }) - const fetched = session({ status: "idle", runtimeStatusKnown: true, generationRecovery: "interrupted" }) - - const merged = mergeFetchedSessionRuntimeState(fetched, admission, admission) - assert.ok(merged) - assert.equal(merged.runtimeStatusKnown, false) - assert.equal(merged.generationRecovery, "pending") - assert.equal(merged.generationAdmissionToken, 1) - }) - - it("keeps authoritative active status after a captured admission completes", () => { - const captured = session({ - title: "Captured", - status: "idle", - runtimeStatusKnown: true, - generationRecovery: "interrupted", - generationAdmissionToken: undefined, - }) - const latest = session({ - title: "New SSE title", - metadata: { source: "sse" }, - time: { created: 1, updated: 3 }, - status: "idle", - runtimeStatusKnown: false, - generationRecovery: "pending", - generationAdmissionToken: undefined, - }) - const fetched = session({ - title: "Fetched", - status: "working", - runtimeStatusKnown: true, - generationRecovery: null, - }) - - const merged = mergeFetchedSessionRuntimeState(fetched, captured, latest) - assert.ok(merged) - assert.equal(merged.status, "working") - assert.equal(merged.runtimeStatusKnown, true) - assert.equal(merged.generationRecovery, null) - assert.equal(merged.generationAdmissionToken, undefined) - assert.equal(merged.title, "New SSE title") - assert.deepEqual(merged.metadata, { source: "sse" }) - assert.equal(merged.time.updated, 3) - }) - - it("clears a captured admission token when authoritative work is active", () => { - const captured = session({ - status: "idle", - runtimeStatusKnown: false, - generationRecovery: "pending", - generationAdmissionToken: 1, - }) - const latest = session({ - status: "idle", - runtimeStatusKnown: false, - generationRecovery: "pending", - generationAdmissionToken: undefined, - }) - const fetched = session({ - status: "working", - runtimeStatusKnown: true, - generationRecovery: null, - }) - - const merged = mergeFetchedSessionRuntimeState(fetched, captured, latest) - assert.equal(merged?.status, "working") - assert.equal(merged?.runtimeStatusKnown, true) - assert.equal(merged?.generationRecovery, null) - assert.equal(merged?.generationAdmissionToken, undefined) - }) - + } it("does not resurrect a session deleted while its fetch was pending", () => { - const captured = session({ status: "idle" }) - const fetched = session({ status: "idle" }) - assert.equal(mergeFetchedSessionRuntimeState(fetched, captured, undefined), null) + const fetched = session() + assert.equal(mergeFetchedSessionRuntimeState(fetched, session(), undefined), null) assert.equal(mergeFetchedSessionRuntimeState(fetched, undefined, undefined, true), null) }) }) diff --git a/packages/ui/src/stores/session-generation-recovery.ts b/packages/ui/src/stores/session-generation-recovery.ts index de88d087..e0a767eb 100644 --- a/packages/ui/src/stores/session-generation-recovery.ts +++ b/packages/ui/src/stores/session-generation-recovery.ts @@ -37,31 +37,27 @@ export function mergeFetchedSessionRuntimeState( ): Session | null { if (deleted) return null if (captured && !latest) return null - if (!latest || (latest === captured && latest.generationAdmissionToken === undefined)) return fetched - if (captured && latest !== captured) { - const merged = { ...fetched } as Record - const capturedRecord = captured as unknown as Record - const latestRecord = latest as unknown as Record - for (const key of new Set([...Object.keys(capturedRecord), ...Object.keys(latestRecord)])) { - if (!Object.is(capturedRecord[key], latestRecord[key])) merged[key] = latestRecord[key] - } - if ( - (fetched.status === "working" || fetched.status === "compacting") - && latest.generationAdmissionToken === undefined - && latest.runtimeStatusKnown === false - && latest.generationRecovery === "pending" - ) { - merged.status = fetched.status - merged.runtimeStatusKnown = fetched.runtimeStatusKnown - merged.generationRecovery = fetched.generationRecovery - merged.generationAdmissionToken = undefined - merged.retry = fetched.retry - merged.idleSince = fetched.idleSince - } - return merged as unknown as Session + if (!latest) return fetched + if (latest === captured) { + return latest.generationAdmissionToken === undefined ? fetched : { ...fetched, ...latest } } - return { - ...fetched, - ...latest, + const merged = { ...fetched } + const keys = new Set([ + ...(Object.keys(captured ?? {}) as (keyof Session)[]), + ...(Object.keys(latest) as (keyof Session)[]), + ]) + for (const key of keys) { + if (captured && Object.is(captured[key], latest[key])) continue + if (Object.prototype.hasOwnProperty.call(latest, key)) (merged as any)[key] = latest[key] + else delete (merged as any)[key] } + + const fetchedActive = fetched.status === "working" || fetched.status === "compacting" + if (captured && fetchedActive && latest.generationAdmissionToken === undefined + && latest.runtimeStatusKnown === false && latest.generationRecovery === "pending") { + for (const key of ["status", "runtimeStatusKnown", "generationRecovery", "generationAdmissionToken", "retry", "idleSince"] as const) { + (merged as any)[key] = fetched[key] + } + } + return merged } diff --git a/packages/ui/src/stores/session-list-options.ts b/packages/ui/src/stores/session-list-options.ts index ac45ac05..fccbee6a 100644 --- a/packages/ui/src/stores/session-list-options.ts +++ b/packages/ui/src/stores/session-list-options.ts @@ -34,6 +34,10 @@ export function buildProjectSessionListOptions(options: ProjectSessionListInput) } } +export function isProjectSessionListComplete(resultCount: number): boolean { + return resultCount < PROJECT_SESSION_LIST_LIMIT +} + export function filterProjectScopedSessions( sessions: T[], allowedDirectories: Array, @@ -46,3 +50,13 @@ export function filterProjectScopedSessions( return !directory || allowed.has(directory) }) } + +export function getAuthoritativelyMissingSessionIds( + existingIds: Iterable, + listedIds: Iterable, + complete: boolean, +): string[] { + if (!complete) return [] + const listed = new Set(listedIds) + return Array.from(existingIds).filter((id) => !listed.has(id)) +} diff --git a/packages/ui/src/stores/session-metadata-completeness.ts b/packages/ui/src/stores/session-metadata-completeness.ts new file mode 100644 index 00000000..922c52e0 --- /dev/null +++ b/packages/ui/src/stores/session-metadata-completeness.ts @@ -0,0 +1,7 @@ +export const shouldReplaceSessionMetadata = (current: Record | undefined): boolean => + current === undefined || Object.keys(current).length === 0 + +export const preferSessionMetadata = ( + incoming: Record | undefined, + existing: Record | undefined, +): Record | undefined => shouldReplaceSessionMetadata(incoming) ? existing ?? incoming : incoming diff --git a/packages/ui/src/stores/session-metadata.test.ts b/packages/ui/src/stores/session-metadata.test.ts new file mode 100644 index 00000000..8843703a --- /dev/null +++ b/packages/ui/src/stores/session-metadata.test.ts @@ -0,0 +1,18 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" + +import { preferSessionMetadata, shouldReplaceSessionMetadata } from "./session-metadata-completeness.ts" + +describe("session metadata hydration", () => { + it("treats missing and empty list metadata as incomplete", () => { + assert.equal(shouldReplaceSessionMetadata(undefined), true) + assert.equal(shouldReplaceSessionMetadata({}), true) + }) + + it("preserves metadata and tags populated while detailed hydration is pending", () => { + assert.equal(shouldReplaceSessionMetadata({ tags: ["live"] }), false) + assert.equal(shouldReplaceSessionMetadata({ owner: "opencode" }), false) + assert.deepEqual(preferSessionMetadata({}, { tags: ["hydrated"] }), { tags: ["hydrated"] }) + assert.deepEqual(preferSessionMetadata({ tags: ["fresh"] }, { tags: ["old"] }), { tags: ["fresh"] }) + }) +}) diff --git a/packages/ui/src/stores/session-metadata.ts b/packages/ui/src/stores/session-metadata.ts index 15ba37b6..07f5a21d 100644 --- a/packages/ui/src/stores/session-metadata.ts +++ b/packages/ui/src/stores/session-metadata.ts @@ -1,6 +1,7 @@ import type { OpencodeClient } from "@opencode-ai/sdk/v2/client" import { requestData } from "../lib/opencode-api" import { sessions, withSession } from "./session-state" +import { shouldReplaceSessionMetadata } from "./session-metadata-completeness" const CODENOMAD_METADATA_KEY = "codenomad" const CODENOMAD_METADATA_VERSION = 1 @@ -86,11 +87,14 @@ export async function hydrateSessionMetadataWithClient( client: OpencodeClient, instanceId: string, sessionId: string, + query?: { workspace?: string }, ): Promise { - const latest = await requestData(client.session.get({ sessionID: sessionId }), "session.get") + const expectedMetadata = sessions().get(instanceId)?.get(sessionId)?.metadata + const latest = await requestData(client.session.get({ sessionID: sessionId, ...query }), "session.get") const metadata = normalizeMetadata(latest?.metadata) withSession(instanceId, sessionId, (session) => { + if (session.metadata !== expectedMetadata || !shouldReplaceSessionMetadata(session.metadata)) return false session.metadata = metadata }) diff --git a/packages/ui/src/stores/session-pagination.test.ts b/packages/ui/src/stores/session-pagination.test.ts index 50369a57..7ce69377 100644 --- a/packages/ui/src/stores/session-pagination.test.ts +++ b/packages/ui/src/stores/session-pagination.test.ts @@ -2,7 +2,13 @@ import assert from "node:assert/strict" import { describe, it } from "node:test" import { applySessionPage, getDefaultSessionPaginationState } from "./session-pagination-model.ts" -import { PROJECT_SESSION_LIST_LIMIT, buildProjectSessionListOptions, filterProjectScopedSessions } from "./session-list-options.ts" +import { + PROJECT_SESSION_LIST_LIMIT, + buildProjectSessionListOptions, + filterProjectScopedSessions, + getAuthoritativelyMissingSessionIds, + isProjectSessionListComplete, +} from "./session-list-options.ts" describe("project session list loading", () => { it("builds a one-shot project-scoped request without pagination params", () => { @@ -63,4 +69,18 @@ describe("project session list loading", () => { assert.equal(next.hasMore, false) assert.equal(next.nextCursor, undefined) }) + + it("reconciles sessions deleted while disconnected only from a complete refresh", () => { + const existing = ["retained", "outside-current-worktree", "deleted-remotely"] + const listed = ["retained", "outside-current-worktree"] + + assert.deepEqual(getAuthoritativelyMissingSessionIds(existing, listed, true), ["deleted-remotely"]) + assert.equal(isProjectSessionListComplete(PROJECT_SESSION_LIST_LIMIT - 1), true) + assert.equal(isProjectSessionListComplete(PROJECT_SESSION_LIST_LIMIT), false) + assert.deepEqual( + getAuthoritativelyMissingSessionIds(existing, listed, false), + [], + "a result capped at the request limit may be truncated", + ) + }) }) diff --git a/packages/ui/src/stores/session-request-authority.test.ts b/packages/ui/src/stores/session-request-authority.test.ts new file mode 100644 index 00000000..99bfeb78 --- /dev/null +++ b/packages/ui/src/stores/session-request-authority.test.ts @@ -0,0 +1,179 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" + +import { sdkManager } from "../lib/sdk-manager.ts" +import type { Session } from "../types/session.ts" +import { addInstance, removeInstance } from "./instances.ts" +import { messageStoreBus } from "./message-v2/bus.ts" +import { loadMessages, removeSessionRuntimeState, searchSessions } from "./session-api.ts" +import { + clearInstanceDeletedSessionAuthority, + getSessionSearchResultIds, + invalidateSessionMessageLoad, + loading, + messagesLoaded, + sessions, + setSessions, +} from "./session-state.ts" + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((done) => { resolve = done }) + return { promise, resolve } +} + +function session(instanceId: string, id: string, parentId: string | null = null): Session { + return { + id, instanceId, parentId, title: id, agent: "build", model: { providerId: "provider", modelId: "model" }, + status: "idle", retry: null, idleSince: null, generationRecovery: null, runtimeStatusKnown: true, + version: "1", time: { created: 1, updated: 1 }, + } +} + +function apiSession(id: string, parentID?: string) { + return { id, parentID, title: id, version: "1", time: { created: 1, updated: 1 } } +} + +function apiMessage(id: string, sessionId: string) { + return { + info: { + id, sessionID: sessionId, role: "assistant", agent: "build", providerID: "provider", modelID: "model", + time: { created: 1 }, + }, + parts: [], + } +} + +function setup(instanceId: string) { + const client = { session: {} } as any + ;(sdkManager as any).clients.set(`${instanceId}:/workspaces/${instanceId}/instance`, client) + addInstance({ id: instanceId, folder: "/work", port: 0, pid: 0, proxyPath: "", status: "ready", client }) + return { + client, + cleanup() { + messageStoreBus.unregisterInstance(instanceId) + setSessions((prev) => { const next = new Map(prev); next.delete(instanceId); return next }) + clearInstanceDeletedSessionAuthority(instanceId) + removeInstance(instanceId, { authoritative: false }) + sdkManager.destroyClientsForInstance(instanceId) + }, + } +} + +describe("session request authority", () => { + it("does not restore deleted search results or their parent chain", async () => { + const instanceId = "late-search-delete" + const { client, cleanup } = setup(instanceId) + const search = deferred() + const parents = deferred() + let calls = 0 + ;(client.session as any).list = () => (++calls === 1 ? search.promise : parents.promise) + + try { + const request = searchSessions(instanceId, "child") + search.resolve({ data: [apiSession("child", "parent")] }) + await new Promise((resolve) => setImmediate(resolve)) + removeSessionRuntimeState(instanceId, "child") + removeSessionRuntimeState(instanceId, "parent") + parents.resolve({ data: [apiSession("parent")] }) + await request + + assert.equal(sessions().get(instanceId)?.has("child") ?? false, false) + assert.equal(sessions().get(instanceId)?.has("parent") ?? false, false) + assert.deepEqual(getSessionSearchResultIds(instanceId), []) + } finally { + cleanup() + } + }) + + it("does not hydrate messages after definitive deletion", async () => { + const instanceId = "late-message-delete", sessionId = "session" + const { client, cleanup } = setup(instanceId) + const response = deferred() + ;(client.session as any).messages = () => response.promise + setSessions((prev) => new Map(prev).set(instanceId, new Map([[sessionId, session(instanceId, sessionId)]]))) + + try { + const request = loadMessages(instanceId, sessionId) + removeSessionRuntimeState(instanceId, sessionId) + response.resolve({ data: [apiMessage("deleted-message", sessionId)] }) + await request + + assert.deepEqual(messageStoreBus.getOrCreate(instanceId).getSessionMessageIds(sessionId), []) + assert.equal(messagesLoaded().get(instanceId)?.has(sessionId) ?? false, false) + } finally { + cleanup() + } + }) + + it("does not hydrate messages after cache eviction", async () => { + const instanceId = "late-message-eviction", sessionId = "session" + const { client, cleanup } = setup(instanceId) + const response = deferred() + ;(client.session as any).messages = () => response.promise + setSessions((prev) => new Map(prev).set(instanceId, new Map([[sessionId, session(instanceId, sessionId)]]))) + + try { + const request = loadMessages(instanceId, sessionId) + invalidateSessionMessageLoad(instanceId, sessionId) + response.resolve({ data: [apiMessage("evicted-message", sessionId)] }) + await request + + assert.deepEqual(messageStoreBus.getOrCreate(instanceId).getSessionMessageIds(sessionId), []) + assert.equal(messagesLoaded().get(instanceId)?.has(sessionId) ?? false, false) + } finally { + cleanup() + } + }) + + it("does not reuse message load authority after an instance reopens", async () => { + const instanceId = "reopened-message-load", sessionId = "session" + const { client, cleanup } = setup(instanceId) + const oldResponse = deferred() + const newResponse = deferred() + let calls = 0 + ;(client.session as any).messages = () => (++calls === 1 ? oldResponse.promise : newResponse.promise) + setSessions((prev) => new Map(prev).set(instanceId, new Map([[sessionId, session(instanceId, sessionId)]]))) + + try { + const oldRequest = loadMessages(instanceId, sessionId) + removeInstance(instanceId, { authoritative: false }) + addInstance({ id: instanceId, folder: "/work", port: 0, pid: 0, proxyPath: "", status: "ready", client }) + setSessions((prev) => new Map(prev).set(instanceId, new Map([[sessionId, session(instanceId, sessionId)]]))) + const newRequest = loadMessages(instanceId, sessionId, { force: true }) + + oldResponse.resolve({ data: [apiMessage("old-message", sessionId)] }) + await oldRequest + newResponse.resolve({ data: [apiMessage("new-message", sessionId)] }) + await newRequest + + assert.deepEqual(messageStoreBus.getOrCreate(instanceId).getSessionMessageIds(sessionId), ["new-message"]) + } finally { + cleanup() + } + }) + + it("keeps a newer load authoritative when an older request finishes last", async () => { + const instanceId = "newer-message-load", sessionId = "session" + const { client, cleanup } = setup(instanceId) + const oldResponse = deferred() + const newResponse = deferred() + let calls = 0 + ;(client.session as any).messages = () => (++calls === 1 ? oldResponse.promise : newResponse.promise) + setSessions((prev) => new Map(prev).set(instanceId, new Map([[sessionId, session(instanceId, sessionId)]]))) + + try { + const oldRequest = loadMessages(instanceId, sessionId) + const newRequest = loadMessages(instanceId, sessionId, { force: true }) + newResponse.resolve({ data: [apiMessage("new-message", sessionId)] }) + await newRequest + oldResponse.resolve({ data: [apiMessage("old-message", sessionId)] }) + await oldRequest + + assert.deepEqual(messageStoreBus.getOrCreate(instanceId).getSessionMessageIds(sessionId), ["new-message"]) + assert.equal(loading().loadingMessages.get(instanceId)?.has(sessionId) ?? false, false) + } finally { + cleanup() + } + }) +}) diff --git a/packages/ui/src/stores/session-state.ts b/packages/ui/src/stores/session-state.ts index cdc2e78e..d8308595 100644 --- a/packages/ui/src/stores/session-state.ts +++ b/packages/ui/src/stores/session-state.ts @@ -1,6 +1,6 @@ import { batch, createSignal } from "solid-js" -import { getIdleSinceForStatusTransition, type Session, type SessionStatus, type Agent, type Provider } from "../types/session" +import { getIdleSinceForStatusTransition, type Session, type SessionRetryState, type SessionStatus, type Agent, type Provider } from "../types/session" import { deleteSession, loadMessages } from "./session-api" import { showToastNotification } from "../lib/notifications" import { messageStoreBus } from "./message-v2/bus" @@ -32,21 +32,16 @@ export type { SessionThread } from "./session-tree" const log = getLogger("session") let generationAdmissionSequence = 0 -type GenerationAdmissionBaseline = Pick -interface GenerationAdmissionGroup { - id: number - tokens: Set +interface GenerationAdmission { + token: number + pending: number accepted: boolean - baseline: GenerationAdmissionBaseline -} -const generationAdmissionGroups = new Map() - -function generationAdmissionKey(instanceId: string, sessionId: string): string { - return `${instanceId}:${sessionId}` + baseline: Pick } +const generationAdmissions = new Map() function cancelSessionGenerationAdmissions(instanceId: string, sessionId: string): void { - generationAdmissionGroups.delete(generationAdmissionKey(instanceId, sessionId)) + generationAdmissions.delete(`${instanceId}:${sessionId}`) } export interface SessionInfo { @@ -83,6 +78,8 @@ const [loading, setLoading] = createSignal({ const [messagesLoaded, setMessagesLoaded] = createSignal>>(new Map()) const [messageLoadErrors, setMessageLoadErrors] = createSignal>>(new Map()) const [sessionListErrors, setSessionListErrors] = createSignal>(new Map()) +const messageLoadEpochs = new Map() +let nextMessageLoadEpoch = 0 const [sessionInfoByInstance, setSessionInfoByInstance] = createSignal>>(new Map()) const [threadTotalsByInstance, setThreadTotalsByInstance] = createSignal>>(new Map()) @@ -111,6 +108,13 @@ type SessionSearchState = { const [sessionPagination, setSessionPagination] = createSignal>(new Map()) const [sessionSearch, setSessionSearch] = createSignal>(new Map()) +function updateSessionPagination( + instanceId: string, + update: (current: SessionPaginationState | undefined) => SessionPaginationState, +): void { + setSessionPagination((prev) => new Map(prev).set(instanceId, update(prev.get(instanceId)))) +} + function getSessionPaginationState(instanceId: string): SessionPaginationState { return sessionPagination().get(instanceId) ?? getDefaultSessionPaginationState() } @@ -124,11 +128,7 @@ function getSessionNextCursor(instanceId: string): string | undefined { } function setSessionPage(instanceId: string, ids: string[], hasMore: boolean, reset = false, nextCursor?: string): void { - setSessionPagination((prev) => { - const next = new Map(prev) - next.set(instanceId, applySessionPage(prev.get(instanceId), ids, hasMore, reset, nextCursor)) - return next - }) + updateSessionPagination(instanceId, (current) => applySessionPage(current, ids, hasMore, reset, nextCursor)) } function getSessionHasMore(instanceId: string): boolean { @@ -136,30 +136,20 @@ function getSessionHasMore(instanceId: string): boolean { } function resetSessionPagination(instanceId: string): void { - setSessionPagination((prev) => { - const next = new Map(prev) - next.set(instanceId, getDefaultSessionPaginationState()) - return next - }) + updateSessionPagination(instanceId, getDefaultSessionPaginationState) } function prependSessionListId(instanceId: string, sessionId: string): void { - setSessionPagination((prev) => { - const next = new Map(prev) - const current = prev.get(instanceId) ?? { ids: [], hasMore: true } - const ids = [sessionId, ...current.ids.filter((id) => id !== sessionId)] - next.set(instanceId, { ...current, ids }) - return next + updateSessionPagination(instanceId, (value) => { + const current = value ?? { ids: [], hasMore: true } + return { ...current, ids: [sessionId, ...current.ids.filter((id) => id !== sessionId)] } }) } function removeSessionListId(instanceId: string, sessionId: string): void { - setSessionPagination((prev) => { - const next = new Map(prev) - const current = prev.get(instanceId) ?? { ids: [], hasMore: true } - const ids = current.ids.filter((id) => id !== sessionId) - next.set(instanceId, { ...current, ids }) - return next + updateSessionPagination(instanceId, (value) => { + const current = value ?? { ids: [], hasMore: true } + return { ...current, ids: current.ids.filter((id) => id !== sessionId) } }) } @@ -344,9 +334,37 @@ function clearLoadedFlag(instanceId: string, sessionId: string) { }) } -messageStoreBus.onSessionCleared((instanceId, sessionId) => { +function advanceMessageLoadEpoch(instanceId: string, sessionId: string): number { + const key = getDraftKey(instanceId, sessionId) + const epoch = ++nextMessageLoadEpoch + messageLoadEpochs.set(key, epoch) + return epoch +} + +function isCurrentMessageLoad(instanceId: string, sessionId: string, epoch: number): boolean { + return messageLoadEpochs.get(getDraftKey(instanceId, sessionId)) === epoch +} + +function clearMessageLoadingFlag(instanceId: string, sessionId: string): void { + setLoading((prev) => { + const existing = prev.loadingMessages.get(instanceId) + if (!existing?.has(sessionId)) return prev + const loadingMessages = new Map(prev.loadingMessages) + const updated = new Set(existing) + updated.delete(sessionId) + if (updated.size === 0) loadingMessages.delete(instanceId) + else loadingMessages.set(instanceId, updated) + return { ...prev, loadingMessages } + }) +} + +function invalidateSessionMessageLoad(instanceId: string, sessionId: string): void { + advanceMessageLoadEpoch(instanceId, sessionId) clearLoadedFlag(instanceId, sessionId) -}) + clearMessageLoadingFlag(instanceId, sessionId) +} + +messageStoreBus.onSessionCleared(invalidateSessionMessageLoad) function getDraftKey(instanceId: string, sessionId: string): string { return `${instanceId}:${sessionId}` @@ -371,9 +389,8 @@ function writeSessionDraftPrompt(instanceId: string, sessionId: string, value: s }) } -function markSessionDraftAuthoritative(instanceId: string, sessionId: string) { - const key = getDraftKey(instanceId, sessionId) - setAuthoritativeDraftKeys((prev) => { +function addAuthoritativeKey(setter: typeof setAuthoritativeDraftKeys, key: string): void { + setter((prev) => { if (prev.has(key)) return prev const next = new Set(prev) next.add(key) @@ -381,6 +398,10 @@ function markSessionDraftAuthoritative(instanceId: string, sessionId: string) { }) } +function markSessionDraftAuthoritative(instanceId: string, sessionId: string) { + addAuthoritativeKey(setAuthoritativeDraftKeys, getDraftKey(instanceId, sessionId)) +} + function setSessionDraftPrompt(instanceId: string, sessionId: string, value: string) { markSessionDraftAuthoritative(instanceId, sessionId) writeSessionDraftPrompt(instanceId, sessionId, value) @@ -407,39 +428,34 @@ function getSessionDraftPromptsForInstance(instanceId: string): Record { +function getAuthoritativeSessionIds(keys: ReadonlySet, instanceId: string): ReadonlySet { if (!instanceId) return new Set() const prefix = `${instanceId}:` return new Set( - [...authoritativeDraftKeys()] + [...keys] .filter((key) => key.startsWith(prefix)) .map((key) => key.slice(prefix.length)), ) } +function getAuthoritativeDraftSessionIdsForInstance(instanceId: string): ReadonlySet { + return getAuthoritativeSessionIds(authoritativeDraftKeys(), instanceId) +} + function getAuthoritativelyDeletedSessionIdsForInstance(instanceId: string): ReadonlySet { - if (!instanceId) return new Set() - const prefix = `${instanceId}:` - return new Set( - [...authoritativelyDeletedSessionKeys()] - .filter((key) => key.startsWith(prefix)) - .map((key) => key.slice(prefix.length)), - ) + return getAuthoritativeSessionIds(authoritativelyDeletedSessionKeys(), instanceId) } function markSessionDeletedAuthoritative(instanceId: string, sessionId: string): void { - const key = getDraftKey(instanceId, sessionId) - setAuthoritativelyDeletedSessionKeys((prev) => { - if (prev.has(key)) return prev - const next = new Set(prev) - next.add(key) - return next - }) + addAuthoritativeKey(setAuthoritativelyDeletedSessionKeys, getDraftKey(instanceId, sessionId)) } function clearInstanceDeletedSessionAuthority(instanceId: string): void { if (!instanceId) return const prefix = `${instanceId}:` + for (const key of messageLoadEpochs.keys()) { + if (key.startsWith(prefix)) messageLoadEpochs.delete(key) + } setAuthoritativelyDeletedSessionKeys((prev) => { const next = new Set([...prev].filter((key) => !key.startsWith(prefix))) return next.size === prev.size ? prev : next @@ -540,11 +556,16 @@ function withSession(instanceId: string, sessionId: string, updater: (session: S } } -function setSessionPendingPermission(instanceId: string, sessionId: string, pending: boolean): void { +function setSessionPending( + instanceId: string, + sessionId: string, + field: "pendingPermission" | "pendingQuestion", + pending: boolean, +): void { if (pending) cancelSessionGenerationAdmissions(instanceId, sessionId) withSession(instanceId, sessionId, (session) => { - if (session.pendingPermission === pending && (!pending || !session.generationRecovery)) return false - session.pendingPermission = pending + if (session[field] === pending && (!pending || !session.generationRecovery)) return false + session[field] = pending if (pending) { session.generationRecovery = null session.generationAdmissionToken = undefined @@ -552,16 +573,12 @@ function setSessionPendingPermission(instanceId: string, sessionId: string, pend }) } +function setSessionPendingPermission(instanceId: string, sessionId: string, pending: boolean): void { + setSessionPending(instanceId, sessionId, "pendingPermission", pending) +} + function setSessionPendingQuestion(instanceId: string, sessionId: string, pending: boolean): void { - if (pending) cancelSessionGenerationAdmissions(instanceId, sessionId) - withSession(instanceId, sessionId, (session) => { - if (session.pendingQuestion === pending && (!pending || !session.generationRecovery)) return false - session.pendingQuestion = pending - if (pending) { - session.generationRecovery = null - session.generationAdmissionToken = undefined - } - }) + setSessionPending(instanceId, sessionId, "pendingQuestion", pending) } function reconcileSessionPendingState( @@ -596,38 +613,13 @@ function markViewedSessionIdleSeen( sessionId: string, keepUnseenSubagentIdleStatus: boolean, ): void { - setSessions((prev) => { - const instanceSessions = prev.get(instanceId) - if (!instanceSessions) return prev - - const viewedSession = instanceSessions.get(sessionId) - if (!viewedSession) return prev - - const idsToClear = new Set([sessionId]) - if (viewedSession.parentId === null && !keepUnseenSubagentIdleStatus) { - for (const session of instanceSessions.values()) { - if (session.id === sessionId) continue - if (getSessionRootFromMap(instanceSessions, session.id)?.id === sessionId) idsToClear.add(session.id) - } - } - - let changed = false - const updatedSessions = new Map(instanceSessions) - for (const id of idsToClear) { - const session = updatedSessions.get(id) - if (!session) continue - if (session.status !== "idle") continue - if (typeof session.idleSince !== "number") continue - updatedSessions.set(id, { ...session, idleSince: null }) - changed = true - } - - if (!changed) return prev - - const next = new Map(prev) - next.set(instanceId, updatedSessions) - return next - }) + const instanceSessions = sessions().get(instanceId) + const viewedSession = instanceSessions?.get(sessionId) + if (!instanceSessions || !viewedSession) return + const ids = viewedSession.parentId === null && !keepUnseenSubagentIdleStatus + ? [sessionId, ...getDescendantSessionsFromMap(instanceSessions, sessionId).map((session) => session.id)] + : [sessionId] + batch(() => ids.forEach((id) => markSessionIdleSeen(instanceId, id))) } function markSessionSelectionAuthoritative(instanceId: string): void { @@ -640,72 +632,41 @@ function markSessionSelectionAuthoritative(instanceId: string): void { } function hydrateSessionIdleMarkers(instanceId: string, markers: Readonly>): void { - setSessions((prev) => { - const instanceSessions = prev.get(instanceId) - if (!instanceSessions) return prev - - let changed = false - const updatedSessions = new Map(instanceSessions) - for (const [sessionId, idleSince] of Object.entries(markers)) { - const session = updatedSessions.get(sessionId) - if (!session || session.status !== "idle" || typeof session.idleSince === "number") continue - updatedSessions.set(sessionId, { ...session, idleSince }) - changed = true - } - if (!changed) return prev - - const next = new Map(prev) - next.set(instanceId, updatedSessions) - return next - }) + for (const [sessionId, idleSince] of Object.entries(markers)) { + withSession(instanceId, sessionId, (session) => { + if (session.status !== "idle" || typeof session.idleSince === "number") return false + session.idleSince = idleSince + }) + } } function hydrateSessionGenerationRecovery( instanceId: string, markers: Readonly>, ): void { - setSessions((prev) => { - const instanceSessions = prev.get(instanceId) - if (!instanceSessions) return prev - - let changed = false - const updatedSessions = new Map(instanceSessions) - for (const [sessionId, persisted] of Object.entries(markers)) { - const session = updatedSessions.get(sessionId) - if (!session) continue - const generationRecovery = session.pendingPermission || session.pendingQuestion + for (const [sessionId, persisted] of Object.entries(markers)) { + withSession(instanceId, sessionId, (session) => { + const recovery = session.pendingPermission || session.pendingQuestion ? null - : resolveHydratedGenerationRecovery( - persisted, - session.status, - session.runtimeStatusKnown === true, - ) - if ((session.generationRecovery ?? null) === generationRecovery) continue - updatedSessions.set(sessionId, { ...session, generationRecovery }) - changed = true - } - if (!changed) return prev - - const next = new Map(prev) - next.set(instanceId, updatedSessions) - return next - }) + : resolveHydratedGenerationRecovery(persisted, session.status, session.runtimeStatusKnown === true) + if ((session.generationRecovery ?? null) === recovery) return false + session.generationRecovery = recovery + }) + } } function beginSessionGenerationAdmission(instanceId: string, sessionId: string): { complete: () => void rollback: () => void } { - generationAdmissionSequence += 1 - const token = generationAdmissionSequence - const key = generationAdmissionKey(instanceId, sessionId) - let group = generationAdmissionGroups.get(key) - if (!group) { + const key = `${instanceId}:${sessionId}` + let admission = generationAdmissions.get(key) + if (!admission) { const session = sessions().get(instanceId)?.get(sessionId) if (!session) return { complete: () => {}, rollback: () => {} } - group = { - id: token, - tokens: new Set(), + admission = { + token: ++generationAdmissionSequence, + pending: 0, accepted: false, baseline: { generationRecovery: session.generationRecovery, @@ -713,50 +674,52 @@ function beginSessionGenerationAdmission(instanceId: string, sessionId: string): idleSince: session.idleSince, }, } - generationAdmissionGroups.set(key, group) + generationAdmissions.set(key, admission) } - group.tokens.add(token) + admission.pending += 1 withSession(instanceId, sessionId, (session) => { session.generationRecovery = "pending" session.runtimeStatusKnown = false session.idleSince = null - session.generationAdmissionToken = group!.id + session.generationAdmissionToken = admission!.token }) + + let settled = false const settle = (accepted: boolean) => { - const activeGroup = generationAdmissionGroups.get(key) - if (!activeGroup || !activeGroup.tokens.delete(token)) return - activeGroup.accepted ||= accepted - if (activeGroup.tokens.size > 0) return - generationAdmissionGroups.delete(key) + if (settled || generationAdmissions.get(key) !== admission) return + settled = true + admission.accepted ||= accepted + if (--admission.pending > 0) return + generationAdmissions.delete(key) withSession(instanceId, sessionId, (session) => { - if (session.generationAdmissionToken !== activeGroup.id) return false + if (session.generationAdmissionToken !== admission.token) return false session.generationAdmissionToken = undefined - if (activeGroup.accepted) return - session.generationRecovery = activeGroup.baseline.generationRecovery - session.runtimeStatusKnown = activeGroup.baseline.runtimeStatusKnown - session.idleSince = activeGroup.baseline.idleSince + if (admission.accepted) return + Object.assign(session, admission.baseline) }) } - return { - complete: () => settle(true), - rollback: () => settle(false), - } + return { complete: () => settle(true), rollback: () => settle(false) } } function hasAuthoritativeSessionSelection(instanceId: string): boolean { return authoritativeSessionSelectionInstanceIds().has(instanceId) } -function writeActiveSession(instanceId: string, sessionId: string | null): void { - setActiveSessionId((prev) => { +function writeSessionSelection( + setter: typeof setActiveSessionId, + instanceId: string, + sessionId: string | null, +): void { + setter((prev) => { const next = new Map(prev) - if (sessionId) { - next.set(instanceId, sessionId) - } else { - next.delete(instanceId) - } + if (sessionId) next.set(instanceId, sessionId) + else next.delete(instanceId) return next }) +} + +function writeActiveSession(instanceId: string, sessionId: string | null): void { + writeSessionSelection(setActiveSessionId, instanceId, sessionId) if (sessionId) { // Backfill authoritative Yolo state for the now-active session so the badge // matches the server even on first connect / multi-client scenarios. @@ -765,15 +728,7 @@ function writeActiveSession(instanceId: string, sessionId: string | null): void } function writeActiveParentSession(instanceId: string, parentSessionId: string | null): void { - setActiveParentSessionId((prev) => { - const next = new Map(prev) - if (parentSessionId) { - next.set(instanceId, parentSessionId) - } else { - next.delete(instanceId) - } - return next - }) + writeSessionSelection(setActiveParentSessionId, instanceId, parentSessionId) } function setActiveSession(instanceId: string, sessionId: string): void { @@ -819,7 +774,12 @@ function clearInstanceSessionSelection(instanceId: string): void { }) } -function setSessionStatus(instanceId: string, sessionId: string, status: SessionStatus): void { +function setSessionStatus( + instanceId: string, + sessionId: string, + status: SessionStatus, + options: { retry?: SessionRetryState | null; force?: boolean } = {}, +): void { let expandAncestors = false withSession(instanceId, sessionId, (session) => { @@ -827,11 +787,17 @@ function setSessionStatus(instanceId: string, sessionId: string, status: Session const generationRecovery = admissionPending ? "pending" : resolveAuthoritativeGenerationRecovery(session.generationRecovery, status) + const retry = status === "working" ? options.retry ?? null : null + const sameRetry = session.retry?.attempt === retry?.attempt + && session.retry?.message === retry?.message + && session.retry?.next === retry?.next if ( session.status === status + && sameRetry && session.runtimeStatusKnown === !admissionPending && (session.generationRecovery ?? null) === generationRecovery ) return false + if (session.status === "compacting" && status !== "compacting" && !options.force) return false const previous = session.status session.status = status session.runtimeStatusKnown = !admissionPending @@ -841,9 +807,7 @@ function setSessionStatus(instanceId: string, sessionId: string, status: Session session.generationAdmissionToken = undefined } session.idleSince = getIdleSinceForStatusTransition(previous, status, session.idleSince) - if (status !== "working") { - session.retry = null - } + session.retry = retry if (session.parentId && status === "working" && previous !== "working") { expandAncestors = true @@ -1217,13 +1181,6 @@ async function cleanupBlankSessions(instanceId: string, excludeSessionId?: strin } } -// Backward compatibility aliases for renamed exports -const expandedSessionParents = expandedSessions -const isSessionParentExpanded = isSessionExpanded -const setSessionParentExpanded = setSessionExpanded -const toggleSessionParentExpanded = toggleSessionExpanded -const ensureSessionParentExpanded = ensureSessionExpanded - export { sessions, setSessions, @@ -1239,6 +1196,9 @@ export { setMessagesLoaded, getSessionListError, setSessionListError, + advanceMessageLoadEpoch, + isCurrentMessageLoad, + invalidateSessionMessageLoad, setSessionMessagesLoadError, sessionInfoByInstance, setSessionInfoByInstance, @@ -1301,11 +1261,6 @@ export { getSessionInfo, isBlankSession, cleanupBlankSessions, - expandedSessionParents, - isSessionParentExpanded, - setSessionParentExpanded, - toggleSessionParentExpanded, - ensureSessionParentExpanded, SESSION_PAGE_SIZE, sessionPagination, sessionSearch, diff --git a/packages/ui/src/stores/sessions.ts b/packages/ui/src/stores/sessions.ts index 7955ea3c..6570e125 100644 --- a/packages/ui/src/stores/sessions.ts +++ b/packages/ui/src/stores/sessions.ts @@ -73,6 +73,7 @@ import { fetchAgents, fetchProviders, fetchSessions, + hydrateRestoredSessionChain, loadMoreSessions, searchSessions, forkSession, @@ -145,6 +146,7 @@ export { fetchAgents, fetchProviders, fetchSessions, + hydrateRestoredSessionChain, loadMoreSessions, searchSessions, forkSession, diff --git a/packages/ui/src/stores/sidecars.ts b/packages/ui/src/stores/sidecars.ts index 4a9ff80f..d52a8d5f 100644 --- a/packages/ui/src/stores/sidecars.ts +++ b/packages/ui/src/stores/sidecars.ts @@ -4,7 +4,7 @@ import { tGlobal } from "../lib/i18n" import { serverEvents } from "../lib/server-events" import { getLogger } from "../lib/logger" import type { SideCar } from "../../../server/src/api-types" -import { awaitRestoreStep, getAbortReason } from "./app-session-restore-timeout" +import { getAbortReason, runAbortable } from "./app-session-restore-timeout" const log = getLogger("api") @@ -116,9 +116,9 @@ async function openSidecarTab( options?: { activate?: boolean; propagateLoadErrors?: boolean; signal?: AbortSignal }, ) { if (options?.signal?.aborted) throw getAbortReason(options.signal) - await awaitRestoreStep( - ensureSidecarsLoaded({ propagateErrors: options?.propagateLoadErrors }), - options?.signal, + await runAbortable( + () => ensureSidecarsLoaded({ propagateErrors: options?.propagateLoadErrors }), + { signal: options?.signal }, ) const sidecar = sidecars().get(sidecarId) diff --git a/packages/ui/src/stores/workspace-list-reconciliation-fence.test.ts b/packages/ui/src/stores/workspace-list-reconciliation-fence.test.ts new file mode 100644 index 00000000..563e5465 --- /dev/null +++ b/packages/ui/src/stores/workspace-list-reconciliation-fence.test.ts @@ -0,0 +1,49 @@ +import assert from "node:assert/strict" +import { readFileSync } from "node:fs" +import { describe, it } from "node:test" +import { WorkspaceListReconciliationFence } from "./workspace-list-reconciliation-fence.ts" + +describe("workspace list reconciliation fence", () => { + it("is wired before restore gates and event early returns", () => { + const source = readFileSync(new URL("./instances.ts", import.meta.url), "utf8") + const refresh = source.slice(source.indexOf("async function refreshWorkspaceList"), source.indexOf("const initialWorkspaceLoad")) + assert.ok(refresh.indexOf("workspaceListReconciliationFence.allows(requestFence, workspace.id)") + < refresh.indexOf("restoreCreationCommitGates.deferRefreshWorkspace(workspace)")) + const events = source.slice(source.indexOf("function handleWorkspaceEvent"), source.indexOf("function handleWorkspaceLog")) + assert.ok(events.indexOf("workspaceListReconciliationFence.markMutation(workspaceId)") + < events.indexOf("restoreCreationCommitGates.deferWorkspace(event.workspace)")) + const create = source.slice(source.indexOf("async function createInstance"), source.indexOf("function normalizeInstanceFolderPath")) + assert.ok(create.indexOf("workspaceListReconciliationFence.markMutation(workspace.id)") + < create.indexOf("upsertWorkspace(committedWorkspace")) + const stop = source.slice(source.indexOf("function stopInstance"), source.indexOf("async function fetchLspStatus")) + assert.ok(stop.indexOf("workspaceListReconciliationFence.markMutation(id)") < stop.indexOf("removeInstance(id)")) + }) + + it("rejects stale list entries and absences after lifecycle mutations", () => { + const fence = new WorkspaceListReconciliationFence() + const request = fence.begin() + for (const id of ["created", "started", "errored", "stopped", "deleted"]) fence.markMutation(id) + for (const id of ["created", "started", "errored", "stopped", "deleted"]) { + assert.equal(fence.allows(request, id), false) + } + assert.equal(fence.allows(request, "unchanged"), true) + }) + + it("rejects an older response after a newer list request starts", () => { + const fence = new WorkspaceListReconciliationFence() + const older = fence.begin() + const newer = fence.begin() + assert.equal(fence.isCurrent(older), false) + assert.equal(fence.allows(older, "workspace"), false) + assert.equal(fence.allows(newer, "workspace"), true) + }) + + it("accepts state that changed before the request and resets after completion", () => { + const fence = new WorkspaceListReconciliationFence() + fence.markMutation("workspace") + const request = fence.begin() + assert.equal(fence.allows(request, "workspace"), true) + fence.complete(request) + assert.equal(fence.allows(fence.begin(), "workspace"), true) + }) +}) diff --git a/packages/ui/src/stores/workspace-list-reconciliation-fence.ts b/packages/ui/src/stores/workspace-list-reconciliation-fence.ts new file mode 100644 index 00000000..d0759e05 --- /dev/null +++ b/packages/ui/src/stores/workspace-list-reconciliation-fence.ts @@ -0,0 +1,36 @@ +export interface WorkspaceListRequestFence { + requestRevision: number + mutationRevision: number +} + +export class WorkspaceListReconciliationFence { + private requestRevision = 0 + private mutationRevision = 0 + private readonly workspaceRevisions = new Map() + + begin(): WorkspaceListRequestFence { + return { + requestRevision: ++this.requestRevision, + mutationRevision: this.mutationRevision, + } + } + + markMutation(workspaceId: string): void { + this.workspaceRevisions.set(workspaceId, ++this.mutationRevision) + } + + isCurrent(request: WorkspaceListRequestFence): boolean { + return request.requestRevision === this.requestRevision + } + + allows(request: WorkspaceListRequestFence, workspaceId: string): boolean { + return this.isCurrent(request) + && (this.workspaceRevisions.get(workspaceId) ?? 0) <= request.mutationRevision + } + + complete(request: WorkspaceListRequestFence): void { + if (!this.isCurrent(request)) return + this.workspaceRevisions.clear() + this.requestRevision += 1 + } +}